├── go.mod ├── go.sum ├── LICENSE ├── sort.go ├── main.go ├── git-todo └── main.go ├── edit.go ├── acme.go └── task └── task.go /go.mod: -------------------------------------------------------------------------------- 1 | module rsc.io/todo 2 | 3 | go 1.13 4 | 5 | require 9fans.net/go v0.0.1 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | 9fans.net/go v0.0.1 h1:PLKE9jnKK5I/hnQ4hZ0kM92946us4DClpcrzS+RTQZ0= 2 | 9fans.net/go v0.0.1/go.mod h1:lfPdxjq9v8pVQXUMBCx5EO5oLXWQFlKRQgs1kEkjoIM= 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 The Go Authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /sort.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "strings" 9 | 10 | "rsc.io/todo/task" 11 | ) 12 | 13 | func (w *awin) ExecSort(arg string) { 14 | if w.mode != modeList { 15 | w.acme.Err("Sort can only sort task list windows") 16 | return 17 | } 18 | if arg != "" { 19 | w.sortBy = arg 20 | } else if w.sortBy != "" && w.sortBy != "title" { 21 | w.sortBy = "title" 22 | } else { 23 | w.sortBy = "id" 24 | } 25 | 26 | rev := false 27 | by := w.sortBy 28 | if strings.HasPrefix(by, "-") { 29 | rev = true 30 | by = by[1:] 31 | } 32 | var cmp func(string, string) int 33 | if by == "id" { 34 | cmp = func(x, y string) int { 35 | nx := lineNumber(x) 36 | ny := lineNumber(y) 37 | switch { 38 | case nx < ny: 39 | return -1 40 | case nx > ny: 41 | return +1 42 | case x < y: 43 | return -1 44 | case x > y: 45 | return +1 46 | } 47 | return 0 48 | } 49 | } else if by == "title" || by == "" { 50 | cmp = func(x, y string) int { return strings.Compare(skipField(x), skipField(y)) } 51 | } else { 52 | cache := make(map[string]*task.Task) 53 | cachedTask := func(id string) *task.Task { 54 | if t, ok := cache[id]; ok { 55 | return t 56 | } 57 | t, _ := w.list().Read(id) 58 | cache[id] = t 59 | return t 60 | } 61 | cmp = func(x, y string) int { 62 | tx := cachedTask(lineID(x)) 63 | ty := cachedTask(lineID(y)) 64 | if tx != nil && ty != nil { 65 | kx := tx.Header(by) 66 | ky := ty.Header(by) 67 | if kx != ky { 68 | return strings.Compare(kx, ky) 69 | } 70 | } else if tx != nil || ty != nil { 71 | if tx == nil { 72 | return -1 73 | } 74 | return +1 75 | } 76 | return strings.Compare(x, y) 77 | } 78 | } 79 | var less func(x, y string) bool 80 | if rev { 81 | less = func(x, y string) bool { return cmp(x, y) > 0 } 82 | } else { 83 | less = func(x, y string) bool { return cmp(x, y) < 0 } 84 | } 85 | 86 | if err := w.acme.Addr("0/^[0-9a-z_\\-]+\t/,"); err != nil { 87 | w.acme.Err("nothing to sort") 88 | } 89 | if err := w.acme.Sort(less); err != nil { 90 | w.acme.Err(err.Error()) 91 | } 92 | w.acme.Addr("0") 93 | w.acme.Ctl("dot=addr") 94 | w.acme.Ctl("show") 95 | } 96 | 97 | func lineNumber(s string) int { 98 | n := 0 99 | j := 0 100 | for ; j < len(s) && '0' <= s[j] && s[j] <= '9'; j++ { 101 | n = n*10 + int(s[j]-'0') 102 | } 103 | if j < len(s) && s[j] != ' ' && s[j] != '\t' { 104 | return 999999999 105 | } 106 | return n 107 | } 108 | 109 | func lineID(s string) string { 110 | i := strings.Index(s, "\t") 111 | if i < 0 { 112 | return s 113 | } 114 | return s[:i] 115 | } 116 | 117 | func skipField(s string) string { 118 | i := strings.Index(s, "\t") 119 | if i < 0 { 120 | return s 121 | } 122 | for i < len(s) && s[i+1] == '\t' { 123 | i++ 124 | } 125 | return s[i:] 126 | } 127 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | /* 6 | Todo is a command-line and acme client for a to-do task tracking system. 7 | 8 | usage: todo [-a] [-e] [-d subdir] [-done] 9 | 10 | Todo runs the query and prints the maching tasks, one per line. 11 | If the query is a single task number, as in ``todo 1'', todo prints 12 | the full history of the task. 13 | 14 | The -a flag opens the task or query in an acme window. 15 | The -e flag opens the task or query in the system editor. 16 | 17 | The exact acme/editor integration remains undocumented 18 | but is similar to acme mail or to rsc.io/github/issue. 19 | 20 | */ 21 | package main 22 | 23 | import ( 24 | "bytes" 25 | "flag" 26 | "fmt" 27 | "io" 28 | "log" 29 | "os" 30 | "sort" 31 | "strings" 32 | "time" 33 | 34 | "rsc.io/todo/task" 35 | ) 36 | 37 | var ( 38 | acmeFlag = flag.Bool("a", false, "open in new acme window") 39 | editFlag = flag.Bool("e", false, "edit in system editor") 40 | dirFlag = flag.String("d", "", "todo subdirectory") 41 | doneFlag = flag.Bool("done", false, "mark matching todos as done") 42 | ) 43 | 44 | func usage() { 45 | fmt.Fprintf(os.Stderr, `usage: todo [-a] [-e] 46 | 47 | If query is a single task ID, prints the full history for the task. 48 | Otherwise, prints a table of matching results. 49 | `) 50 | flag.PrintDefaults() 51 | os.Exit(2) 52 | } 53 | 54 | func main() { 55 | flag.Usage = usage 56 | flag.Parse() 57 | log.SetFlags(0) 58 | log.SetPrefix("todo: ") 59 | 60 | if flag.NArg() == 0 && !*acmeFlag { 61 | usage() 62 | } 63 | 64 | if *acmeFlag { 65 | runAcme() 66 | } 67 | 68 | q := strings.Join(flag.Args(), " ") 69 | l := taskList(*dirFlag) 70 | 71 | if *editFlag && q == "new" { 72 | editTask(l, []byte(createTemplate), nil) 73 | return 74 | } 75 | 76 | if t, err := l.Read(q); err == nil { 77 | if *editFlag { 78 | var buf bytes.Buffer 79 | issue, err := showTask(&buf, l, q) 80 | if err != nil { 81 | log.Fatal(err) 82 | } 83 | editTask(l, buf.Bytes(), issue) 84 | return 85 | } 86 | if *doneFlag { 87 | if t.Header("todo") != "done" { 88 | err = taskList(*dirFlag).Write(t, time.Now(), map[string]string{"todo": "done"}, nil) 89 | if err != nil { 90 | log.Print(err) 91 | } 92 | } 93 | return 94 | } 95 | if _, err := showTask(os.Stdout, l, q); err != nil { 96 | log.Fatal(err) 97 | } 98 | return 99 | } 100 | 101 | if *editFlag { 102 | all, err := taskList(*dirFlag).Search(q) 103 | if err != nil { 104 | log.Fatal(err) 105 | } 106 | if len(all) == 0 { 107 | log.Fatal("no issues matched search") 108 | } 109 | sort.Sort(tasksByTitle(all)) 110 | bulkEditTasks(l, all) 111 | return 112 | } 113 | 114 | if *doneFlag { 115 | all, err := taskList(*dirFlag).Search(q) 116 | if err != nil { 117 | log.Fatal(err) 118 | } 119 | for _, t := range all { 120 | if t.Header("todo") != "done" { 121 | err = taskList(*dirFlag).Write(t, time.Now(), map[string]string{"todo": "done"}, nil) 122 | if err != nil { 123 | log.Print(err) 124 | } 125 | } 126 | } 127 | return 128 | } 129 | 130 | if err := showQuery(os.Stdout, l, q); err != nil { 131 | log.Fatal(err) 132 | } 133 | } 134 | 135 | var createTemplate = `title: ` + ` 136 | 137 | 138 | 139 | ` 140 | 141 | func showTask(w io.Writer, l *task.List, id string) (*task.Task, error) { 142 | t, err := l.Read(id) 143 | if err != nil { 144 | return nil, err 145 | } 146 | t.PrintTo(w) 147 | return t, nil 148 | } 149 | 150 | func showQuery(w io.Writer, l *task.List, q string) error { 151 | all, err := l.Search(q) 152 | if err != nil { 153 | return err 154 | } 155 | sort.Sort(tasksByTitle(all)) 156 | for _, t := range all { 157 | fmt.Fprintf(w, "%v\t%v\n", t.ID(), t.Title()) 158 | } 159 | return nil 160 | } 161 | 162 | type tasksByTitle []*task.Task 163 | 164 | func (x tasksByTitle) Len() int { return len(x) } 165 | func (x tasksByTitle) Swap(i, j int) { x[i], x[j] = x[j], x[i] } 166 | func (x tasksByTitle) Less(i, j int) bool { 167 | ti := x[i].Title() 168 | tj := x[j].Title() 169 | if ti != tj { 170 | return ti < tj 171 | } 172 | return x[i].ID() < x[j].ID() 173 | } 174 | 175 | type tasksByHeader struct { 176 | key string 177 | list []*task.Task 178 | } 179 | 180 | func (x *tasksByHeader) Len() int { return len(x.list) } 181 | func (x *tasksByHeader) Swap(i, j int) { x.list[i], x.list[j] = x.list[j], x.list[i] } 182 | func (x *tasksByHeader) Less(i, j int) bool { 183 | hi := x.list[i].Header(x.key) 184 | hj := x.list[j].Header(x.key) 185 | if hi != hj { 186 | return hi < hj 187 | } 188 | return x.list[i].ID() < x.list[j].ID() 189 | } 190 | -------------------------------------------------------------------------------- /git-todo/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "flag" 10 | "fmt" 11 | "log" 12 | "os" 13 | "os/exec" 14 | "path/filepath" 15 | "strconv" 16 | "strings" 17 | "time" 18 | 19 | "rsc.io/todo/task" 20 | ) 21 | 22 | func usage() { 23 | fmt.Fprintf(os.Stderr, "usage: git-todo [repo...]\n") 24 | os.Exit(2) 25 | } 26 | 27 | var exit = 0 28 | 29 | func main() { 30 | log.SetPrefix("git-todo: ") 31 | log.SetFlags(0) 32 | flag.Usage = usage 33 | flag.Parse() 34 | args := flag.Args() 35 | if len(args) == 0 { 36 | out, err := exec.Command("git", "rev-parse", "--show-toplevel").CombinedOutput() 37 | if err != nil { 38 | log.Fatalf("git rev-parse --show-toplevel: %v\n%s", out, err) 39 | } 40 | args = []string{strings.TrimSpace(string(out))} 41 | if info, err := os.Stat(args[0]); err != nil { 42 | log.Fatal(err) 43 | } else if !info.IsDir() { 44 | log.Fatalf("%s: not a directory", args[0]) 45 | } 46 | } 47 | 48 | for _, arg := range args { 49 | update(arg) 50 | } 51 | os.Exit(exit) 52 | } 53 | 54 | func update(dir string) { 55 | if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { 56 | log.Printf("%s: not a git root", dir) 57 | exit = 1 58 | return 59 | } 60 | 61 | if err := os.Chdir(dir); err != nil { 62 | log.Print(err) 63 | exit = 1 64 | return 65 | } 66 | 67 | l := task.OpenList(filepath.Join(os.Getenv("HOME"), "/todo/git/", filepath.Base(dir))) 68 | 69 | const numField = 6 70 | out, err := exec.Command("git", "log", "--topo-order", "--format=format:%H%x00%B%x00%s%x00%ct%x00%an <%ae>%x00%cn <%ce>%x00", "--").CombinedOutput() 71 | if err != nil { 72 | log.Printf("%s: git log: %v\n%s", dir, err, out) 73 | exit = 1 74 | return 75 | } 76 | fields := strings.Split(string(out), "\x00") 77 | if len(fields) < numField { 78 | return // nothing pending 79 | } 80 | for i, field := range fields { 81 | fields[i] = strings.TrimLeft(field, "\r\n") 82 | } 83 | Log: 84 | for i := 0; i+numField <= len(fields); i += numField { 85 | hash := fields[i] 86 | message := fields[i+1] 87 | subject := fields[i+2] 88 | unixtime, err := strconv.ParseInt(fields[i+3], 0, 64) 89 | if err != nil { 90 | log.Printf("%s: git log: invalid unix time %s", dir, fields[i+3]) 91 | exit = 1 92 | return 93 | } 94 | tm := time.Unix(unixtime, 0) 95 | author := fields[i+4] 96 | committer := fields[i+5] 97 | 98 | // Shorten hash to 7 digits, like old-school Git. 99 | // We don't need perfect uniqueness: if we collide 100 | // with an older entry, we can let it keep the 7-digit 101 | // prefix and use the 8-digit prefix for the newer commit. 102 | // We should expect about 6 such collisions for 40k commits 103 | // (the current Go repo size, in June 2019), 104 | // and only about 37 collisions for 100k commits, 105 | // which we won't reach for quite a long time. 106 | // 107 | // $ hoc 108 | // func expect(digits, n) { return n * (1 - (1 - 1/16^digits)^(n-1)) } 109 | // expect(7, 4e4) 110 | // 5.959871432077435 111 | // expect(7, 1e5) 112 | // 37.24559263136307 113 | // 114 | var id string 115 | for n := 7; ; n++ { 116 | id = hash[:n] 117 | t, err := l.Read(id) 118 | if err != nil { 119 | break 120 | } 121 | if t.Header("commit") == hash { 122 | // Already have this commit. 123 | continue Log 124 | } 125 | } 126 | 127 | url := "" 128 | for _, line := range strings.Split(message, "\n") { 129 | line = strings.TrimSpace(line) 130 | if strings.HasPrefix(line, "Reviewed-on: ") { 131 | url = strings.TrimSpace(strings.TrimPrefix(line, "Reviewed-on:")) 132 | } 133 | } 134 | 135 | hdr := map[string]string{ 136 | "title": subject, 137 | "commit": hash, 138 | "author": author, 139 | "committer": committer, 140 | } 141 | if url != "" { 142 | hdr["url"] = url 143 | } 144 | body, err := exec.Command("git", "log", "-n1", "--stat", hash).CombinedOutput() 145 | if err != nil { 146 | log.Printf("%s: git log -n1 --stat %s: %v\n%s", dir, hash, err, body) 147 | exit = 1 148 | continue 149 | } 150 | body = append(body, '\n') 151 | 152 | diff, err := exec.Command("git", "show", hash).CombinedOutput() 153 | if err != nil { 154 | log.Printf("%s: git show %s: %v\n%s", dir, hash, err, body) 155 | exit = 1 156 | continue 157 | } 158 | if len(diff) < 32*1024 { 159 | i := bytes.Index(diff, []byte("\ndiff")) 160 | if i >= 0 { 161 | diff = diff[i:] 162 | } 163 | body = append(body, diff...) 164 | } 165 | 166 | _, err = l.Create(id, tm, hdr, body) 167 | if err != nil { 168 | log.Printf("%s: write task: %v", dir, err) 169 | exit = 1 170 | return 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /edit.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "errors" 10 | "fmt" 11 | "io/ioutil" 12 | "log" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "strings" 17 | "sync" 18 | "time" 19 | 20 | "rsc.io/todo/task" 21 | ) 22 | 23 | var taskListCache struct { 24 | sync.Mutex 25 | m map[string]*task.List 26 | } 27 | 28 | func taskList(dir string) *task.List { 29 | dir = filepath.Clean(dir) 30 | 31 | taskListCache.Lock() 32 | defer taskListCache.Unlock() 33 | 34 | if taskListCache.m == nil { 35 | taskListCache.m = make(map[string]*task.List) 36 | } 37 | if list := taskListCache.m[dir]; list != nil { 38 | return list 39 | } 40 | list := task.OpenList(dir) 41 | taskListCache.m[dir] = list 42 | return list 43 | } 44 | 45 | func editTask(l *task.List, original []byte, t *task.Task) { 46 | updated := editText(original) 47 | if bytes.Equal(original, updated) { 48 | log.Print("no changes made") 49 | return 50 | } 51 | 52 | newTask, err := writeTask(l, t, updated, false) 53 | if err != nil { 54 | log.Fatal(err) 55 | } 56 | if newTask != nil { 57 | t = newTask 58 | } 59 | } 60 | 61 | func editText(original []byte) []byte { 62 | f, err := ioutil.TempFile("", "todo-edit-") 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | if err := ioutil.WriteFile(f.Name(), original, 0600); err != nil { 67 | log.Fatal(err) 68 | } 69 | if err := runEditor(f.Name()); err != nil { 70 | log.Fatal(err) 71 | } 72 | updated, err := ioutil.ReadFile(f.Name()) 73 | if err != nil { 74 | log.Fatal(err) 75 | } 76 | name := f.Name() 77 | f.Close() 78 | os.Remove(name) 79 | return updated 80 | } 81 | 82 | func runEditor(filename string) error { 83 | ed := os.Getenv("VISUAL") 84 | if ed == "" { 85 | ed = os.Getenv("EDITOR") 86 | } 87 | if ed == "" { 88 | ed = "ed" 89 | } 90 | 91 | // If the editor contains spaces or other magic shell chars, 92 | // invoke it as a shell command. This lets people have 93 | // environment variables like "EDITOR=emacs -nw". 94 | // The magic list of characters and the idea of running 95 | // sh -c this way is taken from git/run-command.c. 96 | var cmd *exec.Cmd 97 | if strings.ContainsAny(ed, "|&;<>()$`\\\"' \t\n*?[#~=%") { 98 | cmd = exec.Command("sh", "-c", ed+` "$@"`, "$EDITOR", filename) 99 | } else { 100 | cmd = exec.Command(ed, filename) 101 | } 102 | 103 | cmd.Stdin = os.Stdin 104 | cmd.Stdout = os.Stdout 105 | cmd.Stderr = os.Stderr 106 | if err := cmd.Run(); err != nil { 107 | return fmt.Errorf("invoking editor: %v", err) 108 | } 109 | return nil 110 | } 111 | 112 | const bulkHeader = "\n— Bulk editing these tasks:" 113 | 114 | func writeTask(l *task.List, old *task.Task, updated []byte, isBulk bool) (t *task.Task, err error) { 115 | var errbuf bytes.Buffer 116 | defer func() { 117 | if errbuf.Len() > 0 { 118 | err = errors.New(strings.TrimSpace(errbuf.String())) 119 | } 120 | }() 121 | 122 | sdata := string(updated) 123 | hdr := make(map[string]string) 124 | off := 0 125 | for _, line := range strings.SplitAfter(sdata, "\n") { 126 | off += len(line) 127 | line = strings.TrimSpace(line) 128 | if line == "" { 129 | break 130 | } 131 | i := strings.Index(line, ":") 132 | if i < 0 { 133 | fmt.Fprintf(&errbuf, "unknown summary line: %s\n", line) 134 | continue 135 | } 136 | k := strings.TrimSpace(strings.ToLower(line[:i])) 137 | v := strings.TrimSpace(line[i+1:]) 138 | if old == nil || old.Header(k) != v { 139 | hdr[k] = v 140 | } 141 | } 142 | 143 | if errbuf.Len() > 0 { 144 | return nil, nil 145 | } 146 | 147 | if old == nil && isBulk { 148 | // Asking to just sanity check the text parsing. 149 | return nil, nil 150 | } 151 | 152 | if old == nil { 153 | body := strings.TrimSpace(sdata[off:]) 154 | t, err := l.Create(hdr["id"], time.Now(), hdr, []byte(body)) 155 | if err != nil { 156 | fmt.Fprintf(&errbuf, "error creating task: %v\n", err) 157 | return nil, nil 158 | } 159 | return t, nil 160 | } 161 | 162 | marker := "\n— " 163 | var comment string 164 | if i := strings.Index(sdata, marker); i >= off { 165 | comment = strings.TrimSpace(sdata[off:i]) 166 | } 167 | 168 | if comment == "" { 169 | comment = "" 170 | } 171 | 172 | err = l.Write(old, time.Now(), hdr, []byte(comment)) 173 | if err != nil { 174 | fmt.Fprintf(&errbuf, "error updating task: %v\n", err) 175 | } 176 | 177 | return old, nil 178 | } 179 | 180 | func readBulkIDs(l *task.List, text []byte) []string { 181 | var ids []string 182 | for _, line := range strings.Split(string(text), "\n") { 183 | id := line 184 | if i := strings.Index(id, "\t"); i >= 0 { 185 | id = id[:i] 186 | } 187 | if i := strings.Index(id, " "); i >= 0 { 188 | id = id[:i] 189 | } 190 | if l.Exists(id) { 191 | ids = append(ids, id) 192 | } 193 | } 194 | return ids 195 | } 196 | 197 | func bulkEditStartFromText(l *task.List, content []byte) (base *task.Task, original []byte, err error) { 198 | ids := readBulkIDs(l, content) 199 | if len(ids) == 0 { 200 | return nil, nil, fmt.Errorf("found no todos in selection") 201 | } 202 | 203 | var all []*task.Task 204 | for _, id := range ids { 205 | var t *task.Task 206 | t, err = l.Read(id) 207 | if err == nil { 208 | all = append(all, t) 209 | } 210 | } 211 | if len(all) == 0 { 212 | return nil, nil, err 213 | } 214 | 215 | base, original = bulkEditStart(all) 216 | return base, original, nil 217 | } 218 | 219 | func suffix(n int) string { 220 | if n == 1 { 221 | return "" 222 | } 223 | return "s" 224 | } 225 | 226 | func bulkEditTasks(l *task.List, tasks []*task.Task) { 227 | base, original := bulkEditStart(tasks) 228 | updated := editText(original) 229 | if bytes.Equal(original, updated) { 230 | log.Print("no changes made") 231 | return 232 | } 233 | ids, err := bulkWriteTask(l, base, updated, func(s string) { log.Print(s) }) 234 | if err != nil { 235 | errText := strings.Replace(err.Error(), "\n", "\t\n", -1) 236 | if len(ids) > 0 { 237 | log.Fatal("updated %d issue%s with errors:\n\t%v", len(ids), suffix(len(ids)), errText) 238 | } 239 | log.Fatal(errText) 240 | } 241 | log.Printf("updated %d task%s", len(ids), suffix) 242 | } 243 | 244 | func bulkEditStart(tasks []*task.Task) (*task.Task, []byte) { 245 | c := task.Common(tasks) 246 | var buf bytes.Buffer 247 | c.PrintTo(&buf) 248 | fmt.Fprintf(&buf, "\n\n— Bulk editing these tasks:\n\n") 249 | for _, t := range tasks { 250 | fmt.Fprintf(&buf, "%s\t%s\n", t.ID(), t.Title()) 251 | } 252 | return c, buf.Bytes() 253 | } 254 | 255 | func bulkWriteTask(l *task.List, base *task.Task, updated []byte, status func(string)) (ids []string, err error) { 256 | i := bytes.Index(updated, []byte(bulkHeader)) 257 | if i < 0 { 258 | return nil, fmt.Errorf("cannot find bulk edit issue list") 259 | } 260 | ids = readBulkIDs(l, updated[i:]) 261 | if len(ids) == 0 { 262 | return nil, fmt.Errorf("found no todos in bulk edit issue list") 263 | } 264 | 265 | // Check for formatting only. 266 | _, err = writeTask(l, nil, updated, true) 267 | if err != nil { 268 | return nil, err 269 | } 270 | 271 | // Apply to all issues in list. 272 | suffix := "" 273 | if len(ids) != 1 { 274 | suffix = "s" 275 | } 276 | status(fmt.Sprintf("updating %d task%s", len(ids), suffix)) 277 | 278 | failed := false 279 | for _, id := range ids { 280 | t, err := l.Read(id) 281 | if err == nil { 282 | _, err = writeTask(l, t, updated, true) 283 | } 284 | if err != nil { 285 | failed = true 286 | status(fmt.Sprintf("writing %s: %v", id, strings.Replace(err.Error(), "\n", "\n\t", -1))) 287 | continue 288 | } 289 | } 290 | 291 | if failed { 292 | return ids, fmt.Errorf("failed to update all tasks") 293 | } 294 | return ids, nil 295 | } 296 | -------------------------------------------------------------------------------- /acme.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "flag" 11 | "fmt" 12 | "log" 13 | "path" 14 | "strconv" 15 | "strings" 16 | "time" 17 | 18 | "9fans.net/go/acme" 19 | "9fans.net/go/plumb" 20 | "rsc.io/todo/task" 21 | ) 22 | 23 | const root = "/todo/" // acme window root "directory" 24 | 25 | func runAcme() { 26 | acme.AutoExit(true) 27 | 28 | q := strings.Join(flag.Args(), " ") 29 | if q == "" { 30 | q = "all" 31 | } 32 | 33 | l := taskList(".") 34 | if q == "new" { 35 | openNew(l) 36 | } else if look(l, q) { 37 | // done 38 | } else { 39 | openSearch(l, q) 40 | } 41 | 42 | go servePlumb() 43 | select {} 44 | } 45 | 46 | const ( 47 | modeSingle = 1 + iota 48 | modeList 49 | modeCreate 50 | modeBulk 51 | ) 52 | 53 | type awin struct { 54 | acme *acme.Win 55 | name string 56 | tag string 57 | mode int 58 | query string 59 | task *task.Task 60 | sortBy string // "" means "title" 61 | } 62 | 63 | // dir returns the window name's "directory": "/todo/home/" for /todo/home/123. 64 | // It always ends in a slash. 65 | func (w *awin) dir() string { 66 | dir, _ := path.Split(w.name) 67 | if dir == "" { // name has no slashes; should not happen 68 | dir = root 69 | } 70 | return dir 71 | } 72 | 73 | // adir returns the acme window directory for the list l: "/todo/home/" for taskList("home"). 74 | // It always ends in a slash. 75 | func adir(l *task.List) string { 76 | return strings.TrimSuffix(path.Join(root, l.Name()), "/") + "/" 77 | } 78 | 79 | // list returns the task list for the window. 80 | func (w *awin) list() *task.List { 81 | list, _ := path.Split(strings.TrimPrefix(w.name, root)) 82 | if list == "" || list == "/" { 83 | list = "." 84 | } 85 | return taskList(list) 86 | } 87 | 88 | // id returns the window name's id: "123" for /todo/home/123. 89 | func (w *awin) id() string { 90 | _, id := path.Split(w.name) 91 | return id 92 | } 93 | 94 | func open(w *awin) { 95 | var err error 96 | w.acme, err = acme.New() 97 | if err != nil { 98 | log.Printf("creating acme window: %v", err) 99 | time.Sleep(10 * time.Millisecond) 100 | w.acme, err = acme.New() 101 | if err != nil { 102 | log.Fatalf("creating acme window again: %v", err) 103 | } 104 | } 105 | w.acme.SetErrorPrefix(w.dir()) // TODO 106 | w.acme.Name(w.name) 107 | w.acme.Ctl("cleartag") 108 | w.acme.Fprintf("tag", " "+w.tag+" ") 109 | go w.ExecGet() 110 | go w.acme.EventLoop(w) 111 | } 112 | 113 | func openNew(l *task.List) { 114 | open(&awin{ 115 | mode: modeCreate, 116 | name: adir(l) + "new", 117 | tag: "Put Search", 118 | }) 119 | } 120 | 121 | func openTask(l *task.List, id string) { 122 | open(&awin{ 123 | mode: modeSingle, 124 | name: adir(l) + id, 125 | tag: "Get Put Done Look", 126 | }) 127 | } 128 | 129 | func openAll(l *task.List) { 130 | open(&awin{ 131 | mode: modeList, 132 | name: adir(l) + "all", 133 | query: "all", 134 | tag: "New Get Bulk Sort Search", 135 | }) 136 | } 137 | 138 | func openSearch(l *task.List, query string) { 139 | open(&awin{ 140 | mode: modeList, 141 | name: adir(l) + "search", 142 | query: query, 143 | tag: "New Get Bulk Sort Search", 144 | }) 145 | } 146 | 147 | func (w *awin) Execute(line string) bool { 148 | // Exec* methods handle all our comments. 149 | return false 150 | } 151 | 152 | func (w *awin) ExecNew() { 153 | openNew(w.list()) 154 | } 155 | 156 | func (w *awin) ExecSearch(arg string) { 157 | if arg == "" { 158 | w.acme.Err("Search needs an argument") 159 | return 160 | } 161 | openSearch(w.list(), arg) 162 | } 163 | 164 | func (w *awin) ExecGet() (err error) { 165 | // Make long-running Get (for example, network delay) 166 | // easier to understand: blink during load. 167 | // The first blink does not happen until a second has gone by, 168 | // so most Gets won't see any blinking at all. 169 | blinkStop := w.acme.Blink() 170 | defer func() { 171 | blinkStop() 172 | if err != nil { 173 | w.acme.Ctl("dirty") 174 | return 175 | } 176 | w.acme.Ctl("clean") 177 | w.acme.Addr("0") 178 | w.acme.Ctl("dot=addr") 179 | w.acme.Ctl("show") 180 | }() 181 | 182 | switch w.mode { 183 | case modeCreate: 184 | w.acme.Clear() 185 | w.acme.Write("body", []byte(createTemplate)) 186 | 187 | case modeSingle: 188 | var buf bytes.Buffer 189 | t, err := showTask(&buf, w.list(), w.id()) 190 | if err != nil { 191 | return err 192 | } 193 | w.acme.Clear() 194 | w.acme.Write("body", buf.Bytes()) 195 | w.task = t 196 | 197 | case modeList: 198 | var buf bytes.Buffer 199 | err := showQuery(&buf, w.list(), w.query) 200 | if err != nil { 201 | return err 202 | } 203 | w.acme.Clear() 204 | switch w.id() { 205 | case "search": 206 | w.acme.Fprintf("body", "Search %s\n\n", w.query) 207 | 208 | case "all": 209 | var buf bytes.Buffer 210 | for _, name := range w.list().Sublists() { 211 | fmt.Fprintf(&buf, "%s/\n", name) 212 | } 213 | if buf.Len() > 0 { 214 | fmt.Fprintf(&buf, "\n") 215 | w.acme.Write("body", buf.Bytes()) 216 | } 217 | } 218 | w.acme.PrintTabbed(buf.String()) 219 | 220 | case modeBulk: 221 | body, err := w.acme.ReadAll("body") 222 | if err != nil { 223 | return err 224 | } 225 | base, original, err := bulkEditStartFromText(w.list(), body) 226 | if err != nil { 227 | return err 228 | } 229 | w.acme.Clear() 230 | w.acme.PrintTabbed(string(original)) 231 | w.task = base 232 | } 233 | return nil 234 | } 235 | 236 | func (w *awin) Look(text string) bool { 237 | return look(w.list(), text) 238 | } 239 | 240 | func look(l *task.List, text string) bool { 241 | // In multiline look, find all IDs. 242 | if strings.Contains(text, "\n") { 243 | for _, id := range readBulkIDs(l, []byte(text)) { 244 | if acme.Show(adir(l)+id) == nil { 245 | openTask(l, id) 246 | } 247 | } 248 | return true 249 | } 250 | 251 | // Otherwise, expect a single ID relative to the list, 252 | // which may mean switching to a different list. 253 | // A /todo/ prefix is OK to signal the root. 254 | // Do not try to handle a rooted path outside the todo hierarchy, 255 | // like /tmp or ../../tmp. 256 | var list, id string 257 | if strings.HasPrefix(text, "/todo/") { 258 | list = "." 259 | id = strings.TrimPrefix(id, "/todo/") 260 | } else if strings.HasPrefix(text, "/") { 261 | return false 262 | } else { 263 | full := path.Join(l.Name(), text) 264 | if strings.HasPrefix(full, "../") { 265 | return false 266 | } 267 | if task.IsList(full) { 268 | list = full 269 | id = "all" 270 | } else { 271 | list, id = path.Split(path.Join(l.Name(), text)) 272 | if list == "" { 273 | list = "." 274 | } 275 | } 276 | } 277 | if !task.IsList(list) { 278 | return false 279 | } 280 | l = taskList(list) 281 | 282 | if id == "all" { 283 | if acme.Show(adir(l)+"all") == nil { 284 | openAll(l) 285 | } 286 | return true 287 | } 288 | if _, err := l.Read(id); err == nil { 289 | openTask(l, id) 290 | return true 291 | } 292 | return false 293 | } 294 | 295 | func (w *awin) ExecPut() { 296 | stop := w.acme.Blink() 297 | defer stop() 298 | switch w.mode { 299 | case modeSingle, modeCreate: 300 | old := w.task 301 | data, err := w.acme.ReadAll("body") 302 | if err != nil { 303 | w.acme.Err(fmt.Sprintf("Put: %v", err)) 304 | return 305 | } 306 | t, err := writeTask(w.list(), old, data, false) 307 | if err != nil { 308 | w.acme.Err(err.Error()) 309 | return 310 | } 311 | if w.mode == modeCreate { 312 | w.mode = modeSingle 313 | w.name = w.dir() + t.ID() 314 | w.acme.Name(w.name) 315 | w.task = t 316 | } 317 | w.ExecGet() 318 | 319 | case modeBulk: 320 | data, err := w.acme.ReadAll("body") 321 | if err != nil { 322 | w.acme.Err(fmt.Sprintf("Put: %v", err)) 323 | return 324 | } 325 | ids, err := bulkWriteTask(w.list(), w.task, data, func(s string) { w.acme.Err("Put: " + s) }) 326 | if err != nil { 327 | errText := strings.Replace(err.Error(), "\n", "\t\n", -1) 328 | if len(ids) > 0 { 329 | w.acme.Err(fmt.Sprintf("updated %d task%s with errors:\n\t%v", len(ids), suffix(len(ids)), errText)) 330 | break 331 | } 332 | w.acme.Err(fmt.Sprintf("%s", errText)) 333 | break 334 | } 335 | w.acme.Err(fmt.Sprintf("updated %d task%s", len(ids), suffix(len(ids)))) 336 | 337 | case modeList: 338 | w.acme.Err("cannot Put task list") 339 | } 340 | } 341 | 342 | func (w *awin) ExecDel() { 343 | if w.mode == modeList { 344 | w.acme.Ctl("delete") 345 | return 346 | } 347 | w.acme.Ctl("del") 348 | } 349 | 350 | func (w *awin) ExecDebug() { 351 | if w.task != nil { 352 | w.acme.Err(fmt.Sprintf("id=%v ctime=%q mtime=%q", w.task.ID(), w.task.Header("ctime"), w.task.Header("mtime"))) 353 | } 354 | } 355 | 356 | func (w *awin) ExecBulk() { 357 | // TODO(rsc): If Bulk has an argument, treat as search query and use results? 358 | if w.mode != modeList { 359 | w.acme.Err("can only start bulk edit in task list windows") 360 | return 361 | } 362 | text := w.acme.Selection() 363 | if text == "" { 364 | data, err := w.acme.ReadAll("body") 365 | if err != nil { 366 | w.acme.Err(fmt.Sprintf("%v", err)) 367 | return 368 | } 369 | text = string(data) 370 | } 371 | 372 | open(&awin{ 373 | name: w.dir() + "bulkedit", 374 | mode: modeBulk, 375 | tag: "New Get Done Sort Search", 376 | query: "", 377 | }) 378 | } 379 | 380 | func (w *awin) ExecDone() { 381 | w.putHeader("todo: done") 382 | } 383 | 384 | func (w *awin) ExecMute() { 385 | w.putHeader("todo: mute") 386 | } 387 | 388 | func (w *awin) ExecSnooze(arg string) { 389 | days := 1 390 | if arg != "" { 391 | n, err := strconv.Atoi(arg) 392 | if err != nil { 393 | w.acme.Err("Snooze needs numeric day count") 394 | return 395 | } 396 | days = n 397 | } 398 | wakeup := time.Now().Add(time.Duration(days) * 24 * time.Hour).Format("2006-01-02") 399 | w.putHeader("todo: snooze " + wakeup) 400 | } 401 | 402 | func (w *awin) putHeader(hdr string) bool { 403 | if hdr == "" { 404 | return true 405 | } 406 | if !strings.HasSuffix(hdr, "\n") { 407 | hdr += "\n" 408 | } 409 | if w.mode == modeSingle || w.mode == modeBulk { 410 | w.acme.Addr("0") 411 | w.acme.Write("data", []byte(hdr)) 412 | w.ExecPut() 413 | w.acme.Ctl("del") 414 | return true 415 | } 416 | if w.mode == modeList { 417 | text := w.acme.Selection() 418 | if text == "" { 419 | return false 420 | } 421 | base, original, err := bulkEditStartFromText(w.list(), []byte(text)) 422 | if err != nil { 423 | w.acme.Err(fmt.Sprintf("%v", err)) 424 | return true 425 | } 426 | edited := append([]byte(hdr), original...) 427 | bulkWriteTask(w.list(), base, edited, func(s string) { w.acme.Err("Put: " + s) }) 428 | w.acme.Ctl("addr=dot") 429 | w.acme.Write("data", nil) 430 | return true 431 | } 432 | return false 433 | } 434 | 435 | func servePlumb() { 436 | kind := strings.Trim(root, "/") 437 | fid, err := plumb.Open(kind, 0) 438 | if err != nil { 439 | acme.Err(root, fmt.Sprintf("plumb: %v", err)) 440 | return 441 | } 442 | r := bufio.NewReader(fid) 443 | for { 444 | var m plumb.Message 445 | if err := m.Recv(r); err != nil { 446 | acme.Errf(root, "plumb recv: %v", err) 447 | return 448 | } 449 | if m.Type != "text" { 450 | acme.Errf(root, "plumb recv: unexpected type: %s\n", m.Type) 451 | continue 452 | } 453 | if m.Dst != kind { 454 | acme.Errf(root, "plumb recv: unexpected dst: %s\n", m.Dst) 455 | continue 456 | } 457 | // TODO use m.Dir? 458 | data := string(m.Data) 459 | if !strings.HasPrefix(data, root) || strings.Contains(data, "\n") { 460 | acme.Errf(root, "plumb recv: bad text %q", data) 461 | continue 462 | } 463 | if !look(taskList("."), strings.TrimPrefix(data, root)) { 464 | acme.Errf(root, "plumb recv: can't look %s", data) 465 | } 466 | } 467 | } 468 | -------------------------------------------------------------------------------- /task/task.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package task 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "os" 13 | "path/filepath" 14 | "sort" 15 | "strconv" 16 | "strings" 17 | "sync" 18 | "time" 19 | ) 20 | 21 | type Task struct { 22 | file string 23 | id string 24 | hdr map[string]string 25 | body []byte 26 | _id []string 27 | ctime string 28 | mtime string 29 | } 30 | 31 | func (t *Task) ID() string { return t.id } 32 | func (t *Task) Title() string { return t.hdr["title"] } 33 | func (t *Task) Header(key string) string { 34 | switch key { 35 | case "id": 36 | return t.id 37 | case "mtime": 38 | return t.mtime 39 | case "ctime": 40 | return t.ctime 41 | } 42 | return t.hdr[strings.ToLower(key)] 43 | } 44 | 45 | func (t *Task) EIDs() []string { return t._id } 46 | 47 | type List struct { 48 | name string 49 | dir string 50 | mu sync.Mutex 51 | haveAll bool 52 | haveDone bool 53 | cache map[string]*Task 54 | } 55 | 56 | var ( 57 | emSpace = []byte("— ") 58 | spaceEm = []byte(" —") 59 | nl = []byte("\n") 60 | ) 61 | 62 | func isMarker(line []byte) bool { 63 | return bytes.HasPrefix(line, emSpace) && bytes.HasSuffix(line, spaceEm) && len(line) >= 2*len(emSpace) 64 | } 65 | 66 | func dir(name string) string { 67 | return filepath.Join(os.Getenv("HOME"), "todo", name) 68 | } 69 | 70 | func OpenList(name string) *List { 71 | return &List{name: name, dir: dir(name)} 72 | } 73 | 74 | func (l *List) Name() string { 75 | return l.name 76 | } 77 | 78 | func IsList(name string) bool { 79 | info, err := os.Stat(dir(name)) 80 | return err == nil && info.IsDir() 81 | } 82 | 83 | func (l *List) Sublists() []string { 84 | var out []string 85 | infos, _ := ioutil.ReadDir(l.dir) 86 | for _, info := range infos { 87 | name := info.Name() 88 | if !strings.HasPrefix(name, "_") && !strings.HasPrefix(name, ".") && info.IsDir() { 89 | out = append(out, name) 90 | } 91 | } 92 | return out 93 | } 94 | 95 | func (l *List) Exists(id string) bool { 96 | l.mu.Lock() 97 | _, ok := l.cache[id] 98 | l.mu.Unlock() 99 | 100 | if ok { 101 | return true 102 | } 103 | _, err1 := os.Stat(filepath.Join(l.dir, id+".todo")) 104 | _, err2 := os.Stat(filepath.Join(l.dir, id+".done")) 105 | return err1 == nil || err2 == nil 106 | } 107 | 108 | func (l *List) Read(id string) (*Task, error) { 109 | l.mu.Lock() 110 | defer l.mu.Unlock() 111 | return l.read(id) 112 | } 113 | 114 | func (l *List) read(id string) (*Task, error) { 115 | // l is locked 116 | if t := l.cache[id]; t != nil { 117 | return t, nil 118 | } 119 | if l.cache == nil { 120 | l.cache = make(map[string]*Task) 121 | } 122 | 123 | file := filepath.Join(l.dir, id+".todo") 124 | d, err := ioutil.ReadFile(file) 125 | if err != nil { 126 | var err1 error 127 | file = filepath.Join(l.dir, id+".done") 128 | d, err1 = ioutil.ReadFile(file) 129 | if err1 != nil { 130 | return nil, err 131 | } 132 | } 133 | 134 | if !bytes.HasPrefix(d, emSpace) { 135 | return nil, fmt.Errorf("malformed task file") 136 | } 137 | 138 | t := &Task{ 139 | id: id, 140 | file: file, 141 | hdr: make(map[string]string), 142 | body: d, 143 | } 144 | if strings.HasSuffix(file, ".done") { 145 | t.hdr["done"] = "done" 146 | } 147 | 148 | hdr := false 149 | for _, line := range bytes.Split(d, nl) { 150 | if isMarker(line) { 151 | ts := strings.TrimSpace(string(line[len(emSpace) : len(line)-len(emSpace)])) 152 | if t.ctime == "" { 153 | t.ctime = ts 154 | } 155 | t.mtime = ts 156 | hdr = true 157 | continue 158 | } 159 | if len(bytes.TrimSpace(line)) == 0 { 160 | hdr = false 161 | continue 162 | } 163 | if hdr { 164 | i := bytes.IndexByte(line, ':') 165 | if i < 0 { 166 | hdr = false 167 | continue 168 | } 169 | k, v := string(bytes.ToLower(bytes.TrimSpace(line[:i]))), string(bytes.TrimSpace(line[i+1:])) 170 | if k == "#id" { 171 | t._id = append(t._id, v) 172 | continue 173 | } 174 | if strings.HasPrefix(k, "#") { 175 | continue 176 | } 177 | if v == "" { 178 | delete(t.hdr, k) 179 | } else { 180 | t.hdr[k] = v 181 | } 182 | } 183 | } 184 | 185 | l.cache[id] = t 186 | return t, nil 187 | } 188 | 189 | func (t *Task) Done() bool { 190 | switch t.Header("todo") { 191 | case "done", "mute": 192 | return true 193 | } 194 | return false 195 | } 196 | 197 | func (l *List) Write(t *Task, now time.Time, hdr map[string]string, comment []byte) error { 198 | l.mu.Lock() 199 | defer l.mu.Unlock() 200 | 201 | return l.write(t, now, hdr, comment) 202 | } 203 | 204 | func (l *List) write(t *Task, now time.Time, hdr map[string]string, comment []byte) error { 205 | // l is locked 206 | var buf bytes.Buffer 207 | var keys []string 208 | for k := range hdr { 209 | keys = append(keys, k) 210 | } 211 | if t.Done() && t.Header("todo") != "mute" { 212 | if _, ok := hdr["todo"]; !ok { 213 | // Pretend "todo": "" is in hdr, to undo the "todo: done". 214 | // Unless task is muted. 215 | keys = append(keys, "todo") 216 | } 217 | } 218 | sort.Strings(keys) 219 | fmt.Fprintf(&buf, "— %s —\n", now.Local().Format("2006-01-02 15:04:05")) 220 | for _, k := range keys { 221 | fmt.Fprintf(&buf, "%s: %s\n", k, hdr[k]) 222 | } 223 | fmt.Fprintf(&buf, "\n") 224 | buf.Write(comment) 225 | if len(comment) > 0 { 226 | if comment[len(comment)-1] != '\n' { 227 | buf.WriteByte('\n') 228 | } 229 | buf.WriteByte('\n') 230 | } 231 | 232 | f, err := os.OpenFile(t.file, os.O_WRONLY|os.O_APPEND, 0666) 233 | if err != nil { 234 | return err 235 | } 236 | _, err1 := f.Write(buf.Bytes()) 237 | err2 := f.Close() 238 | if err1 != nil { 239 | return err1 240 | } 241 | if err2 != nil { 242 | return err2 243 | } 244 | 245 | // Range keys, not hdr, to pick up todo change. 246 | for _, k := range keys { 247 | v := hdr[k] 248 | if v == "" { 249 | delete(t.hdr, k) 250 | } else { 251 | t.hdr[k] = v 252 | } 253 | } 254 | t.body = append(t.body, buf.Bytes()...) 255 | 256 | if t.Done() != strings.HasSuffix(t.file, ".done") { 257 | base := t.file[:strings.LastIndex(t.file, ".")] 258 | if t.Done() { 259 | if err := os.Rename(base+".todo", base+".done"); err != nil { 260 | return err 261 | } 262 | t.file = base + ".done" 263 | } else { 264 | if err := os.Rename(base+".done", base+".todo"); err != nil { 265 | return err 266 | } 267 | t.file = base + ".todo" 268 | } 269 | } 270 | 271 | return nil 272 | } 273 | 274 | func (l *List) Create(id string, now time.Time, hdr map[string]string, comment []byte) (*Task, error) { 275 | l.mu.Lock() 276 | defer l.mu.Unlock() 277 | 278 | var file string 279 | var f *os.File 280 | if id == "" { 281 | names, err := filepath.Glob(filepath.Join(l.dir, "*.*")) 282 | if err != nil { 283 | return nil, err 284 | } 285 | // TODO cache max 286 | max := 0 287 | for _, name := range names { 288 | if !strings.HasSuffix(name, ".todo") && !strings.HasSuffix(name, ".done") { 289 | continue 290 | } 291 | n, _ := strconv.Atoi(strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))) 292 | if max < n { 293 | max = n 294 | } 295 | } 296 | for try := 0; ; try++ { 297 | id = fmt.Sprintf("%d", max+try+1) 298 | file = filepath.Join(l.dir, id+".todo") 299 | var err error 300 | f, err = os.OpenFile(file, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0666) 301 | if err != nil { 302 | if try >= 2 { 303 | return nil, err 304 | } 305 | continue 306 | } 307 | break 308 | } 309 | } else { 310 | for _, c := range id { 311 | if '0' <= c && c <= '9' || 'a' <= c && c <= 'z' || c == '-' || c == '_' { 312 | continue 313 | } 314 | return nil, fmt.Errorf("invalid task name %q - must be /[0-9a-z_\\-]+/", id) 315 | } 316 | 317 | if l.cache[id] != nil { 318 | return nil, fmt.Errorf("task already exists") 319 | } 320 | file = filepath.Join(l.dir, id+".todo") 321 | var err error 322 | f, err = os.OpenFile(file, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0666) 323 | if err != nil { 324 | return nil, err 325 | } 326 | } 327 | f.Close() 328 | 329 | t := &Task{ 330 | file: file, 331 | id: id, 332 | hdr: make(map[string]string), 333 | } 334 | l.cache[id] = t 335 | 336 | if err := l.write(t, now, hdr, comment); err != nil { 337 | os.Remove(file) 338 | return nil, err 339 | } 340 | return t, nil 341 | } 342 | 343 | func (l *List) readAll(glob string) ([]*Task, error) { 344 | // l is locked 345 | names, err := filepath.Glob(filepath.Join(l.dir, glob)) 346 | if err != nil { 347 | return nil, err 348 | } 349 | var tasks []*Task 350 | for _, name := range names { 351 | t, err := l.read(strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))) 352 | if err != nil { 353 | continue 354 | } 355 | tasks = append(tasks, t) 356 | } 357 | return tasks, nil 358 | } 359 | 360 | func (l *List) All() ([]*Task, error) { 361 | l.mu.Lock() 362 | defer l.mu.Unlock() 363 | 364 | if !l.haveAll { 365 | l.readAll("*.todo") 366 | } 367 | 368 | var list []*Task 369 | for _, t := range l.cache { 370 | if !t.Done() { 371 | list = append(list, t) 372 | } 373 | } 374 | sort.Slice(list, func(i, j int) bool { 375 | return list[i].ID() < list[j].ID() 376 | }) 377 | return list, nil 378 | } 379 | 380 | func (l *List) Done() ([]*Task, error) { 381 | l.mu.Lock() 382 | defer l.mu.Unlock() 383 | 384 | if !l.haveDone { 385 | l.readAll("*.done") 386 | } 387 | 388 | var list []*Task 389 | for _, t := range l.cache { 390 | if t.Done() { 391 | list = append(list, t) 392 | } 393 | } 394 | sort.Slice(list, func(i, j int) bool { 395 | return list[i].ID() < list[j].ID() 396 | }) 397 | return list, nil 398 | } 399 | 400 | func (l *List) Search(q string) ([]*Task, error) { 401 | m, needDone, err := parseQuery(q) 402 | if err != nil { 403 | return nil, err 404 | } 405 | 406 | all, err := l.All() 407 | if err != nil { 408 | return nil, err 409 | } 410 | var done []*Task 411 | if needDone { 412 | done, err = l.Done() 413 | if err != nil { 414 | return nil, err 415 | } 416 | } 417 | 418 | var tasks []*Task 419 | for _, list := range [][]*Task{all, done} { 420 | for _, t := range list { 421 | if m(t) { 422 | tasks = append(tasks, t) 423 | } 424 | } 425 | } 426 | return tasks, nil 427 | } 428 | 429 | func parseQuery(q string) (match func(*Task) bool, needDone bool, err error) { 430 | var ms []func(*Task) bool 431 | applySnooze := true 432 | for _, f := range strings.Fields(q) { 433 | var m func(*Task) bool 434 | neg := false 435 | if strings.HasPrefix(f, "-") { 436 | neg = true 437 | f = f[1:] 438 | } 439 | if f == "all" { 440 | m = func(t *Task) bool { return t.Header("todo") != "done" } 441 | } else if i := strings.Index(f, ":"); i >= 0 { 442 | k := f[:i] 443 | v := f[i+1:] 444 | if k == "todo" && (strings.Contains(v, "done") || strings.Contains(v, "mute")) { 445 | needDone = true 446 | } 447 | if k == "todo" && strings.Contains(v, "snooze") { 448 | applySnooze = false 449 | } 450 | if strings.HasPrefix(v, "<") { 451 | m = func(t *Task) bool { return t.hdr[k] != "" && t.hdr[k] < v[1:] } 452 | } else if strings.HasPrefix(v, ">") { 453 | m = func(t *Task) bool { return t.hdr[k] != "" && t.hdr[k] > v[1:] } 454 | } else if strings.HasPrefix(v, "=") { 455 | m = func(t *Task) bool { return t.hdr[k] == v[1:] } 456 | } else { 457 | m = func(t *Task) bool { return strings.Contains(t.hdr[k], v) } 458 | } 459 | } else { 460 | b := []byte(f) 461 | m = func(t *Task) bool { return bytes.Contains(t.body, b) } 462 | } 463 | if neg { 464 | m1 := m 465 | m = func(t *Task) bool { return !m1(t) } 466 | } 467 | ms = append(ms, m) 468 | } 469 | 470 | if applySnooze { 471 | snoozeTime := "snooze " + time.Now().Format("2006-01-02") 472 | ms = append(ms, func(t *Task) bool { 473 | s := t.Header("todo") 474 | if strings.HasPrefix(s, "snooze ") && s > snoozeTime { 475 | return false 476 | } 477 | return true 478 | }) 479 | } 480 | 481 | m := func(t *Task) bool { 482 | for _, m1 := range ms { 483 | if !m1(t) { 484 | return false 485 | } 486 | } 487 | return true 488 | } 489 | 490 | return m, needDone, nil 491 | } 492 | 493 | var nlEmSpace = []byte("\n— ") 494 | 495 | func (t *Task) PrintTo(w io.Writer) { 496 | var keys []string 497 | for k := range t.hdr { 498 | if k != "title" { 499 | keys = append(keys, k) 500 | } 501 | } 502 | sort.Strings(keys) 503 | 504 | if v, ok := t.hdr["title"]; ok { 505 | fmt.Fprintf(w, "title: %s\n", v) 506 | } 507 | for _, k := range keys { 508 | fmt.Fprintf(w, "%s: %s\n", k, t.hdr[k]) 509 | } 510 | fmt.Fprintf(w, "\n") 511 | 512 | var update [][]byte 513 | start := 0 514 | for { 515 | i := bytes.Index(t.body[start:], nlEmSpace) 516 | if i < 0 { 517 | break 518 | } 519 | update = append(update, t.body[start:start+i+1]) 520 | start += i + 1 521 | } 522 | update = append(update, t.body[start:]) 523 | 524 | for i := len(update) - 1; i >= 0; i-- { 525 | w.Write(update[i]) 526 | } 527 | } 528 | 529 | func Common(tasks []*Task) *Task { 530 | hdr := make(map[string]string) 531 | for i, t := range tasks { 532 | if i == 0 { 533 | for k, v := range t.hdr { 534 | hdr[k] = v 535 | } 536 | } else { 537 | for k, v := range hdr { 538 | if t.hdr[k] != v { 539 | delete(hdr, k) 540 | } 541 | } 542 | } 543 | } 544 | return &Task{hdr: hdr} 545 | } 546 | --------------------------------------------------------------------------------