diff --git a/cmd/export.go b/cmd/export.go index 14d3703..26ef865 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -6,19 +6,28 @@ import ( "os" "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/export" "github.com/spf13/cobra" ) -var exportOutput string +var ( + exportOutput string + exportFormat string + exportTag string + exportTitle string +) var exportCmd = &cobra.Command{ Use: "export", - Short: "dump all entities to JSON", + Short: "export entities to JSON or HTML card deck", RunE: runExport, } func init() { 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) } @@ -48,13 +57,58 @@ func runExport(cmd *cobra.Command, _ []string) error { } defer store.Close() - ctx := cmd.Context() - p := db.DefaultListParams() - p.IncludeDeleted = true 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 { return err } diff --git a/internal/export/html.go b/internal/export/html.go new file mode 100644 index 0000000..50a3688 --- /dev/null +++ b/internal/export/html.go @@ -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 + } + } +} diff --git a/internal/export/template.html b/internal/export/template.html new file mode 100644 index 0000000..bf72f5a --- /dev/null +++ b/internal/export/template.html @@ -0,0 +1,355 @@ + + +
+ + +{{.Body}}
+