mirror of
https://github.com/ByteAtATime/raycast-linux.git
synced 2025-09-02 12:17:24 +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",
|
||||||
"notify-debouncer-full",
|
"notify-debouncer-full",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"percent-encoding",
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
"rayon",
|
"rayon",
|
||||||
"rdev",
|
"rdev",
|
||||||
|
|
|
@ -62,6 +62,7 @@ tauri-plugin-fs = "2"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
notify = "6.1.1"
|
notify = "6.1.1"
|
||||||
notify-debouncer-full = "0.3.1"
|
notify-debouncer-full = "0.3.1"
|
||||||
|
percent-encoding = "2.3.1"
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
|
|
|
@ -6,7 +6,7 @@ use super::{
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use once_cell::sync::Lazy;
|
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::path::PathBuf;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
@ -18,6 +18,31 @@ pub struct ClipboardHistoryManager {
|
||||||
pub image_dir: PathBuf,
|
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 {
|
impl ClipboardHistoryManager {
|
||||||
fn new(app_handle: AppHandle) -> Result<Self, AppError> {
|
fn new(app_handle: AppHandle) -> Result<Self, AppError> {
|
||||||
let data_dir = app_handle
|
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 params_ref: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
|
||||||
|
|
||||||
let mut stmt = db.prepare(&query)?;
|
let mut stmt = db.prepare(&query)?;
|
||||||
let items_iter = stmt.query_map(¶ms_ref[..], |row| {
|
let key = self.key;
|
||||||
let conditional_encrypted_content: Option<String> = row.get(10)?;
|
let items_iter = stmt.query_map(¶ms_ref[..], |row| row_to_clipboard_item(row, &key))?;
|
||||||
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 mut all_items = items_iter.collect::<Result<Vec<_>, _>>()?;
|
let mut all_items = items_iter.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
@ -183,6 +184,21 @@ impl ClipboardHistoryManager {
|
||||||
Ok(all_items)
|
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> {
|
pub fn get_item_content(&self, id: i64) -> Result<String, AppError> {
|
||||||
let db = self.db.lock().unwrap();
|
let db = self.db.lock().unwrap();
|
||||||
let encrypted_content: String = db.query_row(
|
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),
|
Err(e) => eprintln!("Failed to create ClipboardHistoryManager: {:?}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,13 +6,13 @@ pub mod clipboard_history;
|
||||||
mod desktop;
|
mod desktop;
|
||||||
mod error;
|
mod error;
|
||||||
mod extensions;
|
mod extensions;
|
||||||
|
mod file_search;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod frecency;
|
mod frecency;
|
||||||
mod oauth;
|
mod oauth;
|
||||||
mod quicklinks;
|
mod quicklinks;
|
||||||
mod snippets;
|
mod snippets;
|
||||||
mod system;
|
mod system;
|
||||||
mod file_search;
|
|
||||||
|
|
||||||
use crate::snippets::input_manager::{EvdevInputManager, InputManager};
|
use crate::snippets::input_manager::{EvdevInputManager, InputManager};
|
||||||
use crate::{app::App, cache::AppCache};
|
use crate::{app::App, cache::AppCache};
|
||||||
|
@ -276,26 +276,27 @@ pub fn run() {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.build(tauri::generate_context!()).unwrap();
|
.build(tauri::generate_context!())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
app.run(|app, event| {
|
app.run(|app, event| {
|
||||||
if let tauri::RunEvent::WindowEvent { label, event, .. } = event {
|
if let tauri::RunEvent::WindowEvent { label, event, .. } = event {
|
||||||
if label == "main" {
|
if label == "main" {
|
||||||
match event {
|
match event {
|
||||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.hide();
|
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::input_manager::{InputEvent, InputManager};
|
||||||
use crate::snippets::manager::SnippetManager;
|
use crate::snippets::manager::SnippetManager;
|
||||||
use arboard::Clipboard;
|
use arboard::Clipboard;
|
||||||
use chrono::Local;
|
use chrono::{DateTime, Duration, Local, Months};
|
||||||
use enigo::Key as EnigoKey;
|
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::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
const BUFFER_SIZE: usize = 30;
|
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 struct ResolvedSnippet {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub cursor_pos: Option<usize>,
|
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 {
|
pub struct ExpansionEngine {
|
||||||
buffer: Arc<Mutex<String>>,
|
buffer: Arc<Mutex<String>>,
|
||||||
snippet_manager: Arc<SnippetManager>,
|
snippet_manager: Arc<SnippetManager>,
|
||||||
|
@ -67,9 +93,15 @@ impl ExpansionEngine {
|
||||||
if let Ok(snippets) = self.snippet_manager.list_snippets(None) {
|
if let Ok(snippets) = self.snippet_manager.list_snippets(None) {
|
||||||
for snippet in snippets {
|
for snippet in snippets {
|
||||||
if buffer.ends_with(&snippet.keyword) {
|
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);
|
drop(buffer);
|
||||||
self.expand_snippet(&keyword, &content);
|
self.expand_snippet(&keyword, &content);
|
||||||
|
// run in a separate thread to not block input
|
||||||
|
thread::spawn(move || {
|
||||||
|
let _ = manager.snippet_was_used(id);
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -82,7 +114,24 @@ impl ExpansionEngine {
|
||||||
backspaces.push('\u{8}');
|
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 content_to_paste = resolved.content;
|
||||||
|
|
||||||
let chars_to_move_left = if let Some(pos) = resolved.cursor_pos {
|
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 resolved_content = String::with_capacity(raw_content.len());
|
||||||
let mut cursor_pos: Option<usize> = None;
|
let mut cursor_pos: Option<usize> = None;
|
||||||
let mut last_end = 0;
|
let mut last_end = 0;
|
||||||
|
|
||||||
for (start, _) in raw_content.match_indices('{') {
|
for cap in PLACEHOLDER_REGEX.captures_iter(raw_content) {
|
||||||
if start < last_end {
|
let full_match = cap.get(0).unwrap();
|
||||||
continue;
|
resolved_content.push_str(&raw_content[last_end..full_match.start()]);
|
||||||
}
|
|
||||||
if let Some(end) = raw_content[start..].find('}') {
|
|
||||||
let placeholder = &raw_content[start + 1..start + end];
|
|
||||||
|
|
||||||
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 {
|
let placeholder = ParsedPlaceholder {
|
||||||
"cursor" => {
|
name,
|
||||||
if cursor_pos.is_none() {
|
attributes,
|
||||||
cursor_pos = Some(resolved_content.chars().count());
|
modifiers,
|
||||||
}
|
};
|
||||||
String::new()
|
|
||||||
}
|
if placeholder.name == "cursor" {
|
||||||
"clipboard" => Clipboard::new()
|
if cursor_pos.is_none() {
|
||||||
.ok()
|
cursor_pos = Some(resolved_content.chars().count());
|
||||||
.and_then(|mut c| c.get_text().ok())
|
}
|
||||||
.unwrap_or_default(),
|
} else {
|
||||||
"date" => Local::now().format("%d %b %Y").to_string(),
|
let value = resolve_value(&placeholder, snippet_manager, clipboard_manager)?;
|
||||||
"time" => Local::now().format("%H:%M").to_string(),
|
let modified_value = apply_modifiers(value, &placeholder.modifiers);
|
||||||
"datetime" => Local::now().format("%d %b %Y at %H:%M").to_string(),
|
resolved_content.push_str(&modified_value);
|
||||||
"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;
|
|
||||||
}
|
}
|
||||||
|
last_end = full_match.end();
|
||||||
}
|
}
|
||||||
resolved_content.push_str(&raw_content[last_end..]);
|
resolved_content.push_str(&raw_content[last_end..]);
|
||||||
|
|
||||||
ResolvedSnippet {
|
Ok(ResolvedSnippet {
|
||||||
content: resolved_content,
|
content: resolved_content,
|
||||||
cursor_pos,
|
cursor_pos,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -169,4 +169,30 @@ impl SnippetManager {
|
||||||
Ok(None)
|
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 manager;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
|
use crate::clipboard_history;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
@ -72,12 +73,20 @@ pub fn snippet_was_used(app: AppHandle, id: i64) -> Result<(), String> {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn paste_snippet_content(app: AppHandle, content: String) -> Result<(), String> {
|
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
|
let input_manager = app
|
||||||
.state::<Arc<dyn input_manager::InputManager>>()
|
.state::<Arc<dyn input_manager::InputManager>>()
|
||||||
.inner()
|
.inner()
|
||||||
.clone();
|
.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 content_to_paste = resolved.content;
|
||||||
|
|
||||||
let chars_to_move_left = if let Some(pos) = resolved.cursor_pos {
|
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,
|
snippets_added,
|
||||||
duplicates_skipped,
|
duplicates_skipped,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,49 +19,122 @@
|
||||||
let content = $state('');
|
let content = $state('');
|
||||||
let error = $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(() => {
|
const parsedContent = $derived.by(() => {
|
||||||
if (!content) return [];
|
if (!content) return [];
|
||||||
|
|
||||||
const parts: { text: string; highlightType: 'text' | 'valid-bracket' | 'invalid-bracket' }[] =
|
const parts: ParsedPart[] = [];
|
||||||
[];
|
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
const regex = /({([a-zA-Z_]+?)})/g;
|
let cursorCount = 0;
|
||||||
let match;
|
|
||||||
let cursorFoundAndValid = false;
|
|
||||||
|
|
||||||
while ((match = regex.exec(content)) !== null) {
|
for (const match of content.matchAll(PLACEHOLDER_REGEX)) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index! > lastIndex) {
|
||||||
parts.push({
|
parts.push({
|
||||||
text: content.substring(lastIndex, match.index),
|
text: content.substring(lastIndex, match.index),
|
||||||
highlightType: 'text'
|
type: 'text'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const placeholderName = match[2];
|
const placeholderName = match.groups!.name;
|
||||||
let currentBracketHighlightType: 'valid-bracket' | 'invalid-bracket' = 'invalid-bracket';
|
const attributes = parseAttributes(match.groups!.attributes);
|
||||||
|
const modifiers = parseModifiers(match.groups!.modifiers);
|
||||||
|
|
||||||
|
let isValid = validatePlaceholder(placeholderName, attributes, modifiers);
|
||||||
|
|
||||||
if (placeholderName === 'cursor') {
|
if (placeholderName === 'cursor') {
|
||||||
if (!cursorFoundAndValid) {
|
cursorCount++;
|
||||||
currentBracketHighlightType = 'valid-bracket';
|
if (cursorCount > 1) {
|
||||||
cursorFoundAndValid = true;
|
isValid = false;
|
||||||
} else {
|
|
||||||
currentBracketHighlightType = 'invalid-bracket';
|
|
||||||
}
|
}
|
||||||
} else if (VALID_PLACEHOLDERS_NO_CURSOR.has(placeholderName)) {
|
|
||||||
currentBracketHighlightType = 'valid-bracket';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push({ text: '{', highlightType: currentBracketHighlightType });
|
const bracketType = isValid ? 'valid-bracket' : 'invalid-bracket';
|
||||||
parts.push({ text: placeholderName, highlightType: 'text' });
|
const nameType = isValid ? 'valid-name' : 'invalid-name';
|
||||||
parts.push({ text: '}', highlightType: currentBracketHighlightType });
|
|
||||||
|
|
||||||
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) {
|
if (lastIndex < content.length) {
|
||||||
parts.push({ text: content.substring(lastIndex), highlightType: 'text' });
|
parts.push({ text: content.substring(lastIndex), type: 'text' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
|
@ -129,18 +202,18 @@
|
||||||
>
|
>
|
||||||
{#each parsedContent as part}
|
{#each parsedContent as part}
|
||||||
<span
|
<span
|
||||||
class:text-blue-300={part.highlightType === 'valid-bracket'}
|
class:text-blue-400={part.type === 'valid-bracket'}
|
||||||
class:text-red-400={part.highlightType === 'invalid-bracket'}
|
class:text-red-400={part.type === 'invalid-bracket' || part.type === 'invalid-name'}
|
||||||
class:text-foreground={part.highlightType === 'text'}
|
class:text-foreground={part.type === 'text' || part.type === 'valid-name'}
|
||||||
>
|
>
|
||||||
{part.text}
|
{part.text}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
<span>​</span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="content"
|
id="content"
|
||||||
placeholder="Enter your snippet content... e.g. Hello {'{'}clipboard}!"
|
placeholder="Enter your snippet content... e.g. Hello {'{'}clipboard | uppercase}!"
|
||||||
bind:value={content}
|
bind:value={content}
|
||||||
class="caret-foreground col-start-1 row-start-1 min-h-32 resize-none !bg-transparent font-mono text-transparent"
|
class="caret-foreground col-start-1 row-start-1 min-h-32 resize-none !bg-transparent font-mono text-transparent"
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue