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 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 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 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 build_unified_preview(
544 &elements,
545 content_info,
546 ID,
547 "Notion",
548 None, 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}