Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate internal links to notes #32

Merged
merged 6 commits into from
Apr 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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