1use anyhow::Result;
2use chrono::{Datelike, Local, NaiveTime, Timelike};
3use editor::scroll::autoscroll::Autoscroll;
4use editor::Editor;
5use gpui::{actions, AppContext, ViewContext, WindowContext};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use settings::Settings;
9use std::{
10 fs::OpenOptions,
11 path::{Path, PathBuf},
12 sync::Arc,
13};
14use workspace::{AppState, Workspace};
15
16actions!(journal, [NewJournalEntry]);
17
18#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
19pub struct JournalSettings {
20 pub path: Option<String>,
21 pub hour_format: Option<HourFormat>,
22}
23
24impl Default for JournalSettings {
25 fn default() -> Self {
26 Self {
27 path: Some("~".into()),
28 hour_format: Some(Default::default()),
29 }
30 }
31}
32
33#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
34#[serde(rename_all = "snake_case")]
35pub enum HourFormat {
36 #[default]
37 Hour12,
38 Hour24,
39}
40
41impl settings::Settings for JournalSettings {
42 const KEY: Option<&'static str> = Some("journal");
43
44 type FileContent = Self;
45
46 fn load(
47 defaults: &Self::FileContent,
48 user_values: &[&Self::FileContent],
49 _: &mut AppContext,
50 ) -> Result<Self> {
51 Self::load_via_json_merge(defaults, user_values)
52 }
53}
54
55pub fn init(_: Arc<AppState>, cx: &mut AppContext) {
56 JournalSettings::register(cx);
57
58 cx.observe_new_views(
59 |workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
60 workspace.register_action(|workspace, _: &NewJournalEntry, cx| {
61 new_journal_entry(workspace.app_state().clone(), cx);
62 });
63 },
64 )
65 .detach();
66}
67
68pub fn new_journal_entry(app_state: Arc<AppState>, cx: &mut WindowContext) {
69 let settings = JournalSettings::get_global(cx);
70 let journal_dir = match journal_dir(settings.path.as_ref().unwrap()) {
71 Some(journal_dir) => journal_dir,
72 None => {
73 log::error!("Can't determine journal directory");
74 return;
75 }
76 };
77
78 let now = Local::now();
79 let month_dir = journal_dir
80 .join(format!("{:02}", now.year()))
81 .join(format!("{:02}", now.month()));
82 let entry_path = month_dir.join(format!("{:02}.md", now.day()));
83 let now = now.time();
84 let entry_heading = heading_entry(now, &settings.hour_format);
85
86 let create_entry = cx.background_executor().spawn(async move {
87 std::fs::create_dir_all(month_dir)?;
88 OpenOptions::new()
89 .create(true)
90 .write(true)
91 .open(&entry_path)?;
92 Ok::<_, std::io::Error>((journal_dir, entry_path))
93 });
94
95 cx.spawn(|mut cx| async move {
96 let (journal_dir, entry_path) = create_entry.await?;
97 let (workspace, _) = cx
98 .update(|_, cx| workspace::open_paths(&[journal_dir], &app_state, None, cx))?
99 .await?;
100
101 let opened = workspace
102 .update(&mut cx, |workspace, cx| {
103 workspace.open_paths(vec![entry_path], true, cx)
104 })?
105 .await;
106
107 if let Some(Some(Ok(item))) = opened.first() {
108 if let Some(editor) = item.downcast::<Editor>().map(|editor| editor.downgrade()) {
109 editor.update(&mut cx, |editor, cx| {
110 let len = editor.buffer().read(cx).len(cx);
111 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
112 s.select_ranges([len..len])
113 });
114 if len > 0 {
115 editor.insert("\n\n", cx);
116 }
117 editor.insert(&entry_heading, cx);
118 editor.insert("\n\n", cx);
119 })?;
120 }
121 }
122
123 anyhow::Ok(())
124 })
125 .detach_and_log_err(cx);
126}
127
128fn journal_dir(path: &str) -> Option<PathBuf> {
129 let expanded_journal_dir = shellexpand::full(path) //TODO handle this better
130 .ok()
131 .map(|dir| Path::new(&dir.to_string()).to_path_buf().join("journal"));
132
133 return expanded_journal_dir;
134}
135
136fn heading_entry(now: NaiveTime, hour_format: &Option<HourFormat>) -> String {
137 match hour_format {
138 Some(HourFormat::Hour24) => {
139 let hour = now.hour();
140 format!("# {}:{:02}", hour, now.minute())
141 }
142 _ => {
143 let (pm, hour) = now.hour12();
144 let am_or_pm = if pm { "PM" } else { "AM" };
145 format!("# {}:{:02} {}", hour, now.minute(), am_or_pm)
146 }
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 mod heading_entry_tests {
153 use super::super::*;
154
155 #[test]
156 fn test_heading_entry_defaults_to_hour_12() {
157 let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();
158 let actual_heading_entry = heading_entry(naive_time, &None);
159 let expected_heading_entry = "# 3:00 PM";
160
161 assert_eq!(actual_heading_entry, expected_heading_entry);
162 }
163
164 #[test]
165 fn test_heading_entry_is_hour_12() {
166 let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();
167 let actual_heading_entry = heading_entry(naive_time, &Some(HourFormat::Hour12));
168 let expected_heading_entry = "# 3:00 PM";
169
170 assert_eq!(actual_heading_entry, expected_heading_entry);
171 }
172
173 #[test]
174 fn test_heading_entry_is_hour_24() {
175 let naive_time = NaiveTime::from_hms_milli_opt(15, 0, 0, 0).unwrap();
176 let actual_heading_entry = heading_entry(naive_time, &Some(HourFormat::Hour24));
177 let expected_heading_entry = "# 15:00";
178
179 assert_eq!(actual_heading_entry, expected_heading_entry);
180 }
181 }
182}