1use super::error::LoreError;
7use super::{db, file_loader, graph::WikiGraph, link_parser, search};
8use crate::state::AppState;
9use crate::types::{VaultInfo, PageData, BacklinkEntry, PageListEntry};
10use rusqlite::Connection;
11use std::collections::HashSet;
12use std::path::Path;
13
14fn resolve_link_slug(short_slug: &str, known_slugs: &HashSet<String>) -> String {
21 if known_slugs.contains(short_slug) {
23 return short_slug.to_string();
24 }
25
26 let suffix = format!("/{short_slug}");
28 let matches: Vec<&String> = known_slugs
29 .iter()
30 .filter(|s| s.ends_with(&suffix))
31 .collect();
32
33 if matches.len() == 1 {
34 return matches[0].clone();
35 }
36
37 short_slug.to_string()
39}
40
41fn get_known_slugs(conn: &Connection) -> Result<HashSet<String>, LoreError> {
43 db::get_all_slugs(conn)
44}
45
46pub(crate) fn get_known_slugs_from(conn: &Connection) -> Result<HashSet<String>, LoreError> {
48 get_known_slugs(conn)
49}
50
51fn validate_relative_path(path: &str) -> Result<(), LoreError> {
55 let p = Path::new(path);
56 if p.is_absolute() {
57 return Err(LoreError::UnsafePath(path.to_string()));
58 }
59 for component in p.components() {
60 if matches!(component, std::path::Component::ParentDir) {
61 return Err(LoreError::UnsafePath(path.to_string()));
62 }
63 }
64 Ok(())
65}
66
67pub(crate) fn sync_page_links(
74 conn: &Connection,
75 source_slug: &str,
76 title: &str,
77 content: &str,
78 known_slugs: &HashSet<String>,
79) -> Result<Vec<String>, LoreError> {
80 let wiki_links = link_parser::extract_links(content);
81
82 let resolved: Vec<(String, &link_parser::WikiLink)> = wiki_links
84 .iter()
85 .map(|wl| (resolve_link_slug(&wl.slug, known_slugs), wl))
86 .collect();
87
88 db::delete_links_from(conn, source_slug)?;
89
90 let link_rows: Vec<db::LinkRow> = resolved
91 .iter()
92 .map(|(slug, wl)| db::LinkRow {
93 source_slug: source_slug.to_string(),
94 target_slug: slug.clone(),
95 alias: wl.alias.clone(),
96 line_number: Some(i32::try_from(wl.line_number).unwrap_or(i32::MAX)),
97 })
98 .collect();
99
100 for (slug, wl) in &resolved {
102 if db::get_page(conn, slug)?.is_none() {
103 db::upsert_page(conn, slug, &wl.target, None, None, true)?;
104 }
105 }
106
107 db::insert_links(conn, &link_rows)?;
108 db::update_fts(conn, source_slug, title, content)?;
109
110 let target_slugs: Vec<String> = resolved.into_iter().map(|(slug, _)| slug).collect();
111 Ok(target_slugs)
112}
113
114pub fn open_vault(folder_path: &Path, state: &AppState) -> Result<VaultInfo, LoreError> {
117 let conn = db::open_db(folder_path)?;
118
119 let scanned = file_loader::scan_folder(folder_path)?;
121
122 let existing_hashes = db::get_all_hashes(&conn)?;
124 let (changed, deleted) = file_loader::diff_scan(&scanned, &existing_hashes);
125
126 conn.execute_batch("BEGIN TRANSACTION")?;
127
128 for slug in &deleted {
129 db::delete_page(&conn, slug)?;
130 log::info!("Deleted page: {slug}");
131 }
132
133 for file in &changed {
135 db::upsert_page(
136 &conn,
137 &file.slug,
138 &file.title,
139 Some(&file.relative_path),
140 Some(&file.content_hash),
141 false,
142 )?;
143 }
144
145 let known_slugs = get_known_slugs(&conn)?;
147
148 for file in &changed {
150 sync_page_links(&conn, &file.slug, &file.title, &file.content, &known_slugs)?;
151 }
152
153 conn.execute_batch("COMMIT")?;
154
155 let all_pages = db::get_all_pages(&conn)?;
157 let mut graph = WikiGraph::new();
158
159 for page in &all_pages {
160 graph.upsert_node(&page.slug, &page.title, page.is_placeholder);
161 }
162
163 for page in &all_pages {
164 let targets = db::get_outgoing_targets(&conn, &page.slug)?;
165 if !targets.is_empty() {
166 graph.set_outgoing_edges(&page.slug, &targets);
167 }
168 }
169
170 let orphans = graph.cleanup_orphaned_placeholders();
171 for slug in &orphans {
172 if let Err(e) = db::delete_page(&conn, slug) {
173 log::warn!("Failed to clean up orphan placeholder '{slug}': {e}");
174 }
175 }
176
177 let (node_count, edge_count) = graph.stats();
178 let vault_path_str = folder_path.to_string_lossy().into_owned();
179
180 log::info!("Vault opened: {vault_path_str} ({node_count} pages, {edge_count} links)");
181
182 *state.vault_path.lock()? = Some(folder_path.to_path_buf());
184 *state.db.lock()? = Some(conn);
185 *state.graph.lock()? = graph;
186
187 Ok(VaultInfo {
188 path: vault_path_str,
189 page_count: node_count,
190 link_count: edge_count,
191 })
192}
193
194pub fn load_page(slug: &str, state: &AppState) -> Result<PageData, LoreError> {
196 let db_lock = state.db.lock()?;
197 let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
198 let vault_lock = state.vault_path.lock()?;
199 let vault_path = vault_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
200
201 let page = if let Some(p) = db::get_page(conn, slug)? { p } else {
203 let known_slugs = get_known_slugs(conn)?;
204 let resolved = resolve_link_slug(slug, &known_slugs);
205 db::get_page(conn, &resolved)?
206 .ok_or_else(|| LoreError::PageNotFound(slug.to_string()))?
207 };
208
209 let content = if let Some(ref fp) = page.file_path {
210 let full_path = vault_path.join(fp);
211 std::fs::read_to_string(&full_path)
212 .map(|c| c.replace("\r\n", "\n"))?
213 } else {
214 String::new()
215 };
216
217 let backlink_pages = db::get_backlinks(conn, &page.slug)?;
218 let backlinks = backlink_pages
219 .into_iter()
220 .map(|p| BacklinkEntry {
221 slug: p.slug,
222 title: p.title,
223 })
224 .collect();
225
226 Ok(PageData {
227 slug: page.slug,
228 title: page.title,
229 content,
230 backlinks,
231 is_placeholder: page.is_placeholder,
232 })
233}
234
235pub fn save_page(slug: &str, content: &str, state: &AppState) -> Result<(), LoreError> {
240 let db_lock = state.db.lock()?;
241 let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
242 let vault_lock = state.vault_path.lock()?;
243 let vault_path = vault_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
244
245 let page = db::get_page(conn, slug)?;
246
247 let effective_title = link_parser::extract_h1(content)
249 .unwrap_or_else(|| {
250 page.as_ref().map_or_else(
251 || link_parser::title_from_slug(slug),
252 |p| p.title.clone(),
253 )
254 });
255
256 let file_path = match &page {
258 Some(p) if !p.is_placeholder => {
259 let fp = p.file_path
260 .as_ref()
261 .ok_or_else(|| LoreError::PlaceholderPage(slug.to_string()))?
262 .clone();
263 let full_path = vault_path.join(&fp);
264 std::fs::write(&full_path, content)?;
265 fp
266 }
267 _ => {
268 let file_name = format!("{}.md", file_loader::sanitize_filename(&effective_title));
270 let full_path = vault_path.join(&file_name);
271 std::fs::write(&full_path, content)?;
272 file_name
273 }
274 };
275
276 conn.execute_batch("BEGIN TRANSACTION")?;
278
279 let tx_result = (|| -> Result<Vec<String>, LoreError> {
280 let hash = file_loader::content_hash(content);
281 db::upsert_page(conn, slug, &effective_title, Some(&file_path), Some(&hash), false)?;
282 db::update_fts(conn, slug, &effective_title, content)?;
283
284 let known_slugs = get_known_slugs(conn)?;
285 let target_slugs = sync_page_links(conn, slug, &effective_title, content, &known_slugs)?;
286
287 Ok(target_slugs)
288 })();
289
290 match tx_result {
291 Ok(target_slugs) => {
292 conn.execute_batch("COMMIT")?;
293
294 let mut graph = state.graph.lock()?;
296 graph.upsert_node(slug, &effective_title, false);
297
298 for resolved_slug in &target_slugs {
299 if graph.degree(resolved_slug) == 0 {
300 if let Some(p) = db::get_page(conn, resolved_slug)? {
301 graph.upsert_node(resolved_slug, &p.title, p.is_placeholder);
302 }
303 }
304 }
305
306 graph.set_outgoing_edges(slug, &target_slugs);
307
308 let orphans = graph.cleanup_orphaned_placeholders();
309 drop(graph);
310 for orphan_slug in &orphans {
311 if let Err(e) = db::delete_page(conn, orphan_slug) {
312 log::warn!("Failed to clean up orphan placeholder '{orphan_slug}': {e}");
313 }
314 }
315 }
316 Err(e) => {
317 let _ = conn.execute_batch("ROLLBACK");
318 return Err(e);
319 }
320 }
321
322 log::info!("Saved page: {slug}");
323 Ok(())
324}
325
326pub fn create_page(title: &str, folder: &str, state: &AppState) -> Result<PageData, LoreError> {
332 let db_lock = state.db.lock()?;
333 let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
334 let vault_lock = state.vault_path.lock()?;
335 let vault_path = vault_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
336
337 let title_slug = link_parser::slugify(title);
338 if title_slug.is_empty() {
339 return Err(LoreError::InvalidSlug(title.to_string()));
340 }
341
342 if !folder.is_empty() {
344 validate_relative_path(folder)?;
345 }
346
347 let safe_name = file_loader::sanitize_filename(title);
348 let (slug, file_name, full_path) = if folder.is_empty() {
349 let slug = title_slug;
350 let file_name = format!("{safe_name}.md");
351 let full_path = vault_path.join(&file_name);
352 (slug, file_name, full_path)
353 } else {
354 let slug = format!("{folder}/{title_slug}");
355 let file_name = format!("{folder}/{safe_name}.md");
356 let dir = vault_path.join(folder);
357 std::fs::create_dir_all(&dir)?;
358 let full_path = dir.join(format!("{safe_name}.md"));
359 (slug, file_name, full_path)
360 };
361
362 let initial_content = format!("# {title}\n\n");
363 std::fs::write(&full_path, &initial_content)?;
364
365 let hash = file_loader::content_hash(&initial_content);
366 db::upsert_page(conn, &slug, title, Some(&file_name), Some(&hash), false)?;
367 db::update_fts(conn, &slug, title, &initial_content)?;
368
369 let mut graph = state.graph.lock()?;
370 graph.upsert_node(&slug, title, false);
371
372 log::info!("Created page: {slug}");
373
374 Ok(PageData {
375 slug,
376 title: title.to_string(),
377 content: initial_content,
378 backlinks: Vec::new(),
379 is_placeholder: false,
380 })
381}
382
383pub fn get_backlinks_list(slug: &str, state: &AppState) -> Result<Vec<BacklinkEntry>, LoreError> {
385 let db_lock = state.db.lock()?;
386 let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
387
388 let pages = db::get_backlinks(conn, slug)?;
389 Ok(pages
390 .into_iter()
391 .map(|p| BacklinkEntry {
392 slug: p.slug,
393 title: p.title,
394 })
395 .collect())
396}
397
398pub fn search_vault(query: &str, limit: usize, state: &AppState) -> Result<Vec<search::SearchResult>, LoreError> {
400 let db_lock = state.db.lock()?;
401 let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
402 search::search_pages(conn, query, limit)
403}
404
405pub fn search_titles(query: &str, state: &AppState) -> Result<Vec<search::SearchResult>, LoreError> {
407 let db_lock = state.db.lock()?;
408 let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
409 search::search_titles(conn, query, 20)
410}
411
412pub fn get_page_list(state: &AppState) -> Result<Vec<PageListEntry>, LoreError> {
414 let db_lock = state.db.lock()?;
415 let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
416
417 let pages = db::get_all_pages(conn)?;
418 Ok(pages
419 .into_iter()
420 .map(|p| PageListEntry {
421 slug: p.slug,
422 title: p.title,
423 is_placeholder: p.is_placeholder,
424 file_path: p.file_path,
425 })
426 .collect())
427}
428
429pub fn find_unlinked_mentions(slug: &str, state: &AppState) -> Result<Vec<BacklinkEntry>, LoreError> {
431 let db_lock = state.db.lock()?;
432 let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
433
434 let page = db::get_page(conn, slug)?
435 .ok_or_else(|| LoreError::PageNotFound(slug.to_string()))?;
436
437 let fts_results = search::search_pages(conn, &page.title, 100)?;
439
440 let backlinks = db::get_backlinks(conn, slug)?;
442 let linked_slugs: HashSet<String> = backlinks.into_iter().map(|p| p.slug).collect();
443
444 let mentions: Vec<BacklinkEntry> = fts_results
445 .into_iter()
446 .filter(|r| r.slug != slug && !linked_slugs.contains(&r.slug))
447 .map(|r| BacklinkEntry {
448 slug: r.slug,
449 title: r.title,
450 })
451 .collect();
452
453 Ok(mentions)
454}
455
456pub fn get_graph_data(
458 state: &AppState,
459) -> Result<(Vec<super::graph::GraphNode>, Vec<super::graph::GraphEdge>), LoreError> {
460 let graph = state.graph.lock()?;
461 Ok(graph.get_full_graph())
462}
463
464pub fn rename_page(old_slug: &str, new_title: &str, state: &AppState) -> Result<(), LoreError> {
466 let db_lock = state.db.lock()?;
467 let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
468 let vault_lock = state.vault_path.lock()?;
469 let vault_path = vault_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
470
471 let page = db::get_page(conn, old_slug)?
472 .ok_or_else(|| LoreError::PageNotFound(old_slug.to_string()))?;
473
474 let title_slug = link_parser::slugify(new_title);
475 if title_slug.is_empty() {
476 return Err(LoreError::InvalidSlug(new_title.to_string()));
477 }
478 let new_slug = if let Some(slash_pos) = old_slug.rfind('/') {
480 format!("{}/{}", &old_slug[..slash_pos], title_slug)
481 } else {
482 title_slug
483 };
484
485 let backlink_pages = db::get_backlinks(conn, old_slug)?;
486
487 let new_file_path = if let Some(ref old_fp) = page.file_path {
488 let old_full = vault_path.join(old_fp);
489 let new_file_name = format!("{}.md", file_loader::sanitize_filename(new_title));
490 let new_full = old_full
491 .parent()
492 .unwrap_or(vault_path)
493 .join(&new_file_name);
494
495 std::fs::rename(&old_full, &new_full)?;
496
497 let relative = new_full
498 .strip_prefix(vault_path)
499 .unwrap_or(&new_full)
500 .to_string_lossy()
501 .replace('\\', "/");
502 Some(relative)
503 } else {
504 None
505 };
506
507 db::rename_page(conn, old_slug, &new_slug, new_title, new_file_path.as_deref())?;
508
509 db::delete_fts(conn, old_slug)?;
510 if let Some(ref fp) = new_file_path {
511 let full_path = vault_path.join(fp);
512 match std::fs::read_to_string(&full_path) {
513 Ok(content) => db::update_fts(conn, &new_slug, new_title, &content)?,
514 Err(e) => log::warn!("Could not read renamed file for FTS update: {e}"),
515 }
516 }
517
518 let mut graph = state.graph.lock()?;
519 let forward = graph.get_forward_links(old_slug);
520 graph.remove_node(old_slug);
521 graph.upsert_node(&new_slug, new_title, false);
522 graph.set_outgoing_edges(&new_slug, &forward);
523
524 let known_slugs = get_known_slugs(conn)?;
527 for bp in &backlink_pages {
528 if let Some(ref fp) = bp.file_path {
529 let full_path = vault_path.join(fp);
530 if let Ok(content) = std::fs::read_to_string(&full_path) {
531 let updated = link_parser::replace_wikilinks(&content, old_slug, new_title);
532 if updated != content {
533 if let Err(e) = std::fs::write(&full_path, &updated) {
534 log::error!("Failed to rewrite links in {fp}: {e}");
535 continue;
536 }
537 sync_page_links(conn, &bp.slug, &bp.title, &updated, &known_slugs)?;
539 db::update_fts(conn, &bp.slug, &bp.title, &updated)?;
540 if let Ok(targets) = db::get_outgoing_targets(conn, &bp.slug) {
542 graph.set_outgoing_edges(&bp.slug, &targets);
543 }
544 }
545 }
546 }
547 }
548
549 log::info!("Renamed page: {old_slug} -> {new_slug}");
550 Ok(())
551}
552
553pub fn delete_page(slug: &str, state: &AppState) -> Result<(), LoreError> {
555 let db_lock = state.db.lock()?;
556 let conn = db_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
557 let vault_lock = state.vault_path.lock()?;
558 let vault_path = vault_lock.as_ref().ok_or(LoreError::VaultNotSet)?;
559
560 let page = db::get_page(conn, slug)?
561 .ok_or_else(|| LoreError::PageNotFound(slug.to_string()))?;
562
563 if let Some(ref fp) = page.file_path {
564 let full_path = vault_path.join(fp);
565 if full_path.exists() {
566 trash::delete(&full_path)
567 .map_err(|e| LoreError::Io(std::io::Error::other(e.to_string())))?;
568 }
569 }
570
571 let backlinks = db::get_backlinks(conn, slug)?;
572
573 conn.execute_batch("BEGIN TRANSACTION")?;
575
576 let tx_result = if backlinks.is_empty() {
577 db::delete_page(conn, slug)
578 } else {
579 (|| -> Result<(), LoreError> {
580 db::upsert_page(conn, slug, &page.title, None, None, true)?;
581 db::delete_links_from(conn, slug)?;
582 db::delete_fts(conn, slug)?;
583 Ok(())
584 })()
585 };
586
587 match tx_result {
588 Ok(()) => {
589 conn.execute_batch("COMMIT")?;
590
591 if backlinks.is_empty() {
593 let mut graph = state.graph.lock()?;
594 graph.remove_node(slug);
595 } else {
596 let mut graph = state.graph.lock()?;
597 graph.upsert_node(slug, &page.title, true);
598 graph.set_outgoing_edges(slug, &[]);
599
600 let orphans = graph.cleanup_orphaned_placeholders();
601 drop(graph);
602 for orphan_slug in &orphans {
603 if let Err(e) = db::delete_page(conn, orphan_slug) {
604 log::warn!("Failed to clean up orphan placeholder '{orphan_slug}': {e}");
605 }
606 }
607 }
608 }
609 Err(e) => {
610 let _ = conn.execute_batch("ROLLBACK");
611 return Err(e);
612 }
613 }
614
615 log::info!("Deleted page: {slug}");
616 Ok(())
617}