Files
narratio/internal/app/operator_session_init.go

269 lines
9.3 KiB
Go

package app
import (
"context"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gopkg.in/yaml.v3"
)
// SessionInit creates a local or remote session.yml skeleton.
func SessionInit(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
var remote, force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier")
fs.StringVar(&date, "date", "", "session date")
fs.StringVar(&title, "title", "", "session title")
fs.StringVar(&output, "output", "", "local output session.yml path")
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
fs.BoolVar(&force, "force", false, "overwrite existing target")
if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil {
return err
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("session init: session_id is required")
}
if (strings.TrimSpace(output) == "") == !remote {
return fmt.Errorf("session init: specify exactly one target: --output <path> or --remote")
}
if strings.TrimSpace(audioDir) != "" && strings.TrimSpace(audioS3Prefix) != "" {
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
}
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath)
if err != nil {
return fmt.Errorf("session init: %w", err)
}
input := sessionInitInput{
Campaign: config.CampaignID(base.Campaign),
CampaignPath: base.CampaignPath,
TemplateFile: base.Campaign.SessionTemplateFile,
SessionID: sessionID,
PreviousSessionID: previousSessionID,
Date: date,
Title: title,
AudioS3Prefix: audioS3Prefix,
AudioDir: audioDir,
}
data, err := buildSessionInitYAML(input)
if err != nil {
return fmt.Errorf("session init: %w", err)
}
label := strings.TrimSpace(output)
if label == "" {
label = "remote session.yml"
}
sessionCfg, err := config.LoadSessionBytesWithOptions(label, data, config.SessionLoadOptions{
SessionID: sessionID,
PreviousSessionID: previousSessionID,
})
if err != nil {
return fmt.Errorf("session init: %w", err)
}
cfg, err := config.Resolve(base.PipelinePath, base.Pipeline, base.CampaignPath, base.Campaign, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
if err != nil {
return fmt.Errorf("session init: %w", err)
}
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("session init: %w", err)
}
if !remote {
if err := writeLocalFile(output, data, force); err != nil {
return fmt.Errorf("session init: %w", err)
}
_, err := fmt.Fprintf(out, "narratio session init: wrote %s\n", filepath.Clean(output))
return err
}
store, err := newCommandObjectStore(ctx, cfg, nil)
if err != nil {
return fmt.Errorf("session init: %w", err)
}
sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID)
key := artifacts.S3SessionConfigKey(sessionPrefix)
exists, err := store.Exists(ctx, key)
if err != nil {
return fmt.Errorf("session init: check remote session %q: %w", key, err)
}
if exists && !force {
return fmt.Errorf("session init: remote session %q already exists; pass --force to overwrite", key)
}
tmp, err := os.CreateTemp("", "narratio-session-init-*.yml")
if err != nil {
return fmt.Errorf("session init: create temp file: %w", err)
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("session init: write temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("session init: close temp file: %w", err)
}
if _, err := store.Upload(ctx, tmpPath, key, storage.UploadOptions{ContentType: "application/x-yaml; charset=utf-8"}); err != nil {
return fmt.Errorf("session init: upload remote session %q: %w", key, err)
}
_, err = fmt.Fprintf(out, "narratio session init: wrote s3://%s/%s\n", s3BucketName(base.Pipeline), key)
return err
}
func buildSessionYAML(campaign, sessionID, previousSessionID, date, title, audioS3Prefix, audioDir string) ([]byte, error) {
if strings.TrimSpace(date) == "" && regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`).MatchString(strings.TrimSpace(sessionID)) {
date = strings.TrimSpace(sessionID)
}
type audioS3 struct {
Prefix string `yaml:"prefix"`
}
type inputs struct {
AudioDir string `yaml:"audio_dir,omitempty"`
AudioS3 *audioS3 `yaml:"audio_s3,omitempty"`
}
type sessionYAML struct {
Campaign string `yaml:"campaign"`
SessionID string `yaml:"session_id"`
PreviousSessionID string `yaml:"previous_session_id,omitempty"`
Date string `yaml:"date,omitempty"`
Title string `yaml:"title,omitempty"`
Inputs inputs `yaml:"inputs"`
}
in := inputs{AudioDir: strings.TrimSpace(audioDir)}
if in.AudioDir == "" {
prefix := strings.TrimSpace(audioS3Prefix)
if prefix == "" {
prefix = "audio/"
}
in.AudioS3 = &audioS3{Prefix: prefix}
}
data, err := yaml.Marshal(sessionYAML{
Campaign: strings.TrimSpace(campaign),
SessionID: strings.TrimSpace(sessionID),
PreviousSessionID: strings.TrimSpace(previousSessionID),
Date: strings.TrimSpace(date),
Title: strings.TrimSpace(title),
Inputs: in,
})
if err != nil {
return nil, err
}
return data, nil
}
type sessionInitInput struct {
Campaign string
CampaignPath string
TemplateFile string
SessionID string
PreviousSessionID string
Date string
Title string
AudioS3Prefix string
AudioDir string
}
func buildSessionInitYAML(in sessionInitInput) ([]byte, error) {
if strings.TrimSpace(in.TemplateFile) == "" {
return buildSessionYAML(in.Campaign, in.SessionID, in.PreviousSessionID, in.Date, in.Title, in.AudioS3Prefix, in.AudioDir)
}
templatePath := resolveSessionInitTemplatePath(in.CampaignPath, in.TemplateFile)
templateBytes, err := os.ReadFile(templatePath)
if err != nil {
return nil, fmt.Errorf("read session template %q: %w", templatePath, err)
}
rendered, err := renderSessionInitTemplate(string(templateBytes), in)
if err != nil {
return nil, fmt.Errorf("render session template %q: %w", templatePath, err)
}
return []byte(rendered), nil
}
func resolveSessionInitTemplatePath(campaignPath, templateFile string) string {
templateFile = strings.TrimSpace(templateFile)
if filepath.IsAbs(templateFile) {
return filepath.Clean(templateFile)
}
return filepath.Clean(filepath.Join(filepath.Dir(campaignPath), templateFile))
}
var sessionInitTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
func renderSessionInitTemplate(content string, in sessionInitInput) (string, error) {
values := map[string]string{
"session_id": strings.TrimSpace(in.SessionID),
"previous_session_id": strings.TrimSpace(in.PreviousSessionID),
"date": strings.TrimSpace(in.Date),
"title": strings.TrimSpace(in.Title),
"audio_s3_prefix": strings.TrimSpace(in.AudioS3Prefix),
"audio_dir": strings.TrimSpace(in.AudioDir),
}
used := map[string]struct{}{}
unknown := map[string]struct{}{}
missing := map[string]struct{}{}
rendered := sessionInitTemplatePattern.ReplaceAllStringFunc(content, func(match string) string {
parts := sessionInitTemplatePattern.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
name := parts[1]
value, ok := values[name]
if !ok {
unknown[name] = struct{}{}
return match
}
used[name] = struct{}{}
if value == "" {
missing[name] = struct{}{}
return match
}
return value
})
if len(unknown) > 0 {
return "", fmt.Errorf("unsupported template variable(s): %s", sortedStringSet(unknown))
}
if len(missing) > 0 {
return "", fmt.Errorf("missing required template variable value(s): %s", sortedStringSet(missing))
}
unused := map[string]struct{}{}
for _, name := range []string{"previous_session_id", "date", "title", "audio_s3_prefix", "audio_dir"} {
if values[name] == "" {
continue
}
if _, ok := used[name]; !ok {
unused[name] = struct{}{}
}
}
if len(unused) > 0 {
return "", fmt.Errorf("unused template variable value(s): %s", sortedStringSet(unused))
}
return rendered, nil
}
func sortedStringSet(set map[string]struct{}) string {
items := make([]string, 0, len(set))
for item := range set {
items = append(items, item)
}
sort.Strings(items)
return strings.Join(items, ", ")
}