ck3_history_extractor/
main.rs

1use clap::Parser;
2use derive_more::From;
3use human_panic::setup_panic;
4use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
5use serde_json;
6use std::{
7    env, error,
8    fmt::{self, Debug, Display, Formatter},
9    fs,
10    io::{stdin, stdout, IsTerminal},
11    ops::Not,
12    time::Duration,
13};
14
15use ck3_history_extractor_lib::{
16    display::{GetPath, Renderer},
17    game_data::{GameDataLoader, Localizable},
18    parser::{process_section, yield_section, GameState, SaveFile, SaveFileError},
19    structures::{GameObjectDerived, Player},
20};
21
22/// The submodule responsible for creating the [minijinja::Environment] and loading of templates.
23mod jinja_env;
24use jinja_env::create_env;
25
26/// A submodule for handling the arguments passed to the program
27mod args;
28use args::Args;
29
30/// A submodule for handling Steam integration
31mod steam;
32
33/// The interval at which the progress bars should update.
34const INTERVAL: Duration = Duration::from_secs(1);
35
36/// An error a user has caused. Shame on him.
37#[derive(From, Debug)]
38enum UserError {
39    /// The program is not running in a terminal
40    NoTerminal,
41    /// The file does not exist
42    FileDoesNotExist,
43    /// An error occurred during file handling
44    FileError(SaveFileError),
45}
46
47impl Display for UserError {
48    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
49        match self {
50            UserError::NoTerminal => write!(f, "The program is not running in a terminal"),
51            UserError::FileDoesNotExist => write!(f, "The file does not exist"),
52            UserError::FileError(e) => write!(f, "An error occurred during file handling: {}", e),
53        }
54    }
55}
56
57impl error::Error for UserError {
58    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
59        match self {
60            UserError::FileError(e) => Some(e),
61            _ => None,
62        }
63    }
64}
65
66/// Main function. This is the entry point of the program.
67///
68/// # Process
69///
70/// 1. Reads user input through the command line arguments or prompts the user for input.
71/// 2. Parses the save file.
72///     1. Initializes a [SaveFile] object using the provided file name
73///     2. Iterates over the Section objects in the save file
74///         If the section is of interest to us (e.g. `living`, `dead_unprunable`, etc.):
75///         1. We parse the section into [SaveFileObject](crate::parser::SaveFileObject) objects
76///         2. We parse the objects into [Derived](structures::GameObjectDerived) objects
77///         3. We store the objects in the [GameState] object
78/// 3. Initializes a [minijinja::Environment] and loads the templates from the `templates` folder
79/// 4. Foreach encountered [structures::Player] in game:
80///     1. Creates a folder with the player's name
81///     2. Renders the objects into HTML using the templates and writes them to the folder
82/// 5. Prints the time taken to parse the save file
83///
84fn main() -> Result<(), UserError> {
85    if cfg!(debug_assertions) {
86        env::set_var("RUST_BACKTRACE", "1");
87    }
88    setup_panic!();
89    //User IO
90    let args = if env::args().len() < 2 {
91        if !stdout().is_terminal() {
92            return Err(UserError::NoTerminal);
93        }
94        Args::get_from_user()
95    } else {
96        Args::parse()
97    };
98    // arguments passed
99    if !args.filename.exists() || !args.filename.is_file() {
100        return Err(UserError::FileDoesNotExist);
101    }
102    let bar_style = ProgressStyle::default_bar()
103        .template("[{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}")
104        .unwrap()
105        .progress_chars("#>-");
106    let spinner_style = ProgressStyle::default_spinner()
107        .template("[{elapsed_precise}] {spinner} {msg}")
108        .unwrap()
109        .tick_chars("|/-\\ ");
110    let mut include_paths = args.include;
111    //even though we don't need these for parsing, we load them here to error out early
112    if let Some(game_path) = args.game_path {
113        include_paths.push(game_path);
114    }
115    let mut loader = GameDataLoader::new(args.no_vis, args.language);
116    if !include_paths.is_empty() {
117        println!("Using game files from: {:?}", include_paths);
118        let progress_bar = ProgressBar::new(include_paths.len() as u64);
119        progress_bar.set_style(bar_style.clone());
120        // "items" in this case are huge, 8s on my ssd, so we enable the steady tick
121        progress_bar.enable_steady_tick(INTERVAL);
122        for path in progress_bar.wrap_iter(include_paths.iter().rev()) {
123            progress_bar.set_message(path.to_str().unwrap().to_owned());
124            loader.process_path(path).unwrap();
125        }
126        progress_bar.finish_with_message("Game files loaded");
127    }
128    let mut data = loader.finalize();
129    //initialize the save file
130    let save = SaveFile::open(args.filename)?;
131    // this is sort of like the first round of filtering where we store the objects we care about
132    let mut game_state: GameState = GameState::default();
133    let mut players: Vec<Player> = Vec::new();
134    let progress_bar = ProgressBar::new_spinner();
135    progress_bar.set_style(spinner_style.clone());
136    progress_bar.enable_steady_tick(INTERVAL);
137    let mut tape = save.tape();
138    while let Some(res) = yield_section(&mut tape) {
139        let section = res.unwrap();
140        progress_bar.set_message(section.get_name().to_owned());
141        // if an error occured somewhere here, there's nothing we can do
142        process_section(section, &mut game_state, &mut players).unwrap();
143        progress_bar.inc(1);
144    }
145    progress_bar.finish_with_message("Save parsing complete");
146    //prepare things for rendering
147    game_state.localize(&mut data).unwrap();
148    let grapher = args.no_vis.not().then(|| game_state.new_grapher());
149    let timeline = args.no_vis.not().then(|| game_state.new_timeline());
150    let mut env = create_env(
151        args.use_internal,
152        data.get_map().is_some(),
153        args.no_vis,
154        &data,
155    );
156    // a big progress bar to show the progress of rendering that contains multiple progress bars
157    let rendering_progress_bar = MultiProgress::new();
158    let player_progress = rendering_progress_bar.add(ProgressBar::new(players.len() as u64));
159    player_progress.set_style(bar_style);
160    player_progress.enable_steady_tick(INTERVAL);
161    for player in player_progress.wrap_iter(players.iter_mut()) {
162        player.localize(&mut data).unwrap();
163        //render each player
164        let folder_name = player.get_name().to_string() + "'s history";
165        player_progress.set_message(format!("Rendering {}", folder_name));
166        let path = args.output.join(folder_name);
167        let mut renderer = Renderer::new(
168            path.as_path(),
169            &game_state,
170            &data,
171            grapher.as_ref(),
172            args.depth,
173        );
174        let render_spinner = rendering_progress_bar.add(ProgressBar::new_spinner());
175        render_spinner.set_style(spinner_style.clone());
176        render_spinner.enable_steady_tick(INTERVAL);
177        if !args.no_vis {
178            render_spinner.inc(renderer.add_object(timeline.as_ref().unwrap()) as u64);
179        }
180        render_spinner.inc(renderer.add_object(player) as u64);
181        renderer.render_all(&mut env);
182        render_spinner.finish_with_message("Rendering complete");
183        if stdin().is_terminal() && stdout().is_terminal() && !args.no_interaction {
184            // no need to error out here, its just a convenience feature
185            if let Err(e) = open::that(player.get_path(path.as_path())) {
186                eprintln!("Error opening browser: {}", e);
187            }
188        }
189        rendering_progress_bar.remove(&render_spinner);
190    }
191    player_progress.finish_with_message("Players rendered");
192    if let Some(dump_path) = args.dump {
193        let json = serde_json::to_string_pretty(&game_state).unwrap();
194        fs::write(dump_path, json).unwrap();
195    }
196    if let Some(dump_path) = args.dump_data {
197        let json = serde_json::to_string_pretty(&data).unwrap();
198        fs::write(dump_path, json).unwrap();
199    }
200    return Ok(());
201}