use chrono::{DateTime, Local, Locale};
use liwe::model::config::LinkType as ConfigLinkType;
use liwe::model::Key;
use liwe::operations::Changes;
use minijinja::{context, Environment};
use sanitize_filename::sanitize;
use super::{string_to_slug, Action, ActionContext, ActionProvider};
use crate::router::server::extensions::utf16_to_byte_offset;
pub struct LinkAction {
pub title: String,
pub identifier: String,
pub link_type: Option<ConfigLinkType>,
pub key_template: String,
pub key_date_format: String,
pub locale: Locale,
}
impl LinkAction {
fn format_target_key(
&self,
context: &impl ActionContext,
id: &str,
_parent_key: &str,
word: &str,
) -> Key {
let now: DateTime<Local> = context.now().into();
let formatted = now.format_localized(&self.key_date_format, self.locale).to_string();
let slug = string_to_slug(word);
let relative_key = Environment::new()
.template_from_str(&self.key_template)
.expect("correct template")
.render(context! {
today => formatted,
id => id.to_string(),
title => sanitize(word),
slug => slug,
})
.expect("template to work");
let base_key = Key::name(&relative_key);
std::iter::successors(Some((base_key.clone(), 1)), |(key, counter)| {
context.key_exists(key).then(|| {
let suffixed_name = format!("{}-{}", base_key, counter);
(Key::name(&suffixed_name), counter + 1)
})
})
.last()
.map(|(key, _)| key)
.unwrap_or(base_key)
}
fn extract_selected_text(
line_text: &str,
start_byte: usize,
end_byte: usize,
) -> Option<(String, usize, usize)> {
if start_byte < end_byte && end_byte <= line_text.len() {
let text = line_text[start_byte..end_byte].to_string();
(!text.trim().is_empty()).then_some((text, start_byte, end_byte))
} else {
None
}
}
fn extract_word_at_cursor(
line_text: &str,
cursor_byte: usize,
) -> Option<(String, usize, usize)> {
(cursor_byte <= line_text.len()).then(|| {
let bytes = line_text.as_bytes();
let start = (0..cursor_byte)
.rev()
.take_while(|&i| Self::is_word_char(bytes[i]))
.last()
.unwrap_or(cursor_byte);
let end = (cursor_byte..bytes.len())
.take_while(|&i| Self::is_word_char(bytes[i]))
.last()
.map(|i| i + 1)
.unwrap_or(cursor_byte);
(start != end)
.then(|| line_text[start..end].to_string())
.filter(|word| !word.trim().is_empty())
.map(|word| (word, start, end))
})?
}
fn is_word_char(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || (c >= 128)
}
fn replace_word_with_link(
line_text: &str,
word: &str,
start_byte: usize,
end_byte: usize,
new_key: &Key,
link_type: Option<&ConfigLinkType>,
) -> String {
let link_text = match link_type {
Some(ConfigLinkType::WikiLink) => format!("[[{}]]", new_key),
Some(ConfigLinkType::Markdown) | None => format!("[{}]({})", word, new_key),
};
format!(
"{}{}{}",
&line_text[..start_byte],
link_text,
&line_text[end_byte..]
)
}
}
impl ActionProvider for LinkAction {
fn identifier(&self) -> String {
format!("custom.{}", self.identifier)
}
fn action(
&self,
key: super::Key,
selection: super::TextRange,
context: impl ActionContext,
) -> Option<Action> {
(selection.start.line == selection.end.line).then(|| {
let document = context.get_document_markdown(&key)?;
let lines: Vec<&str> = document.lines().collect();
let target_line = selection.start.line as usize;
let line_text = lines.get(target_line)?;
let start_byte = utf16_to_byte_offset(line_text, selection.start.character)?;
let end_byte = utf16_to_byte_offset(line_text, selection.end.character)?;
let has_selection = start_byte != end_byte;
if has_selection {
Self::extract_selected_text(line_text, start_byte, end_byte)?;
} else {
Self::extract_word_at_cursor(line_text, start_byte)?;
}
Some(Action {
title: self.title.clone(),
identifier: self.identifier(),
key: key.clone(),
range: selection.clone(),
})
})?
}
fn changes(
&self,
key: super::Key,
selection: super::TextRange,
context: impl ActionContext,
) -> Option<Changes> {
(selection.start.line == selection.end.line).then(|| {
let document = context.get_document_markdown(&key)?;
let lines: Vec<&str> = document.lines().collect();
let target_line = selection.start.line as usize;
let line_text = lines.get(target_line)?;
let start_byte = utf16_to_byte_offset(line_text, selection.start.character)?;
let end_byte = utf16_to_byte_offset(line_text, selection.end.character)?;
let has_selection = start_byte != end_byte;
let (word, start, end) = if has_selection {
Self::extract_selected_text(line_text, start_byte, end_byte)?
} else {
Self::extract_word_at_cursor(line_text, start_byte)?
};
let id = context
.unique_ids(&key.parent(), 1)
.first()
.expect("to have one")
.to_string();
let new_key = self.format_target_key(&context, &id, &key.parent(), &word);
let new_markdown = format!("# {}\n", word);
let updated_line = Self::replace_word_with_link(
line_text,
&word,
start,
end,
&new_key,
self.link_type.as_ref(),
);
let updated_markdown = lines
.iter()
.enumerate()
.map(|(i, &line)| {
if i == target_line {
updated_line.as_str()
} else {
line
}
})
.collect::<Vec<_>>()
.join("\n")
+ "\n";
Some(
Changes::new()
.create(new_key, new_markdown)
.update(key, updated_markdown),
)
})?
}
}