Skip to main content

typub_adapter_notion/
adapter.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use chrono::Utc;
4use reqwest::Client;
5use serde_json::{Value, json};
6use std::path::PathBuf;
7use typub_adapters_core::{
8    AdapterContext, AdapterPayload, ContentInfo, OutputFormat, PlatformAdapter, PlatformBranding,
9    build_unified_preview, downcast_payload, ensure_no_unresolved_image_markers,
10    materialize_and_resolve_urls, mock_materialize_and_resolve_urls, prepare_deferred_assets,
11    resolve_asset_urls, warn,
12};
13use typub_config::{Config, PlatformConfig};
14use typub_core::AssetStrategy;
15use typub_ir::Document;
16use typub_storage::{PublishResult, build_resolved_url_map, mime_type_from_path};
17
18use crate::blocks;
19use crate::client::{NOTION_API_BASE, NotionClient};
20use crate::config::{render_config_for, resolve_asset_strategy};
21use crate::model::{DESIRED_TITLE_PROPERTY, ID, NotionPayload, NotionSchema};
22
23pub struct NotionAdapter {
24    http_client: Client,
25    api_base: String,
26    api_key: Option<String>,
27    data_source_id: Option<String>,
28    has_token: bool,
29    tags_property: String,
30    asset_strategy: AssetStrategy,
31}
32
33impl NotionAdapter {
34    pub fn new(config: &Config) -> Result<Self> {
35        let platform_config = config.get_platform(ID);
36
37        let data_source_id = platform_config.and_then(|c| c.get_str("data_source_id"));
38
39        let api_key = platform_config
40            .and_then(|c| c.get_str("api_key"))
41            .or_else(|| std::env::var("NOTION_API_KEY").ok());
42        let has_token = api_key.is_some();
43        let tags_property = platform_config
44            .and_then(|c| c.get_str("tags_property"))
45            .unwrap_or_else(|| "Tags".to_string());
46        let asset_strategy = resolve_asset_strategy(platform_config)?;
47
48        Ok(Self {
49            http_client: Client::new(),
50            api_base: NOTION_API_BASE.to_string(),
51            api_key,
52            data_source_id,
53            has_token,
54            tags_property,
55            asset_strategy,
56        })
57    }
58
59    fn client(&self) -> NotionClient<'_> {
60        NotionClient::new(
61            &self.http_client,
62            &self.api_base,
63            self.api_key.as_deref().unwrap_or(""),
64        )
65    }
66
67    pub(crate) async fn find_existing_page(
68        &self,
69        data_source_id: &str,
70        title_property: &str,
71        title: &str,
72    ) -> Result<Option<String>> {
73        let filter = json!({
74            "filter": {
75                "property": title_property,
76                "title": { "equals": title }
77            }
78        });
79
80        let result = self
81            .client()
82            .query_data_source(data_source_id, filter)
83            .await?;
84
85        if let Some(pages) = result["results"].as_array()
86            && let Some(page) = pages.first()
87            && let Some(id) = page["id"].as_str()
88        {
89            return Ok(Some(id.to_string()));
90        }
91
92        Ok(None)
93    }
94
95    async fn upload_file(&self, file_path: &std::path::Path, filename: &str) -> Result<String> {
96        let file_data = std::fs::read(file_path)
97            .with_context(|| format!("Failed to read file: {}", file_path.display()))?;
98
99        let content_type = mime_type_from_path(file_path);
100
101        let (upload_id, upload_url) = self
102            .client()
103            .create_file_upload(filename, content_type)
104            .await?;
105
106        self.client()
107            .send_file_upload(&upload_url, file_data, filename, content_type)
108            .await?;
109
110        Ok(upload_id)
111    }
112
113    async fn try_update_page(
114        &self,
115        page_id: &str,
116        properties: &Value,
117        blocks: &[Value],
118    ) -> Result<()> {
119        self.client()
120            .update_page_properties(page_id, properties.clone())
121            .await?;
122        self.client().erase_page_content(page_id).await?;
123        self.client().append_block_children(page_id, blocks).await?;
124        Ok(())
125    }
126
127    async fn fallback_update_or_create(
128        &self,
129        data_source_id: &str,
130        title_property: &str,
131        title: &str,
132        properties: Value,
133        blocks: &[Value],
134    ) -> Result<(String, String)> {
135        if let Some(fallback_id) = self
136            .find_existing_page(data_source_id, title_property, title)
137            .await?
138        {
139            self.try_update_page(&fallback_id, &properties, blocks)
140                .await?;
141            let url = format!("https://www.notion.so/{}", fallback_id.replace("-", ""));
142            Ok((fallback_id, url))
143        } else {
144            self.create_page_with_blocks(data_source_id, properties, blocks)
145                .await
146        }
147    }
148
149    pub(crate) async fn create_page_with_blocks(
150        &self,
151        data_source_id: &str,
152        properties: Value,
153        blocks: &[Value],
154    ) -> Result<(String, String)> {
155        const BLOCK_LIMIT: usize = 100;
156        let (initial, remaining) = if blocks.len() > BLOCK_LIMIT {
157            blocks.split_at(BLOCK_LIMIT)
158        } else {
159            (blocks, [].as_slice())
160        };
161
162        let result = self
163            .client()
164            .create_page(data_source_id, properties, initial)
165            .await?;
166
167        let page_id = result["id"]
168            .as_str()
169            .ok_or_else(|| anyhow::anyhow!("No page ID in response"))?
170            .to_string();
171        let url = result["url"]
172            .as_str()
173            .ok_or_else(|| anyhow::anyhow!("No URL in response"))?
174            .to_string();
175
176        if !remaining.is_empty() {
177            self.client()
178                .append_block_children(&page_id, remaining)
179                .await?;
180        }
181
182        Ok((page_id, url))
183    }
184
185    pub(crate) fn normalized_tags(tags: &[String]) -> Vec<String> {
186        let mut normalized: Vec<String> = tags
187            .iter()
188            .map(|tag| tag.trim())
189            .filter(|tag| !tag.is_empty())
190            .map(ToOwned::to_owned)
191            .collect();
192        normalized.sort_by_key(|tag| tag.to_lowercase());
193        normalized.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
194        normalized
195    }
196
197    pub(crate) fn build_properties(
198        &self,
199        schema: &NotionSchema,
200        title: &str,
201        tags: &[String],
202    ) -> Value {
203        let title_text = json!([{
204            "text": { "content": title }
205        }]);
206        let normalized_tags = Self::normalized_tags(tags);
207        let tags_values: Vec<Value> = normalized_tags
208            .into_iter()
209            .map(|name| json!({ "name": name }))
210            .collect();
211
212        json!({
213            &schema.title_property: { "title": title_text },
214            &schema.tags_property: { "multi_select": tags_values },
215        })
216    }
217
218    pub(crate) async fn ensure_data_source_schema(
219        &self,
220        data_source_id: &str,
221    ) -> Result<NotionSchema> {
222        let data_source = self.client().get_data_source(data_source_id).await?;
223        let properties = data_source["properties"]
224            .as_object()
225            .ok_or_else(|| anyhow::anyhow!("Notion data source properties missing"))?;
226
227        let mut title_property_id: Option<String> = None;
228        for (_name, prop) in properties {
229            if prop["type"].as_str() == Some("title") {
230                title_property_id = prop["id"].as_str().map(ToOwned::to_owned);
231                break;
232            }
233        }
234
235        if let Some(existing_title) = properties.get(DESIRED_TITLE_PROPERTY) {
236            if existing_title["type"].as_str() != Some("title") {
237                anyhow::bail!(
238                    "Notion property '{}' exists but is type '{}'; expected type 'title'",
239                    DESIRED_TITLE_PROPERTY,
240                    existing_title["type"].as_str().unwrap_or("unknown")
241                );
242            }
243        } else if let Some(property_id) = title_property_id {
244            self.client()
245                .update_data_source(
246                    data_source_id,
247                    json!({
248                        "properties": {
249                            property_id: { "name": DESIRED_TITLE_PROPERTY }
250                        }
251                    }),
252                )
253                .await?;
254        } else {
255            self.client()
256                .update_data_source(
257                    data_source_id,
258                    json!({
259                        "properties": {
260                            DESIRED_TITLE_PROPERTY: { "title": {} }
261                        }
262                    }),
263                )
264                .await?;
265        }
266
267        if let Some(tags_prop) = properties.get(&self.tags_property) {
268            if tags_prop["type"].as_str() != Some("multi_select") {
269                anyhow::bail!(
270                    "Notion property '{}' exists but is type '{}'; expected type 'multi_select'",
271                    self.tags_property,
272                    tags_prop["type"].as_str().unwrap_or("unknown")
273                );
274            }
275        } else {
276            self.client()
277                .update_data_source(
278                    data_source_id,
279                    json!({
280                        "properties": {
281                            &self.tags_property: { "multi_select": {} }
282                        }
283                    }),
284                )
285                .await?;
286        }
287
288        Ok(NotionSchema {
289            title_property: DESIRED_TITLE_PROPERTY.to_string(),
290            tags_property: self.tags_property.clone(),
291        })
292    }
293
294    #[cfg(test)]
295    pub(crate) fn new_for_test() -> NotionAdapter {
296        NotionAdapter::new_for_test_with(
297            "http://localhost",
298            true,
299            Some("ds-1".to_string()),
300            AssetStrategy::Upload,
301        )
302    }
303
304    #[cfg(test)]
305    pub(crate) fn new_for_test_with(
306        api_base: &str,
307        has_token: bool,
308        data_source_id: Option<String>,
309        asset_strategy: AssetStrategy,
310    ) -> NotionAdapter {
311        NotionAdapter {
312            http_client: Client::new(),
313            api_base: api_base.to_string(),
314            api_key: if has_token {
315                Some("test-token".to_string())
316            } else {
317                None
318            },
319            data_source_id,
320            has_token,
321            tags_property: "Tags".to_string(),
322            asset_strategy,
323        }
324    }
325}
326
327#[async_trait(?Send)]
328impl PlatformAdapter for NotionAdapter {
329    fn id(&self) -> &'static str {
330        ID
331    }
332
333    fn name(&self) -> &'static str {
334        "Notion"
335    }
336
337    fn required_format(&self) -> OutputFormat {
338        OutputFormat::Html
339    }
340
341    fn asset_strategy(&self) -> AssetStrategy {
342        self.asset_strategy
343    }
344
345    fn render_config(&self, _content_info: &ContentInfo) -> typub_adapters_core::RenderConfig {
346        render_config_for(self.asset_strategy)
347    }
348
349    fn validate_config(&self, _config: &PlatformConfig) -> Result<()> {
350        if !self.has_token {
351            anyhow::bail!(
352                "NOTION_API_KEY not set (configure notion.api_key or set NOTION_API_KEY env var)"
353            );
354        }
355        if self.data_source_id.is_none() {
356            anyhow::bail!("notion.data_source_id not configured");
357        }
358        Ok(())
359    }
360
361    fn supports_shared_link_rewrite(&self) -> bool {
362        true
363    }
364
365    async fn specialize_payload(
366        &self,
367        document: Document,
368        ctx: &dyn AdapterContext,
369    ) -> Result<AdapterPayload> {
370        let content_info = ctx.content_info();
371        let data_source_id = self
372            .data_source_id
373            .as_ref()
374            .ok_or_else(|| anyhow::anyhow!("notion.data_source_id not configured"))?;
375
376        let deferred = prepare_deferred_assets(self.asset_strategy, &document, &content_info.path);
377
378        Ok(AdapterPayload::new(
379            NotionPayload {
380                data_source_id: data_source_id.clone(),
381                title: content_info.title.clone(),
382                existing_page_id: ctx.get_platform_id(&content_info.slug, ID)?,
383                schema: None,
384                blocks: Vec::new(),
385            },
386            content_info.clone(),
387            deferred,
388            document,
389        ))
390    }
391
392    async fn provision_target(
393        &self,
394        mut payload: AdapterPayload,
395        _ctx: &dyn AdapterContext,
396    ) -> Result<AdapterPayload> {
397        let inner = payload
398            .downcast_mut::<NotionPayload>()
399            .ok_or_else(|| anyhow::anyhow!("Invalid Notion payload type"))?;
400        inner.schema = Some(
401            self.ensure_data_source_schema(&inner.data_source_id)
402                .await?,
403        );
404        Ok(payload)
405    }
406
407    async fn materialize_payload(
408        &self,
409        mut payload: AdapterPayload,
410        ctx: &dyn AdapterContext,
411    ) -> Result<AdapterPayload> {
412        // Dry-run mode: generate mock URLs without file I/O
413        if ctx.is_dry_run() {
414            mock_materialize_and_resolve_urls(&mut payload, ctx)?;
415            return Ok(payload);
416        }
417
418        if payload.assets.needs_materialize() {
419            match payload.assets.strategy {
420                AssetStrategy::Upload => {
421                    // Notion uses its native file upload API
422                    let mut resolved = std::collections::HashMap::new();
423                    for asset in &payload.assets.pending.assets {
424                        let filename = asset
425                            .local_path
426                            .file_name()
427                            .and_then(|n| n.to_str())
428                            .unwrap_or("image");
429                        let upload_id = self
430                            .upload_file(&asset.local_path, filename)
431                            .await
432                            .with_context(|| {
433                                format!(
434                                    "Failed to upload image '{}' to Notion",
435                                    asset.local_path.display()
436                                )
437                            })?;
438                        resolved.insert(asset.index, upload_id);
439                    }
440                    payload.assets.resolved = resolved;
441                }
442                AssetStrategy::External => {
443                    // External uses S3 storage with standard helper
444                    materialize_and_resolve_urls(&mut payload, ctx).await?;
445                    return Ok(payload);
446                }
447                _ => {}
448            }
449
450            if !payload.assets.resolved.is_empty() {
451                let url_map = build_resolved_url_map(&payload.assets, &payload.content_info.path);
452                resolve_asset_urls(&mut payload.document, &url_map);
453            }
454        }
455
456        Ok(payload)
457    }
458
459    async fn serialize_payload(
460        &self,
461        mut payload: AdapterPayload,
462        _ctx: &dyn AdapterContext,
463    ) -> Result<AdapterPayload> {
464        ensure_no_unresolved_image_markers(self.id(), self.asset_strategy, &payload.document)?;
465        let marker_map = build_resolved_url_map(&payload.assets, &payload.content_info.path);
466        let notion_blocks = blocks::document_to_blocks(&payload.document, &marker_map);
467
468        let inner = payload
469            .downcast_mut::<NotionPayload>()
470            .ok_or_else(|| anyhow::anyhow!("Invalid Notion payload type"))?;
471        inner.blocks = notion_blocks;
472        Ok(payload)
473    }
474
475    async fn publish_payload(
476        &self,
477        payload: AdapterPayload,
478        _ctx: &dyn AdapterContext,
479    ) -> Result<PublishResult> {
480        let content_info = payload.content_info.clone();
481        let payload = downcast_payload::<NotionPayload>(payload, "Notion")?;
482        let schema = payload
483            .schema
484            .as_ref()
485            .ok_or_else(|| anyhow::anyhow!("Notion schema not provisioned"))?;
486        let properties = self.build_properties(schema, &content_info.title, &content_info.tags);
487        let blocks = &payload.blocks;
488
489        let existing_page_id = if payload.existing_page_id.is_some() {
490            payload.existing_page_id.clone()
491        } else {
492            self.find_existing_page(
493                &payload.data_source_id,
494                &schema.title_property,
495                &payload.title,
496            )
497            .await?
498        };
499
500        let (page_id, url) = if let Some(page_id) = existing_page_id {
501            match self.try_update_page(&page_id, &properties, blocks).await {
502                Ok(()) => {
503                    let page_url = format!("https://www.notion.so/{}", page_id.replace("-", ""));
504                    (page_id, page_url)
505                }
506                Err(update_err) if payload.existing_page_id.is_some() => {
507                    warn!(
508                        "Cached Notion page id '{}' update failed ({}); attempting title lookup fallback",
509                        page_id, update_err
510                    );
511                    self.fallback_update_or_create(
512                        &payload.data_source_id,
513                        &schema.title_property,
514                        &payload.title,
515                        properties,
516                        blocks,
517                    )
518                    .await?
519                }
520                Err(update_err) => return Err(update_err),
521            }
522        } else {
523            self.create_page_with_blocks(&payload.data_source_id, properties, blocks)
524                .await?
525        };
526
527        Ok(PublishResult {
528            url: Some(url),
529            platform_id: Some(page_id),
530            published_at: Utc::now(),
531        })
532    }
533
534    fn build_preview(
535        &self,
536        _title: &str,
537        elements: Document,
538        ctx: &dyn AdapterContext,
539    ) -> Result<PathBuf> {
540        let content_info = ctx.content_info();
541
542        // Use unified preview builder with MathJax support for LaTeX
543        build_unified_preview(
544            &elements,
545            content_info,
546            ID,
547            "Notion",
548            None, // Notion uses custom inline styles
549            Some(&PlatformBranding::new("#ffffff", "#000000")),
550        )
551    }
552
553    async fn check_status(&self, _slug: &str) -> Result<bool> {
554        if !self.has_token {
555            return Ok(false);
556        }
557        Ok(false)
558    }
559}