Conversation
will_change_leafable? compared leafable.attributes[key], but a Page's body is a has_markdown association, not a column, so attributes["body"] was always nil and any submitted body counted as a change. Every edit more than 10 minutes after the last recorded a revision even when the content was identical, duplicating the page and its markdown row. Compare the markdown content for markdown attributes so no-op edits stay no-ops. Groundwork for the write API, where a sync client re-sends every page on every push and relies on unchanged pages recording nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
title: "<%= @leaf.title %>" emitted invalid YAML whenever a title contained a double quote, and ERB's HTML escaping turned & into & inside what claims to be markdown. Emit the title as a JSON string, which is valid YAML and needs no further escaping. Plain JSON.generate rather than to_json to avoid ActiveSupport's \u0026-style HTML entity escapes, which are correct but needlessly unreadable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
has_secure_token, same as Session. The key authenticates non-browser clients in the next commit; regenerate_bearer_key handles resets. The migration backfills existing users, since has_secure_token only generates on create. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Authorization: Bearer <key> resolves to a real, active User and sets Current.user without a Session row, so Access rows and book.editable? keep working untouched. This brings the previously dead bot_key CSRF exemption to life under its new bearer_key name. Keys only authenticate on controllers that opt in with allow_bearer_key_access — everywhere else the request stays anonymous, so a leaked key can drive the API surface but not the account or user admin. Opting in the authentication attempt rather than denying after the fact avoids a callback-ordering hole on allow_unauthenticated_access controllers, where a default-deny check would run before the bearer authentication it is supposed to veto. Requests that present a key (or want a non-HTML format) get 401 instead of the login redirect. books#show opts in first, so a key can read an unpublished book it has access to. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stable upsert key for sync clients: a leaf keeps its identity across pushes without the client maintaining its own name-to-id map. Unique per book; nullable, since leaves made in the web UI have none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One serializer/parser both directions share: YAML front matter, then the body, verbatim. The leaf .md export now renders through it, and the write API will parse requests with it, so PUT-ing back what you GET is byte-exact by construction. The parser takes the first closing delimiter and exactly one blank line after it, so bodies containing --- lines and leading/trailing whitespace round-trip untouched. Titles are emitted as JSON strings (valid YAML); parsing is YAML, so hand-authored files with unquoted values work too. url is output-only and unknown keys are ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /books/:book_id/leaves.json lists the active leaves in reading order: id, type, title, slug, position, external_id, url. This is what a mirror diffs against to decide what to create, update, reorder, and trash. position is the 0-based index, matching what move_to_position takes and what the arrangement UI submits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /books/:book_id/pages.md creates from a Leaf::Document; carrying an external_id makes it an upsert that updates the existing leaf — and restores it if it was trashed, so a bad sync heals on the next good push. PUT /books/:book_id/pages/:id.md updates through Leaf#edit, so revision coalescing and no-op detection apply; PUT-ing back exactly what you GET records nothing. DELETE trashes, as the web UI does. position (0-based, same as the arrangement UI) applies through move_to_position. The browser flows are untouched: document handling only engages for md/json requests, which also skip the being-edited broadcast. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The leafables controller already carries the create/upsert/update machinery; these prove it for sections, which speak JSON instead of the .md document format since their body is a bare string. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /books/:book_id/pages/:id/uploads.json resolves the page from the path and authorizes with the book's editability, then reuses the same attach-and-render as the editor's upload endpoint — which no script could use, since the signed GlobalID is only minted into the page editor. fileUrl is now absolute, reversing the earlier relative-URL stance: bodies mirrored into a git repo need URLs that render on GitHub and in local editors, and byte-identical bodies on both sides is what makes a re-sync a no-op. The trade, made deliberately: a hostname change means a one-time rewrite of stored bodies. main_app-qualified in the template because the shared view also renders inside the isolated ActionText namespace, where bare url helpers resolve against the engine's routes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
request.raw_post arrives ASCII-8BIT, so a body with any multibyte character blew up at the database layer once a real manual page came through. The wire format is UTF-8; declare it at the parse boundary and reject bodies that aren't valid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
on_load(:active_storage_attachment) only fired once something else loaded ActiveStorage::Attachment, so a process whose first storage-touching request was an upload raised NoMethodError on Markdown#uploads. Surfaced by an API-only sync process; the editor path was masked because page renders load storage classes first. has_many_attached doesn't exist until after the initializers run, so the include goes in to_prepare. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Puts the bearer key on the profile edit page, next to the session transfer link it resembles: a readonly field, a copy button, and a reset. Only on your own settings page — the profile show page is visible to everyone in the account, so the key never renders there. Reset regenerates in place, matching how join codes reset. Scripts holding the old key start getting 401s immediately, hence the confirm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lets a book be maintained in a git repo and mirrored into Writebook by a script — the write half of what the `.md` exports started. Driving use case: the Omarchy manual syncing from `basecamp/omarchy` to learn.omacom.io on push. Full design and decisions in `plans/api.md`.
How it works
Bugs fixed on the way
Validation
Mirrored the live Omarchy manual (45 pages + 4 sections from learn.omacom.io) into a local instance with a sync script driving only the public API: initial sync, idempotent re-sync (zero new Edits), reorders via filename-prefix renumbering, git-wins overwrite of web edits, trash/restore, and uploads.
🤖 Generated with Claude Code