Compare commits
24 commits
ecbb4f3bb6
...
3654232dd6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3654232dd6 | |||
| f0a4e12f2b | |||
| c3d9dff83f | |||
| f2e00afbb9 | |||
| 25f00d1665 | |||
| 91229287a8 | |||
| e353977da9 | |||
| 5487a1801f | |||
| 544e380835 | |||
| 0639c5ca12 | |||
| 0bb9a81d60 | |||
| 3748606e21 | |||
| f5fc83a471 | |||
| 7bb4cf4230 | |||
| d7123fb153 | |||
| e07a98ddbb | |||
| 162ba78430 | |||
| 440e897edf | |||
| 22871f5789 | |||
| f42e558db9 | |||
| 41badb6c9f | |||
| e2153c2eed | |||
| 985f724267 | |||
| e1cb00f0b1 |
18 changed files with 5043 additions and 5 deletions
8
flake.lock
generated
8
flake.lock
generated
|
|
@ -2,16 +2,16 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1768127708,
|
"lastModified": 1769933782,
|
||||||
"narHash": "sha256-1Sm77VfZh3mU0F5OqKABNLWxOuDeHIlcFjsXeeiPazs=",
|
"narHash": "sha256-GlZemJ2dxhXMMq6TNyt588OFv4/jIt3J1QVBO9MspBE=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38",
|
"rev": "64728753f1a42c81c5688a136a6bee173665acc9",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"ref": "nixos-unstable",
|
"ref": "nixos-25.11-small",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
description = "Koucha rust flake";
|
description = "Koucha rust flake";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11-small";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs }:
|
outputs = { self, nixpkgs }:
|
||||||
|
|
@ -24,6 +24,8 @@
|
||||||
rustc
|
rustc
|
||||||
cargo
|
cargo
|
||||||
rust-analyzer
|
rust-analyzer
|
||||||
|
|
||||||
|
sqlx-cli
|
||||||
];
|
];
|
||||||
|
|
||||||
RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}";
|
RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}";
|
||||||
|
|
|
||||||
1
koucha/.gitignore
vendored
1
koucha/.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
|
*.db
|
||||||
/target
|
/target
|
||||||
|
|
|
||||||
3058
koucha/Cargo.lock
generated
3058
koucha/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,3 +4,9 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
axum = "0.8.8"
|
||||||
|
reqwest = "0.13.1"
|
||||||
|
rss = "2.0.12"
|
||||||
|
tokio = { version = "1.49.0", features = ["full"] }
|
||||||
|
sqlx = { version = "0.8.6", features = [ "runtime-tokio", "sqlite" ] }
|
||||||
|
chrono = "0.4.43"
|
||||||
|
|
|
||||||
62
koucha/migrations/20260115003047_initial_schema.sql
Normal file
62
koucha/migrations/20260115003047_initial_schema.sql
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
-- Add migration script here
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE channels (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
link TEXT UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
last_fetched TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE items (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
channel_id INTEGER NOT NULL,
|
||||||
|
guid TEXT NOT NULL,
|
||||||
|
|
||||||
|
fetched_at TEXT,
|
||||||
|
title TEXT,
|
||||||
|
description TEXT,
|
||||||
|
content TEXT,
|
||||||
|
|
||||||
|
UNIQUE(channel_id, guid),
|
||||||
|
FOREIGN KEY (channel_id) REFERENCES channels(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE feeds (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE feed_channels (
|
||||||
|
feed_id INTEGER NOT NULL,
|
||||||
|
channel_id INTEGER NOT NULL,
|
||||||
|
initial_score INTEGER,
|
||||||
|
gravity INTEGER,
|
||||||
|
boost INTEGER,
|
||||||
|
PRIMARY KEY (feed_id, channel_id),
|
||||||
|
FOREIGN KEY (feed_id) REFERENCES feeds(id),
|
||||||
|
FOREIGN KEY (channel_id) REFERENCES channels(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE feed_items (
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
feed_id INTEGER NOT NULL,
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
last_updated TEXT NOT NULL,
|
||||||
|
boosted_at TEXT,
|
||||||
|
archived BOOLEAN DEFAULT FALSE,
|
||||||
|
PRIMARY KEY (item_id, feed_id),
|
||||||
|
FOREIGN KEY (feed_id) REFERENCES feeds(id),
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_feed_items_score
|
||||||
|
ON feed_items(feed_id, archived, score DESC);
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
33
koucha/src/db.rs
Normal file
33
koucha/src/db.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
mod user;
|
||||||
|
pub use user::User;
|
||||||
|
mod feed;
|
||||||
|
pub use feed::Feed;
|
||||||
|
mod feed_channel;
|
||||||
|
pub use feed_channel::FeedChannel;
|
||||||
|
mod feed_item;
|
||||||
|
pub use feed_item::FeedItem;
|
||||||
|
mod channel;
|
||||||
|
pub use channel::Channel;
|
||||||
|
mod item;
|
||||||
|
pub use item::Item;
|
||||||
|
|
||||||
|
macro_rules! define_key {
|
||||||
|
($name:ident) => {
|
||||||
|
#[derive(PartialEq, Debug, Copy, Clone)]
|
||||||
|
pub struct $name(i64);
|
||||||
|
};
|
||||||
|
|
||||||
|
($name:ident, $($field:ident),* $(,)?) => {
|
||||||
|
#[derive(PartialEq, Debug, Copy, Clone)]
|
||||||
|
pub struct $name {
|
||||||
|
$($field: i64),*
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
define_key!(UserKey);
|
||||||
|
define_key!(FeedKey);
|
||||||
|
define_key!(FeedChannelKey, feed_id, channel_id);
|
||||||
|
define_key!(FeedItemKey, feed_id, item_id);
|
||||||
|
define_key!(ChannelKey);
|
||||||
|
define_key!(ItemKey);
|
||||||
315
koucha/src/db/channel.rs
Normal file
315
koucha/src/db/channel.rs
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
use reqwest::Url;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use crate::{
|
||||||
|
Result,
|
||||||
|
AdapterPool,
|
||||||
|
db::{
|
||||||
|
ChannelKey,
|
||||||
|
Item,
|
||||||
|
FeedChannel,
|
||||||
|
feed_channel::UnparsedFeedChannel,
|
||||||
|
item::UnparsedItem,
|
||||||
|
},
|
||||||
|
fetch::FetchedRSSChannel,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct UnparsedChannel {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub link: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub last_fetched: Option<String>,
|
||||||
|
}
|
||||||
|
impl UnparsedChannel {
|
||||||
|
pub fn parse(self) -> Result<Channel> {
|
||||||
|
Ok(Channel {
|
||||||
|
id: ChannelKey(self.id),
|
||||||
|
title: self.title,
|
||||||
|
link: Url::parse(&self.link)?,
|
||||||
|
description: self.description,
|
||||||
|
last_fetched: self.last_fetched.as_deref()
|
||||||
|
.map(DateTime::parse_from_rfc2822)
|
||||||
|
.transpose()?
|
||||||
|
.map(|dt| dt.with_timezone(&Utc)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Channel {
|
||||||
|
id: ChannelKey,
|
||||||
|
title: String,
|
||||||
|
link: Url,
|
||||||
|
description: Option<String>,
|
||||||
|
last_fetched: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Channel {
|
||||||
|
pub fn id(&self) -> ChannelKey { self.id }
|
||||||
|
pub fn title(&self) -> &str { &self.title }
|
||||||
|
pub fn link(&self) -> &Url { &self.link }
|
||||||
|
pub fn description(&self) -> Option<&str> { self.description.as_deref() }
|
||||||
|
pub fn last_fetched(&self) -> Option<DateTime<Utc>> { self.last_fetched }
|
||||||
|
|
||||||
|
pub async fn get_all(pool: &AdapterPool) -> Result<Vec<Self>> {
|
||||||
|
let channels: Result<Vec<Channel>> = sqlx::query_as!(
|
||||||
|
UnparsedChannel,
|
||||||
|
"SELECT id, title, link, description, last_fetched FROM channels"
|
||||||
|
).fetch_all(&pool.0).await?.into_iter().map(UnparsedChannel::parse).collect();
|
||||||
|
|
||||||
|
channels
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(pool: &AdapterPool, id: ChannelKey) -> Result<Self> {
|
||||||
|
let channel: Result<Self> = sqlx::query_as!(
|
||||||
|
UnparsedChannel,
|
||||||
|
"SELECT id, title, link, description, last_fetched
|
||||||
|
FROM channels
|
||||||
|
WHERE id = ?",
|
||||||
|
id.0
|
||||||
|
).fetch_one(&pool.0).await?.parse();
|
||||||
|
|
||||||
|
channel
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_or_create(
|
||||||
|
pool: &AdapterPool, link: Url
|
||||||
|
) -> Result<Self> {
|
||||||
|
let link_str = link.as_str();
|
||||||
|
|
||||||
|
let channel = sqlx::query_as!(
|
||||||
|
UnparsedChannel,
|
||||||
|
"INSERT INTO channels (title, link)
|
||||||
|
VALUES(?, ?)
|
||||||
|
ON CONFLICT(link) DO UPDATE SET link = link
|
||||||
|
RETURNING id, title, link, description, last_fetched",
|
||||||
|
link_str, link_str // We use the url as a placeholder title
|
||||||
|
).fetch_one(&pool.0).await?.parse();
|
||||||
|
|
||||||
|
channel
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO implement fetch skipping
|
||||||
|
pub fn should_skip_fetch(&self) -> bool { false }
|
||||||
|
|
||||||
|
pub async fn update_metadata(
|
||||||
|
&self, pool: &AdapterPool, fetched: FetchedRSSChannel
|
||||||
|
) -> Result<()> {
|
||||||
|
let title = fetched.title();
|
||||||
|
let description = fetched.description();
|
||||||
|
let link = fetched.link().as_str();
|
||||||
|
let fetched_at = fetched.fetched_at().to_rfc2822();
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE channels
|
||||||
|
SET title = ?, link = ?, description = ?,
|
||||||
|
last_fetched = ?
|
||||||
|
WHERE id = ?",
|
||||||
|
title, link, description, fetched_at,
|
||||||
|
self.id.0
|
||||||
|
).execute(&pool.0).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_feed_channels(
|
||||||
|
&self, pool: &AdapterPool
|
||||||
|
) -> Result<Vec<FeedChannel>> {
|
||||||
|
let feeds: Result<Vec<FeedChannel>> = sqlx::query_as!(
|
||||||
|
UnparsedFeedChannel,
|
||||||
|
"SELECT channel_id, feed_id, initial_score, gravity, boost
|
||||||
|
FROM feed_channels
|
||||||
|
WHERE channel_id = ?",
|
||||||
|
self.id.0
|
||||||
|
).fetch_all(&pool.0).await?.into_iter()
|
||||||
|
.map(UnparsedFeedChannel::parse).collect();
|
||||||
|
|
||||||
|
feeds
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_items(
|
||||||
|
&self, pool: &AdapterPool, fetched: FetchedRSSChannel
|
||||||
|
) -> Result<()> {
|
||||||
|
let fetched_at = fetched.fetched_at();
|
||||||
|
|
||||||
|
let feed_channels = self.get_feed_channels(pool).await?;
|
||||||
|
|
||||||
|
for rss_item in fetched.items() {
|
||||||
|
let new_item = Item::get_or_create(pool, self.id, rss_item.guid()).await?;
|
||||||
|
new_item.update_content(pool, rss_item, &fetched_at).await?;
|
||||||
|
for feed_channel in &feed_channels {
|
||||||
|
feed_channel.add_item(pool, &new_item).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_items(&self, pool: &AdapterPool) -> Result<Vec<Item>> {
|
||||||
|
let items: Result<Vec<Item>> = sqlx::query_as!(
|
||||||
|
UnparsedItem,
|
||||||
|
"SELECT id as `id!`, channel_id, fetched_at, title, description,
|
||||||
|
content
|
||||||
|
FROM items
|
||||||
|
WHERE channel_id = ?",
|
||||||
|
self.id.0
|
||||||
|
).fetch_all(&pool.0).await?.into_iter().map(UnparsedItem::parse).collect();
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
db::{
|
||||||
|
Feed,
|
||||||
|
User,
|
||||||
|
},
|
||||||
|
test_utils::{
|
||||||
|
FEED1, FEED2, CHANNEL_TITLE, CHANNEL_DESC, USERNAME, FEED_TITLE,
|
||||||
|
FEED_TITLE2, ITEM_GUID, ITEM_GUID2,
|
||||||
|
setup_adapter,
|
||||||
|
setup_channel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unparsed_item() {
|
||||||
|
const CHANNEL_ID: i64 = 1;
|
||||||
|
let date: DateTime<Utc> = Utc.with_ymd_and_hms(2020,1,1,0,0,0).unwrap();
|
||||||
|
|
||||||
|
let raw_channel = UnparsedChannel {
|
||||||
|
id: CHANNEL_ID,
|
||||||
|
title: CHANNEL_TITLE.to_string(),
|
||||||
|
link: FEED1.to_string(),
|
||||||
|
description: Some(CHANNEL_DESC.to_string()),
|
||||||
|
last_fetched: Some(date.to_rfc2822()),
|
||||||
|
};
|
||||||
|
let channel = raw_channel.parse().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(channel.id.0, CHANNEL_ID);
|
||||||
|
assert_eq!(channel.title, CHANNEL_TITLE);
|
||||||
|
assert_eq!(channel.link.as_str(), FEED1);
|
||||||
|
assert_eq!(channel.description, Some(CHANNEL_DESC.to_string()));
|
||||||
|
assert_eq!(channel.last_fetched, Some(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_all() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let url1 = Url::parse(FEED1).unwrap();
|
||||||
|
let url2 = Url::parse(FEED2).unwrap();
|
||||||
|
Channel::get_or_create(pool, url1).await.unwrap();
|
||||||
|
Channel::get_or_create(pool, url2).await.unwrap();
|
||||||
|
|
||||||
|
let channels = Channel::get_all(pool).await.unwrap();
|
||||||
|
assert_eq!(channels.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let channel_a = setup_channel(pool).await;
|
||||||
|
|
||||||
|
let channel_b = Channel::get(pool, channel_a.id()).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(channel_a.id, channel_b.id);
|
||||||
|
assert_eq!(channel_a.title, channel_b.title);
|
||||||
|
assert_eq!(channel_a.link, channel_b.link);
|
||||||
|
assert_eq!(channel_a.last_fetched, channel_b.last_fetched);
|
||||||
|
assert_eq!(channel_a.description, channel_b.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let url_feed = Url::parse(FEED1).unwrap();
|
||||||
|
|
||||||
|
let channel = Channel::get_or_create(pool, url_feed).await.unwrap();
|
||||||
|
|
||||||
|
assert!(channel.id().0 > 0);
|
||||||
|
assert_eq!(channel.link().as_str(), FEED1);
|
||||||
|
assert!(channel.title().len() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_duplicate_returns_existing() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let url_feed = Url::parse(FEED1).unwrap();
|
||||||
|
|
||||||
|
let channel1 = Channel::get_or_create(pool, url_feed.clone()).await.unwrap();
|
||||||
|
let channel2 = Channel::get_or_create(pool, url_feed).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(channel1.id(), channel2.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_all_channels() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let url_feed1 = Url::parse(FEED1).unwrap();
|
||||||
|
let url_feed2 = Url::parse(FEED2).unwrap();
|
||||||
|
|
||||||
|
Channel::get_or_create(pool, url_feed1).await.unwrap();
|
||||||
|
Channel::get_or_create(pool, url_feed2).await.unwrap();
|
||||||
|
|
||||||
|
let channels = Channel::get_all(pool).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(channels.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_feed_channels() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let channel = setup_channel(pool).await;
|
||||||
|
|
||||||
|
let user = User::create(pool, USERNAME).await.unwrap();
|
||||||
|
let feed1 = Feed::create(pool, user.id(), FEED_TITLE).await.unwrap();
|
||||||
|
let feed2 = Feed::create(pool, user.id(), FEED_TITLE2).await.unwrap();
|
||||||
|
|
||||||
|
feed1.add_channel(pool, channel.id).await.unwrap();
|
||||||
|
feed2.add_channel(pool, channel.id).await.unwrap();
|
||||||
|
|
||||||
|
let fc_list = channel.get_feed_channels(pool).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fc_list.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_channels() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let channel = setup_channel(pool).await;
|
||||||
|
|
||||||
|
let user = User::create(pool, USERNAME).await.unwrap();
|
||||||
|
let feed1 = Feed::create(pool, user.id(), FEED_TITLE).await.unwrap();
|
||||||
|
let feed2 = Feed::create(pool, user.id(), FEED_TITLE2).await.unwrap();
|
||||||
|
|
||||||
|
feed1.add_channel(pool, channel.id).await.unwrap();
|
||||||
|
feed2.add_channel(pool, channel.id).await.unwrap();
|
||||||
|
|
||||||
|
let fc_list = channel.get_feed_channels(pool).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fc_list.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_items() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let channel = setup_channel(pool).await;
|
||||||
|
|
||||||
|
Item::get_or_create(pool, channel.id(), ITEM_GUID).await.unwrap();
|
||||||
|
Item::get_or_create(pool, channel.id(), ITEM_GUID2).await.unwrap();
|
||||||
|
|
||||||
|
let items = channel.get_items(pool).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
koucha/src/db/feed.rs
Normal file
217
koucha/src/db/feed.rs
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
use crate::{
|
||||||
|
AdapterPool,
|
||||||
|
Result,
|
||||||
|
db::{
|
||||||
|
Channel,
|
||||||
|
ChannelKey,
|
||||||
|
FeedKey,
|
||||||
|
Item,
|
||||||
|
UserKey,
|
||||||
|
channel::UnparsedChannel,
|
||||||
|
item::UnparsedItem,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct UnparsedFeed {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
impl UnparsedFeed {
|
||||||
|
pub fn parse(self) -> Result<Feed> {
|
||||||
|
Ok(Feed {
|
||||||
|
id: FeedKey(self.id),
|
||||||
|
title: self.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Feed {
|
||||||
|
id: FeedKey,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Feed {
|
||||||
|
pub fn id(&self) -> FeedKey { self.id }
|
||||||
|
pub fn title(&self) -> &str { &self.title }
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
pool: &AdapterPool, id: FeedKey
|
||||||
|
) -> Result<Self> {
|
||||||
|
let feed = sqlx::query_as!(
|
||||||
|
UnparsedFeed,
|
||||||
|
"SELECT id, title FROM feeds WHERE id = ?",
|
||||||
|
id.0
|
||||||
|
).fetch_one(&pool.0).await?.parse();
|
||||||
|
|
||||||
|
feed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &AdapterPool, user_id: UserKey, title: &str
|
||||||
|
) -> Result<Self> {
|
||||||
|
let new_feed = sqlx::query_as!(
|
||||||
|
UnparsedFeed,
|
||||||
|
"INSERT INTO feeds (user_id, title)
|
||||||
|
VALUES (?, ?)
|
||||||
|
RETURNING id as `id!`, title",
|
||||||
|
user_id.0, title
|
||||||
|
).fetch_one(&pool.0).await?.parse();
|
||||||
|
|
||||||
|
new_feed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_title(
|
||||||
|
pool: &AdapterPool, id: FeedKey, new_title: &str
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE feeds SET title = ? WHERE id = ?",
|
||||||
|
new_title, id.0
|
||||||
|
).execute(&pool.0).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_channel(
|
||||||
|
&self, pool: &AdapterPool, channel_id: ChannelKey
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO feed_channels (feed_id, channel_id)
|
||||||
|
VALUES (?, ?)",
|
||||||
|
self.id.0, channel_id.0
|
||||||
|
).execute(&pool.0).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_items(
|
||||||
|
&self, pool: &AdapterPool, limit: u8, offset: u32
|
||||||
|
) -> Result<Vec<Item>> {
|
||||||
|
let items: Result<Vec<Item>> = sqlx::query_as!(
|
||||||
|
UnparsedItem,
|
||||||
|
"SELECT i.id as `id!`, i.channel_id, i.fetched_at, i.title, i.description,
|
||||||
|
i.content
|
||||||
|
FROM items i
|
||||||
|
JOIN feed_items fi on i.id = fi.item_id
|
||||||
|
WHERE feed_id = ? AND archived = FALSE
|
||||||
|
ORDER BY score DESC
|
||||||
|
LIMIT ? OFFSET ?",
|
||||||
|
self.id.0, limit, offset
|
||||||
|
).fetch_all(&pool.0).await?.into_iter().map(UnparsedItem::parse).collect();
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_channels(
|
||||||
|
&self, pool: &AdapterPool
|
||||||
|
) -> Result<Vec<Channel>> {
|
||||||
|
let channels: Result<Vec<Channel>> = sqlx::query_as!(
|
||||||
|
UnparsedChannel,
|
||||||
|
"SELECT c.id as `id!`, c.title, c.link, c.description, c.last_fetched
|
||||||
|
FROM channels c
|
||||||
|
JOIN feed_channels fc on c.id = fc.channel_id
|
||||||
|
WHERE fc.feed_id = ?",
|
||||||
|
self.id.0
|
||||||
|
).fetch_all(&pool.0).await?.into_iter()
|
||||||
|
.map(UnparsedChannel::parse).collect();
|
||||||
|
|
||||||
|
channels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
db::User,
|
||||||
|
test_utils::{
|
||||||
|
FEED_TITLE, USERNAME, FEED1, FEED2,
|
||||||
|
setup_adapter,
|
||||||
|
setup_feed,
|
||||||
|
setup_channel,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
use reqwest::Url;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse() {
|
||||||
|
const FID: i64 = 1;
|
||||||
|
let uf = UnparsedFeed {
|
||||||
|
id: FID,
|
||||||
|
title: FEED_TITLE.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let f = uf.parse().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(f.id.0, FID);
|
||||||
|
assert_eq!(f.title, FEED_TITLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let feed = setup_feed(pool).await;
|
||||||
|
|
||||||
|
let gotten_feed = Feed::get(pool, feed.id).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(feed.id, gotten_feed.id);
|
||||||
|
assert_eq!(feed.title, gotten_feed.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let user = User::create(pool, USERNAME).await.unwrap();
|
||||||
|
let feed = Feed::create(pool, user.id(), FEED_TITLE).await.unwrap();
|
||||||
|
|
||||||
|
assert!(feed.id().0 > 0);
|
||||||
|
assert_eq!(feed.title(), FEED_TITLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_title() {
|
||||||
|
const NEW_FEED_TITLE: &str = "My NEW feed!";
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let feed = setup_feed(pool).await;
|
||||||
|
|
||||||
|
Feed::update_title(pool, feed.id(), NEW_FEED_TITLE).await.unwrap();
|
||||||
|
|
||||||
|
let updated = Feed::get(pool, feed.id()).await.unwrap();
|
||||||
|
assert_eq!(updated.title(), NEW_FEED_TITLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_channel() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let feed = setup_feed(pool).await;
|
||||||
|
let channel = setup_channel(pool).await;
|
||||||
|
|
||||||
|
feed.add_channel(pool, channel.id()).await.unwrap();
|
||||||
|
|
||||||
|
let channels = feed.get_channels(pool).await.unwrap();
|
||||||
|
let gotten_channel = &channels[0];
|
||||||
|
|
||||||
|
assert_eq!(gotten_channel.id().0, channel.id().0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_channels() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let feed = setup_feed(pool).await;
|
||||||
|
let url1 = Url::parse(FEED1).unwrap();
|
||||||
|
let channel1 = Channel::get_or_create(pool, url1).await.unwrap();
|
||||||
|
let url2 = Url::parse(FEED2).unwrap();
|
||||||
|
let channel2 = Channel::get_or_create(pool, url2).await.unwrap();
|
||||||
|
|
||||||
|
feed.add_channel(pool, channel1.id()).await.unwrap();
|
||||||
|
feed.add_channel(pool, channel2.id()).await.unwrap();
|
||||||
|
|
||||||
|
let channels = feed.get_channels(pool).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(channels.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
koucha/src/db/feed_channel.rs
Normal file
180
koucha/src/db/feed_channel.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
use crate::{
|
||||||
|
Result,
|
||||||
|
AdapterPool,
|
||||||
|
db::{
|
||||||
|
Channel,
|
||||||
|
ChannelKey,
|
||||||
|
Feed,
|
||||||
|
FeedKey,
|
||||||
|
Item,
|
||||||
|
},
|
||||||
|
score::{
|
||||||
|
Score,
|
||||||
|
Gravity,
|
||||||
|
Boost,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use chrono::{Utc, DateTime};
|
||||||
|
|
||||||
|
pub struct UnparsedFeedChannel {
|
||||||
|
pub channel_id: i64,
|
||||||
|
pub feed_id: i64,
|
||||||
|
pub initial_score: Option<i64>,
|
||||||
|
pub gravity: Option<i64>,
|
||||||
|
pub boost: Option<i64>,
|
||||||
|
}
|
||||||
|
impl UnparsedFeedChannel {
|
||||||
|
pub fn parse(self) -> Result<FeedChannel> {
|
||||||
|
Ok(FeedChannel {
|
||||||
|
channel_id: ChannelKey(self.channel_id),
|
||||||
|
feed_id: FeedKey(self.feed_id),
|
||||||
|
initial_score: Score::new(self.initial_score),
|
||||||
|
gravity: Gravity::new(self.gravity),
|
||||||
|
boost: Boost::new(self.boost),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FeedChannel {
|
||||||
|
channel_id: ChannelKey,
|
||||||
|
feed_id: FeedKey,
|
||||||
|
initial_score: Score,
|
||||||
|
gravity: Gravity,
|
||||||
|
boost: Boost,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedChannel {
|
||||||
|
pub async fn get_channel(&self, pool: &AdapterPool) -> Result<Channel> {
|
||||||
|
Channel::get(pool, self.channel_id).await
|
||||||
|
}
|
||||||
|
pub async fn get_feed(&self, pool: &AdapterPool) -> Result<Feed> {
|
||||||
|
Feed::get(pool, self.feed_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_item(
|
||||||
|
&self, pool: &AdapterPool, item: &Item
|
||||||
|
) -> Result<()> {
|
||||||
|
self.add_item_at(pool, item, Utc::now()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_item_at(
|
||||||
|
&self, pool: &AdapterPool, item: &Item, add_at: DateTime<Utc>
|
||||||
|
) -> Result<()> {
|
||||||
|
let int_item_id = item.id().0;
|
||||||
|
let int_initial_score = i64::from(self.initial_score);
|
||||||
|
let string_last_updated = add_at.to_rfc2822();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT OR IGNORE INTO feed_items (feed_id, item_id, score, last_updated)
|
||||||
|
VALUES (?, ?, ?, ?)",
|
||||||
|
self.feed_id.0, int_item_id, int_initial_score, string_last_updated
|
||||||
|
).execute(&pool.0).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use reqwest::Url;
|
||||||
|
use crate::{
|
||||||
|
db::{
|
||||||
|
Channel,
|
||||||
|
FeedKey,
|
||||||
|
User
|
||||||
|
},
|
||||||
|
test_utils::{
|
||||||
|
FEED1, setup_adapter, get_datetime
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse() {
|
||||||
|
const CID: i64 = 1;
|
||||||
|
const FID: i64 = 2;
|
||||||
|
const IS: i64 = 3;
|
||||||
|
const G: i64 = 4;
|
||||||
|
const B: i64 = 5;
|
||||||
|
let ufc = UnparsedFeedChannel {
|
||||||
|
channel_id: CID,
|
||||||
|
feed_id: FID,
|
||||||
|
initial_score: Some(IS),
|
||||||
|
gravity: Some(G),
|
||||||
|
boost: Some(B),
|
||||||
|
};
|
||||||
|
|
||||||
|
let fc = ufc.parse().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fc.channel_id.0, CID);
|
||||||
|
assert_eq!(fc.feed_id.0, FID);
|
||||||
|
assert_eq!(i64::from(fc.initial_score), IS);
|
||||||
|
assert_eq!(i64::from(fc.gravity), G);
|
||||||
|
assert_eq!(i64::from(fc.boost), B);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedChannel Tests
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_channel() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
|
||||||
|
let url = Url::parse(FEED1).unwrap();
|
||||||
|
let channel = Channel::get_or_create(pool, url).await.unwrap();
|
||||||
|
|
||||||
|
let fc = FeedChannel {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
feed_id: FeedKey(1), // Fake Feed
|
||||||
|
initial_score: Score::new(None),
|
||||||
|
gravity: Gravity::new(None),
|
||||||
|
boost: Boost::new(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let channel_from_fc = fc.get_channel(pool).await.unwrap();
|
||||||
|
assert_eq!(channel_from_fc.id(), channel.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_feed() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
|
||||||
|
let user = User::create(pool, "Alice").await.unwrap();
|
||||||
|
let feed = Feed::create(pool, user.id(), "My Feed").await.unwrap();
|
||||||
|
|
||||||
|
let fc = FeedChannel {
|
||||||
|
channel_id: ChannelKey(1), // Fake Channel
|
||||||
|
feed_id: feed.id(),
|
||||||
|
initial_score: Score::new(None),
|
||||||
|
gravity: Gravity::new(None),
|
||||||
|
boost: Boost::new(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let feed_from_fc = fc.get_feed(pool).await.unwrap();
|
||||||
|
assert_eq!(feed_from_fc.id(), feed.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
pub async fn add_item() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
|
||||||
|
let user = User::create(pool, "Alice").await.unwrap();
|
||||||
|
let feed = Feed::create(pool, user.id(), "My Feed").await.unwrap();
|
||||||
|
let url = Url::parse(FEED1).unwrap();
|
||||||
|
let channel = Channel::get_or_create(pool, url).await.unwrap();
|
||||||
|
let fc = FeedChannel {
|
||||||
|
channel_id: channel.id(),
|
||||||
|
feed_id: feed.id(),
|
||||||
|
initial_score: Score::new(None),
|
||||||
|
gravity: Gravity::new(None),
|
||||||
|
boost: Boost::new(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = Item::get_or_create(pool, channel.id(), "item-guid").await.unwrap();
|
||||||
|
fc.add_item_at(pool, &item, dt).await.unwrap();
|
||||||
|
|
||||||
|
let items = feed.get_items(pool, 1, 0).await.unwrap();
|
||||||
|
assert_eq!(items[0].id(), item.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
101
koucha/src/db/feed_item.rs
Normal file
101
koucha/src/db/feed_item.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use crate::{
|
||||||
|
Result,
|
||||||
|
AdapterPool,
|
||||||
|
db::{
|
||||||
|
FeedItemKey,
|
||||||
|
},
|
||||||
|
score::{
|
||||||
|
TimedScore,
|
||||||
|
UnparsedTimedScore
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct UnparsedFeedItem {
|
||||||
|
pub item_id: i64,
|
||||||
|
pub feed_id: i64,
|
||||||
|
pub score: i64,
|
||||||
|
pub last_updated: String,
|
||||||
|
pub boosted_at: Option<String>,
|
||||||
|
}
|
||||||
|
impl UnparsedFeedItem {
|
||||||
|
pub fn parse(self) -> Result<FeedItem> {
|
||||||
|
Ok(FeedItem {
|
||||||
|
key: FeedItemKey {
|
||||||
|
feed_id: self.feed_id,
|
||||||
|
item_id: self.item_id,
|
||||||
|
},
|
||||||
|
score: (UnparsedTimedScore {
|
||||||
|
value: self.score,
|
||||||
|
last_updated: DateTime::parse_from_rfc2822(&self.last_updated)?
|
||||||
|
.with_timezone(&Utc),
|
||||||
|
last_boosted: self.boosted_at.as_deref()
|
||||||
|
.map(DateTime::parse_from_rfc2822)
|
||||||
|
.transpose()?
|
||||||
|
.map(|dt| dt.with_timezone(&Utc)),
|
||||||
|
}).parse(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FeedItem {
|
||||||
|
key: FeedItemKey,
|
||||||
|
score: TimedScore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedItem {
|
||||||
|
pub fn key(&self) -> FeedItemKey { self.key }
|
||||||
|
pub fn score(&self) -> TimedScore { self.score.clone() }
|
||||||
|
|
||||||
|
pub async fn archive(
|
||||||
|
&self, pool: &AdapterPool
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE feed_items
|
||||||
|
SET archived = ?
|
||||||
|
WHERE feed_id = ? AND item_id = ?",
|
||||||
|
true, self.key.feed_id, self.key.item_id
|
||||||
|
).execute(&pool.0).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_score(
|
||||||
|
pool: &AdapterPool, key: FeedItemKey, new_score: TimedScore
|
||||||
|
) -> Result<()> {
|
||||||
|
let unparsed_score = UnparsedTimedScore::unparse(new_score);
|
||||||
|
let last_updated = unparsed_score.last_updated.to_rfc2822();
|
||||||
|
let boosted_at = unparsed_score.last_boosted.map(|lb| lb.to_rfc2822());
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE feed_items
|
||||||
|
SET score = ?, last_updated = ?, boosted_at = ?
|
||||||
|
WHERE feed_id = ? AND item_id = ?",
|
||||||
|
unparsed_score.value, last_updated, boosted_at, key.feed_id, key.item_id
|
||||||
|
).execute(&pool.0).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::test_utils::get_datetime;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_feed_item() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let upi = UnparsedFeedItem {
|
||||||
|
item_id: 1,
|
||||||
|
feed_id: 2,
|
||||||
|
score: 5,
|
||||||
|
last_updated: dt.to_string(),
|
||||||
|
boosted_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fi = upi.parse().unwrap();
|
||||||
|
assert_eq!(fi.key.item_id, 1);
|
||||||
|
assert_eq!(fi.key.feed_id, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
koucha/src/db/item.rs
Normal file
144
koucha/src/db/item.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
use crate::{
|
||||||
|
Result,
|
||||||
|
AdapterPool,
|
||||||
|
db::{
|
||||||
|
ChannelKey,
|
||||||
|
ItemKey,
|
||||||
|
},
|
||||||
|
fetch::FetchedRSSItem,
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
pub struct UnparsedItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub channel_id: i64,
|
||||||
|
pub fetched_at: Option<String>,
|
||||||
|
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnparsedItem {
|
||||||
|
pub fn parse(self) -> Result<Item> {
|
||||||
|
Ok(Item {
|
||||||
|
id: ItemKey(self.id),
|
||||||
|
channel_id: ChannelKey(self.channel_id),
|
||||||
|
fetched_at: match self.fetched_at {
|
||||||
|
Some(dt_str) => Some(DateTime::parse_from_rfc2822(&dt_str)?
|
||||||
|
.with_timezone(&Utc)),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
|
||||||
|
title: self.title,
|
||||||
|
description: self.description,
|
||||||
|
content: self.content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Item {
|
||||||
|
id: ItemKey,
|
||||||
|
channel_id: ChannelKey,
|
||||||
|
|
||||||
|
#[allow(dead_code)] // TODO: Use for score decay calculations later
|
||||||
|
fetched_at: Option<DateTime<Utc>>,
|
||||||
|
title: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
content: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Item {
|
||||||
|
pub fn id(&self) -> ItemKey { self.id }
|
||||||
|
pub fn channel(&self) -> ChannelKey { self.channel_id }
|
||||||
|
pub fn title(&self) -> Option<&str> { self.title.as_deref() }
|
||||||
|
pub fn description(&self) -> Option<&str> { self.description.as_deref() }
|
||||||
|
pub fn content(&self) -> Option<&str> { self.content.as_deref() }
|
||||||
|
|
||||||
|
pub async fn get_or_create(
|
||||||
|
pool: &AdapterPool, from_channel: ChannelKey, guid: &str
|
||||||
|
) -> Result<Self> {
|
||||||
|
|
||||||
|
let item = sqlx::query_as!(
|
||||||
|
UnparsedItem,
|
||||||
|
"INSERT INTO items (channel_id, guid)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(channel_id, guid) DO UPDATE SET channel_id = channel_id
|
||||||
|
RETURNING id as `id!`, channel_id, fetched_at, title, description,
|
||||||
|
content",
|
||||||
|
from_channel.0, guid
|
||||||
|
).fetch_one(&pool.0).await?.parse();
|
||||||
|
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_content(
|
||||||
|
&self, pool: &AdapterPool, fetched: &FetchedRSSItem, fetched_at: &DateTime<Utc>
|
||||||
|
) -> Result<()> {
|
||||||
|
let title = fetched.title();
|
||||||
|
let description = fetched.description();
|
||||||
|
let content = fetched.content();
|
||||||
|
let string_fetched_at = fetched_at.to_rfc2822();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE items
|
||||||
|
SET title = ?, description = ?, content = ?,
|
||||||
|
fetched_at = ?
|
||||||
|
WHERE id = ?",
|
||||||
|
title, description, content, string_fetched_at,
|
||||||
|
self.id.0
|
||||||
|
).execute(&pool.0).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::test_utils::{
|
||||||
|
ITEM_GUID, ITEM_TITLE, ITEM_DESC, ITEM_CONT,
|
||||||
|
setup_adapter,
|
||||||
|
setup_channel,
|
||||||
|
};
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
// UnparsedItem tests
|
||||||
|
#[test]
|
||||||
|
fn parse_unparsed_item() {
|
||||||
|
const ITEM_ID: i64 = 1;
|
||||||
|
const CHANNEL_ID: i64 = 1;
|
||||||
|
|
||||||
|
let date: DateTime<Utc> = Utc.with_ymd_and_hms(2020,1,1,0,0,0).unwrap();
|
||||||
|
let raw_item = UnparsedItem {
|
||||||
|
id: ITEM_ID,
|
||||||
|
channel_id: CHANNEL_ID,
|
||||||
|
fetched_at: Some(date.to_rfc2822()),
|
||||||
|
title: Some(ITEM_TITLE.to_string()),
|
||||||
|
description: Some(ITEM_DESC.to_string()),
|
||||||
|
content: Some(ITEM_CONT.to_string()),
|
||||||
|
};
|
||||||
|
let item = raw_item.parse().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(item.id.0, ITEM_ID);
|
||||||
|
assert_eq!(item.channel_id.0, CHANNEL_ID);
|
||||||
|
assert_eq!(item.fetched_at, Some(date));
|
||||||
|
assert_eq!(item.title, Some(ITEM_TITLE.to_string()));
|
||||||
|
assert_eq!(item.description, Some(ITEM_DESC.to_string()));
|
||||||
|
assert_eq!(item.content, Some(ITEM_CONT.to_string()));
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item Tests
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_or_create_duplicate() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let channel = setup_channel(pool).await;
|
||||||
|
|
||||||
|
let item1 = Item::get_or_create(pool, channel.id(), ITEM_GUID).await.unwrap();
|
||||||
|
let item2 = Item::get_or_create(pool, channel.id(), ITEM_GUID).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(item1.id(), item2.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
208
koucha/src/db/user.rs
Normal file
208
koucha/src/db/user.rs
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
use crate::{
|
||||||
|
Result,
|
||||||
|
AdapterPool,
|
||||||
|
db::{
|
||||||
|
UserKey,
|
||||||
|
Feed,
|
||||||
|
feed::UnparsedFeed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct UnparsedUser {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
impl UnparsedUser {
|
||||||
|
pub fn parse(self) -> Result<User> {
|
||||||
|
Ok(User {
|
||||||
|
id: UserKey(self.id),
|
||||||
|
name: self.name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct User {
|
||||||
|
id: UserKey,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub fn id(&self) -> UserKey { self.id }
|
||||||
|
pub fn name(&self) -> &str { &self.name }
|
||||||
|
|
||||||
|
pub async fn get(pool: &AdapterPool, id: UserKey) -> Result<Self> {
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
UnparsedUser,
|
||||||
|
"SELECT id, name FROM users WHERE id = ?",
|
||||||
|
id.0
|
||||||
|
).fetch_one(&pool.0).await?.parse();
|
||||||
|
|
||||||
|
user
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all(pool: &AdapterPool) -> Result<Vec<Self>> {
|
||||||
|
let users: Result<Vec<Self>> = sqlx::query_as!(
|
||||||
|
UnparsedUser,
|
||||||
|
"SELECT id, name FROM users"
|
||||||
|
).fetch_all(&pool.0).await?.into_iter().map(UnparsedUser::parse).collect();
|
||||||
|
|
||||||
|
users
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(pool: &AdapterPool, name: &str) -> Result<Self> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
"INSERT INTO users (name)
|
||||||
|
VALUES (?)
|
||||||
|
RETURNING id, name",
|
||||||
|
name
|
||||||
|
).fetch_one(&pool.0).await?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
id: UserKey(result.id),
|
||||||
|
name: result.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_name(
|
||||||
|
pool: &AdapterPool, id: UserKey, new_name: &str
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE users SET name = ? WHERE id = ?",
|
||||||
|
new_name, id.0
|
||||||
|
).execute(&pool.0).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_feeds(&self, pool: &AdapterPool) -> Result<Vec<Feed>> {
|
||||||
|
let feeds: Result<Vec<Feed>> = sqlx::query_as!(
|
||||||
|
UnparsedFeed,
|
||||||
|
"SELECT id, title FROM feeds WHERE user_id = ?",
|
||||||
|
self.id.0
|
||||||
|
).fetch_all(&pool.0).await?.into_iter()
|
||||||
|
.map(UnparsedFeed::parse).collect();
|
||||||
|
|
||||||
|
feeds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
db::Feed,
|
||||||
|
test_utils::{
|
||||||
|
USERNAME, USERNAME2, FEED_TITLE, FEED_TITLE2,
|
||||||
|
setup_adapter,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse() {
|
||||||
|
const UID: i64 = 1;
|
||||||
|
let unparsed_user = UnparsedUser {
|
||||||
|
id: UID,
|
||||||
|
name: USERNAME.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = unparsed_user.parse().unwrap();
|
||||||
|
assert_eq!(user.id.0, UID);
|
||||||
|
assert_eq!(user.name, USERNAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let new_user = User::create(pool, USERNAME).await.unwrap();
|
||||||
|
|
||||||
|
let fetched_user = User::get(pool, new_user.id).await.unwrap();
|
||||||
|
assert_eq!(fetched_user.name, USERNAME);
|
||||||
|
assert_eq!(fetched_user.id.0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_all() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
User::create(pool, USERNAME).await.unwrap();
|
||||||
|
User::create(pool, USERNAME2).await.unwrap();
|
||||||
|
|
||||||
|
let users = User::get_all(pool).await.unwrap();
|
||||||
|
assert_eq!(users.len(), 2);
|
||||||
|
assert!(users.iter().any(|u| u.name == USERNAME));
|
||||||
|
assert!(users.iter().any(|u| u.name == USERNAME2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_user() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
|
||||||
|
let user = User::create(pool, USERNAME).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(user.name, USERNAME);
|
||||||
|
assert_eq!(user.id.0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_duplicate_user() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
|
||||||
|
User::create(pool, USERNAME).await.unwrap();
|
||||||
|
let duplicate_user = User::create(pool, USERNAME).await;
|
||||||
|
|
||||||
|
assert!(duplicate_user.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_name() {
|
||||||
|
const NEW_USERNAME: &str = "Alicia";
|
||||||
|
assert!(NEW_USERNAME != USERNAME);
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
|
||||||
|
let user = User::create(pool, USERNAME).await.unwrap();
|
||||||
|
User::update_name(pool, user.id, NEW_USERNAME).await.unwrap();
|
||||||
|
|
||||||
|
let updated = User::get(pool, user.id).await.unwrap();
|
||||||
|
assert_eq!(updated.name, NEW_USERNAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_name_to_duplicate() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
|
||||||
|
let user1 = User::create(pool, USERNAME).await.unwrap();
|
||||||
|
User::create(pool, USERNAME2).await.unwrap();
|
||||||
|
let status = User::update_name(pool, user1.id, USERNAME2).await;
|
||||||
|
|
||||||
|
assert!(status.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_feeds() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let user = User::create(pool, USERNAME).await.unwrap();
|
||||||
|
Feed::create(pool, user.id, FEED_TITLE).await.unwrap();
|
||||||
|
Feed::create(pool, user.id, FEED_TITLE2).await.unwrap();
|
||||||
|
|
||||||
|
let feeds = user.get_feeds(pool).await.unwrap();
|
||||||
|
assert_eq!(feeds.len(), 2);
|
||||||
|
assert!(feeds.iter().any(|f| f.title() == FEED_TITLE));
|
||||||
|
assert!(feeds.iter().any(|f| f.title() == FEED_TITLE2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_feeds_empty() {
|
||||||
|
let adapter = setup_adapter().await;
|
||||||
|
let pool = adapter.get_pool();
|
||||||
|
let user = User::create(pool, USERNAME).await.unwrap();
|
||||||
|
let feeds = user.get_feeds(pool).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(feeds.len(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
koucha/src/fetch.rs
Normal file
155
koucha/src/fetch.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
use crate::{
|
||||||
|
Result,
|
||||||
|
db::{Channel, ChannelId},
|
||||||
|
AdapterClient,
|
||||||
|
};
|
||||||
|
use reqwest::Url;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
pub struct FetchedRSSItem {
|
||||||
|
guid: String,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
impl FetchedRSSItem {
|
||||||
|
pub fn guid(&self) -> &str { &self.guid }
|
||||||
|
pub fn title(&self) -> &str { &self.title }
|
||||||
|
pub fn description(&self) -> &str { &self.description }
|
||||||
|
pub fn content(&self) -> &str { &self.content }
|
||||||
|
|
||||||
|
fn parse(item: rss::Item) -> Self {
|
||||||
|
FetchedRSSItem {
|
||||||
|
guid: Self::get_or_create_guid(&item),
|
||||||
|
title: item.title().unwrap_or("").to_string(),
|
||||||
|
description: item.description().unwrap_or("").to_string(),
|
||||||
|
content: item.content().unwrap_or("").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_or_create_guid(item: &rss::Item) -> String {
|
||||||
|
if let Some(guid) = item.guid() {
|
||||||
|
return guid.value().to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
item.link().unwrap_or("").hash(&mut hasher);
|
||||||
|
item.title().unwrap_or("").hash(&mut hasher);
|
||||||
|
item.description().unwrap_or("").hash(&mut hasher);
|
||||||
|
|
||||||
|
format!("gen-{:x}", hasher.finish())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub struct FetchedRSSChannel {
|
||||||
|
title: String,
|
||||||
|
link: Url,
|
||||||
|
description: String,
|
||||||
|
|
||||||
|
items: Vec<FetchedRSSItem>,
|
||||||
|
|
||||||
|
fetched_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
impl FetchedRSSChannel {
|
||||||
|
pub fn title(&self) -> &str { &self.title }
|
||||||
|
pub fn link(&self) -> &Url { &self.link }
|
||||||
|
pub fn description(&self) -> &str { &self.description }
|
||||||
|
pub fn items(&self) -> &[FetchedRSSItem] { &self.items }
|
||||||
|
pub fn fetched_at(&self) -> &DateTime<Utc> { &self.fetched_at }
|
||||||
|
|
||||||
|
pub async fn fetch_channel(
|
||||||
|
client: &AdapterClient, channel: Channel
|
||||||
|
) -> Result<Option<Self>> {
|
||||||
|
if channel.should_skip_fetch() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytestream = client.0.get(channel.link().clone())
|
||||||
|
.send().await?
|
||||||
|
.bytes().await?;
|
||||||
|
|
||||||
|
let rss_channel = rss::Channel::read_from(&bytestream[..])?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
Ok(Some(FetchedRSSChannel::parse(rss_channel, now)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(rss: rss::Channel, fetched_at: DateTime<Utc>) -> Result<Self> {
|
||||||
|
Ok(FetchedRSSChannel {
|
||||||
|
title: rss.title,
|
||||||
|
link: Url::parse(&rss.link)?,
|
||||||
|
description: rss.description,
|
||||||
|
|
||||||
|
items: rss.items.into_iter().map(FetchedRSSItem::parse).collect(),
|
||||||
|
|
||||||
|
fetched_at: fetched_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::test_utils::{
|
||||||
|
ITEM_TITLE, ITEM_GUID, ITEM_GUID2, ITEM_DESC, ITEM_CONT,
|
||||||
|
CHANNEL_TITLE, CHANNEL_DESC, FEED1,
|
||||||
|
get_datetime
|
||||||
|
};
|
||||||
|
|
||||||
|
fn create_guid(value: String) -> rss::Guid {
|
||||||
|
rss::Guid { value, permalink: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_item(guid: rss::Guid) -> rss::Item {
|
||||||
|
rss::ItemBuilder::default()
|
||||||
|
.title(ITEM_TITLE.to_string())
|
||||||
|
.guid(guid)
|
||||||
|
.description(ITEM_DESC.to_string())
|
||||||
|
.content(ITEM_CONT.to_string())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_channel(items: Vec<rss::Item>) -> rss::Channel {
|
||||||
|
rss::ChannelBuilder::default()
|
||||||
|
.title(CHANNEL_TITLE.to_string())
|
||||||
|
.description(CHANNEL_DESC.to_string())
|
||||||
|
.link(FEED1.to_string())
|
||||||
|
.items(items)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_item() {
|
||||||
|
let rss_guid = create_guid(ITEM_GUID.to_string());
|
||||||
|
let rss_item = create_item(rss_guid);
|
||||||
|
let item = FetchedRSSItem::parse(rss_item);
|
||||||
|
|
||||||
|
assert_eq!(item.guid, ITEM_GUID);
|
||||||
|
assert_eq!(item.title, ITEM_TITLE);
|
||||||
|
assert_eq!(item.description, ITEM_DESC);
|
||||||
|
assert_eq!(item.content, ITEM_CONT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_feed() {
|
||||||
|
let rss_guid = create_guid(ITEM_GUID.to_string());
|
||||||
|
let rss_guid2 = create_guid(ITEM_GUID2.to_string());
|
||||||
|
let rss_item = create_item(rss_guid);
|
||||||
|
let rss_item2 = create_item(rss_guid2);
|
||||||
|
|
||||||
|
let rss_channel = create_channel([rss_item, rss_item2].to_vec());
|
||||||
|
|
||||||
|
let date: DateTime<Utc> = get_datetime();
|
||||||
|
|
||||||
|
let channel = FetchedRSSChannel::parse(rss_channel, date).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(channel.title, CHANNEL_TITLE);
|
||||||
|
assert_eq!(channel.link.as_str(), FEED1);
|
||||||
|
assert_eq!(channel.description, CHANNEL_DESC);
|
||||||
|
assert_eq!(channel.fetched_at, date);
|
||||||
|
assert_eq!(channel.items.len(), 2);
|
||||||
|
assert!(channel.items.iter().any(|i| i.guid() == ITEM_GUID));
|
||||||
|
assert!(channel.items.iter().any(|i| i.guid() == ITEM_GUID2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, Box<dyn Error>>;
|
||||||
|
|
||||||
|
pub mod db;
|
||||||
|
pub mod fetch;
|
||||||
|
pub mod score;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod test_utils;
|
||||||
|
|
||||||
|
pub struct AdapterPool(sqlx::SqlitePool);
|
||||||
|
pub struct AdapterClient(reqwest::Client);
|
||||||
|
|
||||||
|
pub struct AdapterBuilder {
|
||||||
|
database_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdapterBuilder {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
database_url: "sqlite:test.db".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn database_url(mut self, url: &str) -> Self {
|
||||||
|
self.database_url = url.to_string();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(self) -> Result<Adapter> {
|
||||||
|
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.connect(&self.database_url).await?;
|
||||||
|
sqlx::migrate!().run(&db).await?;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
Ok(Adapter { db: AdapterPool(db), client: AdapterClient(client) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Adapter {
|
||||||
|
db: AdapterPool,
|
||||||
|
client: AdapterClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Adapter {
|
||||||
|
pub fn get_pool(&self) -> &AdapterPool { &self.db }
|
||||||
|
pub fn get_client(&self) -> &AdapterClient { &self.client }
|
||||||
|
}
|
||||||
447
koucha/src/score.rs
Normal file
447
koucha/src/score.rs
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
use chrono::{DateTime, Utc, TimeDelta};
|
||||||
|
use crate::{Result};
|
||||||
|
use std::ops::{Add, Sub};
|
||||||
|
|
||||||
|
mod default {
|
||||||
|
use crate::score::SECONDS_IN_A_DAY;
|
||||||
|
|
||||||
|
pub const INITIAL_SCORE: i64 = 70;
|
||||||
|
pub const GRAVITY: i64 = -10;
|
||||||
|
pub const BOOST: i64 = 12;
|
||||||
|
pub const BOOST_FREEZE_IN_SECONDS: i64 = SECONDS_IN_A_DAY;
|
||||||
|
}
|
||||||
|
const SECONDS_IN_A_DAY: i64 = 60 * 60 * 24;
|
||||||
|
|
||||||
|
macro_rules! rich_i64 {
|
||||||
|
($name:ident) => {
|
||||||
|
#[derive(PartialOrd, PartialEq, Debug, Copy, Clone)]
|
||||||
|
pub struct $name(i64);
|
||||||
|
impl From<$name> for i64 { fn from(id: $name) -> Self { id.0 } }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! defaulting_i64 {
|
||||||
|
($name:ident, $default:expr) => {
|
||||||
|
rich_i64!($name);
|
||||||
|
impl $name {
|
||||||
|
pub fn new(value: Option<i64>) -> Self {
|
||||||
|
Self(value.unwrap_or($default))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! addable_i64s {
|
||||||
|
($lhs:ident, $rhs:ident) => {
|
||||||
|
impl Add<$rhs> for $lhs {
|
||||||
|
type Output = Self;
|
||||||
|
fn add(self, other: $rhs) -> Self::Output { Self(self.0 + other.0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaulting_i64!(Score, default::INITIAL_SCORE);
|
||||||
|
addable_i64s!(Score, Score);
|
||||||
|
addable_i64s!(Score, Boost);
|
||||||
|
impl Sub<Boost> for Score {
|
||||||
|
type Output = Self;
|
||||||
|
fn sub(self, other: Boost) -> Self::Output { Self(self.0 - other.0) }
|
||||||
|
}
|
||||||
|
addable_i64s!(Score, GravityOverDuration);
|
||||||
|
defaulting_i64!(Boost, default::BOOST);
|
||||||
|
defaulting_i64!(Gravity, default::GRAVITY);
|
||||||
|
rich_i64!(GravityOverDuration);
|
||||||
|
impl Gravity {
|
||||||
|
fn over_duration(
|
||||||
|
&self, start: DateTime<Utc>, end: DateTime<Utc>
|
||||||
|
) -> GravityOverDuration {
|
||||||
|
let elapsed_time = end.signed_duration_since(start);
|
||||||
|
GravityOverDuration(
|
||||||
|
self.0 * (elapsed_time.num_seconds() / SECONDS_IN_A_DAY)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub struct UnparsedTimedScore {
|
||||||
|
pub value: i64,
|
||||||
|
pub last_updated: DateTime<Utc>,
|
||||||
|
pub last_boosted: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnparsedTimedScore {
|
||||||
|
pub fn parse(self) -> TimedScore {
|
||||||
|
match self.last_boosted {
|
||||||
|
None => TimedScore::Decaying(DecayingScore {
|
||||||
|
value: Score(self.value),
|
||||||
|
last_updated: self.last_updated,
|
||||||
|
}),
|
||||||
|
Some(last_boosted) => TimedScore::Boosted(BoostedScore {
|
||||||
|
value: Score(self.value),
|
||||||
|
boosted_at: last_boosted,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unparse(ts: TimedScore) -> Self {
|
||||||
|
match ts {
|
||||||
|
TimedScore::Decaying(ds) => UnparsedTimedScore {
|
||||||
|
value: ds.value.into(),
|
||||||
|
last_updated: ds.last_updated,
|
||||||
|
last_boosted: None,
|
||||||
|
},
|
||||||
|
TimedScore::Boosted(bs) => UnparsedTimedScore {
|
||||||
|
value: bs.value.into(),
|
||||||
|
last_updated: bs.boosted_at,
|
||||||
|
last_boosted: Some(bs.boosted_at),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum TimedScore {
|
||||||
|
Decaying(DecayingScore),
|
||||||
|
Boosted(BoostedScore),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimedScore {
|
||||||
|
pub fn new() -> DecayingScore {
|
||||||
|
Self::new_with_initial(Score::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_initial(initial_score: Score) -> DecayingScore {
|
||||||
|
Self::new_with_initial_and_time(initial_score, Utc::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_initial_and_time(
|
||||||
|
initial: Score, time: DateTime<Utc>
|
||||||
|
) -> DecayingScore {
|
||||||
|
DecayingScore {
|
||||||
|
value: initial,
|
||||||
|
last_updated: time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_score(&self) -> Score {
|
||||||
|
match self {
|
||||||
|
Self::Decaying(s) => s.get_score(),
|
||||||
|
Self::Boosted(b) => b.get_score(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_score(self, gravity: Gravity) -> Self {
|
||||||
|
self.update_score_at_time(gravity, Utc::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_score_at_time(self, gravity: Gravity, time: DateTime<Utc>) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Decaying(d) => TimedScore::Decaying(
|
||||||
|
d.apply_gravity_to_time(gravity, time)
|
||||||
|
),
|
||||||
|
Self::Boosted(b) => {
|
||||||
|
let try_unfrozen = b.try_unfreeze_at_time(time);
|
||||||
|
match try_unfrozen {
|
||||||
|
Self::Decaying(s) => TimedScore::Decaying(
|
||||||
|
s.apply_gravity_to_time(gravity, time)
|
||||||
|
),
|
||||||
|
Self::Boosted(b) => TimedScore::Boosted(b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_decaying(self) -> Result<DecayingScore> {
|
||||||
|
match self {
|
||||||
|
Self::Decaying(s) => Ok(s),
|
||||||
|
Self::Boosted(_) => Err("Attempted to get_decaying() of a boosted score".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_boosted(self) -> Result<BoostedScore> {
|
||||||
|
match self {
|
||||||
|
Self::Decaying(_) => Err("Attempted to get_boosted() of a decaying score".into()),
|
||||||
|
Self::Boosted(b) => Ok(b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BoostedScore {
|
||||||
|
value: Score,
|
||||||
|
boosted_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct DecayingScore {
|
||||||
|
value: Score,
|
||||||
|
last_updated: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecayingScore {
|
||||||
|
fn get_score(&self) -> Score {
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_gravity_to_time(
|
||||||
|
self, gravity: Gravity, update_time: DateTime<Utc>
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
last_updated: update_time,
|
||||||
|
value: self.value + gravity.over_duration(self.last_updated, update_time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn boost(self, boost: Boost) -> BoostedScore {
|
||||||
|
self.boost_at_time(boost, Utc::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boost_at_time(self, boost: Boost, boost_time: DateTime<Utc>) -> BoostedScore {
|
||||||
|
BoostedScore {
|
||||||
|
value: self.value + boost,
|
||||||
|
boosted_at: boost_time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoostedScore {
|
||||||
|
fn get_score(&self) -> Score {
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unboost(self, boost: Boost) -> DecayingScore {
|
||||||
|
DecayingScore {
|
||||||
|
value: self.value - boost,
|
||||||
|
last_updated: self.boosted_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_unfreeze_at_time(self, update_time: DateTime<Utc>) -> TimedScore {
|
||||||
|
let boost_end = self.boosted_at + TimeDelta::seconds(default::BOOST_FREEZE_IN_SECONDS);
|
||||||
|
if boost_end < update_time {
|
||||||
|
TimedScore::Decaying(DecayingScore {
|
||||||
|
value: self.value,
|
||||||
|
last_updated: boost_end,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
TimedScore::Boosted(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::test_utils::get_datetime;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gravity_default() {
|
||||||
|
let gravity = Gravity::new(None);
|
||||||
|
assert_eq!(i64::from(gravity), default::GRAVITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boost_default() {
|
||||||
|
let boost = Boost::new(None);
|
||||||
|
assert_eq!(i64::from(boost), default::BOOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Score" Tests
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_decaying() {
|
||||||
|
let ups = UnparsedTimedScore {
|
||||||
|
value: 10,
|
||||||
|
last_updated: get_datetime(),
|
||||||
|
last_boosted: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
ups.parse().get_decaying().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_boosted() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let ups = UnparsedTimedScore {
|
||||||
|
value: 10,
|
||||||
|
last_updated: dt,
|
||||||
|
last_boosted: Some(dt),
|
||||||
|
};
|
||||||
|
|
||||||
|
ups.parse().get_boosted().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new() {
|
||||||
|
let score = TimedScore::new();
|
||||||
|
assert_eq!(score.value, Score(default::INITIAL_SCORE));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_with_values() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = TimedScore::new_with_initial_and_time(Score(10), dt);
|
||||||
|
assert_eq!(score.value, Score(10));
|
||||||
|
assert_eq!(score.last_updated, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_score_stays_decaying() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = TimedScore::Decaying(
|
||||||
|
TimedScore::new_with_initial_and_time(Score(10), dt)
|
||||||
|
);
|
||||||
|
let gravity = Gravity::new(None);
|
||||||
|
|
||||||
|
let dt2 = dt + TimeDelta::seconds(SECONDS_IN_A_DAY);
|
||||||
|
|
||||||
|
score.update_score_at_time(gravity, dt2).get_decaying().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_score_stays_frozen() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = TimedScore::Boosted(
|
||||||
|
BoostedScore { value: Score(10), boosted_at: dt }
|
||||||
|
);
|
||||||
|
let gravity = Gravity::new(None);
|
||||||
|
|
||||||
|
let dt2 = dt + TimeDelta::seconds(default::BOOST_FREEZE_IN_SECONDS);
|
||||||
|
|
||||||
|
score.update_score_at_time(gravity, dt2).get_boosted().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_score_thaws_and_decays() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = TimedScore::Boosted(
|
||||||
|
BoostedScore { value: Score(10), boosted_at: dt }
|
||||||
|
);
|
||||||
|
let gravity = Gravity::new(None);
|
||||||
|
|
||||||
|
let dt2 = dt + TimeDelta::seconds(
|
||||||
|
default::BOOST_FREEZE_IN_SECONDS + SECONDS_IN_A_DAY
|
||||||
|
);
|
||||||
|
|
||||||
|
let updated = score.update_score_at_time(gravity, dt2)
|
||||||
|
.get_decaying().unwrap();
|
||||||
|
assert!(updated.value < Score(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_decaying_success() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = TimedScore::Decaying(
|
||||||
|
TimedScore::new_with_initial_and_time(Score(10), dt)
|
||||||
|
);
|
||||||
|
|
||||||
|
score.get_decaying().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic = "Attempted to get_boosted() of a decaying score"]
|
||||||
|
fn get_boosted_failure() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = TimedScore::Decaying(
|
||||||
|
TimedScore::new_with_initial_and_time(Score(10), dt)
|
||||||
|
);
|
||||||
|
|
||||||
|
score.get_boosted().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic = "Attempted to get_decaying() of a boosted score"]
|
||||||
|
fn get_decaying_failure() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let boost = Boost::new(None);
|
||||||
|
let score = TimedScore::Boosted(
|
||||||
|
TimedScore::new_with_initial_and_time(Score(10), dt)
|
||||||
|
.boost_at_time(boost, dt)
|
||||||
|
);
|
||||||
|
|
||||||
|
score.get_decaying().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_boosted_success() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let boost = Boost::new(None);
|
||||||
|
let score = TimedScore::Boosted(
|
||||||
|
TimedScore::new_with_initial_and_time(Score(10), dt)
|
||||||
|
.boost_at_time(boost, dt)
|
||||||
|
);
|
||||||
|
|
||||||
|
score.get_boosted().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "DecayingScore" Tests
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_gravity_to_future() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = DecayingScore { value: Score(10), last_updated: dt };
|
||||||
|
let future = dt + TimeDelta::seconds(SECONDS_IN_A_DAY);
|
||||||
|
let gravity = Gravity::new(None);
|
||||||
|
|
||||||
|
let updated = score.apply_gravity_to_time(gravity, future);
|
||||||
|
|
||||||
|
assert!(updated.value < Score(10));
|
||||||
|
assert_eq!(updated.last_updated, future);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_gravity_to_past() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = DecayingScore { value: Score(10), last_updated: dt };
|
||||||
|
let past = dt - TimeDelta::seconds(SECONDS_IN_A_DAY);
|
||||||
|
let gravity = Gravity::new(None);
|
||||||
|
|
||||||
|
let updated = score.apply_gravity_to_time(gravity, past);
|
||||||
|
|
||||||
|
assert!(updated.value > Score(10));
|
||||||
|
assert_eq!(updated.last_updated, past);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boost() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = DecayingScore { value: Score(10), last_updated: dt };
|
||||||
|
let boost = Boost::new(None);
|
||||||
|
|
||||||
|
let boosted = score.boost_at_time(boost, dt);
|
||||||
|
assert_eq!(boosted.value, Score(10) + Boost(default::BOOST));
|
||||||
|
assert_eq!(boosted.boosted_at, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "BoostedScore" tests
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unboost() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = DecayingScore { value: Score(10), last_updated: dt };
|
||||||
|
let boost = Boost::new(None);
|
||||||
|
let boosted = score.boost_at_time(boost, dt);
|
||||||
|
|
||||||
|
let unboosted = boosted.unboost(boost);
|
||||||
|
assert_eq!(unboosted.value, Score(10));
|
||||||
|
assert_eq!(unboosted.last_updated, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boosted_stays_frozen() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = BoostedScore { value: Score(10), boosted_at: dt };
|
||||||
|
|
||||||
|
let last_second = dt + TimeDelta::seconds(default::BOOST_FREEZE_IN_SECONDS);
|
||||||
|
|
||||||
|
score.try_unfreeze_at_time(last_second).get_boosted().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boosted_thaws() {
|
||||||
|
let dt = get_datetime();
|
||||||
|
let score = BoostedScore { value: Score(10), boosted_at: dt };
|
||||||
|
|
||||||
|
let first_second = dt + TimeDelta::days(default::BOOST_FREEZE_IN_SECONDS+1);
|
||||||
|
|
||||||
|
score.try_unfreeze_at_time(first_second).get_decaying().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
koucha/src/test_utils.rs
Normal file
53
koucha/src/test_utils.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
#![cfg(test)]
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Adapter,
|
||||||
|
AdapterBuilder,
|
||||||
|
AdapterPool,
|
||||||
|
db::{
|
||||||
|
Channel,
|
||||||
|
Feed,
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
use reqwest::Url;
|
||||||
|
use chrono::{
|
||||||
|
Utc,
|
||||||
|
TimeZone,
|
||||||
|
DateTime
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const FEED1: &str = "https://example.com/feed";
|
||||||
|
pub const FEED2: &str = "https://example2.com/feed";
|
||||||
|
pub const USERNAME: &str = "Alice";
|
||||||
|
pub const USERNAME2: &str = "Bob";
|
||||||
|
pub const FEED_TITLE: &str = "My Feed!";
|
||||||
|
pub const FEED_TITLE2: &str = "My Second Feed!";
|
||||||
|
pub const CHANNEL_TITLE: &str = "My Channel!";
|
||||||
|
pub const CHANNEL_DESC: &str = "My Channel's description";
|
||||||
|
pub const ITEM_GUID: &str = "item-guid";
|
||||||
|
pub const ITEM_GUID2: &str = "item-guid2";
|
||||||
|
pub const ITEM_TITLE: &str = "My Item!";
|
||||||
|
pub const ITEM_DESC: &str = "My Item's description";
|
||||||
|
pub const ITEM_CONT: &str = "The content of my Item";
|
||||||
|
|
||||||
|
|
||||||
|
pub fn get_datetime() -> DateTime<Utc> {
|
||||||
|
Utc.with_ymd_and_hms(2020,1,1,0,0,0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn setup_adapter() -> Adapter {
|
||||||
|
AdapterBuilder::new()
|
||||||
|
.database_url("sqlite::memory:")
|
||||||
|
.create().await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn setup_channel(pool: &AdapterPool) -> Channel {
|
||||||
|
let url = Url::parse(FEED1).unwrap();
|
||||||
|
Channel::get_or_create(pool, url).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn setup_feed(pool: &AdapterPool) -> Feed {
|
||||||
|
let user = User::create(pool, USERNAME).await.unwrap();
|
||||||
|
Feed::create(pool, user.id(), FEED_TITLE).await.unwrap()
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue