mirror of
https://github.com/RWejlgaard/org.git
synced 2026-05-06 04:34:45 +00:00
feat: adding multi-file mode to allow opening all org files in a directory
This commit is contained in:
parent
5a6fede2d8
commit
80f5398d58
7 changed files with 324 additions and 31 deletions
43
README.md
43
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
|
||||
```
|
||||
|
||||
## Features
|
||||
|
|
|
|||
|
|
@ -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,12 +31,54 @@ func main() {
|
|||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
if err := ui.RunUI(orgFile, cfg); err != nil {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
// 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
|
||||
|
||||
|
|
@ -400,6 +478,7 @@ func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue