Archive v1.2 milestone: ROADMAP + REQUIREMENTS + phases to milestones/. Evolve PROJECT.md with v1.2 validated requirements and key decisions. Update RETROSPECTIVE.md with v1.2 lessons and cross-milestone trends. Clean STATE.md for next milestone.
22 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 | |||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 17-single-track-edit | 02 | execute | 2 |
|
|
false |
|
|
Purpose: This is the core user-facing work of Phase 17. The track-details dialog already has full edit mode scaffolding (inputs, editValues record, Edit/Save/Cancel buttons) but saveEdit() is a TODO stub. This plan implements the real save flow, adds cover art replacement/removal UI, and handles errors inline in the dialog.
Output: A fully functional single-track tag editor that writes changes to the audio file, updates the database, and refreshes views.
<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/17-single-track-edit/17-CONTEXT.md @.planning/phases/17-single-track-edit/17-01-SUMMARY.mdFrom frontend/wailsjs/go/tagwriter/TagWriter (generated by Plan 01):
export function WriteTrackTagsByPath(filePath: string, changes: Record<string, any>): Promise<void>;
From frontend/wailsjs/go/frontendutil/FrontendUtil (generated by Plan 01):
export function ImageFilePicker(): Promise<string>;
From backend/tagwriter/tagwriter.go (field constants — use these as diff map keys):
title, artist, album, album_artist, genre, year,
track_number, disc_number, composer, cover_art
From frontend/src/components/track-details/track-details.ts (existing state):
@state() private track: library.Track | null = null;
@state() private coverArt: CoverArtUrls | null = null;
@state() private editing = false;
@state() private editValues: Record<string, string> = {};
// Existing methods:
show(track: library.Track, coverArt?: CoverArtUrls): void
startEdit(): void // sets editing=true, clears editValues
cancelEdit(): void // sets editing=false, clears editValues
saveEdit(): void // TODO stub — exits edit mode
getEditValue(key, fallback): string
onEditInput(key, e): void
// Edit field keys used in editValues:
// Main: 'title', 'artist', 'album'
// Detail grid: 'genre', 'year', 'composer', 'trackNumber', 'discNumber'
From library.Track type (Go → TS):
interface Track {
TrackName: string;
ArtistName: string;
TrackLength: string; // milliseconds as string
FilePath: string;
TrackNumber: number;
DiscNumber: number;
Album: string;
Genre: string[];
Year: number;
Composer: string;
FileType: string;
SampleRate: number;
BitDepth: number;
Channels: number;
Bitrate: number;
FileSize: number;
}
@state() private saving = false;
@state() private errorMessage = '';
@state() private pendingCoverArt: { data: ArrayBuffer; previewUrl: string } | null = null;
@state() private clearCoverArt = false;
saving: true while WriteTrackTagsByPath is in progresserrorMessage: error string shown inline in the dialog when save failspendingCoverArt: holds the selected cover art image (read from disk) and its object URL for instant previewclearCoverArt: true when user wants to remove existing embedded cover art
Add new imports at the top of the file:
import { WriteTrackTagsByPath } from '@go/tagwriter/TagWriter';
import { ImageFilePicker } from '@go/frontendutil/FrontendUtil';
Implement saveEdit — replace the TODO stub:
private saveEdit = async () => {
if (!this.track || this.saving) return;
this.saving = true;
this.errorMessage = '';
try {
const changes = this.buildChanges();
if (Object.keys(changes).length === 0) {
// No actual changes — just exit edit mode.
this.exitEditMode();
return;
}
await WriteTrackTagsByPath(this.track.FilePath, changes);
// Success — switch to read-only view, re-fetch track data.
// The TrackMetadataChanged event will trigger library store
// invalidation, which refreshes all other views. The dialog
// itself stays open in read-only mode so the user can verify.
this.exitEditMode();
// Note: The dialog's track data will be stale until the parent
// component re-opens it or we add a refresh mechanism.
// For now, closing edit mode with the old data is acceptable
// since the user can see updated data in the views behind.
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.errorMessage = msg;
} finally {
this.saving = false;
}
};
Add buildChanges helper — builds the TagChanges diff map from editValues, comparing against original track values. Only include fields that actually changed:
private buildChanges(): Record<string, any> {
const t = this.track!;
const changes: Record<string, any> = {};
// Map frontend edit keys to backend field constants and original values.
const fieldMap: Array<{
editKey: string;
backendKey: string;
original: string;
transform?: (v: string) => any;
}> = [
{ editKey: 'title', backendKey: 'title', original: t.TrackName },
{ editKey: 'artist', backendKey: 'artist', original: t.ArtistName },
{ editKey: 'album', backendKey: 'album', original: t.Album },
{ editKey: 'genre', backendKey: 'genre', original: (t.Genre ?? []).join(', ') },
{
editKey: 'year',
backendKey: 'year',
original: t.Year ? String(t.Year) : '',
transform: (v) => v ? parseInt(v, 10) : 0,
},
{ editKey: 'composer', backendKey: 'composer', original: t.Composer ?? '' },
{
editKey: 'trackNumber',
backendKey: 'track_number',
original: t.TrackNumber ? String(t.TrackNumber) : '',
transform: (v) => v ? parseInt(v, 10) : 0,
},
{
editKey: 'discNumber',
backendKey: 'disc_number',
original: t.DiscNumber ? String(t.DiscNumber) : '',
transform: (v) => v ? parseInt(v, 10) : 0,
},
];
for (const { editKey, backendKey, original, transform } of fieldMap) {
if (editKey in this.editValues) {
const newVal = this.editValues[editKey]!;
if (newVal !== original) {
changes[backendKey] = transform ? transform(newVal) : newVal;
}
}
}
// Cover art changes.
if (this.pendingCoverArt) {
// Convert ArrayBuffer to number[] for JSON serialization
// (Wails will pass this as []byte on the Go side).
changes['cover_art'] = Array.from(
new Uint8Array(this.pendingCoverArt.data),
);
} else if (this.clearCoverArt) {
changes['cover_art'] = null;
}
return changes;
}
Add exitEditMode helper:
private exitEditMode(): void {
this.editing = false;
this.editValues = {};
this.errorMessage = '';
this.cleanupPendingCoverArt();
}
Add cover art cleanup helper:
private cleanupPendingCoverArt(): void {
if (this.pendingCoverArt?.previewUrl) {
URL.revokeObjectURL(this.pendingCoverArt.previewUrl);
}
this.pendingCoverArt = null;
this.clearCoverArt = false;
}
Add selectCoverArt handler — opens native file picker, reads file, creates preview:
private selectCoverArt = async () => {
try {
const filePath = await ImageFilePicker();
if (!filePath) return; // User cancelled.
// Read the file as bytes via fetch from the filesystem.
// Wails serves local files via the asset handler, but we
// need the raw bytes. Use a Go helper or read via fetch.
// Actually, we need to read the file on the Go side and
// return the bytes. For now, store just the path and
// let the Go side read it during WriteTrackTags.
//
// Alternative approach: Read file in Go, return base64.
// But WriteTrackTags already handles reading cover_art
// as []byte from the changes map.
//
// Simplest approach: Read file via Go, return bytes.
// But we also need a preview. Two options:
// A) Read in Go, return base64, decode for preview
// B) Use Wails local file URL for preview, read in Go for save
//
// Going with approach B: preview via local file URL,
// save by reading file bytes in a new Go method.
// Actually — Wails doesn't serve arbitrary local files.
//
// Going with approach A: Add a ReadFileBytes Go method,
// or just read the file path in the cover art changes.
//
// Simplest: Pass the file PATH as cover_art in changes.
// The Go side detects string vs []byte and reads the file.
// BUT: TagChanges defines cover_art as []byte.
//
// Most practical approach for preview + save:
// Use a FileReader to read the file... but we don't have
// a File object (we have a path from a native dialog).
//
// DECISION: Add a ReadImageFile Go method to FrontendUtil
// that returns base64 string. Use for both preview and save.
// OR: Change the cover_art handling to accept a file path
// string and read it in Go.
//
// Actually, the simplest approach per CONTEXT.md:
// "Cover art bytes are sent to WriteTrackTags via the
// cover_art field ([]byte for set, nil/sentinel for clear)"
// So we need the bytes on the frontend. But we only have a
// file path. We need a Go helper to read the file.
// PRACTICAL SOLUTION: Store the file path. Add a small
// Go helper `ReadFile(path string) ([]byte, error)` on
// FrontendUtil that returns the raw bytes. Use the bytes
// for both preview (via Blob URL) and save (via changes map).
//
// This is the cleanest approach. Implement ReadFile below.
const bytes = await this.readCoverArtFile(filePath);
if (!bytes) return;
// Create preview URL from bytes.
const blob = new Blob([bytes]);
const previewUrl = URL.createObjectURL(blob);
this.cleanupPendingCoverArt();
this.pendingCoverArt = {
data: bytes.buffer,
previewUrl,
};
this.clearCoverArt = false;
} catch (err) {
console.error('Failed to select cover art:', err);
}
};
IMPORTANT IMPLEMENTATION NOTE: The above approach requires reading the selected image file's bytes on the Go side and returning them to the frontend. Add a ReadFile method to FrontendUtil:
In backend/frontendutil/frontendutil.go, add:
// ReadFile reads a file from disk and returns its contents.
// Used by the frontend to read cover art image files selected
// via ImageFilePicker.
func (fe *FrontendUtil) ReadFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file %q: %w", path, err)
}
return data, nil
}
Add "os" to the imports if not present. Then add the frontend wrapper:
private async readCoverArtFile(filePath: string): Promise<Uint8Array | null> {
try {
// ReadFile returns number[] (Go []byte serialized as JSON array).
const { ReadFile } = await import('@go/frontendutil/FrontendUtil');
const bytes = await ReadFile(filePath);
return new Uint8Array(bytes);
} catch (err) {
console.error('Failed to read cover art file:', err);
return null;
}
}
After adding ReadFile to Go, run make generate to create the binding.
Add removeCoverArt handler:
private removeCoverArt = () => {
this.cleanupPendingCoverArt();
this.clearCoverArt = true;
};
Update renderCoverArt — in edit mode, make the cover art clickable with an edit overlay and a remove button:
In edit mode:
- Wrap the cover art in a clickable container with a semi-transparent overlay showing an edit/pencil icon
- If
pendingCoverArtis set, show itspreviewUrlinstead of the original cover art - If
clearCoverArtis true, show the placeholder (music icon) - Add a small "×" remove button positioned absolutely in the top-right corner of the cover art
Add CSS for:
.cover-art-editcontainer withcursor: pointerandposition: relative.cover-art-overlay— semi-transparent dark overlay with centered pencil icon, shown on hover.cover-art-remove— small × button in top-right corner,position: absolute
private renderCoverArt() {
if (this.editing) {
return this.renderCoverArtEditable();
}
// ... existing read-only render
}
private renderCoverArtEditable() {
const showRemove = !this.clearCoverArt && (this.pendingCoverArt || this.coverArt);
// Determine which image to show.
let src: string | undefined;
if (this.clearCoverArt) {
src = undefined; // Show placeholder.
} else if (this.pendingCoverArt) {
src = this.pendingCoverArt.previewUrl;
} else {
src = this.coverArt?.coverArtLarge ??
this.coverArt?.coverArtMedium ??
this.coverArt?.coverArtPath;
}
return html`
<div class="cover-art cover-art-edit" @click=${this.selectCoverArt}>
${src
? html`<img src="${src}" alt="Album cover" @error=${this.handleImageError} />`
: html`<div class="cover-placeholder">
<wa-icon name="music"></wa-icon>
</div>`}
<div class="cover-art-overlay">
<wa-icon name="pen-to-square"></wa-icon>
</div>
${showRemove
? html`<button class="cover-art-remove"
@click=${(e: Event) => { e.stopPropagation(); this.removeCoverArt(); }}
title="Remove cover art">×</button>`
: nothing}
</div>
`;
}
Add CSS for cover art edit mode:
.cover-art-edit {
cursor: pointer;
position: relative;
}
.cover-art-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease;
border-radius: 6px;
}
.cover-art-edit:hover .cover-art-overlay {
opacity: 1;
}
.cover-art-overlay wa-icon {
color: #fff;
font-size: 32px;
}
.cover-art-remove {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease;
}
.cover-art-edit:hover .cover-art-remove {
opacity: 1;
}
.cover-art-remove:hover {
background: var(--yj-error, #e03131);
}
Update renderActions — add saving state and error display:
private renderActions() {
if (this.editing) {
return html`
${this.errorMessage
? html`<div class="error-message">${this.errorMessage}</div>`
: nothing}
<button class="btn" @click=${this.cancelEdit} ?disabled=${this.saving}>
Cancel
</button>
<button class="btn btn-primary" @click=${this.saveEdit} ?disabled=${this.saving}>
${this.saving ? 'Saving…' : 'Save'}
</button>
`;
}
// ... existing Edit button
}
Add CSS for error message:
.error-message {
flex: 1;
color: var(--yj-error, #e03131);
font-size: var(--yj-text-sm);
padding: 4px 0;
word-break: break-word;
}
Update cancelEdit to clean up cover art state:
private cancelEdit = () => {
this.exitEditMode();
};
Update startEdit to clear error and cover art state:
private startEdit = () => {
this.editing = true;
this.editValues = {};
this.errorMessage = '';
this.cleanupPendingCoverArt();
};
Update the show method to clean up any stale cover art preview:
In the show method, add this.cleanupPendingCoverArt(); and this.errorMessage = ''; alongside the existing state resets.
Update the close method to clean up:
In close, add this.cleanupPendingCoverArt(); and this.errorMessage = '';.
pnpm run typecheck in frontend/ passes.
go build -tags webkit2_41 ./... compiles (for the ReadFile addition).
make generate succeeds.
Manual visual test (checkpoint task below).
saveEdit() builds a diff map of only changed fields and calls WriteTrackTagsByPath.
Save button shows "Saving…" and is disabled during save; Cancel is also disabled.
Errors display inline in the dialog action bar; edit mode stays active on error.
In edit mode, clicking cover art opens native file picker for JPEG/PNG.
Selected image previews instantly via object URL.
Remove button (×) appears on cover art hover in edit mode to clear embedded art.
After successful save, dialog returns to read-only view mode.
All edit state (editValues, pendingCoverArt, clearCoverArt, errorMessage) cleaned up on close/cancel.
<success_criteria> User can edit any track's metadata and cover art from within the app. Changes are written to the audio file, synchronized to the database and search index, and reflected in all views immediately without restarting or rescanning. </success_criteria>
After completion, create `.planning/phases/17-single-track-edit/17-02-SUMMARY.md`