clone

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

main
atlas / internal / generate / compose / write.go
write.go 9.2K
1package compose23import (4 "bytes"5 "encoding/json"6 "fmt"7 "log/slog"8 "os"9 "path/filepath"10 "sort"1112 "github.com/FelineStateMachine/atlas/format/bundle"13 "github.com/FelineStateMachine/atlas/format/semconv"14 "github.com/FelineStateMachine/atlas/internal/generate/tiles"15 "github.com/FelineStateMachine/atlas/internal/logging"16)1718// revisionOf is the revision this build carries: the caller's, where it has one19// to state, and this lane's own policy revision otherwise.20func revisionOf(o Options) int {21 if o.Revision != 0 {22 return o.Revision23 }24 return PolicyRevision25}2627// payloadParts is one world's three payloads, built and hashed before anything28// is written: the stamp has to be known before the file can be named, and the29// file cannot be named before it is opened.30type payloadParts struct {31 slug string32 detail []byte33 packed []byte34 text []byte35}3637// write stamps the volume, decides whether it needs writing at all, and writes38// it if it does.39//40// The stamp is taken over named parts rather than over the finished archive, so41// tile pyramids weigh into it through their derivation stamps without a single42// raster being read. The manifest is hashed as it stands before its own stamp43// and creation time are filled in -- a stamp cannot cover itself -- and the44// revision is included, so a policy change supersedes the builds already in45// every library.46func write(o Options, worlds []composedWorld, icons map[string][]byte, log *slog.Logger) (Result, error) {47 manifest := bundle.Manifest{48 Format: bundle.Format,49 FormatVersion: bundle.FormatVersion,50 Conventions: semconv.Version,51 Volume: bundle.Volume{52 Slug: o.Document.Volume.Slug,53 Title: o.Document.Volume.Title,54 },55 Version: bundle.Version{Revision: revisionOf(o)},56 TileGrid: bundle.TileGrid{57 SourceZoom: o.Curation.Window.SourceZoom,58 FirstTile: o.Curation.Window.FirstTile,59 TileSize: o.Tiles.TileSize,60 Size: o.Tiles.Size,61 },62 }6364 var stamp bundle.Stamp65 var parts []payloadParts66 // pyramids maps a bundle-local pyramid name to the tile set's own name for67 // it. Several worlds may draw from one pyramid -- pieces of a split sheet do68 // -- so it is a map, and the pyramid travels once.69 pyramids := make(map[string]tiles.Pyramid)7071 for _, world := range worlds {72 payload, packed, text := buildPayload(world)73 for index, lens := range payload.Lenses {74 pyramids[lens.Tiles] = world.Pyramids[index]75 }76 detail, err := json.Marshal(payload)77 if err != nil {78 return Result{}, fmt.Errorf("world %s: marshal payload: %w", world.Slug, err)79 }80 textJSON, err := json.Marshal(text)81 if err != nil {82 return Result{}, fmt.Errorf("world %s: marshal text: %w", world.Slug, err)83 }84 parts = append(parts, payloadParts{slug: world.Slug, detail: detail, packed: packed, text: textJSON})85 stamp.Add(bundle.WorldEntryName(world.Slug, bundle.WorldSuffix), bundle.HashBytes(detail))86 stamp.Add(bundle.WorldEntryName(world.Slug, bundle.PackedSuffix), bundle.HashBytes(packed))87 stamp.Add(bundle.WorldEntryName(world.Slug, bundle.TextSuffix), bundle.HashBytes(textJSON))8889 count := tally(world.Collections)90 manifest.Worlds = append(manifest.Worlds, bundle.WorldEntry{91 Slug: world.Slug,92 Title: world.Title,93 Parent: world.Parent,94 IconOutset: world.IconOutset,95 Center: bundle.Coordinate{Lat: world.Center.Lat, Lng: world.Center.Lng},96 Points: count.Point,97 Paths: count.Path,98 Areas: count.Area,99 UpdatedAt: world.CapturedAt,100 })101 }102103 // A pyramid's own stamp already names the source tiles and the tool that104 // derived them, so the tiles weigh into the bundle's stamp without being105 // read until the bundle is actually written.106 for local, pyramid := range pyramids {107 stamp.Add(tiles.StampPart(local), pyramid.Stamp)108 }109 iconNames := make([]string, 0, len(icons))110 for name, data := range icons {111 iconNames = append(iconNames, name)112 stamp.Add("icons/"+name, bundle.HashBytes(data))113 }114 sort.Strings(iconNames)115116 identity, err := bundle.MarshalManifest(manifest)117 if err != nil {118 return Result{}, err119 }120 stamp.Add(bundle.ManifestName, bundle.HashBytes(identity))121 manifest.Version.Stamp = stamp.Sum()122123 // The version is the capture, not the build: the newest capture time across124 // the volume's worlds. Building the same archive anywhere, at any time,125 // yields the same version and the same file name, which is what lets a126 // directory of these files stand as a registry.127 for _, world := range worlds {128 if world.CapturedAt > manifest.Version.CreatedAt {129 manifest.Version.CreatedAt = world.CapturedAt130 }131 }132 if manifest.Version.CreatedAt == "" {133 return Result{}, fmt.Errorf("no world carries a capture time to version the bundle by")134 }135136 tileCount := 0137 for _, pyramid := range pyramids {138 list, err := o.Tiles.Tiles(pyramid)139 if err != nil {140 return Result{}, err141 }142 tileCount += len(list)143 }144 result := Result{145 Volume: manifest.Volume.Slug,146 Stamp: manifest.Version.Stamp,147 File: bundle.VersionedFileName(manifest),148 Worlds: len(worlds),149 Tiles: tileCount,150 Icons: len(iconNames),151 }152 if o.BundleDir == "" {153 log.Info("volume composed", logging.Stamp(bundle.ShortStamp(result.Stamp)),154 "worlds", result.Worlds, "tiles", result.Tiles, "icons", result.Icons)155 return result, nil156 }157158 if err := os.MkdirAll(o.BundleDir, 0o755); err != nil {159 return Result{}, err160 }161 target := filepath.Join(o.BundleDir, result.File)162 result.Path = target163 // The name carries the stamp, so a file of that name is that build. There164 // is nothing to write and nothing to check.165 if _, err := os.Stat(target); err == nil {166 result.Present = true167 log.Info("build already installed", logging.Stamp(bundle.ShortStamp(result.Stamp)),168 logging.Path(result.File))169 return result, nil170 }171 if err := install(o, manifest, parts, pyramids, icons, iconNames, target); err != nil {172 return Result{}, err173 }174 log.Info("bundle installed", logging.Stamp(bundle.ShortStamp(result.Stamp)),175 logging.Path(result.File), "worlds", result.Worlds,176 "tiles", result.Tiles, "icons", result.Icons)177 return result, nil178}179180// install writes the bundle beside the registry it belongs to and then proves181// it.182//183// The sequence is deliberate. The archive is built under a temporary name that184// does not end in the bundle extension, so a reader scanning the registry185// mid-write passes over it; it is renamed into place, so the name appears186// whole or not at all; and then it is reopened from disk and validated, because187// the file that will serve is the copy, and it is the copy's promises that188// matter. A bundle that fails that check fails this build rather than somebody's189// import.190func install(191 o Options,192 manifest bundle.Manifest,193 parts []payloadParts,194 pyramids map[string]tiles.Pyramid,195 icons map[string][]byte,196 iconNames []string,197 target string,198) error {199 file, err := os.CreateTemp(o.BundleDir, manifest.Volume.Slug+".atlas.tmp-*")200 if err != nil {201 return err202 }203 defer os.Remove(file.Name())204 defer file.Close()205206 writer, err := bundle.NewWriter(file, manifest)207 if err != nil {208 return err209 }210 for _, part := range parts {211 if err := writer.AddDeflated(bundle.WorldEntryName(part.slug, bundle.WorldSuffix), part.detail); err != nil {212 return err213 }214 if err := writer.AddStored(bundle.WorldEntryName(part.slug, bundle.PackedSuffix), bytes.NewReader(part.packed)); err != nil {215 return err216 }217 if err := writer.AddDeflated(bundle.WorldEntryName(part.slug, bundle.TextSuffix), part.text); err != nil {218 return err219 }220 }221 locals := make([]string, 0, len(pyramids))222 for local := range pyramids {223 locals = append(locals, local)224 }225 sort.Strings(locals)226 for _, local := range locals {227 if err := addPyramid(writer, o.Tiles, pyramids[local], local); err != nil {228 return err229 }230 }231 for _, name := range iconNames {232 if err := writer.AddDeflated("icons/"+name, icons[name]); err != nil {233 return err234 }235 }236 if err := writer.Close(); err != nil {237 return err238 }239 if err := file.Close(); err != nil {240 return err241 }242 // CreateTemp opens private files; a bundle is an artefact anyone may copy.243 if err := os.Chmod(file.Name(), 0o644); err != nil {244 return err245 }246 if err := os.Rename(file.Name(), target); err != nil {247 return err248 }249250 written, err := bundle.Open(target)251 if err != nil {252 return fmt.Errorf("reopen %s: %w", filepath.Base(target), err)253 }254 defer written.Close()255 if err := written.Validate(); err != nil {256 return fmt.Errorf("validate %s: %w", filepath.Base(target), err)257 }258 return nil259}260261// addPyramid copies one derived pyramid's rasters into the bundle, stored262// uncompressed so a reader serves them as straight byte ranges.263func addPyramid(writer *bundle.Writer, set *tiles.Set, pyramid tiles.Pyramid, local string) error {264 list, err := set.Tiles(pyramid)265 if err != nil {266 return err267 }268 for _, tile := range list {269 file, err := os.Open(tile.Path)270 if err != nil {271 return err272 }273 err = writer.AddStored("tiles/"+local+"/"+tile.Name, file)274 file.Close()275 if err != nil {276 return err277 }278 }279 return nil280}281282// WriteRegistryIndex derives the registry listing from the files beside it. It283// is always safe to delete: the next scan derives it again.284func WriteRegistryIndex(dir string) error {285 candidates, skipped, err := bundle.Scan(dir)286 if err != nil {287 return err288 }289 _ = skipped290 return bundle.WriteIndex(dir, candidates)291}