Skip to main content

web_capture/
shared_dialog.rs

1//! Shared AI dialog capture and normalization.
2//!
3//! `ChatGPT` shared pages embed a React Router stream with a devalue table that
4//! contains `linear_conversation`. This module decodes that table into a
5//! provider-neutral capture result and reports unsupported provider states as
6//! structured diagnostics.
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use url::Url;
11
12const ENQUEUE_MARKER: &str = "window.__reactRouterContext.streamController.enqueue(";
13const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
14
15#[derive(Debug, Default, Clone, PartialEq, Eq)]
16pub struct SharedDialogParseOptions {
17    pub source_url: Option<String>,
18    pub capture_method: String,
19    pub captured_at: Option<String>,
20    pub http_status: Option<u16>,
21    pub provider: Option<String>,
22    pub warnings: Vec<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "camelCase")]
27pub struct SharedDialogCapture {
28    pub provider: String,
29    pub source_url: String,
30    pub capture_method: String,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub captured_at: Option<String>,
33    pub status: String,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub conversation_id: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub title: Option<String>,
38    pub turns: Vec<SharedDialogTurn>,
39    pub diagnostics: SharedDialogDiagnostics,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "camelCase")]
44pub struct SharedDialogTurn {
45    pub id: String,
46    pub role: String,
47    pub content: String,
48    pub visibility: String,
49    pub source_evidence: Vec<SharedDialogEvidence>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "camelCase")]
54pub struct SharedDialogEvidence {
55    pub kind: String,
56    pub source_url: String,
57    pub capture_method: String,
58    pub pointer: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "camelCase")]
63pub struct SharedDialogDiagnostics {
64    pub status: String,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub http_status: Option<u16>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub unsupported_reason: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub message: Option<String>,
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub warnings: Vec<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub error: Option<String>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78struct UnsupportedDiagnostic {
79    reason: &'static str,
80    message: &'static str,
81}
82
83/// Parse captured HTML or a compact transcript into a normalized shared-dialog
84/// result. Unsupported inputs return a structured diagnostic instead of an
85/// error.
86#[must_use]
87pub fn parse_shared_dialog(input: &str, options: &SharedDialogParseOptions) -> SharedDialogCapture {
88    let source_url = options.source_url.clone().unwrap_or_default();
89    let capture_method = if options.capture_method.is_empty() {
90        "static_http".to_string()
91    } else {
92        options.capture_method.clone()
93    };
94    let provider = options
95        .provider
96        .clone()
97        .unwrap_or_else(|| detect_provider(input, &source_url));
98
99    if provider == "chatgpt" {
100        return match parse_chatgpt_share_html(input, options, &source_url, &capture_method) {
101            Ok(capture) => capture,
102            Err(error) => {
103                let diagnostic = parse_unsupported(input, &provider);
104                unsupported_capture(UnsupportedCaptureInput {
105                    provider,
106                    source_url,
107                    capture_method,
108                    captured_at: options.captured_at.clone(),
109                    http_status: options.http_status,
110                    unsupported_reason: diagnostic.reason,
111                    message: if diagnostic.reason == "no_transcript_in_captured_dom" {
112                        format!(
113                            "ChatGPT share capture did not contain a parseable linear_conversation transcript: {error}"
114                        )
115                    } else {
116                        diagnostic.message.to_string()
117                    },
118                    warnings: options.warnings.clone(),
119                    error: None,
120                })
121            }
122        };
123    }
124
125    let markdown =
126        parse_markdown_transcript(input, options, &provider, &source_url, &capture_method);
127    if markdown.status == "ok" {
128        return markdown;
129    }
130
131    let diagnostic = parse_unsupported(input, &provider);
132    unsupported_capture(UnsupportedCaptureInput {
133        provider,
134        source_url,
135        capture_method,
136        captured_at: options.captured_at.clone(),
137        http_status: options.http_status,
138        unsupported_reason: diagnostic.reason,
139        message: diagnostic.message.to_string(),
140        warnings: options.warnings.clone(),
141        error: None,
142    })
143}
144
145/// Fetch and normalize a shared dialog URL.
146///
147/// Browser mode first tries static HTTP, then falls back to rendered DOM when
148/// the static capture is unsupported.
149pub async fn capture_shared_dialog(
150    url: &str,
151    capture: &str,
152    captured_at: Option<String>,
153) -> Result<SharedDialogCapture, String> {
154    let source_url = normalize_source_url(url)?;
155    let mut http_status = None;
156    let mut warnings = Vec::new();
157    let mut static_html = String::new();
158
159    match reqwest::Client::builder().user_agent(USER_AGENT).build() {
160        Ok(client) => match client
161            .get(&source_url)
162            .header("Accept", "text/html,application/xhtml+xml")
163            .header("Accept-Language", "en-US,en;q=0.9")
164            .send()
165            .await
166        {
167            Ok(response) => {
168                http_status = Some(response.status().as_u16());
169                match response.text().await {
170                    Ok(body) => static_html = body,
171                    Err(error) => warnings.push(format!("static_http_read_failed: {error}")),
172                }
173            }
174            Err(error) => warnings.push(format!("static_http_failed: {error}")),
175        },
176        Err(error) => warnings.push(format!("static_http_client_failed: {error}")),
177    }
178
179    let static_capture = parse_shared_dialog(
180        &static_html,
181        &SharedDialogParseOptions {
182            source_url: Some(source_url.clone()),
183            capture_method: "static_http".to_string(),
184            captured_at: captured_at.clone(),
185            http_status,
186            warnings,
187            ..SharedDialogParseOptions::default()
188        },
189    );
190
191    if static_capture.status == "ok" || capture.eq_ignore_ascii_case("api") {
192        return Ok(static_capture);
193    }
194    if !capture.eq_ignore_ascii_case("browser") {
195        return Ok(with_warning(
196            static_capture,
197            format!("unsupported_capture_method: {capture}; use browser or api"),
198        ));
199    }
200
201    match crate::render_html(&source_url).await {
202        Ok(rendered) => {
203            let mut render_warnings = static_capture.diagnostics.warnings.clone();
204            if let Some(reason) = static_capture.diagnostics.unsupported_reason.as_ref() {
205                render_warnings.push(format!("static_http_unsupported: {reason}"));
206            }
207            Ok(parse_shared_dialog(
208                &rendered,
209                &SharedDialogParseOptions {
210                    source_url: Some(source_url),
211                    capture_method: "browser".to_string(),
212                    captured_at,
213                    warnings: render_warnings,
214                    ..SharedDialogParseOptions::default()
215                },
216            ))
217        }
218        Err(error) => Ok(with_warning(
219            static_capture,
220            format!("browser_render_failed: {error}"),
221        )),
222    }
223}
224
225fn parse_chatgpt_share_html(
226    input: &str,
227    options: &SharedDialogParseOptions,
228    source_url: &str,
229    capture_method: &str,
230) -> Result<SharedDialogCapture, String> {
231    let table = extract_chatgpt_devalue_table(input)?;
232    let resolved = resolve_devalue_root(&table)?;
233    let data = find_object_with_array_key(&resolved, "linear_conversation")
234        .ok_or_else(|| "linear_conversation data was not found".to_string())?;
235    let linear = data
236        .get("linear_conversation")
237        .and_then(Value::as_array)
238        .ok_or_else(|| "linear_conversation field was not an array".to_string())?;
239
240    let mut turns = Vec::new();
241    for (index, item) in linear.iter().enumerate() {
242        if let Some(turn) = chatgpt_turn_from_item(item, index, source_url, capture_method) {
243            turns.push(turn);
244        }
245    }
246    if turns.is_empty() {
247        return Err("no visible user or assistant turns were found".to_string());
248    }
249
250    let title = string_field(data, "title").or_else(|| title_from_html(input));
251    let conversation_id =
252        string_field(data, "conversation_id").or_else(|| chatgpt_share_id(source_url));
253
254    Ok(ok_capture(OkCaptureInput {
255        provider: "chatgpt".to_string(),
256        source_url: source_url.to_string(),
257        capture_method: capture_method.to_string(),
258        captured_at: options.captured_at.clone(),
259        http_status: options.http_status,
260        title,
261        conversation_id,
262        turns,
263        warnings: options.warnings.clone(),
264    }))
265}
266
267fn chatgpt_turn_from_item(
268    item: &Value,
269    index: usize,
270    source_url: &str,
271    capture_method: &str,
272) -> Option<SharedDialogTurn> {
273    let message = item.get("message").unwrap_or(item);
274    let role = message
275        .get("author")
276        .and_then(|author| author.get("role"))
277        .and_then(Value::as_str)?;
278    if role != "user" && role != "assistant" {
279        return None;
280    }
281    if message_is_hidden(message) {
282        return None;
283    }
284    let content = message
285        .get("content")
286        .map(message_content_text)
287        .unwrap_or_default()
288        .trim()
289        .to_string();
290    if content.is_empty() {
291        return None;
292    }
293    let id = message
294        .get("id")
295        .and_then(Value::as_str)
296        .map_or_else(|| format!("chatgpt-turn-{}", index + 1), ToOwned::to_owned);
297    Some(SharedDialogTurn {
298        id,
299        role: role.to_string(),
300        content,
301        visibility: "visible".to_string(),
302        source_evidence: vec![SharedDialogEvidence {
303            kind: "chatgpt_linear_conversation".to_string(),
304            source_url: source_url.to_string(),
305            capture_method: capture_method.to_string(),
306            pointer: format!("linear_conversation[{index}].message"),
307        }],
308    })
309}
310
311fn message_is_hidden(message: &Value) -> bool {
312    message
313        .get("metadata")
314        .and_then(|metadata| metadata.get("is_visually_hidden_from_conversation"))
315        .and_then(Value::as_bool)
316        .unwrap_or(false)
317}
318
319fn message_content_text(content: &Value) -> String {
320    if let Some(text) = content.as_str() {
321        return text.to_string();
322    }
323    if let Some(parts) = content.get("parts").and_then(Value::as_array) {
324        let mut texts = Vec::new();
325        for part in parts {
326            if let Some(text) = part.as_str() {
327                if !text.is_empty() {
328                    texts.push(text.to_string());
329                }
330            } else if let Some(text) = part.get("text").and_then(Value::as_str) {
331                if !text.is_empty() {
332                    texts.push(text.to_string());
333                }
334            }
335        }
336        return texts.join("\n\n");
337    }
338    content
339        .get("text")
340        .and_then(Value::as_str)
341        .unwrap_or_default()
342        .to_string()
343}
344
345fn extract_chatgpt_devalue_table(input: &str) -> Result<Vec<Value>, String> {
346    for chunk in extract_react_router_stream_chunks(input)? {
347        let trimmed = chunk.trim_start();
348        if !trimmed.starts_with('[') {
349            continue;
350        }
351        let value = serde_json::from_str::<Value>(trimmed)
352            .map_err(|error| format!("failed to parse React Router payload JSON: {error}"))?;
353        if let Value::Array(table) = value {
354            return Ok(table);
355        }
356    }
357    Err("ChatGPT share capture did not contain a JSON devalue table".to_string())
358}
359
360fn extract_react_router_stream_chunks(input: &str) -> Result<Vec<String>, String> {
361    let mut chunks = Vec::new();
362    let mut cursor = 0;
363    let bytes = input.as_bytes();
364    while let Some(relative) = input[cursor..].find(ENQUEUE_MARKER) {
365        let mut literal_start = cursor + relative + ENQUEUE_MARKER.len();
366        while matches!(bytes.get(literal_start), Some(b' ' | b'\n' | b'\r' | b'\t')) {
367            literal_start += 1;
368        }
369        if bytes.get(literal_start) != Some(&b'"') {
370            cursor = literal_start.saturating_add(1);
371            continue;
372        }
373        let (literal, literal_len) = extract_json_string_literal(&input[literal_start..])?;
374        let chunk = serde_json::from_str::<String>(literal)
375            .map_err(|error| format!("failed to decode React Router stream string: {error}"))?;
376        chunks.push(chunk);
377        cursor = literal_start + literal_len;
378    }
379    if chunks.is_empty() {
380        return Err("ChatGPT share capture did not contain React Router chunks".to_string());
381    }
382    Ok(chunks)
383}
384
385fn extract_json_string_literal(input: &str) -> Result<(&str, usize), String> {
386    let bytes = input.as_bytes();
387    if bytes.first() != Some(&b'"') {
388        return Err("expected a JSON string literal".to_string());
389    }
390    let mut index = 1;
391    while index < bytes.len() {
392        match bytes[index] {
393            b'\\' => index += 2,
394            b'"' => return Ok((&input[..=index], index + 1)),
395            _ => index += 1,
396        }
397    }
398    Err("unterminated JSON string literal".to_string())
399}
400
401fn resolve_devalue_root(table: &[Value]) -> Result<Value, String> {
402    if table.is_empty() {
403        return Err("ChatGPT devalue table was empty".to_string());
404    }
405    Ok(resolve_table_index(table, 0, &mut Vec::new()))
406}
407
408fn resolve_table_index(table: &[Value], index: usize, stack: &mut Vec<usize>) -> Value {
409    if index >= table.len() || stack.contains(&index) {
410        return Value::Null;
411    }
412    stack.push(index);
413    let value = resolve_table_value(table, &table[index], stack);
414    stack.pop();
415    value
416}
417
418fn resolve_table_value(table: &[Value], value: &Value, stack: &mut Vec<usize>) -> Value {
419    match value {
420        Value::Array(items) => Value::Array(
421            items
422                .iter()
423                .map(|item| resolve_encoded_reference(table, item, stack))
424                .collect(),
425        ),
426        Value::Object(map) => {
427            let mut resolved = Map::new();
428            for (encoded_key, encoded_value) in map {
429                let key = resolve_object_key(table, encoded_key, stack);
430                let value = resolve_encoded_reference(table, encoded_value, stack);
431                resolved.insert(key, value);
432            }
433            Value::Object(resolved)
434        }
435        other => other.clone(),
436    }
437}
438
439fn resolve_encoded_reference(table: &[Value], value: &Value, stack: &mut Vec<usize>) -> Value {
440    if let Some(index) = value.as_i64() {
441        if index < 0 {
442            return Value::Null;
443        }
444        if let Ok(index) = usize::try_from(index) {
445            return resolve_table_index(table, index, stack);
446        }
447        return Value::Null;
448    }
449    resolve_table_value(table, value, stack)
450}
451
452fn resolve_object_key(table: &[Value], encoded_key: &str, stack: &mut Vec<usize>) -> String {
453    if let Some(raw_index) = encoded_key.strip_prefix('_') {
454        if let Ok(index) = raw_index.parse::<usize>() {
455            if let Value::String(key) = resolve_table_index(table, index, stack) {
456                return key;
457            }
458        }
459    }
460    encoded_key.to_string()
461}
462
463fn find_object_with_array_key<'a>(value: &'a Value, key: &str) -> Option<&'a Map<String, Value>> {
464    match value {
465        Value::Object(map) => {
466            if map.get(key).and_then(Value::as_array).is_some() {
467                return Some(map);
468            }
469            map.values()
470                .find_map(|child| find_object_with_array_key(child, key))
471        }
472        Value::Array(items) => items
473            .iter()
474            .find_map(|child| find_object_with_array_key(child, key)),
475        _ => None,
476    }
477}
478
479fn parse_markdown_transcript(
480    input: &str,
481    options: &SharedDialogParseOptions,
482    provider: &str,
483    source_url: &str,
484    capture_method: &str,
485) -> SharedDialogCapture {
486    let mut turns = Vec::new();
487    let mut current_role: Option<&'static str> = None;
488    let mut current_lines = Vec::new();
489
490    for line in input.lines() {
491        if let Some((role, rest)) = markdown_turn_prefix(line) {
492            push_markdown_turn(
493                &mut turns,
494                current_role.take(),
495                &current_lines,
496                source_url,
497                capture_method,
498            );
499            current_role = Some(role);
500            current_lines.clear();
501            current_lines.push(rest.trim_start().to_string());
502        } else if current_role.is_some() {
503            current_lines.push(line.to_string());
504        }
505    }
506    push_markdown_turn(
507        &mut turns,
508        current_role.take(),
509        &current_lines,
510        source_url,
511        capture_method,
512    );
513
514    if turns.is_empty() {
515        return unsupported_capture(UnsupportedCaptureInput {
516            provider: provider.to_string(),
517            source_url: source_url.to_string(),
518            capture_method: capture_method.to_string(),
519            captured_at: options.captured_at.clone(),
520            http_status: options.http_status,
521            unsupported_reason: "no_transcript_in_captured_dom",
522            message: "No compact Markdown transcript turns were found.".to_string(),
523            warnings: options.warnings.clone(),
524            error: None,
525        });
526    }
527
528    ok_capture(OkCaptureInput {
529        provider: provider.to_string(),
530        source_url: source_url.to_string(),
531        capture_method: capture_method.to_string(),
532        captured_at: options.captured_at.clone(),
533        http_status: options.http_status,
534        title: None,
535        conversation_id: None,
536        turns,
537        warnings: options.warnings.clone(),
538    })
539}
540
541fn markdown_turn_prefix(line: &str) -> Option<(&'static str, &str)> {
542    let trimmed = line.trim_start();
543    for (prefix, role) in [
544        ("U:", "user"),
545        ("User:", "user"),
546        ("A:", "assistant"),
547        ("Assistant:", "assistant"),
548    ] {
549        if trimmed.len() >= prefix.len() && trimmed[..prefix.len()].eq_ignore_ascii_case(prefix) {
550            return Some((role, &trimmed[prefix.len()..]));
551        }
552    }
553    None
554}
555
556fn push_markdown_turn(
557    turns: &mut Vec<SharedDialogTurn>,
558    role: Option<&'static str>,
559    lines: &[String],
560    source_url: &str,
561    capture_method: &str,
562) {
563    let Some(role) = role else {
564        return;
565    };
566    let content = trimmed_content_lines(lines);
567    if content.is_empty() {
568        return;
569    }
570    let index = turns.len();
571    turns.push(SharedDialogTurn {
572        id: format!("markdown-turn-{}", index + 1),
573        role: role.to_string(),
574        content,
575        visibility: "visible".to_string(),
576        source_evidence: vec![SharedDialogEvidence {
577            kind: "markdown_transcript".to_string(),
578            source_url: source_url.to_string(),
579            capture_method: capture_method.to_string(),
580            pointer: format!("turns[{index}]"),
581        }],
582    });
583}
584
585fn trimmed_content_lines(lines: &[String]) -> String {
586    let Some(start) = lines.iter().position(|line| !line.trim().is_empty()) else {
587        return String::new();
588    };
589    let end = lines
590        .iter()
591        .rposition(|line| !line.trim().is_empty())
592        .unwrap_or(start);
593    lines[start..=end].join("\n").trim().to_string()
594}
595
596fn detect_provider(input: &str, source_url: &str) -> String {
597    if let Some(provider) = provider_from_url(source_url) {
598        return provider;
599    }
600    if input.contains(ENQUEUE_MARKER) || input.contains("linear_conversation") {
601        return "chatgpt".to_string();
602    }
603    if looks_like_google_ai_mode_interstitial(input) {
604        return "google_ai_mode".to_string();
605    }
606    "unknown".to_string()
607}
608
609fn provider_from_url(source_url: &str) -> Option<String> {
610    let parsed = Url::parse(source_url).ok()?;
611    let host = parsed.host_str()?.to_ascii_lowercase();
612    if host == "chatgpt.com" || host.ends_with(".chatgpt.com") {
613        return Some("chatgpt".to_string());
614    }
615    if host == "share.google" && parsed.path().starts_with("/aimode/") {
616        return Some("google_ai_mode".to_string());
617    }
618    None
619}
620
621fn parse_unsupported(input: &str, provider: &str) -> UnsupportedDiagnostic {
622    if looks_like_google_ai_mode_interstitial(input) {
623        return UnsupportedDiagnostic {
624            reason: "provider_challenge_interstitial",
625            message: "Google AI Mode capture returned a Google Search JavaScript/interstitial page instead of transcript data.",
626        };
627    }
628    let lower = input.to_ascii_lowercase();
629    if lower.contains("log in")
630        || lower.contains("sign in")
631        || lower.contains("login required")
632        || lower.contains("authentication required")
633    {
634        return UnsupportedDiagnostic {
635            reason: "login_required",
636            message: "The shared dialog page requires login before transcript data is visible.",
637        };
638    }
639    if lower.contains("deleted")
640        || lower.contains("expired")
641        || lower.contains("not found")
642        || lower.contains("unavailable")
643    {
644        return UnsupportedDiagnostic {
645            reason: "deleted_or_expired_share",
646            message: "The shared dialog appears to be deleted, expired, or unavailable.",
647        };
648    }
649    if provider == "unknown" {
650        return UnsupportedDiagnostic {
651            reason: "unsupported_provider_format",
652            message: "The URL or captured DOM is not a supported shared-dialog provider format.",
653        };
654    }
655    UnsupportedDiagnostic {
656        reason: "no_transcript_in_captured_dom",
657        message: "The captured DOM did not contain replayable transcript data.",
658    }
659}
660
661fn looks_like_google_ai_mode_interstitial(input: &str) -> bool {
662    input.contains("share.google/aimode")
663        || (input.contains("Google Search")
664            && input.contains("If you're having trouble accessing Google Search"))
665        || (input.contains("/search?q=") && input.contains("enablejs"))
666        || (input.contains("/httpservice/retry/enablejs") && input.contains("Google Search"))
667}
668
669fn title_from_html(input: &str) -> Option<String> {
670    let start = input.find("<title>")? + "<title>".len();
671    let end = input[start..].find("</title>")? + start;
672    let title = html_escape::decode_html_entities(&input[start..end])
673        .trim()
674        .to_string();
675    let stripped = title
676        .strip_prefix("ChatGPT - ")
677        .unwrap_or(&title)
678        .trim()
679        .to_string();
680    if stripped.is_empty() {
681        None
682    } else {
683        Some(stripped)
684    }
685}
686
687fn string_field(map: &Map<String, Value>, key: &str) -> Option<String> {
688    map.get(key).and_then(Value::as_str).map(ToOwned::to_owned)
689}
690
691fn chatgpt_share_id(source_url: &str) -> Option<String> {
692    let parsed = Url::parse(source_url).ok()?;
693    let parts: Vec<_> = parsed
694        .path()
695        .split('/')
696        .filter(|part| !part.is_empty())
697        .collect();
698    let index = parts.iter().position(|part| *part == "share")?;
699    parts.get(index + 1).map(|value| (*value).to_string())
700}
701
702struct OkCaptureInput {
703    provider: String,
704    source_url: String,
705    capture_method: String,
706    captured_at: Option<String>,
707    http_status: Option<u16>,
708    title: Option<String>,
709    conversation_id: Option<String>,
710    turns: Vec<SharedDialogTurn>,
711    warnings: Vec<String>,
712}
713
714fn ok_capture(input: OkCaptureInput) -> SharedDialogCapture {
715    SharedDialogCapture {
716        provider: input.provider,
717        source_url: input.source_url,
718        capture_method: input.capture_method,
719        captured_at: input.captured_at,
720        status: "ok".to_string(),
721        conversation_id: input.conversation_id,
722        title: input.title,
723        turns: input.turns,
724        diagnostics: SharedDialogDiagnostics {
725            status: "ok".to_string(),
726            http_status: input.http_status,
727            unsupported_reason: None,
728            message: None,
729            warnings: input.warnings,
730            error: None,
731        },
732    }
733}
734
735struct UnsupportedCaptureInput {
736    provider: String,
737    source_url: String,
738    capture_method: String,
739    captured_at: Option<String>,
740    http_status: Option<u16>,
741    unsupported_reason: &'static str,
742    message: String,
743    warnings: Vec<String>,
744    error: Option<String>,
745}
746
747fn unsupported_capture(input: UnsupportedCaptureInput) -> SharedDialogCapture {
748    SharedDialogCapture {
749        provider: input.provider,
750        source_url: input.source_url,
751        capture_method: input.capture_method,
752        captured_at: input.captured_at,
753        status: "unsupported".to_string(),
754        conversation_id: None,
755        title: None,
756        turns: Vec::new(),
757        diagnostics: SharedDialogDiagnostics {
758            status: "unsupported".to_string(),
759            http_status: input.http_status,
760            unsupported_reason: Some(input.unsupported_reason.to_string()),
761            message: Some(input.message),
762            warnings: input.warnings,
763            error: input.error,
764        },
765    }
766}
767
768fn with_warning(mut capture: SharedDialogCapture, warning: String) -> SharedDialogCapture {
769    capture.diagnostics.warnings.push(warning);
770    capture
771}
772
773fn normalize_source_url(url: &str) -> Result<String, String> {
774    let source_url = if url.starts_with("http://") || url.starts_with("https://") {
775        url.to_string()
776    } else {
777        format!("https://{url}")
778    };
779    Url::parse(&source_url)
780        .map_err(|error| format!("Invalid shared-dialog URL \"{url}\": {error}"))?;
781    Ok(source_url)
782}
783
784fn escape_lino(value: &str) -> String {
785    value
786        .replace('\\', "\\\\")
787        .replace('"', "\\\"")
788        .replace('\n', "\\n")
789        .replace('\r', "\\r")
790        .replace('\t', "\\t")
791}
792
793fn push_field(lines: &mut Vec<String>, indent: &str, name: &str, value: Option<&str>) {
794    if let Some(value) = value {
795        if !value.is_empty() {
796            lines.push(format!("{indent}{name} \"{}\"", escape_lino(value)));
797        }
798    }
799}
800
801/// Format a capture as repository-local Links Notation style meta-language.
802#[must_use]
803pub fn format_shared_dialog_as_meta_language(capture: &SharedDialogCapture) -> String {
804    let mut lines = vec!["shared_dialog_capture".to_string()];
805    push_field(&mut lines, "  ", "provider", Some(&capture.provider));
806    push_field(&mut lines, "  ", "sourceUrl", Some(&capture.source_url));
807    push_field(
808        &mut lines,
809        "  ",
810        "captureMethod",
811        Some(&capture.capture_method),
812    );
813    push_field(
814        &mut lines,
815        "  ",
816        "capturedAt",
817        capture.captured_at.as_deref(),
818    );
819    push_field(&mut lines, "  ", "status", Some(&capture.status));
820    push_field(
821        &mut lines,
822        "  ",
823        "conversationId",
824        capture.conversation_id.as_deref(),
825    );
826    push_field(&mut lines, "  ", "title", capture.title.as_deref());
827    lines.push("  diagnostics".to_string());
828    push_field(
829        &mut lines,
830        "    ",
831        "status",
832        Some(&capture.diagnostics.status),
833    );
834    if let Some(status) = capture.diagnostics.http_status {
835        lines.push(format!("    httpStatus \"{status}\""));
836    }
837    push_field(
838        &mut lines,
839        "    ",
840        "unsupportedReason",
841        capture.diagnostics.unsupported_reason.as_deref(),
842    );
843    push_field(
844        &mut lines,
845        "    ",
846        "message",
847        capture.diagnostics.message.as_deref(),
848    );
849    for warning in &capture.diagnostics.warnings {
850        push_field(&mut lines, "    ", "warning", Some(warning));
851    }
852    for turn in &capture.turns {
853        lines.push(format!("  turn \"{}\"", escape_lino(&turn.id)));
854        push_field(&mut lines, "    ", "role", Some(&turn.role));
855        push_field(&mut lines, "    ", "visibility", Some(&turn.visibility));
856        push_field(&mut lines, "    ", "content", Some(&turn.content));
857        for evidence in &turn.source_evidence {
858            lines.push("    evidence".to_string());
859            push_field(&mut lines, "      ", "kind", Some(&evidence.kind));
860            push_field(
861                &mut lines,
862                "      ",
863                "sourceUrl",
864                Some(&evidence.source_url),
865            );
866            push_field(
867                &mut lines,
868                "      ",
869                "captureMethod",
870                Some(&evidence.capture_method),
871            );
872            push_field(&mut lines, "      ", "pointer", Some(&evidence.pointer));
873        }
874    }
875    format!("{}\n", lines.join("\n"))
876}
877
878/// Format a capture as formal-ai compatible `demo_memory` Links Notation.
879#[must_use]
880pub fn format_shared_dialog_as_demo_memory(
881    capture: &SharedDialogCapture,
882    demo_label: Option<&str>,
883) -> String {
884    let mut lines = vec!["demo_memory".to_string()];
885    if capture.status != "ok" {
886        lines.push("  diagnostic \"shared-dialog-unsupported\"".to_string());
887        push_field(&mut lines, "    ", "provider", Some(&capture.provider));
888        push_field(&mut lines, "    ", "sourceUrl", Some(&capture.source_url));
889        push_field(
890            &mut lines,
891            "    ",
892            "captureMethod",
893            Some(&capture.capture_method),
894        );
895        push_field(
896            &mut lines,
897            "    ",
898            "unsupportedReason",
899            capture.diagnostics.unsupported_reason.as_deref(),
900        );
901        push_field(
902            &mut lines,
903            "    ",
904            "message",
905            capture.diagnostics.message.as_deref(),
906        );
907        return format!("{}\n", lines.join("\n"));
908    }
909
910    let label = demo_label.unwrap_or("web-capture-shared-dialog");
911    for turn in &capture.turns {
912        lines.push(format!("  event \"{}\"", escape_lino(&turn.id)));
913        push_field(&mut lines, "    ", "role", Some(&turn.role));
914        push_field(&mut lines, "    ", "content", Some(&turn.content));
915        push_field(&mut lines, "    ", "demoLabel", Some(label));
916        push_field(
917            &mut lines,
918            "    ",
919            "conversationId",
920            capture.conversation_id.as_deref(),
921        );
922        push_field(
923            &mut lines,
924            "    ",
925            "conversationTitle",
926            capture.title.as_deref(),
927        );
928        for evidence in &turn.source_evidence {
929            push_field(&mut lines, "    ", "evidence", Some(&evidence.source_url));
930        }
931    }
932    format!("{}\n", lines.join("\n"))
933}
934
935/// Format a capture as Markdown.
936#[must_use]
937pub fn format_shared_dialog_as_markdown(capture: &SharedDialogCapture) -> String {
938    let mut lines = Vec::new();
939    lines.push(format!(
940        "# {}",
941        capture.title.as_deref().unwrap_or("Shared Dialog Capture")
942    ));
943    lines.push(String::new());
944    lines.push(format!("- Provider: `{}`", capture.provider));
945    lines.push(format!("- Source: {}", capture.source_url));
946    lines.push(format!("- Capture method: `{}`", capture.capture_method));
947    lines.push(format!("- Status: `{}`", capture.status));
948    if let Some(conversation_id) = capture.conversation_id.as_ref() {
949        lines.push(format!("- Conversation ID: `{conversation_id}`"));
950    }
951    if capture.status != "ok" {
952        if let Some(reason) = capture.diagnostics.unsupported_reason.as_ref() {
953            lines.push(format!("- Unsupported reason: `{reason}`"));
954        }
955        if let Some(message) = capture.diagnostics.message.as_ref() {
956            lines.push(String::new());
957            lines.push(message.clone());
958        }
959        return format!("{}\n", lines.join("\n"));
960    }
961    lines.push(String::new());
962    for turn in &capture.turns {
963        let label = if turn.role == "user" {
964            "User"
965        } else {
966            "Assistant"
967        };
968        lines.push(format!("**{label}**"));
969        lines.push(String::new());
970        lines.push(turn.content.clone());
971        lines.push(String::new());
972    }
973    format!("{}\n", lines.join("\n").trim_end())
974}
975
976/// Format a capture as plain text.
977#[must_use]
978pub fn format_shared_dialog_as_text(capture: &SharedDialogCapture) -> String {
979    if capture.status != "ok" {
980        return format!(
981            "Provider: {}\nSource: {}\nCapture method: {}\nStatus: {}\nUnsupported reason: {}\n{}\n",
982            capture.provider,
983            capture.source_url,
984            capture.capture_method,
985            capture.status,
986            capture
987                .diagnostics
988                .unsupported_reason
989                .as_deref()
990                .unwrap_or_default(),
991            capture.diagnostics.message.as_deref().unwrap_or_default()
992        );
993    }
994    let mut lines = Vec::new();
995    for turn in &capture.turns {
996        let label = if turn.role == "user" {
997            "User"
998        } else {
999            "Assistant"
1000        };
1001        lines.push(format!("{label}:"));
1002        lines.push(turn.content.clone());
1003        lines.push(String::new());
1004    }
1005    lines.join("\n")
1006}
1007
1008/// Format a capture as simple HTML containing the Markdown representation.
1009#[must_use]
1010pub fn format_shared_dialog_as_html(capture: &SharedDialogCapture) -> String {
1011    let title =
1012        html_escape::encode_text(capture.title.as_deref().unwrap_or("Shared Dialog Capture"));
1013    let markdown = format_shared_dialog_as_markdown(capture);
1014    let body = html_escape::encode_text(&markdown);
1015    format!(
1016        "<!doctype html>\n<html>\n<head><meta charset=\"utf-8\"><title>{title}</title></head>\n<body><pre>{body}</pre></body>\n</html>\n"
1017    )
1018}
1019
1020/// Format a capture according to a CLI/API format name.
1021///
1022/// # Errors
1023///
1024/// Returns a serialization error for JSON output if serde cannot serialize the
1025/// capture.
1026pub fn format_shared_dialog_result(
1027    capture: &SharedDialogCapture,
1028    format: &str,
1029) -> Result<String, serde_json::Error> {
1030    Ok(match format.to_ascii_lowercase().as_str() {
1031        "meta-language" | "meta_language" => format_shared_dialog_as_meta_language(capture),
1032        "demo-memory" | "demo_memory" => format_shared_dialog_as_demo_memory(capture, None),
1033        "markdown" | "md" => format_shared_dialog_as_markdown(capture),
1034        "txt" | "text" => format_shared_dialog_as_text(capture),
1035        "html" => format_shared_dialog_as_html(capture),
1036        _ => format!("{}\n", serde_json::to_string_pretty(capture)?),
1037    })
1038}
1039
1040/// Return the conventional extension for a shared-dialog output format.
1041#[must_use]
1042pub fn shared_dialog_output_extension(format: &str) -> &'static str {
1043    match format.to_ascii_lowercase().as_str() {
1044        "meta-language" | "meta_language" | "demo-memory" | "demo_memory" => "lino",
1045        "markdown" | "md" => "md",
1046        "txt" | "text" => "txt",
1047        "html" => "html",
1048        _ => "json",
1049    }
1050}