1use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8pub const DIR_USER_ROOT: &str = "/";
14
15pub const DIR_ARCHIVE: &str = "archive";
17
18pub const DIR_MEDIA: &str = "media";
20
21pub const DIR_JOURNAL: &str = "journal";
23
24pub const DIR_HABITS: &str = "habits";
26
27pub const DIR_INSIGHTS: &str = "insights";
29
30pub const CHAT_FILENAME: &str = "Chat.md";
32
33pub const LATER_FILENAME: &str = "Later.md";
35
36pub const DONE_FILENAME: &str = "Done.md";
38
39pub const SHOP_FILENAME: &str = "Shop.md";
41
42pub const WATCH_FILENAME: &str = "Watch.md";
44
45pub const READ_FILENAME: &str = "Read.md";
47
48pub const POMODORO_TASK: &str = "Finished a break";
50
51pub const MD_EXT: &str = ".md";
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct FileEntry {
61 pub name: String,
63 pub hash: String,
65 pub display_name: String,
67 pub ctime: i64,
69 pub has_content: bool,
71 pub is_dir: bool,
73 pub parent_dir: String,
75}
76
77impl FileEntry {
78 pub fn new(
80 name: String,
81 hash: String,
82 display_name: String,
83 ctime: i64,
84 has_content: bool,
85 is_dir: bool,
86 parent_dir: String,
87 ) -> Self {
88 Self {
89 name,
90 hash,
91 display_name,
92 ctime,
93 has_content,
94 is_dir,
95 parent_dir,
96 }
97 }
98}
99
100#[derive(Debug, thiserror::Error)]
106pub enum FsError {
107 #[error("storage quota exceeded")]
109 QuotaExceeded,
110 #[error("unsafe path, possible security issue")]
112 UnsafePath,
113 #[error("cannot unhash, maybe the file is missing")]
115 CannotUnhash,
116 #[error("{0}")]
118 Io(#[from] std::io::Error),
119}
120
121pub const STATUS_OK: &str = "ok";
127
128pub const STATUS_NOT_MODIFIED: &str = "notModified";
130
131pub const STATUS_UPDATED_ON_SERVER: &str = "updatedOnServer";
133
134pub const STATUS_MERGED: &str = "merged";
136
137pub const MAX_TEXT_SIZE: usize = 5 * 1024 * 1024;
139
140pub const MAX_TEXTS_SIZE: usize = 10 * 1024 * 1024;
142
143pub const MAX_MEDIA_SIZE: usize = 20 * 1024 * 1024;
145
146pub const MAX_MEDIAS_SIZE: usize = 512 * 1024;
148
149pub const MAX_TOKEN_SIZE: usize = 4 * 1024;
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct SyncFile {
155 pub status: String,
157 pub path: String,
159 #[serde(rename = "lastModified")]
161 pub last_modified: i64,
162 #[serde(rename = "clientLastModified", default)]
164 pub client_last_modified: i64,
165 #[serde(rename = "clientLastSynced", default)]
167 pub client_last_synced: i64,
168 #[serde(default)]
170 pub content: String,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SyncRequest {
176 pub modified: Vec<SyncFile>,
178 pub deleted: Vec<String>,
180 pub timestamps: HashMap<String, i64>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct SyncResponse {
187 pub status: String,
189 #[serde(default)]
191 pub files: Vec<SyncFile>,
192 #[serde(default)]
194 pub timestamps: HashMap<String, i64>,
195 #[serde(default)]
197 pub renames: HashMap<String, String>,
198}
199
200impl Default for SyncResponse {
201 fn default() -> Self {
202 SyncResponse {
203 status: STATUS_OK.to_string(),
204 files: vec![],
205 timestamps: HashMap::new(),
206 renames: HashMap::new(),
207 }
208 }
209}
210
211#[derive(Debug, thiserror::Error)]
213pub enum SyncError {
214 #[error("invalid JSON")]
216 InvalidJson,
217 #[error("file not found")]
219 NotFound,
220 #[error("quota exceeded")]
222 QuotaExceeded,
223 #[error("storage error: {0}")]
225 Storage(String),
226 #[error("internal error: {0}")]
228 Internal(String),
229}
230
231impl From<FsError> for SyncError {
232 fn from(err: FsError) -> Self {
233 match err {
234 FsError::QuotaExceeded => SyncError::QuotaExceeded,
235 _ => SyncError::Storage(err.to_string()),
236 }
237 }
238}
239
240pub type YearHabits = HashMap<i32, i32>;
246
247pub type Habits = HashMap<String, YearHabits>;
249
250pub const HABIT_SKIPPED: &str = "⚪️";
252
253pub const HABIT_COMPLETED: &str = "🟢";
255
256pub const HABIT_COMPLETED_AT_WEEKEND: &str = "🟡";
258
259pub const MOOD_HABIT: &str = "Mood";
261
262pub const MOOD_EMOJIS: &[&str] = &["⚪️", "🤕", "😔", "😐", "🙂", "😊"];
264
265#[derive(Debug, Clone, Default, Serialize, Deserialize)]
271pub struct Schedule {
272 pub filename: String,
274 pub scheduled_at: i64,
276 pub cron: String,
278 #[serde(default)]
280 pub cmd: String,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct KnowledgeConfig {
292 #[serde(default = "default_language")]
294 pub language: String,
295 #[serde(default = "default_timezone")]
297 pub timezone: String,
298 #[serde(default)]
300 pub move_to_commands: Vec<String>,
301 #[serde(default = "default_pomodoro_duration")]
303 pub pomodoro_duration_in_minutes: i64,
304 #[serde(default)]
306 pub schedules: Vec<Schedule>,
307 #[serde(default)]
309 pub quick_commands: Vec<String>,
310 #[serde(default)]
312 pub two_emojis_enabled: bool,
313 #[serde(default = "default_mode")]
315 pub mode: String,
316 #[serde(default)]
318 pub quick_habits_enabled: bool,
319 #[serde(default)]
321 pub channels: Vec<i64>,
322}
323
324fn default_language() -> String {
325 "en".to_string()
326}
327fn default_timezone() -> String {
328 "UTC".to_string()
329}
330fn default_pomodoro_duration() -> i64 {
331 50
332}
333fn default_mode() -> String {
334 "full".to_string()
335}
336
337impl Default for KnowledgeConfig {
338 fn default() -> Self {
339 Self {
340 language: default_language(),
341 timezone: default_timezone(),
342 move_to_commands: vec![],
343 pomodoro_duration_in_minutes: default_pomodoro_duration(),
344 schedules: vec![],
345 quick_commands: vec![],
346 two_emojis_enabled: false,
347 mode: default_mode(),
348 quick_habits_enabled: false,
349 channels: vec![],
350 }
351 }
352}
353
354pub const MODE_CHAT: &str = "chat";
356pub const MODE_FULL: &str = "full";
358pub const MODE_TASKS: &str = "tasks";
360pub const MODE_NOTES: &str = "notes";
362pub const MODE_JOURNAL: &str = "journal";
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn test_file_entry_new() {
371 let entry = FileEntry::new(
372 "Rust.md".to_string(),
373 "abc12345678".to_string(),
374 "Rust".to_string(),
375 1700000000000,
376 true,
377 false,
378 "/notes".to_string(),
379 );
380 assert_eq!(entry.name, "Rust.md");
381 assert_eq!(entry.hash, "abc12345678");
382 assert_eq!(entry.display_name, "Rust");
383 assert!(entry.has_content);
384 assert!(!entry.is_dir);
385 assert_eq!(entry.parent_dir, "/notes");
386 }
387
388 #[test]
389 fn test_file_entry_serialization() {
390 let entry = FileEntry::new(
391 "Test.md".to_string(),
392 "hash".to_string(),
393 "Test".to_string(),
394 1000,
395 false,
396 true,
397 "/".to_string(),
398 );
399 let json = serde_json::to_string(&entry).unwrap();
400 let restored: FileEntry = serde_json::from_str(&json).unwrap();
401 assert_eq!(restored.name, entry.name);
402 assert!(restored.is_dir);
403 assert!(!restored.has_content);
404 }
405
406 #[test]
407 fn test_fs_error_display() {
408 assert_eq!(FsError::QuotaExceeded.to_string(), "storage quota exceeded");
409 assert_eq!(
410 FsError::UnsafePath.to_string(),
411 "unsafe path, possible security issue"
412 );
413 assert_eq!(
414 FsError::CannotUnhash.to_string(),
415 "cannot unhash, maybe the file is missing"
416 );
417 }
418
419 #[test]
420 fn test_sync_response_default() {
421 let resp = SyncResponse::default();
422 assert_eq!(resp.status, STATUS_OK);
423 assert!(resp.files.is_empty());
424 assert!(resp.timestamps.is_empty());
425 assert!(resp.renames.is_empty());
426 }
427
428 #[test]
429 fn test_sync_file_serialization() {
430 let file = SyncFile {
431 status: STATUS_OK.to_string(),
432 path: "notes/Test.md".to_string(),
433 last_modified: 1700000000000,
434 client_last_modified: 1700000000000,
435 client_last_synced: 1700000000000,
436 content: "# Hello".to_string(),
437 };
438 let json = serde_json::to_string(&file).unwrap();
439 let restored: SyncFile = serde_json::from_str(&json).unwrap();
440 assert_eq!(restored.path, "notes/Test.md");
441 assert_eq!(restored.content, "# Hello");
442 }
443
444 #[test]
445 fn test_sync_request_serialization() {
446 let req = SyncRequest {
447 modified: vec![],
448 deleted: vec!["old.md".to_string()],
449 timestamps: {
450 let mut m = HashMap::new();
451 m.insert("/".to_string(), 1700000000000);
452 m
453 },
454 };
455 let json = serde_json::to_string(&req).unwrap();
456 let restored: SyncRequest = serde_json::from_str(&json).unwrap();
457 assert!(restored.modified.is_empty());
458 assert_eq!(restored.deleted.len(), 1);
459 assert_eq!(restored.deleted[0], "old.md");
460 }
461
462 #[test]
463 fn test_sync_error_from_fs_error() {
464 let err = SyncError::from(FsError::QuotaExceeded);
465 assert!(matches!(err, SyncError::QuotaExceeded));
466
467 let err = SyncError::from(FsError::CannotUnhash);
468 assert!(matches!(err, SyncError::Storage(_)));
469 }
470
471 #[test]
472 fn test_sync_error_display() {
473 assert_eq!(SyncError::InvalidJson.to_string(), "invalid JSON");
474 assert_eq!(SyncError::NotFound.to_string(), "file not found");
475 assert_eq!(SyncError::QuotaExceeded.to_string(), "quota exceeded");
476 }
477
478 #[test]
479 fn test_knowledge_config_default() {
480 let config = KnowledgeConfig::default();
481 assert_eq!(config.language, "en");
482 assert_eq!(config.timezone, "UTC");
483 assert_eq!(config.mode, "full");
484 assert_eq!(config.pomodoro_duration_in_minutes, 50);
485 assert!(config.move_to_commands.is_empty());
486 assert!(config.schedules.is_empty());
487 assert!(config.quick_commands.is_empty());
488 assert!(!config.two_emojis_enabled);
489 assert!(!config.quick_habits_enabled);
490 assert!(config.channels.is_empty());
491 }
492
493 #[test]
494 fn test_knowledge_config_serialization_roundtrip() {
495 let config = KnowledgeConfig {
496 language: "ko".to_string(),
497 timezone: "+09:00".to_string(),
498 move_to_commands: vec!["archive".to_string()],
499 pomodoro_duration_in_minutes: 25,
500 schedules: vec![Schedule {
501 filename: "Daily.md".to_string(),
502 scheduled_at: 1700000000000,
503 cron: "9:00".to_string(),
504 cmd: String::new(),
505 }],
506 quick_commands: vec!["today".to_string()],
507 two_emojis_enabled: true,
508 mode: "chat".to_string(),
509 quick_habits_enabled: true,
510 channels: vec![42],
511 };
512 let json = serde_json::to_string(&config).unwrap();
513 let restored: KnowledgeConfig = serde_json::from_str(&json).unwrap();
514 assert_eq!(restored.language, "ko");
515 assert_eq!(restored.timezone, "+09:00");
516 assert_eq!(restored.mode, "chat");
517 assert_eq!(restored.pomodoro_duration_in_minutes, 25);
518 assert_eq!(restored.schedules.len(), 1);
519 assert_eq!(restored.schedules[0].cron, "9:00");
520 assert!(restored.two_emojis_enabled);
521 assert!(restored.quick_habits_enabled);
522 assert_eq!(restored.channels, vec![42]);
523 }
524
525 #[test]
526 fn test_constants() {
527 assert_eq!(MAX_TEXT_SIZE, 5 * 1024 * 1024);
528 assert_eq!(MAX_TEXTS_SIZE, 10 * 1024 * 1024);
529 assert_eq!(MAX_MEDIA_SIZE, 20 * 1024 * 1024);
530 assert_eq!(MAX_MEDIAS_SIZE, 512 * 1024);
531 assert_eq!(MAX_TOKEN_SIZE, 4 * 1024);
532 assert_eq!(CHAT_FILENAME, "Chat.md");
533 assert_eq!(DIR_ARCHIVE, "archive");
534 assert_eq!(DIR_JOURNAL, "journal");
535 assert_eq!(DIR_HABITS, "habits");
536 assert_eq!(MD_EXT, ".md");
537 assert_eq!(HABIT_SKIPPED, "\u{26aa}\u{fe0f}");
538 assert_eq!(HABIT_COMPLETED, "\u{1f7e2}");
539 assert_eq!(MODE_CHAT, "chat");
540 assert_eq!(MODE_FULL, "full");
541 assert_eq!(MOOD_HABIT, "Mood");
542 assert_eq!(MOOD_EMOJIS.len(), 6);
543 }
544}