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 } // 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) { 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 } // matchesClient applies the client-content pattern to the text and to two // folded forms of it, so the cheap evasions do not defeat it: NFKC (fullwidth // letters), format characters removed (zero-width joiners, soft hyphens), every // dash made "-", and whitespace removed ("acme-\nclient", "acme - client"). It catches what it names and nothing else; it is a filter, // not a classifier. func matchesClient(re *regexp.Regexp, text string) bool { folded := xnorm.NFKC.String(text) var b strings.Builder for _, r := range folded { switch { case unicode.Is(unicode.Cf, r): continue case unicode.Is(unicode.Pd, r) || r == '\u2212': b.WriteRune('-') default: b.WriteRune(r) } } clean := b.String() squeezed := strings.Join(strings.Fields(clean), "") return re.MatchString(text) || re.MatchString(squeezed) }