From b102a4a2063e84c091b00f19c7c9ccdbe9def583 Mon Sep 17 00:00:00 2001 From: Spark Date: Sun, 11 Oct 2026 10:17:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(core):=20moteur=20PromptKey=20=E2=80=94=20?= =?UTF-8?q?biblioth=C3=A8que=20.md,=20variables,=20injection,=20raccourcis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PromptStore/Frontmatter : un prompt = un fichier Markdown, en-tête au sous-ensemble strict du document d'architecture (§7), écriture UTF-8 - VariableEngine : variables typées + automatiques (selection, clipboard, date, app, cursor), résolution et profils de destination - Injector/ClipboardGuard : focus capturé/rendu, presse-papiers sauvegardé puis restauré conditionnellement, aucun envoi automatique par défaut - Search : classement flou insensible aux accents, pondéré par l'usage - UsageStats (usage.json), HotkeyManager (conflits signalés, jamais muets) - Settings : config.ini + profils par IA (claude, chatgpt, qwen, terminal…) - PromptKey.ahk : tray, instance unique, capture du contexte, surveillance de la bibliothèque, installation du pack au premier lancement --- PromptKey.ahk | 259 +++++++++++++++++++++++++++++++++++++++++ lib/ClipboardGuard.ahk | 76 ++++++++++++ lib/Frontmatter.ahk | 208 +++++++++++++++++++++++++++++++++ lib/HotkeyManager.ahk | 72 ++++++++++++ lib/Injector.ahk | 94 +++++++++++++++ lib/Json.ahk | 201 ++++++++++++++++++++++++++++++++ lib/PromptStore.ahk | 221 +++++++++++++++++++++++++++++++++++ lib/Search.ahk | 129 ++++++++++++++++++++ lib/Settings.ahk | 135 +++++++++++++++++++++ lib/UsageStats.ahk | 75 ++++++++++++ lib/Util.ahk | 100 ++++++++++++++++ lib/VariableEngine.ahk | 125 ++++++++++++++++++++ 12 files changed, 1695 insertions(+) create mode 100644 PromptKey.ahk create mode 100644 lib/ClipboardGuard.ahk create mode 100644 lib/Frontmatter.ahk create mode 100644 lib/HotkeyManager.ahk create mode 100644 lib/Injector.ahk create mode 100644 lib/Json.ahk create mode 100644 lib/PromptStore.ahk create mode 100644 lib/Search.ahk create mode 100644 lib/Settings.ahk create mode 100644 lib/UsageStats.ahk create mode 100644 lib/Util.ahk create mode 100644 lib/VariableEngine.ahk diff --git a/PromptKey.ahk b/PromptKey.ahk new file mode 100644 index 0000000..cb8a87b --- /dev/null +++ b/PromptKey.ahk @@ -0,0 +1,259 @@ +#Requires AutoHotkey v2.0 +#SingleInstance Force +Persistent() +; PromptKey — Gestionnaire de prompts pour IA (Windows, AutoHotkey v2). +; Document d'architecture : architecture-prompt-manager-autohotkey.md (Spark, v1.0). +; Principe : insérer, ne pas envoyer (D5) ; zéro réseau (D7) ; un prompt = un fichier .md (D2). + +#Include lib\Util.ahk +#Include lib\Json.ahk +#Include lib\Settings.ahk +#Include lib\Frontmatter.ahk +#Include lib\VariableEngine.ahk +#Include lib\PromptStore.ahk +#Include lib\Search.ahk +#Include lib\ClipboardGuard.ahk +#Include lib\Injector.ahk +#Include lib\UsageStats.ahk +#Include lib\HotkeyManager.ahk +#Include gui\VarForm.ahk +#Include gui\Picker.ahk +#Include gui\Editor.ahk +#Include gui\Manager.ahk +#Include gui\SettingsGui.ahk + +class App { + static Settings := "" + static Store := "" + static Usage := "" + static InjectorObj := "" + static Hotkeys := HotkeyManager() + static Picker := PromptPicker() + static Manager := PromptManager() + static Editor := PromptEditor() + static PrefsGui := SettingsGui() + static VarForm := VarForm() + static LastCtx := "" + static LastPromptId := "" + static Started := false + + static Init() { + Util.LogFile := A_ScriptDir "\promptkey.log" + App.Settings := Settings(A_ScriptDir "\config.ini") + App.Store := PromptStore(App.Settings.LibraryPath) + App.Store.Load() + App.InstallStarterPackIfEmpty() + App.Usage := UsageStats(App.Settings.LibraryPath "\usage.json") + App.InjectorObj := Injector(App.Settings) + App.Picker.Build((p, useDefaults) => App.PickerUse(p, useDefaults), (p) => App.Editor.Show(p)) + App.Manager.Build((p) => App.UsePrompt(p, App.LastCtxOrCapture(), Map()), (p) => App.Editor.Show(p)) + App.BuildTray() + App.RegisterHotkeys() + SetTimer(() => App.WatchLibrary(), 5000) + App.Started := true + Util.Log("PromptKey démarré — " App.Store.Prompts.Length " prompts, bibliothèque : " App.Settings.LibraryPath) + TrayTip("Prêt — " App.Store.Prompts.Length " prompts. Raccourci : " App.Settings.PickerHotkey, "PromptKey") + } + + ; Premier lancement : si la bibliothèque est vide et que le pack est présent, on le recopie. + static InstallStarterPackIfEmpty() { + if (App.Store.Prompts.Length > 0) + return + packRoot := A_ScriptDir "\packs\essentiels" + if !DirExist(packRoot) + return + copied := 0 + Loop Files packRoot "\*.md", "FR" { + ; Chemin relatif : \.md + rel := SubStr(A_LoopFilePath, StrLen(packRoot) + 2) + dest := App.Settings.LibraryPath "\" rel + SplitPath(dest, , &destDir) + DirCreate(destDir) + if !FileExist(dest) { + FileCopy(A_LoopFilePath, dest) + copied++ + } + } + if (copied > 0) { + App.Store.Load() + Util.Log("Pack de démarrage installé : " copied " prompts") + } + } + + static BuildTray() { + A_IconTip := "PromptKey — Gestionnaire de prompts" + tray := A_TrayMenu + tray.Delete() + tray.Add("Ouvrir le Picker", (*) => App.OpenPicker()) + tray.Add("Gestionnaire", (*) => App.OpenManager()) + tray.Add("Nouveau prompt", (*) => App.NewPromptInteractive()) + tray.Add() + tray.Add("Recharger la bibliothèque", (*) => App.RefreshAfterLibraryChange(true)) + tray.Add("Paramètres", (*) => App.PrefsGui.Show()) + tray.Add("Ouvrir le dossier de la bibliothèque", (*) => Run(App.Settings.LibraryPath)) + tray.Add() + tray.Add("Quitter", (*) => ExitApp()) + tray.Default := "Ouvrir le Picker" + tray.ClickCount := 1 + } + + static RegisterHotkeys() { + callbacks := Map( + "picker", (*) => App.OpenPicker(), + "manager", (*) => App.OpenManager(), + "newPrompt", (*) => App.NewPromptInteractive(), + "replay", (*) => App.Replay(), + "usePrompt", (id, extra*) => App.UsePromptById(id) + ) + App.Hotkeys.RegisterAll(App.Settings, App.Store.Prompts, callbacks) + } + + ; Après un changement de bibliothèque (édition, rechargement) : raccourcis à jour. + static RefreshAfterLibraryChange(announce := false) { + App.Store.Load() + App.RegisterHotkeys() + if announce + TrayTip("Bibliothèque rechargée : " App.Store.Prompts.Length " prompts.", "PromptKey") + } + + static WatchLibrary() { + if (App.Started && App.Store.ChangedOnDisk()) { + Util.Log("Changement détecté dans la bibliothèque — rechargement.") + App.RefreshAfterLibraryChange() + } + } + + ; Capture du contexte d'invocation (§11.2) : fenêtre active, processus, sélection, presse-papiers. + ; L'ordre compte : lire le presse-papiers AVANT la capture de sélection (qui le restaure ensuite). + static CaptureContext() { + ctx := Map("hwnd", 0, "title", "", "process", "", "selection", "", "clipboardText", "") + try ctx["hwnd"] := WinExist("A") + if (ctx["hwnd"]) { + try ctx["title"] := WinGetTitle("ahk_id " ctx["hwnd"]) + try ctx["process"] := WinGetProcessName("ahk_id " ctx["hwnd"]) + } + ctx["clipboardText"] := ClipboardGuard.ClipboardText() + ctx["selection"] := ClipboardGuard.CaptureSelection() + return ctx + } + + static LastCtxOrCapture() { + return App.LastCtx != "" ? App.LastCtx : App.CaptureContext() + } + + static OpenPicker() { + App.LastCtx := App.CaptureContext() + ctx := App.LastCtx + ctx["profileName"] := App.ResolveProfileName("", ctx) + App.Picker.Show(ctx) + } + + static OpenManager() { + App.LastCtx := App.CaptureContext() + App.Manager.Show() + } + + static NewPromptInteractive() { + ctx := App.CaptureContext() + App.LastCtx := ctx + ib := InputBox("Titre du nouveau prompt :", "PromptKey — Nouveau prompt") + if (ib.Result != "OK" || Trim(ib.Value) = "") + return + body := ctx["selection"] != "" ? ctx["selection"] : "" + item := App.Store.CreateNew(Trim(ib.Value), "Sans catégorie", body) + App.RefreshAfterLibraryChange() + App.Editor.Show(item) + } + + static Replay() { + if (App.LastPromptId = "") { + TrayTip("Aucun prompt récent à rejouer.", "PromptKey") + return + } + App.LastCtx := App.CaptureContext() + App.UsePromptById(App.LastPromptId) + } + + static PickerUse(prompt, useDefaults) { + opts := Map("useDefaults", useDefaults ? 1 : 0) + App.UsePrompt(prompt, App.LastCtxOrCapture(), opts) + } + + static UsePromptById(id) { + prompt := App.Store.ById(id) + if (prompt = "") + return + App.UsePrompt(prompt, App.LastCtxOrCapture(), Map()) + } + + ; Résolution du profil (§12.3) : prompt > targets > auto-détection > défaut. + static ResolveProfileName(prompt, ctx) { + if (prompt != "" && prompt.Profile != "") + return StrLower(prompt.Profile) + if (prompt != "" && prompt.Targets.Length > 0) + return StrLower(String(prompt.Targets[1])) + proc := ctx.Has("process") ? ctx["process"] : "" + title := ctx.Has("title") ? ctx["title"] : "" + return App.Settings.DetectProfile(proc, title) + } + + static UsePrompt(prompt, ctx, opts) { + useDefaults := opts.Has("useDefaults") ? opts["useDefaults"] : 0 + profileName := App.ResolveProfileName(prompt, ctx) + defs := VariableEngine.ManualVars(prompt) + lastVals := App.Usage.LastValues(prompt.Id) + defaults := Map() + for d in defs { + defaults[d["name"]] := lastVals.Has(d["name"]) ? lastVals[d["name"]] : d["default"] + } + if (defs.Length = 0 || useDefaults) { + values := Map() + for d in defs + values[d["name"]] := defaults[d["name"]] + App.FinishUse(prompt, ctx, profileName, values, defs) + return + } + App.VarForm.Show(prompt, defs, defaults, (values) => App.OnVarFormDone(prompt, ctx, profileName, values, defs)) + } + + static OnVarFormDone(prompt, ctx, profileName, values, defs) { + if (values = "") { + ; Annulation : rendre le focus à l'application d'origine, sans rien injecter (§10.4). + if (ctx.Has("hwnd") && ctx["hwnd"] && WinExist("ahk_id " ctx["hwnd"])) + try WinActivate("ahk_id " ctx["hwnd"]) + return + } + App.FinishUse(prompt, ctx, profileName, values, defs) + } + + static FinishUse(prompt, ctx, profileName, values, defs) { + resolved := VariableEngine.Resolve(prompt.Body, values, ctx) + if (resolved["unresolved"].Length > 0) + TrayTip("Variables non résolues : " Join(resolved["unresolved"], ", "), "PromptKey") + profile := App.Settings.GetProfile(profileName) + finalText := VariableEngine.ApplyProfile(resolved["text"], profile) + sendAfter := prompt.SendAfter != "" ? prompt.SendAfter : profile["sendAfter"] + method := profile["methode"] != "" ? profile["methode"] : App.Settings.Method + ; Statistiques : exclure les variables de type password (§15). + statsValues := Map() + pwNames := Map() + for d in defs { + if (d["type"] = "password") + pwNames[d["name"]] := true + } + for name, val in values { + if !pwNames.Has(name) + statsValues[name] := val + } + App.Usage.RecordUse(prompt.Id, statsValues) + App.LastPromptId := prompt.Id + App.InjectorObj.Deliver(finalText, Map( + "hwnd", ctx.Has("hwnd") ? ctx["hwnd"] : 0, + "method", method, + "sendAfter", sendAfter, + "sensitive", prompt.Sensitive, + "cursorFromEnd", resolved["cursorFromEnd"])) + } +} + +App.Init() diff --git a/lib/ClipboardGuard.ahk b/lib/ClipboardGuard.ahk new file mode 100644 index 0000000..495ac32 --- /dev/null +++ b/lib/ClipboardGuard.ahk @@ -0,0 +1,76 @@ +; ClipboardGuard.ahk — Sauvegarde / restauration sûre du presse-papiers (architecture §11.3). +; Règle : on ne restaure que si le presse-papiers contient encore exactement ce qu'on y a mis ; +; si l'utilisateur a copié autre chose entre-temps, on abandonne sans rien écraser. + +class ClipboardGuard { + Saved := "" ; ClipboardAll sauvegardé + HasSaved := false + InjectedText := "" + + Save() { + try this.Saved := ClipboardAll() + this.HasSaved := true + } + + ClearSave() { + this.Saved := "" + this.HasSaved := false + this.InjectedText := "" + } + + ; À appeler après l'injection (via minuterie) : restauration conditionnelle. + RestoreIfUnchanged() { + if (!this.HasSaved) + return + current := "" + try current := A_Clipboard + if (current = this.InjectedText) { + try A_Clipboard := this.Saved + } + this.ClearSave() + } + + ; Mode sensitive (§18, point 6) : vider plutôt que restaurer. + WipeIfUnchanged() { + if (!this.HasSaved) + return + current := "" + try current := A_Clipboard + if (current = this.InjectedText) + A_Clipboard := "" + this.ClearSave() + } + + ; Capture best-effort du texte sélectionné dans l'application active (§11.2). + ; Sauvegarde le presse-papiers, simule Ctrl+C, lit, restaure immédiatement. + ; Retourne "" si rien n'est sélectionné ou si la capture échoue — sans jamais lever d'erreur. + static CaptureSelection() { + saved := "" + try saved := ClipboardAll() + result := "" + try { + A_Clipboard := "" + Send("^c") + if ClipWait(0.25) { + if DllCall("IsClipboardFormatAvailable", "UInt", 13) ; CF_UNICODETEXT + result := A_Clipboard + } + } + try { + if (saved != "") + A_Clipboard := saved + else + A_Clipboard := "" + } + return result + } + + static ClipboardText() { + txt := "" + try { + if DllCall("IsClipboardFormatAvailable", "UInt", 13) + txt := A_Clipboard + } + return txt + } +} diff --git a/lib/Frontmatter.ahk b/lib/Frontmatter.ahk new file mode 100644 index 0000000..3eaff96 --- /dev/null +++ b/lib/Frontmatter.ahk @@ -0,0 +1,208 @@ +; Frontmatter.ahk — Parseur / sérialiseur de l'en-tête des fichiers prompts. +; Contrat strict défini dans le document d'architecture, section 7.3 (sous-ensemble ligne par ligne, +; PAS un YAML complet). Toute déviation produit une erreur avec numéro de ligne, jamais une devinette. + +class Frontmatter { + ; Retourne un Map : { meta: Map, body: String, errors: [String], hasHeader: Bool } + static Parse(content) { + result := Map("meta", Map(), "body", content, "errors", [], "hasHeader", false) + text := StrReplace(content, "`r`n", "`n") + lines := StrSplit(text, "`n") + if (lines.Length = 0 || Trim(lines[1]) != "---") + return result ; pas d'en-tête : titre = nom de fichier, corps = tout (architecture §7.3) + + ; Trouver le second délimiteur. + endIdx := 0 + loop lines.Length - 1 { + if (Trim(lines[A_Index + 1]) = "---") { + endIdx := A_Index + 1 + break + } + } + if (endIdx = 0) { + result["errors"].Push("Ligne 1 : en-tête ouvert par --- mais jamais fermé.") + return result + } + result["hasHeader"] := true + + meta := result["meta"] + vars := [] + meta["variables"] := vars + inVars := false + currentVar := "" + loop endIdx - 2 { + idx := A_Index + 1 + raw := lines[idx] + lineNo := idx + line := RTrim(raw) + if (Trim(line) = "" || SubStr(Trim(line), 1, 1) = "#") + continue + ; Commentaire de fin de ligne hors guillemets : on coupe à " #". + line := Frontmatter.StripComment(line) + + if (inVars) { + if RegExMatch(line, "^ - (.*)$", &m) { + currentVar := Map() + vars.Push(currentVar) + inner := Trim(m[1]) + if (inner != "") + Frontmatter.SetPair(currentVar, inner, lineNo, result["errors"]) + continue + } + if RegExMatch(line, "^ (.+)$", &m) { + if (currentVar = "") { + result["errors"].Push("Ligne " lineNo " : entrée de variable sans élément de liste.") + continue + } + Frontmatter.SetPair(currentVar, Trim(m[1]), lineNo, result["errors"]) + continue + } + ; Fin du bloc variables : on retombe sur le traitement normal ci-dessous. + inVars := false + } + + if RegExMatch(line, "^([A-Za-z_][A-Za-z0-9_]*) *: *(.*)$", &m) { + key := StrLower(m[1]) + val := Trim(m[2]) + if (key = "variables") { + if (val != "") { + result["errors"].Push("Ligne " lineNo " : 'variables:' doit être seul sur sa ligne (liste indentée en dessous).") + } + inVars := true + continue + } + ; Interdits du sous-ensemble (§7.3). + if (val = "|" || val = ">" || InStr(val, "&") = 1 || InStr(val, "*") = 1) { + result["errors"].Push("Ligne " lineNo " : valeur multi-ligne ou ancre YAML interdite dans ce format.") + continue + } + meta[key] := Frontmatter.ParseValue(val) + } else { + result["errors"].Push("Ligne " lineNo " : ligne non reconnue dans l'en-tête : « " Util.Truncate(line, 40) " »") + } + } + if (vars.Length = 0) + meta.Delete("variables") + ; Corps : tout ce qui suit le second délimiteur, en sautant une ligne vide initiale. + bodyLines := [] + loop lines.Length - endIdx { + bodyLines.Push(lines[endIdx + A_Index]) + } + body := Join(bodyLines, "`n") + body := RegExReplace(body, "^\n+", "") + result["body"] := body + return result + } + + static StripComment(line) { + inQ := false + loop parse line { + if (A_LoopField = '"') + inQ := !inQ + else if (A_LoopField = "#" && !inQ && A_Index > 1 && SubStr(line, A_Index - 1, 1) = " ") + return RTrim(SubStr(line, 1, A_Index - 2)) + } + return line + } + + static SetPair(map, pairText, lineNo, errors) { + if !RegExMatch(pairText, "^([A-Za-z_][A-Za-z0-9_]*) *: *(.*)$", &m) { + errors.Push("Ligne " lineNo " : paire clé: valeur attendue, reçu « " Util.Truncate(pairText, 40) " »") + return + } + map[StrLower(m[1])] := Frontmatter.ParseValue(Trim(m[2])) + } + + ; Valeur : liste [a, b, "c d"] | booléen | nombre (gardé en texte, typé par le consommateur) | chaîne. + static ParseValue(val) { + if (val = "") + return "" + if (SubStr(val, 1, 1) = "[" && SubStr(val, -1) = "]") { + inner := SubStr(val, 2, StrLen(val) - 2) + items := [] + if (Trim(inner) = "") + return items + cur := "", inQ := false + loop parse inner { + ch := A_LoopField + if (ch = '"') + inQ := !inQ + if (ch = "," && !inQ) { + items.Push(Util.Unquote(Trim(cur))) + cur := "" + } else { + cur .= ch + } + } + items.Push(Util.Unquote(Trim(cur))) + return items + } + low := StrLower(val) + if (low = "true") + return 1 + if (low = "false") + return 0 + return Util.Unquote(val) + } + + ; Sérialisation : ordre stable des clés connues, puis les autres, puis le bloc variables. + static Serialize(meta, body) { + out := "---`n" + order := ["id", "title", "tags", "targets", "favorite", "pinned", "hotkey", "hotstring", "profile", "send_after", "sensitive", "created", "updated"] + done := Map() + for key in order { + if meta.Has(key) { + out .= key ": " Frontmatter.FormatValue(key, meta[key]) "`n" + done[key] := true + } + } + for key, val in meta { + if (key = "variables" || done.Has(key)) + continue + out .= key ": " Frontmatter.FormatValue(key, val) "`n" + } + if meta.Has("variables") && meta["variables"].Length > 0 { + out .= "variables:`n" + for v in meta["variables"] { + first := true + vOrder := ["name", "label", "type", "choices", "default", "required", "multiline"] + for key in vOrder { + if v.Has(key) { + prefix := first ? " - " : " " + out .= prefix key ": " Frontmatter.FormatValue(key, v[key]) "`n" + first := false + } + } + for key, val in v { + if !HasInArray(vOrder, key) { + prefix := first ? " - " : " " + out .= prefix key ": " Frontmatter.FormatValue(key, val) "`n" + first := false + } + } + } + } + out .= "---`n`n" body + return out + } + + static FormatValue(key, val) { + if (val is Array) { + parts := [] + for item in val + parts.Push(Util.QuoteIfNeeded(String(item))) + return "[" Join(parts, ", ") "]" + } + if (key = "favorite" || key = "send_after" || key = "sensitive" || key = "required" || key = "multiline") + return (val = 1 || val = "1" || val = "true") ? "true" : "false" + return Util.QuoteIfNeeded(String(val)) + } +} + +HasInArray(arr, needle) { + for v in arr { + if (v = needle) + return true + } + return false +} diff --git a/lib/HotkeyManager.ahk b/lib/HotkeyManager.ahk new file mode 100644 index 0000000..b2bf53b --- /dev/null +++ b/lib/HotkeyManager.ahk @@ -0,0 +1,72 @@ +; HotkeyManager.ahk — Enregistrement des raccourcis globaux et hotstrings (architecture §13). +; Aucun conflit silencieux : tout échec est consigné dans this.Conflicts et signalé (§13.3). + +class HotkeyManager { + Registered := [] ; raccourcis effectivement enregistrés (pour désactivation propre) + HotstringsOn := [] ; hotstrings effectivement activées + Conflicts := [] ; [ "description — erreur" ] + + __New() { + } + + Clear() { + for hk in this.Registered { + try Hotkey(hk, "Off") + } + for hs in this.HotstringsOn { + try Hotstring(hs, "Off") + } + this.Registered := [] + this.HotstringsOn := [] + this.Conflicts := [] + } + + ; callbacks : Map { picker, manager, newPrompt, replay, usePrompt (Func(id, ...)) } — usePrompt reçoit l'id en premier (Bind). + RegisterAll(settings, prompts, callbacks) { + this.Clear() + this.TryHotkey(settings.PickerHotkey, callbacks["picker"], "Raccourci principal (Picker)") + this.TryHotkey(settings.ManagerHotkey, callbacks["manager"], "Raccourci Gestionnaire") + this.TryHotkey(settings.NewHotkey, callbacks["newPrompt"], "Raccourci Nouveau prompt") + this.TryHotkey(settings.ReplayHotkey, callbacks["replay"], "Raccourci Rejouer le dernier") + + ; Épinglés : Ctrl+Alt+1..9 (§13.1). Bind fige l'id (les fermetures AHK capturent par référence). + pinnedMap := Map() + for p in prompts { + if (p.Pinned >= 1 && p.Pinned <= 9) + pinnedMap[p.Pinned] := p.Id + } + for n, id in pinnedMap { + this.TryHotkey("^!" n, callbacks["usePrompt"].Bind(id), "Épinglé n° " n) + } + + ; Raccourcis propres et hotstrings par prompt. + for p in prompts { + if (p.Hotkey != "") + this.TryHotkey(p.Hotkey, callbacks["usePrompt"].Bind(p.Id), "Raccourci du prompt « " p.Title " »") + if (p.Hotstring != "") { + hs := "::" p.Hotstring + try { + Hotstring(hs, callbacks["usePrompt"].Bind(p.Id)) + this.HotstringsOn.Push(hs) + } catch as e { + this.Conflicts.Push("Hotstring " hs " (« " p.Title " ») — " e.Message) + } + } + } + if (this.Conflicts.Length > 0) { + Util.Log("Conflits de raccourcis : " this.Conflicts.Length) + TrayTip(this.Conflicts.Length " raccourci(s) en conflit. Détail dans Paramètres.", "PromptKey — conflits de raccourcis") + } + } + + TryHotkey(hk, callback, label) { + if (hk = "") + return + try { + Hotkey(hk, callback) + this.Registered.Push(hk) + } catch as e { + this.Conflicts.Push(label " (" hk ") — " e.Message) + } + } +} diff --git a/lib/Injector.ahk b/lib/Injector.ahk new file mode 100644 index 0000000..8f150ee --- /dev/null +++ b/lib/Injector.ahk @@ -0,0 +1,94 @@ +; Injector.ahk — SEUL module autorisé à toucher au focus, au presse-papiers et à la frappe +; simulée pour livrer un prompt (architecture §11.1). Aucun autre module n'appelle +; Send / A_Clipboard pour l'injection. + +class Injector { + Guard := ClipboardGuard() + SettingsRef := "" + + __New(settings) { + this.SettingsRef := settings + } + + ; options : Map { hwnd, method ("clipboard"|"sendtext"), sendAfter (0/1), sensitive (0/1), cursorFromEnd } + ; Retourne Map { ok, method, error } + Deliver(text, options) { + st := this.SettingsRef + method := options.Has("method") && options["method"] != "" ? options["method"] : st.Method + hwnd := options.Has("hwnd") ? options["hwnd"] : 0 + if (Trim(text) = "") + return Map("ok", false, "method", method, "error", "Texte vide : rien à injecter.") + if (hwnd && !WinExist("ahk_id " hwnd)) { + ; Fenêtre disparue : copie de secours (§11.5). + A_Clipboard := text + TrayTip("La fenêtre d'origine a disparu — le prompt a été copié dans le presse-papiers.", "PromptKey") + return Map("ok", false, "method", method, "error", "Fenêtre cible disparue, prompt copié.") + } + + if (method = "sendtext" && StrLen(text) > 4000) { + TrayTip("Prompt long (" StrLen(text) " caractères) : la frappe simulée sera lente.", "PromptKey") + } + + ; Rendre le focus à la fenêtre d'origine. + if (hwnd) { + try WinActivate("ahk_id " hwnd) + try { + if !WinWaitActive("ahk_id " hwnd, , 0.5) { + A_Clipboard := text + TrayTip("Impossible de réactiver la fenêtre cible — prompt copié dans le presse-papiers.", "PromptKey") + return Map("ok", false, "method", method, "error", "Réactivation de la fenêtre impossible.") + } + } + Sleep(60) + } + + if (method = "sendtext") { + SendText(text) + } else { + this.Guard.Save() + this.Guard.InjectedText := text + A_Clipboard := text + if !ClipWait(0.5) { + this.Guard.RestoreIfUnchanged() + return Map("ok", false, "method", method, "error", "Presse-papiers non prêt.") + } + Send("^v") + Sleep(st.PasteDelayMs) + ; Restauration (ou vidage) différée, conditionnelle (§11.3). + guard := this.Guard + sensitive := options.Has("sensitive") ? options["sensitive"] : 0 + if sensitive + SetTimer(() => guard.WipeIfUnchanged(), -st.RestoreDelayMs) + else + SetTimer(() => guard.RestoreIfUnchanged(), -st.RestoreDelayMs) + } + + ; Marqueur {{cursor}} : replacer le curseur (§10.3). + if (options.Has("cursorFromEnd") && options["cursorFromEnd"] > 0) { + n := options["cursorFromEnd"] + Send("{Left " n "}") + } + + ; Envoi automatique : seulement si le profil le demande explicitement (§12.4, D5). + if (options.Has("sendAfter") && options["sendAfter"]) { + delay := st.SendAfterDelayMs + TrayTip("Envoi automatique dans " Round(delay / 1000, 1) " s — Échap pour annuler.", "PromptKey") + ; Fenêtre d'annulation : on écoute Échap pendant le délai. + cancelled := false + endTime := A_TickCount + delay + while (A_TickCount < endTime) { + if GetKeyState("Escape", "P") { + cancelled := true + break + } + Sleep(25) + } + if (!cancelled) + Send("{Enter}") + else + TrayTip("Envoi annulé — le texte reste en place.", "PromptKey") + } + Util.Log("Injection OK (" method ", " StrLen(text) " caractères)") + return Map("ok", true, "method", method, "error", "") + } +} diff --git a/lib/Json.ahk b/lib/Json.ahk new file mode 100644 index 0000000..c513c8b --- /dev/null +++ b/lib/Json.ahk @@ -0,0 +1,201 @@ +; Json.ahk — Petit parseur / sérialiseur JSON autonome pour PromptKey. +; Utilisé pour usage.json, index-cache.json et l'import/export (architecture §15, §16.1). +; Types : Map -> objet, Array -> tableau, String, Integer/Float, 1/0 (les booléens +; AHK sont des entiers ; à la sérialisation, true/false sont émis comme true/false +; uniquement pour les valeurs stockées via Json.True / Json.False — sinon nombres). + +class Json { + static Parse(text) { + p := _JsonParser(text) + v := p.ParseValue() + p.SkipWs() + return v + } + + static Stringify(value, indent := 0) { + pad := indent > 0 ? StrRepeat(" ", indent) : "" + padIn := indent > 0 ? StrRepeat(" ", indent * 2) : "" + nl := indent > 0 ? "`n" : "" + if (value is Map) { + if (value.Count = 0) + return "{}" + parts := [] + for k, v in value { + parts.Push(padIn Json.EscapeString(String(k)) ": " Json.Stringify(v, indent)) + } + return "{" nl Join(parts, "," nl) nl pad "}" + } + if (value is Array) { + if (value.Length = 0) + return "[]" + parts := [] + for v in value + parts.Push(Json.Stringify(v, indent)) + return "[" Join(parts, ", ") "]" + } + if (value is String) + return Json.EscapeString(value) + if (value is Integer || value is Float) + return String(value) + if (value = "") + return '""' + return Json.EscapeString(String(value)) + } + + static EscapeString(s) { + s := StrReplace(s, "\", "\\") + s := StrReplace(s, '"', '\"') + s := StrReplace(s, "`n", "\n") + s := StrReplace(s, "`r", "\r") + s := StrReplace(s, "`t", "\t") + return '"' s '"' + } +} + +StrRepeat(ch, n) { + out := "" + loop n + out .= ch + return out +} + +Join(arr, sep) { + out := "" + for i, v in arr { + if (i > 1) + out .= sep + out .= v + } + return out +} + +class _JsonParser { + __New(text) { + this.Text := text + this.Pos := 1 + this.Len := StrLen(text) + } + + SkipWs() { + while (this.Pos <= this.Len) { + c := SubStr(this.Text, this.Pos, 1) + if (c = " " || c = "`t" || c = "`n" || c = "`r") + this.Pos++ + else + break + } + } + + Peek() { + return this.Pos <= this.Len ? SubStr(this.Text, this.Pos, 1) : "" + } + + ParseValue() { + this.SkipWs() + c := this.Peek() + if (c = "{") + return this.ParseObject() + if (c = "[") + return this.ParseArray() + if (c = '"') + return this.ParseString() + if (c = "t") { + this.Pos += 4 ; true + return 1 + } + if (c = "f") { + this.Pos += 5 ; false + return 0 + } + if (c = "n") { + this.Pos += 4 ; null + return "" + } + return this.ParseNumber() + } + + ParseObject() { + obj := Map() + this.Pos++ ; { + this.SkipWs() + if (this.Peek() = "}") { + this.Pos++ + return obj + } + loop { + this.SkipWs() + key := this.ParseString() + this.SkipWs() + this.Pos++ ; : + obj[key] := this.ParseValue() + this.SkipWs() + c := this.Peek() + this.Pos++ + if (c = "}") + break + } + return obj + } + + ParseArray() { + arr := [] + this.Pos++ ; [ + this.SkipWs() + if (this.Peek() = "]") { + this.Pos++ + return arr + } + loop { + arr.Push(this.ParseValue()) + this.SkipWs() + c := this.Peek() + this.Pos++ + if (c = "]") + break + } + return arr + } + + ParseString() { + out := "" + this.Pos++ ; guillemet ouvrant + while (this.Pos <= this.Len) { + c := SubStr(this.Text, this.Pos, 1) + this.Pos++ + if (c = '"') + break + if (c = "\") { + e := SubStr(this.Text, this.Pos, 1) + this.Pos++ + switch e { + case "n": out .= "`n" + case "r": out .= "`r" + case "t": out .= "`t" + case "b": out .= Chr(8) + case "f": out .= Chr(12) + case "/": out .= "/" + case "\": out .= "\" + case '"': out .= '"' + case "u": + hex := SubStr(this.Text, this.Pos, 4) + this.Pos += 4 + out .= Chr("0x" hex) + default: out .= e + } + } else { + out .= c + } + } + return out + } + + ParseNumber() { + start := this.Pos + while (this.Pos <= this.Len && RegExMatch(SubStr(this.Text, this.Pos, 1), "[0-9eE+\-.]")) + this.Pos++ + raw := SubStr(this.Text, start, this.Pos - start) + if (InStr(raw, ".") || InStr(raw, "e") || InStr(raw, "E")) + return Float(raw) + return Integer(raw) + } +} diff --git a/lib/PromptStore.ahk b/lib/PromptStore.ahk new file mode 100644 index 0000000..ee23dd0 --- /dev/null +++ b/lib/PromptStore.ahk @@ -0,0 +1,221 @@ +; PromptStore.ahk — Bibliothèque de prompts : un fichier .md par prompt (architecture §7). +; La catégorie est le dossier de premier niveau. L'id (en-tête) est l'identité stable. + +class PromptItem { + Id := "" + Title := "" + Category := "" + Tags := [] + Targets := [] + Favorite := 0 + Pinned := 0 + Hotkey := "" + Hotstring := "" + Profile := "" + SendAfter := "" ; "" = hérite du profil ; 0/1 = surcharge + Sensitive := 0 + Created := "" + Updated := "" + Body := "" + Meta := Map() + Path := "" + ParseError := "" + + __New(path, category, parsed) { + this.Path := path + this.Category := category + meta := parsed["meta"] + this.Meta := meta + this.Body := parsed["body"] + if (parsed["errors"].Length > 0) + this.ParseError := parsed["errors"][1] + SplitPath(path, , , , &nameNoExt) + this.Id := meta.Has("id") ? String(meta["id"]) : "" + this.Title := meta.Has("title") && meta["title"] != "" ? String(meta["title"]) : nameNoExt + this.Tags := meta.Has("tags") && (meta["tags"] is Array) ? meta["tags"] : [] + this.Targets := meta.Has("targets") && (meta["targets"] is Array) ? meta["targets"] : [] + this.Favorite := meta.Has("favorite") ? Truthy(meta["favorite"]) : 0 + this.Pinned := meta.Has("pinned") ? Integer(meta["pinned"]) : 0 + this.Hotkey := meta.Has("hotkey") ? String(meta["hotkey"]) : "" + this.Hotstring := meta.Has("hotstring") ? String(meta["hotstring"]) : "" + this.Profile := meta.Has("profile") ? String(meta["profile"]) : "" + if meta.Has("send_after") + this.SendAfter := Truthy(meta["send_after"]) + this.Sensitive := meta.Has("sensitive") ? Truthy(meta["sensitive"]) : 0 + this.Created := meta.Has("created") ? String(meta["created"]) : "" + this.Updated := meta.Has("updated") ? String(meta["updated"]) : "" + } + + Variables() { + if (this.Meta.Has("variables") && (this.Meta["variables"] is Array)) + return this.Meta["variables"] + return [] + } +} + +class PromptStore { + LibraryPath := "" + Prompts := [] ; [PromptItem] + ErrorFiles := [] ; [PromptItem avec ParseError] + Signature := "" + + __New(libraryPath) { + this.LibraryPath := libraryPath + } + + ; Charge (ou recharge) toute la bibliothèque depuis le disque (encodage UTF-8 explicite, §19.4). + Load() { + this.Prompts := [] + this.ErrorFiles := [] + root := this.LibraryPath + if !DirExist(root) + return + ; Fichiers à la racine = catégorie « Sans catégorie ». + this.LoadFolder(root, "Sans catégorie", false) + ; Dossiers de premier niveau = catégories (sauf _packs). + Loop Files root "\*", "D" { + if (SubStr(A_LoopFileName, 1, 1) = "_") + continue + this.LoadFolder(A_LoopFilePath, A_LoopFileName, false) + } + this.Signature := this.ComputeSignature() + } + + LoadFolder(folder, category, recursive) { + Loop Files folder "\*.md", recursive ? "FR" : "F" { + try { + content := FileRead(A_LoopFilePath, "UTF-8") + } catch { + continue + } + parsed := Frontmatter.Parse(content) + item := PromptItem(A_LoopFilePath, category, parsed) + ; Générer un id manquant et le persister (avec sauvegarde .bak unique, §7.4). + if (item.Id = "") { + item.Id := Util.NewGuid() + item.Meta["id"] := item.Id + try { + if !FileExist(A_LoopFilePath ".bak") + FileCopy(A_LoopFilePath, A_LoopFilePath ".bak") + this.WriteFile(item) + } + } + if (item.ParseError != "") + this.ErrorFiles.Push(item) + else + this.Prompts.Push(item) + } + } + + ComputeSignature() { + sig := 0 + count := 0 + Loop Files this.LibraryPath "\*.md", "FR" { + count++ + sig += A_LoopFileSize + Integer(SubStr(A_LoopFileTimeModified, 9)) + } + return count ":" sig + } + + ChangedOnDisk() { + return this.ComputeSignature() != this.Signature + } + + ById(id) { + for p in this.Prompts { + if (p.Id = id) + return p + } + return "" + } + + Categories() { + seen := Map() + cats := [] + for p in this.Prompts { + if !seen.Has(p.Category) { + seen[p.Category] := true + cats.Push(p.Category) + } + } + return cats + } + + ; Crée un nouveau prompt (fichier) et retourne le PromptItem. + CreateNew(title, category, body) { + if (Trim(category) = "") + category := "Sans catégorie" + folder := category = "Sans catégorie" ? this.LibraryPath : this.LibraryPath "\" category + DirCreate(folder) + path := folder "\" Util.Slugify(title) ".md" + n := 2 + while FileExist(path) { + path := folder "\" Util.Slugify(title) "-" n ".md" + n++ + } + item := PromptItem(path, category, Map("meta", Map(), "body", body, "errors", [], "hasHeader", false)) + item.Id := Util.NewGuid() + item.Title := title + item.Created := Util.Today() + item.Updated := Util.Today() + item.Body := body + this.SyncMeta(item) + this.WriteFile(item) + this.Prompts.Push(item) + this.Signature := this.ComputeSignature() + return item + } + + ; Recopie les champs de l'objet vers la Map Meta (avant sérialisation). + SyncMeta(item) { + m := item.Meta + m["id"] := item.Id + m["title"] := item.Title + m["tags"] := item.Tags + m["targets"] := item.Targets + m["favorite"] := item.Favorite + m["pinned"] := item.Pinned + m["hotkey"] := item.Hotkey + m["hotstring"] := item.Hotstring + m["profile"] := item.Profile + if (item.SendAfter != "") + m["send_after"] := item.SendAfter + m["sensitive"] := item.Sensitive + if (item.Created != "") + m["created"] := item.Created + m["updated"] := Util.Today() + item.Body := item.Body + } + + WriteFile(item) { + this.SyncMeta(item) + text := Frontmatter.Serialize(item.Meta, item.Body) + ; Écriture UTF-8 explicite : FileOpen avec encodage nommé (jamais ANSI par défaut, §19.4). + f := FileOpen(item.Path, "w", "UTF-8") + normalized := StrReplace(text, "`r`n", "`n") ; d'abord LF partout (le corps peut venir de l'éditeur en CRLF) + f.Write(StrReplace(normalized, "`n", "`r`n")) + f.Close() + } + + Save(item) { + this.WriteFile(item) + this.Signature := this.ComputeSignature() + } + + Duplicate(item) { + copy := this.CreateNew(item.Title " (copie)", item.Category, item.Body) + copy.Tags := item.Tags.Clone() + copy.Targets := item.Targets.Clone() + copy.Meta["variables"] := item.Meta.Has("variables") ? item.Meta["variables"] : [] + if (copy.Meta["variables"].Length = 0) + copy.Meta.Delete("variables") + this.Save(copy) + return copy + } + + ; Suppression = corbeille Windows (jamais définitive, §9.1). + Delete(item) { + FileRecycle(item.Path) + this.Load() + } +} diff --git a/lib/Search.ahk b/lib/Search.ahk new file mode 100644 index 0000000..ebbb4f5 --- /dev/null +++ b/lib/Search.ahk @@ -0,0 +1,129 @@ +; Search.ahk — Recherche floue et classement (architecture §14 et Annexe B). +; Fonction pure : score déterministe, insensible aux accents, pondéré par l'usage. + +class Search { + ; usage : Map id -> Map(count, last_used) (peut être vide) + static Filter(prompts, query, usage) { + q := Util.Normalize(Trim(query)) + scored := [] + if (q = "") { + for p in prompts + scored.Push(Map("prompt", p, "score", Search.UsageWeight(p, usage))) + } else { + words := StrSplit(q, " ") + for p in prompts { + total := 0 + ok := true + hayTitle := Util.Normalize(p.Title) + hayTags := Util.Normalize(Join(p.Tags, " ")) + hayCat := Util.Normalize(p.Category) + hayBody := Util.Normalize(SubStr(p.Body, 1, 2000)) + for w in words { + if (w = "") + continue + s := Search.WordScore(w, hayTitle, hayTags, hayCat, hayBody) + if (s = 0) { + ok := false + break + } + total += s + } + if ok + scored.Push(Map("prompt", p, "score", total * Search.UsageWeight(p, usage))) + } + } + ; Tri décroissant par score, égalité : dernier usage puis titre (déterminisme, Annexe B). + SortScored(scored, usage) + out := [] + for entry in scored + out.Push(entry["prompt"]) + return out + } + + static WordScore(w, title, tags, cat, body) { + best := 0 + best := Max(best, Search.FieldScore(w, title) * 4) + best := Max(best, Search.FieldScore(w, tags) * 2) + best := Max(best, Search.FieldScore(w, cat) * 2) + best := Max(best, Search.FieldScore(w, body)) + return best + } + + ; Score d'un champ : sous-séquence floue ; bonus début de mot et mot entier. + static FieldScore(w, field) { + if (field = "") + return 0 + if InStr(field, w) { + score := 50 + ; Mot entier ou début de champ / de mot. + if (SubStr(field, 1, StrLen(w)) = w) + score += 50 + if InStr(field, " " w) + score += 40 + if InStr(field, w " ") || InStr(field, " " w " ") + score += 30 + return score + } + ; Sous-séquence : toutes les lettres de w, dans l'ordre. + pos := 1 + loop parse w { + found := InStr(field, A_LoopField, , pos) + if (!found) + return 0 + pos := found + 1 + } + return 15 + } + + static UsageWeight(p, usage) { + wgt := 1.0 + if (p.Pinned > 0) + wgt *= 1.5 + if (p.Favorite) + wgt *= 1.3 + if (usage is Map && usage.Has(p.Id)) { + u := usage[p.Id] + count := u.Has("count") ? Integer(u["count"]) : 0 + if (count > 0) + wgt *= 1 + (Log(1 + count) / Log(10)) * 0.2 + if u.Has("last_used") { + age := DateDiff(A_Now, u["last_used"], "Days") + if (age <= 7) + wgt *= 1.2 + } + } + return wgt + } +} + +; Tri simple par insertion (les listes sont petites ; la stabilité prime sur la vitesse). +SortScored(scored, usage) { + n := scored.Length + loop n - 1 { + i := A_Index + loop n - i { + j := A_Index + a := scored[j], b := scored[j + 1] + swap := false + if (b["score"] > a["score"]) + swap := true + else if (b["score"] = a["score"]) { + la := LastUsedOf(a["prompt"], usage), lb := LastUsedOf(b["prompt"], usage) + if (lb > la) + swap := true + else if (lb = la && StrCompare(a["prompt"].Title, b["prompt"].Title) > 0) + swap := true + } + if swap { + scored[j] := b + scored[j + 1] := a + } + } + } +} + +LastUsedOf(p, usage) { + if (usage is Map && usage.Has(p.Id) && usage[p.Id].Has("last_used")) + return usage[p.Id]["last_used"] + return "" +} diff --git a/lib/Settings.ahk b/lib/Settings.ahk new file mode 100644 index 0000000..5d79d4c --- /dev/null +++ b/lib/Settings.ahk @@ -0,0 +1,135 @@ +; Settings.ahk — Paramètres de PromptKey (config.ini) et profils de destination. +; Architecture §12 (profils) et §17.1 (paramètres en INI). + +class Settings { + Path := "" + LibraryPath := "" + PickerHotkey := "^!Space" + ManagerHotkey := "^!m" + NewHotkey := "^!n" + ReplayHotkey := "^!v" + Method := "clipboard" ; clipboard | sendtext + PasteDelayMs := 150 + RestoreDelayMs := 1500 + SendAfterDelayMs := 800 + ExternalEditor := "" ; vide = éditeur par défaut de Windows pour .md + Profiles := Map() ; nom -> Map(preambule, postambule, methode, sendAfter, appMatch, titreMatch) + + __New(configPath) { + this.Path := configPath + this.Load() + } + + Load() { + ini := this.Path + if FileExist(ini) { + this.LibraryPath := IniRead(ini, "General", "LibraryPath", "") + this.PickerHotkey := IniRead(ini, "General", "PickerHotkey", this.PickerHotkey) + this.ManagerHotkey := IniRead(ini, "General", "ManagerHotkey", this.ManagerHotkey) + this.NewHotkey := IniRead(ini, "General", "NewHotkey", this.NewHotkey) + this.ReplayHotkey := IniRead(ini, "General", "ReplayHotkey", this.ReplayHotkey) + this.Method := IniRead(ini, "Injection", "Method", this.Method) + this.PasteDelayMs := Integer(IniRead(ini, "Injection", "PasteDelayMs", this.PasteDelayMs)) + this.RestoreDelayMs := Integer(IniRead(ini, "Injection", "RestoreDelayMs", this.RestoreDelayMs)) + this.SendAfterDelayMs := Integer(IniRead(ini, "Injection", "SendAfterDelayMs", this.SendAfterDelayMs)) + this.ExternalEditor := IniRead(ini, "Editeur", "ExternalEditor", "") + } + if (this.LibraryPath = "" || !DirExist(this.LibraryPath)) { + ; Mode portable : dossier Bibliothèque à côté du script. Sinon Documents\PromptKey. + portable := A_ScriptDir "\Bibliothèque" + this.LibraryPath := DirExist(portable) ? portable : A_MyDocuments "\PromptKey\Bibliothèque" + } + DirCreate(this.LibraryPath) + this.LoadProfiles() + } + + Save() { + ini := this.Path + IniWrite(this.LibraryPath, ini, "General", "LibraryPath") + IniWrite(this.PickerHotkey, ini, "General", "PickerHotkey") + IniWrite(this.ManagerHotkey, ini, "General", "ManagerHotkey") + IniWrite(this.NewHotkey, ini, "General", "NewHotkey") + IniWrite(this.ReplayHotkey, ini, "General", "ReplayHotkey") + IniWrite(this.Method, ini, "Injection", "Method") + IniWrite(this.PasteDelayMs, ini, "Injection", "PasteDelayMs") + IniWrite(this.RestoreDelayMs, ini, "Injection", "RestoreDelayMs") + IniWrite(this.SendAfterDelayMs, ini, "Injection", "SendAfterDelayMs") + IniWrite(this.ExternalEditor, ini, "Editeur", "ExternalEditor") + } + + ; Profils intégrés, surchargeables par des sections [Profil.] dans config.ini. + LoadProfiles() { + this.Profiles := Map() + base := Map( + "défaut", Map("preambule", "", "postambule", "", "methode", "", "sendAfter", 0, "appMatch", "", "titreMatch", ""), + "claude", Map("preambule", "", "postambule", "", "methode", "", "sendAfter", 0, "appMatch", "", "titreMatch", "Claude"), + "chatgpt", Map("preambule", "", "postambule", "", "methode", "", "sendAfter", 0, "appMatch", "", "titreMatch", "ChatGPT"), + "gemini", Map("preambule", "", "postambule", "", "methode", "", "sendAfter", 0, "appMatch", "", "titreMatch", "Gemini"), + "qwen", Map("preambule", "", "postambule", "", "methode", "", "sendAfter", 0, "appMatch", "", "titreMatch", "Qwen"), + "deepseek", Map("preambule", "", "postambule", "", "methode", "", "sendAfter", 0, "appMatch", "", "titreMatch", "DeepSeek"), + "terminal", Map("preambule", "", "postambule", "", "methode", "sendtext", "sendAfter", 0, "appMatch", "wt.exe,powershell.exe,cmd.exe,Code.exe", "titreMatch", ""), + "courriel", Map("preambule", "", "postambule", "", "methode", "", "sendAfter", 0, "appMatch", "", "titreMatch", "") + ) + for name, p in base + this.Profiles[name] := p + ; Surcharge INI : lire le fichier et repérer les sections [Profil.xxx]. + if FileExist(this.Path) { + text := FileRead(this.Path, "UTF-8") + current := "" + for line in StrSplit(text, "`n", "`r") { + line := Trim(line) + if RegExMatch(line, "^\[Profil\.(.+)\]$", &m) { + current := StrLower(m[1]) + if !this.Profiles.Has(current) + this.Profiles[current] := Map("preambule", "", "postambule", "", "methode", "", "sendAfter", 0, "appMatch", "", "titreMatch", "") + } else if (current != "" && RegExMatch(line, "^\[")) { + current := "" + } else if (current != "" && InStr(line, "=")) { + pos := InStr(line, "=") + key := StrLower(Trim(SubStr(line, 1, pos - 1))) + val := Trim(SubStr(line, pos + 1)) + val := StrReplace(val, "\n", "`n") + switch key { + case "preambule": this.Profiles[current]["preambule"] := val + case "postambule": this.Profiles[current]["postambule"] := val + case "methode": this.Profiles[current]["methode"] := val + case "sendafter": this.Profiles[current]["sendAfter"] := (val = "true" || val = "1") ? 1 : 0 + case "appmatch": this.Profiles[current]["appMatch"] := val + case "titrematch": this.Profiles[current]["titreMatch"] := val + } + } + } + } + } + + GetProfile(name) { + name := StrLower(Trim(name)) + if (name != "" && this.Profiles.Has(name)) + return this.Profiles[name] + return this.Profiles["défaut"] + } + + ProfileNames() { + names := [] + for name in this.Profiles + names.Push(name) + return names + } + + ; Auto-détection (§12.3, étape 3) : AppMatch (processus) puis TitreMatch (titre de fenêtre). + DetectProfile(processName, windowTitle) { + for name, p in this.Profiles { + if (name = "défaut") + continue + if (p["appMatch"] != "") { + for app in StrSplit(p["appMatch"], ",") { + if (StrLower(Trim(app)) = StrLower(processName)) + return name + } + } + if (p["titreMatch"] != "" && InStr(windowTitle, p["titreMatch"])) + return name + } + return "défaut" + } +} diff --git a/lib/UsageStats.ahk b/lib/UsageStats.ahk new file mode 100644 index 0000000..34cd390 --- /dev/null +++ b/lib/UsageStats.ahk @@ -0,0 +1,75 @@ +; UsageStats.ahk — Compteurs d'usage et derniers choix de variables (architecture §15). +; Fichier usage.json à côté de la bibliothèque. Ne contient JAMAIS de valeurs 'password' +; ni le contenu des variables automatiques (sélection / presse-papiers). + +class UsageStats { + Path := "" + Data := Map() ; id -> Map(count, last_used, last_values: Map) + + __New(path) { + this.Path := path + this.Load() + } + + Load() { + this.Data := Map() + if !FileExist(this.Path) + return + try { + raw := FileRead(this.Path, "UTF-8") + parsed := Json.Parse(raw) + if (parsed is Map) + this.Data := parsed + } catch as e { + Util.Log("usage.json illisible, reparti de zéro : " e.Message) + this.Data := Map() + } + } + + Save() { + try { + f := FileOpen(this.Path, "w", "UTF-8") + f.Write(Json.Stringify(this.Data, 1)) + f.Close() + } catch as e { + Util.Log("Écriture usage.json impossible : " e.Message) + } + } + + RecordUse(id, manualValues := "") { + if !this.Data.Has(id) + this.Data[id] := Map("count", 0, "last_used", "", "last_values", Map()) + entry := this.Data[id] + entry["count"] := (entry.Has("count") ? Integer(entry["count"]) : 0) + 1 + entry["last_used"] := A_Now ; format AHK YYYYMMDDHH24MISS (comparaisons et DateDiff) + if (manualValues is Map) { + if !entry.Has("last_values") || !(entry["last_values"] is Map) + entry["last_values"] := Map() + lv := entry["last_values"] + for name, val in manualValues { + ; Les mots de passe et les contextes ne sont jamais mémorisés (§10.4 / §15). + if (name = "password" || name = "selection" || name = "clipboard") + continue + lv[name] := String(val) + } + } + this.Save() + } + + LastValues(id) { + if (this.Data.Has(id) && this.Data[id].Has("last_values") && (this.Data[id]["last_values"] is Map)) + return this.Data[id]["last_values"] + return Map() + } + + Count(id) { + if (this.Data.Has(id) && this.Data[id].Has("count")) + return Integer(this.Data[id]["count"]) + return 0 + } + + Reset() { + this.Data := Map() + this.Save() + } +} diff --git a/lib/Util.ahk b/lib/Util.ahk new file mode 100644 index 0000000..e9b392a --- /dev/null +++ b/lib/Util.ahk @@ -0,0 +1,100 @@ +; Util.ahk — Fonctions utilitaires de PromptKey (dates, GUID, normalisation, journal). +; Aucune dépendance. Voir le document d'architecture, section 19. + +class Util { + ; Journal local : horodatage + message. JAMAIS de contenu de prompt (architecture §11.6 / §18). + static LogFile := "" + static Log(msg) { + if (Util.LogFile = "") + return + try { + line := FormatTime(, "yyyy-MM-dd HH:mm:ss") " " msg "`r`n" + FileAppend(line, Util.LogFile, "UTF-8") + ; Rotation grossière : au-delà d'environ 1 Mo, on repart de zéro. + if (FileGetSize(Util.LogFile) > 1048576) + FileMove(Util.LogFile, Util.LogFile ".bak", true) + } + } + + static NewGuid() { + ; GUID via l'API Windows (CoCreateGuid), formaté en minuscules avec tirets. + buf := Buffer(16, 0) + DllCall("ole32\CoCreateGuid", "Ptr", buf.Ptr) + g := "" + fmt := [4, 2, 2, 2, 6] ; tailles en octets des groupes 8-4-4-4-12 (hex) + off := 0 + parts := [] + for i, sz in fmt { + hex := "" + ; Le premier groupe est un entier little-endian : on lit les octets dans l'ordre inverse pour les groupes 1 à 3. + if (i <= 3) { + loop sz { + hex := Format("{:02x}", NumGet(buf, off + A_Index - 1, "UChar")) hex + } + } else { + loop sz { + hex .= Format("{:02x}", NumGet(buf, off + A_Index - 1, "UChar")) + } + } + parts.Push(hex) + off += sz + } + return parts[1] "-" parts[2] "-" parts[3] "-" parts[4] "-" parts[5] + } + + static NowIso() { + return FormatTime(, "yyyy-MM-ddTHH:mm:ss") + } + + static Today() { + return FormatTime(, "yyyy-MM-dd") + } + + ; Normalisation pour la recherche : minuscules + suppression des accents (architecture §14.1). + static Normalize(s) { + s := StrLower(s) + static accents := Map( + "à", "a", "á", "a", "â", "a", "ä", "a", "ã", "a", + "è", "e", "é", "e", "ê", "e", "ë", "e", + "ì", "i", "í", "i", "î", "i", "ï", "i", + "ò", "o", "ó", "o", "ô", "o", "ö", "o", "õ", "o", + "ù", "u", "ú", "u", "û", "u", "ü", "u", + "ç", "c", "ñ", "n", "ÿ", "y") + out := "" + loop parse s { + out .= accents.Has(A_LoopField) ? accents[A_LoopField] : A_LoopField + } + return out + } + + ; Tronque proprement pour les aperçus. + static Truncate(s, n) { + if (StrLen(s) <= n) + return s + return SubStr(s, 1, n) "…" + } + + ; Nom de fichier sûr à partir d'un titre (minuscules, tirets, sans accents). + static Slugify(title) { + s := Util.Normalize(Trim(title)) + s := RegExReplace(s, "[^a-z0-9]+", "-") + s := Trim(s, "-") + return (s = "") ? "sans-titre" : s + } + + ; Échappe une valeur pour l'en-tête : guillemets si la valeur contient un caractère spécial du format. + static QuoteIfNeeded(s) { + if (s = "") + return s + if RegExMatch(s, "[,\[\]#:]") || (Trim(s) != s) + return '"' StrReplace(s, '"', '\"') '"' + return s + } + + static Unquote(s) { + s := Trim(s) + if (StrLen(s) >= 2 && SubStr(s, 1, 1) = '"' && SubStr(s, -1) = '"') + s := StrReplace(SubStr(s, 2, StrLen(s) - 2), '\"', '"') + return s + } +} diff --git a/lib/VariableEngine.ahk b/lib/VariableEngine.ahk new file mode 100644 index 0000000..e9f43d1 --- /dev/null +++ b/lib/VariableEngine.ahk @@ -0,0 +1,125 @@ +; VariableEngine.ahk — Variables des prompts : automatiques et manuelles (architecture §10). + +class VariableEngine { + static AutoNames := ["selection", "clipboard", "date", "datetime", "time", "app", "cursor"] + + static IsAuto(name) { + return HasInArray(VariableEngine.AutoNames, StrLower(name)) + } + + ; Toutes les variables {{...}} d'un corps, dans l'ordre d'apparition, dédupliquées. + static FindVars(body) { + found := [] + seen := Map() + pos := 1 + while (m := RegExMatch(body, "\{\{\s*([A-Za-z0-9_]+)\s*\}\}", &match, pos)) { + name := StrLower(match[1]) + if !seen.Has(name) { + seen[name] := true + found.Push(name) + } + pos := m + match.Len[0] + } + return found + } + + ; Variables manuelles requises : celles du corps qui ne sont pas automatiques. + ; Retourne [ Map {name, label, type, choices, default, required, multiline, declared} ]. + static ManualVars(prompt) { + declared := Map() + for v in prompt.Variables() { + if v.Has("name") + declared[StrLower(String(v["name"]))] := v + } + out := [] + for name in VariableEngine.FindVars(prompt.Body) { + if VariableEngine.IsAuto(name) + continue + if declared.Has(name) { + d := declared[name] + out.Push(Map( + "name", name, + "label", d.Has("label") && d["label"] != "" ? String(d["label"]) : name, + "type", d.Has("type") ? String(d["type"]) : "text", + "choices", d.Has("choices") && (d["choices"] is Array) ? d["choices"] : [], + "default", d.Has("default") ? String(d["default"]) : "", + "required", d.Has("required") ? Truthy(d["required"]) : 0, + "multiline", d.Has("multiline") ? Truthy(d["multiline"]) : 0, + "declared", 1)) + } else { + out.Push(Map("name", name, "label", name, "type", "text", "choices", [], "default", "", "required", 0, "multiline", 0, "declared", 0)) + } + } + return out + } + + ; Valeurs automatiques à partir du contexte d'invocation (figées à l'invocation, §10.3). + static AutoValues(ctx) { + vals := Map() + vals["selection"] := ctx.Has("selection") ? ctx["selection"] : "" + vals["clipboard"] := ctx.Has("clipboardText") ? ctx["clipboardText"] : "" + vals["date"] := FormatTime(, "yyyy-MM-dd") + vals["datetime"] := FormatTime(, "yyyy-MM-dd HH:mm") + vals["time"] := FormatTime(, "HH:mm") + vals["app"] := ctx.Has("process") ? ctx["process"] : "" + return vals + } + + ; Résolution (§10.5). Retourne Map { text, unresolved: [], cursorFromEnd: Integer }. + static Resolve(body, manualValues, ctx) { + auto := VariableEngine.AutoValues(ctx) + text := body + unresolved := [] + ; Remplacement variable par variable (toutes occurrences). + for name in VariableEngine.FindVars(body) { + token := "" + if (name = "cursor") + continue + if auto.Has(name) + value := auto[name] + else if manualValues.Has(name) + value := String(manualValues[name]) + else { + unresolved.Push(name) + continue + } + text := RegExReplace(text, "\{\{\s*" name "\s*\}\}", VariableEngine.EscapeReplacement(value)) + } + ; Marqueur de curseur : on retient la distance à la fin, puis on retire le marqueur. + cursorFromEnd := 0 + if RegExMatch(text, "\{\{\s*cursor\s*\}\}", &cm) { + cursorFromEnd := StrLen(text) - (cm.Pos[0] + cm.Len[0] - 1) + text := StrReplace(text, cm[0], "") + } + return Map("text", text, "unresolved", unresolved, "cursorFromEnd", cursorFromEnd) + } + + ; Dans RegExReplace, $ a un sens spécial : on le neutralise pour insérer le texte tel quel. + static EscapeReplacement(value) { + return StrReplace(value, "$", "$$") + } + + ; Enveloppe du profil (§12.2) : préambule + corps + postambule. + static ApplyProfile(text, profile) { + out := text + pre := profile.Has("preambule") ? profile["preambule"] : "" + post := profile.Has("postambule") ? profile["postambule"] : "" + if (pre != "") + out := pre "`n`n" out + if (post != "") + out := out "`n`n" post + return out + } +} + +; Vrai pour 1, "1", "true" (insensible à la casse) — valeurs lues depuis l'en-tête. +Truthy(v) { + if (v = 1) + return 1 + if (v is String) { + lv := StrLower(v) + if (lv = "true" || lv = "1" || lv = "oui") + return 1 + } + return 0 +}