Skip to content

Commit

Permalink
feat: categories/labels (#98)
Browse files Browse the repository at this point in the history
  • Loading branch information
micielski authored Aug 24, 2024
1 parent 308372d commit ac00650
Show file tree
Hide file tree
Showing 30 changed files with 686 additions and 183 deletions.
6 changes: 6 additions & 0 deletions rm-config/defaults/categories.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Example category:
# [[category]]
# name = "Classical Music" # required
# icon = "[M]" # optional, default: ""
# default_dir = "/mnt/Music/Classical" # optional, default: transmission's default
# color = "Green" # optional, default: "White"
7 changes: 6 additions & 1 deletion rm-config/defaults/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,14 @@ free_space_refresh = 10
# Available fields:
# Id, Name, SizeWhenDone, Progress, Eta, DownloadRate, UploadRate, DownloadDir,
# Padding, UploadRatio, UploadedEver, AddedDate, ActivityDate, PeersConnected
# SmallStatus
# SmallStatus, Category, CategoryIcon
headers = ["Name", "SizeWhenDone", "Progress", "Eta", "DownloadRate", "UploadRate"]

# Whether to insert category icon into name as declared in categories.toml.
# An alternative to inserting category's icon into torrent's name is adding a
# CategoryIcon header into your headers.
category_icon_insert_into_name = true

[search_tab]
# If you uncomment this, providers won't be automatically added in future
# versions of Rustmission.
Expand Down
1 change: 1 addition & 0 deletions rm-config/defaults/keymap.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ keybindings = [
keybindings = [
{ on = "a", action = "AddMagnet" },
{ on = "m", action = "MoveTorrent" },
{ on = "c", action = "ChangeCategory" },
{ on = "p", action = "Pause" },
{ on = "f", action = "ShowFiles" },
{ on = "s", action = "ShowStats" },
Expand Down
99 changes: 99 additions & 0 deletions rm-config/src/categories.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
use std::{collections::HashMap, io::ErrorKind, path::PathBuf, sync::OnceLock};

use anyhow::{Context, Result};
use ratatui::style::Color;
use serde::Deserialize;

use crate::utils::{self, ConfigFetchingError};

#[derive(Deserialize)]
pub struct CategoriesConfig {
#[serde(default)]
pub categories: Vec<Category>,
#[serde(skip)]
pub map: HashMap<String, Category>,
#[serde(skip)]
pub max_name_len: u8,
#[serde(skip)]
pub max_icon_len: u8,
}

#[derive(Deserialize, Clone)]
pub struct Category {
pub name: String,
pub icon: String,
pub color: Color,
pub default_dir: String,
}

impl CategoriesConfig {
pub(crate) const FILENAME: &'static str = "categories.toml";
pub const DEFAULT_CONFIG: &'static str = include_str!("../defaults/categories.toml");

pub(crate) fn init() -> Result<Self> {
match utils::fetch_config::<Self>(Self::FILENAME) {
Ok(mut config) => {
config.after_init();
Ok(config)
}
Err(e) => match e {
ConfigFetchingError::Io(e) if e.kind() == ErrorKind::NotFound => {
let mut config =
utils::put_config::<Self>(Self::DEFAULT_CONFIG, Self::FILENAME)?;
config.after_init();
Ok(config)
}
ConfigFetchingError::Toml(e) => Err(e).with_context(|| {
format!(
"Failed to parse config located at {:?}",
utils::get_config_path(Self::FILENAME)
)
}),
_ => anyhow::bail!(e),
},
}
}

pub(crate) fn path() -> &'static PathBuf {
static PATH: OnceLock<PathBuf> = OnceLock::new();
PATH.get_or_init(|| utils::get_config_path(Self::FILENAME))
}
}

impl CategoriesConfig {
pub fn is_empty(&self) -> bool {
self.categories.is_empty()
}

fn after_init(&mut self) {
self.populate_hashmap();
self.set_lengths();
}

fn populate_hashmap(&mut self) {
for category in &self.categories {
self.map.insert(category.name.clone(), category.clone());
}
}

fn set_lengths(&mut self) {
let mut max_icon_len = 0u8;
let mut max_name_len = 0u8;

for category in &self.categories {
let name_len = u8::try_from(category.name.chars().count()).unwrap_or(u8::MAX);
let icon_len = u8::try_from(category.icon.chars().count()).unwrap_or(u8::MAX);

if name_len > max_name_len {
max_name_len = name_len;
}

if icon_len > max_icon_len {
max_icon_len = icon_len
}
}

self.max_name_len = max_name_len;
self.max_icon_len = max_icon_len;
}
}
3 changes: 3 additions & 0 deletions rm-config/src/keymap/actions/torrents_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub enum TorrentsAction {
DeleteWithoutFiles,
ShowFiles,
ShowStats,
ChangeCategory,
}

impl UserAction for TorrentsAction {
Expand All @@ -24,6 +25,7 @@ impl UserAction for TorrentsAction {
TorrentsAction::DeleteWithoutFiles => "delete without files",
TorrentsAction::ShowFiles => "show files",
TorrentsAction::ShowStats => "show statistics",
TorrentsAction::ChangeCategory => "change category",
}
}
}
Expand All @@ -38,6 +40,7 @@ impl From<TorrentsAction> for Action {
TorrentsAction::DeleteWithoutFiles => Action::DeleteWithoutFiles,
TorrentsAction::ShowFiles => Action::ShowFiles,
TorrentsAction::ShowStats => Action::ShowStats,
TorrentsAction::ChangeCategory => Action::ChangeCategory,
}
}
}
9 changes: 8 additions & 1 deletion rm-config/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
pub mod categories;
pub mod keymap;
pub mod main_config;
mod utils;

use std::{path::PathBuf, sync::LazyLock};

use anyhow::Result;
use categories::CategoriesConfig;
use keymap::KeymapConfig;
use main_config::MainConfig;

Expand All @@ -22,22 +24,26 @@ pub struct Config {
pub search_tab: main_config::SearchTab,
pub icons: main_config::Icons,
pub keybindings: KeymapConfig,
pub categories: CategoriesConfig,
pub directories: Directories,
}

pub struct Directories {
pub main_path: &'static PathBuf,
pub keymap_path: &'static PathBuf,
pub categories_path: &'static PathBuf,
}

impl Config {
fn init() -> Result<Self> {
let main_config = MainConfig::init()?;
let keybindings = KeymapConfig::init()?;
let categories = CategoriesConfig::init()?;

let directories = Directories {
main_path: MainConfig::path(),
keymap_path: KeymapConfig::path(),
categories_path: CategoriesConfig::path(),
};

Ok(Self {
Expand All @@ -46,7 +52,8 @@ impl Config {
torrents_tab: main_config.torrents_tab,
search_tab: main_config.search_tab,
icons: main_config.icons,
keybindings: keybindings.clone(),
keybindings,
categories,
directories,
})
}
Expand Down
7 changes: 7 additions & 0 deletions rm-config/src/main_config/torrents_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ use serde::Deserialize;
pub struct TorrentsTab {
#[serde(default = "default_headers")]
pub headers: Vec<Header>,
#[serde(default = "default_true")]
pub category_icon_insert_into_name: bool,
}

fn default_true() -> bool {
true
}

fn default_headers() -> Vec<Header> {
Expand All @@ -22,6 +28,7 @@ impl Default for TorrentsTab {
fn default() -> Self {
Self {
headers: default_headers(),
category_icon_insert_into_name: default_true(),
}
}
}
46 changes: 37 additions & 9 deletions rm-main/src/transmission/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ use rm_shared::action::UpdateAction;
const FAILED_TO_COMMUNICATE: &str = "Failed to communicate with Transmission";

pub enum TorrentAction {
// Add a torrent with this Magnet/URL, Directory
Add(String, Option<String>),
// Add a torrent with this Magnet/URL, Directory, Label (Category)
Add(String, Option<String>, Option<String>),
// Stop Torrents with these given IDs
Stop(Vec<Id>),
// Start Torrents with these given IDs
Start(Vec<Id>),
// Torrent ID, Directory to move to
Move(Vec<Id>, String),
// Torrent ID, Category to set
ChangeCategory(Vec<Id>, String),
// Delete Torrents with these given IDs (without files)
DelWithoutFiles(Vec<Id>),
// Delete Torrents with these given IDs (with files)
Expand Down Expand Up @@ -51,30 +53,34 @@ pub async fn action_handler(
) {
while let Some(action) = trans_rx.recv().await {
match action {
TorrentAction::Add(ref url, directory) => {
TorrentAction::Add(ref url, directory, label) => {
let formatted = {
if url.starts_with("www") {
format!("https://{url}")
} else {
url.to_string()
}
};

let label = label.map(|label| vec![label]);

let args = TorrentAddArgs {
filename: Some(formatted),
download_dir: directory,
labels: label,
..Default::default()
};
match client.torrent_add(args).await {
Ok(_) => {
action_tx.send(UpdateAction::TaskSuccess).unwrap();
action_tx.send(UpdateAction::StatusTaskSuccess).unwrap();
}
Err(err) => {
let msg = format!("Failed to add torrent with URL/Path: \"{url}\"");
let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err);
action_tx
.send(UpdateAction::Error(Box::new(err_message)))
.unwrap();
action_tx.send(UpdateAction::TaskFailure).unwrap();
action_tx.send(UpdateAction::StatusTaskFailure).unwrap();
}
}
}
Expand Down Expand Up @@ -104,27 +110,27 @@ pub async fn action_handler(
}
TorrentAction::DelWithFiles(ids) => {
match client.torrent_remove(ids.clone(), true).await {
Ok(_) => action_tx.send(UpdateAction::TaskSuccess).unwrap(),
Ok(_) => action_tx.send(UpdateAction::StatusTaskSuccess).unwrap(),
Err(err) => {
let msg = format!("Failed to remove torrents with these IDs: {:?}", ids);
let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err);
action_tx
.send(UpdateAction::Error(Box::new(err_message)))
.unwrap();
action_tx.send(UpdateAction::TaskFailure).unwrap();
action_tx.send(UpdateAction::StatusTaskFailure).unwrap();
}
}
}
TorrentAction::DelWithoutFiles(ids) => {
match client.torrent_remove(ids.clone(), false).await {
Ok(_) => action_tx.send(UpdateAction::TaskSuccess).unwrap(),
Ok(_) => action_tx.send(UpdateAction::StatusTaskSuccess).unwrap(),
Err(err) => {
let msg = format!("Failed to remove torrents with these IDs: {:?}", ids);
let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err);
action_tx
.send(UpdateAction::Error(Box::new(err_message)))
.unwrap();
action_tx.send(UpdateAction::TaskFailure).unwrap();
action_tx.send(UpdateAction::StatusTaskFailure).unwrap();
}
}
}
Expand Down Expand Up @@ -203,6 +209,28 @@ pub async fn action_handler(
}
}
}
TorrentAction::ChangeCategory(ids, category) => {
let labels = if category.is_empty() {
vec![]
} else {
vec![category]
};
let args = TorrentSetArgs {
labels: Some(labels),
..Default::default()
};
match client.torrent_set(args, Some(ids)).await {
Ok(_) => action_tx.send(UpdateAction::StatusTaskSuccess).unwrap(),
Err(err) => {
let msg = "Failed to set category";
let err_message = ErrorMessage::new(FAILED_TO_COMMUNICATE, msg, err);
action_tx
.send(UpdateAction::Error(Box::new(err_message)))
.unwrap();
action_tx.send(UpdateAction::StatusTaskFailure).unwrap();
}
}
}
}
}
}
1 change: 1 addition & 0 deletions rm-main/src/transmission/fetchers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ pub async fn torrents(ctx: app::Ctx) {
TorrentGetField::PeersConnected,
TorrentGetField::Error,
TorrentGetField::ErrorString,
TorrentGetField::Labels,
];
let (torrents_tx, torrents_rx) = oneshot::channel();
ctx.send_torrent_action(TorrentAction::GetTorrents(fields, torrents_tx));
Expand Down
Loading

0 comments on commit ac00650

Please sign in to comment.