From 6e6c9c2c75487aa7680d7e898a0744e6734d7022 Mon Sep 17 00:00:00 2001 From: Forbes Date: Wed, 18 Feb 2026 19:03:44 -0600 Subject: [PATCH] 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 --- internal/api/handlers.go | 3 + internal/api/macro_handlers.go | 95 +++++++++++++++++++++++++++++++ internal/api/metadata_handlers.go | 21 +++++++ internal/api/routes.go | 2 + internal/db/item_macros.go | 93 ++++++++++++++++++++++++++++++ internal/kc/kc.go | 31 ++++++++++ 6 files changed, 245 insertions(+) create mode 100644 internal/api/macro_handlers.go create mode 100644 internal/db/item_macros.go diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 98bb9fd..69dc0a4 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -57,6 +57,7 @@ type Server struct { settings *db.SettingsRepository metadata *db.ItemMetadataRepository deps *db.ItemDependencyRepository + macros *db.ItemMacroRepository } // NewServer creates a new API server. @@ -87,6 +88,7 @@ func NewServer( locations := db.NewLocationRepository(database) metadata := db.NewItemMetadataRepository(database) itemDeps := db.NewItemDependencyRepository(database) + itemMacros := db.NewItemMacroRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) @@ -117,6 +119,7 @@ func NewServer( settings: settings, metadata: metadata, deps: itemDeps, + macros: itemMacros, } } diff --git a/internal/api/macro_handlers.go b/internal/api/macro_handlers.go new file mode 100644 index 0000000..791043b --- /dev/null +++ b/internal/api/macro_handlers.go @@ -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, + }) +} diff --git a/internal/api/metadata_handlers.go b/internal/api/metadata_handlers.go index 8b134fb..4ed2f1f 100644 --- a/internal/api/metadata_handlers.go +++ b/internal/api/metadata_handlers.go @@ -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") } diff --git a/internal/api/routes.go b/internal/api/routes.go index f4b6999..8cd96fb 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -175,6 +175,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Get("/metadata", server.HandleGetMetadata) r.Get("/dependencies", server.HandleGetDependencies) r.Get("/dependencies/resolve", server.HandleResolveDependencies) + r.Get("/macros", server.HandleGetMacros) + r.Get("/macros/{filename}", server.HandleGetMacro) // DAG (gated by dag module) r.Route("/dag", func(r chi.Router) { diff --git a/internal/db/item_macros.go b/internal/db/item_macros.go new file mode 100644 index 0000000..37ff66e --- /dev/null +++ b/internal/db/item_macros.go @@ -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 +} diff --git a/internal/kc/kc.go b/internal/kc/kc.go index 4865a93..c10a38e 100644 --- a/internal/kc/kc.go +++ b/internal/kc/kc.go @@ -40,11 +40,18 @@ type Dependency struct { 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. type ExtractResult struct { Manifest *Manifest Metadata *Metadata Dependencies []Dependency + Macros []MacroFile } // 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 macroFiles []*zip.File hasSiloDir := false for _, f := range r.File { @@ -89,6 +97,13 @@ func Extract(data []byte) (*ExtractResult, error) { metadataFile = f case "silo/dependencies.json": 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 } -- 2.49.1