├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── app ├── app.go ├── app_test.go └── errors.go ├── cmd └── grit │ ├── cmds.go │ ├── main.go │ └── utils.go ├── db ├── db.go ├── db_test.go ├── graph.go ├── link.go ├── migrate.go ├── node.go └── utils.go ├── docs └── assets │ ├── demo.gif │ ├── fig1.gif │ └── fig2.png ├── go.mod ├── go.sum └── multitree ├── import.go ├── link.go ├── multitree_test.go ├── node.go ├── repr.go ├── sort.go ├── status.go ├── traverse.go ├── utils.go └── validate.go /.gitignore: -------------------------------------------------------------------------------- 1 | /grit 2 | /dist/ 3 | /.release-env 4 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: grit 2 | 3 | builds: 4 | - id: darwin-amd64 5 | main: ./cmd/grit 6 | binary: "{{.ProjectName}}" 7 | goos: 8 | - darwin 9 | goarch: 10 | - amd64 11 | env: 12 | - CC=o64-clang 13 | ldflags: 14 | - -s -w -X github.com/climech/grit/app.Version=v{{.Version}} 15 | 16 | - id: darwin-arm64 17 | main: ./cmd/grit 18 | binary: "{{.ProjectName}}" 19 | goos: 20 | - darwin 21 | goarch: 22 | - arm64 23 | env: 24 | - CC=oa64-clang 25 | ldflags: 26 | - -s -w -X github.com/climech/grit/app.Version=v{{.Version}} 27 | 28 | - id: linux-amd64 29 | main: ./cmd/grit 30 | binary: "{{.ProjectName}}" 31 | goos: 32 | - linux 33 | goarch: 34 | - amd64 35 | env: 36 | - CC=gcc 37 | ldflags: 38 | - -linkmode external -extldflags '-static' 39 | - -s -w -X github.com/climech/grit/app.Version=v{{.Version}} 40 | 41 | - id: linux-arm64 42 | main: ./cmd/grit 43 | binary: "{{.ProjectName}}" 44 | goos: 45 | - linux 46 | goarch: 47 | - arm64 48 | env: 49 | - CC=aarch64-linux-gnu-gcc 50 | ldflags: 51 | - -linkmode external -extldflags '-static' 52 | - -s -w -X github.com/climech/grit/app.Version=v{{.Version}} 53 | 54 | - id: linux-armhf 55 | main: ./cmd/grit 56 | binary: "{{.ProjectName}}" 57 | goos: 58 | - linux 59 | goarch: 60 | - arm 61 | goarm: 62 | - 6 63 | - 7 64 | env: 65 | - CC=arm-linux-gnueabihf-gcc 66 | ldflags: 67 | - -linkmode external -extldflags '-static' 68 | - -s -w -X github.com/climech/grit/app.Version=v{{.Version}} 69 | 70 | - id: windows-amd64 71 | main: ./cmd/grit 72 | binary: "{{.ProjectName}}" 73 | goos: 74 | - windows 75 | goarch: 76 | - amd64 77 | env: 78 | - CC=x86_64-w64-mingw32-gcc 79 | ldflags: 80 | - -s -w -X github.com/climech/grit/app.Version=v{{.Version}} 81 | 82 | archives: 83 | - id: grit 84 | builds: 85 | - darwin-amd64 86 | - darwin-arm64 87 | - linux-amd64 88 | - linux-arm64 89 | - linux-armhf 90 | - windows-amd64 91 | name_template: "{{.ProjectName}}_v{{.Version}}_{{.Os}}_{{.Arch}}{{.Arm}}" 92 | format: tar.gz 93 | format_overrides: 94 | - goos: windows 95 | format: zip 96 | wrap_in_directory: true 97 | files: 98 | - README* 99 | - LICENSE* 100 | - docs/* 101 | 102 | checksum: 103 | name_template: "checksums.txt" 104 | 105 | changelog: 106 | sort: asc 107 | 108 | brews: 109 | - tap: 110 | owner: climech 111 | name: homebrew-repo 112 | folder: Formula 113 | homepage: "https://github.com/climech/grit" 114 | description: "Multitree-based personal task manager" 115 | license: MIT 116 | skip_upload: true 117 | 118 | snapshot: 119 | name_template: "{{.Version}}-SNAPSHOT-{{.ShortCommit}}" 120 | 121 | release: 122 | github: 123 | owner: climech 124 | name: grit 125 | prerelease: auto 126 | draft: true 127 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020-2021 Tomasz Klimczak 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APPNAME = grit 2 | VERSION = $(shell git describe --long --always --dirty 2>/dev/null || echo -n 'v0.3.0') 3 | GOCMD = go 4 | GOPATH ?= $(shell mktemp -d) 5 | GOMODULE = github.com/climech/grit 6 | CWD = $(shell pwd) 7 | PREFIX ?= /usr/local 8 | BINDIR ?= $(PREFIX)/bin 9 | BUILDDIR ?= . 10 | BASHCOMPDIR ?= $(PREFIX)/share/bash-completion/completions 11 | GOLANG_CROSS_VERSION ?= v1.16.3 12 | 13 | all: build 14 | 15 | .PHONY: build 16 | build: 17 | @$(GOCMD) build -v \ 18 | -o "$(BUILDDIR)/$(APPNAME)" \ 19 | -ldflags "-s -w -X '$(GOMODULE)/app.Version=$(VERSION)'" \ 20 | "$(CWD)/cmd/$(APPNAME)" 21 | 22 | install: $(APPNAME) 23 | @mkdir -p "$(DESTDIR)$(BINDIR)" 24 | @install -cv "$(APPNAME)" "$(DESTDIR)$(BINDIR)" 25 | 26 | .PHONY: test 27 | test: 28 | @$(GOCMD) test -count=1 ./... 29 | 30 | .PHONY: clean 31 | clean: 32 | @rm -f $(APPNAME) 33 | 34 | .PHONY: release-dry-run 35 | release-dry-run: 36 | @docker run \ 37 | --rm \ 38 | --privileged \ 39 | -e CGO_ENABLED=1 \ 40 | -v /var/run/docker.sock:/var/run/docker.sock \ 41 | -v `pwd`:/go/src/$(GOMODULE) \ 42 | -w /go/src/$(GOMODULE) \ 43 | troian/golang-cross:${GOLANG_CROSS_VERSION} \ 44 | --rm-dist --snapshot 45 | 46 | .PHONY: release 47 | release: 48 | @if [ ! -f ".release-env" ]; then \ 49 | echo "\033[91m.release-env is required for release\033[0m";\ 50 | exit 1;\ 51 | fi 52 | docker run \ 53 | --rm \ 54 | --privileged \ 55 | -e CGO_ENABLED=1 \ 56 | --env-file .release-env \ 57 | -v /var/run/docker.sock:/var/run/docker.sock \ 58 | -v `pwd`:/go/src/$(GOMODULE) \ 59 | -w /go/src/$(GOMODULE) \ 60 | troian/golang-cross:${GOLANG_CROSS_VERSION} \ 61 | release --rm-dist -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grit # 2 | 3 | Grit is an experimental personal task manager that represents tasks as nodes of a [multitree](https://en.wikipedia.org/wiki/Multitree), a class of [directed acyclic graphs](https://en.wikipedia.org/wiki/Directed_acyclic_graph). The structure enables the subdivision of tasks, and supports short-term as well as long-term planning. 4 | 5 |

6 | 7 |

8 | 9 | ## Contents 10 | 11 | * [Installation](#installation) 12 | * [From Source](#from-source) 13 | * [External Repositories](#external-repositories) 14 | * [Introduction](#introduction) 15 | * [Trees](#trees) 16 | * [Multitrees](#multitrees) 17 | * [States](#states) 18 | * [Date nodes](#date-nodes) 19 | * [Practical guide](#practical-guide) 20 | * [Basic usage](#basic-usage) 21 | * [Subtasks](#subtasks) 22 | * [Roots](#roots) 23 | * [Links](#links) 24 | * [Pointers](#pointers) 25 | * [Organizing tasks](#organizing-tasks) 26 | * [Reading challenge](#reading-challenge) 27 | * [More information](#more-information) 28 | * [License](#license) 29 | 30 | 31 | ## Installation ## 32 | 33 | ### From source ### 34 | 35 | Make sure `go` (>=1.14) and `gcc` are installed on your system. Get the [latest release](https://github.com/climech/grit/releases), and run: 36 | 37 | ``` 38 | $ make && sudo make install 39 | ``` 40 | 41 | ### External repositories ### 42 | 43 | * [Arch Linux (AUR)](https://aur.archlinux.org/packages/grit-task-manager/) ([@adityasaky](https://github.com/adityasaky)) 44 | * [macOS via MacPorts](https://ports.macports.org/port/grit/summary) ([@herbygillot](https://github.com/herbygillot)) 45 | 46 | 47 | ## Introduction ## 48 | 49 | *(This section is a little technical — you may want to skip over to [Practical guide](#practical-guide) first.)* 50 | 51 | Grit's design is based on two premises: 52 | 53 | 1. Breaking a problem up into smaller, more manageable parts is generally a good approach to problem-solving. 54 | 2. Tracking progress across time improves focus by removing the mental overhead associated with many parallel tasks spanning multiple days. 55 | 56 | Tasks may be represented as tree nodes, enabling their subdivision. By introducing *date nodes* into the structure, and viewing the trees in the context of a larger multitree, we can distribute work on root tasks across multiple days by creating cross links to their descendants. 57 | 58 | ### Trees ### 59 | 60 | A big task may be represented as a tree, e.g.: 61 | 62 | ``` 63 | [~] Digitize family photos 64 | ├──[x] Scan album 1 65 | ├──[x] Scan album 2 66 | ├──[ ] Scan album 3 67 | └──[ ] ... 68 | ``` 69 | 70 | In the example above, the parent task is divided into a number of subtasks. Completing the subtasks is equivalent to completing the parent task. 71 | 72 | ### Multitrees ### 73 | 74 | In [*Multitrees: Enriching and Reusing Hierarchical Structure*](http://adrenaline.ucsd.edu/kirsh/Articles/In_Process/MultiTrees.pdf), George W. Furnas and Jeff Zachs introduce the structure: 75 | 76 | >[...] a class of directed acyclic graphs (DAGs) with the unusual property that they have large easily identifiable substructures that are trees. These subtrees have a natural semantic interpretation providing alternate hierarchical contexts for information, as well as providing a natural model for hierarchical reuse. 77 | 78 | Unlike tree nodes, nodes of a multitree can have multiple parents, allowing us to create cross links between different task trees (see *Links* below). 79 | 80 | Multitrees are digraphs, so the nodes are connected to one another by directed links. For our purposes, the direction flows from parent tasks to their subtasks. From any node we can induce a valid tree by following the outgoing links: 81 | 82 |

83 | 84 |

85 | 86 | We also get valid *inverted trees* by going in the opposite direction, from nodes to their parents! This property is used by Grit to propagate changes made at the lower levels all the way up to the roots. 87 | 88 | ### States 89 | 90 | At any given time, a Grit task is said to be in one of the three states: 91 | 92 | 1. `[ ]` — *inactive;* task is yet to be completed 93 | 2. `[~]` — *in progress;* some of the subtasks have been completed 94 | 3. `[x]` or `[*]` — *completed;* `[*]` is used when the task is viewed in the context of a date that is different from the task's completion date 95 | 96 | ### Date nodes 97 | 98 | To add a time dimension to the structure, the idea of a *date node* is introduced. 99 | 100 | A date node is a root node with a special name that follows the standard date format `YYYY-MM-DD`. Descendants of date nodes are supposed to be completed on the stated date. Date nodes exist so long as they link to at least one descendant—they are created and destroyed automatically. 101 | 102 | ## Practical guide ## 103 | 104 | ### Basic usage ### 105 | 106 | Let's add a few things we want to do today: 107 | 108 | ``` 109 | $ grit add Take out the trash 110 | (1) -> (2) 111 | $ grit add Do the laundry 112 | (1) -> (3) 113 | $ grit add Call Dad 114 | (1) -> (4) 115 | ``` 116 | 117 | Run `grit` without arguments to display the current date tree: 118 | 119 | ``` 120 | $ grit 121 | [ ] 2020-11-10 (1) 122 | ├──[ ] Call Dad (4) 123 | ├──[ ] Do the laundry (3) 124 | └──[ ] Take out the trash (2) 125 | ``` 126 | 127 | So far it looks like an old-fashioned to-do list. We can `check` a task to mark it as completed: 128 | 129 | ``` 130 | $ grit check 2 131 | $ grit 132 | [~] 2020-11-10 (1) 133 | ├──[ ] Call Dad (4) 134 | ├──[ ] Do the laundry (3) 135 | └──[x] Take out the trash (2) 136 | ``` 137 | 138 | The change is automatically propagated through the graph. We can see that the status of the parent task (the date node) has changed to _in progress_. 139 | 140 | ### Subtasks ### 141 | 142 | Let's add another task: 143 | 144 | ``` 145 | $ grit add Get groceries 146 | (1) -> (5) 147 | ``` 148 | 149 | To divide it into subtasks, we have to specify the parent (when no parent is given, `add` defaults to the current date node): 150 | 151 | ``` 152 | $ grit add -p 5 Bread 153 | (5) -> (6) 154 | $ grit add -p 5 Milk 155 | (5) -> (7) 156 | $ grit add -p 5 Eggs 157 | (5) -> (8) 158 | ``` 159 | 160 | Task 5 is now pointing to subtasks 6, 7 and 8. We can create infinitely many levels, if needed. 161 | 162 | ``` 163 | $ grit 164 | [~] 2020-11-10 (1) 165 | ├──[ ] Call Dad (4) 166 | ├──[ ] Do the laundry (3) 167 | ├──[ ] Get groceries (5) 168 | │ ├──[ ] Bread (6) 169 | │ ├──[ ] Eggs (8) 170 | │ └──[ ] Milk (7) 171 | └──[x] Take out the trash (2) 172 | ``` 173 | 174 | Check the entire branch: 175 | 176 | ``` 177 | $ grit check 5 178 | $ grit tree 5 179 | [x] Get groceries (5) 180 | ├──[x] Bread (6) 181 | ├──[x] Eggs (8) 182 | └──[x] Milk (7) 183 | ``` 184 | 185 | The `tree` command prints out a tree rooted at the given node. When running `grit` without arguments, `tree` is invoked implicitly, defaulting to the current date node. 186 | 187 | ### Roots ### 188 | 189 | Some tasks are big—they can't realistically be completed in one day, so we can't associate them with a single date node. The trick is to add it as a root task and break it up into smaller subtasks. Then we can associate the subtasks with specific dates. 190 | 191 | To create a root, run `add` with the `-r` flag: 192 | 193 | ``` 194 | $ grit add -r Work through Higher Algebra - Henry S. Hall 195 | (9) 196 | ``` 197 | 198 | It's useful to assign aliases to frequently used nodes. An alias is an alternative identifier that can be used in place of a numeric one. 199 | 200 | ``` 201 | $ grit alias 9 textbook 202 | ``` 203 | 204 | The book contains 35 chapters—adding each of them individually would be very laborious. We can use a Bash loop to make the job easier (a feature like this will probably be added in a future release): 205 | 206 | ``` 207 | $ for i in {1..35}; do grit add -p textbook "Chapter $i"; done 208 | (9) -> (10) 209 | (9) -> (11) 210 | ... 211 | (9) -> (44) 212 | ``` 213 | 214 | Working through a chapter involves reading it and solving all the exercise problems included at the end. Chapter 1 has 28 exercises. 215 | 216 | ``` 217 | $ grit add -p 10 Read the chapter 218 | (10) -> (45) 219 | $ grit add -p 10 Solve the exercises 220 | (10) -> (46) 221 | $ for i in {1..28}; do grit add -p 46 "Solve ex. $i"; done 222 | (46) -> (47) 223 | (46) -> (48) 224 | ... 225 | (46) -> (74) 226 | ``` 227 | 228 | Our tree so far: 229 | 230 | ``` 231 | $ grit tree textbook 232 | [ ] Work through Higher Algebra - Henry S. Hall (9:textbook) 233 | ├──[ ] Chapter 1 (10) 234 | │ ├──[ ] Read the chapter (45) 235 | │ └──[ ] Solve the exercises (46) 236 | │ ├──[ ] Solve ex. 1 (47) 237 | │ ├──[ ] Solve ex. 2 (48) 238 | │ ├──[ ] ... 239 | │ └──[ ] Solve ex. 28 (74) 240 | ├──[ ] Chapter 2 (11) 241 | ├──[ ] Chapter ... 242 | └──[ ] Chapter 35 (44) 243 | ``` 244 | 245 | We can do this for each chapter, or leave it for later, building our tree as we go along. In any case, we are ready to use this tree to schedule our day. 246 | 247 | Before we proceed, let's run `stat` to see some more information about the node: 248 | 249 | ``` 250 | $ grit stat textbook 251 | 252 | (9) ───┬─── (10) 253 | ├─── (11) 254 | : 255 | └─── (44) 256 | 257 | ID: 9 258 | Name: Work through Higher Algebra - Henry S. Hall 259 | Status: inactive (0/63) 260 | Parents: 0 261 | Children: 35 262 | Alias: textbook 263 | ``` 264 | 265 | We can confirm that the node is a root—it has no parents. There's a little map showing the node's parents and children. Progress is also displayed, calculated by counting all the leaves reachable from the node. 266 | 267 | ### Links ### 268 | 269 | Say we want to read the first chapter of our Algebra book, and solve a few exercises today. Let's add a new task to the current date node: 270 | 271 | ``` 272 | $ grit add Work on ch. 1 of the Algebra textbook 273 | (1) -> (75) 274 | ``` 275 | 276 | Create cross links from this node to the relevant `textbook` nodes (the first argument to `link` is the origin, the ones following it are targets): 277 | 278 | ``` 279 | $ grit link 75 45 47 48 49 280 | $ grit 281 | [~] 2020-11-10 (1) 282 | ├──[x] ... 283 | └──[ ] Work on ch. 1 of the Algebra textbook (75) 284 | ├··[ ] Read the chapter (45) 285 | ├··[ ] Solve ex. 1 (47) 286 | ├··[ ] Solve ex. 2 (48) 287 | └··[ ] Solve ex. 3 (49) 288 | ``` 289 | 290 | The dotted lines indicate that the node has multiple parents. We can confirm this by taking a closer look at one of them using `stat`: 291 | 292 | ``` 293 | $ grit stat 45 294 | 295 | (10) ───┐ 296 | (75) ───┴─── (45) 297 | 298 | ID: 45 299 | Name: Read the chapter 300 | Status: inactive 301 | Parents: 2 302 | Children: 0 303 | ``` 304 | 305 | If we wanted to draw an accurate representation of the entire multitree at this point, it might look something like this: 306 | 307 |

308 | 309 |

310 | 311 | This looks somewhat readable, but attempts to draw a complete representation of a structure even slightly more complex than this typically result in a tangled mess. Because of this, Grit only gives us glimpses of the digraph, one `tree` (or `ls`) at a time. Beyond that it relies on the user to fill in the gaps. 312 | 313 | We can check the nodes and see how the changes propagate through the graph: 314 | 315 | ``` 316 | $ grit check 75 317 | $ grit 318 | [x] 2020-11-10 (1) 319 | ├──[x] ... 320 | └──[x] Work on ch. 1 of the algebra textbook (75) 321 | ├··[x] Read the chapter (45) 322 | ├··[x] Solve ex. 1 (47) 323 | ├··[x] Solve ex. 2 (48) 324 | └··[x] Solve ex. 3 (49) 325 | ``` 326 | 327 | The nodes are the same, so the change is visible in the textbook tree as well as the date tree: 328 | 329 | ``` 330 | $ grit tree textbook 331 | [~] Work through Higher Algebra - Henry S. Hall (9:textbook) 332 | ├──[~] Chapter 1 (10) 333 | │ ├··[x] Read the chapter (45) 334 | │ └──[~] Solve the exercises (46) 335 | │ ├··[x] Solve ex. 1 (47) 336 | │ ├··[x] Solve ex. 2 (48) 337 | │ ├··[x] Solve ex. 3 (49) 338 | │ ├──[ ] Solve ex. 4 (50) 339 | │ ├──[ ] ... 340 | │ └──[ ] Solve ex. 28 (74) 341 | ├──[ ] ... 342 | └──[ ] Chapter 35 (44) 343 | ``` 344 | 345 | We've completed all the tasks for the day, but there's still work to be done under `textbook`. We can schedule more work for tomorrow: 346 | 347 | ``` 348 | $ grit add -p 2020-11-11 Work on the algebra textbook 349 | (149) -> (150) 350 | $ grit add -p 150 Solve exercises from ch. 1 351 | (149) -> (151) 352 | $ grit link 151 50 51 52 53 54 353 | $ grit add -p 150 Work on ch. 2 354 | (149) -> (152) 355 | $ grit link 152 76 78 79 80 356 | $ grit tree 2020-11-11 357 | [x] 2020-11-10 (149) 358 | └──[ ] Work on the algebra textbook (150) 359 | ├──[ ] Solve exercises from ch. 1 (151) 360 | │ ├··[ ] Solve ex. 4 (50) 361 | │ ├··[ ] Solve ex. 5 (51) 362 | │ ├··[ ] Solve ex. 6 (52) 363 | │ ├··[ ] Solve ex. 7 (53) 364 | │ └··[ ] Solve ex. 8 (54) 365 | └──[ ] Work on ch. 2 (152) 366 | ├··[ ] Read the chapter (76) 367 | ├··[ ] Solve ex. 1 (78) 368 | ├··[ ] Solve ex. 2 (79) 369 | └··[ ] Solve ex. 3 (80) 370 | ``` 371 | 372 | ### Pointers ### 373 | 374 | We can define a *pointer* as a non-task node whose purpose is to link to other nodes. Pointers can be used to classify tasks, or as placeholders for tasks expected to be added in the future. 375 | 376 | #### Organizing tasks #### 377 | 378 | One aspect where Grit differs from other productivity tools is the lack of tags. This is by choice—Grit is an experiment, and the idea is to solve problems by utilizing the multitree as much as possible. 379 | 380 | How do we organize tasks without tags, then? As we add more and more nodes at the root level, things start to get messy. Running `grit ls` may result in a long list of assorted nodes. The Grit way to solve this is to make pointers. 381 | 382 | For example, if our algebra textbook was just one of many textbooks, we could create a node named "Textbooks" and point it at them: 383 | 384 | ``` 385 | $ grit add -r Textbooks 386 | (420) 387 | $ grit alias 420 textbooks 388 | $ grit link textbooks 81 184 349 389 | $ grit ls textbooks 390 | [~] Calculus - Michael Spivak (184) 391 | [x] Higher Algebra - Henry S. Hall (81) 392 | [ ] Linear Algebra - Jim Hefferon (349) 393 | ``` 394 | 395 | This gives them a parent, so they no longer appear at the root level. 396 | 397 | Note that the same node can be pointed to by an infinite number of nodes, allowing us to create overlapping categories, e.g. the same node may be reachable from "Books to read" and "Preparation for the upcoming talk", etc. 398 | 399 | #### Reading challenge #### 400 | 401 | A challenge can be a good motivational tool: 402 | 403 | ``` 404 | $ grit add -r Read 24 books in 2020 405 | (76) 406 | $ grit alias 76 rc2020 407 | ``` 408 | 409 | We could simply add books to it as we go, but this wouldn't give us a nice way to track our progress. Let's go a step further and create a pointer (or "slot") for each of the 24 books. 410 | 411 | ``` 412 | $ for i in {1..24}; do grit add -p rc2020 "Book $i"; done 413 | (76) -> (77) 414 | (76) -> (78) 415 | ... 416 | (76) -> (100) 417 | $ grit tree rc2020 418 | [ ] Challenge: Read 24 books in 2020 (76:rc2020) 419 | ├──[ ] Book 1 (77) 420 | ├──[ ] Book 2 (78) 421 | ├──[ ] ... 422 | └──[ ] Book 24 (100) 423 | ``` 424 | 425 | Now, whenever we decide what book we want to read next, we can simply create a new task and link the pointer to it: 426 | 427 | ``` 428 | $ grit add 1984 - George Orwell 429 | (1) -> (101) 430 | $ grit link 77 101 431 | $ grit check 101 432 | $ grit tree rc2020 433 | [~] Challenge: Read 24 books in 2020 (76:rc2020) 434 | ├──[x] Book 1 (77) 435 | │ └──[x] 1984 - George Orwell (101) 436 | └──[ ] ... 437 | ``` 438 | 439 | The number of leaves remains the same, so `stat` will correctly display our progress: 440 | 441 | ``` 442 | $ grit stat rc2020 443 | ... 444 | Status: in progress (1/24) 445 | ... 446 | ``` 447 | 448 | ### More information ### 449 | 450 | For more information about specific commands, refer to `grit --help`. 451 | 452 | ## License ## 453 | 454 | This project is released under the [MIT license](https://en.wikipedia.org/wiki/MIT_License). 455 | -------------------------------------------------------------------------------- /app/app.go: -------------------------------------------------------------------------------- 1 | // Package app implements grit's business logic layer. 2 | package app 3 | 4 | import ( 5 | "fmt" 6 | "path" 7 | "reflect" 8 | "strconv" 9 | 10 | "github.com/climech/grit/db" 11 | "github.com/climech/grit/multitree" 12 | 13 | "github.com/kirsle/configdir" 14 | sqlite "github.com/mattn/go-sqlite3" 15 | ) 16 | 17 | const ( 18 | AppName = "grit" 19 | ) 20 | 21 | var ( 22 | Version = "development" // overwritten on build 23 | ) 24 | 25 | type App struct { 26 | Database *db.Database 27 | } 28 | 29 | func New() (*App, error) { 30 | configPath := configdir.LocalConfig(AppName) 31 | if err := configdir.MakePath(configPath); err != nil { 32 | return nil, err 33 | } 34 | 35 | dbPath := path.Join(configPath, "graph.db") 36 | d, err := db.New(dbPath) 37 | if err != nil { 38 | return nil, fmt.Errorf("couldn't initialize db: %v", err) 39 | } 40 | 41 | return &App{Database: d}, nil 42 | } 43 | 44 | func (a *App) Close() { 45 | a.Database.Close() 46 | } 47 | 48 | // AddNode creates a root and returns it as a member of its multitree. 49 | func (a *App) AddRoot(name string) (*multitree.Node, error) { 50 | if err := multitree.ValidateNodeName(name); err != nil { 51 | return nil, NewError(ErrInvalidSelector, err.Error()) 52 | } 53 | if err := multitree.ValidateDateNodeName(name); err == nil { 54 | return nil, NewError(ErrInvalidName, 55 | fmt.Sprintf("%v is a reserved name", name)) 56 | } 57 | nodeID, err := a.Database.CreateNode(name, 0) 58 | if err != nil { 59 | return nil, err 60 | } 61 | return a.Database.GetGraph(nodeID) 62 | } 63 | 64 | // AddChild creates a new node and links an existing node to it. A 65 | // parent d-node is implicitly created, if it doesn't already exist. 66 | func (a *App) AddChild(name string, parent interface{}) (*multitree.Node, error) { 67 | if err := multitree.ValidateNodeName(name); err != nil { 68 | return nil, NewError(ErrInvalidName, err.Error()) 69 | } 70 | if err := multitree.ValidateDateNodeName(name); err == nil { 71 | return nil, NewError(ErrInvalidName, 72 | fmt.Sprintf("%v is a reserved name", name)) 73 | } 74 | parentID, err := a.selectorToID(parent) 75 | if err != nil { 76 | return nil, NewError(ErrInvalidSelector, err.Error()) 77 | } 78 | 79 | var nodeID int64 80 | var nodeErr error 81 | if parentID == 0 { 82 | nodeID, nodeErr = a.Database.CreateChildOfDateNode(parent.(string), name) 83 | } else { 84 | nodeID, nodeErr = a.Database.CreateNode(name, parentID) 85 | } 86 | 87 | if nodeErr != nil { 88 | if e, ok := nodeErr.(sqlite.Error); ok && e.ExtendedCode == sqlite.ErrConstraintForeignKey { 89 | return nil, NewError(ErrNotFound, "parent does not exist") 90 | } else { 91 | return nil, nodeErr 92 | } 93 | } 94 | 95 | return a.Database.GetGraph(nodeID) 96 | } 97 | 98 | func validateTree(root *multitree.Node) error { 99 | for _, n := range root.All() { 100 | if err := multitree.ValidateNodeName(n.Name); err != nil { 101 | return NewError(ErrInvalidName, err.Error()) 102 | } 103 | if err := multitree.ValidateDateNodeName(n.Name); err == nil { 104 | return NewError(ErrInvalidName, 105 | fmt.Sprintf("%v is a reserved name", n.Name)) 106 | } 107 | } 108 | return nil 109 | } 110 | 111 | // AddRootTree creates a new tree at the root level. It returns the root ID. 112 | func (a *App) AddRootTree(tree *multitree.Node) (int64, error) { 113 | if err := validateTree(tree); err != nil { 114 | return 0, err 115 | } 116 | return a.Database.CreateTree(tree, 0) 117 | } 118 | 119 | // AddChildTree creates a new tree and links parent to its root. It returns the 120 | // root ID. 121 | func (a *App) AddChildTree(tree *multitree.Node, parent interface{}) (int64, error) { 122 | if err := validateTree(tree); err != nil { 123 | return 0, err 124 | } 125 | 126 | parentID, err := a.selectorToID(parent) 127 | if err != nil { 128 | return 0, NewError(ErrInvalidSelector, err.Error()) 129 | } 130 | 131 | var rootID int64 132 | var createErr error 133 | if parentID == 0 { 134 | rootID, createErr = a.Database.CreateTreeAsChildOfDateNode(parent.(string), tree) 135 | } else { 136 | rootID, createErr = a.Database.CreateTree(tree, parentID) 137 | } 138 | 139 | if createErr != nil { 140 | if e, ok := createErr.(sqlite.Error); ok && e.ExtendedCode == sqlite.ErrConstraintForeignKey { 141 | return 0, NewError(ErrNotFound, "parent does not exist") 142 | } else { 143 | return 0, createErr 144 | } 145 | } 146 | 147 | return rootID, nil 148 | } 149 | 150 | func (a *App) RenameNode(selector interface{}, name string) error { 151 | if err := multitree.ValidateDateNodeName(name); err == nil { 152 | return NewError(ErrForbidden, "date nodes cannot be renamed") 153 | } 154 | id, err := a.selectorToID(selector) 155 | if err != nil { 156 | return NewError(ErrInvalidSelector, err.Error()) 157 | } 158 | if id == 0 { 159 | return NewError(ErrNotFound, "node does not exist") 160 | } 161 | if err := multitree.ValidateNodeName(name); err != nil { 162 | return NewError(ErrInvalidName, err.Error()) 163 | } 164 | 165 | node, err := a.Database.GetNode(id) 166 | if err != nil { 167 | return err 168 | } 169 | if node == nil { 170 | return NewError(ErrNotFound, "node does not exist") 171 | } 172 | if multitree.ValidateDateNodeName(node.Name) == nil { 173 | return NewError(ErrForbidden, "date nodes cannot be renamed") 174 | } 175 | 176 | if err := a.Database.RenameNode(node.ID, name); err != nil { 177 | return err 178 | } 179 | return nil 180 | } 181 | 182 | func (a *App) GetGraph(selector interface{}) (*multitree.Node, error) { 183 | id, err := a.selectorToID(selector) 184 | if err != nil { 185 | return nil, NewError(ErrInvalidSelector, err.Error()) 186 | } 187 | if id == 0 { 188 | if s, ok := selector.(string); ok && multitree.ValidateDateNodeName(s) == nil { 189 | // Return a mock d-node. 190 | return multitree.NewNode(s), nil 191 | } 192 | return nil, NewError(ErrNotFound, "node does not exist") 193 | } 194 | return a.Database.GetGraph(id) 195 | } 196 | 197 | func (a *App) GetNode(selector interface{}) (*multitree.Node, error) { 198 | id, err := a.selectorToID(selector) 199 | if err != nil { 200 | return nil, NewError(ErrInvalidSelector, err.Error()) 201 | } 202 | if id == 0 { 203 | // Return mock d-node. 204 | return multitree.NewNode(selector.(string)), nil 205 | } 206 | return a.Database.GetNode(id) 207 | } 208 | func (a *App) GetNodeByName(name string) (*multitree.Node, error) { 209 | return a.Database.GetNodeByName(name) 210 | } 211 | 212 | func (a *App) GetNodeByAlias(alias string) (*multitree.Node, error) { 213 | return a.Database.GetNodeByAlias(alias) 214 | } 215 | 216 | // LinkNodes creates a new link connecting two nodes. D-nodes are implicitly 217 | // created as needed. 218 | func (a *App) LinkNodes(origin, dest interface{}) (*multitree.Link, error) { 219 | originID, err := a.selectorToID(origin) 220 | if err != nil { 221 | return nil, NewError(ErrInvalidSelector, err.Error()) 222 | } 223 | destID, err := a.selectorToID(dest) 224 | if err != nil { 225 | return nil, NewError(ErrInvalidSelector, err.Error()) 226 | } 227 | 228 | var linkID int64 229 | var errCreate error 230 | if originID == 0 { 231 | linkID, errCreate = a.Database.CreateLinkFromDateNode(origin.(string), destID) 232 | } else { 233 | linkID, errCreate = a.Database.CreateLink(originID, destID) 234 | } 235 | if errCreate != nil { 236 | return nil, errCreate 237 | } 238 | 239 | return a.Database.GetLink(linkID) 240 | } 241 | 242 | // UnlinkNodes removes the link connecting the given nodes. 243 | func (a *App) UnlinkNodes(origin, dest interface{}) error { 244 | originID, err := a.selectorToID(origin) 245 | if err != nil { 246 | return NewError(ErrInvalidSelector, err.Error()) 247 | } 248 | destID, err := a.selectorToID(dest) 249 | if err != nil { 250 | return err 251 | } 252 | if originID == 0 || destID == 0 { 253 | // Assuming there can't be an link from/to an empty d-node. 254 | return NewError(ErrNotFound, "link does not exist") 255 | } 256 | if err := a.Database.DeleteLinkByEndpoints(originID, destID); err != nil { 257 | return err 258 | } 259 | return nil 260 | } 261 | 262 | func (a *App) SetAlias(id int64, alias string) error { 263 | err := a.Database.SetAlias(id, alias) 264 | if err != nil { 265 | if e, ok := err.(sqlite.Error); ok && e.ExtendedCode == sqlite.ErrConstraintUnique { 266 | return NewError(ErrForbidden, "alias already exists") 267 | } 268 | return err 269 | } 270 | return nil 271 | } 272 | 273 | // RemoveNode deletes the node and returns its orphaned children. 274 | func (a *App) RemoveNode(selector interface{}) ([]*multitree.Node, error) { 275 | id, err := a.selectorToID(selector) 276 | if err != nil { 277 | return nil, NewError(ErrInvalidSelector, err.Error()) 278 | } 279 | if id == 0 { 280 | return nil, NewError(ErrNotFound, "node does not exist") 281 | } 282 | orphaned, err := a.Database.DeleteNode(id) 283 | if err != nil { 284 | return nil, err 285 | } 286 | return orphaned, nil 287 | } 288 | 289 | // RemoveNodeRecursive deletes the node and all its tree descendants. Nodes 290 | // that have multiple parents are only unlinked from the current tree. 291 | func (a *App) RemoveNodeRecursive(selector interface{}) ([]*multitree.Node, error) { 292 | id, err := a.selectorToID(selector) 293 | if err != nil { 294 | return nil, NewError(ErrInvalidSelector, err.Error()) 295 | } 296 | if id == 0 { 297 | return nil, NewError(ErrNotFound, "node does not exist") 298 | } 299 | deleted, err := a.Database.DeleteNodeRecursive(id) 300 | if err != nil { 301 | return nil, err 302 | } 303 | return deleted, nil 304 | } 305 | 306 | func (a *App) checkNode(selector interface{}, value bool) error { 307 | id, err := a.selectorToID(selector) 308 | if err != nil { 309 | return NewError(ErrInvalidSelector, err.Error()) 310 | } 311 | if value { 312 | return a.Database.CheckNode(id) 313 | } 314 | return a.Database.UncheckNode(id) 315 | } 316 | 317 | func (a *App) CheckNode(selector interface{}) error { 318 | return a.checkNode(selector, true) 319 | } 320 | 321 | func (a *App) UncheckNode(selector interface{}) error { 322 | return a.checkNode(selector, false) 323 | } 324 | 325 | func (a *App) GetRoots() ([]*multitree.Node, error) { 326 | roots, err := a.Database.GetRoots() 327 | if err != nil { 328 | return nil, err 329 | } 330 | if len(roots) == 0 { 331 | return nil, nil 332 | } 333 | var ret []*multitree.Node 334 | for _, r := range roots { 335 | // Omit d-nodes. 336 | if multitree.ValidateDateNodeName(r.Name) != nil { 337 | ret = append(ret, r) 338 | } 339 | } 340 | // TODO: sort by name alphabetically(?) 341 | return ret, nil 342 | } 343 | 344 | func (a *App) GetDateNodes() ([]*multitree.Node, error) { 345 | roots, err := a.Database.GetRoots() 346 | if err != nil { 347 | return nil, err 348 | } 349 | if len(roots) == 0 { 350 | return nil, nil 351 | } 352 | var ret []*multitree.Node 353 | for _, r := range roots { 354 | // Omit roots that aren't d-nodes. 355 | if multitree.ValidateDateNodeName(r.Name) == nil { 356 | ret = append(ret, r) 357 | } 358 | } 359 | // TODO: sort by name alphabetically(?) 360 | return ret, nil 361 | } 362 | 363 | func (a *App) stringSelectorToID(selector string) (int64, error) { 364 | // Check if integer. 365 | id, err := strconv.ParseInt(selector, 10, 64) 366 | if err == nil && id > 0 { 367 | return id, nil 368 | } 369 | // Check if date. 370 | if multitree.ValidateDateNodeName(selector) == nil { 371 | node, err := a.GetNodeByName(selector) 372 | if err != nil { 373 | return 0, err 374 | } 375 | if node == nil { 376 | return 0, nil // not found 377 | } 378 | return node.ID, nil 379 | } 380 | // Check if alias. 381 | if multitree.ValidateNodeAlias(selector) == nil { 382 | node, err := a.GetNodeByAlias(selector) 383 | if err != nil { 384 | return 0, err 385 | } 386 | if node == nil { 387 | return 0, nil // not found 388 | } 389 | return node.ID, nil 390 | } 391 | return 0, fmt.Errorf("invalid selector") 392 | } 393 | 394 | // selectorToID parses the selector and returns a valid node ID, or zero for 395 | // valid d-node name that isn't in the DB. 396 | func (a *App) selectorToID(selector interface{}) (int64, error) { 397 | switch value := selector.(type) { 398 | case *multitree.Node: 399 | return value.ID, nil 400 | case string: 401 | return a.stringSelectorToID(value) 402 | case int64: 403 | if value < 1 { 404 | return 0, fmt.Errorf("invalid selector") 405 | } 406 | return value, nil 407 | default: 408 | panic(fmt.Sprintf("unsupported selector type: %v", 409 | reflect.TypeOf(selector))) 410 | } 411 | } 412 | -------------------------------------------------------------------------------- /app/app_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "reflect" 8 | "testing" 9 | "time" 10 | 11 | "github.com/climech/grit/db" 12 | ) 13 | 14 | // setupApp creates a new App and hooks it up to a test database. 15 | func setupApp(t *testing.T) *App { 16 | tmpfile, err := ioutil.TempFile("", "grit_test_db") 17 | if err != nil { 18 | t.Fatalf("couldn't create temp file: %v", err) 19 | } 20 | tmpfile.Close() // We only want the name. 21 | d, err := db.New(tmpfile.Name()) 22 | if err != nil { 23 | t.Fatalf("couldn't create db: %v", err) 24 | } 25 | return &App{Database: d} 26 | } 27 | 28 | func tearApp(t *testing.T, a *App) { 29 | a.Close() 30 | if err := os.Remove(a.Database.Filename); err != nil { 31 | t.Fatalf("error removing file: %v", err) 32 | } 33 | } 34 | 35 | func ptrValueToString(ptr interface{}) string { 36 | v := reflect.ValueOf(ptr) 37 | if v.IsNil() { 38 | return "nil" 39 | } 40 | return fmt.Sprintf("%v\n", reflect.Indirect(v)) 41 | } 42 | 43 | // TestLoop fails if it's able to create a loop. 44 | func TestLoop(t *testing.T) { 45 | a := setupApp(t) 46 | defer tearApp(t, a) 47 | 48 | node, err := a.AddRoot("test") 49 | if err != nil { 50 | t.Fatal("couldn't create node (1)") 51 | } 52 | 53 | if _, err := a.LinkNodes(node.ID, node.ID); err == nil { 54 | t.Fatal("loop created and no error returned") 55 | } 56 | } 57 | 58 | // TestBackEdge fails if it's able to create a back edge. 59 | func TestBackEdge(t *testing.T) { 60 | a := setupApp(t) 61 | defer tearApp(t, a) 62 | 63 | // Create the graph: 64 | // 65 | // [ ] test (1) 66 | // └──[ ] test (2) 67 | // └──[ ] test (3) 68 | // 69 | node1, err := a.AddRoot("test") 70 | if err != nil { 71 | t.Fatal("couldn't create node (1)") 72 | } 73 | node2, err := a.AddChild("test", node1.ID) 74 | if err != nil { 75 | t.Fatal("couldn't create node (2)") 76 | } 77 | node3, err := a.AddChild("test", node2.ID) 78 | if err != nil { 79 | t.Fatal("couldn't create node (3)") 80 | } 81 | 82 | // To make a cycle, link (3) to (1). 83 | if _, err := a.LinkNodes(node3.ID, node1.ID); err == nil { 84 | t.Fatal("a back edge was created") 85 | } 86 | } 87 | 88 | // TestForwardEdge fails if it's able to create a forward edge. 89 | func TestForwardEdge(t *testing.T) { 90 | a := setupApp(t) 91 | defer tearApp(t, a) 92 | 93 | // Create the graph: 94 | // 95 | // [ ] test (1) 96 | // └──[ ] test (2) 97 | // └──[ ] test (3) 98 | // 99 | node1, err := a.AddRoot("test") 100 | if err != nil { 101 | t.Fatal("couldn't create node (1)") 102 | } 103 | node2, err := a.AddChild("test", node1.ID) 104 | if err != nil { 105 | t.Fatal("couldn't create node (2)") 106 | } 107 | node3, err := a.AddChild("test", node2.ID) 108 | if err != nil { 109 | t.Fatal("couldn't create node (3)") 110 | } 111 | 112 | // To make a forward edge, link (1) to (3). 113 | if _, err := a.LinkNodes(node1.ID, node3.ID); err == nil { 114 | t.Fatal("forward edge successfully created") 115 | } 116 | } 117 | 118 | // TestCrossEdge fails if it cannot create a cross edge. 119 | func TestCrossEdge(t *testing.T) { 120 | a := setupApp(t) 121 | defer tearApp(t, a) 122 | 123 | // Create the nodes: 124 | // 125 | // [ ] test (1) 126 | // └──[ ] test (2) 127 | // 128 | // [ ] test (3) 129 | // 130 | root1, err := a.AddRoot("test") 131 | if err != nil { 132 | t.Fatal("couldn't create node (1)") 133 | } 134 | succ, err := a.AddChild("test", root1.ID) 135 | if err != nil { 136 | t.Fatal("couldn't create node (2)") 137 | } 138 | root2, err := a.AddRoot("test") 139 | if err != nil { 140 | t.Fatal("couldn't create node (3)") 141 | } 142 | 143 | // To make a cross edge, link (3) to (2). 144 | if _, err := a.LinkNodes(root2.ID, succ.ID); err != nil { 145 | t.Fatalf("couldn't create a cross edge: %v", err) 146 | } 147 | } 148 | 149 | func TestStatusChange(t *testing.T) { 150 | a := setupApp(t) 151 | defer tearApp(t, a) 152 | 153 | // Create the graph: 154 | // 155 | // [ ] test (1) 156 | // ├──[ ] test (2) 157 | // └──[ ] test (3) 158 | // └──[ ] test (4) 159 | // 160 | node1, err := a.AddRoot("test") 161 | if err != nil { 162 | t.Fatal("couldn't create node (1)") 163 | } 164 | node2, err := a.AddChild("test", node1.ID) 165 | if err != nil { 166 | t.Fatal("couldn't create node (2)") 167 | } 168 | node3, err := a.AddChild("test", node1.ID) 169 | if err != nil { 170 | t.Fatal("couldn't create node (3)") 171 | } 172 | node4, err := a.AddChild("test", node3.ID) 173 | if err != nil { 174 | t.Fatal("couldn't create node (4)") 175 | } 176 | 177 | // Checking (3) and (2) should cause (1) and (4) to be checked as well. 178 | // 179 | // [x] test (1) 180 | // ├──[x] test (2) 181 | // └──[x] test (3) 182 | // └──[x] test (4) 183 | // 184 | if err := a.CheckNode(node3.ID); err != nil { 185 | t.Fatalf("couldn't check successor (3): %v", err) 186 | } 187 | time.Sleep(1 * time.Second) // to make the timestamps different 188 | if err := a.CheckNode(node2.ID); err != nil { 189 | t.Fatalf("couldn't check successor (2): %v", err) 190 | } 191 | 192 | if root, err := a.GetGraph(node1.ID); err != nil { 193 | t.Fatalf("couldn't get graph: %v", err) 194 | } else { 195 | if n := root.Get(node2.ID); !n.IsCompleted() { 196 | t.Errorf("checking node had no effect: %s", n) 197 | } 198 | if n := root.Get(node3.ID); !n.IsCompleted() { 199 | t.Errorf("checking node had no effect: %s", n) 200 | } 201 | if !root.IsCompleted() { 202 | t.Error("checked all successors of root, but root.IsCompleted() = false") 203 | } 204 | if !root.Get(node4.ID).IsCompleted() { 205 | t.Error("node checked, but successor is still unchecked") 206 | } 207 | 208 | c1 := root.Completed 209 | c2 := root.Get(node2.ID).Completed 210 | c3 := root.Get(node3.ID).Completed 211 | c4 := root.Get(node4.ID).Completed 212 | 213 | if !reflect.DeepEqual(c1, c2) { 214 | t.Errorf("backpropped completion time should be the same as in "+ 215 | "last checked successor; want %v, got %v", 216 | ptrValueToString(c2), ptrValueToString(c1)) 217 | } 218 | if !reflect.DeepEqual(c3, c4) { 219 | t.Errorf("successor should inherit the completion time from its checked "+ 220 | "predecessor; want %v, got %v", ptrValueToString(c3), 221 | ptrValueToString(c4)) 222 | } 223 | } 224 | 225 | // Unchecking (3) should cause (1) and (4) to be unchecked as well; (2) should 226 | // be left unchanged. 227 | // 228 | // [ ] test (1) 229 | // ├──[x] test (2) 230 | // └──[ ] test (3) 231 | // └──[ ] test (4) 232 | // 233 | if err := a.UncheckNode(node3.ID); err != nil { 234 | t.Fatalf("couldn't uncheck successor (3): %v", err) 235 | } 236 | if root, err := a.GetGraph(node1.ID); err != nil { 237 | t.Fatalf("couldn't get graph: %v", err) 238 | } else { 239 | if n := root.Get(node3.ID); n.IsCompleted() { 240 | t.Fatalf("unchecking node had no effect: %s", n) 241 | } 242 | if root.IsCompleted() { 243 | t.Fatal("node unchecked, but predecessor is checked") 244 | } 245 | if n := root.Get(node2.ID); !n.IsCompleted() { 246 | t.Fatalf("unchecking node changed node's sibling(!): %s", n) 247 | } 248 | if n := root.Get(node4.ID); n.IsCompleted() { 249 | t.Fatalf("node unchecked, but successor is checked: %s", n) 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /app/errors.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | ) 5 | 6 | type ErrCode int 7 | 8 | const ( 9 | ErrNotFound ErrCode = iota 10 | ErrForbidden 11 | ErrInvalidSelector 12 | ErrInvalidName 13 | ) 14 | 15 | type AppError struct { 16 | Msg string 17 | Code ErrCode 18 | } 19 | 20 | func (e *AppError) Error() string { 21 | return e.Msg 22 | } 23 | 24 | func NewError(code ErrCode, msg string) *AppError { 25 | return &AppError{Msg: msg, Code: code} 26 | } 27 | -------------------------------------------------------------------------------- /cmd/grit/cmds.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/climech/grit/app" 12 | "github.com/climech/grit/multitree" 13 | 14 | "github.com/fatih/color" 15 | cli "github.com/jawher/mow.cli" 16 | ) 17 | 18 | func cmdAdd(cmd *cli.Cmd) { 19 | cmd.Spec = "[ -p= | -r ] NAME_PARTS..." 20 | today := time.Now().Format("2006-01-02") 21 | 22 | var ( 23 | nameParts = cmd.StringsArg("NAME_PARTS", nil, 24 | "strings to be joined together to form the node's name") 25 | predecessor = cmd.StringOpt("p predecessor", today, 26 | "predecessor to attach the node to") 27 | makeRoot = cmd.BoolOpt("r root", false, 28 | "create a root node") 29 | ) 30 | 31 | cmd.Action = func() { 32 | a, err := app.New() 33 | if err != nil { 34 | die(err) 35 | } 36 | defer a.Close() 37 | 38 | name := strings.Join(*nameParts, " ") 39 | 40 | if *makeRoot { 41 | node, err := a.AddRoot(name) 42 | if err != nil { 43 | dief("Couldn't create node: %v\n", err) 44 | } 45 | color.Cyan("(%d)", node.ID) 46 | } else { 47 | node, err := a.AddChild(name, *predecessor) 48 | if err != nil { 49 | dief("Couldn't create node: %v\n", err) 50 | } 51 | parents := node.Parents() 52 | accent := color.New(color.FgCyan).SprintFunc() 53 | if parents[0].Name == today { 54 | accent = color.New(color.FgYellow).SprintFunc() 55 | } 56 | highlighted := accent(fmt.Sprintf("(%d)", node.ID)) 57 | fmt.Printf("(%d) -> %s\n", parents[0].ID, highlighted) 58 | } 59 | } 60 | } 61 | 62 | func cmdTree(cmd *cli.Cmd) { 63 | cmd.Spec = "[NODE]" 64 | today := time.Now().Format("2006-01-02") 65 | var ( 66 | selector = cmd.StringArg("NODE", today, "node selector") 67 | ) 68 | cmd.Action = func() { 69 | a, err := app.New() 70 | if err != nil { 71 | die(err) 72 | } 73 | defer a.Close() 74 | 75 | node, err := a.GetGraph(*selector) 76 | if err != nil { 77 | die(capitalize(err.Error())) 78 | } 79 | if node == nil { 80 | die("Node does not exist") 81 | } 82 | 83 | node.TraverseDescendants(func(current *multitree.Node, _ func()) { 84 | multitree.SortNodesByName(current.Children()) 85 | }) 86 | fmt.Print(node.StringTree()) 87 | } 88 | } 89 | 90 | func cmdList(cmd *cli.Cmd) { 91 | cmd.Spec = "[NODE]" 92 | var ( 93 | selector = cmd.StringArg("NODE", "", "node selector") 94 | ) 95 | cmd.Action = func() { 96 | a, err := app.New() 97 | if err != nil { 98 | die(err) 99 | } 100 | defer a.Close() 101 | var nodes []*multitree.Node 102 | 103 | if *selector == "" { 104 | // List roots by default. 105 | roots, err := a.GetRoots() 106 | if err != nil { 107 | die(err) 108 | } 109 | for _, r := range roots { 110 | // Get as part of multitree for accurate status. 111 | n, err := a.GetGraph(r.ID) 112 | if err != nil { 113 | die(err) 114 | } 115 | if n == nil { 116 | continue 117 | } 118 | nodes = append(nodes, n) 119 | } 120 | } else { 121 | node, err := a.GetGraph(*selector) 122 | if err != nil { 123 | die(capitalize(err.Error())) 124 | } 125 | if node == nil { 126 | die("Node does not exist") 127 | } 128 | nodes = node.Children() 129 | } 130 | 131 | multitree.SortNodesByName(nodes) 132 | for _, n := range nodes { 133 | fmt.Println(n) 134 | } 135 | } 136 | } 137 | 138 | func cmdCheck(cmd *cli.Cmd) { 139 | cmd.Spec = "NODE..." 140 | var ( 141 | selectors = cmd.StringsArg("NODE", nil, "node selector(s)") 142 | ) 143 | cmd.Action = func() { 144 | a, err := app.New() 145 | if err != nil { 146 | die(err) 147 | } 148 | defer a.Close() 149 | for _, sel := range *selectors { 150 | if err := a.CheckNode(sel); err != nil { 151 | dief("Couldn't check node: %v", err) 152 | } 153 | } 154 | } 155 | } 156 | 157 | func cmdUncheck(cmd *cli.Cmd) { 158 | cmd.Spec = "NODE..." 159 | var ( 160 | selectors = cmd.StringsArg("NODE", nil, "node selector(s)") 161 | ) 162 | cmd.Action = func() { 163 | a, err := app.New() 164 | if err != nil { 165 | die(err) 166 | } 167 | defer a.Close() 168 | for _, sel := range *selectors { 169 | if err := a.UncheckNode(sel); err != nil { 170 | dief("Couldn't uncheck node: %v", err) 171 | } 172 | } 173 | } 174 | } 175 | 176 | func cmdLink(cmd *cli.Cmd) { 177 | cmd.Spec = "ORIGIN TARGETS..." 178 | var ( 179 | origin = cmd.StringArg("ORIGIN", "", "origin selector") 180 | targets = cmd.StringsArg("TARGETS", nil, "target selector(s)") 181 | ) 182 | cmd.Action = func() { 183 | a, err := app.New() 184 | if err != nil { 185 | die(err) 186 | } 187 | defer a.Close() 188 | 189 | for _, t := range *targets { 190 | if _, err := a.LinkNodes(*origin, t); err != nil { 191 | errf("Couldn't create link (%s) -> (%s): %v\n", *origin, t, err) 192 | } 193 | } 194 | } 195 | } 196 | 197 | func cmdUnlink(cmd *cli.Cmd) { 198 | cmd.Spec = "ORIGIN TARGET" 199 | var ( 200 | origin = cmd.StringArg("ORIGIN", "", "origin selector") 201 | target = cmd.StringArg("TARGET", "", "target selector") 202 | ) 203 | cmd.Action = func() { 204 | a, err := app.New() 205 | if err != nil { 206 | die(err) 207 | } 208 | defer a.Close() 209 | 210 | if err := a.UnlinkNodes(*origin, *target); err != nil { 211 | dief("Couldn't unlink nodes: %v\n", err) 212 | } 213 | } 214 | } 215 | 216 | func cmdListDates(cmd *cli.Cmd) { 217 | cmd.Action = func() { 218 | a, err := app.New() 219 | if err != nil { 220 | die(err) 221 | } 222 | defer a.Close() 223 | 224 | dnodes, err := a.GetDateNodes() 225 | if err != nil { 226 | die(err) 227 | } 228 | for _, d := range dnodes { 229 | // Get the nodes as members of their graphs to get accurate status. 230 | n, err := a.GetGraph(d.ID) 231 | if err != nil { 232 | die(err) 233 | } 234 | if n == nil { 235 | continue 236 | } 237 | fmt.Println(n) 238 | } 239 | } 240 | } 241 | 242 | func cmdRename(cmd *cli.Cmd) { 243 | cmd.Spec = "NODE NAME_PARTS..." 244 | var ( 245 | selector = cmd.StringArg("NODE", "", "node selector") 246 | nameParts = cmd.StringsArg("NAME_PARTS", nil, 247 | "strings forming the new node name") 248 | ) 249 | cmd.Action = func() { 250 | a, err := app.New() 251 | if err != nil { 252 | die(err) 253 | } 254 | defer a.Close() 255 | name := strings.Join(*nameParts, " ") 256 | if err := a.RenameNode(*selector, name); err != nil { 257 | dief("Couldn't rename node: %v", err) 258 | } 259 | } 260 | } 261 | 262 | func cmdAlias(cmd *cli.Cmd) { 263 | cmd.Spec = "NODE_ID ALIAS" 264 | var ( 265 | selector = cmd.StringArg("NODE_ID", "", "node ID selector") 266 | alias = cmd.StringArg("ALIAS", "", "alias string") 267 | ) 268 | cmd.Action = func() { 269 | a, err := app.New() 270 | if err != nil { 271 | die(err) 272 | } 273 | defer a.Close() 274 | 275 | id, err := strconv.ParseInt(*selector, 10, 64) 276 | if err != nil { 277 | dief("Selector must be an integer") 278 | } 279 | if err := a.SetAlias(id, *alias); err != nil { 280 | dief("Couldn't set alias: %v", err) 281 | } 282 | } 283 | } 284 | 285 | func cmdUnalias(cmd *cli.Cmd) { 286 | cmd.Spec = "NODE_ID" 287 | var ( 288 | selector = cmd.StringArg("NODE_ID", "", "node ID selector") 289 | ) 290 | cmd.Action = func() { 291 | a, err := app.New() 292 | if err != nil { 293 | die(err) 294 | } 295 | defer a.Close() 296 | 297 | id, err := strconv.ParseInt(*selector, 10, 64) 298 | if err != nil { 299 | dief("Selector must be an integer") 300 | } 301 | if err := a.SetAlias(id, ""); err != nil { 302 | dief("Couldn't unset alias: %v", err) 303 | } 304 | } 305 | } 306 | 307 | func cmdRemove(cmd *cli.Cmd) { 308 | cmd.Spec = "[-r] [-v] NODE..." 309 | var ( 310 | selectors = cmd.StringsArg("NODE", nil, "node selector(s)") 311 | recursive = cmd.BoolOpt("r recursive", false, 312 | "remove node and all its descendants") 313 | verbose = cmd.BoolOpt("v verbose", false, "print each removed node") 314 | ) 315 | cmd.Action = func() { 316 | a, err := app.New() 317 | if err != nil { 318 | die(err) 319 | } 320 | defer a.Close() 321 | 322 | var msgs []string 323 | var errs []error 324 | 325 | appendErr := func(sel string, err error) { 326 | errs = append(errs, fmt.Errorf("Couldn't remove %s: %v", sel, err)) 327 | } 328 | 329 | for _, sel := range *selectors { 330 | if *recursive { 331 | removed, err := a.RemoveNodeRecursive(sel) 332 | if err != nil { 333 | appendErr(sel, err) 334 | continue 335 | } 336 | for _, node := range removed { 337 | msgs = append(msgs, fmt.Sprintf("Removed: %v ", node)) 338 | } 339 | } else { 340 | removed, err := a.GetGraph(sel) 341 | if err != nil { 342 | appendErr(sel, err) 343 | continue 344 | } 345 | orphaned, err := a.RemoveNode(sel) 346 | if err != nil { 347 | appendErr(sel, err) 348 | continue 349 | } 350 | msgs = append(msgs, fmt.Sprintf("Removed: %v ", removed)) 351 | for _, node := range orphaned { 352 | msgs = append(msgs, fmt.Sprintf("Orphaned: %v ", node)) 353 | } 354 | } 355 | } 356 | 357 | if *verbose { 358 | for _, msg := range msgs { 359 | fmt.Println(msg) 360 | } 361 | } 362 | 363 | for _, e := range errs { 364 | fmt.Fprintln(os.Stderr, e) 365 | } 366 | } 367 | } 368 | 369 | func cmdImport(cmd *cli.Cmd) { 370 | cmd.Spec = "[ -p= | -r ] [FILENAME]" 371 | today := time.Now().Format("2006-01-02") 372 | 373 | var ( 374 | filename = cmd.StringArg("FILENAME", "", 375 | "file containing tab-indented lines") 376 | predecessor = cmd.StringOpt("p predecessor", today, 377 | "predecessor for the tree root(s)") 378 | makeRoot = cmd.BoolOpt("r root", false, "create top-level tree(s)") 379 | ) 380 | 381 | cmd.Action = func() { 382 | a, err := app.New() 383 | if err != nil { 384 | die(err) 385 | } 386 | defer a.Close() 387 | 388 | var reader io.Reader 389 | if *filename == "" { 390 | reader = os.Stdin 391 | } else { 392 | f, err := os.Open(*filename) 393 | if err != nil { 394 | dief("%s\n", capitalize(err.Error())) 395 | } 396 | defer f.Close() 397 | reader = f 398 | } 399 | 400 | roots, err := multitree.ImportTrees(reader) 401 | if err != nil { 402 | dief("Import error: %v", err) 403 | } 404 | 405 | var errs []error 406 | var treesTotal, nodesTotal int 407 | 408 | for _, root := range roots { 409 | var id int64 410 | var err error 411 | if *makeRoot { 412 | id, err = a.AddRootTree(root) 413 | } else { 414 | id, err = a.AddChildTree(root, *predecessor) 415 | } 416 | if err != nil { 417 | e := fmt.Errorf("Couldn't import tree: %v", err) 418 | errs = append(errs, e) 419 | continue 420 | } 421 | 422 | if g, err := a.GetGraph(id); err != nil { 423 | errs = append(errs, err) 424 | } else { 425 | fmt.Print(g.StringTree()) 426 | treesTotal++ 427 | nodesTotal += len(g.Tree().All()) 428 | } 429 | } 430 | 431 | for _, e := range errs { 432 | fmt.Fprintln(os.Stderr, e) 433 | } 434 | fmt.Printf("Imported %d trees (%d nodes)\n", treesTotal, nodesTotal) 435 | } 436 | } 437 | 438 | func cmdStat(cmd *cli.Cmd) { 439 | cmd.Spec = "NODE" 440 | var ( 441 | selector = cmd.StringArg("NODE", "", "node selector") 442 | ) 443 | cmd.Action = func() { 444 | a, err := app.New() 445 | if err != nil { 446 | die(err) 447 | } 448 | defer a.Close() 449 | 450 | node, err := a.GetGraph(*selector) 451 | if err != nil { 452 | die(err) 453 | } else if node == nil { 454 | die("Node does not exist") 455 | } 456 | 457 | parents := node.Parents() 458 | children := node.Children() 459 | 460 | if len(parents)+len(children) > 0 { 461 | fmt.Println(node.StringNeighbors()) 462 | } 463 | 464 | status := node.Status().String() 465 | leaves := node.Leaves() 466 | done := 0 467 | total := len(leaves) 468 | for _, leaf := range leaves { 469 | if leaf.IsCompleted() { 470 | done++ 471 | } 472 | } 473 | if total > 0 { 474 | status += fmt.Sprintf(" (%d/%d)", done, total) 475 | } 476 | 477 | // Make name bold if root. 478 | name := node.Name 479 | if len(parents) == 0 { 480 | bold := color.New(color.Bold).SprintFunc() 481 | name = bold(name) 482 | } 483 | 484 | fmt.Printf("ID: %d\n", node.ID) 485 | fmt.Printf("Name: %s\n", name) 486 | fmt.Printf("Status: %s\n", status) 487 | fmt.Printf("Parents: %d\n", len(parents)) 488 | fmt.Printf("Children: %d\n", len(children)) 489 | 490 | if node.Alias != "" { 491 | fmt.Printf("Alias: %s\n", node.Alias) 492 | } 493 | 494 | timeFmt := "2006-01-02 15:04:05" 495 | fmt.Printf("Created: %s\n", time.Unix(node.Created, 0).Format(timeFmt)) 496 | if node.IsCompleted() { 497 | fmt.Printf("Checked: %s\n", time.Unix(*node.Completed, 0).Format(timeFmt)) 498 | } 499 | 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /cmd/grit/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/climech/grit/app" 8 | cli "github.com/jawher/mow.cli" 9 | ) 10 | 11 | func main() { 12 | c := cli.App(app.AppName, "A multitree-based personal task manager") 13 | c.Version("v version", fmt.Sprintf("%s %s", app.AppName, app.Version)) 14 | 15 | c.Command("add", "Add a new node", cmdAdd) 16 | c.Command("alias", "Create alias", cmdAlias) 17 | c.Command("unalias", "Remove alias", cmdUnalias) 18 | c.Command("tree", "Print tree representation rooted at node", cmdTree) 19 | c.Command("check", "Mark node(s) as completed", cmdCheck) 20 | c.Command("uncheck", "Revert node status to inactive", cmdUncheck) 21 | c.Command("link", "Create a link from one node to another", cmdLink) 22 | c.Command("unlink", "Remove an existing link between two nodes", cmdUnlink) 23 | c.Command("list ls", "List children of selected node", cmdList) 24 | c.Command("list-dates lsd", "List all date nodes", cmdListDates) 25 | c.Command("rename", "Rename a node", cmdRename) 26 | c.Command("remove rm", "Remove node(s)", cmdRemove) 27 | c.Command("import", "Import trees from indented lines", cmdImport) 28 | c.Command("stat", "Display node information", cmdStat) 29 | 30 | args := os.Args 31 | if len(args) == 1 { 32 | // Run `tree` implicitly. 33 | args = append(os.Args, "tree") 34 | } 35 | 36 | c.Run(args) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/grit/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | func die(a ...interface{}) { 10 | msg := fmt.Sprint(a...) 11 | if !strings.HasSuffix(msg, "\n") { 12 | msg += "\n" 13 | } 14 | fmt.Fprint(os.Stderr, msg) 15 | os.Exit(1) 16 | } 17 | 18 | func errf(format string, a ...interface{}) { 19 | msg := fmt.Sprintf(format, a...) 20 | if !strings.HasSuffix(msg, "\n") { 21 | msg += "\n" 22 | } 23 | fmt.Fprint(os.Stderr, msg) 24 | } 25 | 26 | func dief(format string, a ...interface{}) { 27 | errf(format, a...) 28 | os.Exit(1) 29 | } 30 | 31 | func capitalize(s string) string { 32 | if len(s) > 0 { 33 | return strings.ToUpper(string(s[0])) + s[1:] 34 | } 35 | return s 36 | } 37 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | // Package grit/db implements the basic CRUD operations used to interact with 2 | // grit data. All operations are atomic. 3 | package db 4 | 5 | import ( 6 | "context" 7 | "database/sql" 8 | "fmt" 9 | 10 | _ "github.com/mattn/go-sqlite3" 11 | ) 12 | 13 | type Database struct { 14 | DB *sql.DB 15 | Filename string 16 | } 17 | 18 | func New(filename string) (*Database, error) { 19 | d := &Database{} 20 | if err := d.Open(filename); err != nil { 21 | return nil, err 22 | } 23 | if err := d.init(); err != nil { 24 | return nil, err 25 | } 26 | d.Filename = filename 27 | return d, nil 28 | } 29 | 30 | func (d *Database) init() error { 31 | if _, err := d.DB.Exec("PRAGMA foreign_keys = ON"); err != nil { 32 | return err 33 | } 34 | return d.migrate() 35 | } 36 | 37 | func (d *Database) getUserVersion() (int64, error) { 38 | row := d.DB.QueryRow(`PRAGMA user_version`) 39 | var version int64 40 | if err := row.Scan(&version); err != nil { 41 | return 0, err 42 | } 43 | return version, nil 44 | } 45 | 46 | func (d *Database) setUserVersion(version int64) error { 47 | // Using fmt.Sprintf -- driver doesn't parametrize values for PRAGMAs. 48 | query := fmt.Sprintf("PRAGMA user_version = %d", version) 49 | _, err := d.DB.Exec(query) 50 | if err != nil { 51 | return err 52 | } 53 | return nil 54 | } 55 | 56 | func (d *Database) Open(fp string) error { 57 | sqlite3db, err := sql.Open("sqlite3", fp) 58 | if err != nil { 59 | return err 60 | } 61 | d.DB = sqlite3db 62 | return nil 63 | } 64 | 65 | func (d *Database) Close() error { 66 | return d.DB.Close() 67 | } 68 | 69 | func (d *Database) beginTx() (*sql.Tx, error) { 70 | ctx := context.TODO() 71 | return d.DB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) 72 | } 73 | 74 | func (d *Database) execTxFunc(f func(*sql.Tx) error) error { 75 | tx, err := d.beginTx() 76 | if err != nil { 77 | return err 78 | } 79 | if err := f(tx); err != nil { 80 | _ = tx.Rollback() 81 | return err 82 | } 83 | return tx.Commit() 84 | } 85 | -------------------------------------------------------------------------------- /db/db_test.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func setupDB(t *testing.T) *Database { 10 | tmpfile, err := ioutil.TempFile("", "grit_test_db") 11 | if err != nil { 12 | t.Fatalf("couldn't create temp file: %v", err) 13 | } 14 | tmpfile.Close() // We only want the name. 15 | d, err := New(tmpfile.Name()) 16 | if err != nil { 17 | t.Fatalf("couldn't create db: %v", err) 18 | } 19 | return d 20 | } 21 | 22 | func tearDB(t *testing.T, d *Database) { 23 | d.Close() 24 | if err := os.Remove(d.Filename); err != nil { 25 | t.Fatalf("error removing file: %v", err) 26 | } 27 | } 28 | 29 | func TestLinkToDateNode(t *testing.T) { 30 | d := setupDB(t) 31 | defer tearDB(t, d) 32 | 33 | rootID, err := d.CreateNode("test root", 0) 34 | if err != nil { 35 | t.Fatalf("couldn't create root: %v", err) 36 | } 37 | if rootID != 1 { 38 | t.Fatalf("got root ID = %d, want 1", rootID) 39 | } 40 | 41 | childID, err := d.CreateChildOfDateNode("2020-01-01", "test child") 42 | if err != nil { 43 | t.Fatalf("couldn't create child of date node: %v", err) 44 | } 45 | if childID != 3 { 46 | t.Fatalf("got child ID = %d, want 3", childID) 47 | } 48 | 49 | // ID 2 should be our date node. 50 | if _, err := d.CreateLink(1, 2); err == nil { 51 | t.Fatalf("created link with date node as dest; err = nil, want non-nil") 52 | } 53 | } 54 | 55 | func TestAutodeleteDateNode(t *testing.T) { 56 | d := setupDB(t) 57 | defer tearDB(t, d) 58 | 59 | datestr := "2020-01-01" 60 | 61 | // Delete last child. 62 | { 63 | childID, err := d.CreateChildOfDateNode(datestr, "test") 64 | if err != nil { 65 | t.Fatalf("couldn't create child of date node: %v", err) 66 | } 67 | dateNode, err := d.GetNodeByName(datestr) 68 | if err != nil { 69 | t.Fatalf(`couldn't get node by name "%s": %v`, datestr, err) 70 | } 71 | if _, err := d.DeleteNode(childID); err != nil { 72 | t.Fatalf(`couldn't delete child (%d): %v`, childID, err) 73 | } 74 | if n, err := d.GetNode(dateNode.ID); err != nil { 75 | t.Fatalf(`error getting node (%d): %v`, dateNode.ID, err) 76 | } else if n != nil { 77 | t.Error("date node still exists after deleting its only child") 78 | } 79 | } 80 | 81 | // Unlink last child. 82 | { 83 | childID, err := d.CreateChildOfDateNode(datestr, "test") 84 | if err != nil { 85 | t.Fatalf("couldn't create date node child: %v", err) 86 | } 87 | dateNode, err := d.GetNodeByName(datestr) 88 | if err != nil { 89 | t.Fatalf(`couldn't get node by name "%s": %v`, datestr, err) 90 | } 91 | if err := d.DeleteLinkByEndpoints(dateNode.ID, childID); err != nil { 92 | t.Fatalf(`couldn't delete link (%d) -> (%d): %v`, 93 | dateNode.ID, childID, err) 94 | } 95 | if n, err := d.GetNode(dateNode.ID); err != nil { 96 | t.Fatalf(`error getting node (%d): %v`, dateNode.ID, err) 97 | } else if n != nil { 98 | t.Error("date node still exists after unlinking its only child") 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /db/graph.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "container/list" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/climech/grit/multitree" 9 | ) 10 | 11 | // getAdjacent gets parents and children of the node. 12 | func getAdjacent(tx *sql.Tx, id int64) ([]*multitree.Node, error) { 13 | rows, err := tx.Query( 14 | "SELECT node_id, node_name, node_alias, node_created, node_completed "+ 15 | "FROM nodes LEFT JOIN links ON node_id = origin_id WHERE dest_id = ?", 16 | id, 17 | ) 18 | if err != nil { 19 | return nil, err 20 | } 21 | nodes := rowsToNodes(rows) 22 | return nodes, nil 23 | } 24 | 25 | // getParents gets nodes connected to the given node by incoming links. 26 | func getParents(tx *sql.Tx, id int64) ([]*multitree.Node, error) { 27 | rows, err := tx.Query( 28 | "SELECT node_id, node_name, node_alias, node_created, node_completed "+ 29 | "FROM nodes LEFT JOIN links ON node_id = origin_id WHERE dest_id = ?", 30 | id, 31 | ) 32 | if err != nil { 33 | return nil, err 34 | } 35 | nodes := rowsToNodes(rows) 36 | return nodes, nil 37 | } 38 | 39 | // getChildren gets nodes connected to the given node by outgoing links. 40 | func getChildren(tx *sql.Tx, id int64) ([]*multitree.Node, error) { 41 | rows, err := tx.Query( 42 | "SELECT node_id, node_name, node_alias, node_created, node_completed "+ 43 | "FROM nodes LEFT JOIN links ON node_id = dest_id WHERE origin_id = ?", 44 | id, 45 | ) 46 | if err != nil { 47 | return nil, err 48 | } 49 | nodes := rowsToNodes(rows) 50 | return nodes, nil 51 | } 52 | 53 | func getGraph(tx *sql.Tx, nodeID int64) (*multitree.Node, error) { 54 | row := tx.QueryRow("SELECT * FROM nodes WHERE node_id = ?", nodeID) 55 | node, err := rowToNode(row) 56 | if err != nil { 57 | return nil, err 58 | } 59 | if node == nil { 60 | return nil, nil 61 | } 62 | 63 | queue := list.New() 64 | queue.PushBack(node) 65 | visited := make(map[int64]*multitree.Node) 66 | visited[node.ID] = node 67 | 68 | linkNodes := func(origin, dest *multitree.Node) { 69 | if origin.HasChild(dest) { 70 | return 71 | } 72 | if err := multitree.LinkNodes(origin, dest); err != nil { 73 | panic(fmt.Sprintf("invalid multitree link in DB (%d->%d): %v", 74 | origin.ID, dest.ID, err)) 75 | } 76 | } 77 | 78 | for { 79 | if elem := queue.Front(); elem == nil { 80 | break 81 | } else { 82 | queue.Remove(elem) 83 | current := elem.Value.(*multitree.Node) 84 | 85 | parents, err := getParents(tx, current.ID) 86 | if err != nil { 87 | return nil, err 88 | } 89 | for _, p := range parents { 90 | if _, ok := visited[p.ID]; !ok { 91 | linkNodes(p, current) 92 | visited[p.ID] = p 93 | queue.PushBack(p) 94 | } else { 95 | linkNodes(visited[p.ID], current) 96 | } 97 | } 98 | 99 | children, err := getChildren(tx, current.ID) 100 | if err != nil { 101 | return nil, err 102 | } 103 | for _, c := range children { 104 | if _, ok := visited[c.ID]; !ok { 105 | linkNodes(current, c) 106 | visited[c.ID] = c 107 | queue.PushBack(c) 108 | } else { 109 | linkNodes(current, visited[c.ID]) 110 | } 111 | } 112 | } 113 | } 114 | 115 | return node, nil 116 | } 117 | 118 | // GetGraph builds a multitree using Breadth-First Search, and returns the 119 | // requested node as part of the multitree. 120 | func (d *Database) GetGraph(nodeID int64) (*multitree.Node, error) { 121 | var node *multitree.Node 122 | err := d.execTxFunc(func(tx *sql.Tx) error { 123 | n, err := getGraph(tx, nodeID) 124 | if err != nil { 125 | return err 126 | } 127 | node = n 128 | return nil 129 | }) 130 | if err != nil { 131 | return nil, err 132 | } 133 | return node, nil 134 | } 135 | -------------------------------------------------------------------------------- /db/link.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | "github.com/climech/grit/multitree" 8 | 9 | _ "github.com/mattn/go-sqlite3" 10 | ) 11 | 12 | func (d *Database) GetLink(linkID int64) (*multitree.Link, error) { 13 | row := d.DB.QueryRow("SELECT * FROM links WHERE link_id = ?", linkID) 14 | return rowToLink(row) 15 | } 16 | 17 | func (d *Database) GetLinkByEndpoints(originID, destID int64) (*multitree.Link, error) { 18 | row := d.DB.QueryRow( 19 | "SELECT * FROM links WHERE origin_id = ? AND dest_id = ?", originID, destID) 20 | return rowToLink(row) 21 | } 22 | 23 | // GetLinksByNodeID gets the node's incoming and outcoming links. 24 | func (d *Database) GetLinksByNodeID(nodeID int64) ([]*multitree.Link, error) { 25 | rows, err := d.DB.Query( 26 | "SELECT * FROM links WHERE origin_id = ? OR dest_id = ?", 27 | nodeID, 28 | nodeID, 29 | ) 30 | if err != nil { 31 | return nil, err 32 | } 33 | return rowsToLinks(rows), nil 34 | } 35 | 36 | func insertLink(tx *sql.Tx, originID, destID int64) (int64, error) { 37 | r, err := tx.Exec("INSERT INTO links (origin_id, dest_id) VALUES (?, ?)", 38 | originID, destID) 39 | if err != nil { 40 | return 0, err 41 | } 42 | return r.LastInsertId() 43 | } 44 | 45 | func createLink(tx *sql.Tx, originID, destID int64) (int64, error) { 46 | origin, err := getGraph(tx, originID) 47 | if err != nil { 48 | return 0, err 49 | } 50 | if origin == nil { 51 | return 0, fmt.Errorf("link origin does not exist") 52 | } 53 | 54 | dest, err := getGraph(tx, destID) 55 | if err != nil { 56 | return 0, err 57 | } 58 | if dest == nil { 59 | return 0, fmt.Errorf("link target does not exist") 60 | } 61 | 62 | if err := multitree.LinkNodes(origin, dest); err != nil { 63 | return 0, err 64 | } 65 | 66 | linkID, err := insertLink(tx, originID, destID) 67 | if err != nil { 68 | return 0, err 69 | } 70 | if err := backpropCompletion(tx, origin); err != nil { 71 | return 0, err 72 | } 73 | 74 | return linkID, nil 75 | } 76 | 77 | func (d *Database) CreateLink(originID, destID int64) (int64, error) { 78 | var linkID int64 79 | txf := func(tx *sql.Tx) error { 80 | id, err := createLink(tx, originID, destID) 81 | if err != nil { 82 | return err 83 | } 84 | linkID = id 85 | return nil 86 | } 87 | if err := d.execTxFunc(txf); err != nil { 88 | return 0, err 89 | } 90 | return linkID, nil 91 | } 92 | 93 | // CreateLinkFromDateNode atomically creates an link with date node as the 94 | // origin. Date node is automatically created if it doesn't exist. 95 | func (d *Database) CreateLinkFromDateNode(date string, destID int64) (int64, error) { 96 | if err := multitree.ValidateDateNodeName(date); err != nil { 97 | panic(err) 98 | } 99 | 100 | var linkID int64 101 | txf := func(tx *sql.Tx) error { 102 | originID, err := createDateNodeIfNotExists(tx, date) 103 | if err != nil { 104 | return err 105 | } 106 | id, err := createLink(tx, originID, destID) 107 | if err != nil { 108 | return err 109 | } 110 | linkID = id 111 | return nil 112 | } 113 | 114 | if err := d.execTxFunc(txf); err != nil { 115 | return 0, err 116 | } 117 | return linkID, nil 118 | } 119 | 120 | func deleteLinkByEndpoints(tx *sql.Tx, originID, destID int64) error { 121 | r, err := tx.Exec("DELETE FROM links WHERE origin_id = ? AND dest_id = ?", 122 | originID, destID) 123 | if err != nil { 124 | return err 125 | } 126 | if count, _ := r.RowsAffected(); count == 0 { 127 | return fmt.Errorf("link (%d) -> (%d) does not exist", originID, destID) 128 | } 129 | return nil 130 | } 131 | 132 | func (d *Database) DeleteLinkByEndpoints(originID, destID int64) error { 133 | return d.execTxFunc(func(tx *sql.Tx) error { 134 | if err := deleteLinkByEndpoints(tx, originID, destID); err != nil { 135 | return err 136 | } 137 | 138 | origin, err := getGraph(tx, originID) 139 | if err != nil { 140 | return err 141 | } 142 | 143 | if origin.IsDateNode() && len(origin.Children()) == 0 { 144 | deleteNode(tx, originID) 145 | } else { 146 | if err := backpropCompletion(tx, origin); err != nil { 147 | return err 148 | } 149 | } 150 | 151 | return nil 152 | }) 153 | } 154 | -------------------------------------------------------------------------------- /db/migrate.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | _ "github.com/mattn/go-sqlite3" 8 | ) 9 | 10 | func migrateFrom0(db *sql.DB) error { 11 | createNodes := ` 12 | CREATE TABLE nodes ( 13 | node_id INTEGER PRIMARY KEY, 14 | node_name VARCHAR(100) NOT NULL, 15 | node_alias VARCHAR(100) DEFAULT NULL, 16 | node_created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 17 | node_completed INTEGER DEFAULT NULL, 18 | 19 | UNIQUE(node_alias) 20 | )` 21 | 22 | createLinks := ` 23 | CREATE TABLE links ( 24 | link_id INTEGER PRIMARY KEY, 25 | origin_id INTEGER NOT NULL, 26 | dest_id INTEGER NOT NULL, 27 | 28 | FOREIGN KEY (origin_id) 29 | REFERENCES nodes (node_id) 30 | ON DELETE CASCADE 31 | 32 | FOREIGN KEY (dest_id) 33 | REFERENCES nodes (node_id) 34 | ON DELETE CASCADE 35 | 36 | CHECK(origin_id != dest_id) 37 | UNIQUE(origin_id, dest_id) 38 | )` 39 | 40 | if _, err := db.Exec(createNodes); err != nil { 41 | return err 42 | } 43 | if _, err := db.Exec(createLinks); err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | } 49 | 50 | // migrationFuncs is a slice of functions that incrementally migrate the DB from 51 | // one version to the next. The length of this slice determines the latest known 52 | // database version. The first "migration" initializes an empty DB. 53 | var migrationFuncs = []func(*sql.DB) error{ 54 | migrateFrom0, 55 | } 56 | 57 | // migrate checks if the underlying database is up-to-date, and migrates 58 | // the data if needed. It returns an error if there's an IO problem or 59 | // Grit doesn't recognize the DB version. 60 | func (d *Database) migrate() error { 61 | v, err := d.getUserVersion() 62 | if err != nil { 63 | return err 64 | } 65 | if v < 0 { 66 | return fmt.Errorf("Corrupted database (negative user_version).") 67 | } 68 | 69 | current := int64(len(migrationFuncs)) 70 | if v > current { 71 | return fmt.Errorf("Database version is not supported by this version of " + 72 | "Grit -- try upgrading to the latest release.") 73 | } 74 | for v < current { 75 | if err := migrationFuncs[v](d.DB); err != nil { 76 | return err 77 | } 78 | v++ 79 | if err := d.setUserVersion(v); err != nil { 80 | return err 81 | } 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /db/node.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/climech/grit/multitree" 9 | 10 | _ "github.com/mattn/go-sqlite3" 11 | ) 12 | 13 | func getNode(tx *sql.Tx, id int64) (*multitree.Node, error) { 14 | row := tx.QueryRow("SELECT * FROM nodes WHERE node_id = ?", id) 15 | return rowToNode(row) 16 | } 17 | 18 | // GetNode returns the node with the given id, or nil if it doesn't exist. 19 | func (d *Database) GetNode(id int64) (*multitree.Node, error) { 20 | var node *multitree.Node 21 | err := d.execTxFunc(func(tx *sql.Tx) error { 22 | n, err := getNode(tx, id) 23 | if err != nil { 24 | return err 25 | } 26 | node = n 27 | return nil 28 | }) 29 | if err != nil { 30 | return nil, err 31 | } 32 | return node, nil 33 | } 34 | 35 | func getNodeByName(tx *sql.Tx, name string) (*multitree.Node, error) { 36 | row := tx.QueryRow("SELECT * FROM nodes WHERE node_name = ?", name) 37 | return rowToNode(row) 38 | } 39 | 40 | // GetNode returns the node with the given name, or nil if it doesn't exist. 41 | func (d *Database) GetNodeByName(name string) (*multitree.Node, error) { 42 | var node *multitree.Node 43 | err := d.execTxFunc(func(tx *sql.Tx) error { 44 | n, err := getNodeByName(tx, name) 45 | if err != nil { 46 | return err 47 | } 48 | node = n 49 | return nil 50 | }) 51 | if err != nil { 52 | return nil, err 53 | } 54 | return node, nil 55 | } 56 | 57 | func getNodeByAlias(tx *sql.Tx, alias string) (*multitree.Node, error) { 58 | row := tx.QueryRow("SELECT * FROM nodes WHERE node_alias = ?", alias) 59 | return rowToNode(row) 60 | } 61 | 62 | // GetNode returns the node with the given alias, or nil if it doesn't exist. 63 | func (d *Database) GetNodeByAlias(alias string) (*multitree.Node, error) { 64 | var node *multitree.Node 65 | err := d.execTxFunc(func(tx *sql.Tx) error { 66 | n, err := getNodeByAlias(tx, alias) 67 | if err != nil { 68 | return err 69 | } 70 | node = n 71 | return nil 72 | }) 73 | if err != nil { 74 | return nil, err 75 | } 76 | return node, nil 77 | } 78 | 79 | // GetRoots returns a slice of nodes that have no predecessors. 80 | func (d *Database) GetRoots() ([]*multitree.Node, error) { 81 | rows, err := d.DB.Query( 82 | "SELECT * FROM nodes " + 83 | "WHERE NOT EXISTS(SELECT * FROM links WHERE dest_id = node_id)", 84 | ) 85 | if err != nil { 86 | return nil, err 87 | } 88 | return rowsToNodes(rows), nil 89 | } 90 | 91 | func backpropCompletion(tx *sql.Tx, node *multitree.Node) error { 92 | var updateQueue []*multitree.Node 93 | var backprop func(*multitree.Node) 94 | 95 | backprop = func(n *multitree.Node) { 96 | allChildrenCompleted := true 97 | for _, c := range n.Children() { 98 | if !c.IsCompleted() { 99 | allChildrenCompleted = false 100 | break 101 | } 102 | } 103 | if n.IsCompleted() != allChildrenCompleted { 104 | if allChildrenCompleted { 105 | n.Completed = copyCompletion(n.Children()[0].Completed) 106 | } else { 107 | n.Completed = nil 108 | } 109 | updateQueue = append(updateQueue, n) 110 | } 111 | for _, p := range n.Parents() { 112 | backprop(p) 113 | } 114 | } 115 | 116 | for _, leaf := range node.Leaves() { 117 | for _, p := range leaf.Parents() { 118 | backprop(p) 119 | } 120 | } 121 | 122 | for _, node := range updateQueue { 123 | _, err := tx.Exec("UPDATE nodes SET node_completed = ? WHERE node_id = ?", 124 | node.Completed, node.ID) 125 | if err != nil { 126 | return err 127 | } 128 | } 129 | 130 | return nil 131 | } 132 | 133 | func createNode(tx *sql.Tx, name string, parentID int64) (int64, error) { 134 | r, err := tx.Exec(`INSERT INTO nodes (node_name) VALUES (?)`, name) 135 | if err != nil { 136 | return 0, err 137 | } 138 | id, _ := r.LastInsertId() 139 | if parentID != 0 { 140 | if _, err := createLink(tx, parentID, id); err != nil { 141 | return 0, err 142 | } 143 | node, err := getGraph(tx, id) 144 | if err != nil { 145 | return 0, err 146 | } 147 | if err := backpropCompletion(tx, node); err != nil { 148 | return 0, err 149 | } 150 | } 151 | return id, nil 152 | } 153 | 154 | // CreateNode creates a node and returns its ID. It updates the status of 155 | // other nodes in the multitree if needed. 156 | func (d *Database) CreateNode(name string, parentID int64) (int64, error) { 157 | var childID int64 158 | txf := func(tx *sql.Tx) error { 159 | id, err := createNode(tx, name, parentID) 160 | if err != nil { 161 | return err 162 | } 163 | childID = id 164 | return nil 165 | } 166 | if err := d.execTxFunc(txf); err != nil { 167 | return 0, err 168 | } 169 | return childID, nil 170 | } 171 | 172 | func createDateNodeIfNotExists(tx *sql.Tx, date string) (int64, error) { 173 | if err := multitree.ValidateDateNodeName(date); err != nil { 174 | panic(err) 175 | } 176 | node, err := getNodeByName(tx, date) 177 | if err != nil { 178 | return 0, err 179 | } 180 | if node != nil { 181 | return node.ID, nil 182 | } 183 | return createNode(tx, date, 0) 184 | } 185 | 186 | // CreateChildOfDateNode atomically creates a node and links the date node to 187 | // it. Date node is created if it doesn't exist. 188 | func (d *Database) CreateChildOfDateNode(date, name string) (int64, error) { 189 | var childID int64 190 | 191 | txf := func(tx *sql.Tx) error { 192 | dateNodeID, err := createDateNodeIfNotExists(tx, date) 193 | if err != nil { 194 | return err 195 | } 196 | childID, err = createNode(tx, name, dateNodeID) 197 | if err != nil { 198 | return err 199 | } 200 | return nil 201 | } 202 | 203 | if err := d.execTxFunc(txf); err != nil { 204 | return 0, err 205 | } 206 | return childID, nil 207 | } 208 | 209 | func createTree(tx *sql.Tx, node *multitree.Node, parentID int64) (int64, error) { 210 | tree := node.Tree() 211 | var retErr error 212 | 213 | tree.TraverseDescendants(func(current *multitree.Node, stop func()) { 214 | pid := parentID 215 | parents := current.Parents() 216 | if len(parents) > 0 { 217 | pid = parents[0].ID 218 | } 219 | id, err := createNode(tx, current.Name, pid) 220 | if err != nil { 221 | retErr = err 222 | stop() 223 | } else { 224 | current.ID = id 225 | } 226 | }) 227 | 228 | if retErr != nil { 229 | return 0, retErr 230 | } 231 | 232 | // Update ancestors, if any. 233 | if parentID != 0 { 234 | g, err := getGraph(tx, tree.ID) 235 | if err != nil { 236 | return 0, err 237 | } 238 | if err := backpropCompletion(tx, g); err != nil { 239 | return 0, err 240 | } 241 | } 242 | 243 | return tree.ID, nil 244 | } 245 | 246 | // CreateTree saves an entire tree in the database and returns the root ID. It 247 | // updates the status of other nodes in the multitree to reflect the change. 248 | func (d *Database) CreateTree(node *multitree.Node, parentID int64) (int64, error) { 249 | var rootID int64 250 | 251 | txf := func(tx *sql.Tx) error { 252 | id, err := createTree(tx, node, parentID) 253 | if err != nil { 254 | return err 255 | } 256 | rootID = id 257 | return nil 258 | } 259 | 260 | if err := d.execTxFunc(txf); err != nil { 261 | return 0, err 262 | } 263 | return rootID, nil 264 | } 265 | 266 | // CreateTreeAsChildOfDateNode atomically creates a tree and links the date node 267 | // to its root. Date node is created if it doesn't exist. 268 | func (d *Database) CreateTreeAsChildOfDateNode(date string, node *multitree.Node) (int64, error) { 269 | var rootID int64 270 | 271 | txf := func(tx *sql.Tx) error { 272 | dateNodeID, err := createDateNodeIfNotExists(tx, date) 273 | if err != nil { 274 | return err 275 | } 276 | id, err := createTree(tx, node, dateNodeID) 277 | if err != nil { 278 | return err 279 | } 280 | rootID = id 281 | return nil 282 | } 283 | 284 | if err := d.execTxFunc(txf); err != nil { 285 | return 0, err 286 | } 287 | return rootID, nil 288 | } 289 | 290 | func (d *Database) checkNode(nodeID int64, check bool) error { 291 | var value *int64 292 | if check { 293 | now := time.Now().Unix() 294 | value = &now 295 | } 296 | 297 | update := func(tx *sql.Tx, node *multitree.Node) error { 298 | r, err := tx.Exec("UPDATE nodes SET node_completed = ? WHERE node_id = ?", 299 | value, node.ID) 300 | if err != nil { 301 | return err 302 | } 303 | if count, _ := r.RowsAffected(); count == 0 { 304 | return fmt.Errorf("node does not exist") 305 | } 306 | node.Completed = copyCompletion(value) 307 | return nil 308 | } 309 | 310 | return d.execTxFunc(func(tx *sql.Tx) error { 311 | node, err := getGraph(tx, nodeID) 312 | if err != nil { 313 | return err 314 | } 315 | if node == nil { 316 | return fmt.Errorf("node does not exist") 317 | } 318 | // Update local root. 319 | if err := update(tx, node); err != nil { 320 | return err 321 | } 322 | // Update direct and indirect successors. 323 | for _, n := range node.Descendants() { 324 | if err := update(tx, n); err != nil { 325 | return err 326 | } 327 | } 328 | if err := backpropCompletion(tx, node); err != nil { 329 | return err 330 | } 331 | return nil 332 | }) 333 | } 334 | 335 | // CheckNode marks the node as completed, along with all its direct and indirect 336 | // successors. The rest of the multitree is updated to reflect the change. 337 | func (d *Database) CheckNode(nodeID int64) error { 338 | return d.checkNode(nodeID, true) 339 | } 340 | 341 | // UncheckNode sets the node's status to inactive, along with all its direct 342 | // and indirect successors. The rest of the multitree is updated to reflect the 343 | // change. 344 | func (d *Database) UncheckNode(nodeID int64) error { 345 | return d.checkNode(nodeID, false) 346 | } 347 | 348 | func (d *Database) RenameNode(nodeID int64, name string) error { 349 | r, err := d.DB.Exec("UPDATE nodes SET node_name = ? WHERE node_id = ?", 350 | name, nodeID) 351 | if err != nil { 352 | return err 353 | } 354 | if count, _ := r.RowsAffected(); count == 0 { 355 | return fmt.Errorf("not found") 356 | } 357 | return nil 358 | } 359 | 360 | func deleteNode(tx *sql.Tx, id int64) error { 361 | r, err := tx.Exec(`DELETE FROM nodes WHERE node_id = ?`, id) 362 | if err != nil { 363 | return err 364 | } 365 | if count, _ := r.RowsAffected(); count == 0 { 366 | return fmt.Errorf("node does not exist") 367 | } 368 | return nil 369 | } 370 | 371 | // DeleteNode deletes a single node and propagates the change to the rest of the 372 | // multitree. It returns the node's orphaned successors. 373 | func (d *Database) DeleteNode(id int64) ([]*multitree.Node, error) { 374 | var orphans []*multitree.Node 375 | 376 | txf := func(tx *sql.Tx) error { 377 | node, err := getGraph(tx, id) 378 | if err != nil { 379 | return err 380 | } 381 | if node == nil { 382 | return fmt.Errorf("node does not exist") 383 | } 384 | 385 | if err := deleteNode(tx, id); err != nil { 386 | return err 387 | } 388 | 389 | // Auto-delete any empty date nodes. 390 | for _, dn := range filterDateNodes(node.Parents()) { 391 | if len(dn.Children()) == 1 { 392 | if err := deleteNode(tx, dn.ID); err != nil { 393 | return err 394 | } 395 | // Unlink to ignore in backprop. 396 | if err := multitree.UnlinkNodes(dn, node); err != nil { 397 | panic(err) 398 | } 399 | } 400 | } 401 | 402 | if err := backpropCompletion(tx, node); err != nil { 403 | return err 404 | } 405 | orphans = node.Children() 406 | return nil 407 | } 408 | 409 | if err := d.execTxFunc(txf); err != nil { 410 | return nil, err 411 | } 412 | return orphans, nil 413 | } 414 | 415 | // DeleteNodeRecursive deletes the tree rooted at the given node and updates the 416 | // multitree. Nodes that have parents outside of this tree are preserved. It 417 | // returns a slice of all deleted nodes. 418 | func (d *Database) DeleteNodeRecursive(id int64) ([]*multitree.Node, error) { 419 | var deleted []*multitree.Node 420 | 421 | txf := func(tx *sql.Tx) error { 422 | node, err := getGraph(tx, id) 423 | if err != nil { 424 | return err 425 | } 426 | if node == nil { 427 | return fmt.Errorf("node does not exist") 428 | } 429 | 430 | if err := deleteNode(tx, id); err != nil { 431 | return err 432 | } 433 | deleted = append(deleted, node) 434 | 435 | for _, d := range node.Descendants() { 436 | if len(d.Parents()) == 1 { 437 | if err := deleteNode(tx, d.ID); err != nil { 438 | return err 439 | } 440 | deleted = append(deleted, d) 441 | } 442 | } 443 | 444 | if err := backpropCompletion(tx, node); err != nil { 445 | return err 446 | } 447 | return nil 448 | } 449 | 450 | if err := d.execTxFunc(txf); err != nil { 451 | return nil, err 452 | } 453 | return deleted, nil 454 | } 455 | 456 | func (d *Database) SetAlias(nodeID int64, alias string) error { 457 | nullable := &alias 458 | if alias == "" { 459 | nullable = nil 460 | } 461 | r, err := d.DB.Exec("UPDATE nodes SET node_alias = ? WHERE node_id = ?", 462 | nullable, nodeID) 463 | if err != nil { 464 | return err 465 | } 466 | if count, _ := r.RowsAffected(); count == 0 { 467 | return fmt.Errorf("node does not exist") 468 | } 469 | return nil 470 | } 471 | -------------------------------------------------------------------------------- /db/utils.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/climech/grit/multitree" 7 | _ "github.com/mattn/go-sqlite3" 8 | ) 9 | 10 | func copyCompletion(value *int64) *int64 { 11 | if value == nil { 12 | return nil 13 | } 14 | cp := *value 15 | return &cp 16 | } 17 | 18 | type scannable interface { 19 | Scan(...interface{}) error 20 | } 21 | 22 | func scanToNode(s scannable) (*multitree.Node, error) { 23 | node := &multitree.Node{} 24 | var alias sql.NullString 25 | var completed sql.NullInt64 26 | err := s.Scan(&node.ID, &node.Name, &alias, &node.Created, &completed) 27 | if err == nil { 28 | node.Alias = alias.String 29 | if completed.Valid { 30 | node.Completed = &completed.Int64 31 | } 32 | } 33 | return node, err 34 | } 35 | 36 | func rowToNode(row *sql.Row) (*multitree.Node, error) { 37 | node, err := scanToNode(row) 38 | if err != nil { 39 | if err == sql.ErrNoRows { 40 | return nil, nil 41 | } 42 | return nil, err 43 | } 44 | return node, nil 45 | } 46 | 47 | func rowsToNodes(rows *sql.Rows) []*multitree.Node { 48 | defer rows.Close() 49 | var nodes []*multitree.Node 50 | for rows.Next() { 51 | node, _ := scanToNode(rows) 52 | nodes = append(nodes, node) 53 | } 54 | return nodes 55 | } 56 | 57 | func rowToLink(row *sql.Row) (*multitree.Link, error) { 58 | link := &multitree.Link{} 59 | err := row.Scan(&link.ID, &link.OriginID, &link.DestID) 60 | if err == sql.ErrNoRows { 61 | return nil, nil 62 | } 63 | return link, err 64 | } 65 | 66 | func rowsToLinks(rows *sql.Rows) []*multitree.Link { 67 | defer rows.Close() 68 | var links []*multitree.Link 69 | for rows.Next() { 70 | link := &multitree.Link{} 71 | err := rows.Scan(&link.ID, &link.OriginID, &link.DestID) 72 | if err != nil { 73 | panic(err) 74 | } 75 | links = append(links, link) 76 | } 77 | return links 78 | } 79 | 80 | func filterDateNodes(nodes []*multitree.Node) []*multitree.Node { 81 | var filtered []*multitree.Node 82 | for _, n := range nodes { 83 | if n.IsDateNode() { 84 | filtered = append(filtered, n) 85 | } 86 | } 87 | return filtered 88 | } 89 | -------------------------------------------------------------------------------- /docs/assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/climech/grit/31e482ed372e20a21ae47b9d469bc9abd8bca430/docs/assets/demo.gif -------------------------------------------------------------------------------- /docs/assets/fig1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/climech/grit/31e482ed372e20a21ae47b9d469bc9abd8bca430/docs/assets/fig1.gif -------------------------------------------------------------------------------- /docs/assets/fig2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/climech/grit/31e482ed372e20a21ae47b9d469bc9abd8bca430/docs/assets/fig2.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/climech/grit 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/climech/naturalsort v0.1.0 7 | github.com/fatih/color v1.10.0 8 | github.com/jawher/mow.cli v1.2.0 9 | github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f 10 | github.com/mattn/go-sqlite3 v2.0.3+incompatible 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/climech/naturalsort v0.1.0 h1:RerFYAgz3gxoSGTsvbecDKrv5BJkDDj6D6BQiykhHu8= 2 | github.com/climech/naturalsort v0.1.0/go.mod h1:QHbmEAQ0dpDa3j+BX6rM1t24knxySiHH+ef3ziY79K4= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= 7 | github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= 8 | github.com/jawher/mow.cli v1.2.0 h1:e6ViPPy+82A/NFF/cfbq3Lr6q4JHKT9tyHwTCcUQgQw= 9 | github.com/jawher/mow.cli v1.2.0/go.mod h1:y+pcA3jBAdo/GIZx/0rFjw/K2bVEODP9rfZOfaiq8Ko= 10 | github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= 11 | github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= 12 | github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= 13 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 14 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 15 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 16 | github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= 17 | github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 22 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 23 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 24 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 25 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 26 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= 27 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 31 | gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c= 32 | gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 33 | -------------------------------------------------------------------------------- /multitree/import.go: -------------------------------------------------------------------------------- 1 | package multitree 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | // ImportTrees reads a sequence of tab-indented lines and builds trees out of 10 | // them. It returns pointers to the roots. 11 | func ImportTrees(reader io.Reader) ([]*Node, error) { 12 | type stackItem struct { 13 | indent int 14 | node *Node 15 | } 16 | 17 | var roots []*Node 18 | var stack []*stackItem 19 | scanner := bufio.NewScanner(reader) 20 | lineNum := 1 21 | 22 | for scanner.Scan() { 23 | indent, name := parseImportLine(scanner.Text()) 24 | 25 | // Ignore empty lines. 26 | if name == "" { 27 | lineNum++ 28 | continue 29 | } 30 | 31 | if err := ValidateNodeName(name); err != nil { 32 | return nil, fmt.Errorf("line %d: %v", lineNum, err) 33 | } 34 | 35 | // Backtrack until current indent > top stack indent. 36 | if len(stack) > 0 { 37 | top := len(stack) - 1 38 | for top >= 0 && stack[top].indent >= indent { 39 | stack = stack[:top] // pop 40 | top-- 41 | } 42 | } 43 | 44 | var newNode *Node 45 | if len(stack) == 0 { 46 | newNode = NewNode(name) 47 | newNode.ID = 1 48 | roots = append(roots, newNode) 49 | } else { 50 | topNode := stack[len(stack)-1].node 51 | newNode = topNode.New(name) 52 | _ = LinkNodes(topNode, newNode) 53 | } 54 | 55 | stack = append(stack, &stackItem{indent: indent, node: newNode}) 56 | lineNum++ 57 | } 58 | 59 | return roots, nil 60 | } 61 | 62 | // parseImportLine returns the node's indent level and name. 63 | func parseImportLine(line string) (int, string) { 64 | if len(line) == 0 { 65 | return 0, "" 66 | } 67 | var indent int 68 | for i := 0; i < len(line) && (line[i] == '\t' || line[i] == ' '); i++ { 69 | indent++ 70 | } 71 | return indent, line[indent:] 72 | } 73 | -------------------------------------------------------------------------------- /multitree/link.go: -------------------------------------------------------------------------------- 1 | package multitree 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatih/color" 7 | ) 8 | 9 | type Link struct { 10 | ID int64 11 | OriginID int64 12 | DestID int64 13 | } 14 | 15 | func NewLink(originID, destID int64) *Link { 16 | return &Link{OriginID: originID, DestID: destID} 17 | } 18 | 19 | func (l *Link) String() string { 20 | accent := color.New(color.FgCyan).SprintFunc() 21 | id1 := accent(fmt.Sprintf("(%d)", l.OriginID)) 22 | id2 := accent(fmt.Sprintf("(%d)", l.DestID)) 23 | return fmt.Sprintf("%s -> %s", id1, id2) 24 | } 25 | -------------------------------------------------------------------------------- /multitree/multitree_test.go: -------------------------------------------------------------------------------- 1 | package multitree 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func newTestNode(id int64) *Node { 12 | created := time.Now().Unix() - 1000000 + id 13 | return &Node{ 14 | ID: id, 15 | Name: "test", 16 | Created: created, 17 | } 18 | } 19 | 20 | func sprintfIDs(nodes []*Node) string { 21 | ids := make([]string, 0, len(nodes)) 22 | for _, n := range nodes { 23 | ids = append(ids, fmt.Sprintf("%d", n.ID)) 24 | } 25 | return fmt.Sprintf("[%s]", strings.Join(ids, ", ")) 26 | } 27 | 28 | func linkOrFail(t *testing.T, origin, dest *Node) { 29 | if err := LinkNodes(origin, dest); err != nil { 30 | t.Fatalf("couldn't create link %d->%d: %v", origin.ID, dest.ID, err) 31 | } 32 | } 33 | 34 | func TestLinkNodes(t *testing.T) { 35 | { 36 | // Simple link. 37 | root1 := newTestNode(1) 38 | root2 := newTestNode(2) 39 | 40 | if err := LinkNodes(root1, root2); err != nil { 41 | t.Fatalf("couldn't make root a child of another root: %v", err) 42 | } 43 | if err := LinkNodes(root1, root2); err == nil { 44 | t.Error("added duplicate child") 45 | } 46 | 47 | if len(root1.Parents()) != 0 { 48 | t.Errorf("root1 shouldn't have any parents") 49 | } 50 | childrenWant := []*Node{root2} 51 | childrenGot := root1.Children() 52 | if !reflect.DeepEqual(childrenWant, childrenGot) { 53 | t.Errorf("invalid children after linking nodes; want %s, got %s", 54 | sprintfIDs(childrenWant), sprintfIDs(childrenGot)) 55 | } 56 | 57 | if len(root2.Children()) != 0 { 58 | t.Errorf("root2 shouldn't have any children") 59 | } 60 | parentsWant := []*Node{root1} 61 | parentsGot := root2.Parents() 62 | if !reflect.DeepEqual(parentsWant, parentsGot) { 63 | t.Errorf("invalid parents after linking nodes; want %s, got %s", 64 | sprintfIDs(parentsWant), sprintfIDs(parentsGot)) 65 | } 66 | } 67 | 68 | { 69 | // Cross link. 70 | // 71 | // (0) (2) 72 | // | 73 | // (1) 74 | // 75 | var nodes []*Node 76 | for i := 0; i < 4; i++ { 77 | nodes = append(nodes, newTestNode(int64(i+1))) 78 | } 79 | _ = LinkNodes(nodes[0], nodes[1]) 80 | 81 | // (2) -> (0) 82 | if err := LinkNodes(nodes[2], nodes[1]); err != nil { 83 | t.Errorf("couldn't create a cross link: %v", err) 84 | } 85 | } 86 | 87 | { 88 | // Diamonds. 89 | // 90 | // (0) 91 | // / \ 92 | // (1) (2) 93 | // \ 94 | // (3) 95 | // 96 | var nodes []*Node 97 | for i := 0; i < 4; i++ { 98 | nodes = append(nodes, newTestNode(int64(i+1))) 99 | } 100 | linkOrFail(t, nodes[0], nodes[1]) 101 | linkOrFail(t, nodes[0], nodes[2]) 102 | linkOrFail(t, nodes[2], nodes[3]) 103 | 104 | // (0) -> (3) 105 | if err := LinkNodes(nodes[0], nodes[3]); err == nil { 106 | t.Errorf("a diamond slipped through LinkNodes: 1->2, 1->3, 3->4, 4->1") 107 | } 108 | // (3) -> (1) 109 | if err := LinkNodes(nodes[3], nodes[1]); err == nil { 110 | t.Errorf("a diamond slipped through LinkNodes: 1->2, 1->3, 3->4, 2->4") 111 | } 112 | } 113 | } 114 | 115 | func TestRoots(t *testing.T) { 116 | // 117 | // (0) 118 | // / \ 119 | // (1) (2) (3) 120 | // / \ / \ 121 | // (4) (5) (6) 122 | // 123 | var nodes []*Node 124 | for i := 0; i < 7; i++ { 125 | nodes = append(nodes, newTestNode(int64(i+1))) 126 | } 127 | _ = LinkNodes(nodes[0], nodes[1]) 128 | _ = LinkNodes(nodes[0], nodes[2]) 129 | _ = LinkNodes(nodes[2], nodes[4]) 130 | _ = LinkNodes(nodes[2], nodes[5]) 131 | _ = LinkNodes(nodes[3], nodes[5]) 132 | _ = LinkNodes(nodes[3], nodes[6]) 133 | 134 | rootsWant := []*Node{nodes[3]} 135 | rootsGot := nodes[6].Roots() 136 | if !reflect.DeepEqual(rootsWant, rootsGot) { 137 | t.Errorf("invalid local roots; want %s, got %s", 138 | sprintfIDs(rootsWant), sprintfIDs(rootsGot)) 139 | } 140 | 141 | rootsAllWant := []*Node{nodes[0], nodes[3]} 142 | rootsAllGot := nodes[6].RootsAll() 143 | if !reflect.DeepEqual(rootsAllWant, rootsAllGot) { 144 | t.Errorf("invalid global roots; want %s, got %s", 145 | sprintfIDs(rootsAllWant), sprintfIDs(rootsAllGot)) 146 | } 147 | } 148 | 149 | func TestLeaves(t *testing.T) { 150 | // 151 | // (0) 152 | // / \ 153 | // (1) (2) (3) 154 | // / \ / \ 155 | // (4) (5) (6) 156 | // 157 | var nodes []*Node 158 | for i := 0; i < 7; i++ { 159 | nodes = append(nodes, newTestNode(int64(i+1))) 160 | } 161 | _ = LinkNodes(nodes[0], nodes[1]) 162 | _ = LinkNodes(nodes[0], nodes[2]) 163 | _ = LinkNodes(nodes[2], nodes[4]) 164 | _ = LinkNodes(nodes[2], nodes[5]) 165 | _ = LinkNodes(nodes[3], nodes[5]) 166 | _ = LinkNodes(nodes[3], nodes[6]) 167 | 168 | leavesWant := []*Node{nodes[5], nodes[6]} 169 | leavesGot := nodes[3].Leaves() 170 | if !reflect.DeepEqual(leavesWant, leavesGot) { 171 | t.Errorf("invalid local leaves; want %s, got %s", 172 | sprintfIDs(leavesWant), sprintfIDs(leavesGot)) 173 | } 174 | 175 | leavesAllWant := []*Node{nodes[1], nodes[4], nodes[5], nodes[6]} 176 | leavesAllGot := nodes[3].LeavesAll() 177 | if !reflect.DeepEqual(leavesAllWant, leavesAllGot) { 178 | t.Errorf("invalid global leaves; want %s, got %s", 179 | sprintfIDs(leavesAllWant), sprintfIDs(leavesAllGot)) 180 | } 181 | } 182 | 183 | func TestTree(t *testing.T) { 184 | // 185 | // (0) 186 | // / \ 187 | // (1) (2) (3) 188 | // / \ / \ 189 | // (4) (5) (6) 190 | // 191 | var nodes []*Node 192 | for i := 0; i < 7; i++ { 193 | nodes = append(nodes, newTestNode(int64(i+1))) 194 | } 195 | _ = LinkNodes(nodes[0], nodes[1]) 196 | _ = LinkNodes(nodes[0], nodes[2]) 197 | _ = LinkNodes(nodes[2], nodes[4]) 198 | _ = LinkNodes(nodes[2], nodes[5]) 199 | _ = LinkNodes(nodes[3], nodes[5]) 200 | _ = LinkNodes(nodes[3], nodes[6]) 201 | 202 | want := sprintfIDs([]*Node{nodes[2], nodes[4], nodes[5]}) 203 | got := sprintfIDs(nodes[2].Tree().All()) 204 | 205 | if want != got { 206 | t.Errorf("invalid tree nodes: want %s, got %s", want, got) 207 | } 208 | } 209 | 210 | func TestTreeString(t *testing.T) { 211 | want := ` 212 | [ ] test (1) 213 | ├──[ ] test (2) 214 | │ └──[ ] test (3) 215 | └──[ ] test (4) 216 | ├──[ ] test (5) 217 | └──[ ] test (6)` 218 | 219 | want = strings.TrimSpace(want) 220 | 221 | var nodes []*Node 222 | for i := 0; i < 6; i++ { 223 | nodes = append(nodes, newTestNode(int64(i+1))) 224 | } 225 | 226 | linkOrFail(t, nodes[0], nodes[1]) 227 | linkOrFail(t, nodes[1], nodes[2]) 228 | linkOrFail(t, nodes[0], nodes[3]) 229 | linkOrFail(t, nodes[3], nodes[4]) 230 | linkOrFail(t, nodes[3], nodes[5]) 231 | 232 | got := strings.TrimSpace(nodes[0].StringTree()) 233 | 234 | if want != got { 235 | t.Errorf("invalid string representation of a tree\n\n"+ 236 | "want:\n\n%s\n\ngot:\n\n%s\n\n", want, got) 237 | } 238 | } 239 | 240 | func TestImportTrees(t *testing.T) { 241 | want := []string{ 242 | `[ ] test (1)`, 243 | strings.TrimSpace(` 244 | [ ] test (1) 245 | ├──[ ] test (2) 246 | │ └──[ ] test (3) 247 | └──[ ] test (4) 248 | ├──[ ] test (5) 249 | └──[ ] test (6)`), 250 | } 251 | wantString := strings.Join(want, "\n") 252 | 253 | testStringInput := func(input string) { 254 | roots, err := ImportTrees(strings.NewReader(input)) 255 | if err != nil { 256 | t.Errorf("error importing trees: %v", err) 257 | return 258 | } 259 | 260 | if len(roots) != len(want) { 261 | t.Errorf("want %d trees, imported %d", len(want), len(roots)) 262 | } 263 | 264 | var gotString string 265 | for _, r := range roots { 266 | gotString += r.StringTree() 267 | } 268 | 269 | if strings.TrimSpace(gotString) != wantString { 270 | t.Errorf("\n\nwant:\n\n%s\n\ngot:\n\n%s\n\n", wantString, gotString) 271 | } 272 | } 273 | 274 | inputTabs := ` 275 | test 276 | test 277 | test 278 | test 279 | 280 | test 281 | test 282 | test` 283 | 284 | inputSpaces := ` 285 | test 286 | test 287 | test 288 | test 289 | 290 | test 291 | test 292 | test` 293 | 294 | testStringInput(inputTabs) 295 | testStringInput(inputSpaces) 296 | 297 | // TODO: mixing tabs and spaces should return an error. 298 | } 299 | -------------------------------------------------------------------------------- /multitree/node.go: -------------------------------------------------------------------------------- 1 | package multitree 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type Node struct { 9 | ID int64 10 | Name string 11 | 12 | // Alias is an optional secondary identifier of the node. 13 | Alias string 14 | 15 | // Created holds the Unix timestamp for the node's creation time. 16 | Created int64 17 | 18 | // Completed points to the Unix timestamp of when the node was marked as 19 | // completed, or nil, if the node hasn't been completed yet. 20 | Completed *int64 21 | 22 | parents []*Node 23 | children []*Node 24 | } 25 | 26 | func NewNode(name string) *Node { 27 | return &Node{Name: name} 28 | } 29 | 30 | // nextID returns one more than the highest ID in the 31 | // multitree. 32 | func (n *Node) nextID() int64 { 33 | var max int64 34 | for _, node := range n.All() { 35 | if node.ID > max { 36 | max = node.ID 37 | } 38 | } 39 | return max + 1 40 | } 41 | 42 | // New creates a new node with the ID set to 1 more than the highest ID in the 43 | // multitree. 44 | func (n *Node) New(name string) *Node { 45 | return &Node{ID: n.nextID(), Name: name} 46 | } 47 | 48 | func (n *Node) IsCompleted() bool { 49 | return n.Completed != nil 50 | } 51 | 52 | // IsCompletedOnDate returns true if n was completed on date given as a string 53 | // in the format "YYYY-MM-DD". The start of day is determined by offset, e.g. if 54 | // offset is 4, the day starts at 4 A.M. 55 | func (n *Node) IsCompletedOnDate(date string, offset int) bool { 56 | t := n.TimeCompleted() 57 | 58 | if !t.IsZero() { 59 | start, err := time.Parse("2006-01-02", date) 60 | if err != nil { 61 | panic(err) 62 | } 63 | start = start.Local().Add(time.Duration(offset) * time.Hour) 64 | end := start.Add(24 * time.Hour) 65 | 66 | if t.Equal(start) || (t.After(start) && t.Before(end)) { 67 | return true 68 | } 69 | } 70 | 71 | return false 72 | } 73 | 74 | func (n *Node) IsInProgress() bool { 75 | if n.IsCompleted() { 76 | return false 77 | } 78 | for _, d := range n.Descendants() { 79 | if d.IsCompleted() { 80 | return true 81 | } 82 | } 83 | return false 84 | } 85 | 86 | func (n *Node) IsInactive() bool { 87 | return !n.IsCompleted() && !n.IsInProgress() 88 | } 89 | 90 | func (n *Node) IsRoot() bool { 91 | return len(n.parents) == 0 92 | } 93 | 94 | func (n *Node) IsDateNode() bool { 95 | if n.IsRoot() && ValidateDateNodeName(n.Name) == nil { 96 | return true 97 | } 98 | return false 99 | } 100 | 101 | // TimeCompleted returns the task completion time as local time.Time. 102 | func (n *Node) TimeCompleted() time.Time { 103 | var t time.Time 104 | if n.Completed != nil { 105 | t = time.Unix(*n.Completed, 0) 106 | } 107 | return t 108 | } 109 | 110 | func (n *Node) Children() []*Node { 111 | return n.children 112 | } 113 | 114 | func (n *Node) Parents() []*Node { 115 | return n.parents 116 | } 117 | 118 | // Ancestors returns a flat list of the node's ancestors. 119 | func (n *Node) Ancestors() []*Node { 120 | var nodes []*Node 121 | n.TraverseAncestors(func(current *Node, _ func()) { 122 | nodes = append(nodes, current) 123 | }) 124 | if len(nodes) > 0 { 125 | return nodes[1:] 126 | } 127 | return nodes 128 | } 129 | 130 | // Descendants returns a flat list of the node's descendants. 131 | func (n *Node) Descendants() []*Node { 132 | var nodes []*Node 133 | n.TraverseDescendants(func(current *Node, _ func()) { 134 | nodes = append(nodes, current) 135 | }) 136 | if len(nodes) > 0 { 137 | return nodes[1:] 138 | } 139 | return nodes 140 | } 141 | 142 | // All returns a flat list of all nodes in the multitree. The nodes are sorted 143 | // by ID in ascending order. 144 | func (n *Node) All() []*Node { 145 | var nodes []*Node 146 | n.DepthFirstSearchUndirected(func(cur *Node, ss SearchState, _ func()) { 147 | if ss == SearchStateWhite { 148 | nodes = append(nodes, cur) 149 | } 150 | }) 151 | SortNodesByID(nodes) 152 | return nodes 153 | } 154 | 155 | // Roots returns the local roots found by following the node's ancestors all 156 | // the way up. The nodes are sorted by ID in ascending order. 157 | func (n *Node) Roots() []*Node { 158 | var roots []*Node 159 | n.TraverseAncestors(func(current *Node, _ func()) { 160 | if current.IsRoot() { 161 | roots = append(roots, current) 162 | } 163 | }) 164 | SortNodesByID(roots) 165 | return roots 166 | } 167 | 168 | // RootsAll returns a list of all roots in the multitree, not just the roots 169 | // local to the node. The nodes are sorted by ID in ascending order. 170 | func (n *Node) RootsAll() []*Node { 171 | var roots []*Node 172 | for _, node := range n.All() { 173 | if node.IsRoot() { 174 | roots = append(roots, node) 175 | } 176 | } 177 | SortNodesByID(roots) 178 | return roots 179 | } 180 | 181 | // Roots returns the local roots found by following the node's descendants all 182 | // the way down. The nodes are sorted by ID in ascending order. 183 | func (n *Node) IsLeaf() bool { 184 | return len(n.children) == 0 185 | } 186 | 187 | func (n *Node) HasChildren() bool { 188 | return len(n.children) != 0 189 | } 190 | 191 | func (n *Node) HasParents() bool { 192 | return len(n.parents) != 0 193 | } 194 | 195 | func (n *Node) Leaves() []*Node { 196 | var leaves []*Node 197 | n.TraverseDescendants(func(current *Node, _ func()) { 198 | if current.IsLeaf() { 199 | leaves = append(leaves, current) 200 | } 201 | }) 202 | SortNodesByID(leaves) 203 | return leaves 204 | } 205 | 206 | // LeavesAll returns a list of all leaves in the multitree, not just the leaves 207 | // local to the node. The nodes are sorted by ID in ascending order. 208 | func (n *Node) LeavesAll() []*Node { 209 | var leaves []*Node 210 | for _, node := range n.All() { 211 | if node.IsLeaf() { 212 | leaves = append(leaves, node) 213 | } 214 | } 215 | SortNodesByID(leaves) 216 | return leaves 217 | } 218 | 219 | // Tree returns a tree rooted at n, induced by following the children all the 220 | // way down to the leaves. The tree nodes are guaranteed to only have one 221 | // parent. 222 | func (n *Node) Tree() *Node { 223 | root := n.DeepCopy() 224 | root.parents = nil 225 | root.TraverseDescendants(func(current *Node, _ func()) { 226 | for _, child := range current.children { 227 | child.parents = []*Node{current} 228 | } 229 | }) 230 | return root 231 | } 232 | 233 | // Copy returns a shallow, unlinked copy of the node. 234 | func (n *Node) Copy() *Node { 235 | return &Node{ 236 | ID: n.ID, 237 | Name: n.Name, 238 | Alias: n.Alias, 239 | Created: n.Created, 240 | Completed: copyCompletion(n.Completed), 241 | } 242 | } 243 | 244 | // Copy returns a deep copy of the entire multitree that the node belongs to. 245 | func (n *Node) DeepCopy() *Node { 246 | nodes := n.All() 247 | nodesByID := make(map[int64]*Node) 248 | 249 | // Copy the nodes into the map, ignoring the links for now. 250 | for _, src := range nodes { 251 | nodesByID[src.ID] = src.Copy() 252 | } 253 | 254 | // Create the links between the new nodes. 255 | for _, src := range nodes { 256 | nodesByID[src.ID].parents = make([]*Node, len(src.parents)) 257 | for i, p := range src.parents { 258 | nodesByID[src.ID].parents[i] = nodesByID[p.ID] 259 | } 260 | nodesByID[src.ID].children = make([]*Node, len(src.children)) 261 | for i, c := range src.children { 262 | nodesByID[src.ID].children[i] = nodesByID[c.ID] 263 | } 264 | } 265 | 266 | return nodesByID[n.ID] 267 | } 268 | 269 | func (n *Node) HasChild(node *Node) bool { 270 | return nodesInclude(n.children, node) 271 | } 272 | 273 | func (n *Node) HasParent(node *Node) bool { 274 | return nodesInclude(n.parents, node) 275 | } 276 | 277 | // hasBackEdge returns true if at least one back edge is found in the directed 278 | // graph. 279 | func (n *Node) hasBackEdge() (found bool) { 280 | roots := n.RootsAll() 281 | if len(roots) == 0 { 282 | // Cyclic graph -- choose any node as our "root". 283 | roots = append(roots, n) 284 | } 285 | for _, r := range roots { 286 | r.DepthFirstSearch(func(cur *Node, ss SearchState, stop func()) { 287 | if ss == SearchStateGray { 288 | found = true 289 | stop() 290 | } 291 | }) 292 | if found { 293 | break 294 | } 295 | } 296 | return found 297 | } 298 | 299 | // hasDiamond returns true if at least one diamond is found in the graph, which 300 | // is here assumed to be a DAG. (A diamond occurs when two directed paths 301 | // diverge from a node and meet again at some other node.) 302 | func (n *Node) hasDiamond() (found bool) { 303 | roots := n.RootsAll() 304 | if len(roots) == 0 { 305 | panic("cyclic graph passed to Node.hasDiamond") 306 | } 307 | for _, r := range roots { 308 | r.DepthFirstSearch(func(cur *Node, ss SearchState, stop func()) { 309 | if ss == SearchStateBlack { 310 | found = true 311 | stop() 312 | } 313 | }) 314 | if found { 315 | break 316 | } 317 | } 318 | return found 319 | } 320 | 321 | // Get returns the first node matching the ID, or nil, if no match is found. 322 | func (n *Node) Get(id int64) *Node { 323 | for _, node := range n.All() { 324 | if node.ID == id { 325 | return node 326 | } 327 | } 328 | return nil 329 | } 330 | 331 | // Get returns the first node matching the name, or nil, if no match is found. 332 | func (n *Node) GetByName(name string) *Node { 333 | for _, node := range n.All() { 334 | if node.Name == name { 335 | return node 336 | } 337 | } 338 | return nil 339 | } 340 | 341 | // Get returns the first node matching the alias, or nil, if no match is found. 342 | func (n *Node) GetByAlias(alias string) *Node { 343 | for _, node := range n.All() { 344 | if node.Alias == alias { 345 | return node 346 | } 347 | } 348 | return nil 349 | } 350 | 351 | // validateNewLink creates deep copies of the nodes' graphs, connects the copied 352 | // nodes, and checks if the resulting graph is a valid multitree. 353 | func validateNewLink(origin, dest *Node) error { 354 | if origin.ID == 0 || dest.ID == 0 { 355 | panic("link endpoints must have IDs") 356 | } 357 | if origin.HasChild(dest) != dest.HasParent(origin) { 358 | panic("parent/child out of sync") 359 | } 360 | if origin.HasChild(dest) { 361 | return fmt.Errorf("link already exists") 362 | } 363 | if ValidateDateNodeName(dest.Name) == nil { 364 | return fmt.Errorf("cannot unroot date node") 365 | } 366 | 367 | parent := origin.DeepCopy() 368 | child := dest.DeepCopy() 369 | parent.children = append(parent.children, child) 370 | child.parents = append(child.parents, parent) 371 | 372 | if parent.hasBackEdge() { 373 | return fmt.Errorf("cycles are not allowed") 374 | } 375 | if parent.hasDiamond() { 376 | return fmt.Errorf("diamonds are not allowed") 377 | } 378 | 379 | return nil 380 | } 381 | 382 | // LinkNodes creates a directed link from origin to dest, provided that the 383 | // resulting graph will be a valid multitree. It returns an error otherwise. 384 | // The given nodes are modified only if no error occurs. 385 | func LinkNodes(origin, dest *Node) error { 386 | if err := validateNewLink(origin, dest); err != nil { 387 | return err 388 | } 389 | origin.children = append(origin.children, dest) 390 | dest.parents = append(dest.parents, origin) 391 | return nil 392 | } 393 | 394 | // LinkNodes removes an existing directed link between origin and dest. It 395 | // returns an error if the link doesn't exist. 396 | func UnlinkNodes(origin, dest *Node) error { 397 | if origin.HasChild(dest) != dest.HasParent(origin) { 398 | panic("parent/child out of sync") 399 | } 400 | if !origin.HasChild(dest) { 401 | return fmt.Errorf("link does not exist") 402 | } 403 | origin.children, _ = removeNode(origin.children, dest) 404 | dest.parents, _ = removeNode(dest.parents, origin) 405 | return nil 406 | } 407 | -------------------------------------------------------------------------------- /multitree/repr.go: -------------------------------------------------------------------------------- 1 | package multitree 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | "unicode/utf8" 8 | 9 | "github.com/fatih/color" 10 | ) 11 | 12 | func (n *Node) checkbox() string { 13 | switch n.Status() { 14 | case TaskStatusCompleted: 15 | return "[x]" 16 | case TaskStatusInProgress: 17 | return "[~]" 18 | case TaskStatusInactive: 19 | return "[ ]" 20 | default: 21 | panic("invalid node status") 22 | } 23 | } 24 | 25 | // String returns a basic string representation of the node. Color is 26 | // automatically disabled when in non-tty output mode. 27 | func (n *Node) String() string { 28 | var id string 29 | if n.Alias == "" { 30 | id = fmt.Sprintf("(%d)", n.ID) 31 | } else { 32 | id = fmt.Sprintf("(%d:%s)", n.ID, n.Alias) 33 | } 34 | 35 | // Change accent color for descendants of the current date node. 36 | accent := color.New(color.FgCyan).SprintFunc() 37 | for _, r := range n.Roots() { 38 | if r.Name == time.Now().Format("2006-01-02") { 39 | accent = color.New(color.FgYellow).SprintFunc() 40 | break 41 | } 42 | } 43 | 44 | // Highlight root node. 45 | name := n.Name 46 | if len(n.parents) == 0 { 47 | bold := color.New(color.Bold).SprintFunc() 48 | name = bold(name) 49 | } 50 | 51 | return fmt.Sprintf("%s %s %s", accent(n.checkbox()), name, accent(id)) 52 | } 53 | 54 | const ( 55 | treeIndentBlank = " " 56 | treeIndentExtend = " │ " 57 | treeIndentSplit = " ├──" 58 | treeIndentTerminate = " └──" 59 | ) 60 | 61 | // StringTree returns a string representation of a tree rooted at n. 62 | // 63 | // [~] Clean up the house (234) 64 | // ├──[~] Clean up the bedroom (235) 65 | // │ ├──[x] Clean up the desk (236) 66 | // │ ├──[ ] Clean up the floor (237) 67 | // │ └──[ ] Make the bed (238) 68 | // ├──[ ] Clean up the kitchen (239) 69 | // └──[ ] ... 70 | // 71 | func (n *Node) StringTree() string { 72 | var sb strings.Builder 73 | var traverse func(*Node, []bool) 74 | viewRoot := n.Tree().Roots()[0] 75 | 76 | // The stack holds a boolean value for each of the node's indent levels. If 77 | // the value is true, there are more siblings to come on that level, and the 78 | // line should be extended or "split". Otherwise, the line should be 79 | // terminated or left blank. 80 | traverse = func(n *Node, stack []bool) { 81 | var indents []string 82 | 83 | if len(stack) != 0 { 84 | // Previous levels -- extend or leave blank. 85 | for _, v := range stack[:len(stack)-1] { 86 | if v { 87 | indents = append(indents, treeIndentExtend) 88 | } else { 89 | indents = append(indents, treeIndentBlank) 90 | } 91 | } 92 | // Current level -- split or terminate. 93 | if stack[len(stack)-1] { 94 | indents = append(indents, treeIndentSplit) 95 | } else { 96 | indents = append(indents, treeIndentTerminate) 97 | } 98 | // Change to "dotted line" if node has multiple parents. 99 | if len(n.parents) > 1 { 100 | i := len(indents) - 1 101 | indents[i] = string([]rune(indents[i])[:2]) + "··" 102 | } 103 | } 104 | 105 | for _, i := range indents { 106 | sb.WriteString(i) 107 | } 108 | 109 | // Change "[x]" to "[*]" when current view date != node's completion date. 110 | // TODO: make start of day configurable. 111 | nodeStr := n.String() 112 | if viewRoot.IsDateNode() && !n.IsCompletedOnDate(viewRoot.Name, 4) { 113 | nodeStr = strings.Replace(nodeStr, "[x]", "[*]", 1) 114 | } 115 | 116 | sb.WriteString(nodeStr) 117 | sb.WriteString("\n") 118 | 119 | if len(n.children) != 0 { 120 | for _, c := range n.children[:len(n.children)-1] { 121 | traverse(c, append(stack, true)) 122 | } 123 | traverse(n.children[len(n.children)-1], append(stack, false)) 124 | } 125 | } 126 | 127 | traverse(n, []bool{}) 128 | return sb.String() 129 | } 130 | 131 | // StringNeighbors returns a string representation of the node's neighborhood, 132 | // e.g.: 133 | // 134 | // (45) ──┐ 135 | // (150) ──┴── (123) ──┬── (124) 136 | // └── (125) 137 | // 138 | func (n *Node) StringNeighbors() string { 139 | // Stringify the IDs. 140 | pids := make([]string, 0, len(n.parents)) 141 | for _, p := range n.parents { 142 | pids = append(pids, fmt.Sprintf("(%d)", p.ID)) 143 | } 144 | cids := make([]string, 0, len(n.children)) 145 | for _, c := range n.children { 146 | cids = append(cids, fmt.Sprintf("(%d)", c.ID)) 147 | } 148 | 149 | padleft := func(text string, n int) string { 150 | return strings.Repeat(" ", n-utf8.RuneCountInString(text)) + text 151 | } 152 | 153 | var output string 154 | maxlen := longestStringRuneCount(pids) 155 | indent := 0 156 | left := 0 157 | 158 | if length := len(pids); length == 0 { 159 | output += strings.Repeat(" ", indent) 160 | left = indent 161 | } else { 162 | spaces := strings.Repeat(" ", indent) 163 | if length == 1 { 164 | id := padleft(pids[0], maxlen) 165 | output += spaces + id + " ──── " 166 | left = indent + maxlen + 6 167 | } else { 168 | spaces := strings.Repeat(" ", indent) 169 | for i, p := range pids { 170 | id := padleft(p, maxlen) 171 | if i == 0 { 172 | output += spaces + id + " ───┐\n" 173 | } else if i != length-1 { 174 | output += spaces + id + " ───┤\n" 175 | } else { 176 | output += spaces + id + " ───┴─── " 177 | } 178 | } 179 | left = indent + maxlen + 9 180 | } 181 | } 182 | 183 | id := fmt.Sprintf("(%d)", n.ID) 184 | left += len(id) 185 | accent := color.New(color.FgCyan).SprintFunc() 186 | output += accent(id) 187 | 188 | if length := len(cids); length == 1 { 189 | output += " ──── " + cids[0] + "\n" 190 | } else if length > 1 { 191 | spaces := strings.Repeat(" ", left) 192 | for i, c := range cids { 193 | if i == 0 { 194 | output += " ───┬─── " + c + "\n" 195 | } else if i != length-1 { 196 | output += spaces + " ├─── " + c + "\n" 197 | } else { 198 | output += spaces + " └─── " + c + "\n" 199 | } 200 | } 201 | } else { 202 | output += "\n" 203 | } 204 | 205 | return output 206 | } 207 | -------------------------------------------------------------------------------- /multitree/sort.go: -------------------------------------------------------------------------------- 1 | package multitree 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/climech/naturalsort" 7 | ) 8 | 9 | // SortNodesByID sorts a slice of nodes in-place by Node.ID in ascending order. 10 | func SortNodesByID(nodes []*Node) { 11 | sort.SliceStable(nodes, func(i, j int) bool { 12 | return nodes[i].ID < nodes[j].ID 13 | }) 14 | } 15 | 16 | // SortNodesByName uses natural sort to sort a slice of nodes in-place by 17 | // Node.Name in ascending order. 18 | func SortNodesByName(nodes []*Node) { 19 | sort.SliceStable(nodes, func(i, j int) bool { 20 | return naturalsort.Compare(nodes[i].Name, nodes[j].Name) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /multitree/status.go: -------------------------------------------------------------------------------- 1 | package multitree 2 | 3 | type TaskStatus int 4 | 5 | const ( 6 | TaskStatusCompleted TaskStatus = iota 7 | TaskStatusInProgress 8 | TaskStatusInactive 9 | ) 10 | 11 | func (s TaskStatus) String() string { 12 | switch s { 13 | case TaskStatusCompleted: 14 | return "completed" 15 | case TaskStatusInProgress: 16 | return "in progress" 17 | case TaskStatusInactive: 18 | return "inactive" 19 | default: 20 | panic("invalid task status") 21 | } 22 | } 23 | 24 | func (n *Node) Status() TaskStatus { 25 | if n.IsCompleted() { 26 | return TaskStatusCompleted 27 | } else if n.IsInProgress() { 28 | return TaskStatusInProgress 29 | } 30 | return TaskStatusInactive 31 | } 32 | -------------------------------------------------------------------------------- /multitree/traverse.go: -------------------------------------------------------------------------------- 1 | package multitree 2 | 3 | type SearchState int 4 | 5 | const ( 6 | SearchStateWhite SearchState = iota // undiscovered 7 | SearchStateGray // discovered, but not finished 8 | SearchStateBlack // finished 9 | ) 10 | 11 | // TraverseDescendants calls f for each descendant of the node. The function is 12 | // passed a pointer to the current node and a stop function that can be called 13 | // to exit early. 14 | func (n *Node) TraverseDescendants(f func(*Node, func())) { 15 | var stop bool 16 | stopFunc := func() { 17 | stop = true 18 | } 19 | var traverse func(*Node) 20 | traverse = func(cur *Node) { 21 | f(cur, stopFunc) 22 | for _, c := range cur.children { 23 | if stop { 24 | break 25 | } 26 | traverse(c) 27 | } 28 | } 29 | traverse(n) 30 | } 31 | 32 | // TraverseAncestors calls f for each ancestor of the node. The function is 33 | // passed a pointer to the current node and a stop function that can be called 34 | // to exit early. 35 | func (n *Node) TraverseAncestors(f func(*Node, func())) { 36 | var stop bool 37 | stopFunc := func() { 38 | stop = true 39 | } 40 | var traverse func(*Node) 41 | traverse = func(cur *Node) { 42 | f(cur, stopFunc) 43 | for _, p := range cur.parents { 44 | if stop { 45 | break 46 | } 47 | traverse(p) 48 | } 49 | } 50 | traverse(n) 51 | } 52 | 53 | func dfs(node *Node, f func(*Node, SearchState, func()), directed bool) { 54 | var stop bool 55 | stopFunc := func() { 56 | stop = true 57 | } 58 | 59 | stateByID := make(map[int64]SearchState) // not in map => white 60 | var traverse func(*Node) 61 | 62 | traverse = func(current *Node) { 63 | stateByID[current.ID] = SearchStateGray 64 | 65 | reachable := current.children 66 | if !directed { 67 | reachable = append(current.parents, current.children...) 68 | } 69 | 70 | for _, r := range reachable { 71 | if stop { 72 | break 73 | } 74 | if ss, ok := stateByID[r.ID]; ok { 75 | f(r, ss, stopFunc) 76 | } else { 77 | f(r, SearchStateWhite, stopFunc) 78 | traverse(r) 79 | } 80 | } 81 | 82 | stateByID[current.ID] = SearchStateBlack 83 | } 84 | 85 | f(node, SearchStateWhite, stopFunc) 86 | traverse(node) 87 | } 88 | 89 | // DepthFirstSearch traverses the graph directionally starting from the node, 90 | // calling f on each step forward. The function is passed a pointer to the 91 | // current node, the node's search state, and a stop function that can be called 92 | // to exit early. 93 | func (n *Node) DepthFirstSearch(f func(*Node, SearchState, func())) { 94 | dfs(n, f, true) 95 | } 96 | 97 | // DepthFirstSearchUndirected traverses the entire graph, ignoring the 98 | // direction, advancing through parents and children alike. It starts from n and 99 | // calls f on each step forward. The function is passed a pointer to the current 100 | // node, the node's search state, and a stop function that can be called to exit 101 | // early. 102 | func (n *Node) DepthFirstSearchUndirected(f func(*Node, SearchState, func())) { 103 | dfs(n, f, false) 104 | } 105 | -------------------------------------------------------------------------------- /multitree/utils.go: -------------------------------------------------------------------------------- 1 | package multitree 2 | 3 | import ( 4 | "fmt" 5 | "unicode/utf8" 6 | ) 7 | 8 | func nodesInclude(nodes []*Node, node *Node) bool { 9 | for _, n := range nodes { 10 | if n.ID == node.ID { 11 | return true 12 | } 13 | } 14 | return false 15 | } 16 | 17 | func removeNode(nodes []*Node, node *Node) ([]*Node, error) { 18 | index := -1 19 | for i, n := range nodes { 20 | if n.ID == node.ID { 21 | index = i 22 | break 23 | } 24 | } 25 | if index == -1 { 26 | return nil, fmt.Errorf("node was not found") 27 | } 28 | return append(nodes[:index], nodes[index+1:]...), nil 29 | } 30 | 31 | func copyCompletion(value *int64) *int64 { 32 | if value == nil { 33 | return nil 34 | } 35 | cp := *value 36 | return &cp 37 | } 38 | 39 | func longestStringRuneCount(slice []string) int { 40 | var max, count int 41 | for _, s := range slice { 42 | count = utf8.RuneCountInString(s) 43 | if count > max { 44 | max = count 45 | } 46 | } 47 | return max 48 | } 49 | -------------------------------------------------------------------------------- /multitree/validate.go: -------------------------------------------------------------------------------- 1 | package multitree 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | func ValidateNodeName(name string) error { 10 | if ValidateDateNodeName(name) == nil { 11 | return errors.New("name is reserved") 12 | } 13 | if len(name) == 0 { 14 | return errors.New("invalid node name (empty name)") 15 | } 16 | if len(name) > 100 { 17 | return errors.New("invalid node name (name too long)") 18 | } 19 | return nil 20 | } 21 | 22 | func ValidateDateNodeName(name string) error { 23 | if len(name) == 0 { 24 | return fmt.Errorf("invalid date node name: empty string") 25 | } 26 | if _, err := time.Parse("2006-01-02", name); err != nil { 27 | return fmt.Errorf("invalid date node name: %v", name) 28 | } 29 | return nil 30 | } 31 | 32 | func ValidateNodeAlias(alias string) error { 33 | // TODO 34 | if len(alias) == 0 { 35 | return errors.New("invalid alias (empty)") 36 | } 37 | if len(alias) > 100 { 38 | return errors.New("invalid alias (too long)") 39 | } 40 | return nil 41 | } 42 | --------------------------------------------------------------------------------