From d3b459a5e99a95448a5f7b85031e010be36edd86 Mon Sep 17 00:00:00 2001 From: randomnoise Date: Fri, 23 May 2025 17:27:36 +0300 Subject: [PATCH 1/9] Fix missing ABI warning for Rust 1.86 --- crates/core/src/document/djvulibre_sys.rs | 2 +- crates/core/src/document/mupdf_sys.rs | 2 +- crates/core/src/font/freetype_sys.rs | 4 ++-- crates/core/src/font/harfbuzz_sys.rs | 2 +- crates/core/src/font/mod.rs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/core/src/document/djvulibre_sys.rs b/crates/core/src/document/djvulibre_sys.rs index d7cca2a7..efbb00a2 100644 --- a/crates/core/src/document/djvulibre_sys.rs +++ b/crates/core/src/document/djvulibre_sys.rs @@ -42,7 +42,7 @@ pub type RenderMode = libc::c_uint; pub type FormatStyle = libc::c_uint; #[link(name="djvulibre")] -extern { +extern "C" { pub fn ddjvu_context_create(name: *const libc::c_char) -> *mut ExoContext; pub fn ddjvu_context_release(ctx: *mut ExoContext); pub fn ddjvu_cache_set_size(ctx: *mut ExoContext, size: libc::c_ulong); diff --git a/crates/core/src/document/mupdf_sys.rs b/crates/core/src/document/mupdf_sys.rs index 2bcac493..6a312019 100644 --- a/crates/core/src/document/mupdf_sys.rs +++ b/crates/core/src/document/mupdf_sys.rs @@ -42,7 +42,7 @@ pub enum FzImage {} #[link(name="mupdf")] #[link(name="mupdf_wrapper", kind="static")] -extern { +extern "C" { pub fn fz_new_context_imp(alloc_ctx: *const FzAllocContext, locks_ctx: *const FzLocksContext, cache_size: libc::size_t, version: *const libc::c_char) -> *mut FzContext; pub fn fz_drop_context(ctx: *mut FzContext); pub fn fz_register_document_handlers(ctx: *mut FzContext); diff --git a/crates/core/src/font/freetype_sys.rs b/crates/core/src/font/freetype_sys.rs index b1e3f25f..8a92a968 100644 --- a/crates/core/src/font/freetype_sys.rs +++ b/crates/core/src/font/freetype_sys.rs @@ -23,7 +23,7 @@ pub type FtPos = libc::c_long; pub type FtFixed = libc::c_long; pub type FtGlyphFormat = libc::c_uint; pub type GlyphBBoxMode = libc::c_uint; -pub type FtGenericFinalizer = extern fn(*mut libc::c_void); +pub type FtGenericFinalizer = extern "C" fn(*mut libc::c_void); pub enum FtLibrary {} pub enum FtCharMap {} @@ -36,7 +36,7 @@ pub enum FtMemory {} pub enum FtStream {} #[link(name="freetype")] -extern { +extern "C" { pub fn FT_Init_FreeType(lib: *mut *mut FtLibrary) -> FtError; pub fn FT_Done_FreeType(lib: *mut FtLibrary) -> FtError; pub fn FT_New_Face(lib: *mut FtLibrary, path: *const libc::c_char, idx: libc::c_long, face: *mut *mut FtFace) -> FtError; diff --git a/crates/core/src/font/harfbuzz_sys.rs b/crates/core/src/font/harfbuzz_sys.rs index 52ed1e08..dda3e329 100644 --- a/crates/core/src/font/harfbuzz_sys.rs +++ b/crates/core/src/font/harfbuzz_sys.rs @@ -20,7 +20,7 @@ pub enum HbBuffer {} pub enum HbFont {} #[link(name="harfbuzz")] -extern { +extern "C" { pub fn hb_ft_font_create(face: *mut FtFace, destroy: *const libc::c_void) -> *mut HbFont; pub fn hb_ft_font_changed(font: *mut HbFont); pub fn hb_font_destroy(font: *mut HbFont); diff --git a/crates/core/src/font/mod.rs b/crates/core/src/font/mod.rs index 45eca13d..2697ea1e 100644 --- a/crates/core/src/font/mod.rs +++ b/crates/core/src/font/mod.rs @@ -255,7 +255,7 @@ extern { #[cfg(all(target_os = "linux", not(target_arch = "arm")))] #[link(name="mupdf")] -extern { +extern "C" { pub static _binary_resources_fonts_droid_DroidSansFallback_ttf_start: [libc::c_uchar; 3556308]; pub static _binary_resources_fonts_noto_NotoEmoji_Regular_ttf_start: [libc::c_uchar; 418804]; pub static _binary_resources_fonts_noto_NotoMusic_Regular_otf_start: [libc::c_uchar; 60812]; From b602d758af7f89dd847727db849fa6d6960eaa1e Mon Sep 17 00:00:00 2001 From: Ayke van Laethem Date: Wed, 2 Jul 2025 15:31:19 +0200 Subject: [PATCH 2/9] Indent blockquotes Ideally we'd also put a line on the left to more clearly mark it as a blockquote, but this will at least make it easier to distinguish blockquotes from other text. --- css/epub.css | 4 ++++ css/html.css | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/css/epub.css b/css/epub.css index 1d9fca06..73dd2938 100644 --- a/css/epub.css +++ b/css/epub.css @@ -29,6 +29,10 @@ h4, p, blockquote, dl { margin: 1.12em 0; } +blockquote { + margin-left: 1em; +} + h5 { font-size: 0.83em; margin: 1.5em 0; diff --git a/css/html.css b/css/html.css index d53ee478..28ccb561 100644 --- a/css/html.css +++ b/css/html.css @@ -33,6 +33,10 @@ h4, p, blockquote, dl { margin: 1.12em 0; } +blockquote { + margin-left: 1em; +} + h5 { font-size: 0.83em; margin: 1.5em 0; From eb2f43a19dd74bcc4a0696254b6f7baf322c8c5b Mon Sep 17 00:00:00 2001 From: Ayke van Laethem Date: Fri, 27 Jun 2025 11:02:36 +0200 Subject: [PATCH 3/9] Move the home BottomBar to common code as PagerBar This bottom bar is also useful for the articles view. --- crates/core/src/view/home/mod.rs | 14 ++++++-------- crates/core/src/view/{home => }/library_label.rs | 0 crates/core/src/view/mod.rs | 2 ++ .../src/view/{home/bottom_bar.rs => pager_bar.rs} | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) rename crates/core/src/view/{home => }/library_label.rs (100%) rename crates/core/src/view/{home/bottom_bar.rs => pager_bar.rs} (97%) diff --git a/crates/core/src/view/home/mod.rs b/crates/core/src/view/home/mod.rs index 952a75ae..c08ee970 100644 --- a/crates/core/src/view/home/mod.rs +++ b/crates/core/src/view/home/mod.rs @@ -1,11 +1,9 @@ -mod library_label; mod address_bar; mod navigation_bar; mod directories_bar; mod directory; mod shelf; mod book; -mod bottom_bar; use std::fs; use std::mem; @@ -38,7 +36,7 @@ use super::top_bar::TopBar; use self::address_bar::AddressBar; use self::navigation_bar::NavigationBar; use self::shelf::Shelf; -use self::bottom_bar::BottomBar; +use crate::view::pager_bar::PagerBar; use crate::gesture::GestureEvent; use crate::geom::{Rectangle, Dir, DiagDir, CycleDir, halves}; use crate::input::{DeviceEvent, ButtonCode, ButtonStatus}; @@ -177,7 +175,7 @@ impl Home { BLACK); children.push(Box::new(separator) as Box); - let bottom_bar = BottomBar::new(rect![rect.min.x, rect.max.y - small_height + big_thickness, + let bottom_bar = PagerBar::new(rect![rect.min.x, rect.max.y - small_height + big_thickness, rect.max.x, rect.max.y], current_page, pages_count, @@ -411,8 +409,8 @@ impl Home { } fn update_bottom_bar(&mut self, rq: &mut RenderQueue, context: &Context) { - if let Some(index) = rlocate::(self) { - let bottom_bar = self.children[index].as_mut().downcast_mut::().unwrap(); + if let Some(index) = rlocate::(self) { + let bottom_bar = self.children[index].as_mut().downcast_mut::().unwrap(); let filter = self.query.is_some() || self.current_directory != context.library.home; let selected_library = context.settings.selected_library; @@ -463,7 +461,7 @@ impl Home { return; } - let index = rlocate::(self).unwrap() - 1; + let index = rlocate::(self).unwrap() - 1; let mut kb_rect = rect![self.rect.min.x, self.rect.max.y - (small_height + 3 * big_height) as i32 + big_thickness, self.rect.max.x, @@ -1822,7 +1820,7 @@ impl View for Home { } // Bottom bar. - let bottom_bar_index = rlocate::(self).unwrap(); + let bottom_bar_index = rlocate::(self).unwrap(); index = bottom_bar_index; let separator_rect = rect![rect.min.x, rect.max.y - small_height - small_thickness, diff --git a/crates/core/src/view/home/library_label.rs b/crates/core/src/view/library_label.rs similarity index 100% rename from crates/core/src/view/home/library_label.rs rename to crates/core/src/view/library_label.rs diff --git a/crates/core/src/view/mod.rs b/crates/core/src/view/mod.rs index 4e8a350d..42b58265 100644 --- a/crates/core/src/view/mod.rs +++ b/crates/core/src/view/mod.rs @@ -23,6 +23,8 @@ pub mod named_input; pub mod labeled_icon; pub mod top_bar; pub mod search_bar; +pub mod pager_bar; +mod library_label; pub mod dialog; pub mod notification; pub mod intermission; diff --git a/crates/core/src/view/home/bottom_bar.rs b/crates/core/src/view/pager_bar.rs similarity index 97% rename from crates/core/src/view/home/bottom_bar.rs rename to crates/core/src/view/pager_bar.rs index bb259691..ebba56a3 100644 --- a/crates/core/src/view/home/bottom_bar.rs +++ b/crates/core/src/view/pager_bar.rs @@ -10,7 +10,7 @@ use crate::context::Context; use crate::font::Fonts; #[derive(Debug)] -pub struct BottomBar { +pub struct PagerBar { id: Id, rect: Rectangle, children: Vec>, @@ -18,8 +18,8 @@ pub struct BottomBar { is_next_disabled: bool, } -impl BottomBar { - pub fn new(rect: Rectangle, current_page: usize, pages_count: usize, name: &str, count: usize, filter: bool) -> BottomBar { +impl PagerBar { + pub fn new(rect: Rectangle, current_page: usize, pages_count: usize, name: &str, count: usize, filter: bool) -> PagerBar { let id = ID_FEEDER.next(); let mut children = Vec::new(); let side = rect.height() as i32; @@ -65,7 +65,7 @@ impl BottomBar { children.push(Box::new(next_icon) as Box); } - BottomBar { + PagerBar { id, rect, children, @@ -123,7 +123,7 @@ impl BottomBar { } } -impl View for BottomBar { +impl View for PagerBar { fn handle_event(&mut self, _evt: &Event, _hub: &Hub, _bus: &mut Bus, _rq: &mut RenderQueue, _context: &mut Context) -> bool { false } From 7930fee8f112019678470a1e59a20a14daa8aab0 Mon Sep 17 00:00:00 2001 From: Ayke van Laethem Date: Thu, 3 Jul 2025 08:54:44 +0200 Subject: [PATCH 4/9] WIP update defaults when changing a given setting --- crates/core/src/view/reader/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/core/src/view/reader/mod.rs b/crates/core/src/view/reader/mod.rs index e677440f..0f946013 100644 --- a/crates/core/src/view/reader/mod.rs +++ b/crates/core/src/view/reader/mod.rs @@ -2314,6 +2314,10 @@ impl Reader { if let Some(ref mut r) = self.info.reader { if self.reflowable { r.margin_width = Some(width); + + // Use last configured margin width (in any book) as the default + // margin width from now on. + context.settings.reader.margin_width = width; } else { if width == 0 { r.screen_margin_width = None; From df10c0f7f7053352d6a979a5ac1c4ef30cb9c00b Mon Sep 17 00:00:00 2001 From: Ayke van Laethem Date: Thu, 26 Jun 2025 10:49:58 +0200 Subject: [PATCH 5/9] Article app (with Wallabag support) First version of built-in Wallabag support. There are a number of things that aren't quite there yet: * All articles are always downloaded. Ideally, only new unread articles would be downloaded. * The epubs downloaded from Wallabag aren't great, they have some noise that I'd rather avoid. We might want to create our own by converting them from HTML to custom epubs. --- .gitignore | 1 + Cargo.lock | 135 +++- crates/core/Cargo.toml | 3 + crates/core/src/articles/dummy.rs | 29 + crates/core/src/articles/mod.rs | 178 +++++ crates/core/src/articles/wallabag.rs | 685 +++++++++++++++++++ crates/core/src/lib.rs | 1 + crates/core/src/settings/mod.rs | 52 ++ crates/core/src/view/articles/accountview.rs | 389 +++++++++++ crates/core/src/view/articles/mod.rs | 624 +++++++++++++++++ crates/core/src/view/articles/shelf.rs | 267 ++++++++ crates/core/src/view/button.rs | 8 + crates/core/src/view/common.rs | 2 + crates/core/src/view/home/mod.rs | 13 +- crates/core/src/view/input_field.rs | 5 + crates/core/src/view/library_label.rs | 53 +- crates/core/src/view/mod.rs | 31 +- crates/core/src/view/pager_bar.rs | 10 +- crates/emulator/src/main.rs | 4 + crates/plato/src/app.rs | 4 + 20 files changed, 2451 insertions(+), 43 deletions(-) create mode 100644 crates/core/src/articles/dummy.rs create mode 100644 crates/core/src/articles/mod.rs create mode 100644 crates/core/src/articles/wallabag.rs create mode 100644 crates/core/src/view/articles/accountview.rs create mode 100644 crates/core/src/view/articles/mod.rs create mode 100644 crates/core/src/view/articles/shelf.rs diff --git a/.gitignore b/.gitignore index 250af8f8..b06df2f0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /libs /bin /Settings.toml +/.articles /.metadata.json /.reading-states /.fat32-epoch diff --git a/Cargo.lock b/Cargo.lock index 19c65089..8fd23f6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -236,6 +236,32 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -339,6 +365,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -541,9 +576,9 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", @@ -613,7 +648,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] @@ -901,6 +936,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + [[package]] name = "lockfree-object-pool" version = "0.1.6" @@ -909,9 +950,9 @@ checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" [[package]] name = "log" -version = "0.4.22" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "lzma-rs" @@ -1059,6 +1100,7 @@ dependencies = [ "flate2", "fxhash", "globset", + "http", "indexmap", "kl-hyphenate", "lazy_static", @@ -1078,6 +1120,8 @@ dependencies = [ "titlecase", "toml", "unicode-normalization", + "ureq", + "url", "walkdir", "xi-unicode", "zip", @@ -1288,7 +1332,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 0.26.11", "windows-registry", ] @@ -1321,10 +1365,11 @@ checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" [[package]] name = "rustls" -version = "0.23.18" +version = "0.23.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" +checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -1344,18 +1389,19 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ "web-time", + "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.102.8" +version = "0.103.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" dependencies = [ "ring", "rustls-pki-types", @@ -1602,10 +1648,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", "time-core", + "time-macros", ] [[package]] @@ -1614,6 +1662,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -1772,6 +1830,38 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f0fde9bc91026e381155f8c67cb354bcd35260b2f4a29bcc84639f762760c39" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "ureq-proto", + "url", + "utf-8", + "webpki-roots 0.26.11", +] + +[[package]] +name = "ureq-proto" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59db78ad1923f2b1be62b6da81fe80b173605ca0d57f85da2e005382adf693f7" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.4" @@ -1783,6 +1873,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf16_iter" version = "1.0.5" @@ -1921,9 +2017,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.7" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.1", +] + +[[package]] +name = "webpki-roots" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502" dependencies = [ "rustls-pki-types", ] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 25a48e61..5ea31357 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -39,3 +39,6 @@ rand_core = "0.6.4" rand_xoshiro = "0.6.0" percent-encoding = "2.3.1" chrono = { version = "0.4.38", features = ["serde", "clock"], default-features = false } +ureq = { version = "3.0.12", features = ["cookies"] } +http = "1.3.1" +url = "2.5.4" diff --git a/crates/core/src/articles/dummy.rs b/crates/core/src/articles/dummy.rs new file mode 100644 index 00000000..e5d5fc54 --- /dev/null +++ b/crates/core/src/articles/dummy.rs @@ -0,0 +1,29 @@ +use std::sync::{Arc, Mutex}; + +use crate::{ + articles::{ArticleIndex, Service}, + view::Hub, +}; + +pub struct Dummy { + index: Arc>, +} + +impl Dummy { + pub fn new() -> Dummy { + Dummy { + index: Arc::new(Mutex::new(ArticleIndex::default())), + } + } +} + +impl Service for Dummy { + fn index(&self) -> Arc> { + self.index.clone() + } + fn save_index(&self) {} + fn update(&mut self, _hub: &Hub) -> bool { + // nothing to do, always finishes immediately + true + } +} diff --git a/crates/core/src/articles/mod.rs b/crates/core/src/articles/mod.rs new file mode 100644 index 00000000..9c2a0a4a --- /dev/null +++ b/crates/core/src/articles/mod.rs @@ -0,0 +1,178 @@ +mod dummy; +mod wallabag; + +use chrono::FixedOffset; +use fxhash::FxHashSet; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File}, + io::{self, Error, Write}, + os::unix::fs::MetadataExt, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use crate::settings::ArticleAuth; +use crate::{ + articles::wallabag::Wallabag, + metadata::{FileInfo, Info}, + settings::{self, ArticleList}, + view::Hub, +}; + +pub const ARTICLES_DIR: &str = ".articles"; + +#[derive(Serialize, Deserialize)] +pub struct ArticleIndex { + pub articles: BTreeMap, +} + +impl Default for ArticleIndex { + fn default() -> Self { + ArticleIndex { + articles: BTreeMap::new(), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum Changes { + Deleted, + Starred, + Archived, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Article { + pub id: String, + #[serde(skip_serializing_if = "FxHashSet::is_empty")] + #[serde(default)] + pub changed: FxHashSet, + pub loaded: bool, + pub title: String, + pub domain: String, + pub authors: Vec, + pub format: String, + pub language: String, + pub reading_time: u32, + pub added: chrono::DateTime, + pub starred: bool, + pub archived: bool, +} + +impl Article { + fn path(&self) -> PathBuf { + std::path::absolute(PathBuf::from(format!( + "{}/article-{}.{}", + ARTICLES_DIR, self.id, self.format + ))) + .unwrap() + } + + pub fn file(&self) -> FileInfo { + let path = self.path(); + let size = match fs::metadata(&path) { + Ok(metadata) => metadata.size(), + Err(_err) => 0, + }; + FileInfo { + path: path, + kind: self.format.to_owned(), + size: size, + } + } + + pub fn info(&self) -> Info { + Info { + title: self.title.to_owned(), + subtitle: self.domain.to_owned(), + author: self.authors.join(", "), + year: "".to_string(), + language: self.language.to_owned(), + publisher: "".to_string(), + series: "".to_string(), + edition: "".to_string(), + volume: "".to_string(), + number: "".to_string(), + identifier: "".to_string(), + categories: BTreeSet::new(), + file: self.file(), + reader: None, + reader_info: None, + toc: None, + added: self.added.naive_local(), + } + } +} + +pub trait Service { + fn index(&self) -> Arc>; + + fn save_index(&self); + + // Update the list of articles. + // Returns true when the update was started, false when an update is already + // in progress. + fn update(&mut self, hub: &Hub) -> bool; +} + +fn read_index() -> Result { + let file = File::open(ARTICLES_DIR.to_owned() + "/index.json")?; + let index: ArticleIndex = serde_json::from_reader(file)?; + + Ok(index) +} + +pub fn load(auth: settings::ArticleAuth) -> Box { + let index = read_index().unwrap_or_default(); + match auth.api.as_str() { + "wallabag" => Box::new(Wallabag::load(auth, index)), + _ => Box::new(dummy::Dummy::new()), + } +} + +pub fn authenticate( + api: String, + server: String, + username: String, + password: String, +) -> Result { + match api.as_str() { + "wallabag" => wallabag::authenticate(server, "Plato".to_string(), username, password), + _ => Err(format!("unknown API: {api}")), + } +} + +pub fn filter(service: &Box, list: crate::settings::ArticleList) -> Vec
{ + // TODO: perhaps only return a list of articles on the current page, to + // reduce the amount of cloning? + let mut articles: Vec
= service.index() + .lock() + .unwrap() + .articles + .values() + .filter(|article| match list { + ArticleList::Unread => !article.archived, + ArticleList::Starred => article.starred, + ArticleList::Archive => article.archived, + } && !article.changed.contains(&Changes::Deleted)) + .cloned() + .collect(); + + // Sort newest first. + articles.sort_by(|a, b| b.added.cmp(&a.added)); + + articles +} + +fn save_index(index: &ArticleIndex) -> io::Result<()> { + let buf = serde_json::to_string(index).unwrap(); + let mut file = File::create(ARTICLES_DIR.to_owned() + "/index.json.tmp")?; + file.write_all(buf.as_bytes())?; + fs::rename( + ARTICLES_DIR.to_owned() + "/index.json.tmp", + ARTICLES_DIR.to_owned() + "/index.json", + ) +} diff --git a/crates/core/src/articles/wallabag.rs b/crates/core/src/articles/wallabag.rs new file mode 100644 index 00000000..71cf78f5 --- /dev/null +++ b/crates/core/src/articles/wallabag.rs @@ -0,0 +1,685 @@ +use fxhash::FxHashSet; +use http::{HeaderValue, StatusCode}; +use regex::{Captures, Regex}; +use serde::Deserialize; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File}, + io::{Error, Write}, + ops::Deref, + sync::{ + atomic::{ + AtomicBool, + Ordering::{Acquire, Release}, + }, + Arc, Mutex, + }, + thread, + time::{SystemTime, UNIX_EPOCH}, +}; +use ureq::Agent; +use url::Url; + +use crate::{ + articles::{save_index, Article, ArticleIndex, Changes, Service, ARTICLES_DIR}, + settings::ArticleAuth, + view::{ArticleUpdateProgress, Event, Hub}, +}; + +struct ClientCredentials { + client_id: String, + client_secret: String, +} + +pub struct Wallabag { + auth: ArticleAuth, + index: Arc>, + updating: Arc, +} + +impl Wallabag { + pub fn load(auth: ArticleAuth, index: ArticleIndex) -> Wallabag { + Wallabag { + auth: auth, + index: Arc::new(Mutex::new(index)), + updating: Arc::new(AtomicBool::new(false)), + } + } +} + +impl Service for Wallabag { + fn index(&self) -> std::sync::Arc> { + self.index.clone() + } + + fn save_index(&self) { + let index = self.index.lock().unwrap(); + if let Err(err) = save_index(index.deref()) { + eprintln!("failed to save index: {}", err); + }; + } + + fn update(&mut self, hub: &Hub) -> bool { + if self.updating.swap(true, Acquire) { + return false; + } + hub.send(Event::ArticleUpdateProgress( + ArticleUpdateProgress::ListStart, + )) + .ok(); + let hub = hub.clone(); + let auth = self.auth.clone(); + let updating = self.updating.clone(); + let index = self.index.clone(); + thread::spawn(move || { + if let Err(err) = update(&hub, auth, index) { + eprintln!("while fetching article list: {err}"); + hub.send(Event::Notify(err.to_string())).ok(); + }; + hub.send(Event::ArticleUpdateProgress(ArticleUpdateProgress::Finish)) + .ok(); + updating.store(false, Release); + }); + return true; + } +} + +// Create new API client by doing HTTP requests and reading the response HTML, +// similar to how the Android app does it. If an API client with the given name +// already exists, that API client is returned instead of creating a new one. +// +// This is a terrible idea, but there doesn't seem to be an alternative that +// doesn't involve asking the user to manually create an API client and copying +// over the client ID and secret. Since the Android app does something similar, +// I hope the Wallabag authors won't break this. +fn create_api_client( + server: String, + client_name: String, + username: String, + password: String, +) -> Result { + let url = "https://".to_owned() + &server + "/"; + + // Create a HTTP client that works similar to a browser. + // Disable redirection though since that saves a request during login. + let agent: Agent = Agent::config_builder().max_redirects(0).build().into(); + + // Fetch the login page (which importantly includes the CSRF token). + let login_url = url.clone() + "login"; + let mut response = match agent.get(&login_url).call() { + Ok(response) => response, + Err(ureq::Error::Io(_)) => { + // Any I/O error, but most likely there's a networking issuing. + return Err(Error::other("failed to connect to the server")); + } + Err(ureq::Error::StatusCode(404)) => { + // Special case: provide better error message for invalid + // (mistyped?) addresses. + return Err(Error::other(format!( + "login page does not exist: {login_url}", + ))); + } + Err(err) => { + return Err(Error::other(format!("could not fetch login page: {err}"))); + } + }; + let response_body = match response.body_mut().read_to_string() { + Ok(text) => text, + Err(err) => { + return Err(Error::other(format!( + "could not fetch response body of login page: {err}", + ))) + } + }; + + // Extract the CSRF token of the login page. + let csrf_token = match Regex::new("name=\"_csrf_token\" value=\"(.*+)\"") + .unwrap() + .captures(&response_body) + { + Some(caps) => caps[1].to_owned(), + None => return Err(Error::other("could not find CSRF token in login page")), + }; + + // Log in to Wallabag. + let login_result_url = url.clone() + "login_check"; + let response = match agent.post(&login_result_url).send_form([ + ("_username", username), + ("_password", password), + ("_csrf_token", csrf_token), + ]) { + Ok(response) => response, + Err(err) => return Err(Error::other(format!("failed to log in: {err}"))), + }; + + // Check that we got a 302 redirect to the homepage (which indicates a + // successful login). + if response.status() != StatusCode::FOUND { + return Err(Error::other(format!( + "could not log in, expected 302 redirect but got {}", + response.status() + ))); + } + let empty_header_value = &HeaderValue::from_str("").unwrap(); + let redirect_url = response + .headers() + .get("Location") + .unwrap_or(empty_header_value) + .to_str() + .unwrap(); + if redirect_url != url { + // Try to determine why the login failed. + // Not really handling any errors here, since we'll fall back to the + // "could not log in" error message below. + if redirect_url == login_url { + if let Ok(mut response) = agent.get(redirect_url).call() { + if let Ok(body) = response.body_mut().read_to_string() { + if let Some(caps) = Regex::new("