generated from eblume/project-template
fix: deleting a project unfiles its tasks to the Inbox (§8.1/§8.2)
Some checks failed
Build / validate (pull_request) Failing after 11s
Some checks failed
Build / validate (pull_request) Failing after 11s
Project delete previously tombstoned only the project node, leaving its tasks with a live in-project link to a dead project — orphaned (not in the Inbox, unbrowsable, blank project) rather than unfiled as intended. New atomic Store::delete_project tombstones every in-project link to the project (tasks fall to the Inbox), then tombstones the project node; tasks are never deleted. Exposed as the project.delete RPC (LocalStore + RemoteStore); the heph-tui sidebar `D` now routes through it. Core test asserts the task survives and becomes unfiled; the project node is tombstoned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9511f6a009
commit
dd5ef7dc63
8 changed files with 116 additions and 2 deletions
|
|
@ -243,6 +243,11 @@ impl Store for LocalStore {
|
|||
tasks::set_project(&mut self.conn, &self.owner_id, now, node_id, project_id)
|
||||
}
|
||||
|
||||
fn delete_project(&mut self, project_id: &str) -> Result<()> {
|
||||
let now = self.clock.now_ms();
|
||||
tasks::delete_project(&mut self.conn, &self.owner_id, now, project_id)
|
||||
}
|
||||
|
||||
fn promote(
|
||||
&mut self,
|
||||
container_id: &str,
|
||||
|
|
@ -465,6 +470,62 @@ mod tests {
|
|||
assert_eq!(v, latest_version());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_project_unfiles_its_tasks_then_tombstones_it() {
|
||||
use crate::filter::ListFilter;
|
||||
use crate::model::{NewNode, NewTask, NodeKind};
|
||||
let mut store = store_at(1);
|
||||
|
||||
let proj = store
|
||||
.create_node(NewNode {
|
||||
kind: NodeKind::Project,
|
||||
title: "Garden".into(),
|
||||
body: None,
|
||||
})
|
||||
.unwrap();
|
||||
let task = store
|
||||
.create_task(NewTask {
|
||||
title: "Weed the beds".into(),
|
||||
attention: None,
|
||||
do_date: None,
|
||||
late_on: None,
|
||||
recurrence: None,
|
||||
project_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.set_task_project(&task.node_id, Some(&proj.id))
|
||||
.unwrap();
|
||||
|
||||
// Filed under the project before deletion.
|
||||
let filed = store
|
||||
.list(&ListFilter {
|
||||
scope: vec![proj.id.clone()],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(filed.len(), 1);
|
||||
|
||||
store.delete_project(&proj.id).unwrap();
|
||||
|
||||
// The project node is tombstoned…
|
||||
let ts: i64 = store
|
||||
.conn
|
||||
.query_row("SELECT tombstoned FROM nodes WHERE id=?1", [&proj.id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(ts, 1);
|
||||
// …and the task survives, now unfiled (it shows in the Inbox), not orphaned.
|
||||
let inbox = store
|
||||
.list(&ListFilter {
|
||||
unfiled: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(inbox.iter().any(|t| t.node_id == task.node_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_node_matches_exact_title_not_fuzzy() {
|
||||
use crate::model::NewNode;
|
||||
|
|
|
|||
|
|
@ -570,6 +570,34 @@ pub(super) fn set_project(
|
|||
require(conn, node_id)
|
||||
}
|
||||
|
||||
/// Delete a project: **unfile every task currently filed under it** (tombstone
|
||||
/// the `in-project` links, so those tasks fall to the Inbox), then tombstone the
|
||||
/// project node itself — atomically. Tasks are preserved, never deleted.
|
||||
pub(super) fn delete_project(
|
||||
conn: &mut Connection,
|
||||
owner: &str,
|
||||
now: i64,
|
||||
project_id: &str,
|
||||
) -> Result<()> {
|
||||
let project =
|
||||
nodes::get(conn, project_id)?.ok_or_else(|| Error::NodeNotFound(project_id.into()))?;
|
||||
if project.kind != NodeKind::Project {
|
||||
return Err(Error::InvalidArg(format!(
|
||||
"{project_id} is not a project node"
|
||||
)));
|
||||
}
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
for link in links::backlinks(&tx, project_id)? {
|
||||
if link.link_type == LinkType::InProject {
|
||||
links::tombstone(&tx, owner, now, &link.id)?;
|
||||
}
|
||||
}
|
||||
nodes::tombstone(&tx, owner, now, project_id)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a partial schedule update (do-date / late-on / recurrence) — the
|
||||
/// "reschedule" path (tech-spec §6). Reads the current row, overlays the
|
||||
/// present `patch` fields (a double-option per field: absent = leave, `null` =
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ pub trait Store {
|
|||
/// A given `project_id` must name a live `project`-kind node.
|
||||
fn set_task_project(&mut self, node_id: &str, project_id: Option<&str>) -> Result<Task>;
|
||||
|
||||
/// Delete a project: unfile its tasks (they fall to the Inbox) and tombstone
|
||||
/// the project node. Tasks are preserved, never deleted.
|
||||
fn delete_project(&mut self, project_id: &str) -> Result<()>;
|
||||
|
||||
/// Promote a `- [ ]` context-item line in `container_id`'s body into a
|
||||
/// committed task, rewriting that source line into a `[[link]]` to the new
|
||||
/// task (Fork A, tech-spec §4.3, §6). `item_ref` is the 1-based index of the
|
||||
|
|
|
|||
|
|
@ -752,8 +752,8 @@ impl<B: Backend> App<B> {
|
|||
self.mutate(format!("deleted: {title}"), |b| b.tombstone(&task_id));
|
||||
}
|
||||
Some(PendingDelete::Project { project_id, title }) => {
|
||||
self.mutate(format!("deleted project: {title}"), |b| {
|
||||
b.tombstone(&project_id)
|
||||
self.mutate(format!("deleted project: {title} (tasks → Inbox)"), |b| {
|
||||
b.delete_project(&project_id)
|
||||
});
|
||||
self.rebuild_projects();
|
||||
self.reload();
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ pub trait Backend {
|
|||
) -> Result<String>;
|
||||
/// Create a new project node; returns its node id.
|
||||
fn create_project(&mut self, name: &str) -> Result<String>;
|
||||
/// Delete a project: unfile its tasks (→ Inbox), then tombstone the project.
|
||||
fn delete_project(&mut self, project_id: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// The real backend: a thin client of the `hephd` unix socket.
|
||||
|
|
@ -217,4 +219,9 @@ impl Backend for ClientBackend {
|
|||
let node: heph_core::Node = serde_json::from_value(v)?;
|
||||
Ok(node.id)
|
||||
}
|
||||
|
||||
fn delete_project(&mut self, project_id: &str) -> Result<()> {
|
||||
self.call("project.delete", json!({ "id": project_id }))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,10 @@ impl Backend for Fake {
|
|||
self.rec.borrow_mut().created_projects.push(name.into());
|
||||
Ok(format!("proj:{name}"))
|
||||
}
|
||||
fn delete_project(&mut self, project_id: &str) -> Result<()> {
|
||||
self.rec.borrow_mut().tombstoned.push(project_id.into());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> Fake {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@ impl Store for RemoteStore {
|
|||
self.call("node.tombstone", json!({ "id": id })).map(|_| ())
|
||||
}
|
||||
|
||||
fn delete_project(&mut self, project_id: &str) -> Result<()> {
|
||||
self.call("project.delete", json!({ "id": project_id }))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn resolve_node(&self, title: &str) -> Result<Option<Node>> {
|
||||
self.call_as("node.resolve", json!({ "title": title }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -369,6 +369,11 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
|
|||
let p: SetProjectParams = parse(params)?;
|
||||
json!(store.set_task_project(&p.id, p.project_id.as_deref())?)
|
||||
}
|
||||
"project.delete" => {
|
||||
let p: IdParam = parse(params)?;
|
||||
store.delete_project(&p.id)?;
|
||||
Value::Null
|
||||
}
|
||||
"task.skip" => {
|
||||
let p: IdParam = parse(params)?;
|
||||
json!(store.skip_recurrence(&p.id)?)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue