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