Skip to main content

bear_rs/
store.rs

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
13// ── Helpers ───────────────────────────────────────────────────────────────────
14
15fn 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
31// ── Core note row → Note struct ───────────────────────────────────────────────
32
33fn 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// ── SqliteStore ───────────────────────────────────────────────────────────────
64
65#[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    /// Open read-only (sufficient for all read commands).
90    pub fn open_ro() -> Result<Self> {
91        Ok(SqliteStore { conn: open_ro()? })
92    }
93
94    /// Open read-write (required for any mutation).
95    pub fn open_rw() -> Result<Self> {
96        Ok(SqliteStore { conn: open_rw()? })
97    }
98
99    // ── Tag helpers ───────────────────────────────────────────────────────────
100
101    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    // ── Note resolution ───────────────────────────────────────────────────────
175
176    /// Resolve a note by ZUNIQUEIDENTIFIER or case-insensitive title.
177    /// If title matches multiple notes, picks the most recently modified.
178    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            // Exact case-insensitive title match; most-recently-modified wins on ties.
213            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    // ── List notes ────────────────────────────────────────────────────────────
230
231    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            // Default: pinned DESC, modified DESC
255            "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    // ── Get single note (show) ────────────────────────────────────────────────
302
303    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    // ── Cat (raw content) ─────────────────────────────────────────────────────
321
322    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 = &note.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    // ── Search ────────────────────────────────────────────────────────────────
339
340    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    // ── Search-in ─────────────────────────────────────────────────────────────
381
382    /// Returns (line_number, line_text) pairs for lines containing `string`.
383    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    // ── List tags ─────────────────────────────────────────────────────────────
411
412    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    // ── List pins ─────────────────────────────────────────────────────────────
438
439    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        // All pins across the database
457        let mut pins = Vec::new();
458
459        // Global pins (ZPINNED = 1)
460        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        // Tag-scoped pins
478        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    // ── List attachments ──────────────────────────────────────────────────────
501
502    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    // ── Read attachment bytes ─────────────────────────────────────────────────
512
513    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 = &note.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        // Fallback: search by note UUID subdirectory
541        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    // ─────────────────────────────────────────────────────────────────────────
556    // Write operations
557    // All writes: check app lock → begin txn → mutate → commit → notify.
558    // ─────────────────────────────────────────────────────────────────────────
559
560    fn get_or_create_tag_pk(&self, name: &str) -> Result<i64> {
561        // Lookup existing
562        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        // Get next Z_PK from Z_PRIMARYKEY metadata
576        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        // Update Z_PRIMARYKEY
587        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            // INSERT OR IGNORE to skip if already linked
617            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    // ── Create ────────────────────────────────────────────────────────────────
653
654    pub fn create_note(&self, text: &str, tags: &[&str], if_not_exists: bool) -> Result<Note> {
655        check_app_lock()?;
656
657        // Extract title from first heading or first line
658        let title = extract_title(text);
659
660        // if_not_exists: check for existing note with same title
661        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    // ── Append ────────────────────────────────────────────────────────────────
701
702    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 = &note.text;
714
715        let new_text = match position {
716            InsertPosition::End => {
717                // Insert before bottom-placed tags if tag_position is Bottom
718                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                // Insert after title (and any top-placed tags)
726                if tag_position == TagPosition::Top {
727                    insert_after_title_block(current, content)
728                } else {
729                    // Insert after title line only
730                    insert_after_first_line(current, content)
731                }
732            }
733        };
734
735        let now = if update_modified {
736            now_coredata()
737        } else {
738            // preserve existing timestamp
739            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    // ── Write (overwrite) ─────────────────────────────────────────────────────
757
758    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    // ── Edit (find/replace) ───────────────────────────────────────────────────
789
790    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    // ── Trash / Archive / Restore ─────────────────────────────────────────────
825
826    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    // ── Tags add / remove / rename / delete ───────────────────────────────────
864
865    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        // Bear rewrites inline #tag markers itself on next open;
875        // we only update the join tables here.
876        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        // Check if new name already exists
924        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            // Merge: re-point Z_5TAGS rows, delete old tag
949            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        // Rewrite #tag markers in all note bodies
974        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    /// Rewrite `#tag` occurrences in all note bodies.
1001    /// `replacement = None` → remove the tag marker.
1002    fn rewrite_tag_in_notes(&self, old_name: &str, replacement: Option<&str>) -> Result<()> {
1003        // Find notes containing this tag in their text
1004        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    // ── Pin add / remove ──────────────────────────────────────────────────────
1031
1032    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        // Atomic: verify all targets exist first
1071        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    // ── Attachment add / delete ───────────────────────────────────────────────
1109
1110    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        // Get next file PK
1134        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
1217// ── Text manipulation helpers ─────────────────────────────────────────────────
1218
1219/// Extract Bear-style title from note text (first # heading or first line).
1220pub 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    // Find the last block of lines that are pure #tag lines (bottom tag area).
1235    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        // No bottom tag block found, just append
1250        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    // Skip leading # heading and any immediately following #tag lines
1260    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    // A "tag line" is a line consisting only of #tag tokens
1299    line.split_whitespace()
1300        .all(|tok| tok.starts_with('#') && tok.len() > 1)
1301}
1302
1303/// Result of applying a single edit operation.
1304struct 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    // Determine replacement string
1317    let replacement: String = if let Some(r) = &op.replace {
1318        r.clone()
1319    } else if let Some(ins) = &op.insert {
1320        // insert = needle + ins
1321        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 occurrences
1341        replace_all(text, &hay, &needle, &op.at, &replacement, op.word)
1342    } else {
1343        // Replace first occurrence
1344        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    // Word boundary check: chars before and after must be non-alphanumeric
1401    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
1420/// Rewrite `#tag_name` occurrences in text.
1421/// `replacement = None` removes the marker; `Some(new_name)` replaces it.
1422fn 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            // Check if this is exactly our tag (followed by whitespace, newline, or end)
1431            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}