use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use sculblog::html::{strip_and_extract_links, write_page_html};
use sculblog::xanadoc::{generate_xvuid, write_xanadoc_dif};
use std::os::unix::ffi::OsStrExt;
fn filename_map_path(maps_path: &Path) -> PathBuf {
maps_path.join("filename_to_xvuid.map")
}
fn canonicalize_file_path(file_path: &str) -> String {
let expanded = sculblog::html::expand_tilde_path(file_path);
match std::fs::canonicalize(&expanded) {
Ok(p) => p.to_string_lossy().to_string(),
Err(_) => expanded,
}
}
fn lookup_filename_xvuid(maps_path: &Path, canonical_path: &str) -> Option<String> {
let map_path = filename_map_path(maps_path);
let content = fs::read_to_string(&map_path).ok()?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() { continue; }
if let Some((path, xvuid)) = line.split_once('\t') {
if path == canonical_path {
return Some(xvuid.to_string());
}
}
}
None
}
fn save_filename_xvuid(maps_path: &Path, canonical_path: &str, xvuid_hex: &str) {
let map_path = filename_map_path(maps_path);
let content = fs::read_to_string(&map_path).unwrap_or_default();
let mut content = filter_filename_map(&content, canonical_path);
content.push_str(&format!("{}\t{}\n", canonical_path, xvuid_hex));
let _ = fs::write(&map_path, content);
}
fn remove_filename_entry(maps_path: &Path, canonical_path: &str) {
let map_path = filename_map_path(maps_path);
let content = match fs::read_to_string(&map_path) {
Ok(c) => c,
Err(_) => return,
};
let _ = fs::write(&map_path, filter_filename_map(&content, canonical_path));
}
fn get_word_count(md: &str) -> usize {
md.split_whitespace().count()
}
struct XdcRef {
kind: u8, def_cid: [u8; 32],
ref_start: u32,
length: u32,
}
fn read_xdc_content(file_path: &str) -> Option<Vec<u8>> {
let data = fs::read(file_path).ok()?;
unsafe {
let mut out_len: usize = 0;
let ptr = sculblog::dtob_ffi::ffi_xdc_decode_content(
data.as_ptr(), data.len(), &mut out_len
);
if ptr.is_null() || out_len == 0 { return None; }
let result = std::slice::from_raw_parts(ptr, out_len).to_vec();
sculblog::dtob_ffi::ffi_xdc_decode_content as usize; libc::free(ptr as *mut libc::c_void);
Some(result)
}
}
fn read_xdc_references(file_path: &str) -> Vec<XdcRef> {
let data = match fs::read(file_path) {
Ok(d) => d,
Err(_) => return Vec::new(),
};
unsafe {
let mut out_len: usize = 0;
let ptr = sculblog::dtob_ffi::ffi_xdc_decode_references(
data.as_ptr(), data.len(), &mut out_len
);
if ptr.is_null() || out_len < 4 { return Vec::new(); }
let buf = std::slice::from_raw_parts(ptr, out_len);
let count = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize;
let mut refs = Vec::with_capacity(count);
let mut off = 4;
for _ in 0..count {
if off + 41 > out_len { break; }
let kind = buf[off]; off += 1;
let mut def_cid = [0u8; 32];
def_cid.copy_from_slice(&buf[off..off+32]); off += 32;
let ref_start = u32::from_le_bytes([buf[off], buf[off+1], buf[off+2], buf[off+3]]); off += 4;
let length = u32::from_le_bytes([buf[off], buf[off+1], buf[off+2], buf[off+3]]); off += 4;
refs.push(XdcRef { kind, def_cid, ref_start, length });
}
libc::free(ptr as *mut libc::c_void);
refs
}
}
fn inject_reference_links(md: &[u8], refs: &[XdcRef]) -> String {
let mut sorted: Vec<&XdcRef> = refs.iter().collect();
sorted.sort_by(|a, b| b.ref_start.cmp(&a.ref_start));
let mut result = md.to_vec();
for r in sorted {
let start = r.ref_start as usize;
let end = start + r.length as usize;
if end > result.len() { continue; }
let class = match r.kind {
0 => "transclusion",
1 => "microlink",
2 => "macrolink",
_ => "link",
};
let cid_hex = hex::encode(r.def_cid);
let open_tag = format!("<a href=\"https://xanadex.com/doc?cid={}\" class=\"{}\">", cid_hex, class);
let close_tag = b"</a>";
result.splice(end..end, close_tag.iter().copied());
result.splice(start..start, open_tag.as_bytes().iter().copied());
}
String::from_utf8_lossy(&result).to_string()
}
fn check_writable(dir: &Path, name: &str) {
if !dir.exists() {
if let Err(e) = std::fs::create_dir_all(dir) {
eprintln!("Error: Cannot create {} directory at {}: {}", name, dir.display(), e);
eprintln!("To fix this, please run:");
eprintln!(" sudo mkdir -p {}", dir.display());
eprintln!(" sudo chown -R $USER {}", dir.display());
std::process::exit(2);
}
}
let c_path = std::ffi::CString::new(dir.as_os_str().as_bytes()).unwrap();
let res = unsafe { libc::access(c_path.as_ptr(), libc::W_OK) };
if res != 0 {
eprintln!("Error: Insufficient permissions to write to {} at {}", name, dir.display());
eprintln!("To fix this, please run:");
eprintln!(" sudo chown -R $USER {}", dir.display());
eprintln!(" chmod u+w {}", dir.display());
std::process::exit(2);
}
}
use sculblog::shared::{load_config, filter_filename_map};
fn post_updater(file_path: &str, subcommand: &str, xvuid_flag: Option<&str>, debug: bool, force_title_prompt: bool) {
if !Path::new(file_path).is_file() {
eprintln!("Error: Target file '{}' does not exist or is not a regular file.", file_path);
std::process::exit(1);
}
let repo_path = load_config("REPO_DIR");
check_writable(&repo_path, "REPO_DIR");
let maps_path = load_config("MAPS_DIR");
let canonical = canonicalize_file_path(file_path);
let is_xdc = file_path.ends_with(".xdc");
let (md, page_html): (String, String);
if is_xdc {
let content_bytes = read_xdc_content(file_path).unwrap_or_else(|| {
eprintln!("Error: Failed to decode xdc content from '{}'", file_path);
std::process::exit(1);
});
md = String::from_utf8_lossy(&content_bytes).to_string();
let refs = read_xdc_references(file_path);
let md_with_links = if refs.is_empty() {
md.clone()
} else {
inject_reference_links(&content_bytes, &refs)
};
let tmp = tempfile::NamedTempFile::new().expect("Failed to create temp file");
fs::write(tmp.path(), &md_with_links).expect("Failed to write temp file");
let lua_filter = std::env::current_exe().ok()
.and_then(|exe| exe.parent().and_then(|p| p.parent()).and_then(|p| p.parent()).map(|d| d.join("subsection-wrapper.lua")))
.filter(|p| p.is_file());
let lua_str = lua_filter.as_ref().map(|p| p.to_string_lossy().to_string());
page_html = write_page_html(tmp.path().to_str().unwrap(), lua_str.as_deref(), debug);
} else {
let lua_filter = std::env::current_exe().ok()
.and_then(|exe| exe.parent().and_then(|p| p.parent()).and_then(|p| p.parent()).map(|d| d.join("subsection-wrapper.lua")))
.filter(|p| p.is_file());
if lua_filter.is_none() {
eprintln!("warning: subsection-wrapper.lua not found next to binary, skipping lua filter");
}
let lua_str = lua_filter.as_ref().map(|p| p.to_string_lossy().to_string());
page_html = write_page_html(file_path, lua_str.as_deref(), debug);
md = fs::read_to_string(file_path).unwrap_or_default();
}
let word_count = get_word_count(&md);
let path = Path::new(file_path);
let _file_name = path.file_stem().unwrap().to_str().unwrap();
let (stripped_html, macrolinks, autolinks) = if is_xdc {
(page_html.clone(), Vec::new(), Vec::new())
} else {
strip_and_extract_links(&page_html)
};
let xvuid;
let is_new;
let resolved_xvuid_str: Option<String> = if let Some(x_str) = xvuid_flag {
Some(x_str.to_string())
} else {
lookup_filename_xvuid(&maps_path, &canonical)
};
if let Some(x_str) = resolved_xvuid_str {
let mut arr = [0u8; 32];
if hex::decode_to_slice(&x_str, &mut arr).is_ok() {
let dif_path = repo_path.join(format!("{}.dif", x_str));
if !dif_path.exists() {
eprintln!("Error: Resolved xvuid {} does not have a .dif file. Generating new xvuid.", x_str);
xvuid = generate_xvuid();
is_new = true;
} else {
xvuid = arr;
is_new = false;
}
} else {
eprintln!("Invalid xvuid format: {}", x_str);
std::process::exit(1);
}
} else {
xvuid = generate_xvuid();
is_new = true;
}
let title = if is_new || force_title_prompt {
use std::io::{self, Write};
print!("What is the header of the page? ");
io::stdout().flush().unwrap();
let mut t = String::new();
io::stdin().read_line(&mut t).unwrap();
let trimmed = t.trim();
let mut byte_count = 0;
let mut res = String::new();
for c in trimmed.chars() {
let len = c.len_utf8();
if byte_count + len > 140 { break; }
byte_count += len;
res.push(c);
}
res
} else {
let maps_path = load_config("MAPS_DIR");
let cache = sculblog::maps::MapsCache::load(&maps_path);
cache.lookup_title(&hex::encode(xvuid))
.unwrap_or_else(|| "Untitled".to_string())
};
let doc_timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
match subcommand {
"draft" => {
let maps_path = load_config("MAPS_DIR");
check_writable(&maps_path, "MAPS_DIR");
let md_repo_path = repo_path.join("md");
if !md_repo_path.exists() {
let _ = std::fs::create_dir_all(&md_repo_path);
}
let _ = write_xanadoc_dif(
&md_repo_path,
&maps_path,
"",
"",
xvuid,
&title,
word_count,
&md,
Vec::new(),
Vec::new(),
doc_timestamp,
debug,
false,
);
let res = write_xanadoc_dif(
&repo_path,
&maps_path,
"",
"",
xvuid,
&title,
word_count,
&stripped_html,
macrolinks,
autolinks,
doc_timestamp,
debug,
true,
);
if let Err(e) = res {
eprintln!("Error writing xanadoc dif: {}", e);
} else {
let xvuid_hex = hex::encode(xvuid);
let current_mapping = lookup_filename_xvuid(&maps_path, &canonical);
if current_mapping.as_deref() != Some(&xvuid_hex) {
save_filename_xvuid(&maps_path, &canonical, &xvuid_hex);
eprintln!("Saved filename mapping: {} -> {}", canonical, xvuid_hex);
}
println!("xvuid: {}", xvuid_hex);
}
}
_ => eprintln!("unknown subcommand {}", subcommand),
}
}
fn main() {
unsafe {
sculblog::dtob_ffi::ffi_bromberg_init();
}
let args: Vec<String> = env::args().collect();
let debug = args.iter().any(|s| s == "--debug");
let force_title_prompt = args.iter().any(|s| s == "--title");
let xvuid_flag: Option<String> = {
let mut val = None;
for i in 1..args.len() {
if args[i] == "--xvuid" && i + 1 < args.len() {
val = Some(args[i + 1].clone());
break;
}
}
val
};
let positional: Vec<&str> = {
let mut result = Vec::new();
let mut skip_next = false;
for s in &args[1..] {
if skip_next {
skip_next = false;
continue;
}
if s == "--xvuid" {
skip_next = true;
continue;
}
if s.starts_with("--") {
continue;
}
result.push(s.as_str());
}
result
};
if positional.is_empty() {
eprintln!("Usage: sculblog <command> [args...]");
eprintln!("Commands:");
eprintln!(" draft <file_path> [--xvuid <xvuid>]");
eprintln!(" cache-maps");
eprintln!(" serve:xvuid <xvuid> [--recent | <offset>]");
eprintln!(" table");
eprintln!(" resolve:ccid:xvuid <ccid>");
eprintln!(" resolve:cid:xvuid <cid>");
eprintln!(" resolve:ccid:cid <ccid>");
eprintln!(" resolve:xvuid:cid <xvuid>");
eprintln!(" resolve:xvuid:ccid <xvuid>");
eprintln!(" resolve:ccid:title <ccid>");
eprintln!(" rm:xvuid <xvuid>");
eprintln!(" rm:cid <cid>");
eprintln!(" rm:ccid <ccid>");
eprintln!(" rm:filename <file_path>");
eprintln!(" daemon");
std::process::exit(1);
}
let command = positional[0];
match command {
"daemon" => {
sculblog::daemon::run_daemon();
}
"draft" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog draft <file_path> [--xvuid <xvuid>]");
std::process::exit(1);
}
let file_path = positional[1];
post_updater(file_path, command, xvuid_flag.as_deref(), debug, force_title_prompt);
}
"cache-maps" => {
let repo_path = load_config("REPO_DIR");
let maps_path = load_config("MAPS_DIR");
check_writable(&maps_path, "MAPS_DIR");
sculblog::maps::rebuild_memcache_maps(&repo_path, &maps_path);
}
"serve:xvuid" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog serve:xvuid <xvuid> [--recent | <offset>]");
std::process::exit(1);
}
let xvuid = positional[1];
let is_recent = args.iter().any(|s| s == "--recent");
let requested_offset = if is_recent {
None
} else if positional.len() >= 3 {
positional[2].parse().ok()
} else {
None
};
let repo_path = load_config("REPO_DIR");
let maps_path = load_config("MAPS_DIR");
let cache = sculblog::maps::MapsCache::load(&maps_path);
if let Some(built) = sculblog::shared::serve_xvuid(&cache, &repo_path, xvuid, requested_offset, debug) {
use std::io::Write;
let _ = std::io::stdout().write_all(&built.current_content);
} else {
let dif_path = repo_path.join(format!("{}.dif", xvuid));
eprintln!("Failed to serve reconstructed bytes for xvuid: {}", xvuid);
eprintln!(" dif_path: {:?} (exists: {}, size: {})",
dif_path,
dif_path.exists(),
std::fs::metadata(&dif_path).map(|m| m.len()).unwrap_or(0)
);
eprintln!(" hint: re-run with --debug for detailed reconstruction trace");
std::process::exit(1);
}
}
"serve:cid" | "serve:ccid" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog {} <hash>", command);
std::process::exit(1);
}
let hash = positional[1];
let repo_path = load_config("REPO_DIR");
let maps_path = load_config("MAPS_DIR");
let cache = sculblog::maps::MapsCache::load(&maps_path);
let map_name = if command == "serve:cid" { "cid_to_xvuid" } else { "ccid_to_xvuid" };
match cache.lookup_hash(map_name, hash) {
sculblog::maps::HashLookup::Found { xvuid, index } => {
if let Some(built) = sculblog::shared::serve_xvuid(&cache, &repo_path, &xvuid, Some(index), debug) {
use std::io::Write;
let _ = std::io::stdout().write_all(&built.current_content);
} else {
eprintln!("Failed to serve {} hash: {}", command, hash);
std::process::exit(1);
}
}
sculblog::maps::HashLookup::Missing => {
eprintln!("Could not map {} {} to any known offset", command, hash);
std::process::exit(1);
}
}
}
"resolve:cid:xvuid" | "resolve:ccid:xvuid" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog {} <hash>", command);
std::process::exit(1);
}
let hash = positional[1];
let maps_path = load_config("MAPS_DIR");
let cache = sculblog::maps::MapsCache::load(&maps_path);
let map_name = if command == "resolve:cid:xvuid" { "cid_to_xvuid" } else { "ccid_to_xvuid" };
match cache.lookup_hash(map_name, hash) {
sculblog::maps::HashLookup::Found { xvuid, index: _ } => {
println!("{}", xvuid);
}
sculblog::maps::HashLookup::Missing => {
std::process::exit(1);
}
}
}
"resolve:ccid:cid" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog resolve:ccid:cid <ccid>");
std::process::exit(1);
}
let hash = positional[1];
let repo_path = load_config("REPO_DIR");
let maps_path = load_config("MAPS_DIR");
let cache = sculblog::maps::MapsCache::load(&maps_path);
match cache.lookup_hash("ccid_to_xvuid", hash) {
sculblog::maps::HashLookup::Found { xvuid, index } => {
if let Some(built) = sculblog::shared::serve_xvuid(&cache, &repo_path, &xvuid, Some(index), false) {
if let Some(ids) = xanadoc::decode_ids(&built.current_content) {
println!("{}", hex::encode(ids.cid));
}
unsafe { sculblog::dtob_ffi::ffi_dtob_free(built.target_root); }
} else {
eprintln!("Failed to parse target dif structure.");
std::process::exit(1);
}
}
sculblog::maps::HashLookup::Missing => {
std::process::exit(1);
}
}
}
"resolve:xvuid:cid" | "resolve:xvuid:ccid" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog {} <xvuid>", command);
std::process::exit(1);
}
let xvuid = positional[1];
let maps_path = load_config("MAPS_DIR");
let cache = sculblog::maps::MapsCache::load(&maps_path);
let field = if command == "resolve:xvuid:cid" { 1usize } else { 2usize };
match cache.lookup_meta_hex(xvuid, field) {
Some(val) => println!("{}", val),
None => std::process::exit(1),
}
}
"resolve:ccid:title" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog resolve:ccid:title <ccid>");
std::process::exit(1);
}
let hash = positional[1];
let maps_path = load_config("MAPS_DIR");
let cache = sculblog::maps::MapsCache::load(&maps_path);
match cache.lookup_hash("ccid_to_xvuid", hash) {
sculblog::maps::HashLookup::Found { xvuid, .. } => {
match cache.lookup_title(&xvuid) {
Some(title) => println!("{}", title),
None => std::process::exit(1),
}
}
sculblog::maps::HashLookup::Missing => std::process::exit(1),
}
}
"table" => {
let maps_path = load_config("MAPS_DIR");
sculblog::table::generate_table(&maps_path);
}
"table:xvuid" | "table:cid" | "table:ccid" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog {} <hash>", command);
std::process::exit(1);
}
let hash = positional[1];
let maps_path = load_config("MAPS_DIR");
let cache = sculblog::maps::MapsCache::load(&maps_path);
let target_xvuid = if command == "table:xvuid" {
hash.to_string()
} else {
let map_name = if command == "table:cid" { "cid_to_xvuid" } else { "ccid_to_xvuid" };
match cache.lookup_hash(map_name, hash) {
sculblog::maps::HashLookup::Found { xvuid, .. } => xvuid,
sculblog::maps::HashLookup::Missing => {
eprintln!("Could not map {} {} to any known xvuid", command, hash);
std::process::exit(1);
}
}
};
sculblog::table::generate_table_for_xvuid(&maps_path, &cache, &target_xvuid);
}
"rm:xvuid" | "remove:xvuid" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog rm:xvuid <xvuid>");
std::process::exit(1);
}
let xvuid = positional[1];
let repo_path = load_config("REPO_DIR");
let maps_path = load_config("MAPS_DIR");
sculblog::remove::remove_xvuid(&repo_path, &maps_path, xvuid);
let map_path = filename_map_path(&maps_path);
if let Ok(content) = fs::read_to_string(&map_path) {
for line in content.lines() {
if let Some((path, v)) = line.split_once('\t') {
if v == xvuid {
remove_filename_entry(&maps_path, path);
}
}
}
}
}
"rm:cid" | "remove:cid" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog rm:cid <cid>");
std::process::exit(1);
}
let hash = positional[1];
let repo_path = load_config("REPO_DIR");
let maps_path = load_config("MAPS_DIR");
sculblog::remove::remove_by_hash(&repo_path, &maps_path, "cid_to_xvuid", hash);
}
"rm:ccid" | "remove:ccid" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog rm:ccid <ccid>");
std::process::exit(1);
}
let hash = positional[1];
let repo_path = load_config("REPO_DIR");
let maps_path = load_config("MAPS_DIR");
sculblog::remove::remove_by_hash(&repo_path, &maps_path, "ccid_to_xvuid", hash);
}
"rm:filename" | "remove:filename" => {
if positional.len() < 2 {
eprintln!("Usage: sculblog rm:filename <file_path>");
std::process::exit(1);
}
let file_path = positional[1];
let canonical = canonicalize_file_path(file_path);
let maps_path = load_config("MAPS_DIR");
let repo_path = load_config("REPO_DIR");
match lookup_filename_xvuid(&maps_path, &canonical) {
Some(xvuid) => {
sculblog::remove::remove_xvuid(&repo_path, &maps_path, &xvuid);
remove_filename_entry(&maps_path, &canonical);
eprintln!("Removed filename mapping: {}", canonical);
}
None => {
eprintln!("Error: No xvuid mapping found for {}", canonical);
std::process::exit(1);
}
}
}
_ => {
eprintln!("Invalid command: {}", command);
std::process::exit(1);
}
}
}