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
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
// [[preferences/devcontainer-ports]] for the full rationale and the
// consumer recipes (launch.json, tasks.json, host scripts).
"initializeCommand": ".devcontainer/initializeCommand.sh",
"postCreateCommand": "go version && node --version",
"postCreateCommand": "git config --global --add safe.directory /workspaces/mind-map && go version && node --version && { CHROME=$(find ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome 2>/dev/null | head -1) && [ -n \"$CHROME\" ] && sudo ln -sf \"$CHROME\" /usr/local/bin/chromium || true; }",
"postAttachCommand": "npm install --prefix webui",
"customizations": {
"vscode": {
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
webui/dist/*
!webui/dist/.gitkeep
webui/node_modules/
tools/node_modules/

# VS Code cache
.vscode/cache/
Expand Down
97 changes: 96 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ The web UI is a static Preact app served by `mind-map serve` over HTTP. It uses

Both modes use the same wiki engine and the same wiki directory (`~/.mind-map/wiki` by default). Multiple stdio processes can safely share the same wiki via SQLite page locking.

## MCP Tools (11 total)
## MCP Tools (12 total)

| Tool | Description |
|------|-------------|
Expand All @@ -100,6 +100,7 @@ Both modes use the same wiki engine and the same wiki directory (`~/.mind-map/wi
| `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) |
| `export_pages` | Export a page (and linked pages up to N hops) as zip or PDF |

## Wiki Features

Expand All @@ -110,6 +111,50 @@ Both modes use the same wiki engine and the same wiki directory (`~/.mind-map/wi
- **Full-text search**: SQLite FTS5 with ranked results and snippets
- **Multi-process safe**: SQLite page locking for concurrent agent access
- **Git sync**: sync wiki pages to GitHub repo wikis via configurable mappings
- **Export / Share**: export pages as zip or PDF — see below

## Export & Share

Export one or more wiki pages for sharing outside the wiki. The system follows **wikilinks** from a starting page (BFS traversal) to collect a self-contained set of pages with no broken links.

### Depth control

| Depth | Meaning |
|-------|---------|
| `0` | Just the selected page |
| `1` | The page + all pages it links to |
| `N` | N hops of outgoing links |
| `-1` | Unlimited — follow all reachable links |

### Formats

Export is **pluggable** — formats register themselves at startup:

| Format | Description | Requirements |
|--------|-------------|--------------|
| **zip** | Archive of markdown files + assets | None |
| **pdf** | Multi-page PDF with table of contents | Chrome, Edge, or Chromium on `$PATH` |

PDF export uses [chromedp](https://github.com/chromedp/chromedp) to render markdown → HTML → PDF via a headless browser. It includes a clickable TOC, embedded images, and a print-friendly stylesheet. The PDF sharer only registers if a supported browser is detected — no browser, no PDF option.

### Using export

**Web UI**: Click the share icon in the page header → pick depth and format → download.

**REST API**:
```
GET /api/export?format=zip&page=projects/my-project&depth=1
GET /api/export/formats # list available formats with settings schemas
```

**MCP tool**:
```
export_pages(format: "pdf", page: "architecture/auth", depth: -1)
```

### Adding new formats

Implement the `Sharer` interface in `internal/share/` and call `Register()` in an `init()` function. The format automatically appears in the UI, REST API, and MCP tool.

## Web UI

Expand All @@ -119,10 +164,60 @@ The built-in web UI is a lightning-fast, Metro-inspired, chromeless Preact app:
- Markdown rendering with wikilinks as clickable links
- Backlinks section on every page
- Edit mode with raw markdown editor
- Export / share panel for zip and PDF export
- Interactive graph view of the wiki link structure
- Dark / light theme toggle

The web UI speaks the same language as the wiki engine. If an agent creates a page via stdio, it appears in the browser. If you edit in the browser, the agent sees the change on its next read.

### Page view

<p align="center">
<img src="docs/screenshots/page-view.png" alt="Page view" width="720">
</p>

### Dark theme

<p align="center">
<img src="docs/screenshots/page-view-dark.png" alt="Page view (dark theme)" width="720">
</p>

### Search

<p align="center">
<img src="docs/screenshots/search.png" alt="Search" width="720">
</p>

### Edit mode

<p align="center">
<img src="docs/screenshots/edit-mode.png" alt="Edit mode" width="720">
</p>

### Export panel

<p align="center">
<img src="docs/screenshots/export-panel.png" alt="Export panel" width="720">
</p>

### Graph view

<p align="center">
<img src="docs/screenshots/graph-view.png" alt="Graph view" width="720">
</p>

### Backlinks

<p align="center">
<img src="docs/screenshots/backlinks.png" alt="Backlinks" width="720">
</p>

### Settings

<p align="center">
<img src="docs/screenshots/settings.png" alt="Settings" width="720">
</p>

## Service Management

The installer can set up mind-map as a persistent system service that starts on boot:
Expand Down
Binary file added docs/screenshots/backlinks.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/edit-mode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/export-panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/graph-view.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/page-view-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/page-view.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/search.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ module github.com/aniongithub/mind-map
go 1.26.2

require (
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1
github.com/kardianos/service v1.2.4
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/spf13/cobra v1.10.2
Expand All @@ -12,7 +14,12 @@ require (
)

require (
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down
18 changes: 18 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand All @@ -17,12 +31,16 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand Down
157 changes: 157 additions & 0 deletions internal/httpapi/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package httpapi

import (
"context"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"

"github.com/aniongithub/mind-map/internal/share"
"github.com/aniongithub/mind-map/internal/wiki"
)

// registerExport wires the export routes. Called from register().
func (s *Server) registerExport(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/formats", s.getExportFormats)
mux.HandleFunc("GET /api/export", s.getExport)
}

// getExportFormats handles GET /api/export/formats. Returns the list of
// registered export formats with their settings schemas so the UI can
// render format-specific options.
func (s *Server) getExportFormats(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, share.Formats())
}

// getExport handles GET /api/export. Streams an exported file in the
// requested format.
//
// Query parameters:
// - format (required): the sharer name (e.g. "zip")
// - page (required): starting page path for link traversal
// - depth (optional): link-follow depth (-1 = unlimited, 0 = just this
// page, 1 = page + its links, etc.). Defaults to 0.
// - all other params become plugin settings
func (s *Server) getExport(rw http.ResponseWriter, r *http.Request) {
start := time.Now()

format := r.URL.Query().Get("format")
if format == "" {
http.Error(rw, "format parameter is required", http.StatusBadRequest)
return
}

sharer := share.Get(format)
if sharer == nil {
http.Error(rw, fmt.Sprintf("unknown export format: %q", format), http.StatusBadRequest)
return
}

page := r.URL.Query().Get("page")
if page == "" {
http.Error(rw, "page parameter is required", http.StatusBadRequest)
return
}

depth := 0
if d := r.URL.Query().Get("depth"); d != "" {
parsed, err := strconv.Atoi(d)
if err != nil {
http.Error(rw, "depth must be an integer", http.StatusBadRequest)
return
}
depth = parsed
}

// Build plugin-specific settings from remaining query params
settings := make(map[string]any)
reserved := map[string]bool{"format": true, "page": true, "depth": true}
for key, values := range r.URL.Query() {
if reserved[key] || len(values) == 0 {
continue
}
val := values[0]
if val == "true" || val == "false" {
settings[key] = val == "true"
} else if n, err := strconv.Atoi(val); err == nil {
settings[key] = n
} else {
settings[key] = val
}
}

cfg := share.ShareConfig{
Format: format,
Page: page,
Depth: depth,
Settings: settings,
}

// Gather pages via link-graph traversal
exportPages, err := s.deps.Wiki.ExportPages(r.Context(), page, depth)
if err != nil {
http.Error(rw, "export failed: "+err.Error(), http.StatusInternalServerError)
return
}

// Convert wiki.ExportPage to share.Page
pages := make([]share.Page, len(exportPages))
for i, ep := range exportPages {
pages[i] = share.Page{
Path: ep.Path,
Title: ep.Title,
Body: ep.Body,
Frontmatter: ep.Frontmatter,
ModifiedAt: ep.ModifiedAt,
ImageRefs: ep.ImageRefs,
}
}

req := share.ExportRequest{
Config: cfg,
Pages: pages,
Assets: &wikiAssetReader{wiki: s.deps.Wiki, ctx: r.Context()},
}

// Build filename for Content-Disposition
filename := exportFilename(page, sharer.FileExtension())

rw.Header().Set("Content-Type", sharer.ContentType())
rw.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))

if err := sharer.Export(r.Context(), rw, req); err != nil {
slog.Error("export stream failed",
slog.String("format", format),
slog.String("page", page),
slog.Any("error", err),
)
return
}

slog.Info("export completed",
slog.String("format", format),
slog.String("page", page),
slog.Int("depth", depth),
slog.Int("pages", len(pages)),
slog.Duration("elapsed", time.Since(start)),
)
}

// wikiAssetReader adapts the wiki to the share.AssetReader interface.
type wikiAssetReader struct {
wiki *wiki.Wiki
ctx context.Context
}

func (r *wikiAssetReader) ReadAsset(_ context.Context, path string) ([]byte, string, error) {
return r.wiki.ReadAsset(r.ctx, path)
}

// exportFilename builds a suitable download filename from page path and extension.
func exportFilename(page, ext string) string {
clean := strings.ReplaceAll(page, "/", "-")
return clean + ext
}
1 change: 1 addition & 0 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ func (s *Server) register(mux *http.ServeMux) {
mux.HandleFunc("POST /api/reindex", s.postReindex)
mux.HandleFunc("GET /api/sync/status", s.getSyncStatus)
s.registerAssets(mux)
s.registerExport(mux)
mux.Handle("/", s.staticHandler())
}

Expand Down
Loading