handoff-mcp-server 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,12 @@ pub struct TaskData {
29
29
  pub labels: Vec<String>,
30
30
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
31
31
  pub links: Vec<String>,
32
+ /// Typed document/task/URL links (wiki/130-document-management.md §9.1).
33
+ /// Additive alongside the legacy `links: Vec<String>` field, which is kept
34
+ /// as-is for backward compatibility. Use the `links()` accessor to read a
35
+ /// normalized, deduplicated view of both fields combined.
36
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
37
+ pub task_links: Vec<TaskLink>,
32
38
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
33
39
  pub done_criteria: Vec<DoneCriterion>,
34
40
  #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -68,6 +74,50 @@ pub struct DoneCriterion {
68
74
  pub checked: bool,
69
75
  }
70
76
 
77
+ /// A typed link from a task to a document, URL, file, or another task
78
+ /// (wiki/130-document-management.md §9.1). `link_type` distinguishes the
79
+ /// target kind: `"doc"` (document management fragment), `"url"`, `"file"`,
80
+ /// or `"task"`.
81
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82
+ pub struct TaskLink {
83
+ pub target: String,
84
+ pub link_type: String,
85
+ #[serde(default, skip_serializing_if = "Option::is_none")]
86
+ pub label: Option<String>,
87
+ }
88
+
89
+ impl TaskData {
90
+ /// Normalized, deduplicated view of every link on this task: the legacy
91
+ /// `links: Vec<String>` field (each entry becomes `TaskLink { link_type:
92
+ /// "file", label: None }`) merged with `task_links`. Dedupes by
93
+ /// `(target, link_type)`, keeping the first occurrence — `task_links`
94
+ /// entries are checked first so a richer (labeled) `task_links` entry
95
+ /// wins over an equivalent bare legacy `links` entry.
96
+ pub fn links(&self) -> Vec<TaskLink> {
97
+ let mut seen: HashSet<(String, String)> = HashSet::new();
98
+ let mut result = Vec::with_capacity(self.task_links.len() + self.links.len());
99
+
100
+ for link in &self.task_links {
101
+ let key = (link.target.clone(), link.link_type.clone());
102
+ if seen.insert(key) {
103
+ result.push(link.clone());
104
+ }
105
+ }
106
+ for target in &self.links {
107
+ let key = (target.clone(), "file".to_string());
108
+ if seen.insert(key) {
109
+ result.push(TaskLink {
110
+ target: target.clone(),
111
+ link_type: "file".to_string(),
112
+ label: None,
113
+ });
114
+ }
115
+ }
116
+
117
+ result
118
+ }
119
+ }
120
+
71
121
  #[derive(Debug, Clone, Serialize, Deserialize)]
72
122
  pub struct TaskIndex {
73
123
  pub id: String,
@@ -821,3 +871,84 @@ fn check_children_terminal(task_dir: &Path, parent_id: &str) -> Result<()> {
821
871
  }
822
872
  Ok(())
823
873
  }
874
+
875
+ /// Result of [`sync_doc_task_links`]: reports which of the requested task ids
876
+ /// could not be resolved to an existing task, so the caller (e.g. `doc_save`)
877
+ /// can surface a warning to the user instead of the sync silently doing
878
+ /// nothing for a mistyped id.
879
+ #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
880
+ pub struct SyncReport {
881
+ /// Task ids passed in `link_task_ids` or `unlink_task_ids` that did not
882
+ /// resolve to an existing task directory. Order-preserved, may contain
883
+ /// ids from either list combined.
884
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
885
+ pub unresolved: Vec<String>,
886
+ }
887
+
888
+ /// Synchronize the task side of a task<->document bidirectional link
889
+ /// (wiki/130-document-management.md §9.2). Intended to be called by the
890
+ /// `handoff_doc_save` handler (t96) after it has written its own doc-side
891
+ /// `task_ids` field, so the two stay in agreement.
892
+ ///
893
+ /// For every task id in `link_task_ids`, push-if-absent a
894
+ /// `TaskLink { target: doc_id, link_type: "doc", label: Some(doc_title) }`
895
+ /// into that task's `task_links` (deduped by `(target, link_type)`).
896
+ /// For every task id in `unlink_task_ids`, remove any `task_links` entry
897
+ /// with `target == doc_id && link_type == "doc"`.
898
+ ///
899
+ /// A task id that does not resolve to an existing task directory is not
900
+ /// treated as a hard error: the caller (doc_save) may be syncing a batch of
901
+ /// task_ids where one was mistyped, and a document write should not be
902
+ /// rolled back over an unrelated task lookup failure. Instead it is
903
+ /// collected into the returned [`SyncReport::unresolved`] list so the caller
904
+ /// can report it back to the user (partial success + report, not a silent
905
+ /// skip).
906
+ pub fn sync_doc_task_links(
907
+ tasks_dir: &Path,
908
+ doc_id: &str,
909
+ doc_title: &str,
910
+ link_task_ids: &[String],
911
+ unlink_task_ids: &[String],
912
+ ) -> Result<SyncReport> {
913
+ let mut unresolved = Vec::new();
914
+
915
+ for task_id in link_task_ids {
916
+ let Some(task_dir) = find_task_dir_by_id(tasks_dir, task_id)? else {
917
+ unresolved.push(task_id.clone());
918
+ continue;
919
+ };
920
+ read_modify_write_task(&task_dir, |data, status| {
921
+ let already_linked = data
922
+ .task_links
923
+ .iter()
924
+ .any(|l| l.target == doc_id && l.link_type == "doc");
925
+ if !already_linked {
926
+ data.task_links.push(TaskLink {
927
+ target: doc_id.to_string(),
928
+ link_type: "doc".to_string(),
929
+ label: Some(doc_title.to_string()),
930
+ });
931
+ data.updated_at = Some(Utc::now().to_rfc3339());
932
+ }
933
+ Ok(status.to_string())
934
+ })?;
935
+ }
936
+
937
+ for task_id in unlink_task_ids {
938
+ let Some(task_dir) = find_task_dir_by_id(tasks_dir, task_id)? else {
939
+ unresolved.push(task_id.clone());
940
+ continue;
941
+ };
942
+ read_modify_write_task(&task_dir, |data, status| {
943
+ let before = data.task_links.len();
944
+ data.task_links
945
+ .retain(|l| !(l.target == doc_id && l.link_type == "doc"));
946
+ if data.task_links.len() != before {
947
+ data.updated_at = Some(Utc::now().to_rfc3339());
948
+ }
949
+ Ok(status.to_string())
950
+ })?;
951
+ }
952
+
953
+ Ok(SyncReport { unresolved })
954
+ }