diff options
Diffstat (limited to '')
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/input/handle.rs | 148 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/input/mod.rs | 260 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/mod.rs | 14 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs | 100 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs | 35 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/open/handle.rs | 190 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/open/mod.rs | 170 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/project/handle.rs | 99 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/project/mod.rs | 83 |
9 files changed, 1099 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..76eea6dc --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/input/handle.rs @@ -0,0 +1,148 @@ +// 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 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 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); + } + + 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()), + )?; + + { + 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..1d1d67f4 --- /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::{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 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..ea3a89ae --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs @@ -0,0 +1,100 @@ +// 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 fn handle(command: NeorgCommand, state: &mut State) -> Result<()> { + match command { + NeorgCommand::Task { task } => { + let project = task.project(state)?; + 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)?, 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)?; + } + } + } + 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..0cf60b41 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/open/handle.rs @@ -0,0 +1,190 @@ +// 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 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).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).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).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..e302c7d1 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/open/mod.rs @@ -0,0 +1,170 @@ +// 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::File, io::Read, str::FromStr}; + +use anyhow::{anyhow, Context, Result}; +use taskchampion::chrono::NaiveDateTime; +use url::Url; +use yaml_rust2::Yaml; + +use crate::task::Project; + +pub mod handle; +pub use handle::handle; + +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, +); |