feat(api): macro indexing from .kc files and read-only API
- Add MacroFile type to internal/kc and extract silo/macros/* files
from .kc ZIP archives on commit
- Create ItemMacroRepository with ReplaceForItem, ListByItem, and
GetByFilename methods
- Add GET /{partNumber}/macros (list) and
GET /{partNumber}/macros/{filename} (source content) endpoints
- Index macros in extractKCMetadata with SSE broadcast
- List endpoint omits content for lightweight responses
Closes #144
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
internal/api/macro_handlers.go
Normal file
95
internal/api/macro_handlers.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
93
internal/db/item_macros.go
Normal file
93
internal/db/item_macros.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user