1use std::path::{Path, PathBuf};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use rusqlite::types::Value;
17
18use crate::clip::ClipFile;
19use crate::cmc::{CmcFile, NODE_PAGE};
20use crate::copy::insert_row;
21use crate::database::{Row, has_column, has_table, quote, select, select_all};
22use crate::error::{Error, Result, bail};
23
24#[derive(Debug, Clone, PartialEq)]
26pub struct PageLink {
27 pub file_name: String,
29 pub spread: bool,
31 pub unit: i64,
33 pub width: f64,
35 pub height: f64,
37 pub resolution: f64,
39 pub modified: [i64; 6],
42}
43
44impl PageLink {
45 pub fn read(path: &Path) -> Result<PageLink> {
47 let file = ClipFile::open(path)?;
48 let row = select_all(file.database(), "Canvas")?
49 .into_iter()
50 .next()
51 .ok_or_else(|| Error::format(format!("No Canvas row in {}", path.display())))?;
52 let seconds = std::fs::metadata(path)?.modified()?.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
53 Ok(PageLink {
54 file_name: path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(),
55 spread: row.int("CanvasDoublePage") != 0,
56 unit: row.int("CanvasUnit"),
57 width: row.real("CanvasWidth"),
58 height: row.real("CanvasHeight"),
59 resolution: row.real("CanvasResolution"),
60 modified: civil_time(seconds as i64),
61 })
62 }
63}
64
65pub fn civil_time(seconds: i64) -> [i64; 6] {
67 let (days, rest) = (seconds.div_euclid(86_400), seconds.rem_euclid(86_400));
68 let z = days + 719_468;
70 let era = z.div_euclid(146_097);
71 let doe = z - era * 146_097;
72 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
73 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
74 let mp = (5 * doy + 2) / 153;
75 let day = doy - (153 * mp + 2) / 5 + 1;
76 let month = if mp < 10 { mp + 3 } else { mp - 9 };
77 let year = yoe + era * 400 + (month <= 2) as i64;
78 [year, month, day, rest / 3600, rest / 60 % 60, rest % 60]
79}
80
81fn max_file_index(links: &[PageLink]) -> i64 {
83 links
84 .iter()
85 .filter_map(|link| {
86 let lower = link.file_name.to_lowercase();
87 let stem = lower.strip_suffix(".clip")?;
88 let digits: String = stem.chars().rev().take_while(char::is_ascii_digit).collect();
89 digits.chars().rev().collect::<String>().parse().ok()
90 })
91 .max()
92 .unwrap_or(links.len() as i64)
93}
94
95fn node_values(link: &PageLink) -> Vec<(&'static str, Value)> {
96 let int = Value::Integer;
97 let [year, month, day, hour, minute, second] = link.modified;
98 vec![
99 ("FirstChildIndex", int(0)),
100 ("SelectedIndex", int(0)),
101 ("CanvasIndex", int(0)),
102 ("PageFlag", int(link.spread as i64)),
103 ("Memo", Value::Text(String::new())),
104 ("BookBindMemo", Value::Text(String::new())),
105 ("LinkPath", Value::Text(format!(".:{}", link.file_name))),
106 ("LinkWriteYear", int(year)),
107 ("LinkWriteMonth", int(month)),
108 ("LinkWriteDay", int(day)),
109 ("LinkWriteHour", int(hour)),
110 ("LinkWriteMinute", int(minute)),
111 ("LinkWriteSecond", int(second)),
112 ("LinkIsFolder", int(0)),
113 ("LinkUnit", int(link.unit)),
114 ("LinkWidth", Value::Real(link.width)),
115 ("LinkHeight", Value::Real(link.height)),
116 ("LinkResolution", Value::Real(link.resolution)),
117 ]
118}
119
120impl CmcFile {
121 pub fn set_pages(&mut self, links: &[PageLink]) -> Result<()> {
125 if links.is_empty() {
126 bail!("A work needs at least one page");
127 }
128 let connection = &self.connection;
129 let root = self.project.root_node;
130 let base = select(connection, "SELECT * FROM CanvasNode WHERE Type = ?1 ORDER BY MainId LIMIT 1", [NODE_PAGE])?
131 .into_iter()
132 .next()
133 .unwrap_or_else(|| Row::new(Vec::new(), Vec::new()));
134 connection.execute("DELETE FROM CanvasNode WHERE MainId != ?1", [root])?;
135 let ids: Vec<i64> = (1..=links.len() as i64 + 1).filter(|id| *id != root).take(links.len()).collect();
136 for (position, (id, link)) in ids.iter().zip(links).enumerate() {
137 let mut columns: Vec<String> = base.columns().iter().filter(|c| *c != "_PW_ID").cloned().collect();
138 let mut values: Vec<Value> = columns.iter().map(|c| base.get(c).cloned().unwrap_or(Value::Null)).collect();
139 let next = ids.get(position + 1).copied().unwrap_or(0);
140 let mut set = |column: &str, value: Value| match columns.iter().position(|c| c == column) {
141 Some(i) => values[i] = value,
142 None => {
143 columns.push(column.to_owned());
144 values.push(value);
145 }
146 };
147 for (column, value) in node_values(link) {
148 set(column, value);
149 }
150 set("_PW_ID", Value::Integer(*id));
152 set("MainId", Value::Integer(*id));
153 set("Type", Value::Integer(NODE_PAGE));
154 set("NextIndex", Value::Integer(next));
155 insert_row(connection, "CanvasNode", &Row::new(columns, values))?;
156 }
157 for column in ["FirstChildIndex", "SelectedIndex"] {
158 if has_column(connection, "CanvasNode", column)? {
159 connection.execute(
160 &format!("UPDATE CanvasNode SET {} = ?1 WHERE MainId = ?2", quote(column)),
161 (ids[0], root),
162 )?;
163 }
164 }
165 if has_table(connection, "ElemScheme")? {
166 let top = ids.iter().copied().chain([root]).max().unwrap_or(root);
167 connection.execute("UPDATE ElemScheme SET MaxIndex = ?1 WHERE TableName = 'CanvasNode'", [top])?;
168 }
169 if has_column(connection, "Project", "MaxCanvasFileIndex")? {
170 connection.execute("UPDATE Project SET MaxCanvasFileIndex = ?1", [max_file_index(links)])?;
171 }
172 self.modified = true;
173 self.reload()
174 }
175
176 pub fn set_project_value(&mut self, column: &str, value: Value) -> Result<bool> {
178 if !has_column(&self.connection, "Project", column)? {
179 return Ok(false);
180 }
181 self.connection.execute(&format!("UPDATE Project SET {} = ?1", quote(column)), [value])?;
182 self.modified = true;
183 self.reload()?;
184 Ok(true)
185 }
186
187 pub fn page_canvas_values(&self) -> Result<Vec<(String, Value)>> {
192 let project = select_all(&self.connection, "Project")?
193 .into_iter()
194 .next()
195 .ok_or_else(|| Error::format(".cmc has no Project row"))?;
196 Ok(project
197 .iter()
198 .filter(|(column, _)| column.starts_with("ComicStory") || *column == "ComicBindPosition")
199 .map(|(column, value)| (column.to_owned(), value.clone()))
200 .collect())
201 }
202
203 pub fn save_with_pages(&mut self, output: &Path, pages: &[PathBuf]) -> Result<()> {
207 let folder = match output.parent() {
208 Some(parent) if !parent.as_os_str().is_empty() => parent.canonicalize()?,
209 _ => Path::new(".").canonicalize()?,
210 };
211 let mut links = Vec::with_capacity(pages.len());
212 for page in pages {
213 if page.canonicalize()?.parent() != Some(folder.as_path()) {
214 bail!("Page {} must be in the folder of {}", page.display(), output.display());
215 }
216 links.push(PageLink::read(page)?);
217 }
218 self.set_pages(&links)?;
219 self.save(output)
220 }
221}
222
223pub fn unix_seconds(time: SystemTime) -> i64 {
225 time.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::work::NewWork;
232
233 fn link(name: &str, spread: bool) -> PageLink {
234 PageLink {
235 file_name: name.into(),
236 spread,
237 unit: 2,
238 width: 192.0,
239 height: 267.0,
240 resolution: 600.0,
241 modified: [2026, 10, 26, 12, 0, 0],
242 }
243 }
244
245 #[test]
246 fn civil_times() {
247 assert_eq!(civil_time(0), [1970, 1, 1, 0, 0, 0]);
248 assert_eq!(civil_time(951_782_400 + 3661), [2000, 2, 29, 1, 1, 1]);
249 assert_eq!(civil_time(1_792_972_800), [2026, 10, 26, 0, 0, 0]);
250 }
251
252 #[test]
253 fn pages_replace_the_list_and_keep_the_settings() {
254 let work = NewWork { pages: 4, title: "t".into(), ..Default::default() }.build().unwrap();
255 let mut file = CmcFile::from_bytes(work.cmc).unwrap();
256 let before = file.project.clone();
257 let links = [link("page0003.clip", false), link("page0010.clip", true), link("a.clip", false)];
258 file.set_pages(&links).unwrap();
259 assert!(file.set_project_value("ComicStoryNombreStart", Value::Integer(5)).unwrap());
260 assert!(!file.set_project_value("NoSuchColumn", Value::Integer(5)).unwrap());
261 let mut file = CmcFile::from_bytes(file.to_bytes().unwrap()).unwrap();
262 let pages = file.pages().unwrap();
263 let names: Vec<String> = pages.iter().map(|n| n.relative_path().unwrap().display().to_string()).collect();
264 assert_eq!(names, ["page0003.clip", "page0010.clip", "a.clip"]);
265 assert_eq!(pages.iter().map(|n| n.spread).collect::<Vec<_>>(), [false, true, false]);
266 assert_eq!(file.project.max_canvas_file_index, 10);
267 assert_eq!(file.project.bind_position, before.bind_position);
268 assert_eq!(file.root().unwrap().selected, pages[0].main_id);
269 assert!(file.findings(None).is_empty(), "{:?}", file.findings(None));
270 let values = file.page_canvas_values().unwrap();
271 assert!(values.iter().any(|(c, v)| c == "ComicStoryNombreStart" && *v == Value::Integer(5)));
272 assert!(values.iter().any(|(c, _)| c == "ComicBindPosition"));
273 assert!(file.set_pages(&[]).is_err());
274 }
275}