generated from eblume/project-template
Add `Store::set_task_project` (heph-core + RemoteStore) and the `task.set_project` RPC: tombstone the task's existing `in-project` link(s) and add a new one (or none, to unfile). A given project id must name a live project-kind node, else InvalidArg/NodeNotFound. Route `heph edit --project` through it, fixing a duplicate-link bug (the old path added an in-project link without removing the prior one); `--project none` now unfiles. Factor a `links::tombstone` helper out of `sync_wiki_links`. Tests: core move/unfile/reject + a duplicate-link regression; a socket dispatch test. The TUI `m` gesture follows in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
245 lines
8.3 KiB
Rust
245 lines
8.3 KiB
Rust
//! `links` table operations, plus `wiki` link materialization from bodies.
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use rusqlite::{Connection, OptionalExtension, Row};
|
|
|
|
use super::{new_id, next_hlc, ops};
|
|
use crate::error::Result;
|
|
use crate::extract::extract;
|
|
use crate::model::{Link, LinkType};
|
|
use crate::oplog::op_type;
|
|
use serde_json::json;
|
|
|
|
const COLUMNS: &str = "id, src_id, dst_id, type, created_at, tombstoned";
|
|
|
|
fn from_row(row: &Row) -> rusqlite::Result<Link> {
|
|
Ok(Link {
|
|
id: row.get("id")?,
|
|
src_id: row.get("src_id")?,
|
|
dst_id: row.get("dst_id")?,
|
|
link_type: LinkType::parse(&row.get::<_, String>("type")?)
|
|
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
|
|
created_at: row.get("created_at")?,
|
|
tombstoned: row.get::<_, i64>("tombstoned")? != 0,
|
|
})
|
|
}
|
|
|
|
/// Add a typed link.
|
|
pub(super) fn add(
|
|
conn: &Connection,
|
|
owner: &str,
|
|
now: i64,
|
|
src_id: &str,
|
|
dst_id: &str,
|
|
link_type: LinkType,
|
|
) -> Result<Link> {
|
|
let link = Link {
|
|
id: new_id(),
|
|
src_id: src_id.to_string(),
|
|
dst_id: dst_id.to_string(),
|
|
link_type,
|
|
created_at: now,
|
|
tombstoned: false,
|
|
};
|
|
conn.execute(
|
|
"INSERT INTO links (id, src_id, dst_id, type, created_at, tombstoned)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, 0)",
|
|
(
|
|
&link.id,
|
|
&link.src_id,
|
|
&link.dst_id,
|
|
link.link_type.as_str(),
|
|
link.created_at,
|
|
),
|
|
)?;
|
|
let hlc = next_hlc(conn, now)?;
|
|
ops::record(
|
|
conn,
|
|
owner,
|
|
&hlc,
|
|
op_type::LINK_ADD,
|
|
&link.id,
|
|
json!({
|
|
"src": link.src_id,
|
|
"dst": link.dst_id,
|
|
"type": link.link_type.as_str(),
|
|
"created_at": link.created_at,
|
|
}),
|
|
)?;
|
|
Ok(link)
|
|
}
|
|
|
|
/// Tombstone a single link by id, recording the OR-set `link.remove` op.
|
|
/// Monotonic: re-tombstoning an already-dead link is a harmless no-op write.
|
|
pub(super) fn tombstone(conn: &Connection, owner: &str, now: i64, link_id: &str) -> Result<()> {
|
|
conn.execute("UPDATE links SET tombstoned = 1 WHERE id = ?1", [link_id])?;
|
|
let hlc = next_hlc(conn, now)?;
|
|
ops::record(conn, owner, &hlc, op_type::LINK_REMOVE, link_id, json!({}))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The destination of the first non-tombstoned link of `link_type` out of
|
|
/// `src_id`, if any (e.g. a task's canonical-context doc or its log doc).
|
|
pub(super) fn first_dst(
|
|
conn: &Connection,
|
|
src_id: &str,
|
|
link_type: LinkType,
|
|
) -> Result<Option<String>> {
|
|
let dst = conn
|
|
.query_row(
|
|
"SELECT dst_id FROM links
|
|
WHERE src_id = ?1 AND type = ?2 AND tombstoned = 0
|
|
ORDER BY created_at, id LIMIT 1",
|
|
(src_id, link_type.as_str()),
|
|
|r| r.get(0),
|
|
)
|
|
.optional()?;
|
|
Ok(dst)
|
|
}
|
|
|
|
/// All non-tombstoned links originating at `id`.
|
|
pub(super) fn outgoing(conn: &Connection, id: &str) -> Result<Vec<Link>> {
|
|
query(conn, "src_id", id)
|
|
}
|
|
|
|
/// All non-tombstoned links pointing at `id`.
|
|
pub(super) fn backlinks(conn: &Connection, id: &str) -> Result<Vec<Link>> {
|
|
query(conn, "dst_id", id)
|
|
}
|
|
|
|
fn query(conn: &Connection, column: &str, id: &str) -> Result<Vec<Link>> {
|
|
let sql = format!(
|
|
"SELECT {COLUMNS} FROM links WHERE {column} = ?1 AND tombstoned = 0 ORDER BY created_at, id"
|
|
);
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
let rows = stmt.query_map([id], from_row)?;
|
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
|
}
|
|
|
|
/// Reconcile the `wiki` links out of `src_id` to match the resolvable
|
|
/// `[[wiki-links]]` in `body`. Diff-based and idempotent: unchanged bodies
|
|
/// produce no writes. Targets that don't resolve to a node are left for a later
|
|
/// re-sync once the target exists (tech-spec §5).
|
|
pub(super) fn sync_wiki_links(
|
|
conn: &Connection,
|
|
owner: &str,
|
|
src_id: &str,
|
|
body: &str,
|
|
now: i64,
|
|
) -> Result<()> {
|
|
// Desired set: resolved destination node ids, de-duplicated, order-stable.
|
|
let mut desired: Vec<String> = Vec::new();
|
|
let mut desired_set: HashSet<String> = HashSet::new();
|
|
for target in extract(body).wiki_links {
|
|
if let Some(dst) = resolve_id(conn, owner, &target)? {
|
|
if dst != src_id && desired_set.insert(dst.clone()) {
|
|
desired.push(dst);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Existing wiki links from this source.
|
|
let existing: Vec<(String, String)> = {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, dst_id FROM links
|
|
WHERE src_id = ?1 AND type = 'wiki' AND tombstoned = 0",
|
|
)?;
|
|
let rows = stmt.query_map([src_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
|
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
|
};
|
|
let existing_dsts: HashSet<&str> = existing.iter().map(|(_, d)| d.as_str()).collect();
|
|
|
|
// Tombstone links whose target is no longer referenced.
|
|
for (link_id, dst) in &existing {
|
|
if !desired_set.contains(dst) {
|
|
tombstone(conn, owner, now, link_id)?;
|
|
}
|
|
}
|
|
// Add links for newly-referenced targets.
|
|
for dst in &desired {
|
|
if !existing_dsts.contains(dst.as_str()) {
|
|
add(conn, owner, now, src_id, dst, LinkType::Wiki)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve a wiki-link target to a node id for this owner, matching an alias
|
|
/// first, then an exact title. `None` if nothing matches. Shared by `wiki`
|
|
/// link materialization and the `node.resolve` surface (tech-spec §5, §6).
|
|
pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result<Option<String>> {
|
|
let by_alias: Option<String> = conn
|
|
.query_row(
|
|
"SELECT n.id FROM aliases a JOIN nodes n ON n.id = a.node_id
|
|
WHERE a.alias = ?1 AND n.owner_id = ?2 AND n.tombstoned = 0
|
|
ORDER BY n.created_at, n.id LIMIT 1",
|
|
(target, owner),
|
|
|r| r.get(0),
|
|
)
|
|
.optional()?;
|
|
if by_alias.is_some() {
|
|
return Ok(by_alias);
|
|
}
|
|
// A title may be shared by a task and its canonical-context doc (they are
|
|
// created with the same title). Wiki-links address the first-class node, so
|
|
// canonical-context docs are excluded — `[[Task Title]]` resolves to the
|
|
// task, never its internal context attachment (deterministic; tech-spec §6).
|
|
let by_title: Option<String> = conn
|
|
.query_row(
|
|
"SELECT id FROM nodes
|
|
WHERE title = ?1 AND owner_id = ?2 AND tombstoned = 0
|
|
AND id NOT IN (SELECT dst_id FROM links
|
|
WHERE type = 'canonical-context' AND tombstoned = 0)
|
|
ORDER BY created_at, id LIMIT 1",
|
|
(target, owner),
|
|
|r| r.get(0),
|
|
)
|
|
.optional()?;
|
|
Ok(by_title)
|
|
}
|
|
|
|
/// Resolve a project **name** to its node id, restricted to `project`-kind
|
|
/// nodes (so a like-named task/doc never wins). `None` if no such project.
|
|
/// Used by filter-view scope/exclude resolution (tech-spec §8.2).
|
|
pub(super) fn resolve_project_id(
|
|
conn: &Connection,
|
|
owner: &str,
|
|
name: &str,
|
|
) -> Result<Option<String>> {
|
|
Ok(conn
|
|
.query_row(
|
|
"SELECT id FROM nodes
|
|
WHERE title = ?1 AND owner_id = ?2 AND kind = 'project' AND tombstoned = 0
|
|
ORDER BY created_at, id LIMIT 1",
|
|
(name, owner),
|
|
|r| r.get(0),
|
|
)
|
|
.optional()?)
|
|
}
|
|
|
|
/// Every project node id in the subtree rooted at `root` (inclusive): `root`
|
|
/// plus every project that reaches it through `parent` links (a child holds a
|
|
/// `parent` link to its parent, src=child → dst=parent). Powers the
|
|
/// project-subtree scope of filter views (tech-spec §8.2). Cycle-safe via the
|
|
/// visited set.
|
|
pub(super) fn project_subtree(conn: &Connection, root: &str) -> Result<Vec<String>> {
|
|
let mut out = vec![root.to_string()];
|
|
let mut frontier = vec![root.to_string()];
|
|
let mut stmt = conn.prepare(
|
|
"SELECT src_id FROM links
|
|
WHERE dst_id = ?1 AND type = 'parent' AND tombstoned = 0",
|
|
)?;
|
|
while let Some(parent) = frontier.pop() {
|
|
let children: Vec<String> = stmt
|
|
.query_map([&parent], |r| r.get(0))?
|
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
for child in children {
|
|
if !out.contains(&child) {
|
|
out.push(child.clone());
|
|
frontier.push(child);
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|