feat(export): add HTML card deck export
CI / test (pull_request) Failing after 10s

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
This commit is contained in:
2026-05-20 21:49:19 -04:00
parent eea59b3f3c
commit 564039112a
3 changed files with 596 additions and 6 deletions
+60 -6
View File
@@ -6,19 +6,28 @@ import (
"os" "os"
"github.com/lerko/nib/internal/db" "github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/export"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var exportOutput string var (
exportOutput string
exportFormat string
exportTag string
exportTitle string
)
var exportCmd = &cobra.Command{ var exportCmd = &cobra.Command{
Use: "export", Use: "export",
Short: "dump all entities to JSON", Short: "export entities to JSON or HTML card deck",
RunE: runExport, RunE: runExport,
} }
func init() { func init() {
exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "write to file instead of stdout") exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "write to file instead of stdout")
exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "json", "output format: json or html")
exportCmd.Flags().StringVarP(&exportTag, "tag", "t", "", "filter by tag (used as deck name for HTML)")
exportCmd.Flags().StringVar(&exportTitle, "title", "", "deck title for HTML export")
rootCmd.AddCommand(exportCmd) rootCmd.AddCommand(exportCmd)
} }
@@ -48,13 +57,58 @@ func runExport(cmd *cobra.Command, _ []string) error {
} }
defer store.Close() defer store.Close()
ctx := cmd.Context()
p := db.DefaultListParams() p := db.DefaultListParams()
p.IncludeDeleted = true
p.Limit = 10000 p.Limit = 10000
entities, err := store.List(ctx, p) if exportTag != "" {
p.Tag = &exportTag
}
switch exportFormat {
case "html":
return runHTMLExport(cmd, store, p)
case "json":
p.IncludeDeleted = true
return runJSONExport(cmd, store, p)
default:
return fmt.Errorf("unknown format %q (use json or html)", exportFormat)
}
}
func runHTMLExport(cmd *cobra.Command, store *db.Store, p db.ListParams) error {
p.CardsOnly = true
entities, err := store.List(cmd.Context(), p)
if err != nil {
return err
}
title := exportTitle
if title == "" && exportTag != "" {
title = "#" + exportTag
}
if title == "" {
title = "nib cards"
}
if exportOutput != "" {
f, err := os.Create(exportOutput)
if err != nil {
return err
}
defer f.Close()
if err := export.RenderHTML(f, entities, title); err != nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(), "exported %d cards to %s\n", len(entities), exportOutput)
return nil
}
return export.RenderHTML(cmd.OutOrStdout(), entities, title)
}
func runJSONExport(cmd *cobra.Command, store *db.Store, p db.ListParams) error {
entities, err := store.List(cmd.Context(), p)
if err != nil { if err != nil {
return err return err
} }
+181
View File
@@ -0,0 +1,181 @@
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
}
}
}
+355
View File
@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{.Title}}</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#111110;--surface:#1a1a19;--surface2:#232322;
--border:#2e2e2c;--border-focus:#4a4a47;
--text:#e8e6e1;--text2:#9a9890;--text3:#6b6a63;
--accent:#c4a46c;--accent2:#a68a52;
--red:#c45b5b;--green:#6bab6b;--blue:#6b8fc4;
--mono:"Berkeley Mono","SF Mono","Cascadia Code","JetBrains Mono",monospace;
--sans:"Inter","SF Pro Text","Segoe UI",system-ui,sans-serif;
--card-radius:12px;
--safe-bottom:env(safe-area-inset-bottom,0px);
}
html{font-size:16px;-webkit-text-size-adjust:100%}
body{
font-family:var(--sans);color:var(--text);background:var(--bg);
min-height:100dvh;padding:0 0 calc(72px + var(--safe-bottom));
-webkit-font-smoothing:antialiased;
}
.deck-header{
position:sticky;top:0;z-index:10;
background:var(--bg);border-bottom:1px solid var(--border);
padding:16px 20px 12px;
}
.deck-title{font-size:1.25rem;font-weight:600;color:var(--text);letter-spacing:-0.01em}
.deck-meta{font-size:0.8rem;color:var(--text3);margin-top:4px}
.filter-bar{
display:flex;gap:8px;padding:12px 20px;overflow-x:auto;
-webkit-overflow-scrolling:touch;scrollbar-width:none;
}
.filter-bar::-webkit-scrollbar{display:none}
.filter-chip{
flex-shrink:0;padding:6px 14px;border-radius:20px;
font-size:0.8rem;font-weight:500;border:1px solid var(--border);
background:var(--surface);color:var(--text2);cursor:pointer;
transition:all 0.15s ease;-webkit-tap-highlight-color:transparent;
}
.filter-chip.active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
.search-wrap{padding:8px 20px 4px}
.search-input{
width:100%;padding:10px 14px;border-radius:10px;border:1px solid var(--border);
background:var(--surface);color:var(--text);font-size:0.9rem;
font-family:var(--sans);outline:none;transition:border-color 0.15s;
}
.search-input:focus{border-color:var(--accent)}
.search-input::placeholder{color:var(--text3)}
.card-list{padding:8px 16px}
.card{
background:var(--surface);border:1px solid var(--border);
border-radius:var(--card-radius);padding:16px;margin-bottom:10px;
transition:border-color 0.15s;position:relative;
}
.card:active{border-color:var(--border-focus)}
.card.pinned{border-left:3px solid var(--accent)}
.card-top{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.card-glyph{
font-size:1.1rem;width:28px;height:28px;
display:flex;align-items:center;justify-content:center;
background:var(--surface2);border-radius:6px;flex-shrink:0;
}
.card-type{
font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;
color:var(--text3);
}
.card-use{font-size:0.7rem;color:var(--text3);margin-left:auto}
.card-title{
font-size:0.95rem;font-weight:600;color:var(--text);
line-height:1.4;margin-bottom:6px;
}
.card-body{
font-size:0.85rem;color:var(--text2);line-height:1.55;
white-space:pre-wrap;word-break:break-word;
}
.card-body code{
font-family:var(--mono);font-size:0.8rem;
background:var(--surface2);padding:2px 5px;border-radius:4px;
}
.card-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.tag{
font-size:0.7rem;color:var(--accent);background:var(--surface2);
padding:3px 8px;border-radius:6px;
}
/* snippet */
.snippet-block{
background:var(--bg);border:1px solid var(--border);border-radius:8px;
padding:12px;margin-top:8px;position:relative;overflow-x:auto;
}
.snippet-block pre{
font-family:var(--mono);font-size:0.8rem;color:var(--text);
white-space:pre-wrap;word-break:break-word;margin:0;line-height:1.5;
}
.copy-btn{
position:absolute;top:8px;right:8px;
padding:4px 10px;border-radius:6px;border:1px solid var(--border);
background:var(--surface);color:var(--text2);font-size:0.7rem;
cursor:pointer;transition:all 0.15s;
}
.copy-btn:active{background:var(--accent);color:var(--bg);border-color:var(--accent)}
/* checklist */
.checklist{margin-top:8px;list-style:none}
.checklist li{
display:flex;align-items:flex-start;gap:10px;
padding:8px 0;border-bottom:1px solid var(--border);
font-size:0.85rem;color:var(--text2);
}
.checklist li:last-child{border-bottom:none}
.check-box{
width:20px;height:20px;border-radius:5px;border:2px solid var(--border);
flex-shrink:0;cursor:pointer;display:flex;align-items:center;justify-content:center;
transition:all 0.15s;margin-top:1px;
}
.check-box.checked{background:var(--green);border-color:var(--green)}
.check-box.checked::after{content:"✓";color:var(--bg);font-size:0.7rem;font-weight:700}
.check-text.done{text-decoration:line-through;color:var(--text3)}
.progress-bar{
height:4px;background:var(--surface2);border-radius:2px;margin-top:10px;overflow:hidden;
}
.progress-fill{height:100%;background:var(--green);border-radius:2px;transition:width 0.3s}
/* template */
.template-field{margin-top:8px}
.template-field label{
display:block;font-size:0.7rem;font-weight:600;
text-transform:uppercase;letter-spacing:0.05em;
color:var(--text3);margin-bottom:4px;
}
.template-field input{
width:100%;padding:8px 12px;border-radius:8px;
border:1px solid var(--border);background:var(--surface2);
color:var(--text);font-size:0.85rem;font-family:var(--sans);
outline:none;transition:border-color 0.15s;
}
.template-field input:focus{border-color:var(--accent)}
.template-output{margin-top:10px}
.template-copy-btn{
width:100%;padding:10px;border-radius:8px;border:1px solid var(--accent);
background:transparent;color:var(--accent);font-size:0.85rem;font-weight:600;
cursor:pointer;transition:all 0.15s;font-family:var(--sans);
}
.template-copy-btn:active{background:var(--accent);color:var(--bg)}
/* decision */
.decision-grid{margin-top:8px}
.decision-row{
padding:8px 0;border-bottom:1px solid var(--border);
}
.decision-row:last-child{border-bottom:none}
.decision-label{
font-size:0.7rem;font-weight:600;text-transform:uppercase;
letter-spacing:0.05em;color:var(--text3);margin-bottom:2px;
}
.decision-value{font-size:0.85rem;color:var(--text);line-height:1.4}
.decision-rejected{color:var(--red);font-style:italic}
/* link */
.link-target{
display:flex;align-items:center;gap:10px;margin-top:8px;
padding:12px;background:var(--bg);border:1px solid var(--border);
border-radius:8px;text-decoration:none;color:var(--text);
transition:border-color 0.15s;
}
.link-target:active{border-color:var(--accent)}
.link-url{font-size:0.8rem;color:var(--text2);word-break:break-all;flex:1}
.link-arrow{font-size:1.2rem;color:var(--accent);flex-shrink:0}
.empty-state{
text-align:center;padding:60px 20px;color:var(--text3);font-size:0.9rem;
}
@media(min-width:640px){
.card-list{
display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));
gap:10px;padding:8px 20px;
}
.card{margin-bottom:0}
}
</style>
</head>
<body>
<div class="deck-header">
<div class="deck-title">{{.Title}}</div>
<div class="deck-meta">{{.Count}} cards{{if .ExportedAt}} · exported {{.ExportedAt}}{{end}}</div>
</div>
<div class="search-wrap">
<input type="text" class="search-input" placeholder="search cards…" id="search">
</div>
<div class="filter-bar" id="filters">
<button class="filter-chip active" data-type="all">All</button>
{{range .Types}}
<button class="filter-chip" data-type="{{.}}">{{.}}</button>
{{end}}
</div>
<div class="card-list" id="cards">
{{range .Cards}}
<div class="card{{if .Pinned}} pinned{{end}}" data-type="{{.CardType}}" data-search="{{.SearchText}}">
<div class="card-top">
<span class="card-glyph">{{.Glyph}}</span>
<span class="card-type">{{.CardType}}</span>
{{if gt .UseCount 0}}<span class="card-use">{{.UseCount}}×</span>{{end}}
</div>
{{if .Title}}<div class="card-title">{{.Title}}</div>{{end}}
{{if eq .CardType "snippet"}}
<div class="snippet-block">
<button class="copy-btn" onclick="copyText(this)">copy</button>
<pre>{{.Body}}</pre>
</div>
{{else if eq .CardType "checklist"}}
<div class="card-body">{{.Description}}</div>
<ul class="checklist" data-card-id="{{.ID}}">
{{range $i, $step := .Steps}}
<li>
<div class="check-box{{if $step.Done}} checked{{end}}" onclick="toggleCheck(this)" data-idx="{{$i}}"></div>
<span class="check-text{{if $step.Done}} done{{end}}">{{$step.Text}}</span>
</li>
{{end}}
</ul>
<div class="progress-bar"><div class="progress-fill" style="width:{{.Progress}}%"></div></div>
{{else if eq .CardType "template"}}
<div class="card-body">{{.Description}}</div>
<form class="template-form" data-template="{{.TemplateBody}}" onsubmit="return false">
{{range .Slots}}
<div class="template-field">
<label>{{.Name}}</label>
<input type="text" data-slot="{{.Name}}" placeholder="{{.Name}}{{if .Default}} ({{.Default}}){{end}}" oninput="updateTemplate(this)">
</div>
{{end}}
<div class="template-output">
<button class="template-copy-btn" onclick="copyTemplate(this)">copy filled template</button>
</div>
</form>
{{else if eq .CardType "decision"}}
<div class="decision-grid">
{{if .Decision.Chose}}<div class="decision-row"><div class="decision-label">Chose</div><div class="decision-value">{{.Decision.Chose}}</div></div>{{end}}
{{if .Decision.Why}}<div class="decision-row"><div class="decision-label">Why</div><div class="decision-value">{{.Decision.Why}}</div></div>{{end}}
{{if .Decision.Rejected}}<div class="decision-row"><div class="decision-label">Rejected</div><div class="decision-value decision-rejected">{{range $i, $r := .Decision.Rejected}}{{if $i}}, {{end}}{{$r}}{{end}}</div></div>{{end}}
</div>
{{if .Body}}<div class="card-body" style="margin-top:8px">{{.Body}}</div>{{end}}
{{else if eq .CardType "link"}}
{{if .Body}}<div class="card-body">{{.Body}}</div>{{end}}
<a class="link-target" href="{{.LinkURL}}" target="_blank" rel="noopener">
<span class="link-url">{{.LinkURL}}</span>
<span class="link-arrow"></span>
</a>
{{else}}
<div class="card-body">{{.Body}}</div>
{{end}}
{{if .Tags}}
<div class="card-tags">
{{range .Tags}}<span class="tag">#{{.}}</span>{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="empty-state" id="empty" style="display:none">no matching cards</div>
<script>
(function(){
const cards=document.querySelectorAll('.card');
const chips=document.querySelectorAll('.filter-chip');
const search=document.getElementById('search');
const empty=document.getElementById('empty');
let activeType='all';
function applyFilters(){
const q=search.value.toLowerCase().trim();
let visible=0;
cards.forEach(c=>{
const typeMatch=activeType==='all'||c.dataset.type===activeType;
const searchMatch=!q||c.dataset.search.toLowerCase().includes(q);
c.style.display=(typeMatch&&searchMatch)?'':'none';
if(typeMatch&&searchMatch)visible++;
});
empty.style.display=visible?'none':'block';
}
chips.forEach(chip=>{
chip.addEventListener('click',()=>{
chips.forEach(c=>c.classList.remove('active'));
chip.classList.add('active');
activeType=chip.dataset.type;
applyFilters();
});
});
search.addEventListener('input',applyFilters);
})();
function copyText(btn){
const pre=btn.parentElement.querySelector('pre');
navigator.clipboard.writeText(pre.textContent).then(()=>{
btn.textContent='copied!';
setTimeout(()=>btn.textContent='copy',1500);
});
}
function toggleCheck(box){
box.classList.toggle('checked');
const span=box.nextElementSibling;
span.classList.toggle('done');
const list=box.closest('.checklist');
const boxes=list.querySelectorAll('.check-box');
const done=[...boxes].filter(b=>b.classList.contains('checked')).length;
const bar=list.nextElementSibling.querySelector('.progress-fill');
bar.style.width=Math.round((done/boxes.length)*100)+'%';
}
function updateTemplate(input){
const form=input.closest('.template-form');
const tmpl=form.dataset.template;
let filled=tmpl;
form.querySelectorAll('input[data-slot]').forEach(inp=>{
const re=new RegExp('\\$\\{'+inp.dataset.slot+'\\}','g');
filled=filled.replace(re,inp.value||'${'+inp.dataset.slot+'}');
});
form._filled=filled;
}
function copyTemplate(btn){
const form=btn.closest('.template-form');
const text=form._filled||form.dataset.template;
navigator.clipboard.writeText(text).then(()=>{
btn.textContent='copied!';
setTimeout(()=>btn.textContent='copy filled template',1500);
});
}
</script>
</body>
</html>