Skip to main content

qualia_client_core/wellfair/
hypermedia_store.rs

1//! **Persistent hypermedia asset library** — ingest documents/assets, and find them by *meaning* (topic /
2//! depiction / time / place / project / purpose), never by folder.
3//!
4//! # Sections (product lanes)
5//! The library is one store, many **sections** — purpose-shaped views, not folders:
6//! - **Secret** — sanctuary / restricted / classified / Wellfair-private health
7//! - **Wellfair** — health & welfare purposes (can also force secret when sensitivity is high)
8//! - **Personal** — private life, default home shelf
9//! - **Work** — project-scoped labour
10//! - **Tools** — logs, telemetry, technical artefacts, agent/tool output
11//! - **Software** — QApps, websites, packages, installable/runnable software artefacts
12//! - **Commons** — permissive share surface (peers / micro-commons via social networking)
13//!
14//! Sensitivity (`public` | `restricted` | `classified` | `sanctuary`) is orthogonal: high sensitivity
15//! always routes into **Secret** even if the purpose is Wellfair or Work.
16
17use std::collections::{BTreeMap, HashSet};
18use std::fs;
19use std::path::{Path, PathBuf};
20
21use qualia_core_db::hypermedia;
22use qualia_core_db::NQuin;
23use serde::{Deserialize, Serialize};
24
25pub const LIBRARY_FILE: &str = "wellfair/hypermedia_library.json";
26
27/// Product section id for the Library chrome.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum LibrarySection {
31    /// All items (except those filtered by UI for secret gate).
32    All,
33    /// High-sensitivity / sanctuary / private health — the secret shelf.
34    Secret,
35    /// Health, welfare, care (Wellfair).
36    Wellfair,
37    /// Default personal shelf.
38    Personal,
39    /// Project / cooperative work.
40    Work,
41    /// Logs, telemetry, agent/tool output, technical diagnostics.
42    Tools,
43    /// QApps, websites, packages, installable or runnable software artefacts.
44    Software,
45    /// Permissive commons — shareable with peers / social networking layers.
46    Commons,
47}
48
49impl LibrarySection {
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::All => "all",
53            Self::Secret => "secret",
54            Self::Wellfair => "wellfair",
55            Self::Personal => "personal",
56            Self::Work => "work",
57            Self::Tools => "tools",
58            Self::Software => "software",
59            Self::Commons => "commons",
60        }
61    }
62
63    pub fn parse(s: &str) -> Self {
64        match s.trim().to_ascii_lowercase().as_str() {
65            "secret" | "sanctuary" | "private" => Self::Secret,
66            "wellfair" | "health" | "welfare" => Self::Wellfair,
67            "personal" | "home" => Self::Personal,
68            "work" | "project" | "coop" => Self::Work,
69            "tools" | "tool" | "logs" | "log" | "tech" | "technical" | "ops" | "debug"
70            | "telemetry" | "agent" => Self::Tools,
71            "software" | "qapp" | "qapps" | "app" | "apps" | "website" | "websites" | "web"
72            | "site" | "package" | "packages" | "install" | "pwa" | "extension" => Self::Software,
73            "commons" | "public" | "share" | "permissive" => Self::Commons,
74            _ => Self::All,
75        }
76    }
77
78    pub fn label(self) -> &'static str {
79        match self {
80            Self::All => "All",
81            Self::Secret => "Secret",
82            Self::Wellfair => "Wellfair",
83            Self::Personal => "Personal",
84            Self::Work => "Work",
85            Self::Tools => "Tools",
86            Self::Software => "Software",
87            Self::Commons => "Commons",
88        }
89    }
90
91    pub fn blurb(self) -> &'static str {
92        match self {
93            Self::All => "Everything on this device you have rights to see.",
94            Self::Secret => "Sanctuary & high-sensitivity — Wellfair-private health and other secrets. Not for commons.",
95            Self::Wellfair => "Health, care, and welfare records — may also live under Secret when classified.",
96            Self::Personal => "Your private shelf — notes, life admin, unshared research.",
97            Self::Work => "Project-scoped material for cooperative labour.",
98            Self::Tools => "Logs, telemetry, agent/tool output, technical diagnostics — the machine's paper trail.",
99            Self::Software => "QApps, websites, packages, and other installable or runnable software artefacts.",
100            Self::Commons => "Permissive share surface — peers and micro-commons via Talk social networking.",
101        }
102    }
103}
104
105/// How far an item may travel on social / commons layers.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
107#[serde(rename_all = "snake_case")]
108pub enum CommonsVisibility {
109    /// Device-local only (default for secret).
110    #[default]
111    None,
112    /// Visible to accepted social peers (bilateral / mesh).
113    Peers,
114    /// Permissive commons — intended for broader micro-commons replication.
115    Commons,
116}
117
118impl CommonsVisibility {
119    pub fn parse(s: &str) -> Self {
120        match s.trim().to_ascii_lowercase().as_str() {
121            "peers" | "peer" | "bilateral" => Self::Peers,
122            "commons" | "public" | "permissive" => Self::Commons,
123            _ => Self::None,
124        }
125    }
126
127    pub fn as_str(self) -> &'static str {
128        match self {
129            Self::None => "none",
130            Self::Peers => "peers",
131            Self::Commons => "commons",
132        }
133    }
134}
135
136/// Normalize sensitivity tokens used at ingest / UI.
137pub fn normalize_sensitivity(s: &str) -> String {
138    match s.trim().to_ascii_lowercase().as_str() {
139        "restricted" => "restricted".into(),
140        "classified" | "sanctuary" => "classified".into(),
141        "secret" => "classified".into(),
142        _ => "public".into(),
143    }
144}
145
146/// Resolve the product section for an entry (secret always wins on high sensitivity).
147pub fn resolve_section(
148    sensitivity: &str,
149    purposes: &[String],
150    projects: &[String],
151    commons: CommonsVisibility,
152    section_hint: Option<&str>,
153) -> LibrarySection {
154    let sens = normalize_sensitivity(sensitivity);
155    if sens == "restricted" || sens == "classified" {
156        return LibrarySection::Secret;
157    }
158    if commons == CommonsVisibility::Commons || commons == CommonsVisibility::Peers {
159        // Explicit share lane — still never secret.
160        if let Some(h) = section_hint {
161            let p = LibrarySection::parse(h);
162            if p != LibrarySection::Secret {
163                return if commons == CommonsVisibility::Commons {
164                    LibrarySection::Commons
165                } else {
166                    p
167                };
168            }
169        }
170        return LibrarySection::Commons;
171    }
172    if let Some(h) = section_hint {
173        let p = LibrarySection::parse(h);
174        if p != LibrarySection::All {
175            return p;
176        }
177    }
178    let purpose_blob = purposes
179        .iter()
180        .chain(projects.iter())
181        .map(|s| s.to_ascii_lowercase())
182        .collect::<Vec<_>>()
183        .join(" ");
184    if purpose_blob.contains("health")
185        || purpose_blob.contains("wellfair")
186        || purpose_blob.contains("welfare")
187        || purpose_blob.contains("medical")
188        || purpose_blob.contains("care")
189    {
190        return LibrarySection::Wellfair;
191    }
192    if purpose_blob.contains("legislation")
193        || purpose_blob.contains("statute")
194        || purpose_blob.contains("legal")
195        || purpose_blob.contains("regulation")
196        || purpose_blob.contains("bill")
197    {
198        // Statutes and regulations sit on the Work shelf (research / labour), not Software.
199        return LibrarySection::Work;
200    }
201    if purpose_blob.contains("qapp")
202        || purpose_blob.contains("website")
203        || purpose_blob.contains("web-app")
204        || purpose_blob.contains("webapp")
205        || purpose_blob.contains("software")
206        || purpose_blob.contains("package")
207        || purpose_blob.contains("pwa")
208        || purpose_blob.contains("extension")
209        || purpose_blob.contains("installable")
210        || purpose_blob.contains("application")
211    {
212        return LibrarySection::Software;
213    }
214    if purpose_blob.contains("log")
215        || purpose_blob.contains("telemetry")
216        || purpose_blob.contains("debug")
217        || purpose_blob.contains("trace")
218        || purpose_blob.contains("tool")
219        || purpose_blob.contains("agent")
220        || purpose_blob.contains("ops")
221        || purpose_blob.contains("technical")
222        || purpose_blob.contains("diagnostic")
223        || purpose_blob.contains("build")
224        || purpose_blob.contains("ci")
225    {
226        return LibrarySection::Tools;
227    }
228    if !projects.is_empty()
229        || purpose_blob.contains("work")
230        || purpose_blob.contains("project")
231        || purpose_blob.contains("coop")
232    {
233        return LibrarySection::Work;
234    }
235    LibrarySection::Personal
236}
237
238/// A summarised flag on an ingested asset (for display / the guardian path).
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct LibraryFlag {
241    pub kind: String,
242    pub severity_level: u64,
243    pub detail: String,
244}
245
246/// One ingested asset in the person's library — its identity + the container's semantic edge-graph.
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248pub struct LibraryEntry {
249    pub asset_uri: String,
250    /// The container primary's subject (`= fnv60(asset_uri)`) — the join key for search results.
251    pub primary_subject: u64,
252    pub media_type: String,
253    /// The container's edge-graph (container + descriptor + flag quins) — the searchable semantic form.
254    pub quins: Vec<NQuin>,
255    /// Display facets (the string forms of the descriptors; search itself runs over the quins above).
256    #[serde(default)]
257    pub topics: Vec<String>,
258    #[serde(default)]
259    pub projects: Vec<String>,
260    /// Purpose tags (tax, health, research, …) — power purpose-shaped sections.
261    #[serde(default)]
262    pub purposes: Vec<String>,
263    #[serde(default)]
264    pub place: Option<String>,
265    /// Event instant (unix seconds) if the asset carries one — the timeline anchor
266    /// (e.g. a photo's EXIF capture time). `None` = no dated event.
267    #[serde(default)]
268    pub occurred_at: Option<i64>,
269    /// Geographic coordinates if the asset carries them (e.g. a photo's GPS) — the
270    /// map pin. Both present together or both `None`.
271    #[serde(default)]
272    pub lat: Option<f32>,
273    #[serde(default)]
274    pub lon: Option<f32>,
275    #[serde(default)]
276    pub flags: Vec<LibraryFlag>,
277    pub ingested_unix: u64,
278    /// A short excerpt for display in results (never the whole asset).
279    #[serde(default)]
280    pub excerpt: String,
281    /// `public` | `restricted` | `classified` — high sensitivity forces Secret section.
282    #[serde(default = "default_sensitivity_public")]
283    pub sensitivity: String,
284    /// Product section lane (secret / wellfair / personal / work / commons).
285    #[serde(default = "default_section_personal")]
286    pub section: String,
287    /// How far this may travel on social / commons layers.
288    #[serde(default)]
289    pub commons_visibility: CommonsVisibility,
290    /// CML context-graph signal tags (`privacy:consent`, `deontic:obligation`, …).
291    #[serde(default)]
292    pub cml_signals: Vec<String>,
293    /// Number of proposed CML concepts on this entry.
294    #[serde(default)]
295    pub cml_concept_count: u32,
296    /// Compact proposed CML N3 for this unit (TEXT→CONCEPT→LOGIC; cml:Proposed only).
297    /// Truncated for large instruments; full graph also lives in `quins`.
298    #[serde(default)]
299    pub cml_n3: String,
300    /// COF HTML+RDFa segment (profile html-rdfa-1). Empty if not emitted.
301    /// Large instruments store the **index** on the root and body segments as child entries.
302    #[serde(default)]
303    pub cof_html: String,
304    /// Number of COF segments in the package this entry belongs to (0 = none).
305    #[serde(default)]
306    pub cof_segment_count: u32,
307    /// This entry's segment index (0 = index/TOC).
308    #[serde(default)]
309    pub cof_segment_index: u32,
310    /// COF profile IRI when `cof_html` is set.
311    #[serde(default)]
312    pub cof_profile: String,
313}
314
315fn default_sensitivity_public() -> String {
316    "public".into()
317}
318fn default_section_personal() -> String {
319    LibrarySection::Personal.as_str().into()
320}
321
322impl LibraryEntry {
323    /// Recompute section from sensitivity / purposes / commons (call after mutate).
324    pub fn recompute_section(&mut self) {
325        self.section = resolve_section(
326            &self.sensitivity,
327            &self.purposes,
328            &self.projects,
329            self.commons_visibility,
330            Some(&self.section),
331        )
332        .as_str()
333        .into();
334        // Secret can never be commons-visible.
335        if self.section == LibrarySection::Secret.as_str() {
336            self.commons_visibility = CommonsVisibility::None;
337        }
338    }
339
340    pub fn is_secret(&self) -> bool {
341        self.section == LibrarySection::Secret.as_str()
342            || matches!(
343                normalize_sensitivity(&self.sensitivity).as_str(),
344                "restricted" | "classified"
345            )
346    }
347}
348
349/// On-disk library store (whole-file JSON, write-temp-then-rename), matching the sibling-store convention.
350pub struct HypermediaStore {
351    path: PathBuf,
352}
353
354impl HypermediaStore {
355    pub fn open(storage_root: impl AsRef<Path>) -> std::io::Result<Self> {
356        let path = storage_root.as_ref().join(LIBRARY_FILE);
357        if let Some(parent) = path.parent() {
358            fs::create_dir_all(parent)?;
359        }
360        Ok(Self { path })
361    }
362
363    pub fn load(&self) -> std::io::Result<Vec<LibraryEntry>> {
364        match fs::read(&self.path) {
365            Ok(bytes) => {
366                serde_json::from_slice(&bytes).map_err(|e| std::io::Error::other(e.to_string()))
367            }
368            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
369            Err(e) => Err(e),
370        }
371    }
372
373    fn save(&self, entries: &[LibraryEntry]) -> std::io::Result<()> {
374        let bytes =
375            serde_json::to_vec_pretty(entries).map_err(|e| std::io::Error::other(e.to_string()))?;
376        let tmp = self.path.with_extension("json.tmp");
377        fs::write(&tmp, &bytes)?;
378        fs::rename(&tmp, &self.path)?;
379        Ok(())
380    }
381
382    /// Add (or replace by `asset_uri`) an entry.
383    pub fn add(&self, entry: LibraryEntry) -> std::io::Result<()> {
384        let mut entries = self.load()?;
385        entries.retain(|e| e.asset_uri != entry.asset_uri);
386        entries.push(entry);
387        self.save(&entries)
388    }
389
390    /// Replace the entire library (used by bulk seed paths — one write).
391    pub fn replace_all(&self, entries: &[LibraryEntry]) -> std::io::Result<()> {
392        self.save(entries)
393    }
394
395    /// Everything in the library (newest first).
396    pub fn all(&self) -> std::io::Result<Vec<LibraryEntry>> {
397        let mut entries = self.load()?;
398        // Backfill section/sensitivity on old entries.
399        for e in &mut entries {
400            if e.section.is_empty() || e.sensitivity.is_empty() {
401                e.sensitivity = normalize_sensitivity(&e.sensitivity);
402                e.recompute_section();
403            }
404        }
405        entries.sort_by(|a, b| b.ingested_unix.cmp(&a.ingested_unix));
406        Ok(entries)
407    }
408
409    /// Entries in one product section (`all` = everything).
410    pub fn by_section(&self, section: LibrarySection) -> std::io::Result<Vec<LibraryEntry>> {
411        let mut entries = self.all()?;
412        if section != LibrarySection::All {
413            let want = section.as_str();
414            entries.retain(|e| e.section == want);
415        }
416        Ok(entries)
417    }
418
419    /// Counts per section for the section rail UI.
420    pub fn section_counts(&self) -> std::io::Result<BTreeMap<String, usize>> {
421        let entries = self.all()?;
422        let mut m = BTreeMap::new();
423        m.insert(LibrarySection::All.as_str().into(), entries.len());
424        for sec in [
425            LibrarySection::Secret,
426            LibrarySection::Wellfair,
427            LibrarySection::Personal,
428            LibrarySection::Work,
429            LibrarySection::Tools,
430            LibrarySection::Software,
431            LibrarySection::Commons,
432        ] {
433            m.insert(
434                sec.as_str().into(),
435                entries.iter().filter(|e| e.section == sec.as_str()).count(),
436            );
437        }
438        Ok(m)
439    }
440
441    /// Publish (or revoke) commons visibility — never allowed for Secret / high sensitivity.
442    pub fn set_commons_visibility(
443        &self,
444        asset_uri: &str,
445        visibility: CommonsVisibility,
446    ) -> std::io::Result<LibraryEntry> {
447        let mut entries = self.load()?;
448        let e = entries
449            .iter_mut()
450            .find(|e| e.asset_uri == asset_uri)
451            .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "unknown asset"))?;
452        if e.is_secret() && visibility != CommonsVisibility::None {
453            return Err(std::io::Error::other(
454                "secret / high-sensitivity items cannot join the commons or peer share lane",
455            ));
456        }
457        e.commons_visibility = visibility;
458        if visibility == CommonsVisibility::Commons {
459            e.section = LibrarySection::Commons.as_str().into();
460        } else if visibility == CommonsVisibility::None
461            && e.section == LibrarySection::Commons.as_str()
462        {
463            e.recompute_section();
464        }
465        let out = e.clone();
466        self.save(&entries)?;
467        Ok(out)
468    }
469
470    /// Return the entries whose primary subject is in `subjects` (the join back from a graph query).
471    fn entries_for(&self, subjects: &HashSet<u64>) -> std::io::Result<Vec<LibraryEntry>> {
472        Ok(self
473            .load()?
474            .into_iter()
475            .filter(|e| subjects.contains(&e.primary_subject))
476            .collect())
477    }
478
479    /// Run a graph query over the union of all entries' quins, returning matching entries. `facet` is one of
480    /// `topic` | `depicts` | `place` | `project` | `purpose`.
481    pub fn search(&self, facet: &str, value: &str) -> std::io::Result<Vec<LibraryEntry>> {
482        let entries = self.load()?;
483        let all: Vec<NQuin> = entries
484            .iter()
485            .flat_map(|e| e.quins.iter().cloned())
486            .collect();
487        let subjects: HashSet<u64> = match facet {
488            "topic" => hypermedia::by_topic(&all, value),
489            "depicts" => hypermedia::by_depiction(&all, value),
490            "place" => hypermedia::by_place(&all, value),
491            "project" => hypermedia::in_project(&all, value),
492            "purpose" => hypermedia::for_purpose(&all, value),
493            "target" => hypermedia::analytics_for(&all, hypermedia::fnv60(value.as_bytes())),
494            _ => Vec::new(),
495        }
496        .into_iter()
497        .collect();
498        self.entries_for(&subjects)
499    }
500
501    /// The **timeline** query — entries whose event instant is within `[start, end]` (unix seconds).
502    pub fn search_time_range(&self, start: i64, end: i64) -> std::io::Result<Vec<LibraryEntry>> {
503        let entries = self.load()?;
504        let all: Vec<NQuin> = entries
505            .iter()
506            .flat_map(|e| e.quins.iter().cloned())
507            .collect();
508        let subjects: HashSet<u64> = hypermedia::in_time_range(&all, start, end)
509            .into_iter()
510            .collect();
511        self.entries_for(&subjects)
512    }
513
514    /// Free-text filter over uri, excerpt, topics, projects, purposes, place (case-insensitive).
515    pub fn search_text(&self, query: &str) -> std::io::Result<Vec<LibraryEntry>> {
516        let q = query.trim().to_lowercase();
517        if q.is_empty() {
518            return self.all();
519        }
520        let mut entries = self.load()?;
521        entries.retain(|e| entry_matches_text(e, &q));
522        entries.sort_by(|a, b| b.ingested_unix.cmp(&a.ingested_unix));
523        Ok(entries)
524    }
525
526    /// Multi-facet filter + sort. Facets are **AND** across dimensions; within each
527    /// non-empty list, match is **OR** (any of the selected values).
528    ///
529    /// Categories match `topics` containing the slug or `projects` of form `category:{slug}`.
530    pub fn query_faceted(
531        &self,
532        filter: &FacetFilter,
533        sort: LibrarySort,
534    ) -> std::io::Result<Vec<LibraryEntry>> {
535        let mut entries = self.all()?;
536        entries.retain(|e| filter.matches(e));
537        sort_entries(&mut entries, sort);
538        Ok(entries)
539    }
540
541    /// Facet value counts over entries matching `filter` (for chip UI). Counts are
542    /// computed **after** the filter so selecting a category narrows other facet tallies.
543    pub fn facet_counts(&self, filter: &FacetFilter) -> std::io::Result<FacetCounts> {
544        let entries = self.query_faceted(filter, LibrarySort::Newest)?;
545        let mut topics = BTreeMap::new();
546        let mut purposes = BTreeMap::new();
547        let mut projects = BTreeMap::new();
548        let mut media_types = BTreeMap::new();
549        let mut categories = BTreeMap::new();
550        let mut sections = BTreeMap::new();
551        for e in &entries {
552            *sections.entry(e.section.clone()).or_default() += 1;
553            *media_types.entry(e.media_type.clone()).or_default() += 1;
554            for t in &e.topics {
555                *topics.entry(t.clone()).or_default() += 1;
556            }
557            for p in &e.purposes {
558                *purposes.entry(p.clone()).or_default() += 1;
559            }
560            for p in &e.projects {
561                *projects.entry(p.clone()).or_default() += 1;
562                if let Some(cat) = p.strip_prefix("category:") {
563                    *categories.entry(cat.to_string()).or_default() += 1;
564                }
565            }
566            // Also treat topic slugs that look like domain categories.
567            for t in &e.topics {
568                if t.contains('-') && t != "qapp" && t != "academic" && !t.contains(':') {
569                    // Prefer explicit category: project tags; fill gaps from topics.
570                    categories.entry(t.clone()).or_insert(0);
571                }
572            }
573        }
574        // Re-count categories from project tags primarily (authoritative for QApps).
575        categories.clear();
576        for e in &entries {
577            for p in &e.projects {
578                if let Some(cat) = p.strip_prefix("category:") {
579                    *categories.entry(cat.to_string()).or_default() += 1;
580                }
581            }
582            // Fallback: topic that matches a known category project tag pattern on peers.
583            if e.projects.iter().all(|p| !p.starts_with("category:")) {
584                for t in &e.topics {
585                    if matches_category_slug(t) {
586                        *categories.entry(t.clone()).or_default() += 1;
587                    }
588                }
589            }
590        }
591        Ok(FacetCounts {
592            total: entries.len(),
593            topics,
594            purposes,
595            projects,
596            media_types,
597            categories,
598            sections,
599        })
600    }
601
602    /// Remove an entry by asset_uri.
603    pub fn remove(&self, asset_uri: &str) -> std::io::Result<bool> {
604        let mut entries = self.load()?;
605        let before = entries.len();
606        entries.retain(|e| e.asset_uri != asset_uri);
607        if entries.len() == before {
608            return Ok(false);
609        }
610        self.save(&entries)?;
611        Ok(true)
612    }
613
614    /// Library stats for the UI chrome.
615    pub fn stats(&self) -> std::io::Result<LibraryStats> {
616        let entries = self.load()?;
617        let mut topics: std::collections::BTreeMap<String, usize> =
618            std::collections::BTreeMap::new();
619        let mut projects: std::collections::BTreeMap<String, usize> =
620            std::collections::BTreeMap::new();
621        let mut with_date = 0usize;
622        let mut with_place = 0usize;
623        let mut flags = 0usize;
624        let mut quins = 0usize;
625        for e in &entries {
626            for t in &e.topics {
627                *topics.entry(t.clone()).or_default() += 1;
628            }
629            for p in &e.projects {
630                *projects.entry(p.clone()).or_default() += 1;
631            }
632            if e.occurred_at.is_some() {
633                with_date += 1;
634            }
635            if e.lat.is_some() && e.lon.is_some() {
636                with_place += 1;
637            }
638            flags += e.flags.len();
639            quins += e.quins.len();
640        }
641        Ok(LibraryStats {
642            total: entries.len(),
643            with_date,
644            with_place,
645            flags,
646            quins,
647            topics,
648            projects,
649        })
650    }
651
652    /// Flatten all library quins for graph export / daemon inject (caller owns the slice).
653    pub fn all_quins(&self) -> std::io::Result<Vec<NQuin>> {
654        Ok(self.load()?.into_iter().flat_map(|e| e.quins).collect())
655    }
656}
657
658/// Aggregate counts for the Library UI header.
659#[derive(Debug, Clone, Default, Serialize, Deserialize)]
660pub struct LibraryStats {
661    pub total: usize,
662    pub with_date: usize,
663    pub with_place: usize,
664    pub flags: usize,
665    /// Total NQuins across all containers — the semantic graph mass.
666    pub quins: usize,
667    pub topics: std::collections::BTreeMap<String, usize>,
668    pub projects: std::collections::BTreeMap<String, usize>,
669}
670
671/// Multi-facet filter for library browse / Software QApp shelf.
672///
673/// Empty lists mean "no constraint" on that dimension. Within a non-empty list,
674/// matching is OR; across dimensions, matching is AND.
675#[derive(Debug, Clone, Default, Serialize, Deserialize)]
676pub struct FacetFilter {
677    /// Product section id (`software`, `tools`, …). `all` / empty = no section filter.
678    #[serde(default)]
679    pub section: Option<String>,
680    /// Free-text over uri / excerpt / topics / purposes / projects / place / media.
681    #[serde(default)]
682    pub text: Option<String>,
683    #[serde(default)]
684    pub topics: Vec<String>,
685    #[serde(default)]
686    pub purposes: Vec<String>,
687    #[serde(default)]
688    pub projects: Vec<String>,
689    #[serde(default)]
690    pub media_types: Vec<String>,
691    /// Domain categories (e.g. `natural-sciences`) — matches `category:{slug}` projects or topic slug.
692    #[serde(default)]
693    pub categories: Vec<String>,
694}
695
696impl FacetFilter {
697    pub fn matches(&self, e: &LibraryEntry) -> bool {
698        if let Some(sec) = self.section.as_deref() {
699            let sec = sec.trim();
700            if !sec.is_empty() && sec != "all" && e.section != sec {
701                return false;
702            }
703        }
704        if let Some(q) = self.text.as_deref() {
705            let q = q.trim().to_lowercase();
706            if !q.is_empty() && !entry_matches_text(e, &q) {
707                return false;
708            }
709        }
710        if !self.topics.is_empty()
711            && !self.topics.iter().any(|t| {
712                let t = t.to_ascii_lowercase();
713                e.topics.iter().any(|et| et.to_ascii_lowercase() == t)
714            })
715        {
716            return false;
717        }
718        if !self.purposes.is_empty()
719            && !self.purposes.iter().any(|p| {
720                let p = p.to_ascii_lowercase();
721                e.purposes.iter().any(|ep| ep.to_ascii_lowercase() == p)
722            })
723        {
724            return false;
725        }
726        if !self.projects.is_empty()
727            && !self.projects.iter().any(|p| {
728                let p = p.to_ascii_lowercase();
729                e.projects.iter().any(|ep| ep.to_ascii_lowercase() == p)
730            })
731        {
732            return false;
733        }
734        if !self.media_types.is_empty()
735            && !self
736                .media_types
737                .iter()
738                .any(|m| e.media_type.eq_ignore_ascii_case(m.trim()))
739        {
740            return false;
741        }
742        if !self.categories.is_empty() && !self.categories.iter().any(|c| entry_has_category(e, c))
743        {
744            return false;
745        }
746        true
747    }
748}
749
750/// Sort keys for faceted library browse.
751#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
752#[serde(rename_all = "snake_case")]
753pub enum LibrarySort {
754    #[default]
755    Newest,
756    Oldest,
757    TitleAsc,
758    TitleDesc,
759    MediaType,
760    Category,
761}
762
763impl LibrarySort {
764    pub fn parse(s: &str) -> Self {
765        match s.trim().to_ascii_lowercase().as_str() {
766            "oldest" | "old" => Self::Oldest,
767            "title" | "title_asc" | "name" | "name_asc" | "a-z" | "az" => Self::TitleAsc,
768            "title_desc" | "name_desc" | "z-a" | "za" => Self::TitleDesc,
769            "media" | "media_type" | "type" => Self::MediaType,
770            "category" | "cat" => Self::Category,
771            _ => Self::Newest,
772        }
773    }
774
775    pub fn as_str(self) -> &'static str {
776        match self {
777            Self::Newest => "newest",
778            Self::Oldest => "oldest",
779            Self::TitleAsc => "title_asc",
780            Self::TitleDesc => "title_desc",
781            Self::MediaType => "media_type",
782            Self::Category => "category",
783        }
784    }
785}
786
787/// Per-value counts for facet chips after a filter is applied.
788#[derive(Debug, Clone, Default, Serialize, Deserialize)]
789pub struct FacetCounts {
790    pub total: usize,
791    pub topics: BTreeMap<String, usize>,
792    pub purposes: BTreeMap<String, usize>,
793    pub projects: BTreeMap<String, usize>,
794    pub media_types: BTreeMap<String, usize>,
795    pub categories: BTreeMap<String, usize>,
796    pub sections: BTreeMap<String, usize>,
797}
798
799fn entry_matches_text(e: &LibraryEntry, q: &str) -> bool {
800    e.asset_uri.to_lowercase().contains(q)
801        || e.excerpt.to_lowercase().contains(q)
802        || e.media_type.to_lowercase().contains(q)
803        || e.section.to_lowercase().contains(q)
804        || e.topics.iter().any(|t| t.to_lowercase().contains(q))
805        || e.projects.iter().any(|t| t.to_lowercase().contains(q))
806        || e.purposes.iter().any(|t| t.to_lowercase().contains(q))
807        || e.cml_signals.iter().any(|t| t.to_lowercase().contains(q))
808        || e.cml_n3.to_lowercase().contains(q)
809        || e.place
810            .as_ref()
811            .map(|p| p.to_lowercase().contains(q))
812            .unwrap_or(false)
813}
814
815fn entry_has_category(e: &LibraryEntry, cat: &str) -> bool {
816    let cat = cat.trim().to_ascii_lowercase();
817    if cat.is_empty() {
818        return true;
819    }
820    let tag = format!("category:{cat}");
821    e.projects
822        .iter()
823        .any(|p| p.eq_ignore_ascii_case(&tag) || p.to_ascii_lowercase() == cat)
824        || e.topics.iter().any(|t| t.eq_ignore_ascii_case(&cat))
825}
826
827fn matches_category_slug(s: &str) -> bool {
828    // Domain categories are kebab-case multi-word slugs (contain a hyphen).
829    let s = s.trim();
830    s.contains('-')
831        && s.chars()
832            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
833        && s != "qapp"
834}
835
836fn entry_title_key(e: &LibraryEntry) -> String {
837    // Prefer last path segment of URI for title-ish sort.
838    let uri = e.asset_uri.as_str();
839    let t = uri
840        .rsplit(['/', ':'])
841        .next()
842        .unwrap_or(uri)
843        .to_ascii_lowercase();
844    if t.is_empty() {
845        uri.to_ascii_lowercase()
846    } else {
847        t
848    }
849}
850
851fn entry_category_key(e: &LibraryEntry) -> String {
852    for p in &e.projects {
853        if let Some(c) = p.strip_prefix("category:") {
854            return c.to_ascii_lowercase();
855        }
856    }
857    e.topics
858        .iter()
859        .find(|t| matches_category_slug(t))
860        .cloned()
861        .unwrap_or_default()
862}
863
864fn sort_entries(entries: &mut [LibraryEntry], sort: LibrarySort) {
865    match sort {
866        LibrarySort::Newest => entries.sort_by(|a, b| b.ingested_unix.cmp(&a.ingested_unix)),
867        LibrarySort::Oldest => entries.sort_by(|a, b| a.ingested_unix.cmp(&b.ingested_unix)),
868        LibrarySort::TitleAsc => {
869            entries.sort_by(|a, b| entry_title_key(a).cmp(&entry_title_key(b)))
870        }
871        LibrarySort::TitleDesc => {
872            entries.sort_by(|a, b| entry_title_key(b).cmp(&entry_title_key(a)))
873        }
874        LibrarySort::MediaType => entries.sort_by(|a, b| {
875            a.media_type
876                .cmp(&b.media_type)
877                .then_with(|| entry_title_key(a).cmp(&entry_title_key(b)))
878        }),
879        LibrarySort::Category => entries.sort_by(|a, b| {
880            entry_category_key(a)
881                .cmp(&entry_category_key(b))
882                .then_with(|| entry_title_key(a).cmp(&entry_title_key(b)))
883        }),
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890    use qualia_core_db::hypermedia::{ingest_with, TextProcessor};
891
892    fn ingest(store: &HypermediaStore, uri: &str, text: &str, now: u64) {
893        let proc = TextProcessor::default();
894        let r = ingest_with(&proc, uri, "text/markdown", 1, text.as_bytes());
895        let mut entry = LibraryEntry {
896            asset_uri: uri.to_string(),
897            primary_subject: r.container.primary.subject(),
898            media_type: "text/markdown".to_string(),
899            quins: r.quins,
900            topics: Vec::new(),
901            projects: Vec::new(),
902            purposes: Vec::new(),
903            place: None,
904            occurred_at: None,
905            lat: None,
906            lon: None,
907            flags: Vec::new(),
908            ingested_unix: now,
909            excerpt: text.chars().take(40).collect(),
910            sensitivity: "public".into(),
911            section: "personal".into(),
912            commons_visibility: CommonsVisibility::None,
913            cml_signals: Vec::new(),
914            cml_concept_count: 0,
915            cml_n3: String::new(),
916            cof_html: String::new(),
917            cof_segment_count: 0,
918            cof_segment_index: 0,
919            cof_profile: String::new(),
920        };
921        entry.recompute_section();
922        store.add(entry).unwrap();
923    }
924
925    #[test]
926    fn ingested_documents_are_findable_by_topic_across_the_library() {
927        let dir = tempfile::tempdir().unwrap();
928        let store = HypermediaStore::open(dir.path()).unwrap();
929        ingest(
930            &store,
931            "urn:doc:liver",
932            "The liver is an organ; hepatocytes secrete bile.",
933            1_000,
934        );
935        ingest(
936            &store,
937            "urn:doc:contract",
938            "This contract is governed by statute and jurisdiction.",
939            1_100,
940        );
941        ingest(
942            &store,
943            "urn:doc:receipt",
944            "Invoice for a tax-deductible expense; keep this receipt.",
945            1_200,
946        );
947
948        // Search the WHOLE library by meaning — biology finds the liver, law finds the contract, finance the receipt.
949        let bio = store.search("topic", "biology").unwrap();
950        assert_eq!(bio.len(), 1);
951        assert_eq!(bio[0].asset_uri, "urn:doc:liver");
952        let law = store.search("topic", "law").unwrap();
953        assert_eq!(law.len(), 1);
954        assert_eq!(law[0].asset_uri, "urn:doc:contract");
955        // The tax/expenses use case: finance topic finds the receipt.
956        let fin = store.search("topic", "finance").unwrap();
957        assert_eq!(fin.len(), 1);
958        assert_eq!(fin[0].asset_uri, "urn:doc:receipt");
959        // A topic no doc has returns nothing; the whole library lists all three.
960        assert!(store.search("topic", "astronomy").unwrap().is_empty());
961        assert_eq!(store.all().unwrap().len(), 3);
962    }
963
964    #[test]
965    fn secret_section_forced_by_classified_sensitivity() {
966        assert_eq!(
967            resolve_section(
968                "classified",
969                &["health".into()],
970                &[],
971                CommonsVisibility::Commons,
972                Some("commons")
973            ),
974            LibrarySection::Secret
975        );
976    }
977
978    #[test]
979    fn wellfair_purpose_routes_to_wellfair_when_public() {
980        assert_eq!(
981            resolve_section(
982                "public",
983                &["health-record".into()],
984                &[],
985                CommonsVisibility::None,
986                None
987            ),
988            LibrarySection::Wellfair
989        );
990    }
991
992    #[test]
993    fn commons_visibility_routes_to_commons() {
994        assert_eq!(
995            resolve_section("public", &[], &[], CommonsVisibility::Commons, None),
996            LibrarySection::Commons
997        );
998    }
999
1000    #[test]
1001    fn tools_purpose_routes_to_tools() {
1002        assert_eq!(
1003            resolve_section(
1004                "public",
1005                &["agent-log".into(), "telemetry".into()],
1006                &[],
1007                CommonsVisibility::None,
1008                None
1009            ),
1010            LibrarySection::Tools
1011        );
1012        assert_eq!(
1013            resolve_section("public", &[], &[], CommonsVisibility::None, Some("tools")),
1014            LibrarySection::Tools
1015        );
1016    }
1017
1018    #[test]
1019    fn software_purpose_routes_to_software() {
1020        assert_eq!(
1021            resolve_section(
1022                "public",
1023                &["qapp".into()],
1024                &[],
1025                CommonsVisibility::None,
1026                None
1027            ),
1028            LibrarySection::Software
1029        );
1030        assert_eq!(
1031            resolve_section(
1032                "public",
1033                &["website".into()],
1034                &[],
1035                CommonsVisibility::None,
1036                None
1037            ),
1038            LibrarySection::Software
1039        );
1040        assert_eq!(
1041            resolve_section(
1042                "public",
1043                &[],
1044                &[],
1045                CommonsVisibility::None,
1046                Some("software")
1047            ),
1048            LibrarySection::Software
1049        );
1050    }
1051
1052    #[test]
1053    fn faceted_filter_and_sort() {
1054        let dir = tempfile::tempdir().unwrap();
1055        let store = HypermediaStore::open(dir.path()).unwrap();
1056        for (uri, topics, purposes, projects, media, section, when) in [
1057            (
1058                "qapp://studio/biology",
1059                vec!["qapp", "natural-sciences", "biology"],
1060                vec!["qapp", "software"],
1061                vec!["category:natural-sciences"],
1062                "application/x-webizen-qapp",
1063                "software",
1064                100u64,
1065            ),
1066            (
1067                "qapp://studio/philosophy",
1068                vec!["qapp", "humanities", "philosophy"],
1069                vec!["qapp", "software"],
1070                vec!["category:humanities"],
1071                "application/x-webizen-qapp",
1072                "software",
1073                200,
1074            ),
1075            (
1076                "urn:doc:note",
1077                vec!["personal"],
1078                vec!["note"],
1079                vec![],
1080                "text/markdown",
1081                "personal",
1082                300,
1083            ),
1084        ] {
1085            let mut e = LibraryEntry {
1086                asset_uri: uri.into(),
1087                primary_subject: when,
1088                media_type: media.into(),
1089                quins: Vec::new(),
1090                topics: topics.into_iter().map(str::to_string).collect(),
1091                projects: projects.into_iter().map(str::to_string).collect(),
1092                purposes: purposes.into_iter().map(str::to_string).collect(),
1093                place: None,
1094                occurred_at: None,
1095                lat: None,
1096                lon: None,
1097                flags: Vec::new(),
1098                ingested_unix: when,
1099                excerpt: uri.into(),
1100                sensitivity: "public".into(),
1101                section: section.into(),
1102                commons_visibility: CommonsVisibility::None,
1103                cml_signals: Vec::new(),
1104                cml_concept_count: 0,
1105                cml_n3: String::new(),
1106                cof_html: String::new(),
1107                cof_segment_count: 0,
1108                cof_segment_index: 0,
1109                cof_profile: String::new(),
1110            };
1111            e.recompute_section();
1112            store.add(e).unwrap();
1113        }
1114
1115        let soft = store
1116            .query_faceted(
1117                &FacetFilter {
1118                    section: Some("software".into()),
1119                    ..Default::default()
1120                },
1121                LibrarySort::TitleAsc,
1122            )
1123            .unwrap();
1124        assert_eq!(soft.len(), 2);
1125        assert!(soft[0].asset_uri.contains("biology"));
1126        assert!(soft[1].asset_uri.contains("philosophy"));
1127
1128        let nat = store
1129            .query_faceted(
1130                &FacetFilter {
1131                    section: Some("software".into()),
1132                    categories: vec!["natural-sciences".into()],
1133                    ..Default::default()
1134                },
1135                LibrarySort::Newest,
1136            )
1137            .unwrap();
1138        assert_eq!(nat.len(), 1);
1139        assert!(nat[0].asset_uri.contains("biology"));
1140
1141        let text = store
1142            .query_faceted(
1143                &FacetFilter {
1144                    text: Some("philo".into()),
1145                    ..Default::default()
1146                },
1147                LibrarySort::Newest,
1148            )
1149            .unwrap();
1150        assert_eq!(text.len(), 1);
1151
1152        let counts = store
1153            .facet_counts(&FacetFilter {
1154                section: Some("software".into()),
1155                ..Default::default()
1156            })
1157            .unwrap();
1158        assert_eq!(counts.total, 2);
1159        assert_eq!(counts.categories.get("natural-sciences"), Some(&1));
1160        assert_eq!(counts.categories.get("humanities"), Some(&1));
1161    }
1162}