Files
promptkey/lib/Frontmatter.ahk
Spark b102a4a206 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
2026-10-11 10:17:30 -04:00

209 lines
7.8 KiB
AutoHotkey

; 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
}