c468b24d2b
Registry (issue 0130a):
- 5 fns infra: parse_issue_md, write_issue_md, scan_issues_dir,
scan_flows_dir, watch_dir_fsnotify
- 3 tipos: Issue, Flow, FsEvent
- Tests round-trip + scan reales + watcher fsnotify (all PASS)
- Capability group 'kanban' nuevo (docs/capabilities/kanban.md)
Apps:
- apps/kanban_cpp/ (sub-repo) — frontend ImGui: board drag-drop,
flows, filters, detail con CSV editors
- apps/kanban_cpp/backend/ — Go service port 8487: REST + SSE +
fsnotify watcher, parser bidireccional MD<->SQLite cache
Issues:
- dev/issues/0130-kanban-cpp-v2.md (epic)
- 0130a parser, 0130b backend, 0130c frontend
CMakeLists.txt: add_subdirectory apps/kanban_cpp (registrado por
init_cpp_app scaffolder).
End-to-end verde: backend devuelve 189 issues + 9 flows; PATCH a
/api/issues/{id} reescribe .md (solo frontmatter, body intacto);
frontend --self-test exit 0; tests Go infra 5/5 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
88 lines
2.3 KiB
Go
88 lines
2.3 KiB
Go
package infra
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ParseIssueMd lee un archivo Markdown de issue, extrae y parsea el frontmatter YAML
|
|
// en un struct Issue, y devuelve el body (todo lo que va despues del segundo "---").
|
|
// FilePath e MtimeNs se rellenan con los valores del archivo en disco.
|
|
// Completed se deduce del path (contiene "/completed/").
|
|
func ParseIssueMd(path string) (Issue, []byte, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Issue{}, nil, fmt.Errorf("parse_issue_md: read %s: %w", path, err)
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return Issue{}, nil, fmt.Errorf("parse_issue_md: stat %s: %w", path, err)
|
|
}
|
|
|
|
fm, body, err := splitFrontmatter(data)
|
|
if err != nil {
|
|
return Issue{}, nil, fmt.Errorf("parse_issue_md: %s: %w", path, err)
|
|
}
|
|
|
|
var iss Issue
|
|
if err := yaml.Unmarshal(fm, &iss); err != nil {
|
|
return Issue{}, nil, fmt.Errorf("parse_issue_md: yaml %s: %w", path, err)
|
|
}
|
|
|
|
iss.FilePath = path
|
|
iss.MtimeNs = info.ModTime().UnixNano()
|
|
iss.Completed = strings.Contains(path, "/completed/")
|
|
|
|
return iss, body, nil
|
|
}
|
|
|
|
// splitFrontmatter divide el contenido en bloque YAML y body.
|
|
// Espera formato: "---\n<yaml>\n---\n<body>".
|
|
// Devuelve el YAML (sin los delimitadores) y el body (incluye el \n posterior al segundo ---).
|
|
func splitFrontmatter(data []byte) ([]byte, []byte, error) {
|
|
sep := []byte("---")
|
|
newline := []byte("\n")
|
|
|
|
// El archivo debe empezar con "---\n"
|
|
if !bytes.HasPrefix(data, append(sep, '\n')) {
|
|
return nil, nil, fmt.Errorf("missing opening '---' delimiter")
|
|
}
|
|
|
|
// Buscar el segundo "---" (en su propia linea)
|
|
rest := data[len(sep)+1:] // avanza pasado el primer "---\n"
|
|
|
|
idx := -1
|
|
for i := 0; i <= len(rest)-len(sep); i++ {
|
|
// Debe estar al inicio de linea: posicion 0 o precedido por '\n'
|
|
atLineStart := i == 0 || rest[i-1] == '\n'
|
|
if atLineStart && bytes.Equal(rest[i:i+len(sep)], sep) {
|
|
// El separador debe ir seguido de '\n' o EOF
|
|
end := i + len(sep)
|
|
if end == len(rest) || rest[end] == '\n' {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if idx == -1 {
|
|
return nil, nil, fmt.Errorf("missing closing '---' delimiter")
|
|
}
|
|
|
|
fm := rest[:idx]
|
|
// El body empieza despues del segundo "---\n"
|
|
bodyStart := idx + len(sep)
|
|
if bodyStart < len(rest) && rest[bodyStart] == '\n' {
|
|
bodyStart++
|
|
}
|
|
body := rest[bodyStart:]
|
|
|
|
_ = newline
|
|
return fm, body, nil
|
|
}
|