~gbmor/rustwtxt

5267cbbaddde30c6a8a2a27f08d0632651cb103b — Benjamin Morrison 1 year, 20 days ago 4605b6c
Merging the client and library into a single crate
M Cargo.toml => Cargo.toml +34 -0
@@ 17,6 17,40 @@ codecov = { repository = "rustwtxt/rustwtxt", branch = "master", service = "gith
maintenance = { status = "experimental" }

[dependencies]
chrono = "0.4"
clap = "2.33"
colored = "1"
failure = "^0.1.6"
lazy_static = "1.4"
regex = "1"
reqwest = "0.9"
serde_yaml = "0.8"
ureq = "^0.11.3"

[dependencies.serde]
version = "1"
features = ["derive"]

[profile.release]
opt-level = 3
lto = true
debug = false
rpath = false
debug-assertions = false
overflow-checks = false

[profile.dev]
opt-level = 0
lto = false
debug = true
rpath = false
debug-assertions = true
overflow-checks = true

[profile.bench]
opt-level = 3
lto = true
debug = false
debug-assertions = false
rpath = false
overflow-checks = false
\ No newline at end of file

M README.md => README.md +3 -4
@@ 1,10 1,9 @@
# rustwtxt   [![Build Status](https://travis-ci.com/rustwtxt/rustwtxt.svg?branch=master)](https://travis-ci.com/rustwtxt/rustwtxt) [![codecov](https://codecov.io/gh/rustwtxt/rustwtxt/branch/master/graph/badge.svg?token=4DfKP7oHRQ)](https://codecov.io/gh/rustwtxt/rustwtxt) [![Documentation](https://img.shields.io/badge/docs.rs-%E2%9C%93-brightgreen)](https://docs.rs/rustwtxt)
# rustwtxt
[![Build Status](https://travis-ci.com/rustwtxt/rustwtxt.svg?branch=master)](https://travis-ci.com/rustwtxt/rustwtxt) [![codecov](https://codecov.io/gh/rustwtxt/rustwtxt/branch/master/graph/badge.svg?token=4DfKP7oHRQ)](https://codecov.io/gh/rustwtxt/rustwtxt) [![Documentation](https://img.shields.io/badge/docs.rs-%E2%9C%93-brightgreen)](https://docs.rs/rustwtxt)

A library that makes it easier to interact with `twtxt` status files.
Feel free to help hack on it.
`twtxt` client and library.

## Notes
* [`crates.io/crates/rustwtxt`](https://crates.io/crates/rustwtxt)
* `twtxt` is a decentralized microblogging platform based on text files. 
The files are transmitted over `http`. 
* For more information on `twtxt`:

A src/bin/rustwtxt/cache.rs => src/bin/rustwtxt/cache.rs +16 -0
@@ 0,0 1,16 @@
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

pub fn get_remote_modtime(url: &str) -> Result<String> {
    let client = reqwest::Client::new();
    let resp = client.head(url).send()?;
    let headers = resp.headers();

    if headers.contains_key("Last-Modified") {
        match headers.get("Last-Modified") {
            Some(val) => return Ok(val.to_str()?.into()),
            None => return Err("Last-Modified Header Empty".into()),
        };
    }

    Err("Last-Modified Not Found in Response Headers".into())
}

A src/bin/rustwtxt/conf.rs => src/bin/rustwtxt/conf.rs +66 -0
@@ 0,0 1,66 @@
//
// rustweet - Copyright (c) 2019 Ben Morrison (gbmor)
// See LICENSE file for detailed license information.
//
use serde::{Deserialize, Serialize};
use serde_yaml;

use std::fs;
use std::process;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Data {
    pub nick: String,
    pub path: String,
    pub url: String,
    pub follow: Vec<String>,
}

lazy_static! {
    pub static ref DATA: Data = init();
    pub static ref FILE: String = {
        format!(
            "{}/.config/rustweet",
            std::env::var("HOME").unwrap_or_else(|_| ".".into())
        )
    };
}

pub fn init() -> Data {
    let file = (*FILE).to_string();

    if fs::metadata(&file).is_err() {
        eprintln!();
        eprintln!("Configuration file missing: $HOME/.config/rustweet");
        eprintln!("For instructions, please see:");
        eprintln!("\t$ rustweet --manual");
        eprintln!();
        process::exit(1);
    }

    let conf_as_str = match fs::read_to_string(&file) {
        Ok(data) => data,
        Err(err) => {
            eprintln!();
            eprintln!(
                "Can't read configuration file: $HOME/.config/rustweet -- {:?}",
                err
            );
            eprintln!();
            process::exit(1);
        }
    };

    match serde_yaml::from_str::<Data>(&conf_as_str) {
        Ok(data) => data,
        Err(err) => {
            eprintln!();
            eprintln!(
                "Improperly formatted configuration file: $HOME/.config/rustweet: {:?}",
                err
            );
            eprintln!();
            process::exit(1);
        }
    }
}

A src/bin/rustwtxt/ed.rs => src/bin/rustwtxt/ed.rs +61 -0
@@ 0,0 1,61 @@
use std::env;
use std::fs;
use std::process;

use chrono::prelude::*;

use crate::conf;

lazy_static! {
    static ref VAR: String = match env::var("EDITOR") {
        Ok(ed) => {
            if &ed == "" {
                "nano".into()
            } else {
                ed
            }
        }
        Err(err) => {
            eprintln!("{:?}", err);
            "nano".into()
        }
    };
}

fn create_tmp_file<'a>() -> Result<String, &'a str> {
    let the_time = Utc::now().to_rfc3339();
    let conf = conf::DATA.clone();

    let file_name = format!("/tmp/rustweet_ed_{}_{}", conf.nick, the_time);
    match fs::write(&file_name, "") {
        Ok(_) => Ok(file_name),
        Err(_) => Err("Unable to create temp file"),
    }
}

pub fn call() -> String {
    let tmp_loc = match create_tmp_file() {
        Ok(filename) => filename,
        Err(err) => panic!("{:?}", err),
    };

    if let Err(err) = process::Command::new(VAR.clone())
        .arg(tmp_loc.clone())
        .stdin(process::Stdio::inherit())
        .stdout(process::Stdio::inherit())
        .output()
    {
        eprintln!("{:?}", err);
    };

    let body = match fs::read_to_string(tmp_loc.clone()) {
        Ok(string) => string.trim().to_owned(),
        Err(err) => panic!("{:?}", err),
    };

    if let Err(err) = fs::remove_file(tmp_loc) {
        eprintln!("{:?}", err);
    };

    body
}

A src/bin/rustwtxt/main.rs => src/bin/rustwtxt/main.rs +65 -0
@@ 0,0 1,65 @@
#[macro_use]
extern crate lazy_static;

mod cache;
mod conf;
mod ed;
mod timeline;
mod user;

const VERS: &str = clap::crate_version!();

fn main() {
    let args = clap::App::new("rustwtxt")
        .version(VERS)
        .author("Ben Morrison <ben@gbmor.dev>")
        .about("command-line twtxt client")
        .arg(
            clap::Arg::with_name("follow")
                .short("f")
                .long("follow")
                .value_name("URL")
                .help("URL of a user's twtxt.txt file you wish to follow."),
        )
        .arg(
            clap::Arg::with_name("unfollow")
                .short("u")
                .long("unfollow")
                .value_name("NICK")
                .help("Nick of the user you wish to stop following."),
        )
        .subcommand(
            clap::SubCommand::with_name("timeline")
                .about("Displays the followed users' tweets in a timeline."),
        )
        .subcommand(
            clap::SubCommand::with_name("tweet")
                .about("Opens your preferred editor to compose a new tweet."),
        )
        .get_matches();

    println!();
    println!("rustweet v{}", VERS);
    println!("(c) 2019 Ben Morrison <ben@gbmor.dev>");
    println!();

    if let Some(url) = args.value_of("follow") {
        user::follow(url);
        return;
    } else if let Some(url) = args.value_of("unfollow") {
        user::unfollow(url);
        return;
    }

    match args.subcommand() {
        ("tweet", _args) => {
            timeline::tweet();
        }
        ("timeline", _args) => {
            timeline::show();
        }
        (_, _args) => {
            timeline::show();
        }
    }
}

A src/bin/rustwtxt/timeline.rs => src/bin/rustwtxt/timeline.rs +139 -0
@@ 0,0 1,139 @@
use chrono::prelude::*;
use colored::*;
use rustwtxt::{TweetMap, Twtxt};

use std::collections::BTreeMap;
use std::fs;
use std::process;

use crate::cache;
use crate::conf;
use crate::ed;

#[derive(Debug, Clone)]
struct Timeline {
    tweets: TweetMap,
}

pub fn tweet() {
    let twtxt_path = &*conf::DATA.path.clone();
    let tweet_body = ed::call();

    let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, false);

    let tweet_body = format!("{}\t{}", timestamp, tweet_body);

    let current_tweets = match fs::read_to_string(twtxt_path) {
        Ok(data) => data,
        Err(err) => {
            eprintln!("Can't read twtxt.txt: {:?}", err);
            process::exit(1);
        }
    };

    let mut line_vec = current_tweets.split('\n').collect::<Vec<&str>>();
    let mut trimmed_line_vec = Vec::new();
    line_vec.iter().for_each(|line| {
        if line == &"" {
            return;
        }
        trimmed_line_vec.push(line.to_owned());
    });

    line_vec.push(&tweet_body);
    let new_tweets = trimmed_line_vec.join("\n");

    match fs::write(twtxt_path, new_tweets) {
        Err(err) => {
            eprintln!("Couldn't append new tweet to twtxt.txt: {:?}", err);
        }
        _ => {
            println!();
            println!("Tweet added!");
            println!();
        }
    }
}

pub fn show() {
    let twtxt_path = &*conf::DATA.path.clone();
    let twtxt_str = match fs::read_to_string(twtxt_path) {
        Ok(data) => data,
        Err(_) => {
            eprintln!("Couldn't read local twtxt.txt - omitting from timeline.");
            "".into()
        }
    };

    let nick = &*conf::DATA.nick;
    let url = &*conf::DATA.url;

    let tweet_lines = twtxt_str.split('\n').collect::<Vec<&str>>();
    let mut tweet_lines_sanitized = Vec::new();
    tweet_lines.iter().for_each(|line| {
        if line == &"" || line.starts_with('#') {
            return;
        }
        let timestamp = line.split('\t').collect::<Vec<&str>>();
        if DateTime::parse_from_rfc3339(timestamp[0]).is_err() {
            return;
        }

        let line = format!(
            "{}{}{}\n\t{}\n",
            nick.green(),
            "@".bold(),
            url.white(),
            line.white().bold()
        );
        let line = (timestamp[0].to_string(), line);
        tweet_lines_sanitized.push(line);
    });

    let mut follows = pull_followed_tweets();

    tweet_lines_sanitized.iter().for_each(|(k, v)| {
        follows.insert(k.to_owned(), v.to_owned());
    });

    follows.iter().for_each(|(_, v)| {
        println!("{}", v);
    });
}

fn pull_followed_tweets() -> BTreeMap<String, String> {
    let follows = &*conf::DATA.follow;
    let broken_follows = follows
        .iter()
        .map(|each| {
            let split = each.split(' ').collect::<Vec<&str>>();
            (split[0].into(), split[1].into())
        })
        .collect::<Vec<(String, String)>>();

    let mut tweetmap = BTreeMap::new();

    broken_follows.iter().for_each(|(nick, url)| {
        let _modtime = cache::get_remote_modtime(url);
        let twtxt = match Twtxt::from(url) {
            Some(data) => data,
            None => return,
        };
        let tweets = twtxt.tweets().clone();
        tweets.iter().for_each(|(k, v)| {
            tweetmap.insert(
                k.clone(),
                format!(
                    "{}{}{}\n\t{}\t{}\n",
                    nick.blue(),
                    "@".bold(),
                    url.white(),
                    k.clone().white().bold(),
                    (*v.body()).to_string().white().bold(),
                ),
            );
        });
    });

    tweetmap
}

A src/bin/rustwtxt/user.rs => src/bin/rustwtxt/user.rs +84 -0
@@ 0,0 1,84 @@
use std::fs;
use std::process;

use crate::conf;

pub fn follow(url: &str) {
    let data = &*conf::DATA.follow;
    let mut data = data.to_vec();
    let twtxt = if let Ok(val) = rustwtxt::pull_twtxt(url) {
        val
    } else {
        eprintln!("Can't pull twtxt file.");
        eprintln!("I won't be able to parse the nick out of the metadata.");
        String::new()
    };

    let nick = if let Ok(val) = rustwtxt::parse::metadata(&twtxt, "nick") {
        val
    } else {
        eprintln!("Can't parse nick out of metadata.");
        eprintln!("Please add it to the entry manually.");
        String::new()
    };

    let entry = format!("{} {}", nick, url);
    data.push(entry);

    let nick = (&*conf::DATA.nick).to_owned();
    let path = (&*conf::DATA.path).to_owned();
    let url = (&*conf::DATA.url).to_owned();

    let new_conf = conf::Data {
        nick,
        path,
        url,
        follow: data,
    };

    let yaml_str = if let Ok(yaml) = serde_yaml::to_string(&new_conf) {
        yaml
    } else {
        eprintln!("Couldn't parse data as yaml.");
        process::exit(1);
    };

    if let Err(err) = fs::write(&*conf::FILE, yaml_str) {
        eprintln!("Couldn't rewrite config file: {:?}", err);
    }
}

pub fn unfollow(nick: &str) {
    let data = &*conf::DATA.follow;
    let data = data.to_vec();
    let mut new_data = vec![];

    data.iter().for_each(|entry| {
        if entry.contains(nick) {
            return;
        }
        new_data.push(entry.to_owned());
    });

    let nick = (&*conf::DATA.nick).to_owned();
    let path = (&*conf::DATA.path).to_owned();
    let url = (&*conf::DATA.url).to_owned();

    let new_conf = conf::Data {
        nick,
        path,
        url,
        follow: new_data,
    };

    let yaml_str = if let Ok(yaml) = serde_yaml::to_string(&new_conf) {
        yaml
    } else {
        eprintln!("Couldn't reparse data as yaml.");
        process::exit(1);
    };

    if let Err(err) = fs::write(&*conf::FILE, yaml_str) {
        eprintln!("Couldn't rewrite config file: {:?}", err);
    }
}