Skip to content

Commit 8bacaba

Browse files
authored
Merge pull request #89 from bugzmanov/pre_037
Release 0.3.7 prep
2 parents 306c140 + abc3555 commit 8bacaba

86 files changed

Lines changed: 3578 additions & 1141 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## CRITICAL RULES FOR AI ASSISTANTS
66

7+
0. **Working Directory**: NEVER CHANGE WORKING DIRECTORY UNLESS SPECIFICALLY ASKED. THE CURRENT USER WORKFLOW IS BASED ON WORKTREES, BY RECKLESSLY CHANGING DIRECTORIES YOU CAN MAKE IRREVERSIBLE DAMAGE.
8+
79
1. **Testing**: ALWAYS use the existing SVG-based snapshot testing in `tests/svg_snapshots.rs`. NEVER introduce new testing frameworks or approaches.
10+
1a. **Sandbox-Safe Tests**: All tests must run in sandboxed environments (e.g., Nix builds). This means tests MUST NOT: rely on a writable home directory or system directories (`dirs::data_dir()`, `dirs::cache_dir()`, etc.); make network requests; depend on system fonts, a real TTY, or specific environment variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`); assume standard tools exist in `PATH` beyond what's declared as dependencies. Use `tempfile::TempDir` for any filesystem operations, and inject/mock any external dependencies rather than relying on the host environment.
811
2. **Golden Snapshots**: NEVER update golden snapshot files with `SNAPSHOTS=overwrite` unless explicitly requested by the user. This is critical for test integrity.
912
3. **Test Updates**: NEVER update any test files or test expectations unless explicitly requested by the user. This includes unit tests, integration tests, and snapshot tests.
1013
4. **File Creation**: Prefer editing existing files over creating new ones. Only create new files when absolutely necessary.

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "bookokrat"
3-
version = "0.3.6"
3+
version = "0.3.7"
44
edition = "2024"
55
rust-version = "1.86"
66
authors = ["Rafael Bagmanov <bugzmanov@gmail.com>"]

TODO.md

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ Hard UX:
1313
[ ] Rework footer command bar to be context-aware and higher-contrast, reducing clutter.
1414
[ ] Comments editing in pdf annotations is very non-ergonomic. Potentially need normal mode.
1515
--
16-
[ ] update & analyze tapes & tests
17-
[ ] Analyze the PDF impact: size wise, heap wise and make it sure that it wouldn't affect epub only readers
18-
[ ] update help (new stuff & inspiration links)
16+
[x] update & analyze tapes & tests
17+
[x] Analyze the PDF impact: size wise, heap wise and make it sure that it wouldn't affect epub only readers
18+
[x] update help (new stuff & inspiration links)
1919
[ ] Make help context dependent
20-
[ ] new pictures for the site and potentially new video for the app.
20+
[x] new pictures for the site and potentially new video for the app.
2121

2222
ideas to implement:
2323
- [ ] Dimming should probably just use math instead of sticking to fixed palette
@@ -94,6 +94,35 @@ bugs:
9494
[ ] Effective rust - chapter 4
9595
[ ] Too much visual noise. (potentially because bg highlights)
9696
[ ] <i>code</i> will have bg color instead of being cursive
97+
[ ]
98+
-----
99+
of choosing which rule to apply to rewrite a symbol, we make that decision randomly. For example,
100+
consider our apple-banana grammar: Sentence → apple Sentence banana, Sentence → apple banana. If we
101+
start with the symbol Sentence and then flip a coin for which rule to apply, we end up defining a
102+
probability distribution over all sentences with some number of apples followed by the same number of
103+
bananas: “apple banana” appears with probability
104+
105+
1
106+
107+
2
108+
109+
, “apple apple banana banana” with probability
110+
111+
1
112+
113+
4
114+
115+
, “apple apple apple banana banana banana” with probability
116+
117+
1
118+
119+
8
120+
121+
, and so on.
122+
-----
123+
124+
Simple mathml expressions should be one-liners: 1/2, 1/4 etc.
125+
97126

98127
Tools with cool ratatui UI:
99128
- https://github.com/erikjuhani/basalt

readme.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@
9898
│ Ctrl+i Jump forward in history │
9999
│ z (PDF) Zoom to fit height │
100100
│ Z (PDF) Zoom to fit width │
101-
│ i (PDF) Toggle image inversion │
101+
│ i (PDF) Toggle image inversion (themed mode only; saved per book) │
102+
│ I (PDF) Switch between original PDF rendering and themed style │
103+
│ (saved per book). │
104+
│ Themed style is the default. │
105+
│ In original rendering mode, i has no visual effect. │
102106
│ n Toggle normal mode │
103107
└─────────────────────────────────────────────────────────────────────────────┘
104108

src/bookmarks.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ pub struct Bookmark {
3030
#[cfg(feature = "pdf")]
3131
#[serde(skip_serializing_if = "Option::is_none", default)]
3232
pub pdf_invert_images: Option<bool>,
33+
34+
#[cfg(feature = "pdf")]
35+
#[serde(skip_serializing_if = "Option::is_none", default)]
36+
pub pdf_themed_rendering: Option<bool>,
3337
}
3438

3539
#[derive(Debug, Serialize, Deserialize)]
@@ -188,6 +192,7 @@ impl Bookmarks {
188192
pdf_zoom,
189193
pdf_pan,
190194
None,
195+
None,
191196
);
192197
}
193198

@@ -204,6 +209,7 @@ impl Bookmarks {
204209
pdf_zoom: Option<f32>,
205210
pdf_pan: Option<u16>,
206211
pdf_invert_images: Option<bool>,
212+
pdf_themed_rendering: Option<bool>,
207213
) {
208214
self.update_bookmark_internal(
209215
path,
@@ -215,6 +221,7 @@ impl Bookmarks {
215221
pdf_zoom,
216222
pdf_pan,
217223
pdf_invert_images,
224+
pdf_themed_rendering,
218225
);
219226
}
220227

@@ -230,7 +237,9 @@ impl Bookmarks {
230237
pdf_zoom: Option<f32>,
231238
pdf_pan: Option<u16>,
232239
#[cfg(feature = "pdf")] pdf_invert_images: Option<bool>,
240+
#[cfg(feature = "pdf")] pdf_themed_rendering: Option<bool>,
233241
#[cfg(not(feature = "pdf"))] _pdf_invert_images: Option<bool>,
242+
#[cfg(not(feature = "pdf"))] _pdf_themed_rendering: Option<bool>,
234243
) {
235244
let key = self
236245
.resolve_existing_key(path)
@@ -248,6 +257,8 @@ impl Bookmarks {
248257
pdf_pan,
249258
#[cfg(feature = "pdf")]
250259
pdf_invert_images,
260+
#[cfg(feature = "pdf")]
261+
pdf_themed_rendering,
251262
},
252263
);
253264

src/images/image_popup.rs

Lines changed: 46 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -98,61 +98,59 @@ impl ImagePopup {
9898
loading_duration.as_millis()
9999
);
100100

101-
// Time the protocol creation (which includes resize)
102-
let start = Instant::now();
103-
let protocol = self
104-
.picker
105-
.new_protocol(
101+
let needs_protocol_rebuild = self
102+
.protocol
103+
.as_ref()
104+
.is_none_or(|existing| existing.area() != inner_area);
105+
106+
if needs_protocol_rebuild {
107+
let start = Instant::now();
108+
match self.picker.new_protocol(
106109
self.image.as_ref().clone(),
107-
self.calculate_optimal_popup_area(terminal_size),
110+
inner_area,
108111
Resize::Viewport(ViewportOptions {
109112
y_offset: 0,
110113
x_offset: 0,
111114
}),
112-
)
113-
.unwrap();
114-
let duration = start.elapsed();
115-
116-
self.protocol = Some(protocol);
117-
self.is_loading = false;
118-
119-
// Log the timing information
120-
let total_time = self.load_start.map(|s| s.elapsed()).unwrap_or(duration);
121-
debug!(
122-
"--Image popup stats for '{}': protocol creation: {}ms, total time: {}ms",
123-
self.src_path,
124-
duration.as_millis(),
125-
total_time.as_millis()
126-
);
115+
) {
116+
Ok(protocol) => {
117+
let duration = start.elapsed();
118+
self.protocol = Some(protocol);
119+
self.is_loading = false;
120+
121+
let total_time = self.load_start.map(|s| s.elapsed()).unwrap_or(duration);
122+
debug!(
123+
"--Image popup stats for '{}': protocol creation: {}ms, total time: {}ms",
124+
self.src_path,
125+
duration.as_millis(),
126+
total_time.as_millis()
127+
);
128+
}
129+
Err(e) => {
130+
debug!(
131+
"Failed to create image popup protocol for '{}': {e}",
132+
self.src_path
133+
);
134+
self.popup_area = Some(popup_area);
135+
return;
136+
}
137+
}
138+
}
127139

128140
let image_area = inner_area;
129-
let image_widget = Image::new(self.protocol.as_ref().unwrap());
130-
131-
let total_time = self.load_start.map(|s| s.elapsed()).unwrap_or(duration);
132-
let duration = start.elapsed();
133-
debug!(
134-
"--Image creation stats for '{}': protocol creation: {}ms, total time: {}ms",
135-
self.src_path,
136-
duration.as_millis(),
137-
total_time.as_millis()
138-
);
139-
140-
let render_start = Instant::now();
141-
f.render_widget(image_widget, image_area);
142-
let render_duration = render_start.elapsed();
143-
144-
debug!(
145-
"--Image widget render time for '{}': {}ms",
146-
self.src_path,
147-
render_duration.as_millis()
148-
);
149-
150-
let total_render_time = render_start.elapsed();
151-
debug!(
152-
"TOTAL render() time for '{}': {}ms",
153-
self.src_path,
154-
total_render_time.as_millis()
155-
);
141+
if let Some(ref protocol) = self.protocol {
142+
let image_widget = Image::new(protocol);
143+
144+
let image_render_start = Instant::now();
145+
f.render_widget(image_widget, image_area);
146+
let render_duration = image_render_start.elapsed();
147+
148+
debug!(
149+
"--Image widget render time for '{}': {}ms",
150+
self.src_path,
151+
render_duration.as_millis()
152+
);
153+
}
156154

157155
// Return the popup area so the main app knows where the image is displayed
158156
self.popup_area = Some(popup_area)

src/images/image_storage.rs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,29 @@ impl ImageStorage {
3939
let epub_path_str = epub_path.to_string_lossy().to_string();
4040
info!("Starting image extraction for: {epub_path_str}");
4141

42-
if self.book_dirs.lock().unwrap().contains_key(&epub_path_str) {
43-
info!("Images already extracted for this book");
44-
return Ok(());
45-
}
46-
4742
let book_name = epub_path
4843
.file_stem()
4944
.and_then(|s| s.to_str())
5045
.unwrap_or("unknown");
5146
let safe_book_name = sanitize_filename(book_name);
5247
let book_dir = self.base_dir.join(&safe_book_name);
48+
let cache_is_stale = is_cache_stale(epub_path, &book_dir);
49+
50+
if cache_is_stale {
51+
info!("EPUB is newer than cached images; forcing re-extraction");
52+
self.book_dirs.lock().unwrap().remove(&epub_path_str);
53+
if let Err(e) = fs::remove_dir_all(&book_dir)
54+
&& book_dir.exists()
55+
{
56+
warn!("Failed to remove stale image cache {book_dir:?}: {e}");
57+
}
58+
} else if self.book_dirs.lock().unwrap().contains_key(&epub_path_str) {
59+
info!("Images already extracted for this book");
60+
return Ok(());
61+
}
5362

5463
// Check if directory exists and already contains images
55-
if book_dir.exists() {
64+
if book_dir.exists() && !cache_is_stale {
5665
let mut has_images = false;
5766
if let Ok(entries) = fs::read_dir(&book_dir) {
5867
for entry in entries.flatten() {
@@ -460,9 +469,17 @@ fn collect_images_recursive(dir: &Path, images: &mut Vec<PathBuf>) -> Result<()>
460469
Ok(())
461470
}
462471

472+
fn is_cache_stale(epub_path: &Path, book_dir: &Path) -> bool {
473+
let epub_modified = epub_path.metadata().and_then(|m| m.modified()).ok();
474+
let cache_modified = book_dir.metadata().and_then(|m| m.modified()).ok();
475+
matches!((epub_modified, cache_modified), (Some(epub_t), Some(cache_t)) if epub_t > cache_t)
476+
}
477+
463478
#[cfg(test)]
464479
mod tests {
465480
use super::*;
481+
use std::thread::sleep;
482+
use std::time::Duration;
466483
use tempfile::TempDir;
467484
use walkdir::WalkDir;
468485

@@ -583,4 +600,18 @@ mod tests {
583600

584601
assert!(found, "expected extracted .jpg in image storage");
585602
}
603+
604+
#[test]
605+
fn cache_stale_when_epub_newer_than_cache_dir() {
606+
let root = TempDir::new().unwrap();
607+
let epub = root.path().join("book.epub");
608+
let cache = root.path().join("book");
609+
610+
fs::create_dir_all(&cache).unwrap();
611+
fs::write(cache.join("cover.webp"), b"old").unwrap();
612+
sleep(Duration::from_millis(1100));
613+
fs::write(&epub, b"newer epub").unwrap();
614+
615+
assert!(is_cache_stale(&epub, &cache));
616+
}
586617
}

0 commit comments

Comments
 (0)