From 2e9980e73cf754d9e767d93f5a87f380e5f5c8f4 Mon Sep 17 00:00:00 2001 From: "Rasmus \"Pez\" Wejlgaard" Date: Mon, 10 Nov 2025 18:50:01 +0000 Subject: [PATCH] feat: adding multi-file mode to allow opening all org files in a directory (#10) --- README.md | 43 +++++++++++++++-- cmd/org/main.go | 70 ++++++++++++++++++++------- internal/model/item.go | 1 + internal/parser/parser.go | 69 +++++++++++++++++++++++++++ internal/parser/writer.go | 72 ++++++++++++++++++++++++++++ internal/ui/app.go | 1 + internal/ui/modes.go | 99 +++++++++++++++++++++++++++++++++++---- 7 files changed, 324 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 88c17d6..8f754bc 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,46 @@ go build -o bin/org ./cmd/org ## Usage ```bash -org [filename] # Open specific org file -org -f tasks.org # Open using -f flag -org # Opens ./todo.org by default +org # Open ./todo.org (default) +org tasks.org # Open specific org file +org /path/to/work.org # Open specific org file with path +org -m # Multi-file: Load all .org files in current directory +org -m /path/to/dir # Multi-file: Load all .org files in specified directory +``` + +### Single-File Mode (Default) + +By default, `org` opens `./todo.org` or the file you specify: + +```bash +org # Opens ./todo.org +org tasks.org # Opens tasks.org +org ~/work/project.org # Opens specific file +``` + +### Multi-File Mode + +Use the `-m` or `--multi` flag to load all `.org` files in a directory as top-level items. Each file appears as a top-level item in the interface, with its contents nested underneath. Changes made to items are automatically saved back to their respective files. + +```bash +org -m # Load all .org files in current directory +org -m /path/to/dir # Load all .org files in specified directory +``` + +**Example:** If you have these files in your directory: +- `work.org` containing work tasks +- `personal.org` containing personal tasks +- `ideas.org` containing project ideas + +Running `org -m` will display: +``` +* work.org +** TODO Complete project proposal +** PROG Review code changes +* personal.org +** TODO Buy groceries +* ideas.org +** New app concept ``` ## Contributing diff --git a/cmd/org/main.go b/cmd/org/main.go index 2a8c90a..c36a939 100644 --- a/cmd/org/main.go +++ b/cmd/org/main.go @@ -7,31 +7,23 @@ import ( "path/filepath" "github.com/rwejlgaard/org/internal/config" + "github.com/rwejlgaard/org/internal/model" "github.com/rwejlgaard/org/internal/parser" "github.com/rwejlgaard/org/internal/ui" ) func main() { var filePath string - flag.StringVar(&filePath, "file", "", "Path to org file (default: ./todo.org)") - flag.StringVar(&filePath, "f", "", "Path to org file (shorthand)") + var multiMode bool + flag.BoolVar(&multiMode, "multi", false, "Load all org files in current directory as top-level items") + flag.BoolVar(&multiMode, "m", false, "Load all org files in current directory (shorthand)") flag.Parse() - // Check for positional argument first + // Check for positional argument if filePath == "" && len(flag.Args()) > 0 { filePath = flag.Args()[0] } - // Default to ./todo.org if no file specified - if filePath == "" { - cwd, err := os.Getwd() - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err) - os.Exit(1) - } - filePath = filepath.Join(cwd, "todo.org") - } - // Load configuration cfg, err := config.LoadConfig() if err != nil { @@ -39,11 +31,53 @@ func main() { cfg = config.DefaultConfig() } - // Parse the org file - orgFile, err := parser.ParseOrgFile(filePath, cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing org file: %v\n", err) - os.Exit(1) + var orgFile *model.OrgFile + + if multiMode { + // Multi-file mode: load all .org files in directory + var dirPath string + if filePath != "" { + // Check if provided path is a directory + info, err := os.Stat(filePath) + if err == nil && info.IsDir() { + dirPath = filePath + } else { + // Use directory of the provided file path + dirPath = filepath.Dir(filePath) + } + } else { + // Use current directory + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err) + os.Exit(1) + } + dirPath = cwd + } + + orgFile, err = parser.ParseMultipleOrgFiles(dirPath, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing org files: %v\n", err) + os.Exit(1) + } + } else { + // Single file mode (default) + if filePath == "" { + // Default to ./todo.org + cwd, err := os.Getwd() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err) + os.Exit(1) + } + filePath = filepath.Join(cwd, "todo.org") + } + + // Parse the org file + orgFile, err = parser.ParseOrgFile(filePath, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing org file: %v\n", err) + os.Exit(1) + } } // Run the UI diff --git a/internal/model/item.go b/internal/model/item.go index 2a91470..ac87698 100644 --- a/internal/model/item.go +++ b/internal/model/item.go @@ -26,6 +26,7 @@ type Item struct { Children []*Item // Sub-items Folded bool // Whether the item is folded (hides notes and children) ClockEntries []ClockEntry // Clock in/out entries + SourceFile string // Source file path (used in multi-file mode) } // OrgFile represents a parsed org-mode file diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 226fe5f..b43cd87 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -3,7 +3,9 @@ package parser import ( "bufio" "os" + "path/filepath" "regexp" + "sort" "strings" "github.com/rwejlgaard/org/internal/config" @@ -216,3 +218,70 @@ func ParseOrgFile(path string, cfg *config.Config) (*model.OrgFile, error) { return orgFile, nil } + +// ParseMultipleOrgFiles loads all .org files in a directory and wraps them as top-level items +func ParseMultipleOrgFiles(dirPath string, cfg *config.Config) (*model.OrgFile, error) { + // Find all .org files in the directory + matches, err := filepath.Glob(filepath.Join(dirPath, "*.org")) + if err != nil { + return nil, err + } + + // Sort files alphabetically + sort.Strings(matches) + + // Create a virtual org file + multiOrgFile := &model.OrgFile{ + Path: dirPath, // Store directory path + Items: []*model.Item{}, + } + + // Parse each file and wrap it as a top-level item + for _, filePath := range matches { + orgFile, err := ParseOrgFile(filePath, cfg) + if err != nil { + // Skip files that can't be parsed + continue + } + + // Create a wrapper item for this file + fileName := filepath.Base(filePath) + fileItem := &model.Item{ + Level: 1, + State: model.StateNone, + Priority: model.PriorityNone, + Title: fileName, + Tags: []string{}, + Notes: []string{}, + Children: []*model.Item{}, + SourceFile: filePath, + } + + // Increment the level of all items from this file and add as children + for _, item := range orgFile.Items { + incrementItemLevel(item) + setSourceFileRecursive(item, filePath) + fileItem.Children = append(fileItem.Children, item) + } + + multiOrgFile.Items = append(multiOrgFile.Items, fileItem) + } + + return multiOrgFile, nil +} + +// incrementItemLevel recursively increments the level of an item and its children +func incrementItemLevel(item *model.Item) { + item.Level++ + for _, child := range item.Children { + incrementItemLevel(child) + } +} + +// setSourceFileRecursive sets the source file for an item and all its descendants +func setSourceFileRecursive(item *model.Item, filePath string) { + item.SourceFile = filePath + for _, child := range item.Children { + setSourceFileRecursive(child, filePath) + } +} diff --git a/internal/parser/writer.go b/internal/parser/writer.go index 4209047..fcebb00 100644 --- a/internal/parser/writer.go +++ b/internal/parser/writer.go @@ -11,6 +11,18 @@ import ( // Save writes the org file back to disk func Save(orgFile *model.OrgFile) error { + // Check if this is a multi-file org (directory-based) + // In multi-file mode, top-level items have SourceFile set and represent files + isMultiFile := false + if len(orgFile.Items) > 0 && orgFile.Items[0].SourceFile != "" { + isMultiFile = true + } + + if isMultiFile { + return saveMultiFile(orgFile) + } + + // Single file mode file, err := os.Create(orgFile.Path) if err != nil { return err @@ -29,6 +41,66 @@ func Save(orgFile *model.OrgFile) error { return nil } +// saveMultiFile saves items back to their individual source files +func saveMultiFile(orgFile *model.OrgFile) error { + // Group items by source file + fileItems := make(map[string][]*model.Item) + + for _, fileItem := range orgFile.Items { + if fileItem.SourceFile == "" { + continue + } + + // The children of this file item are the actual items to save + fileItems[fileItem.SourceFile] = fileItem.Children + } + + // Save each file + for filePath, items := range fileItems { + if err := saveItemsToFile(filePath, items); err != nil { + return err + } + } + + return nil +} + +// saveItemsToFile writes a list of items to a specific file +func saveItemsToFile(filePath string, items []*model.Item) error { + file, err := os.Create(filePath) + if err != nil { + return err + } + defer file.Close() + + writer := bufio.NewWriter(file) + defer writer.Flush() + + for _, item := range items { + // Decrement level since we're saving to individual files + decrementedItem := decrementItemLevelForSave(item) + if err := writeItem(writer, decrementedItem); err != nil { + return err + } + } + + return nil +} + +// decrementItemLevelForSave creates a copy of an item with decremented levels for saving +func decrementItemLevelForSave(item *model.Item) *model.Item { + copied := *item + copied.Level-- + + copiedChildren := make([]*model.Item, len(item.Children)) + for i, child := range item.Children { + copiedChildren[i] = decrementItemLevelForSave(child) + } + copied.Children = copiedChildren + + return &copied +} + // writeItem recursively writes an item and its children func writeItem(writer *bufio.Writer, item *model.Item) error { // Write heading diff --git a/internal/ui/app.go b/internal/ui/app.go index 115227a..764b299 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -51,6 +51,7 @@ type uiModel struct { settingsCursor int // Cursor position in settings view settingsScroll int // Scroll position in settings view settingsSection settingsSection // Current settings section/tab + captureCursor int // Store cursor position when entering capture mode } func InitialModel(orgFile *model.OrgFile, cfg *config.Config) uiModel { diff --git a/internal/ui/modes.go b/internal/ui/modes.go index 1446ce3..0282bcd 100644 --- a/internal/ui/modes.go +++ b/internal/ui/modes.go @@ -148,7 +148,18 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.EditNotes): items := m.getVisibleItems() if len(items) > 0 && m.cursor < len(items) { - m.editingItem = items[m.cursor] + selectedItem := items[m.cursor] + + // Check if we're in multi-file mode + isMultiFile := len(m.orgFile.Items) > 0 && m.orgFile.Items[0].SourceFile != "" + + // Prevent editing notes for top-level file items in multi-file mode + if isMultiFile && selectedItem.Level == 1 && selectedItem.SourceFile != "" { + m.setStatus("Cannot add notes to file-level items") + return m, nil + } + + m.editingItem = selectedItem m.mode = modeEdit m.textarea.SetValue(strings.Join(m.editingItem.Notes, "\n")) m.textarea.Focus() @@ -173,6 +184,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Capture): m.mode = modeCapture + m.captureCursor = m.cursor // Store current cursor position m.textinput.SetValue("") m.textinput.Placeholder = "What needs doing?" m.textinput.Focus() @@ -356,13 +368,36 @@ func (m uiModel) updateCapture(msg tea.Msg) (tea.Model, tea.Cmd) { Notes: []string{}, Children: []*model.Item{}, } - // Insert at beginning - m.orgFile.Items = append([]*model.Item{newItem}, m.orgFile.Items...) - m.setStatus("TODO captured!") + + // Check if we're in multi-file mode + isMultiFile := len(m.orgFile.Items) > 0 && m.orgFile.Items[0].SourceFile != "" + + if isMultiFile { + // In multi-file mode, add to the file of the highlighted item (using stored cursor position) + items := m.getVisibleItems() + targetFileItem := m.findTopLevelFileItem(items, m.captureCursor) + + if targetFileItem != nil { + // Set the source file for the new item + newItem.SourceFile = targetFileItem.SourceFile + newItem.Level = 2 // Children of file items are level 2 + + // Insert at the beginning of the file item's children + targetFileItem.Children = append([]*model.Item{newItem}, targetFileItem.Children...) + targetFileItem.Folded = false // Unfold to show the new item + m.setStatus("TODO captured to " + targetFileItem.Title) + } else { + m.setStatus("Error: Could not find file to add to") + } + } else { + // Single file mode: insert at beginning + m.orgFile.Items = append([]*model.Item{newItem}, m.orgFile.Items...) + m.setStatus("TODO captured!") + } } m.mode = modeList m.textinput.Blur() - m.cursor = 0 + // Don't reset cursor, keep it where it was return m, nil case tea.KeyEsc: m.mode = modeList @@ -376,6 +411,49 @@ func (m uiModel) updateCapture(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } +// findTopLevelFileItem finds the top-level file item that contains the item at the given cursor position +func (m *uiModel) findTopLevelFileItem(items []*model.Item, cursorPos int) *model.Item { + if cursorPos < 0 || cursorPos >= len(items) { + // Fallback to first file if cursor out of bounds + if len(m.orgFile.Items) > 0 { + return m.orgFile.Items[0] + } + return nil + } + + selectedItem := items[cursorPos] + + // Check if we're in multi-file mode + isMultiFile := len(m.orgFile.Items) > 0 && m.orgFile.Items[0].SourceFile != "" + + if !isMultiFile { + // Not in multi-file mode, return nil + return nil + } + + // If the selected item itself is a file item (level 1 with SourceFile), return it + if selectedItem.SourceFile != "" && selectedItem.Level == 1 { + return selectedItem + } + + // Otherwise, find which top-level file item this item belongs to + // by checking the SourceFile field + if selectedItem.SourceFile != "" { + for _, fileItem := range m.orgFile.Items { + if fileItem.SourceFile == selectedItem.SourceFile { + return fileItem + } + } + } + + // Fallback: return the first file item + if len(m.orgFile.Items) > 0 { + return m.orgFile.Items[0] + } + + return nil +} + func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd @@ -395,11 +473,12 @@ func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) { // Create new sub-task newItem := &model.Item{ - Level: m.editingItem.Level + 1, - State: defaultState, - Title: title, - Notes: []string{}, - Children: []*model.Item{}, + Level: m.editingItem.Level + 1, + State: defaultState, + Title: title, + Notes: []string{}, + Children: []*model.Item{}, + SourceFile: m.editingItem.SourceFile, // Inherit source file from parent } m.editingItem.Children = append(m.editingItem.Children, newItem) m.editingItem.Folded = false // Unfold to show new sub-task