Files
yellowjacket/.planning/phases/18-batch-edit/18-02-PLAN.md
T
2026-03-18 12:30:36 -04:00

26 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
18-batch-edit 02 execute 2
18-01
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
false
BATCH-01
BATCH-02
BATCH-03
BATCH-04
truths artifacts key_links
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
path provides contains
frontend/src/components/track-details/track-details.ts Batch mode: multi-track show(), summary view, three-state editing, confirmation, progress, cover art showBatch
path provides contains
frontend/src/components/track-list/track-list.ts Updated context menu handler passing all selected filePaths to track-details showBatch
path provides contains
frontend/src/components/cover-grid/cover-grid.ts Updated context menu handler passing all selected filePaths to track-details showBatch
path provides contains
frontend/src/components/queue-panel/queue-panel.ts Updated context menu handler passing all selected filePaths to track-details showBatch
path provides contains
frontend/src/components/playlist-details/playlist-details.ts Updated context menu handler passing all selected filePaths to track-details showBatch
from to via pattern
frontend/src/components/track-details/track-details.ts frontend/wailsjs/go/tagwriter/TagWriter.js import { BatchWriteTrackTags, CancelBatchWrite } BatchWriteTrackTags
from to via pattern
frontend/src/components/track-list/track-list.ts frontend/src/components/track-details/track-details.ts trackDetailsDialog.showBatch(tracks, coverArt) showBatch
from to via pattern
frontend/src/components/track-details/track-details.ts frontend/src/events.ts EventsOn(Events.BatchWriteProgress, ...) BatchWriteProgress
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.

<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>

@.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

From backend/tagwriter/pipeline.go (created by Plan 01):

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):

// TagWriter.d.ts
export function BatchWriteTrackTags(arg1:Array<string>,arg2:tagwriter.TagChanges):Promise<tagwriter.BatchResult>;
export function CancelBatchWrite():Promise<void>;

BatchWriteProgress event payload shape:

{ current: number, total: number, filePath: string, succeeded: number, failed: number }

From frontend/src/components/track-details/track-details.ts (existing):

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):

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):

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:

import { Events } from '../events';
import { EventsOn, EventsOff } from '@wailsjs/runtime/runtime';
// Usage: EventsOn(Events.BatchWriteProgress, (data) => { ... })
Task 1: Add batch mode to track-details component frontend/src/components/track-details/track-details.ts 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):

@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:

import { BatchWriteTrackTags, CancelBatchWrite } from '@go/tagwriter/TagWriter';
import { EventsOn, EventsOff } from '@wailsjs/runtime/runtime';
import { Events } from '../../events';

1. New public API — showBatch():

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:

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:

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:

<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:
    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():

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:

<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. 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 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
Task 2: Update all view context menu handlers for batch mode 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 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:

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:

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:

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. 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 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().

Task 3: Verify complete batch edit flow frontend/src/components/track-details/track-details.ts 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. cd frontend && npx tsc --noEmit && echo "TYPECHECK OK" All batch edit user flows verified: summary view, three-state editing, confirmation, progress, results, cover art, and single-track regression check passes.

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

<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>
After completion, create `.planning/phases/18-batch-edit/18-02-SUMMARY.md`