Skip to main content

sheetkit_core/
hyperlink.rs

1//! Hyperlink management for worksheet cells.
2//!
3//! Provides types and functions for setting, getting, and deleting hyperlinks
4//! on individual cells. Supports external URLs, internal sheet references,
5//! and email (mailto) links.
6
7use sheetkit_xml::relationships::{rel_types, Relationship, Relationships};
8use sheetkit_xml::worksheet::{Hyperlink, Hyperlinks, WorksheetXml};
9
10use crate::error::Result;
11
12/// Type of hyperlink target.
13#[derive(Debug, Clone, PartialEq)]
14pub enum HyperlinkType {
15    /// External URL (e.g., "https://example.com" or "file:///path").
16    External(String),
17    /// Internal sheet reference (e.g., "Sheet2!A1").
18    Internal(String),
19    /// Email link (e.g., "mailto:user@example.com").
20    Email(String),
21}
22
23/// Hyperlink information returned by get operations.
24#[derive(Debug, Clone, PartialEq)]
25pub struct HyperlinkInfo {
26    /// The hyperlink target.
27    pub link_type: HyperlinkType,
28    /// Optional display text.
29    pub display: Option<String>,
30    /// Optional tooltip text.
31    pub tooltip: Option<String>,
32}
33
34/// Set a hyperlink on a cell.
35///
36/// For external URLs and email links, a relationship entry is created in the
37/// worksheet `.rels` file with `TargetMode="External"`. For internal sheet
38/// references, only the `location` attribute is set on the hyperlink element
39/// (no relationship is needed).
40///
41/// If a hyperlink already exists on the cell, it is replaced.
42pub 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    // Remove any existing hyperlink on this cell first.
51    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
88/// Get hyperlink information for a cell.
89///
90/// Returns `None` if the cell has no hyperlink.
91pub 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        // Look up the relationship target.
108        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                // Relationship not found; treat as external with empty target.
120                HyperlinkType::External(String::new())
121            }
122        }
123    } else if let Some(ref location) = hl.location {
124        HyperlinkType::Internal(location.clone())
125    } else {
126        // No r:id and no location; should not happen in valid files.
127        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
137/// Delete a hyperlink from a cell.
138///
139/// Removes the hyperlink element from the worksheet XML and, if the hyperlink
140/// used a relationship, removes the corresponding relationship entry.
141pub 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    // Find and remove the hyperlink for this cell.
152    let removed: Vec<Hyperlink> = hyperlinks
153        .hyperlinks
154        .extract_if(.., |h| h.reference == cell)
155        .collect();
156
157    // Remove associated relationships.
158    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 no hyperlinks remain, remove the container.
165    if hyperlinks.hyperlinks.is_empty() {
166        ws.hyperlinks = None;
167    }
168
169    Ok(())
170}
171
172/// Generate the next relationship ID for a rels collection.
173fn 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)]
184mod tests {
185    use super::*;
186    use sheetkit_xml::namespaces;
187
188    fn empty_rels() -> Relationships {
189        Relationships {
190            xmlns: namespaces::PACKAGE_RELATIONSHIPS.to_string(),
191            relationships: vec![],
192        }
193    }
194
195    #[test]
196    fn test_set_external_hyperlink() {
197        let mut ws = WorksheetXml::default();
198        let mut rels = empty_rels();
199
200        set_cell_hyperlink(
201            &mut ws,
202            &mut rels,
203            "A1",
204            &HyperlinkType::External("https://example.com".to_string()),
205            None,
206            None,
207        )
208        .unwrap();
209
210        // Hyperlink element should exist.
211        let hls = ws.hyperlinks.as_ref().unwrap();
212        assert_eq!(hls.hyperlinks.len(), 1);
213        assert_eq!(hls.hyperlinks[0].reference, "A1");
214        assert!(hls.hyperlinks[0].r_id.is_some());
215        assert!(hls.hyperlinks[0].location.is_none());
216
217        // Relationship should exist.
218        assert_eq!(rels.relationships.len(), 1);
219        assert_eq!(rels.relationships[0].target, "https://example.com");
220        assert_eq!(
221            rels.relationships[0].target_mode,
222            Some("External".to_string())
223        );
224        assert_eq!(rels.relationships[0].rel_type, rel_types::HYPERLINK);
225    }
226
227    #[test]
228    fn test_set_internal_hyperlink() {
229        let mut ws = WorksheetXml::default();
230        let mut rels = empty_rels();
231
232        set_cell_hyperlink(
233            &mut ws,
234            &mut rels,
235            "B2",
236            &HyperlinkType::Internal("Sheet2!A1".to_string()),
237            None,
238            None,
239        )
240        .unwrap();
241
242        let hls = ws.hyperlinks.as_ref().unwrap();
243        assert_eq!(hls.hyperlinks.len(), 1);
244        assert_eq!(hls.hyperlinks[0].reference, "B2");
245        assert!(hls.hyperlinks[0].r_id.is_none());
246        assert_eq!(hls.hyperlinks[0].location, Some("Sheet2!A1".to_string()));
247
248        // No relationship should be created for internal links.
249        assert!(rels.relationships.is_empty());
250    }
251
252    #[test]
253    fn test_set_email_hyperlink() {
254        let mut ws = WorksheetXml::default();
255        let mut rels = empty_rels();
256
257        set_cell_hyperlink(
258            &mut ws,
259            &mut rels,
260            "C3",
261            &HyperlinkType::Email("mailto:user@example.com".to_string()),
262            None,
263            None,
264        )
265        .unwrap();
266
267        let hls = ws.hyperlinks.as_ref().unwrap();
268        assert_eq!(hls.hyperlinks.len(), 1);
269        assert!(hls.hyperlinks[0].r_id.is_some());
270
271        assert_eq!(rels.relationships.len(), 1);
272        assert_eq!(rels.relationships[0].target, "mailto:user@example.com");
273        assert_eq!(
274            rels.relationships[0].target_mode,
275            Some("External".to_string())
276        );
277    }
278
279    #[test]
280    fn test_get_hyperlink_external() {
281        let mut ws = WorksheetXml::default();
282        let mut rels = empty_rels();
283
284        set_cell_hyperlink(
285            &mut ws,
286            &mut rels,
287            "A1",
288            &HyperlinkType::External("https://rust-lang.org".to_string()),
289            Some("Rust"),
290            Some("Visit Rust"),
291        )
292        .unwrap();
293
294        let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
295        assert_eq!(
296            info.link_type,
297            HyperlinkType::External("https://rust-lang.org".to_string())
298        );
299        assert_eq!(info.display, Some("Rust".to_string()));
300        assert_eq!(info.tooltip, Some("Visit Rust".to_string()));
301    }
302
303    #[test]
304    fn test_get_hyperlink_internal() {
305        let mut ws = WorksheetXml::default();
306        let mut rels = empty_rels();
307
308        set_cell_hyperlink(
309            &mut ws,
310            &mut rels,
311            "D4",
312            &HyperlinkType::Internal("Summary!B10".to_string()),
313            Some("Go to Summary"),
314            None,
315        )
316        .unwrap();
317
318        let info = get_cell_hyperlink(&ws, &rels, "D4").unwrap().unwrap();
319        assert_eq!(
320            info.link_type,
321            HyperlinkType::Internal("Summary!B10".to_string())
322        );
323        assert_eq!(info.display, Some("Go to Summary".to_string()));
324        assert!(info.tooltip.is_none());
325    }
326
327    #[test]
328    fn test_get_hyperlink_email() {
329        let mut ws = WorksheetXml::default();
330        let mut rels = empty_rels();
331
332        set_cell_hyperlink(
333            &mut ws,
334            &mut rels,
335            "A1",
336            &HyperlinkType::Email("mailto:test@test.com".to_string()),
337            None,
338            None,
339        )
340        .unwrap();
341
342        let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
343        assert_eq!(
344            info.link_type,
345            HyperlinkType::Email("mailto:test@test.com".to_string())
346        );
347    }
348
349    #[test]
350    fn test_delete_hyperlink() {
351        let mut ws = WorksheetXml::default();
352        let mut rels = empty_rels();
353
354        set_cell_hyperlink(
355            &mut ws,
356            &mut rels,
357            "A1",
358            &HyperlinkType::External("https://example.com".to_string()),
359            None,
360            None,
361        )
362        .unwrap();
363
364        assert!(ws.hyperlinks.is_some());
365        assert_eq!(rels.relationships.len(), 1);
366
367        delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
368
369        // Hyperlinks container should be removed when empty.
370        assert!(ws.hyperlinks.is_none());
371        // Relationship should be cleaned up.
372        assert!(rels.relationships.is_empty());
373    }
374
375    #[test]
376    fn test_delete_internal_hyperlink() {
377        let mut ws = WorksheetXml::default();
378        let mut rels = empty_rels();
379
380        set_cell_hyperlink(
381            &mut ws,
382            &mut rels,
383            "A1",
384            &HyperlinkType::Internal("Sheet2!A1".to_string()),
385            None,
386            None,
387        )
388        .unwrap();
389
390        delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
391
392        assert!(ws.hyperlinks.is_none());
393        assert!(rels.relationships.is_empty());
394    }
395
396    #[test]
397    fn test_hyperlink_with_display_and_tooltip() {
398        let mut ws = WorksheetXml::default();
399        let mut rels = empty_rels();
400
401        set_cell_hyperlink(
402            &mut ws,
403            &mut rels,
404            "A1",
405            &HyperlinkType::External("https://example.com".to_string()),
406            Some("Click here"),
407            Some("Opens example.com"),
408        )
409        .unwrap();
410
411        let hls = ws.hyperlinks.as_ref().unwrap();
412        assert_eq!(hls.hyperlinks[0].display, Some("Click here".to_string()));
413        assert_eq!(
414            hls.hyperlinks[0].tooltip,
415            Some("Opens example.com".to_string())
416        );
417
418        let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
419        assert_eq!(info.display, Some("Click here".to_string()));
420        assert_eq!(info.tooltip, Some("Opens example.com".to_string()));
421    }
422
423    #[test]
424    fn test_overwrite_hyperlink() {
425        let mut ws = WorksheetXml::default();
426        let mut rels = empty_rels();
427
428        // Set first hyperlink.
429        set_cell_hyperlink(
430            &mut ws,
431            &mut rels,
432            "A1",
433            &HyperlinkType::External("https://old.com".to_string()),
434            None,
435            None,
436        )
437        .unwrap();
438
439        // Overwrite with a new hyperlink.
440        set_cell_hyperlink(
441            &mut ws,
442            &mut rels,
443            "A1",
444            &HyperlinkType::External("https://new.com".to_string()),
445            Some("New Link"),
446            None,
447        )
448        .unwrap();
449
450        // Should have only one hyperlink.
451        let hls = ws.hyperlinks.as_ref().unwrap();
452        assert_eq!(hls.hyperlinks.len(), 1);
453
454        // Should have only one relationship (old one cleaned up).
455        assert_eq!(rels.relationships.len(), 1);
456        assert_eq!(rels.relationships[0].target, "https://new.com");
457
458        let info = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
459        assert_eq!(
460            info.link_type,
461            HyperlinkType::External("https://new.com".to_string())
462        );
463        assert_eq!(info.display, Some("New Link".to_string()));
464    }
465
466    #[test]
467    fn test_overwrite_external_with_internal() {
468        let mut ws = WorksheetXml::default();
469        let mut rels = empty_rels();
470
471        set_cell_hyperlink(
472            &mut ws,
473            &mut rels,
474            "A1",
475            &HyperlinkType::External("https://example.com".to_string()),
476            None,
477            None,
478        )
479        .unwrap();
480
481        assert_eq!(rels.relationships.len(), 1);
482
483        // Overwrite with internal link.
484        set_cell_hyperlink(
485            &mut ws,
486            &mut rels,
487            "A1",
488            &HyperlinkType::Internal("Sheet2!A1".to_string()),
489            None,
490            None,
491        )
492        .unwrap();
493
494        // Old relationship should be cleaned up.
495        assert!(rels.relationships.is_empty());
496
497        let hls = ws.hyperlinks.as_ref().unwrap();
498        assert_eq!(hls.hyperlinks.len(), 1);
499        assert!(hls.hyperlinks[0].r_id.is_none());
500        assert_eq!(hls.hyperlinks[0].location, Some("Sheet2!A1".to_string()));
501    }
502
503    #[test]
504    fn test_get_nonexistent_hyperlink() {
505        let ws = WorksheetXml::default();
506        let rels = empty_rels();
507
508        let result = get_cell_hyperlink(&ws, &rels, "Z99").unwrap();
509        assert!(result.is_none());
510    }
511
512    #[test]
513    fn test_get_nonexistent_hyperlink_with_empty_container() {
514        let mut ws = WorksheetXml::default();
515        ws.hyperlinks = Some(Hyperlinks { hyperlinks: vec![] });
516        let rels = empty_rels();
517
518        let result = get_cell_hyperlink(&ws, &rels, "A1").unwrap();
519        assert!(result.is_none());
520    }
521
522    #[test]
523    fn test_delete_nonexistent_hyperlink() {
524        let mut ws = WorksheetXml::default();
525        let mut rels = empty_rels();
526
527        // Deleting a hyperlink that does not exist should succeed silently.
528        delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
529        assert!(ws.hyperlinks.is_none());
530    }
531
532    #[test]
533    fn test_multiple_hyperlinks() {
534        let mut ws = WorksheetXml::default();
535        let mut rels = empty_rels();
536
537        set_cell_hyperlink(
538            &mut ws,
539            &mut rels,
540            "A1",
541            &HyperlinkType::External("https://example.com".to_string()),
542            Some("Example"),
543            None,
544        )
545        .unwrap();
546
547        set_cell_hyperlink(
548            &mut ws,
549            &mut rels,
550            "B1",
551            &HyperlinkType::Internal("Sheet2!A1".to_string()),
552            Some("Sheet 2"),
553            None,
554        )
555        .unwrap();
556
557        set_cell_hyperlink(
558            &mut ws,
559            &mut rels,
560            "C1",
561            &HyperlinkType::Email("mailto:info@example.com".to_string()),
562            Some("Email Us"),
563            Some("Send email"),
564        )
565        .unwrap();
566
567        let hls = ws.hyperlinks.as_ref().unwrap();
568        assert_eq!(hls.hyperlinks.len(), 3);
569
570        // External and email should have relationships; internal should not.
571        assert_eq!(rels.relationships.len(), 2);
572
573        // Verify each hyperlink.
574        let a1 = get_cell_hyperlink(&ws, &rels, "A1").unwrap().unwrap();
575        assert_eq!(
576            a1.link_type,
577            HyperlinkType::External("https://example.com".to_string())
578        );
579        assert_eq!(a1.display, Some("Example".to_string()));
580
581        let b1 = get_cell_hyperlink(&ws, &rels, "B1").unwrap().unwrap();
582        assert_eq!(
583            b1.link_type,
584            HyperlinkType::Internal("Sheet2!A1".to_string())
585        );
586        assert_eq!(b1.display, Some("Sheet 2".to_string()));
587
588        let c1 = get_cell_hyperlink(&ws, &rels, "C1").unwrap().unwrap();
589        assert_eq!(
590            c1.link_type,
591            HyperlinkType::Email("mailto:info@example.com".to_string())
592        );
593        assert_eq!(c1.display, Some("Email Us".to_string()));
594        assert_eq!(c1.tooltip, Some("Send email".to_string()));
595    }
596
597    #[test]
598    fn test_delete_one_of_multiple() {
599        let mut ws = WorksheetXml::default();
600        let mut rels = empty_rels();
601
602        set_cell_hyperlink(
603            &mut ws,
604            &mut rels,
605            "A1",
606            &HyperlinkType::External("https://a.com".to_string()),
607            None,
608            None,
609        )
610        .unwrap();
611
612        set_cell_hyperlink(
613            &mut ws,
614            &mut rels,
615            "B1",
616            &HyperlinkType::External("https://b.com".to_string()),
617            None,
618            None,
619        )
620        .unwrap();
621
622        assert_eq!(ws.hyperlinks.as_ref().unwrap().hyperlinks.len(), 2);
623        assert_eq!(rels.relationships.len(), 2);
624
625        // Delete only A1.
626        delete_cell_hyperlink(&mut ws, &mut rels, "A1").unwrap();
627
628        let hls = ws.hyperlinks.as_ref().unwrap();
629        assert_eq!(hls.hyperlinks.len(), 1);
630        assert_eq!(hls.hyperlinks[0].reference, "B1");
631
632        // Only B1's relationship should remain.
633        assert_eq!(rels.relationships.len(), 1);
634        assert_eq!(rels.relationships[0].target, "https://b.com");
635    }
636
637    #[test]
638    fn test_next_rel_id_empty() {
639        let rels = empty_rels();
640        assert_eq!(next_rel_id(&rels), "rId1");
641    }
642
643    #[test]
644    fn test_next_rel_id_with_existing() {
645        let rels = Relationships {
646            xmlns: namespaces::PACKAGE_RELATIONSHIPS.to_string(),
647            relationships: vec![
648                Relationship {
649                    id: "rId1".to_string(),
650                    rel_type: rel_types::HYPERLINK.to_string(),
651                    target: "https://a.com".to_string(),
652                    target_mode: Some("External".to_string()),
653                },
654                Relationship {
655                    id: "rId3".to_string(),
656                    rel_type: rel_types::HYPERLINK.to_string(),
657                    target: "https://b.com".to_string(),
658                    target_mode: Some("External".to_string()),
659                },
660            ],
661        };
662        assert_eq!(next_rel_id(&rels), "rId4");
663    }
664}