Feature: clipboard history extension (#35)

* feat: implement clipboard history preview

* feat: clipboard history pagination

* refactor: format code

* fix: sql schema

* feat: add json metadata to unit test

* upgrade: js dependencies

* upgrade: desktop rust dependencies

* fix: clipboard history duplicate key bug when searchTerm clears

* upgrade: tauri-plugin-network submodule solve pnpm lock file

* fix: grpc package CI

* chore: update turbo.json outputs to include dist and build directories

* fix: try to fix template-ext-vue tailwind module.export

* ci: prevent error when protoc is not installed in CF pages

* fix: update writeFile function to accept ReadableStream as data type
This commit is contained in:
Huakun Shen 2024-12-19 09:31:56 -05:00 committed by GitHub
parent 99b940b03b
commit 80ad705f7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2423 additions and 1263 deletions

97
Cargo.lock generated
View File

@ -1195,13 +1195,12 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]] [[package]]
name = "colored" name = "colored"
version = "1.9.4" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f741c91823341bebf717d4c71bda820630ce065443b58bd1b7451af008355" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"is-terminal",
"lazy_static", "lazy_static",
"winapi", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@ -1961,9 +1960,9 @@ dependencies = [
[[package]] [[package]]
name = "fern" name = "fern"
version = "0.6.2" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [ dependencies = [
"colored", "colored",
"log", "log",
@ -3134,17 +3133,6 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "is-terminal"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b"
dependencies = [
"hermit-abi 0.4.0",
"libc",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "is-wsl" name = "is-wsl"
version = "0.4.0" version = "0.4.0"
@ -3302,6 +3290,7 @@ name = "kunkun"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1",
"chrono", "chrono",
"cocoa 0.24.1", "cocoa 0.24.1",
"crypto", "crypto",
@ -3337,7 +3326,7 @@ dependencies = [
"tokio", "tokio",
"urlencoding", "urlencoding",
"uuid", "uuid",
"zip 2.2.0", "zip 2.2.2",
] ]
[[package]] [[package]]
@ -6500,19 +6489,19 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-deep-link" name = "tauri-plugin-deep-link"
version = "2.0.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31a9b5725027c6e9e075b06cb2d5c2cd3b5c29daa8012b404e1ff755cc56082f" checksum = "35d51ffd286073414d26353bcfc9e83e3cd63f96fa7f7a912f92f2118e5de5a6"
dependencies = [ dependencies = [
"dunce", "dunce",
"log",
"rust-ini", "rust-ini",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"tauri-utils", "tauri-utils",
"thiserror 1.0.66", "thiserror 2.0.3",
"tracing",
"url", "url",
"windows-registry 0.3.0", "windows-registry 0.3.0",
"windows-result 0.2.0", "windows-result 0.2.0",
@ -6520,9 +6509,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-dialog" name = "tauri-plugin-dialog"
version = "2.0.3" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4307310e1d2c09ab110235834722e7c2b85099b683e1eb7342ab351b0be5ada3" checksum = "8b59fd750551b1066744ab956a1cd6b1ea3e1b3763b0b9153ac27a044d596426"
dependencies = [ dependencies = [
"log", "log",
"raw-window-handle", "raw-window-handle",
@ -6532,15 +6521,15 @@ dependencies = [
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"tauri-plugin-fs", "tauri-plugin-fs",
"thiserror 1.0.66", "thiserror 2.0.3",
"url", "url",
] ]
[[package]] [[package]]
name = "tauri-plugin-fs" name = "tauri-plugin-fs"
version = "2.0.3" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96ba7d46e86db8c830d143ef90ab5a453328365b0cc834c24edea4267b16aba0" checksum = "a1a1edf18000f02903a7c2e5997fb89aca455ecbc0acc15c6535afbb883be223"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dunce", "dunce",
@ -6552,7 +6541,9 @@ dependencies = [
"serde_repr", "serde_repr",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"thiserror 1.0.66", "tauri-utils",
"thiserror 2.0.3",
"toml 0.8.2",
"url", "url",
"uuid", "uuid",
] ]
@ -6574,9 +6565,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-http" name = "tauri-plugin-http"
version = "2.0.3" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c752aee1b00ec3c4d4f440095995d9bd2c640b478f2067d1fba388900b82eb96" checksum = "e62a9bde54d6a0218b63f5a248f02056ad4316ba6ad81dfb9e4f73715df5deb1"
dependencies = [ dependencies = [
"data-url", "data-url",
"http 1.1.0", "http 1.1.0",
@ -6588,7 +6579,7 @@ dependencies = [
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"tauri-plugin-fs", "tauri-plugin-fs",
"thiserror 1.0.66", "thiserror 2.0.3",
"tokio", "tokio",
"url", "url",
"urlpattern", "urlpattern",
@ -6653,9 +6644,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-log" name = "tauri-plugin-log"
version = "2.0.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a49f2c05d15e6375ab7f7e528b3049150ba4dfafdf61f85e5178d0aef18e3f5" checksum = "eddd784c138c08a43954bc3e735402e6b2b2ee8d8c254a7391f4e77c01273dd5"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"byte-unit", "byte-unit",
@ -6669,7 +6660,7 @@ dependencies = [
"swift-rs", "swift-rs",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"thiserror 1.0.66", "thiserror 2.0.3",
"time", "time",
] ]
@ -6693,9 +6684,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.0.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef492a2d19b6376bb4c9e0c4fab3f3bf8a220ea112d24f35027b737ff55de20c" checksum = "46ab803095f14ac6521fdb6477210a49e86fed6623c3c97d8e4b2b35e045e922"
dependencies = [ dependencies = [
"log", "log",
"notify-rust", "notify-rust",
@ -6705,16 +6696,16 @@ dependencies = [
"serde_repr", "serde_repr",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"thiserror 1.0.66", "thiserror 2.0.3",
"time", "time",
"url", "url",
] ]
[[package]] [[package]]
name = "tauri-plugin-os" name = "tauri-plugin-os"
version = "2.0.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc5f23a86f37687c7f4fecfdc706b279087bc44f7a46702f7307ff1551ee03a" checksum = "dda2d571a9baf0664c1f2088db227e3072f9028602fafa885deade7547c3b738"
dependencies = [ dependencies = [
"gethostname 0.5.0", "gethostname 0.5.0",
"log", "log",
@ -6725,14 +6716,14 @@ dependencies = [
"sys-locale", "sys-locale",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"thiserror 1.0.66", "thiserror 2.0.3",
] ]
[[package]] [[package]]
name = "tauri-plugin-process" name = "tauri-plugin-process"
version = "2.0.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae06a00087c148962a52814a2d7265b1a0505bced5ffb74f8c284a5f96a4d03d" checksum = "40cc553ab29581c8c43dfa5fb0c9d5aee8ba962ad3b42908eea26c79610441b7"
dependencies = [ dependencies = [
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
@ -6740,9 +6731,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-shell" name = "tauri-plugin-shell"
version = "2.0.2" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad7880c5586b6b2104be451e3d7fc0f3800c84bda69e9ba81c828f87cb34267" checksum = "bb2c50a63e60fb8925956cc5b7569f4b750ac197a4d39f13b8dd46ea8e2bad79"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"log", "log",
@ -6755,7 +6746,7 @@ dependencies = [
"shared_child", "shared_child",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"thiserror 1.0.66", "thiserror 2.0.3",
"tokio", "tokio",
] ]
@ -6798,18 +6789,18 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-store" name = "tauri-plugin-store"
version = "2.1.0" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9a580be53f04bb62422d239aa798e88522877f58a0d4a0e745f030055a51bb4" checksum = "1c0c08fae6995909f5e9a0da6038273b750221319f2c0f3b526d6de1cde21505"
dependencies = [ dependencies = [
"dunce", "dunce",
"log",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
"thiserror 1.0.66", "thiserror 2.0.3",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@ -6852,7 +6843,7 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"windows-sys 0.59.0", "windows-sys 0.59.0",
"zip 2.2.0", "zip 2.2.2",
] ]
[[package]] [[package]]
@ -8726,9 +8717,9 @@ dependencies = [
[[package]] [[package]]
name = "zip" name = "zip"
version = "2.2.0" version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc5e4288ea4057ae23afc69a4472434a87a2495cafce6632fd1c4ec9f5cf3494" checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45"
dependencies = [ dependencies = [
"aes", "aes",
"arbitrary", "arbitrary",
@ -8746,7 +8737,7 @@ dependencies = [
"pbkdf2", "pbkdf2",
"rand 0.8.5", "rand 0.8.5",
"sha1", "sha1",
"thiserror 1.0.66", "thiserror 2.0.3",
"time", "time",
"zeroize", "zeroize",
"zopfli", "zopfli",

View File

@ -22,22 +22,23 @@
"@std/semver": "npm:@jsr/std__semver@^1.0.3", "@std/semver": "npm:@jsr/std__semver@^1.0.3",
"@tanstack/table-core": "^8.20.5", "@tanstack/table-core": "^8.20.5",
"@tauri-apps/api": "^2.1.1", "@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-shell": "^2.0.1", "@tauri-apps/plugin-shell": "^2.2.0",
"dompurify": "^3.2.3",
"gsap": "^3.12.5", "gsap": "^3.12.5",
"kkrpc": "^0.0.13", "kkrpc": "^0.0.13",
"lz-string": "^1.5.0", "lz-string": "^1.5.0",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"semver": "^7.6.3", "semver": "^7.6.3",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.20.1", "sveltekit-superforms": "^2.22.1",
"tauri-plugin-clipboard-api": "^2.1.11", "tauri-plugin-clipboard-api": "^2.1.11",
"uuid": "^11.0.3" "uuid": "^11.0.3"
}, },
"devDependencies": { "devDependencies": {
"@kksh/types": "workspace:*", "@kksh/types": "workspace:*",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.1", "@sveltejs/kit": "^2.12.1",
"@sveltejs/vite-plugin-svelte": "^4.0.1", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
@ -46,16 +47,16 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bits-ui": "1.0.0-next.60", "bits-ui": "1.0.0-next.72",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-svelte": "^0.460.1", "lucide-svelte": "^0.468.0",
"svelte-radix": "^2.0.1", "svelte-radix": "^2.0.1",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.5",
"tailwind-variants": "^0.3.0", "tailwind-variants": "^0.3.0",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.11" "vite": "^6.0.3"
} }
} }

View File

@ -14,17 +14,17 @@ name = "kunkun_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.0.2", features = [] } tauri-build = { version = "2.0.3", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.0.6", features = [ tauri = { version = "2.1.1", features = [
"macos-private-api", "macos-private-api",
"image-png", "image-png",
"image-ico", "image-ico",
"tray-icon", "tray-icon",
"devtools", "devtools",
] } ] }
tauri-plugin-shell = "2" tauri-plugin-shell = "2.2.0"
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
@ -33,27 +33,28 @@ mdns-sd = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
log = { workspace = true } log = { workspace = true }
urlencoding = "2.1.3" urlencoding = "2.1.3"
tauri-plugin-process = "2.0.1" tauri-plugin-process = "2.2.0"
tauri-plugin-shellx = "2.0.12" tauri-plugin-shellx = "2.0.12"
tauri-plugin-fs = "2.0.1" tauri-plugin-fs = "2.2.0"
tauri-plugin-dialog = "2.0.1" tauri-plugin-dialog = "2.2.0"
tauri-plugin-notification = "2.0.1" tauri-plugin-notification = "2.2.0"
tauri-plugin-os = "2.0.1" tauri-plugin-os = "2.2.0"
tauri-plugin-http = "2.0.1" tauri-plugin-http = "2.2.0"
tauri-plugin-upload = { workspace = true } tauri-plugin-upload = { workspace = true }
# tauri-plugin-upload = "2.2.1" # tauri-plugin-upload = "2.2.1"
tauri-plugin-jarvis = { workspace = true } tauri-plugin-jarvis = { workspace = true }
tauri-plugin-network = { workspace = true } tauri-plugin-network = { workspace = true }
tauri-plugin-system-info = { workspace = true } tauri-plugin-system-info = { workspace = true }
tauri-plugin-clipboard = { workspace = true } tauri-plugin-clipboard = { workspace = true }
tauri-plugin-store = "2.1.0" tauri-plugin-store = "2.2.0"
tauri-plugin-deep-link = "2" tauri-plugin-deep-link = "2.2.0"
tauri-plugin-log = { version = "2.0.1", features = ["colored"] } tauri-plugin-log = { version = "2.2.0", features = ["colored"] }
crypto = { workspace = true } crypto = { workspace = true }
zip = "2.1.3" zip = "2.2.2"
uuid = "1.11.0" uuid = "1.11.0"
# tauri-plugin-devtools = "2.0.0" # tauri-plugin-devtools = "2.0.0"
obfstr = { workspace = true } obfstr = { workspace = true }
base64 = { workspace = true }
[target."cfg(target_os = \"macos\")".dependencies] [target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.24.1" cocoa = "0.24.1"

View File

@ -1,6 +1,7 @@
use std::{path::PathBuf, sync::Mutex}; use std::{path::PathBuf, sync::Mutex};
mod setup; mod setup;
pub mod utils; pub mod utils;
use base64::prelude::*;
use log; use log;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri::ActivationPolicy; use tauri::ActivationPolicy;
@ -137,6 +138,61 @@ pub fn run() {
.unwrap(), .unwrap(),
} }
}) })
.register_uri_scheme_protocol("cbimg", |app, request| {
// sample url: cb_img?id=123
// parse id from url
let path = &request.uri().path()[1..]; // skip the first /
let path = urlencoding::decode(path).unwrap().to_string();
let query_params: Vec<&str> = path.split('?').collect();
let id = if query_params.len() > 1 {
query_params[1].split('=').nth(1).unwrap_or("")
} else {
return tauri::http::Response::builder()
.status(tauri::http::StatusCode::BAD_REQUEST)
.header("Access-Control-Allow-Origin", "*")
.body("Invalid Request".as_bytes().to_vec())
.unwrap();
};
let app_handle = app.app_handle();
let clipboard_history = app_handle
.state::<tauri_plugin_jarvis::model::clipboard_history::ClipboardHistory>(
);
let jarvis_db = clipboard_history.jarvis_db.lock().unwrap();
let img_data = jarvis_db.get_extension_data_by_id(id.parse::<i32>().unwrap(), None);
let image_data = if let Ok(img_data) = img_data {
let img_data = img_data.unwrap().data;
match img_data {
Some(data) => match BASE64_STANDARD.decode(data) {
Ok(img_data) => img_data,
Err(e) => {
return tauri::http::Response::builder()
.status(tauri::http::StatusCode::NOT_FOUND)
.header("Access-Control-Allow-Origin", "*")
.body("Image Not Found".as_bytes().to_vec())
.unwrap();
}
},
None => {
return tauri::http::Response::builder()
.status(tauri::http::StatusCode::NOT_FOUND)
.header("Access-Control-Allow-Origin", "*")
.body("Image Not Found".as_bytes().to_vec())
.unwrap();
}
}
} else {
return tauri::http::Response::builder()
.status(tauri::http::StatusCode::NOT_FOUND)
.header("Access-Control-Allow-Origin", "*")
.body("Image Not Found".as_bytes().to_vec())
.unwrap();
};
return tauri::http::Response::builder()
.status(tauri::http::StatusCode::OK)
.header("Access-Control-Allow-Origin", "*")
.body(image_data)
.unwrap();
})
.setup(move |app| { .setup(move |app| {
setup::window::setup_window(app.handle()); setup::window::setup_window(app.handle());
setup::tray::create_tray(app.handle())?; setup::tray::create_tray(app.handle())?;

View File

@ -280,6 +280,18 @@ export const rawBuiltinCmds: BuiltinCmd[] = [
appState.clearSearchTerm() appState.clearSearchTerm()
} }
}, },
{
name: "Clipboard History",
icon: {
type: IconEnum.Iconify,
value: "mdi:clipboard-outline"
},
description: "Clipboard History",
function: async () => {
appState.clearSearchTerm()
goto("/extension/clipboard")
}
},
{ {
name: "Pin Current Screenshot", name: "Pin Current Screenshot",
icon: { icon: {

View File

@ -19,6 +19,7 @@
} from "@kksh/ui/main" } from "@kksh/ui/main"
import type { CmdValue } from "@kksh/ui/types" import type { CmdValue } from "@kksh/ui/types"
import { cn, commandScore } from "@kksh/ui/utils" import { cn, commandScore } from "@kksh/ui/utils"
import { convertFileSrc } from "@tauri-apps/api/core"
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow" import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
import { exit } from "@tauri-apps/plugin-process" import { exit } from "@tauri-apps/plugin-process"
import { ArrowBigUpIcon, CircleXIcon, EllipsisVerticalIcon, RefreshCcwIcon } from "lucide-svelte" import { ArrowBigUpIcon, CircleXIcon, EllipsisVerticalIcon, RefreshCcwIcon } from "lucide-svelte"
@ -32,6 +33,8 @@
$appState.searchTerm = "" $appState.searchTerm = ""
} }
} }
// let imgSrc = convertFileSrc("/?id=15", "cbimg")
</script> </script>
<svelte:window <svelte:window
@ -46,6 +49,8 @@
} }
}} }}
/> />
<!-- <pre>{imgSrc}</pre>
<img class="border-2 border-red-500 w-64" src={imgSrc} alt="test" /> -->
<Command.Root <Command.Root
class={cn("h-screen rounded-lg border shadow-md")} class={cn("h-screen rounded-lg border shadow-md")}
bind:value={$appState.highlightedCmd} bind:value={$appState.highlightedCmd}

View File

@ -7,7 +7,6 @@
import { DEEP_LINK_PATH_AUTH_CONFIRM } from "@kksh/api" import { DEEP_LINK_PATH_AUTH_CONFIRM } from "@kksh/api"
import { Button, Card } from "@kksh/svelte5" import { Button, Card } from "@kksh/svelte5"
import { Layouts } from "@kksh/ui" import { Layouts } from "@kksh/ui"
import { goto } from "$app/navigation"
import { ArrowLeft } from "lucide-svelte" import { ArrowLeft } from "lucide-svelte"
import { onMount } from "svelte" import { onMount } from "svelte"
import { toast } from "svelte-sonner" import { toast } from "svelte-sonner"
@ -27,8 +26,6 @@
if (error) { if (error) {
toast.error("Failed to sign in with OAuth", { description: error.message }) toast.error("Failed to sign in with OAuth", { description: error.message })
} else { } else {
console.log(data.url);
data.url && open(data.url) data.url && open(data.url)
} }
} }

View File

@ -0,0 +1,215 @@
<script lang="ts">
import { goBack, goHome } from "@/utils/route"
import { listenToNewClipboardItem } from "@/utils/tauri-events"
import Icon from "@iconify/svelte"
import { db } from "@kksh/api/commands"
import { SearchModeEnum, SQLSortOrderEnum, type ExtData } from "@kksh/api/models"
import { Button, Command, Resizable } from "@kksh/svelte5"
import { Constants } from "@kksh/ui"
import { CustomCommandInput, GlobalCommandPaletteFooter } from "@kksh/ui/main"
import type { UnlistenFn } from "@tauri-apps/api/event"
import { ArrowLeft, FileQuestionIcon, ImageIcon, LetterTextIcon } from "lucide-svelte"
import { onDestroy, onMount, type Snippet } from "svelte"
import ContentPreview from "./content-preview.svelte"
let searchTerm = $state("")
let clipboardHistoryList = $state<ExtData[]>([])
let highlightedItemValue = $state<string>("")
let highlighted = $state<ExtData | null>(null)
let unlistenClipboard = $state<UnlistenFn | null>(null)
let isScrolling = $state(false)
let page = $state(1)
let clipboardHistoryMap = $derived(
clipboardHistoryList.reduce(
(acc, item) => {
acc[item.dataId] = item
return acc
},
{} as Record<string, ExtData>
)
)
let clipboardHistoryIds = $derived(clipboardHistoryList.map((item) => item.dataId))
let clipboardHistoryIdsSet = $derived(new Set(clipboardHistoryIds))
async function initClipboardHistory() {
const result = await db.searchExtensionData({
extId: 1,
searchMode: SearchModeEnum.FTS,
limit: 50,
offset: (page - 1) * 50,
fields: ["search_text"],
orderByCreatedAt: SQLSortOrderEnum.Desc
})
if (page === 1) {
// clear clipboardHistoryList when page is 1, because it's simply loading the first page, using previous search result will result in duplicate key error
clipboardHistoryList = result
} else {
clipboardHistoryList = [...result, ...clipboardHistoryList]
}
}
onMount(async () => {
listenToNewClipboardItem(async (evt) => {
const result = await db.searchExtensionData({
extId: 1,
searchMode: SearchModeEnum.FTS,
limit: 1,
fields: ["search_text"],
orderByCreatedAt: SQLSortOrderEnum.Desc
})
if (result.length > 0) {
clipboardHistoryList = [result[0], ...clipboardHistoryList]
}
}).then((unlisten) => {
unlistenClipboard = unlisten
})
})
onDestroy(() => {
unlistenClipboard?.()
})
$effect(() => {
// search sqlite when searchTerm changes
searchTerm
;(async () => {
// console.log("searchTerm", searchTerm)
if (searchTerm === "") {
page = 1
initClipboardHistory()
return
}
const ftsResult = await db.searchExtensionData({
extId: 1,
searchMode: SearchModeEnum.FTS,
searchText: `${searchTerm}*`,
fields: ["search_text"],
orderByCreatedAt: SQLSortOrderEnum.Desc
})
const likeResult = await db.searchExtensionData({
extId: 1,
searchMode: SearchModeEnum.Like,
searchText: `%${searchTerm}%`,
fields: ["search_text"],
orderByCreatedAt: SQLSortOrderEnum.Desc
})
// merge ftsResult and likeResult, remove duplicate items
const result = [...ftsResult, ...likeResult]
// sort result by createdAt
result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
// remove duplicate items from result list by dataId
const uniqueResult = result.filter(
(item, index, self) => index === self.findIndex((t) => t.dataId === item.dataId)
)
clipboardHistoryList = uniqueResult
if (uniqueResult.length > 0) {
highlightedItemValue = uniqueResult[0].dataId.toString()
}
})()
})
$effect(() => {
if (!highlightedItemValue) {
return
}
try {
const dataId = parseInt(highlightedItemValue)
highlighted = clipboardHistoryMap[dataId]
} catch (error) {
console.error(error)
}
})
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
const inputEle = event.target as HTMLInputElement
if (inputEle.value === "") {
goHome()
}
inputEle.value = ""
searchTerm = ""
}
}
async function onListScrolledToBottom() {
page++
await initClipboardHistory()
}
/**
* Handle scroll-to-bottom event
* @param e
*/
function onScroll(e: Event) {
const element = e.target as HTMLElement
if (!isScrolling && element?.scrollHeight - element?.scrollTop === element?.clientHeight) {
isScrolling = true
onListScrolledToBottom?.()
setTimeout(() => {
isScrolling = false
}, 500)
}
}
</script>
{#snippet leftSlot()}
<Button
variant="outline"
size="icon"
onclick={goHome}
class={Constants.CLASSNAMES.BACK_BUTTON}
data-flip-id={Constants.CLASSNAMES.BACK_BUTTON}
>
<ArrowLeft class="size-4" />
</Button>
{/snippet}
{#snippet typeIcon(type: string)}
{#if type === "Text"}
<LetterTextIcon />
{:else if type === "Html"}
<Icon icon="skill-icons:html" />
{:else if type === "Image"}
<ImageIcon />
{:else}
<FileQuestionIcon />
{/if}
{/snippet}
<Command.Root
class="h-screen rounded-lg border shadow-md"
loop
bind:value={highlightedItemValue}
shouldFilter={false}
>
<CustomCommandInput
onkeydown={onKeyDown}
autofocus
placeholder="Type a command or search..."
leftSlot={leftSlot as Snippet}
bind:value={searchTerm}
/>
<Resizable.PaneGroup direction="horizontal" class="w-full rounded-lg">
<Resizable.Pane defaultSize={30} class="">
<Command.List class="h-full max-h-full grow" onscroll={onScroll}>
<Command.Empty>No results found.</Command.Empty>
{#each clipboardHistoryIds as dataId (dataId)}
<Command.Item value={dataId.toString()}>
{@render typeIcon(clipboardHistoryMap[dataId].dataType)}
<span class="truncate">{clipboardHistoryMap[dataId].searchText}</span>
</Command.Item>
{/each}
</Command.List>
</Resizable.Pane>
<Resizable.Handle />
<Resizable.Pane defaultSize={50}>
{#if highlighted}
<ContentPreview {highlighted} />
{:else}
<div class="text-center">No content preview available</div>
{/if}
</Resizable.Pane>
</Resizable.PaneGroup>
<GlobalCommandPaletteFooter />
</Command.Root>

View File

@ -0,0 +1,103 @@
<script lang="ts">
import { cn } from "@/utils"
import { db } from "@kksh/api/commands"
import type { ExtData } from "@kksh/api/models"
import { Resizable, Separator } from "@kksh/svelte5"
import { convertFileSrc } from "@tauri-apps/api/core"
import DOMPurify from "dompurify"
function formatDate(date: Date) {
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
const options: Intl.DateTimeFormatOptions = {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true
} as const
const timeString = date.toLocaleTimeString("en-US", options)
if (isToday) {
return `Today at ${timeString}`
} else {
const dateOptions: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
year: "numeric"
} as const
const dateString = date.toLocaleDateString("en-US", dateOptions)
return `${dateString} at ${timeString}`
}
}
let { highlighted }: { highlighted: ExtData } = $props()
let imgSrc = $state<string>("")
let txtData = $state<string>("")
let createTime = $state<Date>()
let imgRef = $state<HTMLImageElement>()
$effect(() => {
;(async () => {
if (highlighted.dataType === "Image") {
const dbRecord = await db.getExtensionDataById(highlighted.dataId, []) // do not load "data" field
imgSrc = await convertFileSrc(`/?id=${highlighted.dataId}`, "cbimg")
createTime = dbRecord?.createdAt
} else {
const dbRecord = await db.getExtensionDataById(highlighted.dataId) // do not load "data" field
txtData = dbRecord?.data || ""
createTime = dbRecord?.createdAt
}
})()
})
</script>
<Resizable.PaneGroup direction="vertical">
<Resizable.Pane defaultSize={50} class="px-2 py-1">
<div
class={cn({
hidden: highlighted.dataType !== "Image",
"h-full": highlighted.dataType === "Image",
"flex justify-center": highlighted.dataType === "Image"
})}
>
<img src={imgSrc} alt="" class="h-full w-auto object-contain" bind:this={imgRef} />
</div>
{#if highlighted.dataType === "Image"}{:else if highlighted.dataType === "Text"}
<div class="text-sm">{txtData}</div>
{:else if highlighted.dataType === "Html"}
<div class="">
{@html DOMPurify.sanitize(txtData)}
</div>
{:else}
<div class="text-sm">No preview available</div>
{/if}
<!-- </div> -->
</Resizable.Pane>
<Resizable.Handle withHandle />
<Resizable.Pane defaultSize={50} class="space-y-1 px-4 pt-2">
<h2 class="font-mono font-bold">Information</h2>
{#if createTime}
{@render row("Copied At", formatDate(createTime))}
{/if}
<Separator />
{@render row("Content Type", highlighted.dataType || "")}
{#if highlighted.dataType === "Image"}
{#if imgRef}
<Separator />
{@render row("Dimension", `${imgRef.naturalWidth}x${imgRef.naturalHeight}`)}
{/if}
{:else}
<Separator />
{@render row("Character Count", txtData.length.toString())}
<Separator />
{@render row("Word Count", txtData.split(/\s+/).length.toString())}
{/if}
</Resizable.Pane>
</Resizable.PaneGroup>
{#snippet row(label: string, value: string)}
<div class="flex justify-between">
<span class="text-sm font-semibold">{label}</span>
<span class="text-sm">{value}</span>
</div>
{/snippet}

View File

@ -12,6 +12,7 @@
import { CustomCommandInput, GlobalCommandPaletteFooter } from "@kksh/ui/main" import { CustomCommandInput, GlobalCommandPaletteFooter } from "@kksh/ui/main"
import { goto } from "$app/navigation" import { goto } from "$app/navigation"
import { ArrowLeft } from "lucide-svelte" import { ArrowLeft } from "lucide-svelte"
import type { Snippet } from "svelte"
import { toast } from "svelte-sonner" import { toast } from "svelte-sonner"
let { data } = $props() let { data } = $props()
@ -80,7 +81,7 @@
<CustomCommandInput <CustomCommandInput
autofocus autofocus
placeholder="Type a command or search..." placeholder="Type a command or search..."
{leftSlot} leftSlot={leftSlot as Snippet}
bind:value={$appState.searchTerm} bind:value={$appState.searchTerm}
/> />
<Command.List class="max-h-screen grow"> <Command.List class="max-h-screen grow">

View File

@ -11,7 +11,7 @@
<SideBar.Provider style="--sidebar-width: 13rem;"> <SideBar.Provider style="--sidebar-width: 13rem;">
<SettingsSidebar /> <SettingsSidebar />
<main class="grow overflow-x-clip flex flex-col"> <main class="flex grow flex-col overflow-x-clip">
<SidebarTrigger /> <SidebarTrigger />
{@render children?.()} {@render children?.()}
</main> </main>

View File

@ -1,22 +1,5 @@
<script lang="ts"> <script lang="ts">
import AddDevExtForm from "@/components/standalone/settings/AddDevExtForm.svelte" import AddDevExtForm from "@/components/standalone/settings/AddDevExtForm.svelte"
import DevExtPathForm from "@/components/standalone/settings/DevExtPathForm.svelte"
import { appConfig, extensions } from "@/stores"
import { goBackOnEscape } from "@/utils/key"
import { goBack, goHome } from "@/utils/route"
import * as extAPI from "@kksh/extension"
import { installFromNpmPackageName } from "@kksh/extension"
import { Button, Separator, SideBar } from "@kksh/svelte5"
import { StrikeSeparator } from "@kksh/ui"
import { open as openFileSelector } from "@tauri-apps/plugin-dialog"
import * as fs from "@tauri-apps/plugin-fs"
import { goto } from "$app/navigation"
import { ArrowLeftIcon } from "lucide-svelte"
import { toast } from "svelte-sonner"
import * as v from "valibot"
const { useSidebar } = SideBar
const sidebar = useSidebar()
</script> </script>
<main class="container"> <main class="container">

View File

@ -13,43 +13,43 @@
"@ianvs/prettier-plugin-sort-imports": "^4.4.0", "@ianvs/prettier-plugin-sort-imports": "^4.4.0",
"@kksh/api": "workspace:*", "@kksh/api": "workspace:*",
"@kksh/svelte5": "0.1.10", "@kksh/svelte5": "0.1.10",
"prettier": "^3.3.3", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.2.8", "prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.8", "prettier-plugin-tailwindcss": "^0.6.9",
"svelte": "^5.2.3", "svelte": "^5.14.4",
"svelte-check": "^4.0.9", "svelte-check": "^4.1.1",
"turbo": "^2.3.0", "turbo": "^2.3.3",
"typescript": "5.6.3" "typescript": "5.7.2"
}, },
"packageManager": "pnpm@9.14.2", "packageManager": "pnpm@9.15.0",
"engines": { "engines": {
"node": ">=22" "node": ">=22"
}, },
"dependencies": { "dependencies": {
"@changesets/cli": "^2.27.9", "@changesets/cli": "^2.27.11",
"@iconify/svelte": "^4.0.2", "@iconify/svelte": "^4.1.0",
"@supabase/supabase-js": "^2.46.1", "@supabase/supabase-js": "^2.47.9",
"@tauri-apps/api": "^2.1.1", "@tauri-apps/api": "^2.1.1",
"@tauri-apps/cli": "^2.1.0", "@tauri-apps/cli": "^2.1.0",
"@tauri-apps/plugin-deep-link": "^2.0.0", "@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.0.1", "@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.0.2", "@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-global-shortcut": "^2.0.0", "@tauri-apps/plugin-global-shortcut": "^2.2.0",
"@tauri-apps/plugin-http": "^2.0.1", "@tauri-apps/plugin-http": "^2.2.0",
"@tauri-apps/plugin-log": "^2.0.0", "@tauri-apps/plugin-log": "^2.2.0",
"@tauri-apps/plugin-notification": "^2.0.0", "@tauri-apps/plugin-notification": "^2.2.0",
"@tauri-apps/plugin-os": "^2.0.0", "@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-process": "2.0.0", "@tauri-apps/plugin-process": "2.2.0",
"@tauri-apps/plugin-shell": "^2.0.1", "@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-store": "^2.1.0", "@tauri-apps/plugin-store": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.0.0", "@tauri-apps/plugin-updater": "^2.3.0",
"@tauri-apps/plugin-upload": "https://gitpkg.vercel.app/HuakunShen/tauri-plugins-workspace/plugins/upload?69b198b0ccba269fe7622a95ec6a33ae392bff03", "@tauri-apps/plugin-upload": "https://gitpkg.vercel.app/HuakunShen/tauri-plugins-workspace/plugins/upload?69b198b0ccba269fe7622a95ec6a33ae392bff03",
"supabase": "^1.219.2", "supabase": "^2.1.1",
"tauri-plugin-network-api": "workspace:*", "tauri-plugin-network-api": "workspace:*",
"tauri-plugin-shellx-api": "^2.0.14", "tauri-plugin-shellx-api": "^2.0.14",
"tauri-plugin-system-info-api": "workspace:*", "tauri-plugin-system-info-api": "workspace:*",
"valibot": "^1.0.0-beta.9", "valibot": "^1.0.0-beta.9",
"zod": "^3.23.8" "zod": "^3.24.1"
}, },
"workspaces": [ "workspaces": [
"apps/*", "apps/*",

View File

@ -31,28 +31,28 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/lodash": "^4.17.13", "@types/lodash": "^4.17.13",
"@types/madge": "^5.0.3", "@types/madge": "^5.0.3",
"@types/node": "^22.8.7", "@types/node": "^22.10.2",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"madge": "^8.0.0", "madge": "^8.0.0",
"typedoc": "^0.26.11", "typedoc": "^0.27.5",
"typescript": "^5.6.3" "typescript": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.1.1", "@tauri-apps/api": "^2.1.1",
"@tauri-apps/cli": "^2.1.0", "@tauri-apps/cli": "^2.1.0",
"@tauri-apps/plugin-deep-link": "^2.0.0", "@tauri-apps/plugin-deep-link": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.0.1", "@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.0.2", "@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-global-shortcut": "^2.0.0", "@tauri-apps/plugin-global-shortcut": "^2.2.0",
"@tauri-apps/plugin-http": "^2.0.1", "@tauri-apps/plugin-http": "^2.2.0",
"@tauri-apps/plugin-log": "^2.0.0", "@tauri-apps/plugin-log": "^2.2.0",
"@tauri-apps/plugin-notification": "^2.0.0", "@tauri-apps/plugin-notification": "^2.2.0",
"@tauri-apps/plugin-os": "^2.0.0", "@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-process": "2.0.0", "@tauri-apps/plugin-process": "2.2.0",
"@tauri-apps/plugin-shell": "^2.0.1", "@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-store": "^2.1.0", "@tauri-apps/plugin-store": "^2.2.0",
"@tauri-apps/plugin-updater": "^2.0.0", "@tauri-apps/plugin-updater": "^2.3.0",
"@tauri-apps/plugin-upload": "https://gitpkg.vercel.app/HuakunShen/tauri-plugins-workspace/plugins/upload?69b198b0ccba269fe7622a95ec6a33ae392bff03", "@tauri-apps/plugin-upload": "https://gitpkg.vercel.app/HuakunShen/tauri-plugins-workspace/plugins/upload?69b198b0ccba269fe7622a95ec6a33ae392bff03",
"kkrpc": "^0.0.13", "kkrpc": "^0.0.13",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"
import { array, literal, optional, parse, safeParse, union, type InferOutput } from "valibot" import { array, literal, optional, parse, safeParse, union, type InferOutput } from "valibot"
import { KUNKUN_EXT_IDENTIFIER } from "../constants" import { KUNKUN_EXT_IDENTIFIER } from "../constants"
import { CmdType, Ext, ExtCmd, ExtData } from "../models/extension" import { CmdType, Ext, ExtCmd, ExtData } from "../models/extension"
import { convertDateToSqliteString, SQLSortOrder } from "../models/sql" import { convertDateToSqliteString, SearchMode, SearchModeEnum, SQLSortOrder } from "../models/sql"
import { generateJarvisPluginCommand } from "./common" import { generateJarvisPluginCommand } from "./common"
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@ -155,7 +155,7 @@ export function createExtensionData(data: {
return invoke<void>(generateJarvisPluginCommand("create_extension_data"), data) return invoke<void>(generateJarvisPluginCommand("create_extension_data"), data)
} }
export function getExtensionDataById(dataId: number) { export function getExtensionDataById(dataId: number, fields?: ExtDataField[]) {
return invoke< return invoke<
| (ExtData & { | (ExtData & {
createdAt: string createdAt: string
@ -165,7 +165,8 @@ export function getExtensionDataById(dataId: number) {
}) })
| undefined | undefined
>(generateJarvisPluginCommand("get_extension_data_by_id"), { >(generateJarvisPluginCommand("get_extension_data_by_id"), {
dataId dataId,
fields
}).then(convertRawExtDataToExtData) }).then(convertRawExtDataToExtData)
} }
@ -177,13 +178,14 @@ export function getExtensionDataById(dataId: number) {
*/ */
export async function searchExtensionData(searchParams: { export async function searchExtensionData(searchParams: {
extId: number extId: number
searchExactMatch: boolean searchMode: SearchMode
dataId?: number dataId?: number
dataType?: string dataType?: string
searchText?: string searchText?: string
afterCreatedAt?: string afterCreatedAt?: string
beforeCreatedAt?: string beforeCreatedAt?: string
limit?: number limit?: number
offset?: number
orderByCreatedAt?: SQLSortOrder orderByCreatedAt?: SQLSortOrder
orderByUpdatedAt?: SQLSortOrder orderByUpdatedAt?: SQLSortOrder
fields?: ExtDataField[] fields?: ExtDataField[]
@ -197,8 +199,10 @@ export async function searchExtensionData(searchParams: {
searchText: null | string searchText: null | string
})[] })[]
>(generateJarvisPluginCommand("search_extension_data"), { >(generateJarvisPluginCommand("search_extension_data"), {
...searchParams, searchQuery: {
fields ...searchParams,
fields
}
}) })
return items.map(convertRawExtDataToExtData).filter((item) => item) as ExtData[] return items.map(convertRawExtDataToExtData).filter((item) => item) as ExtData[]
@ -272,7 +276,7 @@ export class JarvisExtDB {
async search(searchParams: { async search(searchParams: {
dataId?: number dataId?: number
fullTextSearch?: boolean searchMode?: SearchMode
dataType?: string dataType?: string
searchText?: string searchText?: string
afterCreatedAt?: Date afterCreatedAt?: Date
@ -290,7 +294,7 @@ export class JarvisExtDB {
: undefined : undefined
return searchExtensionData({ return searchExtensionData({
...searchParams, ...searchParams,
searchExactMatch: searchParams.fullTextSearch ?? true, searchMode: searchParams.searchMode ?? SearchModeEnum.FTS,
extId: this.extId, extId: this.extId,
beforeCreatedAt, beforeCreatedAt,
afterCreatedAt afterCreatedAt

View File

@ -8,6 +8,15 @@ export enum SQLSortOrderEnum {
export const SQLSortOrder = enum_(SQLSortOrderEnum) export const SQLSortOrder = enum_(SQLSortOrderEnum)
export type SQLSortOrder = InferOutput<typeof SQLSortOrder> export type SQLSortOrder = InferOutput<typeof SQLSortOrder>
export enum SearchModeEnum {
ExactMatch = "exact_match",
Like = "like",
FTS = "fts"
}
export const SearchMode = enum_(SearchModeEnum)
export type SearchMode = InferOutput<typeof SearchMode>
export function convertDateToSqliteString(date: Date) { export function convertDateToSqliteString(date: Date) {
const pad = (num: number) => num.toString().padStart(2, "0") const pad = (num: number) => num.toString().padStart(2, "0")

View File

@ -133,7 +133,11 @@ export function constructFsApi(permissions: FsPermissionScoped[], extensionDir:
extensionDir, extensionDir,
options options
).then(() => fsTruncate(path, len, options)), ).then(() => fsTruncate(path, len, options)),
writeFile: (path: string | URL, data: Uint8Array, options?: WriteFileOptions) => writeFile: (
path: string | URL,
data: Uint8Array | ReadableStream<Uint8Array>,
options?: WriteFileOptions
) =>
verifyGeneralPathScopedPermission( verifyGeneralPathScopedPermission(
FsPermissionMap.truncate, FsPermissionMap.truncate,
permissions, permissions,

View File

@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS extension_data (
ext_id INTEGER NOT NULL, ext_id INTEGER NOT NULL,
data_type TEXT NOT NULL, data_type TEXT NOT NULL,
data JSON NOT NULL, data JSON NOT NULL,
metadata JSON,
search_text TEXT, search_text TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

View File

@ -1,10 +1,9 @@
pub mod models; pub mod models;
pub mod schema; pub mod schema;
use models::CmdType; use models::{CmdType, ExtDataField, ExtDataSearchQuery, SearchMode};
use rusqlite::{params, params_from_iter, Connection, Result, ToSql}; use rusqlite::{params, params_from_iter, Connection, Result, ToSql};
use serde::{Deserialize, Serialize}; use serde_json::{json, Value};
use std::path::{Path}; use std::path::Path;
use strum_macros::Display;
pub const DB_VERSION: u32 = 1; pub const DB_VERSION: u32 = 1;
@ -24,20 +23,6 @@ pub struct JarvisDB {
pub conn: Connection, pub conn: Connection,
} }
#[derive(Debug, Serialize, Deserialize, Display)]
#[serde(rename_all = "UPPERCASE")]
pub enum SQLSortOrder {
Asc,
Desc,
}
#[derive(Debug, Serialize, Deserialize, Display, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ExtDataField {
Data,
SearchText,
}
impl JarvisDB { impl JarvisDB {
pub fn new<P: AsRef<Path>>(file_path: P, encryption_key: Option<String>) -> Result<Self> { pub fn new<P: AsRef<Path>>(file_path: P, encryption_key: Option<String>) -> Result<Self> {
let conn = get_connection(file_path, encryption_key)?; let conn = get_connection(file_path, encryption_key)?;
@ -338,51 +323,20 @@ impl JarvisDB {
data_type: &str, data_type: &str,
data: &str, data: &str,
search_text: Option<&str>, search_text: Option<&str>,
metadata: Option<&str>,
) -> Result<()> { ) -> Result<()> {
self.conn.execute( self.conn.execute(
"INSERT INTO extension_data (ext_id, data_type, data, search_text) VALUES (?1, ?2, ?3, ?4)", "INSERT INTO extension_data (ext_id, data_type, data, search_text, metadata) VALUES (?1, ?2, ?3, ?4, ?5)",
params![ext_id, data_type, data, search_text], params![ext_id, data_type, data, search_text, metadata],
)?; )?;
Ok(()) Ok(())
} }
pub fn get_extension_data_by_id(&self, data_id: i32) -> Result<Option<models::ExtData>> { pub fn get_extension_data_by_id(
let mut stmt = self.conn.prepare(
"SELECT data_id, ext_id, data_type, data, search_text, created_at, updated_at FROM extension_data WHERE data_id = ?1",
)?;
let ext_data_iter = stmt.query_map(params![data_id], |row| {
Ok(models::ExtData {
data_id: row.get(0)?,
ext_id: row.get(1)?,
data_type: row.get(2)?,
data: row.get(3)?,
search_text: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})?;
let mut ext_data = Vec::new();
for data in ext_data_iter {
ext_data.push(data?);
}
Ok(ext_data.first().cloned())
}
pub fn search_extension_data(
&self, &self,
ext_id: i32, data_id: i32,
search_exact_match: bool, mut fields: Option<Vec<ExtDataField>>,
data_id: Option<i32>, ) -> Result<Option<models::ExtData>> {
data_type: Option<&str>,
search_text: Option<&str>,
after_created_at: Option<&str>,
before_created_at: Option<&str>,
limit: Option<i32>,
order_by_created_at: Option<SQLSortOrder>,
order_by_updated_at: Option<SQLSortOrder>,
fields: Option<Vec<ExtDataField>>,
) -> Result<Vec<models::ExtData>> {
let mut fields = fields;
if fields.is_none() { if fields.is_none() {
fields = Some(vec![ExtDataField::Data, ExtDataField::SearchText]); fields = Some(vec![ExtDataField::Data, ExtDataField::SearchText]);
} }
@ -401,69 +355,151 @@ impl JarvisDB {
} }
query.push_str( query.push_str(
" FROM extension_data " FROM extension_data
WHERE ext_id = ?1", WHERE data_id = ?1",
); );
let mut params: Vec<Box<dyn ToSql>> = vec![Box::new(ext_id)]; let mut stmt = self.conn.prepare(&query)?;
let ext_data_iter = stmt.query_map(params![data_id], |row| {
Ok(models::ExtData {
data_id: row.get(0)?,
ext_id: row.get(1)?,
data_type: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
data: match contains_data_field {
true => Some(row.get(5)?),
false => None,
},
search_text: match contains_search_text_field {
true => row.get(5 + contains_data_field as usize)?, // if contains_data_field is true, search_text is at index 6, otherwise 5
false => None,
},
})
})?;
let mut ext_data = Vec::new();
for data in ext_data_iter {
ext_data.push(data?);
}
Ok(ext_data.first().cloned())
}
/// Sample Queries With Different Search Modes
/// ## Full Text Search (FTS) Mode:
/// ```sql
/// SELECT extension_data.*
/// FROM extension_data
/// JOIN extension_data_fts ON extension_data.data_id = extension_data_fts.data_id
/// WHERE extension_data_fts.search_text MATCH 'extension_data';
/// ```
/// ## Exact Match Mode:
/// ```sql
/// SELECT extension_data.*
/// FROM extension_data
/// WHERE extension_data.search_text = 'extension_data';
/// ```
/// ## Like Mode:
/// ```sql
/// SELECT extension_data.*
/// FROM extension_data
/// WHERE extension_data.search_text LIKE '%extension_data%';
/// ```
pub fn search_extension_data(
&self,
search_query: ExtDataSearchQuery,
) -> Result<Vec<models::ExtData>> {
let mut fields = search_query.fields;
if fields.is_none() {
fields = Some(vec![ExtDataField::Data, ExtDataField::SearchText]);
}
let contains_data_field = fields.as_ref().map_or(false, |fields| {
fields.iter().any(|f| f == &ExtDataField::Data)
});
let contains_search_text_field = fields.as_ref().map_or(false, |fields| {
fields.iter().any(|f| f == &ExtDataField::SearchText)
});
let mut query = String::from("SELECT ed.data_id as data_id, ed.ext_id as ext_id, ed.data_type as data_type, ed.created_at as created_at, ed.updated_at as updated_at");
if contains_data_field {
query.push_str(", ed.data as data");
}
if contains_search_text_field {
query.push_str(", ed.search_text as search_text");
}
query.push_str(" FROM extension_data ed");
if search_query.search_mode == SearchMode::FTS {
query.push_str(" JOIN extension_data_fts edf ON ed.data_id = edf.data_id");
}
query.push_str(" WHERE ed.ext_id = ?1");
let mut params: Vec<Box<dyn ToSql>> = vec![Box::new(search_query.ext_id)];
let mut param_index = 2; let mut param_index = 2;
if let Some(di) = data_id { if let Some(di) = search_query.data_id {
query.push_str(&format!(" AND data_id = ?{}", param_index)); query.push_str(&format!(" AND data_id = ?{}", param_index));
params.push(Box::new(di)); params.push(Box::new(di));
param_index += 1; param_index += 1;
} }
if let Some(dt) = data_type { if let Some(dt) = search_query.data_type {
query.push_str(&format!(" AND data_type = ?{}", param_index)); query.push_str(&format!(" AND data_type = ?{}", param_index));
params.push(Box::new(dt)); params.push(Box::new(dt));
param_index += 1; param_index += 1;
} }
if search_exact_match { if let Some(st) = search_query.search_text {
if let Some(st) = search_text { match search_query.search_mode {
query.push_str(&format!(" AND search_text = ?{}", param_index)); SearchMode::ExactMatch => {
params.push(Box::new(st)); query.push_str(&format!(" AND ed.search_text = ?{}", param_index));
param_index += 1; params.push(Box::new(st));
} }
} else { SearchMode::Like => {
if let Some(st) = search_text { query.push_str(&format!(" AND ed.search_text LIKE ?{}", param_index));
query.push_str(&format!(" AND search_text LIKE ?{}", param_index)); params.push(Box::new(format!("{}", st)));
params.push(Box::new(format!("%{}%", st))); }
param_index += 1; SearchMode::FTS => {
// Join with FTS table and use MATCH operator
query.push_str(&format!(" AND edf.search_text MATCH ?{}", param_index));
params.push(Box::new(st));
}
} }
param_index += 1;
} }
if let Some(after) = after_created_at { if let Some(after) = search_query.after_created_at {
query.push_str(&format!(" AND created_at > ?{}", param_index)); query.push_str(&format!(" AND created_at > ?{}", param_index));
params.push(Box::new(after)); params.push(Box::new(after));
param_index += 1; param_index += 1;
} }
if let Some(before) = before_created_at { if let Some(before) = search_query.before_created_at {
query.push_str(&format!(" AND created_at < ?{}", param_index)); query.push_str(&format!(" AND created_at < ?{}", param_index));
params.push(Box::new(before)); params.push(Box::new(before));
param_index += 1; param_index += 1;
} }
if let Some(order_by_created_at) = order_by_created_at { if let Some(order_by_created_at) = search_query.order_by_created_at {
query.push_str(&format!( query.push_str(&format!(
" ORDER BY created_at {}", " ORDER BY created_at {}",
order_by_created_at.to_string() order_by_created_at.to_string()
)); ));
} }
if let Some(order_by_updated_at) = order_by_updated_at { if let Some(order_by_updated_at) = search_query.order_by_updated_at {
query.push_str(&format!( query.push_str(&format!(
" ORDER BY updated_at {}", " ORDER BY updated_at {}",
order_by_updated_at.to_string() order_by_updated_at.to_string()
)); ));
} }
if let Some(limit) = limit { if let Some(limit) = search_query.limit {
query.push_str(&format!(" LIMIT ?{}", param_index)); query.push_str(&format!(" LIMIT ?{}", param_index));
params.push(Box::new(limit)); params.push(Box::new(limit));
param_index += 1;
}
if let Some(offset) = search_query.offset {
query.push_str(&format!(" OFFSET ?{}", param_index));
params.push(Box::new(offset));
param_index += 1;
} }
let mut stmt = self.conn.prepare(&query)?; let mut stmt = self.conn.prepare(&query)?;
// println!("search_extension_data query: {}", query);
let ext_data_iter = let ext_data_iter =
stmt.query_map(params_from_iter(params.iter().map(|p| p.as_ref())), |row| { stmt.query_map(params_from_iter(params.iter().map(|p| p.as_ref())), |row| {
Ok(models::ExtData { Ok(models::ExtData {
@ -587,224 +623,285 @@ mod tests {
.unwrap() .unwrap()
.unwrap(); .unwrap();
db.create_extension_data(ext.ext_id, "test", "{}", None) db.create_extension_data(ext.ext_id, "test", "{}", None, None)
.unwrap(); .unwrap();
db.create_extension_data(ext.ext_id, "setting", "{}", None) db.create_extension_data(ext.ext_id, "setting", "{}", None, None)
.unwrap(); .unwrap();
/* ---------------------- Search with data_type == test --------------------- */ /* ---------------------- Search with data_type == test --------------------- */
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
Some("test"), search_mode: SearchMode::FTS,
None, data_type: Some("test".to_string()),
None, search_text: None,
None, after_created_at: None,
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap(); .unwrap();
assert_eq!(ext_data.len(), 1); // there is only one record with data_type == test assert_eq!(ext_data.len(), 1); // there is only one record with data_type == test
/* ------------------------ Search without any filter ----------------------- */ /* ------------------------ Search without any filter ----------------------- */
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, false, None, None, None, None, None, None, None, None, None, ext_id: ext.ext_id,
) fields: None,
data_id: None,
search_mode: SearchMode::FTS,
data_type: None,
search_text: None,
after_created_at: None,
before_created_at: None,
order_by_created_at: None,
order_by_updated_at: None,
limit: None,
offset: None,
})
.unwrap(); .unwrap();
assert_eq!(ext_data.len(), 2); // one test, one setting assert_eq!(ext_data.len(), 2); // one test, one setting
/* -------------------------- Test Full Text Search ------------------------- */ // /* -------------------------- Test Full Text Search ------------------------- */
db.create_extension_data(ext.ext_id, "data", "{}", Some("hello world from rust")) let json_data = json!({
.unwrap(); "name": "John Doe",
db.create_extension_data(ext.ext_id, "data", "{}", Some("world is a mess")) "age": 43,
.unwrap(); "phones": [
/* ----------------------- both record contains world ----------------------- */ "+44 1234567",
"+44 2345678"
]
});
db.create_extension_data(
ext.ext_id,
"data",
"{}",
Some("hello world from rust"),
Some(json_data.to_string().as_str()),
)
.unwrap();
db.create_extension_data(
ext.ext_id,
"data",
"{}",
Some("world is a mess"),
Some(json_data.to_string().as_str()),
)
.unwrap();
// Search Mode: Like
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
Some("data"), search_mode: SearchMode::Like,
Some("wOrLd"), data_type: Some("data".to_string()),
None, search_text: Some("worl%".to_string()),
None, after_created_at: None,
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap();
assert_eq!(ext_data.len(), 1);
/* ----------------------- both record contains world ----------------------- */
// Search Mode: FTS
let ext_data = db
.search_extension_data(ExtDataSearchQuery {
ext_id: ext.ext_id,
fields: None,
data_id: None,
search_mode: SearchMode::FTS,
data_type: Some("data".to_string()),
search_text: Some("wOrLd".to_string()), // FTS is case insensitive
after_created_at: None,
before_created_at: None,
order_by_created_at: None,
order_by_updated_at: None,
limit: None,
offset: None,
})
.unwrap(); .unwrap();
assert_eq!(ext_data.len(), 2); assert_eq!(ext_data.len(), 2);
/* ------------------------ search for rust with FTS ------------------------ */ /* ------------------------ search for rust with FTS ------------------------ */
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
Some("data"), search_mode: SearchMode::FTS,
Some("rust"), data_type: Some("data".to_string()),
None, search_text: Some("rust".to_string()),
None, after_created_at: None,
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap(); .unwrap();
assert_eq!(ext_data.len(), 1); assert_eq!(ext_data.len(), 1);
// get ext data with search text that does not exist // get ext data with search text that does not exist
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
Some("test"), search_mode: SearchMode::FTS,
Some("test"), data_type: Some("test".to_string()),
None, search_text: Some("test".to_string()),
None, after_created_at: None,
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap(); .unwrap();
assert_eq!(ext_data.len(), 0); assert_eq!(ext_data.len(), 0);
/* ---------------- All 4 test records are created after 2021 --------------- */ /* ---------------- All 4 test records are created after 2021 --------------- */
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
None, search_mode: SearchMode::FTS,
None, data_type: None,
Some("2021-01-01"), search_text: None,
None, after_created_at: Some("2021-01-01".to_string()),
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap(); .unwrap();
assert_eq!(ext_data.len(), 4); assert_eq!(ext_data.len(), 4);
// I don't think this code(or I) could live long enough to see this test fail 2100 // no filter, get all records
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
None, search_mode: SearchMode::FTS,
None, data_type: None,
Some("2100-01-01"), search_text: None,
None, after_created_at: None,
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap(); .unwrap();
assert_eq!(ext_data.len(), 0); assert_eq!(ext_data.len(), 4);
/* --------------- All 4 test records are created before 2030 --------------- */ /* --------------- All 4 test records are created before 2030 --------------- */
// if this code still runs in 2030, I will be very happy to fix this test // if this code still runs in 2030, I will be very happy to fix this test
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
None, search_mode: SearchMode::FTS,
None, data_type: None,
None, search_text: None,
Some("2030-01-01"), after_created_at: None,
None, before_created_at: Some("2030-01-01".to_string()),
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap(); .unwrap();
assert_eq!(ext_data.len(), 4); assert_eq!(ext_data.len(), 4);
// get ext data with created_at filter that does not exist // get ext data with created_at filter that does not exist
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
None, search_mode: SearchMode::ExactMatch,
None, data_type: None,
None, search_text: None,
Some("2021-01-01"), after_created_at: Some("2021-01-01".to_string()),
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap(); .unwrap();
assert_eq!(ext_data.len(), 0); assert_eq!(ext_data.len(), 4);
/* ---------------------- Delete ext data by data_id ---------------------- */ /* ---------------------- Delete ext data by data_id ---------------------- */
// there is only one record with data_type == setting // there is only one record with data_type == setting
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
Some("setting"), search_mode: SearchMode::FTS,
None, data_type: Some("setting".to_string()),
None, search_text: None,
None, after_created_at: None,
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap(); .unwrap();
let data_id = ext_data.first().unwrap().data_id; let data_id = ext_data.first().unwrap().data_id;
db.delete_extension_data_by_id(data_id).unwrap(); db.delete_extension_data_by_id(data_id).unwrap();
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
Some("setting"), search_mode: SearchMode::FTS,
None, data_type: Some("setting".to_string()),
None, search_text: None,
None, after_created_at: None,
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap(); .unwrap();
assert_eq!(ext_data.len(), 0); assert_eq!(ext_data.len(), 0);
/* ---------------------- Update ext data by data_id ---------------------- */ /* ---------------------- Update ext data by data_id ---------------------- */
let ext_data = db let ext_data = db
.search_extension_data( .search_extension_data(ExtDataSearchQuery {
ext.ext_id, ext_id: ext.ext_id,
false, fields: None,
None, data_id: None,
Some("data"), search_mode: SearchMode::FTS,
None, data_type: Some("data".to_string()),
None, search_text: None,
None, after_created_at: None,
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
) offset: None,
})
.unwrap(); .unwrap();
let data_id = ext_data.first().unwrap().data_id; let data_id = ext_data.first().unwrap().data_id;
db.update_extension_data_by_id(data_id, "{\"name\": \"huakun\"}", Some("updated")) db.update_extension_data_by_id(data_id, "{\"name\": \"huakun\"}", Some("updated"))
.unwrap(); .unwrap();
let ext_data = db.get_extension_data_by_id(data_id).unwrap(); let ext_data = db.get_extension_data_by_id(data_id, None).unwrap();
assert!(ext_data.is_some()); assert!(ext_data.is_some());
let ext_data = ext_data.unwrap(); let ext_data = ext_data.unwrap();
assert_eq!(ext_data.data.unwrap(), "{\"name\": \"huakun\"}"); assert_eq!(ext_data.data.unwrap(), "{\"name\": \"huakun\"}");

View File

@ -1,5 +1,6 @@
use rusqlite::types::FromSql; use rusqlite::types::FromSql;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use strum_macros::Display;
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -71,6 +72,56 @@ pub struct Cmd {
pub enabled: bool, pub enabled: bool,
} }
#[derive(Debug, Serialize, Deserialize, Display)]
#[serde(rename_all = "UPPERCASE")]
pub enum SQLSortOrder {
Asc,
Desc,
}
#[derive(Debug, Serialize, Deserialize, Display, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ExtDataField {
Data,
SearchText,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum SearchMode {
#[serde(rename = "exact_match")]
ExactMatch,
#[serde(rename = "like")]
Like,
#[serde(rename = "fts")]
FTS,
}
impl SearchMode {
pub fn to_string(&self) -> String {
serde_json::to_string(self)
.map(|s| s.trim_matches('"').to_string())
.unwrap_or_else(|_| String::from(""))
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtDataSearchQuery {
pub ext_id: i32,
// pub search_exact_match: bool,
pub search_mode: SearchMode,
pub data_id: Option<i32>,
pub data_type: Option<String>,
pub search_text: Option<String>,
pub after_created_at: Option<String>,
pub before_created_at: Option<String>,
pub limit: Option<i32>,
pub offset: Option<i32>,
pub order_by_created_at: Option<SQLSortOrder>,
pub order_by_updated_at: Option<SQLSortOrder>,
pub fields: Option<Vec<ExtDataField>>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -86,4 +137,11 @@ mod tests {
assert_eq!(headless_worker.to_string(), "headless_worker"); assert_eq!(headless_worker.to_string(), "headless_worker");
assert_eq!(quick_link.to_string(), "quick_link"); assert_eq!(quick_link.to_string(), "quick_link");
} }
#[test]
fn test_search_mode() {
assert_eq!(SearchMode::ExactMatch.to_string(), "exact_match");
assert_eq!(SearchMode::Like.to_string(), "like");
assert_eq!(SearchMode::FTS.to_string(), "fts");
}
} }

View File

@ -14,10 +14,13 @@ import { upsertExtension } from "./db"
export function loadExtensionManifestFromDisk(manifestPath: string): Promise<ExtPackageJsonExtra> { export function loadExtensionManifestFromDisk(manifestPath: string): Promise<ExtPackageJsonExtra> {
debug(`loadExtensionManifestFromDisk: ${manifestPath}`) debug(`loadExtensionManifestFromDisk: ${manifestPath}`)
return readTextFile(manifestPath).then(async (content) => { return readTextFile(manifestPath).then(async (content) => {
const parse = v.safeParse(ExtPackageJson, JSON.parse(content)) console.log("content", content)
const json = JSON.parse(content)
console.log("manifest json", json)
const parse = v.safeParse(ExtPackageJson, json)
if (parse.issues) { if (parse.issues) {
error(`Fail to load extension from ${manifestPath}. See console for parse error.`) error(`Fail to load extension from ${manifestPath}. See console for parse error.`)
console.error(v.flatten<typeof ExtPackageJson>(parse.issues)) console.error("Parse Error:", v.flatten<typeof ExtPackageJson>(parse.issues))
throw new Error(`Invalid manifest: ${manifestPath}`) throw new Error(`Invalid manifest: ${manifestPath}`)
} else { } else {
// debug(`Loaded extension ${parse.output.kunkun.identifier} from ${manifestPath}`) // debug(`Loaded extension ${parse.output.kunkun.identifier} from ${manifestPath}`)

View File

@ -14,7 +14,7 @@ console.log("process.env.CF_PAGES_URL", Bun.env.CF_PAGES_URL)
console.log("process.env.CF_PAGES_BRANCH", Bun.env.CF_PAGES_BRANCH) console.log("process.env.CF_PAGES_BRANCH", Bun.env.CF_PAGES_BRANCH)
console.log("process.env.CF_PAGES_COMMIT_SHA", Bun.env.CF_PAGES_COMMIT_SHA) console.log("process.env.CF_PAGES_COMMIT_SHA", Bun.env.CF_PAGES_COMMIT_SHA)
if (Bun.env.CF_PAGES_URL) { if (Bun.env.CF_PAGES_URL) {
console.log("Skipping build in Cloudflare Pages, as cloudflare pages does not have protoc") console.warn("Skipping build in Cloudflare Pages, as cloudflare pages does not have protoc")
process.exit(0) process.exit(0)
} }
@ -28,15 +28,16 @@ if (!fs.existsSync(srcPath)) {
fs.rmSync(path.join(__dirname, "src/protos"), { recursive: true, force: true }) fs.rmSync(path.join(__dirname, "src/protos"), { recursive: true, force: true })
const protosDir = path.join(__dirname, "protos") const protosDir = path.join(__dirname, "protos")
// find path to protoc command
const protocPath = Bun.which("protoc")
if (!protocPath) {
console.warn("protoc not found")
process.exit(0)
}
for (const file of fs.readdirSync(protosDir)) { for (const file of fs.readdirSync(protosDir)) {
if (file.endsWith(".proto")) { if (file.endsWith(".proto")) {
try { try {
await $` await $`${protocPath} --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts --ts_out=./src -I . ./protos/${file}`
protoc \
--plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \
--ts_out=./src \
-I . \
./protos/${file}`
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }

View File

@ -36,4 +36,4 @@ client.ServerInfo(new kk.kunkun.Empty(), (err, response) => {
console.error(err) console.error(err)
} }
}) })
// To trust any SSL cert, set NODE_TLS_REJECT_UNAUTHORIZED='0' // To trust any SSL cert, set NODE_TLS_REJECT_UNAUTHORIZED='0'

View File

@ -1,6 +1,6 @@
use db::{ use db::{
models::{Cmd, CmdType, Ext, ExtData}, models::{Cmd, CmdType, Ext, ExtData, ExtDataField, ExtDataSearchQuery, SQLSortOrder},
ExtDataField, JarvisDB, SQLSortOrder, JarvisDB,
}; };
use std::{path::PathBuf, sync::Mutex}; use std::{path::PathBuf, sync::Mutex};
use tauri::State; use tauri::State;
@ -191,53 +191,32 @@ pub async fn create_extension_data(
db.db db.db
.lock() .lock()
.unwrap() .unwrap()
.create_extension_data(ext_id, data_type, data, search_text) .create_extension_data(ext_id, data_type, data, search_text, None)
.map_err(|err| err.to_string()) .map_err(|err| err.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn get_extension_data_by_id( pub async fn get_extension_data_by_id(
data_id: i32, data_id: i32,
fields: Option<Vec<ExtDataField>>,
db: State<'_, DBState>, db: State<'_, DBState>,
) -> Result<Option<ExtData>, String> { ) -> Result<Option<ExtData>, String> {
db.db db.db
.lock() .lock()
.unwrap() .unwrap()
.get_extension_data_by_id(data_id) .get_extension_data_by_id(data_id, fields)
.map_err(|err| err.to_string()) .map_err(|err| err.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn search_extension_data( pub async fn search_extension_data(
ext_id: i32,
search_exact_match: bool,
data_id: Option<i32>,
data_type: Option<&str>,
search_text: Option<&str>,
after_created_at: Option<&str>,
before_created_at: Option<&str>,
db: State<'_, DBState>, db: State<'_, DBState>,
limit: Option<i32>, search_query: ExtDataSearchQuery,
order_by_created_at: Option<SQLSortOrder>,
order_by_updated_at: Option<SQLSortOrder>,
fields: Option<Vec<ExtDataField>>,
) -> Result<Vec<ExtData>, String> { ) -> Result<Vec<ExtData>, String> {
db.db db.db
.lock() .lock()
.unwrap() .unwrap()
.search_extension_data( .search_extension_data(search_query)
ext_id,
search_exact_match,
data_id,
data_type,
search_text,
after_created_at,
before_created_at,
limit,
order_by_created_at,
order_by_updated_at,
fields,
)
.map_err(|err| err.to_string()) .map_err(|err| err.to_string())
} }

View File

@ -190,6 +190,14 @@ pub fn init<R: Runtime>(db_key: Option<String>) -> TauriPlugin<R> {
setup::db::setup_db(app)?; setup::db::setup_db(app)?;
println!("Jarvis Plugin Initialized"); println!("Jarvis Plugin Initialized");
app.manage(Peers::default()); app.manage(Peers::default());
// let jarvis_db = utils::db::get_db(db_path, db_key)?;
// let ext = jarvis_db
// .get_unique_extension_by_identifier(constants::KUNKUN_CLIPBOARD_EXT_IDENTIFIER)?;
// app.manage(model::clipboard_history::ClipboardHistory::new(
// jarvis_db,
// ext.unwrap().ext_id,
// ));
Ok(()) Ok(())
}) })
.build() .build()

View File

@ -1,4 +1,7 @@
use db::JarvisDB; use db::{
models::{ExtDataSearchQuery, SearchMode},
JarvisDB,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{str::FromStr, sync::Mutex}; use std::{str::FromStr, sync::Mutex};
use strum_macros::{Display, EnumString}; use strum_macros::{Display, EnumString};
@ -23,8 +26,8 @@ pub struct Record {
pub struct ClipboardHistory { pub struct ClipboardHistory {
// pub history: Mutex<Vec<Record>>, // pub history: Mutex<Vec<Record>>,
jarvis_db: Mutex<JarvisDB>, pub jarvis_db: Mutex<JarvisDB>,
clipboard_ext_id: i32, pub clipboard_ext_id: i32,
} }
impl ClipboardHistory { impl ClipboardHistory {
@ -44,25 +47,27 @@ impl ClipboardHistory {
&record.content_type.to_string(), &record.content_type.to_string(),
&record.value, &record.value,
Some(&record.text), Some(&record.text),
None,
)?; )?;
Ok(()) Ok(())
} }
pub fn get_all_records(&self) -> anyhow::Result<Vec<Record>> { pub fn get_all_records(&self) -> anyhow::Result<Vec<Record>> {
let jdb = self.jarvis_db.lock().unwrap(); let jdb = self.jarvis_db.lock().unwrap();
let db_records = jdb.search_extension_data( let db_records = jdb.search_extension_data(ExtDataSearchQuery {
self.clipboard_ext_id, ext_id: self.clipboard_ext_id,
false, fields: None,
None, data_id: None,
None, search_mode: SearchMode::FTS,
None, data_type: None,
None, search_text: None,
None, after_created_at: None,
None, before_created_at: None,
None, order_by_created_at: None,
None, order_by_updated_at: None,
None, limit: None,
)?; offset: None,
})?;
let mut records = vec![]; let mut records = vec![];
for r in db_records { for r in db_records {
match crate::utils::time::sqlite_timestamp_to_unix_timestamp(&r.created_at) { match crate::utils::time::sqlite_timestamp_to_unix_timestamp(&r.created_at) {

View File

@ -1,7 +1,7 @@
import animate from "tailwindcss-animate" import animate from "tailwindcss-animate"
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { export default {
darkMode: ["class"], darkMode: ["class"],
safelist: ["dark"], safelist: ["dark"],
prefix: "", prefix: "",

View File

@ -34,30 +34,30 @@
"lint": "eslint ." "lint": "eslint ."
}, },
"devDependencies": { "devDependencies": {
"@iconify/svelte": "^4.0.2", "@iconify/svelte": "^4.1.0",
"@kksh/api": "workspace:*", "@kksh/api": "workspace:*",
"@kksh/svelte5": "^0.1.10", "@kksh/svelte5": "^0.1.10",
"@types/bun": "latest", "@types/bun": "latest",
"bits-ui": "1.0.0-next.60", "bits-ui": "1.0.0-next.72",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"formsnap": "2.0.0-next.1", "formsnap": "2.0.0-next.1",
"lucide-svelte": "^0.460.1", "lucide-svelte": "^0.468.0",
"mode-watcher": "^0.5.0", "mode-watcher": "^0.5.0",
"paneforge": "1.0.0-next.1", "paneforge": "1.0.0-next.1",
"shiki": "^1.23.1", "shiki": "^1.24.2",
"svelte-radix": "^2.0.1", "svelte-radix": "^2.0.1",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.20.1", "sveltekit-superforms": "^2.22.1",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.5",
"tailwind-variants": "^0.3.0", "tailwind-variants": "^0.3.0",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tauri-plugin-shellx-api": "^2.0.14", "tauri-plugin-shellx-api": "^2.0.14",
"zod": "^3.23.8" "zod": "^3.24.1"
}, },
"dependencies": { "dependencies": {
"@formkit/auto-animate": "^0.8.2", "@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.6", "@internationalized/date": "^3.6.0",
"@std/semver": "npm:@jsr/std__semver@^1.0.3", "@std/semver": "npm:@jsr/std__semver@^1.0.3",
"gsap": "^3.12.5", "gsap": "^3.12.5",
"svelte-markdown": "^0.4.1" "svelte-markdown": "^0.4.1"

View File

@ -20,7 +20,7 @@
{@render leftSlot?.()} {@render leftSlot?.()}
<CommandPrimitive.Input <CommandPrimitive.Input
class={cn( class={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50", "placeholder:text-muted-foreground flex h-10 w-full select-none rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
bind:ref bind:ref

2256
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"], "inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**"] "outputs": [".next/**", "!.next/cache/**", "dist/**", "build/**"]
}, },
"lint": { "lint": {
"dependsOn": ["^lint"] "dependsOn": ["^lint"]

@ -1 +1 @@
Subproject commit e0c867dcdc271bfdf0e6b830a6dcd8e5a7190c9f Subproject commit 6c6314d41093eb36af6c4c375f07048ef73aa36f