2021-01-03 20:19:21 +00:00
|
|
|
package sqlite
|
|
|
|
|
|
|
|
import (
|
|
|
|
"database/sql"
|
2021-03-08 20:38:32 +00:00
|
|
|
"encoding/json"
|
2021-01-11 20:51:50 +00:00
|
|
|
"fmt"
|
2021-03-07 16:00:09 +00:00
|
|
|
"regexp"
|
2021-03-06 18:38:52 +00:00
|
|
|
"strconv"
|
2021-01-11 20:51:50 +00:00
|
|
|
"strings"
|
2021-01-03 20:19:21 +00:00
|
|
|
"time"
|
|
|
|
|
2021-03-06 18:38:52 +00:00
|
|
|
"github.com/mickael-menu/zk/core"
|
2021-01-03 20:19:21 +00:00
|
|
|
"github.com/mickael-menu/zk/core/note"
|
|
|
|
"github.com/mickael-menu/zk/util"
|
2021-01-05 19:50:39 +00:00
|
|
|
"github.com/mickael-menu/zk/util/errors"
|
2021-01-11 19:19:39 +00:00
|
|
|
"github.com/mickael-menu/zk/util/fts5"
|
2021-02-27 18:23:03 +00:00
|
|
|
"github.com/mickael-menu/zk/util/icu"
|
2021-03-13 14:31:05 +00:00
|
|
|
"github.com/mickael-menu/zk/util/opt"
|
2021-01-09 12:01:41 +00:00
|
|
|
"github.com/mickael-menu/zk/util/paths"
|
2021-01-25 21:57:12 +00:00
|
|
|
strutil "github.com/mickael-menu/zk/util/strings"
|
2021-01-03 20:19:21 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
// NoteDAO persists notes in the SQLite database.
|
2021-03-06 18:38:52 +00:00
|
|
|
// It implements the core port note.Finder.
|
2021-01-03 20:19:21 +00:00
|
|
|
type NoteDAO struct {
|
|
|
|
tx Transaction
|
|
|
|
logger util.Logger
|
|
|
|
|
|
|
|
// Prepared SQL statements
|
2021-01-25 20:44:44 +00:00
|
|
|
indexedStmt *LazyStmt
|
|
|
|
addStmt *LazyStmt
|
|
|
|
updateStmt *LazyStmt
|
|
|
|
removeStmt *LazyStmt
|
|
|
|
findIdByPathStmt *LazyStmt
|
|
|
|
findIdByPathPrefixStmt *LazyStmt
|
|
|
|
addLinkStmt *LazyStmt
|
|
|
|
setLinksTargetStmt *LazyStmt
|
|
|
|
removeLinksStmt *LazyStmt
|
2021-01-03 20:19:21 +00:00
|
|
|
}
|
|
|
|
|
2021-01-25 20:44:44 +00:00
|
|
|
// NewNoteDAO creates a new instance of a DAO working on the given database
|
|
|
|
// transaction.
|
2021-01-09 11:17:15 +00:00
|
|
|
func NewNoteDAO(tx Transaction, logger util.Logger) *NoteDAO {
|
2021-01-03 20:19:21 +00:00
|
|
|
return &NoteDAO{
|
2021-01-03 20:49:11 +00:00
|
|
|
tx: tx,
|
|
|
|
logger: logger,
|
2021-01-25 20:44:44 +00:00
|
|
|
|
|
|
|
// Get file info about all indexed notes.
|
2021-01-03 20:49:11 +00:00
|
|
|
indexedStmt: tx.PrepareLazy(`
|
2021-01-09 11:17:15 +00:00
|
|
|
SELECT path, modified from notes
|
2021-01-17 16:37:16 +00:00
|
|
|
ORDER BY sortable_path ASC
|
2021-01-03 20:49:11 +00:00
|
|
|
`),
|
2021-01-25 20:44:44 +00:00
|
|
|
|
|
|
|
// Add a new note to the index.
|
2021-01-03 20:49:11 +00:00
|
|
|
addStmt: tx.PrepareLazy(`
|
2021-03-08 20:38:32 +00:00
|
|
|
INSERT INTO notes (path, sortable_path, title, lead, body, raw_content, word_count, metadata, checksum, created, modified)
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
2021-01-03 20:49:11 +00:00
|
|
|
`),
|
2021-01-25 20:44:44 +00:00
|
|
|
|
|
|
|
// Update the content of a note.
|
2021-01-03 20:49:11 +00:00
|
|
|
updateStmt: tx.PrepareLazy(`
|
|
|
|
UPDATE notes
|
2021-03-08 20:38:32 +00:00
|
|
|
SET title = ?, lead = ?, body = ?, raw_content = ?, word_count = ?, metadata = ?, checksum = ?, modified = ?
|
2021-01-09 11:17:15 +00:00
|
|
|
WHERE path = ?
|
2021-01-03 20:49:11 +00:00
|
|
|
`),
|
2021-01-25 20:44:44 +00:00
|
|
|
|
|
|
|
// Remove a note.
|
2021-01-03 20:49:11 +00:00
|
|
|
removeStmt: tx.PrepareLazy(`
|
|
|
|
DELETE FROM notes
|
2021-01-25 20:44:44 +00:00
|
|
|
WHERE id = ?
|
|
|
|
`),
|
|
|
|
|
|
|
|
// Find a note ID from its exact path.
|
|
|
|
findIdByPathStmt: tx.PrepareLazy(`
|
|
|
|
SELECT id FROM notes
|
2021-01-09 11:17:15 +00:00
|
|
|
WHERE path = ?
|
2021-01-03 20:49:11 +00:00
|
|
|
`),
|
2021-01-25 20:44:44 +00:00
|
|
|
|
|
|
|
// Find a note ID from a prefix of its path.
|
|
|
|
findIdByPathPrefixStmt: tx.PrepareLazy(`
|
|
|
|
SELECT id FROM notes
|
|
|
|
WHERE path LIKE ? || '%'
|
|
|
|
`),
|
|
|
|
|
|
|
|
// Add a new link.
|
|
|
|
addLinkStmt: tx.PrepareLazy(`
|
2021-03-08 20:38:32 +00:00
|
|
|
INSERT INTO links (source_id, target_id, title, href, external, rels, snippet, snippet_start, snippet_end)
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
2021-01-25 20:44:44 +00:00
|
|
|
`),
|
|
|
|
|
|
|
|
// Set links matching a given href and missing a target ID to the given
|
|
|
|
// target ID.
|
|
|
|
setLinksTargetStmt: tx.PrepareLazy(`
|
|
|
|
UPDATE links
|
|
|
|
SET target_id = ?
|
|
|
|
WHERE target_id IS NULL AND external = 0 AND ? LIKE href || '%'
|
|
|
|
`),
|
|
|
|
|
|
|
|
// Remove all the outbound links of a note.
|
|
|
|
removeLinksStmt: tx.PrepareLazy(`
|
|
|
|
DELETE FROM links
|
|
|
|
WHERE source_id = ?
|
2021-01-05 19:50:39 +00:00
|
|
|
`),
|
2021-01-03 20:49:11 +00:00
|
|
|
}
|
2021-01-03 20:19:21 +00:00
|
|
|
}
|
|
|
|
|
2021-01-25 20:44:44 +00:00
|
|
|
// Indexed returns file info of all indexed notes.
|
2021-01-09 12:01:41 +00:00
|
|
|
func (d *NoteDAO) Indexed() (<-chan paths.Metadata, error) {
|
2021-01-03 20:19:21 +00:00
|
|
|
rows, err := d.indexedStmt.Query()
|
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
return nil, err
|
2021-01-03 20:19:21 +00:00
|
|
|
}
|
|
|
|
|
2021-01-09 12:01:41 +00:00
|
|
|
c := make(chan paths.Metadata)
|
2021-01-03 20:19:21 +00:00
|
|
|
go func() {
|
|
|
|
defer close(c)
|
|
|
|
defer rows.Close()
|
|
|
|
var (
|
2021-01-09 11:17:15 +00:00
|
|
|
path string
|
|
|
|
modified time.Time
|
2021-01-03 20:19:21 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
for rows.Next() {
|
2021-01-09 11:17:15 +00:00
|
|
|
err := rows.Scan(&path, &modified)
|
2021-01-03 20:19:21 +00:00
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
d.logger.Err(err)
|
2021-01-03 20:19:21 +00:00
|
|
|
}
|
|
|
|
|
2021-01-09 12:01:41 +00:00
|
|
|
c <- paths.Metadata{
|
2021-01-09 11:17:15 +00:00
|
|
|
Path: path,
|
2021-01-03 20:19:21 +00:00
|
|
|
Modified: modified,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
err = rows.Err()
|
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
d.logger.Err(err)
|
2021-01-03 20:19:21 +00:00
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
return c, nil
|
|
|
|
}
|
|
|
|
|
2021-01-25 20:44:44 +00:00
|
|
|
// Add inserts a new note to the index.
|
2021-03-06 18:38:52 +00:00
|
|
|
func (d *NoteDAO) Add(note note.Metadata) (core.NoteId, error) {
|
2021-01-17 16:37:16 +00:00
|
|
|
// 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
|
|
|
|
// returned by filepath.Walk.
|
|
|
|
// \x01 is used instead of \x00, because SQLite treats \x00 as and end of
|
|
|
|
// string.
|
|
|
|
sortablePath := strings.ReplaceAll(note.Path, "/", "\x01")
|
|
|
|
|
2021-03-08 20:38:32 +00:00
|
|
|
metadata, err := d.metadataToJson(note)
|
|
|
|
if err != nil {
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
|
2021-01-25 20:44:44 +00:00
|
|
|
res, err := d.addStmt.Exec(
|
2021-03-08 20:38:32 +00:00
|
|
|
note.Path, sortablePath, note.Title, note.Lead, note.Body,
|
|
|
|
note.RawContent, note.WordCount, metadata, note.Checksum, note.Created,
|
|
|
|
note.Modified,
|
2021-01-03 20:19:21 +00:00
|
|
|
)
|
2021-01-25 20:44:44 +00:00
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
return 0, err
|
2021-01-25 20:44:44 +00:00
|
|
|
}
|
|
|
|
|
2021-03-06 18:38:52 +00:00
|
|
|
lastId, err := res.LastInsertId()
|
2021-01-25 20:44:44 +00:00
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
return core.NoteId(0), err
|
2021-01-25 20:44:44 +00:00
|
|
|
}
|
|
|
|
|
2021-03-06 18:38:52 +00:00
|
|
|
id := core.NoteId(lastId)
|
2021-01-25 20:44:44 +00:00
|
|
|
err = d.addLinks(id, note)
|
|
|
|
return id, err
|
2021-01-03 20:19:21 +00:00
|
|
|
}
|
|
|
|
|
2021-01-25 20:44:44 +00:00
|
|
|
// Update modifies an existing note.
|
2021-03-06 18:38:52 +00:00
|
|
|
func (d *NoteDAO) Update(note note.Metadata) (core.NoteId, error) {
|
2021-01-25 20:44:44 +00:00
|
|
|
id, err := d.findIdByPath(note.Path)
|
2021-01-05 19:50:39 +00:00
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
return 0, err
|
2021-01-05 19:50:39 +00:00
|
|
|
}
|
2021-03-06 18:38:52 +00:00
|
|
|
if !id.IsValid() {
|
|
|
|
return 0, errors.New("note not found in the index")
|
2021-01-05 19:50:39 +00:00
|
|
|
}
|
|
|
|
|
2021-03-08 20:38:32 +00:00
|
|
|
metadata, err := d.metadataToJson(note)
|
|
|
|
if err != nil {
|
|
|
|
return 0, err
|
|
|
|
}
|
|
|
|
|
2021-01-05 19:50:39 +00:00
|
|
|
_, err = d.updateStmt.Exec(
|
2021-03-08 20:38:32 +00:00
|
|
|
note.Title, note.Lead, note.Body, note.RawContent, note.WordCount,
|
|
|
|
metadata, note.Checksum, note.Modified, note.Path,
|
2021-01-03 20:19:21 +00:00
|
|
|
)
|
2021-01-25 20:44:44 +00:00
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
return id, err
|
2021-01-25 20:44:44 +00:00
|
|
|
}
|
|
|
|
|
2021-03-06 18:38:52 +00:00
|
|
|
_, err = d.removeLinksStmt.Exec(d.idToSql(id))
|
2021-01-25 20:44:44 +00:00
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
return id, err
|
2021-01-25 20:44:44 +00:00
|
|
|
}
|
|
|
|
|
2021-03-06 18:38:52 +00:00
|
|
|
err = d.addLinks(id, note)
|
|
|
|
return id, err
|
2021-01-03 20:19:21 +00:00
|
|
|
}
|
|
|
|
|
2021-03-08 20:38:32 +00:00
|
|
|
func (d *NoteDAO) metadataToJson(note note.Metadata) (string, error) {
|
|
|
|
json, err := json.Marshal(note.Metadata)
|
|
|
|
if err != nil {
|
|
|
|
return "", errors.Wrapf(err, "cannot serialize note metadata to JSON: %s", note.Path)
|
|
|
|
}
|
|
|
|
return string(json), nil
|
|
|
|
}
|
|
|
|
|
2021-01-25 20:44:44 +00:00
|
|
|
// addLinks inserts all the outbound links of the given note.
|
2021-03-06 18:38:52 +00:00
|
|
|
func (d *NoteDAO) addLinks(id core.NoteId, note note.Metadata) error {
|
2021-01-25 20:44:44 +00:00
|
|
|
for _, link := range note.Links {
|
|
|
|
targetId, err := d.findIdByPathPrefix(link.Href)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2021-03-08 20:38:32 +00:00
|
|
|
_, err = d.addLinkStmt.Exec(id, d.idToSql(targetId), link.Title, link.Href, link.External, joinLinkRels(link.Rels), link.Snippet, link.SnippetStart, link.SnippetEnd)
|
2021-01-25 20:44:44 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-06 18:38:52 +00:00
|
|
|
_, err := d.setLinksTargetStmt.Exec(int64(id), note.Path)
|
2021-01-25 20:44:44 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
if len(rels) == 0 {
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
delimiter := "\x01"
|
|
|
|
return delimiter + strings.Join(rels, delimiter) + delimiter
|
|
|
|
}
|
|
|
|
|
|
|
|
// Remove deletes the note with the given path from the index.
|
2021-01-09 11:17:15 +00:00
|
|
|
func (d *NoteDAO) Remove(path string) error {
|
2021-01-25 20:44:44 +00:00
|
|
|
id, err := d.findIdByPath(path)
|
2021-01-05 19:50:39 +00:00
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
return err
|
2021-01-05 19:50:39 +00:00
|
|
|
}
|
2021-03-06 18:38:52 +00:00
|
|
|
if !id.IsValid() {
|
|
|
|
return errors.New("note not found in the index")
|
2021-01-05 19:50:39 +00:00
|
|
|
}
|
|
|
|
|
2021-01-25 20:44:44 +00:00
|
|
|
_, err = d.removeStmt.Exec(id)
|
2021-03-06 18:38:52 +00:00
|
|
|
return err
|
2021-01-05 19:50:39 +00:00
|
|
|
}
|
|
|
|
|
2021-03-06 18:38:52 +00:00
|
|
|
func (d *NoteDAO) findIdByPath(path string) (core.NoteId, error) {
|
2021-01-25 20:44:44 +00:00
|
|
|
row, err := d.findIdByPathStmt.QueryRow(path)
|
2021-01-05 19:50:39 +00:00
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
return core.NoteId(0), err
|
2021-01-05 19:50:39 +00:00
|
|
|
}
|
2021-01-25 20:44:44 +00:00
|
|
|
return idForRow(row)
|
|
|
|
}
|
|
|
|
|
2021-03-06 18:38:52 +00:00
|
|
|
func (d *NoteDAO) findIdsByPathPrefixes(paths []string) ([]core.NoteId, error) {
|
|
|
|
ids := make([]core.NoteId, 0)
|
2021-01-25 21:57:12 +00:00
|
|
|
for _, path := range paths {
|
|
|
|
id, err := d.findIdByPathPrefix(path)
|
|
|
|
if err != nil {
|
|
|
|
return ids, err
|
|
|
|
}
|
2021-03-06 18:38:52 +00:00
|
|
|
if id.IsValid() {
|
|
|
|
ids = append(ids, id)
|
2021-01-25 21:57:12 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return ids, nil
|
|
|
|
}
|
|
|
|
|
2021-03-06 18:38:52 +00:00
|
|
|
func (d *NoteDAO) findIdByPathPrefix(path string) (core.NoteId, error) {
|
2021-01-25 20:44:44 +00:00
|
|
|
row, err := d.findIdByPathPrefixStmt.QueryRow(path)
|
2021-01-05 19:50:39 +00:00
|
|
|
if err != nil {
|
2021-03-06 18:38:52 +00:00
|
|
|
return core.NoteId(0), err
|
2021-01-25 20:44:44 +00:00
|
|
|
}
|
|
|
|
return idForRow(row)
|
|
|
|
}
|
|
|
|
|
2021-03-06 18:38:52 +00:00
|
|
|
func idForRow(row *sql.Row) (core.NoteId, error) {
|
2021-01-25 20:44:44 +00:00
|
|
|
var id sql.NullInt64
|
|
|
|
err := row.Scan(&id)
|
|
|
|
|
|
|
|
switch {
|
|
|
|
case err == sql.ErrNoRows:
|
2021-03-06 18:38:52 +00:00
|
|
|
return core.NoteId(0), nil
|
2021-01-25 20:44:44 +00:00
|
|
|
case err != nil:
|
2021-03-06 18:38:52 +00:00
|
|
|
return core.NoteId(0), err
|
2021-01-25 20:44:44 +00:00
|
|
|
default:
|
2021-03-06 18:38:52 +00:00
|
|
|
return core.NoteId(id.Int64), nil
|
2021-01-05 19:50:39 +00:00
|
|
|
}
|
2021-01-03 20:19:21 +00:00
|
|
|
}
|
|
|
|
|
2021-01-25 20:44:44 +00:00
|
|
|
// Find returns all the notes matching the given criteria.
|
2021-01-23 12:29:14 +00:00
|
|
|
func (d *NoteDAO) Find(opts note.FinderOpts) ([]note.Match, error) {
|
|
|
|
matches := make([]note.Match, 0)
|
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
opts, err := d.expandMentionsIntoMatch(opts)
|
|
|
|
if err != nil {
|
|
|
|
return matches, err
|
|
|
|
}
|
|
|
|
|
2021-01-16 13:13:57 +00:00
|
|
|
rows, err := d.findRows(opts)
|
2021-01-03 20:19:21 +00:00
|
|
|
if err != nil {
|
2021-01-23 12:29:14 +00:00
|
|
|
return matches, err
|
2021-01-03 20:19:21 +00:00
|
|
|
}
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
var (
|
2021-01-30 17:08:02 +00:00
|
|
|
id, wordCount int
|
|
|
|
title, lead, body, rawContent string
|
2021-03-07 16:00:09 +00:00
|
|
|
snippets, tags sql.NullString
|
2021-03-08 20:38:32 +00:00
|
|
|
path, metadataJSON, checksum string
|
2021-01-30 17:08:02 +00:00
|
|
|
created, modified time.Time
|
2021-01-03 20:19:21 +00:00
|
|
|
)
|
|
|
|
|
2021-03-08 20:38:32 +00:00
|
|
|
err := rows.Scan(
|
|
|
|
&id, &path, &title, &lead, &body, &rawContent, &wordCount,
|
|
|
|
&created, &modified, &metadataJSON, &checksum, &tags, &snippets,
|
|
|
|
)
|
2021-01-03 20:19:21 +00:00
|
|
|
if err != nil {
|
|
|
|
d.logger.Err(err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
metadata, err := d.unmarshalMetadata(metadataJSON)
|
2021-03-08 20:38:32 +00:00
|
|
|
if err != nil {
|
2021-03-13 14:31:05 +00:00
|
|
|
d.logger.Err(errors.Wrap(err, path))
|
2021-03-08 20:38:32 +00:00
|
|
|
}
|
|
|
|
|
2021-01-23 12:29:14 +00:00
|
|
|
matches = append(matches, note.Match{
|
2021-03-07 16:00:09 +00:00
|
|
|
Snippets: parseListFromNullString(snippets),
|
2021-01-03 20:19:21 +00:00
|
|
|
Metadata: note.Metadata{
|
2021-01-16 19:18:03 +00:00
|
|
|
Path: path,
|
|
|
|
Title: title,
|
|
|
|
Lead: lead,
|
|
|
|
Body: body,
|
|
|
|
RawContent: rawContent,
|
|
|
|
WordCount: wordCount,
|
2021-03-07 16:00:09 +00:00
|
|
|
Links: []note.Link{},
|
|
|
|
Tags: parseListFromNullString(tags),
|
2021-03-08 20:38:32 +00:00
|
|
|
Metadata: metadata,
|
2021-01-16 19:18:03 +00:00
|
|
|
Created: created,
|
|
|
|
Modified: modified,
|
|
|
|
Checksum: checksum,
|
2021-01-03 20:19:21 +00:00
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2021-01-23 12:29:14 +00:00
|
|
|
return matches, nil
|
2021-01-03 20:19:21 +00:00
|
|
|
}
|
2021-01-12 18:58:14 +00:00
|
|
|
|
2021-03-07 16:00:09 +00:00
|
|
|
// parseListFromNullString splits a 0-separated string.
|
|
|
|
func parseListFromNullString(str sql.NullString) []string {
|
|
|
|
list := []string{}
|
|
|
|
if str.Valid && str.String != "" {
|
|
|
|
list = strings.Split(str.String, "\x01")
|
|
|
|
list = strutil.RemoveDuplicates(list)
|
|
|
|
}
|
|
|
|
return list
|
|
|
|
}
|
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
// 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) {
|
|
|
|
notFoundErr := fmt.Errorf("could not find notes at: " + strings.Join(opts.Mention, ","))
|
|
|
|
|
|
|
|
if opts.Mention == nil {
|
|
|
|
return opts, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Find the IDs for the mentioned paths.
|
|
|
|
ids, err := d.findIdsByPathPrefixes(opts.Mention)
|
|
|
|
if err != nil {
|
|
|
|
return opts, err
|
|
|
|
}
|
|
|
|
if len(ids) == 0 {
|
|
|
|
return opts, notFoundErr
|
|
|
|
}
|
|
|
|
|
|
|
|
// Exclude the mentioned notes from the results.
|
|
|
|
if opts.ExcludeIds == nil {
|
|
|
|
opts.ExcludeIds = ids
|
|
|
|
} else {
|
|
|
|
for _, id := range ids {
|
|
|
|
opts.ExcludeIds = append(opts.ExcludeIds, id)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Find their titles.
|
|
|
|
titlesQuery := "SELECT title, metadata FROM notes WHERE id IN (" + d.joinIds(ids, ",") + ")"
|
|
|
|
rows, err := d.tx.Query(titlesQuery)
|
|
|
|
if err != nil {
|
|
|
|
return opts, err
|
|
|
|
}
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
titles := []string{}
|
2021-03-14 16:20:01 +00:00
|
|
|
|
|
|
|
appendTitle := func(t string) {
|
|
|
|
titles = append(titles, `"`+strings.ReplaceAll(t, `"`, "")+`"`)
|
|
|
|
}
|
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
for rows.Next() {
|
|
|
|
var title, metadataJSON string
|
|
|
|
err := rows.Scan(&title, &metadataJSON)
|
|
|
|
if err != nil {
|
|
|
|
return opts, err
|
|
|
|
}
|
|
|
|
|
2021-03-14 16:20:01 +00:00
|
|
|
appendTitle(title)
|
2021-03-13 14:31:05 +00:00
|
|
|
|
|
|
|
// Support `aliases` key in the YAML frontmatter, like Obsidian:
|
|
|
|
// https://publish.obsidian.md/help/How+to/Add+aliases+to+note
|
|
|
|
metadata, err := d.unmarshalMetadata(metadataJSON)
|
|
|
|
if err != nil {
|
|
|
|
d.logger.Err(err)
|
|
|
|
} else {
|
|
|
|
if aliases, ok := metadata["aliases"]; ok {
|
|
|
|
switch aliases := aliases.(type) {
|
|
|
|
case []interface{}:
|
|
|
|
for _, alias := range aliases {
|
2021-03-14 16:20:01 +00:00
|
|
|
appendTitle(fmt.Sprint(alias))
|
2021-03-13 14:31:05 +00:00
|
|
|
}
|
|
|
|
case string:
|
2021-03-14 16:20:01 +00:00
|
|
|
appendTitle(aliases)
|
2021-03-13 14:31:05 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(titles) == 0 {
|
|
|
|
return opts, notFoundErr
|
|
|
|
}
|
|
|
|
|
|
|
|
// Expand the titles in the match predicate.
|
|
|
|
match := opts.Match.String()
|
|
|
|
match += " (" + strings.Join(titles, " OR ") + ")"
|
|
|
|
opts.Match = opt.NewString(match)
|
|
|
|
|
|
|
|
return opts, nil
|
|
|
|
}
|
|
|
|
|
2021-01-16 13:13:57 +00:00
|
|
|
func (d *NoteDAO) findRows(opts note.FinderOpts) (*sql.Rows, error) {
|
2021-01-16 19:18:03 +00:00
|
|
|
snippetCol := `n.lead`
|
2021-03-07 16:00:09 +00:00
|
|
|
joinClauses := []string{}
|
|
|
|
whereExprs := []string{}
|
|
|
|
additionalOrderTerms := []string{}
|
|
|
|
args := []interface{}{}
|
2021-02-01 21:04:44 +00:00
|
|
|
groupBy := ""
|
2021-01-16 13:13:57 +00:00
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
transitiveClosure := false
|
|
|
|
maxDistance := 0
|
|
|
|
|
|
|
|
setupLinkFilter := func(paths []string, direction int, negate, recursive bool) error {
|
2021-01-30 17:08:02 +00:00
|
|
|
ids, err := d.findIdsByPathPrefixes(paths)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if len(ids) == 0 {
|
|
|
|
return nil
|
|
|
|
}
|
2021-03-06 18:38:52 +00:00
|
|
|
idsList := "(" + d.joinIds(ids, ",") + ")"
|
2021-01-30 17:08:02 +00:00
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
linksSrc := "links"
|
2021-01-30 17:18:20 +00:00
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
if recursive {
|
|
|
|
transitiveClosure = true
|
|
|
|
linksSrc = "transitive_closure"
|
2021-01-30 17:08:02 +00:00
|
|
|
}
|
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
if !negate {
|
|
|
|
if direction != 0 {
|
2021-03-07 16:00:09 +00:00
|
|
|
snippetCol = "GROUP_CONCAT(REPLACE(l.snippet, l.title, '<zk:match>' || l.title || '</zk:match>'), '\x01')"
|
2021-02-01 21:04:44 +00:00
|
|
|
}
|
2021-01-31 16:07:28 +00:00
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
joinOns := make([]string, 0)
|
|
|
|
if direction <= 0 {
|
|
|
|
joinOns = append(joinOns, fmt.Sprintf(
|
|
|
|
"(n.id = l.target_id AND l.source_id IN %s)", idsList,
|
|
|
|
))
|
|
|
|
}
|
|
|
|
if direction >= 0 {
|
|
|
|
joinOns = append(joinOns, fmt.Sprintf(
|
|
|
|
"(n.id = l.source_id AND l.target_id IN %s)", idsList,
|
|
|
|
))
|
2021-01-31 16:07:28 +00:00
|
|
|
}
|
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
joinClauses = append(joinClauses, fmt.Sprintf(
|
|
|
|
"LEFT JOIN %s l ON %s",
|
|
|
|
linksSrc,
|
|
|
|
strings.Join(joinOns, " OR "),
|
|
|
|
))
|
2021-01-31 11:24:33 +00:00
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
groupBy = "GROUP BY n.id"
|
2021-01-30 17:08:02 +00:00
|
|
|
}
|
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
idExpr := "n.id"
|
2021-01-30 17:08:02 +00:00
|
|
|
if negate {
|
2021-02-01 21:04:44 +00:00
|
|
|
idExpr += " NOT"
|
|
|
|
}
|
|
|
|
|
|
|
|
idSelects := make([]string, 0)
|
|
|
|
if direction <= 0 {
|
|
|
|
idSelects = append(idSelects, fmt.Sprintf(
|
|
|
|
" SELECT target_id FROM %s WHERE target_id IS NOT NULL AND source_id IN %s",
|
|
|
|
linksSrc, idsList,
|
|
|
|
))
|
|
|
|
}
|
|
|
|
if direction >= 0 {
|
|
|
|
idSelects = append(idSelects, fmt.Sprintf(
|
|
|
|
" SELECT source_id FROM %s WHERE target_id IS NOT NULL AND target_id IN %s",
|
|
|
|
linksSrc, idsList,
|
|
|
|
))
|
2021-01-30 17:08:02 +00:00
|
|
|
}
|
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
idExpr += " IN (\n" + strings.Join(idSelects, "\n UNION\n") + "\n)"
|
|
|
|
|
|
|
|
whereExprs = append(whereExprs, idExpr)
|
2021-01-30 17:08:02 +00:00
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if !opts.Match.IsNull() {
|
|
|
|
snippetCol = `snippet(notes_fts, 2, '<zk:match>', '</zk:match>', '…', 20)`
|
|
|
|
joinClauses = append(joinClauses, "JOIN notes_fts ON n.id = notes_fts.rowid")
|
|
|
|
additionalOrderTerms = append(additionalOrderTerms, `bm25(notes_fts, 1000.0, 500.0, 1.0)`)
|
|
|
|
whereExprs = append(whereExprs, "notes_fts MATCH ?")
|
|
|
|
args = append(args, fts5.ConvertQuery(opts.Match.String()))
|
|
|
|
}
|
2021-01-16 13:13:57 +00:00
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if opts.IncludePaths != nil {
|
|
|
|
regexes := make([]string, 0)
|
|
|
|
for _, path := range opts.IncludePaths {
|
|
|
|
regexes = append(regexes, "n.path REGEXP ?")
|
|
|
|
args = append(args, pathRegex(path))
|
|
|
|
}
|
|
|
|
whereExprs = append(whereExprs, strings.Join(regexes, " OR "))
|
|
|
|
}
|
2021-01-16 13:13:57 +00:00
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if opts.ExcludePaths != nil {
|
|
|
|
regexes := make([]string, 0)
|
|
|
|
for _, path := range opts.ExcludePaths {
|
|
|
|
regexes = append(regexes, "n.path NOT REGEXP ?")
|
|
|
|
args = append(args, pathRegex(path))
|
|
|
|
}
|
|
|
|
whereExprs = append(whereExprs, strings.Join(regexes, " AND "))
|
|
|
|
}
|
2021-01-16 13:13:57 +00:00
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if opts.ExcludeIds != nil {
|
|
|
|
whereExprs = append(whereExprs, "n.id NOT IN ("+d.joinIds(opts.ExcludeIds, ",")+")")
|
|
|
|
}
|
2021-03-07 16:36:54 +00:00
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if opts.Tags != nil {
|
|
|
|
separatorRegex := regexp.MustCompile(`(\ OR\ )|\|`)
|
|
|
|
for _, tagsArg := range opts.Tags {
|
|
|
|
tags := separatorRegex.Split(tagsArg, -1)
|
2021-03-07 16:00:09 +00:00
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
negate := false
|
|
|
|
globs := make([]string, 0)
|
|
|
|
for _, tag := range tags {
|
|
|
|
tag = strings.TrimSpace(tag)
|
|
|
|
|
|
|
|
if strings.HasPrefix(tag, "-") {
|
|
|
|
negate = true
|
|
|
|
tag = strings.TrimPrefix(tag, "-")
|
|
|
|
} else if strings.HasPrefix(tag, "NOT") {
|
|
|
|
negate = true
|
|
|
|
tag = strings.TrimPrefix(tag, "NOT")
|
2021-03-07 16:36:54 +00:00
|
|
|
}
|
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
tag = strings.TrimSpace(tag)
|
|
|
|
if len(tag) == 0 {
|
|
|
|
continue
|
2021-03-07 16:36:54 +00:00
|
|
|
}
|
2021-03-13 14:31:05 +00:00
|
|
|
globs = append(globs, "t.name GLOB ?")
|
|
|
|
args = append(args, tag)
|
2021-03-07 16:00:09 +00:00
|
|
|
}
|
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if len(globs) == 0 {
|
|
|
|
continue
|
2021-01-16 13:13:57 +00:00
|
|
|
}
|
2021-03-13 14:31:05 +00:00
|
|
|
if negate && len(globs) > 1 {
|
|
|
|
return nil, fmt.Errorf("cannot negate a tag in a OR group: %s", tagsArg)
|
2021-01-16 13:13:57 +00:00
|
|
|
}
|
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
expr := "n.id"
|
|
|
|
if negate {
|
|
|
|
expr += " NOT"
|
2021-01-25 21:57:12 +00:00
|
|
|
}
|
2021-03-13 14:31:05 +00:00
|
|
|
expr += fmt.Sprintf(` IN (
|
|
|
|
SELECT note_id FROM notes_collections
|
|
|
|
WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
|
|
|
|
)`,
|
|
|
|
note.CollectionKindTag,
|
|
|
|
strings.Join(globs, " OR "),
|
|
|
|
)
|
|
|
|
whereExprs = append(whereExprs, expr)
|
|
|
|
}
|
|
|
|
}
|
2021-01-27 20:25:33 +00:00
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if opts.LinkedBy != nil {
|
|
|
|
filter := opts.LinkedBy
|
|
|
|
maxDistance = filter.MaxDistance
|
|
|
|
err := setupLinkFilter(filter.Paths, -1, filter.Negate, filter.Recursive)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
2021-02-01 21:04:44 +00:00
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if opts.LinkTo != nil {
|
|
|
|
filter := opts.LinkTo
|
|
|
|
maxDistance = filter.MaxDistance
|
|
|
|
err := setupLinkFilter(filter.Paths, 1, filter.Negate, filter.Recursive)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
2021-01-16 13:13:57 +00:00
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if opts.Related != nil {
|
|
|
|
maxDistance = 2
|
|
|
|
err := setupLinkFilter(opts.Related, 0, false, true)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
groupBy += " HAVING MIN(l.distance) = 2"
|
|
|
|
}
|
2021-01-16 13:13:57 +00:00
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if opts.Orphan {
|
|
|
|
whereExprs = append(whereExprs, `n.id NOT IN (
|
|
|
|
SELECT target_id FROM links WHERE target_id IS NOT NULL
|
|
|
|
)`)
|
|
|
|
}
|
2021-01-23 12:29:14 +00:00
|
|
|
|
2021-03-13 14:31:05 +00:00
|
|
|
if opts.CreatedStart != nil {
|
|
|
|
whereExprs = append(whereExprs, "created >= ?")
|
|
|
|
args = append(args, opts.CreatedStart)
|
|
|
|
}
|
|
|
|
|
|
|
|
if opts.CreatedEnd != nil {
|
|
|
|
whereExprs = append(whereExprs, "created < ?")
|
|
|
|
args = append(args, opts.CreatedEnd)
|
|
|
|
}
|
|
|
|
|
|
|
|
if opts.ModifiedStart != nil {
|
|
|
|
whereExprs = append(whereExprs, "modified >= ?")
|
|
|
|
args = append(args, opts.ModifiedStart)
|
|
|
|
}
|
|
|
|
|
|
|
|
if opts.ModifiedEnd != nil {
|
|
|
|
whereExprs = append(whereExprs, "modified < ?")
|
|
|
|
args = append(args, opts.ModifiedEnd)
|
2021-01-16 13:13:57 +00:00
|
|
|
}
|
|
|
|
|
2021-02-27 10:23:16 +00:00
|
|
|
orderTerms := []string{}
|
2021-01-16 13:13:57 +00:00
|
|
|
for _, sorter := range opts.Sorters {
|
|
|
|
orderTerms = append(orderTerms, orderTerm(sorter))
|
|
|
|
}
|
2021-02-27 10:23:16 +00:00
|
|
|
orderTerms = append(orderTerms, additionalOrderTerms...)
|
2021-01-16 13:13:57 +00:00
|
|
|
orderTerms = append(orderTerms, `n.title ASC`)
|
|
|
|
|
2021-01-31 11:24:33 +00:00
|
|
|
query := ""
|
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
// Credit to https://inviqa.com/blog/storing-graphs-database-sql-meets-social-network
|
|
|
|
if transitiveClosure {
|
|
|
|
orderTerms = append([]string{"l.distance"}, orderTerms...)
|
|
|
|
|
|
|
|
query += `WITH RECURSIVE transitive_closure(source_id, target_id, title, snippet, distance, path) AS (
|
|
|
|
SELECT source_id, target_id, title, snippet,
|
2021-03-07 16:00:09 +00:00
|
|
|
1 AS distance,
|
2021-02-01 21:04:44 +00:00
|
|
|
'.' || source_id || '.' || target_id || '.' AS path
|
|
|
|
FROM links
|
|
|
|
|
|
|
|
UNION ALL
|
|
|
|
|
|
|
|
SELECT tc.source_id, l.target_id, l.title, l.snippet,
|
2021-03-07 16:00:09 +00:00
|
|
|
tc.distance + 1,
|
2021-02-01 21:04:44 +00:00
|
|
|
tc.path || l.target_id || '.' AS path
|
|
|
|
FROM links AS l
|
|
|
|
JOIN transitive_closure AS tc
|
|
|
|
ON l.source_id = tc.target_id
|
|
|
|
WHERE tc.path NOT LIKE '%.' || l.target_id || '.%'`
|
|
|
|
|
|
|
|
if maxDistance != 0 {
|
|
|
|
query += fmt.Sprintf(" AND tc.distance < %d", maxDistance)
|
|
|
|
}
|
|
|
|
|
2021-02-27 17:15:42 +00:00
|
|
|
// Guard against infinite loops by limiting the number of recursions.
|
|
|
|
query += "\n LIMIT 100000"
|
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
query += "\n)\n"
|
2021-01-31 11:24:33 +00:00
|
|
|
}
|
|
|
|
|
2021-03-08 20:38:32 +00:00
|
|
|
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)
|
2021-01-16 13:13:57 +00:00
|
|
|
|
2021-03-07 16:00:09 +00:00
|
|
|
query += "FROM notes_with_metadata n\n"
|
2021-01-30 17:08:02 +00:00
|
|
|
|
|
|
|
for _, clause := range joinClauses {
|
2021-01-31 11:24:33 +00:00
|
|
|
query += clause + "\n"
|
2021-01-30 17:08:02 +00:00
|
|
|
}
|
2021-01-16 13:13:57 +00:00
|
|
|
|
|
|
|
if len(whereExprs) > 0 {
|
2021-01-31 11:24:33 +00:00
|
|
|
query += "WHERE " + strings.Join(whereExprs, "\nAND ") + "\n"
|
2021-01-16 13:13:57 +00:00
|
|
|
}
|
|
|
|
|
2021-02-01 21:04:44 +00:00
|
|
|
if groupBy != "" {
|
|
|
|
query += groupBy + "\n"
|
2021-01-30 17:08:02 +00:00
|
|
|
}
|
|
|
|
|
2021-01-31 11:24:33 +00:00
|
|
|
query += "ORDER BY " + strings.Join(orderTerms, ", ") + "\n"
|
2021-01-16 13:13:57 +00:00
|
|
|
|
|
|
|
if opts.Limit > 0 {
|
2021-01-31 11:24:33 +00:00
|
|
|
query += fmt.Sprintf("LIMIT %d\n", opts.Limit)
|
2021-01-16 13:13:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// fmt.Println(query)
|
|
|
|
// fmt.Println(args)
|
|
|
|
return d.tx.Query(query, args...)
|
|
|
|
}
|
|
|
|
|
2021-01-13 21:06:05 +00:00
|
|
|
func orderTerm(sorter note.Sorter) string {
|
|
|
|
order := " ASC"
|
|
|
|
if !sorter.Ascending {
|
|
|
|
order = " DESC"
|
|
|
|
}
|
|
|
|
|
2021-01-14 21:10:35 +00:00
|
|
|
switch sorter.Field {
|
2021-01-13 21:06:05 +00:00
|
|
|
case note.SortCreated:
|
|
|
|
return "n.created" + order
|
|
|
|
case note.SortModified:
|
|
|
|
return "n.modified" + order
|
|
|
|
case note.SortPath:
|
|
|
|
return "n.path" + order
|
|
|
|
case note.SortRandom:
|
|
|
|
return "RANDOM()"
|
|
|
|
case note.SortTitle:
|
|
|
|
return "n.title" + order
|
|
|
|
case note.SortWordCount:
|
|
|
|
return "n.word_count" + order
|
|
|
|
default:
|
2021-01-14 21:10:35 +00:00
|
|
|
panic(fmt.Sprintf("%v: unknown note.SortField", sorter.Field))
|
2021-01-12 18:58:14 +00:00
|
|
|
}
|
|
|
|
}
|
2021-02-27 18:23:03 +00:00
|
|
|
|
|
|
|
// pathRegex returns an ICU regex to match the files in the folder at given
|
|
|
|
// `path`, or any file having `path` for prefix.
|
|
|
|
func pathRegex(path string) string {
|
|
|
|
path = icu.EscapePattern(path)
|
|
|
|
return path + "[^/]*|" + path + "/.+"
|
|
|
|
}
|
2021-03-06 18:38:52 +00:00
|
|
|
|
|
|
|
func (d *NoteDAO) idToSql(id core.NoteId) sql.NullInt64 {
|
|
|
|
if id.IsValid() {
|
|
|
|
return sql.NullInt64{Int64: int64(id), Valid: true}
|
|
|
|
} else {
|
|
|
|
return sql.NullInt64{}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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))
|
|
|
|
}
|
|
|
|
return strings.Join(strs, delimiter)
|
|
|
|
}
|
2021-03-13 14:31:05 +00:00
|
|
|
|
|
|
|
func (d *NoteDAO) unmarshalMetadata(metadataJSON string) (metadata map[string]interface{}, err error) {
|
|
|
|
err = json.Unmarshal([]byte(metadataJSON), &metadata)
|
|
|
|
err = errors.Wrapf(err, "cannot parse note metadata from JSON: %s", metadataJSON)
|
|
|
|
return
|
|
|
|
}
|