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