mirror of https://github.com/mickael-menu/zk
Parse tags in Markdown documents (#5)
Many tag flavors are supported: `#hashtags`, `:colon:separated:tags:` and even Bear's [`#multi-word tags#`](https://blog.bear.app/2017/11/bear-tips-how-to-create-multi-word-tags/). If you prefer to use a YAML frontmatter, list your tags with the keys `tags` or `keywords`.pull/6/head
parent
a4b961c36a
commit
4bf660f934
@ -0,0 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
* Support for tags.
|
||||
* Many tag flavors are supported: `#hashtags`, `:colon:separated:tags:` and even Bear's [`#multi-word tags#`](https://blog.bear.app/2017/11/bear-tips-how-to-create-multi-word-tags/). If you prefer to use a YAML frontmatter, list your tags with the keys `tags` or `keywords`.
|
@ -0,0 +1,256 @@
|
||||
package extensions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Tags represents a list of inline tags in a Markdown document.
|
||||
type Tags struct {
|
||||
gast.BaseInline
|
||||
// Tags in this list.
|
||||
Tags []string
|
||||
}
|
||||
|
||||
func (n *Tags) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["Tags"] = strings.Join(n.Tags, ", ")
|
||||
gast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
// KindTags is a NodeKind of the Tags node.
|
||||
var KindTags = gast.NewNodeKind("Tags")
|
||||
|
||||
func (n *Tags) Kind() gast.NodeKind {
|
||||
return KindTags
|
||||
}
|
||||
|
||||
// TagExt is an extension parsing various flavors of tags.
|
||||
//
|
||||
// * #hashtags, including Bear's #multi words# tags
|
||||
// * :colon:separated:tags:`, e.g. vimwiki and Org mode
|
||||
//
|
||||
// Are authorized in a tag:
|
||||
// * unicode categories [L]etter and [N]umber
|
||||
// * / @ ' ~ - _ $ % & + = and when possible # :
|
||||
// * any character escaped with \, including whitespace
|
||||
type TagExt struct {
|
||||
// Indicates whether #hashtags are parsed.
|
||||
HashtagEnabled bool
|
||||
// Indicates whether Bear's multi-word tags are parsed. Hashtags must be enabled as well.
|
||||
MultiWordTagEnabled bool
|
||||
// Indicates whether :colon:tags: are parsed.
|
||||
ColontagEnabled bool
|
||||
}
|
||||
|
||||
func (t *TagExt) Extend(m goldmark.Markdown) {
|
||||
parsers := []util.PrioritizedValue{}
|
||||
|
||||
if t.HashtagEnabled {
|
||||
parsers = append(parsers, util.Prioritized(&hashtagParser{
|
||||
multiWordTagEnabled: t.MultiWordTagEnabled,
|
||||
}, 2000))
|
||||
}
|
||||
|
||||
if t.ColontagEnabled {
|
||||
parsers = append(parsers, util.Prioritized(&colontagParser{}, 2000))
|
||||
}
|
||||
|
||||
if len(parsers) > 0 {
|
||||
m.Parser().AddOptions(parser.WithInlineParsers(parsers...))
|
||||
}
|
||||
}
|
||||
|
||||
// hashtagParser parses #hashtags, including Bear's #multi words# tags
|
||||
type hashtagParser struct {
|
||||
multiWordTagEnabled bool
|
||||
}
|
||||
|
||||
func (p *hashtagParser) Trigger() []byte {
|
||||
return []byte{'#'}
|
||||
}
|
||||
|
||||
func (p *hashtagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
||||
previousChar := block.PrecendingCharacter()
|
||||
line, _ := block.PeekLine()
|
||||
|
||||
// A hashtag can't be directly preceded by a # or any other valid character.
|
||||
if isValidTagChar(previousChar, '\x00') {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
tag string // Accumulator for the hashtag
|
||||
multiWordTagCandidate string // Accumulator for a potential Bear multi-word tag
|
||||
)
|
||||
|
||||
var (
|
||||
escaping = false // Found a backslash, next character will be literal
|
||||
parsingMultiWordTag = false // Finished parsing a hashtag, now attempt parsing a Bear multi-word tag
|
||||
endPos = 0 // Last position of the tag in the line
|
||||
multiWordTagEndPos = 0 // Last position of the multi-word tag in the line
|
||||
)
|
||||
|
||||
appendChar := func(c rune) {
|
||||
if parsingMultiWordTag {
|
||||
multiWordTagCandidate += string(c)
|
||||
} else {
|
||||
tag += string(c)
|
||||
}
|
||||
}
|
||||
|
||||
for i, char := range string(line[1:]) {
|
||||
if parsingMultiWordTag {
|
||||
multiWordTagEndPos = i
|
||||
} else {
|
||||
endPos = i
|
||||
}
|
||||
|
||||
if escaping {
|
||||
// Currently escaping? The character will be appended literally.
|
||||
appendChar(char)
|
||||
escaping = false
|
||||
|
||||
} else if char == '\\' {
|
||||
// Found a backslash, next character will be escaped.
|
||||
escaping = true
|
||||
|
||||
} else if parsingMultiWordTag {
|
||||
// Parsing a multi-word tag candidate.
|
||||
if isValidTagChar(char, '#') || unicode.IsSpace(char) {
|
||||
appendChar(char)
|
||||
} else if char == '#' {
|
||||
// A valid multi-word tag must not have a space before the closing #.
|
||||
if !unicode.IsSpace(previousChar) {
|
||||
tag = multiWordTagCandidate
|
||||
endPos = multiWordTagEndPos
|
||||
}
|
||||
break
|
||||
}
|
||||
previousChar = char
|
||||
|
||||
} else if !p.multiWordTagEnabled && char == '#' {
|
||||
// A tag terminated with a # is invalid when not in a multi-word tag.
|
||||
return nil
|
||||
|
||||
} else if p.multiWordTagEnabled && unicode.IsSpace(char) {
|
||||
// Found a space, let's try to parse a multi-word tag.
|
||||
previousChar = char
|
||||
multiWordTagCandidate = tag
|
||||
parsingMultiWordTag = true
|
||||
appendChar(char)
|
||||
|
||||
} else if !isValidTagChar(char, '#') {
|
||||
// Found an invalid character, the hashtag is complete.
|
||||
break
|
||||
|
||||
} else {
|
||||
appendChar(char)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tag) == 0 || !isValidHashTag(tag) {
|
||||
return nil
|
||||
}
|
||||
|
||||
block.Advance(endPos)
|
||||
|
||||
return &Tags{
|
||||
BaseInline: gast.BaseInline{},
|
||||
Tags: []string{tag},
|
||||
}
|
||||
}
|
||||
|
||||
func isValidHashTag(tag string) bool {
|
||||
for _, char := range tag {
|
||||
if !unicode.IsNumber(char) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// colontagParser parses :colon:separated:tags:.
|
||||
type colontagParser struct{}
|
||||
|
||||
func (p *colontagParser) Trigger() []byte {
|
||||
return []byte{':'}
|
||||
}
|
||||
|
||||
func (p *colontagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
||||
previousChar := block.PrecendingCharacter()
|
||||
line, _ := block.PeekLine()
|
||||
|
||||
// A colontag can't be directly preceded by a : or any other valid character.
|
||||
if isValidTagChar(previousChar, '\x00') {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
tag string // Accumulator for the current colontag
|
||||
tags = []string{} // All colontags found
|
||||
)
|
||||
|
||||
var (
|
||||
escaping = false // Found a backslash, next character will be literal
|
||||
endPos = 0 // Last position of the colontags in the line
|
||||
)
|
||||
|
||||
appendChar := func(c rune) {
|
||||
tag += string(c)
|
||||
}
|
||||
|
||||
for i, char := range string(line[1:]) {
|
||||
endPos = i
|
||||
|
||||
if escaping {
|
||||
// Currently escaping? The character will be appended literally.
|
||||
appendChar(char)
|
||||
escaping = false
|
||||
|
||||
} else if char == '\\' {
|
||||
// Found a backslash, next character will be escaped.
|
||||
escaping = true
|
||||
|
||||
} else if char == ':' {
|
||||
if len(tag) == 0 {
|
||||
break
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
tag = ""
|
||||
|
||||
} else if !isValidTagChar(char, ':') {
|
||||
// Found an invalid character, the colontag is complete.
|
||||
break
|
||||
|
||||
} else {
|
||||
appendChar(char)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
block.Advance(endPos)
|
||||
|
||||
return &Tags{
|
||||
BaseInline: gast.BaseInline{},
|
||||
Tags: tags,
|
||||
}
|
||||
}
|
||||
|
||||
func isValidTagChar(r rune, excluded rune) bool {
|
||||
return r != excluded && (unicode.IsLetter(r) || unicode.IsNumber(r) ||
|
||||
r == '/' || r == '@' || r == '\'' || r == '~' ||
|
||||
r == '-' || r == '_' || r == '$' || r == '%' ||
|
||||
r == '&' || r == '+' || r == '=' || r == ':' ||
|
||||
r == '#')
|
||||
}
|
@ -0,0 +1,188 @@
|
||||
package sqlite
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// CollectionDAO persists collections (e.g. tags) in the SQLite database.
|
||||
type CollectionDAO struct {
|
||||
tx Transaction
|
||||
logger util.Logger
|
||||
|
||||
// Prepared SQL statements
|
||||
createCollectionStmt *LazyStmt
|
||||
findCollectionStmt *LazyStmt
|
||||
findAssociationStmt *LazyStmt
|
||||
createAssociationStmt *LazyStmt
|
||||
removeAssociationsStmt *LazyStmt
|
||||
}
|
||||
|
||||
// NewCollectionDAO creates a new instance of a DAO working on the given
|
||||
// database transaction.
|
||||
func NewCollectionDAO(tx Transaction, logger util.Logger) *CollectionDAO {
|
||||
return &CollectionDAO{
|
||||
tx: tx,
|
||||
logger: logger,
|
||||
|
||||
// Create a new collection.
|
||||
createCollectionStmt: tx.PrepareLazy(`
|
||||
INSERT INTO collections (kind, name)
|
||||
VALUES (?, ?)
|
||||
`),
|
||||
|
||||
// Finds a collection's ID from its kind and name.
|
||||
findCollectionStmt: tx.PrepareLazy(`
|
||||
SELECT id FROM collections
|
||||
WHERE kind = ? AND name = ?
|
||||
`),
|
||||
|
||||
// Returns whether a note and a collection are associated.
|
||||
findAssociationStmt: tx.PrepareLazy(`
|
||||
SELECT id FROM notes_collections
|
||||
WHERE note_id = ? AND collection_id = ?
|
||||
`),
|
||||
|
||||
// Creates a new association between a note and a collection.
|
||||
createAssociationStmt: tx.PrepareLazy(`
|
||||
INSERT INTO notes_collections (note_id, collection_id)
|
||||
VALUES (?, ?)
|
||||
`),
|
||||
|
||||
// Removes all associations for the given note.
|
||||
removeAssociationsStmt: tx.PrepareLazy(`
|
||||
DELETE FROM notes_collections
|
||||
WHERE note_id = ?
|
||||
`),
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
id, err := d.findCollection(kind, name)
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return id, err
|
||||
case id.IsValid():
|
||||
return id, nil
|
||||
default:
|
||||
return d.create(kind, name)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *CollectionDAO) findCollection(kind note.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)
|
||||
}
|
||||
|
||||
var id sql.NullInt64
|
||||
err = row.Scan(&id)
|
||||
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return core.CollectionId(0), nil
|
||||
case err != nil:
|
||||
return core.CollectionId(0), wrap(err)
|
||||
default:
|
||||
return core.CollectionId(id.Int64), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (d *CollectionDAO) create(kind note.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)
|
||||
if err != nil {
|
||||
return 0, wrap(err)
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, wrap(err)
|
||||
}
|
||||
|
||||
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) {
|
||||
wrap := errors.Wrapperf("failed to associate note %d to collection %d", noteId, collectionId)
|
||||
|
||||
id, err := d.findAssociation(noteId, collectionId)
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
return id, wrap(err)
|
||||
case id.IsValid():
|
||||
return id, nil
|
||||
default:
|
||||
id, err = d.createAssociation(noteId, collectionId)
|
||||
return id, wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
row, err := d.findAssociationStmt.QueryRow(noteId, collectionId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var id sql.NullInt64
|
||||
err = row.Scan(&id)
|
||||
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return 0, nil
|
||||
case err != nil:
|
||||
return 0, err
|
||||
default:
|
||||
return core.NoteCollectionId(id.Int64), nil
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
res, err := d.createAssociationStmt.Exec(noteId, collectionId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return core.NoteCollectionId(id), nil
|
||||
}
|
||||
|
||||
// RemoveAssociations deletes all associations with the given note.
|
||||
func (d *CollectionDAO) RemoveAssociations(noteId core.NoteId) error {
|
||||
if !noteId.IsValid() {
|
||||
return fmt.Errorf("Note ID (%d) not valid", noteId)
|
||||
}
|
||||
|
||||
_, err := d.removeAssociationsStmt.Exec(noteId)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to remove associations of note %d", noteId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mickael-menu/zk/core"
|
||||
"github.com/mickael-menu/zk/util"
|
||||
"github.com/mickael-menu/zk/util/test/assert"
|
||||
)
|
||||
|
||||
func TestCollectionDAOFindOrCreate(t *testing.T) {
|
||||
testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) {
|
||||
// Finds existing ones
|
||||
id, err := dao.FindOrCreate("tag", "adventure")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, id, core.CollectionId(2))
|
||||
id, err = dao.FindOrCreate("genre", "fiction")
|
||||
assert.Nil(t, err)
|
||||
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))
|
||||
|
||||
// Creates when not found
|
||||
sql := "SELECT id FROM collections WHERE kind = ? AND name = ?"
|
||||
assertNotExist(t, tx, sql, "unknown", "created")
|
||||
id, err = dao.FindOrCreate("unknown", "created")
|
||||
assert.Nil(t, err)
|
||||
assertExist(t, tx, sql, "unknown", "created")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCollectionDAOAssociate(t *testing.T) {
|
||||
testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) {
|
||||
// Returns existing association
|
||||
id, err := dao.Associate(1, 2)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, id, core.NoteCollectionId(2))
|
||||
|
||||
// Creates a new association if missing
|
||||
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)
|
||||
_, err = dao.Associate(noteId, collectionId)
|
||||
assert.Nil(t, err)
|
||||
assertExist(t, tx, sql, noteId, collectionId)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCollectionDAORemoveAssociations(t *testing.T) {
|
||||
testCollectionDAO(t, func(tx Transaction, dao *CollectionDAO) {
|
||||
noteId := core.NoteId(1)
|
||||
sql := "SELECT id FROM notes_collections WHERE note_id = ?"
|
||||
assertExist(t, tx, sql, noteId)
|
||||
err := dao.RemoveAssociations(noteId)
|
||||
assert.Nil(t, err)
|
||||
assertNotExist(t, tx, sql, noteId)
|
||||
|
||||
// Removes associations of note without any.
|
||||
err = dao.RemoveAssociations(999)
|
||||
assert.Nil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func testCollectionDAO(t *testing.T, callback func(tx Transaction, dao *CollectionDAO)) {
|
||||
testTransaction(t, func(tx Transaction) {
|
||||
callback(tx, NewCollectionDAO(tx, &util.NullLogger))
|
||||
})
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
- id: 1
|
||||
kind: "tag"
|
||||
name: "fiction"
|
||||
- id: 2
|
||||
kind: "tag"
|
||||
name: "adventure"
|
||||
- id: 3
|
||||
kind: "genre"
|
||||
name: "fiction"
|
||||
- id: 4
|
||||
kind: "tag"
|
||||
name: "fantasy"
|
@ -0,0 +1,9 @@
|
||||
- id: 1
|
||||
note_id: 1
|
||||
collection_id: 1
|
||||
- id: 2
|
||||
note_id: 1
|
||||
collection_id: 2
|
||||
- id: 3
|
||||
note_id: 2
|
||||
collection_id: 3
|
@ -0,0 +1,91 @@
|
||||
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)
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
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)
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
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
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
# {{title}}
|
||||
|
||||
{{content}}
|
||||
|
Loading…
Reference in New Issue