diff --git a/apps/desktop/src/store/zustand/listener/general.ts b/apps/desktop/src/store/zustand/listener/general.ts index b18e415863..f30e76c349 100644 --- a/apps/desktop/src/store/zustand/listener/general.ts +++ b/apps/desktop/src/store/zustand/listener/general.ts @@ -1,3 +1,5 @@ +import { getName } from "@tauri-apps/api/app"; +import { appDataDir } from "@tauri-apps/api/path"; import { Effect, Exit } from "effect"; import { create as mutate } from "mutative"; import type { StoreApi } from "zustand"; @@ -202,9 +204,18 @@ export const createGeneralSlice = < }), ); - hooksCommands - .runEventHooks({ - beforeListeningStarted: { args: { session_id: targetSessionId } }, + Promise.all([appDataDir(), getName().catch(() => "com.hyprnote.app")]) + .then(([dataDirPath, appName]) => { + const sessionPath = `${dataDirPath}/hyprnote/sessions/${targetSessionId}`; + return hooksCommands.runEventHooks({ + beforeListeningStarted: { + args: { + resource_dir: sessionPath, + app_hyprnote: appName, + app_meeting: null, + }, + }, + }); }) .catch((error) => { console.error("[hooks] BeforeListeningStarted failed:", error); @@ -264,9 +275,21 @@ export const createGeneralSlice = < }, onSuccess: () => { if (sessionId) { - hooksCommands - .runEventHooks({ - afterListeningStopped: { args: { session_id: sessionId } }, + Promise.all([ + appDataDir(), + getName().catch(() => "com.hyprnote.app"), + ]) + .then(([dataDirPath, appName]) => { + const sessionPath = `${dataDirPath}/hyprnote/sessions/${sessionId}`; + return hooksCommands.runEventHooks({ + afterListeningStopped: { + args: { + resource_dir: sessionPath, + app_hyprnote: appName, + app_meeting: null, + }, + }, + }); }) .catch((error) => { console.error("[hooks] AfterListeningStopped failed:", error); diff --git a/apps/web/content-collections.ts b/apps/web/content-collections.ts index 6f518255ea..4d59748b45 100644 --- a/apps/web/content-collections.ts +++ b/apps/web/content-collections.ts @@ -267,6 +267,7 @@ const hooks = defineCollection({ name: z.string(), type_name: z.string(), description: z.string(), + optional: z.boolean().default(false), }), ) .optional(), diff --git a/apps/web/content/docs/hooks/afterListeningStopped.mdx b/apps/web/content/docs/hooks/afterListeningStopped.mdx index 4cbb6eb4b8..181df807e6 100644 --- a/apps/web/content/docs/hooks/afterListeningStopped.mdx +++ b/apps/web/content/docs/hooks/afterListeningStopped.mdx @@ -1,8 +1,15 @@ --- name: afterListeningStopped -description: '123' +description: Arguments passed to hooks triggered after listening stops. args: -- name: session_id - description: '345' +- name: --resource-dir + description: Path to the resource directory. type_name: string +- name: --app-hyprnote + description: Application-specific Hyprnote data. + type_name: string +- name: --app-meeting + description: Optional meeting-specific data. + type_name: string + optional: true --- diff --git a/apps/web/content/docs/hooks/beforeListeningStarted.mdx b/apps/web/content/docs/hooks/beforeListeningStarted.mdx index da52a0d463..3bc6c018aa 100644 --- a/apps/web/content/docs/hooks/beforeListeningStarted.mdx +++ b/apps/web/content/docs/hooks/beforeListeningStarted.mdx @@ -1,8 +1,15 @@ --- name: beforeListeningStarted -description: '123' +description: Arguments passed to hooks triggered before listening starts. args: -- name: session_id - description: '345' +- name: --resource-dir + description: Path to the resource directory. type_name: string +- name: --app-hyprnote + description: Application-specific Hyprnote data. + type_name: string +- name: --app-meeting + description: Optional meeting-specific data. + type_name: string + optional: true --- diff --git a/apps/web/src/components/hooks-list.tsx b/apps/web/src/components/hooks-list.tsx index 003c213703..b7487de212 100644 --- a/apps/web/src/components/hooks-list.tsx +++ b/apps/web/src/components/hooks-list.tsx @@ -9,42 +9,63 @@ export function HooksList() { } return ( -
+
{hooks.map((hook) => ( -
-

- {hook.name} -

- {hook.description && ( -

{hook.description}

- )} +
+
+

+ {hook.name} +

+ {hook.description && ( +

+ {hook.description} +

+ )} +
+ {hook.args && hook.args.length > 0 && ( -
-

Arguments

-
+
+

+ Arguments +

+
{hook.args.map((arg) => (
-
- {arg.name} - - {" "} - : {arg.type_name} - +
+ + {arg.name} + +
+ {arg.type_name} + {arg.optional && ( + <> + + + optional + + + )} +
+
+
+ {arg.description}
- {arg.description && ( -

- {arg.description} -

- )}
))}
)} -
+ +
diff --git a/plugins/hooks/js/bindings.gen.ts b/plugins/hooks/js/bindings.gen.ts index 64e84a71a8..ed34b18fb6 100644 --- a/plugins/hooks/js/bindings.gen.ts +++ b/plugins/hooks/js/bindings.gen.ts @@ -28,33 +28,56 @@ async runEventHooks(event: HookEvent) : Promise> { /** user-defined types **/ /** - * 123 + * Arguments passed to hooks triggered after listening stops. */ export type AfterListeningStoppedArgs = { /** - * 345 + * Path to the resource directory. */ -session_id: string } +resource_dir: string; /** - * 123 + * Application-specific Hyprnote data. + */ +app_hyprnote: string; +/** + * Optional meeting-specific data. + */ +app_meeting?: string | null } +/** + * Arguments passed to hooks triggered before listening starts. */ export type BeforeListeningStartedArgs = { /** - * 345 + * Path to the resource directory. + */ +resource_dir: string; +/** + * Application-specific Hyprnote data. + */ +app_hyprnote: string; +/** + * Optional meeting-specific data. + */ +app_meeting?: string | null } +/** + * Defines a single hook to be executed on an event. + */ +export type HookDefinition = { +/** + * Shell command to execute when the hook is triggered. */ -session_id: string } -export type HookDefinition = { command: string } +command: string } export type HookEvent = { afterListeningStopped: { args: AfterListeningStoppedArgs } } | { beforeListeningStarted: { args: BeforeListeningStartedArgs } } /** - * 123 + * Configuration for hook execution. */ export type HooksConfig = { /** - * 345 + * Configuration schema version. */ version: number; /** - * 678 + * Map of event names to their associated hook definitions. */ hooks?: Partial<{ [key in string]: HookDefinition[] }> } diff --git a/plugins/hooks/src/config.rs b/plugins/hooks/src/config.rs index 036f8b2b03..86ea57bef3 100644 --- a/plugins/hooks/src/config.rs +++ b/plugins/hooks/src/config.rs @@ -2,18 +2,20 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; -/// 123 +/// Configuration for hook execution. #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] pub struct HooksConfig { - /// 345 + /// Configuration schema version. pub version: u8, - /// 678 + /// Map of event names to their associated hook definitions. #[serde(default)] pub hooks: HashMap>, } +/// Defines a single hook to be executed on an event. #[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] pub struct HookDefinition { + /// Shell command to execute when the hook is triggered. pub command: String, } diff --git a/plugins/hooks/src/docs.rs b/plugins/hooks/src/docs.rs index 35d15d4707..120c05f209 100644 --- a/plugins/hooks/src/docs.rs +++ b/plugins/hooks/src/docs.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use crate::naming::cli_flag; use serde::{Deserialize, Serialize}; use swc_common::{sync::Lrc, FileName, SourceMap, Span}; @@ -29,6 +30,8 @@ pub struct ArgField { pub name: String, pub description: Option, pub type_name: String, + #[serde(skip_serializing_if = "is_false")] + pub optional: bool, } #[derive(Debug, Clone)] @@ -37,6 +40,21 @@ struct TypeDoc { args: Vec, } +#[derive(Debug, Clone)] +struct TypeInfo { + type_name: String, + optional: bool, +} + +impl TypeInfo { + fn unknown() -> Self { + Self { + type_name: "unknown".to_string(), + optional: false, + } + } +} + pub fn parse_hooks(source_code: &str) -> Result, String> { let (module, fm) = parse_module(source_code)?; let jsdoc = JsDocExtractor::new(source_code, &fm); @@ -73,109 +91,152 @@ fn parse_module(source_code: &str) -> Result<(Module, Lrc) -> HashMap { - let mut docs = HashMap::new(); + exported_type_aliases(module) + .map(|(alias, span)| { + let type_name = alias.id.sym.to_string(); + let description = jsdoc.for_span(&span); + let args = extract_fields(alias.type_ann.as_ref(), jsdoc); + (type_name, TypeDoc { description, args }) + }) + .collect() +} - for item in &module.body { - if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) = item { - if let Decl::TsTypeAlias(type_alias) = &export.decl { - let type_name = type_alias.id.sym.to_string(); - let description = jsdoc.for_span(&export.span); - let args = extract_fields(type_alias.type_ann.as_ref(), jsdoc); +fn extract_hook_events(module: &Module, type_docs: &HashMap) -> Vec { + hook_union(module) + .map(|ty| hook_variants(ty, type_docs)) + .unwrap_or_default() +} - docs.insert(type_name, TypeDoc { description, args }); - } - } +fn hook_variants(type_ann: &TsType, type_docs: &HashMap) -> Vec { + if let TsType::TsUnionOrIntersectionType(TsUnionOrIntersectionType::TsUnionType(union)) = + type_ann + { + union + .types + .iter() + .filter_map(|variant| hook_from_variant(variant.as_ref(), type_docs)) + .collect() + } else { + Vec::new() } +} - docs +fn hook_from_variant(type_ann: &TsType, type_docs: &HashMap) -> Option { + let type_lit = type_lit_from(type_ann)?; + let prop = first_property(type_lit)?; + let hook_name = prop_name(prop)?; + let args_type = prop + .type_ann + .as_ref() + .and_then(|ty| args_type_name(&ty.type_ann))?; + + let (description, args) = type_docs + .get(&args_type) + .map(|doc| (doc.description.clone(), doc.args.clone())) + .unwrap_or((None, Vec::new())); + + Some(HookInfo { + name: hook_name, + description, + args, + }) } -fn extract_hook_events(module: &Module, type_docs: &HashMap) -> Vec { - for item in &module.body { +fn extract_fields(type_ann: &TsType, jsdoc: &JsDocExtractor<'_>) -> Vec { + let type_lit = match type_lit_from(type_ann) { + Some(lit) => lit, + None => return Vec::new(), + }; + + type_lit + .members + .iter() + .filter_map(|member| { + if let TsTypeElement::TsPropertySignature(prop) = member { + let field_name = prop_name(prop)?; + let doc_name = cli_flag(&field_name); + let description = jsdoc.for_span(&prop.span); + let type_info = prop + .type_ann + .as_ref() + .map(|ta| format_type(&ta.type_ann)) + .unwrap_or_else(TypeInfo::unknown); + + Some(ArgField { + name: doc_name, + description, + type_name: type_info.type_name, + optional: prop.optional || type_info.optional, + }) + } else { + None + } + }) + .collect() +} + +fn exported_type_aliases(module: &Module) -> impl Iterator + '_ { + module.body.iter().filter_map(|item| { if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) = item { if let Decl::TsTypeAlias(type_alias) = &export.decl { - if type_alias.id.sym == "HookEvent" { - return extract_union_variants(&type_alias.type_ann, type_docs); - } + return Some((type_alias.as_ref(), export.span)); } } - } + None + }) +} - Vec::new() +fn hook_union(module: &Module) -> Option<&TsType> { + exported_type_aliases(module) + .find(|(alias, _)| alias.id.sym.as_ref() == "HookEvent") + .map(|(alias, _)| alias.type_ann.as_ref()) } -fn extract_union_variants( - type_ann: &TsType, - type_docs: &HashMap, -) -> Vec { - let mut hooks = Vec::new(); +fn args_type_name(type_ann: &TsType) -> Option { + let type_lit = type_lit_from(type_ann)?; + let prop = property_by_name(&type_lit.members, "args")?; + prop.type_ann + .as_ref() + .and_then(|ta| type_name_from(&ta.type_ann)) +} - if let TsType::TsUnionOrIntersectionType(TsUnionOrIntersectionType::TsUnionType(union)) = - type_ann - { - for variant in &union.types { - if let Some(hook) = extract_variant_info(variant.as_ref(), type_docs) { - hooks.push(hook); - } - } - } +fn property_by_name<'a>( + members: &'a [TsTypeElement], + name: &str, +) -> Option<&'a TsPropertySignature> { + members.iter().find_map(|member| match member { + TsTypeElement::TsPropertySignature(prop) => match &*prop.key { + Expr::Ident(ident) if ident.sym.as_ref() == name => Some(prop), + _ => None, + }, + _ => None, + }) +} - hooks +fn first_property(type_lit: &TsTypeLit) -> Option<&TsPropertySignature> { + type_lit.members.iter().find_map(|member| match member { + TsTypeElement::TsPropertySignature(prop) => Some(prop), + _ => None, + }) } -fn extract_variant_info( - type_ann: &TsType, - type_docs: &HashMap, -) -> Option { - if let TsType::TsTypeLit(type_lit) = type_ann { - for member in &type_lit.members { - if let TsTypeElement::TsPropertySignature(prop) = member { - if let Expr::Ident(ident) = &*prop.key { - let hook_name = ident.sym.to_string(); - - let args_type_name = prop - .type_ann - .as_ref() - .and_then(|ta| extract_args_type_name(&ta.type_ann))?; - - let (description, args) = type_docs - .get(&args_type_name) - .map(|doc| (doc.description.clone(), doc.args.clone())) - .unwrap_or((None, Vec::new())); - - return Some(HookInfo { - name: hook_name, - description, - args, - }); - } - } - } +fn prop_name(prop: &TsPropertySignature) -> Option { + if let Expr::Ident(ident) = &*prop.key { + Some(ident.sym.to_string()) + } else { + None } - - None } -fn extract_args_type_name(type_ann: &TsType) -> Option { - if let TsType::TsTypeLit(type_lit) = type_ann { - for member in &type_lit.members { - if let TsTypeElement::TsPropertySignature(prop) = member { - if let Expr::Ident(ident) = &*prop.key { - if ident.sym == "args" { - return prop - .type_ann - .as_ref() - .and_then(|ta| extract_type_name(&ta.type_ann)); - } - } - } - } +fn type_lit_from(type_ann: &TsType) -> Option<&TsTypeLit> { + match type_ann { + TsType::TsTypeLit(type_lit) => Some(type_lit), + TsType::TsParenthesizedType(paren) => type_lit_from(&paren.type_ann), + _ => None, } - - None } -fn extract_type_name(type_ann: &TsType) -> Option { +fn type_name_from(type_ann: &TsType) -> Option { if let TsType::TsTypeRef(type_ref) = type_ann { if let TsEntityName::Ident(ident) = &type_ref.type_name { return Some(ident.sym.to_string()); @@ -184,34 +245,6 @@ fn extract_type_name(type_ann: &TsType) -> Option { None } -fn extract_fields(type_ann: &TsType, jsdoc: &JsDocExtractor<'_>) -> Vec { - let mut fields = Vec::new(); - - if let TsType::TsTypeLit(type_lit) = type_ann { - for member in &type_lit.members { - if let TsTypeElement::TsPropertySignature(prop) = member { - if let Expr::Ident(ident) = &*prop.key { - let field_name = ident.sym.to_string(); - let description = jsdoc.for_span(&prop.span); - let type_name = prop - .type_ann - .as_ref() - .map(|ta| format_type(&ta.type_ann)) - .unwrap_or_else(|| "unknown".to_string()); - - fields.push(ArgField { - name: field_name, - description, - type_name, - }); - } - } - } - } - - fields -} - struct JsDocExtractor<'a> { source: &'a str, fm: Lrc, @@ -274,21 +307,80 @@ fn format_jsdoc_content(block: &str) -> Option { } } -fn format_type(type_ann: &TsType) -> String { +fn format_type(type_ann: &TsType) -> TypeInfo { match type_ann { - TsType::TsKeywordType(kw) => match kw.kind { - TsKeywordTypeKind::TsStringKeyword => "string".to_string(), - TsKeywordTypeKind::TsNumberKeyword => "number".to_string(), - TsKeywordTypeKind::TsBooleanKeyword => "boolean".to_string(), - _ => format!("{:?}", kw.kind).to_lowercase(), - }, + TsType::TsKeywordType(kw) => format_keyword_type(&kw.kind), TsType::TsTypeRef(type_ref) => { if let TsEntityName::Ident(ident) = &type_ref.type_name { - ident.sym.to_string() + TypeInfo { + type_name: ident.sym.to_string(), + optional: false, + } } else { - "unknown".to_string() + TypeInfo::unknown() } } - _ => "unknown".to_string(), + TsType::TsUnionOrIntersectionType(TsUnionOrIntersectionType::TsUnionType(union)) => { + let mut parts = Vec::new(); + let mut optional = false; + + for ty in &union.types { + let ty_name = format_type(ty); + + if matches!(ty_name.type_name.as_str(), "null" | "undefined" | "void") { + optional = true; + continue; + } + + if ty_name.type_name == "unknown" { + continue; + } + + if ty_name.optional { + optional = true; + } + + if !parts.contains(&ty_name.type_name) { + parts.push(ty_name.type_name); + } + } + + if parts.is_empty() { + TypeInfo { + type_name: "unknown".to_string(), + optional, + } + } else { + TypeInfo { + type_name: parts.join(" | "), + optional, + } + } + } + TsType::TsParenthesizedType(paren) => format_type(&paren.type_ann), + _ => TypeInfo::unknown(), } } + +fn format_keyword_type(kind: &TsKeywordTypeKind) -> TypeInfo { + let name = format!("{:?}", kind) + .trim_start_matches("Ts") + .trim_end_matches("Keyword") + .to_lowercase(); + + let optional = matches!( + kind, + TsKeywordTypeKind::TsNullKeyword + | TsKeywordTypeKind::TsUndefinedKeyword + | TsKeywordTypeKind::TsVoidKeyword + ); + + TypeInfo { + type_name: name, + optional, + } +} + +fn is_false(value: &bool) -> bool { + !*value +} diff --git a/plugins/hooks/src/event.rs b/plugins/hooks/src/event.rs index b805aed5ff..aa5a693f90 100644 --- a/plugins/hooks/src/event.rs +++ b/plugins/hooks/src/event.rs @@ -1,3 +1,4 @@ +use crate::naming::cli_flag; use std::ffi::OsString; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] @@ -28,34 +29,59 @@ pub trait HookArgs { fn to_cli_args(&self) -> Vec; } +fn push_cli_arg(args: &mut Vec, field_name: &str, value: &str) { + args.push(OsString::from(cli_flag(field_name))); + args.push(OsString::from(value)); +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] -/// 123 +/// Arguments passed to hooks triggered after listening stops. pub struct AfterListeningStoppedArgs { - /// 345 - pub session_id: String, + /// Path to the resource directory. + pub resource_dir: String, + /// Application-specific Hyprnote data. + pub app_hyprnote: String, + /// Optional meeting-specific data. + #[serde(skip_serializing_if = "Option::is_none")] + pub app_meeting: Option, } impl HookArgs for AfterListeningStoppedArgs { fn to_cli_args(&self) -> Vec { - vec![ - OsString::from("--session-id"), - OsString::from(&self.session_id), - ] + let mut args = Vec::with_capacity(6); + push_cli_arg(&mut args, stringify!(resource_dir), &self.resource_dir); + push_cli_arg(&mut args, stringify!(app_hyprnote), &self.app_hyprnote); + + if let Some(meeting) = &self.app_meeting { + push_cli_arg(&mut args, stringify!(app_meeting), meeting); + } + + args } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type)] -/// 123 +/// Arguments passed to hooks triggered before listening starts. pub struct BeforeListeningStartedArgs { - /// 345 - pub session_id: String, + /// Path to the resource directory. + pub resource_dir: String, + /// Application-specific Hyprnote data. + pub app_hyprnote: String, + /// Optional meeting-specific data. + #[serde(skip_serializing_if = "Option::is_none")] + pub app_meeting: Option, } impl HookArgs for BeforeListeningStartedArgs { fn to_cli_args(&self) -> Vec { - vec![ - OsString::from("--session-id"), - OsString::from(&self.session_id), - ] + let mut args = Vec::with_capacity(6); + push_cli_arg(&mut args, stringify!(resource_dir), &self.resource_dir); + push_cli_arg(&mut args, stringify!(app_hyprnote), &self.app_hyprnote); + + if let Some(meeting) = &self.app_meeting { + push_cli_arg(&mut args, stringify!(app_meeting), meeting); + } + + args } } diff --git a/plugins/hooks/src/lib.rs b/plugins/hooks/src/lib.rs index 0eb4ccee42..4febaf6d34 100644 --- a/plugins/hooks/src/lib.rs +++ b/plugins/hooks/src/lib.rs @@ -3,6 +3,7 @@ mod config; mod error; mod event; mod ext; +mod naming; mod runner; #[cfg(test)] diff --git a/plugins/hooks/src/naming.rs b/plugins/hooks/src/naming.rs new file mode 100644 index 0000000000..cd6518207d --- /dev/null +++ b/plugins/hooks/src/naming.rs @@ -0,0 +1,32 @@ +pub(crate) fn cli_flag(field_name: &str) -> String { + let mut flag = String::with_capacity(field_name.len() + 2); + flag.push_str("--"); + + for ch in field_name.chars() { + flag.push(if ch == '_' { '-' } else { ch }); + } + + flag +} + +#[cfg(test)] +mod tests { + use super::cli_flag; + + #[test] + fn transforms_snake_case_into_cli_flag() { + assert_eq!(cli_flag("resource_dir"), "--resource-dir"); + assert_eq!(cli_flag("app_hyprnote"), "--app-hyprnote"); + assert_eq!(cli_flag("app_meeting"), "--app-meeting"); + } + + #[test] + fn leaves_hyphenated_names_intact() { + assert_eq!(cli_flag("already-hyphenated"), "--already-hyphenated"); + } + + #[test] + fn handles_empty_strings() { + assert_eq!(cli_flag(""), "--"); + } +}