1use sheetkit_xml::relationships::{rel_types, Relationship, Relationships};
8use sheetkit_xml::worksheet::{Hyperlink, Hyperlinks, WorksheetXml};
9
10use crate::error::Result;
11
12#[derive(Debug, Clone, PartialEq)]
14pub enum HyperlinkType {
15 External(String),
17 Internal(String),
19 Email(String),
21}
22
23#[derive(Debug, Clone, PartialEq)]
25pub struct HyperlinkInfo {
26 pub link_type: HyperlinkType,
28 pub display: Option<String>,
30 pub tooltip: Option<String>,
32}
33
34pub fn set_cell_hyperlink(
43 ws: &mut WorksheetXml,
44 rels: &mut Relationships,
45 cell: &str,
46 link: &HyperlinkType,
47 display: Option<&str>,
48 tooltip: Option<&str>,
49) -> Result<()> {
50 delete_cell_hyperlink(ws, rels, cell)?;
52
53 let hyperlinks = ws
54 .hyperlinks
55 .get_or_insert_with(|| Hyperlinks { hyperlinks: vec![] });
56
57 match link {
58 HyperlinkType::External(url) | HyperlinkType::Email(url) => {
59 let rid = next_rel_id(rels);
60 rels.relationships.push(Relationship {
61 id: rid.clone(),
62 rel_type: rel_types::HYPERLINK.to_string(),
63 target: url.clone(),
64 target_mode: Some("External".to_string()),
65 });
66 hyperlinks.hyperlinks.push(Hyperlink {
67 reference: cell.to_string(),
68 r_id: Some(rid),
69 location: None,
70 display: display.map(|s| s.to_string()),
71 tooltip: tooltip.map(|s| s.to_string()),
72 });
73 }
74 HyperlinkType::Internal(location) => {
75 hyperlinks.hyperlinks.push(Hyperlink {
76 reference: cell.to_string(),
77 r_id: None,
78 location: Some(location.clone()),
79 display: display.map(|s| s.to_string()),
80 tooltip: tooltip.map(|s| s.to_string()),
81 });
82 }
83 }
84
85 Ok(())
86}
87
88pub fn get_cell_hyperlink(
92 ws: &WorksheetXml,
93 rels: &Relationships,
94 cell: &str,
95) -> Result<Option<HyperlinkInfo>> {
96 let hyperlinks = match &ws.hyperlinks {
97 Some(h) => h,
98 None => return Ok(None),
99 };
100
101 let hl = match hyperlinks.hyperlinks.iter().find(|h| h.reference == cell) {
102 Some(h) => h,
103 None => return Ok(None),
104 };
105
106 let link_type = if let Some(ref rid) = hl.r_id {
107 let rel = rels.relationships.iter().find(|r| r.id == *rid);
109 match rel {
110 Some(r) => {
111 let target = &r.target;
112 if target.starts_with("mailto:") {
113 HyperlinkType::Email(target.clone())
114 } else {
115 HyperlinkType::External(target.clone())
116 }
117 }
118 None => {
119 HyperlinkType::External(String::new())
121 }
122 }
123 } else if let Some(ref location) = hl.location {
124 HyperlinkType::Internal(location.clone())
125 } else {
126 return Ok(None);
128 };
129
130 Ok(Some(HyperlinkInfo {
131 link_type,
132 display: hl.display.clone(),
133 tooltip: hl.tooltip.clone(),
134 }))
135}
136
137pub fn delete_cell_hyperlink(
142 ws: &mut WorksheetXml,
143 rels: &mut Relationships,
144 cell: &str,
145) -> Result<()> {
146 let hyperlinks = match &mut ws.hyperlinks {
147 Some(h) => h,
148 None => return Ok(()),
149 };
150
151 let removed: Vec<Hyperlink> = hyperlinks
153 .hyperlinks
154 .extract_if(.., |h| h.reference == cell)
155 .collect();
156
157 for hl in &removed {
159 if let Some(ref rid) = hl.r_id {
160 rels.relationships.retain(|r| r.id != *rid);
161 }
162 }
163
164 if hyperlinks.hyperlinks.is_empty() {
166 ws.hyperlinks = None;
167 }
168
169 Ok(())
170}
171
172fn next_rel_id(rels: &Relationships) -> String {
174 let max = rels
175 .relationships
176 .iter()
177 .filter_map(|r| r.id.strip_prefix("rId").and_then(|n| n.parse::<u32>().ok()))
178 .max()
179 .unwrap_or(0);
180 format!("rId{}", max + 1)
181}
182
183#[cfg(test)]
184#[allow(clippy::field_reassign_with_default)]
185mod tests {
186 use super::*;
187 use sheetkit_xml::namespaces;
188
189 fn empty_rels() -> Relationships {
190 Relationships {
191 xmlns: namespaces::PACKAGE_RELATIONSHIPS.to_string(),
192 relationships: vec![],
193 }
194 }
195
196 #[test]
197 fn test_set_external_hyperlink() {
198 let mut ws = WorksheetXml::default();
199 let mut rels = empty_rels();
200
201 set_cell_hyperlink(
202 &mut ws,
203 &mut rels,
204 "A1",
205 &HyperlinkType::External("https://example.com".to_string()),
206 None,
207 None,
208 )
209 .unwrap();
210
211 let hls = ws.hyperlinks.as_ref().unwrap();
213 assert_eq!(hls.hyperlinks.len(), 1);
214 assert_eq!(hls.hyperlinks[0].reference, "A1");
215 assert!(hls.hyperlinks[0].r_id.is_some());
216 assert!(hls.hyperlinks[0].location.is_none());
217
218 assert_eq!(rels.relationships.len(), 1);
220 assert_eq!(rels.relationships[0].target, "https://example.com");
221 assert_eq!(
222 rels.relationships[0].target_mode,
223 Some("External".to_string())
224 );
225 assert_eq!(rels.relationships[0].rel_type, rel_types::HYPERLINK);
226 }
227
228 #[test]
229 fn test_set_internal_hyperlink() {
230 let mut ws = WorksheetXml::default();
231 let mut rels = empty_rels();
232
233 set_cell_hyperlink(
234 &mut ws,
235 &mut rels,
236 "B2",
237 &HyperlinkType::Internal("Sheet2!A1".to_string()),
238 None,
239 None,
240 )
241 .unwrap();
242
243 let hls = ws.hyperlinks.as_ref().unwrap();
244 assert_eq!(hls.hyperlinks.len(), 1);
245 assert_eq!(hls.hyperlinks[0].reference, "B2");
246 assert!(hls.hyperlinks[0].r_id.is_none());
247 assert_eq!(hls.hyperlinks[0].location, Some("Sheet2!A1".to_string()));
248
249 assert!(rels.relationships.is_empty());
251 }
252
253 #[test]
254 fn test_set_email_hyperlink() {
255 let mut ws = WorksheetXml::default();
256 let mut rels = empty_rels();
257
258 set_cell_hyperlink(
259 &mut ws,
260 &mut rels,
261 "C3",
262 &HyperlinkType::Email("mailto:[email protected]".to_string()),
263 None,
264 None,
265 )
266 .unwrap();
267
268 let hls = ws.hyperlinks.as_ref().unwrap();
269 assert_eq!(hls.hyperlinks.len(), 1);
270 assert!(hls.hyperlinks[0].r_id.is_some());
271
272 assert_eq!(rels.relationships.len(), 1);
273 assert_eq!(rels.relationships[0].target, "mailto:[email protected]");
274 assert_eq!(
275 rels.relationships[0].target_mode,
276 Some("External".to_string())
277 );
278 }
279
280 #[test]
281 fn test_get_hyperlink_external() {
282 let mut ws = WorksheetXml::default();
283 let mut rels = empty_rels();
284
285 set_cell_hyperlink(
286 &mut ws,
287 &mut rels,
288 "A1",
289 &HyperlinkType::External("https://rust-lang.org".to_string()),
290 Some("Rust"),
291 Some("Visit Rust"),
292 )
293 .unwrap();
294
295 let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
296 assert_eq!(
297 info.link_type,
298 HyperlinkType::External("https://rust-lang.org".to_string())
299 );
300 assert_eq!(info.display, Some("Rust".to_string()));
301 assert_eq!(info.tooltip, Some("Visit Rust".to_string()));
302 }
303
304 #[test]
305 fn test_get_hyperlink_internal() {
306 let mut ws = WorksheetXml::default();
307 let mut rels = empty_rels();
308
309 set_cell_hyperlink(
310 &mut ws,
311 &mut rels,
312 "D4",
313 &HyperlinkType::Internal("Summary!B10".to_string()),
314 Some("Go to Summary"),
315 None,
316 )
317 .unwrap();
318
319 let info = get_cell_hyperlink(&ws, &rels, "D4").unwrap().unwrap();
320 assert_eq!(
321 info.link_type,
322 HyperlinkType::Internal("Summary!B10".to_string())
323 );
324 assert_eq!(info.display, Some("Go to Summary".to_string()));
325 assert!(info.tooltip.is_none());
326 }
327
328 #[test]
329 fn test_get_hyperlink_email() {
330 let mut ws = WorksheetXml::default();
331 let mut rels = empty_rels();
332
333 set_cell_hyperlink(
334 &mut ws,
335 &mut rels,
336 "A1",
337 &HyperlinkType::Email("mailto:[email protected]".to_string()),
338 None,
339 None,
340 )
341 .unwrap();
342
343 let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
344 assert_eq!(
345 info.link_type,
346 HyperlinkType::Email("mailto:[email protected]".to_string())
347 );
348 }
349
350 #[test]
351 fn test_delete_hyperlink() {
352 let mut ws = WorksheetXml::default();
353 let mut rels = empty_rels();
354
355 set_cell_hyperlink(
356 &mut ws,
357 &mut rels,
358 "A1",
359 &HyperlinkType::External("https://example.com".to_string()),
360 None,
361 None,
362 )
363 .unwrap();
364
365 assert!(ws.hyperlinks.is_some());
366 assert_eq!(rels.relationships.len(), 1);
367
368 delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
369
370 assert!(ws.hyperlinks.is_none());
372 assert!(rels.relationships.is_empty());
374 }
375
376 #[test]
377 fn test_delete_internal_hyperlink() {
378 let mut ws = WorksheetXml::default();
379 let mut rels = empty_rels();
380
381 set_cell_hyperlink(
382 &mut ws,
383 &mut rels,
384 "A1",
385 &HyperlinkType::Internal("Sheet2!A1".to_string()),
386 None,
387 None,
388 )
389 .unwrap();
390
391 delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
392
393 assert!(ws.hyperlinks.is_none());
394 assert!(rels.relationships.is_empty());
395 }
396
397 #[test]
398 fn test_hyperlink_with_display_and_tooltip() {
399 let mut ws = WorksheetXml::default();
400 let mut rels = empty_rels();
401
402 set_cell_hyperlink(
403 &mut ws,
404 &mut rels,
405 "A1",
406 &HyperlinkType::External("https://example.com".to_string()),
407 Some("Click here"),
408 Some("Opens example.com"),
409 )
410 .unwrap();
411
412 let hls = ws.hyperlinks.as_ref().unwrap();
413 assert_eq!(hls.hyperlinks[0].display, Some("Click here".to_string()));
414 assert_eq!(
415 hls.hyperlinks[0].tooltip,
416 Some("Opens example.com".to_string())
417 );
418
419 let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
420 assert_eq!(info.display, Some("Click here".to_string()));
421 assert_eq!(info.tooltip, Some("Opens example.com".to_string()));
422 }
423
424 #[test]
425 fn test_overwrite_hyperlink() {
426 let mut ws = WorksheetXml::default();
427 let mut rels = empty_rels();
428
429 set_cell_hyperlink(
431 &mut ws,
432 &mut rels,
433 "A1",
434 &HyperlinkType::External("https://old.com".to_string()),
435 None,
436 None,
437 )
438 .unwrap();
439
440 set_cell_hyperlink(
442 &mut ws,
443 &mut rels,
444 "A1",
445 &HyperlinkType::External("https://new.com".to_string()),
446 Some("New Link"),
447 None,
448 )
449 .unwrap();
450
451 let hls = ws.hyperlinks.as_ref().unwrap();
453 assert_eq!(hls.hyperlinks.len(), 1);
454
455 assert_eq!(rels.relationships.len(), 1);
457 assert_eq!(rels.relationships[0].target, "https://new.com");
458
459 let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
460 assert_eq!(
461 info.link_type,
462 HyperlinkType::External("https://new.com".to_string())
463 );
464 assert_eq!(info.display, Some("New Link".to_string()));
465 }
466
467 #[test]
468 fn test_overwrite_external_with_internal() {
469 let mut ws = WorksheetXml::default();
470 let mut rels = empty_rels();
471
472 set_cell_hyperlink(
473 &mut ws,
474 &mut rels,
475 "A1",
476 &HyperlinkType::External("https://example.com".to_string()),
477 None,
478 None,
479 )
480 .unwrap();
481
482 assert_eq!(rels.relationships.len(), 1);
483
484 set_cell_hyperlink(
486 &mut ws,
487 &mut rels,
488 "A1",
489 &HyperlinkType::Internal("Sheet2!A1".to_string()),
490 None,
491 None,
492 )
493 .unwrap();
494
495 assert!(rels.relationships.is_empty());
497
498 let hls = ws.hyperlinks.as_ref().unwrap();
499 assert_eq!(hls.hyperlinks.len(), 1);
500 assert!(hls.hyperlinks[0].r_id.is_none());
501 assert_eq!(hls.hyperlinks[0].location, Some("Sheet2!A1".to_string()));
502 }
503
504 #[test]
505 fn test_get_nonexistent_hyperlink() {
506 let ws = WorksheetXml::default();
507 let rels = empty_rels();
508
509 let result = get_cell_hyperlink(&ws, &rels, "Z99").unwrap();
510 assert!(result.is_none());
511 }
512
513 #[test]
514 fn test_get_nonexistent_hyperlink_with_empty_container() {
515 let mut ws = WorksheetXml::default();
516 ws.hyperlinks = Some(Hyperlinks { hyperlinks: vec![] });
517 let rels = empty_rels();
518
519 let result = get_cell_hyperlink(&ws, &rels, "A1").unwrap();
520 assert!(result.is_none());
521 }
522
523 #[test]
524 fn test_delete_nonexistent_hyperlink() {
525 let mut ws = WorksheetXml::default();
526 let mut rels = empty_rels();
527
528 delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
530 assert!(ws.hyperlinks.is_none());
531 }
532
533 #[test]
534 fn test_multiple_hyperlinks() {
535 let mut ws = WorksheetXml::default();
536 let mut rels = empty_rels();
537
538 set_cell_hyperlink(
539 &mut ws,
540 &mut rels,
541 "A1",
542 &HyperlinkType::External("https://example.com".to_string()),
543 Some("Example"),
544 None,
545 )
546 .unwrap();
547
548 set_cell_hyperlink(
549 &mut ws,
550 &mut rels,
551 "B1",
552 &HyperlinkType::Internal("Sheet2!A1".to_string()),
553 Some("Sheet 2"),
554 None,
555 )
556 .unwrap();
557
558 set_cell_hyperlink(
559 &mut ws,
560 &mut rels,
561 "C1",
562 &HyperlinkType::Email("mailto:[email protected]".to_string()),
563 Some("Email Us"),
564 Some("Send email"),
565 )
566 .unwrap();
567
568 let hls = ws.hyperlinks.as_ref().unwrap();
569 assert_eq!(hls.hyperlinks.len(), 3);
570
571 assert_eq!(rels.relationships.len(), 2);
573
574 let a1 = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
576 assert_eq!(
577 a1.link_type,
578 HyperlinkType::External("https://example.com".to_string())
579 );
580 assert_eq!(a1.display, Some("Example".to_string()));
581
582 let b1 = get_cell_hyperlink(&ws, &rels, "B1").unwrap().unwrap();
583 assert_eq!(
584 b1.link_type,
585 HyperlinkType::Internal("Sheet2!A1".to_string())
586 );
587 assert_eq!(b1.display, Some("Sheet 2".to_string()));
588
589 let c1 = get_cell_hyperlink(&ws, &rels, "C1").unwrap().unwrap();
590 assert_eq!(
591 c1.link_type,
592 HyperlinkType::Email("mailto:[email protected]".to_string())
593 );
594 assert_eq!(c1.display, Some("Email Us".to_string()));
595 assert_eq!(c1.tooltip, Some("Send email".to_string()));
596 }
597
598 #[test]
599 fn test_delete_one_of_multiple() {
600 let mut ws = WorksheetXml::default();
601 let mut rels = empty_rels();
602
603 set_cell_hyperlink(
604 &mut ws,
605 &mut rels,
606 "A1",
607 &HyperlinkType::External("https://a.com".to_string()),
608 None,
609 None,
610 )
611 .unwrap();
612
613 set_cell_hyperlink(
614 &mut ws,
615 &mut rels,
616 "B1",
617 &HyperlinkType::External("https://b.com".to_string()),
618 None,
619 None,
620 )
621 .unwrap();
622
623 assert_eq!(ws.hyperlinks.as_ref().unwrap().hyperlinks.len(), 2);
624 assert_eq!(rels.relationships.len(), 2);
625
626 delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
628
629 let hls = ws.hyperlinks.as_ref().unwrap();
630 assert_eq!(hls.hyperlinks.len(), 1);
631 assert_eq!(hls.hyperlinks[0].reference, "B1");
632
633 assert_eq!(rels.relationships.len(), 1);
635 assert_eq!(rels.relationships[0].target, "https://b.com");
636 }
637
638 #[test]
639 fn test_next_rel_id_empty() {
640 let rels = empty_rels();
641 assert_eq!(next_rel_id(&rels), "rId1");
642 }
643
644 #[test]
645 fn test_next_rel_id_with_existing() {
646 let rels = Relationships {
647 xmlns: namespaces::PACKAGE_RELATIONSHIPS.to_string(),
648 relationships: vec![
649 Relationship {
650 id: "rId1".to_string(),
651 rel_type: rel_types::HYPERLINK.to_string(),
652 target: "https://a.com".to_string(),
653 target_mode: Some("External".to_string()),
654 },
655 Relationship {
656 id: "rId3".to_string(),
657 rel_type: rel_types::HYPERLINK.to_string(),
658 target: "https://b.com".to_string(),
659 target_mode: Some("External".to_string()),
660 },
661 ],
662 };
663 assert_eq!(next_rel_id(&rels), "rId4");
664 }
665}