clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / internal / workbench / project.go
project.go 11.5K
1package workbench23import (4 "bytes"5 "fmt"6 "io"7 "mime"8 "net/http"9 "os"10 "path/filepath"11 "sort"12 "strings"1314 "github.com/FelineStateMachine/atlas/internal/authoring"15 "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"16)1718const maxManifestBytes = 2 << 201920type projectPage struct {21 Targets Targets22 Manifest string23 Project authoring.Project24 Plan authoring.Plan25 Error string26 Notice string27 Run supervisedRun28 FeatureSets int29 SourceCount int30 RasterCount int31 LayerCount int32 CachedCount int33 RequestCount int34 ArtifactName string35 Graph projectGraph36 Stages []projectBuildStage37}3839// projectGraph is the manifest and resolved plan shaped for the page. It is a40// view only: adapters still acquire bytes, mappings still assign meaning, and41// the compiler remains the sole authority for the artifact.42type projectGraph struct {43 FeatureSets []projectFeatureSet44 Rasters []projectSideInput45 Assets []projectSideInput46 Styles int47 Layers int48}4950type projectFeatureSet struct {51 ID string52 Title string53 SemanticType string54 Geometry string55 Sources []projectSourceLane56 Properties []projectProperty57 Relations []projectRelation58}5960type projectSourceLane struct {61 ID string62 Locator string63 Adapter string64 Cache string65 Cached bool66 Identity string67 FeatureTitle string68 Geometry string69 SourceSpace string70 Transform string71 PropertyCount int72 RelationCount int73}7475type projectProperty struct {76 ID string77 Name string78 Type string79 Optional bool80}8182type projectRelation struct {83 Predicate string84 Target string85 Optional bool86}8788type projectSideInput struct {89 ID string90 Adapter string91 Cache string92 Cached bool93}9495type projectBuildStage struct {96 ID string97 Label string98 State string99}100101func (w *Workbench) projectPage(notice string) projectPage {102 page := projectPage{Targets: w.targets, Notice: notice, Run: w.supervisor.Snapshot()}103 page.ArtifactName = filepath.Base(page.Run.Artifact)104 page.Stages = projectStages(page.Run)105 data, err := os.ReadFile(w.targets.Project)106 if err != nil {107 page.Error = err.Error()108 return page109 }110 if len(data) > maxManifestBytes {111 page.Error = "manifest is larger than the editor limit"112 return page113 }114 page.Manifest = string(data)115 project, err := authoring.LoadProject(w.targets.Project)116 if err != nil {117 page.Error = err.Error()118 return page119 }120 page.Project = project121 plan, err := authoring.PlanProject(project, authoring.OpenCache(w.targets.Cache), w.targets.Registry)122 if err != nil {123 page.Error = err.Error()124 return page125 }126 page.Plan = plan127 page.Graph = graphProject(project, plan)128 page.SourceCount = len(project.Sources)129 page.RasterCount = len(project.Rasters)130 page.LayerCount = len(project.Presentation.Layers)131 page.CachedCount = plan.Cached132 page.RequestCount = len(plan.Requests)133 page.FeatureSets = len(project.FeatureSets)134 return page135}136137func graphProject(project authoring.Project, plan authoring.Plan) projectGraph {138 graph := projectGraph{Styles: len(project.Presentation.Styles), Layers: len(project.Presentation.Layers)}139 requestCache := make(map[string]bool, len(plan.Requests))140 for _, request := range plan.Requests {141 requestCache[string(request.Kind)+"\x00"+request.Source] = request.Cached142 }143 setIndex := make(map[string]int)144 for _, contract := range project.FeatureSets {145 setIndex[contract.ID] = len(graph.FeatureSets)146 set := projectFeatureSet{147 ID: contract.ID, Title: contract.Title, SemanticType: contract.SemanticType, Geometry: contract.Geometry,148 }149 for _, property := range contract.Properties {150 set.Properties = append(set.Properties, projectProperty{151 ID: property.ID, Name: property.Name, Type: property.Type, Optional: property.Optional,152 })153 }154 for _, relation := range contract.Relationships {155 set.Relations = append(set.Relations, projectRelation{156 Predicate: relation.Predicate, Target: relation.FeatureSet, Optional: relation.Optional,157 })158 }159 sort.Slice(set.Properties, func(i, j int) bool { return set.Properties[i].ID < set.Properties[j].ID })160 sort.Slice(set.Relations, func(i, j int) bool {161 if set.Relations[i].Predicate != set.Relations[j].Predicate {162 return set.Relations[i].Predicate < set.Relations[j].Predicate163 }164 return set.Relations[i].Target < set.Relations[j].Target165 })166 graph.FeatureSets = append(graph.FeatureSets, set)167 }168 for _, source := range project.Sources {169 setID := source.Mapping.FeatureSet170 at, held := setIndex[setID]171 if !held {172 continue173 }174 set := &graph.FeatureSets[at]175 cached := requestCache[string(authoring.RequestFeatures)+"\x00"+source.ID]176 cache := "fetch"177 if cached {178 cache = "ready"179 }180 set.Sources = append(set.Sources, projectSourceLane{181 ID: source.ID, Locator: source.Locator, Adapter: source.Adapter, Cache: cache, Cached: cached,182 Identity: source.Mapping.Identity, FeatureTitle: source.Mapping.FeatureTitle,183 Geometry: set.Geometry, SourceSpace: source.Mapping.Geometry.SourceSpace,184 Transform: source.Mapping.Geometry.Transform.Kind,185 PropertyCount: len(source.Mapping.Properties), RelationCount: len(source.Mapping.Relations),186 })187 }188 for _, raster := range project.Rasters {189 ready := true190 found := false191 for _, request := range plan.Requests {192 if request.Kind != authoring.RequestRaster || request.Source != raster.ID {193 continue194 }195 found = true196 ready = ready && request.Cached197 }198 graph.Rasters = append(graph.Rasters, projectSideInput{199 ID: raster.ID, Adapter: raster.Adapter, Cache: cacheWord(found && ready), Cached: found && ready,200 })201 }202 for _, asset := range project.Assets {203 ready := requestCache[string(authoring.RequestAsset)+"\x00"+asset.ID]204 graph.Assets = append(graph.Assets, projectSideInput{205 ID: asset.ID, Adapter: "asset-file", Cache: cacheWord(ready), Cached: ready,206 })207 }208 return graph209}210211func cacheWord(cached bool) string {212 if cached {213 return "ready"214 }215 return "fetch"216}217218func projectStages(run supervisedRun) []projectBuildStage {219 stages := []projectBuildStage{220 {ID: "plan", Label: "Plan", State: "pending"},221 {ID: "capture", Label: "Capture", State: "pending"},222 {ID: "observe", Label: "Observe", State: "pending"},223 {ID: "assemble", Label: "Assemble", State: "pending"},224 {ID: "publish", Label: "Publish", State: "pending"},225 }226 if run.Name == "" {227 return stages228 }229 current := 0230 for _, row := range run.Rows {231 for _, attr := range row.Attrs {232 if attr.Key != "stage" {233 continue234 }235 for index := range stages {236 if stages[index].ID == attr.Value && index > current {237 current = index238 }239 }240 }241 }242 for index := range stages {243 if index < current || !run.Running && !run.Failed && index <= current {244 stages[index].State = "done"245 } else if index == current && run.Running {246 stages[index].State = "active"247 }248 }249 if run.Failed {250 stages[current].State = "failed"251 }252 return stages253}254255func (w *Workbench) handleProject(rw http.ResponseWriter, r *http.Request) {256 w.render(rw, "project", w.projectPage(r.URL.Query().Get("notice")))257}258259func (w *Workbench) handleProjectSave(rw http.ResponseWriter, r *http.Request) {260 if err := oprunner.CheckOrigin(r); err != nil {261 http.Error(rw, err.Error(), http.StatusForbidden)262 return263 }264 r.Body = http.MaxBytesReader(rw, r.Body, maxManifestBytes)265 if err := r.ParseForm(); err != nil {266 http.Error(rw, err.Error(), http.StatusBadRequest)267 return268 }269 manifest := r.FormValue("manifest")270 if strings.TrimSpace(manifest) == "" {271 http.Error(rw, "manifest is empty", http.StatusBadRequest)272 return273 }274 if err := validateAndInstallManifest(w.targets.Project, []byte(manifest)); err != nil {275 http.Error(rw, err.Error(), http.StatusBadRequest)276 return277 }278 http.Redirect(rw, r, "/project?notice=Manifest+saved", http.StatusSeeOther)279}280281func validateAndInstallManifest(path string, data []byte) error {282 dir := filepath.Dir(path)283 stage, err := os.CreateTemp(dir, ".atlas-project-*.atlas-project")284 if err != nil {285 return fmt.Errorf("stage manifest: %w", err)286 }287 stagePath := stage.Name()288 defer os.Remove(stagePath)289 if _, err := stage.Write(data); err != nil {290 stage.Close()291 return fmt.Errorf("write staged manifest: %w", err)292 }293 if err := stage.Sync(); err != nil {294 stage.Close()295 return fmt.Errorf("sync staged manifest: %w", err)296 }297 if err := stage.Close(); err != nil {298 return fmt.Errorf("close staged manifest: %w", err)299 }300 if _, err := authoring.LoadProject(stagePath); err != nil {301 return err302 }303 if err := os.Chmod(stagePath, 0o644); err != nil {304 return fmt.Errorf("prepare staged manifest: %w", err)305 }306 if err := os.Rename(stagePath, path); err != nil {307 return fmt.Errorf("install manifest: %w", err)308 }309 return nil310}311312func (w *Workbench) handleProjectBuild(rw http.ResponseWriter, r *http.Request) {313 if err := oprunner.CheckOrigin(r); err != nil {314 http.Error(rw, err.Error(), http.StatusForbidden)315 return316 }317 if _, err := authoring.LoadProject(w.targets.Project); err != nil {318 http.Error(rw, err.Error(), http.StatusBadRequest)319 return320 }321 if err := r.ParseForm(); err != nil {322 http.Error(rw, err.Error(), http.StatusBadRequest)323 return324 }325 op, err := buildOperation(w.targets, r.FormValue("offline") == "true")326 if err != nil {327 http.Error(rw, err.Error(), http.StatusBadRequest)328 return329 }330 if err := w.supervisor.Start(op); err != nil {331 status := http.StatusInternalServerError332 if err == oprunner.ErrBusy {333 status = http.StatusConflict334 }335 http.Error(rw, err.Error(), status)336 return337 }338 http.Redirect(rw, r, "/project?notice=Build+started", http.StatusSeeOther)339}340341func (w *Workbench) handleProjectRun(rw http.ResponseWriter, _ *http.Request) {342 run := w.supervisor.Snapshot()343 page := projectPage{Run: run, ArtifactName: filepath.Base(run.Artifact), Stages: projectStages(run)}344 var body bytes.Buffer345 if err := w.pages["project"].ExecuteTemplate(&body, "project-run", page); err != nil {346 http.Error(rw, err.Error(), http.StatusInternalServerError)347 return348 }349 rw.Header().Set("Content-Type", "text/html; charset=utf-8")350 rw.Header().Set("Cache-Control", "no-store")351 rw.Write(body.Bytes())352}353354func (w *Workbench) handleProjectOpen(rw http.ResponseWriter, r *http.Request) {355 if err := oprunner.CheckOrigin(r); err != nil {356 http.Error(rw, err.Error(), http.StatusForbidden)357 return358 }359 artifact, err := w.currentArtifact()360 if err != nil {361 http.Error(rw, err.Error(), http.StatusBadRequest)362 return363 }364 if w.openArtifact == nil {365 http.Error(rw, "native Atlas handoff is unavailable", http.StatusNotImplemented)366 return367 }368 if err := w.openArtifact(artifact); err != nil {369 http.Error(rw, err.Error(), http.StatusInternalServerError)370 return371 }372 http.Redirect(rw, r, "/project?notice=Opened+in+Atlas", http.StatusSeeOther)373}374375func (w *Workbench) handleProjectArtifact(rw http.ResponseWriter, r *http.Request) {376 artifact, err := w.openCompletedArtifact()377 if err != nil {378 http.NotFound(rw, r)379 return380 }381 defer artifact.Close()382 if _, err := artifact.source.Seek(0, io.SeekStart); err != nil {383 http.Error(rw, err.Error(), http.StatusInternalServerError)384 return385 }386 disposition := mime.FormatMediaType("attachment", map[string]string{"filename": artifact.name})387 rw.Header().Set("Content-Disposition", disposition)388 info, err := artifact.source.Stat()389 if err != nil {390 http.Error(rw, err.Error(), http.StatusInternalServerError)391 return392 }393 http.ServeContent(rw, r, artifact.name, info.ModTime(), artifact.source)394}395396func (w *Workbench) currentArtifact() (string, error) {397 artifact, err := w.openCompletedArtifact()398 if err != nil {399 return "", err400 }401 defer artifact.Close()402 return artifact.path, nil403}