1package workbench23import (4 "crypto/sha256"5 "encoding/base64"6 "encoding/hex"7 "fmt"8 "net/http"9 "sort"10 "strconv"11 "strings"1213 "github.com/FelineStateMachine/atlas/format/semconv"14 "github.com/FelineStateMachine/atlas/format/vnext"15 "github.com/FelineStateMachine/atlas/internal/authoring"16)1718// artifactPage is a semantic projection of a validated native Atlas file. It19// is intentionally built from vnext.Reader.Volume rather than the authoring20// manifest, so this page describes what was actually packed and reopened.21type artifactPage struct {22 File string23 Descriptor vnext.Descriptor24 TableCount int25 BlobCount int26 RuntimeTypes int27 Tables []artifactTable28 BlobNames []string29 Schema []artifactSchemaType30 Worlds []artifactWorld31 Assets []artifactAsset32 Receipt *artifactReceipt33 ReceiptErr string34}3536type artifactTable struct {37 Name string38 Rows int39 Columns int40}4142type artifactSchemaType struct {43 ID string44 Name string45 Fields []artifactField46}4748type artifactWorld struct {49 ID string50 Title string51 CoordinateSpace vnext.CoordinateSpace52 Claims []artifactProperty53 FeatureSets []artifactFeatureSet54 Rasters []artifactRaster55 Presentation artifactPresentation56 Features int57}5859type artifactFeatureSet struct {60 ID string61 Title string62 SemanticType string63 Geometry string64 Properties []artifactField65 Claims []artifactProperty66 Features []artifactFeature67 FeatureCount int68 Offset int69 Previous int70 Next int71 HasPrevious bool72 HasNext bool73 Selected *artifactFeature74}7576type artifactField struct {77 ID string78 Name string79 Kind string80 Optional bool81}8283type artifactFeature struct {84 ID string85 Title string86 Subtitle string87 Description string88 Geometry artifactGeometry89 Properties []artifactProperty90 Relationships []artifactRelationship91 Provenance []vnext.Provenance92}9394type artifactGeometry struct {95 Kind string96 Parts int97 Rings int98 Vertices int99 Bounds [4]float64100 HasData bool101}102103type artifactProperty struct {104 ID string105 Name string106 Kind string107 Value string108}109110type artifactRelationship struct {111 Predicate string112 Target string113 Title string114}115116type artifactRaster struct {117 ID string118 Name string119 Codec string120 TileSize int64121 MinZoom int64122 MaxZoom int64123 FullZoom int64124 Formats []string125 Coverage int126 TileBlobs int127 Template string128 Interpolate bool129 Background string130}131132type artifactPresentation struct {133 ID string134 Title string135 Layers []vnext.Layer136 Styles []vnext.Style137 Legend []vnext.LegendEntry138}139140type artifactAsset struct {141 ID string142 MediaType string143 Bytes int144 Provenance string145}146147type artifactReceipt struct {148 Project string149 ProjectDigest string150 Captures []authoring.SelectedCapture151}152153func (w *Workbench) handleProjectInspect(rw http.ResponseWriter, request *http.Request) {154 artifact, err := w.openCompletedArtifact()155 if err != nil {156 http.Error(rw, err.Error(), http.StatusBadRequest)157 return158 }159 defer artifact.Close()160 offset, _ := strconv.Atoi(request.URL.Query().Get("offset"))161 if offset < 0 {162 offset = 0163 }164 page, err := inspectArtifact(artifact, request.URL.Query().Get("set"), request.URL.Query().Get("feature"), offset)165 if err != nil {166 http.Error(rw, err.Error(), http.StatusInternalServerError)167 return168 }169 w.render(rw, "inspector", page)170}171172func inspectArtifact(artifact *completedArtifact, selectedSet, selectedFeature string, selectedOffset int) (artifactPage, error) {173 volume, err := artifact.reader.Volume()174 if err != nil {175 return artifactPage{}, fmt.Errorf("restore native artifact: %w", err)176 }177 page := artifactPage{178 File: artifact.name, Descriptor: artifact.descriptor,179 TableCount: len(artifact.reader.TableNames()), BlobCount: len(artifact.reader.BlobNames()),180 RuntimeTypes: len(artifact.reader.RuntimeSchema.Types),181 }182 for _, name := range artifact.reader.TableNames() {183 table, err := artifact.reader.Table(name)184 if err != nil {185 return artifactPage{}, fmt.Errorf("inspect table %s: %w", name, err)186 }187 page.Tables = append(page.Tables, artifactTable{Name: name, Rows: table.Rows, Columns: len(table.Columns)})188 }189 for index, name := range artifact.reader.BlobNames() {190 if index == 50 {191 break192 }193 page.BlobNames = append(page.BlobNames, name)194 }195 for _, typ := range artifact.reader.FileSchema.Types {196 held := artifactSchemaType{ID: typ.ID.String(), Name: typ.Name}197 for _, field := range typ.Fields {198 held.Fields = append(held.Fields, artifactField{199 ID: field.ID.String(), Name: field.Name, Kind: kindName(field.Kind), Optional: field.Optional,200 })201 }202 page.Schema = append(page.Schema, held)203 }204 titles := featureTitles(volume)205 for _, world := range volume.Worlds {206 view := artifactWorld{207 ID: world.ID, Title: world.Title, CoordinateSpace: world.CoordinateSpace,208 Claims: inspectProperties(world.Claims),209 Presentation: artifactPresentation{210 ID: world.Presentation.ID, Title: world.Presentation.Title,211 Layers: world.Presentation.Layers, Styles: world.Presentation.Styles, Legend: world.Presentation.Legend,212 },213 }214 for _, set := range world.FeatureSets {215 contract := artifactFeatureSet{216 ID: set.ID, Title: set.Title, SemanticType: set.SemanticType,217 Geometry: geometryContract(set), Claims: inspectProperties(set.Claims), FeatureCount: len(set.Features),218 }219 for _, field := range set.Properties {220 contract.Properties = append(contract.Properties, artifactField{221 ID: field.ID.String(), Name: field.Name, Kind: kindName(field.Kind), Optional: field.Optional,222 })223 }224 offset := 0225 if set.ID == selectedSet {226 offset = selectedOffset227 }228 if offset >= len(set.Features) && len(set.Features) > 0 {229 offset = (len(set.Features) - 1) / 25 * 25230 }231 contract.Offset = offset232 contract.HasPrevious = offset > 0233 contract.Previous = max(0, offset-25)234 contract.HasNext = offset+25 < len(set.Features)235 contract.Next = offset + 25236 for index, feature := range set.Features {237 item := inspectFeature(feature, titles)238 if set.ID == selectedSet && feature.ID == selectedFeature {239 selected := item240 contract.Selected = &selected241 }242 if index >= offset && index < offset+25 {243 contract.Features = append(contract.Features, item)244 }245 }246 view.Features += len(set.Features)247 view.FeatureSets = append(view.FeatureSets, contract)248 }249 for _, raster := range world.RasterPyramids {250 view.Rasters = append(view.Rasters, artifactRaster{251 ID: raster.ID, Name: raster.Name, Codec: raster.Codec, TileSize: raster.TileSize,252 MinZoom: raster.MinZoom, MaxZoom: raster.MaxZoom, FullZoom: raster.FullZoom,253 Formats: raster.Formats, Coverage: len(raster.Coverage), TileBlobs: countBlobPrefix(artifact.reader.BlobNames(), raster.Template),254 Template: raster.Template, Interpolate: raster.Interpolate, Background: raster.Background,255 })256 }257 page.Worlds = append(page.Worlds, view)258 }259 for _, asset := range volume.Assets {260 page.Assets = append(page.Assets, artifactAsset{261 ID: asset.ID, MediaType: asset.MediaType, Bytes: len(asset.Data), Provenance: asset.Provenance,262 })263 if asset.ID == "build-receipt" {264 selection, err := inspectEvidenceSelection(asset.Data)265 if err != nil {266 page.ReceiptErr = err.Error()267 } else {268 page.Receipt = &artifactReceipt{269 Project: selection.Project, ProjectDigest: selection.ProjectDigest, Captures: selection.Captures,270 }271 }272 }273 }274 return page, nil275}276277func countBlobPrefix(names []string, template string) int {278 prefix := template279 if marker := strings.IndexByte(prefix, '{'); marker >= 0 {280 prefix = prefix[:marker]281 }282 if prefix == "" {283 return 0284 }285 count := 0286 for _, name := range names {287 if strings.HasPrefix(name, prefix) {288 count++289 }290 }291 return count292}293294func inspectEvidenceSelection(data []byte) (authoring.EvidenceSelection, error) {295 selection, err := authoring.ParseEvidenceSelection(data)296 if err != nil {297 return selection, fmt.Errorf("receipt metadata is invalid: %w", err)298 }299 return selection, nil300}301302func isLowerSHA256(value string) bool {303 if len(value) != sha256.Size*2 || value != strings.ToLower(value) {304 return false305 }306 _, err := hex.DecodeString(value)307 return err == nil308}309310func inspectFeature(feature vnext.Feature, titles map[string]string) artifactFeature {311 item := artifactFeature{312 ID: feature.ID, Title: feature.Title, Subtitle: feature.Subtitle, Description: feature.Description,313 Geometry: inspectGeometry(feature.Geometry), Properties: inspectProperties(feature.Properties),314 Provenance: feature.Provenance,315 }316 for _, relationship := range feature.Relationships {317 item.Relationships = append(item.Relationships, artifactRelationship{318 Predicate: relationship.Predicate, Target: relationship.Target, Title: titles[relationship.Target],319 })320 }321 return item322}323324func featureTitles(volume vnext.Volume) map[string]string {325 titles := make(map[string]string)326 for _, world := range volume.Worlds {327 for _, set := range world.FeatureSets {328 for _, feature := range set.Features {329 titles[feature.ID] = feature.Title330 }331 }332 }333 return titles334}335336func geometryContract(set vnext.FeatureSet) string {337 for _, claim := range set.Claims {338 if claim.Field.Name == semconv.KeyGeometryKind && claim.Value.Kind == vnext.KindString {339 return claim.Value.String340 }341 }342 if len(set.Features) > 0 {343 return geometryKindName(set.Features[0].Geometry.Kind)344 }345 return "undeclared"346}347348func inspectGeometry(geometry vnext.Geometry) artifactGeometry {349 view := artifactGeometry{Kind: geometryKindName(geometry.Kind), Parts: len(geometry.Parts)}350 for _, part := range geometry.Parts {351 view.Rings += len(part.Rings)352 for _, ring := range part.Rings {353 for _, position := range ring {354 view.Vertices++355 if !view.HasData {356 view.Bounds = [4]float64{position[0], position[1], position[0], position[1]}357 view.HasData = true358 continue359 }360 view.Bounds[0] = min(view.Bounds[0], position[0])361 view.Bounds[1] = min(view.Bounds[1], position[1])362 view.Bounds[2] = max(view.Bounds[2], position[0])363 view.Bounds[3] = max(view.Bounds[3], position[1])364 }365 }366 }367 return view368}369370func inspectProperties(values []vnext.Property) []artifactProperty {371 out := make([]artifactProperty, 0, len(values))372 for _, property := range values {373 out = append(out, artifactProperty{374 ID: property.FieldID.String(), Name: property.Field.Name,375 Kind: kindName(property.Value.Kind), Value: valueString(property.Value),376 })377 }378 sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })379 return out380}381382func geometryKindName(kind vnext.GeometryKind) string {383 switch kind {384 case vnext.GeometryPoint:385 return "point"386 case vnext.GeometryLineString:387 return "path"388 case vnext.GeometryPolygon:389 return "area"390 default:391 return "unknown"392 }393}394395func kindName(kind vnext.Kind) string {396 switch kind {397 case vnext.KindBool:398 return "bool"399 case vnext.KindInt64:400 return "int64"401 case vnext.KindFloat64:402 return "float64"403 case vnext.KindString:404 return "string"405 case vnext.KindBytes:406 return "bytes"407 case vnext.KindID:408 return "id"409 default:410 return "null"411 }412}413414func valueString(value vnext.Value) string {415 switch value.Kind {416 case vnext.KindBool:417 return fmt.Sprintf("%t", value.Bool)418 case vnext.KindInt64:419 return fmt.Sprintf("%d", value.Int64)420 case vnext.KindFloat64:421 return fmt.Sprintf("%g", value.Float64)422 case vnext.KindString:423 return value.String424 case vnext.KindBytes:425 digest := sha256.Sum256(value.Bytes)426 prefix := value.Bytes427 truncated := ""428 if len(prefix) > 16 {429 prefix = prefix[:16]430 truncated = "…"431 }432 return fmt.Sprintf("%d bytes · sha256 %x · %s%s", len(value.Bytes), digest[:6], base64.StdEncoding.EncodeToString(prefix), truncated)433 case vnext.KindID:434 return value.ID.String()435 default:436 return ""437 }438}