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"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/rwejlgaard/org/internal/parser"
|
||||||
|
"github.com/rwejlgaard/org/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -29,20 +32,20 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the org file
|
// Parse the org file
|
||||||
orgFile, err := ParseOrgFile(filePath)
|
orgFile, err := parser.ParseOrgFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error parsing org file: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error parsing org file: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the UI
|
// 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)
|
fmt.Fprintf(os.Stderr, "Error running UI: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save on exit
|
// 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)
|
fmt.Fprintf(os.Stderr, "Error saving file: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
11
go.mod
11
go.mod
|
|
@ -3,13 +3,16 @@ module github.com/rwejlgaard/org
|
||||||
go 1.25.3
|
go 1.25.3
|
||||||
|
|
||||||
require (
|
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/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // 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/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/ansi v0.10.1 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // 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 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
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 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
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/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 h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
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 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
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 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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=
|
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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
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