feat(core): moteur PromptKey — bibliothèque .md, variables, injection, raccourcis

- 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
This commit is contained in:
Spark
2026-10-11 10:17:30 -04:00
commit b102a4a206
12 changed files with 1695 additions and 0 deletions
+259
View File
@@ -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 : <Catégorie>\<fichier>.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()
+76
View File
@@ -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
}
}
+208
View File
@@ -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
}
+72
View File
@@ -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)
}
}
}
+94
View File
@@ -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", "")
}
}
+201
View File
@@ -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)
}
}
+221
View File
@@ -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()
}
}
+129
View File
@@ -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 ""
}
+135
View File
@@ -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.<nom>] 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"
}
}
+75
View File
@@ -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()
}
}
+100
View File
@@ -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
}
}
+125
View File
@@ -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
}