Skip to main content

pebble_cms/cli/
export.rs

1use crate::models::{ContentStatus, ContentType};
2use crate::services::content;
3use crate::Config;
4use anyhow::Result;
5use std::fs;
6use std::path::Path;
7
8pub async fn run(
9    config_path: &Path,
10    output_dir: &Path,
11    include_drafts: bool,
12    include_media: bool,
13    format: &str,
14) -> Result<()> {
15    let config = Config::load(config_path)?;
16    let db = crate::Database::open(&config.database.path)?;
17
18    match format {
19        "hugo" => export_hugo(&db, &config, output_dir, include_drafts, include_media),
20        "zola" => export_zola(&db, &config, output_dir, include_drafts, include_media),
21        _ => export_pebble(&db, &config, output_dir, include_drafts, include_media),
22    }
23}
24
25fn export_pebble(
26    db: &crate::Database,
27    config: &Config,
28    output_dir: &Path,
29    include_drafts: bool,
30    include_media: bool,
31) -> Result<()> {
32    fs::create_dir_all(output_dir)?;
33    fs::create_dir_all(output_dir.join("posts"))?;
34    fs::create_dir_all(output_dir.join("pages"))?;
35
36    let status = if include_drafts {
37        None
38    } else {
39        Some(ContentStatus::Published)
40    };
41
42    let posts = content::list_content(db, Some(ContentType::Post), status.clone(), 10000, 0)?;
43    let pages = content::list_content(db, Some(ContentType::Page), status, 10000, 0)?;
44
45    tracing::info!("Exporting {} posts and {} pages", posts.len(), pages.len());
46
47    for post in posts {
48        let full_content = content::get_content_by_id(db, post.id)?;
49        if let Some(c) = full_content {
50            let filename = format!("{}.md", c.content.slug);
51            let filepath = output_dir.join("posts").join(&filename);
52
53            let frontmatter = format!(
54                r#"---
55title: "{}"
56slug: "{}"
57status: "{}"
58published_at: {}
59created_at: "{}"
60---
61
62"#,
63                c.content.title.replace('"', r#"\""#),
64                c.content.slug,
65                c.content.status,
66                c.content
67                    .published_at
68                    .as_deref()
69                    .map(|d| format!("\"{}\"", d))
70                    .unwrap_or_else(|| "null".to_string()),
71                c.content.created_at,
72            );
73
74            let content_str = format!("{}{}", frontmatter, c.content.body_markdown);
75            fs::write(&filepath, content_str)?;
76            tracing::info!("Exported: {}", filepath.display());
77        }
78    }
79
80    for page in pages {
81        let full_content = content::get_content_by_id(db, page.id)?;
82        if let Some(c) = full_content {
83            let filename = format!("{}.md", c.content.slug);
84            let filepath = output_dir.join("pages").join(&filename);
85
86            let frontmatter = format!(
87                r#"---
88title: "{}"
89slug: "{}"
90status: "{}"
91created_at: "{}"
92---
93
94"#,
95                c.content.title.replace('"', r#"\""#),
96                c.content.slug,
97                c.content.status,
98                c.content.created_at,
99            );
100
101            let content_str = format!("{}{}", frontmatter, c.content.body_markdown);
102            fs::write(&filepath, content_str)?;
103            tracing::info!("Exported: {}", filepath.display());
104        }
105    }
106
107    if include_media {
108        copy_media(config, output_dir, "media")?;
109    }
110
111    tracing::info!("Export complete to {}", output_dir.display());
112    Ok(())
113}
114
115fn export_hugo(
116    db: &crate::Database,
117    config: &Config,
118    output_dir: &Path,
119    include_drafts: bool,
120    include_media: bool,
121) -> Result<()> {
122    let posts_dir = output_dir.join("content").join("posts");
123    let pages_dir = output_dir.join("content");
124    fs::create_dir_all(&posts_dir)?;
125    fs::create_dir_all(&pages_dir)?;
126
127    let status = if include_drafts {
128        None
129    } else {
130        Some(ContentStatus::Published)
131    };
132
133    let posts = content::list_content(db, Some(ContentType::Post), status.clone(), 10000, 0)?;
134    let pages = content::list_content(db, Some(ContentType::Page), status, 10000, 0)?;
135
136    tracing::info!("Exporting {} posts and {} pages (Hugo format)", posts.len(), pages.len());
137
138    for post in posts {
139        let full_content = content::get_content_by_id(db, post.id)?;
140        if let Some(c) = full_content {
141            let filename = format!("{}.md", c.content.slug);
142            let filepath = posts_dir.join(&filename);
143
144            let date = c.content.published_at.as_deref()
145                .or(Some(&c.content.created_at))
146                .unwrap_or("");
147
148            let is_draft = c.content.status != ContentStatus::Published;
149            let tag_names: Vec<&str> = c.tags.iter().map(|t| t.name.as_str()).collect();
150
151            let mut frontmatter = format!(
152                "+++\ntitle = \"{}\"\nslug = \"{}\"\ndate = \"{}\"\ndraft = {}\n",
153                c.content.title.replace('"', "\\\""),
154                c.content.slug,
155                date,
156                is_draft,
157            );
158
159            if !tag_names.is_empty() {
160                let tags_str = tag_names.iter().map(|t| format!("\"{}\"", t)).collect::<Vec<_>>().join(", ");
161                frontmatter.push_str(&format!("tags = [{}]\n", tags_str));
162            }
163
164            if let Some(ref excerpt) = c.content.excerpt {
165                frontmatter.push_str(&format!("description = \"{}\"\n", excerpt.replace('"', "\\\"")));
166            }
167
168            if let Some(ref img) = c.content.featured_image {
169                frontmatter.push_str(&format!("images = [\"{}\"]\n", img));
170            }
171
172            frontmatter.push_str("+++\n\n");
173
174            let content_str = format!("{}{}", frontmatter, c.content.body_markdown);
175            fs::write(&filepath, content_str)?;
176            tracing::info!("Exported: {}", filepath.display());
177        }
178    }
179
180    for page in pages {
181        let full_content = content::get_content_by_id(db, page.id)?;
182        if let Some(c) = full_content {
183            let filename = format!("{}.md", c.content.slug);
184            let filepath = pages_dir.join(&filename);
185
186            let date = c.content.published_at.as_deref()
187                .or(Some(&c.content.created_at))
188                .unwrap_or("");
189
190            let is_draft = c.content.status != ContentStatus::Published;
191
192            let frontmatter = format!(
193                "+++\ntitle = \"{}\"\nslug = \"{}\"\ndate = \"{}\"\ndraft = {}\n+++\n\n",
194                c.content.title.replace('"', "\\\""),
195                c.content.slug,
196                date,
197                is_draft,
198            );
199
200            let content_str = format!("{}{}", frontmatter, c.content.body_markdown);
201            fs::write(&filepath, content_str)?;
202            tracing::info!("Exported: {}", filepath.display());
203        }
204    }
205
206    if include_media {
207        copy_media(config, output_dir, "static/media")?;
208    }
209
210    tracing::info!("Hugo export complete to {}", output_dir.display());
211    Ok(())
212}
213
214fn export_zola(
215    db: &crate::Database,
216    config: &Config,
217    output_dir: &Path,
218    include_drafts: bool,
219    include_media: bool,
220) -> Result<()> {
221    let posts_dir = output_dir.join("content").join("blog");
222    let pages_dir = output_dir.join("content");
223    fs::create_dir_all(&posts_dir)?;
224    fs::create_dir_all(&pages_dir)?;
225
226    let status = if include_drafts {
227        None
228    } else {
229        Some(ContentStatus::Published)
230    };
231
232    let posts = content::list_content(db, Some(ContentType::Post), status.clone(), 10000, 0)?;
233    let pages = content::list_content(db, Some(ContentType::Page), status, 10000, 0)?;
234
235    tracing::info!("Exporting {} posts and {} pages (Zola format)", posts.len(), pages.len());
236
237    for post in posts {
238        let full_content = content::get_content_by_id(db, post.id)?;
239        if let Some(c) = full_content {
240            let filename = format!("{}.md", c.content.slug);
241            let filepath = posts_dir.join(&filename);
242
243            let date = c.content.published_at.as_deref()
244                .or(Some(&c.content.created_at))
245                .unwrap_or("");
246
247            let is_draft = c.content.status != ContentStatus::Published;
248            let tag_names: Vec<&str> = c.tags.iter().map(|t| t.name.as_str()).collect();
249
250            let mut frontmatter = format!(
251                "+++\ntitle = \"{}\"\nslug = \"{}\"\ndate = \"{}\"\ndraft = {}\n",
252                c.content.title.replace('"', "\\\""),
253                c.content.slug,
254                date,
255                is_draft,
256            );
257
258            if let Some(ref excerpt) = c.content.excerpt {
259                frontmatter.push_str(&format!("description = \"{}\"\n", excerpt.replace('"', "\\\"")));
260            }
261
262            if !tag_names.is_empty() {
263                let tags_str = tag_names.iter().map(|t| format!("\"{}\"", t)).collect::<Vec<_>>().join(", ");
264                frontmatter.push_str(&format!("\n[taxonomies]\ntags = [{}]\n", tags_str));
265            }
266
267            if let Some(ref img) = c.content.featured_image {
268                frontmatter.push_str(&format!("\n[extra]\nimage = \"{}\"\n", img));
269            }
270
271            frontmatter.push_str("+++\n\n");
272
273            let content_str = format!("{}{}", frontmatter, c.content.body_markdown);
274            fs::write(&filepath, content_str)?;
275            tracing::info!("Exported: {}", filepath.display());
276        }
277    }
278
279    for page in pages {
280        let full_content = content::get_content_by_id(db, page.id)?;
281        if let Some(c) = full_content {
282            let filename = format!("{}.md", c.content.slug);
283            let filepath = pages_dir.join(&filename);
284
285            let date = c.content.published_at.as_deref()
286                .or(Some(&c.content.created_at))
287                .unwrap_or("");
288
289            let is_draft = c.content.status != ContentStatus::Published;
290
291            let frontmatter = format!(
292                "+++\ntitle = \"{}\"\nslug = \"{}\"\ndate = \"{}\"\ndraft = {}\n+++\n\n",
293                c.content.title.replace('"', "\\\""),
294                c.content.slug,
295                date,
296                is_draft,
297            );
298
299            let content_str = format!("{}{}", frontmatter, c.content.body_markdown);
300            fs::write(&filepath, content_str)?;
301            tracing::info!("Exported: {}", filepath.display());
302        }
303    }
304
305    if include_media {
306        copy_media(config, output_dir, "static/media")?;
307    }
308
309    tracing::info!("Zola export complete to {}", output_dir.display());
310    Ok(())
311}
312
313fn copy_media(config: &Config, output_dir: &Path, subdir: &str) -> Result<()> {
314    let media_src = Path::new(&config.media.upload_dir);
315    if media_src.exists() {
316        let media_dest = output_dir.join(subdir);
317        fs::create_dir_all(&media_dest)?;
318
319        let mut count = 0;
320        for entry in fs::read_dir(media_src)? {
321            let entry = entry?;
322            let path = entry.path();
323            if path.is_file() {
324                if let Some(filename) = path.file_name() {
325                    fs::copy(&path, media_dest.join(filename))?;
326                    count += 1;
327                }
328            }
329        }
330        tracing::info!("Exported {} media files", count);
331    }
332    Ok(())
333}