Refactoring

pull/71/head
Mickaël Menu 3 years ago
parent 20b195224b
commit 4b7a5a00fb
No known key found for this signature in database
GPG Key ID: 53D73664CD359895

@ -546,7 +546,7 @@ func (s *Server) executeCommandNew(context *glsp.Context, args []interface{}) (i
return nil, errors.Wrapf(err, "%s, failed to parse the `date` option", opts.Date)
}
path, err := notebook.NewNote(core.NewNoteOpts{
note, err := notebook.NewNote(core.NewNoteOpts{
Title: opt.NewNotEmptyString(opts.Title),
Content: opts.Content,
Directory: opt.NewNotEmptyString(opts.Dir),
@ -560,11 +560,16 @@ func (s *Server) executeCommandNew(context *glsp.Context, args []interface{}) (i
if !errors.As(err, &noteExists) {
return nil, err
}
path = noteExists.Path
note, err = notebook.FindNote(core.NoteFindOpts{
IncludePaths: []string{noteExists.Name},
})
if err != nil {
return nil, err
}
}
if note == nil {
return nil, errors.New("zk.new could not generate a new note")
}
// Index the notebook to be able to navigate to the new note.
notebook.Index(false)
if opts.InsertLinkAtLocation != nil {
doc, ok := s.documents.Get(opts.InsertLinkAtLocation.URI)
@ -576,12 +581,13 @@ func (s *Server) executeCommandNew(context *glsp.Context, args []interface{}) (i
return nil, err
}
relPath, err := filepath.Rel(filepath.Dir(doc.Path), path)
currentDir := filepath.Dir(doc.Path)
linkFormatterContext, err := core.NewLinkFormatterContext(note.AsMinimalNote(), notebook.Path, currentDir)
if err != nil {
return nil, err
}
link, err := linkFormatter(relPath, opts.Title)
link, err := linkFormatter(linkFormatterContext)
if err != nil {
return nil, err
}
@ -595,14 +601,15 @@ func (s *Server) executeCommandNew(context *glsp.Context, args []interface{}) (i
}, nil)
}
absPath := filepath.Join(notebook.Path, note.Path)
if opts.Edit {
go context.Call(protocol.ServerWindowShowDocument, protocol.ShowDocumentParams{
URI: "file://" + path,
URI: pathToURI(absPath),
TakeFocus: boolPtr(true),
}, nil)
}
return map[string]interface{}{"path": path}, nil
return map[string]interface{}{"path": absPath}, nil
}
func (s *Server) notebookOf(doc *document) (*core.Notebook, error) {
@ -621,6 +628,7 @@ func (s *Server) noteForLink(link documentLink, doc *document, notebook *core.No
// Try to find a partial href match.
note, err = notebook.FindByHref(link.Href, true)
if note == nil && err == nil {
// Fallback on matching the note title.
note, err = s.noteMatchingTitle(link.Href, notebook)
}
}
@ -843,14 +851,12 @@ func (s *Server) newCompletionItem(notebook *core.Notebook, note core.MinimalNot
}
func (s *Server) newTextEditForLink(notebook *core.Notebook, note core.MinimalNote, doc *document, pos protocol.Position, linkFormatter core.LinkFormatter) (interface{}, error) {
path := filepath.Join(notebook.Path, note.Path)
path = s.fs.Canonical(path)
path, err := filepath.Rel(filepath.Dir(doc.Path), path)
currentDir := filepath.Dir(doc.Path)
context, err := core.NewLinkFormatterContext(note, notebook.Path, currentDir)
if err != nil {
path = note.Path
return nil, err
}
link, err := linkFormatter(path, note.Title)
link, err := linkFormatter(context)
if err != nil {
return nil, err
}

@ -60,8 +60,8 @@ func NewParser(options ParserOpts) *Parser {
}
}
// Parse implements core.NoteParser.
func (p *Parser) Parse(content string) (*core.ParsedNote, error) {
// ParseNoteContent implements core.NoteContentParser.
func (p *Parser) ParseNoteContent(content string) (*core.NoteContent, error) {
bytes := []byte(content)
context := parser.NewContext()
@ -91,7 +91,7 @@ func (p *Parser) Parse(content string) (*core.ParsedNote, error) {
return nil, err
}
return &core.ParsedNote{
return &core.NoteContent{
Title: title,
Body: body,
Lead: parseLead(body),

@ -575,7 +575,7 @@ Paragraph
})
}
func parse(t *testing.T, source string) core.ParsedNote {
func parse(t *testing.T, source string) core.NoteContent {
return parseWithOptions(t, source, ParserOpts{
HashtagEnabled: true,
MultiWordTagEnabled: true,
@ -583,8 +583,8 @@ func parse(t *testing.T, source string) core.ParsedNote {
})
}
func parseWithOptions(t *testing.T, source string, options ParserOpts) core.ParsedNote {
content, err := NewParser(options).Parse(source)
func parseWithOptions(t *testing.T, source string, options ParserOpts) core.NoteContent {
content, err := NewParser(options).ParseNoteContent(source)
assert.Nil(t, err)
return *content
}

@ -345,21 +345,27 @@ func (d *NoteDAO) FindMinimal(opts core.NoteFindOpts) ([]core.MinimalNote, error
func (d *NoteDAO) scanMinimalNote(row RowScanner) (*core.MinimalNote, error) {
var (
id int
path, title string
id int
path, title, metadataJSON string
)
err := row.Scan(&id, &path, &title)
err := row.Scan(&id, &path, &title, &metadataJSON)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
return nil, err
default:
metadata, err := unmarshalMetadata(metadataJSON)
if err != nil {
d.logger.Err(errors.Wrap(err, path))
}
return &core.MinimalNote{
ID: core.NoteID(id),
Path: path,
Title: title,
ID: core.NoteID(id),
Path: path,
Title: title,
Metadata: metadata,
}, nil
}
}
@ -403,8 +409,8 @@ func (d *NoteDAO) scanNote(row RowScanner) (*core.ContextualNote, error) {
)
err := row.Scan(
&id, &path, &title, &lead, &body, &rawContent, &wordCount,
&created, &modified, &metadataJSON, &checksum, &tags, &snippets,
&id, &path, &title, &metadataJSON, &lead, &body, &rawContent,
&wordCount, &created, &modified, &checksum, &tags, &snippets,
)
switch {
case err == sql.ErrNoRows:
@ -777,9 +783,9 @@ WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
query += "\n)\n"
}
query += "SELECT n.id, n.path, n.title"
query += "SELECT n.id, n.path, n.title, n.metadata"
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 += fmt.Sprintf(", n.lead, n.body, n.raw_content, n.word_count, n.created, n.modified, n.checksum, n.tags, %s AS snippet", snippetCol)
}
query += "\nFROM notes_with_metadata n\n"

@ -397,13 +397,19 @@ func TestNoteDAOFindMinimalAll(t *testing.T) {
assert.Nil(t, err)
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"},
{ID: 5, Path: "ref/test/b.md", Title: "A nested note", Metadata: map[string]interface{}{}},
{ID: 4, Path: "f39c8.md", Title: "An interesting note", Metadata: map[string]interface{}{}},
{ID: 6, Path: "ref/test/a.md", Title: "Another nested note", Metadata: map[string]interface{}{
"alias": "a.md",
}},
{ID: 1, Path: "log/2021-01-03.md", Title: "Daily note", Metadata: map[string]interface{}{
"author": "Dom",
}},
{ID: 7, Path: "log/2021-02-04.md", Title: "February 4, 2021", Metadata: map[string]interface{}{}},
{ID: 3, Path: "index.md", Title: "Index", Metadata: map[string]interface{}{
"aliases": []interface{}{"First page"},
}},
{ID: 2, Path: "log/2021-01-04.md", Title: "January 4, 2021", Metadata: map[string]interface{}{}},
})
})
}
@ -418,9 +424,13 @@ func TestNoteDAOFindMinimalWithFilter(t *testing.T) {
assert.Nil(t, err)
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"},
{ID: 1, Path: "log/2021-01-03.md", Title: "Daily note", Metadata: map[string]interface{}{
"author": "Dom",
}},
{ID: 3, Path: "index.md", Title: "Index", Metadata: map[string]interface{}{
"aliases": []interface{}{"First page"},
}},
{ID: 7, Path: "log/2021-02-04.md", Title: "February 4, 2021", Metadata: map[string]interface{}{}},
})
})
}

@ -3,6 +3,7 @@ package cmd
import (
"errors"
"fmt"
"path/filepath"
"time"
"github.com/mickael-menu/zk/internal/cli"
@ -32,7 +33,7 @@ func (cmd *New) Run(container *cli.Container) error {
return err
}
path, err := notebook.NewNote(core.NewNoteOpts{
note, err := notebook.NewNote(core.NewNoteOpts{
Title: opt.NewNotEmptyString(cmd.Title),
Content: content.Unwrap(),
Directory: opt.NewNotEmptyString(cmd.Directory),
@ -41,19 +42,15 @@ func (cmd *New) Run(container *cli.Container) error {
Extra: cmd.Extra,
Date: time.Now(),
})
path := filepath.Join(notebook.Path, note.Path)
if err != nil {
var noteExists core.ErrNoteExists
if !errors.As(err, &noteExists) {
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),
fmt.Sprintf("%s already exists, do you want to edit this note instead?", note.Path),
true,
); !confirmed {
// abort...

@ -89,7 +89,7 @@ func NewContainer(version string) (*Container, error) {
notebook := core.NewNotebook(path, config, core.NotebookPorts{
NoteIndex: sqlite.NewNoteIndex(db, logger),
NoteParser: markdown.NewParser(markdown.ParserOpts{
NoteContentParser: markdown.NewParser(markdown.ParserOpts{
HashtagEnabled: config.Format.Markdown.Hashtags,
MultiWordTagEnabled: config.Format.Markdown.MultiwordTags,
ColontagEnabled: config.Format.Markdown.ColonTags,

@ -26,7 +26,7 @@ type LinkFormatterContext struct {
Metadata map[string]interface{}
}
func NewLinkFormatterContext(note Note, notebookDir string, currentDir string) (LinkFormatterContext, error) {
func NewLinkFormatterContext(note MinimalNote, notebookDir string, currentDir string) (LinkFormatterContext, error) {
absPath := filepath.Join(notebookDir, note.Path)
relPath, err := filepath.Rel(currentDir, absPath)
if err != nil {

@ -150,7 +150,7 @@ func TestCustomLinkFormatter(t *testing.T) {
RelPath: "../path/to note.md",
})
test("", "", LinkFormatterContext{
Filename: "",
Filename: ".",
Path: "",
AbsPath: "/",
RelPath: "../",

@ -12,6 +12,18 @@ func (id NoteID) IsValid() bool {
return id > 0
}
// 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
// JSON dictionary of raw metadata extracted from the frontmatter.
Metadata map[string]interface{}
}
// Note holds the metadata and content of a single note.
type Note struct {
// Unique ID of this note in a NoteRepository.
@ -42,6 +54,15 @@ type Note struct {
Checksum string
}
func (n Note) AsMinimalNote() MinimalNote {
return MinimalNote{
ID: n.ID,
Path: n.Path,
Title: n.Title,
Metadata: n.Metadata,
}
}
// ContextualNote holds a Note and context-sensitive content snippets.
//
// This is used for example:
@ -52,13 +73,3 @@ type ContextualNote struct {
// 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
}

@ -1,24 +1,18 @@
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
@ -30,7 +24,7 @@ type NoteIndex interface {
// Indexed returns the list of indexed note file metadata.
IndexedPaths() (<-chan paths.Metadata, error)
// Add indexes a new note from its metadata.
// Add indexes a new note.
Add(note Note) (NoteID, error)
// Update resets the metadata of an already indexed note.
Update(note Note) error
@ -75,11 +69,12 @@ func (s NoteIndexingStats) String() string {
// 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
path string
config Config
force bool
index NoteIndex
parser NoteParser
logger util.Logger
}
func (t *indexTask) execute(callback func(change paths.DiffChange)) (NoteIndexingStats, error) {
@ -96,7 +91,7 @@ func (t *indexTask) execute(callback func(change paths.DiffChange)) (NoteIndexin
force := t.force || needsReindexing
shouldIgnorePath := func(path string) (bool, error) {
group, err := t.notebook.Config.GroupConfigForPath(path)
group, err := t.config.GroupConfigForPath(path)
if err != nil {
return true, err
}
@ -118,7 +113,7 @@ func (t *indexTask) execute(callback func(change paths.DiffChange)) (NoteIndexin
return false, nil
}
source := paths.Walk(t.notebook.Path, t.logger, shouldIgnorePath)
source := paths.Walk(t.path, t.logger, shouldIgnorePath)
target, err := t.index.IndexedPaths()
if err != nil {
@ -128,21 +123,22 @@ func (t *indexTask) execute(callback func(change paths.DiffChange)) (NoteIndexin
// FIXME: Use the FS?
count, err := paths.Diff(source, target, force, func(change paths.DiffChange) error {
callback(change)
absPath := filepath.Join(change.Path)
switch change.Kind {
case paths.DiffAdded:
stats.AddedCount += 1
note, err := t.noteAt(change.Path)
if err == nil {
_, err = t.index.Add(note)
note, err := t.parser.ParseNoteAt(absPath)
if note != 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)
note, err := t.parser.ParseNoteAt(absPath)
if note != nil {
err = t.index.Update(*note)
}
t.logger.Err(err)
@ -163,81 +159,3 @@ func (t *indexTask) execute(callback func(change paths.DiffChange)) (NoteIndexin
return stats, wrap(err)
}
// noteAt parses a Note at the given path.
func (t *indexTask) noteAt(path string) (Note, error) {
wrap := errors.Wrapper(path)
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, wrap(err)
}
contentStr := string(content)
contentParts, err := t.parser.Parse(contentStr)
if err != nil {
return note, wrap(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 {
t.logger.Err(err)
continue
}
}
note.Links = append(note.Links, link)
}
times, err := times.Stat(absPath)
if err != nil {
return note, wrap(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()
}

@ -7,6 +7,7 @@ import (
"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"
)
@ -16,7 +17,7 @@ func TestNotebookNewNote(t *testing.T) {
}
test.setup()
path, err := test.run(NewNoteOpts{
note, err := test.run(NewNoteOpts{
Title: opt.NewString("Note title"),
Content: "Note content",
Extra: map[string]string{
@ -25,11 +26,12 @@ func TestNotebookNewNote(t *testing.T) {
Date: now,
})
assert.NotNil(t, note)
assert.Nil(t, err)
assert.Equal(t, path, "/notebook/filename.ext")
assert.Equal(t, note.Path, "filename.ext")
// Check created note.
assert.Equal(t, test.fs.files[path], "body")
assert.Equal(t, test.fs.files["/notebook/filename.ext"], "body")
assert.Equal(t, test.receivedLang, test.config.Note.Lang)
assert.Equal(t, test.receivedIDOpts, test.config.Note.IDOptions)
@ -105,17 +107,17 @@ func TestNotebookNewNoteInDir(t *testing.T) {
}
test.setup()
path, err := test.run(NewNoteOpts{
note, 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")
assert.Equal(t, note.Path, "a-dir/filename.ext")
// Check created note.
assert.Equal(t, test.fs.files[path], "body")
assert.Equal(t, test.fs.files["/notebook/a-dir/filename.ext"], "body")
// Check that the templates received the proper render contexts.
assert.Equal(t, test.filenameTemplate.Contexts, []interface{}{
@ -180,16 +182,15 @@ func TestNotebookNewNoteInDirWithGroup(t *testing.T) {
filenameTemplate := test.templateLoader.SpyString("group-filename.group-ext")
bodyTemplate := test.templateLoader.SpyFile("group-body", "group template body")
path, err := test.run(NewNoteOpts{
note, 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")
assert.Equal(t, note.Path, "a-dir/group-filename.group-ext")
// Check created note.
assert.Equal(t, test.fs.files[path], "group template body")
assert.Equal(t, test.fs.files["/notebook/a-dir/group-filename.group-ext"], "group template body")
assert.Equal(t, test.receivedLang, groupConfig.Note.Lang)
assert.Equal(t, test.receivedIDOpts, groupConfig.Note.IDOptions)
@ -255,16 +256,16 @@ func TestNotebookNewNoteWithGroup(t *testing.T) {
filenameTemplate := test.templateLoader.SpyString("group-filename.group-ext")
bodyTemplate := test.templateLoader.SpyFile("group-body", "group template body")
path, err := test.run(NewNoteOpts{
note, err := test.run(NewNoteOpts{
Group: opt.NewString("group-a"),
Date: now,
})
assert.Nil(t, err)
assert.Equal(t, path, "/notebook/group-filename.group-ext")
assert.Equal(t, note.Path, "group-filename.group-ext")
// Check created note.
assert.Equal(t, test.fs.files[path], "group template body")
assert.Equal(t, test.fs.files["/notebook/group-filename.group-ext"], "group template body")
assert.Equal(t, test.receivedLang, groupConfig.Note.Lang)
assert.Equal(t, test.receivedIDOpts, groupConfig.Note.IDOptions)
@ -319,13 +320,13 @@ func TestNotebookNewNoteWithCustomTemplate(t *testing.T) {
test.setup()
test.templateLoader.SpyFile("custom-body", "custom body template")
path, err := test.run(NewNoteOpts{
note, 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")
assert.Equal(t, test.fs.files["/notebook/"+note.Path], "custom body template")
}
// Tries to generate a filename until one is free.
@ -344,15 +345,15 @@ func TestNotebookNewNoteTriesUntilFreePath(t *testing.T) {
}
test.setup()
path, err := test.run(NewNoteOpts{
note, err := test.run(NewNoteOpts{
Date: now,
})
assert.Nil(t, err)
assert.Equal(t, path, "/notebook/filename4.ext")
assert.Equal(t, note.Path, "filename4.ext")
// Check created note.
assert.Equal(t, test.fs.files[path], "body")
assert.Equal(t, test.fs.files["/notebook/filename4.ext"], "body")
}
func TestNotebookNewNoteErrorWhenNoFreePath(t *testing.T) {
@ -386,6 +387,8 @@ type newNoteTest struct {
files map[string]string
dirs []string
fs *fileStorageMock
index *noteIndexAddMock
parser *noteContentParserMock
config Config
groups map[string]GroupConfig
templateLoader *templateLoaderMock
@ -412,6 +415,9 @@ func (t *newNoteTest) setup() {
t.fs.files = t.files
}
t.index = &noteIndexAddMock{ReturnedID: 42}
t.parser = newNoteContentParserMock(map[string]*NoteContent{})
t.templateLoader = newTemplateLoaderMock()
if t.filenameTemplateRender != nil {
t.filenameTemplate = t.templateLoader.Spy("filename.ext", func(context interface{}) string {
@ -459,7 +465,11 @@ func (t *newNoteTest) setup() {
}
}
func (t *newNoteTest) run(opts NewNoteOpts) (string, error) {
func (t *newNoteTest) parseContentAsNote(content string, note *NoteContent) {
t.parser.results[content] = note
}
func (t *newNoteTest) run(opts NewNoteOpts) (*Note, error) {
notebook := NewNotebook(t.rootDir, t.config, NotebookPorts{
TemplateLoaderFactory: func(language string) (TemplateLoader, error) {
t.receivedLang = language
@ -469,9 +479,11 @@ func (t *newNoteTest) run(opts NewNoteOpts) (string, error) {
t.receivedIDOpts = opts
return t.idGeneratorFactory(opts)
},
FS: t.fs,
Logger: &util.NullLogger,
OSEnv: func() map[string]string { return t.osEnv },
FS: t.fs,
NoteIndex: t.index,
NoteContentParser: t.parser,
Logger: &util.NullLogger,
OSEnv: func() map[string]string { return t.osEnv },
})
return notebook.NewNote(opts)
@ -485,3 +497,20 @@ func incrementingID(opts IDOptions) func() string {
return fmt.Sprintf("%d", i)
}
}
type noteIndexAddMock struct {
ReturnedID NoteID
}
func (m *noteIndexAddMock) Find(opts NoteFindOpts) ([]ContextualNote, error) { return nil, nil }
func (m *noteIndexAddMock) FindMinimal(opts NoteFindOpts) ([]MinimalNote, error) { return nil, nil }
func (m *noteIndexAddMock) FindCollections(kind CollectionKind) ([]Collection, error) {
return nil, nil
}
func (m *noteIndexAddMock) IndexedPaths() (<-chan paths.Metadata, error) { return nil, nil }
func (m *noteIndexAddMock) Add(note Note) (NoteID, error) { return m.ReturnedID, nil }
func (m *noteIndexAddMock) Update(note Note) error { return nil }
func (m *noteIndexAddMock) Remove(path string) error { return nil }
func (m *noteIndexAddMock) Commit(transaction func(idx NoteIndex) error) error { return nil }
func (m *noteIndexAddMock) NeedsReindexing() (bool, error) { return false, nil }
func (m *noteIndexAddMock) SetNeedsReindexing(needsReindexing bool) error { return nil }

@ -1,16 +1,31 @@
package core
import (
"crypto/sha256"
"fmt"
"path/filepath"
"strings"
"time"
"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/relvacode/iso8601"
"gopkg.in/djherbis/times.v1"
)
// NoteParser parses a note's raw content into its components.
// NoteParser parses a note on the file system into a Note model.
type NoteParser interface {
Parse(content string) (*ParsedNote, error)
ParseNoteAt(absPath string) (*Note, error)
}
// NoteContentParser parses a note's raw content into its components.
type NoteContentParser interface {
ParseNoteContent(content string) (*NoteContent, error)
}
// ParsedNote holds the data parsed from the note content.
type ParsedNote struct {
// NoteContent holds the data parsed from the note content.
type NoteContent struct {
// Title is the heading of the note.
Title opt.String
// Lead is the opening paragraph or section of the note.
@ -24,3 +39,83 @@ type ParsedNote struct {
// Additional metadata. For example, extracted from a YAML frontmatter.
Metadata map[string]interface{}
}
// ParseNoteAt implements NoteParser.
func (n *Notebook) ParseNoteAt(absPath string) (*Note, error) {
wrap := errors.Wrapper(absPath)
relPath, err := n.RelPath(absPath)
if err != nil {
return nil, wrap(err)
}
content, err := n.fs.Read(absPath)
if err != nil {
return nil, wrap(err)
}
contentStr := string(content)
contentParts, err := n.parser.ParseNoteContent(contentStr)
if err != nil {
return nil, wrap(err)
}
note := Note{
Path: relPath,
Title: contentParts.Title.String(),
Lead: contentParts.Lead.String(),
Body: contentParts.Body.String(),
RawContent: contentStr,
WordCount: len(strings.Fields(contentStr)),
Links: make([]Link, 0),
Tags: contentParts.Tags,
Metadata: contentParts.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 = n.RelPath(href)
if err != nil {
n.logger.Err(err)
continue
}
}
note.Links = append(note.Links, link)
}
times, err := times.Stat(absPath)
if err != nil {
n.logger.Err(err)
} else {
note.Modified = times.ModTime().UTC()
note.Created = creationDateFrom(note.Metadata, times)
}
return &note, 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()
}

@ -0,0 +1,18 @@
package core
type noteContentParserMock struct {
results map[string]*NoteContent
}
func newNoteContentParserMock(results map[string]*NoteContent) *noteContentParserMock {
return &noteContentParserMock{
results: results,
}
}
func (p *noteContentParserMock) ParseNoteContent(content string) (*NoteContent, error) {
if note, ok := p.results[content]; ok {
return note, nil
}
return &NoteContent{}, nil
}

@ -21,7 +21,7 @@ type Notebook struct {
Config Config
index NoteIndex
parser NoteParser
parser NoteContentParser
templateLoaderFactory TemplateLoaderFactory
idGeneratorFactory IDGeneratorFactory
fs FileStorage
@ -39,7 +39,7 @@ func NewNotebook(
Path: path,
Config: config,
index: ports.NoteIndex,
parser: ports.NoteParser,
parser: ports.NoteContentParser,
templateLoaderFactory: ports.TemplateLoaderFactory,
idGeneratorFactory: ports.IDGeneratorFactory,
fs: ports.FS,
@ -50,7 +50,7 @@ func NewNotebook(
type NotebookPorts struct {
NoteIndex NoteIndex
NoteParser NoteParser
NoteContentParser NoteContentParser
TemplateLoaderFactory TemplateLoaderFactory
IDGeneratorFactory IDGeneratorFactory
FS FileStorage
@ -73,11 +73,12 @@ func (n *Notebook) Index(force bool) (stats NoteIndexingStats, err error) {
err = n.index.Commit(func(index NoteIndex) error {
task := indexTask{
notebook: n,
force: force,
index: index,
parser: n.parser,
logger: n.logger,
path: n.Path,
config: n.Config,
force: force,
index: index,
parser: n,
logger: n.logger,
}
stats, err = task.execute(func(change paths.DiffChange) {
bar.Add(1)
@ -120,20 +121,20 @@ 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.
// NewNote generates a new note in the notebook, index and returns it.
//
// Returns ErrNoteExists if no free filename can be generated for this note.
func (n *Notebook) NewNote(opts NewNoteOpts) (string, error) {
func (n *Notebook) NewNote(opts NewNoteOpts) (*Note, error) {
wrap := errors.Wrapper("new note")
dir, err := n.RequireDirAt(opts.Directory.OrString(n.Path).Unwrap())
if err != nil {
return "", wrap(err)
return nil, wrap(err)
}
config, err := n.Config.GroupConfigNamed(opts.Group.OrString(dir.Group).Unwrap())
if err != nil {
return "", wrap(err)
return nil, wrap(err)
}
extra := config.Extra
@ -143,7 +144,7 @@ func (n *Notebook) NewNote(opts NewNoteOpts) (string, error) {
templates, err := n.templateLoaderFactory(config.Note.Lang)
if err != nil {
return "", wrap(err)
return nil, wrap(err)
}
task := newNoteTask{
@ -160,7 +161,22 @@ func (n *Notebook) NewNote(opts NewNoteOpts) (string, error) {
genID: n.idGeneratorFactory(config.Note.IDOptions),
}
path, err := task.execute()
return path, wrap(err)
if err != nil {
return nil, wrap(err)
}
note, err := n.ParseNoteAt(path)
if note == nil || err != nil {
return nil, wrap(err)
}
id, err := n.index.Add(*note)
if err != nil {
return nil, wrap(err)
}
note.ID = id
return note, nil
}
// FindNotes retrieves the notes matching the given filtering options.
@ -168,12 +184,41 @@ func (n *Notebook) FindNotes(opts NoteFindOpts) ([]ContextualNote, error) {
return n.index.Find(opts)
}
// FindNote retrieves the first note matching the given filtering options.
func (n *Notebook) FindNote(opts NoteFindOpts) (*Note, error) {
opts.Limit = 1
notes, err := n.FindNotes(opts)
switch {
case err != nil:
return nil, err
case len(notes) == 0:
return nil, nil
default:
return &notes[0].Note, nil
}
}
// 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)
}
// FindMinimalNotes retrieves lightweight metadata for the first note matching
// the given filtering options.
func (n *Notebook) FindMinimalNote(opts NoteFindOpts) (*MinimalNote, error) {
opts.Limit = 1
notes, err := n.FindMinimalNotes(opts)
switch {
case err != nil:
return nil, err
case len(notes) == 0:
return nil, nil
default:
return &notes[0], nil
}
}
// FindByHref retrieves the first note matching the given link href.
// If allowPartialMatch is true, the href can match any unique sub portion of a note path.
func (n *Notebook) FindByHref(href string, allowPartialMatch bool) (*MinimalNote, error) {
@ -185,40 +230,20 @@ func (n *Notebook) FindByHref(href string, allowPartialMatch bool) (*MinimalNote
href = "(.*)" + icu.EscapePattern(href) + "(.*)"
}
notes, err := n.FindMinimalNotes(NoteFindOpts{
return n.FindMinimalNote(NoteFindOpts{
IncludePaths: []string{href},
EnablePathRegexes: allowPartialMatch,
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 &notes[0], nil
}
}
// FindMatching retrieves the first note matching the given search terms.
func (n *Notebook) FindMatching(terms string) (*MinimalNote, error) {
notes, err := n.FindMinimalNotes(NoteFindOpts{
return n.FindMinimalNote(NoteFindOpts{
Match: opt.NewNotEmptyString(terms),
Limit: 1,
})
switch {
case err != nil:
return nil, err
case len(notes) == 0:
return nil, nil
default:
return &notes[0], nil
}
}
// FindCollections retrieves all the collections of the given kind.

Loading…
Cancel
Save