feat: adding multi-file mode to allow opening all org files in a directory (#10)

This commit is contained in:
Rasmus Wejlgaard 2025-11-10 18:50:01 +00:00 committed by GitHub
parent 5eb672e0c9
commit 2e9980e73c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 324 additions and 31 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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