Merge pull request 'feat(api): macro indexing from .kc files and read-only API' (#152) from feat/kc-macros into main

Reviewed-on: #152
This commit was merged in pull request #152.
This commit is contained in:
2026-02-19 01:06:00 +00:00
6 changed files with 245 additions and 0 deletions

View File

@@ -57,6 +57,7 @@ type Server struct {
settings *db.SettingsRepository settings *db.SettingsRepository
metadata *db.ItemMetadataRepository metadata *db.ItemMetadataRepository
deps *db.ItemDependencyRepository deps *db.ItemDependencyRepository
macros *db.ItemMacroRepository
} }
// NewServer creates a new API server. // NewServer creates a new API server.
@@ -87,6 +88,7 @@ func NewServer(
locations := db.NewLocationRepository(database) locations := db.NewLocationRepository(database)
metadata := db.NewItemMetadataRepository(database) metadata := db.NewItemMetadataRepository(database)
itemDeps := db.NewItemDependencyRepository(database) itemDeps := db.NewItemDependencyRepository(database)
itemMacros := db.NewItemMacroRepository(database)
seqStore := &dbSequenceStore{db: database, schemas: schemas} seqStore := &dbSequenceStore{db: database, schemas: schemas}
partgen := partnum.NewGenerator(schemas, seqStore) partgen := partnum.NewGenerator(schemas, seqStore)
@@ -117,6 +119,7 @@ func NewServer(
settings: settings, settings: settings,
metadata: metadata, metadata: metadata,
deps: itemDeps, deps: itemDeps,
macros: itemMacros,
} }
} }

View File

@@ -0,0 +1,95 @@
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// MacroListItem is the JSON representation for GET /macros list entries.
type MacroListItem struct {
Filename string `json:"filename"`
Trigger string `json:"trigger"`
RevisionNumber int `json:"revision_number"`
}
// MacroResponse is the JSON representation for GET /macros/{filename}.
type MacroResponse struct {
Filename string `json:"filename"`
Trigger string `json:"trigger"`
Content string `json:"content"`
RevisionNumber int `json:"revision_number"`
}
// HandleGetMacros returns the list of registered macros for an item.
// GET /api/items/{partNumber}/macros
func (s *Server) HandleGetMacros(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
macros, err := s.macros.ListByItem(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list macros")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list macros")
return
}
resp := make([]MacroListItem, len(macros))
for i, m := range macros {
resp[i] = MacroListItem{
Filename: m.Filename,
Trigger: m.Trigger,
RevisionNumber: m.RevisionNumber,
}
}
writeJSON(w, http.StatusOK, resp)
}
// HandleGetMacro returns a single macro's source content.
// GET /api/items/{partNumber}/macros/{filename}
func (s *Server) HandleGetMacro(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
filename := chi.URLParam(r, "filename")
item, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
macro, err := s.macros.GetByFilename(ctx, item.ID, filename)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get macro")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get macro")
return
}
if macro == nil {
writeError(w, http.StatusNotFound, "not_found", "Macro not found")
return
}
writeJSON(w, http.StatusOK, MacroResponse{
Filename: macro.Filename,
Trigger: macro.Trigger,
Content: macro.Content,
RevisionNumber: macro.RevisionNumber,
})
}

View File

@@ -430,6 +430,27 @@ func (s *Server) extractKCMetadata(ctx context.Context, item *db.Item, fileKey s
} }
} }
// Index macros from silo/macros/*.
if len(result.Macros) > 0 {
dbMacros := make([]*db.ItemMacro, len(result.Macros))
for i, m := range result.Macros {
dbMacros[i] = &db.ItemMacro{
ItemID: item.ID,
Filename: m.Filename,
Trigger: "manual",
Content: m.Content,
}
}
if err := s.macros.ReplaceForItem(ctx, item.ID, rev.RevisionNumber, dbMacros); err != nil {
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to index macros")
} else {
s.broker.Publish("macros.changed", mustMarshal(map[string]any{
"part_number": item.PartNumber,
"count": len(dbMacros),
}))
}
}
s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully") s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully")
} }

View File

@@ -175,6 +175,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Get("/metadata", server.HandleGetMetadata) r.Get("/metadata", server.HandleGetMetadata)
r.Get("/dependencies", server.HandleGetDependencies) r.Get("/dependencies", server.HandleGetDependencies)
r.Get("/dependencies/resolve", server.HandleResolveDependencies) r.Get("/dependencies/resolve", server.HandleResolveDependencies)
r.Get("/macros", server.HandleGetMacros)
r.Get("/macros/{filename}", server.HandleGetMacro)
// DAG (gated by dag module) // DAG (gated by dag module)
r.Route("/dag", func(r chi.Router) { r.Route("/dag", func(r chi.Router) {

View File

@@ -0,0 +1,93 @@
package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ItemMacro represents a row in the item_macros table.
type ItemMacro struct {
ID string
ItemID string
Filename string
Trigger string
Content string
RevisionNumber int
CreatedAt time.Time
}
// ItemMacroRepository provides item_macros database operations.
type ItemMacroRepository struct {
db *DB
}
// NewItemMacroRepository creates a new item macro repository.
func NewItemMacroRepository(db *DB) *ItemMacroRepository {
return &ItemMacroRepository{db: db}
}
// ReplaceForItem atomically replaces all macros for an item.
// Deletes existing rows and inserts the new set.
func (r *ItemMacroRepository) ReplaceForItem(ctx context.Context, itemID string, revisionNumber int, macros []*ItemMacro) error {
return r.db.Tx(ctx, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `DELETE FROM item_macros WHERE item_id = $1`, itemID)
if err != nil {
return fmt.Errorf("deleting old macros: %w", err)
}
for _, m := range macros {
_, err := tx.Exec(ctx, `
INSERT INTO item_macros (item_id, filename, trigger, content, revision_number)
VALUES ($1, $2, $3, $4, $5)
`, itemID, m.Filename, m.Trigger, m.Content, revisionNumber)
if err != nil {
return fmt.Errorf("inserting macro %s: %w", m.Filename, err)
}
}
return nil
})
}
// ListByItem returns all macros for an item (without content), ordered by filename.
func (r *ItemMacroRepository) ListByItem(ctx context.Context, itemID string) ([]*ItemMacro, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, item_id, filename, trigger, revision_number, created_at
FROM item_macros
WHERE item_id = $1
ORDER BY filename
`, itemID)
if err != nil {
return nil, fmt.Errorf("listing macros: %w", err)
}
defer rows.Close()
var macros []*ItemMacro
for rows.Next() {
m := &ItemMacro{}
if err := rows.Scan(&m.ID, &m.ItemID, &m.Filename, &m.Trigger, &m.RevisionNumber, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning macro: %w", err)
}
macros = append(macros, m)
}
return macros, nil
}
// GetByFilename returns a single macro by item ID and filename, including content.
func (r *ItemMacroRepository) GetByFilename(ctx context.Context, itemID string, filename string) (*ItemMacro, error) {
m := &ItemMacro{}
err := r.db.pool.QueryRow(ctx, `
SELECT id, item_id, filename, trigger, content, revision_number, created_at
FROM item_macros
WHERE item_id = $1 AND filename = $2
`, itemID, filename).Scan(&m.ID, &m.ItemID, &m.Filename, &m.Trigger, &m.Content, &m.RevisionNumber, &m.CreatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting macro: %w", err)
}
return m, nil
}

View File

@@ -40,11 +40,18 @@ type Dependency struct {
Relationship string `json:"relationship"` Relationship string `json:"relationship"`
} }
// MacroFile represents a script file found under silo/macros/.
type MacroFile struct {
Filename string
Content string
}
// ExtractResult holds the parsed silo/ directory contents from a .kc file. // ExtractResult holds the parsed silo/ directory contents from a .kc file.
type ExtractResult struct { type ExtractResult struct {
Manifest *Manifest Manifest *Manifest
Metadata *Metadata Metadata *Metadata
Dependencies []Dependency Dependencies []Dependency
Macros []MacroFile
} }
// HistoryEntry represents one entry in silo/history.json. // HistoryEntry represents one entry in silo/history.json.
@@ -76,6 +83,7 @@ func Extract(data []byte) (*ExtractResult, error) {
} }
var manifestFile, metadataFile, dependenciesFile *zip.File var manifestFile, metadataFile, dependenciesFile *zip.File
var macroFiles []*zip.File
hasSiloDir := false hasSiloDir := false
for _, f := range r.File { for _, f := range r.File {
@@ -89,6 +97,13 @@ func Extract(data []byte) (*ExtractResult, error) {
metadataFile = f metadataFile = f
case "silo/dependencies.json": case "silo/dependencies.json":
dependenciesFile = f dependenciesFile = f
default:
if strings.HasPrefix(f.Name, "silo/macros/") && !f.FileInfo().IsDir() {
name := strings.TrimPrefix(f.Name, "silo/macros/")
if name != "" {
macroFiles = append(macroFiles, f)
}
}
} }
} }
@@ -124,6 +139,22 @@ func Extract(data []byte) (*ExtractResult, error) {
} }
} }
for _, mf := range macroFiles {
rc, err := mf.Open()
if err != nil {
return nil, fmt.Errorf("kc: open macro %s: %w", mf.Name, err)
}
content, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("kc: read macro %s: %w", mf.Name, err)
}
result.Macros = append(result.Macros, MacroFile{
Filename: strings.TrimPrefix(mf.Name, "silo/macros/"),
Content: string(content),
})
}
return result, nil return result, nil
} }