├── .gitignore ├── go.mod ├── LICENSE ├── libzk ├── utils.go ├── zk_test.go └── zk.go ├── README.md └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | zk 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/floren/zk 2 | 3 | go 1.13 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 John Floren. 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 5 | are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 15 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 16 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 17 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 18 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 19 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 20 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 21 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 22 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 23 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /libzk/utils.go: -------------------------------------------------------------------------------- 1 | package zk 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "strconv" 12 | ) 13 | 14 | func (z *ZK) readState() (err error) { 15 | p := filepath.Join(z.root, "state") 16 | fd, err := os.OpenFile(p, os.O_RDWR, 0700) 17 | if err != nil { 18 | return 19 | } 20 | defer fd.Close() 21 | 22 | dec := json.NewDecoder(fd) 23 | err = dec.Decode(&z.state) 24 | if err != nil { 25 | // If we couldn't decode the state, we need to try and derive it 26 | z.state, err = z.deriveState() 27 | if err != nil { 28 | err = fmt.Errorf("couldn't parse state or derive it: %v", err) 29 | } 30 | } 31 | // belt and suspenders time 32 | if z.state.Aliases == nil { 33 | z.state.Aliases = map[string]int{} 34 | } 35 | if z.state.Notes == nil { 36 | z.state.Notes = map[int]NoteMeta{} 37 | } 38 | return 39 | } 40 | 41 | func (z *ZK) deriveState() (state zkState, err error) { 42 | state.Notes = make(map[int]NoteMeta) 43 | // Stat the directory 44 | var contents []os.FileInfo 45 | contents, err = ioutil.ReadDir(z.root) 46 | if err != nil { 47 | return 48 | } 49 | 50 | // Walk each numeric subdirectory and see if we can extract their info 51 | for i := range contents { 52 | if contents[i].IsDir() { 53 | if id, err := strconv.Atoi(contents[i].Name()); err == nil { 54 | var note Note 55 | note, err = z.readNote(id) 56 | if err != nil { 57 | // oh well 58 | continue 59 | } 60 | state.Notes[id] = note.NoteMeta 61 | if id >= state.NextNoteId { 62 | state.NextNoteId = id + 1 63 | } 64 | } 65 | } 66 | } 67 | return 68 | } 69 | 70 | func (z *ZK) readNoteMetadata(id int) (meta NoteMeta, err error) { 71 | noteRoot := filepath.Join(z.root, fmt.Sprintf("%d", id)) 72 | metaPath := filepath.Join(noteRoot, "metadata") 73 | fd, err := os.OpenFile(metaPath, os.O_RDWR, 0755) 74 | if err != nil { 75 | return meta, err 76 | } 77 | defer fd.Close() 78 | 79 | dec := json.NewDecoder(fd) 80 | err = dec.Decode(&meta) 81 | if err != nil && err != io.EOF { 82 | return meta, fmt.Errorf("failure parsing state file: %v", err) 83 | } 84 | 85 | files, err := ioutil.ReadDir(filepath.Join(noteRoot, "files")) 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | fileLoop: 90 | for _, f := range files { 91 | for i := range meta.Files { 92 | if meta.Files[i] == f.Name() { 93 | continue fileLoop 94 | } 95 | meta.Files = append(meta.Files, f.Name()) 96 | } 97 | } 98 | 99 | return meta, nil 100 | } 101 | 102 | // infers where to write based on the metadata 103 | func (z *ZK) writeNoteMetadata(meta NoteMeta) error { 104 | p := filepath.Join(z.root, fmt.Sprintf("%d", meta.Id), "metadata") 105 | fd, err := os.OpenFile(p, os.O_RDWR|os.O_CREATE, 0755) 106 | if err != nil { 107 | return err 108 | } 109 | defer fd.Close() 110 | 111 | fd.Truncate(0) 112 | fd.Seek(0, 0) 113 | enc := json.NewEncoder(fd) 114 | enc.Encode(meta) 115 | fd.Sync() 116 | return nil 117 | } 118 | 119 | func (z *ZK) writeState() (err error) { 120 | p := filepath.Join(z.root, "state") 121 | fd, err := os.OpenFile(p, os.O_RDWR|os.O_CREATE, 0755) 122 | if err != nil { 123 | err = fmt.Errorf("Failed to open state file: %v", err) 124 | return 125 | } 126 | defer fd.Close() 127 | 128 | fd.Truncate(0) 129 | fd.Seek(0, 0) 130 | enc := json.NewEncoder(fd) 131 | err = enc.Encode(z.state) 132 | if err != nil { 133 | err = fmt.Errorf("Failure marshalling to state file: %v", err) 134 | } 135 | fd.Sync() 136 | return 137 | } 138 | -------------------------------------------------------------------------------- /libzk/zk_test.go: -------------------------------------------------------------------------------- 1 | package zk 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestNewZK(t *testing.T) { 10 | dir, err := ioutil.TempDir("", "zk") 11 | if err != nil { 12 | t.Fatal(err) 13 | } 14 | defer os.RemoveAll(dir) 15 | 16 | if err := InitZK(dir); err != nil { 17 | t.Fatal(err) 18 | } 19 | if _, err := NewZK(dir); err != nil { 20 | t.Fatal(err) 21 | } 22 | } 23 | 24 | func TestNewNote(t *testing.T) { 25 | var err error 26 | dir, err := ioutil.TempDir("", "zk") 27 | if err != nil { 28 | t.Fatal(err) 29 | } 30 | defer os.RemoveAll(dir) 31 | 32 | if err = InitZK(dir); err != nil { 33 | t.Fatal(err) 34 | } 35 | var z *ZK 36 | if z, err = NewZK(dir); err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | // Create a note 41 | if _, err = z.NewNote(0, "Testing\n"); err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | // Now make sure it is listed as a child of note 0 46 | var md NoteMeta 47 | if md, err = z.GetNoteMeta(0); err != nil { 48 | t.Fatal(err) 49 | } 50 | if len(md.Subnotes) != 1 { 51 | t.Fatalf("Wrong number of subnotes on note 0, got %d should be 1", len(md.Subnotes)) 52 | } 53 | 54 | // And make sure the new note (guaranteed to be id 1) is ok 55 | if md, err = z.GetNoteMeta(1); err != nil { 56 | t.Fatal(err) 57 | } 58 | } 59 | 60 | func TestUpdateNote(t *testing.T) { 61 | var err error 62 | dir, err := ioutil.TempDir("", "zk") 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | defer os.RemoveAll(dir) 67 | 68 | if err = InitZK(dir); err != nil { 69 | t.Fatal(err) 70 | } 71 | var z *ZK 72 | if z, err = NewZK(dir); err != nil { 73 | t.Fatal(err) 74 | } 75 | 76 | // Create a note 77 | if _, err = z.NewNote(0, "Testing\n"); err != nil { 78 | t.Fatal(err) 79 | } 80 | 81 | // Now update it, check title 82 | newBody := string("Foo\nLong live the new flesh") 83 | if err = z.UpdateNote(1, newBody); err != nil { 84 | t.Fatal(err) 85 | } 86 | var md Note 87 | if md, err = z.GetNote(1); err != nil { 88 | t.Fatal(err) 89 | } 90 | if md.Title != "Foo" { 91 | t.Fatalf("Invalid title on note: %v", md.Title) 92 | } 93 | if md.Body != newBody { 94 | t.Fatalf("Invalid note body: %v", md.Body) 95 | } 96 | } 97 | 98 | func TestLinkNote(t *testing.T) { 99 | var err error 100 | dir, err := ioutil.TempDir("", "zk") 101 | if err != nil { 102 | t.Fatal(err) 103 | } 104 | defer os.RemoveAll(dir) 105 | 106 | if err = InitZK(dir); err != nil { 107 | t.Fatal(err) 108 | } 109 | var z *ZK 110 | if z, err = NewZK(dir); err != nil { 111 | t.Fatal(err) 112 | } 113 | 114 | // Create a note 115 | if _, err = z.NewNote(0, "Testing\n"); err != nil { 116 | t.Fatal(err) 117 | } 118 | 119 | // And then make a child of that note 120 | if _, err = z.NewNote(1, "Child note\n"); err != nil { 121 | t.Fatal(err) 122 | } 123 | 124 | // Now link the new note (id will be 2) to note 0 125 | if err = z.LinkNote(0, 2); err != nil { 126 | t.Fatal(err) 127 | } 128 | 129 | // Verify that it's listed as a child of both 0 and 1 130 | var md NoteMeta 131 | if md, err = z.GetNoteMeta(0); err != nil { 132 | t.Fatal(err) 133 | } 134 | if !containsSubnote(md, 2) { 135 | t.Fatalf("Note 0 doesn't contain new note as subnote") 136 | } 137 | if md, err = z.GetNoteMeta(1); err != nil { 138 | t.Fatal(err) 139 | } 140 | if !containsSubnote(md, 2) { 141 | t.Fatalf("Note 1 doesn't contain new note as subnote") 142 | } 143 | 144 | } 145 | 146 | func TestUnlinkNote(t *testing.T) { 147 | var err error 148 | dir, err := ioutil.TempDir("", "zk") 149 | if err != nil { 150 | t.Fatal(err) 151 | } 152 | defer os.RemoveAll(dir) 153 | 154 | if err = InitZK(dir); err != nil { 155 | t.Fatal(err) 156 | } 157 | var z *ZK 158 | if z, err = NewZK(dir); err != nil { 159 | t.Fatal(err) 160 | } 161 | 162 | // Create a new note. Its ID will be 1. 163 | if _, err = z.NewNote(0, "Testing\n"); err != nil { 164 | t.Fatal(err) 165 | } 166 | 167 | // And then make a child of that note 168 | if _, err = z.NewNote(1, "Child note\n"); err != nil { 169 | t.Fatal(err) 170 | } 171 | 172 | // Now link the new note (id will be 2) to note 0 173 | // Tree now looks like this: 174 | // 0 175 | // 1 176 | // 2 177 | // 2 178 | if err = z.LinkNote(0, 2); err != nil { 179 | t.Fatal(err) 180 | } 181 | 182 | // Verify that it's listed as a child of 0 183 | var md NoteMeta 184 | if md, err = z.GetNoteMeta(0); err != nil { 185 | t.Fatal(err) 186 | } 187 | if !containsSubnote(md, 2) { 188 | t.Fatalf("Note 0 doesn't contain new note as subnote") 189 | } 190 | 191 | // Unlink it from 0... 192 | if err = z.UnlinkNote(0, 2); err != nil { 193 | t.Fatal(err) 194 | } 195 | if md, err = z.GetNoteMeta(0); err != nil { 196 | t.Fatal(err) 197 | } 198 | if containsSubnote(md, 2) { 199 | t.Fatalf("Note 0 still improperly contains note 2: %+v", md) 200 | } 201 | 202 | // Now we'll also unlink note 2 from note 1, orphaning it. 203 | if err = z.UnlinkNote(1, 2); err != nil { 204 | t.Fatal(err) 205 | } 206 | // Make sure it comes back in the list of orphans 207 | if orphans := z.GetOrphans(); len(orphans) != 1 { 208 | t.Fatalf("Got wrong number of orphans, expected 1 got %v (list: %v)", len(orphans), orphans) 209 | } else if orphans[0].Id != 2 { 210 | t.Fatalf("Got the wrong orphan back: %v", orphans[0]) 211 | } 212 | } 213 | 214 | func containsSubnote(md NoteMeta, id int) bool { 215 | for _, c := range md.Subnotes { 216 | if c == id { 217 | return true 218 | } 219 | } 220 | return false 221 | } 222 | 223 | func TestFiles(t *testing.T) { 224 | var err error 225 | dir, err := ioutil.TempDir("", "zk") 226 | if err != nil { 227 | t.Fatal(err) 228 | } 229 | defer os.RemoveAll(dir) 230 | 231 | if err = InitZK(dir); err != nil { 232 | t.Fatal(err) 233 | } 234 | var z *ZK 235 | if z, err = NewZK(dir); err != nil { 236 | t.Fatal(err) 237 | } 238 | 239 | // Create a note 240 | if _, err = z.NewNote(0, "Testing\n"); err != nil { 241 | t.Fatal(err) 242 | } 243 | 244 | // Make a temp file 245 | f, err := ioutil.TempFile("", "foo") 246 | if err != nil { 247 | t.Fatal(err) 248 | } 249 | defer os.Remove(f.Name()) 250 | 251 | if err = z.AddFile(1, f.Name(), "foo"); err != nil { 252 | t.Fatal(err) 253 | } 254 | 255 | // Now make sure it actually got added 256 | var md NoteMeta 257 | if md, err = z.GetNoteMeta(1); err != nil { 258 | t.Fatal(err) 259 | } 260 | if len(md.Files) != 1 && md.Files[0] != "foo" { 261 | t.Fatalf("Got bad files list: %v", md.Files) 262 | } 263 | 264 | // Check that we can get a path to it 265 | if _, err := z.GetFilePath(1, "foo"); err != nil { 266 | t.Fatalf("Couldn't get path to file: %v", err) 267 | } 268 | 269 | // And remove it 270 | if err = z.RemoveFile(1, "foo"); err != nil { 271 | t.Fatal(err) 272 | } 273 | 274 | // Now make sure it actually got removed 275 | if md, err = z.GetNoteMeta(1); err != nil { 276 | t.Fatal(err) 277 | } 278 | for _, f := range md.Files { 279 | if f == "foo" { 280 | t.Fatal("File still exists!") 281 | } 282 | } 283 | } 284 | 285 | func TestGrep(t *testing.T) { 286 | var err error 287 | dir, err := ioutil.TempDir("", "zk") 288 | if err != nil { 289 | t.Fatal(err) 290 | } 291 | defer os.RemoveAll(dir) 292 | 293 | if err = InitZK(dir); err != nil { 294 | t.Fatal(err) 295 | } 296 | var z *ZK 297 | if z, err = NewZK(dir); err != nil { 298 | t.Fatal(err) 299 | } 300 | 301 | // Create a note 302 | body := `Test note title xyzzy 303 | This is the note. Not every line contains a match. 304 | There are three lines which will match a regex that consists of an x, followed by some non-space chars, followed by a y, and this is not one. 305 | But this line matches: x12y 306 | And xFFFF*y matches too, as does the title.` 307 | if _, err = z.NewNote(0, body); err != nil { 308 | t.Fatal(err) 309 | } 310 | 311 | // Now do a grep 312 | var r chan *GrepResult 313 | if r, err = z.Grep(`x\S+y`, []int{}); err != nil { 314 | t.Fatal(err) 315 | } 316 | var count int 317 | for _ = range r { 318 | count++ 319 | } 320 | if count != 3 { 321 | t.Fatalf("Got bad results, expected 3 got %v\n", count) 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zk: fast CLI note-taking 2 | 3 | 'zk' is a tool for making and organizing hierarchical notes at the command line. It tries to stay out of your way; I wrote it because the only way I'll take notes at all is if it's as easy as possible. 4 | 5 | Every note gets a unique ID assigned at creation time. Notes are organized in a tree, starting with note 0 at the root (note 0 is created automatically when you run `zk init`). 6 | 7 | zk remembers which note you've been working in. When you run a command, zk will act on the current note if no other note id is specified. 8 | 9 | The code to interact with zk's file structure is extracted into a library, [libzk](https://pkg.go.dev/github.com/floren/zk/libzk). This means if you hate the default CLI interface, you can write your own. 10 | 11 | ## Commands 12 | 13 | Commands in zk can typically be abbreviated to a single letter. zk offers the following commands: 14 | 15 | ### Browsing & Viewing Notes 16 | 17 | * `show` (`s`): show the current note's title and any subnote titles. If a note id is given as an argument, it will show that note instead. 18 | * ``: set the current note to the given id 19 | * `up` (`u`): move up one level in the tree. If a note is linked into multiple places, this may be confusing! 20 | * `print` (`p`): print out the current note or the specified note ID. 21 | * `tree` (`t`): show the full note tree from the root (0) or from the specified ID. 22 | * `grep`: find notes containing the specified regular expression, e.g. `zk grep foo` or `zk grep "foo.+bar"`. 23 | * `tgrep`: file notes containing the specified regular expression under the current or specified note, e.g. `zk tgrep 17 foobar` to find "foobar" in note 17 or its sub-notes. 24 | 25 | Running `zk` with no arguments will list the title of the current note and its immediate sub-notes. 26 | 27 | ### Creating and Editing Notes 28 | * `new` (`n`): create a new note under the current note or under the specified note ID. zk will prompt you for a title and any additional text you want to enter into the note at this time. 29 | * `edit` (`e`): edit the current note (or specify a note id as an argument to edit a different one). Uses the $EDITOR variable to determine which editor to run. 30 | * `append` (`a`): append to the current note (or specified note id). Reads from standard input. 31 | * `link`: link a note as a sub-note of another. `zk link 22 3` will make note 22 a sub-note of note 3. `zk link 22` will make note 22 a sub-note of the *current* note. 32 | * `unlink`: unlink a sub-note from the current note, e.g. `zk unlink 22`. As with the link command, `zk unlink 22 3` will *remove* 22 as a sub-note of note 3. 33 | 34 | ### Aliases 35 | * `alias`: define a new alias, a human-friendly name for a particular note, e.g. `zk alias 7 todo`; you can then use "todo" in place of "7" in future commands. 36 | * `unalias`: remove an alias, e.g. `zk unalias todo`. 37 | * `aliases`: list existing aliases. 38 | 39 | ### Misc. 40 | * `init`: takes a file path as an argument, sets up a zk in that directory. If the directory already contains zk files, simply sets that as the new default. 41 | * `orphans`: list notes with no parents (excluding note 0). Unlinking a note from the tree entirely makes it an "orphan" and hides it; this lets you see what has been orphaned. 42 | * `rescan`: attempts to re-derive the state from the contents of the zk directory. Sometimes you'll need to run this if you've changed the title (the first line) of a note. 43 | 44 | ## Installation and setup 45 | 46 | Fetch and build the code; make sure $GOPATH/bin is in your path! 47 | 48 | go install github.com/floren/zk@latest 49 | 50 | Initialize your zk directory: 51 | 52 | zk init ~/zk 53 | 54 | ## User Guide / Examples 55 | 56 | After running zk init, you'll have a top-level note and nothing else. We can see the current state like so: 57 | 58 | $ zk 59 | 0 Top Level 60 | 61 | We'll create a new note which will contain all information about my Go projects: 62 | 63 | $ zk n 64 | Enter note; the first line will be the title. Ctrl-D when done. 65 | Go hacking 66 | Notes about my Go programming stuff goes under this 67 | ^D 68 | 69 | The very first line I entered, "Go hacking", becomes the note title; the remainder is body. If we run 'zk' again, we'll see the new note: 70 | 71 | $ zk 72 | 0 Top Level 73 | 1 Go hacking 74 | 75 | Note 0 is still the current note; we can set the current note to the new note like so: 76 | 77 | $ zk 1 78 | 1 Go hacking 79 | 80 | We can now create a new note in this Go category: 81 | 82 | $ zk n 83 | Enter note; the first line will be the title. Ctrl-D when done. 84 | zk 85 | 86 | Notes about my note-taking system (so meta) 87 | 88 | $ zk 89 | 1 Go hacking 90 | 2 zk 91 | 92 | The "up" command (abbreviated 'u') takes you up one level in the tree, or you can specify a note ID number directly to go to it: 93 | 94 | $ zk u 95 | 0 Top Level 96 | 1 Go hacking 97 | 98 | $ zk 2 99 | 2 zk 100 | 101 | $ zk u 102 | 1 Go hacking 103 | 2 zk 104 | 105 | $ zk u 106 | 0 Top Level 107 | 1 Go hacking 108 | 109 | $ zk 1 110 | 1 Go hacking 111 | 2 zk 112 | 113 | $ zk 0 114 | 0 Top Level 115 | 1 Go hacking 116 | 117 | I'll create another note under the top-level, make another note under *that* note, then use the 'tree' command to see all notes: 118 | 119 | $ zk n 0 120 | Enter note; the first line will be the title. Ctrl-D when done. 121 | Foo 122 | 123 | $ zk 124 | 0 Top Level 125 | 1 Go hacking 126 | 3 Foo 127 | 128 | $ zk 3 129 | 3 Foo 130 | 131 | $ zk n 132 | Enter note; the first line will be the title. Ctrl-D when done. 133 | Bar 134 | 135 | $ zk t 136 | 0 Top Level 137 | 1 Go hacking 138 | 2 zk 139 | 3 Foo 140 | 4 Bar 141 | 142 | Notes are never deleted, because a note can appear as the child of multiple other notes; deleting the actual file would leave them hanging. It is, however, possible to 'unlink' a child from the current note so it will not appear any more. This makes it an "orphan"; use `zk orphans` to list orphaned notes. 143 | 144 | ### Linking 145 | 146 | A note can appear at multiple places in the tree. Suppose I have a tree that looks like this: 147 | 148 | $ zk t 149 | 0 Top Level 150 | 1 Go hacking 151 | 2 zk 152 | 3 Personal Projects 153 | 4 Bellwether mouse 154 | 155 | Since `zk` is a personal project, I'd also like it to appear as a child of note 3, so I use the `link` command: 156 | 157 | $ zk link 2 3 158 | $ zk t 159 | 0 Top Level 160 | 1 Go hacking 161 | 2 zk 162 | 3 Personal Projects 163 | 4 Bellwether mouse 164 | 2 zk 165 | 166 | If I decide that in fact, `zk` is so much a personal project that I don't even want to see it under "Go hacking" any more, I can use the `unlink` command: 167 | 168 | $ zk unlink 2 1 169 | $ zk t 170 | 0 Top Level 171 | 1 Go hacking 172 | 3 Personal Projects 173 | 4 Bellwether mouse 174 | 2 zk 175 | 176 | Perhaps I now realize that "Go hacking" is a silly category to have; if I unlink note 1 from note 0, it will be completely unlinked ("orphaned"). It will no longer appear in the tree. 177 | 178 | $ zk unlink 1 0 179 | $ zk t 180 | 0 Top Level 181 | 3 Personal Projects 182 | 4 Bellwether mouse 183 | 2 zk 184 | $ zk orphans 185 | 1 Go hacking 186 | 187 | There are several advantages to unlinking notes rather than deleting them: 188 | 189 | - I can refer to "note 1" in other notes and still view it at any time, because it still exists. 190 | - I can reinstate it into the tree whenever I wish with a `link` command. 191 | - It will still be included in `zk grep` results (you can `zk tgrep 0 ` to restrict the search to only nodes actually in the tree) 192 | 193 | ### Aliases 194 | 195 | Aliases let you assign human-friendly names to particular notes. Suppose I have a note where I keep a list of tasks to be done: 196 | 197 | $ zk p 5 198 | TODO 199 | 200 | * TODO update readme 201 | 202 | The number 5 isn't particularly conducive to memory, so I use the `alias` command to give it another name, "todo": 203 | 204 | $ zk alias 5 todo 205 | $ zk aliases 206 | todo → 5 TODO 207 | 208 | I can then use the string "todo" anywhere I would have referred to the note by number: 209 | 210 | john@frodo:~/hacking/zk$ zk append todo 211 | Ctrl-D when done. 212 | * TODO buy milk 213 | 214 | ## Development 215 | 216 | The implementation of zk is split out into a library, [libzk](https://pkg.go.dev/github.com/floren/zk/libzk). You first init an (empty) directory: 217 | 218 | ``` 219 | // This will create a default top-level note 0 220 | libzk.InitZK("/path/to/rootdir") 221 | ``` 222 | 223 | Then you can call NewZK and use it: 224 | 225 | ``` 226 | var z *ZK 227 | if z, err = NewZK("/path/to/rootdir"); err != nil { 228 | log.Fatal(err) 229 | } 230 | 231 | // Create a note as a sub-note of note 0 232 | if err = z.NewNote(0, "Testing\n"); err != nil { 233 | log.Fatal(err) 234 | } 235 | 236 | // Now make sure it is listed as a child of note 0 237 | var md NoteMeta 238 | if md, err = z.GetNoteMeta(0); err != nil { 239 | log.Fatal(err) 240 | } 241 | if len(md.Subnotes) != 1 { 242 | log.Fatalf("Wrong number of subnotes on note 0, got %d should be 1", len(md.Subnotes)) 243 | } 244 | ``` 245 | 246 | ## Internals 247 | 248 | Notes are stored in numeric directories within your zk dir: 249 | 250 | $ ls ~/zk 251 | 0/ 1/ 2/ 3/ 4/ 5/ state 252 | 253 | Each note is itself a directory, containing the `body` file, the `metadata` file, and a directory named `files` containing any files you have linked with the note (experimental feature). 254 | 255 | $ ls ~/zk/3 256 | body files metadata 257 | 258 | The metadata file is JSON formatted: 259 | 260 | {"Id":3,"Title":"Personal Projects","Subnotes":[4,2],"Files":[],"Parent":0} 261 | 262 | Each note has one "canonical" parent. This only comes into play with using the `zk up` command, and it faces the same issues as `cd ..` does in Unix when dealing with symlinks. 263 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /************************************************************************* 2 | * Copyright 2017 John Floren. All rights reserved. 3 | * Contact: 4 | * 5 | * This software may be modified and distributed under the terms of the 6 | * BSD 2-clause license. See the LICENSE file for details. 7 | **************************************************************************/ 8 | 9 | package main 10 | 11 | import ( 12 | "encoding/json" 13 | "flag" 14 | "fmt" 15 | "log" 16 | "os" 17 | "os/exec" 18 | "path/filepath" 19 | "sort" 20 | "strings" 21 | 22 | zk "github.com/floren/zk/libzk" 23 | "io" 24 | ) 25 | 26 | var ( 27 | configFile = flag.String("config", "", "Path to alternate config file") 28 | 29 | cfg Config 30 | z *zk.ZK 31 | ) 32 | 33 | type Config struct { 34 | ZKRoot string 35 | CurrentNoteId int 36 | } 37 | 38 | func defaultConfigPath() (string, error) { 39 | configDir, err := os.UserConfigDir() 40 | if err != nil { 41 | return "", err 42 | } 43 | pth := filepath.Join(configDir, "zk") 44 | if err = os.MkdirAll(pth, 0755); err != nil { 45 | return "", err 46 | } 47 | pth = filepath.Join(pth, "zkconfig") 48 | return pth, nil 49 | } 50 | 51 | func writeConfig() error { 52 | var pth string 53 | var err error 54 | if *configFile != `` { 55 | pth = *configFile 56 | } else { 57 | // default 58 | if pth, err = defaultConfigPath(); err != nil { 59 | return fmt.Errorf("something wrong with config path: %v", err) 60 | } 61 | } 62 | var b []byte 63 | b, err = json.Marshal(cfg) 64 | if err != nil { 65 | return fmt.Errorf("JSON marshal failure: %v", err) 66 | } 67 | return os.WriteFile(pth, b, 0600) 68 | } 69 | 70 | func readConfig() error { 71 | // Build config path 72 | var pth string 73 | var err error 74 | if *configFile != `` { 75 | pth = *configFile 76 | } else { 77 | // default 78 | if pth, err = defaultConfigPath(); err != nil { 79 | return err 80 | } 81 | // If it doesn't exist, make it 82 | if _, err := os.Stat(pth); os.IsNotExist(err) { 83 | writeConfig() 84 | } 85 | } 86 | // Now read it out 87 | contents, err := os.ReadFile(pth) 88 | if err != nil { 89 | return err 90 | } 91 | return json.Unmarshal(contents, &cfg) 92 | } 93 | 94 | func main() { 95 | var err error 96 | flag.Parse() 97 | 98 | // All commands take the form "zk " 99 | // Specifying simply "zk" should show the current note's summary & subnotes 100 | var cmd string 101 | if len(flag.Args()) > 0 { 102 | cmd = flag.Arg(0) 103 | } 104 | var args []string 105 | if len(flag.Args()) > 1 { 106 | args = flag.Args()[1:] 107 | } 108 | 109 | // If the command was "init", we actually handle that *before* reading the 110 | // config file, because we're going to re-write a new config. 111 | if cmd == "init" { 112 | // Make sure we have a single argument 113 | if len(args) != 1 { 114 | log.Fatalf("Usage: zk init ") 115 | } 116 | root := args[0] 117 | // First we attempt to open an existing ZK if it's pre-populated 118 | if z, err = zk.NewZK(root); err != nil { 119 | // NewZK failed, we better call init 120 | if err := zk.InitZK(root); err != nil { 121 | // If both calls failed, something bad has happened 122 | log.Fatalf("Couldn't initialize new zk: %v", err) 123 | } 124 | } 125 | // If we got this far, one of the calls succeeded. 126 | cfg.ZKRoot = root 127 | if err := writeConfig(); err != nil { 128 | log.Fatalf("Couldn't write-back config: %v", err) 129 | } 130 | return 131 | } 132 | 133 | if err := readConfig(); err != nil { 134 | log.Fatalf("Failed to read config: %v", err) 135 | } 136 | 137 | if z, err = zk.NewZK(cfg.ZKRoot); err != nil { 138 | log.Fatal(err) 139 | } 140 | defer z.Close() 141 | 142 | switch cmd { 143 | case "show", "s": 144 | showNote(args) 145 | case "new", "n": 146 | newNote(args) 147 | case "up", "u": 148 | if cfg.CurrentNoteId != 0 { 149 | if md, err := z.GetNoteMeta(cfg.CurrentNoteId); err != nil { 150 | log.Fatalf("Couldn't get info about current note: %v", err) 151 | } else { 152 | changeLevel(md.Parent) 153 | showNote([]string{}) 154 | } 155 | } 156 | case "edit", "e": 157 | editNote(args) 158 | case "append", "a": 159 | appendNote(args) 160 | case "print", "p": 161 | printNote(args) 162 | case "tree", "t": 163 | printTree(args) 164 | case "link": 165 | linkNote(args) 166 | case "unlink": 167 | unlinkNote(args) 168 | case "addfile": 169 | addFile(args) 170 | case "listfiles", "ls": 171 | listFiles(args) 172 | case "grep": 173 | grep(args) 174 | case "tgrep": 175 | tgrep(args) 176 | case "rescan": 177 | z.Rescan() 178 | case "orphans": 179 | orphans(args) 180 | case "alias": 181 | alias(args) 182 | case "unalias": 183 | unalias(args) 184 | case "aliases": 185 | aliases() 186 | default: 187 | if flag.NArg() == 1 { 188 | id, _, err := getNoteId(flag.Args()) 189 | if err != nil { 190 | log.Fatalf("couldn't parse %v as a note id: %v", flag.Arg(0), err) 191 | } 192 | // we've been given an argument, try to change to the specified note 193 | changeLevel(id) 194 | showNote([]string{}) 195 | } else if flag.NArg() == 0 { 196 | // just show the current note 197 | showNote([]string{}) 198 | } else { 199 | log.Fatalf("Invalid command") 200 | } 201 | } 202 | 203 | writeConfig() 204 | } 205 | 206 | // getNoteId takes a slice of arguments and, assuming the first 207 | // argument is a node name, returns the corresponding numeric id along 208 | // with the rest of the slice. If the length of the slice is zero, it 209 | // returns the current note ID. If there was an error parsing the 210 | // argument, it returns the error and the returned slice is unchanged. 211 | func getNoteId(args []string) (id int, rest []string, err error) { 212 | // if they specified nothing, just return 0 213 | if len(args) == 0 { 214 | id = cfg.CurrentNoteId 215 | return 216 | } 217 | id, err = z.ResolveNoteId(args[0]) 218 | if err != nil { 219 | return 220 | } 221 | rest = args[1:] 222 | return 223 | } 224 | 225 | func newNote(args []string) { 226 | var targetNote int 227 | var err error 228 | 229 | targetNote, args, err = getNoteId(args) 230 | if err != nil { 231 | log.Fatalf("failed to parse specified note %v: %v", args[0], err) 232 | } 233 | // You're not allowed to specify any arguments after the (optional) parent ID 234 | if len(args) != 0 { 235 | log.Fatalf("usage: zk new [parent]") 236 | } 237 | // read in a body 238 | fmt.Fprintf(os.Stderr, "Enter note; the first line will be the title. Ctrl-D when done.\n") 239 | body, err := io.ReadAll(os.Stdin) 240 | if err != nil { 241 | log.Fatalf("couldn't read body text: %v", err) 242 | } 243 | 244 | newId, err := z.NewNote(targetNote, string(body)) 245 | if err != nil { 246 | log.Fatalf("couldn't create note: %v", err) 247 | } 248 | fmt.Fprintf(os.Stderr, "Created new note %v\n", newId) 249 | } 250 | 251 | func showNote(args []string) { 252 | var targetNote int 253 | var err error 254 | 255 | targetNote, args, err = getNoteId(args) 256 | if err != nil { 257 | log.Fatalf("failed to parse specified note %v: %v", args[0], err) 258 | } 259 | // You're not allowed to specify any arguments after the (optional) note ID 260 | if len(args) != 0 { 261 | log.Fatalf("usage: zk show [note]") 262 | } 263 | 264 | note, err := z.GetNoteMeta(targetNote) 265 | if err != nil { 266 | log.Fatalf("couldn't read note: %v", err) 267 | } 268 | 269 | var subnotes []zk.NoteMeta 270 | for _, n := range note.Subnotes { 271 | sn, err := z.GetNoteMeta(n) 272 | if err != nil { 273 | log.Fatalf("failed to read subnote %v: %v", n, err) 274 | } 275 | subnotes = append(subnotes, sn) 276 | } 277 | 278 | // Sort the subnotes by ID 279 | sort.Slice(subnotes, func(i, j int) bool { return subnotes[i].Id < subnotes[j].Id }) 280 | 281 | fmt.Printf("%d %s\n", note.Id, note.Title) 282 | for _, sn := range subnotes { 283 | fmt.Printf(" %d %s\n", sn.Id, sn.Title) 284 | } 285 | } 286 | 287 | func changeLevel(id int) { 288 | if _, err := z.GetNoteMeta(id); err != nil { 289 | log.Fatalf("invalid note id %v", id) 290 | } 291 | 292 | cfg.CurrentNoteId = id 293 | } 294 | 295 | func addFile(args []string) { 296 | var err error 297 | var srcPath string 298 | target := cfg.CurrentNoteId 299 | switch len(args) { 300 | case 1: 301 | // just a filename, append to current note 302 | srcPath = args[0] 303 | case 2: 304 | // note number followed by filename 305 | target, args, err = getNoteId(args) 306 | if err != nil { 307 | log.Fatalf("failed to parse specified note %v: %v", args[0], err) 308 | } 309 | srcPath = args[0] 310 | default: 311 | log.Fatalf("usage: zk addfile [note id] ") 312 | } 313 | // Add the file 314 | // TODO: allow the user to specify an alternate name 315 | if err := z.AddFile(target, srcPath, ""); err != nil { 316 | log.Fatalf("Failed to add file: %v", err) 317 | } 318 | 319 | // Re-read the note to update the metadata 320 | n, err := z.GetNote(target) 321 | if err != nil { 322 | log.Fatalf("Failed to read note %v: %v", target, err) 323 | } 324 | fmt.Printf("Files for [%d] %v:\n", n.Id, n.Title) 325 | for _, f := range n.Files { 326 | fmt.Printf(" %v\n", f) 327 | } 328 | } 329 | 330 | func listFiles(args []string) { 331 | var err error 332 | target := cfg.CurrentNoteId 333 | if len(args) == 1 { 334 | target, _, err = getNoteId(args) 335 | if err != nil { 336 | log.Fatalf("failed to parse specified note %v: %v", args[0], err) 337 | } 338 | } 339 | // Re-read the note to update the metadata 340 | n, err := z.GetNote(target) 341 | if err != nil { 342 | log.Fatalf("Failed to read note %v: %v", target, err) 343 | } 344 | fmt.Printf("Files for [%d] %v:\n", n.Id, n.Title) 345 | for _, f := range n.Files { 346 | fmt.Printf(" %v\n", f) 347 | } 348 | } 349 | 350 | func editNote(args []string) { 351 | var err error 352 | target := cfg.CurrentNoteId 353 | if len(args) == 1 { 354 | target, _, err = getNoteId(args) 355 | if err != nil { 356 | log.Fatalf("failed to parse specified note %v: %v", args[0], err) 357 | } 358 | } 359 | // TODO: add editor to config 360 | editor := os.Getenv("EDITOR") 361 | if editor == "" { 362 | editor = "vim" 363 | } 364 | p, err := z.GetNoteBodyPath(target) 365 | if err != nil { 366 | log.Fatalf("Couldn't get path to note body: %v", err) 367 | } 368 | cmd := exec.Command(editor, p) 369 | cmd.Stdin = os.Stdin 370 | cmd.Stdout = os.Stdout 371 | cmd.Stderr = os.Stderr 372 | cmd.Start() 373 | cmd.Wait() 374 | } 375 | 376 | func appendNote(args []string) { 377 | var err error 378 | target := cfg.CurrentNoteId 379 | if len(args) == 1 { 380 | target, _, err = getNoteId(args) 381 | if err != nil { 382 | log.Fatalf("failed to parse specified note %v: %v", args[0], err) 383 | } 384 | } 385 | p, err := z.GetNoteBodyPath(target) 386 | if err != nil { 387 | log.Fatalf("Couldn't get path to note body: %v", err) 388 | } 389 | w, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 390 | if err != nil { 391 | log.Fatalf("Couldn't open note body: %v", err) 392 | } 393 | defer w.Close() 394 | 395 | // Now read from stdin 396 | fmt.Fprintf(os.Stderr, "Ctrl-D when done.\n") 397 | body, err := io.ReadAll(os.Stdin) 398 | if err != nil { 399 | log.Fatalf("couldn't read body text: %v", err) 400 | } 401 | w.Write(body) 402 | w.Sync() 403 | } 404 | 405 | func printNote(args []string) { 406 | var err error 407 | target := cfg.CurrentNoteId 408 | if len(args) == 1 { 409 | target, _, err = getNoteId(args) 410 | if err != nil { 411 | log.Fatalf("failed to parse specified note %v: %v", args[0], err) 412 | } 413 | } 414 | if note, err := z.GetNote(target); err == nil { 415 | fmt.Print(note.Body) 416 | } else { 417 | log.Fatalf("couldn't read note: %v", err) 418 | } 419 | } 420 | 421 | // Arg 0: source 422 | // Arg 1: target 423 | func linkNote(args []string) { 424 | var src, dst int 425 | var err error 426 | if len(args) == 2 { 427 | src, args, err = getNoteId(args) 428 | if err != nil { 429 | log.Fatalf("failed to parse source note %v: %v", args[0], err) 430 | } 431 | dst, args, err = getNoteId(args) 432 | if err != nil { 433 | log.Fatalf("failed to parse destination note %v: %v", args[0], err) 434 | } 435 | } else { 436 | log.Fatalf("must specify source (note to be linked) and destination (note into which it will be linked)") 437 | } 438 | if err := z.LinkNote(dst, src); err != nil { 439 | log.Fatalf("Failed to link %d to %d: %v", src, dst, err) 440 | } 441 | } 442 | 443 | // Unlink the specified note from the current note 444 | func unlinkNote(args []string) { 445 | var err error 446 | target := cfg.CurrentNoteId 447 | var child int 448 | if len(args) == 1 { 449 | child, args, err = getNoteId(args) 450 | if err != nil { 451 | log.Fatalf("failed to parse child note %v: %v", args[0], err) 452 | } 453 | } else if len(args) == 2 { 454 | child, args, err = getNoteId(args) 455 | if err != nil { 456 | log.Fatalf("failed to parse child note %v: %v", args[0], err) 457 | } 458 | target, args, err = getNoteId(args) 459 | if err != nil { 460 | log.Fatalf("failed to parse child note %v: %v", args[0], err) 461 | } 462 | } else { 463 | log.Fatal("usage: zk unlink [parent] ") 464 | } 465 | if err := z.UnlinkNote(target, child); err != nil { 466 | log.Fatalf("Failed to unlink %d from %d: %v", child, target, err) 467 | } 468 | } 469 | 470 | func printTree(args []string) { 471 | var err error 472 | target := 0 473 | if len(args) == 1 { 474 | target, _, err = getNoteId(args) 475 | if err != nil { 476 | log.Fatalf("failed to parse specified note %v: %v", args[0], err) 477 | } 478 | } 479 | printTreeRecursive(0, target) 480 | } 481 | 482 | func printTreeRecursive(depth, id int) { 483 | if note, err := z.GetNoteMeta(id); err == nil { 484 | for i := 0; i < depth; i++ { 485 | fmt.Printf(" ") 486 | } 487 | fmt.Printf("%s\n", formatNoteSummary(note)) 488 | for _, sn := range note.Subnotes { 489 | printTreeRecursive(depth+1, sn) 490 | } 491 | } else { 492 | log.Fatalf("Problem getting note %d in recursive tree print: %v", id, err) 493 | } 494 | } 495 | 496 | func formatNoteSummary(note zk.NoteMeta) string { 497 | return fmt.Sprintf("%d %s", note.Id, note.Title) 498 | } 499 | 500 | func grep(args []string) { 501 | if len(args) == 0 { 502 | log.Fatalf("Must give a pattern to grep for") 503 | } 504 | // Just in case somebody leaves off quotes, we'll just join all args by space 505 | pattern := strings.Join(args, " ") 506 | 507 | if c, err := z.Grep(pattern, []int{}); err != nil { 508 | log.Fatal(err) 509 | } else { 510 | for r := range c { 511 | fmt.Printf("%d [%v]: %s\n", r.Note.Id, r.Note.Title, r.Line) 512 | } 513 | } 514 | } 515 | 516 | func tgrep(args []string) { 517 | if len(args) == 0 { 518 | log.Fatalf("usage: zk tgrep [root id] ") 519 | } 520 | // Root ID is optional (current note is implied) so let's check 521 | root := cfg.CurrentNoteId 522 | if len(args) >= 2 { 523 | // Try to parse the first arg as a node ID 524 | if id, nargs, err := getNoteId(args); err == nil { 525 | root = id 526 | args = nargs 527 | } 528 | } 529 | // Just in case somebody leaves off quotes, we'll just join all args by space 530 | pattern := strings.Join(args, " ") 531 | 532 | if c, err := z.TreeGrep(pattern, root); err != nil { 533 | log.Fatal(err) 534 | } else { 535 | for r := range c { 536 | fmt.Printf("%d [%v]: %s\n", r.Note.Id, r.Note.Title, r.Line) 537 | } 538 | } 539 | } 540 | 541 | func orphans(args []string) { 542 | if len(args) != 0 { 543 | log.Fatalf("orphans command takes no arguments") 544 | } 545 | orphans := z.GetOrphans() 546 | for _, o := range orphans { 547 | fmt.Println(formatNoteSummary(o)) 548 | } 549 | } 550 | 551 | func alias(args []string) { 552 | var targetNote int 553 | var err error 554 | 555 | if len(args) != 2 { 556 | log.Fatalf("usage: zk alias ") 557 | } 558 | targetNote, args, err = getNoteId(args) 559 | if err != nil { 560 | log.Fatalf("failed to parse specified note %v: %v", args[0], err) 561 | } 562 | z.AddAlias(targetNote, args[0]) 563 | } 564 | 565 | func unalias(args []string) { 566 | if len(args) != 1 { 567 | log.Fatalf("usage: zk unalias ") 568 | } 569 | z.RemoveAlias(args[0]) 570 | } 571 | 572 | func aliases() { 573 | aliases := z.Aliases() 574 | for name, id := range aliases { 575 | if note, err := z.GetNoteMeta(id); err != nil { 576 | fmt.Fprintf(os.Stderr, "%v: points to note %v, whose metadata cannot be retrieved: %v", name, id, err) 577 | continue 578 | } else { 579 | fmt.Printf("%v → %v\n", name, formatNoteSummary(note)) 580 | } 581 | } 582 | } 583 | -------------------------------------------------------------------------------- /libzk/zk.go: -------------------------------------------------------------------------------- 1 | package zk 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "os" 12 | "path/filepath" 13 | "regexp" 14 | "strconv" 15 | "strings" 16 | ) 17 | 18 | type zkState struct { 19 | NextNoteId int 20 | Aliases map[string]int 21 | Notes map[int]NoteMeta 22 | } 23 | 24 | type NoteMeta struct { 25 | Id int 26 | Title string 27 | Subnotes []int 28 | Files []string 29 | Parent int 30 | } 31 | 32 | func (o *NoteMeta) Equal(n NoteMeta) bool { 33 | if o.Id != n.Id || o.Title != n.Title || o.Parent != n.Parent { 34 | return false 35 | } 36 | if len(o.Subnotes) != len(n.Subnotes) || len(o.Files) != len(n.Files) { 37 | return false 38 | } 39 | for i := range o.Subnotes { 40 | if o.Subnotes[i] != n.Subnotes[i] { 41 | return false 42 | } 43 | } 44 | for i := range o.Files { 45 | if o.Files[i] != n.Files[i] { 46 | return false 47 | } 48 | } 49 | return true 50 | } 51 | 52 | type Note struct { 53 | NoteMeta 54 | Body string 55 | } 56 | 57 | type ZK struct { 58 | root string 59 | state zkState 60 | } 61 | 62 | // InitZK will initialize a new zk with the specified path as the 63 | // root directory. If the path already exists, it must be empty. 64 | func InitZK(root string) error { 65 | z := &ZK{ 66 | root: root, 67 | state: zkState{ 68 | NextNoteId: 1, 69 | Aliases: make(map[string]int), 70 | Notes: make(map[int]NoteMeta), 71 | }, 72 | } 73 | 74 | // There should be nothing in the directory, if it exists 75 | if contents, err := ioutil.ReadDir(z.root); err == nil { 76 | if len(contents) > 0 { 77 | return errors.New("specified root already contains files/directories") 78 | } 79 | } 80 | 81 | // Creating the directory is fine 82 | os.MkdirAll(z.root, 0755) 83 | 84 | // Generate a top-level note 85 | if err := z.makeNote(0, 0, "Top Level\n"); err != nil { 86 | return err 87 | } 88 | return z.writeState() 89 | } 90 | 91 | // NewZK creates a ZK object rooted at the specified directory. 92 | // The directory should have been previously initialized with the InitZK function. 93 | func NewZK(root string) (z *ZK, err error) { 94 | z = &ZK{ 95 | root: root, 96 | } 97 | 98 | // Attempt to read a state file. 99 | err = z.readState() 100 | 101 | return 102 | } 103 | 104 | func (z *ZK) Close() { 105 | z.writeState() 106 | } 107 | 108 | // ResolveNoteId returns the numeric ID from a string name. You'll 109 | // use this to determine if the user has specified an exact ID or an 110 | // alias. 111 | func (z *ZK) ResolveNoteId(name string) (int, error) { 112 | // First check if it's an alias 113 | for k, v := range z.state.Aliases { 114 | if k == name { 115 | return v, nil 116 | } 117 | } 118 | // Otherwise, just treat it as a number 119 | return strconv.Atoi(name) 120 | } 121 | 122 | // GetNote returns the full contents of the specified note ID, 123 | // including the body. Unlike GetNoteMeta, it actually reads 124 | // from the disk and will update the in-memory state if out of sync. 125 | func (z *ZK) GetNote(id int) (note Note, err error) { 126 | return z.readNote(id) 127 | } 128 | 129 | // Read a note by id from the filesystem, updating our metadata map 130 | // as we do it. 131 | func (z *ZK) readNote(id int) (result Note, err error) { 132 | // read the metadata 133 | result.NoteMeta, err = z.readNoteMetadata(id) 134 | if err != nil { 135 | return 136 | } 137 | 138 | orig := result.NoteMeta 139 | 140 | p := filepath.Join(z.root, fmt.Sprintf("%d", id)) 141 | // list the files -- we want to double check in case somebody did something stupid manually 142 | result.Files = []string{} 143 | files, err := ioutil.ReadDir(filepath.Join(p, "files")) 144 | if err != nil { 145 | log.Fatal(err) 146 | } 147 | for _, f := range files { 148 | result.Files = append(result.Files, f.Name()) 149 | } 150 | 151 | // read the body 152 | b, err := ioutil.ReadFile(filepath.Join(p, "body")) 153 | if err != nil { 154 | return result, err 155 | } 156 | result.Body = string(b) 157 | 158 | // get the title 159 | s := bufio.NewScanner(bytes.NewBuffer(b)) 160 | if s.Scan() { 161 | result.Title = s.Text() 162 | } 163 | 164 | // now write the metadata to our state map 165 | z.state.Notes[id] = result.NoteMeta 166 | 167 | // If there was a change to the metadata, write it back 168 | if !orig.Equal(result.NoteMeta) { 169 | if err := z.writeNoteMetadata(result.NoteMeta); err != nil { 170 | return result, err 171 | } 172 | } 173 | 174 | return result, nil 175 | } 176 | 177 | func (z *ZK) GetNoteMeta(id int) (md NoteMeta, err error) { 178 | var ok bool 179 | if md, ok = z.state.Notes[id]; !ok { 180 | err = fmt.Errorf("Note %d not found", id) 181 | } 182 | return 183 | } 184 | 185 | func (z *ZK) NewNote(parent int, body string) (int, error) { 186 | id := z.state.NextNoteId 187 | err := z.makeNote(id, parent, body) 188 | if err != nil { 189 | return 0, err 190 | } 191 | z.state.NextNoteId++ 192 | return id, z.writeState() 193 | } 194 | 195 | // makeNote does NOT write the state file 196 | func (z *ZK) makeNote(id, parent int, body string) error { 197 | // First verify that the id doesn't already exist 198 | if m, ok := z.state.Notes[id]; ok { 199 | return fmt.Errorf("a note with id %v already exists: %v", id, m) 200 | } 201 | meta := NoteMeta{Id: id} 202 | s := bufio.NewScanner(bytes.NewBuffer([]byte(body))) 203 | if s.Scan() { 204 | meta.Title = s.Text() 205 | } 206 | 207 | meta.Parent = parent 208 | 209 | // make the note dir 210 | path := filepath.Join(z.root, fmt.Sprintf("%d", id)) 211 | err := os.MkdirAll(path, 0700) 212 | if err != nil { 213 | return err 214 | } 215 | 216 | // Now create the subdirectories and files that go in it 217 | err = ioutil.WriteFile(filepath.Join(path, "body"), []byte(body), 0700) 218 | if err != nil { 219 | return err 220 | } 221 | 222 | err = os.MkdirAll(filepath.Join(path, "files"), 0700) 223 | if err != nil { 224 | return err 225 | } 226 | 227 | err = z.writeNoteMetadata(meta) 228 | if err != nil { 229 | return err 230 | } 231 | 232 | // At this point we should be ok to append ourselves to the parent's subnote list 233 | // very dumb check to make sure we're not note 0 234 | if id != 0 { 235 | pmeta := z.state.Notes[meta.Parent] 236 | pmeta.Subnotes = append(pmeta.Subnotes, id) 237 | z.state.Notes[meta.Parent] = pmeta 238 | 239 | // Now write it out to the file 240 | err = z.writeNoteMetadata(z.state.Notes[meta.Parent]) 241 | if err != nil { 242 | return err 243 | } 244 | } 245 | 246 | // We've made all the files, write the metadata into the map. 247 | z.state.Notes[id] = meta 248 | 249 | return nil 250 | } 251 | 252 | func (z *ZK) UpdateNote(id int, body string) error { 253 | // Make sure the note exists 254 | var meta NoteMeta 255 | var ok bool 256 | if meta, ok = z.state.Notes[id]; !ok { 257 | return fmt.Errorf("Note %d not found", id) 258 | } 259 | 260 | // Figure out the new title & update metadata 261 | s := bufio.NewScanner(bytes.NewBuffer([]byte(body))) 262 | if s.Scan() { 263 | meta.Title = s.Text() 264 | } 265 | 266 | // Write out to the body file 267 | path := filepath.Join(z.root, fmt.Sprintf("%d", id)) 268 | if err := ioutil.WriteFile(filepath.Join(path, "body"), []byte(body), 0700); err != nil { 269 | return err 270 | } 271 | 272 | // Finally, update metadata 273 | z.state.Notes[id] = meta 274 | if err := z.writeNoteMetadata(meta); err != nil { 275 | return err 276 | } 277 | 278 | return nil 279 | } 280 | 281 | // AddAlias installs an alias, allowing the note with the given id to 282 | // be referred to by the specified name. 283 | func (z *ZK) AddAlias(id int, name string) error { 284 | z.state.Aliases[name] = id 285 | return z.writeState() 286 | } 287 | 288 | // RemoveAlias removes the specified alias. 289 | func (z *ZK) RemoveAlias(name string) { 290 | delete(z.state.Aliases, name) 291 | } 292 | 293 | // Aliases returns a *copy* of the map of aliases 294 | func (z *ZK) Aliases() map[string]int { 295 | ret := map[string]int{} 296 | for k, v := range z.state.Aliases { 297 | ret[k] = v 298 | } 299 | return ret 300 | } 301 | 302 | // MetadataDump returns the entire contents of the in-memory state. 303 | // This can be useful when walking the entire tree. 304 | func (z *ZK) MetadataDump() map[int]NoteMeta { 305 | return z.state.Notes 306 | } 307 | 308 | // GetNoteBodyPath returns an absolute path to the given note's body 309 | // file, suitable for passing to an editor. Note that changing the 310 | // note's title here by editing this file will not change the title 311 | // in the in-memory metadata until GetNote, Rescan, or another function 312 | // which reads and parses the on-disk files is called. 313 | func (z *ZK) GetNoteBodyPath(id int) (path string, err error) { 314 | if _, ok := z.state.Notes[id]; !ok { 315 | err = fmt.Errorf("Note %d not found", id) 316 | return 317 | } 318 | path = filepath.Join(z.root, fmt.Sprintf("%d", id), "body") 319 | return 320 | } 321 | 322 | // LinkNote links the specified note as a child of the parent note. 323 | func (z *ZK) LinkNote(parent, id int) error { 324 | // Get the parent 325 | p, ok := z.state.Notes[parent] 326 | if !ok { 327 | return fmt.Errorf("Parent note %d not found", parent) 328 | } 329 | 330 | // Make sure the child exists 331 | _, ok = z.state.Notes[id] 332 | if !ok { 333 | return fmt.Errorf("Note %d not found", id) 334 | } 335 | 336 | // Add the link 337 | for i := range p.Subnotes { 338 | if p.Subnotes[i] == id { 339 | // it's already linked 340 | return nil 341 | } 342 | } 343 | p.Subnotes = append(p.Subnotes, id) 344 | 345 | // Write state & metadata file 346 | z.state.Notes[parent] = p 347 | return z.writeNoteMetadata(p) 348 | } 349 | 350 | // UnlinkNote removes the specified note from the parent note's subnotes 351 | func (z *ZK) UnlinkNote(parent, id int) error { 352 | // Get the child 353 | child, ok := z.state.Notes[id] 354 | if !ok { 355 | return fmt.Errorf("Child note %d not found", id) 356 | } 357 | // Get the parent 358 | p, ok := z.state.Notes[parent] 359 | if !ok { 360 | return fmt.Errorf("Parent note %d not found", parent) 361 | } 362 | 363 | // Remove the link 364 | var newSubnotes []int 365 | for _, sn := range p.Subnotes { 366 | if sn != id { 367 | newSubnotes = append(newSubnotes, sn) 368 | } 369 | } 370 | p.Subnotes = newSubnotes 371 | 372 | // If we've just removed the "parent" note, re-parent it to note 0. 373 | // We could look for other notes with this as a subnote, but 374 | // that seems more confusing to users. 375 | if parent == child.Parent { 376 | child.Parent = 0 377 | z.state.Notes[id] = child 378 | } 379 | 380 | // Write state & metadata file 381 | z.state.Notes[parent] = p 382 | return z.writeNoteMetadata(p) 383 | } 384 | 385 | // AddFile copies the file at the specified path into the given note's files. 386 | // If dstName is not empty, the resulting file will be given that name. 387 | func (z *ZK) AddFile(id int, path string, dstName string) error { 388 | // Make sure that note actually exists 389 | dstNote, ok := z.state.Notes[id] 390 | if !ok { 391 | return fmt.Errorf("Note %d not found", id) 392 | } 393 | // Verify that the source file exists 394 | var err error 395 | _, err = os.Stat(path) 396 | if err != nil { 397 | return fmt.Errorf("Cannot find source file %v: %v", dstName, err) 398 | } 399 | src, err := os.Open(path) 400 | if err != nil { 401 | return fmt.Errorf("Cannot open source file %v: %v", path, err) 402 | } 403 | 404 | // Verify that the destination files directory exists 405 | p := filepath.Join(z.root, fmt.Sprintf("%d", id), "files") 406 | _, err = os.Stat(p) 407 | if err != nil { 408 | return fmt.Errorf("Cannot open %v: %v", p, err) 409 | } 410 | 411 | // Copy the file into the directory 412 | base := dstName 413 | if base == "" { 414 | base = filepath.Base(path) 415 | if base == "." { 416 | return fmt.Errorf("Cannot find base name for %v", path) 417 | } 418 | } 419 | // Make sure there's not already a file with that name in the destination 420 | for _, f := range dstNote.Files { 421 | if f == base { 422 | return fmt.Errorf("File named %v already exists for note %d", base, id) 423 | } 424 | } 425 | 426 | dstPath := filepath.Join(p, base) 427 | dst, err := os.Create(dstPath) 428 | if err != nil { 429 | return fmt.Errorf("Cannot create destination file %v: %v", dstPath, err) 430 | } 431 | 432 | _, err = io.Copy(dst, src) 433 | if err != nil { 434 | return fmt.Errorf("Problem copying %v to %v: %v", path, dstPath, err) 435 | } 436 | 437 | // Re-read the note to update the metadata 438 | _, err = z.readNote(id) 439 | if err != nil { 440 | return fmt.Errorf("Failed to read note %v: %v", id, err) 441 | } 442 | return nil 443 | } 444 | 445 | // RemoveFile removes the specified file from the note. 446 | func (z *ZK) RemoveFile(id int, name string) error { 447 | // Make sure that note actually exists 448 | dstNote, ok := z.state.Notes[id] 449 | if !ok { 450 | return fmt.Errorf("Note %d not found", id) 451 | } 452 | 453 | // First remove the file from the disk 454 | p := filepath.Join(z.root, fmt.Sprintf("%d", id), "files", name) 455 | if err := os.Remove(p); err != nil { 456 | return err 457 | } 458 | 459 | // Now take it out of the metadata 460 | var newFiles []string 461 | for _, f := range dstNote.Files { 462 | if f != name { 463 | newFiles = append(newFiles, f) 464 | } 465 | } 466 | dstNote.Files = newFiles 467 | 468 | // And update metadata 469 | z.state.Notes[id] = dstNote 470 | return z.writeNoteMetadata(dstNote) 471 | } 472 | 473 | // GetFilePath returns an absolute path to a given file within a note 474 | func (z *ZK) GetFilePath(id int, name string) (string, error) { 475 | return z.getFilePath(id, name) 476 | } 477 | 478 | func (z *ZK) getFilePath(id int, name string) (string, error) { 479 | // Make sure that note actually exists 480 | _, ok := z.state.Notes[id] 481 | if !ok { 482 | return "", fmt.Errorf("Note %d not found", id) 483 | } 484 | p := filepath.Join(z.root, fmt.Sprintf("%d", id), "files", name) 485 | if _, err := os.Stat(p); err != nil { 486 | return "", err 487 | } 488 | return p, nil 489 | } 490 | 491 | // GetFileReader returns an io.Reader attached to the specified file within a note 492 | func (z *ZK) GetFileReader(id int, name string) (io.Reader, error) { 493 | p, err := z.getFilePath(id, name) 494 | if err != nil { 495 | return nil, err 496 | } 497 | return os.Open(p) 498 | } 499 | 500 | // Rescan will attempt to re-derive the state from the contents of the zk 501 | // directory. Useful if you have manually messed with the directories, or 502 | // if things just seem out of sync. 503 | func (z *ZK) Rescan() error { 504 | state, err := z.deriveState() 505 | if err != nil { 506 | // give up 507 | return err 508 | } 509 | z.state = state 510 | return nil 511 | } 512 | 513 | // GrepResult contains a single matching line returned from the Grep function. 514 | // The Note field is the id of the note which matched 515 | // The Line field is the text of the note which matched. 516 | type GrepResult struct { 517 | Note NoteMeta 518 | Line string 519 | Error error 520 | } 521 | 522 | type oneGrep struct { 523 | c chan *GrepResult 524 | } 525 | 526 | func (z *ZK) grep(n NoteMeta, pattern *regexp.Regexp, c chan *oneGrep) { 527 | // Create a channel of GrepResults and hand it back up to the master routine 528 | res := make(chan *GrepResult) 529 | defer close(res) 530 | c <- &oneGrep{res} 531 | 532 | // Get a reader on the note body 533 | p := filepath.Join(z.root, fmt.Sprintf("%d", n.Id), "body") 534 | f, err := os.Open(p) 535 | if err != nil { 536 | res <- &GrepResult{Note: n, Error: err} 537 | return 538 | } 539 | defer f.Close() 540 | 541 | // Now walk it, looking for any matching lines 542 | rdr := bufio.NewReader(f) 543 | for { 544 | if s, err := rdr.ReadString('\n'); err != nil && err != io.EOF { 545 | // Legit error, pass it up 546 | res <- &GrepResult{Note: n, Error: err} 547 | } else { 548 | if pattern.MatchString(s) { 549 | // match! 550 | res <- &GrepResult{Note: n, Line: strings.TrimSuffix(s, "\n")} 551 | } 552 | if err == io.EOF { 553 | break 554 | } 555 | } 556 | } 557 | } 558 | 559 | // TreeGrep searches note bodies for a regular expression. It takes as arguments 560 | // a regular expression string and a note ID. That note, and the entire tree of 561 | // subnotes below it, are searched. 562 | func (z *ZK) TreeGrep(pattern string, root int) (c chan *GrepResult, err error) { 563 | // Make sure the specified root actually exists 564 | if _, ok := z.state.Notes[root]; !ok { 565 | err = fmt.Errorf("Note %d does not exist", root) 566 | return 567 | } 568 | // Simple lambda function to walk the tree and build up a list of notes to search 569 | var f func(int) []int 570 | f = func(id int) []int { 571 | note, ok := z.state.Notes[id] 572 | if !ok { 573 | // this shouldn't happen 574 | return []int{} 575 | } 576 | l := []int{note.Id} 577 | for _, n := range note.Subnotes { 578 | l = append(l, f(n)...) 579 | } 580 | return l 581 | } 582 | return z.Grep(pattern, f(root)) 583 | } 584 | 585 | // Grep searches note bodies for a regular expression and returns a channel of *GrepResult. 586 | // If the notes parameter is non-empty, it will restrict the search to only the specified note IDs. 587 | func (z *ZK) Grep(pattern string, notes []int) (c chan *GrepResult, err error) { 588 | c = make(chan *GrepResult, 1024) 589 | results := make(chan *oneGrep) 590 | 591 | // Check the regular expression 592 | var re *regexp.Regexp 593 | if re, err = regexp.Compile(pattern); err != nil { 594 | return 595 | } 596 | 597 | // Figure out which notes we're working with. If none were passed, use all of them. 598 | if len(notes) == 0 { 599 | for _, v := range z.state.Notes { 600 | notes = append(notes, v.Id) 601 | } 602 | } 603 | var toSearch []NoteMeta 604 | for _, n := range notes { 605 | if md, ok := z.state.Notes[n]; ok { 606 | toSearch = append(toSearch, md) 607 | } 608 | } 609 | 610 | // Fire off a goroutine for each note 611 | for _, n := range toSearch { 612 | go z.grep(n, re, results) 613 | } 614 | 615 | // Now fire the goroutine which relays from those notes to the reader. 616 | // We do it like this so we get all results from one note at a time. 617 | go func() { 618 | nGrep := len(toSearch) 619 | 620 | for g := range results { 621 | for r := range g.c { 622 | c <- r 623 | } 624 | nGrep-- 625 | if nGrep == 0 { 626 | break 627 | } 628 | } 629 | 630 | close(c) 631 | }() 632 | 633 | return c, nil 634 | } 635 | 636 | // GetOrphans returns a list of "orphaned" notes, notes which are not the subnote of 637 | // any other note. 638 | func (z *ZK) GetOrphans() (orphans []NoteMeta) { 639 | // For every note... 640 | orphanLoop: 641 | for id, meta := range z.state.Notes { 642 | // Note 0 can never be an orphan. 643 | if id == 0 { 644 | continue orphanLoop 645 | } 646 | // walk the set of notes *again* to see if it's a sub-note of any of them. 647 | for _, candidate := range z.state.Notes { 648 | for i := range candidate.Subnotes { 649 | if candidate.Subnotes[i] == id { 650 | // We found the ID in a list of subnotes, on to the next potential orphan! 651 | continue orphanLoop 652 | } 653 | } 654 | } 655 | // If we got this far, the note was not a subnote of *any* other note. 656 | orphans = append(orphans, meta) 657 | } 658 | return 659 | } 660 | --------------------------------------------------------------------------------