summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-10-03 19:53:04 +0300
committerPaul Buetow <paul@buetow.org>2025-10-03 19:53:04 +0300
commitdba07073652cb1bbad58d2e82329d07eefe3c12d (patch)
tree79df1afd3837b5efc46880eab9adf58381293dfc /internal
parent0ae149babf5ddf5eda22778b9e5e81567a180561 (diff)
Add tag editing UI and responsive layoutv0.2.2
Diffstat (limited to 'internal')
-rw-r--r--internal/app/filters.go22
-rw-r--r--internal/app/loader.go26
-rw-r--r--internal/app/loader_test.go34
-rw-r--r--internal/app/messages.go7
-rw-r--r--internal/app/model.go168
-rw-r--r--internal/app/model_durations.go23
-rw-r--r--internal/app/model_keys.go9
-rw-r--r--internal/app/model_tags.go135
-rw-r--r--internal/app/model_test.go243
-rw-r--r--internal/app/tag_commands.go20
-rw-r--r--internal/app/video.go1
-rw-r--r--internal/app/view_helpers.go11
-rw-r--r--internal/meta/version.go2
-rw-r--r--internal/tags/tags.go69
-rw-r--r--internal/tags/tags_test.go47
15 files changed, 782 insertions, 35 deletions
diff --git a/internal/app/filters.go b/internal/app/filters.go
index 691be41..3e864a5 100644
--- a/internal/app/filters.go
+++ b/internal/app/filters.go
@@ -17,6 +17,7 @@ type filterState struct {
minMinutes int
maxEnabled bool
maxMinutes int
+ tags string
}
type filterInputs struct {
@@ -28,8 +29,9 @@ func (m *model) applyFilterInputs() error {
name := strings.TrimSpace(m.inputs.fields[0].Value())
minText := strings.TrimSpace(m.inputs.fields[1].Value())
maxText := strings.TrimSpace(m.inputs.fields[2].Value())
+ tags := strings.TrimSpace(m.inputs.fields[3].Value())
- filters := filterState{name: name}
+ filters := filterState{name: name, tags: tags}
if err := populateMinFilter(&filters, minText); err != nil {
return err
}
@@ -98,6 +100,9 @@ func (m model) describeFilters() string {
if m.filters.name != "" {
parts = append(parts, fmt.Sprintf("name contains %q", m.filters.name))
}
+ if m.filters.tags != "" {
+ parts = append(parts, fmt.Sprintf("tags contain %q", m.filters.tags))
+ }
if m.filters.minEnabled {
parts = append(parts, fmt.Sprintf(">=%d min", m.filters.minMinutes))
}
@@ -121,6 +126,19 @@ func (m *model) passesFilters(v video) bool {
if m.filters.maxEnabled && (v.Duration == 0 || durMinutes > m.filters.maxMinutes) {
return false
}
+ if m.filters.tags != "" {
+ query := strings.ToLower(m.filters.tags)
+ matched := false
+ for _, tag := range v.Tags {
+ if strings.Contains(strings.ToLower(tag), query) {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ return false
+ }
+ }
return true
}
@@ -128,7 +146,7 @@ func (m *model) renderFilterModal() string {
var b strings.Builder
b.WriteString("Filter videos\n")
b.WriteString("(Enter to apply, Esc to cancel)\n\n")
- labels := []string{"Name contains:", "Min length (minutes):", "Max length (minutes):"}
+ labels := []string{"Name contains:", "Min length (minutes):", "Max length (minutes):", "Tags contain:"}
for i, field := range m.inputs.fields {
line := fmt.Sprintf("%s %s", labels[i], field.View())
if i == m.inputs.focus {
diff --git a/internal/app/loader.go b/internal/app/loader.go
index 37c8c94..a11946a 100644
--- a/internal/app/loader.go
+++ b/internal/app/loader.go
@@ -3,6 +3,7 @@ package app
import (
"context"
"errors"
+ "fmt"
"io/fs"
"os"
"os/exec"
@@ -13,16 +14,18 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea"
+
+ "yoga/internal/tags"
)
func loadVideosCmd(root, cachePath string, progress *loadProgress) tea.Cmd {
return func() tea.Msg {
cache, cacheErr := loadDurationCache(cachePath)
- videos, pending, err := loadVideos(root, cache, progress)
+ videos, pending, tagErr, err := loadVideos(root, cache, progress)
if progress != nil {
progress.MarkDone()
}
- return videosLoadedMsg{videos: videos, err: err, cacheErr: cacheErr, pending: pending, cache: cache}
+ return videosLoadedMsg{videos: videos, err: err, cacheErr: cacheErr, pending: pending, cache: cache, tagErr: tagErr}
}
}
@@ -36,16 +39,17 @@ func progressTickerCmd(progress *loadProgress) tea.Cmd {
})
}
-func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error) {
+func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error, error) {
paths, err := collectVideoPaths(root)
if err != nil {
- return nil, nil, err
+ return nil, nil, nil, err
}
if progress != nil {
progress.SetTotal(len(paths))
}
videos := make([]video, 0, len(paths))
pending := make([]string, 0)
+ var tagErrors []string
for _, path := range paths {
info, statErr := os.Stat(path)
if statErr != nil {
@@ -57,16 +61,28 @@ func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]vi
if dur == 0 {
pending = append(pending, path)
}
+ tagList, tagErr := tags.Load(path)
+ if tagErr != nil {
+ tagErrors = append(tagErrors, fmt.Sprintf("%s: %v", filepath.Base(path), tagErr))
+ }
videos = append(videos, video{
Name: filepath.Base(path),
Path: path,
Duration: dur,
ModTime: info.ModTime(),
Size: info.Size(),
+ Tags: tagList,
})
increment(progress)
}
- return videos, pending, nil
+ return videos, pending, joinErrors(tagErrors), nil
+}
+
+func joinErrors(messages []string) error {
+ if len(messages) == 0 {
+ return nil
+ }
+ return errors.New(strings.Join(messages, "; "))
}
func increment(progress *loadProgress) {
diff --git a/internal/app/loader_test.go b/internal/app/loader_test.go
index 538bca0..843e471 100644
--- a/internal/app/loader_test.go
+++ b/internal/app/loader_test.go
@@ -6,6 +6,8 @@ import (
"runtime"
"testing"
"time"
+
+ "yoga/internal/tags"
)
func TestCollectVideoPathsDetectsMP4(t *testing.T) {
@@ -70,10 +72,13 @@ func TestLoadVideosWithCache(t *testing.T) {
_ = cache.Record(video, info, time.Minute)
progress := &loadProgress{}
progress.Reset()
- videos, pending, err := loadVideos(dir, cache, progress)
+ videos, pending, tagErr, err := loadVideos(dir, cache, progress)
if err != nil {
t.Fatalf("loadVideos: %v", err)
}
+ if tagErr != nil {
+ t.Fatalf("unexpected tag error: %v", tagErr)
+ }
if len(videos) != 1 || len(pending) != 0 {
t.Fatalf("expected cached video without pending: videos=%d pending=%d", len(videos), len(pending))
}
@@ -82,6 +87,28 @@ func TestLoadVideosWithCache(t *testing.T) {
}
}
+func TestLoadVideosReadsTags(t *testing.T) {
+ dir := t.TempDir()
+ videoPath := filepath.Join(dir, "session.mp4")
+ if err := os.WriteFile(videoPath, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write video: %v", err)
+ }
+ metaPath := tags.PathFor(videoPath)
+ if err := os.WriteFile(metaPath, []byte("[\"calm\", \"focus\"]"), 0o644); err != nil {
+ t.Fatalf("write tags: %v", err)
+ }
+ videos, _, tagErr, err := loadVideos(dir, nil, nil)
+ if err != nil {
+ t.Fatalf("loadVideos: %v", err)
+ }
+ if tagErr != nil {
+ t.Fatalf("unexpected tag error: %v", tagErr)
+ }
+ if len(videos) != 1 || len(videos[0].Tags) != 2 {
+ t.Fatalf("expected tags loaded, got %#v", videos)
+ }
+}
+
func TestProbeDurationsCmdHandlesMissingBinary(t *testing.T) {
cmd := probeDurationsCmd("/no/such/file.mp4", nil)
msg := cmd()
@@ -152,10 +179,13 @@ func TestLoadVideosHandlesStatError(t *testing.T) {
if err := os.Symlink(filepath.Join(dir, "missing.mp4"), broken); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
- videos, _, err := loadVideos(dir, nil, nil)
+ videos, _, tagErr, err := loadVideos(dir, nil, nil)
if err != nil {
t.Fatalf("loadVideos: %v", err)
}
+ if tagErr != nil {
+ t.Fatalf("unexpected tag error: %v", tagErr)
+ }
if len(videos) != 1 || videos[0].Err == nil {
t.Fatalf("expected stat error recorded, got %+v", videos)
}
diff --git a/internal/app/messages.go b/internal/app/messages.go
index a905263..6c571f7 100644
--- a/internal/app/messages.go
+++ b/internal/app/messages.go
@@ -8,6 +8,7 @@ type videosLoadedMsg struct {
cacheErr error
pending []string
cache *durationCache
+ tagErr error
}
type playVideoMsg struct {
@@ -26,3 +27,9 @@ type durationUpdateMsg struct {
duration time.Duration
err error
}
+
+type tagsSavedMsg struct {
+ path string
+ tags []string
+ err error
+}
diff --git a/internal/app/model.go b/internal/app/model.go
index 8cdcddc..76ab358 100644
--- a/internal/app/model.go
+++ b/internal/app/model.go
@@ -18,6 +18,17 @@ const (
sortByAge
)
+const (
+ preferredNameColumnWidth = 40
+ preferredDurationColumnWidth = 12
+ preferredAgeColumnWidth = 14
+ preferredTagsColumnWidth = 28
+ nameColumnFloorWidth = 16
+ durationColumnFloorWidth = 8
+ ageColumnFloorWidth = 10
+ tagsColumnFloorWidth = 12
+)
+
type model struct {
table table.Model
videos []video
@@ -25,6 +36,7 @@ type model struct {
filters filterState
inputs filterInputs
showFilters bool
+ editingTags bool
sortField sortField
sortAscending bool
statusMessage string
@@ -40,12 +52,18 @@ type model struct {
durationInFlight int
cropValue string
cropEnabled bool
+ tagInput textinput.Model
+ tagEditPath string
+ baseStatus string
+ showHelp bool
+ viewportWidth int
}
func newModel(opts Options) (model, error) {
tbl := buildTable()
inputs := buildFilterInputs()
inputs.fields[0].Focus()
+ tagInput := buildTagInput()
progress := &loadProgress{}
cachePath := filepath.Join(opts.Root, ".video_duration_cache.json")
@@ -53,6 +71,7 @@ func newModel(opts Options) (model, error) {
return model{
table: tbl,
inputs: inputs,
+ tagInput: tagInput,
sortField: sortByName,
sortAscending: true,
statusMessage: "Scanning for videos...",
@@ -62,16 +81,17 @@ func newModel(opts Options) (model, error) {
cachePath: cachePath,
cropValue: opts.Crop,
cropEnabled: opts.Crop != "",
+ showHelp: true,
}, nil
}
func buildTable() table.Model {
- columns := []table.Column{
- {Title: headerStyle.Render("Name"), Width: 50},
- {Title: headerStyle.Render("Duration"), Width: 12},
- {Title: headerStyle.Render("Age"), Width: 14},
- {Title: headerStyle.Render("Path"), Width: 40},
- }
+ columns := makeColumns(
+ preferredNameColumnWidth,
+ preferredDurationColumnWidth,
+ preferredAgeColumnWidth,
+ preferredTagsColumnWidth,
+ )
tbl := table.New(
table.WithColumns(columns),
table.WithFocused(true),
@@ -97,12 +117,34 @@ func buildFilterInputs() filterInputs {
maxInput.Prompt = "Max minutes: "
maxInput.CharLimit = 4
+ tagInput := textinput.New()
+ tagInput.Placeholder = "tag substring"
+ tagInput.Prompt = "Tags: "
+ tagInput.CharLimit = 256
+
return filterInputs{
- fields: []textinput.Model{nameInput, minInput, maxInput},
+ fields: []textinput.Model{nameInput, minInput, maxInput, tagInput},
focus: 0,
}
}
+func buildTagInput() textinput.Model {
+ input := textinput.New()
+ input.Placeholder = "comma-separated tags"
+ input.Prompt = "Tags: "
+ input.CharLimit = 512
+ return input
+}
+
+func makeColumns(nameWidth, durationWidth, ageWidth, tagsWidth int) []table.Column {
+ return []table.Column{
+ {Title: headerStyle.Render("Name"), Width: nameWidth},
+ {Title: headerStyle.Render("Duration"), Width: durationWidth},
+ {Title: headerStyle.Render("Age"), Width: ageWidth},
+ {Title: headerStyle.Render("Tags"), Width: tagsWidth},
+ }
+}
+
func (m model) Init() tea.Cmd {
if m.progress != nil {
m.progress.Reset()
@@ -126,6 +168,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleVideosLoaded(typed)
case playVideoMsg:
return m.handlePlayVideo(typed), nil
+ case tagsSavedMsg:
+ return m.handleTagsSaved(typed)
+ case tea.WindowSizeMsg:
+ return m.handleWindowSize(typed)
default:
return m.updateTable(msg)
}
@@ -136,6 +182,9 @@ func (m model) View() string {
return statusStyle.Render("Loading videos, please wait...")
}
body := m.renderBody()
+ if m.editingTags {
+ return body + "\n\n" + m.renderTagModal()
+ }
if m.showFilters {
return body + "\n\n" + m.renderFilterModal()
}
@@ -144,20 +193,117 @@ func (m model) View() string {
func (m model) renderBody() string {
helpLines := []string{
- "↑/↓ navigate • enter play • s sort • / filter • c copy path • q quit",
+ "↑/↓ navigate • enter play • s sort • / filter • c crop • t edit tags • q quit",
}
- info := statusStyle.Render(m.statusMessage)
+ info := statusStyle.Render(m.statusText())
progressLine := m.renderProgressLine()
content := tableStyle.Render(m.table.View())
- help := strings.Join(helpLines, "\n")
parts := []string{content}
if progressLine != "" {
parts = append(parts, progressLine)
}
- parts = append(parts, info, help)
+ parts = append(parts, info)
+ if m.showHelp {
+ help := strings.Join(helpLines, "\n")
+ parts = append(parts, help)
+ }
return strings.Join(parts, "\n")
}
+func (m model) statusText() string {
+ status := strings.TrimSpace(m.statusMessage)
+ base := strings.TrimSpace(m.baseStatus)
+ if base == "" {
+ return status
+ }
+ if status == "" || status == base {
+ return base
+ }
+ return fmt.Sprintf("%s • %s", base, status)
+}
+
+func (m model) showHelpBar() (tea.Model, tea.Cmd) {
+ if m.showHelp {
+ return m, nil
+ }
+ m.showHelp = true
+ if strings.Contains(m.statusMessage, "Help hidden") {
+ m.statusMessage = ""
+ }
+ return m, nil
+}
+
+func (m model) hideHelpBar() (tea.Model, tea.Cmd) {
+ if !m.showHelp {
+ return m, nil
+ }
+ m.showHelp = false
+ m.statusMessage = "Help hidden (press h to show)"
+ return m, nil
+}
+
+func (m model) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
+ m.viewportWidth = msg.Width
+ m.resizeColumns(msg.Width)
+ tbl, cmd := m.table.Update(msg)
+ m.table = tbl
+ if cmd == nil {
+ return m, nil
+ }
+ return m, cmd
+}
+
+func (m *model) resizeColumns(totalWidth int) {
+ if totalWidth <= 0 {
+ return
+ }
+ frame := tableStyle.GetHorizontalFrameSize()
+ contentWidth := totalWidth - frame
+ minWidth := nameColumnFloorWidth + durationColumnFloorWidth + ageColumnFloorWidth + tagsColumnFloorWidth
+ if contentWidth < minWidth {
+ contentWidth = minWidth
+ }
+ preferred := preferredNameColumnWidth + preferredDurationColumnWidth + preferredAgeColumnWidth + preferredTagsColumnWidth
+ nameWidth := preferredNameColumnWidth
+ durationWidth := preferredDurationColumnWidth
+ ageWidth := preferredAgeColumnWidth
+ tagsWidth := preferredTagsColumnWidth
+ if contentWidth >= preferred {
+ extra := contentWidth - preferred
+ nameWidth += extra
+ } else {
+ deficit := preferred - contentWidth
+ if deficit > 0 {
+ reduce := min(deficit, nameWidth-nameColumnFloorWidth)
+ nameWidth -= reduce
+ deficit -= reduce
+ }
+ if deficit > 0 {
+ reduce := min(deficit, tagsWidth-tagsColumnFloorWidth)
+ tagsWidth -= reduce
+ deficit -= reduce
+ }
+ if deficit > 0 {
+ reduce := min(deficit, ageWidth-ageColumnFloorWidth)
+ ageWidth -= reduce
+ deficit -= reduce
+ }
+ if deficit > 0 {
+ reduce := min(deficit, durationWidth-durationColumnFloorWidth)
+ durationWidth -= reduce
+ }
+ }
+ m.table.SetColumns(makeColumns(nameWidth, durationWidth, ageWidth, tagsWidth))
+ m.table.SetWidth(contentWidth)
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
func (m model) renderProgressLine() string {
if m.durationTotal == 0 {
return ""
diff --git a/internal/app/model_durations.go b/internal/app/model_durations.go
index b92e816..95fd6c7 100644
--- a/internal/app/model_durations.go
+++ b/internal/app/model_durations.go
@@ -162,20 +162,25 @@ func (m model) handleVideosLoaded(msg videosLoadedMsg) (tea.Model, tea.Cmd) {
func (m *model) updateStatusAfterLoad(msg videosLoadedMsg) {
if len(m.filtered) == 0 {
- m.statusMessage = "No videos found"
+ m.baseStatus = "No videos found"
+ m.statusMessage = m.baseStatus
return
}
+ status := ""
if len(msg.pending) > 0 {
+ status = fmt.Sprintf("Loaded %d videos, probing durations...", len(m.filtered))
if msg.cacheErr != nil {
- m.statusMessage = fmt.Sprintf("Loaded %d videos (cache warning: %v), probing durations...", len(m.filtered), msg.cacheErr)
- return
+ status = fmt.Sprintf("Loaded %d videos (cache warning: %v), probing durations...", len(m.filtered), msg.cacheErr)
+ }
+ } else {
+ status = fmt.Sprintf("Loaded %d videos", len(m.filtered))
+ if msg.cacheErr != nil {
+ status = fmt.Sprintf("Loaded %d videos (cache warning: %v)", len(m.filtered), msg.cacheErr)
}
- m.statusMessage = fmt.Sprintf("Loaded %d videos, probing durations...", len(m.filtered))
- return
}
- if msg.cacheErr != nil {
- m.statusMessage = fmt.Sprintf("Loaded %d videos (cache warning: %v)", len(m.filtered), msg.cacheErr)
- return
+ if msg.tagErr != nil {
+ status = fmt.Sprintf("%s (tag warning: %v)", status, msg.tagErr)
}
- m.statusMessage = fmt.Sprintf("Loaded %d videos", len(m.filtered))
+ m.baseStatus = status
+ m.statusMessage = status
}
diff --git a/internal/app/model_keys.go b/internal/app/model_keys.go
index d02cf46..ee15d5d 100644
--- a/internal/app/model_keys.go
+++ b/internal/app/model_keys.go
@@ -13,6 +13,9 @@ func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.loading {
return m, nil
}
+ if m.editingTags {
+ return m.handleTagKey(msg)
+ }
if m.showFilters {
return m.handleFilterKey(msg)
}
@@ -83,6 +86,12 @@ func (m model) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.sortAndReport(sortByAge)
case "c":
return m.toggleCrop()
+ case "t":
+ return m.openTagEditor()
+ case "H":
+ return m.hideHelpBar()
+ case "h":
+ return m.showHelpBar()
case "r":
return m.resetFilterState()
default:
diff --git a/internal/app/model_tags.go b/internal/app/model_tags.go
new file mode 100644
index 0000000..1ea7b3a
--- /dev/null
+++ b/internal/app/model_tags.go
@@ -0,0 +1,135 @@
+package app
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func (m model) openTagEditor() (tea.Model, tea.Cmd) {
+ if len(m.filtered) == 0 {
+ m.statusMessage = "No videos to edit"
+ return m, nil
+ }
+ cursor := m.table.Cursor()
+ if cursor < 0 || cursor >= len(m.filtered) {
+ m.statusMessage = "No selection"
+ return m, nil
+ }
+ video := m.filtered[cursor]
+ m.editingTags = true
+ m.tagEditPath = video.Path
+ m.tagInput = cloneInput(m.tagInput)
+ m.tagInput.SetValue(strings.Join(video.Tags, ", "))
+ m.tagInput.CursorEnd()
+ m.tagInput.Focus()
+ m.statusMessage = fmt.Sprintf("Editing tags for %s", video.Name)
+ return m, nil
+}
+
+func (m model) handleTagKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ switch msg.String() {
+ case "esc":
+ m.editingTags = false
+ m.tagEditPath = ""
+ m.tagInput.Blur()
+ m.statusMessage = "Tag edit cancelled"
+ return m, nil
+ case "enter":
+ return m.commitTags()
+ }
+ var cmd tea.Cmd
+ m.tagInput, cmd = m.tagInput.Update(msg)
+ return m, cmd
+}
+
+func (m model) commitTags() (tea.Model, tea.Cmd) {
+ if m.tagEditPath == "" {
+ m.editingTags = false
+ m.tagInput.Blur()
+ m.statusMessage = "No video selected"
+ return m, nil
+ }
+ value := m.tagInput.Value()
+ tags := parseTagInput(value)
+ m.editingTags = false
+ m.tagInput.Blur()
+ path := m.tagEditPath
+ m.tagEditPath = ""
+ name := filepath.Base(path)
+ m.statusMessage = fmt.Sprintf("Saving tags for %s", name)
+ return m, saveTagsCmd(path, tags)
+}
+
+func (m model) handleTagsSaved(msg tagsSavedMsg) (tea.Model, tea.Cmd) {
+ if msg.err != nil {
+ m.editingTags = false
+ m.tagEditPath = ""
+ m.tagInput.Blur()
+ m.showHelp = true
+ m.statusMessage = fmt.Sprintf("Tag save error: %v", msg.err)
+ return m, nil
+ }
+ m.editingTags = false
+ m.tagEditPath = ""
+ m.tagInput.Blur()
+ m.showHelp = true
+ m.setVideoTags(msg.path, msg.tags)
+ m.applyFiltersAndSort()
+ m.restoreSelection(msg.path)
+ if len(msg.tags) == 0 {
+ m.statusMessage = "Tags cleared"
+ return m, nil
+ }
+ m.statusMessage = fmt.Sprintf("Tags updated (%d)", len(msg.tags))
+ return m, nil
+}
+
+func (m *model) setVideoTags(path string, tags []string) {
+ for i := range m.videos {
+ if m.videos[i].Path == path {
+ m.videos[i].Tags = append([]string{}, tags...)
+ return
+ }
+ }
+}
+
+func parseTagInput(value string) []string {
+ if strings.TrimSpace(value) == "" {
+ return nil
+ }
+ parts := strings.Split(value, ",")
+ var tags []string
+ seen := make(map[string]struct{}, len(parts))
+ for _, part := range parts {
+ trimmed := strings.TrimSpace(part)
+ if trimmed == "" {
+ continue
+ }
+ lower := strings.ToLower(trimmed)
+ if _, ok := seen[lower]; ok {
+ continue
+ }
+ seen[lower] = struct{}{}
+ tags = append(tags, trimmed)
+ }
+ return tags
+}
+
+func cloneInput(in textinput.Model) textinput.Model {
+ copy := in
+ return copy
+}
+
+func (m model) renderTagModal() string {
+ var b strings.Builder
+ b.WriteString("Edit tags\n")
+ b.WriteString("(comma separated)\n\n")
+ b.WriteString(m.tagInput.View())
+ b.WriteString("\n\n")
+ b.WriteString("Enter to save, Esc to cancel")
+ return filterStyle.Render(b.String())
+}
diff --git a/internal/app/model_test.go b/internal/app/model_test.go
index 53e391e..6d1053b 100644
--- a/internal/app/model_test.go
+++ b/internal/app/model_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"time"
+ "github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
)
@@ -170,9 +171,9 @@ func TestDescribeFilters(t *testing.T) {
if err != nil {
t.Fatalf("newModel: %v", err)
}
- m.filters = filterState{name: "flow", minEnabled: true, minMinutes: 5, maxEnabled: true, maxMinutes: 20}
+ m.filters = filterState{name: "flow", minEnabled: true, minMinutes: 5, maxEnabled: true, maxMinutes: 20, tags: "calm"}
desc := m.describeFilters()
- if !strings.Contains(desc, "flow") || !strings.Contains(desc, ">=5") {
+ if !strings.Contains(desc, "flow") || !strings.Contains(desc, ">=5") || !strings.Contains(desc, "calm") {
t.Fatalf("unexpected description %s", desc)
}
}
@@ -335,13 +336,26 @@ func TestUpdateStatusAfterLoadCacheWarning(t *testing.T) {
}
}
+func TestUpdateStatusAfterLoadTagWarning(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ msg := videosLoadedMsg{videos: []video{{Name: "a", Path: "a.mp4"}}, tagErr: errors.New("bad json")}
+ modelAny, _ := m.Update(msg)
+ m = modelAny.(model)
+ if !strings.Contains(m.statusMessage, "tag warning") {
+ t.Fatalf("expected tag warning, got %s", m.statusMessage)
+ }
+}
+
func TestPassesFiltersBounds(t *testing.T) {
m, err := newModel(Options{Root: t.TempDir()})
if err != nil {
t.Fatalf("newModel: %v", err)
}
m.filters = filterState{minEnabled: true, minMinutes: 5, maxEnabled: true, maxMinutes: 15}
- video := video{Name: "clip", Duration: 10 * time.Minute}
+ video := video{Name: "clip", Duration: 10 * time.Minute, Tags: []string{"calm", "focus"}}
if !m.passesFilters(video) {
t.Fatalf("expected video within bounds")
}
@@ -353,6 +367,14 @@ func TestPassesFiltersBounds(t *testing.T) {
if m.passesFilters(video) {
t.Fatalf("expected name filter to exclude video")
}
+ m.filters = filterState{tags: "calm"}
+ if !m.passesFilters(video) {
+ t.Fatalf("expected tag filter to include video")
+ }
+ m.filters = filterState{tags: "power"}
+ if m.passesFilters(video) {
+ t.Fatalf("expected tag filter to exclude video")
+ }
}
func TestProgressTickerCmdTick(t *testing.T) {
@@ -439,6 +461,221 @@ func TestResetFilters(t *testing.T) {
}
}
+func TestHandleTagsSavedUpdatesModel(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ vid := video{Name: "clip.mp4", Path: filepath.Join(root, "clip.mp4")}
+ m.videos = []video{vid}
+ m.filtered = []video{vid}
+ m.table.SetRows([]table.Row{videoRow(vid)})
+ modelAny, _ := m.handleTagsSaved(tagsSavedMsg{path: vid.Path, tags: []string{"calm", "focus"}})
+ m = modelAny.(model)
+ if len(m.videos[0].Tags) != 2 {
+ t.Fatalf("expected tags recorded")
+ }
+ if len(m.filtered) != 1 || len(m.filtered[0].Tags) != 2 {
+ t.Fatalf("expected filtered list updated")
+ }
+ if !strings.Contains(m.statusMessage, "Tags updated") {
+ t.Fatalf("expected status update, got %s", m.statusMessage)
+ }
+}
+
+func TestOpenTagEditorLoadsExistingTags(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ vid := video{Name: "clip.mp4", Path: filepath.Join(root, "clip.mp4"), Tags: []string{"calm"}}
+ m.videos = []video{vid}
+ m.applyFiltersAndSort()
+ modelAny, _ := m.openTagEditor()
+ m = modelAny.(model)
+ if !m.editingTags {
+ t.Fatalf("expected tag editor to open")
+ }
+ if m.tagInput.Value() != "calm" {
+ t.Fatalf("expected input prefilled, got %s", m.tagInput.Value())
+ }
+}
+
+func TestParseTagInput(t *testing.T) {
+ result := parseTagInput(" calm , Focus , focus , ")
+ if len(result) != 2 {
+ t.Fatalf("expected two tags, got %v", result)
+ }
+ if result[0] != "calm" || result[1] != "Focus" {
+ t.Fatalf("unexpected order or casing: %v", result)
+ }
+ if out := parseTagInput(" "); out != nil {
+ t.Fatalf("expected nil for blank input, got %v", out)
+ }
+}
+
+func TestSaveTagsCmd(t *testing.T) {
+ dir := t.TempDir()
+ videoPath := filepath.Join(dir, "clip.mp4")
+ if err := os.WriteFile(videoPath, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write video: %v", err)
+ }
+ msg := saveTagsCmd(videoPath, []string{" calm ", "calm", "Focus"})()
+ result, ok := msg.(tagsSavedMsg)
+ if !ok {
+ t.Fatalf("expected tagsSavedMsg, got %T", msg)
+ }
+ if result.err != nil {
+ t.Fatalf("unexpected error: %v", result.err)
+ }
+ if len(result.tags) != 2 {
+ t.Fatalf("expected deduped tags, got %v", result.tags)
+ }
+}
+
+func TestHelpLineAfterTagEdit(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ vid := video{Name: "clip.mp4", Path: filepath.Join(root, "clip.mp4")}
+ loaded := videosLoadedMsg{videos: []video{vid}, cache: newDurationCache(filepath.Join(root, "cache.json"))}
+ modelAny, _ := m.handleVideosLoaded(loaded)
+ m = modelAny.(model)
+ if view := m.View(); !strings.Contains(view, "Loaded 1 videos") {
+ t.Fatalf("expected base status in view: %s", view)
+ }
+ modelAny, _ = m.openTagEditor()
+ m = modelAny.(model)
+ m.tagInput.SetValue("calm")
+ modelAny, cmd := m.handleTagKey(tea.KeyMsg{Type: tea.KeyEnter})
+ m = modelAny.(model)
+ if cmd == nil {
+ t.Fatalf("expected save command")
+ }
+ if view := m.View(); !strings.Contains(view, "Loaded 1 videos") || !strings.Contains(view, "Saving tags") {
+ t.Fatalf("expected combined status while saving: %s", view)
+ }
+ msg := cmd().(tagsSavedMsg)
+ modelAny, _ = m.handleTagsSaved(msg)
+ m = modelAny.(model)
+ if view := m.View(); !strings.Contains(view, "Loaded 1 videos") {
+ t.Fatalf("expected base status after save: %s", view)
+ }
+ if view := m.View(); !strings.Contains(view, "↑/↓ navigate") {
+ t.Fatalf("expected help line after save: %s", view)
+ }
+ if !strings.Contains(m.statusMessage, "Tags updated") {
+ t.Fatalf("expected status message to report update, got %s", m.statusMessage)
+ }
+}
+
+func TestToggleHelpKeys(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ vid := video{Name: "clip.mp4", Path: filepath.Join(root, "clip.mp4")}
+ loaded := videosLoadedMsg{videos: []video{vid}, cache: newDurationCache(filepath.Join(root, "cache.json"))}
+ modelAny, _ := m.handleVideosLoaded(loaded)
+ m = modelAny.(model)
+ helpLine := "↑/↓ navigate • enter play • s sort • / filter • c crop • t edit tags • q quit"
+ if view := m.View(); !strings.Contains(view, helpLine) {
+ t.Fatalf("expected help line visible: %s", view)
+ }
+ modelAny, _ = m.handleKeyMsg(keyMsg("H"))
+ m = modelAny.(model)
+ if m.showHelp {
+ t.Fatalf("expected help to be hidden")
+ }
+ if view := m.View(); strings.Contains(view, helpLine) {
+ t.Fatalf("expected help line hidden: %s", view)
+ }
+ if !strings.Contains(m.statusMessage, "Help hidden") {
+ t.Fatalf("expected help hidden status, got %s", m.statusMessage)
+ }
+ modelAny, _ = m.handleKeyMsg(keyMsg("h"))
+ m = modelAny.(model)
+ if !m.showHelp {
+ t.Fatalf("expected help to be shown")
+ }
+ if view := m.View(); !strings.Contains(view, helpLine) {
+ t.Fatalf("expected help line visible again: %s", view)
+ }
+}
+
+func TestWindowResizeExpandsNameColumn(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ initial := m.table.Columns()[0].Width
+ modelAny, _ := m.Update(tea.WindowSizeMsg{Width: 180, Height: 40})
+ m = modelAny.(model)
+ resized := m.table.Columns()[0].Width
+ if resized <= initial {
+ t.Fatalf("expected name column to expand, initial=%d resized=%d", initial, resized)
+ }
+}
+
+func TestWindowResizeShrinksColumnsGracefully(t *testing.T) {
+ m, err := newModel(Options{Root: t.TempDir()})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ modelAny, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 40})
+ m = modelAny.(model)
+ cols := m.table.Columns()
+ if len(cols) != 4 {
+ t.Fatalf("expected 4 columns")
+ }
+ if cols[0].Width < nameColumnFloorWidth {
+ t.Fatalf("expected name column >= floor, got %d", cols[0].Width)
+ }
+ if cols[3].Width < tagsColumnFloorWidth {
+ t.Fatalf("expected tags column >= floor, got %d", cols[3].Width)
+ }
+}
+
+func TestFilterByDurationRange(t *testing.T) {
+ root := t.TempDir()
+ m, err := newModel(Options{Root: root})
+ if err != nil {
+ t.Fatalf("newModel: %v", err)
+ }
+ m.loading = false
+ m.videos = []video{{Name: "short.mp4", Duration: 5 * time.Minute}, {Name: "long.mp4", Duration: 20 * time.Minute}}
+ m.applyFiltersAndSort()
+ modelAny, _ := m.handleKeyMsg(keyMsg("/"))
+ m = modelAny.(model)
+ if !m.showFilters {
+ t.Fatalf("expected filters to open")
+ }
+ // Move focus to min minutes field.
+ modelAny, _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyTab})
+ m = modelAny.(model)
+ // Enter "10".
+ modelAny, _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}})
+ m = modelAny.(model)
+ modelAny, _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'0'}})
+ m = modelAny.(model)
+ // Apply filter.
+ modelAny, _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyEnter})
+ m = modelAny.(model)
+ if len(m.filtered) != 1 || m.filtered[0].Name != "long.mp4" {
+ t.Fatalf("expected only long video after filtering, got %+v", m.filtered)
+ }
+ if !strings.Contains(m.statusMessage, "Filters applied") {
+ t.Fatalf("expected status update, got %s", m.statusMessage)
+ }
+}
+
func TestSyncFilterFocus(t *testing.T) {
m, err := newModel(Options{Root: t.TempDir()})
if err != nil {
diff --git a/internal/app/tag_commands.go b/internal/app/tag_commands.go
new file mode 100644
index 0000000..c83fab7
--- /dev/null
+++ b/internal/app/tag_commands.go
@@ -0,0 +1,20 @@
+package app
+
+import tea "github.com/charmbracelet/bubbletea"
+
+import "yoga/internal/tags"
+
+func saveTagsCmd(path string, entries []string) tea.Cmd {
+ // Copy slice to avoid accidental mutation after scheduling command.
+ values := append([]string{}, entries...)
+ return func() tea.Msg {
+ if err := tags.Save(path, values); err != nil {
+ return tagsSavedMsg{path: path, err: err}
+ }
+ sanitized, err := tags.Load(path)
+ if err != nil {
+ return tagsSavedMsg{path: path, err: err}
+ }
+ return tagsSavedMsg{path: path, tags: sanitized}
+ }
+}
diff --git a/internal/app/video.go b/internal/app/video.go
index f969ce6..9c85772 100644
--- a/internal/app/video.go
+++ b/internal/app/video.go
@@ -9,4 +9,5 @@ type video struct {
ModTime time.Time
Size int64
Err error
+ Tags []string
}
diff --git a/internal/app/view_helpers.go b/internal/app/view_helpers.go
index b023d62..9613d63 100644
--- a/internal/app/view_helpers.go
+++ b/internal/app/view_helpers.go
@@ -15,11 +15,11 @@ func videoRow(v video) table.Row {
duration = formatDuration(v.Duration)
}
age := humanizeAge(v.ModTime)
- path := trimPath(v.Path)
+ tags := formatTags(v.Tags)
if v.Err != nil {
duration = "!" + v.Err.Error()
}
- return table.Row{v.Name, duration, age, path}
+ return table.Row{v.Name, duration, age, tags}
}
func renderProgressBar(done, total, width int) string {
@@ -78,3 +78,10 @@ func trimPath(path string) string {
}
return path
}
+
+func formatTags(tags []string) string {
+ if len(tags) == 0 {
+ return "--"
+ }
+ return strings.Join(tags, ", ")
+}
diff --git a/internal/meta/version.go b/internal/meta/version.go
index 687450b..f096369 100644
--- a/internal/meta/version.go
+++ b/internal/meta/version.go
@@ -1,3 +1,3 @@
package meta
-const Version = "v0.2.0"
+const Version = "v0.2.2"
diff --git a/internal/tags/tags.go b/internal/tags/tags.go
new file mode 100644
index 0000000..a4f0f40
--- /dev/null
+++ b/internal/tags/tags.go
@@ -0,0 +1,69 @@
+package tags
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// PathFor returns the path to the tag metadata file for the given video path.
+func PathFor(videoPath string) string {
+ ext := filepath.Ext(videoPath)
+ if strings.EqualFold(ext, ".mp4") {
+ return strings.TrimSuffix(videoPath, ext) + ".json"
+ }
+ return videoPath + ".json"
+}
+
+// Load reads the tags associated with a video. Missing files yield an empty slice.
+func Load(videoPath string) ([]string, error) {
+ metadataPath := PathFor(videoPath)
+ data, err := os.ReadFile(metadataPath)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var parsed []string
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ return nil, err
+ }
+ return sanitize(parsed), nil
+}
+
+// Save persists the tags for a video to its metadata file.
+func Save(videoPath string, tagValues []string) error {
+ metadataPath := PathFor(videoPath)
+ cleaned := sanitize(tagValues)
+ payload, err := json.MarshalIndent(cleaned, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(metadataPath, payload, 0o644)
+}
+
+func sanitize(raw []string) []string {
+ if len(raw) == 0 {
+ return []string{}
+ }
+ seen := make(map[string]struct{}, len(raw))
+ var cleaned []string
+ for _, tag := range raw {
+ trimmed := strings.TrimSpace(tag)
+ if trimmed == "" {
+ continue
+ }
+ normalized := trimmed
+ if _, ok := seen[normalized]; ok {
+ continue
+ }
+ seen[normalized] = struct{}{}
+ cleaned = append(cleaned, normalized)
+ }
+ sort.Strings(cleaned)
+ return cleaned
+}
diff --git a/internal/tags/tags_test.go b/internal/tags/tags_test.go
new file mode 100644
index 0000000..61f84bf
--- /dev/null
+++ b/internal/tags/tags_test.go
@@ -0,0 +1,47 @@
+package tags
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPathForReplacesExtension(t *testing.T) {
+ path := "/tmp/video.MP4"
+ tagsPath := PathFor(path)
+ if tagsPath != "/tmp/video.json" {
+ t.Fatalf("expected json path, got %s", tagsPath)
+ }
+}
+
+func TestSaveAndLoadRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ videoPath := filepath.Join(dir, "clip.mp4")
+ if err := os.WriteFile(videoPath, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write video: %v", err)
+ }
+ tags := []string{" calm ", "focus", "focus"}
+ if err := Save(videoPath, tags); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+ loaded, err := Load(videoPath)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if len(loaded) != 2 {
+ t.Fatalf("expected sanitized tags, got %v", loaded)
+ }
+ if loaded[0] != "calm" || loaded[1] != "focus" {
+ t.Fatalf("unexpected ordering: %v", loaded)
+ }
+}
+
+func TestLoadMissingFile(t *testing.T) {
+ tags, err := Load("/tmp/missing.mp4")
+ if err != nil {
+ t.Fatalf("Load missing: %v", err)
+ }
+ if tags != nil {
+ t.Fatalf("expected nil tags for missing file, got %v", tags)
+ }
+}