diff --git a/adapter/container.go b/adapter/container.go deleted file mode 100644 index a45ac50..0000000 --- a/adapter/container.go +++ /dev/null @@ -1,219 +0,0 @@ -package adapter - -import ( - "io" - "os" - "path/filepath" - "time" - - "github.com/mickael-menu/zk/adapter/fzf" - "github.com/mickael-menu/zk/adapter/handlebars" - "github.com/mickael-menu/zk/adapter/markdown" - "github.com/mickael-menu/zk/adapter/sqlite" - "github.com/mickael-menu/zk/adapter/term" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/core/zk" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/date" - "github.com/mickael-menu/zk/util/errors" - "github.com/mickael-menu/zk/util/pager" - "github.com/mickael-menu/zk/util/paths" - "github.com/schollz/progressbar/v3" -) - -type Container struct { - Version string - Config zk.Config - Date date.Provider - Logger util.Logger - Terminal *term.Terminal - WorkingDir string - templateLoader *handlebars.Loader - zk *zk.Zk - zkErr error -} - -func NewContainer(version string) (*Container, error) { - wrap := errors.Wrapper("initialization") - - config := zk.NewDefaultConfig() - - // Load global user config - configPath, err := locateGlobalConfig() - if err != nil { - return nil, wrap(err) - } - if configPath != "" { - config, err = zk.OpenConfig(configPath, config) - if err != nil { - return nil, wrap(err) - } - } - - date := date.NewFrozenNow() - - return &Container{ - Version: version, - Config: config, - // zk is short-lived, so we freeze the current date to use the same - // date for any template rendering during the execution. - Date: &date, - Logger: util.NewStdLogger("zk: ", 0), - Terminal: term.New(), - }, nil -} - -// locateGlobalConfig looks for the global zk config file following the -// XDG Base Directory specification -// https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html -func locateGlobalConfig() (string, error) { - configHome, ok := os.LookupEnv("XDG_CONFIG_HOME") - if !ok { - home, ok := os.LookupEnv("HOME") - if !ok { - home = "~/" - } - configHome = filepath.Join(home, ".config") - } - - configPath := filepath.Join(configHome, "zk/config.toml") - exists, err := paths.Exists(configPath) - switch { - case err != nil: - return "", err - case exists: - return configPath, nil - default: - return "", nil - } -} - -// OpenNotebook resolves and loads the first notebook found in the given -// searchPaths. -func (c *Container) OpenNotebook(searchPaths []string) { - if len(searchPaths) == 0 { - panic("no notebook search paths provided") - } - - for _, path := range searchPaths { - c.zk, c.zkErr = zk.Open(path, c.Config) - if c.zkErr == nil { - c.WorkingDir = path - c.Config = c.zk.Config - os.Setenv("ZK_NOTEBOOK_DIR", c.zk.Path) - return - } - } -} - -func (c *Container) Zk() (*zk.Zk, error) { - return c.zk, c.zkErr -} - -func (c *Container) TemplateLoader(lang string) *handlebars.Loader { - if c.templateLoader == nil { - handlebars.Init(lang, c.Terminal.SupportsUTF8(), c.Logger, c.Terminal) - c.templateLoader = handlebars.NewLoader() - } - return c.templateLoader -} - -func (c *Container) Parser() *markdown.Parser { - return markdown.NewParser(markdown.ParserOpts{ - HashtagEnabled: c.Config.Format.Markdown.Hashtags, - MultiWordTagEnabled: c.Config.Format.Markdown.MultiwordTags, - ColontagEnabled: c.Config.Format.Markdown.ColonTags, - }) -} - -func (c *Container) NoteFinder(tx sqlite.Transaction, opts fzf.NoteFinderOpts) *fzf.NoteFinder { - notes := sqlite.NewNoteDAO(tx, c.Logger) - return fzf.NewNoteFinder(opts, notes, c.Terminal) -} - -func (c *Container) NoteIndexer(tx sqlite.Transaction) *sqlite.NoteIndexer { - notes := sqlite.NewNoteDAO(tx, c.Logger) - collections := sqlite.NewCollectionDAO(tx, c.Logger) - return sqlite.NewNoteIndexer(notes, collections, c.Logger) -} - -// Database returns the DB instance for the given notebook, after executing any -// pending migration and indexing the notes if needed. -func (c *Container) Database(forceIndexing bool) (*sqlite.DB, note.IndexingStats, error) { - var stats note.IndexingStats - - if c.zkErr != nil { - return nil, stats, c.zkErr - } - - db, err := sqlite.Open(c.zk.DBPath()) - if err != nil { - return nil, stats, err - } - needsReindexing, err := db.Migrate() - if err != nil { - return nil, stats, errors.Wrap(err, "failed to migrate the database") - } - - stats, err = c.index(db, forceIndexing || needsReindexing) - if err != nil { - return nil, stats, err - } - - return db, stats, err -} - -func (c *Container) index(db *sqlite.DB, force bool) (note.IndexingStats, error) { - var bar = progressbar.NewOptions(-1, - progressbar.OptionSetWriter(os.Stderr), - progressbar.OptionThrottle(100*time.Millisecond), - progressbar.OptionSpinnerType(14), - ) - - var err error - var stats note.IndexingStats - - if c.zkErr != nil { - return stats, c.zkErr - } - - err = db.WithTransaction(func(tx sqlite.Transaction) error { - stats, err = note.Index( - c.zk, - force, - c.Parser(), - c.NoteIndexer(tx), - c.Logger, - func(change paths.DiffChange) { - bar.Add(1) - bar.Describe(change.String()) - }, - ) - return err - }) - bar.Clear() - - return stats, err -} - -// Paginate creates an auto-closing io.Writer which will be automatically -// paginated if noPager is false, using the user's pager. -// -// You can write to the pager only in the run callback. -func (c *Container) Paginate(noPager bool, run func(out io.Writer) error) error { - pager, err := c.pager(noPager || c.Config.Tool.Pager.IsEmpty()) - if err != nil { - return err - } - err = run(pager) - pager.Close() - return err -} - -func (c *Container) pager(noPager bool) (*pager.Pager, error) { - if noPager || !c.Terminal.IsInteractive() { - return pager.PassthroughPager, nil - } else { - return pager.New(c.Config.Tool.Pager, c.Logger) - } -} diff --git a/adapter/fzf/finder.go b/adapter/fzf/finder.go deleted file mode 100644 index ca4241e..0000000 --- a/adapter/fzf/finder.go +++ /dev/null @@ -1,131 +0,0 @@ -package fzf - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/mickael-menu/zk/adapter/term" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/core/style" - "github.com/mickael-menu/zk/core/zk" - "github.com/mickael-menu/zk/util/opt" - stringsutil "github.com/mickael-menu/zk/util/strings" -) - -// NoteFinder wraps a note.Finder and filters its result interactively using fzf. -type NoteFinder struct { - opts NoteFinderOpts - finder note.Finder - terminal *term.Terminal -} - -// NoteFinderOpts holds the configuration for the fzf notes finder. -// -// The absolute path to the notebook (BasePath) and the working directory -// (CurrentPath) are used to make the path of each note relative to the working -// directory. -type NoteFinderOpts struct { - // Indicates whether fzf is opened for every query, even if empty. - AlwaysFilter bool - // Preview command to run when selecting a note. - PreviewCmd opt.String - // When non nil, a "create new note from query" binding will be added to - // fzf to create a note in this directory. - NewNoteDir *zk.Dir - // Absolute path to the notebook. - BasePath string - // Path to the working directory. - CurrentPath string -} - -func NewNoteFinder(opts NoteFinderOpts, finder note.Finder, terminal *term.Terminal) *NoteFinder { - return &NoteFinder{ - opts: opts, - finder: finder, - terminal: terminal, - } -} - -func (f *NoteFinder) Find(opts note.FinderOpts) ([]note.Match, error) { - selectedMatches := make([]note.Match, 0) - matches, err := f.finder.Find(opts) - relPaths := []string{} - - if !opts.Interactive || !f.terminal.IsInteractive() || err != nil || (!f.opts.AlwaysFilter && len(matches) == 0) { - return matches, err - } - - for _, match := range matches { - absPath := filepath.Join(f.opts.BasePath, match.Path) - relPath, err := filepath.Rel(f.opts.CurrentPath, absPath) - if err != nil { - return selectedMatches, err - } - relPaths = append(relPaths, relPath) - } - - zkBin, err := os.Executable() - if err != nil { - return selectedMatches, err - } - - bindings := []Binding{} - - if dir := f.opts.NewNoteDir; dir != nil { - suffix := "" - if dir.Name != "" { - suffix = " in " + dir.Name + "/" - } - - bindings = append(bindings, Binding{ - Keys: "Ctrl-N", - Description: "create a note with the query as title" + suffix, - Action: fmt.Sprintf("abort+execute(%s new %s --title {q} < /dev/tty > /dev/tty)", zkBin, dir.Path), - }) - } - - previewCmd := f.opts.PreviewCmd.OrString("cat {-1}").Unwrap() - if previewCmd != "" { - // The note paths will be relative to the current path, so we need to - // move there otherwise the preview command will fail. - previewCmd = `cd "` + f.opts.CurrentPath + `" && ` + previewCmd - } - - fzf, err := New(Opts{ - PreviewCmd: opt.NewNotEmptyString(previewCmd), - Padding: 2, - Bindings: bindings, - }) - if err != nil { - return selectedMatches, err - } - - for i, match := range matches { - title := match.Title - if title == "" { - title = relPaths[i] - } - fzf.Add([]string{ - f.terminal.MustStyle(title, style.RuleYellow), - f.terminal.MustStyle(stringsutil.JoinLines(match.Body), style.RuleUnderstate), - f.terminal.MustStyle(relPaths[i], style.RuleUnderstate), - }) - } - - selection, err := fzf.Selection() - if err != nil { - return selectedMatches, err - } - - for _, s := range selection { - path := s[len(s)-1] - for i, m := range matches { - if relPaths[i] == path { - selectedMatches = append(selectedMatches, m) - } - } - } - - return selectedMatches, nil -} diff --git a/adapter/handlebars/handlebars.go b/adapter/handlebars/handlebars.go deleted file mode 100644 index 80863fc..0000000 --- a/adapter/handlebars/handlebars.go +++ /dev/null @@ -1,97 +0,0 @@ -package handlebars - -import ( - "html" - "path/filepath" - - "github.com/aymerick/raymond" - "github.com/mickael-menu/zk/adapter/handlebars/helpers" - "github.com/mickael-menu/zk/core/style" - "github.com/mickael-menu/zk/core/templ" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/errors" -) - -func Init(lang string, supportsUTF8 bool, logger util.Logger, styler style.Styler) { - helpers.RegisterConcat() - helpers.RegisterDate(logger) - helpers.RegisterJoin() - helpers.RegisterList(supportsUTF8) - helpers.RegisterPrepend(logger) - helpers.RegisterShell(logger) - helpers.RegisterSlug(lang, logger) - helpers.RegisterStyle(styler, logger) -} - -// Template renders a parsed handlebars template. -type Template struct { - template *raymond.Template -} - -// Render renders the template with the given context. -func (t *Template) Render(context interface{}) (string, error) { - res, err := t.template.Exec(context) - if err != nil { - return "", errors.Wrap(err, "render template failed") - } - return html.UnescapeString(res), nil -} - -// Loader loads and holds parsed handlebars templates. -type Loader struct { - strings map[string]*Template - files map[string]*Template -} - -// NewLoader creates a new instance of Loader. -func NewLoader() *Loader { - return &Loader{ - strings: make(map[string]*Template), - files: make(map[string]*Template), - } -} - -// Load retrieves or parses a handlebars string template. -func (l *Loader) Load(content string) (templ.Renderer, error) { - wrap := errors.Wrapperf("load template failed") - - // Already loaded? - template, ok := l.strings[content] - if ok { - return template, nil - } - - // Load new template. - vendorTempl, err := raymond.Parse(content) - if err != nil { - return nil, wrap(err) - } - template = &Template{vendorTempl} - l.strings[content] = template - return template, nil -} - -// LoadFile retrieves or parses a handlebars file template. -func (l *Loader) LoadFile(path string) (templ.Renderer, error) { - wrap := errors.Wrapper("load template file failed") - - path, err := filepath.Abs(path) - if err != nil { - return nil, wrap(err) - } - - // Already loaded? - template, ok := l.files[path] - if ok { - return template, nil - } - - // Load new template. - vendorTempl, err := raymond.ParseFile(path) - if err != nil { - return nil, wrap(err) - } - template = &Template{vendorTempl} - l.files[path] = template - return template, nil -} diff --git a/adapter/sqlite/fixtures/sample.db b/adapter/sqlite/fixtures/sample.db deleted file mode 100644 index a584291..0000000 --- a/adapter/sqlite/fixtures/sample.db +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1395602c94007495dec225bc4f1062aa7fe9a6188ba28c6419fe1e132f41778a -size 36864 diff --git a/adapter/sqlite/note_indexer.go b/adapter/sqlite/note_indexer.go deleted file mode 100644 index 8b3de50..0000000 --- a/adapter/sqlite/note_indexer.go +++ /dev/null @@ -1,91 +0,0 @@ -package sqlite - -import ( - "github.com/mickael-menu/zk/core" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/errors" - "github.com/mickael-menu/zk/util/paths" -) - -// NoteIndexer persists note indexing results in the SQLite database. -// It implements the core port note.Indexer and acts as a facade to the DAOs. -type NoteIndexer struct { - tx Transaction - notes *NoteDAO - collections *CollectionDAO - logger util.Logger -} - -func NewNoteIndexer(notes *NoteDAO, collections *CollectionDAO, logger util.Logger) *NoteIndexer { - return &NoteIndexer{ - notes: notes, - collections: collections, - logger: logger, - } -} - -// Indexed returns the list of indexed note file metadata. -func (i *NoteIndexer) Indexed() (<-chan paths.Metadata, error) { - c, err := i.notes.Indexed() - return c, errors.Wrap(err, "failed to get indexed notes") -} - -// Add indexes a new note from its metadata. -func (i *NoteIndexer) Add(metadata note.Metadata) (core.NoteId, error) { - wrap := errors.Wrapperf("%v: failed to index the note", metadata.Path) - noteId, err := i.notes.Add(metadata) - if err != nil { - return 0, wrap(err) - } - - err = i.associateTags(noteId, metadata.Tags) - if err != nil { - return 0, wrap(err) - } - - return noteId, nil -} - -// Update updates the metadata of an already indexed note. -func (i *NoteIndexer) Update(metadata note.Metadata) error { - wrap := errors.Wrapperf("%v: failed to update note index", metadata.Path) - - noteId, err := i.notes.Update(metadata) - if err != nil { - return wrap(err) - } - - err = i.collections.RemoveAssociations(noteId) - if err != nil { - return wrap(err) - } - - err = i.associateTags(noteId, metadata.Tags) - if err != nil { - return wrap(err) - } - - return err -} - -func (i *NoteIndexer) associateTags(noteId core.NoteId, tags []string) error { - for _, tag := range tags { - tagId, err := i.collections.FindOrCreate(note.CollectionKindTag, tag) - if err != nil { - return err - } - _, err = i.collections.Associate(noteId, tagId) - if err != nil { - return err - } - } - - return nil -} - -// Remove deletes a note from the index. -func (i *NoteIndexer) Remove(path string) error { - err := i.notes.Remove(path) - return errors.Wrapf(err, "%v: failed to remove note index", path) -} diff --git a/adapter/sqlite/note_indexer_test.go b/adapter/sqlite/note_indexer_test.go deleted file mode 100644 index edf3d90..0000000 --- a/adapter/sqlite/note_indexer_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package sqlite - -import ( - "testing" - - "github.com/mickael-menu/zk/core" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/test/assert" -) - -func TestNoteIndexerAddWithTags(t *testing.T) { - testNoteIndexer(t, func(tx Transaction, indexer *NoteIndexer) { - assertSQL := func(after bool) { - assertTagExistsOrNot(t, tx, true, "fiction") - assertTagExistsOrNot(t, tx, after, "new-tag") - } - - assertSQL(false) - id, err := indexer.Add(note.Metadata{ - Path: "log/added.md", - Tags: []string{"new-tag", "fiction"}, - }) - assert.Nil(t, err) - assertSQL(true) - assertTaggedOrNot(t, tx, true, id, "new-tag") - assertTaggedOrNot(t, tx, true, id, "fiction") - }) -} - -func TestNoteIndexerUpdateWithTags(t *testing.T) { - testNoteIndexer(t, func(tx Transaction, indexer *NoteIndexer) { - id := core.NoteId(1) - - assertSQL := func(after bool) { - assertTaggedOrNot(t, tx, true, id, "fiction") - assertTaggedOrNot(t, tx, after, id, "new-tag") - assertTaggedOrNot(t, tx, after, id, "fantasy") - } - - assertSQL(false) - err := indexer.Update(note.Metadata{ - Path: "log/2021-01-03.md", - Tags: []string{"new-tag", "fiction", "fantasy"}, - }) - assert.Nil(t, err) - assertSQL(true) - }) -} - -func testNoteIndexer(t *testing.T, callback func(tx Transaction, dao *NoteIndexer)) { - testTransaction(t, func(tx Transaction) { - logger := &util.NullLogger - callback(tx, NewNoteIndexer(NewNoteDAO(tx, logger), NewCollectionDAO(tx, logger), logger)) - }) -} - -func assertTagExistsOrNot(t *testing.T, tx Transaction, shouldExist bool, tag string) { - assertExistOrNot(t, tx, shouldExist, "SELECT id FROM collections WHERE kind = 'tag' AND name = ?", tag) -} - -func assertTaggedOrNot(t *testing.T, tx Transaction, shouldBeTagged bool, noteId core.NoteId, tag string) { - assertExistOrNot(t, tx, shouldBeTagged, "SELECT id FROM notes_collections WHERE note_id = ? AND collection_id IS (SELECT id FROM collections WHERE kind = 'tag' AND name = ?)", noteId, tag) -} diff --git a/adapter/sqlite/transaction_test.go b/adapter/sqlite/transaction_test.go deleted file mode 100644 index 0937874..0000000 --- a/adapter/sqlite/transaction_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package sqlite - -import ( - "testing" - - "github.com/go-testfixtures/testfixtures/v3" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/test/assert" -) - -// testTransaction is an utility function used to test a SQLite transaction to -// the DB, which loads the default set of DB fixtures. -func testTransaction(t *testing.T, test func(tx Transaction)) { - testTransactionWithFixtures(t, opt.NewString("default"), test) -} - -// testTransactionWithFixtures is an utility function used to test a SQLite transaction to -// the DB, which loads the given set of DB fixtures. -func testTransactionWithFixtures(t *testing.T, fixturesDir opt.String, test func(tx Transaction)) { - db, err := OpenInMemory() - assert.Nil(t, err) - _, err = db.Migrate() - assert.Nil(t, err) - - if !fixturesDir.IsNull() { - fixtures, err := testfixtures.New( - testfixtures.Database(db.db), - testfixtures.Dialect("sqlite"), - testfixtures.Directory("fixtures/"+fixturesDir.String()), - // Necessary to work with an in-memory database. - testfixtures.DangerousSkipTestDatabaseCheck(), - ) - assert.Nil(t, err) - err = fixtures.Load() - assert.Nil(t, err) - } - - err = db.WithTransaction(func(tx Transaction) error { - test(tx) - return nil - }) - assert.Nil(t, err) -} - -func assertExistOrNot(t *testing.T, tx Transaction, shouldExist bool, sql string, args ...interface{}) { - if shouldExist { - assertExist(t, tx, sql, args...) - } else { - assertNotExist(t, tx, sql, args...) - } -} - -func assertExist(t *testing.T, tx Transaction, sql string, args ...interface{}) { - if !exists(t, tx, sql, args...) { - t.Errorf("SQL query did not return any result: %s, with arguments %v", sql, args) - } -} - -func assertNotExist(t *testing.T, tx Transaction, sql string, args ...interface{}) { - if exists(t, tx, sql, args...) { - t.Errorf("SQL query returned a result: %s, with arguments %v", sql, args) - } -} - -func exists(t *testing.T, tx Transaction, sql string, args ...interface{}) bool { - var exists int - err := tx.QueryRow("SELECT EXISTS ("+sql+")", args...).Scan(&exists) - assert.Nil(t, err) - return exists == 1 -} diff --git a/adapter/term/prompt.go b/adapter/term/prompt.go deleted file mode 100644 index c115cee..0000000 --- a/adapter/term/prompt.go +++ /dev/null @@ -1,20 +0,0 @@ -package term - -import ( - survey "github.com/AlecAivazis/survey/v2" -) - -// Confirm is a shortcut to prompt a yes/no question to the user. -func (t *Terminal) Confirm(msg string, defaultAnswer bool) (confirmed, skipped bool) { - if !t.IsInteractive() { - return defaultAnswer, true - } - - confirmed = false - prompt := &survey.Confirm{ - Message: msg, - Default: defaultAnswer, - } - survey.AskOne(prompt, &confirmed) - return confirmed, false -} diff --git a/adapter/term/styler.go b/adapter/term/styler.go deleted file mode 100644 index ac61d9e..0000000 --- a/adapter/term/styler.go +++ /dev/null @@ -1,120 +0,0 @@ -package term - -import ( - "fmt" - - "github.com/fatih/color" - "github.com/mickael-menu/zk/core/style" -) - -// Style implements style.Styler using ANSI escape codes to be used with a terminal. -func (t *Terminal) Style(text string, rules ...style.Rule) (string, error) { - if text == "" { - return text, nil - } - attrs, err := attributes(expandThemeAliases(rules)) - if err != nil { - return "", err - } - if len(attrs) == 0 { - return text, nil - } - return color.New(attrs...).Sprint(text), nil -} - -func (t *Terminal) MustStyle(text string, rules ...style.Rule) string { - text, err := t.Style(text, rules...) - if err != nil { - panic(err.Error()) - } - return text -} - -// FIXME: User config -var themeAliases = map[style.Rule][]style.Rule{ - "title": {"bold", "yellow"}, - "path": {"underline", "cyan"}, - "term": {"red"}, - "emphasis": {"bold", "cyan"}, - "understate": {"faint"}, -} - -func expandThemeAliases(rules []style.Rule) []style.Rule { - expanded := make([]style.Rule, 0) - for _, rule := range rules { - aliases, ok := themeAliases[rule] - if ok { - aliases = expandThemeAliases(aliases) - for _, alias := range aliases { - expanded = append(expanded, alias) - } - - } else { - expanded = append(expanded, rule) - } - } - - return expanded -} - -var attrsMapping = map[style.Rule]color.Attribute{ - style.RuleBold: color.Bold, - style.RuleFaint: color.Faint, - style.RuleItalic: color.Italic, - style.RuleUnderline: color.Underline, - style.RuleBlink: color.BlinkSlow, - style.RuleReverse: color.ReverseVideo, - style.RuleHidden: color.Concealed, - style.RuleStrikethrough: color.CrossedOut, - - style.RuleBlack: color.FgBlack, - style.RuleRed: color.FgRed, - style.RuleGreen: color.FgGreen, - style.RuleYellow: color.FgYellow, - style.RuleBlue: color.FgBlue, - style.RuleMagenta: color.FgMagenta, - style.RuleCyan: color.FgCyan, - style.RuleWhite: color.FgWhite, - - style.RuleBlackBg: color.BgBlack, - style.RuleRedBg: color.BgRed, - style.RuleGreenBg: color.BgGreen, - style.RuleYellowBg: color.BgYellow, - style.RuleBlueBg: color.BgBlue, - style.RuleMagentaBg: color.BgMagenta, - style.RuleCyanBg: color.BgCyan, - style.RuleWhiteBg: color.BgWhite, - - style.RuleBrightBlack: color.FgHiBlack, - style.RuleBrightRed: color.FgHiRed, - style.RuleBrightGreen: color.FgHiGreen, - style.RuleBrightYellow: color.FgHiYellow, - style.RuleBrightBlue: color.FgHiBlue, - style.RuleBrightMagenta: color.FgHiMagenta, - style.RuleBrightCyan: color.FgHiCyan, - style.RuleBrightWhite: color.FgHiWhite, - - style.RuleBrightBlackBg: color.BgHiBlack, - style.RuleBrightRedBg: color.BgHiRed, - style.RuleBrightGreenBg: color.BgHiGreen, - style.RuleBrightYellowBg: color.BgHiYellow, - style.RuleBrightBlueBg: color.BgHiBlue, - style.RuleBrightMagentaBg: color.BgHiMagenta, - style.RuleBrightCyanBg: color.BgHiCyan, - style.RuleBrightWhiteBg: color.BgHiWhite, -} - -func attributes(rules []style.Rule) ([]color.Attribute, error) { - attrs := make([]color.Attribute, 0) - - for _, rule := range rules { - attr, ok := attrsMapping[rule] - if !ok { - return attrs, fmt.Errorf("unknown styling rule: %v", rule) - } else { - attrs = append(attrs, attr) - } - } - - return attrs, nil -} diff --git a/cmd/init.go b/cmd/init.go deleted file mode 100644 index 4089607..0000000 --- a/cmd/init.go +++ /dev/null @@ -1,26 +0,0 @@ -package cmd - -import ( - "fmt" - "path/filepath" - - "github.com/mickael-menu/zk/core/zk" -) - -// Init creates a notebook in the given directory -type Init struct { - Directory string `arg optional type:"path" default:"." help:"Directory containing the notebook."` -} - -func (cmd *Init) Run() error { - err := zk.Create(cmd.Directory) - if err == nil { - path, err := filepath.Abs(cmd.Directory) - if err != nil { - path = cmd.Directory - } - - fmt.Printf("Initialized a notebook in %v\n", path) - } - return err -} diff --git a/cmd/list.go b/cmd/list.go deleted file mode 100644 index 198dc14..0000000 --- a/cmd/list.go +++ /dev/null @@ -1,99 +0,0 @@ -package cmd - -import ( - "fmt" - "io" - "os" - - "github.com/mickael-menu/zk/adapter" - "github.com/mickael-menu/zk/adapter/fzf" - "github.com/mickael-menu/zk/adapter/sqlite" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/strings" -) - -// List displays notes matching a set of criteria. -type List struct { - Format string `group:format short:f placeholder:TEMPLATE help:"Pretty print the list using the given format."` - Delimiter string "group:format short:d default:\n help:\"Print notes delimited by the given separator.\"" - Delimiter0 bool "group:format short:0 name:delimiter0 help:\"Print notes delimited by ASCII NUL characters. This is useful when used in conjunction with `xargs -0`.\"" - NoPager bool `group:format short:P help:"Do not pipe output into a pager."` - Quiet bool `group:format short:q help:"Do not print the total number of notes found."` - Filtering -} - -func (cmd *List) Run(container *adapter.Container) error { - if cmd.Delimiter0 { - cmd.Delimiter = "\x00" - } - - zk, err := container.Zk() - if err != nil { - return err - } - - opts, err := NewFinderOpts(zk, cmd.Filtering) - if err != nil { - return err - } - - db, _, err := container.Database(false) - if err != nil { - return err - } - - templates := container.TemplateLoader(container.Config.Note.Lang) - styler := container.Terminal - format := opt.NewNotEmptyString(cmd.Format) - formatter, err := note.NewFormatter(zk.Path, container.WorkingDir, format, templates, styler) - if err != nil { - return err - } - - var notes []note.Match - err = db.WithTransaction(func(tx sqlite.Transaction) error { - finder := container.NoteFinder(tx, fzf.NoteFinderOpts{ - AlwaysFilter: false, - PreviewCmd: container.Config.Tool.FzfPreview, - BasePath: zk.Path, - CurrentPath: container.WorkingDir, - }) - notes, err = finder.Find(*opts) - return err - }) - if err != nil { - if err == note.ErrCanceled { - return nil - } - return err - } - - count := len(notes) - if count > 0 { - err = container.Paginate(cmd.NoPager, func(out io.Writer) error { - for i, note := range notes { - if i > 0 { - fmt.Fprint(out, cmd.Delimiter) - } - - ft, err := formatter.Format(note) - if err != nil { - return err - } - fmt.Fprint(out, ft) - } - if cmd.Delimiter0 { - fmt.Fprint(out, "\x00") - } - - return nil - }) - } - - if err == nil && !cmd.Quiet { - fmt.Fprintf(os.Stderr, "\n\nFound %d %s\n", count, strings.Pluralize("note", count)) - } - - return err -} diff --git a/cmd/new.go b/cmd/new.go deleted file mode 100644 index 87c4a90..0000000 --- a/cmd/new.go +++ /dev/null @@ -1,80 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - - "github.com/mickael-menu/zk/adapter" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/core/zk" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/os" -) - -// New adds a new note to the notebook. -type New struct { - Directory string `arg optional default:"." help:"Directory in which to create the note."` - - Title string `short:t placeholder:TITLE help:"Title of the new note."` - Group string `short:g placeholder:NAME help:"Name of the config group this note belongs to. Takes precedence over the config of the directory."` - Extra map[string]string ` help:"Extra variables passed to the templates." mapsep:","` - Template string `type:path placeholder:PATH help:"Custom template used to render the note."` - PrintPath bool `short:p help:"Print the path of the created note instead of editing it."` -} - -func (cmd *New) ConfigOverrides() zk.ConfigOverrides { - return zk.ConfigOverrides{ - Group: opt.NewNotEmptyString(cmd.Group), - BodyTemplatePath: opt.NewNotEmptyString(cmd.Template), - Extra: cmd.Extra, - } -} - -func (cmd *New) Run(container *adapter.Container) error { - zk, err := container.Zk() - if err != nil { - return err - } - - dir, err := zk.RequireDirAt(cmd.Directory, cmd.ConfigOverrides()) - if err != nil { - return err - } - - content, err := os.ReadStdinPipe() - if err != nil { - return err - } - - opts := note.CreateOpts{ - Config: container.Config, - Dir: *dir, - Title: opt.NewNotEmptyString(cmd.Title), - Content: content, - } - - file, err := note.Create(opts, container.TemplateLoader(dir.Config.Note.Lang), container.Date) - if err != nil { - var noteExists note.ErrNoteExists - if !errors.As(err, ¬eExists) { - return err - } - - if confirmed, _ := container.Terminal.Confirm( - fmt.Sprintf("%s already exists, do you want to edit this note instead?", noteExists.Name), - true, - ); !confirmed { - // abort... - return nil - } - - file = noteExists.Path - } - - if cmd.PrintPath { - fmt.Printf("%+v\n", file) - return nil - } else { - return note.Edit(zk, file) - } -} diff --git a/core/ids.go b/core/ids.go deleted file mode 100644 index dc9eb93..0000000 --- a/core/ids.go +++ /dev/null @@ -1,19 +0,0 @@ -package core - -type NoteId int64 - -func (id NoteId) IsValid() bool { - return id > 0 -} - -type CollectionId int64 - -func (id CollectionId) IsValid() bool { - return id > 0 -} - -type NoteCollectionId int64 - -func (id NoteCollectionId) IsValid() bool { - return id > 0 -} diff --git a/core/note/create.go b/core/note/create.go deleted file mode 100644 index e2bf31b..0000000 --- a/core/note/create.go +++ /dev/null @@ -1,175 +0,0 @@ -package note - -import ( - "fmt" - "path/filepath" - "time" - - "github.com/mickael-menu/zk/core/templ" - "github.com/mickael-menu/zk/core/zk" - "github.com/mickael-menu/zk/util/date" - "github.com/mickael-menu/zk/util/errors" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/os" - "github.com/mickael-menu/zk/util/paths" - "github.com/mickael-menu/zk/util/rand" -) - -// CreateOpts holds the options to create a new note. -type CreateOpts struct { - // Current configuration. - Config zk.Config - // Parent directory for the new note. - Dir zk.Dir - // Title of the note. - Title opt.String - // Initial content of the note, which will be injected in the template. - Content opt.String -} - -// ErrNoteExists is an error returned when a note already exists with the -// filename generated by Create(). -type ErrNoteExists struct { - Name string - Path string -} - -func (e ErrNoteExists) Error() string { - return fmt.Sprintf("%s: note already exists", e.Path) -} - -// Create generates a new note from the given options. -// Returns the path of the newly created note. -func Create( - opts CreateOpts, - templateLoader templ.Loader, - date date.Provider, -) (string, error) { - wrap := errors.Wrapperf("new note") - - filenameTemplate, err := templateLoader.Load(opts.Dir.Config.Note.FilenameTemplate) - if err != nil { - return "", err - } - - var bodyTemplate templ.Renderer = templ.NullRenderer - if templatePath := opts.Dir.Config.Note.BodyTemplatePath.Unwrap(); templatePath != "" { - absPath, ok := opts.Config.LocateTemplate(templatePath) - if !ok { - return "", wrap(fmt.Errorf("%s: cannot find template", templatePath)) - } - bodyTemplate, err = templateLoader.LoadFile(absPath) - if err != nil { - return "", wrap(err) - } - } - - createdNote, err := create(opts, createDeps{ - filenameTemplate: filenameTemplate, - bodyTemplate: bodyTemplate, - genId: rand.NewIDGenerator(opts.Dir.Config.Note.IDOptions), - validatePath: validatePath, - now: date.Date(), - }) - if err != nil { - return "", wrap(err) - } - - err = paths.WriteString(createdNote.path, createdNote.content) - if err != nil { - return "", wrap(err) - } - - return createdNote.path, nil -} - -func validatePath(path string) (bool, error) { - exists, err := paths.Exists(path) - return !exists, err -} - -type createdNote struct { - path string - content string -} - -// renderContext holds the placeholder values which will be expanded in the templates. -type renderContext struct { - ID string `handlebars:"id"` - Title string - Content string - Dir string - Filename string - FilenameStem string `handlebars:"filename-stem"` - Extra map[string]string - Now time.Time - Env map[string]string -} - -type createDeps struct { - filenameTemplate templ.Renderer - bodyTemplate templ.Renderer - genId func() string - validatePath func(path string) (bool, error) - now time.Time -} - -func create( - opts CreateOpts, - deps createDeps, -) (*createdNote, error) { - context := renderContext{ - Title: opts.Title.OrString(opts.Dir.Config.Note.DefaultTitle).Unwrap(), - Content: opts.Content.Unwrap(), - Dir: opts.Dir.Name, - Extra: opts.Dir.Config.Extra, - Now: deps.now, - Env: os.Env(), - } - - path, context, err := genPath(context, opts.Dir, deps) - if err != nil { - return nil, err - } - - content, err := deps.bodyTemplate.Render(context) - if err != nil { - return nil, err - } - - return &createdNote{path: path, content: content}, nil -} - -func genPath( - context renderContext, - dir zk.Dir, - deps createDeps, -) (string, renderContext, error) { - var err error - var filename string - var path string - for i := 0; i < 50; i++ { - context.ID = deps.genId() - - filename, err = deps.filenameTemplate.Render(context) - if err != nil { - return "", context, err - } - - filename = filename + "." + dir.Config.Note.Extension - path = filepath.Join(dir.Path, filename) - validPath, err := deps.validatePath(path) - if err != nil { - return "", context, err - } else if validPath { - context.Filename = filepath.Base(path) - context.FilenameStem = paths.FilenameStem(path) - return path, context, nil - } - } - - return "", context, ErrNoteExists{ - Name: filepath.Join(dir.Name, filename), - Path: path, - } -} diff --git a/core/note/create_test.go b/core/note/create_test.go deleted file mode 100644 index 49e1acd..0000000 --- a/core/note/create_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package note - -import ( - "fmt" - "testing" - - "github.com/mickael-menu/zk/core/templ" - "github.com/mickael-menu/zk/core/zk" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/os" - "github.com/mickael-menu/zk/util/test/assert" -) - -func TestCreate(t *testing.T) { - filenameTemplate := NewRendererSpyString("filename") - bodyTemplate := NewRendererSpyString("body") - - res, err := create( - CreateOpts{ - Dir: zk.Dir{ - Name: "log", - Path: "/test/log", - Config: zk.GroupConfig{ - Note: zk.NoteConfig{ - Extension: "md", - }, - Extra: map[string]string{ - "hello": "world", - }, - }, - }, - Title: opt.NewString("Note title"), - Content: opt.NewString("Note content"), - }, - createDeps{ - filenameTemplate: filenameTemplate, - bodyTemplate: bodyTemplate, - genId: func() string { return "abc" }, - validatePath: func(path string) (bool, error) { return true, nil }, - now: Now, - }, - ) - - // Check the created note. - assert.Nil(t, err) - assert.Equal(t, res, &createdNote{ - path: "/test/log/filename.md", - content: "body", - }) - - // Check that the templates received the proper render contexts. - assert.Equal(t, filenameTemplate.Contexts, []interface{}{renderContext{ - ID: "abc", - Title: "Note title", - Content: "Note content", - Dir: "log", - Extra: map[string]string{ - "hello": "world", - }, - Now: Now, - Env: os.Env(), - }}) - assert.Equal(t, bodyTemplate.Contexts, []interface{}{renderContext{ - ID: "abc", - Title: "Note title", - Content: "Note content", - Dir: "log", - Filename: "filename.md", - FilenameStem: "filename", - Extra: map[string]string{ - "hello": "world", - }, - Now: Now, - Env: os.Env(), - }}) -} - -func TestCreateTriesUntilValidPath(t *testing.T) { - filenameTemplate := NewRendererSpy(func(context interface{}) string { - return context.(renderContext).ID - }) - bodyTemplate := NewRendererSpyString("body") - - res, err := create( - CreateOpts{ - Dir: zk.Dir{ - Name: "log", - Path: "/test/log", - Config: zk.GroupConfig{ - Note: zk.NoteConfig{ - Extension: "md", - }, - }, - }, - Title: opt.NewString("Note title"), - }, - createDeps{ - filenameTemplate: filenameTemplate, - bodyTemplate: bodyTemplate, - genId: incrementingID(), - validatePath: func(path string) (bool, error) { - return path == "/test/log/3.md", nil - }, - now: Now, - }, - ) - - // Check the created note. - assert.Nil(t, err) - assert.Equal(t, res, &createdNote{ - path: "/test/log/3.md", - content: "body", - }) - - assert.Equal(t, filenameTemplate.Contexts, []interface{}{ - renderContext{ - ID: "1", - Title: "Note title", - Dir: "log", - Now: Now, - Env: os.Env(), - }, - renderContext{ - ID: "2", - Title: "Note title", - Dir: "log", - Now: Now, - Env: os.Env(), - }, - renderContext{ - ID: "3", - Title: "Note title", - Dir: "log", - Now: Now, - Env: os.Env(), - }, - }) -} - -func TestCreateErrorWhenNoValidPaths(t *testing.T) { - _, err := create( - CreateOpts{ - Dir: zk.Dir{ - Name: "log", - Path: "/test/log", - Config: zk.GroupConfig{ - Note: zk.NoteConfig{ - Extension: "md", - }, - }, - }, - }, - createDeps{ - filenameTemplate: templ.RendererFunc(func(context interface{}) (string, error) { - return "filename", nil - }), - bodyTemplate: templ.NullRenderer, - genId: func() string { return "abc" }, - validatePath: func(path string) (bool, error) { return false, nil }, - now: Now, - }, - ) - - assert.Err(t, err, "/test/log/filename.md: note already exists") -} - -// incrementingID returns a generator of incrementing string ID. -func incrementingID() func() string { - i := 0 - return func() string { - i++ - return fmt.Sprintf("%d", i) - } -} diff --git a/core/note/edit.go b/core/note/edit.go deleted file mode 100644 index a226945..0000000 --- a/core/note/edit.go +++ /dev/null @@ -1,41 +0,0 @@ -package note - -import ( - "fmt" - "os" - "strings" - - "github.com/kballard/go-shellquote" - "github.com/mickael-menu/zk/core/zk" - "github.com/mickael-menu/zk/util/errors" - executil "github.com/mickael-menu/zk/util/exec" - "github.com/mickael-menu/zk/util/opt" - osutil "github.com/mickael-menu/zk/util/os" -) - -// Edit starts the editor with the notes at given paths. -func Edit(zk *zk.Zk, paths ...string) error { - editor := editor(zk) - if editor.IsNull() { - return fmt.Errorf("no editor set in config") - } - - // /dev/tty is restored as stdin, in case the user used a pipe to feed - // initial note content to `zk new`. Without this, Vim doesn't work - // properly in this case. - // See https://github.com/mickael-menu/zk/issues/4 - cmd := executil.CommandFromString(editor.String() + " " + shellquote.Join(paths...) + " (.*?)`) - -// Format formats a note to be printed on the screen. -func (f *Formatter) Format(match Match) (string, error) { - path, err := filepath.Rel(f.currentPath, filepath.Join(f.basePath, match.Path)) - if err != nil { - return "", err - } - - snippets := make([]string, 0) - for _, snippet := range match.Snippets { - snippets = append(snippets, termRegex.ReplaceAllString(snippet, f.snippetTermReplacement)) - } - - return f.renderer.Render(formatRenderContext{ - Path: path, - Title: match.Title, - Lead: match.Lead, - Body: match.Body, - Snippets: snippets, - Tags: match.Tags, - RawContent: match.RawContent, - WordCount: match.WordCount, - Metadata: match.Metadata.Metadata, - Created: match.Created, - Modified: match.Modified, - Checksum: match.Checksum, - }) -} - -type formatRenderContext struct { - Path string - Title string - Lead string - Body string - Snippets []string - RawContent string `handlebars:"raw-content"` - WordCount int `handlebars:"word-count"` - Tags []string - Metadata map[string]interface{} - Created time.Time - Modified time.Time - Checksum string - Env map[string]string -} diff --git a/core/note/format_test.go b/core/note/format_test.go deleted file mode 100644 index ce69fbf..0000000 --- a/core/note/format_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package note - -import ( - "testing" - "time" - - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/test/assert" -) - -func TestEmptyFormat(t *testing.T) { - f, _ := newFormatter(t, opt.NewString("")) - res, err := f.Format(Match{}) - assert.Nil(t, err) - assert.Equal(t, res, "") -} - -func TestDefaultFormat(t *testing.T) { - f, _ := newFormatter(t, opt.NullString) - res, err := f.Format(Match{}) - assert.Nil(t, err) - assert.Equal(t, res, `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}}) - -{{list snippets}}`) -} - -func TestFormats(t *testing.T) { - test := func(format string, expected string) { - f, _ := newFormatter(t, opt.NewString(format)) - actual, err := f.Format(Match{}) - assert.Nil(t, err) - assert.Equal(t, actual, expected) - } - - // Known formats - test("path", `{{path}}`) - - test("oneline", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})`) - - test("short", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}}) - -{{list snippets}}`) - - test("medium", `{{style "title" title}} {{style "path" path}} -Created: {{date created "short"}} - -{{list snippets}}`) - - test("long", `{{style "title" title}} {{style "path" path}} -Created: {{date created "short"}} -Modified: {{date created "short"}} - -{{list snippets}}`) - - test("full", `{{style "title" title}} {{style "path" path}} -Created: {{date created "short"}} -Modified: {{date created "short"}} -Tags: {{join tags ", "}} - -{{prepend " " body}} -`) - - // Known formats are case sensitive. - test("Path", "Path") - - // Custom formats are used literally. - test("{{title}}", "{{title}}") - - // \n and \t in custom formats are expanded. - test(`{{title}}\t{{path}}\n{{snippet}}`, "{{title}}\t{{path}}\n{{snippet}}") -} - -func TestFormatRenderContext(t *testing.T) { - f, templs := newFormatter(t, opt.NewString("path")) - - _, err := f.Format(Match{ - Snippets: []string{"Note snippet"}, - Metadata: Metadata{ - Path: "dir/note.md", - Title: "Note title", - Lead: "Lead paragraph", - Body: "Note body", - RawContent: "Raw content", - WordCount: 42, - Created: Now, - Modified: Now.Add(48 * time.Hour), - Checksum: "Note checksum", - }, - }) - assert.Nil(t, err) - - // Check that the template was provided with the proper information in the - // render context. - assert.Equal(t, templs.Contexts, []interface{}{ - formatRenderContext{ - Path: "dir/note.md", - Title: "Note title", - Lead: "Lead paragraph", - Body: "Note body", - Snippets: []string{"Note snippet"}, - RawContent: "Raw content", - WordCount: 42, - Created: Now, - Modified: Now.Add(48 * time.Hour), - Checksum: "Note checksum", - }, - }) -} - -func TestFormatPath(t *testing.T) { - test := func(basePath, currentPath, path string, expected string) { - f, templs := newFormatterWithPaths(t, basePath, currentPath, opt.NullString) - _, err := f.Format(Match{ - Metadata: Metadata{Path: path}, - }) - assert.Nil(t, err) - assert.Equal(t, templs.Contexts, []interface{}{ - formatRenderContext{ - Path: expected, - Snippets: []string{}, - }, - }) - } - - // Check that the path is relative to the current directory. - test("", "", "note.md", "note.md") - test("", "", "dir/note.md", "dir/note.md") - test("/abs/zk", "/abs/zk", "note.md", "note.md") - test("/abs/zk", "/abs/zk", "dir/note.md", "dir/note.md") - test("/abs/zk", "/abs/zk/dir", "note.md", "../note.md") - test("/abs/zk", "/abs/zk/dir", "dir/note.md", "note.md") - test("/abs/zk", "/abs", "note.md", "zk/note.md") - test("/abs/zk", "/abs", "dir/note.md", "zk/dir/note.md") -} - -func TestFormatStylesSnippetTerm(t *testing.T) { - test := func(snippet string, expected string) { - f, templs := newFormatter(t, opt.NullString) - _, err := f.Format(Match{ - Snippets: []string{snippet}, - }) - assert.Nil(t, err) - assert.Equal(t, templs.Contexts, []interface{}{ - formatRenderContext{ - Path: ".", - Snippets: []string{expected}, - }, - }) - } - - test("Hello world!", "Hello world!") - test("Hello world!", "Hello term(world)!") - test("Hello world with several matches!", "Hello term(world) with term(several matches)!") - test("Hello world with several matches!", "Hello term(world) with term(several matches)!") -} - -func newFormatter(t *testing.T, format opt.String) (*Formatter, *TemplLoaderSpy) { - return newFormatterWithPaths(t, "", "", format) -} - -func newFormatterWithPaths(t *testing.T, basePath, currentPath string, format opt.String) (*Formatter, *TemplLoaderSpy) { - loader := NewTemplLoaderSpy() - styler := &StylerMock{} - formatter, err := NewFormatter(basePath, currentPath, format, loader, styler) - assert.Nil(t, err) - return formatter, loader -} diff --git a/core/note/index.go b/core/note/index.go deleted file mode 100644 index 88ad00a..0000000 --- a/core/note/index.go +++ /dev/null @@ -1,192 +0,0 @@ -package note - -import ( - "crypto/sha256" - "fmt" - "io/ioutil" - "path/filepath" - "strings" - "time" - - "github.com/mickael-menu/zk/core" - "github.com/mickael-menu/zk/core/zk" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/errors" - "github.com/mickael-menu/zk/util/paths" - strutil "github.com/mickael-menu/zk/util/strings" - "github.com/relvacode/iso8601" - "gopkg.in/djherbis/times.v1" -) - -// Metadata holds information about a particular note. -type Metadata struct { - Path string - Title string - Lead string - Body string - RawContent string - WordCount int - Links []Link - Tags []string - Metadata map[string]interface{} - Created time.Time - Modified time.Time - Checksum string -} - -// IndexingStats holds metrics about an indexing process. -type IndexingStats struct { - SourceCount int - AddedCount int - ModifiedCount int - RemovedCount int - Duration time.Duration -} - -// String implements Stringer -func (s IndexingStats) String() string { - return fmt.Sprintf(`Indexed %d %v in %v - + %d added - ~ %d modified - - %d removed`, - s.SourceCount, - strutil.Pluralize("note", s.SourceCount), - s.Duration.Round(500*time.Millisecond), - s.AddedCount, s.ModifiedCount, s.RemovedCount, - ) -} - -// Indexer persists the notes index. -type Indexer interface { - // Indexed returns the list of indexed note file metadata. - Indexed() (<-chan paths.Metadata, error) - // Add indexes a new note from its metadata. - Add(metadata Metadata) (core.NoteId, error) - // Update updates the metadata of an already indexed note. - Update(metadata Metadata) error - // Remove deletes a note from the index. - Remove(path string) error -} - -// Index indexes the content of the notes in the given notebook. -func Index(zk *zk.Zk, force bool, parser Parser, indexer Indexer, logger util.Logger, callback func(change paths.DiffChange)) (IndexingStats, error) { - wrap := errors.Wrapper("indexing failed") - - stats := IndexingStats{} - startTime := time.Now() - - // FIXME: Use Extension defined in each DirConfig. - source := paths.Walk(zk.Path, zk.Config.Note.Extension, logger) - target, err := indexer.Indexed() - if err != nil { - return stats, wrap(err) - } - - count, err := paths.Diff(source, target, force, func(change paths.DiffChange) error { - callback(change) - - switch change.Kind { - case paths.DiffAdded: - stats.AddedCount += 1 - metadata, err := metadata(change.Path, zk, parser) - if err == nil { - _, err = indexer.Add(metadata) - } - logger.Err(err) - - case paths.DiffModified: - stats.ModifiedCount += 1 - metadata, err := metadata(change.Path, zk, parser) - if err == nil { - err = indexer.Update(metadata) - } - logger.Err(err) - - case paths.DiffRemoved: - stats.RemovedCount += 1 - err := indexer.Remove(change.Path) - logger.Err(err) - } - return nil - }) - - stats.SourceCount = count - stats.Duration = time.Since(startTime) - - return stats, wrap(err) -} - -// metadata retrieves note metadata for the given file. -func metadata(path string, zk *zk.Zk, parser Parser) (Metadata, error) { - metadata := Metadata{ - Path: path, - Links: []Link{}, - Tags: []string{}, - } - - absPath := filepath.Join(zk.Path, path) - content, err := ioutil.ReadFile(absPath) - if err != nil { - return metadata, err - } - contentStr := string(content) - contentParts, err := parser.Parse(contentStr) - if err != nil { - return metadata, err - } - metadata.Title = contentParts.Title.String() - metadata.Lead = contentParts.Lead.String() - metadata.Body = contentParts.Body.String() - metadata.RawContent = contentStr - metadata.WordCount = len(strings.Fields(contentStr)) - metadata.Links = make([]Link, 0) - metadata.Tags = contentParts.Tags - metadata.Metadata = contentParts.Metadata - metadata.Checksum = fmt.Sprintf("%x", sha256.Sum256(content)) - - for _, link := range contentParts.Links { - if !strutil.IsURL(link.Href) { - // Make the href relative to the notebook root. - href := filepath.Join(filepath.Dir(absPath), link.Href) - link.Href, err = zk.RelPath(href) - if err != nil { - return metadata, err - } - } - metadata.Links = append(metadata.Links, link) - } - - times, err := times.Stat(absPath) - if err != nil { - return metadata, err - } - - metadata.Modified = times.ModTime().UTC() - metadata.Created = creationDateFrom(metadata.Metadata, times) - - return metadata, nil -} - -func creationDateFrom(metadata map[string]interface{}, times times.Timespec) time.Time { - // Read the creation date from the YAML frontmatter `date` key. - if dateVal, ok := metadata["date"]; ok { - if dateStr, ok := dateVal.(string); ok { - if time, err := iso8601.ParseString(dateStr); err == nil { - return time - } - // Omitting the `T` is common - if time, err := time.Parse("2006-01-02 15:04:05", dateStr); err == nil { - return time - } - if time, err := time.Parse("2006-01-02 15:04", dateStr); err == nil { - return time - } - } - } - - if times.HasBirthTime() { - return times.BirthTime().UTC() - } - - return time.Now().UTC() -} diff --git a/core/note/parse.go b/core/note/parse.go deleted file mode 100644 index 45be872..0000000 --- a/core/note/parse.go +++ /dev/null @@ -1,59 +0,0 @@ -package note - -import ( - "github.com/mickael-menu/zk/util/opt" -) - -type Content struct { - // Title is the heading of the note. - Title opt.String - // Lead is the opening paragraph or section of the note. - Lead opt.String - // Body is the content of the note, including the Lead but without the Title. - Body opt.String - // Tags is the list of tags found in the note content. - Tags []string - // Links is the list of outbound links found in the note. - Links []Link - // Additional metadata. For example, extracted from a YAML frontmatter. - Metadata map[string]interface{} -} - -// Link links a note to another note or an external resource. -type Link struct { - Title string - Href string - External bool - Rels []string - Snippet string - SnippetStart int - SnippetEnd int -} - -// LinkRelation defines the relationship between a link's source and target. -type LinkRelation string - -const ( - // LinkRelationDown defines the target note as a child of the source. - LinkRelationDown LinkRelation = "down" - // LinkRelationDown defines the target note as a parent of the source. - LinkRelationUp LinkRelation = "up" -) - -type Parser interface { - Parse(source string) (*Content, error) -} - -// Collection holds metadata about a note collection. -type Collection struct { - Kind CollectionKind - Name string - NoteCount int -} - -// CollectionKind defines a kind of note collection, such as tags. -type CollectionKind string - -const ( - CollectionKindTag CollectionKind = "tag" -) diff --git a/core/note/util_test.go b/core/note/util_test.go deleted file mode 100644 index 02d4185..0000000 --- a/core/note/util_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package note - -import ( - "fmt" - "time" - - "github.com/mickael-menu/zk/core/style" - "github.com/mickael-menu/zk/core/templ" -) - -var Now = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) - -// TemplLoaderSpy implements templ.Loader and saves the render contexts -// provided to the templates it creates. -// -// The generated Renderer returns the template used to create them without -// modification. -type TemplLoaderSpy struct { - Contexts []interface{} -} - -func NewTemplLoaderSpy() *TemplLoaderSpy { - return &TemplLoaderSpy{ - Contexts: make([]interface{}, 0), - } -} - -func (l *TemplLoaderSpy) Load(template string) (templ.Renderer, error) { - return NewRendererSpy(func(context interface{}) string { - l.Contexts = append(l.Contexts, context) - return template - }), nil -} - -func (l *TemplLoaderSpy) LoadFile(path string) (templ.Renderer, error) { - panic("not implemented") -} - -// RendererSpy implements templ.Renderer and saves the provided render contexts. -type RendererSpy struct { - Result func(interface{}) string - Contexts []interface{} -} - -func NewRendererSpy(result func(interface{}) string) *RendererSpy { - return &RendererSpy{ - Contexts: make([]interface{}, 0), - Result: result, - } -} - -func NewRendererSpyString(result string) *RendererSpy { - return &RendererSpy{ - Contexts: make([]interface{}, 0), - Result: func(_ interface{}) string { return result }, - } -} - -func (m *RendererSpy) Render(context interface{}) (string, error) { - m.Contexts = append(m.Contexts, context) - return m.Result(context), nil -} - -// StylerMock implements core.Styler by doing the transformation: -// "hello", "red" -> "red(hello)" -type StylerMock struct{} - -func (s *StylerMock) Style(text string, rules ...style.Rule) (string, error) { - return s.MustStyle(text, rules...), nil -} - -func (s *StylerMock) MustStyle(text string, rules ...style.Rule) string { - for _, rule := range rules { - text = fmt.Sprintf("%s(%s)", rule, text) - } - return text -} diff --git a/core/style/style.go b/core/style/style.go deleted file mode 100644 index bce947b..0000000 --- a/core/style/style.go +++ /dev/null @@ -1,80 +0,0 @@ -package style - -// Styler stylizes text according to predefined styling rules. -// -// A rule key can be either semantic, e.g. "title" or explicit, e.g. "red". -type Styler interface { - Style(text string, rules ...Rule) (string, error) - MustStyle(text string, rules ...Rule) string -} - -// Rule is a key representing a single styling rule. -type Rule string - -// Predefined styling rules. -var ( - // Title of a note. - RuleTitle = Rule("title") - // Path to notebook file. - RulePath = Rule("path") - // Searched for term in a note. - RuleTerm = Rule("term") - // Element to emphasize, for example the short version of a prompt response: [y]es. - RuleEmphasis = Rule("emphasis") - // Element to understate, for example the content of the note in fzf. - RuleUnderstate = Rule("understate") - - RuleBold = Rule("bold") - RuleItalic = Rule("italic") - RuleFaint = Rule("faint") - RuleUnderline = Rule("underline") - RuleStrikethrough = Rule("strikethrough") - RuleBlink = Rule("blink") - RuleReverse = Rule("reverse") - RuleHidden = Rule("hidden") - - RuleBlack = Rule("black") - RuleRed = Rule("red") - RuleGreen = Rule("green") - RuleYellow = Rule("yellow") - RuleBlue = Rule("blue") - RuleMagenta = Rule("magenta") - RuleCyan = Rule("cyan") - RuleWhite = Rule("white") - - RuleBlackBg = Rule("black-bg") - RuleRedBg = Rule("red-bg") - RuleGreenBg = Rule("green-bg") - RuleYellowBg = Rule("yellow-bg") - RuleBlueBg = Rule("blue-bg") - RuleMagentaBg = Rule("magenta-bg") - RuleCyanBg = Rule("cyan-bg") - RuleWhiteBg = Rule("white-bg") - - RuleBrightBlack = Rule("bright-black") - RuleBrightRed = Rule("bright-red") - RuleBrightGreen = Rule("bright-green") - RuleBrightYellow = Rule("bright-yellow") - RuleBrightBlue = Rule("bright-blue") - RuleBrightMagenta = Rule("bright-magenta") - RuleBrightCyan = Rule("bright-cyan") - RuleBrightWhite = Rule("bright-white") - - RuleBrightBlackBg = Rule("bright-black-bg") - RuleBrightRedBg = Rule("bright-red-bg") - RuleBrightGreenBg = Rule("bright-green-bg") - RuleBrightYellowBg = Rule("bright-yellow-bg") - RuleBrightBlueBg = Rule("bright-blue-bg") - RuleBrightMagentaBg = Rule("bright-magenta-bg") - RuleBrightCyanBg = Rule("bright-cyan-bg") - RuleBrightWhiteBg = Rule("bright-white-bg") -) - -// NullStyler is a Styler with no styling rules. -var NullStyler = nullStyler{} - -type nullStyler struct{} - -func (s nullStyler) Style(text string, rule ...Rule) (string, error) { - return text, nil -} diff --git a/core/templ/templ.go b/core/templ/templ.go deleted file mode 100644 index 104aa0f..0000000 --- a/core/templ/templ.go +++ /dev/null @@ -1,28 +0,0 @@ -package templ - -// Loader parses a given string template. -type Loader interface { - Load(template string) (Renderer, error) - LoadFile(path string) (Renderer, error) -} - -// Renderer produces a string using a given context. -type Renderer interface { - Render(context interface{}) (string, error) -} - -// RendererFunc is an adapter to use a function as a Renderer. -type RendererFunc func(context interface{}) (string, error) - -func (f RendererFunc) Render(context interface{}) (string, error) { - return f(context) -} - -// NullRenderer is a Renderer always returning an empty string. -var NullRenderer = nullRenderer{} - -type nullRenderer struct{} - -func (t nullRenderer) Render(context interface{}) (string, error) { - return "", nil -} diff --git a/core/zk/zk_test.go b/core/zk/zk_test.go deleted file mode 100644 index d0b8898..0000000 --- a/core/zk/zk_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package zk - -import ( - "os" - "path/filepath" - "testing" - - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/test/assert" -) - -func TestDBPath(t *testing.T) { - wd, _ := os.Getwd() - zk := &Zk{Path: wd} - - assert.Equal(t, zk.DBPath(), filepath.Join(wd, ".zk/notebook.db")) -} - -func TestRootDir(t *testing.T) { - wd, _ := os.Getwd() - zk := &Zk{Path: wd} - - assert.Equal(t, zk.RootDir(), Dir{ - Name: "", - Path: wd, - Config: zk.Config.RootGroupConfig(), - }) -} - -func TestRelativePathFromGivenPath(t *testing.T) { - // The tests are relative to the working directory, for convenience. - wd, _ := os.Getwd() - - zk := &Zk{Path: wd} - - for path, expected := range map[string]string{ - "log": "log", - "log/sub": "log/sub", - "log/sub/..": "log", - "log/sub/../sub": "log/sub", - filepath.Join(wd, "log"): "log", - filepath.Join(wd, "log/sub"): "log/sub", - } { - actual, err := zk.RelPath(path) - assert.Nil(t, err) - assert.Equal(t, actual, expected) - } -} - -func TestDirAtGivenPath(t *testing.T) { - // The tests are relative to the working directory, for convenience. - wd, _ := os.Getwd() - - zk := &Zk{Path: wd} - - for path, name := range map[string]string{ - "log": "log", - "log/sub": "log/sub", - "log/sub/..": "log", - "log/sub/../sub": "log/sub", - filepath.Join(wd, "log"): "log", - filepath.Join(wd, "log/sub"): "log/sub", - } { - actual, err := zk.DirAt(path) - assert.Nil(t, err) - assert.Equal(t, actual.Name, name) - assert.Equal(t, actual.Path, filepath.Join(wd, name)) - } -} - -func TestDirAtOutsideNotebook(t *testing.T) { - wd, _ := os.Getwd() - zk := &Zk{Path: wd} - - for _, path := range []string{ - "..", - "../..", - "/tmp", - } { - _, err := zk.DirAt(path) - assert.Err(t, err, "path is outside the notebook") - } -} - -// When requesting the root directory `.`, the config is the default one. -func TestDirAtRoot(t *testing.T) { - wd, _ := os.Getwd() - - zk := Zk{ - Path: wd, - Config: Config{ - Note: NoteConfig{ - FilenameTemplate: "{{id}}.note", - BodyTemplatePath: opt.NewString("default.note"), - IDOptions: IDOptions{ - Length: 4, - Charset: CharsetAlphanum, - Case: CaseLower, - }, - }, - Groups: map[string]GroupConfig{ - "log": { - Note: NoteConfig{ - FilenameTemplate: "{{date}}.md", - }, - }, - }, - Extra: map[string]string{ - "hello": "world", - }, - }, - } - - dir, err := zk.DirAt(".") - assert.Nil(t, err) - assert.Equal(t, dir.Name, "") - assert.Equal(t, dir.Path, wd) - assert.Equal(t, dir.Config, GroupConfig{ - Paths: []string{}, - Note: NoteConfig{ - FilenameTemplate: "{{id}}.note", - BodyTemplatePath: opt.NewString("default.note"), - IDOptions: IDOptions{ - Length: 4, - Charset: CharsetAlphanum, - Case: CaseLower, - }, - }, - Extra: map[string]string{ - "hello": "world", - }, - }) -} - -// When requesting a directory, the matching GroupConfig will be returned. -func TestDirAtFindsGroup(t *testing.T) { - wd, _ := os.Getwd() - - zk := Zk{ - Path: wd, - Config: Config{ - Groups: map[string]GroupConfig{ - "ref": { - Paths: []string{"ref"}, - }, - "log": { - Paths: []string{"journal/daily", "journal/weekly"}, - }, - "glob": { - Paths: []string{"glob/*"}, - }, - }, - }, - } - - dir, err := zk.DirAt("ref") - assert.Nil(t, err) - assert.Equal(t, dir.Config.Paths, []string{"ref"}) - - dir, err = zk.DirAt("journal/weekly") - assert.Nil(t, err) - assert.Equal(t, dir.Config.Paths, []string{"journal/daily", "journal/weekly"}) - - dir, err = zk.DirAt("glob/qwfpgj") - assert.Nil(t, err) - assert.Equal(t, dir.Config.Paths, []string{"glob/*"}) - - dir, err = zk.DirAt("glob/qwfpgj/no") - assert.Nil(t, err) - assert.Equal(t, dir.Config.Paths, []string{}) - - dir, err = zk.DirAt("glob") - assert.Nil(t, err) - assert.Equal(t, dir.Config.Paths, []string{}) -} - -// Modifying the GroupConfig of the returned Dir should not modify the global config. -func TestDirAtReturnsClonedConfig(t *testing.T) { - wd, _ := os.Getwd() - zk := Zk{ - Path: wd, - Config: Config{ - Note: NoteConfig{ - FilenameTemplate: "{{id}}.note", - BodyTemplatePath: opt.NewString("default.note"), - IDOptions: IDOptions{ - Length: 4, - Charset: CharsetAlphanum, - Case: CaseLower, - }, - }, - Extra: map[string]string{ - "hello": "world", - }, - }, - } - - dir, err := zk.DirAt(".") - assert.Nil(t, err) - - dir.Config.Note.FilenameTemplate = "modified" - dir.Config.Note.BodyTemplatePath = opt.NewString("modified") - dir.Config.Note.IDOptions.Length = 41 - dir.Config.Note.IDOptions.Charset = CharsetNumbers - dir.Config.Note.IDOptions.Case = CaseUpper - dir.Config.Extra["test"] = "modified" - - assert.Equal(t, zk.Config.RootGroupConfig(), GroupConfig{ - Paths: []string{}, - Note: NoteConfig{ - FilenameTemplate: "{{id}}.note", - BodyTemplatePath: opt.NewString("default.note"), - IDOptions: IDOptions{ - Length: 4, - Charset: CharsetAlphanum, - Case: CaseLower, - }, - }, - Extra: map[string]string{ - "hello": "world", - }, - }) -} - -func TestDirAtWithOverrides(t *testing.T) { - wd, _ := os.Getwd() - zk := Zk{ - Path: wd, - Config: Config{ - Note: NoteConfig{ - FilenameTemplate: "{{id}}.note", - BodyTemplatePath: opt.NewString("default.note"), - IDOptions: IDOptions{ - Length: 4, - Charset: CharsetLetters, - Case: CaseUpper, - }, - }, - Extra: map[string]string{ - "hello": "world", - }, - Groups: map[string]GroupConfig{ - "group": { - Paths: []string{"group-path"}, - Note: NoteConfig{ - BodyTemplatePath: opt.NewString("group.note"), - }, - Extra: map[string]string{}, - }, - }, - }, - } - - dir, err := zk.DirAt(".", - ConfigOverrides{ - BodyTemplatePath: opt.NewString("overridden-template"), - Extra: map[string]string{ - "hello": "overridden", - "additional": "value", - }, - }, - ConfigOverrides{ - Extra: map[string]string{ - "additional": "value2", - "additional2": "value3", - }, - }, - ) - - assert.Nil(t, err) - assert.Equal(t, dir.Config, GroupConfig{ - Paths: []string{}, - Note: NoteConfig{ - FilenameTemplate: "{{id}}.note", - BodyTemplatePath: opt.NewString("overridden-template"), - IDOptions: IDOptions{ - Length: 4, - Charset: CharsetLetters, - Case: CaseUpper, - }, - }, - Extra: map[string]string{ - "hello": "overridden", - "additional": "value2", - "additional2": "value3", - }, - }) - - // Overriding the group will select a different group config. - dir, err = zk.DirAt(".", ConfigOverrides{Group: opt.NewString("group")}) - assert.Nil(t, err) - assert.Equal(t, dir.Config, GroupConfig{ - Paths: []string{"group-path"}, - Note: NoteConfig{ - BodyTemplatePath: opt.NewString("group.note"), - }, - Extra: map[string]string{}, - }) - - // An unknown group override returns an error. - _, err = zk.DirAt(".", ConfigOverrides{Group: opt.NewString("foobar")}) - assert.Err(t, err, "foobar: group not find in the config file") - - // Check that the original config was not modified. - assert.Equal(t, zk.Config.RootGroupConfig(), GroupConfig{ - Paths: []string{}, - Note: NoteConfig{ - FilenameTemplate: "{{id}}.note", - BodyTemplatePath: opt.NewString("default.note"), - IDOptions: IDOptions{ - Length: 4, - Charset: CharsetLetters, - Case: CaseUpper, - }, - }, - Extra: map[string]string{ - "hello": "world", - }, - }) -} diff --git a/internal/adapter/editor/editor.go b/internal/adapter/editor/editor.go new file mode 100644 index 0000000..39ec0ae --- /dev/null +++ b/internal/adapter/editor/editor.go @@ -0,0 +1,47 @@ +package editor + +import ( + "fmt" + "os" + "strings" + + "github.com/kballard/go-shellquote" + "github.com/mickael-menu/zk/internal/util/errors" + executil "github.com/mickael-menu/zk/internal/util/exec" + "github.com/mickael-menu/zk/internal/util/opt" + osutil "github.com/mickael-menu/zk/internal/util/os" +) + +// Editor represents an external editor able to edit the notes. +type Editor struct { + editor string +} + +// NewEditor creates a new Editor from the given editor user setting or the +// matching environment variables. +func NewEditor(editor opt.String) (*Editor, error) { + editor = osutil.GetOptEnv("ZK_EDITOR"). + Or(editor). + Or(osutil.GetOptEnv("VISUAL")). + Or(osutil.GetOptEnv("EDITOR")) + + if editor.IsNull() { + return nil, fmt.Errorf("no editor set in config") + } + + return &Editor{editor.Unwrap()}, nil +} + +// Open launches the editor with the notes at given paths. +func (e *Editor) Open(paths ...string) error { + // /dev/tty is restored as stdin, in case the user used a pipe to feed + // initial note content to `zk new`. Without this, Vim doesn't work + // properly in this case. + // See https://github.com/mickael-menu/zk/issues/4 + cmd := executil.CommandFromString(e.editor + " " + shellquote.Join(paths...) + " /dev/tty)", zkBin, dir.Path), + }) + } + + previewCmd := f.opts.PreviewCmd.OrString("cat {-1}").Unwrap() + if previewCmd != "" { + // The note paths will be relative to the current path, so we need to + // move there otherwise the preview command will fail. + previewCmd = `cd "` + f.opts.WorkingDir + `" && ` + previewCmd + } + + fzf, err := New(Opts{ + PreviewCmd: opt.NewNotEmptyString(previewCmd), + Padding: 2, + Bindings: bindings, + }) + if err != nil { + return selectedNotes, err + } + + for i, note := range notes { + title := note.Title + if title == "" { + title = relPaths[i] + } + fzf.Add([]string{ + f.terminal.MustStyle(title, core.StyleYellow), + f.terminal.MustStyle(stringsutil.JoinLines(note.Body), core.StyleUnderstate), + f.terminal.MustStyle(relPaths[i], core.StyleUnderstate), + }) + } + + selection, err := fzf.Selection() + if err != nil { + return selectedNotes, err + } + + for _, s := range selection { + path := s[len(s)-1] + for i, m := range notes { + if relPaths[i] == path { + selectedNotes = append(selectedNotes, m) + } + } + } + + return selectedNotes, nil +} diff --git a/internal/adapter/handlebars/handlebars.go b/internal/adapter/handlebars/handlebars.go new file mode 100644 index 0000000..bf615f7 --- /dev/null +++ b/internal/adapter/handlebars/handlebars.go @@ -0,0 +1,153 @@ +package handlebars + +import ( + "fmt" + "html" + "path/filepath" + + "github.com/aymerick/raymond" + "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/errors" + "github.com/mickael-menu/zk/internal/util/paths" +) + +func Init(supportsUTF8 bool, logger util.Logger) { + helpers.RegisterConcat() + helpers.RegisterDate(logger) + helpers.RegisterJoin() + helpers.RegisterList(supportsUTF8) + helpers.RegisterPrepend(logger) + helpers.RegisterShell(logger) +} + +// Template renders a parsed handlebars template. +type Template struct { + template *raymond.Template + styler core.Styler +} + +// Styler implements core.Template. +func (t *Template) Styler() core.Styler { + return t.styler +} + +// Render implements core.Template. +func (t *Template) Render(context interface{}) (string, error) { + res, err := t.template.Exec(context) + if err != nil { + return "", errors.Wrap(err, "render template failed") + } + return html.UnescapeString(res), nil +} + +// Loader loads and holds parsed handlebars templates. +type Loader struct { + strings map[string]*Template + files map[string]*Template + lookupPaths []string + lang string + styler core.Styler + logger util.Logger +} + +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. +// +func NewLoader(opts LoaderOpts) *Loader { + return &Loader{ + strings: make(map[string]*Template), + files: make(map[string]*Template), + lookupPaths: opts.LookupPaths, + lang: opts.Lang, + styler: opts.Styler, + logger: opts.Logger, + } +} + +// LoadTemplate implements core.TemplateLoader. +func (l *Loader) LoadTemplate(content string) (core.Template, error) { + wrap := errors.Wrapperf("load template failed") + + // Already loaded? + template, ok := l.strings[content] + if ok { + return template, nil + } + + // Load new template. + vendorTempl, err := raymond.Parse(content) + if err != nil { + return nil, wrap(err) + } + template = l.newTemplate(vendorTempl) + l.strings[content] = template + return template, nil +} + +// LoadTemplateAt implements core.TemplateLoader. +func (l *Loader) LoadTemplateAt(path string) (core.Template, error) { + wrap := errors.Wrapper("load template file failed") + + path, ok := l.locateTemplate(path) + if !ok { + return nil, wrap(fmt.Errorf("cannot find template at %s", path)) + } + + // Already loaded? + template, ok := l.files[path] + if ok { + return template, nil + } + + // Load new template. + vendorTempl, err := raymond.ParseFile(path) + if err != nil { + return nil, wrap(err) + } + template = l.newTemplate(vendorTempl) + l.files[path] = template + return template, nil +} + +// locateTemplate returns the absolute path for the given template path, by +// looking for it in the templates directories registered in this Config. +func (l *Loader) locateTemplate(path string) (string, bool) { + if path == "" { + return "", false + } + + exists := func(path string) bool { + exists, err := paths.Exists(path) + return exists && err == nil + } + + if filepath.IsAbs(path) { + return path, exists(path) + } + + for _, dir := range l.lookupPaths { + if candidate := filepath.Join(dir, path); exists(candidate) { + return candidate, true + } + } + + return path, false +} + +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), + }) + + return &Template{vendorTempl, l.styler} +} diff --git a/adapter/handlebars/handlebars_test.go b/internal/adapter/handlebars/handlebars_test.go similarity index 69% rename from adapter/handlebars/handlebars_test.go rename to internal/adapter/handlebars/handlebars_test.go index 6300f44..a1a816b 100644 --- a/adapter/handlebars/handlebars_test.go +++ b/internal/adapter/handlebars/handlebars_test.go @@ -2,28 +2,31 @@ package handlebars import ( "fmt" + "os" + "path/filepath" "testing" "time" - "github.com/mickael-menu/zk/core/style" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/fixtures" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/fixtures" + "github.com/mickael-menu/zk/internal/util/paths" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func init() { - Init("en", true, &util.NullLogger, &styler{}) + Init(true, &util.NullLogger) } // styler is a test double for core.Styler // "hello", "red" -> "red(hello)" type styler struct{} -func (s *styler) Style(text string, rules ...style.Rule) (string, error) { +func (s *styler) Style(text string, rules ...core.Style) (string, error) { return s.MustStyle(text, rules...), nil } -func (s *styler) MustStyle(text string, rules ...style.Rule) string { +func (s *styler) MustStyle(text string, rules ...core.Style) string { for _, rule := range rules { text = fmt.Sprintf("%s(%s)", rule, text) } @@ -31,9 +34,9 @@ func (s *styler) MustStyle(text string, rules ...style.Rule) string { } func testString(t *testing.T, template string, context interface{}, expected string) { - sut := NewLoader() + sut := testLoader([]string{}) - templ, err := sut.Load(template) + templ, err := sut.LoadTemplate(template) assert.Nil(t, err) actual, err := templ.Render(context) @@ -42,9 +45,9 @@ func testString(t *testing.T, template string, context interface{}, expected str } func testFile(t *testing.T, name string, context interface{}, expected string) { - sut := NewLoader() + sut := testLoader([]string{}) - templ, err := sut.LoadFile(fixtures.Path(name)) + templ, err := sut.LoadTemplateAt(fixtures.Path(name)) assert.Nil(t, err) actual, err := templ.Render(context) @@ -52,6 +55,45 @@ func testFile(t *testing.T, name string, context interface{}, expected string) { assert.Equal(t, actual, expected) } +func TestLookupPaths(t *testing.T) { + root := fmt.Sprintf("/tmp/zk-test-%d", time.Now().Unix()) + os.Remove(root) + path1 := filepath.Join(root, "1") + os.MkdirAll(path1, os.ModePerm) + path2 := filepath.Join(root, "1") + os.MkdirAll(filepath.Join(path2, "subdir"), os.ModePerm) + + sut := testLoader([]string{path1, path2}) + + test := func(path string, expected string) { + tpl, err := sut.LoadTemplateAt(path) + assert.Nil(t, err) + res, err := tpl.Render(nil) + assert.Nil(t, err) + assert.Equal(t, res, expected) + } + + test1 := filepath.Join(path1, "test1.tpl") + + tpl1, err := sut.LoadTemplateAt(test1) + assert.Err(t, err, "cannot find template at "+test1) + assert.Nil(t, tpl1) + + paths.WriteString(test1, "Test 1") + test(test1, "Test 1") // absolute + test("test1.tpl", "Test 1") // relative + + test2 := filepath.Join(path2, "test2.tpl") + paths.WriteString(test2, "Test 2") + test(test2, "Test 2") // absolute + test("test2.tpl", "Test 2") // relative + + test3 := filepath.Join(path2, "subdir/test3.tpl") + paths.WriteString(test3, "Test 3") + test(test3, "Test 3") // absolute + test("subdir/test3.tpl", "Test 3") // relative +} + func TestRenderString(t *testing.T) { testString(t, "Goodbye, {{name}}", @@ -183,3 +225,12 @@ func TestStyleHelper(t *testing.T) { // block 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, + }) +} diff --git a/adapter/handlebars/helpers/concat.go b/internal/adapter/handlebars/helpers/concat.go similarity index 100% rename from adapter/handlebars/helpers/concat.go rename to internal/adapter/handlebars/helpers/concat.go diff --git a/adapter/handlebars/helpers/date.go b/internal/adapter/handlebars/helpers/date.go similarity index 97% rename from adapter/handlebars/helpers/date.go rename to internal/adapter/handlebars/helpers/date.go index 64f7779..136de89 100644 --- a/adapter/handlebars/helpers/date.go +++ b/internal/adapter/handlebars/helpers/date.go @@ -5,7 +5,7 @@ import ( "github.com/aymerick/raymond" "github.com/lestrrat-go/strftime" - "github.com/mickael-menu/zk/util" + "github.com/mickael-menu/zk/internal/util" "github.com/rvflash/elapsed" ) diff --git a/adapter/handlebars/helpers/join.go b/internal/adapter/handlebars/helpers/join.go similarity index 100% rename from adapter/handlebars/helpers/join.go rename to internal/adapter/handlebars/helpers/join.go diff --git a/adapter/handlebars/helpers/list.go b/internal/adapter/handlebars/helpers/list.go similarity index 100% rename from adapter/handlebars/helpers/list.go rename to internal/adapter/handlebars/helpers/list.go diff --git a/adapter/handlebars/helpers/prepend.go b/internal/adapter/handlebars/helpers/prepend.go similarity index 89% rename from adapter/handlebars/helpers/prepend.go rename to internal/adapter/handlebars/helpers/prepend.go index fa07418..f6d33ea 100644 --- a/adapter/handlebars/helpers/prepend.go +++ b/internal/adapter/handlebars/helpers/prepend.go @@ -2,8 +2,8 @@ package helpers import ( "github.com/aymerick/raymond" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/strings" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/strings" ) // RegisterPrepend registers a {{prepend}} template helper which prepend a diff --git a/adapter/handlebars/helpers/shell.go b/internal/adapter/handlebars/helpers/shell.go similarity index 88% rename from adapter/handlebars/helpers/shell.go rename to internal/adapter/handlebars/helpers/shell.go index b28d6b8..f6a4ce3 100644 --- a/adapter/handlebars/helpers/shell.go +++ b/internal/adapter/handlebars/helpers/shell.go @@ -4,8 +4,8 @@ import ( "strings" "github.com/aymerick/raymond" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/exec" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/exec" ) // RegisterShell registers the {{sh}} template helper, which runs shell commands. diff --git a/adapter/handlebars/helpers/slug.go b/internal/adapter/handlebars/helpers/slug.go similarity index 69% rename from adapter/handlebars/helpers/slug.go rename to internal/adapter/handlebars/helpers/slug.go index 38cac8c..1dd4794 100644 --- a/adapter/handlebars/helpers/slug.go +++ b/internal/adapter/handlebars/helpers/slug.go @@ -3,15 +3,15 @@ package helpers import ( "github.com/aymerick/raymond" "github.com/gosimple/slug" - "github.com/mickael-menu/zk/util" + "github.com/mickael-menu/zk/internal/util" ) -// RegisterSlug registers a {{slug}} template helper to slugify text. +// NewSlugHelper creates a new template helper to slugify text. // // {{slug "This will be slugified!"}} -> this-will-be-slugified // {{#slug}}This will be slugified!{{/slug}} -> this-will-be-slugified -func RegisterSlug(lang string, logger util.Logger) { - raymond.RegisterHelper("slug", func(opt interface{}) string { +func NewSlugHelper(lang string, logger util.Logger) interface{} { + return func(opt interface{}) string { switch arg := opt.(type) { case *raymond.Options: return slug.MakeLang(arg.Fn(), lang) @@ -21,5 +21,5 @@ func RegisterSlug(lang string, logger util.Logger) { logger.Printf("the {{slug}} template helper is expecting a string as argument, received: %v", opt) return "" } - }) + } } diff --git a/adapter/handlebars/helpers/style.go b/internal/adapter/handlebars/helpers/style.go similarity index 59% rename from adapter/handlebars/helpers/style.go rename to internal/adapter/handlebars/helpers/style.go index 08c87ad..c31831c 100644 --- a/adapter/handlebars/helpers/style.go +++ b/internal/adapter/handlebars/helpers/style.go @@ -4,20 +4,20 @@ import ( "strings" "github.com/aymerick/raymond" - "github.com/mickael-menu/zk/core/style" - "github.com/mickael-menu/zk/util" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" ) -// RegisterStyle register the {{style}} template helpers which stylizes the -// text input according to predefined styling rules. +// NewStyleHelper creates a new template helper which stylizes the text input +// according to predefined styling rules. // // {{style "date" created}} // {{#style "red"}}Hello, world{{/style}} -func RegisterStyle(styler style.Styler, logger util.Logger) { +func NewStyleHelper(styler core.Styler, logger util.Logger) interface{} { style := func(keys string, text string) string { - rules := make([]style.Rule, 0) + rules := make([]core.Style, 0) for _, key := range strings.Fields(keys) { - rules = append(rules, style.Rule(key)) + rules = append(rules, core.Style(key)) } res, err := styler.Style(text, rules...) if err != nil { @@ -28,7 +28,7 @@ func RegisterStyle(styler style.Styler, logger util.Logger) { } } - raymond.RegisterHelper("style", func(rules string, opt interface{}) string { + return func(rules string, opt interface{}) string { switch arg := opt.(type) { case *raymond.Options: return style(rules, arg.Fn()) @@ -38,5 +38,5 @@ func RegisterStyle(styler style.Styler, logger util.Logger) { logger.Printf("the {{style}} template helper is expecting a string as input, received: %v", opt) return "" } - }) + } } diff --git a/adapter/handlebars/fixtures/template.txt b/internal/adapter/handlebars/testdata/template.txt similarity index 100% rename from adapter/handlebars/fixtures/template.txt rename to internal/adapter/handlebars/testdata/template.txt diff --git a/adapter/handlebars/fixtures/unescape.txt b/internal/adapter/handlebars/testdata/unescape.txt similarity index 100% rename from adapter/handlebars/fixtures/unescape.txt rename to internal/adapter/handlebars/testdata/unescape.txt diff --git a/adapter/lsp/document.go b/internal/adapter/lsp/document.go similarity index 100% rename from adapter/lsp/document.go rename to internal/adapter/lsp/document.go diff --git a/internal/adapter/lsp/logger.go b/internal/adapter/lsp/logger.go new file mode 100644 index 0000000..e3f88e1 --- /dev/null +++ b/internal/adapter/lsp/logger.go @@ -0,0 +1,38 @@ +package lsp + +import ( + "fmt" + + "github.com/tliron/kutil/logging" +) + +// glspLogger is a Logger wrapping the GLSP one. +// Can be used to change the active logger during runtime. +type glspLogger struct { + log logging.Logger +} + +func newGlspLogger(log logging.Logger) *glspLogger { + return &glspLogger{log} +} + +func (l *glspLogger) Printf(format string, v ...interface{}) { + l.log.Debugf("zk: "+format, v...) +} + +func (l *glspLogger) Println(vs ...interface{}) { + message := "zk: " + for i, v := range vs { + if i > 0 { + message += ", " + } + message += fmt.Sprint(v) + } + l.log.Debug(message) +} + +func (l *glspLogger) Err(err error) { + if err != nil { + l.log.Debugf("zk: warning: %v", err) + } +} diff --git a/adapter/lsp/server.go b/internal/adapter/lsp/server.go similarity index 64% rename from adapter/lsp/server.go rename to internal/adapter/lsp/server.go index 7491003..c90875c 100644 --- a/adapter/lsp/server.go +++ b/internal/adapter/lsp/server.go @@ -7,13 +7,11 @@ import ( "path/filepath" "strings" - "github.com/mickael-menu/zk/adapter" - "github.com/mickael-menu/zk/adapter/sqlite" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/core/zk" - "github.com/mickael-menu/zk/util/errors" - "github.com/mickael-menu/zk/util/opt" - strutil "github.com/mickael-menu/zk/util/strings" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" + "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" "github.com/tliron/glsp" protocol "github.com/tliron/glsp/protocol_3_16" glspserv "github.com/tliron/glsp/server" @@ -24,8 +22,10 @@ import ( // Server holds the state of the Language Server. type Server struct { server *glspserv.Server - container *adapter.Container + notebooks *core.NotebookStore documents map[protocol.DocumentUri]*document + fs core.FileStorage + logger util.Logger } // ServerOpts holds the options to create a new Server. @@ -33,11 +33,14 @@ type ServerOpts struct { Name string Version string LogFile opt.String - Container *adapter.Container + Logger *util.ProxyLogger + Notebooks *core.NotebookStore + FS core.FileStorage } // NewServer creates a new Server instance. func NewServer(opts ServerOpts) *Server { + fs := opts.FS debug := !opts.LogFile.IsNull() if debug { logging.Configure(10, opts.LogFile.Value) @@ -47,8 +50,16 @@ func NewServer(opts ServerOpts) *Server { handler := protocol.Handler{} server := &Server{ server: glspserv.NewServer(&handler, opts.Name, debug), - container: opts.Container, + notebooks: opts.Notebooks, documents: map[string]*document{}, + fs: fs, + } + + // 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 } var clientCapabilities protocol.ClientCapabilities @@ -66,8 +77,6 @@ func NewServer(opts ServerOpts) *Server { workspace.addFolder(*params.RootPath) } - server.container.OpenNotebook(workspace.folders) - // 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 { @@ -77,30 +86,19 @@ func NewServer(opts ServerOpts) *Server { capabilities := handler.CreateServerCapabilities() capabilities.HoverProvider = true - zk, err := server.container.Zk() - if err == nil { - capabilities.TextDocumentSync = protocol.TextDocumentSyncKindIncremental - capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{ - ResolveProvider: boolPtr(true), - } - - triggerChars := []string{"["} - - // Setup tag completion trigger characters - if zk.Config.Format.Markdown.Hashtags { - triggerChars = append(triggerChars, "#") - } - if zk.Config.Format.Markdown.ColonTags { - triggerChars = append(triggerChars, ":") - } + capabilities.TextDocumentSync = protocol.TextDocumentSyncKindIncremental + capabilities.DocumentLinkProvider = &protocol.DocumentLinkOptions{ + ResolveProvider: boolPtr(true), + } - capabilities.CompletionProvider = &protocol.CompletionOptions{ - TriggerCharacters: triggerChars, - } + triggerChars := []string{"[", "#", ":"} - capabilities.DefinitionProvider = boolPtr(true) + capabilities.CompletionProvider = &protocol.CompletionOptions{ + TriggerCharacters: triggerChars, } + capabilities.DefinitionProvider = boolPtr(true) + return protocol.InitializeResult{ Capabilities: capabilities, ServerInfo: &protocol.InitializeResultServerInfo{ @@ -140,8 +138,10 @@ func NewServer(opts ServerOpts) *Server { return nil } + path := fs.Canonical(strings.TrimPrefix(params.TextDocument.URI, "file://")) + server.documents[params.TextDocument.URI] = &document{ - Path: strings.TrimPrefix(params.TextDocument.URI, "file://"), + Path: path, Content: params.TextDocument.Text, Log: server.server.Log, } @@ -170,17 +170,31 @@ func NewServer(opts ServerOpts) *Server { handler.TextDocumentCompletion = func(context *glsp.Context, params *protocol.CompletionParams) (interface{}, error) { triggerChar := params.Context.TriggerCharacter - if params.Context.TriggerKind != protocol.CompletionTriggerKindTriggerCharacter || triggerChar == nil { return nil, nil } - switch *triggerChar { - case "#", ":": - return server.buildTagCompletionList(*triggerChar) + doc, ok := server.documents[params.TextDocument.URI] + if !ok { + return nil, nil + } + + notebook, err := server.notebookOf(doc) + if err != nil { + return nil, err + } + switch *triggerChar { + case "#": + if notebook.Config.Format.Markdown.Hashtags { + return server.buildTagCompletionList(notebook, "#") + } + case ":": + if notebook.Config.Format.Markdown.ColonTags { + return server.buildTagCompletionList(notebook, ":") + } case "[": - return server.buildLinkCompletionList(params) + return server.buildLinkCompletionList(doc, notebook, params) } return nil, nil @@ -192,29 +206,21 @@ func NewServer(opts ServerOpts) *Server { return nil, nil } - zk, err := server.container.Zk() - if err != nil { + link, err := doc.DocumentLinkAt(params.Position) + if link == nil || err != nil { return nil, err } - db, _, err := server.container.Database(false) + notebook, err := server.notebookOf(doc) if err != nil { return nil, err } - var target string - err = db.WithTransaction(func(tx sqlite.Transaction) error { - finder := sqlite.NewNoteDAO(tx, server.container.Logger) - link, err := doc.DocumentLinkAt(params.Position) - if link == nil || err != nil { - return err - } - target, err = server.targetForHref(link.Href, doc, zk.Path, finder) - return err - }) + target, err := server.targetForHref(link.Href, doc, notebook) if err != nil || target == "" || strutil.IsURL(target) { return nil, err } + target = strings.TrimPrefix(target, "file://") contents, err := ioutil.ReadFile(target) if err != nil { @@ -232,41 +238,31 @@ func NewServer(opts ServerOpts) *Server { handler.TextDocumentDocumentLink = func(context *glsp.Context, params *protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) { doc, ok := server.documents[params.TextDocument.URI] if !ok { - return []protocol.DocumentLink{}, nil + return nil, nil } - zk, err := server.container.Zk() + links, err := doc.DocumentLinks() if err != nil { return nil, err } - db, _, err := server.container.Database(false) + notebook, err := server.notebookOf(doc) if err != nil { return nil, err } - var documentLinks []protocol.DocumentLink - err = db.WithTransaction(func(tx sqlite.Transaction) error { - finder := sqlite.NewNoteDAO(tx, server.container.Logger) - links, err := doc.DocumentLinks() - if err != nil { - return err + documentLinks := []protocol.DocumentLink{} + for _, link := range links { + target, err := server.targetForHref(link.Href, doc, notebook) + if target == "" || err != nil { + continue } - for _, link := range links { - target, err := server.targetForHref(link.Href, doc, zk.Path, finder) - if target == "" || err != nil { - continue - } - - documentLinks = append(documentLinks, protocol.DocumentLink{ - Range: link.Range, - Target: &target, - }) - } - - return nil - }) + documentLinks = append(documentLinks, protocol.DocumentLink{ + Range: link.Range, + Target: &target, + }) + } return documentLinks, err } @@ -274,30 +270,20 @@ func NewServer(opts ServerOpts) *Server { handler.TextDocumentDefinition = func(context *glsp.Context, params *protocol.DefinitionParams) (interface{}, error) { doc, ok := server.documents[params.TextDocument.URI] if !ok { - return []protocol.DocumentLink{}, nil + return nil, nil } - zk, err := server.container.Zk() - if err != nil { + link, err := doc.DocumentLinkAt(params.Position) + if link == nil || err != nil { return nil, err } - db, _, err := server.container.Database(false) + notebook, err := server.notebookOf(doc) if err != nil { return nil, err } - var link *documentLink - var target string - err = db.WithTransaction(func(tx sqlite.Transaction) error { - finder := sqlite.NewNoteDAO(tx, server.container.Logger) - link, err = doc.DocumentLinkAt(params.Position) - if link == nil || err != nil { - return err - } - target, err = server.targetForHref(link.Href, doc, zk.Path, finder) - return err - }) + target, err := server.targetForHref(link.Href, doc, notebook) if link == nil || target == "" || err != nil { return nil, err } @@ -319,25 +305,29 @@ func NewServer(opts ServerOpts) *Server { return server } +func (s *Server) notebookOf(doc *document) (*core.Notebook, error) { + return s.notebooks.Open(doc.Path) +} + // targetForHref returns the LSP documentUri for the note at the given HREF. -func (s *Server) targetForHref(href string, doc *document, basePath string, finder note.Finder) (string, error) { +func (s *Server) targetForHref(href string, doc *document, notebook *core.Notebook) (string, error) { if strutil.IsURL(href) { return href, nil } else { path := filepath.Clean(filepath.Join(filepath.Dir(doc.Path), href)) - path, err := filepath.Rel(basePath, path) + path, err := filepath.Rel(notebook.Path, path) if err != nil { return "", errors.Wrapf(err, "failed to resolve href: %s", href) } - note, err := finder.FindByHref(path) + note, err := notebook.FindByHref(path) if err != nil { - s.server.Log.Errorf("findByHref(%s): %s", href, err.Error()) + s.logger.Printf("findByHref(%s): %s", href, err.Error()) return "", err } if note == nil { return "", nil } - return "file://" + filepath.Join(basePath, note.Path), nil + return "file://" + filepath.Join(notebook.Path, note.Path), nil } } @@ -346,21 +336,8 @@ func (s *Server) Run() error { return errors.Wrap(s.server.RunStdio(), "lsp") } -func (s *Server) buildTagCompletionList(triggerChar string) ([]protocol.CompletionItem, error) { - zk, err := s.container.Zk() - if err != nil { - return nil, err - } - db, _, err := s.container.Database(false) - if err != nil { - return nil, err - } - - var tags []note.Collection - err = db.WithTransaction(func(tx sqlite.Transaction) error { - tags, err = sqlite.NewCollectionDAO(tx, s.container.Logger).FindAll(note.CollectionKindTag) - return err - }) +func (s *Server) buildTagCompletionList(notebook *core.Notebook, triggerChar string) ([]protocol.CompletionItem, error) { + tags, err := notebook.FindCollections(core.CollectionKindTag) if err != nil { return nil, err } @@ -369,7 +346,7 @@ func (s *Server) buildTagCompletionList(triggerChar string) ([]protocol.Completi for _, tag := range tags { items = append(items, protocol.CompletionItem{ Label: tag.Name, - InsertText: s.buildInsertForTag(tag.Name, triggerChar, zk.Config), + InsertText: s.buildInsertForTag(tag.Name, triggerChar, notebook.Config), Detail: stringPtr(fmt.Sprintf("%d %s", tag.NoteCount, strutil.Pluralize("note", tag.NoteCount))), }) } @@ -377,7 +354,7 @@ func (s *Server) buildTagCompletionList(triggerChar string) ([]protocol.Completi return items, nil } -func (s *Server) buildInsertForTag(name string, triggerChar string, config zk.Config) *string { +func (s *Server) buildInsertForTag(name string, triggerChar string, config core.Config) *string { switch triggerChar { case ":": name += ":" @@ -393,27 +370,8 @@ func (s *Server) buildInsertForTag(name string, triggerChar string, config zk.Co return &name } -func (s *Server) buildLinkCompletionList(params *protocol.CompletionParams) ([]protocol.CompletionItem, error) { - zk, err := s.container.Zk() - if err != nil { - return nil, err - } - doc, ok := s.documents[params.TextDocument.URI] - if !ok { - return nil, nil - } - - db, _, err := s.container.Database(false) - if err != nil { - return nil, err - } - - var notes []note.Match - err = db.WithTransaction(func(tx sqlite.Transaction) error { - finder := sqlite.NewNoteDAO(tx, s.container.Logger) - notes, err = finder.Find(note.FinderOpts{}) - return err - }) +func (s *Server) buildLinkCompletionList(doc *document, notebook *core.Notebook, params *protocol.CompletionParams) ([]protocol.CompletionItem, error) { + notes, err := notebook.FindNotes(core.NoteFindOpts{}) if err != nil { return nil, err } @@ -422,7 +380,7 @@ func (s *Server) buildLinkCompletionList(params *protocol.CompletionParams) ([]p for _, note := range notes { items = append(items, protocol.CompletionItem{ Label: note.Title, - TextEdit: s.buildTextEditForLink(zk, note, doc, params.Position), + TextEdit: s.buildTextEditForLink(notebook, note, doc, params.Position), Documentation: protocol.MarkupContent{ Kind: protocol.MarkupKindMarkdown, Value: note.RawContent, @@ -433,11 +391,12 @@ func (s *Server) buildLinkCompletionList(params *protocol.CompletionParams) ([]p return items, nil } -func (s *Server) buildTextEditForLink(zk *zk.Zk, note note.Match, document *document, pos protocol.Position) interface{} { +func (s *Server) buildTextEditForLink(notebook *core.Notebook, note core.ContextualNote, document *document, pos protocol.Position) interface{} { isWikiLink := (document.LookBehind(pos, 2) == "[[") var text string - path := filepath.Join(zk.Path, note.Path) + 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 diff --git a/adapter/lsp/workspace.go b/internal/adapter/lsp/workspace.go similarity index 100% rename from adapter/lsp/workspace.go rename to internal/adapter/lsp/workspace.go diff --git a/adapter/markdown/extensions/tag.go b/internal/adapter/markdown/extensions/tag.go similarity index 100% rename from adapter/markdown/extensions/tag.go rename to internal/adapter/markdown/extensions/tag.go diff --git a/adapter/markdown/extensions/wikilink.go b/internal/adapter/markdown/extensions/wikilink.go similarity index 94% rename from adapter/markdown/extensions/wikilink.go rename to internal/adapter/markdown/extensions/wikilink.go index 8db0738..4198fa2 100644 --- a/adapter/markdown/extensions/wikilink.go +++ b/internal/adapter/markdown/extensions/wikilink.go @@ -3,7 +3,7 @@ package extensions import ( "strings" - "github.com/mickael-menu/zk/core/note" + "github.com/mickael-menu/zk/internal/core" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" @@ -38,7 +38,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) var ( href string label string - rel note.LinkRelation + rel core.LinkRelation ) var ( @@ -65,7 +65,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) if closed { // Supports trailing hash syntax for Neuron's Folgezettel, e.g. [[id]]# if char == '#' { - rel = note.LinkRelationDown + rel = core.LinkRelationDown } break } @@ -74,7 +74,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) switch char { // Supports leading hash syntax for Neuron's Folgezettel, e.g. #[[id]] case '#': - rel = note.LinkRelationUp + rel = core.LinkRelationUp continue case '[': openerCharCount += 1 @@ -104,7 +104,7 @@ func (p *wlParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) closed = true // Neuron's legacy [[[Folgezettel]]]. if closerCharCount == 3 { - rel = note.LinkRelationDown + rel = core.LinkRelationDown } } continue diff --git a/adapter/markdown/markdown.go b/internal/adapter/markdown/markdown.go similarity index 91% rename from adapter/markdown/markdown.go rename to internal/adapter/markdown/markdown.go index ce50070..aae7c0c 100644 --- a/adapter/markdown/markdown.go +++ b/internal/adapter/markdown/markdown.go @@ -6,11 +6,11 @@ import ( "regexp" "strings" - "github.com/mickael-menu/zk/adapter/markdown/extensions" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util/opt" - strutil "github.com/mickael-menu/zk/util/strings" - "github.com/mickael-menu/zk/util/yaml" + "github.com/mickael-menu/zk/internal/adapter/markdown/extensions" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util/opt" + strutil "github.com/mickael-menu/zk/internal/util/strings" + "github.com/mickael-menu/zk/internal/util/yaml" "github.com/mvdan/xurls" "github.com/yuin/goldmark" meta "github.com/yuin/goldmark-meta" @@ -60,9 +60,9 @@ func NewParser(options ParserOpts) *Parser { } } -// Parse implements note.Parse. -func (p *Parser) Parse(source string) (*note.Content, error) { - bytes := []byte(source) +// Parse implements core.NoteParser. +func (p *Parser) Parse(content string) (*core.ParsedNote, error) { + bytes := []byte(content) context := parser.NewContext() root := p.md.Parser().Parse( @@ -91,7 +91,7 @@ func (p *Parser) Parse(source string) (*note.Content, error) { return nil, err } - return ¬e.Content{ + return &core.ParsedNote{ Title: title, Body: body, Lead: parseLead(body), @@ -208,8 +208,8 @@ func parseTags(frontmatter frontmatter, root ast.Node, source []byte) ([]string, } // parseLinks extracts outbound links from the note. -func parseLinks(root ast.Node, source []byte) ([]note.Link, error) { - links := make([]note.Link, 0) +func parseLinks(root ast.Node, source []byte) ([]core.Link, error) { + links := make([]core.Link, 0) err := ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if entering { @@ -218,11 +218,11 @@ func parseLinks(root ast.Node, source []byte) ([]note.Link, error) { href := string(link.Destination) if href != "" { snippet, snStart, snEnd := extractLines(n, source) - links = append(links, note.Link{ + links = append(links, core.Link{ Title: string(link.Text(source)), Href: href, - Rels: strings.Fields(string(link.Title)), - External: strutil.IsURL(href), + Rels: core.LinkRels(strings.Fields(string(link.Title))...), + IsExternal: strutil.IsURL(href), Snippet: snippet, SnippetStart: snStart, SnippetEnd: snEnd, @@ -232,11 +232,11 @@ func parseLinks(root ast.Node, source []byte) ([]note.Link, error) { case *ast.AutoLink: if href := string(link.URL(source)); href != "" && link.AutoLinkType == ast.AutoLinkURL { snippet, snStart, snEnd := extractLines(n, source) - links = append(links, note.Link{ + links = append(links, core.Link{ Title: string(link.Label(source)), Href: href, - Rels: []string{}, - External: true, + Rels: []core.LinkRelation{}, + IsExternal: true, Snippet: snippet, SnippetStart: snStart, SnippetEnd: snEnd, diff --git a/adapter/markdown/markdown_test.go b/internal/adapter/markdown/markdown_test.go similarity index 89% rename from adapter/markdown/markdown_test.go rename to internal/adapter/markdown/markdown_test.go index 96133a1..fa0f5fe 100644 --- a/adapter/markdown/markdown_test.go +++ b/internal/adapter/markdown/markdown_test.go @@ -3,9 +3,9 @@ package markdown import ( "testing" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util/opt" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func TestParseTitle(t *testing.T) { @@ -346,13 +346,13 @@ Tags: [tag1, "#tag1", tag2] } func TestParseLinks(t *testing.T) { - test := func(source string, links []note.Link) { + test := func(source string, links []core.Link) { content := parse(t, source) assert.Equal(t, content.Links, links) } - test("", []note.Link{}) - test("No links around here", []note.Link{}) + test("", []core.Link{}) + test("No links around here", []core.Link{}) test(` # Heading with a [link](heading) @@ -375,51 +375,51 @@ A #[[leading hash]] is used for #uplinks. Neuron links with titles: [[trailing|Trailing link]]# #[[leading | Leading link]] [External links](http://example.com) are marked [as such](ftp://domain). -`, []note.Link{ +`, []core.Link{ { Title: "link", Href: "heading", - Rels: []string{}, - External: false, + Rels: []core.LinkRelation{}, + IsExternal: false, Snippet: "Heading with a [link](heading)", SnippetStart: 3, SnippetEnd: 33, }, { - Title: "multiple links", - Href: "stripped-formatting", - Rels: []string{}, - External: false, + Title: "multiple links", + Href: "stripped-formatting", + Rels: []core.LinkRelation{}, + IsExternal: false, Snippet: `Paragraph containing [multiple **links**](stripped-formatting), here's one [relative](../other). A link can have [one relation](one "rel-1") or [several relations](several "rel-1 rel-2").`, SnippetStart: 35, SnippetEnd: 222, }, { - Title: "relative", - Href: "../other", - Rels: []string{}, - External: false, + Title: "relative", + Href: "../other", + Rels: []core.LinkRelation{}, + IsExternal: false, Snippet: `Paragraph containing [multiple **links**](stripped-formatting), here's one [relative](../other). A link can have [one relation](one "rel-1") or [several relations](several "rel-1 rel-2").`, SnippetStart: 35, SnippetEnd: 222, }, { - Title: "one relation", - Href: "one", - Rels: []string{"rel-1"}, - External: false, + Title: "one relation", + Href: "one", + Rels: core.LinkRels("rel-1"), + IsExternal: false, Snippet: `Paragraph containing [multiple **links**](stripped-formatting), here's one [relative](../other). A link can have [one relation](one "rel-1") or [several relations](several "rel-1 rel-2").`, SnippetStart: 35, SnippetEnd: 222, }, { - Title: "several relations", - Href: "several", - Rels: []string{"rel-1", "rel-2"}, - External: false, + Title: "several relations", + Href: "several", + Rels: core.LinkRels("rel-1", "rel-2"), + IsExternal: false, Snippet: `Paragraph containing [multiple **links**](stripped-formatting), here's one [relative](../other). A link can have [one relation](one "rel-1") or [several relations](several "rel-1 rel-2").`, SnippetStart: 35, @@ -428,8 +428,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "https://inline-link.com", Href: "https://inline-link.com", - External: true, - Rels: []string{}, + IsExternal: true, + Rels: []core.LinkRelation{}, Snippet: "An https://inline-link.com and http://another-inline-link.com.", SnippetStart: 224, SnippetEnd: 286, @@ -437,8 +437,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "http://another-inline-link.com", Href: "http://another-inline-link.com", - External: true, - Rels: []string{}, + IsExternal: true, + Rels: []core.LinkRelation{}, Snippet: "An https://inline-link.com and http://another-inline-link.com.", SnippetStart: 224, SnippetEnd: 286, @@ -446,8 +446,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "Wiki link", Href: "Wiki link", - External: false, - Rels: []string{}, + IsExternal: false, + Rels: []core.LinkRelation{}, Snippet: "A [[Wiki link]] is surrounded by [[2-brackets | two brackets]].", SnippetStart: 288, SnippetEnd: 351, @@ -455,8 +455,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "two brackets", Href: "2-brackets", - External: false, - Rels: []string{}, + IsExternal: false, + Rels: []core.LinkRelation{}, Snippet: "A [[Wiki link]] is surrounded by [[2-brackets | two brackets]].", SnippetStart: 288, SnippetEnd: 351, @@ -464,8 +464,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: `esca]]ped [chara\cters`, Href: `esca]]ped [chara\cters`, - External: false, - Rels: []string{}, + IsExternal: false, + Rels: []core.LinkRelation{}, Snippet: `It can contain [[esca]\]ped \[chara\\cters]].`, SnippetStart: 353, SnippetEnd: 398, @@ -473,8 +473,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "Folgezettel link", Href: "Folgezettel link", - External: false, - Rels: []string{"down"}, + IsExternal: false, + Rels: core.LinkRels("down"), Snippet: "A [[[Folgezettel link]]] is surrounded by three brackets.", SnippetStart: 400, SnippetEnd: 457, @@ -482,8 +482,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "trailing hash", Href: "trailing hash", - External: false, - Rels: []string{"down"}, + IsExternal: false, + Rels: core.LinkRels("down"), Snippet: "Neuron also supports a [[trailing hash]]# for Folgezettel links.", SnippetStart: 459, SnippetEnd: 523, @@ -491,8 +491,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "leading hash", Href: "leading hash", - External: false, - Rels: []string{"up"}, + IsExternal: false, + Rels: core.LinkRels("up"), Snippet: "A #[[leading hash]] is used for #uplinks.", SnippetStart: 525, SnippetEnd: 566, @@ -500,8 +500,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "Trailing link", Href: "trailing", - External: false, - Rels: []string{"down"}, + IsExternal: false, + Rels: core.LinkRels("down"), Snippet: "Neuron links with titles: [[trailing|Trailing link]]# #[[leading | Leading link]]", SnippetStart: 568, SnippetEnd: 650, @@ -509,8 +509,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "Leading link", Href: "leading", - External: false, - Rels: []string{"up"}, + IsExternal: false, + Rels: core.LinkRels("up"), Snippet: "Neuron links with titles: [[trailing|Trailing link]]# #[[leading | Leading link]]", SnippetStart: 568, SnippetEnd: 650, @@ -518,8 +518,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "External links", Href: "http://example.com", - Rels: []string{}, - External: true, + Rels: []core.LinkRelation{}, + IsExternal: true, Snippet: `[External links](http://example.com) are marked [as such](ftp://domain).`, SnippetStart: 652, SnippetEnd: 724, @@ -527,8 +527,8 @@ A link can have [one relation](one "rel-1") or [several relations](several "rel- { Title: "as such", Href: "ftp://domain", - Rels: []string{}, - External: true, + Rels: []core.LinkRelation{}, + IsExternal: true, Snippet: `[External links](http://example.com) are marked [as such](ftp://domain).`, SnippetStart: 652, SnippetEnd: 724, @@ -564,7 +564,7 @@ Paragraph }) } -func parse(t *testing.T, source string) note.Content { +func parse(t *testing.T, source string) core.ParsedNote { return parseWithOptions(t, source, ParserOpts{ HashtagEnabled: true, MultiWordTagEnabled: true, @@ -572,7 +572,7 @@ func parse(t *testing.T, source string) note.Content { }) } -func parseWithOptions(t *testing.T, source string, options ParserOpts) note.Content { +func parseWithOptions(t *testing.T, source string, options ParserOpts) core.ParsedNote { content, err := NewParser(options).Parse(source) assert.Nil(t, err) return *content diff --git a/adapter/sqlite/collection_dao.go b/internal/adapter/sqlite/collection_dao.go similarity index 75% rename from adapter/sqlite/collection_dao.go rename to internal/adapter/sqlite/collection_dao.go index 8866046..3eda465 100644 --- a/adapter/sqlite/collection_dao.go +++ b/internal/adapter/sqlite/collection_dao.go @@ -4,10 +4,9 @@ import ( "database/sql" "fmt" - "github.com/mickael-menu/zk/core" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/errors" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/errors" ) // CollectionDAO persists collections (e.g. tags) in the SQLite database. @@ -47,7 +46,7 @@ func NewCollectionDAO(tx Transaction, logger util.Logger) *CollectionDAO { findAllCollectionsStmt: tx.PrepareLazy(` SELECT c.name, COUNT(nc.id) as count FROM collections c - LEFT JOIN notes_collections nc ON nc.collection_id = c.id + INNER JOIN notes_collections nc ON nc.collection_id = c.id WHERE kind = ? GROUP BY c.id ORDER BY c.name @@ -75,7 +74,7 @@ func NewCollectionDAO(tx Transaction, logger util.Logger) *CollectionDAO { // FindOrCreate returns the ID of the collection with given kind and name. // Creates the collection if it does not already exist. -func (d *CollectionDAO) FindOrCreate(kind note.CollectionKind, name string) (core.CollectionId, error) { +func (d *CollectionDAO) FindOrCreate(kind core.CollectionKind, name string) (core.CollectionID, error) { id, err := d.findCollection(kind, name) switch { @@ -88,14 +87,14 @@ func (d *CollectionDAO) FindOrCreate(kind note.CollectionKind, name string) (cor } } -func (d *CollectionDAO) FindAll(kind note.CollectionKind) ([]note.Collection, error) { +func (d *CollectionDAO) FindAll(kind core.CollectionKind) ([]core.Collection, error) { rows, err := d.findAllCollectionsStmt.Query(kind) if err != nil { - return []note.Collection{}, err + return []core.Collection{}, err } defer rows.Close() - collections := []note.Collection{} + collections := []core.Collection{} for rows.Next() { var name string @@ -105,7 +104,7 @@ func (d *CollectionDAO) FindAll(kind note.CollectionKind) ([]note.Collection, er return collections, err } - collections = append(collections, note.Collection{ + collections = append(collections, core.Collection{ Kind: kind, Name: name, NoteCount: count, @@ -115,12 +114,12 @@ func (d *CollectionDAO) FindAll(kind note.CollectionKind) ([]note.Collection, er return collections, nil } -func (d *CollectionDAO) findCollection(kind note.CollectionKind, name string) (core.CollectionId, error) { +func (d *CollectionDAO) findCollection(kind core.CollectionKind, name string) (core.CollectionID, error) { wrap := errors.Wrapperf("failed to get %s named %s", kind, name) row, err := d.findCollectionStmt.QueryRow(kind, name) if err != nil { - return core.CollectionId(0), wrap(err) + return 0, wrap(err) } var id sql.NullInt64 @@ -128,15 +127,15 @@ func (d *CollectionDAO) findCollection(kind note.CollectionKind, name string) (c switch { case err == sql.ErrNoRows: - return core.CollectionId(0), nil + return 0, nil case err != nil: - return core.CollectionId(0), wrap(err) + return 0, wrap(err) default: - return core.CollectionId(id.Int64), nil + return core.CollectionID(id.Int64), nil } } -func (d *CollectionDAO) create(kind note.CollectionKind, name string) (core.CollectionId, error) { +func (d *CollectionDAO) create(kind core.CollectionKind, name string) (core.CollectionID, error) { wrap := errors.Wrapperf("failed to create new %s named %s", kind, name) res, err := d.createCollectionStmt.Exec(kind, name) @@ -149,12 +148,12 @@ func (d *CollectionDAO) create(kind note.CollectionKind, name string) (core.Coll return 0, wrap(err) } - return core.CollectionId(id), nil + return core.CollectionID(id), nil } // Associate creates a new association between a note and a collection, if it // does not already exist. -func (d *CollectionDAO) Associate(noteId core.NoteId, collectionId core.CollectionId) (core.NoteCollectionId, error) { +func (d *CollectionDAO) Associate(noteId core.NoteID, collectionId core.CollectionID) (core.NoteCollectionID, error) { wrap := errors.Wrapperf("failed to associate note %d to collection %d", noteId, collectionId) id, err := d.findAssociation(noteId, collectionId) @@ -170,7 +169,7 @@ func (d *CollectionDAO) Associate(noteId core.NoteId, collectionId core.Collecti } } -func (d *CollectionDAO) findAssociation(noteId core.NoteId, collectionId core.CollectionId) (core.NoteCollectionId, error) { +func (d *CollectionDAO) findAssociation(noteId core.NoteID, collectionId core.CollectionID) (core.NoteCollectionID, error) { if !noteId.IsValid() || !collectionId.IsValid() { return 0, fmt.Errorf("Note ID (%d) or collection ID (%d) not valid", noteId, collectionId) } @@ -189,11 +188,11 @@ func (d *CollectionDAO) findAssociation(noteId core.NoteId, collectionId core.Co case err != nil: return 0, err default: - return core.NoteCollectionId(id.Int64), nil + return core.NoteCollectionID(id.Int64), nil } } -func (d *CollectionDAO) createAssociation(noteId core.NoteId, collectionId core.CollectionId) (core.NoteCollectionId, error) { +func (d *CollectionDAO) createAssociation(noteId core.NoteID, collectionId core.CollectionID) (core.NoteCollectionID, error) { if !noteId.IsValid() || !collectionId.IsValid() { return 0, fmt.Errorf("Note ID (%d) or collection ID (%d) not valid", noteId, collectionId) } @@ -208,11 +207,11 @@ func (d *CollectionDAO) createAssociation(noteId core.NoteId, collectionId core. return 0, err } - return core.NoteCollectionId(id), nil + return core.NoteCollectionID(id), nil } // RemoveAssociations deletes all associations with the given note. -func (d *CollectionDAO) RemoveAssociations(noteId core.NoteId) error { +func (d *CollectionDAO) RemoveAssociations(noteId core.NoteID) error { if !noteId.IsValid() { return fmt.Errorf("Note ID (%d) not valid", noteId) } diff --git a/adapter/sqlite/collection_dao_test.go b/internal/adapter/sqlite/collection_dao_test.go similarity index 69% rename from adapter/sqlite/collection_dao_test.go rename to internal/adapter/sqlite/collection_dao_test.go index 4d161af..76d4317 100644 --- a/adapter/sqlite/collection_dao_test.go +++ b/internal/adapter/sqlite/collection_dao_test.go @@ -3,10 +3,9 @@ package sqlite import ( "testing" - "github.com/mickael-menu/zk/core" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func TestCollectionDAOFindOrCreate(t *testing.T) { @@ -14,22 +13,22 @@ func TestCollectionDAOFindOrCreate(t *testing.T) { // Finds existing ones id, err := dao.FindOrCreate("tag", "adventure") assert.Nil(t, err) - assert.Equal(t, id, core.CollectionId(2)) + assert.Equal(t, id, core.CollectionID(2)) id, err = dao.FindOrCreate("genre", "fiction") assert.Nil(t, err) - assert.Equal(t, id, core.CollectionId(3)) + assert.Equal(t, id, core.CollectionID(3)) // The name is case sensitive id, err = dao.FindOrCreate("tag", "Adventure") assert.Nil(t, err) - assert.NotEqual(t, id, core.CollectionId(2)) + assert.NotEqual(t, id, core.CollectionID(2)) // Creates when not found sql := "SELECT id FROM collections WHERE kind = ? AND name = ?" - assertNotExist(t, tx, sql, "unknown", "created") + assertNotExistTx(t, tx, sql, "unknown", "created") _, err = dao.FindOrCreate("unknown", "created") assert.Nil(t, err) - assertExist(t, tx, sql, "unknown", "created") + assertExistTx(t, tx, sql, "unknown", "created") }) } @@ -43,7 +42,7 @@ func TestCollectionDaoFindAll(t *testing.T) { // Finds existing cs, err = dao.FindAll("tag") assert.Nil(t, err) - assert.Equal(t, cs, []note.Collection{ + assert.Equal(t, cs, []core.Collection{ {Kind: "tag", Name: "adventure", NoteCount: 2}, {Kind: "tag", Name: "fantasy", NoteCount: 1}, {Kind: "tag", Name: "fiction", NoteCount: 1}, @@ -55,32 +54,32 @@ func TestCollectionDaoFindAll(t *testing.T) { func TestCollectionDAOAssociate(t *testing.T) { testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) { // Returns existing association - id, err := dao.Associate(1, 2) + id, err := dao.Associate(core.NoteID(1), core.CollectionID(2)) assert.Nil(t, err) - assert.Equal(t, id, core.NoteCollectionId(2)) + assert.Equal(t, id, core.NoteCollectionID(2)) // Creates a new association if missing - noteId := core.NoteId(5) - collectionId := core.CollectionId(3) + noteId := core.NoteID(5) + collectionId := core.CollectionID(3) sql := "SELECT id FROM notes_collections WHERE note_id = ? AND collection_id = ?" - assertNotExist(t, tx, sql, noteId, collectionId) + assertNotExistTx(t, tx, sql, noteId, collectionId) _, err = dao.Associate(noteId, collectionId) assert.Nil(t, err) - assertExist(t, tx, sql, noteId, collectionId) + assertExistTx(t, tx, sql, noteId, collectionId) }) } func TestCollectionDAORemoveAssociations(t *testing.T) { testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) { - noteId := core.NoteId(1) + noteId := core.NoteID(1) sql := "SELECT id FROM notes_collections WHERE note_id = ?" - assertExist(t, tx, sql, noteId) + assertExistTx(t, tx, sql, noteId) err := dao.RemoveAssociations(noteId) assert.Nil(t, err) - assertNotExist(t, tx, sql, noteId) + assertNotExistTx(t, tx, sql, noteId) // Removes associations of note without any. - err = dao.RemoveAssociations(999) + err = dao.RemoveAssociations(core.NoteID(999)) assert.Nil(t, err) }) } diff --git a/adapter/sqlite/db.go b/internal/adapter/sqlite/db.go similarity index 83% rename from adapter/sqlite/db.go rename to internal/adapter/sqlite/db.go index 06ec8b8..688eae9 100644 --- a/adapter/sqlite/db.go +++ b/internal/adapter/sqlite/db.go @@ -4,8 +4,8 @@ import ( "database/sql" sqlite "github.com/mattn/go-sqlite3" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util/errors" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util/errors" ) func init() { @@ -38,19 +38,26 @@ func OpenInMemory() (*DB, error) { func open(uri string) (*DB, error) { wrap := errors.Wrapper("failed to open the database") - db, err := sql.Open("sqlite3_custom", uri) + nativeDB, err := sql.Open("sqlite3_custom", uri) if err != nil { return nil, wrap(err) } // Make sure that CASCADE statements are properly applied by enabling // foreign keys. - _, err = db.Exec("PRAGMA foreign_keys = ON") + _, err = nativeDB.Exec("PRAGMA foreign_keys = ON") if err != nil { return nil, wrap(err) } - return &DB{db}, nil + db := &DB{nativeDB} + + err = db.migrate() + if err != nil { + return nil, errors.Wrap(err, "failed to migrate the database") + } + + return db, nil } // Close terminates the connections to the SQLite database. @@ -59,15 +66,17 @@ func (db *DB) Close() error { return errors.Wrap(err, "failed to close the database") } -// Migrate upgrades the SQL schema of the database. -func (db *DB) Migrate() (needsReindexing bool, err error) { - err = db.WithTransaction(func(tx Transaction) error { +// migrate upgrades the SQL schema of the database. +func (db *DB) migrate() error { + err := db.WithTransaction(func(tx Transaction) error { var version int err := tx.QueryRow("PRAGMA user_version").Scan(&version) if err != nil { return err } + needsReindexing := false + if version <= 0 { err = tx.ExecStmts([]string{ // Notes @@ -123,7 +132,6 @@ func (db *DB) Migrate() (needsReindexing bool, err error) { END`, `PRAGMA user_version = 1`, }) - if err != nil { return err } @@ -155,12 +163,11 @@ func (db *DB) Migrate() (needsReindexing bool, err error) { SELECT n.*, GROUP_CONCAT(c.name, '` + "\x01" + `') AS tags FROM notes n LEFT JOIN notes_collections nc ON nc.note_id = n.id - LEFT JOIN collections c ON nc.collection_id = c.id AND c.kind = '` + string(note.CollectionKindTag) + `' + LEFT JOIN collections c ON nc.collection_id = c.id AND c.kind = '` + string(core.CollectionKindTag) + `' GROUP BY n.id`, `PRAGMA user_version = 2`, }) - if err != nil { return err } @@ -177,7 +184,6 @@ func (db *DB) Migrate() (needsReindexing bool, err error) { `PRAGMA user_version = 3`, }) - if err != nil { return err } @@ -185,9 +191,30 @@ func (db *DB) Migrate() (needsReindexing bool, err error) { needsReindexing = true } + if version <= 3 { + err = tx.ExecStmts([]string{ + // Metadata + `CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NO NULL + )`, + }) + if err != nil { + return err + } + } + + if needsReindexing { + metadata := NewMetadataDAO(tx) + // During the next indexing, all notes will be reindexed. + err = metadata.Set(reindexingRequiredKey, "true") + if err != nil { + return err + } + } + return nil }) - err = errors.Wrap(err, "database migration failed") - return + return errors.Wrap(err, "database migration failed") } diff --git a/adapter/sqlite/db_test.go b/internal/adapter/sqlite/db_test.go similarity index 77% rename from adapter/sqlite/db_test.go rename to internal/adapter/sqlite/db_test.go index f19ae95..8f806e1 100644 --- a/adapter/sqlite/db_test.go +++ b/internal/adapter/sqlite/db_test.go @@ -3,8 +3,8 @@ package sqlite import ( "testing" - "github.com/mickael-menu/zk/util/fixtures" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/util/fixtures" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func TestOpen(t *testing.T) { @@ -23,12 +23,6 @@ func TestMigrateFrom0(t *testing.T) { db, err := OpenInMemory() assert.Nil(t, err) - _, err = db.Migrate() - assert.Nil(t, err) - // Should be able to migrate twice in a row - _, err = db.Migrate() - assert.Nil(t, err) - err = db.WithTransaction(func(tx Transaction) error { var version int err := tx.QueryRow("PRAGMA user_version").Scan(&version) diff --git a/internal/adapter/sqlite/metadata_dao.go b/internal/adapter/sqlite/metadata_dao.go new file mode 100644 index 0000000..787e751 --- /dev/null +++ b/internal/adapter/sqlite/metadata_dao.go @@ -0,0 +1,62 @@ +package sqlite + +import ( + "database/sql" + + "github.com/mickael-menu/zk/internal/util/errors" +) + +// Known metadata keys. +var reindexingRequiredKey = "zk.reindexing_required" + +// MetadataDAO persists arbitrary key/value pairs in the SQLite database. +type MetadataDAO struct { + tx Transaction + + // Prepared SQL statements + getStmt *LazyStmt + setStmt *LazyStmt +} + +// NewMetadataDAO creates a new instance of a DAO working on the given +// database transaction. +func NewMetadataDAO(tx Transaction) *MetadataDAO { + return &MetadataDAO{ + tx: tx, + getStmt: tx.PrepareLazy(` + SELECT key, value FROM metadata WHERE key = ? + `), + setStmt: tx.PrepareLazy(` + INSERT OR REPLACE INTO metadata(key, value) + VALUES (?, ?) + `), + } +} + +// Get returns the value for the given key. +func (d *MetadataDAO) Get(key string) (string, error) { + wrap := errors.Wrapperf("failed to get metadata with key %s", key) + + row, err := d.getStmt.QueryRow(key) + if err != nil { + return "", wrap(err) + } + + var value string + err = row.Scan(&key, &value) + + switch { + case err == sql.ErrNoRows: + return "", nil + case err != nil: + return "", wrap(err) + default: + return value, nil + } +} + +// Set resets the value for the given metadata key. +func (d *MetadataDAO) Set(key string, value string) error { + _, err := d.setStmt.Exec(key, value) + return err +} diff --git a/internal/adapter/sqlite/metadata_dao_test.go b/internal/adapter/sqlite/metadata_dao_test.go new file mode 100644 index 0000000..ef0db73 --- /dev/null +++ b/internal/adapter/sqlite/metadata_dao_test.go @@ -0,0 +1,55 @@ +package sqlite + +import ( + "testing" + + "github.com/mickael-menu/zk/internal/util/test/assert" +) + +func TestMetadataDAOGetUnknown(t *testing.T) { + testMetadataDAO(t, func(tx Transaction, dao *MetadataDAO) { + res, err := dao.Get("unknown") + assert.Nil(t, err) + assert.Equal(t, res, "") + }) +} + +func TestMetadataDAOGetExisting(t *testing.T) { + testMetadataDAO(t, func(tx Transaction, dao *MetadataDAO) { + res, err := dao.Get("a_metadata") + assert.Nil(t, err) + assert.Equal(t, res, "value") + }) +} + +func TestMetadataDAOSetUnknown(t *testing.T) { + testMetadataDAO(t, func(tx Transaction, dao *MetadataDAO) { + res, err := dao.Get("new_metadata") + assert.Nil(t, err) + assert.Equal(t, res, "") + + err = dao.Set("new_metadata", "pamplemousse") + assert.Nil(t, err) + + res, err = dao.Get("new_metadata") + assert.Nil(t, err) + assert.Equal(t, res, "pamplemousse") + }) +} + +func TestMetadataDAOSetExisting(t *testing.T) { + testMetadataDAO(t, func(tx Transaction, dao *MetadataDAO) { + err := dao.Set("a_metadata", "new_value") + assert.Nil(t, err) + + res, err := dao.Get("a_metadata") + assert.Nil(t, err) + assert.Equal(t, res, "new_value") + }) +} + +func testMetadataDAO(t *testing.T, callback func(tx Transaction, dao *MetadataDAO)) { + testTransaction(t, func(tx Transaction) { + callback(tx, NewMetadataDAO(tx)) + }) +} diff --git a/adapter/sqlite/note_dao.go b/internal/adapter/sqlite/note_dao.go similarity index 83% rename from adapter/sqlite/note_dao.go rename to internal/adapter/sqlite/note_dao.go index 403dbdc..a8a378b 100644 --- a/adapter/sqlite/note_dao.go +++ b/internal/adapter/sqlite/note_dao.go @@ -9,19 +9,17 @@ import ( "strings" "time" - "github.com/mickael-menu/zk/core" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/errors" - "github.com/mickael-menu/zk/util/fts5" - "github.com/mickael-menu/zk/util/icu" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/paths" - strutil "github.com/mickael-menu/zk/util/strings" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/errors" + "github.com/mickael-menu/zk/internal/util/fts5" + "github.com/mickael-menu/zk/internal/util/icu" + "github.com/mickael-menu/zk/internal/util/opt" + "github.com/mickael-menu/zk/internal/util/paths" + strutil "github.com/mickael-menu/zk/internal/util/strings" ) // NoteDAO persists notes in the SQLite database. -// It implements the core port note.Finder. type NoteDAO struct { tx Transaction logger util.Logger @@ -151,7 +149,7 @@ func (d *NoteDAO) Indexed() (<-chan paths.Metadata, error) { } // Add inserts a new note to the index. -func (d *NoteDAO) Add(note note.Metadata) (core.NoteId, error) { +func (d *NoteDAO) Add(note core.Note) (core.NoteID, error) { // For sortable_path, we replace in path / by the shortest non printable // character available to make it sortable. Without this, sorting by the // path would be a lexicographical sort instead of being the same order @@ -172,16 +170,16 @@ func (d *NoteDAO) Add(note note.Metadata) (core.NoteId, error) { lastId, err := res.LastInsertId() if err != nil { - return core.NoteId(0), err + return 0, err } - id := core.NoteId(lastId) + id := core.NoteID(lastId) err = d.addLinks(id, note) return id, err } // Update modifies an existing note. -func (d *NoteDAO) Update(note note.Metadata) (core.NoteId, error) { +func (d *NoteDAO) Update(note core.Note) (core.NoteID, error) { id, err := d.findIdByPath(note.Path) if err != nil { return 0, err @@ -208,7 +206,7 @@ func (d *NoteDAO) Update(note note.Metadata) (core.NoteId, error) { return id, err } -func (d *NoteDAO) metadataToJSON(note note.Metadata) string { +func (d *NoteDAO) metadataToJSON(note core.Note) string { json, err := json.Marshal(note.Metadata) if err != nil { // Failure to serialize the metadata to JSON should not prevent the @@ -220,14 +218,14 @@ func (d *NoteDAO) metadataToJSON(note note.Metadata) string { } // addLinks inserts all the outbound links of the given note. -func (d *NoteDAO) addLinks(id core.NoteId, note note.Metadata) error { +func (d *NoteDAO) addLinks(id core.NoteID, note core.Note) error { for _, link := range note.Links { targetId, err := d.findIdByPathPrefix(link.Href) if err != nil { return err } - _, err = d.addLinkStmt.Exec(id, d.idToSql(targetId), link.Title, link.Href, link.External, joinLinkRels(link.Rels), link.Snippet, link.SnippetStart, link.SnippetEnd) + _, err = d.addLinkStmt.Exec(id, d.idToSql(targetId), link.Title, link.Href, link.IsExternal, joinLinkRels(link.Rels), link.Snippet, link.SnippetStart, link.SnippetEnd) if err != nil { return err } @@ -239,12 +237,16 @@ func (d *NoteDAO) addLinks(id core.NoteId, note note.Metadata) error { // joinLinkRels will concatenate a list of rels into a SQLite ready string. // Each rel is delimited by \x01 for easy matching in queries. -func joinLinkRels(rels []string) string { +func joinLinkRels(rels []core.LinkRelation) string { if len(rels) == 0 { return "" } delimiter := "\x01" - return delimiter + strings.Join(rels, delimiter) + delimiter + res := delimiter + for _, rel := range rels { + res += string(rel) + delimiter + } + return res } // Remove deletes the note with the given path from the index. @@ -261,16 +263,16 @@ func (d *NoteDAO) Remove(path string) error { return err } -func (d *NoteDAO) findIdByPath(path string) (core.NoteId, error) { +func (d *NoteDAO) findIdByPath(path string) (core.NoteID, error) { row, err := d.findIdByPathStmt.QueryRow(path) if err != nil { - return core.NoteId(0), err + return core.NoteID(0), err } return idForRow(row) } -func (d *NoteDAO) findIdsByPathPrefixes(paths []string) ([]core.NoteId, error) { - ids := make([]core.NoteId, 0) +func (d *NoteDAO) findIdsByPathPrefixes(paths []string) ([]core.NoteID, error) { + ids := make([]core.NoteID, 0) for _, path := range paths { id, err := d.findIdByPathPrefix(path) if err != nil { @@ -287,71 +289,107 @@ func (d *NoteDAO) findIdsByPathPrefixes(paths []string) ([]core.NoteId, error) { return ids, nil } -func (d *NoteDAO) findIdByPathPrefix(path string) (core.NoteId, error) { +func (d *NoteDAO) findIdByPathPrefix(path string) (core.NoteID, error) { row, err := d.findIdByPathPrefixStmt.QueryRow(path) if err != nil { - return core.NoteId(0), err + return 0, err } return idForRow(row) } -func idForRow(row *sql.Row) (core.NoteId, error) { +func idForRow(row *sql.Row) (core.NoteID, error) { var id sql.NullInt64 err := row.Scan(&id) switch { case err == sql.ErrNoRows: - return core.NoteId(0), nil + return 0, nil case err != nil: - return core.NoteId(0), err + return 0, err default: - return core.NoteId(id.Int64), nil + return core.NoteID(id.Int64), nil } } -// FindByHref returns the first note matching the given link href. -func (d *NoteDAO) FindByHref(href string) (*note.Match, error) { - id, err := d.findIdByPathPrefix(href) +func (d *NoteDAO) FindMinimal(opts core.NoteFindOpts) ([]core.MinimalNote, error) { + notes := make([]core.MinimalNote, 0) + + opts, err := d.expandMentionsIntoMatch(opts) if err != nil { - return nil, err + return notes, err } - row, err := d.findByIdStmt.QueryRow(id) + + rows, err := d.findRows(opts, true) if err != nil { + return notes, err + } + defer rows.Close() + + for rows.Next() { + note, err := d.scanMinimalNote(rows) + if err != nil { + d.logger.Err(err) + continue + } + if note != nil { + notes = append(notes, *note) + } + } + + return notes, nil +} + +func (d *NoteDAO) scanMinimalNote(row RowScanner) (*core.MinimalNote, error) { + var ( + id int + path, title string + ) + + err := row.Scan(&id, &path, &title) + switch { + case err == sql.ErrNoRows: + return nil, nil + case err != nil: return nil, err + default: + return &core.MinimalNote{ + ID: core.NoteID(id), + Path: path, + Title: title, + }, nil } - return d.scanNoteMatch(row) } // Find returns all the notes matching the given criteria. -func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) { - matches := make([]note.Match, 0) +func (d *NoteDAO) Find(opts core.NoteFindOpts) ([]core.ContextualNote, error) { + notes := make([]core.ContextualNote, 0) opts, err := d.expandMentionsIntoMatch(opts) if err != nil { - return matches, err + return notes, err } - rows, err := d.findRows(opts) + rows, err := d.findRows(opts, false) if err != nil { - return matches, err + return notes, err } defer rows.Close() for rows.Next() { - match, err := d.scanNoteMatch(rows) + note, err := d.scanNote(rows) if err != nil { d.logger.Err(err) continue } - if match != nil { - matches = append(matches, *match) + if note != nil { + notes = append(notes, *note) } } - return matches, nil + return notes, nil } -func (d *NoteDAO) scanNoteMatch(row RowScanner) (*note.Match, error) { +func (d *NoteDAO) scanNote(row RowScanner) (*core.ContextualNote, error) { var ( id, wordCount int title, lead, body, rawContent string @@ -375,16 +413,17 @@ func (d *NoteDAO) scanNoteMatch(row RowScanner) (*note.Match, error) { d.logger.Err(errors.Wrap(err, path)) } - return ¬e.Match{ + return &core.ContextualNote{ Snippets: parseListFromNullString(snippets), - Metadata: note.Metadata{ + Note: core.Note{ + ID: core.NoteID(id), Path: path, Title: title, Lead: lead, Body: body, RawContent: rawContent, WordCount: wordCount, - Links: []note.Link{}, + Links: []core.Link{}, Tags: parseListFromNullString(tags), Metadata: metadata, Created: created, @@ -407,7 +446,7 @@ func parseListFromNullString(str sql.NullString) []string { // expandMentionsIntoMatch finds the titles associated with the notes in opts.Mention to // expand them into the opts.Match predicate. -func (d *NoteDAO) expandMentionsIntoMatch(opts note.FinderOpts) (note.FinderOpts, error) { +func (d *NoteDAO) expandMentionsIntoMatch(opts core.NoteFindOpts) (core.NoteFindOpts, error) { if opts.Mention == nil { return opts, nil } @@ -419,7 +458,9 @@ func (d *NoteDAO) expandMentionsIntoMatch(opts note.FinderOpts) (note.FinderOpts } // Exclude the mentioned notes from the results. - opts = opts.ExcludingIds(ids...) + for _, id := range ids { + opts = opts.ExcludingID(id) + } // Find their titles. titlesQuery := "SELECT title, metadata FROM notes WHERE id IN (" + d.joinIds(ids, ",") + ")" @@ -453,7 +494,7 @@ func (d *NoteDAO) expandMentionsIntoMatch(opts note.FinderOpts) (note.FinderOpts return opts, nil } -func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) { +func (d *NoteDAO) findRows(opts core.NoteFindOpts, minimal bool) (*sql.Rows, error) { snippetCol := `n.lead` joinClauses := []string{} whereExprs := []string{} @@ -600,7 +641,7 @@ func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) { SELECT note_id FROM notes_collections WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s)) )`, - note.CollectionKindTag, + core.CollectionKindTag, strings.Join(globs, " OR "), ) whereExprs = append(whereExprs, expr) @@ -614,7 +655,9 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s)) } // Exclude the mentioning notes from the results. - opts = opts.ExcludingIds(ids...) + for _, id := range ids { + opts = opts.ExcludingID(id) + } snippetCol = `snippet(nsrc.notes_fts, 2, '', '', '…', 20)` joinClauses = append(joinClauses, "JOIN notes_fts nsrc ON nsrc.rowid IN ("+d.joinIds(ids, ",")+") AND nsrc.notes_fts MATCH mention_query(n.title, n.metadata)") @@ -673,8 +716,8 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s)) args = append(args, opts.ModifiedEnd) } - if opts.ExcludeIds != nil { - whereExprs = append(whereExprs, "n.id NOT IN ("+d.joinIds(opts.ExcludeIds, ",")+")") + if opts.ExcludeIDs != nil { + whereExprs = append(whereExprs, "n.id NOT IN ("+d.joinIds(opts.ExcludeIDs, ",")+")") } orderTerms := []string{} @@ -716,9 +759,12 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s)) query += "\n)\n" } - query += fmt.Sprintf("SELECT n.id, n.path, n.title, n.lead, n.body, n.raw_content, n.word_count, n.created, n.modified, n.metadata, n.checksum, n.tags, %s AS snippet\n", snippetCol) + query += "SELECT n.id, n.path, n.title" + if !minimal { + query += fmt.Sprintf(", n.lead, n.body, n.raw_content, n.word_count, n.created, n.modified, n.metadata, n.checksum, n.tags, %s AS snippet", snippetCol) + } - query += "FROM notes_with_metadata n\n" + query += "\nFROM notes_with_metadata n\n" for _, clause := range joinClauses { query += clause + "\n" @@ -738,32 +784,35 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s)) query += fmt.Sprintf("LIMIT %d\n", opts.Limit) } - // fmt.Println(query) - // fmt.Println(args) + // d.logger.Println(query) + // d.logger.Println(args) + return d.tx.Query(query, args...) } -func orderTerm(sorter note.Sorter) string { +func orderTerm(sorter core.NoteSorter) string { order := " ASC" if !sorter.Ascending { order = " DESC" } switch sorter.Field { - case note.SortCreated: + case core.NoteSortCreated: return "n.created" + order - case note.SortModified: + case core.NoteSortModified: return "n.modified" + order - case note.SortPath: + case core.NoteSortPath: return "n.path" + order - case note.SortRandom: + case core.NoteSortRandom: return "RANDOM()" - case note.SortTitle: + case core.NoteSortTitle: return "n.title" + order - case note.SortWordCount: + case core.NoteSortWordCount: return "n.word_count" + order + case core.NoteSortPathLength: + return "LENGTH(path)" + order default: - panic(fmt.Sprintf("%v: unknown note.SortField", sorter.Field)) + panic(fmt.Sprintf("%v: unknown core.NoteSortField", sorter.Field)) } } @@ -774,7 +823,7 @@ func pathRegex(path string) string { return path + "[^/]*|" + path + "/.+" } -func (d *NoteDAO) idToSql(id core.NoteId) sql.NullInt64 { +func (d *NoteDAO) idToSql(id core.NoteID) sql.NullInt64 { if id.IsValid() { return sql.NullInt64{Int64: int64(id), Valid: true} } else { @@ -782,7 +831,7 @@ func (d *NoteDAO) idToSql(id core.NoteId) sql.NullInt64 { } } -func (d *NoteDAO) joinIds(ids []core.NoteId, delimiter string) string { +func (d *NoteDAO) joinIds(ids []core.NoteID, delimiter string) string { strs := make([]string, 0) for _, i := range ids { strs = append(strs, strconv.FormatInt(int64(i), 10)) diff --git a/adapter/sqlite/note_dao_test.go b/internal/adapter/sqlite/note_dao_test.go similarity index 79% rename from adapter/sqlite/note_dao_test.go rename to internal/adapter/sqlite/note_dao_test.go index 778cb90..f422162 100644 --- a/adapter/sqlite/note_dao_test.go +++ b/internal/adapter/sqlite/note_dao_test.go @@ -6,17 +6,16 @@ import ( "testing" "time" - "github.com/mickael-menu/zk/core" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/paths" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/opt" + "github.com/mickael-menu/zk/internal/util/paths" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func TestNoteDAOIndexed(t *testing.T) { testNoteDAOWithFixtures(t, "", func(tx Transaction, dao *NoteDAO) { - for _, note := range []note.Metadata{ + for _, note := range []core.Note{ { Path: "a.md", Modified: time.Date(2020, 1, 20, 8, 52, 42, 0, time.UTC), @@ -105,7 +104,7 @@ func TestNoteDAOIndexed(t *testing.T) { func TestNoteDAOAdd(t *testing.T) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { - _, err := dao.Add(note.Metadata{ + _, err := dao.Add(core.Note{ Path: "log/added.md", Title: "Added note", Lead: "Note", @@ -138,13 +137,13 @@ func TestNoteDAOAdd(t *testing.T) { func TestNoteDAOAddWithLinks(t *testing.T) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { - id, err := dao.Add(note.Metadata{ + id, err := dao.Add(core.Note{ Path: "log/added.md", - Links: []note.Link{ + Links: []core.Link{ { Title: "Same dir", Href: "log/2021-01-04", - Rels: []string{"rel-1", "rel-2"}, + Rels: core.LinkRels("rel-1", "rel-2"), }, { Title: "Relative", @@ -156,17 +155,17 @@ func TestNoteDAOAddWithLinks(t *testing.T) { { Title: "Second is added", Href: "f39c8", - Rels: []string{"second"}, + Rels: core.LinkRels("second"), }, { Title: "Unknown", Href: "unknown", }, { - Title: "URL", - Href: "http://example.com", - External: true, - Snippet: "External [URL](http://example.com)", + Title: "URL", + Href: "http://example.com", + IsExternal: true, + Snippet: "External [URL](http://example.com)", }, }, }) @@ -206,13 +205,13 @@ func TestNoteDAOAddWithLinks(t *testing.T) { Rels: "", }, { - SourceId: id, - TargetId: nil, - Title: "URL", - Href: "http://example.com", - External: true, - Rels: "", - Snippet: "External [URL](http://example.com)", + SourceId: id, + TargetId: nil, + Title: "URL", + Href: "http://example.com", + IsExternal: true, + Rels: "", + Snippet: "External [URL](http://example.com)", }, }) }) @@ -220,7 +219,7 @@ func TestNoteDAOAddWithLinks(t *testing.T) { func TestNoteDAOAddFillsLinksMissingTargetId(t *testing.T) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { - id, err := dao.Add(note.Metadata{ + id, err := dao.Add(core.Note{ Path: "missing_target.md", }) assert.Nil(t, err) @@ -241,14 +240,14 @@ func TestNoteDAOAddFillsLinksMissingTargetId(t *testing.T) { // Check that we can't add a duplicate note with an existing path. func TestNoteDAOAddExistingNote(t *testing.T) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { - _, err := dao.Add(note.Metadata{Path: "ref/test/a.md"}) + _, err := dao.Add(core.Note{Path: "ref/test/a.md"}) assert.Err(t, err, "UNIQUE constraint failed: notes.path") }) } func TestNoteDAOUpdate(t *testing.T) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { - id, err := dao.Update(note.Metadata{ + id, err := dao.Update(core.Note{ Path: "ref/test/a.md", Title: "Updated note", Lead: "Updated lead", @@ -261,7 +260,7 @@ func TestNoteDAOUpdate(t *testing.T) { Modified: time.Date(2020, 11, 22, 16, 49, 47, 0, time.UTC), }) assert.Nil(t, err) - assert.Equal(t, id, core.NoteId(6)) + assert.Equal(t, id, core.NoteID(6)) row, err := queryNoteRow(tx, `path = "ref/test/a.md"`) assert.Nil(t, err) @@ -282,7 +281,7 @@ func TestNoteDAOUpdate(t *testing.T) { func TestNoteDAOUpdateUnknown(t *testing.T) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { - _, err := dao.Update(note.Metadata{ + _, err := dao.Update(core.Note{ Path: "unknown/unknown.md", }) assert.Err(t, err, "note not found in the index") @@ -301,30 +300,30 @@ func TestNoteDAOUpdateWithLinks(t *testing.T) { Snippet: "[[An internal link]]", }, { - SourceId: 1, - TargetId: nil, - Title: "An external link", - Href: "https://domain.com", - External: true, - Snippet: "[[An external link]]", + SourceId: 1, + TargetId: nil, + Title: "An external link", + Href: "https://domain.com", + IsExternal: true, + Snippet: "[[An external link]]", }, }) - _, err := dao.Update(note.Metadata{ + _, err := dao.Update(core.Note{ Path: "log/2021-01-03.md", - Links: []note.Link{ + Links: []core.Link{ { - Title: "A new link", - Href: "index", - External: false, - Rels: []string{"rel"}, - Snippet: "[[A new link]]", + Title: "A new link", + Href: "index", + IsExternal: false, + Rels: core.LinkRels("rel"), + Snippet: "[[A new link]]", }, { - Title: "An external link", - Href: "https://domain.com", - External: true, - Snippet: "[[An external link]]", + Title: "An external link", + Href: "https://domain.com", + IsExternal: true, + Snippet: "[[An external link]]", }, }, }) @@ -341,12 +340,12 @@ func TestNoteDAOUpdateWithLinks(t *testing.T) { Snippet: "[[A new link]]", }, { - SourceId: 1, - TargetId: nil, - Title: "An external link", - Href: "https://domain.com", - External: true, - Snippet: "[[An external link]]", + SourceId: 1, + TargetId: nil, + Title: "An external link", + Href: "https://domain.com", + IsExternal: true, + Snippet: "[[An external link]]", }, }) }) @@ -379,7 +378,7 @@ func TestNoteDAORemoveCascadeLinks(t *testing.T) { assert.Equal(t, len(links) > 0, true) links = queryLinkRows(t, tx, `id = 4`) - assert.Equal(t, *links[0].TargetId, core.NoteId(1)) + assert.Equal(t, *links[0].TargetId, core.NoteID(1)) err := dao.Remove("log/2021-01-03.md") assert.Nil(t, err) @@ -392,76 +391,49 @@ func TestNoteDAORemoveCascadeLinks(t *testing.T) { }) } -func TestNoteDAOFindByHref(t *testing.T) { - test := func(href string, expected *note.Match) { - testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { - actual, err := dao.FindByHref(href) - assert.Nil(t, err) - assert.Equal(t, actual, expected) - }) - } - - test("not-found", nil) - - // Full path - test("log/2021-01-03.md", ¬e.Match{ - Metadata: note.Metadata{ - Path: "log/2021-01-03.md", - Title: "Daily note", - Lead: "A daily note", - Body: "A daily note\n\nWith lot of content", - RawContent: "# A daily note\nA daily note\n\nWith lot of content", - WordCount: 3, - Links: []note.Link{}, - Tags: []string{"fiction", "adventure"}, - Metadata: map[string]interface{}{ - "author": "Dom", - }, - Created: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC), - Modified: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC), - Checksum: "qwfpgj", - }, - Snippets: []string{"A daily note"}, - }) +func TestNoteDAOFindMinimalAll(t *testing.T) { + testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { + notes, err := dao.FindMinimal(core.NoteFindOpts{}) + assert.Nil(t, err) - // Prefix - test("log/2021-01", ¬e.Match{ - Metadata: note.Metadata{ - Path: "log/2021-01-03.md", - Title: "Daily note", - Lead: "A daily note", - Body: "A daily note\n\nWith lot of content", - RawContent: "# A daily note\nA daily note\n\nWith lot of content", - WordCount: 3, - Links: []note.Link{}, - Tags: []string{"fiction", "adventure"}, - Metadata: map[string]interface{}{ - "author": "Dom", - }, - Created: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC), - Modified: time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC), - Checksum: "qwfpgj", - }, - Snippets: []string{"A daily note"}, + assert.Equal(t, notes, []core.MinimalNote{ + {ID: 5, Path: "ref/test/b.md", Title: "A nested note"}, + {ID: 4, Path: "f39c8.md", Title: "An interesting note"}, + {ID: 6, Path: "ref/test/a.md", Title: "Another nested note"}, + {ID: 1, Path: "log/2021-01-03.md", Title: "Daily note"}, + {ID: 7, Path: "log/2021-02-04.md", Title: "February 4, 2021"}, + {ID: 3, Path: "index.md", Title: "Index"}, + {ID: 2, Path: "log/2021-01-04.md", Title: "January 4, 2021"}, + }) }) +} - // Prefix matches the shortest path (fixes https://github.com/mickael-menu/zk/issues/23) - testNoteDAOWithFixtures(t, "issue23", func(tx Transaction, dao *NoteDAO) { - actual, err := dao.FindByHref("prefix") +func TestNoteDAOFindMinimalWithFilter(t *testing.T) { + testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { + notes, err := dao.FindMinimal(core.NoteFindOpts{ + Match: opt.NewString("daily | index"), + Sorters: []core.NoteSorter{{Field: core.NoteSortWordCount, Ascending: true}}, + Limit: 3, + }) assert.Nil(t, err) - assert.Equal(t, actual.Path, "prefix-short.md") + + assert.Equal(t, notes, []core.MinimalNote{ + {ID: 1, Path: "log/2021-01-03.md", Title: "Daily note"}, + {ID: 3, Path: "index.md", Title: "Index"}, + {ID: 7, Path: "log/2021-02-04.md", Title: "February 4, 2021"}, + }) }) } func TestNoteDAOFindAll(t *testing.T) { - testNoteDAOFindPaths(t, note.FinderOpts{}, []string{ + testNoteDAOFindPaths(t, core.NoteFindOpts{}, []string{ "ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-01-03.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md", }) } func TestNoteDAOFindLimit(t *testing.T) { - testNoteDAOFindPaths(t, note.FinderOpts{Limit: 2}, []string{ + testNoteDAOFindPaths(t, core.NoteFindOpts{Limit: 2}, []string{ "ref/test/b.md", "f39c8.md", }) @@ -469,7 +441,7 @@ func TestNoteDAOFindLimit(t *testing.T) { func TestNoteDAOFindTag(t *testing.T) { test := func(tags []string, expectedPaths []string) { - testNoteDAOFindPaths(t, note.FinderOpts{Tags: tags}, expectedPaths) + testNoteDAOFindPaths(t, core.NoteFindOpts{Tags: tags}, expectedPaths) } test([]string{"fiction"}, []string{"log/2021-01-03.md"}) @@ -488,17 +460,18 @@ func TestNoteDAOFindTag(t *testing.T) { func TestNoteDAOFindMatch(t *testing.T) { testNoteDAOFind(t, - note.FinderOpts{Match: opt.NewString("daily | index")}, - []note.Match{ + core.NoteFindOpts{Match: opt.NewString("daily | index")}, + []core.ContextualNote{ { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 3, Path: "index.md", Title: "Index", Lead: "Index of the Zettelkasten", Body: "Index of the Zettelkasten", RawContent: "# Index\nIndex of the Zettelkasten", WordCount: 4, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{}, Metadata: map[string]interface{}{ "aliases": []interface{}{"First page"}, @@ -510,14 +483,15 @@ func TestNoteDAOFindMatch(t *testing.T) { Snippets: []string{"Index of the Zettelkasten"}, }, { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 1, Path: "log/2021-01-03.md", Title: "Daily note", Lead: "A daily note", Body: "A daily note\n\nWith lot of content", RawContent: "# A daily note\nA daily note\n\nWith lot of content", WordCount: 3, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{"fiction", "adventure"}, Metadata: map[string]interface{}{ "author": "Dom", @@ -529,14 +503,15 @@ func TestNoteDAOFindMatch(t *testing.T) { Snippets: []string{"A daily note\n\nWith lot of content"}, }, { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 7, Path: "log/2021-02-04.md", Title: "February 4, 2021", Lead: "A third daily note", Body: "A third daily note", RawContent: "# A third daily note", WordCount: 4, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{}, Metadata: map[string]interface{}{}, Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC), @@ -546,14 +521,15 @@ func TestNoteDAOFindMatch(t *testing.T) { Snippets: []string{"A third daily note"}, }, { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 2, Path: "log/2021-01-04.md", Title: "January 4, 2021", Lead: "A second daily note", Body: "A second daily note", RawContent: "# A second daily note", WordCount: 4, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{}, Metadata: map[string]interface{}{}, Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC), @@ -568,10 +544,10 @@ func TestNoteDAOFindMatch(t *testing.T) { func TestNoteDAOFindMatchWithSort(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ Match: opt.NewString("daily | index"), - Sorters: []note.Sorter{ - {Field: note.SortPath, Ascending: false}, + Sorters: []core.NoteSorter{ + {Field: core.NoteSortPath, Ascending: false}, }, }, []string{ @@ -585,7 +561,7 @@ func TestNoteDAOFindMatchWithSort(t *testing.T) { func TestNoteDAOFindInPathAbsoluteFile(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ IncludePaths: []string{"log/2021-01-03.md"}, }, []string{"log/2021-01-03.md"}, @@ -595,7 +571,7 @@ func TestNoteDAOFindInPathAbsoluteFile(t *testing.T) { // You can look for files with only their prefix. func TestNoteDAOFindInPathWithFilePrefix(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ IncludePaths: []string{"log/2021-01"}, }, []string{"log/2021-01-03.md", "log/2021-01-04.md"}, @@ -605,13 +581,13 @@ func TestNoteDAOFindInPathWithFilePrefix(t *testing.T) { // For directory, only complete names work, no prefixes. func TestNoteDAOFindInPathRequiresCompleteDirName(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ IncludePaths: []string{"lo"}, }, []string{}, ) testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ IncludePaths: []string{"log"}, }, []string{"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md"}, @@ -621,7 +597,7 @@ func TestNoteDAOFindInPathRequiresCompleteDirName(t *testing.T) { // You can look for multiple paths, in which case notes can be in any of them. func TestNoteDAOFindInMultiplePaths(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ IncludePaths: []string{"ref", "index.md"}, }, []string{"ref/test/b.md", "ref/test/a.md", "index.md"}, @@ -630,7 +606,7 @@ func TestNoteDAOFindInMultiplePaths(t *testing.T) { func TestNoteDAOFindExcludingPath(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ ExcludePaths: []string{"log"}, }, []string{"ref/test/b.md", "f39c8.md", "ref/test/a.md", "index.md"}, @@ -639,7 +615,7 @@ func TestNoteDAOFindExcludingPath(t *testing.T) { func TestNoteDAOFindExcludingMultiplePaths(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ ExcludePaths: []string{"ref", "log/2021-01"}, }, []string{"f39c8.md", "log/2021-02-04.md", "index.md"}, @@ -648,17 +624,18 @@ func TestNoteDAOFindExcludingMultiplePaths(t *testing.T) { func TestNoteDAOFindMentions(t *testing.T) { testNoteDAOFind(t, - note.FinderOpts{Mention: []string{"log/2021-01-03.md", "index.md"}}, - []note.Match{ + core.NoteFindOpts{Mention: []string{"log/2021-01-03.md", "index.md"}}, + []core.ContextualNote{ { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 5, Path: "ref/test/b.md", Title: "A nested note", Lead: "This one is in a sub sub directory", Body: "This one is in a sub sub directory, not the first page", RawContent: "# A nested note\nThis one is in a sub sub directory", WordCount: 8, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{"adventure", "history"}, Metadata: map[string]interface{}{}, Created: time.Date(2019, 11, 20, 20, 32, 56, 0, time.UTC), @@ -668,14 +645,15 @@ func TestNoteDAOFindMentions(t *testing.T) { Snippets: []string{"This one is in a sub sub directory, not the first page"}, }, { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 7, Path: "log/2021-02-04.md", Title: "February 4, 2021", Lead: "A third daily note", Body: "A third daily note", RawContent: "# A third daily note", WordCount: 4, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{}, Metadata: map[string]interface{}{}, Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC), @@ -685,14 +663,15 @@ func TestNoteDAOFindMentions(t *testing.T) { Snippets: []string{"A third daily note"}, }, { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 2, Path: "log/2021-01-04.md", Title: "January 4, 2021", Lead: "A second daily note", Body: "A second daily note", RawContent: "# A second daily note", WordCount: 4, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{}, Metadata: map[string]interface{}{}, Created: time.Date(2020, 11, 29, 8, 20, 18, 0, time.UTC), @@ -708,9 +687,9 @@ func TestNoteDAOFindMentions(t *testing.T) { // Common use case: `--mention x --no-link-to x` func TestNoteDAOFindUnlinkedMentions(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ Mention: []string{"log/2021-01-03.md", "index.md"}, - LinkTo: ¬e.LinkFilter{ + LinkTo: &core.LinkFilter{ Paths: []string{"log/2021-01-03.md", "index.md"}, Negate: true, }, @@ -721,17 +700,18 @@ func TestNoteDAOFindUnlinkedMentions(t *testing.T) { func TestNoteDAOFindMentionedBy(t *testing.T) { testNoteDAOFind(t, - note.FinderOpts{MentionedBy: []string{"ref/test/b.md", "log/2021-01-04.md"}}, - []note.Match{ + core.NoteFindOpts{MentionedBy: []string{"ref/test/b.md", "log/2021-01-04.md"}}, + []core.ContextualNote{ { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 1, Path: "log/2021-01-03.md", Title: "Daily note", Lead: "A daily note", Body: "A daily note\n\nWith lot of content", RawContent: "# A daily note\nA daily note\n\nWith lot of content", WordCount: 3, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{"fiction", "adventure"}, Metadata: map[string]interface{}{ "author": "Dom", @@ -743,14 +723,15 @@ func TestNoteDAOFindMentionedBy(t *testing.T) { Snippets: []string{"A second daily note"}, }, { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 3, Path: "index.md", Title: "Index", Lead: "Index of the Zettelkasten", Body: "Index of the Zettelkasten", RawContent: "# Index\nIndex of the Zettelkasten", WordCount: 4, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{}, Metadata: map[string]interface{}{ "aliases": []interface{}{ @@ -770,9 +751,9 @@ func TestNoteDAOFindMentionedBy(t *testing.T) { // Common use case: `--mentioned-by x --no-linked-by x` func TestNoteDAOFindUnlinkedMentionedBy(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ MentionedBy: []string{"ref/test/b.md", "log/2021-01-04.md"}, - LinkedBy: ¬e.LinkFilter{ + LinkedBy: &core.LinkFilter{ Paths: []string{"ref/test/b.md", "log/2021-01-04.md"}, Negate: true, }, @@ -783,8 +764,8 @@ func TestNoteDAOFindUnlinkedMentionedBy(t *testing.T) { func TestNoteDAOFindLinkedBy(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ - LinkedBy: ¬e.LinkFilter{ + core.NoteFindOpts{ + LinkedBy: &core.LinkFilter{ Paths: []string{"f39c8.md", "log/2021-01-03"}, Negate: false, Recursive: false, @@ -796,8 +777,8 @@ func TestNoteDAOFindLinkedBy(t *testing.T) { func TestNoteDAOFindLinkedByRecursive(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ - LinkedBy: ¬e.LinkFilter{ + core.NoteFindOpts{ + LinkedBy: &core.LinkFilter{ Paths: []string{"log/2021-01-04.md"}, Negate: false, Recursive: true, @@ -809,8 +790,8 @@ func TestNoteDAOFindLinkedByRecursive(t *testing.T) { func TestNoteDAOFindLinkedByRecursiveWithMaxDistance(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ - LinkedBy: ¬e.LinkFilter{ + core.NoteFindOpts{ + LinkedBy: &core.LinkFilter{ Paths: []string{"log/2021-01-04.md"}, Negate: false, Recursive: true, @@ -823,19 +804,20 @@ func TestNoteDAOFindLinkedByRecursiveWithMaxDistance(t *testing.T) { func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) { testNoteDAOFind(t, - note.FinderOpts{ - LinkedBy: ¬e.LinkFilter{Paths: []string{"f39c8.md"}}, + core.NoteFindOpts{ + LinkedBy: &core.LinkFilter{Paths: []string{"f39c8.md"}}, }, - []note.Match{ + []core.ContextualNote{ { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 6, Path: "ref/test/a.md", Title: "Another nested note", Lead: "It shall appear before b.md", Body: "It shall appear before b.md", RawContent: "#Another nested note\nIt shall appear before b.md", WordCount: 5, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{}, Metadata: map[string]interface{}{ "alias": "a.md", @@ -850,14 +832,15 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) { }, }, { - Metadata: note.Metadata{ + Note: core.Note{ + ID: 1, Path: "log/2021-01-03.md", Title: "Daily note", Lead: "A daily note", Body: "A daily note\n\nWith lot of content", RawContent: "# A daily note\nA daily note\n\nWith lot of content", WordCount: 3, - Links: []note.Link{}, + Links: []core.Link{}, Tags: []string{"fiction", "adventure"}, Metadata: map[string]interface{}{ "author": "Dom", @@ -876,8 +859,8 @@ func TestNoteDAOFindLinkedByWithSnippets(t *testing.T) { func TestNoteDAOFindNotLinkedBy(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ - LinkedBy: ¬e.LinkFilter{ + core.NoteFindOpts{ + LinkedBy: &core.LinkFilter{ Paths: []string{"f39c8.md", "log/2021-01-03"}, Negate: true, Recursive: false, @@ -889,8 +872,8 @@ func TestNoteDAOFindNotLinkedBy(t *testing.T) { func TestNoteDAOFindLinkTo(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ - LinkTo: ¬e.LinkFilter{ + core.NoteFindOpts{ + LinkTo: &core.LinkFilter{ Paths: []string{"log/2021-01-04", "ref/test/a.md"}, Negate: false, Recursive: false, @@ -902,8 +885,8 @@ func TestNoteDAOFindLinkTo(t *testing.T) { func TestNoteDAOFindLinkToRecursive(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ - LinkTo: ¬e.LinkFilter{ + core.NoteFindOpts{ + LinkTo: &core.LinkFilter{ Paths: []string{"log/2021-01-04.md"}, Negate: false, Recursive: true, @@ -915,8 +898,8 @@ func TestNoteDAOFindLinkToRecursive(t *testing.T) { func TestNoteDAOFindLinkToRecursiveWithMaxDistance(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ - LinkTo: ¬e.LinkFilter{ + core.NoteFindOpts{ + LinkTo: &core.LinkFilter{ Paths: []string{"log/2021-01-04.md"}, Negate: false, Recursive: true, @@ -929,8 +912,8 @@ func TestNoteDAOFindLinkToRecursiveWithMaxDistance(t *testing.T) { func TestNoteDAOFindNotLinkTo(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ - LinkTo: ¬e.LinkFilter{Paths: []string{"log/2021-01-04", "ref/test/a.md"}, Negate: true}, + core.NoteFindOpts{ + LinkTo: &core.LinkFilter{Paths: []string{"log/2021-01-04", "ref/test/a.md"}, Negate: true}, }, []string{"ref/test/b.md", "ref/test/a.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md"}, ) @@ -938,14 +921,14 @@ func TestNoteDAOFindNotLinkTo(t *testing.T) { func TestNoteDAOFindRelated(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ Related: []string{"log/2021-02-04"}, }, []string{}, ) testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ Related: []string{"log/2021-01-03.md"}, }, []string{"index.md"}, @@ -954,7 +937,7 @@ func TestNoteDAOFindRelated(t *testing.T) { func TestNoteDAOFindOrphan(t *testing.T) { testNoteDAOFindPaths(t, - note.FinderOpts{Orphan: true}, + core.NoteFindOpts{Orphan: true}, []string{"ref/test/b.md", "log/2021-02-04.md"}, ) } @@ -963,7 +946,7 @@ func TestNoteDAOFindCreatedOn(t *testing.T) { start := time.Date(2020, 11, 22, 0, 0, 0, 0, time.UTC) end := time.Date(2020, 11, 23, 0, 0, 0, 0, time.UTC) testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ CreatedStart: &start, CreatedEnd: &end, }, @@ -974,7 +957,7 @@ func TestNoteDAOFindCreatedOn(t *testing.T) { func TestNoteDAOFindCreatedBefore(t *testing.T) { end := time.Date(2019, 12, 04, 11, 59, 11, 0, time.UTC) testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ CreatedEnd: &end, }, []string{"ref/test/b.md", "ref/test/a.md"}, @@ -984,7 +967,7 @@ func TestNoteDAOFindCreatedBefore(t *testing.T) { func TestNoteDAOFindCreatedAfter(t *testing.T) { start := time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC) testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ CreatedStart: &start, }, []string{"log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md"}, @@ -995,7 +978,7 @@ func TestNoteDAOFindModifiedOn(t *testing.T) { start := time.Date(2020, 01, 20, 0, 0, 0, 0, time.UTC) end := time.Date(2020, 01, 21, 0, 0, 0, 0, time.UTC) testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ ModifiedStart: &start, ModifiedEnd: &end, }, @@ -1006,7 +989,7 @@ func TestNoteDAOFindModifiedOn(t *testing.T) { func TestNoteDAOFindModifiedBefore(t *testing.T) { end := time.Date(2020, 01, 20, 8, 52, 42, 0, time.UTC) testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ ModifiedEnd: &end, }, []string{"ref/test/b.md", "ref/test/a.md", "index.md"}, @@ -1016,7 +999,7 @@ func TestNoteDAOFindModifiedBefore(t *testing.T) { func TestNoteDAOFindModifiedAfter(t *testing.T) { start := time.Date(2020, 11, 22, 16, 27, 45, 0, time.UTC) testNoteDAOFindPaths(t, - note.FinderOpts{ + core.NoteFindOpts{ ModifiedStart: &start, }, []string{"log/2021-01-03.md", "log/2021-01-04.md"}, @@ -1024,70 +1007,70 @@ func TestNoteDAOFindModifiedAfter(t *testing.T) { } func TestNoteDAOFindSortCreated(t *testing.T) { - testNoteDAOFindSort(t, note.SortCreated, true, []string{ + testNoteDAOFindSort(t, core.NoteSortCreated, true, []string{ "ref/test/b.md", "ref/test/a.md", "index.md", "f39c8.md", "log/2021-01-03.md", "log/2021-02-04.md", "log/2021-01-04.md", }) - testNoteDAOFindSort(t, note.SortCreated, false, []string{ + testNoteDAOFindSort(t, core.NoteSortCreated, false, []string{ "log/2021-02-04.md", "log/2021-01-04.md", "log/2021-01-03.md", "f39c8.md", "index.md", "ref/test/b.md", "ref/test/a.md", }) } func TestNoteDAOFindSortModified(t *testing.T) { - testNoteDAOFindSort(t, note.SortModified, true, []string{ + testNoteDAOFindSort(t, core.NoteSortModified, true, []string{ "ref/test/b.md", "ref/test/a.md", "index.md", "f39c8.md", "log/2021-02-04.md", "log/2021-01-03.md", "log/2021-01-04.md", }) - testNoteDAOFindSort(t, note.SortModified, false, []string{ + testNoteDAOFindSort(t, core.NoteSortModified, false, []string{ "log/2021-01-04.md", "log/2021-01-03.md", "log/2021-02-04.md", "f39c8.md", "index.md", "ref/test/b.md", "ref/test/a.md", }) } func TestNoteDAOFindSortPath(t *testing.T) { - testNoteDAOFindSort(t, note.SortPath, true, []string{ + testNoteDAOFindSort(t, core.NoteSortPath, true, []string{ "f39c8.md", "index.md", "log/2021-01-03.md", "log/2021-01-04.md", "log/2021-02-04.md", "ref/test/a.md", "ref/test/b.md", }) - testNoteDAOFindSort(t, note.SortPath, false, []string{ + testNoteDAOFindSort(t, core.NoteSortPath, false, []string{ "ref/test/b.md", "ref/test/a.md", "log/2021-02-04.md", "log/2021-01-04.md", "log/2021-01-03.md", "index.md", "f39c8.md", }) } func TestNoteDAOFindSortTitle(t *testing.T) { - testNoteDAOFindSort(t, note.SortTitle, true, []string{ + testNoteDAOFindSort(t, core.NoteSortTitle, true, []string{ "ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-01-03.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md", }) - testNoteDAOFindSort(t, note.SortTitle, false, []string{ + testNoteDAOFindSort(t, core.NoteSortTitle, false, []string{ "log/2021-01-04.md", "index.md", "log/2021-02-04.md", "log/2021-01-03.md", "ref/test/a.md", "f39c8.md", "ref/test/b.md", }) } func TestNoteDAOFindSortWordCount(t *testing.T) { - testNoteDAOFindSort(t, note.SortWordCount, true, []string{ + testNoteDAOFindSort(t, core.NoteSortWordCount, true, []string{ "log/2021-01-03.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md", "f39c8.md", "ref/test/a.md", "ref/test/b.md", }) - testNoteDAOFindSort(t, note.SortWordCount, false, []string{ + testNoteDAOFindSort(t, core.NoteSortWordCount, false, []string{ "ref/test/b.md", "f39c8.md", "ref/test/a.md", "log/2021-02-04.md", "index.md", "log/2021-01-04.md", "log/2021-01-03.md", }) } -func testNoteDAOFindSort(t *testing.T, field note.SortField, ascending bool, expected []string) { +func testNoteDAOFindSort(t *testing.T, field core.NoteSortField, ascending bool, expected []string) { testNoteDAOFindPaths(t, - note.FinderOpts{ - Sorters: []note.Sorter{{Field: field, Ascending: ascending}}, + core.NoteFindOpts{ + Sorters: []core.NoteSorter{{Field: field, Ascending: ascending}}, }, expected, ) } -func testNoteDAOFindPaths(t *testing.T, opts note.FinderOpts, expected []string) { +func testNoteDAOFindPaths(t *testing.T, opts core.NoteFindOpts, expected []string) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { matches, err := dao.Find(opts) assert.Nil(t, err) @@ -1100,7 +1083,7 @@ func testNoteDAOFindPaths(t *testing.T, opts note.FinderOpts, expected []string) }) } -func testNoteDAOFind(t *testing.T, opts note.FinderOpts, expected []note.Match) { +func testNoteDAOFind(t *testing.T, opts core.NoteFindOpts, expected []core.ContextualNote) { testNoteDAO(t, func(tx Transaction, dao *NoteDAO) { actual, err := dao.Find(opts) assert.Nil(t, err) @@ -1137,11 +1120,11 @@ func queryNoteRow(tx Transaction, where string) (noteRow, error) { } type linkRow struct { - SourceId core.NoteId - TargetId *core.NoteId + SourceId core.NoteID + TargetId *core.NoteID Href, Title, Rels, Snippet string SnippetStart, SnippetEnd int - External bool + IsExternal bool } func queryLinkRows(t *testing.T, tx Transaction, where string) []linkRow { @@ -1159,9 +1142,9 @@ func queryLinkRows(t *testing.T, tx Transaction, where string) []linkRow { var row linkRow var sourceId int64 var targetId *int64 - err = rows.Scan(&sourceId, &targetId, &row.Title, &row.Href, &row.External, &row.Rels, &row.Snippet, &row.SnippetStart, &row.SnippetEnd) + err = rows.Scan(&sourceId, &targetId, &row.Title, &row.Href, &row.IsExternal, &row.Rels, &row.Snippet, &row.SnippetStart, &row.SnippetEnd) assert.Nil(t, err) - row.SourceId = core.NoteId(sourceId) + row.SourceId = core.NoteID(sourceId) if targetId != nil { row.TargetId = idPointer(*targetId) } @@ -1173,7 +1156,7 @@ func queryLinkRows(t *testing.T, tx Transaction, where string) []linkRow { return links } -func idPointer(i int64) *core.NoteId { - id := core.NoteId(i) +func idPointer(i int64) *core.NoteID { + id := core.NoteID(i) return &id } diff --git a/internal/adapter/sqlite/note_index.go b/internal/adapter/sqlite/note_index.go new file mode 100644 index 0000000..619a5f3 --- /dev/null +++ b/internal/adapter/sqlite/note_index.go @@ -0,0 +1,171 @@ +package sqlite + +import ( + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/errors" + "github.com/mickael-menu/zk/internal/util/paths" +) + +// NoteIndex persists note indexing results in the SQLite database. +// It implements the port core.NoteIndex and acts as a facade to the DAOs. +type NoteIndex struct { + db *DB + dao *dao + logger util.Logger +} + +type dao struct { + notes *NoteDAO + collections *CollectionDAO + metadata *MetadataDAO +} + +func NewNoteIndex(db *DB, logger util.Logger) *NoteIndex { + return &NoteIndex{ + db: db, + logger: logger, + } +} + +// Find implements core.NoteIndex. +func (ni *NoteIndex) Find(opts core.NoteFindOpts) (notes []core.ContextualNote, err error) { + err = ni.commit(func(dao *dao) error { + notes, err = dao.notes.Find(opts) + return err + }) + return +} + +// FindMinimal implements core.NoteIndex. +func (ni *NoteIndex) FindMinimal(opts core.NoteFindOpts) (notes []core.MinimalNote, err error) { + err = ni.commit(func(dao *dao) error { + notes, err = dao.notes.FindMinimal(opts) + return err + }) + return +} + +// FindCollections implements core.NoteIndex. +func (ni *NoteIndex) FindCollections(kind core.CollectionKind) (collections []core.Collection, err error) { + err = ni.commit(func(dao *dao) error { + collections, err = dao.collections.FindAll(kind) + return err + }) + return +} + +// IndexedPaths implements core.NoteIndex. +func (ni *NoteIndex) IndexedPaths() (metadata <-chan paths.Metadata, err error) { + err = ni.commit(func(dao *dao) error { + metadata, err = dao.notes.Indexed() + return err + }) + err = errors.Wrap(err, "failed to get indexed notes") + return +} + +// Add implements core.NoteIndex. +func (ni *NoteIndex) Add(note core.Note) (id core.NoteID, err error) { + err = ni.commit(func(dao *dao) error { + id, err = dao.notes.Add(note) + if err != nil { + return err + } + + return ni.associateTags(dao.collections, id, note.Tags) + }) + + err = errors.Wrapf(err, "%v: failed to index the note", note.Path) + return +} + +// Update implements core.NoteIndex. +func (ni *NoteIndex) Update(note core.Note) error { + err := ni.commit(func(dao *dao) error { + noteId, err := dao.notes.Update(note) + if err != nil { + return err + } + + err = dao.collections.RemoveAssociations(noteId) + if err != nil { + return err + } + + return ni.associateTags(dao.collections, noteId, note.Tags) + }) + + return errors.Wrapf(err, "%v: failed to update note index", note.Path) +} + +func (ni *NoteIndex) associateTags(collections *CollectionDAO, noteId core.NoteID, tags []string) error { + for _, tag := range tags { + tagId, err := collections.FindOrCreate(core.CollectionKindTag, tag) + if err != nil { + return err + } + _, err = collections.Associate(noteId, tagId) + if err != nil { + return err + } + } + + return nil +} + +// Remove implements core.NoteIndex +func (ni *NoteIndex) Remove(path string) error { + err := ni.commit(func(dao *dao) error { + return dao.notes.Remove(path) + }) + return errors.Wrapf(err, "%v: failed to remove note from index", path) +} + +// Commit implements core.NoteIndex. +func (ni *NoteIndex) Commit(transaction func(idx core.NoteIndex) error) error { + return ni.commit(func(dao *dao) error { + return transaction(&NoteIndex{ + db: ni.db, + dao: dao, + logger: ni.logger, + }) + }) +} + +// NeedsReindexing implements core.NoteIndex. +func (ni *NoteIndex) NeedsReindexing() (needsReindexing bool, err error) { + err = ni.commit(func(dao *dao) error { + res, err := dao.metadata.Get(reindexingRequiredKey) + needsReindexing = (res == "true") + return err + }) + return +} + +// SetNeedsReindexing implements core.NoteIndex. +func (ni *NoteIndex) SetNeedsReindexing(needsReindexing bool) error { + return ni.commit(func(dao *dao) error { + value := "false" + if needsReindexing { + value = "true" + } + + return dao.metadata.Set(reindexingRequiredKey, value) + }) +} + +func (ni *NoteIndex) commit(transaction func(dao *dao) error) error { + if ni.dao != nil { + return transaction(ni.dao) + } else { + return ni.db.WithTransaction(func(tx Transaction) error { + dao := dao{ + notes: NewNoteDAO(tx, ni.logger), + collections: NewCollectionDAO(tx, ni.logger), + metadata: NewMetadataDAO(tx), + } + return transaction(&dao) + }) + } +} diff --git a/internal/adapter/sqlite/note_index_test.go b/internal/adapter/sqlite/note_index_test.go new file mode 100644 index 0000000..13fb26b --- /dev/null +++ b/internal/adapter/sqlite/note_index_test.go @@ -0,0 +1,62 @@ +package sqlite + +import ( + "testing" + + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/test/assert" +) + +// FIXME: Missing tests + +func TestNoteIndexAddWithTags(t *testing.T) { + db, index := testNoteIndex(t) + + assertSQL := func(after bool) { + assertTagExistsOrNot(t, db, true, "fiction") + assertTagExistsOrNot(t, db, after, "new-tag") + } + + assertSQL(false) + id, err := index.Add(core.Note{ + Path: "log/added.md", + Tags: []string{"new-tag", "fiction"}, + }) + assert.Nil(t, err) + assertSQL(true) + assertTaggedOrNot(t, db, true, id, "new-tag") + assertTaggedOrNot(t, db, true, id, "fiction") +} + +func TestNoteIndexUpdateWithTags(t *testing.T) { + db, index := testNoteIndex(t) + id := core.NoteID(1) + + assertSQL := func(after bool) { + assertTaggedOrNot(t, db, true, id, "fiction") + assertTaggedOrNot(t, db, after, id, "new-tag") + assertTaggedOrNot(t, db, after, id, "fantasy") + } + + assertSQL(false) + err := index.Update(core.Note{ + Path: "log/2021-01-03.md", + Tags: []string{"new-tag", "fiction", "fantasy"}, + }) + assert.Nil(t, err) + assertSQL(true) +} + +func testNoteIndex(t *testing.T) (*DB, *NoteIndex) { + db := testDB(t) + return db, NewNoteIndex(db, &util.NullLogger) +} + +func assertTagExistsOrNot(t *testing.T, db *DB, shouldExist bool, tag string) { + assertExistOrNot(t, db, shouldExist, "SELECT id FROM collections WHERE kind = 'tag' AND name = ?", tag) +} + +func assertTaggedOrNot(t *testing.T, db *DB, shouldBeTagged bool, noteId core.NoteID, tag string) { + assertExistOrNot(t, db, shouldBeTagged, "SELECT id FROM notes_collections WHERE note_id = ? AND collection_id IS (SELECT id FROM collections WHERE kind = 'tag' AND name = ?)", noteId, tag) +} diff --git a/adapter/sqlite/stmt.go b/internal/adapter/sqlite/stmt.go similarity index 96% rename from adapter/sqlite/stmt.go rename to internal/adapter/sqlite/stmt.go index a595379..7a3eae8 100644 --- a/adapter/sqlite/stmt.go +++ b/internal/adapter/sqlite/stmt.go @@ -4,7 +4,7 @@ import ( "database/sql" "sync" - "github.com/mickael-menu/zk/util/errors" + "github.com/mickael-menu/zk/internal/util/errors" ) // LazyStmt is a wrapper around a sql.Stmt which will be evaluated on first use. diff --git a/adapter/sqlite/fixtures/default/collections.yml b/internal/adapter/sqlite/testdata/default/collections.yml similarity index 84% rename from adapter/sqlite/fixtures/default/collections.yml rename to internal/adapter/sqlite/testdata/default/collections.yml index 9051d1f..334f7e6 100644 --- a/adapter/sqlite/fixtures/default/collections.yml +++ b/internal/adapter/sqlite/testdata/default/collections.yml @@ -13,3 +13,6 @@ - id: 5 kind: "tag" name: "history" +- id: 6 + kind: "tag" + name: "empty" diff --git a/adapter/sqlite/fixtures/default/links.yml b/internal/adapter/sqlite/testdata/default/links.yml similarity index 100% rename from adapter/sqlite/fixtures/default/links.yml rename to internal/adapter/sqlite/testdata/default/links.yml diff --git a/internal/adapter/sqlite/testdata/default/metadata.yml b/internal/adapter/sqlite/testdata/default/metadata.yml new file mode 100644 index 0000000..62f5444 --- /dev/null +++ b/internal/adapter/sqlite/testdata/default/metadata.yml @@ -0,0 +1,2 @@ +- key: "a_metadata" + value: "value" diff --git a/adapter/sqlite/fixtures/default/notes.yml b/internal/adapter/sqlite/testdata/default/notes.yml similarity index 100% rename from adapter/sqlite/fixtures/default/notes.yml rename to internal/adapter/sqlite/testdata/default/notes.yml diff --git a/adapter/sqlite/fixtures/default/notes_collections.yml b/internal/adapter/sqlite/testdata/default/notes_collections.yml similarity index 100% rename from adapter/sqlite/fixtures/default/notes_collections.yml rename to internal/adapter/sqlite/testdata/default/notes_collections.yml diff --git a/adapter/sqlite/fixtures/issue23/notes.yml b/internal/adapter/sqlite/testdata/issue23/notes.yml similarity index 100% rename from adapter/sqlite/fixtures/issue23/notes.yml rename to internal/adapter/sqlite/testdata/issue23/notes.yml diff --git a/internal/adapter/sqlite/testdata/sample.db b/internal/adapter/sqlite/testdata/sample.db new file mode 100644 index 0000000..db374fb --- /dev/null +++ b/internal/adapter/sqlite/testdata/sample.db @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b7ba9b3ab5296c19e6a7a61ee4ca43116840ef956a1491c670bd7591abbd313 +size 86016 diff --git a/adapter/sqlite/transaction.go b/internal/adapter/sqlite/transaction.go similarity index 100% rename from adapter/sqlite/transaction.go rename to internal/adapter/sqlite/transaction.go diff --git a/internal/adapter/sqlite/transaction_test.go b/internal/adapter/sqlite/transaction_test.go new file mode 100644 index 0000000..20bf95a --- /dev/null +++ b/internal/adapter/sqlite/transaction_test.go @@ -0,0 +1,106 @@ +package sqlite + +import ( + "testing" + + "github.com/go-testfixtures/testfixtures/v3" + "github.com/mickael-menu/zk/internal/util/opt" + "github.com/mickael-menu/zk/internal/util/test/assert" +) + +// testDB is an utility function to create a database loaded with the default fixtures. +func testDB(t *testing.T) *DB { + return testDBWithFixtures(t, opt.NewString("default")) +} + +// testDB is an utility function to create a database loaded with a set of DB fixtures. +func testDBWithFixtures(t *testing.T, fixturesDir opt.String) *DB { + db, err := OpenInMemory() + assert.Nil(t, err) + + if !fixturesDir.IsNull() { + fixtures, err := testfixtures.New( + testfixtures.Database(db.db), + testfixtures.Dialect("sqlite"), + testfixtures.Directory("testdata/"+fixturesDir.String()), + // Necessary to work with an in-memory database. + testfixtures.DangerousSkipTestDatabaseCheck(), + ) + assert.Nil(t, err) + err = fixtures.Load() + assert.Nil(t, err) + } + + return db +} + +// testTransaction is an utility function used to test a SQLite transaction to +// the DB, which loads the default set of DB fixtures. +func testTransaction(t *testing.T, test func(tx Transaction)) { + testTransactionWithFixtures(t, opt.NewString("default"), test) +} + +// testTransactionWithFixtures is an utility function used to test a SQLite transaction to +// the DB, which loads the given set of DB fixtures. +func testTransactionWithFixtures(t *testing.T, fixturesDir opt.String, test func(tx Transaction)) { + err := testDBWithFixtures(t, fixturesDir).WithTransaction(func(tx Transaction) error { + test(tx) + return nil + }) + assert.Nil(t, err) +} + +func assertExistOrNot(t *testing.T, db *DB, shouldExist bool, sql string, args ...interface{}) { + if shouldExist { + assertExist(t, db, sql, args...) + } else { + assertNotExist(t, db, sql, args...) + } +} + +func assertExist(t *testing.T, db *DB, sql string, args ...interface{}) { + if !exists(t, db, sql, args...) { + t.Errorf("SQL query did not return any result: %s, with arguments %v", sql, args) + } +} + +func assertNotExist(t *testing.T, db *DB, sql string, args ...interface{}) { + if exists(t, db, sql, args...) { + t.Errorf("SQL query returned a result: %s, with arguments %v", sql, args) + } +} + +func exists(t *testing.T, db *DB, sql string, args ...interface{}) bool { + var exists int + err := db.db.QueryRow("SELECT EXISTS ("+sql+")", args...).Scan(&exists) + assert.Nil(t, err) + return exists == 1 +} + +// FIXME: Migrate to DB-based versions? +func assertExistOrNotTx(t *testing.T, tx Transaction, shouldExist bool, sql string, args ...interface{}) { + if shouldExist { + assertExistTx(t, tx, sql, args...) + } else { + assertNotExistTx(t, tx, sql, args...) + } +} + +func assertExistTx(t *testing.T, tx Transaction, sql string, args ...interface{}) { + if !existsTx(t, tx, sql, args...) { + t.Errorf("SQL query did not return any result: %s, with arguments %v", sql, args) + } +} + +func assertNotExistTx(t *testing.T, tx Transaction, sql string, args ...interface{}) { + if existsTx(t, tx, sql, args...) { + t.Errorf("SQL query returned a result: %s, with arguments %v", sql, args) + } +} + +func existsTx(t *testing.T, tx Transaction, sql string, args ...interface{}) bool { + var exists int + err := tx.QueryRow("SELECT EXISTS ("+sql+")", args...).Scan(&exists) + assert.Nil(t, err) + return exists == 1 +} diff --git a/internal/adapter/term/styler.go b/internal/adapter/term/styler.go new file mode 100644 index 0000000..52df469 --- /dev/null +++ b/internal/adapter/term/styler.go @@ -0,0 +1,120 @@ +package term + +import ( + "fmt" + + "github.com/fatih/color" + "github.com/mickael-menu/zk/internal/core" +) + +// Style implements core.Styler using ANSI escape codes to be used with a terminal. +func (t *Terminal) Style(text string, rules ...core.Style) (string, error) { + if text == "" { + return text, nil + } + attrs, err := attributes(expandThemeAliases(rules)) + if err != nil { + return "", err + } + if len(attrs) == 0 { + return text, nil + } + return color.New(attrs...).Sprint(text), nil +} + +func (t *Terminal) MustStyle(text string, rules ...core.Style) string { + text, err := t.Style(text, rules...) + if err != nil { + panic(err.Error()) + } + return text +} + +// FIXME: User config +var themeAliases = map[core.Style][]core.Style{ + "title": {"bold", "yellow"}, + "path": {"underline", "cyan"}, + "term": {"red"}, + "emphasis": {"bold", "cyan"}, + "understate": {"faint"}, +} + +func expandThemeAliases(rules []core.Style) []core.Style { + expanded := make([]core.Style, 0) + for _, rule := range rules { + aliases, ok := themeAliases[rule] + if ok { + aliases = expandThemeAliases(aliases) + for _, alias := range aliases { + expanded = append(expanded, alias) + } + + } else { + expanded = append(expanded, rule) + } + } + + return expanded +} + +var attrsMapping = map[core.Style]color.Attribute{ + core.StyleBold: color.Bold, + core.StyleFaint: color.Faint, + core.StyleItalic: color.Italic, + core.StyleUnderline: color.Underline, + core.StyleBlink: color.BlinkSlow, + core.StyleReverse: color.ReverseVideo, + core.StyleHidden: color.Concealed, + core.StyleStrikethrough: color.CrossedOut, + + core.StyleBlack: color.FgBlack, + core.StyleRed: color.FgRed, + core.StyleGreen: color.FgGreen, + core.StyleYellow: color.FgYellow, + core.StyleBlue: color.FgBlue, + core.StyleMagenta: color.FgMagenta, + core.StyleCyan: color.FgCyan, + core.StyleWhite: color.FgWhite, + + core.StyleBlackBg: color.BgBlack, + core.StyleRedBg: color.BgRed, + core.StyleGreenBg: color.BgGreen, + core.StyleYellowBg: color.BgYellow, + core.StyleBlueBg: color.BgBlue, + core.StyleMagentaBg: color.BgMagenta, + core.StyleCyanBg: color.BgCyan, + core.StyleWhiteBg: color.BgWhite, + + core.StyleBrightBlack: color.FgHiBlack, + core.StyleBrightRed: color.FgHiRed, + core.StyleBrightGreen: color.FgHiGreen, + core.StyleBrightYellow: color.FgHiYellow, + core.StyleBrightBlue: color.FgHiBlue, + core.StyleBrightMagenta: color.FgHiMagenta, + core.StyleBrightCyan: color.FgHiCyan, + core.StyleBrightWhite: color.FgHiWhite, + + core.StyleBrightBlackBg: color.BgHiBlack, + core.StyleBrightRedBg: color.BgHiRed, + core.StyleBrightGreenBg: color.BgHiGreen, + core.StyleBrightYellowBg: color.BgHiYellow, + core.StyleBrightBlueBg: color.BgHiBlue, + core.StyleBrightMagentaBg: color.BgHiMagenta, + core.StyleBrightCyanBg: color.BgHiCyan, + core.StyleBrightWhiteBg: color.BgHiWhite, +} + +func attributes(rules []core.Style) ([]color.Attribute, error) { + attrs := make([]color.Attribute, 0) + + for _, rule := range rules { + attr, ok := attrsMapping[rule] + if !ok { + return attrs, fmt.Errorf("unknown styling rule: %v", rule) + } else { + attrs = append(attrs, attr) + } + } + + return attrs, nil +} diff --git a/adapter/term/styler_test.go b/internal/adapter/term/styler_test.go similarity index 83% rename from adapter/term/styler_test.go rename to internal/adapter/term/styler_test.go index 1a40112..c9716ff 100644 --- a/adapter/term/styler_test.go +++ b/internal/adapter/term/styler_test.go @@ -4,8 +4,8 @@ import ( "testing" "github.com/fatih/color" - "github.com/mickael-menu/zk/core/style" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func createTerminal() *Terminal { @@ -20,24 +20,24 @@ func TestStyleNoRule(t *testing.T) { } func TestStyleOneRule(t *testing.T) { - res, err := createTerminal().Style("Hello", style.Rule("red")) + res, err := createTerminal().Style("Hello", core.Style("red")) assert.Nil(t, err) assert.Equal(t, res, "\033[31mHello\033[0m") } func TestStyleMultipleRule(t *testing.T) { - res, err := createTerminal().Style("Hello", style.Rule("red"), style.Rule("bold")) + res, err := createTerminal().Style("Hello", core.Style("red"), core.Style("bold")) assert.Nil(t, err) assert.Equal(t, res, "\033[31;1mHello\033[0m") } func TestStyleUnknownRule(t *testing.T) { - _, err := createTerminal().Style("Hello", style.Rule("unknown")) + _, err := createTerminal().Style("Hello", core.Style("unknown")) assert.Err(t, err, "unknown styling rule: unknown") } func TestStyleEmptyString(t *testing.T) { - res, err := createTerminal().Style("", style.Rule("bold")) + res, err := createTerminal().Style("", core.Style("bold")) assert.Nil(t, err) assert.Equal(t, res, "") } @@ -45,7 +45,7 @@ func TestStyleEmptyString(t *testing.T) { func TestStyleAllRules(t *testing.T) { styler := createTerminal() test := func(rule string, expected string) { - res, err := styler.Style("Hello", style.Rule(rule)) + res, err := styler.Style("Hello", core.Style(rule)) assert.Nil(t, err) assert.Equal(t, res, "\033["+expected+"Hello\033[0m") } diff --git a/adapter/term/term.go b/internal/adapter/term/term.go similarity index 67% rename from adapter/term/term.go rename to internal/adapter/term/term.go index 4aa97bc..dfe0db7 100644 --- a/adapter/term/term.go +++ b/internal/adapter/term/term.go @@ -4,6 +4,7 @@ import ( "os" "strings" + survey "github.com/AlecAivazis/survey/v2" "github.com/mattn/go-isatty" ) @@ -33,3 +34,18 @@ func (t *Terminal) SupportsUTF8() bool { lc := strings.ToUpper(os.Getenv("LC_ALL")) return strings.Contains(lang, "UTF") || strings.Contains(lc, "UTF") } + +// Confirm is a shortcut to prompt a yes/no question to the user. +func (t *Terminal) Confirm(msg string, defaultAnswer bool) (confirmed, skipped bool) { + if !t.IsInteractive() { + return defaultAnswer, true + } + + confirmed = false + prompt := &survey.Confirm{ + Message: msg, + Default: defaultAnswer, + } + survey.AskOne(prompt, &confirmed) + return confirmed, false +} diff --git a/cmd/edit.go b/internal/cli/cmd/edit.go similarity index 53% rename from cmd/edit.go rename to internal/cli/cmd/edit.go index 407ac08..667d893 100644 --- a/cmd/edit.go +++ b/internal/cli/cmd/edit.go @@ -4,50 +4,46 @@ import ( "fmt" "path/filepath" - "github.com/mickael-menu/zk/adapter" - "github.com/mickael-menu/zk/adapter/fzf" - "github.com/mickael-menu/zk/adapter/sqlite" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/core/zk" - "github.com/mickael-menu/zk/util/errors" + "github.com/mickael-menu/zk/internal/adapter/fzf" + "github.com/mickael-menu/zk/internal/cli" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util/errors" ) // Edit opens notes matching a set of criteria with the user editor. type Edit struct { Force bool `short:f help:"Do not confirm before editing many notes at the same time."` - Filtering + cli.Filtering } -func (cmd *Edit) Run(container *adapter.Container) error { - zk, err := container.Zk() +func (cmd *Edit) Run(container *cli.Container) error { + notebook, err := container.CurrentNotebook() if err != nil { return err } - opts, err := NewFinderOpts(zk, cmd.Filtering) + findOpts, err := cmd.Filtering.NewNoteFindOpts(notebook) if err != nil { return errors.Wrapf(err, "incorrect criteria") } - db, _, err := container.Database(false) + notes, err := notebook.FindNotes(findOpts) if err != nil { return err } - var notes []note.Match - err = db.WithTransaction(func(tx sqlite.Transaction) error { - finder := container.NoteFinder(tx, fzf.NoteFinderOpts{ - AlwaysFilter: true, - PreviewCmd: container.Config.Tool.FzfPreview, - NewNoteDir: cmd.newNoteDir(zk), - BasePath: zk.Path, - CurrentPath: container.WorkingDir, - }) - notes, err = finder.Find(*opts) - return err + filter := container.NewNoteFilter(fzf.NoteFilterOpts{ + Interactive: cmd.Interactive, + AlwaysFilter: true, + PreviewCmd: container.Config.Tool.FzfPreview, + NewNoteDir: cmd.newNoteDir(notebook), + NotebookDir: notebook.Path, + WorkingDir: container.WorkingDir, }) + + notes, err = filter.Apply(notes) if err != nil { - if err == note.ErrCanceled { + if err == fzf.ErrCancelled { return nil } return err @@ -66,32 +62,35 @@ func (cmd *Edit) Run(container *adapter.Container) error { } paths := make([]string, 0) for _, note := range notes { - absPath := filepath.Join(zk.Path, note.Path) + absPath := filepath.Join(notebook.Path, note.Path) paths = append(paths, absPath) } - note.Edit(zk, paths...) + editor, err := container.NewNoteEditor(notebook) + if err != nil { + return err + } + return editor.Open(paths...) } else { fmt.Println("Found 0 note") + return nil } - - return err } // newNoteDir returns the directory in which to create a new note when the fzf // binding is triggered. -func (cmd *Edit) newNoteDir(zk *zk.Zk) *zk.Dir { +func (cmd *Edit) newNoteDir(notebook *core.Notebook) *core.Dir { switch len(cmd.Path) { case 0: - dir := zk.RootDir() + dir := notebook.RootDir() return &dir case 1: - dir, err := zk.DirAt(cmd.Path[0]) + dir, err := notebook.DirAt(cmd.Path[0]) if err != nil { return nil } - return dir + return &dir default: // More than one directory, it's ambiguous for the "new note" fzf binding. return nil diff --git a/cmd/index.go b/internal/cli/cmd/index.go similarity index 64% rename from cmd/index.go rename to internal/cli/cmd/index.go index 44b1f0b..eba08d3 100644 --- a/cmd/index.go +++ b/internal/cli/cmd/index.go @@ -3,7 +3,7 @@ package cmd import ( "fmt" - "github.com/mickael-menu/zk/adapter" + "github.com/mickael-menu/zk/internal/cli" ) // Index indexes the content of all the notes in the notebook. @@ -16,15 +16,20 @@ func (cmd *Index) Help() string { return "You usually do not need to run `zk index` manually, as notes are indexed automatically when needed." } -func (cmd *Index) Run(container *adapter.Container) error { - _, stats, err := container.Database(cmd.Force) +func (cmd *Index) Run(container *cli.Container) error { + notebook, err := container.CurrentNotebook() if err != nil { return err } - if err == nil && !cmd.Quiet { + stats, err := notebook.Index(cmd.Force) + if err != nil { + return err + } + + if !cmd.Quiet { fmt.Println(stats) } - return err + return nil } diff --git a/internal/cli/cmd/init.go b/internal/cli/cmd/init.go new file mode 100644 index 0000000..4db8ffa --- /dev/null +++ b/internal/cli/cmd/init.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/mickael-menu/zk/internal/cli" +) + +// Init creates a notebook in the given directory +type Init struct { + Directory string `arg optional type:"path" default:"." help:"Directory containing the notebook."` +} + +func (cmd *Init) Run(container *cli.Container) error { + notebook, err := container.Notebooks.Init(cmd.Directory) + if err != nil { + return err + } + + force := false + _, err = notebook.Index(force) + if err != nil { + return err + } + + path, err := filepath.Abs(cmd.Directory) + if err != nil { + path = cmd.Directory + } + + fmt.Printf("Initialized a notebook in %v\n", path) + return nil +} diff --git a/internal/cli/cmd/list.go b/internal/cli/cmd/list.go new file mode 100644 index 0000000..85fc635 --- /dev/null +++ b/internal/cli/cmd/list.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/mickael-menu/zk/internal/adapter/fzf" + "github.com/mickael-menu/zk/internal/cli" + "github.com/mickael-menu/zk/internal/util/errors" + strutil "github.com/mickael-menu/zk/internal/util/strings" +) + +// List displays notes matching a set of criteria. +type List struct { + Format string `group:format short:f placeholder:TEMPLATE help:"Pretty print the list using the given format."` + Delimiter string "group:format short:d default:\n help:\"Print notes delimited by the given separator.\"" + Delimiter0 bool "group:format short:0 name:delimiter0 help:\"Print notes delimited by ASCII NUL characters. This is useful when used in conjunction with `xargs -0`.\"" + NoPager bool `group:format short:P help:"Do not pipe output into a pager."` + Quiet bool `group:format short:q help:"Do not print the total number of notes found."` + cli.Filtering +} + +func (cmd *List) Run(container *cli.Container) error { + if cmd.Delimiter0 { + cmd.Delimiter = "\x00" + } + + notebook, err := container.CurrentNotebook() + if err != nil { + return err + } + + format, err := notebook.NewNoteFormatter(cmd.noteTemplate()) + if err != nil { + return err + } + + findOpts, err := cmd.Filtering.NewNoteFindOpts(notebook) + if err != nil { + return errors.Wrapf(err, "incorrect criteria") + } + + notes, err := notebook.FindNotes(findOpts) + if err != nil { + return err + } + + filter := container.NewNoteFilter(fzf.NoteFilterOpts{ + Interactive: cmd.Interactive, + AlwaysFilter: false, + PreviewCmd: container.Config.Tool.FzfPreview, + NotebookDir: notebook.Path, + WorkingDir: container.WorkingDir, + }) + + notes, err = filter.Apply(notes) + if err != nil { + if err == fzf.ErrCancelled { + return nil + } + return err + } + + count := len(notes) + if count > 0 { + err = container.Paginate(cmd.NoPager, func(out io.Writer) error { + for i, note := range notes { + if i > 0 { + fmt.Fprint(out, cmd.Delimiter) + } + + ft, err := format(note) + if err != nil { + return err + } + fmt.Fprint(out, ft) + } + if cmd.Delimiter0 { + fmt.Fprint(out, "\x00") + } + + return nil + }) + } + + if err == nil && !cmd.Quiet { + fmt.Fprintf(os.Stderr, "\n\nFound %d %s\n", count, strutil.Pluralize("note", count)) + } + + return err +} + +func (cmd *List) noteTemplate() string { + format := cmd.Format + if format == "" { + format = "short" + } + + templ, ok := defaultNoteFormats[format] + if !ok { + templ = format + // Replace raw \n and \t by actual newlines and tabs in user format. + templ = strings.ReplaceAll(templ, "\\n", "\n") + templ = strings.ReplaceAll(templ, "\\t", "\t") + } + + return templ +} + +var defaultNoteFormats = map[string]string{ + "path": `{{path}}`, + + "oneline": `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})`, + + "short": `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}}) + +{{list snippets}}`, + + "medium": `{{style "title" title}} {{style "path" path}} +Created: {{date created "short"}} + +{{list snippets}}`, + + "long": `{{style "title" title}} {{style "path" path}} +Created: {{date created "short"}} +Modified: {{date created "short"}} + +{{list snippets}}`, + + "full": `{{style "title" title}} {{style "path" path}} +Created: {{date created "short"}} +Modified: {{date created "short"}} +Tags: {{join tags ", "}} + +{{prepend " " body}} +`, +} diff --git a/internal/cli/cmd/list_test.go b/internal/cli/cmd/list_test.go new file mode 100644 index 0000000..7bae4fc --- /dev/null +++ b/internal/cli/cmd/list_test.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "testing" + + "github.com/mickael-menu/zk/internal/util/test/assert" +) + +func TestListFormatDefault(t *testing.T) { + cmd := List{} + assert.Equal(t, cmd.noteTemplate(), `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}}) + +{{list snippets}}`) +} + +func TestListFormatPredefined(t *testing.T) { + test := func(format, expectedTemplate string) { + cmd := List{Format: format} + assert.Equal(t, cmd.noteTemplate(), expectedTemplate) + } + + // Known formats + test("path", `{{path}}`) + + test("oneline", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}})`) + + test("short", `{{style "title" title}} {{style "path" path}} ({{date created "elapsed"}}) + +{{list snippets}}`) + + test("medium", `{{style "title" title}} {{style "path" path}} +Created: {{date created "short"}} + +{{list snippets}}`) + + test("long", `{{style "title" title}} {{style "path" path}} +Created: {{date created "short"}} +Modified: {{date created "short"}} + +{{list snippets}}`) + + test("full", `{{style "title" title}} {{style "path" path}} +Created: {{date created "short"}} +Modified: {{date created "short"}} +Tags: {{join tags ", "}} + +{{prepend " " body}} +`) + + // Predefined formats are case sensitive. + test("Path", "Path") +} + +func TestListFormatCustom(t *testing.T) { + test := func(format, expectedTemplate string) { + cmd := List{Format: format} + assert.Equal(t, cmd.noteTemplate(), expectedTemplate) + } + + // Custom formats are used literally. + test("{{title}}", "{{title}}") + // \n and \t in custom formats are expanded. + test(`{{title}}\t{{path}}\n{{snippet}}`, "{{title}}\t{{path}}\n{{snippet}}") +} diff --git a/cmd/lsp.go b/internal/cli/cmd/lsp.go similarity index 55% rename from cmd/lsp.go rename to internal/cli/cmd/lsp.go index 58b8e98..d2181bf 100644 --- a/cmd/lsp.go +++ b/internal/cli/cmd/lsp.go @@ -1,9 +1,9 @@ package cmd import ( - "github.com/mickael-menu/zk/adapter" - "github.com/mickael-menu/zk/adapter/lsp" - "github.com/mickael-menu/zk/util/opt" + "github.com/mickael-menu/zk/internal/adapter/lsp" + "github.com/mickael-menu/zk/internal/cli" + "github.com/mickael-menu/zk/internal/util/opt" ) // LSP starts a server implementing the Language Server Protocol. @@ -11,12 +11,14 @@ type LSP struct { Log string `type:path placeholder:PATH help:"Absolute path to the log file"` } -func (cmd *LSP) Run(container *adapter.Container) error { +func (cmd *LSP) Run(container *cli.Container) error { server := lsp.NewServer(lsp.ServerOpts{ Name: "zk", Version: container.Version, + Logger: container.Logger, LogFile: opt.NewNotEmptyString(cmd.Log), - Container: container, + Notebooks: container.Notebooks, + FS: container.FS, }) return server.Run() diff --git a/internal/cli/cmd/new.go b/internal/cli/cmd/new.go new file mode 100644 index 0000000..a3e71c6 --- /dev/null +++ b/internal/cli/cmd/new.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "errors" + "fmt" + "time" + + "github.com/mickael-menu/zk/internal/cli" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util/opt" + "github.com/mickael-menu/zk/internal/util/os" +) + +// New adds a new note to the notebook. +type New struct { + Directory string `arg optional default:"." help:"Directory in which to create the note."` + Title string `short:t placeholder:TITLE help:"Title of the new note."` + Group string `short:g placeholder:NAME help:"Name of the config group this note belongs to. Takes precedence over the config of the directory."` + Extra map[string]string ` help:"Extra variables passed to the templates." mapsep:","` + Template string ` placeholder:PATH help:"Custom template used to render the note."` + PrintPath bool `short:p help:"Print the path of the created note instead of editing it."` +} + +func (cmd *New) Run(container *cli.Container) error { + notebook, err := container.CurrentNotebook() + if err != nil { + return err + } + + content, err := os.ReadStdinPipe() + if err != nil { + return err + } + + path, err := notebook.NewNote(core.NewNoteOpts{ + Title: opt.NewNotEmptyString(cmd.Title), + Content: content.Unwrap(), + Directory: opt.NewNotEmptyString(cmd.Directory), + Group: opt.NewNotEmptyString(cmd.Group), + Template: opt.NewNotEmptyString(cmd.Template), + Extra: cmd.Extra, + Date: time.Now(), + }) + if err != nil { + var noteExists core.ErrNoteExists + if !errors.As(err, ¬eExists) { + return err + } + + relPath, err := notebook.RelPath(path) + if err != nil { + return err + } + + if confirmed, _ := container.Terminal.Confirm( + fmt.Sprintf("%s already exists, do you want to edit this note instead?", relPath), + true, + ); !confirmed { + // abort... + return nil + } + + path = noteExists.Path + } + + if cmd.PrintPath { + fmt.Printf("%+v\n", path) + return nil + } else { + editor, err := container.NewNoteEditor(notebook) + if err != nil { + return err + } + return editor.Open(path) + } +} diff --git a/internal/cli/container.go b/internal/cli/container.go new file mode 100644 index 0000000..432e029 --- /dev/null +++ b/internal/cli/container.go @@ -0,0 +1,190 @@ +package cli + +import ( + "io" + "os" + "path/filepath" + + "github.com/mickael-menu/zk/internal/adapter/editor" + "github.com/mickael-menu/zk/internal/adapter/fs" + "github.com/mickael-menu/zk/internal/adapter/fzf" + "github.com/mickael-menu/zk/internal/adapter/handlebars" + "github.com/mickael-menu/zk/internal/adapter/markdown" + "github.com/mickael-menu/zk/internal/adapter/sqlite" + "github.com/mickael-menu/zk/internal/adapter/term" + "github.com/mickael-menu/zk/internal/core" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/errors" + osutil "github.com/mickael-menu/zk/internal/util/os" + "github.com/mickael-menu/zk/internal/util/pager" + "github.com/mickael-menu/zk/internal/util/paths" + "github.com/mickael-menu/zk/internal/util/rand" +) + +type Container struct { + Version string + Config core.Config + Logger *util.ProxyLogger + Terminal *term.Terminal + FS *fs.FileStorage + WorkingDir string + Notebooks *core.NotebookStore + currentNotebook *core.Notebook + currentNotebookErr error +} + +func NewContainer(version string) (*Container, error) { + wrap := errors.Wrapper("initialization") + + term := term.New() + styler := term + logger := util.NewProxyLogger(util.NewStdLogger("zk: ", 0)) + fs, err := fs.NewFileStorage("", logger) + config := core.NewDefaultConfig() + + handlebars.Init(term.SupportsUTF8(), logger) + + // Load global user config + configPath, err := locateGlobalConfig() + if err != nil { + return nil, wrap(err) + } + if configPath != "" { + config, err = core.OpenConfig(configPath, config, fs) + if err != nil { + return nil, wrap(err) + } + } + + return &Container{ + Version: version, + Config: config, + Logger: logger, + Terminal: term, + FS: fs, + Notebooks: core.NewNotebookStore(config, core.NotebookStorePorts{ + FS: fs, + NotebookFactory: func(path string, config core.Config) (*core.Notebook, error) { + dbPath := filepath.Join(path, ".zk/notebook.db") + db, err := sqlite.Open(dbPath) + if err != nil { + return nil, err + } + + notebook := core.NewNotebook(path, config, core.NotebookPorts{ + NoteIndex: sqlite.NewNoteIndex(db, logger), + NoteParser: markdown.NewParser(markdown.ParserOpts{ + HashtagEnabled: config.Format.Markdown.Hashtags, + MultiWordTagEnabled: config.Format.Markdown.MultiwordTags, + ColontagEnabled: config.Format.Markdown.ColonTags, + }), + TemplateLoaderFactory: func(language string) (core.TemplateLoader, error) { + return handlebars.NewLoader(handlebars.LoaderOpts{ + LookupPaths: []string{ + filepath.Join(globalConfigDir(), "templates"), + filepath.Join(path, ".zk/templates"), + }, + Lang: config.Note.Lang, + Styler: styler, + Logger: logger, + }), nil + }, + IDGeneratorFactory: func(opts core.IDOptions) func() string { + return rand.NewIDGenerator(opts) + }, + FS: fs, + Logger: logger, + OSEnv: func() map[string]string { + return osutil.Env() + }, + }) + + return notebook, nil + }, + }), + }, nil +} + +// locateGlobalConfig looks for the global zk config file following the +// XDG Base Directory specification +// https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html +func locateGlobalConfig() (string, error) { + configPath := filepath.Join(globalConfigDir(), "config.toml") + exists, err := paths.Exists(configPath) + switch { + case err != nil: + return "", err + case exists: + return configPath, nil + default: + return "", nil + } +} + +// globalConfigDir returns the parent directory of the global configuration file. +func globalConfigDir() string { + path, ok := os.LookupEnv("XDG_CONFIG_HOME") + if !ok { + home, ok := os.LookupEnv("HOME") + if !ok { + home = "~/" + } + path = filepath.Join(home, ".config") + } + return filepath.Join(path, "zk") +} + +// SetCurrentNotebook sets the first notebook found in the given search paths +// as the current default one. +func (c *Container) SetCurrentNotebook(searchPaths []string) { + if len(searchPaths) == 0 { + return + } + + for _, path := range searchPaths { + c.currentNotebook, c.currentNotebookErr = c.Notebooks.Open(path) + if c.currentNotebookErr == nil { + c.WorkingDir = path + c.FS.WorkingDir = path + c.Config = c.currentNotebook.Config + // FIXME: Is there something to do to support multiple notebooks here? + os.Setenv("ZK_NOTEBOOK_DIR", c.currentNotebook.Path) + return + } + } +} + +// CurrentNotebook returns the current default notebook. +func (c *Container) CurrentNotebook() (*core.Notebook, error) { + return c.currentNotebook, c.currentNotebookErr +} + +func (c *Container) NewNoteFilter(opts fzf.NoteFilterOpts) *fzf.NoteFilter { + return fzf.NewNoteFilter(opts, c.Terminal) +} + +func (c *Container) NewNoteEditor(notebook *core.Notebook) (*editor.Editor, error) { + return editor.NewEditor(notebook.Config.Tool.Editor) +} + +// Paginate creates an auto-closing io.Writer which will be automatically +// paginated if noPager is false, using the user's pager. +// +// You can write to the pager only in the run callback. +func (c *Container) Paginate(noPager bool, run func(out io.Writer) error) error { + pager, err := c.pager(noPager || c.Config.Tool.Pager.IsEmpty()) + if err != nil { + return err + } + err = run(pager) + pager.Close() + return err +} + +func (c *Container) pager(noPager bool) (*pager.Pager, error) { + if noPager || !c.Terminal.IsInteractive() { + return pager.PassthroughPager, nil + } else { + return pager.New(c.Config.Tool.Pager, c.Logger) + } +} diff --git a/cmd/finder_opts.go b/internal/cli/filtering.go similarity index 75% rename from cmd/finder_opts.go rename to internal/cli/filtering.go index 0ff2866..675f33b 100644 --- a/cmd/finder_opts.go +++ b/internal/cli/filtering.go @@ -1,4 +1,4 @@ -package cmd +package cli import ( "fmt" @@ -7,11 +7,10 @@ import ( "github.com/alecthomas/kong" "github.com/kballard/go-shellquote" - "github.com/mickael-menu/zk/core/note" - "github.com/mickael-menu/zk/core/zk" - "github.com/mickael-menu/zk/util/errors" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/strings" + "github.com/mickael-menu/zk/internal/core" + "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" ) @@ -129,136 +128,134 @@ func (f Filtering) ExpandNamedFilters(filters map[string]string, expandedFilters return f, nil } -// NewFinderOpts creates an instance of note.FinderOpts from a set of user flags. -func NewFinderOpts(zk *zk.Zk, filtering Filtering) (*note.FinderOpts, error) { - filtering, err := filtering.ExpandNamedFilters(zk.Config.Filters, []string{}) +// NewNoteFindOpts creates an instance of core.NoteFindOpts from a set of user flags. +func (f Filtering) NewNoteFindOpts(notebook *core.Notebook) (core.NoteFindOpts, error) { + opts := core.NoteFindOpts{} + + f, err := f.ExpandNamedFilters(notebook.Config.Filters, []string{}) if err != nil { - return nil, err + return opts, err } - opts := note.FinderOpts{} - - opts.Match = opt.NewNotEmptyString(filtering.Match) + opts.Match = opt.NewNotEmptyString(f.Match) - if paths, ok := relPaths(zk, filtering.Path); ok { + if paths, ok := relPaths(notebook, f.Path); ok { opts.IncludePaths = paths } - if paths, ok := relPaths(zk, filtering.Exclude); ok { + if paths, ok := relPaths(notebook, f.Exclude); ok { opts.ExcludePaths = paths } - if len(filtering.Tag) > 0 { - opts.Tags = filtering.Tag + if len(f.Tag) > 0 { + opts.Tags = f.Tag } - if len(filtering.Mention) > 0 { - opts.Mention = filtering.Mention + if len(f.Mention) > 0 { + opts.Mention = f.Mention } - if len(filtering.MentionedBy) > 0 { - opts.MentionedBy = filtering.MentionedBy + if len(f.MentionedBy) > 0 { + opts.MentionedBy = f.MentionedBy } - if paths, ok := relPaths(zk, filtering.LinkedBy); ok { - opts.LinkedBy = ¬e.LinkFilter{ + if paths, ok := relPaths(notebook, f.LinkedBy); ok { + opts.LinkedBy = &core.LinkFilter{ Paths: paths, Negate: false, - Recursive: filtering.Recursive, - MaxDistance: filtering.MaxDistance, + Recursive: f.Recursive, + MaxDistance: f.MaxDistance, } - } else if paths, ok := relPaths(zk, filtering.NoLinkedBy); ok { - opts.LinkedBy = ¬e.LinkFilter{ + } else if paths, ok := relPaths(notebook, f.NoLinkedBy); ok { + opts.LinkedBy = &core.LinkFilter{ Paths: paths, Negate: true, } } - if paths, ok := relPaths(zk, filtering.LinkTo); ok { - opts.LinkTo = ¬e.LinkFilter{ + if paths, ok := relPaths(notebook, f.LinkTo); ok { + opts.LinkTo = &core.LinkFilter{ Paths: paths, Negate: false, - Recursive: filtering.Recursive, - MaxDistance: filtering.MaxDistance, + Recursive: f.Recursive, + MaxDistance: f.MaxDistance, } - } else if paths, ok := relPaths(zk, filtering.NoLinkTo); ok { - opts.LinkTo = ¬e.LinkFilter{ + } else if paths, ok := relPaths(notebook, f.NoLinkTo); ok { + opts.LinkTo = &core.LinkFilter{ Paths: paths, Negate: true, } } - if paths, ok := relPaths(zk, filtering.Related); ok { + if paths, ok := relPaths(notebook, f.Related); ok { opts.Related = paths } - opts.Orphan = filtering.Orphan + opts.Orphan = f.Orphan - if filtering.Created != "" { - start, end, err := parseDayRange(filtering.Created) + if f.Created != "" { + start, end, err := parseDayRange(f.Created) if err != nil { - return nil, err + return opts, err } opts.CreatedStart = &start opts.CreatedEnd = &end } else { - if filtering.CreatedBefore != "" { - date, err := parseDate(filtering.CreatedBefore) + if f.CreatedBefore != "" { + date, err := parseDate(f.CreatedBefore) if err != nil { - return nil, err + return opts, err } opts.CreatedEnd = &date } - if filtering.CreatedAfter != "" { - date, err := parseDate(filtering.CreatedAfter) + if f.CreatedAfter != "" { + date, err := parseDate(f.CreatedAfter) if err != nil { - return nil, err + return opts, err } opts.CreatedStart = &date } } - if filtering.Modified != "" { - start, end, err := parseDayRange(filtering.Modified) + if f.Modified != "" { + start, end, err := parseDayRange(f.Modified) if err != nil { - return nil, err + return opts, err } opts.ModifiedStart = &start opts.ModifiedEnd = &end } else { - if filtering.ModifiedBefore != "" { - date, err := parseDate(filtering.ModifiedBefore) + if f.ModifiedBefore != "" { + date, err := parseDate(f.ModifiedBefore) if err != nil { - return nil, err + return opts, err } opts.ModifiedEnd = &date } - if filtering.ModifiedAfter != "" { - date, err := parseDate(filtering.ModifiedAfter) + if f.ModifiedAfter != "" { + date, err := parseDate(f.ModifiedAfter) if err != nil { - return nil, err + return opts, err } opts.ModifiedStart = &date } } - opts.Interactive = filtering.Interactive - - sorters, err := note.SortersFromStrings(filtering.Sort) + sorters, err := core.NoteSortersFromStrings(f.Sort) if err != nil { - return nil, err + return opts, err } opts.Sorters = sorters - opts.Limit = filtering.Limit + opts.Limit = f.Limit - return &opts, nil + return opts, nil } -func relPaths(zk *zk.Zk, paths []string) ([]string, bool) { +func relPaths(notebook *core.Notebook, paths []string) ([]string, bool) { relPaths := make([]string, 0) for _, p := range paths { - path, err := zk.RelPath(p) + path, err := notebook.RelPath(p) if err == nil { relPaths = append(relPaths, path) } diff --git a/cmd/finder_opts_test.go b/internal/cli/filtering_test.go similarity index 98% rename from cmd/finder_opts_test.go rename to internal/cli/filtering_test.go index 929069d..d9c703e 100644 --- a/cmd/finder_opts_test.go +++ b/internal/cli/filtering_test.go @@ -1,9 +1,9 @@ -package cmd +package cli import ( "testing" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func TestExpandNamedFiltersNone(t *testing.T) { diff --git a/internal/core/collection.go b/internal/core/collection.go new file mode 100644 index 0000000..3b80acb --- /dev/null +++ b/internal/core/collection.go @@ -0,0 +1,56 @@ +package core + +// Collection represents a collection, such as a tag. +type Collection struct { + // Unique ID of this collection in the Notebook. + ID CollectionID + // Kind of this note collection, such as a tag. + Kind CollectionKind + // Name of this collection. + Name string + // Number of notes associated with this collection. + NoteCount int +} + +// CollectionID represents the unique ID of a collection relative to a given +// NoteIndex implementation. +type CollectionID int64 + +func (id CollectionID) IsValid() bool { + return id > 0 +} + +// NoteCollectionID represents the unique ID of an association between a note +// and a collection in a NoteIndex implementation. +type NoteCollectionID int64 + +func (id NoteCollectionID) IsValid() bool { + return id > 0 +} + +// CollectionKind defines a kind of note collection, such as tags. +type CollectionKind string + +const ( + CollectionKindTag CollectionKind = "tag" +) + +// CollectionRepository persists note collection across sessions. +type CollectionRepository interface { + + // FindOrCreate returns the ID of the collection with given kind and name. + // If the collection does not exist, creates a new one. + FindOrCreateCollection(name string, kind CollectionKind) (CollectionID, error) + + // FindCollections returns the list of all collections in the repository + // for the given kind. + FindCollections(kind CollectionKind) ([]Collection, error) + + // AssociateNoteCollection creates a new association between a note and a + // collection, if it does not already exist. + AssociateNoteCollection(noteID NoteID, collectionID CollectionID) (NoteCollectionID, error) + + // RemoveNoteCollections deletes all collection associations with the given + // note. + RemoveNoteAssociations(noteId NoteID) error +} diff --git a/core/zk/config.go b/internal/core/config.go similarity index 81% rename from core/zk/config.go rename to internal/core/config.go index 17b5277..d59e1a2 100644 --- a/core/zk/config.go +++ b/internal/core/config.go @@ -1,16 +1,15 @@ -package zk +package core import ( - "io/ioutil" + "fmt" "path/filepath" - "github.com/mickael-menu/zk/util/errors" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/paths" + "github.com/mickael-menu/zk/internal/util/errors" + "github.com/mickael-menu/zk/internal/util/opt" toml "github.com/pelletier/go-toml" ) -// Config holds the global user configuration. +// Config holds the user configuration. type Config struct { Note NoteConfig Groups map[string]GroupConfig @@ -19,8 +18,6 @@ type Config struct { Filters map[string]string Aliases map[string]string Extra map[string]string - // Base directories for the relative template paths used in NoteConfig. - TemplatesDirs []string } // NewDefaultConfig creates a new Config with the default settings. @@ -46,10 +43,9 @@ func NewDefaultConfig() Config { MultiwordTags: false, }, }, - Filters: map[string]string{}, - Aliases: map[string]string{}, - Extra: map[string]string{}, - TemplatesDirs: []string{}, + Filters: map[string]string{}, + Aliases: map[string]string{}, + Extra: map[string]string{}, } } @@ -62,29 +58,35 @@ func (c Config) RootGroupConfig() GroupConfig { } } -// LocateTemplate returns the absolute path for the given template path, by -// looking for it in the templates directories registered in this Config. -func (c Config) LocateTemplate(path string) (string, bool) { - if path == "" { - return "", false - } - - exists := func(path string) bool { - exists, err := paths.Exists(path) - return exists && err == nil - } - - if filepath.IsAbs(path) { - return path, exists(path) +// GroupConfigNamed returns the GroupConfig for the group with the given name. +// An empty name matches the root GroupConfig. +func (c Config) GroupConfigNamed(name string) (GroupConfig, error) { + if name == "" { + return c.RootGroupConfig(), nil + } else { + group, ok := c.Groups[name] + if !ok { + return GroupConfig{}, fmt.Errorf("no group named `%s` found in the config", name) + } + return group, nil } +} - for _, dir := range c.TemplatesDirs { - if candidate := filepath.Join(dir, path); exists(candidate) { - return candidate, true +// GroupNameForPath returns the name of the GroupConfig matching the given +// path, relative to the notebook. +func (c Config) GroupNameForPath(path string) (string, error) { + for name, config := range c.Groups { + for _, groupPath := range config.Paths { + matches, err := filepath.Match(groupPath, path) + if err != nil { + return "", errors.Wrapf(err, "failed to match group %s to %s", name, path) + } else if matches { + return name, nil + } } } - return path, false + return "", nil } // FormatConfig holds the configuration for document formats, such as Markdown. @@ -132,14 +134,6 @@ type GroupConfig struct { Extra map[string]string } -// ConfigOverrides holds user configuration overridden values, for example fed -// from CLI flags. -type ConfigOverrides struct { - Group opt.String - BodyTemplatePath opt.String - Extra map[string]string -} - // Clone creates a copy of the GroupConfig receiver. func (c GroupConfig) Clone() GroupConfig { clone := c @@ -154,29 +148,16 @@ func (c GroupConfig) Clone() GroupConfig { return clone } -// Override modifies the GroupConfig receiver by updating the properties -// overridden in ConfigOverrides. -func (c *GroupConfig) Override(overrides ConfigOverrides) { - if !overrides.BodyTemplatePath.IsNull() { - c.Note.BodyTemplatePath = overrides.BodyTemplatePath - } - if overrides.Extra != nil { - for k, v := range overrides.Extra { - c.Extra[k] = v - } - } -} - // OpenConfig creates a new Config instance from its TOML representation stored // in the given file. -func OpenConfig(path string, parentConfig Config) (Config, error) { +func OpenConfig(path string, parentConfig Config, fs FileStorage) (Config, error) { // The local config is optional. - exists, err := paths.Exists(path) + exists, err := fs.FileExists(path) if err == nil && !exists { return parentConfig, nil } - content, err := ioutil.ReadFile(path) + content, err := fs.Read(path) if err != nil { return parentConfig, errors.Wrapf(err, "failed to open config file at %s", path) } @@ -278,8 +259,6 @@ func ParseConfig(content []byte, path string, parentConfig Config) (Config, erro } } - config.TemplatesDirs = append([]string{filepath.Join(filepath.Dir(path), "templates")}, config.TemplatesDirs...) - return config, nil } diff --git a/core/zk/config_test.go b/internal/core/config_test.go similarity index 78% rename from core/zk/config_test.go rename to internal/core/config_test.go index 28ccbc5..cae645f 100644 --- a/core/zk/config_test.go +++ b/internal/core/config_test.go @@ -1,15 +1,12 @@ -package zk +package core import ( "fmt" - "os" - "path/filepath" "testing" - "time" "github.com/google/go-cmp/cmp" - "github.com/mickael-menu/zk/util/opt" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/util/opt" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func TestParseDefaultConfig(t *testing.T) { @@ -42,10 +39,9 @@ func TestParseDefaultConfig(t *testing.T) { Pager: opt.NullString, FzfPreview: opt.NullString, }, - Filters: make(map[string]string), - Aliases: make(map[string]string), - Extra: make(map[string]string), - TemplatesDirs: []string{".zk/templates"}, + Filters: make(map[string]string), + Aliases: make(map[string]string), + Extra: make(map[string]string), }) } @@ -211,7 +207,6 @@ func TestParseComplete(t *testing.T) { "hello": "world", "salut": "le monde", }, - TemplatesDirs: []string{".zk/templates"}, }) } @@ -313,7 +308,6 @@ func TestParseMergesGroupConfig(t *testing.T) { "hello": "world", "salut": "le monde", }, - TemplatesDirs: []string{".zk/templates"}, }) } @@ -373,37 +367,6 @@ func TestParseIDCase(t *testing.T) { test("unknown", CaseLower) } -func TestLocateTemplate(t *testing.T) { - root := fmt.Sprintf("/tmp/zk-test-%d", time.Now().Unix()) - os.Remove(root) - os.MkdirAll(filepath.Join(root, "templates"), os.ModePerm) - - test := func(template string, expected string, exists bool) { - conf, err := ParseConfig([]byte(""), filepath.Join(root, "config.toml"), NewDefaultConfig()) - assert.Nil(t, err) - - path, ok := conf.LocateTemplate(template) - if exists { - assert.True(t, ok) - if path != expected { - t.Errorf("Didn't resolve template `%v` as expected: %v", template, expected) - } - } else { - assert.False(t, ok) - } - } - - tpl1 := filepath.Join(root, "templates/template.tpl") - test("template.tpl", tpl1, false) - os.Create(tpl1) - test("template.tpl", tpl1, true) - - tpl2 := filepath.Join(root, "abs.tpl") - test(tpl2, tpl2, false) - os.Create(tpl2) - test(tpl2, tpl2, true) -} - func TestGroupConfigClone(t *testing.T) { original := GroupConfig{ Paths: []string{"original"}, @@ -459,67 +422,3 @@ func TestGroupConfigClone(t *testing.T) { }, }) } - -func TestGroupConfigOverride(t *testing.T) { - sut := GroupConfig{ - Paths: []string{"path"}, - Note: NoteConfig{ - FilenameTemplate: "filename", - BodyTemplatePath: opt.NewString("body.tpl"), - IDOptions: IDOptions{ - Length: 4, - Charset: CharsetLetters, - Case: CaseUpper, - }, - }, - Extra: map[string]string{ - "hello": "world", - "salut": "le monde", - }, - } - - // Empty overrides - sut.Override(ConfigOverrides{}) - assert.Equal(t, sut, GroupConfig{ - Paths: []string{"path"}, - Note: NoteConfig{ - FilenameTemplate: "filename", - BodyTemplatePath: opt.NewString("body.tpl"), - IDOptions: IDOptions{ - Length: 4, - Charset: CharsetLetters, - Case: CaseUpper, - }, - }, - Extra: map[string]string{ - "hello": "world", - "salut": "le monde", - }, - }) - - // Some overrides - sut.Override(ConfigOverrides{ - BodyTemplatePath: opt.NewString("overridden-template"), - Extra: map[string]string{ - "hello": "overridden", - "additional": "value", - }, - }) - assert.Equal(t, sut, GroupConfig{ - Paths: []string{"path"}, - Note: NoteConfig{ - FilenameTemplate: "filename", - BodyTemplatePath: opt.NewString("overridden-template"), - IDOptions: IDOptions{ - Length: 4, - Charset: CharsetLetters, - Case: CaseUpper, - }, - }, - Extra: map[string]string{ - "hello": "overridden", - "salut": "le monde", - "additional": "value", - }, - }) -} diff --git a/internal/core/fs.go b/internal/core/fs.go new file mode 100644 index 0000000..4810edc --- /dev/null +++ b/internal/core/fs.go @@ -0,0 +1,33 @@ +package core + +// FileStorage is a port providing read and write access to a file storage. +type FileStorage interface { + + // Abs makes the given file path absolute if needed, using the FileStorage + // working directory. + Abs(path string) (string, error) + + // Rel makes the given absolute file path relative to the current working + // directory. + Rel(path string) (string, error) + + // Canonical returns the canonical version of this path, resolving any + // symbolic link. + Canonical(path string) string + + // FileExists returns whether a file exists at the given file path. + FileExists(path string) (bool, error) + + // DirExists returns whether a directory exists at the given file path. + DirExists(path string) (bool, error) + + // IsDescendantOf returns whether the given path is dir or one of its descendants. + IsDescendantOf(dir string, path string) (bool, error) + + // Read returns the bytes content of the file at the given file path. + Read(path string) ([]byte, error) + + // Write creates or overwrite the content at the given file path, creating + // any intermediate directories if needed. + Write(path string, content []byte) error +} diff --git a/internal/core/fs_test.go b/internal/core/fs_test.go new file mode 100644 index 0000000..bdf9980 --- /dev/null +++ b/internal/core/fs_test.go @@ -0,0 +1,77 @@ +package core + +import ( + "os" + "path/filepath" +) + +// fileStorageMock implements an in-memory FileStorage for testing purposes. +type fileStorageMock struct { + // Working directory used to calculate relative paths. + WorkingDir string + // File content indexed by their path in this file storage. + Files map[string]string + // Existing directories + Dirs []string +} + +func newFileStorageMock(workingDir string, dirs []string) *fileStorageMock { + return &fileStorageMock{ + WorkingDir: workingDir, + Files: map[string]string{}, + Dirs: dirs, + } +} + +func (fs *fileStorageMock) Abs(path string) (string, error) { + var err error + if !filepath.IsAbs(path) { + path = filepath.Join(fs.WorkingDir, path) + path, err = filepath.Abs(path) + if err != nil { + return path, err + } + } + + return path, nil +} + +func (fs *fileStorageMock) Rel(path string) (string, error) { + return filepath.Rel(fs.WorkingDir, path) +} + +func (fs *fileStorageMock) Canonical(path string) string { + return path +} + +func (fs *fileStorageMock) FileExists(path string) (bool, error) { + _, ok := fs.Files[path] + return ok, nil +} + +func (fs *fileStorageMock) DirExists(path string) (bool, error) { + for _, dir := range fs.Dirs { + if dir == path { + return true, nil + } + } + return false, nil +} + +func (fs *fileStorageMock) fileInfo(path string) (*os.FileInfo, error) { + panic("not implemented") +} + +func (fs *fileStorageMock) IsDescendantOf(dir string, path string) (bool, error) { + panic("not implemented") +} + +func (fs *fileStorageMock) Read(path string) ([]byte, error) { + content, _ := fs.Files[path] + return []byte(content), nil +} + +func (fs *fileStorageMock) Write(path string, content []byte) error { + fs.Files[path] = string(content) + return nil +} diff --git a/core/zk/id.go b/internal/core/id.go similarity index 75% rename from core/zk/id.go rename to internal/core/id.go index 77f5ed6..c85b321 100644 --- a/core/zk/id.go +++ b/internal/core/id.go @@ -1,4 +1,4 @@ -package zk +package core // IDOptions holds the options used to generate an ID. type IDOptions struct { @@ -29,3 +29,9 @@ const ( CaseUpper CaseMixed ) + +// IDGenerator is a function returning a new ID with each invocation. +type IDGenerator func() string + +// IDGeneratorFactory creates a new IDGenerator function using the given IDOptions. +type IDGeneratorFactory func(opts IDOptions) func() string diff --git a/internal/core/link.go b/internal/core/link.go new file mode 100644 index 0000000..e8a1bdd --- /dev/null +++ b/internal/core/link.go @@ -0,0 +1,38 @@ +package core + +// Link represents a link in a note to another note or an external resource. +type Link struct { + // Label of the link. + Title string + // Destination URI of the link. + Href string + // Indicates whether the target is a remote (e.g. HTTP) resource. + IsExternal bool + // Relationships between the note and the linked target. + Rels []LinkRelation + // Excerpt of the paragraph containing the note. + Snippet string + // Start byte offset of the snippet in the note content. + SnippetStart int + // End byte offset of the snippet in the note content. + SnippetEnd int +} + +// LinkRelation defines the relationship between a link's source and target. +type LinkRelation string + +const ( + // LinkRelationDown defines the target note as a child of the source. + LinkRelationDown LinkRelation = "down" + // LinkRelationDown defines the target note as a parent of the source. + LinkRelationUp LinkRelation = "up" +) + +// LinkRels creates a slice of LinkRelation from a list of strings. +func LinkRels(rel ...string) []LinkRelation { + rels := []LinkRelation{} + for _, r := range rel { + rels = append(rels, LinkRelation(r)) + } + return rels +} diff --git a/internal/core/note.go b/internal/core/note.go new file mode 100644 index 0000000..5a31922 --- /dev/null +++ b/internal/core/note.go @@ -0,0 +1,64 @@ +package core + +import ( + "time" +) + +// NoteID represents the unique ID of a note collection relative to a given +// NoteIndex implementation. +type NoteID int64 + +func (id NoteID) IsValid() bool { + return id > 0 +} + +// Note holds the metadata and content of a single note. +type Note struct { + // Unique ID of this note in a NoteRepository. + ID NoteID + // Path relative to the root of the notebook. + Path string + // Title of the note. + Title string + // First paragraph from the note body. + Lead string + // Content of the note, after any frontmatter and title heading. + Body string + // Whole raw content of the note. + RawContent string + // Number of words found in the content. + WordCount int + // List of outgoing links (internal or external) found in the content. + Links []Link + // List of tags found in the content. + Tags []string + // JSON dictionary of raw metadata extracted from the frontmatter. + Metadata map[string]interface{} + // Date of creation. + Created time.Time + // Date of last modification. + Modified time.Time + // Checksum of the note content. + Checksum string +} + +// ContextualNote holds a Note and context-sensitive content snippets. +// +// This is used for example: +// * to show an excerpt with highlighted search terms +// * when following links, to print the source paragraph +type ContextualNote struct { + Note + // List of context-sensitive excerpts from the note. + Snippets []string +} + +// MinimalNote holds a Note's title and path information, for display purposes. +type MinimalNote struct { + // Unique ID of this note in a notebook. + ID NoteID + // Path relative to the root of the notebook. + Path string + // Title of the note. + Title string +} diff --git a/core/note/find.go b/internal/core/note_find.go similarity index 55% rename from core/note/find.go rename to internal/core/note_find.go index f7c473b..78e93a1 100644 --- a/core/note/find.go +++ b/internal/core/note_find.go @@ -1,29 +1,16 @@ -package note +package core import ( - "errors" "fmt" "strings" "time" "unicode/utf8" - "github.com/mickael-menu/zk/core" - "github.com/mickael-menu/zk/util/opt" + "github.com/mickael-menu/zk/internal/util/opt" ) -// ErrCanceled is returned when the user cancelled an operation. -var ErrCanceled = errors.New("canceled") - -// Finder retrieves notes matching the given options. -// -// Returns the number of matches found. -type Finder interface { - Find(opts FinderOpts) ([]Match, error) - FindByHref(href string) (*Match, error) -} - -// FinderOpts holds the option used to filter and order a list of notes. -type FinderOpts struct { +// NoteFindOpts holds a set of filtering options used to find notes. +type NoteFindOpts struct { // Filter used to match the notes with FTS predicates. Match opt.String // Filter by note paths. @@ -31,7 +18,7 @@ type FinderOpts struct { // Filter excluding notes at the given paths. ExcludePaths []string // Filter excluding notes with the given IDs. - ExcludeIds []core.NoteId + ExcludeIDs []NoteID // Filter by tags found in the notes. Tags []string // Filter the notes mentioning the given ones. @@ -54,32 +41,23 @@ type FinderOpts struct { ModifiedStart *time.Time // Filter notes modified before the given date. ModifiedEnd *time.Time - // Indicates that the user should select manually the notes. - Interactive bool // Limits the number of results Limit int // Sorting criteria - Sorters []Sorter + Sorters []NoteSorter } -// ExcludingIds creates a new FinderOpts after adding the given ids to the list -// of excluded note ids. -func (o FinderOpts) ExcludingIds(ids ...core.NoteId) FinderOpts { - if o.ExcludeIds == nil { - o.ExcludeIds = []core.NoteId{} +// ExcludingID creates a new FinderOpts after adding the given ID to the list +// of excluded note IDs. +func (o NoteFindOpts) ExcludingID(id NoteID) NoteFindOpts { + if o.ExcludeIDs == nil { + o.ExcludeIDs = []NoteID{} } - o.ExcludeIds = append(o.ExcludeIds, ids...) + o.ExcludeIDs = append(o.ExcludeIDs, id) return o } -// Match holds information about a note matching the find options. -type Match struct { - Metadata - // Snippets are relevant excerpts in the note. - Snippets []string -} - // LinkFilter is a note filter used to select notes linking to other ones. type LinkFilter struct { Paths []string @@ -88,53 +66,74 @@ type LinkFilter struct { MaxDistance int } -// Sorter represents an order term used to sort a list of notes. -type Sorter struct { - Field SortField +// NoteSorter represents an order term used to sort a list of notes. +type NoteSorter struct { + Field NoteSortField Ascending bool } -// SortField represents a note field used to sort a list of notes. -type SortField int +// NoteSortField represents a note field used to sort a list of notes. +type NoteSortField int const ( // Sort by creation date. - SortCreated SortField = iota + 1 + NoteSortCreated NoteSortField = iota + 1 // Sort by modification date. - SortModified + NoteSortModified // Sort by the file paths. - SortPath + NoteSortPath // Sort randomly. - SortRandom + NoteSortRandom // Sort by the note titles. - SortTitle + NoteSortTitle // Sort by the number of words in the note bodies. - SortWordCount + NoteSortWordCount + // Sort by the length of the note path. + // This is not accessible to the user but used for technical reasons, to + // find the best match when searching a path prefix. + NoteSortPathLength ) -// SorterFromString returns a Sorter from its string representation. +// NoteSortersFromStrings returns a list of NoteSorter from their string +// representation. +func NoteSortersFromStrings(strs []string) ([]NoteSorter, error) { + sorters := make([]NoteSorter, 0) + + // Iterates in reverse order to be able to override sort criteria set in a + // config alias with a `--sort` flag. + for i := len(strs) - 1; i >= 0; i-- { + sorter, err := NoteSorterFromString(strs[i]) + if err != nil { + return sorters, err + } + sorters = append(sorters, sorter) + } + return sorters, nil +} + +// NoteSorterFromString returns a NoteSorter from its string representation. // // If the input str has for suffix `+`, then the order will be ascending, while // descending for `-`. If no suffix is given, then the default order for the // sorting field will be used. -func SorterFromString(str string) (Sorter, error) { +func NoteSorterFromString(str string) (NoteSorter, error) { orderSymbol, _ := utf8.DecodeLastRuneInString(str) str = strings.TrimRight(str, "+-") - var sorter Sorter + var sorter NoteSorter switch str { case "created", "c": - sorter = Sorter{Field: SortCreated, Ascending: false} + sorter = NoteSorter{Field: NoteSortCreated, Ascending: false} case "modified", "m": - sorter = Sorter{Field: SortModified, Ascending: false} + sorter = NoteSorter{Field: NoteSortModified, Ascending: false} case "path", "p": - sorter = Sorter{Field: SortPath, Ascending: true} + sorter = NoteSorter{Field: NoteSortPath, Ascending: true} case "title", "t": - sorter = Sorter{Field: SortTitle, Ascending: true} + sorter = NoteSorter{Field: NoteSortTitle, Ascending: true} case "random", "r": - sorter = Sorter{Field: SortRandom, Ascending: true} + sorter = NoteSorter{Field: NoteSortRandom, Ascending: true} case "word-count", "wc": - sorter = Sorter{Field: SortWordCount, Ascending: true} + sorter = NoteSorter{Field: NoteSortWordCount, Ascending: true} default: return sorter, fmt.Errorf("%s: unknown sorting term\ntry created, modified, path, title, random or word-count", str) } @@ -148,19 +147,3 @@ func SorterFromString(str string) (Sorter, error) { return sorter, nil } - -// SortersFromStrings returns a list of Sorter from their string representation. -func SortersFromStrings(strs []string) ([]Sorter, error) { - sorters := make([]Sorter, 0) - - // Iterates in reverse order to be able to override sort criteria set in a - // config alias with a `--sort` flag. - for i := len(strs) - 1; i >= 0; i-- { - sorter, err := SorterFromString(strs[i]) - if err != nil { - return sorters, err - } - sorters = append(sorters, sorter) - } - return sorters, nil -} diff --git a/internal/core/note_find_test.go b/internal/core/note_find_test.go new file mode 100644 index 0000000..cc349f2 --- /dev/null +++ b/internal/core/note_find_test.go @@ -0,0 +1,69 @@ +package core + +import ( + "testing" + + "github.com/mickael-menu/zk/internal/util/test/assert" +) + +func TestNoteSorterFromString(t *testing.T) { + test := func(str string, expectedField NoteSortField, expectedAscending bool) { + actual, err := NoteSorterFromString(str) + assert.Nil(t, err) + assert.Equal(t, actual, NoteSorter{Field: expectedField, Ascending: expectedAscending}) + } + + test("c", NoteSortCreated, false) + test("c+", NoteSortCreated, true) + test("created", NoteSortCreated, false) + test("created-", NoteSortCreated, false) + test("created+", NoteSortCreated, true) + + test("m", NoteSortModified, false) + test("modified", NoteSortModified, false) + test("modified+", NoteSortModified, true) + + test("p", NoteSortPath, true) + test("path", NoteSortPath, true) + test("path-", NoteSortPath, false) + + test("t", NoteSortTitle, true) + test("title", NoteSortTitle, true) + test("title-", NoteSortTitle, false) + + test("r", NoteSortRandom, true) + test("random", NoteSortRandom, true) + test("random-", NoteSortRandom, false) + + test("wc", NoteSortWordCount, true) + test("word-count", NoteSortWordCount, true) + test("word-count-", NoteSortWordCount, false) + + _, err := NoteSorterFromString("foobar") + assert.Err(t, err, "foobar: unknown sorting term") +} + +func TestSortersFromStrings(t *testing.T) { + test := func(strs []string, expected []NoteSorter) { + actual, err := NoteSortersFromStrings(strs) + assert.Nil(t, err) + assert.Equal(t, actual, expected) + } + + test([]string{}, []NoteSorter{}) + + test([]string{"created"}, []NoteSorter{ + {Field: NoteSortCreated, Ascending: false}, + }) + + // It is parsed in reverse order to be able to override sort criteria set + // in aliases. + test([]string{"c+", "title", "random"}, []NoteSorter{ + {Field: NoteSortRandom, Ascending: true}, + {Field: NoteSortTitle, Ascending: true}, + {Field: NoteSortCreated, Ascending: true}, + }) + + _, err := NoteSortersFromStrings([]string{"c", "foobar"}) + assert.Err(t, err, "foobar: unknown sorting term") +} diff --git a/internal/core/note_format.go b/internal/core/note_format.go new file mode 100644 index 0000000..b99fe20 --- /dev/null +++ b/internal/core/note_format.go @@ -0,0 +1,64 @@ +package core + +import ( + "path/filepath" + "regexp" + "time" +) + +// NoteFormatter formats notes to be printed on the screen. +type NoteFormatter func(note ContextualNote) (string, error) + +func newNoteFormatter(basePath string, template Template, fs FileStorage) (NoteFormatter, error) { + termRepl, err := template.Styler().Style("$1", StyleTerm) + if err != nil { + return nil, err + } + + return func(note ContextualNote) (string, error) { + path, err := fs.Rel(filepath.Join(basePath, note.Path)) + if err != nil { + return "", err + } + + snippets := make([]string, 0) + for _, snippet := range note.Snippets { + snippets = append(snippets, noteTermRegex.ReplaceAllString(snippet, termRepl)) + } + + return template.Render(noteFormatRenderContext{ + Path: path, + Title: note.Title, + Lead: note.Lead, + Body: note.Body, + Snippets: snippets, + Tags: note.Tags, + RawContent: note.RawContent, + WordCount: note.WordCount, + Metadata: note.Metadata, + Created: note.Created, + Modified: note.Modified, + Checksum: note.Checksum, + }) + }, nil +} + +var noteTermRegex = regexp.MustCompile(`(.*?)`) + +// noteFormatRenderContext holds the variables available to the note formatting +// templates. +type noteFormatRenderContext struct { + Path string + Title string + Lead string + Body string + Snippets []string + RawContent string `handlebars:"raw-content"` + WordCount int `handlebars:"word-count"` + Tags []string + Metadata map[string]interface{} + Created time.Time + Modified time.Time + Checksum string + Env map[string]string +} diff --git a/internal/core/note_format_test.go b/internal/core/note_format_test.go new file mode 100644 index 0000000..2f8cac0 --- /dev/null +++ b/internal/core/note_format_test.go @@ -0,0 +1,202 @@ +package core + +import ( + "testing" + "time" + + "github.com/mickael-menu/zk/internal/util/test/assert" +) + +func TestNewNoteFormatter(t *testing.T) { + test := formatTest{ + format: "format", + } + test.setup() + + var date1 = time.Date(2009, 1, 17, 20, 34, 58, 651387237, time.UTC) + var date2 = time.Date(2009, 2, 17, 20, 34, 58, 651387237, time.UTC) + var date3 = time.Date(2009, 3, 17, 20, 34, 58, 651387237, time.UTC) + var date4 = time.Date(2009, 4, 17, 20, 34, 58, 651387237, time.UTC) + + formatter, err := test.run("format") + assert.Nil(t, err) + assert.Equal(t, test.receivedLang, "fr") + + res, err := formatter(ContextualNote{ + Note: Note{ + ID: 1, + Path: "note1", + Title: "Note 1", + Lead: "Lead 1", + Body: "Body 1", + RawContent: "Content 1", + WordCount: 1, + Tags: []string{"tag1", "tag2"}, + Metadata: map[string]interface{}{ + "metadata1": "val1", + "metadata2": "val2", + }, + Created: date1, + Modified: date2, + Checksum: "checksum1", + }, + Snippets: []string{"snippet1", "snippet2"}, + }) + assert.Nil(t, err) + assert.Equal(t, res, "format") + + res, err = formatter(ContextualNote{ + Note: Note{ + ID: 2, + Path: "dir/note2", + Title: "Note 2", + Lead: "Lead 2", + Body: "Body 2", + RawContent: "Content 2", + WordCount: 2, + Tags: []string{}, + Metadata: map[string]interface{}{}, + Created: date3, + Modified: date4, + Checksum: "checksum2", + }, + Snippets: []string{}, + }) + assert.Nil(t, err) + assert.Equal(t, res, "format") + + // Check that the template received the proper contexts + assert.Equal(t, test.template.Contexts, []interface{}{ + noteFormatRenderContext{ + Path: "note1", + Title: "Note 1", + Lead: "Lead 1", + Body: "Body 1", + Snippets: []string{"snippet1", "snippet2"}, + RawContent: "Content 1", + WordCount: 1, + Tags: []string{"tag1", "tag2"}, + Metadata: map[string]interface{}{ + "metadata1": "val1", + "metadata2": "val2", + }, + Created: date1, + Modified: date2, + Checksum: "checksum1", + }, + noteFormatRenderContext{ + Path: "dir/note2", + Title: "Note 2", + Lead: "Lead 2", + Body: "Body 2", + Snippets: []string{}, + RawContent: "Content 2", + WordCount: 2, + Tags: []string{}, + Metadata: map[string]interface{}{}, + Created: date3, + Modified: date4, + Checksum: "checksum2", + }, + }) +} + +func TestNoteFormatterMakesPathRelative(t *testing.T) { + test := func(basePath, currentPath, path string, expected string) { + test := formatTest{ + rootDir: basePath, + workingDir: currentPath, + } + test.setup() + formatter, err := test.run("format") + assert.Nil(t, err) + _, err = formatter(ContextualNote{ + Note: Note{Path: path}, + }) + assert.Nil(t, err) + assert.Equal(t, test.template.Contexts, []interface{}{ + noteFormatRenderContext{ + Path: expected, + Snippets: []string{}, + }, + }) + } + + // Check that the path is relative to the current directory. + test("", "", "note.md", "note.md") + test("", "", "dir/note.md", "dir/note.md") + test("/abs/zk", "/abs/zk", "note.md", "note.md") + test("/abs/zk", "/abs/zk", "dir/note.md", "dir/note.md") + test("/abs/zk", "/abs/zk/dir", "note.md", "../note.md") + test("/abs/zk", "/abs/zk/dir", "dir/note.md", "note.md") + test("/abs/zk", "/abs", "note.md", "zk/note.md") + test("/abs/zk", "/abs", "dir/note.md", "zk/dir/note.md") +} + +func TestNoteFormatterStylesSnippetTerm(t *testing.T) { + test := func(snippet string, expected string) { + test := formatTest{} + test.setup() + formatter, err := test.run("format") + assert.Nil(t, err) + _, err = formatter(ContextualNote{ + Snippets: []string{snippet}, + }) + assert.Nil(t, err) + assert.Equal(t, test.template.Contexts, []interface{}{ + noteFormatRenderContext{ + Path: ".", + Snippets: []string{expected}, + }, + }) + } + + test("Hello world!", "Hello world!") + test("Hello world!", "Hello term(world)!") + test("Hello world with several matches!", "Hello term(world) with term(several matches)!") + test("Hello world with several matches!", "Hello term(world) with term(several matches)!") +} + +// formatTest builds and runs the SUT for note formatter test cases. +type formatTest struct { + format string + rootDir string + workingDir string + fs *fileStorageMock + config Config + templateLoader *templateLoaderMock + template *templateSpy + receivedLang string +} + +func (t *formatTest) setup() { + if t.format == "" { + t.format = "format" + } + + if t.rootDir == "" { + t.rootDir = "/notebook" + } + if t.workingDir == "" { + t.workingDir = t.rootDir + } + t.fs = newFileStorageMock(t.workingDir, []string{}) + + t.templateLoader = newTemplateLoaderMock() + t.template = t.templateLoader.SpyString(t.format) + + t.config = NewDefaultConfig() + t.config.Note.Lang = "fr" +} + +func (t *formatTest) run(format string) (NoteFormatter, error) { + notebook := NewNotebook(t.rootDir, t.config, NotebookPorts{ + TemplateLoaderFactory: func(language string) (TemplateLoader, error) { + t.receivedLang = language + return t.templateLoader, nil + }, + FS: t.fs, + }) + + return notebook.NewNoteFormatter(format) +} diff --git a/internal/core/note_index.go b/internal/core/note_index.go new file mode 100644 index 0000000..6b9e31e --- /dev/null +++ b/internal/core/note_index.go @@ -0,0 +1,217 @@ +package core + +import ( + "crypto/sha256" + "fmt" + "io/ioutil" + "path/filepath" + "strings" + "time" + + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/errors" + "github.com/mickael-menu/zk/internal/util/paths" + strutil "github.com/mickael-menu/zk/internal/util/strings" + "github.com/relvacode/iso8601" + "gopkg.in/djherbis/times.v1" +) + +// NoteIndex persists and grants access to indexed information about the notes. +type NoteIndex interface { + + // Find retrieves the notes matching the given filtering and sorting criteria. + Find(opts NoteFindOpts) ([]ContextualNote, error) + // FindMinimal retrieves lightweight metadata for the notes matching the + // given filtering and sorting criteria. + FindMinimal(opts NoteFindOpts) ([]MinimalNote, error) + + // FindCollections retrieves all the collections of the given kind. + FindCollections(kind CollectionKind) ([]Collection, error) + + // Indexed returns the list of indexed note file metadata. + IndexedPaths() (<-chan paths.Metadata, error) + // Add indexes a new note from its metadata. + Add(note Note) (NoteID, error) + // Update resets the metadata of an already indexed note. + Update(note Note) error + // Remove deletes a note from the index. + Remove(path string) error + + // Commit performs a set of operations atomically. + Commit(transaction func(idx NoteIndex) error) error + + // NeedsReindexing returns whether all notes should be reindexed. + NeedsReindexing() (bool, error) + // SetNeedsReindexing indicates whether all notes should be reindexed. + SetNeedsReindexing(needsReindexing bool) error +} + +// NoteIndexingStats holds statistics about a notebook indexing process. +type NoteIndexingStats struct { + // Number of notes in the source. + SourceCount int + // Number of newly indexed notes. + AddedCount int + // Number of notes modified since last indexing. + ModifiedCount int + // Number of notes removed since last indexing. + RemovedCount int + // Duration of the indexing process. + Duration time.Duration +} + +// String implements Stringer +func (s NoteIndexingStats) String() string { + return fmt.Sprintf(`Indexed %d %v in %v + + %d added + ~ %d modified + - %d removed`, + s.SourceCount, + strutil.Pluralize("note", s.SourceCount), + s.Duration.Round(500*time.Millisecond), + s.AddedCount, s.ModifiedCount, s.RemovedCount, + ) +} + +// indexTask indexes the notes in the given directory with the NoteIndex. +type indexTask struct { + notebook *Notebook + force bool + index NoteIndex + parser NoteParser + logger util.Logger +} + +func (t *indexTask) execute(callback func(change paths.DiffChange)) (NoteIndexingStats, error) { + wrap := errors.Wrapper("indexing failed") + + stats := NoteIndexingStats{} + startTime := time.Now() + + needsReindexing, err := t.index.NeedsReindexing() + if err != nil { + return stats, wrap(err) + } + + force := t.force || needsReindexing + + // FIXME: Use Extension defined in each DirConfig. + source := paths.Walk(t.notebook.Path, t.notebook.Config.Note.Extension, t.logger) + target, err := t.index.IndexedPaths() + if err != nil { + return stats, wrap(err) + } + + // FIXME: Use the FS? + count, err := paths.Diff(source, target, force, func(change paths.DiffChange) error { + callback(change) + + switch change.Kind { + case paths.DiffAdded: + stats.AddedCount += 1 + note, err := t.noteAt(change.Path) + if err == nil { + _, err = t.index.Add(note) + } + t.logger.Err(err) + + case paths.DiffModified: + stats.ModifiedCount += 1 + note, err := t.noteAt(change.Path) + if err == nil { + err = t.index.Update(note) + } + t.logger.Err(err) + + case paths.DiffRemoved: + stats.RemovedCount += 1 + err := t.index.Remove(change.Path) + t.logger.Err(err) + } + return nil + }) + + stats.SourceCount = count + stats.Duration = time.Since(startTime) + + if needsReindexing { + err = t.index.SetNeedsReindexing(false) + } + + return stats, wrap(err) +} + +// noteAt parses a Note at the given path. +func (t *indexTask) noteAt(path string) (Note, error) { + note := Note{ + Path: path, + Links: []Link{}, + Tags: []string{}, + } + + absPath := filepath.Join(t.notebook.Path, path) + content, err := ioutil.ReadFile(absPath) + if err != nil { + return note, err + } + contentStr := string(content) + contentParts, err := t.parser.Parse(contentStr) + if err != nil { + return note, err + } + note.Title = contentParts.Title.String() + note.Lead = contentParts.Lead.String() + note.Body = contentParts.Body.String() + note.RawContent = contentStr + note.WordCount = len(strings.Fields(contentStr)) + note.Links = make([]Link, 0) + note.Tags = contentParts.Tags + note.Metadata = contentParts.Metadata + note.Checksum = fmt.Sprintf("%x", sha256.Sum256(content)) + + for _, link := range contentParts.Links { + if !strutil.IsURL(link.Href) { + // Make the href relative to the notebook root. + href := filepath.Join(filepath.Dir(absPath), link.Href) + link.Href, err = t.notebook.RelPath(href) + if err != nil { + return note, err + } + } + note.Links = append(note.Links, link) + } + + times, err := times.Stat(absPath) + if err != nil { + return note, err + } + + note.Modified = times.ModTime().UTC() + note.Created = t.creationDateFrom(note.Metadata, times) + + return note, nil +} + +func (t *indexTask) creationDateFrom(metadata map[string]interface{}, times times.Timespec) time.Time { + // Read the creation date from the YAML frontmatter `date` key. + if dateVal, ok := metadata["date"]; ok { + if dateStr, ok := dateVal.(string); ok { + if time, err := iso8601.ParseString(dateStr); err == nil { + return time + } + // Omitting the `T` is common + if time, err := time.Parse("2006-01-02 15:04:05", dateStr); err == nil { + return time + } + if time, err := time.Parse("2006-01-02 15:04", dateStr); err == nil { + return time + } + } + } + + if times.HasBirthTime() { + return times.BirthTime().UTC() + } + + return time.Now().UTC() +} diff --git a/internal/core/note_new.go b/internal/core/note_new.go new file mode 100644 index 0000000..80a28b1 --- /dev/null +++ b/internal/core/note_new.go @@ -0,0 +1,107 @@ +package core + +import ( + "path/filepath" + "time" + + "github.com/mickael-menu/zk/internal/util/opt" + "github.com/mickael-menu/zk/internal/util/paths" +) + +type newNoteTask struct { + dir Dir + title string + content string + date time.Time + extra map[string]string + env map[string]string + fs FileStorage + filenameTemplate string + bodyTemplatePath opt.String + templates TemplateLoader + genID IDGenerator +} + +func (t *newNoteTask) execute() (string, error) { + filenameTemplate, err := t.templates.LoadTemplate(t.filenameTemplate) + if err != nil { + return "", err + } + + var contentTemplate Template = NullTemplate + if templatePath := t.bodyTemplatePath.Unwrap(); templatePath != "" { + contentTemplate, err = t.templates.LoadTemplateAt(templatePath) + if err != nil { + return "", err + } + } + + context := newNoteTemplateContext{ + Title: t.title, + Content: t.content, + Dir: t.dir.Name, + Extra: t.extra, + Now: t.date, + Env: t.env, + } + + path, context, err := t.generatePath(context, filenameTemplate) + if err != nil { + return "", err + } + + content, err := contentTemplate.Render(context) + if err != nil { + return "", err + } + + err = t.fs.Write(path, []byte(content)) + if err != nil { + return "", err + } + + return path, nil +} + +func (c *newNoteTask) generatePath(context newNoteTemplateContext, filenameTemplate Template) (string, newNoteTemplateContext, error) { + var err error + var filename string + var path string + + for i := 0; i < 50; i++ { + context.ID = c.genID() + + filename, err = filenameTemplate.Render(context) + if err != nil { + return "", context, err + } + + path = filepath.Join(c.dir.Path, filename) + exists, err := c.fs.FileExists(path) + if err != nil { + return "", context, err + } else if !exists { + context.Filename = filepath.Base(path) + context.FilenameStem = paths.FilenameStem(path) + return path, context, nil + } + } + + return "", context, ErrNoteExists{ + Name: filepath.Join(c.dir.Name, filename), + Path: path, + } +} + +// newNoteTemplateContext holds the placeholder values which will be expanded in the templates. +type newNoteTemplateContext struct { + ID string `handlebars:"id"` + Title string + Content string + Dir string + Filename string + FilenameStem string `handlebars:"filename-stem"` + Extra map[string]string + Now time.Time + Env map[string]string +} diff --git a/internal/core/note_new_test.go b/internal/core/note_new_test.go new file mode 100644 index 0000000..89baa3a --- /dev/null +++ b/internal/core/note_new_test.go @@ -0,0 +1,487 @@ +package core + +import ( + "fmt" + "testing" + "time" + + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/opt" + "github.com/mickael-menu/zk/internal/util/test/assert" +) + +func TestNotebookNewNote(t *testing.T) { + test := newNoteTest{ + rootDir: "/notebook", + } + test.setup() + + path, err := test.run(NewNoteOpts{ + Title: opt.NewString("Note title"), + Content: "Note content", + Extra: map[string]string{ + "add-extra": "ec83da", + }, + Date: now, + }) + + assert.Nil(t, err) + assert.Equal(t, path, "/notebook/filename.ext") + + // Check created note. + assert.Equal(t, test.fs.Files[path], "body") + + assert.Equal(t, test.receivedLang, test.config.Note.Lang) + assert.Equal(t, test.receivedIDOpts, test.config.Note.IDOptions) + + // Check that the templates received the proper render contexts. + assert.Equal(t, test.filenameTemplate.Contexts, []interface{}{ + newNoteTemplateContext{ + ID: "id", + Title: "Note title", + Content: "Note content", + Dir: "", + Filename: "", + FilenameStem: "", + Extra: map[string]string{"add-extra": "ec83da", "conf-extra": "38srnw"}, + Now: now, + Env: map[string]string{"KEY1": "foo", "KEY2": "bar"}, + }, + }) + assert.Equal(t, test.bodyTemplate.Contexts, []interface{}{ + newNoteTemplateContext{ + ID: "id", + Title: "Note title", + Content: "Note content", + Dir: "", + Filename: "filename.ext", + FilenameStem: "filename", + Extra: map[string]string{"add-extra": "ec83da", "conf-extra": "38srnw"}, + Now: now, + Env: map[string]string{"KEY1": "foo", "KEY2": "bar"}, + }, + }) +} + +func TestNotebookNewNoteWithDefaultTitle(t *testing.T) { + test := newNoteTest{ + rootDir: "/notebook", + } + test.setup() + + _, err := test.run(NewNoteOpts{ + Date: now, + }) + + assert.Nil(t, err) + assert.Equal(t, test.filenameTemplate.Contexts, []interface{}{ + newNoteTemplateContext{ + ID: "id", + Title: "Titre par défaut", + Extra: map[string]string{"conf-extra": "38srnw"}, + Now: now, + Env: map[string]string{"KEY1": "foo", "KEY2": "bar"}, + }, + }) +} + +func TestNotebookNewNoteInUnknownDir(t *testing.T) { + test := newNoteTest{ + rootDir: "/notebook", + } + test.setup() + + _, err := test.run(NewNoteOpts{ + Directory: opt.NewString("a-dir"), + }) + + assert.Err(t, err, "a-dir: directory not found") +} + +func TestNotebookNewNoteInDir(t *testing.T) { + test := newNoteTest{ + rootDir: "/notebook", + dirs: []string{"/notebook/a-dir"}, + } + test.setup() + + path, err := test.run(NewNoteOpts{ + Title: opt.NewString("Note title"), + Directory: opt.NewString("a-dir"), + Date: now, + }) + + assert.Nil(t, err) + assert.Equal(t, path, "/notebook/a-dir/filename.ext") + + // Check created note. + assert.Equal(t, test.fs.Files[path], "body") + + // Check that the templates received the proper render contexts. + assert.Equal(t, test.filenameTemplate.Contexts, []interface{}{ + newNoteTemplateContext{ + ID: "id", + Title: "Note title", + Content: "", + Dir: "a-dir", + Filename: "", + FilenameStem: "", + Extra: map[string]string{"conf-extra": "38srnw"}, + Now: now, + Env: map[string]string{"KEY1": "foo", "KEY2": "bar"}, + }, + }) + assert.Equal(t, test.bodyTemplate.Contexts, []interface{}{ + newNoteTemplateContext{ + ID: "id", + Title: "Note title", + Content: "", + Dir: "a-dir", + Filename: "filename.ext", + FilenameStem: "filename", + Extra: map[string]string{"conf-extra": "38srnw"}, + Now: now, + Env: map[string]string{"KEY1": "foo", "KEY2": "bar"}, + }, + }) +} + +// Create a note in a directory belonging to a config group which will override +// the default config. +func TestNotebookNewNoteInDirWithGroup(t *testing.T) { + groupConfig := GroupConfig{ + Paths: []string{"a-dir"}, + Note: NoteConfig{ + DefaultTitle: "Group default title", + FilenameTemplate: "group-filename", + BodyTemplatePath: opt.NewString("group-body"), + Extension: "group-ext", + Lang: "de", + IDOptions: IDOptions{ + Length: 29, + Charset: []rune("group"), + Case: CaseMixed, + }, + }, + Extra: map[string]string{ + "group-extra": "e48rs", + }, + } + + test := newNoteTest{ + rootDir: "/notebook", + dirs: []string{"/notebook/a-dir"}, + groups: map[string]GroupConfig{ + "group-a": groupConfig, + }, + } + test.setup() + + filenameTemplate := test.templateLoader.SpyString("group-filename.group-ext") + bodyTemplate := test.templateLoader.SpyFile("group-body", "group template body") + + path, err := test.run(NewNoteOpts{ + Directory: opt.NewString("a-dir"), + Date: now, + }) + + assert.Nil(t, err) + assert.Equal(t, path, "/notebook/a-dir/group-filename.group-ext") + + // Check created note. + assert.Equal(t, test.fs.Files[path], "group template body") + + assert.Equal(t, test.receivedLang, groupConfig.Note.Lang) + assert.Equal(t, test.receivedIDOpts, groupConfig.Note.IDOptions) + + // Check that the templates received the proper render contexts. + assert.Equal(t, filenameTemplate.Contexts, []interface{}{ + newNoteTemplateContext{ + ID: "id", + Title: "Group default title", + Content: "", + Dir: "a-dir", + Filename: "", + FilenameStem: "", + Extra: map[string]string{"group-extra": "e48rs"}, + Now: now, + Env: map[string]string{"KEY1": "foo", "KEY2": "bar"}, + }, + }) + assert.Equal(t, bodyTemplate.Contexts, []interface{}{ + newNoteTemplateContext{ + ID: "id", + Title: "Group default title", + Content: "", + Dir: "a-dir", + Filename: "group-filename.group-ext", + FilenameStem: "group-filename", + Extra: map[string]string{"group-extra": "e48rs"}, + Now: now, + Env: map[string]string{"KEY1": "foo", "KEY2": "bar"}, + }, + }) +} + +// Create a note with an explicit group overriding the default config. +func TestNotebookNewNoteWithGroup(t *testing.T) { + groupConfig := GroupConfig{ + Paths: []string{"a-dir"}, + Note: NoteConfig{ + DefaultTitle: "Group default title", + FilenameTemplate: "group-filename", + BodyTemplatePath: opt.NewString("group-body"), + Extension: "group-ext", + Lang: "de", + IDOptions: IDOptions{ + Length: 29, + Charset: []rune("group"), + Case: CaseMixed, + }, + }, + Extra: map[string]string{ + "group-extra": "e48rs", + }, + } + + test := newNoteTest{ + rootDir: "/notebook", + groups: map[string]GroupConfig{ + "group-a": groupConfig, + }, + } + test.setup() + + filenameTemplate := test.templateLoader.SpyString("group-filename.group-ext") + bodyTemplate := test.templateLoader.SpyFile("group-body", "group template body") + + path, err := test.run(NewNoteOpts{ + Group: opt.NewString("group-a"), + Date: now, + }) + + assert.Nil(t, err) + assert.Equal(t, path, "/notebook/group-filename.group-ext") + + // Check created note. + assert.Equal(t, test.fs.Files[path], "group template body") + + assert.Equal(t, test.receivedLang, groupConfig.Note.Lang) + assert.Equal(t, test.receivedIDOpts, groupConfig.Note.IDOptions) + + // Check that the templates received the proper render contexts. + assert.Equal(t, filenameTemplate.Contexts, []interface{}{ + newNoteTemplateContext{ + ID: "id", + Title: "Group default title", + Content: "", + Dir: "", + Filename: "", + FilenameStem: "", + Extra: map[string]string{"group-extra": "e48rs"}, + Now: now, + Env: map[string]string{"KEY1": "foo", "KEY2": "bar"}, + }, + }) + assert.Equal(t, bodyTemplate.Contexts, []interface{}{ + newNoteTemplateContext{ + ID: "id", + Title: "Group default title", + Content: "", + Dir: "", + Filename: "group-filename.group-ext", + FilenameStem: "group-filename", + Extra: map[string]string{"group-extra": "e48rs"}, + Now: now, + Env: map[string]string{"KEY1": "foo", "KEY2": "bar"}, + }, + }) +} + +func TestNotebookNewNoteWithUnknownGroup(t *testing.T) { + test := newNoteTest{ + rootDir: "/notebook", + } + test.setup() + + _, err := test.run(NewNoteOpts{ + Group: opt.NewString("group-a"), + Date: now, + }) + + assert.Err(t, err, "no group named `group-a` found in the config") +} + +func TestNotebookNewNoteWithCustomTemplate(t *testing.T) { + test := newNoteTest{ + rootDir: "/notebook", + } + test.setup() + test.templateLoader.SpyFile("custom-body", "custom body template") + + path, err := test.run(NewNoteOpts{ + Template: opt.NewString("custom-body"), + Date: now, + }) + + assert.Nil(t, err) + assert.Equal(t, test.fs.Files[path], "custom body template") +} + +// Tries to generate a filename until one is free. +func TestNotebookNewNoteTriesUntilFreePath(t *testing.T) { + test := newNoteTest{ + rootDir: "/notebook", + files: map[string]string{ + "/notebook/filename1.ext": "file1", + "/notebook/filename2.ext": "file2", + "/notebook/filename3.ext": "file3", + }, + filenameTemplateRender: func(context newNoteTemplateContext) string { + return "filename" + context.ID + ".ext" + }, + idGeneratorFactory: incrementingID, + } + test.setup() + + path, err := test.run(NewNoteOpts{ + Date: now, + }) + + assert.Nil(t, err) + assert.Equal(t, path, "/notebook/filename4.ext") + + // Check created note. + assert.Equal(t, test.fs.Files[path], "body") +} + +func TestNotebookNewNoteErrorWhenNoFreePath(t *testing.T) { + files := map[string]string{} + for i := 1; i < 51; i++ { + files[fmt.Sprintf("/notebook/filename%d.ext", i)] = "body" + } + test := newNoteTest{ + rootDir: "/notebook", + files: files, + filenameTemplateRender: func(context newNoteTemplateContext) string { + return "filename" + context.ID + ".ext" + }, + idGeneratorFactory: incrementingID, + } + test.setup() + + _, err := test.run(NewNoteOpts{ + Date: now, + }) + + assert.Err(t, err, "/notebook/filename50.ext: note already exists") + assert.Equal(t, test.fs.Files, files) +} + +var now = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) + +// newNoteTest builds and runs the SUT for new note test cases. +type newNoteTest struct { + rootDir string + files map[string]string + dirs []string + fs *fileStorageMock + config Config + groups map[string]GroupConfig + templateLoader *templateLoaderMock + filenameTemplateRender func(context newNoteTemplateContext) string + filenameTemplate *templateSpy + bodyTemplate *templateSpy + idGeneratorFactory IDGeneratorFactory + osEnv map[string]string + + receivedLang string + receivedIDOpts IDOptions +} + +func (t *newNoteTest) setup() { + if t.rootDir == "" { + t.rootDir = "/notebook" + } + if t.dirs == nil { + t.dirs = []string{} + } + t.dirs = append(t.dirs, t.rootDir) + t.fs = newFileStorageMock(t.rootDir, t.dirs) + if t.files != nil { + t.fs.Files = t.files + } + + t.templateLoader = newTemplateLoaderMock() + if t.filenameTemplateRender != nil { + t.filenameTemplate = t.templateLoader.Spy("filename.ext", func(context interface{}) string { + return t.filenameTemplateRender(context.(newNoteTemplateContext)) + }) + } else { + t.filenameTemplate = t.templateLoader.SpyString("filename.ext") + } + t.bodyTemplate = t.templateLoader.SpyFile("default", "body") + + if t.idGeneratorFactory == nil { + t.idGeneratorFactory = func(opts IDOptions) func() string { + return func() string { return "id" } + } + } + + if t.osEnv == nil { + t.osEnv = map[string]string{ + "KEY1": "foo", + "KEY2": "bar", + } + } + + if t.groups == nil { + t.groups = map[string]GroupConfig{} + } + + t.config = Config{ + Note: NoteConfig{ + FilenameTemplate: "filename", + Extension: "ext", + BodyTemplatePath: opt.NewString("default"), + Lang: "fr", + DefaultTitle: "Titre par défaut", + IDOptions: IDOptions{ + Length: 42, + Charset: []rune("hello"), + Case: CaseUpper, + }, + }, + Groups: t.groups, + Extra: map[string]string{ + "conf-extra": "38srnw", + }, + } +} + +func (t *newNoteTest) run(opts NewNoteOpts) (string, error) { + notebook := NewNotebook(t.rootDir, t.config, NotebookPorts{ + TemplateLoaderFactory: func(language string) (TemplateLoader, error) { + t.receivedLang = language + return t.templateLoader, nil + }, + IDGeneratorFactory: func(opts IDOptions) func() string { + t.receivedIDOpts = opts + return t.idGeneratorFactory(opts) + }, + FS: t.fs, + Logger: &util.NullLogger, + OSEnv: func() map[string]string { return t.osEnv }, + }) + + return notebook.NewNote(opts) +} + +// incrementingID returns a generator of incrementing string ID. +func incrementingID(opts IDOptions) func() string { + i := 0 + return func() string { + i++ + return fmt.Sprintf("%d", i) + } +} diff --git a/internal/core/note_parse.go b/internal/core/note_parse.go new file mode 100644 index 0000000..fbcf091 --- /dev/null +++ b/internal/core/note_parse.go @@ -0,0 +1,26 @@ +package core + +import ( + "github.com/mickael-menu/zk/internal/util/opt" +) + +// NoteParser parses a note's raw content into its components. +type NoteParser interface { + Parse(content string) (*ParsedNote, error) +} + +// ParsedNote holds the data parsed from the note content. +type ParsedNote struct { + // Title is the heading of the note. + Title opt.String + // Lead is the opening paragraph or section of the note. + Lead opt.String + // Body is the content of the note, including the Lead but without the Title. + Body opt.String + // Tags is the list of tags found in the note content. + Tags []string + // Links is the list of outbound links found in the note. + Links []Link + // Additional metadata. For example, extracted from a YAML frontmatter. + Metadata map[string]interface{} +} diff --git a/internal/core/notebook.go b/internal/core/notebook.go new file mode 100644 index 0000000..31fa456 --- /dev/null +++ b/internal/core/notebook.go @@ -0,0 +1,295 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/errors" + "github.com/mickael-menu/zk/internal/util/opt" + "github.com/mickael-menu/zk/internal/util/paths" + "github.com/schollz/progressbar/v3" +) + +// Notebook handles queries and commands performed on an opened notebook. +type Notebook struct { + Path string + Config Config + + index NoteIndex + parser NoteParser + templateLoaderFactory TemplateLoaderFactory + idGeneratorFactory IDGeneratorFactory + fs FileStorage + logger util.Logger + osEnv func() map[string]string +} + +// NewNotebook creates a new Notebook instance. +func NewNotebook( + path string, + config Config, + ports NotebookPorts, +) *Notebook { + return &Notebook{ + Path: path, + Config: config, + index: ports.NoteIndex, + parser: ports.NoteParser, + templateLoaderFactory: ports.TemplateLoaderFactory, + idGeneratorFactory: ports.IDGeneratorFactory, + fs: ports.FS, + logger: ports.Logger, + osEnv: ports.OSEnv, + } +} + +type NotebookPorts struct { + NoteIndex NoteIndex + NoteParser NoteParser + TemplateLoaderFactory TemplateLoaderFactory + IDGeneratorFactory IDGeneratorFactory + FS FileStorage + Logger util.Logger + OSEnv func() map[string]string +} + +// NotebookFactory creates a new Notebook instance at the given root path. +type NotebookFactory func(path string, config Config) (*Notebook, error) + +// Index indexes the content of the notebook to be searchable. +// If force is true, existing notes will be reindexed. +func (n *Notebook) Index(force bool) (stats NoteIndexingStats, err error) { + // FIXME: Move out of Core + bar := progressbar.NewOptions(-1, + progressbar.OptionSetWriter(os.Stderr), + progressbar.OptionThrottle(100*time.Millisecond), + progressbar.OptionSpinnerType(14), + ) + + err = n.index.Commit(func(index NoteIndex) error { + task := indexTask{ + notebook: n, + force: force, + index: index, + parser: n.parser, + logger: n.logger, + } + stats, err = task.execute(func(change paths.DiffChange) { + bar.Add(1) + bar.Describe(change.String()) + }) + return err + }) + + bar.Clear() + err = errors.Wrap(err, "indexing") + return +} + +// NewNoteOpts holds the options used to create a new note in a Notebook. +type NewNoteOpts struct { + // Title of the new note. + Title opt.String + // Initial content of the note. + Content string + // Directory in which to create the note, relative to the root of the notebook. + Directory opt.String + // Group this note belongs to. + Group opt.String + // Path to a custom template used to render the note. + Template opt.String + // Extra variables passed to the templates. + Extra map[string]string + // Creation date provided to the templates. + Date time.Time +} + +// ErrNoteExists is an error returned when a note already exists with the +// generated filename. +type ErrNoteExists struct { + Name string + Path string +} + +func (e ErrNoteExists) Error() string { + return fmt.Sprintf("%s: note already exists", e.Path) +} + +// NewNote generates a new note in the notebook and returns its path. +// +// Returns ErrNoteExists if no free filename can be generated for this note. +func (n *Notebook) NewNote(opts NewNoteOpts) (string, error) { + wrap := errors.Wrapper("new note") + + dir, err := n.RequireDirAt(opts.Directory.OrString(n.Path).Unwrap()) + if err != nil { + return "", wrap(err) + } + + config, err := n.Config.GroupConfigNamed(opts.Group.OrString(dir.Group).Unwrap()) + if err != nil { + return "", wrap(err) + } + + extra := config.Extra + for k, v := range opts.Extra { + extra[k] = v + } + + templates, err := n.templateLoaderFactory(config.Note.Lang) + if err != nil { + return "", wrap(err) + } + + task := newNoteTask{ + dir: dir, + title: opts.Title.OrString(config.Note.DefaultTitle).Unwrap(), + content: opts.Content, + date: opts.Date, + extra: extra, + env: n.osEnv(), + fs: n.fs, + filenameTemplate: config.Note.FilenameTemplate + "." + config.Note.Extension, + bodyTemplatePath: opts.Template.Or(config.Note.BodyTemplatePath), + templates: templates, + genID: n.idGeneratorFactory(config.Note.IDOptions), + } + path, err := task.execute() + return path, wrap(err) +} + +// FindNotes retrieves the notes matching the given filtering options. +func (n *Notebook) FindNotes(opts NoteFindOpts) ([]ContextualNote, error) { + return n.index.Find(opts) +} + +// FindMinimalNotes retrieves lightweight metadata for the notes matching +// the given filtering options. +func (n *Notebook) FindMinimalNotes(opts NoteFindOpts) ([]MinimalNote, error) { + return n.index.FindMinimal(opts) +} + +// FindByHref retrieves the first note matching the given link href. +func (n *Notebook) FindByHref(href string) (*MinimalNote, error) { + notes, err := n.FindMinimalNotes(NoteFindOpts{ + IncludePaths: []string{href}, + Limit: 1, + // To find the best match possible, we sort by path length. + // See https://github.com/mickael-menu/zk/issues/23 + Sorters: []NoteSorter{{Field: NoteSortPathLength, Ascending: true}}, + }) + + switch { + case err != nil: + return nil, err + case len(notes) == 0: + return nil, nil + default: + return ¬es[0], nil + } +} + +// FindCollections retrieves all the collections of the given kind. +func (n *Notebook) FindCollections(kind CollectionKind) ([]Collection, error) { + return n.index.FindCollections(kind) +} + +// RelPath returns the path relative to the notebook root to the given path. +func (n *Notebook) RelPath(originalPath string) (string, error) { + wrap := errors.Wrapperf("%v: not a valid notebook path", originalPath) + + path, err := n.fs.Abs(originalPath) + if err != nil { + return path, wrap(err) + } + + path, err = filepath.Rel(n.Path, path) + if err != nil { + return path, wrap(err) + } + if strings.HasPrefix(path, "..") { + return path, fmt.Errorf("%s: path is outside the notebook", originalPath) + } + if path == "." { + path = "" + } + return path, nil +} + +// Dir represents a directory inside a notebook. +type Dir struct { + // Name of the directory, which is the path relative to the notebook's root. + Name string + // Absolute path to the directory. + Path string + // Name of the config group this directory belongs to, if any. + Group string +} + +// RootDir returns the root directory for this notebook. +func (n *Notebook) RootDir() Dir { + return Dir{ + Name: "", + Path: n.Path, + Group: "", + } +} + +// DirAt returns a Dir representation of the notebook directory at the given path. +func (n *Notebook) DirAt(path string) (Dir, error) { + path, err := n.fs.Abs(path) + if err != nil { + return Dir{}, err + } + + name, err := n.RelPath(path) + if err != nil { + return Dir{}, err + } + + group, err := n.Config.GroupNameForPath(name) + if err != nil { + return Dir{}, err + } + + return Dir{ + Name: name, + Path: path, + Group: group, + }, nil +} + +// RequireDirAt is the same as DirAt, but checks that the directory exists +// before returning the Dir. +func (n *Notebook) RequireDirAt(path string) (Dir, error) { + dir, err := n.DirAt(path) + if err != nil { + return dir, err + } + exists, err := n.fs.DirExists(dir.Path) + if err != nil { + return dir, err + } + if !exists { + return dir, fmt.Errorf("%v: directory not found", path) + } + return dir, nil +} + +// NewNoteFormatter returns a NoteFormatter used to format notes with the given template. +func (n *Notebook) NewNoteFormatter(templateString string) (NoteFormatter, error) { + templates, err := n.templateLoaderFactory(n.Config.Note.Lang) + if err != nil { + return nil, err + } + template, err := templates.LoadTemplate(templateString) + if err != nil { + return nil, err + } + + return newNoteFormatter(n.Path, template, n.fs) +} diff --git a/core/zk/zk.go b/internal/core/notebook_store.go similarity index 54% rename from core/zk/zk.go rename to internal/core/notebook_store.go index cbc907d..425c922 100644 --- a/core/zk/zk.go +++ b/internal/core/notebook_store.go @@ -1,14 +1,38 @@ -package zk +package core import ( "fmt" "path/filepath" - "strings" - "github.com/mickael-menu/zk/util/errors" - "github.com/mickael-menu/zk/util/paths" + "github.com/mickael-menu/zk/internal/util/errors" ) +// NotebookStore retrieves or creates new notebooks. +type NotebookStore struct { + config Config + notebookFactory NotebookFactory + fs FileStorage + + // Cached opened notebooks. + notebooks map[string]*Notebook +} + +type NotebookStorePorts struct { + NotebookFactory NotebookFactory + FS FileStorage +} + +// NewNotebookStore creates a new NotebookStore instance using the given +// options and port implementations. +func NewNotebookStore(config Config, ports NotebookStorePorts) *NotebookStore { + return &NotebookStore{ + config: config, + notebookFactory: ports.NotebookFactory, + fs: ports.FS, + notebooks: map[string]*Notebook{}, + } +} + // ErrNotebookNotFound is an error returned when a notebook cannot be found at the given path or its parents. type ErrNotebookNotFound string @@ -16,6 +40,110 @@ func (e ErrNotebookNotFound) Error() string { return fmt.Sprintf("no notebook found in %s or a parent directory", string(e)) } +// Open returns a new Notebook instance for the notebook containing the +// given file path. +func (ns *NotebookStore) Open(path string) (*Notebook, error) { + wrap := errors.Wrapper("open failed") + + path = ns.fs.Canonical(path) + nb := ns.cachedNotebookAt(path) + if nb != nil { + return nb, nil + } + + path, err := ns.fs.Abs(path) + if err != nil { + return nil, wrap(err) + } + path, err = ns.locateNotebook(path) + if err != nil { + return nil, wrap(err) + } + + configPath := filepath.Join(path, ".zk/config.toml") + config, err := OpenConfig(configPath, ns.config, ns.fs) + if err != nil { + return nil, wrap(err) + } + + nb, err = ns.notebookFactory(path, config) + if err != nil { + return nil, wrap(err) + } + ns.notebooks[path] = nb + + return nb, nil +} + +// cachedNotebookAt returns any cached notebook containing the given path. +func (ns *NotebookStore) cachedNotebookAt(path string) *Notebook { + path, err := ns.fs.Abs(path) + if err != nil { + return nil + } + + for root, nb := range ns.notebooks { + if isDesc, err := ns.fs.IsDescendantOf(root, path); isDesc && err == nil { + return nb + } + } + + return nil +} + +// Init creates a new notebook at the given file path. +func (ns *NotebookStore) Init(path string) (*Notebook, error) { + wrap := errors.Wrapper("init") + + path, err := ns.fs.Abs(path) + if err != nil { + return nil, wrap(err) + } + + if existingPath, err := ns.locateNotebook(path); err == nil { + return nil, wrap(fmt.Errorf("a notebook already exists in %v", existingPath)) + } + + // Create the default configuration file. + err = ns.fs.Write(filepath.Join(path, ".zk/config.toml"), []byte(defaultConfig)) + if err != nil { + return nil, wrap(err) + } + + // Create the default template. + err = ns.fs.Write(filepath.Join(path, ".zk/templates/default.md"), []byte(defaultTemplate)) + if err != nil { + return nil, wrap(err) + } + + return ns.Open(path) +} + +// locateNotebook finds the root of the notebook containing the given path. +func (ns *NotebookStore) locateNotebook(path string) (string, error) { + if !filepath.IsAbs(path) { + panic("absolute path expected") + } + + var locate func(string) (string, error) + locate = func(currentPath string) (string, error) { + if currentPath == "/" || currentPath == "." { + return "", ErrNotebookNotFound(path) + } + exists, err := ns.fs.DirExists(filepath.Join(currentPath, ".zk")) + switch { + case err != nil: + return "", err + case exists: + return currentPath, nil + default: + return locate(filepath.Dir(currentPath)) + } + } + + return locate(path) +} + const defaultConfig = `# zk configuration file # # Uncomment the properties you want to customize. @@ -80,11 +208,11 @@ template = "default.md" # the group. This can be useful to quickly declare a group by the name of the # directory it applies to. -#[dir.""] +#[group.""] #paths = ["", ""] -#[dir."".note] +#[group."".note] #filename = "{{date now}}" -#[dir."".extra] +#[group."".extra] #key = "value" @@ -176,230 +304,3 @@ const defaultTemplate = `# {{title}} {{content}} ` - -// Zk (Zettelkasten) represents an opened notebook. -type Zk struct { - // Notebook root path. - Path string - // Global user configuration. - Config Config - // Working directory from which paths are relative. - workingDir string -} - -// Dir represents a directory inside a notebook. -type Dir struct { - // Name of the directory, which is the path relative to the notebook's root. - Name string - // Absolute path to the directory. - Path string - // User configuration for this directory. - Config GroupConfig -} - -// Open locates a notebook at the given path and parses its configuration. -func Open(originalPath string, parentConfig Config) (*Zk, error) { - wrap := errors.Wrapper("open failed") - - path, err := filepath.Abs(originalPath) - if err != nil { - return nil, wrap(err) - } - path, err = locateRoot(path) - if err != nil { - return nil, wrap(err) - } - - config, err := OpenConfig(filepath.Join(path, ".zk/config.toml"), parentConfig) - if err != nil { - return nil, wrap(err) - } - - return &Zk{ - Path: path, - Config: config, - workingDir: originalPath, - }, nil -} - -// Create initializes a new notebook at the given path. -func Create(path string) error { - wrap := errors.Wrapper("init failed") - - path, err := filepath.Abs(path) - if err != nil { - return wrap(err) - } - - if existingPath, err := locateRoot(path); err == nil { - return wrap(fmt.Errorf("a notebook already exists in %v", existingPath)) - } - - // Write default config.toml. - err = paths.WriteString(filepath.Join(path, ".zk/config.toml"), defaultConfig) - if err != nil { - return wrap(err) - } - - // Write default template. - err = paths.WriteString(filepath.Join(path, ".zk/templates/default.md"), defaultTemplate) - if err != nil { - return wrap(err) - } - - return nil -} - -// locate finds the root of the notebook containing the given path. -func locateRoot(path string) (string, error) { - if !filepath.IsAbs(path) { - panic("absolute path expected") - } - - var locate func(string) (string, error) - locate = func(currentPath string) (string, error) { - if currentPath == "/" || currentPath == "." { - return "", ErrNotebookNotFound(path) - } - exists, err := paths.DirExists(filepath.Join(currentPath, ".zk")) - switch { - case err != nil: - return "", err - case exists: - return currentPath, nil - default: - return locate(filepath.Dir(currentPath)) - } - } - - return locate(path) -} - -// DBPath returns the path to the notebook database. -func (zk *Zk) DBPath() string { - return filepath.Join(zk.Path, ".zk/notebook.db") -} - -// RelPath returns the path relative to the notebook root to the given path. -func (zk *Zk) RelPath(originalPath string) (string, error) { - wrap := errors.Wrapperf("%v: not a valid notebook path", originalPath) - - path, err := zk.absPath(originalPath) - if err != nil { - return path, wrap(err) - } - - path, err = filepath.Rel(zk.Path, path) - if err != nil { - return path, wrap(err) - } - if strings.HasPrefix(path, "..") { - return path, fmt.Errorf("%s: path is outside the notebook", originalPath) - } - if path == "." { - path = "" - } - return path, nil -} - -// AbsPath makes the given path absolute, using the current working directory -// as reference. -func (zk *Zk) absPath(originalPath string) (string, error) { - var err error - - path := originalPath - if !filepath.IsAbs(path) { - path = filepath.Join(zk.workingDir, path) - path, err = filepath.Abs(path) - if err != nil { - return path, err - } - } - - return path, nil -} - -// RootDir returns the root Dir for this notebook. -func (zk *Zk) RootDir() Dir { - return Dir{ - Name: "", - Path: zk.Path, - Config: zk.Config.RootGroupConfig(), - } -} - -// DirAt returns a Dir representation of the notebook directory at the given path. -func (zk *Zk) DirAt(path string, overrides ...ConfigOverrides) (*Dir, error) { - path, err := zk.absPath(path) - if err != nil { - return nil, errors.Wrapf(err, "%v: not a valid notebook directory", path) - } - - name, err := zk.RelPath(path) - if err != nil { - return nil, err - } - - config, err := zk.findConfigForDirNamed(name, overrides) - if err != nil { - return nil, err - } - - config = config.Clone() - for _, v := range overrides { - config.Override(v) - } - - return &Dir{ - Name: name, - Path: path, - Config: config, - }, nil -} - -func (zk *Zk) findConfigForDirNamed(name string, overrides []ConfigOverrides) (GroupConfig, error) { - // If there's a Group overrides, attempt to find a matching group. - overriddenGroup := "" - for _, o := range overrides { - if !o.Group.IsNull() { - overriddenGroup = o.Group.Unwrap() - if group, ok := zk.Config.Groups[overriddenGroup]; ok { - return group, nil - } - } - } - - if overriddenGroup != "" { - return GroupConfig{}, fmt.Errorf("%s: group not find in the config file", overriddenGroup) - } - - for groupName, group := range zk.Config.Groups { - for _, path := range group.Paths { - matches, err := filepath.Match(path, name) - if err != nil { - return GroupConfig{}, errors.Wrapf(err, "failed to match group %s to %s", groupName, name) - } else if matches { - return group, nil - } - } - } - // Fallback on root config. - return zk.Config.RootGroupConfig(), nil -} - -// RequiredDirAt is the same as DirAt, but checks that the directory exists -// before returning the Dir. -func (zk *Zk) RequireDirAt(path string, overrides ...ConfigOverrides) (*Dir, error) { - dir, err := zk.DirAt(path, overrides...) - if err != nil { - return nil, err - } - exists, err := paths.Exists(dir.Path) - if err != nil { - return nil, err - } - if !exists { - return nil, fmt.Errorf("%v: directory not found", path) - } - return dir, nil -} diff --git a/internal/core/style.go b/internal/core/style.go new file mode 100644 index 0000000..0b11786 --- /dev/null +++ b/internal/core/style.go @@ -0,0 +1,89 @@ +package core + +// Style is a key representing a single styling rule. +type Style string + +// Predefined styling rules. +var ( + // Title of a note. + StyleTitle = Style("title") + // Path to notebook file. + StylePath = Style("path") + // Searched for term in a note. + StyleTerm = Style("term") + // Element to emphasize, for example the short version of a prompt response: [y]es. + StyleEmphasis = Style("emphasis") + // Element to understate, for example the content of the note in fzf. + StyleUnderstate = Style("understate") + + StyleBold = Style("bold") + StyleItalic = Style("italic") + StyleFaint = Style("faint") + StyleUnderline = Style("underline") + StyleStrikethrough = Style("strikethrough") + StyleBlink = Style("blink") + StyleReverse = Style("reverse") + StyleHidden = Style("hidden") + + StyleBlack = Style("black") + StyleRed = Style("red") + StyleGreen = Style("green") + StyleYellow = Style("yellow") + StyleBlue = Style("blue") + StyleMagenta = Style("magenta") + StyleCyan = Style("cyan") + StyleWhite = Style("white") + + StyleBlackBg = Style("black-bg") + StyleRedBg = Style("red-bg") + StyleGreenBg = Style("green-bg") + StyleYellowBg = Style("yellow-bg") + StyleBlueBg = Style("blue-bg") + StyleMagentaBg = Style("magenta-bg") + StyleCyanBg = Style("cyan-bg") + StyleWhiteBg = Style("white-bg") + + StyleBrightBlack = Style("bright-black") + StyleBrightRed = Style("bright-red") + StyleBrightGreen = Style("bright-green") + StyleBrightYellow = Style("bright-yellow") + StyleBrightBlue = Style("bright-blue") + StyleBrightMagenta = Style("bright-magenta") + StyleBrightCyan = Style("bright-cyan") + StyleBrightWhite = Style("bright-white") + + StyleBrightBlackBg = Style("bright-black-bg") + StyleBrightRedBg = Style("bright-red-bg") + StyleBrightGreenBg = Style("bright-green-bg") + StyleBrightYellowBg = Style("bright-yellow-bg") + StyleBrightBlueBg = Style("bright-blue-bg") + StyleBrightMagentaBg = Style("bright-magenta-bg") + StyleBrightCyanBg = Style("bright-cyan-bg") + StyleBrightWhiteBg = Style("bright-white-bg") +) + +// Styler stylizes text according to predefined styling rules. +// +// A rule key can be either semantic, e.g. "title" or explicit, e.g. "red". +type Styler interface { + // Style formats the given text according to the provided styling rules. + Style(text string, rules ...Style) (string, error) + // Style formats the given text according to the provided styling rules, + // panicking if the rules are unknown. + MustStyle(text string, rules ...Style) string +} + +// NullStyler is a Styler with no styling rules. +var NullStyler = nullStyler{} + +type nullStyler struct{} + +// Style implements Styler. +func (s nullStyler) Style(text string, rule ...Style) (string, error) { + return text, nil +} + +// MustStyle implements Styler. +func (s nullStyler) MustStyle(text string, rule ...Style) string { + return text +} diff --git a/internal/core/style_test.go b/internal/core/style_test.go new file mode 100644 index 0000000..d7db8e0 --- /dev/null +++ b/internal/core/style_test.go @@ -0,0 +1,18 @@ +package core + +import "fmt" + +// stylerMock implements core.Styler by doing the transformation: +// "hello", "red" -> "red(hello)" +type stylerMock struct{} + +func (s *stylerMock) Style(text string, rules ...Style) (string, error) { + return s.MustStyle(text, rules...), nil +} + +func (s *stylerMock) MustStyle(text string, rules ...Style) string { + for _, rule := range rules { + text = fmt.Sprintf("%s(%s)", rule, text) + } + return text +} diff --git a/internal/core/template.go b/internal/core/template.go new file mode 100644 index 0000000..abc9537 --- /dev/null +++ b/internal/core/template.go @@ -0,0 +1,52 @@ +package core + +// Template produces a string using a given context. +type Template interface { + + // Styler used to format the templates content. + Styler() Styler + + // Render generates this template using the given variable context. + Render(context interface{}) (string, error) +} + +// TemplateFunc is an adapter to use a function as a Template. +type TemplateFunc func(context interface{}) (string, error) + +// Styler implements Template. +func (f TemplateFunc) Styler() Styler { + return NullStyler +} + +// Render implements Template. +func (f TemplateFunc) Render(context interface{}) (string, error) { + return f(context) +} + +// NullTemplate is a Template always returning an empty string. +var NullTemplate = nullTemplate{} + +type nullTemplate struct{} + +func (t nullTemplate) Styler() Styler { + return NullStyler +} + +func (t nullTemplate) Render(context interface{}) (string, error) { + return "", nil +} + +// TemplateLoader parses a string into a new Template instance. +type TemplateLoader interface { + // LoadTemplate creates a Template instance from a string template. + LoadTemplate(template string) (Template, error) + + // LoadTemplate creates a Template instance from a template stored in the + // file at the given path. + // The path may be relative to template directories registered to the loader. + LoadTemplateAt(path string) (Template, error) +} + +// TemplateLoaderFactory creates a new instance of an implementation of the +// TemplateLoader port. +type TemplateLoaderFactory func(language string) (TemplateLoader, error) diff --git a/internal/core/template_test.go b/internal/core/template_test.go new file mode 100644 index 0000000..ce6234e --- /dev/null +++ b/internal/core/template_test.go @@ -0,0 +1,83 @@ +package core + +// templateLoaderMock implements an in-memory TemplateLoader for testing. +type templateLoaderMock struct { + templates map[string]*templateSpy + fileTemplates map[string]*templateSpy + styler Styler +} + +func newTemplateLoaderMock() *templateLoaderMock { + return &templateLoaderMock{ + templates: map[string]*templateSpy{}, + fileTemplates: map[string]*templateSpy{}, + styler: &stylerMock{}, + } +} + +func (m *templateLoaderMock) Spy(template string, result func(context interface{}) string) *templateSpy { + spy := newTemplateSpy(result) + spy.styler = m.styler + m.templates[template] = spy + return spy +} + +func (m *templateLoaderMock) SpyString(content string) *templateSpy { + spy := newTemplateSpyString(content) + spy.styler = m.styler + m.templates[content] = spy + return spy +} + +func (m *templateLoaderMock) SpyFile(path string, content string) *templateSpy { + spy := newTemplateSpyString(content) + spy.styler = m.styler + m.fileTemplates[path] = spy + return spy +} + +func (l *templateLoaderMock) LoadTemplate(template string) (Template, error) { + tpl, ok := l.templates[template] + if !ok { + panic("no template spy for content: " + template) + } + return tpl, nil +} + +func (l *templateLoaderMock) LoadTemplateAt(path string) (Template, error) { + tpl, ok := l.fileTemplates[path] + if !ok { + panic("no template spy for path: " + path) + } + return tpl, nil +} + +// templateSpy implements Template and saves the provided render contexts. +type templateSpy struct { + Result func(interface{}) string + Contexts []interface{} + styler Styler +} + +func newTemplateSpy(result func(interface{}) string) *templateSpy { + return &templateSpy{ + Contexts: make([]interface{}, 0), + Result: result, + } +} + +func newTemplateSpyString(result string) *templateSpy { + return &templateSpy{ + Contexts: make([]interface{}, 0), + Result: func(_ interface{}) string { return result }, + } +} + +func (m *templateSpy) Styler() Styler { + return m.styler +} + +func (m *templateSpy) Render(context interface{}) (string, error) { + m.Contexts = append(m.Contexts, context) + return m.Result(context), nil +} diff --git a/util/date/date.go b/internal/util/date/date.go similarity index 100% rename from util/date/date.go rename to internal/util/date/date.go diff --git a/util/errors/errors.go b/internal/util/errors/errors.go similarity index 100% rename from util/errors/errors.go rename to internal/util/errors/errors.go diff --git a/util/exec/exec.go b/internal/util/exec/exec.go similarity index 100% rename from util/exec/exec.go rename to internal/util/exec/exec.go diff --git a/util/exec/exec_unix.go b/internal/util/exec/exec_unix.go similarity index 100% rename from util/exec/exec_unix.go rename to internal/util/exec/exec_unix.go diff --git a/util/exec/exec_windows.go b/internal/util/exec/exec_windows.go similarity index 100% rename from util/exec/exec_windows.go rename to internal/util/exec/exec_windows.go diff --git a/util/fixtures/fixtures.go b/internal/util/fixtures/fixtures.go similarity index 79% rename from util/fixtures/fixtures.go rename to internal/util/fixtures/fixtures.go index 0aae55b..61d6eba 100644 --- a/util/fixtures/fixtures.go +++ b/internal/util/fixtures/fixtures.go @@ -11,5 +11,5 @@ func Path(name string) string { if !ok { panic("failed to get the caller's path") } - return filepath.Join(filepath.Dir(callerPath), "fixtures", name) + return filepath.Join(filepath.Dir(callerPath), "testdata", name) } diff --git a/util/fts5/fts5.go b/internal/util/fts5/fts5.go similarity index 100% rename from util/fts5/fts5.go rename to internal/util/fts5/fts5.go diff --git a/util/fts5/fts5_test.go b/internal/util/fts5/fts5_test.go similarity index 97% rename from util/fts5/fts5_test.go rename to internal/util/fts5/fts5_test.go index ca3ba54..ce13590 100644 --- a/util/fts5/fts5_test.go +++ b/internal/util/fts5/fts5_test.go @@ -3,7 +3,7 @@ package fts5 import ( "testing" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func TestConvertQuery(t *testing.T) { diff --git a/util/icu/icu.go b/internal/util/icu/icu.go similarity index 100% rename from util/icu/icu.go rename to internal/util/icu/icu.go diff --git a/util/icu/icu_test.go b/internal/util/icu/icu_test.go similarity index 92% rename from util/icu/icu_test.go rename to internal/util/icu/icu_test.go index 5a70a06..685dcab 100644 --- a/util/icu/icu_test.go +++ b/internal/util/icu/icu_test.go @@ -3,7 +3,7 @@ package icu import ( "testing" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func TestEscapePAttern(t *testing.T) { diff --git a/util/logger.go b/internal/util/logger.go similarity index 62% rename from util/logger.go rename to internal/util/logger.go index 983310d..8f90812 100644 --- a/util/logger.go +++ b/internal/util/logger.go @@ -38,3 +38,25 @@ func (l StdLogger) Err(err error) { l.Printf("warning: %v", err) } } + +// ProxyLogger is a logger delegating to an underlying logger. +// Can be used to change the active logger during runtime. +type ProxyLogger struct { + Logger Logger +} + +func NewProxyLogger(logger Logger) *ProxyLogger { + return &ProxyLogger{logger} +} + +func (l *ProxyLogger) Printf(format string, v ...interface{}) { + l.Logger.Printf(format, v...) +} + +func (l *ProxyLogger) Println(v ...interface{}) { + l.Logger.Println(v...) +} + +func (l *ProxyLogger) Err(err error) { + l.Logger.Err(err) +} diff --git a/util/opt/opt.go b/internal/util/opt/opt.go similarity index 100% rename from util/opt/opt.go rename to internal/util/opt/opt.go diff --git a/util/os/os.go b/internal/util/os/os.go similarity index 94% rename from util/os/os.go rename to internal/util/os/os.go index fb9a0c5..75f5683 100644 --- a/util/os/os.go +++ b/internal/util/os/os.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "github.com/mickael-menu/zk/util/opt" + "github.com/mickael-menu/zk/internal/util/opt" ) // ReadStdinPipe returns the content of any piped input. diff --git a/util/pager/pager.go b/internal/util/pager/pager.go similarity index 91% rename from util/pager/pager.go rename to internal/util/pager/pager.go index 5c9dc13..c52ebf5 100644 --- a/util/pager/pager.go +++ b/internal/util/pager/pager.go @@ -9,11 +9,11 @@ import ( "sync" "github.com/kballard/go-shellquote" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/errors" - executil "github.com/mickael-menu/zk/util/exec" - "github.com/mickael-menu/zk/util/opt" - osutil "github.com/mickael-menu/zk/util/os" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/errors" + executil "github.com/mickael-menu/zk/internal/util/exec" + "github.com/mickael-menu/zk/internal/util/opt" + osutil "github.com/mickael-menu/zk/internal/util/os" ) // Pager writes text to a terminal using the user's pager. diff --git a/util/paths/diff.go b/internal/util/paths/diff.go similarity index 100% rename from util/paths/diff.go rename to internal/util/paths/diff.go diff --git a/util/paths/diff_test.go b/internal/util/paths/diff_test.go similarity index 98% rename from util/paths/diff_test.go rename to internal/util/paths/diff_test.go index d3f9b8d..0197eff 100644 --- a/util/paths/diff_test.go +++ b/internal/util/paths/diff_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/util/test/assert" ) var date1 = time.Date(2009, 11, 17, 20, 34, 58, 651387237, time.UTC) diff --git a/util/paths/paths.go b/internal/util/paths/paths.go similarity index 100% rename from util/paths/paths.go rename to internal/util/paths/paths.go diff --git a/util/paths/fixtures/walk/Dir3/a.md b/internal/util/paths/testdata/walk/Dir3/a.md similarity index 100% rename from util/paths/fixtures/walk/Dir3/a.md rename to internal/util/paths/testdata/walk/Dir3/a.md diff --git a/util/paths/fixtures/walk/a.md b/internal/util/paths/testdata/walk/a.md similarity index 100% rename from util/paths/fixtures/walk/a.md rename to internal/util/paths/testdata/walk/a.md diff --git a/util/paths/fixtures/walk/b.md b/internal/util/paths/testdata/walk/b.md similarity index 100% rename from util/paths/fixtures/walk/b.md rename to internal/util/paths/testdata/walk/b.md diff --git a/util/paths/fixtures/walk/dir1 a space/a.md b/internal/util/paths/testdata/walk/dir1 a space/a.md similarity index 100% rename from util/paths/fixtures/walk/dir1 a space/a.md rename to internal/util/paths/testdata/walk/dir1 a space/a.md diff --git a/util/paths/fixtures/walk/dir1/.ignored.md b/internal/util/paths/testdata/walk/dir1/.ignored.md similarity index 100% rename from util/paths/fixtures/walk/dir1/.ignored.md rename to internal/util/paths/testdata/walk/dir1/.ignored.md diff --git a/util/paths/fixtures/walk/dir1/.ignored/a.md b/internal/util/paths/testdata/walk/dir1/.ignored/a.md similarity index 100% rename from util/paths/fixtures/walk/dir1/.ignored/a.md rename to internal/util/paths/testdata/walk/dir1/.ignored/a.md diff --git a/util/paths/fixtures/walk/dir1/a.md b/internal/util/paths/testdata/walk/dir1/a.md similarity index 100% rename from util/paths/fixtures/walk/dir1/a.md rename to internal/util/paths/testdata/walk/dir1/a.md diff --git a/util/paths/fixtures/walk/dir1/b.md b/internal/util/paths/testdata/walk/dir1/b.md similarity index 100% rename from util/paths/fixtures/walk/dir1/b.md rename to internal/util/paths/testdata/walk/dir1/b.md diff --git a/util/paths/fixtures/walk/dir1/dir1/a.md b/internal/util/paths/testdata/walk/dir1/dir1/a.md similarity index 100% rename from util/paths/fixtures/walk/dir1/dir1/a.md rename to internal/util/paths/testdata/walk/dir1/dir1/a.md diff --git a/util/paths/fixtures/walk/dir1/ignored.txt b/internal/util/paths/testdata/walk/dir1/ignored.txt similarity index 100% rename from util/paths/fixtures/walk/dir1/ignored.txt rename to internal/util/paths/testdata/walk/dir1/ignored.txt diff --git a/util/paths/fixtures/walk/dir2/a.md b/internal/util/paths/testdata/walk/dir2/a.md similarity index 100% rename from util/paths/fixtures/walk/dir2/a.md rename to internal/util/paths/testdata/walk/dir2/a.md diff --git a/util/paths/walk.go b/internal/util/paths/walk.go similarity index 95% rename from util/paths/walk.go rename to internal/util/paths/walk.go index 51462c8..66e329a 100644 --- a/util/paths/walk.go +++ b/internal/util/paths/walk.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/mickael-menu/zk/util" + "github.com/mickael-menu/zk/internal/util" ) // Walk emits the metadata of each file stored in the directory with the given extension. diff --git a/util/paths/walk_test.go b/internal/util/paths/walk_test.go similarity index 73% rename from util/paths/walk_test.go rename to internal/util/paths/walk_test.go index c692021..cf0ef12 100644 --- a/util/paths/walk_test.go +++ b/internal/util/paths/walk_test.go @@ -3,9 +3,9 @@ package paths import ( "testing" - "github.com/mickael-menu/zk/util" - "github.com/mickael-menu/zk/util/fixtures" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/util" + "github.com/mickael-menu/zk/internal/util/fixtures" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func TestWalk(t *testing.T) { diff --git a/util/rand/rand.go b/internal/util/rand/rand.go similarity index 83% rename from util/rand/rand.go rename to internal/util/rand/rand.go index b209a40..5f3c2f6 100644 --- a/util/rand/rand.go +++ b/internal/util/rand/rand.go @@ -5,12 +5,12 @@ import ( "time" "unicode" - "github.com/mickael-menu/zk/core/zk" + "github.com/mickael-menu/zk/internal/core" ) // NewIDGenerator returns a function generating string IDs using the given options. // Inspired by https://www.calhoun.io/creating-random-strings-in-go/ -func NewIDGenerator(options zk.IDOptions) func() string { +func NewIDGenerator(options core.IDOptions) func() string { if options.Length < 1 { panic("IDOptions.Length must be at least 1") } @@ -18,11 +18,11 @@ func NewIDGenerator(options zk.IDOptions) func() string { var charset []rune for _, char := range options.Charset { switch options.Case { - case zk.CaseLower: + case core.CaseLower: charset = append(charset, unicode.ToLower(char)) - case zk.CaseUpper: + case core.CaseUpper: charset = append(charset, unicode.ToUpper(char)) - case zk.CaseMixed: + case core.CaseMixed: charset = append(charset, unicode.ToLower(char)) charset = append(charset, unicode.ToUpper(char)) default: diff --git a/util/strings/strings.go b/internal/util/strings/strings.go similarity index 100% rename from util/strings/strings.go rename to internal/util/strings/strings.go diff --git a/util/strings/strings_test.go b/internal/util/strings/strings_test.go similarity index 98% rename from util/strings/strings_test.go rename to internal/util/strings/strings_test.go index 3cb6c7a..ed0154e 100644 --- a/util/strings/strings_test.go +++ b/internal/util/strings/strings_test.go @@ -3,7 +3,7 @@ package strings import ( "testing" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/util/test/assert" ) func TestPrepend(t *testing.T) { diff --git a/util/test/assert/assert.go b/internal/util/test/assert/assert.go similarity index 100% rename from util/test/assert/assert.go rename to internal/util/test/assert/assert.go diff --git a/util/yaml/yaml.go b/internal/util/yaml/yaml.go similarity index 100% rename from util/yaml/yaml.go rename to internal/util/yaml/yaml.go diff --git a/util/yaml/yaml_test.go b/internal/util/yaml/yaml_test.go similarity index 95% rename from util/yaml/yaml_test.go rename to internal/util/yaml/yaml_test.go index 9bef505..2b875c0 100644 --- a/util/yaml/yaml_test.go +++ b/internal/util/yaml/yaml_test.go @@ -3,7 +3,7 @@ package yaml import ( "testing" - "github.com/mickael-menu/zk/util/test/assert" + "github.com/mickael-menu/zk/internal/util/test/assert" ) // Credit: https://github.com/icza/dyno diff --git a/main.go b/main.go index 1b2ccef..cc292b0 100644 --- a/main.go +++ b/main.go @@ -8,16 +8,16 @@ import ( "strings" "github.com/alecthomas/kong" - "github.com/mickael-menu/zk/adapter" - "github.com/mickael-menu/zk/cmd" - "github.com/mickael-menu/zk/core/style" - executil "github.com/mickael-menu/zk/util/exec" + "github.com/mickael-menu/zk/internal/cli" + "github.com/mickael-menu/zk/internal/cli/cmd" + "github.com/mickael-menu/zk/internal/core" + executil "github.com/mickael-menu/zk/internal/util/exec" ) var Version = "dev" var Build = "dev" -var cli struct { +var root struct { Init cmd.Init `cmd group:"zk" help:"Create a new notebook in the given directory."` Index cmd.Index `cmd group:"zk" help:"Index the notes to be searchable."` @@ -36,7 +36,7 @@ var cli struct { // NoInput is a flag preventing any user prompt when enabled. type NoInput bool -func (f NoInput) BeforeApply(container *adapter.Container) error { +func (f NoInput) BeforeApply(container *cli.Container) error { container.Terminal.NoInput = true return nil } @@ -44,8 +44,8 @@ func (f NoInput) BeforeApply(container *adapter.Container) error { // ShowHelp is the default command run. It's equivalent to `zk --help`. type ShowHelp struct{} -func (cmd *ShowHelp) Run(container *adapter.Container) error { - parser, err := kong.New(&cli, options(container)...) +func (cmd *ShowHelp) Run(container *cli.Container) error { + parser, err := kong.New(&root, options(container)...) if err != nil { return err } @@ -58,25 +58,35 @@ func (cmd *ShowHelp) Run(container *adapter.Container) error { func main() { // Create the dependency graph. - container, err := adapter.NewContainer(Version) + container, err := cli.NewContainer(Version) fatalIfError(err) // Open the notebook if there's any. searchPaths, err := notebookSearchPaths() fatalIfError(err) - container.OpenNotebook(searchPaths) + container.SetCurrentNotebook(searchPaths) // Run the alias or command. if isAlias, err := runAlias(container, os.Args[1:]); isAlias { fatalIfError(err) } else { - ctx := kong.Parse(&cli, options(container)...) + ctx := kong.Parse(&root, options(container)...) + + // Index the current notebook except if the user is running the `index` + // command, otherwise it would hide the stats. + if ctx.Command() != "index" { + if notebook, err := container.CurrentNotebook(); err == nil { + _, err = notebook.Index(false) + ctx.FatalIfErrorf(err) + } + } + err := ctx.Run(container) ctx.FatalIfErrorf(err) } } -func options(container *adapter.Container) []kong.Option { +func options(container *cli.Container) []kong.Option { term := container.Terminal return []kong.Option{ kong.Bind(container), @@ -93,8 +103,8 @@ func options(container *adapter.Container) []kong.Option { "filter": "Filtering", "sort": "Sorting", "format": "Formatting", - "notes": term.MustStyle("NOTES", style.RuleYellow, style.RuleBold) + "\n" + term.MustStyle("Edit or browse your notes", style.RuleBold), - "zk": term.MustStyle("NOTEBOOK", style.RuleYellow, style.RuleBold) + "\n" + term.MustStyle("A notebook is a directory containing a collection of notes", style.RuleBold), + "notes": term.MustStyle("NOTES", core.StyleYellow, core.StyleBold) + "\n" + term.MustStyle("Edit or browse your notes", core.StyleBold), + "zk": term.MustStyle("NOTEBOOK", core.StyleYellow, core.StyleBold) + "\n" + term.MustStyle("A notebook is a directory containing a collection of notes", core.StyleBold), }), } } @@ -107,7 +117,7 @@ func fatalIfError(err error) { } // runAlias will execute a user alias if the command is one of them. -func runAlias(container *adapter.Container, args []string) (bool, error) { +func runAlias(container *cli.Container, args []string) (bool, error) { if len(args) < 1 { return false, nil } @@ -121,9 +131,10 @@ func runAlias(container *adapter.Container, args []string) (bool, error) { // Prevent infinite loop if an alias calls itself. os.Setenv("ZK_RUNNING_ALIAS", alias) - // Move to the provided working directory if it is not the current one, - // before running the alias. - cmdStr = `cd "` + container.WorkingDir + `" && ` + cmdStr + // Move to the current notebook's root directory before running the alias. + if notebook, err := container.CurrentNotebook(); err == nil { + cmdStr = `cd "` + notebook.Path + `" && ` + cmdStr + } cmd := executil.CommandFromString(cmdStr, args[1:]...) cmd.Stdin = os.Stdin