/* Copyright (C) 2019, 2020, 2021, 2022, 2023 Monomax Software Pty Ltd * * This file is part of Dnote. * * Dnote is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Dnote is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Dnote. If not, see . */ package find import ( "database/sql" "fmt" "strings" "github.com/dnote/dnote/pkg/cli/context" "github.com/dnote/dnote/pkg/cli/infra" "github.com/dnote/dnote/pkg/cli/log" "github.com/pkg/errors" "github.com/spf13/cobra" ) var example = ` # find notes by a keyword dnote find rpoplpush # find notes by multiple keywords dnote find "building a heap" # find notes within a book dnote find "merge sort" -b algorithm ` var bookName string func preRun(cmd *cobra.Command, args []string) error { if len(args) != 1 { return errors.New("Incorrect number of argument") } return nil } // NewCmd returns a new remove command func NewCmd(ctx context.DnoteCtx) *cobra.Command { cmd := &cobra.Command{ Use: "find", Short: "Find notes by keywords", Aliases: []string{"f"}, Example: example, PreRunE: preRun, RunE: newRun(ctx), } f := cmd.Flags() f.StringVarP(&bookName, "book", "b", "", "book name to find notes in") return cmd } // noteInfo is an information about the note to be printed on screen type noteInfo struct { RowID int BookLabel string Body string } // formatFTSSnippet turns the matched snippet from a full text search // into a format suitable for CLI output func formatFTSSnippet(s string) (string, error) { // first, strip all new lines body := newLineReg.ReplaceAllString(s, " ") var format, buf strings.Builder var args []interface{} toks := tokenize(body) for _, tok := range toks { if tok.Kind == tokenKindHLBegin || tok.Kind == tokenKindEOL { format.WriteString("%s") args = append(args, buf.String()) buf.Reset() } else if tok.Kind == tokenKindHLEnd { format.WriteString("%s") str := log.ColorYellow.Sprintf("%s", buf.String()) args = append(args, str) buf.Reset() } else { if err := buf.WriteByte(tok.Value); err != nil { return "", errors.Wrap(err, "building string") } } } return fmt.Sprintf(format.String(), args...), nil } // escapePhrase escapes the user-supplied FTS keywords by wrapping each term around // double quotations so that they are treated as 'strings' as defined by SQLite FTS5. func escapePhrase(s string) (string, error) { var b strings.Builder terms := strings.Fields(s) for idx, term := range terms { if _, err := b.WriteString(fmt.Sprintf("\"%s\"", term)); err != nil { return "", errors.Wrap(err, "writing string to builder") } if idx != len(term)-1 { if err := b.WriteByte(' '); err != nil { return "", errors.Wrap(err, "writing space to builder") } } } return b.String(), nil } func doQuery(ctx context.DnoteCtx, query, bookName string) (*sql.Rows, error) { db := ctx.DB sql := `SELECT notes.rowid, books.label AS book_label, snippet(note_fts, 0, '', '', '...', 28) FROM note_fts INNER JOIN notes ON notes.rowid = note_fts.rowid INNER JOIN books ON notes.book_uuid = books.uuid WHERE note_fts MATCH ?` args := []interface{}{query} if bookName != "" { sql = fmt.Sprintf("%s AND books.label = ?", sql) args = append(args, bookName) } rows, err := db.Query(sql, args...) return rows, err } func newRun(ctx context.DnoteCtx) infra.RunEFunc { return func(cmd *cobra.Command, args []string) error { phrase, err := escapePhrase(args[0]) if err != nil { return errors.Wrap(err, "escaping phrase") } rows, err := doQuery(ctx, phrase, bookName) if err != nil { return errors.Wrap(err, "querying notes") } defer rows.Close() infos := []noteInfo{} for rows.Next() { var info noteInfo var body string err = rows.Scan(&info.RowID, &info.BookLabel, &body) if err != nil { return errors.Wrap(err, "scanning a row") } body, err := formatFTSSnippet(body) if err != nil { return errors.Wrap(err, "formatting a body") } info.Body = body infos = append(infos, info) } for _, info := range infos { bookLabel := log.ColorYellow.Sprintf("(%s)", info.BookLabel) rowid := log.ColorYellow.Sprintf("(%d)", info.RowID) log.Plainf("%s %s %s\n", bookLabel, rowid, info.Body) } return nil } }