This commit is contained in:
parent
e35f8aad2d
commit
4efdd770dd
15 changed files with 2903 additions and 114 deletions
|
|
@ -1427,17 +1427,17 @@
|
|||
"diffGroupRemoved": {
|
||||
"en": "{count} removed",
|
||||
"es": "{count} eliminados",
|
||||
"ar": "{count} تمت إزالته"
|
||||
"ar": "تمت إزالة {count}"
|
||||
},
|
||||
"diffGroupModified": {
|
||||
"en": "{count} modified",
|
||||
"es": "{count} modificados",
|
||||
"ar": "{count} تم تعديله"
|
||||
"ar": "تم تعديل {count}"
|
||||
},
|
||||
"diffGroupKept": {
|
||||
"en": "{count} kept",
|
||||
"es": "{count} conservados",
|
||||
"ar": "{count} تم الاحتفاظ به"
|
||||
"ar": "تم الاحتفاظ بـ {count}"
|
||||
},
|
||||
"diffGroupSeparator": {
|
||||
"en": ", ",
|
||||
|
|
@ -1477,7 +1477,7 @@
|
|||
"diffGroupAdded": {
|
||||
"en": "{count} added",
|
||||
"es": "{count} añadidos",
|
||||
"ar": "{count} تمت إضافته"
|
||||
"ar": "تمت إضافة {count}"
|
||||
},
|
||||
"diffEmptyValue": {
|
||||
"en": "—",
|
||||
|
|
@ -1494,6 +1494,36 @@
|
|||
"es": "Cargando lector de metadatos…",
|
||||
"ar": "جارٍ تحميل قارئ البيانات الوصفية…"
|
||||
},
|
||||
"diffCopyButton": {
|
||||
"en": "Copy",
|
||||
"es": "Copiar",
|
||||
"ar": "نسخ"
|
||||
},
|
||||
"diffClipboardHeader": {
|
||||
"en": "Metadata diff",
|
||||
"es": "Diferencia de metadatos",
|
||||
"ar": "الفرق في البيانات الوصفية"
|
||||
},
|
||||
"diffClipboardRemoved": {
|
||||
"en": "removed",
|
||||
"es": "eliminado",
|
||||
"ar": "تمت إزالته"
|
||||
},
|
||||
"diffClipboardNone": {
|
||||
"en": "none",
|
||||
"es": "ninguno",
|
||||
"ar": "لا شيء"
|
||||
},
|
||||
"diffClipboardKept": {
|
||||
"en": "kept",
|
||||
"es": "conservado",
|
||||
"ar": "محفوظ"
|
||||
},
|
||||
"copyToast": {
|
||||
"en": "Copied to clipboard",
|
||||
"es": "Copiado al portapapeles",
|
||||
"ar": "تم النسخ إلى الحافظة"
|
||||
},
|
||||
"zipExpansion.statusCleaned": {
|
||||
"en": "Cleaned",
|
||||
"es": "Limpiado",
|
||||
|
|
|
|||
1390
docs/superpowers/plans/2026-05-22-issue-187-copy-diff-clipboard.md
Normal file
1390
docs/superpowers/plans/2026-05-22-issue-187-copy-diff-clipboard.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,528 @@
|
|||
# Copy Diff to Clipboard — Design Spec
|
||||
|
||||
**Issue:** #187 — copy to clipboard diff (before and after)
|
||||
**Date:** 2026-05-22
|
||||
**Status:** Ready for implementation
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The two-pane metadata diff (`MetadataDiffTable`) shows what was removed, modified, added, or
|
||||
kept between the original file and the stripped output. Users currently have no way to copy
|
||||
this diff out of the app — they'd have to screenshot it or manually retype each row to
|
||||
share it in a bug report, chat thread, forensic note, or compliance log.
|
||||
|
||||
The diff feature is the audit trail of what the app actually did to a file. Making that
|
||||
audit trail trivially copy-pasteable is essentially free engineering — the data is already
|
||||
in `MetadataDocument` form, and the rest of the app already has a clipboard pattern in
|
||||
`ErrorExpansion` plus a toast pipeline (`onCopyToast`) wired through `FileTable` →
|
||||
`FileRow`.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Where does the copy affordance live? | A single `Copy` button at the top-right of the diff expansion, beside the BEFORE/AFTER pane labels |
|
||||
| What gets copied? | The full diff for that file (no per-source partial copy) |
|
||||
| Clipboard format | Markdown — bullet-list per source group with arrow notation; renders cleanly on Forgejo/GitHub, stays legible in plain-text destinations |
|
||||
| Include "kept" entries? | Yes — match the in-app diff faithfully so the audit trail is complete |
|
||||
| Inner ZIP leaf diffs | Yes — copy button appears on every cleaned-leaf diff inside a ZIP archive (uses leaf's full archive-relative path in the header) |
|
||||
| Visual distinction for removed | Bold field name on `removed` rows |
|
||||
| Visual distinction for modified | None beyond the `before → after` notation |
|
||||
| Filename header | Yes — single line `**Metadata diff** — \`filename\`` |
|
||||
| Total summary line | Yes — second line: `N removed · N modified · N added · N kept` |
|
||||
| Per-source summary line | Yes — appended to each `###` source heading, matching the in-app group summary |
|
||||
| Skeleton / loading state | No copy button — nothing to copy yet |
|
||||
| Empty diff state ("already clean") | No copy button — nothing meaningful |
|
||||
| Toast on copy | Reuse existing `onCopyToast` pipeline (currently fires for `ErrorExpansion` copies) |
|
||||
| i18n scope for new strings | en / es / ar (matches the newer-keys pattern already in `strings.json` for diff/zip keys) |
|
||||
| Lift hardcoded "Copied to clipboard" toast string | Yes — move to i18n while we're touching the toast pipeline |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/web/
|
||||
utils/
|
||||
format_diff_clipboard.ts ← NEW: pure markdown formatter (MetadataDocument → string)
|
||||
components/
|
||||
icons/
|
||||
ClipboardIcon.tsx ← NEW: inline SVG glyph
|
||||
file-list/
|
||||
MetadataDiffTable.tsx ← add onCopy prop, render Copy button in pane-label row
|
||||
MetadataDiffExpansion.tsx ← add onCopyToast prop, build the copy handler
|
||||
FileRow.tsx ← pipe onCopyToast to MetadataDiffExpansion + ZipExpansion
|
||||
ZipExpansion.tsx ← thread onCopyToast through recursion to leaf MetadataDiffTable
|
||||
file-list/
|
||||
FileTable.tsx ← lift hardcoded "Copied to clipboard" to i18n
|
||||
styles/
|
||||
file_table.css ← .file-table__diff-copy-btn rule
|
||||
|
||||
tests/web/
|
||||
utils/
|
||||
format_diff_clipboard.test.ts ← NEW: pure formatter unit tests
|
||||
|
||||
tests/e2e/web/
|
||||
metadata-diff.spec.ts ← extend (or add new spec) for click-Copy → toast + clipboard-read assert
|
||||
|
||||
.resources/strings.json ← add 6 keys × 3 locales
|
||||
```
|
||||
|
||||
Nothing in `src/domain/`, `src/application/`, or `src/infrastructure/` changes. This is a
|
||||
pure presentation-layer feature: it reads from the existing `MetadataDocument` shape and
|
||||
writes to the existing `onCopyToast` toast.
|
||||
|
||||
---
|
||||
|
||||
## Clipboard format
|
||||
|
||||
The clipboard payload is markdown. Each diff produces one self-contained document with a
|
||||
header, a total-summary line, then one `###` section per source group, each section
|
||||
containing one bullet per metadata entry.
|
||||
|
||||
```
|
||||
**Metadata diff** — `IMG_1234.jpg`
|
||||
6 removed · 1 modified · 1 added · 2 kept
|
||||
|
||||
### EXIF · 2 removed · 1 added · 1 kept
|
||||
- **DateTimeOriginal**: `2024:03:14 10:32:00` → (removed)
|
||||
- **GPSLatitude**: `37.774929` → (removed)
|
||||
- Orientation: (none) → `1`
|
||||
- ColorSpace: `sRGB` (kept)
|
||||
|
||||
### XMP · 4 removed · 1 modified · 1 kept
|
||||
- **XMP:CreatorTool**: `Lightroom 12.0` → (removed)
|
||||
- **XMP:Creator**: `Jane Doe` → (removed)
|
||||
- **XMP:CreateDate**: `2024:03:14` → (removed)
|
||||
- **XMP:Subject**: `vacation, beach` → (removed)
|
||||
- XMP:Rating: `4` → `5`
|
||||
- XMP:Format: `image/jpeg` (kept)
|
||||
```
|
||||
|
||||
### Formatting rules
|
||||
|
||||
1. **Header line** — `**Metadata diff** — \`{filename}\`` where `{filename}` is the
|
||||
display path. For top-level file rows this is `file.name`. For ZIP inner leaves this is
|
||||
the leaf's archive-relative path (`entry.path`, which already encodes nested
|
||||
sub-folders like `inner/photo.jpg`).
|
||||
2. **Total summary line** — comma-separated (`·` separator from existing `diffGroupSeparator`)
|
||||
counts in order `removed / modified / added / kept`. **Skip statuses with count 0** to
|
||||
mirror the existing in-app `makePaneGroupSummary` behavior.
|
||||
3. **Source sections** — one `###` heading per source group, in the order sources first
|
||||
appear in `document.before` then `document.after` (matches existing
|
||||
`groupRowsBySource` ordering). Per-source summary suffix uses the same skip-zeros rule.
|
||||
4. **Bullet rows**, per status:
|
||||
- `removed` — `- **{name}**: \`{beforeValue}\` → (removed)` *(field name bold)*
|
||||
- `added` — `- {name}: (none) → \`{afterValue}\``
|
||||
- `modified` — `- {name}: \`{beforeValue}\` → \`{afterValue}\``
|
||||
- `kept` — `- {name}: \`{value}\` (kept)`
|
||||
5. **Value escaping** — values are wrapped in single backticks by default. If a value
|
||||
contains a literal backtick (rare in real metadata; never seen in our forensic
|
||||
corpus), the wrapper switches to double backticks per the standard CommonMark inline
|
||||
code rule (e.g. `` `` `foo` `` ``). If a value contains a newline character (XMP can
|
||||
carry these), the literal `\n` / `\r` are replaced with the visible escape sequences
|
||||
`\\n` / `\\r` so the inline code span stays on one line and the markdown remains
|
||||
valid. Trailing whitespace is preserved as-is — round-trip fidelity matters in audit
|
||||
contexts.
|
||||
6. **Empty values in `before` / `after`** — the `(none)` placeholder is used for the
|
||||
missing side in `added` / `removed`. This is i18n'd (`diffClipboardNone`) so RTL
|
||||
languages render naturally.
|
||||
7. **Trailing newline** — final newline after the last bullet, so a paste into a chat
|
||||
buffer doesn't leave the cursor mid-line.
|
||||
|
||||
### Pure-function contract
|
||||
|
||||
```ts
|
||||
export function formatDiffForClipboard({
|
||||
document,
|
||||
filename,
|
||||
t,
|
||||
}: {
|
||||
document: MetadataDocument;
|
||||
filename: string;
|
||||
t: (key: string) => string;
|
||||
}): string;
|
||||
```
|
||||
|
||||
- Reuses `computeDiffRows()` and `groupRowsBySource()` logic from `MetadataDiffTable` —
|
||||
to avoid drift, those helpers move from `MetadataDiffTable.tsx` to a shared module
|
||||
(`src/web/utils/diff_rows.ts`) and both files import them.
|
||||
- No DOM, no React. Pure `MetadataDocument → string`. Trivially unit-testable.
|
||||
|
||||
---
|
||||
|
||||
## UI changes
|
||||
|
||||
### Copy button placement
|
||||
|
||||
`MetadataDiffTable` already renders a header row with `BEFORE` / `AFTER` pane labels:
|
||||
|
||||
```tsx
|
||||
<div className="file-table__diff-pane-header">
|
||||
<span ...>{t("diffPaneBefore")}</span>
|
||||
<span ...>{t("diffPaneAfter")}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Add a right-aligned Copy button to this row:
|
||||
|
||||
```tsx
|
||||
<div className="file-table__diff-pane-header">
|
||||
<span ...>{t("diffPaneBefore")}</span>
|
||||
<span ...>{t("diffPaneAfter")}</span>
|
||||
{onCopy !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className="file-table__diff-copy-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
aria-label={t("diffCopyButton")}
|
||||
>
|
||||
<ClipboardIcon />
|
||||
<span>{t("diffCopyButton")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
- `onCopy` is optional — `MetadataDiffTable` is also rendered in places where wiring it
|
||||
doesn't make sense (e.g. test harness mounts), so the button is gated behind the prop.
|
||||
- `e.stopPropagation()` keeps the click from bubbling up to the row's expand/collapse
|
||||
handler.
|
||||
- The button is `position: absolute; top: 0; right: 0;` (or flex-end on the header row) so
|
||||
it doesn't reshape the pane label layout on narrow mobile viewports. CSS goes in
|
||||
`file_table.css`.
|
||||
|
||||
### Mobile layout
|
||||
|
||||
On the Android APK / 360px viewport, the button collapses to icon-only (the existing
|
||||
mobile media-query CSS toggles `.file-table__diff-copy-btn span { display: none }` below
|
||||
`480px`). The icon retains the `aria-label` for screen-reader users.
|
||||
|
||||
### Skeleton / empty states
|
||||
|
||||
`MetadataDiffTable` is only rendered when `diffDocument.before.length > 0 ||
|
||||
diffDocument.after.length > 0` (see `MetadataDiffExpansion`). The button shows in every
|
||||
case where the table renders. The skeleton and "(already clean)" branches don't render
|
||||
`MetadataDiffTable` at all, so they naturally get no button — no extra gating needed.
|
||||
|
||||
### Coordination with PR #189 (`fix/android-downloads`)
|
||||
|
||||
PR #189 adds a per-row **Share** icon in the RESULT column and a batch **"Share zip"**
|
||||
button in `FileTable`. The diff Copy button is in a different DOM subtree (inside the
|
||||
expansion panel, only visible when the diff is open) and uses a distinct CSS namespace
|
||||
(`.file-table__diff-copy-btn` vs `.file-table__share` / `.file-table__share-zip-btn`).
|
||||
Expected merge conflicts are limited to imports and prop-wiring in `FileRow.tsx` /
|
||||
`FileTable.tsx` — textual, not semantic.
|
||||
|
||||
Branching strategy: branch off `master`. If #189 lands first (likely — Android downloads
|
||||
is a release-blocker), rebase this branch onto it. If this branch lands first, #189
|
||||
rebases.
|
||||
|
||||
---
|
||||
|
||||
## i18n changes
|
||||
|
||||
### New keys (en / es / ar)
|
||||
|
||||
| Key | en | es | ar |
|
||||
|---|---|---|---|
|
||||
| `diffCopyButton` | `Copy` | `Copiar` | `نسخ` |
|
||||
| `diffClipboardHeader` | `Metadata diff` | `Diferencia de metadatos` | `الفرق في البيانات الوصفية` |
|
||||
| `diffClipboardRemoved` | `(removed)` | `(eliminado)` | `(تمت إزالته)` |
|
||||
| `diffClipboardNone` | `(none)` | `(ninguno)` | `(لا شيء)` |
|
||||
| `diffClipboardKept` | `(kept)` | `(conservado)` | `(محفوظ)` |
|
||||
| `copyToast` | `Copied to clipboard` | `Copiado al portapapeles` | `تم النسخ إلى الحافظة` |
|
||||
|
||||
Adding to only en / es / ar matches the existing pattern for newer keys in
|
||||
`strings.json` (see `diffGroupRemoved`, `diffPaneBefore`, `zipExpansion.*`, etc.). The
|
||||
locale-fallback chain in `i18nLookup()` handles other languages by falling back to
|
||||
English.
|
||||
|
||||
### Reused keys (no change)
|
||||
|
||||
- `diffGroupRemoved` / `diffGroupModified` / `diffGroupAdded` / `diffGroupKept` —
|
||||
templates like `{count} removed`. Used for both the total summary line and the
|
||||
per-source summary suffix.
|
||||
- `diffGroupSeparator` — the `·` (en/es) / `، ` (ar) separator between summary chunks.
|
||||
|
||||
### Lift hardcoded toast
|
||||
|
||||
`FileTable.tsx` currently hardcodes the toast text:
|
||||
|
||||
```ts
|
||||
const handleCopyToast = useCallback(() => {
|
||||
showToast("Copied to clipboard");
|
||||
}, []);
|
||||
```
|
||||
|
||||
Replace with `t("copyToast")`. The `useCallback` dep array grows from `[]` to `[t]`.
|
||||
(Note: `t` is stable across renders in `useI18n`, so the practical re-render impact is
|
||||
nil — but the dep array still needs to be accurate.)
|
||||
|
||||
---
|
||||
|
||||
## Code changes (detailed)
|
||||
|
||||
### New: `src/web/utils/format_diff_clipboard.ts`
|
||||
|
||||
```ts
|
||||
import type { MetadataDocument } from "../../domain";
|
||||
import { computeDiffRows, groupRowsBySource } from "./diff_rows";
|
||||
|
||||
export function formatDiffForClipboard({
|
||||
document,
|
||||
filename,
|
||||
t,
|
||||
}: {
|
||||
document: MetadataDocument;
|
||||
filename: string;
|
||||
t: (key: string) => string;
|
||||
}): string {
|
||||
const rows = computeDiffRows(document);
|
||||
const grouped = groupRowsBySource(rows);
|
||||
const totals = countByStatus(rows);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`**${t("diffClipboardHeader")}** — \`${filename}\``);
|
||||
lines.push(renderSummary(totals, t));
|
||||
lines.push("");
|
||||
|
||||
for (const group of grouped) {
|
||||
const groupTotals = countByStatus(group.rows);
|
||||
lines.push(`### ${group.source} · ${renderSummary(groupTotals, t)}`);
|
||||
for (const row of group.rows) {
|
||||
lines.push(renderBullet(row, t));
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").trimEnd() + "\n";
|
||||
}
|
||||
```
|
||||
|
||||
(`countByStatus`, `renderSummary`, `renderBullet`, value-escape helper, etc. are
|
||||
file-local helpers.)
|
||||
|
||||
### New: `src/web/utils/diff_rows.ts`
|
||||
|
||||
Move `computeDiffRows`, `groupRowsBySource`, `makeKey`, `DiffRow`, `SourceRowGroup`,
|
||||
`DiffRowStatus` out of `MetadataDiffTable.tsx` into this shared module. `MetadataDiffTable`
|
||||
imports them; the new formatter imports them. Avoids code-duplication drift.
|
||||
|
||||
### New: `src/web/components/icons/ClipboardIcon.tsx`
|
||||
|
||||
Inline SVG glyph for the Copy button. Same style as `ChevronIcon`, `GearIcon`,
|
||||
`ShareIcon` — `currentColor` stroke, 14×14 viewport, named export.
|
||||
|
||||
### Modified: `src/web/components/file-list/MetadataDiffTable.tsx`
|
||||
|
||||
- Drop `computeDiffRows` / `groupRowsBySource` (moved to `utils/diff_rows.ts`).
|
||||
- Add optional `onCopy?: () => void` prop.
|
||||
- Render the Copy button in the existing pane-label header row.
|
||||
|
||||
### Modified: `src/web/components/file-list/MetadataDiffExpansion.tsx`
|
||||
|
||||
- Add `onCopyToast` prop.
|
||||
- Construct `handleCopy`:
|
||||
```ts
|
||||
const handleCopy = useCallback((): void => {
|
||||
const text = formatDiffForClipboard({ document: diffDocument!, filename, t });
|
||||
navigator.clipboard.writeText(text).then(onCopyToast, () => { /* silent */ });
|
||||
}, [diffDocument, filename, t, onCopyToast]);
|
||||
```
|
||||
- Pass `handleCopy` to `MetadataDiffTable` via `onCopy`.
|
||||
- Add `filename` prop (so the formatter has it) — defaults to `""` if a caller doesn't
|
||||
provide one (defensive; in practice every caller does).
|
||||
|
||||
### Modified: `src/web/components/file-list/FileRow.tsx`
|
||||
|
||||
- Pipe `onCopyToast` to both `MetadataDiffExpansion` (already gets `filename={file.name}`)
|
||||
and `ZipExpansion`.
|
||||
|
||||
### Modified: `src/web/components/file-list/ZipExpansion.tsx`
|
||||
|
||||
- Add `onCopyToast` prop to `ZipExpansion` itself; thread to recursive
|
||||
`<ZipExpansion>` calls.
|
||||
- Pass `onCopyToast` and the leaf's `fullPath` (or `entry.path` — discussed in
|
||||
Implementation Q&A below) into `renderLeafBody`, which then passes them to
|
||||
`MetadataDiffTable` as `onCopy` (constructed locally inside `renderLeafBody`).
|
||||
|
||||
### Modified: `src/web/components/file-list/FileTable.tsx`
|
||||
|
||||
- Lift the hardcoded `"Copied to clipboard"` string to `t("copyToast")`.
|
||||
|
||||
### Modified: `.resources/strings.json`
|
||||
|
||||
- Add the 6 new keys (en / es / ar each) listed above.
|
||||
|
||||
### Modified: `src/web/styles/file_table.css`
|
||||
|
||||
- Add `.file-table__diff-copy-btn` rules:
|
||||
- Reset button defaults (background, border, padding).
|
||||
- Inline-flex, `gap: 4px`, color = `var(--ec-color-text-secondary)`.
|
||||
- Hover: `color: var(--ec-color-text-primary)`.
|
||||
- Position in the pane header (`margin-left: auto` if the header is flex; or absolute
|
||||
top-right if not).
|
||||
- Mobile (`@media (max-width: 480px)`): hide the text span, keep the icon.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
### New: `tests/web/utils/format_diff_clipboard.test.ts`
|
||||
|
||||
Pure formatter tests. The `t` function is mocked to identity (returns key) so test
|
||||
assertions don't depend on i18n strings — they just verify the structural shape of the
|
||||
output.
|
||||
|
||||
Cases:
|
||||
|
||||
1. **Empty document** — both `before` and `after` empty. Output is just the header line
|
||||
+ an empty summary line (or assert this branch isn't reached in practice; gated by
|
||||
`MetadataDiffExpansion`).
|
||||
2. **Removed-only** — one source, three removed entries.
|
||||
3. **Added-only** — one source, two added entries.
|
||||
4. **Modified-only** — one source, two modified entries.
|
||||
5. **Kept-only** — one source, two kept entries.
|
||||
6. **Mixed statuses, single source**.
|
||||
7. **Multiple sources** — EXIF + XMP + IPTC with mixed statuses; verify each gets its
|
||||
own `###` heading and the source ordering matches discovery order.
|
||||
8. **Total summary** — verify the totals on line 2 match the row counts.
|
||||
9. **Per-source summary** — verify each `###` heading suffix matches the per-source
|
||||
counts; zero counts are skipped.
|
||||
10. **Bold on removed** — verify `**FieldName**:` format for removed; non-removed rows
|
||||
don't bold the name.
|
||||
11. **Value with backtick** — verify the wrapper switches to double backticks.
|
||||
12. **Value with newline** — verify literal `\n` / `\r` are replaced with the visible
|
||||
escape sequences `\\n` / `\\r`.
|
||||
13. **Empty filename** — verify the header still renders without crashing.
|
||||
14. **Trailing newline** — final line ends in `\n`, but no double-newline at EOF.
|
||||
|
||||
### Modified: `tests/web/components/file_row.test.tsx`
|
||||
|
||||
The existing test exercises `onCopyToast` for the error path. Extend (or add a sibling
|
||||
test) for: when the file has a `diffDocument`, expand the row, click the Copy button,
|
||||
assert `onCopyToast` was called.
|
||||
|
||||
### Modified: `tests/web/components/zip_expansion.test.tsx` (if exists, else new)
|
||||
|
||||
Verify the Copy button appears on a cleaned-leaf diff inside ZipExpansion and clicking
|
||||
it fires `onCopyToast`. Mock `navigator.clipboard.writeText` to resolve.
|
||||
|
||||
### New: e2e — extend `tests/e2e/web/metadata-diff.spec.ts` (or add a new spec)
|
||||
|
||||
End-to-end flow:
|
||||
1. Drop a JPEG with EXIF metadata.
|
||||
2. Wait for the diff to land.
|
||||
3. Expand the row.
|
||||
4. Click `Copy`.
|
||||
5. Assert the toast appears with the i18n'd copy text.
|
||||
6. Read `navigator.clipboard.readText()` (Playwright supports this with the
|
||||
`clipboard-read` permission grant) and assert the markdown payload matches the
|
||||
expected shape.
|
||||
|
||||
Clipboard permission is granted via `context.grantPermissions(['clipboard-read',
|
||||
'clipboard-write'])` in the test setup.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Q&A
|
||||
|
||||
### Q: For ZIP leaf diffs, what filename goes in the markdown header?
|
||||
|
||||
The leaf's archive-relative path. `ZipExpansion` already constructs `fullPath =
|
||||
pathPrefix + entry.path` with a `\0` separator for nested archives. For the clipboard
|
||||
header we want a human-readable path, so:
|
||||
|
||||
- Use `entry.path` for shallow archives (no nested zips). Renders as e.g. `inner/photo.jpg`.
|
||||
- For nested zips, transform the `\0`-joined `fullPath` to `>`-joined: `outer.zip >
|
||||
inner/photo.jpg`. This is purely for human readability in the clipboard header; the
|
||||
internal `fullPath` keying is unchanged.
|
||||
|
||||
A small helper:
|
||||
|
||||
```ts
|
||||
function leafDisplayPath(fullPath: string): string {
|
||||
return fullPath.split("\0").filter((s) => s.length > 0).join(" > ");
|
||||
}
|
||||
```
|
||||
|
||||
### Q: Does `navigator.clipboard.writeText` work on the standalone HTML (file:// origin) and inside the Android Capacitor WebView?
|
||||
|
||||
- **Capacitor WebView** — serves the app over `https://localhost/`, which is a secure
|
||||
context. Clipboard API works.
|
||||
- **Desktop browsers** — modern Chromium / Firefox / Safari allow
|
||||
`navigator.clipboard.writeText` from a user gesture. The Copy button click counts as a
|
||||
user gesture.
|
||||
- **Standalone HTML opened from `file://`** — Chromium and Safari treat `file://` as a
|
||||
potentially-trustworthy origin and allow `writeText` from a user gesture. Firefox is
|
||||
more restrictive; it may prompt or silently fail. The existing `ErrorExpansion` uses
|
||||
the same API in the same context and works, so this is a precedent, not a new risk.
|
||||
|
||||
The implementation silently swallows clipboard failures (matches `ErrorExpansion`):
|
||||
|
||||
```ts
|
||||
navigator.clipboard.writeText(text).then(onCopyToast, () => { /* no-op */ });
|
||||
```
|
||||
|
||||
Future improvement (out of scope for #187): a fallback toast on failure ("Clipboard
|
||||
unavailable") and an `execCommand("copy")` fallback. Tracked separately if it ever
|
||||
matters.
|
||||
|
||||
### Q: Why move `computeDiffRows` to a shared module instead of calling it from `MetadataDiffTable`?
|
||||
|
||||
Two reasons:
|
||||
1. The formatter needs to run *without* mounting the React component. Importing from a
|
||||
component file forces React import overhead on the formatter and any test that uses
|
||||
it.
|
||||
2. Keeping the diff-row computation in a single source of truth prevents the formatter
|
||||
and the renderer from drifting on edge-case row classification.
|
||||
|
||||
### Q: Should we copy a "summary-only" mode if the diff is enormous (1000+ rows)?
|
||||
|
||||
No, not for #187. The forensic use case explicitly wants the full row list. If a
|
||||
practical pain point emerges (a real user reports their paste was truncated by the
|
||||
target app), we add a second "Copy summary" button alongside the main one. Tracked as
|
||||
a hypothetical follow-up; not built now.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
Items explicitly **not** in this spec; each is a candidate follow-up issue if a real need
|
||||
emerges:
|
||||
|
||||
- **Per-source partial copy buttons** — confirmed full-diff is enough.
|
||||
- **Compact mode toggle** — including `kept` entries is the default and only behavior.
|
||||
- **Sharing the diff via `@capacitor/share`** on Android — separate intent, separate
|
||||
issue.
|
||||
- **Fallback for clipboard-write failure** — silent swallow today, matches
|
||||
`ErrorExpansion` precedent.
|
||||
- **i18n for the other 22 locales** — follow the established newer-keys pattern (en /
|
||||
es / ar), let `i18nLookup` fall back to English elsewhere.
|
||||
- **`navigator.share` for plain-text** — possible future "Share diff" button alongside
|
||||
Copy, but not part of this issue.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A `Copy` button appears at the top-right of the diff expansion when a `diffDocument`
|
||||
is present and non-empty.
|
||||
- A `Copy` button appears on every cleaned-leaf diff inside a ZIP expansion.
|
||||
- Clicking `Copy` writes a markdown-formatted diff to the clipboard.
|
||||
- A "Copied to clipboard" toast appears after a successful copy (i18n'd in en / es / ar).
|
||||
- The clipboard payload format matches the structure documented in this spec
|
||||
(filename header, total summary, per-source `###` sections, bullet rows with the
|
||||
per-status pattern, bold field names for `removed`).
|
||||
- The skeleton and "already clean" expansion states show no Copy button.
|
||||
- No DOM / CSS collision with PR #189's Share / share-zip buttons.
|
||||
- All quality gates pass: `yarn typecheck`, `yarn lint`, `yarn test`, `yarn check:deps`,
|
||||
`yarn build:web`, `yarn build:web:standalone`, `yarn test:e2e:web`.
|
||||
|
|
@ -236,6 +236,8 @@ export function FileRow({
|
|||
<MetadataDiffExpansion
|
||||
diffDocument={file.diffDocument}
|
||||
diffPending={diffPending}
|
||||
filename={file.name}
|
||||
onCopyToast={onCopyToast}
|
||||
/>
|
||||
) : (
|
||||
<div className="file-table__expansion">
|
||||
|
|
@ -245,7 +247,11 @@ export function FileRow({
|
|||
</div>
|
||||
))}
|
||||
{isExpanded && isComplete && hasArchiveEntries && (
|
||||
<ZipExpansion entryId={file.id} entries={file.archiveEntries!} />
|
||||
<ZipExpansion
|
||||
entryId={file.id}
|
||||
entries={file.archiveEntries!}
|
||||
onCopyToast={onCopyToast}
|
||||
/>
|
||||
)}
|
||||
{isExpanded &&
|
||||
file.status === FileProcessingStatus.Complete &&
|
||||
|
|
@ -254,6 +260,8 @@ export function FileRow({
|
|||
<MetadataDiffExpansion
|
||||
diffDocument={file.diffDocument}
|
||||
diffPending={diffPending}
|
||||
filename={file.name}
|
||||
onCopyToast={onCopyToast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -103,8 +103,8 @@ export function FileTable(): React.JSX.Element {
|
|||
}, [t]);
|
||||
|
||||
const handleCopyToast = useCallback(() => {
|
||||
showToast("Copied to clipboard");
|
||||
}, []);
|
||||
showToast(t("copyToast"));
|
||||
}, [t]);
|
||||
|
||||
const handleRevealError = useCallback((message: string) => {
|
||||
showToast(message);
|
||||
|
|
|
|||
|
|
@ -20,14 +20,23 @@
|
|||
// Skeleton mode: while the async ExifTool read is in flight (diffPending)
|
||||
// and no diffDocument is on the entry yet, render a wayfinding cue so the
|
||||
// expansion area isn't blank when the user opens the row early.
|
||||
//
|
||||
// Copy button: when `onCopyToast` is provided, the rendered diff exposes
|
||||
// a floating Copy button that writes a markdown payload (see
|
||||
// src/web/utils/format_diff_clipboard.ts) to navigator.clipboard and
|
||||
// fires `onCopyToast` on success.
|
||||
|
||||
import { useI18n } from "../../hooks/use_i18n";
|
||||
import { useCallback } from "react";
|
||||
import type { MetadataDocument } from "../../../domain";
|
||||
import { MetadataDiffTable, DiffSkeleton } from "./MetadataDiffTable";
|
||||
import { useI18n } from "../../hooks/use_i18n";
|
||||
import { formatDiffForClipboard } from "../../utils/format_diff_clipboard";
|
||||
import { DiffSkeleton, MetadataDiffTable } from "./MetadataDiffTable";
|
||||
|
||||
export function MetadataDiffExpansion({
|
||||
diffDocument,
|
||||
diffPending = false,
|
||||
filename,
|
||||
onCopyToast,
|
||||
}: {
|
||||
diffDocument: MetadataDocument | null;
|
||||
// True while the out-of-band ExifTool diff build is still in flight.
|
||||
|
|
@ -36,14 +45,38 @@ export function MetadataDiffExpansion({
|
|||
// row while pending they see the skeleton; if the diff arrives while
|
||||
// the row is expanded, the skeleton swaps for the two-pane view.
|
||||
diffPending?: boolean;
|
||||
filename: string;
|
||||
onCopyToast?: () => void;
|
||||
}): React.JSX.Element | null {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const handleCopy = useCallback((): void => {
|
||||
if (diffDocument === null) return;
|
||||
const text = formatDiffForClipboard({
|
||||
document: diffDocument,
|
||||
filename,
|
||||
t,
|
||||
locale,
|
||||
});
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {
|
||||
onCopyToast?.();
|
||||
},
|
||||
() => {
|
||||
/* clipboard write failed silently — matches ErrorExpansion */
|
||||
},
|
||||
);
|
||||
}, [diffDocument, filename, t, locale, onCopyToast]);
|
||||
|
||||
if (
|
||||
diffDocument != null &&
|
||||
(diffDocument.before.length > 0 || diffDocument.after.length > 0)
|
||||
) {
|
||||
return <MetadataDiffTable document={diffDocument} t={t} />;
|
||||
// Conditional spread (not `onCopy={undefined}`) because
|
||||
// exactOptionalPropertyTypes treats `undefined` and "absent" as
|
||||
// distinct.
|
||||
const copyProp = onCopyToast !== undefined ? { onCopy: handleCopy } : {};
|
||||
return <MetadataDiffTable document={diffDocument} t={t} {...copyProp} />;
|
||||
}
|
||||
|
||||
// Diff still in flight — skeleton wayfinding cue.
|
||||
|
|
|
|||
|
|
@ -9,32 +9,46 @@
|
|||
// - ZipExpansion leaf renders pass "zip-expansion__leaf-diff" to get a
|
||||
// slimmer wrapper without the file-table expansion padding.
|
||||
|
||||
import type { MetadataDocument, MetadataEntry } from "../../../domain";
|
||||
|
||||
type DiffRowStatus = "removed" | "added" | "modified" | "kept";
|
||||
|
||||
interface DiffRow {
|
||||
readonly status: DiffRowStatus;
|
||||
readonly source: string;
|
||||
readonly name: string;
|
||||
readonly before: string | null;
|
||||
readonly after: string | null;
|
||||
}
|
||||
import type { MetadataDocument } from "../../../domain";
|
||||
import {
|
||||
type DiffRow,
|
||||
computeDiffRows,
|
||||
groupRowsBySource,
|
||||
} from "../../utils/diff_rows";
|
||||
import { ClipboardIcon } from "../icons/ClipboardIcon";
|
||||
|
||||
export function MetadataDiffTable({
|
||||
document,
|
||||
t,
|
||||
wrapperClassName = "file-table__expansion file-table__diff file-table__diff--two-pane",
|
||||
onCopy,
|
||||
}: {
|
||||
document: MetadataDocument;
|
||||
t: (key: string) => string;
|
||||
wrapperClassName?: string;
|
||||
onCopy?: () => void;
|
||||
}): React.JSX.Element {
|
||||
const rows = computeDiffRows(document);
|
||||
const grouped = groupRowsBySource(rows);
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
{onCopy !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className="file-table__diff-copy-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
aria-label={t("diffCopyButton")}
|
||||
>
|
||||
<ClipboardIcon />
|
||||
<span className="file-table__diff-copy-btn-label">
|
||||
{t("diffCopyButton")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="file-table__diff-pane-header">
|
||||
<span className="file-table__diff-pane-label file-table__diff-pane-label--before">
|
||||
{t("diffPaneBefore")}
|
||||
|
|
@ -157,71 +171,6 @@ function PaneRow({
|
|||
);
|
||||
}
|
||||
|
||||
function computeDiffRows(document: MetadataDocument): readonly DiffRow[] {
|
||||
const afterByKey = new Map<string, MetadataEntry>();
|
||||
for (const entry of document.after) {
|
||||
afterByKey.set(makeKey(entry.source, entry.name), entry);
|
||||
}
|
||||
|
||||
const beforeKeys = new Set<string>();
|
||||
const rows: DiffRow[] = [];
|
||||
|
||||
for (const entry of document.before) {
|
||||
const key = makeKey(entry.source, entry.name);
|
||||
beforeKeys.add(key);
|
||||
const after = afterByKey.get(key);
|
||||
if (after === undefined) {
|
||||
rows.push({
|
||||
status: "removed",
|
||||
source: entry.source,
|
||||
name: entry.name,
|
||||
before: entry.value,
|
||||
after: null,
|
||||
});
|
||||
} else if (after.value === entry.value) {
|
||||
rows.push({
|
||||
status: "kept",
|
||||
source: entry.source,
|
||||
name: entry.name,
|
||||
before: entry.value,
|
||||
after: after.value,
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
status: "modified",
|
||||
source: entry.source,
|
||||
name: entry.name,
|
||||
before: entry.value,
|
||||
after: after.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of document.after) {
|
||||
const key = makeKey(entry.source, entry.name);
|
||||
if (!beforeKeys.has(key)) {
|
||||
rows.push({
|
||||
status: "added",
|
||||
source: entry.source,
|
||||
name: entry.name,
|
||||
before: null,
|
||||
after: entry.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// NUL separator (not a space, not a colon) so the composed key can't
|
||||
// collide with a tag name that legitimately contains the separator.
|
||||
// Tag names from ExifTool -G1 are mostly ASCII identifiers, but spaces
|
||||
// have shown up in extended XMP namespaces. NUL is forbidden in every
|
||||
// metadata grammar we route, so it's a safe sentinel.
|
||||
function makeKey(source: string, name: string): string {
|
||||
return `${source}\0${name}`;
|
||||
}
|
||||
|
||||
function makePaneGroupSummary(
|
||||
rows: readonly DiffRow[],
|
||||
t: (key: string) => string,
|
||||
|
|
@ -257,28 +206,3 @@ function makePaneGroupSummary(
|
|||
if (parts.length === 0) return "";
|
||||
return `· ${parts.join(t("diffGroupSeparator"))}`;
|
||||
}
|
||||
|
||||
interface SourceRowGroup {
|
||||
readonly source: string;
|
||||
readonly rows: readonly DiffRow[];
|
||||
}
|
||||
|
||||
function groupRowsBySource(
|
||||
rows: readonly DiffRow[],
|
||||
): readonly SourceRowGroup[] {
|
||||
const order: string[] = [];
|
||||
const byKey = new Map<string, DiffRow[]>();
|
||||
for (const row of rows) {
|
||||
const existing = byKey.get(row.source);
|
||||
if (existing === undefined) {
|
||||
order.push(row.source);
|
||||
byKey.set(row.source, [row]);
|
||||
} else {
|
||||
existing.push(row);
|
||||
}
|
||||
}
|
||||
return order.map((source) => ({
|
||||
source,
|
||||
rows: byKey.get(source) as DiffRow[],
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { assertNever } from "../../../common";
|
|||
import type { ArchiveEntryResult } from "../../../domain";
|
||||
import { useI18n } from "../../hooks/use_i18n";
|
||||
import { ChevronIcon } from "../icons/ChevronIcon";
|
||||
import { formatDiffForClipboard } from "../../utils/format_diff_clipboard";
|
||||
import { MetadataDiffTable, DiffSkeleton } from "./MetadataDiffTable";
|
||||
|
||||
// Pagination — render the first N entries eagerly; surface a button
|
||||
|
|
@ -43,6 +44,7 @@ export function ZipExpansion({
|
|||
entries,
|
||||
depth = 0,
|
||||
pathPrefix = "",
|
||||
onCopyToast,
|
||||
}: {
|
||||
entryId: string;
|
||||
entries: readonly ArchiveEntryResult[];
|
||||
|
|
@ -55,8 +57,12 @@ export function ZipExpansion({
|
|||
// in WasmProcessor.stashArchiveLeaves. Without this prefix,
|
||||
// nested zips with same-named leaves would collide.
|
||||
pathPrefix?: string;
|
||||
// When provided, each cleaned-leaf diff renders a Copy button that
|
||||
// writes the per-leaf markdown to the clipboard and fires this
|
||||
// callback on success. Threaded recursively to nested archives.
|
||||
onCopyToast?: () => void;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const [visible, setVisible] = useState(VISIBLE_PAGE_SIZE);
|
||||
const [leafStates, setLeafStates] = useState<Map<string, LeafState>>(
|
||||
new Map(),
|
||||
|
|
@ -128,6 +134,8 @@ export function ZipExpansion({
|
|||
setLeafState={setLeafState}
|
||||
setLeafStateIfStill={setLeafStateIfStill}
|
||||
t={t}
|
||||
locale={locale}
|
||||
{...(onCopyToast !== undefined ? { onCopyToast } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -154,6 +162,8 @@ function ZipExpansionRow({
|
|||
setLeafState,
|
||||
setLeafStateIfStill,
|
||||
t,
|
||||
locale,
|
||||
onCopyToast,
|
||||
}: {
|
||||
stateKey: string;
|
||||
entryId: string;
|
||||
|
|
@ -168,6 +178,8 @@ function ZipExpansionRow({
|
|||
next: LeafState;
|
||||
}) => void;
|
||||
t: (key: string) => string;
|
||||
locale: string;
|
||||
onCopyToast?: () => void;
|
||||
}): React.JSX.Element {
|
||||
// Both "cleaned" and "already-clean" are processable (the bytes stash
|
||||
// was populated; the diff is meaningful). The distinction is only for
|
||||
|
|
@ -281,13 +293,14 @@ function ZipExpansionRow({
|
|||
{isExpanded &&
|
||||
isCleanedLeaf &&
|
||||
state.kind === "loaded" &&
|
||||
renderLeafBody(state.result, t)}
|
||||
renderLeafBody(state.result, t, locale, fullPath, onCopyToast)}
|
||||
{isExpanded && isNestedZip && entry.entries !== null && (
|
||||
<ZipExpansion
|
||||
entryId={entryId}
|
||||
entries={entry.entries}
|
||||
depth={depth + 1}
|
||||
pathPrefix={`${fullPath}\0`}
|
||||
{...(onCopyToast !== undefined ? { onCopyToast } : {})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -301,6 +314,9 @@ function ZipExpansionRow({
|
|||
function renderLeafBody(
|
||||
result: LeafDiffResult,
|
||||
t: (key: string) => string,
|
||||
locale: string,
|
||||
fullPath: string,
|
||||
onCopyToast: (() => void) | undefined,
|
||||
): React.JSX.Element {
|
||||
if (result.kind === "failed") {
|
||||
return (
|
||||
|
|
@ -311,11 +327,31 @@ function renderLeafBody(
|
|||
}
|
||||
const { doc } = result;
|
||||
if (doc.before.length > 0 || doc.after.length > 0) {
|
||||
const handleCopy =
|
||||
onCopyToast !== undefined
|
||||
? (): void => {
|
||||
const text = formatDiffForClipboard({
|
||||
document: doc,
|
||||
filename: leafDisplayPath(fullPath),
|
||||
t,
|
||||
locale,
|
||||
});
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {
|
||||
onCopyToast();
|
||||
},
|
||||
() => {
|
||||
/* silent — matches top-level diff behavior */
|
||||
},
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
return (
|
||||
<MetadataDiffTable
|
||||
document={doc}
|
||||
t={t}
|
||||
wrapperClassName="zip-expansion__leaf-diff file-table__diff--two-pane"
|
||||
{...(handleCopy !== undefined ? { onCopy: handleCopy } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -326,6 +362,17 @@ function renderLeafBody(
|
|||
);
|
||||
}
|
||||
|
||||
// Transforms the NUL-joined composite archive path into a human-readable
|
||||
// breadcrumb string used in the clipboard markdown header. Top-level
|
||||
// leaves come through as e.g. "inner/photo.jpg" (no NULs). Nested zips
|
||||
// produce "outer.zip > inner/photo.jpg".
|
||||
function leafDisplayPath(fullPath: string): string {
|
||||
return fullPath
|
||||
.split("\0")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join(" > ");
|
||||
}
|
||||
|
||||
function renderStatus(
|
||||
entry: ArchiveEntryResult,
|
||||
t: (key: string) => string,
|
||||
|
|
|
|||
19
src/web/components/icons/ClipboardIcon.tsx
Normal file
19
src/web/components/icons/ClipboardIcon.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export function ClipboardIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="3" width="8" height="11" rx="1.5" />
|
||||
<path d="M6 3v-1a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1" />
|
||||
<path d="M7 7h2M7 9.5h2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -427,11 +427,16 @@
|
|||
The before / after panes get distinct background tints to keep the
|
||||
visual side-by-side metaphor even when individual cells are neutral. */
|
||||
|
||||
.file-table__diff--two-pane {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-table__diff--two-pane .file-table__diff-pane-header {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 1fr;
|
||||
gap: var(--ec-space-4);
|
||||
padding: var(--ec-space-2) var(--ec-space-4);
|
||||
padding-right: 90px; /* space for the absolutely-positioned Copy button */
|
||||
border-bottom: 1px solid var(--ec-color-border);
|
||||
background: var(--ec-color-surface);
|
||||
font-family: var(--ec-font-family);
|
||||
|
|
@ -442,6 +447,74 @@
|
|||
color: var(--ec-color-text-secondary);
|
||||
}
|
||||
|
||||
/* Floating Copy button — top-right of the diff wrapper. Sits above the
|
||||
pane header on desktop and remains visible when the header collapses
|
||||
on mobile. */
|
||||
.file-table__diff-copy-btn {
|
||||
position: absolute;
|
||||
top: var(--ec-space-1);
|
||||
right: var(--ec-space-2);
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
color: var(--ec-color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: var(--ec-font-family);
|
||||
font-size: 12px;
|
||||
font-weight: var(--ec-font-weight-semibold);
|
||||
line-height: 1;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
.file-table__diff-copy-btn:hover,
|
||||
.file-table__diff-copy-btn:focus-visible {
|
||||
color: var(--ec-color-text-primary);
|
||||
background-color: var(--ec-color-surface);
|
||||
border-color: var(--ec-color-border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.file-table__diff-copy-btn-label {
|
||||
display: none;
|
||||
}
|
||||
.file-table__diff-copy-btn {
|
||||
padding: 6px;
|
||||
}
|
||||
.file-table__diff--two-pane .file-table__diff-pane-header {
|
||||
padding-right: var(--ec-space-4);
|
||||
}
|
||||
}
|
||||
|
||||
/* RTL: pin to top-left instead of top-right. */
|
||||
[dir="rtl"] .file-table__diff-copy-btn {
|
||||
right: auto;
|
||||
left: var(--ec-space-2);
|
||||
}
|
||||
|
||||
[dir="rtl"]
|
||||
.file-table__diff--two-pane
|
||||
.file-table__diff-pane-header {
|
||||
padding-right: var(--ec-space-4);
|
||||
padding-left: 90px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
[dir="rtl"]
|
||||
.file-table__diff--two-pane
|
||||
.file-table__diff-pane-header {
|
||||
padding-left: var(--ec-space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.file-table__diff--two-pane .file-table__diff-pane-label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
105
src/web/utils/diff_rows.ts
Normal file
105
src/web/utils/diff_rows.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Shared diff-row computation, extracted from MetadataDiffTable so the
|
||||
// clipboard formatter (src/web/utils/format_diff_clipboard.ts) can reuse
|
||||
// the same row-classification logic without depending on the React
|
||||
// component.
|
||||
|
||||
import type { MetadataDocument, MetadataEntry } from "../../domain";
|
||||
|
||||
export type DiffRowStatus = "removed" | "added" | "modified" | "kept";
|
||||
|
||||
export interface DiffRow {
|
||||
readonly status: DiffRowStatus;
|
||||
readonly source: string;
|
||||
readonly name: string;
|
||||
readonly before: string | null;
|
||||
readonly after: string | null;
|
||||
}
|
||||
|
||||
export interface SourceRowGroup {
|
||||
readonly source: string;
|
||||
readonly rows: readonly DiffRow[];
|
||||
}
|
||||
|
||||
export function computeDiffRows(
|
||||
document: MetadataDocument,
|
||||
): readonly DiffRow[] {
|
||||
const afterByKey = new Map<string, MetadataEntry>();
|
||||
for (const entry of document.after) {
|
||||
afterByKey.set(makeKey(entry.source, entry.name), entry);
|
||||
}
|
||||
|
||||
const beforeKeys = new Set<string>();
|
||||
const rows: DiffRow[] = [];
|
||||
|
||||
for (const entry of document.before) {
|
||||
const key = makeKey(entry.source, entry.name);
|
||||
beforeKeys.add(key);
|
||||
const after = afterByKey.get(key);
|
||||
if (after === undefined) {
|
||||
rows.push({
|
||||
status: "removed",
|
||||
source: entry.source,
|
||||
name: entry.name,
|
||||
before: entry.value,
|
||||
after: null,
|
||||
});
|
||||
} else if (after.value === entry.value) {
|
||||
rows.push({
|
||||
status: "kept",
|
||||
source: entry.source,
|
||||
name: entry.name,
|
||||
before: entry.value,
|
||||
after: after.value,
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
status: "modified",
|
||||
source: entry.source,
|
||||
name: entry.name,
|
||||
before: entry.value,
|
||||
after: after.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of document.after) {
|
||||
const key = makeKey(entry.source, entry.name);
|
||||
if (!beforeKeys.has(key)) {
|
||||
rows.push({
|
||||
status: "added",
|
||||
source: entry.source,
|
||||
name: entry.name,
|
||||
before: null,
|
||||
after: entry.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function groupRowsBySource(
|
||||
rows: readonly DiffRow[],
|
||||
): readonly SourceRowGroup[] {
|
||||
const order: string[] = [];
|
||||
const byKey = new Map<string, DiffRow[]>();
|
||||
for (const row of rows) {
|
||||
const existing = byKey.get(row.source);
|
||||
if (existing === undefined) {
|
||||
order.push(row.source);
|
||||
byKey.set(row.source, [row]);
|
||||
} else {
|
||||
existing.push(row);
|
||||
}
|
||||
}
|
||||
return order.map((source) => ({
|
||||
source,
|
||||
rows: byKey.get(source) as DiffRow[],
|
||||
}));
|
||||
}
|
||||
|
||||
// NUL separator (not a space, not a colon) so the composed key can't
|
||||
// collide with a tag name that legitimately contains the separator.
|
||||
function makeKey(source: string, name: string): string {
|
||||
return `${source}\0${name}`;
|
||||
}
|
||||
198
src/web/utils/format_diff_clipboard.ts
Normal file
198
src/web/utils/format_diff_clipboard.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// Pure markdown formatter for the metadata diff. Used by both the
|
||||
// top-level MetadataDiffExpansion and per-leaf renders inside
|
||||
// ZipExpansion to produce the clipboard payload when the Copy button
|
||||
// is clicked.
|
||||
//
|
||||
// Format contract is documented in
|
||||
// docs/superpowers/specs/2026-05-22-issue-187-copy-diff-clipboard-design.md.
|
||||
|
||||
import type { MetadataDocument } from "../../domain";
|
||||
import { type DiffRow, computeDiffRows, groupRowsBySource } from "./diff_rows";
|
||||
|
||||
// Locales whose markdown destinations default to RTL paragraph
|
||||
// direction. When the destination is RTL, mixed-bidi content like
|
||||
// `- **Author**: `J.K. Rowling` → (تمت إزالته)`
|
||||
// renders with jarring directional jumps — the parenthesised Arabic
|
||||
// label, the arrow, and the LTR value get reordered by the bidi
|
||||
// algorithm and the result is hard to scan. For RTL locales we wrap
|
||||
// every non-empty line in Unicode bidi isolates so each line resolves
|
||||
// as LTR-base regardless of surrounding paragraph direction. The
|
||||
// isolates are zero-width invisible characters in every modern
|
||||
// renderer (browsers, terminals, chat apps), so plain-text paste
|
||||
// destinations don't see literal HTML tags — unlike the alternative
|
||||
// of wrapping the payload in a `<div dir="ltr">` block.
|
||||
const RTL_LOCALES: ReadonlySet<string> = new Set(["ar"]);
|
||||
|
||||
const LRI = ""; // U+2066 — Left-to-Right Isolate
|
||||
const PDI = ""; // U+2069 — Pop Directional Isolate
|
||||
|
||||
function isRTL(locale: string): boolean {
|
||||
const base = locale.split("-")[0] ?? locale;
|
||||
return RTL_LOCALES.has(base);
|
||||
}
|
||||
|
||||
// Wrap the line content in LRI…PDI so a Forgejo / GitHub Arabic context
|
||||
// resolves it as an LTR run.
|
||||
//
|
||||
// IMPORTANT: markdown block syntax (`### ` for headings, `- ` for list
|
||||
// items) must remain at column 0 for the parser to recognize it.
|
||||
// Putting LRI at position 0 of the line pushes `#` to position 1 and
|
||||
// the heading silently degrades to a paragraph. So for these prefixes
|
||||
// we wrap only the content after the marker; otherwise we wrap the
|
||||
// whole line.
|
||||
function wrapLineLTR(line: string): string {
|
||||
if (line.length === 0) return line;
|
||||
const headingMatch = line.match(/^(#{1,6} )(.*)$/);
|
||||
if (headingMatch !== null) {
|
||||
return `${headingMatch[1]}${LRI}${headingMatch[2]}${PDI}`;
|
||||
}
|
||||
const bulletMatch = line.match(/^(- )(.*)$/);
|
||||
if (bulletMatch !== null) {
|
||||
return `${bulletMatch[1]}${LRI}${bulletMatch[2]}${PDI}`;
|
||||
}
|
||||
return `${LRI}${line}${PDI}`;
|
||||
}
|
||||
|
||||
export function formatDiffForClipboard({
|
||||
document,
|
||||
filename,
|
||||
t,
|
||||
locale = "en",
|
||||
}: {
|
||||
document: MetadataDocument;
|
||||
filename: string;
|
||||
t: (key: string) => string;
|
||||
locale?: string;
|
||||
}): string {
|
||||
const rows = computeDiffRows(document);
|
||||
const grouped = groupRowsBySource(rows);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`**${t("diffClipboardHeader")}** — ${codeWrap(filename)}`);
|
||||
const total = renderSummary(countByStatus(rows), t);
|
||||
if (total.length > 0) lines.push(total);
|
||||
lines.push("");
|
||||
|
||||
for (const group of grouped) {
|
||||
const groupSummary = renderSummary(countByStatus(group.rows), t);
|
||||
const sourceLabel = escapeMdInline(group.source);
|
||||
const heading =
|
||||
groupSummary.length > 0
|
||||
? `### ${sourceLabel} · ${groupSummary}`
|
||||
: `### ${sourceLabel}`;
|
||||
lines.push(heading);
|
||||
for (const row of group.rows) {
|
||||
lines.push(renderBullet(row, t));
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const body = `${lines.join("\n").trimEnd()}\n`;
|
||||
// Strip pre-existing bidi control codepoints from the assembled
|
||||
// payload. These could come from adversarial or unusual metadata
|
||||
// values (e.g. an EXIF Artist field carrying U+2069 / PDI) and would
|
||||
// otherwise prematurely close our own LRI/PDI wrapping in the RTL
|
||||
// path, or surface as invisible noise in any destination's bidi
|
||||
// resolution. They serve no user purpose in a clipboard payload.
|
||||
const sanitized = body.replace(/[--]/g, "");
|
||||
if (!isRTL(locale)) return sanitized;
|
||||
return sanitized.split("\n").map(wrapLineLTR).join("\n");
|
||||
}
|
||||
|
||||
interface StatusCounts {
|
||||
removed: number;
|
||||
modified: number;
|
||||
added: number;
|
||||
kept: number;
|
||||
}
|
||||
|
||||
function countByStatus(rows: readonly DiffRow[]): StatusCounts {
|
||||
const out: StatusCounts = { removed: 0, modified: 0, added: 0, kept: 0 };
|
||||
for (const row of rows) out[row.status] += 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderSummary(
|
||||
counts: StatusCounts,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const sep = t("diffGroupSeparator");
|
||||
const parts: string[] = [];
|
||||
if (counts.removed > 0)
|
||||
parts.push(
|
||||
t("diffGroupRemoved").replace("{count}", String(counts.removed)),
|
||||
);
|
||||
if (counts.modified > 0)
|
||||
parts.push(
|
||||
t("diffGroupModified").replace("{count}", String(counts.modified)),
|
||||
);
|
||||
if (counts.added > 0)
|
||||
parts.push(t("diffGroupAdded").replace("{count}", String(counts.added)));
|
||||
if (counts.kept > 0)
|
||||
parts.push(t("diffGroupKept").replace("{count}", String(counts.kept)));
|
||||
return parts.join(sep);
|
||||
}
|
||||
|
||||
function renderBullet(row: DiffRow, t: (key: string) => string): string {
|
||||
const name = escapeMdInline(row.name);
|
||||
switch (row.status) {
|
||||
case "removed":
|
||||
return `- **${name}**: ${codeWrap(row.before ?? "")} → (${t("diffClipboardRemoved")})`;
|
||||
case "added":
|
||||
return `- ${name}: (${t("diffClipboardNone")}) → ${codeWrap(row.after ?? "")}`;
|
||||
case "modified":
|
||||
return `- ${name}: ${codeWrap(row.before ?? "")} → ${codeWrap(row.after ?? "")}`;
|
||||
case "kept":
|
||||
return `- ${name}: ${codeWrap(row.before ?? "")} (${t("diffClipboardKept")})`;
|
||||
default:
|
||||
return exhaustive(row.status);
|
||||
}
|
||||
}
|
||||
|
||||
// CommonMark inline-code rule: find the longest run of N consecutive
|
||||
// backticks in the value, then wrap with (N+1)-backtick delimiters. Pad
|
||||
// with a leading/trailing space when the value itself begins or ends
|
||||
// with a backtick (the parser strips one leading + one trailing space
|
||||
// inside a code span). Newlines are escaped to keep the span on one
|
||||
// line so the markdown stays valid in destinations that don't accept
|
||||
// multi-line code spans (most chat renderers, plain-text paste).
|
||||
function codeWrap(raw: string): string {
|
||||
const escaped = raw.replace(/\n/g, "\\n").replace(/\r/g, "\\r");
|
||||
const longestBacktickRun = findLongestBacktickRun(escaped);
|
||||
if (longestBacktickRun === 0) return `\`${escaped}\``;
|
||||
const fence = "`".repeat(longestBacktickRun + 1);
|
||||
const startsOrEndsWithBacktick =
|
||||
escaped.startsWith("`") || escaped.endsWith("`");
|
||||
const pad = startsOrEndsWithBacktick ? " " : "";
|
||||
return `${fence}${pad}${escaped}${pad}${fence}`;
|
||||
}
|
||||
|
||||
function findLongestBacktickRun(s: string): number {
|
||||
let longest = 0;
|
||||
let current = 0;
|
||||
for (const ch of s) {
|
||||
if (ch === "`") {
|
||||
current += 1;
|
||||
if (current > longest) longest = current;
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
return longest;
|
||||
}
|
||||
|
||||
// Escape every character that has inline-markdown meaning so a field
|
||||
// name or source label can't accidentally trigger emphasis (`*`, `_`),
|
||||
// link / reference syntax (`[`, `]`), raw-HTML / autolink (`<`, `>`),
|
||||
// a code span (`` ` ``), or an escape sequence (`\`). Code-span values
|
||||
// are wrapped separately via codeWrap and don't need this treatment.
|
||||
// ExifTool tag names are usually safe ASCII identifiers, but custom
|
||||
// XMP qualified names, PDF Info-dict keys, and Office walker source
|
||||
// labels can include any of these characters.
|
||||
function escapeMdInline(raw: string): string {
|
||||
return raw.replace(/[\\*_`[\]<>]/g, "\\$&");
|
||||
}
|
||||
|
||||
function exhaustive(value: never): never {
|
||||
throw new Error(`Unhandled diff row status: ${String(value)}`);
|
||||
}
|
||||
81
tests/e2e/web/copy_diff.spec.ts
Normal file
81
tests/e2e/web/copy_diff.spec.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Copy diff to clipboard — Web (issue #187)
|
||||
//
|
||||
// Drops sample.jpg, waits for the row to become expandable, expands the
|
||||
// diff, clicks the Copy button, and verifies:
|
||||
//
|
||||
// 1. The "Copied to clipboard" toast appears.
|
||||
// 2. navigator.clipboard.readText() contains the expected markdown
|
||||
// payload (filename header, ### source heading, at least one
|
||||
// bullet row).
|
||||
//
|
||||
// WebKit is skipped — Playwright's WebKit driver does not grant
|
||||
// clipboard-read without a user prompt, even with permission grants in
|
||||
// the context. The clipboard *write* itself works on WebKit (component
|
||||
// test in tests/web exercises that path); this is purely a read-side
|
||||
// limitation of the test harness.
|
||||
//
|
||||
// Standalone Chromium project is skipped too — Playwright won't grant
|
||||
// clipboard permissions on a file:// origin.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { fixturePath } from "./helpers/fixture_loader";
|
||||
import { launchPage } from "./helpers/page_launcher";
|
||||
|
||||
test.describe("Copy diff to clipboard", () => {
|
||||
test.beforeEach(async ({ page, context, browserName }, testInfo) => {
|
||||
test.skip(
|
||||
browserName === "webkit",
|
||||
"WebKit driver — clipboard-read grant doesn't bypass the user prompt under Playwright.",
|
||||
);
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("standalone-"),
|
||||
"Standalone (file:// origin) — Playwright cannot grant clipboard permissions on file://.",
|
||||
);
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await launchPage(page);
|
||||
});
|
||||
|
||||
test("clicks Copy and writes markdown to the clipboard", async ({
|
||||
page,
|
||||
isMobile,
|
||||
}) => {
|
||||
test.setTimeout(45_000);
|
||||
|
||||
const input = page.locator(".file-browse-button__input").first();
|
||||
await input.setInputFiles([fixturePath("sample.jpg")], { force: true });
|
||||
|
||||
const row = page.locator(".file-table__row--complete").first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toHaveClass(/file-table__row--expandable/);
|
||||
|
||||
if (isMobile) {
|
||||
await row.tap();
|
||||
} else {
|
||||
await row.click();
|
||||
}
|
||||
|
||||
const diff = page.locator(".file-table__diff--two-pane");
|
||||
await expect(diff).toBeVisible();
|
||||
|
||||
const copyBtn = diff.locator(".file-table__diff-copy-btn");
|
||||
await expect(copyBtn).toBeVisible();
|
||||
|
||||
if (isMobile) {
|
||||
await copyBtn.tap();
|
||||
} else {
|
||||
await copyBtn.click();
|
||||
}
|
||||
|
||||
const toast = page.locator(".toast--visible");
|
||||
await expect(toast).toContainText(/Copied to clipboard/i, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
const clipboard = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
expect(clipboard).toContain("**Metadata diff** — `sample.jpg`");
|
||||
expect(clipboard).toMatch(/^### /m);
|
||||
expect(clipboard).toMatch(/^- /m);
|
||||
});
|
||||
});
|
||||
51
tests/web/utils/diff_rows.test.ts
Normal file
51
tests/web/utils/diff_rows.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { MetadataDocument } from "../../../src/domain";
|
||||
import {
|
||||
computeDiffRows,
|
||||
groupRowsBySource,
|
||||
} from "../../../src/web/utils/diff_rows";
|
||||
|
||||
describe("computeDiffRows", () => {
|
||||
it("classifies removed / kept / modified / added", () => {
|
||||
const doc: MetadataDocument = {
|
||||
before: [
|
||||
{ source: "EXIF", name: "GPSLatitude", value: "37.0" },
|
||||
{ source: "EXIF", name: "Orientation", value: "1" },
|
||||
{ source: "EXIF", name: "Software", value: "Lightroom" },
|
||||
],
|
||||
after: [
|
||||
{ source: "EXIF", name: "Orientation", value: "1" },
|
||||
{ source: "EXIF", name: "Software", value: "MetaScrub" },
|
||||
{ source: "EXIF", name: "ColorSpace", value: "sRGB" },
|
||||
],
|
||||
};
|
||||
const rows = computeDiffRows(doc);
|
||||
expect(rows.map((r) => [r.name, r.status])).toEqual([
|
||||
["GPSLatitude", "removed"],
|
||||
["Orientation", "kept"],
|
||||
["Software", "modified"],
|
||||
["ColorSpace", "added"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns no rows when both panes are empty", () => {
|
||||
expect(computeDiffRows({ before: [], after: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupRowsBySource", () => {
|
||||
it("preserves first-seen source order", () => {
|
||||
const doc: MetadataDocument = {
|
||||
before: [
|
||||
{ source: "XMP", name: "Rating", value: "3" },
|
||||
{ source: "EXIF", name: "Make", value: "Canon" },
|
||||
{ source: "XMP", name: "Creator", value: "Jane" },
|
||||
],
|
||||
after: [],
|
||||
};
|
||||
const groups = groupRowsBySource(computeDiffRows(doc));
|
||||
expect(groups.map((g) => g.source)).toEqual(["XMP", "EXIF"]);
|
||||
expect(groups[0]!.rows).toHaveLength(2);
|
||||
expect(groups[1]!.rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
302
tests/web/utils/format_diff_clipboard.test.ts
Normal file
302
tests/web/utils/format_diff_clipboard.test.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { MetadataDocument } from "../../../src/domain";
|
||||
import { formatDiffForClipboard } from "../../../src/web/utils/format_diff_clipboard";
|
||||
|
||||
// Identity translator — returns the key. Lets the assertions focus on
|
||||
// structural shape, not on string content.
|
||||
const tId = (key: string): string => key;
|
||||
|
||||
// Convenience builder.
|
||||
const doc = (
|
||||
before: ReadonlyArray<[string, string, string]>,
|
||||
after: ReadonlyArray<[string, string, string]>,
|
||||
): MetadataDocument => ({
|
||||
before: before.map(([source, name, value]) => ({ source, name, value })),
|
||||
after: after.map(([source, name, value]) => ({ source, name, value })),
|
||||
});
|
||||
|
||||
describe("formatDiffForClipboard", () => {
|
||||
it("renders the header line with the filename", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "Make", "Canon"]], [["EXIF", "Make", "Canon"]]),
|
||||
filename: "IMG_1234.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out.split("\n")[0]).toBe(
|
||||
"**diffClipboardHeader** — `IMG_1234.jpg`",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the total summary line on line 2, skipping zero counts", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc(
|
||||
[
|
||||
["EXIF", "GPS", "37.0"],
|
||||
["EXIF", "Make", "Canon"],
|
||||
],
|
||||
[["EXIF", "Make", "Canon"]],
|
||||
),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out.split("\n")[1]).toBe(
|
||||
"diffGroupRemoveddiffGroupSeparatordiffGroupKept",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats a removed row with bold field name and (removed) suffix", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "GPS", "37.0"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out).toContain("- **GPS**: `37.0` → (diffClipboardRemoved)");
|
||||
});
|
||||
|
||||
it("formats an added row without bold, with (none) placeholder", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([], [["EXIF", "ColorSpace", "sRGB"]]),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out).toContain("- ColorSpace: (diffClipboardNone) → `sRGB`");
|
||||
});
|
||||
|
||||
it("formats a modified row with X → Y notation, no bold", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["XMP", "Rating", "4"]], [["XMP", "Rating", "5"]]),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out).toContain("- Rating: `4` → `5`");
|
||||
});
|
||||
|
||||
it("formats a kept row with (kept) suffix, no bold", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc(
|
||||
[["EXIF", "ColorSpace", "sRGB"]],
|
||||
[["EXIF", "ColorSpace", "sRGB"]],
|
||||
),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out).toContain("- ColorSpace: `sRGB` (diffClipboardKept)");
|
||||
});
|
||||
|
||||
it("groups by source with ### heading + per-source summary", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc(
|
||||
[
|
||||
["EXIF", "GPS", "37.0"],
|
||||
["XMP", "Creator", "Jane"],
|
||||
],
|
||||
[],
|
||||
),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out).toContain("### EXIF · diffGroupRemoved");
|
||||
expect(out).toContain("### XMP · diffGroupRemoved");
|
||||
});
|
||||
|
||||
it("escapes embedded backticks with double-backtick wrapper", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "Comment", "weird`value"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out).toContain(
|
||||
"- **Comment**: ``weird`value`` → (diffClipboardRemoved)",
|
||||
);
|
||||
});
|
||||
|
||||
it("escalates the fence for values with multiple consecutive backticks", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "Comment", "a``b"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
// "a``b" has a 2-backtick run → fence must be 3 backticks.
|
||||
expect(out).toContain(
|
||||
"- **Comment**: ```a``b``` → (diffClipboardRemoved)",
|
||||
);
|
||||
});
|
||||
|
||||
it("pads the fence when the value starts or ends with a backtick", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "Comment", "`foo"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
// Value starts with backtick → pad with a leading space inside the
|
||||
// 2-backtick fence so the parser doesn't read the inner backtick as
|
||||
// part of the delimiter.
|
||||
expect(out).toContain(
|
||||
"- **Comment**: `` `foo `` → (diffClipboardRemoved)",
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps the filename via codeWrap so backticks in the filename don't corrupt the header", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "Make", "Canon"]], [["EXIF", "Make", "Canon"]]),
|
||||
filename: "weird`name.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out.split("\n")[0]).toBe(
|
||||
"**diffClipboardHeader** — ``weird`name.jpg``",
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes inline-markdown characters in field names across every status", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc(
|
||||
[
|
||||
["XMP", "Foo*Bar", "a"],
|
||||
["XMP", "Foo_Bar", "b"],
|
||||
["XMP", "Foo[Bar]", "c"],
|
||||
],
|
||||
[
|
||||
["XMP", "Foo_Bar", "b"],
|
||||
["XMP", "Foo[Bar]", "c2"],
|
||||
["XMP", "<Foo>", "d"],
|
||||
],
|
||||
),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
// Removed: bold + escaped name.
|
||||
expect(out).toContain(
|
||||
"- **Foo\\*Bar**: `a` → (diffClipboardRemoved)",
|
||||
);
|
||||
// Kept: plain + escaped name (otherwise '_Bar' would render italic).
|
||||
expect(out).toContain("- Foo\\_Bar: `b` (diffClipboardKept)");
|
||||
// Modified: plain + escaped name (otherwise '[Bar]' could parse as a link).
|
||||
expect(out).toContain("- Foo\\[Bar\\]: `c` → `c2`");
|
||||
// Added: plain + escaped name (otherwise '<Foo>' could parse as raw HTML).
|
||||
expect(out).toContain("- \\<Foo\\>: (diffClipboardNone) → `d`");
|
||||
});
|
||||
|
||||
it("escapes inline-markdown characters in source headings", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["Office_Custom*", "Foo", "bar"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out).toContain("### Office\\_Custom\\* ·");
|
||||
});
|
||||
|
||||
it("strips bidi control codepoints from values so they don't pop the outer isolate", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "Artist", "goodbad"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
locale: "ar",
|
||||
});
|
||||
// The U+2069 inside the value would otherwise close our LRI wrap.
|
||||
// After sanitization there is exactly one LRI (per non-empty line)
|
||||
// and one PDI per line — never any inside a value.
|
||||
expect(out).not.toContain("goodbad");
|
||||
expect(out).toContain("goodbad");
|
||||
});
|
||||
|
||||
it("escapes newlines in values as visible \\n", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["XMP", "Desc", "line1\nline2"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out).toContain("`line1\\nline2`");
|
||||
expect(out).not.toMatch(/line1\nline2/);
|
||||
});
|
||||
|
||||
it("ends with exactly one trailing newline", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "GPS", "37.0"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out.endsWith("\n")).toBe(true);
|
||||
expect(out.endsWith("\n\n")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles an empty document without crashing", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out).toContain("**diffClipboardHeader** — `f.jpg`");
|
||||
});
|
||||
|
||||
it("wraps each non-empty line in U+2066/U+2069 bidi isolates for RTL locales", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "Make", "Canon"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
locale: "ar",
|
||||
});
|
||||
const lines = out.split("\n");
|
||||
const contentLines = lines.filter((l) => l.length > 0);
|
||||
// Every non-empty line ends with U+2069 (PDI).
|
||||
for (const line of contentLines) {
|
||||
expect(line.endsWith("")).toBe(true);
|
||||
}
|
||||
// Heading lines must KEEP `### ` at column 0 — otherwise the markdown
|
||||
// parser doesn't recognize them as headings. Wrap only the content
|
||||
// AFTER the marker. Same for bullet lines (`- `).
|
||||
for (const line of contentLines) {
|
||||
if (line.startsWith("### ")) expect(line).toMatch(/^### /);
|
||||
else if (line.startsWith("- ")) expect(line).toMatch(/^- /);
|
||||
else expect(line.startsWith("")).toBe(true);
|
||||
}
|
||||
// Blank lines between sections are preserved as-is.
|
||||
expect(lines).toContain("");
|
||||
// No visible HTML wrapper.
|
||||
expect(out).not.toContain("<div");
|
||||
expect(out).not.toContain("</div>");
|
||||
});
|
||||
|
||||
it("does not wrap lines for LTR locales", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "Make", "Canon"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
locale: "en",
|
||||
});
|
||||
expect(out).not.toContain("");
|
||||
expect(out).not.toContain("");
|
||||
});
|
||||
|
||||
it("treats a regional Arabic variant (ar-EG) as RTL", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc([["EXIF", "Make", "Canon"]], []),
|
||||
filename: "f.jpg",
|
||||
t: tId,
|
||||
locale: "ar-EG",
|
||||
});
|
||||
expect(out).toContain("");
|
||||
});
|
||||
|
||||
it("renders all four statuses in a single source block", () => {
|
||||
const out = formatDiffForClipboard({
|
||||
document: doc(
|
||||
[
|
||||
["EXIF", "GPS", "37.0"],
|
||||
["EXIF", "Orientation", "1"],
|
||||
["EXIF", "Software", "Lightroom"],
|
||||
],
|
||||
[
|
||||
["EXIF", "Orientation", "1"],
|
||||
["EXIF", "Software", "MetaScrub"],
|
||||
["EXIF", "ColorSpace", "sRGB"],
|
||||
],
|
||||
),
|
||||
filename: "mixed.jpg",
|
||||
t: tId,
|
||||
});
|
||||
expect(out).toContain("- **GPS**: `37.0` → (diffClipboardRemoved)");
|
||||
expect(out).toContain("- Orientation: `1` (diffClipboardKept)");
|
||||
expect(out).toContain("- Software: `Lightroom` → `MetaScrub`");
|
||||
expect(out).toContain("- ColorSpace: (diffClipboardNone) → `sRGB`");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue