1pub mod aggr;
2pub mod audio;
3pub mod chart;
4pub mod config;
5pub mod layout;
6pub mod log;
7pub mod metadata;
8pub mod panel;
9pub mod stream;
10pub mod tickers_table;
11pub mod util;
12
13use std::fs::File;
14use std::io::{Read, Write};
15use std::path::PathBuf;
16
17pub use audio::AudioStream;
18pub use chart::kline::indicators::{
19 IndicatorCatalogPaths, initialize_indicator_catalog, initialize_indicator_catalog_with_paths,
20};
21pub use config::ScaleFactor;
22pub use config::network::{Network, TradeFetchMode};
23pub use config::sidebar::{self, Sidebar};
24pub use config::state::{Layouts, State};
25pub use config::theme::Theme;
26pub use config::timezone::UserTimezone;
27
28#[doc(hidden)]
29pub use ::exchange;
30use ::log::{error, info, warn};
31#[doc(hidden)]
32pub use ::serde;
33pub use layout::{Dashboard, Layout, Pane};
34pub use metadata::{MarketMetadata, MetadataSource};
35
36use crate::layout::pane::VisualConfig;
37
38pub const SAVED_STATE_PATH: &str = "saved-state.json";
39
40#[derive(Debug, Clone)]
41pub enum PaneSettingsSnapshot {
42 Heatmap(chart::heatmap::SettingsSnapshot),
43 Kline(chart::kline::SettingsSnapshot),
44 TimeAndSales(Option<panel::timesales::Config>),
45 Ladder(Option<panel::ladder::Config>),
46}
47
48impl PaneSettingsSnapshot {
49 pub fn visual_config(&self) -> Option<VisualConfig> {
50 match self {
51 Self::Heatmap(snapshot) => snapshot.config.map(VisualConfig::Heatmap),
52 Self::TimeAndSales(config) => config.map(VisualConfig::TimeAndSales),
53 Self::Ladder(config) => config.map(VisualConfig::Ladder),
54 Self::Kline(_) => None,
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
60pub enum PaneSettingsTransfer {
61 Visual(Option<VisualConfig>),
62 Kline,
63}
64
65#[derive(thiserror::Error, Debug, Clone)]
66pub enum InternalError {
67 #[error("Fetch error: {0}")]
68 Fetch(String),
69 #[error("Layout error: {0}")]
70 Layout(String),
71}
72
73pub fn write_json_to_file(json: &str, file_name: &str) -> std::io::Result<()> {
74 let path = data_path(Some(file_name));
75
76 let parent = path.parent().ok_or_else(|| {
77 std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid state file path")
78 })?;
79
80 if !parent.exists() {
81 std::fs::create_dir_all(parent)?;
82 }
83
84 let tmp_path = path.with_extension("tmp");
87
88 let write_result = File::create(&tmp_path).and_then(|mut file| {
89 file.write_all(json.as_bytes())?;
90 file.sync_all()?;
91 std::fs::rename(&tmp_path, &path)
92 });
93
94 if let Err(e) = write_result {
95 let _ = std::fs::remove_file(&tmp_path);
96 return Err(e);
97 }
98 Ok(())
99}
100
101pub fn read_from_file(file_name: &str) -> Result<State, Box<dyn std::error::Error>> {
102 let path = data_path(Some(file_name));
103
104 let file_open_result = File::open(&path);
105 let mut file = match file_open_result {
106 Ok(file) => file,
107 Err(e) => return Err(Box::new(e)),
108 };
109
110 let mut contents = String::new();
111 if let Err(e) = file.read_to_string(&mut contents) {
112 return Err(Box::new(e));
113 }
114
115 match serde_json::from_str(&contents) {
116 Ok(state) => Ok(state),
117 Err(e) => {
118 drop(file); let backup_file_name = if let Some(pos) = file_name.rfind('.') {
123 format!("{}_old{}", &file_name[..pos], &file_name[pos..])
124 } else {
125 format!("{}_old", file_name)
126 };
127
128 let backup_path = data_path(Some(&backup_file_name));
129
130 if let Err(rename_err) = std::fs::rename(&path, &backup_path) {
131 warn!(
132 "Failed to backup corrupted state file '{}' to '{}': {}",
133 path.display(),
134 backup_path.display(),
135 rename_err
136 );
137 } else {
138 info!(
139 "Backed up corrupted state file to '{}'. It can be restored manually.",
140 backup_path.display()
141 );
142 }
143
144 Err(Box::new(e))
145 }
146 }
147}
148
149pub fn open_data_folder() -> Result<(), InternalError> {
150 let pathbuf = data_path(None);
151
152 if pathbuf.exists() {
153 if let Err(err) = open::that(&pathbuf) {
154 Err(InternalError::Layout(format!(
155 "Failed to open data folder: {:?}, error: {}",
156 pathbuf, err
157 )))
158 } else {
159 info!("Opened data folder: {:?}", pathbuf);
160 Ok(())
161 }
162 } else {
163 Err(InternalError::Layout(format!(
164 "Data folder does not exist: {:?}",
165 pathbuf
166 )))
167 }
168}
169
170pub fn open_url(url: &str) -> Result<(), InternalError> {
171 if let Err(err) = open::that(url) {
172 Err(InternalError::Layout(format!(
173 "Failed to open URL '{}': {}",
174 url, err
175 )))
176 } else {
177 info!("Opened URL: {url}");
178 Ok(())
179 }
180}
181
182pub fn data_path(path_name: Option<&str>) -> PathBuf {
183 if let Ok(path) = std::env::var("FLOWSURFACE_DATA_PATH") {
184 let root = PathBuf::from(path);
185 match path_name {
186 Some(name) => root.join(name),
187 None => root,
188 }
189 } else {
190 let data_dir = dirs_next::data_dir().unwrap_or_else(|| PathBuf::from("."));
191 if let Some(path_name) = path_name {
192 data_dir.join("flowsurface").join(path_name)
193 } else {
194 data_dir.join("flowsurface")
195 }
196 }
197}
198
199fn cleanup_directory(data_path: &PathBuf) -> usize {
200 if !data_path.exists() {
201 warn!("Data path {:?} does not exist, skipping cleanup", data_path);
202 return 0;
203 }
204
205 let re =
206 regex::Regex::new(r".*-(\d{4}-\d{2}-\d{2})\.zip$").expect("Cleanup regex pattern is valid");
207 let today = chrono::Local::now().date_naive();
208 let mut deleted_files = Vec::new();
209
210 let entries = match std::fs::read_dir(data_path) {
211 Ok(entries) => entries,
212 Err(e) => {
213 error!("Failed to read data directory {:?}: {}", data_path, e);
214 return 0;
215 }
216 };
217
218 for entry in entries.filter_map(Result::ok) {
219 let symbol_dir = match std::fs::read_dir(entry.path()) {
220 Ok(dir) => dir,
221 Err(e) => {
222 error!("Failed to read symbol directory {:?}: {}", entry.path(), e);
223 continue;
224 }
225 };
226
227 for file in symbol_dir.filter_map(Result::ok) {
228 let path = file.path();
229 let Some(filename) = path.to_str() else {
230 continue;
231 };
232
233 if let Some(cap) = re.captures(filename)
234 && let Ok(file_date) = chrono::NaiveDate::parse_from_str(&cap[1], "%Y-%m-%d")
235 {
236 let days_old = today.signed_duration_since(file_date).num_days();
237 if days_old > 4 {
238 if let Err(e) = std::fs::remove_file(&path) {
239 error!("Failed to remove old file {}: {}", filename, e);
240 } else {
241 deleted_files.push(filename.to_string());
242 info!("Removed old file: {}", filename);
243 }
244 }
245 }
246 }
247 }
248
249 deleted_files.len()
250}
251
252pub fn load_antialiasing() -> bool {
253 read_from_file(SAVED_STATE_PATH)
254 .map(|state| state.antialiasing)
255 .unwrap_or_else(|_| true)
256}
257
258pub fn cleanup_old_market_data() -> usize {
259 let paths = ["um", "cm"].map(|market_type| {
260 data_path(Some(&format!(
261 "market_data/binance/data/futures/{}/daily/aggTrades",
262 market_type
263 )))
264 });
265
266 let total_deleted: usize = paths.iter().map(cleanup_directory).sum();
267
268 info!("File cleanup completed. Deleted {} files", total_deleted);
269 total_deleted
270}