use super::{count_nodes, doc_path, EdgeGroups};
use crate::datatypes::values::{DataFrame, Value};
use crate::graph::mutation::maintain;
use crate::graph::DirGraph;
use crate::okf::model::{
BuildOptions, BuildReport, ConceptDoc, FolderNoteDirection, Profile, CONTAINS_CONN_TYPE,
FOLDER_LABEL,
};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
enum Container<'a> {
Folder(String),
Note(&'a ConceptDoc),
}
#[derive(Default)]
struct FolderLayout<'a> {
note_of_dir: BTreeMap<String, &'a ConceptDoc>,
dir_of_note: BTreeMap<&'a str, String>,
}
impl<'a> FolderLayout<'a> {
fn owner(&self, dir: &str) -> Option<Container<'a>> {
if dir.is_empty() {
None
} else if let Some(note) = self.note_of_dir.get(dir) {
Some(Container::Note(note))
} else {
Some(Container::Folder(dir.to_string()))
}
}
}
fn folder_layout<'a>(
by_path: &BTreeMap<&'a str, &'a ConceptDoc>,
dirs: &BTreeSet<String>,
report: &mut BuildReport,
) -> FolderLayout<'a> {
let mut layout = FolderLayout::default();
for dir in dirs {
let base = dir.rsplit('/').next().unwrap_or(dir);
let beside = by_path.get(dir.as_str()).copied();
let inside = by_path.get(format!("{dir}/{base}").as_str()).copied();
if beside.is_some() && inside.is_some() {
report.errors.push(format!(
"folder note declared twice for `{dir}/`: `{dir}.md` and `{dir}/{base}.md`; `{dir}.md` is used"
));
}
if let Some(note) = beside.or(inside) {
layout.note_of_dir.insert(dir.clone(), note);
layout.dir_of_note.insert(doc_path(note), dir.clone());
}
}
layout
}
fn push_containment(
groups: &mut EdgeGroups,
container: &Container<'_>,
child_label: &str,
child_id: &str,
child_is_note: bool,
profile: &Profile,
) {
let (conn, src_label, src_id, tgt_label, tgt_id) = match container {
Container::Note(parent) if child_is_note => {
let down = profile.folder_note_direction == FolderNoteDirection::ParentToChild;
let (s, si, t, ti) = if down {
(
parent.label.as_str(),
parent.concept_id.as_str(),
child_label,
child_id,
)
} else {
(
child_label,
child_id,
parent.label.as_str(),
parent.concept_id.as_str(),
)
};
(profile.folder_note_edge.as_str(), s, si, t, ti)
}
Container::Note(parent) => (
CONTAINS_CONN_TYPE,
parent.label.as_str(),
parent.concept_id.as_str(),
child_label,
child_id,
),
Container::Folder(dir) => (
CONTAINS_CONN_TYPE,
FOLDER_LABEL,
dir.as_str(),
child_label,
child_id,
),
};
groups
.entry((
conn.to_string(),
src_label.to_string(),
tgt_label.to_string(),
))
.or_default()
.push((src_id.to_string(), tgt_id.to_string(), Vec::new()));
}
pub(super) fn build_folders<'a>(
graph: &mut DirGraph,
docs: &'a [ConceptDoc],
index_files: &HashMap<String, PathBuf>,
opts: &BuildOptions,
report: &mut BuildReport,
) -> Result<EdgeGroups, String> {
let mut dirs: BTreeSet<String> = BTreeSet::new();
for d in docs {
let mut p = crate::okf::parent_dir(doc_path(d)).to_string();
while !p.is_empty() {
let parent = crate::okf::parent_dir(&p).to_string();
dirs.insert(p);
p = parent;
}
}
if dirs.is_empty() {
return Ok(BTreeMap::new());
}
let profile = &opts.profile;
let by_path: BTreeMap<&'a str, &'a ConceptDoc> =
docs.iter().map(|d| (doc_path(d), d)).collect();
let layout = if profile.folder_notes {
folder_layout(&by_path, &dirs, report)
} else {
FolderLayout::default()
};
report.folder_notes = layout.note_of_dir.len();
let folder_dirs: Vec<&str> = dirs
.iter()
.map(String::as_str)
.filter(|d| !layout.note_of_dir.contains_key(*d))
.collect();
count_nodes(report, FOLDER_LABEL, folder_dirs.len());
if !folder_dirs.is_empty() {
let mut rows: Vec<Vec<Value>> = Vec::with_capacity(folder_dirs.len());
for dir in &folder_dirs {
let (title, desc) = index_files
.get(*dir)
.map(|p| folder_meta(p))
.unwrap_or((None, None));
let title = title.unwrap_or_else(|| dir.rsplit('/').next().unwrap_or(dir).to_string());
rows.push(vec![
Value::String((*dir).to_string()),
Value::String(title),
desc.map(Value::String).unwrap_or(Value::Null),
]);
}
let df = DataFrame::from_cypher_rows(
vec![
"id".to_string(),
"title".to_string(),
"description".to_string(),
],
rows,
)?;
maintain::add_nodes(
graph,
df,
FOLDER_LABEL.to_string(),
"id".to_string(),
Some("title".to_string()),
Some("update".to_string()),
)?;
}
let mut groups: EdgeGroups = BTreeMap::new();
for d in docs {
let dir = match layout.dir_of_note.get(doc_path(d)) {
Some(owned) => crate::okf::parent_dir(owned),
None => crate::okf::parent_dir(doc_path(d)),
};
if let Some(c) = layout.owner(dir) {
push_containment(&mut groups, &c, &d.label, &d.concept_id, true, profile);
}
}
for dir in &folder_dirs {
if let Some(c) = layout.owner(crate::okf::parent_dir(dir)) {
push_containment(&mut groups, &c, FOLDER_LABEL, dir, false, profile);
}
}
Ok(groups)
}
fn folder_meta(path: &Path) -> (Option<String>, Option<String>) {
let Ok(text) = std::fs::read_to_string(path) else {
return (None, None);
};
let mut title = None;
let mut desc = None;
for line in text.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if let Some(h) = crate::okf::links::heading_text(t) {
if title.is_none() {
title = Some(h.to_string());
}
} else if desc.is_none() {
desc = Some(t.to_string());
}
if title.is_some() && desc.is_some() {
break;
}
}
(title.filter(|s| !s.is_empty()), desc)
}
#[cfg(test)]
#[path = "folders_tests.rs"]
mod folders_tests;