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 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, ", ") }