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}