Skip to content

Commit 773081a

Browse files
committed
implement 'bull graph' subcommand for quick garden analysis
Generated using Claude Opus 4.6. In combination with a capable language model, this tool enables very quick turnaround to analyze / re-organize / clean up your knowledge garden. For example, I tried this prompt on one of my gardens: > run 'bull graph' on my ~/keep garden and tell me the result > (want to see how useful one invocation is) ● Bash(bull --content ~/keep graph 2>&1) ⎿  github.com/gokrazy/bull v0.0.0-20260327101859-04e186f16833+dirty 2026/03/27 11:44:38 indexed 617 pages (263 links) in 0.02s … +93 lines (ctrl+o to expand) ● 617 pages, 263 links, indexed in 0.02s. Some observations: - 494 orphans — that's 80% of pages! Most are in archive/ and untagged/ directories. These are likely pages that were created standalone and never linked from elsewhere. The Library/Core/ pages are SilverBullet leftovers. - 38 broken links — a few categories: - SilverBullet refs (!silverbullet.md/...) — legacy links from when this was a SilverBullet wiki - Absolute paths (/ax52, /nix/storage3, etc.) in computer-map — these are root-relative links that bull's link checker doesn't resolve - Hugo blog links (/posts/2019-...) in cool papers/ — links to your blog, not wiki pages - Template placeholders ({{name}}, {{ref}}) — SilverBullet template syntax in Library/Core/ - A few genuine broken links — bull → wikilinks, prompts/health → health/2026-03-14, self-hosting → self-hosting/peertube The genuinely actionable items are probably the last category — a handful of real broken links. The orphan list is useful for identifying cleanup candidates, especially the archive/ and untagged/ directories which could potentially be pruned.
1 parent 04e186f commit 773081a

3 files changed

Lines changed: 475 additions & 1 deletion

File tree

internal/bull/bull.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func (c *Customization) Runbull() error {
8888
if info, ok := debug.ReadBuildInfo(); ok {
8989
mainVersion = info.Main.Version
9090
}
91-
fmt.Printf("github.com/gokrazy/bull %s\n", mainVersion)
91+
fmt.Fprintf(os.Stderr, "github.com/gokrazy/bull %s\n", mainVersion)
9292

9393
flag.Usage = func() {
9494
os.Stderr.Write([]byte(`
@@ -103,6 +103,7 @@ If no verb is specified, bull will default to 'serve'.
103103
Verbs:
104104
serve - serve markdown pages
105105
mv - rename markdown page and update links
106+
graph - show link graph, orphans, and broken links
106107
107108
Examples:
108109
% bull # serve the current directory
@@ -137,6 +138,8 @@ Global command-line flags:
137138
return c.serve(args)
138139
case "mv":
139140
return mv(args)
141+
case "graph":
142+
return graph(args)
140143
}
141144
fmt.Fprintf(os.Stderr, "unknown verb %q\n", verb)
142145
flag.Usage()

internal/bull/cmdgraph.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package bull
2+
3+
import (
4+
"encoding/json"
5+
"flag"
6+
"fmt"
7+
"log"
8+
"os"
9+
"slices"
10+
"strings"
11+
"time"
12+
)
13+
14+
const graphUsage = `
15+
graph - show link graph, orphans, and broken links
16+
17+
Syntax:
18+
% bull graph [--output=text|json]
19+
20+
Examples:
21+
% bull --content ~/keep graph
22+
% bull --content ~/keep graph --output=json
23+
% bull --content ~/keep graph --output=json | jq .stats
24+
`
25+
26+
type graphOutput struct {
27+
Pages map[string]graphPage `json:"pages"`
28+
Orphans []string `json:"orphans"`
29+
BrokenLinks []brokenLink `json:"broken_links"`
30+
Stats graphStats `json:"stats"`
31+
}
32+
33+
type graphPage struct {
34+
Outgoing []string `json:"outgoing"`
35+
Incoming []string `json:"incoming"`
36+
}
37+
38+
type brokenLink struct {
39+
Source string `json:"source"`
40+
Target string `json:"target"`
41+
}
42+
43+
type graphStats struct {
44+
TotalPages int `json:"total_pages"`
45+
TotalLinks int `json:"total_links"`
46+
OrphanCount int `json:"orphan_count"`
47+
BrokenLinkCount int `json:"broken_link_count"`
48+
}
49+
50+
func graph(args []string) error {
51+
fset := flag.NewFlagSet("graph", flag.ExitOnError)
52+
fset.Usage = usage(fset, graphUsage)
53+
output := fset.String("output", "text", "output format: text or json")
54+
55+
if err := fset.Parse(args); err != nil {
56+
return err
57+
}
58+
59+
content, err := os.OpenRoot(*contentDir)
60+
if err != nil {
61+
return err
62+
}
63+
64+
cs, err := loadContentSettings(content)
65+
if err != nil {
66+
return err
67+
}
68+
69+
bull := &bullServer{
70+
content: content,
71+
contentDir: *contentDir,
72+
contentSettings: cs,
73+
contentChanged: make(chan struct{}),
74+
}
75+
if err := bull.init(); err != nil {
76+
return err
77+
}
78+
79+
start := time.Now()
80+
idx, err := bull.index()
81+
if err != nil {
82+
return err
83+
}
84+
elapsed := time.Since(start)
85+
86+
// Compute total links
87+
totalLinks := 0
88+
for _, targets := range idx.links {
89+
totalLinks += len(targets)
90+
}
91+
92+
// Find orphan pages (no incoming links, excluding "index")
93+
orphans := make([]string, 0)
94+
for pageName := range idx.links {
95+
if pageName == "index" {
96+
continue
97+
}
98+
if len(idx.backlinks[pageName]) == 0 {
99+
orphans = append(orphans, pageName)
100+
}
101+
}
102+
slices.Sort(orphans)
103+
104+
// Find broken links (target does not exist as a page)
105+
broken := make([]brokenLink, 0)
106+
for source, targets := range idx.links {
107+
for _, target := range targets {
108+
if strings.Contains(target, "://") {
109+
continue
110+
}
111+
// Strip fragment (e.g. "page#section" → "page")
112+
if i := strings.IndexByte(target, '#'); i >= 0 {
113+
target = target[:i]
114+
}
115+
if target == "" {
116+
continue
117+
}
118+
if _, exists := idx.links[target]; !exists {
119+
broken = append(broken, brokenLink{Source: source, Target: target})
120+
}
121+
}
122+
}
123+
slices.SortFunc(broken, func(a, b brokenLink) int {
124+
if c := strings.Compare(a.Source, b.Source); c != 0 {
125+
return c
126+
}
127+
return strings.Compare(a.Target, b.Target)
128+
})
129+
130+
stats := graphStats{
131+
TotalPages: int(idx.pages),
132+
TotalLinks: totalLinks,
133+
OrphanCount: len(orphans),
134+
BrokenLinkCount: len(broken),
135+
}
136+
137+
switch *output {
138+
case "json":
139+
pages := make(map[string]graphPage, len(idx.links))
140+
for pageName, targets := range idx.links {
141+
outgoing := targets
142+
if outgoing == nil {
143+
outgoing = make([]string, 0)
144+
}
145+
incoming := idx.backlinks[pageName]
146+
if incoming == nil {
147+
incoming = make([]string, 0)
148+
}
149+
pages[pageName] = graphPage{
150+
Outgoing: outgoing,
151+
Incoming: incoming,
152+
}
153+
}
154+
out := graphOutput{
155+
Pages: pages,
156+
Orphans: orphans,
157+
BrokenLinks: broken,
158+
Stats: stats,
159+
}
160+
enc := json.NewEncoder(os.Stdout)
161+
enc.SetIndent("", " ")
162+
return enc.Encode(out)
163+
164+
case "text":
165+
log.Printf("indexed %d pages (%d links) in %.2fs\n", idx.pages, totalLinks, elapsed.Seconds())
166+
167+
if len(orphans) > 0 {
168+
fmt.Println()
169+
fmt.Println("orphan pages (no incoming links):")
170+
for _, o := range orphans {
171+
fmt.Printf(" %s\n", o)
172+
}
173+
}
174+
175+
if len(broken) > 0 {
176+
fmt.Println()
177+
fmt.Println("broken links (target does not exist):")
178+
for _, bl := range broken {
179+
fmt.Printf(" %s → %s\n", bl.Source, bl.Target)
180+
}
181+
}
182+
183+
fmt.Println()
184+
fmt.Println("graph summary:")
185+
fmt.Printf(" pages: %d\n", stats.TotalPages)
186+
fmt.Printf(" links: %d\n", stats.TotalLinks)
187+
fmt.Printf(" orphans: %d\n", stats.OrphanCount)
188+
fmt.Printf(" broken links: %d\n", stats.BrokenLinkCount)
189+
190+
default:
191+
return fmt.Errorf("unknown output format %q (supported: text, json)", *output)
192+
}
193+
194+
return nil
195+
}

0 commit comments

Comments
 (0)