Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ tools:
- list_pages
- get_backlinks
- register_sync
- reindex_wiki
---

# Mind-Map Skill
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions internal/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -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())
}
}
23 changes: 23 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down Expand Up @@ -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)
}
23 changes: 23 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
9 changes: 6 additions & 3 deletions internal/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))
}
}
Expand Down
4 changes: 2 additions & 2 deletions internal/sync/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 48 additions & 18 deletions internal/wiki/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion internal/wiki/wiki.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading