make project public
This commit is contained in:
commit
c25e8d57c1
10 changed files with 2338 additions and 0 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* @troylusty
|
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
dev-dependencies:
|
||||
patterns:
|
||||
- "*"
|
28
.github/workflows/rust.yml
vendored
Normal file
28
.github/workflows/rust.yml
vendored
Normal file
|
@ -0,0 +1,28 @@
|
|||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
BIN_NAME: target/release/packard
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --release
|
||||
|
||||
- name: Upload release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ env.BIN_NAME }}
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
2022
Cargo.lock
generated
Normal file
2022
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
31
Cargo.toml
Normal file
31
Cargo.toml
Normal file
|
@ -0,0 +1,31 @@
|
|||
[package]
|
||||
name = "packard"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
description = "A terminal based feed checker."
|
||||
authors = ["Troy Lusty <hello@troylusty.com>"]
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.39"
|
||||
clap = { version = "4.5.26", features = ["derive"] }
|
||||
reqwest = "0.12.12"
|
||||
rss = "2.0.11"
|
||||
serde = { version = "1.0.217", features = ["derive"] }
|
||||
tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread"] }
|
||||
toml = "0.8.19"
|
||||
xdg = "2.5.2"
|
||||
futures = "0.3.31"
|
||||
indicatif = "0.17.9"
|
||||
colored = "3.0.0"
|
||||
terminal-link = "0.1.0"
|
||||
emojis = "0.6.4"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
panic = "abort"
|
55
README.md
Normal file
55
README.md
Normal file
|
@ -0,0 +1,55 @@
|
|||
<div align="center">
|
||||
<h1>️📰 Packard</h1>
|
||||
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/troylusty/packard">
|
||||
<h5>Packard is a simple RSS aggregator meant to allow you to take a quick glance at what's occurring in topics you care about.</h5>
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
There are now several options available to be configured, with more on their way. As of now Packard's config file should be placed within `$HOME/.config/packard` and formatted in TOML.
|
||||
|
||||
1. `count`
|
||||
|
||||
This can be adjusted either by using the `-c` flag when running the program, or by setting it in the config. Note that if `count` is set within the config file and then the program is run with the `-c` flag, this flag will overwrite the config file. Unlike other options, there is a default value for `count` which is set to 8.
|
||||
|
||||
2. `skip_amount`
|
||||
|
||||
If you wish to view older articles without having to scroll through newer ones to get there, use `-s` or the `skip_amount` option to do that. This will skip the amount specified whilst still returning your specified `count` amount of articles. By default this option is set to 0.
|
||||
|
||||
3. `selected_list`
|
||||
|
||||
Using the configured lists mentioned above, this option selects which of those you want to see. This can be set using the `selected_list` option in the config or by running the program using the `-l` flag. Again, using the flag will overwrite the option if it is specified in the config.
|
||||
|
||||
4. `[lists]`
|
||||
|
||||
Lists can be configured in Packard so that they can be easily swapped out depending on what you want to see. Please see the example config for reference.
|
||||
|
||||
### Example config.toml
|
||||
|
||||
```toml
|
||||
count = 8
|
||||
#skip_amount = 4
|
||||
selected_list = "personal"
|
||||
|
||||
[lists]
|
||||
personal = [
|
||||
"https://troylusty.com/rss.xml",
|
||||
"https://mitchellh.com/feed.xml"
|
||||
]
|
||||
news = [
|
||||
"https://www.gamingonlinux.com/article_rss.php?newsonly",
|
||||
"https://store.steampowered.com/feeds/news.xml",
|
||||
]
|
||||
```
|
||||
|
||||
## Interaction
|
||||
|
||||
After running Packard with your configured settings, the parsed results can be opened in your default browser however your terminal allows for opening URLs. For example the keybind for this with [Foot](https://codeberg.org/dnkl/foot#urls) is `ctrl` + `shift` + `o`.
|
||||
|
||||
Currently no keyboard interaction is implemented. To get around this you can pipe the output of Packard into a tool like `less` like so: `packard -c 12 -l news -s 3 | less`. Be aware, this will remove any text formatting that has been applied.
|
||||
|
||||
## Notes
|
||||
|
||||
For anyone who may or may not look at this, I am very new to Rust with this being my first project making use of it. So expect mistakes.
|
10
shell.nix
Normal file
10
shell.nix
Normal file
|
@ -0,0 +1,10 @@
|
|||
{pkgs ? import <nixpkgs> {}}:
|
||||
pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs.buildPackages; [
|
||||
cargo
|
||||
rustc
|
||||
rustfmt
|
||||
pkg-config
|
||||
openssl
|
||||
];
|
||||
}
|
179
src/main.rs
Normal file
179
src/main.rs
Normal file
|
@ -0,0 +1,179 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use clap::Parser;
|
||||
use colored::Colorize;
|
||||
use emojis;
|
||||
use futures::future::join_all;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use reqwest::get;
|
||||
use rss::Channel;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use terminal_link::Link;
|
||||
use tokio;
|
||||
use toml;
|
||||
use xdg::BaseDirectories;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Cli {
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
#[arg(short, long)]
|
||||
count: Option<u8>,
|
||||
#[arg(short = 'l', long)]
|
||||
selected_list: Option<String>,
|
||||
#[arg(short, long)]
|
||||
skip_amount: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Config {
|
||||
count: Option<u8>,
|
||||
skip_amount: Option<u8>,
|
||||
selected_list: Option<String>,
|
||||
lists: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FeedItem {
|
||||
title: String,
|
||||
description: String,
|
||||
link: String,
|
||||
pub_date: DateTime<Utc>,
|
||||
}
|
||||
|
||||
async fn fetch_rss(url: &str, pb: &ProgressBar) -> Result<Channel, Box<dyn Error>> {
|
||||
let response = get(url).await?.text().await?;
|
||||
let channel = Channel::read_from(response.as_bytes())?;
|
||||
pb.inc(1);
|
||||
pb.set_message(format!("Processing: {}", channel.title));
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
fn parse_feed(channel: &Channel) -> Vec<FeedItem> {
|
||||
channel
|
||||
.items()
|
||||
.iter()
|
||||
.map(|item| FeedItem {
|
||||
title: item.title().unwrap_or("No title").to_string(),
|
||||
description: item.description().unwrap_or("No description").to_string(),
|
||||
link: item.link().unwrap_or("No link").to_string(),
|
||||
pub_date: item
|
||||
.pub_date()
|
||||
.and_then(|date_str| DateTime::parse_from_rfc2822(date_str).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|| Utc::now()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_config() -> Config {
|
||||
let xdg_dirs = BaseDirectories::new().expect("Failed to get XDG directories");
|
||||
let config_path = xdg_dirs
|
||||
.place_config_file("packard/config.toml")
|
||||
.expect("Failed to determine config file path");
|
||||
|
||||
if !config_path.exists() {
|
||||
eprintln!("Configuration file not found at {:?}", config_path);
|
||||
}
|
||||
|
||||
let config_content = fs::read_to_string(&config_path).expect("Failed to read config file");
|
||||
let config: Config = toml::de::from_str(&config_content).expect("Failed to parse TOML");
|
||||
config
|
||||
}
|
||||
|
||||
fn trim_chars(input: &str) -> String {
|
||||
let trimmed: String = input.chars().take(256).collect();
|
||||
|
||||
if trimmed.len() < input.len() {
|
||||
format!("{}...", trimmed)
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_tasks(sources: Vec<String>, count: u8, skip: u8, pb: &ProgressBar) -> Vec<FeedItem> {
|
||||
let fetch_futures: Vec<_> = sources.iter().map(|url| fetch_rss(url, &pb)).collect();
|
||||
|
||||
let channels = join_all(fetch_futures).await;
|
||||
|
||||
let mut all_items = Vec::new();
|
||||
|
||||
for channel in channels.into_iter().filter_map(Result::ok) {
|
||||
let feed_items = parse_feed(&channel);
|
||||
all_items.extend(feed_items);
|
||||
}
|
||||
|
||||
all_items.sort_by(|a, b| b.pub_date.cmp(&a.pub_date));
|
||||
all_items.truncate((count + skip).into());
|
||||
let removed_items = all_items.split_off(skip.into());
|
||||
|
||||
removed_items
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let config = validate_config();
|
||||
let args = Cli::parse();
|
||||
|
||||
if args.verbose {
|
||||
println!("{:?}", args);
|
||||
println!("Selected list: {:?}", config.selected_list);
|
||||
println!("Items: {:?}", config.lists);
|
||||
}
|
||||
|
||||
let count: u8 = if args.count.is_some() {
|
||||
args.count.expect("Count flag wrong?")
|
||||
} else if config.count.is_some() {
|
||||
config.count.expect("Unable to use count value from config")
|
||||
} else {
|
||||
8
|
||||
};
|
||||
|
||||
let skip_amount: u8 = if args.skip_amount.is_some() {
|
||||
args.skip_amount.expect("Skip amount flag wrong?")
|
||||
} else if config.skip_amount.is_some() {
|
||||
config
|
||||
.skip_amount
|
||||
.expect("Unable to use skip amount value from config")
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let list: String = if args.selected_list.is_some() {
|
||||
args.selected_list
|
||||
.expect("Error getting selected list from flag")
|
||||
} else if config.selected_list.is_some() {
|
||||
config
|
||||
.selected_list
|
||||
.expect("Need to specify a selected list")
|
||||
} else {
|
||||
panic!("Need to set selected list")
|
||||
};
|
||||
|
||||
if let Some(values) = config.lists.get(&list) {
|
||||
let pb = indicatif::ProgressBar::new(12);
|
||||
pb.set_style(
|
||||
ProgressStyle::with_template("[{elapsed}] {bar:40.green/black} {msg}").unwrap(),
|
||||
);
|
||||
|
||||
let all_items = run_tasks(values.to_vec(), count, skip_amount, &pb).await;
|
||||
pb.finish_and_clear();
|
||||
|
||||
for item in all_items {
|
||||
println!(
|
||||
"{} {}\n{}\n{}\n",
|
||||
emojis::get_by_shortcode("newspaper").unwrap(),
|
||||
Link::new(&item.title, &item.link),
|
||||
trim_chars(&item.description).dimmed().italic(),
|
||||
item.pub_date.to_string().dimmed().bold()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
panic!("Have you specified your site lists and chosen one?");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
Loading…
Add table
Reference in a new issue