1use anyhow::{Context, Result, bail};
2use rusqlite::{Connection, params};
3use uuid::Uuid;
4
5use crate::db::{coredata_to_unix, now_coredata, open_ro, open_rw};
6use crate::model::{
7 Attachment, InsertPosition, Note, PinRecord, SortDir, SortField, Tag, TagPosition,
8};
9use crate::notify::request_app_refresh;
10use crate::prefs::check_app_lock;
11use crate::search::parse_query;
12
13fn col_bool(v: Option<i64>) -> bool {
16 v.unwrap_or(0) != 0
17}
18
19fn col_i64(v: Option<i64>) -> i64 {
20 v.unwrap_or(0)
21}
22
23fn col_ts(v: Option<f64>) -> i64 {
24 v.map(coredata_to_unix).unwrap_or(0)
25}
26
27fn col_str(v: Option<String>) -> String {
28 v.unwrap_or_default()
29}
30
31fn row_to_note(row: &rusqlite::Row<'_>) -> rusqlite::Result<Note> {
34 Ok(Note {
35 pk: row.get(0)?,
36 id: col_str(row.get(1)?),
37 title: col_str(row.get(2)?),
38 text: col_str(row.get(3)?),
39 created: col_ts(row.get(4)?),
40 modified: col_ts(row.get(5)?),
41 trashed: col_bool(row.get(6)?),
42 archived: col_bool(row.get(7)?),
43 pinned: col_bool(row.get(8)?),
44 locked: col_bool(row.get(9)?),
45 encrypted: col_bool(row.get(10)?),
46 has_images: col_bool(row.get(11)?),
47 has_files: col_bool(row.get(12)?),
48 has_source_code: col_bool(row.get(13)?),
49 todo_completed: col_i64(row.get(14)?),
50 todo_incompleted: col_i64(row.get(15)?),
51 tags: Vec::new(),
52 attachments: Vec::new(),
53 pinned_in_tags: Vec::new(),
54 })
55}
56
57const NOTE_COLS: &str = "n.Z_PK, n.ZUNIQUEIDENTIFIER, n.ZTITLE, n.ZTEXT,
58 n.ZCREATIONDATE, n.ZMODIFICATIONDATE,
59 n.ZTRASHED, n.ZARCHIVED, n.ZPINNED, n.ZLOCKED, n.ZENCRYPTED,
60 n.ZHASIMAGES, n.ZHASFILES, n.ZHASSOURCECODE,
61 n.ZTODOCOMPLETED, n.ZTODOINCOMPLETED";
62
63#[derive(Default)]
66pub struct ListInput<'a> {
67 pub tag: Option<&'a str>,
68 pub sort: Vec<(SortField, SortDir)>,
69 pub limit: Option<usize>,
70 pub include_trashed: bool,
71 pub include_archived: bool,
72 pub include_tags: bool,
73}
74
75pub struct EditOp {
76 pub at: String,
77 pub replace: Option<String>,
78 pub insert: Option<String>,
79 pub all: bool,
80 pub ignore_case: bool,
81 pub word: bool,
82}
83
84pub struct SqliteStore {
85 conn: Connection,
86}
87
88impl SqliteStore {
89 pub fn open_ro() -> Result<Self> {
91 Ok(SqliteStore { conn: open_ro()? })
92 }
93
94 pub fn open_rw() -> Result<Self> {
96 Ok(SqliteStore { conn: open_rw()? })
97 }
98
99 fn tags_for_note(&self, note_pk: i64) -> Result<Vec<String>> {
102 let mut stmt = self.conn.prepare_cached(
103 "SELECT t.ZTITLE FROM ZSFNOTETAG t
104 JOIN Z_5TAGS jt ON jt.Z_13TAGS = t.Z_PK
105 WHERE jt.Z_5NOTES = ?
106 ORDER BY t.ZTITLE",
107 )?;
108 let tags: Result<Vec<String>> = stmt
109 .query_map(params![note_pk], |row| row.get(0))?
110 .map(|r| r.map_err(Into::into))
111 .collect();
112 tags
113 }
114
115 fn attachments_for_note(&self, note_pk: i64) -> Result<Vec<Attachment>> {
116 let mut stmt = self.conn.prepare_cached(
117 "SELECT ZFILENAME, ZFILESIZE, ZUNIQUEIDENTIFIER FROM ZSFNOTEFILE
118 WHERE ZNOTE = ?
119 AND (ZUNUSED IS NULL OR ZUNUSED = 0)
120 AND (ZPERMANENTLYDELETED IS NULL OR ZPERMANENTLYDELETED = 0)
121 ORDER BY ZINSERTIONDATE",
122 )?;
123 let rows: Result<Vec<Attachment>> = stmt
124 .query_map(params![note_pk], |row| {
125 Ok(Attachment {
126 filename: col_str(row.get(0)?),
127 size: col_i64(row.get(1)?),
128 uuid: col_str(row.get(2)?),
129 })
130 })?
131 .map(|r| r.map_err(Into::into))
132 .collect();
133 rows
134 }
135
136 fn pin_contexts_for_note(&self, note_pk: i64, globally_pinned: bool) -> Result<Vec<String>> {
137 let mut contexts = Vec::new();
138 if globally_pinned {
139 contexts.push("global".to_string());
140 }
141 let mut stmt = self.conn.prepare_cached(
142 "SELECT t.ZTITLE FROM ZSFNOTETAG t
143 JOIN Z_5PINNEDINTAGS jp ON jp.Z_13PINNEDINTAGS = t.Z_PK
144 WHERE jp.Z_5PINNEDNOTES = ?
145 ORDER BY t.ZTITLE",
146 )?;
147 let tag_pins: Result<Vec<String>> = stmt
148 .query_map(params![note_pk], |row| row.get(0))?
149 .map(|r| r.map_err(Into::into))
150 .collect();
151 contexts.extend(tag_pins?);
152 Ok(contexts)
153 }
154
155 fn populate_note(
156 &self,
157 mut note: Note,
158 include_tags: bool,
159 include_attachments: bool,
160 include_pins: bool,
161 ) -> Result<Note> {
162 if include_tags {
163 note.tags = self.tags_for_note(note.pk)?;
164 }
165 if include_attachments {
166 note.attachments = self.attachments_for_note(note.pk)?;
167 }
168 if include_pins {
169 note.pinned_in_tags = self.pin_contexts_for_note(note.pk, note.pinned)?;
170 }
171 Ok(note)
172 }
173
174 pub fn resolve_note(
179 &self,
180 id: Option<&str>,
181 title: Option<&str>,
182 include_trashed: bool,
183 include_archived: bool,
184 ) -> Result<Note> {
185 let trashed_clause = if include_trashed {
186 ""
187 } else {
188 "AND (n.ZTRASHED IS NULL OR n.ZTRASHED = 0)"
189 };
190 let archived_clause = if include_archived {
191 ""
192 } else {
193 "AND (n.ZARCHIVED IS NULL OR n.ZARCHIVED = 0)"
194 };
195 let base = format!(
196 "FROM ZSFNOTE n
197 WHERE (n.ZPERMANENTLYDELETED IS NULL OR n.ZPERMANENTLYDELETED = 0)
198 {trashed_clause}
199 {archived_clause}"
200 );
201
202 if let Some(uid) = id {
203 let sql = format!("SELECT {NOTE_COLS} {base} AND n.ZUNIQUEIDENTIFIER = ?");
204 let note = self
205 .conn
206 .query_row(&sql, params![uid], row_to_note)
207 .with_context(|| format!("Note not found: {uid}"))?;
208 return self.populate_note(note, true, false, false);
209 }
210
211 if let Some(t) = title {
212 let sql_exact = format!(
214 "SELECT {NOTE_COLS} {base}
215 AND n.ZTITLE = ? COLLATE NOCASE
216 ORDER BY n.ZMODIFICATIONDATE DESC
217 LIMIT 1"
218 );
219 let result = self.conn.query_row(&sql_exact, params![t], row_to_note);
220 if let Ok(note) = result {
221 return self.populate_note(note, true, false, false);
222 }
223 bail!("Note not found: {t}");
224 }
225
226 bail!("provide an id or --title to identify the note")
227 }
228
229 pub fn list_notes(&self, input: &ListInput<'_>) -> Result<Vec<Note>> {
232 let tag_join = if let Some(tag) = input.tag {
233 format!(
234 "JOIN Z_5TAGS jt ON jt.Z_5NOTES = n.Z_PK \
235 JOIN ZSFNOTETAG ft ON ft.Z_PK = jt.Z_13TAGS AND ft.ZTITLE = '{}'",
236 tag.replace('\'', "''")
237 )
238 } else {
239 String::new()
240 };
241
242 let trashed_clause = if input.include_trashed {
243 ""
244 } else {
245 "AND (n.ZTRASHED IS NULL OR n.ZTRASHED = 0)"
246 };
247 let archived_clause = if input.include_archived {
248 ""
249 } else {
250 "AND (n.ZARCHIVED IS NULL OR n.ZARCHIVED = 0)"
251 };
252
253 let order_clause = if input.sort.is_empty() {
254 "ORDER BY n.ZPINNED DESC NULLS LAST, n.ZMODIFICATIONDATE DESC".to_string()
256 } else {
257 let parts: Vec<String> = input
258 .sort
259 .iter()
260 .map(|(field, dir)| {
261 let dir_str = match dir {
262 SortDir::Asc => "ASC",
263 SortDir::Desc => "DESC",
264 };
265 format!("{} {}", field.sql_column(), dir_str)
266 })
267 .collect();
268 format!("ORDER BY {}", parts.join(", "))
269 };
270
271 let limit_clause = input
272 .limit
273 .map(|n| format!("LIMIT {n}"))
274 .unwrap_or_default();
275
276 let sql = format!(
277 "SELECT {NOTE_COLS} FROM ZSFNOTE n
278 {tag_join}
279 WHERE (n.ZPERMANENTLYDELETED IS NULL OR n.ZPERMANENTLYDELETED = 0)
280 {trashed_clause}
281 {archived_clause}
282 {order_clause}
283 {limit_clause}"
284 );
285
286 let mut stmt = self.conn.prepare(&sql)?;
287 let notes: Result<Vec<Note>> = stmt
288 .query_map([], row_to_note)?
289 .map(|r| r.map_err(Into::into))
290 .collect();
291 let mut notes = notes?;
292
293 if input.include_tags {
294 for note in &mut notes {
295 note.tags = self.tags_for_note(note.pk)?;
296 }
297 }
298 Ok(notes)
299 }
300
301 pub fn get_note(
304 &self,
305 id: Option<&str>,
306 title: Option<&str>,
307 include_attachments: bool,
308 include_pins: bool,
309 ) -> Result<Note> {
310 let mut note = self.resolve_note(id, title, false, false)?;
311 if include_attachments {
312 note.attachments = self.attachments_for_note(note.pk)?;
313 }
314 if include_pins {
315 note.pinned_in_tags = self.pin_contexts_for_note(note.pk, note.pinned)?;
316 }
317 Ok(note)
318 }
319
320 pub fn cat_note(
323 &self,
324 id: Option<&str>,
325 title: Option<&str>,
326 offset: Option<usize>,
327 limit: Option<usize>,
328 ) -> Result<String> {
329 let note = self.resolve_note(id, title, false, false)?;
330 let text = ¬e.text;
331 let start = offset.unwrap_or(0).min(text.len());
332 let end = limit
333 .map(|l| (start + l).min(text.len()))
334 .unwrap_or(text.len());
335 Ok(text[start..end].to_string())
336 }
337
338 pub fn search_notes(&self, query: &str, limit: Option<usize>) -> Result<Vec<Note>> {
341 let pq = parse_query(query);
342
343 let join_str = pq.joins.join("\n");
344 let where_extra = if pq.clauses.is_empty() {
345 String::new()
346 } else {
347 format!("AND {}", pq.clauses.join(" AND "))
348 };
349 let limit_clause = limit.map(|n| format!("LIMIT {n}")).unwrap_or_default();
350
351 let sql = format!(
352 "SELECT DISTINCT {NOTE_COLS} FROM ZSFNOTE n
353 {join_str}
354 WHERE (n.ZPERMANENTLYDELETED IS NULL OR n.ZPERMANENTLYDELETED = 0)
355 AND (n.ZTRASHED IS NULL OR n.ZTRASHED = 0)
356 AND (n.ZARCHIVED IS NULL OR n.ZARCHIVED = 0)
357 {where_extra}
358 ORDER BY n.ZMODIFICATIONDATE DESC
359 {limit_clause}"
360 );
361
362 let mut stmt = self.conn.prepare(&sql)?;
363 let param_refs: Vec<&dyn rusqlite::ToSql> = pq
364 .params
365 .iter()
366 .map(|s| s as &dyn rusqlite::ToSql)
367 .collect();
368
369 let notes: Result<Vec<Note>> = stmt
370 .query_map(param_refs.as_slice(), row_to_note)?
371 .map(|r| r.map_err(Into::into))
372 .collect();
373 let mut notes = notes?;
374 for note in &mut notes {
375 note.tags = self.tags_for_note(note.pk)?;
376 }
377 Ok(notes)
378 }
379
380 pub fn search_in_note(
384 &self,
385 id: Option<&str>,
386 title: Option<&str>,
387 string: &str,
388 ignore_case: bool,
389 ) -> Result<Vec<(usize, String)>> {
390 let note = self.resolve_note(id, title, false, false)?;
391 let needle = if ignore_case {
392 string.to_lowercase()
393 } else {
394 string.to_string()
395 };
396 let mut matches = Vec::new();
397 for (i, line) in note.text.lines().enumerate() {
398 let hay = if ignore_case {
399 line.to_lowercase()
400 } else {
401 line.to_string()
402 };
403 if hay.contains(&needle) {
404 matches.push((i + 1, line.to_string()));
405 }
406 }
407 Ok(matches)
408 }
409
410 pub fn list_tags(&self, note_id: Option<&str>, note_title: Option<&str>) -> Result<Vec<Tag>> {
413 if note_id.is_some() || note_title.is_some() {
414 let note = self.resolve_note(note_id, note_title, false, false)?;
415 let names = self.tags_for_note(note.pk)?;
416 return Ok(names
417 .into_iter()
418 .enumerate()
419 .map(|(i, name)| Tag { name, pk: i as i64 })
420 .collect());
421 }
422 let mut stmt = self
423 .conn
424 .prepare_cached("SELECT Z_PK, ZTITLE FROM ZSFNOTETAG ORDER BY ZTITLE")?;
425 let tags: Result<Vec<Tag>> = stmt
426 .query_map([], |row| {
427 Ok(Tag {
428 pk: row.get(0)?,
429 name: col_str(row.get(1)?),
430 })
431 })?
432 .map(|r| r.map_err(Into::into))
433 .collect();
434 tags
435 }
436
437 pub fn list_pins(
440 &self,
441 note_id: Option<&str>,
442 note_title: Option<&str>,
443 ) -> Result<Vec<PinRecord>> {
444 if note_id.is_some() || note_title.is_some() {
445 let note = self.resolve_note(note_id, note_title, false, false)?;
446 let contexts = self.pin_contexts_for_note(note.pk, note.pinned)?;
447 return Ok(contexts
448 .into_iter()
449 .map(|pin| PinRecord {
450 note_id: note.id.clone(),
451 pin,
452 })
453 .collect());
454 }
455
456 let mut pins = Vec::new();
458
459 let mut stmt = self.conn.prepare_cached(
461 "SELECT ZUNIQUEIDENTIFIER FROM ZSFNOTE
462 WHERE ZPINNED = 1
463 AND (ZPERMANENTLYDELETED IS NULL OR ZPERMANENTLYDELETED = 0)
464 AND (ZTRASHED IS NULL OR ZTRASHED = 0)",
465 )?;
466 let global: Result<Vec<String>> = stmt
467 .query_map([], |row| row.get(0))?
468 .map(|r| r.map_err(Into::into))
469 .collect();
470 for note_id in global? {
471 pins.push(PinRecord {
472 note_id,
473 pin: "global".to_string(),
474 });
475 }
476
477 let mut stmt = self.conn.prepare_cached(
479 "SELECT n.ZUNIQUEIDENTIFIER, t.ZTITLE
480 FROM ZSFNOTE n
481 JOIN Z_5PINNEDINTAGS jp ON jp.Z_5PINNEDNOTES = n.Z_PK
482 JOIN ZSFNOTETAG t ON t.Z_PK = jp.Z_13PINNEDINTAGS
483 WHERE (n.ZPERMANENTLYDELETED IS NULL OR n.ZPERMANENTLYDELETED = 0)
484 AND (n.ZTRASHED IS NULL OR n.ZTRASHED = 0)
485 ORDER BY t.ZTITLE, n.ZUNIQUEIDENTIFIER",
486 )?;
487 let tag_pins: Result<Vec<PinRecord>> = stmt
488 .query_map([], |row| {
489 Ok(PinRecord {
490 note_id: col_str(row.get(0)?),
491 pin: col_str(row.get(1)?),
492 })
493 })?
494 .map(|r| r.map_err(Into::into))
495 .collect();
496 pins.extend(tag_pins?);
497 Ok(pins)
498 }
499
500 pub fn list_attachments(
503 &self,
504 note_id: Option<&str>,
505 note_title: Option<&str>,
506 ) -> Result<Vec<Attachment>> {
507 let note = self.resolve_note(note_id, note_title, false, false)?;
508 self.attachments_for_note(note.pk)
509 }
510
511 pub fn read_attachment(
514 &self,
515 note_id: Option<&str>,
516 note_title: Option<&str>,
517 filename: &str,
518 ) -> Result<Vec<u8>> {
519 let note = self.resolve_note(note_id, note_title, false, false)?;
520 let attachments = self.attachments_for_note(note.pk)?;
521 let att = attachments
522 .iter()
523 .find(|a| a.filename == filename)
524 .with_context(|| format!("Attachment not found: {filename}"))?;
525
526 let note_uuid = ¬e.id;
527 let container = crate::db::group_container_path()?;
528 let file_path = container
529 .join("Application Data")
530 .join("Local Files")
531 .join("Note Files")
532 .join(&att.uuid)
533 .join(filename);
534
535 if file_path.exists() {
536 return std::fs::read(&file_path)
537 .with_context(|| format!("File not found on disk: {}", file_path.display()));
538 }
539
540 let alt_path = container
542 .join("Application Data")
543 .join("Local Files")
544 .join("Note Files")
545 .join(note_uuid)
546 .join(filename);
547
548 if alt_path.exists() {
549 return std::fs::read(&alt_path).context("Attachment file not found on disk");
550 }
551
552 bail!("Attachment file not found on disk: {filename}")
553 }
554
555 fn get_or_create_tag_pk(&self, name: &str) -> Result<i64> {
561 let existing: Option<i64> = self
563 .conn
564 .query_row(
565 "SELECT Z_PK FROM ZSFNOTETAG WHERE ZTITLE = ?",
566 params![name],
567 |row| row.get(0),
568 )
569 .ok();
570
571 if let Some(pk) = existing {
572 return Ok(pk);
573 }
574
575 let next_pk: i64 = self
577 .conn
578 .query_row(
579 "SELECT Z_MAX FROM Z_PRIMARYKEY WHERE Z_NAME = 'SFNoteTag'",
580 [],
581 |row| row.get(0),
582 )
583 .unwrap_or(0)
584 + 1;
585
586 self.conn.execute(
588 "UPDATE Z_PRIMARYKEY SET Z_MAX = ? WHERE Z_NAME = 'SFNoteTag'",
589 params![next_pk],
590 )?;
591
592 let ent: i64 = self
593 .conn
594 .query_row(
595 "SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME = 'SFNoteTag'",
596 [],
597 |row| row.get(0),
598 )
599 .unwrap_or(crate::db::SFNOTETAG_ENT);
600
601 let uuid = Uuid::new_v4().to_string().to_uppercase();
602 let now = now_coredata();
603 self.conn.execute(
604 "INSERT INTO ZSFNOTETAG (Z_PK, Z_ENT, Z_OPT, ZTITLE, ZUNIQUEIDENTIFIER,
605 ZSORTING, ZSORTINGDIRECTION, ZPINNED, ZHIDESUBTAGSNOTES, ZISROOT, ZVERSION,
606 ZMODIFICATIONDATE)
607 VALUES (?, ?, 1, ?, ?, 0, 0, 0, 0, 0, 1, ?)",
608 params![next_pk, ent, name, uuid, now],
609 )?;
610 Ok(next_pk)
611 }
612
613 fn add_tags_to_note(&self, note_pk: i64, tags: &[&str]) -> Result<()> {
614 for &tag in tags {
615 let tag_pk = self.get_or_create_tag_pk(tag)?;
616 self.conn.execute(
618 "INSERT OR IGNORE INTO Z_5TAGS (Z_5NOTES, Z_13TAGS) VALUES (?, ?)",
619 params![note_pk, tag_pk],
620 )?;
621 }
622 Ok(())
623 }
624
625 fn next_note_pk(&self) -> Result<i64> {
626 let max: i64 = self
627 .conn
628 .query_row(
629 "SELECT Z_MAX FROM Z_PRIMARYKEY WHERE Z_NAME = 'SFNote'",
630 [],
631 |row| row.get(0),
632 )
633 .unwrap_or(0)
634 + 1;
635 self.conn.execute(
636 "UPDATE Z_PRIMARYKEY SET Z_MAX = ? WHERE Z_NAME = 'SFNote'",
637 params![max],
638 )?;
639 Ok(max)
640 }
641
642 fn note_ent(&self) -> i64 {
643 self.conn
644 .query_row(
645 "SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME = 'SFNote'",
646 [],
647 |row| row.get(0),
648 )
649 .unwrap_or(crate::db::SFNOTE_ENT)
650 }
651
652 pub fn create_note(&self, text: &str, tags: &[&str], if_not_exists: bool) -> Result<Note> {
655 check_app_lock()?;
656
657 let title = extract_title(text);
659
660 if if_not_exists && !title.is_empty() {
662 let existing = self.conn.query_row(
663 "SELECT ZUNIQUEIDENTIFIER FROM ZSFNOTE
664 WHERE ZTITLE = ? COLLATE NOCASE
665 AND (ZTRASHED IS NULL OR ZTRASHED = 0)
666 AND (ZPERMANENTLYDELETED IS NULL OR ZPERMANENTLYDELETED = 0)
667 LIMIT 1",
668 params![title],
669 |row| row.get::<_, String>(0),
670 );
671 if let Ok(id) = existing {
672 return self.resolve_note(Some(&id), None, false, false);
673 }
674 }
675
676 let pk = self.next_note_pk()?;
677 let ent = self.note_ent();
678 let id = Uuid::new_v4().to_string().to_uppercase();
679 let now = now_coredata();
680
681 self.conn.execute(
682 "INSERT INTO ZSFNOTE (Z_PK, Z_ENT, Z_OPT, ZUNIQUEIDENTIFIER, ZTITLE, ZTEXT,
683 ZCREATIONDATE, ZMODIFICATIONDATE,
684 ZTRASHED, ZARCHIVED, ZPINNED, ZLOCKED, ZENCRYPTED,
685 ZHASIMAGES, ZHASFILES, ZHASSOURCECODE,
686 ZTODOCOMPLETED, ZTODOINCOMPLETED, ZVERSION,
687 ZPERMANENTLYDELETED)
688 VALUES (?,?,1,?,?,?,?,?,0,0,0,0,0,0,0,0,0,0,1,0)",
689 params![pk, ent, id, title, text, now, now],
690 )?;
691
692 if !tags.is_empty() {
693 self.add_tags_to_note(pk, tags)?;
694 }
695
696 request_app_refresh();
697 self.resolve_note(Some(&id), None, false, false)
698 }
699
700 pub fn append_to_note(
703 &self,
704 note_id: Option<&str>,
705 note_title: Option<&str>,
706 content: &str,
707 position: InsertPosition,
708 update_modified: bool,
709 tag_position: TagPosition,
710 ) -> Result<()> {
711 check_app_lock()?;
712 let note = self.resolve_note(note_id, note_title, false, false)?;
713 let current = ¬e.text;
714
715 let new_text = match position {
716 InsertPosition::End => {
717 if tag_position == TagPosition::Bottom {
719 insert_before_bottom_tags(current, content)
720 } else {
721 format!("{current}\n{content}")
722 }
723 }
724 InsertPosition::Beginning => {
725 if tag_position == TagPosition::Top {
727 insert_after_title_block(current, content)
728 } else {
729 insert_after_first_line(current, content)
731 }
732 }
733 };
734
735 let now = if update_modified {
736 now_coredata()
737 } else {
738 self.conn
740 .query_row(
741 "SELECT ZMODIFICATIONDATE FROM ZSFNOTE WHERE Z_PK = ?",
742 params![note.pk],
743 |row| row.get(0),
744 )
745 .unwrap_or_else(|_| now_coredata())
746 };
747
748 self.conn.execute(
749 "UPDATE ZSFNOTE SET ZTEXT = ?, ZMODIFICATIONDATE = ? WHERE Z_PK = ?",
750 params![new_text, now, note.pk],
751 )?;
752 request_app_refresh();
753 Ok(())
754 }
755
756 pub fn write_note(
759 &self,
760 note_id: Option<&str>,
761 note_title: Option<&str>,
762 content: &str,
763 base_hash: Option<&str>,
764 ) -> Result<()> {
765 check_app_lock()?;
766 let note = self.resolve_note(note_id, note_title, false, false)?;
767
768 if let Some(expected) = base_hash {
769 let actual = note.hash();
770 if actual != expected {
771 bail!(
772 "hashMismatch: base hash does not match current note content \
773 (expected {expected}, got {actual})"
774 );
775 }
776 }
777
778 let title = extract_title(content);
779 let now = now_coredata();
780 self.conn.execute(
781 "UPDATE ZSFNOTE SET ZTEXT = ?, ZTITLE = ?, ZMODIFICATIONDATE = ? WHERE Z_PK = ?",
782 params![content, title, now, note.pk],
783 )?;
784 request_app_refresh();
785 Ok(())
786 }
787
788 pub fn edit_note(
791 &self,
792 note_id: Option<&str>,
793 note_title: Option<&str>,
794 ops: &[EditOp],
795 ) -> Result<()> {
796 check_app_lock()?;
797 let note = self.resolve_note(note_id, note_title, false, false)?;
798 let mut text = note.text.clone();
799 let mut any_match = false;
800
801 for op in ops {
802 let result = apply_edit_op(&text, op);
803 if result.matched {
804 any_match = true;
805 text = result.text;
806 } else {
807 bail!("String not found in note: {}", op.at);
808 }
809 }
810
811 if !any_match {
812 return Ok(());
813 }
814
815 let now = now_coredata();
816 self.conn.execute(
817 "UPDATE ZSFNOTE SET ZTEXT = ?, ZMODIFICATIONDATE = ? WHERE Z_PK = ?",
818 params![text, now, note.pk],
819 )?;
820 request_app_refresh();
821 Ok(())
822 }
823
824 pub fn trash_note(&self, note_id: Option<&str>, note_title: Option<&str>) -> Result<()> {
827 check_app_lock()?;
828 let note = self.resolve_note(note_id, note_title, true, true)?;
829 let now = now_coredata();
830 self.conn.execute(
831 "UPDATE ZSFNOTE SET ZTRASHED = 1, ZTRASHEDDATE = ? WHERE Z_PK = ?",
832 params![now, note.pk],
833 )?;
834 request_app_refresh();
835 Ok(())
836 }
837
838 pub fn archive_note(&self, note_id: Option<&str>, note_title: Option<&str>) -> Result<()> {
839 check_app_lock()?;
840 let note = self.resolve_note(note_id, note_title, false, true)?;
841 let now = now_coredata();
842 self.conn.execute(
843 "UPDATE ZSFNOTE SET ZARCHIVED = 1, ZARCHIVEDDATE = ? WHERE Z_PK = ?",
844 params![now, note.pk],
845 )?;
846 request_app_refresh();
847 Ok(())
848 }
849
850 pub fn restore_note(&self, note_id: Option<&str>, note_title: Option<&str>) -> Result<()> {
851 check_app_lock()?;
852 let note = self.resolve_note(note_id, note_title, true, true)?;
853 self.conn.execute(
854 "UPDATE ZSFNOTE SET ZTRASHED = 0, ZTRASHEDDATE = NULL,
855 ZARCHIVED = 0, ZARCHIVEDDATE = NULL
856 WHERE Z_PK = ?",
857 params![note.pk],
858 )?;
859 request_app_refresh();
860 Ok(())
861 }
862
863 pub fn add_tags(
866 &self,
867 note_id: Option<&str>,
868 note_title: Option<&str>,
869 tags: &[&str],
870 ) -> Result<()> {
871 check_app_lock()?;
872 let note = self.resolve_note(note_id, note_title, false, false)?;
873 self.add_tags_to_note(note.pk, tags)?;
874 let now = now_coredata();
877 self.conn.execute(
878 "UPDATE ZSFNOTE SET ZMODIFICATIONDATE = ? WHERE Z_PK = ?",
879 params![now, note.pk],
880 )?;
881 request_app_refresh();
882 Ok(())
883 }
884
885 pub fn remove_tags(
886 &self,
887 note_id: Option<&str>,
888 note_title: Option<&str>,
889 tags: &[&str],
890 ) -> Result<()> {
891 check_app_lock()?;
892 let note = self.resolve_note(note_id, note_title, false, false)?;
893 for &tag in tags {
894 let tag_pk: Option<i64> = self
895 .conn
896 .query_row(
897 "SELECT Z_PK FROM ZSFNOTETAG WHERE ZTITLE = ?",
898 params![tag],
899 |row| row.get(0),
900 )
901 .ok();
902 if let Some(tpk) = tag_pk {
903 self.conn.execute(
904 "DELETE FROM Z_5TAGS WHERE Z_5NOTES = ? AND Z_13TAGS = ?",
905 params![note.pk, tpk],
906 )?;
907 } else {
908 bail!("Tags not found: {tag}");
909 }
910 }
911 let now = now_coredata();
912 self.conn.execute(
913 "UPDATE ZSFNOTE SET ZMODIFICATIONDATE = ? WHERE Z_PK = ?",
914 params![now, note.pk],
915 )?;
916 request_app_refresh();
917 Ok(())
918 }
919
920 pub fn rename_tag(&self, old_name: &str, new_name: &str, force: bool) -> Result<()> {
921 check_app_lock()?;
922
923 let new_exists: bool = self
925 .conn
926 .query_row(
927 "SELECT COUNT(*) FROM ZSFNOTETAG WHERE ZTITLE = ?",
928 params![new_name],
929 |row| row.get::<_, i64>(0),
930 )
931 .map(|n| n > 0)
932 .unwrap_or(false);
933
934 if new_exists && !force {
935 bail!("Tag '{new_name}' already exists. Use --force to merge.");
936 }
937
938 let old_pk: i64 = self
939 .conn
940 .query_row(
941 "SELECT Z_PK FROM ZSFNOTETAG WHERE ZTITLE = ?",
942 params![old_name],
943 |row| row.get(0),
944 )
945 .with_context(|| format!("Tags not found: {old_name}"))?;
946
947 if new_exists {
948 let new_pk: i64 = self
950 .conn
951 .query_row(
952 "SELECT Z_PK FROM ZSFNOTETAG WHERE ZTITLE = ?",
953 params![new_name],
954 |row| row.get(0),
955 )
956 .unwrap();
957 self.conn.execute(
958 "INSERT OR IGNORE INTO Z_5TAGS (Z_5NOTES, Z_13TAGS)
959 SELECT Z_5NOTES, ? FROM Z_5TAGS WHERE Z_13TAGS = ?",
960 params![new_pk, old_pk],
961 )?;
962 self.conn
963 .execute("DELETE FROM Z_5TAGS WHERE Z_13TAGS = ?", params![old_pk])?;
964 self.conn
965 .execute("DELETE FROM ZSFNOTETAG WHERE Z_PK = ?", params![old_pk])?;
966 } else {
967 self.conn.execute(
968 "UPDATE ZSFNOTETAG SET ZTITLE = ? WHERE Z_PK = ?",
969 params![new_name, old_pk],
970 )?;
971 }
972
973 self.rewrite_tag_in_notes(old_name, Some(new_name))?;
975 request_app_refresh();
976 Ok(())
977 }
978
979 pub fn delete_tag(&self, name: &str) -> Result<()> {
980 check_app_lock()?;
981 let tag_pk: i64 = self
982 .conn
983 .query_row(
984 "SELECT Z_PK FROM ZSFNOTETAG WHERE ZTITLE = ?",
985 params![name],
986 |row| row.get(0),
987 )
988 .with_context(|| format!("Tags not found: {name}"))?;
989
990 self.conn
991 .execute("DELETE FROM Z_5TAGS WHERE Z_13TAGS = ?", params![tag_pk])?;
992 self.conn
993 .execute("DELETE FROM ZSFNOTETAG WHERE Z_PK = ?", params![tag_pk])?;
994
995 self.rewrite_tag_in_notes(name, None)?;
996 request_app_refresh();
997 Ok(())
998 }
999
1000 fn rewrite_tag_in_notes(&self, old_name: &str, replacement: Option<&str>) -> Result<()> {
1003 let pattern = format!("%#{}%", old_name.replace('%', "\\%"));
1005 let mut stmt = self.conn.prepare(
1006 "SELECT Z_PK, ZTEXT FROM ZSFNOTE
1007 WHERE ZTEXT LIKE ? ESCAPE '\\'
1008 AND (ZPERMANENTLYDELETED IS NULL OR ZPERMANENTLYDELETED = 0)",
1009 )?;
1010 let rows: Vec<(i64, String)> = stmt
1011 .query_map(params![pattern], |row| {
1012 Ok((col_i64(row.get(0)?), col_str(row.get(1)?)))
1013 })?
1014 .filter_map(|r| r.ok())
1015 .collect();
1016
1017 let now = now_coredata();
1018 for (pk, text) in rows {
1019 let new_text = rewrite_tag_in_text(&text, old_name, replacement);
1020 if new_text != text {
1021 self.conn.execute(
1022 "UPDATE ZSFNOTE SET ZTEXT = ?, ZMODIFICATIONDATE = ? WHERE Z_PK = ?",
1023 params![new_text, now, pk],
1024 )?;
1025 }
1026 }
1027 Ok(())
1028 }
1029
1030 pub fn add_pins(
1033 &self,
1034 note_id: Option<&str>,
1035 note_title: Option<&str>,
1036 contexts: &[&str],
1037 ) -> Result<()> {
1038 check_app_lock()?;
1039 let note = self.resolve_note(note_id, note_title, false, false)?;
1040 let now = now_coredata();
1041
1042 for &ctx in contexts {
1043 if ctx == "global" {
1044 self.conn.execute(
1045 "UPDATE ZSFNOTE SET ZPINNED = 1, ZPINNEDDATE = ? WHERE Z_PK = ?",
1046 params![now, note.pk],
1047 )?;
1048 } else {
1049 let tag_pk = self.get_or_create_tag_pk(ctx)?;
1050 self.conn.execute(
1051 "INSERT OR IGNORE INTO Z_5PINNEDINTAGS (Z_5PINNEDNOTES, Z_13PINNEDINTAGS)
1052 VALUES (?, ?)",
1053 params![note.pk, tag_pk],
1054 )?;
1055 }
1056 }
1057 request_app_refresh();
1058 Ok(())
1059 }
1060
1061 pub fn remove_pins(
1062 &self,
1063 note_id: Option<&str>,
1064 note_title: Option<&str>,
1065 contexts: &[&str],
1066 ) -> Result<()> {
1067 check_app_lock()?;
1068 let note = self.resolve_note(note_id, note_title, false, false)?;
1069
1070 for &ctx in contexts {
1072 if ctx != "global" {
1073 let exists: bool = self
1074 .conn
1075 .query_row(
1076 "SELECT COUNT(*) FROM Z_5PINNEDINTAGS jp
1077 JOIN ZSFNOTETAG t ON t.Z_PK = jp.Z_13PINNEDINTAGS
1078 WHERE jp.Z_5PINNEDNOTES = ? AND t.ZTITLE = ?",
1079 params![note.pk, ctx],
1080 |row| row.get::<_, i64>(0),
1081 )
1082 .map(|n| n > 0)
1083 .unwrap_or(false);
1084 if !exists {
1085 bail!("Tags not found: {ctx}");
1086 }
1087 }
1088 }
1089
1090 for &ctx in contexts {
1091 if ctx == "global" {
1092 self.conn.execute(
1093 "UPDATE ZSFNOTE SET ZPINNED = 0, ZPINNEDDATE = NULL WHERE Z_PK = ?",
1094 params![note.pk],
1095 )?;
1096 } else {
1097 self.conn.execute(
1098 "DELETE FROM Z_5PINNEDINTAGS WHERE Z_5PINNEDNOTES = ?
1099 AND Z_13PINNEDINTAGS = (SELECT Z_PK FROM ZSFNOTETAG WHERE ZTITLE = ?)",
1100 params![note.pk, ctx],
1101 )?;
1102 }
1103 }
1104 request_app_refresh();
1105 Ok(())
1106 }
1107
1108 pub fn add_attachment(
1111 &self,
1112 note_id: Option<&str>,
1113 note_title: Option<&str>,
1114 filename: &str,
1115 data: &[u8],
1116 ) -> Result<()> {
1117 check_app_lock()?;
1118 let note = self.resolve_note(note_id, note_title, false, false)?;
1119
1120 let file_uuid = Uuid::new_v4().to_string().to_uppercase();
1121 let container = crate::db::group_container_path()?;
1122 let dir = container
1123 .join("Application Data")
1124 .join("Local Files")
1125 .join("Note Files")
1126 .join(&file_uuid);
1127 std::fs::create_dir_all(&dir)
1128 .with_context(|| format!("cannot create attachment directory {}", dir.display()))?;
1129 let file_path = dir.join(filename);
1130 std::fs::write(&file_path, data)
1131 .with_context(|| format!("cannot write attachment {}", file_path.display()))?;
1132
1133 let next_pk: i64 = self
1135 .conn
1136 .query_row(
1137 "SELECT Z_MAX FROM Z_PRIMARYKEY WHERE Z_NAME = 'SFNoteFile'",
1138 [],
1139 |row| row.get(0),
1140 )
1141 .unwrap_or(0)
1142 + 1;
1143 self.conn.execute(
1144 "UPDATE Z_PRIMARYKEY SET Z_MAX = ? WHERE Z_NAME = 'SFNoteFile'",
1145 params![next_pk],
1146 )?;
1147
1148 let ent: i64 = self
1149 .conn
1150 .query_row(
1151 "SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME = 'SFNoteFile'",
1152 [],
1153 |row| row.get(0),
1154 )
1155 .unwrap_or(0);
1156
1157 let ext = std::path::Path::new(filename)
1158 .extension()
1159 .map(|e| e.to_string_lossy().to_lowercase())
1160 .unwrap_or_default();
1161 let now = now_coredata();
1162
1163 self.conn.execute(
1164 "INSERT INTO ZSFNOTEFILE (Z_PK, Z_ENT, Z_OPT, ZNOTE, ZUNIQUEIDENTIFIER,
1165 ZFILENAME, ZFILESIZE, ZNORMALIZEDFILEEXTENSION,
1166 ZDOWNLOADED, ZUPLOADED, ZUNUSED, ZPERMANENTLYDELETED,
1167 ZINSERTIONDATE, ZMODIFICATIONDATE, ZCREATIONDATE, ZVERSION)
1168 VALUES (?,?,1,?,?,?,?,?,1,0,0,0,?,?,?,1)",
1169 params![
1170 next_pk,
1171 ent,
1172 note.pk,
1173 file_uuid,
1174 filename,
1175 data.len() as i64,
1176 ext,
1177 now,
1178 now,
1179 now
1180 ],
1181 )?;
1182
1183 let now_mod = now_coredata();
1184 self.conn.execute(
1185 "UPDATE ZSFNOTE SET ZHASFILES = 1, ZMODIFICATIONDATE = ? WHERE Z_PK = ?",
1186 params![now_mod, note.pk],
1187 )?;
1188
1189 request_app_refresh();
1190 Ok(())
1191 }
1192
1193 pub fn delete_attachment(
1194 &self,
1195 note_id: Option<&str>,
1196 note_title: Option<&str>,
1197 filename: &str,
1198 ) -> Result<()> {
1199 check_app_lock()?;
1200 let note = self.resolve_note(note_id, note_title, false, false)?;
1201
1202 let rows = self.conn.execute(
1203 "UPDATE ZSFNOTEFILE SET ZUNUSED = 1
1204 WHERE ZNOTE = ? AND ZFILENAME = ?
1205 AND (ZUNUSED IS NULL OR ZUNUSED = 0)",
1206 params![note.pk, filename],
1207 )?;
1208
1209 if rows == 0 {
1210 bail!("Attachment not found: {filename}");
1211 }
1212 request_app_refresh();
1213 Ok(())
1214 }
1215}
1216
1217pub fn extract_title(text: &str) -> String {
1221 for line in text.lines() {
1222 let trimmed = line.trim();
1223 if let Some(rest) = trimmed.strip_prefix("# ") {
1224 return rest.trim().to_string();
1225 }
1226 if !trimmed.is_empty() {
1227 return trimmed.to_string();
1228 }
1229 }
1230 String::new()
1231}
1232
1233fn insert_before_bottom_tags(text: &str, content: &str) -> String {
1234 let lines: Vec<&str> = text.lines().collect();
1236 let mut split_at = lines.len();
1237 for i in (0..lines.len()).rev() {
1238 let trimmed = lines[i].trim();
1239 if trimmed.is_empty() {
1240 continue;
1241 }
1242 if is_tag_line(trimmed) {
1243 split_at = i;
1244 } else {
1245 break;
1246 }
1247 }
1248 if split_at == lines.len() {
1249 format!("{text}\n{content}")
1251 } else {
1252 let before = lines[..split_at].join("\n");
1253 let after = lines[split_at..].join("\n");
1254 format!("{before}\n{content}\n{after}")
1255 }
1256}
1257
1258fn insert_after_title_block(text: &str, content: &str) -> String {
1259 let lines: Vec<&str> = text.lines().collect();
1261 let mut insert_after = 0;
1262 let mut past_title = false;
1263 for (i, line) in lines.iter().enumerate() {
1264 let trimmed = line.trim();
1265 if !past_title {
1266 if trimmed.starts_with("# ") || trimmed.is_empty() {
1267 insert_after = i + 1;
1268 if trimmed.starts_with("# ") {
1269 past_title = true;
1270 }
1271 } else {
1272 break;
1273 }
1274 } else if is_tag_line(trimmed) || trimmed.is_empty() {
1275 insert_after = i + 1;
1276 } else {
1277 break;
1278 }
1279 }
1280 let before = lines[..insert_after].join("\n");
1281 let after = lines[insert_after..].join("\n");
1282 if after.is_empty() {
1283 format!("{before}\n{content}")
1284 } else {
1285 format!("{before}\n{content}\n{after}")
1286 }
1287}
1288
1289fn insert_after_first_line(text: &str, content: &str) -> String {
1290 if let Some(pos) = text.find('\n') {
1291 format!("{}\n{content}{}", &text[..pos], &text[pos..])
1292 } else {
1293 format!("{text}\n{content}")
1294 }
1295}
1296
1297fn is_tag_line(line: &str) -> bool {
1298 line.split_whitespace()
1300 .all(|tok| tok.starts_with('#') && tok.len() > 1)
1301}
1302
1303struct EditResult {
1305 text: String,
1306 matched: bool,
1307}
1308
1309fn apply_edit_op(text: &str, op: &EditOp) -> EditResult {
1310 let needle = if op.ignore_case {
1311 op.at.to_lowercase()
1312 } else {
1313 op.at.clone()
1314 };
1315
1316 let replacement: String = if let Some(r) = &op.replace {
1318 r.clone()
1319 } else if let Some(ins) = &op.insert {
1320 format!("{}{}", op.at, ins)
1322 } else {
1323 op.at.clone()
1324 };
1325
1326 let hay = if op.ignore_case {
1327 text.to_lowercase()
1328 } else {
1329 text.to_string()
1330 };
1331
1332 if !hay.contains(&needle) {
1333 return EditResult {
1334 text: text.to_string(),
1335 matched: false,
1336 };
1337 }
1338
1339 let result = if op.all {
1340 replace_all(text, &hay, &needle, &op.at, &replacement, op.word)
1342 } else {
1343 replace_first(text, &hay, &needle, &op.at, &replacement, op.word)
1345 };
1346
1347 EditResult {
1348 matched: true,
1349 text: result,
1350 }
1351}
1352
1353fn replace_first(
1354 original: &str,
1355 hay: &str,
1356 needle: &str,
1357 original_needle: &str,
1358 replacement: &str,
1359 word: bool,
1360) -> String {
1361 if let Some(pos) = find_match(hay, needle, word) {
1362 let end = pos + original_needle.len();
1363 format!("{}{}{}", &original[..pos], replacement, &original[end..])
1364 } else {
1365 original.to_string()
1366 }
1367}
1368
1369fn replace_all(
1370 original: &str,
1371 hay: &str,
1372 needle: &str,
1373 original_needle: &str,
1374 replacement: &str,
1375 word: bool,
1376) -> String {
1377 let mut result = String::new();
1378 let mut last = 0usize;
1379 let mut search_from = 0usize;
1380
1381 while let Some(pos) = find_match(&hay[search_from..], needle, word) {
1382 let abs_pos = search_from + pos;
1383 result.push_str(&original[last..abs_pos]);
1384 result.push_str(replacement);
1385 last = abs_pos + original_needle.len();
1386 search_from = last;
1387 if search_from >= hay.len() {
1388 break;
1389 }
1390 }
1391 result.push_str(&original[last..]);
1392 result
1393}
1394
1395fn find_match(hay: &str, needle: &str, word: bool) -> Option<usize> {
1396 let pos = hay.find(needle)?;
1397 if !word {
1398 return Some(pos);
1399 }
1400 let before_ok = pos == 0
1402 || hay[..pos]
1403 .chars()
1404 .last()
1405 .map(|c| !c.is_alphanumeric() && c != '_')
1406 .unwrap_or(true);
1407 let after_ok = (pos + needle.len()) >= hay.len()
1408 || hay[pos + needle.len()..]
1409 .chars()
1410 .next()
1411 .map(|c| !c.is_alphanumeric() && c != '_')
1412 .unwrap_or(true);
1413 if before_ok && after_ok {
1414 Some(pos)
1415 } else {
1416 None
1417 }
1418}
1419
1420fn rewrite_tag_in_text(text: &str, old_name: &str, replacement: Option<&str>) -> String {
1423 let marker = format!("#{old_name}");
1424 let mut result = String::with_capacity(text.len());
1425 let mut i = 0;
1426 let bytes = text.as_bytes();
1427
1428 while i < bytes.len() {
1429 if bytes[i] == b'#' {
1430 let rest = &text[i..];
1432 if rest.starts_with(&marker) {
1433 let after = i + marker.len();
1434 let boundary = after >= bytes.len()
1435 || bytes[after].is_ascii_whitespace()
1436 || bytes[after] == b'#';
1437 if boundary {
1438 if let Some(rep) = replacement {
1439 result.push('#');
1440 result.push_str(rep);
1441 }
1442 i += marker.len();
1443 continue;
1444 }
1445 }
1446 }
1447 result.push(text[i..].chars().next().unwrap());
1448 i += text[i..].chars().next().unwrap().len_utf8();
1449 }
1450 result
1451}
1452
1453#[cfg(test)]
1454mod tests {
1455 use super::*;
1456
1457 #[test]
1458 fn extract_title_heading() {
1459 assert_eq!(extract_title("# My Note\n\nBody"), "My Note");
1460 }
1461
1462 #[test]
1463 fn extract_title_first_line() {
1464 assert_eq!(extract_title("Quick note\nBody"), "Quick note");
1465 }
1466
1467 #[test]
1468 fn extract_title_empty() {
1469 assert_eq!(extract_title(""), "");
1470 }
1471
1472 #[test]
1473 fn rewrite_tag_rename() {
1474 let text = "Some text #old and #other";
1475 let result = rewrite_tag_in_text(text, "old", Some("new"));
1476 assert_eq!(result, "Some text #new and #other");
1477 }
1478
1479 #[test]
1480 fn rewrite_tag_remove() {
1481 let text = "Text #remove keep";
1482 let result = rewrite_tag_in_text(text, "remove", None);
1483 assert_eq!(result, "Text keep");
1484 }
1485
1486 #[test]
1487 fn edit_replace_first() {
1488 let op = EditOp {
1489 at: "foo".into(),
1490 replace: Some("bar".into()),
1491 insert: None,
1492 all: false,
1493 ignore_case: false,
1494 word: false,
1495 };
1496 let result = apply_edit_op("foo baz foo", &op);
1497 assert!(result.matched);
1498 assert_eq!(result.text, "bar baz foo");
1499 }
1500
1501 #[test]
1502 fn edit_replace_all() {
1503 let op = EditOp {
1504 at: "x".into(),
1505 replace: Some("Y".into()),
1506 insert: None,
1507 all: true,
1508 ignore_case: false,
1509 word: false,
1510 };
1511 let result = apply_edit_op("x and x", &op);
1512 assert_eq!(result.text, "Y and Y");
1513 }
1514}