tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

main.go (4891B)


      1 package main
      2 
      3 import (
      4 	"cmp"
      5 	"fmt"
      6 	"os"
      7 	"os/exec"
      8 	"path/filepath"
      9 	"runtime"
     10 	"strings"
     11 	"sync"
     12 	"time"
     13 )
     14 
     15 // caps how many content matches are collected per keystroke
     16 const resultLimit = 2000
     17 
     18 type doc struct {
     19 	src        string
     20 	rel        string
     21 	mtime      time.Time
     22 	lines      []string
     23 	linesLower []string
     24 }
     25 
     26 type match struct {
     27 	d    *doc
     28 	line int // 1-based, 0 in browse mode
     29 }
     30 
     31 func (m match) text() string {
     32 	if m.line == 0 {
     33 		return m.d.rel
     34 	}
     35 	return m.d.rel + ":" + strings.TrimSpace(m.d.lines[m.line-1])
     36 }
     37 
     38 type app struct {
     39 	base, txt string
     40 	scope     string
     41 	docs      []*doc
     42 
     43 	content bool
     44 	query   []rune
     45 	results []match
     46 	sel     int
     47 	top     int
     48 
     49 	rows, cols int
     50 	savedStty  string
     51 }
     52 
     53 func main() {
     54 	home, _ := os.UserHomeDir()
     55 	cache := cmp.Or(os.Getenv("XDG_CACHE_HOME"), filepath.Join(home, ".cache"))
     56 	a := &app{
     57 		base:    filepath.Join(home, "misc", "www"),
     58 		txt:     filepath.Join(cache, "wrd", "text"),
     59 		content: true,
     60 	}
     61 	if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
     62 		a.scope = os.Args[1]
     63 	}
     64 
     65 	a.scan()
     66 	if len(a.docs) == 0 {
     67 		fmt.Fprintln(os.Stderr, "wrd: no pages under", a.base)
     68 		os.Exit(1)
     69 	}
     70 	a.index()
     71 	a.load()
     72 	a.run()
     73 }
     74 
     75 func (a *app) scan() {
     76 	_ = filepath.WalkDir(a.base, func(p string, d os.DirEntry, err error) error {
     77 		if err != nil || d.IsDir() {
     78 			return nil // skip unreadable entries, keep walking
     79 		}
     80 		ext := strings.ToLower(filepath.Ext(p))
     81 		if ext != ".html" && ext != ".md" {
     82 			return nil
     83 		}
     84 		rel, _ := filepath.Rel(a.base, p)
     85 		if a.scope != "" && !strings.Contains(rel, a.scope) {
     86 			return nil
     87 		}
     88 		fi, err := os.Stat(p)
     89 		if err != nil {
     90 			return nil // broken symlink or a vanished file
     91 		}
     92 		a.docs = append(a.docs, &doc{src: p, rel: rel, mtime: fi.ModTime()})
     93 		return nil
     94 	})
     95 }
     96 
     97 // index renders each page to a text cache, skipping ones already up to date
     98 func (a *app) index() {
     99 	type job struct{ src, out string }
    100 	var jobs []job
    101 	for _, d := range a.docs {
    102 		out := filepath.Join(a.txt, d.rel+".txt")
    103 		if oi, err := os.Stat(out); err == nil &&
    104 			!d.mtime.Truncate(time.Second).After(oi.ModTime().Truncate(time.Second)) {
    105 			continue
    106 		}
    107 		jobs = append(jobs, job{d.src, out})
    108 	}
    109 	if len(jobs) == 0 {
    110 		return
    111 	}
    112 
    113 	fmt.Fprintf(os.Stderr, "indexing %d pages\n", len(jobs))
    114 	ch := make(chan job)
    115 	var wg sync.WaitGroup
    116 	for range runtime.NumCPU() {
    117 		wg.Add(1)
    118 		go func() {
    119 			defer wg.Done()
    120 			for j := range ch {
    121 				if err := render(j.src, j.out); err != nil {
    122 					fmt.Fprintf(os.Stderr, "wrd: %s: %v\n", j.src, err)
    123 				}
    124 			}
    125 		}()
    126 	}
    127 	for _, j := range jobs {
    128 		ch <- j
    129 	}
    130 	close(ch)
    131 	wg.Wait()
    132 }
    133 
    134 func render(src, out string) error {
    135 	fi, err := os.Stat(src)
    136 	if err != nil {
    137 		return err
    138 	}
    139 	if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
    140 		return err
    141 	}
    142 	f, err := os.Create(out)
    143 	if err != nil {
    144 		return err
    145 	}
    146 
    147 	html := strings.EqualFold(filepath.Ext(src), ".html")
    148 	cmd := exec.Command("pandoc", "-t", "plain", src)
    149 	if html {
    150 		cmd = exec.Command("cha", "-d", src)
    151 	}
    152 	cmd.Stdout = f
    153 	rerr := cmd.Run()
    154 	if rerr != nil && !html { // fall back to the raw markdown
    155 		_, _ = f.Seek(0, 0)
    156 		_ = f.Truncate(0)
    157 		if b, e := os.ReadFile(src); e == nil {
    158 			_, _ = f.Write(b)
    159 			rerr = nil
    160 		}
    161 	}
    162 	_ = f.Close()
    163 
    164 	if rerr != nil {
    165 		_ = os.Remove(out)
    166 		return rerr
    167 	}
    168 	_ = os.Chtimes(out, time.Now(), fi.ModTime())
    169 	return nil
    170 }
    171 
    172 func (a *app) load() {
    173 	for _, d := range a.docs {
    174 		b, err := os.ReadFile(filepath.Join(a.txt, d.rel+".txt"))
    175 		if err != nil {
    176 			continue
    177 		}
    178 		s := strings.TrimRight(string(b), "\n")
    179 		if strings.TrimSpace(s) == "" {
    180 			continue // missing or empty render: leave it as no-text
    181 		}
    182 		d.lines = strings.Split(s, "\n")
    183 		d.linesLower = make([]string, len(d.lines))
    184 		for i, ln := range d.lines {
    185 			d.linesLower[i] = strings.ToLower(ln)
    186 		}
    187 	}
    188 }
    189 
    190 func (a *app) search() {
    191 	q := strings.TrimSpace(string(a.query))
    192 	fold := q == strings.ToLower(q) // lowercase query => case-insensitive
    193 	a.results = a.results[:0]
    194 
    195 	switch {
    196 	case a.content && q == "":
    197 	case a.content:
    198 		for _, d := range a.docs {
    199 			lines := d.lines
    200 			if fold {
    201 				lines = d.linesLower
    202 			}
    203 			for i, ln := range lines {
    204 				if strings.Contains(ln, q) {
    205 					a.results = append(a.results, match{d, i + 1})
    206 					if len(a.results) >= resultLimit {
    207 						goto done
    208 					}
    209 				}
    210 			}
    211 		}
    212 	default:
    213 		for _, d := range a.docs {
    214 			hay := d.rel
    215 			if fold {
    216 				hay = strings.ToLower(hay)
    217 			}
    218 			if fuzzy(hay, q) {
    219 				a.results = append(a.results, match{d: d})
    220 			}
    221 		}
    222 	}
    223 done:
    224 	a.sel = max(0, min(a.sel, len(a.results)-1))
    225 	a.top = 0
    226 }
    227 
    228 func fuzzy(hay, needle string) bool {
    229 	nr := []rune(needle)
    230 	i := 0
    231 	for _, c := range hay {
    232 		if i == len(nr) {
    233 			break
    234 		}
    235 		if c == nr[i] {
    236 			i++
    237 		}
    238 	}
    239 	return i == len(nr)
    240 }
    241 
    242 func url(rel string) string {
    243 	rel = strings.TrimSuffix(rel, "/index.html")
    244 	rel = strings.TrimSuffix(rel, "index.html")
    245 	return "https://" + rel
    246 }