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:
+259
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user