1use 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#[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
145pub 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 ¤t_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 ¤t_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#[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#[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#[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#[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#[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
1020pub 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#[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}