siteforge 0.1.19

Archive websites into AI-readable local knowledge archives
Documentation
use std::collections::HashSet;
use std::fs;

use crate::archive::{list_archives, ArchiveLayout};
use crate::config::Config;
use crate::errors::{Result, SiteforgeError};
use crate::storage::{SearchResult, Storage};

const READABLE_SEARCH_PAGE_BATCH: usize = 500;

pub fn search_archives(
    config: &Config,
    archive_id: Option<&str>,
    query: &str,
    limit: usize,
) -> Result<Vec<SearchResult>> {
    let root = config.resolved_archive_root()?;
    let archive_ids = match archive_id {
        Some(id) => vec![id.to_string()],
        None => list_archives(config)?
            .into_iter()
            .map(|manifest| manifest.archive_id)
            .collect(),
    };

    let mut results = Vec::new();
    for id in archive_ids {
        let layout = ArchiveLayout::new(root.clone(), id.clone());
        if !layout.database().exists() {
            if archive_id.is_some() {
                return Err(SiteforgeError::ArchiveNotFound(id));
            }
            continue;
        }
        let storage = Storage::open(&layout.database())?;
        let mut seen_pages = HashSet::new();
        for mut result in storage.search(query, limit)? {
            if let Some(path) = &result.local_file_path {
                result.local_file_path = Some(layout.base().join(path).display().to_string());
            }
            seen_pages.insert(result.page_id.clone());
            results.push(result);
        }
        if results.len() < limit {
            results.extend(search_readable_files(
                &id,
                &layout,
                &storage,
                query,
                limit - results.len(),
                &seen_pages,
            )?);
        }
    }
    results.truncate(limit);
    Ok(results)
}

fn search_readable_files(
    archive_id: &str,
    layout: &ArchiveLayout,
    storage: &Storage,
    query: &str,
    limit: usize,
    seen_pages: &HashSet<String>,
) -> Result<Vec<SearchResult>> {
    let query_lower = query.to_ascii_lowercase();
    let mut results = Vec::new();
    let mut offset = 0usize;
    while results.len() < limit {
        let pages = storage.list_pages_page(READABLE_SEARCH_PAGE_BATCH, offset)?;
        if pages.is_empty() {
            break;
        }
        offset = offset.saturating_add(pages.len());
        for page in pages {
            if seen_pages.contains(&page.page_id) {
                continue;
            }
            let readable_path = page
                .text_path
                .as_deref()
                .or(page.markdown_path.as_deref())
                .map(|path| layout.base().join(path));
            let Some(path) = readable_path else {
                continue;
            };
            let Ok(text) = fs::read_to_string(&path) else {
                continue;
            };
            if !text.to_ascii_lowercase().contains(&query_lower) {
                continue;
            }
            results.push(SearchResult {
                archive_id: archive_id.to_string(),
                page_id: page.page_id,
                source_url: page.source_url,
                title: page.title,
                matched_heading: None,
                snippet: file_snippet(&text, query),
                local_file_path: Some(path.display().to_string()),
            });
            if results.len() >= limit {
                break;
            }
        }
    }
    Ok(results)
}

fn file_snippet(text: &str, query: &str) -> String {
    let lower = text.to_ascii_lowercase();
    let query_lower = query.to_ascii_lowercase();
    let pos = lower.find(&query_lower).unwrap_or(0);
    let start = pos.saturating_sub(80);
    let end = (pos + query.len() + 160).min(text.len());
    text.get(start..end).unwrap_or(text).replace('\n', " ")
}