use crate::datatypes::values::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dialect {
Okf,
Loose,
Obsidian,
}
impl Dialect {
pub fn parse(name: Option<&str>) -> Self {
match name.map(|s| s.to_ascii_lowercase()).as_deref() {
Some("loose") => Dialect::Loose,
Some("obsidian") => Dialect::Obsidian,
_ => Dialect::Okf,
}
}
pub fn wikilinks(self) -> bool {
matches!(self, Dialect::Loose | Dialect::Obsidian)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LabelFrom {
Type,
Folder,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdScheme {
Path,
FrontmatterOrStem,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FolderNoteDirection {
ChildToParent,
ParentToChild,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HubSpec {
pub label: String,
pub edge: String,
pub case_insensitive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Profile {
pub index_as_folder_metadata: bool,
pub skip_log_files: bool,
pub label_from: LabelFrom,
pub default_label: Option<String>,
pub metadata_type_label: bool,
pub folder_label: bool,
pub fallback_label: String,
pub id_scheme: IdScheme,
pub native_collections: bool,
pub require_frontmatter: bool,
pub store_body: bool,
pub wikilinks: bool,
pub link_edge_props: bool,
pub embeds: bool,
pub inline_tags: bool,
pub frontmatter_edges: bool,
pub alias_resolution: bool,
pub folder_note_edge: String,
pub folder_note_direction: FolderNoteDirection,
pub folder_notes: bool,
pub hubs: BTreeMap<String, HubSpec>,
pub heading_edges: BTreeMap<String, String>,
pub body_property: String,
pub skip_dirs: Vec<String>,
pub infer_temporal: bool,
pub attachments: bool,
pub reserved_key_shapes: bool,
pub(crate) ignored_keys: &'static [&'static str],
pub(crate) structure: Option<crate::okf::structure::StructureProfile>,
pub(crate) edge_defaults: BTreeMap<String, Vec<(String, Value)>>,
pub path_safety: bool,
}
impl Default for Profile {
fn default() -> Self {
Profile {
index_as_folder_metadata: true,
skip_log_files: true,
label_from: LabelFrom::Type,
default_label: None,
metadata_type_label: true,
folder_label: false,
fallback_label: DEFAULT_LABEL.to_string(),
id_scheme: IdScheme::Path,
native_collections: false,
require_frontmatter: true,
store_body: false,
wikilinks: false,
link_edge_props: false,
embeds: false,
inline_tags: false,
frontmatter_edges: false,
alias_resolution: false,
folder_note_edge: FOLDER_NOTE_CONN_TYPE.to_string(),
folder_note_direction: FolderNoteDirection::ChildToParent,
folder_notes: false,
hubs: default_hubs(false),
heading_edges: BTreeMap::new(),
skip_dirs: Vec::new(),
body_property: DEFAULT_BODY_PROPERTY.to_string(),
infer_temporal: false,
attachments: false,
path_safety: false,
reserved_key_shapes: false,
ignored_keys: &[],
structure: None,
edge_defaults: BTreeMap::new(),
}
}
}
impl Profile {
pub fn obsidian() -> Self {
Profile {
label_from: LabelFrom::Type,
default_label: None,
metadata_type_label: false,
folder_label: true,
fallback_label: VAULT_DEFAULT_LABEL.to_string(),
id_scheme: IdScheme::FrontmatterOrStem,
native_collections: true,
require_frontmatter: false,
store_body: true,
wikilinks: true,
link_edge_props: true,
embeds: true,
inline_tags: true,
frontmatter_edges: true,
alias_resolution: true,
folder_notes: true,
index_as_folder_metadata: false,
skip_log_files: false,
infer_temporal: true,
attachments: true,
path_safety: true,
reserved_key_shapes: true,
ignored_keys: VAULT_IGNORED_KEYS,
hubs: default_hubs(true),
..Profile::default()
}
}
pub fn for_dialect(dialect: Dialect) -> Self {
let base = match dialect {
Dialect::Okf | Dialect::Loose => Profile::default(),
Dialect::Obsidian => Profile::obsidian(),
};
Profile {
wikilinks: dialect.wikilinks(),
..base
}
}
}
fn default_hubs(case_insensitive: bool) -> BTreeMap<String, HubSpec> {
BTreeMap::from([(
"tags".to_string(),
HubSpec {
label: TAG_LABEL.to_string(),
edge: TAGGED_CONN_TYPE.to_string(),
case_insensitive,
},
)])
}
pub(crate) fn vault_builtin_hub(key: &str) -> Option<HubSpec> {
default_hubs(true).get(key).cloned()
}
const VAULT_IGNORED_KEYS: &[&str] = &["cssclasses"];
#[derive(Debug, Clone)]
pub struct BuildOptions {
pub dialect: Dialect,
pub profile: Profile,
pub require_frontmatter: bool,
pub respect_skip: bool,
pub skip_dirs: Vec<String>,
pub with_body: bool,
}
impl Default for BuildOptions {
fn default() -> Self {
BuildOptions {
dialect: Dialect::Okf,
profile: Profile::default(),
require_frontmatter: true,
respect_skip: true,
skip_dirs: Vec::new(),
with_body: false,
}
}
}
impl BuildOptions {
pub fn for_dialect(dialect: Dialect) -> Self {
let profile = Profile::for_dialect(dialect);
BuildOptions {
dialect,
require_frontmatter: profile.require_frontmatter,
with_body: profile.store_body,
profile,
..BuildOptions::default()
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BuildReport {
pub files_scanned: usize,
pub concepts: usize,
pub nodes_by_label: BTreeMap<String, usize>,
pub edges_by_type: BTreeMap<String, usize>,
pub dangling: usize,
pub folder_notes: usize,
pub missing_attachments: usize,
pub ambiguous_attachments: usize,
pub embed_targets: Vec<(String, String)>,
pub indexes_declared: usize,
pub text_indexes_built: usize,
pub skills_imported: usize,
pub recipes_imported: usize,
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
impl BuildReport {
pub fn is_ok(&self, strict: bool) -> bool {
self.errors.is_empty() && (!strict || self.warnings.is_empty())
}
pub fn render(&self) -> String {
let mut out = String::new();
let counted = |map: &BTreeMap<String, usize>| {
if map.is_empty() {
"none".to_string()
} else {
map.iter()
.map(|(k, v)| format!("{k} {v}"))
.collect::<Vec<_>>()
.join(", ")
}
};
out.push_str(&format!("files scanned: {}\n", self.files_scanned));
out.push_str(&format!("concepts: {}\n", self.concepts));
out.push_str(&format!("nodes: {}\n", counted(&self.nodes_by_label)));
out.push_str(&format!("edges: {}\n", counted(&self.edges_by_type)));
out.push_str(&format!("dangling links: {}\n", self.dangling));
out.push_str(&format!("folder notes: {}\n", self.folder_notes));
out.push_str(&format!(
"missing attachments: {} ({} ambiguous)\n",
self.missing_attachments, self.ambiguous_attachments
));
out.push_str(&format!("indexes declared: {}\n", self.indexes_declared));
out.push_str(&format!(
"text indexes built: {}\n",
self.text_indexes_built
));
out.push_str(&format!("skills imported: {}\n", self.skills_imported));
out.push_str(&format!("recipes imported: {}\n", self.recipes_imported));
out.push_str(&format!(
"embed targets: {}\n",
if self.embed_targets.is_empty() {
"none".to_string()
} else {
self.embed_targets
.iter()
.map(|(label, property)| format!("{label}.{property}"))
.collect::<Vec<_>>()
.join(", ")
}
));
for (name, findings) in [("errors", &self.errors), ("warnings", &self.warnings)] {
if findings.is_empty() {
out.push_str(&format!("{name}: none\n"));
continue;
}
out.push_str(&format!("{name} ({}):\n", findings.len()));
for finding in findings {
out.push_str(&format!(" - {finding}\n"));
}
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link {
pub target: String,
pub conn_type: String,
pub is_external: bool,
pub props: Vec<(String, Value)>,
pub reverse: bool,
}
impl Link {
pub(crate) fn plain(target: String, conn_type: String, is_external: bool) -> Self {
Link {
target,
conn_type,
is_external,
props: Vec::new(),
reverse: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachmentRef {
pub target: String,
pub alt: Option<String>,
pub section: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ConceptDoc {
pub concept_id: String,
pub file_path: String,
pub label: String,
pub title: String,
pub props: Vec<(String, Value)>,
pub links: Vec<Link>,
pub inline_tags: Vec<String>,
pub hub_key_edges: Vec<String>,
pub attachments: Vec<AttachmentRef>,
pub errors: Vec<String>,
pub body: Option<String>,
pub(crate) derived: crate::okf::structure::Derived,
}
pub const DEFAULT_CONN_TYPE: &str = "LINKS_TO";
pub const CONTAINS_CONN_TYPE: &str = "CONTAINS";
pub const DEFAULT_LABEL: &str = "Concept";
pub const VAULT_DEFAULT_LABEL: &str = "Note";
pub const EMBEDS_CONN_TYPE: &str = "EMBEDS";
pub const FOLDER_NOTE_CONN_TYPE: &str = "CHILD_OF";
pub const TAG_LABEL: &str = "Tag";
pub const TAGGED_CONN_TYPE: &str = "TAGGED";
pub const SOURCE_LABEL: &str = "Source";
pub const FOLDER_LABEL: &str = "Folder";
pub const DEFAULT_BODY_PROPERTY: &str = "body";
pub const SKIP_KEY: &str = "kg_skip";
pub const IMAGE_LABEL: &str = "Image";
pub const ATTACHMENT_LABEL: &str = "Attachment";
pub const HAS_IMAGE_CONN_TYPE: &str = "HAS_IMAGE";
pub const HAS_ATTACHMENT_CONN_TYPE: &str = "HAS_ATTACHMENT";
pub const DEFAULT_MIME: &str = "application/octet-stream";
pub(crate) fn mime_for_extension(ext: &str) -> &'static str {
match ext {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
"avif" => "image/avif",
"heic" => "image/heic",
"ico" => "image/vnd.microsoft.icon",
"pdf" => "application/pdf",
"md" | "markdown" => "text/markdown",
"txt" => "text/plain",
"csv" => "text/csv",
"tsv" => "text/tab-separated-values",
"html" | "htm" => "text/html",
"json" => "application/json",
"yaml" | "yml" => "application/yaml",
"xml" => "application/xml",
"zip" => "application/zip",
"gz" => "application/gzip",
"doc" | "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls" | "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt" | "pptx" => {
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
}
"mp4" => "video/mp4",
"webm" => "video/webm",
"mov" => "video/quicktime",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" => "audio/ogg",
"m4a" => "audio/mp4",
"ttf" => "font/ttf",
"woff2" => "font/woff2",
_ => DEFAULT_MIME,
}
}
pub(crate) fn label_for_mime(mime: &str) -> &'static str {
match mime {
"image/png" | "image/jpeg" | "image/gif" | "image/webp" => IMAGE_LABEL,
_ => ATTACHMENT_LABEL,
}
}
pub(crate) fn extension_of(path: &str) -> String {
let file = path.rsplit('/').next().unwrap_or(path);
file.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dialect_names_map_to_variants() {
assert_eq!(Dialect::parse(None), Dialect::Okf);
assert_eq!(Dialect::parse(Some("okf")), Dialect::Okf);
assert_eq!(Dialect::parse(Some("loose")), Dialect::Loose);
assert_eq!(Dialect::parse(Some("Obsidian")), Dialect::Obsidian);
assert_eq!(Dialect::parse(Some("nonsense")), Dialect::Okf);
assert!(!Dialect::Okf.wikilinks());
assert!(Dialect::Loose.wikilinks());
assert!(Dialect::Obsidian.wikilinks());
}
#[test]
fn for_dialect_pairs_the_profile_with_the_dialect() {
let opts = BuildOptions::for_dialect(Dialect::Obsidian);
assert_eq!(opts.dialect, Dialect::Obsidian);
assert_eq!(opts.profile, Profile::obsidian());
assert_eq!(
BuildOptions::for_dialect(Dialect::Loose).profile,
Profile {
wikilinks: true,
..Profile::default()
}
);
assert!(!BuildOptions::for_dialect(Dialect::Okf).profile.wikilinks);
}
}