diff --git a/jetbrains b/jetbrains index 7238b18..b78ec06 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 7238b18cd6ed47b59aea04fa9257e254c7a7efed +Subproject commit b78ec068e2340a9e9031cbb2500d4e566c02b4f1 diff --git a/media/annotationPanel/app.js b/media/annotationPanel/app.js index 63e456b..52ee399 100644 --- a/media/annotationPanel/app.js +++ b/media/annotationPanel/app.js @@ -344,7 +344,7 @@ if (problem.code === 'segment') return t('boxPathBadSegment', { segment: problem.bad || '' }); if (problem.code === 'duplicate') return t('boxPathExists'); if (problem.code === 'rule') return t('boxPathRuleMissing'); - return t('generateBoxFailed'); + return t('positionPathFailed'); } function syncAnnotationList() { @@ -546,8 +546,9 @@ document.getElementById('templateModeBtn').classList.toggle('active', annotationMode === 'template'); document.getElementById('rectModeBtn').classList.toggle('active', annotationMode === 'rect'); document.getElementById('pointModeBtn').classList.toggle('active', annotationMode === 'point'); - document.getElementById('generateBoxBtn').style.display = annotationMode === 'template' ? '' : 'none'; - document.getElementById('drawBtn').textContent = pointMode ? 'Point (' + keybindings.drawBbox.toUpperCase() + ')' : t('drawBbox'); + document.getElementById('drawBtn').textContent = pointMode + ? t('pointTool', { key: keybindings.drawBbox.toUpperCase() }) + : t('drawBbox'); document.getElementById('bboxWRow').style.display = pointMode ? 'none' : ''; document.getElementById('bboxHRow').style.display = pointMode ? 'none' : ''; updateUndoRedoButtons(); @@ -562,7 +563,10 @@ const wInput = document.getElementById('bboxW'); const hInput = document.getElementById('bboxH'); const ok = document.getElementById('bboxOk'); - document.getElementById('bboxTitle').textContent = category ? (pointMode ? 'Edit point' : t('editBboxTitle')) : (pointMode ? 'New point' : t('newBboxTitle')); + document.getElementById('bboxTitle').textContent = category + ? (pointMode ? t('editPointTitle') : t('editBboxTitle')) + : (pointMode ? t('newPointTitle') : t('newBboxTitle')); + document.getElementById('bboxCatLabel').textContent = isPositionMode() ? t('generatePath') + ':' : t('categoryLabel'); catInput.value = category; catInput.placeholder = isPositionMode() ? t('generatePathPlaceholder') : t('categoryLabel'); xInput.value = x; @@ -779,10 +783,10 @@ function pasteClipboardText(text) { if (!img || toolMode !== 'none' || modeLoading) return; const payload = parseClipboardPayload(text); - if (!payload) { pasteStatus('Clipboard is not a valid normalized coordinate tuple.'); return; } + if (!payload) { pasteStatus(t('clipboardInvalid')); return; } const zeroSize = payload.box.w === 0 && payload.box.h === 0; if (zeroSize && annotationMode !== 'point') { - pasteStatus('Zero-size coordinates can only be pasted in Point mode.'); + pasteStatus(t('clipboardPointOnly')); return; } let box = payload.box; @@ -1012,30 +1016,6 @@ offsetX = px - ix * scale; offsetY = py - iy * scale; recalcOffset(); e.preventDefault(); paint(); }, { passive:false }); - function openGenerateBox() { - if (annotationMode !== 'template') return; - const choices = document.getElementById('generateChoices'); choices.replaceChildren(); - const selected = new Set(selectedList()); - annotations.forEach((ann, index) => { - const label = document.createElement('label'); label.className = 'annotation-row'; - const input = document.createElement('input'); input.type = 'checkbox'; input.checked = selected.size === 0 || selected.has(index); input.dataset.index = String(index); - const text = document.createElement('span'); text.textContent = ann.category; label.append(input, text); choices.append(label); - }); - const seedAnn = annotations[selectedIdx] || annotations[0]; - const seed = seedAnn?.category || 'region'; - document.getElementById('generatePath').value = 'screen.' + String(seed).replace(/[^A-Za-z0-9_]/g, '_'); - document.getElementById('generateError').textContent = ''; - document.getElementById('generateModal').classList.add('visible'); - } - - function refreshGeneratePathState() { - const input = document.getElementById('generatePath'); - const problem = pathProblem(input.value) || pathOccupied(input.value, null); - document.getElementById('generateError').textContent = problem ? pathMessage(problem) : ''; - document.getElementById('generateOk').disabled = !!problem; - return problem; - } - function navigate(delta) { if (!imageData) return; const target = imageData.currentIndex + delta; @@ -1049,10 +1029,6 @@ document.addEventListener('keydown', (e) => { if (document.getElementById('bboxModal').classList.contains('visible')) return; - if (document.getElementById('generateModal').classList.contains('visible')) { - if (e.key === 'Enter') { e.preventDefault(); document.getElementById('generateOk').click(); } - return; - } if (matchKeybinding(e, keybindings.modeTemplate)) { e.preventDefault(); requestAnnotationMode('template'); return; } if (matchKeybinding(e, keybindings.modeRect)) { e.preventDefault(); requestAnnotationMode('rect'); return; } if (matchKeybinding(e, keybindings.modePoint)) { e.preventDefault(); requestAnnotationMode('point'); return; } @@ -1107,31 +1083,26 @@ annotations.forEach(a => { if (!categories.has(a.category)) hidden.add(a.category); }); listSignature=''; paint(); }; - document.getElementById('generateBoxBtn').onclick = openGenerateBox; - document.getElementById('generateCancel').onclick = () => document.getElementById('generateModal').classList.remove('visible'); - document.getElementById('generatePath').oninput = refreshGeneratePathState; - document.getElementById('generateOk').onclick = () => { - if (refreshGeneratePathState()) return; - const chosen = []; - document.querySelectorAll('#generateChoices input').forEach(input => { if (input.checked) chosen.push(annotations[Number(input.dataset.index)]); }); - if (!chosen.length) { document.getElementById('generateError').textContent = t('generateNeedSelection'); return; } - vscode.postMessage({ type:'generateBox', path:document.getElementById('generatePath').value.trim(), boxes:chosen.map(a=>({x:a.x,y:a.y,w:a.w,h:a.h})) }); - }; function updateStaticText() { - document.getElementById('bboxCatLabel').textContent = t('categoryLabel'); + document.getElementById('annotationModeGroup').setAttribute('aria-label', t('annotationModeLabel')); + document.getElementById('templateModeBtn').textContent = t('modeTemplate'); + document.getElementById('rectModeBtn').textContent = t('modeRect'); + document.getElementById('pointModeBtn').textContent = t('modePoint'); + document.getElementById('sharedHistoryLabel').textContent = t('sharedHistory'); + document.getElementById('sharedHistoryWrap').title = t('sharedHistoryTooltip'); + document.getElementById('coordPreferenceWrap').title = t('coordPreferenceTooltip'); + document.getElementById('bboxCatLabel').textContent = isPositionMode() ? t('generatePath') + ':' : t('categoryLabel'); + document.getElementById('bboxWLabel').textContent = t('widthLabel'); + document.getElementById('bboxHLabel').textContent = t('heightLabel'); document.getElementById('bboxCancel').textContent = t('cancel'); + document.getElementById('bboxOk').textContent = t('confirm'); document.getElementById('coordBtn').textContent = t('copyCoords'); document.getElementById('deleteBtn').textContent = t('deleteMode'); document.getElementById('annotationListTitle').textContent = t('annotationListTitle'); document.getElementById('showAllBtn').textContent = t('showAllAnnotations'); document.getElementById('hideAllBtn').textContent = t('hideAllAnnotations'); document.getElementById('onlyCurrentBtn').textContent = t('showOnlyCurrent'); - document.getElementById('generateBoxBtn').textContent = t('generateBox'); - document.getElementById('generateTitle').textContent = t('generateBoxTitle'); - document.getElementById('generatePathLabel').textContent = t('generatePath'); - document.getElementById('generateCancel').textContent = t('cancel'); - document.getElementById('generateOk').textContent = t('ok'); document.getElementById('prevBtn').title = t('prevImage'); document.getElementById('nextBtn').title = t('nextImage'); document.getElementById('emptyMsg').textContent = t('noImageLoaded'); @@ -1158,11 +1129,6 @@ pasteClipboardText(msg.text || ''); return; } - if (msg.type === 'generateBoxResult') { - if (msg.ok) document.getElementById('generateModal').classList.remove('visible'); - else document.getElementById('generateError').textContent = pathMessage(msg.error === 'duplicate' ? {code:'duplicate'} : {code:msg.error || 'unknown'}); - return; - } if (msg.type !== 'load') return; const incomingMode = msg.annotationMode || annotationMode; @@ -1182,7 +1148,7 @@ clearSelection(); hoveredIdx = -1; listSignature = ''; - updateModeUi(); + updateStaticText(); const needImageLoad = !img || imageChanged; if (msg.imageBase64 && needImageLoad) { @@ -1207,4 +1173,4 @@ resize(); updateStaticText(); vscode.postMessage({ type:'ready' }); -})(); +})(); \ No newline at end of file diff --git a/media/annotationPanel/conflict.css b/media/annotationPanel/conflict.css new file mode 100644 index 0000000..490be09 --- /dev/null +++ b/media/annotationPanel/conflict.css @@ -0,0 +1,74 @@ +#conflictCanvas { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; +} + +.conflict-panel { + display: flex; + flex-direction: column; + gap: var(--space-sm); + max-height: 52%; + padding: var(--space-sm); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-container-raised); + overflow: hidden; +} + +.conflict-panel[hidden] { + display: none; +} + +.conflict-summary { + white-space: pre-line; + color: var(--text-muted); + font-size: var(--font-xs); +} + +.conflict-rows { + display: flex; + flex-direction: column; + gap: var(--space-sm); + overflow: auto; + min-height: 0; +} + +.conflict-row { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-xs); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-row); +} + +.conflict-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-sm); + font-weight: var(--weight-medium); +} + +.conflict-fields { + color: var(--text-muted); + font-size: var(--font-xs); +} + +.conflict-choice { + width: 100%; + height: auto; + min-height: var(--control-h-sm); + padding: var(--space-xs) var(--space-sm); + white-space: normal; + text-align: left; + line-height: 1.35; +} + +.conflict-apply { + width: 100%; + flex-shrink: 0; +} diff --git a/media/annotationPanel/conflict.js b/media/annotationPanel/conflict.js new file mode 100644 index 0000000..ae4d67c --- /dev/null +++ b/media/annotationPanel/conflict.js @@ -0,0 +1,183 @@ +(() => { + const vscode = acquireVsCodeApi(); + const I18N = JSON.parse(document.getElementById('annotationPanelI18n')?.textContent || '{}'); + const t = (key, args = {}) => (I18N[key] || key).replace(/\{(\w+)\}/g, (_, name) => String(args[name] ?? '{' + name + '}')); + const canvas = document.getElementById('canvas'); + const wrap = canvas?.parentElement; + const panel = document.getElementById('conflictPanel'); + const rows = document.getElementById('conflictRows'); + const apply = document.getElementById('conflictApply'); + const summary = document.getElementById('conflictSummary'); + if (!canvas || !wrap || !panel || !rows || !apply || !summary) return; + + const overlay = document.createElement('canvas'); + overlay.id = 'conflictCanvas'; + overlay.setAttribute('aria-hidden', 'true'); + wrap.appendChild(overlay); + + let session = null; + let transform = null; + let submitting = false; + const choices = new Map(); + + const originalDrawImage = CanvasRenderingContext2D.prototype.drawImage; + CanvasRenderingContext2D.prototype.drawImage = function(...args) { + const result = originalDrawImage.apply(this, args); + if (this.canvas === canvas && args.length >= 5 && args[0] instanceof HTMLImageElement) { + const image = args[0]; + const imageWidth = image.naturalWidth || image.width || 1; + const imageHeight = image.naturalHeight || image.height || 1; + transform = { + x: Number(args[1]) || 0, + y: Number(args[2]) || 0, + sx: (Number(args[3]) || imageWidth) / imageWidth, + sy: (Number(args[4]) || imageHeight) / imageHeight, + }; + resizeOverlay(); + paintOverlay(); + } + return result; + }; + + function resizeOverlay() { + if (overlay.width !== canvas.width) overlay.width = canvas.width; + if (overlay.height !== canvas.height) overlay.height = canvas.height; + } + + function candidateLabel(candidate) { + if (!candidate) return '∅'; + const dims = candidate.w === 0 && candidate.h === 0 + ? `(${candidate.x}, ${candidate.y})` + : `[${candidate.x}, ${candidate.y}, ${candidate.w}, ${candidate.h}]`; + return `${candidate.category || '?'} ${dims}`; + } + + function paintCandidate(ctx, candidate, kind) { + if (!candidate || !transform) return; + const style = getComputedStyle(document.body); + const currentColor = style.getPropertyValue('--accent').trim() || '#4daafc'; + const externalColor = style.getPropertyValue('--err').trim() || '#f14c4c'; + const color = kind === 'local' ? currentColor : externalColor; + const x = transform.x + candidate.x * transform.sx; + const y = transform.y + candidate.y * transform.sy; + const w = candidate.w * transform.sx; + const h = candidate.h * transform.sy; + ctx.save(); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 2; + ctx.setLineDash(kind === 'local' ? [] : [7, 4]); + if (candidate.w === 0 && candidate.h === 0) { + ctx.beginPath(); + ctx.arc(x, y, 7, 0, Math.PI * 2); + ctx.stroke(); + } else { + ctx.strokeRect(x, y, w, h); + } + ctx.setLineDash([]); + ctx.font = 'bold 11px sans-serif'; + ctx.fillText(kind === 'local' ? 'L' : 'E', x + 3, Math.max(11, y - 4)); + ctx.restore(); + } + + function paintOverlay() { + resizeOverlay(); + const ctx = overlay.getContext('2d'); + ctx.clearRect(0, 0, overlay.width, overlay.height); + if (!session) return; + for (const conflict of session.conflicts || []) { + paintCandidate(ctx, conflict.local, 'local'); + paintCandidate(ctx, conflict.external, 'external'); + } + } + + function renderConflictRow(conflict) { + const row = document.createElement('div'); + row.className = 'conflict-row'; + row.dataset.key = conflict.key; + + const name = document.createElement('div'); + name.className = 'conflict-name'; + name.textContent = conflict.key; + + const fields = document.createElement('div'); + fields.className = 'conflict-fields'; + fields.textContent = `${t('conflictFields')}: ${(conflict.fields || []).join(', ') || '—'}`; + + const current = document.createElement('button'); + current.type = 'button'; + current.className = 'mini-btn conflict-choice'; + current.dataset.choice = 'local'; + current.textContent = `${t('conflictCurrent')}: ${candidateLabel(conflict.local)}`; + + const external = document.createElement('button'); + external.type = 'button'; + external.className = 'mini-btn conflict-choice'; + external.dataset.choice = 'external'; + external.textContent = `${t('conflictExternal')}: ${candidateLabel(conflict.external)}`; + + const choose = choice => { + if (submitting) return; + choices.set(conflict.key, choice); + for (const button of row.querySelectorAll('.conflict-choice')) { + button.classList.toggle('active', button.dataset.choice === choice); + } + apply.disabled = choices.size !== (session?.conflicts?.length || 0); + }; + current.addEventListener('click', () => choose('local')); + external.addEventListener('click', () => choose('external')); + + row.append(name, fields, current, external); + return row; + } + + function showConflicts(message) { + session = message; + submitting = false; + choices.clear(); + rows.replaceChildren(); + for (const conflict of message.conflicts || []) rows.appendChild(renderConflictRow(conflict)); + summary.textContent = `${t('conflictTitle')} · ${(message.conflicts || []).length}\n${t('conflictHint')}`; + apply.textContent = t('conflictApply'); + apply.disabled = true; + panel.hidden = false; + paintOverlay(); + } + + function clearConflicts() { + session = null; + submitting = false; + choices.clear(); + rows.replaceChildren(); + panel.hidden = true; + paintOverlay(); + } + + apply.addEventListener('click', () => { + if (!session || submitting || choices.size !== (session.conflicts?.length || 0)) return; + submitting = true; + apply.disabled = true; + vscode.postMessage({ + type: 'resolveAnnotationConflicts', + conflictSessionId: session.conflictSessionId, + choices: [...choices].map(([key, choice]) => ({ key, choice })), + }); + }); + + window.addEventListener('message', event => { + const message = event.data || {}; + if (message.type === 'annotationConflicts') showConflicts(message); + else if (message.type === 'clearAnnotationConflicts' + && (!message.conflictSessionId || message.conflictSessionId === session?.conflictSessionId)) clearConflicts(); + else if (message.type === 'conflictResolutionFailed' + && message.conflictSessionId === session?.conflictSessionId) { + submitting = false; + apply.disabled = choices.size !== (session?.conflicts?.length || 0); + } else if (message.type === 'load' && session) clearConflicts(); + }); + + window.addEventListener('resize', () => { + resizeOverlay(); + paintOverlay(); + }); +})(); \ No newline at end of file diff --git a/media/annotationPanel/externalSync.js b/media/annotationPanel/externalSync.js new file mode 100644 index 0000000..67ddf95 --- /dev/null +++ b/media/annotationPanel/externalSync.js @@ -0,0 +1,138 @@ +(() => { + const vscode = acquireVsCodeApi(); + const nativePostMessage = vscode.postMessage.bind(vscode); + const canvas = document.getElementById('canvas'); + const bboxModal = document.getElementById('bboxModal'); + const pendingModes = new Set(); + const annotationSnapshots = new Map(); + const editorVersions = new Map(); + const pendingSaveCounts = new Map(); + const rejectedLoads = new Map(); + let currentImagePath = ''; + let pointerActive = false; + let reportScheduled = false; + + const cloneAnnotations = values => Array.isArray(values) ? values.map(value => ({ ...value })) : []; + const stateKey = (imagePath, mode) => `${imagePath || ''}\n${mode || ''}`; + const currentKey = mode => stateKey(currentImagePath, mode); + + // app.js and this helper intentionally share the same VS Code API object. A + // save increments the editor version before the message leaves the Webview, + // closing the small window where an older host load could otherwise arrive + // before the extension has started processing that save. + vscode.postMessage = message => { + if ((message?.type === 'save' || message?.type === 'saveMode') && message.mode && Array.isArray(message.annotations)) { + const key = currentKey(message.mode); + const editorVersion = (editorVersions.get(key) || 0) + 1; + editorVersions.set(key, editorVersion); + pendingSaveCounts.set(key, (pendingSaveCounts.get(key) || 0) + 1); + annotationSnapshots.set(key, cloneAnnotations(message.annotations)); + return nativePostMessage({ ...message, imagePath: currentImagePath, editorVersion }); + } + return nativePostMessage(message); + }; + + function hasTransientEdit() { + return pointerActive || !!bboxModal?.classList.contains('visible'); + } + + function reportPending() { + reportScheduled = false; + if (!pendingModes.size) return; + const transient = hasTransientEdit(); + for (const mode of pendingModes) { + nativePostMessage({ + type: 'externalEditorState', + imagePath: currentImagePath, + mode, + transient, + annotations: transient ? undefined : cloneAnnotations(annotationSnapshots.get(currentKey(mode))), + }); + } + if (!transient) pendingModes.clear(); + } + + function scheduleReport() { + if (reportScheduled || !pendingModes.size) return; + reportScheduled = true; + queueMicrotask(reportPending); + } + + canvas?.addEventListener('mousedown', event => { + if (event.button === 0) pointerActive = true; + }, true); + window.addEventListener('mouseup', () => { + pointerActive = false; + scheduleReport(); + }, true); + window.addEventListener('blur', () => { + pointerActive = false; + scheduleReport(); + }); + document.addEventListener('click', scheduleReport, true); + document.addEventListener('keyup', scheduleReport, true); + + if (bboxModal) { + new MutationObserver(scheduleReport).observe(bboxModal, { attributes: true, attributeFilter: ['class'] }); + } + + window.addEventListener('message', event => { + const message = event.data || {}; + if (message.type === 'load' && message.annotationMode) { + const imagePath = message.imagePath || ''; + const mode = message.annotationMode; + const key = stateKey(imagePath, mode); + const editorVersion = editorVersions.get(key) || 0; + const expectedEditorVersion = Number.isInteger(message.expectedEditorVersion) + ? message.expectedEditorVersion + : editorVersion; + const pendingSaves = pendingSaveCounts.get(key) || 0; + + if (message.loadRequestId && (pendingSaves > 0 || editorVersion !== expectedEditorVersion)) { + rejectedLoads.set(key, { imagePath, mode }); + event.stopImmediatePropagation(); + nativePostMessage({ + type: 'loadRejected', + loadRequestId: message.loadRequestId, + imagePath, + mode, + editorVersion, + pendingSaves, + annotations: cloneAnnotations(annotationSnapshots.get(key)), + }); + return; + } + + currentImagePath = imagePath; + editorVersions.set(key, expectedEditorVersion); + annotationSnapshots.set(key, cloneAnnotations(message.annotations)); + rejectedLoads.delete(key); + if (message.loadRequestId) { + queueMicrotask(() => nativePostMessage({ + type: 'loadAccepted', + loadRequestId: message.loadRequestId, + imagePath, + mode, + editorVersion: expectedEditorVersion, + })); + } + return; + } + + if (message.type === 'annotationSaveProcessed' && message.mode) { + const key = stateKey(message.imagePath || '', message.mode); + const remaining = Math.max(0, (pendingSaveCounts.get(key) || 0) - 1); + pendingSaveCounts.set(key, remaining); + if (remaining === 0 && rejectedLoads.has(key) && message.saved !== false) { + const rejected = rejectedLoads.get(key); + rejectedLoads.delete(key); + nativePostMessage({ type: 'retryLoad', imagePath: rejected.imagePath, mode: rejected.mode }); + } + return; + } + + if (message.type !== 'externalSourceChanged' || !message.mode) return; + pendingModes.add(message.mode); + reportPending(); + }); +})(); diff --git a/media/annotationPanel/index.html b/media/annotationPanel/index.html index 01be16d..691ea8c 100644 --- a/media/annotationPanel/index.html +++ b/media/annotationPanel/index.html @@ -6,27 +6,27 @@ +
-
+
-
- + + + diff --git a/media/annotationPanel/uiLocalization.js b/media/annotationPanel/uiLocalization.js new file mode 100644 index 0000000..ae95dc2 --- /dev/null +++ b/media/annotationPanel/uiLocalization.js @@ -0,0 +1,61 @@ +(() => { + const I18N = JSON.parse(document.getElementById('annotationPanelI18n')?.textContent || '{}'); + const t = (key, args = {}) => (I18N[key] || key).replace(/\{(\w+)\}/g, (_, name) => String(args[name] ?? '{' + name + '}')); + + const modeSwitch = document.querySelector('.annotation-mode-switch'); + const templateModeBtn = document.getElementById('templateModeBtn'); + const rectModeBtn = document.getElementById('rectModeBtn'); + const pointModeBtn = document.getElementById('pointModeBtn'); + const sharedHistory = document.getElementById('sharedHistoryChk'); + const sharedHistoryLabel = document.getElementById('sharedHistoryLabel'); + const coordPreference = document.getElementById('coordPreferXywhChk'); + const drawBtn = document.getElementById('drawBtn'); + const bboxTitle = document.getElementById('bboxTitle'); + const colorInfo = document.getElementById('colorInfo'); + + function localizeStatic() { + if (modeSwitch) modeSwitch.setAttribute('aria-label', t('annotationModeLabel')); + if (templateModeBtn) templateModeBtn.textContent = t('modeTemplate'); + if (rectModeBtn) rectModeBtn.textContent = t('modeRect'); + if (pointModeBtn) pointModeBtn.textContent = t('modePoint'); + if (sharedHistoryLabel) sharedHistoryLabel.textContent = t('sharedHistory'); + if (sharedHistory?.parentElement) sharedHistory.parentElement.title = t('sharedHistoryTooltip'); + if (coordPreference?.parentElement) coordPreference.parentElement.title = t('coordPreferenceTooltip'); + } + + function localizeDrawTool() { + if (!drawBtn) return; + const expected = pointModeBtn?.classList.contains('active') ? t('pointTool') : t('drawBbox'); + if (drawBtn.textContent !== expected) drawBtn.textContent = expected; + } + + function localizeTitle() { + if (!bboxTitle) return; + if (bboxTitle.textContent === 'Edit point') bboxTitle.textContent = t('editPointTitle'); + else if (bboxTitle.textContent === 'New point') bboxTitle.textContent = t('newPointTitle'); + } + + function localizeStatus() { + if (!colorInfo) return; + if (colorInfo.textContent === 'Clipboard is not a valid normalized coordinate tuple.') { + colorInfo.textContent = t('clipboardInvalid'); + } else if (colorInfo.textContent === 'Zero-size coordinates can only be pasted in Point mode.') { + colorInfo.textContent = t('clipboardPointOnly'); + } + } + + localizeStatic(); + localizeDrawTool(); + localizeTitle(); + localizeStatus(); + + const observer = new MutationObserver(() => { + localizeStatic(); + localizeDrawTool(); + localizeTitle(); + localizeStatus(); + }); + for (const target of [templateModeBtn, rectModeBtn, pointModeBtn, drawBtn, bboxTitle, colorInfo]) { + if (target) observer.observe(target, { attributes: true, childList: true, characterData: true, subtree: true }); + } +})(); diff --git a/media/templateAssetPanel/app.js b/media/templateAssetPanel/app.js index 74db08e..e6b1ecb 100644 --- a/media/templateAssetPanel/app.js +++ b/media/templateAssetPanel/app.js @@ -34,12 +34,21 @@ return n; } + function updateCardMeta(card, meta) { + card.title = meta.name + '\n' + meta.width + 'x' + meta.height + + (meta.categories.length ? '\n' + meta.categories.join(', ') : ''); + const name = card.querySelector('.name'); + const cats = card.querySelector('.cats'); + const size = card.querySelector('.size'); + if (name) name.textContent = meta.name; + if (cats) cats.textContent = meta.categories.length ? meta.categories.join(', ') : ''; + if (size) size.textContent = meta.width + 'x' + meta.height; + } + function makeCard(meta) { const card = document.createElement('div'); card.className = 'card'; card.dataset.name = meta.name; - card.title = meta.name + '\n' + meta.width + 'x' + meta.height + - (meta.categories.length ? '\n' + meta.categories.join(', ') : ''); const box = document.createElement('div'); box.className = 'thumb-box'; @@ -51,31 +60,28 @@ const actDiv = document.createElement('div'); actDiv.className = 'actions thumbnail-actions'; actDiv.append( - ThumbnailActions.button('✎', t('editAnnotations'), () => vscode.postMessage({ type: 'openAnnotation', imagePath: meta.imagePath })), ThumbnailActions.button('👁', t('openSourceImage'), () => vscode.postMessage({ type: 'openSource', imagePath: meta.imagePath })), ThumbnailActions.button('⇄', t('assetSwapTooltip'), () => openSwapPicker(meta)), ThumbnailActions.button('×', t('assetDeleteTooltip'), () => vscode.postMessage({ type: 'deleteImage', imagePath: meta.imagePath })), ); - ['edit', 'open', 'swap', 'delete'].forEach((action, index) => { actDiv.children[index].dataset.action = action; }); + ['open', 'swap', 'delete'].forEach((action, index) => { actDiv.children[index].dataset.action = action; }); box.appendChild(actDiv); const m = document.createElement('div'); m.className = 'meta'; const nm = document.createElement('div'); nm.className = 'name'; - nm.textContent = meta.name; const cats = document.createElement('div'); cats.className = 'cats'; - cats.textContent = meta.categories.length ? meta.categories.join(', ') : ''; const sz = document.createElement('div'); sz.className = 'size'; - sz.textContent = meta.width + 'x' + meta.height; m.appendChild(nm); m.appendChild(cats); m.appendChild(sz); card.appendChild(box); card.appendChild(m); + updateCardMeta(card, meta); card.addEventListener('click', () => { vscode.postMessage({ type: 'openAnnotation', imagePath: meta.imagePath }); }); @@ -115,12 +121,25 @@ function applyThumb(name, url) { thumbUrls.set(name, url); const card = cards.get(name); - if (card && card.dataset.thumbDone !== '1') { + if (card) { + const box = card.querySelector('.thumb-box'); + const current = box && box.querySelector('img'); + if (current) { + if (current.src !== url) current.src = url; + } else { + fillThumbBox(box, url, name, t('loadFailed')); + } card.dataset.thumbDone = '1'; - fillThumbBox(card.querySelector('.thumb-box'), url, name, t('loadFailed')); } for (const thumb of swapList.querySelectorAll('.swap-thumb')) { - if (thumb.dataset.name === name) fillThumbBox(thumb, url, name, ''); + if (thumb.dataset.name === name) { + const current = thumb.querySelector('img'); + if (current) { + if (current.src !== url) current.src = url; + } else { + fillThumbBox(thumb, url, name, ''); + } + } } } @@ -244,14 +263,33 @@ const msg = e.data; switch (msg.type) { case 'templates': { - grid.innerHTML = ''; cards.clear(); - metas = msg.templates || []; - thumbUrls.clear(); - for (const meta of metas) { - const card = makeCard(meta); - cards.set(meta.name, card); + const incoming = msg.templates || []; + const nextNames = new Set(incoming.map(meta => meta.name)); + for (const [name, card] of [...cards]) { + if (nextNames.has(name)) continue; + card.remove(); + cards.delete(name); + thumbUrls.delete(name); + } + + const nextMetas = []; + for (const raw of incoming) { + const existingMeta = metas.find(meta => meta.name === raw.name); + const meta = existingMeta || raw; + if (existingMeta) Object.assign(existingMeta, raw); + let card = cards.get(meta.name); + if (!card) { + card = makeCard(meta); + cards.set(meta.name, card); + } else { + updateCardMeta(card, meta); + } + // appendChild moves an existing node without recreating it. This keeps the + // decoded thumbnail and hover state alive across annotation-only refreshes. grid.appendChild(card); + nextMetas.push(meta); } + metas = nextMetas; applyFilter(); break; } diff --git a/package.json b/package.json index 209a168..30aa10e 100644 --- a/package.json +++ b/package.json @@ -350,7 +350,7 @@ "verify:version": "node scripts/release/verify-version.js", "verify:l10n": "node scripts/release/verify-l10n.js", "release": "sh scripts/release.sh", - "test": "npm run verify:version && npm run verify:l10n && npm run compile && npm run test:webview-html && npm run test:design-system && npm run test:thumbnail-actions && npm run test:task-launcher && npm run test:console-accountseg && npm run test:task-launcher-executor && npm run test:task-reconcile && npm run test:temp-screenshots && npm run test:annotation-coords && npm run test:annotation-clipboard && npm run test:annotation-swap && npm run test:annotation-history && npm run test:position-resource && npm run test:point-resource && npm run test:asset-swap-picker && npm run test:thumb-content-hash && npm run test:list-editor && npm run test:project-config && npm run test:project-config-schema && npm run test:convention-sources && npm run test:label-enum-guard && npm run test:save-to-assets-flow && npm run test:save-to-assets && npm run test:asset-pack-worker && npm run test:coco-feature-path && npm run test:box-resource && npm run test:probe-schemas && npm run test:probe-gui-group-names && npm run test:probe-qt-layer && npm run test:probe-multi-account-path && npm run test:probe-window-config && npm run test:run-sandbox && npm run test:startup-patches && npm run test:executor-runtime && npm run test:executor-project-config && npm run test:executor-gconfig && npm run test:project-store && npm run test:project-runtime", + "test": "npm run verify:version && npm run verify:l10n && npm run compile && npm run test:webview-html && npm run test:design-system && npm run test:thumbnail-actions && npm run test:task-launcher && npm run test:console-accountseg && npm run test:task-launcher-executor && npm run test:task-reconcile && npm run test:temp-screenshots && npm run test:annotation-coords && npm run test:annotation-clipboard && npm run test:annotation-swap && npm run test:annotation-merge && npm run test:annotation-history && npm run test:position-resource && npm run test:point-resource && npm run test:asset-swap-picker && npm run test:thumb-content-hash && npm run test:list-editor && npm run test:project-config && npm run test:project-config-schema && npm run test:convention-sources && npm run test:label-enum-guard && npm run test:save-to-assets-flow && npm run test:save-to-assets && npm run test:asset-pack-worker && npm run test:coco-feature-path && npm run test:box-resource && npm run test:probe-schemas && npm run test:probe-gui-group-names && npm run test:probe-qt-layer && npm run test:probe-multi-account-path && npm run test:probe-window-config && npm run test:run-sandbox && npm run test:startup-patches && npm run test:executor-runtime && npm run test:executor-project-config && npm run test:executor-gconfig && npm run test:project-store && npm run test:project-runtime", "test:webview-html": "node scripts/test_webview_html.js", "test:design-system": "node scripts/test_design_system.js", "test:thumbnail-actions": "node scripts/test_thumbnail_actions.js", @@ -362,6 +362,7 @@ "test:annotation-coords": "node scripts/test_annotation_coords.js", "test:annotation-clipboard": "node scripts/test_annotation_clipboard.js", "test:annotation-swap": "node scripts/test_annotation_swap.js", + "test:annotation-merge": "node scripts/test_annotation_merge.js", "test:annotation-history": "node scripts/test_annotation_history.js", "test:position-resource": "node scripts/test_position_resource.js", "test:point-resource": "node scripts/test_point_resource.js", diff --git a/scripts/test_annotation_clipboard.js b/scripts/test_annotation_clipboard.js index e7d3970..4fb92d2 100644 --- a/scripts/test_annotation_clipboard.js +++ b/scripts/test_annotation_clipboard.js @@ -12,7 +12,7 @@ const { JSDOM, VirtualConsole } = jsdom; const root = path.resolve(__dirname, '..'); const componentRoot = path.join(root, 'media', 'annotationPanel'); const dictionary = { - categoryLabel: 'Category:', widthLabel: 'Width:', heightLabel: 'Height:', cancel: 'Cancel', + categoryLabel: 'Category:', widthLabel: 'Width:', heightLabel: 'Height:', cancel: 'Cancel', confirm: 'Confirm', newBboxTitle: 'New Box', drawBbox: 'Draw (R)', drawBboxTooltip: 'Draw tip', copyCoords: 'Coords (C)', copyCoordsTooltip: 'Coords tip', coordLabel: 'Coords:', deleteMode: 'Delete (D)', deleteBboxTooltip: 'Delete tip', prevImage: 'Prev', nextImage: 'Next', @@ -20,6 +20,13 @@ const dictionary = { categoryExists: 'Exists in {file}', undo: 'Undo', redo: 'Redo', boxPathRequired: 'Path required', boxPathTwoSegments: 'Two segments: {path}', boxPathBadSegment: 'Bad segment: {segment}', boxPathExists: 'Path exists', boxPathRuleMissing: 'Rule missing', + annotationModeLabel: 'Annotation mode', modeTemplate: 'Template', modeRect: 'Box', modePoint: 'Point', + sharedHistory: 'Shared undo history', sharedHistoryTooltip: 'Share undo history', + coordPreferenceTooltip: 'Coordinate preference', pointTool: 'Point ({key})', + newPointTitle: 'New Point', editPointTitle: 'Edit Point', generatePath: 'Path', generatePathPlaceholder: 'screen.name', + clipboardInvalid: 'Clipboard is not a valid normalized coordinate tuple.', + clipboardPointOnly: 'Zero-size coordinates can only be pasted in Point mode.', + positionPathFailed: 'Invalid position path', }; let html = fs.readFileSync(path.join(componentRoot, 'index.html'), 'utf8'); @@ -197,7 +204,8 @@ function nameOpenPaste(name) { message({ type: 'clipboardText', text: '0.3, 0.3, 0, 0' }); await flush(); assert(posts('save').length === beforeZeroRect, 'zero-size XYWH point is rejected in rect mode'); - assert(/Point mode/i.test(document.getElementById('colorInfo').textContent), 'zero-size rejection explains point-only rule'); + assert(document.getElementById('colorInfo').textContent === dictionary.clipboardPointOnly, + 'zero-size rejection uses the localized point-only explanation'); message({ type: 'load', annotationMode: 'point', imagePath: 'x/a.png', imageBase64: 'data:image/png;base64,FAKE-1000x1000', diff --git a/scripts/test_annotation_history.js b/scripts/test_annotation_history.js index 79d0779..f84834a 100644 --- a/scripts/test_annotation_history.js +++ b/scripts/test_annotation_history.js @@ -21,7 +21,8 @@ html = html .replace('', '') .replace('', '') .replace('', '') - .replace('', ``); + .replace('', ``) + .replace('', ``); const sent = []; let webviewState = {}; @@ -88,10 +89,13 @@ function message(data) { window.dispatchEvent(new window.MessageEvent('message', function key(keyValue, init = {}) { document.dispatchEvent(new window.KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: keyValue, ...init })); } -function load(mode, annotations) { +function mouse(type, x, y, init = {}) { + canvas.dispatchEvent(new window.MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0, ...init })); +} +function load(mode, annotations, imagePath = 'x/a.png', extra = {}) { message({ - type: 'load', annotationMode: mode, imagePath: 'x/a.png', imageBase64: 'data:image/png;base64,FAKE-1000x1000', - filename: 'a.png', currentIndex: 0, totalImages: 1, allCategories: {}, annotations, + type: 'load', annotationMode: mode, imagePath, imageBase64: 'data:image/png;base64,FAKE-1000x1000', + filename: path.basename(imagePath), currentIndex: 0, totalImages: 1, allCategories: {}, annotations, ...extra, }); } @@ -110,8 +114,11 @@ function load(mode, annotations) { await flush(); await flush(); message({ type: 'clipboardText', text: '{"name":"screen.rect_one","bbox":[0.1,0.1,0.2,0.2]}' }); await flush(); - const rectOne = last('save').annotations; + const rectSave = last('save'); + const rectOne = rectSave.annotations; assert(rectOne.length === 1 && rectOne[0].category === 'screen.rect_one', 'rect paste creates a real editor transaction'); + assert(rectSave.imagePath === 'x/a.png' && rectSave.editorVersion === 1, + 'save messages bind the edit to its image and editor version before leaving the webview'); load('point', []); await flush(); @@ -187,6 +194,94 @@ function load(mode, annotations) { assert(writes().length === beforeSwitchWrites + 1, 'matching mode load unlocks editing exactly once'); assert(last('save').annotations.some(item => item.category === 'screen.after_load'), 'post-load point paste is saved in Point mode'); + // External changes can live-reload only when no transient editor interaction is active. + load('rect', rectOne); + await flush(); await flush(); + const cleanCount = posts('externalEditorState').length; + message({ type: 'externalSourceChanged', mode: 'rect' }); + await flush(); + assert(posts('externalEditorState').length === cleanCount + 1, 'clean external change reports editor state immediately'); + assert(last('externalEditorState').transient === false, 'clean editor allows immediate external reload'); + assert(last('externalEditorState').imagePath === 'x/a.png', 'external sync state is bound to the image currently owned by the editor'); + assert(last('externalEditorState').annotations?.[0]?.category === 'screen.rect_one', + 'idle external sync reports the exact editor-owned annotation snapshot'); + + document.getElementById('drawBtn').click(); + message({ type: 'externalSourceChanged', mode: 'rect' }); + await flush(); + assert(last('externalEditorState').transient === false, + 'selecting the draw tool alone does not indefinitely defer an external sync'); + document.getElementById('drawBtn').click(); + + // The first rect starts at widget x=160,y=60 for a 1000x1000 image fitted into 800x600. + mouse('mousedown', 170, 70); + message({ type: 'externalSourceChanged', mode: 'rect' }); + await flush(); + assert(last('externalEditorState').transient === true, 'external reload is blocked during a drag'); + mouse('mousemove', 190, 90); + const beforeReleaseMessages = sent.length; + mouse('mouseup', 190, 90); + await flush(); await flush(); + assert(last('externalEditorState').transient === false, 'drag completion re-opens the safe reload point'); + assert(last('externalEditorState').annotations?.[0]?.x !== rectOne[0].x, + 'safe reload report carries the drag result instead of the stale loaded snapshot'); + const releaseMessages = sent.slice(beforeReleaseMessages); + const saveIndex = releaseMessages.findIndex(item => item?.type === 'save'); + const readyIndex = releaseMessages.findIndex(item => item?.type === 'externalEditorState' && item.transient === false); + assert(saveIndex >= 0 && readyIndex > saveIndex, 'current drag is saved before host is told it may reload external data'); + + // A load sampled before an outstanding save must be rejected until that exact image/version is processed. + load('rect', [], 'x/guard.png'); + await flush(); await flush(); + message({ type: 'clipboardText', text: '{"name":"screen.guard_one","bbox":[0.1,0.1,0.2,0.2]}' }); + await flush(); + const guardSave = last('save'); + assert(guardSave.imagePath === 'x/guard.png' && guardSave.editorVersion === 1, + 'guarded save starts versioning independently for the new image'); + load('rect', [], 'x/guard.png', { loadRequestId: 9001, expectedEditorVersion: 0 }); + await flush(); + assert(last('loadRejected')?.loadRequestId === 9001 && last('loadRejected')?.pendingSaves === 1, + 'stale host load is rejected while the image has a pending save'); + + const retryBeforeFailedAck = posts('retryLoad').length; + message({ type: 'annotationSaveProcessed', imagePath: 'x/guard.png', mode: 'rect', editorVersion: 1, saved: false }); + await flush(); + assert(posts('retryLoad').length === retryBeforeFailedAck, + 'failed save acknowledgement preserves the editor state instead of retrying a destructive load'); + + message({ type: 'clipboardText', text: '{"name":"screen.guard_two","bbox":[0.4,0.4,0.1,0.1]}' }); + await flush(); + const guardSaveTwo = last('save'); + assert(guardSaveTwo.imagePath === 'x/guard.png' && guardSaveTwo.editorVersion === 2, + 'later save advances the same image version'); + message({ type: 'annotationSaveProcessed', imagePath: 'x/guard.png', mode: 'rect', editorVersion: 2, saved: true }); + await flush(); + assert(posts('retryLoad').length === retryBeforeFailedAck + 1, + 'successful save acknowledgement retries the previously rejected load'); + assert(last('retryLoad').imagePath === 'x/guard.png' && last('retryLoad').mode === 'rect', + 'retry remains bound to the image and mode that rejected the load'); + + const controllerSource = fs.readFileSync(path.join(root, 'src', 'annotationPanel.ts'), 'utf8'); + const reloadStart = controllerSource.indexOf(' reloadIfShowing(imagePaths: readonly string[]): void {'); + const attachStart = controllerSource.indexOf('\n attachHtml(): void {', reloadStart); + assert(reloadStart >= 0 && attachStart > reloadStart, 'reloadIfShowing remains covered by the sync regression test'); + const reloadBlock = controllerSource.slice(reloadStart, attachStart); + assert(reloadBlock.includes('this.pendingExternal.add(this.mode);'), + 'cross-panel reload marks the active mode as externally changed'); + assert(reloadBlock.includes("this.webview.postMessage({ type: 'externalSourceChanged', mode: this.mode });"), + 'cross-panel reload enters the editor-state coordination flow'); + assert(!reloadBlock.includes('this.loadImage('), + 'cross-panel reload must not bypass pending-save coordination with a direct load'); + assert(controllerSource.includes('msg.imagePath === this._currentImage'), + 'host accepts external editor state only for the image it currently owns'); + assert(controllerSource.includes("type: 'annotationSaveProcessed'"), + 'host acknowledges every image-bound save so pending-save load guards can drain'); + assert(controllerSource.includes("case 'loadAccepted'") && controllerSource.includes("case 'loadRejected'") + && controllerSource.includes("case 'retryLoad'"), + 'host completes the webview load version handshake instead of leaving it one-sided'); + assert(controllerSource.includes('loadRequestId: requestId') && controllerSource.includes('expectedEditorVersion'), + 'host versions every load request against the editor state it has already observed'); + console.log('annotation history runtime tests passed'); })().catch(error => { console.error(error && error.stack ? error.stack : error); diff --git a/scripts/test_annotation_merge.js b/scripts/test_annotation_merge.js new file mode 100644 index 0000000..1a377eb --- /dev/null +++ b/scripts/test_annotation_merge.js @@ -0,0 +1,130 @@ +const assert = require('assert'); +const { mergeAnnotations, findAnnotationConflictItemIndex } = require('../out/annotationMergePure'); + +const ann = (id, category, x, y, w = 10, h = 10) => ({ id, category, x, y, w, h }); + +{ + const base = [ann(1, 'screen.a', 10, 10), ann(2, 'screen.b', 20, 20)]; + const local = [ann(1, 'screen.a', 11, 10), ann(2, 'screen.b', 20, 20)]; + const external = [ann(1, 'screen.a', 10, 10), ann(2, 'screen.b', 20, 21)]; + const result = mergeAnnotations('rect', base, local, external); + assert.deepStrictEqual(result.conflicts, []); + assert.strictEqual(result.merged.find(x => x.category === 'screen.a').x, 11); + assert.strictEqual(result.merged.find(x => x.category === 'screen.b').y, 21); +} + +{ + const base = [ann(7, 'title', 10, 10)]; + const local = [ann(7, 'title', 30, 10)]; + const external = [ann(7, 'title', 40, 10)]; + const result = mergeAnnotations('template', base, local, external); + assert.strictEqual(result.conflicts.length, 1); + assert.deepStrictEqual(result.conflicts[0].fields, ['x']); + assert.strictEqual(result.conflicts[0].mergedIndex, 0); + assert.strictEqual(result.merged[0].x, 30, 'local candidate stays editable while external candidate is retained in conflict data'); +} + +{ + const base = [ann(1, 'screen.point', 10, 10, 0, 0)]; + const local = []; + const external = [ann(1, 'screen.point', 11, 10, 0, 0)]; + const result = mergeAnnotations('point', base, local, external); + assert.strictEqual(result.conflicts.length, 1); + assert.strictEqual(result.conflicts[0].kind, 'delete-modify'); + assert.strictEqual(result.conflicts[0].local, undefined); + assert.strictEqual(result.conflicts[0].mergedIndex, undefined); +} + +{ + const base = [ann(4, 'old_name', 10, 10)]; + const local = [ann(4, 'new_name', 10, 10)]; + const external = [ann(4, 'old_name', 10, 15)]; + const result = mergeAnnotations('template', base, local, external); + assert.strictEqual(result.conflicts.length, 0, 'template id lets rename and geometry edit merge'); + assert.strictEqual(result.merged[0].category, 'new_name'); + assert.strictEqual(result.merged[0].y, 15); +} + +{ + const base = [ann(1, 'shared.category', 10, 10)]; + const local = [ann(2, 'shared.category', 30, 10)]; + const external = [ann(3, 'shared.category', 15, 10)]; + const result = mergeAnnotations('template', base, local, external); + assert.strictEqual(result.conflicts.length, 1, 'template replacement must not inherit identity from category alone'); + assert.strictEqual(result.conflicts[0].kind, 'add-add'); + assert.strictEqual(result.merged[0].id, 2, 'local replacement stays independent from the external original'); +} + +{ + const base = [ann(1, 'screen.same', 10, 10)]; + const local = [ann(20, 'screen.same', 11, 10)]; + const external = [ann(30, 'screen.same', 10, 12)]; + const result = mergeAnnotations('rect', base, local, external); + assert.strictEqual(result.conflicts.length, 0, 'rect identity remains category-based when persisted ids differ'); + assert.strictEqual(result.merged[0].x, 11); + assert.strictEqual(result.merged[0].y, 12); +} + +{ + const base = [ann(1, 'screen.same_point', 10, 10, 0, 0)]; + const local = [ann(20, 'screen.same_point', 11, 10, 0, 0)]; + const external = [ann(30, 'screen.same_point', 10, 12, 0, 0)]; + const result = mergeAnnotations('point', base, local, external); + assert.strictEqual(result.conflicts.length, 0, 'point identity remains category-based when persisted ids differ'); + assert.strictEqual(result.merged[0].x, 11); + assert.strictEqual(result.merged[0].y, 12); +} + +{ + const base = []; + const local = [ann(1, 'screen.same', 10, 10)]; + const external = [ann(99, 'screen.same', 20, 10)]; + const result = mergeAnnotations('rect', base, local, external); + assert.strictEqual(result.conflicts.length, 1); + assert.strictEqual(result.conflicts[0].kind, 'add-add'); +} + +{ + const base = []; + const local = [ + ann(1, 'shared.category', 10, 10), + ann(2, 'shared.category', 30, 10), + ]; + const external = [ + ann(101, 'shared.category', 11, 10), + ann(102, 'shared.category', 31, 10), + ]; + const result = mergeAnnotations('template', base, local, external); + assert.strictEqual(result.conflicts.length, 2, 'template mode allows multiple new annotations in one category'); + assert.strictEqual(new Set(result.conflicts.map(conflict => conflict.key)).size, 2, + 'each template add-add conflict has an independent choice key'); +} + +{ + const base = [ann(1, 'screen.a', 10, 10)]; + const local = [ann(2, 'screen.b', 50, 50)]; + const external = [ann(2, 'screen.a', 15, 10)]; + const result = mergeAnnotations('template', base, local, external); + assert.strictEqual(result.conflicts.length, 1, + 'template replacements with ambiguous branch-local ids become an explicit conflict'); + assert.strictEqual(result.conflicts[0].kind, 'add-add'); + assert.strictEqual(result.conflicts[0].local.category, 'screen.b'); + assert.strictEqual(result.conflicts[0].external.category, 'screen.a'); + assert.strictEqual(result.merged.length, 1, + 'the merge keeps the local candidate in the editable slot while retaining external data in conflict metadata'); + assert.strictEqual(result.merged[0].category, 'screen.b'); + assert.strictEqual(findAnnotationConflictItemIndex('template', result.merged, { + key: 'synthetic', kind: 'delete-modify', fields: ['x'], external: external[0], + }), -1, 'a conflict without a merge-owned slot never retargets by branch-local id'); +} + +{ + const base = [ann(1, 'screen.a', 10, 10), ann(2, 'screen.b', 20, 20)]; + const local = [ann(1, 'screen.a', 11, 10), ann(2, 'screen.b', 21, 20)]; + const result = mergeAnnotations('rect', base, local, []); + assert.strictEqual(result.conflicts.length, 2); + assert(result.conflicts[0].mergedIndex > result.conflicts[1].mergedIndex, + 'conflicts are ordered from higher to lower merge slot so removals cannot shift later targets'); +} + +console.log('annotation merge tests passed'); diff --git a/scripts/test_asset_swap_picker.js b/scripts/test_asset_swap_picker.js old mode 100644 new mode 100755 index d7dd949..fb3008e --- a/scripts/test_asset_swap_picker.js +++ b/scripts/test_asset_swap_picker.js @@ -1,358 +1,85 @@ #!/usr/bin/env node -/** - * 素材面板「交换标注」入口的回归测试(`media/templateAssetPanel/app.js`)。 - * - * 覆盖的是**入口本身**:卡片上的 ⇄ 按钮能不能打开目标选择器、选择器里有没有自己、 - * 选中之后发给宿主的那条消息**带没带对路径**。 - * - * 为什么值得单独测:这条消息是宿主唯一的输入,而宿主对"路径缺失/与自己相同" - * 一律**静默 return**(`handleSwapAnnotations` 的首行守卫)。也就是说 webview 一旦 - * 把 `targetPath` 发成 undefined 或者发出自己,界面上什么都不会发生、也不会报错 —— - * 典型的"看起来点了没反应",靠人肉点一遍很难稳定复现。 - * - * 与 `test_annotation_coords.js` 一样用 jsdom 直接跑真实 app.js(不另写一份逻辑), - * 所以这里不需要 canvas 桩:素材面板不画图。 - */ +const assert = require('assert'); const fs = require('fs'); +const os = require('os'); const path = require('path'); let jsdom; -try { - jsdom = require('jsdom'); -} catch { - const jsdomRoot = process.env.OK_LANG_HINTS_JSDOM_ROOT || path.join(process.env.TEMP, 'ok-script-toolkit-jsdom'); - jsdom = require(path.join(jsdomRoot, 'node_modules', 'jsdom')); +try { jsdom = require('jsdom'); } catch { + const root = process.env.OK_LANG_HINTS_JSDOM_ROOT || path.join(os.tmpdir(), 'ok-script-toolkit-jsdom'); + jsdom = require(path.join(root, 'node_modules', 'jsdom')); } const { JSDOM, VirtualConsole } = jsdom; - const root = path.resolve(__dirname, '..'); const componentRoot = path.join(root, 'media', 'templateAssetPanel'); - const dictionary = { - templateAssetsTitle: 'Template Assets', - templatesSearch: 'Search...', - assetImport: 'Import', - screenshot: 'Screenshot', - hardForeground: 'Hard foreground', - hardForegroundHint: 'Hard foreground hint', - saveToAssetsTitle: 'Save to assets', - assetDeleteTooltip: 'Delete', - assetSwapTooltip: 'Swap annotations with another image', - assetSwapTitle: 'Swap annotations', - assetSwapHint: 'Pick the image to swap annotations with', - assetSwapEmpty: 'No other image to swap with', - assetSwapBoxes: '{count} boxes', - assetSwapNoBoxes: 'No annotations', - cancel: 'Cancel', - loadFailed: 'Failed to load', - assetNoMatch: 'No match', - noTemplatesWithHint: 'No templates found.', - assetDropHint: 'Release to import into ok_templates', + templateAssetsTitle: 'Template Assets', templatesSearch: 'Search...', assetImport: 'Import', screenshot: 'Screenshot', + hardForeground: 'Hard foreground', hardForegroundHint: 'Hard foreground hint', publish: 'Publish', openSourceImage: 'Open source', + assetDeleteTooltip: 'Delete', assetSwapTooltip: 'Swap annotations', assetSwapTitle: 'Swap annotations', + assetSwapHint: 'Pick target', assetSwapEmpty: 'No other image', assetSwapBoxes: '{count} boxes', assetSwapNoBoxes: 'No annotations', + cancel: 'Cancel', loadFailed: 'Failed', assetNoMatch: 'No match', noTemplatesWithHint: 'No templates', assetDropHint: 'Drop', }; - let html = fs.readFileSync(path.join(componentRoot, 'index.html'), 'utf8'); -html = html - .replaceAll('__CSP_NONCE__', 'test') - .replaceAll('__CSP_SOURCE__', "'self'") - .replaceAll('__I18N_JSON__', JSON.stringify(dictionary)) - .replaceAll('__SHARED_TOKENS_URI__', '') - .replaceAll('__SHARED_CONTROLS_URI__', '') - .replace('', ``) +html = html.replaceAll('__CSP_NONCE__', 'test').replaceAll('__CSP_SOURCE__', "'self'") + .replaceAll('__I18N_JSON__', JSON.stringify(dictionary)).replaceAll('__SHARED_TOKENS_URI__', '').replaceAll('__SHARED_CONTROLS_URI__', '') + .replace('', ``) .replace('', '') .replace('', ``); - -const sent = []; -const virtualConsole = new VirtualConsole(); -virtualConsole.on('jsdomError', (error) => { throw error; }); - -function makeDom(source) { - const page = source === undefined ? html : html.replace( - fs.readFileSync(path.join(componentRoot, 'app.js'), 'utf8'), - source, - ); - return new JSDOM(page, { - runScripts: 'dangerously', - pretendToBeVisual: true, - virtualConsole, - beforeParse(window) { - window.acquireVsCodeApi = () => ({ - postMessage: (message) => sent.push(message), - getState: () => undefined, - setState: () => { }, - }); - }, - }); -} - -const failures = []; -function check(condition, message) { - if (condition) { - console.log(` ok ${message}`); - } else { - console.log(` FAIL ${message}`); - failures.push(message); - } -} - -const TEMPLATES = [ - { name: '1.png', imagePath: 'C:/proj/ok_templates/1.png', width: 1920, height: 1080, categories: ['confirm'], annotations: 1 }, - { name: '2.png', imagePath: 'C:/proj/ok_templates/2.png', width: 800, height: 600, categories: [], annotations: 0 }, - { name: '3.png', imagePath: 'C:/proj/ok_templates/3.png', width: 1280, height: 720, categories: ['back', 'home'], annotations: 2 }, +const templates = [ + { name:'1.png', imagePath:'C:/p/1.png', width:1920, height:1080, categories:['a'], annotations:1 }, + { name:'2.png', imagePath:'C:/p/2.png', width:800, height:600, categories:[], annotations:0 }, + { name:'3.png', imagePath:'C:/p/3.png', width:1280, height:720, categories:['b','c'], annotations:2 }, ]; - -(async function run() { - const dom = makeDom(); - const { window } = dom; - const document = window.document; - const flush = () => new Promise((resolve) => window.setTimeout(resolve, 0)); - await flush(); - - const cards = [...document.querySelectorAll('.card')]; - check(cards.length === 0, '还没收到模板列表时没有卡片'); - - window.dispatchEvent(new window.MessageEvent('message', { data: { type: 'templates', templates: TEMPLATES } })); - await flush(); - - /* ── 1. 卡片上的入口 ─────────────────────────────────────────── */ - console.log('卡片入口'); - const cardOf = (name) => document.querySelector(`.card[data-name="${name}"]`); - check(document.querySelectorAll('.card').length === 3, '三张图各一张卡片'); - check( - !!cardOf('1.png') && cardOf('1.png').querySelectorAll('.actions button').length === 4, - '卡片操作区有 4 个按钮(编辑、打开、交换、删除)', - ); - const swapBtn = cardOf('1.png').querySelector('button[data-action="swap"]'); - check(swapBtn.textContent === '⇄', `交换按钮(实际 "${swapBtn.textContent}")`); - check(swapBtn.title === dictionary.assetSwapTooltip, '交换按钮的 tooltip 走文案字典'); - - /* ── 2. 打开选择器:排除自己、显示尺寸与标注数 ───────────────── */ - console.log('目标选择器'); - const modal = document.getElementById('swapModal'); - check(!modal.classList.contains('visible'), '初始时选择器是关闭的'); - - swapBtn.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); - await flush(); - check(modal.classList.contains('visible'), '点 ⇄ 后选择器打开'); - check(document.getElementById('swapTitle').textContent === dictionary.assetSwapTitle, '标题走文案字典'); - - const items = [...document.querySelectorAll('#swapList .swap-item')]; - check(items.length === 2, `列表里是"除自己以外"的 2 张图(实际 ${items.length})`); - const names = items.map((it) => it.querySelector('.swap-name').textContent); - check(!names.includes('1.png'), `列表里不含自己(实际 ${names.join(', ')})`); - check(names.includes('2.png') && names.includes('3.png'), '另外两张都在列表里'); - - const metaOf = (name) => items[names.indexOf(name)].querySelector('.swap-meta').textContent; - check(metaOf('3.png').includes('1280x720'), `列表项显示尺寸(实际 "${metaOf('3.png')}")`); - check(metaOf('3.png').includes('2 boxes'), `列表项显示标注数(实际 "${metaOf('3.png')}")`); - check(metaOf('2.png').includes(dictionary.assetSwapNoBoxes), '无标注的图显示"无标注"而不是"0 boxes"'); - - /* ── 3. 选中目标:发出去的消息必须带全两个路径 ───────────────── */ - console.log('选中目标'); - const target = items[names.indexOf('2.png')]; - target.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); - await flush(); - - const swaps = sent.filter((m) => m && m.type === 'swapAnnotations'); - check(swaps.length === 1, `恰好发出一条交换消息(实际 ${swaps.length})`); - const msg = swaps[0] || {}; - check(msg.imagePath === 'C:/proj/ok_templates/1.png', `imagePath 是被交换的那张(实际 ${JSON.stringify(msg.imagePath)})`); - check(msg.targetPath === 'C:/proj/ok_templates/2.png', `targetPath 是选中的目标(实际 ${JSON.stringify(msg.targetPath)})`); - check( - typeof msg.imagePath === 'string' && typeof msg.targetPath === 'string' && msg.imagePath !== msg.targetPath, - '两个路径都是字符串且不相同 —— 宿主对"缺失/与自己相同"是静默 return,发错了就是"点了没反应"', - ); - check(!modal.classList.contains('visible'), '选完即关闭选择器'); - - /* ── 4. 关闭方式:取消 / 遮罩 / Esc ───────────────────────────── */ - console.log('关闭方式'); - swapBtn.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); - await flush(); - document.getElementById('swapCancel').dispatchEvent(new window.MouseEvent('click', { bubbles: true })); - await flush(); - check(!modal.classList.contains('visible'), '点取消关闭'); - check(sent.filter((m) => m && m.type === 'swapAnnotations').length === 1, '取消不会发出交换消息'); - - swapBtn.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); - await flush(); - modal.dispatchEvent(new window.MouseEvent('mousedown', { bubbles: true })); - await flush(); - check(!modal.classList.contains('visible'), '点遮罩关闭'); - - swapBtn.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); - await flush(); - document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - await flush(); - check(!modal.classList.contains('visible'), 'Esc 关闭'); - - /* ── 5. 只有一张图时给出空态 ─────────────────────────────────── */ - console.log('只有一张图'); - const solo = makeDom(); - await new Promise((resolve) => solo.window.setTimeout(resolve, 0)); - solo.window.dispatchEvent(new solo.window.MessageEvent('message', { - data: { type: 'templates', templates: [TEMPLATES[0]] }, - })); - await new Promise((resolve) => solo.window.setTimeout(resolve, 0)); - solo.window.document.querySelector('.card .actions button[data-action="swap"]') - .dispatchEvent(new solo.window.MouseEvent('click', { bubbles: true })); - await new Promise((resolve) => solo.window.setTimeout(resolve, 0)); - check( - solo.window.document.getElementById('swapModal').classList.contains('visible'), - '只有一张图时也能打开(用来给出"没有其他图片"的说明)', - ); - check( - solo.window.document.querySelector('#swapList .swap-empty')?.textContent === dictionary.assetSwapEmpty, - '空态文案走文案字典', - ); - - /* ── 6. 缩略图:缺的要现要,后补的要能填进已经打开的列表 ───────── - 背景:网格的缩略图是宿主**分批异步**推来的,而选择器的列表只在打开那一刻渲染一次。 - 用户完全可能在这一轮推完之前就点开 ⇄ —— 那时缺的那几张会永远停在占位符上, - 只有关掉再打开才"碰巧"有(因为缓存那时已经被填上了)。这一组钉住的就是这个。 */ - console.log('缩略图补推'); - { - const mark = sent.length; - const d2 = makeDom(); - const w2 = d2.window; - const doc2 = w2.document; - const flush2 = () => new Promise((resolve) => w2.setTimeout(resolve, 0)); - const since = (type) => sent.slice(mark).filter((m) => m && m.type === type); - const openPicker = async (name) => { - doc2.querySelector(`.card[data-name="${name}"] .actions button[data-action="swap"]`) - .dispatchEvent(new w2.MouseEvent('click', { bubbles: true })); - await flush2(); - }; - - await flush2(); - w2.dispatchEvent(new w2.MessageEvent('message', { data: { type: 'templates', templates: TEMPLATES } })); - await flush2(); - check( - doc2.querySelectorAll('.card .thumb-box img').length === 0, - '这一轮还没收到任何缩略图(模拟"刚打开面板就点 ⇄")', - ); - - await openPicker('1.png'); - const requested = since('requestThumbs'); - check(requested.length === 1, `打开选择器时发了一次补推请求(实际 ${requested.length} 次)`); - const asked = (requested[0] && requested[0].imagePaths) || []; - check( - asked.length === 2 && asked.includes('C:/proj/ok_templates/2.png') && asked.includes('C:/proj/ok_templates/3.png'), - `只请求缺缩略图的那几张、且不含自己(实际 ${JSON.stringify(asked)})`, - ); - check( - doc2.querySelectorAll('#swapList .swap-thumb img').length === 0, - '补推到达之前列表里是占位符', - ); - - // 宿主后补一张(列表**开着**) - w2.dispatchEvent(new w2.MessageEvent('message', { - data: { type: 'thumbs', items: [{ name: '3.png', url: 'data:image/png;base64,AAA' }] }, - })); - await flush2(); - check( - doc2.querySelectorAll('#swapList .swap-thumb img').length === 1, - '后补的缩略图会填进**已经打开**的列表(不必关掉再打开)', - ); - check( - doc2.querySelector('.card[data-name="3.png"] .thumb-box img') !== null, - '同一张缩略图也落到网格卡片上(一份缓存、两处复用,就是网格那一张)', - ); - - w2.dispatchEvent(new w2.MessageEvent('message', { - data: { type: 'thumbs', items: [{ name: '3.png', url: 'data:image/png;base64,BBB' }] }, - })); - await flush2(); - check( - doc2.querySelectorAll('#swapList .swap-thumb img').length === 1, - '同一张重复推送不会插第二张(幂等)', - ); - - // 重新下发模板列表 ⇒ 缩略图 URL 缓存作废(模板名会被复用,旧 URL 会显示错图) - doc2.getElementById('swapCancel').dispatchEvent(new w2.MouseEvent('click', { bubbles: true })); - await flush2(); - w2.dispatchEvent(new w2.MessageEvent('message', { data: { type: 'templates', templates: TEMPLATES } })); - await flush2(); - const beforeSecondOpen = since('requestThumbs').length; - await openPicker('1.png'); - check( - since('requestThumbs').length === beforeSecondOpen + 1, - '模板列表重下发后缓存整份作废:再开选择器会重新要一遍(不会沿用旧 URL)', - ); - } - - /* ── 7. 破坏性对照:去掉"排除自己" ───────────────────────────── */ - console.log('破坏性对照'); - { - const appSource = fs.readFileSync(path.join(componentRoot, 'app.js'), 'utf8'); - const noSelfFilter = appSource.replace( - 'metas.filter((m) => m.name !== source.name)', - 'metas.filter((m) => true)', - ); - check(noSelfFilter !== appSource, '对照源码确实被改动了(替换命中)—— 否则对照是假的'); - - const broken = makeDom(noSelfFilter); - await new Promise((resolve) => broken.window.setTimeout(resolve, 0)); - broken.window.dispatchEvent(new broken.window.MessageEvent('message', { - data: { type: 'templates', templates: TEMPLATES }, - })); - await new Promise((resolve) => broken.window.setTimeout(resolve, 0)); - broken.window.document.querySelector('.card[data-name="1.png"] .actions button[data-action="swap"]') - .dispatchEvent(new broken.window.MouseEvent('click', { bubbles: true })); - await new Promise((resolve) => broken.window.setTimeout(resolve, 0)); - const brokenNames = [...broken.window.document.querySelectorAll('#swapList .swap-item')] - .map((it) => it.querySelector('.swap-name').textContent); - check( - brokenNames.includes('1.png') && brokenNames.length === 3, - `对照:去掉自我排除后列表里出现自己(实际 ${brokenNames.join(', ')})—— 与第 2 组相反`, - ); - - /* 对照二:拿掉"回填已经打开的选择器"那段 —— 正是用户报的"不会尝试渲染"。 */ - const noPatch = appSource.replace( - "if (thumb.dataset.name === name) fillThumbBox(thumb, url, name, '');", - "if (false) fillThumbBox(thumb, url, name, '');", - ); - check(noPatch !== appSource, '对照二源码确实被改动了(替换命中)—— 否则对照是假的'); - - const noPatchDom = makeDom(noPatch); - const w3 = noPatchDom.window; - await new Promise((resolve) => w3.setTimeout(resolve, 0)); - w3.dispatchEvent(new w3.MessageEvent('message', { data: { type: 'templates', templates: TEMPLATES } })); - await new Promise((resolve) => w3.setTimeout(resolve, 0)); - w3.document.querySelector('.card[data-name="1.png"] .actions button[data-action="swap"]') - .dispatchEvent(new w3.MouseEvent('click', { bubbles: true })); - await new Promise((resolve) => w3.setTimeout(resolve, 0)); - w3.dispatchEvent(new w3.MessageEvent('message', { - data: { type: 'thumbs', items: [{ name: '3.png', url: 'data:image/png;base64,AAA' }] }, - })); - await new Promise((resolve) => w3.setTimeout(resolve, 0)); - check( - w3.document.querySelectorAll('#swapList .swap-thumb img').length === 0, - '对照二:不回填时,后补的缩略图进不了已经打开的列表(只能关掉再开)—— 与第 6 组相反', - ); - - /* 对照三:拿掉"为缺缩略图的候选发补推请求"—— 那样缺的图永远没有机会被补上。 */ - const noRequest = appSource.replace( - 'if (missing.length) vscode.postMessage({ type: \'requestThumbs\', imagePaths: missing });', - 'if (false) vscode.postMessage({ type: \'requestThumbs\', imagePaths: missing });', - ); - check(noRequest !== appSource, '对照三源码确实被改动了(替换命中)—— 否则对照是假的'); - - const noRequestMark = sent.length; - const noRequestDom = makeDom(noRequest); - const w4 = noRequestDom.window; - await new Promise((resolve) => w4.setTimeout(resolve, 0)); - w4.dispatchEvent(new w4.MessageEvent('message', { data: { type: 'templates', templates: TEMPLATES } })); - await new Promise((resolve) => w4.setTimeout(resolve, 0)); - w4.document.querySelector('.card[data-name="1.png"] .actions button[data-action="swap"]') - .dispatchEvent(new w4.MouseEvent('click', { bubbles: true })); - await new Promise((resolve) => w4.setTimeout(resolve, 0)); - check( - sent.slice(noRequestMark).filter((m) => m && m.type === 'requestThumbs').length === 0, - '对照三:不主动要时一张补推请求都不发(裁剪失败的那几张整轮都不会再推)—— 与第 6 组相反', - ); - } - - console.log('\n' + (failures.length ? `失败 ${failures.length} 项` : '全部通过')); - process.exit(failures.length ? 1 : 0); -})().catch((error) => { - console.error(error); - process.exit(1); -}); +function makeDom() { + const sent = []; + const vc = new VirtualConsole(); vc.on('jsdomError', e => { throw e; }); + const dom = new JSDOM(html, { runScripts:'dangerously', pretendToBeVisual:true, virtualConsole:vc, beforeParse(w) { + w.acquireVsCodeApi = () => ({ postMessage:m => sent.push(JSON.parse(JSON.stringify(m))), getState:()=>({}), setState(){} }); + }}); + return { dom, w:dom.window, sent }; +} +const send = (w, data) => w.dispatchEvent(new w.MessageEvent('message', { data })); +const flush = w => new Promise(r => w.setTimeout(r, 0)); +(async () => { + const { dom, w, sent } = makeDom(); const d = w.document; await flush(w); + send(w, { type:'templates', templates }); await flush(w); + const card = name => d.querySelector(`.card[data-name="${name}"]`); + assert.strictEqual(card('1.png').querySelectorAll('.actions button').length, 3, 'cards expose open/swap/delete only'); + assert.strictEqual(card('1.png').querySelector('[data-action="edit"]'), null, 'edit button is removed'); + card('1.png').dispatchEvent(new w.MouseEvent('click', { bubbles:true })); + assert.deepStrictEqual(sent.at(-1), { type:'openAnnotation', imagePath:'C:/p/1.png' }); + card('1.png').querySelector('[data-action="open"]').click(); + assert.deepStrictEqual(sent.at(-1), { type:'openSource', imagePath:'C:/p/1.png' }); + + card('1.png').querySelector('[data-action="swap"]').click(); await flush(w); + const items = [...d.querySelectorAll('#swapList .swap-item')]; + assert.deepStrictEqual(items.map(x => x.querySelector('.swap-name').textContent).sort(), ['2.png','3.png']); + const req = sent.filter(x => x.type === 'requestThumbs').at(-1); + assert.deepStrictEqual(new Set(req.imagePaths), new Set(['C:/p/2.png','C:/p/3.png'])); + + send(w, { type:'thumbs', items:[{ name:'3.png', url:'data:image/png;base64,AAA' }] }); await flush(w); + const gridImg = card('3.png').querySelector('.thumb-box img'); + const pickerImg = [...d.querySelectorAll('#swapList .swap-item')].find(x => x.querySelector('.swap-name').textContent === '3.png').querySelector('.swap-thumb img'); + assert(gridImg && pickerImg, 'late thumbnail populates grid and open picker'); + send(w, { type:'thumbs', items:[{ name:'3.png', url:'data:image/png;base64,BBB' }] }); await flush(w); + assert.strictEqual(card('3.png').querySelectorAll('.thumb-box img').length, 1); + assert(gridImg.src.includes('BBB') && pickerImg.src.includes('BBB'), 'new content updates existing img nodes'); + + d.getElementById('swapCancel').click(); + const oldCard = card('3.png'); const oldImg = gridImg; + const changed = templates.map(x => x.name === '3.png' ? { ...x, categories:['b','c','new'], annotations:3 } : { ...x }); + send(w, { type:'templates', templates:changed }); await flush(w); + assert.strictEqual(card('3.png'), oldCard, 'metadata refresh preserves card DOM'); + assert.strictEqual(card('3.png').querySelector('.thumb-box img'), oldImg, 'metadata refresh preserves decoded thumbnail'); + assert(card('3.png').querySelector('.cats').textContent.includes('new')); + + card('1.png').querySelector('[data-action="swap"]').click(); await flush(w); + const target = [...d.querySelectorAll('#swapList .swap-item')].find(x => x.querySelector('.swap-name').textContent === '2.png'); + target.click(); await flush(w); + assert.deepStrictEqual(sent.filter(x => x.type === 'swapAnnotations').at(-1), { type:'swapAnnotations', imagePath:'C:/p/1.png', targetPath:'C:/p/2.png' }); + + const solo = makeDom(); await flush(solo.w); send(solo.w, { type:'templates', templates:[templates[0]] }); await flush(solo.w); + solo.w.document.querySelector('[data-action="swap"]').click(); await flush(solo.w); + assert.strictEqual(solo.w.document.querySelector('#swapList .swap-empty').textContent, dictionary.assetSwapEmpty); + dom.window.close(); solo.dom.window.close(); + console.log('asset swap picker tests passed'); +})().catch(e => { console.error(e.stack || e); process.exitCode = 1; }); diff --git a/scripts/test_box_resource.js b/scripts/test_box_resource.js index 24b907d..c6f89a7 100644 --- a/scripts/test_box_resource.js +++ b/scripts/test_box_resource.js @@ -8,7 +8,7 @@ const zlib = require('zlib'); const pure = require('../out/boxResourcePure'); const store = require('../out/boxResourceStore'); -const { CocoAnnotationData } = require('../out/cocoAnnotationData'); +const { CocoAnnotationData, writeAnnotationTextIfUnchanged } = require('../out/cocoAnnotationData'); const CRC_TABLE = (() => { const table = new Int32Array(256); @@ -56,6 +56,106 @@ writePng(path.join(folder, 'b.png'), 200, 200); try { const source = path.join(folder, 'boxes.json'); + // The compare-and-write handoff must preserve a writer that recreates the path + // after the expected revision was moved aside but before our prepared file is installed. + const casFile = path.join(folder, 'cas.json'); + fs.writeFileSync(casFile, 'expected', 'utf8'); + const originalLink = fs.linkSync; + let injectedExternalWrite = false; + fs.linkSync = function (from, to) { + if (!injectedExternalWrite && path.resolve(String(to)) === path.resolve(casFile)) { + injectedExternalWrite = true; + fs.writeFileSync(casFile, 'external', 'utf8'); + const error = new Error('already exists'); + error.code = 'EEXIST'; + throw error; + } + return originalLink.apply(this, arguments); + }; + try { + assert.strictEqual(writeAnnotationTextIfUnchanged(casFile, 'ours', 'expected', false), false); + } finally { + fs.linkSync = originalLink; + } + assert.strictEqual(fs.readFileSync(casFile, 'utf8'), 'external', + 'a writer that recreates the path during handoff must win instead of being overwritten'); + + // Recovery must use the same hard-link -> exclusive-copy fallback as installation. + const recoveryFile = path.join(folder, 'recovery.json'); + fs.writeFileSync(recoveryFile, 'expected', 'utf8'); + fs.linkSync = function (from, to) { + if (String(from).includes('.previous') && path.resolve(String(to)) === path.resolve(recoveryFile)) { + const error = new Error('hard links unavailable'); + error.code = 'EPERM'; + throw error; + } + return originalLink.apply(this, arguments); + }; + try { + assert.strictEqual(writeAnnotationTextIfUnchanged(recoveryFile, 'ours', 'stale', false), false); + } finally { + fs.linkSync = originalLink; + } + assert.strictEqual(fs.readFileSync(recoveryFile, 'utf8'), 'expected', + 'revision mismatch restores the previous bytes through the copy fallback'); + + // If installation fails after the old file is moved aside, restore it but keep the original error. + const installFailureFile = path.join(folder, 'install-failure.json'); + fs.writeFileSync(installFailureFile, 'expected', 'utf8'); + fs.linkSync = function (from, to) { + if (String(from).includes('.tmp') && path.resolve(String(to)) === path.resolve(installFailureFile)) { + const error = new Error('prepared install failed'); + error.code = 'EIO'; + throw error; + } + return originalLink.apply(this, arguments); + }; + try { + assert.throws( + () => writeAnnotationTextIfUnchanged(installFailureFile, 'ours', 'expected', false), + error => error && error.code === 'EIO' && error.message === 'prepared install failed', + ); + } finally { + fs.linkSync = originalLink; + } + assert.strictEqual(fs.readFileSync(installFailureFile, 'utf8'), 'expected', + 'failed install restores the original file before rethrowing the install error'); + + // If restoration itself fails, never delete the only preserved previous copy. + const recoveryFailureFile = path.join(folder, 'recovery-failure.json'); + fs.writeFileSync(recoveryFailureFile, 'expected', 'utf8'); + const originalCopy = fs.copyFileSync; + fs.linkSync = function (from, to) { + if (String(from).includes('.previous') && path.resolve(String(to)) === path.resolve(recoveryFailureFile)) { + const error = new Error('hard links unavailable'); + error.code = 'EPERM'; + throw error; + } + return originalLink.apply(this, arguments); + }; + fs.copyFileSync = function (from, to) { + if (String(from).includes('.previous') && path.resolve(String(to)) === path.resolve(recoveryFailureFile)) { + const error = new Error('restore copy failed'); + error.code = 'EIO'; + throw error; + } + return originalCopy.apply(this, arguments); + }; + try { + assert.throws( + () => writeAnnotationTextIfUnchanged(recoveryFailureFile, 'ours', 'stale', false), + error => error && error.code === 'EIO' && error.message === 'restore copy failed', + ); + } finally { + fs.linkSync = originalLink; + fs.copyFileSync = originalCopy; + } + assert.strictEqual(fs.existsSync(recoveryFailureFile), false); + const preservedPrevious = fs.readdirSync(folder).find(name => name.startsWith('.recovery-failure.json.') && name.endsWith('.previous')); + assert(preservedPrevious, 'failed recovery preserves the previous copy for manual recovery'); + assert.strictEqual(fs.readFileSync(path.join(folder, preservedPrevious), 'utf8'), 'expected'); + fs.rmSync(path.join(folder, preservedPrevious), { force: true }); + assert.deepStrictEqual(store.authoringReadErrors(project, directory), []); assert.deepStrictEqual(store.readAuthoringFile(project, directory), { images: [], boxes: [] }); @@ -74,6 +174,16 @@ try { assert(!Object.prototype.hasOwnProperty.call(raw, 'version')); assert(!Object.prototype.hasOwnProperty.call(raw, 'boxes')); + // CocoAnnotationData guarded saves use the same expected revision and leave newer disk data intact. + const guardedData = new CocoAnnotationData(project, directory, 'boxes.json'); + guardedData.load(); + const guardedRevision = guardedData.revision; + const externalRevision = guardedRevision + '\n'; + fs.writeFileSync(source, externalRevision, 'utf8'); + assert.strictEqual(guardedData.saveIfRevision(guardedRevision), false); + assert.strictEqual(fs.readFileSync(source, 'utf8'), externalRevision); + fs.writeFileSync(source, guardedRevision, 'utf8'); + const authoring = store.readAuthoringFile(project, directory); assert.deepStrictEqual(authoring.boxes.map(box => box.path).sort(), ['panels.allowed', 'screen.first']); assert.deepStrictEqual(authoring.boxes.find(box => box.path === 'screen.first').bbox, [10, 20, 30, 40]); diff --git a/scripts/test_point_resource.js b/scripts/test_point_resource.js index bacf299..c77465f 100644 --- a/scripts/test_point_resource.js +++ b/scripts/test_point_resource.js @@ -64,6 +64,26 @@ const byName = new Map(raw.annotations.map(ann => [names.get(ann.category_id), a assert.deepStrictEqual(byName.get('screen.existing'), [10,20,0,0]); assert.deepStrictEqual(byName.get('screen.new'), [30,40,0,0]); +// Revision-guarded point saves must not overwrite a newer external revision. +const pointRevision = fs.readFileSync(pointFile, 'utf8'); +const externalPointRevision = pointRevision + '\n'; +fs.writeFileSync(pointFile, externalPointRevision, 'utf8'); +assert.strictEqual(points.savePointsForImageIfRevision(project, directory, image, [ + { path: 'screen.existing', x: 11, y: 21 }, + { path: 'screen.new', x: 31, y: 41 }, +], pointRevision), 'changed'); +assert.strictEqual(fs.readFileSync(pointFile, 'utf8'), externalPointRevision, + 'stale point save preserves the external revision'); +assert.strictEqual(points.savePointsForImageIfRevision(project, directory, image, [ + { path: 'screen.existing', x: 11, y: 21 }, + { path: 'screen.new', x: 31, y: 41 }, +], externalPointRevision), undefined); +read = points.readPoints(project, directory); +assert.deepStrictEqual(read.file.points.map(point => [point.path, point.x, point.y]).sort(), [ + ['screen.existing', 11, 21], + ['screen.new', 31, 41], +]); + read = points.readPoints(project, directory); assert.deepStrictEqual(read.errors, []); assert.strictEqual(read.file.points.length, 2); diff --git a/scripts/test_thumbnail_actions.js b/scripts/test_thumbnail_actions.js index 6e6af04..f4c2cca 100644 --- a/scripts/test_thumbnail_actions.js +++ b/scripts/test_thumbnail_actions.js @@ -103,11 +103,19 @@ const { dom, w, sent } = gallery('templateAssetPanel', { type: 'templates', temp name: 'source.png', imagePath: 'source.png', width: 20, height: 30, categories: [], annotations: 0, }] }); const card = w.document.querySelector('.card'); -assert.strictEqual(card.querySelectorAll('.thumbnail-actions button').length, 4); -card.querySelector('[data-action="edit"]').click(); +assert.strictEqual(card.querySelectorAll('.thumbnail-actions button').length, 3, 'asset card exposes only source, swap and delete actions'); +assert.strictEqual(card.querySelector('[data-action="edit"]'), null, 'clicking the card itself is the annotation edit action'); +card.dispatchEvent(new w.MouseEvent('click', { bubbles: true })); assert.deepStrictEqual(sent.pop(), { type: 'openAnnotation', imagePath: 'source.png' }); card.querySelector('[data-action="open"]').click(); assert.deepStrictEqual(sent.pop(), { type: 'openSource', imagePath: 'source.png' }); assert.strictEqual(sent.length, 0, 'direct asset actions never trigger a second card action'); + +w.dispatchEvent(new w.MessageEvent('message', { data: { type: 'templates', templates: [{ + name: 'source.png', imagePath: 'source.png', width: 20, height: 30, categories: ['screen.updated'], annotations: 1, +}] } })); +assert.strictEqual(w.document.querySelector('.card'), card, 'annotation refresh preserves the existing source-card DOM node'); +assert.strictEqual(card.querySelector('.cats').textContent, 'screen.updated', 'annotation metadata updates in place'); + dom.window.close(); console.log('thumbnail action parity: unified template/rect/point preview and asset actions OK'); diff --git a/src/annotationLocalization.ts b/src/annotationLocalization.ts new file mode 100644 index 0000000..ba1a46b --- /dev/null +++ b/src/annotationLocalization.ts @@ -0,0 +1,139 @@ +import * as vscode from 'vscode'; +import { SupportedUiLocale, uiLocale, webviewStrings, WebviewStrings } from './localization'; + +const EXTRAS: Record = { + 'zh-cn': { + annotationModeLabel: '标注模式', + modeTemplate: '模板', + modeRect: '框', + modePoint: '点', + sharedHistory: '共享撤销历史', + sharedHistoryTooltip: '模板、框和点模式共用撤销/重做历史', + coordPreferenceTooltip: '未勾选:x1,y1,x2,y2;勾选:x,y,w,h。也用于解释没有名称的四数字粘贴。', + pointTool: '点 ({key})', + newPointTitle: '新建点', + editPointTitle: '编辑点', + clipboardInvalid: '剪贴板内容不是有效的归一化坐标。', + clipboardPointOnly: '零尺寸坐标只能在点模式中粘贴。', + positionPathFailed: '位置路径无效', + conflictTitle: '标注冲突', + conflictCurrent: '当前编辑', + conflictExternal: '外部修改', + conflictFields: '冲突字段', + conflictApply: '应用选择并保存', + conflictHint: '两边都会保留到你完成选择;未解决前不会写盘。', + }, + 'zh-tw': { + annotationModeLabel: '標註模式', + modeTemplate: '模板', + modeRect: '框', + modePoint: '點', + sharedHistory: '共用復原歷史', + sharedHistoryTooltip: '模板、框和點模式共用復原/重做歷史', + coordPreferenceTooltip: '未勾選:x1,y1,x2,y2;勾選:x,y,w,h。也用於解讀沒有名稱的四數字貼上。', + pointTool: '點 ({key})', + newPointTitle: '新增點', + editPointTitle: '編輯點', + clipboardInvalid: '剪貼簿內容不是有效的正規化座標。', + clipboardPointOnly: '零尺寸座標只能在點模式中貼上。', + positionPathFailed: '位置路徑無效', + conflictTitle: '標註衝突', + conflictCurrent: '目前編輯', + conflictExternal: '外部修改', + conflictFields: '衝突欄位', + conflictApply: '套用選擇並儲存', + conflictHint: '完成選擇前會保留兩個版本;衝突未解決前不會寫入磁碟。', + }, + en: { + annotationModeLabel: 'Annotation mode', + modeTemplate: 'Template', + modeRect: 'Box', + modePoint: 'Point', + sharedHistory: 'Shared undo history', + sharedHistoryTooltip: 'Share undo/redo history between Template, Box and Point modes', + coordPreferenceTooltip: 'Unchecked: x1,y1,x2,y2. Checked: x,y,w,h. Also resolves ambiguous unnamed four-number pastes.', + pointTool: 'Point ({key})', + newPointTitle: 'New Point', + editPointTitle: 'Edit Point', + clipboardInvalid: 'Clipboard is not a valid normalized coordinate tuple.', + clipboardPointOnly: 'Zero-size coordinates can only be pasted in Point mode.', + positionPathFailed: 'Invalid position path', + conflictTitle: 'Annotation conflicts', + conflictCurrent: 'Current edit', + conflictExternal: 'External change', + conflictFields: 'Conflicting fields', + conflictApply: 'Apply choices and save', + conflictHint: 'Both versions are kept until every conflict is resolved; nothing is written before that.', + }, + ja: { + annotationModeLabel: 'アノテーションモード', + modeTemplate: 'テンプレート', + modeRect: 'ボックス', + modePoint: 'ポイント', + sharedHistory: '取り消し履歴を共有', + sharedHistoryTooltip: 'テンプレート・ボックス・ポイントで取り消し/やり直し履歴を共有します', + coordPreferenceTooltip: '未選択: x1,y1,x2,y2。選択: x,y,w,h。名前なしの4数値貼り付けの解釈にも使います。', + pointTool: 'ポイント ({key})', + newPointTitle: '新しいポイント', + editPointTitle: 'ポイントを編集', + clipboardInvalid: 'クリップボードの内容は有効な正規化座標ではありません。', + clipboardPointOnly: 'サイズ 0 の座標はポイントモードでのみ貼り付けできます。', + positionPathFailed: '位置パスが無効です', + conflictTitle: 'アノテーション競合', + conflictCurrent: '現在の編集', + conflictExternal: '外部変更', + conflictFields: '競合フィールド', + conflictApply: '選択を適用して保存', + conflictHint: 'すべての競合を解決するまで両方のバージョンを保持し、その前には書き込みません。', + }, + ko: { + annotationModeLabel: '주석 모드', + modeTemplate: '템플릿', + modeRect: '박스', + modePoint: '포인트', + sharedHistory: '실행 취소 기록 공유', + sharedHistoryTooltip: '템플릿, 박스, 포인트 모드가 실행 취소/다시 실행 기록을 공유합니다', + coordPreferenceTooltip: '해제: x1,y1,x2,y2. 선택: x,y,w,h. 이름 없는 네 숫자 붙여넣기 해석에도 사용합니다.', + pointTool: '포인트 ({key})', + newPointTitle: '새 포인트', + editPointTitle: '포인트 편집', + clipboardInvalid: '클립보드 내용이 유효한 정규화 좌표가 아닙니다.', + clipboardPointOnly: '크기가 0인 좌표는 포인트 모드에서만 붙여넣을 수 있습니다.', + positionPathFailed: '잘못된 위치 경로', + conflictTitle: '주석 충돌', + conflictCurrent: '현재 편집', + conflictExternal: '외부 변경', + conflictFields: '충돌 필드', + conflictApply: '선택 적용 및 저장', + conflictHint: '모든 충돌을 해결할 때까지 두 버전을 모두 유지하며 그 전에는 파일에 쓰지 않습니다.', + }, + es: { + annotationModeLabel: 'Modo de anotación', + modeTemplate: 'Plantilla', + modeRect: 'Cuadro', + modePoint: 'Punto', + sharedHistory: 'Compartir historial de deshacer', + sharedHistoryTooltip: 'Comparte el historial de deshacer/rehacer entre Plantilla, Cuadro y Punto', + coordPreferenceTooltip: 'Desmarcado: x1,y1,x2,y2. Marcado: x,y,w,h. También resuelve pegados ambiguos de cuatro números sin nombre.', + pointTool: 'Punto ({key})', + newPointTitle: 'Nuevo punto', + editPointTitle: 'Editar punto', + clipboardInvalid: 'El contenido del portapapeles no es una coordenada normalizada válida.', + clipboardPointOnly: 'Las coordenadas de tamaño cero solo se pueden pegar en el modo Punto.', + positionPathFailed: 'Ruta de posición no válida', + conflictTitle: 'Conflicto de anotaciones', + conflictCurrent: 'Edición actual', + conflictExternal: 'Cambio externo', + conflictFields: 'Campos en conflicto', + conflictApply: 'Aplicar selección y guardar', + conflictHint: 'Se conservan ambas versiones hasta resolver todos los conflictos; antes no se escribe nada.', + }, +}; + +export function annotationWebviewStrings(language = vscode.env.language): WebviewStrings { + return { ...webviewStrings(language), ...EXTRAS[uiLocale(language)] }; +} + +export function injectAnnotationWebviewLocalization(html: string, marker = '__I18N_JSON__'): string { + return html.split(marker).join(JSON.stringify(annotationWebviewStrings()).replace(/; + base?: MergeAnnotation; + local?: MergeAnnotation; + external?: MergeAnnotation; + mergedIndex?: number; +} + +export interface AnnotationMergeResult { + merged: MergeAnnotation[]; + conflicts: AnnotationConflict[]; +} + +const FIELDS: Array<'category' | 'x' | 'y' | 'w' | 'h'> = ['category', 'x', 'y', 'w', 'h']; + +function cloneAnnotation(value: MergeAnnotation | undefined): MergeAnnotation | undefined { + return value ? { ...value } : undefined; +} + +function sameValue(a: unknown, b: unknown): boolean { + return a === b; +} + +function sameAnnotation(a: MergeAnnotation | undefined, b: MergeAnnotation | undefined): boolean { + if (!a || !b) return a === b; + return FIELDS.every(field => sameValue(a[field], b[field])); +} + +function categoryKey(value: MergeAnnotation): string { + return value.category.trim(); +} + +function geometryEqual(a: MergeAnnotation, b: MergeAnnotation): boolean { + return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; +} + +function findMatch( + base: MergeAnnotation, + candidates: readonly MergeAnnotation[], + used: Set, + mode: AnnotationMergeMode, +): number { + const available = (predicate: (candidate: MergeAnnotation) => boolean): number => { + for (let index = 0; index < candidates.length; index++) { + if (!used.has(index) && predicate(candidates[index])) return index; + } + return -1; + }; + + if (mode === 'template') { + const byId = available(candidate => candidate.id === base.id); + if (byId >= 0) return byId; + } + + if (mode !== 'template') { + const key = categoryKey(base); + const byCategory = available(candidate => categoryKey(candidate) === key); + if (byCategory >= 0) return byCategory; + } + + if (mode === 'template') { + const byGeometry = available(candidate => geometryEqual(candidate, base)); + if (byGeometry >= 0) return byGeometry; + } + + return -1; +} + +function mergeExisting( + key: string, + base: MergeAnnotation, + local: MergeAnnotation | undefined, + external: MergeAnnotation | undefined, +): { annotation?: MergeAnnotation; conflict?: AnnotationConflict } { + if (!local && !external) return {}; + if (!local) { + if (sameAnnotation(base, external)) return {}; + return { + conflict: { + key, + kind: 'delete-modify', + fields: FIELDS.filter(field => external && !sameValue(base[field], external[field])), + base: cloneAnnotation(base), + external: cloneAnnotation(external), + }, + }; + } + if (!external) { + if (sameAnnotation(base, local)) return {}; + return { + annotation: cloneAnnotation(local), + conflict: { + key, + kind: 'delete-modify', + fields: FIELDS.filter(field => !sameValue(base[field], local[field])), + base: cloneAnnotation(base), + local: cloneAnnotation(local), + }, + }; + } + + if (sameAnnotation(local, external)) return { annotation: cloneAnnotation(local) }; + if (sameAnnotation(base, local)) return { annotation: cloneAnnotation(external) }; + if (sameAnnotation(base, external)) return { annotation: cloneAnnotation(local) }; + + const merged: MergeAnnotation = { ...local }; + const conflicts: AnnotationConflict['fields'] = []; + for (const field of FIELDS) { + const baseValue = base[field]; + const localValue = local[field]; + const externalValue = external[field]; + if (sameValue(localValue, externalValue)) merged[field] = localValue as never; + else if (sameValue(localValue, baseValue)) merged[field] = externalValue as never; + else if (sameValue(externalValue, baseValue)) merged[field] = localValue as never; + else conflicts.push(field); + } + + return conflicts.length + ? { + annotation: merged, + conflict: { + key, + kind: 'modify-modify', + fields: conflicts, + base: cloneAnnotation(base), + local: cloneAnnotation(local), + external: cloneAnnotation(external), + }, + } + : { annotation: merged }; +} + +function additionMatch( + value: MergeAnnotation, + candidates: readonly MergeAnnotation[], + used: Set, + mode: AnnotationMergeMode, +): number { + for (let index = 0; index < candidates.length; index++) { + if (used.has(index)) continue; + const candidate = candidates[index]; + if (categoryKey(candidate) === categoryKey(value)) return index; + } + if (mode === 'template') { + for (let index = 0; index < candidates.length; index++) { + if (used.has(index)) continue; + const candidate = candidates[index]; + if (candidate.id === value.id || geometryEqual(candidate, value)) return index; + } + } + return -1; +} + +/** + * Locate the exact merge-owned slot represented by one conflict. + * The merge phase records this identity before branch-local ids can collide. + */ +export function findAnnotationConflictItemIndex( + _mode: AnnotationMergeMode, + annotations: readonly MergeAnnotation[], + conflict: AnnotationConflict, +): number { + const index = conflict.mergedIndex; + return index !== undefined && index >= 0 && index < annotations.length ? index : -1; +} + +/** + * Three-way merge for one image and one annotation mode. + * + * `base` is the last canonical snapshot shown by the editor, `local` is the + * current in-editor state, and `external` is the newest valid on-disk state. + * Non-overlapping edits are merged automatically. True collisions are returned + * separately and the merged list deliberately prefers the local candidate for + * conflicting fields so callers can keep editing without losing either side. + */ +export function mergeAnnotations( + mode: AnnotationMergeMode, + base: readonly MergeAnnotation[], + local: readonly MergeAnnotation[], + external: readonly MergeAnnotation[], +): AnnotationMergeResult { + const merged: MergeAnnotation[] = []; + const conflicts: AnnotationConflict[] = []; + const usedLocal = new Set(); + const usedExternal = new Set(); + + for (const baseItem of base) { + const localIndex = findMatch(baseItem, local, usedLocal, mode); + const externalIndex = findMatch(baseItem, external, usedExternal, mode); + if (localIndex >= 0) usedLocal.add(localIndex); + if (externalIndex >= 0) usedExternal.add(externalIndex); + const localItem = localIndex >= 0 ? local[localIndex] : undefined; + const externalItem = externalIndex >= 0 ? external[externalIndex] : undefined; + const result = mergeExisting( + mode === 'template' ? `template:${baseItem.id}:${baseItem.category}` : `${mode}:${categoryKey(baseItem)}`, + baseItem, + localItem, + externalItem, + ); + let mergedIndex: number | undefined; + if (result.annotation) { + merged.push(result.annotation); + mergedIndex = merged.length - 1; + } + if (result.conflict) conflicts.push({ ...result.conflict, mergedIndex }); + } + + for (let localIndex = 0; localIndex < local.length; localIndex++) { + if (usedLocal.has(localIndex)) continue; + const localItem = local[localIndex]; + const externalIndex = additionMatch(localItem, external, usedExternal, mode); + if (externalIndex < 0) { + merged.push({ ...localItem }); + continue; + } + usedExternal.add(externalIndex); + const externalItem = external[externalIndex]; + if (sameAnnotation(localItem, externalItem)) { + merged.push({ ...localItem }); + continue; + } + merged.push({ ...localItem }); + conflicts.push({ + key: mode === 'template' + ? `template:new:${localIndex}:${categoryKey(localItem)}` + : `${mode}:${categoryKey(localItem)}`, + kind: 'add-add', + fields: FIELDS.filter(field => !sameValue(localItem[field], externalItem[field])), + local: cloneAnnotation(localItem), + external: cloneAnnotation(externalItem), + mergedIndex: merged.length - 1, + }); + } + + for (let externalIndex = 0; externalIndex < external.length; externalIndex++) { + if (!usedExternal.has(externalIndex)) merged.push({ ...external[externalIndex] }); + } + + conflicts.sort((a, b) => (b.mergedIndex ?? -1) - (a.mergedIndex ?? -1)); + return { merged, conflicts }; +} diff --git a/src/annotationPanel.ts b/src/annotationPanel.ts index 2119505..61415a2 100644 --- a/src/annotationPanel.ts +++ b/src/annotationPanel.ts @@ -2,36 +2,110 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { TemplateAssetData } from './templateAssetData'; -import { addBox, pixelUnionFromAnnotations, readAuthoringFile, boxNamesError } from './boxResourceStore'; +import { readAuthoringFile, boxNamesError } from './boxResourceStore'; import { AUTHORING_FILE_NAME, BOX_PATH_SEGMENT_SOURCE } from './boxResourcePure'; import { onAnnotationDataChanged, sameAnnotationFile } from './cocoAnnotationData'; -import { readImageSize } from './pngCrop'; -import { injectWebviewLocalization, tr } from './localization'; +import { tr } from './localization'; +import { injectAnnotationWebviewLocalization } from './annotationLocalization'; import { applySharedAssets, getNonce } from './webviewHtml'; -import { POINT_AUTHORING_FILE, pointPathOccupancy, pointsForImage, savePointsForImage } from './pointResourceStore'; +import { POINT_AUTHORING_FILE, pointPathOccupancy, readPoints, savePointsForImageIfRevision } from './pointResourceStore'; +import { + AnnotationConflict, + MergeAnnotation, + findAnnotationConflictItemIndex, + mergeAnnotations, +} from './annotationMergePure'; export type UnifiedAnnotationMode = 'template' | 'rect' | 'point'; -interface Annotation { - id: number; - category: string; - x: number; - y: number; - w: number; - h: number; +interface Annotation extends MergeAnnotation {} + +interface ModeSnapshot { + annotations: Annotation[]; + allCategories: Record; + revision: string | undefined; + errors: string[]; +} + +interface ConflictChoice { + key: string; + choice: 'local' | 'external'; } +interface PendingConflictSession { + sessionId: number; + imagePath: string; + mode: UnifiedAnnotationMode; + externalRevision: string | undefined; + externalBase: Annotation[]; + local: Annotation[]; + merged: Annotation[]; + conflicts: AnnotationConflict[]; +} + +interface PreparedAnnotationSave { + annotations: Annotation[]; + mergedExternal: boolean; + expectedRevision: string | undefined; +} + +interface PendingLoadRequest { + requestId: number; + generation: number; + imagePath: string; + mode: UnifiedAnnotationMode; + snapshot: ModeSnapshot; +} + +type AnnotationWriteResult = 'saved' | 'changed' | 'failed'; + function normalizeAnnotationMode(value: UnifiedAnnotationMode | boolean): UnifiedAnnotationMode { if (value === true) return 'rect'; if (value === false) return 'template'; return value; } +function cloneAnnotations(values: readonly Annotation[]): Annotation[] { + return values.map(value => ({ ...value })); +} + +function annotationContentSignature(value: Annotation): string { + return JSON.stringify([value.category, value.x, value.y, value.w, value.h]); +} + +function annotationContentsEqual(a: readonly Annotation[], b: readonly Annotation[]): boolean { + if (a.length !== b.length) return false; + const left = a.map(annotationContentSignature).sort(); + const right = b.map(annotationContentSignature).sort(); + return left.every((value, index) => value === right[index]); +} + class AnnotationController { private generation = 0; private disposed = false; private saving = false; - private sourceRevision: string | undefined; + private conflictSessionSequence = 0; + private loadRequestSequence = 0; + private resolvingConflictSessionId: number | undefined; + private pendingConflictSession: PendingConflictSession | undefined; + private readonly pendingLoadRequests = new Map(); + private readonly editorVersions = new Map(); + private readonly sourceRevisions: Record = { + template: undefined, + rect: undefined, + point: undefined, + }; + private readonly baseSnapshots: Record = { + template: [], + rect: [], + point: [], + }; + private readonly localSnapshots: Record = { + template: [], + rect: [], + point: [], + }; + private readonly pendingExternal = new Set(); private readonly disposables: vscode.Disposable[] = []; private _currentImage: string | undefined; private _imageList: string[] = []; @@ -55,11 +129,17 @@ class AnnotationController { webview.onDidReceiveMessage((msg) => { void this.onMessage(msg); }), onAnnotationDataChanged(file => { if (this.saving || !this._currentImage) return; - if (this.activeSourceMatches(file)) { - let revision: string | undefined; - try { revision = fs.readFileSync(file, 'utf8'); } catch { /* deleted or unreadable source */ } - if (revision !== this.sourceRevision) this.reloadIfShowing([this._currentImage]); - } else if (this.isPositionSource(file)) { + const changedMode = this.modeForSource(file); + if (changedMode) { + const revision = this.readSourceRevision(changedMode); + if (revision !== this.sourceRevisions[changedMode]) { + this.pendingExternal.add(changedMode); + if (changedMode === this.mode) { + void this.webview.postMessage({ type: 'externalSourceChanged', mode: changedMode }); + } + } + } + if (this.isPositionSource(file)) { const positionPaths = this.positionOccupancy(); void this.webview.postMessage({ type: 'positionPaths', positionPaths }); // Transitional alias for already-open pre-unification webviews/tests. @@ -78,6 +158,28 @@ class AnnotationController { get root(): string { return this.templateData.root; } get currentImage(): string | undefined { return this._currentImage; } + private editorStateKey(imagePath: string, mode: UnifiedAnnotationMode): string { + return `${imagePath}\n${mode}`; + } + + private noteEditorVersion(imagePath: string, mode: UnifiedAnnotationMode, editorVersion?: number): void { + if (!Number.isInteger(editorVersion) || editorVersion! < 0) return; + const key = this.editorStateKey(imagePath, mode); + this.editorVersions.set(key, Math.max(this.editorVersions.get(key) ?? 0, editorVersion!)); + } + + private resetMergeState(): void { + for (const mode of ['template', 'rect', 'point'] as const) { + this.sourceRevisions[mode] = undefined; + this.baseSnapshots[mode] = []; + this.localSnapshots[mode] = []; + } + this.pendingLoadRequests.clear(); + this.pendingExternal.clear(); + this.pendingConflictSession = undefined; + this.resolvingConflictSessionId = undefined; + } + private dataForMode(mode = this.mode): TemplateAssetData | undefined { if (mode === 'template') return this.templateData; if (mode === 'rect') return this.boxData; @@ -89,10 +191,11 @@ class AnnotationController { return data?.annotationFile ?? path.join(this.templateData.templatesDir, POINT_AUTHORING_FILE); } - private activeSourceFile(): string { return this.sourceFileForMode(this.mode); } - - private activeSourceMatches(file: string): boolean { - return sameAnnotationFile(file, this.activeSourceFile()); + private modeForSource(file: string): UnifiedAnnotationMode | undefined { + for (const mode of ['template', 'rect', 'point'] as const) { + if (sameAnnotationFile(file, this.sourceFileForMode(mode))) return mode; + } + return undefined; } private isPositionSource(file: string): boolean { @@ -100,9 +203,146 @@ class AnnotationController { || sameAnnotationFile(file, path.join(this.templateData.templatesDir, POINT_AUTHORING_FILE)); } + private readSourceRevision(mode: UnifiedAnnotationMode): string | undefined { + try { return fs.readFileSync(this.sourceFileForMode(mode), 'utf8'); } + catch { return undefined; } + } + + private snapshotForMode(imagePath: string, mode: UnifiedAnnotationMode): ModeSnapshot { + const activeData = this.dataForMode(mode); + if (activeData) { + activeData.load(); + const annotations = activeData.getAnnotationsForImage(imagePath, true).map(a => ({ + id: a.id, + category: a.categoryName, + x: a.bbox[0], y: a.bbox[1], w: a.bbox[2], h: a.bbox[3], + })); + const allCategories: Record = {}; + for (const img of activeData.data.images) { + const imgPath = path.join(activeData.templatesDir, img.file_name); + if (imgPath === imagePath) continue; + for (const name of activeData.getCategoriesForImage(imgPath)) allCategories[name] = img.file_name; + } + return { + annotations, + allCategories, + revision: activeData.revision, + errors: [...activeData.readErrors], + }; + } + + const result = readPoints(this.root, path.relative(this.root, this.templateData.templatesDir)); + const imageName = path.basename(imagePath); + const own = result.file.points.filter(point => point.image.toLowerCase() === imageName.toLowerCase()); + const annotations = own.map((point, index) => ({ + id: index + 1, + category: point.path, + x: point.x, + y: point.y, + w: 0, + h: 0, + })); + const ownNames = new Set(own.map(point => point.path)); + const allCategories = Object.fromEntries( + result.file.points.filter(point => !ownNames.has(point.path)).map(point => [point.path, point.image]), + ); + return { + annotations, + allCategories, + revision: this.readSourceRevision(mode), + errors: [...result.errors], + }; + } + + private showExternalChangeNotice(): void { + void vscode.window.showWarningMessage(tr('Annotations changed while confirming. Retry the swap.')); + } + + private commitAcceptedLoad(request: PendingLoadRequest): void { + if (this.disposed || request.generation !== this.generation + || this._currentImage !== request.imagePath || this.mode !== request.mode) return; + const { snapshot, mode, imagePath } = request; + this.sourceRevisions[mode] = snapshot.revision; + this.baseSnapshots[mode] = cloneAnnotations(snapshot.annotations); + this.localSnapshots[mode] = cloneAnnotations(snapshot.annotations); + if (this.readSourceRevision(mode) === snapshot.revision) { + this.pendingExternal.delete(mode); + } else { + this.pendingExternal.add(mode); + void this.webview.postMessage({ type: 'externalSourceChanged', mode }); + } + if (this.pendingConflictSession?.mode === mode && this.pendingConflictSession.imagePath === imagePath) { + this.pendingConflictSession = undefined; + } + } + + private async processEditorSave( + imagePath: string | undefined, + mode: UnifiedAnnotationMode | undefined, + annotations: Annotation[] | undefined, + editorVersion?: number, + ): Promise { + if (!imagePath || !mode || !annotations) return; + this.noteEditorVersion(imagePath, mode, editorVersion); + let saved = false; + try { + if (imagePath === this._currentImage) { + saved = await this.persistAnnotationsForMode(imagePath, mode, annotations); + if (saved) this.onSaved(imagePath); + } + } finally { + await this.webview.postMessage({ + type: 'annotationSaveProcessed', + imagePath, + mode, + editorVersion, + saved, + }); + } + } + + private async handleExternalEditorState( + mode: UnifiedAnnotationMode, + transient: boolean, + localAnnotations?: readonly Annotation[], + ): Promise { + if (this.disposed || !this._currentImage || mode !== this.mode || !this.pendingExternal.has(mode)) return; + if (transient) { + if (this.isVisible()) this.showExternalChangeNotice(); + return; + } + if (this.pendingConflictSession?.mode === mode) return; + + const imagePath = this._currentImage; + if (!localAnnotations) { + if (this.isVisible()) this.showExternalChangeNotice(); + return; + } + if (!annotationContentsEqual(localAnnotations, this.baseSnapshots[mode])) { + const saved = await this.persistAnnotationsForMode(imagePath, mode, cloneAnnotations(localAnnotations)); + if (saved) this.onSaved(imagePath); + return; + } + + const external = this.snapshotForMode(imagePath, mode); + if (external.errors.length) { + if (this.isVisible()) { + void vscode.window.showErrorMessage(tr('The annotation source is invalid. Fix the source file before saving or exporting.')); + } + return; + } + if (external.revision === this.sourceRevisions[mode]) { + this.pendingExternal.delete(mode); + return; + } + + await this.loadImage(imagePath); + } + reloadIfShowing(imagePaths: readonly string[]): void { if (this.disposed || !this._currentImage || !imagePaths.includes(this._currentImage)) return; - void this.loadImage(this._currentImage); + this.pendingExternal.add(this.mode); + void this.webview.postMessage({ type: 'externalSourceChanged', mode: this.mode }); } attachHtml(): void { @@ -129,6 +369,7 @@ class AnnotationController { } open(imagePath: string, imageList: string[], mode?: UnifiedAnnotationMode | boolean): void { + if (this._currentImage && this._currentImage !== imagePath) this.resetMergeState(); if (mode !== undefined) this.mode = normalizeAnnotationMode(mode); this._imageList = [...imageList]; this._currentImage = imagePath; @@ -138,16 +379,11 @@ class AnnotationController { private async loadImage(imagePath: string): Promise { if (this.disposed) return; + if (this._currentImage && this._currentImage !== imagePath) this.resetMergeState(); this._currentImage = imagePath; const generation = ++this.generation; - const activeData = this.dataForMode(); - if (activeData) { - activeData.load(); - this.sourceRevision = activeData.revision; - } else { - try { this.sourceRevision = fs.readFileSync(this.activeSourceFile(), 'utf8'); } - catch { this.sourceRevision = undefined; } - } + const loadingMode = this.mode; + const snapshot = this.snapshotForMode(imagePath, loadingMode); let imageBase64 = ''; try { @@ -161,45 +397,23 @@ class AnnotationController { } catch { imageBase64 = ''; } if (this.disposed || generation !== this.generation) return; - let annotations: Annotation[] = []; - let allCategories: Record = {}; - if (activeData) { - const source = activeData.getAnnotationsForImage(imagePath, true); - annotations = source.map(a => ({ - id: a.id, - category: a.categoryName, - x: a.bbox[0], y: a.bbox[1], w: a.bbox[2], h: a.bbox[3], - })); - for (const img of activeData.data.images) { - const imgPath = path.join(activeData.templatesDir, img.file_name); - if (imgPath === imagePath) continue; - for (const name of activeData.getCategoriesForImage(imgPath)) allCategories[name] = img.file_name; - } - } else { - const points = pointsForImage(this.root, this.templateData.templatesDir, path.basename(imagePath)); - annotations = points.map((point, index) => ({ - id: index + 1, - category: point.path, - x: point.x, - y: point.y, - w: 0, - h: 0, - })); - allCategories = pointPathOccupancy(this.root, this.templateData.templatesDir); - for (const point of points) delete allCategories[point.path]; - } - const positionPaths = this.positionOccupancy(); + const requestId = ++this.loadRequestSequence; + const expectedEditorVersion = this.editorVersions.get(this.editorStateKey(imagePath, loadingMode)) ?? 0; + this.pendingLoadRequests.clear(); + this.pendingLoadRequests.set(requestId, { requestId, generation, imagePath, mode: loadingMode, snapshot }); await this.webview.postMessage({ type: 'load', + loadRequestId: requestId, + expectedEditorVersion, imagePath, imageBase64, - annotations, - allCategories, + annotations: snapshot.annotations, + allCategories: snapshot.allCategories, positionPaths, boxPaths: positionPaths, - annotationMode: this.mode, - pointMode: this.mode === 'point', + annotationMode: loadingMode, + pointMode: loadingMode === 'point', currentIndex: this._imageList.indexOf(imagePath), totalImages: this._imageList.length, filename: path.basename(imagePath), @@ -217,13 +431,18 @@ class AnnotationController { private async onMessage(msg: { type?: string; mode?: UnifiedAnnotationMode; + imagePath?: string; + editorVersion?: number; + loadRequestId?: number; + pendingSaves?: number; annotation?: Annotation; annotations?: Annotation[]; index?: number; category?: string; text?: string; - path?: string; - boxes?: Array<{ x: number; y: number; w: number; h: number }>; + choices?: ConflictChoice[]; + conflictSessionId?: number; + transient?: boolean; }): Promise { switch (msg.type) { case 'ready': @@ -238,31 +457,40 @@ class AnnotationController { } break; case 'save': - if (this._currentImage && msg.annotations && this.persistAnnotationsForMode(this._currentImage, this.mode, msg.annotations)) { - this.onSaved(this._currentImage); - } - break; case 'saveMode': - if (this._currentImage && msg.mode && msg.annotations - && this.persistAnnotationsForMode(this._currentImage, msg.mode, msg.annotations)) { - this.onSaved(this._currentImage); + await this.processEditorSave(msg.imagePath, msg.mode, msg.annotations, msg.editorVersion); + break; + case 'externalEditorState': + if (msg.imagePath === this._currentImage && msg.mode && typeof msg.transient === 'boolean') { + await this.handleExternalEditorState(msg.mode, msg.transient, msg.annotations); } break; - case 'generateBox': { - if (!this._currentImage || !msg.path || !msg.boxes?.length) { - void this.webview.postMessage({ type: 'generateBoxResult', ok: false, error: 'path' }); - break; + case 'loadAccepted': { + if (!msg.loadRequestId || !msg.imagePath || !msg.mode) break; + const request = this.pendingLoadRequests.get(msg.loadRequestId); + if (!request || request.imagePath !== msg.imagePath || request.mode !== msg.mode) break; + this.pendingLoadRequests.delete(msg.loadRequestId); + this.noteEditorVersion(msg.imagePath, msg.mode, msg.editorVersion); + this.commitAcceptedLoad(request); + break; + } + case 'loadRejected': { + if (!msg.loadRequestId || !msg.imagePath || !msg.mode) break; + const request = this.pendingLoadRequests.get(msg.loadRequestId); + if (!request || request.imagePath !== msg.imagePath || request.mode !== msg.mode) break; + this.pendingLoadRequests.delete(msg.loadRequestId); + this.noteEditorVersion(msg.imagePath, msg.mode, msg.editorVersion); + if ((msg.pendingSaves ?? 0) === 0 && this._currentImage === msg.imagePath && this.mode === msg.mode) { + await this.loadImage(msg.imagePath); } - let size: { width: number; height: number } | undefined; - try { size = readImageSize(fs.readFileSync(this._currentImage)); } catch { size = undefined; } - const union = size ? pixelUnionFromAnnotations(msg.boxes) : undefined; - const error = union - ? addBox(this.root, path.relative(this.root, this.templateData.templatesDir), msg.path, path.basename(this._currentImage), union) - : 'image'; - void this.webview.postMessage({ type: 'generateBoxResult', ok: !error, error: error || '' }); - if (!error) this.onSaved(this._currentImage); break; } + case 'retryLoad': + if (msg.imagePath === this._currentImage && msg.mode === this.mode) await this.loadImage(msg.imagePath); + break; + case 'resolveAnnotationConflicts': + await this.resolveAnnotationConflicts(msg.choices || [], msg.conflictSessionId); + break; case 'navigate': if (msg.index !== undefined && msg.index >= 0 && msg.index < this._imageList.length) await this.loadImage(this._imageList[msg.index]); break; @@ -286,48 +514,256 @@ class AnnotationController { } } - private persistAnnotationsForMode(imagePath: string, mode: UnifiedAnnotationMode, annotations: Annotation[]): boolean { + private applyExternalConflictChoice( + mode: UnifiedAnnotationMode, + annotations: Annotation[], + conflict: AnnotationConflict, + ): void { + const index = findAnnotationConflictItemIndex(mode, annotations, conflict); + if (!conflict.external) { + if (index >= 0) annotations.splice(index, 1); + return; + } + if (index < 0) { + annotations.push({ ...conflict.external }); + return; + } + for (const field of conflict.fields) { + annotations[index][field] = conflict.external[field] as never; + } + } + + private async postConflictSession(session: PendingConflictSession): Promise { + await this.webview.postMessage({ + type: 'annotationConflicts', + conflictSessionId: session.sessionId, + annotationMode: session.mode, + base: cloneAnnotations(this.baseSnapshots[session.mode]), + local: cloneAnnotations(session.local), + external: cloneAnnotations(session.externalBase), + merged: cloneAnnotations(session.merged), + conflicts: session.conflicts, + }); + } + + private async resolveAnnotationConflicts( + choices: readonly ConflictChoice[], + conflictSessionId?: number, + ): Promise { + const session = this.pendingConflictSession; + if (!session || conflictSessionId !== session.sessionId) return; + if (this.resolvingConflictSessionId === session.sessionId) return; + const choiceMap = new Map(choices.map(choice => [choice.key, choice.choice])); + if (session.conflicts.some(conflict => !choiceMap.has(conflict.key))) return; + + this.resolvingConflictSessionId = session.sessionId; + const resolutionGeneration = ++this.generation; + const sessionStillCurrent = (): boolean => !this.disposed + && this.generation === resolutionGeneration + && this._currentImage === session.imagePath + && this.mode === session.mode; + try { + // The file may have changed again while the user was inspecting the two candidates. + // Never apply decisions against a stale external revision; rebuild the merge instead. + if (this.readSourceRevision(session.mode) !== session.externalRevision) { + this.pendingConflictSession = undefined; + this.pendingExternal.add(session.mode); + await this.webview.postMessage({ type: 'clearAnnotationConflicts', conflictSessionId: session.sessionId }); + if (!sessionStillCurrent()) return; + const saved = await this.persistAnnotationsForMode(session.imagePath, session.mode, session.local); + if (!saved) return; + this.onSaved(session.imagePath); + return; + } + + const resolved = cloneAnnotations(session.merged); + for (const conflict of session.conflicts) { + if (choiceMap.get(conflict.key) === 'external') { + this.applyExternalConflictChoice(session.mode, resolved, conflict); + } + } + + const outcome = await this.writePreparedAnnotations(session.imagePath, session.mode, { + annotations: resolved, + mergedExternal: true, + expectedRevision: session.externalRevision, + }); + if (!sessionStillCurrent()) return; + if (outcome === 'changed') { + this.pendingConflictSession = undefined; + this.pendingExternal.add(session.mode); + await this.webview.postMessage({ type: 'clearAnnotationConflicts', conflictSessionId: session.sessionId }); + if (!sessionStillCurrent()) return; + const saved = await this.persistAnnotationsForMode(session.imagePath, session.mode, session.local); + if (saved) this.onSaved(session.imagePath); + return; + } + if (outcome !== 'saved') { + await this.webview.postMessage({ type: 'conflictResolutionFailed', conflictSessionId: session.sessionId }); + return; + } + + this.onSaved(session.imagePath); + if (this._currentImage === session.imagePath && this.mode === session.mode) { + await this.loadImage(session.imagePath); + } + } finally { + if (this.resolvingConflictSessionId === session.sessionId) this.resolvingConflictSessionId = undefined; + } + } + + private async mergeExternalBeforeSave( + imagePath: string, + mode: UnifiedAnnotationMode, + local: readonly Annotation[], + ): Promise { + const diskRevision = this.readSourceRevision(mode); + if (!this.pendingExternal.has(mode) && diskRevision === this.sourceRevisions[mode]) { + return { + annotations: cloneAnnotations(local), + mergedExternal: false, + expectedRevision: diskRevision, + }; + } + + const external = this.snapshotForMode(imagePath, mode); + if (external.errors.length) { + void vscode.window.showErrorMessage(tr('Could not save annotations.')); + this.pendingExternal.add(mode); + return undefined; + } + + if (external.revision === this.sourceRevisions[mode]) { + this.pendingExternal.delete(mode); + return { + annotations: cloneAnnotations(local), + mergedExternal: false, + expectedRevision: external.revision, + }; + } + + const result = mergeAnnotations(mode, this.baseSnapshots[mode], local, external.annotations); + if (result.conflicts.length) { + this.pendingExternal.add(mode); + const session: PendingConflictSession = { + sessionId: ++this.conflictSessionSequence, + imagePath, + mode, + externalRevision: external.revision, + externalBase: cloneAnnotations(external.annotations), + local: cloneAnnotations(local), + merged: cloneAnnotations(result.merged as Annotation[]), + conflicts: result.conflicts, + }; + this.pendingConflictSession = session; + await this.postConflictSession(session); + return undefined; + } + + // Preparing a merge must not advance the accepted base. The external + // snapshot is committed only after persistence and canonical reread succeed. + return { + annotations: cloneAnnotations(result.merged as Annotation[]), + mergedExternal: true, + expectedRevision: external.revision, + }; + } + + private async writePreparedAnnotations( + imagePath: string, + mode: UnifiedAnnotationMode, + prepared: PreparedAnnotationSave, + ): Promise { + const annotations = prepared.annotations; try { this.saving = true; if (mode === 'point') { - const error = savePointsForImage( + const error = savePointsForImageIfRevision( this.root, path.relative(this.root, this.templateData.templatesDir), imagePath, annotations.map(ann => ({ path: ann.category.trim(), x: ann.x, y: ann.y })), + prepared.expectedRevision, ); + if (error === 'changed') { + this.pendingExternal.add(mode); + return 'changed'; + } if (error) throw new Error(error); - if (mode === this.mode) { - try { this.sourceRevision = fs.readFileSync(this.sourceFileForMode(mode), 'utf8'); } - catch { this.sourceRevision = undefined; } + } else { + const data = this.dataForMode(mode)!; + data.load(); + if (data.readErrors.length) throw new Error('parse'); + if (data.revision !== prepared.expectedRevision) { + this.pendingExternal.add(mode); + return 'changed'; + } + const mapped = annotations.map(ann => ({ + category: mode === 'rect' ? ann.category.trim() : ann.category, + x: ann.x, y: ann.y, w: ann.w, h: ann.h, + })); + const namesError = mode === 'rect' ? boxNamesError(data, imagePath, mapped.map(ann => ann.category)) : undefined; + if (namesError) throw new Error(namesError); + if (!data.setAnnotationsForImage(imagePath, mapped)) throw new Error('geometry'); + if (!data.saveIfRevision(prepared.expectedRevision)) { + this.pendingExternal.add(mode); + return 'changed'; } - return true; } - const data = this.dataForMode(mode)!; - data.load(); - const mapped = annotations.map(ann => ({ - category: mode === 'rect' ? ann.category.trim() : ann.category, - x: ann.x, y: ann.y, w: ann.w, h: ann.h, - })); - const namesError = mode === 'rect' ? boxNamesError(data, imagePath, mapped.map(ann => ann.category)) : undefined; - if (namesError) throw new Error(namesError); - if (!data.setAnnotationsForImage(imagePath, mapped)) throw new Error('geometry'); - data.save(); - if (mode === this.mode) this.sourceRevision = data.revision; - return true; + const canonical = this.snapshotForMode(imagePath, mode); + if (canonical.errors.length) throw new Error('canonical'); + if (this._currentImage === imagePath) { + this.sourceRevisions[mode] = canonical.revision; + this.baseSnapshots[mode] = cloneAnnotations(canonical.annotations); + this.localSnapshots[mode] = cloneAnnotations(canonical.annotations); + this.pendingExternal.delete(mode); + if (this.pendingConflictSession?.mode === mode && this.pendingConflictSession.imagePath === imagePath) { + this.pendingConflictSession = undefined; + } + } else { + this.pendingExternal.add(mode); + if (mode === this.mode) void this.webview.postMessage({ type: 'externalSourceChanged', mode }); + } + return 'saved'; } catch (error) { console.error('[ok-script] save annotations:', error); void vscode.window.showErrorMessage(tr('Could not save annotations.')); - return false; + return 'failed'; } finally { this.saving = false; } } + private async persistAnnotationsForMode( + imagePath: string, + mode: UnifiedAnnotationMode, + localAnnotations: Annotation[], + ): Promise { + // Any save attempt supersedes a pending load. Otherwise a load that sampled + // older annotations before its async image read can overwrite a newer edit. + this.generation++; + for (let attempt = 0; attempt < 2; attempt++) { + const prepared = await this.mergeExternalBeforeSave(imagePath, mode, localAnnotations); + if (!prepared) return false; + const outcome = await this.writePreparedAnnotations(imagePath, mode, prepared); + if (outcome === 'saved') { + if (prepared.mergedExternal && this._currentImage === imagePath && mode === this.mode) { + await this.loadImage(imagePath); + } + return true; + } + if (outcome === 'failed') return false; + this.pendingExternal.add(mode); + } + if (this.isVisible()) this.showExternalChangeNotice(); + return false; + } + dispose(): void { this.disposed = true; this.generation++; + this.pendingLoadRequests.clear(); for (const d of this.disposables) d.dispose(); this.disposables.length = 0; } @@ -393,11 +829,14 @@ export function annotationHtml(cspSource: string, extensionUri: vscode.Uri, webv const file = path.join(extensionUri.fsPath, 'media', 'annotationPanel', 'index.html'); const nonce = getNonce(); const resource = (name: string) => webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'media', 'annotationPanel', name)).toString(true); - return injectWebviewLocalization(applySharedAssets(webview, extensionUri, + return injectAnnotationWebviewLocalization(applySharedAssets(webview, extensionUri, fs.readFileSync(file, 'utf-8') .split('__CSP_NONCE__').join(nonce) .split('__CSP_SOURCE__').join(cspSource) .split('__STYLE_URI__').join(resource('style.css')) - .split('__APP_SCRIPT_URI__').join(resource('app.js')), + .split('__CONFLICT_STYLE_URI__').join(resource('conflict.css')) + .split('__CONFLICT_SCRIPT_URI__').join(resource('conflict.js')) + .split('__APP_SCRIPT_URI__').join(resource('app.js')) + .split('__EXTERNAL_SYNC_SCRIPT_URI__').join(resource('externalSync.js')), )); } diff --git a/src/cocoAnnotationData.ts b/src/cocoAnnotationData.ts index f9e12bb..785a1b0 100644 --- a/src/cocoAnnotationData.ts +++ b/src/cocoAnnotationData.ts @@ -146,18 +146,101 @@ export function notifyAnnotationDataChanged(file: string): void { } } -export function writeAnnotationText(file: string, text: string, notify = true): void { +function renameWithRetry(source: string, target: string): void { + for (let attempt = 0; ; attempt++) { + try { fs.renameSync(source, target); return; } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (attempt >= 3 || !['EPERM', 'EACCES', 'EBUSY'].includes(code ?? '')) throw error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + } +} + +function linkPreparedFile(temp: string, file: string): boolean { + try { + fs.linkSync(temp, file); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EEXIST') return false; + if (!['EPERM', 'ENOTSUP', 'EOPNOTSUPP'].includes(code ?? '')) throw error; + } + try { + fs.copyFileSync(temp, file, fs.constants.COPYFILE_EXCL); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false; + throw error; + } +} + +/** + * Replace an annotation file only if the bytes being replaced still equal expectedRevision. + * The current path is first moved aside atomically, then the moved bytes are verified. A fully + * written temp file is installed with exclusive-create semantics, so a writer that recreates the + * path during the handoff wins and this write reports a revision change instead of overwriting it. + */ +export function writeAnnotationTextIfUnchanged( + file: string, + text: string, + expectedRevision: string | undefined, + notify = true, +): boolean { fs.mkdirSync(path.dirname(file), { recursive: true }); - const temp = path.join(path.dirname(file), '.' + path.basename(file) + '.' + randomUUID() + '.tmp'); + const token = randomUUID(); + const temp = path.join(path.dirname(file), '.' + path.basename(file) + '.' + token + '.tmp'); + const previous = path.join(path.dirname(file), '.' + path.basename(file) + '.' + token + '.previous'); + let movedPrevious = false; + let saved = false; + let operationError: unknown; try { fs.writeFileSync(temp, text, 'utf8'); - for (let attempt = 0; ; attempt++) { - try { fs.renameSync(temp, file); break; } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (attempt >= 3 || !['EPERM', 'EACCES', 'EBUSY'].includes(code ?? '')) throw error; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + if (expectedRevision === undefined) { + saved = linkPreparedFile(temp, file); + } else { + try { + renameWithRetry(file, previous); + movedPrevious = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (movedPrevious && fs.readFileSync(previous, 'utf8') === expectedRevision) { + saved = linkPreparedFile(temp, file); } } + } catch (error) { + operationError = error; + } + + let recoveryError: unknown; + let removePrevious = !movedPrevious || fs.existsSync(file); + if (movedPrevious && !removePrevious) { + try { + const restored = linkPreparedFile(previous, file); + removePrevious = restored || fs.existsSync(file); + if (!removePrevious) recoveryError = new Error('Could not restore previous annotation file'); + } catch (error) { + recoveryError = error; + removePrevious = false; + } + } + if (removePrevious) { + try { fs.rmSync(previous, { force: true }); } catch { /* preserve the write/recovery error */ } + } + try { fs.rmSync(temp, { force: true }); } catch { /* preserve the write/recovery error */ } + + if (operationError) throw operationError; + if (recoveryError) throw recoveryError; + if (saved && notify) notifyAnnotationDataChanged(file); + return saved; +} + +export function writeAnnotationText(file: string, text: string, notify = true): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temp = path.join(path.dirname(file), '.' + path.basename(file) + '.' + randomUUID() + '.tmp'); + try { + fs.writeFileSync(temp, text, 'utf8'); + renameWithRetry(temp, file); } finally { try { fs.rmSync(temp, { force: true }); } catch { /* preserve the write error */ } } @@ -236,6 +319,20 @@ export class CocoAnnotationData { notifyAnnotationDataChanged(this.cocoPath); } + saveIfRevision(expectedRevision: string | undefined): boolean { + if (this.readErrors.length) throw new Error('Unreadable annotation file: ' + this.readErrors.join(', ')); + if (this.legacy && expectedRevision !== undefined && fs.existsSync(this.cocoPath)) { + fs.copyFileSync(this.cocoPath, this.cocoPath + '.pre-coco.' + randomUUID() + '.bak', fs.constants.COPYFILE_EXCL); + } + const text = JSON.stringify(this.cocoData, null, 2); + if (!writeAnnotationTextIfUnchanged(this.cocoPath, text, expectedRevision, false)) return false; + this.loadedText = text; + this.legacy = false; + this._dirty = false; + notifyAnnotationDataChanged(this.cocoPath); + return true; + } + get data(): CocoData { return this.cocoData; } /* ---------- 图片列表 ---------- */ diff --git a/src/pointResourceStore.ts b/src/pointResourceStore.ts index 278fa79..09e2202 100644 --- a/src/pointResourceStore.ts +++ b/src/pointResourceStore.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { CocoData, writeAnnotationText } from './cocoAnnotationData'; +import { CocoData, writeAnnotationText, writeAnnotationTextIfUnchanged } from './cocoAnnotationData'; import { readImageSize } from './imageHeader'; import { positionPathError, PixelPoint, PositionAuthoringItem, PositionImage } from './positionResourcePure'; @@ -135,28 +135,26 @@ export function pointPathOccupancy(root: string, directory: string): Record [point.path, point.image])); } -export function savePointsForImage( - root: string, - directory: string, +function preparePointsForImage( + current: PointReadResult, imagePath: string, points: readonly { path: string; x: number; y: number }[], -): string | undefined { - const current = readPoints(root, directory); - if (current.errors.length) return 'parse'; +): { error?: string; text?: string } { + if (current.errors.length) return { error: 'parse' }; const imageName = path.basename(imagePath); let size; - try { size = readImageSize(fs.readFileSync(imagePath)); } catch { return 'image'; } - if (!size || size.width <= 0 || size.height <= 0) return 'image'; + try { size = readImageSize(fs.readFileSync(imagePath)); } catch { return { error: 'image' }; } + if (!size || size.width <= 0 || size.height <= 0) return { error: 'image' }; const own = new Set(current.file.points.filter(point => point.image.toLowerCase() === imageName.toLowerCase()).map(point => point.path)); const occupied = new Set(current.file.points.filter(point => !own.has(point.path)).map(point => point.path)); for (const point of points) { const name = point.path.trim(); - if (positionPathError(name)) return 'path'; - if (occupied.has(name)) return 'duplicate'; + if (positionPathError(name)) return { error: 'path' }; + if (occupied.has(name)) return { error: 'duplicate' }; occupied.add(name); if (!Number.isFinite(point.x) || !Number.isFinite(point.y) - || point.x < 0 || point.y < 0 || point.x > size.width || point.y > size.height) return 'geometry'; + || point.x < 0 || point.y < 0 || point.x > size.width || point.y > size.height) return { error: 'geometry' }; } current.file.points = current.file.points.filter(point => point.image.toLowerCase() !== imageName.toLowerCase()); @@ -165,14 +163,46 @@ export function savePointsForImage( if (image) { image.width = size.width; image.height = size.height; } else current.file.images.push({ file: imageName, width: size.width, height: size.height }); + return { text: JSON.stringify(toCoco(current.file), null, 2) + '\n' }; +} + +export function savePointsForImage( + root: string, + directory: string, + imagePath: string, + points: readonly { path: string; x: number; y: number }[], +): string | undefined { + const prepared = preparePointsForImage(readPoints(root, directory), imagePath, points); + if (prepared.error) return prepared.error; try { - writeAnnotationText(sourceFile(root, directory), JSON.stringify(toCoco(current.file), null, 2) + '\n'); + writeAnnotationText(sourceFile(root, directory), prepared.text!); return undefined; } catch { return 'write'; } } +export function savePointsForImageIfRevision( + root: string, + directory: string, + imagePath: string, + points: readonly { path: string; x: number; y: number }[], + expectedRevision: string | undefined, +): string | undefined { + const current = expectedRevision === undefined + ? { file: emptyFile(), errors: [] } + : parse(expectedRevision); + const prepared = preparePointsForImage(current, imagePath, points); + if (prepared.error) return prepared.error; + try { + return writeAnnotationTextIfUnchanged(sourceFile(root, directory), prepared.text!, expectedRevision) + ? undefined + : 'changed'; + } catch { + return 'write'; + } +} + export function pointAuthoringPositions(root: string, directory: string): { items: PositionAuthoringItem[]; images: PositionImage[]; errors: string[] } { const result = readPoints(root, directory); if (result.errors.length) return { items: [], images: [], errors: result.errors };