1use std::path::{Path, PathBuf};
18use std::time::Duration;
19
20use time::OffsetDateTime;
21
22use parking_lot::RwLock;
23
24use crate::config::{AUTO_COLORS, CategoryDef, VaultConfig};
25use crate::error::{CoreError, Result};
26use crate::hash;
27use crate::lock::{FileLock, LockKind, acquire};
28use crate::memo::{Cursor, IndexStats, Memo, MemoFilter, MemoId, MemoSummary, Page, make_preview};
29use crate::paths::Paths;
30use crate::store::files::FileStore;
31use crate::store::index::{IndexRecord, MemoIndex, RedbIndex};
32use crate::store::search::{SearchIndex, TantivySearch};
33use crate::sync::{FullRecord, ManifestRecord};
34use crate::tags::extract_tags;
35
36const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
38
39const INDEX_FORMAT_VERSION: u32 = 3;
43
44pub struct Vault {
45 paths: Paths,
46 config: RwLock<VaultConfig>,
47 files: FileStore,
48}
49
50impl Vault {
51 pub fn open(vault: Option<&Path>) -> Result<Self> {
55 let paths = Paths::resolve(vault);
56 let config = VaultConfig::load(&paths);
57 let files = FileStore::new(paths.clone());
58 Ok(Self {
59 paths,
60 config: RwLock::new(config),
61 files,
62 })
63 }
64
65 pub fn with_config<R>(&self, f: impl FnOnce(&VaultConfig) -> R) -> R {
69 f(&self.config.read())
70 }
71
72 pub fn categories(&self) -> Vec<crate::config::CategoryDef> {
74 self.config.read().categories.items.clone()
75 }
76
77 pub fn create_category(&self, id: String, color: Option<String>) -> Result<CategoryDef> {
81 let id = normalize_id(&id);
82 if id.is_empty() {
83 return Err(CoreError::other("category id empty"));
84 }
85 let mut cfg = self.config.write();
86 if cfg.categories.items.iter().any(|c| c.id == id) {
87 return Err(CoreError::other(format!("category '{id}' exists")));
88 }
89 let color = color.unwrap_or_else(|| pick_auto_color(&cfg.categories.items));
90 let def = CategoryDef {
91 id: id.clone(),
92 color,
93 builtin: false,
94 };
95 cfg.categories.items.push(def.clone());
96 cfg.save(&self.paths)?;
97 Ok(def)
98 }
99
100 pub fn update_category(&self, id: String, color: String) -> Result<()> {
104 let id = normalize_id(&id);
105 let mut cfg = self.config.write();
106 let def = cfg
107 .categories
108 .items
109 .iter_mut()
110 .find(|c| c.id == id)
111 .ok_or_else(|| CoreError::other(format!("category '{id}' not found")))?;
112 def.color = color;
113 cfg.save(&self.paths)
114 }
115
116 pub fn delete_category(&self, id: String) -> Result<()> {
118 let id = normalize_id(&id);
119 if id == crate::memo::DEFAULT_CATEGORY {
120 return Err(CoreError::other("inbox cannot be deleted"));
121 }
122 let mut cfg = self.config.write();
123 let before = cfg.categories.items.len();
124 cfg.categories.items.retain(|c| c.id != id);
125 if cfg.categories.items.len() == before {
126 return Err(CoreError::other(format!("category '{id}' not found")));
127 }
128 cfg.save(&self.paths)
129 }
130
131 pub fn paths(&self) -> &Paths {
132 &self.paths
133 }
134
135 pub fn ensure_initialized(&self) -> Result<()> {
137 std::fs::create_dir_all(self.paths.memos_root())?;
138 std::fs::create_dir_all(self.paths.trash_root())?;
139 std::fs::create_dir_all(self.paths.assets_root())?;
140 std::fs::create_dir_all(&self.paths.index_dir)?;
141 Ok(())
142 }
143
144 pub fn save_asset(&self, bytes: &[u8], ext: &str) -> Result<crate::assets::AssetRef> {
150 self.ensure_initialized()?;
151 let ext = crate::assets::normalize_ext(ext)?;
152 let name = crate::assets::asset_name(bytes, ext);
153 let path = self.paths.asset_path(&name);
154 if !path.exists() {
155 let tmp = path.with_extension("tmp");
157 std::fs::write(&tmp, bytes)?;
158 std::fs::rename(&tmp, &path)?;
159 }
160 Ok(crate::assets::AssetRef {
161 url: format!("oximg://localhost/{name}"),
162 name,
163 })
164 }
165
166 pub fn save_asset_from_path(&self, src: &Path) -> Result<crate::assets::AssetRef> {
169 let ext = src
170 .extension()
171 .and_then(|e| e.to_str())
172 .ok_or_else(|| CoreError::AssetRejected("source has no extension".into()))?;
173 let bytes = std::fs::read(src)?;
174 self.save_asset(&bytes, ext)
175 }
176
177 pub fn read_asset(&self, name: &str) -> Option<(Vec<u8>, &'static str)> {
180 if !crate::assets::valid_name(name) {
181 return None;
182 }
183 let ext = crate::assets::ext_of(name)?;
184 let path = self.paths.asset_path(name);
185 std::fs::read(&path)
186 .ok()
187 .map(|b| (b, crate::assets::mime_for_ext(ext)))
188 }
189
190 pub fn list_assets(&self) -> Result<Vec<crate::assets::AssetInfo>> {
192 use crate::assets::{AssetInfo, valid_name};
193 let root = self.paths.assets_root();
194 if !root.exists() {
195 return Ok(Vec::new());
196 }
197 let mut out = Vec::new();
198 for entry in std::fs::read_dir(&root)? {
199 let entry = entry?;
200 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
201 continue;
202 };
203 if !valid_name(&name) {
204 continue;
205 }
206 let meta = entry.metadata()?;
207 let modified = meta
208 .modified()
209 .ok()
210 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
211 .map(|d| {
212 OffsetDateTime::from_unix_timestamp(d.as_secs() as i64)
213 .unwrap_or_else(|_| OffsetDateTime::now_utc())
214 })
215 .unwrap_or_else(OffsetDateTime::now_utc);
216 let ext = crate::assets::ext_of(&name).unwrap_or("").to_string();
217 out.push(AssetInfo {
218 url: format!("oximg://localhost/{name}"),
219 name,
220 ext,
221 bytes: meta.len(),
222 modified,
223 });
224 }
225 out.sort_by_key(|b| std::cmp::Reverse(b.modified));
226 Ok(out)
227 }
228
229 pub fn gc_assets(&self) -> Result<u64> {
233 let live = self.asset_refs_in_bodies()?;
234 let root = self.paths.assets_root();
235 if !root.exists() {
236 return Ok(0);
237 }
238 let mut removed = 0u64;
239 for entry in std::fs::read_dir(&root)? {
240 let entry = entry?;
241 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
242 continue;
243 };
244 if !crate::assets::valid_name(&name) || live.contains(&name) {
245 continue;
246 }
247 if std::fs::remove_file(entry.path()).is_ok() {
248 removed += 1;
249 }
250 }
251 Ok(removed)
252 }
253
254 fn asset_refs_in_bodies(&self) -> Result<std::collections::HashSet<String>> {
256 let mut live = std::collections::HashSet::new();
257 for path in self.files.list_memo_files() {
258 match self.files.read_memo(&path) {
259 Ok(Some(parsed)) => {
260 for name in crate::assets::refs_in_body(&parsed.body) {
261 live.insert(name);
262 }
263 }
264 Ok(None) => {}
265 Err(e) => tracing::warn!(
266 path = %path.display(),
267 error = %e,
268 "gc: skipping unparseable memo; its image refs are not counted"
269 ),
270 }
271 }
272 Ok(live)
273 }
274
275 pub fn find_memo_by_asset(&self, name: &str) -> Result<Option<MemoId>> {
278 for path in self.files.list_memo_files() {
279 match self.files.read_memo(&path) {
280 Ok(Some(parsed)) if crate::assets::refs_in_body(&parsed.body).contains(name) => {
281 return Ok(Some(parsed.id));
282 }
283 _ => {}
284 }
285 }
286 Ok(None)
287 }
288
289 fn lock(&self, kind: LockKind) -> Result<FileLock> {
292 acquire(&self.paths.meta_lock_path(), kind, LOCK_TIMEOUT)
293 }
294
295 fn with_redb<R>(&self, f: impl FnOnce(&RedbIndex) -> Result<R>) -> Result<R> {
297 let _g = self.lock(LockKind::Shared)?;
298 let idx = RedbIndex::open(&self.paths.meta_db_path())?;
299 f(&idx)
300 }
301
302 fn with_redb_and_search<R>(
304 &self,
305 f: impl FnOnce(&RedbIndex, &TantivySearch) -> Result<R>,
306 ) -> Result<R> {
307 let _g = self.lock(LockKind::Exclusive)?;
308 let idx = RedbIndex::open(&self.paths.meta_db_path())?;
309 let search = TantivySearch::open(&self.paths.search_dir())?;
310 f(&idx, &search)
311 }
312
313 pub fn create_memo(&self, body: String, category: Option<String>) -> Result<Memo> {
316 self.ensure_initialized()?;
317 let tags = extract_tags(&body);
318 validate_note_input(&body, &tags)?;
319 let now = OffsetDateTime::now_utc();
320 let id = MemoId::now();
321 let category = category.unwrap_or_else(|| crate::memo::DEFAULT_CATEGORY.to_string());
322 let note = Memo {
323 id,
324 created_at: now,
325 updated_at: now,
326 hash: hash::hash_memo(body.as_bytes(), false, &category),
327 favorite: false,
328 category,
329 tags,
330 body,
331 deleted_at: None,
332 };
333 self.files.write(¬e)?;
334 self.with_redb_and_search(|idx, search| {
335 idx.upsert(&record_of(¬e))?;
336 search.upsert(note.id, ¬e.body, ¬e.tags)
337 })?;
338 Ok(note)
339 }
340
341 pub fn get_memo(&self, id: MemoId) -> Result<Memo> {
342 let created_at = self.with_redb(|idx| idx.get(id))?.map(|r| r.created_at);
344 if let Some(ca) = created_at {
345 let live = self.paths.memo_path(id, ca);
346 if live.exists() {
347 return self
348 .files
349 .read_memo(&live)?
350 .ok_or_else(|| CoreError::NotFound(id.to_string()));
351 }
352 let trash = self.paths.trash_path(id);
353 if trash.exists() {
354 return self
355 .files
356 .read_memo(&trash)?
357 .ok_or_else(|| CoreError::NotFound(id.to_string()));
358 }
359 }
360 for path in self
362 .files
363 .list_memo_files()
364 .iter()
365 .chain(self.files.list_trash_files().iter())
366 {
367 if let Ok(Some(n)) = self.files.read_memo(path)
368 && n.id == id
369 {
370 return Ok(n);
371 }
372 }
373 Err(CoreError::NotFound(id.to_string()))
374 }
375
376 pub fn update_memo(
377 &self,
378 id: MemoId,
379 body: Option<String>,
380 favorite: Option<bool>,
381 category: Option<String>,
382 ) -> Result<Memo> {
383 let mut note = self.get_memo(id)?;
384 if let Some(b) = body {
385 note.body = b;
386 note.tags = extract_tags(¬e.body);
387 }
388 if let Some(p) = favorite {
389 note.favorite = p;
390 }
391 if let Some(c) = category {
392 note.category = c;
393 }
394 validate_note_input(¬e.body, ¬e.tags)?;
395 note.updated_at = OffsetDateTime::now_utc();
396 note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, ¬e.category);
397 self.files.write(¬e)?;
398 self.with_redb_and_search(|idx, search| {
399 idx.upsert(&record_of(¬e))?;
400 search.upsert(note.id, ¬e.body, ¬e.tags)
401 })?;
402 Ok(note)
403 }
404
405 pub fn delete_memo(&self, id: MemoId) -> Result<()> {
407 let mut note = self.get_memo(id)?;
408 let now = OffsetDateTime::now_utc();
409 note.deleted_at = Some(now);
410 note.updated_at = now;
411 note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, ¬e.category);
412 self.files.move_to_trash(¬e)?;
413 self.files.write(¬e)?;
414 self.with_redb_and_search(|idx, search| {
415 idx.upsert(&record_of(¬e))?;
416 search.remove(note.id)
417 })?;
418 Ok(())
419 }
420
421 pub fn restore_memo(&self, id: MemoId) -> Result<Memo> {
422 let mut note = self.get_memo(id)?;
423 note.deleted_at = None;
424 note.updated_at = OffsetDateTime::now_utc();
425 note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, ¬e.category);
426 self.files.restore_from_trash(¬e)?;
427 self.files.write(¬e)?;
428 self.with_redb_and_search(|idx, search| {
429 idx.upsert(&record_of(¬e))?;
430 search.upsert(note.id, ¬e.body, ¬e.tags)
431 })?;
432 Ok(note)
433 }
434
435 pub fn purge(&self, retention: Duration) -> Result<u64> {
438 let cutoff = OffsetDateTime::now_utc() - retention;
439 let mut purged = 0u64;
440 self.with_redb_and_search(|idx, search| {
441 for path in self.files.list_trash_files() {
442 let Ok(Some(n)) = self.files.read_memo(&path) else {
443 continue;
444 };
445 if n.deleted_at.is_some_and(|t| t < cutoff) {
446 self.files.purge(n.id)?;
447 idx.remove(n.id)?;
448 search.remove(n.id)?;
449 purged += 1;
450 }
451 }
452 Ok(())
453 })?;
454 Ok(purged)
455 }
456
457 pub fn list_memos(
460 &self,
461 after: Option<Cursor>,
462 limit: u32,
463 filter: MemoFilter,
464 ) -> Result<Page<MemoSummary>> {
465 self.with_redb(|idx| {
466 let recs = idx.list(after, limit, &filter)?;
467 let items: Vec<MemoSummary> = recs.iter().map(|r| r.to_summary()).collect();
468 let next_cursor = items.last().and_then(|s| {
469 serde_json::to_string(&Cursor {
470 updated_at: s.updated_at,
471 id: s.id,
472 })
473 .ok()
474 });
475 Ok(Page { items, next_cursor })
476 })
477 }
478
479 pub fn search_memos(&self, query: &str, limit: u32) -> Result<Vec<MemoSummary>> {
480 let _g = self.lock(LockKind::Shared)?;
481 let search = TantivySearch::open(&self.paths.search_dir())?;
482 let idx = RedbIndex::open(&self.paths.meta_db_path())?;
483 let ids = search.search(query, limit)?;
484 let mut out = Vec::with_capacity(ids.len());
485 for id in ids {
486 if let Some(r) = idx.get(id)? {
487 if r.deleted {
488 continue;
489 }
490 out.push(r.to_summary());
491 }
492 }
493 Ok(out)
494 }
495
496 pub fn get_note_summary(&self, id: MemoId) -> Result<MemoSummary> {
497 self.with_redb(|idx| match idx.get(id)? {
498 Some(r) => Ok(r.to_summary()),
499 None => Err(CoreError::NotFound(id.to_string())),
500 })
501 }
502
503 pub fn memo_stats(&self) -> Result<crate::memo::MemoStats> {
505 self.with_redb(|idx| {
506 let recs = idx.export_since(None)?;
507 let mut stats = crate::memo::MemoStats::default();
508 for r in &recs {
509 if r.deleted {
510 continue;
511 }
512 stats.memos += 1;
513 if r.favorite {
514 stats.favorites += 1;
515 }
516 }
517 Ok(stats)
518 })
519 }
520
521 pub fn list_facets(&self) -> Result<crate::memo::Facets> {
523 self.with_redb(|idx| {
524 let recs = idx.export_since(None)?;
525 let mut tag_map: std::collections::BTreeMap<String, u32> = Default::default();
526 let mut cat_map: std::collections::BTreeMap<String, u32> = Default::default();
527 for r in &recs {
528 if r.deleted {
529 continue;
530 }
531 for t in &r.tags {
532 *tag_map.entry(t.clone()).or_insert(0) += 1;
533 }
534 if !r.category.is_empty() {
535 *cat_map.entry(r.category.clone()).or_insert(0) += 1;
536 }
537 }
538 Ok(crate::memo::Facets {
539 tags: tag_map.into_iter().collect(),
540 categories: cat_map.into_iter().collect(),
541 })
542 })
543 }
544
545 pub fn export_manifest(&self, since: Option<OffsetDateTime>) -> Result<Vec<ManifestRecord>> {
548 self.with_redb(|idx| {
549 let recs = idx.export_since(since)?;
550 Ok(recs
551 .iter()
552 .map(|r| ManifestRecord {
553 id: r.id,
554 hash: r.hash.clone(),
555 updated_at: r.updated_at,
556 deleted: r.deleted,
557 })
558 .collect())
559 })
560 }
561
562 pub fn export_full(&self, ids: &[MemoId]) -> Result<Vec<FullRecord>> {
563 let mut out = Vec::with_capacity(ids.len());
564 for id in ids {
565 match self.get_memo(*id) {
566 Ok(n) => out.push(FullRecord::from_memo(&n)),
567 Err(CoreError::NotFound(_)) => { }
568 Err(e) => return Err(e),
569 }
570 }
571 Ok(out)
572 }
573
574 pub fn reindex(&self) -> Result<IndexStats> {
578 self.ensure_initialized()?;
579 self.with_redb_and_search(|idx, search| {
580 let mut stats = IndexStats::default();
581 let mut search_owned: Vec<(MemoId, String, Vec<String>)> = Vec::new();
584 for path in self.files.list_memo_files() {
585 match self.files.read_memo(&path) {
586 Ok(Some(note)) => {
587 let rec = record_of(¬e);
588 match idx.get(note.id)? {
589 None => {
590 idx.upsert(&rec)?;
591 search_owned.push((note.id, note.body, note.tags));
592 stats.added += 1;
593 }
594 Some(prev) if prev.hash == rec.hash && prev.preview == rec.preview => {
595 stats.unchanged += 1;
596 }
597 Some(_) => {
598 idx.upsert(&rec)?;
599 search_owned.push((note.id, note.body, note.tags));
600 stats.updated += 1;
601 }
602 }
603 stats.memos += 1;
604 }
605 Ok(None) => {}
606 Err(e) => {
607 tracing::warn!(path = %path.display(), error = %e, "reindex: parse failed");
608 stats.failed += 1;
609 }
610 }
611 }
612 for path in self.files.list_trash_files() {
613 if let Ok(Some(note)) = self.files.read_memo(&path) {
614 let rec = record_of(¬e);
615 idx.upsert(&rec)?;
616 search_owned.push((note.id, note.body, note.tags));
617 stats.trashed_memos += 1;
618 }
619 }
620 let batch: Vec<crate::store::search::Upsert<'_>> = search_owned
622 .iter()
623 .map(|(id, body, tags)| crate::store::search::Upsert {
624 id: *id,
625 body,
626 tags,
627 })
628 .collect();
629 search.upsert_batch(&batch)?;
630 Ok(stats)
631 })
632 }
633 pub fn migrate(&self) -> Result<()> {
642 let old_root = self.paths.vault.join("notes");
646 let new_root = self.paths.memos_root();
647 if old_root.exists()
648 && old_root != new_root
649 && !new_root.exists()
650 && std::fs::read_dir(&old_root)?.next().is_some()
651 {
652 tracing::info!(from = %old_root.display(), to = %new_root.display(), "renaming vault memos root");
653 std::fs::rename(&old_root, &new_root)?;
654 }
655 self.ensure_initialized()?;
656 let marker = self.paths.index_fmt_marker_path();
657 let wants = INDEX_FORMAT_VERSION.to_string();
658 if std::fs::read_to_string(&marker)
659 .ok()
660 .map(|s| s.trim() == wants)
661 .unwrap_or(false)
662 {
663 return Ok(());
664 }
665 tracing::info!(
666 version = INDEX_FORMAT_VERSION,
667 "migrating index preview format"
668 );
669 self.reindex()?;
670 std::fs::write(&marker, &wants)?;
671 Ok(())
672 }
673
674 pub fn reindex_path(&self, path: &Path) {
677 if let Err(e) = self.do_reindex_path(path) {
678 tracing::warn!(path = %path.display(), error = %e, "watcher reindex failed");
679 }
680 }
681
682 fn do_reindex_path(&self, path: &Path) -> Result<()> {
683 if !path.exists() {
684 if let Some(id) = id_from_path(path) {
685 self.with_redb_and_search(|idx, search| {
686 idx.remove(id)?;
687 search.remove(id)
688 })?;
689 }
690 return Ok(());
691 }
692 match self.files.read_memo(path)? {
693 Some(note) => self.with_redb_and_search(|idx, search| {
694 idx.upsert(&record_of(¬e))?;
695 search.upsert(note.id, ¬e.body, ¬e.tags)
696 }),
697 None => Ok(()),
698 }
699 }
700
701 pub fn watch(&self) -> Result<crate::watcher::MemoWatcher> {
704 let debounce = Duration::from_millis(self.config.read().index.watcher_debounce_ms as u64);
705 let vault_path = self.paths.vault.clone();
706 let on_change: crate::watcher::OnChange = std::sync::Arc::new(move |path| {
709 let Ok(v) = Vault::open(Some(&vault_path)) else {
710 return;
711 };
712 v.reindex_path(&path);
713 });
714 crate::watcher::MemoWatcher::spawn(
715 vec![self.paths.memos_root(), self.paths.trash_root()],
716 debounce,
717 on_change,
718 )
719 }
720
721 pub fn doctor(&self, fix: bool) -> Result<DoctorReport> {
724 self.ensure_initialized()?;
725 let mut report = DoctorReport {
726 index_locked: crate::lock::is_locked(&self.paths.meta_lock_path()),
727 ..DoctorReport::default()
728 };
729
730 let all_recs = self.with_redb(|idx| idx.export_since(None))?;
732 let indexed: std::collections::HashMap<MemoId, IndexRecord> =
733 all_recs.iter().map(|r| (r.id, r.clone())).collect();
734
735 let mut seen: std::collections::HashSet<MemoId> = std::collections::HashSet::new();
736 for path in self
737 .files
738 .list_memo_files()
739 .iter()
740 .chain(self.files.list_trash_files().iter())
741 {
742 match self.files.read_memo(path) {
743 Ok(Some(mut note)) => {
744 seen.insert(note.id);
745 let recomputed =
748 hash::hash_memo(note.body.as_bytes(), note.favorite, ¬e.category);
749 if recomputed != note.hash {
750 let repaired = if fix {
754 note.hash = recomputed;
755 match self.files.write(¬e) {
756 Ok(_) => true,
757 Err(e) => {
758 report.hash_repair_failed += 1;
759 tracing::warn!(
760 id = %note.id,
761 error = %e,
762 "doctor: failed to rewrite hash"
763 );
764 false
765 }
766 }
767 } else {
768 false
769 };
770 if !repaired {
771 report.hash_mismatches.push(note.id);
772 }
773 }
774 }
775 Ok(None) => report.orphan_files.push(path.clone()),
776 Err(CoreError::Frontmatter { reason, .. }) => {
777 report.corrupt_frontmatter.push((path.clone(), reason));
778 }
779 Err(e) => {
780 report
781 .corrupt_frontmatter
782 .push((path.clone(), e.to_string()));
783 }
784 }
785 }
786
787 for id in indexed.keys() {
788 if !seen.contains(id) {
789 report.orphan_index_records.push(*id);
790 }
791 }
792 if fix {
793 let orphans = report.orphan_index_records.clone();
795 self.with_redb_and_search(|idx, search| {
796 for id in &orphans {
797 idx.remove(*id)?;
798 search.remove(*id)?;
799 }
800 Ok(())
801 })?;
802 report.orphan_index_records.clear();
803 }
804
805 let cutoff = OffsetDateTime::now_utc()
807 - Duration::from_secs(86400 * self.config.read().general.trash_retention_days as u64);
808 for path in self.files.list_trash_files() {
809 if let Ok(Some(n)) = self.files.read_memo(&path)
810 && n.deleted_at.is_some_and(|t| t < cutoff)
811 {
812 report.trash_expiring += 1;
813 }
814 }
815
816 report.vault_ok = self.paths.vault.is_dir();
817 Ok(report)
818 }
819
820 pub fn rename_category(&self, old: String, new: String) -> Result<u64> {
832 let old = normalize_id(&old);
833 let new = normalize_id(&new);
834 if old == crate::memo::DEFAULT_CATEGORY || new == crate::memo::DEFAULT_CATEGORY {
835 return Err(CoreError::other("inbox id is immutable"));
836 }
837 if old == new {
838 return Err(CoreError::other("old == new"));
839 }
840
841 {
843 let cfg = self.config.read();
844 if !cfg.categories.items.iter().any(|c| c.id == old) {
845 return Err(CoreError::other(format!("category '{old}' not found")));
846 }
847 if cfg.categories.items.iter().any(|c| c.id == new) {
848 return Err(CoreError::other(format!("category '{new}' exists")));
849 }
850 }
851
852 let mut migrated = 0u64;
853 self.with_redb_and_search(|idx, search| {
854 for rec in idx.export_since(None)? {
855 if rec.category != old {
856 continue;
857 }
858 let path = self.paths.memo_path(rec.id, rec.created_at);
859 let mut note = self
860 .files
861 .read_memo(&path)?
862 .ok_or_else(|| CoreError::NotFound(rec.id.to_string()))?;
863 note.category = new.clone();
864 note.updated_at = OffsetDateTime::now_utc();
865 note.hash = hash::hash_memo(note.body.as_bytes(), note.favorite, ¬e.category);
866 self.files.write(¬e)?;
867 idx.upsert(&record_of(¬e))?;
868 search.upsert(note.id, ¬e.body, ¬e.tags)?;
869 migrated += 1;
870 }
871 Ok(())
872 })?;
873
874 {
876 let mut cfg = self.config.write();
877 for def in cfg.categories.items.iter_mut() {
878 if def.id == old {
879 def.id = new.clone();
880 }
881 }
882 cfg.save(&self.paths)?;
883 }
884 Ok(migrated)
885 }
886
887 pub fn reset(&self) -> Result<()> {
892 self.ensure_initialized()?;
893 let roots = [self.paths.memos_root(), self.paths.trash_root()];
899 self.with_redb_and_search(|idx, search| {
900 for root in roots {
901 if root.exists() {
902 for entry in std::fs::read_dir(&root)? {
903 let path = entry?.path();
904 if path.is_dir() {
905 std::fs::remove_dir_all(&path)?;
906 } else {
907 std::fs::remove_file(&path)?;
908 }
909 }
910 }
911 }
912 idx.clear()?;
913 search.clear()?;
914 Ok(())
915 })
916 }
917}
918
919fn record_of(n: &Memo) -> IndexRecord {
921 IndexRecord {
922 id: n.id,
923 created_at: n.created_at,
924 updated_at: n.updated_at,
925 hash: n.hash.clone(),
926 favorite: n.favorite,
927 category: n.category.clone(),
928 tags: n.tags.clone(),
929 deleted: n.deleted_at.is_some(),
930 deleted_at: n.deleted_at,
931 preview: make_preview(&n.body),
932 }
933}
934
935fn id_from_path(path: &Path) -> Option<MemoId> {
936 let stem = path.file_stem()?.to_str()?;
937 MemoId::parse(stem).ok()
938}
939
940const MAX_BODY_BYTES: usize = 64 * 1024;
944const MAX_TAGS: usize = 64;
945const MAX_TAG_LEN: usize = 64;
946
947fn validate_note_input(body: &str, tags: &[String]) -> Result<()> {
948 if body.len() > MAX_BODY_BYTES {
949 return Err(CoreError::other(format!(
950 "memo body too large: {} bytes (max {})",
951 body.len(),
952 MAX_BODY_BYTES
953 )));
954 }
955 if tags.len() > MAX_TAGS {
956 return Err(CoreError::other(format!(
957 "too many tags: {} (max {})",
958 tags.len(),
959 MAX_TAGS
960 )));
961 }
962 for t in tags {
963 if t.chars().count() > MAX_TAG_LEN {
964 return Err(CoreError::other(format!(
965 "tag too long: {} chars (max {})",
966 t.chars().count(),
967 MAX_TAG_LEN
968 )));
969 }
970 }
971 Ok(())
972}
973
974#[derive(Debug, Default, serde::Serialize)]
976pub struct DoctorReport {
977 pub corrupt_frontmatter: Vec<(PathBuf, String)>,
978 pub orphan_index_records: Vec<MemoId>,
979 pub orphan_files: Vec<PathBuf>,
980 pub hash_mismatches: Vec<MemoId>,
981 pub hash_repair_failed: u64,
983 pub index_locked: bool,
984 pub trash_expiring: u64,
985 pub vault_ok: bool,
986}
987
988fn normalize_id(id: &str) -> String {
993 id.trim().to_lowercase()
994}
995
996fn pick_auto_color(items: &[CategoryDef]) -> String {
1002 AUTO_COLORS
1003 .iter()
1004 .skip(1) .find(|c| !items.iter().any(|item| &item.color == *c))
1006 .map(|s| (*s).to_string())
1007 .unwrap_or_else(|| AUTO_COLORS[1].to_string())
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013 use tempfile::TempDir;
1014
1015 fn tmp_vault() -> (TempDir, Vault) {
1016 let dir = TempDir::new().unwrap();
1017 let v = Vault::open(Some(dir.path())).unwrap();
1018 (dir, v)
1019 }
1020
1021 #[test]
1022 fn create_get_update_delete_restore() {
1023 let (_t, v) = tmp_vault();
1024 let n = v.create_memo("hello world".into(), None).unwrap();
1025 let got = v.get_memo(n.id).unwrap();
1026 assert_eq!(got.body, "hello world");
1027
1028 let updated = v
1029 .update_memo(n.id, Some("edited".into()), Some(true), None)
1030 .unwrap();
1031 assert!(updated.favorite);
1032 assert_ne!(updated.hash, n.hash);
1033
1034 v.delete_memo(n.id).unwrap();
1035 let trashed = v.get_memo(n.id).unwrap();
1036 assert!(trashed.deleted_at.is_some());
1037
1038 v.restore_memo(n.id).unwrap();
1039 assert!(v.get_memo(n.id).unwrap().deleted_at.is_none());
1040 }
1041
1042 #[test]
1043 fn list_and_search() {
1044 let (_t, v) = tmp_vault();
1045 v.create_memo("rust async runtime".into(), None).unwrap();
1046 v.create_memo("go goroutines".into(), None).unwrap();
1047 let page = v.list_memos(None, 10, MemoFilter::default()).unwrap();
1048 assert_eq!(page.items.len(), 2);
1049 let hits = v.search_memos("rust", 10).unwrap();
1050 assert_eq!(hits.len(), 1);
1051 }
1052
1053 #[test]
1054 fn list_notes_next_cursor_roundtrips_as_string() {
1055 let (_t, v) = tmp_vault();
1060 v.create_memo("first note".into(), None).unwrap();
1061 v.create_memo("second note".into(), None).unwrap();
1062
1063 let page = v.list_memos(None, 1, MemoFilter::default()).unwrap();
1065 assert_eq!(page.items.len(), 1);
1066 let cursor = page.next_cursor.expect("page 1 must carry a next cursor");
1067 assert!(
1068 cursor.starts_with('{'),
1069 "next_cursor must be a JSON object string, got: {cursor}"
1070 );
1071
1072 let parsed = Cursor::parse(&cursor).expect("cursor must round-trip via Cursor::parse");
1073 let page2 = v
1074 .list_memos(Some(parsed), 10, MemoFilter::default())
1075 .unwrap();
1076 assert_eq!(
1077 page2.items.len(),
1078 1,
1079 "page 2 must return the remaining note"
1080 );
1081 let c2 = page2.next_cursor.expect("page 2 carries a cursor");
1084 let page3 = v
1085 .list_memos(Some(Cursor::parse(&c2).unwrap()), 10, MemoFilter::default())
1086 .unwrap();
1087 assert!(page3.items.is_empty(), "page 3 must be empty");
1088 assert!(
1089 page3.next_cursor.is_none(),
1090 "empty page must carry no cursor"
1091 );
1092 }
1093
1094 #[test]
1095 fn export_manifest_and_full_roundtrip() {
1096 let (_t, v) = tmp_vault();
1097 let n = v.create_memo("body text".into(), None).unwrap();
1098 let manifest = v.export_manifest(None).unwrap();
1099 assert_eq!(manifest.len(), 1);
1100 let full = v.export_full(&[n.id]).unwrap();
1101 assert_eq!(full[0].body, "body text");
1102 }
1103
1104 #[test]
1105 fn reindex_is_idempotent() {
1106 let (_t, v) = tmp_vault();
1107 v.create_memo("one".into(), None).unwrap();
1108 let s1 = v.reindex().unwrap();
1109 let s2 = v.reindex().unwrap();
1110 assert_eq!(s2.added, 0);
1111 assert!(s2.unchanged >= 1);
1112 let _ = s1;
1113 }
1114 #[test]
1115 fn derived_tags_from_body_end_to_end() {
1116 let (_t, v) = tmp_vault();
1117 let n = v.create_memo("회의록 #work #urgent".into(), None).unwrap();
1118 let got = v.get_memo(n.id).unwrap();
1119 assert_eq!(got.tags, vec!["work", "urgent"]);
1121
1122 let inc = v
1124 .list_memos(
1125 None,
1126 10,
1127 MemoFilter {
1128 include_tags: vec!["work".into()],
1129 match_all: true,
1130 ..Default::default()
1131 },
1132 )
1133 .unwrap();
1134 assert_eq!(inc.items.len(), 1);
1135
1136 let exc = v
1138 .list_memos(
1139 None,
1140 10,
1141 MemoFilter {
1142 include_tags: vec!["work".into()],
1143 exclude_tags: vec!["urgent".into()],
1144 ..Default::default()
1145 },
1146 )
1147 .unwrap();
1148 assert!(exc.items.is_empty());
1149
1150 let facets = v.list_facets().unwrap();
1152 assert_eq!(
1153 facets
1154 .tags
1155 .iter()
1156 .find(|(t, _)| t == "work")
1157 .map(|(_, c)| *c),
1158 Some(1)
1159 );
1160 assert_eq!(
1161 facets
1162 .tags
1163 .iter()
1164 .find(|(t, _)| t == "urgent")
1165 .map(|(_, c)| *c),
1166 Some(1)
1167 );
1168 }
1169
1170 #[test]
1171 fn category_crud_persists() {
1172 let dir = tempfile::tempdir().unwrap();
1173 let v = Vault::open(Some(dir.path())).unwrap();
1174 v.ensure_initialized().unwrap();
1175
1176 let c = v.create_category("urgent".into(), None).unwrap();
1178 assert_eq!(c.id, "urgent");
1179 assert!(!c.color.is_empty());
1180
1181 assert!(v.create_category("urgent".into(), None).is_err());
1183 assert!(v.create_category(" ".into(), None).is_err());
1185 assert!(v.create_category("inbox".into(), None).is_err());
1187
1188 v.update_category("urgent".into(), "oklch(0.6 0.2 25)".into())
1190 .unwrap();
1191 assert_eq!(
1192 v.categories()
1193 .iter()
1194 .find(|c| c.id == "urgent")
1195 .unwrap()
1196 .color,
1197 "oklch(0.6 0.2 25)"
1198 );
1199
1200 v.delete_category("urgent".into()).unwrap();
1202 assert!(v.categories().iter().all(|c| c.id != "urgent"));
1203
1204 assert!(v.delete_category("inbox".into()).is_err());
1206 assert!(v.update_category("nope".into(), "x".into()).is_err());
1208
1209 let v2 = Vault::open(Some(dir.path())).unwrap();
1211 assert!(v2.categories().iter().all(|c| c.id != "urgent"));
1212 }
1213
1214 #[test]
1215 fn rename_category_migrates_notes() {
1216 let dir = tempfile::tempdir().unwrap();
1217 let v = Vault::open(Some(dir.path())).unwrap();
1218 v.ensure_initialized().unwrap();
1219
1220 let a = v.create_memo("note A".into(), Some("todo".into())).unwrap();
1221 let b = v.create_memo("note B".into(), Some("todo".into())).unwrap();
1222 let c = v.create_memo("note C".into(), Some("idea".into())).unwrap();
1223
1224 let n = v.rename_category("todo".into(), "tasks".into()).unwrap();
1225 assert_eq!(n, 2);
1226
1227 assert_eq!(v.get_memo(a.id).unwrap().category, "tasks");
1229 assert_eq!(v.get_memo(b.id).unwrap().category, "tasks");
1230 assert_eq!(v.get_memo(c.id).unwrap().category, "idea");
1232 assert!(v.categories().iter().any(|c| c.id == "tasks"));
1234 assert!(v.categories().iter().all(|c| c.id != "todo"));
1235
1236 assert!(v.rename_category("inbox".into(), "x".into()).is_err());
1238 assert!(v.rename_category("tasks".into(), "idea".into()).is_err());
1240 }
1241
1242 #[test]
1243 fn migrate_renames_legacy_notes_root() {
1244 let dir = tempfile::tempdir().unwrap();
1245 let v = Vault::open(Some(dir.path())).unwrap();
1246 v.ensure_initialized().unwrap();
1247 let _ = v.create_memo("hello".into(), None).unwrap();
1248 let live = v.paths.memos_root();
1249 let legacy = v.paths.vault.join("notes");
1250 std::fs::rename(&live, &legacy).unwrap();
1251 assert!(legacy.exists());
1252 assert!(!live.exists());
1253 let v2 = Vault::open(Some(dir.path())).unwrap();
1255 v2.migrate().unwrap();
1256 assert!(live.exists());
1257 assert!(!legacy.exists());
1258 }
1259
1260 #[test]
1261 fn migrate_preserves_existing_memos_root() {
1262 let dir = tempfile::tempdir().unwrap();
1263 let v = Vault::open(Some(dir.path())).unwrap();
1264 v.ensure_initialized().unwrap();
1265 let live = v.paths.memos_root();
1266 let legacy = v.paths.vault.join("notes");
1267 std::fs::create_dir_all(&legacy).unwrap();
1268 std::fs::write(legacy.join("stale.md"), "stale").unwrap();
1269 let new_id = v.create_memo("fresh".into(), None).unwrap();
1270 assert!(live.exists());
1271 assert!(legacy.exists());
1272
1273 v.migrate().unwrap();
1274 assert!(live.exists());
1275 assert!(legacy.exists());
1276 assert!(v.get_memo(new_id.id).is_ok());
1277 }
1278
1279 #[test]
1280 fn reset_clears_memos_and_indexes() {
1281 let (_t, v) = tmp_vault();
1282 v.create_memo("one".into(), None).unwrap();
1283 v.create_memo("two".into(), None).unwrap();
1284 assert_eq!(v.memo_stats().unwrap().memos, 2);
1285 v.reset().unwrap();
1286 assert_eq!(v.memo_stats().unwrap().memos, 0);
1288 v.create_memo("three".into(), None).unwrap();
1289 assert_eq!(v.memo_stats().unwrap().memos, 1);
1290 }
1291
1292 #[test]
1293 fn asset_save_dedup_read_list() {
1294 let (_t, v) = tmp_vault();
1295 let bytes = [1u8, 2, 3, 4, 5];
1296 let r = v.save_asset(&bytes, "PNG").unwrap();
1297 assert_eq!(r.url, format!("oximg://localhost/{}", r.name));
1299 assert!(r.name.ends_with(".png"));
1300 let r2 = v.save_asset(&bytes, "png").unwrap();
1302 assert_eq!(r.name, r2.name);
1303 let (got, mime) = v.read_asset(&r.name).unwrap();
1305 assert_eq!(got, bytes);
1306 assert_eq!(mime, "image/png");
1307 let list = v.list_assets().unwrap();
1309 assert_eq!(list.len(), 1);
1310 assert_eq!(list[0].name, r.name);
1311 }
1312
1313 #[test]
1314 fn asset_gc_removes_orphans_keeps_referenced() {
1315 let (_t, v) = tmp_vault();
1316 let referenced = v.save_asset(&[1, 2, 3], "png").unwrap();
1318 v.create_memo(format!("see ", referenced.url), None)
1319 .unwrap();
1320 let orphan = v.save_asset(&[9, 9, 9], "gif").unwrap();
1322 assert_eq!(v.list_assets().unwrap().len(), 2);
1323
1324 let removed = v.gc_assets().unwrap();
1325 assert_eq!(removed, 1);
1326 assert!(v.read_asset(&referenced.name).is_some());
1327 assert!(v.read_asset(&orphan.name).is_none());
1328 }
1329
1330 #[test]
1331 fn read_asset_rejects_traversal() {
1332 let (_t, v) = tmp_vault();
1333 assert!(v.read_asset("../../etc/passwd").is_none());
1335 assert!(v.read_asset("deadbeefdeadbeef.exe").is_none());
1336 }
1337}