1use anyhow::{Context, bail};
15use oxibrain::{Brain, BrainConfig, IngestAttachment, SourceRef, TrustTier};
16use oxibrain_connectors::scan_directory;
17use oxibrain_core::{
18 SyncAction, SyncFile, classify_event, content_hash, occurrence_id, sync::LocatorState,
19};
20use oxibrain_ports::Timestamp;
21use std::collections::HashMap;
22use std::path::Path;
23use std::time::UNIX_EPOCH;
24
25#[derive(Debug, Default, PartialEq, Eq)]
27pub struct SyncReport {
28 pub new: Vec<String>,
29 pub unchanged: Vec<String>,
30 pub modified: Vec<String>,
31}
32
33pub async fn run(dir: &Path, root: &Path, space: &str) -> anyhow::Result<()> {
34 let report = sync(dir, root, space).await?;
35 print_report(&report);
36 Ok(())
37}
38
39pub async fn sync(dir: &Path, root: &Path, space: &str) -> anyhow::Result<SyncReport> {
42 if !root.is_dir() {
43 bail!("not a directory: {}", root.display());
44 }
45 let files = scan_directory(root);
46 let brain = Brain::open(BrainConfig::at(dir)).await?;
47 let space_id = brain.ensure_space(space).await?;
48
49 let source_name = root
51 .canonicalize()
52 .unwrap_or_else(|_| root.to_path_buf())
53 .to_string_lossy()
54 .into_owned();
55 let source_id = brain
56 .ensure_source(&space_id, &source_name, "document_revision", "pull")
57 .await?;
58
59 let legacy = brain.note_hashes(&space_id).await?;
61 let event_states = brain.locator_states(&space_id, &source_id).await?;
62
63 let mut contents: HashMap<String, (String, Timestamp)> = HashMap::new();
65 let sync_files: Vec<SyncFile> = files
66 .into_iter()
67 .filter_map(|f| {
68 let path = f.path.to_str()?.to_string();
69 let modified = systemtime_to_timestamp(f.modified);
70 let hash = content_hash(&f.content);
71 contents.insert(path.clone(), (f.content, modified));
72 Some(SyncFile {
73 path,
74 content_hash: hash,
75 modified,
76 })
77 })
78 .collect();
79
80 let mut report = SyncReport::default();
81 let now = brain.clock_now();
82 for action in classify_event(sync_files, &legacy, &event_states) {
83 match action {
84 SyncAction::New(f) => {
85 ingest_event_one(
86 &brain,
87 &space_id,
88 &source_id,
89 &contents,
90 &event_states,
91 &f,
92 now,
93 )
94 .await?;
95 report.new.push(f.path);
96 }
97 SyncAction::Modified(f) => {
98 ingest_event_one(
99 &brain,
100 &space_id,
101 &source_id,
102 &contents,
103 &event_states,
104 &f,
105 now,
106 )
107 .await?;
108 report.modified.push(f.path);
109 }
110 SyncAction::Unchanged(p) => report.unchanged.push(p),
111 }
112 }
113 Ok(report)
114}
115
116async fn ingest_event_one(
117 brain: &Brain,
118 space_id: &str,
119 source_id: &str,
120 contents: &HashMap<String, (String, Timestamp)>,
121 event_states: &HashMap<String, LocatorState>,
122 f: &SyncFile,
123 now: Timestamp,
124) -> anyhow::Result<()> {
125 let (content, _occurred_at) = contents
126 .get(&f.path)
127 .with_context(|| format!("content missing for scanned path {}", f.path))?;
128
129 let predecessor = event_states
131 .get(&f.path)
132 .map(|s| s.latest_occurrence_id.as_str());
133 let occ = occurrence_id(source_id, &f.path, predecessor, &f.content_hash);
134
135 let attachment = IngestAttachment {
136 source_id: source_id.into(),
137 occurrence_id: occ,
138 accepted_at: now,
139 principal: "sync".into(),
140 claims_json: "{}".into(),
141 };
142
143 brain
144 .ingest_event(
145 space_id,
146 content.clone(),
147 SourceRef::Note {
148 path: f.path.clone(),
149 },
150 TrustTier::Trusted,
151 Some(&attachment),
152 "vault-sync",
153 )
154 .await?;
155 Ok(())
156}
157
158fn systemtime_to_timestamp(t: std::time::SystemTime) -> Timestamp {
159 let millis = t
160 .duration_since(UNIX_EPOCH)
161 .map(|d| d.as_millis() as i64)
162 .unwrap_or(0);
163 Timestamp(millis)
164}
165
166fn print_report(report: &SyncReport) {
167 if !report.new.is_empty() {
168 for p in &report.new {
169 println!(" new: {p}");
170 }
171 }
172 if !report.modified.is_empty() {
173 for p in &report.modified {
174 println!(" modified: {p}");
175 }
176 }
177 println!(
178 "sync complete: {} new, {} unchanged, {} modified",
179 report.new.len(),
180 report.unchanged.len(),
181 report.modified.len()
182 );
183}