1use std::collections::BTreeMap;
21use std::fmt::Write as _;
22
23use crate::chat_session::{ChatMessage, ChatSession, Role, SessionKind, SessionMeta};
24
25const NS_QC: &str = "https://ns.webcivics.net/qualia-chat/";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct SolidChatExport {
30 pub index_ttl: String,
32 pub day_files: BTreeMap<String, String>,
34}
35
36impl SolidChatExport {
37 pub fn files(&self) -> Vec<(String, String)> {
40 let mut out = vec![("index.ttl".to_string(), self.index_ttl.clone())];
41 for (day, body) in &self.day_files {
42 out.push((format!("{day}/chat.ttl"), body.clone()));
43 }
44 out
45 }
46}
47
48fn iso_utc(unix_secs: u64) -> String {
49 chrono::DateTime::from_timestamp(unix_secs as i64, 0)
50 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
51 .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
52}
53
54fn day_path(unix_secs: u64) -> String {
55 chrono::DateTime::from_timestamp(unix_secs as i64, 0)
56 .map(|dt| dt.format("%Y/%m/%d").to_string())
57 .unwrap_or_else(|| "1970/01/01".to_string())
58}
59
60fn parse_iso(s: &str) -> u64 {
61 chrono::DateTime::parse_from_rfc3339(s)
62 .map(|dt| dt.timestamp().max(0) as u64)
63 .unwrap_or(0)
64}
65
66fn esc(s: &str) -> String {
68 let mut out = String::with_capacity(s.len() + 2);
69 for c in s.chars() {
70 match c {
71 '\\' => out.push_str("\\\\"),
72 '"' => out.push_str("\\\""),
73 '\n' => out.push_str("\\n"),
74 '\r' => out.push_str("\\r"),
75 '\t' => out.push_str("\\t"),
76 _ => out.push(c),
77 }
78 }
79 out
80}
81
82fn unesc(s: &str) -> String {
83 let mut out = String::with_capacity(s.len());
84 let mut chars = s.chars();
85 while let Some(c) = chars.next() {
86 if c == '\\' {
87 match chars.next() {
88 Some('n') => out.push('\n'),
89 Some('r') => out.push('\r'),
90 Some('t') => out.push('\t'),
91 Some('"') => out.push('"'),
92 Some('\\') => out.push('\\'),
93 Some(other) => out.push(other),
94 None => {}
95 }
96 } else {
97 out.push(c);
98 }
99 }
100 out
101}
102
103fn maker_uri(msg: &ChatMessage, owner_did: &str) -> String {
104 match &msg.author_did {
105 Some(d) if !d.is_empty() => d.clone(),
106 _ => owner_did.to_string(),
107 }
108}
109
110fn message_id(msg: &ChatMessage) -> String {
111 format!("msg-{}", msg.lamport)
112}
113
114pub fn export_session(session: &ChatSession) -> SolidChatExport {
116 export_parts(&session.meta, &session.messages)
117}
118
119pub fn export_parts(meta: &SessionMeta, messages: &[ChatMessage]) -> SolidChatExport {
121 let owner = &meta.owner_did;
122 let kind = match meta.session_kind {
123 SessionKind::Solo => "solo",
124 SessionKind::Group => "group",
125 };
126
127 let mut index = String::new();
129 let _ = writeln!(
130 index,
131 "@prefix meeting: <http://www.w3.org/ns/pim/meeting#> ."
132 );
133 let _ = writeln!(index, "@prefix dct: <http://purl.org/dc/terms/> .");
134 let _ = writeln!(index, "@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .");
135 let _ = writeln!(index, "@prefix qc: <{NS_QC}> .");
136 let _ = writeln!(index);
137 let _ = writeln!(index, "<#this> a meeting:LongChat ;");
138 let _ = writeln!(index, " dct:title \"{}\" ;", esc(&meta.title));
139 if !owner.is_empty() {
140 let _ = writeln!(index, " dct:author <{owner}> ;");
141 }
142 let _ = writeln!(
143 index,
144 " dct:created \"{}\"^^xsd:dateTime ;",
145 iso_utc(meta.created_at)
146 );
147 if !meta.session_did.is_empty() {
148 let _ = writeln!(index, " qc:sessionDid \"{}\" ;", esc(&meta.session_did));
149 }
150 let _ = write!(index, " qc:sessionKind \"{kind}\"");
151 if messages.is_empty() {
153 let _ = writeln!(index, " .");
154 } else {
155 let _ = writeln!(index, " ;");
156 let n = messages.len();
157 for (i, msg) in messages.iter().enumerate() {
158 let uri = format!("{}/chat.ttl#{}", day_path(msg.timestamp), message_id(msg));
159 let sep = if i + 1 == n { " ." } else { " ," };
160 let lead = if i == 0 {
161 " meeting:message "
162 } else {
163 " "
164 };
165 let _ = writeln!(index, "{lead}<{uri}>{sep}");
166 }
167 }
168
169 let mut day_files: BTreeMap<String, String> = BTreeMap::new();
171 for msg in messages {
172 let day = day_path(msg.timestamp);
173 let body = day_files.entry(day).or_insert_with(day_header);
174 write_message(body, msg, owner);
175 }
176
177 SolidChatExport {
178 index_ttl: index,
179 day_files,
180 }
181}
182
183fn day_header() -> String {
184 let mut h = String::new();
185 let _ = writeln!(h, "@prefix sioc: <http://rdfs.org/sioc/ns#> .");
186 let _ = writeln!(h, "@prefix dct: <http://purl.org/dc/terms/> .");
187 let _ = writeln!(h, "@prefix foaf: <http://xmlns.com/foaf/0.1/> .");
188 let _ = writeln!(h, "@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .");
189 let _ = writeln!(h, "@prefix qc: <{NS_QC}> .");
190 let _ = writeln!(h);
191 h
192}
193
194fn write_message(out: &mut String, msg: &ChatMessage, owner_did: &str) {
195 let id = message_id(msg);
196 let _ = writeln!(
198 out,
199 "<#{id}> dct:created \"{}\"^^xsd:dateTime ;",
200 iso_utc(msg.timestamp)
201 );
202 let _ = writeln!(out, " sioc:content \"{}\" ;", esc(&msg.content));
203 let _ = writeln!(out, " foaf:maker <{}> ;", maker_uri(msg, owner_did));
204 let _ = writeln!(out, " qc:lamport {} ;", msg.lamport);
206 let _ = writeln!(out, " qc:role \"{}\" ;", msg.role.as_str());
207 let _ = write!(out, " qc:contentHash \"{:016x}\"", msg.content_hash);
208 let opt = |out: &mut String, pred: &str, v: &Option<String>, is_uri: bool| {
209 if let Some(x) = v {
210 if !x.is_empty() {
211 if is_uri {
212 let _ = write!(out, " ;\n {pred} <{x}>");
213 } else {
214 let _ = write!(out, " ;\n {pred} \"{}\"", esc(x));
215 }
216 }
217 }
218 };
219 opt(out, "qc:authorName", &msg.author_name, false);
220 opt(out, "qc:replyToFragment", &msg.reply_to_fragment, false);
221 opt(out, "qc:source", &msg.source, false);
222 opt(out, "qc:subAgentOf", &msg.sub_agent_of, true);
223 opt(out, "qc:agentDid", &msg.agent_did, true);
224 opt(out, "qc:modelId", &msg.model_id, false);
225 opt(out, "qc:agentBackend", &msg.agent_backend, false);
226 let _ = writeln!(out, " .");
227 let _ = writeln!(out);
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Default)]
233pub struct ImportedMessage {
234 pub id: String,
235 pub content: String,
236 pub maker: String,
237 pub created_unix: u64,
238 pub lamport: Option<u64>,
239 pub role: Option<String>,
240 pub content_hash: Option<u64>,
241 pub author_name: Option<String>,
242 pub reply_to_fragment: Option<String>,
243 pub source: Option<String>,
244 pub sub_agent_of: Option<String>,
245 pub agent_did: Option<String>,
246 pub model_id: Option<String>,
247 pub agent_backend: Option<String>,
248}
249
250impl ImportedMessage {
251 pub fn to_chat_message(&self, fallback_lamport: u64) -> ChatMessage {
254 let content_hash = self
255 .content_hash
256 .unwrap_or_else(|| crate::chat_session::content_hash_u64(&self.content));
257 ChatMessage {
258 lamport: self.lamport.unwrap_or(fallback_lamport),
259 role: self
260 .role
261 .as_deref()
262 .and_then(|r| Role::from_str(r).ok())
263 .unwrap_or(Role::User),
264 content: self.content.clone(),
265 timestamp: self.created_unix,
266 content_hash,
267 author_did: (!self.maker.is_empty()).then(|| self.maker.clone()),
268 author_name: self.author_name.clone(),
269 reply_to_fragment: self.reply_to_fragment.clone(),
270 source: self.source.clone().or_else(|| Some("solid".to_string())),
271 sub_agent_of: self.sub_agent_of.clone(),
272 agent_did: self.agent_did.clone(),
273 model_id: self.model_id.clone(),
274 agent_backend: self.agent_backend.clone(),
275 outcome_sharing: None,
276 }
277 }
278}
279
280pub fn parse_day_ttl(ttl: &str) -> Vec<ImportedMessage> {
283 let mut msgs = Vec::new();
284 let mut stmt = String::new();
286 for raw in ttl.lines() {
287 let line = raw.trim();
288 if line.is_empty() || line.starts_with("@prefix") || line.starts_with('#') {
289 continue;
290 }
291 if !stmt.is_empty() {
292 stmt.push(' ');
293 }
294 stmt.push_str(line);
295 if line.ends_with('.') && !line.ends_with("\\.") {
296 if let Some(m) = parse_statement(stmt.trim_end_matches('.').trim()) {
297 msgs.push(m);
298 }
299 stmt.clear();
300 }
301 }
302 msgs
303}
304
305fn parse_statement(stmt: &str) -> Option<ImportedMessage> {
306 let (subject, rest) = split_first_token(stmt);
308 if !subject.starts_with("<#") {
309 return None;
310 }
311 let mut m = ImportedMessage {
312 id: subject
313 .trim_start_matches("<#")
314 .trim_end_matches('>')
315 .to_string(),
316 ..Default::default()
317 };
318 for clause in split_top_level_semicolons(rest) {
319 let clause = clause.trim();
320 if clause.is_empty() {
321 continue;
322 }
323 let (pred, obj) = split_first_token(clause);
324 let obj = obj.trim();
325 match pred {
326 "a" => {}
327 "dct:created" => m.created_unix = parse_iso(&literal(obj)),
328 "sioc:content" => m.content = literal(obj),
329 "foaf:maker" => m.maker = uri(obj),
330 "qc:lamport" => m.lamport = obj.trim().parse().ok(),
331 "qc:role" => m.role = Some(literal(obj)),
332 "qc:contentHash" => m.content_hash = u64::from_str_radix(&literal(obj), 16).ok(),
333 "qc:authorName" => m.author_name = Some(literal(obj)),
334 "qc:replyToFragment" => m.reply_to_fragment = Some(literal(obj)),
335 "qc:source" => m.source = Some(literal(obj)),
336 "qc:subAgentOf" => m.sub_agent_of = Some(uri(obj)),
337 "qc:agentDid" => m.agent_did = Some(uri(obj)),
338 "qc:modelId" => m.model_id = Some(literal(obj)),
339 "qc:agentBackend" => m.agent_backend = Some(literal(obj)),
340 _ => {}
341 }
342 }
343 (!m.id.is_empty()).then_some(m)
344}
345
346fn split_first_token(s: &str) -> (&str, &str) {
347 let s = s.trim_start();
348 match s.find(char::is_whitespace) {
349 Some(i) => (&s[..i], s[i..].trim_start()),
350 None => (s, ""),
351 }
352}
353
354fn split_top_level_semicolons(s: &str) -> Vec<String> {
356 let mut out = Vec::new();
357 let mut cur = String::new();
358 let mut in_str = false;
359 let mut escaped = false;
360 for c in s.chars() {
361 match c {
362 '\\' if in_str => {
363 escaped = !escaped;
364 cur.push(c);
365 }
366 '"' if !escaped => {
367 in_str = !in_str;
368 cur.push(c);
369 }
370 ';' if !in_str => {
371 out.push(std::mem::take(&mut cur));
372 }
373 _ => {
374 escaped = false;
375 cur.push(c);
376 }
377 }
378 }
379 if !cur.trim().is_empty() {
380 out.push(cur);
381 }
382 out
383}
384
385fn literal(obj: &str) -> String {
387 let obj = obj.trim();
388 if let Some(rest) = obj.strip_prefix('"') {
389 let mut end = None;
391 let mut escaped = false;
392 for (i, c) in rest.char_indices() {
393 if c == '\\' && !escaped {
394 escaped = true;
395 } else if c == '"' && !escaped {
396 end = Some(i);
397 break;
398 } else {
399 escaped = false;
400 }
401 }
402 if let Some(i) = end {
403 return unesc(&rest[..i]);
404 }
405 }
406 obj.to_string()
407}
408
409fn uri(obj: &str) -> String {
410 obj.trim()
411 .trim_start_matches('<')
412 .trim_end_matches('>')
413 .to_string()
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::chat_session::{ChatMessage, Role, SessionKind, SessionMeta};
420
421 fn msg(lamport: u64, role: Role, content: &str, ts: u64) -> ChatMessage {
422 ChatMessage {
423 lamport,
424 role,
425 content: content.into(),
426 timestamp: ts,
427 content_hash: crate::chat_session::content_hash_u64(content),
428 author_did: Some("did:wf:alice".into()),
429 author_name: Some("Alice".into()),
430 reply_to_fragment: None,
431 source: None,
432 sub_agent_of: None,
433 agent_did: None,
434 model_id: None,
435 agent_backend: None,
436 outcome_sharing: None,
437 }
438 }
439
440 fn meta() -> SessionMeta {
441 SessionMeta {
442 id: "s1".into(),
443 title: "Care chat".into(),
444 created_at: 1_700_000_000,
445 updated_at: 1_700_000_100,
446 message_count: 0,
447 next_lamport: 99,
448 environment_ref: String::new(),
449 session_kind: SessionKind::Group,
450 participants: vec![],
451 owner_did: "did:wf:owner".into(),
452 session_did: "did:qualia:chat:group:abcd".into(),
453 }
454 }
455
456 #[test]
457 fn index_declares_a_long_chat() {
458 let export = export_parts(&meta(), &[msg(1, Role::User, "hi", 1_700_000_050)]);
459 assert!(export.index_ttl.contains("a meeting:LongChat"));
460 assert!(export.index_ttl.contains("dct:title \"Care chat\""));
461 assert!(export.index_ttl.contains("dct:author <did:wf:owner>"));
462 assert!(export.index_ttl.contains("meeting:message"));
463 assert!(
465 export.index_ttl.contains("2023/11/14/chat.ttl#msg-1")
466 || export.index_ttl.contains("/chat.ttl#msg-1")
467 );
468 }
469
470 #[test]
471 fn message_carries_solid_subset_and_native_fidelity() {
472 let mut m = msg(7, Role::Agent, "grounded answer", 1_700_000_050);
473 m.agent_did = Some("did:wf:agent7".into());
474 m.model_id = Some("qwen2-1_5b".into());
475 m.sub_agent_of = Some("did:wf:owner".into());
476 let export = export_parts(&meta(), &[m]);
477 let day = export.day_files.values().next().unwrap();
478 assert!(day.contains("sioc:content \"grounded answer\""));
480 assert!(day.contains("foaf:maker <did:wf:alice>"));
481 assert!(day.contains("dct:created"));
482 assert!(day.contains("qc:lamport 7"));
484 assert!(day.contains("qc:role \"agent\""));
485 assert!(day.contains("qc:agentDid <did:wf:agent7>"));
486 assert!(day.contains("qc:modelId \"qwen2-1_5b\""));
487 assert!(day.contains("qc:subAgentOf <did:wf:owner>"));
488 }
489
490 #[test]
491 fn roundtrip_is_lossless() {
492 let mut agent = msg(2, Role::Agent, "line one\nline \"two\"", 1_700_000_060);
493 agent.agent_did = Some("did:wf:agent".into());
494 agent.model_id = Some("m1".into());
495 agent.reply_to_fragment = Some("frag-1".into());
496 agent.author_did = Some("did:wf:agent".into());
497 let originals = vec![msg(1, Role::User, "hello", 1_700_000_050), agent];
498 let export = export_parts(&meta(), &originals);
499
500 let mut parsed: Vec<ImportedMessage> = export
502 .day_files
503 .values()
504 .flat_map(|b| parse_day_ttl(b))
505 .collect();
506 parsed.sort_by_key(|m| m.lamport.unwrap_or(0));
507 assert_eq!(parsed.len(), 2);
508
509 for (orig, back) in originals.iter().zip(parsed.iter()) {
510 let rebuilt = back.to_chat_message(0);
511 assert_eq!(
512 rebuilt.content, orig.content,
513 "content (incl. newlines/quotes) preserved"
514 );
515 assert_eq!(rebuilt.lamport, orig.lamport);
516 assert_eq!(rebuilt.role, orig.role);
517 assert_eq!(rebuilt.content_hash, orig.content_hash);
518 assert_eq!(rebuilt.author_did, orig.author_did);
519 assert_eq!(rebuilt.reply_to_fragment, orig.reply_to_fragment);
520 assert_eq!(rebuilt.agent_did, orig.agent_did);
521 assert_eq!(rebuilt.model_id, orig.model_id);
522 }
523 }
524
525 #[test]
526 fn pure_solid_message_without_qc_still_imports() {
527 let ttl = r#"@prefix sioc: <http://rdfs.org/sioc/ns#> .
529@prefix dct: <http://purl.org/dc/terms/> .
530@prefix foaf: <http://xmlns.com/foaf/0.1/> .
531@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
532
533<#msgX> dct:created "2023-11-14T20:54:10Z"^^xsd:dateTime ;
534 sioc:content "hi from solid" ;
535 foaf:maker <https://bob.example/profile/card#me> .
536"#;
537 let parsed = parse_day_ttl(ttl);
538 assert_eq!(parsed.len(), 1);
539 let m = parsed[0].to_chat_message(42);
540 assert_eq!(m.content, "hi from solid");
541 assert_eq!(
542 m.author_did.as_deref(),
543 Some("https://bob.example/profile/card#me")
544 );
545 assert_eq!(m.lamport, 42, "no qc:lamport → caller's fallback");
546 assert_eq!(m.role, Role::User, "default role");
547 assert_eq!(m.source.as_deref(), Some("solid"));
548 }
549}