Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,19 @@ Run `dispatch doctor` to print setup checks for the config file, session store,

Add `--json` (`dispatch doctor --json`) to print the same checks as a single JSON object for scripts and CI.

### Export

Save a full session (metadata and the complete conversation) to a file with `dispatch export <id>`:

```sh
dispatch export 0a1b2c3d
dispatch export 0a1b2c3d --format json
dispatch export 0a1b2c3d --stdout
dispatch export 0a1b2c3d --out ./exports
```

By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output, `--stdout` to print to the terminal instead of writing a file, and `--out <dir>` to choose the destination directory. `--stdout` and `--out` cannot be combined.

### Key Bindings

#### Navigation
Expand Down
13 changes: 10 additions & 3 deletions cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd
}
return true, cleanup, "", nil

case "export":
if eErr := runExport(os.Stdout, args); eErr != nil {
fmt.Fprintf(os.Stderr, "export: %v\n", eErr)
return true, cleanup, "", eErr
}
return true, cleanup, "", nil

case "--demo":
c, demoErr := setupDemo()
if demoErr != nil {
Expand Down Expand Up @@ -173,7 +180,7 @@ func runCompletion(w io.Writer, shell string) error {
const bashCompletionScript = `# bash completion for dispatch
_dispatch_completion() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local commands="help version open doctor update completion stats"
local commands="help version open doctor update completion stats export"
local flags="-h --help -v --version --demo --clear-cache --reindex"

if [[ "${COMP_CWORD}" -eq 1 ]]; then
Expand All @@ -192,7 +199,7 @@ complete -F _dispatch_completion dispatch disp
const zshCompletionScript = `#compdef dispatch disp
_dispatch_completion() {
local -a commands shells flags
commands=(help version open doctor update completion stats)
commands=(help version open doctor update completion stats export)
shells=(bash zsh powershell)
flags=(-h --help -v --version --demo --clear-cache --reindex)

Expand All @@ -210,7 +217,7 @@ _dispatch_completion "$@"
`

const powershellCompletionScript = `# PowerShell completion for dispatch
$script:DispatchCommands = @('help', 'version', 'open', 'doctor', 'update', 'completion', 'stats')
$script:DispatchCommands = @('help', 'version', 'open', 'doctor', 'update', 'completion', 'stats', 'export')
$script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex')
$script:DispatchShells = @('bash', 'zsh', 'powershell')

Expand Down
207 changes: 207 additions & 0 deletions cmd/dispatch/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package main

import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/jongio/dispatch/internal/data"
)

// Function variables allow test substitution of external calls, matching the
// pattern used elsewhere in this package (see cli.go and open.go).
var (
exportGetDetailFn = defaultExportGetDetail
exportDirFn = data.ExportDir
)

// exportOptions holds the parsed flags for the export command.
type exportOptions struct {
id string
format string // "md" or "json"
stdout bool
outDir string
}

// runExport writes a session's full content as Markdown or JSON. It writes to
// the exports directory by default, or to stdout with --stdout. args is the
// full argument slice with args[0] == "export".
func runExport(w io.Writer, args []string) error {
if w == nil {
w = io.Discard
}

opts, err := parseExportArgs(args)
if err != nil {
return err
}

detail, err := exportGetDetailFn(opts.id)
if err != nil {
return err
}
if detail == nil {
return fmt.Errorf("session %q not found", opts.id)
}

content, err := renderExport(detail, opts.format)
if err != nil {
return err
}

if opts.stdout {
_, err := io.WriteString(w, content)
return err
}

dir := opts.outDir
if dir == "" {
dir, err = exportDirFn()
if err != nil {
return err
}
}

path, err := writeExportFile(dir, detail.Session.ID, opts.format, content)
if err != nil {
return err
}
fmt.Fprintf(w, "Exported session to %s\n", path)
return nil
}

// parseExportArgs extracts the session ID and options from the "export"
// subcommand arguments. args[0] is expected to be "export".
func parseExportArgs(args []string) (exportOptions, error) {
opts := exportOptions{format: "md"}

rest := args
if len(rest) > 0 {
rest = rest[1:] // drop the "export" token
}

takeValue := func(i int, name, inline string) (string, int, error) {
if inline != "" {
return inline, i, nil
}
if i+1 >= len(rest) {
return "", i, fmt.Errorf("%s requires a value", name)
}
return rest[i+1], i + 1, nil
}

var positionals []string
for i := 0; i < len(rest); i++ {
arg := rest[i]
name, inline, hasInline := splitFlag(arg)

switch {
case name == "--format" || name == "-f":
v, ni, err := takeValue(i, "--format", inlineOrEmpty(inline, hasInline))
if err != nil {
return exportOptions{}, err
}
f, err := normalizeExportFormat(v)
if err != nil {
return exportOptions{}, err
}
opts.format = f
i = ni
case name == "--out" || name == "-o":
v, ni, err := takeValue(i, "--out", inlineOrEmpty(inline, hasInline))
if err != nil {
return exportOptions{}, err
}
opts.outDir = v
i = ni
case name == "--stdout":
opts.stdout = true
case strings.HasPrefix(arg, "-"):
return exportOptions{}, fmt.Errorf("unknown flag: %s", arg)
default:
positionals = append(positionals, arg)
}
}

switch len(positionals) {
case 0:
return exportOptions{}, errors.New("export requires a session ID")
case 1:
opts.id = positionals[0]
default:
return exportOptions{}, fmt.Errorf("export accepts a single session ID, got %d arguments", len(positionals))
}

if opts.stdout && opts.outDir != "" {
return exportOptions{}, errors.New("--stdout and --out cannot be used together")
}

return opts, nil
}

// normalizeExportFormat maps a user-facing format string to "md" or "json".
func normalizeExportFormat(format string) (string, error) {
switch strings.ToLower(format) {
case "md", "markdown":
return "md", nil
case "json":
return "json", nil
default:
return "", fmt.Errorf("invalid format %q (want md or json)", format)
}
}

// renderExport produces the export content for the given format.
func renderExport(detail *data.SessionDetail, format string) (string, error) {
switch format {
case "json":
b, err := json.MarshalIndent(detail, "", " ")
if err != nil {
return "", fmt.Errorf("encoding session as JSON: %w", err)
}
return string(b) + "\n", nil
default:
return data.RenderMarkdown(detail), nil
}
}

// writeExportFile writes content to <dir>/<safe-id>.<ext> and returns the path.
func writeExportFile(dir, id, format, content string) (string, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("creating export directory: %w", err)
}
ext := "md"
if format == "json" {
ext = "json"
}
path := filepath.Join(dir, data.SafeFilename(id)+"."+ext)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return "", fmt.Errorf("writing export file: %w", err)
}
return path, nil
}

// defaultExportGetDetail loads a full session detail by ID from the default
// session store. It returns (nil, nil) when no session with that ID exists.
func defaultExportGetDetail(id string) (*data.SessionDetail, error) {
store, err := data.Open()
if err != nil {
return nil, fmt.Errorf("opening session store: %w", err)
}
defer store.Close() //nolint:errcheck // read-only, best-effort close

detail, err := store.GetSession(context.Background(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return detail, nil
}
Loading