git/intake/main

feat: a journal screen, with forget and un-file

'J' on the album list opens the journal: every record with what is actually
on disk beside it, and per record a menu of what can be done to it.

Each line shows the artist and album, the phase, and any disagreement with
the disk -- archive missing, placeholder cover, no library copy, or an
album in the archive that no record names at all. That last kind is the one
nothing else surfaces.

Two actions, chosen from a menu rather than remembered as keys:

- Forget it. The record goes and every file stays exactly where it is. This
  is a statement about what intake remembers, not about what is on disk.
- Un-file it. The masters move back to staging so the album can be reviewed
  again, and the record goes with them. The library copy is removed: it is
  a transcode of those masters and would otherwise sit there describing an
  album that is no longer archived. The move is a rename, not a copy and a
  delete -- after filing the archive holds the only copy of that audio,
  because the staged originals were removed once the archive verified.

Un-filing is the only place in the interface that moves an album already in
the archive, so it takes two keypresses: the first says what will happen,
the second does it.

A record with nothing behind it -- an album in the archive that no journal
record names -- offers no actions and says so, because both forgetting it
and moving it are impossible.

The same two operations are available without the interface, as
'intake library forget <album>' and 'intake library unfile <album>', and
the listing reaches the TUI through the runner rather than the interface
reaching into the journal itself.

Closes 8209d5b
diff --git a/README.md b/README.md
index 1ec1ed1..45ebe93 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,9 @@ produces a clean, consistently tagged, consistently named library: lossless mast
- Transcodes a streaming copy from the masters and optimises its cover
- Remembers artist folder decisions in `artists.toml` and the journal, so the same
artist always lands in the same folder
+- `J` on the album list opens the journal: every record, with what is actually on disk
+ beside it, and per record the choice to forget it (files untouched) or move the masters
+ back to staging so it can be reviewed again
## Requirements
@@ -50,6 +53,8 @@ intake library verify # placeholder covers and missing libraries
intake library audit # compare archive and library for drift
intake library transcode <album> # rebuild a streaming copy from the masters
intake library re-cover <album> # replace an album's cover
+intake library forget <album> # drop the record, leave every file alone
+intake library unfile <album> # move the masters back to staging and forget it
intake config # effective config and resolved paths
```
diff --git a/cmd/intake/main.go b/cmd/intake/main.go
index 99f8cd2..7082e8a 100644
--- a/cmd/intake/main.go
+++ b/cmd/intake/main.go
@@ -29,6 +29,8 @@ Commands:
library re-cover Replace the cover of an archived album
library transcode Rebuild an album's streaming copy from its masters
library audit Compare the archive and library for drift
+ library forget Drop an album's journal record, leaving the files alone
+ library unfile Move an archived album back to staging and forget it
config Show the effective configuration and resolved paths
version Print version information
help Show this help
@@ -250,6 +252,34 @@ func run(ctx context.Context, args []string) error {
return err
}
return app(cfg, cf).Audit(ctx)
+ case "forget":
+ fs := flag.NewFlagSet("library forget", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest[1:]); err != nil {
+ return err
+ }
+ if fs.NArg() == 0 {
+ return fmt.Errorf("library forget: an album path or name is required")
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return app(cfg, cf).ForgetRecord(ctx, strings.Join(fs.Args(), " "))
+ case "unfile":
+ fs := flag.NewFlagSet("library unfile", flag.ExitOnError)
+ cf := register(fs)
+ if err := fs.Parse(rest[1:]); err != nil {
+ return err
+ }
+ if fs.NArg() == 0 {
+ return fmt.Errorf("library unfile: an album path or name is required")
+ }
+ cfg, err := resolve(cf)
+ if err != nil {
+ return err
+ }
+ return app(cfg, cf).Unfile(ctx, strings.Join(fs.Args(), " "))
case "re-cover":
fs := flag.NewFlagSet("library re-cover", flag.ExitOnError)
cf := register(fs)
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index a07be83..f897812 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -168,21 +168,31 @@ Nothing is guessed silently. An unknown album or artist, an empty normalised nam
different titles collapsing to one name, or two tracks that would be written to the same
file are all hard issues, and `--yes` refuses to proceed rather than inventing metadata.
-## State
+## The journal
-One journal at `~/.local/state/intake/state.json`, keyed by the staging path, recording
-the phase, the archive and library directories, track count and the placeholder flag.
-`library verify` reconciles it against disk; `library audit` compares the two trees.
+One record per staging path, holding where the album ended up and what is known about it.
+Everything that reads it treats it as the only index of what has been filed, so two rules
+follow.
-Every change to the journal is a read-modify-write under a lock file beside it, and each
-save lands through a uniquely named temporary file. Without both, two runs on one root
-each wrote back a snapshot taken before the other, so an album could be filed while its
-record vanished, and a save could fail on a temporary file another writer had already
-renamed away.
+Every change is a read, a change and a write under a lock file beside it, and each save
+lands through a uniquely named temporary file. Without both, two runs on one root each
+wrote back a snapshot taken before the other, and an album could be filed while its record
+vanished.
A record is only deleted when the album it points at has gone. A run that fails is not
evidence about an album recorded earlier.
+Because the journal is the only index, the archive is also read and compared against it:
+an album that reached the archive without a record — the process was killed between the
+promotion and the write, or somebody copied it in — would otherwise be invisible, and a
+later run would file a second copy beside it. `library verify` and `library audit` list
+those, and the journal screen marks them.
+
+Records can be removed on purpose. Forgetting one touches no files at all; un-filing moves
+the masters back to staging and removes the library copy, which is derived. Un-filing
+moves rather than copies, because after filing the archive holds the only copy of that
+audio.
+
## The album sheet
Everything intake is about to write is editable in one `$EDITOR` document, reachable from
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index dc9b2ab..a229a57 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -823,6 +823,257 @@ func (a *App) Verify(ctx context.Context) error {
return nil
}
+// JournalEntry is one record as the interface shows it.
+type JournalEntry struct {
+ Source string
+ AlbumArtist string
+ Album string
+ Phase string
+ ArchiveDir string
+ LibraryDir string
+ Tracks int
+ Placeholder bool
+ Library bool
+ Missing bool
+ Unrecorded bool
+}
+
+// JournalEntries lists every record, newest name first, with what is actually on
+// disk beside it.
+func (a *App) JournalEntries(ctx context.Context) ([]JournalEntry, error) {
+ j, err := a.journal()
+ if err != nil {
+ return nil, err
+ }
+ out := make([]JournalEntry, 0, len(j.Albums))
+ for _, rec := range j.Albums {
+ e := JournalEntry{
+ Source: rec.Source,
+ AlbumArtist: rec.AlbumArtist,
+ Album: rec.Album,
+ Phase: string(rec.Phase),
+ ArchiveDir: rec.ArchiveDir,
+ LibraryDir: rec.LibraryDir,
+ Tracks: rec.Tracks,
+ Placeholder: rec.Placeholder,
+ Library: rec.LibraryBuilt,
+ }
+ if rec.ArchiveDir != "" {
+ if _, err := os.Stat(rec.ArchiveDir); err != nil {
+ e.Missing = true
+ }
+ }
+ out = append(out, e)
+ }
+ dirs, err := a.archiveDirs()
+ if err != nil {
+ return nil, err
+ }
+ for _, dir := range a.unrecorded(j, dirs) {
+ out = append(out, JournalEntry{
+ Album: filepath.Base(dir),
+ ArchiveDir: dir,
+ Phase: "unrecorded",
+ Unrecorded: true,
+ })
+ }
+ sort.Slice(out, func(i, k int) bool {
+ if out[i].AlbumArtist != out[k].AlbumArtist {
+ return out[i].AlbumArtist < out[k].AlbumArtist
+ }
+ return out[i].Album < out[k].Album
+ })
+ return out, nil
+}
+
+// ForgetSource drops the record for a staging source, without a prompt.
+func (a *App) ForgetSource(source string) error {
+ if a.DryRun {
+ return nil
+ }
+ return state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
+ if _, ok := j.Get(source); !ok {
+ return false, fmt.Errorf("no journal record for %s", source)
+ }
+ j.Delete(source)
+ return true, nil
+ })
+}
+
+// UnfileSource moves a recorded album back to staging and drops its record.
+func (a *App) UnfileSource(source string, progress func(string)) error {
+ if progress == nil {
+ progress = func(string) {}
+ }
+ j, err := a.journal()
+ if err != nil {
+ return err
+ }
+ rec, ok := j.Get(source)
+ if !ok {
+ return fmt.Errorf("no journal record for %s", source)
+ }
+ if rec.ArchiveDir == "" {
+ return fmt.Errorf("the record for %q has no archive directory", rec.Album)
+ }
+ if _, err := os.Stat(rec.ArchiveDir); err != nil {
+ return fmt.Errorf("the album is not in the archive at %s: %w", rec.ArchiveDir, err)
+ }
+ if a.DryRun {
+ progress("dry run: would move " + rec.ArchiveDir + " back to staging")
+ return nil
+ }
+ dest := filepath.Join(a.Cfg.Staging(), filepath.Base(rec.ArchiveDir))
+ if _, err := os.Stat(dest); err == nil {
+ dest = freePath(dest)
+ }
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ return err
+ }
+ if err := os.Rename(rec.ArchiveDir, dest); err != nil {
+ return fmt.Errorf("moving the album back to staging: %w", err)
+ }
+ progress("moved the masters back to " + dest)
+ if rec.LibraryDir != "" {
+ if err := os.RemoveAll(rec.LibraryDir); err != nil {
+ progress("could not remove the library copy: " + err.Error())
+ } else {
+ progress("removed the library copy")
+ }
+ }
+ return state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
+ j.Delete(source)
+ return true, nil
+ })
+}
+
+// ForgetRecord drops a journal record and leaves every file exactly where it is.
+func (a *App) ForgetRecord(ctx context.Context, target string) error {
+ j, err := a.journal()
+ if err != nil {
+ return err
+ }
+ rec, ok := findRecord(j, target)
+ if !ok {
+ return fmt.Errorf("no archived album matching %q in the journal", target)
+ }
+
+ a.logf("album : %s - %s", rec.AlbumArtist, rec.Album)
+ a.logf("archive: %s", rec.ArchiveDir)
+ if rec.LibraryDir != "" {
+ a.logf("library: %s", rec.LibraryDir)
+ }
+ a.logf("")
+ a.logf("The record goes; every file stays exactly where it is. intake will no")
+ a.logf("longer know about this album: library verify and audit will stop counting")
+ a.logf("it, and if its staging folder ever comes back it will be filed again.")
+ if !a.Yes {
+ ok, err := a.confirm("forget this album?")
+ if err != nil {
+ return err
+ }
+ if !ok {
+ a.logf("aborted; nothing changed")
+ return nil
+ }
+ }
+ if a.DryRun {
+ a.logf("dry run: would forget %s", rec.Album)
+ return nil
+ }
+ if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
+ j.Delete(rec.Source)
+ return true, nil
+ }); err != nil {
+ return fmt.Errorf("forgetting %q: %w", rec.Album, err)
+ }
+ a.logf("forgotten: %s", rec.Album)
+ return nil
+}
+
+// Unfile puts an album back in staging and drops its record, for the mistake that
+// is only noticed later.
+//
+// The masters are moved, never copied: after filing, the archive holds the only
+// copy of that audio -- the staged originals were deleted once the archive
+// verified -- so a copy followed by a delete would risk losing it. The library
+// copy is derived from the masters and is removed, because it would otherwise sit
+// there describing an album that is no longer archived.
+func (a *App) Unfile(ctx context.Context, target string) error {
+ j, err := a.journal()
+ if err != nil {
+ return err
+ }
+ rec, ok := findRecord(j, target)
+ if !ok {
+ return fmt.Errorf("no archived album matching %q in the journal", target)
+ }
+ if _, err := os.Stat(rec.ArchiveDir); err != nil {
+ return fmt.Errorf("the album is not in the archive at %s: %w", rec.ArchiveDir, err)
+ }
+
+ dest := filepath.Join(a.Cfg.Staging(), filepath.Base(rec.ArchiveDir))
+ if _, err := os.Stat(dest); err == nil {
+ dest = freePath(dest)
+ }
+
+ a.logf("album : %s - %s", rec.AlbumArtist, rec.Album)
+ a.logf("archive: %s", rec.ArchiveDir)
+ a.logf("staging: %s", dest)
+ if rec.LibraryDir != "" {
+ a.logf("the library copy at %s is a transcode of these masters and will be removed", rec.LibraryDir)
+ }
+ a.logf("")
+ a.logf("The masters move back to staging. Nothing is copied, so nothing is lost")
+ a.logf("if this is interrupted; the album is simply in one place or the other.")
+ if !a.Yes {
+ ok, err := a.confirm("move it back to staging?")
+ if err != nil {
+ return err
+ }
+ if !ok {
+ a.logf("aborted; nothing changed")
+ return nil
+ }
+ }
+ if a.DryRun {
+ a.logf("dry run: would move %s to %s", rec.ArchiveDir, dest)
+ return nil
+ }
+
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ return err
+ }
+ if err := os.Rename(rec.ArchiveDir, dest); err != nil {
+ return fmt.Errorf("moving the album back to staging: %w", err)
+ }
+ if rec.LibraryDir != "" {
+ if err := os.RemoveAll(rec.LibraryDir); err != nil {
+ return fmt.Errorf("the album is back in staging, but its library copy could not be removed: %w", err)
+ }
+ }
+ if err := state.Update(a.journalPath(), func(j *state.Journal) (bool, error) {
+ j.Delete(rec.Source)
+ return true, nil
+ }); err != nil {
+ return fmt.Errorf("the album is back in staging, but its record could not be removed: %w", err)
+ }
+ a.logf("")
+ a.logf("moved %s back to staging", rec.Album)
+ return nil
+}
+
+// freePath finds a name beside path that is not taken.
+func freePath(path string) string {
+ for i := 2; i < 1000; i++ {
+ candidate := fmt.Sprintf("%s (%d)", path, i)
+ if _, err := os.Stat(candidate); os.IsNotExist(err) {
+ return candidate
+ }
+ }
+ return path + " (copy)"
+}
+
func (a *App) ReCover(ctx context.Context, target string) error {
j, err := a.journal()
if err != nil {
diff --git a/internal/cli/journal_test.go b/internal/cli/journal_test.go
index 44a6b90..e9ab5f4 100644
--- a/internal/cli/journal_test.go
+++ b/internal/cli/journal_test.go
@@ -270,3 +270,118 @@ func TestUnrecordedArchiveDirectoriesAreReported(t *testing.T) {
t.Errorf("unrecorded = %v, want one entry", parsed["unrecorded"])
}
}
+
+// Un-filing moves the masters back to staging rather than copying and deleting
+// them, because after filing the archive holds the only copy: the staged
+// originals were removed once it verified.
+func TestUnfileMovesTheAlbumBackToStaging(t *testing.T) {
+ root := t.TempDir()
+ a, out := cliApp(t, root)
+ a.Yes = true
+
+ archive := filepath.Join(root, "archive", "an-artist", "an-album")
+ lib := filepath.Join(root, "library", "an-artist", "an-album")
+ for _, d := range []string{archive, lib} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ master := filepath.Join(archive, "1-01_one.flac")
+ if err := os.WriteFile(master, []byte("the only copy"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(lib, "1-01_one.ogg"), []byte("derived"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ seed(t, a, state.Record{Source: "/staging/an-album", Phase: state.PhaseArchived, AlbumArtist: "An Artist", Album: "An Album", ArchiveDir: archive, LibraryDir: lib, Tracks: 1})
+
+ if err := a.Unfile(t.Context(), "an album"); err != nil {
+ t.Fatalf("%v\n%s", err, out.String())
+ }
+
+ back := filepath.Join(root, "staging", "an-album")
+ got, err := os.ReadFile(filepath.Join(back, "1-01_one.flac"))
+ if err != nil {
+ t.Fatalf("the masters did not come back: %v\n%s", err, out.String())
+ }
+ if string(got) != "the only copy" {
+ t.Errorf("the masters changed on the way back: %q", got)
+ }
+ if _, err := os.Stat(archive); !os.IsNotExist(err) {
+ t.Error("the album is still in the archive as well")
+ }
+ if _, err := os.Stat(lib); !os.IsNotExist(err) {
+ t.Error("the stale library copy was left behind")
+ }
+ j, err := a.journal()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(j.Albums) != 0 {
+ t.Errorf("the record survived the un-filing: %+v", j.Albums)
+ }
+}
+
+// Forgetting a record leaves every file exactly where it is: it is a statement
+// about what intake remembers, not about what is on disk.
+func TestForgetLeavesEveryFileWhereItIs(t *testing.T) {
+ root := t.TempDir()
+ a, out := cliApp(t, root)
+ a.Yes = true
+
+ archive := filepath.Join(root, "archive", "an-artist", "an-album")
+ if err := os.MkdirAll(archive, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ master := filepath.Join(archive, "1-01_one.flac")
+ if err := os.WriteFile(master, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ seed(t, a, state.Record{Source: "/staging/an-album", Phase: state.PhaseArchived, Album: "An Album", ArchiveDir: archive})
+
+ if err := a.ForgetRecord(t.Context(), "an album"); err != nil {
+ t.Fatalf("%v\n%s", err, out.String())
+ }
+ if _, err := os.Stat(master); err != nil {
+ t.Errorf("forgetting a record removed a file: %v", err)
+ }
+ j, _ := a.journal()
+ if len(j.Albums) != 0 {
+ t.Error("the record was not forgotten")
+ }
+}
+
+// The journal screen needs to see the two ways a record and the disk disagree.
+func TestJournalEntriesShowWhatIsOnDisk(t *testing.T) {
+ root := t.TempDir()
+ a, _ := cliApp(t, root)
+
+ here := filepath.Join(root, "archive", "an-artist", "here")
+ gone := filepath.Join(root, "archive", "an-artist", "gone")
+ orphan := filepath.Join(root, "archive", "an-artist", "orphan")
+ for _, d := range []string{here, orphan} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ seed(t, a, state.Record{Source: "/staging/here", Phase: state.PhaseArchived, Album: "Here", ArchiveDir: here, LibraryBuilt: true})
+ seed(t, a, state.Record{Source: "/staging/gone", Phase: state.PhaseArchived, Album: "Gone", ArchiveDir: gone})
+
+ entries, err := a.JournalEntries(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ byAlbum := map[string]JournalEntry{}
+ for _, e := range entries {
+ byAlbum[e.Album] = e
+ }
+ if e := byAlbum["Here"]; e.Missing || !e.Library {
+ t.Errorf("a complete album was reported wrong: %+v", e)
+ }
+ if e := byAlbum["Gone"]; !e.Missing {
+ t.Errorf("a missing archive was not noticed: %+v", e)
+ }
+ if e := byAlbum["orphan"]; !e.Unrecorded {
+ t.Errorf("an album with no record was not listed: %+v", e)
+ }
+}
diff --git a/internal/tui/runner.go b/internal/tui/runner.go
index fa1a42a..526e8da 100644
--- a/internal/tui/runner.go
+++ b/internal/tui/runner.go
@@ -60,6 +60,44 @@ func (r *Runner) ApplyCover(p *plan.Plan, choice CoverOption) error {
return nil
}
+func (r *Runner) Journal(ctx context.Context) ([]JournalEntry, error) {
+ app := r.app(nil)
+ entries, err := app.JournalEntries(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]JournalEntry, 0, len(entries))
+ for _, e := range entries {
+ out = append(out, JournalEntry{
+ Source: e.Source,
+ AlbumArtist: e.AlbumArtist,
+ Album: e.Album,
+ Phase: e.Phase,
+ ArchiveDir: e.ArchiveDir,
+ LibraryDir: e.LibraryDir,
+ Tracks: e.Tracks,
+ Placeholder: e.Placeholder,
+ Library: e.Library,
+ Missing: e.Missing,
+ Unrecorded: e.Unrecorded,
+ })
+ }
+ return out, nil
+}
+
+func (r *Runner) ForgetSource(source string) error {
+ return r.app(nil).ForgetSource(source)
+}
+
+func (r *Runner) UnfileSource(source string, progress func(string)) error {
+ logf := func(format string, args ...any) {
+ if progress != nil {
+ progress(strings.TrimSpace(fmt.Sprintf(format, args...)))
+ }
+ }
+ return r.app(logf).UnfileSource(source, progress)
+}
+
func (r *Runner) Run(ctx context.Context, album scan.Album, progress func(string)) (Outcome, error) {
logf := func(format string, args ...any) {
if progress != nil {
@@ -121,6 +159,9 @@ func Run(cfg config.Config, out io.Writer, in io.Reader) error {
Run: r.Run,
CoverOptions: r.CoverOptions,
ApplyCover: r.ApplyCover,
+ Journal: r.Journal,
+ ForgetSource: r.ForgetSource,
+ UnfileSource: r.UnfileSource,
})
opts := []tea.ProgramOption{tea.WithInput(in)}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 36f00b4..286ee52 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -21,6 +21,7 @@ type Screen int
const (
ScreenBrowse Screen = iota
+ ScreenJournal
ScreenReview
ScreenCoverPicker
ScreenWorking
@@ -58,6 +59,34 @@ type Options struct {
Run func(ctx context.Context, album scan.Album, progress func(string)) (Outcome, error)
CoverOptions func(ctx context.Context, p plan.Plan) ([]CoverOption, error)
ApplyCover func(p *plan.Plan, choice CoverOption) error
+ Journal func(ctx context.Context) ([]JournalEntry, error)
+ ForgetSource func(source string) error
+ UnfileSource func(source string, progress func(string)) error
+}
+
+// JournalEntry is one line of the journal screen: what intake remembers about an
+// album, and what is actually on disk.
+type JournalEntry struct {
+ Source string
+ AlbumArtist string
+ Album string
+ Phase string
+ ArchiveDir string
+ LibraryDir string
+ Tracks int
+ Placeholder bool
+ Library bool
+ Missing bool
+ Unrecorded bool
+}
+
+// journalAction is one thing that can be done to a record.
+type journalAction struct {
+ key string
+ label string
+ run func(m *Module, e JournalEntry) tea.Cmd
+ // confirm says the action changes something on disk and needs a second key.
+ confirm string
}
type Outcome struct {
@@ -104,6 +133,17 @@ type Module struct {
coversFetched bool
placed *kittyPlacement
+ journal []JournalEntry
+ journalCursor int
+ journalOffset int
+ // journalMenu is -1 while the list itself has the keys, and otherwise the
+ // index of the action being chosen for the selected record.
+ journalMenu int
+ journalAction int
+ // journalConfirm is set while an action that moves files waits for a second
+ // enter.
+ journalConfirm bool
+
sheetPath string
sheetBefore *sheet.Sheet
@@ -138,6 +178,24 @@ type coversMsg struct {
err error
}
+type journalMsg struct {
+ entries []JournalEntry
+ err error
+}
+
+type journalDoneMsg struct {
+ note string
+ err error
+}
+
+func (m Module) journalCmd() tea.Cmd {
+ get := m.opts.Journal
+ return func() tea.Msg {
+ entries, err := get(context.Background())
+ return journalMsg{entries: entries, err: err}
+ }
+}
+
type doneMsg struct {
outcome Outcome
err error
@@ -166,6 +224,9 @@ func New(opts Options) Module {
prev: NewImagePreviewer(),
spin: sp,
status: "reading staging",
+ // -1 means the journal list itself has the keys; 0 would mean the first
+ // action menu is open before anything asked for it.
+ journalMenu: -1,
}
return m
}
@@ -293,6 +354,27 @@ func (m Module) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = "filed"
return m, nil
+ case journalMsg:
+ m.ready = true
+ if msg.err != nil {
+ m.status = msg.err.Error()
+ return m, nil
+ }
+ m.journal = msg.entries
+ m.journalCursor, m.journalOffset, m.journalMenu = 0, 0, -1
+ m.journalConfirm = false
+ m.screen = ScreenJournal
+ m.status = fmt.Sprintf("%d record%s", len(m.journal), plural(len(m.journal)))
+ return m, nil
+
+ case journalDoneMsg:
+ if msg.err != nil {
+ m.status = msg.err.Error()
+ return m, m.journalCmd()
+ }
+ m.status = msg.note
+ return m, m.journalCmd()
+
case editorFinishedMsg:
if msg.err != nil {
m.status = "editor exited with an error: " + msg.err.Error()
@@ -339,6 +421,8 @@ func (m Module) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch m.screen {
case ScreenBrowse:
return m.keyBrowse(key, msg)
+ case ScreenJournal:
+ return m.keyJournal(key)
case ScreenReview:
return m.keyReview(key)
case ScreenCoverPicker:
@@ -434,6 +518,13 @@ func (m Module) keyBrowse(key string, msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
case "r":
m.status = "refreshing"
return m, m.scanCmd()
+ case "J":
+ if m.opts.Journal == nil {
+ m.status = "this build cannot read the journal"
+ return m, nil
+ }
+ m.status = "reading the journal"
+ return m, m.journalCmd()
case "up", "k":
if m.cursor > 0 {
m.cursor--
@@ -620,6 +711,7 @@ func (m *Module) resetAlbum() {
m.covers, m.coverCursor, m.coversFetched = nil, 0, false
m.placed = nil
m.sheetPath, m.sheetBefore = "", nil
+ m.journalMenu, m.journalAction, m.journalConfirm = -1, 0, false
m.log, m.outcome = nil, Outcome{}
m.err, m.status = nil, ""
m.scroll, m.offset = 0, 0
@@ -692,6 +784,8 @@ func (m Module) renderHeader() string {
switch m.screen {
case ScreenBrowse:
crumbs = append(crumbs, m.st.CrumbDim.Render("staging"))
+ case ScreenJournal:
+ crumbs = append(crumbs, m.st.CrumbDim.Render("journal"))
case ScreenReview, ScreenCoverPicker, ScreenWorking, ScreenDone:
crumbs = append(crumbs, m.st.CrumbDim.Render("staging"))
if m.current.Name != "" {
@@ -749,7 +843,13 @@ func (m Module) renderFooter() string {
if m.filtering {
hints = []string{"enter", "apply", "esc", "cancel"}
} else {
- hints = []string{"↑↓", "move", "/", "filter", "enter", "review", "r", "refresh", "q", "quit"}
+ hints = []string{"↑↓", "move", "/", "filter", "enter", "review", "e", "edit album", "J", "journal", "r", "refresh", "q", "quit"}
+ }
+ case ScreenJournal:
+ if m.journalMenu >= 0 {
+ hints = []string{"↑↓", "choose", "enter", "do it", "esc", "cancel"}
+ } else {
+ hints = []string{"↑↓", "move", "enter", "actions", "r", "refresh", "esc", "back"}
}
case ScreenReview:
hints = []string{"↑↓", "scroll", "enter", "continue", "e", "edit album", "esc", "back", "q", "quit"}
@@ -838,6 +938,8 @@ func (m Module) renderBody() string {
switch m.screen {
case ScreenBrowse:
return m.viewBrowse()
+ case ScreenJournal:
+ return m.viewJournal()
case ScreenReview:
return m.viewReview()
case ScreenCoverPicker:
@@ -853,6 +955,8 @@ func (m Module) renderBody() string {
func (m Module) renderTiny() string {
what := "staging"
switch m.screen {
+ case ScreenJournal:
+ what = "journal"
case ScreenReview:
what = fmt.Sprintf("%s · %s", m.current.Name, m.step)
case ScreenWorking:
@@ -980,3 +1084,132 @@ func plural(n int) string {
}
return "s"
}
+
+// journalActions is what can be done to a record, in the order the menu shows
+// them. Nothing here deletes audio: forgetting a record leaves every file where
+// it is, and un-filing moves the masters back rather than copying and deleting
+// them, so an interruption can only leave the album in one place or the other.
+func (m Module) journalActions() []journalAction {
+ return []journalAction{
+ {
+ key: "forget",
+ label: "forget it (leave every file where it is)",
+ run: func(m *Module, e JournalEntry) tea.Cmd {
+ if m.opts.ForgetSource == nil {
+ return nil
+ }
+ src := e.Source
+ return func() tea.Msg {
+ if err := m.opts.ForgetSource(src); err != nil {
+ return journalDoneMsg{err: err}
+ }
+ return journalDoneMsg{note: "forgotten; the files are untouched"}
+ }
+ },
+ },
+ {
+ key: "unfile",
+ label: "move the masters back to staging (removes the library copy)",
+ confirm: "press enter again to move it, or esc to cancel",
+ run: func(m *Module, e JournalEntry) tea.Cmd {
+ if m.opts.UnfileSource == nil {
+ return nil
+ }
+ src := e.Source
+ return func() tea.Msg {
+ var last string
+ err := m.opts.UnfileSource(src, func(line string) { last = line })
+ if err != nil {
+ return journalDoneMsg{err: err}
+ }
+ if last == "" {
+ last = "moved back to staging"
+ }
+ return journalDoneMsg{note: last}
+ }
+ },
+ },
+ }
+}
+
+func (m Module) keyJournal(keyp string) (tea.Model, tea.Cmd) {
+ if m.journalMenu >= 0 {
+ return m.keyJournalMenu(keyp)
+ }
+ switch keyp {
+ case "q":
+ return m.quit()
+ case "esc":
+ m.screen, m.scroll = ScreenBrowse, 0
+ return m, nil
+ case "up", "k":
+ if m.journalCursor > 0 {
+ m.journalCursor--
+ }
+ case "down", "j":
+ if m.journalCursor < len(m.journal)-1 {
+ m.journalCursor++
+ }
+ case "home", "g":
+ m.journalCursor = 0
+ case "end", "G":
+ m.journalCursor = len(m.journal) - 1
+ case "r":
+ m.status = "refreshing"
+ return m, m.journalCmd()
+ case "enter":
+ if m.journalCursor >= len(m.journal) {
+ return m, nil
+ }
+ if m.journal[m.journalCursor].Unrecorded {
+ // There is no record here to forget or to move: the album is on disk
+ // and the journal knows nothing about it. Saying so is more useful
+ // than offering actions that cannot work.
+ m.status = m.journal[m.journalCursor].Album + ": in the archive with no record, so there is nothing to forget or un-file"
+ return m, nil
+ }
+ m.journalMenu, m.journalAction, m.journalConfirm = 0, 0, false
+ }
+ return m, nil
+}
+
+func (m Module) keyJournalMenu(keyp string) (tea.Model, tea.Cmd) {
+ actions := m.journalActions()
+ if m.journalCursor >= len(m.journal) {
+ m.journalMenu = -1
+ return m, nil
+ }
+ switch keyp {
+ case "esc", "q":
+ m.journalMenu, m.journalAction, m.journalConfirm = -1, 0, false
+ return m, nil
+ case "up", "k":
+ if m.journalAction > 0 {
+ m.journalAction--
+ }
+ return m, nil
+ case "down", "j":
+ if m.journalAction < len(actions)-1 {
+ m.journalAction++
+ }
+ return m, nil
+ case "enter":
+ a := actions[m.journalAction]
+ if a.confirm != "" && !m.journalConfirm {
+ // One key to choose, one to confirm. This is the only place in the
+ // interface that moves an album already in the archive, so it does
+ // not happen on a single stray keypress.
+ m.journalConfirm = true
+ m.status = a.confirm
+ return m, nil
+ }
+ return m.runJournalAction(a)
+ }
+ return m, nil
+}
+
+func (m Module) runJournalAction(a journalAction) (tea.Model, tea.Cmd) {
+ entry := m.journal[m.journalCursor]
+ m.journalMenu, m.journalAction, m.journalConfirm = -1, 0, false
+ return m, a.run(&m, entry)
+}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 5c7fdd2..1e9b955 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -537,3 +537,162 @@ func TestACoverTypedIntoTheSheetIsApplied(t *testing.T) {
t.Error("a cover that cannot be read should be reported")
}
}
+
+func journalModule(t *testing.T, entries []JournalEntry, forget, unfile *[]string) Module {
+ t.Helper()
+ m := testModule(120, 40)
+ m.journal = entries
+ m.opts.Journal = func(context.Context) ([]JournalEntry, error) { return entries, nil }
+ m.opts.ForgetSource = func(source string) error {
+ *forget = append(*forget, source)
+ return nil
+ }
+ m.opts.UnfileSource = func(source string, progress func(string)) error {
+ *unfile = append(*unfile, source)
+ if progress != nil {
+ progress("moved")
+ }
+ return nil
+ }
+ return m
+}
+
+// The journal is reachable from the album list, because a record whose archive
+// has gone and an album with no record both look fine everywhere else.
+func TestTheJournalOpensFromTheAlbumList(t *testing.T) {
+ var forget, unfile []string
+ m := journalModule(t, []JournalEntry{{Source: "/s/a", Album: "A"}}, &forget, &unfile)
+ got, cmd := m.handleKey(key("J"))
+ m = got.(Module)
+ if cmd == nil {
+ t.Fatal("'J' should ask for the journal")
+ }
+ // The command produces the message; feed it back the way the runtime would.
+ got, _ = m.Update(cmd())
+ m = got.(Module)
+ if m.screen != ScreenJournal {
+ t.Fatalf("screen = %v, want the journal", m.screen)
+ }
+ if len(m.journal) != 1 {
+ t.Fatalf("journal holds %d entries", len(m.journal))
+ }
+}
+
+// Choosing "forget" from the menu drops the record and touches nothing else, so
+// it happens on one keypress.
+func TestForgettingARecordFromTheJournal(t *testing.T) {
+ var forget, unfile []string
+ m := journalModule(t, []JournalEntry{{Source: "/s/a", Album: "A", Tracks: 3}}, &forget, &unfile)
+ m.screen = ScreenJournal
+
+ got, cmd := m.handleKey(key("enter"))
+ m = got.(Module)
+ if m.journalMenu < 0 {
+ t.Fatal("enter should open the actions")
+ }
+ got, cmd = m.handleKey(key("enter"))
+ m = got.(Module)
+ if cmd == nil {
+ t.Fatal("choosing forget should do something")
+ }
+ got, _ = m.Update(cmd())
+ m = got.(Module)
+ if len(forget) != 1 || forget[0] != "/s/a" {
+ t.Errorf("forget was not called: %v", forget)
+ }
+ if !strings.Contains(m.status, "untouched") {
+ t.Errorf("status = %q", m.status)
+ }
+ if len(unfile) != 0 {
+ t.Errorf("the other action ran too: %v", unfile)
+ }
+}
+
+// Moving an album out of the archive is the one action that changes files, so it
+// takes two keypresses and the second only after the first said what would happen.
+func TestUnfilingNeedsAConfirmation(t *testing.T) {
+ var forget, unfile []string
+ m := journalModule(t, []JournalEntry{{Source: "/s/a", Album: "A", Tracks: 1}}, &forget, &unfile)
+ m.screen = ScreenJournal
+
+ got, _ := m.handleKey(key("enter")) // open the menu
+ m = got.(Module)
+ got, _ = m.handleKey(key("down")) // move to unfile
+ m = got.(Module)
+ if m.journalAction != 1 {
+ t.Fatalf("action = %d, want unfile", m.journalAction)
+ }
+ got, cmd := m.handleKey(key("enter"))
+ m = got.(Module)
+ if cmd != nil {
+ t.Fatal("the first enter should only say what is about to happen")
+ }
+ if len(unfile) != 0 {
+ t.Fatalf("unfile ran before it was confirmed: %v", unfile)
+ }
+ if !strings.Contains(m.status, "enter again") {
+ t.Errorf("status = %q", m.status)
+ }
+
+ got, cmd = m.handleKey(key("enter"))
+ m = got.(Module)
+ if cmd == nil {
+ t.Fatal("the second enter should do it")
+ }
+ got, _ = m.Update(cmd())
+ m = got.(Module)
+ if len(unfile) != 1 {
+ t.Errorf("unfile was not called: %v", unfile)
+ }
+ if m.screen != ScreenJournal {
+ t.Errorf("the journal screen was left: %v", m.screen)
+ }
+}
+
+// Escape gets out of the action menu without doing anything.
+func TestEscapingTheJournalMenu(t *testing.T) {
+ var forget, unfile []string
+ m := journalModule(t, []JournalEntry{{Source: "/s/a", Album: "A"}}, &forget, &unfile)
+ m.screen = ScreenJournal
+
+ got, _ := m.handleKey(key("enter"))
+ m = got.(Module)
+ got, cmd := m.handleKey(key("esc"))
+ m = got.(Module)
+ if m.journalMenu >= 0 {
+ t.Error("esc did not close the actions")
+ }
+ if cmd != nil || len(forget) != 0 || len(unfile) != 0 {
+ t.Error("esc did something")
+ }
+}
+
+// An album in the archive that no record names has nothing to forget and nothing
+// to un-file; the screen says so rather than offering an action that cannot work.
+func TestAnUnrecordedArchiveEntryOffersNoAction(t *testing.T) {
+ var forget, unfile []string
+ m := journalModule(t, []JournalEntry{{ArchiveDir: "/archive/a/b", Unrecorded: true}}, &forget, &unfile)
+ m.screen = ScreenJournal
+
+ got, cmd := m.handleKey(key("enter"))
+ m = got.(Module)
+ if cmd != nil || m.journalMenu >= 0 {
+ t.Error("an unrecorded entry should not open an action menu")
+ }
+ if !strings.Contains(m.status, "no record") {
+ t.Errorf("status = %q", m.status)
+ }
+}
+
+func TestJournalFlagsSayWhatIsWrong(t *testing.T) {
+ m := testModule(120, 40)
+ got := m.journalFlags(JournalEntry{Tracks: 3, Missing: true, Placeholder: true})
+ for _, want := range []string{"archive missing", "placeholder cover", "no library copy", "3 tracks"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("flags %q are missing %q", got, want)
+ }
+ }
+ if got := m.journalFlags(JournalEntry{Tracks: 1, Library: true}); !strings.Contains(got, "complete") {
+ t.Errorf("a complete record should say so: %q", got)
+ }
+}
diff --git a/internal/tui/view.go b/internal/tui/view.go
index 57ae760..d96c1b3 100644
--- a/internal/tui/view.go
+++ b/internal/tui/view.go
@@ -402,3 +402,103 @@ func humanBytes(n int64) string {
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp])
}
+
+// viewJournal lists what intake remembers and what is actually on disk beside it,
+// because the two disagreeing is the thing worth seeing: a record whose archive
+// has gone, and an album in the archive that no record names, both look fine in
+// every other screen.
+func (m Module) viewJournal() string {
+ if len(m.journal) == 0 {
+ return " " + m.st.Muted.Render("the journal is empty: nothing has been filed yet") + "\n"
+ }
+
+ rows := m.body().H
+ if rows < 6 {
+ rows = 6
+ }
+ per := 2
+ if m.journalMenu >= 0 {
+ per = 2 + len(m.journalActions())
+ }
+ capacity := (rows - 2) / per
+ if capacity < 1 {
+ capacity = 1
+ }
+ off := m.journalOffset
+ if m.journalCursor < off {
+ off = m.journalCursor
+ }
+ if m.journalCursor >= off+capacity {
+ off = m.journalCursor - capacity + 1
+ }
+
+ var b strings.Builder
+ head := fmt.Sprintf("JOURNAL %d", len(m.journal))
+ if m.journalMenu >= 0 {
+ head = "WHAT SHOULD HAPPEN TO THIS RECORD?"
+ }
+ b.WriteString(" " + m.st.Heading.Render(head) + "\n")
+
+ end := off + capacity
+ if end > len(m.journal) {
+ end = len(m.journal)
+ }
+ for i := off; i < end; i++ {
+ e := m.journal[i]
+ selected := i == m.journalCursor
+ title := strings.TrimSpace(e.AlbumArtist + " - " + e.Album)
+ if title == "" || title == "-" {
+ title = filepath.Base(e.ArchiveDir)
+ }
+ flags := m.journalFlags(e)
+
+ if selected {
+ b.WriteString(m.st.Marker.Render("\u258e ") + m.st.Selected.Render(" "+truncateTo(title, m.width-8)+" ") + "\n")
+ } else {
+ b.WriteString(" " + m.st.Value.Render(truncateTo(title, m.width-4)) + "\n")
+ }
+ detail := m.st.Dim.Render(" " + truncateTo(e.Phase+flags, m.width-6))
+ b.WriteString(detail + "\n")
+
+ if !selected || m.journalMenu < 0 {
+ continue
+ }
+ for j, a := range m.journalActions() {
+ marker := " "
+ style := m.st.Muted
+ if j == m.journalAction {
+ marker = m.st.Marker.Render("\u258e ")
+ style = m.st.Selected
+ }
+ label := a.label
+ if a.confirm != "" && m.journalConfirm && j == m.journalAction {
+ label = a.confirm
+ }
+ b.WriteString(marker + style.Render(" "+truncateTo(label, m.width-6)+" ") + "\n")
+ }
+ }
+ return b.String()
+}
+
+func (m Module) journalFlags(e JournalEntry) string {
+ var flags []string
+ switch {
+ case e.Unrecorded:
+ flags = append(flags, "in the archive, no record")
+ case e.Missing:
+ flags = append(flags, "archive missing")
+ }
+ if e.Placeholder {
+ flags = append(flags, "placeholder cover")
+ }
+ if !e.Library && !e.Unrecorded {
+ flags = append(flags, "no library copy")
+ }
+ if len(flags) == 0 {
+ flags = append(flags, "on disk, complete")
+ }
+ if e.Tracks > 0 {
+ flags = append(flags, fmt.Sprintf("%d track%s", e.Tracks, plural(e.Tracks)))
+ }
+ return " · " + strings.Join(flags, " · ")
+}