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 / app / importing.go
importing.go 5.3K
1package app23import (4 "errors"5 "io"6 "log/slog"7 "net/http"8 "sync/atomic"910 "github.com/FelineStateMachine/atlas/internal/app/hostenv"11 "github.com/FelineStateMachine/atlas/internal/logging"12)1314// Adding a volume from inside the application: the host puts a picker in front15// of the reader, what they chose is validated and copied into the library, and16// the library is rescanned. Native file-open and window-drop events call this17// same command, so every desktop intake path has identical behavior.18//19// The response is a stream of rows rather than one answer at the end, because20// a hundred-megabyte bundle takes long enough that silence reads as failure.21// Each row is flushed as it happens.22//23// ONE RUN IS ONE ROW. The stream is a stream of *states of the same row*, not24// a stream of rows: every write in this file renders the whole region with the25// one row that is true right now, and the region is morphed onto itself26// (internal/app/partials.go). An append would make each state a line of its27// own and leave every line on screen for the life of the page, which is what28// this used to do -- a cancelled picker alone left two of them.29//30// The row carries the run's number as its element id, so the morph has an31// identity to work against: within a run the id is the same and the row is32// updated in place, and a *second* run's row is a different element, which is33// what makes a fresh dismissal animation start rather than the finished one34// being inherited. The number is a name on the page and nothing else -- it is35// not application state and nobody remembers it -- so a counter for the36// process is all it takes.37var importRuns atomic.Uint643839// ImportRow is one line of an import's progress, as the rows region renders it.40type ImportRow struct {41 // Run is which import this row belongs to. It is the row's identity on42 // the page: one run's row is one element, morphed from state to state.43 Run uint644445 // State is one of "picking", "installing", "installed", "unchanged",46 // "refused". It is a class name as much as a word: the CSS system styles47 // a row by it.48 State string4950 // Detail is what to say: a file name, a volume title, an error.51 Detail string52}5354// Fading says whether a row takes itself off the screen. An import that55// finished well is news for a moment and clutter after that, so the good ends56// are marked here and faded out by the stylesheet; a refusal is the opposite,57// and stays until the next import replaces it (it is the only thing left to58// read about what went wrong).59func (r ImportRow) Fading() bool {60 return r.State == "installed" || r.State == "unchanged"61}6263// handleImport runs one import.64func (a *App) handleImport(w http.ResponseWriter, r *http.Request) {65 w.Header().Set("Content-Type", "text/html; charset=utf-8")66 w.Header().Set("Cache-Control", "no-store")67 control := http.NewResponseController(w)6869 run := importRuns.Add(1)70 row := func(state, detail string) {71 a.writeRegion(w, []ImportRow{{Run: run, State: state, Detail: detail}})72 _ = control.Flush()73 }74 // quiet takes the row away again: the region is rendered with nothing in75 // it, which is how a run that turned out to be nothing at all leaves the76 // screen exactly as it found it.77 quiet := func() {78 a.writeRegion(w, nil)79 _ = control.Flush()80 }8182 row("picking", "choosing a bundle")83 chosen, name, err := a.env.PickFile(r.Context())84 switch {85 case errors.Is(err, hostenv.ErrNoSelection):86 // A cancelled picker is silent. The reader closed a dialog they87 // opened; they know what they did, and being told about it is the88 // application talking to itself. The reference implementation said89 // nothing here either.90 quiet()91 return92 case errors.Is(err, hostenv.ErrNotAvailable):93 // The status is set before anything is written for a host that94 // cannot pick at all, which is the one case where the answer is95 // known before the stream starts.96 slog.Warn("import asked of a host with no picker", logging.Op("install"))97 row("refused", "this host has no file picker; drop the bundle into the library instead")98 return99 case err != nil:100 row("refused", err.Error())101 return102 }103 defer chosen.Close()104105 row("installing", name)106 installed, err := a.Install(name, chosen)107 if err != nil {108 slog.Warn("import refused", logging.Op("install"),109 logging.Path(name), slog.Any("error", err))110 row("refused", err.Error())111 return112 }113 if installed.Already {114 row("unchanged", installed.Title+" is already installed at this build")115 } else {116 row("installed", installed.Title)117 }118119}120121// Install is the single native intake command used by the picker, desktop122// file-open events, and drag/drop. It validates, atomically installs, rescans,123// and announces the changed semantic volumes.124func (a *App) Install(name string, content io.Reader) (hostenv.Installed, error) {125 installed, err := a.env.Volumes().Install(name, content)126 if err != nil {127 return hostenv.Installed{}, err128 }129 a.announce(installed.Changed)130 return installed, nil131}132133// writeRegion renders the import region, holding the one row that is true now134// -- or nothing, which is a region that is not on screen at all.135func (a *App) writeRegion(w http.ResponseWriter, rows []ImportRow) {136 body, err := renderPartials([]string{"import"}, View{Rows: rows})137 if err != nil {138 slog.Error("rendering an import row", logging.Op("render"), slog.Any("error", err))139 return140 }141 _, _ = w.Write(body)142}