1use std::{collections::HashMap, sync::Arc};
2
3use ratatui::{
4 prelude::{Alignment, Rect},
5 style::Style,
6};
7use tokio::sync::mpsc::UnboundedSender;
8use tracing::{debug, error};
9use uuid::Uuid;
10
11use crate::{
12 action::{Action, ActionResult, PageViewerAction},
13 config::{Config, Theme},
14 terminal::Frame,
15 ui::centered_rect,
16};
17
18use super::{page::PageComponent, page_language_popup::PageLanguageSelectionComponent, Component};
19
20use wiki_api::{
21 languages::Language,
22 page::{Page, Property},
23};
24
25#[derive(Default)]
28pub struct PageViewer {
29 page: Vec<PageComponent>,
30 page_n: usize,
31 page_cache: HashMap<Uuid, PageComponent>,
32 page_identifier_index: HashMap<(String, String), Uuid>,
34
35 is_processing: bool,
36 changing_page_language_popup: Option<PageLanguageSelectionComponent>,
37
38 config: Arc<Config>,
39 theme: Arc<Theme>,
40
41 action_tx: Option<UnboundedSender<Action>>,
42}
43
44impl PageViewer {
45 fn cache_path() -> std::path::PathBuf {
46 let mut path = directories::ProjectDirs::from("com", "github", "wiki-tui")
47 .unwrap()
48 .cache_dir()
49 .to_path_buf();
50 path.push("page_cache.json");
51 path
52 }
53
54 fn load_cache(&mut self) {
55 let path = Self::cache_path();
56 if !path.exists() {
57 debug!("no cache file found at {:?}", path);
58 return;
59 }
60
61 let file = match std::fs::File::open(&path) {
62 Ok(file) => file,
63 Err(e) => {
64 error!("failed to open cache file at {:?}: {}", path, e);
65 return;
66 }
67 };
68
69 let reader = std::io::BufReader::new(file);
70 match serde_json::from_reader(reader) {
71 Ok(cache) => {
72 debug!("successfully loaded cache from {:?}", path);
73 self.page_cache = cache;
74 self.rebuild_identifier_index();
75 }
76 Err(e) => {
77 error!("failed to deserialize cache from {:?}: {}", path, e);
78 }
79 };
80 }
81
82 fn rebuild_identifier_index(&mut self) {
83 self.page_identifier_index.clear();
84 for (uuid, page_component) in &self.page_cache {
85 let key = (
86 page_component.page.title.clone(),
87 page_component.page.language.code().to_string(),
88 );
89 self.page_identifier_index.insert(key, *uuid);
90 }
91 debug!(
92 "rebuilt identifier index with {} entries",
93 self.page_identifier_index.len()
94 );
95 }
96
97 fn save_cache(&self) {
98 let path = Self::cache_path();
99 let file = match std::fs::File::create(&path) {
100 Ok(file) => file,
101 Err(e) => {
102 error!("failed to create cache file at {:?}: {}", path, e);
103 return;
104 }
105 };
106
107 let writer = std::io::BufWriter::new(file);
108 match serde_json::to_writer(writer, &self.page_cache) {
109 Ok(_) => debug!("successfully saved cache to {:?}", path),
110 Err(e) => error!("failed to serialize and save cache to {:?}: {}", path, e),
111 }
112 }
113
114 pub fn sync_and_save_cache(&mut self) {
116 debug!("syncing {} active pages to cache", self.page.len());
117 for page_component in &self.page {
118 let key = (
119 page_component.page.title.clone(),
120 page_component.page.language.code().to_string(),
121 );
122 self.page_cache
123 .insert(page_component.page.uuid, page_component.clone());
124 self.page_identifier_index
125 .insert(key, page_component.page.uuid);
126 }
127 self.save_cache();
128 }
129
130 pub fn get_cached_page(&self, title: &str, language: Language) -> Option<Page> {
132 let key = (title.to_string(), language.code().to_string());
133 debug!(
134 "cache lookup for: title='{}', language='{}'",
135 title,
136 language.code()
137 );
138 let uuid = self.page_identifier_index.get(&key)?;
139 debug!("found uuid in index: {}", uuid);
140 let page_component = self.page_cache.get(uuid)?;
141 debug!("found page component in cache");
142 Some(page_component.page.clone())
143 }
144
145 fn current_page_mut(&mut self) -> Option<&mut PageComponent> {
146 self.page.get_mut(self.page_n)
147 }
148
149 pub fn current_page(&self) -> Option<&PageComponent> {
150 self.page.get(self.page_n)
151 }
152
153 fn display_page(&mut self, page: Page) {
154 self.page_n = self.page.len();
155 debug!(
156 "display_page called for '{}' with uuid {}",
157 page.title, page.uuid
158 );
159
160 if let Some(mut cached_page) = self.page_cache.get(&page.uuid).cloned() {
162 debug!("found page in cache by uuid, using cached version");
163 cached_page.rebuild(self.config.clone(), self.theme.clone());
164 self.page.push(cached_page);
165 } else {
166 let key = (page.title.clone(), page.language.code().to_string());
168
169 if let Some(&existing_uuid) = self.page_identifier_index.get(&key) {
170 debug!(
172 "found existing page in index with different uuid {}, updating uuid to {}",
173 existing_uuid, page.uuid
174 );
175
176 if let Some(mut existing_page) = self.page_cache.remove(&existing_uuid) {
178 existing_page.page = page.clone();
180 existing_page.rebuild(self.config.clone(), self.theme.clone());
181
182 self.page_cache.insert(page.uuid, existing_page.clone());
184 self.page_identifier_index.insert(key, page.uuid);
185 self.page.push(existing_page);
186 } else {
187 debug!("index pointed to non-existent uuid, creating new page");
189 let new_page =
190 PageComponent::new(page.clone(), self.config.clone(), self.theme.clone());
191 self.page_cache.insert(page.uuid, new_page.clone());
192 self.page_identifier_index.insert(key, page.uuid);
193 self.page.push(new_page);
194 }
195 } else {
196 debug!("page not in cache or index, creating new PageComponent");
198 let new_page =
199 PageComponent::new(page.clone(), self.config.clone(), self.theme.clone());
200 debug!("adding page to cache and index with key: {:?}", key);
201 self.page_cache.insert(new_page.page.uuid, new_page.clone());
202 self.page_identifier_index.insert(key, new_page.page.uuid);
203 self.page.push(new_page);
204 }
205 self.save_cache();
206 }
207
208 if self.changing_page_language_popup.is_some() {
209 self.changing_page_language_popup = None;
210 }
211
212 self.is_processing = false;
214 }
215
216 fn pop(&mut self) {
217 self.page.pop();
218 self.page_n = self.page_n.saturating_sub(1);
219 }
220
221 pub fn get_page_language_selection_popup(&self) -> PageLanguageSelectionComponent {
222 let language_links = self
223 .current_page()
224 .and_then(|x| x.page.language_links.to_owned())
225 .unwrap_or_default();
226 PageLanguageSelectionComponent::new(language_links, self.config.clone(), self.theme.clone())
227 }
228}
229
230impl Component for PageViewer {
231 fn init(
232 &mut self,
233 action_tx: UnboundedSender<Action>,
234 config: Arc<Config>,
235 theme: Arc<Theme>,
236 ) -> anyhow::Result<()> {
237 self.action_tx = Some(action_tx);
238 self.config = config;
239 self.theme = theme;
240 self.load_cache();
241 Ok(())
242 }
243 fn handle_key_events(&mut self, key: crossterm::event::KeyEvent) -> ActionResult {
244 if self
245 .config
246 .bindings
247 .page
248 .toggle_page_language_selection
249 .matches_event(key)
250 {
251 return Action::ShowPageLanguageSelection.into();
252 }
253
254 if self.config.bindings.page.pop_page.matches_event(key) {
255 return Action::PageViewer(PageViewerAction::PopPage).into();
256 }
257
258 if let Some(page) = self.current_page_mut() {
259 return page.handle_key_events(key);
260 }
261
262 ActionResult::Ignored
263 }
264
265 fn update(&mut self, action: Action) -> ActionResult {
266 match action {
267 Action::TryLoadPage(title, language, endpoint) => {
268 if let Some(cached_page) = self.get_cached_page(&title, language) {
269 debug!("cache hit for page '{}' - loading instantly", title);
270 self.action_tx
271 .as_ref()
272 .unwrap()
273 .send(Action::SwitchContextPage)
274 .unwrap();
275 self.display_page(cached_page);
276 return ActionResult::consumed();
277 } else {
278 debug!("cache miss for page '{}' - fetching from API", title);
279 let page_request = wiki_api::page::Page::builder()
281 .page(title.clone())
282 .properties(vec![
283 Property::Text,
284 Property::Sections,
285 Property::LangLinks,
286 ])
287 .endpoint(endpoint)
288 .language(language)
289 .redirects(self.config.api.page_redirects);
290
291 let tx = self.action_tx.clone().unwrap();
292 tokio::spawn(async move {
293 tx.send(Action::SwitchContextPage).unwrap();
294 tx.send(Action::EnterProcessing).unwrap();
295
296 match page_request.fetch().await {
297 Ok(page) => tx
298 .send(Action::PageViewer(
299 crate::action::PageViewerAction::DisplayPage(page),
300 ))
301 .unwrap(),
302 Err(error) => {
303 let error_msg =
304 format!("Unable to fetch the page '{}': {}", title, error);
305 tracing::error!("{}", error_msg);
306 tx.send(Action::PageViewer(
307 crate::action::PageViewerAction::ExitLoading,
308 ))
309 .unwrap();
310 tx.send(Action::PopupError(error_msg)).unwrap();
311 }
312 };
313
314 tx.send(Action::EnterNormal).unwrap();
315 });
316 return ActionResult::consumed();
317 }
318 }
319 Action::PageViewer(page_viewer_action) => match page_viewer_action {
320 PageViewerAction::DisplayPage(page) => self.display_page(page),
321 PageViewerAction::PopPage => self.pop(),
322 PageViewerAction::ExitLoading => self.is_processing = false,
323 PageViewerAction::SaveCache => self.sync_and_save_cache(),
324 },
325 Action::EnterProcessing => self.is_processing = true,
326 Action::EnterNormal => self.is_processing = false,
327 _ => {
328 if let Some(page) = self.current_page_mut() {
329 return page.update(action);
330 }
331 return ActionResult::Ignored;
332 }
333 }
334 ActionResult::consumed()
335 }
336
337 fn render(&mut self, f: &mut Frame<'_>, area: Rect) {
338 if self.is_processing {
339 f.render_widget(
340 self.theme.default_block().border_style(
341 Style::default()
342 .fg(self.theme.border_highlight_fg)
343 .bg(self.theme.border_highlight_bg),
344 ),
345 area,
346 );
347 f.render_widget(
348 self.theme
349 .default_paragraph("Processing")
350 .alignment(Alignment::Center),
351 centered_rect(area, 100, 50),
352 );
353 return;
354 }
355
356 if self.current_page().is_none() {
357 f.render_widget(
358 self.theme
359 .default_paragraph("No page opened")
360 .alignment(Alignment::Center),
361 centered_rect(area, 100, 50),
362 );
363 return;
364 }
365
366 if let Some(page) = self.current_page_mut() {
367 page.render(f, area);
368 }
369 }
370}