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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Wikilinks + block refs in CM6: `wikilinks()` extension (`[[target]]` highlighted, title-first + `path/`/`.md` escape-hatch resolution, resolved vs faint-unresolved styling, click-to-open note via `handleWikilinkClick`); `blockRefs()` extension (`^id` line-end definitions decorated, `#^id` decoded in link targets); `CodeMirrorEditor` gains `links`/`onOpenLink` props (resolution data injected from the app — editor stays Tauri-free); `Home.tsx` builds the title→path index from `VaultManager.list()` and wires link navigation; first `@trachyte/editor` vitest harness (resolver + wikilink extension specs)

- Link/heading/tag data layer: MD parser extracts `[[wikilinks]]` (char-offset positions), `#headings`, and `#tags`; `Indexer` persists them to the `headings`/`tags`/`backlinks` tables via the new `IndexDriver.storeExtracted` method; `IndexDriver.backlinks(vaultPath, target)` query returns source notes + char positions (Rust `store_extracted`/`list_backlinks`, IPC `index_store_extracted`/`index_list_backlinks`, desktop wrappers); wikilink resolver relocated into core and shared with the editor

### Changed

- CI Hardening for `ci.yml` now checks inside app/desktop to confirm build ablility
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ pub fn run() {
trachyte_ipc::commands::index_delete_file,
trachyte_ipc::commands::index_list_files,
trachyte_ipc::commands::index_rebuild,
trachyte_ipc::commands::index_store_extracted,
trachyte_ipc::commands::index_list_backlinks,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
43 changes: 42 additions & 1 deletion apps/desktop/src/ipc/db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { IndexedFileMeta } from "@trachyte/core";
import type { IndexedFileMeta, ExtractedNote } from "@trachyte/core";

export async function indexInsertFile(
vaultPath: string,
Expand Down Expand Up @@ -40,3 +40,44 @@ export interface RebuildFile {
export async function indexRebuild(vaultPath: string, files: RebuildFile[]): Promise<void> {
return invoke<void>("index_rebuild", { vaultPath, files });
}

export interface StoredLink {
targetPath: string;
positionChar: number;
}

export interface StoredHeading {
level: number;
text: string;
position: number;
}

export interface StoredTag {
tag: string;
}

export async function indexStoreExtracted(
vaultPath: string,
relPath: string,
note: ExtractedNote,
): Promise<void> {
return invoke<void>("index_store_extracted", {
vaultPath,
relPath,
links: note.links.map((l) => ({ targetPath: l.target, positionChar: l.positionChar })),
headings: note.headings,
tags: note.tags.map((tag) => ({ tag })),
});
}

export interface Backlink {
sourcePath: string;
positionChar: number;
}

export async function indexListBacklinks(
vaultPath: string,
targetPath: string,
): Promise<Backlink[]> {
return invoke<Backlink[]>("index_list_backlinks", { vaultPath, targetPath });
}
5 changes: 5 additions & 0 deletions apps/desktop/src/ipc/index-driver.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { IndexDriver } from "@trachyte/core";
import {
indexDeleteFile,
indexListBacklinks,
indexListFiles,
indexRebuild,
indexSearch,
indexStoreExtracted,
indexUpsertFile,
} from "./db.js";

export const tauriIndexDriver: IndexDriver = {
upsertFile: indexUpsertFile,
deleteFile: indexDeleteFile,
Expand All @@ -16,4 +19,6 @@ export const tauriIndexDriver: IndexDriver = {
files.map((f) => ({ relPath: f.path, content: f.content, mtime: f.mtime })),
),
search: indexSearch,
storeExtracted: indexStoreExtracted,
backlinks: indexListBacklinks,
};
216 changes: 209 additions & 7 deletions crates/trachyte-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,45 @@ pub struct FileMeta {
pub hash: String,
}

/// A wikilink found in a source note, already resolved to a target path.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredLink {
/// Vault-relative path of the link target.
pub target_path: String,
/// Char offset of the `[[` in the (frontmatter-stripped) source content.
pub position_char: i64,
}

/// A markdown heading in a source note.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredHeading {
/// Heading level (1..=6).
pub level: i64,
/// Heading text, trimmed.
pub text: String,
/// Char offset of the heading text start.
pub position: i64,
}

/// A `#tag` in a source note.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredTag {
pub tag: String,
}

/// A note that points at a given target note.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Backlink {
/// Vault-relative path of the source note.
pub source_path: String,
/// Char offset of the link in the source note.
pub position_char: i64,
}

/// A single entry fed into a transactional index rebuild.
pub struct RebuildEntry {
/// Vault-relative path of the indexed file.
Expand All @@ -43,9 +82,7 @@ pub struct Database {
}

impl Database {
/// Open (or create) an index database at `path`, applying pragmas and
/// running pending migrations.
///
/// Open (or create) an index database at `path`, applying pragmas and running pending migrations.
/// The parent directory must already exist (the vault's `.trachyte/`
/// guarantees this); it is not created.
pub fn open(path: impl AsRef<Path>) -> Result<Database, DbError> {
Expand All @@ -55,10 +92,9 @@ impl Database {
Ok(Database { conn })
}

/// Insert a file and its content into the index, returning its row id.
///
/// Insert a file and its content into the index, returning its row id
/// `hash` should be the BLAKE3 hex digest of `content`
/// (see [`hash::hash_content`]); the caller computes `size`.
/// (see [`hash::hash_content`]); the caller computes `size`
pub fn insert_file(
&self,
path: &str,
Expand Down Expand Up @@ -110,7 +146,7 @@ impl Database {
}

/// Remove a file and its FTS mirror row from the index.
/// Headings, tags, and backlinks are removed by `ON DELETE CASCADE`.
/// Headings, tags, and backlinks are removed by `ON DELETE CASCADE`
pub fn delete_file(&self, path: &str) -> Result<(), DbError> {
self.conn.execute(
"DELETE FROM content_fts WHERE path = ?1",
Expand Down Expand Up @@ -164,6 +200,88 @@ impl Database {
pub fn search(&self, query: &str) -> Result<Vec<String>, DbError> {
fts::search(&self.conn, query)
}

/// Replace a file's headings, tags, and backlink rows (delete-then-insert)
/// The file must already exist ([`Database::upsert_file`] / `rebuild`
/// guarantee this). Link targets that don't resolve to an indexed file
/// are skipped — no orphan `backlinks` row is created
pub fn store_extracted(
&self,
source_path: &str,
links: &[StoredLink],
headings: &[StoredHeading],
tags: &[StoredTag],
) -> Result<(), DbError> {
let tx = self.conn.unchecked_transaction()?;
let source_id: i64 = tx.query_row(
"SELECT id FROM files WHERE path = ?1",
rusqlite::params![source_path],
|row| row.get(0),
)?;
tx.execute(
"DELETE FROM backlinks WHERE source_file_id = ?1",
rusqlite::params![source_id],
)?;
tx.execute(
"DELETE FROM headings WHERE file_id = ?1",
rusqlite::params![source_id],
)?;
tx.execute(
"DELETE FROM tags WHERE file_id = ?1",
rusqlite::params![source_id],
)?;
{
let mut stmt = tx.prepare(
"INSERT INTO headings (file_id, level, text, position) VALUES (?1, ?2, ?3, ?4)",
)?;
for h in headings {
stmt.execute(rusqlite::params![source_id, h.level, h.text, h.position])?;
}
}
{
let mut stmt = tx.prepare("INSERT INTO tags (file_id, tag) VALUES (?1, ?2)")?;
for t in tags {
stmt.execute(rusqlite::params![source_id, t.tag])?;
}
}
{
let mut stmt = tx.prepare(
"INSERT INTO backlinks (target_file_id, source_file_id, position_char)
VALUES (?1, ?2, ?3)",
)?;
for link in links {
let target_id: Result<i64, _> = tx.query_row(
"SELECT id FROM files WHERE path = ?1",
rusqlite::params![link.target_path],
|row| row.get(0),
);
if let Ok(target_id) = target_id {
stmt.execute(rusqlite::params![target_id, source_id, link.position_char])?;
}
}
}
tx.commit()?;
Ok(())
}

/// Return all notes that link to `target_path`, ordered by source then position
pub fn list_backlinks(&self, target_path: &str) -> Result<Vec<Backlink>, DbError> {
let mut stmt = self.conn.prepare(
"SELECT f.path, b.position_char
FROM backlinks b
JOIN files f ON f.id = b.source_file_id
JOIN files t ON t.id = b.target_file_id
WHERE t.path = ?1
ORDER BY f.path, b.position_char",
)?;
let rows = stmt.query_map(rusqlite::params![target_path], |row| {
Ok(Backlink {
source_path: row.get(0)?,
position_char: row.get(1)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(DbError::from)
}
}

#[cfg(test)]
Expand All @@ -177,6 +295,13 @@ mod tests {
(dir, db)
}

fn link(target: &str, pos: i64) -> StoredLink {
StoredLink {
target_path: target.into(),
position_char: pos,
}
}

#[test]
fn insert_and_search_roundtrip() {
let (_dir, db) = open_db();
Expand Down Expand Up @@ -322,4 +447,81 @@ mod tests {
assert_eq!(db.list_meta().unwrap().len(), 1);
assert_eq!(db.search("alpha").unwrap(), vec!["Notes/A.md".to_string()]);
}

#[test]
fn store_extracted_then_list_backlinks_roundtrip() {
let (_dir, db) = open_db();
db.insert_file("Notes/A.md", "See [[Java]]", 0, 12, "h-a")
.unwrap();
db.insert_file("Notes/Java.md", "content", 0, 7, "h-j")
.unwrap();

db.store_extracted(
"Notes/A.md",
&[link("Notes/Java.md", 4)],
&[StoredHeading {
level: 1,
text: "A".into(),
position: 2,
}],
&[StoredTag { tag: "rust".into() }],
)
.unwrap();

assert_eq!(
db.list_backlinks("Notes/Java.md").unwrap(),
vec![Backlink {
source_path: "Notes/A.md".into(),
position_char: 4
}]
);
}

#[test]
fn unresolved_link_target_skipped() {
let (_dir, db) = open_db();
db.insert_file("Notes/A.md", "see [[Nope]]", 0, 12, "h-a")
.unwrap();

db.store_extracted("Notes/A.md", &[link("Notes/Nope.md", 4)], &[], &[])
.unwrap();

assert!(db.list_backlinks("Notes/Nope.md").unwrap().is_empty());
}

#[test]
fn reindex_does_not_duplicate_rows() {
let (_dir, db) = open_db();
db.insert_file("Notes/A.md", "see [[Java]]", 0, 12, "h-a")
.unwrap();
db.insert_file("Notes/Java.md", "x", 0, 1, "h-j").unwrap();

db.store_extracted("Notes/A.md", &[link("Notes/Java.md", 4)], &[], &[])
.unwrap();
db.store_extracted("Notes/A.md", &[link("Notes/Java.md", 9)], &[], &[])
.unwrap();

let links = db.list_backlinks("Notes/Java.md").unwrap();
assert_eq!(
links,
vec![Backlink {
source_path: "Notes/A.md".into(),
position_char: 9
}]
);
}

#[test]
fn delete_cascades_to_backlinks() {
let (_dir, db) = open_db();
db.insert_file("Notes/A.md", "see [[Java]]", 0, 12, "h-a")
.unwrap();
db.insert_file("Notes/Java.md", "x", 0, 1, "h-j").unwrap();
db.store_extracted("Notes/A.md", &[link("Notes/Java.md", 4)], &[], &[])
.unwrap();

db.delete_file("Notes/A.md").unwrap();

assert!(db.list_backlinks("Notes/Java.md").unwrap().is_empty());
}
}
Loading