mirror of
https://github.com/ByteAtATime/raycast-linux.git
synced 2025-08-31 03:07:23 +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
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
@ -4593,6 +4593,7 @@ dependencies = [
|
|||
"notify",
|
||||
"notify-debouncer-full",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand 0.9.1",
|
||||
"rayon",
|
||||
"rdev",
|
||||
|
|
|
@ -62,6 +62,7 @@ tauri-plugin-fs = "2"
|
|||
walkdir = "2.5.0"
|
||||
notify = "6.1.1"
|
||||
notify-debouncer-full = "0.3.1"
|
||||
percent-encoding = "2.3.1"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
|
|
|
@ -6,7 +6,7 @@ use super::{
|
|||
use crate::error::AppError;
|
||||
use chrono::Utc;
|
||||
use once_cell::sync::Lazy;
|
||||
use rusqlite::{params, Connection, Result as RusqliteResult};
|
||||
use rusqlite::{params, Connection, OptionalExtension, Result as RusqliteResult};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
|
@ -18,6 +18,31 @@ pub struct ClipboardHistoryManager {
|
|||
pub image_dir: PathBuf,
|
||||
}
|
||||
|
||||
fn row_to_clipboard_item(row: &rusqlite::Row, key: &[u8; 32]) -> RusqliteResult<ClipboardItem> {
|
||||
let conditional_encrypted_content: Option<String> = row.get(10)?;
|
||||
let content_value = conditional_encrypted_content.and_then(|cec| decrypt(&cec, key).ok());
|
||||
|
||||
let encrypted_preview: Option<String> = row.get(9)?;
|
||||
let preview = encrypted_preview.and_then(|ep| decrypt(&ep, key).ok());
|
||||
|
||||
let first_ts: i64 = row.get(4)?;
|
||||
let last_ts: i64 = row.get(5)?;
|
||||
|
||||
Ok(ClipboardItem {
|
||||
id: row.get(0)?,
|
||||
hash: row.get(1)?,
|
||||
content_type: ContentType::from_str(&row.get::<_, String>(2)?).unwrap_or(ContentType::Text),
|
||||
content_value,
|
||||
preview,
|
||||
content_size_bytes: row.get(8)?,
|
||||
source_app_name: row.get(3)?,
|
||||
first_copied_at: chrono::DateTime::from_timestamp(first_ts, 0).unwrap_or_default(),
|
||||
last_copied_at: chrono::DateTime::from_timestamp(last_ts, 0).unwrap_or_default(),
|
||||
times_copied: row.get(6)?,
|
||||
is_pinned: row.get::<_, i32>(7)? == 1,
|
||||
})
|
||||
}
|
||||
|
||||
impl ClipboardHistoryManager {
|
||||
fn new(app_handle: AppHandle) -> Result<Self, AppError> {
|
||||
let data_dir = app_handle
|
||||
|
@ -136,32 +161,8 @@ impl ClipboardHistoryManager {
|
|||
let params_ref: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
|
||||
|
||||
let mut stmt = db.prepare(&query)?;
|
||||
let items_iter = stmt.query_map(¶ms_ref[..], |row| {
|
||||
let conditional_encrypted_content: Option<String> = row.get(10)?;
|
||||
let content_value =
|
||||
conditional_encrypted_content.and_then(|cec| decrypt(&cec, &self.key).ok());
|
||||
|
||||
let encrypted_preview: Option<String> = row.get(9)?;
|
||||
let preview = encrypted_preview.and_then(|ep| decrypt(&ep, &self.key).ok());
|
||||
|
||||
let first_ts: i64 = row.get(4)?;
|
||||
let last_ts: i64 = row.get(5)?;
|
||||
|
||||
Ok(ClipboardItem {
|
||||
id: row.get(0)?,
|
||||
hash: row.get(1)?,
|
||||
content_type: ContentType::from_str(&row.get::<_, String>(2)?)
|
||||
.unwrap_or(ContentType::Text),
|
||||
content_value,
|
||||
preview,
|
||||
content_size_bytes: row.get(8)?,
|
||||
source_app_name: row.get(3)?,
|
||||
first_copied_at: chrono::DateTime::from_timestamp(first_ts, 0).unwrap_or_default(),
|
||||
last_copied_at: chrono::DateTime::from_timestamp(last_ts, 0).unwrap_or_default(),
|
||||
times_copied: row.get(6)?,
|
||||
is_pinned: row.get::<_, i32>(7)? == 1,
|
||||
})
|
||||
})?;
|
||||
let key = self.key;
|
||||
let items_iter = stmt.query_map(¶ms_ref[..], |row| row_to_clipboard_item(row, &key))?;
|
||||
|
||||
let mut all_items = items_iter.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
|
@ -183,6 +184,21 @@ impl ClipboardHistoryManager {
|
|||
Ok(all_items)
|
||||
}
|
||||
|
||||
pub fn get_content_by_offset(&self, offset: u32) -> Result<Option<String>, AppError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let res: rusqlite::Result<String> = db.query_row(
|
||||
"SELECT encrypted_content FROM clipboard_history ORDER BY last_copied_at DESC LIMIT 1 OFFSET ?",
|
||||
params![offset],
|
||||
|row| row.get(0),
|
||||
);
|
||||
|
||||
match res {
|
||||
Ok(encrypted) => Ok(Some(decrypt(&encrypted, &self.key)?)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_item_content(&self, id: i64) -> Result<String, AppError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let encrypted_content: String = db.query_row(
|
||||
|
@ -241,4 +257,4 @@ pub fn init(app_handle: AppHandle) {
|
|||
Err(e) => eprintln!("Failed to create ClipboardHistoryManager: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,13 +6,13 @@ pub mod clipboard_history;
|
|||
mod desktop;
|
||||
mod error;
|
||||
mod extensions;
|
||||
mod file_search;
|
||||
mod filesystem;
|
||||
mod frecency;
|
||||
mod oauth;
|
||||
mod quicklinks;
|
||||
mod snippets;
|
||||
mod system;
|
||||
mod file_search;
|
||||
|
||||
use crate::snippets::input_manager::{EvdevInputManager, InputManager};
|
||||
use crate::{app::App, cache::AppCache};
|
||||
|
@ -276,26 +276,27 @@ pub fn run() {
|
|||
|
||||
Ok(())
|
||||
})
|
||||
.build(tauri::generate_context!()).unwrap();
|
||||
.build(tauri::generate_context!())
|
||||
.unwrap();
|
||||
|
||||
app.run(|app, event| {
|
||||
if let tauri::RunEvent::WindowEvent { label, event, .. } = event {
|
||||
if label == "main" {
|
||||
match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
api.prevent_close();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
app.run(|app, event| {
|
||||
if let tauri::RunEvent::WindowEvent { label, event, .. } = event {
|
||||
if label == "main" {
|
||||
match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
api.prevent_close();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
tauri::WindowEvent::Focused(false) => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
tauri::WindowEvent::Focused(false) => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,49 +19,122 @@
|
|||
let content = $state('');
|
||||
let error = $state('');
|
||||
|
||||
const VALID_PLACEHOLDERS_NO_CURSOR = new Set(['clipboard', 'date', 'time', 'datetime', 'day']);
|
||||
type ParsedPart = {
|
||||
text: string;
|
||||
type: 'text' | 'valid-bracket' | 'invalid-bracket' | 'valid-name' | 'invalid-name';
|
||||
};
|
||||
|
||||
const PLACEHOLDER_REGEX =
|
||||
/\{(?<name>\w+)(?<attributes>(?:\s+\w+=(?:"[^"]*"|\S+))*)?(?<modifiers>(?:\s*\|\s*[\w%-]+)*)\}/g;
|
||||
|
||||
const VALID_PLACEHOLDERS = new Set([
|
||||
'clipboard',
|
||||
'snippet',
|
||||
'cursor',
|
||||
'date',
|
||||
'time',
|
||||
'datetime',
|
||||
'day',
|
||||
'uuid'
|
||||
]);
|
||||
const VALID_MODIFIERS = new Set([
|
||||
'uppercase',
|
||||
'lowercase',
|
||||
'trim',
|
||||
'percent-encode',
|
||||
'json-stringify'
|
||||
]);
|
||||
const VALID_ATTRIBUTES: Record<string, Set<string>> = {
|
||||
clipboard: new Set(['offset']),
|
||||
snippet: new Set(['name']),
|
||||
date: new Set(['offset', 'format']),
|
||||
time: new Set(['offset', 'format']),
|
||||
datetime: new Set(['offset', 'format']),
|
||||
day: new Set(['offset', 'format'])
|
||||
};
|
||||
const ATTRIBUTE_REGEX = /\s*(?<key>\w+)\s*=\s*"(?:[^"]*)"/g;
|
||||
|
||||
function parseAttributes(attrStr: string | undefined): Record<string, string> {
|
||||
if (!attrStr) return {};
|
||||
const attributes: Record<string, string> = {};
|
||||
for (const match of attrStr.matchAll(ATTRIBUTE_REGEX)) {
|
||||
if (match.groups) {
|
||||
attributes[match.groups.key] = 'dummy';
|
||||
}
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function parseModifiers(modStr: string | undefined): string[] {
|
||||
if (!modStr) return [];
|
||||
return modStr
|
||||
.split('|')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function validatePlaceholder(
|
||||
name: string,
|
||||
attributes: Record<string, string>,
|
||||
modifiers: string[]
|
||||
): boolean {
|
||||
if (!VALID_PLACEHOLDERS.has(name)) return false;
|
||||
|
||||
const validAttrsForPlaceholder = VALID_ATTRIBUTES[name] || new Set();
|
||||
for (const attrName in attributes) {
|
||||
if (!validAttrsForPlaceholder.has(attrName)) return false;
|
||||
}
|
||||
|
||||
for (const mod of modifiers) {
|
||||
if (!VALID_MODIFIERS.has(mod)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsedContent = $derived.by(() => {
|
||||
if (!content) return [];
|
||||
|
||||
const parts: { text: string; highlightType: 'text' | 'valid-bracket' | 'invalid-bracket' }[] =
|
||||
[];
|
||||
const parts: ParsedPart[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /({([a-zA-Z_]+?)})/g;
|
||||
let match;
|
||||
let cursorFoundAndValid = false;
|
||||
let cursorCount = 0;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
for (const match of content.matchAll(PLACEHOLDER_REGEX)) {
|
||||
if (match.index! > lastIndex) {
|
||||
parts.push({
|
||||
text: content.substring(lastIndex, match.index),
|
||||
highlightType: 'text'
|
||||
type: 'text'
|
||||
});
|
||||
}
|
||||
|
||||
const placeholderName = match[2];
|
||||
let currentBracketHighlightType: 'valid-bracket' | 'invalid-bracket' = 'invalid-bracket';
|
||||
const placeholderName = match.groups!.name;
|
||||
const attributes = parseAttributes(match.groups!.attributes);
|
||||
const modifiers = parseModifiers(match.groups!.modifiers);
|
||||
|
||||
let isValid = validatePlaceholder(placeholderName, attributes, modifiers);
|
||||
|
||||
if (placeholderName === 'cursor') {
|
||||
if (!cursorFoundAndValid) {
|
||||
currentBracketHighlightType = 'valid-bracket';
|
||||
cursorFoundAndValid = true;
|
||||
} else {
|
||||
currentBracketHighlightType = 'invalid-bracket';
|
||||
cursorCount++;
|
||||
if (cursorCount > 1) {
|
||||
isValid = false;
|
||||
}
|
||||
} else if (VALID_PLACEHOLDERS_NO_CURSOR.has(placeholderName)) {
|
||||
currentBracketHighlightType = 'valid-bracket';
|
||||
}
|
||||
|
||||
parts.push({ text: '{', highlightType: currentBracketHighlightType });
|
||||
parts.push({ text: placeholderName, highlightType: 'text' });
|
||||
parts.push({ text: '}', highlightType: currentBracketHighlightType });
|
||||
const bracketType = isValid ? 'valid-bracket' : 'invalid-bracket';
|
||||
const nameType = isValid ? 'valid-name' : 'invalid-name';
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
parts.push({ text: '{', type: bracketType });
|
||||
parts.push({
|
||||
text: match[0].slice(1, -1),
|
||||
type: nameType
|
||||
});
|
||||
parts.push({ text: '}', type: bracketType });
|
||||
|
||||
lastIndex = match.index! + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
parts.push({ text: content.substring(lastIndex), highlightType: 'text' });
|
||||
parts.push({ text: content.substring(lastIndex), type: 'text' });
|
||||
}
|
||||
|
||||
return parts;
|
||||
|
@ -129,18 +202,18 @@
|
|||
>
|
||||
{#each parsedContent as part}
|
||||
<span
|
||||
class:text-blue-300={part.highlightType === 'valid-bracket'}
|
||||
class:text-red-400={part.highlightType === 'invalid-bracket'}
|
||||
class:text-foreground={part.highlightType === 'text'}
|
||||
class:text-blue-400={part.type === 'valid-bracket'}
|
||||
class:text-red-400={part.type === 'invalid-bracket' || part.type === 'invalid-name'}
|
||||
class:text-foreground={part.type === 'text' || part.type === 'valid-name'}
|
||||
>
|
||||
{part.text}
|
||||
</span>
|
||||
{/each}
|
||||
<span>​</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder="Enter your snippet content... e.g. Hello {'{'}clipboard}!"
|
||||
placeholder="Enter your snippet content... e.g. Hello {'{'}clipboard | uppercase}!"
|
||||
bind:value={content}
|
||||
class="caret-foreground col-start-1 row-start-1 min-h-32 resize-none !bg-transparent font-mono text-transparent"
|
||||
spellcheck={false}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue