diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go index ec84271..93978e0 100644 --- a/pkg/app/app_test.go +++ b/pkg/app/app_test.go @@ -202,6 +202,145 @@ func (m *memoryPersistence) DeleteCollection(_ context.Context, name string) err return nil } +func TestMigrationCandidates_DefaultFilters(t *testing.T) { + now := time.Date(2024, time.May, 15, 9, 0, 0, 0, time.UTC) + task := &entry.Entry{ + ID: "task-1", + Collection: "Inbox", + Bullet: glyph.Task, + Created: entry.Timestamp{Time: now.Add(-24 * time.Hour)}, + } + eventEntry := &entry.Entry{ + ID: "event-1", + Collection: "Meetings", + Bullet: glyph.Event, + Created: entry.Timestamp{Time: now.Add(-2 * time.Hour)}, + } + completed := &entry.Entry{ + ID: "done-1", + Collection: "Inbox", + Bullet: glyph.Completed, + Created: entry.Timestamp{Time: now.Add(-time.Hour)}, + } + note := &entry.Entry{ + ID: "note-1", + Collection: "Inbox", + Bullet: glyph.Note, + Created: entry.Timestamp{Time: now}, + } + futureRoot := &entry.Entry{ + ID: "future-root", + Collection: "Future", + Bullet: glyph.Task, + Created: entry.Timestamp{Time: now.Add(-10 * 24 * time.Hour)}, + } + futureMonth := &entry.Entry{ + ID: "future-month", + Collection: "Future/June 2024", + Bullet: glyph.Task, + Created: entry.Timestamp{Time: now.Add(-3 * 24 * time.Hour)}, + } + futureDay := &entry.Entry{ + ID: "future-day", + Collection: "May 2024/May 20, 2024", + Bullet: glyph.Task, + Created: entry.Timestamp{Time: now.Add(-2 * 24 * time.Hour)}, + } + pastDay := &entry.Entry{ + ID: "past-day", + Collection: "May 2024/May 10, 2024", + Bullet: glyph.Task, + Created: entry.Timestamp{Time: now.Add(-6 * 24 * time.Hour)}, + } + locked := &entry.Entry{ + ID: "locked", + Collection: "Inbox", + Bullet: glyph.Task, + Created: entry.Timestamp{Time: now.Add(-3 * time.Hour)}, + Immutable: true, + } + + store := newMemoryPersistence(task, eventEntry, completed, note, futureRoot, futureMonth, futureDay, pastDay, locked) + svc := &Service{Persistence: store} + + results, err := svc.MigrationCandidates(context.Background(), time.Time{}, now) + if err != nil { + t.Fatalf("MigrationCandidates error: %v", err) + } + got := make(map[string]bool) + for _, cand := range results { + if cand.Entry != nil { + got[cand.Entry.ID] = true + } + } + assertIncluded := func(id string) { + if !got[id] { + t.Fatalf("expected %q to be included, but was missing (results=%v)", id, got) + } + } + assertExcluded := func(id string) { + if got[id] { + t.Fatalf("expected %q to be excluded, but found (results=%v)", id, got) + } + } + + assertIncluded("task-1") + assertIncluded("event-1") + assertIncluded("future-root") + assertIncluded("past-day") + + assertExcluded("done-1") + assertExcluded("note-1") + assertExcluded("future-month") + assertExcluded("future-day") + assertExcluded("locked") +} + +func TestMigrationCandidates_WindowFilters(t *testing.T) { + now := time.Date(2024, time.April, 30, 12, 0, 0, 0, time.UTC) + recent := &entry.Entry{ + ID: "recent", + Collection: "Inbox", + Bullet: glyph.Task, + Created: entry.Timestamp{Time: now.Add(-2 * time.Hour)}, + } + old := &entry.Entry{ + ID: "old", + Collection: "Inbox", + Bullet: glyph.Task, + Created: entry.Timestamp{Time: now.Add(-10 * 24 * time.Hour)}, + } + futureRoot := &entry.Entry{ + ID: "future-root", + Collection: "Future", + Bullet: glyph.Task, + Created: entry.Timestamp{Time: now.Add(-30 * 24 * time.Hour)}, + } + store := newMemoryPersistence(recent, old, futureRoot) + svc := &Service{Persistence: store} + + since := now.Add(-72 * time.Hour) + results, err := svc.MigrationCandidates(context.Background(), since, now) + if err != nil { + t.Fatalf("MigrationCandidates error: %v", err) + } + got := make(map[string]bool) + for _, cand := range results { + if cand.Entry != nil { + got[cand.Entry.ID] = true + } + } + if !got["recent"] { + t.Fatalf("expected \"recent\" to be included within window, got %v", got) + } + if got["old"] { + t.Fatalf("expected \"old\" to be excluded outside window, got %v", got) + } + if !got["future-root"] { + t.Fatalf("expected \"future-root\" to be included regardless of window, got %v", got) + } +} + func (m *memoryPersistence) SetCollectionType(name string, typ collection.Type) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/pkg/app/migration.go b/pkg/app/migration.go index 62e5046..a82ef30 100644 --- a/pkg/app/migration.go +++ b/pkg/app/migration.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "tableflip.dev/bujo/pkg/collection" "tableflip.dev/bujo/pkg/entry" "tableflip.dev/bujo/pkg/glyph" ) @@ -18,10 +19,12 @@ type MigrationCandidate struct { LastTouched time.Time } -// MigrationCandidates returns open tasks that were touched within the provided window. -// A task is considered "open" when it still carries the task bullet (i.e. not completed, -// moved, struck, or converted to another bullet). The LastTouched timestamp reflects the -// most recent history record (or creation time). +// MigrationCandidates returns open, completable entries that require review in a migration +// session. When the since window is zero, all open tasks/events outside the Future tree +// (and not scheduled on future daily collections) are returned alongside top-level Future +// entries. When a non-zero window is provided, candidates must have been touched within the +// window, with top-level Future entries always included. LastTouched reflects the most recent +// history record (or creation time). func (s *Service) MigrationCandidates(ctx context.Context, since, until time.Time) ([]MigrationCandidate, error) { if ctx != nil { if err := ctx.Err(); err != nil { @@ -39,17 +42,40 @@ func (s *Service) MigrationCandidates(ctx context.Context, since, until time.Tim items := indexEntriesByID(all) results := make([]MigrationCandidate, 0, len(all)) + now := until + if now.IsZero() { + now = time.Now() + } + if until.IsZero() { + until = now + } + sinceZero := since.IsZero() + for _, e := range all { - if e == nil || e.ID == "" { + if !isCompletableCandidate(e) { continue } - if !isOpenTask(e) { + last := lastTouchedAt(e) + if !until.IsZero() && !last.IsZero() && last.After(until) { continue } - last := lastTouchedAt(e) - if last.Before(since) || last.After(until) { + + collectionPath := strings.TrimSpace(e.Collection) + if collectionPath == "" { continue } + topLevelFuture := isTopLevelFuture(collectionPath) + if !topLevelFuture { + if isInFutureTree(collectionPath) { + continue + } + if isDailyCollectionAfter(now, collectionPath) { + continue + } + if !sinceZero && (last.IsZero() || last.Before(since)) { + continue + } + } var parent *entry.Entry if e.ParentID != "" { parent = items[e.ParentID] @@ -73,21 +99,19 @@ func (s *Service) MigrationCandidates(ctx context.Context, since, until time.Tim return results, nil } -func isOpenTask(e *entry.Entry) bool { - if e == nil { +func isCompletableCandidate(e *entry.Entry) bool { + if e == nil || e.ID == "" { return false } if e.Immutable { return false } - if e.Bullet != glyph.Task { - return false - } - if strings.TrimSpace(e.Message) == "" && e.ParentID == "" { - // Allow blank tasks, but keep logic symmetrical in case we need special handling later. + switch e.Bullet { + case glyph.Task, glyph.Event: return true + default: + return false } - return true } func lastTouchedAt(e *entry.Entry) time.Time { @@ -106,3 +130,42 @@ func lastTouchedAt(e *entry.Entry) time.Time { } return latest } + +func isTopLevelFuture(path string) bool { + return strings.EqualFold(strings.TrimSpace(path), "Future") +} + +func isInFutureTree(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + if isTopLevelFuture(path) { + return false + } + return strings.HasPrefix(path, "Future/") +} + +func isDailyCollectionAfter(now time.Time, path string) bool { + if path == "" { + return false + } + segments := strings.Split(path, "/") + if len(segments) == 0 { + return false + } + last := strings.TrimSpace(segments[len(segments)-1]) + if !collection.IsDayName(last) { + return false + } + loc := now.Location() + day, err := time.ParseInLocation("January 2, 2006", last, loc) + if err != nil { + day, err = time.Parse("January 2, 2006", last) + if err != nil { + return false + } + } + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + return day.After(today) +} diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index e102016..a6709a5 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -44,6 +44,7 @@ func AddCommands(topLevel *cobra.Command) { addComplete(topLevel) addStrike(topLevel) addReport(topLevel) + addMigration(topLevel) addTrack(topLevel) addLog(topLevel) addCompletions(topLevel) diff --git a/pkg/commands/migration.go b/pkg/commands/migration.go new file mode 100644 index 0000000..be00e5a --- /dev/null +++ b/pkg/commands/migration.go @@ -0,0 +1,134 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "tableflip.dev/bujo/pkg/app" + "tableflip.dev/bujo/pkg/glyph" + "tableflip.dev/bujo/pkg/store" + "tableflip.dev/bujo/pkg/timeutil" +) + +func addMigration(topLevel *cobra.Command) { + migrationCmd := &cobra.Command{ + Use: "migration", + Short: "Inspect tasks eligible for migration", + } + + addMigrationList(migrationCmd) + topLevel.AddCommand(migrationCmd) +} + +func addMigrationList(parent *cobra.Command) { + var last string + + cmd := &cobra.Command{ + Use: "list", + Short: "List migration candidates using the specified time window", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + + duration, label, err := timeutil.ParseWindow(last) + if err != nil { + return err + } + until := time.Now() + since := until.Add(-duration) + + persistence, err := store.Load(nil) + if err != nil { + return err + } + service := &app.Service{Persistence: persistence} + candidates, err := service.MigrationCandidates(context.Background(), since, until) + if err != nil { + return err + } + renderMigrationList(candidates, since, until, label) + return nil + }, + } + + cmd.Flags().StringVar(&last, "last", timeutil.DefaultWindow, "time window to include (for example 3d, 1w)") + parent.AddCommand(cmd) +} + +func renderMigrationList(candidates []app.MigrationCandidate, since, until time.Time, label string) { + fmt.Printf("Migration candidates · last %s (%s → %s)\n", + label, + since.Local().Format("2006-01-02 15:04"), + until.Local().Format("2006-01-02 15:04"), + ) + + if len(candidates) == 0 { + fmt.Println(" No open tasks matched this window.") + fmt.Println() + return + } + + grouped := make(map[string][]app.MigrationCandidate) + for _, cand := range candidates { + if cand.Entry == nil { + continue + } + group := cand.Entry.Collection + grouped[group] = append(grouped[group], cand) + } + + collections := make([]string, 0, len(grouped)) + for col := range grouped { + collections = append(collections, col) + } + sort.Strings(collections) + + for _, col := range collections { + fmt.Printf("\n%s\n", col) + list := grouped[col] + for _, cand := range list { + entry := cand.Entry + bullet := entry.Bullet.Glyph().Symbol + if strings.TrimSpace(bullet) == "" { + bullet = entry.Bullet.String() + } + message := strings.TrimSpace(entry.Message) + if message == "" { + message = "" + } + signifier := entry.Signifier.Glyph().Symbol + if strings.TrimSpace(signifier) == "" || entry.Signifier == glyph.None { + signifier = " " + } + age := "unknown" + if !cand.LastTouched.IsZero() { + age = humanDuration(cand.LastTouched, until) + } + parent := "" + if cand.Parent != nil { + parent = strings.TrimSpace(cand.Parent.Message) + if parent == "" { + parent = cand.Parent.ID + } + if parent != "" { + parent = " · parent: " + parent + } + } + immutable := "" + if entry.Immutable { + immutable = " · immutable" + } + line := fmt.Sprintf(" %s%s %s", signifier, bullet, message) + line = fmt.Sprintf("%s · last touched %s%s%s", line, age, parent, immutable) + fmt.Println(line) + fmt.Printf(" id:%s created:%s\n", entry.ID, entry.Created.Local().Format("2006-01-02 15:04")) + } + } + + fmt.Println() +} diff --git a/pkg/tui/app/app.go b/pkg/tui/app/app.go index 777bf15..7b90643 100644 --- a/pkg/tui/app/app.go +++ b/pkg/tui/app/app.go @@ -102,20 +102,25 @@ type Model struct { dump io.Writer - helpVisible bool - helpReturn journalcomponent.FocusPane - helpHadFocus bool - addVisible bool - addOverlay *addtaskOverlay - detailVisible bool - detailOverlay *bulletdetailOverlay - detailLoadID string - moveVisible bool - moveOverlay *movebulletOverlay - moveLoadID string - moveBulletID string - moveCollectionID string - moveFutureOnly bool + helpVisible bool + helpReturn journalcomponent.FocusPane + helpHadFocus bool + addVisible bool + addOverlay *addtaskOverlay + detailVisible bool + detailOverlay *bulletdetailOverlay + detailLoadID string + moveVisible bool + moveOverlay *movebulletOverlay + moveLoadID string + moveBulletID string + moveCollectionID string + moveFutureOnly bool + newCollectionVisible bool + newCollectionOverlay *newCollectionOverlay + migrateVisible bool + migrateOverlay *migrationOverlay + migrateWindow migrationWindow statusText string statusClearPending bool @@ -162,6 +167,8 @@ const ( overlayKindAdd overlayKindBulletDetail overlayKindMove + overlayKindNewCollection + overlayKindMigrate ) type moveOverlayConfig struct { @@ -219,6 +226,7 @@ func New(service *app.Service) *Model { {Name: "quit", Description: "Exit bujo"}, {Name: "report", Description: "Show completed entries report"}, {Name: "debug", Description: "Toggle debug event viewer"}, + {Name: "migrate", Description: "Review and migrate open tasks"}, }) ctx, cancel := context.WithCancel(context.Background()) return &Model{ @@ -282,6 +290,40 @@ func (m *Model) Init() tea.Cmd { return tea.Batch(cmds...) } +func (m *Model) closeMigrateOverlay() tea.Cmd { + return m.closeMigrateOverlayWithStatus("") +} + +func (m *Model) closeMigrateOverlayWithStatus(status string) tea.Cmd { + if !m.migrateVisible { + if status != "" && m.command != nil { + m.setStatus(status) + } + return nil + } + var cmds []tea.Cmd + if m.overlayPane != nil { + if cmd := m.overlayPane.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + m.overlayPane.ClearOverlay() + } + m.migrateOverlay = nil + m.migrateVisible = false + m.migrateWindow = migrationWindow{} + _, _ = m.popFocusKind(focusKindOverlay) + if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + if status != "" && m.command != nil { + m.setStatus(status) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + // Update routes Bubble Tea messages to composed components and returns the updated model with any commands to execute. func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.noteEvent(msg) @@ -365,6 +407,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = v.Height m.layoutContent() case tea.KeyMsg: + if m.commandActive { + skipJournalKey = true + } switch v.String() { case "ctrl+c": return m, tea.Quit @@ -385,12 +430,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.command != nil && v.Component == m.command.ID() { raw := strings.TrimSpace(v.Value) if raw == "" { - m.setStatus("Commands: :quit, :today, :future, :debug, :report [window], :lock, :unlock, :help") + m.setStatus("Commands: :quit, :today, :future, :debug, :report [window], :migrate [window], :lock, :unlock, :help") break } parts := strings.Fields(raw) if len(parts) == 0 { - m.setStatus("Commands: :quit, :today, :future, :debug, :report [window], :lock, :unlock, :help") + m.setStatus("Commands: :quit, :today, :future, :debug, :report [window], :migrate [window], :lock, :unlock, :help") break } cmdName := strings.ToLower(parts[0]) @@ -431,6 +476,22 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // status set inside showReportOverlay } m.layoutContent() + case "migrate": + cmd, state := m.showMigrateOverlay(arg) + if cmd != nil { + cmds = append(cmds, cmd) + } + switch state { + case "opened": + m.setStatus("Migration overlay opened") + case "closed": + m.setStatus("Migration overlay closed") + case "error": + // status set within showMigrateOverlay + case "noop": + // no change + } + m.layoutContent() case "today": if cmd := m.jumpToToday(true); cmd != nil { cmds = append(cmds, cmd) @@ -476,12 +537,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.commandActive = true cmds = append(cmds, m.blurJournalPanes()...) + if m.overlayPane != nil && m.overlayPane.HasOverlay() { + if cmd := m.overlayPane.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } m.pushFocus(focusTarget{kind: focusKindCommand}) } } else { if m.commandActive { m.commandActive = false - if !m.helpVisible { + if m.overlayPane != nil && m.overlayPane.HasOverlay() { + if cmd := m.overlayPane.Focus(); cmd != nil { + cmds = append(cmds, cmd) + } + } else if !m.helpVisible { if cmd := m.focusJournalPane(m.commandReturn); cmd != nil { cmds = append(cmds, cmd) } @@ -500,6 +570,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancel = nil } case events.AddTaskRequestMsg: + if m.migrateVisible || m.moveVisible || m.detailVisible { + break + } if cmd := m.handleAddTaskRequest(v); cmd != nil { cmds = append(cmds, cmd) } @@ -508,6 +581,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } case events.MoveBulletRequestMsg: + if m.migrateVisible { + if cmd := m.handleMigrationMoveRequest(v); cmd != nil { + cmds = append(cmds, cmd) + } + skipCommandUpdate = true + skipJournalKey = true + break + } if cmd := m.handleMoveBulletRequest(v); cmd != nil { cmds = append(cmds, cmd) } @@ -520,6 +601,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } case events.BulletMoveFutureMsg: + if m.migrateVisible { + if cmd := m.handleMigrationMoveFuture(v); cmd != nil { + cmds = append(cmds, cmd) + } + skipCommandUpdate = true + skipJournalKey = true + break + } if cmd := m.handleBulletMoveFuture(v); cmd != nil { cmds = append(cmds, cmd) } @@ -529,17 +618,92 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } skipJournalKey = true skipCommandUpdate = true + case events.BulletSelectMsg: + if m.migrateVisible && v.Component == migrateDetailID { + if cmd := m.handleMigrationKeep(v); cmd != nil { + cmds = append(cmds, cmd) + } + skipJournalKey = true + skipCommandUpdate = true + } + case migrationCreateCollectionMsg: + if cmd := m.handleMigrationCreateCollection(v.Name); cmd != nil { + cmds = append(cmds, cmd) + } + skipJournalKey = true + skipCommandUpdate = true + case migrationCreateCollectionCancelledMsg: + if m.command != nil { + m.setStatus("New collection creation cancelled") + } + if m.migrateOverlay != nil { + if cmd := m.migrateOverlay.FocusDetail(); cmd != nil { + cmds = append(cmds, cmd) + } + } + skipJournalKey = true + skipCommandUpdate = true + case moveCreateCollectionMsg: + if cmd := m.handleMoveCreateCollection(v.Name); cmd != nil { + cmds = append(cmds, cmd) + } + skipJournalKey = true + skipCommandUpdate = true + case moveCreateCollectionCancelledMsg: + if m.command != nil { + m.setStatus("New collection creation cancelled") + } + if m.moveOverlay != nil { + if cmd := m.moveOverlay.FocusNav(); cmd != nil { + cmds = append(cmds, cmd) + } + } + skipJournalKey = true + skipCommandUpdate = true + case newCollectionCreateMsg: + if cmd := m.handleNewCollectionCreate(v.Name); cmd != nil { + cmds = append(cmds, cmd) + } + skipJournalKey = true + skipCommandUpdate = true + case newCollectionCancelledMsg: + if cmd := m.closeNewCollectionOverlayWithStatus("New collection creation cancelled"); cmd != nil { + cmds = append(cmds, cmd) + } + if m.journalView != nil { + if cmd := m.journalView.FocusNav(); cmd != nil { + cmds = append(cmds, cmd) + } + } + skipJournalKey = true + skipCommandUpdate = true case bulletDetailLoadedMsg: if cmd := m.handleBulletDetailLoaded(v); cmd != nil { cmds = append(cmds, cmd) } case events.CollectionSelectMsg: + if m.migrateVisible && m.migrateOverlay != nil { + if cmd := m.handleMigrationCollectionSelect(v); cmd != nil { + cmds = append(cmds, cmd) + } + skipCommandUpdate = true + skipJournalKey = true + break + } if m.moveVisible && v.Component == moveNavID { if cmd := m.handleMoveSelection(v); cmd != nil { cmds = append(cmds, cmd) } skipCommandUpdate = true skipJournalKey = true + break + } + if m.journalNav != nil && v.Component == m.journalNav.ID() && strings.EqualFold(strings.TrimSpace(v.Collection.ID), newCollectionOptionID) { + if cmd := m.startNewCollectionPrompt(); cmd != nil { + cmds = append(cmds, cmd) + } + skipCommandUpdate = true + skipJournalKey = true } case reportClosedMsg: m.reportVisible = false @@ -568,7 +732,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) cache.SetCollections(snap.Metas) cache.SetSections(snap.Sections) - nav := collectionnav.NewModel(snap.Collections) + navCollections := append([]*viewmodel.ParsedCollection(nil), snap.Collections...) + navCollections = appendNewCollectionOption(navCollections) + nav := collectionnav.NewModel(navCollections) if !m.today.IsZero() { nav.SetNow(time.Now()) } @@ -620,7 +786,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, post) } - if m.addVisible || m.detailVisible || m.moveVisible { + if m.addVisible || m.detailVisible || m.moveVisible || m.newCollectionVisible || m.migrateVisible { skipCommandUpdate = true } @@ -658,7 +824,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.journalView != nil { skipUpdate := false if _, isKey := msg.(tea.KeyMsg); isKey { - if skipJournalKey || m.helpVisible || m.addVisible || m.detailVisible || m.moveVisible { + if skipJournalKey || m.helpVisible || m.addVisible || m.detailVisible || m.moveVisible || m.newCollectionVisible || m.migrateVisible { skipUpdate = true } } @@ -994,6 +1160,7 @@ func (m *Model) handleMoveBulletRequest(msg events.MoveBulletRequestMsg) tea.Cmd return nil } trimmedCollections := filterMoveCollections(snapshot.Collections) + trimmedCollections = appendNewCollectionOption(trimmedCollections) if len(trimmedCollections) == 0 { if m.command != nil { m.setStatus("Move unavailable: no target collections") @@ -1044,6 +1211,11 @@ func (m *Model) openMoveOverlay(cfg moveOverlayConfig) tea.Cmd { cmds = append(cmds, cmd) } } + if m.newCollectionVisible { + if cmd := m.closeNewCollectionOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } if m.addVisible { if cmd := m.closeAddTaskOverlay(); cmd != nil { cmds = append(cmds, cmd) @@ -1337,7 +1509,17 @@ func (m *Model) handleMoveSelection(msg events.CollectionSelectMsg) tea.Cmd { if !m.moveVisible { return nil } + if strings.EqualFold(strings.TrimSpace(msg.Collection.ID), newCollectionOptionID) { + return m.startMoveNewCollectionPrompt() + } target := resolvedCollectionPath(msg.Collection) + collectionLabel := strings.TrimSpace(msg.Collection.Label()) + if strings.EqualFold(strings.TrimSpace(target), newCollectionOptionID) || + strings.EqualFold(strings.TrimSpace(msg.Collection.Name), newCollectionOptionLabel) || + strings.EqualFold(collectionLabel, newCollectionOptionLabel) || + strings.Contains(strings.ToLower(target), newCollectionOptionID) { + return m.startMoveNewCollectionPrompt() + } if target == "" { return nil } @@ -1384,7 +1566,7 @@ func (m *Model) handleMoveSelection(msg events.CollectionSelectMsg) tea.Cmd { } return nil } - label := strings.TrimSpace(msg.Collection.Label()) + label := collectionLabel if label == "" { label = target } @@ -1465,17 +1647,28 @@ func (m *Model) handleBulletComplete(msg events.BulletCompleteMsg) tea.Cmd { } return nil } + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" && entry != nil { + label = strings.TrimSpace(entry.Message) + } + if label == "" { + label = id + } + status := "Completed " + label if m.command != nil { - label := strings.TrimSpace(msg.Bullet.Label) - if label == "" && entry != nil { - label = strings.TrimSpace(entry.Message) - } - if label == "" { - label = id - } - m.setStatus("Completed " + label) + m.setStatus(status) } - return m.collectionSyncCmd(msg.Collection.ID) + var cmds []tea.Cmd + if cmd := m.removeMigrationBullet(id, status); cmd != nil { + cmds = append(cmds, cmd) + } + if sync := m.collectionSyncCmd(msg.Collection.ID); sync != nil { + cmds = append(cmds, sync) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) } func (m *Model) handleBulletStrike(msg events.BulletStrikeMsg) tea.Cmd { @@ -1497,17 +1690,28 @@ func (m *Model) handleBulletStrike(msg events.BulletStrikeMsg) tea.Cmd { } return nil } + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" && entry != nil { + label = strings.TrimSpace(entry.Message) + } + if label == "" { + label = id + } + status := "Marked irrelevant: " + label if m.command != nil { - label := strings.TrimSpace(msg.Bullet.Label) - if label == "" && entry != nil { - label = strings.TrimSpace(entry.Message) - } - if label == "" { - label = id - } - m.setStatus("Marked irrelevant: " + label) + m.setStatus(status) } - return m.collectionSyncCmd(msg.Collection.ID) + var cmds []tea.Cmd + if cmd := m.removeMigrationBullet(id, status); cmd != nil { + cmds = append(cmds, cmd) + } + if sync := m.collectionSyncCmd(msg.Collection.ID); sync != nil { + cmds = append(cmds, sync) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) } func (m *Model) handleBulletMoveFuture(msg events.BulletMoveFutureMsg) tea.Cmd { @@ -1783,31 +1987,91 @@ func (m *Model) jumpToToday(showStatus bool) tea.Cmd { return tea.Batch(cmds...) } -func (m *Model) jumpToFuture(showStatus bool) tea.Cmd { - if m.journalNav == nil { - if showStatus { - m.setStatus("Future collection unavailable") +func (m *Model) handleMigrationMoveRequest(msg events.MoveBulletRequestMsg) tea.Cmd { + if !m.migrateVisible || m.migrateOverlay == nil { + return nil + } + bulletID := strings.TrimSpace(msg.Bullet.ID) + if bulletID == "" { + return nil + } + targetRef, exists, ok := m.migrateOverlay.TargetSelection() + if !ok { + if m.command != nil { + m.setStatus("Move unavailable: select a destination") } return nil } - ref := events.CollectionRef{ID: "Future", Name: "Future", Type: collection.TypeMonthly} - var cmds []tea.Cmd - if cmd := m.collectionSyncCmd(ref.ID); cmd != nil { - cmds = append(cmds, cmd) + targetPath := strings.TrimSpace(resolvedCollectionPath(targetRef)) + if targetPath == "" { + targetPath = strings.TrimSpace(targetRef.Name) } - if cmd := m.journalNav.SelectCollection(ref); cmd != nil { - cmds = append(cmds, cmd) + if targetPath == "" { + if m.command != nil { + m.setStatus("Move unavailable: select a destination") + } + return nil } - if m.journalView != nil { - if cmd := m.journalView.FocusDetail(); cmd != nil { - cmds = append(cmds, cmd) + origin := strings.TrimSpace(msg.Collection.ID) + if origin == "" { + origin = strings.TrimSpace(msg.Bullet.Note) + } + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" { + label = bulletID + } + if targetPath == origin { + status := "Kept " + label + return m.removeMigrationBullet(bulletID, status) + } + if m.service == nil { + if m.command != nil { + m.setStatus("Move unavailable: service offline") } + return nil } - if m.journalDetail != nil { - m.journalDetail.FocusCollection(ref.ID) + ctx := context.Background() + if !exists { + targetType := targetRef.Type + if targetType == "" { + targetType = collection.TypeGeneric + } + if err := m.service.EnsureCollectionOfType(ctx, targetPath, targetType); err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + return nil + } } - if showStatus { - m.setStatus("Selected Future") + clone, err := m.service.Move(ctx, bulletID, targetPath) + if err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + return nil + } + if strings.TrimSpace(label) == "" && clone != nil { + label = strings.TrimSpace(clone.Message) + if label == "" { + label = bulletID + } + } + destination := targetRef.Label() + if strings.TrimSpace(destination) == "" { + destination = targetPath + } + status := "Moved " + label + " to " + destination + var cmds []tea.Cmd + if cmd := m.removeMigrationBullet(bulletID, status); cmd != nil { + cmds = append(cmds, cmd) + } + if sync := m.collectionSyncCmd(targetPath); sync != nil { + cmds = append(cmds, sync) + } + if origin != "" && origin != targetPath { + if sync := m.collectionSyncCmd(origin); sync != nil { + cmds = append(cmds, sync) + } } if len(cmds) == 0 { return nil @@ -1815,42 +2079,576 @@ func (m *Model) jumpToFuture(showStatus bool) tea.Cmd { return tea.Batch(cmds...) } -func (m *Model) setStatus(status string) { - if m.command == nil { - return - } - m.command.SetStatus(status) - m.statusText = status - m.statusClearActive = false - trim := strings.TrimSpace(status) - if trim == "" || strings.EqualFold(trim, "ready") { - m.statusClearPending = false - return +func (m *Model) handleMigrationMoveFuture(msg events.BulletMoveFutureMsg) tea.Cmd { + if !m.migrateVisible || m.migrateOverlay == nil { + return nil } - m.statusClearPending = true - m.statusClearToken++ -} - -func (m *Model) scheduleStatusClear() tea.Cmd { - if !m.statusClearPending || m.statusClearActive || strings.TrimSpace(m.statusText) == "" { + bulletID := strings.TrimSpace(msg.Bullet.ID) + if bulletID == "" { return nil } - m.statusClearPending = false - m.statusClearActive = true - token := m.statusClearToken - return tea.Tick(statusClearTimeout, func(time.Time) tea.Msg { - return statusClearMsg{token: token} - }) -} - -func (m *Model) clearStatus() { - m.statusClearActive = false - m.statusClearPending = false - m.statusText = "Ready" - if m.command != nil { - m.command.SetStatus("Ready") + targetRef, exists, ok := m.migrateOverlay.FutureSelection() + if !ok { + targetRef = events.CollectionRef{ID: "Future", Name: "Future", Type: collection.TypeMonthly} + exists = true } -} + targetPath := strings.TrimSpace(resolvedCollectionPath(targetRef)) + if targetPath == "" { + targetPath = "Future" + } + origin := strings.TrimSpace(msg.Collection.ID) + if origin == "" { + origin = strings.TrimSpace(msg.Bullet.Note) + } + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" { + label = bulletID + } + if targetPath == origin { + status := "Kept " + label + return m.removeMigrationBullet(bulletID, status) + } + if m.service == nil { + if m.command != nil { + m.setStatus("Move unavailable: service offline") + } + return nil + } + ctx := context.Background() + if !exists { + targetType := targetRef.Type + if strings.HasPrefix(targetPath, "Future/") { + if targetType == "" { + targetType = collection.TypeGeneric + } + } else if targetType == "" { + if targetPath == "Future" { + targetType = collection.TypeMonthly + } else { + targetType = collection.TypeGeneric + } + } + if err := m.service.EnsureCollectionOfType(ctx, targetPath, targetType); err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + return nil + } + } + clone, err := m.service.Move(ctx, bulletID, targetPath) + if err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + return nil + } + if strings.TrimSpace(label) == "" && clone != nil { + label = strings.TrimSpace(clone.Message) + if label == "" { + label = bulletID + } + } + dest := targetRef.Label() + if strings.TrimSpace(dest) == "" { + dest = targetPath + } + status := "Moved " + label + " to " + dest + var cmds []tea.Cmd + if cmd := m.removeMigrationBullet(bulletID, status); cmd != nil { + cmds = append(cmds, cmd) + } + if sync := m.collectionSyncCmd(targetPath); sync != nil { + cmds = append(cmds, sync) + } + if origin != "" && origin != targetPath { + if sync := m.collectionSyncCmd(origin); sync != nil { + cmds = append(cmds, sync) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) handleMigrationKeep(msg events.BulletSelectMsg) tea.Cmd { + if !m.migrateVisible || m.migrateOverlay == nil { + return nil + } + if !msg.Exists { + return nil + } + bulletID := strings.TrimSpace(msg.Bullet.ID) + if bulletID == "" { + return nil + } + label := strings.TrimSpace(msg.Bullet.Label) + if label == "" { + label = bulletID + } + status := "Kept " + label + return m.removeMigrationBullet(bulletID, status) +} + +func (m *Model) removeMigrationBullet(id, status string) tea.Cmd { + id = strings.TrimSpace(id) + if id == "" { + if status != "" && m.command != nil { + m.setStatus(status) + } + return nil + } + if !m.migrateVisible || m.migrateOverlay == nil { + if status != "" && m.command != nil { + m.setStatus(status) + } + return nil + } + m.migrateOverlay.RemoveBullet(id) + if m.migrateOverlay.IsEmpty() { + final := status + if strings.TrimSpace(final) == "" { + final = "Migration complete" + } + return m.closeMigrateOverlayWithStatus(final) + } + if status != "" && m.command != nil { + m.setStatus(status) + } + if cmd := m.migrateOverlay.FocusDetail(); cmd != nil { + return cmd + } + return nil +} + +func (m *Model) handleMigrationCollectionSelect(msg events.CollectionSelectMsg) tea.Cmd { + if m.migrateOverlay == nil { + return nil + } + if msg.Component == migrateTargetNavID && strings.EqualFold(strings.TrimSpace(msg.Collection.ID), migrationNewCollectionID) { + return m.startMigrationNewCollectionPrompt() + } + section, bulletRow, item, ok := m.migrateOverlay.CurrentMigrationSelection() + if !ok || item == nil || item.Candidate.Entry == nil { + return m.migrateOverlay.FocusDetail() + } + label := strings.TrimSpace(bulletRow.Label) + if label == "" { + label = strings.TrimSpace(item.Candidate.Entry.Message) + } + if label == "" { + label = bulletRow.ID + } + sectionRef := events.CollectionViewRef{ + ID: section.ID, + Title: section.Title, + Subtitle: section.Subtitle, + } + bulletRef := events.BulletRef{ + ID: bulletRow.ID, + Label: label, + Note: item.SectionID, + Bullet: bulletRow.Bullet, + Signifier: bulletRow.Signifier, + } + switch msg.Component { + case migrateFutureNavID: + return m.handleMigrationMoveFuture(events.BulletMoveFutureMsg{ + Component: migrateDetailID, + Collection: sectionRef, + Bullet: bulletRef, + }) + case migrateTargetNavID: + return m.handleMigrationMoveRequest(events.MoveBulletRequestMsg{ + Component: migrateDetailID, + Collection: sectionRef, + Bullet: bulletRef, + }) + default: + return m.migrateOverlay.FocusDetail() + } +} + +func (m *Model) startMigrationNewCollectionPrompt() tea.Cmd { + if m.migrateOverlay == nil { + return nil + } + if m.command != nil { + m.setStatus("Enter a name for the new collection") + } + return m.migrateOverlay.BeginNewCollectionPrompt() +} + +func (m *Model) startMoveNewCollectionPrompt() tea.Cmd { + if m.moveOverlay == nil { + return nil + } + if m.command != nil { + m.setStatus("Enter a name for the new collection") + } + return m.moveOverlay.BeginNewCollectionPrompt() +} + +func (m *Model) startNewCollectionPrompt() tea.Cmd { + if m.overlayPane == nil { + m.overlayPane = overlaypane.New(m.width, maxInt(1, m.height-1)) + } + if m.newCollectionVisible { + return m.closeNewCollectionOverlay() + } + overlay := newNewCollectionOverlay(m.dump) + overlay.SetSize(m.width, maxInt(1, m.height-1)) + + var cmds []tea.Cmd + if m.helpVisible { + if cmd := m.closeHelpOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.reportVisible { + if cmd := m.closeReportOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.addVisible { + if cmd := m.closeAddTaskOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.detailVisible { + if cmd := m.closeBulletDetailOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.moveVisible { + if cmd := m.closeMoveOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.migrateVisible { + if cmd := m.closeMigrateOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + placement := command.OverlayPlacement{Fullscreen: true} + if cmd := m.overlayPane.SetOverlay(overlay, placement); cmd != nil { + cmds = append(cmds, cmd) + } + m.newCollectionOverlay = overlay + m.newCollectionVisible = true + _ = m.dropFocusKind(focusKindCommand) + m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindNewCollection}) + cmds = append(cmds, m.blurJournalPanes()...) + if focus := m.overlayPane.Focus(); focus != nil { + cmds = append(cmds, focus) + } + if m.command != nil { + m.setStatus("Enter a name for the new collection") + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) handleNewCollectionCreate(name string) tea.Cmd { + name = strings.TrimSpace(name) + if name == "" { + if m.command != nil { + m.setStatus("Collection name cannot be empty") + } + return nil + } + if m.service == nil { + if m.command != nil { + m.setStatus("Create failed: service offline") + } + return m.closeNewCollectionOverlayWithStatus("Create failed: service offline") + } + ctx := context.Background() + if err := m.service.EnsureCollectionOfType(ctx, name, collection.TypeGeneric); err != nil { + if m.command != nil { + m.setStatus("Create failed: " + err.Error()) + } + return nil + } + + var cmds []tea.Cmd + if m.journalNav != nil { + ref := events.CollectionRef{ID: name, Name: name, Type: collection.TypeGeneric} + if cmd := m.journalNav.SelectCollection(ref); cmd != nil { + cmds = append(cmds, cmd) + } + } + if cache := m.journalCache; cache != nil { + if cmd := m.collectionSyncCmd(name); cmd != nil { + cmds = append(cmds, cmd) + } + } + if snap := m.snapshotSyncCmd(); snap != nil { + cmds = append(cmds, snap) + } + if cmd := m.closeNewCollectionOverlayWithStatus("Created " + name); cmd != nil { + cmds = append(cmds, cmd) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) handleMoveCreateCollection(name string) tea.Cmd { + name = strings.TrimSpace(name) + if name == "" { + if m.command != nil { + m.setStatus("Collection name cannot be empty") + } + if m.moveOverlay != nil { + return m.moveOverlay.BeginNewCollectionPrompt() + } + return nil + } + if m.service == nil { + if m.command != nil { + m.setStatus("Create failed: service offline") + } + if m.moveOverlay != nil { + return m.moveOverlay.FocusNav() + } + return nil + } + bulletID := strings.TrimSpace(m.moveBulletID) + if bulletID == "" { + return m.closeMoveOverlayWithStatus("Move unavailable: no bullet selected") + } + ctx := context.Background() + if err := m.service.EnsureCollectionOfType(ctx, name, collection.TypeGeneric); err != nil { + if m.command != nil { + m.setStatus("Create failed: " + err.Error()) + } + if m.moveOverlay != nil { + return m.moveOverlay.BeginNewCollectionPrompt() + } + return nil + } + clone, err := m.service.Move(ctx, bulletID, name) + if err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + if m.moveOverlay != nil { + return m.moveOverlay.BeginNewCollectionPrompt() + } + return nil + } + + target := strings.TrimSpace(name) + if target == "" { + target = name + } + var cmds []tea.Cmd + if m.journalNav != nil { + ref := events.CollectionRef{ID: target} + if idx := strings.LastIndex(target, "/"); idx >= 0 { + ref.ParentID = strings.TrimSpace(target[:idx]) + ref.Name = strings.TrimSpace(target[idx+1:]) + } else { + ref.Name = target + } + if cmd := m.journalNav.SelectCollection(ref); cmd != nil { + cmds = append(cmds, cmd) + } + } + if cache := m.journalCache; cache != nil { + if cmd := m.collectionSyncCmd(target); cmd != nil { + cmds = append(cmds, cmd) + } + origin := strings.TrimSpace(m.moveCollectionID) + if origin == "" && clone != nil { + origin = strings.TrimSpace(clone.Collection) + } + if origin != "" && !strings.EqualFold(origin, target) { + if cmd := m.collectionSyncCmd(origin); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + status := "Created " + target + " · moved bullet" + if cmd := m.closeMoveOverlayWithStatus(status); cmd != nil { + cmds = append(cmds, cmd) + } + if snap := m.snapshotSyncCmd(); snap != nil { + cmds = append(cmds, snap) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) handleMigrationCreateCollection(name string) tea.Cmd { + name = strings.TrimSpace(name) + if name == "" { + if m.command != nil { + m.setStatus("Collection name cannot be empty") + } + if m.migrateOverlay != nil { + return m.migrateOverlay.FocusDetail() + } + return nil + } + if m.service == nil { + if m.command != nil { + m.setStatus("Create failed: service offline") + } + if m.migrateOverlay != nil { + return m.migrateOverlay.FocusDetail() + } + return nil + } + ctx := context.Background() + if err := m.service.EnsureCollectionOfType(ctx, name, collection.TypeGeneric); err != nil { + if m.command != nil { + m.setStatus("Create failed: " + err.Error()) + } + if m.migrateOverlay != nil { + return m.migrateOverlay.FocusDetail() + } + return nil + } + cmds := []tea.Cmd{} + if cmd := m.collectionSyncCmd(name); cmd != nil { + cmds = append(cmds, cmd) + } + _, bulletRow, item, ok := m.migrateOverlay.CurrentMigrationSelection() + if !ok || item == nil || item.Candidate.Entry == nil { + if focus := m.migrateOverlay.FocusDetail(); focus != nil { + cmds = append(cmds, focus) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) + } + bulletID := strings.TrimSpace(item.Candidate.Entry.ID) + if bulletID == "" { + if focus := m.migrateOverlay.FocusDetail(); focus != nil { + cmds = append(cmds, focus) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) + } + clone, err := m.service.Move(ctx, bulletID, name) + if err != nil { + if m.command != nil { + m.setStatus("Move failed: " + err.Error()) + } + if focus := m.migrateOverlay.FocusDetail(); focus != nil { + cmds = append(cmds, focus) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) + } + label := strings.TrimSpace(bulletRow.Label) + if label == "" && clone != nil { + label = strings.TrimSpace(clone.Message) + } + if label == "" { + label = bulletID + } + status := "Moved " + label + " to " + name + if cmd := m.removeMigrationBullet(bulletID, status); cmd != nil { + cmds = append(cmds, cmd) + } + origin := strings.TrimSpace(item.Candidate.Entry.Collection) + if origin != "" && !strings.EqualFold(origin, name) { + if cmd := m.collectionSyncCmd(origin); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.command != nil { + m.setStatus("Created " + name + " · moved " + label) + } + if focus := m.migrateOverlay.FocusDetail(); focus != nil { + cmds = append(cmds, focus) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} +func (m *Model) jumpToFuture(showStatus bool) tea.Cmd { + if m.journalNav == nil { + if showStatus { + m.setStatus("Future collection unavailable") + } + return nil + } + ref := events.CollectionRef{ID: "Future", Name: "Future", Type: collection.TypeMonthly} + var cmds []tea.Cmd + if cmd := m.collectionSyncCmd(ref.ID); cmd != nil { + cmds = append(cmds, cmd) + } + if cmd := m.journalNav.SelectCollection(ref); cmd != nil { + cmds = append(cmds, cmd) + } + if m.journalView != nil { + if cmd := m.journalView.FocusDetail(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.journalDetail != nil { + m.journalDetail.FocusCollection(ref.ID) + } + if showStatus { + m.setStatus("Selected Future") + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) setStatus(status string) { + if m.command == nil { + return + } + m.command.SetStatus(status) + m.statusText = status + m.statusClearActive = false + trim := strings.TrimSpace(status) + if trim == "" || strings.EqualFold(trim, "ready") { + m.statusClearPending = false + return + } + m.statusClearPending = true + m.statusClearToken++ +} + +func (m *Model) scheduleStatusClear() tea.Cmd { + if !m.statusClearPending || m.statusClearActive || strings.TrimSpace(m.statusText) == "" { + return nil + } + m.statusClearPending = false + m.statusClearActive = true + token := m.statusClearToken + return tea.Tick(statusClearTimeout, func(time.Time) tea.Msg { + return statusClearMsg{token: token} + }) +} + +func (m *Model) clearStatus() { + m.statusClearActive = false + m.statusClearPending = false + m.statusText = "Ready" + if m.command != nil { + m.command.SetStatus("Ready") + } +} func (m *Model) postInteractionStatus(msg tea.Msg) tea.Cmd { switch msg.(type) { @@ -1890,6 +2688,11 @@ func (m *Model) showReportOverlay(arg string) (tea.Cmd, string) { cmds = append(cmds, cmd) } } + if m.newCollectionVisible { + if cmd := m.closeNewCollectionOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } overlay := newReportOverlay(m.service, dur, label) placement := m.reportPlacement() width := placement.Width @@ -1919,6 +2722,115 @@ func (m *Model) showReportOverlay(arg string) (tea.Cmd, string) { return tea.Batch(cmds...), "opened" } +func (m *Model) showMigrateOverlay(arg string) (tea.Cmd, string) { + if m.command == nil { + return nil, "noop" + } + if m.overlayPane == nil { + m.overlayPane = overlaypane.New(m.width, maxInt(1, m.height-1)) + } + if m.service == nil { + m.setStatus("Migration unavailable: service offline") + return nil, "error" + } + if m.migrateVisible { + return m.closeMigrateOverlay(), "closed" + } + now := time.Now() + if !m.today.IsZero() { + now = now.In(m.today.Location()) + if now.Before(m.today) { + now = m.today + } + } + window, err := resolveMigrationWindow(now, arg) + if err != nil { + m.setStatus("Migration: " + err.Error()) + return nil, "error" + } + ctx := context.Background() + metas, err := m.service.CollectionsMeta(ctx, "") + if err != nil { + m.setStatus("Migration unavailable: " + err.Error()) + return nil, "error" + } + parsedRoots := viewmodel.BuildTree(metas) + futureRoots := futureCollectionsFromMetas(metas, now) + targetRoots := filterMoveCollections(parsedRoots) + targetRoots = appendNewCollectionOption(targetRoots) + targetRoots = includeNextMonthCollection(targetRoots, now) + candidates, err := m.service.MigrationCandidates(ctx, window.Since, window.Until) + if err != nil { + m.setStatus("Migration unavailable: " + err.Error()) + return nil, "error" + } + data := buildMigrationData(now, candidates, parsedRoots) + futureNav := collectionnav.NewModel(futureRoots) + futureNav.SetBlurOnSelect(false) + targetNav := collectionnav.NewModel(targetRoots) + targetNav.SetBlurOnSelect(false) + overlay := newMigrationOverlay(data, window, futureNav, targetNav, m.dump) + overlay.SetSize(m.width, maxInt(1, m.height-1)) + + if data.IsEmpty() && m.command != nil { + m.setStatus("Migration inbox empty") + } + + var cmds []tea.Cmd + if m.helpVisible { + if cmd := m.closeHelpOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.reportVisible { + if cmd := m.closeReportOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.addVisible { + if cmd := m.closeAddTaskOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.detailVisible { + if cmd := m.closeBulletDetailOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.moveVisible { + if cmd := m.closeMoveOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.newCollectionVisible { + if cmd := m.closeNewCollectionOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.migrateVisible { + if cmd := m.closeMigrateOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } + placement := command.OverlayPlacement{Fullscreen: true} + if cmd := m.overlayPane.SetOverlay(overlay, placement); cmd != nil { + cmds = append(cmds, cmd) + } + m.migrateOverlay = overlay + m.migrateVisible = true + m.migrateWindow = window + _ = m.dropFocusKind(focusKindCommand) + m.pushFocus(focusTarget{kind: focusKindOverlay, overlay: overlayKindMigrate}) + cmds = append(cmds, m.blurJournalPanes()...) + if focusCmd := m.overlayPane.Focus(); focusCmd != nil { + cmds = append(cmds, focusCmd) + } + if len(cmds) == 0 { + return nil, "opened" + } + return tea.Batch(cmds...), "opened" +} + func (m *Model) loadJournalSnapshot() tea.Cmd { if m.service == nil { m.journalError = fmt.Errorf("service unavailable") @@ -2191,6 +3103,11 @@ func (m *Model) openHelpOverlay() tea.Cmd { cmds = append(cmds, cmd) } } + if m.newCollectionVisible { + if cmd := m.closeNewCollectionOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + } placement := m.helpPlacement() width := placement.Width if width <= 0 { @@ -2410,6 +3327,44 @@ func (m *Model) closeMoveOverlayWithStatus(status string) tea.Cmd { return tea.Batch(cmds...) } +func (m *Model) closeNewCollectionOverlayWithStatus(status string) tea.Cmd { + if !m.newCollectionVisible { + if status != "" && m.command != nil { + m.setStatus(status) + } + return nil + } + var cmds []tea.Cmd + if m.overlayPane != nil { + if cmd := m.overlayPane.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + m.overlayPane.ClearOverlay() + } + m.newCollectionOverlay = nil + m.newCollectionVisible = false + _, _ = m.popFocusKind(focusKindOverlay) + if cmd := m.restoreFocusAfterOverlay(); cmd != nil { + cmds = append(cmds, cmd) + } + if status != "" && m.command != nil { + m.setStatus(status) + } + if m.journalView != nil { + if cmd := m.journalView.FocusNav(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (m *Model) closeNewCollectionOverlay() tea.Cmd { + return m.closeNewCollectionOverlayWithStatus("") +} + func (m *Model) closeReportOverlay() tea.Cmd { if !m.reportVisible { return nil @@ -2456,6 +3411,9 @@ func (m *Model) dismissActiveOverlay() tea.Cmd { if m.moveVisible { return m.closeMoveOverlay() } + if m.migrateVisible { + return m.closeMigrateOverlay() + } m.overlayPane.ClearOverlay() _, _ = m.popFocusKind(focusKindOverlay) return m.restoreFocusAfterOverlay() diff --git a/pkg/tui/app/app_migrate_test.go b/pkg/tui/app/app_migrate_test.go new file mode 100644 index 0000000..06e4816 --- /dev/null +++ b/pkg/tui/app/app_migrate_test.go @@ -0,0 +1,166 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "tableflip.dev/bujo/pkg/app" + "tableflip.dev/bujo/pkg/collection" + "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/glyph" + "tableflip.dev/bujo/pkg/store" + "tableflip.dev/bujo/pkg/tui/components/command" +) + +func TestShowMigrateOverlayIncludesRecentEntries(t *testing.T) { + t.Helper() + + now := time.Now() + recent := now.Add(-time.Minute) + + persistence := &migrateTestPersistence{ + entries: []*entry.Entry{ + { + ID: "recent", + Collection: "Inbox", + Bullet: glyph.Task, + Message: "Migrate me", + Created: entry.Timestamp{Time: recent}, + }, + }, + metas: []collection.Meta{ + {Name: "Inbox", Type: collection.TypeGeneric}, + {Name: "Future", Type: collection.TypeMonthly}, + }, + } + + model := &Model{ + service: &app.Service{Persistence: persistence}, + command: command.NewModel(command.Options{}), + width: 80, + height: 24, + today: startOfDay(now), + } + + if _, state := model.showMigrateOverlay(""); state != "opened" { + t.Fatalf("expected overlay state \"opened\", got %q", state) + } + if model.migrateOverlay == nil { + t.Fatalf("expected migrate overlay to be initialized") + } + if model.migrateOverlay.IsEmpty() { + t.Fatalf("expected migrate overlay to include recent entries") + } +} + +type migrateTestPersistence struct { + entries []*entry.Entry + metas []collection.Meta +} + +func (p *migrateTestPersistence) MapAll(ctx context.Context) map[string][]*entry.Entry { + out := make(map[string][]*entry.Entry) + for _, e := range p.entries { + if e == nil { + continue + } + col := strings.TrimSpace(e.Collection) + out[col] = append(out[col], cloneEntry(e)) + } + return out +} + +func (p *migrateTestPersistence) ListAll(ctx context.Context) []*entry.Entry { + out := make([]*entry.Entry, 0, len(p.entries)) + for _, e := range p.entries { + out = append(out, cloneEntry(e)) + } + return out +} + +func (p *migrateTestPersistence) List(ctx context.Context, collection string) []*entry.Entry { + trimmed := strings.TrimSpace(collection) + out := make([]*entry.Entry, 0) + for _, e := range p.entries { + if e == nil { + continue + } + if strings.EqualFold(strings.TrimSpace(e.Collection), trimmed) { + out = append(out, cloneEntry(e)) + } + } + return out +} + +func (p *migrateTestPersistence) Collections(ctx context.Context, prefix string) []string { + meta := p.CollectionsMeta(ctx, prefix) + names := make([]string, 0, len(meta)) + for _, m := range meta { + names = append(names, m.Name) + } + return names +} + +func (p *migrateTestPersistence) CollectionsMeta(ctx context.Context, prefix string) []collection.Meta { + out := make([]collection.Meta, 0, len(p.metas)) + for _, m := range p.metas { + if prefix == "" || strings.HasPrefix(strings.ToLower(m.Name), strings.ToLower(prefix)) { + out = append(out, m) + } + } + return out +} + +func (p *migrateTestPersistence) Store(e *entry.Entry) error { + return nil +} + +func (p *migrateTestPersistence) Delete(e *entry.Entry) error { + return nil +} + +func (p *migrateTestPersistence) DeleteCollection(ctx context.Context, collection string) error { + return nil +} + +func (p *migrateTestPersistence) EnsureCollection(collection string) error { + return nil +} + +func (p *migrateTestPersistence) EnsureCollectionTyped(collection string, typ collection.Type) error { + return nil +} + +func (p *migrateTestPersistence) SetCollectionType(collection string, typ collection.Type) error { + return nil +} + +func (p *migrateTestPersistence) Watch(ctx context.Context) (<-chan store.Event, error) { + return nil, errors.New("not implemented") +} + +func cloneEntry(e *entry.Entry) *entry.Entry { + if e == nil { + return nil + } + clone := &entry.Entry{ + ID: e.ID, + Bullet: e.Bullet, + Schema: e.Schema, + Created: e.Created, + Collection: e.Collection, + Signifier: e.Signifier, + Message: e.Message, + ParentID: e.ParentID, + Immutable: e.Immutable, + History: append([]entry.HistoryRecord(nil), e.History...), + } + if e.On != nil { + on := *e.On + clone.On = &on + } + return clone +} diff --git a/pkg/tui/app/migrate_constants.go b/pkg/tui/app/migrate_constants.go new file mode 100644 index 0000000..5c82b15 --- /dev/null +++ b/pkg/tui/app/migrate_constants.go @@ -0,0 +1,13 @@ +package app + +import "tableflip.dev/bujo/pkg/tui/constants" + +const ( + newCollectionOptionID = constants.NewCollectionOptionID + newCollectionOptionLabel = constants.NewCollectionOptionLabel +) + +const ( + migrationNewCollectionID = constants.NewCollectionOptionID + migrationNewCollectionLabel = constants.NewCollectionOptionLabel +) diff --git a/pkg/tui/app/migrate_data.go b/pkg/tui/app/migrate_data.go new file mode 100644 index 0000000..323e5bc --- /dev/null +++ b/pkg/tui/app/migrate_data.go @@ -0,0 +1,442 @@ +package app + +import ( + "fmt" + "math" + "sort" + "strings" + "time" + + "tableflip.dev/bujo/pkg/app" + "tableflip.dev/bujo/pkg/collection" + viewmodel "tableflip.dev/bujo/pkg/collection/viewmodel" + "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/timeutil" + "tableflip.dev/bujo/pkg/tui/components/collectiondetail" + "tableflip.dev/bujo/pkg/tui/events" + "tableflip.dev/bujo/pkg/tui/uiutil" +) + +type migrationWindow struct { + HasWindow bool + Duration time.Duration + Label string + Since time.Time + Until time.Time +} + +func resolveMigrationWindow(now time.Time, spec string) (migrationWindow, error) { + if now.IsZero() { + now = time.Now() + } + trimmed := strings.TrimSpace(spec) + if trimmed == "" { + return migrationWindow{ + HasWindow: false, + Duration: 0, + Label: "all open tasks", + Since: time.Time{}, + Until: now, + }, nil + } + duration, label, err := timeutil.ParseWindow(trimmed) + if err != nil { + return migrationWindow{}, err + } + since := now.Add(-duration) + return migrationWindow{ + HasWindow: true, + Duration: duration, + Label: "last " + label, + Since: since, + Until: now, + }, nil +} + +type migrationBullet struct { + Candidate app.MigrationCandidate + SectionID string + SectionTitle string + SectionType collection.Type + CollectionRef events.CollectionRef + ParentLabel string +} + +type migrationData struct { + now time.Time + order []string + buckets map[string][]*migrationBullet + bulletsByID map[string]*migrationBullet + sections []collectiondetail.Section +} + +func newMigrationData(now time.Time) *migrationData { + if now.IsZero() { + now = time.Now() + } + return &migrationData{ + now: now, + buckets: make(map[string][]*migrationBullet), + bulletsByID: make(map[string]*migrationBullet), + } +} + +func (d *migrationData) cloneSections() []collectiondetail.Section { + if len(d.sections) == 0 { + return nil + } + out := make([]collectiondetail.Section, len(d.sections)) + for i := range d.sections { + sec := d.sections[i] + clone := collectiondetail.Section{ + ID: sec.ID, + Title: sec.Title, + Subtitle: sec.Subtitle, + Placeholder: sec.Placeholder, + Bullets: make([]collectiondetail.Bullet, len(sec.Bullets)), + } + copy(clone.Bullets, sec.Bullets) + out[i] = clone + } + return out +} + +func (d *migrationData) Sections() []collectiondetail.Section { + return d.cloneSections() +} + +func (d *migrationData) Bullet(id string) (*migrationBullet, bool) { + b, ok := d.bulletsByID[strings.TrimSpace(id)] + return b, ok +} + +func (d *migrationData) Remove(id string) bool { + id = strings.TrimSpace(id) + bullet, ok := d.bulletsByID[id] + if !ok { + return false + } + list := d.buckets[bullet.SectionID] + if len(list) == 0 { + return false + } + updated := make([]*migrationBullet, 0, len(list)-1) + for _, candidate := range list { + if candidate.Candidate.Entry == nil || strings.TrimSpace(candidate.Candidate.Entry.ID) == id { + continue + } + updated = append(updated, candidate) + } + if len(updated) == 0 { + delete(d.buckets, bullet.SectionID) + d.removeOrderEntry(bullet.SectionID) + } else { + d.buckets[bullet.SectionID] = updated + } + delete(d.bulletsByID, id) + d.rebuildSections() + return true +} + +func (d *migrationData) removeOrderEntry(collectionID string) { + if len(d.order) == 0 { + return + } + target := strings.TrimSpace(collectionID) + next := d.order[:0] + for _, id := range d.order { + if strings.EqualFold(id, target) { + continue + } + next = append(next, id) + } + d.order = next +} + +func (d *migrationData) IsEmpty() bool { + return len(d.bulletsByID) == 0 +} + +func (d *migrationData) rebuildSections() { + if len(d.order) == 0 || len(d.buckets) == 0 { + d.sections = nil + return + } + sections := make([]collectiondetail.Section, 0, len(d.buckets)) + for _, collectionID := range d.order { + list := d.buckets[collectionID] + if len(list) == 0 { + continue + } + section := collectiondetail.Section{ + ID: collectionID, + Title: list[0].SectionTitle, + } + if list[0].SectionType != "" { + section.Subtitle = string(list[0].SectionType) + } + section.Bullets = make([]collectiondetail.Bullet, 0, len(list)) + for _, bullet := range list { + if bullet == nil || bullet.Candidate.Entry == nil { + continue + } + section.Bullets = append(section.Bullets, migrationBulletToDetail(d.now, bullet)) + } + if len(section.Bullets) == 0 { + continue + } + sections = append(sections, section) + } + d.sections = sections +} + +func migrationBulletToDetail(now time.Time, bullet *migrationBullet) collectiondetail.Bullet { + entry := bullet.Candidate.Entry + label := uiutil.EntryLabel(entry) + if strings.TrimSpace(label) == "" { + label = strings.TrimSpace(entry.Message) + } + note := describeLastTouched(now, bullet.Candidate.LastTouched) + if bullet.ParentLabel != "" { + note = fmt.Sprintf("%s — parent: %s", note, bullet.ParentLabel) + } + return collectiondetail.Bullet{ + ID: entry.ID, + Label: label, + Note: note, + Bullet: entry.Bullet, + Signifier: entry.Signifier, + Created: entry.Created.Time, + Locked: entry.Immutable, + } +} + +func buildMigrationData(now time.Time, candidates []app.MigrationCandidate, parsed []*viewmodel.ParsedCollection) *migrationData { + data := newMigrationData(now) + if len(candidates) == 0 { + data.sections = nil + return data + } + index := indexParsedCollections(parsed) + order := make([]string, 0) + buckets := make(map[string][]*migrationBullet) + for _, cand := range candidates { + entry := cand.Entry + if entry == nil || strings.TrimSpace(entry.ID) == "" { + continue + } + collectionID := strings.TrimSpace(entry.Collection) + if collectionID == "" { + collectionID = "(unfiled)" + } + if _, ok := buckets[collectionID]; !ok { + order = append(order, collectionID) + buckets[collectionID] = make([]*migrationBullet, 0, 4) + } + parentLabel := "" + if cand.Parent != nil { + parentLabel = entryLabelOrMessage(cand.Parent) + } + sectionTitle, sectionType := sectionMetadata(collectionID, index) + ref := collectionRefFor(collectionID, index) + item := &migrationBullet{ + Candidate: cand, + SectionID: collectionID, + SectionTitle: sectionTitle, + SectionType: sectionType, + CollectionRef: ref, + ParentLabel: parentLabel, + } + buckets[collectionID] = append(buckets[collectionID], item) + data.bulletsByID[strings.TrimSpace(entry.ID)] = item + } + for _, entries := range buckets { + sort.SliceStable(entries, func(i, j int) bool { + left := entries[i].Candidate.LastTouched + right := entries[j].Candidate.LastTouched + if left.Equal(right) { + return strings.TrimSpace(entries[i].Candidate.Entry.Message) < strings.TrimSpace(entries[j].Candidate.Entry.Message) + } + return left.After(right) + }) + } + data.order = order + data.buckets = buckets + data.rebuildSections() + return data +} + +func indexParsedCollections(parsed []*viewmodel.ParsedCollection) map[string]*viewmodel.ParsedCollection { + index := make(map[string]*viewmodel.ParsedCollection) + var walk func(list []*viewmodel.ParsedCollection) + walk = func(list []*viewmodel.ParsedCollection) { + for _, node := range list { + if node == nil { + continue + } + id := strings.TrimSpace(node.ID) + if id == "" { + continue + } + index[id] = node + if len(node.Children) > 0 { + walk(node.Children) + } + } + } + walk(parsed) + return index +} + +func sectionMetadata(collectionID string, index map[string]*viewmodel.ParsedCollection) (string, collection.Type) { + if node, ok := index[collectionID]; ok && node != nil { + title := node.Name + if formatted := uiutil.FormattedCollectionName(node.ID); formatted != "" { + title = formatted + } else if friendly := uiutil.FriendlyCollectionName(node.ID); friendly != "" { + title = friendly + } + if strings.TrimSpace(title) == "" { + title = node.ID + } + return title, node.Type + } + segments := strings.Split(strings.TrimSpace(collectionID), "/") + last := segments[len(segments)-1] + if formatted := uiutil.FormattedCollectionName(collectionID); formatted != "" { + last = formatted + } else if friendly := uiutil.FriendlyCollectionName(collectionID); friendly != "" { + last = friendly + } + return last, collection.GuessType(last, collection.TypeGeneric) +} + +func collectionRefFor(collectionID string, index map[string]*viewmodel.ParsedCollection) events.CollectionRef { + if node, ok := index[collectionID]; ok && node != nil { + return events.RefFromParsed(node) + } + segments := strings.Split(strings.TrimSpace(collectionID), "/") + last := segments[len(segments)-1] + parent := "" + if len(segments) > 1 { + parent = strings.Join(segments[:len(segments)-1], "/") + } + ref := events.CollectionRef{ + ID: collectionID, + Name: last, + ParentID: parent, + Type: collection.GuessType(last, collection.TypeGeneric), + } + if collection.IsMonthName(last) { + if month, err := time.Parse("January 2006", last); err == nil { + ref.Month = month + } + } + if collection.IsDayName(last) { + if day, err := time.Parse("January 2, 2006", last); err == nil { + ref.Day = day + if ref.Month.IsZero() { + ref.Month = time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()) + } + if parent == "" { + ref.ParentID = fmt.Sprintf("%s %d", day.Month().String(), day.Year()) + } + } + } + return ref +} + +func entryLabelOrMessage(e *entry.Entry) string { + if e == nil { + return "" + } + label := uiutil.EntryLabel(e) + if strings.TrimSpace(label) != "" { + return label + } + return strings.TrimSpace(e.Message) +} + +func describeLastTouched(now, ts time.Time) string { + if ts.IsZero() { + return "last touched unknown" + } + if now.IsZero() { + now = time.Now() + } + diff := now.Sub(ts) + switch { + case diff < time.Minute: + return "last touched just now" + case diff < time.Hour: + mins := int(diff.Minutes()) + if mins == 1 { + return "last touched 1 minute ago" + } + return fmt.Sprintf("last touched %d minutes ago", mins) + case diff < 24*time.Hour: + hours := int(diff.Hours()) + if hours == 1 { + return "last touched 1 hour ago" + } + return fmt.Sprintf("last touched %d hours ago", hours) + case diff < 48*time.Hour: + return "last touched yesterday" + case diff < 14*24*time.Hour: + days := int(diff.Hours() / 24) + return fmt.Sprintf("last touched %d days ago", days) + default: + return "last touched " + ts.Format("2006-01-02") + } +} + +func includeNextMonthCollection(list []*viewmodel.ParsedCollection, now time.Time) []*viewmodel.ParsedCollection { + if len(list) == 0 { + return list + } + if now.IsZero() { + now = time.Now() + } + start := startOfMonth(now) + if start.IsZero() { + start = startOfMonth(time.Now()) + } + next := start.AddDate(0, 1, 0) + if next.Sub(now) > 14*24*time.Hour { + return list + } + targetID := next.Format("January 2006") + for _, node := range list { + if node == nil { + continue + } + if strings.EqualFold(strings.TrimSpace(node.ID), targetID) { + return list + } + } + stub := &viewmodel.ParsedCollection{ + ID: targetID, + Name: targetID, + Type: collection.TypeDaily, + Exists: false, + Month: next, + } + return append(list, stub) +} + +func appendNewCollectionOption(list []*viewmodel.ParsedCollection) []*viewmodel.ParsedCollection { + for _, node := range list { + if node != nil && strings.EqualFold(strings.TrimSpace(node.ID), newCollectionOptionID) { + return list + } + } + option := &viewmodel.ParsedCollection{ + ID: newCollectionOptionID, + Name: newCollectionOptionLabel, + Type: collection.TypeGeneric, + Exists: false, + Priority: math.MaxInt32, + SortKey: "zzzzzz-new-collection", + } + return append(list, option) +} diff --git a/pkg/tui/app/migrate_data_test.go b/pkg/tui/app/migrate_data_test.go new file mode 100644 index 0000000..e8f7fbf --- /dev/null +++ b/pkg/tui/app/migrate_data_test.go @@ -0,0 +1,94 @@ +package app + +import ( + "strings" + "testing" + "time" + + "tableflip.dev/bujo/pkg/app" + "tableflip.dev/bujo/pkg/collection" + viewmodel "tableflip.dev/bujo/pkg/collection/viewmodel" + "tableflip.dev/bujo/pkg/entry" + "tableflip.dev/bujo/pkg/glyph" +) + +func TestBuildMigrationDataSections(t *testing.T) { + now := time.Date(2024, time.March, 10, 8, 0, 0, 0, time.UTC) + metas := []collection.Meta{ + {Name: "Inbox", Type: collection.TypeGeneric}, + {Name: "Future", Type: collection.TypeMonthly}, + {Name: "March 2024", Type: collection.TypeDaily}, + {Name: "March 2024/March 8, 2024", Type: collection.TypeGeneric}, + } + parsed := viewmodel.BuildTree(metas) + + parent := &entry.Entry{ + ID: "parent", + Collection: "Inbox", + Bullet: glyph.Task, + Message: "Parent Task", + Created: entry.Timestamp{Time: now.Add(-48 * time.Hour)}, + } + candidates := []app.MigrationCandidate{ + { + Entry: &entry.Entry{ + ID: "task-1", + Collection: "Inbox", + Bullet: glyph.Task, + Message: "Primary task", + Created: entry.Timestamp{Time: now.Add(-2 * time.Hour)}, + }, + Parent: parent, + LastTouched: now.Add(-2 * time.Hour), + }, + { + Entry: &entry.Entry{ + ID: "future-root", + Collection: "Future", + Bullet: glyph.Task, + Message: "Future item", + Created: entry.Timestamp{Time: now.Add(-7 * 24 * time.Hour)}, + }, + LastTouched: now.Add(-7 * 24 * time.Hour), + }, + { + Entry: &entry.Entry{ + ID: "day-task", + Collection: "March 2024/March 8, 2024", + Bullet: glyph.Task, + Message: "Daily task", + Created: entry.Timestamp{Time: now.Add(-24 * time.Hour)}, + }, + LastTouched: now.Add(-24 * time.Hour), + }, + } + + data := buildMigrationData(now, candidates, parsed) + sections := data.Sections() + if len(sections) != 3 { + t.Fatalf("expected 3 sections, got %d", len(sections)) + } + if sections[0].ID != "Inbox" { + t.Fatalf("expected first section to be Inbox, got %q", sections[0].ID) + } + if len(sections[0].Bullets) != 1 { + t.Fatalf("expected Inbox section to have 1 bullet, got %d", len(sections[0].Bullets)) + } + note := sections[0].Bullets[0].Note + if note == "" || !strings.Contains(note, "parent: Parent Task") { + t.Fatalf("expected note to mention parent label, got %q", note) + } + + // Ensure removal updates sections and empties when all items removed. + if removed := data.Remove("task-1"); !removed { + t.Fatalf("expected removal of task-1 to succeed") + } + if data.IsEmpty() { + t.Fatalf("data should not be empty after removing one bullet") + } + data.Remove("future-root") + data.Remove("day-task") + if !data.IsEmpty() { + t.Fatalf("expected data to be empty after removing all bullets") + } +} diff --git a/pkg/tui/app/migrate_overlay.go b/pkg/tui/app/migrate_overlay.go new file mode 100644 index 0000000..bb7c01f --- /dev/null +++ b/pkg/tui/app/migrate_overlay.go @@ -0,0 +1,751 @@ +package app + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + bulletdetail "tableflip.dev/bujo/pkg/tui/components/bulletdetail" + collectiondetail "tableflip.dev/bujo/pkg/tui/components/collectiondetail" + collectionnav "tableflip.dev/bujo/pkg/tui/components/collectionnav" + "tableflip.dev/bujo/pkg/tui/components/command" + "tableflip.dev/bujo/pkg/tui/events" +) + +func shouldForwardDetailMsg(msg tea.Msg) bool { + switch msg.(type) { + case tea.KeyMsg, tea.WindowSizeMsg, events.FocusMsg, events.BlurMsg: + return true + default: + return false + } +} + +type migrationFocus int + +const ( + migrationFocusDetail migrationFocus = iota + migrationFocusFutureNav + migrationFocusTargetNav +) + +const ( + migrateDetailID = events.ComponentID("MigrateDetail") + migrateFutureNavID = events.ComponentID("MigrateFutureNav") + migrateTargetNavID = events.ComponentID("MigrateTargetNav") + migrateOverlayTitle = "Migration candidates" +) + +const ( + migrateHeaderTimeFormat = "2006-01-02 15:04" +) + +type migrationCreateCollectionMsg struct { + Name string +} + +type migrationCreateCollectionCancelledMsg struct{} + +type migrationOverlay struct { + data *migrationData + list *collectiondetail.Model + detail *bulletdetail.Model + futureNav *collectionnav.Model + targetNav *collectionnav.Model + window migrationWindow + focus migrationFocus + logger io.Writer + + width int + height int + contentH int + leftWidth int + rightWidth int + centerWidth int + topHeight int + bottomHeight int + + futureSelection events.CollectionRef + targetSelection events.CollectionRef + + creatingNew bool + createInput textinput.Model + createConfirm bool + createPending string + createError string +} + +func newMigrationOverlay(data *migrationData, window migrationWindow, futureNav, targetNav *collectionnav.Model, logger io.Writer) *migrationOverlay { + list := collectiondetail.NewModel(nil) + list.SetID(migrateDetailID) + list.SetSections(data.Sections()) + detail := bulletdetail.New("", "", "", "") + detail.SetLoading(false) + if futureNav != nil { + futureNav.SetID(migrateFutureNavID) + } + if targetNav != nil { + targetNav.SetID(migrateTargetNavID) + } + input := textinput.New() + input.Placeholder = "New collection name" + input.CharLimit = 256 + input.Prompt = "> " + overlay := &migrationOverlay{ + data: data, + list: list, + detail: detail, + futureNav: futureNav, + targetNav: targetNav, + window: window, + focus: migrationFocusDetail, + logger: logger, + createInput: input, + } + overlay.initializeDetail() + return overlay +} + +func (o *migrationOverlay) Init() tea.Cmd { + var cmds []tea.Cmd + if o.list != nil { + if cmd := o.list.Focus(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if o.futureNav != nil { + if cmd := o.futureNav.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if o.targetNav != nil { + if cmd := o.targetNav.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (o *migrationOverlay) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { + if o.creatingNew { + return o.updateCreateNewCollection(msg) + } + var ( + cmds []tea.Cmd + skipDetailUpdate bool + ) + switch v := msg.(type) { + case tea.KeyMsg: + switch v.String() { + case "<": + if o.futureNav != nil { + o.switchFocus(migrationFocusFutureNav, &cmds) + skipDetailUpdate = true + } + case ">": + if o.targetNav != nil { + o.switchFocus(migrationFocusTargetNav, &cmds) + skipDetailUpdate = true + } + } + } + + switch msg.(type) { + case events.CollectionChangeMsg, events.CollectionOrderMsg: + if len(cmds) == 0 { + return o, nil + } + return o, tea.Batch(cmds...) + } + + if o.futureNav != nil { + if next, cmd := o.futureNav.Update(msg); cmd != nil { + cmds = append(cmds, cmd) + } else { + o.futureNav = next.(*collectionnav.Model) + } + } + if o.targetNav != nil { + if next, cmd := o.targetNav.Update(msg); cmd != nil { + cmds = append(cmds, cmd) + } else { + o.targetNav = next.(*collectionnav.Model) + } + } + if o.list != nil && !skipDetailUpdate && shouldForwardDetailMsg(msg) { + if next, cmd := o.list.Update(msg); cmd != nil { + cmds = append(cmds, cmd) + } else if nextModel, ok := next.(*collectiondetail.Model); ok { + o.list = nextModel + } + } + if o.detail != nil { + if next, cmd := o.detail.Update(msg); cmd != nil { + cmds = append(cmds, cmd) + } else if d, ok := next.(*bulletdetail.Model); ok { + o.detail = d + } + } + + switch v := msg.(type) { + case events.CollectionHighlightMsg: + o.handleCollectionHighlight(v) + case events.BulletHighlightMsg: + if cmd := o.handleBulletHighlight(v); cmd != nil { + cmds = append(cmds, cmd) + } + } + + if len(cmds) == 0 { + return o, nil + } + return o, tea.Batch(cmds...) +} + +func (o *migrationOverlay) updateCreateNewCollection(msg tea.Msg) (command.Overlay, tea.Cmd) { + switch v := msg.(type) { + case tea.KeyMsg: + switch v.String() { + case "enter": + name := strings.TrimSpace(o.createInput.Value()) + if name == "" { + o.createError = "Collection name cannot be empty" + return o, nil + } + if !o.createConfirm { + o.createConfirm = true + o.createPending = name + o.createError = "" + return o, nil + } + if o.createPending != name { + // Value changed after confirmation; reset confirmation state. + o.createConfirm = false + o.createPending = "" + o.createError = "" + return o, nil + } + finalName := name + o.creatingNew = false + o.createConfirm = false + o.createPending = "" + o.createError = "" + o.createInput.Blur() + return o, func() tea.Msg { + return migrationCreateCollectionMsg{Name: finalName} + } + case "esc": + if o.createConfirm { + o.createConfirm = false + o.createPending = "" + o.createError = "" + return o, nil + } + o.creatingNew = false + o.createConfirm = false + o.createPending = "" + o.createError = "" + o.createInput.Blur() + o.createInput.SetValue("") + return o, func() tea.Msg { return migrationCreateCollectionCancelledMsg{} } + default: + o.createError = "" + } + } + model, cmd := o.createInput.Update(msg) + o.createInput = model + if o.createConfirm && strings.TrimSpace(o.createInput.Value()) != o.createPending { + o.createConfirm = false + o.createPending = "" + } + if _, ok := msg.(tea.KeyMsg); !ok { + o.createError = "" + } + return o, cmd +} + +func (o *migrationOverlay) BeginNewCollectionPrompt() tea.Cmd { + o.creatingNew = true + o.createConfirm = false + o.createPending = "" + o.createError = "" + o.createInput.SetValue("") + var cmds []tea.Cmd + o.switchFocus(migrationFocusDetail, &cmds) + if cmd := o.createInput.Focus(); cmd != nil { + cmds = append(cmds, cmd) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (o *migrationOverlay) handleCollectionHighlight(msg events.CollectionHighlightMsg) { + switch msg.Component { + case migrateFutureNavID: + o.futureSelection = msg.Collection + case migrateTargetNavID: + o.targetSelection = msg.Collection + } +} + +func (o *migrationOverlay) handleBulletHighlight(msg events.BulletHighlightMsg) tea.Cmd { + if msg.Component != migrateDetailID { + return nil + } + if o.data == nil { + return nil + } + bulletID := strings.TrimSpace(msg.Bullet.ID) + item, ok := o.data.Bullet(bulletID) + if !ok { + return nil + } + o.updateDetailView(item, msg.Collection.Title, msg.Bullet.Label) + if o.futureNav != nil && isFutureCollection(item.SectionID) { + if cmd := o.futureNav.SelectCollection(item.CollectionRef); cmd != nil { + return cmd + } + } else if o.targetNav != nil { + if cmd := o.targetNav.SelectCollection(item.CollectionRef); cmd != nil { + return cmd + } + } + return nil +} + +func (o *migrationOverlay) updateDetailView(item *migrationBullet, collectionTitle, bulletLabel string) { + if item == nil || item.Candidate.Entry == nil { + return + } + parentLabel := item.ParentLabel + if o.detail == nil { + o.detail = bulletdetail.New(collectionTitle, bulletLabel, item.SectionID, parentLabel) + } else { + o.detail = bulletdetail.New(collectionTitle, bulletLabel, item.SectionID, parentLabel) + } + o.detail.SetSize(o.centerWidth, o.bottomHeight) + o.detail.SetEntry(item.Candidate.Entry) +} + +func (o *migrationOverlay) View() (string, *tea.Cursor) { + header := o.renderHeader() + left := o.renderNav(o.futureNav, o.leftWidth, o.contentH) + right := o.renderNav(o.targetNav, o.rightWidth, o.contentH) + center := o.renderCenter() + + body := lipgloss.JoinHorizontal(lipgloss.Top, left, verticalDivider(o.contentH), center, verticalDivider(o.contentH), right) + content := lipgloss.JoinVertical(lipgloss.Left, header, body) + return lipgloss.NewStyle().Width(o.width).Height(o.height).Render(content), nil +} + +func (o *migrationOverlay) renderHeader() string { + label := migrateOverlayTitle + windowLabel := strings.TrimSpace(o.window.Label) + if windowLabel == "" { + windowLabel = "all open tasks" + } + header := fmt.Sprintf("%s · %s", label, windowLabel) + if span := formatMigrationWindowSpan(o.window); span != "" { + header = fmt.Sprintf("%s %s", header, span) + } + return lipgloss.NewStyle().Bold(true).Width(o.width).Render(header) +} + +func (o *migrationOverlay) renderNav(nav *collectionnav.Model, width, height int) string { + if nav == nil || width <= 0 || height <= 0 { + return lipgloss.NewStyle().Width(width).Height(height).Render("") + } + view := nav.View() + if strings.Contains(view, migrationNewCollectionID) { + view = strings.ReplaceAll(view, migrationNewCollectionID, migrationNewCollectionLabel) + } + return lipgloss.NewStyle(). + Width(width). + Height(height). + AlignVertical(lipgloss.Top). + Render(view) +} + +func (o *migrationOverlay) renderCenter() string { + var top string + if o.list != nil { + view := o.list.View() + top = lipgloss.NewStyle().Width(o.centerWidth).Height(o.topHeight).Render(view) + } else { + top = lipgloss.NewStyle().Width(o.centerWidth).Height(o.topHeight).Render("No entries to migrate.") + } + var bottom string + if o.creatingNew { + inputView := o.createInput.View() + info := "Press Enter to continue, Esc to cancel." + if o.createConfirm && o.createPending != "" { + info = fmt.Sprintf("Press Enter again to create %q, Esc to edit.", o.createPending) + } + if strings.TrimSpace(o.createInput.Value()) == "" { + info = "Enter a new collection name. Esc cancels." + } + message := lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Render(info) + if strings.TrimSpace(o.createError) != "" { + message = lipgloss.NewStyle().Foreground(lipgloss.Color("204")).Render(o.createError) + } + prompt := lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().Bold(true).Render("Create New Collection"), + inputView, + message, + ) + bottom = lipgloss.NewStyle(). + Width(o.centerWidth). + Height(o.bottomHeight). + Align(lipgloss.Left, lipgloss.Top). + Render(prompt) + } else if o.detail != nil { + view, _ := o.detail.View() + bottom = lipgloss.NewStyle().Width(o.centerWidth).Height(o.bottomHeight).Render(view) + } else { + bottom = lipgloss.NewStyle().Width(o.centerWidth).Height(o.bottomHeight).Render("") + } + divider := horizontalDivider(o.centerWidth) + return lipgloss.JoinVertical(lipgloss.Top, top, divider, bottom) +} + +func (o *migrationOverlay) SetSize(width, height int) { + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + o.width = width + o.height = height + headerRows := 2 + contentHeight := height - headerRows + if contentHeight <= 0 { + contentHeight = 1 + } + o.contentH = contentHeight + + const ( + preferredNavWidth = 24 + minNavWidth = 14 + minCenterWidth = 30 + ) + + remaining := width - 2 + left := preferredNavWidth + right := preferredNavWidth + center := remaining - left - right + if center < minCenterWidth { + deficit := minCenterWidth - center + reclaim := (deficit + 1) / 2 + left = maxInt(minNavWidth, left-reclaim) + right = maxInt(minNavWidth, right-reclaim) + center = remaining - left - right + if center < minCenterWidth { + center = minCenterWidth + if leftover := remaining - center; leftover > 0 { + left = leftover / 2 + right = leftover - left + } + } + } + if center <= 0 { + center = minCenterWidth + } + o.leftWidth = maxInt(0, left) + o.rightWidth = maxInt(0, right) + o.centerWidth = maxInt(20, center) + + available := contentHeight - 1 + if available <= 0 { + available = contentHeight + } + top := available / 2 + bottom := available - top + if top < 6 { + top = 6 + bottom = available - top + } + if bottom < 6 { + bottom = 6 + top = available - bottom + if top < 4 { + top = 4 + } + } + o.topHeight = top + o.bottomHeight = bottom + + if o.futureNav != nil { + o.futureNav.SetSize(o.leftWidth, contentHeight) + } + if o.targetNav != nil { + o.targetNav.SetSize(o.rightWidth, contentHeight) + } + if o.list != nil { + o.list.SetSize(o.centerWidth, o.topHeight) + } + if o.detail != nil { + o.detail.SetSize(o.centerWidth, o.bottomHeight) + } +} + +func (o *migrationOverlay) switchFocus(next migrationFocus, cmds *[]tea.Cmd) { + if next == o.focus { + return + } + switch o.focus { + case migrationFocusDetail: + if o.list != nil { + if cmd := o.list.Blur(); cmd != nil { + *cmds = append(*cmds, cmd) + } + } + case migrationFocusFutureNav: + if o.futureNav != nil { + if cmd := o.futureNav.Blur(); cmd != nil { + *cmds = append(*cmds, cmd) + } + } + case migrationFocusTargetNav: + if o.targetNav != nil { + if cmd := o.targetNav.Blur(); cmd != nil { + *cmds = append(*cmds, cmd) + } + } + } + o.focus = next + switch o.focus { + case migrationFocusDetail: + if o.list != nil { + if cmd := o.list.Focus(); cmd != nil { + *cmds = append(*cmds, cmd) + } + } + case migrationFocusFutureNav: + if o.futureNav != nil { + if cmd := o.futureNav.Focus(); cmd != nil { + *cmds = append(*cmds, cmd) + } + } + case migrationFocusTargetNav: + if o.targetNav != nil { + if cmd := o.targetNav.Focus(); cmd != nil { + *cmds = append(*cmds, cmd) + } + } + } +} + +func (o *migrationOverlay) SetData(data *migrationData) { + o.data = data + if o.list != nil { + if data == nil { + o.list.SetSections(nil) + } else { + o.list.SetSections(data.Sections()) + } + } + if data == nil && o.detail != nil { + o.detail = bulletdetail.New("", "", "", "") + o.detail.SetSize(o.centerWidth, o.bottomHeight) + o.detail.SetEntry(nil) + } + o.initializeDetail() +} + +func (o *migrationOverlay) RemoveBullet(id string) { + if o.data == nil { + return + } + if !o.data.Remove(id) { + return + } + if o.list != nil { + o.list.SetSections(o.data.Sections()) + } + if o.data.IsEmpty() { + o.detail = bulletdetail.New("", "", "", "") + o.detail.SetSize(o.centerWidth, o.bottomHeight) + o.detail.SetEntry(nil) + } + o.initializeDetail() +} + +func (o *migrationOverlay) IsEmpty() bool { + if o.data == nil { + return true + } + return o.data.IsEmpty() +} + +func (o *migrationOverlay) CurrentBullet() (collectiondetail.Section, collectiondetail.Bullet, bool) { + if o.list == nil { + return collectiondetail.Section{}, collectiondetail.Bullet{}, false + } + return o.list.CurrentSelection() +} + +func (o *migrationOverlay) FutureSelection() (events.CollectionRef, bool, bool) { + if o.futureNav == nil { + return events.CollectionRef{}, false, false + } + return o.futureNav.CurrentSelection() +} + +func (o *migrationOverlay) TargetSelection() (events.CollectionRef, bool, bool) { + if o.targetNav == nil { + return events.CollectionRef{}, false, false + } + return o.targetNav.CurrentSelection() +} + +func (o *migrationOverlay) CurrentMigrationSelection() (collectiondetail.Section, collectiondetail.Bullet, *migrationBullet, bool) { + if o.list == nil || o.data == nil { + return collectiondetail.Section{}, collectiondetail.Bullet{}, nil, false + } + section, bullet, ok := o.list.CurrentSelection() + if !ok || strings.TrimSpace(bullet.ID) == "" { + return section, bullet, nil, false + } + item, found := o.data.Bullet(bullet.ID) + if !found { + return section, bullet, nil, false + } + return section, bullet, item, true +} + +func (o *migrationOverlay) Focus() tea.Cmd { + if o.creatingNew { + return o.createInput.Focus() + } + switch o.focus { + case migrationFocusFutureNav: + if o.futureNav != nil { + return o.futureNav.Focus() + } + case migrationFocusTargetNav: + if o.targetNav != nil { + return o.targetNav.Focus() + } + default: + if o.list != nil { + return o.list.Focus() + } + } + return nil +} + +func (o *migrationOverlay) Blur() tea.Cmd { + var cmds []tea.Cmd + if o.creatingNew { + o.createInput.Blur() + } + if o.list != nil { + if cmd := o.list.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if o.futureNav != nil { + if cmd := o.futureNav.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if o.targetNav != nil { + if cmd := o.targetNav.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (o *migrationOverlay) FocusDetail() tea.Cmd { + if o.list == nil { + return nil + } + var cmds []tea.Cmd + if o.creatingNew { + o.creatingNew = false + o.createConfirm = false + o.createPending = "" + o.createError = "" + o.createInput.Blur() + o.createInput.SetValue("") + } + if o.futureNav != nil { + if cmd := o.futureNav.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if o.targetNav != nil { + if cmd := o.targetNav.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if cmd := o.list.Focus(); cmd != nil { + cmds = append(cmds, cmd) + } + o.focus = migrationFocusDetail + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (o *migrationOverlay) initializeDetail() { + if o.list == nil || o.data == nil { + return + } + section, bullet, ok := o.list.CurrentSelection() + if !ok || strings.TrimSpace(bullet.ID) == "" { + return + } + item, exists := o.data.Bullet(bullet.ID) + if !exists { + return + } + o.updateDetailView(item, section.Title, bullet.Label) + if o.futureNav != nil && isFutureCollection(item.SectionID) { + _ = o.futureNav.SelectCollection(item.CollectionRef) + } else if o.targetNav != nil { + _ = o.targetNav.SelectCollection(item.CollectionRef) + } +} + +func horizontalDivider(width int) string { + if width <= 0 { + width = 1 + } + line := strings.Repeat("─", width) + return lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(line) +} + +func formatMigrationWindowSpan(window migrationWindow) string { + since := window.Since + until := window.Until + if window.HasWindow { + if until.IsZero() { + until = time.Now() + } + if since.IsZero() && window.Duration > 0 { + since = until.Add(-window.Duration) + } + } + if since.IsZero() || until.IsZero() { + return "" + } + return fmt.Sprintf("(%s → %s)", since.Local().Format(migrateHeaderTimeFormat), until.Local().Format(migrateHeaderTimeFormat)) +} diff --git a/pkg/tui/app/move_overlay.go b/pkg/tui/app/move_overlay.go index 6463b98..07e4db3 100644 --- a/pkg/tui/app/move_overlay.go +++ b/pkg/tui/app/move_overlay.go @@ -1,9 +1,11 @@ package app import ( + "fmt" "io" "strings" + "github.com/charmbracelet/bubbles/v2/textinput" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" @@ -21,10 +23,32 @@ type movebulletOverlay struct { navWidth int navOnRight bool logger io.Writer + + creatingNew bool + createInput textinput.Model + createConfirm bool + createPending string + createError string +} + +type moveCreateCollectionMsg struct { + Name string } +type moveCreateCollectionCancelledMsg struct{} + func newMovebulletOverlay(detail *bulletdetail.Model, nav *collectionnav.Model, navOnRight bool, logger io.Writer) *movebulletOverlay { - return &movebulletOverlay{detail: detail, nav: nav, navOnRight: navOnRight, logger: logger} + input := textinput.New() + input.Placeholder = "New collection name" + input.CharLimit = 256 + input.Prompt = "> " + return &movebulletOverlay{ + detail: detail, + nav: nav, + navOnRight: navOnRight, + logger: logger, + createInput: input, + } } func (o *movebulletOverlay) Init() tea.Cmd { @@ -35,6 +59,9 @@ func (o *movebulletOverlay) Init() tea.Cmd { } func (o *movebulletOverlay) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { + if o.creatingNew { + return o.updateCreateNewCollection(msg) + } var cmds []tea.Cmd if o.nav != nil { next, cmd := o.nav.Update(msg) @@ -87,19 +114,55 @@ func (o *movebulletOverlay) View() (string, *tea.Cursor) { Width(o.width). Render("Select destination and press Enter · Esc cancels") + promptBlock := "" + if o.creatingNew { + info := "Enter a new collection name. Esc cancels." + value := strings.TrimSpace(o.createInput.Value()) + if value != "" { + info = "Press Enter to continue, Esc to cancel." + } + if o.createConfirm && o.createPending != "" { + info = fmt.Sprintf("Press Enter again to create %q, Esc to edit.", o.createPending) + } + messageStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + text := info + if trimmed := strings.TrimSpace(o.createError); trimmed != "" { + messageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("204")) + text = trimmed + } + message := messageStyle.Render(text) + prompt := lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.NewStyle().Bold(true).Render("Create New Collection"), + o.createInput.View(), + message, + ) + promptBlock = lipgloss.NewStyle(). + Width(o.width). + Align(lipgloss.Left, lipgloss.Top). + Render(prompt) + } + + content := "" + if o.navOnRight { + content = lipgloss.JoinHorizontal(lipgloss.Top, detailBlock, divider, navBlock) + } else { + content = lipgloss.JoinHorizontal(lipgloss.Top, navBlock, divider, detailBlock) + } + content = lipgloss.NewStyle().Width(o.width).Align(lipgloss.Left, lipgloss.Top).Render(content) + + parts := []string{instructions} + if promptBlock != "" { + parts = append(parts, promptBlock) + } + parts = append(parts, content) + + body := lipgloss.JoinVertical(lipgloss.Left, parts...) frame := lipgloss.NewStyle(). Width(o.width). Height(o.height). AlignVertical(lipgloss.Top) - if o.navOnRight { - content := lipgloss.JoinHorizontal(lipgloss.Top, detailBlock, divider, navBlock) - body := instructions + "\n" + content - return frame.Render(body), nil - } - - content := lipgloss.JoinHorizontal(lipgloss.Top, navBlock, divider, detailBlock) - body := instructions + "\n" + content return frame.Render(body), nil } @@ -161,6 +224,108 @@ func (o *movebulletOverlay) Blur() tea.Cmd { return nil } +func (o *movebulletOverlay) updateCreateNewCollection(msg tea.Msg) (command.Overlay, tea.Cmd) { + switch v := msg.(type) { + case tea.KeyMsg: + switch v.String() { + case "enter": + name := strings.TrimSpace(o.createInput.Value()) + if name == "" { + o.createError = "Collection name cannot be empty" + return o, nil + } + if !o.createConfirm { + o.createConfirm = true + o.createPending = name + o.createError = "" + return o, nil + } + if o.createPending != name { + o.createConfirm = false + o.createPending = "" + o.createError = "" + return o, nil + } + finalName := name + o.creatingNew = false + o.createConfirm = false + o.createPending = "" + o.createError = "" + o.createInput.Blur() + o.createInput.SetValue("") + o.SetSize(o.width, o.height) + return o, func() tea.Msg { + return moveCreateCollectionMsg{Name: finalName} + } + case "esc": + if o.createConfirm { + o.createConfirm = false + o.createPending = "" + o.createError = "" + return o, nil + } + o.creatingNew = false + o.createConfirm = false + o.createPending = "" + o.createError = "" + o.createInput.Blur() + o.createInput.SetValue("") + o.SetSize(o.width, o.height) + return o, func() tea.Msg { return moveCreateCollectionCancelledMsg{} } + default: + o.createError = "" + } + } + model, cmd := o.createInput.Update(msg) + o.createInput = model + if o.createConfirm && strings.TrimSpace(o.createInput.Value()) != o.createPending { + o.createConfirm = false + o.createPending = "" + } + if _, ok := msg.(tea.KeyMsg); !ok { + o.createError = "" + } + return o, cmd +} + +func (o *movebulletOverlay) BeginNewCollectionPrompt() tea.Cmd { + o.creatingNew = true + o.createConfirm = false + o.createPending = "" + o.createError = "" + o.createInput.SetValue("") + o.SetSize(o.width, o.height) + var cmds []tea.Cmd + if o.nav != nil { + if cmd := o.nav.Blur(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if cmd := o.createInput.Focus(); cmd != nil { + cmds = append(cmds, cmd) + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +func (o *movebulletOverlay) FocusNav() tea.Cmd { + if o.nav == nil { + return nil + } + if o.creatingNew { + o.creatingNew = false + o.createConfirm = false + o.createPending = "" + o.createError = "" + o.createInput.Blur() + o.createInput.SetValue("") + o.SetSize(o.width, o.height) + } + return o.nav.Focus() +} + func verticalDivider(height int) string { if height <= 0 { height = 1 @@ -171,8 +336,15 @@ func verticalDivider(height int) string { func (o *movebulletOverlay) contentHeight() int { height := o.height - 1 + if o.creatingNew { + height -= o.promptHeight() + } if height <= 0 { height = 1 } return height } + +func (o *movebulletOverlay) promptHeight() int { + return 3 +} diff --git a/pkg/tui/app/new_collection_overlay.go b/pkg/tui/app/new_collection_overlay.go new file mode 100644 index 0000000..d2d5449 --- /dev/null +++ b/pkg/tui/app/new_collection_overlay.go @@ -0,0 +1,148 @@ +package app + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + + "tableflip.dev/bujo/pkg/tui/components/command" +) + +type newCollectionCreateMsg struct { + Name string +} + +type newCollectionCancelledMsg struct{} + +type newCollectionOverlay struct { + input textinput.Model + logger io.Writer + confirm bool + pending string + errMsg string + + width int + height int +} + +func newNewCollectionOverlay(logger io.Writer) *newCollectionOverlay { + ti := textinput.New() + ti.Placeholder = "Collection name" + ti.CharLimit = 256 + ti.Prompt = "> " + return &newCollectionOverlay{ + input: ti, + logger: logger, + } +} + +func (o *newCollectionOverlay) Init() tea.Cmd { + return o.input.Focus() +} + +func (o *newCollectionOverlay) Update(msg tea.Msg) (command.Overlay, tea.Cmd) { + switch v := msg.(type) { + case tea.KeyMsg: + switch v.String() { + case "enter": + name := strings.TrimSpace(o.input.Value()) + if name == "" { + o.errMsg = "Collection name cannot be empty" + return o, nil + } + if !o.confirm { + o.confirm = true + o.pending = name + o.errMsg = "" + return o, nil + } + if o.pending != name { + o.confirm = false + o.pending = "" + o.errMsg = "" + return o, nil + } + o.confirm = false + o.pending = "" + o.errMsg = "" + o.input.Blur() + o.input.SetValue("") + return o, func() tea.Msg { + return newCollectionCreateMsg{Name: name} + } + case "esc": + o.confirm = false + o.pending = "" + o.errMsg = "" + o.input.Blur() + o.input.SetValue("") + return o, func() tea.Msg { return newCollectionCancelledMsg{} } + default: + o.errMsg = "" + } + } + model, cmd := o.input.Update(msg) + o.input = model + if o.confirm && strings.TrimSpace(o.input.Value()) != o.pending { + o.confirm = false + o.pending = "" + } + if _, ok := msg.(tea.KeyMsg); !ok { + o.errMsg = "" + } + return o, cmd +} + +func (o *newCollectionOverlay) View() (string, *tea.Cursor) { + title := lipgloss.NewStyle().Bold(true).Render("Create New Collection") + value := strings.TrimSpace(o.input.Value()) + info := "Enter a name and press Enter. Esc cancels." + if value != "" { + info = "Press Enter to continue, Esc to cancel." + } + if o.confirm && o.pending != "" { + info = fmt.Sprintf("Press Enter again to create %q, Esc to edit.", o.pending) + } + messageStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + if trimmed := strings.TrimSpace(o.errMsg); trimmed != "" { + info = trimmed + messageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("204")) + } + body := lipgloss.JoinVertical( + lipgloss.Left, + title, + o.input.View(), + messageStyle.Render(info), + ) + content := lipgloss.NewStyle(). + Width(o.width). + Height(o.height). + Align(lipgloss.Top, lipgloss.Left). + Padding(1, 2). + Render(body) + return content, nil +} + +func (o *newCollectionOverlay) SetSize(width, height int) { + if width <= 0 { + width = 60 + } + if height <= 0 { + height = 7 + } + o.width = width + o.height = height +} + +func (o *newCollectionOverlay) Focus() tea.Cmd { + return o.input.Focus() +} + +func (o *newCollectionOverlay) Blur() tea.Cmd { + o.input.Blur() + return nil +} diff --git a/pkg/tui/components/collectiondetail/model.go b/pkg/tui/components/collectiondetail/model.go index 052d61d..c17fd3b 100644 --- a/pkg/tui/components/collectiondetail/model.go +++ b/pkg/tui/components/collectiondetail/model.go @@ -645,7 +645,7 @@ func (m *Model) renderLine(idx int, selected bool) string { func (m *Model) renderSectionHeader(section int, highlight bool) string { sec := m.sections[section] - style := lipgloss.NewStyle().Bold(true) + style := lipgloss.NewStyle().Bold(true).Underline(true) if sec.Placeholder { style = style.Italic(true).Foreground(lipgloss.Color("244")) } diff --git a/pkg/tui/components/collectionnav/model.go b/pkg/tui/components/collectionnav/model.go index 7d8f9fa..bdbbd8e 100644 --- a/pkg/tui/components/collectionnav/model.go +++ b/pkg/tui/components/collectionnav/model.go @@ -15,6 +15,7 @@ import ( "tableflip.dev/bujo/pkg/collection" "tableflip.dev/bujo/pkg/collection/viewmodel" "tableflip.dev/bujo/pkg/tui/components/index" + "tableflip.dev/bujo/pkg/tui/constants" "tableflip.dev/bujo/pkg/tui/events" "tableflip.dev/bujo/pkg/tui/uiutil" ) @@ -739,6 +740,45 @@ func (m *Model) SelectCollection(ref events.CollectionRef) tea.Cmd { return m.highlightCmd() } +// CurrentSelection reports the currently highlighted collection reference. +// The boolean results indicate whether a selection exists and whether the +// collection already exists on disk. +func (m *Model) CurrentSelection() (events.CollectionRef, bool, bool) { + item, ok := m.selectedNavItem() + if !ok || item.collection == nil { + return events.CollectionRef{}, false, false + } + ref := events.RefFromParsed(item.collection) + exists := item.exists + if item.calendar != nil && item.kind == RowKindDaily { + day := item.calendar.SelectedDay() + if day > 0 { + month := item.collection.Month + if month.IsZero() { + if parsed, err := time.Parse(monthLayout, item.collection.Name); err == nil { + month = parsed + } + } + if month.IsZero() { + month = time.Now() + } + dayTime := time.Date(month.Year(), month.Month(), day, 0, 0, 0, 0, month.Location()) + ref.ParentID = item.collection.ID + ref.Month = month + ref.Day = dayTime + name := dayTime.Format(dayLayout) + ref.Name = name + if ref.ID != "" { + ref.ID = fmt.Sprintf("%s/%s", strings.TrimSuffix(item.collection.ID, "/"), name) + } else { + ref.ID = fmt.Sprintf("%s/%s", item.collection.ID, name) + } + ref.Type = collection.TypeGeneric + } + } + return ref, exists, true +} + func (m *Model) ensureSelection(ref events.CollectionRef) bool { var changed bool if ref.ID != "" { @@ -1009,6 +1049,9 @@ func (i navItem) baseView() string { indent := strings.Repeat(" ", i.depth) lines := make([]string, 0, 1) label := i.collection.Name + if strings.EqualFold(strings.TrimSpace(i.collection.ID), constants.NewCollectionOptionID) { + label = constants.NewCollectionOptionLabel + } if i.calendar != nil { label += " ▾" } else if i.hasChildren { @@ -1019,7 +1062,9 @@ func (i navItem) baseView() string { label = label + " " + marker } display := label - if !i.exists { + if strings.EqualFold(strings.TrimSpace(i.collection.ID), constants.NewCollectionOptionID) { + display = lipgloss.NewStyle().Italic(true).Foreground(lipgloss.Color("244")).Render(label) + } else if !i.exists { display = lipgloss.NewStyle().Italic(true).Render(label) } lines = append(lines, fmt.Sprintf("%s%s", indent, display)) diff --git a/pkg/tui/components/help/help.md b/pkg/tui/components/help/help.md index abc602e..bb7227d 100644 --- a/pkg/tui/components/help/help.md +++ b/pkg/tui/components/help/help.md @@ -32,12 +32,14 @@ bujo keeps your bullet journal close to the command line—capture tasks, notes, | `:lock` | Mark the selected task as immutable. | | `:unlock` | Remove the immutable flag from the selected task. | | `:report [window]` | Show completed entries for the given window (`1w`, `3d`, etc.). | +| `:migrate [window]` | Review open tasks for migration (defaults to all open work). | | `:debug` | Toggle the event log at the bottom of the screen. | | `:quit`, `:exit`, `:q` | Leave the TUI. | ### Navigation & Editing - Arrow keys / `j` `k` move through collections and bullets. +- In the migration overlay use `<` to select a Future destination, `>` to pick another collection (or `+ New Collection…`), `Enter` to keep the task in place, `x` to complete it, and `Esc` to exit or cancel prompts. - `i` opens the add-task overlay for the focused collection or bullet. - `Esc` cancels overlays or prompts; `:` enters command mode from the status bar. - Within this help overlay use the arrow keys, PageUp/PageDown, or mouse wheel to scroll. Press `Esc` or `:` to close. diff --git a/pkg/tui/constants/constants.go b/pkg/tui/constants/constants.go new file mode 100644 index 0000000..711cab4 --- /dev/null +++ b/pkg/tui/constants/constants.go @@ -0,0 +1,8 @@ +package constants + +const ( + // NewCollectionOptionID identifies the synthetic "+ New Collection..." row shared across nav surfaces. + NewCollectionOptionID = "__migration:new_collection__" + // NewCollectionOptionLabel is the user-facing label for the synthetic "+ New Collection..." row. + NewCollectionOptionLabel = "+ New Collection..." +)