diff --git a/CHANGELOG.md b/CHANGELOG.md index 21cb280..584c517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,20 @@ All notable changes to this project will be documented in this file. ### Added +* [Editor integration through LSP](https://github.com/mickael-menu/zk/issues/22): + * New code actions to create a note using the current selection as title. + * Custom commands to [run `new` and `index` from your editor](docs/editors-integration.md#custom-commands). * Customize the format of `fzf`'s lines [with your own template](docs/tool-fzf.md). ```toml [tool] fzf-line = "{{style 'green' path}}{{#each tags}} #{{this}}{{/each}} {{style 'black' body}}" ``` +### Changed + +* Automatically index the notebook when saving a note with an LSP-enabled editor. + * This ensures that tags and notes auto-completion lists are up-to-date. + ### Fixed * Creating a new note from `fzf` in a directory containing spaces. diff --git a/docs/editors-integration.md b/docs/editors-integration.md index 7e54cdd..cce9271 100644 --- a/docs/editors-integration.md +++ b/docs/editors-integration.md @@ -13,16 +13,21 @@ There are several extensions available to integrate `zk` in your favorite editor * Auto-complete [hashtags and colon-separated tags](tags.md). * Preview the content of a note when hovering a link. * Navigate in your notes by following internal links. +* Create a new note using the current selection as title. * [And more to come...](https://github.com/mickael-menu/zk/issues/22) +### Editor LSP configurations + To start the Language Server, use the `zk lsp` command. Refer to the following sections for editor-specific examples. [Feel free to share the configuration for your editor](https://github.com/mickael-menu/zk/issues/22). -### Vim and Neovim +#### Vim and Neovim -#### Vim and Neovim 0.4 +##### Vim and Neovim 0.4 With [`coc.nvim`](https://github.com/neoclide/coc.nvim), run `:CocConfig` and add the following in the settings file: +
coc-settings.json + ```jsonc { // Important, otherwise link completion containing spaces and other special characters won't work. @@ -38,11 +43,39 @@ With [`coc.nvim`](https://github.com/neoclide/coc.nvim), run `:CocConfig` and ad } } ``` +
+ +Here are some additional useful key bindings and custom commands: + +
~/.config/nvim/init.vim + +```viml +" User command to index the current notebook. +" +" zk.index expects a notebook path as first argument, so we provide the current +" buffer path with expand("%:p"). +command! -nargs=0 ZkIndex :call CocAction("runCommand", "zk.index", expand("%:p")) +nnoremap zi :ZkIndex + +" User command to create and open a new note, to be called like this: +" :ZkNew {"title": "An interesting subject", "dir": "inbox", ...} +" +" Note the concatenation with the "edit" command to open the note right away. +command! -nargs=? ZkNew :exec "edit ".CocAction("runCommand", "zk.new", expand("%:p"), ).path + +" Create a new note after prompting for its title. +nnoremap zn :ZkNew {"title": input("Title: ")} +" Create a new note in the directory journal/daily. +nnoremap zj :ZkNew {"dir": "journal/daily"} +``` +
-#### Neovim 0.5 built-in LSP client +##### Neovim 0.5 built-in LSP client Using [`nvim-lspconfig`](https://github.com/neovim/nvim-lspconfig): +
~/.config/nvim/init.lua + ```lua local lspconfig = require('lspconfig') local configs = require('lspconfig/configs') @@ -62,11 +95,14 @@ lspconfig.zk.setup({ on_attach = function(client, buffer) -- Add keybindings here, see https://github.com/neovim/nvim-lspconfig#keybindings-and-completion end }) ``` +
-### Sublime Text +#### Sublime Text Install the [Sublime LSP](https://github.com/sublimelsp/LSP) package, then run the **Preferences: LSP Settings** command. Add the following to the settings file: +
LSP.sublime-settings + ```jsonc { "clients": { @@ -80,7 +116,60 @@ Install the [Sublime LSP](https://github.com/sublimelsp/LSP) package, then run t } } ``` +
-### Visual Studio Code +#### Visual Studio Code Install the [`zk-vscode`](https://marketplace.visualstudio.com/items?itemName=mickael-menu.zk-vscode) extension from the Marketplace. + +### Custom commands + +Using `zk`'s LSP custom commands, you can call `zk` commands right from your editor. Please refer to your editor's documentation on how to bind keyboard shortcuts to custom LSP commands. + +#### `zk.index` + +This LSP command calls `zk index` to refresh your notebook's index. It can be useful to make sure that the auto-completion is up-to-date. `zk.index` takes two arguments: + +1. A path to a file or directory in the notebook to index. +2.
(Optional) A dictionary of additional options (click to expand) + + | Key | Type | Description | + |---------|---------|-----------------------------------| + | `force` | boolean | Reindexes all the notes when true | +
+ +`zk.index` returns a dictionary of indexing statistics. + +#### `zk.new` + +This LSP command calls `zk new` to create a new note. It can be useful to quickly create a new note with a key binding. `zk.new` takes two arguments: + +1. A path to any file or directory in the notebook, to locate it. +2.
(Optional) A dictionary of additional options (click to expand) + + | Key | Type | Description | + |------------------------|------------|-------------------------------------------------------------------------------------------| + | `title` | string | Title of the new note | + | `content` | string | Initial content of the note | + | `dir` | string | Parent directory, relative to the root of the notebook | + | `group` | string | [Note configuration group](config-group.md) | + | `template` | string | [Custom template used to render the note](template-creation.md) | + | `extra` | dictionary | A dictionary of extra variables to expand in the template | + | `date` | string | A date of creation for the note in natural language, e.g. "tomorrow" | + | `edit` | boolean | When true, the editor will open the newly created note (**not supported by all editors**) | + | `insertLinkAtLocation` | location | A location in another note where a link to the new note will be inserted | + + The `location` type is an [LSP Location object](https://microsoft.github.io/language-server-protocol/specification#location), for example: + + ```json + { + "uri":"file:///Users/mickael/notes/9se3.md", + "range": { + "end":{"line": 5, "character":149}, + "start":{"line": 5, "character":137} + } + } + ``` +
+ +`zk.new` returns a dictionary with the key `path` containing the absolute path to the newly created file. diff --git a/internal/adapter/lsp/document.go b/internal/adapter/lsp/document.go index b829666..116130c 100644 --- a/internal/adapter/lsp/document.go +++ b/internal/adapter/lsp/document.go @@ -5,15 +5,71 @@ import ( "regexp" "strings" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/errors" protocol "github.com/tliron/glsp/protocol_3_16" - "github.com/tliron/kutil/logging" ) +// documentStore holds opened documents. +type documentStore struct { + documents map[string]*document + fs core.FileStorage + logger util.Logger +} + +func newDocumentStore(fs core.FileStorage, logger util.Logger) *documentStore { + return &documentStore{ + documents: map[string]*document{}, + fs: fs, + logger: logger, + } +} + +func (s *documentStore) DidOpen(params protocol.DidOpenTextDocumentParams) error { + langID := params.TextDocument.LanguageID + if langID != "markdown" && langID != "vimwiki" { + return nil + } + + path, err := s.normalizePath(params.TextDocument.URI) + if err != nil { + return err + } + s.documents[path] = &document{ + Path: path, + Content: params.TextDocument.Text, + } + + return nil +} + +func (s *documentStore) Close(uri protocol.DocumentUri) { + delete(s.documents, uri) +} + +func (s *documentStore) Get(pathOrURI string) (*document, bool) { + path, err := s.normalizePath(pathOrURI) + if err != nil { + s.logger.Err(err) + return nil, false + } + d, ok := s.documents[path] + return d, ok +} + +func (s *documentStore) normalizePath(pathOrUri string) (string, error) { + path, err := uriToPath(pathOrUri) + if err != nil { + return "", errors.Wrapf(err, "unable to parse URI: %s", pathOrUri) + } + return s.fs.Canonical(path), nil +} + // document represents an opened file. type document struct { Path string Content string - Log logging.Logger lines []string } @@ -53,6 +109,12 @@ func (d *document) WordAt(pos protocol.Position) string { return "" } +// ContentAtRange returns the document text at given range. +func (d *document) ContentAtRange(rng protocol.Range) string { + startIndex, endIndex := rng.IndexesIn(d.Content) + return d.Content[startIndex:endIndex] +} + // GetLine returns the line at the given index. func (d *document) GetLine(index int) (string, bool) { lines := d.GetLines() diff --git a/internal/adapter/lsp/server.go b/internal/adapter/lsp/server.go index a545d88..5652435 100644 --- a/internal/adapter/lsp/server.go +++ b/internal/adapter/lsp/server.go @@ -1,6 +1,7 @@ package lsp import ( + "encoding/json" "fmt" "io/ioutil" "path/filepath" @@ -8,6 +9,7 @@ import ( "github.com/mickael-menu/zk/internal/core" "github.com/mickael-menu/zk/internal/util" + dateutil "github.com/mickael-menu/zk/internal/util/date" "github.com/mickael-menu/zk/internal/util/errors" "github.com/mickael-menu/zk/internal/util/opt" strutil "github.com/mickael-menu/zk/internal/util/strings" @@ -22,7 +24,7 @@ import ( type Server struct { server *glspserv.Server notebooks *core.NotebookStore - documents map[protocol.DocumentUri]*document + documents *documentStore fs core.FileStorage logger util.Logger } @@ -45,20 +47,21 @@ func NewServer(opts ServerOpts) *Server { logging.Configure(10, opts.LogFile.Value) } - workspace := newWorkspace() handler := protocol.Handler{} - server := &Server{ - server: glspserv.NewServer(&handler, opts.Name, debug), - notebooks: opts.Notebooks, - documents: map[string]*document{}, - fs: fs, - } + glspServer := glspserv.NewServer(&handler, opts.Name, debug) // Redirect zk's logger to GLSP's to avoid breaking the JSON-RPC protocol // with unwanted output. if opts.Logger != nil { - opts.Logger.Logger = newGlspLogger(server.server.Log) - server.logger = opts.Logger + opts.Logger.Logger = newGlspLogger(glspServer.Log) + } + + server := &Server{ + server: glspServer, + notebooks: opts.Notebooks, + documents: newDocumentStore(fs, opts.Logger), + fs: fs, + logger: opts.Logger, } var clientCapabilities protocol.ClientCapabilities @@ -66,16 +69,6 @@ func NewServer(opts ServerOpts) *Server { handler.Initialize = func(context *glsp.Context, params *protocol.InitializeParams) (interface{}, error) { clientCapabilities = params.Capabilities - if len(params.WorkspaceFolders) > 0 { - for _, f := range params.WorkspaceFolders { - workspace.addFolder(f.URI) - } - } else if params.RootURI != nil { - workspace.addFolder(*params.RootURI) - } else if params.RootPath != nil { - workspace.addFolder(*params.RootPath) - } - // To see the logs with coc.nvim, run :CocCommand workspace.showOutput // https://github.com/neoclide/coc.nvim/wiki/Debug-language-server#using-output-channel if params.Trace != nil { @@ -84,6 +77,8 @@ func NewServer(opts ServerOpts) *Server { capabilities := handler.CreateServerCapabilities() capabilities.HoverProvider = true + capabilities.DefinitionProvider = true + capabilities.CodeActionProvider = true change := protocol.TextDocumentSyncKindIncremental capabilities.TextDocumentSync = protocol.TextDocumentSyncOptions{ @@ -97,13 +92,17 @@ func NewServer(opts ServerOpts) *Server { triggerChars := []string{"[", "#", ":"} + capabilities.ExecuteCommandProvider = &protocol.ExecuteCommandOptions{ + Commands: []string{ + cmdIndex, + cmdNew, + }, + } capabilities.CompletionProvider = &protocol.CompletionOptions{ TriggerCharacters: triggerChars, ResolveProvider: boolPtr(true), } - capabilities.DefinitionProvider = boolPtr(true) - return protocol.InitializeResult{ Capabilities: capabilities, ServerInfo: &protocol.InitializeResultServerInfo{ @@ -127,40 +126,12 @@ func NewServer(opts ServerOpts) *Server { return nil } - handler.WorkspaceDidChangeWorkspaceFolders = func(context *glsp.Context, params *protocol.DidChangeWorkspaceFoldersParams) error { - for _, f := range params.Event.Added { - workspace.addFolder(f.URI) - } - for _, f := range params.Event.Removed { - workspace.removeFolder(f.URI) - } - return nil - } - handler.TextDocumentDidOpen = func(context *glsp.Context, params *protocol.DidOpenTextDocumentParams) error { - langID := params.TextDocument.LanguageID - if langID != "markdown" && langID != "vimwiki" { - return nil - } - - path, err := uriToPath(params.TextDocument.URI) - if err != nil { - server.logger.Printf("unable to parse URI: %v", err) - return nil - } - path = fs.Canonical(path) - - server.documents[params.TextDocument.URI] = &document{ - Path: path, - Content: params.TextDocument.Text, - Log: server.server.Log, - } - - return nil + return server.documents.DidOpen(*params) } handler.TextDocumentDidChange = func(context *glsp.Context, params *protocol.DidChangeTextDocumentParams) error { - doc, ok := server.documents[params.TextDocument.URI] + doc, ok := server.documents.Get(params.TextDocument.URI) if !ok { return nil } @@ -170,11 +141,24 @@ func NewServer(opts ServerOpts) *Server { } handler.TextDocumentDidClose = func(context *glsp.Context, params *protocol.DidCloseTextDocumentParams) error { - delete(server.documents, params.TextDocument.URI) + server.documents.Close(params.TextDocument.URI) return nil } handler.TextDocumentDidSave = func(context *glsp.Context, params *protocol.DidSaveTextDocumentParams) error { + doc, ok := server.documents.Get(params.TextDocument.URI) + if !ok { + return nil + } + + notebook, err := server.notebookOf(doc) + if err != nil { + server.logger.Err(err) + return nil + } + + _, err = notebook.Index(false) + server.logger.Err(err) return nil } @@ -184,7 +168,7 @@ func NewServer(opts ServerOpts) *Server { return nil, nil } - doc, ok := server.documents[params.TextDocument.URI] + doc, ok := server.documents.Get(params.TextDocument.URI) if !ok { return nil, nil } @@ -228,7 +212,7 @@ func NewServer(opts ServerOpts) *Server { } handler.TextDocumentHover = func(context *glsp.Context, params *protocol.HoverParams) (*protocol.Hover, error) { - doc, ok := server.documents[params.TextDocument.URI] + doc, ok := server.documents.Get(params.TextDocument.URI) if !ok { return nil, nil } @@ -269,7 +253,7 @@ func NewServer(opts ServerOpts) *Server { } handler.TextDocumentDocumentLink = func(context *glsp.Context, params *protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) { - doc, ok := server.documents[params.TextDocument.URI] + doc, ok := server.documents.Get(params.TextDocument.URI) if !ok { return nil, nil } @@ -301,7 +285,7 @@ func NewServer(opts ServerOpts) *Server { } handler.TextDocumentDefinition = func(context *glsp.Context, params *protocol.DefinitionParams) (interface{}, error) { - doc, ok := server.documents[params.TextDocument.URI] + doc, ok := server.documents.Get(params.TextDocument.URI) if !ok { return nil, nil } @@ -335,9 +319,201 @@ func NewServer(opts ServerOpts) *Server { } } + handler.WorkspaceExecuteCommand = func(context *glsp.Context, params *protocol.ExecuteCommandParams) (interface{}, error) { + switch params.Command { + case cmdIndex: + return server.executeCommandIndex(params.Arguments) + case cmdNew: + return server.executeCommandNew(context, params.Arguments) + default: + return nil, fmt.Errorf("unknown zk LSP command: %s", params.Command) + } + } + + handler.TextDocumentCodeAction = func(context *glsp.Context, params *protocol.CodeActionParams) (interface{}, error) { + if isRangeEmpty(params.Range) { + return nil, nil + } + + doc, ok := server.documents.Get(params.TextDocument.URI) + if !ok { + return nil, nil + } + wd := filepath.Dir(doc.Path) + + actions := []protocol.CodeAction{} + + addAction := func(dir string, actionTitle string) error { + opts := cmdNewOpts{ + Title: doc.ContentAtRange(params.Range), + Dir: dir, + InsertLinkAtLocation: &protocol.Location{ + URI: params.TextDocument.URI, + Range: params.Range, + }, + } + + var jsonOpts map[string]interface{} + err := unmarshalJSON(opts, &jsonOpts) + if err != nil { + return err + } + + actions = append(actions, protocol.CodeAction{ + Title: actionTitle, + Kind: stringPtr(protocol.CodeActionKindRefactor), + Command: &protocol.Command{ + Command: cmdNew, + Arguments: []interface{}{wd, jsonOpts}, + }, + }) + + return nil + } + + addAction(wd, "New note in current directory") + addAction("", "New note in top directory") + + return actions, nil + } + return server } +const cmdIndex = "zk.index" + +func (s *Server) executeCommandIndex(args []interface{}) (interface{}, error) { + if len(args) == 0 { + return nil, fmt.Errorf("zk.index expects a notebook path as first argument") + } + path, ok := args[0].(string) + if !ok { + return nil, fmt.Errorf("zk.index expects a notebook path as first argument, got: %v", args[0]) + } + + force := false + if len(args) == 2 { + options, ok := args[1].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("zk.index expects a dictionary of options as second argument, got: %v", args[1]) + } + if forceOption, ok := options["force"]; ok { + force = toBool(forceOption) + } + } + + notebook, err := s.notebooks.Open(path) + if err != nil { + return nil, err + } + + return notebook.Index(force) +} + +const cmdNew = "zk.new" + +type cmdNewOpts struct { + Title string `json:"title,omitempty"` + Content string `json:"content,omitempty"` + Dir string `json:"dir,omitempty"` + Group string `json:"group,omitempty"` + Template string `json:"template,omitempty"` + Extra map[string]string `json:"extra,omitempty"` + Date string `json:"date,omitempty"` + Edit jsonBoolean `json:"edit,omitempty"` + InsertLinkAtLocation *protocol.Location `json:"insertLinkAtLocation,omitempty"` +} + +func (s *Server) executeCommandNew(context *glsp.Context, args []interface{}) (interface{}, error) { + if len(args) == 0 { + return nil, fmt.Errorf("zk.index expects a notebook path as first argument") + } + wd, ok := args[0].(string) + if !ok { + return nil, fmt.Errorf("zk.index expects a notebook path as first argument, got: %v", args[0]) + } + + var opts cmdNewOpts + if len(args) > 1 { + arg, ok := args[1].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("zk.new expects a dictionary of options as second argument, got: %v", args[1]) + } + err := unmarshalJSON(arg, &opts) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse zk.new args, got: %v", arg) + } + } + + notebook, err := s.notebooks.Open(wd) + if err != nil { + return nil, err + } + + date, err := dateutil.TimeFromNatural(opts.Date) + if err != nil { + return nil, errors.Wrapf(err, "%s, failed to parse the `date` option", opts.Date) + } + + path, err := notebook.NewNote(core.NewNoteOpts{ + Title: opt.NewNotEmptyString(opts.Title), + Content: opts.Content, + Directory: opt.NewNotEmptyString(opts.Dir), + Group: opt.NewNotEmptyString(opts.Group), + Template: opt.NewNotEmptyString(opts.Template), + Extra: opts.Extra, + Date: date, + }) + if err != nil { + var noteExists core.ErrNoteExists + if !errors.As(err, ¬eExists) { + return nil, err + } + path = noteExists.Path + } + + // Index the notebook to be able to navigate to the new note. + notebook.Index(false) + + if opts.InsertLinkAtLocation != nil { + doc, ok := s.documents.Get(opts.InsertLinkAtLocation.URI) + if !ok { + return nil, fmt.Errorf("can't insert link in %s", opts.InsertLinkAtLocation.URI) + } + linkFormatter, err := notebook.NewLinkFormatter() + if err != nil { + return nil, err + } + + relPath, err := filepath.Rel(filepath.Dir(doc.Path), path) + if err != nil { + return nil, err + } + + link, err := linkFormatter(relPath, opts.Title) + if err != nil { + return nil, err + } + + go context.Call(protocol.ServerWorkspaceApplyEdit, protocol.ApplyWorkspaceEditParams{ + Edit: protocol.WorkspaceEdit{ + Changes: map[string][]protocol.TextEdit{ + opts.InsertLinkAtLocation.URI: {{Range: opts.InsertLinkAtLocation.Range, NewText: link}}, + }, + }, + }, nil) + } + + if opts.Edit { + go context.Call(protocol.ServerWindowShowDocument, protocol.ShowDocumentParams{ + URI: "file://" + path, + TakeFocus: boolPtr(true), + }, nil) + } + + return map[string]interface{}{"path": path}, nil +} + func (s *Server) notebookOf(doc *document) (*core.Notebook, error) { return s.notebooks.Open(doc.Path) } @@ -513,6 +689,10 @@ func rangeFromPosition(pos protocol.Position, startOffset, endOffset int) protoc } } +func isRangeEmpty(pos protocol.Range) bool { + return pos.Start == pos.End +} + func boolPtr(v bool) *bool { b := v return &b @@ -530,3 +710,16 @@ func stringPtr(v string) *string { s := v return &s } + +func unmarshalJSON(obj interface{}, v interface{}) error { + js, err := json.Marshal(obj) + if err != nil { + return err + } + return json.Unmarshal(js, v) +} + +func toBool(obj interface{}) bool { + s := strings.ToLower(fmt.Sprint(obj)) + return s == "true" || s == "1" +} diff --git a/internal/adapter/lsp/util.go b/internal/adapter/lsp/util.go index 2a064ff..493c0a2 100644 --- a/internal/adapter/lsp/util.go +++ b/internal/adapter/lsp/util.go @@ -1,19 +1,20 @@ package lsp import ( + "fmt" "net/url" + "github.com/mickael-menu/zk/internal/util/errors" ) func pathToURI(path string) string { u := &url.URL{ - Scheme: "file", - Path: path, + Scheme: "file", + Path: path, } return u.String() } - func uriToPath(uri string) (string, error) { parsed, err := url.Parse(uri) if err != nil { @@ -25,3 +26,18 @@ func uriToPath(uri string) (string, error) { return parsed.Path, nil } +// jsonBoolean can be unmarshalled from integers or strings. +// Neovim cannot send a boolean easily, so it's useful to support integers too. +type jsonBoolean bool + +func (b *jsonBoolean) UnmarshalJSON(data []byte) error { + s := string(data) + if s == "1" || s == "true" { + *b = true + } else if s == "0" || s == "false" { + *b = false + } else { + return fmt.Errorf("%s: failed to unmarshal as boolean", s) + } + return nil +} diff --git a/internal/adapter/lsp/workspace.go b/internal/adapter/lsp/workspace.go deleted file mode 100644 index e713613..0000000 --- a/internal/adapter/lsp/workspace.go +++ /dev/null @@ -1,28 +0,0 @@ -package lsp - -import "strings" - -type workspace struct { - folders []string -} - -func newWorkspace() *workspace { - return &workspace{ - folders: []string{}, - } -} - -func (w *workspace) addFolder(folder string) { - folder = strings.TrimPrefix(folder, "file://") - w.folders = append(w.folders, folder) -} - -func (w *workspace) removeFolder(folder string) { - folder = strings.TrimPrefix(folder, "file://") - for i, f := range w.folders { - if f == folder { - w.folders = append(w.folders[:i], w.folders[i+1:]...) - break - } - } -} diff --git a/internal/cli/filtering.go b/internal/cli/filtering.go index c6fc7f0..0596b24 100644 --- a/internal/cli/filtering.go +++ b/internal/cli/filtering.go @@ -2,16 +2,15 @@ package cli import ( "fmt" - "strconv" "time" "github.com/alecthomas/kong" "github.com/kballard/go-shellquote" "github.com/mickael-menu/zk/internal/core" + dateutil "github.com/mickael-menu/zk/internal/util/date" "github.com/mickael-menu/zk/internal/util/errors" "github.com/mickael-menu/zk/internal/util/opt" "github.com/mickael-menu/zk/internal/util/strings" - "github.com/tj/go-naturaldate" ) // Filtering holds filtering options to select notes. @@ -205,14 +204,14 @@ func (f Filtering) NewNoteFindOpts(notebook *core.Notebook) (core.NoteFindOpts, opts.CreatedEnd = &end } else { if f.CreatedBefore != "" { - date, err := parseDate(f.CreatedBefore) + date, err := dateutil.TimeFromNatural(f.CreatedBefore) if err != nil { return opts, err } opts.CreatedEnd = &date } if f.CreatedAfter != "" { - date, err := parseDate(f.CreatedAfter) + date, err := dateutil.TimeFromNatural(f.CreatedAfter) if err != nil { return opts, err } @@ -229,14 +228,14 @@ func (f Filtering) NewNoteFindOpts(notebook *core.Notebook) (core.NoteFindOpts, opts.ModifiedEnd = &end } else { if f.ModifiedBefore != "" { - date, err := parseDate(f.ModifiedBefore) + date, err := dateutil.TimeFromNatural(f.ModifiedBefore) if err != nil { return opts, err } opts.ModifiedEnd = &date } if f.ModifiedAfter != "" { - date, err := parseDate(f.ModifiedAfter) + date, err := dateutil.TimeFromNatural(f.ModifiedAfter) if err != nil { return opts, err } @@ -266,15 +265,8 @@ func relPaths(notebook *core.Notebook, paths []string) ([]string, bool) { return relPaths, len(relPaths) > 0 } -func parseDate(date string) (time.Time, error) { - if i, err := strconv.ParseInt(date, 10, 0); err == nil && i >= 1000 && i < 5000 { - return time.Date(int(i), time.January, 0, 0, 0, 0, 0, time.UTC), nil - } - return naturaldate.Parse(date, time.Now().UTC(), naturaldate.WithDirection(naturaldate.Past)) -} - func parseDayRange(date string) (start time.Time, end time.Time, err error) { - day, err := parseDate(date) + day, err := dateutil.TimeFromNatural(date) if err != nil { return } diff --git a/internal/core/note_index.go b/internal/core/note_index.go index d7f70fd..5539ead 100644 --- a/internal/core/note_index.go +++ b/internal/core/note_index.go @@ -49,15 +49,15 @@ type NoteIndex interface { // NoteIndexingStats holds statistics about a notebook indexing process. type NoteIndexingStats struct { // Number of notes in the source. - SourceCount int + SourceCount int `json:"sourceCount"` // Number of newly indexed notes. - AddedCount int + AddedCount int `json:"addedCount"` // Number of notes modified since last indexing. - ModifiedCount int + ModifiedCount int `json:"modifiedCount"` // Number of notes removed since last indexing. - RemovedCount int + RemovedCount int `json:"removedCount"` // Duration of the indexing process. - Duration time.Duration + Duration time.Duration `json:"duration"` } // String implements Stringer diff --git a/internal/util/date/date.go b/internal/util/date/date.go index c1afe0a..b3d774a 100644 --- a/internal/util/date/date.go +++ b/internal/util/date/date.go @@ -1,6 +1,11 @@ package date -import "time" +import ( + "strconv" + "time" + + "github.com/tj/go-naturaldate" +) // Provider returns a date instance. type Provider interface { @@ -30,3 +35,14 @@ func NewFrozen(date time.Time) Frozen { func (n *Frozen) Date() time.Time { return n.date } + +// TimeFromNatural parses a human date into a time.Time. +func TimeFromNatural(date string) (time.Time, error) { + if date == "" { + return time.Now(), nil + } + if i, err := strconv.ParseInt(date, 10, 0); err == nil && i >= 1000 && i < 5000 { + return time.Date(int(i), time.January, 0, 0, 0, 0, 0, time.UTC), nil + } + return naturaldate.Parse(date, time.Now().UTC(), naturaldate.WithDirection(naturaldate.Past)) +} diff --git a/internal/util/errors/errors.go b/internal/util/errors/errors.go index 9d52cb1..e8d2ffe 100644 --- a/internal/util/errors/errors.go +++ b/internal/util/errors/errors.go @@ -29,3 +29,7 @@ func Wrap(err error, msg string) error { func New(text string) error { return errors.New(text) } + +func As(err error, target interface{}) bool { + return errors.As(err, target) +}