From 1c5ed073498977efe9aaa44b5630adc0a273bed4 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 13 Sep 2026 21:45:49 +0200 Subject: [PATCH] feat(endpoint-micropub): filter the list of posts by text and category The README promises `filter` for every list query and gives `/micropub?q=source&filter=web` as the example, but the source query ignored it: the parameter only ever reached the configuration queries. Honour it for posts as a case-insensitive match on the name and content, and accept `category` alongside it for posts carrying that category. Filtering keeps paging: getCursor takes an optional query that is merged into the range condition and into the hasNext/hasPrev probes, so a filtered page reports whether more filtered items exist rather than whether more items exist at all. Existing callers are unaffected; the parameter defaults to an empty object. Tests cover the cursor filter in util, and for the endpoint: match by name, match by content, match by category, both together across two pages, and no match. --- packages/endpoint-micropub/README.md | 4 +- .../lib/controllers/query.js | 26 +++++- .../integration/200-query-source-filter.js | 89 +++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 packages/endpoint-micropub/test/integration/200-query-source-filter.js diff --git a/packages/endpoint-micropub/README.md b/packages/endpoint-micropub/README.md index cede5cc47..1adcdda61 100644 --- a/packages/endpoint-micropub/README.md +++ b/packages/endpoint-micropub/README.md @@ -38,4 +38,6 @@ To customise the behaviour of this plug-in, add `@indiekit/endpoint-micropub` to - Previously published posts (list): `/micropub?q=source` - Source content: `/micropub?q=source&url=WEBSITE_URL` -List queries support `filter`, `limit` and `offset` and parameters. For example, `/micropub?q=source&filter=web&limit=10&offset=10`. +List queries support `filter`, `limit` and `offset` parameters. For example, `/micropub?q=category&filter=web&limit=10&offset=10`. + +The list of published posts is paged with `after` and `before` rather than `offset`, and can be narrowed with `filter` (a case-insensitive match on a post’s name and content) and `category` (posts with that category). For example, `/micropub?q=source&filter=web&category=indieweb&limit=10`. diff --git a/packages/endpoint-micropub/lib/controllers/query.js b/packages/endpoint-micropub/lib/controllers/query.js index df01e4373..83403fe4f 100644 --- a/packages/endpoint-micropub/lib/controllers/query.js +++ b/packages/endpoint-micropub/lib/controllers/query.js @@ -8,7 +8,8 @@ import { getMf2Properties, jf2ToMf2 } from "../mf2.js"; * @typedef {object} QueryParameters * @property {string|string[]} [after] - Return items after this item ID * @property {string|string[]} [before] - Return items before this item ID - * @property {string} [filter] - Value to filter items by + * @property {string} [category] - Only return posts with this category + * @property {string} [filter] - Value to filter items by (for posts, a case-insensitive match on name and content) * @property {string} [limit] - Number of items to return * @property {string} [offset] - Offset to start limit of items * @property {string|string[]} [properties] - mf2 properties to select @@ -29,7 +30,8 @@ export const queryController = async (request, response, next) => { const config = getConfig(application, publication); const limit = Number(request.query.limit) || 0; const offset = Number(request.query.offset) || 0; - let { after, before, filter, properties, q, uid, url } = request.query; + let { after, before, category, filter, properties, q, uid, url } = + request.query; if (!q) { throw IndiekitError.badRequest( @@ -87,7 +89,25 @@ export const queryController = async (request, response, next) => { }; if (postsCollection) { - cursor = await getCursor(postsCollection, after, before, limit); + // `filter` matches the name or content, `category` a category. + // Both are cast to strings so that, whatever the query parser + // yields, nothing reaches the database as an operator. + const pattern = + filter && new RegExp(RegExp.escape(String(filter)), "i"); + const postsFilter = { + ...(category && { "properties.category": String(category) }), + ...(pattern && { + $or: [ + { "properties.name": pattern }, + { "properties.content.text": pattern }, + { "properties.content": pattern }, + ], + }), + }; + + cursor = await getCursor(postsCollection, after, before, limit, { + filter: postsFilter, + }); } const items = []; diff --git a/packages/endpoint-micropub/test/integration/200-query-source-filter.js b/packages/endpoint-micropub/test/integration/200-query-source-filter.js new file mode 100644 index 000000000..0c0b76d1b --- /dev/null +++ b/packages/endpoint-micropub/test/integration/200-query-source-filter.js @@ -0,0 +1,89 @@ +import { strict as assert } from "node:assert"; +import { after, before, describe, it } from "node:test"; + +import { testDatabase } from "@indiekit-test/database"; +import { mockAgent } from "@indiekit-test/mock-agent"; +import { testServer } from "@indiekit-test/server"; +import { testToken } from "@indiekit-test/token"; +import supertest from "supertest"; + +await mockAgent("endpoint-micropub"); +const { client, mongoServer, mongoUri } = await testDatabase(); +const server = await testServer({ + application: { mongodbUrl: mongoUri }, +}); +const request = supertest.agent(server); + +const query = (parameters) => + request + .get("/micropub") + .auth(testToken(), { type: "bearer" }) + .set("accept", "application/json") + .query({ q: "source", ...parameters }); + +describe("endpoint-micropub GET /micropub?q=source&filter=*&category=*", () => { + before(async () => { + // Two notes with content and one with a name only; the test + // configuration has no article post type, so no post may carry both + for (const post of [ + "h=entry&name=Cheese+sandwich&category=food", + "h=entry&content=Bicycle+ride+at+dawn&category=cycling", + "h=entry&content=Building+for+the+web&category=food", + ]) { + const created = await request + .post("/micropub") + .auth(testToken(), { type: "bearer" }) + .set("accept", "application/json") + .send(post); + assert.ok(created.status < 300, `seed failed: ${created.text}`); + } + }); + + it("Filters posts by text in the name or content", async () => { + const byName = await query({ filter: "cheese" }); + const byContent = await query({ filter: "WEB" }); + + assert.equal(byName.body.items.length, 1); + assert.equal(byName.body.items[0].properties.name[0], "Cheese sandwich"); + assert.equal(byContent.body.items.length, 1); + assert.match( + JSON.stringify(byContent.body.items[0].properties.content), + /web/, + ); + }); + + it("Filters posts by category", async () => { + const result = await query({ category: "food" }); + + assert.equal(result.body.items.length, 2); + }); + + it("Combines filters and keeps paging", async () => { + const page = await query({ category: "food", limit: 1 }); + const next = await query({ + category: "food", + limit: 1, + after: page.body.paging.after, + }); + + assert.equal(page.body.items.length, 1); + assert.equal(next.body.items.length, 1); + assert.notEqual( + page.body.items[0].properties.url[0], + next.body.items[0].properties.url[0], + ); + assert.equal(next.body.paging.after, undefined); + }); + + it("Returns nothing when no post matches", async () => { + const result = await query({ filter: "sandwich", category: "cycling" }); + + assert.deepEqual(result.body.items, []); + }); + + after(async () => { + await client.close(); + await mongoServer.stop(); + server.close((error) => process.exit(error ? 1 : 0)); + }); +});