@@ -13,95 +13,199 @@ import (
1313)
1414
1515func MarshalMarkdown (tasks []core.Task ) []byte {
16- byStatus := map [core.Status ][]core.Task {
17- core .StatusTodo : {},
18- core .StatusDoing : {},
19- core .StatusDone : {},
16+ taskMap := make (map [string ]core.Task )
17+ for _ , t := range tasks {
18+ taskMap [t .ID ] = t
2019 }
20+
21+ children := make (map [string ][]core.Task )
2122 for _ , t := range tasks {
22- byStatus [t .Status ] = append (byStatus [t .Status ], t )
23+ if t .ParentID != "" {
24+ children [t .ParentID ] = append (children [t .ParentID ], t )
25+ }
2326 }
24- for st := range byStatus {
25- sort .Slice (byStatus [st ], func (i , j int ) bool {
26- return byStatus [st ][i ].UpdatedAt .After (byStatus [st ][j ].UpdatedAt )
27- })
27+
28+ var roots []core.Task
29+ for _ , t := range tasks {
30+ if t .ParentID == "" {
31+ roots = append (roots , t )
32+ } else if _ , ok := taskMap [t .ParentID ]; ! ok {
33+ roots = append (roots , t )
34+ }
2835 }
2936
37+ // Sort roots by status and then by update time
38+ sortTasks (roots )
39+
3040 var b bytes.Buffer
3141 fmt .Fprintf (& b , "# Kairo Export\n \n " )
3242 fmt .Fprintf (& b , "_Exported: %s_\n \n " , time .Now ().UTC ().Format (time .RFC3339 ))
3343
34- writeSection := func (title string , st core.Status ) {
35- fmt .Fprintf (& b , "## %s\n \n " , title )
36- for _ , t := range byStatus [st ] {
37- box := " "
38- if st == core .StatusDone {
39- box = "x"
40- }
41- line := fmt .Sprintf ("- [%s] %s" , box , escapeMDInline (strings .TrimSpace (t .Title )))
42- if t .Deadline != nil {
43- line += " _(due " + t .Deadline .Local ().Format ("2006-01-02" ) + ")_"
44- }
45- if len (t .Tags ) > 0 {
46- line += " " + tagsInline (t .Tags )
47- }
48- fmt .Fprintln (& b , line )
49- if strings .TrimSpace (t .Description ) != "" {
50- for _ , ln := range strings .Split (strings .TrimRight (t .Description , "\n " ), "\n " ) {
51- fmt .Fprintln (& b , " " + ln )
52- }
44+ var walk func (t core.Task , depth int )
45+ walk = func (t core.Task , depth int ) {
46+ indent := strings .Repeat (" " , depth )
47+ box := " "
48+ if t .Status == core .StatusDone {
49+ box = "x"
50+ }
51+
52+ line := fmt .Sprintf ("%s- [%s] %s" , indent , box , escapeMDInline (strings .TrimSpace (t .Title )))
53+ if t .Deadline != nil {
54+ line += " _(due " + t .Deadline .Local ().Format ("2006-01-02" ) + ")_"
55+ }
56+ if len (t .Tags ) > 0 {
57+ line += " " + tagsInline (t .Tags )
58+ }
59+ fmt .Fprintln (& b , line )
60+
61+ if strings .TrimSpace (t .Description ) != "" {
62+ descIndent := indent + " "
63+ for _ , ln := range strings .Split (strings .TrimRight (t .Description , "\n " ), "\n " ) {
64+ fmt .Fprintln (& b , descIndent + ln )
5365 }
5466 }
55- fmt .Fprintln (& b )
67+
68+ kids := children [t .ID ]
69+ sortTasks (kids )
70+ for _ , kid := range kids {
71+ walk (kid , depth + 1 )
72+ }
73+ }
74+
75+ for _ , r := range roots {
76+ walk (r , 0 )
5677 }
5778
58- writeSection ("Todo" , core .StatusTodo )
59- writeSection ("Doing" , core .StatusDoing )
60- writeSection ("Done" , core .StatusDone )
6179 return b .Bytes ()
6280}
6381
64- var mdTaskRe = regexp .MustCompile (`^\s*-\s*\[( |x|X)\]\s+(.*)$` )
82+ func sortTasks (ts []core.Task ) {
83+ sort .Slice (ts , func (i , j int ) bool {
84+ if ts [i ].Status != ts [j ].Status {
85+ // Todo < Doing < Done (arbitrary, but consistent)
86+ return statusOrder (ts [i ].Status ) < statusOrder (ts [j ].Status )
87+ }
88+ return ts [i ].UpdatedAt .After (ts [j ].UpdatedAt )
89+ })
90+ }
91+
92+ func statusOrder (s core.Status ) int {
93+ switch s {
94+ case core .StatusTodo :
95+ return 0
96+ case core .StatusDoing :
97+ return 1
98+ case core .StatusDone :
99+ return 2
100+ default :
101+ return 3
102+ }
103+ }
104+
105+ var mdTaskRe = regexp .MustCompile (`^(\s*)-\s*\[( |x|X)\]\s+(.*)$` )
65106
66107func UnmarshalMarkdown (b []byte ) ([]core.Task , error ) {
67108 s := bufio .NewScanner (bytes .NewReader (b ))
68109 var tasks []core.Task
69- var cur * core.Task
110+
111+ type taskWithDepth struct {
112+ task * core.Task
113+ depth int
114+ }
115+ var stack []taskWithDepth
116+
70117 for s .Scan () {
71118 line := s .Text ()
72119 if m := mdTaskRe .FindStringSubmatch (line ); m != nil {
73- if cur != nil {
74- tasks = append ( tasks , * cur )
75- }
76- title := strings .TrimSpace (m [2 ])
120+ indent := m [ 1 ]
121+ depth := len ( indent ) / 2
122+
123+ title := strings .TrimSpace (m [3 ])
77124 st := core .StatusTodo
78- if m [1 ] == "x" || m [1 ] == "X" {
125+ if m [2 ] == "x" || m [2 ] == "X" {
79126 st = core .StatusDone
80127 }
81128 tags := extractTags (title )
82129 title = stripTags (title )
83- cur = & core.Task {
130+
131+ // Extract due date if present
132+ var deadline * time.Time
133+ if idx := strings .LastIndex (title , "_(due " ); idx != - 1 {
134+ if endIdx := strings .Index (title [idx :], ")_" ); endIdx != - 1 {
135+ dueStr := title [idx + 6 : idx + endIdx ]
136+ if t , err := time .Parse ("2006-01-02" , dueStr ); err == nil {
137+ deadline = & t
138+ }
139+ title = strings .TrimSpace (title [:idx ])
140+ }
141+ }
142+
143+ curTask := & core.Task {
144+ ID : fmt .Sprintf ("import-%d" , len (tasks )), // Temporary ID
84145 Title : title ,
85146 Tags : tags ,
86147 Status : st ,
148+ Deadline : deadline ,
87149 Priority : core .P1 ,
88150 }
151+
152+ // Find parent based on depth
153+ for len (stack ) > 0 && stack [len (stack )- 1 ].depth >= depth {
154+ stack = stack [:len (stack )- 1 ]
155+ }
156+ if len (stack ) > 0 {
157+ curTask .ParentID = stack [len (stack )- 1 ].task .ID
158+ }
159+
160+ tasks = append (tasks , * curTask )
161+ // Use the pointer to the task in the slice so we can update it (e.g. Description)
162+ stack = append (stack , taskWithDepth {task : & tasks [len (tasks )- 1 ], depth : depth })
89163 continue
90164 }
91- if cur != nil {
92- if strings .HasPrefix (line , " " ) {
93- cur .Description += strings .TrimPrefix (line , " " ) + "\n "
165+
166+ if len (stack ) > 0 {
167+ cur := stack [len (stack )- 1 ].task
168+ trimmed := strings .TrimSpace (line )
169+ if trimmed != "" && ! strings .HasPrefix (trimmed , "#" ) && ! strings .HasPrefix (trimmed , "_" ) {
170+ // Potential description line.
171+ // Check if it's indented enough to be a description of the current task
172+ indent := ""
173+ if idx := strings .Index (line , trimmed ); idx != - 1 {
174+ indent = line [:idx ]
175+ }
176+
177+ expectedIndent := stack [len (stack )- 1 ].depth * 2 + 2
178+ if len (indent ) >= expectedIndent {
179+ cur .Description += strings .TrimPrefix (line , strings .Repeat (" " , expectedIndent )) + "\n "
180+ }
94181 }
95182 }
96183 }
97- if cur != nil {
98- tasks = append (tasks , * cur )
99- }
184+
100185 if err := s .Err (); err != nil {
101186 return nil , err
102187 }
188+
103189 for i := range tasks {
104190 tasks [i ].Description = strings .TrimRight (tasks [i ].Description , "\n " )
191+ // Clean up temporary IDs for service to regenerate if needed,
192+ // but keep ParentID relationships.
193+ // Wait, if I clear IDs, ParentID will point to non-existent things.
194+ // The service.UpsertTask will handle it if we keep the temporary IDs
195+ // OR we let the service generate new IDs but we need to map them.
196+
197+ // Actually, api.handleImport calls service.UpsertTask.
198+ // UpsertTask in repo.go:
199+ /*
200+ func (r *Repository) UpsertTask(ctx context.Context, task core.Task) error {
201+ if task.ID == "" {
202+ task.ID = r.nextID()
203+ }
204+ ...
205+ }
206+ */
207+ // If I keep "import-N", it will be saved as "import-N".
208+ // This might be fine.
105209 }
106210 return tasks , nil
107211}
0 commit comments