Skip to main content

systemprompt_cli/commands/core/content/
edit.rs

1use super::edit_apply::{
2    ContentEditState, apply_body_flags, apply_set_value_flags, apply_visibility_flags,
3};
4use super::types::UpdateOutput;
5use crate::cli_settings::CliConfig;
6use crate::interactive::{Prompter, resolve_required};
7use crate::shared::CommandOutput;
8use anyhow::{Result, anyhow};
9use clap::Args;
10use systemprompt_content::{CategoryIdUpdate, ContentRepository};
11use systemprompt_database::DbPool;
12use systemprompt_identifiers::{ContentId, LocaleCode, SourceId};
13use systemprompt_logging::CliService;
14use systemprompt_runtime::AppContext;
15
16#[derive(Debug, Args)]
17pub struct EditArgs {
18    #[arg(help = "Content ID or slug to edit")]
19    pub identifier: Option<String>,
20
21    #[arg(long, help = "Source ID (required when using slug)")]
22    pub source: Option<String>,
23
24    #[arg(long = "set", value_name = "KEY=VALUE", help = "Set a field value")]
25    pub set_values: Vec<String>,
26
27    #[arg(long, help = "Make content public", conflicts_with = "private")]
28    pub public: bool,
29
30    #[arg(long, help = "Make content private", conflicts_with = "public")]
31    pub private: bool,
32
33    #[arg(long, help = "Body content")]
34    pub body: Option<String>,
35
36    #[arg(long, help = "File containing body content")]
37    pub body_file: Option<String>,
38}
39
40pub(super) async fn execute(
41    args: EditArgs,
42    prompter: &dyn Prompter,
43    config: &CliConfig,
44) -> Result<CommandOutput> {
45    let ctx = AppContext::new().await?;
46    execute_with_pool(args, prompter, ctx.db_pool(), config).await
47}
48
49pub async fn execute_with_pool(
50    args: EditArgs,
51    prompter: &dyn Prompter,
52    pool: &DbPool,
53    config: &CliConfig,
54) -> Result<CommandOutput> {
55    let repo = ContentRepository::new(pool)?;
56
57    let identifier = resolve_required(args.identifier.clone(), "identifier", config, || {
58        prompt_content_selection(prompter, &repo, args.source.as_deref())
59    })?;
60
61    let content = if identifier.starts_with("content_")
62        || identifier.contains('-') && identifier.len() > 30
63    {
64        let id = ContentId::new(identifier.clone());
65        repo.get_by_id(&id)
66            .await?
67            .ok_or_else(|| anyhow!("Content not found: {}", identifier))?
68    } else {
69        let source_id = args
70            .source
71            .as_ref()
72            .ok_or_else(|| anyhow!("Source ID required when using slug"))?;
73        let source = SourceId::new(source_id.clone());
74        repo.get_by_source_and_slug(&source, &identifier, &LocaleCode::new("en"))
75            .await?
76            .ok_or_else(|| anyhow!("Content not found: {} in source {}", identifier, source_id))?
77    };
78
79    let mut changes = Vec::new();
80    let mut state = ContentEditState {
81        title: content.title.clone(),
82        description: content.description.clone(),
83        body: content.body.clone(),
84        keywords: content.keywords.clone(),
85        image: content.image.clone(),
86        category_id: CategoryIdUpdate::Unchanged,
87        public_value: None,
88        kind_value: None,
89    };
90    apply_visibility_flags(&args, &mut state, &mut changes);
91    apply_body_flags(&args, &mut state, &mut changes)?;
92    apply_set_value_flags(&args, &mut state, &mut changes, &repo).await?;
93
94    if changes.is_empty() {
95        return Err(anyhow!(
96            "No changes specified. Use --set, --public, --private, --body, or --body-file"
97        ));
98    }
99
100    CliService::info(&format!("Updating content '{}'...", content.slug));
101
102    let params = systemprompt_content::UpdateContentParams::new(
103        content.id.clone(),
104        state.title,
105        state.description,
106        state.body,
107    )
108    .with_keywords(state.keywords)
109    .with_image(state.image)
110    .with_version_hash(content.version_hash.clone())
111    .with_category_id(state.category_id)
112    .with_public(state.public_value)
113    .with_kind(state.kind_value);
114
115    repo.update(&params).await?;
116
117    CliService::success(&format!("Content '{}' updated successfully", content.slug));
118
119    let output = UpdateOutput {
120        content_id: content.id,
121        slug: content.slug,
122        updated_fields: changes,
123        success: true,
124    };
125
126    Ok(CommandOutput::card_value("Content Updated", &output))
127}
128
129fn prompt_content_selection(
130    prompter: &dyn Prompter,
131    repo: &ContentRepository,
132    source: Option<&str>,
133) -> Result<String> {
134    let rt = tokio::runtime::Handle::current();
135    let contents = rt.block_on(async {
136        if let Some(source) = source {
137            let source = SourceId::new(source.to_owned());
138            repo.list_by_source_limited(&source, &LocaleCode::new("en"), 50)
139                .await
140        } else {
141            repo.list(50, 0).await
142        }
143    })?;
144
145    if contents.is_empty() {
146        return Err(anyhow!("No content found"));
147    }
148
149    let items: Vec<String> = contents
150        .iter()
151        .map(|c| format!("{} - {} ({})", c.id.as_str(), c.title, c.source_id.as_str()))
152        .collect();
153
154    let selection = prompter.select("Select content to edit", &items)?;
155
156    Ok(contents[selection].id.as_str().to_owned())
157}