Skip to main content

qualia_client_core/wellfair/api/
library.rs

1//! Hypermedia asset library: ingest, search, CML, COF
2
3use super::*;
4
5impl WebizenHostApi {
6    // --- Hypermedia asset library: ingest a document → make it searchable by meaning ---
7
8    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    /// **Ingest a text document** into the library: derive its topics + searchable text, bind them into a
14    /// hypermedia container, persist it (findable by meaning), and — if `guardian_did` is set (the principal
15    /// is under a guardianship relation) and a flag is raised — notify the guardian **and record it in the
16    /// tamper-evident ledger**. Returns a summary (topics, flags, any guardian notifications).
17    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    /// Ingest a text document **with person-authored facets** — an optional date (→ timeline), place
35    /// (→ map), project and purpose the person chooses to attach. The document's derived topics still come
36    /// from its content; these facets are added on top (the person authoring meaning, not being defined).
37    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    /// **Ingest any asset bytes** (a document, a **photo**, an audio clip) into the library. The processor
49    /// registered for `media_type` derives searchability — a text doc → topics; a **JPEG/PNG → its EXIF
50    /// capture time (timeline) + GPS place (map)**; a WAV → duration + dominant frequency — and it all folds
51    /// into the container so the original is findable by meaning. `excerpt_source` is a short human string for
52    /// the results list (the text for a doc; a caption/filename for binary). Guardianship + ledger hook as
53    /// [`Self::ingest_document`].
54    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        // Merge the person-authored facets as additional descriptor edges on the primary asset. A processor's
77        // own derivation (a photo's EXIF) takes precedence for its fields; manual facets fill / extend.
78        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        // Effective facets for the entry's display fields: processor-derived first, else the person's.
118        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        // Rust-native CML context graph for text-like assets (TEXT→CONCEPT→LOGIC, cml:Proposed).
135        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        // COF HTML+RDFa package (token-bounded segments) for text assets.
202        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        // High sensitivity can never be commons.
231        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        // Sibling COF body segments — load only the budget needed for a turn.
242        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        // Guardianship hook: a flagged ingest under a guardianship relation notifies + records.
281        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    /// **Ingest a photo/audio file from hex-encoded bytes** — the boundary form for the desktop, which reads a
316    /// picked file and passes its bytes as hex (a JPEG is not valid utf-8, so it cannot come through the text
317    /// path). A photo's EXIF capture-time + GPS auto-populate the timeline + map. `caption` is the short
318    /// display string. Same derive + persist + guardian hook as [`Self::ingest_bytes`].
319    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    /// Search the library by facet (`topic` | `depicts` | `place` | `project` | `purpose`). Returns per-entry
339    /// summaries (not the raw quins).
340    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    /// The **timeline** query — entries whose event instant falls within `[start, end]` (unix seconds).
353    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    /// Everything in the library (newest first), as summaries.
366    /// Optional `section` filters to secret | wellfair | personal | work | commons.
367    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    /// Free-text search over uri / excerpt / topics / projects / place.
386    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    /// Multi-facet library query with sort. `filter_json` is a [`FacetFilter`] object;
395    /// `sort` is newest|oldest|title_asc|title_desc|media_type|category.
396    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    /// Facet value counts for chip UI (optionally narrowed by the same filter JSON).
422    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    /// Seed the early studio academic QApp inventory into Library → Software.
436    /// Idempotent; returns add/update counts.
437    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    /// Seed perception models + ontology catalogue rows into Library → Software.
445    /// Also ensures seed weight files under `{storage}/models/`.
446    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    /// Native legislation ingest (structure parse, no Ollama): PDF bytes → Work shelf
454    /// entries for the instrument and every Part/Section/Subsection with full body text.
455    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    /// Native legislation ingest from plain text (already extracted PDF text or HTML).
475    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    /// Build a Rust-native CML context graph for arbitrary text (no Python).
494    /// Returns concepts, signal tags, N3, and deontic/privacy counts — does not persist.
495    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    /// Build a **COF HTML+RDFa** package (token-bounded segments) without persisting.
521    /// `max_chars` defaults to 24000 when zero/None.
522    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    /// Re-run CML context enrichment on an existing library entry's excerpt/text fields.
567    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    /// List catalogue categories (for Software shelf UI without seeding first).
607    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    /// Aggregate library stats for the UI header (includes section counts).
628    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    /// Set commons / peer visibility (refuses Secret).
645    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    /// Build a **permissive commons share card** for social networking (no secret payloads).
659    /// Returns metadata peers can list; raw content stays on-device until a fuller mesh transfer.
660    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    /// Remove one library entry by asset URI.
691    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    /// Export the full hypermedia graph mass (quin count + optional dump for inject).
700    /// Returns `{ quin_count, entries }` — the live graph inject seam for daemon/MCP.
701    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
714// ── Vault-free path helpers (AppState storage_path; no Sanctuary HostApi) ─────
715//
716// The hypermedia shelf is a JSON index under `{storage}/wellfair/`. Reading and
717// seeding catalogue rows must work **before** the person unlocks Sanctuary —
718// otherwise Library looks permanently empty after a perception seed.
719
720use 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
728/// List library entries at `storage_root` (newest-first store order). Optional section filter.
729pub 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
743/// Faceted query + facet counts at `storage_root` (same JSON shape as HostApi).
744pub 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
769/// Aggregate stats at `storage_root` (header chips + section counts).
770pub 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
786/// Facet search (`topic` | `depicts` | `place` | `project` | `purpose`) without vault.
787pub 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
797/// Free-text search without vault.
798pub 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
807/// Timeline range search without vault.
808pub 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}