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("file too large")]
118 TooLarge,
119 #[error("{0}")]
121 Io(#[from] std::io::Error),
122}
123
124pub const STATUS_OK: &str = "ok";
130
131pub const STATUS_NOT_MODIFIED: &str = "notModified";
133
134pub const STATUS_UPDATED_ON_SERVER: &str = "updatedOnServer";
136
137pub const STATUS_MERGED: &str = "merged";
139
140pub const MAX_TEXT_SIZE: usize = 5 * 1024 * 1024;
142
143pub const MAX_TEXTS_SIZE: usize = 10 * 1024 * 1024;
145
146pub const MAX_MEDIA_SIZE: usize = 20 * 1024 * 1024;
148
149pub const MAX_MEDIAS_SIZE: usize = 512 * 1024;
151
152pub const MAX_TOKEN_SIZE: usize = 4 * 1024;
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct SyncFile {
158 pub status: String,
160 pub path: String,
162 #[serde(rename = "lastModified")]
164 pub last_modified: i64,
165 #[serde(rename = "clientLastModified", default)]
167 pub client_last_modified: i64,
168 #[serde(rename = "clientLastSynced", default)]
170 pub client_last_synced: i64,
171 #[serde(default)]
173 pub content: String,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct SyncRequest {
179 pub modified: Vec<SyncFile>,
181 pub deleted: Vec<String>,
183 pub timestamps: HashMap<String, i64>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct SyncResponse {
190 pub status: String,
192 #[serde(default)]
194 pub files: Vec<SyncFile>,
195 #[serde(default)]
197 pub timestamps: HashMap<String, i64>,
198 #[serde(default)]
200 pub renames: HashMap<String, String>,
201}
202
203impl Default for SyncResponse {
204 fn default() -> Self {
205 SyncResponse {
206 status: STATUS_OK.to_string(),
207 files: vec![],
208 timestamps: HashMap::new(),
209 renames: HashMap::new(),
210 }
211 }
212}
213
214#[derive(Debug, thiserror::Error)]
216pub enum SyncError {
217 #[error("invalid JSON")]
219 InvalidJson,
220 #[error("file not found")]
222 NotFound,
223 #[error("quota exceeded")]
225 QuotaExceeded,
226 #[error("storage error: {0}")]
228 Storage(String),
229 #[error("internal error: {0}")]
231 Internal(String),
232}
233
234impl From<FsError> for SyncError {
235 fn from(err: FsError) -> Self {
236 match err {
237 FsError::QuotaExceeded => SyncError::QuotaExceeded,
238 _ => SyncError::Storage(err.to_string()),
239 }
240 }
241}
242
243pub type YearHabits = HashMap<i32, i32>;
249
250pub type Habits = HashMap<String, YearHabits>;
252
253pub const HABIT_SKIPPED: &str = "⚪️";
255
256pub const HABIT_COMPLETED: &str = "🟢";
258
259pub const HABIT_COMPLETED_AT_WEEKEND: &str = "🟡";
261
262pub const MOOD_HABIT: &str = "Mood";
264
265pub const MOOD_EMOJIS: &[&str] = &["⚪️", "🤕", "😔", "😐", "🙂", "😊"];
267
268#[derive(Debug, Clone, Default, Serialize, Deserialize)]
274pub struct Schedule {
275 pub filename: String,
277 pub scheduled_at: i64,
279 pub cron: String,
281 #[serde(default)]
283 pub cmd: String,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
292pub enum NoteSource {
293 Hook,
295 Tool,
297 Ui,
299 Dream,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
305pub enum NoteQuality {
306 Raw,
308 Curated,
310 Refined,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct NoteMeta {
319 pub author: String,
321 pub source: NoteSource,
323 pub quality: NoteQuality,
325 pub needs_review: bool,
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub session_id: Option<String>,
330 #[serde(skip_serializing_if = "Option::is_none")]
332 pub message_index: Option<usize>,
333 #[serde(skip_serializing_if = "Option::is_none")]
335 pub saved_at: Option<String>,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct KnowledgeConfig {
348 #[serde(default = "default_language")]
350 pub language: String,
351 #[serde(default = "default_timezone")]
353 pub timezone: String,
354 #[serde(default)]
356 pub move_to_commands: Vec<String>,
357 #[serde(default = "default_pomodoro_duration")]
359 pub pomodoro_duration_in_minutes: i64,
360 #[serde(default)]
362 pub schedules: Vec<Schedule>,
363 #[serde(default)]
365 pub quick_commands: Vec<String>,
366 #[serde(default)]
368 pub two_emojis_enabled: bool,
369 #[serde(default = "default_mode")]
371 pub mode: String,
372 #[serde(default)]
374 pub quick_habits_enabled: bool,
375 #[serde(default)]
377 pub channels: Vec<i64>,
378}
379
380fn default_language() -> String {
381 "en".to_string()
382}
383fn default_timezone() -> String {
384 "UTC".to_string()
385}
386fn default_pomodoro_duration() -> i64 {
387 50
388}
389fn default_mode() -> String {
390 "full".to_string()
391}
392
393impl Default for KnowledgeConfig {
394 fn default() -> Self {
395 Self {
396 language: default_language(),
397 timezone: default_timezone(),
398 move_to_commands: vec![],
399 pomodoro_duration_in_minutes: default_pomodoro_duration(),
400 schedules: vec![],
401 quick_commands: vec![],
402 two_emojis_enabled: false,
403 mode: default_mode(),
404 quick_habits_enabled: false,
405 channels: vec![],
406 }
407 }
408}
409
410pub const MODE_CHAT: &str = "chat";
412pub const MODE_FULL: &str = "full";
414pub const MODE_TASKS: &str = "tasks";
416pub const MODE_NOTES: &str = "notes";
418pub const MODE_JOURNAL: &str = "journal";
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn test_file_entry_new() {
427 let entry = FileEntry::new(
428 "Rust.md".to_string(),
429 "abc12345678".to_string(),
430 "Rust".to_string(),
431 1700000000000,
432 true,
433 false,
434 "/notes".to_string(),
435 );
436 assert_eq!(entry.name, "Rust.md");
437 assert_eq!(entry.hash, "abc12345678");
438 assert_eq!(entry.display_name, "Rust");
439 assert!(entry.has_content);
440 assert!(!entry.is_dir);
441 assert_eq!(entry.parent_dir, "/notes");
442 }
443
444 #[test]
445 fn test_file_entry_serialization() {
446 let entry = FileEntry::new(
447 "Test.md".to_string(),
448 "hash".to_string(),
449 "Test".to_string(),
450 1000,
451 false,
452 true,
453 "/".to_string(),
454 );
455 let json = serde_json::to_string(&entry).unwrap();
456 let restored: FileEntry = serde_json::from_str(&json).unwrap();
457 assert_eq!(restored.name, entry.name);
458 assert!(restored.is_dir);
459 assert!(!restored.has_content);
460 }
461
462 #[test]
463 fn test_fs_error_display() {
464 assert_eq!(FsError::QuotaExceeded.to_string(), "storage quota exceeded");
465 assert_eq!(
466 FsError::UnsafePath.to_string(),
467 "unsafe path, possible security issue"
468 );
469 assert_eq!(
470 FsError::CannotUnhash.to_string(),
471 "cannot unhash, maybe the file is missing"
472 );
473 }
474
475 #[test]
476 fn test_sync_response_default() {
477 let resp = SyncResponse::default();
478 assert_eq!(resp.status, STATUS_OK);
479 assert!(resp.files.is_empty());
480 assert!(resp.timestamps.is_empty());
481 assert!(resp.renames.is_empty());
482 }
483
484 #[test]
485 fn test_sync_file_serialization() {
486 let file = SyncFile {
487 status: STATUS_OK.to_string(),
488 path: "notes/Test.md".to_string(),
489 last_modified: 1700000000000,
490 client_last_modified: 1700000000000,
491 client_last_synced: 1700000000000,
492 content: "# Hello".to_string(),
493 };
494 let json = serde_json::to_string(&file).unwrap();
495 let restored: SyncFile = serde_json::from_str(&json).unwrap();
496 assert_eq!(restored.path, "notes/Test.md");
497 assert_eq!(restored.content, "# Hello");
498 }
499
500 #[test]
501 fn test_sync_request_serialization() {
502 let req = SyncRequest {
503 modified: vec![],
504 deleted: vec!["old.md".to_string()],
505 timestamps: {
506 let mut m = HashMap::new();
507 m.insert("/".to_string(), 1700000000000);
508 m
509 },
510 };
511 let json = serde_json::to_string(&req).unwrap();
512 let restored: SyncRequest = serde_json::from_str(&json).unwrap();
513 assert!(restored.modified.is_empty());
514 assert_eq!(restored.deleted.len(), 1);
515 assert_eq!(restored.deleted[0], "old.md");
516 }
517
518 #[test]
519 fn test_sync_error_from_fs_error() {
520 let err = SyncError::from(FsError::QuotaExceeded);
521 assert!(matches!(err, SyncError::QuotaExceeded));
522
523 let err = SyncError::from(FsError::CannotUnhash);
524 assert!(matches!(err, SyncError::Storage(_)));
525 }
526
527 #[test]
528 fn test_sync_error_display() {
529 assert_eq!(SyncError::InvalidJson.to_string(), "invalid JSON");
530 assert_eq!(SyncError::NotFound.to_string(), "file not found");
531 assert_eq!(SyncError::QuotaExceeded.to_string(), "quota exceeded");
532 }
533
534 #[test]
535 fn test_knowledge_config_default() {
536 let config = KnowledgeConfig::default();
537 assert_eq!(config.language, "en");
538 assert_eq!(config.timezone, "UTC");
539 assert_eq!(config.mode, "full");
540 assert_eq!(config.pomodoro_duration_in_minutes, 50);
541 assert!(config.move_to_commands.is_empty());
542 assert!(config.schedules.is_empty());
543 assert!(config.quick_commands.is_empty());
544 assert!(!config.two_emojis_enabled);
545 assert!(!config.quick_habits_enabled);
546 assert!(config.channels.is_empty());
547 }
548
549 #[test]
550 fn test_knowledge_config_serialization_roundtrip() {
551 let config = KnowledgeConfig {
552 language: "ko".to_string(),
553 timezone: "+09:00".to_string(),
554 move_to_commands: vec!["archive".to_string()],
555 pomodoro_duration_in_minutes: 25,
556 schedules: vec![Schedule {
557 filename: "Daily.md".to_string(),
558 scheduled_at: 1700000000000,
559 cron: "9:00".to_string(),
560 cmd: String::new(),
561 }],
562 quick_commands: vec!["today".to_string()],
563 two_emojis_enabled: true,
564 mode: "chat".to_string(),
565 quick_habits_enabled: true,
566 channels: vec![42],
567 };
568 let json = serde_json::to_string(&config).unwrap();
569 let restored: KnowledgeConfig = serde_json::from_str(&json).unwrap();
570 assert_eq!(restored.language, "ko");
571 assert_eq!(restored.timezone, "+09:00");
572 assert_eq!(restored.mode, "chat");
573 assert_eq!(restored.pomodoro_duration_in_minutes, 25);
574 assert_eq!(restored.schedules.len(), 1);
575 assert_eq!(restored.schedules[0].cron, "9:00");
576 assert!(restored.two_emojis_enabled);
577 assert!(restored.quick_habits_enabled);
578 assert_eq!(restored.channels, vec![42]);
579 }
580
581 #[test]
582 fn test_constants() {
583 assert_eq!(MAX_TEXT_SIZE, 5 * 1024 * 1024);
584 assert_eq!(MAX_TEXTS_SIZE, 10 * 1024 * 1024);
585 assert_eq!(MAX_MEDIA_SIZE, 20 * 1024 * 1024);
586 assert_eq!(MAX_MEDIAS_SIZE, 512 * 1024);
587 assert_eq!(MAX_TOKEN_SIZE, 4 * 1024);
588 assert_eq!(CHAT_FILENAME, "Chat.md");
589 assert_eq!(DIR_ARCHIVE, "archive");
590 assert_eq!(DIR_JOURNAL, "journal");
591 assert_eq!(DIR_HABITS, "habits");
592 assert_eq!(MD_EXT, ".md");
593 assert_eq!(HABIT_SKIPPED, "\u{26aa}\u{fe0f}");
594 assert_eq!(HABIT_COMPLETED, "\u{1f7e2}");
595 assert_eq!(MODE_CHAT, "chat");
596 assert_eq!(MODE_FULL, "full");
597 assert_eq!(MOOD_HABIT, "Mood");
598 assert_eq!(MOOD_EMOJIS.len(), 6);
599 }
600}