Skip to main content

llm_wiki/ops/
content.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Result, bail};
4use serde::Serialize;
5use tantivy::{
6    Searcher, Term,
7    query::TermQuery,
8    schema::{IndexRecordOption, Value},
9};
10
11use crate::config;
12use crate::engine::EngineState;
13use crate::git;
14use crate::index_schema::IndexSchema;
15use crate::markdown;
16use crate::slug::{ReadTarget, Slug, WikiUri, resolve_read_target};
17
18/// A page that links to a given target — slug and display title.
19#[derive(Debug, Clone, Serialize)]
20pub struct BacklinkRef {
21    /// Slug of the linking page.
22    pub slug: String,
23    /// Title of the linking page.
24    pub title: String,
25}
26
27/// Query the index for all pages that contain a link to `target_slug`.
28pub fn backlinks_query(
29    searcher: &Searcher,
30    is: &IndexSchema,
31    target_slug: &str,
32) -> Result<Vec<BacklinkRef>> {
33    let f_body_links = is.field("body_links");
34    let f_slug = is.field("slug");
35    let f_title = is.field("title");
36
37    let term = Term::from_field_text(f_body_links, target_slug);
38    let query = TermQuery::new(term, IndexRecordOption::Basic);
39
40    let doc_addrs = searcher.search(&query, &tantivy::collector::DocSetCollector)?;
41
42    let mut refs: Vec<BacklinkRef> = doc_addrs
43        .into_iter()
44        .filter_map(|addr| {
45            let doc: tantivy::TantivyDocument = searcher.doc(addr).ok()?;
46            let slug = doc
47                .get_first(f_slug)
48                .and_then(|v| v.as_str())
49                .unwrap_or("")
50                .to_string();
51            let title = doc
52                .get_first(f_title)
53                .and_then(|v| v.as_str())
54                .unwrap_or("")
55                .to_string();
56            if slug.is_empty() {
57                None
58            } else {
59                Some(BacklinkRef { slug, title })
60            }
61        })
62        .collect();
63
64    refs.sort_by(|a, b| a.slug.cmp(&b.slug));
65    Ok(refs)
66}
67
68/// Return all pages linking to `target_slug` in the named wiki.
69pub fn backlinks_for(
70    engine: &EngineState,
71    wiki_name: &str,
72    target_slug: &str,
73) -> Result<Vec<BacklinkRef>> {
74    let space = engine.space(wiki_name)?;
75    let searcher = space.index_manager.searcher()?;
76    backlinks_query(&searcher, &space.index_schema, target_slug)
77}
78
79/// Result of a content read — page text, asset list, or binary asset.
80pub enum ContentReadResult {
81    /// Page markdown content (possibly with frontmatter stripped).
82    Page(String),
83    /// List of co-located asset filenames.
84    Assets(Vec<String>),
85    /// The resolved target is a binary file — read it directly from disk.
86    Binary,
87}
88
89/// Read a wiki page or list its co-located assets.
90pub fn content_read(
91    engine: &EngineState,
92    uri: &str,
93    wiki_flag: Option<&str>,
94    no_frontmatter: bool,
95    list_assets: bool,
96) -> Result<ContentReadResult> {
97    let (entry, slug) = WikiUri::resolve(uri, wiki_flag, &engine.config)?;
98    let wiki_root = engine.space(&entry.name)?.wiki_root.clone();
99
100    if list_assets {
101        let assets = markdown::list_assets(&slug, &wiki_root)?;
102        return Ok(ContentReadResult::Assets(assets));
103    }
104
105    match resolve_read_target(slug.as_str(), &wiki_root)? {
106        ReadTarget::Page(_) => {
107            let wiki_cfg = config::load_wiki(&PathBuf::from(&entry.path)).unwrap_or_default();
108            let resolved = config::resolve(&engine.config, &wiki_cfg);
109            let strip = no_frontmatter || resolved.read.no_frontmatter;
110            let content = markdown::read_page(&slug, &wiki_root, strip)?;
111            Ok(ContentReadResult::Page(content))
112        }
113        ReadTarget::Asset(parent_slug, filename) => {
114            let parent = Slug::try_from(parent_slug.as_str())?;
115            let bytes = markdown::read_asset(&parent, &filename, &wiki_root)?;
116            match String::from_utf8(bytes) {
117                Ok(text) => Ok(ContentReadResult::Page(text)),
118                Err(_) => Ok(ContentReadResult::Binary),
119            }
120        }
121    }
122}
123
124/// Result of a content write operation.
125pub struct WriteResult {
126    /// Number of bytes written to disk.
127    pub bytes_written: usize,
128    /// Absolute path of the written file.
129    pub path: PathBuf,
130}
131
132/// Write content to a wiki page identified by slug or URI.
133pub fn content_write(
134    engine: &EngineState,
135    uri: &str,
136    wiki_flag: Option<&str>,
137    content: &str,
138) -> Result<WriteResult> {
139    let (_entry, slug) = WikiUri::resolve(uri, wiki_flag, &engine.config)?;
140    let wiki_root = engine.space(&_entry.name)?.wiki_root.clone();
141    let path = markdown::write_page(slug.as_str(), content, &wiki_root)?;
142    Ok(WriteResult {
143        bytes_written: content.len(),
144        path,
145    })
146}
147
148/// Result of creating a new wiki page or section.
149pub struct ContentNewResult {
150    /// `wiki://` URI for the created page.
151    pub uri: String,
152    /// Slug of the created page.
153    pub slug: String,
154    /// Absolute filesystem path of the created file.
155    pub path: PathBuf,
156    /// Absolute path to the wiki root directory.
157    pub wiki_root: PathBuf,
158    /// True if the page was created as a bundle (folder + index.md).
159    pub bundle: bool,
160}
161
162/// Create a new wiki page or section with scaffolded frontmatter.
163pub fn content_new(
164    engine: &EngineState,
165    uri: &str,
166    wiki_flag: Option<&str>,
167    section: bool,
168    bundle: bool,
169    name: Option<&str>,
170    type_: Option<&str>,
171) -> Result<ContentNewResult> {
172    let (entry, slug) = WikiUri::resolve(uri, wiki_flag, &engine.config)?;
173    let repo_root = PathBuf::from(&entry.path);
174    let wiki_root = engine.space(&entry.name)?.wiki_root.clone();
175
176    let type_name = if section {
177        "section"
178    } else {
179        type_.unwrap_or("page")
180    };
181    let body_template = resolve_body_template(&repo_root, type_name);
182
183    let path = if section {
184        markdown::create_section(&slug, &wiki_root, body_template.as_deref())?
185    } else {
186        markdown::create_page(
187            &slug,
188            bundle,
189            &wiki_root,
190            name,
191            type_,
192            body_template.as_deref(),
193        )?
194    };
195
196    Ok(ContentNewResult {
197        uri: format!("wiki://{}/{slug}", entry.name),
198        slug: slug.as_str().to_string(),
199        path,
200        wiki_root,
201        bundle,
202    })
203}
204
205/// Resolve a body template for a type.
206/// 1. `schemas/<type>.md` in the wiki repo
207/// 2. Embedded default template
208/// 3. None
209fn resolve_body_template(repo_root: &Path, type_name: &str) -> Option<String> {
210    let template_path = repo_root.join("schemas").join(format!("{type_name}.md"));
211    if template_path.is_file() {
212        return std::fs::read_to_string(&template_path).ok();
213    }
214    crate::default_schemas::embedded_body_template(type_name).map(|s| s.to_string())
215}
216
217/// Commit specified slugs (or all uncommitted files) to git and return the commit hash.
218pub fn content_commit(
219    engine: &EngineState,
220    wiki_name: &str,
221    slugs: &[String],
222    all: bool,
223    message: Option<&str>,
224) -> Result<String> {
225    let space = engine.space(wiki_name)?;
226
227    if slugs.is_empty() && !all {
228        bail!("specify slugs or --all");
229    }
230
231    if all {
232        let msg = message.unwrap_or("commit: all");
233        return git::commit(&space.repo_root, msg);
234    }
235
236    let mut paths = Vec::new();
237    for s in slugs {
238        let slug = Slug::try_from(s.as_str())?;
239        let resolved = slug.resolve(&space.wiki_root)?;
240        if resolved.file_name() == Some(std::ffi::OsStr::new("index.md")) {
241            let bundle_dir = resolved.parent().unwrap();
242            for entry in walkdir::WalkDir::new(bundle_dir)
243                .into_iter()
244                .filter_map(|e| e.ok())
245            {
246                if entry.path().is_file() {
247                    paths.push(entry.path().to_path_buf());
248                }
249            }
250        } else {
251            paths.push(resolved);
252        }
253    }
254    let path_refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
255    let default_msg = format!("commit: {}", slugs.join(", "));
256    let msg = message.unwrap_or(&default_msg);
257    git::commit_paths(&space.repo_root, &path_refs, msg)
258}