mirror of
https://github.com/ByteAtATime/raycast-linux.git
synced 2025-09-09 23:46:29 +00:00
feat(snippets): implement dynamic placeholders and modifiers
This commit implements a system for dynamic snippet placeholders, including support for date/time manipulation, clipboard history, and nested snippets. It also adds a flexible modifier system (`uppercase`, `trim`, `percent-encode`, `json-stringify`) that can be chained and applied to any placeholder.
This commit is contained in:
parent
e2316cf949
commit
2437ad7d61
8 changed files with 509 additions and 111 deletions
|
@ -1,18 +1,44 @@
|
|||
use crate::clipboard_history::manager::{
|
||||
ClipboardHistoryManager, MANAGER as CLIPBOARD_MANAGER_STATIC,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::snippets::input_manager::{InputEvent, InputManager};
|
||||
use crate::snippets::manager::SnippetManager;
|
||||
use arboard::Clipboard;
|
||||
use chrono::Local;
|
||||
use chrono::{DateTime, Duration, Local, Months};
|
||||
use enigo::Key as EnigoKey;
|
||||
use once_cell::sync::Lazy;
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use uuid::Uuid;
|
||||
|
||||
const BUFFER_SIZE: usize = 30;
|
||||
const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
|
||||
|
||||
static PLACEHOLDER_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"\{(?P<name>\w+)(?P<attributes>(?:\s+\w+=(?:"[^"]*"|\S+))*)?(?P<modifiers>(?:\s*\|\s*[\w%-]+)*)\}"#).unwrap()
|
||||
});
|
||||
static ATTRIBUTE_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"\s*(?P<key>\w+)=(?:"(?P<q_value>[^"]*)"|(?P<uq_value>\S+))"#).unwrap()
|
||||
});
|
||||
static OFFSET_REGEX: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"(?P<sign>[+-])(?P<num>\d+)(?P<unit>[ymhMd])").unwrap());
|
||||
|
||||
pub struct ResolvedSnippet {
|
||||
pub content: String,
|
||||
pub cursor_pos: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ParsedPlaceholder<'a> {
|
||||
name: &'a str,
|
||||
attributes: HashMap<&'a str, &'a str>,
|
||||
modifiers: Vec<&'a str>,
|
||||
}
|
||||
|
||||
pub struct ExpansionEngine {
|
||||
buffer: Arc<Mutex<String>>,
|
||||
snippet_manager: Arc<SnippetManager>,
|
||||
|
@ -67,9 +93,15 @@ impl ExpansionEngine {
|
|||
if let Ok(snippets) = self.snippet_manager.list_snippets(None) {
|
||||
for snippet in snippets {
|
||||
if buffer.ends_with(&snippet.keyword) {
|
||||
let (keyword, content) = (snippet.keyword.clone(), snippet.content.clone());
|
||||
let (keyword, content, id) =
|
||||
(snippet.keyword.clone(), snippet.content.clone(), snippet.id);
|
||||
let manager = self.snippet_manager.clone();
|
||||
drop(buffer);
|
||||
self.expand_snippet(&keyword, &content);
|
||||
// run in a separate thread to not block input
|
||||
thread::spawn(move || {
|
||||
let _ = manager.snippet_was_used(id);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -82,7 +114,24 @@ impl ExpansionEngine {
|
|||
backspaces.push('\u{8}');
|
||||
}
|
||||
|
||||
let resolved = parse_and_resolve_placeholders(content);
|
||||
let clipboard_manager_lock = CLIPBOARD_MANAGER_STATIC.lock().unwrap();
|
||||
let resolved_result = parse_and_resolve_placeholders(
|
||||
content,
|
||||
&self.snippet_manager,
|
||||
clipboard_manager_lock.as_ref(),
|
||||
);
|
||||
|
||||
let resolved = match resolved_result {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
eprintln!("[ExpansionEngine] Error resolving placeholders: {}", e);
|
||||
ResolvedSnippet {
|
||||
content: content.to_string(),
|
||||
cursor_pos: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let content_to_paste = resolved.content;
|
||||
|
||||
let chars_to_move_left = if let Some(pos) = resolved.cursor_pos {
|
||||
|
@ -117,45 +166,267 @@ impl ExpansionEngine {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn parse_and_resolve_placeholders(raw_content: &str) -> ResolvedSnippet {
|
||||
fn parse_attributes(attr_str: &str) -> HashMap<&str, &str> {
|
||||
ATTRIBUTE_REGEX
|
||||
.captures_iter(attr_str)
|
||||
.filter_map(|cap| {
|
||||
let key = cap.name("key")?.as_str();
|
||||
let value = cap
|
||||
.name("q_value")
|
||||
.or_else(|| cap.name("uq_value"))?
|
||||
.as_str();
|
||||
Some((key, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_modifiers(mod_str: &str) -> Vec<&str> {
|
||||
mod_str
|
||||
.split('|')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn apply_modifiers(mut value: String, modifiers: &[&str]) -> String {
|
||||
for &modifier in modifiers {
|
||||
value = match modifier {
|
||||
"uppercase" => value.to_uppercase(),
|
||||
"lowercase" => value.to_lowercase(),
|
||||
"trim" => value.trim().to_string(),
|
||||
"percent-encode" => utf8_percent_encode(&value, FRAGMENT).to_string(),
|
||||
"json-stringify" => serde_json::to_string(&value).unwrap_or(value),
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn translate_date_format(format_str: &str) -> String {
|
||||
let mut result = String::with_capacity(format_str.len());
|
||||
let mut in_literal = false;
|
||||
let mut chars = format_str.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\'' {
|
||||
in_literal = !in_literal;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_literal {
|
||||
result.push(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut count = 1;
|
||||
while chars.peek() == Some(&c) {
|
||||
chars.next();
|
||||
count += 1;
|
||||
}
|
||||
|
||||
let format_specifier = match (c, count) {
|
||||
// year
|
||||
('y', 4) => "%Y",
|
||||
('y', 2) => "%y",
|
||||
// month
|
||||
('M', 4) => "%B",
|
||||
('M', 3) => "%b",
|
||||
('M', 2) => "%m",
|
||||
('M', 1) => "%-m",
|
||||
// day of week
|
||||
('E', 4) => "%A",
|
||||
('E', 1..=3) => "%a",
|
||||
// day of month
|
||||
('d', 2) => "%d",
|
||||
('d', 1) => "%-d",
|
||||
// hour (0-23)
|
||||
('H', 2) => "%H",
|
||||
('H', 1) => "%-H",
|
||||
// hour (1-12)
|
||||
('h', 2) => "%I",
|
||||
('h', 1) => "%-I",
|
||||
// minute
|
||||
('m', 2) => "%M",
|
||||
('m', 1) => "%-M",
|
||||
// second
|
||||
('s', 2) => "%S",
|
||||
('s', 1) => "%-S",
|
||||
// fractional second
|
||||
('S', 3) => "%f",
|
||||
// am/pm
|
||||
('a', 1) => "%p",
|
||||
// timezone
|
||||
('Z', 1) => "%z",
|
||||
|
||||
(other_char, num_chars) => {
|
||||
// treat as literal
|
||||
for _ in 0..num_chars {
|
||||
result.push(other_char);
|
||||
}
|
||||
|
||||
""
|
||||
}
|
||||
};
|
||||
|
||||
result.push_str(format_specifier);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn resolve_value<'a>(
|
||||
placeholder: &ParsedPlaceholder,
|
||||
snippet_manager: &SnippetManager,
|
||||
clipboard_manager: Option<&ClipboardHistoryManager>,
|
||||
) -> Result<String, AppError> {
|
||||
let now = Local::now();
|
||||
|
||||
match placeholder.name {
|
||||
"cursor" => Ok(String::new()),
|
||||
"uuid" => Ok(Uuid::new_v4().to_string().to_uppercase()),
|
||||
"clipboard" => {
|
||||
let offset: u32 = placeholder
|
||||
.attributes
|
||||
.get("offset")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
if offset > 0 {
|
||||
if let Some(cm) = clipboard_manager {
|
||||
if let Some(content) = cm.get_content_by_offset(offset)? {
|
||||
return Ok(content);
|
||||
}
|
||||
}
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
Ok(Clipboard::new()
|
||||
.ok()
|
||||
.and_then(|mut c| c.get_text().ok())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
"snippet" => {
|
||||
if let Some(name) = placeholder.attributes.get("name") {
|
||||
if let Some(snippet) = snippet_manager.find_snippet_by_name(name)? {
|
||||
if PLACEHOLDER_REGEX.is_match(&snippet.content) {
|
||||
return Ok(String::new()); // prevent recursion
|
||||
}
|
||||
return Ok(snippet.content);
|
||||
}
|
||||
}
|
||||
Ok(String::new())
|
||||
}
|
||||
"date" | "time" | "datetime" | "day" => {
|
||||
let mut date_time: DateTime<Local> = now;
|
||||
if let Some(offset_str) = placeholder.attributes.get("offset") {
|
||||
for cap in OFFSET_REGEX.captures_iter(offset_str) {
|
||||
let sign = cap.name("sign").unwrap().as_str();
|
||||
let num: i64 = cap.name("num").unwrap().as_str().parse().unwrap_or(0);
|
||||
let val = if sign == "-" { -num } else { num };
|
||||
|
||||
match cap.name("unit").unwrap().as_str() {
|
||||
"y" => {
|
||||
let months_to_add = val * 12;
|
||||
if months_to_add > 0 {
|
||||
date_time = date_time
|
||||
.checked_add_months(Months::new(months_to_add as u32))
|
||||
.unwrap_or(date_time);
|
||||
} else if months_to_add < 0 {
|
||||
date_time = date_time
|
||||
.checked_sub_months(Months::new(-months_to_add as u32))
|
||||
.unwrap_or(date_time);
|
||||
}
|
||||
}
|
||||
"M" => {
|
||||
if val > 0 {
|
||||
date_time = date_time
|
||||
.checked_add_months(Months::new(val as u32))
|
||||
.unwrap_or(date_time);
|
||||
} else if val < 0 {
|
||||
date_time = date_time
|
||||
.checked_sub_months(Months::new(-val as u32))
|
||||
.unwrap_or(date_time);
|
||||
}
|
||||
}
|
||||
"d" => {
|
||||
date_time = date_time
|
||||
.checked_add_signed(Duration::days(val))
|
||||
.unwrap_or(date_time)
|
||||
}
|
||||
"h" => {
|
||||
date_time = date_time
|
||||
.checked_add_signed(Duration::hours(val))
|
||||
.unwrap_or(date_time)
|
||||
}
|
||||
"m" => {
|
||||
date_time = date_time
|
||||
.checked_add_signed(Duration::minutes(val))
|
||||
.unwrap_or(date_time)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let format_str = if let Some(fmt) = placeholder.attributes.get("format") {
|
||||
translate_date_format(fmt)
|
||||
} else {
|
||||
match placeholder.name {
|
||||
"date" => "%-d %b %Y".to_string(),
|
||||
"time" => "%-I:%M %p".to_string(),
|
||||
"datetime" => "%-d %b %Y at %-I:%M %p".to_string(),
|
||||
"day" => "%A".to_string(),
|
||||
_ => "".to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
Ok(date_time
|
||||
.format(&format_str)
|
||||
.to_string()
|
||||
.replace("am", "AM")
|
||||
.replace("pm", "PM"))
|
||||
}
|
||||
_ => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_and_resolve_placeholders(
|
||||
raw_content: &str,
|
||||
snippet_manager: &SnippetManager,
|
||||
clipboard_manager: Option<&ClipboardHistoryManager>,
|
||||
) -> Result<ResolvedSnippet, AppError> {
|
||||
let mut resolved_content = String::with_capacity(raw_content.len());
|
||||
let mut cursor_pos: Option<usize> = None;
|
||||
let mut last_end = 0;
|
||||
|
||||
for (start, _) in raw_content.match_indices('{') {
|
||||
if start < last_end {
|
||||
continue;
|
||||
}
|
||||
if let Some(end) = raw_content[start..].find('}') {
|
||||
let placeholder = &raw_content[start + 1..start + end];
|
||||
for cap in PLACEHOLDER_REGEX.captures_iter(raw_content) {
|
||||
let full_match = cap.get(0).unwrap();
|
||||
resolved_content.push_str(&raw_content[last_end..full_match.start()]);
|
||||
|
||||
resolved_content.push_str(&raw_content[last_end..start]);
|
||||
let name = cap.name("name").unwrap().as_str();
|
||||
let attributes = parse_attributes(cap.name("attributes").map_or("", |m| m.as_str()));
|
||||
let modifiers = parse_modifiers(cap.name("modifiers").map_or("", |m| m.as_str()));
|
||||
|
||||
let replacement = match placeholder {
|
||||
"cursor" => {
|
||||
if cursor_pos.is_none() {
|
||||
cursor_pos = Some(resolved_content.chars().count());
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
"clipboard" => Clipboard::new()
|
||||
.ok()
|
||||
.and_then(|mut c| c.get_text().ok())
|
||||
.unwrap_or_default(),
|
||||
"date" => Local::now().format("%d %b %Y").to_string(),
|
||||
"time" => Local::now().format("%H:%M").to_string(),
|
||||
"datetime" => Local::now().format("%d %b %Y at %H:%M").to_string(),
|
||||
"day" => Local::now().format("%A").to_string(),
|
||||
_ => raw_content[start..start + end + 1].to_string(),
|
||||
};
|
||||
resolved_content.push_str(&replacement);
|
||||
last_end = start + end + 1;
|
||||
let placeholder = ParsedPlaceholder {
|
||||
name,
|
||||
attributes,
|
||||
modifiers,
|
||||
};
|
||||
|
||||
if placeholder.name == "cursor" {
|
||||
if cursor_pos.is_none() {
|
||||
cursor_pos = Some(resolved_content.chars().count());
|
||||
}
|
||||
} else {
|
||||
let value = resolve_value(&placeholder, snippet_manager, clipboard_manager)?;
|
||||
let modified_value = apply_modifiers(value, &placeholder.modifiers);
|
||||
resolved_content.push_str(&modified_value);
|
||||
}
|
||||
last_end = full_match.end();
|
||||
}
|
||||
resolved_content.push_str(&raw_content[last_end..]);
|
||||
|
||||
ResolvedSnippet {
|
||||
Ok(ResolvedSnippet {
|
||||
content: resolved_content,
|
||||
cursor_pos,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -169,4 +169,30 @@ impl SnippetManager {
|
|||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_snippet_by_name(&self, name: &str) -> Result<Option<Snippet>, AppError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let mut stmt = db.prepare("SELECT id, name, keyword, content, created_at, updated_at, times_used, last_used_at FROM snippets WHERE name = ?1 ORDER BY updated_at DESC LIMIT 1")?;
|
||||
let mut rows = stmt.query_map(params![name], |row| {
|
||||
let created_at_ts: i64 = row.get(4)?;
|
||||
let updated_at_ts: i64 = row.get(5)?;
|
||||
let last_used_at_ts: i64 = row.get(7)?;
|
||||
Ok(Snippet {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
keyword: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
created_at: DateTime::from_timestamp(created_at_ts, 0).unwrap_or_default(),
|
||||
updated_at: DateTime::from_timestamp(updated_at_ts, 0).unwrap_or_default(),
|
||||
times_used: row.get(6)?,
|
||||
last_used_at: DateTime::from_timestamp(last_used_at_ts, 0).unwrap_or_default(),
|
||||
})
|
||||
})?;
|
||||
|
||||
if let Some(row) = rows.next() {
|
||||
Ok(Some(row?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ pub mod input_manager;
|
|||
pub mod manager;
|
||||
pub mod types;
|
||||
|
||||
use crate::clipboard_history;
|
||||
use crate::error::AppError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
@ -72,12 +73,20 @@ pub fn snippet_was_used(app: AppHandle, id: i64) -> Result<(), String> {
|
|||
|
||||
#[tauri::command]
|
||||
pub fn paste_snippet_content(app: AppHandle, content: String) -> Result<(), String> {
|
||||
let snippet_manager = app.state::<manager::SnippetManager>().inner();
|
||||
let clipboard_manager = clipboard_history::manager::MANAGER.lock().unwrap();
|
||||
let input_manager = app
|
||||
.state::<Arc<dyn input_manager::InputManager>>()
|
||||
.inner()
|
||||
.clone();
|
||||
|
||||
let resolved = engine::parse_and_resolve_placeholders(&content);
|
||||
let resolved = engine::parse_and_resolve_placeholders(
|
||||
&content,
|
||||
snippet_manager,
|
||||
clipboard_manager.as_ref(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let content_to_paste = resolved.content;
|
||||
|
||||
let chars_to_move_left = if let Some(pos) = resolved.cursor_pos {
|
||||
|
@ -131,4 +140,4 @@ pub fn import_snippets(app: AppHandle, json_content: String) -> Result<ImportRes
|
|||
snippets_added,
|
||||
duplicates_skipped,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue