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, PartialEq, Eq)]
289pub enum NoteSource {
290 Hook,
292 Tool,
294 Ui,
296 Dream,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
302pub enum NoteQuality {
303 Raw,
305 Curated,
307 Refined,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct NoteMeta {
316 pub author: String,
318 pub source: NoteSource,
320 pub quality: NoteQuality,
322 pub needs_review: bool,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub session_id: Option<String>,
327 #[serde(skip_serializing_if = "Option::is_none")]
329 pub message_index: Option<usize>,
330 #[serde(skip_serializing_if = "Option::is_none")]
332 pub saved_at: Option<String>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct KnowledgeConfig {
345 #[serde(default = "default_language")]
347 pub language: String,
348 #[serde(default = "default_timezone")]
350 pub timezone: String,
351 #[serde(default)]
353 pub move_to_commands: Vec<String>,
354 #[serde(default = "default_pomodoro_duration")]
356 pub pomodoro_duration_in_minutes: i64,
357 #[serde(default)]
359 pub schedules: Vec<Schedule>,
360 #[serde(default)]
362 pub quick_commands: Vec<String>,
363 #[serde(default)]
365 pub two_emojis_enabled: bool,
366 #[serde(default = "default_mode")]
368 pub mode: String,
369 #[serde(default)]
371 pub quick_habits_enabled: bool,
372 #[serde(default)]
374 pub channels: Vec<i64>,
375}
376
377fn default_language() -> String {
378 "en".to_string()
379}
380fn default_timezone() -> String {
381 "UTC".to_string()
382}
383fn default_pomodoro_duration() -> i64 {
384 50
385}
386fn default_mode() -> String {
387 "full".to_string()
388}
389
390impl Default for KnowledgeConfig {
391 fn default() -> Self {
392 Self {
393 language: default_language(),
394 timezone: default_timezone(),
395 move_to_commands: vec![],
396 pomodoro_duration_in_minutes: default_pomodoro_duration(),
397 schedules: vec![],
398 quick_commands: vec![],
399 two_emojis_enabled: false,
400 mode: default_mode(),
401 quick_habits_enabled: false,
402 channels: vec![],
403 }
404 }
405}
406
407pub const MODE_CHAT: &str = "chat";
409pub const MODE_FULL: &str = "full";
411pub const MODE_TASKS: &str = "tasks";
413pub const MODE_NOTES: &str = "notes";
415pub const MODE_JOURNAL: &str = "journal";
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn test_file_entry_new() {
424 let entry = FileEntry::new(
425 "Rust.md".to_string(),
426 "abc12345678".to_string(),
427 "Rust".to_string(),
428 1700000000000,
429 true,
430 false,
431 "/notes".to_string(),
432 );
433 assert_eq!(entry.name, "Rust.md");
434 assert_eq!(entry.hash, "abc12345678");
435 assert_eq!(entry.display_name, "Rust");
436 assert!(entry.has_content);
437 assert!(!entry.is_dir);
438 assert_eq!(entry.parent_dir, "/notes");
439 }
440
441 #[test]
442 fn test_file_entry_serialization() {
443 let entry = FileEntry::new(
444 "Test.md".to_string(),
445 "hash".to_string(),
446 "Test".to_string(),
447 1000,
448 false,
449 true,
450 "/".to_string(),
451 );
452 let json = serde_json::to_string(&entry).unwrap();
453 let restored: FileEntry = serde_json::from_str(&json).unwrap();
454 assert_eq!(restored.name, entry.name);
455 assert!(restored.is_dir);
456 assert!(!restored.has_content);
457 }
458
459 #[test]
460 fn test_fs_error_display() {
461 assert_eq!(FsError::QuotaExceeded.to_string(), "storage quota exceeded");
462 assert_eq!(
463 FsError::UnsafePath.to_string(),
464 "unsafe path, possible security issue"
465 );
466 assert_eq!(
467 FsError::CannotUnhash.to_string(),
468 "cannot unhash, maybe the file is missing"
469 );
470 }
471
472 #[test]
473 fn test_sync_response_default() {
474 let resp = SyncResponse::default();
475 assert_eq!(resp.status, STATUS_OK);
476 assert!(resp.files.is_empty());
477 assert!(resp.timestamps.is_empty());
478 assert!(resp.renames.is_empty());
479 }
480
481 #[test]
482 fn test_sync_file_serialization() {
483 let file = SyncFile {
484 status: STATUS_OK.to_string(),
485 path: "notes/Test.md".to_string(),
486 last_modified: 1700000000000,
487 client_last_modified: 1700000000000,
488 client_last_synced: 1700000000000,
489 content: "# Hello".to_string(),
490 };
491 let json = serde_json::to_string(&file).unwrap();
492 let restored: SyncFile = serde_json::from_str(&json).unwrap();
493 assert_eq!(restored.path, "notes/Test.md");
494 assert_eq!(restored.content, "# Hello");
495 }
496
497 #[test]
498 fn test_sync_request_serialization() {
499 let req = SyncRequest {
500 modified: vec![],
501 deleted: vec!["old.md".to_string()],
502 timestamps: {
503 let mut m = HashMap::new();
504 m.insert("/".to_string(), 1700000000000);
505 m
506 },
507 };
508 let json = serde_json::to_string(&req).unwrap();
509 let restored: SyncRequest = serde_json::from_str(&json).unwrap();
510 assert!(restored.modified.is_empty());
511 assert_eq!(restored.deleted.len(), 1);
512 assert_eq!(restored.deleted[0], "old.md");
513 }
514
515 #[test]
516 fn test_sync_error_from_fs_error() {
517 let err = SyncError::from(FsError::QuotaExceeded);
518 assert!(matches!(err, SyncError::QuotaExceeded));
519
520 let err = SyncError::from(FsError::CannotUnhash);
521 assert!(matches!(err, SyncError::Storage(_)));
522 }
523
524 #[test]
525 fn test_sync_error_display() {
526 assert_eq!(SyncError::InvalidJson.to_string(), "invalid JSON");
527 assert_eq!(SyncError::NotFound.to_string(), "file not found");
528 assert_eq!(SyncError::QuotaExceeded.to_string(), "quota exceeded");
529 }
530
531 #[test]
532 fn test_knowledge_config_default() {
533 let config = KnowledgeConfig::default();
534 assert_eq!(config.language, "en");
535 assert_eq!(config.timezone, "UTC");
536 assert_eq!(config.mode, "full");
537 assert_eq!(config.pomodoro_duration_in_minutes, 50);
538 assert!(config.move_to_commands.is_empty());
539 assert!(config.schedules.is_empty());
540 assert!(config.quick_commands.is_empty());
541 assert!(!config.two_emojis_enabled);
542 assert!(!config.quick_habits_enabled);
543 assert!(config.channels.is_empty());
544 }
545
546 #[test]
547 fn test_knowledge_config_serialization_roundtrip() {
548 let config = KnowledgeConfig {
549 language: "ko".to_string(),
550 timezone: "+09:00".to_string(),
551 move_to_commands: vec!["archive".to_string()],
552 pomodoro_duration_in_minutes: 25,
553 schedules: vec![Schedule {
554 filename: "Daily.md".to_string(),
555 scheduled_at: 1700000000000,
556 cron: "9:00".to_string(),
557 cmd: String::new(),
558 }],
559 quick_commands: vec!["today".to_string()],
560 two_emojis_enabled: true,
561 mode: "chat".to_string(),
562 quick_habits_enabled: true,
563 channels: vec![42],
564 };
565 let json = serde_json::to_string(&config).unwrap();
566 let restored: KnowledgeConfig = serde_json::from_str(&json).unwrap();
567 assert_eq!(restored.language, "ko");
568 assert_eq!(restored.timezone, "+09:00");
569 assert_eq!(restored.mode, "chat");
570 assert_eq!(restored.pomodoro_duration_in_minutes, 25);
571 assert_eq!(restored.schedules.len(), 1);
572 assert_eq!(restored.schedules[0].cron, "9:00");
573 assert!(restored.two_emojis_enabled);
574 assert!(restored.quick_habits_enabled);
575 assert_eq!(restored.channels, vec![42]);
576 }
577
578 #[test]
579 fn test_constants() {
580 assert_eq!(MAX_TEXT_SIZE, 5 * 1024 * 1024);
581 assert_eq!(MAX_TEXTS_SIZE, 10 * 1024 * 1024);
582 assert_eq!(MAX_MEDIA_SIZE, 20 * 1024 * 1024);
583 assert_eq!(MAX_MEDIAS_SIZE, 512 * 1024);
584 assert_eq!(MAX_TOKEN_SIZE, 4 * 1024);
585 assert_eq!(CHAT_FILENAME, "Chat.md");
586 assert_eq!(DIR_ARCHIVE, "archive");
587 assert_eq!(DIR_JOURNAL, "journal");
588 assert_eq!(DIR_HABITS, "habits");
589 assert_eq!(MD_EXT, ".md");
590 assert_eq!(HABIT_SKIPPED, "\u{26aa}\u{fe0f}");
591 assert_eq!(HABIT_COMPLETED, "\u{1f7e2}");
592 assert_eq!(MODE_CHAT, "chat");
593 assert_eq!(MODE_FULL, "full");
594 assert_eq!(MOOD_HABIT, "Mood");
595 assert_eq!(MOOD_EMOJIS.len(), 6);
596 }
597}