Release / Build and publish image (push) Failing after 10m3s
Første etape af noter. Tabel, CRUD, fuldtekstsøgning og de links, en note bærer. Markdown i en kolonne, ikke rich text og ikke et format af vores eget. Eksport var et krav, før der var skrevet en linje, og er den gemte form den eksporterede form, er der ingen konvertering tilbage at tabe noget i — noten kan læses af enhver anden editor den dag programmet ikke findes. Et #tag i en note er DET SAMME tag som i en opgave. Ikke en anden slags, der ligner. Det er hele svaret på "tag en note og se den i projektet": referencen er den samme, så der ikke opstår to systemer, brugeren selv skal holde i sync. Links gemmes som rækker, udledt af teksten ved hver gemning. Teksten er stadig sandheden; tabellen er et indeks over den. Uden det ville "hvad peger på denne opgave" betyde en gennemsøgning af al prosa, og den rude ville være for langsom til at lade stå åben. De rives ned og bygges op frem for at blive sammenlignet: en sammenligning er en anden beskrivelse af samme kendsgerning, og den kan komme til at være uenig med teksten. Noter ligger under projekter og arver deling, roller, papirkurv og historik. En mappestruktur ved siden af ville være ét hierarki for meget. MatchExpr fik navngivne kolonner. Den var skrevet til opgavernes og gav "no such column" mod noternes — hvilket i det mindste er højlydt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
90 lines
3.0 KiB
Go
90 lines
3.0 KiB
Go
package store
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// FoldDanish transliterates the Danish letters that Unicode does not treat as
|
|
// accented forms of anything, so they survive into a form FTS5 can fold further.
|
|
//
|
|
// This has to be applied to the query with exactly the same mapping the `fold`
|
|
// column on `tasks` uses. The two are a pair: change one and searching quietly
|
|
// stops finding half the words in the user's own language.
|
|
func FoldDanish(s string) string {
|
|
r := strings.NewReplacer(
|
|
"ø", "o", "Ø", "o",
|
|
"æ", "ae", "Æ", "ae",
|
|
"å", "aa", "Å", "aa",
|
|
)
|
|
return r.Replace(s)
|
|
}
|
|
|
|
// MatchExpr turns what someone typed into the search box into an FTS5 MATCH
|
|
// expression.
|
|
//
|
|
// User input is never passed to FTS5 as-is. The MATCH grammar has its own operators
|
|
// (`AND`, `NEAR`, `*`, `^`, `:`, `-`), so a perfectly ordinary task title like
|
|
// "budget: q3 - final" parses as a query and fails with a syntax error rather than
|
|
// finding anything. Every term is quoted, which makes it a literal.
|
|
//
|
|
// Each term must match somewhere in the row, and may match either the text as
|
|
// written or its folded form — so "gron" finds "grøn" and "grøn" still finds
|
|
// itself. Terms are prefix-matched, which is what makes the Cmd+K box feel like it
|
|
// is searching as you type.
|
|
//
|
|
// Returns "" when there is nothing searchable in the input; callers should treat
|
|
// that as "no query" rather than as "match everything".
|
|
func MatchExpr(query string) string {
|
|
return MatchExprOver(query, "content", "description")
|
|
}
|
|
|
|
// MatchExprOver is MatchExpr against a named set of columns.
|
|
//
|
|
// The columns have to be named because the index they are searched in decides
|
|
// them: tasks carry content and description, notes carry title and body. Passing
|
|
// one table's column names to the other's index is not a wrong answer but an
|
|
// error — "no such column" — which is at least loud.
|
|
func MatchExprOver(query string, columns ...string) string {
|
|
terms := tokenize(query)
|
|
if len(terms) == 0 || len(columns) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
for i, term := range terms {
|
|
if i > 0 {
|
|
b.WriteString(" AND ")
|
|
}
|
|
folded := FoldDanish(term)
|
|
b.WriteString(`({` + strings.Join(columns, " ") + `} : `)
|
|
b.WriteString(quote(term))
|
|
b.WriteString(`* OR fold : `)
|
|
b.WriteString(quote(folded))
|
|
b.WriteString(`*)`)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// tokenize splits on everything that is not a letter or a digit. Splitting on
|
|
// punctuation rather than whitespace alone means "q3/2026" searches for "q3" and
|
|
// "2026" instead of for a term FTS5's own tokenizer would never have indexed.
|
|
func tokenize(query string) []string {
|
|
fields := strings.FieldsFunc(query, func(r rune) bool {
|
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
|
})
|
|
terms := make([]string, 0, len(fields))
|
|
for _, f := range fields {
|
|
if f != "" {
|
|
terms = append(terms, f)
|
|
}
|
|
}
|
|
return terms
|
|
}
|
|
|
|
// quote wraps a term as an FTS5 string literal. Inside one, the only character
|
|
// with meaning is the double quote, escaped by doubling it.
|
|
func quote(term string) string {
|
|
return `"` + strings.ReplaceAll(term, `"`, `""`) + `"`
|
|
}
|