1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum LibrarySection {
31 All,
33 Secret,
35 Wellfair,
37 Personal,
39 Work,
41 Tools,
43 Software,
45 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
107#[serde(rename_all = "snake_case")]
108pub enum CommonsVisibility {
109 #[default]
111 None,
112 Peers,
114 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
136pub 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
146pub 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 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 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248pub struct LibraryEntry {
249 pub asset_uri: String,
250 pub primary_subject: u64,
252 pub media_type: String,
253 pub quins: Vec<NQuin>,
255 #[serde(default)]
257 pub topics: Vec<String>,
258 #[serde(default)]
259 pub projects: Vec<String>,
260 #[serde(default)]
262 pub purposes: Vec<String>,
263 #[serde(default)]
264 pub place: Option<String>,
265 #[serde(default)]
268 pub occurred_at: Option<i64>,
269 #[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 #[serde(default)]
280 pub excerpt: String,
281 #[serde(default = "default_sensitivity_public")]
283 pub sensitivity: String,
284 #[serde(default = "default_section_personal")]
286 pub section: String,
287 #[serde(default)]
289 pub commons_visibility: CommonsVisibility,
290 #[serde(default)]
292 pub cml_signals: Vec<String>,
293 #[serde(default)]
295 pub cml_concept_count: u32,
296 #[serde(default)]
299 pub cml_n3: String,
300 #[serde(default)]
303 pub cof_html: String,
304 #[serde(default)]
306 pub cof_segment_count: u32,
307 #[serde(default)]
309 pub cof_segment_index: u32,
310 #[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 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 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
349pub 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 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 pub fn replace_all(&self, entries: &[LibraryEntry]) -> std::io::Result<()> {
392 self.save(entries)
393 }
394
395 pub fn all(&self) -> std::io::Result<Vec<LibraryEntry>> {
397 let mut entries = self.load()?;
398 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 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 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 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 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 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 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 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 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 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 for t in &e.topics {
568 if t.contains('-') && t != "qapp" && t != "academic" && !t.contains(':') {
569 categories.entry(t.clone()).or_insert(0);
571 }
572 }
573 }
574 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 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 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 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 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#[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 pub quins: usize,
667 pub topics: std::collections::BTreeMap<String, usize>,
668 pub projects: std::collections::BTreeMap<String, usize>,
669}
670
671#[derive(Debug, Clone, Default, Serialize, Deserialize)]
676pub struct FacetFilter {
677 #[serde(default)]
679 pub section: Option<String>,
680 #[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 #[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#[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#[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 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 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 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 let fin = store.search("topic", "finance").unwrap();
957 assert_eq!(fin.len(), 1);
958 assert_eq!(fin[0].asset_uri, "urn:doc:receipt");
959 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}