1use super::*;
4
5impl WebizenHostApi {
6 fn library(&self) -> Result<super::super::hypermedia_store::HypermediaStore, String> {
9 super::super::hypermedia_store::HypermediaStore::open(&self.storage_root)
10 .map_err(|e| e.to_string())
11 }
12
13 pub fn ingest_document(
18 &self,
19 uri: &str,
20 media_type: &str,
21 text: &str,
22 guardian_did: Option<String>,
23 ) -> Result<serde_json::Value, String> {
24 self.ingest_bytes(
25 uri,
26 media_type,
27 text.as_bytes(),
28 text,
29 &ManualFacets::default(),
30 guardian_did,
31 )
32 }
33
34 pub fn ingest_document_annotated(
38 &self,
39 uri: &str,
40 media_type: &str,
41 text: &str,
42 manual: &ManualFacets,
43 guardian_did: Option<String>,
44 ) -> Result<serde_json::Value, String> {
45 self.ingest_bytes(uri, media_type, text.as_bytes(), text, manual, guardian_did)
46 }
47
48 pub fn ingest_bytes(
55 &self,
56 uri: &str,
57 media_type: &str,
58 bytes: &[u8],
59 excerpt_source: &str,
60 manual: &ManualFacets,
61 guardian_did: Option<String>,
62 ) -> Result<serde_json::Value, String> {
63 use qualia_core_db::hypermedia::processors::processor_for;
64 use qualia_core_db::hypermedia::{
65 content_digest, descriptors_to_nquins, ingest_with, Descriptors, FlagSeverity, Place,
66 };
67
68 let proc = processor_for(media_type)
69 .ok_or_else(|| format!("no ingest processor for media type '{media_type}'"))?;
70 let digest = content_digest(bytes);
71 let out = proc.process(uri, bytes, media_type);
72 let mut r = ingest_with(proc.as_ref(), uri, media_type, digest, bytes);
73 let now = Self::now_unix();
74 let primary_subject = r.container.primary.subject();
75
76 let manual_place = match (manual.lat, manual.lon) {
79 (Some(lat), Some(lon)) => Some(Place {
80 label: manual
81 .place_label
82 .clone()
83 .unwrap_or_else(|| format!("{lat:.5},{lon:.5}")),
84 lat,
85 lon,
86 }),
87 _ => None,
88 };
89 if !manual.is_empty() {
90 let extra = Descriptors {
91 occurred_at: manual
92 .occurred_at
93 .filter(|_| out.descriptors.occurred_at.is_none()),
94 place: if out.descriptors.place.is_none() {
95 manual_place.clone()
96 } else {
97 None
98 },
99 projects: manual.projects.clone(),
100 purposes: manual.purposes.clone(),
101 ..Default::default()
102 };
103 let (eq, _lex) = descriptors_to_nquins(primary_subject, &extra);
104 r.quins.extend(eq);
105 }
106
107 let flags: Vec<super::super::hypermedia_store::LibraryFlag> = out
108 .flags
109 .iter()
110 .map(|f| super::super::hypermedia_store::LibraryFlag {
111 kind: f.kind.clone(),
112 severity_level: f.severity.level(),
113 detail: f.detail.clone(),
114 })
115 .collect();
116
117 let eff_occurred_at = out.descriptors.occurred_at.or(manual.occurred_at);
119 let eff_place = out.descriptors.place.clone().or(manual_place);
120 let (lat, lon) = eff_place
121 .as_ref()
122 .map(|p| (Some(p.lat), Some(p.lon)))
123 .unwrap_or((None, None));
124 let mut projects = out.descriptors.projects.clone();
125 projects.extend(manual.projects.iter().cloned());
126
127 let purposes = manual.purposes.clone();
128 let sensitivity = super::super::hypermedia_store::normalize_sensitivity(
129 manual.sensitivity.as_deref().unwrap_or("public"),
130 );
131 let commons = super::super::hypermedia_store::CommonsVisibility::parse(
132 manual.commons_visibility.as_deref().unwrap_or("none"),
133 );
134 let mut cml_topics = Vec::new();
136 let mut cml_purposes = purposes.clone();
137 let mut cml_signals = Vec::new();
138 let mut cml_concept_count = 0u32;
139 let mut cml_n3 = String::new();
140 let mut cml_quins = Vec::new();
141 if media_type.starts_with("text/")
142 || media_type.contains("json")
143 || media_type.contains("markdown")
144 {
145 let text = String::from_utf8_lossy(bytes);
146 let units = super::super::cml_context::units_from_headings(&text);
147 let g = super::super::cml_context::build_document_context(uri, excerpt_source, &units);
148 cml_topics = g.topics.clone();
149 for p in &g.purposes {
150 if !cml_purposes.iter().any(|x| x == p) {
151 cml_purposes.push(p.clone());
152 }
153 }
154 cml_signals = g.signal_tags.clone();
155 cml_concept_count = g.concepts.len() as u32;
156 cml_n3 = if g.n3.len() > 48_000 {
157 format!("{}…\n# [cml_n3 truncated]", &g.n3[..48_000])
158 } else {
159 g.n3
160 };
161 cml_quins = g.quins;
162 }
163
164 let mut topics = out.descriptors.topics.clone();
165 for t in cml_topics {
166 if !topics.iter().any(|x| x == &t) {
167 topics.push(t);
168 }
169 }
170
171 let mut all_quins = r.quins;
172 all_quins.extend(cml_quins);
173
174 let mut entry = super::super::hypermedia_store::LibraryEntry {
175 asset_uri: uri.to_string(),
176 primary_subject,
177 media_type: media_type.to_string(),
178 quins: all_quins,
179 topics,
180 projects,
181 purposes: cml_purposes,
182 place: eff_place.as_ref().map(|p| p.label.clone()),
183 occurred_at: eff_occurred_at,
184 lat,
185 lon,
186 flags: flags.clone(),
187 ingested_unix: now,
188 excerpt: excerpt_source.chars().take(160).collect(),
189 sensitivity: sensitivity.clone(),
190 section: manual.section.clone().unwrap_or_else(|| "personal".into()),
191 commons_visibility: commons,
192 cml_signals,
193 cml_concept_count,
194 cml_n3,
195 cof_html: String::new(),
196 cof_segment_count: 0,
197 cof_segment_index: 0,
198 cof_profile: String::new(),
199 };
200
201 let mut cof_segment_count = 0u32;
203 let mut cof_profile = String::new();
204 let mut cof_body_segments: Vec<super::super::cml_context::CofSegment> = Vec::new();
205 if media_type.starts_with("text/") {
206 let text = String::from_utf8_lossy(bytes);
207 let units = super::super::cml_context::units_from_headings(&text);
208 let pkg = super::super::cml_context::build_cof_package(
209 uri,
210 excerpt_source,
211 &units,
212 super::super::cml_context::DEFAULT_SEGMENT_MAX_CHARS,
213 super::super::cml_context::CofStyle::AgentLean,
214 );
215 cof_segment_count = pkg.segments.len() as u32;
216 cof_profile = pkg.profile.clone();
217 entry.cof_segment_count = cof_segment_count;
218 entry.cof_profile = cof_profile.clone();
219 if let Some(index_seg) = pkg.segments.iter().find(|s| s.is_index) {
220 entry.cof_html = index_seg.html.clone();
221 entry.cof_segment_index = 0;
222 } else if let Some(first) = pkg.segments.first() {
223 entry.cof_html = first.html.clone();
224 entry.cof_segment_index = first.index;
225 }
226 cof_body_segments = pkg.segments.into_iter().filter(|s| !s.is_index).collect();
227 }
228
229 entry.recompute_section();
230 if entry.is_secret() {
232 entry.commons_visibility = super::super::hypermedia_store::CommonsVisibility::None;
233 }
234 let section = entry.section.clone();
235 let commons_visibility = entry.commons_visibility;
236 let entry_topics = entry.topics.clone();
237 let entry_purposes = entry.purposes.clone();
238 let store = self.library()?;
239 store.add(entry).map_err(|e| e.to_string())?;
240
241 for seg in &cof_body_segments {
243 let seg_uri = format!("{uri}#cof-seg-{}", seg.index);
244 let mut se = super::super::hypermedia_store::LibraryEntry {
245 asset_uri: seg_uri.clone(),
246 primary_subject: qualia_core_db::hypermedia::fnv60(seg_uri.as_bytes()),
247 media_type: super::super::cml_context::MEDIA_TYPE_COF.into(),
248 quins: Vec::new(),
249 topics: entry_topics.clone(),
250 projects: Vec::new(),
251 purposes: entry_purposes.clone(),
252 place: None,
253 occurred_at: None,
254 lat: None,
255 lon: None,
256 flags: Vec::new(),
257 ingested_unix: now,
258 excerpt: format!(
259 "COF segment {}/{} · ~{} tokens · units: {}",
260 seg.index + 1,
261 seg.total,
262 seg.approx_tokens,
263 seg.unit_frags.join(", ")
264 ),
265 sensitivity: sensitivity.clone(),
266 section: section.clone(),
267 commons_visibility,
268 cml_signals: Vec::new(),
269 cml_concept_count: seg.unit_frags.len() as u32,
270 cml_n3: String::new(),
271 cof_html: seg.html.clone(),
272 cof_segment_count,
273 cof_segment_index: seg.index,
274 cof_profile: cof_profile.clone(),
275 };
276 se.recompute_section();
277 store.add(se).map_err(|e| e.to_string())?;
278 }
279
280 let mut notified = Vec::new();
282 if let Some(g) = &guardian_did {
283 if !out.flags.is_empty() {
284 let ns = super::super::ingest_guardian::guardian_notifications(
285 &out.flags,
286 uri,
287 g,
288 &self.owner_did,
289 FlagSeverity::Notice,
290 now,
291 );
292 self.record_guardian_notifications(&ns)?;
293 notified = ns;
294 }
295 }
296 Ok(serde_json::json!({
297 "asset_uri": uri,
298 "topics": entry_topics,
299 "occurred_at": eff_occurred_at,
300 "place": eff_place.as_ref().map(|p| &p.label),
301 "lat": lat,
302 "lon": lon,
303 "flags": flags,
304 "guardian_notifications": notified,
305 "section": section,
306 "sensitivity": sensitivity,
307 "commons_visibility": commons_visibility,
308 "purposes": entry_purposes,
309 "cml_concept_count": cml_concept_count,
310 "cof_segment_count": cof_segment_count,
311 "cof_profile": cof_profile,
312 }))
313 }
314
315 pub fn ingest_file_hex(
320 &self,
321 uri: &str,
322 media_type: &str,
323 bytes_hex: &str,
324 caption: &str,
325 guardian_did: Option<String>,
326 ) -> Result<serde_json::Value, String> {
327 let bytes = decode_hex(bytes_hex).map_err(|e| format!("bad hex: {e}"))?;
328 self.ingest_bytes(
329 uri,
330 media_type,
331 &bytes,
332 caption,
333 &ManualFacets::default(),
334 guardian_did,
335 )
336 }
337
338 pub fn search_library(
341 &self,
342 facet: &str,
343 value: &str,
344 ) -> Result<Vec<serde_json::Value>, String> {
345 let entries = self
346 .library()?
347 .search(facet, value)
348 .map_err(|e| e.to_string())?;
349 Ok(entries.iter().map(library_summary).collect())
350 }
351
352 pub fn search_library_time(
354 &self,
355 start: i64,
356 end: i64,
357 ) -> Result<Vec<serde_json::Value>, String> {
358 let entries = self
359 .library()?
360 .search_time_range(start, end)
361 .map_err(|e| e.to_string())?;
362 Ok(entries.iter().map(library_summary).collect())
363 }
364
365 pub fn list_library(&self) -> Result<Vec<serde_json::Value>, String> {
368 self.list_library_section(None)
369 }
370
371 pub fn list_library_section(
372 &self,
373 section: Option<&str>,
374 ) -> Result<Vec<serde_json::Value>, String> {
375 let store = self.library()?;
376 let entries = match section {
377 Some(s) if !s.is_empty() && s != "all" => store
378 .by_section(super::super::hypermedia_store::LibrarySection::parse(s))
379 .map_err(|e| e.to_string())?,
380 _ => store.all().map_err(|e| e.to_string())?,
381 };
382 Ok(entries.iter().map(library_summary).collect())
383 }
384
385 pub fn search_library_text(&self, query: &str) -> Result<Vec<serde_json::Value>, String> {
387 let entries = self
388 .library()?
389 .search_text(query)
390 .map_err(|e| e.to_string())?;
391 Ok(entries.iter().map(library_summary).collect())
392 }
393
394 pub fn query_library_faceted(
397 &self,
398 filter_json: &str,
399 sort: &str,
400 ) -> Result<serde_json::Value, String> {
401 let filter: super::super::hypermedia_store::FacetFilter = if filter_json.trim().is_empty() {
402 Default::default()
403 } else {
404 serde_json::from_str(filter_json).map_err(|e| format!("facet filter json: {e}"))?
405 };
406 let sort = super::super::hypermedia_store::LibrarySort::parse(sort);
407 let store = self.library()?;
408 let entries = store
409 .query_faceted(&filter, sort)
410 .map_err(|e| e.to_string())?;
411 let counts = store.facet_counts(&filter).map_err(|e| e.to_string())?;
412 Ok(serde_json::json!({
413 "entries": entries.iter().map(library_summary).collect::<Vec<_>>(),
414 "total": entries.len(),
415 "sort": sort.as_str(),
416 "filter": filter,
417 "facets": counts,
418 }))
419 }
420
421 pub fn library_facet_counts(&self, filter_json: &str) -> Result<serde_json::Value, String> {
423 let filter: super::super::hypermedia_store::FacetFilter = if filter_json.trim().is_empty() {
424 Default::default()
425 } else {
426 serde_json::from_str(filter_json).map_err(|e| format!("facet filter json: {e}"))?
427 };
428 let counts = self
429 .library()?
430 .facet_counts(&filter)
431 .map_err(|e| e.to_string())?;
432 Ok(serde_json::to_value(counts).map_err(|e| e.to_string())?)
433 }
434
435 pub fn seed_studio_qapps_library(&self) -> Result<serde_json::Value, String> {
438 let store = self.library()?;
439 let report = super::super::qapp_catalog::seed_studio_qapps_into_library(&store)
440 .map_err(|e| e.to_string())?;
441 Ok(serde_json::to_value(report).map_err(|e| e.to_string())?)
442 }
443
444 pub fn seed_perception_library(&self) -> Result<serde_json::Value, String> {
447 let store = self.library()?;
448 let root = self.storage_root();
449 let report = super::super::perception_catalog::seed_perception_into_library(&store, root)?;
450 Ok(serde_json::to_value(report).map_err(|e| e.to_string())?)
451 }
452
453 pub fn ingest_legislation_pdf_hex(
456 &self,
457 hex_bytes: &str,
458 register_id: Option<&str>,
459 jurisdiction: Option<&str>,
460 title_hint: Option<&str>,
461 ) -> Result<serde_json::Value, String> {
462 let bytes = decode_hex(hex_bytes)?;
463 let store = self.library()?;
464 let report = super::super::legislation_ingest::ingest_legislation_pdf_bytes(
465 &store,
466 &bytes,
467 register_id,
468 jurisdiction.unwrap_or("AU"),
469 title_hint,
470 )?;
471 Ok(serde_json::to_value(report).map_err(|e| e.to_string())?)
472 }
473
474 pub fn ingest_legislation_text(
476 &self,
477 text: &str,
478 register_id: Option<&str>,
479 jurisdiction: Option<&str>,
480 title_hint: Option<&str>,
481 ) -> Result<serde_json::Value, String> {
482 let store = self.library()?;
483 let report = super::super::legislation_ingest::ingest_legislation_text(
484 &store,
485 text,
486 register_id,
487 jurisdiction.unwrap_or("AU"),
488 title_hint,
489 )?;
490 Ok(serde_json::to_value(report).map_err(|e| e.to_string())?)
491 }
492
493 pub fn build_cml_context_graph(
496 &self,
497 uri: &str,
498 title: &str,
499 text: &str,
500 ) -> Result<serde_json::Value, String> {
501 let units = super::super::cml_context::units_from_headings(text);
502 let g = super::super::cml_context::build_document_context(uri, title, &units);
503 Ok(serde_json::json!({
504 "document_uri": g.document_uri,
505 "title": g.title,
506 "concepts": g.concepts,
507 "signal_tags": g.signal_tags,
508 "topics": g.topics,
509 "purposes": g.purposes,
510 "deontic_norms": g.deontic_norms,
511 "privacy_hits": g.privacy_hits,
512 "rights_hits": g.rights_hits,
513 "quin_count": g.quins.len(),
514 "n3": g.n3,
515 "curation": "cml:Proposed",
516 "engine": "qualia-client-core::wellfair::cml_context",
517 }))
518 }
519
520 pub fn build_cof_html_package(
523 &self,
524 uri: &str,
525 title: &str,
526 text: &str,
527 max_chars: Option<usize>,
528 dual_surface: bool,
529 ) -> Result<serde_json::Value, String> {
530 let units = super::super::cml_context::units_from_headings(text);
531 let style = if dual_surface {
532 super::super::cml_context::CofStyle::DualSurface
533 } else {
534 super::super::cml_context::CofStyle::AgentLean
535 };
536 let max = max_chars
537 .filter(|n| *n >= 2000)
538 .unwrap_or(super::super::cml_context::DEFAULT_SEGMENT_MAX_CHARS);
539 let pkg = super::super::cml_context::build_cof_package(uri, title, &units, max, style);
540 Ok(serde_json::json!({
541 "document_uri": pkg.document_uri,
542 "title": pkg.title,
543 "profile": pkg.profile,
544 "segment_max_chars": pkg.segment_max_chars,
545 "total_chars": pkg.total_chars,
546 "total_approx_tokens": pkg.total_approx_tokens,
547 "segments": pkg.segments.iter().map(|s| serde_json::json!({
548 "index": s.index,
549 "total": s.total,
550 "id": s.id,
551 "title": s.title,
552 "char_count": s.char_count,
553 "approx_tokens": s.approx_tokens,
554 "unit_frags": s.unit_frags,
555 "is_index": s.is_index,
556 "html": s.html,
557 })).collect::<Vec<_>>(),
558 "how": [
559 "Load segment 0 (index) for a token-cheap map of the instrument.",
560 "Load only the body segment(s) whose unit_frags match the query.",
561 "RDFa attributes carry CML edges; do not strip typeof/property/resource.",
562 ],
563 }))
564 }
565
566 pub fn enrich_library_entry_cml(&self, asset_uri: &str) -> Result<serde_json::Value, String> {
568 let store = self.library()?;
569 let mut entries = store.load().map_err(|e| e.to_string())?;
570 let e = entries
571 .iter_mut()
572 .find(|x| x.asset_uri == asset_uri)
573 .ok_or_else(|| format!("unknown asset '{asset_uri}'"))?;
574 let text = if e.excerpt.len() > 40 {
575 e.excerpt.clone()
576 } else {
577 return Err("entry has no usable text in excerpt to enrich".into());
578 };
579 let units = super::super::cml_context::units_from_headings(&text);
580 let g =
581 super::super::cml_context::build_document_context(&e.asset_uri, &e.asset_uri, &units);
582 for t in &g.topics {
583 if !e.topics.iter().any(|x| x == t) {
584 e.topics.push(t.clone());
585 }
586 }
587 for p in &g.purposes {
588 if !e.purposes.iter().any(|x| x == p) {
589 e.purposes.push(p.clone());
590 }
591 }
592 e.cml_signals = g.signal_tags.clone();
593 e.cml_concept_count = g.concepts.len() as u32;
594 e.cml_n3 = if g.n3.len() > 48_000 {
595 format!("{}…", &g.n3[..48_000])
596 } else {
597 g.n3.clone()
598 };
599 e.quins.extend(g.quins);
600 e.recompute_section();
601 let out = library_summary(e);
602 store.replace_all(&entries).map_err(|e| e.to_string())?;
603 Ok(out)
604 }
605
606 pub fn list_qapp_catalog_categories(&self) -> Result<serde_json::Value, String> {
608 let cats: Vec<serde_json::Value> = super::super::qapp_catalog::catalogue_categories()
609 .into_iter()
610 .map(|slug| {
611 serde_json::json!({
612 "slug": slug,
613 "label": super::super::qapp_catalog::category_label(slug),
614 "count": super::super::qapp_catalog::STUDIO_QAPP_CATALOG
615 .iter()
616 .filter(|e| e.category == slug)
617 .count(),
618 })
619 })
620 .collect();
621 Ok(serde_json::json!({
622 "total": super::super::qapp_catalog::STUDIO_QAPP_CATALOG.len(),
623 "categories": cats,
624 }))
625 }
626
627 pub fn library_stats(&self) -> Result<serde_json::Value, String> {
629 let store = self.library()?;
630 let s = store.stats().map_err(|e| e.to_string())?;
631 let sections = store.section_counts().map_err(|e| e.to_string())?;
632 Ok(serde_json::json!({
633 "total": s.total,
634 "with_date": s.with_date,
635 "with_place": s.with_place,
636 "flags": s.flags,
637 "quins": s.quins,
638 "topics": s.topics,
639 "projects": s.projects,
640 "sections": sections,
641 }))
642 }
643
644 pub fn set_library_commons_visibility(
646 &self,
647 asset_uri: &str,
648 visibility: &str,
649 ) -> Result<serde_json::Value, String> {
650 let vis = super::super::hypermedia_store::CommonsVisibility::parse(visibility);
651 let e = self
652 .library()?
653 .set_commons_visibility(asset_uri, vis)
654 .map_err(|e| e.to_string())?;
655 Ok(library_summary(&e))
656 }
657
658 pub fn library_commons_share_card(&self, asset_uri: &str) -> Result<serde_json::Value, String> {
661 let entries = self.library()?.all().map_err(|e| e.to_string())?;
662 let e = entries
663 .iter()
664 .find(|x| x.asset_uri == asset_uri)
665 .ok_or_else(|| format!("unknown asset '{asset_uri}'"))?;
666 if e.is_secret() {
667 return Err("secret items cannot be offered to the commons".into());
668 }
669 if e.commons_visibility == super::super::hypermedia_store::CommonsVisibility::None {
670 return Err("set commons visibility to peers or commons before sharing".into());
671 }
672 Ok(serde_json::json!({
673 "qualia_library_commons": "1",
674 "asset_uri": e.asset_uri,
675 "media_type": e.media_type,
676 "topics": e.topics,
677 "projects": e.projects,
678 "purposes": e.purposes,
679 "excerpt": e.excerpt,
680 "section": e.section,
681 "commons_visibility": e.commons_visibility,
682 "how": [
683 "Host: Keep → Library → Commons → Share to peers.",
684 "Peer: accept via Talk social connection; request content over mesh when available.",
685 ],
686 "note": "Card is metadata only — not the secret body. High-sensitivity items never appear here.",
687 }))
688 }
689
690 pub fn remove_library_entry(&self, asset_uri: &str) -> Result<serde_json::Value, String> {
692 let ok = self
693 .library()?
694 .remove(asset_uri)
695 .map_err(|e| e.to_string())?;
696 Ok(serde_json::json!({ "removed": ok, "asset_uri": asset_uri }))
697 }
698
699 pub fn export_library_graph(&self) -> Result<serde_json::Value, String> {
702 let store = self.library()?;
703 let entries = store.all().map_err(|e| e.to_string())?;
704 let quins = store.all_quins().map_err(|e| e.to_string())?;
705 Ok(serde_json::json!({
706 "quin_count": quins.len(),
707 "entry_count": entries.len(),
708 "message": "Hypermedia edge-graph ready for daemon /query inject. Quins are the searchable semantic form.",
709 "sample_subjects": entries.iter().take(8).map(|e| e.primary_subject).collect::<Vec<_>>(),
710 }))
711 }
712}
713
714use super::super::hypermedia_store::{FacetFilter, HypermediaStore, LibrarySection, LibrarySort};
721use super::library_summary;
722use std::path::Path;
723
724fn open_store(storage_root: &Path) -> Result<HypermediaStore, String> {
725 HypermediaStore::open(storage_root).map_err(|e| e.to_string())
726}
727
728pub fn list_library_section_at(
730 storage_root: &Path,
731 section: Option<&str>,
732) -> Result<Vec<serde_json::Value>, String> {
733 let store = open_store(storage_root)?;
734 let entries = match section {
735 Some(s) if !s.is_empty() && s != "all" => store
736 .by_section(LibrarySection::parse(s))
737 .map_err(|e| e.to_string())?,
738 _ => store.all().map_err(|e| e.to_string())?,
739 };
740 Ok(entries.iter().map(library_summary).collect())
741}
742
743pub fn query_library_faceted_at(
745 storage_root: &Path,
746 filter_json: &str,
747 sort: &str,
748) -> Result<serde_json::Value, String> {
749 let filter: FacetFilter = if filter_json.trim().is_empty() {
750 Default::default()
751 } else {
752 serde_json::from_str(filter_json).map_err(|e| format!("facet filter json: {e}"))?
753 };
754 let sort = LibrarySort::parse(sort);
755 let store = open_store(storage_root)?;
756 let entries = store
757 .query_faceted(&filter, sort)
758 .map_err(|e| e.to_string())?;
759 let counts = store.facet_counts(&filter).map_err(|e| e.to_string())?;
760 Ok(serde_json::json!({
761 "entries": entries.iter().map(library_summary).collect::<Vec<_>>(),
762 "total": entries.len(),
763 "sort": sort.as_str(),
764 "filter": filter,
765 "facets": counts,
766 }))
767}
768
769pub fn library_stats_at(storage_root: &Path) -> Result<serde_json::Value, String> {
771 let store = open_store(storage_root)?;
772 let s = store.stats().map_err(|e| e.to_string())?;
773 let sections = store.section_counts().map_err(|e| e.to_string())?;
774 Ok(serde_json::json!({
775 "total": s.total,
776 "with_date": s.with_date,
777 "with_place": s.with_place,
778 "flags": s.flags,
779 "quins": s.quins,
780 "topics": s.topics,
781 "projects": s.projects,
782 "sections": sections,
783 }))
784}
785
786pub fn search_library_at(
788 storage_root: &Path,
789 facet: &str,
790 value: &str,
791) -> Result<Vec<serde_json::Value>, String> {
792 let store = open_store(storage_root)?;
793 let entries = store.search(facet, value).map_err(|e| e.to_string())?;
794 Ok(entries.iter().map(library_summary).collect())
795}
796
797pub fn search_library_text_at(
799 storage_root: &Path,
800 query: &str,
801) -> Result<Vec<serde_json::Value>, String> {
802 let store = open_store(storage_root)?;
803 let entries = store.search_text(query).map_err(|e| e.to_string())?;
804 Ok(entries.iter().map(library_summary).collect())
805}
806
807pub fn search_library_time_at(
809 storage_root: &Path,
810 start: i64,
811 end: i64,
812) -> Result<Vec<serde_json::Value>, String> {
813 let store = open_store(storage_root)?;
814 let entries = store
815 .search_time_range(start, end)
816 .map_err(|e| e.to_string())?;
817 Ok(entries.iter().map(library_summary).collect())
818}
819
820#[cfg(test)]
821mod vault_free_tests {
822 use super::*;
823 use crate::wellfair::hypermedia_store::{CommonsVisibility, LibraryEntry};
824
825 #[test]
826 fn vault_free_list_and_stats_see_seeded_rows() {
827 let dir = tempfile::tempdir().unwrap();
828 let store = HypermediaStore::open(dir.path()).unwrap();
829 let entry = LibraryEntry {
830 asset_uri: "model://test-vision".into(),
831 primary_subject: 42,
832 media_type: "application/x-webizen-model".into(),
833 quins: vec![],
834 topics: vec!["perception".into(), "computer_vision".into()],
835 projects: vec!["perception:vision".into()],
836 purposes: vec!["model".into()],
837 place: None,
838 occurred_at: None,
839 lat: None,
840 lon: None,
841 flags: vec![],
842 ingested_unix: 1_700_000_000,
843 excerpt: "test model row".into(),
844 sensitivity: "public".into(),
845 section: "software".into(),
846 commons_visibility: CommonsVisibility::None,
847 cml_signals: vec![],
848 cml_concept_count: 0,
849 cml_n3: String::new(),
850 cof_html: String::new(),
851 cof_segment_count: 0,
852 cof_segment_index: 0,
853 cof_profile: String::new(),
854 };
855 store.add(entry).unwrap();
856
857 let listed = list_library_section_at(dir.path(), Some("software")).unwrap();
858 assert_eq!(listed.len(), 1);
859 assert_eq!(listed[0]["asset_uri"], "model://test-vision");
860
861 let stats = library_stats_at(dir.path()).unwrap();
862 assert_eq!(stats["total"], 1);
863 assert_eq!(stats["sections"]["software"], 1);
864
865 let faceted =
866 query_library_faceted_at(dir.path(), r#"{"section":"software"}"#, "newest").unwrap();
867 assert_eq!(faceted["total"], 1);
868 assert_eq!(faceted["entries"].as_array().unwrap().len(), 1);
869 }
870}