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 / authoring / minter.go
minter.go 7.2K
1package authoring23import (4 "context"5 "fmt"6 "os"7 "path/filepath"8 "strings"9 "sync"10 "unicode"1112 "github.com/FelineStateMachine/atlas/format/vnext"13 "github.com/FelineStateMachine/atlas/internal/minting"14)1516// NativeMinterOptions names the app-owned recipe, evidence, and library17// locations. They are supplied by the desktop host and never cross the HTTP18// application boundary.19type NativeMinterOptions struct {20 RecipesDir string21 CacheDir string22 LibraryDir string23}2425// NativeMinter turns one compact request into a durable single manifest and26// runs the same deterministic builder used by the CLI and Workbench.27type NativeMinter struct {28 options NativeMinterOptions29 build func(context.Context, BuildOptions) (BuildResult, error)30 mu sync.Mutex31}3233var _ minting.Minter = (*NativeMinter)(nil)3435// NewNativeMinter prepares the three app-owned locations.36func NewNativeMinter(options NativeMinterOptions) (*NativeMinter, error) {37 for name, directory := range map[string]string{38 "recipes": options.RecipesDir, "cache": options.CacheDir, "library": options.LibraryDir,39 } {40 if strings.TrimSpace(directory) == "" {41 return nil, fmt.Errorf("native minter %s directory is empty", name)42 }43 if err := os.MkdirAll(directory, 0o755); err != nil {44 return nil, fmt.Errorf("create native minter %s directory: %w", name, err)45 }46 }47 return &NativeMinter{options: options, build: Build}, nil48}4950// Default is the neutral, immediately valid starting request.51func (m *NativeMinter) Default() minting.Request {52 profile := DefaultAreaProfile()53 return minting.Request{54 Title: "My Atlas", Bounds: profile.Bounds, DetailZoom: profile.DetailZoom,55 Topo: profile.IncludeTopo, Roads: profile.IncludeRoads,56 Hydro: profile.IncludeHydro, Counties: profile.IncludeCounties,57 RoadLabel: profile.Presentation.RoadLabel, HydroLabel: profile.Presentation.HydroLabel,58 CountyLabel: profile.Presentation.CountyLabel, RoadColor: profile.Presentation.RoadColor,59 HydroColor: profile.Presentation.HydroColor, HydroFill: profile.Presentation.HydroFill,60 CountyColor: profile.Presentation.CountyColor,61 }62}6364// Preview plans the exact project Mint will persist without touching network65// evidence or the library.66func (m *NativeMinter) Preview(request minting.Request) (minting.Preview, error) {67 _, summary, err := mintProject(request)68 if err != nil {69 return minting.Preview{}, err70 }71 return minting.Preview{72 Pixels: summary.PixelSize, RasterTiles: summary.RasterTiles,73 Requests: summary.CaptureRequests, EstimatedBytes: summary.EstimatedBytes,74 }, nil75}7677// Mint serializes builds that share one cache and library. It atomically78// replaces the app-managed manifest before invoking the ordinary builder, so79// a failed capture leaves an inspectable recipe and no partial Atlas.80func (m *NativeMinter) Mint(ctx context.Context, request minting.Request, emit func(minting.Event)) (minting.Result, error) {81 m.mu.Lock()82 defer m.mu.Unlock()8384 project, _, err := mintProject(request)85 if err != nil {86 return minting.Result{}, err87 }88 descriptors, _, err := vnext.Scan(m.options.LibraryDir, vnext.StandardSchema())89 if err != nil {90 return minting.Result{}, fmt.Errorf("scan Atlas library before mint: %w", err)91 }92 project.Release.Revision = nextMintRevision(descriptors, project.ID)93 manifest, err := MarshalProjectYAML(project)94 if err != nil {95 return minting.Result{}, err96 }97 projectPath := filepath.Join(m.options.RecipesDir, project.ID+ProjectExtension)98 if err := writeManagedManifest(projectPath, manifest); err != nil {99 return minting.Result{}, err100 }101 result, err := m.build(ctx, BuildOptions{102 ProjectPath: projectPath, CacheDir: m.options.CacheDir, LibraryDir: m.options.LibraryDir,103 Event: func(event Event) {104 if emit != nil {105 emit(minting.Event{106 Stage: event.Stage, Message: event.Message, Current: event.Current,107 Total: event.Total, Bytes: event.Bytes, Cached: event.Cached, Artifact: event.Artifact,108 })109 }110 },111 })112 if err != nil {113 return minting.Result{}, err114 }115 return minting.Result{116 Slug: result.Descriptor.Slug, World: project.Target.World,117 Title: result.Descriptor.Title, Stamp: result.Descriptor.Stamp,118 }, nil119}120121func nextMintRevision(descriptors []vnext.Descriptor, slug string) int {122 revision := 1123 for _, descriptor := range descriptors {124 if descriptor.Slug == slug && descriptor.Revision >= revision {125 revision = descriptor.Revision + 1126 }127 }128 return revision129}130131func mintProject(request minting.Request) (Project, AreaSummary, error) {132 id := mintSlug(request.Title)133 if err := vnext.ValidSlug(id); err != nil {134 return Project{}, AreaSummary{}, fmt.Errorf("Atlas name: %w", err)135 }136 profile := DefaultAreaProfile()137 profile.ID, profile.Title = id, strings.TrimSpace(request.Title)138 profile.Bounds, profile.DetailZoom = request.Bounds, request.DetailZoom139 profile.IncludeTopo, profile.IncludeRoads = request.Topo, request.Roads140 profile.IncludeHydro, profile.IncludeCounties = request.Hydro, request.Counties141 if request.RoadLabel != "" {142 profile.Presentation.RoadLabel = request.RoadLabel143 }144 if request.HydroLabel != "" {145 profile.Presentation.HydroLabel = request.HydroLabel146 }147 if request.CountyLabel != "" {148 profile.Presentation.CountyLabel = request.CountyLabel149 }150 if request.RoadColor != "" {151 profile.Presentation.RoadColor = request.RoadColor152 }153 if request.HydroColor != "" {154 profile.Presentation.HydroColor = request.HydroColor155 }156 if request.HydroFill != "" {157 profile.Presentation.HydroFill = request.HydroFill158 }159 if request.CountyColor != "" {160 profile.Presentation.CountyColor = request.CountyColor161 }162 return NewAreaProject(profile)163}164165func mintSlug(title string) string {166 var out strings.Builder167 dash := false168 for _, character := range strings.ToLower(strings.TrimSpace(title)) {169 if character <= unicode.MaxASCII && (unicode.IsLetter(character) || unicode.IsDigit(character)) {170 if out.Len() >= 48 {171 break172 }173 out.WriteRune(character)174 dash = false175 continue176 }177 if out.Len() > 0 && !dash {178 out.WriteByte('-')179 dash = true180 }181 }182 return strings.Trim(out.String(), "-")183}184185func writeManagedManifest(path string, data []byte) (err error) {186 directory := filepath.Dir(path)187 temporary, err := os.CreateTemp(directory, ".atlas-project-*")188 if err != nil {189 return fmt.Errorf("create managed manifest: %w", err)190 }191 temporaryPath := temporary.Name()192 defer func() {193 _ = temporary.Close()194 if err != nil {195 _ = os.Remove(temporaryPath)196 }197 }()198 if err = temporary.Chmod(0o644); err != nil {199 return fmt.Errorf("set managed manifest permissions: %w", err)200 }201 if _, err = temporary.Write(data); err != nil {202 return fmt.Errorf("write managed manifest: %w", err)203 }204 if err = temporary.Sync(); err != nil {205 return fmt.Errorf("sync managed manifest: %w", err)206 }207 if err = temporary.Close(); err != nil {208 return fmt.Errorf("close managed manifest: %w", err)209 }210 if err = os.Rename(temporaryPath, path); err != nil {211 return fmt.Errorf("publish managed manifest: %w", err)212 }213 directoryFile, openErr := os.Open(directory)214 if openErr != nil {215 return fmt.Errorf("open managed manifest directory: %w", openErr)216 }217 defer directoryFile.Close()218 if syncErr := directoryFile.Sync(); syncErr != nil {219 return fmt.Errorf("sync managed manifest directory: %w", syncErr)220 }221 return nil222}