Skip to main content

ironcalc_base/
links.rs

1//! Cell hyperlinks. Links are cell metadata: the text displayed in the cell is the
2//! cell content and is not part of the link.
3
4use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    constants::{LAST_COLUMN, LAST_ROW},
10    types::{Color, Link},
11    Model,
12};
13
14/// Theme color slot of the hyperlink color (see `Theme::get_color_by_index`)
15pub(crate) const THEME_COLOR_HYPERLINK: i32 = 10;
16
17/// Returns the link target if `value` should be automatically converted into a
18/// link when entered in a cell: an URL ("https://calc.com", "www.calc.com"), a
19/// mailto: URI or a plain email address ("daniel@calc.com" -> "mailto:daniel@calc.com").
20pub(crate) fn detect_link_target(value: &str) -> Option<String> {
21    let value = value.trim();
22    if value.chars().any(char::is_whitespace) {
23        return None;
24    }
25    let lower = value.to_ascii_lowercase();
26    for scheme in ["http://", "https://", "ftp://", "ftps://", "mailto:"] {
27        if let Some(rest) = lower.strip_prefix(scheme) {
28            if rest.is_empty() {
29                return None;
30            }
31            return Some(value.to_string());
32        }
33    }
34    if let Some(rest) = lower.strip_prefix("www.") {
35        // "www.example.com" but not "www." or "www.example"
36        if rest.contains('.') && !rest.starts_with('.') {
37            return Some(format!("https://{value}"));
38        }
39        return None;
40    }
41    // an email address: local@domain.tld
42    if let Some((local, domain)) = value.split_once('@') {
43        if !local.is_empty()
44            && !domain.contains('@')
45            && domain.contains('.')
46            && !domain.starts_with('.')
47            && !domain.ends_with('.')
48        {
49            return Some(format!("mailto:{value}"));
50        }
51    }
52    None
53}
54
55/// A link together with the cell (`row`, `column`) it is attached to.
56/// This is the shape the bindings expose to UIs, with the link fields flattened:
57/// `{"row": 2, "column": 2, "dynamic": false, "type": "External", "target": "..."}`.
58#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
59pub struct CellLinkView {
60    /// Row of the cell the link is attached to
61    pub row: i32,
62    /// Column of the cell the link is attached to
63    pub column: i32,
64    /// A dynamic link is created by a formula like HYPERLINK. It cannot be
65    /// edited or deleted: it lives as long as the formula produces it.
66    pub dynamic: bool,
67    /// The link itself
68    #[serde(flatten)]
69    pub link: Link,
70}
71
72fn check_valid_cell(row: i32, column: i32) -> Result<(), String> {
73    if !(1..=LAST_ROW).contains(&row) {
74        return Err(format!("Invalid row: '{row}'"));
75    }
76    if !(1..=LAST_COLUMN).contains(&column) {
77        return Err(format!("Invalid column: '{column}'"));
78    }
79    Ok(())
80}
81
82impl Model<'_> {
83    /// Returns the link attached to cell (`row`, `column`) or `None` if there isn't one.
84    /// Only links in the worksheet are returned: they are the editable ones.
85    /// Dynamic links created by formulas like HYPERLINK are not included (they
86    /// cannot be edited, only the formula can), see [`Model::get_links_list`].
87    pub fn get_cell_link(&self, sheet: u32, row: i32, column: i32) -> Result<Option<Link>, String> {
88        check_valid_cell(row, column)?;
89        Ok(self
90            .workbook
91            .worksheet(sheet)?
92            .links
93            .get(&(row, column))
94            .cloned())
95    }
96
97    /// Attaches `link` to cell (`row`, `column`), replacing any existing link.
98    pub fn set_cell_link(
99        &mut self,
100        sheet: u32,
101        row: i32,
102        column: i32,
103        link: Link,
104    ) -> Result<(), String> {
105        check_valid_cell(row, column)?;
106        self.workbook
107            .worksheet_mut(sheet)?
108            .links
109            .insert((row, column), link);
110        Ok(())
111    }
112
113    /// Removes the link attached to cell (`row`, `column`). It is not an error
114    /// if the cell has no link.
115    pub fn delete_cell_link(&mut self, sheet: u32, row: i32, column: i32) -> Result<(), String> {
116        check_valid_cell(row, column)?;
117        self.workbook
118            .worksheet_mut(sheet)?
119            .links
120            .remove(&(row, column));
121        Ok(())
122    }
123
124    /// Returns all the links in the worksheet, keyed by (row, column).
125    pub fn get_links(&self, sheet: u32) -> Result<&HashMap<(i32, i32), Link>, String> {
126        Ok(&self.workbook.worksheet(sheet)?.links)
127    }
128
129    /// Attaches an external link to the cell if `value` looks like an URL or an
130    /// email address. This is the auto-linking of typed or pasted URLs done by
131    /// [`Model::set_user_input`], the same way other inputs change the number
132    /// format of the cell.
133    ///
134    /// A cell that already has a link only gets its target updated; otherwise
135    /// the link style (underline and the theme hyperlink color) is applied too.
136    pub(crate) fn auto_link_cell(
137        &mut self,
138        sheet: u32,
139        row: i32,
140        column: i32,
141        value: &str,
142    ) -> Result<(), String> {
143        let Some(target) = detect_link_target(value) else {
144            return Ok(());
145        };
146        let is_new_link = !self
147            .workbook
148            .worksheet(sheet)?
149            .links
150            .contains_key(&(row, column));
151        self.set_cell_link(
152            sheet,
153            row,
154            column,
155            Link::External {
156                target,
157                tooltip: None,
158            },
159        )?;
160        if is_new_link {
161            let mut style = self.get_style_for_cell(sheet, row, column)?;
162            style.font.u = true;
163            style.font.color = Color::Theme(THEME_COLOR_HYPERLINK, 0.0);
164            self.set_cell_style(sheet, row, column, &style)?;
165        }
166        Ok(())
167    }
168
169    /// Returns all the links in the worksheet as a list sorted by (row, column):
170    /// the links in the worksheet together with the dynamic ones created by
171    /// formulas like HYPERLINK, marked with `dynamic: true` (worksheet links
172    /// take precedence).
173    pub fn get_links_list(&self, sheet: u32) -> Result<Vec<CellLinkView>, String> {
174        let worksheet_links = &self.workbook.worksheet(sheet)?.links;
175        let mut list: Vec<CellLinkView> = worksheet_links
176            .iter()
177            .map(|(&(row, column), link)| CellLinkView {
178                row,
179                column,
180                dynamic: false,
181                link: link.clone(),
182            })
183            .collect();
184        for (&(link_sheet, row, column), link) in &self.links {
185            if link_sheet == sheet && !worksheet_links.contains_key(&(row, column)) {
186                list.push(CellLinkView {
187                    row,
188                    column,
189                    dynamic: true,
190                    link: link.clone(),
191                });
192            }
193        }
194        list.sort_by_key(|l| (l.row, l.column));
195        Ok(list)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::detect_link_target;
202
203    #[test]
204    fn detect_link_target_urls() {
205        assert_eq!(
206            detect_link_target("https://www.ironcalc.com/"),
207            Some("https://www.ironcalc.com/".to_string())
208        );
209        assert_eq!(
210            detect_link_target("http://example.com"),
211            Some("http://example.com".to_string())
212        );
213        assert_eq!(
214            detect_link_target("ftp://ftp.gnu.org/"),
215            Some("ftp://ftp.gnu.org/".to_string())
216        );
217        // case is kept, the scheme check is case-insensitive
218        assert_eq!(
219            detect_link_target("HTTPS://EXAMPLE.COM"),
220            Some("HTTPS://EXAMPLE.COM".to_string())
221        );
222        // scheme-less www gets https:// prepended
223        assert_eq!(
224            detect_link_target("www.example.com"),
225            Some("https://www.example.com".to_string())
226        );
227        // surrounding whitespace is ignored
228        assert_eq!(
229            detect_link_target("  www.example.com  "),
230            Some("https://www.example.com".to_string())
231        );
232    }
233
234    #[test]
235    fn detect_link_target_emails() {
236        assert_eq!(
237            detect_link_target("hello@ironcalc.com"),
238            Some("mailto:hello@ironcalc.com".to_string())
239        );
240        assert_eq!(
241            detect_link_target("mailto:hello@ironcalc.com"),
242            Some("mailto:hello@ironcalc.com".to_string())
243        );
244    }
245
246    #[test]
247    fn detect_link_target_rejects() {
248        assert_eq!(detect_link_target("Hello world"), None);
249        assert_eq!(detect_link_target("42"), None);
250        assert_eq!(detect_link_target("https://"), None);
251        assert_eq!(detect_link_target("www."), None);
252        assert_eq!(detect_link_target("www.example"), None);
253        assert_eq!(detect_link_target("not a link www.example.com"), None);
254        assert_eq!(detect_link_target("daniel@localhost"), None);
255        assert_eq!(detect_link_target("@example.com"), None);
256        assert_eq!(detect_link_target("a@b@c.com"), None);
257        assert_eq!(detect_link_target(""), None);
258    }
259}