diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4cc7c6f..2e760c7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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": { diff --git a/.gitignore b/.gitignore index 96ed349..6adf002 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ webui/dist/* !webui/dist/.gitkeep webui/node_modules/ +tools/node_modules/ # VS Code cache .vscode/cache/ diff --git a/README.md b/README.md index 0ceafdb..2eff183 100644 --- a/README.md +++ b/README.md @@ -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 | |------|-------------| @@ -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 @@ -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 @@ -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 + +

+ Page view +

+ +### Dark theme + +

+ Page view (dark theme) +

+ +### Search + +

+ Search +

+ +### Edit mode + +

+ Edit mode +

+ +### Export panel + +

+ Export panel +

+ +### Graph view + +

+ Graph view +

+ +### Backlinks + +

+ Backlinks +

+ +### Settings + +

+ Settings +

+ ## Service Management The installer can set up mind-map as a persistent system service that starts on boot: diff --git a/docs/screenshots/backlinks.png b/docs/screenshots/backlinks.png new file mode 100644 index 0000000..34e2d96 Binary files /dev/null and b/docs/screenshots/backlinks.png differ diff --git a/docs/screenshots/edit-mode.png b/docs/screenshots/edit-mode.png new file mode 100644 index 0000000..4a60017 Binary files /dev/null and b/docs/screenshots/edit-mode.png differ diff --git a/docs/screenshots/export-panel.png b/docs/screenshots/export-panel.png new file mode 100644 index 0000000..bedd127 Binary files /dev/null and b/docs/screenshots/export-panel.png differ diff --git a/docs/screenshots/graph-view.png b/docs/screenshots/graph-view.png new file mode 100644 index 0000000..7f2cc45 Binary files /dev/null and b/docs/screenshots/graph-view.png differ diff --git a/docs/screenshots/page-view-dark.png b/docs/screenshots/page-view-dark.png new file mode 100644 index 0000000..b815d82 Binary files /dev/null and b/docs/screenshots/page-view-dark.png differ diff --git a/docs/screenshots/page-view.png b/docs/screenshots/page-view.png new file mode 100644 index 0000000..fccdeb3 Binary files /dev/null and b/docs/screenshots/page-view.png differ diff --git a/docs/screenshots/search.png b/docs/screenshots/search.png new file mode 100644 index 0000000..c55912d Binary files /dev/null and b/docs/screenshots/search.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png new file mode 100644 index 0000000..6114daa Binary files /dev/null and b/docs/screenshots/settings.png differ diff --git a/go.mod b/go.mod index e5baf03..5146c81 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index 7aeb682..b4e9aba 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/internal/httpapi/export.go b/internal/httpapi/export.go new file mode 100644 index 0000000..d3bebab --- /dev/null +++ b/internal/httpapi/export.go @@ -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 +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index c8ae0a0..33d6d6e 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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()) } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 0b2e61b..1152e55 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -8,10 +8,12 @@ import ( "errors" "fmt" "log/slog" + "os" "strings" "time" "github.com/aniongithub/mind-map/internal/config" + "github.com/aniongithub/mind-map/internal/share" "github.com/aniongithub/mind-map/internal/wiki" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -147,6 +149,11 @@ func (s *Server) registerTools() { Name: "delete_image", Description: "Remove an image asset from the wiki. Pages that still reference the deleted image will have a dangling markdown link until edited — the caller is responsible for cleaning up references. Useful for capture tooling that wants a clean canonical filename across re-runs rather than auto-suffixed duplicates.", }, s.deleteImage) + + mcp.AddTool(s.server, &mcp.Tool{ + Name: "export_pages", + Description: "Export wiki pages as a downloadable file. Starts from a given page and follows wikilinks to the specified depth. Returns the path to the exported file on disk. Depth: -1 = all reachable pages, 0 = just this page, 1 = page + its direct links, etc.", + }, s.exportPages) } // --- Tool input types --- @@ -198,6 +205,13 @@ type moveInput struct { Overwrite bool `json:"overwrite,omitempty" jsonschema:"set true to replace an existing destination page; ask the user for explicit confirmation first since the destination's content will be lost"` } +type exportInput struct { + Format string `json:"format,omitempty" jsonschema:"export format name (default: 'zip'). Use GET /api/export/formats to list available formats."` + Page string `json:"page" jsonschema:"starting page path for link-graph traversal"` + Depth int `json:"depth,omitempty" jsonschema:"link-follow depth: -1 = unlimited, 0 = just this page, 1 = page + its links, etc."` + Settings map[string]any `json:"settings,omitempty" jsonschema:"plugin-specific settings as key-value pairs"` +} + // --- Tool handlers --- func (s *Server) searchPages(ctx context.Context, _ *mcp.CallToolRequest, input searchInput) (*mcp.CallToolResult, any, error) { @@ -475,3 +489,111 @@ func (s *Server) reindexWiki(ctx context.Context, _ *mcp.CallToolRequest, _ any) ) return textResult(stats) } + +func (s *Server) exportPages(ctx context.Context, _ *mcp.CallToolRequest, input exportInput) (*mcp.CallToolResult, any, error) { + start := time.Now() + + format := input.Format + if format == "" { + format = "zip" + } + + sharer := share.Get(format) + if sharer == nil { + return nil, nil, fmt.Errorf("unknown export format %q; available: %v", format, formatNames()) + } + + exportPages, err := s.wiki.ExportPages(ctx, input.Page, input.Depth) + if err != nil { + slog.Error("tool.export_pages gather failed", slog.Any("error", err)) + return nil, nil, err + } + + if len(exportPages) == 0 { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "No pages found matching the given page/depth."}, + }, + }, nil, nil + } + + // Convert 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, + } + } + + cfg := share.ShareConfig{ + Format: format, + Page: input.Page, + Depth: input.Depth, + Settings: input.Settings, + } + + // Write to a temp file + tmpFile, err := os.CreateTemp("", "mind-map-export-*"+sharer.FileExtension()) + if err != nil { + return nil, nil, fmt.Errorf("create temp file: %w", err) + } + defer tmpFile.Close() + + req := share.ExportRequest{ + Config: cfg, + Pages: pages, + Assets: &mcpAssetReader{wiki: s.wiki}, + } + + if err := sharer.Export(ctx, tmpFile, req); err != nil { + os.Remove(tmpFile.Name()) + return nil, nil, fmt.Errorf("export failed: %w", err) + } + + info, _ := tmpFile.Stat() + var sizeBytes int64 + if info != nil { + sizeBytes = info.Size() + } + + slog.Info("tool.export_pages", + slog.String("format", format), + slog.String("page", input.Page), + slog.Int("depth", input.Depth), + slog.Int("pages", len(pages)), + slog.Int64("size_bytes", sizeBytes), + slog.Duration("elapsed", time.Since(start)), + ) + + result := map[string]any{ + "path": tmpFile.Name(), + "format": format, + "pages": len(pages), + "size_bytes": sizeBytes, + } + return textResult(result) +} + +// mcpAssetReader adapts the wiki to the share.AssetReader interface for MCP. +type mcpAssetReader struct { + wiki *wiki.Wiki +} + +func (r *mcpAssetReader) ReadAsset(ctx context.Context, path string) ([]byte, string, error) { + return r.wiki.ReadAsset(ctx, path) +} + +// formatNames returns the names of all registered share formats. +func formatNames() []string { + formats := share.Formats() + names := make([]string, len(formats)) + for i, f := range formats { + names[i] = f.Name + } + return names +} diff --git a/internal/share/pdf.go b/internal/share/pdf.go new file mode 100644 index 0000000..7f87487 --- /dev/null +++ b/internal/share/pdf.go @@ -0,0 +1,403 @@ +package share + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "html" + "io" + "log/slog" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/chromedp/cdproto/page" + "github.com/chromedp/chromedp" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer" + goldhtml "github.com/yuin/goldmark/renderer/html" +) + +// PDFSharer exports wiki pages as a multi-page PDF with a table of contents, +// rendered via a Chromium-based browser (Chrome, Edge, or Chromium). +type PDFSharer struct{} + +func init() { + // Only register if a Chromium-based browser is available on the system. + if findBrowser() != "" { + Register(&PDFSharer{}) + } +} + +func (p *PDFSharer) Name() string { return "pdf" } +func (p *PDFSharer) Description() string { return "Multi-page PDF with table of contents (requires Chrome/Edge)" } +func (p *PDFSharer) ContentType() string { return "application/pdf" } +func (p *PDFSharer) FileExtension() string { return ".pdf" } + +func (p *PDFSharer) Settings() SharerSettings { + return SharerSettings{ + Fields: []SettingsField{ + { + Key: "include_toc", + Label: "Include table of contents", + Description: "Generate a clickable table of contents at the beginning", + Type: "bool", + Default: true, + }, + { + Key: "include_assets", + Label: "Embed images", + Description: "Embed referenced images inline in the PDF", + Type: "bool", + Default: true, + }, + { + Key: "page_size", + Label: "Page size", + Description: "Paper size for the PDF", + Type: "enum", + Default: "A4", + Enum: []string{"A4", "Letter", "A3", "Legal"}, + }, + }, + } +} + +func (p *PDFSharer) Export(ctx context.Context, w io.Writer, req ExportRequest) error { + browserPath := findBrowser() + if browserPath == "" { + return fmt.Errorf("no Chromium-based browser found (need Chrome, Edge, or Chromium)") + } + + // Extract settings + includeTOC := SettingBool(req.Config, "include_toc", true) + includeAssets := SettingBool(req.Config, "include_assets", true) + pageSize := SettingString(req.Config, "page_size", "A4") + + // Render pages to HTML + htmlDoc := renderHTMLDocument(req.Pages, req.Assets, ctx, includeTOC, includeAssets) + + // Convert to PDF via headless browser + pdfBytes, err := htmlToPDF(ctx, browserPath, htmlDoc, pageSize) + if err != nil { + return fmt.Errorf("PDF generation failed: %w", err) + } + + _, err = w.Write(pdfBytes) + return err +} + +// renderHTMLDocument builds a complete HTML document from the exported pages. +func renderHTMLDocument(pages []Page, assets AssetReader, ctx context.Context, includeTOC, includeAssets bool) string { + var buf strings.Builder + + buf.WriteString(``) + buf.WriteString(``) + + // Table of contents + if includeTOC && len(pages) > 1 { + buf.WriteString(`

Contents

`) + } + + // Render each page + md := goldmark.New( + goldmark.WithExtensions(extension.Table, extension.Strikethrough, extension.TaskList), + goldmark.WithRendererOptions(renderer.WithNodeRenderers(), goldhtml.WithUnsafe()), + ) + + for i, p := range pages { + buf.WriteString(fmt.Sprintf(`
`, i)) + + // Page title + title := p.Title + if title == "" { + title = p.Path + } + buf.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(title))) + buf.WriteString(fmt.Sprintf(`
%s
`, html.EscapeString(p.Path))) + + // Render markdown body to HTML + body := p.Body + + // If including assets, replace image references with data URIs + if includeAssets && assets != nil { + body = embedImages(body, p.ImageRefs, assets, ctx) + } + + var mdBuf bytes.Buffer + if err := md.Convert([]byte(body), &mdBuf); err != nil { + slog.Warn("pdf: markdown render failed", slog.String("page", p.Path), slog.Any("error", err)) + buf.WriteString(fmt.Sprintf(`
%s
`, html.EscapeString(body))) + } else { + buf.WriteString(mdBuf.String()) + } + + buf.WriteString(`
`) + } + + buf.WriteString(``) + return buf.String() +} + +// embedImages replaces markdown image references with base64 data URIs. +func embedImages(body string, imageRefs []string, assets AssetReader, ctx context.Context) string { + for _, ref := range imageRefs { + content, mime, err := assets.ReadAsset(ctx, ref) + if err != nil { + slog.Warn("pdf: failed to read asset", slog.String("ref", ref), slog.Any("error", err)) + continue + } + // Build data URI + dataURI := fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(content)) + + // Replace the reference in the markdown body. + // Image refs appear as relative paths in markdown: ![alt](path) + // We need to find the markdown image syntax referencing this asset. + body = strings.ReplaceAll(body, ref, dataURI) + } + return body +} + +// htmlToPDF uses chromedp to render HTML to PDF. +func htmlToPDF(ctx context.Context, browserPath, htmlContent, pageSize string) ([]byte, error) { + // Create a context with the browser path + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.ExecPath(browserPath), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("no-sandbox", true), + ) + + allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...) + defer allocCancel() + + taskCtx, taskCancel := chromedp.NewContext(allocCtx) + defer taskCancel() + + // Set a reasonable timeout + taskCtx, timeoutCancel := context.WithTimeout(taskCtx, 60*time.Second) + defer timeoutCancel() + + // Navigate to the HTML content and print to PDF + var pdfBuf []byte + err := chromedp.Run(taskCtx, + chromedp.Navigate("about:blank"), + chromedp.ActionFunc(func(ctx context.Context) error { + frameTree, err := page.GetFrameTree().Do(ctx) + if err != nil { + return err + } + return page.SetDocumentContent(frameTree.Frame.ID, htmlContent).Do(ctx) + }), + chromedp.ActionFunc(func(ctx context.Context) error { + paperWidth, paperHeight := paperDimensions(pageSize) + var err error + pdfBuf, _, err = page.PrintToPDF(). + WithPaperWidth(paperWidth). + WithPaperHeight(paperHeight). + WithMarginTop(0.5). + WithMarginBottom(0.5). + WithMarginLeft(0.5). + WithMarginRight(0.5). + WithPrintBackground(true). + Do(ctx) + return err + }), + ) + if err != nil { + return nil, err + } + + return pdfBuf, nil +} + +// paperDimensions returns width and height in inches for the given page size. +func paperDimensions(size string) (width, height float64) { + switch strings.ToLower(size) { + case "letter": + return 8.5, 11 + case "legal": + return 8.5, 14 + case "a3": + return 11.69, 16.54 + default: // A4 + return 8.27, 11.69 + } +} + +// findBrowser scans for a Chromium-based browser on the system. +func findBrowser() string { + // Platform-specific paths to try + var candidates []string + + switch runtime.GOOS { + case "darwin": + candidates = []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + } + case "windows": + candidates = []string{ + `C:\Program Files\Google\Chrome\Application\chrome.exe`, + `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`, + `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, + `C:\Program Files\Microsoft\Edge\Application\msedge.exe`, + } + default: // linux and others + candidates = []string{} + } + + // Also check $PATH for common names + pathNames := []string{ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "microsoft-edge", + "microsoft-edge-stable", + "brave-browser", + } + + for _, name := range pathNames { + if path, err := exec.LookPath(name); err == nil { + return path + } + } + + for _, path := range candidates { + if _, err := exec.LookPath(path); err == nil { + return path + } + } + + return "" +} + +// pdfCSS is the stylesheet used for PDF rendering. +const pdfCSS = ` +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 12pt; + line-height: 1.6; + color: #1a1a1a; + max-width: 100%; +} + +.toc { + page-break-after: always; +} +.toc h1 { + font-size: 24pt; + margin-bottom: 16pt; +} +.toc ul { + list-style: none; + padding: 0; +} +.toc li { + padding: 4pt 0; + border-bottom: 1px solid #eee; +} +.toc a { + color: #0366d6; + text-decoration: none; +} + +article { + page-break-before: always; +} +article:first-of-type { + page-break-before: auto; +} + +.page-title { + font-size: 20pt; + margin-bottom: 4pt; + color: #111; +} +.page-path { + font-size: 9pt; + color: #666; + font-family: monospace; + margin-bottom: 16pt; + padding-bottom: 8pt; + border-bottom: 1px solid #ddd; +} + +h1, h2, h3, h4 { + page-break-after: avoid; +} +h1 { font-size: 18pt; } +h2 { font-size: 15pt; } +h3 { font-size: 13pt; } + +pre, code { + font-family: "SF Mono", "Fira Code", monospace; + font-size: 10pt; +} +pre { + background: #f6f8fa; + padding: 12pt; + border-radius: 4pt; + overflow-wrap: break-word; + white-space: pre-wrap; + page-break-inside: avoid; +} +code { + background: #f0f0f0; + padding: 1pt 4pt; + border-radius: 2pt; +} +pre code { + background: none; + padding: 0; +} + +table { + border-collapse: collapse; + width: 100%; + margin: 12pt 0; + page-break-inside: avoid; +} +th, td { + border: 1px solid #ddd; + padding: 6pt 10pt; + text-align: left; +} +th { + background: #f6f8fa; + font-weight: 600; +} + +img { + max-width: 100%; + height: auto; + page-break-inside: avoid; +} + +blockquote { + border-left: 3pt solid #ddd; + margin-left: 0; + padding-left: 12pt; + color: #555; +} + +a { + color: #0366d6; +} +` diff --git a/internal/share/pdf_test.go b/internal/share/pdf_test.go new file mode 100644 index 0000000..d72393d --- /dev/null +++ b/internal/share/pdf_test.go @@ -0,0 +1,16 @@ +package share + +import "testing" + +func TestPDFRegistered(t *testing.T) { + if findBrowser() == "" { + t.Skip("no Chromium-based browser on $PATH — PDF sharer won't register") + } + s := Get("pdf") + if s == nil { + t.Fatal("pdf sharer not registered despite browser being available") + } + if s.Name() != "pdf" { + t.Errorf("expected name 'pdf', got %q", s.Name()) + } +} diff --git a/internal/share/share.go b/internal/share/share.go new file mode 100644 index 0000000..779cff7 --- /dev/null +++ b/internal/share/share.go @@ -0,0 +1,172 @@ +// Package share defines the pluggable export system for the mind-map wiki. +// Each export format (zip, PDF, HTML, etc.) implements the Sharer interface +// and registers itself in the global registry. The HTTP and MCP layers +// resolve a format name to a Sharer and delegate streaming. +package share + +import ( + "context" + "fmt" + "io" + "sync" + "time" +) + +// Page mirrors the subset of wiki.Page fields needed for export. +// Decoupled from the wiki package so the share package doesn't import it. +type Page struct { + // Path relative to the wiki root, without extension. + Path string + // Title extracted from frontmatter or first heading. + Title string + // Body is the raw markdown content (without frontmatter). + Body string + // Frontmatter is the parsed YAML frontmatter as key-value pairs. + Frontmatter map[string]interface{} + // ModifiedAt is the file modification time. + ModifiedAt time.Time + // ImageRefs lists wiki-relative asset paths referenced by this page. + ImageRefs []string +} + +// AssetReader provides on-demand access to asset bytes by wiki-relative path. +type AssetReader interface { + ReadAsset(ctx context.Context, path string) (content []byte, mime string, err error) +} + +// SettingsField is a single configurable option within a SharerSettings. +type SettingsField struct { + // Key is the machine name (e.g. "include_assets", "page_size"). + Key string `json:"key"` + // Label is the human-readable display name. + Label string `json:"label"` + // Description is optional help text shown below the field. + Description string `json:"description,omitempty"` + // Type is one of: "bool", "int", "string", "enum". + Type string `json:"type"` + // Default is the default value. + Default any `json:"default"` + // Enum lists the allowed values when Type == "enum". + Enum []string `json:"enum,omitempty"` +} + +// SharerSettings describes the configurable knobs a share plugin exposes. +// Serialized as JSON; the web UI renders these as form fields. +type SharerSettings struct { + // Fields is an ordered list of configuration fields the plugin accepts. + Fields []SettingsField `json:"fields"` +} + +// ShareConfig holds the user's choices for a specific export invocation. +type ShareConfig struct { + // Format is the Sharer name to use (e.g. "zip"). + Format string `json:"format"` + // Page is the starting page for link-graph traversal. + Page string `json:"page"` + // Depth controls how many wikilink hops to follow from the start page. + // -1 = unlimited (all reachable pages), 0 = just this page, + // 1 = this page + pages it links to, etc. + Depth int `json:"depth"` + // Settings holds the plugin-specific key-value pairs. + // Keys correspond to SharerSettings.Fields[].Key. + Settings map[string]any `json:"settings,omitempty"` +} + +// ExportRequest is the fully-resolved bundle passed to Sharer.Export(). +type ExportRequest struct { + Config ShareConfig + Pages []Page + Assets AssetReader +} + +// Sharer is the interface every export plugin implements. +type Sharer interface { + // Name returns the unique identifier for this format (e.g. "zip", "pdf"). + Name() string + // Description returns a human-readable description for UI display. + Description() string + // Settings returns the schema for this plugin's configuration. + Settings() SharerSettings + // Export writes the exported content to w using the given request. + Export(ctx context.Context, w io.Writer, req ExportRequest) error + // ContentType returns the MIME type for HTTP responses. + ContentType() string + // FileExtension returns the file extension including the dot (e.g. ".zip"). + FileExtension() string +} + +// FormatInfo is the JSON-serializable metadata about a registered format. +type FormatInfo struct { + Name string `json:"name"` + Description string `json:"description"` + ContentType string `json:"content_type"` + Extension string `json:"extension"` + Settings SharerSettings `json:"settings"` +} + +// registry holds the global set of registered sharers. +var ( + registryMu sync.RWMutex + registry = make(map[string]Sharer) +) + +// Register adds a Sharer to the global registry. Panics on duplicate name. +func Register(s Sharer) { + registryMu.Lock() + defer registryMu.Unlock() + name := s.Name() + if _, exists := registry[name]; exists { + panic(fmt.Sprintf("share: duplicate sharer registration: %q", name)) + } + registry[name] = s +} + +// Get returns the Sharer registered under the given name, or nil. +func Get(name string) Sharer { + registryMu.RLock() + defer registryMu.RUnlock() + return registry[name] +} + +// Formats returns metadata for all registered sharers. +func Formats() []FormatInfo { + registryMu.RLock() + defer registryMu.RUnlock() + out := make([]FormatInfo, 0, len(registry)) + for _, s := range registry { + out = append(out, FormatInfo{ + Name: s.Name(), + Description: s.Description(), + ContentType: s.ContentType(), + Extension: s.FileExtension(), + Settings: s.Settings(), + }) + } + return out +} + +// SettingBool extracts a boolean setting from config, falling back to def. +func SettingBool(cfg ShareConfig, key string, def bool) bool { + v, ok := cfg.Settings[key] + if !ok { + return def + } + b, ok := v.(bool) + if !ok { + return def + } + return b +} + +// SettingString extracts a string setting from config, falling back to def. +func SettingString(cfg ShareConfig, key string, def string) string { + v, ok := cfg.Settings[key] + if !ok { + return def + } + s, ok := v.(string) + if !ok { + return def + } + return s +} diff --git a/internal/share/zip.go b/internal/share/zip.go new file mode 100644 index 0000000..e724a21 --- /dev/null +++ b/internal/share/zip.go @@ -0,0 +1,155 @@ +package share + +import ( + "archive/zip" + "context" + "fmt" + "io" + "strings" + "time" +) + +// ZipSharer exports wiki pages as a zip archive of markdown files with +// optional asset inclusion. +type ZipSharer struct{} + +func init() { + Register(&ZipSharer{}) +} + +func (z *ZipSharer) Name() string { return "zip" } +func (z *ZipSharer) Description() string { return "Zip archive of markdown files with optional assets" } +func (z *ZipSharer) ContentType() string { return "application/zip" } +func (z *ZipSharer) FileExtension() string { return ".zip" } + +func (z *ZipSharer) Settings() SharerSettings { + return SharerSettings{ + Fields: []SettingsField{ + { + Key: "include_assets", + Label: "Include images/assets", + Description: "Bundle referenced images from .assets/ directories into the zip", + Type: "bool", + Default: true, + }, + { + Key: "flatten", + Label: "Flatten directory structure", + Description: "Place all files in the zip root with path separators replaced by dashes", + Type: "bool", + Default: false, + }, + }, + } +} + +func (z *ZipSharer) Export(ctx context.Context, w io.Writer, req ExportRequest) error { + includeAssets := SettingBool(req.Config, "include_assets", true) + flatten := SettingBool(req.Config, "flatten", false) + + zw := zip.NewWriter(w) + defer zw.Close() + + for _, page := range req.Pages { + if err := ctx.Err(); err != nil { + return err + } + + mdContent := reconstitute(page) + mdPath := pageMdPath(page.Path, flatten) + + hdr := &zip.FileHeader{ + Name: mdPath, + Method: zip.Deflate, + Modified: page.ModifiedAt, + } + fw, err := zw.CreateHeader(hdr) + if err != nil { + return fmt.Errorf("create zip entry %s: %w", mdPath, err) + } + if _, err := io.WriteString(fw, mdContent); err != nil { + return fmt.Errorf("write zip entry %s: %w", mdPath, err) + } + + if includeAssets && req.Assets != nil { + for _, assetPath := range page.ImageRefs { + if err := ctx.Err(); err != nil { + return err + } + content, _, err := req.Assets.ReadAsset(ctx, assetPath) + if err != nil { + continue // skip missing assets + } + zipAssetPath := assetZipPath(assetPath, flatten) + ahdr := &zip.FileHeader{ + Name: zipAssetPath, + Method: zip.Deflate, + Modified: page.ModifiedAt, + } + afw, err := zw.CreateHeader(ahdr) + if err != nil { + return fmt.Errorf("create asset entry %s: %w", zipAssetPath, err) + } + if _, err := afw.Write(content); err != nil { + return fmt.Errorf("write asset entry %s: %w", zipAssetPath, err) + } + } + } + } + + return nil +} + +// reconstitute rebuilds a markdown file from a Page, including frontmatter. +func reconstitute(p Page) string { + var sb strings.Builder + if len(p.Frontmatter) > 0 { + sb.WriteString("---\n") + for k, v := range p.Frontmatter { + sb.WriteString(fmt.Sprintf("%s: %v\n", k, yamlValue(v))) + } + sb.WriteString("---\n\n") + } + sb.WriteString(p.Body) + if !strings.HasSuffix(p.Body, "\n") { + sb.WriteString("\n") + } + return sb.String() +} + +// yamlValue provides a simple YAML-safe string representation of a value. +func yamlValue(v any) string { + switch val := v.(type) { + case string: + if strings.ContainsAny(val, ":#{}[]&*!|>'\"%@`") || val == "" { + return fmt.Sprintf("%q", val) + } + return val + case []interface{}: + parts := make([]string, len(val)) + for i, item := range val { + parts[i] = fmt.Sprintf("%v", item) + } + return "[" + strings.Join(parts, ", ") + "]" + case time.Time: + return val.Format(time.RFC3339) + default: + return fmt.Sprintf("%v", val) + } +} + +// pageMdPath computes the zip-internal path for a markdown file. +func pageMdPath(pagePath string, flatten bool) string { + if flatten { + return strings.ReplaceAll(pagePath, "/", "--") + ".md" + } + return pagePath + ".md" +} + +// assetZipPath computes the zip-internal path for an asset file. +func assetZipPath(assetPath string, flatten bool) string { + if flatten { + return strings.ReplaceAll(assetPath, "/", "--") + } + return assetPath +} diff --git a/internal/share/zip_test.go b/internal/share/zip_test.go new file mode 100644 index 0000000..4597e4c --- /dev/null +++ b/internal/share/zip_test.go @@ -0,0 +1,241 @@ +package share + +import ( + "archive/zip" + "bytes" + "context" + "io" + "strings" + "testing" + "time" +) + +// mockAssetReader implements AssetReader for tests. +type mockAssetReader struct { + assets map[string][]byte +} + +func (m *mockAssetReader) ReadAsset(_ context.Context, path string) ([]byte, string, error) { + data, ok := m.assets[path] + if !ok { + return nil, "", io.EOF + } + return data, "image/png", nil +} + +func TestZipSharer_Basic(t *testing.T) { + z := &ZipSharer{} + + pages := []Page{ + { + Path: "projects/mind-map", + Title: "mind-map", + Body: "# mind-map\n\nA wiki engine.", + Frontmatter: map[string]interface{}{"title": "mind-map", "type": "project"}, + ModifiedAt: time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC), + }, + { + Path: "index", + Title: "Home", + Body: "# Welcome\n", + ModifiedAt: time.Date(2025, 1, 14, 9, 0, 0, 0, time.UTC), + }, + } + + cfg := ShareConfig{ + Format: "zip", + Settings: map[string]any{"include_assets": false}, + } + req := ExportRequest{Config: cfg, Pages: pages} + + var buf bytes.Buffer + if err := z.Export(context.Background(), &buf, req); err != nil { + t.Fatalf("Export: %v", err) + } + + // Read the zip and verify contents + r, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatalf("zip.NewReader: %v", err) + } + + if len(r.File) != 2 { + t.Fatalf("expected 2 zip entries, got %d", len(r.File)) + } + + // Check first entry + if r.File[0].Name != "index.md" && r.File[1].Name != "index.md" { + t.Errorf("expected index.md in zip, got entries: %v, %v", r.File[0].Name, r.File[1].Name) + } + + // Read content of first entry + for _, f := range r.File { + if f.Name == "projects/mind-map.md" { + rc, err := f.Open() + if err != nil { + t.Fatalf("Open %s: %v", f.Name, err) + } + data, _ := io.ReadAll(rc) + rc.Close() + content := string(data) + if !strings.Contains(content, "# mind-map") { + t.Errorf("expected markdown body in %s, got: %s", f.Name, content) + } + if !strings.Contains(content, "title: mind-map") { + t.Errorf("expected frontmatter in %s, got: %s", f.Name, content) + } + } + } +} + +func TestZipSharer_WithAssets(t *testing.T) { + z := &ZipSharer{} + + pages := []Page{ + { + Path: "docs/guide", + Title: "Guide", + Body: "# Guide\n\n![diagram](docs/guide.assets/diagram.png)\n", + ModifiedAt: time.Now(), + ImageRefs: []string{"docs/guide.assets/diagram.png"}, + }, + } + + assets := &mockAssetReader{ + assets: map[string][]byte{ + "docs/guide.assets/diagram.png": []byte("fake png data"), + }, + } + + cfg := ShareConfig{ + Format: "zip", + Settings: map[string]any{"include_assets": true}, + } + req := ExportRequest{Config: cfg, Pages: pages, Assets: assets} + + var buf bytes.Buffer + if err := z.Export(context.Background(), &buf, req); err != nil { + t.Fatalf("Export: %v", err) + } + + r, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatalf("zip.NewReader: %v", err) + } + + // Should have the .md file and the asset + if len(r.File) != 2 { + names := make([]string, len(r.File)) + for i, f := range r.File { + names[i] = f.Name + } + t.Fatalf("expected 2 zip entries, got %d: %v", len(r.File), names) + } + + found := map[string]bool{} + for _, f := range r.File { + found[f.Name] = true + } + if !found["docs/guide.md"] { + t.Error("missing docs/guide.md in zip") + } + if !found["docs/guide.assets/diagram.png"] { + t.Error("missing docs/guide.assets/diagram.png in zip") + } +} + +func TestZipSharer_Flatten(t *testing.T) { + z := &ZipSharer{} + + pages := []Page{ + { + Path: "projects/mind-map", + Title: "mind-map", + Body: "# mind-map\n", + ModifiedAt: time.Now(), + }, + } + + cfg := ShareConfig{ + Format: "zip", + Settings: map[string]any{"flatten": true, "include_assets": false}, + } + req := ExportRequest{Config: cfg, Pages: pages} + + var buf bytes.Buffer + if err := z.Export(context.Background(), &buf, req); err != nil { + t.Fatalf("Export: %v", err) + } + + r, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatalf("zip.NewReader: %v", err) + } + + if r.File[0].Name != "projects--mind-map.md" { + t.Errorf("expected flattened name, got %q", r.File[0].Name) + } +} + +func TestRegistry(t *testing.T) { + // The zip sharer is registered via init() + s := Get("zip") + if s == nil { + t.Fatal("zip sharer not registered") + } + if s.Name() != "zip" { + t.Errorf("expected name 'zip', got %q", s.Name()) + } + if s.ContentType() != "application/zip" { + t.Errorf("expected content type 'application/zip', got %q", s.ContentType()) + } + if s.FileExtension() != ".zip" { + t.Errorf("expected extension '.zip', got %q", s.FileExtension()) + } + + // Check settings schema + settings := s.Settings() + if len(settings.Fields) != 2 { + t.Fatalf("expected 2 settings fields, got %d", len(settings.Fields)) + } + if settings.Fields[0].Key != "include_assets" { + t.Errorf("expected first field key 'include_assets', got %q", settings.Fields[0].Key) + } +} + +func TestFormats(t *testing.T) { + formats := Formats() + if len(formats) == 0 { + t.Fatal("no formats registered") + } + found := false + for _, f := range formats { + if f.Name == "zip" { + found = true + break + } + } + if !found { + t.Error("zip format not found in Formats()") + } +} + +func TestSettingBool(t *testing.T) { + cfg := ShareConfig{Settings: map[string]any{"flag": true}} + if !SettingBool(cfg, "flag", false) { + t.Error("expected true") + } + if SettingBool(cfg, "missing", true) != true { + t.Error("expected default true for missing key") + } +} + +func TestSettingString(t *testing.T) { + cfg := ShareConfig{Settings: map[string]any{"name": "hello"}} + if SettingString(cfg, "name", "") != "hello" { + t.Error("expected 'hello'") + } + if SettingString(cfg, "missing", "def") != "def" { + t.Error("expected default 'def'") + } +} diff --git a/internal/wiki/export.go b/internal/wiki/export.go new file mode 100644 index 0000000..fb2bb88 --- /dev/null +++ b/internal/wiki/export.go @@ -0,0 +1,125 @@ +package wiki + +import ( + "context" + "encoding/json" + "log/slog" + "time" +) + +// ExportPage is a page with full content suitable for export. It includes +// the raw body, frontmatter, and the list of image asset paths referenced. +type ExportPage struct { + Path string `json:"path"` + Title string `json:"title"` + Body string `json:"body"` + Frontmatter map[string]interface{} `json:"frontmatter,omitempty"` + ModifiedAt time.Time `json:"modified_at"` + ImageRefs []string `json:"image_refs,omitempty"` +} + +// ExportPages returns pages reachable from startPage by following outgoing +// wikilinks up to the given depth (BFS). +// +// Parameters: +// - startPage: the page to start from (required) +// - depth: how many link-hops to follow. +// 0 = just the start page itself +// 1 = start page + pages it links to +// N = N hops from start +// -1 = unlimited (follow all reachable links) +func (w *Wiki) ExportPages(ctx context.Context, startPage string, depth int) ([]ExportPage, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + normalized, err := normalizePagePath(startPage) + if err != nil { + return nil, err + } + startPage = normalized + + // BFS traversal of the wikilink graph + visited := map[string]bool{startPage: true} + queue := []string{startPage} + currentDepth := 0 + + for len(queue) > 0 && (depth < 0 || currentDepth < depth) { + if err := ctx.Err(); err != nil { + return nil, err + } + + // Process all pages at the current depth level + levelSize := len(queue) + var nextLevel []string + for i := 0; i < levelSize; i++ { + links, err := w.getLinks(ctx, queue[i]) + if err != nil { + slog.Warn("export: failed to get links", + slog.String("page", queue[i]), slog.Any("error", err)) + continue + } + for _, target := range links { + if !visited[target] { + visited[target] = true + nextLevel = append(nextLevel, target) + } + } + } + queue = nextLevel + currentDepth++ + } + + // Fetch full content for all visited pages + var pages []ExportPage + for path := range visited { + if err := ctx.Err(); err != nil { + return nil, err + } + + ep, err := w.exportOnePage(ctx, path) + if err != nil { + slog.Warn("export: failed to load page", + slog.String("page", path), slog.Any("error", err)) + continue + } + if ep != nil { + pages = append(pages, *ep) + } + } + + return pages, nil +} + +// exportOnePage loads a single page for export. Returns nil if the page +// doesn't exist (dangling link). +func (w *Wiki) exportOnePage(ctx context.Context, pagePath string) (*ExportPage, error) { + var p ExportPage + var metaStr, modified string + err := w.db.QueryRowContext(ctx, + "SELECT path, title, body, meta, modified FROM pages WHERE path = ?", + pagePath, + ).Scan(&p.Path, &p.Title, &p.Body, &metaStr, &modified) + if err != nil { + // Page might not exist (dangling wikilink) — not an error + return nil, nil + } + + if err := json.Unmarshal([]byte(metaStr), &p.Frontmatter); err != nil { + slog.Warn("export page metadata parse error", + slog.String("page", p.Path), slog.Any("error", err)) + } + if t, err := time.Parse(time.RFC3339Nano, modified); err == nil { + p.ModifiedAt = t + } + + images, err := w.imageRefsFor(ctx, p.Path) + if err != nil { + slog.Warn("export page image refs error", + slog.String("page", p.Path), slog.Any("error", err)) + } + p.ImageRefs = images + + return &p, nil +} + diff --git a/internal/wiki/export_test.go b/internal/wiki/export_test.go new file mode 100644 index 0000000..e5fe8b3 --- /dev/null +++ b/internal/wiki/export_test.go @@ -0,0 +1,114 @@ +package wiki + +import ( + "context" + "sort" + "testing" +) + +func TestExportPages_JustThisPage(t *testing.T) { + w, _ := testWiki(t) + ctx := context.Background() + + // Depth 0 = just the start page + pages, err := w.ExportPages(ctx, "index", 0) + if err != nil { + t.Fatalf("ExportPages: %v", err) + } + + if len(pages) != 1 { + t.Fatalf("expected 1 page, got %d: %v", len(pages), pageNames(pages)) + } + if pages[0].Path != "index" { + t.Errorf("expected index, got %q", pages[0].Path) + } + if pages[0].Body == "" { + t.Error("page body should not be empty") + } +} + +func TestExportPages_OneHop(t *testing.T) { + w, _ := testWiki(t) + ctx := context.Background() + + // Depth 1 from index: index + its direct links (projects/mind-map, people/alice) + pages, err := w.ExportPages(ctx, "index", 1) + if err != nil { + t.Fatalf("ExportPages: %v", err) + } + + names := pageNames(pages) + sort.Strings(names) + expected := []string{"index", "people/alice", "projects/mind-map"} + if len(names) != len(expected) { + t.Fatalf("expected %v, got %v", expected, names) + } + for i, name := range names { + if name != expected[i] { + t.Errorf("page[%d] = %q, want %q", i, name, expected[i]) + } + } +} + +func TestExportPages_Unlimited(t *testing.T) { + w, _ := testWiki(t) + ctx := context.Background() + + // Depth -1 from index: follow all reachable links + // index → projects/mind-map, people/alice + // projects/mind-map → Go, index, people/alice + // people/alice → projects/mind-map + // So all 4 pages should be reachable + pages, err := w.ExportPages(ctx, "index", -1) + if err != nil { + t.Fatalf("ExportPages: %v", err) + } + + if len(pages) != 4 { + t.Fatalf("expected 4 pages (all reachable), got %d: %v", len(pages), pageNames(pages)) + } +} + +func TestExportPages_LeafNode(t *testing.T) { + w, _ := testWiki(t) + ctx := context.Background() + + // Go has no outgoing links, so any depth > 0 still returns just Go + pages, err := w.ExportPages(ctx, "Go", 5) + if err != nil { + t.Fatalf("ExportPages: %v", err) + } + + if len(pages) != 1 { + t.Fatalf("expected 1 page from leaf node, got %d: %v", len(pages), pageNames(pages)) + } + if pages[0].Path != "Go" { + t.Errorf("expected Go, got %q", pages[0].Path) + } +} + +func TestExportPages_DanglingLink(t *testing.T) { + w, _ := testWiki(t) + ctx := context.Background() + + // Create a page with a link to a non-existent page + w.CreatePage(ctx, "test-dangling", "Links to [[does-not-exist]].") + + pages, err := w.ExportPages(ctx, "test-dangling", 1) + if err != nil { + t.Fatalf("ExportPages: %v", err) + } + + // Should only contain the start page (dangling link target is skipped) + if len(pages) != 1 { + t.Fatalf("expected 1 page (dangling link skipped), got %d: %v", len(pages), pageNames(pages)) + } +} + +func pageNames(pages []ExportPage) []string { + names := make([]string, len(pages)) + for i, p := range pages { + names[i] = p.Path + } + return names +} diff --git a/tools/package-lock.json b/tools/package-lock.json new file mode 100644 index 0000000..1baeb5d --- /dev/null +++ b/tools/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tools", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "playwright": "^1.60.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 0000000..626e3b3 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,16 @@ +{ + "name": "tools", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "playwright": "^1.60.0" + } +} \ No newline at end of file diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs new file mode 100644 index 0000000..35eb9f7 --- /dev/null +++ b/tools/screenshots.mjs @@ -0,0 +1,216 @@ +/** + * Playwright script to capture screenshots of mind-map features. + * Run from the repo root inside the devcontainer: + * node tools/screenshots.mjs + */ +import { chromium } from 'playwright'; +import { spawn } from 'child_process'; +import { existsSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { setTimeout as sleep } from 'timers/promises'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const OUT = join(ROOT, 'docs', 'screenshots'); +const PORT = process.env.SCREENSHOT_PORT || '14242'; +const BASE = `http://127.0.0.1:${PORT}`; + +mkdirSync(OUT, { recursive: true }); + +// Build if needed +const binPath = '/tmp/mind-map-bin'; +if (!existsSync(binPath)) { + console.log('Build the server first: go build -o /tmp/mind-map-bin ./cmd/mind-map/'); + process.exit(1); +} + +// Start server +console.log(`Starting server on port ${PORT}...`); +const server = spawn(binPath, ['serve', '--addr', `127.0.0.1:${PORT}`], { + cwd: ROOT, + stdio: ['ignore', 'pipe', 'pipe'], +}); + +// Wait for ready +let ready = false; +for (let i = 0; i < 40; i++) { + await sleep(500); + try { + const resp = await fetch(`${BASE}/api/pages`); + if (resp.ok) { ready = true; break; } + } catch {} +} +if (!ready) { + console.error('Server failed to start'); + server.kill(); + process.exit(1); +} +console.log('Server ready.\n'); + +const browser = await chromium.launch({ + args: ['--no-sandbox', '--disable-gpu'], +}); + +async function capture(name, opts, fn) { + const { dark = false, viewport = { width: 1280, height: 800 } } = opts; + const context = await browser.newContext({ + viewport, + deviceScaleFactor: 2, + colorScheme: dark ? 'dark' : 'light', + }); + const page = await context.newPage(); + try { + await fn(page); + await page.screenshot({ path: join(OUT, `${name}.png`), fullPage: false }); + console.log(` ✓ ${name}.png`); + } catch (e) { + console.error(` ✗ ${name}: ${e.message}`); + } + await context.close(); +} + +console.log('Capturing screenshots...\n'); + +// --- 1. Page view (light) --- +await capture('page-view', {}, async (page) => { + await page.goto(`${BASE}/#/introduction`); + await page.waitForSelector('.page-header', { timeout: 5000 }); + await sleep(600); +}); + +// --- 2. Page view (dark) --- +await capture('page-view-dark', { dark: true }, async (page) => { + // Set localStorage before the app loads so it initializes in dark mode + await page.addInitScript(() => { + localStorage.setItem('mm-theme', 'dark'); + }); + await page.goto(`${BASE}/#/introduction`); + await page.waitForSelector('.page-header', { timeout: 5000 }); + await sleep(300); + // Verify dark class is present, click toggle if not + const isDark = await page.evaluate(() => + document.documentElement.classList.contains('dark') + ); + if (!isDark) { + await page.locator('.theme-toggle').click(); + await sleep(400); + } + await sleep(300); +}); + +// --- 3. Search (with graph filtered) --- +await capture('search', {}, async (page) => { + // Use "architecture" as a term that hits multiple pages and shows + // a nice subgraph. Navigate to graph view first, then apply filter. + await page.goto(`${BASE}/#/`); + await page.waitForSelector('.sidebar', { timeout: 5000 }); + await sleep(300); + // Go to graph view + const graphLink = page.locator('.sidebar-header-text, [title="Show graph view"]'); + if (await graphLink.count() > 0) await graphLink.first().click(); + await sleep(1500); + // Type search term and press Enter to activate filtering + const searchInput = page.locator('input[type="search"], input[placeholder*="earch"], .search-input'); + if (await searchInput.count() > 0) { + await searchInput.first().click(); + await searchInput.first().fill('architecture'); + await searchInput.first().press('Enter'); + await sleep(1500); + } + // Click "fit all" to center the filtered nodes nicely + const fitBtn = page.locator('.graph-fit, button:has-text("fit all")'); + if (await fitBtn.count() > 0) { + await fitBtn.first().click(); + await sleep(600); + } + // Zoom in a bit on the filtered subgraph + await page.locator('.graph-canvas').hover(); + for (let i = 0; i < 2; i++) { + await page.mouse.wheel(0, -120); + await sleep(200); + } + await sleep(400); +}); + +// --- 4. Edit mode --- +await capture('edit-mode', {}, async (page) => { + await page.goto(`${BASE}/#/introduction`); + await page.waitForSelector('.page-header', { timeout: 5000 }); + await sleep(300); + await page.locator('.edit-icon-btn').first().click(); + await sleep(500); +}); + +// --- 5. Export panel --- +await capture('export-panel', {}, async (page) => { + await page.goto(`${BASE}/#/introduction`); + await page.waitForSelector('.page-header', { timeout: 5000 }); + await sleep(300); + // Click the export/share button (second edit-icon-btn) + const btns = page.locator('.edit-icon-btn'); + if (await btns.count() >= 2) { + await btns.nth(1).click(); + } else { + await page.locator('button[title="Export subtree"]').click(); + } + await sleep(600); +}); + +// --- 6. Graph view (zoomed in) --- +await capture('graph-view', {}, async (page) => { + await page.goto(`${BASE}/#/`); + await page.waitForSelector('.sidebar', { timeout: 5000 }); + await sleep(300); + // Go to graph view + const graphLink = page.locator('.sidebar-header-text, [title="Show graph view"]'); + if (await graphLink.count() > 0) { + await graphLink.first().click(); + } + await sleep(2500); // Graph layout needs time to settle + // Zoom in by calling the graph API via the saved view + await page.evaluate(() => { + localStorage.setItem('mm-graph-view', JSON.stringify({ k: 2.2, x: 0, y: 0 })); + }); + // Click "fit all" then zoom in a bit more — fit centers nicely, then we boost + const fitBtn = page.locator('.graph-fit, button:has-text("fit all")'); + if (await fitBtn.count() > 0) { + await fitBtn.first().click(); + await sleep(600); + } + // Use mouse wheel to zoom in further on the center + await page.locator('.graph-canvas').hover(); + for (let i = 0; i < 3; i++) { + await page.mouse.wheel(0, -120); + await sleep(200); + } + await sleep(400); +}); + +// --- 7. Backlinks --- +await capture('backlinks', {}, async (page) => { + // Use a page that likely has backlinks + await page.goto(`${BASE}/#/architecture/wiki-engine`); + await page.waitForSelector('.page-header', { timeout: 5000 }); + await sleep(500); + // Scroll to bottom to show backlinks + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await sleep(400); +}); + +// --- 8. Settings --- +await capture('settings', {}, async (page) => { + await page.goto(`${BASE}/#/introduction`); + await page.waitForSelector('.sidebar', { timeout: 5000 }); + await sleep(300); + // Open settings (gear icon in sidebar) + const gear = page.locator('.sidebar-status button, button[title*="etting"]'); + if (await gear.count() > 0) { + await gear.first().click(); + await sleep(500); + } +}); + +await browser.close(); +server.kill(); +console.log(`\nDone! Screenshots saved to ${OUT}`); diff --git a/webui/src/App.tsx b/webui/src/App.tsx index a45dca2..6c33ed0 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -5,6 +5,9 @@ import { PageBrowser } from './PageBrowser'; import { GraphView } from './GraphView'; import { searchTokens, searchRegex, Highlighted } from './search'; import { TagInput } from './TagInput'; +import { ExportPanel } from './ExportPanel'; +import { ShareIcon } from './ShareIcon'; +import { ShareSettings } from './ShareSettings'; import { marked } from 'marked'; import mermaid from 'mermaid'; @@ -65,6 +68,7 @@ export function App() { const [editContent, setEditContent] = useState(''); const [searchQuery, setSearchQuery] = useState(() => localStorage.getItem('mm-search-query') || ''); const [showSettings, setShowSettings] = useState(false); + const [showExport, setShowExport] = useState(false); const [settings, setSettings] = useState(null); const [configPath, setConfigPath] = useState(''); const [settingsDirty, setSettingsDirty] = useState(false); @@ -296,6 +300,7 @@ export function App() { setSettings(s); setConfigPath(p); setShowSettings(true); + setShowExport(false); setSettingsDirty(false); setSettingsSaved(false); setCurrent(null); @@ -566,7 +571,12 @@ export function App() { {/* Main */}
- {showSettings && settings ? ( + {showExport && current ? ( + setShowExport(false)} + /> + ) : showSettings && settings ? ( <>
Settings
@@ -735,6 +745,8 @@ export function App() {
+ +
+ <> + + + )}
diff --git a/webui/src/ExportPanel.tsx b/webui/src/ExportPanel.tsx new file mode 100644 index 0000000..eb431c5 --- /dev/null +++ b/webui/src/ExportPanel.tsx @@ -0,0 +1,159 @@ +import { useState, useEffect } from 'preact/hooks'; +import { api, ExportFormat } from './api'; + +interface ExportPanelProps { + /** The current page path to export from */ + page: string; + onClose: () => void; +} + +/** + * ExportPanel — triggered from the page header. Exports starting from the + * current page, following wikilinks to the chosen depth. + */ +export function ExportPanel({ page, onClose }: ExportPanelProps) { + const [formats, setFormats] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [selectedFormat, setSelectedFormat] = useState(''); + const [depth, setDepth] = useState(0); + + useEffect(() => { + api.exportFormats() + .then((fmts) => { + setFormats(fmts); + if (fmts.length > 0) setSelectedFormat(fmts[0].name); + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + const handleExport = () => { + const url = api.exportUrl(selectedFormat, page, depth); + const a = document.createElement('a'); + a.href = url; + a.download = ''; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + const currentFormat = formats.find((f) => f.name === selectedFormat); + + if (loading) { + return ( +
+
Export
+
Loading export formats…
+
+ ); + } + + if (error) { + return ( +
+
Export
+
Failed to load formats: {error}
+ +
+ ); + } + + if (formats.length === 0) { + return ( +
+
Export
+
No export formats available.
+ +
+ ); + } + + return ( + <> +
Export
+
+ {/* Page info */} +
+
Page
+
+ {page} +
+
+ + {/* Depth */} +
+
Depth
+
+ How many wikilink hops to follow from this page. +
+
+ + +
+
+ + {/* Format selector */} +
+
Format
+
+ {formats.map((fmt) => ( + + ))} +
+
+ + {/* Actions */} +
+ + +
+
+ + ); +} diff --git a/webui/src/ShareIcon.tsx b/webui/src/ShareIcon.tsx new file mode 100644 index 0000000..5ca372f --- /dev/null +++ b/webui/src/ShareIcon.tsx @@ -0,0 +1,15 @@ +/** Share/export icon — three nodes connected by lines. Uses currentColor for theming. */ +export function ShareIcon({ size = 16 }: { size?: number }) { + return ( + + + + ); +} diff --git a/webui/src/ShareSettings.tsx b/webui/src/ShareSettings.tsx new file mode 100644 index 0000000..c49320c --- /dev/null +++ b/webui/src/ShareSettings.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect } from 'preact/hooks'; +import { api, ExportFormat, ExportSettingsField } from './api'; + +/** + * ShareSettings renders per-plugin settings for all registered export + * formats in the main Settings panel. Each plugin gets a collapsible + * section with its fields rendered generically from the schema — + * like VS Code extension settings. + */ +export function ShareSettings() { + const [formats, setFormats] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [values, setValues] = useState>>({}); + const [collapsed, setCollapsed] = useState>({}); + + useEffect(() => { + api.exportFormats() + .then((fmts) => { + setFormats(fmts); + // Initialize with defaults + const defaults: Record> = {}; + for (const fmt of fmts) { + defaults[fmt.name] = {}; + for (const field of fmt.settings.fields) { + defaults[fmt.name][field.key] = field.default; + } + } + setValues(defaults); + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, []); + + const updateValue = (format: string, key: string, value: any) => { + setValues((prev) => ({ + ...prev, + [format]: { ...(prev[format] || {}), [key]: value }, + })); + }; + + const toggleCollapse = (name: string) => { + setCollapsed((prev) => ({ ...prev, [name]: !prev[name] })); + }; + + if (loading) { + return ( +
+
Export Extensions
+
Loading…
+
+ ); + } + + if (error) { + return ( +
+
Export Extensions
+
{error}
+
+ ); + } + + if (formats.length === 0) return null; + + return ( +
+
Export Extensions
+
+ Settings for registered export formats. These are the defaults used when exporting pages. +
+ {formats.map((fmt) => ( + + ))} +
+ ); +} + +/** Generic renderer for a single settings field based on its schema type. */ +function SettingsFieldRenderer({ + field, + value, + onChange, +}: { + field: ExportSettingsField; + value: any; + onChange: (v: any) => void; +}) { + switch (field.type) { + case 'bool': + return ( +
+
+ onChange((e.target as HTMLInputElement).checked)} + /> + +
+ {field.description &&
{field.description}
} +
+ ); + case 'enum': + return ( +
+ + {field.description &&
{field.description}
} + +
+ ); + case 'int': + return ( +
+ + {field.description &&
{field.description}
} + onChange(parseInt((e.target as HTMLInputElement).value, 10) || 0)} + /> +
+ ); + default: + return ( +
+ + {field.description &&
{field.description}
} + onChange((e.target as HTMLInputElement).value)} + /> +
+ ); + } +} diff --git a/webui/src/api.ts b/webui/src/api.ts index 4e53a46..761dcf4 100644 --- a/webui/src/api.ts +++ b/webui/src/api.ts @@ -119,6 +119,51 @@ class APIClient { } return res.json(); } + + /** + * Fetch available export formats and their settings schemas. + */ + async exportFormats(): Promise { + const res = await fetch('/api/export/formats'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return (await res.json()) || []; + } + + /** + * Trigger a file download for the given export configuration. + * Opens the export URL in a new tab/download. + */ + exportUrl(format: string, page: string, depth?: number, settings?: Record): string { + const params = new URLSearchParams(); + params.set('format', format); + params.set('page', page); + if (depth !== undefined) params.set('depth', String(depth)); + if (settings) { + for (const [key, value] of Object.entries(settings)) { + params.set(key, String(value)); + } + } + return `/api/export?${params.toString()}`; + } +} + +export interface ExportFormat { + name: string; + description: string; + content_type: string; + extension: string; + settings: { + fields: ExportSettingsField[]; + }; +} + +export interface ExportSettingsField { + key: string; + label: string; + description?: string; + type: 'bool' | 'int' | 'string' | 'enum'; + default: any; + enum?: string[]; } export interface Link { diff --git a/webui/src/styles.css b/webui/src/styles.css index 32ada0d..2c1cd74 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -902,3 +902,116 @@ mark { font-size: 13px; padding: 4px 2px; } + +/* Export panel — format picker and per-plugin settings */ +.export-format-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.export-format-option { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--border); + cursor: pointer; +} + +.export-format-option:hover { + border-color: var(--accent); +} + +.export-format-option input[type="radio"] { + margin-top: 3px; +} + +.export-format-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.export-format-name { + font-weight: 600; + font-size: 13px; + color: var(--fg); +} + +.export-format-desc { + font-size: 12px; + color: var(--fg-dim); +} + +.settings-actions { + display: flex; + gap: 8px; + padding-top: 12px; +} + +/* Share extension settings (VS Code-like collapsible per-plugin sections) */ +.share-extension { + border: 1px solid var(--border); + margin-top: 8px; +} + +.share-extension-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + background: transparent; + border: none; + color: var(--fg); + cursor: pointer; + text-align: left; + font-size: 13px; +} + +.share-extension-header:hover { + background: var(--bg-hover); +} + +.share-extension-chevron { + font-size: 11px; + color: var(--fg-dim); + width: 12px; +} + +.share-extension-name { + font-weight: 600; +} + +.share-extension-desc { + color: var(--fg-dim); + font-size: 12px; +} + +.share-extension-fields { + padding: 8px 12px 12px; + border-top: 1px solid var(--border); +} + +.export-depth-options { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} + +.export-depth-input { + width: 48px; + padding: 2px 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--fg); + font-size: 13px; + text-align: center; +} + +.export-depth-input:focus { + border-color: var(--accent); + outline: none; +}