Items are actually stored and retrieved from the DB 🎉
This commit is contained in:
parent
2534cd2730
commit
03084ac15f
10 changed files with 210 additions and 55 deletions
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
iced = {git = "https://github.com/iced-rs/iced", branch = "master", features = ["advanced","image","markdown","tokio"]}
|
iced = {git = "https://github.com/iced-rs/iced", branch = "master", features = ["advanced","image","markdown","svg",]}
|
||||||
reqwest = { version = "0.12", features= ["blocking"]}
|
reqwest = { version = "0.12", features= ["blocking"]}
|
||||||
rss = "2.0"
|
rss = "2.0"
|
||||||
web-sys = "0.3.70"
|
web-sys = "0.3.70"
|
||||||
|
|
44
docs/planned_features.md
Normal file
44
docs/planned_features.md
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
|
||||||
|
# Future Plans
|
||||||
|
|
||||||
|
I have ambitious plans for the future, there are many features
|
||||||
|
|
||||||
|
## Building a great RSS Desktop Client
|
||||||
|
|
||||||
|
At minimum I need to build a functionally complete RSS reader which will include:
|
||||||
|
- Feed management
|
||||||
|
- Beautiful UI
|
||||||
|
- Multimedia support
|
||||||
|
- Export stories to Markdown
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Loads of caching
|
||||||
|
- Respect 304 (not modified)
|
||||||
|
|
||||||
|
## Pro-Decentralization
|
||||||
|
|
||||||
|
Many import/export options (OPML + full database)
|
||||||
|
|
||||||
|
### Discovery Service
|
||||||
|
|
||||||
|
RSSCAR crawls links from the content to discover new feeds. This adds a crucial discovery feature that can help people actually browse the independent web without worrying about censorship.
|
||||||
|
|
||||||
|
|
||||||
|
### Darknet Support
|
||||||
|
Censorship resistance matters! Tor & I2P sites should be first-class citizens and handled accordingly.
|
||||||
|
|
||||||
|
### Podcasting 2.0
|
||||||
|
Where possible the podcast namespace should be supported (transcriptions, chapters, etc)
|
||||||
|
|
||||||
|
|
||||||
|
## Information Management
|
||||||
|
|
||||||
|
### Full-text search
|
||||||
|
Basically mandatory
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
Manage feeds and put them in logical groups
|
||||||
|
|
||||||
|
|
||||||
|
### Markdown Export
|
||||||
|
Export stories for Logseq/Obsidian
|
6
docs/readme.md
Normal file
6
docs/readme.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
# Documentation
|
||||||
|
|
||||||
|
This is where guides and details about RSSCAR will be.
|
||||||
|
|
||||||
|
|
||||||
|
-[Planned Features](planned_features.md)
|
28
readme.md
28
readme.md
|
@ -1,21 +1,21 @@
|
||||||
# Really Sweet Stuff
|
RSSCAR
|
||||||
|
======
|
||||||
|
### *Really Simple Syndication Crawler-Aided Reader*
|
||||||
|
|
||||||
*Really Sweet Stuff* is my temporary codename for the ambitious new project. Learning [Rust](https://www.rust-lang.org/) & [iced](https://iced.rs) by building a great RSS desktop client.
|
RSSCAR is a new kind of RSS reader. [RSS is fantastic](https://gabe.rocks/tech/rss-love) but a huge problem is that it lacks *discovery*. With relatively simple client-side crawling, it is trivial to find additional publications related to your own interests.
|
||||||
|
|
||||||
It's got a long way to go, but I'm optimistic.
|
In short: **RSSCAR is to be the RSS reader built for the modern independent web!**
|
||||||
For now it uses hardcoded URLs while I work on the UI.
|
|
||||||
I don't consider this even usable yet, so please consider looking elsewhere until I have more features built.
|
|
||||||
|
|
||||||
## Building a great RSS Desktop Client
|
This project began as my own way to learn [Rust](https://www.rust-lang.org/) & [iced](https://iced.rs) by building a great RSS desktop client.
|
||||||
|
|
||||||
At minimum I need to build a functionally complete RSS reader which will include:
|
This Project is FAR from complete ⏳
|
||||||
- Feed management
|
---
|
||||||
- Beautiful UI
|
*Please be patient!*
|
||||||
- Multimedia support
|
It's got a long way to go, but progress is ongoing.
|
||||||
|
|
||||||
But! I have much more ambitious goals for this project once I start building momentum.
|
|
||||||
|
|
||||||
The best way to stay up to date on this would be to follow my personal RSS feed at [gabe.rocks/rss](https://gabe.rocks/rss)
|
Currently The best way to stay up to date on this would be to follow my [tech only feed](https://gabe.rocks/tech/index.xml)
|
||||||
|
|
||||||
|
### Posts:
|
||||||
|
- [Trying out iced](https://gabe.rocks/tech/trying-out-iced/)
|
||||||
|
|
||||||
## Posts:
|
|
||||||
- [Trying out iced](https://gabe.rocks/tech/trying-out-iced/)
|
|
64
src/db.rs
64
src/db.rs
|
@ -22,6 +22,9 @@ fn get_db_path() -> PathBuf {
|
||||||
get_data_directory().join(DB_LOCATION)
|
get_data_directory().join(DB_LOCATION)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_db() -> Connection{
|
||||||
|
Connection::open(get_db_path()).unwrap()
|
||||||
|
}
|
||||||
const FEEDS_TABLE_CREATE: &str = "CREATE TABLE IF NOT EXISTS 'feeds' (
|
const FEEDS_TABLE_CREATE: &str = "CREATE TABLE IF NOT EXISTS 'feeds' (
|
||||||
'feedID' INTEGER NOT NULL,
|
'feedID' INTEGER NOT NULL,
|
||||||
'title' TEXT NOT NULL,
|
'title' TEXT NOT NULL,
|
||||||
|
@ -50,10 +53,28 @@ const ITEMS_TABLE_CREATE: &str = "CREATE TABLE IF NOT EXISTS 'items' (
|
||||||
);";
|
);";
|
||||||
const ITEMS_INDEX_CREATE: &str = "CREATE INDEX IF NOT EXISTS 'items_idx' on 'items'('itemID' ASC);";
|
const ITEMS_INDEX_CREATE: &str = "CREATE INDEX IF NOT EXISTS 'items_idx' on 'items'('itemID' ASC);";
|
||||||
|
|
||||||
|
const DB_RESET: &str = "
|
||||||
|
drop table feeds;
|
||||||
|
drop table items;
|
||||||
|
";
|
||||||
|
|
||||||
|
pub fn reset(){
|
||||||
|
println!("⚠️WARNING⚠️\nResetting Database");
|
||||||
|
let conn = get_db();
|
||||||
|
match conn.execute_batch(DB_RESET) {
|
||||||
|
Ok(_) => {println!("Database successfully wiped.")}
|
||||||
|
Err(e) => {panic!("Error erasing database.\nError: {0}",e)}
|
||||||
|
}
|
||||||
|
conn.close().unwrap();
|
||||||
|
initialize();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn initialize() {
|
pub fn initialize() {
|
||||||
let path = get_db_path();
|
let path = get_db_path();
|
||||||
println!("Database at {} initialized", path.as_os_str().display());
|
println!("Database at {} initialized", path.as_os_str().display());
|
||||||
let conn = Connection::open(path).unwrap();
|
let conn = get_db();
|
||||||
conn.execute(FEEDS_TABLE_CREATE, []).unwrap();
|
conn.execute(FEEDS_TABLE_CREATE, []).unwrap();
|
||||||
conn.execute(FEEDS_INDEX_CREATE, []).unwrap();
|
conn.execute(FEEDS_INDEX_CREATE, []).unwrap();
|
||||||
conn.execute(ITEMS_TABLE_CREATE, []).unwrap();
|
conn.execute(ITEMS_TABLE_CREATE, []).unwrap();
|
||||||
|
@ -62,6 +83,7 @@ pub fn initialize() {
|
||||||
println!("Database Initialized.")
|
println!("Database Initialized.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn add_feed(url: &str) {
|
pub fn add_feed(url: &str) {
|
||||||
let conn = Connection::open(get_db_path()).unwrap();
|
let conn = Connection::open(get_db_path()).unwrap();
|
||||||
let feed = load_rss(url).unwrap();
|
let feed = load_rss(url).unwrap();
|
||||||
|
@ -73,6 +95,7 @@ pub fn add_feed(url: &str) {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
conn.close().unwrap();
|
conn.close().unwrap();
|
||||||
|
//need to get the feed_id from the DB and then make sure items are mapped to feed
|
||||||
store_items(new_feed);
|
store_items(new_feed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,8 +136,38 @@ pub fn return_item() -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct FeedItem {
|
||||||
|
pub item_id: usize,
|
||||||
|
pub title: String,
|
||||||
|
pub url: String,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub content: Option<String>
|
||||||
|
//date missing! needed for ordering!!!
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_feed_items(id: usize) -> Vec<FeedItem>{
|
||||||
|
let conn = get_db();
|
||||||
|
let mut stmt = conn.prepare("select itemID,title,url,icon,description,content from items").unwrap();
|
||||||
|
let items:Result<Vec<FeedItem>> = stmt.query_map([], |row| {
|
||||||
|
Ok(FeedItem{
|
||||||
|
item_id: row.get(0).unwrap(),
|
||||||
|
title: row.get(1).unwrap(),
|
||||||
|
url: row.get(2).unwrap(),
|
||||||
|
icon: row.get(3).unwrap(),
|
||||||
|
description: row.get(4).unwrap(),
|
||||||
|
content: row.get(5).unwrap()
|
||||||
|
})
|
||||||
|
}).unwrap().collect();
|
||||||
|
match items {
|
||||||
|
Ok(i) => {i},
|
||||||
|
Err(_) => {panic!("No items for this feed\nFeedID:{}",id)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Feed {
|
pub struct Feed {
|
||||||
pub feed_id: u8,
|
pub feed_id: usize,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub icon: Option<String>,
|
pub icon: Option<String>,
|
||||||
|
@ -131,8 +184,8 @@ fn time_string_conversion(str: String) -> Option<DateTime<Utc>>{
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_feeds() -> Vec<Feed> {
|
pub fn get_feeds() -> Vec<Feed> {
|
||||||
let conn = Connection::open(get_db_path()).unwrap();
|
let conn = get_db();
|
||||||
let mut stmt = conn.prepare("select feedID,title,description,icon,url,subscribed,last_updated from feeds").unwrap();
|
let mut stmt = conn.prepare("select feedID,title,description,icon,url,subscribed,last_updated from feeds order by last_updated desc").unwrap();
|
||||||
let rows: Result<Vec<Feed>> = stmt
|
let rows: Result<Vec<Feed>> = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
Ok(Feed {
|
Ok(Feed {
|
||||||
|
@ -147,10 +200,9 @@ pub fn get_feeds() -> Vec<Feed> {
|
||||||
}).unwrap().collect();
|
}).unwrap().collect();
|
||||||
match rows {
|
match rows {
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
println!("Feed found\n{}",r[0].title);
|
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
Err(e) => {panic!()}
|
Err(e) => {panic!("No idea what causes this")}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
struct ReturnedFeedURLs {
|
struct ReturnedFeedURLs {
|
||||||
|
|
|
@ -7,7 +7,7 @@ pub fn get_data_directory() -> std::path::PathBuf {
|
||||||
match fs::create_dir(dirs.data_dir()){
|
match fs::create_dir(dirs.data_dir()){
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
|
||||||
Err(e) => {println!("Unable to create data directory")}
|
Err(_) => {println!("Unable to create data directory")}
|
||||||
};
|
};
|
||||||
dirs.data_dir().to_owned()
|
dirs.data_dir().to_owned()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
mod net;
|
mod net;
|
||||||
use db::initialize;
|
|
||||||
use net::load_rss;
|
|
||||||
mod ui;
|
mod ui;
|
||||||
mod html;
|
mod html;
|
||||||
mod db;
|
mod db;
|
||||||
mod files;
|
mod files;
|
||||||
|
mod widgets;
|
||||||
|
|
||||||
pub fn main() -> iced::Result {
|
pub fn main() -> iced::Result {
|
||||||
db::initialize();
|
db::initialize();
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
use core::panic;
|
use core::panic;
|
||||||
use std::io::{Bytes, Read};
|
|
||||||
use iced::widget::image::Handle;
|
use iced::widget::image::Handle;
|
||||||
use rss::{Channel};
|
use rss::{Channel};
|
||||||
use reqwest::{self, blocking::Client, header::USER_AGENT};
|
use reqwest::{self, blocking::Client, header::USER_AGENT};
|
||||||
|
@ -54,7 +53,7 @@ pub fn download_image(url: &str) -> Option<iced::widget::image::Handle>{
|
||||||
let img: Handle = Handle::from_bytes(r.bytes().unwrap());
|
let img: Handle = Handle::from_bytes(r.bytes().unwrap());
|
||||||
Some(img)
|
Some(img)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(_) => {
|
||||||
println!("Failed to download image.");
|
println!("Failed to download image.");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
78
src/ui.rs
78
src/ui.rs
|
@ -1,5 +1,8 @@
|
||||||
|
use crate::db::add_feed;
|
||||||
|
|
||||||
use super::db;
|
use super::db;
|
||||||
use db::Feed;
|
use super::widgets;
|
||||||
|
use iced::widget::scrollable;
|
||||||
use iced::{
|
use iced::{
|
||||||
widget::{button, column, container, text},
|
widget::{button, column, container, text},
|
||||||
Element,
|
Element,
|
||||||
|
@ -13,27 +16,47 @@ pub fn user_interface() -> iced::Result {
|
||||||
enum Page {
|
enum Page {
|
||||||
home,
|
home,
|
||||||
feed_view,
|
feed_view,
|
||||||
|
all_items,
|
||||||
item_view,
|
item_view,
|
||||||
testing,
|
testing,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct State {
|
struct State {
|
||||||
page: Page,
|
page: Page,
|
||||||
|
current_feed: usize
|
||||||
}
|
}
|
||||||
impl Default for State {
|
impl Default for State {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
State { page: Page::home }
|
State {
|
||||||
|
page: Page::home,
|
||||||
|
current_feed: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Message {
|
pub enum Message {
|
||||||
changePage(Page),
|
ChangePage(Page),
|
||||||
|
LoadFeed(usize),
|
||||||
|
AddFeed(String),
|
||||||
|
ResetDB
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(state: &mut State, mes: Message) {
|
fn update(state: &mut State, mes: Message) {
|
||||||
match mes {
|
match mes {
|
||||||
Message::changePage(p) => state.page=p
|
Message::ChangePage(p) => state.page=p,
|
||||||
|
Message::LoadFeed(feed_id) => {
|
||||||
|
state.current_feed = feed_id;
|
||||||
|
state.page=Page::feed_view;
|
||||||
|
|
||||||
|
},
|
||||||
|
Message::AddFeed(f) => {
|
||||||
|
db::add_feed(&f);
|
||||||
|
},
|
||||||
|
Message::ResetDB => {
|
||||||
|
db::reset();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,16 +66,17 @@ fn update(state: &mut State, mes: Message) {
|
||||||
fn view(state: &State) -> Element<'_, Message> {
|
fn view(state: &State) -> Element<'_, Message> {
|
||||||
match state.page {
|
match state.page {
|
||||||
Page::home => home(&state),
|
Page::home => home(&state),
|
||||||
Page::feed_view => feeds(&state),
|
Page::feed_view => feed_layout(&state),
|
||||||
Page::item_view => item(&state),
|
Page::all_items => item_list(&state),
|
||||||
|
Page::item_view => item_view(&state),
|
||||||
Page::testing => testing(&state),
|
Page::testing => testing(&state),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn home(state: &State) -> Element<'_, Message> {
|
fn home(state: &State) -> Element<'_, Message> {
|
||||||
container(column!(
|
container(column!(
|
||||||
list_feeds(),
|
scrollable(widgets::list_feeds()).width(iced::Fill).height(iced::Fill),
|
||||||
button("Go to test!").on_press(Message::changePage(Page::testing))
|
button("Go to test!").on_press(Message::ChangePage(Page::testing))
|
||||||
))
|
))
|
||||||
.padding(15)
|
.padding(15)
|
||||||
.height(Fill)
|
.height(Fill)
|
||||||
|
@ -60,37 +84,33 @@ fn home(state: &State) -> Element<'_, Message> {
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_feeds() -> iced::widget::Column<'static, Message> {
|
|
||||||
let feeds = db::get_feeds();
|
|
||||||
column(
|
|
||||||
feeds
|
|
||||||
.iter()
|
|
||||||
.map(|f| {
|
|
||||||
let title = f.title.clone();
|
|
||||||
let index = f.feedID;
|
|
||||||
text(title)
|
|
||||||
})
|
|
||||||
.map(Element::from),
|
|
||||||
)
|
|
||||||
.align_x(iced::Alignment::Start)
|
|
||||||
.spacing(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn feeds(state: &State) -> Element<'_, Message> {
|
fn feed_layout(state: &State) -> Element<'_, Message> {
|
||||||
container(list_feeds().padding(15))
|
container(
|
||||||
|
column!(
|
||||||
|
button(text("Go Home")).on_press(Message::ChangePage(Page::home)),
|
||||||
|
scrollable(widgets::list_items(state.current_feed)).width(iced::Fill).height(iced::Fill),
|
||||||
|
)
|
||||||
|
)
|
||||||
.height(Fill)
|
.height(Fill)
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn item(state: &State) -> Element<'_, Message> {
|
fn item_view(state: &State) -> Element<'_, Message> {
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn item_list(state: &State) -> Element<'_,Message>{
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
fn testing(state: &State) -> Element<'_, Message> {
|
fn testing(state: &State) -> Element<'_, Message> {
|
||||||
column!(
|
column!(
|
||||||
iced::widget::text("Ayy lmao"),
|
text("Dev Panel"),
|
||||||
button("go back!").on_press(Message::changePage(Page::home))
|
button("Add gabe.rocks").on_press(Message::AddFeed(String::from("https://gabe.rocks/rss"))),
|
||||||
|
button("Add LSN").on_press(Message::AddFeed(String::from("https://libresolutions.network/rss"))),
|
||||||
|
button("Wipe DB").on_press(Message::ResetDB),
|
||||||
|
button("go back!").on_press(Message::ChangePage(Page::home))
|
||||||
)
|
)
|
||||||
.spacing(5)
|
.spacing(5)
|
||||||
.into()
|
.into()
|
||||||
|
|
35
src/widgets.rs
Normal file
35
src/widgets.rs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
use super::db;
|
||||||
|
use super::ui;
|
||||||
|
use ui::Message;
|
||||||
|
use iced::{
|
||||||
|
widget::{button, column, container, text},
|
||||||
|
Element,
|
||||||
|
Length::Fill,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn list_feeds() -> iced::widget::Column<'static, Message> {
|
||||||
|
let feeds = db::get_feeds();
|
||||||
|
column(
|
||||||
|
feeds
|
||||||
|
.iter()
|
||||||
|
.map(|f| {
|
||||||
|
button(text(f.title.clone())).on_press(Message::LoadFeed(f.feed_id))
|
||||||
|
})
|
||||||
|
.map(Element::from),
|
||||||
|
)
|
||||||
|
.align_x(iced::Alignment::Start)
|
||||||
|
.spacing(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_items(feed_id: usize) -> iced::widget::Column<'static,Message> {
|
||||||
|
let items: Vec<db::FeedItem> = db::get_feed_items(feed_id);
|
||||||
|
column(
|
||||||
|
items.iter()
|
||||||
|
.map(|i| {
|
||||||
|
text(i.title.clone())
|
||||||
|
})
|
||||||
|
.map(Element::from),
|
||||||
|
)
|
||||||
|
.align_x(iced::Alignment::Start)
|
||||||
|
.spacing(5)
|
||||||
|
}
|
Loading…
Reference in a new issue