mirror of
https://github.com/RWejlgaard/org.git
synced 2026-05-06 04:34:45 +00:00
make it more formal
This commit is contained in:
parent
6b88066b20
commit
c16bee05df
19 changed files with 1890 additions and 1807 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
bin/
|
||||
|
|
@ -5,6 +5,9 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/rwejlgaard/org/internal/parser"
|
||||
"github.com/rwejlgaard/org/internal/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -29,20 +32,20 @@ func main() {
|
|||
}
|
||||
|
||||
// Parse the org file
|
||||
orgFile, err := ParseOrgFile(filePath)
|
||||
orgFile, err := parser.ParseOrgFile(filePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing org file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Run the UI
|
||||
if err := runUI(orgFile); err != nil {
|
||||
if err := ui.RunUI(orgFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error running UI: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save on exit
|
||||
if err := orgFile.Save(); err != nil {
|
||||
if err := parser.Save(orgFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
11
go.mod
11
go.mod
|
|
@ -3,13 +3,16 @@ module github.com/rwejlgaard/org
|
|||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.20.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
|
|
|
|||
14
go.sum
14
go.sum
|
|
@ -1,9 +1,17 @@
|
|||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
|
|
@ -16,12 +24,16 @@ github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7
|
|||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
|
@ -41,6 +53,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
|
|
|
|||
9
internal/model/clock.go
Normal file
9
internal/model/clock.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ClockEntry represents a single clock entry
|
||||
type ClockEntry struct {
|
||||
Start time.Time
|
||||
End *time.Time // nil if currently clocked in
|
||||
}
|
||||
123
internal/model/item.go
Normal file
123
internal/model/item.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Item represents a single org-mode item (heading)
|
||||
type Item struct {
|
||||
Level int // Heading level (number of *)
|
||||
State TodoState // TODO, PROG, BLOCK, DONE, or empty
|
||||
Title string // The main title text
|
||||
Scheduled *time.Time
|
||||
Deadline *time.Time
|
||||
Notes []string // Notes/content under the heading
|
||||
Children []*Item // Sub-items
|
||||
Folded bool // Whether the item is folded (hides notes and children)
|
||||
ClockEntries []ClockEntry // Clock in/out entries
|
||||
}
|
||||
|
||||
// OrgFile represents a parsed org-mode file
|
||||
type OrgFile struct {
|
||||
Path string
|
||||
Items []*Item
|
||||
}
|
||||
|
||||
// ToggleFold toggles the folded state of an item
|
||||
func (item *Item) ToggleFold() {
|
||||
item.Folded = !item.Folded
|
||||
}
|
||||
|
||||
// CycleState cycles through todo states
|
||||
func (item *Item) CycleState() {
|
||||
switch item.State {
|
||||
case StateNone:
|
||||
item.State = StateTODO
|
||||
case StateTODO:
|
||||
item.State = StatePROG
|
||||
case StatePROG:
|
||||
item.State = StateBLOCK
|
||||
case StateBLOCK:
|
||||
item.State = StateDONE
|
||||
case StateDONE:
|
||||
item.State = StateNone
|
||||
}
|
||||
}
|
||||
|
||||
// ClockIn starts a new clock entry
|
||||
func (item *Item) ClockIn() bool {
|
||||
// Check if already clocked in
|
||||
if item.IsClockedIn() {
|
||||
return false
|
||||
}
|
||||
|
||||
entry := ClockEntry{
|
||||
Start: time.Now(),
|
||||
End: nil,
|
||||
}
|
||||
item.ClockEntries = append(item.ClockEntries, entry)
|
||||
return true
|
||||
}
|
||||
|
||||
// ClockOut ends the current clock entry
|
||||
func (item *Item) ClockOut() bool {
|
||||
// Find the most recent open clock entry
|
||||
for i := len(item.ClockEntries) - 1; i >= 0; i-- {
|
||||
if item.ClockEntries[i].End == nil {
|
||||
now := time.Now()
|
||||
item.ClockEntries[i].End = &now
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsClockedIn returns true if there's an active clock entry
|
||||
func (item *Item) IsClockedIn() bool {
|
||||
for _, entry := range item.ClockEntries {
|
||||
if entry.End == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetCurrentClockDuration returns the duration of the current clock entry
|
||||
func (item *Item) GetCurrentClockDuration() time.Duration {
|
||||
for _, entry := range item.ClockEntries {
|
||||
if entry.End == nil {
|
||||
return time.Since(entry.Start)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetTotalClockDuration returns the total duration of all clock entries
|
||||
func (item *Item) GetTotalClockDuration() time.Duration {
|
||||
var total time.Duration
|
||||
for _, entry := range item.ClockEntries {
|
||||
if entry.End != nil {
|
||||
// Completed clock entry
|
||||
total += entry.End.Sub(entry.Start)
|
||||
} else {
|
||||
// Currently clocked in
|
||||
total += time.Since(entry.Start)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// GetAllItems returns a flattened list of all items (for UI display)
|
||||
// Respects folding - folded items don't show their children
|
||||
func (of *OrgFile) GetAllItems() []*Item {
|
||||
var items []*Item
|
||||
var flatten func([]*Item)
|
||||
flatten = func(list []*Item) {
|
||||
for _, item := range list {
|
||||
items = append(items, item)
|
||||
if !item.Folded {
|
||||
flatten(item.Children)
|
||||
}
|
||||
}
|
||||
}
|
||||
flatten(of.Items)
|
||||
return items
|
||||
}
|
||||
12
internal/model/state.go
Normal file
12
internal/model/state.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package model
|
||||
|
||||
// TodoState represents the state of a todo item
|
||||
type TodoState string
|
||||
|
||||
const (
|
||||
StateTODO TodoState = "TODO"
|
||||
StatePROG TodoState = "PROG"
|
||||
StateBLOCK TodoState = "BLOCK"
|
||||
StateDONE TodoState = "DONE"
|
||||
StateNone TodoState = ""
|
||||
)
|
||||
51
internal/parser/datetime.go
Normal file
51
internal/parser/datetime.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package parser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parseOrgDate parses org-mode date format
|
||||
func parseOrgDate(dateStr string) (time.Time, error) {
|
||||
// Org-mode format: 2024-01-15 Mon 10:00
|
||||
formats := []string{
|
||||
"2006-01-02 Mon 15:04",
|
||||
"2006-01-02 Mon",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, dateStr); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unable to parse date: %s", dateStr)
|
||||
}
|
||||
|
||||
// parseClockTimestamp parses org-mode clock timestamp format
|
||||
func parseClockTimestamp(timestampStr string) (time.Time, error) {
|
||||
// Org-mode clock format: [2024-01-15 Mon 10:00]
|
||||
formats := []string{
|
||||
"2006-01-02 Mon 15:04",
|
||||
"2006-01-02 Mon 15:04:05",
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, timestampStr); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unable to parse clock timestamp: %s", timestampStr)
|
||||
}
|
||||
|
||||
// formatClockTimestamp formats a time as org-mode clock timestamp
|
||||
func formatClockTimestamp(t time.Time) string {
|
||||
return t.Format("2006-01-02 Mon 15:04")
|
||||
}
|
||||
|
||||
// FormatOrgDate formats a time as org-mode date
|
||||
func FormatOrgDate(t time.Time) string {
|
||||
return t.Format("2006-01-02 Mon")
|
||||
}
|
||||
160
internal/parser/parser.go
Normal file
160
internal/parser/parser.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/rwejlgaard/org/internal/model"
|
||||
)
|
||||
|
||||
// Parser patterns
|
||||
var (
|
||||
headingPattern = regexp.MustCompile(`^(\*+)\s+(?:(TODO|PROG|BLOCK|DONE)\s+)?(.+)$`)
|
||||
scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`)
|
||||
deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`)
|
||||
clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`)
|
||||
drawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`)
|
||||
drawerEnd = regexp.MustCompile(`^\s*:END:\s*$`)
|
||||
codeBlockStart = regexp.MustCompile(`^\s*#\+BEGIN_SRC`)
|
||||
codeBlockEnd = regexp.MustCompile(`^\s*#\+END_SRC`)
|
||||
)
|
||||
|
||||
// ParseOrgFile reads and parses an org-mode file
|
||||
func ParseOrgFile(path string) (*model.OrgFile, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
// If file doesn't exist, return empty org file
|
||||
if os.IsNotExist(err) {
|
||||
return &model.OrgFile{Path: path, Items: []*model.Item{}}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
orgFile := &model.OrgFile{Path: path, Items: []*model.Item{}}
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
var currentItem *model.Item
|
||||
var itemStack []*model.Item // Stack to track parent items
|
||||
var inCodeBlock bool
|
||||
var inLogbookDrawer bool
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Check for drawer boundaries
|
||||
if drawerStart.MatchString(line) {
|
||||
inLogbookDrawer = true
|
||||
if currentItem != nil {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if drawerEnd.MatchString(line) && inLogbookDrawer {
|
||||
inLogbookDrawer = false
|
||||
if currentItem != nil {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for code block boundaries
|
||||
if codeBlockStart.MatchString(line) {
|
||||
inCodeBlock = true
|
||||
if currentItem != nil {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if codeBlockEnd.MatchString(line) {
|
||||
inCodeBlock = false
|
||||
if currentItem != nil {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If in code block, add line to notes
|
||||
if inCodeBlock {
|
||||
if currentItem != nil {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to match heading
|
||||
if matches := headingPattern.FindStringSubmatch(line); matches != nil {
|
||||
level := len(matches[1])
|
||||
state := model.TodoState(matches[2])
|
||||
title := matches[3]
|
||||
|
||||
item := &model.Item{
|
||||
Level: level,
|
||||
State: state,
|
||||
Title: title,
|
||||
Notes: []string{},
|
||||
Children: []*model.Item{},
|
||||
}
|
||||
|
||||
// Find parent based on level
|
||||
for len(itemStack) > 0 && itemStack[len(itemStack)-1].Level >= level {
|
||||
itemStack = itemStack[:len(itemStack)-1]
|
||||
}
|
||||
|
||||
if len(itemStack) == 0 {
|
||||
// Top-level item
|
||||
orgFile.Items = append(orgFile.Items, item)
|
||||
} else {
|
||||
// Child item
|
||||
parent := itemStack[len(itemStack)-1]
|
||||
parent.Children = append(parent.Children, item)
|
||||
}
|
||||
|
||||
itemStack = append(itemStack, item)
|
||||
currentItem = item
|
||||
} else if currentItem != nil {
|
||||
// This is content under the current item
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check for SCHEDULED
|
||||
if matches := scheduledPattern.FindStringSubmatch(line); matches != nil {
|
||||
if t, err := parseOrgDate(matches[1]); err == nil {
|
||||
currentItem.Scheduled = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Check for DEADLINE
|
||||
if matches := deadlinePattern.FindStringSubmatch(line); matches != nil {
|
||||
if t, err := parseOrgDate(matches[1]); err == nil {
|
||||
currentItem.Deadline = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CLOCK (can be inside or outside drawer)
|
||||
if matches := clockPattern.FindStringSubmatch(line); matches != nil {
|
||||
if startTime, err := parseClockTimestamp(matches[1]); err == nil {
|
||||
entry := model.ClockEntry{Start: startTime}
|
||||
if len(matches) > 2 && matches[2] != "" {
|
||||
if endTime, err := parseClockTimestamp(matches[2]); err == nil {
|
||||
entry.End = &endTime
|
||||
}
|
||||
}
|
||||
currentItem.ClockEntries = append(currentItem.ClockEntries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Add all lines as notes (including scheduling lines and drawer content for proper serialization)
|
||||
if trimmed != "" || len(currentItem.Notes) > 0 {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return orgFile, nil
|
||||
}
|
||||
111
internal/parser/writer.go
Normal file
111
internal/parser/writer.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/rwejlgaard/org/internal/model"
|
||||
)
|
||||
|
||||
// Save writes the org file back to disk
|
||||
func Save(orgFile *model.OrgFile) error {
|
||||
file, err := os.Create(orgFile.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
|
||||
for _, item := range orgFile.Items {
|
||||
if err := writeItem(writer, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeItem recursively writes an item and its children
|
||||
func writeItem(writer *bufio.Writer, item *model.Item) error {
|
||||
// Write heading
|
||||
stars := strings.Repeat("*", item.Level)
|
||||
line := stars
|
||||
if item.State != model.StateNone {
|
||||
line += " " + string(item.State)
|
||||
}
|
||||
line += " " + item.Title + "\n"
|
||||
|
||||
if _, err := writer.WriteString(line); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write scheduling info if not already in notes
|
||||
hasScheduled := false
|
||||
hasDeadline := false
|
||||
hasLogbook := false
|
||||
for _, note := range item.Notes {
|
||||
if strings.Contains(note, "SCHEDULED:") {
|
||||
hasScheduled = true
|
||||
}
|
||||
if strings.Contains(note, "DEADLINE:") {
|
||||
hasDeadline = true
|
||||
}
|
||||
if strings.Contains(note, ":LOGBOOK:") {
|
||||
hasLogbook = true
|
||||
}
|
||||
}
|
||||
|
||||
if item.Scheduled != nil && !hasScheduled {
|
||||
scheduledLine := fmt.Sprintf("SCHEDULED: <%s>\n", FormatOrgDate(*item.Scheduled))
|
||||
if _, err := writer.WriteString(scheduledLine); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if item.Deadline != nil && !hasDeadline {
|
||||
deadlineLine := fmt.Sprintf("DEADLINE: <%s>\n", FormatOrgDate(*item.Deadline))
|
||||
if _, err := writer.WriteString(deadlineLine); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Write clock entries in :LOGBOOK: drawer if not already in notes
|
||||
if len(item.ClockEntries) > 0 && !hasLogbook {
|
||||
if _, err := writer.WriteString(":LOGBOOK:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range item.ClockEntries {
|
||||
clockLine := fmt.Sprintf("CLOCK: [%s]", formatClockTimestamp(entry.Start))
|
||||
if entry.End != nil {
|
||||
clockLine += fmt.Sprintf("--[%s]", formatClockTimestamp(*entry.End))
|
||||
}
|
||||
clockLine += "\n"
|
||||
if _, err := writer.WriteString(clockLine); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := writer.WriteString(":END:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Write notes
|
||||
for _, note := range item.Notes {
|
||||
if _, err := writer.WriteString(note + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Write children
|
||||
for _, child := range item.Children {
|
||||
if err := writeItem(writer, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
32
internal/ui/agenda.go
Normal file
32
internal/ui/agenda.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rwejlgaard/org/internal/model"
|
||||
)
|
||||
|
||||
// getAgendaItems returns items with scheduling or deadlines within the next 7 days
|
||||
func (m uiModel) getAgendaItems() []*model.Item {
|
||||
var items []*model.Item
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endOfWeek := startOfDay.AddDate(0, 0, 7)
|
||||
|
||||
// Get all items regardless of folding for agenda view
|
||||
var getAllItems func([]*model.Item)
|
||||
getAllItems = func(list []*model.Item) {
|
||||
for _, item := range list {
|
||||
if item.Scheduled != nil && item.Scheduled.Before(endOfWeek) {
|
||||
items = append(items, item)
|
||||
}
|
||||
if item.Deadline != nil && item.Deadline.Before(endOfWeek) {
|
||||
items = append(items, item)
|
||||
}
|
||||
getAllItems(item.Children)
|
||||
}
|
||||
}
|
||||
getAllItems(m.orgFile.Items)
|
||||
|
||||
return items
|
||||
}
|
||||
86
internal/ui/app.go
Normal file
86
internal/ui/app.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/rwejlgaard/org/internal/model"
|
||||
)
|
||||
|
||||
type viewMode int
|
||||
|
||||
const (
|
||||
modeList viewMode = iota
|
||||
modeAgenda
|
||||
modeEdit
|
||||
modeConfirmDelete
|
||||
modeCapture
|
||||
modeAddSubTask
|
||||
modeSetDeadline
|
||||
)
|
||||
|
||||
type uiModel struct {
|
||||
orgFile *model.OrgFile
|
||||
cursor int
|
||||
mode viewMode
|
||||
help help.Model
|
||||
keys keyMap
|
||||
width int
|
||||
height int
|
||||
statusMsg string
|
||||
statusExpiry time.Time
|
||||
editingItem *model.Item
|
||||
textarea textarea.Model
|
||||
textinput textinput.Model
|
||||
itemToDelete *model.Item
|
||||
reorderMode bool
|
||||
}
|
||||
|
||||
func initialModel(orgFile *model.OrgFile) uiModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = "Enter notes here (code blocks supported)..."
|
||||
ta.ShowLineNumbers = false
|
||||
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "What needs doing?"
|
||||
ti.CharLimit = 200
|
||||
|
||||
h := help.New()
|
||||
h.ShowAll = false
|
||||
|
||||
return uiModel{
|
||||
orgFile: orgFile,
|
||||
cursor: 0,
|
||||
mode: modeList,
|
||||
help: h,
|
||||
keys: keys,
|
||||
textarea: ta,
|
||||
textinput: ti,
|
||||
}
|
||||
}
|
||||
|
||||
func (m uiModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *uiModel) setStatus(msg string) {
|
||||
m.statusMsg = msg
|
||||
m.statusExpiry = time.Now().Add(3 * time.Second)
|
||||
}
|
||||
|
||||
func (m uiModel) getVisibleItems() []*model.Item {
|
||||
if m.mode == modeAgenda {
|
||||
return m.getAgendaItems()
|
||||
}
|
||||
return m.orgFile.GetAllItems()
|
||||
}
|
||||
|
||||
// RunUI starts the terminal UI
|
||||
func RunUI(orgFile *model.OrgFile) error {
|
||||
p := tea.NewProgram(initialModel(orgFile), tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
134
internal/ui/keybindings.go
Normal file
134
internal/ui/keybindings.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package ui
|
||||
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
type keyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
ShiftUp key.Binding
|
||||
ShiftDown key.Binding
|
||||
CycleState key.Binding
|
||||
ToggleView key.Binding
|
||||
Quit key.Binding
|
||||
Help key.Binding
|
||||
Capture key.Binding
|
||||
AddSubTask key.Binding
|
||||
Delete key.Binding
|
||||
Save key.Binding
|
||||
ToggleFold key.Binding
|
||||
EditNotes key.Binding
|
||||
ToggleReorder key.Binding
|
||||
ClockIn key.Binding
|
||||
ClockOut key.Binding
|
||||
SetDeadline key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "k"),
|
||||
key.WithHelp("↑/k", "move up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "j"),
|
||||
key.WithHelp("↓/j", "move down"),
|
||||
),
|
||||
Left: key.NewBinding(
|
||||
key.WithKeys("left", "h"),
|
||||
key.WithHelp("←/h", "cycle state backward"),
|
||||
),
|
||||
Right: key.NewBinding(
|
||||
key.WithKeys("right", "l"),
|
||||
key.WithHelp("→/l", "cycle state forward"),
|
||||
),
|
||||
ShiftUp: key.NewBinding(
|
||||
key.WithKeys("shift+up"),
|
||||
key.WithHelp("shift+↑", "move item up"),
|
||||
),
|
||||
ShiftDown: key.NewBinding(
|
||||
key.WithKeys("shift+down"),
|
||||
key.WithHelp("shift+↓", "move item down"),
|
||||
),
|
||||
CycleState: key.NewBinding(
|
||||
key.WithKeys("t", " "),
|
||||
key.WithHelp("t/space", "cycle todo state"),
|
||||
),
|
||||
ToggleFold: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "fold/unfold"),
|
||||
),
|
||||
EditNotes: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "edit notes"),
|
||||
),
|
||||
ToggleView: key.NewBinding(
|
||||
key.WithKeys("a"),
|
||||
key.WithHelp("a", "toggle agenda view"),
|
||||
),
|
||||
Capture: key.NewBinding(
|
||||
key.WithKeys("c"),
|
||||
key.WithHelp("c", "capture TODO"),
|
||||
),
|
||||
AddSubTask: key.NewBinding(
|
||||
key.WithKeys("s"),
|
||||
key.WithHelp("s", "add sub-task"),
|
||||
),
|
||||
Delete: key.NewBinding(
|
||||
key.WithKeys("shift+d"),
|
||||
key.WithHelp("shift+d", "delete item"),
|
||||
),
|
||||
Save: key.NewBinding(
|
||||
key.WithKeys("ctrl+s"),
|
||||
key.WithHelp("ctrl+s", "save"),
|
||||
),
|
||||
ToggleReorder: key.NewBinding(
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("r", "reorder mode"),
|
||||
),
|
||||
ClockIn: key.NewBinding(
|
||||
key.WithKeys("i"),
|
||||
key.WithHelp("i", "clock in"),
|
||||
),
|
||||
ClockOut: key.NewBinding(
|
||||
key.WithKeys("o"),
|
||||
key.WithHelp("o", "clock out"),
|
||||
),
|
||||
SetDeadline: key.NewBinding(
|
||||
key.WithKeys("d"),
|
||||
key.WithHelp("d", "set deadline"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "toggle help"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q", "quit"),
|
||||
),
|
||||
}
|
||||
|
||||
func (k keyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Help}
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
// This will be overridden by custom rendering in viewFullHelp
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.Left, k.Right},
|
||||
{k.ToggleFold, k.EditNotes, k.ToggleReorder},
|
||||
{k.Capture, k.AddSubTask, k.Delete, k.Save},
|
||||
{k.ToggleView, k.Help, k.Quit},
|
||||
}
|
||||
}
|
||||
|
||||
// getAllBindings returns all keybindings as a flat list
|
||||
func (k keyMap) getAllBindings() []key.Binding {
|
||||
return []key.Binding{
|
||||
k.Up, k.Down, k.Left, k.Right,
|
||||
k.ToggleFold, k.EditNotes, k.ToggleReorder,
|
||||
k.Capture, k.AddSubTask, k.Delete, k.Save,
|
||||
k.ClockIn, k.ClockOut, k.SetDeadline,
|
||||
k.ToggleView, k.Help, k.Quit,
|
||||
}
|
||||
}
|
||||
550
internal/ui/modes.go
Normal file
550
internal/ui/modes.go
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/rwejlgaard/org/internal/model"
|
||||
"github.com/rwejlgaard/org/internal/parser"
|
||||
)
|
||||
|
||||
func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Handle special modes
|
||||
switch m.mode {
|
||||
case modeEdit:
|
||||
return m.updateEditMode(msg)
|
||||
case modeConfirmDelete:
|
||||
return m.updateConfirmDelete(msg)
|
||||
case modeCapture:
|
||||
return m.updateCapture(msg)
|
||||
case modeAddSubTask:
|
||||
return m.updateAddSubTask(msg)
|
||||
case modeSetDeadline:
|
||||
return m.updateSetDeadline(msg)
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.help.Width = msg.Width
|
||||
m.textarea.SetWidth(msg.Width - 4)
|
||||
m.textarea.SetHeight(msg.Height - 10)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
return m, tea.Quit
|
||||
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.reorderMode {
|
||||
m.moveItemUp()
|
||||
} else {
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.reorderMode {
|
||||
m.moveItemDown()
|
||||
} else {
|
||||
items := m.getVisibleItems()
|
||||
if m.cursor < len(items)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Left):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
m.cycleStateBackward(items[m.cursor])
|
||||
// Auto clock out when changing to DONE
|
||||
if items[m.cursor].State == model.StateDONE && items[m.cursor].IsClockedIn() {
|
||||
items[m.cursor].ClockOut()
|
||||
}
|
||||
m.setStatus("State changed")
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Right):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
items[m.cursor].CycleState()
|
||||
// Auto clock out when changing to DONE
|
||||
if items[m.cursor].State == model.StateDONE && items[m.cursor].IsClockedIn() {
|
||||
items[m.cursor].ClockOut()
|
||||
}
|
||||
m.setStatus("State changed")
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.ShiftUp):
|
||||
m.moveItemUp()
|
||||
|
||||
case key.Matches(msg, m.keys.ShiftDown):
|
||||
m.moveItemDown()
|
||||
|
||||
case key.Matches(msg, m.keys.CycleState):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
items[m.cursor].CycleState()
|
||||
// Auto clock out when changing to DONE
|
||||
if items[m.cursor].State == model.StateDONE && items[m.cursor].IsClockedIn() {
|
||||
items[m.cursor].ClockOut()
|
||||
}
|
||||
m.setStatus("State changed")
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.ToggleFold):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
items[m.cursor].ToggleFold()
|
||||
if items[m.cursor].Folded {
|
||||
m.setStatus("Folded")
|
||||
} else {
|
||||
m.setStatus("Unfolded")
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.EditNotes):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
m.editingItem = items[m.cursor]
|
||||
m.mode = modeEdit
|
||||
m.textarea.SetValue(strings.Join(m.editingItem.Notes, "\n"))
|
||||
m.textarea.Focus()
|
||||
return m, textarea.Blink
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Capture):
|
||||
m.mode = modeCapture
|
||||
m.textinput.SetValue("")
|
||||
m.textinput.Placeholder = "What needs doing?"
|
||||
m.textinput.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.AddSubTask):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
m.editingItem = items[m.cursor]
|
||||
m.mode = modeAddSubTask
|
||||
m.textinput.SetValue("")
|
||||
m.textinput.Placeholder = "Sub-task title"
|
||||
m.textinput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.Delete):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
m.itemToDelete = items[m.cursor]
|
||||
m.mode = modeConfirmDelete
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.ToggleView):
|
||||
if m.mode == modeList {
|
||||
m.mode = modeAgenda
|
||||
} else {
|
||||
m.mode = modeList
|
||||
}
|
||||
m.cursor = 0
|
||||
|
||||
case key.Matches(msg, m.keys.Save):
|
||||
if err := parser.Save(m.orgFile); err != nil {
|
||||
m.setStatus(fmt.Sprintf("Error saving: %v", err))
|
||||
} else {
|
||||
m.setStatus("Saved!")
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.ToggleReorder):
|
||||
m.reorderMode = !m.reorderMode
|
||||
if m.reorderMode {
|
||||
m.setStatus("Reorder mode ON - Use ↑/↓ to move items, 'r' to exit")
|
||||
} else {
|
||||
m.setStatus("Reorder mode OFF")
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.ClockIn):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
if items[m.cursor].ClockIn() {
|
||||
m.setStatus("Clocked in!")
|
||||
} else {
|
||||
m.setStatus("Already clocked in")
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.ClockOut):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
if items[m.cursor].ClockOut() {
|
||||
m.setStatus("Clocked out!")
|
||||
} else {
|
||||
m.setStatus("Not clocked in")
|
||||
}
|
||||
}
|
||||
|
||||
case key.Matches(msg, m.keys.SetDeadline):
|
||||
items := m.getVisibleItems()
|
||||
if len(items) > 0 && m.cursor < len(items) {
|
||||
m.editingItem = items[m.cursor]
|
||||
m.mode = modeSetDeadline
|
||||
m.textinput.SetValue("")
|
||||
m.textinput.Placeholder = "YYYY-MM-DD or +N (days from today)"
|
||||
m.textinput.Focus()
|
||||
return m, textinput.Blink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m uiModel) updateEditMode(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.textarea.SetWidth(msg.Width - 4)
|
||||
m.textarea.SetHeight(msg.Height - 10)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
// Save notes and exit edit mode
|
||||
if m.editingItem != nil {
|
||||
noteText := m.textarea.Value()
|
||||
if noteText == "" {
|
||||
m.editingItem.Notes = []string{}
|
||||
} else {
|
||||
m.editingItem.Notes = strings.Split(noteText, "\n")
|
||||
}
|
||||
}
|
||||
m.mode = modeList
|
||||
m.textarea.Blur()
|
||||
m.setStatus("Notes saved")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m uiModel) updateConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "y", "Y":
|
||||
// Delete the item
|
||||
m.deleteItem(m.itemToDelete)
|
||||
m.mode = modeList
|
||||
m.itemToDelete = nil
|
||||
m.setStatus("Item deleted")
|
||||
// Adjust cursor if needed
|
||||
items := m.getVisibleItems()
|
||||
if m.cursor >= len(items) && len(items) > 0 {
|
||||
m.cursor = len(items) - 1
|
||||
}
|
||||
case "n", "N", "esc":
|
||||
m.mode = modeList
|
||||
m.itemToDelete = nil
|
||||
m.setStatus("Cancelled")
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m uiModel) updateCapture(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEnter:
|
||||
title := strings.TrimSpace(m.textinput.Value())
|
||||
if title != "" {
|
||||
// Create new TODO at top level
|
||||
newItem := &model.Item{
|
||||
Level: 1,
|
||||
State: model.StateTODO,
|
||||
Title: title,
|
||||
Notes: []string{},
|
||||
Children: []*model.Item{},
|
||||
}
|
||||
// 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
|
||||
return m, nil
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.setStatus("Cancelled")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
m.textinput, cmd = m.textinput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m uiModel) updateAddSubTask(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEnter:
|
||||
title := strings.TrimSpace(m.textinput.Value())
|
||||
if title != "" && m.editingItem != nil {
|
||||
// Create new sub-task
|
||||
newItem := &model.Item{
|
||||
Level: m.editingItem.Level + 1,
|
||||
State: model.StateTODO,
|
||||
Title: title,
|
||||
Notes: []string{},
|
||||
Children: []*model.Item{},
|
||||
}
|
||||
m.editingItem.Children = append(m.editingItem.Children, newItem)
|
||||
m.editingItem.Folded = false // Unfold to show new sub-task
|
||||
m.setStatus("Sub-task added!")
|
||||
}
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.editingItem = nil
|
||||
return m, nil
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.editingItem = nil
|
||||
m.setStatus("Cancelled")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
m.textinput, cmd = m.textinput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m uiModel) updateSetDeadline(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEnter:
|
||||
input := strings.TrimSpace(m.textinput.Value())
|
||||
if m.editingItem != nil {
|
||||
if input == "" {
|
||||
// Empty input clears the deadline
|
||||
m.editingItem.Deadline = nil
|
||||
// Remove DEADLINE line from notes (only lines starting with DEADLINE:)
|
||||
var filteredNotes []string
|
||||
for _, note := range m.editingItem.Notes {
|
||||
trimmedNote := strings.TrimSpace(note)
|
||||
if !strings.HasPrefix(trimmedNote, "DEADLINE:") {
|
||||
filteredNotes = append(filteredNotes, note)
|
||||
}
|
||||
}
|
||||
m.editingItem.Notes = filteredNotes
|
||||
m.setStatus("Deadline cleared!")
|
||||
} else {
|
||||
deadline, err := parseDeadlineInput(input)
|
||||
if err != nil {
|
||||
m.setStatus(fmt.Sprintf("Invalid date: %v", err))
|
||||
} else {
|
||||
m.editingItem.Deadline = &deadline
|
||||
// Also update or add DEADLINE line in notes
|
||||
updatedNotes := false
|
||||
for i, note := range m.editingItem.Notes {
|
||||
trimmedNote := strings.TrimSpace(note)
|
||||
if strings.HasPrefix(trimmedNote, "DEADLINE:") {
|
||||
m.editingItem.Notes[i] = fmt.Sprintf("DEADLINE: <%s>", parser.FormatOrgDate(deadline))
|
||||
updatedNotes = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// If DEADLINE wasn't in notes, it will be added by writeItem
|
||||
if !updatedNotes {
|
||||
// Remove old deadline lines just to be safe
|
||||
var filteredNotes []string
|
||||
for _, note := range m.editingItem.Notes {
|
||||
trimmedNote := strings.TrimSpace(note)
|
||||
if !strings.HasPrefix(trimmedNote, "DEADLINE:") {
|
||||
filteredNotes = append(filteredNotes, note)
|
||||
}
|
||||
}
|
||||
m.editingItem.Notes = filteredNotes
|
||||
}
|
||||
m.setStatus("Deadline set!")
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.editingItem = nil
|
||||
return m, nil
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeList
|
||||
m.textinput.Blur()
|
||||
m.editingItem = nil
|
||||
m.setStatus("Cancelled")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
m.textinput, cmd = m.textinput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// parseDeadlineInput parses deadline input like "2024-01-15" or "+3" (3 days from now)
|
||||
func parseDeadlineInput(input string) (time.Time, error) {
|
||||
// Check if it's a relative date (+N days)
|
||||
if strings.HasPrefix(input, "+") {
|
||||
daysStr := strings.TrimPrefix(input, "+")
|
||||
days := 0
|
||||
_, err := fmt.Sscanf(daysStr, "%d", &days)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid relative date format: %s", input)
|
||||
}
|
||||
return time.Now().AddDate(0, 0, days), nil
|
||||
}
|
||||
|
||||
// Try parsing as absolute date
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"2006/01/02",
|
||||
"01/02/2006",
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, input); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unable to parse date: %s (use YYYY-MM-DD or +N)", input)
|
||||
}
|
||||
|
||||
func (m *uiModel) cycleStateBackward(item *model.Item) {
|
||||
switch item.State {
|
||||
case model.StateNone:
|
||||
item.State = model.StateDONE
|
||||
case model.StateTODO:
|
||||
item.State = model.StateNone
|
||||
case model.StatePROG:
|
||||
item.State = model.StateTODO
|
||||
case model.StateBLOCK:
|
||||
item.State = model.StatePROG
|
||||
case model.StateDONE:
|
||||
item.State = model.StateBLOCK
|
||||
}
|
||||
}
|
||||
|
||||
func (m *uiModel) deleteItem(item *model.Item) {
|
||||
var removeFromList func([]*model.Item, *model.Item) []*model.Item
|
||||
removeFromList = func(items []*model.Item, target *model.Item) []*model.Item {
|
||||
result := []*model.Item{}
|
||||
for _, it := range items {
|
||||
if it == target {
|
||||
continue
|
||||
}
|
||||
it.Children = removeFromList(it.Children, target)
|
||||
result = append(result, it)
|
||||
}
|
||||
return result
|
||||
}
|
||||
m.orgFile.Items = removeFromList(m.orgFile.Items, item)
|
||||
}
|
||||
|
||||
func (m *uiModel) moveItemUp() {
|
||||
items := m.getVisibleItems()
|
||||
if len(items) == 0 || m.cursor == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
currentItem := items[m.cursor]
|
||||
prevItem := items[m.cursor-1]
|
||||
|
||||
// Can only swap items at the same level
|
||||
if currentItem.Level != prevItem.Level {
|
||||
m.setStatus("Cannot move across different levels")
|
||||
return
|
||||
}
|
||||
|
||||
m.swapItems(currentItem, prevItem)
|
||||
m.cursor--
|
||||
m.setStatus("Item moved up")
|
||||
}
|
||||
|
||||
func (m *uiModel) moveItemDown() {
|
||||
items := m.getVisibleItems()
|
||||
if len(items) == 0 || m.cursor >= len(items)-1 {
|
||||
return
|
||||
}
|
||||
|
||||
currentItem := items[m.cursor]
|
||||
nextItem := items[m.cursor+1]
|
||||
|
||||
// Can only swap items at the same level
|
||||
if currentItem.Level != nextItem.Level {
|
||||
m.setStatus("Cannot move across different levels")
|
||||
return
|
||||
}
|
||||
|
||||
m.swapItems(currentItem, nextItem)
|
||||
m.cursor++
|
||||
m.setStatus("Item moved down")
|
||||
}
|
||||
|
||||
func (m *uiModel) swapItems(item1, item2 *model.Item) {
|
||||
// Find parent list containing both items
|
||||
var swapInList func([]*model.Item) bool
|
||||
swapInList = func(items []*model.Item) bool {
|
||||
for i := 0; i < len(items)-1; i++ {
|
||||
if items[i] == item1 && items[i+1] == item2 {
|
||||
items[i], items[i+1] = items[i+1], items[i]
|
||||
return true
|
||||
}
|
||||
if items[i] == item2 && items[i+1] == item1 {
|
||||
items[i], items[i+1] = items[i+1], items[i]
|
||||
return true
|
||||
}
|
||||
if swapInList(items[i].Children) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(items) > 0 && swapInList(items[len(items)-1].Children) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
swapInList(m.orgFile.Items)
|
||||
}
|
||||
18
internal/ui/styles.go
Normal file
18
internal/ui/styles.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Styles for UI rendering
|
||||
var (
|
||||
todoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("202")) // Orange
|
||||
progStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) // Yellow
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // Red
|
||||
doneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("34")) // Green
|
||||
cursorStyle = lipgloss.NewStyle().Background(lipgloss.Color("240"))
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99"))
|
||||
scheduledStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple
|
||||
overdueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) // Red
|
||||
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Italic(true)
|
||||
noteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246")).Italic(true)
|
||||
foldedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
||||
)
|
||||
576
internal/ui/views.go
Normal file
576
internal/ui/views.go
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alecthomas/chroma/v2/quick"
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/rwejlgaard/org/internal/model"
|
||||
"github.com/rwejlgaard/org/internal/parser"
|
||||
)
|
||||
|
||||
// dynamicKeyMap is a helper type for rendering keybindings with dynamic layout
|
||||
type dynamicKeyMap struct {
|
||||
rows [][]key.Binding
|
||||
}
|
||||
|
||||
// ShortHelp for dynamicKeyMap
|
||||
func (d dynamicKeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{}
|
||||
}
|
||||
|
||||
// FullHelp for dynamicKeyMap
|
||||
func (d dynamicKeyMap) FullHelp() [][]key.Binding {
|
||||
return d.rows
|
||||
}
|
||||
|
||||
// renderFullHelp renders the help with width-aware layout
|
||||
func (m uiModel) renderFullHelp() string {
|
||||
bindings := m.keys.getAllBindings()
|
||||
|
||||
// Estimate the width needed for each keybinding (key + desc + padding)
|
||||
// Average is roughly 20-25 chars per binding
|
||||
const estimatedBindingWidth = 22
|
||||
const minWidth = 40 // Minimum width before stacking
|
||||
|
||||
var columnsPerRow int
|
||||
if m.width < minWidth {
|
||||
columnsPerRow = 1 // Stack vertically on very narrow terminals
|
||||
} else if m.width < 80 {
|
||||
columnsPerRow = 2 // Two columns on narrow terminals
|
||||
} else if m.width < 120 {
|
||||
columnsPerRow = 3 // Three columns on medium terminals
|
||||
} else {
|
||||
columnsPerRow = 4 // Four columns on wide terminals
|
||||
}
|
||||
|
||||
// Build rows based on columns per row
|
||||
var rows [][]key.Binding
|
||||
for i := 0; i < len(bindings); i += columnsPerRow {
|
||||
end := i + columnsPerRow
|
||||
if end > len(bindings) {
|
||||
end = len(bindings)
|
||||
}
|
||||
rows = append(rows, bindings[i:end])
|
||||
}
|
||||
|
||||
// Use the help model to render with our dynamic layout
|
||||
h := help.New()
|
||||
h.Width = m.width
|
||||
h.ShowAll = true
|
||||
|
||||
// Create a temporary keyMap for rendering
|
||||
dkm := dynamicKeyMap{rows: rows}
|
||||
|
||||
return h.View(dkm)
|
||||
}
|
||||
|
||||
func (m uiModel) View() string {
|
||||
switch m.mode {
|
||||
case modeEdit:
|
||||
return m.viewEditMode()
|
||||
case modeConfirmDelete:
|
||||
return m.viewConfirmDelete()
|
||||
case modeCapture:
|
||||
return m.viewCapture()
|
||||
case modeAddSubTask:
|
||||
return m.viewAddSubTask()
|
||||
case modeSetDeadline:
|
||||
return m.viewSetDeadline()
|
||||
}
|
||||
|
||||
// Build footer (status + help)
|
||||
var footer strings.Builder
|
||||
|
||||
// Status message
|
||||
if time.Now().Before(m.statusExpiry) {
|
||||
footer.WriteString(statusStyle.Render(m.statusMsg))
|
||||
footer.WriteString("\n")
|
||||
}
|
||||
|
||||
// Help
|
||||
if m.help.ShowAll {
|
||||
footer.WriteString(m.renderFullHelp())
|
||||
} else {
|
||||
footer.WriteString(m.help.View(m.keys))
|
||||
}
|
||||
|
||||
footerHeight := lipgloss.Height(footer.String())
|
||||
|
||||
// Build main content
|
||||
var content strings.Builder
|
||||
|
||||
// Title
|
||||
title := "Org Mode - List View"
|
||||
if m.mode == modeAgenda {
|
||||
title = "Org Mode - Agenda View (Next 7 Days)"
|
||||
}
|
||||
if m.reorderMode {
|
||||
reorderIndicator := lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Render(" [REORDER MODE]")
|
||||
content.WriteString(titleStyle.Render(title))
|
||||
content.WriteString(reorderIndicator)
|
||||
} else {
|
||||
content.WriteString(titleStyle.Render(title))
|
||||
}
|
||||
content.WriteString("\n\n")
|
||||
|
||||
// Calculate available height for items (total - title - footer)
|
||||
availableHeight := m.height - 3 - footerHeight // 3 for title + spacing
|
||||
if availableHeight < 5 {
|
||||
availableHeight = 5 // Minimum height
|
||||
}
|
||||
|
||||
// Items
|
||||
items := m.getVisibleItems()
|
||||
if len(items) == 0 {
|
||||
content.WriteString("No items. Press 'c' to capture a new TODO.\n")
|
||||
}
|
||||
|
||||
itemLines := 0
|
||||
for i, item := range items {
|
||||
if itemLines >= availableHeight {
|
||||
break // Don't render more items than fit
|
||||
}
|
||||
|
||||
line := m.renderItem(item, i == m.cursor)
|
||||
content.WriteString(line)
|
||||
content.WriteString("\n")
|
||||
itemLines++
|
||||
|
||||
// Show notes if not folded
|
||||
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
|
||||
indent := strings.Repeat(" ", item.Level)
|
||||
// Filter out LOGBOOK drawer and apply syntax highlighting to notes
|
||||
filteredNotes := filterLogbookDrawer(item.Notes)
|
||||
highlightedNotes := renderNotesWithHighlighting(filteredNotes)
|
||||
for _, note := range highlightedNotes {
|
||||
if itemLines >= availableHeight {
|
||||
break
|
||||
}
|
||||
content.WriteString(indent)
|
||||
content.WriteString(" " + note)
|
||||
content.WriteString("\n")
|
||||
itemLines++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine content and footer with padding
|
||||
contentHeight := lipgloss.Height(content.String())
|
||||
paddingNeeded := m.height - contentHeight - footerHeight
|
||||
if paddingNeeded < 0 {
|
||||
paddingNeeded = 0
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(content.String())
|
||||
if paddingNeeded > 0 {
|
||||
result.WriteString(strings.Repeat("\n", paddingNeeded))
|
||||
}
|
||||
result.WriteString(footer.String())
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (m uiModel) viewConfirmDelete() string {
|
||||
var b strings.Builder
|
||||
|
||||
dialogStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("196")).
|
||||
Padding(1, 2).
|
||||
Width(60)
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(titleStyle.Render("⚠ Delete Item"))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
if m.itemToDelete != nil {
|
||||
itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("202")).Bold(true)
|
||||
content.WriteString(itemStyle.Render(m.itemToDelete.Title))
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
content.WriteString("\n")
|
||||
content.WriteString(statusStyle.Render("This will delete the item and all sub-tasks."))
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString("Press Y to confirm • N or ESC to cancel")
|
||||
|
||||
dialog := dialogStyle.Render(content.String())
|
||||
|
||||
// Center the dialog
|
||||
if m.height > 0 {
|
||||
verticalPadding := (m.height - lipgloss.Height(dialog)) / 2
|
||||
if verticalPadding > 0 {
|
||||
b.WriteString(strings.Repeat("\n", verticalPadding))
|
||||
}
|
||||
}
|
||||
b.WriteString(dialog)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m uiModel) viewCapture() string {
|
||||
var b strings.Builder
|
||||
|
||||
dialogStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("99")).
|
||||
Padding(1, 2).
|
||||
Width(60)
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(titleStyle.Render("Capture TODO"))
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(m.textinput.View())
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel"))
|
||||
|
||||
dialog := dialogStyle.Render(content.String())
|
||||
|
||||
// Center the dialog
|
||||
if m.height > 0 {
|
||||
verticalPadding := (m.height - lipgloss.Height(dialog)) / 2
|
||||
if verticalPadding > 0 {
|
||||
b.WriteString(strings.Repeat("\n", verticalPadding))
|
||||
}
|
||||
}
|
||||
b.WriteString(dialog)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m uiModel) viewAddSubTask() string {
|
||||
var b strings.Builder
|
||||
|
||||
dialogStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("99")).
|
||||
Padding(1, 2).
|
||||
Width(60)
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(titleStyle.Render("Add Sub-Task"))
|
||||
content.WriteString("\n")
|
||||
if m.editingItem != nil {
|
||||
content.WriteString(statusStyle.Render(fmt.Sprintf("Under: %s", m.editingItem.Title)))
|
||||
}
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(m.textinput.View())
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel"))
|
||||
|
||||
dialog := dialogStyle.Render(content.String())
|
||||
|
||||
// Center the dialog
|
||||
if m.height > 0 {
|
||||
verticalPadding := (m.height - lipgloss.Height(dialog)) / 2
|
||||
if verticalPadding > 0 {
|
||||
b.WriteString(strings.Repeat("\n", verticalPadding))
|
||||
}
|
||||
}
|
||||
b.WriteString(dialog)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m uiModel) viewSetDeadline() string {
|
||||
var b strings.Builder
|
||||
|
||||
dialogStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("141")).
|
||||
Padding(1, 2).
|
||||
Width(60)
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(titleStyle.Render("Set Deadline"))
|
||||
content.WriteString("\n")
|
||||
if m.editingItem != nil {
|
||||
content.WriteString(statusStyle.Render(fmt.Sprintf("For: %s", m.editingItem.Title)))
|
||||
}
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(m.textinput.View())
|
||||
content.WriteString("\n\n")
|
||||
content.WriteString(statusStyle.Render("Examples: 2025-12-31, +7 (7 days from now)"))
|
||||
content.WriteString("\n")
|
||||
content.WriteString(statusStyle.Render("Leave empty to clear deadline"))
|
||||
content.WriteString("\n")
|
||||
content.WriteString(statusStyle.Render("Press Enter to save • ESC to cancel"))
|
||||
|
||||
dialog := dialogStyle.Render(content.String())
|
||||
|
||||
// Center the dialog
|
||||
if m.height > 0 {
|
||||
verticalPadding := (m.height - lipgloss.Height(dialog)) / 2
|
||||
if verticalPadding > 0 {
|
||||
b.WriteString(strings.Repeat("\n", verticalPadding))
|
||||
}
|
||||
}
|
||||
b.WriteString(dialog)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m uiModel) viewEditMode() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(titleStyle.Render("Editing Notes"))
|
||||
b.WriteString("\n")
|
||||
if m.editingItem != nil {
|
||||
b.WriteString(fmt.Sprintf("Item: %s\n", m.editingItem.Title))
|
||||
}
|
||||
b.WriteString(statusStyle.Render("Press ESC to save and exit"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(m.textarea.View())
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// filterLogbookDrawer removes LOGBOOK drawer content and scheduling metadata from notes
|
||||
func filterLogbookDrawer(notes []string) []string {
|
||||
var filtered []string
|
||||
inLogbook := false
|
||||
|
||||
for _, note := range notes {
|
||||
trimmed := strings.TrimSpace(note)
|
||||
|
||||
// Check for start of LOGBOOK drawer
|
||||
if trimmed == ":LOGBOOK:" {
|
||||
inLogbook = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for end of LOGBOOK drawer
|
||||
if trimmed == ":END:" && inLogbook {
|
||||
inLogbook = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip lines inside LOGBOOK drawer
|
||||
if inLogbook {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip SCHEDULED and DEADLINE lines
|
||||
if strings.HasPrefix(trimmed, "SCHEDULED:") || strings.HasPrefix(trimmed, "DEADLINE:") {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, note)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// renderNotesWithHighlighting renders notes with syntax highlighting for code blocks
|
||||
func renderNotesWithHighlighting(notes []string) []string {
|
||||
if len(notes) == 0 {
|
||||
return notes
|
||||
}
|
||||
|
||||
var result []string
|
||||
var inCodeBlock bool
|
||||
var codeLanguage string
|
||||
var codeLines []string
|
||||
var codeBlockDelimiter string // Track whether we're in #+BEGIN_SRC or ``` block
|
||||
|
||||
for _, note := range notes {
|
||||
trimmed := strings.TrimSpace(note)
|
||||
|
||||
// Check for org-mode style code block start
|
||||
if strings.HasPrefix(trimmed, "#+BEGIN_SRC") {
|
||||
inCodeBlock = true
|
||||
codeBlockDelimiter = "org"
|
||||
// Extract language
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) > 1 {
|
||||
codeLanguage = strings.ToLower(parts[1])
|
||||
} else {
|
||||
codeLanguage = "text"
|
||||
}
|
||||
result = append(result, note) // Keep the delimiter visible
|
||||
codeLines = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for markdown style code block start
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
if !inCodeBlock {
|
||||
// Starting a code block
|
||||
inCodeBlock = true
|
||||
codeBlockDelimiter = "markdown"
|
||||
// Extract language
|
||||
lang := strings.TrimPrefix(trimmed, "```")
|
||||
if lang != "" {
|
||||
codeLanguage = strings.ToLower(lang)
|
||||
} else {
|
||||
codeLanguage = "text"
|
||||
}
|
||||
result = append(result, note) // Keep the delimiter visible
|
||||
codeLines = []string{}
|
||||
continue
|
||||
} else if codeBlockDelimiter == "markdown" {
|
||||
// Ending a markdown code block
|
||||
inCodeBlock = false
|
||||
// Highlight and add the code
|
||||
if len(codeLines) > 0 {
|
||||
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
|
||||
highlightedLines := strings.Split(highlighted, "\n")
|
||||
result = append(result, highlightedLines...)
|
||||
}
|
||||
result = append(result, note) // Keep the delimiter visible
|
||||
codeLines = []string{}
|
||||
codeLanguage = ""
|
||||
codeBlockDelimiter = ""
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check for org-mode style code block end
|
||||
if strings.HasPrefix(trimmed, "#+END_SRC") {
|
||||
inCodeBlock = false
|
||||
// Highlight and add the code
|
||||
if len(codeLines) > 0 {
|
||||
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
|
||||
highlightedLines := strings.Split(highlighted, "\n")
|
||||
result = append(result, highlightedLines...)
|
||||
}
|
||||
result = append(result, note) // Keep the delimiter visible
|
||||
codeLines = []string{}
|
||||
codeLanguage = ""
|
||||
codeBlockDelimiter = ""
|
||||
continue
|
||||
}
|
||||
|
||||
// If in code block, accumulate lines
|
||||
if inCodeBlock {
|
||||
codeLines = append(codeLines, note)
|
||||
} else {
|
||||
result = append(result, note)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle case where code block wasn't closed
|
||||
if inCodeBlock && len(codeLines) > 0 {
|
||||
highlighted := highlightCode(strings.Join(codeLines, "\n"), codeLanguage)
|
||||
highlightedLines := strings.Split(highlighted, "\n")
|
||||
result = append(result, highlightedLines...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// highlightCode applies syntax highlighting to code
|
||||
func highlightCode(code, language string) string {
|
||||
if code == "" {
|
||||
return code
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
err := quick.Highlight(&buf, code, language, "terminal256", "monokai")
|
||||
if err != nil {
|
||||
// If highlighting fails, return the original code
|
||||
return code
|
||||
}
|
||||
|
||||
return strings.TrimRight(buf.String(), "\n")
|
||||
}
|
||||
|
||||
func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Indentation for level
|
||||
indent := strings.Repeat(" ", item.Level-1)
|
||||
b.WriteString(indent)
|
||||
|
||||
// Fold indicator
|
||||
if len(item.Children) > 0 || len(item.Notes) > 0 {
|
||||
if item.Folded {
|
||||
b.WriteString(foldedStyle.Render("▶ "))
|
||||
} else {
|
||||
b.WriteString(foldedStyle.Render("▼ "))
|
||||
}
|
||||
} else {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
|
||||
// State
|
||||
stateStr := ""
|
||||
switch item.State {
|
||||
case model.StateTODO:
|
||||
stateStr = todoStyle.Render("[TODO] ")
|
||||
case model.StatePROG:
|
||||
stateStr = progStyle.Render("[PROG] ")
|
||||
case model.StateBLOCK:
|
||||
stateStr = blockStyle.Render("[BLOCK]")
|
||||
case model.StateDONE:
|
||||
stateStr = doneStyle.Render("[DONE] ")
|
||||
default:
|
||||
stateStr = " " // Empty space for alignment
|
||||
}
|
||||
b.WriteString(stateStr)
|
||||
b.WriteString(" ")
|
||||
|
||||
// Title
|
||||
b.WriteString(item.Title)
|
||||
|
||||
// Clock status
|
||||
if item.IsClockedIn() {
|
||||
duration := item.GetCurrentClockDuration()
|
||||
hours := int(duration.Hours())
|
||||
minutes := int(duration.Minutes()) % 60
|
||||
clockStr := fmt.Sprintf(" [CLOCKED IN: %dh %dm]", hours, minutes)
|
||||
clockStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true) // Bright green
|
||||
b.WriteString(clockStyle.Render(clockStr))
|
||||
}
|
||||
|
||||
// Total clocked time (show if there are any clock entries)
|
||||
if len(item.ClockEntries) > 0 {
|
||||
totalDuration := item.GetTotalClockDuration()
|
||||
totalHours := int(totalDuration.Hours())
|
||||
totalMinutes := int(totalDuration.Minutes()) % 60
|
||||
|
||||
// Format the time display based on magnitude
|
||||
var timeStr string
|
||||
if totalHours > 0 {
|
||||
timeStr = fmt.Sprintf("%dh %dm", totalHours, totalMinutes)
|
||||
} else {
|
||||
timeStr = fmt.Sprintf("%dm", totalMinutes)
|
||||
}
|
||||
|
||||
totalTimeStr := fmt.Sprintf(" (Time: %s)", timeStr)
|
||||
totalTimeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple, similar to scheduled
|
||||
b.WriteString(totalTimeStyle.Render(totalTimeStr))
|
||||
}
|
||||
|
||||
// Scheduling info
|
||||
now := time.Now()
|
||||
if item.Scheduled != nil {
|
||||
schedStr := fmt.Sprintf(" (Scheduled: %s)", parser.FormatOrgDate(*item.Scheduled))
|
||||
if item.Scheduled.Before(now) {
|
||||
b.WriteString(overdueStyle.Render(schedStr))
|
||||
} else {
|
||||
b.WriteString(scheduledStyle.Render(schedStr))
|
||||
}
|
||||
}
|
||||
if item.Deadline != nil {
|
||||
deadlineStr := fmt.Sprintf(" (Deadline: %s)", parser.FormatOrgDate(*item.Deadline))
|
||||
if item.Deadline.Before(now) {
|
||||
b.WriteString(overdueStyle.Render(deadlineStr))
|
||||
} else {
|
||||
b.WriteString(scheduledStyle.Render(deadlineStr))
|
||||
}
|
||||
}
|
||||
|
||||
line := b.String()
|
||||
if isCursor {
|
||||
return cursorStyle.Render(line)
|
||||
}
|
||||
return line
|
||||
}
|
||||
443
org.go
443
org.go
|
|
@ -1,443 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TodoState represents the state of a todo item
|
||||
type TodoState string
|
||||
|
||||
const (
|
||||
StateTODO TodoState = "TODO"
|
||||
StatePROG TodoState = "PROG"
|
||||
StateBLOCK TodoState = "BLOCK"
|
||||
StateDONE TodoState = "DONE"
|
||||
StateNone TodoState = ""
|
||||
)
|
||||
|
||||
// ClockEntry represents a single clock entry
|
||||
type ClockEntry struct {
|
||||
Start time.Time
|
||||
End *time.Time // nil if currently clocked in
|
||||
}
|
||||
|
||||
// Item represents a single org-mode item (heading)
|
||||
type Item struct {
|
||||
Level int // Heading level (number of *)
|
||||
State TodoState // TODO, PROG, BLOCK, DONE, or empty
|
||||
Title string // The main title text
|
||||
Scheduled *time.Time
|
||||
Deadline *time.Time
|
||||
Notes []string // Notes/content under the heading
|
||||
Children []*Item // Sub-items
|
||||
Folded bool // Whether the item is folded (hides notes and children)
|
||||
ClockEntries []ClockEntry // Clock in/out entries
|
||||
}
|
||||
|
||||
// OrgFile represents a parsed org-mode file
|
||||
type OrgFile struct {
|
||||
Path string
|
||||
Items []*Item
|
||||
}
|
||||
|
||||
// Parser patterns
|
||||
var (
|
||||
headingPattern = regexp.MustCompile(`^(\*+)\s+(?:(TODO|PROG|BLOCK|DONE)\s+)?(.+)$`)
|
||||
scheduledPattern = regexp.MustCompile(`SCHEDULED:\s*<([^>]+)>`)
|
||||
deadlinePattern = regexp.MustCompile(`DEADLINE:\s*<([^>]+)>`)
|
||||
clockPattern = regexp.MustCompile(`CLOCK:\s*\[([^\]]+)\](?:--\[([^\]]+)\])?`)
|
||||
drawerStart = regexp.MustCompile(`^\s*:LOGBOOK:\s*$`)
|
||||
drawerEnd = regexp.MustCompile(`^\s*:END:\s*$`)
|
||||
codeBlockStart = regexp.MustCompile(`^\s*#\+BEGIN_SRC`)
|
||||
codeBlockEnd = regexp.MustCompile(`^\s*#\+END_SRC`)
|
||||
)
|
||||
|
||||
// ParseOrgFile reads and parses an org-mode file
|
||||
func ParseOrgFile(path string) (*OrgFile, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
// If file doesn't exist, return empty org file
|
||||
if os.IsNotExist(err) {
|
||||
return &OrgFile{Path: path, Items: []*Item{}}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
orgFile := &OrgFile{Path: path, Items: []*Item{}}
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
var currentItem *Item
|
||||
var itemStack []*Item // Stack to track parent items
|
||||
var inCodeBlock bool
|
||||
var inLogbookDrawer bool
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Check for drawer boundaries
|
||||
if drawerStart.MatchString(line) {
|
||||
inLogbookDrawer = true
|
||||
if currentItem != nil {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if drawerEnd.MatchString(line) && inLogbookDrawer {
|
||||
inLogbookDrawer = false
|
||||
if currentItem != nil {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for code block boundaries
|
||||
if codeBlockStart.MatchString(line) {
|
||||
inCodeBlock = true
|
||||
if currentItem != nil {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if codeBlockEnd.MatchString(line) {
|
||||
inCodeBlock = false
|
||||
if currentItem != nil {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If in code block, add line to notes
|
||||
if inCodeBlock {
|
||||
if currentItem != nil {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to match heading
|
||||
if matches := headingPattern.FindStringSubmatch(line); matches != nil {
|
||||
level := len(matches[1])
|
||||
state := TodoState(matches[2])
|
||||
title := matches[3]
|
||||
|
||||
item := &Item{
|
||||
Level: level,
|
||||
State: state,
|
||||
Title: title,
|
||||
Notes: []string{},
|
||||
Children: []*Item{},
|
||||
}
|
||||
|
||||
// Find parent based on level
|
||||
for len(itemStack) > 0 && itemStack[len(itemStack)-1].Level >= level {
|
||||
itemStack = itemStack[:len(itemStack)-1]
|
||||
}
|
||||
|
||||
if len(itemStack) == 0 {
|
||||
// Top-level item
|
||||
orgFile.Items = append(orgFile.Items, item)
|
||||
} else {
|
||||
// Child item
|
||||
parent := itemStack[len(itemStack)-1]
|
||||
parent.Children = append(parent.Children, item)
|
||||
}
|
||||
|
||||
itemStack = append(itemStack, item)
|
||||
currentItem = item
|
||||
} else if currentItem != nil {
|
||||
// This is content under the current item
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check for SCHEDULED
|
||||
if matches := scheduledPattern.FindStringSubmatch(line); matches != nil {
|
||||
if t, err := parseOrgDate(matches[1]); err == nil {
|
||||
currentItem.Scheduled = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Check for DEADLINE
|
||||
if matches := deadlinePattern.FindStringSubmatch(line); matches != nil {
|
||||
if t, err := parseOrgDate(matches[1]); err == nil {
|
||||
currentItem.Deadline = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CLOCK (can be inside or outside drawer)
|
||||
if matches := clockPattern.FindStringSubmatch(line); matches != nil {
|
||||
if startTime, err := parseClockTimestamp(matches[1]); err == nil {
|
||||
entry := ClockEntry{Start: startTime}
|
||||
if len(matches) > 2 && matches[2] != "" {
|
||||
if endTime, err := parseClockTimestamp(matches[2]); err == nil {
|
||||
entry.End = &endTime
|
||||
}
|
||||
}
|
||||
currentItem.ClockEntries = append(currentItem.ClockEntries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Add all lines as notes (including scheduling lines and drawer content for proper serialization)
|
||||
if trimmed != "" || len(currentItem.Notes) > 0 {
|
||||
currentItem.Notes = append(currentItem.Notes, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return orgFile, nil
|
||||
}
|
||||
|
||||
// parseOrgDate parses org-mode date format
|
||||
func parseOrgDate(dateStr string) (time.Time, error) {
|
||||
// Org-mode format: 2024-01-15 Mon 10:00
|
||||
formats := []string{
|
||||
"2006-01-02 Mon 15:04",
|
||||
"2006-01-02 Mon",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, dateStr); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unable to parse date: %s", dateStr)
|
||||
}
|
||||
|
||||
// parseClockTimestamp parses org-mode clock timestamp format
|
||||
func parseClockTimestamp(timestampStr string) (time.Time, error) {
|
||||
// Org-mode clock format: [2024-01-15 Mon 10:00]
|
||||
formats := []string{
|
||||
"2006-01-02 Mon 15:04",
|
||||
"2006-01-02 Mon 15:04:05",
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, timestampStr); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unable to parse clock timestamp: %s", timestampStr)
|
||||
}
|
||||
|
||||
// formatClockTimestamp formats a time as org-mode clock timestamp
|
||||
func formatClockTimestamp(t time.Time) string {
|
||||
return t.Format("2006-01-02 Mon 15:04")
|
||||
}
|
||||
|
||||
// formatOrgDate formats a time as org-mode date
|
||||
func formatOrgDate(t time.Time) string {
|
||||
return t.Format("2006-01-02 Mon")
|
||||
}
|
||||
|
||||
// Save writes the org file back to disk
|
||||
func (of *OrgFile) Save() error {
|
||||
file, err := os.Create(of.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
|
||||
for _, item := range of.Items {
|
||||
if err := writeItem(writer, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeItem recursively writes an item and its children
|
||||
func writeItem(writer *bufio.Writer, item *Item) error {
|
||||
// Write heading
|
||||
stars := strings.Repeat("*", item.Level)
|
||||
line := stars
|
||||
if item.State != StateNone {
|
||||
line += " " + string(item.State)
|
||||
}
|
||||
line += " " + item.Title + "\n"
|
||||
|
||||
if _, err := writer.WriteString(line); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write scheduling info if not already in notes
|
||||
hasScheduled := false
|
||||
hasDeadline := false
|
||||
hasLogbook := false
|
||||
for _, note := range item.Notes {
|
||||
if strings.Contains(note, "SCHEDULED:") {
|
||||
hasScheduled = true
|
||||
}
|
||||
if strings.Contains(note, "DEADLINE:") {
|
||||
hasDeadline = true
|
||||
}
|
||||
if strings.Contains(note, ":LOGBOOK:") {
|
||||
hasLogbook = true
|
||||
}
|
||||
}
|
||||
|
||||
if item.Scheduled != nil && !hasScheduled {
|
||||
scheduledLine := fmt.Sprintf("SCHEDULED: <%s>\n", formatOrgDate(*item.Scheduled))
|
||||
if _, err := writer.WriteString(scheduledLine); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if item.Deadline != nil && !hasDeadline {
|
||||
deadlineLine := fmt.Sprintf("DEADLINE: <%s>\n", formatOrgDate(*item.Deadline))
|
||||
if _, err := writer.WriteString(deadlineLine); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Write clock entries in :LOGBOOK: drawer if not already in notes
|
||||
if len(item.ClockEntries) > 0 && !hasLogbook {
|
||||
if _, err := writer.WriteString(":LOGBOOK:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range item.ClockEntries {
|
||||
clockLine := fmt.Sprintf("CLOCK: [%s]", formatClockTimestamp(entry.Start))
|
||||
if entry.End != nil {
|
||||
clockLine += fmt.Sprintf("--[%s]", formatClockTimestamp(*entry.End))
|
||||
}
|
||||
clockLine += "\n"
|
||||
if _, err := writer.WriteString(clockLine); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := writer.WriteString(":END:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Write notes
|
||||
for _, note := range item.Notes {
|
||||
if _, err := writer.WriteString(note + "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Write children
|
||||
for _, child := range item.Children {
|
||||
if err := writeItem(writer, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllItems returns a flattened list of all items (for UI display)
|
||||
// Respects folding - folded items don't show their children
|
||||
func (of *OrgFile) GetAllItems() []*Item {
|
||||
var items []*Item
|
||||
var flatten func([]*Item)
|
||||
flatten = func(list []*Item) {
|
||||
for _, item := range list {
|
||||
items = append(items, item)
|
||||
if !item.Folded {
|
||||
flatten(item.Children)
|
||||
}
|
||||
}
|
||||
}
|
||||
flatten(of.Items)
|
||||
return items
|
||||
}
|
||||
|
||||
// ToggleFold toggles the folded state of an item
|
||||
func (item *Item) ToggleFold() {
|
||||
item.Folded = !item.Folded
|
||||
}
|
||||
|
||||
// CycleState cycles through todo states
|
||||
func (item *Item) CycleState() {
|
||||
switch item.State {
|
||||
case StateNone:
|
||||
item.State = StateTODO
|
||||
case StateTODO:
|
||||
item.State = StatePROG
|
||||
case StatePROG:
|
||||
item.State = StateBLOCK
|
||||
case StateBLOCK:
|
||||
item.State = StateDONE
|
||||
case StateDONE:
|
||||
item.State = StateNone
|
||||
}
|
||||
}
|
||||
|
||||
// ClockIn starts a new clock entry
|
||||
func (item *Item) ClockIn() bool {
|
||||
// Check if already clocked in
|
||||
if item.IsClockedIn() {
|
||||
return false
|
||||
}
|
||||
|
||||
entry := ClockEntry{
|
||||
Start: time.Now(),
|
||||
End: nil,
|
||||
}
|
||||
item.ClockEntries = append(item.ClockEntries, entry)
|
||||
return true
|
||||
}
|
||||
|
||||
// ClockOut ends the current clock entry
|
||||
func (item *Item) ClockOut() bool {
|
||||
// Find the most recent open clock entry
|
||||
for i := len(item.ClockEntries) - 1; i >= 0; i-- {
|
||||
if item.ClockEntries[i].End == nil {
|
||||
now := time.Now()
|
||||
item.ClockEntries[i].End = &now
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsClockedIn returns true if there's an active clock entry
|
||||
func (item *Item) IsClockedIn() bool {
|
||||
for _, entry := range item.ClockEntries {
|
||||
if entry.End == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetCurrentClockDuration returns the duration of the current clock entry
|
||||
func (item *Item) GetCurrentClockDuration() time.Duration {
|
||||
for _, entry := range item.ClockEntries {
|
||||
if entry.End == nil {
|
||||
return time.Since(entry.Start)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetTotalClockDuration returns the total duration of all clock entries
|
||||
func (item *Item) GetTotalClockDuration() time.Duration {
|
||||
var total time.Duration
|
||||
for _, entry := range item.ClockEntries {
|
||||
if entry.End != nil {
|
||||
// Completed clock entry
|
||||
total += entry.End.Sub(entry.Start)
|
||||
} else {
|
||||
// Currently clocked in
|
||||
total += time.Since(entry.Start)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
0
todo.org
Normal file
0
todo.org
Normal file
Loading…
Add table
Reference in a new issue