1use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20use rusqlite::Connection;
21
22use crate::database::{self, Row, select_all};
23use crate::error::{Result, bail};
24
25pub const NODE_ROOT: i64 = 0;
27pub const NODE_PAGE: i64 = 2;
29
30#[derive(Debug, Clone, PartialEq, serde::Serialize)]
32pub struct CanvasNode {
33 pub main_id: i64,
35 pub kind: i64,
37 pub next: i64,
39 pub first_child: i64,
41 pub selected: i64,
43 pub spread: bool,
45 pub link_path: Option<String>,
47}
48
49impl CanvasNode {
50 fn from_row(row: &Row) -> Self {
51 CanvasNode {
52 main_id: row.int("MainId"),
53 kind: row.int("Type"),
54 next: row.int("NextIndex"),
55 first_child: row.int("FirstChildIndex"),
56 selected: row.int("SelectedIndex"),
57 spread: row.int("PageFlag") & 1 != 0,
58 link_path: row.text("LinkPath"),
59 }
60 }
61
62 pub fn relative_path(&self) -> Option<PathBuf> {
64 let link = self.link_path.as_deref()?;
65 let rest = link.strip_prefix(".:").unwrap_or(link);
66 Some(rest.split(':').collect())
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, serde::Serialize)]
72pub struct CmcProject {
73 pub version: Option<String>,
75 pub root_node: i64,
77 pub bind_position: i64,
79 pub start_page: i64,
81 pub cover: bool,
83 pub page_unit: i64,
85 pub page_width: f64,
87 pub page_height: f64,
89 pub page_resolution: f64,
91 pub double_page: bool,
93 pub max_canvas_file_index: i64,
95}
96
97impl CmcProject {
98 fn from_row(row: &Row) -> Self {
99 CmcProject {
100 version: row.text("ProjectInternalVersion"),
101 root_node: row.int("ProjectRootCanvasNode"),
102 bind_position: row.int("ComicBindPosition"),
103 start_page: row.int("ComicStartPage"),
104 cover: row.truthy("ComicCoverExist"),
105 page_unit: row.int("DefaultPageUnit"),
106 page_width: row.real("DefaultPageWidth"),
107 page_height: row.real("DefaultPageHeight"),
108 page_resolution: row.real("DefaultPageResolution"),
109 double_page: row.truthy("DefaultPageDoublePage"),
110 max_canvas_file_index: row.int("MaxCanvasFileIndex"),
111 }
112 }
113}
114
115pub struct CmcFile {
117 original: Vec<u8>,
118 pub(crate) connection: Connection,
119 pub(crate) modified: bool,
120 pub project: CmcProject,
122 pub nodes: Vec<CanvasNode>,
124}
125
126impl CmcFile {
127 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
129 Self::from_bytes(std::fs::read(path)?)
130 }
131
132 pub fn from_bytes(data: Vec<u8>) -> Result<Self> {
134 if !data.starts_with(b"SQLite format 3\0") {
135 bail!("Not a .cmc (SQLite) file");
136 }
137 let connection = database::open_bytes(&data)?;
138 let mut file =
139 CmcFile { original: data, connection, modified: false, project: empty_project(), nodes: Vec::new() };
140 file.reload()?;
141 Ok(file)
142 }
143
144 pub(crate) fn reload(&mut self) -> Result<()> {
145 let project = select_all(&self.connection, "Project")?;
146 let Some(project) = project.first() else { bail!(".cmc has no Project row") };
147 self.project = CmcProject::from_row(project);
148 self.nodes = select_all(&self.connection, "CanvasNode")?.iter().map(CanvasNode::from_row).collect();
149 Ok(())
150 }
151
152 pub fn database(&self) -> &Connection {
154 &self.connection
155 }
156
157 pub fn version_info(&self) -> Result<crate::VersionInfo> {
159 crate::VersionInfo::read(&self.connection, Some(&self.original))
160 }
161
162 pub fn node(&self, main_id: i64) -> Option<&CanvasNode> {
164 self.nodes.iter().find(|node| node.main_id == main_id)
165 }
166
167 pub fn root(&self) -> Result<&CanvasNode> {
169 self.node(self.project.root_node)
170 .or_else(|| self.nodes.iter().find(|node| node.kind == NODE_ROOT))
171 .ok_or_else(|| crate::Error::format(".cmc has no root canvas node"))
172 }
173
174 pub fn pages(&self) -> Result<Vec<&CanvasNode>> {
176 let mut pages = Vec::new();
177 let mut seen = HashSet::new();
178 let mut id = self.root()?.first_child;
179 while id != 0 {
180 if !seen.insert(id) {
181 bail!("Page list loops at canvas node {id}");
182 }
183 let Some(node) = self.node(id) else { bail!("Page list points to missing canvas node {id}") };
184 pages.push(node);
185 id = node.next;
186 }
187 Ok(pages)
188 }
189
190 pub fn positions(&self) -> Result<Vec<crate::work::PageSlot>> {
195 let start = crate::work::StartSide::from_code(self.project.start_page);
196 let mut position = 0;
197 let mut slots = Vec::new();
198 for page in self.pages()? {
199 slots.push(crate::work::PageSlot {
200 position,
201 spread: page.spread,
202 left: crate::work::is_left_page(position, start),
203 });
204 position += if page.spread { 2 } else { 1 };
205 }
206 Ok(slots)
207 }
208
209 pub fn page_paths(&self, folder: &Path) -> Result<Vec<PathBuf>> {
211 Ok(self.pages()?.iter().filter_map(|node| node.relative_path()).map(|path| folder.join(path)).collect())
212 }
213
214 pub fn page_thumbnail(&self, folder: &Path, node: &CanvasNode) -> Result<Option<crate::RgbaImage>> {
218 let Some(path) = node.relative_path().map(|path| folder.join(path)) else { return Ok(None) };
219 if !path.is_file() {
220 return Ok(None);
221 }
222 crate::ClipFile::open(path)?.preview()
223 }
224
225 pub fn findings(&self, folder: Option<&Path>) -> Vec<String> {
227 let mut findings = Vec::new();
228 let pages = match self.pages() {
229 Ok(pages) => pages,
230 Err(error) => return vec![error.to_string()],
231 };
232 let listed: HashSet<i64> = pages.iter().map(|node| node.main_id).collect();
233 let root = self.root().map(|root| root.main_id).unwrap_or(0);
234 for node in &self.nodes {
235 if node.main_id != root && !listed.contains(&node.main_id) {
236 findings.push(format!("Canvas node {} is not in the page list", node.main_id));
237 }
238 }
239 let mut paths = HashSet::new();
240 for node in &pages {
241 match node.relative_path() {
242 None => findings.push(format!("Page node {} has no LinkPath", node.main_id)),
243 Some(path) => {
244 if let Some(index) = file_index(&path)
245 && index > self.project.max_canvas_file_index
246 {
247 findings.push(format!("{} is above MaxCanvasFileIndex", path.display()));
248 }
249 if !paths.insert(path.clone()) {
250 findings.push(format!("{} is used by two pages", path.display()));
251 }
252 if let Some(folder) = folder
253 && !folder.join(&path).is_file()
254 {
255 findings.push(format!("{} is missing", path.display()));
256 }
257 }
258 }
259 }
260 if let Ok(selected) = self.root().map(|root| root.selected)
261 && selected != 0
262 && !listed.contains(&selected)
263 {
264 findings.push(format!("Selected node {selected} is not a page"));
265 }
266 let max_index = self
267 .connection
268 .query_row("SELECT MaxIndex FROM ElemScheme WHERE TableName = 'CanvasNode'", [], |row| row.get::<_, i64>(0))
269 .unwrap_or(i64::MAX);
270 if let Some(largest) = self.nodes.iter().map(|node| node.main_id).max()
271 && largest > max_index
272 {
273 findings.push(format!("CanvasNode MainId {largest} is above ElemScheme.MaxIndex {max_index}"));
274 }
275 findings
276 }
277
278 fn set_links(&mut self, order: &[i64]) -> Result<()> {
279 let root = self.root()?.main_id;
280 let first = order.first().copied().unwrap_or(0);
281 self.connection.execute("UPDATE CanvasNode SET FirstChildIndex = ?1 WHERE MainId = ?2", (first, root))?;
282 for (i, id) in order.iter().enumerate() {
283 let next = order.get(i + 1).copied().unwrap_or(0);
284 self.connection.execute("UPDATE CanvasNode SET NextIndex = ?1 WHERE MainId = ?2", (next, id))?;
285 }
286 let selected = self.root()?.selected;
287 if !order.contains(&selected) {
288 self.connection.execute("UPDATE CanvasNode SET SelectedIndex = ?1 WHERE MainId = ?2", (first, root))?;
289 }
290 self.modified = true;
291 self.reload()
292 }
293
294 pub fn reorder(&mut self, order: &[i64]) -> Result<()> {
296 let current: HashSet<i64> = self.pages()?.iter().map(|node| node.main_id).collect();
297 let wanted: HashSet<i64> = order.iter().copied().collect();
298 if wanted.len() != order.len() || wanted != current {
299 bail!("The new order must list every page exactly once");
300 }
301 self.set_links(order)
302 }
303
304 pub fn add_page(&mut self, after: Option<i64>, spread: bool) -> Result<(i64, String)> {
308 let mut order: Vec<i64> = self.pages()?.iter().map(|node| node.main_id).collect();
309 let position = match after {
310 None => order.len(),
311 Some(id) => order
312 .iter()
313 .position(|page| *page == id)
314 .map(|i| i + 1)
315 .ok_or_else(|| crate::Error::format(format!("Canvas node {id} is not a page")))?,
316 };
317 let main_id = crate::schema::allocate_main_id(&self.connection, "CanvasNode")?;
318 let file_index = self.project.max_canvas_file_index + 1;
319 let name = format!("page{file_index:04}.clip");
320 let template = order.last().copied();
323 let warning: (i64, i64) = match template {
324 Some(id) => self.connection.query_row(
325 "SELECT COALESCE(BookBindWarning, 0), COALESCE(BookBindWarningDef, 1) FROM CanvasNode WHERE MainId = ?1",
326 [id],
327 |row| Ok((row.get(0)?, row.get(1)?)),
328 )?,
329 None => (0, 1),
330 };
331 self.connection.execute(
332 "INSERT INTO CanvasNode(MainId, Type, NextIndex, FirstChildIndex, SelectedIndex, CanvasIndex, PageFlag, \
333 Memo, BookBindMemo, BookBindWarning, BookBindWarningDef, LinkPath) \
334 VALUES (?1, ?2, 0, 0, 0, 0, ?3, '', '', ?4, ?5, ?6)",
335 (main_id, NODE_PAGE, spread as i64, warning.0, warning.1, format!(".:{name}")),
336 )?;
337 self.connection.execute("UPDATE Project SET MaxCanvasFileIndex = ?1", [file_index])?;
338 self.reload()?;
339 order.insert(position, main_id);
340 self.set_links(&order)?;
341 Ok((main_id, name))
342 }
343
344 pub fn remove_page(&mut self, main_id: i64) -> Result<Option<PathBuf>> {
347 let mut order: Vec<i64> = self.pages()?.iter().map(|node| node.main_id).collect();
348 let Some(position) = order.iter().position(|id| *id == main_id) else {
349 bail!("Canvas node {main_id} is not a page");
350 };
351 let path = self.node(main_id).and_then(CanvasNode::relative_path);
352 order.remove(position);
353 self.connection.execute("DELETE FROM CanvasNode WHERE MainId = ?1", [main_id])?;
354 self.reload()?;
355 self.set_links(&order)?;
356 Ok(path)
357 }
358
359 pub fn is_modified(&self) -> bool {
361 self.modified
362 }
363
364 pub fn to_bytes(&self) -> Result<Vec<u8>> {
366 if !self.modified {
367 return Ok(self.original.clone());
368 }
369 database::serialize(&self.connection)
370 }
371
372 pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
380 std::fs::write(path, self.to_bytes()?)?;
381 Ok(())
382 }
383}
384
385fn file_index(path: &Path) -> Option<i64> {
387 let stem = path.file_stem()?.to_str()?;
388 stem.strip_prefix("page")?.parse().ok()
389}
390
391fn empty_project() -> CmcProject {
392 CmcProject {
393 version: None,
394 root_node: 0,
395 bind_position: 0,
396 start_page: 0,
397 cover: false,
398 page_unit: 0,
399 page_width: 0.0,
400 page_height: 0.0,
401 page_resolution: 0.0,
402 double_page: false,
403 max_canvas_file_index: 0,
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 fn sample(pages: usize) -> Vec<u8> {
413 let connection = Connection::open_in_memory().unwrap();
414 connection
415 .execute_batch(
416 "CREATE TABLE Project(_PW_ID INTEGER PRIMARY KEY AUTOINCREMENT, ProjectInternalVersion TEXT DEFAULT NULL, \
417 ProjectRootCanvasNode INTEGER DEFAULT NULL, ComicBindPosition INTEGER DEFAULT NULL, ComicStartPage INTEGER DEFAULT NULL, \
418 ComicCoverExist INTEGER DEFAULT NULL, DefaultPageUnit INTEGER DEFAULT NULL, DefaultPageWidth REAL DEFAULT NULL, \
419 DefaultPageHeight REAL DEFAULT NULL, DefaultPageResolution REAL DEFAULT NULL, DefaultPageDoublePage INTEGER DEFAULT NULL, \
420 MaxCanvasFileIndex INTEGER DEFAULT NULL);
421 CREATE TABLE CanvasNode(_PW_ID INTEGER PRIMARY KEY AUTOINCREMENT, MainId INTEGER DEFAULT NULL, Type INTEGER DEFAULT NULL, \
422 NextIndex INTEGER DEFAULT NULL, FirstChildIndex INTEGER DEFAULT NULL, SelectedIndex INTEGER DEFAULT NULL, \
423 CanvasIndex INTEGER DEFAULT NULL, PageFlag INTEGER DEFAULT NULL, Memo TEXT DEFAULT NULL, BookBindMemo TEXT DEFAULT NULL, \
424 BookBindWarning INTEGER DEFAULT NULL, BookBindWarningDef INTEGER DEFAULT NULL, LinkPath TEXT DEFAULT NULL);
425 CREATE TABLE ElemScheme(_PW_ID INTEGER PRIMARY KEY AUTOINCREMENT, TableName TEXT DEFAULT NULL, ElemType INTEGER DEFAULT NULL, \
426 MaxIndex INTEGER DEFAULT NULL);",
427 )
428 .unwrap();
429 connection
430 .execute(
431 "INSERT INTO Project VALUES (1, '1.1.0', 1, 0, 0, 0, 2, 257.0, 364.0, 350.0, 0, ?1)",
432 [pages as i64],
433 )
434 .unwrap();
435 let first = if pages > 0 { 2 } else { 0 };
436 connection
437 .execute("INSERT INTO CanvasNode VALUES (1, 1, 0, 0, ?1, ?1, 0, 0, '', '', 0, 0, NULL)", [first])
438 .unwrap();
439 for page in 1..=pages as i64 {
440 let next = if page < pages as i64 { page + 2 } else { 0 };
441 connection
442 .execute(
443 "INSERT INTO CanvasNode VALUES (?1, ?1, 2, ?2, 0, 0, 0, 0, '', '', 0, 1, ?3)",
444 (page + 1, next, format!(".:page{page:04}.clip")),
445 )
446 .unwrap();
447 }
448 connection.execute("INSERT INTO ElemScheme VALUES (1, 'CanvasNode', 0, ?1)", [pages as i64 + 1]).unwrap();
449 database::serialize(&connection).unwrap()
450 }
451
452 #[test]
453 fn reads_the_page_list() {
454 let file = CmcFile::from_bytes(sample(3)).unwrap();
455 let pages: Vec<String> =
456 file.pages().unwrap().iter().map(|node| node.relative_path().unwrap().display().to_string()).collect();
457 assert_eq!(pages, ["page0001.clip", "page0002.clip", "page0003.clip"]);
458 assert!(file.findings(None).is_empty(), "{:?}", file.findings(None));
459 assert_eq!(file.to_bytes().unwrap(), sample(3));
460 }
461
462 #[test]
463 fn edits_keep_the_list_consistent() {
464 let mut file = CmcFile::from_bytes(sample(3)).unwrap();
465 file.reorder(&[4, 2, 3]).unwrap();
466 let (id, name) = file.add_page(Some(2), false).unwrap();
467 assert_eq!((id, name.as_str()), (5, "page0004.clip"));
468 assert_eq!(file.remove_page(3).unwrap(), Some(PathBuf::from("page0002.clip")));
469 let file = CmcFile::from_bytes(file.to_bytes().unwrap()).unwrap();
470 let order: Vec<i64> = file.pages().unwrap().iter().map(|node| node.main_id).collect();
471 assert_eq!(order, [4, 2, 5]);
472 assert_eq!(file.project.max_canvas_file_index, 4);
473 assert!(file.findings(None).is_empty(), "{:?}", file.findings(None));
474 let mut file = file;
475 assert!(file.reorder(&[4, 2]).is_err());
476 assert!(file.reorder(&[4, 2, 2]).is_err());
477 }
478
479 #[test]
480 fn loops_are_reported() {
481 let mut file = CmcFile::from_bytes(sample(2)).unwrap();
482 file.connection.execute("UPDATE CanvasNode SET NextIndex = 2 WHERE MainId = 3", []).unwrap();
483 file.reload().unwrap();
484 assert!(file.pages().is_err());
485 assert!(!file.findings(None).is_empty());
486 }
487}