Skip to content

Commit b82f5bf

Browse files
committed
feat: Official v1.0 Launch with GitHub Actions, Cross-platform releases, and offline reading
1 parent b4cfc36 commit b82f5bf

14 files changed

Lines changed: 599 additions & 309 deletions

File tree

.github/workflows/release.yml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
jobs:
9+
build-and-release:
10+
name: Build & Release
11+
runs-on: ${{ matrix.os }}
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
include:
16+
- os: ubuntu-latest
17+
target: x86_64-unknown-linux-gnu
18+
artifact_name: sage
19+
asset_name: sage-linux-x86_64.tar.gz
20+
- os: windows-latest
21+
target: x86_64-pc-windows-msvc
22+
artifact_name: sage.exe
23+
asset_name: sage-windows-x86_64.zip
24+
- os: macos-latest
25+
target: x86_64-apple-darwin
26+
artifact_name: sage
27+
asset_name: sage-macos-x86_64.tar.gz
28+
- os: macos-latest
29+
target: aarch64-apple-darwin
30+
artifact_name: sage
31+
asset_name: sage-macos-aarch64.tar.gz
32+
33+
steps:
34+
- name: Checkout repository
35+
uses: actions/checkout@v4
36+
37+
- name: Setup Rust
38+
uses: dtolnay/rust-toolchain@stable
39+
with:
40+
targets: ${{ matrix.target }}
41+
42+
- name: Cache dependencies
43+
uses: Swatinem/rust-cache@v2
44+
with:
45+
key: ${{ matrix.target }}
46+
47+
- name: Build binary
48+
run: cargo build --release --locked --target ${{ matrix.target }}
49+
50+
- name: Package Release (Linux/macOS)
51+
if: matrix.os != 'windows-latest'
52+
run: |
53+
cd target/${{ matrix.target }}/release
54+
tar -czvf ../../../${{ matrix.asset_name }} ${{ matrix.artifact_name }}
55+
56+
- name: Package Release (Windows)
57+
if: matrix.os == 'windows-latest'
58+
run: |
59+
cd target\${{ matrix.target }}\release
60+
Compress-Archive -Path ${{ matrix.artifact_name }} -DestinationPath ..\..\..\${{ matrix.asset_name }}
61+
62+
- name: Upload binaries to Release
63+
uses: softprops/action-gh-release@v2
64+
with:
65+
files: ${{ matrix.asset_name }}
66+
env:
67+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Generated by Cargo
2+
# will have compiled files and executables
3+
/target/
4+
5+
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
6+
# (Since Sage is an executable binary, we SHOULD commit Cargo.lock to ensure reproducible builds for users)
7+
# Cargo.lock
8+
9+
# These are backup files generated by rustfmt
10+
**/*.rs.bk
11+
12+
# Sage specific
13+
sage.db
14+
sage.db-journal
15+
*.epub
16+
*.log
17+
18+
# IDEs and Editors
19+
.idea/
20+
.vscode/
21+
*.swp
22+
*.swo
23+
24+
# OS specific
25+
.DS_Store
26+
.DS_Store?
27+
._*
28+
.Spotlight-V100
29+
.Trashes
30+
ehthumbs.db
31+
Thumbs.db
32+
33+
# Temp / Scratch files
34+
.agents/
35+
skills-lock.json
36+
refactor_ui.py
37+
scratch
38+
scratch.rs
39+
scratch_test/
40+
test_fetch.rs
41+
test_scraper.rs
42+
*.py

README.md

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,52 @@
1-
# Sage
1+
<div align="center">
2+
3+
# 📖 Sage
4+
5+
**A blazingly fast, highly customizable terminal-based web novel reader.**
6+
7+
[![Release](https://img.shields.io/github/v/release/musprodev/sage?style=for-the-badge&color=success)](https://github.com/musprodev/sage/releases/latest)
8+
[![Rust](https://img.shields.io/badge/rust-1.80%2B-blue.svg?style=for-the-badge)](https://www.rust-lang.org)
9+
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macOS%20%7C%20windows-lightgrey?style=for-the-badge)](#)
10+
[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=for-the-badge)](LICENSE)
11+
12+
<!-- Replace the link below with the actual URL to your recorded GIF of Sage in action -->
13+
![Sage in Action](https://raw.githubusercontent.com/musprodev/sage/main/assets/demo.gif)
14+
15+
</div>
216

317
Sage is a high-performance, terminal-based user interface (TUI) web novel reader written in Rust. It utilizes the powerful `ratatui` crate for rendering and features an advanced asynchronous scraping engine designed to bypass Cloudflare and directly fetch full chapter catalogs from sources like NovelBuddy.
418

5-
## Features
19+
## Features
620

721
- **Blazing Fast TUI:** Built with Rust and `ratatui`, offering an incredibly responsive, keyboard-driven interface inspired by `btop`.
822
- **Advanced Scraping:** Uses `primp` with Chrome V144 impersonation to seamlessly bypass Cloudflare and interact with hidden API endpoints, fetching entire novel chapter lists (1000+ chapters) instantly.
23+
- **True Offline Reading:** Background download your favorite novels directly into your local database. Sage will intelligently load texts from your hard drive if available, allowing you to read seamlessly without an internet connection.
924
- **Local SQLite Library:** Persists your novel progress, bookmarks, and downloaded chapters locally.
10-
- **Storage Manager:** An integrated `ncdu`-style storage manager lets you visualize disk usage per novel and instantly clear downloaded caches to free up space.
25+
- **Storage Manager:** An integrated `ncdu`-style storage manager lets you visualize disk usage per novel, set custom cross-platform export directories, and instantly clear downloaded caches to free up space.
1126
- **Premium Reader Experience:** A highly customizable reader view that supports:
1227
- Dynamic margins and text-width constraints (Narrow, Medium, Wide, Full).
1328
- Customizable line and paragraph spacing (`Compact` mode removes blank lines and indents paragraphs).
1429
- Reader-specific color themes (Sepia, Paper, Soft Dark).
1530
- Text alignment toggles.
16-
- **EPUB Export:** Seamlessly export fully downloaded novels into well-formatted EPUB files for your e-reader.
31+
- **EPUB Export:** Seamlessly export fully downloaded novels into well-formatted EPUB files for your e-reader (saved to your custom Export Directory).
1732

18-
## Installation
33+
## 🚀 Installation
1934

20-
### Prerequisites
35+
### Download Pre-compiled Binaries (Recommended)
2136

22-
Ensure you have the Rust toolchain installed. If not, install it via [rustup](https://rustup.rs/):
37+
Sage automatically builds and bundles standalone binaries for Windows, macOS, and Linux on every release!
2338

24-
```bash
25-
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
26-
```
39+
1. Head over to the [GitHub Releases page](https://github.com/musprodev/sage/releases).
40+
2. Download the compressed archive for your operating system:
41+
- **Linux:** `sage-linux-x86_64.tar.gz`
42+
- **macOS:** `sage-macos-x86_64.tar.gz` (or `aarch64` for Apple Silicon)
43+
- **Windows:** `sage-windows-x86_64.zip`
44+
3. Extract the archive and place the `sage` executable in your system's `PATH`.
2745

2846
### Building from Source
2947

48+
If you prefer to compile from source, ensure you have the [Rust toolchain](https://rustup.rs/) installed.
49+
3050
Clone the repository and build the release binary:
3151

3252
```bash

src/app.rs

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -256,15 +256,13 @@ impl App {
256256
self.is_loading = true;
257257

258258
// Optimistically load from DB first
259-
if let Some(novel) = &self.current_novel {
260-
if let Ok(db_chapters) = self.db().get_novel_chapters(&novel.id) {
261-
if !db_chapters.is_empty() {
259+
if let Some(novel) = &self.current_novel
260+
&& let Ok(db_chapters) = self.db().get_novel_chapters(&novel.id)
261+
&& !db_chapters.is_empty() {
262262
self.chapters = db_chapters;
263263
self.is_loading = false;
264264
self.status_message = String::from("Loaded chapters from library.");
265265
}
266-
}
267-
}
268266

269267
if self.is_loading {
270268
self.status_message = String::from("Fetching chapter list from web...");
@@ -284,15 +282,13 @@ impl App {
284282
pub fn load_selected_chapter(&mut self) {
285283
if let Some(chapter) = self.chapters.get(self.selected_chapter).cloned() {
286284
// First check if DB has the content
287-
if let Ok(db_chapters) = self.db().get_novel_chapters(&chapter.novel_id) {
288-
if let Some(db_chap) = db_chapters.into_iter().find(|c| c.id == chapter.id) {
289-
if let Some(content) = db_chap.content {
285+
if let Ok(db_chapters) = self.db().get_novel_chapters(&chapter.novel_id)
286+
&& let Some(db_chap) = db_chapters.into_iter().find(|c| c.id == chapter.id)
287+
&& let Some(content) = db_chap.content {
290288
self.current_chapter_content = Some(content);
291289
self.scroll_offset = 0;
292290
return; // Found locally!
293291
}
294-
}
295-
}
296292

297293
// Fallback to in-memory content or trigger web fetch
298294
if let Some(content) = chapter.content {
@@ -307,7 +303,7 @@ impl App {
307303

308304
pub fn trigger_download_chapter(&mut self, chapter_id: String, chapter_url: String) {
309305
self.is_loading = true;
310-
self.status_message = format!("Downloading chapter…");
306+
self.status_message = "Downloading chapter…".to_string();
311307

312308
let provider = Arc::clone(&self.provider);
313309
let tx = self.event_tx.clone();
@@ -390,11 +386,7 @@ impl App {
390386
AppEvent::DownloadProgress(novel_id, current, total) => {
391387
self.downloads_progress.insert(novel_id.clone(), (current, total));
392388
self.status_message = format!("Downloading '{}': {}/{} chapters", novel_id, current, total);
393-
if current == total {
394-
self.is_loading = false;
395-
} else {
396-
self.is_loading = true;
397-
}
389+
self.is_loading = current != total;
398390
}
399391

400392
AppEvent::ExportCompleted(msg) => {
@@ -435,13 +427,11 @@ impl App {
435427

436428
/// Try saving the reading progress if we are in Reading mode.
437429
pub fn try_save_progress(&mut self) {
438-
if self.current_pane == ActivePane::Reading {
439-
if let Some(novel) = &self.current_novel {
440-
if let Some(chapter) = self.chapters.get(self.selected_chapter) {
430+
if self.current_pane == ActivePane::Reading
431+
&& let Some(novel) = &self.current_novel
432+
&& let Some(chapter) = self.chapters.get(self.selected_chapter) {
441433
let _ = self.save_reading_progress(&novel.id.clone(), &chapter.id.clone());
442434
}
443-
}
444-
}
445435
}
446436

447437
/// Load chapters for a novel from the local database (offline).
@@ -562,11 +552,19 @@ mod tests {
562552
search_query: String::new(),
563553
search_input_focused: true,
564554
selected_novel: 0,
555+
selected_library_novel: 0,
565556
selected_chapter: 0,
566557
scroll_offset: 0,
567558
should_quit: false,
559+
theme_index: 0,
560+
reader_settings: crate::reader_settings::ReaderSettings::default(),
561+
config: crate::config::AppConfig::load(),
562+
storage_items: Vec::new(),
563+
storage_selected: 0,
564+
show_settings_panel: false,
568565

569566
search_state: SearchState::Idle,
567+
library_novels: Vec::new(),
570568
chapters: Vec::new(),
571569
current_novel: None,
572570
current_chapter_content: None,

src/config.rs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@ use serde::{Deserialize, Serialize};
22
use std::path::PathBuf;
33

44
#[derive(Debug, Clone, Serialize, Deserialize)]
5+
#[derive(Default)]
56
pub struct AppConfig {
67
pub export_dir: Option<PathBuf>,
78
}
89

9-
impl Default for AppConfig {
10-
fn default() -> Self {
11-
Self { export_dir: None }
12-
}
13-
}
1410

1511
impl AppConfig {
1612
pub fn config_path() -> PathBuf {
@@ -23,13 +19,11 @@ impl AppConfig {
2319

2420
pub fn load() -> Self {
2521
let path = Self::config_path();
26-
if path.exists() {
27-
if let Ok(content) = std::fs::read_to_string(&path) {
28-
if let Ok(config) = serde_json::from_str(&content) {
22+
if path.exists()
23+
&& let Ok(content) = std::fs::read_to_string(&path)
24+
&& let Ok(config) = serde_json::from_str(&content) {
2925
return config;
3026
}
31-
}
32-
}
3327
Self::default()
3428
}
3529

src/db.rs

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ impl Database {
120120
"SELECT id, title, author, cover_url, source_url, description
121121
FROM novels WHERE id = ?1",
122122
)?;
123-
123+
124124
let mut rows = stmt.query_map(params![novel_id], |row| {
125125
Ok(Novel {
126126
id: row.get(0)?,
@@ -143,7 +143,7 @@ impl Database {
143143
pub fn get_all_novels(&self) -> Result<Vec<Novel>> {
144144
let mut stmt = self.conn.prepare(
145145
"SELECT id, title, author, cover_url, source_url, description
146-
FROM novels ORDER BY title"
146+
FROM novels ORDER BY title",
147147
)?;
148148

149149
let rows = stmt.query_map([], |row| {
@@ -201,9 +201,9 @@ impl Database {
201201
JOIN chapters c ON n.id = c.novel_id
202202
WHERE c.is_downloaded = 1
203203
GROUP BY n.id
204-
ORDER BY size_bytes DESC"
204+
ORDER BY size_bytes DESC",
205205
)?;
206-
206+
207207
let items = stmt.query_map([], |row| {
208208
let size: i64 = row.get(3).unwrap_or(0);
209209
Ok(crate::app::StorageItem {
@@ -216,15 +216,24 @@ impl Database {
216216

217217
let mut result = Vec::new();
218218
for item in items {
219-
result.push(item.map_err(|e| SageError::Database(e))?);
219+
result.push(item.map_err(SageError::Database)?);
220220
}
221221
Ok(result)
222222
}
223223

224-
pub fn delete_novel(&self, novel_id: &str) -> Result<()> {
225-
self.conn.execute("DELETE FROM novels WHERE id = ?1", rusqlite::params![novel_id])?;
226-
self.conn.execute("DELETE FROM chapters WHERE novel_id = ?1", rusqlite::params![novel_id])?;
227-
self.conn.execute("DELETE FROM reading_progress WHERE novel_id = ?1", rusqlite::params![novel_id])?;
224+
pub fn delete_novel(&self, novel_id: &str) -> Result<()> {
225+
self.conn.execute(
226+
"DELETE FROM novels WHERE id = ?1",
227+
rusqlite::params![novel_id],
228+
)?;
229+
self.conn.execute(
230+
"DELETE FROM chapters WHERE novel_id = ?1",
231+
rusqlite::params![novel_id],
232+
)?;
233+
self.conn.execute(
234+
"DELETE FROM reading_progress WHERE novel_id = ?1",
235+
rusqlite::params![novel_id],
236+
)?;
228237
Ok(())
229238
}
230239

@@ -264,12 +273,7 @@ impl Database {
264273
// ──────────────────────────── Progress ──────────────────────────
265274

266275
/// Saves (or updates) the reading progress for a novel.
267-
pub fn save_progress(
268-
&self,
269-
novel_id: &str,
270-
chapter_id: &str,
271-
offset: usize,
272-
) -> Result<()> {
276+
pub fn save_progress(&self, novel_id: &str, chapter_id: &str, offset: usize) -> Result<()> {
273277
self.conn.execute(
274278
"INSERT INTO progress (novel_id, chapter_id, scroll_offset)
275279
VALUES (?1, ?2, ?3)
@@ -388,7 +392,10 @@ mod tests {
388392
db.upsert_chapter(&ch).unwrap();
389393

390394
let chapters = db.get_novel_chapters("novel-1").unwrap();
391-
assert_eq!(chapters[0].content.as_deref(), Some("Full chapter text here."));
395+
assert_eq!(
396+
chapters[0].content.as_deref(),
397+
Some("Full chapter text here.")
398+
);
392399
assert!(chapters[0].is_downloaded);
393400
}
394401

0 commit comments

Comments
 (0)