Files
promptkey/lib/PromptStore.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

222 lines
7.3 KiB
AutoHotkey

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