Skip to main content

pebble_cms/cli/
import.rs

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    // Atomically check for existing content and handle overwrite
155    {
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    // The unique constraint on slug will catch any race condition
173    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}