about summary refs log tree commit diff stats
path: root/pkgs/by-name/ts/tskm/src/interface/input
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/by-name/ts/tskm/src/interface/input')
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/input/handle.rs148
-rw-r--r--pkgs/by-name/ts/tskm/src/interface/input/mod.rs260
2 files changed, 408 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(())
+    }
+}