diff options
Diffstat (limited to '')
-rw-r--r-- | pkgs/by-name/ts/tskm/src/cli.rs | 153 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/input/handle.rs | 112 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/input/mod.rs | 257 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/mod.rs | 4 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs | 92 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs | 18 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/open/handle.rs | 222 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/open/mod.rs | 106 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/project/handle.rs | 87 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/project/mod.rs | 73 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/main.rs | 67 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/rofi/mod.rs | 37 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/state.rs | 45 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/task/mod.rs | 342 |
14 files changed, 1615 insertions, 0 deletions
diff --git a/pkgs/by-name/ts/tskm/src/cli.rs b/pkgs/by-name/ts/tskm/src/cli.rs new file mode 100644 index 00000000..1c72b3c2 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/cli.rs @@ -0,0 +1,153 @@ +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use clap::{ArgAction, Parser, Subcommand}; +use url::Url; + +use crate::{ + interface::{input::Input, project::ProjectName}, + state::State, + task, +}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about, verbatim_doc_comment)] +/// This is the core interface to the system-integrated task management +/// +/// `tskm` effectively combines multiple applications together: +/// - `taskwarrior` projects are raised connected to `firefox` profiles, making it possible to “open” +/// a project. +/// - Every `taskwarrior` project has a determined `neorg` path, so that extra information for a +/// `project` can be stored in this `norg` file. +/// - `tskm` can track inputs for you. These are URLs with optional tags which you can that +/// “review” to open tasks based on them. +pub struct CliArgs { + #[command(subcommand)] + pub command: Command, + + /// Increase message verbosity + #[arg(long="verbose", short = 'v', action = ArgAction::Count, default_value_t = 2)] + pub verbosity: u8, + + /// Silence all output + #[arg(long, short = 'q')] + pub quiet: bool, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Interact with projects. + Projects { + #[command(subcommand)] + command: ProjectCommand, + }, + + /// Manage the input queue. + Inputs { + #[command(subcommand)] + command: InputCommand, + }, + + /// Access the associated `neorg` workspace for the project/task. + Neorg { + #[command(subcommand)] + command: NeorgCommand, + }, + + /// Interface with the Firefox profile of each project. + Open { + #[command(subcommand)] + command: OpenCommand, + }, +} + +#[derive(Subcommand, Debug)] +pub enum ProjectCommand { + /// Lists all available projects. + List, + + /// Allows you to quickly add projects. + Add { + /// The name of the new project. + #[arg(value_parser = ProjectName::try_from_project)] + new_project_name: ProjectName, + }, +} + +#[derive(Subcommand, Debug, Clone, Copy)] +pub enum NeorgCommand { + /// Open the `neorg` project associated with id of the task. + Task { + /// The working set id of the task + #[arg(value_parser = task_from_working_set_id)] + id: task::Task, + }, +} + +fn task_from_working_set_id(id: &str) -> Result<task::Task> { + let id: usize = id.parse()?; + let mut state = State::new_ro()?; + + let Some(task) = task::Task::from_working_set(id, &mut state)? else { + bail!("Working set id '{id}' is not valid!") + }; + Ok(task) +} + +#[derive(Subcommand, Debug)] +pub enum OpenCommand { + /// Open each project's Firefox profile consecutively, that was opened since the last review. + /// + /// This allows you to remove stale opened tabs and to commit open tabs to the `inputs`. + Review, + + /// Opens Firefox with either the supplied project or the currently active project profile. + Project { + /// The project to open. + #[arg(value_parser = task::Project::from_project_string)] + project: task::Project, + + /// The URL to open. + url: Option<Url>, + }, + + /// Open a selected project in it's Firefox profile. + /// + /// This will use rofi's dmenu mode to select one project from the list of all registered + /// projects. + Select { + /// The URL to open. + url: Option<Url>, + }, + + /// List all open tabs in the project. + ListTabs { + /// The project to open. + #[arg(value_parser = task::Project::from_project_string)] + project: Option<task::Project>, + }, +} + +#[derive(Subcommand, Debug)] +pub enum InputCommand { + /// Add URLs as inputs to be categorized. + Add { inputs: Vec<Input> }, + /// Remove URLs + Remove { inputs: Vec<Input> }, + + /// Add all URLs in the file as inputs to be categorized. + /// + /// This expects each line to contain one URL. + File { file: PathBuf }, + + /// Like 'review', but for the inputs that have previously been added. + /// It takes a project in which to open the URLs. + Review { + /// Opens all the URLs in this project. + #[arg(value_parser = task::Project::from_project_string)] + project: task::Project, + }, + + /// List all the previously added inputs. + List, +} diff --git a/pkgs/by-name/ts/tskm/src/interface/input/handle.rs b/pkgs/by-name/ts/tskm/src/interface/input/handle.rs new file mode 100644 index 00000000..0ff0e56e --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/input/handle.rs @@ -0,0 +1,112 @@ +use std::{ + fs, process, + str::FromStr, + thread::{self, sleep}, + time::Duration, +}; + +use anyhow::{Context, Result}; +use log::{error, info}; + +use crate::cli::InputCommand; + +use super::Input; + +/// # Errors +/// When command handling fails. +/// +/// # Panics +/// When internal assertions fail. +pub fn handle(command: InputCommand) -> Result<()> { + match command { + InputCommand::Add { inputs } => { + for input in inputs { + input.commit().with_context(|| { + format!("Failed to add input ('{input}') to the input storage.") + })?; + } + } + InputCommand::Remove { inputs } => { + for input in inputs { + input.remove().with_context(|| { + format!("Failed to remove input ('{input}') from the input storage.") + })?; + } + } + InputCommand::File { file } => { + let file = fs::read_to_string(file)?; + for line in file.lines() { + let input = Input::from_str(line)?; + input.commit().with_context(|| { + format!("Failed to add input ('{input}') to the input storage.") + })?; + } + } + InputCommand::Review { project } => { + let project = project.to_project_display(); + + let local_project = project.clone(); + let handle = thread::spawn(move || { + // We assume that the project is not yet open. + let mut firefox = process::Command::new("firefox") + .args(["-P", local_project.as_str(), "about:newtab"]) + .spawn()?; + + Ok::<_, anyhow::Error>(firefox.wait()?) + }); + // Give Firefox some time to start. + info!("Waiting on firefox to start"); + sleep(Duration::from_secs(4)); + + let project_str = project.as_str(); + 'outer: for all in Input::all()?.chunks(100) { + info!("Starting review for the first hundred URLs."); + + for input in all { + info!("-> '{input}'"); + let status = process::Command::new("firefox") + .args(["-P", project_str, input.url().to_string().as_str()]) + .status()?; + + if status.success() { + input.remove()?; + } else { + error!("Adding `{input}` to Firefox failed!"); + } + } + + { + use std::io::{stdin, stdout, Write}; + + let mut s = String::new(); + eprint!("Continue? (y/N) "); + stdout().flush()?; + + stdin() + .read_line(&mut s) + .expect("Did not enter a correct string"); + + if let Some('\n') = s.chars().next_back() { + s.pop(); + } + if let Some('\r') = s.chars().next_back() { + s.pop(); + } + + if s != "y" { + break 'outer; + } + } + } + + info!("Waiting for firefox to stop"); + handle.join().expect("Should be joinable")?; + } + InputCommand::List => { + for url in Input::all()? { + println!("{url}"); + } + } + } + Ok(()) +} diff --git a/pkgs/by-name/ts/tskm/src/interface/input/mod.rs b/pkgs/by-name/ts/tskm/src/interface/input/mod.rs new file mode 100644 index 00000000..9ece7a3a --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/input/mod.rs @@ -0,0 +1,257 @@ +use std::{ + collections::HashSet, + fmt::Display, + fs::{self, read_to_string, File}, + io::Write, + path::PathBuf, + process::Command, + str::FromStr, +}; + +use anyhow::{bail, Context, Result}; +use url::Url; +use walkdir::WalkDir; + +pub mod handle; +pub use handle::handle; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NoWhitespaceString(String); + +impl NoWhitespaceString { + /// # Panics + /// If the input contains whitespace. + #[must_use] + pub fn new(input: String) -> Self { + if input.contains(' ') { + panic!("Your input '{input}' contains whitespace. I did not expect that.") + } else { + Self(input) + } + } +} + +impl Display for NoWhitespaceString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl NoWhitespaceString { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct Input { + url: Url, + tags: HashSet<NoWhitespaceString>, +} + +impl FromStr for Input { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { + if s.contains(' ') { + let (url, tags) = s.split_once(' ').expect("Should work"); + Ok(Self { + url: Url::from_str(url)?, + tags: { + tags.trim() + .split(' ') + .map(|tag| { + if let Some(tag) = tag.strip_prefix('+') { + Ok(NoWhitespaceString::new(tag.to_owned())) + } else { + bail!("Your tag '{tag}' does not start with the required '+'"); + } + }) + .collect::<Result<_, _>>()? + }, + }) + } else { + Ok(Self { + url: Url::from_str(s)?, + tags: HashSet::new(), + }) + } + } +} + +impl Display for Input { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.tags.is_empty() { + self.url.fmt(f) + } else { + write!( + f, + "{} {}", + self.url, + self.tags + .iter() + .fold(String::new(), |mut acc, tag| { + acc.push('+'); + acc.push_str(tag.as_str()); + acc.push(' '); + acc + }) + .trim() + ) + } + } +} + +impl Input { + fn base_path() -> PathBuf { + dirs::data_local_dir() + .expect("This should be set") + .join("tskm/inputs") + } + + fn url_path(url: &Url) -> Result<PathBuf> { + let base_path = Self::base_path(); + + let url_path = base_path.join(url.to_string()); + fs::create_dir_all(&url_path) + .with_context(|| format!("Failed to open file: '{}'", url_path.display()))?; + + Ok(url_path.join("url_value")) + } + + #[must_use] + pub fn url(&self) -> &Url { + &self.url + } + + /// Commit this constructed [`Input`] to storage. + /// + /// # Errors + /// If IO operations fail. + pub fn commit(&self) -> Result<()> { + let url_path = Self::url_path(&self.url)?; + + let url_content = { + if url_path.exists() { + read_to_string(&url_path)? + } else { + String::new() + } + }; + + let mut file = File::create(&url_path) + .with_context(|| format!("Failed to open file: '{}'", url_path.display()))?; + writeln!(file, "{url_content}{self}")?; + + Self::git_commit(&format!("Add new url: '{self}'"))?; + + Ok(()) + } + + /// Remove this constructed [`Input`] to storage. + /// + /// Beware that this does not take tags into account. + /// + /// # Errors + /// If IO operations fail. + pub fn remove(&self) -> Result<()> { + let url_path = Self::url_path(&self.url)?; + + fs::remove_file(&url_path) + .with_context(|| format!("Failed to remove file: '{}'", url_path.display()))?; + + let mut url_path = url_path.as_path(); + while let Some(parent) = url_path.parent() { + if fs::read_dir(parent)?.count() == 0 { + fs::remove_dir(parent)?; + } + url_path = parent; + } + + Self::git_commit(&format!("Remove url: '{self}'"))?; + Ok(()) + } + + /// Commit your changes + fn git_commit(message: &str) -> Result<()> { + let status = Command::new("git") + .args(["add", "."]) + .current_dir(Self::base_path()) + .status()?; + if !status.success() { + bail!("Git add . failed!"); + } + + let status = Command::new("git") + .args(["commit", "--message", message, "--no-gpg-sign"]) + .current_dir(Self::base_path()) + .status()?; + if !status.success() { + bail!("Git commit failed!"); + } + + Ok(()) + } + + /// Get all previously [`Self::commit`]ed inputs. + /// + /// # Errors + /// When IO handling fails. + /// + /// # Panics + /// If internal assertions fail. + pub fn all() -> Result<Vec<Self>> { + let mut output = vec![]; + for entry in WalkDir::new(Self::base_path()) + .min_depth(1) + .into_iter() + .filter_entry(|e| { + let s = e.file_name().to_str(); + s != Some(".git") + }) + { + let entry = entry?; + + if !entry.file_type().is_file() { + continue; + } + + let url_value_file = entry + .path() + .to_str() + .expect("All of these should be URLs and thus valid strings"); + assert!(url_value_file.ends_with("/url_value")); + + let url = { + let base = url_value_file + .strip_prefix(&format!("{}/", Self::base_path().display())) + .expect("This will exist"); + + let (proto, path) = base.split_once(':').expect("This will countain a :"); + + let path = path.strip_suffix("/url_value").expect("Will exist"); + + Url::from_str(&format!("{proto}:/{path}")) + .expect("This was a URL, it should still be one") + }; + let tags = { + let url_values = read_to_string(PathBuf::from(url_value_file))?; + url_values + .lines() + .map(|line| { + let input = Self::from_str(line)?; + Ok::<_, anyhow::Error>(input.tags) + }) + .collect::<Result<Vec<HashSet<NoWhitespaceString>>, _>>()? + .into_iter() + .flatten() + .collect() + }; + + output.push(Self { url, tags }); + } + + Ok(output) + } +} diff --git a/pkgs/by-name/ts/tskm/src/interface/mod.rs b/pkgs/by-name/ts/tskm/src/interface/mod.rs new file mode 100644 index 00000000..1a0d934c --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/mod.rs @@ -0,0 +1,4 @@ +pub mod input; +pub mod neorg; +pub mod open; +pub mod project; diff --git a/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs b/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs new file mode 100644 index 00000000..577de02c --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs @@ -0,0 +1,92 @@ +use std::{ + env, + fs::{self, read_to_string, File, OpenOptions}, + io::Write, + process::Command, +}; + +use anyhow::{bail, Context, Result}; + +use crate::{cli::NeorgCommand, state::State}; + +pub fn handle(command: NeorgCommand, state: &mut State) -> Result<()> { + match command { + NeorgCommand::Task { id } => { + let project = id.project(state)?; + let path = dirs::data_local_dir() + .expect("This should exists") + .join("tskm/notes") + .join(project.get_neorg_path()?); + + fs::create_dir_all(path.parent().expect("This should exist"))?; + + { + let contents = if path.exists() { + read_to_string(&path) + .with_context(|| format!("Failed to read file: '{}'", path.display()))? + } else { + File::create(&path) + .with_context(|| format!("Failed to create file: '{}'", path.display()))?; + String::new() + }; + + if !contents.contains(format!("% {}", id.uuid()).as_str()) { + let mut options = OpenOptions::new(); + options.append(true).create(false); + + let mut file = options.open(&path)?; + file.write_all(format!("* TITLE (% {})", id.uuid()).as_bytes()) + .with_context(|| { + format!("Failed to write task uuid to file: '{}'", path.display()) + })?; + file.flush() + .with_context(|| format!("Failed to flush file: '{}'", path.display()))?; + } + } + + let editor = env::var("EDITOR").unwrap_or("nvim".to_owned()); + let status = Command::new(editor) + .args([ + path.to_str().expect("Should be a utf-8 str"), + "-c", + format!("/% {}", id.uuid()).as_str(), + ]) + .status()?; + if !status.success() { + bail!("$EDITOR fail with error code: {status}"); + } + + { + let status = Command::new("git") + .args(["add", "."]) + .current_dir(path.parent().expect("Will exist")) + .status()?; + if !status.success() { + bail!("Git add . failed!"); + } + + let status = Command::new("git") + .args([ + "commit", + "--message", + format!( + "chore({}): Update", + path.parent().expect("Should have a parent").display() + ) + .as_str(), + "--no-gpg-sign", + ]) + .current_dir(path.parent().expect("Will exist")) + .status()?; + if !status.success() { + bail!("Git commit failed!"); + } + } + + { + id.mark_neorg_data(state)?; + } + } + } + Ok(()) +} diff --git a/pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs b/pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs new file mode 100644 index 00000000..dc5cdf19 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs @@ -0,0 +1,18 @@ +use std::path::PathBuf; + +use anyhow::Result; + +use crate::task::{run_task, Project}; + +pub mod handle; +pub use handle::handle; + +impl Project { + pub(super) fn get_neorg_path(&self) -> Result<PathBuf> { + let project_path = run_task(&[ + "_get", + format!("rc.context.{}.rc.neorg_path", self.to_context_display()).as_str(), + ])?; + Ok(PathBuf::from(project_path.as_str())) + } +} diff --git a/pkgs/by-name/ts/tskm/src/interface/open/handle.rs b/pkgs/by-name/ts/tskm/src/interface/open/handle.rs new file mode 100644 index 00000000..15c7ac4d --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/open/handle.rs @@ -0,0 +1,222 @@ +use std::{ + fs, + net::{IpAddr, Ipv4Addr}, + path::PathBuf, + process, +}; + +use anyhow::{bail, Context, Result}; +use log::{error, info}; +use url::Url; + +use crate::{cli::OpenCommand, rofi, state::State, task}; + +pub fn handle(command: OpenCommand, state: &mut State) -> Result<()> { + match command { + OpenCommand::Review => { + for project in task::Project::all().context("Failed to get all project files")? { + if project.is_touched() { + info!("Reviewing project: '{}'", project.to_project_display()); + open_in_browser(project, state, None).with_context(|| { + format!( + "Failed to open project ('{}') in Firefox", + project.to_project_display() + ) + })?; + project.untouch().with_context(|| { + format!( + "Failed to untouch project ('{}')", + project.to_project_display() + ) + })?; + } + } + } + OpenCommand::Project { project, url } => { + project.touch().context("Failed to touch project")?; + open_in_browser(&project, state, url).with_context(|| { + format!("Failed to open project: {}", project.to_project_display()) + })?; + } + OpenCommand::Select { url } => { + let selected_project: task::Project = task::Project::from_project_string( + &rofi::select( + task::Project::all() + .context("Failed to get all registered projects")? + .iter() + .map(task::Project::to_project_display) + .collect::<Vec<_>>() + .as_slice(), + ) + .context("Failed to get selected project")?, + ) + .expect("This should work, as we send only projects in"); + + selected_project + .touch() + .context("Failed to touch project")?; + + open_in_browser(&selected_project, state, url).context("Failed to open project")?; + } + OpenCommand::ListTabs { project } => { + let project = if let Some(p) = project { + p + } else if let Some(p) = + task::Project::get_current().context("Failed to get currently focused project")? + { + p + } else { + bail!("You need to either supply a project or have a project active!"); + }; + + let session_store = project.get_sessionstore().with_context(|| { + format!( + "Failed to get session store for project: '{}'", + project.to_project_display() + ) + })?; + + let selected = session_store + .windows + .iter() + .map(|w| w.selected) + .collect::<Vec<_>>(); + + let tabs = session_store + .windows + .iter() + .flat_map(|window| window.tabs.iter()) + .map(|tab| tab.entries.get(tab.index - 1).expect("This should be Some")) + .collect::<Vec<_>>(); + + for (index, entry) in tabs.iter().enumerate() { + let index = index + 1; + let is_selected = { + if selected.contains(&index) { + "🔻 " + } else { + " " + } + }; + println!("{}{}", is_selected, entry.url); + } + } + } + Ok(()) +} + +fn open_in_browser( + selected_project: &task::Project, + state: &mut State, + url: Option<Url>, +) -> Result<()> { + let old_project: Option<task::Project> = + task::Project::get_current().context("Failed to get currently active project")?; + let old_task: Option<task::Task> = + task::Task::get_current(state).context("Failed to get currently active task")?; + + selected_project.activate().with_context(|| { + format!( + "Failed to active project: '{}'", + selected_project.to_project_display() + ) + })?; + + let tracking_task = { + let all_tasks = selected_project.get_tasks(state).with_context(|| { + format!( + "Failed to get assoctiated tasks for project: '{}'", + selected_project.to_project_display() + ) + })?; + + let tracking_task = all_tasks.into_iter().find(|t| { + let maybe_desc = t.description(state); + if let Ok(desc) = maybe_desc { + desc == "tracking" + } else { + error!( + "Getting task description returned error: {}", + maybe_desc.expect_err("We already check for Ok") + ); + false + } + }); + + if let Some(task) = tracking_task { + info!( + "Starting task {} -> tracking", + selected_project.to_project_display() + ); + task.start(state) + .with_context(|| format!("Failed to start task {task}"))?; + } + tracking_task + }; + + let status = { + let mut args = vec!["-P".to_owned(), selected_project.to_project_display()]; + if let Some(url) = url { + args.push(url.to_string()); + } else { + let (ip, pid): (IpAddr, u32) = { + let link = fs::read_link( + dirs::home_dir() + .expect("Exists") + .join(".mozilla/firefox") + .join(selected_project.to_project_display()) + .join("lock"), + )?; + let (ip, pid) = link + .to_str() + .expect("Should work") + .split_once(':') + .expect("The split works"); + + ( + ip.parse().expect("Should be a valid ip address"), + pid.parse().expect("Should be a valid pid"), + ) + }; + + assert_eq!(ip, Ipv4Addr::new(127, 0, 0, 1)); + if PathBuf::from("/proc").join(pid.to_string()).exists() { + // Another Firefox instance has already been started for this project + // Add a buffer URL to force Firefox to open it in the already open instance + args.push("about:newtab".to_owned()); + } else { + // This project does not yet have another Firefox instance + // We do not need to add anything to the arguments, Firefox will open a new + // instance. + } + }; + + process::Command::new("firefox") + .args(args) + .status() + .context("Failed to start firefox")? + }; + + if !status.success() { + error!("Firefox run exited with error."); + } + + if let Some(task) = tracking_task { + task.stop(state) + .with_context(|| format!("Failed to stop task {task}"))?; + } + if let Some(task) = old_task { + task.start(state) + .with_context(|| format!("Failed to start task {task}"))?; + } + + if let Some(project) = old_project { + project.activate().with_context(|| { + format!("Failed to active project {}", project.to_project_display()) + })?; + } else { + task::Project::clear().context("Failed to clear currently focused project")?; + } + + Ok(()) +} diff --git a/pkgs/by-name/ts/tskm/src/interface/open/mod.rs b/pkgs/by-name/ts/tskm/src/interface/open/mod.rs new file mode 100644 index 00000000..2dc75957 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/open/mod.rs @@ -0,0 +1,106 @@ +use std::{collections::HashMap, fs::File, io}; + +use anyhow::{Context, Result}; +use lz4_flex::decompress_size_prepended; +use serde::Deserialize; +use serde_json::Value; +use url::Url; + +use crate::task::Project; + +pub mod handle; +pub use handle::handle; + +impl Project { + pub(super) fn get_sessionstore(&self) -> Result<SessionStore> { + let path = dirs::home_dir() + .expect("Will exist") + .join(".mozilla/firefox") + .join(self.to_project_display()) + .join("sessionstore-backups/recovery.jsonlz4"); + let file = decompress_mozlz4( + File::open(&path) + .with_context(|| format!("Failed to open path '{}'", path.display()))?, + ) + .with_context(|| format!("Failed to decompress file as mozlzh '{}'", path.display()))?; + + let contents: SessionStore = serde_json::from_str(&file).with_context(|| { + format!( + "Failed to deserialize file ('{}') as session store.", + path.display() + ) + })?; + Ok(contents) + } +} + +fn decompress_mozlz4<P: io::Read>(mut file: P) -> Result<String> { + const MOZLZ4_MAGIC_NUMBER: &[u8] = b"mozLz40\0"; + + let mut buf = [0u8; 8]; + file.read_exact(&mut buf) + .context("Failed to read the mozlz40 header.")?; + + assert_eq!(buf, MOZLZ4_MAGIC_NUMBER); + + let mut buf = vec![]; + file.read_to_end(&mut buf).context("Failed to read file")?; + + let uncompressed = decompress_size_prepended(&buf).context("Failed to decompress file")?; + + Ok(String::from_utf8(uncompressed).expect("This should be valid json and thus utf8")) +} + +#[derive(Deserialize, Debug)] +pub struct SessionStore { + pub windows: Vec<Window>, +} + +#[derive(Deserialize, Debug)] +pub struct Window { + pub tabs: Vec<Tab>, + pub selected: usize, +} + +#[derive(Deserialize, Debug)] +pub struct Tab { + pub entries: Vec<TabEntry>, + #[serde(rename = "lastAccessed")] + pub last_accessed: u64, + pub hidden: bool, + #[serde(rename = "searchMode")] + pub search_mode: Option<Value>, + #[serde(rename = "userContextId")] + pub user_context_id: u32, + pub attributes: TabAttributes, + #[serde(rename = "extData")] + pub ext_data: Option<HashMap<String, Value>>, + pub index: usize, + #[serde(rename = "requestedIndex")] + pub requested_index: Option<u32>, + pub image: Option<Url>, +} + +#[derive(Deserialize, Debug)] +pub struct TabEntry { + pub url: Url, + pub title: String, + #[serde(rename = "cacheKey")] + pub cache_key: u32, + #[serde(rename = "ID")] + pub id: u32, + #[serde(rename = "docshellUUID")] + pub docshell_uuid: Value, + #[serde(rename = "resultPrincipalURI")] + pub result_principal_uri: Option<Url>, + #[serde(rename = "hasUserInteraction")] + pub has_user_interaction: bool, + #[serde(rename = "triggeringPrincipal_base64")] + pub triggering_principal_base64: Value, + #[serde(rename = "docIdentifier")] + pub doc_identifier: u32, + pub persist: bool, +} + +#[derive(Deserialize, Debug, Clone, Copy)] +pub struct TabAttributes {} diff --git a/pkgs/by-name/ts/tskm/src/interface/project/handle.rs b/pkgs/by-name/ts/tskm/src/interface/project/handle.rs new file mode 100644 index 00000000..2b01f5d1 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/project/handle.rs @@ -0,0 +1,87 @@ +use std::{env, fs::File, io::Write}; + +use anyhow::{anyhow, Context, Result}; +use log::trace; + +use crate::{cli::ProjectCommand, task}; + +use super::{ProjectDefinition, ProjectList, SortAlphabetically}; + +/// # Panics +/// If internal expectations fail. +/// +/// # Errors +/// If IO operations fail. +pub fn handle(command: ProjectCommand) -> Result<()> { + match command { + ProjectCommand::List => { + for project in task::Project::all()? { + println!("{}", project.to_project_display()); + } + } + ProjectCommand::Add { + mut new_project_name, + } => { + let project_file = env::var("TSKM_PROJECT_FILE") + .map_err(|err| anyhow!("The `TSKM_PROJECT_FILE` env var is unset: {err}"))?; + + let mut projects_content: ProjectList = + serde_json::from_reader(File::open(&project_file).with_context(|| { + format!("Failed to open project file ('{project_file:?}') for reading") + })?)?; + + let first = new_project_name.project_segments.remove(0); + if let Some(mut definition) = projects_content.0.get_mut(&first) { + for segment in new_project_name.project_segments { + if definition.subprojects.contains_key(&segment) { + definition = definition + .subprojects + .get_mut(&segment) + .expect("We checked"); + } else { + let new_definition = ProjectDefinition::default(); + let output = definition + .subprojects + .insert(segment.clone(), new_definition); + + assert_eq!(output, None); + + definition = definition + .subprojects + .get_mut(&segment) + .expect("Was just inserted"); + } + } + } else { + let mut orig_definition = ProjectDefinition::default(); + let mut definition = &mut orig_definition; + for segment in new_project_name.project_segments { + trace!("Adding segment: {segment}"); + + let new_definition = ProjectDefinition::default(); + + assert!(definition + .subprojects + .insert(segment.clone(), new_definition) + .is_none()); + + definition = definition + .subprojects + .get_mut(&segment) + .expect("Was just inserted"); + } + assert!(projects_content.0.insert(first, orig_definition).is_none()); + }; + + let mut file = File::create(&project_file).with_context(|| { + format!("Failed to open project file ('{project_file:?}') for writing") + })?; + serde_json::to_writer_pretty( + &file, + &SortAlphabetically::<ProjectList>(projects_content), + )?; + writeln!(file)?; + } + } + Ok(()) +} diff --git a/pkgs/by-name/ts/tskm/src/interface/project/mod.rs b/pkgs/by-name/ts/tskm/src/interface/project/mod.rs new file mode 100644 index 00000000..62069746 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/project/mod.rs @@ -0,0 +1,73 @@ +use std::collections::HashMap; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +pub mod handle; +pub use handle::handle; + +#[derive(Deserialize, Serialize)] +struct ProjectList(HashMap<String, ProjectDefinition>); + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +struct ProjectDefinition { + #[serde(default)] + #[serde(skip_serializing_if = "is_default")] + name: String, + + #[serde(default)] + #[serde(skip_serializing_if = "is_default")] + prefix: String, + + #[serde(default)] + #[serde(skip_serializing_if = "is_default")] + subprojects: HashMap<String, ProjectDefinition>, +} + +fn is_default<T: Default + PartialEq>(input: &T) -> bool { + input == &T::default() +} + +#[derive(Debug, Clone)] +pub struct ProjectName { + project_segments: Vec<String>, +} + +impl ProjectName { + #[must_use] + pub fn segments(&self) -> &[String] { + &self.project_segments + } + + /// # Errors + /// Never. + pub fn try_from_project(s: &str) -> Result<Self> { + Ok(Self::from_project(s)) + } + pub fn from_project(s: &str) -> Self { + let me = Self { + project_segments: s.split('.').map(ToOwned::to_owned).collect(), + }; + me + } + pub fn from_context(s: &str) -> Self { + let me = Self { + project_segments: s.split('_').map(ToOwned::to_owned).collect(), + }; + me + } +} + +// Source: https://stackoverflow.com/a/67792465 +fn sort_alphabetically<T: Serialize, S: serde::Serializer>( + value: &T, + serializer: S, +) -> Result<S::Ok, S::Error> { + let value = serde_json::to_value(value).map_err(serde::ser::Error::custom)?; + value.serialize(serializer) +} + +#[derive(Serialize)] +pub(super) struct SortAlphabetically<T: Serialize>( + #[serde(serialize_with = "sort_alphabetically")] T, +); diff --git a/pkgs/by-name/ts/tskm/src/main.rs b/pkgs/by-name/ts/tskm/src/main.rs new file mode 100644 index 00000000..f4416c6d --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/main.rs @@ -0,0 +1,67 @@ +use anyhow::Result; +use clap::Parser; +use state::State; + +use crate::interface::{input, neorg, open, project}; + +pub mod cli; +pub mod interface; +pub mod rofi; +pub mod state; +pub mod task; + +use crate::cli::{CliArgs, Command}; + +fn main() -> Result<(), anyhow::Error> { + // TODO: Support these completions for the respective types <2025-04-04> + // + // ID_GENERATION_FUNCTION + // ```sh + // context="$(task _get rc.context)" + // if [ "$context" ]; then + // filter="project:$context" + // else + // filter="0-10000" + // fi + // tasks="$(task "$filter" _ids)" + // + // if [ "$tasks" ]; then + // echo "$tasks" | xargs task _zshids | awk -F: -v q="'" '{gsub(/'\''/, q "\\" q q ); print $1 ":" q $2 q}' + // fi + // ``` + // + // ARGUMENTS: + // ID | *([0-9]) := [[%ID_GENERATION_FUNCTION]] + // The function displays all possible IDs of the eligible tasks. + // + // WS := %ALL_WORKSPACES + // All possible workspaces. + // + // P := %ALL_PROJECTS_PIPE + // The possible project. + // + // F := [[fd . --max-depth 3]] + // A URL-Input file to use as source. + let args = CliArgs::parse(); + + stderrlog::new() + .module(module_path!()) + .quiet(args.quiet) + .show_module_names(true) + .color(stderrlog::ColorChoice::Auto) + .verbosity(usize::from(args.verbosity)) + .timestamp(stderrlog::Timestamp::Off) + .init() + .expect("Let's just hope that this does not panic"); + + let mut state = State::new_rw()?; + + match args.command { + Command::Inputs { command } => input::handle(command)?, + Command::Neorg { command } => neorg::handle(command, &mut state)?, + Command::Open { command } => open::handle(command, &mut state)?, + Command::Projects { command } => project::handle(command)?, + } + + Ok(()) +} diff --git a/pkgs/by-name/ts/tskm/src/rofi/mod.rs b/pkgs/by-name/ts/tskm/src/rofi/mod.rs new file mode 100644 index 00000000..a0591b7f --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/rofi/mod.rs @@ -0,0 +1,37 @@ +use std::{ + io::Write, + process::{Command, Stdio}, +}; + +use anyhow::{Context, Result}; + +pub fn select(options: &[String]) -> Result<String> { + let mut child = Command::new("rofi") + .args(["-sep", "\n", "-dmenu"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .context("Failed to spawn rofi")?; + + let mut stdin = child + .stdin + .take() + .expect("We piped this, so should be available"); + + stdin + .write_all(options.join("\n").as_bytes()) + .context("Failed to write to rofi's stdin")?; + + let output = child + .wait_with_output() + .context("Failed to wait for rofi's output")?; + + let selected = String::from_utf8(output.stdout.clone()).with_context(|| { + format!( + "Failed to decode '{}' as utf8", + String::from_utf8_lossy(&output.stdout) + ) + })?; + + Ok(selected.trim_end().to_owned()) +} diff --git a/pkgs/by-name/ts/tskm/src/state.rs b/pkgs/by-name/ts/tskm/src/state.rs new file mode 100644 index 00000000..175a7f03 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/state.rs @@ -0,0 +1,45 @@ +use std::path::PathBuf; + +use anyhow::Result; +use taskchampion::{storage::AccessMode, Replica, StorageConfig}; + +pub struct State { + replica: Replica, +} + +impl std::fmt::Debug for State { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "State") + } +} + +impl State { + fn taskdb_dir() -> PathBuf { + dirs::data_local_dir().expect("Should exist").join("task") + } + + fn new(taskdb_dir: PathBuf, access_mode: AccessMode) -> Result<Self> { + let storage = StorageConfig::OnDisk { + taskdb_dir, + create_if_missing: false, + access_mode, + } + .into_storage()?; + + let replica = Replica::new(storage); + + Ok(Self { replica }) + } + + pub fn new_ro() -> Result<Self> { + Self::new(Self::taskdb_dir(), AccessMode::ReadOnly) + } + pub fn new_rw() -> Result<Self> { + Self::new(Self::taskdb_dir(), AccessMode::ReadWrite) + } + + #[must_use] + pub fn replica(&mut self) -> &mut Replica { + &mut self.replica + } +} diff --git a/pkgs/by-name/ts/tskm/src/task/mod.rs b/pkgs/by-name/ts/tskm/src/task/mod.rs new file mode 100644 index 00000000..03a12faa --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/task/mod.rs @@ -0,0 +1,342 @@ +use std::{ + fmt::Display, + fs::{self, read_to_string, File}, + path::PathBuf, + process::Command, + str::FromStr, + sync::OnceLock, +}; + +use anyhow::{bail, Context, Result}; +use log::{debug, info, trace}; +use taskchampion::Tag; + +use crate::{interface::project::ProjectName, state::State}; + +/// The `taskwarrior` id of a task. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Ord, Eq)] +pub struct Task { + uuid: taskchampion::Uuid, +} + +impl From<&taskchampion::Task> for Task { + fn from(value: &taskchampion::Task) -> Self { + Self { + uuid: value.get_uuid(), + } + } +} +impl From<&taskchampion::TaskData> for Task { + fn from(value: &taskchampion::TaskData) -> Self { + Self { + uuid: value.get_uuid(), + } + } +} + +impl Task { + pub fn from_working_set(id: usize, state: &mut State) -> Result<Option<Self>> { + Ok(state + .replica() + .working_set()? + .by_index(id) + .map(|uuid| Self { uuid })) + } + + pub fn get_current(state: &mut State) -> Result<Option<Self>> { + let tasks = state + .replica() + .pending_tasks()? + .into_iter() + .filter(taskchampion::Task::is_active) + .collect::<Vec<_>>(); + + assert!( + tasks.len() <= 1, + "We have ensured that only one task may be active, via a hook" + ); + if let Some(active) = tasks.first() { + Ok(Some(Self::from(active))) + } else { + Ok(None) + } + } + + #[must_use] + pub fn uuid(&self) -> &taskchampion::Uuid { + &self.uuid + } + + fn as_task(&self, state: &mut State) -> Result<taskchampion::Task> { + Ok(state + .replica() + .get_task(self.uuid)? + .expect("We have the task from this replica, it should still be in it")) + } + + /// Adds a tag to the task, to show the user that it has additional neorg data. + pub fn mark_neorg_data(&self, state: &mut State) -> Result<()> { + let mut ops = vec![]; + self.as_task(state)? + .add_tag(&Tag::from_str("neorg_data").expect("Is valid"), &mut ops)?; + state.replica().commit_operations(ops)?; + Ok(()) + } + + /// Try to start this task. + /// It will stop previously active tasks. + pub fn start(&self, state: &mut State) -> Result<()> { + info!("Activating {self}"); + + if let Some(active) = Self::get_current(state)? { + active.stop(state)?; + } + + let mut ops = vec![]; + self.as_task(state)?.start(&mut ops)?; + state.replica().commit_operations(ops)?; + Ok(()) + } + + /// Stops this task. + pub fn stop(&self, state: &mut State) -> Result<()> { + info!("Stopping {self}"); + + let mut ops = vec![]; + self.as_task(state)?.stop(&mut ops)?; + state.replica().commit_operations(ops)?; + Ok(()) + } + + pub fn description(&self, state: &mut State) -> Result<String> { + Ok(self.as_task(state)?.get_description().to_owned()) + } + + pub fn project(&self, state: &mut State) -> Result<Project> { + let output = { + let task = self.as_task(state)?; + let task_data = task.into_task_data(); + task_data + .get("project") + .expect("Every task should have a project") + .to_owned() + }; + let project = Project::from_project_string(output.as_str()) + .expect("This comes from tw, it should be valid"); + Ok(project) + } +} + +impl Display for Task { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.uuid.fmt(f) + } +} + +impl FromStr for Task { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let uuid = taskchampion::Uuid::from_str(s)?; + Ok(Self { uuid }) + } +} + +/// A registered task Project +#[derive(Debug, Clone, PartialEq)] +pub struct Project { + /// The project name. + /// For example: + /// ```no_run + /// &["trinitrix", "testing", "infra"] + /// ``` + name: Vec<String>, +} + +static ALL_CACHE: OnceLock<Vec<Project>> = OnceLock::new(); +impl Project { + #[must_use] + pub fn to_project_display(&self) -> String { + self.name.join(".") + } + #[must_use] + pub fn to_context_display(&self) -> String { + self.name.join("_") + } + + /// # Errors + /// - When the string does not encode a previously registered project. + /// - When the string does not adhere to the project syntax. + pub fn from_project_string(s: &str) -> Result<Self> { + Self::from_input(s, ProjectName::from_project) + } + + /// # Errors + /// - When the string does not encode a previously registered project. + /// - When the string does not adhere to the context syntax. + pub fn from_context_string(s: &str) -> Result<Self> { + Self::from_input(s, ProjectName::from_context) + } + + fn from_input<F>(s: &str, f: F) -> Result<Self> + where + F: Fn(&str) -> ProjectName, + { + if s.is_empty() { + bail!("Your project is empty") + } + + let all = Self::all()?; + let me = Self::from_project_name_unchecked(&f(s)); + if all.contains(&me) { + Ok(me) + } else { + bail!( + "Your project '{}' is not registered!", + me.to_project_display() + ); + } + } + fn from_project_name_unchecked(pn: &ProjectName) -> Self { + Self { + name: pn.segments().to_owned(), + } + } + + /// Return all known valid projects. + /// + /// # Errors + /// When file operations fail. + /// + /// # Panics + /// Only when internal assertions fail. + pub fn all<'a>() -> Result<&'a [Project]> { + // Inlined from `OnceLock::get_or_try_init` + { + let this = &ALL_CACHE; + let f = || { + let file = dirs::config_local_dir() + .expect("Should be some") + .join("tskm/projects.list"); + let contents = read_to_string(&file) + .with_context(|| format!("Failed to read file: '{}'", file.display()))?; + + Ok::<_, anyhow::Error>( + contents + .lines() + .map(|s| Self::from_project_name_unchecked(&ProjectName::from_project(s))) + .collect::<Vec<_>>(), + ) + }; + + // Fast path check + // NOTE: We need to perform an acquire on the state in this method + // in order to correctly synchronize `LazyLock::force`. This is + // currently done by calling `self.get()`, which in turn calls + // `self.is_initialized()`, which in turn performs the acquire. + if let Some(value) = this.get() { + return Ok(value); + } + + this.set(f()?).expect( + "This should always be able to take our value, as we initialize only once.", + ); + + Ok(this.get().expect("This was initialized")) + } + } + + fn touch_dir(&self) -> PathBuf { + let lock_dir = dirs::data_dir() + .expect("Should be found") + .join("tskm/review"); + lock_dir.join(format!("{}.opened", self.to_project_display())) + } + + /// Mark this project as having been interacted with. + /// + /// # Errors + /// When IO operations fail. + pub fn touch(&self) -> Result<()> { + let lock_file = self.touch_dir(); + + File::create(&lock_file) + .with_context(|| format!("Failed to create lock_file at: {}", lock_file.display()))?; + + Ok(()) + } + /// Returns [`true`] if it was previously [`Self::touch`]ed. + #[must_use] + pub fn is_touched(&self) -> bool { + let lock_file = self.touch_dir(); + lock_file.exists() + } + /// Mark this project as having not been interacted with. + /// + /// # Errors + /// When IO operations fail. + pub fn untouch(&self) -> Result<()> { + let lock_file = self.touch_dir(); + + fs::remove_file(&lock_file) + .with_context(|| format!("Failed to create lock_file at: {}", lock_file.display()))?; + + Ok(()) + } + + /// # Errors + /// When `task` execution fails. + pub fn get_tasks(&self, state: &mut State) -> Result<Vec<Task>> { + Ok(state + .replica() + .pending_task_data()? + .into_iter() + .filter(|t| t.get("project").expect("Is set") == self.to_project_display()) + .map(|t| Task::from(&t)) + .collect()) + } + + /// # Errors + /// When `task` execution fails. + pub fn activate(&self) -> Result<()> { + debug!("Setting project {}", self.to_context_display()); + + run_task(&["context", self.to_context_display().as_str()]).map(|_| ()) + } + /// # Errors + /// When `task` execution fails. + pub fn clear() -> Result<()> { + debug!("Clearing active project"); + + run_task(&["context", "none"]).map(|_| ()) + } + + /// # Errors + /// When `task` execution fails. + pub fn get_current() -> Result<Option<Self>> { + let self_str = run_task(&["_get", "rc.context"])?; + + if self_str.is_empty() { + Ok(None) + } else { + Self::from_context_string(&self_str).map(Some) + } + } +} + +pub(crate) fn run_task(args: &[&str]) -> Result<String> { + debug!("Running task command: `task {}`", args.join(" ")); + + let output = Command::new("task") + .args(args) + .output() + .with_context(|| format!("Failed to run `task {}`", args.join(" ")))?; + + let stdout = String::from_utf8(output.stdout).context("Failed to read task output as utf8")?; + let stderr = String::from_utf8(output.stderr).context("Failed to read task output as utf8")?; + + trace!("Output (stdout): '{}'", stdout.trim()); + trace!("Output (stderr): '{}'", stderr.trim()); + + Ok(stdout.trim().to_owned()) +} |