1use crate::models::{ContentStatus, ContentType, CreateContent};
2use crate::services::content;
3use crate::Config;
4use anyhow::Result;
5use std::fs;
6use std::path::Path;
7
8pub async fn run(config_path: &Path, import_dir: &Path, overwrite: bool) -> Result<()> {
9 let config = Config::load(config_path)?;
10 let db = crate::Database::open(&config.database.path)?;
11
12 if !import_dir.exists() {
13 anyhow::bail!("Import directory not found: {}", import_dir.display());
14 }
15
16 let posts_dir = import_dir.join("posts");
17 let pages_dir = import_dir.join("pages");
18 let media_dir = import_dir.join("media");
19
20 let mut imported = 0;
21 let mut skipped = 0;
22
23 if posts_dir.exists() {
24 let (i, s) = import_content_dir(
25 &db,
26 &posts_dir,
27 ContentType::Post,
28 overwrite,
29 config.content.excerpt_length,
30 )?;
31 imported += i;
32 skipped += s;
33 }
34
35 if pages_dir.exists() {
36 let (i, s) = import_content_dir(
37 &db,
38 &pages_dir,
39 ContentType::Page,
40 overwrite,
41 config.content.excerpt_length,
42 )?;
43 imported += i;
44 skipped += s;
45 }
46
47 if media_dir.exists() {
48 let dest_media = Path::new(&config.media.upload_dir);
49 fs::create_dir_all(dest_media)?;
50
51 for entry in fs::read_dir(&media_dir)? {
52 let entry = entry?;
53 let path = entry.path();
54 if path.is_file() {
55 if let Some(filename) = path.file_name() {
56 let dest = dest_media.join(filename);
57 if !dest.exists() || overwrite {
58 fs::copy(&path, &dest)?;
59 tracing::info!("Copied media: {}", filename.to_string_lossy());
60 }
61 }
62 }
63 }
64 }
65
66 tracing::info!(
67 "Import complete: {} imported, {} skipped",
68 imported,
69 skipped
70 );
71 Ok(())
72}
73
74fn import_content_dir(
75 db: &crate::Database,
76 dir: &Path,
77 content_type: ContentType,
78 overwrite: bool,
79 excerpt_length: usize,
80) -> Result<(usize, usize)> {
81 let mut imported = 0;
82 let mut skipped = 0;
83
84 for entry in fs::read_dir(dir)? {
85 let entry = entry?;
86 let path = entry.path();
87
88 if path.extension().map(|e| e == "md").unwrap_or(false) {
89 match import_markdown_file(db, &path, content_type.clone(), overwrite, excerpt_length) {
90 Ok(true) => imported += 1,
91 Ok(false) => skipped += 1,
92 Err(e) => {
93 tracing::warn!("Failed to import {}: {}", path.display(), e);
94 skipped += 1;
95 }
96 }
97 }
98 }
99
100 Ok((imported, skipped))
101}
102
103fn import_markdown_file(
104 db: &crate::Database,
105 path: &Path,
106 content_type: ContentType,
107 overwrite: bool,
108 excerpt_length: usize,
109) -> Result<bool> {
110 let file_content = fs::read_to_string(path)?;
111 let (frontmatter, body) = parse_frontmatter(&file_content)?;
112
113 let slug = frontmatter
114 .get("slug")
115 .and_then(|v| v.as_str())
116 .map(|s| s.to_string())
117 .unwrap_or_else(|| {
118 path.file_stem()
119 .unwrap_or_default()
120 .to_string_lossy()
121 .to_string()
122 });
123
124 let title = frontmatter
125 .get("title")
126 .and_then(|v| v.as_str())
127 .unwrap_or("Untitled")
128 .to_string();
129
130 let status_str = frontmatter
131 .get("status")
132 .and_then(|v| v.as_str())
133 .unwrap_or("draft");
134
135 let status = match status_str {
136 "published" => ContentStatus::Published,
137 "archived" => ContentStatus::Archived,
138 _ => ContentStatus::Draft,
139 };
140
141 let input = CreateContent {
142 title,
143 slug: Some(slug.clone()),
144 content_type: content_type.clone(),
145 body_markdown: body.to_string(),
146 status,
147 scheduled_at: None,
148 excerpt: None,
149 featured_image: None,
150 tags: vec![],
151 metadata: None,
152 };
153
154 {
156 let conn = db.get()?;
157 let existing_id: Option<i64> = conn
158 .query_row("SELECT id FROM content WHERE slug = ?", [&slug], |row| {
159 row.get(0)
160 })
161 .ok();
162
163 if let Some(id) = existing_id {
164 if !overwrite {
165 tracing::info!("Skipping existing: {}", slug);
166 return Ok(false);
167 }
168 conn.execute("DELETE FROM content WHERE id = ?", [id])?;
169 }
170 }
171
172 match content::create_content(db, input, None, excerpt_length) {
174 Ok(_) => {
175 tracing::info!("Imported: {} ({})", slug, content_type);
176 Ok(true)
177 }
178 Err(e) if e.to_string().contains("UNIQUE constraint") => {
179 if !overwrite {
180 tracing::info!("Skipping existing (race): {}", slug);
181 return Ok(false);
182 }
183 Err(e)
184 }
185 Err(e) => Err(e),
186 }
187}
188
189fn parse_frontmatter(content: &str) -> Result<(serde_json::Map<String, serde_json::Value>, &str)> {
190 let content = content.trim_start();
191
192 if !content.starts_with("---") {
193 return Ok((serde_json::Map::new(), content));
194 }
195
196 let after_first = &content[3..];
197 let end_pos = after_first.find("---");
198
199 match end_pos {
200 Some(pos) => {
201 let yaml_content = &after_first[..pos].trim();
202 let body = &after_first[pos + 3..].trim_start();
203
204 let mut map = serde_json::Map::new();
205 for line in yaml_content.lines() {
206 if let Some((key, value)) = line.split_once(':') {
207 let key = key.trim();
208 let value = value.trim().trim_matches('"').trim_matches('\'');
209 if !key.is_empty() {
210 map.insert(
211 key.to_string(),
212 serde_json::Value::String(value.to_string()),
213 );
214 }
215 }
216 }
217
218 Ok((map, body))
219 }
220 None => Ok((serde_json::Map::new(), content)),
221 }
222}