package wing import ( "context" "fmt" "regexp" "slices" "strings" "unicode" xnorm "golang.org/x/text/unicode/norm" ) // Router places a note with rules only, no model (hyperguild#208): // // 1. A subfolder or frontmatter `wing:` is the owner's deliberate choice. It // must name a plain, EXISTING wing (a typo never creates one) and then it // places, whatever the wing is: extraction runs on the local model, and it // is the owner who dropped the note there. Two signals that disagree park. // 2. With no signal, a note whose text matches ClientBlock is parked; any // other note goes to Default, the one wing that is created on first use. // // Wings are read from disk on every decision, so a wing created while the // server runs is usable at once. type Router struct { BrainDir string Default string ClientBlock *regexp.Regexp // nil = no content check // FilterChunk is how many bytes of a note the content filter folds and // matches at a time (0 = 1 MiB). Folding allocates several times its input, // so a 64 MiB note is checked in pieces (with an overlap, so a marker on a // boundary is still found) instead of all at once. FilterChunk int } // Resolve implements Placer. It never returns an error: anything it cannot // decide safely is an Exception with the reason spelled out. func (r Router) Resolve(_ context.Context, n Note) Decision { for _, raw := range []string{n.Dir, n.FrontmatterWing} { if !isASCII(raw) { // strings.ToLower folds some non-ASCII runes to ASCII (the Kelvin // sign becomes "k"), which would let a lookalike name a real wing. return exception("", 0, fmt.Sprintf("wing name %q contains non-ASCII characters", strings.TrimSpace(raw))) } } dir, fm := norm(n.Dir), norm(n.FrontmatterWing) switch { case dir != "" && fm != "" && dir != fm: return exception("", 0, fmt.Sprintf("subfolder %q and frontmatter wing %q disagree", dir, fm)) case dir != "": return r.explicit(dir, "subfolder") case fm != "": return r.explicit(fm, "frontmatter") } if r.ClientBlock != nil && matchesClient(r.ClientBlock, n.Text, r.FilterChunk) { return exception("", 0, "text matches the client-content marker; not defaulted into a shared wing") } if r.Default == "" || !usableWingName(r.Default) { return exception("", 0, "no valid default wing configured") } return Decision{Wing: r.Default, Source: Default, Confidence: 1, Reason: "no wing named; default wing"} } func (r Router) explicit(w, via string) Decision { if !usableWingName(w) { return exception("", 0, fmt.Sprintf("%s names %q, which is not a usable wing name", via, w)) } if w == r.Default { return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via} } known, err := KnownWings(r.BrainDir) if err != nil { return exception(w, 0, fmt.Sprintf("cannot list wings: %v", err)) } if !slices.Contains(known, w) { return exception(w, 0, fmt.Sprintf("%s names %q: not an existing wing (new wings are never created from a folder name)", via, strings.TrimSpace(w))) } return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via} } // NewRouter builds the production Router. defaultWing must be a plain wing // name; clientPattern, when set, is a case-insensitive regexp over note text // whose match parks an unsignalled note instead of filing it. Both fail at // startup rather than later: a client filter that silently does nothing is // worse than none. func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) { if defaultWing == "" || !usableWingName(defaultWing) { return Router{}, fmt.Errorf("default wing %q is not a usable wing name", defaultWing) } r := Router{BrainDir: brainDir, Default: defaultWing} if clientPattern != "" { re, err := regexp.Compile("(?i)" + clientPattern) if err != nil { return Router{}, fmt.Errorf("client-content pattern: %w", err) } r.ClientBlock = re } return r, nil } // reservedWingNames are directory names under wiki/ that are not wings: the // retired layout and the root. A note must never be proposed into one. var reservedWingNames = map[string]bool{"sources": true, "concepts": true, "entities": true, "wiki": true} // usableWingName: a plain name that is not reserved. func usableWingName(w string) bool { return plainWingName(w) && !reservedWingNames[w] } func isASCII(s string) bool { for _, r := range s { if r > unicode.MaxASCII { return false } } return true } const ( defaultFilterChunk = 1 << 20 filterOverlap = 4096 ) // matchesClient applies the client-content pattern to the text in bounded // chunks, each overlapping the next, so memory stays proportional to the chunk // and a marker on a chunk edge is still found. func matchesClient(re *regexp.Regexp, text string, chunk int) bool { if chunk <= 0 { chunk = defaultFilterChunk } overlap := filterOverlap if overlap >= chunk { overlap = chunk / 2 } for start := 0; start < len(text); { end := start + chunk if end >= len(text) { end = len(text) } if matchesFolded(re, text[start:end]) { return true } if end == len(text) { return false } // A cut inside a rune is harmless: the overlap carries every marker // shorter than it whole into the next chunk. start = end - overlap } return false } // matchesFolded matches the pattern against the chunk as it is and against // folded forms, so the cheap evasions do not defeat it. Folding: NFKD (fullwidth // letters, accents become base letter + mark), then marks, format characters and // invisible fillers dropped, Cyrillic/Greek lookalikes mapped to Latin, every // dash made "-". Views: whitespace collapsed to one space (so "acme corp" and // \s work), whitespace removed (so "acme-\nclient" works), and the same with a // soft hyphen read as a dash. It catches what it names and nothing else: // leetspeak, punctuation splits (a.c.m.e), encodings and the many homoglyphs // not in the small table below are not caught. It is a filter, not a classifier. func matchesFolded(re *regexp.Regexp, chunk string) bool { if re.MatchString(chunk) { return true } decomposed := xnorm.NFKD.String(chunk) return re.MatchString(fold(decomposed, false, true)) || re.MatchString(fold(decomposed, false, false)) || re.MatchString(fold(decomposed, true, false)) } // fold builds one view. softAsDash reads U+00AD as "-" instead of dropping it; // keepSpace collapses whitespace to a single space instead of removing it. func fold(s string, softAsDash, keepSpace bool) string { var b strings.Builder b.Grow(len(s)) space := false for _, r := range s { switch { case r == '\u00ad' && softAsDash: b.WriteRune('-') space = false continue case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Mc, r) || unicode.Is(unicode.Cf, r) || invisibleFiller[r]: continue case unicode.IsSpace(r) || unicode.Is(unicode.Zs, r): if keepSpace && !space { b.WriteByte(' ') space = true } continue } space = false switch { case unicode.Is(unicode.Pd, r) || r == '\u2212': b.WriteRune('-') default: if l, ok := latinLookalike[r]; ok { r = l } b.WriteRune(r) } } return b.String() } // invisibleFiller are characters that render as nothing but are not format // characters: Hangul fillers and the blank Braille pattern. var invisibleFiller = map[rune]bool{0x115F: true, 0x1160: true, 0x3164: true, 0xFFA0: true, 0x2800: true} // latinLookalike maps the commonest Cyrillic and Greek letters that read as // Latin ones. Deliberately small; see matchesFolded. var latinLookalike = map[rune]rune{ '\u0430': 'a', '\u0435': 'e', '\u043e': 'o', '\u0440': 'p', '\u0441': 'c', '\u0445': 'x', '\u0443': 'y', '\u0456': 'i', '\u0458': 'j', '\u0455': 's', '\u04bb': 'h', '\u0501': 'd', '\u043a': 'k', '\u043d': 'h', '\u0442': 't', '\u0410': 'A', '\u0412': 'B', '\u0415': 'E', '\u041a': 'K', '\u041c': 'M', '\u041d': 'H', '\u041e': 'O', '\u0420': 'P', '\u0421': 'C', '\u0422': 'T', '\u0425': 'X', '\u03bf': 'o', '\u03bd': 'v', '\u03c1': 'p', '\u0391': 'A', '\u0392': 'B', '\u0395': 'E', '\u039f': 'O', '\u03a1': 'P', }