Files
nib-v1/internal/export/html.go
lerko 564039112a
CI / test (pull_request) Failing after 10s
feat(export): add HTML card deck export
Self-contained single-file HTML export for cards. Mobile-first,
dark theme, zero dependencies. Each card type gets its own
interactive treatment: snippet tap-to-copy, template slot filling,
checklist with progress bar, decision structured layout, link
tap targets. Filter chips by type, search across all cards.

Usage: nib export -f html -o deck.html
       nib export -f html -t triage -o triage.html
2026-05-20 21:49:19 -04:00

182 lines
3.3 KiB
Go

package export
import (
_ "embed"
"encoding/json"
"html/template"
"io"
"strings"
"time"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
//go:embed template.html
var templateHTML string
type TemplateSlot struct {
Name string `json:"name"`
Default string `json:"default"`
}
type CheckStep struct {
Text string `json:"text"`
Done bool `json:"done"`
}
type DecisionData struct {
Chose string `json:"chose"`
Why string `json:"why"`
Rejected []string `json:"rejected"`
}
type CardView struct {
ID string
Glyph string
CardType string
Title string
Body template.HTML
Description template.HTML
SearchText string
Pinned bool
Tags []string
UseCount int
LinkURL string
Slots []TemplateSlot
TemplateBody string
Steps []CheckStep
Progress int
Decision DecisionData
}
type DeckView struct {
Title string
Count int
ExportedAt string
Types []string
Cards []CardView
}
func RenderHTML(w io.Writer, entities []*db.Entity, title string) error {
tmpl, err := template.New("deck").Parse(templateHTML)
if err != nil {
return err
}
seen := map[string]bool{}
var types []string
var cards []CardView
for _, e := range entities {
if e.CardType == nil {
continue
}
ct := string(*e.CardType)
if !seen[ct] {
seen[ct] = true
types = append(types, ct)
}
cards = append(cards, buildCardView(e))
}
data := DeckView{
Title: title,
Count: len(cards),
ExportedAt: time.Now().Format("Jan 2, 2006"),
Types: types,
Cards: cards,
}
return tmpl.Execute(w, data)
}
func buildCardView(e *db.Entity) CardView {
ct := ""
if e.CardType != nil {
ct = string(*e.CardType)
}
title := ""
if e.Title != nil {
title = *e.Title
}
body := e.Body
desc := ""
if e.Description != nil {
desc = *e.Description
}
search := strings.ToLower(title + " " + body + " " + desc + " " + strings.Join(e.Tags, " "))
cv := CardView{
ID: e.ID,
Glyph: display.DisplayGlyph(e.Glyph, e.CardType),
CardType: ct,
Title: title,
Body: template.HTML(template.HTMLEscapeString(body)),
SearchText: search,
Pinned: e.Pinned,
Tags: e.Tags,
UseCount: e.UseCount,
}
if desc != "" {
cv.Description = template.HTML(template.HTMLEscapeString(desc))
}
if e.CardData != nil {
parseCardData(&cv, ct, *e.CardData, body)
}
return cv
}
func parseCardData(cv *CardView, ct string, raw string, body string) {
switch ct {
case "snippet":
// body is the snippet content, already set
case "template":
var data struct {
Slots []TemplateSlot `json:"slots"`
}
if json.Unmarshal([]byte(raw), &data) == nil {
cv.Slots = data.Slots
}
cv.TemplateBody = body
case "checklist":
var data struct {
Steps []CheckStep `json:"steps"`
}
if json.Unmarshal([]byte(raw), &data) == nil {
cv.Steps = data.Steps
done := 0
for _, s := range cv.Steps {
if s.Done {
done++
}
}
if len(cv.Steps) > 0 {
cv.Progress = (done * 100) / len(cv.Steps)
}
}
case "decision":
var dec DecisionData
if json.Unmarshal([]byte(raw), &dec) == nil {
cv.Decision = dec
}
case "link":
var data struct {
URL string `json:"url"`
}
if json.Unmarshal([]byte(raw), &data) == nil {
cv.LinkURL = data.URL
}
}
}