use crate::bookmark::Handler;
use crate::bookmark::HandlerClass;
use crate::bookmark::Location;
use crate::bookmark::LocationType;
use crate::environment::LinkVariable;
use bookmark::Bookmark;
use clap::{ArgGroup, Parser};
use edit::edit;
use environment::{Category, Configurations};
use prompted::input;
use std::{collections::HashSet, fs};
mod bookmark;
mod data_provider;
mod environment;
#[derive(Parser)]
#[command(version, about)]
#[clap(group(ArgGroup::new("info").required(false).args(&["keyglobal", "keycategory", "name", "edit", "summary", "config", "delete", "rename"])))]
struct Cli {
name: Option<Vec<String>>,
#[arg(short, long, group = "cats")]
category: Option<String>,
#[arg(short = '0', long = "summary", default_value_t = false)]
summary: bool,
#[arg(short, long = "edit-bookmarks", default_value_t = false)]
edit: bool,
#[arg(short = 'E', long = "edit-configuration", default_value_t = false)]
config: bool,
#[arg(short = 'K', long = "list-global-keys", default_value_t = false)]
keyglobal: bool,
#[arg(short, long = "list-category-keys", default_value_t = false)]
keycategory: bool,
#[arg(short, long = "new-category", group = "cats", value_name = "CATEGORY")]
new: Option<String>,
#[arg(
short,
long = "delete-category",
group = "cats",
value_name = "CATEGORY"
)]
delete: Option<String>,
#[arg(
short,
long = "rename-category",
value_delimiter = ' ',
num_args = 2,
group = "cats",
value_names = ["OLD_NAME", "NEW_NAME"]
)]
rename: Option<Vec<String>>,
}
#[derive(Clone)]
enum PadType {
ByKey(char),
ByName(String),
BySearch(SearchService),
BySelector(Selector),
}
#[derive(Clone)]
struct CategoryManager {
root: String,
name: String,
categories: Vec<String>,
}
impl CategoryManager {
fn new() -> Self {
let name = Configurations::get_setting()
.default_category
.unwrap_or(Configurations::default_settings().default_category.unwrap());
let root = Configurations::category_home();
let f_name = format!("{root}/{name}");
if !Configurations::check_path(&f_name) {
eprintln!(
"Hi! It seems the set default-category '{name}' in your configuration file is missing."
);
eprintln!(
"Please correct this by either changing the default-category to some other available category or re-create the '{name}' category and run this program again."
);
eprintln!(
"This message does not appear for a new installation, which means you might have deleted the default bookmark file and forgot to change the default category in the process."
);
eprintln!(
"It also means yu do not have 'create-missing-category' set to true, which prevents hlg from recreating the default bookmark file."
);
std::process::exit(22);
}
let categories = Self::get_category_list();
Self {
name,
categories,
root,
}
}
fn set_global(&self) -> Category {
Category::new(Scope::Global)
}
pub fn by_name<T: AsRef<str> + ToString>(mut self, category_name: T) -> Self {
self.name = category_name.to_string();
self
}
fn refresh_category_headers() {
let checkfile = format!("{}/categories", Configurations::get_records());
if Configurations::check_path(&checkfile) {
fs::remove_file(checkfile).expect("Could not remove the old category file");
}
Self::create_category_file();
}
fn create_new(&self, category_name: String) -> std::io::Result<()> {
if self.categories.contains(&category_name) {
eprintln!(
"Error: {category_name} category already exists in your bookmarks database. Choose another name, or simply use the {category_name} category to perform any bookmark management you wished to perform with this new category."
);
std::process::exit(25);
} else {
let data = format!(
r"# This is the category header for the {category_name} category.
* <@access-computing.com/>"
);
let newcategory = format!("{}/{category_name}", self.root);
fs::write(newcategory, data.as_bytes())?;
println!(
"{category_name} was successfully created and added to database. Feel fre to add bookmarks to it, or even set it as your default category in the configuration file."
);
Self::refresh_category_headers();
}
Ok(())
}
fn safe_category(&self) -> String {
let mut home = Configurations::get_homepage();
home = if home.contains('.') {
let pos = home.find('.').unwrap();
let (dish, _) = home.split_at(pos);
dish.to_string()
} else {
self.name.clone()
};
home
}
fn delete_category(&self, name: &String) -> std::io::Result<()> {
let safe_category = self.safe_category();
if *name == self.name || *name == safe_category {
eprintln!(
"Error: cannot delete default or home-linked category. This option can onnly be called with the name of the category to be deleted, other than the default or home-linked category."
);
std::process::exit(20);
}
if !self.categories.contains(name) {
eprintln!("Error: No category '{name}' found in database.");
std::process::exit(21);
}
let filename = format!("{}/{name}", self.root);
println!(
"are you sure that you would like to delete {name} from the bookmarks database? This action cannot be undone."
);
let confirm = input!("Enter y to confirm:");
if confirm.trim().to_lowercase() == "y" {
fs::remove_file(filename)?;
println!(
"{name} category deleted successfully. You will no longer be able to perform any operation that involves this category unless you recreate it"
);
Self::refresh_category_headers();
} else {
println!(
"{name} category not deleted, and is still available for any category management operations. Bye!"
);
}
Ok(())
}
fn rename_category(&self, old_name: &String, new_name: &String) -> std::io::Result<()> {
if !self.categories.contains(&old_name) {
eprintln!("Sorry, this name {old_name} not found in database.");
eprintln!("You can simply create a new category with the name, '{new_name}' instead.");
std::process::exit(21);
}
let safe_category = self.safe_category();
if *old_name == safe_category || *old_name == self.name {
eprintln!(
"Hi, what are you trying to do? You cannot rename a default category or a home-linked category to a new name while they are still in use."
);
eprintln!(
"Consider changing the default category or a category linked by the main home bookmark in the configuration file before attempting this operation."
);
std::process::exit(20);
}
if self.categories.contains(&new_name) {
eprintln!("Error: This category '{new_name}' is already in the bookmarks database.");
eprintln!("Just use it instead.");
std::process::exit(25);
}
let old_file = format!("{}/{old_name}", self.root);
let new_file = format!("{}/{new_name}", self.root);
println!(
"Are you sure that you would like to rename the category '{old_name}' to '{new_name}'?"
);
let confirm = input!("Enter Y to confirm:");
if confirm.trim().to_lowercase() == "y" {
fs::rename(old_file, new_file)?;
Self::refresh_category_headers();
println!("Success! The category {old_name} was renamed to {new_name}.");
println!(
"Remember to use this name in any category management activities rather than '{old_name}'."
);
} else {
println!(
"Okay, the rename operation was cancelled. Your category is still referred to as '{old_name}' and not '{new_name}'."
);
}
Ok(())
}
fn check_name_availability(&self) {
let create_missing_category = Configurations::get_setting()
.create_missing_category
.unwrap_or(
Configurations::default_settings()
.create_missing_category
.unwrap(),
);
if !self.categories.contains(&self.name) && !create_missing_category {
eprintln!("Error: the {} category could not be found.", self.name);
eprintln!(
"As your default setting is not to create missing categories on the fly, consider creating a file called {} with your bookmarks and place it in the 'bookmarks' directory.",
self.name
);
eprintln!(
"Otherwise, just change the create-missing-category option to 'yes' in your configuration file. Now exiting.."
);
std::process::exit(26);
}
}
fn load_categories() -> Vec<(String, String)> {
let mut loader = Vec::new();
let category_file = format!("{}/categories", Configurations::get_records());
if !Configurations::check_path(&category_file) {
Self::create_category_file();
}
let list =
fs::read_to_string(category_file).expect("Could not load category file for reading");
Self::shortcut_keycheck(&list);
for line in list.lines() {
if line.contains('(') {
let pos = line.find('(').unwrap();
let (category_name, comment) = line.split_at(pos);
loader.push((format!("{category_name}"), format!("{}", &comment[1..])));
} else {
let tokens: Vec<&str> = line.split_whitespace().collect();
loader.push((
format!("{}", tokens.get(0).unwrap()),
format!("<NONE> {}", &tokens[1..].join(" ")),
));
}
}
loader
}
fn create_category_file() {
let filelist = Self::file_list();
if filelist.len() > 0 {
let mut raw_string = String::new();
for file in filelist {
let fname = format!("{}/bookmarks/{file}", Configurations::get_app_home());
let header = Self::find_category_line(&fname);
if header.is_some() {
raw_string = format!("{raw_string}{file}{}\n", header.unwrap());
} else {
raw_string = format!("{raw_string}{file}\n");
}
}
if !Configurations::check_path(&Configurations::get_records()) {
fs::create_dir(Configurations::get_records())
.expect("Could not create the records directory");
}
let category_file = format!("{}/categories", Configurations::get_records());
fs::write(category_file.as_str(), raw_string.as_bytes())
.expect("Could not save category details");
} else {
eprintln!("Error: no categories were found in the database.");
eprintln!("Please add one and try again.");
std::process::exit(36);
}
}
fn get_category_list() -> Vec<String> {
let categories = Self::load_categories();
let mut names = Vec::new();
for name in categories {
names.push(name.0.to_string());
}
names
}
fn get_name_by_key(key: char) -> Option<String> {
let mut category_name = None;
let category_list = Self::load_categories();
for candidate in category_list {
let name = candidate.0;
let header = candidate.1;
let words: Vec<&str> = header.split_whitespace().collect();
if words[0] != "<NONE>" {
let letter = words.get(0).unwrap();
let psb = letter.chars().nth(0).unwrap();
if psb == key {
category_name = Some(name.to_string())
}
}
}
category_name
}
fn file_list() -> Vec<String> {
let working_dir = Configurations::category_home();
let mut categories = Vec::new();
if Configurations::check_path(&working_dir) {
for entry in fs::read_dir(&working_dir)
.unwrap()
.into_iter()
.filter_map(Result::ok)
{
let f_name = String::from(entry.file_name().to_string_lossy());
categories.push(f_name);
}
categories.sort();
categories.dedup();
}
categories
}
fn find_category_line(fs_name: &String) -> Option<String> {
let lines = fs::read_to_string(fs_name).expect("Could not load category header line");
let lines: Vec<&str> = lines.lines().collect();
let lines = lines
.into_iter()
.filter(|line| !line.is_empty())
.map(|ln| ln.to_string())
.collect::<Vec<String>>();
let header = lines.get(0).unwrap();
if header.starts_with("#!") {
let header = &header[2..];
Self::validate_header(header, fs_name);
let header_fields: Vec<&str> = header.split(';').collect();
if header_fields.len() > 1 {
let shortcut = &header_fields[1].trim();
let comment = if header_fields.len() > 2 {
&header_fields[2].trim()
} else {
""
};
if shortcut.is_empty() {
Some(format!(" {comment}"))
} else {
Some(format!("({shortcut}) {comment}"))
}
} else {
None
}
} else {
None
}
}
fn shortcut_keycheck(rawlist: &String) {
let category_list: Vec<&str> = rawlist.lines().collect();
let mut keys = Vec::new();
let mut names = Vec::new();
for category in category_list {
if category.contains('(') {
let divider = category.find('(').unwrap();
let (_, key) = category.split_at(divider);
let key = key.chars().nth(1).unwrap();
keys.push(key);
names.push(category.to_string());
}
}
for key in keys {
let mut list = Vec::new();
for name in &names {
let pos = name.find('(').unwrap();
let (category, sym) = name.split_at(pos);
let sym = sym.chars().nth(1).unwrap();
if sym == key {
list.push(category.to_string());
}
}
if list.len() > 1 {
eprintln!("Error: Category key shortcut duplication detected!");
eprintln!(
"This shortcut, '{key}', is assigned to {} categories, namely {} and {}",
list.len(),
&list[..list.len() - 1].join(", "),
&list[list.len() - 1]
);
eprintln!(
"To fix this error, just change the shortcut keys of the other categories and use {key} on only one category."
);
std::process::exit(19);
}
}
}
fn validate_header(header: &str, fname: &String) {
let fname = {
let pos = fname.rfind('/').unwrap();
let (_, name) = fname.split_at(pos);
name.strip_prefix('/').unwrap()
};
let tokens: Vec<&str> = header.split(';').collect();
if tokens.len() > 3 {
eprintln!(
"Error: A category header cannot have more than three fields: the bookmark field, the shortcut key field and the descripption field."
);
eprintln!(
"Please remove extra fields in the category header of the {fname} category to fix this error."
);
eprintln!(
"Fields are created by semicolons, so remove any extra semicolon characters after the description field."
);
std::process::exit(19);
}
if tokens.len() == 1 {
let home = tokens.get(0).unwrap().trim();
Configurations::check_header_bookmark(home, fname);
} else if tokens.len() == 2 {
let home = tokens.get(0).unwrap().trim();
let token = tokens.get(1).unwrap().trim();
Configurations::check_header_bookmark(home, fname);
if !token.is_empty() {
Configurations::check_header_key(&token, fname);
}
if home.is_empty() && token.is_empty() {
eprintln!("Error: you cannot have an empty category header.");
eprintln!(
"This category header of the {fname} category has got two optional fields, but both of them are empty."
);
eprintln!(
"If you do not like a category header, you can just leave it out of the bookmark file altogether."
);
std::process::exit(19);
}
} else {
let bookmark = tokens.get(0).unwrap().trim();
let key = tokens.get(1).unwrap().trim();
let comment = tokens.get(2).unwrap().trim();
if bookmark.is_empty() && key.is_empty() && comment.is_empty() {
eprintln!("Error: empty category header in the category {fname}!");
eprintln!(
"You must not have an empty category header. If you do not like a category header altogether, just do not add a line that starts with '#!' in the bookmark file."
);
eprintln!(
"Once you add a category header, you have to add either a bookmark field, a shortcut key, a description or all of these fields, but not to leave them out altogether."
);
std::process::exit(19);
}
if !bookmark.is_empty() {
Configurations::check_header_bookmark(bookmark, fname);
}
if !key.is_empty() {
Configurations::check_header_key(key, fname);
}
}
}
fn validate_category(&self) {
if self.name.contains(" ") {
eprintln!(
"Error: A Category name must not have embedded spaces in it. Use dashes or underscores to separate words"
);
std::process::exit(27);
}
if self.name.contains('.') {
eprintln!("Error: Hlg only supports one level of category at this time.");
eprintln!(
"Remove all period characters and replace them with either dashes or underscores to separate words."
);
std::process::exit(27);
}
}
fn preferred_category(self) -> Category {
self.validate_category();
self.check_name_availability();
Category::new(Scope::Local(self.name.clone()))
}
}
struct LaunchPad {
category: Category,
pad_type: PadType,
bookmark: Bookmark,
}
impl LaunchPad {
fn new(target: Target) -> Self {
let pad_type = target.get_pad();
let category = target.scope.apply();
let bookmark = Bookmark::default();
Self {
pad_type,
category,
bookmark,
}
}
fn get_starred(&mut self) {
let mut starred_bookmarks = Vec::new();
for chosen in self.category.entries() {
if chosen.info.is_favorite {
starred_bookmarks.push(chosen.clone());
}
}
let category = if self.category.name == "<global>" {
"the global scope".to_string()
} else {
format!("the {} category", self.category.name)
};
if starred_bookmarks.len() > 0 {
let autoselect = Configurations::get_setting()
.autoselect
.unwrap_or(Configurations::default_settings().autoselect.unwrap());
if starred_bookmarks.len() == 1 && autoselect {
self.bookmark = starred_bookmarks.get(0).unwrap().clone();
} else {
println!("Starred bookmarks in {category}");
println!("* * * * * * *");
println!("[Menu position]\tBookmark name:\t\tDescription");
starred_bookmarks.sort();
starred_bookmarks.dedup();
self.bookmark = Self::make_choice(&starred_bookmarks);
}
} else {
eprintln!("Error: You have no starred bookmarks in {category}.");
eprintln!(
"To star a bookmark, just add a trailing asterisk or star symbol, '*' on the bookmark name."
);
std::process::exit(30);
}
}
fn which_key(&self, key: char) -> Option<Bookmark> {
let mut shortcut = None;
for fast_one in self.category.entries() {
if fast_one.info.key.is_some() {
if fast_one.info.get_key() == key {
shortcut = Some(fast_one.clone());
break;
}
}
}
shortcut
}
fn get_by_key(&mut self, key: char) {
self.bookmark = if let Some(thing) = self.which_key(key) {
thing
} else {
let scope = self.category.name.clone();
let scope = if scope == "<global>" {
format!("the global scope")
} else {
format!("the {scope} category")
};
eprintln!(
"Error: this shortcut key '{key}' is not yet assigned for any bookmark in {scope}."
);
eprintln!(
"Please check the case of your shortcuts when invoking hlg, as the case used is the same you used during saving the appropriate bookmark."
);
std::process::exit(35);
};
}
fn by_alias(&mut self, phrase: String) {
let key = phrase.to_lowercase();
let mut found = Vec::new();
for candidate in &self.category.entries() {
if candidate.info.tags.is_some() {
for marble in candidate.info.get_tags() {
if marble.to_lowercase() == key {
found.push(candidate.clone());
}
}
}
}
if found.len() > 0 {
let autoselect = Configurations::get_setting()
.autoselect
.unwrap_or(Configurations::default_settings().autoselect.unwrap());
if found.len() == 1 && autoselect {
self.bookmark = found.get(0).unwrap().clone();
} else {
let number = if found.len() == 1 {
"is only one"
} else {
"are more than one"
};
println!("There {number} bookmark with the alias '{phrase}'");
println!("Please select one from the list below:");
println!("[Choice]\tBookmark\t\tDescription");
found.sort();
found.dedup();
self.bookmark = Self::make_choice(&found);
}
} else {
let cat = if self.category.name == "<global>" {
"global scope".to_string()
} else {
format!("{} category", self.category.name)
};
eprintln!("Sorry, no bookmark with the alias {phrase} was found in the {cat}.");
eprintln!(
"Just add an alias to a bookmark by enclosing its name inside square brackets on the line before the bookmark entry"
);
std::process::exit(34);
}
}
fn select(&mut self, selector: Selector) {
match selector {
Selector::Starred => self.get_starred(),
Selector::Aliases(alias) => self.by_alias(alias),
}
}
fn web_search(&mut self, service: SearchService) {
let key;
let address: String;
match service {
SearchService::Engine(ref engine, ref search_phrase) => {
address = format!("search?q={search_phrase}");
key = engine;
}
SearchService::Wiki(ref site, ref topic) => {
address = format!("{topic}");
key = site;
}
}
let binding = self.category.entries();
let provisional_bookmark = if key.len() == 1 {
self.which_key(key.chars().nth(0).unwrap())
} else {
binding.get(key.as_str()).cloned()
};
let bookmark = if provisional_bookmark.is_some() {
let chosen = provisional_bookmark.unwrap();
if !chosen.info.is_search && service.is_search() {
eprintln!(
"Error: while this bookmark {chosen} is a valid entry in the database, it has no search flag set on it."
);
eprintln!(
"To set a search flag, just add a trailing question mark on {chosen} like this: '{chosen}?'"
);
std::process::exit(65);
}
if !chosen.info.is_wiki && service.is_wiki() {
eprintln!("Error: this bookmark '{chosen}' has no wiki flag set on it.");
eprintln!(
"If {chosen} points to a wiki site, flag it by adding a trailing plus sign like this: '{chosen}+'"
);
std::process::exit(65);
}
let location = chosen.location.clone();
let path = LocationType::Link(format!("{}/{address}", location.get()));
let location = Location { path, ..location };
Bookmark { location, ..chosen }
} else {
eprintln!("Error: the {key} service bookmark could not be found in database.");
eprintln!(
"If you are sure that it exists, consider placing an appropriate search or wiki flag on {key} and try again."
);
std::process::exit(65);
};
self.bookmark = bookmark;
}
fn get_by_name(&mut self, name: String) {
let start = name.chars().nth(0).unwrap();
let end = name.chars().nth(name.len() - 1).unwrap();
if !start.is_alphanumeric() || !end.is_alphanumeric() {
eprintln!(
"Error: there is an error with this name, '{name}' as it either starts or ends with a non-alphanumeric character."
);
eprintln!(
"Symbols used to flag a bookmark name in the database do not form part of the bookmark name. So try to remove the invalid characters and try again."
);
std::process::exit(34);
}
let bookmark = if let Some(chawada) = self.category.entries().get(name.as_str()) {
chawada.clone()
} else if name == "CATEGORY_HOME" {
self.category
.entries()
.get(
Configurations::default_settings()
.special_pages
.unwrap()
.home
.unwrap()
.as_str(),
)
.unwrap()
.clone()
} else {
let ref_category = if self.category.name == "<global>" {
"global scope".to_string()
} else {
format!("{} category", self.category.name)
};
eprintln!(
"Error, {name} bookmark could not be found in the {ref_category}. Could it be that {name} is an alias, or in another category, or is meant to be used as a search phrase?"
);
eprintln!("If this is an alias, add a leading forward slash to {name}");
eprintln!(
"Otherwise, if you meant it to be a search phrase, then you have to add a leading question mark for a search engine like this, '?{name}', or as a topic for a wiki site like this '+{name}'"
);
eprintln!(
"You can only leave out the leading symbol when you are passing more than one argument to hlg. A single argument you passed in is always taken for a bookmark name."
);
eprintln!("Bye!");
std::process::exit(35);
};
self.bookmark = bookmark;
}
fn start(&mut self) {
match &self.pad_type {
PadType::ByKey(letter) => self.get_by_key(*letter),
PadType::ByName(bkname) => self.get_by_name(bkname.clone()),
PadType::BySelector(selector) => self.select(selector.clone()),
PadType::BySearch(service) => self.web_search(service.clone()),
}
self.run();
}
fn link_resolver(&self) -> Bookmark {
let info = self.bookmark.info.clone();
let link = LinkVariable::new(
self.bookmark.location.get(),
self.category.clone(),
self.bookmark.info.name.clone(),
)
.expand();
let path = LocationType::new(link);
let handler = if self.bookmark.location.handler.name.contains(":hapana:") {
Handler::new().name_from_protocol(HandlerClass::new(&path))
} else {
self.bookmark.location.handler.clone()
};
Bookmark::new(
info,
Location {
path,
handler,
has_variable: false,
},
)
}
fn run(&self) {
let bookmark = self.bookmark.clone();
let bookmark = if bookmark.location.has_variable {
self.link_resolver()
} else {
bookmark
};
if bookmark.info.is_deactivated {
eprintln!(
"Unfortunately, the {} bookmark is deactivated so cannot be launched. Please reactivate it by removing the trailing dash after its name and try again. Now exiting.",
self.bookmark
);
std::process::exit(36);
} else {
Configurations::record(format!("{}.{}", self.category.name, bookmark));
bookmark.launch();
}
}
fn make_choice(found: &Vec<Bookmark>) -> Bookmark {
for (number, bookmark) in found.iter().enumerate() {
println!(
"[{}]\t{}\t\t{}",
number + 1,
bookmark,
if bookmark.info.comment.is_some() {
bookmark.info.get_comment()
} else {
String::from("(No description)")
}
);
}
let number = input!("Your choice:");
let number: usize = number
.trim()
.parse()
.unwrap_or_else(|_| found.len() - found.len());
let number = if number < 1 {
println!("{number} below the minimum entry. Changing it to 1");
1
} else if number > found.len() {
println!(
"{number} out of bounds for these choices. Changing it to {}",
found.len()
);
found.len()
} else {
number
};
found.get(number - 1).unwrap().clone()
}
}
#[derive(Clone, Copy)]
pub enum Statistics {
Keys,
Summary,
}
#[derive(Clone)]
pub enum DoWhat {
Add(String),
Remove(String),
Rename(Vec<String>),
}
#[derive(Clone)]
enum TargetAction<'a> {
Activity(DoWhat),
File(&'a str),
}
#[derive(Clone)]
pub struct Editor<'a> {
manager: CategoryManager,
target_action: TargetAction<'a>,
}
impl<'a> Editor<'a> {
fn new(target_action: TargetAction<'a>) -> Self {
let manager = CategoryManager::new();
Self {
manager,
target_action,
}
}
fn with_scope(mut self, scope: Scope) -> Self {
let scope = if scope.get_name() == "<global>" {
Scope::Local(CategoryManager::new().name)
} else {
scope
};
self.manager = scope.get_manager();
self
}
fn edit(&self) {
match &self.target_action {
TargetAction::Activity(action) => self.modify_category(action.clone()),
TargetAction::File(filename) => self
.edit_config_file(filename)
.expect("Could not edit configuration file"),
}
}
fn modify_category(&self, action: DoWhat) {
match action {
DoWhat::Add(category_name) => self
.manager
.create_new(category_name)
.expect("Could not create new category"),
DoWhat::Remove(fname) => self
.manager
.delete_category(&fname)
.expect("Could not delete category from database"),
DoWhat::Rename(names) => {
let old_name = names.get(0).unwrap();
let new_name = names.get(1).unwrap();
self.manager
.rename_category(old_name, new_name)
.expect("Could not rename category");
}
}
}
fn edit_config_file(&self, config_file: &'a str) -> std::io::Result<()> {
let mut needs_refresh = false;
let mut category_name = String::new();
let filename = match config_file {
"bookmarks" => {
let chosen_category = if Cli::parse().category.is_some() {
Cli::parse().category.unwrap()
} else {
self.manager.name.clone()
};
category_name = chosen_category.clone();
let filename = format!("{}/{chosen_category}", self.manager.root);
if !Configurations::check_path(&filename) {
eprintln!(
"Error: the category which you want to edit, '{chosen_category}', cannot be found in database.",
);
eprintln!(
"Consider creating it with 'hlg --new-category {chosen_category}' before performing any editing operations on it."
);
std::process::exit(24);
}
needs_refresh = true;
filename
}
"config" => Configurations::config_file(),
_ => {
eprintln!("No file with that name exists here");
std::process::exit(1);
}
};
let template = fs::read_to_string(&filename)?;
let edited = edit(template)?;
if needs_refresh {
let lines: Vec<&str> = edited.lines().collect();
let header = lines.get(0).unwrap();
if header.starts_with("#!") {
CategoryManager::validate_header(&header[2..], &filename);
let fields: Vec<&str> = header.split(';').collect();
if fields.len() > 1 {
let key = &fields[1].trim();
if !key.is_empty()
&& CategoryManager::get_name_by_key(key.chars().nth(0).unwrap()).is_some()
{
let key = key.chars().nth(0).unwrap();
let refcat = CategoryManager::get_name_by_key(key).unwrap();
if refcat != category_name {
eprintln!(
"Error: this key '{key}' you are trying to assign to the {category_name} category is already assigned to the {refcat} category."
);
eprintln!(
"No two categories in the database can use one shortcut key."
);
std::process::exit(19);
}
}
}
}
let save_with_shortcuts = Configurations::get_setting().save_with_shortcuts.unwrap_or(
Configurations::default_settings()
.save_with_shortcuts
.unwrap(),
);
let edited = if save_with_shortcuts {
let mut bookmarks = String::new();
for line in lines {
let newline = if line.contains("<http") || line.contains("<file:") {
Self::use_link_shortcuts(line)
} else {
line.to_string()
};
bookmarks = format!("{bookmarks}{newline}\n");
}
bookmarks
} else {
edited
};
fs::write(&filename, edited)?;
CategoryManager::refresh_category_headers();
} else {
fs::write(&filename, edited)?;
}
Ok(())
}
fn use_link_shortcuts(line: &str) -> String {
line.replace("www.", "=")
.replace("http://localhost:", "@#")
.replace("http://", "@!")
.replace("https://", "@")
.replace("file://", "@+")
}
}
pub enum Action<'a> {
Launch(Target),
Listing(Statistics),
Edit(Editor<'a>),
}
impl Action<'_> {
pub fn new() -> Self {
Self::check_program_configs();
let choice = Cli::parse();
if choice.edit
|| choice.config
|| choice.rename.is_some()
|| choice.delete.is_some()
|| choice.new.is_some()
{
let target = {
if choice.edit {
TargetAction::File("bookmarks")
} else if choice.config {
TargetAction::File("config")
} else {
let activity = if choice.delete.is_some() {
DoWhat::Remove(choice.delete.unwrap())
} else if choice.new.is_some() {
DoWhat::Add(choice.new.unwrap().clone())
} else {
DoWhat::Rename(choice.rename.unwrap().clone())
};
TargetAction::Activity(activity)
}
};
let editor = Editor::new(target);
Self::Edit(editor)
} else if choice.keycategory || choice.keyglobal || choice.summary {
let lister = if choice.keycategory || choice.keyglobal {
Statistics::Keys
} else {
Statistics::Summary
};
Self::Listing(lister)
} else {
Self::Launch(Target::new(choice))
}
}
fn check_program_configs() {
if !Configurations::check_path(&Configurations::get_app_home()) {
Configurations::initialize_defaults().expect(
"Could not create program configuration files. Please check file permissions",
);
}
}
pub fn on(&self) {
let mut scope = Scope::Local(
Configurations::get_setting()
.default_category
.unwrap_or(Configurations::default_settings().default_category.unwrap()),
);
match self {
Self::Edit(editor) => editor.clone().with_scope(scope).edit(),
Self::Launch(target) => LaunchPad::new(target.clone()).start(),
Self::Listing(lister) => {
scope = if Cli::parse().keyglobal {
Scope::Global
} else {
scope
};
scope.get_stats(*lister);
}
}
}
}
#[derive(Clone)]
pub enum Scope {
Local(String),
Global,
}
impl Scope {
fn get_manager(&self) -> CategoryManager {
if self.is_local() {
CategoryManager::new().by_name(self.get_name().clone())
} else {
CategoryManager::new()
}
}
pub fn get_name(&self) -> String {
match self {
Self::Local(category_name) => category_name.to_string(),
_ => "<global>".to_string(),
}
}
pub fn is_local(&self) -> bool {
match self {
Self::Global => false,
_ => true,
}
}
fn apply(&self) -> Category {
let manager = CategoryManager::new();
match self {
Self::Global => manager.set_global(),
Self::Local(category_name) => manager.by_name(category_name).preferred_category(),
}
}
fn list_keys(&self) {
let category = self.apply();
let mut keymap: Vec<Bookmark> = category
.entries()
.into_iter()
.filter(|bookmark| bookmark.info.key.is_some())
.collect();
let display_text = match self {
Self::Global => "global scope".to_string(),
Self::Local(name) => "local category ".to_string() + name,
};
if keymap.len() > 0 {
println!("Key Listing for the {display_text}");
println!("* * * * * *");
println!(
"There is a total of {} bookmarks in the scope, of which {} of them have got shortcuts.",
category.entries().len(),
keymap.len()
);
println!("These are:");
println!("Key:\tBookmark");
keymap.sort_by(|a, b| {
a.info
.get_key()
.to_lowercase()
.cmp(b.info.get_key().to_lowercase())
});
for bookmark in keymap {
println!("{}:\t{}", bookmark.info.get_key(), bookmark);
}
} else {
println!(
"Hi, seems you currently have no {display_text} shortcut keys assigned to your bookmarks."
);
println!(
"Assign shortcut keys by either placing an underscore before the bookmark name or else append a pair of parentheses to the bookmark name and place the desired shortcut key inside the parentheses."
);
println!("Bye!");
}
}
fn summary(&self) {
if Configurations::check_path(&Configurations::config_file()) {
println!("hlg Special Bookmark Summary:");
println!("* * * * * * * *");
println!(
"1. Your default category is currently set to '{}'",
Configurations::get_setting()
.default_category
.unwrap_or(Configurations::default_settings().default_category.unwrap())
);
let home = Configurations::get_homepage();
let home = if home.contains(".") {
let (category, bookmark) = home.split_at(home.find('.').unwrap());
let category = if category.is_empty() {
"default"
} else {
category
};
let bookmark = if bookmark.is_empty() {
"category home"
} else {
bookmark
};
format!(
"the '{}' bookmark which is in the {category} category",
bookmark.strip_prefix('.').unwrap()
)
} else {
format!(" the {home} bookmark in the global scope")
};
println!("2. Your main home bookmark is set to {home}");
let last_visited = format!("{}/last", Configurations::get_records());
if Configurations::check_path(&last_visited) {
let last_bookmark = Configurations::get_last_visit();
let position = last_bookmark.find('.').unwrap();
let (category, name) = last_bookmark.split_at(position);
let category = if category == "<global>" {
"the global scope".to_string()
} else {
format!("the {category} category")
};
println!(
"3. Your last visited bookmark is '{}' which is in {category}",
name.strip_prefix('.').unwrap()
);
} else {
println!(
"3. Currently we have no record of your last visited bookmak. Perhaps this is the first time running hlg on this computer."
);
println!(
"Run this program after making some visits to get the record of your last visited bookmark."
);
}
} else {
println!(
"Hi, your configuration files are not yet set. Could this be your first run of this program on this computer?"
);
println!(
"hlg can only provide a quick summary of your activities based on the setting in your configuration file."
);
println!("Bye.");
}
}
fn get_stats(&self, lister: Statistics) {
match lister {
Statistics::Keys => self.list_keys(),
Statistics::Summary => self.summary(),
}
}
pub fn extend_with(category: &str) -> HashSet<Bookmark> {
match category {
"<global>" => CategoryManager::new().set_global().entries(),
_ => CategoryManager::new()
.by_name(category)
.preferred_category()
.entries(),
}
}
}
#[derive(Clone)]
enum Selector {
Aliases(String),
Starred,
}
#[derive(Clone)]
enum SearchService {
Engine(String, String),
Wiki(String, String),
}
impl SearchService {
fn new(candidate: &str) -> Self {
let (engine, phrase) = candidate.split_at(candidate.find(')').unwrap());
let engine = engine.strip_prefix('(').unwrap().to_string();
let phrase = phrase.strip_prefix(')').unwrap();
if phrase.starts_with('+') {
let topic = phrase.strip_prefix('+').unwrap();
let topic = topic.replace(" ", "_");
SearchService::Wiki(engine, topic)
} else {
let keywords = phrase.strip_prefix('?').unwrap();
let keywords = keywords.replace(" ", "+");
SearchService::Engine(engine, keywords)
}
}
fn is_wiki(&self) -> bool {
match self {
Self::Wiki(_, _) => true,
_ => false,
}
}
fn is_search(&self) -> bool {
match self {
Self::Engine(_, _) => true,
_ => false,
}
}
}
#[derive(Clone)]
pub struct Target {
name: String,
scope: Scope,
}
impl Target {
fn new(choice: Cli) -> Self {
let candidate = if choice.category.is_none() && choice.name.is_none() {
let home = Configurations::get_homepage();
if !home.contains('.') {
format!("<global>.{home}")
} else {
home
}
} else if choice.name.is_none() && choice.category.is_some() {
format!("{}.CATEGORY_HOME", choice.category.unwrap())
} else if choice.name.is_some() && choice.category.is_none() {
let values = choice.name.clone().unwrap();
let tg = values.get(0).unwrap();
if (tg.starts_with(':') && values.len() > 1)
|| tg.starts_with(":?")
|| tg.starts_with(":+")
{
eprintln!(
"Error: Already searches are done in the global scope. So there is no need to qualify an expression with the global scope indicator."
);
eprintln!("Simply remove the leading colon to fix this error.");
std::process::exit(54);
}
if tg.starts_with('=') && values.len() > 1 {
eprintln!(
"Error: The equality trigger is only used with category and bookmark names in the database and cannot be used in search phrases."
);
std::process::exit(52);
}
let key = tg.chars().nth(0).unwrap();
if tg.len() == 1 && values.len() == 1 && !key.is_alphanumeric() {
let key = tg.chars().nth(0).unwrap();
if key == '+' {
format!(
"{}./*",
Configurations::get_setting().default_category.unwrap_or(
Configurations::default_settings().default_category.unwrap()
)
)
} else {
Self::special_keys(key)
}
} else if (tg.starts_with('?') || tg.starts_with('+') || tg.ends_with(':'))
|| values.len() > 1
{
Self::process_search(choice)
} else if tg.starts_with('@') {
Self::process_category_shortcuts(tg)
} else if tg.starts_with('=') {
Self::equality_argument_processor(tg)
} else {
Self::process_bookmark(tg)
}
} else {
let values = choice.name.unwrap();
let word = values.get(0).unwrap();
if word.starts_with('?')
|| word.starts_with('+')
|| word.starts_with(':')
|| word.starts_with('@')
{
eprintln!(
"Error: you cannot set the '--category' option while the global scope switch is oactive"
);
eprintln!(
"Searches and the colon symbol automatically switch to the global scope. So remove these if you want to use the category option."
);
eprintln!(
"Additionally, the at magic symbol is a complete instruction on its own which renders the category option unnecessary."
);
std::process::exit(28);
}
if values.len() > 1 {
eprintln!(
"Error: Automatic searching for phrases only happens in the global scope. As you have selected the category '{}', the search facility is no longer available.",
choice.category.unwrap()
);
eprintln!(
"Any argument passed to hlg should either be a bookmark name or an alias. So remove the extra arguments or the category option to fix this error."
);
std::process::exit(49);
}
if word.contains('.') {
eprintln!(
"Error: category is set in two different places: the '--category' option has the argument of {} and the prefix to the {word} argument both set the category to be used.",
choice.category.unwrap()
);
eprintln!("To fix this error, just set the category in one option and try again.");
std::process::exit(28);
}
let text = if word == "+" {
"/*".to_string()
} else {
word.to_string()
};
let category = choice.category.unwrap();
Self::check_spurious_qualifications(&category);
format!("{category}.{text}")
};
let position = candidate.find('.').unwrap();
let (category, name) = candidate.split_at(position);
let scope = if category == "<global>" {
Scope::Global
} else {
Scope::Local(category.to_string())
};
let name = format!("{}", &name[1..]);
Self { scope, name }
}
fn process_search(choice: Cli) -> String {
let phrases = choice.name.unwrap().clone();
let length = phrases.len();
let bookmark_candidate = phrases.get(0).unwrap();
let mut first_word_is_service = false;
let search_phrase = if (bookmark_candidate.starts_with('?')
|| bookmark_candidate.starts_with('+'))
&& length == 1
{
bookmark_candidate.clone()
} else if (bookmark_candidate.starts_with('?') || bookmark_candidate.starts_with('+'))
&& length > 1
{
phrases.join(" ")
} else if length > 1 {
let terms = &phrases[1..];
let guide = terms.get(0).unwrap();
first_word_is_service = bookmark_candidate.ends_with(':');
if first_word_is_service {
if guide.starts_with('?') || guide.starts_with('+') {
terms.join(" ")
} else {
format!("?{}", terms.join(" "))
}
} else {
format!("?{}", phrases.join(" "))
}
} else {
format!("?{}", phrases.join(" "))
};
let service = if first_word_is_service {
format!("{}", bookmark_candidate.strip_suffix(':').unwrap())
} else if search_phrase.starts_with('?') {
Self::get_default_service("search")
} else {
Self::get_default_service("wiki")
};
format!("<global>.({service}){search_phrase}")
}
fn check_spurious_qualifications(category: &String) {
let default_category = Configurations::get_setting()
.default_category
.unwrap_or(Configurations::default_settings().default_category.unwrap());
if default_category == *category {
println!("WARNING: spurious bookmark name qualification.");
println!(
"Your default category is already set to {category}, yet you are invoking its bookmark with the category name supplied."
);
println!(
"Just call bookmarks in the default category without passing in the category name, whether as a shortcut or as a fully-qualified bookmark name to silence this warning."
);
}
}
fn process_category_shortcuts(token: &String) -> String {
let key = token.chars().nth(1).unwrap();
let target = &token[2..];
let possible_category = CategoryManager::get_name_by_key(key);
if possible_category.is_none() {
eprintln!("Error: this shortcut key, '{key}' is not assigned to any category.");
eprintln!(
"If you want to assign it, create a category header which uses semicolon field separators, then in the second field add the key like this: ';{key};' followed by any description of the category."
);
std::process::exit(37);
}
let category = possible_category.unwrap();
if target.contains('.') {
eprintln!("Error: Invalid syntax in calling category via a shortcut.");
eprintln!(
"A period is used to qualify a bookmark name so that it references its category. However, with a shortcut key, the period becomes unnecessary as you are fetching an identified category with that shortcut."
);
eprintln!(
"Just pass in the name of your bookmark or the bookmark shortcut to activate a target."
);
std::process::exit(28);
}
let target = if target.is_empty() {
"CATEGORY_HOME"
} else if target == "+" {
"/*"
} else {
target
};
Self::check_spurious_qualifications(&category);
format!("{category}.{target}")
}
fn process_bookmark(target: &String) -> String {
let category = Configurations::get_setting()
.default_category
.unwrap_or(Configurations::default_settings().default_category.unwrap());
if target.starts_with(':') {
format!("<global>.{}", target.strip_prefix(':').unwrap())
} else if target.starts_with(".") {
format!("{category}{target}")
} else if target.ends_with(".") {
Self::check_spurious_qualifications(&target.strip_suffix('.').unwrap().to_string());
format!("{target}CATEGORY_HOME")
} else if target.ends_with(".+") {
Self::check_spurious_qualifications(&format!("{}", &target[..target.len() - 1]));
format!("{}/*", &target[..target.len() - 1])
} else if !target.contains(".") {
format!("{category}.{target}")
} else {
let (cat, _) = target.split_at(target.find('.').unwrap());
Self::check_spurious_qualifications(&cat.to_string());
target.to_string()
}
}
fn get_default_service(service_name: &str) -> String {
let pages = Configurations::get_setting().special_pages;
if pages.is_none() {
if service_name == "search" {
return "HLG_SEARCH".to_string();
} else if service_name == "wiki" {
return "HLG_WIKI".to_string();
} else {
eprintln!(
"Error: the 'special-pages' section is missing in your configuration file, so you do not have the '{service_name}' option entry set."
);
eprintln!(
"Please create this section and add the 'search' option along with a special bookmark with the search flag, before running this option."
);
std::process::exit(63);
}
}
let page = pages.unwrap();
match service_name {
"search" => {
if page.search.is_some() {
page.search.unwrap()
} else {
String::from("HLG_SEARCH")
}
}
"wiki" => {
if page.wiki.is_some() {
page.wiki.unwrap()
} else {
String::from("HLG_WIKI")
}
}
_ => {
eprintln!("Error: search service not recognized.");
std::process::exit(1);
}
}
}
fn get_pad(&self) -> PadType {
if self.name.len() == 1 {
let char = self.name.chars().nth(0).unwrap();
if !char.is_alphanumeric() {
eprintln!(
"Error: only use alphanumeric characters to launch bookmarks with shortcut keys."
);
eprintln!(
"Only special bookmarks can be launched by symbols, of which {char} is not one of them."
);
std::process::exit(32);
}
PadType::ByKey(char)
} else if self.name.starts_with('/') {
let phrase = &self.name.strip_prefix('/').unwrap();
let selector = if phrase == &"*" {
Selector::Starred
} else {
Selector::Aliases(phrase.to_string())
};
PadType::BySelector(selector)
} else if self.name.starts_with('(') {
let service = SearchService::new(&self.name);
PadType::BySearch(service)
} else {
PadType::ByName(self.name.clone())
}
}
fn special_keys(key: char) -> String {
let category = Configurations::get_setting()
.default_category
.unwrap_or(Configurations::default_settings().default_category.unwrap());
match key {
'-' => Configurations::get_last_visit(),
'.' => format!("{category}.CATEGORY_HOME"),
'?' => format!("<global>.HLG_HELP"),
_ => {
eprintln!("Error: Unrecognized symbol to hlg.");
eprintln!("This key, {key}, is not a special symbol for hlg.");
std::process::exit(32);
}
}
}
fn equality_argument_processor(candidate: &String) -> String {
if candidate.contains('.') {
eprintln!(
"Error: An equality trigger is already a fully-qualified name with both the category and the bookmark name using the same name."
);
eprintln!(
"It is not necessary to add another qualifier with the period as you did here."
);
std::process::exit(52);
}
let candidate = candidate.strip_prefix('=').unwrap();
if candidate.len() == 1 {
Self::process_category_shortcuts(&format!("@{candidate}{candidate}"))
} else {
Self::process_bookmark(&format!("{candidate}.{candidate}"))
}
}
}