Skip to content

Commit 2c30b41

Browse files
feat: implement CSV, Markdown, and text import/export codecs and update Lua engine support
1 parent 49d3592 commit 2c30b41

7 files changed

Lines changed: 335 additions & 80 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.4.3]
9+
### Added
10+
- **Enhanced Data Portability**: All export and import formats (JSON, CSV, Markdown, Text) now fully support nested task hierarchies and folders.
11+
- **Improved CSV Export**: CSV format now includes all task fields, including ParentID, Collapsed state, and Recurrence rules.
12+
- **Indented Tree Exports**: Markdown and Text formats now export tasks as a structured tree with indentation to preserve parent-child relationships.
13+
- **Smart Hierarchy Import**: Markdown and Text imports now automatically reconstruct task nesting based on indentation levels.
14+
15+
### Fixed
16+
- **Nesting Fidelity**: Resolved an issue where importing or exporting from non-JSON formats would lose parent/child relationships.
17+
818
## [1.4.2]
919
### Added
1020
- **Nested Tasks & Hierarchy**: Support for parent/child task relationships, including collapsible parent tasks and task nesting UI.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Organize your workspace into hierarchies.
103103
- Use **`Space`** on a parent task to toggle its expanded/collapsed state.
104104
- When a parent is collapsed, all its children are hidden, keeping your view focused.
105105
- **Hierarchy Visibility**: Tasks are automatically indented based on their nesting level, making it easy to visualize your project structure at a glance.
106+
- **Export/Import Preservation**: All export formats (JSON, CSV, Markdown, Text) now fully preserve your task hierarchy. Markdown and Text exports use structured indentation that is automatically recognized during import, ensuring your project structure remains intact across any format.
106107

107108
### It's fast — genuinely fast
108109
Sub-millisecond fuzzy search. Full keyboard control. Vim bindings (`j/k/gg/G`). Natural language deadlines like `tomorrow 10am` or `next friday`. You never have to leave the keyboard.

VERSION.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.4.2
1+
1.4.3

internal/core/codec/csv.go

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ func MarshalCSV(tasks []core.Task) ([]byte, error) {
1515
var b bytes.Buffer
1616
w := csv.NewWriter(&b)
1717

18-
header := []string{"ID", "Title", "Description", "Tags", "Priority", "Status", "Deadline", "CreatedAt", "UpdatedAt"}
18+
header := []string{
19+
"ID", "Title", "Description", "Tags", "Priority", "Status",
20+
"Deadline", "Recurrence", "RecurrenceWeekly", "RecurrenceMonthly",
21+
"ParentID", "Collapsed", "CreatedAt", "UpdatedAt",
22+
}
1923
if err := w.Write(header); err != nil {
2024
return nil, err
2125
}
@@ -25,6 +29,10 @@ func MarshalCSV(tasks []core.Task) ([]byte, error) {
2529
if t.Deadline != nil {
2630
deadline = t.Deadline.Format(time.RFC3339)
2731
}
32+
collapsed := "false"
33+
if t.Collapsed {
34+
collapsed = "true"
35+
}
2836
row := []string{
2937
t.ID,
3038
t.Title,
@@ -33,6 +41,11 @@ func MarshalCSV(tasks []core.Task) ([]byte, error) {
3341
fmt.Sprintf("%d", t.Priority),
3442
string(t.Status),
3543
deadline,
44+
string(t.Recurrence),
45+
strings.Join(t.RecurrenceWeekly, ";"),
46+
fmt.Sprintf("%d", t.RecurrenceMonthly),
47+
t.ParentID,
48+
collapsed,
3649
t.CreatedAt.Format(time.RFC3339),
3750
t.UpdatedAt.Format(time.RFC3339),
3851
}
@@ -57,33 +70,71 @@ func UnmarshalCSV(b []byte) ([]core.Task, error) {
5770
return nil, nil
5871
}
5972

73+
// Map headers to indices for robustness
74+
headerMap := make(map[string]int)
75+
for i, h := range records[0] {
76+
headerMap[h] = i
77+
}
78+
6079
var tasks []core.Task
6180
for i, row := range records {
6281
if i == 0 {
6382
continue // skip header
6483
}
65-
if len(row) < 6 {
66-
continue
84+
85+
get := func(key string) string {
86+
if idx, ok := headerMap[key]; ok && idx < len(row) {
87+
return row[idx]
88+
}
89+
return ""
6790
}
6891

6992
priority := core.P1
70-
_, _ = fmt.Sscanf(row[4], "%d", &priority)
93+
_, _ = fmt.Sscanf(get("Priority"), "%d", &priority)
94+
95+
recurrenceMonthly := 0
96+
_, _ = fmt.Sscanf(get("RecurrenceMonthly"), "%d", &recurrenceMonthly)
7197

7298
t := core.Task{
73-
ID: row[0],
74-
Title: row[1],
75-
Description: row[2],
76-
Tags: strings.Split(row[3], ","),
77-
Priority: priority,
78-
Status: core.Status(row[5]),
99+
ID: get("ID"),
100+
Title: get("Title"),
101+
Description: get("Description"),
102+
Tags: strings.Split(get("Tags"), ","),
103+
Priority: priority,
104+
Status: core.Status(get("Status")),
105+
Recurrence: core.RecurrenceType(get("Recurrence")),
106+
RecurrenceWeekly: strings.Split(get("RecurrenceWeekly"), ";"),
107+
RecurrenceMonthly: recurrenceMonthly,
108+
ParentID: get("ParentID"),
109+
Collapsed: get("Collapsed") == "true",
110+
}
111+
112+
// Clean up empty strings from split
113+
if len(t.Tags) == 1 && t.Tags[0] == "" {
114+
t.Tags = nil
115+
}
116+
if len(t.RecurrenceWeekly) == 1 && t.RecurrenceWeekly[0] == "" {
117+
t.RecurrenceWeekly = nil
79118
}
80119

81-
if len(row) > 6 && row[6] != "" {
82-
if dt, err := time.Parse(time.RFC3339, row[6]); err == nil {
120+
if dl := get("Deadline"); dl != "" {
121+
if dt, err := time.Parse(time.RFC3339, dl); err == nil {
83122
t.Deadline = &dt
84123
}
85124
}
86125

126+
if ca := get("CreatedAt"); ca != "" {
127+
if dt, err := time.Parse(time.RFC3339, ca); err == nil {
128+
t.CreatedAt = dt
129+
}
130+
}
131+
132+
if ua := get("UpdatedAt"); ua != "" {
133+
if dt, err := time.Parse(time.RFC3339, ua); err == nil {
134+
t.UpdatedAt = dt
135+
}
136+
}
137+
87138
tasks = append(tasks, t)
88139
}
89140
return tasks, nil

internal/core/codec/markdown.go

Lines changed: 150 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,95 +13,199 @@ import (
1313
)
1414

1515
func 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

66107
func 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

Comments
 (0)