Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 48 additions & 55 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub struct NoteContent {
pub incoming_links: Vec<String>,
pub mentions_people: Vec<String>,
pub mentioned_by: Vec<String>,
pub char_count: usize,
pub byte_count: usize,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -110,18 +110,8 @@ fn resolve_file(
return Ok(Some(f));
}

// Basename fallback: append .md if needed, then case-insensitive suffix match
let target = if file_or_docid.ends_with(".md") {
file_or_docid.to_string()
} else {
format!("{}.md", file_or_docid)
};
let target_lower = target.to_lowercase();
let all = params.store.get_all_files()?;
Ok(all.into_iter().find(|f| {
let p = f.path.to_lowercase();
p == target_lower || p.ends_with(&format!("/{}", target_lower))
}))
// Basename fallback via SQL
params.store.find_file_by_basename(file_or_docid)
}

/// Split content into (frontmatter YAML, body) parts.
Expand Down Expand Up @@ -190,7 +180,7 @@ pub fn context_read(params: &ContextParams, file_or_docid: &str) -> Result<NoteC
.filter_map(|(fid, _)| params.store.get_file_path_by_id(*fid).ok().flatten())
.collect();

let char_count = content.len();
let byte_count = content.len();
Ok(NoteContent {
path: record.path,
docid: record.docid,
Expand All @@ -202,7 +192,7 @@ pub fn context_read(params: &ContextParams, file_or_docid: &str) -> Result<NoteC
incoming_links,
mentions_people,
mentioned_by,
char_count,
byte_count,
})
}

Expand All @@ -214,9 +204,14 @@ pub fn context_list(
limit: usize,
) -> Result<Vec<NoteListItem>> {
let files = params.store.list_files(folder, tags, limit)?;
let file_ids: Vec<i64> = files.iter().map(|f| f.id).collect();
let edge_counts = params
.store
.edge_counts_for_files(&file_ids)
.unwrap_or_default();
let mut items = Vec::new();
for f in files {
let edge_count = params.store.edge_count_for_file(f.id).unwrap_or(0);
let edge_count = edge_counts.get(&f.id).copied().unwrap_or(0);
items.push(NoteListItem {
path: f.path,
docid: f.docid,
Expand Down Expand Up @@ -270,15 +265,7 @@ pub fn vault_map(params: &ContextParams) -> Result<VaultMap> {

/// Build a person context bundle: note content, mentions, wikilink connections.
pub fn context_who(params: &ContextParams, name: &str) -> Result<PersonContext> {
let name_md = format!("{}.md", name);
let name_lower = name_md.to_lowercase();
let all_files = params.store.get_all_files()?;
let person_file = all_files.iter().find(|f| {
let basename = f.path.rsplit('/').next().unwrap_or(&f.path).to_lowercase();
basename == name_lower
});

let (note, person_id) = if let Some(pf) = person_file {
let (note, person_id) = if let Some(pf) = resolve_file(params, name)? {
let n = context_read(params, &pf.path)?;
(Some(n), Some(pf.id))
} else {
Expand Down Expand Up @@ -323,7 +310,7 @@ pub fn context_who(params: &ContextParams, name: &str) -> Result<PersonContext>
}
}

let total_chars = note.as_ref().map(|n| n.char_count).unwrap_or(0)
let total_chars = note.as_ref().map(|n| n.byte_count).unwrap_or(0)
+ mentioned_in.iter().map(|m| m.snippet.len()).sum::<usize>();

Ok(PersonContext {
Expand Down Expand Up @@ -364,38 +351,23 @@ fn get_mention_snippet(params: &ContextParams, file_id: i64, name: &str) -> Stri

/// Build a project context bundle: note, child notes, tasks, team, recent mentions.
pub fn context_project(params: &ContextParams, name: &str) -> Result<ProjectContext> {
let name_md = format!("{}.md", name);
let name_lower = name_md.to_lowercase();
let all_files = params.store.get_all_files()?;
let project_file = all_files.iter().find(|f| {
let basename = f.path.rsplit('/').next().unwrap_or(&f.path).to_lowercase();
basename == name_lower
});

let (note, project_id, project_folder) = if let Some(pf) = project_file {
let n = context_read(params, &pf.path)?;
let (note, project_id, project_folder) = if let Some(pf) = resolve_file(params, name)? {
let folder = pf.path.rsplit_once('/').map(|(f, _)| f.to_string());
let n = context_read(params, &pf.path)?;
(Some(n), Some(pf.id), folder)
} else {
(None, None, None)
};

let mut child_ids = HashSet::new();
let mut child_notes = Vec::new();
let mut child_records: Vec<crate::store::FileRecord> = Vec::new();

// Files in same folder
if let Some(folder) = &project_folder {
let folder_files = params.store.list_files(Some(folder), &[], 50)?;
for f in folder_files {
if Some(f.id) != project_id && child_ids.insert(f.id) {
let ec = params.store.edge_count_for_file(f.id).unwrap_or(0);
child_notes.push(NoteListItem {
path: f.path,
docid: f.docid,
tags: f.tags,
indexed_at: f.indexed_at,
edge_count: ec,
});
child_records.push(f);
}
}
}
Expand All @@ -407,18 +379,31 @@ pub fn context_project(params: &ContextParams, name: &str) -> Result<ProjectCont
if child_ids.insert(*fid)
&& let Some(f) = params.store.get_file_by_id(*fid).ok().flatten()
{
let ec = params.store.edge_count_for_file(*fid).unwrap_or(0);
child_notes.push(NoteListItem {
path: f.path,
docid: f.docid,
tags: f.tags,
indexed_at: f.indexed_at,
edge_count: ec,
});
child_records.push(f);
}
}
}

// Batch edge counts for all children
let child_file_ids: Vec<i64> = child_records.iter().map(|f| f.id).collect();
let edge_counts = params
.store
.edge_counts_for_files(&child_file_ids)
.unwrap_or_default();
let child_notes: Vec<NoteListItem> = child_records
.into_iter()
.map(|f| {
let ec = edge_counts.get(&f.id).copied().unwrap_or(0);
NoteListItem {
path: f.path,
docid: f.docid,
tags: f.tags,
indexed_at: f.indexed_at,
edge_count: ec,
}
})
.collect();

// Active tasks
let mut active_tasks = Vec::new();
let scan_tasks = |path: &str, tasks: &mut Vec<TaskItem>| {
Expand Down Expand Up @@ -483,7 +468,15 @@ pub fn context_project(params: &ContextParams, name: &str) -> Result<ProjectCont
}
}

let total_chars = note.as_ref().map(|n| n.char_count).unwrap_or(0);
let total_chars = note.as_ref().map(|n| n.byte_count).unwrap_or(0)
+ child_notes
.iter()
.filter_map(|c| {
let full = params.vault_path.join(&c.path);
std::fs::metadata(&full).ok().map(|m| m.len() as usize)
})
.sum::<usize>()
+ active_tasks.iter().map(|t| t.text.len()).sum::<usize>();

Ok(ProjectContext {
name: name.to_string(),
Expand Down Expand Up @@ -700,7 +693,7 @@ mod tests {
assert!(note.tags.contains(&"rust".to_string()));
assert_eq!(note.outgoing_links.len(), 1);
assert_eq!(note.incoming_links.len(), 1);
assert!(note.char_count > 0);
assert!(note.byte_count > 0);
}

#[test]
Expand Down
16 changes: 2 additions & 14 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,19 +396,7 @@ fn main() -> Result<()> {
} else if let Some(f) = store.get_file(&file)? {
Some(f)
} else {
// Basename match (case-insensitive)
let target = if file.ends_with(".md") {
file.clone()
} else {
format!("{}.md", file)
};
let all = store.get_all_files()?;
let target_lower = target.to_lowercase();
all.into_iter().find(|f| {
let path_lower = f.path.to_lowercase();
path_lower == target_lower
|| path_lower.ends_with(&format!("/{}", target_lower))
})
store.find_file_by_basename(&file)?
};

let record = match record {
Expand Down Expand Up @@ -543,7 +531,7 @@ fn main() -> Result<()> {
println!("Tags: {}", note.tags.join(", "));
println!("Outgoing links: {}", note.outgoing_links.len());
println!("Incoming links: {}", note.incoming_links.len());
println!("Chars: {}\n", note.char_count);
println!("Bytes: {}\n", note.byte_count);
println!("{}", note.body);
}
}
Expand Down
108 changes: 108 additions & 0 deletions src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,75 @@ impl Store {
)?;
Ok(count as usize)
}

/// Get edge counts for multiple files in a single query.
pub fn edge_counts_for_files(
&self,
file_ids: &[i64],
) -> Result<std::collections::HashMap<i64, usize>> {
use std::collections::HashMap;
if file_ids.is_empty() {
return Ok(HashMap::new());
}
let placeholders: Vec<String> = file_ids.iter().map(|_| "?".to_string()).collect();
let ph = placeholders.join(",");
let sql = format!(
"SELECT fid, COUNT(*) FROM (
SELECT from_file AS fid FROM edges WHERE from_file IN ({ph})
UNION ALL
SELECT to_file AS fid FROM edges WHERE to_file IN ({ph})
) GROUP BY fid"
);
let mut stmt = self.conn.prepare(&sql)?;
let params: Vec<Box<dyn rusqlite::types::ToSql>> = file_ids
.iter()
.chain(file_ids.iter())
.map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>)
.collect();
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)? as usize))
})?;
let mut map = HashMap::new();
for row in rows {
let (id, count) = row?;
map.insert(id, count);
}
Ok(map)
}

/// Find a file by case-insensitive basename match. Returns first match (shortest path).
pub fn find_file_by_basename(&self, basename: &str) -> Result<Option<FileRecord>> {
let target = if basename.ends_with(".md") {
basename.to_string()
} else {
format!("{}.md", basename)
};
// Try exact path first
if let Some(f) = self.get_file(&target)? {
return Ok(Some(f));
}
// Basename match via SQL
let mut stmt = self.conn.prepare(
"SELECT id, path, content_hash, mtime, tags, indexed_at, docid FROM files
WHERE lower(path) LIKE '%/' || lower(?1) OR lower(path) = lower(?1)
ORDER BY length(path) ASC LIMIT 1",
)?;
let mut rows = stmt.query_map(params![target], |row| {
Ok(FileRecord {
id: row.get(0)?,
path: row.get(1)?,
content_hash: row.get(2)?,
mtime: row.get(3)?,
tags: parse_tags(&row.get::<_, String>(4)?),
indexed_at: row.get(5)?,
docid: row.get(6)?,
})
})?;
match rows.next() {
Some(r) => Ok(Some(r?)),
None => Ok(None),
}
}
}

fn parse_tags(json: &str) -> Vec<String> {
Expand Down Expand Up @@ -1527,4 +1596,43 @@ mod tests {
assert_eq!(store.edge_count_for_file(f1).unwrap(), 2);
assert_eq!(store.edge_count_for_file(f2).unwrap(), 2);
}

#[test]
fn test_find_file_by_basename() {
let store = Store::open_memory().unwrap();
store
.insert_file("01-Projects/Work/note.md", "h1", 100, &[], "aaa111")
.unwrap();
store
.insert_file("root.md", "h2", 100, &[], "bbb222")
.unwrap();

let found = store.find_file_by_basename("note").unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().path, "01-Projects/Work/note.md");

let found = store.find_file_by_basename("note.md").unwrap();
assert!(found.is_some());

let found = store.find_file_by_basename("nonexistent").unwrap();
assert!(found.is_none());
}

#[test]
fn test_edge_counts_for_files() {
let store = Store::open_memory().unwrap();
let f1 = store.insert_file("a.md", "h1", 100, &[], "a1").unwrap();
let f2 = store.insert_file("b.md", "h2", 100, &[], "b2").unwrap();
let f3 = store.insert_file("c.md", "h3", 100, &[], "c3").unwrap();
store.insert_edge(f1, f2, "wikilink").unwrap();
store.insert_edge(f2, f1, "wikilink").unwrap();
store.insert_edge(f1, f3, "wikilink").unwrap();
let counts = store.edge_counts_for_files(&[f1, f2, f3]).unwrap();
assert_eq!(*counts.get(&f1).unwrap(), 3);
assert_eq!(*counts.get(&f2).unwrap(), 2);
assert_eq!(*counts.get(&f3).unwrap(), 1);
// Empty input returns empty map
let empty = store.edge_counts_for_files(&[]).unwrap();
assert!(empty.is_empty());
}
}
Loading