├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── banner.png ├── crumbs ├── .goreleaser.yml ├── go.mod ├── go.sum └── main.go ├── go.mod ├── go.sum ├── gv ├── graph.go ├── graph_test.go └── render.go ├── note.go ├── parser.go ├── parser_test.go ├── testdata ├── png │ ├── bulb.png │ ├── comments-alt.png │ └── map-signs.png ├── sample1.txt ├── sample4.png ├── sample4.txt ├── sample5.png ├── sample5.txt ├── sample6.png ├── sample6.txt ├── when-to-use-crumbs-icons-no-path-ext.png ├── when-to-use-crumbs-icons-no-path-ext.txt ├── when-to-use-crumbs-icons.png ├── when-to-use-crumbs-icons.txt ├── when-to-use-crumbs.png └── when-to-use-crumbs.txt └── text ├── find.go ├── find_test.go ├── wrap.go └── wrap_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | ## Intellij 2 | .idea/**/workspace.xml 3 | .idea/**/tasks.xml 4 | .idea/**/encodings.xml 5 | .idea/**/compiler.xml 6 | .idea/**/misc.xml 7 | .idea/**/modules.xml 8 | .idea/**/vcs.xml 9 | 10 | ## VSCode 11 | .vscode/ 12 | 13 | ## File-based project format: 14 | *.iws 15 | *.iml 16 | .idea/ 17 | 18 | # Binaries for programs and plugins 19 | *.exe 20 | *.exe~ 21 | *.dll 22 | *.so 23 | *.dylib 24 | *.dat 25 | *.DS_Store 26 | 27 | # Test binary, built with `go test -c` 28 | *.test 29 | 30 | # Output of the go coverage tool, specifically when used with LiteIDE 31 | *.out 32 | 33 | # Goreleaser builds 34 | **/dist/** 35 | 36 | # This is my wip ideas folder 37 | experiments/** 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.3.0] - 2020-11-09 8 | ### Added 9 | - 🎉 new flag `-images-type` to specify a default suffix for all the images 10 | - specifying this flag when including images, you can omit the extension 11 | - example: if flag has `-images-type png`, you can write `[[bulb]]` instead of `[[build.png]]` 12 | 13 | ### Changed 14 | - updated the README markdown file 15 | - improve argument parsing 16 | - when invoked without args, an attempt is made to read from standard input 17 | 18 | ### Fixed 19 | - 🐛 Line with no leading stars causes nil pointer dereference [#2](/issues/#2) 20 | 21 | ## [0.2.0] - 2020-09-09 22 | ### Added 23 | - 📝 more test cases 24 | - 🎉 new flag `images-path` to specify the base images folder 25 | - now when including images, you can specify just the filename 26 | - example: if flag has `-images-path '/Icons/AwesomFonts'`, you can write `[[bulb.png]]` instead of `[[/Icons/AwesomFonts/build.png]]` 27 | 28 | - 🎉 new flag `lim` to specify after how many characters to wrap the text 29 | 30 | ### Changed 31 | - updated the README markdown file 32 | 33 | ### Fixed 34 | - 🐛 removed `go.sum` from the .gitignore file 35 | 36 | ## [0.1.0] - 2020-09-08 37 | - 🎉 First release! 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Luca Sepe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Crumbs 3 | 4 | > Turn asterisk-indented text lines into mind maps. 5 | 6 | Organize your notes in a hierarchical tree structure, using a simple text editor. 7 | 8 | - an asterisk at the beginning of the line means _level 1_ 9 | - two asterisk at the beginning of the line means _level 2_ 10 | - and so on... 11 | 12 | # How [crumbs](https://github.com/lucasepe/crumbs/releases/latest) works? 13 | 14 | - takes in input a text file and generates a [dot script](https://en.wikipedia.org/wiki/DOT_(graph_description_language)) for [Graphviz](https://graphviz.gitlab.io/download/). 15 | 16 | - depends on [GraphViz](https://graphviz.gitlab.io/download/) 17 | - look at the bottom for info about [how to install graphviz](#how-to-install-graphViz). 18 | 19 | 20 | ## Example (without icons) 21 | 22 | Create a simple text file - let's say `meeting-ideas.txt`: 23 | 24 | ```text 25 | * main idea 26 | ** topic 1 27 | *** sub topic 28 | *** sub topic 29 | **** sub topic 30 | **** sub topic 31 | ** topic 2 32 | *** sub topic 33 | ``` 34 | 35 | To create the PNG image you can 36 | 37 | - _"inject"_ the text file to [crumbs](https://github.com/lucasepe/crumbs/releases/latest) and then the result to [dot](https://graphviz.org/doc/info/command.html) redirecting the output to the file `meeting-ideas.png` - (I love the Linux command pipelines! 😍) 38 | 39 | ```bash 40 | cat meeting-ideas.txt | crumbs | dot -Tpng > meeting-ideas.png 41 | ``` 42 | 43 | - or as alternative you can specify your text file to [crumbs](https://github.com/lucasepe/crumbs/releases/latest) directly: 44 | 45 | ```bash 46 | crumbs meeting-ideas.txt | dot -Tpng > meeting-ideas.png 47 | ``` 48 | 49 | Here the output: 50 | 51 | ![](./testdata/sample4.png) 52 | 53 | --- 54 | 55 | ## Example (with icons) 56 | 57 | You can, eventually, add images too (one for text line) using a special syntax: `[[path/to/image.png]]` 58 | 59 | - if you specify the flag `-image-path` you can write `[[image.png]]` instead of `[[path/to/image.png]]` 60 | - if you specify the flag `-image-type` you can write `[[path/to/image]]` instead of `[[path/to/image.png]]` 61 | - therefore if you specify both you can write `[[image]]` instead of `[[path/to/image.png]]` 62 | 63 | ```text 64 | * [[./png/bulb.png]] main idea 65 | ** topic 1 66 | *** sub topic 67 | *** sub topic 68 | **** [[./png/comments-alt.png]] sub topic 69 | **** sub topic 70 | ** [[./png/map-signs.png]] topic 2 71 | *** sub topic 72 | ``` 73 | 74 | then as usual, let's feed graphviz with [crumbs](https://github.com/lucasepe/crumbs/releases/latest): 75 | 76 | ```bash 77 | crumbs meeting-ideas-with-icons.txt | dot -Tpng > meeting-ideas-with-icons.png 78 | ``` 79 | 80 | and the output is... 81 | 82 | ![](./testdata/sample5.png) 83 | 84 | ## Example (with HTML) 85 | 86 | You can enrich the output with a little bit of style, adding some HTML tag. 87 | 88 | The following tags are understood: 89 | 90 | ```html 91 | ,
, , , , , , 92 | ``` 93 | 94 | ```text 95 | * main idea 96 | ** topic 1 97 | *** sub topic 98 | *** sub topic 99 | **** sub topic 100 | **** sub topic 101 | ** topic 2 102 | *** sub topic 103 | ``` 104 | 105 | then as usual, let's feed graphviz with [crumbs](https://github.com/lucasepe/crumbs/releases/latest): 106 | 107 | ```bash 108 | crumbs meeting-ideas-with-html.txt | dot -Tpng > meeting-ideas-with-html.png 109 | ``` 110 | 111 | and the output is... 112 | 113 | ![](./testdata/sample6.png) 114 | 115 | --- 116 | 117 | # Installation Steps 118 | 119 | In order to use the crumbs command, compile it using the following command: 120 | 121 | ```bash 122 | go get -u github.com/lucasepe/crumbs/crumbs 123 | ``` 124 | 125 | This will create the crumbs executable under your $GOPATH/bin directory. 126 | 127 | 128 | ## Ready-To-Use Releases 129 | 130 | If you don't want to compile the sourcecode yourself, [Here you can find the tool already compiled](https://github.com/lucasepe/crumbs/releases/latest) for: 131 | 132 | - MacOS 133 | - Linux 134 | - Windows 135 | 136 | --- 137 | 138 | # CHANGE LOG 139 | 140 | 👉 [Record of all notable changes made to a project](./CHANGELOG.md) -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasepe/crumbs/1363cb8de35ccb67b9d2b0c31eae44ec7c5ed7cb/banner.png -------------------------------------------------------------------------------- /crumbs/.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | # Run locally with: goreleaser --rm-dist --snapshot --skip-publish 4 | project_name: crumbs 5 | before: 6 | hooks: 7 | - go mod tidy 8 | - go mod download 9 | builds: 10 | - binary: '{{ .ProjectName }}' 11 | main: ./main.go 12 | env: 13 | - CGO_ENABLED=0 14 | ldflags: 15 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} 16 | - -a -extldflags "-static" 17 | goos: 18 | - windows 19 | - linux 20 | - darwin 21 | goarch: 22 | - amd64 23 | archives: 24 | - replacements: 25 | darwin: macOS 26 | windows: win 27 | amd64: 64-bit 28 | checksum: 29 | name_template: 'checksums.txt' 30 | snapshot: 31 | name_template: "{{ .ProjectName }}_{{ .Tag }}" 32 | nfpms: 33 | - 34 | package_name: tiles 35 | vendor: Luca Sepe 36 | homepage: https://lucasepe.it/ 37 | maintainer: Luca Sepe 38 | description: Turn asterisk-indented text lines into mind maps. 39 | license: MIT 40 | replacements: 41 | amd64: 64-bit 42 | formats: 43 | - deb 44 | - rpm 45 | changelog: 46 | sort: asc 47 | filters: 48 | exclude: 49 | - '^docs:' 50 | - '^test:' 51 | -------------------------------------------------------------------------------- /crumbs/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lucasepe/crumbs/crumbs 2 | 3 | go 1.14 4 | 5 | require github.com/lucasepe/crumbs v0.3.0 6 | 7 | replace github.com/lucasepe/crumbs => ../ 8 | -------------------------------------------------------------------------------- /crumbs/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/emicklei/dot v0.14.0 h1:DJbbkKThQ0nW361NB79CqrWcKpYR1JoqJB3FcTUgBEU= 4 | github.com/emicklei/dot v0.14.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= 5 | github.com/lucasepe/crumbs/crumbs v0.0.0-20200911185848-e9e9a4af517c/go.mod h1:6iLM2VGL2y4HotHrS+rX5PNEHEcjCDisHn2ifWnWXi0= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 9 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 10 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 11 | github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA= 12 | github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 15 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 16 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /crumbs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/lucasepe/crumbs" 13 | "github.com/lucasepe/crumbs/gv" 14 | ) 15 | 16 | const ( 17 | maxFileSize int64 = 512 * 1000 // 512 Kb 18 | banner = ` 19 | __ ____ __ __ ___ ___ ____ _____ 20 | / ]| \| | || | || \ / ___/ v{{VERSION}} 21 | / / | D ) | || _ _ || o )( \_ 22 | / / | /| | || \_/ || | \__ | 23 | / \_ | \| : || | || O | / \ | 24 | \ || . \ || | || | \ | 25 | \____||__|\_|\__,_||___|___||_____| \___| 26 | Crafted with passion by Luca Sepe - https://github.com/lucasepe/crumbs` 27 | ) 28 | 29 | var ( 30 | version = "0.3.0" 31 | 32 | flagVertical bool 33 | flagWrapLim uint 34 | flagImagesPath string 35 | flagImagesType string 36 | ) 37 | 38 | func main() { 39 | configureFlags() 40 | 41 | entry, err := readEntry() 42 | if err != nil { 43 | fmt.Fprintf(os.Stderr, "error: %s\n", err.Error()) 44 | os.Exit(1) 45 | } 46 | 47 | cfg := gv.RenderConfig{ 48 | WrapTextLimit: flagWrapLim, 49 | VerticalLayout: flagVertical, 50 | } 51 | if err = gv.Render(os.Stdout, entry, cfg); err != nil { 52 | fmt.Fprintf(os.Stderr, "error: %s\n", err.Error()) 53 | os.Exit(1) 54 | } 55 | } 56 | 57 | func readInput() ([]byte, error) { 58 | limit := maxFileSize 59 | args := flag.Args() 60 | if len(args) == 0 { 61 | return readFileObject(os.Stdin, limit) 62 | } 63 | return readFile(args[0], limit) 64 | } 65 | 66 | func readEntry() (*crumbs.Entry, error) { 67 | src, err := readInput() 68 | if err != nil { 69 | return nil, err 70 | } 71 | text := string(src) 72 | lines := strings.SplitAfter(text, "\n") 73 | return crumbs.ParseLines(lines, flagImagesPath, flagImagesType) 74 | } 75 | 76 | func readFileObject(r io.Reader, limit int64) ([]byte, error) { 77 | lr := io.LimitReader(r, limit) 78 | return ioutil.ReadAll(lr) 79 | } 80 | 81 | func readFile(name string, limit int64) ([]byte, error) { 82 | r, err := os.Open(name) 83 | if err != nil { 84 | return nil, err 85 | } 86 | defer r.Close() 87 | 88 | return readFileObject(r, limit) 89 | } 90 | 91 | func configureFlags() { 92 | name := appName() 93 | 94 | flag.CommandLine.Usage = func() { 95 | printBanner() 96 | fmt.Printf("Turn asterisk-indented text lines into mind maps.\n\n") 97 | 98 | fmt.Print("USAGE:\n\n") 99 | fmt.Printf(" %s [flags] \n\n", name) 100 | 101 | fmt.Print("EXAMPLE(s):\n\n") 102 | fmt.Printf(" %s agenda.txt | dot -Tpng > output.png\n", name) 103 | fmt.Printf(" cat agenda.txt | %s | dot -Tpng > output.png\n\n", name) 104 | 105 | fmt.Print("FLAGS:\n\n") 106 | flag.CommandLine.SetOutput(os.Stdout) 107 | flag.CommandLine.PrintDefaults() 108 | flag.CommandLine.SetOutput(ioutil.Discard) // hide flag errors 109 | fmt.Print(" -help\n\tprints this message\n") 110 | fmt.Println() 111 | } 112 | 113 | flag.CommandLine.SetOutput(ioutil.Discard) // hide flag errors 114 | flag.CommandLine.Init(os.Args[0], flag.ExitOnError) 115 | 116 | flag.CommandLine.BoolVar(&flagVertical, "vertical", false, 117 | "layout entries as vertical directed graph") 118 | flag.CommandLine.UintVar(&flagWrapLim, "lim", 28, "wraps each line within this width in characters") 119 | 120 | flag.CommandLine.StringVar(&flagImagesPath, "images-path", "", "folder in which to look for image files") 121 | flag.CommandLine.StringVar(&flagImagesType, "images-type", "", "images file extension [png,jpg,svg]") 122 | 123 | flag.CommandLine.Parse(os.Args[1:]) 124 | } 125 | 126 | func printBanner() { 127 | str := strings.Replace(banner, "{{VERSION}}", version, 1) 128 | fmt.Print(str, "\n\n") 129 | } 130 | 131 | func appName() string { 132 | return filepath.Base(os.Args[0]) 133 | } 134 | 135 | // exitOnErr check for an error and eventually exit 136 | func exitOnErr(err error) { 137 | if err != nil { 138 | fmt.Fprintf(os.Stderr, "error: %s\n", err.Error()) 139 | os.Exit(1) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lucasepe/crumbs 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/emicklei/dot v0.14.0 7 | github.com/stretchr/testify v1.6.1 8 | github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/emicklei/dot v0.14.0 h1:DJbbkKThQ0nW361NB79CqrWcKpYR1JoqJB3FcTUgBEU= 4 | github.com/emicklei/dot v0.14.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 8 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 9 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 10 | github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA= 11 | github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 14 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 15 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 16 | -------------------------------------------------------------------------------- /gv/graph.go: -------------------------------------------------------------------------------- 1 | package gv 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/emicklei/dot" 9 | "github.com/lucasepe/crumbs/text" 10 | ) 11 | 12 | // GraphOption is a graph attribute. 13 | type GraphOption func(*dot.Graph) 14 | 15 | // ImagesPath specifies a list of directories in which 16 | // to look for image files as specified by the 17 | // image attribute or using the IMG element in HTML-like labels. 18 | // The string should be a list of (absolute or relative) 19 | // pathnames, each separated by a semicolon (for Windows) 20 | // or a colon (all other OS). 21 | func ImagesPath(paths string) GraphOption { 22 | return func(gr *dot.Graph) { 23 | gr.Attr("imagepath", paths) 24 | } 25 | } 26 | 27 | // Vertical enables/diables the top to bottom layout direction. 28 | func Vertical(set bool) GraphOption { 29 | return func(gr *dot.Graph) { 30 | if set { 31 | gr.Attr("rankdir", "TB") 32 | } else { 33 | gr.Attr("rankdir", "LR") 34 | } 35 | } 36 | } 37 | 38 | // newGraph returns a new GraphViz DOT language graph 39 | func newGraph(opts ...GraphOption) *dot.Graph { 40 | res := dot.NewGraph(dot.Undirected) 41 | 42 | res.Attr("rankdir", "LR") 43 | res.Attr("pad", "1") 44 | res.Attr("ranksep", "2.3") 45 | res.Attr("nodesep", "0.8") 46 | res.Attr("fontname", "Fira Code") 47 | res.Attr("fontsize", "14") 48 | res.Attr("splines", "curved") 49 | res.Attr("concentrate", "true") 50 | res.Attr("orientation", "portrait") 51 | 52 | for _, opt := range opts { 53 | opt(res) 54 | } 55 | 56 | return res 57 | } 58 | 59 | // createEdge creates a new connection line between two nodes 60 | func createEdge(gr *dot.Graph, fid, tid string, color string) error { 61 | a, ok := gr.FindNodeById(fid) 62 | if !ok { 63 | return fmt.Errorf("node with id=%s not found", fid) 64 | } 65 | 66 | b, ok := gr.FindNodeById(tid) 67 | if !ok { 68 | return fmt.Errorf("node with id=%s not found", tid) 69 | } 70 | 71 | res := gr.Edge(a, b) 72 | 73 | res.Attr("fontname", "Fira Code") 74 | res.Attr("fontsize", "10") 75 | res.Attr("penwidth", "2.5") 76 | //res.Attr("xlabels", strconv.Itoa(lvl)) 77 | 78 | if strings.TrimSpace(color) != "" { 79 | res.Attr("color", color) 80 | } else { 81 | res.Attr("color", "#ced4da") 82 | } 83 | 84 | return nil 85 | } 86 | 87 | // createNode create and adds a new node to the graph. 88 | // You can customize some attributes using 89 | // the variadic node attributes. 90 | func createNode(g *dot.Graph, id string, opts ...nodeAttribute) string { 91 | n := g.Node(id) 92 | n.Label("") 93 | n.Attr("fontname", "Fira Code") 94 | n.Attr("fontsize", "12") 95 | n.Attr("width", "2") 96 | n.Attr("margin", "0.2,0.2") 97 | n.Attr("shape", "plain") 98 | 99 | for _, opt := range opts { 100 | opt(&n) 101 | } 102 | 103 | return id 104 | } 105 | 106 | // nodeAttribute defines a function that 107 | // apply a property to a node 108 | type nodeAttribute func(*dot.Node) 109 | 110 | // nodeFillColor sets the node fill color 111 | func nodeFillColor(hex string) nodeAttribute { 112 | return func(el *dot.Node) { 113 | if strings.TrimSpace(hex) == "" { 114 | return 115 | } 116 | 117 | el.Attr("fillcolor", hex) 118 | 119 | const attr = "filled" 120 | 121 | val := el.AttributesMap.Value("style") 122 | if val == nil { 123 | el.Attr("style", attr) 124 | return 125 | } 126 | 127 | style, ok := val.(string) 128 | if !ok { 129 | el.Attr("style", attr) 130 | return 131 | } 132 | 133 | if _, found := text.Find(strings.Split(style, ","), attr); !found { 134 | el.Attr("style", strings.Join([]string{style, attr}, ",")) 135 | } 136 | } 137 | } 138 | 139 | // nodeFontSize specify the font size, in points, used for text 140 | func nodeFontSize(s int) nodeAttribute { 141 | return func(el *dot.Node) { 142 | el.Attr("fontsize", strconv.Itoa(s)) 143 | } 144 | } 145 | 146 | // nodeLabel is the node caption 147 | // if 'htm' is true the caption is treated as HTML code 148 | func nodeLabel(label string, htm bool) nodeAttribute { 149 | return func(el *dot.Node) { 150 | if htm { 151 | el.Attr("label", dot.HTML(label)) 152 | } else { 153 | el.Attr("label", label) 154 | } 155 | } 156 | } 157 | 158 | // nodeShape sets the shape of a node 159 | func nodeShape(shape string) nodeAttribute { 160 | return func(el *dot.Node) { 161 | el.Attr("shape", shape) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /gv/graph_test.go: -------------------------------------------------------------------------------- 1 | package gv 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/emicklei/dot" 9 | ) 10 | 11 | func TestNewNodeOptions(t *testing.T) { 12 | tests := []struct { 13 | opts []nodeAttribute 14 | want string 15 | }{ 16 | { 17 | []nodeAttribute{}, 18 | `digraph {n1[fontname="Fira Code",fontsize="12",label="",margin="0.2,0.2",width="2"];}`, 19 | }, 20 | 21 | { 22 | []nodeAttribute{nodeFillColor("#ff0000")}, 23 | `digraph {n1[fillcolor="#ff0000",fontname="Fira Code",fontsize="12",label="",margin="0.2,0.2",style="filled",width="2"];}`, 24 | }, 25 | 26 | { 27 | []nodeAttribute{nodeShape("box")}, 28 | `digraph {n1[fontname="Fira Code",fontsize="12",label="",margin="0.2,0.2",shape="box",width="2"];}`, 29 | }, 30 | 31 | { 32 | []nodeAttribute{nodeShape("hexagon"), nodeFillColor("#00ff00")}, 33 | `digraph {n1[fillcolor="#00ff00",fontname="Fira Code",fontsize="12",label="",margin="0.2,0.2",shape="hexagon",style="filled",width="2"];}`, 34 | }, 35 | } 36 | 37 | for i, tt := range tests { 38 | t.Run(fmt.Sprintf("test_%d", i), func(t *testing.T) { 39 | gv := dot.NewGraph() 40 | createNode(gv, "ID", tt.opts...) 41 | if got := flatten(gv.String()); got != tt.want { 42 | t.Errorf("got [%v] want [%v]", got, tt.want) 43 | } 44 | }) 45 | } 46 | } 47 | 48 | func TestNodeHTMLabel(t *testing.T) { 49 | tests := []struct { 50 | html string 51 | want string 52 | }{ 53 | { 54 | `Bold`, 55 | `digraph {n1[fontname="Fira Code",fontsize="12",label=<Bold>,margin="0.2,0.2",width="2"];}`, 56 | }, 57 | { 58 | `
col 1
`, 59 | `digraph {n1[fontname="Fira Code",fontsize="12",label=<
col 1
>,margin="0.2,0.2",width="2"];}`, 60 | }, 61 | } 62 | 63 | for i, tt := range tests { 64 | t.Run(fmt.Sprintf("test_%d", i), func(t *testing.T) { 65 | gv := dot.NewGraph() 66 | createNode(gv, "ID", nodeLabel(tt.html, true)) 67 | if got := flatten(gv.String()); got != tt.want { 68 | t.Errorf("got [%v] want [%v]", got, tt.want) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | // remove tabs and newlines and spaces 75 | func flatten(s string) string { 76 | return strings.Replace((strings.Replace(s, "\n", "", -1)), "\t", "", -1) 77 | } 78 | -------------------------------------------------------------------------------- /gv/render.go: -------------------------------------------------------------------------------- 1 | package gv 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/emicklei/dot" 9 | "github.com/lucasepe/crumbs" 10 | "github.com/lucasepe/crumbs/text" 11 | ) 12 | 13 | // RenderConfig defines some render parameters. 14 | type RenderConfig struct { 15 | VerticalLayout bool 16 | WrapTextLimit uint 17 | } 18 | 19 | // Render translates the mind note tree to a 20 | // graphviz dot language definition. 21 | func Render(wr io.Writer, note *crumbs.Entry, cfg RenderConfig) error { 22 | htmlize := htmlLabelMaker(cfg.WrapTextLimit) 23 | 24 | gr := newGraph(Vertical(cfg.VerticalLayout)) 25 | 26 | renderTree(gr, note.Root(), htmlize) 27 | 28 | _, err := io.WriteString(wr, gr.String()) 29 | return err 30 | } 31 | 32 | // render a tree node (the node, and its children) 33 | func renderTree(gr *dot.Graph, el *crumbs.Entry, htmlize func(*crumbs.Entry) string) { 34 | tintFor := colorSupplier() 35 | 36 | if el.Level() > 0 { 37 | createNode(gr, el.ID(), nodeLabel(htmlize(el), true)) 38 | } 39 | 40 | if el.Parent() != nil { 41 | createEdge(gr, el.Parent().ID(), el.ID(), tintFor(el.Level())) 42 | } 43 | 44 | for _, child := range el.Childrens() { 45 | renderTree(gr, child, htmlize) 46 | } 47 | } 48 | 49 | func colorSupplier() func(lvl int) string { 50 | palette := map[int]string{ 51 | 0: "#264653", 52 | 1: "#2A9D8F", 53 | 2: "#E9C46A", 54 | 3: "#E76F51", 55 | 4: "#FFCDB2", 56 | 5: "#B5838D", 57 | 6: "#6D6875", 58 | } 59 | 60 | return func(lvl int) string { 61 | if val, ok := palette[lvl]; ok { 62 | return val 63 | } 64 | return "#000000" 65 | } 66 | } 67 | 68 | func htmlLabelMaker(lim uint) func(*crumbs.Entry) string { 69 | escaper := strings.NewReplacer( 70 | `&`, "&", 71 | `'`, "'", 72 | `"`, """, 73 | ) 74 | 75 | return func(note *crumbs.Entry) string { 76 | label := strings.TrimSpace(note.Text()) 77 | if lim > 0 { 78 | label = text.WrapString(label, lim) 79 | } 80 | label = escaper.Replace(label) 81 | label = strings.ReplaceAll(label, "\n", "
") 82 | 83 | var sb strings.Builder 84 | sb.WriteString(``) 85 | 86 | if len(note.Icon()) > 0 { 87 | sb.WriteString("") 88 | fmt.Fprintf(&sb, ``, note.Icon()) 89 | sb.WriteString("") 90 | } 91 | 92 | switch { 93 | case note.Level() == 1: 94 | fmt.Fprintf(&sb, ``, label) 95 | case note.Level() > 1: 96 | fmt.Fprintf(&sb, ``, label) 97 | } 98 | 99 | sb.WriteString("
%s
%s
") 100 | 101 | return sb.String() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /note.go: -------------------------------------------------------------------------------- 1 | package crumbs 2 | 3 | // Entry is a thought, an idea. 4 | type Entry struct { 5 | id string 6 | level int 7 | text string 8 | icon string 9 | parent *Entry 10 | childrens []*Entry 11 | } 12 | 13 | // ID returns the node identifier. 14 | func (ti *Entry) ID() string { 15 | return ti.id 16 | } 17 | 18 | // Childrens returns the node childs. 19 | func (ti *Entry) Childrens() []*Entry { 20 | return ti.childrens 21 | } 22 | 23 | // Text returns the node data. 24 | func (ti *Entry) Text() string { 25 | return ti.text 26 | } 27 | 28 | // Icon returns the icon path. 29 | func (ti *Entry) Icon() string { 30 | return ti.icon 31 | } 32 | 33 | // Level returns the node depth. 34 | func (ti *Entry) Level() int { 35 | return ti.level 36 | } 37 | 38 | // Parent returns the node parent 39 | func (ti *Entry) Parent() *Entry { 40 | return ti.parent 41 | } 42 | 43 | // Root returns the root note 44 | func (ti *Entry) Root() *Entry { 45 | if ti.parent == nil { 46 | return ti 47 | } 48 | return ti.parent.Root() 49 | } 50 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package crumbs 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/teris-io/shortid" 10 | ) 11 | 12 | // ParseLines parses a slice of text lines and builds the tree. 13 | func ParseLines(lines []string, imagesPath, imagesSuffix string) (*Entry, error) { 14 | mkID := idGenerator() 15 | checkIcon := lookForIcon(imagesPath, imagesSuffix) 16 | 17 | // generate a short id for the root node 18 | rootID, err := mkID() 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | // create the root node 24 | root := newEmptyNote(rootID) 25 | 26 | node := root 27 | nodeDepth := 0 28 | for _, el := range lines { 29 | // skip empty lines 30 | if strings.TrimSpace(el) == "" { 31 | continue 32 | } 33 | 34 | // count depth 35 | childDepth := depth(el) 36 | 37 | // case: no leading 'stars' (skip line) 38 | if childDepth == 0 { 39 | continue 40 | } 41 | 42 | // trim leading 'stars', then the spaces 43 | text := el[childDepth:] 44 | text = strings.TrimSpace(text) 45 | 46 | // create the child 47 | childID, err := mkID() 48 | if err != nil { 49 | return nil, err 50 | } 51 | child := newNote(childID, childDepth, text) 52 | // check if has an icon 53 | checkIcon(child) 54 | 55 | // case: the current 'node' is the parent 56 | if childDepth > nodeDepth { 57 | // update tree 58 | child.parent = node 59 | node.childrens = append(node.childrens, child) 60 | 61 | // update loop state 62 | node = child 63 | nodeDepth++ 64 | 65 | // case: the current 'node' is not the parent of our child 66 | } else if childDepth <= nodeDepth { 67 | // adjust 'node' until it's correct 68 | for childDepth <= nodeDepth { 69 | node = node.parent 70 | nodeDepth-- 71 | } 72 | 73 | // update tree 74 | child.parent = node 75 | node.childrens = append(node.childrens, child) 76 | 77 | // update loop state 78 | node = child 79 | nodeDepth++ 80 | } 81 | } 82 | 83 | return root, nil 84 | } 85 | 86 | // depth space-counting helper (probably done in a dumb way, dunno) 87 | func depth(line string) int { 88 | i := 0 89 | for line[i] == '*' { 90 | i++ 91 | } 92 | 93 | return i 94 | } 95 | 96 | // newNote creates a new note element 97 | func newNote(id string, lvl int, txt string) *Entry { 98 | f := new(Entry) 99 | f.id = id 100 | f.text = txt 101 | f.level = lvl 102 | return f 103 | } 104 | 105 | // newNote creates a new note element 106 | func newEmptyNote(id string) *Entry { 107 | f := new(Entry) 108 | f.id = id 109 | f.level = -1 110 | return f 111 | } 112 | 113 | // idGenerator generates a new short id at each invocation. 114 | func idGenerator() func() (string, error) { 115 | sid, err := shortid.New(1, shortid.DefaultABC, 2342) 116 | if err != nil { 117 | return func() (string, error) { 118 | return "", err 119 | } 120 | } 121 | 122 | return func() (string, error) { 123 | return sid.Generate() 124 | } 125 | } 126 | 127 | func lookForIcon(imagesPath, imagesSuffix string) func(note *Entry) { 128 | re := regexp.MustCompile(`^\[{2}(.*?)\]{2}`) 129 | 130 | return func(note *Entry) { 131 | str := note.text 132 | res := re.FindStringSubmatch(str) 133 | if len(res) > 0 { 134 | note.icon = filepath.Join(imagesPath, strings.TrimSpace(res[1])) 135 | if len(imagesSuffix) > 0 { 136 | note.icon = fmt.Sprintf("%s.%s", note.icon, imagesSuffix) 137 | } 138 | note.text = re.ReplaceAllString(str, "") 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package crumbs 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestParseLines(t *testing.T) { 11 | test := ` 12 | * main idea 13 | ** topic 1 14 | *** sub topic 1 1 15 | *** sub topic 1 2 16 | **** sub sub topic 17 | ** topic 2 18 | *** sub topic 2 1 19 | ` 20 | got, err := ParseLines(strings.SplitAfter(test, "\n"), "") 21 | if err != nil { 22 | t.Error(err) 23 | } 24 | 25 | assert.Equal(t, 1, len(got.childrens)) 26 | assert.Equal(t, got.childrens[0].text, "main idea") 27 | 28 | assert.Equal(t, 2, len(got.childrens[0].childrens)) 29 | assert.Equal(t, got.childrens[0].childrens[0].text, "topic 1") 30 | assert.Equal(t, got.childrens[0].childrens[1].text, "topic 2") 31 | 32 | assert.Equal(t, 2, len(got.childrens[0].childrens[0].childrens)) 33 | assert.Equal(t, got.childrens[0].childrens[0].childrens[1].text, "sub topic 1 2") 34 | } 35 | 36 | func TestLookForIcon(t *testing.T) { 37 | 38 | tests := []struct { 39 | imagespath string 40 | entry Entry 41 | want string 42 | }{ 43 | { 44 | "./images/png", 45 | Entry{text: "[[blob.png]] La vispa Teresa avea tra l'erbetta, a volo sorpresa"}, 46 | "images/png/blob.png", 47 | }, 48 | { 49 | "/home/lus/Pictures/fontawesome/PNG", 50 | Entry{text: "[[blob.png]] La vispa Teresa avea tra l'erbetta, a volo sorpresa"}, 51 | "/home/lus/Pictures/fontawesome/PNG/blob.png", 52 | }, 53 | } 54 | 55 | for _, tt := range tests { 56 | fn := lookForIcon(tt.imagespath) 57 | fn(&tt.entry) 58 | 59 | t.Run(tt.imagespath, func(t *testing.T) { 60 | if got := tt.entry.icon; got != tt.want { 61 | t.Errorf("got [%v] want [%v]", got, tt.want) 62 | } 63 | }) 64 | } 65 | 66 | } 67 | 68 | func TestDepth(t *testing.T) { 69 | tests := []struct { 70 | line string 71 | want int 72 | }{ 73 | {"* main idea LV.1", 1}, 74 | {"** topic 1 LV.2", 2}, 75 | {"*** sub topic LV.3", 3}, 76 | {"******* LV.7", 7}, 77 | } 78 | 79 | for _, tt := range tests { 80 | 81 | t.Run(tt.line, func(t *testing.T) { 82 | assert.Equal(t, depth(tt.line), tt.want) 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /testdata/png/bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasepe/crumbs/1363cb8de35ccb67b9d2b0c31eae44ec7c5ed7cb/testdata/png/bulb.png -------------------------------------------------------------------------------- /testdata/png/comments-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasepe/crumbs/1363cb8de35ccb67b9d2b0c31eae44ec7c5ed7cb/testdata/png/comments-alt.png -------------------------------------------------------------------------------- /testdata/png/map-signs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasepe/crumbs/1363cb8de35ccb67b9d2b0c31eae44ec7c5ed7cb/testdata/png/map-signs.png -------------------------------------------------------------------------------- /testdata/sample1.txt: -------------------------------------------------------------------------------- 1 | parent1 2 | child1 3 | child2 4 | child3 5 | child3.1 6 | child3.2 7 | child4 8 | 9 | parent2 10 | child1 11 | 12 | parent3 13 | child1 14 | child2 15 | child3 16 | child3.1 17 | child3.2 18 | child4 19 | 20 | parent4 21 | child1 22 | 23 | parent5 24 | child1 25 | child2 26 | child3 27 | child3.1 28 | child3.2 29 | pippo 30 | pluto 31 | paperino 32 | child4 33 | 34 | parent6 35 | child1 -------------------------------------------------------------------------------- /testdata/sample4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasepe/crumbs/1363cb8de35ccb67b9d2b0c31eae44ec7c5ed7cb/testdata/sample4.png -------------------------------------------------------------------------------- /testdata/sample4.txt: -------------------------------------------------------------------------------- 1 | * main idea 2 | ** topic 1 3 | *** sub topic 4 | *** sub topic 5 | **** sub topic 6 | **** sub topic 7 | ** topic 2 8 | *** sub topic 9 | -------------------------------------------------------------------------------- /testdata/sample5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasepe/crumbs/1363cb8de35ccb67b9d2b0c31eae44ec7c5ed7cb/testdata/sample5.png -------------------------------------------------------------------------------- /testdata/sample5.txt: -------------------------------------------------------------------------------- 1 | * [[./png/bulb.png]] main idea 2 | ** topic 1 3 | *** sub topic 4 | *** sub topic 5 | **** [[./png/comments-alt.png]] sub topic 6 | **** sub topic 7 | ** [[./png/map-signs.png]] topic 2 8 | *** sub topic 9 | -------------------------------------------------------------------------------- /testdata/sample6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasepe/crumbs/1363cb8de35ccb67b9d2b0c31eae44ec7c5ed7cb/testdata/sample6.png -------------------------------------------------------------------------------- /testdata/sample6.txt: -------------------------------------------------------------------------------- 1 | * main idea 2 | ** topic 1 3 | *** sub topic 4 | *** sub topic 5 | **** sub topic 6 | **** sub topic 7 | ** topic 2 8 | *** sub topic 9 | -------------------------------------------------------------------------------- /testdata/when-to-use-crumbs-icons-no-path-ext.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasepe/crumbs/1363cb8de35ccb67b9d2b0c31eae44ec7c5ed7cb/testdata/when-to-use-crumbs-icons-no-path-ext.png -------------------------------------------------------------------------------- /testdata/when-to-use-crumbs-icons-no-path-ext.txt: -------------------------------------------------------------------------------- 1 | * When to use "crumbs" 2 | 3 | ** [[bulb]] to generate ideas 4 | *** to search for patterns 5 | *** to speculate and explore ideas 6 | *** to merge ideas 7 | 8 | ** [[comments-alt]] to communicate 9 | *** to demostrate a structure 10 | *** to show your thoughts to others 11 | *** to record a meeting 12 | 13 | ** [[book-reader]] to learn 14 | *** to record a lecture 15 | *** to prepare a paper 16 | *** to study for exams and certifications 17 | 18 | ** [[map-signs]] to make decisions 19 | *** to analyze problems 20 | *** to get team's input 21 | *** to record multiple ideas -------------------------------------------------------------------------------- /testdata/when-to-use-crumbs-icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasepe/crumbs/1363cb8de35ccb67b9d2b0c31eae44ec7c5ed7cb/testdata/when-to-use-crumbs-icons.png -------------------------------------------------------------------------------- /testdata/when-to-use-crumbs-icons.txt: -------------------------------------------------------------------------------- 1 | * When to use "crumbs" 2 | 3 | ** [[./png/bulb.png]] to generate ideas 4 | *** to search for patterns 5 | *** to speculate and explore ideas 6 | *** to merge ideas 7 | 8 | ** [[./png/comments-alt.png]] to communicate 9 | *** to demostrate a structure 10 | *** to show your thoughts to others 11 | *** to record a meeting 12 | 13 | ** [[./png/book-reader.png]] to learn 14 | *** to record a lecture 15 | *** to prepare a paper 16 | *** to study for exams and certifications 17 | 18 | ** [[./png/map-signs.png]] to make decisions 19 | *** to analyze problems 20 | *** to get team's input 21 | *** to record multiple ideas -------------------------------------------------------------------------------- /testdata/when-to-use-crumbs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasepe/crumbs/1363cb8de35ccb67b9d2b0c31eae44ec7c5ed7cb/testdata/when-to-use-crumbs.png -------------------------------------------------------------------------------- /testdata/when-to-use-crumbs.txt: -------------------------------------------------------------------------------- 1 | * When to use "crumbs" 2 | 3 | ** to generate ideas 4 | *** to search for patterns 5 | *** to speculate and explore ideas 6 | *** to merge ideas 7 | 8 | ** to communicate 9 | *** to demostrate a structure 10 | *** to show your thoughts to others 11 | *** to record a meeting 12 | 13 | ** to learn 14 | *** to record a lecture 15 | *** to prepare a paper 16 | *** to study for exams and certifications 17 | 18 | ** to make decisions 19 | *** to analyze problems 20 | *** to get team's input 21 | *** to record multiple ideas -------------------------------------------------------------------------------- /text/find.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | // Find takes a slice and looks for an element in it. 4 | // If found it will return it's key, otherwise it 5 | // will return -1 and a bool of false. 6 | func Find(slice []string, val string) (int, bool) { 7 | for i, item := range slice { 8 | if item == val { 9 | return i, true 10 | } 11 | } 12 | return -1, false 13 | } 14 | -------------------------------------------------------------------------------- /text/find_test.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import "testing" 4 | 5 | func TestFind(t *testing.T) { 6 | tests := []struct { 7 | set []string 8 | el string 9 | want bool 10 | }{ 11 | { 12 | []string{"filled", "rounded", "striped"}, 13 | "filled", 14 | true, 15 | }, 16 | { 17 | []string{"filled", "rounded", "striped"}, 18 | "slashed", 19 | false, 20 | }, 21 | { 22 | []string{"mela", "banana", "caffè"}, 23 | "caffè", 24 | true, 25 | }, 26 | } 27 | 28 | for _, tt := range tests { 29 | t.Run(tt.el, func(t *testing.T) { 30 | if _, got := Find(tt.set, tt.el); got != tt.want { 31 | t.Errorf("got [%v] want [%v]", got, tt.want) 32 | } 33 | }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /text/wrap.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "bytes" 5 | "unicode" 6 | ) 7 | 8 | // WrapString wraps the given string within lim width in characters. 9 | // 10 | // Wrapping is currently naive and only happens at white-space. A future 11 | // version of the library will implement smarter wrapping. This means that 12 | // pathological cases can dramatically reach past the limit, such as a very 13 | // long word. 14 | func WrapString(s string, lim uint) string { 15 | // Initialize a buffer with a slightly larger size to account for breaks 16 | init := make([]byte, 0, len(s)) 17 | buf := bytes.NewBuffer(init) 18 | 19 | var current uint 20 | var wordBuf, spaceBuf bytes.Buffer 21 | 22 | for _, char := range s { 23 | if char == '\n' { 24 | if wordBuf.Len() == 0 { 25 | if current+uint(spaceBuf.Len()) > lim { 26 | current = 0 27 | } else { 28 | current += uint(spaceBuf.Len()) 29 | spaceBuf.WriteTo(buf) 30 | } 31 | spaceBuf.Reset() 32 | } else { 33 | current += uint(spaceBuf.Len() + wordBuf.Len()) 34 | spaceBuf.WriteTo(buf) 35 | spaceBuf.Reset() 36 | wordBuf.WriteTo(buf) 37 | wordBuf.Reset() 38 | } 39 | buf.WriteRune(char) 40 | current = 0 41 | } else if unicode.IsSpace(char) { 42 | if spaceBuf.Len() == 0 || wordBuf.Len() > 0 { 43 | current += uint(spaceBuf.Len() + wordBuf.Len()) 44 | spaceBuf.WriteTo(buf) 45 | spaceBuf.Reset() 46 | wordBuf.WriteTo(buf) 47 | wordBuf.Reset() 48 | } 49 | 50 | spaceBuf.WriteRune(char) 51 | } else { 52 | 53 | wordBuf.WriteRune(char) 54 | 55 | if current+uint(spaceBuf.Len()+wordBuf.Len()) > lim && uint(wordBuf.Len()) < lim { 56 | buf.WriteRune('\n') 57 | current = 0 58 | spaceBuf.Reset() 59 | } 60 | } 61 | } 62 | 63 | if wordBuf.Len() == 0 { 64 | if current+uint(spaceBuf.Len()) <= lim { 65 | spaceBuf.WriteTo(buf) 66 | } 67 | } else { 68 | spaceBuf.WriteTo(buf) 69 | wordBuf.WriteTo(buf) 70 | } 71 | 72 | return buf.String() 73 | } 74 | -------------------------------------------------------------------------------- /text/wrap_test.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestWrapString(t *testing.T) { 8 | cases := []struct { 9 | Input, Output string 10 | Lim uint 11 | }{ 12 | // A simple word passes through. 13 | { 14 | "foo", 15 | "foo", 16 | 4, 17 | }, 18 | // A single word that is too long passes through. 19 | // We do not break words. 20 | { 21 | "foobarbaz", 22 | "foobarbaz", 23 | 4, 24 | }, 25 | // Lines are broken at whitespace. 26 | { 27 | "foo bar baz", 28 | "foo\nbar\nbaz", 29 | 4, 30 | }, 31 | // Lines are broken at whitespace, even if words 32 | // are too long. We do not break words. 33 | { 34 | "foo bars bazzes", 35 | "foo\nbars\nbazzes", 36 | 4, 37 | }, 38 | // A word that would run beyond the width is wrapped. 39 | { 40 | "fo sop", 41 | "fo\nsop", 42 | 4, 43 | }, 44 | // Whitespace that trails a line and fits the width 45 | // passes through, as does whitespace prefixing an 46 | // explicit line break. A tab counts as one character. 47 | { 48 | "foo\nb\t r\n baz", 49 | "foo\nb\t r\n baz", 50 | 4, 51 | }, 52 | // Trailing whitespace is removed if it doesn't fit the width. 53 | // Runs of whitespace on which a line is broken are removed. 54 | { 55 | "foo \nb ar ", 56 | "foo\nb\nar", 57 | 4, 58 | }, 59 | // An explicit line break at the end of the input is preserved. 60 | { 61 | "foo bar baz\n", 62 | "foo\nbar\nbaz\n", 63 | 4, 64 | }, 65 | // Explicit break are always preserved. 66 | { 67 | "\nfoo bar\n\n\nbaz\n", 68 | "\nfoo\nbar\n\n\nbaz\n", 69 | 4, 70 | }, 71 | // Complete example: 72 | { 73 | " This is a list: \n\n\t* foo\n\t* bar\n\n\n\t* baz \nBAM ", 74 | " This\nis a\nlist: \n\n\t* foo\n\t* bar\n\n\n\t* baz\nBAM", 75 | 6, 76 | }, 77 | } 78 | 79 | for i, tc := range cases { 80 | actual := WrapString(tc.Input, tc.Lim) 81 | if actual != tc.Output { 82 | t.Fatalf("Case %d Input:\n\n`%s`\n\nActual Output:\n\n`%s`", i, tc.Input, actual) 83 | } 84 | } 85 | } 86 | --------------------------------------------------------------------------------