1use 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
14pub(crate) const THEME_COLOR_HYPERLINK: i32 = 10;
16
17pub(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 if rest.contains('.') && !rest.starts_with('.') {
37 return Some(format!("https://{value}"));
38 }
39 return None;
40 }
41 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#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
59pub struct CellLinkView {
60 pub row: i32,
62 pub column: i32,
64 pub dynamic: bool,
67 #[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 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 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 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 pub fn get_links(&self, sheet: u32) -> Result<&HashMap<(i32, i32), Link>, String> {
126 Ok(&self.workbook.worksheet(sheet)?.links)
127 }
128
129 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 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 assert_eq!(
219 detect_link_target("HTTPS://EXAMPLE.COM"),
220 Some("HTTPS://EXAMPLE.COM".to_string())
221 );
222 assert_eq!(
224 detect_link_target("www.example.com"),
225 Some("https://www.example.com".to_string())
226 );
227 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}