Merge remote-tracking branch 'origin/wt/web-clip' into wt/legacy-move
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
# The raw/ inbox
|
||||||
|
|
||||||
|
`raw/` is an inbox: only unprocessed material sits there. Syncthing shares it
|
||||||
|
with the machines you drop files from. (hyperguild#205, #208, #209)
|
||||||
|
|
||||||
|
## What happens to a file you drop
|
||||||
|
|
||||||
|
| You drop | Result |
|
||||||
|
|---|---|
|
||||||
|
| `raw/clip.md` (no folder, no `wing:`) | extracted and filed as a source in `wiki/web-clip/sources/`; its concepts and entities update the shared `wiki/concepts/` and `wiki/entities/` pages; the original moves to `raw/processed/<date>/`, so `raw/` is empty again |
|
||||||
|
| `raw/<wing>/clip.md` or `wing: <wing>` in the frontmatter | the same, with the source in `wiki/<wing>/sources/`. The wing must already exist; a typo is parked, never created. This is your deliberate choice and is not second-guessed |
|
||||||
|
| a note whose text matches the client-content pattern, with no folder | parked in `raw/needs-review/`, not extracted |
|
||||||
|
| a folder or `wing:` naming an unknown, reserved or non-ASCII wing, or two disagreeing signals | parked in `raw/needs-review/` |
|
||||||
|
| a note over the size cap, or one that cannot be archived | stays in `raw/`, reported once, not retried until it changes |
|
||||||
|
| a note the model returned nothing usable for (no source page could be written) | fails: the note is kept in `raw/failed/` and the reason is in `log.md`; it is never archived as processed |
|
||||||
|
|
||||||
|
Concepts and entities are shared across wings because their slug is the graph's
|
||||||
|
identity. A source whose slug is taken gets a wing-qualified one (`notes-homelab`).
|
||||||
|
|
||||||
|
## Parked notes
|
||||||
|
|
||||||
|
`brain_pending` lists them with `queue: needs-review`, a proposed wing and the
|
||||||
|
reason. To act on one: `brain_promote` / `brain_reject` (pass `queue` when the
|
||||||
|
same name also sits in the inbox), or move the file into `raw/<wing>/` to have it
|
||||||
|
extracted. Parked pdf/txt files are listed but can only be moved or rejected.
|
||||||
|
|
||||||
|
## Switching it on
|
||||||
|
|
||||||
|
Routing is off by default (the retired layout is used). Set on the ingestion
|
||||||
|
deployment:
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `INGEST_WING_ROUTING` | `1`/`true` enables the rules above; unset or `0`/`false` is off; any other value stops the server at startup |
|
||||||
|
| `INGEST_DEFAULT_WING` | wing for unsignalled notes (default `web-clip`; must be a plain name, not `sources`/`concepts`/`entities`/`wiki`) |
|
||||||
|
| `INGEST_CLIENT_BLOCK` | case-insensitive regexp over note text; a match parks an unsignalled note. Falls back to `CLAUDE_INGEST_CLIENT_BLOCK`. Matched after folding fullwidth letters, removing zero-width characters, normalising dashes and removing whitespace. Patterns with spaces, `\\s` or `\\b` work (whitespace is collapsed in one view and removed in another). It catches what it names and nothing else: leetspeak, punctuation splits (`a.c.m.e`), encodings and homoglyphs outside a small Cyrillic/Greek table are not caught. Large notes are checked in 1 MiB chunks |
|
||||||
|
|
||||||
|
A bad default or pattern stops the server at startup. A wing created while the
|
||||||
|
server runs is usable without a restart.
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- The client filter works on 1 MiB chunks that overlap by 4 KiB. A match that can
|
||||||
|
span more than 4 KiB (crafted padding between two words of a multi-word
|
||||||
|
pattern, exactly at a chunk edge) is not guaranteed to be found; this affects
|
||||||
|
notes over 1 MiB only. On a 64 MiB note the check takes about ten seconds.
|
||||||
|
- A pattern that matches the empty string (`.*`, `x*`) parks every unsignalled
|
||||||
|
note. That fails closed and only the operator's own configuration can do it.
|
||||||
|
|
||||||
|
- A deliberate folder into a sensitive wing places the note there, and the shared
|
||||||
|
concept/entity pages then cite that source by title. Client material does not
|
||||||
|
belong in the brain at all; the pattern only guards the unsignalled lane.
|
||||||
|
- A merge into a shared page refreshes `last_updated` and `origin` to the latest
|
||||||
|
ingest; the first wing's attribution is not kept.
|
||||||
|
- A marker file (`<note>.processed`) is only written when a processed original
|
||||||
|
cannot be removed. One at least as new as its note holds the note (and says
|
||||||
|
so once); an older or future-dated one is ignored and removed.
|
||||||
@@ -150,6 +150,21 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envFlag reads a boolean switch. Unset is off; a value strconv.ParseBool does
|
||||||
|
// not understand is an error, so a typo ("ture") cannot silently leave a
|
||||||
|
// safety-relevant switch off.
|
||||||
|
func envFlag(key string) (bool, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
b, err := strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("%s=%q: want 1, 0, true or false", key, v)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildNotifier returns the ntfy notifier, or a nil interface when
|
// buildNotifier returns the ntfy notifier, or a nil interface when
|
||||||
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
|
||||||
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
// DegradingSink/Reconcile and the governance observer skip it cleanly.
|
||||||
@@ -405,7 +420,12 @@ func main() {
|
|||||||
// the layout this flag exists to leave. The interface stays nil when off
|
// the layout this flag exists to leave. The interface stays nil when off
|
||||||
// (a nil *Router inside it would not be nil).
|
// (a nil *Router inside it would not be nil).
|
||||||
var placer wing.Placer
|
var placer wing.Placer
|
||||||
if os.Getenv("INGEST_WING_ROUTING") == "1" {
|
routing, ferr := envFlag("INGEST_WING_ROUTING")
|
||||||
|
if ferr != nil {
|
||||||
|
logger.Error("INGEST_WING_ROUTING", "err", ferr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if routing {
|
||||||
pattern := os.Getenv("INGEST_CLIENT_BLOCK")
|
pattern := os.Getenv("INGEST_CLIENT_BLOCK")
|
||||||
if pattern == "" {
|
if pattern == "" {
|
||||||
pattern = os.Getenv("CLAUDE_INGEST_CLIENT_BLOCK")
|
pattern = os.Getenv("CLAUDE_INGEST_CLIENT_BLOCK")
|
||||||
|
|||||||
@@ -100,3 +100,32 @@ func TestClaudeSinkDoesNotWriteThroughASymlinkedArchiveDir(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnvFlag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, set string
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"unset is off", "", false, false},
|
||||||
|
{"1", "1", true, false},
|
||||||
|
{"true", "true", true, false},
|
||||||
|
{"TRUE", "TRUE", true, false},
|
||||||
|
{"0", "0", false, false},
|
||||||
|
{"false", "false", false, false},
|
||||||
|
{"a typo is an error, not silently off", "ture", false, true},
|
||||||
|
{"yes is not a bool here", "yes", false, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Setenv("TEST_ENV_FLAG", tt.set)
|
||||||
|
got, err := envFlag("TEST_ENV_FLAG")
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -69,6 +69,6 @@ require (
|
|||||||
golang.org/x/crypto v0.51.0 // indirect
|
golang.org/x/crypto v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.45.0
|
golang.org/x/sys v0.45.0
|
||||||
golang.org/x/text v0.39.0 // indirect
|
golang.org/x/text v0.39.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -617,6 +617,7 @@ type promoteRequest struct {
|
|||||||
Wing string `json:"wing"`
|
Wing string `json:"wing"`
|
||||||
Hall string `json:"hall"`
|
Hall string `json:"hall"`
|
||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
|
Queue string `json:"queue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
||||||
@@ -630,7 +631,7 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Field by field: this door carries no principal, so the promoted note is
|
// Field by field: this door carries no principal, so the promoted note is
|
||||||
// stamped origin: unknown and the body cannot set provenance (#145).
|
// stamped origin: unknown and the body cannot set provenance (#145).
|
||||||
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
||||||
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug,
|
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug, Queue: req.Queue,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestListPending_ShowsParkedNotesWithTheirProposal(t *testing.T) {
|
|||||||
assert.NotContains(t, got[0].Excerpt, "proposed_wing", "excerpt is body, not frontmatter")
|
assert.NotContains(t, got[0].Excerpt, "proposed_wing", "excerpt is body, not frontmatter")
|
||||||
|
|
||||||
assert.Equal(t, "fresh.md", got[1].Filename)
|
assert.Equal(t, "fresh.md", got[1].Filename)
|
||||||
assert.Empty(t, got[1].Queue, "a top-level note has no queue label")
|
assert.Equal(t, QueueInbox, got[1].Queue)
|
||||||
assert.Empty(t, got[1].ProposedWing)
|
assert.Empty(t, got[1].ProposedWing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type PendingNote struct {
|
|||||||
// (hyperguild#205): it was not placed automatically, and the proposal says
|
// (hyperguild#205): it was not placed automatically, and the proposal says
|
||||||
// what the resolver would have done and why it did not. Approve by moving
|
// what the resolver would have done and why it did not. Approve by moving
|
||||||
// the note into raw/<wing>/ and the watcher ingests it there.
|
// the note into raw/<wing>/ and the watcher ingests it there.
|
||||||
Queue string `json:"queue,omitempty"`
|
Queue string `json:"queue"`
|
||||||
ProposedWing string `json:"proposed_wing,omitempty"`
|
ProposedWing string `json:"proposed_wing,omitempty"`
|
||||||
ProposalReason string `json:"proposal_reason,omitempty"`
|
ProposalReason string `json:"proposal_reason,omitempty"`
|
||||||
ProposalConfidence float64 `json:"proposal_confidence,omitempty"`
|
ProposalConfidence float64 `json:"proposal_confidence,omitempty"`
|
||||||
@@ -49,7 +49,7 @@ func ListPending(brainDir string) ([]PendingNote, error) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = root.Close() }()
|
defer func() { _ = root.Close() }()
|
||||||
|
|
||||||
out, err := listNotes(root, "")
|
out, err := listNotes(root, QueueInbox)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func ListPending(brainDir string) ([]PendingNote, error) {
|
|||||||
// or note there is refused exactly like at the top level (#153).
|
// or note there is refused exactly like at the top level (#153).
|
||||||
if sub, serr := root.OpenRoot("needs-review"); serr == nil {
|
if sub, serr := root.OpenRoot("needs-review"); serr == nil {
|
||||||
defer func() { _ = sub.Close() }()
|
defer func() { _ = sub.Close() }()
|
||||||
parked, perr := listNotes(sub, "needs-review")
|
parked, perr := listNotes(sub, QueueNeedsReview)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return nil, perr
|
return nil, perr
|
||||||
}
|
}
|
||||||
@@ -76,11 +76,37 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
|
|||||||
}
|
}
|
||||||
out := make([]PendingNote, 0, len(entries))
|
out := make([]PendingNote, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
isMD := strings.HasSuffix(e.Name(), ".md")
|
||||||
|
// A parked pdf or txt is listed too (the watcher parks any supported
|
||||||
|
// file); at the top level only markdown ever was.
|
||||||
|
parkedOther := queue == QueueNeedsReview && (strings.HasSuffix(e.Name(), ".txt") || strings.HasSuffix(e.Name(), ".pdf"))
|
||||||
|
if !e.Type().IsRegular() || (!isMD && !parkedOther) || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||||
continue // directories, and symlinks whose target may be anywhere (#153)
|
continue // directories, and symlinks whose target may be anywhere (#153)
|
||||||
}
|
}
|
||||||
|
if !isMD {
|
||||||
|
info, ierr := root.Lstat(e.Name())
|
||||||
|
if ierr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, PendingNote{
|
||||||
|
Filename: e.Name(), CreatedAt: info.ModTime().UTC().Format(time.RFC3339), SizeBytes: info.Size(),
|
||||||
|
Excerpt: "(not markdown; the wing proposal is in log.md. Move the file into raw/<wing>/ to ingest it.)",
|
||||||
|
Queue: queue,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
raw, info, readErr := readRawNote(root, e.Name())
|
raw, info, readErr := readRawNote(root, e.Name())
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
|
// Not skipped: a note that cannot be read (over the cap, say) must
|
||||||
|
// not silently vanish from the queue it is waiting in.
|
||||||
|
fi, ierr := root.Lstat(e.Name())
|
||||||
|
if ierr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, PendingNote{
|
||||||
|
Filename: e.Name(), CreatedAt: fi.ModTime().UTC().Format(time.RFC3339), SizeBytes: fi.Size(),
|
||||||
|
Excerpt: "(cannot be read: " + readErr.Error() + "; promote and reject cannot act on it: delete or move it by hand)", Queue: queue,
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fm, body := parseFrontmatter(string(raw))
|
fm, body := parseFrontmatter(string(raw))
|
||||||
@@ -95,7 +121,7 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
|
|||||||
Excerpt: excerpt(body, 200),
|
Excerpt: excerpt(body, 200),
|
||||||
Queue: queue,
|
Queue: queue,
|
||||||
}
|
}
|
||||||
if queue != "" {
|
if queue == QueueNeedsReview {
|
||||||
n.ProposedWing = fm.get("proposed_wing")
|
n.ProposedWing = fm.get("proposed_wing")
|
||||||
n.ProposalReason = unquoteScalar(fm.get("proposal_reason"))
|
n.ProposalReason = unquoteScalar(fm.get("proposal_reason"))
|
||||||
n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64)
|
n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64)
|
||||||
@@ -111,6 +137,9 @@ type PromoteOptions struct {
|
|||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
Slug string // optional; defaults to Filename minus date prefix + .md
|
Slug string // optional; defaults to Filename minus date prefix + .md
|
||||||
|
// Queue names which listing the note is in: QueueInbox or QueueNeedsReview.
|
||||||
|
// Empty is accepted only when the name exists in exactly one of them.
|
||||||
|
Queue string
|
||||||
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
||||||
// note's own frontmatter claims are not provenance (hyperguild#145).
|
// note's own frontmatter claims are not provenance (hyperguild#145).
|
||||||
Origin string
|
Origin string
|
||||||
@@ -133,17 +162,26 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(base, ".md") {
|
||||||
|
return "", fmt.Errorf("only a markdown note can be promoted; move %q into raw/<wing>/ to ingest it", base)
|
||||||
|
}
|
||||||
slug := opts.Slug
|
slug := opts.Slug
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
||||||
}
|
}
|
||||||
|
// The retired layout's directory names are not wings: a note promoted into
|
||||||
|
// "sources" would land in wiki/sources/<hall>/, inside the retired dir.
|
||||||
|
switch brain.Sanitise(opts.Wing) {
|
||||||
|
case "sources", "concepts", "entities", "wiki":
|
||||||
|
return "", fmt.Errorf("%q is not a wing", opts.Wing)
|
||||||
|
}
|
||||||
// NotePath validates hall + wing + slug; do this before reading anything.
|
// NotePath validates hall + wing + slug; do this before reading anything.
|
||||||
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, srcRel, err := readRawSource(brainDir, base)
|
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -198,6 +236,7 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
type RejectOptions struct {
|
type RejectOptions struct {
|
||||||
Filename string // basename in brain/raw/
|
Filename string // basename in brain/raw/
|
||||||
Reason string // optional
|
Reason string // optional
|
||||||
|
Queue string // as PromoteOptions.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// RejectNote records a human decision not to promote a brain/raw/ note,
|
// RejectNote records a human decision not to promote a brain/raw/ note,
|
||||||
@@ -220,7 +259,7 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, srcRel, err := readRawSource(brainDir, base)
|
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -232,15 +271,19 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
|
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
content := string(raw)
|
||||||
|
if strings.HasSuffix(base, ".md") {
|
||||||
fm, body := parseFrontmatter(string(raw))
|
fm, body := parseFrontmatter(string(raw))
|
||||||
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
|
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
|
||||||
if opts.Reason != "" {
|
if opts.Reason != "" {
|
||||||
fm.set("reject_reason", opts.Reason)
|
fm.set("reject_reason", opts.Reason)
|
||||||
}
|
}
|
||||||
|
content = fm.render() + body
|
||||||
|
} // a pdf or txt is moved byte for byte: prepending frontmatter would alter it
|
||||||
|
|
||||||
// Write-then-delete: the source survives any write failure. Both go
|
// Write-then-delete: the source survives any write failure. Both go
|
||||||
// through the brain root (hyperguild#188).
|
// through the brain root (hyperguild#188).
|
||||||
if err := createBrainFile(brainDir, dest, []byte(fm.render()+body)); err != nil {
|
if err := createBrainFile(brainDir, dest, []byte(content)); err != nil {
|
||||||
return "", fmt.Errorf("write rejected note: %w", err)
|
return "", fmt.Errorf("write rejected note: %w", err)
|
||||||
}
|
}
|
||||||
if err := removeBrainFile(brainDir, src); err != nil {
|
if err := removeBrainFile(brainDir, src); err != nil {
|
||||||
@@ -263,7 +306,10 @@ func excerpt(s string, n int) string {
|
|||||||
|
|
||||||
// readRawSource reads a pending note for promote/reject through the raw root,
|
// readRawSource reads a pending note for promote/reject through the raw root,
|
||||||
// refusing symlinks and a symlinked raw/ (hyperguild#153).
|
// refusing symlinks and a symlinked raw/ (hyperguild#153).
|
||||||
func readRawSource(brainDir, base string) ([]byte, string, error) {
|
func readRawSource(brainDir, base, queue string) ([]byte, string, error) {
|
||||||
|
if queue != "" && queue != QueueInbox && queue != QueueNeedsReview {
|
||||||
|
return nil, "", fmt.Errorf("unknown queue %q (want %q or %q)", queue, QueueInbox, QueueNeedsReview)
|
||||||
|
}
|
||||||
root, _, err := OpenRawRoot(brainDir)
|
root, _, err := OpenRawRoot(brainDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -272,34 +318,49 @@ func readRawSource(brainDir, base string) ([]byte, string, error) {
|
|||||||
return nil, "", fmt.Errorf("read source: %w", err)
|
return nil, "", fmt.Errorf("read source: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = root.Close() }()
|
defer func() { _ = root.Close() }()
|
||||||
raw, _, err := readRawNote(root, base)
|
|
||||||
if err == nil {
|
var topRaw, parkedRaw []byte
|
||||||
return raw, "raw/" + base, nil
|
var topErr, parkedErr error
|
||||||
|
if queue != QueueNeedsReview {
|
||||||
|
topRaw, _, topErr = readRawNote(root, base)
|
||||||
|
}
|
||||||
|
if queue != QueueInbox {
|
||||||
|
// The sub-root refuses a symlinked directory like OpenRawRoot does.
|
||||||
|
if sub, serr := root.OpenRoot(parkedDir); serr != nil {
|
||||||
|
parkedErr = serr
|
||||||
|
} else {
|
||||||
|
parkedRaw, _, parkedErr = readRawNote(sub, base)
|
||||||
|
_ = sub.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
haveTop := queue != QueueNeedsReview && topErr == nil
|
||||||
|
haveParked := queue != QueueInbox && parkedErr == nil
|
||||||
|
switch {
|
||||||
|
case haveTop && haveParked:
|
||||||
|
return nil, "", fmt.Errorf("%q is in both raw/ and raw/%s/: name the queue (%q or %q)", base, parkedDir, QueueInbox, QueueNeedsReview)
|
||||||
|
case haveTop:
|
||||||
|
return topRaw, "raw/" + base, nil
|
||||||
|
case haveParked:
|
||||||
|
return parkedRaw, "raw/" + parkedDir + "/" + base, nil
|
||||||
|
}
|
||||||
|
for _, e := range []error{topErr, parkedErr} {
|
||||||
|
if e != nil && !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return nil, "", fmt.Errorf("read source: %w", e)
|
||||||
}
|
}
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, "", fmt.Errorf("read source: %w", err)
|
|
||||||
}
|
}
|
||||||
// brain_pending also lists notes parked in raw/needs-review/ under their
|
|
||||||
// bare filename (hyperguild#208); the top level wins on a name clash. The
|
|
||||||
// sub-root refuses a symlinked directory like OpenRawRoot does.
|
|
||||||
sub, serr := root.OpenRoot(parkedDir)
|
|
||||||
if serr != nil {
|
|
||||||
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
}
|
}
|
||||||
defer func() { _ = sub.Close() }()
|
|
||||||
raw, _, err = readRawNote(sub, base)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("read source: %w", err)
|
|
||||||
}
|
|
||||||
return raw, "raw/" + parkedDir + "/" + base, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parkedDir is where the watcher parks notes it could not place.
|
// parkedDir is where the watcher parks notes it could not place.
|
||||||
const parkedDir = "needs-review"
|
const parkedDir = "needs-review"
|
||||||
|
|
||||||
|
// The two review queues brain_pending lists. promote and reject take one of
|
||||||
|
// them (or neither, when the name is unambiguous).
|
||||||
|
const (
|
||||||
|
QueueInbox = "inbox" // raw/<name>
|
||||||
|
QueueNeedsReview = "needs-review" // raw/needs-review/<name>
|
||||||
|
)
|
||||||
|
|
||||||
// unquoteScalar undoes the Go-style double quoting the watcher writes around a
|
// unquoteScalar undoes the Go-style double quoting the watcher writes around a
|
||||||
// free-text value (strconv.Quote keeps it on one line); a bare value is
|
// free-text value (strconv.Quote keeps it on one line); a bare value is
|
||||||
// returned as is.
|
// returned as is.
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// brain_pending lists a top-level note and a parked note of the same name as two
|
||||||
|
// entries with the same filename. Acting on "a.md" must not silently pick one.
|
||||||
|
func TestPromoteNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
|
||||||
|
writeParked(t, dir, "a.md", parkedNote)
|
||||||
|
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "both")
|
||||||
|
assert.Contains(t, err.Error(), "queue")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/a.md"))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was promoted")
|
||||||
|
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview})
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, rel))
|
||||||
|
assert.Contains(t, string(got), "Body of the parked note.")
|
||||||
|
_, statErr = os.Stat(filepath.Join(dir, "raw", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "the top-level note of the same name is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_QueueInboxSelectsTheTopLevelNote(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
|
||||||
|
writeParked(t, dir, "a.md", parkedNote)
|
||||||
|
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueInbox})
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, rel))
|
||||||
|
assert.Contains(t, string(got), "Top-level body.")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "the parked note is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_ANamedQueueThatDoesNotHoldTheNoteIsAnError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview})
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, err = PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: "elsewhere"})
|
||||||
|
assert.Error(t, err, "an unknown queue name is refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
writeParked(t, dir, "a.md", parkedNote)
|
||||||
|
|
||||||
|
_, err := RejectNote(dir, RejectOptions{Filename: "a.md"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = RejectNote(dir, RejectOptions{Filename: "a.md", Queue: QueueNeedsReview})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "the top-level note is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_NeverIntoARetiredLayoutName(t *testing.T) {
|
||||||
|
for _, w := range []string{"sources", "concepts", "entities", "wiki"} {
|
||||||
|
t.Run(w, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: w, Hall: "facts"})
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki", w))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPending_LabelsEveryEntryWithItsQueue(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "fresh.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nx\n")
|
||||||
|
writeParked(t, dir, "p.md", parkedNote)
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
queues := map[string]string{}
|
||||||
|
for _, n := range got {
|
||||||
|
queues[n.Filename] = n.Queue
|
||||||
|
}
|
||||||
|
assert.Equal(t, QueueInbox, queues["fresh.md"])
|
||||||
|
assert.Equal(t, QueueNeedsReview, queues["p.md"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parked notes that are not markdown (a pdf, a txt) were invisible: the only
|
||||||
|
// trace was a log.md line.
|
||||||
|
func TestListPending_ShowsParkedNonMarkdownFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF-1.4 binary"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "plain.txt"), []byte("some text"), 0o644))
|
||||||
|
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
names := map[string]PendingNote{}
|
||||||
|
for _, n := range got {
|
||||||
|
names[n.Filename] = n
|
||||||
|
}
|
||||||
|
require.Contains(t, names, "paper.pdf")
|
||||||
|
require.Contains(t, names, "plain.txt")
|
||||||
|
assert.Equal(t, QueueNeedsReview, names["paper.pdf"].Queue)
|
||||||
|
assert.Contains(t, names["paper.pdf"].Excerpt, "proposal", "points the reader at where the proposal is")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_OnlyMarkdownCanBePromoted(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF binary"), 0o644))
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "paper.pdf", Wing: "homelab", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "markdown")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "paper.pdf"))
|
||||||
|
assert.NoError(t, statErr, "refused, untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// brain.Sanitise normalises case, trailing dots and separators; the reserved-name
|
||||||
|
// check must look at the normalised name or "Sources" and "sources." walk past it.
|
||||||
|
func TestPromoteNote_ReservedWingNamesAreRefusedInEveryDisguise(t *testing.T) {
|
||||||
|
for _, w := range []string{"Sources", "sources.", "SOURCES ", "Entities/", "concepts\\", " wiki "} {
|
||||||
|
t.Run(w, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: w, Hall: "facts"})
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "refused, untouched")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject moves the bytes; it must not prepend frontmatter to a pdf or txt.
|
||||||
|
func TestRejectNote_AParkedNonMarkdownFileIsMovedUntouched(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pdf := "%PDF-1.4\r\n\x00\xff binary \r\n"
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte(pdf), 0o644))
|
||||||
|
|
||||||
|
rel, err := RejectNote(dir, RejectOptions{Filename: "paper.pdf", Reason: "not relevant"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, rel))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, pdf, string(got), "byte for byte")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "paper.pdf"))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadRawNote_IsBoundedByTheCap(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "big.md"), []byte(strings.Repeat("x", 200)), 0o644))
|
||||||
|
root, err := os.OpenRoot(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = root.Close() }()
|
||||||
|
prev := maxRawNoteBytes
|
||||||
|
maxRawNoteBytes = 100
|
||||||
|
t.Cleanup(func() { maxRawNoteBytes = prev })
|
||||||
|
|
||||||
|
_, _, err = readRawNote(root, "big.md")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "exceeds")
|
||||||
|
}
|
||||||
|
|
||||||
|
type endless struct{ n int64 }
|
||||||
|
|
||||||
|
func (e *endless) Read(p []byte) (int, error) {
|
||||||
|
if e.n > 8<<20 { // a regression fails the test instead of exhausting memory
|
||||||
|
return 0, errors.New("endless reader drained")
|
||||||
|
}
|
||||||
|
e.n += int64(len(p))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadCapped_StopsAtTheCap(t *testing.T) {
|
||||||
|
e := &endless{}
|
||||||
|
_, err := readCapped(e, 1000)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "exceeds")
|
||||||
|
assert.LessOrEqual(t, e.n, int64(1000+1+32*1024), "pulled far more than the cap: %d", e.n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An .md over the read cap used to vanish from the listing (readRawNote failed
|
||||||
|
// and the entry was skipped). It must stay visible, with the reason.
|
||||||
|
func TestListPending_AnUnreadableNoteIsListedWithTheReason(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "huge.md"), []byte(strings.Repeat("x", 200)), 0o644))
|
||||||
|
prev := maxRawNoteBytes
|
||||||
|
maxRawNoteBytes = 100
|
||||||
|
t.Cleanup(func() { maxRawNoteBytes = prev })
|
||||||
|
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.Equal(t, "huge.md", got[0].Filename)
|
||||||
|
assert.Contains(t, got[0].Excerpt, "cannot be read")
|
||||||
|
assert.Contains(t, got[0].Excerpt, "exceeds")
|
||||||
|
}
|
||||||
@@ -48,19 +48,6 @@ func TestRejectNote_ActsOnANoteParkedInNeedsReview(t *testing.T) {
|
|||||||
assert.Empty(t, pending, "a rejected note is not re-listed")
|
assert.Empty(t, pending, "a rejected note is not re-listed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPromoteNote_ATopLevelNoteWinsOverAParkedOneOfTheSameName(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
writeRaw(t, dir, "same.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
|
|
||||||
writeParked(t, dir, "same.md", parkedNote)
|
|
||||||
|
|
||||||
_, err := PromoteNote(dir, PromoteOptions{Filename: "same.md", Wing: "homelab", Hall: "facts"})
|
|
||||||
require.NoError(t, err)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/same.md"))
|
|
||||||
assert.Contains(t, string(got), "Top-level body.")
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "same.md"))
|
|
||||||
assert.NoError(t, statErr, "the parked note of the same name is left alone")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromoteNote_ASymlinkedNeedsReviewIsNotRead(t *testing.T) {
|
func TestPromoteNote_ASymlinkedNeedsReviewIsNotRead(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
outside := t.TempDir()
|
outside := t.TempDir()
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ func OpenRawRoot(brainDir string) (*os.Root, string, error) {
|
|||||||
return r, p, nil
|
return r, p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxRawNoteBytes bounds what promote, reject and the pending listing read from
|
||||||
|
// one raw/ entry. A variable so a test can lower it.
|
||||||
|
var maxRawNoteBytes int64 = 64 << 20
|
||||||
|
|
||||||
// readRawNote reads one raw/ entry by basename through the root, refusing
|
// readRawNote reads one raw/ entry by basename through the root, refusing
|
||||||
// anything that is not a regular file.
|
// anything that is not a regular file.
|
||||||
//
|
//
|
||||||
@@ -59,9 +63,22 @@ func readRawNote(r *os.Root, base string) ([]byte, os.FileInfo, error) {
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
b, err := io.ReadAll(f)
|
b, err := readCapped(f, maxRawNoteBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, fmt.Errorf("%s: %w", base, err)
|
||||||
}
|
}
|
||||||
return b, fi, nil
|
return b, fi, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readCapped reads r but never more than max+1 bytes, so an oversize or growing
|
||||||
|
// file costs at most that much memory.
|
||||||
|
func readCapped(r io.Reader, max int64) ([]byte, error) {
|
||||||
|
b, err := io.ReadAll(io.LimitReader(r, max+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(b)) > max {
|
||||||
|
return nil, fmt.Errorf("note exceeds %d bytes", max)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"wing": str("target wing, e.g. jepa-fx"),
|
"wing": str("target wing, e.g. jepa-fx"),
|
||||||
"hall": enum("target hall", halls...),
|
"hall": enum("target hall", halls...),
|
||||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||||
|
"queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,6 +103,7 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"inputSchema": schema([]string{"filename"}, map[string]any{
|
"inputSchema": schema([]string{"filename"}, map[string]any{
|
||||||
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
||||||
"reason": str("optional; why this isn't worth promoting"),
|
"reason": str("optional; why this isn't worth promoting"),
|
||||||
|
"queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
noteToolDescriptors()[0],
|
noteToolDescriptors()[0],
|
||||||
@@ -383,6 +385,7 @@ type brainPromoteArgs struct {
|
|||||||
Wing string `json:"wing"`
|
Wing string `json:"wing"`
|
||||||
Hall string `json:"hall"`
|
Hall string `json:"hall"`
|
||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
|
Queue string `json:"queue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
||||||
@@ -395,7 +398,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
}
|
}
|
||||||
origin, writtenBy := provenanceFrom(ctx)
|
origin, writtenBy := provenanceFrom(ctx)
|
||||||
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
||||||
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug, Queue: a.Queue,
|
||||||
Origin: origin, WrittenBy: writtenBy,
|
Origin: origin, WrittenBy: writtenBy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -408,6 +411,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
type brainRejectArgs struct {
|
type brainRejectArgs struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Queue string `json:"queue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
||||||
@@ -418,7 +422,7 @@ func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawM
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason})
|
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason, Queue: a.Queue})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,3 +431,28 @@ func TestBrainRejectMissingSourceErrors(t *testing.T) {
|
|||||||
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
||||||
require.NotNil(t, resp["error"])
|
require.NotNil(t, resp["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// brain_pending lists two entries for one name when a top-level note and a
|
||||||
|
// parked one share it; the tool must let the caller say which it means.
|
||||||
|
func TestBrainPromoteAndRejectTakeTheQueueOfAnAmbiguousName(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
raw := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(raw, "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "a.md"), []byte("---\ncreated_at: 2026-10-20T00:00:00Z\n---\nTOP\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "needs-review", "a.md"), []byte("---\nproposed_wing: homelab\n---\nPARKED\n"), 0o644))
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts"})
|
||||||
|
require.NotNil(t, resp["error"], "an ambiguous name is refused, not guessed")
|
||||||
|
|
||||||
|
resp = toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts", "queue": "needs-review"})
|
||||||
|
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, "wiki/homelab/facts/a.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "PARKED")
|
||||||
|
|
||||||
|
resp = toolCall(t, srv, "brain_reject", map[string]any{"filename": "a.md", "queue": "inbox", "reason": "no"})
|
||||||
|
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||||
|
_, err = os.Stat(filepath.Join(brainDir, "raw/rejected/a.md"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func BuildPagesIn(wing string, rawPages []RawPage, sourceSlug, date string) ([]w
|
|||||||
for _, rp := range rawPages {
|
for _, rp := range rawPages {
|
||||||
// The type becomes a directory under wiki/; "../raw" wrote outside it
|
// The type becomes a directory under wiki/; "../raw" wrote outside it
|
||||||
// (PR #147 review). Only a plain lower-case name is a page type.
|
// (PR #147 review). Only a plain lower-case name is a page type.
|
||||||
if !validPageType.MatchString(rp.Type) {
|
if !validPageType.MatchString(rp.Type) || (wing != "" && !layoutType(rp.Type)) {
|
||||||
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
|
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,11 @@ func BuildPagesIn(wing string, rawPages []RawPage, sourceSlug, date string) ([]w
|
|||||||
return out, warnings
|
return out, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// layoutType reports whether a wing-mode page type has a place in the layout.
|
||||||
|
// The type comes from the model, so from the clip being ingested: anything else
|
||||||
|
// would name a directory (wiki/<type>/) of the model's choosing.
|
||||||
|
func layoutType(t string) bool { return t == "source" || t == "concept" || t == "entity" }
|
||||||
|
|
||||||
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||||
|
|
||||||
func computeSlug(rp RawPage, sourceSlug string) string {
|
func computeSlug(rp RawPage, sourceSlug string) string {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -52,20 +54,6 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
var inventory map[wiki.PageType][]wiki.Entry
|
var inventory map[wiki.PageType][]wiki.Entry
|
||||||
var err error
|
var err error
|
||||||
if cfg.Wing != "" {
|
if cfg.Wing != "" {
|
||||||
// A source slug is a page's graph identity: refuse to take over a slug
|
|
||||||
// that lives anywhere but this wing's own sources/ — including when
|
|
||||||
// this wing's own page of that name also exists.
|
|
||||||
slug := wiki.Slug(source)
|
|
||||||
own := "wiki/" + cfg.Wing + "/sources/" + slug + ".md"
|
|
||||||
existing, ferr := wiki.FindSlugs(brainDir, slug)
|
|
||||||
if ferr != nil {
|
|
||||||
return Result{}, fmt.Errorf("check source slug: %w", ferr)
|
|
||||||
}
|
|
||||||
for _, p := range existing {
|
|
||||||
if p != own {
|
|
||||||
return Result{}, fmt.Errorf("source slug %q is already used by %s", slug, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inventory, err = wiki.LoadWingInventory(brainDir, cfg.Wing)
|
inventory, err = wiki.LoadWingInventory(brainDir, cfg.Wing)
|
||||||
} else {
|
} else {
|
||||||
inventory, err = wiki.LoadInventory(brainDir)
|
inventory, err = wiki.LoadInventory(brainDir)
|
||||||
@@ -80,6 +68,12 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceSlug := wiki.Slug(source)
|
sourceSlug := wiki.Slug(source)
|
||||||
|
if cfg.Wing != "" {
|
||||||
|
var rerr error
|
||||||
|
if sourceSlug, rerr = resolveSourceSlug(brainDir, cfg.Wing, sourceSlug); rerr != nil {
|
||||||
|
return Result{}, rerr
|
||||||
|
}
|
||||||
|
}
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
|
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
|
||||||
@@ -120,7 +114,19 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
meta.BodyHash = bodyHash
|
meta.BodyHash = bodyHash
|
||||||
applySourceMeta(allRaw, meta)
|
applySourceMeta(allRaw, meta)
|
||||||
|
|
||||||
return buildAndWrite(ctx, cfg.Wing, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
res, err := buildAndWrite(ctx, cfg.Wing, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||||
|
if err != nil || cfg.Wing == "" {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
// In a wing every ingest must leave its source page: it is what ties the
|
||||||
|
// note to the wiki (and carries the hash the drift check reads). Without one
|
||||||
|
// nothing of the note exists, and the caller would archive it as processed.
|
||||||
|
for _, p := range res.Pages {
|
||||||
|
if isSourcePath(p) {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, fmt.Errorf("no source page was written for %q; nothing of the note was ingested (%s)", source, strings.Join(res.Warnings, "; "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
||||||
@@ -151,6 +157,21 @@ func buildAndWrite(ctx context.Context, wing string, rawPages []RawPage, sourceS
|
|||||||
warnings = append(warnings, linkWarnings...)
|
warnings = append(warnings, linkWarnings...)
|
||||||
withRefs := injectSourceRefs(canonicalized, inventory, brainDir)
|
withRefs := injectSourceRefs(canonicalized, inventory, brainDir)
|
||||||
merged := mergeAll(withRefs)
|
merged := mergeAll(withRefs)
|
||||||
|
if wing != "" {
|
||||||
|
var clashWarnings []string
|
||||||
|
var cerr error
|
||||||
|
merged, clashWarnings, cerr = dropSlugClashes(brainDir, merged)
|
||||||
|
if cerr != nil {
|
||||||
|
return Result{}, cerr
|
||||||
|
}
|
||||||
|
warnings = append(warnings, clashWarnings...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wing != "" {
|
||||||
|
// The source page carries source_hash, which the drift check trusts: it
|
||||||
|
// goes last, so a batch that fails midway leaves nothing to hide behind.
|
||||||
|
sort.SliceStable(merged, func(i, j int) bool { return !isSourcePath(merged[i].Path) && isSourcePath(merged[j].Path) })
|
||||||
|
}
|
||||||
|
|
||||||
var written []string
|
var written []string
|
||||||
for _, page := range merged {
|
for _, page := range merged {
|
||||||
@@ -333,3 +354,150 @@ func loadSchema(brainDir string) string {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(string(b))
|
return strings.TrimSpace(string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveSourceSlug returns the slug this wing's source page uses. A slug is a
|
||||||
|
// page's graph identity and must be unique brain-wide, but two wings dropping a
|
||||||
|
// file of the same name (notes.md, readme.md) is ordinary, so when the plain
|
||||||
|
// slug is taken by anything but this wing's own source page the wing name is
|
||||||
|
// appended (talk-homelab, then talk-homelab-2, ...). The choice is stable: a
|
||||||
|
// re-ingest finds the page it made, because that page is "its own".
|
||||||
|
func resolveSourceSlug(brainDir, wing, base string) (string, error) {
|
||||||
|
for n := 1; n <= 100; n++ {
|
||||||
|
cand := base
|
||||||
|
switch n {
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
cand = base + "-" + wing
|
||||||
|
default:
|
||||||
|
cand = fmt.Sprintf("%s-%s-%d", base, wing, n-1)
|
||||||
|
}
|
||||||
|
existing, err := wiki.FindSlugs(brainDir, cand)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("check source slug: %w", err)
|
||||||
|
}
|
||||||
|
own := "wiki/" + wing + "/sources/" + cand + ".md"
|
||||||
|
free, hasOwn := true, false
|
||||||
|
for _, p := range existing {
|
||||||
|
if p == own {
|
||||||
|
hasOwn = true
|
||||||
|
} else {
|
||||||
|
free = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The wing's own page is its identity: keep using it even if some other
|
||||||
|
// page shares the slug (that other page is the one dropSlugClashes skips).
|
||||||
|
if hasOwn {
|
||||||
|
return cand, nil
|
||||||
|
}
|
||||||
|
if free {
|
||||||
|
return cand, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no free source slug for %q in wing %q", base, wing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropSlugClashes removes, from the pages about to be written in a wing, any
|
||||||
|
// page whose slug belongs to a different page. A slug is the graph identity, so
|
||||||
|
// two pages must not share one; and a page that shares one cannot be updated
|
||||||
|
// again, because every later ingest sees the other as its clash.
|
||||||
|
//
|
||||||
|
// Two kinds of clash, both by slug, neither failing the ingest (one extracted
|
||||||
|
// page must not be able to lock a source out of re-ingest):
|
||||||
|
// - inside the batch (a clip named notes.md whose model output also has an
|
||||||
|
// entity "Notes"): the source wins over an entity, an entity over a concept;
|
||||||
|
// - against the brain: an entity or concept whose slug is another page's. The
|
||||||
|
// SOURCE page is exempt: it is the note's own output, and its slug was
|
||||||
|
// already chosen free (or its own) by resolveSourceSlug.
|
||||||
|
//
|
||||||
|
// A page's own path is never a clash (a shared entity merging into itself).
|
||||||
|
func dropSlugClashes(brainDir string, pages []wiki.Page) ([]wiki.Page, []string, error) {
|
||||||
|
rank := func(p wiki.Page) int {
|
||||||
|
switch {
|
||||||
|
case isSourcePath(p.Path):
|
||||||
|
return 0
|
||||||
|
case strings.HasPrefix(p.Path, "wiki/entities/"):
|
||||||
|
return 1
|
||||||
|
case strings.HasPrefix(p.Path, "wiki/concepts/"):
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
slugOf := func(p wiki.Page) string { return strings.TrimSuffix(filepath.Base(p.Path), ".md") }
|
||||||
|
|
||||||
|
// Group by slug; where several pages with different paths want one slug,
|
||||||
|
// the source wins, then a page that already exists on disk (so its update
|
||||||
|
// is not lost), then entity over concept.
|
||||||
|
bySlug := map[string][]wiki.Page{}
|
||||||
|
for _, p := range pages {
|
||||||
|
bySlug[slugOf(p)] = append(bySlug[slugOf(p)], p)
|
||||||
|
}
|
||||||
|
winner := map[string]wiki.Page{}
|
||||||
|
for slug, group := range bySlug {
|
||||||
|
best := group[0]
|
||||||
|
if distinctPaths(group) > 1 {
|
||||||
|
existing, err := wiki.FindSlugs(brainDir, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
onDisk := func(p wiki.Page) int {
|
||||||
|
for _, e := range existing {
|
||||||
|
if e == p.Path {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
for _, p := range group[1:] {
|
||||||
|
if rank(p) != rank(best) && (rank(p) == 0 || rank(best) == 0) {
|
||||||
|
if rank(p) < rank(best) {
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if onDisk(p) < onDisk(best) || (onDisk(p) == onDisk(best) && rank(p) < rank(best)) {
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
winner[slug] = best
|
||||||
|
}
|
||||||
|
|
||||||
|
var kept []wiki.Page
|
||||||
|
var warnings []string
|
||||||
|
for _, p := range pages {
|
||||||
|
slug := slugOf(p)
|
||||||
|
if w := winner[slug]; w.Path != p.Path {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("skipped %s: slug %q is also used by %s in this batch", p.Path, slug, w.Path))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rank(p) == 0 {
|
||||||
|
kept = append(kept, p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existing, err := wiki.FindSlugs(brainDir, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
clash := ""
|
||||||
|
for _, e := range existing {
|
||||||
|
if e != p.Path {
|
||||||
|
clash = e
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clash != "" {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("skipped %s: slug %q is already used by %s", p.Path, slug, clash))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, p)
|
||||||
|
}
|
||||||
|
return kept, warnings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func distinctPaths(pages []wiki.Page) int {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, p := range pages {
|
||||||
|
seen[p.Path] = true
|
||||||
|
}
|
||||||
|
return len(seen)
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ func Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []w
|
|||||||
|
|
||||||
out := make([]wiki.Page, 0, len(proposed))
|
out := make([]wiki.Page, 0, len(proposed))
|
||||||
for _, page := range proposed {
|
for _, page := range proposed {
|
||||||
|
if isWingSourcePath(page.Path) {
|
||||||
|
// In a wing the source's path is its filename-derived slug. Matching
|
||||||
|
// by TITLE would merge two different notes the model happened to
|
||||||
|
// title alike ("Home", "Untitled") into one page.
|
||||||
|
out = append(out, page)
|
||||||
|
continue
|
||||||
|
}
|
||||||
pt := pageTypeOf(page)
|
pt := pageTypeOf(page)
|
||||||
title := extractTitle(page.Content)
|
title := extractTitle(page.Content)
|
||||||
k := key{pt: pt, normalized: normalizeTitle(title)}
|
k := key{pt: pt, normalized: normalizeTitle(title)}
|
||||||
@@ -97,3 +104,9 @@ func extractTitle(content string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isWingSourcePath reports whether path is wiki/<wing>/sources/<slug>.md.
|
||||||
|
func isWingSourcePath(path string) bool {
|
||||||
|
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||||
|
return len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources"
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -92,27 +93,77 @@ func TestRun_TheSameEntityCitedFromTwoWingsIsOnePageWithBothSources(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A source slug is the graph's identity too: a second wing must not take over
|
// A source slug is the graph's identity too: a second wing must not take over
|
||||||
// the slug of a source that lives elsewhere.
|
// the slug of a page that lives elsewhere. Two wings dropping a file of the same
|
||||||
func TestRun_ASourceSlugAlreadyUsedInAnotherWingIsRefused(t *testing.T) {
|
// name (notes.md, readme.md) is ordinary, so the second source gets a distinct,
|
||||||
|
// stable slug instead of failing.
|
||||||
|
func TestRun_ASourceWhoseSlugIsTakenElsewhereGetsADistinctStableSlug(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
||||||
|
|
||||||
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body", "talk", false)
|
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body", "talk", false)
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, err.Error(), "already used")
|
assert.True(t, exists(dir, "wiki/web-clip/sources/talk.md"), "the first wing's page is untouched")
|
||||||
|
assert.True(t, exists(dir, "wiki/homelab/sources/talk-homelab.md"), "the second gets a wing-qualified slug")
|
||||||
assert.False(t, exists(dir, "wiki/homelab/sources/talk.md"))
|
assert.False(t, exists(dir, "wiki/homelab/sources/talk.md"))
|
||||||
|
|
||||||
|
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body, edited", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
files, _ := filepath.Glob(filepath.Join(dir, "wiki/homelab/sources/*.md"))
|
||||||
|
assert.Len(t, files, 1, "re-ingesting finds the same page, it does not make talk-homelab-2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_ASourceSlugAlreadyInTheRetiredLayoutIsRefused(t *testing.T) {
|
func TestRun_ASourceWhoseSlugIsTakenByARetiredPageOrASharedPageGetsADistinctSlug(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/sources"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/sources"), 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/sources/talk.md"), []byte("---\ntitle: Talk\n---\nold"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/sources/talk.md"), []byte("---\ntitle: Talk\n---\nold"), 0o644))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/other.md"), []byte("---\ntitle: Other\n---\nx"), 0o644))
|
||||||
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, err.Error(), "already used")
|
assert.True(t, exists(dir, "wiki/web-clip/sources/talk-web-clip.md"))
|
||||||
|
assert.Equal(t, "---\ntitle: Talk\n---\nold", read(t, dir, "wiki/sources/talk.md"), "the retired page is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other direction: a model-extracted entity or concept whose slug is a
|
||||||
|
// source's slug in some wing would lock that source out of re-ingest. The page
|
||||||
|
// is skipped with a warning; the ingest itself succeeds.
|
||||||
|
func TestRun_AnEntityWhoseSlugIsASourceElsewhereIsSkippedNotWritten(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", []RawPage{
|
||||||
|
{Title: "Jev", Type: "source", Subtype: "video", Content: "## Summary\n\nA clip titled Jev.\n"},
|
||||||
|
}...), dir, "body", "jev", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "another body", "other-talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists(dir, "wiki/entities/jev.md"), "the entity would take the source's slug")
|
||||||
|
assert.Contains(t, strings.Join(res.Warnings, "\n"), "jev")
|
||||||
|
|
||||||
|
_, err = Run(context.Background(), wingCfg("web-clip", []RawPage{
|
||||||
|
{Title: "Jev", Type: "source", Subtype: "video", Content: "## Summary\n\nEdited.\n"},
|
||||||
|
}...), dir, "body, edited", "jev", false)
|
||||||
|
require.NoError(t, err, "the first wing can still re-ingest its own source")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The type comes from the model, so from the clip: in a wing it may only be one
|
||||||
|
// of the three the layout has a place for.
|
||||||
|
func TestRun_WithAWingAModelChosenTypeCannotWriteOutsideTheLayout(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/alpha/facts"), 0o755))
|
||||||
|
pages := append(sourceEntityConcept(),
|
||||||
|
RawPage{Title: "Planted", Type: "sources", Content: "## x\n\nplanted\n"},
|
||||||
|
RawPage{Title: "Loose", Type: "alpha", Content: "## x\n\nloose\n"},
|
||||||
|
RawPage{Title: "Sneaky", Type: "homelab", Content: "## x\n\nsneaky\n"})
|
||||||
|
res, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists(dir, "wiki/sources/planted.md"))
|
||||||
|
assert.False(t, exists(dir, "wiki/alpha/loose.md"))
|
||||||
|
assert.False(t, exists(dir, "wiki/homelab"))
|
||||||
|
assert.Contains(t, strings.Join(res.Warnings, "\n"), "invalid type")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_ReIngestingTheSameSourceIntoItsOwnWingIsNotACollision(t *testing.T) {
|
func TestRun_ReIngestingTheSameSourceIntoItsOwnWingIsNotACollision(t *testing.T) {
|
||||||
@@ -141,16 +192,205 @@ func listAll(t *testing.T, dir string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// The first match must not hide a second one: this wing's own page of the name
|
// Title resolution can mask a wrong slug choice at the Run level, so the
|
||||||
// exists AND a page of the same name lives elsewhere.
|
// stability of the choice is pinned directly: the page this wing made earlier
|
||||||
func TestRun_ASlugUsedInTwoPlacesIsRefusedEvenWhenTheOwnPageIsOneOfThem(t *testing.T) {
|
// under the qualified slug is "its own" and is found again.
|
||||||
|
func TestResolveSourceSlug(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
for _, rel := range []string{
|
||||||
|
"wiki/web-clip/sources/talk.md",
|
||||||
|
"wiki/homelab/sources/talk-homelab.md",
|
||||||
|
"wiki/entities/kube.md",
|
||||||
|
} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, rel)), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, rel), []byte("---\ntitle: x\n---\n"), 0o644))
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name, wing, base, want string
|
||||||
|
}{
|
||||||
|
{"free slug is used as is", "homelab", "fresh", "fresh"},
|
||||||
|
{"own page under the plain slug", "web-clip", "talk", "talk"},
|
||||||
|
{"taken elsewhere: wing-qualified", "tapir", "talk", "talk-tapir"},
|
||||||
|
{"own earlier qualified page is found again", "homelab", "talk", "talk-homelab"},
|
||||||
|
{"taken by a shared page", "tapir", "kube", "kube-tapir"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := resolveSourceSlug(dir, tt.wing, tt.base)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSourceSlug_AFurtherClashGetsANumber(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, rel := range []string{"wiki/a/sources/talk.md", "wiki/entities/talk-b.md"} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, rel)), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, rel), []byte("x"), 0o644))
|
||||||
|
}
|
||||||
|
got, err := resolveSourceSlug(dir, "b", "talk")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "talk-b-2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the second review of #211: slug clashes INSIDE one batch. dropSlugClashes
|
||||||
|
// only looked at what was already on disk, so a source and an entity extracted
|
||||||
|
// together with one slug were both written, and neither could ever be updated.
|
||||||
|
func TestRun_ASourceAndAnEntityWithOneSlugInTheSameBatchWriteOnlyTheSource(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pages := []RawPage{
|
||||||
|
{Title: "Notes", Type: "source", Subtype: "note", Content: "## Summary\n\nfirst\n"},
|
||||||
|
{Title: "Notes", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity named like its source\n"},
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "body one", "notes", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists(dir, "wiki/homelab/sources/notes.md"))
|
||||||
|
assert.False(t, exists(dir, "wiki/entities/notes.md"), "two pages must not share a slug")
|
||||||
|
assert.Contains(t, strings.Join(res.Warnings, "\n"), "notes")
|
||||||
|
|
||||||
|
pages[0].Content = "## Summary\n\nfirst\n\n## Key Claims\n\n- second\n"
|
||||||
|
_, err = Run(context.Background(), wingCfg("homelab", pages...), dir, "body two, changed", "notes", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "second", "the source can still be updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_AConceptAndAnEntityWithOneSlugInTheSameBatchWriteOne(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pages := []RawPage{
|
||||||
|
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"},
|
||||||
|
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n"},
|
||||||
|
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
written := 0
|
||||||
|
for _, rel := range []string{"wiki/concepts/foo.md", "wiki/entities/foo.md"} {
|
||||||
|
if exists(dir, rel) {
|
||||||
|
written++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, written, "one slug, one page")
|
||||||
|
assert.Contains(t, strings.Join(res.Warnings, "\n"), "foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brain that already holds the bad pair (written before this fix) must not
|
||||||
|
// lock the source out: the source page is the note's own output and is never
|
||||||
|
// the one skipped.
|
||||||
|
func TestRun_AnExistingSourceEntityPairDoesNotLockTheSourceOut(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/sources"), 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/homelab/sources/notes.md"),
|
||||||
|
[]byte("---\ntitle: 'Notes'\ntype: 'note'\nwing: homelab\nhall: sources\naliases:\n - 'Notes'\n---\n\n## Summary\n\nold\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/notes.md"),
|
||||||
|
[]byte("---\ntitle: 'Notes'\naliases:\n - 'Notes'\n---\n\n## Definition\n\nstale entity\n"), 0o644))
|
||||||
|
|
||||||
|
pages := []RawPage{{Title: "Notes", Type: "source", Subtype: "note", Content: "## Summary\n\nnew text\n\n## Key Claims\n\n- fresh\n"}}
|
||||||
|
_, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "changed body", "notes", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "fresh", "the source took the update")
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/entities/notes.md"), "stale entity", "the entity is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3 of #211: a note must never leave the inbox as "processed" when nothing
|
||||||
|
// of it was written.
|
||||||
|
func TestRun_WithAWingAnIngestThatWritesNoSourcePageIsAnError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pages []RawPage
|
||||||
|
prep func(t *testing.T, dir string)
|
||||||
|
}{
|
||||||
|
{"the model returned nothing", nil, nil},
|
||||||
|
{"only pages of a type the layout has no place for", []RawPage{{Title: "Claim", Type: "claim", Content: "## x\n\ny\n"}}, nil},
|
||||||
|
{"only an entity, and its slug is taken by a source", []RawPage{{Title: "S1", Type: "entity", Subtype: "tool", Content: "## Definition\n\nx\n"}},
|
||||||
|
func(t *testing.T, dir string) {
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/web-clip/sources"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/web-clip/sources"), 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/web-clip/sources/talk.md"), []byte("---\ntitle: Talk\n---\nown"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/web-clip/sources/s1.md"), []byte("---\ntitle: 'S1'\n---\nx"), 0o644))
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
|
}},
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/talk.md"), []byte("---\ntitle: Talk\n---\nan entity"), 0o644))
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if tt.prep != nil {
|
||||||
|
tt.prep(t, dir)
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), wingCfg("web-clip", tt.pages...), dir, "a note body", "other-note", false)
|
||||||
|
require.Error(t, err, "nothing was ingested; the caller must not archive it as done")
|
||||||
|
assert.Empty(t, res.Pages)
|
||||||
|
assert.Contains(t, err.Error(), "no source page")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_WithAWingAnUnchangedSourceIsStillASkipNotAnError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, res.Skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title resolution must not merge two different notes into one source page just
|
||||||
|
// because the model titled both "Home": in a wing the source path is already
|
||||||
|
// deterministic (the filename), so the title must not redirect it.
|
||||||
|
func TestRun_WithAWingTwoNotesTheModelTitlesAlikeKeepTheirOwnSourcePages(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
home := func(body string) []RawPage {
|
||||||
|
return []RawPage{{Title: "Home", Type: "source", Subtype: "article", Content: "## Summary\n\n" + body + "\n"}}
|
||||||
|
}
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = Run(context.Background(), wingCfg("web-clip", home("note B text")...), dir, "body B", "note-b", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, exists(dir, "wiki/web-clip/sources/note-a.md"))
|
||||||
|
assert.True(t, exists(dir, "wiki/web-clip/sources/note-b.md"), "note B has its own page")
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-a.md"), "note A text")
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-b.md"), "note B text")
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, res.Skipped, "note A's hash was not overwritten by note B")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both a concept and an entity named "Foo" arrive and the CONCEPT already exists
|
||||||
|
// on disk: the page that exists wins the tie, so its source reference is added.
|
||||||
|
func TestRun_ABatchTieBetweenAConceptAndAnEntityPrefersThePageAlreadyOnDisk(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/concepts"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/concepts/foo.md"),
|
||||||
|
[]byte("---\ntitle: 'Foo'\naliases:\n - 'Foo'\n---\n\n## Definition\n\nexisting concept\n\n## Sources\n"), 0o644))
|
||||||
|
pages := []RawPage{
|
||||||
|
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n\n## Concepts Introduced or Reinforced\n\n[[Foo]]\n"},
|
||||||
|
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
|
||||||
|
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n\n## Sources\n"},
|
||||||
|
}
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists(dir, "wiki/entities/foo.md"))
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/concepts/foo.md"), "talk", "the existing concept got the new source reference")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 4 of #211: the source page carries the hash the drift check reads. If it
|
||||||
|
// is written FIRST and a later page fails to write, the next ingest of the same
|
||||||
|
// content is skipped and the rest of the batch is lost for good. Written LAST,
|
||||||
|
// a failed batch leaves no hash and is simply redone.
|
||||||
|
func TestRun_WithAWingAFailedBatchLeavesNoSourcePageToHideBehind(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// An entity path that cannot be written: a directory where the page goes.
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities/jev.md"), 0o755))
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
||||||
|
require.Error(t, err, "the entity write fails")
|
||||||
|
assert.False(t, exists(dir, "wiki/web-clip/sources/talk.md"),
|
||||||
|
"the source page (and its hash) must not exist when the batch did not complete")
|
||||||
|
|
||||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
|
require.NoError(t, os.RemoveAll(filepath.Join(dir, "wiki/entities/jev.md")))
|
||||||
require.Error(t, err)
|
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
||||||
assert.Contains(t, err.Error(), "already used")
|
require.NoError(t, err)
|
||||||
|
assert.False(t, res.Skipped, "the retry is a real ingest, not a hash skip")
|
||||||
|
assert.True(t, exists(dir, "wiki/entities/jev.md"))
|
||||||
|
assert.True(t, exists(dir, "wiki/web-clip/sources/talk.md"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,14 +392,24 @@ func topDir(rel string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// frontmatterWing reads `wing:` from a note's leading frontmatter, "" if none.
|
// frontmatterWing reads a top-level `wing:` from a note's leading frontmatter,
|
||||||
|
// "" if there is none. The block counts only if it has a real closing fence (a
|
||||||
|
// line that is exactly "---"), and an indented `wing:` belongs to some other
|
||||||
|
// mapping, not to the note.
|
||||||
func frontmatterWing(content string) string {
|
func frontmatterWing(content string) string {
|
||||||
|
content = strings.TrimPrefix(content, "\ufeff") // a byte-order mark must not hide the block
|
||||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
if !strings.HasPrefix(content, "---\n") {
|
if !strings.HasPrefix(content, "---\n") {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
block, _, _ := strings.Cut(content[4:], "\n---")
|
block, _, found := cutFence(content[4:])
|
||||||
|
if !found {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
for _, line := range strings.Split(block, "\n") {
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" {
|
if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" {
|
||||||
return strings.Trim(strings.TrimSpace(val), `"'`)
|
return strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -314,3 +314,45 @@ func TestProcessDir_ANoteThatReappearsChangedIsExtractedAgain(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, int32(2), llmCalls.Load(), "a real edit must still be picked up")
|
assert.Equal(t, int32(2), llmCalls.Load(), "a real edit must still be picked up")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFrontmatterWing(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, in, want string
|
||||||
|
}{
|
||||||
|
{"plain key", "---\nwing: radar\n---\nbody", "radar"},
|
||||||
|
{"quoted", "---\nwing: \"radar\"\n---\nbody", "radar"},
|
||||||
|
{"CRLF", "---\r\nwing: radar\r\n---\r\nbody", "radar"},
|
||||||
|
{"no frontmatter", "wing: radar\nbody", ""},
|
||||||
|
{"an indented key belongs to another mapping", "---\nmeta:\n wing: radar\n---\nbody", ""},
|
||||||
|
{"a key before a non-fence dash line is not frontmatter", "---\nwing: radar\n----\nbody", ""},
|
||||||
|
{"no closing fence", "---\nwing: radar\nbody", ""},
|
||||||
|
{"closing fence at end of input", "---\nwing: radar\n---", "radar"},
|
||||||
|
{"another key first", "---\ntitle: x\nwing: radar\n---\nbody", "radar"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, frontmatterWing(tt.in))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontmatterWing_ABOMDoesNotHideTheKey(t *testing.T) {
|
||||||
|
assert.Equal(t, "homelab", frontmatterWing("\ufeff---\nwing: homelab\n---\nbody"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note that produced nothing must not leave the inbox as "processed".
|
||||||
|
func TestProcessDir_AWingIngestThatWritesNothingFailsTheNoteVisibly(t *testing.T) {
|
||||||
|
var llmCalls atomic.Int32
|
||||||
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
|
cfg.Pipeline.Complete = func(context.Context, string, string) (string, error) { return "[]", nil }
|
||||||
|
put(t, brainDir, "empty-result.md", "A note the model returned nothing for.")
|
||||||
|
|
||||||
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
|
assert.True(t, has(brainDir, "raw/failed/empty-result.md"), "kept where a human looks")
|
||||||
|
processed, _ := filepath.Glob(filepath.Join(brainDir, "raw", "processed", "*", "*"))
|
||||||
|
assert.Empty(t, processed, "never archived as processed")
|
||||||
|
logb, err := os.ReadFile(filepath.Join(brainDir, "log.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(logb), "no source page")
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
xnorm "golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router places a note with rules only, no model (hyperguild#208):
|
// Router places a note with rules only, no model (hyperguild#208):
|
||||||
@@ -23,11 +26,24 @@ type Router struct {
|
|||||||
BrainDir string
|
BrainDir string
|
||||||
Default string
|
Default string
|
||||||
ClientBlock *regexp.Regexp // nil = no content check
|
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
|
// Resolve implements Placer. It never returns an error: anything it cannot
|
||||||
// decide safely is an Exception with the reason spelled out.
|
// decide safely is an Exception with the reason spelled out.
|
||||||
func (r Router) Resolve(_ context.Context, n Note) Decision {
|
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)
|
dir, fm := norm(n.Dir), norm(n.FrontmatterWing)
|
||||||
switch {
|
switch {
|
||||||
case dir != "" && fm != "" && dir != fm:
|
case dir != "" && fm != "" && dir != fm:
|
||||||
@@ -37,20 +53,20 @@ func (r Router) Resolve(_ context.Context, n Note) Decision {
|
|||||||
case fm != "":
|
case fm != "":
|
||||||
return r.explicit(fm, "frontmatter")
|
return r.explicit(fm, "frontmatter")
|
||||||
}
|
}
|
||||||
if r.ClientBlock != nil && r.ClientBlock.MatchString(n.Text) {
|
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")
|
return exception("", 0, "text matches the client-content marker; not defaulted into a shared wing")
|
||||||
}
|
}
|
||||||
if r.Default == "" || !plainWingName(r.Default) {
|
if r.Default == "" || !usableWingName(r.Default) {
|
||||||
return exception("", 0, "no valid default wing configured")
|
return exception("", 0, "no valid default wing configured")
|
||||||
}
|
}
|
||||||
return Decision{Wing: r.Default, Source: Default, Confidence: 1, Reason: "no wing named; default wing"}
|
return Decision{Wing: r.Default, Source: Default, Confidence: 1, Reason: "no wing named; default wing"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r Router) explicit(w, via string) Decision {
|
func (r Router) explicit(w, via string) Decision {
|
||||||
if !plainWingName(w) {
|
if !usableWingName(w) {
|
||||||
return exception("", 0, fmt.Sprintf("%s names %q, which is not a plain wing name", via, w))
|
return exception("", 0, fmt.Sprintf("%s names %q, which is not a usable wing name", via, w))
|
||||||
}
|
}
|
||||||
if w == r.Default && plainWingName(w) {
|
if w == r.Default {
|
||||||
return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via}
|
return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via}
|
||||||
}
|
}
|
||||||
known, err := KnownWings(r.BrainDir)
|
known, err := KnownWings(r.BrainDir)
|
||||||
@@ -69,8 +85,8 @@ func (r Router) explicit(w, via string) Decision {
|
|||||||
// startup rather than later: a client filter that silently does nothing is
|
// startup rather than later: a client filter that silently does nothing is
|
||||||
// worse than none.
|
// worse than none.
|
||||||
func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
|
func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
|
||||||
if defaultWing == "" || !plainWingName(defaultWing) {
|
if defaultWing == "" || !usableWingName(defaultWing) {
|
||||||
return Router{}, fmt.Errorf("default wing %q is not a plain wing name", defaultWing)
|
return Router{}, fmt.Errorf("default wing %q is not a usable wing name", defaultWing)
|
||||||
}
|
}
|
||||||
r := Router{BrainDir: brainDir, Default: defaultWing}
|
r := Router{BrainDir: brainDir, Default: defaultWing}
|
||||||
if clientPattern != "" {
|
if clientPattern != "" {
|
||||||
@@ -82,3 +98,123 @@ func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
|
|||||||
}
|
}
|
||||||
return r, nil
|
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 match that
|
||||||
|
// spans at most filterOverlap bytes whole into the next chunk. A match
|
||||||
|
// longer than that (a pattern like `acme.{5000}corp`, or padding
|
||||||
|
// between two words) can be missed on a chunk edge; documented limit.
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package wing_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -81,7 +82,7 @@ func TestRouter_ANameThatIsNotPlainIsRefusedAsSuch(t *testing.T) {
|
|||||||
d := r.Resolve(context.Background(), wing.Note{Dir: bad, Text: "x"})
|
d := r.Resolve(context.Background(), wing.Note{Dir: bad, Text: "x"})
|
||||||
assert.Equal(t, wing.Exception, d.Source)
|
assert.Equal(t, wing.Exception, d.Source)
|
||||||
assert.Empty(t, d.Wing, "a malformed name is never echoed as a proposal")
|
assert.Empty(t, d.Wing, "a malformed name is never echoed as a proposal")
|
||||||
assert.Contains(t, d.Reason, "not a plain wing name")
|
assert.Contains(t, d.Reason, "not a usable wing name")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,3 +113,124 @@ func TestNewRouter(t *testing.T) {
|
|||||||
assert.Error(t, err, "a client filter that silently does nothing is worse than none")
|
assert.Error(t, err, "a client filter that silently does nothing is worse than none")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From the review of #211: the content filter is the only guard on the default
|
||||||
|
// lane, so it must survive the cheap evasions.
|
||||||
|
func TestRouter_ClientMarkerSurvivesCheapEvasions(t *testing.T) {
|
||||||
|
r := wing.Router{BrainDir: routerBrain(t), Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}
|
||||||
|
tests := []struct{ name, text string }{
|
||||||
|
{"non-breaking hyphen", "notes on acme‑client"},
|
||||||
|
{"zero-width joiner inside", "notes on acme\u200d-client"},
|
||||||
|
{"zero-width space inside", "notes on ac\u200bme-client"},
|
||||||
|
{"split across lines", "notes on acme-\nclient"},
|
||||||
|
{"spaced hyphen", "notes on acme - client"},
|
||||||
|
{"fullwidth letters", "notes on acme-client"},
|
||||||
|
{"soft hyphen", "notes on acme\u00ad-client"},
|
||||||
|
{"en dash", "notes on acme–client"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
d := r.Resolve(context.Background(), wing.Note{Text: tt.text})
|
||||||
|
assert.Equal(t, wing.Exception, d.Source, tt.text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("an unrelated text still files", func(t *testing.T) {
|
||||||
|
d := r.Resolve(context.Background(), wing.Note{Text: "a talk about agents and a client-server model"})
|
||||||
|
assert.Equal(t, wing.Default, d.Source)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_FolderNamesAreAsciiAndNotReserved(t *testing.T) {
|
||||||
|
dir := routerBrain(t)
|
||||||
|
writePage(t, dir, "wiki/kubernetes/facts/a.md", "x")
|
||||||
|
r := wing.Router{BrainDir: dir, Default: "web-clip"}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dir string
|
||||||
|
}{
|
||||||
|
{"Kelvin sign lowercases to an ASCII k", "Kubernetes"},
|
||||||
|
{"cyrillic lookalike", "h\u043emelab"},
|
||||||
|
{"retired layout name: sources", "sources"},
|
||||||
|
{"retired layout name: concepts", "concepts"},
|
||||||
|
{"retired layout name: entities", "entities"},
|
||||||
|
{"wiki", "wiki"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
d := r.Resolve(context.Background(), wing.Note{Dir: tt.dir, Text: "x"})
|
||||||
|
assert.Equal(t, wing.Exception, d.Source)
|
||||||
|
assert.Empty(t, d.Wing, "never proposed as a wing")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("plain ASCII case folding still works", func(t *testing.T) {
|
||||||
|
d := r.Resolve(context.Background(), wing.Note{Dir: "KubernetEs", Text: "x"})
|
||||||
|
assert.Equal(t, wing.Explicit, d.Source)
|
||||||
|
assert.Equal(t, "kubernetes", d.Wing)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRouter_AReservedDefaultIsRefused(t *testing.T) {
|
||||||
|
_, err := wing.NewRouter(routerBrain(t), "sources", "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the second review of #211: the multi-word, \s and \b patterns an operator
|
||||||
|
// would actually write, and the next tier of evasions.
|
||||||
|
func TestRouter_ClientMarkerHandlesRealisticPatternsAndHarderEvasions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, pattern, text string
|
||||||
|
}{
|
||||||
|
{"space in pattern vs NBSP", `acme corp`, "about acme\u00a0corp today"},
|
||||||
|
{"space in pattern vs two spaces", `acme corp`, "about acme corp today"},
|
||||||
|
{"space in pattern vs newline", `acme corp`, "about acme\ncorp today"},
|
||||||
|
{"backslash-s pattern vs NBSP", `acme\s+corp`, "about acme\u00a0corp today"},
|
||||||
|
{"word boundary vs a zero-width char inside", `\bacme\b`, "the ac\u200bme corp"},
|
||||||
|
{"dash pattern vs a soft hyphen as the dash", `acme-client`, "acme\u00adclient"},
|
||||||
|
{"cyrillic a", `acme-client`, "\u0430cme-client"},
|
||||||
|
{"cyrillic e", `acme-client`, "acm\u0435-client"},
|
||||||
|
{"cyrillic o and c", `coop`, "c\u043e\u043ep"},
|
||||||
|
{"combining acute", `acme-client`, "a\u0301cme-client"},
|
||||||
|
{"precomposed accent", `acme-client`, "\u00e1cme-client"},
|
||||||
|
{"hangul filler", `acme-client`, "ac\u3164me-client"},
|
||||||
|
{"halfwidth hangul filler", `acme-client`, "ac\uffa0me-client"},
|
||||||
|
{"braille blank", `acme-client`, "ac\u2800me-client"},
|
||||||
|
{"grapheme joiner", `acme-client`, "ac\u034fme-client"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r, err := wing.NewRouter(routerBrain(t), "web-clip", tt.pattern)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: tt.text}).Source, "%q should be caught by %q", tt.text, tt.pattern)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("ordinary text is not caught by a multi-word pattern", func(t *testing.T) {
|
||||||
|
r, _ := wing.NewRouter(routerBrain(t), "web-clip", `acme corp`)
|
||||||
|
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: "acme is not corp, and corp is not acme"}).Source)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The folding allocates several times the text, so a large note is checked in
|
||||||
|
// bounded chunks. A marker that straddles a chunk edge must still be found.
|
||||||
|
func TestRouter_ClientMarkerIsFoundAcrossAChunkBoundary(t *testing.T) {
|
||||||
|
r, err := wing.NewRouter(routerBrain(t), "web-clip", `acme-client`)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
r.FilterChunk = 64
|
||||||
|
for _, at := range []int{0, 30, 55, 58, 62, 64, 70, 120, 190} {
|
||||||
|
text := strings.Repeat("x", at) + "acme-client" + strings.Repeat("y", 200)
|
||||||
|
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: text}).Source, "marker at offset %d was missed", at)
|
||||||
|
}
|
||||||
|
clean := strings.Repeat("lorem ipsum ", 100)
|
||||||
|
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_ClientMarkerChunkingDoesNotSplitAMultiByteRune(t *testing.T) {
|
||||||
|
r, err := wing.NewRouter(routerBrain(t), "web-clip", `acme-client`)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
r.FilterChunk = 65 // odd, over two-byte runes: every boundary falls inside a rune unless it is moved
|
||||||
|
for pad := 0; pad < 8; pad++ {
|
||||||
|
text := strings.Repeat("\u00e9", 40+pad) + "acme-client" + strings.Repeat("\u00e9", 40)
|
||||||
|
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: text}).Source, "pad %d", pad)
|
||||||
|
}
|
||||||
|
clean := strings.Repeat("\u00e9", 300)
|
||||||
|
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source, "no panic or false match on a text of 2-byte runes")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user