Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions .planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,23 @@ This splice approach is fragile for posts with many blocks. A safer alternative

**Splicing decision.** Re-serializing the whole post with `serialize_blocks( parse_blocks() )` is semantically lossless but can rewrite other blocks' comment JSON byte-for-byte (escaping), producing noisy revisions. Tier 2 should instead locate the target block's byte range with the block-delimiter grammar `WP_Block_Parser` uses (document order, matching `bibliography_builder_collect_blocks()` indexing), replace only that range with `serialize_block()` of the updated block, and verify by re-parsing that exactly one block changed before calling `wp_update_post()`.

**Next (M2 proper):** add the block-range locator with tests against real `parse_blocks()` in the runtime matrix; then the Tier 2 routes behind a companion-plugin flag, dry-run by default, with `If-Match`.
**M2 (2026-09-26): implemented, unreleased.** See `docs/rest-write-routes.md`.

- **Block-range locator** (`includes/block-locator.php`):
- uses core's delimiter grammar and document-order indexing;
- refuses malformed structure;
- re-locates after every splice to prove only the target changed;
- is tested against a vendored copy of the real `WP_Block_Parser` (`tests/phpunit/wp-block-parser/`), not the runtime matrix. Unit tests run it on every push.
- **Tier 2 routes** (`includes/write-routes.php`):
- add, patch, delete, and reorder (numeric styles only);
- off unless the `bibliography_builder_enable_write_routes` filter is true, which is the companion-plugin flag;
- dry run unless `dry_run=false`;
- `If-Match` required (428 without it, 412 when stale).
- **Decisions made on the way:**
- **ETag.** It is a hash of `post_content`, not `post_modified_gmt`, because two saves in one second share a modified time.
- **Add skips duplicates.** It skips entries that are duplicates by the editor's rules, and reports them.
- **Patch mirrors the editor's field editor.** It clears manual display text and stale export strings.
- **Where the routes ship.** They ship in the main plugin behind the filter, not as a separate distribution. Revisit if review asks for the code itself to be split out.

---

Expand Down Expand Up @@ -245,7 +261,7 @@ This decision should be revisited once Tier 2 is prototyped and the static-save
|---|---|---|
| M0 | Stable IDs (no routes) | None — implement in next feature sprint |
| M1 | Validate + diff read extensions (Tier 1) — **done (unreleased)** | M0 complete |
| M2 | Prototype Tier 2 add/update/delete (companion plugin) — static-save spike **done**; locale-independent save markup is the next prerequisite | M1 + static-save spike |
| M2 | Tier 2 add/update/delete/reorder behind an opt-in filter — **done (unreleased)**; see `docs/rest-write-routes.md` | M1 + static-save spike |
| M3 | Reformat, reorder, ETag (Tier 2 complete) | M2 validated |
| M4 | Bulk routes (Tier 4) | M3 + rate-limiting design |
| M5 | Abilities registration (Tier 5) | WP Abilities API stable |
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Citation write routes (Phase 05, M2), **off by default**. A site opts in with the `bibliography_builder_enable_write_routes` filter. Under `/wp-json/bibliography/v1/posts/<id>/bibliographies/<ref>/citations`:
- `POST` adds CSL-JSON items, skipping duplicates;
- `PATCH …/<citation_id>` changes fields;
- `DELETE …/<citation_id>` removes one citation and returns it;
- `PUT …/order` reorders a numeric-style list.

Each route needs `edit_post` and is a dry run unless `dry_run=false`. A write needs `If-Match` with the post's ETag, which dry runs and the read routes return; it gets `428` without one and `412` when the ETag is stale. A write rebuilds the block's markup with the PHP port of `save()`, replaces only that block's bytes in the post, and saves a normal revision. See `docs/rest-write-routes.md`.

- Review routes for editors (Phase 05, Tier 1), all read-only and requiring `edit_post`: `GET …/bibliographies/<ref>/validate` reports per-entry CSL-JSON problems (invalid or missing data, missing title, malformed DOI, and warnings for missing author, date, or container title, or an ISBN with no valid checksum); `…/duplicates` lists likely duplicate pairs using the editor's own duplicate rules; `…/preview?style=<key>` shows each entry reformatted in another citation style next to its current text, without saving. `<ref>` is the block index or its stable `bibliographyId`. The routes live in `includes/review.php`.
- Three matching read-only abilities on WordPress 6.9+, with the same `edit_post` requirement: `borges/validate-bibliography`, `borges/find-duplicate-citations`, and `borges/preview-bibliography-style`. Each takes `post_id` and either `index` or `bibliography_id`.
- Stable IDs (Phase 05, Tier 0). The read-only REST collection and single-bibliography routes, and the `borges/get-bibliographies` ability, now report each block's `bibliographyId`, a UUID that stays put when blocks before it are added or removed, as the future write routes will need. It is `null` for a block saved before IDs were assigned (or with an unusable value) until the post is next edited. Every citation already carried an `id`; the editor now guarantees one, unique within its block.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ A Gutenberg block plugin that accepts DOI identifiers, PubMed/PMID records, BibT
- `GET /isbn/{isbn}` — resolves checksum-valid ISBNs through Open Library's fixed ISBN edition endpoint (author names from its search endpoint), falling back to Google Books, mapping to a CSL book in PHP; requires `edit_posts`
- `GET /posts/{post_id}/bibliographies` — list all bibliography blocks in post
- `GET /posts/{post_id}/bibliographies/{index}` — single bibliography; supports `?format=json|text|csl-json`
- Citation write routes (Phase 05 M2, `includes/write-routes.php`): POST/PATCH/DELETE/PUT under `/posts/{post_id}/bibliographies/{ref}/citations`. They are **off unless** the `bibliography_builder_enable_write_routes` filter returns true. They need `edit_post`, are dry runs unless `dry_run=false`, and need `If-Match` (the post's ETag, a hash of its content). A write rebuilds markup with the `save()` port and splices only that block via `includes/block-locator.php`. See `docs/rest-write-routes.md`.
- The PMID, PMCID, arXiv, and ISBN route callbacks, their provider constants (fixed upstream URLs, timeout, cache TTLs), and the NCBI cache helpers live in `includes/resolvers.php`, loaded by `require_once` from the main file. Route registration and the `edit_posts` permission callbacks stay in `bibliography-builder.php`.
- Read-only Abilities API integration (WordPress 6.9+) lives in `includes/abilities.php`: `borges/get-bibliographies`, `borges/export-bibliography`, `borges/validate-citations` in a `bibliography` category. They reuse the REST read/permission helpers; on older WordPress the `wp_abilities_api_*` hooks never fire.
- Payload limits: 1 MB max body, 50 items max per `/format` request
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Borges is a static-output block: formatted bibliography HTML, JSON-LD, and COinS

| Metric | Value |
|---|---|
| First-party PHP | ~2,064 LOC main plugin file; ~6,514 LOC total with `includes/` |
| First-party PHP | ~2,073 LOC main plugin file; ~7,473 LOC total with `includes/` |
| JS source (`src/`) | ~9,956 LOC |
| Frontend runtime shipped to visitors | `view.js` ~1.4 KB + `style-index.css` ~2.9 KB, no script dependencies, enqueued only when the block is present |
| Installed footprint | ~1.9 MB (`vendor/` ~792 KB, translations 724 KB, build assets ~324 KB) |
Expand Down Expand Up @@ -223,6 +223,8 @@ GET /wp-json/bibliography/v1/posts/<post_id>/bibliographies/<ref>/preview?style=
- Missing posts, forbidden posts, and missing bibliography indexes return explicit REST errors.
- The public bibliography data routes are read-only. They do not add, update, delete, reorder, or persist citations.

Sites that opt in can also write citations over REST. The routes add, change, remove, and reorder citations. They require `edit_post`, are dry runs by default, and need `If-Match`. They are off unless the `bibliography_builder_enable_write_routes` filter returns true. See [docs/rest-write-routes.md](./docs/rest-write-routes.md).

The separate editor-only formatter endpoint accepts `POST /wp-json/bibliography/v1/format`, requires `edit_posts`, and returns formatted citation text for submitted CSL-JSON. It does not save changes.

The editor-only PubMed resolver accepts `GET /wp-json/bibliography/v1/pmid/<pmid>`, requires `edit_posts`, validates the PMID as numeric input, and returns normalized CSL-JSON from the fixed NCBI/PMC citation exporter endpoint. It is used for pasted `PMID:` input and does not persist citations by itself.
Expand Down
25 changes: 17 additions & 8 deletions bibliography-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -1619,11 +1619,14 @@ function bibliography_builder_rest_get_bibliographies( WP_REST_Request $request
$post = get_post( $post_id );
$bibliographies = bibliography_builder_get_bibliographies_for_post( $post );

return rest_ensure_response(
array(
'postId' => $post_id,
'bibliographies' => $bibliographies,
)
return bibliography_builder_with_write_etag(
rest_ensure_response(
array(
'postId' => $post_id,
'bibliographies' => $bibliographies,
)
),
$post
);
}

Expand All @@ -1647,17 +1650,20 @@ function bibliography_builder_rest_get_bibliography( WP_REST_Request $request )
$response = new WP_REST_Response( bibliography_builder_build_plain_text( $bibliography ) );
$response->header( 'Content-Type', 'text/plain; charset=utf-8' );

return $response;
return bibliography_builder_with_write_etag( $response, get_post( absint( $request['post_id'] ) ) );
}

if ( 'csl-json' === $format ) {
$response = rest_ensure_response( bibliography_builder_build_csl_json( $bibliography ) );
$response->header( 'Content-Type', 'application/vnd.citationstyles.csl+json; charset=utf-8' );

return $response;
return bibliography_builder_with_write_etag( $response, get_post( absint( $request['post_id'] ) ) );
}

return rest_ensure_response( $bibliography );
return bibliography_builder_with_write_etag(
rest_ensure_response( $bibliography ),
get_post( absint( $request['post_id'] ) )
);
}

/**
Expand Down Expand Up @@ -1838,6 +1844,7 @@ function bibliography_builder_register_rest_routes() {
);

bibliography_builder_register_review_routes();
bibliography_builder_register_write_routes();

// The same read by stable ID, registered as its own route so numeric
// requests keep the `index` parameter they have always had.
Expand Down Expand Up @@ -1931,6 +1938,8 @@ function bibliography_builder_block_init() {
require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/review.php';
require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/save-markup.php';
require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/frontend-labels.php';
require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/block-locator.php';
require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/write-routes.php';
add_filter( 'rest_pre_serve_request', 'bibliography_builder_rest_pre_serve_request', 10, 4 );

/**
Expand Down
10 changes: 6 additions & 4 deletions docs/current-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,19 @@ Source and LOC figures last verified: **2026-09-25** against the locale-independ

| Metric | Value | Re-derivation command |
|---|---|---|
| Main plugin file (`bibliography-builder.php`) | **2,064** | `wc -l bibliography-builder.php` |
| All first-party PHP (excl. vendor, tests, scripts, playground, packages, output, node_modules, generated `build/`) | **6,514** | `find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -not -path './tests/*' -not -path './packages/*' -not -path './scripts/*' -not -path './playground/*' -not -path './output/*' -not -path './build/*' -print0 \| xargs -0 wc -l \| tail -1` |
| Main plugin file (`bibliography-builder.php`) | **2,073** | `wc -l bibliography-builder.php` |
| All first-party PHP (excl. vendor, tests, scripts, playground, packages, output, node_modules, generated `build/`) | **7,473** | `find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -not -path './tests/*' -not -path './packages/*' -not -path './scripts/*' -not -path './playground/*' -not -path './output/*' -not -path './build/*' -print0 \| xargs -0 wc -l \| tail -1` |
| JS source (`src/`, excl. `*.test.js`) | **9,956** | `find ./src -name '*.js' -not -name '*.test.js' -print0 \| xargs -0 wc -l \| tail -1` |
| Shipped frontend runtime (`build/view.js`, minified) | **1,449 bytes** | `npm run build` then `wc -c < build/view.js` |

The only PHP that executes at runtime on a visitor request path is `bibliography-builder.php`
(REST registration + block registration) and the five `includes/` files it loads:
(REST registration + block registration) and the seven `includes/` files it loads:
`includes/resolvers.php`, which only defines constants and functions (its remote requests run
only inside editor-time REST callbacks); `includes/review.php`, which only defines constants,
functions, and editor-only review routes registered with the others on `rest_api_init`;
`includes/save-markup.php`, which only defines constants and functions (not yet called);
`includes/save-markup.php` and `includes/block-locator.php`, which only define constants and functions
(called only by the opt-in write routes); `includes/write-routes.php`, which only defines functions and
registers its routes on `rest_api_init` when a site opts in (off by default);
`includes/frontend-labels.php`, whose `render_block` filter translates the saved Cite / Export
labels with a few string replacements per bibliography block, only on non-English sites, and
does no I/O; and
Expand Down
99 changes: 99 additions & 0 deletions docs/rest-write-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Citation write routes

Phase 05, M2 (Tier 2 of the [design memo](../.planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md)). Scripts, integrations, and agents can use these routes to add, change, remove, and reorder citations in a saved post, without opening the block editor.

## Enabling

The routes are **off by default**. Most sites never need to write bibliographies over REST, so a site has to opt in. Use a small companion plugin, or an mu-plugin such as `wp-content/mu-plugins/borges-write-api.php`:

```php
<?php
/**
* Plugin Name: Borges write API
*/
add_filter( 'bibliography_builder_enable_write_routes', '__return_true' );
```

While the filter returns false, the routes are not registered and the read routes send no `ETag`.

## Routes

All routes live under `/wp-json/bibliography/v1/posts/<post_id>/bibliographies/<ref>`. `<ref>` is the block's zero-based index, or its stable `bibliographyId`, as in the read routes.

| Method | Path | Body | Change |
| --- | --- | --- | --- |
| `POST` | `…/citations` | `{ "items": [ …CSL-JSON… ] }` (1–50) | Add citations |
| `PATCH` | `…/citations/<citation_id>` | Partial CSL-JSON object | Change fields on one citation |
| `DELETE` | `…/citations/<citation_id>` | none | Remove one citation |
| `PUT` | `…/citations/order` | `{ "ids": [ … ] }` | Reorder a numeric-style bibliography |

Every route requires `edit_post` on the post.

### Dry run by default

Every request is a **dry run** unless you pass `dry_run=false`. A dry run:

- builds the change exactly as a write would, including formatting, rebuilding the markup, and splicing it into the post;
- returns the result;
- saves nothing.

Preview with a dry run first, then send the same request again with `dry_run=false`.

### If-Match

A real write needs an `If-Match` header carrying the post's current ETag. Dry runs return it, and so do the read routes while the write routes are enabled.

- **No header:** `428 Precondition Required`.
- **ETag no longer current:** `412 Precondition Failed`, because someone changed the post since your read. The error carries the current ETag. Read the post again, then retry.
- **Success:** the response carries the new ETag, so writes can be chained.

The ETag is a hash of the post's content. Any change invalidates it, including a change to a different block.

### Responses

A successful response has this shape:

```json
{
"postId": 42,
"dryRun": false,
"changes": { "added": ["…id…"], "skipped": [] },
"bibliography": { "index": 0, "bibliographyId": "…", "citations": [ … ], "entryCount": 3, "…": "…" },
"etag": "\"…\""
}
```

The `changes` object depends on the route:

| Route | `changes` |
| --- | --- |
| add | `added` (new citation IDs); `skipped` (`{ item, duplicateOf }` for items that duplicate an existing citation or an earlier item, by the editor's duplicate rules) |
| patch | `updated` |
| delete | `removed`: the whole entry, so a client can put it back |
| reorder | `order` |

## What a write does

1. Reads `post_content` and locates the bibliography block by its byte range (`includes/block-locator.php`), using the same block grammar as `WP_Block_Parser`. Malformed block delimiters are refused with `409`.
2. Applies the change to the block's attributes, like this:
- new and changed CSL-JSON is sanitized with the same rules as `/format`;
- it is formatted in the block's style;
- new citations get UUIDs;
- the list is stored in display order, the way the editor stores it.

A patch works like the editor's field editor. It clears the entry's manual display text and its stale BibTeX and BibLaTeX export strings. `id` and `type` cannot be patched, and a field set to `null` is removed.
3. Rebuilds the block's saved markup with the PHP port of `save()` (`includes/save-markup.php`). The port matches the editor's output byte for byte, so the block opens as valid.
4. Replaces only that block's bytes. It then locates the blocks again to prove that every other byte of the post is unchanged.
5. Saves with `wp_update_post()`, so the change is an ordinary revision and can be restored like any other.

## Limits and caveats

- **Size:** a request adds at most 50 items, and a bibliography holds at most 200 citations, the editor's limit.
- **Reordering:** only numeric styles (IEEE, Vancouver) can be reordered. Other styles sort their entries themselves, so they return `409`.
- **Export strings:** BibTeX and BibLaTeX export strings for new or changed entries are computed only in the editor, because they need citation-js. With per-entry Cite / Export on, those entries show RIS and CSL-JSON links until someone next saves the post in the editor. The editor then adds the missing strings.
- **HTML filtering:** WordPress filters saved HTML for users without `unfiltered_html`, in exactly the same way as it does for the editor.
- **intl:** the PHP `intl` extension is required. Without it, writes return `501`.

## Still to come

Later milestones are Tier 3 (block settings and reformatting), bulk routes, and write abilities. See the design memo's sequencing table.
Loading
Loading