diff options
Diffstat (limited to '')
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/input/handle.rs | 112 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/input/mod.rs | 257 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/mod.rs | 4 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs | 92 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs | 18 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/open/handle.rs | 222 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/open/mod.rs | 106 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/project/handle.rs | 87 | ||||
-rw-r--r-- | pkgs/by-name/ts/tskm/src/interface/project/mod.rs | 73 |
9 files changed, 971 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..0ff0e56e --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/input/handle.rs @@ -0,0 +1,112 @@ +use std::{ + fs, process, + str::FromStr, + thread::{self, sleep}, + time::Duration, +}; + +use anyhow::{Context, Result}; +use log::{error, info}; + +use crate::cli::InputCommand; + +use super::Input; + +/// # Errors +/// When command handling fails. +/// +/// # Panics +/// When internal assertions fail. +pub fn handle(command: InputCommand) -> Result<()> { + match command { + InputCommand::Add { inputs } => { + for input in inputs { + input.commit().with_context(|| { + format!("Failed to add input ('{input}') to the input storage.") + })?; + } + } + InputCommand::Remove { inputs } => { + for input in inputs { + input.remove().with_context(|| { + format!("Failed to remove input ('{input}') from the input storage.") + })?; + } + } + InputCommand::File { file } => { + let file = fs::read_to_string(file)?; + for line in file.lines() { + let input = Input::from_str(line)?; + input.commit().with_context(|| { + format!("Failed to add input ('{input}') to the input storage.") + })?; + } + } + InputCommand::Review { project } => { + let project = project.to_project_display(); + + let local_project = project.clone(); + let handle = thread::spawn(move || { + // We assume that the project is not yet open. + let mut firefox = process::Command::new("firefox") + .args(["-P", local_project.as_str(), "about:newtab"]) + .spawn()?; + + Ok::<_, anyhow::Error>(firefox.wait()?) + }); + // Give Firefox some time to start. + info!("Waiting on firefox to start"); + sleep(Duration::from_secs(4)); + + let project_str = project.as_str(); + 'outer: for all in Input::all()?.chunks(100) { + info!("Starting review for the first hundred URLs."); + + for input in all { + info!("-> '{input}'"); + let status = process::Command::new("firefox") + .args(["-P", project_str, input.url().to_string().as_str()]) + .status()?; + + if status.success() { + input.remove()?; + } else { + error!("Adding `{input}` to Firefox failed!"); + } + } + + { + use std::io::{stdin, stdout, Write}; + + let mut s = String::new(); + eprint!("Continue? (y/N) "); + stdout().flush()?; + + stdin() + .read_line(&mut s) + .expect("Did not enter a correct string"); + + if let Some('\n') = s.chars().next_back() { + s.pop(); + } + if let Some('\r') = s.chars().next_back() { + s.pop(); + } + + if s != "y" { + break 'outer; + } + } + } + + info!("Waiting for firefox to stop"); + handle.join().expect("Should be joinable")?; + } + InputCommand::List => { + for url in Input::all()? { + println!("{url}"); + } + } + } + Ok(()) +} diff --git a/pkgs/by-name/ts/tskm/src/interface/input/mod.rs b/pkgs/by-name/ts/tskm/src/interface/input/mod.rs new file mode 100644 index 00000000..9ece7a3a --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/input/mod.rs @@ -0,0 +1,257 @@ +use std::{ + collections::HashSet, + fmt::Display, + fs::{self, read_to_string, File}, + io::Write, + path::PathBuf, + process::Command, + str::FromStr, +}; + +use anyhow::{bail, Context, Result}; +use url::Url; +use walkdir::WalkDir; + +pub mod handle; +pub use handle::handle; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct NoWhitespaceString(String); + +impl NoWhitespaceString { + /// # Panics + /// If the input contains whitespace. + #[must_use] + pub fn new(input: String) -> Self { + if input.contains(' ') { + panic!("Your input '{input}' contains whitespace. I did not expect that.") + } else { + Self(input) + } + } +} + +impl Display for NoWhitespaceString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl NoWhitespaceString { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct Input { + url: Url, + tags: HashSet<NoWhitespaceString>, +} + +impl FromStr for Input { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { + if s.contains(' ') { + let (url, tags) = s.split_once(' ').expect("Should work"); + Ok(Self { + url: Url::from_str(url)?, + tags: { + tags.trim() + .split(' ') + .map(|tag| { + if let Some(tag) = tag.strip_prefix('+') { + Ok(NoWhitespaceString::new(tag.to_owned())) + } else { + bail!("Your tag '{tag}' does not start with the required '+'"); + } + }) + .collect::<Result<_, _>>()? + }, + }) + } else { + Ok(Self { + url: Url::from_str(s)?, + tags: HashSet::new(), + }) + } + } +} + +impl Display for Input { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.tags.is_empty() { + self.url.fmt(f) + } else { + write!( + f, + "{} {}", + self.url, + self.tags + .iter() + .fold(String::new(), |mut acc, tag| { + acc.push('+'); + acc.push_str(tag.as_str()); + acc.push(' '); + acc + }) + .trim() + ) + } + } +} + +impl Input { + fn base_path() -> PathBuf { + dirs::data_local_dir() + .expect("This should be set") + .join("tskm/inputs") + } + + fn url_path(url: &Url) -> Result<PathBuf> { + let base_path = Self::base_path(); + + let url_path = base_path.join(url.to_string()); + fs::create_dir_all(&url_path) + .with_context(|| format!("Failed to open file: '{}'", url_path.display()))?; + + Ok(url_path.join("url_value")) + } + + #[must_use] + pub fn url(&self) -> &Url { + &self.url + } + + /// Commit this constructed [`Input`] to storage. + /// + /// # Errors + /// If IO operations fail. + pub fn commit(&self) -> Result<()> { + let url_path = Self::url_path(&self.url)?; + + let url_content = { + if url_path.exists() { + read_to_string(&url_path)? + } else { + String::new() + } + }; + + let mut file = File::create(&url_path) + .with_context(|| format!("Failed to open file: '{}'", url_path.display()))?; + writeln!(file, "{url_content}{self}")?; + + Self::git_commit(&format!("Add new url: '{self}'"))?; + + Ok(()) + } + + /// Remove this constructed [`Input`] to storage. + /// + /// Beware that this does not take tags into account. + /// + /// # Errors + /// If IO operations fail. + pub fn remove(&self) -> Result<()> { + let url_path = Self::url_path(&self.url)?; + + fs::remove_file(&url_path) + .with_context(|| format!("Failed to remove file: '{}'", url_path.display()))?; + + let mut url_path = url_path.as_path(); + while let Some(parent) = url_path.parent() { + if fs::read_dir(parent)?.count() == 0 { + fs::remove_dir(parent)?; + } + url_path = parent; + } + + Self::git_commit(&format!("Remove url: '{self}'"))?; + Ok(()) + } + + /// Commit your changes + fn git_commit(message: &str) -> Result<()> { + let status = Command::new("git") + .args(["add", "."]) + .current_dir(Self::base_path()) + .status()?; + if !status.success() { + bail!("Git add . failed!"); + } + + let status = Command::new("git") + .args(["commit", "--message", message, "--no-gpg-sign"]) + .current_dir(Self::base_path()) + .status()?; + if !status.success() { + bail!("Git commit failed!"); + } + + Ok(()) + } + + /// Get all previously [`Self::commit`]ed inputs. + /// + /// # Errors + /// When IO handling fails. + /// + /// # Panics + /// If internal assertions fail. + pub fn all() -> Result<Vec<Self>> { + let mut output = vec![]; + for entry in WalkDir::new(Self::base_path()) + .min_depth(1) + .into_iter() + .filter_entry(|e| { + let s = e.file_name().to_str(); + s != Some(".git") + }) + { + let entry = entry?; + + if !entry.file_type().is_file() { + continue; + } + + let url_value_file = entry + .path() + .to_str() + .expect("All of these should be URLs and thus valid strings"); + assert!(url_value_file.ends_with("/url_value")); + + let url = { + let base = url_value_file + .strip_prefix(&format!("{}/", Self::base_path().display())) + .expect("This will exist"); + + let (proto, path) = base.split_once(':').expect("This will countain a :"); + + let path = path.strip_suffix("/url_value").expect("Will exist"); + + Url::from_str(&format!("{proto}:/{path}")) + .expect("This was a URL, it should still be one") + }; + let tags = { + let url_values = read_to_string(PathBuf::from(url_value_file))?; + url_values + .lines() + .map(|line| { + let input = Self::from_str(line)?; + Ok::<_, anyhow::Error>(input.tags) + }) + .collect::<Result<Vec<HashSet<NoWhitespaceString>>, _>>()? + .into_iter() + .flatten() + .collect() + }; + + output.push(Self { url, tags }); + } + + Ok(output) + } +} diff --git a/pkgs/by-name/ts/tskm/src/interface/mod.rs b/pkgs/by-name/ts/tskm/src/interface/mod.rs new file mode 100644 index 00000000..1a0d934c --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/mod.rs @@ -0,0 +1,4 @@ +pub mod input; +pub mod neorg; +pub mod open; +pub mod project; diff --git a/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs b/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs new file mode 100644 index 00000000..577de02c --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/neorg/handle.rs @@ -0,0 +1,92 @@ +use std::{ + env, + fs::{self, read_to_string, File, OpenOptions}, + io::Write, + process::Command, +}; + +use anyhow::{bail, Context, Result}; + +use crate::{cli::NeorgCommand, state::State}; + +pub fn handle(command: NeorgCommand, state: &mut State) -> Result<()> { + match command { + NeorgCommand::Task { id } => { + let project = id.project(state)?; + let path = dirs::data_local_dir() + .expect("This should exists") + .join("tskm/notes") + .join(project.get_neorg_path()?); + + fs::create_dir_all(path.parent().expect("This should exist"))?; + + { + let contents = if path.exists() { + read_to_string(&path) + .with_context(|| format!("Failed to read file: '{}'", path.display()))? + } else { + File::create(&path) + .with_context(|| format!("Failed to create file: '{}'", path.display()))?; + String::new() + }; + + if !contents.contains(format!("% {}", id.uuid()).as_str()) { + let mut options = OpenOptions::new(); + options.append(true).create(false); + + let mut file = options.open(&path)?; + file.write_all(format!("* TITLE (% {})", id.uuid()).as_bytes()) + .with_context(|| { + format!("Failed to write task uuid to file: '{}'", path.display()) + })?; + file.flush() + .with_context(|| format!("Failed to flush file: '{}'", path.display()))?; + } + } + + let editor = env::var("EDITOR").unwrap_or("nvim".to_owned()); + let status = Command::new(editor) + .args([ + path.to_str().expect("Should be a utf-8 str"), + "-c", + format!("/% {}", id.uuid()).as_str(), + ]) + .status()?; + if !status.success() { + bail!("$EDITOR fail with error code: {status}"); + } + + { + let status = Command::new("git") + .args(["add", "."]) + .current_dir(path.parent().expect("Will exist")) + .status()?; + if !status.success() { + bail!("Git add . failed!"); + } + + let status = Command::new("git") + .args([ + "commit", + "--message", + format!( + "chore({}): Update", + path.parent().expect("Should have a parent").display() + ) + .as_str(), + "--no-gpg-sign", + ]) + .current_dir(path.parent().expect("Will exist")) + .status()?; + if !status.success() { + bail!("Git commit failed!"); + } + } + + { + id.mark_neorg_data(state)?; + } + } + } + Ok(()) +} diff --git a/pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs b/pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs new file mode 100644 index 00000000..dc5cdf19 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/neorg/mod.rs @@ -0,0 +1,18 @@ +use std::path::PathBuf; + +use anyhow::Result; + +use crate::task::{run_task, Project}; + +pub mod handle; +pub use handle::handle; + +impl Project { + pub(super) fn get_neorg_path(&self) -> Result<PathBuf> { + let project_path = run_task(&[ + "_get", + format!("rc.context.{}.rc.neorg_path", self.to_context_display()).as_str(), + ])?; + Ok(PathBuf::from(project_path.as_str())) + } +} diff --git a/pkgs/by-name/ts/tskm/src/interface/open/handle.rs b/pkgs/by-name/ts/tskm/src/interface/open/handle.rs new file mode 100644 index 00000000..15c7ac4d --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/open/handle.rs @@ -0,0 +1,222 @@ +use std::{ + fs, + net::{IpAddr, Ipv4Addr}, + path::PathBuf, + process, +}; + +use anyhow::{bail, Context, Result}; +use log::{error, info}; +use url::Url; + +use crate::{cli::OpenCommand, rofi, state::State, task}; + +pub fn handle(command: OpenCommand, state: &mut State) -> Result<()> { + match command { + OpenCommand::Review => { + for project in task::Project::all().context("Failed to get all project files")? { + if project.is_touched() { + info!("Reviewing project: '{}'", project.to_project_display()); + open_in_browser(project, state, None).with_context(|| { + format!( + "Failed to open project ('{}') in Firefox", + project.to_project_display() + ) + })?; + project.untouch().with_context(|| { + format!( + "Failed to untouch project ('{}')", + project.to_project_display() + ) + })?; + } + } + } + OpenCommand::Project { project, url } => { + project.touch().context("Failed to touch project")?; + open_in_browser(&project, state, url).with_context(|| { + format!("Failed to open project: {}", project.to_project_display()) + })?; + } + OpenCommand::Select { url } => { + let selected_project: task::Project = task::Project::from_project_string( + &rofi::select( + task::Project::all() + .context("Failed to get all registered projects")? + .iter() + .map(task::Project::to_project_display) + .collect::<Vec<_>>() + .as_slice(), + ) + .context("Failed to get selected project")?, + ) + .expect("This should work, as we send only projects in"); + + selected_project + .touch() + .context("Failed to touch project")?; + + open_in_browser(&selected_project, state, url).context("Failed to open project")?; + } + OpenCommand::ListTabs { project } => { + let project = if let Some(p) = project { + p + } else if let Some(p) = + task::Project::get_current().context("Failed to get currently focused project")? + { + p + } else { + bail!("You need to either supply a project or have a project active!"); + }; + + let session_store = project.get_sessionstore().with_context(|| { + format!( + "Failed to get session store for project: '{}'", + project.to_project_display() + ) + })?; + + let selected = session_store + .windows + .iter() + .map(|w| w.selected) + .collect::<Vec<_>>(); + + let tabs = session_store + .windows + .iter() + .flat_map(|window| window.tabs.iter()) + .map(|tab| tab.entries.get(tab.index - 1).expect("This should be Some")) + .collect::<Vec<_>>(); + + for (index, entry) in tabs.iter().enumerate() { + let index = index + 1; + let is_selected = { + if selected.contains(&index) { + "🔻 " + } else { + " " + } + }; + println!("{}{}", is_selected, entry.url); + } + } + } + Ok(()) +} + +fn open_in_browser( + selected_project: &task::Project, + state: &mut State, + url: Option<Url>, +) -> Result<()> { + let old_project: Option<task::Project> = + task::Project::get_current().context("Failed to get currently active project")?; + let old_task: Option<task::Task> = + task::Task::get_current(state).context("Failed to get currently active task")?; + + selected_project.activate().with_context(|| { + format!( + "Failed to active project: '{}'", + selected_project.to_project_display() + ) + })?; + + let tracking_task = { + let all_tasks = selected_project.get_tasks(state).with_context(|| { + format!( + "Failed to get assoctiated tasks for project: '{}'", + selected_project.to_project_display() + ) + })?; + + let tracking_task = all_tasks.into_iter().find(|t| { + let maybe_desc = t.description(state); + if let Ok(desc) = maybe_desc { + desc == "tracking" + } else { + error!( + "Getting task description returned error: {}", + maybe_desc.expect_err("We already check for Ok") + ); + false + } + }); + + if let Some(task) = tracking_task { + info!( + "Starting task {} -> tracking", + selected_project.to_project_display() + ); + task.start(state) + .with_context(|| format!("Failed to start task {task}"))?; + } + tracking_task + }; + + let status = { + let mut args = vec!["-P".to_owned(), selected_project.to_project_display()]; + if let Some(url) = url { + args.push(url.to_string()); + } else { + let (ip, pid): (IpAddr, u32) = { + let link = fs::read_link( + dirs::home_dir() + .expect("Exists") + .join(".mozilla/firefox") + .join(selected_project.to_project_display()) + .join("lock"), + )?; + let (ip, pid) = link + .to_str() + .expect("Should work") + .split_once(':') + .expect("The split works"); + + ( + ip.parse().expect("Should be a valid ip address"), + pid.parse().expect("Should be a valid pid"), + ) + }; + + assert_eq!(ip, Ipv4Addr::new(127, 0, 0, 1)); + if PathBuf::from("/proc").join(pid.to_string()).exists() { + // Another Firefox instance has already been started for this project + // Add a buffer URL to force Firefox to open it in the already open instance + args.push("about:newtab".to_owned()); + } else { + // This project does not yet have another Firefox instance + // We do not need to add anything to the arguments, Firefox will open a new + // instance. + } + }; + + process::Command::new("firefox") + .args(args) + .status() + .context("Failed to start firefox")? + }; + + if !status.success() { + error!("Firefox run exited with error."); + } + + if let Some(task) = tracking_task { + task.stop(state) + .with_context(|| format!("Failed to stop task {task}"))?; + } + if let Some(task) = old_task { + task.start(state) + .with_context(|| format!("Failed to start task {task}"))?; + } + + if let Some(project) = old_project { + project.activate().with_context(|| { + format!("Failed to active project {}", project.to_project_display()) + })?; + } else { + task::Project::clear().context("Failed to clear currently focused project")?; + } + + Ok(()) +} diff --git a/pkgs/by-name/ts/tskm/src/interface/open/mod.rs b/pkgs/by-name/ts/tskm/src/interface/open/mod.rs new file mode 100644 index 00000000..2dc75957 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/open/mod.rs @@ -0,0 +1,106 @@ +use std::{collections::HashMap, fs::File, io}; + +use anyhow::{Context, Result}; +use lz4_flex::decompress_size_prepended; +use serde::Deserialize; +use serde_json::Value; +use url::Url; + +use crate::task::Project; + +pub mod handle; +pub use handle::handle; + +impl Project { + pub(super) fn get_sessionstore(&self) -> Result<SessionStore> { + let path = dirs::home_dir() + .expect("Will exist") + .join(".mozilla/firefox") + .join(self.to_project_display()) + .join("sessionstore-backups/recovery.jsonlz4"); + let file = decompress_mozlz4( + File::open(&path) + .with_context(|| format!("Failed to open path '{}'", path.display()))?, + ) + .with_context(|| format!("Failed to decompress file as mozlzh '{}'", path.display()))?; + + let contents: SessionStore = serde_json::from_str(&file).with_context(|| { + format!( + "Failed to deserialize file ('{}') as session store.", + path.display() + ) + })?; + Ok(contents) + } +} + +fn decompress_mozlz4<P: io::Read>(mut file: P) -> Result<String> { + const MOZLZ4_MAGIC_NUMBER: &[u8] = b"mozLz40\0"; + + let mut buf = [0u8; 8]; + file.read_exact(&mut buf) + .context("Failed to read the mozlz40 header.")?; + + assert_eq!(buf, MOZLZ4_MAGIC_NUMBER); + + let mut buf = vec![]; + file.read_to_end(&mut buf).context("Failed to read file")?; + + let uncompressed = decompress_size_prepended(&buf).context("Failed to decompress file")?; + + Ok(String::from_utf8(uncompressed).expect("This should be valid json and thus utf8")) +} + +#[derive(Deserialize, Debug)] +pub struct SessionStore { + pub windows: Vec<Window>, +} + +#[derive(Deserialize, Debug)] +pub struct Window { + pub tabs: Vec<Tab>, + pub selected: usize, +} + +#[derive(Deserialize, Debug)] +pub struct Tab { + pub entries: Vec<TabEntry>, + #[serde(rename = "lastAccessed")] + pub last_accessed: u64, + pub hidden: bool, + #[serde(rename = "searchMode")] + pub search_mode: Option<Value>, + #[serde(rename = "userContextId")] + pub user_context_id: u32, + pub attributes: TabAttributes, + #[serde(rename = "extData")] + pub ext_data: Option<HashMap<String, Value>>, + pub index: usize, + #[serde(rename = "requestedIndex")] + pub requested_index: Option<u32>, + pub image: Option<Url>, +} + +#[derive(Deserialize, Debug)] +pub struct TabEntry { + pub url: Url, + pub title: String, + #[serde(rename = "cacheKey")] + pub cache_key: u32, + #[serde(rename = "ID")] + pub id: u32, + #[serde(rename = "docshellUUID")] + pub docshell_uuid: Value, + #[serde(rename = "resultPrincipalURI")] + pub result_principal_uri: Option<Url>, + #[serde(rename = "hasUserInteraction")] + pub has_user_interaction: bool, + #[serde(rename = "triggeringPrincipal_base64")] + pub triggering_principal_base64: Value, + #[serde(rename = "docIdentifier")] + pub doc_identifier: u32, + pub persist: bool, +} + +#[derive(Deserialize, Debug, Clone, Copy)] +pub struct TabAttributes {} diff --git a/pkgs/by-name/ts/tskm/src/interface/project/handle.rs b/pkgs/by-name/ts/tskm/src/interface/project/handle.rs new file mode 100644 index 00000000..2b01f5d1 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/project/handle.rs @@ -0,0 +1,87 @@ +use std::{env, fs::File, io::Write}; + +use anyhow::{anyhow, Context, Result}; +use log::trace; + +use crate::{cli::ProjectCommand, task}; + +use super::{ProjectDefinition, ProjectList, SortAlphabetically}; + +/// # Panics +/// If internal expectations fail. +/// +/// # Errors +/// If IO operations fail. +pub fn handle(command: ProjectCommand) -> Result<()> { + match command { + ProjectCommand::List => { + for project in task::Project::all()? { + println!("{}", project.to_project_display()); + } + } + ProjectCommand::Add { + mut new_project_name, + } => { + let project_file = env::var("TSKM_PROJECT_FILE") + .map_err(|err| anyhow!("The `TSKM_PROJECT_FILE` env var is unset: {err}"))?; + + let mut projects_content: ProjectList = + serde_json::from_reader(File::open(&project_file).with_context(|| { + format!("Failed to open project file ('{project_file:?}') for reading") + })?)?; + + let first = new_project_name.project_segments.remove(0); + if let Some(mut definition) = projects_content.0.get_mut(&first) { + for segment in new_project_name.project_segments { + if definition.subprojects.contains_key(&segment) { + definition = definition + .subprojects + .get_mut(&segment) + .expect("We checked"); + } else { + let new_definition = ProjectDefinition::default(); + let output = definition + .subprojects + .insert(segment.clone(), new_definition); + + assert_eq!(output, None); + + definition = definition + .subprojects + .get_mut(&segment) + .expect("Was just inserted"); + } + } + } else { + let mut orig_definition = ProjectDefinition::default(); + let mut definition = &mut orig_definition; + for segment in new_project_name.project_segments { + trace!("Adding segment: {segment}"); + + let new_definition = ProjectDefinition::default(); + + assert!(definition + .subprojects + .insert(segment.clone(), new_definition) + .is_none()); + + definition = definition + .subprojects + .get_mut(&segment) + .expect("Was just inserted"); + } + assert!(projects_content.0.insert(first, orig_definition).is_none()); + }; + + let mut file = File::create(&project_file).with_context(|| { + format!("Failed to open project file ('{project_file:?}') for writing") + })?; + serde_json::to_writer_pretty( + &file, + &SortAlphabetically::<ProjectList>(projects_content), + )?; + writeln!(file)?; + } + } + Ok(()) +} diff --git a/pkgs/by-name/ts/tskm/src/interface/project/mod.rs b/pkgs/by-name/ts/tskm/src/interface/project/mod.rs new file mode 100644 index 00000000..62069746 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/interface/project/mod.rs @@ -0,0 +1,73 @@ +use std::collections::HashMap; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +pub mod handle; +pub use handle::handle; + +#[derive(Deserialize, Serialize)] +struct ProjectList(HashMap<String, ProjectDefinition>); + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +struct ProjectDefinition { + #[serde(default)] + #[serde(skip_serializing_if = "is_default")] + name: String, + + #[serde(default)] + #[serde(skip_serializing_if = "is_default")] + prefix: String, + + #[serde(default)] + #[serde(skip_serializing_if = "is_default")] + subprojects: HashMap<String, ProjectDefinition>, +} + +fn is_default<T: Default + PartialEq>(input: &T) -> bool { + input == &T::default() +} + +#[derive(Debug, Clone)] +pub struct ProjectName { + project_segments: Vec<String>, +} + +impl ProjectName { + #[must_use] + pub fn segments(&self) -> &[String] { + &self.project_segments + } + + /// # Errors + /// Never. + pub fn try_from_project(s: &str) -> Result<Self> { + Ok(Self::from_project(s)) + } + pub fn from_project(s: &str) -> Self { + let me = Self { + project_segments: s.split('.').map(ToOwned::to_owned).collect(), + }; + me + } + pub fn from_context(s: &str) -> Self { + let me = Self { + project_segments: s.split('_').map(ToOwned::to_owned).collect(), + }; + me + } +} + +// Source: https://stackoverflow.com/a/67792465 +fn sort_alphabetically<T: Serialize, S: serde::Serializer>( + value: &T, + serializer: S, +) -> Result<S::Ok, S::Error> { + let value = serde_json::to_value(value).map_err(serde::ser::Error::custom)?; + value.serialize(serializer) +} + +#[derive(Serialize)] +pub(super) struct SortAlphabetically<T: Serialize>( + #[serde(serialize_with = "sort_alphabetically")] T, +); |