docs(18): create phase plan
This commit is contained in:
@@ -101,7 +101,10 @@ Plans:
|
||||
2. Each field in the batch editor shows one of three states: "keep original" (mixed values, no change), "set to value" (apply this value to all selected tracks), or "clear field" (remove this value from all) — the user can see which fields differ across the selection and choose per-field what to do
|
||||
3. For batch operations on 10+ tracks, a progress indicator shows how many tracks have been processed — the user is never left staring at a frozen UI wondering if the operation is working
|
||||
4. User can set cover art for all selected tracks at once — the same image is embedded in every selected file
|
||||
**Plans:** TBD
|
||||
**Plans:** 2 plans
|
||||
Plans:
|
||||
- [ ] 18-01-PLAN.md — Backend batch write endpoint with progress events, cancellation, and partial failure
|
||||
- [ ] 18-02-PLAN.md — Frontend batch mode in track-details with three-state editing, confirmation, progress UI, and view wiring
|
||||
|
||||
### Phase 19: OGG Vorbis Tag Writing
|
||||
**Goal:** Users can edit tags on OGG Vorbis files with the same experience as MP3 and FLAC — completing full format coverage
|
||||
@@ -134,7 +137,7 @@ Plans:
|
||||
| 15. Schema Migration & Write Safety | 2/2 | Complete | 2026-03-16 | - |
|
||||
| 16. Tag Writing & Database Sync | 3/3 | Complete | 2026-03-17 | - |
|
||||
| 17. Single Track Edit | 2/2 | Complete | 2026-03-18 | - |
|
||||
| 18. Batch Edit | v1.2 | 0/? | Not started | - |
|
||||
| 18. Batch Edit | v1.2 | 0/2 | Not started | - |
|
||||
| 19. OGG Vorbis Tag Writing | v1.2 | 0/? | Not started | - |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
---
|
||||
phase: 18-batch-edit
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- backend/tagwriter/pipeline.go
|
||||
- backend/events/events.go
|
||||
- frontend/src/events.ts
|
||||
- frontend/wailsjs/go/tagwriter/TagWriter.js
|
||||
- frontend/wailsjs/go/tagwriter/TagWriter.d.ts
|
||||
autonomous: true
|
||||
requirements: [BATCH-01, BATCH-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Backend can write the same tag changes to N tracks sequentially, emitting progress events after each track"
|
||||
- "Frontend can call BatchWriteTrackTags with an array of file paths and a TagChanges map"
|
||||
- "Progress events include current index, total count, current file path, and whether the batch was cancelled"
|
||||
- "Partial failures do not abort the batch — failed tracks are collected and returned as a structured result"
|
||||
- "The batch can be cancelled mid-flight via a cancel channel, and already-written tracks keep their changes"
|
||||
artifacts:
|
||||
- path: "backend/tagwriter/pipeline.go"
|
||||
provides: "BatchWriteTrackTags method, BatchResult type, BatchWriteProgress event emission"
|
||||
contains: "func (tw *TagWriter) BatchWriteTrackTags"
|
||||
- path: "backend/events/events.go"
|
||||
provides: "BatchWriteProgress event constant"
|
||||
contains: "BatchWriteProgress"
|
||||
- path: "frontend/src/events.ts"
|
||||
provides: "Auto-generated BatchWriteProgress event constant"
|
||||
contains: "BatchWriteProgress"
|
||||
- path: "frontend/wailsjs/go/tagwriter/TagWriter.js"
|
||||
provides: "Wails binding for BatchWriteTrackTags"
|
||||
contains: "BatchWriteTrackTags"
|
||||
- path: "frontend/wailsjs/go/tagwriter/TagWriter.d.ts"
|
||||
provides: "TypeScript declaration for BatchWriteTrackTags"
|
||||
contains: "BatchWriteTrackTags"
|
||||
key_links:
|
||||
- from: "backend/tagwriter/pipeline.go"
|
||||
to: "backend/events/events.go"
|
||||
via: "EventsEmit(tw.ctx, events.BatchWriteProgress, ...)"
|
||||
pattern: "events\\.BatchWriteProgress"
|
||||
- from: "frontend/wailsjs/go/tagwriter/TagWriter.js"
|
||||
to: "backend/tagwriter/pipeline.go"
|
||||
via: "Wails binding bridge"
|
||||
pattern: "BatchWriteTrackTags"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add a backend BatchWriteTrackTags method that writes the same TagChanges to multiple tracks sequentially, emitting progress events after each track and collecting partial failures.
|
||||
|
||||
Purpose: The batch edit UI needs a backend endpoint that processes N tracks, reports progress per-track, supports cancellation, and returns a structured result with success/failure counts — the existing WriteTrackTagsByPath only handles one track.
|
||||
|
||||
Output: BatchWriteTrackTags Go method exposed via Wails, BatchWriteProgress event for live progress, BatchResult return type.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/18-batch-edit/18-CONTEXT.md
|
||||
@.planning/phases/17-single-track-edit/17-02-SUMMARY.md
|
||||
@.planning/phases/16-tag-writing-database-sync/16-03-SUMMARY.md
|
||||
|
||||
@backend/tagwriter/pipeline.go
|
||||
@backend/tagwriter/tagwriter.go
|
||||
@backend/events/events.go
|
||||
@frontend/src/events.ts
|
||||
@frontend/wailsjs/go/tagwriter/TagWriter.js
|
||||
@frontend/wailsjs/go/tagwriter/TagWriter.d.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From backend/tagwriter/tagwriter.go:
|
||||
```go
|
||||
// TagChanges is a diff map of field name → new value. Only changed
|
||||
// fields are present.
|
||||
type TagChanges map[string]any
|
||||
|
||||
// Field name constants.
|
||||
const (
|
||||
FieldTitle = "title"
|
||||
FieldArtist = "artist"
|
||||
FieldAlbum = "album"
|
||||
FieldAlbumArtist = "album_artist"
|
||||
FieldGenre = "genre"
|
||||
FieldYear = "year"
|
||||
FieldTrackNumber = "track_number"
|
||||
FieldDiscNumber = "disc_number"
|
||||
FieldComposer = "composer"
|
||||
FieldCoverArt = "cover_art" // []byte for set, nil for clear
|
||||
)
|
||||
```
|
||||
|
||||
From backend/tagwriter/pipeline.go:
|
||||
```go
|
||||
type TagWriter struct {
|
||||
logger *slog.Logger
|
||||
db *database.DB
|
||||
ctx context.Context // Wails context for event emission
|
||||
player PlayerStopper
|
||||
library PipelineLocker
|
||||
}
|
||||
|
||||
func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error { ... }
|
||||
func (tw *TagWriter) WriteTrackTagsByPath(filePath string, changes TagChanges) error { ... }
|
||||
```
|
||||
|
||||
From backend/events/events.go:
|
||||
```go
|
||||
// Tag writing events.
|
||||
const (
|
||||
TrackMetadataChanged = "TrackMetadataChanged"
|
||||
)
|
||||
```
|
||||
|
||||
Wails binding pattern (frontend/wailsjs/go/tagwriter/TagWriter.js):
|
||||
```javascript
|
||||
export function WriteTrackTagsByPath(arg1, arg2) {
|
||||
return window['go']['tagwriter']['TagWriter']['WriteTrackTagsByPath'](arg1, arg2);
|
||||
}
|
||||
```
|
||||
|
||||
Wails TypeScript declaration pattern (frontend/wailsjs/go/tagwriter/TagWriter.d.ts):
|
||||
```typescript
|
||||
export function WriteTrackTagsByPath(arg1:string,arg2:tagwriter.TagChanges):Promise<void>;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add BatchWriteProgress event constant</name>
|
||||
<files>backend/events/events.go, frontend/src/events.ts</files>
|
||||
<action>
|
||||
Add `BatchWriteProgress` to the "Tag writing events" const block in `backend/events/events.go`:
|
||||
|
||||
```go
|
||||
// Tag writing events.
|
||||
const (
|
||||
TrackMetadataChanged = "TrackMetadataChanged"
|
||||
BatchWriteProgress = "BatchWriteProgress"
|
||||
)
|
||||
```
|
||||
|
||||
Then regenerate the TypeScript events file:
|
||||
```bash
|
||||
cd backend/events && go generate ./...
|
||||
```
|
||||
|
||||
This runs the existing `genevents` codegen tool that parses Go AST and outputs `frontend/src/events.ts`. Verify the generated file contains `BatchWriteProgress`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "BatchWriteProgress" backend/events/events.go && grep -q "BatchWriteProgress" frontend/src/events.ts && echo "PASS"</automated>
|
||||
</verify>
|
||||
<done>BatchWriteProgress event constant exists in both Go and TypeScript, auto-generated via existing codegen pipeline.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add BatchWriteTrackTags method with progress, cancellation, and partial failure</name>
|
||||
<files>backend/tagwriter/pipeline.go, frontend/wailsjs/go/tagwriter/TagWriter.js, frontend/wailsjs/go/tagwriter/TagWriter.d.ts</files>
|
||||
<action>
|
||||
In `backend/tagwriter/pipeline.go`, add:
|
||||
|
||||
1. **BatchFailure struct** — holds per-track failure info:
|
||||
```go
|
||||
// BatchFailure records a single track that failed during a batch write.
|
||||
type BatchFailure struct {
|
||||
FilePath string `json:"filePath"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
```
|
||||
|
||||
2. **BatchResult struct** — returned from the batch method:
|
||||
```go
|
||||
// BatchResult summarises the outcome of a batch tag write.
|
||||
type BatchResult struct {
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
Failures []BatchFailure `json:"failures"`
|
||||
}
|
||||
```
|
||||
|
||||
3. **cancelBatch field** on TagWriter — a `chan struct{}` that signals cancellation:
|
||||
```go
|
||||
cancelBatch chan struct{}
|
||||
```
|
||||
Add `cancelBatch` to the TagWriter struct. Initialize to nil. The field is checked by BatchWriteTrackTags before each track.
|
||||
|
||||
4. **CancelBatchWrite method** — callable from frontend:
|
||||
```go
|
||||
// CancelBatchWrite signals the in-progress batch write to stop after
|
||||
// the current track completes.
|
||||
func (tw *TagWriter) CancelBatchWrite() {
|
||||
ch := tw.cancelBatch
|
||||
if ch != nil {
|
||||
select {
|
||||
case <-ch:
|
||||
// Already closed.
|
||||
default:
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **BatchWriteTrackTags method** — the core batch pipeline:
|
||||
```go
|
||||
// BatchWriteTrackTags applies the same TagChanges to every file in
|
||||
// filePaths. It processes tracks sequentially, emits a
|
||||
// BatchWriteProgress event after each track, and continues past
|
||||
// individual failures. Returns a BatchResult summarising outcomes.
|
||||
func (tw *TagWriter) BatchWriteTrackTags(filePaths []string, changes TagChanges) BatchResult {
|
||||
```
|
||||
|
||||
Implementation details:
|
||||
- Create `tw.cancelBatch = make(chan struct{})` at the start, defer setting it to nil.
|
||||
- Loop over filePaths with index. Before each iteration, check if cancelBatch is closed via non-blocking select; if so, set result.Cancelled = true and break.
|
||||
- Call `tw.WriteTrackTagsByPath(filePath, changes)` for each track. The existing method already handles pipeline lock, player safety, file write, DB sync, and TrackMetadataChanged event per track.
|
||||
- **IMPORTANT:** The existing WriteTrackTags acquires and releases the pipeline lock per track. This is correct for batch — we do NOT want to hold the lock for the entire batch, because that would block scan for the entire duration. Per-track locking is fine.
|
||||
- **IMPORTANT:** The existing WriteTrackTags emits TrackMetadataChanged per track. For batch, we want ONE invalidation at the end, not N. We need to suppress per-track events. Add a `suppressEvents bool` field to TagWriter that BatchWriteTrackTags sets to true during the loop, then emits a single TrackMetadataChanged after the loop completes. Modify the event emission in WriteTrackTags (step 7) to check `tw.suppressEvents`.
|
||||
- After each track (success or failure), emit `BatchWriteProgress` event with payload:
|
||||
```go
|
||||
map[string]any{
|
||||
"current": i + 1,
|
||||
"total": len(filePaths),
|
||||
"filePath": filePath,
|
||||
"succeeded": result.Succeeded,
|
||||
"failed": result.Failed,
|
||||
}
|
||||
```
|
||||
- On error, append to result.Failures and increment result.Failed; on success, increment result.Succeeded.
|
||||
- After the loop (or after cancel break), emit a single `TrackMetadataChanged` event (since per-track events were suppressed). This triggers one full library store invalidation.
|
||||
- Return the BatchResult (not an error). The return type is the struct itself, so partial success is always communicated. Wails will serialize it as JSON.
|
||||
- Log a summary at Info level: total, succeeded, failed, cancelled, duration.
|
||||
|
||||
6. **Modify WriteTrackTags event emission** — Add a check for `tw.suppressEvents` before the event emission in step 7:
|
||||
```go
|
||||
// 7. Emit event (suppressed during batch writes).
|
||||
if tw.ctx != nil && !tw.suppressEvents {
|
||||
```
|
||||
|
||||
7. **Wails bindings** — Manually add to `frontend/wailsjs/go/tagwriter/TagWriter.js`:
|
||||
```javascript
|
||||
export function BatchWriteTrackTags(arg1, arg2) {
|
||||
return window['go']['tagwriter']['TagWriter']['BatchWriteTrackTags'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CancelBatchWrite() {
|
||||
return window['go']['tagwriter']['TagWriter']['CancelBatchWrite']();
|
||||
}
|
||||
```
|
||||
|
||||
And to `frontend/wailsjs/go/tagwriter/TagWriter.d.ts`:
|
||||
```typescript
|
||||
export function BatchWriteTrackTags(arg1:Array<string>,arg2:tagwriter.TagChanges):Promise<tagwriter.BatchResult>;
|
||||
|
||||
export function CancelBatchWrite():Promise<void>;
|
||||
```
|
||||
|
||||
Also add BatchResult and BatchFailure to the Wails models file `frontend/wailsjs/go/models.ts` in the `tagwriter` namespace (check if a tagwriter namespace already exists; if not, add it following the existing pattern).
|
||||
|
||||
**Codebase conventions to follow:**
|
||||
- godot: all doc comments end with a period.
|
||||
- nlreturn: blank line before return statements.
|
||||
- gci: imports grouped as stdlib, external, internal.
|
||||
- 100-char line limit.
|
||||
- Error wrapping with `%w`.
|
||||
- `slog` structured logging with key-value pairs.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd backend && go build ./tagwriter/... && echo "BUILD OK" && grep -q "BatchWriteTrackTags" ../frontend/wailsjs/go/tagwriter/TagWriter.js && grep -q "CancelBatchWrite" ../frontend/wailsjs/go/tagwriter/TagWriter.js && echo "BINDINGS OK"</automated>
|
||||
</verify>
|
||||
<done>BatchWriteTrackTags method compiles, processes tracks sequentially with progress events and cancellation support, collects partial failures into BatchResult, suppresses per-track TrackMetadataChanged and emits one at the end. Wails bindings exist for BatchWriteTrackTags and CancelBatchWrite.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `cd backend && go build ./...` — entire backend compiles
|
||||
2. `cd backend && go vet ./tagwriter/...` — no vet warnings
|
||||
3. `grep -c "BatchWriteProgress\|BatchWriteTrackTags\|CancelBatchWrite\|BatchResult\|BatchFailure" backend/tagwriter/pipeline.go` — confirms all new types/methods exist
|
||||
4. `grep "BatchWriteProgress" frontend/src/events.ts` — event constant auto-generated
|
||||
5. `grep "BatchWriteTrackTags\|CancelBatchWrite" frontend/wailsjs/go/tagwriter/TagWriter.d.ts` — TypeScript declarations exist
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- BatchWriteTrackTags Go method exists and compiles, accepting []string filePaths and TagChanges, returning BatchResult
|
||||
- CancelBatchWrite Go method exists for mid-batch cancellation
|
||||
- BatchWriteProgress event emitted per-track with current/total/filePath/succeeded/failed
|
||||
- Per-track TrackMetadataChanged suppressed during batch; single event emitted after batch completes
|
||||
- BatchResult struct contains total, succeeded, failed, cancelled, and failures array
|
||||
- Wails bindings (JS + d.ts) manually created for both new methods
|
||||
- BatchResult type added to Wails models
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/18-batch-edit/18-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,569 @@
|
||||
---
|
||||
phase: 18-batch-edit
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["18-01"]
|
||||
files_modified:
|
||||
- frontend/src/components/track-details/track-details.ts
|
||||
- frontend/src/components/track-list/track-list.ts
|
||||
- frontend/src/components/cover-grid/cover-grid.ts
|
||||
- frontend/src/components/queue-panel/queue-panel.ts
|
||||
- frontend/src/components/playlist-details/playlist-details.ts
|
||||
autonomous: false
|
||||
requirements: [BATCH-01, BATCH-02, BATCH-03, BATCH-04]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Selecting 2+ tracks and clicking Track Details opens a batch summary view showing 'N tracks selected' header"
|
||||
- "Each field shows shared value (if identical across tracks) or 'Multiple values' placeholder (if different)"
|
||||
- "In edit mode, typing into a field marks it dirty; only dirty fields are sent as TagChanges on save"
|
||||
- "Clearing a field (empty string) after interaction is a distinct state from 'untouched' — it sends the clear to all tracks"
|
||||
- "A confirmation dialog appears before save showing which fields will be set/cleared and the track count"
|
||||
- "During batch save, a progress bar and 'N of M tracks' counter are visible inside the dialog"
|
||||
- "The cancel button stops the batch after the current track; already-written tracks keep changes"
|
||||
- "Partial failures show a summary with success count and per-failure details"
|
||||
- "Cover art can be set or cleared for all selected tracks at once"
|
||||
- "After batch save completes, dialog returns to read-only summary with refreshed data"
|
||||
artifacts:
|
||||
- path: "frontend/src/components/track-details/track-details.ts"
|
||||
provides: "Batch mode: multi-track show(), summary view, three-state editing, confirmation, progress, cover art"
|
||||
contains: "showBatch"
|
||||
- path: "frontend/src/components/track-list/track-list.ts"
|
||||
provides: "Updated context menu handler passing all selected filePaths to track-details"
|
||||
contains: "showBatch"
|
||||
- path: "frontend/src/components/cover-grid/cover-grid.ts"
|
||||
provides: "Updated context menu handler passing all selected filePaths to track-details"
|
||||
contains: "showBatch"
|
||||
- path: "frontend/src/components/queue-panel/queue-panel.ts"
|
||||
provides: "Updated context menu handler passing all selected filePaths to track-details"
|
||||
contains: "showBatch"
|
||||
- path: "frontend/src/components/playlist-details/playlist-details.ts"
|
||||
provides: "Updated context menu handler passing all selected filePaths to track-details"
|
||||
contains: "showBatch"
|
||||
key_links:
|
||||
- from: "frontend/src/components/track-details/track-details.ts"
|
||||
to: "frontend/wailsjs/go/tagwriter/TagWriter.js"
|
||||
via: "import { BatchWriteTrackTags, CancelBatchWrite }"
|
||||
pattern: "BatchWriteTrackTags"
|
||||
- from: "frontend/src/components/track-list/track-list.ts"
|
||||
to: "frontend/src/components/track-details/track-details.ts"
|
||||
via: "trackDetailsDialog.showBatch(tracks, coverArt)"
|
||||
pattern: "showBatch"
|
||||
- from: "frontend/src/components/track-details/track-details.ts"
|
||||
to: "frontend/src/events.ts"
|
||||
via: "EventsOn(Events.BatchWriteProgress, ...)"
|
||||
pattern: "BatchWriteProgress"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Adapt the track-details component for multi-track batch editing with three-state field model, progress UI, confirmation dialog, and batch cover art. Update all 4 view components to call the new batch API when multiple tracks are selected.
|
||||
|
||||
Purpose: Users need to efficiently edit shared metadata across multiple tracks — the dialog must show merged field values, support implicit three-state editing (keep/set/clear), provide a confirmation guard, show live progress during writes, handle partial failures gracefully, and support batch cover art operations.
|
||||
|
||||
Output: Fully functional batch edit mode in track-details component, all 4 views wired to use it.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/18-batch-edit/18-CONTEXT.md
|
||||
@.planning/phases/18-batch-edit/18-01-SUMMARY.md
|
||||
@.planning/phases/17-single-track-edit/17-02-SUMMARY.md
|
||||
|
||||
@frontend/src/components/track-details/track-details.ts
|
||||
@frontend/src/components/track-list/track-list.ts
|
||||
@frontend/src/components/cover-grid/cover-grid.ts
|
||||
@frontend/src/components/queue-panel/queue-panel.ts
|
||||
@frontend/src/components/playlist-details/playlist-details.ts
|
||||
@frontend/src/events.ts
|
||||
@frontend/wailsjs/go/tagwriter/TagWriter.js
|
||||
@frontend/wailsjs/go/tagwriter/TagWriter.d.ts
|
||||
@frontend/src/store/library-store.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. From Plan 01 output + existing codebase. -->
|
||||
|
||||
From backend/tagwriter/pipeline.go (created by Plan 01):
|
||||
```go
|
||||
type BatchFailure struct {
|
||||
FilePath string `json:"filePath"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type BatchResult struct {
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
Failures []BatchFailure `json:"failures"`
|
||||
}
|
||||
|
||||
func (tw *TagWriter) BatchWriteTrackTags(filePaths []string, changes TagChanges) BatchResult
|
||||
func (tw *TagWriter) CancelBatchWrite()
|
||||
```
|
||||
|
||||
Wails bindings (created by Plan 01):
|
||||
```typescript
|
||||
// TagWriter.d.ts
|
||||
export function BatchWriteTrackTags(arg1:Array<string>,arg2:tagwriter.TagChanges):Promise<tagwriter.BatchResult>;
|
||||
export function CancelBatchWrite():Promise<void>;
|
||||
```
|
||||
|
||||
BatchWriteProgress event payload shape:
|
||||
```typescript
|
||||
{ current: number, total: number, filePath: string, succeeded: number, failed: number }
|
||||
```
|
||||
|
||||
From frontend/src/components/track-details/track-details.ts (existing):
|
||||
```typescript
|
||||
export interface CoverArtUrls {
|
||||
coverArtPath: string;
|
||||
coverArtSmall: string;
|
||||
coverArtMedium: string;
|
||||
coverArtLarge: string;
|
||||
}
|
||||
|
||||
interface MetadataField {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
editable: boolean;
|
||||
type: 'text' | 'number';
|
||||
}
|
||||
|
||||
export class TrackDetails extends LitElement {
|
||||
show(track: library.Track, coverArt?: CoverArtUrls): void;
|
||||
close(): void;
|
||||
// State: editing, editValues, saving, errorMessage, pendingCoverArt, clearCoverArt
|
||||
// Methods: saveEdit, buildChanges, selectCoverArt, removeCoverArt, getEditValue, onEditInput
|
||||
}
|
||||
```
|
||||
|
||||
From library.Track (Wails model):
|
||||
```typescript
|
||||
class Track {
|
||||
TrackName: string; ArtistName: string; TrackLength: string;
|
||||
FilePath: string; TrackNumber: number; DiscNumber: number;
|
||||
Album: string; Genre: string[]; Year: number;
|
||||
Composer: string; FileType: string;
|
||||
// ... more fields
|
||||
}
|
||||
```
|
||||
|
||||
From each view's context menu handler (identical pattern in all 4):
|
||||
```typescript
|
||||
case 'track-details':
|
||||
this.openTrackDetails(filePaths[0]!);
|
||||
break;
|
||||
```
|
||||
|
||||
Each view has: `this.selection.getSelectedKeysOrdered()` returning `string[]` of file paths.
|
||||
Each view has: `resolveCoverArt(albumName: string): CoverArtUrls | null`
|
||||
Each view has: `@query('track-details') private trackDetailsDialog: TrackDetails`
|
||||
Each view has access to the tracks array for resolving file paths to Track objects.
|
||||
|
||||
From frontend/src/events.ts + Wails runtime:
|
||||
```typescript
|
||||
import { Events } from '../events';
|
||||
import { EventsOn, EventsOff } from '@wailsjs/runtime/runtime';
|
||||
// Usage: EventsOn(Events.BatchWriteProgress, (data) => { ... })
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add batch mode to track-details component</name>
|
||||
<files>frontend/src/components/track-details/track-details.ts</files>
|
||||
<action>
|
||||
This is the core task. Extend the existing `track-details` component to handle multi-track batch editing. The component already has full single-track edit infrastructure — batch mode adapts it.
|
||||
|
||||
**New state properties** (add to the existing @state() declarations):
|
||||
|
||||
```typescript
|
||||
@state() private batchMode = false;
|
||||
@state() private batchTracks: library.Track[] = [];
|
||||
@state() private batchFilePaths: string[] = [];
|
||||
@state() private batchCoverArt: CoverArtUrls | null = null; // shared cover art, or null if mixed
|
||||
@state() private batchCoverArtMixed = false; // true if tracks have different cover art
|
||||
@state() private batchProgress: { current: number; total: number } | null = null;
|
||||
@state() private batchResult: { succeeded: number; failed: number; cancelled: boolean; failures: Array<{ filePath: string; error: string }> } | null = null;
|
||||
@state() private showConfirmation = false;
|
||||
```
|
||||
|
||||
**New imports:**
|
||||
```typescript
|
||||
import { BatchWriteTrackTags, CancelBatchWrite } from '@go/tagwriter/TagWriter';
|
||||
import { EventsOn, EventsOff } from '@wailsjs/runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
```
|
||||
|
||||
**1. New public API — `showBatch()`:**
|
||||
|
||||
```typescript
|
||||
showBatch(
|
||||
tracks: library.Track[],
|
||||
coverArt: CoverArtUrls | null,
|
||||
coverArtMixed: boolean,
|
||||
): void {
|
||||
```
|
||||
|
||||
- Sets `this.batchMode = true`, `this.batchTracks = tracks`, `this.batchFilePaths = tracks.map(t => t.FilePath)`.
|
||||
- Sets `this.batchCoverArt = coverArt`, `this.batchCoverArtMixed = coverArtMixed`.
|
||||
- Clears single-track state: `this.track = null`.
|
||||
- Resets edit state: `editing = false`, `editValues = {}`, `errorMessage = ''`, `batchProgress = null`, `batchResult = null`, `showConfirmation = false`.
|
||||
- Cleans up pending cover art.
|
||||
- Opens dialog same as `show()`.
|
||||
|
||||
**2. Merged field values for summary/edit:**
|
||||
|
||||
Add a private method `getMergedFields()` that returns `MetadataField[]` with merged values:
|
||||
|
||||
```typescript
|
||||
private getMergedFields(): MetadataField[] {
|
||||
```
|
||||
|
||||
For each of the 8 editable fields (title, artist, album, genre, year, trackNumber, discNumber, composer), extract the value from every track in `batchTracks`. If all values are identical → the field value is that shared value. If values differ → the field value is `''` (empty string) with a flag indicating mixed.
|
||||
|
||||
Return MetadataField objects with the same structure as the single-track version. Add an optional `mixed` boolean to the MetadataField interface:
|
||||
```typescript
|
||||
interface MetadataField {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
editable: boolean;
|
||||
type: 'text' | 'number';
|
||||
mixed?: boolean; // true if values differ across batch tracks
|
||||
}
|
||||
```
|
||||
|
||||
For the field value extraction, use the same mapping as in `renderDetailFields`:
|
||||
- title → `t.TrackName`
|
||||
- artist → `t.ArtistName`
|
||||
- album → `t.Album`
|
||||
- genre → `(t.Genre ?? []).join(', ')`
|
||||
- year → `t.Year ? String(t.Year) : ''`
|
||||
- composer → `t.Composer ?? ''`
|
||||
- trackNumber → `t.TrackNumber ? String(t.TrackNumber) : ''`
|
||||
- discNumber → `t.DiscNumber ? String(t.DiscNumber) : ''`
|
||||
|
||||
**3. Render: summary/read-only view for batch mode:**
|
||||
|
||||
Modify the `override render()` method. When `batchMode && !editing && !batchProgress && !batchResult`:
|
||||
- Header: `${this.batchTracks.length} tracks selected` (instead of track title).
|
||||
- Cover art section: if `batchCoverArtMixed` show a placeholder with text like "Multiple cover arts" and a count. If shared, show the actual cover art (same as single-track).
|
||||
- For each merged field: if `mixed` show "N different values" in gray italic. If shared, show the actual value.
|
||||
- Buttons: "Edit" and "Close" (same as single-track read-only).
|
||||
- Do NOT show non-editable fields like file path, file type, bitrate, etc. (not meaningful for batch).
|
||||
|
||||
**4. Render: edit mode for batch:**
|
||||
|
||||
When `batchMode && editing && !batchProgress`:
|
||||
- Header: `Editing ${this.batchTracks.length} tracks`.
|
||||
- Cover art section with edit controls (same as single-track: click to pick, X to remove). If mixed, show placeholder; if shared, show art. pendingCoverArt and clearCoverArt work the same.
|
||||
- For each merged field: render an input. Pre-populate with the shared value (if not mixed). If mixed, show empty input with `placeholder="Multiple values"` in gray italic style.
|
||||
- Three-state field model is implicit via the existing `editValues` + `getEditValue` pattern:
|
||||
- **Keep original:** user doesn't touch the field → key NOT in `editValues` → not sent in TagChanges.
|
||||
- **Set value:** user types → key IN `editValues` with the typed value → sent in TagChanges.
|
||||
- **Clear field:** user types then deletes everything → key IN `editValues` with `""` → sent in TagChanges (the value is empty string, which the backend writes as clearing the field).
|
||||
- The existing `onEditInput` handler already adds the key to `editValues` on any input event, which is exactly the dirty-tracking mechanism needed.
|
||||
- Buttons: "Cancel" and "Save" (same as single-track edit mode).
|
||||
|
||||
**5. Confirmation dialog:**
|
||||
|
||||
When user clicks "Save" in batch edit mode, set `this.showConfirmation = true` instead of saving immediately. Render a confirmation overlay within the dialog:
|
||||
|
||||
```html
|
||||
<div class="confirmation-overlay">
|
||||
<div class="confirmation-content">
|
||||
<h3>Apply changes to ${this.batchTracks.length} tracks?</h3>
|
||||
<div class="confirmation-summary">
|
||||
<!-- List each dirty field with its new value or "(clear)" -->
|
||||
</div>
|
||||
<div class="confirmation-actions">
|
||||
<button class="btn" @click=${this.cancelConfirmation}>Cancel</button>
|
||||
<button class="btn btn-primary" @click=${this.confirmSave}>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Build the summary from `editValues`: for each key in editValues, show `"Set {label} to '{value}'"` or `"Clear {label}"` if value is empty. If cover art is pending: `"Set cover art"`. If clearCoverArt: `"Remove cover art"`.
|
||||
|
||||
Style the overlay: position absolute, full dialog coverage, semi-transparent backdrop, centered card.
|
||||
|
||||
**6. Batch save flow (`confirmSave`):**
|
||||
|
||||
When user confirms:
|
||||
- Set `showConfirmation = false`.
|
||||
- Set `batchProgress = { current: 0, total: batchFilePaths.length }`.
|
||||
- Build TagChanges from `buildBatchChanges()` (new method, similar to `buildChanges` but for batch — only includes dirty fields, no diff against original since batch doesn't have a single original).
|
||||
- Register a Wails event listener for `BatchWriteProgress`:
|
||||
```typescript
|
||||
const cleanup = EventsOn(Events.BatchWriteProgress, (data: any) => {
|
||||
this.batchProgress = { current: data.current, total: data.total };
|
||||
});
|
||||
```
|
||||
- Call `await BatchWriteTrackTags(this.batchFilePaths, changes)`.
|
||||
- After completion, call `EventsOff(Events.BatchWriteProgress)` (or use the cleanup function).
|
||||
- Store result in `this.batchResult`.
|
||||
- Set `batchProgress = null`.
|
||||
|
||||
**New method `buildBatchChanges()`:**
|
||||
```typescript
|
||||
private buildBatchChanges(): Record<string, unknown> {
|
||||
const changes: Record<string, unknown> = {};
|
||||
// Same fieldMap as buildChanges but WITHOUT diff against original —
|
||||
// every key in editValues is a change.
|
||||
const fieldMap = [
|
||||
{ editKey: 'title', backendKey: 'title' },
|
||||
{ editKey: 'artist', backendKey: 'artist' },
|
||||
{ editKey: 'album', backendKey: 'album' },
|
||||
{ editKey: 'genre', backendKey: 'genre' },
|
||||
{ editKey: 'year', backendKey: 'year', transform: (v: string) => v ? parseInt(v, 10) : 0 },
|
||||
{ editKey: 'composer', backendKey: 'composer' },
|
||||
{ editKey: 'trackNumber', backendKey: 'track_number', transform: (v: string) => v ? parseInt(v, 10) : 0 },
|
||||
{ editKey: 'discNumber', backendKey: 'disc_number', transform: (v: string) => v ? parseInt(v, 10) : 0 },
|
||||
];
|
||||
for (const { editKey, backendKey, transform } of fieldMap) {
|
||||
if (editKey in this.editValues) {
|
||||
const val = this.editValues[editKey]!;
|
||||
changes[backendKey] = transform ? transform(val) : val;
|
||||
}
|
||||
}
|
||||
// Cover art
|
||||
if (this.pendingCoverArt) {
|
||||
changes['cover_art'] = Array.from(new Uint8Array(this.pendingCoverArt.data));
|
||||
} else if (this.clearCoverArt) {
|
||||
changes['cover_art'] = null;
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
```
|
||||
|
||||
**7. Progress UI:**
|
||||
|
||||
When `batchProgress` is not null, render:
|
||||
```html
|
||||
<div class="batch-progress">
|
||||
<div class="progress-text">${batchProgress.current} of ${batchProgress.total} tracks</div>
|
||||
<div class="progress-bar-track">
|
||||
<div class="progress-bar-fill" style="width: ${(batchProgress.current / batchProgress.total) * 100}%"></div>
|
||||
</div>
|
||||
<button class="btn" @click=${this.cancelBatchWrite}>Cancel</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
The `cancelBatchWrite` handler calls `CancelBatchWrite()` (the Wails binding).
|
||||
|
||||
Style the progress bar: full-width track with rounded corners, fill uses the app's accent color (`var(--yj-accent, #4a9eff)`), smooth width transition.
|
||||
|
||||
**8. Results view:**
|
||||
|
||||
When `batchResult` is not null, render:
|
||||
- If `batchResult.cancelled`: "Batch cancelled — {succeeded} of {total} tracks updated"
|
||||
- Else if `batchResult.failed === 0`: "All {succeeded} tracks updated successfully"
|
||||
- Else: "{succeeded} tracks updated, {failed} failed"
|
||||
- If failures exist, show an expandable list of failures (file name + error).
|
||||
- "Close" button that resets to summary view with refreshed data.
|
||||
|
||||
After displaying results and user clicks "Close":
|
||||
- Reset `batchResult = null`, `batchProgress = null`.
|
||||
- Re-fetch tracks from `libraryStore.getTracks()` and albums from `libraryStore.getAlbums()`.
|
||||
- Re-resolve `batchTracks` from refreshed data (filter tracks by batchFilePaths).
|
||||
- Re-resolve cover art (check if all tracks now share the same album art).
|
||||
- Return to read-only summary view with updated data.
|
||||
|
||||
**9. CSS additions:**
|
||||
|
||||
Add styles for:
|
||||
- `.batch-header` — larger text showing track count
|
||||
- `.mixed-value` — gray italic placeholder text for "Multiple values" and "N different values"
|
||||
- `.confirmation-overlay` — absolute positioned overlay with backdrop
|
||||
- `.confirmation-content` — centered card with padding
|
||||
- `.confirmation-summary` — list of changes
|
||||
- `.batch-progress` — progress section layout
|
||||
- `.progress-bar-track` — progress bar track (gray background, rounded)
|
||||
- `.progress-bar-fill` — progress bar fill (accent color, transition: width 0.3s)
|
||||
- `.batch-result` — results section
|
||||
- `.failure-list` — expandable failure details
|
||||
|
||||
**10. Close/cleanup behavior:**
|
||||
|
||||
Override `close()` to also call `CancelBatchWrite()` if `batchProgress` is not null (closing during progress cancels the batch). Reset all batch state.
|
||||
|
||||
**CRITICAL IMPLEMENTATION NOTES:**
|
||||
- The single-track `show()` method remains unchanged — it sets `batchMode = false`.
|
||||
- All existing single-track rendering and behavior continues to work when `batchMode === false`.
|
||||
- The render method should branch on `batchMode` early to avoid complex conditional nesting. Consider helper methods like `renderBatchSummary()`, `renderBatchEdit()`, `renderBatchProgress()`, `renderBatchResult()`.
|
||||
- Follow existing code style: arrow function handlers, @state() decorators, html tagged template literals, `override` keyword.
|
||||
- Import type for type-only imports.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -30; echo "---"; grep -c "showBatch\|batchMode\|batchProgress\|buildBatchChanges\|confirmSave\|cancelBatchWrite\|renderBatchSummary\|BatchWriteTrackTags" src/components/track-details/track-details.ts</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Track-details component supports batch mode with:
|
||||
- showBatch() public API for multi-track entry
|
||||
- Read-only summary showing merged field values with "Multiple values" for mixed fields
|
||||
- Edit mode with implicit three-state field model (keep/set/clear via dirty tracking)
|
||||
- Confirmation dialog listing all pending changes before save
|
||||
- Progress bar with "N of M" counter during batch write, wired to BatchWriteProgress events
|
||||
- Cancel button calling CancelBatchWrite
|
||||
- Results summary showing success/failure counts with expandable failure details
|
||||
- Batch cover art: pick, preview, or clear for all tracks
|
||||
- Post-save data refresh returning to updated summary view
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Update all view context menu handlers for batch mode</name>
|
||||
<files>
|
||||
frontend/src/components/track-list/track-list.ts,
|
||||
frontend/src/components/cover-grid/cover-grid.ts,
|
||||
frontend/src/components/queue-panel/queue-panel.ts,
|
||||
frontend/src/components/playlist-details/playlist-details.ts
|
||||
</files>
|
||||
<action>
|
||||
Update the `'track-details'` case in `onContextMenuAction` for each of the 4 view components. Currently each does:
|
||||
```typescript
|
||||
case 'track-details':
|
||||
this.openTrackDetails(filePaths[0]!);
|
||||
break;
|
||||
```
|
||||
|
||||
Change to:
|
||||
```typescript
|
||||
case 'track-details':
|
||||
if (filePaths.length === 1) {
|
||||
this.openTrackDetails(filePaths[0]!);
|
||||
} else {
|
||||
this.openBatchTrackDetails(filePaths);
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
Add a new private method `openBatchTrackDetails(filePaths: string[])` to each view:
|
||||
|
||||
**For track-list.ts:**
|
||||
```typescript
|
||||
private openBatchTrackDetails(filePaths: string[]) {
|
||||
const tracks = filePaths
|
||||
.map((fp) => this.tracks.find((t) => t.FilePath === fp))
|
||||
.filter((t): t is library.Track => t != null);
|
||||
|
||||
if (tracks.length === 0) return;
|
||||
|
||||
// Resolve cover art: check if all tracks share the same album.
|
||||
const albumNames = new Set(tracks.map((t) => t.Album));
|
||||
let coverArt: CoverArtUrls | null = null;
|
||||
let coverArtMixed = false;
|
||||
|
||||
if (albumNames.size === 1) {
|
||||
const albumName = [...albumNames][0]!;
|
||||
coverArt = this.resolveCoverArt(albumName);
|
||||
} else {
|
||||
coverArtMixed = true;
|
||||
}
|
||||
|
||||
this.trackDetailsDialog?.showBatch(tracks, coverArt, coverArtMixed);
|
||||
}
|
||||
```
|
||||
|
||||
**For cover-grid.ts:** Same pattern but tracks come from `this.currentTracks` or `this.albumTracks` depending on current view state. Check how cover-grid stores its track list — it may use a different property name. The cover-grid has tracks available via album detail tracks. Look for where tracks are stored and use the same source.
|
||||
|
||||
**For queue-panel.ts:** Queue panel uses indices, not file paths. The existing `openTrackDetails(index: number)` resolves queue tracks by index. For batch, the context menu handler has `indices = this.selection.getSelectedKeysOrdered()` (which are indices for queue). Map indices to queue tracks:
|
||||
```typescript
|
||||
private openBatchTrackDetails(indices: number[]) {
|
||||
const queueTracks = queueStore.tracks;
|
||||
const tracks = indices
|
||||
.map((i) => queueTracks[i])
|
||||
.filter((t): t is QueueTrack => t != null);
|
||||
|
||||
// QueueTrack has different shape than library.Track — need to resolve
|
||||
// from library store. QueueTrack has filePath.
|
||||
// ... resolve tracks from library store or adapt...
|
||||
}
|
||||
```
|
||||
|
||||
**IMPORTANT for queue-panel:** The queue panel's selection uses numeric indices, not file paths. The `onContextMenuAction` handler may already convert to file paths or indices. Check the actual code carefully. The queue-panel context menu handler likely already has access to `filePaths` or can derive them from queue tracks. Each QueueTrack has a `filePath` field. Resolve the library.Track objects from `libraryStore.getTracks()` (await) or use the queue tracks' metadata directly. The key insight: `showBatch` needs `library.Track[]` objects — queue panel must resolve them.
|
||||
|
||||
**For playlist-details.ts:** Similar to track-list. Has its own tracks array. Use the same pattern.
|
||||
|
||||
**Each view's `openBatchTrackDetails` method must:**
|
||||
1. Resolve file paths to `library.Track[]` objects from the view's available track data
|
||||
2. Determine cover art state: if all tracks share same album → resolve shared art. If different albums → coverArtMixed = true.
|
||||
3. Call `this.trackDetailsDialog?.showBatch(tracks, coverArt, coverArtMixed)`
|
||||
|
||||
The `resolveCoverArt(albumName)` method already exists on each view and can be reused.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -30; echo "---"; grep -c "openBatchTrackDetails\|showBatch" src/components/track-list/track-list.ts src/components/cover-grid/cover-grid.ts src/components/queue-panel/queue-panel.ts src/components/playlist-details/playlist-details.ts</automated>
|
||||
</verify>
|
||||
<done>
|
||||
All 4 view components (track-list, cover-grid, queue-panel, playlist-details) branch on selection count in the track-details context menu action: 1 track → existing openTrackDetails, 2+ tracks → new openBatchTrackDetails that resolves tracks, determines cover art state, and calls showBatch().
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: Verify complete batch edit flow</name>
|
||||
<files>frontend/src/components/track-details/track-details.ts</files>
|
||||
<action>
|
||||
Human verification of the complete batch edit flow. Run `wails dev` and test:
|
||||
|
||||
1. **Batch summary view:** In the track list, select 3+ tracks with different metadata. Right-click → Track Details. Verify header shows "N tracks selected", shared fields show value, mixed fields show "Multiple values" placeholder.
|
||||
|
||||
2. **Three-state editing:** Click "Edit". Verify shared fields pre-populated, mixed fields have placeholder, typing marks fields dirty, untouched fields are not sent on save, clearing a field sends empty.
|
||||
|
||||
3. **Confirmation dialog:** Click "Save". Verify confirmation overlay shows field changes and track count.
|
||||
|
||||
4. **Progress:** Click "Apply" on 5+ tracks. Verify progress bar advances and counter updates.
|
||||
|
||||
5. **Results:** After batch completes, verify success/failure summary. Close returns to updated summary.
|
||||
|
||||
6. **Cover art:** Pick/remove in batch mode applies to all tracks.
|
||||
|
||||
7. **Single-track unchanged:** Select 1 track → Track Details works as before.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd frontend && npx tsc --noEmit && echo "TYPECHECK OK"</automated>
|
||||
</verify>
|
||||
<done>All batch edit user flows verified: summary view, three-state editing, confirmation, progress, results, cover art, and single-track regression check passes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `cd frontend && npx tsc --noEmit` — TypeScript compiles with no errors
|
||||
2. `cd backend && go build ./...` — backend still compiles (no regressions)
|
||||
3. Select 2+ tracks → Track Details → shows batch summary (not single track)
|
||||
4. Select 1 track → Track Details → shows single track (existing behavior unchanged)
|
||||
5. Batch edit → save → all tracks updated with correct field values
|
||||
6. Progress bar visible during batch save of 5+ tracks
|
||||
7. Cover art batch set/clear works across all selected tracks
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Batch mode activates when 2+ tracks are selected from any of the 4 views
|
||||
- Summary view correctly shows shared vs mixed field values
|
||||
- Three-state field model works: untouched fields preserved, typed fields set, cleared fields clear
|
||||
- Confirmation dialog appears before batch save with change summary
|
||||
- Progress bar and track counter visible during batch write
|
||||
- Partial failures collected and displayed
|
||||
- Cancel stops remaining tracks
|
||||
- Cover art pick/clear applies to all selected tracks
|
||||
- Single-track mode unchanged (no regression)
|
||||
- All 4 views correctly dispatch to showBatch for multi-select
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/18-batch-edit/18-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user