1use std::collections::{BTreeMap, HashMap};
7use std::sync::LazyLock;
8
9use omgbase_format::hash::hex;
10use omgbase_mutate::{At, ErrorCode, Expect, MutationError, Op, Parent, To};
11use regex::Regex;
12use rusqlite::{Connection, OptionalExtension, params};
13use serde_json::{Map, Value, json};
14
15use crate::Store;
16use crate::error::{Error, Result};
17use crate::links::{glob_clause, rewrite_link_destinations, split_destination};
18use crate::read::blob_text;
19
20fn raw_hash_of_block(conn: &Connection, block_id: &str) -> Result<Option<String>> {
21 let h: Option<Vec<u8>> = conn
22 .query_row(
23 "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
24 params![block_id],
25 |r| r.get(0),
26 )
27 .optional()?;
28 Ok(h.map(|h| hex(&h)))
29}
30
31fn raw_of_block(conn: &Connection, block_id: &str) -> Result<Option<String>> {
32 let h: Option<Vec<u8>> = conn
33 .query_row(
34 "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
35 params![block_id],
36 |r| r.get(0),
37 )
38 .optional()?;
39 h.map(|h| blob_text(conn, &h)).transpose()
40}
41
42fn update_markdown(block: &str, markdown: String, hash: Option<String>) -> Op {
43 Op::Update {
44 block: block.to_owned(),
45 markdown: Some(markdown),
46 attrs: None,
47 expect: hash.map(Expect::content),
48 trivia: None,
49 child_ids: None,
50 }
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct RetargetHit {
56 pub block: String,
57 pub path: String,
59 pub old_raw: String,
60 pub new_raw: String,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct LinkRepair {
66 pub from: String,
67 pub to: String,
68}
69
70#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct LinkRepairCount {
73 pub from: String,
74 pub to: String,
75 pub hits: usize,
76}
77
78#[derive(Clone, Debug, PartialEq)]
80pub struct LinkRepairPlan {
81 pub ops: Vec<Op>,
83 pub hits: Vec<RetargetHit>,
84 pub pairs: Vec<LinkRepairCount>,
85}
86
87impl LinkRepairPlan {
88 #[must_use]
91 pub fn extras_json(&self) -> Map<String, Value> {
92 let mut m = Map::new();
93 m.insert(
94 "hits".to_owned(),
95 Value::Array(
96 self.hits
97 .iter()
98 .map(|h| json!({ "block": h.block, "path": h.path, "old_raw": h.old_raw, "new_raw": h.new_raw }))
99 .collect(),
100 ),
101 );
102 m.insert(
103 "pairs".to_owned(),
104 Value::Array(
105 self.pairs
106 .iter()
107 .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
108 .collect(),
109 ),
110 );
111 m
112 }
113}
114
115fn strip_slash(s: &str) -> &str {
116 s.strip_prefix('/').unwrap_or(s)
117}
118
119fn destination_match<'a>(dest: &'a str, from: &str) -> Option<&'a str> {
123 if strip_slash(dest) == strip_slash(from) {
124 return Some("");
125 }
126 let (path, fragment) = split_destination(dest);
127 (!fragment.is_empty() && strip_slash(path) == strip_slash(from)).then_some(fragment)
128}
129
130static HEADING_LEVEL: LazyLock<Regex> =
131 LazyLock::new(|| Regex::new(r"^(#{1,6})\s").expect("regex"));
132
133#[must_use]
135pub fn editable_props_for(format: &str, kind: &str) -> Vec<&'static str> {
136 if format != "markdown" {
137 return Vec::new();
138 }
139 match kind {
140 "md:link" => vec!["name", "value"],
141 "md:task" => vec!["checked"],
142 _ => Vec::new(),
143 }
144}
145
146static LINK_TEXT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\[[^\]]*\]").expect("regex"));
147static LINK_TARGET: LazyLock<Regex> =
148 LazyLock::new(|| Regex::new(r#"\]\(([^)\s]+)(\s+"[^"]*")?\)$"#).expect("regex"));
149
150impl Store {
151 pub fn tasks_complete(&self, blocks: &[String]) -> Result<Vec<Op>> {
154 let mut attrs = Map::new();
155 attrs.insert("checked".to_owned(), json!(true));
156 blocks
157 .iter()
158 .map(|b| {
159 Ok(Op::Update {
160 block: b.clone(),
161 markdown: None,
162 attrs: Some(attrs.clone()),
163 expect: raw_hash_of_block(&self.conn, b)?.map(Expect::content),
164 trivia: None,
165 child_ids: None,
166 })
167 })
168 .collect()
169 }
170
171 #[must_use]
173 pub fn sections_append(heading: &str, markdown: &str) -> Vec<Op> {
174 vec![Op::Insert {
175 doc: None,
176 to: To {
177 parent: Parent::Section {
178 heading: heading.to_owned(),
179 },
180 at: At::End,
181 },
182 markdown: markdown.to_owned(),
183 expect: None,
184 }]
185 }
186
187 #[must_use]
189 pub fn docs_append(doc: &str, markdown: &str) -> Vec<Op> {
190 vec![Op::Insert {
191 doc: Some(doc.to_owned()),
192 to: To {
193 parent: Parent::Doc,
194 at: At::End,
195 },
196 markdown: markdown.to_owned(),
197 expect: None,
198 }]
199 }
200
201 pub fn sections_rename(&self, heading: &str, title: &str) -> Result<Vec<Op>> {
203 let raw = raw_of_block(&self.conn, heading)?.unwrap_or_default();
204 let level = HEADING_LEVEL.captures(&raw).map_or(1, |c| c[1].len());
205 let hash = raw_hash_of_block(&self.conn, heading)?;
206 Ok(vec![update_markdown(
207 heading,
208 format!("{} {title}", "#".repeat(level)),
209 hash,
210 )])
211 }
212
213 pub fn sections_move(&self, heading: &str, to: &To) -> Result<Vec<Op>> {
217 let row: Option<(String, Option<i64>)> = self
218 .conn
219 .query_row(
220 "SELECT doc_id, json_extract(attrs, '$.level') FROM blocks WHERE block_id = ?1",
221 params![heading],
222 |r| Ok((r.get(0)?, r.get(1)?)),
223 )
224 .optional()?;
225 let Some((doc_id, level)) = row else {
226 return Ok(Vec::new());
227 };
228 let level = level.unwrap_or(1);
229 let tops: Vec<(String, String, Option<i64>)> = {
230 let mut stmt = self.conn.prepare(
231 "SELECT block_id, type, json_extract(attrs, '$.level') FROM blocks
232 WHERE doc_id = ?1 AND parent_block IS NULL AND deleted_commit IS NULL ORDER BY ordinal",
233 )?;
234 let it = stmt.query_map(params![doc_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
235 it.collect::<std::result::Result<Vec<_>, _>>()?
236 };
237 let start_idx: i64 = tops
239 .iter()
240 .position(|t| t.0 == heading)
241 .map_or(-1, |i| i as i64);
242 let mut end = tops.len();
243 for (i, t) in tops.iter().enumerate() {
244 if (i as i64) <= start_idx {
245 continue;
246 }
247 if t.1 == "heading" && t.2.unwrap_or(1) <= level {
248 end = i;
249 break;
250 }
251 }
252 let start = if start_idx < 0 {
253 (tops.len() as i64 + start_idx).max(0) as usize
254 } else {
255 start_idx as usize
256 };
257 let run: Vec<String> = if start < end {
258 tops[start..end].iter().map(|t| t.0.clone()).collect()
259 } else {
260 Vec::new()
261 };
262 Ok(vec![Op::Move {
263 blocks: run,
264 to: to.clone(),
265 expect: None,
266 }])
267 }
268
269 #[must_use]
271 pub fn lists_insert_item(anchor: &str, at: At, markdown: &str) -> Vec<Op> {
272 let item = if markdown.trim_start().starts_with("- ") {
273 markdown.to_owned()
274 } else {
275 format!("- {markdown}")
276 };
277 vec![Op::Insert {
278 doc: None,
279 to: To {
280 parent: Parent::Block(anchor.to_owned()),
281 at,
282 },
283 markdown: item,
284 expect: None,
285 }]
286 }
287
288 pub fn node_set(&self, node_id: &str, prop: &str, value: &str) -> Result<Vec<Op>> {
291 type NodeRow = (
292 String,
293 Option<String>,
294 Option<String>,
295 String,
296 Option<i64>,
297 Option<i64>,
298 Option<String>,
299 String,
300 );
301 let row: Option<NodeRow> = self
302 .conn
303 .query_row(
304 "SELECT n.kind, n.name, n.value, n.attrs, n.span_start, n.span_end, n.block_id, d.format
305 FROM nodes n JOIN docs d ON d.doc_id = n.doc_id WHERE n.node_id = ?1",
306 params![node_id],
307 |r| {
308 Ok((
309 r.get(0)?,
310 r.get(1)?,
311 r.get(2)?,
312 r.get(3)?,
313 r.get(4)?,
314 r.get(5)?,
315 r.get(6)?,
316 r.get(7)?,
317 ))
318 },
319 )
320 .optional()?;
321 let Some((kind, _name, _value, _attrs, span_start, span_end, block_id, format)) = row
322 else {
323 return Err(MutationError::new(
324 ErrorCode::BlockMissing,
325 format!("node {node_id} not found"),
326 )
327 .into());
328 };
329 let Some(block_id) = block_id else {
330 return Err(MutationError::new(
331 ErrorCode::NodeNotEditable,
332 format!("node {node_id} is not anchored to a block"),
333 )
334 .into());
335 };
336 let editable = editable_props_for(&format, &kind);
337 if !editable.contains(&prop) {
338 return Err(MutationError::with_data(
339 ErrorCode::NodeNotEditable,
340 format!("no editor for {kind}.{prop}"),
341 json!({ "kind": kind, "prop": prop, "editable": editable }),
342 )
343 .into());
344 }
345 let Some(block_raw) = raw_of_block(&self.conn, &block_id)? else {
346 return Err(MutationError::new(
347 ErrorCode::BlockMissing,
348 format!("block {block_id} not found"),
349 )
350 .into());
351 };
352 let hash = raw_hash_of_block(&self.conn, &block_id)?;
353 let span = match (span_start, span_end) {
354 (Some(s), Some(e)) => {
355 let s = usize::try_from(s).unwrap_or(0).min(block_raw.len());
356 let e = usize::try_from(e).unwrap_or(0).clamp(s, block_raw.len());
357 Some((s, e))
358 }
359 _ => None,
360 };
361 let seg = |(s, e): (usize, usize)| -> Result<&str> {
362 block_raw.get(s..e).ok_or_else(|| {
363 Error::Other(format!("node span {s}..{e} is not on a char boundary"))
364 })
365 };
366 match (kind.as_str(), prop) {
367 ("md:link", "name") => {
368 let span = span
369 .ok_or_else(|| Error::Other("md:link.name requires a recorded span".into()))?;
370 let seg = seg(span)?;
371 let rebuilt = LINK_TEXT
372 .replace(seg, format!("[{value}]").as_str())
373 .into_owned();
374 if rebuilt == seg {
375 return Err(Error::Other(format!(
376 "could not locate link text in {}",
377 serde_json::to_string(seg).unwrap_or_default()
378 )));
379 }
380 let md = format!("{}{rebuilt}{}", &block_raw[..span.0], &block_raw[span.1..]);
381 Ok(vec![update_markdown(&block_id, md, hash)])
382 }
383 ("md:link", "value") => {
384 let span = span
385 .ok_or_else(|| Error::Other("md:link.value requires a recorded span".into()))?;
386 let seg = seg(span)?;
387 let rebuilt = LINK_TARGET
388 .replace(seg, |caps: ®ex::Captures<'_>| {
389 format!("]({value}{})", caps.get(2).map_or("", |m| m.as_str()))
390 })
391 .into_owned();
392 if rebuilt == seg {
393 return Err(Error::Other(format!(
394 "could not locate link target in {}",
395 serde_json::to_string(seg).unwrap_or_default()
396 )));
397 }
398 let md = format!("{}{rebuilt}{}", &block_raw[..span.0], &block_raw[span.1..]);
399 Ok(vec![update_markdown(&block_id, md, hash)])
400 }
401 ("md:task", "checked") => {
402 let mut attrs = Map::new();
403 attrs.insert("checked".to_owned(), json!(value == "true" || value == "1"));
404 Ok(vec![Op::Update {
405 block: block_id,
406 markdown: None,
407 attrs: Some(attrs),
408 expect: hash.map(Expect::content),
409 trivia: None,
410 child_ids: None,
411 }])
412 }
413 _ => unreachable!("editable props are enumerated above"),
414 }
415 }
416
417 pub fn links_retarget(
419 &self,
420 repo_id: &str,
421 from: &str,
422 to: &str,
423 path_glob: Option<&str>,
424 ) -> Result<LinkRepairPlan> {
425 self.links_repair(
426 repo_id,
427 &[LinkRepair {
428 from: from.to_owned(),
429 to: to.to_owned(),
430 }],
431 path_glob,
432 )
433 }
434
435 pub fn links_repair(
440 &self,
441 repo_id: &str,
442 repairs: &[LinkRepair],
443 path_glob: Option<&str>,
444 ) -> Result<LinkRepairPlan> {
445 let mut pairs: Vec<LinkRepairCount> = repairs
446 .iter()
447 .map(|r| LinkRepairCount {
448 from: r.from.clone(),
449 to: r.to.clone(),
450 hits: 0,
451 })
452 .collect();
453 let effective: Vec<usize> = pairs
454 .iter()
455 .enumerate()
456 .filter(|(_, r)| !strip_slash(&r.from).is_empty() && r.from != r.to)
457 .map(|(i, _)| i)
458 .collect();
459 if effective.is_empty() {
460 return Ok(LinkRepairPlan {
461 ops: Vec::new(),
462 hits: Vec::new(),
463 pairs,
464 });
465 }
466 let (glob_sql, glob_param) = match path_glob {
467 Some(g) => {
468 let (clause, param) = glob_clause("d.path", g);
469 (format!("AND {clause}"), Some(param))
470 }
471 None => (String::new(), None),
472 };
473 let sql = format!(
474 "SELECT bl.block_id, bl.parent_block, d.path, b.bytes
475 FROM blocks bl
476 JOIN blobs b ON b.hash = bl.raw_hash
477 JOIN docs d ON d.doc_id = bl.doc_id
478 WHERE bl.repo_id = ?1 AND bl.deleted_commit IS NULL AND d.deleted_commit IS NULL
479 AND bl.type != 'code_fence' AND instr(b.bytes, ?2) > 0 {glob_sql}
480 ORDER BY d.path, bl.depth, bl.ordinal, bl.block_id"
481 );
482 struct Candidate {
483 parent: Option<String>,
484 path: String,
485 raw: String,
486 }
487 let mut order: Vec<String> = Vec::new();
488 let mut candidates: HashMap<String, Candidate> = HashMap::new();
489 let mut keys_seen: Vec<String> = Vec::new();
490 for &i in &effective {
491 let key = strip_slash(&pairs[i].from).to_owned();
492 if keys_seen.contains(&key) {
493 continue;
494 }
495 keys_seen.push(key.clone());
496 let mut stmt = self.conn.prepare(&sql)?;
497 let rows: Vec<(String, Option<String>, String, Vec<u8>)> = match &glob_param {
498 Some(p) => stmt
499 .query_map(params![repo_id, key, p], |r| {
500 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
501 })?
502 .collect::<std::result::Result<Vec<_>, _>>()?,
503 None => stmt
504 .query_map(params![repo_id, key], |r| {
505 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
506 })?
507 .collect::<std::result::Result<Vec<_>, _>>()?,
508 };
509 for (block_id, parent, path, bytes) in rows {
510 if let std::collections::hash_map::Entry::Vacant(slot) =
511 candidates.entry(block_id.clone())
512 {
513 order.push(block_id);
514 slot.insert(Candidate {
515 parent,
516 path,
517 raw: String::from_utf8_lossy(&bytes).into_owned(),
518 });
519 }
520 }
521 }
522 struct Changed {
523 parent: Option<String>,
524 path: String,
525 old_raw: String,
526 new_raw: String,
527 tally: Vec<usize>,
528 }
529 let mut changed_order: Vec<String> = Vec::new();
530 let mut changed: HashMap<String, Changed> = HashMap::new();
531 for block_id in &order {
532 let c = &candidates[block_id];
533 let mut tally = vec![0usize; effective.len()];
534 let new_raw = rewrite_link_destinations(&c.raw, |dest| {
535 for (k, &i) in effective.iter().enumerate() {
536 if let Some(fragment) = destination_match(dest, &pairs[i].from) {
537 tally[k] += 1;
538 return Some(format!("{}{fragment}", pairs[i].to));
539 }
540 }
541 None
542 });
543 if new_raw != c.raw {
544 changed_order.push(block_id.clone());
545 changed.insert(
546 block_id.clone(),
547 Changed {
548 parent: c.parent.clone(),
549 path: c.path.clone(),
550 old_raw: c.raw.clone(),
551 new_raw,
552 tally,
553 },
554 );
555 }
556 }
557 let parent_of = |id: &str| -> Result<Option<String>> {
558 Ok(self
559 .conn
560 .query_row(
561 "SELECT parent_block FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
562 params![id],
563 |r| r.get::<_, Option<String>>(0),
564 )
565 .optional()?
566 .flatten())
567 };
568 let mut ops = Vec::new();
569 let mut hits = Vec::new();
570 for block_id in &changed_order {
571 let c = &changed[block_id];
572 let mut p = c.parent.clone();
573 let mut has_changed_ancestor = false;
574 while let Some(pid) = p {
575 if changed.contains_key(&pid) {
576 has_changed_ancestor = true;
577 break;
578 }
579 p = parent_of(&pid)?;
580 }
581 if has_changed_ancestor {
582 continue;
583 }
584 for (k, &i) in effective.iter().enumerate() {
585 pairs[i].hits += c.tally[k];
586 }
587 let hash = raw_hash_of_block(&self.conn, block_id)?;
588 ops.push(update_markdown(block_id, c.new_raw.clone(), hash));
589 hits.push(RetargetHit {
590 block: block_id.clone(),
591 path: c.path.clone(),
592 old_raw: c.old_raw.clone(),
593 new_raw: c.new_raw.clone(),
594 });
595 }
596 Ok(LinkRepairPlan { ops, hits, pairs })
597 }
598}
599
600pub fn ops_from_json(v: &Value) -> Result<Vec<Op>> {
603 v.as_array()
604 .ok_or_else(|| Error::Other("ops must be an array".into()))?
605 .iter()
606 .map(|o| Op::from_json(o).map_err(Error::Other))
607 .collect()
608}
609
610pub fn expect_map_from_json(v: &Value) -> Result<BTreeMap<String, Expect>> {
612 let obj = v
613 .as_object()
614 .ok_or_else(|| Error::Other("expect must be an object".into()))?;
615 obj.iter()
616 .map(|(k, e)| {
617 let h = e
618 .get("content_hash")
619 .and_then(Value::as_str)
620 .map(str::to_owned);
621 Ok((
622 k.clone(),
623 Expect {
624 content_hash: h,
625 parent_children_hash: e
626 .get("parent_children_hash")
627 .and_then(Value::as_str)
628 .map(str::to_owned),
629 },
630 ))
631 })
632 .collect()
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 #[test]
640 fn destination_matching_ignores_one_leading_slash_and_keeps_fragments() {
641 assert_eq!(destination_match("/x.md", "x.md"), Some(""));
642 assert_eq!(destination_match("x.md", "/x.md"), Some(""));
643 assert_eq!(destination_match("/x.md#S", "/x.md"), Some("#S"));
644 assert_eq!(destination_match("/x.md^r", "x.md"), Some("^r"));
645 assert_eq!(destination_match("/xx.md", "x.md"), None);
646 assert_eq!(destination_match("/a/x.md", "x.md"), None);
647 }
648
649 #[test]
650 fn pure_expansions() {
651 let ops = Store::sections_append("b_1", "text");
652 assert!(
653 matches!(&ops[0], Op::Insert { doc: None, to, markdown, .. } if to.parent == Parent::Section { heading: "b_1".into() } && markdown == "text")
654 );
655 let ops = Store::docs_append("a.md", "text");
656 assert!(matches!(&ops[0], Op::Insert { doc: Some(d), .. } if d == "a.md"));
657 let ops = Store::lists_insert_item("b_0", At::End, "four");
658 assert!(matches!(&ops[0], Op::Insert { markdown, .. } if markdown == "- four"));
659 let ops = Store::lists_insert_item("b_0", At::Start, " - zero");
660 assert!(matches!(&ops[0], Op::Insert { markdown, .. } if markdown == " - zero"));
661 assert_eq!(editable_props_for("markdown", "md:link"), ["name", "value"]);
662 assert_eq!(editable_props_for("markdown", "md:task"), ["checked"]);
663 assert!(editable_props_for("markdown", "md:section").is_empty());
664 assert!(editable_props_for("yaml", "md:link").is_empty());
665 }
666}