aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/by-name/ts/tskm/src
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-04-04 11:48:44 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-04-04 11:48:44 +0200
commit135d09bfb305d54cac1ba1fb9861d5b9309a7b3a (patch)
tree459109a40320530993ae560f55a730a72df31416 /pkgs/by-name/ts/tskm/src
parentrefactor(modules/legacy/firefox): Move to by-name (diff)
downloadnixos-config-135d09bfb305d54cac1ba1fb9861d5b9309a7b3a.zip
feat(pkgs/neorg): Rewrite in rust
This improves upon neorg by integrating it better into the system context.
Diffstat (limited to 'pkgs/by-name/ts/tskm/src')
-rw-r--r--pkgs/by-name/ts/tskm/src/cli.rs115
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/input/handle.rs112
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/input/mod.rs257
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/mod.rs4
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs79
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs18
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/open/handle.rs137
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/open/mod.rs2
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/project/handle.rs87
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/project/mod.rs73
-rw-r--r--pkgs/by-name/ts/tskm/src/main.rs66
-rw-r--r--pkgs/by-name/ts/tskm/src/rofi/mod.rs37
-rw-r--r--pkgs/by-name/ts/tskm/src/task/mod.rs322
13 files changed, 1309 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..958033b3
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/cli.rs
@@ -0,0 +1,115 @@
+use std::path::PathBuf;
+
+use clap::{Parser, Subcommand};
+
+use crate::{
+ interface::{input::Input, project::ProjectName},
+ 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,
+}
+
+#[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 { id: task::Id },
+}
+
+#[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: Option<task::Project>,
+ },
+
+ /// 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,
+}
+
+#[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..ea11f1e2
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs
@@ -0,0 +1,79 @@
+use std::{
+ env,
+ fs::{self, read_to_string, OpenOptions},
+ io::Write,
+ process::Command,
+};
+
+use anyhow::{bail, Result};
+
+use crate::cli::NeorgCommand;
+
+pub fn handle(command: NeorgCommand) -> Result<()> {
+ match command {
+ NeorgCommand::Task { id } => {
+ let project = id.project()?;
+ let path = dirs::data_local_dir()
+ .expect("This should exists")
+ .join("notes")
+ .join(project.get_neorg_path()?);
+
+ fs::create_dir_all(path.parent().expect("This should exist"))?;
+
+ {
+ let contents = read_to_string(&path)?;
+ if contents.contains(format!("% {}", id.to_uuid()?).as_str()) {
+ let mut options = OpenOptions::new();
+ options.append(true).create(true);
+
+ let mut file = options.open(&path)?;
+ file.write_all(format!("* TITLE (% {})", id.to_uuid()?).as_bytes())?;
+ }
+ }
+
+ 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.to_uuid()?).as_str(),
+ ])
+ .status()?;
+ if !status.success() {
+ bail!("$EDITOR fail with error code: {status}");
+ }
+
+ {
+ let status = Command::new("git")
+ .args(["add", "."])
+ .current_dir(&path)
+ .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)
+ .status()?;
+ if !status.success() {
+ bail!("Git commit failed!");
+ }
+ }
+
+ {
+ id.annotate("[neorg data]]")?;
+ }
+ }
+ }
+ 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..5738d232
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/open/handle.rs
@@ -0,0 +1,137 @@
+use std::process;
+
+use anyhow::{bail, Context, Result};
+use log::{error, info};
+
+use crate::{cli::OpenCommand, rofi, task};
+
+pub fn handle(command: OpenCommand) -> Result<()> {
+ match command {
+ OpenCommand::Review => {
+ for project in task::Project::all()? {
+ if project.is_touched() {
+ info!("Reviewing project: '{}'", project.to_project_display());
+ open_in_browser(project).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 } => {
+ let project = if let Some(p) = project {
+ p
+ } else if let Some(p) = task::Project::get_current()? {
+ p
+ } else {
+ bail!("You need to either supply a project or have a project active!");
+ };
+
+ project.touch().context("Failed to touch project")?;
+ open_in_browser(&project).context("Failed to open project")?;
+ }
+ OpenCommand::Select => {
+ 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).context("Failed to open project")?;
+ }
+ }
+ Ok(())
+}
+
+fn open_in_browser(selected_project: &task::Project) -> Result<()> {
+ let old_project: Option<task::Project> =
+ task::Project::get_current().context("Failed to get currently active project")?;
+ // We have ensured that only one task may be active
+ let old_task: Option<task::Id> =
+ task::Id::get_current().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().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();
+ 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()?;
+ }
+ tracking_task
+ };
+
+ let status = process::Command::new("firefox")
+ .args([
+ "-P",
+ selected_project.to_project_display().as_str(),
+ "about:newtab",
+ ])
+ .status()
+ .context("Failed to start firefox")?;
+
+ if !status.success() {
+ error!("Firefox run exited with error.");
+ }
+
+ if let Some(task) = tracking_task {
+ task.stop()?;
+ }
+ if let Some(task) = old_task {
+ task.start()?;
+ }
+
+ if let Some(project) = old_project {
+ project.activate()?;
+ } else {
+ task::Project::clear()?;
+ }
+
+ 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..a70793bc
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/open/mod.rs
@@ -0,0 +1,2 @@
+pub mod handle;
+pub use handle::handle;
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..d1cc0b1e
--- /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..7fc9c0d4
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/main.rs
@@ -0,0 +1,66 @@
+#![allow(clippy::missing_panics_doc)]
+#![allow(clippy::missing_errors_doc)]
+
+use anyhow::Result;
+use clap::Parser;
+
+use crate::interface::{input, neorg, open, project};
+
+pub mod cli;
+pub mod interface;
+pub mod rofi;
+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(false)
+ .show_module_names(true)
+ .color(stderrlog::ColorChoice::Auto)
+ .verbosity(5)
+ .timestamp(stderrlog::Timestamp::Off)
+ .init()
+ .expect("Let's just hope that this does not panic");
+
+ match args.command {
+ Command::Inputs { command } => input::handle(command)?,
+ Command::Neorg { command } => neorg::handle(command)?,
+ Command::Open { command } => open::handle(command)?,
+ 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/task/mod.rs b/pkgs/by-name/ts/tskm/src/task/mod.rs
new file mode 100644
index 00000000..c3a6d614
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/task/mod.rs
@@ -0,0 +1,322 @@
+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 crate::interface::project::ProjectName;
+
+/// The `taskwarrior` id of a task.
+#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Ord, Eq)]
+pub struct Id {
+ id: u64,
+}
+impl Id {
+ /// # Errors
+ /// When `task` execution fails
+ pub fn get_current() -> Result<Option<Self>> {
+ // We have ensured that only one task may be active
+ let self_str = run_task(&["+ACTIVE", "_ids"])?;
+
+ if self_str.is_empty() {
+ Ok(None)
+ } else {
+ Self::from_str(&self_str).map(Some)
+ }
+ }
+
+ /// # Errors
+ /// When `task` execution fails
+ pub fn to_uuid(&self) -> Result<String> {
+ let uuid = run_task(&[self.to_string().as_str(), "uuids"])?;
+
+ Ok(uuid)
+ }
+
+ /// # Panics
+ /// When internal assertions fail.
+ /// # Errors
+ /// When `task` execution fails
+ pub fn annotate(&self, message: &str) -> Result<()> {
+ run_task(&["annotate", self.to_string().as_str(), "--", message])?;
+ Ok(())
+ }
+
+ /// # Panics
+ /// When internal assertions fail.
+ /// # Errors
+ /// When `task` execution fails
+ pub fn start(&self) -> Result<()> {
+ info!("Activating {self}");
+
+ let output = run_task(&["start", self.to_string().as_str()])?;
+ assert!(output.is_empty());
+ Ok(())
+ }
+ /// # Panics
+ /// When internal assertions fail.
+ /// # Errors
+ /// When `task` execution fails
+ pub fn stop(&self) -> Result<()> {
+ info!("Stopping {self}");
+
+ let output = run_task(&["stop", self.to_string().as_str()])?;
+ assert!(output.is_empty());
+ Ok(())
+ }
+
+ /// # Panics
+ /// When internal assertions fail.
+ /// # Errors
+ /// When `task` execution fails
+ pub fn description(&self) -> Result<String> {
+ let output = run_task(&["rc.context=none", "_zshids", self.to_string().as_str()])?;
+ let (id, desc) = output
+ .split_once(':')
+ .expect("The output should always contain one colon");
+ assert_eq!(id.parse::<Id>().expect("This should be a valid id"), *self);
+ Ok(desc.to_owned())
+ }
+
+ /// # Panics
+ /// When internal assertions fail.
+ /// # Errors
+ /// When `task` execution fails
+ pub fn project(&self) -> Result<Project> {
+ let output = run_task(&[
+ "rc.context=none",
+ "_get",
+ format!("{self}.project").as_str(),
+ ])?;
+ let project = Project::from_project_string(output.as_str())
+ .expect("This comes from tw, it should be valid");
+ Ok(project)
+ }
+}
+
+impl Display for Id {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.id.fmt(f)
+ }
+}
+
+impl FromStr for Id {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let id = u64::from_str(s)?;
+ Ok(Self { id })
+ }
+}
+
+/// 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) -> Result<Vec<Id>> {
+ let output = run_task(&[
+ "rc.context=none",
+ format!("project:{}", self.to_project_display()).as_str(),
+ "_ids",
+ ])?;
+
+ if output.is_empty() {
+ Ok(vec![])
+ } else {
+ output
+ .lines()
+ .map(Id::from_str)
+ .collect::<Result<Vec<Id>>>()
+ }
+ }
+
+ /// # 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())
+}