Skip to content

Commit

Permalink
Generate internal links to notes (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
mickael-menu authored Apr 18, 2021
1 parent 083c0da commit 2bb4cbd
Show file tree
Hide file tree
Showing 24 changed files with 557 additions and 92 deletions.
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@
# Dependency directories (remove the comment below to include it)
# vendor/

# Documentation notebook marker
docs/.zk
.zk
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ All notable changes to this project will be documented in this file.

* Pair `--match` with `--exact-match` / `-e` to search for (case insensitive) exact occurrences in your notes.
* This can be useful when looking for terms including special characters, such as `[[name]]`.
* Generating links to notes.
* Use the `{{link}}` template variable when [formatting notes](docs/template-format.md) to print a link to the note, relative to the working directory.
* Use the `{{format-link path title}}` template helper to render a custom link.
* Customize the link format from the [note formats settings](docs/note-format.md). You can for example choose regular Markdown links, Wiki-links or a custom format.

### Changed

* The local configuration is not required anymore in a notebook's `.zk` directory.
* The local configuration file (`.zk/config.toml`) is not required anymore in a notebook's `.zk` directory.
* `--notebook-dir` does not change the working directory anymore, instead it sets manually the current notebook and disable auto-discovery. Use the new `--working-dir`/`-W` flag to run `zk` as if it was started from this path instead of the current working directory.
* For convenience, `ZK_NOTEBOOK_DIR` behaves like setting a `--working-dir` fallback, instead of `--notebook-dir`. This way, paths will be relative to the root of the notebook.
* A practical use case is to use `zk list -W .` when outside a notebook. This will list the notes in `ZK_NOTEBOOK_DIR` but print paths relative to the current directory, making them actionable from your terminal emulator.
Expand Down
26 changes: 21 additions & 5 deletions docs/note-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,28 @@

To keep your notebooks [future-proof](future-proof.md), `zk` uses a simple plain text format for your notes. Only Markdown is supported at the moment, but more formats may be added in the future.

## Markdown

You can set up some features of `zk`'s Markdown parser from your [configuration file](config.md), under the `[format.markdown]` section.

| Setting | Default | Description |
|------------------|---------|------------------------------------------------------------------------|
| `hashtags ` | `true` | Enable `#hashtags` support |
| `colon-tags` | `false` | Enable `:colon:separated:tags:` support |
| `multiword-tags` | `false` | Enable Bear's [`#multi-word tags#`][1]. Hashtags must also be enabled. |
| Setting | Default | Description |
|-----------------------|-----------------|--------------------------------------------------------------------------------|
| `link-format` | `"markdown"` | Format used to generate internal links (`markdown`, `wiki` or custom template) |
| `link-encode-path` | `-`<sup>1</sup> | Percent-encode paths of generated internal links |
| `link-drop-extension` | `true` | Remove the path file extension of generated internal links |
| `hashtags ` | `true` | Enable `#hashtags` support |
| `colon-tags` | `false` | Enable `:colon:separated:tags:` support |
| `multiword-tags` | `false` | Enable Bear's [`#multi-word tags#`][1]. Hashtags must also be enabled. |

1. Paths are not percent-encoded by default, unless the `link-format` is `markdown`.

[1]: https://blog.bear.app/2017/11/bear-tips-how-to-create-multi-word-tags/

### Customizing the Markdown links generated by `zk`

By default, `zk` will generate regular Markdown links for internal links. If you prefer to use `[[Wiki Links]]` instead, set the `link-format` setting to `wiki`. If you want to override completely the link format, you can also set `link-format` to a [custom template](template.md). Two variables `path` and `title` are available in the template, for example to generate a wiki-link with a title:

```toml
[format.markdown]
link-format = "[[{{path}}|{{title}}]]"
```
32 changes: 17 additions & 15 deletions docs/template-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@

The following variables are available in the templates used when formatting notes, for example with `zk list --format <template>`.

| Variable | Type | Description |
|---------------|----------|---------------------------------------------------------------------|
| `path` | string | File path to the note, relative to the current directory |
| `title` | string | Note title |
| `lead` | string | First paragraph extracted from the note content |
| `body` | string | All of the note content, minus the heading |
| `snippets` | [string] | List of context-sensitive relevant excerpts from the note |
| `raw-content` | string | The full raw content of the note file |
| `word-count` | int | Number of words in the note |
| `tags` | [string] | List of tags found in the note |
| `metadata` | map | YAML frontmatter metadata, e.g. `metadata.description`<sup>1</sup> |
| `created` | date | Date of creation of the note |
| `modified` | date | Last date of modification of the note |
| `checksum` | string | SHA-256 checksum of the note file |
| Variable | Type | Description |
|---------------|----------|--------------------------------------------------------------------------|
| `path` | string | File path to the note, relative to the current directory |
| `title` | string | Note title |
| `link` | string | Markdown link to the note, relative to the current directory<sup>1</sup> |
| `lead` | string | First paragraph extracted from the note content |
| `body` | string | All of the note content, minus the heading |
| `snippets` | [string] | List of context-sensitive relevant excerpts from the note |
| `raw-content` | string | The full raw content of the note file |
| `word-count` | int | Number of words in the note |
| `tags` | [string] | List of tags found in the note |
| `metadata` | map | YAML frontmatter metadata, e.g. `metadata.description`<sup>2</sup> |
| `created` | date | Date of creation of the note |
| `modified` | date | Last date of modification of the note |
| `checksum` | string | SHA-256 checksum of the note file |

1. YAML keys are normalized to lower case.
1. The format of the generated Markdown links can be customized in the [note format configuration](note-format.md).
2. YAML keys are normalized to lower case.
15 changes: 15 additions & 0 deletions docs/template.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@

Besides the default Handlebars helpers, `zk` ships with additional helpers which you might find useful. They are available to all templates.

### Format Link helper

The `{{format-link}}` helper renders an internal link to another note, according to the user preferences set in the [note formats configuration](note-format.md).

```
{{format-link "path/to note.md" "An interesting note"}}
can generate (depending on the user config):
[An interesting note](path/to%20note.md)
[[path/to note]]
```

The second parameter `title` is optional.

### Date helper

The `{{date}}` helper formats the given date for display.
Expand Down
19 changes: 8 additions & 11 deletions internal/adapter/handlebars/handlebars.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,14 @@ type Loader struct {
strings map[string]*Template
files map[string]*Template
lookupPaths []string
lang string
styler core.Styler
logger util.Logger
helpers map[string]interface{}
}

type LoaderOpts struct {
// LookupPaths is used to resolve relative template paths.
LookupPaths []string
Lang string
Styler core.Styler
Logger util.Logger
}

// NewLoader creates a new instance of Loader.
Expand All @@ -67,12 +64,16 @@ func NewLoader(opts LoaderOpts) *Loader {
strings: make(map[string]*Template),
files: make(map[string]*Template),
lookupPaths: opts.LookupPaths,
lang: opts.Lang,
styler: opts.Styler,
logger: opts.Logger,
helpers: map[string]interface{}{},
}
}

// RegisterHelper declares a new template helper to be used with this loader only.
func (l *Loader) RegisterHelper(name string, helper interface{}) {
l.helpers[name] = helper
}

// LoadTemplate implements core.TemplateLoader.
func (l *Loader) LoadTemplate(content string) (core.Template, error) {
wrap := errors.Wrapperf("load template failed")
Expand Down Expand Up @@ -144,10 +145,6 @@ func (l *Loader) locateTemplate(path string) (string, bool) {
}

func (l *Loader) newTemplate(vendorTempl *raymond.Template) *Template {
vendorTempl.RegisterHelpers(map[string]interface{}{
"style": helpers.NewStyleHelper(l.styler, l.logger),
"slug": helpers.NewSlugHelper(l.lang, l.logger),
})

vendorTempl.RegisterHelpers(l.helpers)
return &Template{vendorTempl, l.styler}
}
44 changes: 34 additions & 10 deletions internal/adapter/handlebars/handlebars_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"testing"
"time"

"github.com/mickael-menu/zk/internal/adapter/handlebars/helpers"
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
"github.com/mickael-menu/zk/internal/util/fixtures"
Expand Down Expand Up @@ -34,7 +35,7 @@ func (s *styler) MustStyle(text string, rules ...core.Style) string {
}

func testString(t *testing.T, template string, context interface{}, expected string) {
sut := testLoader([]string{})
sut := testLoader(LoaderOpts{})

templ, err := sut.LoadTemplate(template)
assert.Nil(t, err)
Expand All @@ -45,7 +46,7 @@ func testString(t *testing.T, template string, context interface{}, expected str
}

func testFile(t *testing.T, name string, context interface{}, expected string) {
sut := testLoader([]string{})
sut := testLoader(LoaderOpts{})

templ, err := sut.LoadTemplateAt(fixtures.Path(name))
assert.Nil(t, err)
Expand All @@ -63,7 +64,7 @@ func TestLookupPaths(t *testing.T) {
path2 := filepath.Join(root, "1")
os.MkdirAll(filepath.Join(path2, "subdir"), os.ModePerm)

sut := testLoader([]string{path1, path2})
sut := testLoader(LoaderOpts{LookupPaths: []string{path1, path2}})

test := func(path string, expected string) {
tpl, err := sut.LoadTemplateAt(path)
Expand Down Expand Up @@ -169,6 +170,17 @@ func TestListHelper(t *testing.T) {
test([]string{"An item\non several\nlines\n"}, " ‣ An item\n on several\n lines\n")
}

func TestLinkHelper(t *testing.T) {
sut := testLoader(LoaderOpts{})

templ, err := sut.LoadTemplate(`{{format-link "path/to note.md" "An interesting subject"}}`)
assert.Nil(t, err)

actual, err := templ.Render(map[string]interface{}{})
assert.Nil(t, err)
assert.Equal(t, actual, "path/to note.md - An interesting subject")
}

func TestSlugHelper(t *testing.T) {
// inline
testString(t,
Expand Down Expand Up @@ -226,11 +238,23 @@ func TestStyleHelper(t *testing.T) {
testString(t, "{{#style 'single'}}A multiline\ntext{{/style}}", nil, "single(A multiline\ntext)")
}

func testLoader(lookupPaths []string) *Loader {
return NewLoader(LoaderOpts{
LookupPaths: lookupPaths,
Lang: "en",
Styler: &styler{},
Logger: &util.NullLogger,
})
func testLoader(opts LoaderOpts) *Loader {
if opts.LookupPaths == nil {
opts.LookupPaths = []string{}
}
if opts.Styler == nil {
opts.Styler = &styler{}
}

loader := NewLoader(opts)

loader.RegisterHelper("style", helpers.NewStyleHelper(opts.Styler, &util.NullLogger))
loader.RegisterHelper("slug", helpers.NewSlugHelper("en", &util.NullLogger))

formatter := func(path, title string) (string, error) {
return path + " - " + title, nil
}
loader.RegisterHelper("format-link", helpers.NewLinkHelper(formatter, &util.NullLogger))

return loader
}
25 changes: 25 additions & 0 deletions internal/adapter/handlebars/helpers/link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package helpers

import (
"github.com/mickael-menu/zk/internal/core"
"github.com/mickael-menu/zk/internal/util"
)

// NewLinkHelper creates a new template helper to generate an internal link
// using a LinkFormatter.
//
// {{link "path/to/note.md" "An interesting subject"}} -> (depends on the LinkFormatter)
// [[path/to/note]]
// [An interesting subject](path/to/note)
func NewLinkHelper(formatter core.LinkFormatter, logger util.Logger) interface{} {
return func(path string, opt interface{}) string {
title, _ := opt.(string)
link, err := formatter(path, title)
if err != nil {
logger.Err(err)
return ""
}

return link
}
}
51 changes: 33 additions & 18 deletions internal/adapter/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package lsp
import (
"fmt"
"io/ioutil"
"net/url"
"path/filepath"
"strings"

Expand Down Expand Up @@ -194,7 +193,9 @@ func NewServer(opts ServerOpts) *Server {
return server.buildTagCompletionList(notebook, ":")
}
case "[":
return server.buildLinkCompletionList(doc, notebook, params)
if doc.LookBehind(params.Position, 2) == "[[" {
return server.buildLinkCompletionList(doc, notebook, params)
}
}

return nil, nil
Expand Down Expand Up @@ -371,16 +372,32 @@ func (s *Server) buildInsertForTag(name string, triggerChar string, config core.
}

func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook, params *protocol.CompletionParams) ([]protocol.CompletionItem, error) {
linkFormatter, err := notebook.NewLinkFormatter()
if err != nil {
return nil, err
}

notes, err := notebook.FindNotes(core.NoteFindOpts{})
if err != nil {
return nil, err
}

var items []protocol.CompletionItem
for _, note := range notes {
textEdit, err := s.buildTextEditForLink(notebook, note, doc, params.Position, linkFormatter)
if err != nil {
s.logger.Err(errors.Wrapf(err, "failed to build TextEdit for note at %s", note.Path))
continue
}

label := note.Title
if label == "" {
label = note.Path
}

items = append(items, protocol.CompletionItem{
Label: note.Title,
TextEdit: s.buildTextEditForLink(notebook, note, doc, params.Position),
Label: label,
TextEdit: textEdit,
Documentation: protocol.MarkupContent{
Kind: protocol.MarkupKindMarkdown,
Value: note.RawContent,
Expand All @@ -391,32 +408,30 @@ func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook,
return items, nil
}

func (s *Server) buildTextEditForLink(notebook *core.Notebook, note core.ContextualNote, document *document, pos protocol.Position) interface{} {
isWikiLink := (document.LookBehind(pos, 2) == "[[")
var text string

func (s *Server) buildTextEditForLink(notebook *core.Notebook, note core.ContextualNote, document *document, pos protocol.Position, linkFormatter core.LinkFormatter) (interface{}, error) {
path := filepath.Join(notebook.Path, note.Path)
path = s.fs.Canonical(path)
path, err := filepath.Rel(filepath.Dir(document.Path), path)
if err != nil {
path = note.Path
}
ext := filepath.Ext(path)
path = strings.TrimSuffix(path, ext)
if isWikiLink {
text = path + "]]"
} else {
path = strings.ReplaceAll(url.PathEscape(path), "%2F", "/")
text = note.Title + "](" + path + ")"

link, err := linkFormatter(path, note.Title)
if err != nil {
return nil, err
}

// Overwrite [[ trigger
start := pos
start.Character -= 2

return protocol.TextEdit{
Range: protocol.Range{
Start: pos,
Start: start,
End: pos,
},
NewText: text,
}
NewText: link,
}, nil
}

func positionInRange(content string, rng protocol.Range, pos protocol.Position) bool {
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/cmd/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"os"
"path/filepath"

"github.com/mickael-menu/zk/internal/adapter/fzf"
Expand Down Expand Up @@ -72,7 +73,7 @@ func (cmd *Edit) Run(container *cli.Container) error {
return editor.Open(paths...)

} else {
fmt.Println("Found 0 note")
fmt.Fprintln(os.Stderr, "Found 0 note")
return nil
}
}
Expand Down
1 change: 1 addition & 0 deletions internal/cli/cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ func (cmd *List) noteTemplate() string {

var defaultNoteFormats = map[string]string{
"path": `{{path}}`,
"link": `{{link}}`,

"oneline": `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})`,

Expand Down
Loading

0 comments on commit 2bb4cbd

Please sign in to comment.