Add lots of functionality

This is in a usable state now and I have been using it for the last
months.
This commit is contained in:
Jana Lemke 2023-05-24 11:11:12 +02:00
parent 650d1b2e6c
commit 68e25e0566
9 changed files with 456 additions and 66 deletions

3
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target
Cargo.lock
Cargo.lock
.vscode

View file

@ -4,10 +4,23 @@ version = "0.1.0"
authors = ["Jana Lemke <dev@jana-lemke.de>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
default = ["rofi-menu"]
rofi-menu = ["rofi"]
[dependencies]
structopt = "0.3.16"
structopt = "0.3"
serde = { version = "1.0", features = ["derive"]}
toml = "0.5.7"
glob = "0.3.0"
toml = "0.5"
glob = "0.3"
anyhow = "1"
chrono = {version = "0.4.19", features = ["serde"]}
rofi = {version = "0.2", optional = true}
[lib]
name = "ibis"
path = "src/lib.rs"
[[bin]]
name = "ibis"
path = "src/main.rs"

View file

@ -1,12 +1,33 @@
# Things to do next
- Ability to list lectures
- Interface to rofi
- Ability to choose current course/lecture
- `check` complains about some of the defaults. Make sure default settings conform with opinions of `check`
# If you are bored
# Things to think about
- Do I want to copy templates when creating a course?
-> probably just add a subcommand that can copy templates after the creation of a course should I ever need them.
- sometimes I don't need them
- annoying to do by hand
- I don't know either way when creating the course
- Move `course.toml` to `.course.toml`?
- git doesn't automatically track hidden files I think (both pro and con)
# Things to do, if you are bored
- Think about better parsing for Courses (semester)
- Think about better error handling
- probably implement “bubbling up” of errors using the `?` operator
- Think about better code separation/organization
# Things learned
## Paths
- Os Paths are complicated. (not utf-8)
- When you only need your own paths manipulated try keeping them strings for a while
- When you need to use arbitrary strings, your in for an error handling feast
This is both annoying but makes you think about all the python scripts that probably do not handle this at all and the developers of these python scripts that don't even know about this
- Actually if you e.g. save paths somewhere like in a config you probably want to work with `camino` a crate that provides a Path{,Buf} that is utf-8.
It is no use to have "correct" paths only for the saving and reloading to bungle them/the config when opening with an editor.
- You can use the `debug_assertions` with the `#cfg[]` and `cfg!` macros to detect if you are in debug (`cargo run`) or release (`cargo run --release`) mode. This is useful for debugging and similar to C's `#ifdef DEBUG`.
# WISHLIST
- automatically web-archive/download/gitscrape all course websites
- download all moodle-materials
- automatically web-archive/download/git scrape all course websites
- Calendar Integration
- Integration with Moodle-Abgaben (put them in a calendar somewhere? are they already?); send reminders/ability to list them and select one to work on.
Maybe this needs to go into `moodle-rs`?

22
src/config.rs Normal file
View file

@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use structopt::StructOpt;
#[derive(Serialize, Deserialize)]
pub struct Config {
pub link_path: PathBuf,
pub files_root: PathBuf,
pub note_extensions: Vec<String>,
}
#[derive(StructOpt)]
pub struct ListOptions {
#[structopt(short, long)]
pub all: bool,
}
impl Default for ListOptions {
fn default() -> Self {
ListOptions { all: false }
}
}

188
src/courses.rs Normal file
View file

@ -0,0 +1,188 @@
use crate::config::{Config, ListOptions};
use crate::util;
use anyhow::Result;
use glob::glob;
use serde::{Deserialize, Deserializer, Serialize};
use std::io::prelude::*;
use std::os::unix::fs::symlink;
use std::{
fs::{remove_file, File},
path::PathBuf,
};
use structopt::StructOpt;
#[derive(Serialize, Deserialize, StructOpt, Debug, Clone)]
pub struct Course {
pub name: String,
#[serde(skip)]
#[structopt(skip)]
pub path: PathBuf,
short: String,
#[serde(default)]
#[structopt(long)]
url: Option<String>,
#[serde(default)]
#[structopt(long)]
moodle: Option<String>,
#[serde(deserialize_with = "string_or_vec", alias = "semester")]
semesters: Vec<String>,
#[structopt(skip)]
meetings: Option<Vec<Meeting>>,
}
#[derive(Serialize, Deserialize, StructOpt, Debug, Clone)]
pub struct Meeting {
pub name: String,
pub url: String,
}
impl Course {
/// Get a reference to the course's semesters.
pub fn semesters(&self) -> &Vec<String> {
&self.semesters
}
/// Check if course is part of current semester
pub fn is_current(&self) -> bool {
self.semesters()
.iter()
.any(|x| x == &util::get_current_semester())
}
pub fn meetings(&self) -> &Option<Vec<Meeting>> {
&self.meetings
}
pub fn set(&self, conf: &Config) -> Result<()> {
remove_file(&conf.link_path)?;
symlink(&self.path, &conf.link_path)?;
Ok(())
}
pub fn list(conf: &Config, options: &ListOptions) -> Result<CourseList> {
let mut courses: Vec<Course> = vec![];
let pattern = [
conf.files_root.canonicalize().unwrap().to_str().unwrap(),
"/*/course.toml",
]
.concat();
let mut handle;
for entry in glob(pattern.as_str()).unwrap() {
let path = entry.unwrap();
let mut coursestring = String::new();
handle = File::open(&path).unwrap();
handle.read_to_string(&mut coursestring).unwrap();
let mut course: Course = toml::from_str(&coursestring).expect(&format!("{:?}", &path));
course.path = path.parent().unwrap().to_path_buf();
if options.all || course.is_current() {
courses.push(course);
}
}
Ok(CourseList::new(courses))
}
pub fn check(conf: &Config, list_options: &ListOptions) -> Result<()> {
let mut problematic_course_count = 0;
let toml_list = Self::list(conf, list_options)?;
println!("Checking course.toml's");
for c in toml_list.iter() {
let mut problems = Vec::new();
if c.name.is_empty() {
problems.push(" Name is empty")
}
if c.short.is_empty() {
problems.push(" Short is empty")
}
if c.url.is_none() {
problems.push(" No URL (set empty string to mark nonexistant website)")
}
if c.moodle.is_none() {
problems.push(" No Moodle-URL (set empty string to mark nonexistant website)")
}
if c.semesters.is_empty() {
problems.push(" Course is not in any semesters")
}
if !problems.is_empty() {
eprintln!(
"Found {} problems with {}/course.toml",
problems.len(),
c.path.to_str().unwrap()
);
for p in problems {
eprintln!("{}", p)
}
problematic_course_count += 1;
} else {
println!("No problems found with {:?}", c.path);
}
}
println!("Found {} courses with problems", problematic_course_count);
Ok(())
}
}
fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrVec {
String(String),
Vec(Vec<String>),
}
match StringOrVec::deserialize(deserializer)? {
StringOrVec::String(s) => Ok(vec![s]),
StringOrVec::Vec(vec) => Ok(vec),
}
}
pub struct CourseList(Vec<Course>);
impl std::fmt::Display for CourseList {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for c in &self.0 {
writeln!(f, "{}", c)?;
}
Ok(())
}
}
impl CourseList {
pub fn new(inner: Vec<Course>) -> Self {
CourseList(inner)
}
pub fn iter(&self) -> std::slice::Iter<Course> {
self.0.iter()
}
pub fn get(&self, element: usize) -> Option<&Course> {
self.0.get(element)
}
}
impl std::fmt::Display for Course {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name)
}
}
// #[derive(Debug)]
// pub enum ParseSemesterError{
// ParseIntError(ParseIntError),
// NoFormatFound,
// }
// impl std::fmt::Display for ParseSemesterError{
// fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// todo!()
// }
// }
// impl std::convert::From<ParseIntError> for ParseSemesterError{
// fn from(e: ParseIntError) -> Self {
// Self::ParseIntError(e)
// }
// }

4
src/lib.rs Normal file
View file

@ -0,0 +1,4 @@
pub mod config;
pub mod courses;
pub mod rofi;
pub mod util;

View file

@ -1,90 +1,133 @@
use serde::{Deserialize, Serialize};
use structopt::StructOpt;
use std::path::{Path, PathBuf};
use glob::glob;
use anyhow::{Context, Result};
use std::fs::File;
use std::io::prelude::*;
use std::path::PathBuf;
use structopt::StructOpt;
#[derive(Serialize, Deserialize)]
struct Config {
link_path: PathBuf,
files_root: PathBuf,
}
use ibis::config::{Config, ListOptions};
use ibis::courses::Course;
use ibis::{rofi, util};
#[derive(Serialize, Deserialize)]
struct Course{
name: String,
short: String,
semester: String,
url: Option<String>,
moodle: Option<String>
}
#[derive(StructOpt)]
struct Cli {
#[structopt(short, long)]
debug: bool,
#[structopt(subcommand)]
cmd: Option<Command>,
}
#[derive(StructOpt)]
enum Command {
List(ObjectStruct),
Select(ObjectStruct),
Add(ObjectStruct),
ListCourses(ListOptions),
ListLectures(ListOptions),
SelectCourse(ListOptions),
SelectLecture,
AddCourse(Course),
AddLecture,
Init,
Check(ListOptions),
Meetings(ListOptions),
}
#[derive(StructOpt)]
struct ObjectStruct {
#[structopt(subcommand)]
object: Option<Object>,
}
// #[derive(StructOpt)]
// struct ObjectStruct {
// #[structopt(subcommand)]
// object: Option<Object>,
// }
#[derive(StructOpt, Debug)]
enum Object {
Courses,
Lectures,
}
fn main() {
let conf = Config{link_path: PathBuf::from("/home/jana/current-course"), files_root: PathBuf::from("/home/jana/uni")};
fn main() -> Result<()> {
let conf = Config {
link_path: PathBuf::from("/home/jana/current-course"),
files_root: PathBuf::from("/home/jana/uni"),
note_extensions: vec![String::from("tex"), String::from("md"), String::from("org")],
};
let args = Cli::from_args();
match args.cmd {
Some(subcommand) => match subcommand {
Command::List(object) => list(object.object, conf),
Command::Add(object) => (),
Command::Select(object) => (),
Command::ListCourses(options) => {
for c in Course::list(&conf, &options)?.iter() {
println!("{}", c);
}
}
Command::ListLectures(options) => {
for l in list_lectures(&conf, options)? {
println!("{:?}", l);
}
}
Command::AddCourse(course) => add(course, &conf, args.debug)?,
Command::AddLecture => (),
Command::SelectCourse(options) => rofi::select_course(&conf, &options)?,
Command::SelectLecture => (),
Command::Init => (),
Command::Check(list_options) => Course::check(&conf, &list_options)?,
Command::Meetings(options) => rofi::select_meeting(&conf, &options)?,
},
None => println!("No command given"),
}
};
Ok(())
}
fn list(object: Option<Object>, conf: Config) {
if object.is_some() {
let object = object.unwrap();
match object {
Object::Courses => {list_courses(conf);},
Object::Lectures => list_lectures(),
fn add(course: Course, conf: &Config, debug: bool) -> Result<()> {
let toml = toml::to_string(&course)?;
println!("{}", toml);
let target_dir = [
conf.files_root
.canonicalize()?
.to_str()
.expect("I hate paths"), // TODO
"/",
util::to_snail_case(&course.name).as_ref(),
]
.concat();
let toml_path = PathBuf::from([&target_dir, "/course.toml"].concat());
if !(cfg!(debug_assertions) || debug) {
if !conf.files_root.is_dir() {
std::fs::create_dir_all(conf.files_root.canonicalize().with_context(|| {
format!("The root path {:?} seems malformed", conf.files_root)
})?)?;
}
if !PathBuf::from(&target_dir).is_dir() {
std::fs::create_dir_all(&target_dir)?;
} else {
panic!("Folder {} already exists!", &target_dir);
}
let mut toml_handle = File::create(&toml_path).unwrap();
write!(toml_handle, "{}", toml).unwrap();
println!("Created files!")
}
println!("Path was {:?}", &toml_path);
Ok(())
}
fn list_courses(conf: Config) -> Vec<Course>{
let mut courses: Vec<Course> = vec![];
let pattern = [conf.files_root.canonicalize().unwrap().to_str().unwrap(), "/**/course.toml"].concat();
let mut handle;
for entry in glob(pattern.as_str()).unwrap(){
let path = entry.unwrap();
let mut coursestring = String::new();
handle = File::open(path).unwrap();
handle.read_to_string(&mut coursestring);
courses.push(toml::from_str(&coursestring).unwrap());
}
for c in &courses{
println!("Course {}({}) in {}, url {:?}, moodle {:?}", c.name, c.short, c.semester, c.url, c.moodle);
}
courses
}
fn list_lectures(conf: &Config, options: ListOptions) -> Result<Vec<PathBuf>> {
let lectures = util::glob_paths(
if options.all {
&conf.files_root
} else {
&conf.link_path
},
"/**/lecture*",
);
fn list_lectures() {
println!("Listing lectures")
let lectures = lectures?
.into_iter()
.filter_map(|path| {
if let Some(ext) = path.extension() {
let ext = ext.to_owned().to_string_lossy().into();
if conf.note_extensions.contains(&ext) {
Some(path)
} else {
None
}
} else {
None
}
})
.collect();
Ok(lectures)
}

53
src/rofi.rs Normal file
View file

@ -0,0 +1,53 @@
use crate::config::Config;
use crate::{config::ListOptions, courses::*};
use anyhow::Result;
use rofi::Rofi;
use std::io::Write;
use std::process::Command;
use std::process::Stdio;
pub fn select_course(conf: &Config, options: &ListOptions) -> Result<()> {
let courses = Course::list(conf, options)?;
let course_names: Vec<_> = courses.iter().map(|c| &c.name).collect();
let element = Rofi::new(&course_names).run_index()?;
courses
.get(element)
.expect("Could not find selected Course")
.set(conf)
}
pub fn select_meeting(conf: &Config, options: &ListOptions) -> Result<()> {
let meetings: Vec<_> = Course::list(conf, options)?
.iter()
.cloned()
.map(|c: Course| {
if let Some(meetings) = c.meetings() {
meetings.clone()
} else {
Vec::new()
}
.iter()
.map(move |m| (c.clone(), m.clone()))
.collect::<Vec<_>>()
})
.flatten()
.collect();
let meeting_names: Vec<_> = meetings
.iter()
.map(|(c, m)| format!("{:<30} {:>30}", &c.name, &m.name))
.collect();
let element = Rofi::new(&meeting_names).run_index()?;
let mut clip = Command::new("xclip")
.args(&["-sel", "clip"])
.stdin(Stdio::piped())
.spawn()
.expect("Could not spawn xclip process");
let url = &meetings[element].1.url;
{
let pipe = clip.stdin.as_mut().expect("Could not write to xclip stdin");
pipe.write_all(url.as_bytes())
.expect("Writing to xclip stdin failed");
}
clip.wait()?;
Ok(())
}

45
src/util.rs Normal file
View file

@ -0,0 +1,45 @@
use anyhow::Result;
use chrono::{DateTime, Datelike, Local};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
pub fn to_snail_case(from: impl Into<String>) -> String {
from.into().replace(" ", "_").to_lowercase()
}
// pub fn to_title_case(from: &str) -> String {
// from.to_string().split(|c| c ==' ' || c == '_').map(|s| {if s.len() >= 1 {s[0 as usize] = s[0 as usize].to_uppercase()}}).collect()
// }
pub fn get_current_semester() -> String {
let today = DateTime::<Local>::from(SystemTime::now())
.date()
.naive_local();
let (part, year) = if today.month() >= 4 && today.month() < 10 {
("SS", today.year())
} else {
(
"WS",
if today.month() >= 10 {
today.year()
} else {
today.year() - 1
},
)
};
format!("{}{}", part, year - 2000)
}
pub fn glob_paths<P: AsRef<Path>, S: AsRef<str>>(path: P, pattern: S) -> Result<Vec<PathBuf>> {
let mut paths = vec![];
let pattern = [
(*(path.as_ref())).canonicalize()?.to_str().unwrap(),
pattern.as_ref(),
]
.concat();
for entry in glob::glob(pattern.as_str())? {
let path = entry?;
paths.push(path);
}
Ok(paths)
}