oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1// decks.go is the deck: its identifiers, the one seam every deck read
2// goes through, the two functions that move a document forward, and the
3// six screens that stand on them.
4//
5// The split it keeps: the `decks` row is what a list, a link or a
6// permission check needs; the Y.Doc is the content. Nothing here reads
7// content out of a column, and nothing writes content anywhere but
8// through an op batch the core applies.
9package slides
10
11import (
12 "context"
13 "crypto/rand"
14 "encoding/json"
15 "errors"
16 "fmt"
17 "net/http"
18 "time"
19
20 "github.com/go-chi/chi/v5"
21 "gorm.io/gorm"
22
23 "amadan.net/rastrillo/rastrillo"
24 "amadan.net/rastrillo/rastrillo/form"
25
26 "amadan.net/oficina/slides/internal/slides/core"
27 "amadan.net/oficina/slides/internal/slides/deck"
28 "amadan.net/oficina/slides/internal/slides/present"
29)
30
31// --- identifiers ---
32
33// sqidAlphabet avoids the characters a person misreads or mistypes
34// when a deck link is read aloud: no 0/O, no 1/l/I. Docs' alphabet,
35// character for character — the two apps' URLs should look like they
36// come from the same suite.
37const sqidAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
38
39// sqidLength is how many of those characters a deck's public name has.
40const sqidLength = 10
41
42// maxSqidAttempts bounds the retry loop on a UNIQUE violation. Ten
43// characters over 31 symbols makes a collision astronomically
44// unlikely; this exists so one is handled rather than surprising.
45const maxSqidAttempts = 5
46
47// newSqid mints a deck's public name. len(sqidAlphabet) is 31, not a
48// power of two, so `b % 31` is very slightly biased: 256 % 31 == 8, so
49// the first eight symbols come out about 3% more often than the rest.
50// Said out loud rather than papered over — at ten characters the bias
51// narrows the keyspace by a bounded fraction nowhere near enough to
52// make a deck link guessable, and Docs made the same trade.
53func newSqid() (string, error) {
54 raw := make([]byte, sqidLength)
55 if _, err := rand.Read(raw); err != nil {
56 return "", fmt.Errorf("slides: generate sqid: %w", err)
57 }
58 out := make([]byte, sqidLength)
59 for i, b := range raw {
60 out[i] = sqidAlphabet[int(b)%len(sqidAlphabet)]
61 }
62 return string(out), nil
63}
64
65// idAlphabet is spec §2.2's "12 lowercase base32 characters" for slide
66// and block ids. Thirty-two symbols exactly, so masking a random byte
67// with 31 is uniform — unlike the sqid alphabet above, this one has no
68// bias to admit to.
69const idAlphabet = "abcdefghijklmnopqrstuvwxyz234567"
70
71// idLength is spec §2.2's twelve.
72const idLength = 12
73
74// newID mints a slide or block id. The core never draws entropy of its
75// own (core/src/ops.rs: on wasm the CRDT library's seed is a constant),
76// so every id in a document is minted here and travels in the op.
77func newID() (string, error) {
78 raw := make([]byte, idLength)
79 if _, err := rand.Read(raw); err != nil {
80 return "", fmt.Errorf("slides: generate id: %w", err)
81 }
82 out := make([]byte, idLength)
83 for i, b := range raw {
84 out[i] = idAlphabet[b&31]
85 }
86 return string(out), nil
87}
88
89// --- vocabularies ---
90
91// The rungs of spec §5. Only private and team are offered by a screen
92// in this slice: granted needs a way to name people, which does not
93// exist yet, but a deck already sitting on that rung stays readable to
94// the people its grants name (visibleDecks) and its label renders.
95const (
96 rungPrivate = "private"
97 rungGranted = "granted"
98 rungTeam = "team"
99)
100
101// settableRungs is what the settings screen offers, in the order it
102// offers them. Not every rung there is — see the constants above.
103var settableRungs = []string{rungPrivate, rungTeam}
104
105// themeIDs are the six themes a deck can wear, in the order the picker
106// lists them. Identifiers, not copy: each one's visible name is a
107// catalog key (slides.theme.<id>).
108var themeIDs = []string{"paper", "ink", "sunset", "forest", "slate", "candy"}
109
110// defaultTheme is what a deck gets when nobody chose — the same value
111// the core defaults meta.theme to (core/src/schema.rs's DEFAULT_THEME),
112// so the row and the document agree before anything is written.
113const defaultTheme = "paper"
114
115// serverClientID is the Yjs client id every update this process writes
116// carries. One document is only ever edited server-side by one writer
117// at a time (a Handle is not safe for concurrent use, and every write
118// goes through applyOps), so a single fixed id is correct and stable
119// across restarts — which a random one would not be.
120const serverClientID = 1
121
122// actorFor is spec §6.2's actor string for a signed-in person. Every
123// write records one.
124func actorFor(u User) string { return fmt.Sprintf("person:%d", u.ID) }
125
126// --- ops ---
127
128// op is one operation in a batch (spec §7.2, core/src/ops.rs). A map
129// rather than a Go type per op: the core owns the vocabulary and
130// validates every field of it, so a second declaration here would be a
131// copy to keep in step for no checking that isn't already done. What it
132// buys is that callers — sample.go especially — read as data.
133type op map[string]any
134
135// opsJSON encodes a batch for core.Handle.Ops.
136func opsJSON(ops []op) ([]byte, error) {
137 b, err := json.Marshal(ops)
138 if err != nil {
139 return nil, fmt.Errorf("slides: encode ops: %w", err)
140 }
141 return b, nil
142}
143
144// --- the visibility seam ---
145
146// visibleDecks is the one query every deck read goes through: decks
147// viewer owns, decks on the team rung, and decks a grant names them on
148// (spec §5).
149//
150// Not rastrillo's scope.Owned (SKILL.md §3), which is owner-only: a
151// deck's audience here is wider than its owner by design, and a helper
152// that silently kept filtering to creator-only would make a shared deck
153// look deleted rather than broken. Written here so the day grants get a
154// screen, this is the one function that changes.
155//
156// A deck that is not in here does not exist: handlers answer 404, never
157// 403, so a stranger holding a sqid cannot learn whether it is real.
158//
159// The condition is parenthesised in the string rather than left to
160// GORM's clause building, because every caller chains another Where
161// onto it and an unparenthesised OR would quietly widen to "or this
162// deck is on the team rung" — the whole seam defeated by operator
163// precedence.
164func (a *app) visibleDecks(ctx context.Context, viewer User) *gorm.DB {
165 g := a.db.G.WithContext(ctx).Model(&Deck{})
166 if viewer.ID <= 0 {
167 // Never reached past RequireSession; refusing outright beats
168 // trusting every caller to have checked, because the value
169 // this guards against — 0 — would otherwise match every row a
170 // backfill had ever left ownerless.
171 return g.Where("1 = 0")
172 }
173 return g.Where(
174 "(decks.owner_id = ? OR decks.rung = ? OR EXISTS ("+
175 "SELECT 1 FROM deck_grants WHERE deck_grants.deck_id = decks.id AND deck_grants.user_id = ?))",
176 viewer.ID, rungTeam, viewer.ID)
177}
178
179// --- the document ---
180
181// openDeck instantiates d's Y.Doc: a fresh wasm module, the newest
182// snapshot applied, every update after it replayed in seq order, and
183// the view that produces.
184//
185// The handle is the CALLER's to close. One module per request is
186// deliberate — an instance is not safe for concurrent use, and a
187// long-lived one is a change to make when there is evidence for it.
188//
189// The snapshot is an accelerator and never the truth (snapshot.go): a
190// deck with none, or one whose snapshot row could not be read, opens
191// from the plain log and is exactly the same deck, only slower.
192func (a *app) openDeck(ctx context.Context, d Deck) (*core.Handle, *deck.Deck, error) {
193 h, err := a.core.NewDoc(ctx, d.Sqid, serverClientID)
194 if err != nil {
195 return nil, nil, fmt.Errorf("slides: open deck %s: %w", d.Sqid, err)
196 }
197
198 snap, updates, err := a.replayPlan(ctx, d)
199 if err != nil {
200 h.Close(ctx)
201 return nil, nil, err
202 }
203 var since int64
204 retained := 0
205 if snap != nil {
206 since = snap.Seq
207 retained = 1
208 if err := h.Apply(ctx, snap.Bytes); err != nil {
209 h.Close(ctx)
210 a.logger.Error("slides: read a deck's newest snapshot, replaying the plain log instead",
211 "deck", d.Sqid, "err", err)
212 h, err = a.core.NewDoc(ctx, d.Sqid, serverClientID)
213 if err != nil {
214 return nil, nil, fmt.Errorf("slides: open deck %s: %w", d.Sqid, err)
215 }
216 since = 0
217 retained = 0
218 updates, err = a.replayLog(ctx, d, 0)
219 if err != nil {
220 h.Close(ctx)
221 return nil, nil, err
222 }
223 }
224 }
225 for len(updates) > 0 {
226 for _, u := range updates {
227 if u.Seq != since+1 {
228 h.Close(ctx)
229 return nil, nil, fmt.Errorf("slides: incomplete deck %s log at seq %d", d.Sqid, since+1)
230 }
231 if retained >= replayCheckpointCadence {
232 state, err := h.State(ctx)
233 if err != nil {
234 h.Close(ctx)
235 return nil, nil, fmt.Errorf("slides: checkpoint deck %s replay: %w", d.Sqid, err)
236 }
237 h.Close(ctx)
238 h, err = a.core.NewDoc(ctx, d.Sqid, serverClientID)
239 if err != nil {
240 return nil, nil, err
241 }
242 if err := h.Apply(ctx, state); err != nil {
243 h.Close(ctx)
244 return nil, nil, err
245 }
246 retained = 1
247 }
248 if err := h.Apply(ctx, u.Bytes); err != nil {
249 h.Close(ctx)
250 return nil, nil, fmt.Errorf("slides: replay deck %s update %d: %w", d.Sqid, u.Seq, err)
251 }
252 since = u.Seq
253 retained++
254 }
255 updates, err = a.replayLog(ctx, d, since)
256 if err != nil {
257 h.Close(ctx)
258 return nil, nil, err
259 }
260 }
261 if since != d.Seq {
262 h.Close(ctx)
263 return nil, nil, fmt.Errorf("slides: incomplete deck %s log at seq %d", d.Sqid, since+1)
264 }
265
266 view, err := h.JSON(ctx)
267 if err != nil {
268 h.Close(ctx)
269 return nil, nil, fmt.Errorf("slides: read deck %s view: %w", d.Sqid, err)
270 }
271 parsed, err := deck.Parse(view)
272 if err != nil {
273 h.Close(ctx)
274 return nil, nil, err
275 }
276 return h, parsed, nil
277}
278
279// errDeckBusy is applyOps' optimistic-lock failure: somebody else's
280// update landed between this one reading Deck.Seq and writing it. The
281// batch applied nothing; the caller reopens the deck and tries again.
282var errDeckBusy = errors.New("slides: the deck moved while this update was being written")
283
284// applyOps runs one batch against an open handle and records what it
285// produced: the update bytes as the next row of d's log, and d.Seq
286// bumped to match — both in one transaction, so a deck can never have
287// a seq the log cannot account for.
288//
289// The seq bump is the optimistic lock (`WHERE id = ? AND seq = ?`): if
290// no row matched, another writer got there first and the whole
291// transaction rolls back with errDeckBusy.
292//
293// The (deck_id, seq) unique index is the SAME lock, hit one statement
294// earlier — the update row goes in before the bump, so a losing writer
295// violates the index before it ever reaches the clause. rastrillo's
296// db.Open sets GORM's TranslateError, which makes that violation a
297// gorm.ErrDuplicatedKey, and it is translated here into errDeckBusy:
298// it is not a bug in the batch, it is the same lost race, and the
299// caller's answer to it is the same retry. Without this the loser came
300// out as a 500 where docs/api.md promises a 409, and editDeck's retry
301// never ran.
302//
303// One honest wrinkle: h.Ops runs INSIDE the transaction, so a rollback
304// leaves the handle holding a change the database does not. That is
305// harmless while handles are per-request and discarded after a failure
306// — which is every caller today — and is the reason editDeck reopens
307// rather than retrying on the same handle.
308//
309// d.Seq is updated in place on success so a caller applying two batches
310// in a row does not have to re-read the row.
311//
312// rowUpdates is the row's own columns (title, theme, rung — the ones a
313// list or a link needs) to change alongside the document, in the SAME
314// transaction as the update-log insert. Nil for every caller that only
315// touches the document. Merged into the same `WHERE id = ? AND seq = ?`
316// Updates call the seq bump already makes: that call is the optimistic
317// lock, so folding the row's other columns into it costs nothing extra
318// and means the row and the log commit or roll back together — a
319// failure applying rowUpdates (a rejected rung, a constraint) now rolls
320// back h.Ops' own DeckUpdate row too, rather than leaving the row
321// diverged from the document's meta.
322func (a *app) applyOps(ctx context.Context, h *core.Handle, d *Deck, actor string, ops []byte, rowUpdates map[string]any) (*deck.Deck, error) {
323 err := a.db.G.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
324 update, err := h.Ops(ctx, ops)
325 if err != nil {
326 return fmt.Errorf("slides: apply ops to deck %s: %w", d.Sqid, err)
327 }
328
329 next := d.Seq + 1
330 if err := tx.Create(&DeckUpdate{
331 DeckID: d.ID,
332 Seq: next,
333 Actor: actor,
334 Bytes: update,
335 CreatedAt: time.Now().UTC(),
336 }).Error; err != nil {
337 if errors.Is(err, gorm.ErrDuplicatedKey) {
338 // Somebody else's update is already sitting at this
339 // seq: the lost race, reported as the lost race.
340 return errDeckBusy
341 }
342 return fmt.Errorf("slides: record deck %s update %d: %w", d.Sqid, next, err)
343 }
344
345 // Named columns off a map, never a bound struct (SKILL.md §4):
346 // "seq" is always present so the WHERE ... AND seq = ? clause
347 // above stays the sole optimistic lock, whether or not this
348 // call is also renaming the deck.
349 fields := map[string]any{"seq": next}
350 for k, v := range rowUpdates {
351 fields[k] = v
352 }
353 res := tx.Model(&Deck{}).Where("id = ? AND seq = ?", d.ID, d.Seq).
354 Updates(fields)
355 if res.Error != nil {
356 return fmt.Errorf("slides: bump deck %s seq: %w", d.Sqid, res.Error)
357 }
358 if res.RowsAffected != 1 {
359 return errDeckBusy
360 }
361 return nil
362 })
363 if err != nil {
364 return nil, err
365 }
366
367 // After the commit, not inside it: reading the view is another set
368 // of calls into the wasm module, and SQLite has one writer — doing
369 // it under the transaction would hold that connection for the whole
370 // round trip and serialise every other write behind it.
371 raw, err := h.JSON(ctx)
372 if err != nil {
373 return nil, fmt.Errorf("slides: read deck %s view: %w", d.Sqid, err)
374 }
375 view, err := deck.Parse(raw)
376 if err != nil {
377 return nil, err
378 }
379
380 d.Seq++
381 // After the commit, never inside it: a browser told about a seq a
382 // rolled-back transaction never wrote would fetch it and find
383 // nothing, and its cursor would sit one ahead of the deck for the
384 // rest of the session.
385 a.live.PublishSeq(d.Sqid, d.Seq)
386 // Also after the commit, and best effort: h now holds exactly the
387 // document through d.Seq, which is the one thing maybeSnapshot
388 // cannot check for itself.
389 a.maybeSnapshot(ctx, *d, h, d.Seq)
390 return view, nil
391}
392
393// editDeck is applyOps with the handle's whole life around it: open the
394// deck, apply, close. One retry on errDeckBusy, from a freshly opened
395// document — the batch applied nothing, so replaying it against the
396// state that beat us is the correct thing to do, and a second conflict
397// means real contention rather than a race worth spinning on.
398func (a *app) editDeck(ctx context.Context, d *Deck, actor string, ops []byte, rowUpdates map[string]any) error {
399 var lastErr error
400 for attempt := 0; attempt < 2; attempt++ {
401 h, _, err := a.openDeck(ctx, *d)
402 if err != nil {
403 return err
404 }
405 _, err = a.applyOps(ctx, h, d, actor, ops, rowUpdates)
406 h.Close(ctx)
407 if err == nil {
408 return nil
409 }
410 lastErr = err
411 if !errors.Is(err, errDeckBusy) {
412 return err
413 }
414 // Re-read the row: our copy's Seq is what just lost.
415 if err := a.db.G.WithContext(ctx).First(d, d.ID).Error; err != nil {
416 return fmt.Errorf("slides: reload deck %s: %w", d.Sqid, err)
417 }
418 }
419 return lastErr
420}
421
422// createDeckRow inserts the row, retrying on a sqid collision. The
423// document itself is empty at this point — the caller's first op batch
424// is what puts anything in it.
425func (a *app) createDeckRow(ctx context.Context, owner User, title, theme string) (Deck, error) {
426 now := time.Now().UTC()
427 var lastErr error
428 for attempt := 0; attempt < maxSqidAttempts; attempt++ {
429 sqid, err := newSqid()
430 if err != nil {
431 return Deck{}, err
432 }
433 d := Deck{
434 Sqid: sqid,
435 Title: title,
436 OwnerID: owner.ID,
437 Rung: rungPrivate, // spec §5: every deck starts private
438 Theme: theme,
439 CreatedAt: now,
440 UpdatedAt: now,
441 }
442 err = a.db.G.WithContext(ctx).Create(&d).Error
443 if err == nil {
444 return d, nil
445 }
446 lastErr = err
447 if !errors.Is(err, gorm.ErrDuplicatedKey) {
448 break
449 }
450 }
451 return Deck{}, fmt.Errorf("slides: create deck: %w", lastErr)
452}
453
454// --- request plumbing ---
455
456// viewer is the signed-in person, or a rendered 500 and false. Past
457// RequireSession a failure here means the users row could not be read,
458// not that nobody is signed in, so it is an error page and not a
459// redirect.
460func (a *app) viewer(w http.ResponseWriter, r *http.Request) (User, bool) {
461 u, outcome := a.currentUser(w, r)
462 switch outcome {
463 case sessionOK:
464 return u, true
465 case sessionOffboarded:
466 // currentUser answered for itself: the address is no longer
467 // admitted, so the person has been signed out and told why
468 // (auth.go's offboard). Not an internal error, and not a
469 // second answer written over the one already sent.
470 return User{}, false
471 default:
472 a.fail(w, r, "no current user past the session gate", nil)
473 return User{}, false
474 }
475}
476
477// fail logs why with a ref and renders the app's own 500.
478func (a *app) fail(w http.ResponseWriter, r *http.Request, what string, err error) {
479 ref := rastrillo.NewRef()
480 a.logger.Error("slides: "+what, "err", err, "ref", ref)
481 a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref)
482}
483
484// notFound is the answer for a deck that is not this person's to see —
485// and for one that does not exist. Deliberately the same answer: see
486// visibleDecks.
487func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
488 a.errorPage(w, r, http.StatusNotFound, kindDeck, "")
489}
490
491// deckFor resolves the {sqid} in r to a deck u may read, answering 404
492// itself when there is none.
493func (a *app) deckFor(w http.ResponseWriter, r *http.Request, u User) (Deck, bool) {
494 var d Deck
495 err := a.visibleDecks(r.Context(), u).
496 Where("decks.sqid = ?", chi.URLParam(r, "sqid")).Take(&d).Error
497 switch {
498 case err == nil:
499 return d, true
500 case errors.Is(err, gorm.ErrRecordNotFound):
501 a.notFound(w, r)
502 default:
503 a.fail(w, r, "load deck", err)
504 }
505 return Deck{}, false
506}
507
508// ownedDeckFor is deckFor for a write: settings and delete are the
509// owner's alone, and a team member who can read the deck gets the same
510// 404 as a stranger rather than a 403 that would confirm it exists.
511func (a *app) ownedDeckFor(w http.ResponseWriter, r *http.Request, u User) (Deck, bool) {
512 d, ok := a.deckFor(w, r, u)
513 if !ok {
514 return Deck{}, false
515 }
516 if d.OwnerID != u.ID {
517 a.notFound(w, r)
518 return Deck{}, false
519 }
520 return d, true
521}
522
523// --- shared view data ---
524
525// selectOption is one entry of ui's field-select Options.
526type selectOption struct {
527 Value string
528 Label string
529 Selected bool
530}
531
532// choiceOption is one card of ui's choice-field Options.
533type choiceOption struct {
534 Value string
535 Title string
536 Desc string
537 Checked bool
538}
539
540// themeOptions builds the theme picker with current pre-selected.
541func themeOptions(r *http.Request, current string) []selectOption {
542 opts := make([]selectOption, 0, len(themeIDs))
543 for _, id := range themeIDs {
544 opts = append(opts, selectOption{
545 Value: id,
546 Label: rastrillo.T(r, "slides.theme."+id),
547 Selected: id == current,
548 })
549 }
550 return opts
551}
552
553// rungOptions builds the rung radio group. Each card is a short title
554// plus one explaining line, per choice-field's own contract — "Private"
555// on its own does not say who that leaves out.
556func rungOptions(r *http.Request, current string) []choiceOption {
557 opts := make([]choiceOption, 0, len(settableRungs))
558 for _, rung := range settableRungs {
559 opts = append(opts, choiceOption{
560 Value: rung,
561 Title: rastrillo.T(r, "slides.rung."+rung),
562 Desc: rastrillo.T(r, "slides.rung."+rung+".desc"),
563 Checked: rung == current,
564 })
565 }
566 return opts
567}
568
569// rungLabel is the pill's visible text. Always text: state is never
570// colour alone.
571func rungLabel(r *http.Request, rung string) string {
572 switch rung {
573 case rungPrivate, rungGranted, rungTeam:
574 return rastrillo.T(r, "slides.rung."+rung)
575 default:
576 // A rung nothing here knows: name the raw value rather than
577 // rendering an empty pill, so it is debuggable from the page.
578 return rung
579 }
580}
581
582// rungTone tints the pill. Private is the quiet default; team is the
583// one worth noticing, because it is the rung that widens who can read.
584func rungTone(rung string) string {
585 if rung == rungTeam {
586 return "positive"
587 }
588 return "neutral"
589}
590
591// validTheme reports whether a submitted theme id is one of the six.
592func validTheme(id string) bool {
593 for _, t := range themeIDs {
594 if t == id {
595 return true
596 }
597 }
598 return false
599}
600
601// validRung reports whether a submitted rung is one a screen may set.
602func validRung(rung string) bool {
603 for _, r := range settableRungs {
604 if r == rung {
605 return true
606 }
607 }
608 return false
609}
610
611// --- GET / : the deck list ---
612
613type deckRow struct {
614 Href string
615 Title string
616 Sub string
617 RungLabel string
618 RungTone string
619}
620
621type decksPage struct {
622 shell
623 Title string
624 NewHref string
625 NewLabel string
626 EmptyTitle string
627 EmptyBody string
628 Decks []deckRow
629}
630
631// decks serves GET /: every deck this person may open, most recently
632// edited first.
633//
634// The slide count costs one wasm instance per deck, because the count
635// lives in the document and nothing else knows it. That is the honest
636// price of not denormalising a number that a peer's update can change
637// without this process noticing; it is fine at a small team's deck
638// count and it is the first thing to revisit if this screen ever feels
639// slow — task 6's hub already holds a live handle per open deck.
640func (a *app) decks(w http.ResponseWriter, r *http.Request) {
641 u, ok := a.viewer(w, r)
642 if !ok {
643 return
644 }
645
646 var rows []Deck
647 if err := a.visibleDecks(r.Context(), u).
648 Order("decks.updated_at DESC, decks.id DESC").Find(&rows).Error; err != nil {
649 a.fail(w, r, "list decks", err)
650 return
651 }
652
653 page := decksPage{
654 // The app root's <title> is the app's name alone (spec §10).
655 shell: a.shellFor(r, u, frame{}),
656 Title: rastrillo.T(r, "slides.decks.title"),
657 NewHref: "/decks/new",
658 NewLabel: rastrillo.T(r, "slides.decks.new"),
659 EmptyTitle: rastrillo.T(r, "slides.decks.empty.title"),
660 EmptyBody: rastrillo.T(r, "slides.decks.empty.body"),
661 Decks: make([]deckRow, 0, len(rows)),
662 }
663 by := a.lastChangeBy(r.Context(), rows)
664 for _, d := range rows {
665 slides, counted := a.slideCount(r.Context(), d)
666 page.Decks = append(page.Decks, deckRow{
667 Href: "/d/" + d.Sqid,
668 Title: d.Title,
669 Sub: rowSub(r, slides, counted, d.UpdatedAt, by[d.ID]),
670 RungLabel: rungLabel(r, d.Rung),
671 RungTone: rungTone(d.Rung),
672 })
673 }
674 render(w, "decks", page)
675}
676
677// lastChangeBy answers, for each deck, who made its most recent write,
678// as a person reads it: an address for a person, the robot mark plus
679// the principal for an agent (agents.go's actorLabels).
680//
681// Spec §6.2's actor column is what makes this possible at all, and
682// spec §7.1 is why it is on the screen: an agent's write is a
683// different kind of fact from a person's, and a list that showed them
684// identically would be hiding the one thing worth knowing about a deck
685// a program has been editing.
686//
687// One query for every row, not one per row: the subquery finds each
688// deck's highest seq and the join reads that row's actor. A failure
689// here is logged and the list renders without the line — the list is
690// how a person reaches every deck, so it must not go down over a
691// caption.
692func (a *app) lastChangeBy(ctx context.Context, decks []Deck) map[int64]string {
693 if len(decks) == 0 {
694 return nil
695 }
696 ids := make([]int64, 0, len(decks))
697 for _, d := range decks {
698 ids = append(ids, d.ID)
699 }
700
701 var latest []struct {
702 DeckID int64
703 Actor string
704 }
705 err := a.db.G.WithContext(ctx).Raw(`
706 SELECT u.deck_id AS deck_id, u.actor AS actor
707 FROM deck_updates u
708 JOIN (SELECT deck_id, MAX(seq) AS seq FROM deck_updates WHERE deck_id IN ? GROUP BY deck_id) m
709 ON m.deck_id = u.deck_id AND m.seq = u.seq`, ids).Scan(&latest).Error
710 if err != nil {
711 a.logger.Error("slides: read the last change on each deck", "err", err)
712 return nil
713 }
714
715 actors := make([]string, 0, len(latest))
716 for _, row := range latest {
717 actors = append(actors, row.Actor)
718 }
719 labels := a.actorLabels(ctx, actors)
720
721 out := make(map[int64]string, len(latest))
722 for _, row := range latest {
723 if label := labels[row.Actor]; label != "" {
724 out[row.DeckID] = label
725 }
726 }
727 return out
728}
729
730// slideCount opens d far enough to count its slides. A deck that will
731// not open is logged and reported as uncounted rather than failing the
732// whole screen: the list is how a person reaches every OTHER deck, so
733// one unreadable document must not take the other nine away with it.
734func (a *app) slideCount(ctx context.Context, d Deck) (int, bool) {
735 h, view, err := a.openDeck(ctx, d)
736 if err != nil {
737 a.logger.Error("slides: open deck for the list", "deck", d.Sqid, "err", err)
738 return 0, false
739 }
740 defer h.Close(ctx)
741 return len(view.Slides), true
742}
743
744// rowSub is a list row's meta line: how many slides, when it was last
745// edited, and who made that change. An uncounted deck (see slideCount)
746// shows the time alone rather than a count that would be a guess, and
747// a deck whose last actor could not be resolved shows no name rather
748// than a raw "person:7".
749func rowSub(r *http.Request, slides int, counted bool, updated time.Time, by string) string {
750 when := whenWords(r, updated)
751 sub := rastrillo.Tf(r, "slides.decks.row.sub.plain", "when", when)
752 if counted {
753 count := rastrillo.Tf(r, "slides.decks.row.slides", "n", slides)
754 if slides == 1 {
755 count = rastrillo.T(r, "slides.decks.row.slides.one")
756 }
757 sub = rastrillo.Tf(r, "slides.decks.row.sub", "slides", count, "when", when)
758 }
759 if by == "" {
760 return sub
761 }
762 return rastrillo.Tf(r, "slides.decks.row.by", "sub", sub, "who", by)
763}
764
765// whenWords is a day-grained relative phrase, falling back to the date
766// itself once "n days ago" stops being easier to read than a date.
767// Day granularity on purpose: a deck list is scanned for which deck,
768// not for what minute.
769func whenWords(r *http.Request, t time.Time) string {
770 days := int(time.Since(t).Hours() / 24)
771 switch {
772 case days <= 0:
773 return rastrillo.T(r, "slides.when.today")
774 case days == 1:
775 return rastrillo.T(r, "slides.when.yesterday")
776 case days < 7:
777 return rastrillo.Tf(r, "slides.when.days", "n", days)
778 default:
779 return rastrillo.Tf(r, "slides.when.on", "date", t.Format("2 January 2006"))
780 }
781}
782
783// --- GET /decks/new, POST /decks ---
784
785type deckNewPage struct {
786 shell
787 Title string
788 Body string
789 TitleLabel string
790 TitleValue string
791 ThemeLabel string
792 Themes []selectOption
793 Submit string
794 CancelHref string
795 CancelLabel string
796 Errors map[string]string
797}
798
799func (a *app) newDeck(w http.ResponseWriter, r *http.Request) {
800 u, ok := a.viewer(w, r)
801 if !ok {
802 return
803 }
804 render(w, "deck_new", a.newDeckPage(r, u, "", defaultTheme, nil))
805}
806
807func (a *app) newDeckPage(r *http.Request, u User, title, theme string, errs map[string]string) deckNewPage {
808 return deckNewPage{
809 shell: a.shellFor(r, u, titled(rastrillo.T(r, "slides.decks.new"))),
810 Title: rastrillo.T(r, "slides.decks.new"),
811 Body: rastrillo.T(r, "slides.decks.new.body"),
812 TitleLabel: rastrillo.T(r, "slides.deck.title.label"),
813 TitleValue: title,
814 ThemeLabel: rastrillo.T(r, "slides.deck.theme.label"),
815 Themes: themeOptions(r, theme),
816 Submit: rastrillo.T(r, "slides.decks.new.submit"),
817 CancelHref: "/",
818 CancelLabel: rastrillo.T(r, "slides.cancel"),
819 Errors: errs,
820 }
821}
822
823// createDeck serves POST /decks: the row, then the document's opening
824// batch — the title and theme in meta, and one title slide carrying the
825// same words, so a new deck opens on something rather than on nothing.
826func (a *app) createDeck(w http.ResponseWriter, r *http.Request) {
827 u, ok := a.viewer(w, r)
828 if !ok {
829 return
830 }
831
832 p := form.Parse(r, form.Field{Name: "title", Required: true})
833 title := p.String("title")
834 theme := r.PostFormValue("theme")
835 errs := map[string]string{}
836 if !p.OK() {
837 // The framework's own message is English built from the field
838 // name; this app's every visible string is a catalog key, so
839 // the failure is kept and the sentence replaced.
840 errs["Title"] = rastrillo.T(r, "slides.deck.title.required")
841 }
842 if !validTheme(theme) {
843 errs["Theme"] = rastrillo.T(r, "slides.deck.theme.invalid")
844 }
845 if len(errs) > 0 {
846 w.WriteHeader(http.StatusUnprocessableEntity)
847 render(w, "deck_new", a.newDeckPage(r, u, p.Echo()["title"], theme, errs))
848 return
849 }
850
851 d, err := a.createDeckRow(r.Context(), u, title, theme)
852 if err != nil {
853 a.fail(w, r, "create deck", err)
854 return
855 }
856 ops, err := openingOps(title, theme)
857 if err != nil {
858 a.fail(w, r, "build the opening ops", err)
859 return
860 }
861 if err := a.editDeck(r.Context(), &d, actorFor(u), ops, nil); err != nil {
862 a.fail(w, r, "write the opening ops", err)
863 return
864 }
865 http.Redirect(w, r, "/d/"+d.Sqid, http.StatusSeeOther)
866}
867
868// openingOps is a brand-new deck's first batch: meta, one flow slide on
869// the `title` template, and a level-1 heading carrying the deck's name.
870func openingOps(title, theme string) ([]byte, error) {
871 slide, err := newID()
872 if err != nil {
873 return nil, err
874 }
875 heading, err := newID()
876 if err != nil {
877 return nil, err
878 }
879 return opsJSON([]op{
880 {"op": "set_meta", "title": title, "theme": theme},
881 {"op": "add_slide", "id": slide, "layout": "flow", "template": "title"},
882 {"op": "add_block", "slide": slide, "id": heading, "type": "heading",
883 "props": map[string]any{"level": 1}, "region": "heading", "markdown": title},
884 })
885}
886
887// --- GET/POST /d/{sqid}/settings ---
888
889type deckSettingsPage struct {
890 shell
891 Title string
892 Action string
893 TitleLabel string
894 TitleValue string
895 ThemeLabel string
896 Themes []selectOption
897 RungLegend string
898 Rungs []choiceOption
899 Submit string
900 CancelHref string
901 CancelLabel string
902 DeleteHref string
903 DeleteLabel string
904 DeleteBody string
905 BackHref string
906 BackLabel string
907 Errors map[string]string
908}
909
910func (a *app) settingsPage(r *http.Request, u User, d Deck, title, theme, rung string, errs map[string]string) deckSettingsPage {
911 base := "/d/" + d.Sqid
912 return deckSettingsPage{
913 shell: a.shellFor(r, u, onDeck(rastrillo.T(r, "slides.deck.settings"), d.Title)),
914 Title: rastrillo.T(r, "slides.deck.settings"),
915 Action: base + "/settings",
916 TitleLabel: rastrillo.T(r, "slides.deck.title.label"),
917 TitleValue: title,
918 ThemeLabel: rastrillo.T(r, "slides.deck.theme.label"),
919 Themes: themeOptions(r, theme),
920 RungLegend: rastrillo.T(r, "slides.deck.rung.legend"),
921 Rungs: rungOptions(r, rung),
922 Submit: rastrillo.T(r, "slides.deck.settings.submit"),
923 CancelHref: base,
924 CancelLabel: rastrillo.T(r, "slides.cancel"),
925 DeleteHref: base + "/delete",
926 DeleteLabel: rastrillo.T(r, "slides.deck.delete"),
927 DeleteBody: rastrillo.T(r, "slides.deck.delete.lead"),
928 BackHref: base,
929 BackLabel: rastrillo.T(r, "slides.deck.back"),
930 Errors: errs,
931 }
932}
933
934func (a *app) deckSettings(w http.ResponseWriter, r *http.Request) {
935 u, ok := a.viewer(w, r)
936 if !ok {
937 return
938 }
939 d, ok := a.ownedDeckFor(w, r, u)
940 if !ok {
941 return
942 }
943 render(w, "deck_settings", a.settingsPage(r, u, d, d.Title, d.Theme, d.Rung, nil))
944}
945
946// saveDeckSettings serves POST /d/{sqid}/settings.
947//
948// Title and theme live in two places — the row the list reads and the
949// document everyone edits — so both are written, and both go through
950// editDeck's rowUpdates so they land in the SAME transaction as the
951// update-log insert applyOps makes. A rejected row write (a rung a
952// constraint refuses, say) now rolls the document change back too,
953// rather than leaving the row's title/theme diverged from what the
954// document's meta says — the earlier two-transaction shape's window.
955func (a *app) saveDeckSettings(w http.ResponseWriter, r *http.Request) {
956 u, ok := a.viewer(w, r)
957 if !ok {
958 return
959 }
960 d, ok := a.ownedDeckFor(w, r, u)
961 if !ok {
962 return
963 }
964
965 p := form.Parse(r, form.Field{Name: "title", Required: true})
966 title := p.String("title")
967 theme := r.PostFormValue("theme")
968 rung := r.PostFormValue("rung")
969 errs := map[string]string{}
970 if !p.OK() {
971 errs["Title"] = rastrillo.T(r, "slides.deck.title.required")
972 }
973 if !validTheme(theme) {
974 errs["Theme"] = rastrillo.T(r, "slides.deck.theme.invalid")
975 }
976 if !validRung(rung) {
977 errs["Rung"] = rastrillo.T(r, "slides.deck.rung.invalid")
978 }
979 if len(errs) > 0 {
980 w.WriteHeader(http.StatusUnprocessableEntity)
981 render(w, "deck_settings", a.settingsPage(r, u, d, p.Echo()["title"], theme, rung, errs))
982 return
983 }
984
985 ops, err := opsJSON([]op{{"op": "set_meta", "title": title, "theme": theme}})
986 if err != nil {
987 a.fail(w, r, "build the settings ops", err)
988 return
989 }
990 rowUpdates := map[string]any{"title": title, "theme": theme, "rung": rung}
991 if err := a.editDeck(r.Context(), &d, actorFor(u), ops, rowUpdates); err != nil {
992 a.fail(w, r, "write the settings ops", err)
993 return
994 }
995 http.Redirect(w, r, "/d/"+d.Sqid, http.StatusSeeOther)
996}
997
998// --- GET/POST /d/{sqid}/delete ---
999
1000type deckDeletePage struct {
1001 shell
1002 Title string
1003 Body string
1004 Action string
1005 Label string
1006 CancelHref string
1007 CancelLabel string
1008 BackHref string
1009 BackLabel string
1010}
1011
1012// confirmDeleteDeck serves GET /d/{sqid}/delete: the destructive
1013// action's own URL and its own page, per the app's own convention. No
1014// modal, no JavaScript, and a GET that changes nothing.
1015func (a *app) confirmDeleteDeck(w http.ResponseWriter, r *http.Request) {
1016 u, ok := a.viewer(w, r)
1017 if !ok {
1018 return
1019 }
1020 d, ok := a.ownedDeckFor(w, r, u)
1021 if !ok {
1022 return
1023 }
1024 base := "/d/" + d.Sqid
1025 render(w, "deck_delete", deckDeletePage{
1026 shell: a.shellFor(r, u, frame{parts: []string{rastrillo.Tf(r, "slides.deck.delete.title", "title", d.Title)}, context: d.Title}),
1027 Title: rastrillo.Tf(r, "slides.deck.delete.title", "title", d.Title),
1028 Body: rastrillo.T(r, "slides.deck.delete.body"),
1029 Action: base + "/delete",
1030 Label: rastrillo.T(r, "slides.deck.delete.confirm"),
1031 CancelHref: base + "/settings",
1032 CancelLabel: rastrillo.T(r, "slides.cancel"),
1033 BackHref: base + "/settings",
1034 BackLabel: rastrillo.T(r, "slides.deck.settings"),
1035 })
1036}
1037
1038// deleteDeck serves POST /d/{sqid}/delete. The row, its whole update
1039// log and its grants go in one transaction: a deck's updates without
1040// their deck are unreachable bytes nothing will ever replay, and a
1041// grant to a deck that is gone is a permission pointing at nothing.
1042//
1043// The presentations go too, and they are the one part with a second
1044// half. A session outliving its deck is a live pairing code and a
1045// projector holding a talk that can never move again, so the rows go
1046// in the same transaction; but every page watching one is parked on
1047// the stream, and the stream only ever says anything because the hub
1048// does. So the live sessions are read inside the transaction and told
1049// after the commit — never before, because an "ended" for a delete
1050// that then rolled back would stop a talk that is still running.
1051func (a *app) deleteDeck(w http.ResponseWriter, r *http.Request) {
1052 u, ok := a.viewer(w, r)
1053 if !ok {
1054 return
1055 }
1056 d, ok := a.ownedDeckFor(w, r, u)
1057 if !ok {
1058 return
1059 }
1060
1061 var live []Presentation
1062 err := a.db.G.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
1063 live = nil
1064 if err := tx.Where("deck_id = ? AND ended_at IS NULL", d.ID).
1065 Find(&live).Error; err != nil {
1066 return err
1067 }
1068 if err := tx.Where("deck_id = ?", d.ID).Delete(&Presentation{}).Error; err != nil {
1069 return err
1070 }
1071 if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckUpdate{}).Error; err != nil {
1072 return err
1073 }
1074 // The snapshots and the verified-asset rows are derived from
1075 // the deck and read by nothing but it: a snapshot left behind
1076 // is a whole copy of the document nothing can reach, and an
1077 // asset row a verification for a deck that no longer exists.
1078 if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckSnapshot{}).Error; err != nil {
1079 return err
1080 }
1081 if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckAsset{}).Error; err != nil {
1082 return err
1083 }
1084 // And the upload receipts (models.go's AssetUpload): a receipt
1085 // is registrable only into the deck it was staged for, so one
1086 // whose deck is gone is a row nothing can ever spend.
1087 if err := tx.Where("deck_id = ?", d.ID).Delete(&AssetUpload{}).Error; err != nil {
1088 return err
1089 }
1090 if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckGrant{}).Error; err != nil {
1091 return err
1092 }
1093 // The share links go too. A link outliving its deck is a live
1094 // URL that can only ever answer 404, and leaving one behind
1095 // would mean the one row in this database that grants
1096 // unauthenticated read is also the one nothing cleans up.
1097 if err := tx.Where("deck_id = ?", d.ID).Delete(&ShareLink{}).Error; err != nil {
1098 return err
1099 }
1100 return tx.Where("id = ? AND owner_id = ?", d.ID, u.ID).Delete(&Deck{}).Error
1101 })
1102 if err != nil {
1103 a.fail(w, r, "delete deck", err)
1104 return
1105 }
1106 // Every projector, phone and follower watching one of this deck's
1107 // talks, told at once that it is over.
1108 for _, p := range live {
1109 a.hub.Publish(p.Sqid, present.Event{Ended: true})
1110 }
1111 http.Redirect(w, r, "/", http.StatusSeeOther)
1112}
1113