docs(17): create phase plan for single track edit
This commit is contained in:
1 parent
9951a362ea
commit
4f7c800fb6
3 files changed
+923
-1
No files matched your search
@@ -0,0 +1,285 @@
|
||||
---
|
||||
phase: 17-single-track-edit
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- backend/tagwriter/pipeline.go
|
||||
- backend/frontendutil/frontendutil.go
|
||||
- frontend/src/store/library-store.ts
|
||||
- frontend/src/components/track-list/track-list.ts
|
||||
- frontend/src/components/queue-panel/queue-panel.ts
|
||||
- frontend/src/components/cover-grid/cover-grid.ts
|
||||
- frontend/src/components/playlist-details/playlist-details.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- EDIT-01
|
||||
- EDIT-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "WriteTrackTagsByPath accepts a file path string and TagChanges, resolves the track ID internally, and delegates to WriteTrackTags"
|
||||
- "ImageFilePicker opens a native file dialog filtered to JPEG/PNG and returns the selected file path"
|
||||
- "After a successful tag write, the library store invalidates all caches and re-fetches data so all views reflect the new metadata"
|
||||
- "Right-clicking any single track in track-list, queue-panel, cover-grid, or playlist-details shows 'Track Details' in the context menu regardless of selection state"
|
||||
artifacts:
|
||||
- path: "backend/tagwriter/pipeline.go"
|
||||
provides: "WriteTrackTagsByPath method on TagWriter"
|
||||
contains: "func (tw *TagWriter) WriteTrackTagsByPath"
|
||||
- path: "backend/frontendutil/frontendutil.go"
|
||||
provides: "ImageFilePicker method for cover art selection"
|
||||
contains: "func (fe *FrontendUtil) ImageFilePicker"
|
||||
- path: "frontend/src/store/library-store.ts"
|
||||
provides: "TrackMetadataChanged event handler calling invalidate()"
|
||||
contains: "TrackMetadataChanged"
|
||||
- path: "frontend/src/components/track-list/track-list.ts"
|
||||
provides: "Track Details context menu item visible for any right-clicked track"
|
||||
- path: "frontend/src/components/queue-panel/queue-panel.ts"
|
||||
provides: "Track Details context menu item visible for any right-clicked track"
|
||||
key_links:
|
||||
- from: "frontend/src/store/library-store.ts"
|
||||
to: "backend events"
|
||||
via: "EventsOn(Events.TrackMetadataChanged)"
|
||||
pattern: "EventsOn.*TrackMetadataChanged"
|
||||
- from: "backend/tagwriter/pipeline.go"
|
||||
to: "backend/database"
|
||||
via: "GetAudioFileByPath query"
|
||||
pattern: "GetAudioFileByPath"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the backend bridge methods and frontend plumbing needed for single-track tag editing.
|
||||
|
||||
Purpose: Phase 17 builds on the WriteTrackTags pipeline from Phase 16. The frontend identifies tracks by `FilePath` but WriteTrackTags requires `trackID int64`. This plan adds a path-based wrapper, a cover art file picker, the library store event handler that refreshes views after edits, and removes the selection-count gate on the "Track Details" context menu item.
|
||||
|
||||
Output: Backend methods ready for frontend consumption, library store reacts to tag write events, context menu accessible from any track context.
|
||||
</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/17-single-track-edit/17-CONTEXT.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. -->
|
||||
|
||||
From backend/tagwriter/pipeline.go:
|
||||
```go
|
||||
type TagWriter struct {
|
||||
logger *slog.Logger
|
||||
db *database.DB
|
||||
ctx context.Context
|
||||
player PlayerStopper
|
||||
library PipelineLocker
|
||||
}
|
||||
|
||||
func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error
|
||||
```
|
||||
|
||||
From backend/tagwriter/tagwriter.go:
|
||||
```go
|
||||
type TagChanges map[string]any
|
||||
|
||||
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"
|
||||
)
|
||||
```
|
||||
|
||||
From backend/database/sql/sqlcgen/audio_files.sql.go:
|
||||
```go
|
||||
func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (AudioFile, error)
|
||||
```
|
||||
|
||||
From backend/frontendutil/frontendutil.go:
|
||||
```go
|
||||
type FrontendUtil struct {
|
||||
ctx context.Context
|
||||
}
|
||||
func (fe *FrontendUtil) DirectoryPicker() (string, error)
|
||||
func (fe *FrontendUtil) PlaylistFilePicker() ([]string, error)
|
||||
```
|
||||
|
||||
From frontend/src/store/library-store.ts:
|
||||
```typescript
|
||||
class LibraryStore {
|
||||
private invalidate(): void { ... }
|
||||
// Currently listens for: LibraryScanComplete, LibraryRemoved, LibraryAdded, LibraryRenamed
|
||||
// Does NOT listen for TrackMetadataChanged
|
||||
}
|
||||
```
|
||||
|
||||
From frontend/src/events.ts:
|
||||
```typescript
|
||||
export const Events = {
|
||||
TrackMetadataChanged: "TrackMetadataChanged",
|
||||
// ...
|
||||
} as const;
|
||||
```
|
||||
|
||||
Context menu pattern (track-list.ts line ~1966):
|
||||
```typescript
|
||||
${this.selection.selectionCount === 1
|
||||
? html`<wa-dropdown-item @click=${() => this.onContextMenuAction('track-details')}>
|
||||
<wa-icon slot="icon" name="circle-info"></wa-icon>
|
||||
Track Details
|
||||
</wa-dropdown-item>` : nothing}
|
||||
```
|
||||
|
||||
queue-panel.ts uses the same pattern at line ~1553.
|
||||
cover-grid.ts and playlist-details.ts conditionally show Track Details only for single track context.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add WriteTrackTagsByPath and ImageFilePicker backend methods</name>
|
||||
<files>backend/tagwriter/pipeline.go, backend/frontendutil/frontendutil.go</files>
|
||||
<action>
|
||||
**In `backend/tagwriter/pipeline.go`**, add a new method `WriteTrackTagsByPath` directly below the existing `WriteTrackTags` method:
|
||||
|
||||
```go
|
||||
// WriteTrackTagsByPath resolves a file path to its audio_file.id and
|
||||
// delegates to WriteTrackTags. This is the frontend-facing entry
|
||||
// point since the frontend identifies tracks by FilePath.
|
||||
func (tw *TagWriter) WriteTrackTagsByPath(filePath string, changes TagChanges) error {
|
||||
ctx := context.Background()
|
||||
|
||||
audioFile, err := tw.db.Queries.GetAudioFileByPath(ctx, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve track by path %q: %w", filePath, err)
|
||||
}
|
||||
|
||||
return tw.WriteTrackTags(audioFile.ID, changes)
|
||||
}
|
||||
```
|
||||
|
||||
This uses the existing `GetAudioFileByPath` sqlc query (already generated) to look up the `audio_files.id` from `file_path`, then delegates to the existing `WriteTrackTags` pipeline.
|
||||
|
||||
**In `backend/frontendutil/frontendutil.go`**, add a new method `ImageFilePicker` below `PlaylistFilePicker`:
|
||||
|
||||
```go
|
||||
// ImageFilePicker opens a file selection dialog filtered to image
|
||||
// files (JPEG, PNG). Returns the selected file path, or empty
|
||||
// string if the user cancelled.
|
||||
func (fe *FrontendUtil) ImageFilePicker() (string, error) {
|
||||
file, err := runtime.OpenFileDialog(
|
||||
fe.ctx,
|
||||
runtime.OpenDialogOptions{
|
||||
Title: "Select Cover Art",
|
||||
Filters: []runtime.FileFilter{
|
||||
{
|
||||
DisplayName: "Image Files (*.jpg, *.jpeg, *.png)",
|
||||
Pattern: "*.jpg;*.jpeg;*.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not open file dialog: %w", err)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
```
|
||||
|
||||
After adding both methods, run `make generate` to regenerate Wails TypeScript bindings (this will create the `WriteTrackTagsByPath` and `ImageFilePicker` bindings in `frontend/wailsjs/go/`).
|
||||
|
||||
Ensure both methods follow codebase conventions: doc comments ending with periods, error wrapping with `%w`, `fmt.Errorf` context.
|
||||
</action>
|
||||
<verify>
|
||||
`go build -tags webkit2_41 ./...` compiles without errors.
|
||||
`make generate` succeeds and creates new TypeScript bindings.
|
||||
`rg "WriteTrackTagsByPath" frontend/wailsjs/go/tagwriter/` shows the generated binding.
|
||||
`rg "ImageFilePicker" frontend/wailsjs/go/frontendutil/` shows the generated binding.
|
||||
</verify>
|
||||
<done>
|
||||
WriteTrackTagsByPath method exists on TagWriter, resolves filePath→trackID via GetAudioFileByPath, delegates to WriteTrackTags.
|
||||
ImageFilePicker method exists on FrontendUtil, opens native file dialog filtered to JPEG/PNG, returns selected path.
|
||||
Both have TypeScript bindings generated.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add TrackMetadataChanged handler and fix context menu conditions</name>
|
||||
<files>frontend/src/store/library-store.ts, frontend/src/components/track-list/track-list.ts, frontend/src/components/queue-panel/queue-panel.ts, frontend/src/components/cover-grid/cover-grid.ts, frontend/src/components/playlist-details/playlist-details.ts</files>
|
||||
<action>
|
||||
**In `frontend/src/store/library-store.ts`**, add a `TrackMetadataChanged` event listener in the constructor, after the existing `LibraryRenamed` listener:
|
||||
|
||||
```typescript
|
||||
EventsOn(Events.TrackMetadataChanged, () => {
|
||||
this.invalidate();
|
||||
});
|
||||
```
|
||||
|
||||
This causes a full cache invalidation + re-fetch of all library data (tracks, albums, artists, genres) whenever any track's tags are written. Full reload is acceptable per the CONTEXT.md decision: "Full reload is acceptable because editing is a low-frequency operation."
|
||||
|
||||
**In `frontend/src/components/track-list/track-list.ts`**, find the context menu rendering where "Track Details" is conditionally shown (around line 1966). Change the condition from `this.selection.selectionCount === 1` to always show the item. The item should appear when right-clicking any track. Per CONTEXT.md: "'Track Details' should appear in the context menu when right-clicking any track, regardless of selection state."
|
||||
|
||||
Replace:
|
||||
```typescript
|
||||
${this.selection.selectionCount === 1
|
||||
? html`<wa-dropdown-item @click=${() => this.onContextMenuAction('track-details')}>
|
||||
```
|
||||
With:
|
||||
```typescript
|
||||
${html`<wa-dropdown-item @click=${() => this.onContextMenuAction('track-details')}>
|
||||
```
|
||||
Remove the corresponding `: nothing}` closing.
|
||||
|
||||
When `track-details` action is triggered with multiple selections, use the first selected track (or the right-clicked track). Check how `onContextMenuAction` resolves the target — it should use the context menu target row's `FilePath`, not require exactly 1 selection.
|
||||
|
||||
**In `frontend/src/components/queue-panel/queue-panel.ts`**, apply the same fix: remove the `selectionCount === 1` condition around the "Track Details" context menu item (around line 1553). The queue always has a specific right-click target (the clicked track row), so Track Details should always be available.
|
||||
|
||||
**In `frontend/src/components/cover-grid/cover-grid.ts`**, find the Track Details context menu item condition (it checks `contextMenuTarget.kind === 'track' && selectedTracks.size === 1`). Change to only check `contextMenuTarget.kind === 'track'` — the dialog opens for the right-clicked track regardless of multi-selection.
|
||||
|
||||
**In `frontend/src/components/playlist-details/playlist-details.ts`**, apply the same fix: remove the `selectionCount === 1` condition for the "Track Details" menu item.
|
||||
|
||||
For all 4 components: when Track Details is activated with multiple tracks selected, the `openTrackDetails` method should open details for the first selected track (or the context-menu-target track). Review each component's `openTrackDetails` to ensure it works with the right-clicked track, not the full selection.
|
||||
</action>
|
||||
<verify>
|
||||
`pnpm run typecheck` in frontend/ passes.
|
||||
`rg "TrackMetadataChanged" frontend/src/store/library-store.ts` shows the new event handler.
|
||||
`rg "selectionCount === 1" frontend/src/components/track-list/track-list.ts frontend/src/components/queue-panel/queue-panel.ts` returns no matches (condition removed).
|
||||
</verify>
|
||||
<done>
|
||||
TrackMetadataChanged event handler added to LibraryStore — after a tag write, all caches are invalidated and views refresh.
|
||||
"Track Details" context menu item appears when right-clicking any track in all 4 views (track-list, queue-panel, cover-grid, playlist-details) regardless of how many tracks are selected.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go build -tags webkit2_41 ./...` compiles
|
||||
- `pnpm run typecheck` (in frontend/) passes
|
||||
- `make generate` succeeds
|
||||
- WriteTrackTagsByPath binding exists in `frontend/wailsjs/go/tagwriter/`
|
||||
- ImageFilePicker binding exists in `frontend/wailsjs/go/frontendutil/`
|
||||
- LibraryStore listens for TrackMetadataChanged
|
||||
- "Track Details" context menu item no longer gated on single selection
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
Backend methods ready for Plan 02 to call from the track-details dialog. Library store will automatically refresh all views when tag writes complete. Context menu shows "Track Details" for any right-clicked track.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/17-single-track-edit/17-01-SUMMARY.md`
|
||||
</output>
|
||||
Reference in new issue
Block a user