Skip to main content

flowsurface_data/
lib.rs

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    // Write to a temp file and rename so the previous good state survives a
85    // crash or partial write.
86    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            // If parsing fails, backup the file
119            drop(file); // Close the file before renaming
120
121            // Create backup file with different name to prevent overwriting it
122            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}