269 lines
9.3 KiB
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, ", ")
|
|
}
|