Initial commit
This commit is contained in:
commit
8da5ff8f8b
|
@ -0,0 +1,2 @@
|
|||
/.vscode
|
||||
/target
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "sharextended"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
eyre = "0.6.8"
|
||||
toml = "0.5.9"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0.104", features = ["derive"] }
|
||||
serde_json = "1.0.86"
|
||||
clap = { version = "4.0.17", features = ["derive"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
ansi_term = "0.12.1"
|
||||
indicatif = "0.17.1"
|
||||
dialoguer = "0.10.2"
|
||||
tinyfiledialogs = "3.9.1"
|
||||
open = "3.0.3"
|
||||
lazy_static = "1.4.0"
|
||||
console = "0.15.2"
|
||||
futures = "0.3.24"
|
||||
directories = "4.0.1"
|
|
@ -0,0 +1,33 @@
|
|||
# Delete ShareX online hosted images
|
||||
|
||||
A simple program that deletes all images that are hosted on online image hosting by sending POST requests to all DeletionURL's.
|
||||
|
||||
## Usage
|
||||
|
||||
```ps1
|
||||
./sharextended.exe --help
|
||||
```
|
||||
|
||||
### TODO
|
||||
- Unwrap and expect should be replaced with proper error handling.
|
||||
- Add GitHub action to build and release binaries (Windows only, since ShareX is Windows only).
|
||||
|
||||
## Motivation
|
||||
|
||||
### Why Rust?
|
||||
I wanted to try out the most loved language for myself.
|
||||
While it's a bit hard to get started with due to these new concepts, like ownership, borrowing, and lifetimes, I really like it.
|
||||
It's a very powerful language with great error messages and many useful crates. I'm looking forward to learn more about it.
|
||||
|
||||
### Code
|
||||
Watching video's from [No Boilerplate](https://www.youtube.com/c/NoBoilerplate) motivated me to try out Rust. On advise about your Rust toolkit, he suggested the following Clippy lints:
|
||||
|
||||
```ps1
|
||||
cargo clippy --fix -- -W clippy::pedantic -W clippy::nursery -W clippy::unwrap_used -W clippy::expect_used
|
||||
```
|
||||
|
||||
For now I omit the `clippy::unwrap_used`. It's not great to use unwrap as often as I do, but I use it for getting things working. I'm sure I'll get better at error handling in the future.
|
||||
|
||||
I tried to store the static strings in the `Cargo.toml`, but I figured it's not meant to live outside the code. Also tried to resolve `%USERPROFILE%` in the code but solved it with the `directories` crate.
|
||||
|
||||
I used https://transform.tools/json-to-rust-serde as a reference for the JSON structure.
|
|
@ -0,0 +1,2 @@
|
|||
# cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo clippy --fix -- -W clippy::pedantic -W clippy::nursery -W clippy::unwrap_used -W clippy::expect_used
|
|
@ -0,0 +1,63 @@
|
|||
mod mass_delete;
|
||||
mod util;
|
||||
|
||||
use clap::Parser;
|
||||
use dialoguer::{theme::ColorfulTheme, Select};
|
||||
use eyre::Result;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = util::Args::parse();
|
||||
let command = args.command;
|
||||
|
||||
match command {
|
||||
Some(util::Command::MassDelete { path }) => {
|
||||
mass_delete::handler(path).await.unwrap();
|
||||
}
|
||||
None => {
|
||||
show_menu().await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn show_menu() -> Result<()> {
|
||||
loop {
|
||||
println!();
|
||||
let menu_response: usize = Select::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt("Pick a option (use arrow keys to select, enter to confirm)")
|
||||
.items(&[
|
||||
"1. Mass delete online history items",
|
||||
"2. Open ShareX Website",
|
||||
"3. View source code (GitHub)",
|
||||
"Exit",
|
||||
])
|
||||
.default(0)
|
||||
.interact()
|
||||
.unwrap();
|
||||
println!();
|
||||
|
||||
handle_option(menu_response).await?;
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_option(number: usize) -> Result<()> {
|
||||
match number {
|
||||
0 => {
|
||||
mass_delete::handler(None).await?;
|
||||
}
|
||||
1 => {
|
||||
util::open_webpage(util::SHAREX_URL.to_string().as_str());
|
||||
}
|
||||
2 => {
|
||||
util::open_webpage(util::REPO_URL.to_string().as_str());
|
||||
}
|
||||
3 => {
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {
|
||||
println!("Invalid option");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -0,0 +1,250 @@
|
|||
use crate::util;
|
||||
|
||||
use ansi_term::{self, Colour};
|
||||
use clap::Parser;
|
||||
use directories::{BaseDirs, UserDirs};
|
||||
use eyre::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::thread;
|
||||
use std::{
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HistoryItem {
|
||||
#[serde(rename = "FileName")]
|
||||
pub file_name: String,
|
||||
#[serde(rename = "FilePath")]
|
||||
pub file_path: Option<String>,
|
||||
#[serde(rename = "DateTime")]
|
||||
pub date_time: String,
|
||||
#[serde(rename = "Type")]
|
||||
pub type_field: String,
|
||||
#[serde(rename = "Host")]
|
||||
pub host: String,
|
||||
#[serde(rename = "Tags")]
|
||||
pub tags: Option<Tags>,
|
||||
#[serde(rename = "URL")]
|
||||
pub url: Option<String>,
|
||||
#[serde(rename = "ThumbnailURL")]
|
||||
pub thumbnail_url: Option<String>,
|
||||
#[serde(rename = "DeletionURL")]
|
||||
pub deletion_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Tags {
|
||||
#[serde(rename = "WindowTitle")]
|
||||
pub window_title: Option<String>,
|
||||
#[serde(rename = "ProcessName")]
|
||||
pub process_name: String,
|
||||
}
|
||||
|
||||
pub async fn handler(pathflag: Option<PathBuf>) -> Result<()> {
|
||||
// TODO
|
||||
// If a pathflag is Some() it means it's passed via a flag, use that path and skip the prompting
|
||||
// Otherwise, prompt the user to select a default path (depending on type of installation), or tinyfiledialog for a path, or manual input
|
||||
|
||||
let path = match pathflag {
|
||||
Some(pathflag) => pathflag,
|
||||
None => get_path_input(),
|
||||
};
|
||||
|
||||
let file = prompt_history_file(&path);
|
||||
|
||||
if !Path::new(&file).exists() {
|
||||
eprintln!(
|
||||
"{} The directory does not exist. Given path: {:?}",
|
||||
Colour::Red.paint("Error:"),
|
||||
&file
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let history_urls = get_history_urls(&file);
|
||||
|
||||
delete_urls(history_urls).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_history_file(path: &Path) -> PathBuf {
|
||||
// TODO: Use dialoguer select to prompt the user to select the history file, either with tinyfiledialogs or manual input
|
||||
|
||||
return tinyfiledialogs::open_file_dialog(
|
||||
"Choose where sharex history is stored",
|
||||
path.to_str().unwrap(),
|
||||
Some((&["History.json", "*.json"], "History.json")),
|
||||
)
|
||||
.map_or_else(
|
||||
|| {
|
||||
eprintln!("No file selected, exiting...");
|
||||
std::process::exit(1);
|
||||
},
|
||||
PathBuf::from,
|
||||
);
|
||||
}
|
||||
|
||||
fn get_history_urls(path: &PathBuf) -> Result<Vec<String>> {
|
||||
let spinner = util::setup_spinner("Reading and parsing JSON...");
|
||||
|
||||
let history_json = read_history_json(&path)?;
|
||||
let history_items = parse_history_json(&history_json)?;
|
||||
let deletion_urls = get_deletion_urls(&history_items);
|
||||
|
||||
// spinner.finish_and_clear();
|
||||
spinner.finish_with_message(format!("Done! {} items found", deletion_urls.len()));
|
||||
Ok(deletion_urls)
|
||||
}
|
||||
|
||||
fn get_path_input() -> PathBuf {
|
||||
let args = util::Args::parse();
|
||||
let path = match args.command {
|
||||
Some(util::Command::MassDelete { path }) => path,
|
||||
None => None,
|
||||
};
|
||||
|
||||
let default_history_path = get_default_history_path();
|
||||
|
||||
match path {
|
||||
Some(path) => path,
|
||||
None => default_history_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_default_history_path() -> PathBuf {
|
||||
let document_directory: PathBuf = if let Some(user_dirs) = UserDirs::new() {
|
||||
user_dirs.document_dir().unwrap().to_path_buf()
|
||||
} else {
|
||||
BaseDirs::new().unwrap().home_dir().join("Documents")
|
||||
};
|
||||
let default_history_path: PathBuf = document_directory.join("ShareX").join("History.json");
|
||||
|
||||
default_history_path
|
||||
}
|
||||
|
||||
fn read_history_json(path: &PathBuf) -> Result<String> {
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
|
||||
// Since ShareX history is invalid JSON we add brackets to make it valid JSON
|
||||
contents = format!("[{}]", contents);
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
fn parse_history_json(json: &str) -> Result<Vec<HistoryItem>, serde_json::Error> {
|
||||
let history_items: Vec<HistoryItem> = serde_json::from_str(json)?;
|
||||
Ok(history_items)
|
||||
}
|
||||
|
||||
fn get_deletion_urls(items: &[HistoryItem]) -> Vec<String> {
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| item.deletion_url.is_some() && item.deletion_url != Some("".to_string()))
|
||||
.map(|item| item.deletion_url.clone().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn delete_urls(deletion_urls: Result<Vec<String>>) -> Result<()> {
|
||||
let deletion_urls = deletion_urls?;
|
||||
let progress_bar = util::setup_progressbar(deletion_urls.len());
|
||||
// progress_bar.enable_steady_tick(Duration::from_millis(500)); // This only visually updates the ticker once every 500ms instead of when the tick occurs
|
||||
|
||||
// ? Maybe use Rayon to parallelize the requests and run them through public proxies to prevent rate limiting?
|
||||
for url in deletion_urls {
|
||||
let (remaining, limit, reset) = send_deletion(&url).await?;
|
||||
println!(
|
||||
"Remaining: {} Limit: {} Reset: {}",
|
||||
Colour::Green.paint(remaining),
|
||||
Colour::Yellow.paint(limit),
|
||||
Colour::Red.paint(reset)
|
||||
);
|
||||
progress_bar.inc(1);
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
// let client = reqwest::Client::new();
|
||||
|
||||
// let mut futures = Vec::new();
|
||||
|
||||
// for url in deletion_urls {
|
||||
// let future = client.delete(&url).send();
|
||||
// futures.push(future);
|
||||
// }
|
||||
|
||||
// let results = futures::future::join_all(futures).await;
|
||||
|
||||
// for result in results {
|
||||
// match result {
|
||||
// Ok(response) => {
|
||||
// if response.status().is_success() {
|
||||
// println!("Deleted {}", response.url());
|
||||
// } else {
|
||||
// eprintln!("Failed to delete {}", response.url());
|
||||
// }
|
||||
// }
|
||||
// Err(e) => {
|
||||
// eprintln!("Failed to delete {}", e);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
progress_bar.finish_with_message("Done!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_deletion(url: &str) -> Result<(String, String, String)> {
|
||||
let client = reqwest::Client::new();
|
||||
let params = [("confirm", true)];
|
||||
let resp = client.post(url).form(¶ms).send().await?;
|
||||
|
||||
println!("{:#?}", resp);
|
||||
|
||||
match resp.status() {
|
||||
reqwest::StatusCode::OK => {
|
||||
println!("OK");
|
||||
}
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS => {
|
||||
println!("TOO MANY REQUESTS");
|
||||
}
|
||||
reqwest::StatusCode::BAD_GATEWAY => {
|
||||
println!("BAD GATEWAY");
|
||||
}
|
||||
_ => {
|
||||
println!("Not OK");
|
||||
}
|
||||
}
|
||||
|
||||
// I don't understand Rust enough so the stuff below looks kinda cursed
|
||||
let headers = resp.headers().clone();
|
||||
let remaining = headers
|
||||
.get("x-post-rate-limit-remaining")
|
||||
.unwrap()
|
||||
.to_str()?
|
||||
.to_owned();
|
||||
let limit = headers
|
||||
.get("x-post-rate-limit-limit")
|
||||
.unwrap()
|
||||
.to_str()?
|
||||
.to_owned();
|
||||
let reset = headers
|
||||
.get("x-post-rate-limit-Reset")
|
||||
.unwrap()
|
||||
.to_str()?
|
||||
.to_owned();
|
||||
|
||||
println!(
|
||||
"Remaining: {} Limit: {} Reset: {}",
|
||||
Colour::Green.paint(&remaining),
|
||||
Colour::Yellow.paint(&limit),
|
||||
Colour::Red.paint(&reset)
|
||||
);
|
||||
|
||||
print!("{:?}", headers);
|
||||
|
||||
Ok((remaining, limit, reset))
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
mod mass_delete;
|
||||
mod util;
|
|
@ -0,0 +1,49 @@
|
|||
use clap::Parser;
|
||||
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use lazy_static::lazy_static;
|
||||
use std::path::PathBuf;
|
||||
use std::{borrow::Cow, time::Duration};
|
||||
|
||||
lazy_static! {
|
||||
pub static ref SHAREX_URL: &'static str = "https://getsharex.com/";
|
||||
pub static ref REPO_URL: &'static str = "https://github.com/IndyV/sharextend_cli";
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(version = "1.0", author = "IndyV", about = "Delete all ShareX online history items", long_about = None)]
|
||||
pub struct Args {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Clone, Debug)]
|
||||
pub enum Command {
|
||||
MassDelete {
|
||||
#[clap(short, long)]
|
||||
path: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn setup_spinner(msg: impl Into<Cow<'static, str>>) -> ProgressBar {
|
||||
let spinner: ProgressBar = ProgressBar::new_spinner().with_message(msg);
|
||||
spinner.enable_steady_tick(Duration::from_millis(1000));
|
||||
spinner
|
||||
}
|
||||
|
||||
pub fn setup_progressbar(items: usize) -> ProgressBar {
|
||||
let progress_bar = ProgressBar::new(items as u64);
|
||||
progress_bar.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("##-"),
|
||||
);
|
||||
progress_bar
|
||||
}
|
||||
|
||||
pub fn open_webpage(url: &str) {
|
||||
open::that(url).unwrap_or_else(|_| panic!("Unable to open webpage {}", &url));
|
||||
}
|
Loading…
Reference in New Issue