1use crate::config::{data_dir, state_dir};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum Kind {
21 Contacts,
22 Calendars,
23}
24
25impl Kind {
26 pub fn root(self) -> PathBuf {
28 data_dir().join(self.folder())
29 }
30
31 fn folder(self) -> &'static str {
32 match self {
33 Kind::Contacts => "contacts",
34 Kind::Calendars => "calendars",
35 }
36 }
37
38 pub fn extension(self) -> &'static str {
40 match self {
41 Kind::Contacts => "vcf",
42 Kind::Calendars => "ics",
43 }
44 }
45
46 pub fn media_type(self) -> &'static str {
48 match self {
49 Kind::Contacts => "text/vcard; charset=utf-8",
50 Kind::Calendars => "text/calendar; charset=utf-8",
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Collection {
58 pub kind: Kind,
59 pub account: String,
60 pub id: String,
62 pub dir: PathBuf,
63 pub name: String,
64 pub color: Option<String>,
66 pub read_only: bool,
68 pub components: Vec<String>,
70}
71
72impl Collection {
73 pub fn place(&self, config: &crate::config::Config) -> String {
76 if self.account == LOCAL {
77 return String::new();
78 }
79 let account = config.account(&self.account);
80 if matches!(crate::capabilities::provider_of_collection(account, self), crate::capabilities::Provider::Google | crate::capabilities::Provider::GoogleTasks) {
81 return "Google".into();
82 }
83 let address = account.and_then(|a| a.url.clone().or_else(|| a.host.clone())).unwrap_or_default();
84 let host = crate::sites::host_of(&if address.contains("://") { address } else { format!("https://{address}") });
85 let domain = crate::sites::domain_of(&host);
86 if domain.is_empty() { self.account.clone() } else { domain }
87 }
88
89 pub fn label(&self, config: &crate::config::Config, tr: &crate::i18n::Translator) -> String {
91 let place = self.place(config);
92 format!("{} · {}", self.name, if place.is_empty() { tr.text("collection-here", None) } else { place })
93 }
94
95 pub fn holds(&self, component: &str) -> bool {
97 self.components.is_empty() || self.components.iter().any(|c| c.eq_ignore_ascii_case(component))
98 }
99}
100
101impl Collection {
102 pub fn items(&self) -> Vec<PathBuf> {
104 let Ok(entries) = std::fs::read_dir(&self.dir) else { return Vec::new() };
105 let mut items: Vec<PathBuf> = entries
106 .filter_map(Result::ok)
107 .map(|e| e.path())
108 .filter(|p| p.extension().is_some_and(|x| x == self.kind.extension()))
109 .collect();
110 items.sort();
111 items
112 }
113
114 pub fn state_path(&self) -> PathBuf {
116 state_path(&self.account, self.kind, &self.id)
117 }
118}
119
120pub fn state_path(account: &str, kind: Kind, id: &str) -> PathBuf {
122 state_dir().join("dav").join(account).join(kind.folder()).join(format!("{id}.toml"))
123}
124
125pub fn collections(kind: Kind) -> Vec<Collection> {
127 every_collection(kind).into_iter().filter(|c| !State::load(&c.state_path()).deleted).collect()
128}
129
130const OFF: &str = ".sioul-off";
132
133pub fn set_account_off(account: &str, off: bool) {
135 for kind in [Kind::Contacts, Kind::Calendars] {
136 let dir = kind.root().join(account);
137 if off {
138 if dir.is_dir() {
139 let _ = std::fs::write(dir.join(OFF), "Switched off in Sioul's Accounts: these collections wait here.\n");
140 }
141 } else {
142 let _ = std::fs::remove_file(dir.join(OFF));
143 }
144 }
145}
146
147pub fn every_collection(kind: Kind) -> Vec<Collection> {
149 let mut found = Vec::new();
150 let Ok(accounts) = std::fs::read_dir(kind.root()) else { return found };
151 for account in accounts.filter_map(Result::ok).filter(|e| e.path().is_dir() && !e.path().join(OFF).exists()) {
153 let account_id = account.file_name().to_string_lossy().to_string();
154 let Ok(dirs) = std::fs::read_dir(account.path()) else { continue };
155 for dir in dirs.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.is_dir()) {
156 let id = dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
157 let read = |name: &str| std::fs::read_to_string(dir.join(name)).ok().map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
158 let state = State::load(&state_path(&account_id, kind, &id));
159 found.push(Collection {
160 kind,
161 account: account_id.clone(),
162 name: read("displayname").unwrap_or_else(|| id.clone()),
163 color: read("color"),
164 read_only: state.read_only,
165 components: state.components.clone(),
166 id,
167 dir,
168 });
169 }
170 }
171 found.sort_by(|a, b| (&a.account, a.name.to_lowercase()).cmp(&(&b.account, b.name.to_lowercase())));
172 found
173}
174
175pub fn rename_collection(collection: &Collection, name: &str) -> Result<(), String> {
177 let name = name.trim();
178 if name.is_empty() {
179 return Err(format!("{}: a name is needed", collection.name));
180 }
181 if collection.read_only {
182 return Err(format!("{}: read only", collection.name));
183 }
184 crate::filelock::replace(&collection.dir.join("displayname"), name.as_bytes()).map_err(|e| e.to_string())?;
185 let path = collection.state_path();
186 let mut state = State::load(&path);
187 if collection.account != LOCAL && !state.pending {
188 state.renamed = true;
189 state.save(&path)?;
190 }
191 Ok(())
192}
193
194pub fn holds_items(collection: &Collection) -> bool {
196 std::fs::read_dir(&collection.dir).into_iter().flatten().filter_map(Result::ok).any(|e| {
197 let name = e.file_name().to_string_lossy().to_lowercase();
198 name.ends_with(".ics") || name.ends_with(".vcf")
199 })
200}
201
202pub fn delete_collection(collection: &Collection) -> Result<(), String> {
205 if holds_items(collection) {
206 return Err(format!("{}: not empty", collection.name));
207 }
208 if collection.read_only {
209 return Err(format!("{}: read only", collection.name));
210 }
211 let path = collection.state_path();
212 let mut state = State::load(&path);
213 if collection.account == LOCAL || state.pending {
214 let _ = std::fs::remove_dir_all(&collection.dir);
215 let _ = std::fs::remove_file(&path);
216 return Ok(());
217 }
218 state.deleted = true;
219 state.save(&path)
220}
221
222fn one_folder(name: &str) -> bool {
225 !matches!(name, "" | "." | "..") && !name.contains(['/', '\\', '\0']) && !(cfg!(windows) && name.contains(':'))
226}
227
228pub fn prepare(kind: Kind, account: &str, id: &str, name: &str, color: Option<&str>) -> std::io::Result<PathBuf> {
230 if !one_folder(account) || !one_folder(id) {
231 return Err(std::io::Error::other(format!("{account}/{id}: not a folder's name")));
232 }
233 let dir = kind.root().join(account).join(id);
234 std::fs::create_dir_all(&dir)?;
235 crate::filelock::replace(&dir.join("displayname"), name.as_bytes())?;
237 match color {
238 Some(color) => crate::filelock::replace(&dir.join("color"), color.as_bytes())?,
239 None => {
240 let _ = std::fs::remove_file(dir.join("color"));
241 }
242 }
243 Ok(dir)
244}
245
246pub const LOCAL: &str = "local";
248
249pub fn create(kind: Kind, account: &str, name: &str, color: Option<&str>, components: &[&str]) -> Result<Collection, String> {
254 if !one_folder(account) {
256 return Err(format!("{account}: not an account"));
257 }
258 let base = crate::config::slug(name);
259 let base = if base.is_empty() { "list".to_string() } else { base };
260 let taken = |id: &str| kind.root().join(account).join(id).exists() || state_path(account, kind, id).exists();
261 let id = std::iter::once(base.clone()).chain((2..100).map(|n| format!("{base}-{n}"))).find(|id| !taken(id)).ok_or_else(|| format!("{name}: no free name"))?;
262 let dir = prepare(kind, account, &id, name, color).map_err(|e| e.to_string())?;
263 let state = State { pending: account != LOCAL, components: components.iter().map(|c| c.to_string()).collect(), ..State::default() };
265 state.save(&state_path(account, kind, &id))?;
266 Ok(Collection { kind, account: account.to_string(), id, dir, name: name.to_string(), color: color.map(str::to_string), read_only: false, components: state.components })
267}
268
269pub fn folder_id(href: &str) -> String {
272 let last = href.trim_end_matches('/').rsplit('/').next().unwrap_or("collection");
273 let id: String = last.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
274 if id.is_empty() { "collection".into() } else { id }
275}
276
277#[derive(Debug, Clone, Default, Serialize, Deserialize)]
279pub struct State {
280 #[serde(default)]
282 pub url: String,
283 #[serde(default)]
285 pub sync_token: Option<String>,
286 #[serde(default)]
288 pub ctag: Option<String>,
289 #[serde(default)]
290 pub read_only: bool,
291 #[serde(default)]
293 pub components: Vec<String>,
294 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
296 pub pending: bool,
297 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
299 pub renamed: bool,
300 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
303 pub deleted: bool,
304 #[serde(default, rename = "item")]
305 pub items: Vec<ItemState>,
306}
307
308#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
310pub struct ItemState {
311 pub href: String,
313 pub file: String,
315 pub etag: String,
316 pub hash: String,
318 #[serde(default)]
322 pub size: u64,
323 #[serde(default)]
324 pub mtime: i64,
325}
326
327impl State {
328 pub fn load(path: &Path) -> State {
329 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
330 }
331
332 pub fn save(&self, path: &Path) -> Result<(), String> {
334 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
335 if let Some(parent) = path.parent() {
336 std::fs::create_dir_all(parent).map_err(fail)?;
337 }
338 let text = toml::to_string(self).map_err(|e| e.to_string())?;
339 let temporary = path.with_extension("toml.new");
340 std::fs::write(&temporary, text).map_err(fail)?;
341 std::fs::rename(&temporary, path).map_err(fail)
342 }
343}
344
345pub fn content_hash(bytes: &[u8]) -> String {
347 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
349 for byte in bytes {
350 hash ^= u64::from(*byte);
351 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
352 }
353 format!("{hash:016x}")
354}
355
356pub fn write_item(path: &Path, text: &str) -> Result<(), String> {
358 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
359 if let Some(parent) = path.parent() {
360 std::fs::create_dir_all(parent).map_err(fail)?;
361 }
362 let temporary = path.with_extension("tmp");
363 std::fs::write(&temporary, text).map_err(fail)?;
364 std::fs::rename(&temporary, path).map_err(fail)
365}
366
367pub fn new_name() -> String {
369 use std::hash::{Hash, Hasher};
370 static MADE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
373 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
374 let mut hasher = std::collections::hash_map::DefaultHasher::new();
375 now.hash(&mut hasher);
376 std::process::id().hash(&mut hasher);
377 MADE.fetch_add(1, std::sync::atomic::Ordering::Relaxed).hash(&mut hasher);
378 format!("sioul-{:x}-{:016x}", now.as_secs(), hasher.finish())
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn folder_names_hold_everywhere() {
387 assert_eq!(folder_id("/remote.php/dav/calendars/jane/personal/"), "personal");
388 assert_eq!(folder_id("/dav/Calendrier%20perso/"), "Calendrier_20perso");
389 assert_eq!(folder_id("/"), "collection");
390 assert_eq!(content_hash(b"BEGIN:VCARD"), content_hash(b"BEGIN:VCARD"));
391 assert_ne!(content_hash(b"a"), content_hash(b"b"));
392 assert!(one_folder("local") && one_folder("murmur-2") && !one_folder("..") && !one_folder("../x") && !one_folder("a\\b") && !one_folder(""));
394 assert!(create(Kind::Calendars, "../elsewhere", "List", None, &["VTODO"]).is_err());
395 let names: std::collections::BTreeSet<String> = (0..1000).map(|_| new_name()).collect();
396 assert_eq!(names.len(), 1000, "never the same name twice");
397 }
398}