aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/by-name/ts/tskm/src/interface
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/by-name/ts/tskm/src/interface')
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/input/handle.rs155
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/input/mod.rs260
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/mod.rs14
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs101
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs35
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/open/handle.rs196
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/open/mod.rs198
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/project/handle.rs99
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/project/mod.rs83
9 files changed, 1141 insertions, 0 deletions
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..cd868f7a
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/input/handle.rs
@@ -0,0 +1,155 @@
+// nixos-config - My current NixOS configuration
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of my nixos-config.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use std::{
+ collections::{HashMap, HashSet},
+ fs,
+ str::FromStr,
+};
+
+use anyhow::{Context, Result};
+use log::info;
+use taskchampion::chrono::Utc;
+
+use crate::{browser::open_in_browser, cli::InputCommand, state::State};
+
+use super::{Input, Tag};
+
+/// # Errors
+/// When command handling fails.
+///
+/// # Panics
+/// When internal assertions fail.
+#[allow(clippy::too_many_lines)]
+pub async fn handle(command: InputCommand, state: &mut State) -> 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, tags } => {
+ let file = fs::read_to_string(&file)
+ .with_context(|| format!("Failed to read input file '{}'", file.display()))?;
+
+ let mut tag_set = HashSet::with_capacity(tags.len());
+ for tag in tags {
+ tag_set.insert(tag);
+ }
+
+ tag_set.insert(
+ Tag::new(format!("+{}", Utc::now().format("%Y-%m-%d")).as_str())
+ .expect("hardcoded"),
+ );
+
+ for line in file.lines().map(str::trim) {
+ if line.is_empty() {
+ continue;
+ }
+
+ let mut input = Input::from_str(line)?;
+ input.tags = input.tags.union(&tag_set).cloned().collect();
+
+ input.commit().with_context(|| {
+ format!("Failed to add input ('{input}') to the input storage.")
+ })?;
+ }
+ }
+ InputCommand::Review { project } => {
+ 'outer: for all in Input::all()?.chunks(100) {
+ info!("Starting review for the first hundred URLs.");
+
+ open_in_browser(
+ &project,
+ state,
+ Some(all.iter().map(|f| f.url.clone()).collect()),
+ )
+ .await?;
+
+ {
+ 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;
+ }
+ }
+ }
+ }
+ InputCommand::List { tags } => {
+ let mut tag_set = HashSet::with_capacity(tags.len());
+ for tag in tags {
+ tag_set.insert(tag);
+ }
+
+ for url in Input::all()?
+ .iter()
+ .filter(|input| tag_set.is_subset(&input.tags))
+ {
+ println!("{url}");
+ }
+ }
+ InputCommand::Tags {} => {
+ let mut without_tags = 0;
+ let mut tag_set: HashMap<Tag, u64> = HashMap::new();
+
+ for input in Input::all()? {
+ if input.tags.is_empty() {
+ without_tags += 1;
+ }
+
+ for tag in input.tags {
+ if let Some(number) = tag_set.get_mut(&tag) {
+ *number += 1;
+ } else {
+ tag_set.insert(tag, 1);
+ }
+ }
+ }
+
+ let mut tags: Vec<(Tag, u64)> = tag_set.into_iter().collect();
+ tags.sort_by_key(|(_, number)| *number);
+ tags.reverse();
+
+ for (tag, number) in tags {
+ println!("{tag} {number}");
+ }
+
+ if without_tags != 0 {
+ println!();
+ println!("Witohut tags: {without_tags}");
+ }
+ }
+ }
+ 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..b6176a96
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/input/mod.rs
@@ -0,0 +1,260 @@
+// nixos-config - My current NixOS configuration
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of my nixos-config.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use std::{
+ collections::{HashMap, HashSet},
+ fmt::Display,
+ fs,
+ io::Write,
+ path::PathBuf,
+ process::Command,
+ str::FromStr,
+};
+
+use anyhow::{Context, Result, bail};
+use url::Url;
+use walkdir::WalkDir;
+
+pub mod handle;
+pub use handle::handle;
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct Tag(String);
+
+impl Tag {
+ pub fn new(input: &str) -> Result<Self> {
+ Self::from_str(input)
+ }
+}
+
+impl FromStr for Tag {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
+ if let Some(tag) = s.strip_prefix('+') {
+ if tag.contains(' ') {
+ bail!("Your tag '{s}' should not whitespace.")
+ }
+
+ Ok(Self(tag.to_owned()))
+ } else {
+ bail!("Your tag '{s}' does not start with the required '+'");
+ }
+ }
+}
+
+impl Display for Tag {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "+{}", self.0)
+ }
+}
+
+impl Tag {
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Input {
+ url: Url,
+ tags: HashSet<Tag>,
+}
+
+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::new)
+ .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()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>()
+ .join(" ")
+ )
+ }
+ }
+}
+
+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.scheme())
+ .join(url.host_str().unwrap_or("<No Host>"))
+ .join(url.path().trim_matches('/'));
+ 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 mut file = fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&url_path)
+ .with_context(|| format!("Failed to open file: '{}'", url_path.display()))?;
+ writeln!(file, "{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(())
+ }
+
+ /// Get all previously [committed][`Self::commit`] 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();
+ assert!(url_value_file.ends_with("url_value"));
+
+ let url_values = fs::read_to_string(PathBuf::from(url_value_file))?;
+
+ let mut inputs: HashMap<Url, Self> = HashMap::new();
+ for input in url_values
+ .lines()
+ .map(Self::from_str)
+ .collect::<Result<Vec<Self>, _>>()?
+ {
+ if let Some(found) = inputs.get_mut(&input.url) {
+ found.tags = found.tags.union(&input.tags).cloned().collect();
+ } else {
+ assert_eq!(inputs.insert(input.url.clone(), input), None);
+ }
+ }
+
+ output.extend(inputs.drain().map(|(_, value)| value));
+ }
+
+ Ok(output)
+ }
+
+ /// Commit your changes
+ fn git_commit(message: &str) -> Result<()> {
+ if !Self::base_path().join(".git").exists() {
+ let status = Command::new("git")
+ .args(["init"])
+ .current_dir(Self::base_path())
+ .status()?;
+ if !status.success() {
+ bail!("Git init failed!");
+ }
+ }
+
+ 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(())
+ }
+}
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..513ca317
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/mod.rs
@@ -0,0 +1,14 @@
+// nixos-config - My current NixOS configuration
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of my nixos-config.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+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..12a0180d
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs
@@ -0,0 +1,101 @@
+// nixos-config - My current NixOS configuration
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of my nixos-config.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+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 async fn handle(command: NeorgCommand, state: &mut State) -> Result<()> {
+ match command {
+ NeorgCommand::Task { task } => {
+ let project = task.project(state).await?;
+ let base = dirs::data_local_dir()
+ .expect("This should exists")
+ .join("tskm/notes");
+ let path = base.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!("% {}", task.uuid()).as_str()) {
+ let mut options = OpenOptions::new();
+ options.append(true).create(false);
+
+ let mut file = options.open(&path)?;
+ file.write_all(
+ format!("* {} (% {})", task.description(state).await?, task.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!("/% {}", task.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", project.get_neorg_path()?.display()).as_str(),
+ "--no-gpg-sign",
+ ])
+ .current_dir(path.parent().expect("Will exist"))
+ .status()?;
+ if !status.success() {
+ bail!("Git commit failed!");
+ }
+ }
+
+ {
+ task.mark_neorg_data(state).await?;
+ }
+ }
+ }
+ 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..6bed1e39
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs
@@ -0,0 +1,35 @@
+// nixos-config - My current NixOS configuration
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of my nixos-config.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use std::path::PathBuf;
+
+use anyhow::Result;
+
+use crate::task::{Project, run_task};
+
+pub mod handle;
+pub use handle::handle;
+
+impl Project {
+ /// Return the stored neorg path of this project.
+ /// The returned path will never start with a slash (/).
+ 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(),
+ ])?;
+
+ let final_path = project_path
+ .strip_prefix('/')
+ .unwrap_or(project_path.as_str());
+
+ Ok(PathBuf::from(final_path))
+ }
+}
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..5b9100bc
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/open/handle.rs
@@ -0,0 +1,196 @@
+// nixos-config - My current NixOS configuration
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of my nixos-config.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use std::str::FromStr;
+
+use anyhow::{bail, Context, Result};
+use log::{error, info};
+use url::Url;
+
+use crate::{browser::open_in_browser, cli::OpenCommand, rofi, state::State, task};
+
+fn is_empty(project: &task::Project) -> Result<bool> {
+ let tabs = get_tabs(project)?;
+
+ if tabs.is_empty() {
+ Ok(true)
+ } else if tabs.len() > 1 {
+ Ok(false)
+ } else {
+ let url = &tabs[0].1;
+
+ Ok(url == &Url::from_str("qute://start/").expect("Hardcoded"))
+ }
+}
+
+#[allow(clippy::too_many_lines)]
+pub async fn handle(command: OpenCommand, state: &mut State) -> Result<()> {
+ match command {
+ OpenCommand::Review { non_empty } => {
+ for project in task::Project::all().context("Failed to get all project files")? {
+ let is_empty = is_empty(project)?;
+
+ if project.is_touched() || (non_empty && !is_empty) {
+ info!(
+ "Reviewing project: '{}' ({})",
+ project.to_project_display(),
+ if is_empty { "is empty" } else { "is not empty" }
+ );
+ open_in_browser(project, state, None::<Vec<Url>>)
+ .await
+ .with_context(|| {
+ format!(
+ "Failed to open project ('{}') in qutebrowser",
+ project.to_project_display()
+ )
+ })?;
+
+ if project.is_touched() {
+ project.untouch().with_context(|| {
+ format!(
+ "Failed to untouch project ('{}')",
+ project.to_project_display()
+ )
+ })?;
+ }
+ }
+ }
+ }
+ OpenCommand::Project { project, urls } => {
+ project.touch().context("Failed to touch project")?;
+ open_in_browser(&project, state, urls)
+ .await
+ .with_context(|| {
+ format!("Failed to open project: {}", project.to_project_display())
+ })?;
+ }
+ OpenCommand::Select { urls } => {
+ 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, urls)
+ .await
+ .context("Failed to open project")?;
+ }
+ OpenCommand::ListTabs { projects, mode } => {
+ let projects = {
+ if let Some(p) = projects {
+ p
+ } else if mode.is_some() {
+ task::Project::all()
+ .context("Failed to get all projects")?
+ .to_owned()
+ } else if let Some(p) = task::Project::get_current()
+ .context("Failed to get currently focused project")?
+ {
+ vec![p]
+ } else {
+ bail!("You need to either select projects or pass --mode");
+ }
+ };
+
+ for project in &projects {
+ if let Some(mode) = mode {
+ match mode {
+ crate::cli::ListMode::Empty => {
+ if !is_empty(project)? {
+ continue;
+ }
+
+ // We do not need to print, tabs they are always empty.
+ if projects.len() > 1 {
+ println!("/* {} */", project.to_project_display());
+ }
+ continue;
+ }
+ crate::cli::ListMode::NonEmpty => {
+ if is_empty(project)? {
+ continue;
+ }
+ }
+ }
+ }
+
+ if projects.len() > 1 {
+ println!("/* {} */", project.to_project_display());
+ }
+
+ let tabs = match get_tabs(project) {
+ Ok(ok) => ok,
+ Err(err) => {
+ if projects.len() > 1 {
+ error!(
+ "While trying to get the sessionstore for {}: {:?}",
+ project.to_project_display(),
+ err
+ );
+ continue;
+ }
+
+ return Err(err).with_context(|| {
+ format!(
+ "While trying to get the sessionstore for {}",
+ project.to_project_display()
+ )
+ });
+ }
+ };
+
+ for (active, url) in tabs {
+ let is_selected = {
+ if active {
+ "🔻 "
+ } else {
+ " "
+ }
+ };
+ println!("{is_selected}{url}");
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn get_tabs(project: &task::Project) -> Result<Vec<(bool, Url)>> {
+ let session_store = project.get_sessionstore()?;
+
+ let tabs = session_store
+ .windows
+ .iter()
+ .flat_map(|window| window.tabs.iter())
+ .filter_map(|tab| {
+ tab.history
+ .iter()
+ .find(|hist| hist.active)
+ .map(|hist| (tab.active, hist))
+ })
+ .collect::<Vec<_>>();
+
+ Ok(tabs
+ .into_iter()
+ .map(|(active, hist)| (active, hist.url.clone()))
+ .collect())
+}
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..407536d2
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/open/mod.rs
@@ -0,0 +1,198 @@
+// nixos-config - My current NixOS configuration
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of my nixos-config.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use std::{
+ fs::{self, File},
+ io::Read,
+ str::FromStr,
+};
+
+use anyhow::{Context, Result, anyhow};
+use taskchampion::chrono::NaiveDateTime;
+use url::Url;
+use yaml_rust2::Yaml;
+
+use crate::task::Project;
+
+pub mod handle;
+pub use handle::handle;
+
+/// An Url that also accepts file paths
+#[derive(Debug, Clone)]
+pub struct UrlLike(Url);
+
+impl FromStr for UrlLike {
+ type Err = url::ParseError;
+
+ fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
+ if let Ok(u) = fs::canonicalize(s) {
+ Ok(Self(Url::from_file_path(u).expect(
+ "The path could be canonicalized, as such it is valid for this",
+ )))
+ } else {
+ Url::from_str(s).map(Self)
+ }
+ }
+}
+
+impl From<UrlLike> for Url {
+ fn from(value: UrlLike) -> Self {
+ value.0
+ }
+}
+
+impl Project {
+ pub(super) fn get_sessionstore(&self) -> Result<SessionStore> {
+ let path = dirs::data_local_dir()
+ .context("Failed to get data dir")?
+ .join("qutebrowser")
+ .join(self.to_project_display())
+ .join("data/sessions/default.yml");
+
+ let mut file = File::open(&path)
+ .with_context(|| format!("Failed to open path '{}'", path.display()))?;
+
+ let mut yaml_str = String::new();
+ file.read_to_string(&mut yaml_str)
+ .context("Failed to read _autosave.yml path")?;
+ let yaml = yaml_rust2::YamlLoader::load_from_str(&yaml_str)?;
+
+ let store = qute_store_from_yaml(&yaml).context("Failed to read yaml store")?;
+
+ Ok(store)
+ }
+}
+
+fn qute_store_from_yaml(yaml: &[Yaml]) -> Result<SessionStore> {
+ assert_eq!(yaml.len(), 1);
+ let doc = &yaml[0];
+
+ let hash = doc.as_hash().context("Invalid yaml")?;
+ let windows = hash
+ .get(&Yaml::String("windows".to_owned()))
+ .ok_or(anyhow!("Missing windows"))?
+ .as_vec()
+ .ok_or(anyhow!("Windows not vector"))?;
+
+ Ok(SessionStore {
+ windows: windows
+ .iter()
+ .map(|window| {
+ let hash = window.as_hash().ok_or(anyhow!("Windows not hashmap"))?;
+
+ Ok::<_, anyhow::Error>(Window {
+ geometry: hash
+ .get(&Yaml::String("geometry".to_owned()))
+ .ok_or(anyhow!("Missing window geometry"))?
+ .as_str()
+ .ok_or(anyhow!("geometry not string"))?
+ .to_owned(),
+ tabs: hash
+ .get(&Yaml::String("tabs".to_owned()))
+ .ok_or(anyhow!("Missing window tabs"))?
+ .as_vec()
+ .ok_or(anyhow!("Tabs not vec"))?
+ .iter()
+ .map(|tab| {
+ let hash = tab.as_hash().ok_or(anyhow!("Tab not hashmap"))?;
+
+ Ok::<_, anyhow::Error>(Tab {
+ history: hash
+ .get(&Yaml::String("history".to_owned()))
+ .ok_or(anyhow!("Missing tab history"))?
+ .as_vec()
+ .ok_or(anyhow!("tab history not vec"))?
+ .iter()
+ .map(|history| {
+ let hash = history
+ .as_hash()
+ .ok_or(anyhow!("Tab history not hashmap"))?;
+
+ Ok::<_, anyhow::Error>(TabHistory {
+ active: hash
+ .get(&Yaml::String("active".to_owned()))
+ .unwrap_or(&Yaml::Boolean(false))
+ .as_bool()
+ .ok_or(anyhow!("tab history active not bool"))?,
+ last_visited: NaiveDateTime::from_str(
+ hash.get(&Yaml::String("last_visited".to_owned()))
+ .ok_or(anyhow!(
+ "Missing tab history last_visited"
+ ))?
+ .as_str()
+ .ok_or(anyhow!(
+ "tab history last_visited not string"
+ ))?,
+ )
+ .context("Failed to parse last_visited")?,
+ pinned: hash
+ .get(&Yaml::String("pinned".to_owned()))
+ .ok_or(anyhow!("Missing tab history pinned"))?
+ .as_bool()
+ .ok_or(anyhow!("tab history pinned not bool"))?,
+ title: hash
+ .get(&Yaml::String("title".to_owned()))
+ .ok_or(anyhow!("Missing tab history title"))?
+ .as_str()
+ .ok_or(anyhow!("tab history title not string"))?
+ .to_owned(),
+ url: Url::parse(
+ hash.get(&Yaml::String("url".to_owned()))
+ .ok_or(anyhow!("Missing tab history url"))?
+ .as_str()
+ .ok_or(anyhow!("tab history url not string"))?,
+ )
+ .context("Failed to parse url")?,
+ zoom: hash
+ .get(&Yaml::String("zoom".to_owned()))
+ .unwrap_or(&Yaml::Real("1.0".to_owned()))
+ .as_f64()
+ .ok_or(anyhow!("tab history zoom not 64"))?,
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?,
+ active: hash
+ .get(&Yaml::String("active".to_owned()))
+ .unwrap_or(&Yaml::Boolean(false))
+ .as_bool()
+ .ok_or(anyhow!("active not bool"))?,
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?,
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?,
+ })
+}
+
+#[derive(Debug)]
+pub struct SessionStore {
+ pub windows: Vec<Window>,
+}
+#[derive(Debug)]
+pub struct Window {
+ pub geometry: String,
+ pub tabs: Vec<Tab>,
+}
+#[derive(Debug)]
+pub struct Tab {
+ pub history: Vec<TabHistory>,
+ pub active: bool,
+}
+#[derive(Debug)]
+pub struct TabHistory {
+ pub active: bool,
+ pub last_visited: NaiveDateTime,
+ pub pinned: bool,
+ // pub scroll-pos:
+ pub title: String,
+ pub url: Url,
+ pub zoom: f64,
+}
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..6d44b340
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/project/handle.rs
@@ -0,0 +1,99 @@
+// nixos-config - My current NixOS configuration
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of my nixos-config.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use std::{env, fs::File, io::Write};
+
+use anyhow::{Context, Result, anyhow};
+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..8a7fa1b0
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/interface/project/mod.rs
@@ -0,0 +1,83 @@
+// nixos-config - My current NixOS configuration
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of my nixos-config.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+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,
+);