diff --git a/README.md b/README.md index 1f257cc..22b9ba1 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Both modes use the same wiki engine and the same wiki directory (`~/.mind-map/wi | `list_pages` | List pages, optionally filtered by path prefix | | `get_backlinks` | Get all pages that link to a given page | | `register_sync` | Register a wiki path prefix to sync with a git remote | +| `reindex_wiki` | Force a reindex pass against on-disk markdown (rarely needed; useful after edits made outside the wiki API) | ## Wiki Features diff --git a/SKILL.md b/SKILL.md index 3b3d6d7..97ac592 100644 --- a/SKILL.md +++ b/SKILL.md @@ -12,6 +12,7 @@ tools: - list_pages - get_backlinks - register_sync + - reindex_wiki --- # Mind-Map Skill @@ -182,6 +183,20 @@ register_sync(prefix: "publish/blog", remote: "https://example.com/blog.wiki.gi Re-registering the same prefix replaces the previous direction. Respect existing sync mappings: don't reshape paths under a prefix that's syncing somewhere else without confirming with the user first. +## Forcing a Reindex + +``` +reindex_wiki() +→ returns { total, added, updated, removed, unchanged, elapsed_ms } +``` + +The wiki index updates automatically on every create/update/delete/move and on every sync pull. You shouldn't normally need `reindex_wiki`. Use it only when: + +- You (or the user) edited markdown files **directly on disk** outside the wiki API, and a follow-up `list_pages` or `search_pages` doesn't reflect the change. +- You suspect the index has drifted from disk (rare; usually a sign of a bug worth reporting). + +The pass is incremental — unchanged files are skipped via mtime — so it's cheap to call. Prefer `update_page` / `create_page` / `delete_page` for your own edits; those keep the index synchronous without a reindex. + ## Page Format Example ```markdown diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 5e16e80..cf96774 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -173,6 +173,7 @@ func (s *Server) register(mux *http.ServeMux) { mux.HandleFunc("PUT /api/settings", s.putSettings) mux.HandleFunc("GET /api/settings/path", s.getSettingsPath) mux.HandleFunc("POST /api/restart", s.postRestart) + mux.HandleFunc("POST /api/reindex", s.postReindex) mux.HandleFunc("GET /api/sync/status", s.getSyncStatus) mux.Handle("/", s.staticHandler()) } @@ -493,6 +494,21 @@ func (s *Server) getSyncStatus(rw http.ResponseWriter, r *http.Request) { writeJSON(rw, mindsync.Status{Enabled: false}) } +// postReindex handles POST /api/reindex. Triggers a full reindex pass +// against the on-disk wiki and returns the resulting stats. +// +// Safe to call repeatedly and safe to call concurrently with the sync +// loop — wiki.Reindex acquires per-page locks rather than holding a +// global lock, so requests don't stall the server. +func (s *Server) postReindex(rw http.ResponseWriter, r *http.Request) { + stats, err := s.deps.Wiki.Reindex(r.Context()) + if err != nil { + http.Error(rw, "reindex: "+err.Error(), http.StatusInternalServerError) + return + } + writeJSON(rw, stats) +} + func (s *Server) staticHandler() http.Handler { if s.deps.WebFS != nil { return http.FileServerFS(s.deps.WebFS) diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 9c17d00..bbee42e 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -285,3 +286,75 @@ func TestSettingsChangeDuringShutdown(t *testing.T) { t.Fatalf("expected no manager started, got: %s", rec.Body.String()) } } + +func TestReindex(t *testing.T) { + h := newTestServer(t) + // Seed one page. + doJSON(t, h, "POST", "/api/pages", map[string]string{"path": "p", "content": "v1"}) + + rec := doJSON(t, h, "POST", "/api/reindex", nil) + if rec.Code != 200 { + t.Fatalf("reindex: %d %s", rec.Code, rec.Body.String()) + } + var stats map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &stats); err != nil { + t.Fatalf("response not JSON: %v", err) + } + // Total is the count of .md files found on disk. The seed page + // should be there; CreatePage already indexed it, so a fresh + // reindex should see total=1 with unchanged=1 (matching mtime). + if total, _ := stats["total"].(float64); int(total) != 1 { + t.Errorf("total = %v, want 1", stats["total"]) + } + if _, ok := stats["elapsed_ms"]; !ok { + t.Errorf("response missing elapsed_ms: %+v", stats) + } +} + +func TestReindexDetectsDirectFilesystemChanges(t *testing.T) { + // The whole point of a manual reindex is recovering from changes + // made outside the wiki API. Simulate that by writing a markdown + // file directly to the wiki root. + dir := t.TempDir() + w, err := wiki.Open(dir) + if err != nil { + t.Fatalf("open wiki: %v", err) + } + t.Cleanup(func() { w.Close() }) + + h := New(Deps{ + Wiki: w, + CfgPath: filepath.Join(dir, "config.json"), + Cfg: config.DefaultConfig(), + GetVersion: func() string { return "test" }, + StopCh: make(chan struct{}), + }) + + // Drop a page directly on disk — bypassing CreatePage so the index + // has no entry for it yet. + if err := os.WriteFile(filepath.Join(dir, "fresh.md"), []byte("# fresh\n"), 0o644); err != nil { + t.Fatalf("write fresh page: %v", err) + } + + // It shouldn't be visible via GET yet, even though it's on disk. + if rec := doJSON(t, h, "GET", "/api/pages/fresh", nil); rec.Code == 200 { + // Open() ran a reindex at startup, but the file we wrote came + // AFTER. So it should be 404 until we reindex. + t.Fatalf("page already indexed before reindex (got %d)", rec.Code) + } + + rec := doJSON(t, h, "POST", "/api/reindex", nil) + if rec.Code != 200 { + t.Fatalf("reindex: %d %s", rec.Code, rec.Body.String()) + } + var stats map[string]any + json.Unmarshal(rec.Body.Bytes(), &stats) + if added, _ := stats["added"].(float64); int(added) != 1 { + t.Errorf("added = %v, want 1 (the freshly-written page)", stats["added"]) + } + + // Now it should be reachable. + if rec := doJSON(t, h, "GET", "/api/pages/fresh", nil); rec.Code != 200 { + t.Errorf("page still not indexed after reindex (got %d body=%s)", rec.Code, rec.Body.String()) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b637e23..a58839c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -103,6 +103,11 @@ func (s *Server) registerTools() { Name: "register_sync", Description: "Register a wiki path prefix to sync with a git remote. Pages under this prefix will be synced to the given repository's wiki. The remote URL should be a git clone URL (e.g. https://github.com/user/repo.wiki.git). Direction defaults to 'bidirectional' (pull+push); use 'pull' to mirror an upstream repo read-only into the wiki, or 'push' to publish wiki content to a remote without ever pulling from it. Re-registering the same prefix replaces the previous direction. Auth uses the machine's existing git credentials.", }, s.registerSync) + + mcp.AddTool(s.server, &mcp.Tool{ + Name: "reindex_wiki", + Description: "Force a full reindex pass over the wiki's on-disk markdown files. Use when you've edited files outside the wiki API and want the index (search, page list, backlinks) to reflect disk state without restarting the server. The pass is incremental — unchanged files are skipped via mtime — so it's cheap to call. Returns stats: total/added/updated/removed/unchanged/elapsed_ms.", + }, s.reindexWiki) } // --- Tool input types --- @@ -367,3 +372,21 @@ func topPrefix(path string) string { } return parts[0] + "/" + parts[1] } + +func (s *Server) reindexWiki(ctx context.Context, _ *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + start := time.Now() + stats, err := s.wiki.Reindex(ctx) + if err != nil { + slog.Error("tool.reindex_wiki failed", slog.Any("error", err)) + return nil, nil, err + } + slog.Info("tool.reindex_wiki", + slog.Int("total", stats.Total), + slog.Int("added", stats.Added), + slog.Int("updated", stats.Updated), + slog.Int("removed", stats.Removed), + slog.Int("unchanged", stats.Unchanged), + slog.Duration("elapsed", time.Since(start)), + ) + return textResult(stats) +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index b2a0eb8..dd30bc8 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -527,3 +527,26 @@ func TestRegisterSyncRejectsInvalidDirection(t *testing.T) { t.Errorf("registrar was called despite invalid direction: %+v", reg.calls) } } + +func TestReindexWiki(t *testing.T) { + session := setupTestServer(t) + + // reindex_wiki returns the stats JSON as text content. + text := callTool(t, session, "reindex_wiki", nil) + var stats map[string]any + if err := json.Unmarshal([]byte(text), &stats); err != nil { + t.Fatalf("response not JSON: %v", err) + } + // setupTestServer seeds 4 pages. After Open()'s startup reindex + // they're already indexed, so a fresh reindex should report + // total=4 unchanged=4 added=0. + if total, _ := stats["total"].(float64); int(total) != 4 { + t.Errorf("total = %v, want 4", stats["total"]) + } + if unchanged, _ := stats["unchanged"].(float64); int(unchanged) != 4 { + t.Errorf("unchanged = %v, want 4", stats["unchanged"]) + } + if _, ok := stats["elapsed_ms"]; !ok { + t.Errorf("response missing elapsed_ms: %+v", stats) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 94c33b6..fa7d55f 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -17,12 +17,15 @@ import ( "time" "github.com/aniongithub/mind-map/internal/config" + "github.com/aniongithub/mind-map/internal/wiki" ) // Reindexer is the interface the sync engine uses to trigger a wiki reindex -// after pulling changes. +// after pulling changes. The returned ReindexStats are logged at INFO by +// the implementation; the sync loop itself just cares that the call +// succeeded. type Reindexer interface { - Reindex(ctx context.Context) error + Reindex(ctx context.Context) (wiki.ReindexStats, error) } // RemoteStatus represents the sync state for a single remote. @@ -373,7 +376,7 @@ func (m *Manager) syncTarget(ctx context.Context, t *syncTarget) { if wantPull { m.copyToWiki(t) if m.reindexer != nil { - if err := m.reindexer.Reindex(ctx); err != nil { + if _, err := m.reindexer.Reindex(ctx); err != nil { slog.Warn("reindex after pull failed", slog.Any("error", err)) } } diff --git a/internal/sync/sync_test.go b/internal/sync/sync_test.go index 4ea35cc..048e1c3 100644 --- a/internal/sync/sync_test.go +++ b/internal/sync/sync_test.go @@ -18,9 +18,9 @@ type mockReindexer struct { calls int } -func (m *mockReindexer) Reindex(_ context.Context) error { +func (m *mockReindexer) Reindex(_ context.Context) (wiki.ReindexStats, error) { m.calls++ - return nil + return wiki.ReindexStats{}, nil } // setupBareRemote creates a bare git repo to act as the remote. diff --git a/internal/wiki/index.go b/internal/wiki/index.go index bf71f4e..e847f92 100644 --- a/internal/wiki/index.go +++ b/internal/wiki/index.go @@ -11,13 +11,32 @@ import ( "time" ) +// ReindexStats summarizes what a Reindex pass did. All counts are +// across the entire wiki tree on disk; `Total` is the number of +// markdown files found, not the number of changed pages. +type ReindexStats struct { + Total int `json:"total"` + Added int `json:"added"` + Updated int `json:"updated"` + Removed int `json:"removed"` + Unchanged int `json:"unchanged"` + Elapsed time.Duration `json:"-"` + // ElapsedMs mirrors Elapsed in a JSON-friendly form so the HTTP + // endpoint and MCP tool can return it directly. + ElapsedMs int64 `json:"elapsed_ms"` +} + // Reindex performs an incremental sync of the filesystem with the index. // It only re-indexes pages whose mtime has changed, adds new pages, and // removes index entries for deleted files. The lock is held per-page // rather than for the entire operation, so the server stays responsive. -func (w *Wiki) Reindex(ctx context.Context) error { +// +// Returns ReindexStats summarizing the pass so callers can surface the +// result (HTTP endpoint, MCP tool, settings UI). The stats are also +// logged at INFO regardless of caller, matching the prior behavior. +func (w *Wiki) Reindex(ctx context.Context) (ReindexStats, error) { if err := ctx.Err(); err != nil { - return err + return ReindexStats{}, err } start := time.Now() @@ -26,7 +45,7 @@ func (w *Wiki) Reindex(ctx context.Context) error { indexed := make(map[string]string) // path -> modified (RFC3339) rows, err := w.db.QueryContext(ctx, "SELECT path, modified FROM pages") if err != nil { - return err + return ReindexStats{}, err } for rows.Next() { var path, modified string @@ -66,14 +85,14 @@ func (w *Wiki) Reindex(ctx context.Context) error { return nil }) if err != nil { - return err + return ReindexStats{}, err } // Phase 3: index new/changed pages var added, updated, removed int for pagePath, info := range diskPages { if err := ctx.Err(); err != nil { - return err + return ReindexStats{}, err } diskMtime := info.ModTime().UTC().Format(time.RFC3339Nano) @@ -96,7 +115,7 @@ func (w *Wiki) Reindex(ctx context.Context) error { tx, err := w.db.BeginTx(ctx, nil) if err != nil { - return err + return ReindexStats{}, err } _, err = tx.ExecContext(ctx, @@ -105,22 +124,22 @@ func (w *Wiki) Reindex(ctx context.Context) error { ) if err != nil { tx.Rollback() - return fmt.Errorf("index %s: %w", pagePath, err) + return ReindexStats{}, fmt.Errorf("index %s: %w", pagePath, err) } if _, err := tx.ExecContext(ctx, "DELETE FROM links WHERE source = ?", pagePath); err != nil { tx.Rollback() - return err + return ReindexStats{}, err } for _, target := range parsed.links { if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO links (source, target) VALUES (?, ?)", pagePath, target); err != nil { tx.Rollback() - return err + return ReindexStats{}, err } } if err := tx.Commit(); err != nil { - return err + return ReindexStats{}, err } if _, exists := indexed[pagePath]; exists { @@ -133,7 +152,7 @@ func (w *Wiki) Reindex(ctx context.Context) error { // Phase 4: remove index entries for deleted files for pagePath := range indexed { if err := ctx.Err(); err != nil { - return err + return ReindexStats{}, err } if _, onDisk := diskPages[pagePath]; !onDisk { if err := w.removePageIndex(ctx, pagePath); err != nil { @@ -144,15 +163,26 @@ func (w *Wiki) Reindex(ctx context.Context) error { } } + elapsed := time.Since(start) + stats := ReindexStats{ + Total: len(diskPages), + Added: added, + Updated: updated, + Removed: removed, + Unchanged: len(diskPages) - added - updated, + Elapsed: elapsed, + ElapsedMs: elapsed.Milliseconds(), + } + slog.Info("reindex complete", - slog.Int("total", len(diskPages)), - slog.Int("added", added), - slog.Int("updated", updated), - slog.Int("removed", removed), - slog.Int("unchanged", len(diskPages)-added-updated), - slog.Duration("elapsed", time.Since(start)), + slog.Int("total", stats.Total), + slog.Int("added", stats.Added), + slog.Int("updated", stats.Updated), + slog.Int("removed", stats.Removed), + slog.Int("unchanged", stats.Unchanged), + slog.Duration("elapsed", stats.Elapsed), ) - return nil + return stats, nil } // indexPage indexes a single page (after write/update). diff --git a/internal/wiki/wiki.go b/internal/wiki/wiki.go index 7b68536..84b4778 100644 --- a/internal/wiki/wiki.go +++ b/internal/wiki/wiki.go @@ -92,7 +92,7 @@ func Open(root string) (*Wiki, error) { slog.Warn("pages_fts rebuild failed", slog.Any("error", err)) } - if err := w.Reindex(context.Background()); err != nil { + if _, err := w.Reindex(context.Background()); err != nil { db.Close() return nil, fmt.Errorf("initial index: %w", err) } diff --git a/internal/wiki/wiki_test.go b/internal/wiki/wiki_test.go index 24d3f9e..3b8be01 100644 --- a/internal/wiki/wiki_test.go +++ b/internal/wiki/wiki_test.go @@ -418,7 +418,7 @@ func TestIncrementalReindex(t *testing.T) { } // Reindex with no changes -- should be a no-op - if err := w.Reindex(ctx); err != nil { + if _, err := w.Reindex(ctx); err != nil { t.Fatalf("Reindex (no changes): %v", err) } wctx, _ = w.Context(ctx) @@ -428,7 +428,7 @@ func TestIncrementalReindex(t *testing.T) { // Add a file externally, then reindex writeFile(t, dir, "new-external.md", "# External\n\nAdded outside the API.\n") - if err := w.Reindex(ctx); err != nil { + if _, err := w.Reindex(ctx); err != nil { t.Fatalf("Reindex (after add): %v", err) } wctx, _ = w.Context(ctx) @@ -447,7 +447,7 @@ func TestIncrementalReindex(t *testing.T) { if err := os.Remove(filepath.Join(dir, "Go.md")); err != nil { t.Fatalf("remove Go.md: %v", err) } - if err := w.Reindex(ctx); err != nil { + if _, err := w.Reindex(ctx); err != nil { t.Fatalf("Reindex (after delete): %v", err) } wctx, _ = w.Context(ctx) @@ -472,7 +472,7 @@ func TestIncrementalReindex(t *testing.T) { newMtime := oldMtime.Add(2 * time.Second) os.Chtimes(modPath, newMtime, newMtime) - if err := w.Reindex(ctx); err != nil { + if _, err := w.Reindex(ctx); err != nil { t.Fatalf("Reindex (after modify): %v", err) } p, err = w.GetPage(ctx, "index") diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 0ba4f30..4d6c126 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; -import { api, Page } from './api'; +import { api, Page, ReindexStats } from './api'; import { Logo } from './Logo'; import { PageBrowser } from './PageBrowser'; import { GraphView } from './GraphView'; @@ -54,6 +54,12 @@ export function App() { const [configPath, setConfigPath] = useState(''); const [settingsDirty, setSettingsDirty] = useState(false); const [settingsSaved, setSettingsSaved] = useState(false); + + // Reindex state (settings panel). reindexResult is null until the + // first run completes; reindexError holds the most recent failure. + const [reindexing, setReindexing] = useState(false); + const [reindexResult, setReindexResult] = useState(null); + const [reindexError, setReindexError] = useState(null); const [isDark, setIsDark] = useState(() => { const saved = localStorage.getItem('mm-theme'); if (saved) return saved === 'dark'; @@ -305,6 +311,24 @@ export function App() { } }; + const handleReindex = async () => { + setReindexing(true); + setReindexError(null); + try { + const stats = await api.reindex(); + setReindexResult(stats); + // The list of pages may have changed (files written + // outside the API are now indexed); refresh so the sidebar + // reflects the new state. + await loadPages(); + } catch (e) { + console.error('Reindex failed:', e); + setReindexError(e instanceof Error ? e.message : String(e)); + } finally { + setReindexing(false); + } + }; + const updateSync = (field: keyof SyncSettings, value: string | boolean) => { if (!settings) return; setSettings({ @@ -538,6 +562,37 @@ export function App() { )} +
+
Index
+
+ The wiki keeps a search index over the on-disk markdown files. It updates automatically + on writes and on every sync pull. Use this if you've edited files outside the wiki + (e.g. directly on disk) and want the index to catch up without restarting. +
+
+ + {reindexResult && !reindexing && !reindexError && ( +
+ {reindexResult.total} pages + {' · '} + +{reindexResult.added} / ~{reindexResult.updated} / −{reindexResult.removed} + {' · '} + {reindexResult.elapsed_ms} ms +
+ )} + {reindexError && ( +
{reindexError}
+ )} +
+
+