diff --git a/backend/locales/en.json b/backend/locales/en.json index f2fd7af45..131471af7 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -2611,6 +2611,13 @@ "editor.redirect.title": "Redirection", "editor.renderFailed": "The preview could not be rendered. The last successful render is kept.", "editor.renderPreview": "Render Preview", + "editor.restore.body": "You made changes to this page on {date} that were never saved. They were kept in this browser. Do you want to restore them?", + "editor.restore.bodyNewPage": "You started writing a new page on {date} that was never saved. It was kept in this browser. Do you want to restore it?", + "editor.restore.othersEditing": "Other people are editing this page right now. Restoring replaces the shared document with your version, including anything they have changed.", + "editor.restore.restore": "Restore", + "editor.restore.restored": "Your unsaved changes have been restored.", + "editor.restore.stale": "This page has been saved since you made these changes. Restoring them puts back your version of the whole page, undoing that save.", + "editor.restore.title": "Restore Unsaved Changes?", "editor.save.createSuccess": "Page created successfully.", "editor.save.error": "An error occurred while creating the page", "editor.save.pleaseWait": "Please wait...", diff --git a/dev/specs/drafts.md b/dev/specs/drafts.md new file mode 100644 index 000000000..4a9a20f0e --- /dev/null +++ b/dev/specs/drafts.md @@ -0,0 +1,670 @@ +# Drafts, autosave and live documents + +**Status:** phase 1 of [§13](#13-suggested-order) implemented — the browser buffer and the leave +warnings ([§11](#11-the-browser-buffer)). Everything else is proposed. +**Covers:** where unpublished edits to a page are kept, how they are saved without the author asking, +what happens to a collaborative session when everybody leaves, the per-site draft workflow, and +per-page live documents. + +Today an edit lives in exactly two places: the browser that is making it, and — once somebody presses +Save — the page itself. Everything in between is lost when a tab closes or a browser crashes. A +collaborative room is lost the moment its last participant leaves. And every save goes straight to +readers, so a change that takes three people a week has to be written somewhere else and pasted in. + +This document adds one thing in the middle: **a draft, kept on the server, one per page**, separate +from the page row that readers are served. Autosave writes to it, a collaborative room is saved into +it, and publishing is the one act that copies it onto the page. Which of those a site and a page use +is a matter of settings ([§4](#4-editing-modes)); the storage underneath is the same for all of them. + +The constraints this is written against: + +- **The page row stays the published version.** Every reader of a page — the page route, the app + shell, search, the sitemap, page links, storage targets — reads `pages` and goes on doing so + unchanged. A draft is something only the editor and the people allowed to edit ever see. +- **The server cannot produce a page.** The render is made by the browser at save time. The server + can only render with the Puppeteer extension, which is not installed by default, and only for some + editors. Neither the Visual editor's ProseMirror tree nor an Excalidraw scene can be turned back + into a page's source on the server at all. So the server may *store* an edit, but publishing + always happens in a browser ([§6](#6-publishing)). +- **History records versions somebody meant to make.** One row per publish, not one per autosave. + Every save today also fans out to search, links, storage targets (a git commit each), webhooks, + notifications and the audit log, so autosaving onto the page would flood all of those as well. +- **Suggestions stay what they are.** An edit suggested by somebody who may only read the page is + kept per author in `pageEditSubmissions` and reviewed, exactly as now + ([§8.3](#83-suggestions)). A suggestion may well be unwanted and declined; a draft is work the + page's own editors have chosen to do. + +--- + +## 1. Goals and non-goals + +**Goals** + +- **No edit is lost** to a closed tab, a crashed browser, a dropped connection or a server restart. + The browser keeps a local copy until the server has one ([§11](#11-the-browser-buffer)), and the + server keeps the draft until it is published or discarded. +- **A collaborative session outlives its participants.** When the last person leaves a room, what + they wrote is in the draft, and the next person to open the editor carries on from it + ([§5](#5-drafts-and-collaborative-rooms)). +- **Autosave without flooding history.** Saving the draft records nothing in history; publishing + records one version. +- **A draft workflow, per site** (`features.drafts`). One shared draft per page, worked on by any + number of people over any number of days and published when it is ready, while readers go on + seeing the last published version. +- **Live documents, per page.** A page marked live publishes itself as it is edited, within seconds, + the way a HedgeDoc note is a live document. History is recorded in checkpoints rather than per + keystroke ([§7](#7-live-documents)). +- **Direct edits to the live page are refused while a draft exists** on a site using drafts, or on a + live document ([§8](#8-writes-to-the-page-while-a-draft-exists)), so that the draft cannot silently + be built on top of a page that has since moved. +- **Each history version records everyone who wrote it**, not only whoever pressed Publish + (`pageHistory.contributorIds`). + +**Non-goals for the first version** + +- **Readers watching a live document update in real time.** A reader sees the published version as + of their page load. The shape of the follow-up is in [§7.5](#75-later-readers-watching-live). +- **More than one draft per page.** A draft is shared and there is exactly one. Branches are a + different feature. +- **Drafting moves, deletions or translations.** Path, locale, alias, locale relations and + deletion stay immediate actions ([§3.2](#32-what-a-draft-holds)). +- **Scheduled publishing of a draft** ("publish this on Monday"). The existing `scheduled` publish + state is about when a page is visible, which is a different thing. +- **Rendering a draft on the server.** Even where Puppeteer could do it for markdown, the Visual and + Excalidraw editors rule it out, and a feature that works for some editors only is worse than one + that waits for a browser. +- **Thinning old history** (keep hourly versions for a week, daily after that). It is a separate + retention policy, and becomes far less pressing once autosave stops writing versions. +- **Notifications about drafts.** Watchers are told when something is published, as now. + +--- + +## 2. Vocabulary + +Two existing words collide here, and the interface has to keep them apart. + +- **`publishState`** answers *whether a page is visible to readers at all*: `draft`, `published` or + `scheduled`, on the page row, unchanged by this document. +- **A draft** (this document) is *unpublished changes to a page*, which may itself be published or + not. + +So a page may be published and also have a draft (the case this feature exists for), or be +unpublished with no draft. The stored `publishState` value stays `draft`; renaming it would need a +migration for nothing a reader sees. **The interface should stop labelling that state "Draft"** and +call it "Not published", so that "Draft" means only the new thing. + +Inside this document, **draft** always means a `pageDrafts` row, whatever the site's mode. In +[direct mode](#41-direct-drafts-off) the interface calls it "unsaved changes" instead, because there +it is a recovery buffer rather than something anybody chose to keep. + +--- + +## 3. Storage + +### 3.1 `pageDrafts` + +One row per page at most. + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | uuid | Primary key | +| `pageId` | uuid | FK → `pages.id`, **unique**, `on delete cascade` | +| `siteId` | uuid | FK → `sites.id` | +| `generation` | uuid | Identity of this draft's collaborative document ([§5.3](#53-generations)). New for every new row | +| `baseVersionId` | uuid | The `pageHistory` version the draft started from. Not a FK: history can be purged | +| `baseHash` | varchar(64) | SHA-256 of the page content the draft started from, as `pageEditSubmissions.baseHash` | +| `content` | text | The page source as the draft has it — written by a browser ([§5.2](#52-the-scribe)) | +| `meta` | jsonb | The other drafted fields ([§3.2](#32-what-a-draft-holds)) | +| `render` | text | Nullable. HTML for previewing the draft, post-processed as a page render is | +| `ydoc` | bytea | Nullable. The collaborative document as a Yjs update. **When present, it is the draft**, and `content`/`meta` are what a browser last read out of it | +| `contributorIds` | uuid[] | Everyone who has changed the draft since it started, or since a live page's last checkpoint | +| `createdById`, `updatedById` | uuid | FK → `users.id`, `on delete set null` | +| `createdAt`, `updatedAt` | timestamp | | +| `refreshedAt`, `checkpointedAt` | timestamp | Live documents only ([§7](#7-live-documents)) | + +Indexes: unique on `pageId`, plus `siteId` for the drafts list. + +**Why `ydoc` and `content` both exist.** The server can store a collaborative document but, for two +of the four editors, cannot read the page out of it ([the constraints](#drafts-autosave-and-live-documents)). +So the Yjs state is what the draft *is*, and `content` is a copy a browser keeps up to date for +everything that has no Yjs decoder: the page banner, a preview, the drafts list, a staleness check, +and a session outside a room. `content` may trail `ydoc` by a few seconds. Nothing publishes from it +without a browser having rebuilt it first. + +`bytea` needs a drizzle `customType`; nothing in the schema uses one yet. + +### 3.2 What a draft holds + +**The draft holds what the editor edits; anything that moves or addresses the page stays +immediate.** + +| Drafted (in `content` / `meta`) | Immediate (written to the page, as today) | +| --- | --- | +| content | path and locale (a move) | +| title, description, icon | alias | +| tags | locale relations | +| relations | publish state and dates — set at publish time ([§6.1](#61-what-publishing-does)) | +| scripts: `scriptJsLoad`, `scriptJsUnload`, `scriptCss` | password | +| display settings: `allowBacklinks`, `allowComments`, `allowContributions`, `allowGlossaryLinks`, `allowRatings`, `showLastEditedBy`, `showSidebar`, `showTags`, `showToc`, `tocDepth` | `isBrowsable`, `isSearchable` | +| | navigation (its own API) | +| | `liveDocument` ([§7](#7-live-documents)) | +| | the editor itself — conversion is refused while a draft exists | + +A drafted field reaches the page when the draft is published, and only then. + +### 3.3 `pageHistory.contributorIds` + +`uuid[] NOT NULL DEFAULT '{}'`. Everyone whose changes are in this version, **including** the +author. `authorId` keeps its meaning: who made the version — the person who pressed Publish, or, for +a live checkpoint nobody asked for, the most recent contributor. + +The migration backfills `ARRAY["authorId"]` on every row whose `authorId` is not null, so there is +one shape afterwards and nothing reads `authorId` as a stand-in for the list. An array has no FK, so +the id of a deleted account stays in the list. The history view shows it as a deleted user, as it +already does for a null `authorId`. + +### 3.4 Settings + +- **`features.drafts`** — site config, boolean, **default `false`**: a site behaves as it does today + until an administrator turns this on. Set under **Admin → General → Features**. It goes into both + default literals in `models/sites.ts`, and **a migration writes `false` into every existing site's + `config.features`**. Site defaults are merged only when a site is created, so an existing site + would otherwise not have the key at all. +- **`liveDocument`** — page config, boolean, one of `CONFIG_FIELDS`. Absent means `false`, following + the convention every page config field already uses: the key is written only once somebody edits + the page's properties. Offered only while the site has `features.collaborativeEditing` on — a live + document is a shared one by nature, and outside a room two editors would spend their time + answering each other's 409s. +- **Timings** are constants in code for the first version. Each one is named where it is used: + [§5.1](#51-seeding-and-persisting-a-room), [§7.1](#71-refresh), [§7.2](#72-checkpoints), + [§11](#11-the-browser-buffer). + +### 3.5 Migrations + +1. `pageDrafts` — `npm run db-generate -- --name=page-drafts`. +2. `pageHistory.contributorIds`, with the backfill in the same migration — + `--name=history-contributors`. +3. `features.drafts: false` into each site's config — a hand-written SQL statement appended to one + of the two. + +The new scheduled tasks need no migration: `SYSTEM_SCHEDULE` is reconciled on every boot. + +--- + +## 4. Editing modes + +There is one mechanism and three ways of using it. The site's `features.drafts` setting picks +between the first two, and a page's `liveDocument` setting overrides both. + +| | Direct (`drafts` off) | Draft (`drafts` on) | Live (`liveDocument`) | +| --- | --- | --- | --- | +| The draft is | a recovery buffer | the work in progress | the room's saved state | +| Autosaved to the server | yes | yes | yes | +| Primary action | **Save** (publishes) | **Publish**, plus **Save draft** | none needed; **Save version** is optional | +| Readers see | the page as last saved | the page as last published | the page within seconds of an edit | +| History | one version per Save | one version per Publish | one version per checkpoint | +| Direct edits while a draft exists | allowed, and the draft goes stale | **refused** | **refused** | +| Closing the editor with unpublished changes | asks Save or Discard, as today | nothing to ask: it is saved | nothing to ask | + +### 4.1 Direct (drafts off) + +What an author does is unchanged: Save publishes, Discard throws the edit away. What changes is what +happens when the session ends badly: + +- If the tab closes or crashes, the draft survives. The next person to open the editor on that page + is told: "*Unsaved changes by Ana, from 14:02 — Continue / Discard*". +- If the last participant leaves a room with changes nobody saved, the draft survives the same way. + +**Why direct edits are not refused here.** A draft in this mode is the state of a session that +ended without anybody deciding to keep it. Blocking an API client for days because a laptop crashed +would be the wrong trade. Instead, the draft goes stale ([§6.2](#62-staleness-and-merging)), and +whoever continues it merges. + +**Abandoned buffers are purged.** A daily task (`purgePageDrafts`) deletes drafts that have not been +touched in **30 days** on sites with `features.drafts` off. On a site using drafts, a draft is never +deleted by anything but a person. + +### 4.2 Draft (drafts on) + +- **Edit opens the draft** if one exists, and otherwise starts one on the first change, based on the + current version. +- **The editor saves as you go** ([§5](#5-drafts-and-collaborative-rooms), [§10](#10-api)). **Save + draft** is the same save, done now and acknowledged, for the author who wants to see it happen. +- **Publish** opens the publish dialog — reason for change, publish state and dates — and publishes + ([§6](#6-publishing)). +- **Discard draft** deletes it for everybody. It always asks first and names the other contributors, + because the work being thrown away is often not only the author's. +- **Readers who may edit** see a banner on the page: "*Unpublished changes by Ana and Ben — last + edited 2 hours ago. Open draft · Preview*". Everyone else sees nothing. + +**Turning `features.drafts` off** while drafts exist turns them into direct-mode buffers. Nothing is +deleted, and the 30-day clock starts from each draft's `updatedAt`. The admin screen says how many +pages are affected before the switch is saved. **Turning it on** makes any existing buffer a draft. + +### 4.3 Live + +See [§7](#7-live-documents). + +--- + +## 5. Drafts and collaborative rooms + +Today `core/collab.ts` says a room is not storage: an empty room is destroyed with whatever it held. +That stops being true, and that paragraph is rewritten. + +### 5.1 Seeding and persisting a room + +- **Seeding.** A room starts from `pageDrafts.ydoc` when there is one, and every instance reads the + same bytes. Only when there is no draft does it fall back to today's deterministic `buildSeed` from + the page, or to the Visual and Excalidraw editors seeding themselves client-side. A stored Visual + or Excalidraw document is not empty, so `seedIfEmpty` leaves it alone. +- **Persisting.** Each instance writes its room's state into the draft **2 s** after the last + update, at most every **10 s** while updates keep coming, and immediately when its last local + connection closes. The write is a merge, done in a transaction under `SELECT … FOR UPDATE`: + `Y.mergeUpdates([stored, ours])`. Yjs updates merge in any order and merge with themselves as a + no-op, so instances in an HA set can write without coordinating. +- **No instance has to know that "everybody has left".** An instance only knows about its own + connections, and nothing here needs the answer for the whole cluster. Each instance saves what it + holds, and the merge does the rest. This is the reason to persist continuously rather than "when + the room empties". +- **Grace period.** An empty room is kept for **30 s** before it is destroyed, so that a reload + rejoins the same room instead of re-reading the stored document. This is an optimisation only: the + state is already persisted. +- **Contributors.** The server attributes each incoming Yjs update to the session of the socket it + arrived on. The first update from a user not yet in `contributorIds` adds them, and records + `editDraft` in the audit log ([§12](#12-audit-log)). + +### 5.2 The scribe + +`content`, `meta` and `render` have to be written by a browser, and a room has several. **The scribe +is the participant with the lowest awareness client id**, among clients that advertise the ability +in their awareness state (every editor does). Awareness is relayed between instances, so every +participant reaches the same answer. While a client joins or leaves they may briefly disagree, and +then two scribes write the same state, which is harmless because the last write wins. + +The scribe writes the draft's materialized fields ([§10](#10-api)) **5 s** after the document goes +quiet, at most every **30 s**, and when it leaves. Outside a room, the one editor is its own scribe. + +Fields the room does not share — tags, scripts, the properties dialog — are written by whoever +changes them, through the same route. They are last-write-wins, exactly as in a room today. + +### 5.3 Generations + +A Yjs client identifies its changes by client id and clock, and the room's seed is pinned to client +0 (and the Visual seed to client 1) so that two identical seeds merge into one. That trick has a +known sharp edge: a document built from a *different* seed under the same client id claims clocks +the room has already seen, and Yjs discards it without saying so (see `SEED_CLIENT_ID` in +`editor/visual/collab.js`). Once rooms survive their participants, a browser that held a document +from before a publish or a discard and then reconnects becomes an ordinary event rather than a rare +one. + +So every draft has a `generation`, and **a room belongs to a generation, not only to a page**: + +- The server sends the generation as the first message on a new connection, before sync step 1. + This is a third message type beside the two y-websocket uses. +- A client holding a document of another generation discards it, along with any unsynced edits + (its browser buffer has those, [§11](#11-the-browser-buffer)), and syncs from empty. It never + answers sync step 1 with the old document. +- Deleting a draft (on publish, discard or compaction) ends its generation. The next room starts a + new one, from the page. + +### 5.4 Discarding + +Discard deletes the draft and closes the room on every instance: the relay gains a `discarded` +message alongside `saved`. Connected participants are told who discarded it. Their editors reload +from the page as a new generation. + +--- + +## 6. Publishing + +### 6.1 What publishing does + +`POST …/draft/publish` ([§10](#10-api)) is sent by a browser with the content and render it has just +built from its own copy of the draft, plus the publish dialog's answers. The server: + +1. Checks that the page has not moved on since the draft started ([§6.2](#62-staleness-and-merging)). +2. Applies the drafted fields and the dialog's publish state and dates through `updatePage`, with the + render permissions of [§6.3](#63-render-permissions). The rest is what any save already does: one + history row (now with `contributorIds`), search, links, storage targets, webhooks, notifications + and the app shell cache. +3. Deletes the draft, ending its generation. If a room is still open, the participants are told + through the existing `saved` message, and the room's next persist starts a new draft based on the + version just published. + +The audit entry is `publishDraft`, carrying the version id and the contributors. + +**Notifications leave out every contributor**, not only the actor. Somebody who wrote half a version +does not need to be told it was published. + +**A brand new page** is created as it is today: Save As, the chosen path, the chosen publish state. +There is no page id to hang a draft on before that, so the [browser buffer](#11-the-browser-buffer) +covers the first session. Every later edit goes through a draft. + +### 6.2 Staleness and merging + +A draft is **stale** when the page's current content hash differs from `baseHash`. The page can move +on underneath a draft even with direct edits refused: an import, a git pull, a version restored +before the draft started ([§8](#8-writes-to-the-page-while-a-draft-exists)). + +- `GET …/draft` reports `isStale`, and the editor shows it. +- Publishing a stale draft is refused with **409 `pageDraftStale`**, unless the request carries the + `currentHash` the author merged against. +- **Merging is three-way**: base (`baseVersionId` from history), current page, and draft. The + merged result is shown in a diff view, reusing the approvals review diff, where the author settles + conflicts. If the base version has been purged, it falls back to a two-way diff. +- **Excalidraw** cannot be text-merged. Its own `reconcileElements`, which merges scenes element by + element by id and version, is the candidate. Failing that, the author picks a side. + +### 6.3 Render permissions + +`rendering.postProcess` keeps or strips scripts and styles according to whoever saves. A draft has +several authors, and today's rooms already have a hole here: whoever presses Save publishes everyone +else's text under *their own* `write:scripts` / `write:styles`. + +**Publishing renders with what every contributor may do:** `write:scripts` only if every +`contributorIds` member holds it on the page, and the same for `write:styles`, each checked with +`groups.checkAccess`. For a single author this is exactly today's rule. When the publisher holds a +permission that a contributor lacks, the publish dialog says so: "*Scripts will be removed: Ben may +not add scripts to this page*". + +The draft's `scriptJsLoad` / `scriptJsUnload` / `scriptCss` fields are checked when they are +*saved*, against the person saving, as `PATCH /pages/:id` checks them now. + +--- + +## 7. Live documents + +A page with `liveDocument` on is edited the way a HedgeDoc note is: whoever has it open edits it, and +readers get the result without anybody pressing anything. The pieces are the same draft and room as +above. The difference is that **the scribe also publishes**, in two strengths. + +### 7.1 Refresh + +The scribe sends `POST …/draft/refresh` **5 s** after the document goes quiet, and at most every +**30 s** while it does not. A refresh writes the drafted fields and render onto the page row, then +updates search, links and the app shell cache, and sets `refreshedAt`. **It does not write history, +notifications, webhooks or storage targets, and does not delete the draft.** Readers see the page as +of the last refresh. A refresh whose content hash equals the page's is a no-op, so a second scribe +costs nothing. + +Render permissions are those of [§6.3](#63-render-permissions), over the contributors since the last +checkpoint. A live page skips the staleness check: direct edits to it are refused +([§8](#8-writes-to-the-page-while-a-draft-exists)), and an import that lands anyway is overwritten +by the next refresh. That is the accepted trade for a page whose working copy *is* the document. + +### 7.2 Checkpoints + +A **checkpoint** is a full publish of what the page row already holds: one history row, +notifications, webhooks, storage targets. It needs no browser, since nothing is rendered: the row was +rendered by the last refresh. Checkpoints happen: + +- **On idle** — a task, `checkpointLivePages`, runs every minute and checkpoints every live draft + with `refreshedAt > checkpointedAt` and no update in **10 minutes**. This is what "save once + everybody has left" means, and, as in [§5.1](#51-seeding-and-persisting-a-room), nobody has to + establish that everybody has left. +- **During long sessions** — the same task checkpoints a draft whose last checkpoint is more than + **60 minutes** old, so that a day of editing is a handful of versions rather than one. +- **On request** — **Save version** in the editor, which also lets the author give a reason. + `reasonForChange` is never demanded of a live page, whatever the site's setting: an automatic + version has nobody to ask. +- **Before any other history-writing action** on a live page with an unrecorded refresh — a move, a + deletion, a switch out of live mode — so that the move's version does not absorb the content edits + under the mover's name. + +A checkpoint resets `contributorIds` and sets `checkpointedAt`. If the room is empty and the page row +matches the draft (`refreshedAt` ≥ `updatedAt`), the draft is deleted. That bounds the size of a live +page's Yjs document, which otherwise grows for the page's whole life. The next session starts a new +generation from the page. + +The history entry's author is the most recent contributor, and `contributorIds` lists the rest. +Checkpoints run from the scheduler, so there is no audit row for them: the people involved were +recorded by `editDraft` as they joined ([§12](#12-audit-log)). + +### 7.3 History is no longer always current + +`pageHistory.list` documents that the newest row is the page as it stands. On a live page, between +a refresh and the checkpoint after it, the page row is ahead of its newest version. The history view +shows that interval as a synthetic first entry, "*Current — not yet recorded*", built from the page +row. Diff and restore already read the page itself for the current side, so this is a change to the +list and to that comment, not to the model. + +### 7.4 What a crash leaves behind + +If the last scribe vanishes between an edit and its refresh, the draft's `ydoc` holds edits the page +row does not, and the server cannot render them. They stay in the draft, and **the next browser to +open the editor refreshes as soon as it has synced**, so the page catches up then. Until then readers +see the last refresh, which is at most about 30 s of work behind. The checkpoint task does not +checkpoint such a draft (`refreshedAt` < `updatedAt`), but it does record what the row holds after +the 60-minute ceiling, so the history is never further behind than that. + +Switching a page **into** live mode is refused while it has a draft: publish or discard first, so +that switching modes never publishes anything by itself. Switching **out** of it first checkpoints; +the draft, if any, then becomes an ordinary draft or buffer. + +### 7.5 Later: readers watching live + +Not in the first version. The shape it would take: a reader of a live page subscribes to a +lightweight `pageRefreshed` event and re-fetches the render. That costs no rendering in the reader's +browser, and the latency is the refresh interval. A keystroke-level view, as HedgeDoc has, would mean +readers joining the room read-only and rendering it themselves. That sends the raw source, which is +`read:source` rather than `read:pages`, and puts the markdown pipeline in every reader's bundle. + +--- + +## 8. Writes to the page while a draft exists + +### 8.1 Refused + +On a site with `features.drafts` on, and on any live document, while a draft exists: + +- `PATCH /sites/:siteId/pages/:pageId` setting any **drafted** field ([§3.2](#32-what-a-draft-holds)) + answers **409 `pageHasDraft`**, naming the contributors and `updatedAt`. Immediate fields go + through. +- Changing the editor (`PUT …/editor`) is refused with the same error: the draft is in the old + editor's format. +- Restoring a version onto the page is refused. "Restore into the draft" is the natural follow-up and + is left for later. + +In the interface, Edit always opens the draft, so none of these come up there. + +### 8.2 Not refused + +- **Imports and storage sync** (`adoptStoredPage`, the git target's pull) write the page as they do + today. Their source is outside the wiki and is authoritative by design, so the draft goes stale + ([§6.2](#62-staleness-and-merging)), or, on a live page, the next refresh overwrites the import. +- **Moves and deletion.** A draft follows its page id through a move. Deleting the page cascades to + the draft, and the delete dialog warns when there is one, naming the contributors. Restoring the + page from the recycle bin does not bring the draft back. +- **Re-rendering** (`queueRerender`, `storeRender`) changes the render, not the content. + +### 8.3 Suggestions + +Unchanged: per author, against the published page, reviewed in the approvals queue. People +suggesting edits never see a draft. + +What changes is **accepting one while the page has a draft**, since accepting is a direct edit. The +proposal is to **accept into the draft**: the review screen applies the suggestion's patch to the +draft — joining the room as an ordinary participant if one is open — and the change reaches readers +with the next publish. The reviewer becomes a contributor. This keeps suggestions flowing during a +long draft, rather than queueing for its whole life. See [§14](#14-open-questions). + +--- + +## 9. Permissions + +No new permission names. Every route here is a page-rule check in the handler, with a +`No route-level permissions:` comment. + +- **Seeing and editing a draft** takes `write:pages` or `manage:pages` on the page, from a signed-in + session. This is the rule for seeing an unpublished page (`UNPUBLISHED_PERMISSIONS`), and for the + same reason: work in progress belongs to people. **An API key never sees a draft**, and therefore + cannot write a drafted field on a page that has one. +- **Publishing and discarding** take the same, through one function, `drafts.mayPublish(actor, + page)`, so that the open question in [§14](#14-open-questions) changes one place. +- **Switching a page into or out of live mode** takes the same as publishing, since a live page + publishes on its own. +- **Joining the room** is unchanged: today's editor permission check. + +--- + +## 10. API + +All under `/_api/sites/:siteId/pages/:pageId/draft`, with schemas in `api/schemas/` as usual. + +| Method | Path | Does | +| --- | --- | --- | +| `GET` | `/draft` | The draft: `content`, `meta`, `render`, `generation`, `isStale`, `baseVersionId`, contributors (`id`, `name`), `createdAt`, `updatedAt`. 404 when there is none | +| `PUT` | `/draft` | Save. Creates the draft on the current version when there is none. Body: any of `content`, `meta`, `render`, plus `expectedUpdatedAt` | +| `DELETE` | `/draft` | Discard ([§5.4](#54-discarding)) | +| `POST` | `/draft/publish` | Publish ([§6](#6-publishing)). Body: `content`, `meta`, `render`, `reasonForChange`, `publishState`/dates, `currentHash` when merged. Answers a `PageChange` | +| `POST` | `/draft/refresh` | Live documents only ([§7.1](#71-refresh)). 400 otherwise | +| `POST` | `/draft/checkpoint` | Live documents only. **Save version** ([§7.2](#72-checkpoints)), with an optional reason | + +Plus **`GET /_api/sites/:siteId/drafts`** — the drafts on the site that the caller may see, with +`?mine=true` for the ones they contributed to. People working on something over days need a way back +to it that does not depend on remembering the page. + +**Concurrency outside a room** follows the glossary's rule: `PUT` carries the `updatedAt` it was +opened with, compared at millisecond precision inside the UPDATE, and a mismatch answers 409 with the +draft as it now stands. **Inside a room**, the room is the concurrency control. The scribe's `PUT` +omits `expectedUpdatedAt`, and the server's persists write only `ydoc`, `contributorIds` and +`updatedAt`, so the two never write the same column. + +**The page payload** gains `draft: { updatedAt, contributors } | null`, filled only for a requester +who may see it ([§9](#9-permissions)). That is what the banner and the Edit button read. + +--- + +## 11. The browser buffer + +**Implemented** (phase 1): `frontend/src/helpers/editorBuffer.js` is the storage, +`frontend/src/composables/editorBuffer.js` decides when to write and what to offer back. + +The server draft covers almost everything. The browser keeps a copy of what the server has not yet +acknowledged: a session offline, a request that failed, the first session of a page that does not +exist yet, and the seconds between keystroke and save. Until the server keeps drafts (phase 2) it is +the only copy of an unsaved edit there is. + +- **Only the editors that hold a document**: Markdown, AsciiDoc, Visual and Excalidraw. A + redirection and a blog's settings are a few fields each, quicker typed again than restored. +- **Only for a signed-in account.** Every guest is the same nobody. +- **IndexedDB**, one entry per user, site, mode and page, keyed `userId:siteId:mode:pageId` — an edit + and a suggestion of the same page are different work. A page being created has no id and no settled + path, and its route carries none to come back to, so it is keyed `userId:siteId:create:editor`: + opening a new page in the same editor is what offers it back, path and all. An entry holds the + source, the page fields a save sends (less the render, which the editor makes again, and the + password, which is not written to disk), `savedAt`, and the page's `updatedAt` as the base. Phase 2 + adds the `baseHash` and `generation`. +- **Pending assets are kept as `File`s**, in a second object store so that the entry can be rewritten + every second without copying them. Their `blob:` URLs do not survive a reload, so a restore makes + new ones and rewrites the source to match. If the files will not fit, the entry is kept without + them: the text is the part worth keeping. +- **Plain snapshots, never Yjs state.** A persisted Yjs document from another generation is exactly + the hazard of [§5.3](#53-generations). `y-indexeddb` is therefore not used. +- Written **1 s** after a change, and at once when the editor closes or the document is hidden. + Deleted when nothing is unsaved any more: a save, somebody else saving the room, a submitted + suggestion, Discard. +- **On opening the editor**, an entry that differs from what was loaded is offered back: "*You made + changes to this page on … that were never saved — Discard / Restore*". It is asked once the editor + is ready (`editorStore.isReady`), which in a room means after the binding — the room's document + would otherwise overwrite the restore, and is what the entry is compared with. One matching it says + nothing and is dropped. The prompt warns when the page has been saved since, and when other people + are in the room, since a restore replaces the whole document. Restoring goes through the editor + (`restoreEditorContent`), so it is an edit like any other and reaches a room through the binding. + Phase 2 turns the stale case into a merge. +- **Cleared for that user on logout.** Every read and write is wrapped in try/catch, because private + windows and blocked site data both make IndexedDB throw. Losing the buffer means losing the safety + net, not the editor. +- **`beforeunload` and the router's leave guard** warn while anything in the editor is unsaved, for + every editor. With no server draft yet, "the buffer has it" is not a reason to let a tab close + unasked. Once phase 2 autosaves, this narrows to a change that neither the server nor the buffer + holds. Leaving through the guard is discarding, and clears the entry. + +Assets in a draft: uploading still happens on save. **On a site using drafts that means on the +first draft save** after the asset was added, since co-authors cannot see a file that lives in one +person's browser. An asset therefore exists before the page that uses it is published. The draft's +`meta` records the asset ids it introduced, so that Discard can offer to delete them. + +--- + +## 12. Audit log + +Three new `page` actions in `AUDIT_ACTIONS`, each with its `admin.audit.actions.*` string: + +| Action | Recorded when | `meta` | +| --- | --- | --- | +| `editDraft` | A person **first** becomes a contributor to a draft, or to a live page since its last checkpoint — through `PUT` or a room | page, generation | +| `publishDraft` | A draft is published | page, version id, contributor ids | +| `discardDraft` | A draft is discarded | page, contributor ids | + +Not per autosave, which would bury the table, and not per refresh. Room contributions are recorded +by the collab controller, which has the upgrade request and its session in hand. That keeps them +written by a request handler for a person, as the audit log requires. Live checkpoints are the +scheduler's and leave no row. + +--- + +## 13. Suggested order + +Each phase ships on its own. + +1. **The browser buffer and the unload guard** ([§11](#11-the-browser-buffer)). No schema; covers the + crash case immediately for every site. +2. **`pageDrafts`, direct mode, and `contributorIds`.** The table, the API, autosave through `PUT`, + the scribe in rooms writing `content` (no `ydoc` yet), resume or discard of a leftover buffer, the + purge task. +3. **Draft mode.** `features.drafts`, Publish / Save draft / Discard, the banner, the refusals of + [§8](#8-writes-to-the-page-while-a-draft-exists), the render-permission intersection, the audit + actions, the "Not published" relabel. +4. **Rooms persist into the draft.** `ydoc`, seeding from it, merge-on-write, generations, the grace + period, `discarded`. +5. **Live documents.** `liveDocument`, refresh, checkpoints and their task, the synthetic history + entry. +6. **Later:** the stale-draft merge view, preview at `?draft`, the drafts list, accepting + suggestions into a draft, readers watching live documents, restoring a version into a draft. + +--- + +## 14. Open questions + +1. **Does publishing need a permission of its own?** Undecided. The first version uses + `write:pages`, through `drafts.mayPublish` alone. A separate page permission would let a group + draft without publishing — but approval rules already cover "somebody else signs it off", and + adding a permission is the maintainer's decision. +2. **Accepting a suggestion while a draft exists.** Proposed: accept into the draft + ([§8.3](#83-suggestions)). Alternatives: refuse until the draft is published, or accept onto the + page and let the draft go stale. +3. **Should drafts be reachable by API keys?** Proposed: no, consistent with unpublished pages. The + cost is that automation cannot touch the content of a page somebody is drafting. +4. **The default of `features.drafts` for new sites.** Proposed: off, like existing sites. +5. **Should the timings be site settings** — autosave, refresh, idle checkpoint, the 60-minute + ceiling, the 30-day purge? Proposed: constants until somebody asks. +6. **Should a site be able to forbid live documents?** Proposed: not needed — `features.collaborativeEditing` + off already does, and per page it is an editor's choice. + +--- + +## 15. Alternatives considered + +- **A revisions table, with the page pointing at its published version.** Cleaner on paper. But + every reader of `pages.content` and `pages.render` would have to follow the pointer, and the + denormalized row is exactly what lets them not care. +- **Per-user drafts**, like `pageEditSubmissions`. They do not give "several people, several days" + without merging drafts into each other, and the decision was a shared draft per page. +- **Folding suggestions into drafts.** Rejected: a suggestion from somebody who may only read is a + proposal that may be declined, and a draft is the editors' own work. The two have different + audiences, lifetimes and permissions. +- **A draft as a hidden copy of the page at another path.** It collides with the tree, links, + search, ids and storage targets, which would all have to learn to ignore it. +- **Autosaving onto the page and merging recent history rows**, overwriting the newest version while + the same author keeps editing. It spares history, but not search, git, webhooks or notifications. It + also changes a version that an audit entry already points at by id. +- **Auto-publishing on the server when a room empties.** It needs Puppeteer, works for markdown only, + and needs a cluster-wide answer to "is the room empty". The scribe plus checkpoints + ([§7](#7-live-documents)) get the same result for every editor. +- **Electing the last participant to publish as they leave.** The last participant is exactly the one + whose tab just crashed. +- **`y-indexeddb` for the browser buffer.** It merges stale Yjs state back into a room that may have + been re-seeded since ([§5.3](#53-generations)). Plain snapshots plus a merge prompt do not have + that failure. diff --git a/frontend/src/components/EditorAsciidoc.vue b/frontend/src/components/EditorAsciidoc.vue index a84a5e0a4..c890546db 100644 --- a/frontend/src/components/EditorAsciidoc.vue +++ b/frontend/src/components/EditorAsciidoc.vue @@ -300,7 +300,12 @@ import { import { useI18n } from 'vue-i18n' import { useBlockCompletions } from '@/composables/blockCompletions' -import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab' +import { + bindCollabEditor, + isCollabActive, + startCollabSession, + stopCollabSession +} from '@/composables/collab' import { dialog } from '@/composables/dialog' import { useImagePropertiesLens } from '@/composables/imagePropertiesLens' import { notify } from '@/composables/notify' @@ -1380,6 +1385,21 @@ function reloadEditorContent({ replacements = [] } = {}) { } } +/** + * Put back a whole source the author wrote and never saved — see `composables/editorBuffer.js`. + * + * One edit over the full range rather than `setValue`, for the reasons the Markdown editor's copy of + * this gives: it stays undoable, and in a collaborative session it reaches the room through the + * binding as this author's change. + */ +function restoreEditorContent({ content }) { + const model = editor.getModel() + editor.pushUndoStop() + editor.executeEdits('restore', [{ range: model.getFullModelRange(), text: content ?? '' }]) + editor.pushUndoStop() + syncContentToStore.flush() +} + // MOUNTED onMounted(async () => { @@ -1611,19 +1631,31 @@ onMounted(async () => { collabWatchers.push( watch( () => collabStore.status, - (status) => { - if (status === 'connected') { - bindCollabEditor(editor) - } - if (status !== 'connecting') { - editor.updateOptions({ readOnly: false }) - } + async (status) => { if (status === 'denied') { notify({ type: 'warning', message: t('editor.collab.notAllowed') }) } + if (status === 'connecting') { + return + } + editor.updateOptions({ readOnly: false }) + if (status === 'connected') { + await bindCollabEditor(editor) + // -> The editor was closed while the binding loaded, and is not ready for anything + if (!isCollabActive()) { + return + } + } + /* + Holding the room's document now, or carrying on as a plain editor without one -- and the + store says so before anything asks: what the room holds may be somebody's unsaved edit, + and the debounce would leave the store on the loaded page for another half second. + */ + syncContentToStore.flush() + editorStore.isReady = true } ), /* @@ -1656,6 +1688,13 @@ onMounted(async () => { EVENT_BUS.on('insertBlock', insertBlockClb) EVENT_BUS.on('replaceBlockContent', replaceBlockContentClb) EVENT_BUS.on('reloadEditorContent', reloadEditorContent) + EVENT_BUS.on('restoreEditorContent', restoreEditorContent) + + // -> Ready now, unless a room is still to answer -- the status watcher above says when that is. + // Not if the editor was closed while this hook was still awaiting. + if (!collabEnabled.value && editorStore.isActive) { + editorStore.isReady = true + } }) onBeforeUnmount(() => { @@ -1668,6 +1707,7 @@ onBeforeUnmount(() => { EVENT_BUS.off('insertBlock', insertBlockClb) EVENT_BUS.off('replaceBlockContent', replaceBlockContentClb) EVENT_BUS.off('reloadEditorContent', reloadEditorContent) + EVENT_BUS.off('restoreEditorContent', restoreEditorContent) pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true) monacoRef.value?.removeEventListener('dragover', onEditorDragOver) monacoRef.value?.removeEventListener('drop', onEditorDrop) diff --git a/frontend/src/components/EditorExcalidraw.vue b/frontend/src/components/EditorExcalidraw.vue index 959dcf90b..bcdf1dfc6 100644 --- a/frontend/src/components/EditorExcalidraw.vue +++ b/frontend/src/components/EditorExcalidraw.vue @@ -139,6 +139,9 @@ const adopting = new Set() /** Bumped per sync, so a slow export cannot overwrite a newer one. */ let syncToken = 0 +/** The source as this editor last wrote it to the store, which is what a change is measured against. */ +let lastWritten = null + // COMPUTED /** @@ -197,9 +200,16 @@ async function writeToStore({ touch }) { contentLoaded: true }) pageStore.setRender(render) - if (touch) { + /* + An edit only when the drawing actually changed. Joining a room runs through here too -- the room's + drawing goes onto the canvas and into the store -- and for a room holding exactly what was loaded + that is nothing anybody did; marking it made every visit unsaved, which the leave warning then + asked about on the way out of a drawing nobody had touched. + */ + if (touch && content !== lastWritten) { editorStore.lastChangeTimestamp = Temporal.Now.instant() } + lastWritten = content } /** @@ -365,6 +375,31 @@ function reloadEditorContent({ replacements = [] } = {}) { pageStore.setRender(render) } +/** + * Put back a whole drawing the author made and never saved — see `composables/editorBuffer.js`. + * + * Through the canvas, as a change the author made: Excalidraw reports it to `onChange` like any other, + * which publishes it to the room and writes it to the store, and it is recorded for undo. Its files go + * in first, since an image element drawn before its file arrives draws as a placeholder. The pictures + * pasted in the lost session are pending assets again by now, and their entries already point at the + * new `blob:` URLs the buffer made for them. + * + * Only the background is taken from the stored app state: the rest of it is how the canvas is being + * looked at, which belongs to the author in front of it rather than to the drawing. + */ +function restoreEditorContent({ content }) { + if (!handle) { + return + } + const scene = parseScene(content) + handle.addFiles(Object.values(scene.files)) + handle.updateScene({ + elements: scene.elements, + appState: { viewBackgroundColor: scene.appState.viewBackgroundColor }, + captureUpdate: CaptureUpdateAction.IMMEDIATELY + }) +} + /** Take the room's drawing onto this canvas. */ function applyRemote() { const handles = collabHandles() @@ -439,8 +474,11 @@ onMounted(async () => { onPointerUpdate: publishPointer }) state.ready = true + // -> What the loaded drawing serializes to, set before a room can answer and be measured against it + lastWritten = serializeScene(handle.getScene()) EVENT_BUS.on('reloadEditorContent', reloadEditorContent) + EVENT_BUS.on('restoreEditorContent', restoreEditorContent) /* Live collaboration. Unlike the text editors there is nothing to lock while the room answers: a @@ -457,13 +495,27 @@ onMounted(async () => { collabWatchers.push( watch( () => collabStore.status, - (status) => { + async (status) => { if (status === 'connected') { enableCollab() } if (status === 'denied') { notify({ type: 'warning', message: t('editor.collab.notAllowed') }) } + if (status === 'connecting') { + return + } + /* + Holding the room's drawing now, or carrying on alone without one -- and the store says so + before anything asks: what the room holds may be somebody's unsaved edit, and the debounce + would leave the store on the loaded drawing for a while yet. + */ + syncToStore.cancel() + await writeToStore({ touch: true }) + // -> Closed while the export ran + if (handle) { + editorStore.isReady = true + } } ), watch( @@ -486,6 +538,12 @@ onMounted(async () => { neither until this runs. Not an edit, so it does not touch the change clock -- see `writeToStore`. */ await writeToStore({ touch: false }) + + // -> Ready now, unless a room is still to answer -- the status watcher above says when that is. + // Not if the editor was closed while this hook was still awaiting. + if (!collabEnabled.value && editorStore.isActive && handle) { + editorStore.isReady = true + } }) /* @@ -512,6 +570,7 @@ onBeforeUnmount(() => { stopCollabSession() collaborating = false EVENT_BUS.off('reloadEditorContent', reloadEditorContent) + EVENT_BUS.off('restoreEditorContent', restoreEditorContent) handle?.destroy() handle = null }) diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index e8f5004da..8bc3d79ca 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -342,7 +342,12 @@ import { import { useI18n } from 'vue-i18n' import { useBlockCompletions } from '@/composables/blockCompletions' -import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab' +import { + bindCollabEditor, + isCollabActive, + startCollabSession, + stopCollabSession +} from '@/composables/collab' import { dialog } from '@/composables/dialog' import { useFenceCompletions } from '@/composables/fenceCompletions' import { useImagePropertiesLens } from '@/composables/imagePropertiesLens' @@ -1635,6 +1640,22 @@ function reloadEditorContent({ replacements = [] } = {}) { } } +/** + * Put back a whole source the author wrote and never saved — see `composables/editorBuffer.js`. + * + * The one case where replacing everything IS the edit, so it is done as one: a single edit over the + * full range rather than `setValue`, which keeps it undoable and, in a collaborative session, carries + * it into the room through the binding as this author's change. Only asked for once `isReady` says + * the binding is in place, since a source put in before it would be overwritten by the room's. + */ +function restoreEditorContent({ content }) { + const model = editor.getModel() + editor.pushUndoStop() + editor.executeEdits('restore', [{ range: model.getFullModelRange(), text: content ?? '' }]) + editor.pushUndoStop() + syncContentToStore.flush() +} + // MOUNTED onMounted(async () => { @@ -1939,19 +1960,31 @@ onMounted(async () => { collabWatchers.push( watch( () => collabStore.status, - (status) => { - if (status === 'connected') { - bindCollabEditor(editor) - } - if (status !== 'connecting') { - editor.updateOptions({ readOnly: false }) - } + async (status) => { if (status === 'denied') { notify({ type: 'warning', message: t('editor.collab.notAllowed') }) } + if (status === 'connecting') { + return + } + editor.updateOptions({ readOnly: false }) + if (status === 'connected') { + await bindCollabEditor(editor) + // -> The editor was closed while the binding loaded, and is not ready for anything + if (!isCollabActive()) { + return + } + } + /* + Holding the room's document now, or carrying on as a plain editor without one -- and the + store says so before anything asks: what the room holds may be somebody's unsaved edit, + and the debounce would leave the store on the loaded page for another half second. + */ + syncContentToStore.flush() + editorStore.isReady = true } ), /* @@ -1985,6 +2018,13 @@ onMounted(async () => { EVENT_BUS.on('insertBlock', insertBlockClb) EVENT_BUS.on('replaceBlockContent', replaceBlockContentClb) EVENT_BUS.on('reloadEditorContent', reloadEditorContent) + EVENT_BUS.on('restoreEditorContent', restoreEditorContent) + + // -> Ready now, unless a room is still to answer -- the status watcher above says when that is. + // Not if the editor was closed while this hook was still awaiting. + if (!collabEnabled.value && editorStore.isActive) { + editorStore.isReady = true + } }) onBeforeUnmount(() => { @@ -1998,6 +2038,7 @@ onBeforeUnmount(() => { EVENT_BUS.off('insertBlock', insertBlockClb) EVENT_BUS.off('replaceBlockContent', replaceBlockContentClb) EVENT_BUS.off('reloadEditorContent', reloadEditorContent) + EVENT_BUS.off('restoreEditorContent', restoreEditorContent) pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true) monacoRef.value?.removeEventListener('dragover', onEditorDragOver) monacoRef.value?.removeEventListener('drop', onEditorDrop) diff --git a/frontend/src/components/EditorVisual.vue b/frontend/src/components/EditorVisual.vue index 36fd8d646..03e958c68 100644 --- a/frontend/src/components/EditorVisual.vue +++ b/frontend/src/components/EditorVisual.vue @@ -1291,12 +1291,26 @@ function onEditorDrop(event) { */ const collabWatchers = [] +/** + * The source as this editor last wrote it, which is what a change is measured against. + * + * Measured against the editor's OWN serialization rather than the page store's: what was loaded is + * the stored markdown, which the serializer may spell differently without anything having changed, + * and comparing the two made merely joining a collaborative room count as an edit -- `enableCollab` + * writes the room's document to the store, and an unchanged one was still marked unsaved, which the + * leave warning then asked about on the way out of a page nobody had touched. + */ +let lastSerialized = null + const syncContentToStore = debounce(() => { if (!editor) { return } const markdown = editor.getMarkdown() - editorStore.$patch({ lastChangeTimestamp: Temporal.Now.instant() }) + if (markdown !== lastSerialized) { + lastSerialized = markdown + editorStore.$patch({ lastChangeTimestamp: Temporal.Now.instant() }) + } pageStore.$patch({ content: markdown, // -> What the author has written IS the source, whatever the load did or did not deliver; see @@ -1334,6 +1348,21 @@ function reloadEditorContent({ replacements = [] } = {}) { syncContentToStore.flush() } +/** + * Put back a whole source the author wrote and never saved — see `composables/editorBuffer.js`. + * + * Parsed into the document as one transaction, which in a collaborative session `ySyncPlugin` carries + * into the room as this author's change. Only asked for once `isReady` says the room's document is in + * place, since one put in before it would be replaced by the room's. + */ +function restoreEditorContent({ content }) { + if (!editor) { + return + } + editor.setMarkdown(content ?? '') + syncContentToStore.flush() +} + /** Which blocks this site has, for the parameters form and for saying what is switched off. */ async function loadSiteBlocks() { try { @@ -1403,6 +1432,8 @@ onMounted(async () => { onChange: syncContentToStore, onSelectionChange: refreshActive }) + // -> What the loaded page serializes to, before anybody has changed anything + lastSerialized = editor.getMarkdown() EVENT_BUS.on('insertAsset', insertAssetClb) EVENT_BUS.on('insertBlock', insertBlockClb) @@ -1444,6 +1475,13 @@ onMounted(async () => { } if (status !== 'connecting') { editor.view.setProps({ editable: () => true }) + /* + Holding the room's document now, or carrying on as a plain editor without one -- and the + store says so before anything asks: what the room holds may be somebody's unsaved edit, + and the debounce would leave the store on the loaded page for another half second. + */ + syncContentToStore.flush() + editorStore.isReady = true } if (status === 'denied') { notify({ type: 'warning', message: t('editor.collab.notAllowed') }) @@ -1478,6 +1516,14 @@ onMounted(async () => { refreshActive() editor.view.focus() }) + + EVENT_BUS.on('restoreEditorContent', restoreEditorContent) + + // -> Ready now, unless a room is still to answer -- the status watcher above says when that is. + // Not if the editor was closed while this hook was still awaiting. + if (!collabEnabled.value && editorStore.isActive) { + editorStore.isReady = true + } }) onBeforeUnmount(() => { @@ -1497,6 +1543,7 @@ onBeforeUnmount(() => { EVENT_BUS.off('insertTable', insertTableClb) EVENT_BUS.off('replaceBlockContent', replaceBlockContentClb) EVENT_BUS.off('reloadEditorContent', reloadEditorContent) + EVENT_BUS.off('restoreEditorContent', restoreEditorContent) editor?.destroy() editor = null }) diff --git a/frontend/src/components/HeaderNav.vue b/frontend/src/components/HeaderNav.vue index 94e063ad8..30c8f6c05 100644 --- a/frontend/src/components/HeaderNav.vue +++ b/frontend/src/components/HeaderNav.vue @@ -140,6 +140,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' +import { confirmDiscardUnsaved } from '@/composables/editorBuffer' import { useMinWidth } from '@/composables/screen' import { splitLocalePath } from '@/helpers/pagePaths' @@ -257,28 +258,28 @@ onBeforeUnmount(() => { * Out of the editor, on the way home. The navigation itself is the link's. * * The logo is the way out of any screen the wiki can get into, which is why it is drawn in every one - * of them -- so it has to close the editor as well as go home. Leaving the editor is otherwise the - * job of the page view's route watcher, and that cannot help here: the editor opens WITHOUT changing - * the URL, so an author editing the home page is already at `/`, the router treats the push as a - * duplicate and runs no navigation at all, and the watcher never fires. The result was a logo that - * looked broken on the one page it is most likely to be tried from. + * of them -- so it has to be a way out of the editor as well. Almost always the link does that on its + * own: the editor has a path of its own (`/_edit/...`, `/_create/...`), so the click is a real + * navigation, the page view's guard asks about anything unsaved, and its route watcher closes the + * editor. Nothing is done here in that case, and must not be: closing the editor before the router + * runs left the guard looking at no editor at all, so unsaved edits were thrown away without a word. * - * The page is re-read in exactly that case. Nothing else is going to: the store holds what the editor - * last wrote into it -- the source AND the render -- so closing the editor over it would draw - * somebody's unsaved draft as though it were the page. Where the click really does navigate, the - * destination is loaded anyway and a reload here would be a second request for a page already on its - * way. - * - * A page being CREATED never reaches that branch: it is written at `/_create/...`, which is never the - * home path, so the click is a real navigation and the page view's watcher does the rest. + * What is left is the click that goes NOWHERE, which no guard or watcher will ever see: a suggestion + * being written on the home page, which is written at the page's own address. The router treats the + * push as a duplicate, so this asks the guard's question itself, then closes the editor. The page is + * re-read in that case too, since nothing else is going to: the store holds what the editor last wrote + * into it -- the source AND the render -- and closing the editor over it would draw somebody's + * unsaved draft as though it were the page. */ -function leaveEditorForHome() { - if (!editorStore.isActive) { +async function leaveEditorForHome() { + if (!editorStore.isActive || route.path !== homePath.value) { + return + } + if (!(await confirmDiscardUnsaved(t))) { return } - const isSamePlace = route.path === homePath.value editorStore.closeEditor() - if (isSamePlace && pageStore.id) { + if (pageStore.id) { // -> Failing to put the page back is not a reason to keep the reader in an editor they have just // left; the view draws what it has pageStore.pageLoad({ id: pageStore.id }).catch(() => {}) diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue index 376a14948..a15bcf35c 100644 --- a/frontend/src/components/PageHeader.vue +++ b/frontend/src/components/PageHeader.vue @@ -609,6 +609,16 @@ function onEditableBlur(field, event) { } async function discardChanges() { + const hadPendingChanges = editorStore.hasPendingChanges + const wasSuggesting = isSuggesting.value + + /* + Thrown away, which is said before anything navigates: the router asks before leaving an editor + with unsaved edits, and the browser buffer keeps whatever is unsaved when the editor closes -- + neither of which is what the author just asked for. + */ + editorStore.settleChanges() + /* Abandoning a page that is being written, which is a different thing from reverting an edit: there is nothing stored to go back to, so the editor closes and the reader is put back on the site. @@ -634,9 +644,6 @@ async function discardChanges() { return } - const hadPendingChanges = editorStore.hasPendingChanges - const wasSuggesting = isSuggesting.value - loading.show() try { /* diff --git a/frontend/src/composables/editorBuffer.js b/frontend/src/composables/editorBuffer.js new file mode 100644 index 000000000..2db291809 --- /dev/null +++ b/frontend/src/composables/editorBuffer.js @@ -0,0 +1,407 @@ +import { onBeforeUnmount, onMounted, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { debounce } from 'es-toolkit/function' +import { pick } from 'es-toolkit/object' +import { isEqual } from 'es-toolkit/predicate' + +import { confirm } from '@/composables/dialog' +import { notify } from '@/composables/notify' +import { + bufferKey, + deleteBufferEntry, + readBufferEntry, + readBufferFiles, + writeBufferEntry +} from '@/helpers/editorBuffer' +import { useCollabStore } from '@/stores/collab' +import { useEditorStore } from '@/stores/editor' +import { SAVED_PAGE_FIELDS, usePageStore } from '@/stores/page' +import { useSiteStore } from '@/stores/site' +import { useUserStore } from '@/stores/user' + +/** + * Keep what is typed into an editor in this browser until it is saved, and offer it back if it never + * was -- a closed tab, a crashed browser, a laptop that ran out of battery. + * + * Phase 1 of `dev/specs/drafts.md` (§11): the server keeps nothing until a save, so this browser is + * the only place an unsaved edit can survive the document that holds it. The storage is + * `helpers/editorBuffer.js`; this decides when to write to it and what to do with what is there. + * + * **Only the editors that hold a document.** Markdown, AsciiDoc, Visual and Excalidraw. A redirection + * and a blog's settings are a handful of fields each, quicker typed again than restored, and neither + * editor is asked to take a document from outside. + * + * **Only for a signed-in account.** Every guest is the same nobody, and a suggestion typed on a shared + * computer by one of them is not something to offer to the next. + * + * The life of an entry: + * + * - **Written** a second after a change, while there is anything unsaved, and once more when the + * editor closes or the document is hidden -- the last chance a closing tab gives. + * - **Deleted** when nothing is unsaved any more: a save, somebody else saving the room this author + * is in, a submitted suggestion, and Discard, which settles the editor first so that this sees + * nothing left to keep. + * - **Offered back** when the editor next opens on the same page, once it is ready to take a + * document -- after a collaborative room has synced, since the room's binding would otherwise + * overwrite it. An entry that matches what was loaded says nothing and is dropped in silence. + * + * Restoring goes through the editor (`restoreEditorContent`) rather than into the page store, so that + * it is an edit like any other: undoable where the editor allows it, and carried into a collaborative + * room by the binding as this author's change. + * + * Called once, from the page view, which is what mounts the editors. + */ + +/** How long after a change the buffer is written. The window an unsaved keystroke can be lost in. */ +const WRITE_DELAY = 1000 + +/** The editors whose work is kept. See the note above on why not the other two. */ +const BUFFERED_EDITORS = new Set(['markdown', 'asciidoc', 'visual', 'excalidraw']) + +/** + * The page fields kept beside the source: what a save sends, less the render -- the editor makes that + * again from the source -- and the password, which is not written to disk for the sake of a restore. + */ +const BUFFERED_FIELDS = SAVED_PAGE_FIELDS.filter( + (field) => !['content', 'render', 'password'].includes(field) +) + +/** + * And for a page being created, where it was going to go. An edit has a path already, and changing + * it is a move, which is not something a save does. + */ +const BUFFERED_CREATE_FIELDS = [...BUFFERED_FIELDS, 'path', 'locale'] + +/** + * Ask whether to throw away what is unsaved in the editor, before something closes it. + * + * Resolves true straight away when there is nothing to lose. A yes is settled before it resolves, so + * that the buffer does not keep what the author has just said to throw away, and so that the router's + * guard, which asks this same question, does not ask it a second time on the navigation that follows. + * + * The router's guard in `pages/Index.vue` is the usual caller. Anything that closes the editor WITHOUT + * a navigation the router would see has to ask here itself -- the header logo, clicked on the page the + * author is already at. + * + * @param {Function} t The caller's `t`; there is no i18n instance to reach from outside a component. + * @returns {Promise} Whether the editor may close. + */ +export function confirmDiscardUnsaved(t) { + const editorStore = useEditorStore() + if (!editorStore.isActive || !editorStore.hasUnsavedEdits) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + confirm({ + title: t('editor.unsaved.title'), + message: t('editor.unsaved.body'), + cancel: true, + color: 'negative', + okLabel: t('common.actions.discard') + }) + .onOk(() => { + editorStore.settleChanges() + resolve(true) + }) + .onCancel(() => resolve(false)) + }) +} + +export function useEditorBuffer() { + const { t } = useI18n() + + const collabStore = useCollabStore() + const editorStore = useEditorStore() + const pageStore = usePageStore() + const siteStore = useSiteStore() + const userStore = useUserStore() + + /** + * The editor being kept: the key it is filed under, decided when it opened -- a page that is + * created mid-session gets an id, and the entry is still the one it started as -- and whether + * writing has begun. Not before an entry already there has been dealt with, since the first write + * would replace it. + */ + let session = null + + // -> Bumped per session, so that an answer arriving for one that has since closed is dropped + let sessionSeq = 0 + + function fieldsFor(mode) { + // -> A suggestion is the source and nothing else; the page's fields are not the submitter's to change + if (mode === 'suggest') { + return [] + } + return mode === 'create' ? BUFFERED_CREATE_FIELDS : BUFFERED_FIELDS + } + + /** + * The edit as it stands, as plain data. + * + * The fields go through JSON: they are what a save sends as JSON anyway, and a store's arrays and + * objects are reactive proxies that IndexedDB refuses to clone. + */ + function snapshot(key, mode) { + return { + key, + userId: userStore.id, + siteId: siteStore.id, + pageId: pageStore.id || null, + mode, + editor: editorStore.editor, + savedAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }), + baseUpdatedAt: pageStore.updatedAt || null, + content: pageStore.content ?? '', + fields: JSON.parse(JSON.stringify(pick(pageStore, fieldsFor(mode)))), + assets: editorStore.pendingAssets.map(({ id, kind, fileName, blobUrl }) => ({ + id, + kind, + fileName, + blobUrl + })) + } + } + + /** Write what is unsaved, or forget the entry when nothing is. */ + function persist() { + if (!session?.armed) { + return + } + // -> Signed out since the editor opened: what was kept for that account has just been cleared, + // and a write landing now would put it straight back + if (!userStore.authenticated || userStore.id !== session.userId) { + return + } + if (editorStore.hasUnsavedEdits) { + // -> Taken now, synchronously: by the time the write lands the store may hold another page + const entry = snapshot(session.key, session.mode) + const files = editorStore.pendingAssets.map(({ id, file }) => ({ id, file })) + writeBufferEntry(entry, files) + } else { + deleteBufferEntry(session.key) + } + } + + const schedulePersist = debounce(persist, WRITE_DELAY) + + function arm() { + session.armed = true + if (editorStore.hasUnsavedEdits) { + schedulePersist() + } + } + + /** Whether an entry holds anything the editor that just opened does not. */ + function differsFromLoaded(entry) { + if ((entry.content ?? '') !== (pageStore.content ?? '')) { + return true + } + const fields = JSON.parse(JSON.stringify(pick(pageStore, fieldsFor(entry.mode)))) + return !isEqual(entry.fields ?? {}, fields) + } + + /** Two instants as the API or the room spelled them, compared as instants rather than as strings. */ + function sameInstant(a, b) { + try { + return Temporal.Instant.compare(Temporal.Instant.from(a), Temporal.Instant.from(b)) === 0 + } catch { + return a === b + } + } + + /** Ask whether to put an entry back. Resolves true to restore, false to throw it away. */ + function ask(entry) { + const date = userStore.formatDateTime(t, entry.savedAt) + const message = [ + entry.mode === 'create' + ? t('editor.restore.bodyNewPage', { date }) + : t('editor.restore.body', { date }) + ] + // -> Saved by somebody since: the entry is the whole page as this author had it, so restoring it + // puts back what was there before that save as well + if ( + entry.mode === 'edit' && + entry.baseUpdatedAt && + pageStore.updatedAt && + !sameInstant(entry.baseUpdatedAt, pageStore.updatedAt) + ) { + message.push(t('editor.restore.stale')) + } + // -> And replaced for everybody in the room, who may well have carried on without this author + if (collabStore.participants.some((participant) => !participant.isSelf)) { + message.push(t('editor.restore.othersEditing')) + } + return new Promise((resolve) => { + confirm({ + title: t('editor.restore.title'), + message, + cancel: true, + persistent: true, + okLabel: t('editor.restore.restore'), + cancelLabel: t('common.actions.discard') + }) + .onOk(() => resolve(true)) + .onCancel(() => resolve(false)) + }) + } + + /** + * Put an entry back into the open editor. + * + * Pending assets come first: their old `blob:` URLs died with the document that made them, so each + * file gets a new one and the source is rewritten to match before the editor sees it. Then the page + * fields, straight into the store as the properties panel writes them -- the three a room shares go + * on from there through its own watcher. Then the source, through the editor. + */ + async function restore(entry) { + let content = entry.content ?? '' + const files = new Map((await readBufferFiles(entry.key)).map(({ id, file }) => [id, file])) + for (const asset of entry.assets ?? []) { + const file = files.get(asset.id) + if (!file || editorStore.pendingAssets.some((item) => item.id === asset.id)) { + continue + } + const blobUrl = URL.createObjectURL(file) + content = content.split(asset.blobUrl).join(blobUrl) + editorStore.pendingAssets.push({ + id: asset.id, + kind: asset.kind, + file, + fileName: asset.fileName, + blobUrl + }) + } + if (Object.keys(entry.fields ?? {}).length > 0) { + pageStore.$patch(entry.fields) + } + EVENT_BUS.emit('restoreEditorContent', { content }) + // -> Unsaved again, whatever the editor makes of the source: the fields alone are a change + editorStore.lastChangeTimestamp = Temporal.Now.instant() + notify({ type: 'positive', message: t('editor.restore.restored') }) + } + + /** The editor is ready: deal with what is kept for it, then start keeping. */ + async function begin() { + const seq = ++sessionSeq + const mode = editorStore.mode + const key = + userStore.authenticated && BUFFERED_EDITORS.has(editorStore.editor) + ? bufferKey({ + userId: userStore.id, + siteId: siteStore.id, + mode, + pageId: pageStore.id, + editor: editorStore.editor + }) + : null + session = { key, mode, userId: userStore.id, armed: false } + if (!key) { + return + } + + const entry = await readBufferEntry(key) + if (seq !== sessionSeq) { + return + } + // -> A page converted to another editor since: that source would mean nothing to this one + if (!entry || entry.editor !== editorStore.editor || !differsFromLoaded(entry)) { + if (entry) { + await deleteBufferEntry(key) + } + arm() + return + } + + const accepted = await ask(entry) + if (seq !== sessionSeq) { + return + } + if (accepted) { + await restore(entry) + if (seq !== sessionSeq) { + return + } + } else { + await deleteBufferEntry(key) + } + arm() + } + + /** The editor has closed: one last write, or the entry goes if there is nothing left to keep. */ + function end() { + sessionSeq++ + schedulePersist.cancel() + persist() + session = null + } + + /* + Synchronous, because the moment the editor closes is the moment that matters and it does not last: + leaving the editor closes it BEFORE the next page is loaded into the store (see the route watcher + in `pages/Index.vue`), so in this tick the store still holds the page that was being edited. + */ + watch( + () => editorStore.isReady, + (ready) => (ready ? begin() : session && end()), + { flush: 'sync' } + ) + + watch( + () => [ + editorStore.lastChangeTimestamp, + editorStore.lastSaveTimestamp, + editorStore.pendingAssets.length + ], + () => { + if (session?.armed) { + schedulePersist() + } + } + ) + + /* + A tab being closed, or put away on a phone that may never bring it back: write now rather than in a + second's time, which is the one chance there is. IndexedDB may not finish before the document goes, + but it usually does, and the browser's own leave warning covers the rest. + */ + function onHidden() { + if (document.visibilityState === 'hidden') { + flushNow() + } + } + + function flushNow() { + if (session?.armed) { + schedulePersist.flush() + } + } + + /* + And the warning itself, for a document being unloaded with something in the editor that has not + been saved. The browser writes the words; all a page can do is ask for the prompt. Raised for every + editor, the two that keep nothing included -- the prompt costs nothing to dismiss, and the loss it + stops is silent. + */ + function onBeforeUnload(event) { + if (editorStore.isActive && editorStore.hasUnsavedEdits) { + event.preventDefault() + // -> Still what some browsers look at, in place of `preventDefault` alone + event.returnValue = '' + } + } + + onMounted(() => { + document.addEventListener('visibilitychange', onHidden) + window.addEventListener('pagehide', flushNow) + window.addEventListener('beforeunload', onBeforeUnload) + }) + + onBeforeUnmount(() => { + document.removeEventListener('visibilitychange', onHidden) + window.removeEventListener('pagehide', flushNow) + window.removeEventListener('beforeunload', onBeforeUnload) + if (session) { + end() + } + }) +} diff --git a/frontend/src/helpers/editorBuffer.js b/frontend/src/helpers/editorBuffer.js new file mode 100644 index 000000000..14fd9435e --- /dev/null +++ b/frontend/src/helpers/editorBuffer.js @@ -0,0 +1,234 @@ +/** + * The browser's copy of an edit nobody has saved yet, in IndexedDB. + * + * This file is the storage alone; when to write, when to offer an entry back and how it is put into + * an editor are `composables/editorBuffer.js`. See `dev/specs/drafts.md` §11 for the design. + * + * **Every call here fails quietly.** A private window, blocked site data, a full disk and a browser + * with no IndexedDB at all each make it throw, and none of them is a reason for the editor to stop + * working -- losing the buffer is losing a safety net, not the edit in front of the author. So each + * export catches, warns once, and answers as though there were nothing stored. + * + * Two object stores, because the pictures pasted into an edit are kept too: + * + * - `entries` -- one per user and page (see `bufferKey`): the source, the page fields and a list of + * the pending assets. Rewritten whole on every write, a second after a change. + * - `files` -- one per pending asset, holding its `File`. Separate so that the entry can be + * rewritten every second without copying a 20 MB screenshot along with it each time: a file is + * written once, when it first appears, and deleted with the entry or once the edit stops using it. + * Their `blob:` URLs die with the document that made them, so the file is the only thing worth + * keeping; a restore makes new URLs and rewrites the source to match. + */ + +const DB_NAME = 'wiki-editor-buffer' +const DB_VERSION = 1 +const ENTRIES = 'entries' +const FILES = 'files' + +/** The open database, or the failure to open it -- kept either way, so a refusal is not retried every second. */ +let dbPromise = null +let warned = false + +function warnOnce(err) { + if (!warned) { + warned = true + console.warn(`Unsaved edits cannot be kept in this browser: ${err?.message ?? err}`) + } +} + +function openDb() { + if (!dbPromise) { + dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + request.onupgradeneeded = () => { + const db = request.result + db.createObjectStore(ENTRIES, { keyPath: 'key' }).createIndex('userId', 'userId') + db.createObjectStore(FILES, { keyPath: 'id' }).createIndex('key', 'key') + } + request.onsuccess = () => { + const db = request.result + // -> Another tab opening a newer version of this database has to be able to; this one goes + // on without a buffer rather than blocking it + db.onversionchange = () => db.close() + resolve(db) + } + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error('The editor buffer database is blocked.')) + }) + } + return dbPromise +} + +/** One request as a promise. */ +function done(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) +} + +/** + * Run `work` in one transaction, resolving with what it returned once the transaction has COMMITTED + * rather than once its last request answered -- a write is only kept when the whole of it is. + */ +async function transact(stores, mode, work) { + const db = await openDb() + return new Promise((resolve, reject) => { + const tx = db.transaction(stores, mode) + let result + tx.oncomplete = () => resolve(result) + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error ?? new Error('The editor buffer write was aborted.')) + Promise.resolve(work(tx)).then( + (value) => { + result = value + }, + (err) => { + try { + tx.abort() + } catch { + // -> Already finished, which the handlers above have dealt with + } + reject(err) + } + ) + }) +} + +/** Delete one entry's files, keeping those named in `keep`. Part of a transaction already open. */ +async function pruneFiles(tx, key, keep = new Set()) { + const files = tx.objectStore(FILES) + const ids = await done(files.index('key').getAllKeys(key)) + await Promise.all(ids.filter((id) => !keep.has(id)).map((id) => done(files.delete(id)))) +} + +/** + * Where an edit is kept. + * + * Per user, so that an account signed in after another on the same browser is never offered what the + * first one wrote. Per site and page, and per MODE, because a suggestion and an edit of the same page + * are two different pieces of work that happen to share a source. + * + * A page being created has no id to be filed under, nor a path that is settled -- the save-as dialog + * is where that is decided, and the create route carries no path of its own to come back to after a + * crash. So it is filed under its editor: opening a new page with the same editor is what offers it + * back, with the path it was going to have. + * + * @returns {?string} Null when there is nobody to file it under. + */ +export function bufferKey({ userId, siteId, mode, pageId, editor }) { + if (!userId || !siteId) { + return null + } + if (mode === 'create') { + return editor ? `${userId}:${siteId}:create:${editor}` : null + } + return pageId ? `${userId}:${siteId}:${mode}:${pageId}` : null +} + +/** @returns {Promise} The entry, or null for none -- and for a buffer that cannot be read. */ +export async function readBufferEntry(key) { + try { + return ( + (await transact([ENTRIES], 'readonly', (tx) => done(tx.objectStore(ENTRIES).get(key)))) ?? + null + ) + } catch (err) { + warnOnce(err) + return null + } +} + +/** @returns {Promise>} The files kept with an entry. */ +export async function readBufferFiles(key) { + try { + const rows = await transact([FILES], 'readonly', (tx) => + done(tx.objectStore(FILES).index('key').getAll(key)) + ) + return (rows ?? []).map(({ id, file }) => ({ id, file })) + } catch (err) { + warnOnce(err) + return [] + } +} + +/** + * Keep an edit, replacing whatever was kept for it before. + * + * Files already stored are left where they are, and the ones the edit no longer uses go. When the + * files are what will not fit -- a quota is the likely failure, and a video is the likely cause -- the + * entry is written again without them: the text is the part worth keeping, and a restored page with a + * broken image in it is far better than no restore at all. + * + * @param {object} entry Plain data; nothing reactive survives being cloned into IndexedDB. + * @param {Array<{ id: string, file: File }>} files The pending assets the entry lists. + * @returns {Promise} Whether it was kept. + */ +export async function writeBufferEntry(entry, files = []) { + const write = (withFiles) => + transact([ENTRIES, FILES], 'readwrite', async (tx) => { + const store = tx.objectStore(FILES) + const keep = new Set() + if (withFiles) { + const stored = new Set(await done(store.index('key').getAllKeys(entry.key))) + for (const { id, file } of files) { + keep.add(id) + if (!stored.has(id)) { + await done(store.put({ id, key: entry.key, file })) + } + } + } + await pruneFiles(tx, entry.key, keep) + await done(tx.objectStore(ENTRIES).put(entry)) + }) + + try { + await write(true) + return true + } catch (err) { + if (files.length < 1) { + warnOnce(err) + return false + } + } + try { + await write(false) + return true + } catch (err) { + warnOnce(err) + return false + } +} + +/** Forget an edit and its files. */ +export async function deleteBufferEntry(key) { + try { + await transact([ENTRIES, FILES], 'readwrite', async (tx) => { + await pruneFiles(tx, key) + await done(tx.objectStore(ENTRIES).delete(key)) + }) + } catch (err) { + warnOnce(err) + } +} + +/** + * Forget everything kept for one user -- what signing out does, since the next person at this browser + * has no business finding somebody else's unsaved pages in it. + */ +export async function clearBufferForUser(userId) { + if (!userId) { + return + } + try { + await transact([ENTRIES, FILES], 'readwrite', async (tx) => { + const keys = await done(tx.objectStore(ENTRIES).index('userId').getAllKeys(userId)) + for (const key of keys) { + await pruneFiles(tx, key) + await done(tx.objectStore(ENTRIES).delete(key)) + } + }) + } catch (err) { + warnOnce(err) + } +} diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue index 6171e2b4c..2d9324c7b 100644 --- a/frontend/src/pages/Index.vue +++ b/frontend/src/pages/Index.vue @@ -636,6 +636,7 @@ import { useI18n } from 'vue-i18n' import { useDark } from '@/composables/dark' import { dialog } from '@/composables/dialog' +import { confirmDiscardUnsaved, useEditorBuffer } from '@/composables/editorBuffer' import { useMeta } from '@/composables/meta' import { useMinWidth } from '@/composables/screen' import { usePageScripts } from '@/composables/pageScripts' @@ -790,6 +791,12 @@ const dark = useDark() */ usePageScripts() +/* + What is typed into the editor, kept in this browser until it is saved and offered back if it never + is. Here because this is what mounts the editors; it follows the editor store on its own. +*/ +useEditorBuffer() + // META /* @@ -1306,6 +1313,28 @@ async function guardAnnotations(to, from) { onBeforeRouteLeave(guardAnnotations) onBeforeRouteUpdate(guardAnnotations) +/* + Leaving the editor with unsaved edits throws them away, so the author is asked first: a link in the + sidebar, Back, a click on the logo. Leaving is going somewhere else -- a change of query or hash is + the same editor still -- and a create route being put in place by `pageCreate` itself is not leaving + anything (`ignoreRouteChange`). + + Saying yes is discarding, and is settled as one before the navigation goes on, so that the browser + buffer does not keep what the author has just said to throw away. Everything that leaves the editor + on purpose -- a save, Discard, a submitted suggestion -- settles first for the same reason, and so + never reaches this question. A full unload is asked by the browser instead; see + `composables/editorBuffer.js`. +*/ +async function guardUnsavedEdits(to, from) { + if (editorStore.ignoreRouteChange || to.path === from.path) { + return true + } + return confirmDiscardUnsaved(t) +} + +onBeforeRouteLeave(guardUnsavedEdits) +onBeforeRouteUpdate(guardUnsavedEdits) + /* Reading the page is reading what the reader was told about it: its content as soon as it is on screen, its discussion once the Talk tab is open. This is what lets an email go out once and then diff --git a/frontend/src/stores/editor.js b/frontend/src/stores/editor.js index 363578d23..a96c207df 100644 --- a/frontend/src/stores/editor.js +++ b/frontend/src/stores/editor.js @@ -34,11 +34,30 @@ export const useEditorStore = defineStore('editor', { configIsLoaded: false, reasonForChange: '', ignoreRouteChange: false, - pendingAssets: [] + pendingAssets: [], + /** + * Whether the open editor holds its document and can take an edit from outside -- mounted, and + * past the wait for a collaborative room. Until then a document put into it is overwritten: the + * room's binding replaces whatever the editor was holding. Set by the editor itself, since only it + * knows when that is; see `restoreEditorContent` in each one and `composables/editorBuffer.js`. + */ + isReady: false }), getters: { hasPendingChanges: (state) => { return state.lastSaveTimestamp && state.lastSaveTimestamp !== state.lastChangeTimestamp + }, + /** + * Whether something was changed that has not been saved -- `hasPendingChanges`, and a page being + * created as well. That one never had a save, so `lastSaveTimestamp` is null there when the app + * was opened straight onto the create route, and `hasPendingChanges` says no however much has + * been written. Not tied to the editor being open: the moment it closes is exactly when this is + * asked, by the buffer deciding what to keep. + */ + hasUnsavedEdits: (state) => { + return ( + Boolean(state.lastChangeTimestamp) && state.lastChangeTimestamp !== state.lastSaveTimestamp + ) } }, actions: { @@ -58,12 +77,24 @@ export const useEditorStore = defineStore('editor', { closeEditor() { this.$patch({ isActive: false, + isReady: false, editor: '', mode: 'edit', // -> Only ever meaningful while a page is being created: it is where Discard goes back to originPageId: '' }) }, + /** + * Say that nothing is unsaved any more, because what was has just been saved or thrown away. + * + * The same instant in both, which is how "no pending changes" is spelled. Done BEFORE anything + * navigates away from the editor: the router's guard asks about unsaved edits on the way out, and + * the browser buffer keeps whatever is still unsaved when the editor closes. + */ + settleChanges() { + const now = Temporal.Now.instant() + this.$patch({ lastChangeTimestamp: now, lastSaveTimestamp: now }) + }, /** * Take a file the author has just put into a page, to be uploaded when the page is saved. * diff --git a/frontend/src/stores/page.js b/frontend/src/stores/page.js index 8ba8fc179..97bc5166c 100644 --- a/frontend/src/stores/page.js +++ b/frontend/src/stores/page.js @@ -49,6 +49,43 @@ const DUPLICATED_PAGE_PROPS = [ 'tocDepth' ] +/** + * The page fields a save sends, which is everything the editor and the properties panel can change. + * + * Shared with the browser buffer (`composables/editorBuffer.js`), so that what is kept for an edit + * nobody saved is exactly what saving it would have sent. + */ +export const SAVED_PAGE_FIELDS = [ + 'alias', + 'allowBacklinks', + 'allowComments', + 'allowContributions', + 'allowGlossaryLinks', + 'allowRatings', + 'content', + 'description', + 'icon', + 'isBrowsable', + 'isSearchable', + 'localeRelations', + 'password', + 'publishEndDate', + 'publishStartDate', + 'publishState', + 'relations', + 'render', + 'scriptJsLoad', + 'scriptJsUnload', + 'scriptCss', + 'showLastEditedBy', + 'showSidebar', + 'showTags', + 'showToc', + 'tags', + 'title', + 'tocDepth' +] + export const usePageStore = defineStore('page', { state: () => ({ alias: '', @@ -707,6 +744,13 @@ export const usePageStore = defineStore('page', { storedProps: { title: '', description: '', icon: '' }, mode: 'edit' }) + /* + Nothing about the new page is unsaved until somebody writes something. Whatever the store was + holding before -- a property edit left pending on the page this was started from -- belonged to + a page that is no longer in it, and would otherwise make the router and the browser buffer + treat an untouched new page as somebody's unsaved work. + */ + editorStore.settleChanges() /* -> Page permissions at the path the new page will sit at @@ -832,6 +876,8 @@ export const usePageStore = defineStore('page', { if (!resp?.ok) { throw new Error(resp?.message || 'An unexpected error occured.') } + // -> Submitting is the suggestion's save: nothing typed into it is unsaved any more + useEditorStore().settleChanges() this.hasOpenSuggestion = true return resp.submission }, @@ -991,36 +1037,7 @@ export const usePageStore = defineStore('page', { // against what this author may embed, and deriving the table of contents — so the page it // returns is the authority on what was actually saved. const body = { - ...pick(this, [ - 'alias', - 'allowBacklinks', - 'allowComments', - 'allowContributions', - 'allowGlossaryLinks', - 'allowRatings', - 'content', - 'description', - 'icon', - 'isBrowsable', - 'isSearchable', - 'localeRelations', - 'password', - 'publishEndDate', - 'publishStartDate', - 'publishState', - 'relations', - 'render', - 'scriptJsLoad', - 'scriptJsUnload', - 'scriptCss', - 'showLastEditedBy', - 'showSidebar', - 'showTags', - 'showToc', - 'tags', - 'title', - 'tocDepth' - ]), + ...pick(this, SAVED_PAGE_FIELDS), /* Not a page field: it describes the save rather than the page, and the server records it on the history version this save produces. Collected by the reason-for-change dialog before @@ -1119,6 +1136,14 @@ export const usePageStore = defineStore('page', { */ siteStore.staleTags() + /* + Saved, so nothing is pending -- and said so BEFORE the navigation below rather than after it. + The router's guard asks whether the editor is being left with unsaved edits, and a page that + was just created would otherwise be asked about as though it were being abandoned. + */ + editorStore.settleChanges() + editorStore.reasonForChange = '' + if (editorStore.mode === 'create') { editorStore.$patch({ mode: 'edit' }) /* @@ -1129,14 +1154,6 @@ export const usePageStore = defineStore('page', { */ await this.router.replace(this.editorExitPath) } - - // Update editor state timestamps - const curDate = Temporal.Now.instant() - editorStore.$patch({ - lastChangeTimestamp: curDate, - lastSaveTimestamp: curDate, - reasonForChange: '' - }) } catch (err) { console.warn(err) throw err diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js index 932385926..a97208c3a 100644 --- a/frontend/src/stores/user.js +++ b/frontend/src/stores/user.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { getAccessibleColor } from '@/helpers/accessibility' +import { clearBufferForUser } from '@/helpers/editorBuffer' import { useSiteStore } from './site' @@ -171,6 +172,12 @@ export const useUserStore = defineStore('user', { // left looking at a page that still says they are signed in. console.warn(err) } + /* + And whatever this browser kept of their unsaved edits. Whoever sits down at it next has no + business finding somebody else's pages in it, and the account that wrote them is not the one + that will be offered them -- see `helpers/editorBuffer.js`. Taken before the id below is gone. + */ + await clearBufferForUser(this.id) this.setToGuest() EVENT_BUS.emit('logout', { redirect }) },