13 Commits

Author SHA1 Message Date
Jakub Zych
f7bfb3313b feat: slug on the post author object, for author links that do not expose the login 2026-10-31 13:11:44 +01:00
Jakub Zych
08e3d82ecd feat: relevance-ordered search, suggest rows and journal:reindex
- Engine search asks Typesense for the first 250 candidates, re-gates them
  in SQL, counts the gated rows and pages over them in rank order; pages
  after the first are no longer empty. An explicit sort overrides the rank.
- The settings row gates engine search, so it works straight after boot.
- ?fields=suggest serves id, title, slug, a plain-text excerpt,
  featured_image_url and published_at.
- Content is indexed as plain text; Post declares SearchIndexSchema.
- journal:reindex [--fresh] syncs every post into the index.
2026-10-30 17:59:57 +01:00
Jakub Zych
5b38a3b20d feat: plugin.yaml manifest for the SummerCMS marketplace
- plugin.yaml generated by summer plugin:manifest (name override: Journal)
- TestPluginManifest guards it with kiosk.CheckT
- require SummerCMS v0.6.0, the framework minimum in plugin.yaml
2026-10-29 19:25:49 +01:00
a6fc7cd954 fix: fire RecordSaved for tags the post API creates by name
journal-tag is a content type, but tags created on the fly from names in a
post write fired no event, so SEO and sitemap subscribers only heard of them
from a later bulk job. Each created tag now fires a RecordSaved with Created
inside the post write's transaction.
2026-10-27 15:37:02 +01:00
7a03640b88 fix: report the old translated content_html on API post events
syncTranslations adds content_html to a translation that sends content, after
snapshotPost read the old values, so the event saw content_html as newly
submitted and reported Old: nil for a translation that existed.
submittedTranslations now lists content_html whenever a non-blank content is
sent, so the snapshot and the event cover the same fields.
2026-10-27 15:36:11 +01:00
62c75b1fc2 fix: write API posts in one transaction and fire RecordSaved after its commit
POST and PUT /_journal/api/v1/posts saved the row, then the categories, tags
and translations, and fired RecordSaved only when all succeeded. A failure
part-way left a committed row (possibly a new slug) with no event, so
subscribers such as an SEO plugin never created the redirect. The write now
runs in one lagoon transaction with the event emitted inside it.
2026-10-27 15:35:07 +01:00
f5a0394d69 chore: require SummerCMS v0.5.1 2026-10-27 14:01:40 +01:00
fea88f8ad7 test: content descriptors, journal-tag type and translated slugs
The descriptor matrix for posts, categories and tags: Describe per locale
with and without Polish unprefixed, Match exact and inexact per locale,
Records paging and cursors, host patterns, unknown and malformed ids;
journal-tag with tag_pages by default, on and off; translated slugs in
menu items and matching, including a changed Polish slug.
2026-10-27 13:29:46 +01:00
c5b71472ac refactor: declare the content descriptors in MenuItemTypes
The three descriptors are now literal pact.ContentDescriptor values next
to the types they describe; no behaviour change.
2026-10-26 22:47:20 +01:00
a598a7c5e7 fix: resolve translated post slugs (D-58)
GET /_journal/api/v1/posts/{slug} now looks the slug up in translate's
slug index for the request locale after the default-locale slug and
before the numeric id. TestTranslatedSlug covers the translated slug,
the default slug, the id and an unknown slug.
2026-10-26 22:46:49 +01:00
e17cebd761 feat: content descriptors for posts, categories and the new journal-tag type
- journal-post, journal-category and journal-tag carry pact.ContentDescriptor
  (controller, morph name, Describe, Records, Match) through signpost
- Match reads the locale prefix and the pattern with signpost.MatchPattern,
  finds the record by its slug in that locale, the default slug, another
  locale's slug or :id, and is exact only on the record's own URL (D-20c)
- journal-tag (/blog/tag/:slug, P-11) with references and Resolve; host key
  golem15.journal.tag_pages false empties tag URLs and stops tag matches
- TestContentDescriptors, TestJournalTagType; README documents both
2026-10-26 22:46:32 +01:00
4b3a8bb049 feat: record events from the Journal API writes (D-102)
- POST/PUT/DELETE /_journal/api/v1/posts fire RecordSaved/RecordDeleted
  through cabana.EmitRecordSaved/EmitRecordDeleted, so a slug changed over
  MCP reaches subscribers like an admin save
- PUT reports the changed columns and each submitted translation that
  changed, read before the write (a translated slug per locale)
- TestAPIRecordEvents covers POST, PUT slug change and DELETE
2026-10-26 22:35:08 +01:00
08ab15338c feat: post content descriptor
- journal-post registers a pact.ContentDescriptor (posts controller, Post
  morph name, Describe with per-locale URL, title, excerpt, image, words)
- descriptors read through cabana.TxFromContext inside a save
- TestRecordSavedDescribesNewURL: an admin slug change reaches a subscriber
  after commit and Describe answers the new URL; a failed save fires nothing
2026-10-26 22:22:31 +01:00
25 changed files with 2782 additions and 89 deletions

View File

@@ -20,9 +20,11 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`, `mltextarea` and the plugin's own `mlblogeditor` field type for content (see [Admin: Blog Editor field types](#admin-blog-editor-field-types)); an admin save stores the sanitized HTML in both `content` and `content_html`. `classes.FormatHTML` regenerates `content_html` on the other write paths (`journal:import-posts`, Journal API writes). It renders markdown and passes raw HTML in `content` through the renderer (Winter parity), then sanitizes the result with a `bluemonday` allow-list: bluemonday's UGC policy plus `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` styles, goldmark's footnote roles, `dir="auto"`, two pasted-transcript `data-message-*` attributes, and exactly four rules for the Blog Editor's own output (Phase 16.2): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, and `start` matching `^[0-9]+$` on `ol`. No iframe, other data attribute, style property or URL scheme is allowed for the editor; `TestEditorOutputPassesPolicy` checks every entry of the editor corpus against the policy. Like Winter's `Html::clean`, it strips what the policy does not allow instead of refusing the save: script, style, iframe, object, embed, form, base and meta elements, comments, event handler attributes, and URLs other than http, https, mailto and relative ones. HTML the policy keeps whole is stored byte for byte, so imported bodies survive a save. Admin saves strip the same way and name what they removed (`classes.SanitizeHTMLReport`). A tag that repeats an attribute is never kept whole: the sanitized output, which keeps the first value, is stored. On the API and import paths `content` itself is stored as submitted. Translated `content_html` goes through the same policy on the API and `journal:import-posts` write paths (`classes.FormatTranslation`): when the translation carries a non-blank `content`, `content_html` is rendered from it and a supplied `content_html` is replaced, as the host column is always derived; a translation with only `content_html` is sanitized with `classes.SanitizeHTML`. Winter stored a supplied translated `content_html` verbatim. The API response shapes and status codes are unchanged. - **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`, `mltextarea` and the plugin's own `mlblogeditor` field type for content (see [Admin: Blog Editor field types](#admin-blog-editor-field-types)); an admin save stores the sanitized HTML in both `content` and `content_html`. `classes.FormatHTML` regenerates `content_html` on the other write paths (`journal:import-posts`, Journal API writes). It renders markdown and passes raw HTML in `content` through the renderer (Winter parity), then sanitizes the result with a `bluemonday` allow-list: bluemonday's UGC policy plus `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` styles, goldmark's footnote roles, `dir="auto"`, two pasted-transcript `data-message-*` attributes, and exactly four rules for the Blog Editor's own output (Phase 16.2): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, and `start` matching `^[0-9]+$` on `ol`. No iframe, other data attribute, style property or URL scheme is allowed for the editor; `TestEditorOutputPassesPolicy` checks every entry of the editor corpus against the policy. Like Winter's `Html::clean`, it strips what the policy does not allow instead of refusing the save: script, style, iframe, object, embed, form, base and meta elements, comments, event handler attributes, and URLs other than http, https, mailto and relative ones. HTML the policy keeps whole is stored byte for byte, so imported bodies survive a save. Admin saves strip the same way and name what they removed (`classes.SanitizeHTMLReport`). A tag that repeats an attribute is never kept whole: the sanitized output, which keeps the first value, is stored. On the API and import paths `content` itself is stored as submitted. Translated `content_html` goes through the same policy on the API and `journal:import-posts` write paths (`classes.FormatTranslation`): when the translation carries a non-blank `content`, `content_html` is rendered from it and a supplied `content_html` is replaced, as the host column is always derived; a translation with only `content_html` is sanitized with `classes.SanitizeHTML`. Winter stored a supplied translated `content_html` verbatim. The API response shapes and status codes are unchanged.
- **Categories and tags.** `models.Category` is translatable (name, description, slug). It also stores `content`, an HTML host column that is not translatable, edited in the admin with the `blogeditor` field type. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`. - **Categories and tags.** `models.Category` is translatable (name, description, slug). It also stores `content`, an HTML host column that is not translatable, edited in the admin with the `blogeditor` field type. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`.
- **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) with the framework's menu item type registry, so menus and sitemaps can point at posts and categories. See [menu item types](#menu-item-types). - **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) and `journal-tag` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories and tags. Posts, categories and tags carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors).
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. - **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense.
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal. - **Search.** With a configured search engine and `search_use_typesense` on, published posts are kept in the Typesense collection `golem15_journal_posts` after every committed save, and `GET /posts?search=` answers in relevance order. Otherwise search is a SQL `ILIKE`. `?fields=suggest` serves short rows for a search box, and `journal:reindex` fills the index. See [search](#search).
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. `/posts/{slug}` finds the post by its default-locale slug, then by its slug in the request locale (the router's locale, from `Accept-Language` or the translate plugin's resolver), then by id, so a translated slug resolves. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal.
- **Record events.** Post writes through the admin fire the framework's record events (`pact.RecordSaved`, `pact.RecordDeleted`) after the commit, like every admin controller. The `/_journal/api/v1` post routes, which MCP tools call, fire the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`: `POST /posts` a RecordSaved with `Created`, `PUT /posts/{id}` a RecordSaved whose changes hold the post's changed columns and each submitted translation that changed (a translated slug per locale), `DELETE /posts/{id}` a RecordDeleted. A tag `POST` or `PUT` creates from a name in `tags` fires its own RecordSaved with `Created`. `POST` and `PUT` write the row, its categories, tags and translations in one transaction and fire the event after it commits, so a write that fails part-way changes nothing and fires nothing. Their `Controller` is empty, as they do not go through an admin form. Another plugin (an SEO plugin creating redirects, a search indexer) therefore hears about a slug changed over MCP without importing the Journal.
- **MCP tools.** `journal.Plugin.MCP` declares seven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools). - **MCP tools.** `journal.Plugin.MCP` declares seven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools).
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve. - **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`. - **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.
@@ -101,7 +103,7 @@ The admin Categories form edits content with the `blogeditor` field type: a save
1. `backend_users.golem15_bloghub_author_slug`, the public author slug. A user with a slug resolves even before their first post. 1. `backend_users.golem15_bloghub_author_slug`, the public author slug. A user with a slug resolves even before their first post.
2. `backend_users.login`, but only for a user with at least one published post. That login is already public through the post `author` object, so other backend accounts cannot be enumerated. 2. `backend_users.login`, but only for a user with at least one published post. That login is already public through the post `author` object, so other backend accounts cannot be enumerated.
Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login and no slug. Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login. Since v0.3.1 that object also carries `slug` (the same canonical identifier as below), and consumers link by it.
```json ```json
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe","subtitle":"Editor","description":"Writes about releases.","avatar":"https://example.com/storage/app/uploads/public/012/345/678/0123456789abcdef01234567.webp"}} {"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe","subtitle":"Editor","description":"Writes about releases.","avatar":"https://example.com/storage/app/uploads/public/012/345/678/0123456789abcdef01234567.webp"}}
@@ -126,7 +128,7 @@ An unknown author, a login without published posts, a soft-deleted user and a ma
The response uses the `/posts` pagination envelope (rows trimmed here): The response uses the `/posts` pagination envelope (rows trimmed here):
```json ```json
{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}} {"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe","slug":"jane-doe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}}
``` ```
An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route. An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route.
@@ -141,6 +143,34 @@ Content-Type: application/json
{"title":"Hello","content":"A blog post"} {"title":"Hello","content":"A blog post"}
``` ```
### Search
`GET /_journal/api/v1/posts?search=<term>` filters the list by a term of 3 or more characters; a shorter term is ignored and the list comes back unfiltered.
- **SQL.** By default, and whenever the engine cannot be used, the term is matched with `ILIKE` against title, slug, excerpt and content, and the rows keep the list order (`sort`, default newest first).
- **Typesense.** When `search.driver` is `typesense` with an API key and the Journal setting `search_use_typesense` is on, the engine is asked for the first 250 candidates (`typesense.MaxPerPage`), ranked by text match with the setting weights for title, excerpt and content (default 5, 3, 1), then by date. The candidates are re-gated in SQL with the same filters as any list (published, `category`, `tag`, `author`), so a stale index entry never shows. `meta.total` counts the gated rows, the rows keep the engine's rank, with pinned posts first when `pinned_first` is set, and `page` and `per_page` page over them as over any list. An explicit `sort` overrides the rank. A search that matches more than 250 posts keeps the first 250. When the engine fails, the request is answered from SQL and the failure is logged at Warn.
The setting row is read on every engine search, so turning `search_use_typesense` off takes effect on the next request, without a restart.
`?fields=suggest` replaces each row with the short shape a search box needs, under the same pagination envelope, filters and limits:
| Key | Value |
|-----|-------|
| `id` | Post id. |
| `title` | Default-locale title, as in the full row. |
| `slug` | Default-locale slug, as in the full row. |
| `excerpt` | The excerpt as plain text, cut to about 160 characters on a word boundary with `…`; the content as plain text when the post has no excerpt. |
| `featured_image_url` | The first featured image URL, or `null`. |
| `published_at` | As in the full row. |
Any other `fields` value, or none, serves the full rows.
```http
GET /_journal/api/v1/posts?search=borowik&fields=suggest&per_page=5
```
The search document holds the default-locale title, excerpt and content; content is indexed as plain text, from `content_html` (or `content` when nothing was rendered) with tags removed and entities decoded. The collection is created from `models.Post.SearchIndexSchema` when it does not exist. A collection created before the schema existed keeps its auto-detected fields until `journal:reindex --fresh` recreates it.
### Personal API tokens ### Personal API tokens
The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`): The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`):
@@ -260,10 +290,11 @@ Editor images are uploaded by the admin through the field's upload route and sto
| `journal-post` | A post (listed by title, drafts included) | `/blog/:slug` | | The post's URL while it is published with a past publish date; nothing for a draft or a future post, so the menu entry or sitemap row appears once the post goes live. PHP resolved any post. | | `journal-post` | A post (listed by title, drafts included) | `/blog/:slug` | | The post's URL while it is published with a past publish date; nothing for a draft or a future post, so the menu entry or sitemap row appears once the post goes live. PHP resolved any post. |
| `all-journal-posts` | Nothing | `/blog/:slug` | `journal-post` | No URL of its own; the published posts ordered by title as items. | | `all-journal-posts` | Nothing | `/blog/:slug` | `journal-post` | No URL of its own; the published posts ordered by title as items. |
| `category-journal-posts` | A category from the category tree | `/blog/:slug` | `journal-post` | No URL of its own; the published posts of the category and its descendants ordered by title as items. | | `category-journal-posts` | A category from the category tree | `/blog/:slug` | `journal-post` | No URL of its own; the published posts of the category and its descendants ordered by title as items. |
| `journal-tag` | A tag (listed by name) | `/blog/tag/:slug` | | The tag's URL; nothing when the host has no tag pages (`golem15.journal.tag_pages: false`). It has no PHP counterpart. |
Published means published with a publish date in the past, the public API's rule. Ties in every order break by id. Published means published with a publish date in the past, the public API's rule. Ties in every order break by id.
Placeholders are replaced literally, in order, like the PHP `buildUrlFromPattern`. A category pattern takes `:id` and `:slug`. A post pattern takes `:id`, `:slug`, `:category` (the slug of the post's first category by name) and, for a published post with a publish date, `:year`, `:month` and `:day` (UTC). Placeholders are replaced literally, in order, like the PHP `buildUrlFromPattern`. A category and a tag pattern take `:id` and `:slug`. A post pattern takes `:id`, `:slug`, `:category` (the slug of the post's first category by name) and, for a published post with a publish date, `:year`, `:month` and `:day` (UTC).
Every resolved record carries alternates, one per enabled translate locale in `sort_order` (ties by id). The unprefixed locale (the caller's, for example a sitemap's frontend default locale, else translate's default locale) gets no prefix; every other locale gets `/<code>`. Slugs and titles are translated per locale and fall back to the default value. Without an enabled locale the only alternate is `x-default`. A record's URL is its first alternate's URL, as in PHP. Every resolved record carries alternates, one per enabled translate locale in `sort_order` (ties by id). The unprefixed locale (the caller's, for example a sitemap's frontend default locale, else translate's default locale) gets no prefix; every other locale gets `/<code>`. Slugs and titles are translated per locale and fall back to the default value. Without an enabled locale the only alternate is `x-default`. A record's URL is its first alternate's URL, as in PHP.
@@ -279,6 +310,14 @@ A host may also override a list key itself (for example `all-journal-posts: /art
Category content stays on the Blog Editor (`blogeditor`) field type; it does not move to the framework's `richeditor`. Category content stays on the Blog Editor (`blogeditor`) field type; it does not move to the framework's `richeditor`.
### Content descriptors
`journal-post`, `journal-category` and `journal-tag` set `pact.MenuItemType.Content` (built in `classes/content_descriptors.go`): their admin controllers (`golem15.journal.posts`, `golem15.journal.categories`, `golem15.journal.tags`), their morph names (the `Model` record events carry) and three functions the framework's registry calls with the layered pattern. The functions read through the admin save transaction when one is in the context, so a form extension's `Save` sees a slug the save has just changed.
- **Describe.** One record per enabled locale, with the URL the menu item type resolves to in that locale, the translated title, a plain-text description (a post's excerpt, a category's or tag's description), a post's first featured image and the word count of a post's or category's body. Posts are described with drafts included; `Published` says whether visitors see the post (published, with a past publish date). Categories and tags are always published. On a host with `golem15.journal.tag_pages: false` tag URLs are empty.
- **Records.** Every record in id order, paged by the last id (at most 500 per page), drafts included.
- **Match.** The record a public path belongs to. The path's locale prefix is read first (an enabled locale other than the unprefixed one), then the pattern's placeholders: a post or category is found by its slug in that locale (translate's slug index), else its default slug, else a slug of another locale, else by `:id`; a tag by its slug or id. The match is exact only when the path is the record's own URL in that locale, so every segment (category, date, id, locale prefix, slug) must fit; a path that names the record with a wrong segment, a stale prefix or another locale's slug matches inexactly, for a redirect to the record's URL. Drafts and future posts never match, and tags never match without tag pages. Path values reach the database only as query parameters.
## Configuration ## Configuration
### Site cache purge ### Site cache purge
@@ -303,11 +342,17 @@ With only these set, Boot validates them with the same rules (errors name the Jo
The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Content-Type: application/json` and the body `{"kind":"post","slug":"<slug>"}` or `{"kind":"category","slug":"<slug>"}`. The receiver answers `200` with `{"purged":<n>}`, the number of cache entries it removed. The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Content-Type: application/json` and the body `{"kind":"post","slug":"<slug>"}` or `{"kind":"category","slug":"<slug>"}`. The receiver answers `200` with `{"purged":<n>}`, the number of cache entries it removed.
- **Timing.** The purge is registered with `lagoon.AfterCommit` on the admin save transaction: it runs only after the save commits, and never for a save that fails, so a concurrent page view cannot re-cache the old content. It runs before the save response, so the cache is clear when the administrator sees the save confirmed. The token API writes run outside a transaction: their purge is queued once the write succeeded and sent in the background, so the response does not wait for the site. - **Timing.** The purge is registered with `lagoon.AfterCommit` on the admin save transaction: it runs only after the save commits, and never for a save that fails, so a concurrent page view cannot re-cache the old content. It runs before the save response, so the cache is clear when the administrator sees the save confirmed. The token API writes purge outside their transaction: the purge is queued once the write committed and sent in the background, so the response does not wait for the site.
- **Never fails a save.** The request has a 2 s timeout and ignores the cancellation of the admin request. A refused, failing, slow or unreachable purge is logged as a warning (`cooler: site cache purge failed`) and the save response is unchanged; a successful one logs `cooler: site cache purged` with the kind, slug, status and purged count. The token and the Authorization header are never logged. - **Never fails a save.** The request has a 2 s timeout and ignores the cancellation of the admin request. A refused, failing, slow or unreachable purge is logged as a warning (`cooler: site cache purge failed`) and the save response is unchanged; a successful one logs `cooler: site cache purged` with the kind, slug, status and purged count. The token and the Authorization header are never logged.
- **What purges.** Admin creates, updates and deletes of posts and categories, including a save that changes nothing, and the Journal API post writes (create, update, delete, featured image upload and removal). Tags and the posts import action do not purge. - **What purges.** Admin creates, updates and deletes of posts and categories, including a save that changes nothing, and the Journal API post writes (create, update, delete, featured image upload and removal). Tags and the posts import action do not purge.
- **Receiver.** The host's Nuxt app built on vue-journal-layer 0.4.0 or later provides the shared route `POST /api/__site__/cache/purge` with its token in `NUXT_SITE_CACHE_PURGE_TOKEN`; the old route `POST /api/__journal__/cache/purge` with `NUXT_JOURNAL_CACHE_PURGE_TOKEN` stays for one release. Generate one token on the server and put the same value in both environments. - **Receiver.** The host's Nuxt app built on vue-journal-layer 0.4.0 or later provides the shared route `POST /api/__site__/cache/purge` with its token in `NUXT_SITE_CACHE_PURGE_TOKEN`; the old route `POST /api/__journal__/cache/purge` with `NUXT_JOURNAL_CACHE_PURGE_TOKEN` stays for one release. Generate one token on the server and put the same value in both environments.
### Tag pages
| Key | Environment variable | Value |
|-----|----------------------|-------|
| `golem15.journal.tag_pages` | `SUMMER_GOLEM15__JOURNAL__TAG_PAGES` | Whether the host site has tag pages, default `true`. With `false`, `journal-tag` items resolve to nothing, tag descriptions carry empty URLs and tag paths never match, for sites whose frontend turns tag routes off. |
## API reference ## API reference
| Identifier | Description | | Identifier | Description |
@@ -317,7 +362,7 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. | | `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. | | `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
| `journal.Plugin.Models` | Models registered by this plugin. | | `journal.Plugin.Models` | Models registered by this plugin. |
| `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. | | `journal.Plugin.Commands` | Registers `journal:export-posts`, `journal:import-posts` and `journal:reindex`. |
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. | | `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
| `journal.Plugin.MCP` | The MCP tools and the `draft_post` prompt (`pact.HasMCP`), guard `backend`, permission `golem15.journal.access_posts`. | | `journal.Plugin.MCP` | The MCP tools and the `draft_post` prompt (`pact.HasMCP`), guard `backend`, permission `golem15.journal.access_posts`. |
| `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). | | `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). |
@@ -330,7 +375,9 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
| `models.Post.AttachRelations` | `featured_images` and `content_images` attachMany. | | `models.Post.AttachRelations` | `featured_images` and `content_images` attachMany. |
| `models.Post.SearchableAs` | Index name `golem15_journal_posts`. | | `models.Post.SearchableAs` | Index name `golem15_journal_posts`. |
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. | | `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
| `models.Post.ToSearchableArray` | Typesense document built after a committed save. | | `models.Post.ToSearchableArray` | Typesense document built after a committed save; content is plain text. |
| `models.Post.SearchIndexSchema` | The Typesense collection schema a missing collection is created with (`beachcomber.IndexSchemaProvider`). |
| `models.PlainText` | HTML to plain text: tags dropped, entities decoded, whitespace collapsed. |
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. | | `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. | | `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. |
| `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. | | `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. |
@@ -343,8 +390,9 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
| `classes.BlogEditorTypes` | The `blogeditor` and `mlblogeditor` field types: Decode through `SanitizeHTMLReport` with removal notices, Load with the markdown fallback, list text and the image upload. | | `classes.BlogEditorTypes` | The `blogeditor` and `mlblogeditor` field types: Decode through `SanitizeHTMLReport` with removal notices, Load with the markdown fallback, list text and the image upload. |
| `classes.BlogEditorElement` | The editor's custom element tag, `golem15-journal-blogeditor`. | | `classes.BlogEditorElement` | The editor's custom element tag, `golem15-journal-blogeditor`. |
| `classes.MenuItemTypes` | The Journal menu item types with their references and resolvers (see [menu item types](#menu-item-types)). | | `classes.MenuItemTypes` | The Journal menu item types with their references and resolvers (see [menu item types](#menu-item-types)). |
| `classes.MenuTypeCategory`, `classes.MenuTypeAllCategories`, `classes.MenuTypePost`, `classes.MenuTypeAllPosts`, `classes.MenuTypeCategoryPosts` | The type keys `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts` and `category-journal-posts`. | | `classes.MenuTypeCategory`, `classes.MenuTypeAllCategories`, `classes.MenuTypePost`, `classes.MenuTypeAllPosts`, `classes.MenuTypeCategoryPosts`, `classes.MenuTypeTag` | The type keys `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts` and `journal-tag`. |
| `classes.DefaultPostPattern`, `classes.DefaultCategoryPattern` | The default patterns `/blog/:slug` and `/blog/category/:slug`. | | `classes.DefaultPostPattern`, `classes.DefaultCategoryPattern`, `classes.DefaultTagPattern` | The default patterns `/blog/:slug`, `/blog/category/:slug` and `/blog/tag/:slug`. |
| `classes.TagPagesKey` | `golem15.journal.tag_pages`, the host key that says whether the site has tag pages. |
| `classes.LooksLikeHTML` | Whether stored content is HTML: its first non-space character is `<`. | | `classes.LooksLikeHTML` | Whether stored content is HTML: its first non-space character is `<`. |
| `classes.StoreMedia` | Stores an uploaded image under `/journal/<folder>/<name>` and returns its `StoredMedia`; shared by the public upload route and the editor upload. | | `classes.StoreMedia` | Stores an uploaded image under `/journal/<folder>/<name>` and returns its `StoredMedia`; shared by the public upload route and the editor upload. |
| `classes.StoredMedia` | A stored media object's public `URL` and `Path`. | | `classes.StoredMedia` | A stored media object's public `URL` and `Path`. |
@@ -366,24 +414,26 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
## CLI commands ## CLI commands
Both commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`. The export and import commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`.
| Command | Arguments and flags | Effect | | Command | Arguments and flags | Effect |
|---------|---------------------|--------| |---------|---------------------|--------|
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations, with a progress bar over the posts. Attachments are omitted. Ends with `Exported N posts to: <path>`. | | `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations, with a progress bar over the posts. Attachments are omitted. Ends with `Exported N posts to: <path>`. |
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files, one line per post. Existing slugs are skipped unless `--force` updates them. Ends with `Results: N created, N updated, N skipped, N failed`. | | `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files, one line per post. Existing slugs are skipped unless `--force` updates them. Ends with `Results: N created, N updated, N skipped, N failed`. |
| `journal:reindex` | `--fresh` | Syncs every post into the search index in batches of 200: published posts are upserted, the others removed. `--fresh` first drops the collection and recreates it from the schema. Refuses, sending nothing, when the engine is not configured or `search_use_typesense` is off. Ends with `Reindexed: N synced, N removed, N failed` and exits non-zero on any failure. |
```sh ```sh
./bin/acme journal:export-posts --path journal-export ./bin/acme journal:export-posts --path journal-export
./bin/acme journal:import-posts --path journal-export --dry-run ./bin/acme journal:import-posts --path journal-export --dry-run
./bin/acme journal:import-posts --path journal-export --force ./bin/acme journal:import-posts --path journal-export --force
./bin/acme journal:reindex --fresh
``` ```
`./bin/acme help journal:import-posts` shows each command's long help and examples. The plugin logs through the component `journal` (`log.components.journal` in `config/log.yaml`). `./bin/acme help journal:import-posts` shows each command's long help and examples. The plugin logs through the component `journal` (`log.components.journal` in `config/log.yaml`).
## Dependencies ## Dependencies
- SummerCMS v0.5.0 modules: backpack, beachcomber, bonfire, cabana, cooler (site-cache purge), diary, lagoon/attach, pact, party, surf; signpost in tests. - SummerCMS v0.5.1 modules: backpack, beachcomber, bonfire, cabana, cooler (site-cache purge), diary, lagoon/attach, pact, party, signpost (content descriptors), surf.
- Compiled plugin: `golem15.translate`. - Compiled plugin: `golem15.translate`.
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`. - Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`.
- Blog Editor JS build (not needed by a host application): `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table` and `@tiptap/extension-image` 3.31.4, bundled into `assets/dist`; dev-only `vite`, `vitest`, `happy-dom` and `typescript`, pinned exactly in `package.json`. - Blog Editor JS build (not needed by a host application): `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table` and `@tiptap/extension-image` 3.31.4, bundled into `assets/dist`; dev-only `vite`, `vitest`, `happy-dom` and `typescript`, pinned exactly in `package.json`.

View File

@@ -0,0 +1,727 @@
package classes
import (
"context"
"html"
"strconv"
"strings"
"time"
"unicode"
"git.golem15.com/golem15/sm-journal-plugin/models"
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
// The admin controller IDs whose forms edit the described records. They
// repeat the controllers package constants, which this package cannot
// import (controllers imports classes).
const (
postsControllerID = "golem15.journal.posts"
categoriesControllerID = "golem15.journal.categories"
tagsControllerID = "golem15.journal.tags"
)
// contentDB is the database a content descriptor reads: the write
// transaction in ctx when there is one (cabana.TxFromContext), so code
// running inside a save sees what the save wrote, else the application's.
func (m menuTypes) contentDB(ctx context.Context) (*gorm.DB, error) {
if ctx == nil {
ctx = context.Background()
}
if tx, ok := cabana.TxFromContext(ctx); ok {
return tx.Session(&gorm.Session{NewDB: true, Context: ctx}), nil
}
return m.db(ctx)
}
// describePost describes one post, drafts included: Published tells
// whether visitors see it.
func (m menuTypes) describePost(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
id, ok := menuReferenceID(in.ID)
if !ok {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
var posts []models.Post
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil {
return nil, err
}
if len(posts) == 0 {
return nil, nil
}
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale)
if err != nil {
return nil, err
}
return &records[0], nil
}
// describePosts describes posts in the given order. Each locale's URL is
// the alternate resolvePost builds; Title is the translated title,
// Description the excerpt as plain text, Image the first featured image and
// Words the body's word count, each translated with the default value as
// fallback.
func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, pattern, unprefixed string) ([]pact.ContentRecord, error) {
in := pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}
items, err := resolvePostItems(ctx, db, posts, in)
if err != nil {
return nil, err
}
locales, err := loadMenuLocales(ctx, db, unprefixed)
if err != nil {
return nil, err
}
ids := make([]uint, 0, len(posts))
for _, p := range posts {
ids = append(ids, p.ID)
}
tr, err := loadMenuTranslations(ctx, db, models.Post{}.MorphName(), ids, locales)
if err != nil {
return nil, err
}
images, err := firstImages(ctx, db, models.Post{}.MorphName(), "featured_images", ids)
if err != nil {
return nil, err
}
out := make([]pact.ContentRecord, 0, len(posts))
for i := range posts {
p := &posts[i]
rec := pact.ContentRecord{
ID: strconv.FormatUint(uint64(p.ID), 10),
Published: postPublished(p),
LastMod: copyTime(p.UpdatedAt),
}
for _, alt := range items[i].Alternates {
locale := alt.Locale
if locale == xDefaultLocale {
locale = ""
}
body := tr.value(locale, p.ID, "content_html", p.ContentHTML)
if strings.TrimSpace(body) == "" {
body = tr.value(locale, p.ID, "content", p.Content)
}
words := wordCount(plainText(body))
rec.Locales = append(rec.Locales, pact.ContentLocale{
Locale: alt.Locale,
URL: alt.URL,
Title: alt.Title,
Description: strings.TrimSpace(plainText(tr.value(locale, p.ID, "excerpt", p.Excerpt))),
Image: images[p.ID],
Words: &words,
})
}
out = append(out, rec)
}
return out, nil
}
// postPublished is publishedPosts for one loaded post.
func postPublished(p *models.Post) bool {
return p.Published && p.PublishedAt != nil && p.PublishedAt.Before(time.Now())
}
// firstImages maps each record id to the public URL of its first file in
// field (sort order, then id).
func firstImages(ctx context.Context, db *gorm.DB, morph, field string, ids []uint) (map[uint]string, error) {
out := map[uint]string{}
if len(ids) == 0 {
return out, nil
}
keys := make([]string, 0, len(ids))
for _, id := range ids {
keys = append(keys, strconv.FormatUint(uint64(id), 10))
}
var files []attach.File
err := db.WithContext(ctx).
Where("attachment_type = ? AND field = ? AND attachment_id IN ?", morph, field, keys).
Order("sort_order ASC, id ASC").
Find(&files).Error
if err != nil {
return nil, err
}
for i := range files {
id, err := strconv.ParseUint(files[i].AttachmentID, 10, 32)
if err != nil {
continue
}
if _, seen := out[uint(id)]; !seen {
out[uint(id)] = files[i].URL()
}
}
return out, nil
}
func copyTime(t *time.Time) *time.Time {
if t == nil {
return nil
}
v := *t
return &v
}
// plainText drops HTML tags and unescapes entities, with whitespace runs
// collapsed to one space.
func plainText(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
b.WriteRune(' ')
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(html.UnescapeString(b.String())), " ")
}
// wordCount counts the whitespace-separated words of s.
func wordCount(s string) int {
n := 0
in := false
for _, r := range s {
if unicode.IsSpace(r) {
in = false
continue
}
if !in {
n++
in = true
}
}
return n
}
// TagPagesKey is the host configuration key that says whether the site has
// tag pages (default true). With false, journal-tag items resolve to
// nothing, tag descriptions carry empty URLs and tag paths never match.
const TagPagesKey = "golem15.journal.tag_pages"
// maxContentPage caps the page size of a descriptor's Records; a limit of 0
// or below asks for the cap.
const maxContentPage = 500
func pageLimit(n int) int {
if n <= 0 || n > maxContentPage {
return maxContentPage
}
return n
}
// afterID parses a Records After cursor: "" or an invalid value is the
// start.
func afterID(after string) uint {
id, _ := menuReferenceID(after)
return id
}
// tagPages reports TagPagesKey, true when unset.
func (m menuTypes) tagPages() bool {
if m.app == nil {
return true
}
app := m.app()
if app == nil || app.Config == nil || !app.Config.Has(TagPagesKey) {
return true
}
return app.Config.Bool(TagPagesKey)
}
// listPosts is the Records of journal-post: every post, drafts included, in
// id order.
func (m menuTypes) listPosts(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
db, err := m.contentDB(ctx)
if err != nil {
return pact.ContentPage{}, err
}
limit := pageLimit(in.Limit)
var posts []models.Post
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&posts).Error; err != nil {
return pact.ContentPage{}, err
}
more := len(posts) > limit
if more {
posts = posts[:limit]
}
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale)
if err != nil {
return pact.ContentPage{}, err
}
return contentPage(records, more), nil
}
func contentPage(records []pact.ContentRecord, more bool) pact.ContentPage {
page := pact.ContentPage{Records: records}
if page.Records == nil {
page.Records = []pact.ContentRecord{}
}
if more && len(records) > 0 {
page.Next = records[len(records)-1].ID
}
return page
}
// matchPost is the Match of journal-post. The post is found by its slug in
// the path's locale, else its default slug, else a slug of another locale,
// else by :id; drafts never match. The match is exact only when the path is
// the post's own URL in that locale, so every segment (category, date, id,
// locale prefix, slug) must fit the post.
func (m menuTypes) matchPost(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
morph := models.Post{}.MorphName()
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
return publishedPosts(q.Model(&models.Post{}))
})
}
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
var posts []models.Post
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil || len(posts) == 0 {
return nil, err
}
items, err := resolvePostItems(ctx, db, posts, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale})
if err != nil {
return nil, err
}
return items[0].Alternates, nil
}
return matchContent(ctx, db, in, find, urls)
}
func (m menuTypes) describeCategory(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
id, ok := menuReferenceID(in.ID)
if !ok {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
var cats []models.Category
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil {
return nil, err
}
if len(cats) == 0 {
return nil, nil
}
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale)
if err != nil {
return nil, err
}
return &records[0], nil
}
func (m menuTypes) listCategories(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
db, err := m.contentDB(ctx)
if err != nil {
return pact.ContentPage{}, err
}
limit := pageLimit(in.Limit)
var cats []models.Category
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&cats).Error; err != nil {
return pact.ContentPage{}, err
}
more := len(cats) > limit
if more {
cats = cats[:limit]
}
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale)
if err != nil {
return pact.ContentPage{}, err
}
return contentPage(records, more), nil
}
// describeCategories describes categories, always published: each locale's
// URL and title as resolveCategory builds them, the translated description
// as plain text and the word count of the category's content.
func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category, pattern, unprefixed string) ([]pact.ContentRecord, error) {
ids := make([]uint, 0, len(cats))
for _, c := range cats {
ids = append(ids, c.ID)
}
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}, ids)
if err != nil {
return nil, err
}
out := make([]pact.ContentRecord, 0, len(cats))
for _, c := range cats {
item := ci.item(c)
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(c.ID), 10), Published: true, LastMod: copyTime(c.UpdatedAt)}
var words *int
if c.Content != nil && strings.TrimSpace(*c.Content) != "" {
n := wordCount(plainText(*c.Content))
words = &n
}
host := ""
if c.Description != nil {
host = *c.Description
}
for _, alt := range item.Alternates {
locale := alt.Locale
if locale == xDefaultLocale {
locale = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{
Locale: alt.Locale,
URL: alt.URL,
Title: alt.Title,
Description: plainText(ci.tr.value(locale, c.ID, "description", host)),
Words: words,
})
}
out = append(out, rec)
}
return out, nil
}
// matchCategory is the Match of journal-category, by translated or default
// slug or :id, exact only on the category's own URL in the path's locale.
func (m menuTypes) matchCategory(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
morph := models.Category{}.MorphName()
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
return q.Model(&models.Category{})
})
}
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
var cats []models.Category
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil || len(cats) == 0 {
return nil, err
}
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale}, []uint{id})
if err != nil {
return nil, err
}
return ci.item(cats[0]).Alternates, nil
}
return matchContent(ctx, db, in, find, urls)
}
// tagReferences lists every tag by name, ties by id.
func (m menuTypes) tagReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
db, err := m.db(ctx)
if err != nil {
return nil, err
}
var tags []models.Tag
if err := db.Order("name ASC, id ASC").Find(&tags).Error; err != nil {
return nil, err
}
refs := make([]pact.MenuItemReference, 0, len(tags))
for _, tag := range tags {
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(tag.ID), 10), Label: tag.Name})
}
return refs, nil
}
// resolveTag resolves one journal-tag item; an unknown tag, and any tag on a
// host without tag pages, resolve to nothing.
func (m menuTypes) resolveTag(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
id, ok := menuReferenceID(in.Reference)
if !ok || !m.tagPages() {
return nil, nil
}
db, err := m.db(ctx)
if err != nil {
return nil, err
}
var tags []models.Tag
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, nil
}
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
tag := tags[0]
item := resolvedMenuItem(in.Pattern, locales, tag.Name, tag.UpdatedAt, tagParams(tag), func(string) string { return tag.Name })
return &item, nil
}
// tagParams are a tag's placeholder values; tags are not translatable.
func tagParams(tag models.Tag) func(string) [][2]string {
return func(string) [][2]string {
return [][2]string{{"id", strconv.FormatUint(uint64(tag.ID), 10)}, {"slug", tag.Slug}}
}
}
func (m menuTypes) describeTag(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
id, ok := menuReferenceID(in.ID)
if !ok {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
var tags []models.Tag
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, nil
}
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
if err != nil {
return nil, err
}
return &records[0], nil
}
func (m menuTypes) listTags(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
db, err := m.contentDB(ctx)
if err != nil {
return pact.ContentPage{}, err
}
limit := pageLimit(in.Limit)
var tags []models.Tag
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&tags).Error; err != nil {
return pact.ContentPage{}, err
}
more := len(tags) > limit
if more {
tags = tags[:limit]
}
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
if err != nil {
return pact.ContentPage{}, err
}
return contentPage(records, more), nil
}
// describeTags describes tags, always published, one locale per enabled
// locale with the same name and slug; URLs are empty without tag pages.
func (m menuTypes) describeTags(ctx context.Context, db *gorm.DB, tags []models.Tag, pattern, unprefixed string) ([]pact.ContentRecord, error) {
locales, err := loadMenuLocales(ctx, db, unprefixed)
if err != nil {
return nil, err
}
pages := m.tagPages()
out := make([]pact.ContentRecord, 0, len(tags))
for _, tag := range tags {
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(tag.ID), 10), Published: true, LastMod: copyTime(tag.UpdatedAt)}
desc := ""
if tag.Description != nil {
desc = plainText(*tag.Description)
}
for _, alt := range menuAlternates(pattern, locales, tagParams(tag), func(string) string { return tag.Name }) {
url := alt.URL
if !pages {
url = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: tag.Name, Description: desc})
}
out = append(out, rec)
}
return out, nil
}
// matchTag is the Match of journal-tag, by slug or :id; nothing matches on
// a host without tag pages.
func (m menuTypes) matchTag(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
if !m.tagPages() {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
find := func(values map[string]string, _ string, _ menuLocales) (uint, error) {
q := db.Model(&models.Tag{})
switch {
case values["slug"] != "":
q = q.Where("slug = ?", values["slug"])
case values["id"] != "":
id, ok := menuReferenceID(values["id"])
if !ok {
return 0, nil
}
q = q.Where("id = ?", id)
default:
return 0, nil
}
var ids []uint
if err := q.Order("id ASC").Limit(1).Pluck("id", &ids).Error; err != nil || len(ids) == 0 {
return 0, err
}
return ids[0], nil
}
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
var tags []models.Tag
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil || len(tags) == 0 {
return nil, err
}
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
tag := tags[0]
return menuAlternates(in.Pattern, locales, tagParams(tag), func(string) string { return tag.Name }), nil
}
return matchContent(ctx, db, in, find, urls)
}
// pathReading is one way to read a public path: the locale it addresses,
// the path without its locale prefix, and whether the prefix is the one the
// locale's URLs carry.
type pathReading struct {
locale string
rest string
prefixOK bool
}
// readPath lists the readings of path: with a prefix of an enabled locale
// other than the unprefixed one, with the unprefixed locale's own code as a
// prefix (stale, never exact), and as a path of the unprefixed locale.
func readPath(path string, locales menuLocales) []pathReading {
out := []pathReading{}
seg, rest := path, "/"
if trimmed := strings.TrimPrefix(path, "/"); trimmed != path {
if i := strings.IndexByte(trimmed, '/'); i >= 0 {
seg, rest = trimmed[:i], trimmed[i:]
} else {
seg = trimmed
}
for _, code := range locales.codes {
if seg == code {
out = append(out, pathReading{locale: code, rest: rest, prefixOK: code != locales.unprefixed})
}
}
}
return append(out, pathReading{locale: locales.unprefixed, rest: path, prefixOK: true})
}
// matchContent reads the path in every way readPath lists, finds the record
// its placeholder values name (find) and compares the path with the
// record's URL in the reading's locale (urls). The first exact reading
// wins, else the first that found a record.
func matchContent(ctx context.Context, db *gorm.DB, in pact.ContentMatch, find func(values map[string]string, locale string, locales menuLocales) (uint, error), urls func(id uint) ([]pact.MenuItemAlternate, error)) (*pact.ContentMatchResult, error) {
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
var fallback *pact.ContentMatchResult
for _, reading := range readPath(in.Path, locales) {
values, ok := signpost.MatchPattern(in.Pattern, reading.rest)
if !ok {
continue
}
id, err := find(values, reading.locale, locales)
if err != nil {
return nil, err
}
if id == 0 {
continue
}
alternates, err := urls(id)
if err != nil {
return nil, err
}
exact := false
for _, alt := range alternates {
if alt.Locale == reading.locale || (len(locales.codes) == 0 && alt.Locale == xDefaultLocale) {
exact = reading.prefixOK && alt.URL == in.Path
break
}
}
res := &pact.ContentMatchResult{ID: strconv.FormatUint(uint64(id), 10), Locale: reading.locale, Exact: exact}
if exact {
return res, nil
}
if fallback == nil {
fallback = res
}
}
return fallback, nil
}
// findMatchRecord finds the record a path's values name among scope: by
// :slug, first the slug of the path's locale (translate's slug index), then
// the default slug, then a slug of any other locale; else by :id. A value
// reaches the database only as a query parameter.
func findMatchRecord(ctx context.Context, db *gorm.DB, values map[string]string, locale string, locales menuLocales, morph string, scope func(*gorm.DB) *gorm.DB) (uint, error) {
first := func(ids []uint) (uint, error) {
if len(ids) == 0 {
return 0, nil
}
var found []uint
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("id IN ?", ids).Order("id ASC").Limit(1).Pluck("id", &found).Error; err != nil || len(found) == 0 {
return 0, err
}
return found[0], nil
}
if slug := values["slug"]; slug != "" {
if locale != "" && locale != locales.defaultCode {
ids, err := slugIndexIDs(ctx, db, morph, slug, locale)
if err != nil {
return 0, err
}
if id, err := first(ids); err != nil || id != 0 {
return id, err
}
}
var hosts []uint
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("slug = ?", slug).Order("id ASC").Pluck("id", &hosts).Error; err != nil {
return 0, err
}
if id, err := first(hosts); err != nil || id != 0 {
return id, err
}
ids, err := slugIndexIDs(ctx, db, morph, slug, "")
if err != nil {
return 0, err
}
return first(ids)
}
if raw := values["id"]; raw != "" {
id, ok := menuReferenceID(raw)
if !ok {
return 0, nil
}
return first([]uint{id})
}
return 0, nil
}
// slugIndexIDs are the ids of morph records whose translated slug is slug
// in locale ("" for any locale), from translate's index table.
func slugIndexIDs(ctx context.Context, db *gorm.DB, morph, slug, locale string) ([]uint, error) {
q := db.WithContext(ctx).Table(translatemodels.Index{}.TableName()).
Where("model_type = ? AND item = ? AND value = ?", morph, "slug", slug)
if locale != "" {
q = q.Where("locale = ?", locale)
}
var raw []string
if err := q.Order("id ASC").Pluck("model_id", &raw).Error; err != nil {
return nil, err
}
ids := make([]uint, 0, len(raw))
for _, r := range raw {
if id, ok := menuReferenceID(r); ok {
ids = append(ids, id)
}
}
return ids, nil
}

View File

@@ -24,6 +24,8 @@ const (
MenuTypePost = "journal-post" MenuTypePost = "journal-post"
MenuTypeAllPosts = "all-journal-posts" MenuTypeAllPosts = "all-journal-posts"
MenuTypeCategoryPosts = "category-journal-posts" MenuTypeCategoryPosts = "category-journal-posts"
// MenuTypeTag is one tag; it has no PHP counterpart (Phase 32, D-102).
MenuTypeTag = "journal-tag"
) )
// The phrase keys of the menu item type labels. // The phrase keys of the menu item type labels.
@@ -33,6 +35,7 @@ const (
menuLabelPost = "golem15.journal::lang.menuitem.journal_post" menuLabelPost = "golem15.journal::lang.menuitem.journal_post"
menuLabelAllPosts = "golem15.journal::lang.menuitem.all_journal_posts" menuLabelAllPosts = "golem15.journal::lang.menuitem.all_journal_posts"
menuLabelCategoryPosts = "golem15.journal::lang.menuitem.category_journal_posts" menuLabelCategoryPosts = "golem15.journal::lang.menuitem.category_journal_posts"
menuLabelTag = "golem15.journal::lang.menuitem.journal_tag"
) )
// The default URL patterns of the menu item types. A host overrides them // The default URL patterns of the menu item types. A host overrides them
@@ -43,6 +46,8 @@ const (
const ( const (
DefaultPostPattern = "/blog/:slug" DefaultPostPattern = "/blog/:slug"
DefaultCategoryPattern = "/blog/category/:slug" DefaultCategoryPattern = "/blog/category/:slug"
// DefaultTagPattern is the tag route of the vue-journal-layer.
DefaultTagPattern = "/blog/tag/:slug"
) )
// xDefaultLocale is the single alternate's locale when translate has no // xDefaultLocale is the single alternate's locale when translate has no
@@ -54,6 +59,7 @@ const xDefaultLocale = "x-default"
var ( var (
postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"} postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"}
categoryPlaceholders = []string{":slug", ":id"} categoryPlaceholders = []string{":slug", ":id"}
tagPlaceholders = []string{":slug", ":id"}
) )
// MenuItemTypes returns the Journal menu item types, the typed successors // MenuItemTypes returns the Journal menu item types, the typed successors
@@ -74,6 +80,14 @@ var (
// by title. // by title.
// - category-journal-posts: a list whose items are the published posts of // - category-journal-posts: a list whose items are the published posts of
// the picked category and its descendants, ordered by title. // the picked category and its descendants, ordered by title.
// - journal-tag: one tag picked by id (References lists every tag by
// name); it resolves to the tag's URL, /blog/tag/:slug by default, or to
// nothing when the host has no tag pages (TagPagesKey false). It has no
// PHP counterpart.
//
// journal-category, journal-post and journal-tag carry content descriptors
// (pact.ContentDescriptor, content_descriptors.go), so consumers such as
// sitemaps and metadata editors describe, page and match their records.
// //
// A list type builds its items from the pattern it receives, which the // A list type builds its items from the pattern it receives, which the
// registry layers from the item's override, the host override of the list // registry layers from the item's override, the host override of the list
@@ -107,6 +121,13 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
DynamicItems: true, DynamicItems: true,
References: m.categoryReferences, References: m.categoryReferences,
Resolve: m.resolveCategory, Resolve: m.resolveCategory,
Content: &pact.ContentDescriptor{
Controller: categoriesControllerID,
Model: models.Category{}.MorphName(),
Records: m.listCategories,
Describe: m.describeCategory,
Match: m.matchCategory,
},
}, },
{ {
Key: MenuTypeAllCategories, Key: MenuTypeAllCategories,
@@ -124,6 +145,13 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
Placeholders: append([]string(nil), postPlaceholders...), Placeholders: append([]string(nil), postPlaceholders...),
References: m.postReferences, References: m.postReferences,
Resolve: m.resolvePost, Resolve: m.resolvePost,
Content: &pact.ContentDescriptor{
Controller: postsControllerID,
Model: models.Post{}.MorphName(),
Records: m.listPosts,
Describe: m.describePost,
Match: m.matchPost,
},
}, },
{ {
Key: MenuTypeAllPosts, Key: MenuTypeAllPosts,
@@ -144,6 +172,21 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
References: m.categoryReferences, References: m.categoryReferences,
Resolve: m.resolveCategoryPosts, Resolve: m.resolveCategoryPosts,
}, },
{
Key: MenuTypeTag,
Label: menuLabelTag,
DefaultPattern: DefaultTagPattern,
Placeholders: append([]string(nil), tagPlaceholders...),
References: m.tagReferences,
Resolve: m.resolveTag,
Content: &pact.ContentDescriptor{
Controller: tagsControllerID,
Model: models.Tag{}.MorphName(),
Records: m.listTags,
Describe: m.describeTag,
Match: m.matchTag,
},
},
} }
} }

View File

@@ -12,14 +12,14 @@ import (
"git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/pact"
) )
// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the five // TestMenuItemTypesPatterns pins the Docker-free half of D-04: the six
// registered types, the ordered placeholder fill of PHP buildUrlFromPattern, // registered types (the five PHP ones and journal-tag), the ordered placeholder fill of PHP buildUrlFromPattern,
// the post placeholders (date parts only for a published post), the // the post placeholders (date parts only for a published post), the
// alternates (x-default, unprefixed locale, prefixes, first alternate as the // alternates (x-default, unprefixed locale, prefixes, first alternate as the
// URL), the translation fallback, the reference parse, the category tree and // URL), the translation fallback, the reference parse, the category tree and
// the refusals without a booted app. // the refusals without a booted app.
func TestMenuItemTypesPatterns(t *testing.T) { func TestMenuItemTypesPatterns(t *testing.T) {
t.Run("the five types", func(t *testing.T) { t.Run("the six types", func(t *testing.T) {
types := MenuItemTypes(nil) types := MenuItemTypes(nil)
var got []string var got []string
for _, typ := range types { for _, typ := range types {
@@ -35,6 +35,7 @@ func TestMenuItemTypesPatterns(t *testing.T) {
"journal-post|/blog/:slug||nest=false|dyn=false|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.journal_post", "journal-post|/blog/:slug||nest=false|dyn=false|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.journal_post",
"all-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=false|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.all_journal_posts", "all-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=false|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.all_journal_posts",
"category-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.category_journal_posts", "category-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.category_journal_posts",
"journal-tag|/blog/tag/:slug||nest=false|dyn=false|refs=true|:slug,:id|golem15.journal::lang.menuitem.journal_tag",
} }
if strings.Join(got, "\n") != strings.Join(want, "\n") { if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("types:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) t.Fatalf("types:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))

7
console/logger.go Normal file
View File

@@ -0,0 +1,7 @@
package console
import "git.golem15.com/golem15/summercms/modules/diary"
// pluginLog is the plugin's logger: its records carry component=journal, so
// config/log.yaml can raise or lower them (log.components.journal).
var pluginLog = diary.For("journal")

141
console/reindex.go Normal file
View File

@@ -0,0 +1,141 @@
package console
import (
"context"
"errors"
"fmt"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/beachcomber"
"git.golem15.com/golem15/summercms/modules/bonfire"
"gorm.io/gorm"
)
// reindexBatch is how many posts one database read loads.
const reindexBatch = 200
// ReindexResult counts what a reindex did: posts upserted into the index,
// posts removed from it (unpublished or scheduled) and posts whose sync
// failed.
type ReindexResult struct {
Synced int
Removed int
Failed int
}
// Reindex is journal:reindex.
func Reindex(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "journal:reindex",
Description: "Sync every journal post into the search index",
Long: `Upserts the search document of every published post and removes the
document of every unpublished or scheduled one, through the same path as the
sync after a save. Posts written before search was turned on reach the index
this way.
--fresh first drops the collection and recreates it from the post schema
(models.Post.SearchIndexSchema). Use it once on a collection that was
auto-created without the schema, or after the schema changed; search answers
from SQL while the collection is empty.
The command refuses, and sends nothing, when the search engine is not
configured (search.driver null, or no search.typesense.api_key) or the
Journal setting search_use_typesense is off. It ends with
"Reindexed: N synced, N removed, N failed" and exits non-zero when any post
failed.`,
Flags: []bonfire.Flag{
{Name: "fresh", Description: "Drop and recreate the collection from the schema before syncing", Bare: true},
},
Examples: []bonfire.Example{
{Command: "journal:reindex", Description: "Sync every post into the existing collection"},
{Command: "journal:reindex --fresh", Description: "Recreate the collection from the schema, then sync every post"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
res, err := ReindexPosts(ctx, app, flagOn(in, "fresh"), out)
if err != nil {
return err
}
msg := fmt.Sprintf("Reindexed: %d synced, %d removed, %d failed", res.Synced, res.Removed, res.Failed)
if res.Failed > 0 {
out.Error(msg)
return fmt.Errorf("journal: %d posts failed to sync", res.Failed)
}
out.Success(msg)
return nil
},
}
}
// ReindexPosts syncs every post through beachcomber, in id batches. With
// fresh it drops the collection and recreates it from the schema first. It
// returns an error, before any request, when the engine is not configured
// or search_use_typesense is off. A post that fails to sync is counted and
// reported on out; the rest still run.
func ReindexPosts(ctx context.Context, app *backpack.App, fresh bool, out bonfire.Output) (ReindexResult, error) {
var res ReindexResult
gdb := mustDB(app)
if gdb == nil {
return res, errors.New("journal: database is not configured")
}
svc, err := beachcomber.From(app)
if err != nil {
return res, err
}
engine := svc.Engine()
if engine == nil || !engine.Configured() {
return res, errors.New("journal: the search engine is not configured (set search.driver and search.typesense.api_key)")
}
var on bool
if err := gdb.WithContext(ctx).Raw(`SELECT search_use_typesense FROM golem15_journal_settings WHERE id = 1`).Scan(&on).Error; err != nil {
return res, fmt.Errorf("journal: read search_use_typesense: %w", err)
}
if !on {
return res, errors.New("journal: search is off; turn on search_use_typesense in the Journal settings first")
}
models.SetSearchGate(true)
index := svc.IndexName(&models.Post{})
schema := models.Post{}.SearchIndexSchema()
if fresh {
existed, err := beachcomber.DropIndex(ctx, engine, index)
if err != nil {
return res, fmt.Errorf("journal: drop %s: %w", index, err)
}
if existed {
line(out, "Dropped %s", index)
}
}
if err := beachcomber.EnsureIndex(ctx, engine, index, schema); err != nil {
return res, fmt.Errorf("journal: create %s: %w", index, err)
}
var batch []models.Post
err = gdb.WithContext(ctx).Model(&models.Post{}).
Select("id", "published", "published_at").
Order("id").
FindInBatches(&batch, reindexBatch, func(tx *gorm.DB, _ int) error {
for i := range batch {
post := &batch[i]
if err := svc.Sync(ctx, gdb, post); err != nil {
res.Failed++
if out != nil {
out.Warning(fmt.Sprintf("post %d: %v", post.ID, err))
}
continue
}
if post.ShouldBeSearchable() {
res.Synced++
} else {
res.Removed++
}
}
return ctx.Err()
}).Error
if err != nil {
return res, err
}
pluginLog.Info("journal: reindexed posts", "index", index, "fresh", fresh,
"synced", res.Synced, "removed", res.Removed, "failed", res.Failed)
return res, nil
}

395
content_descriptors_test.go Normal file
View File

@@ -0,0 +1,395 @@
package journal_test
import (
"context"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models"
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
// contentSeed is the records the descriptor tests read: a published post
// with a Polish slug in the news category (Polish slug wiadomosci), a
// draft, and a tag.
type contentSeed struct {
news models.Category
hello models.Post
draft models.Post
tag models.Tag
}
func seedContent(t *testing.T, gdb *gorm.DB) contentSeed {
t.Helper()
ctx := context.Background()
for _, row := range []struct {
code, name string
def bool
order int
}{{"en", "English", true, 1}, {"pl", "Polski", false, 2}} {
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order)
SELECT ?, ?, ?, TRUE, ? WHERE NOT EXISTS (SELECT 1 FROM golem15_translate_locales WHERE code = ?)`,
row.code, row.name, row.def, row.order, row.code).Error; err != nil {
t.Fatal(err)
}
}
var s contentSeed
s.news = menuCategory(t, gdb, "News", "news", nil, 1, 2)
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
s.hello = models.Post{Title: "Hello", Slug: "hello", Excerpt: "<p>Short &amp; sweet</p>", Content: "x", ContentHTML: "<p>One two three</p>", Published: true, PublishedAt: &past}
if err := gdb.Create(&s.hello).Error; err != nil {
t.Fatal(err)
}
s.draft = menuPost(t, gdb, "Draft", "draft", false, &past)
attachCategory(t, gdb, s.hello, s.news)
for field, value := range map[string]string{"slug": "witaj", "title": "Witaj"} {
if err := translate.SetTranslated(ctx, gdb, &s.hello, field, "pl", value); err != nil {
t.Fatal(err)
}
}
if err := translate.SetTranslated(ctx, gdb, &s.news, "slug", "pl", "wiadomosci"); err != nil {
t.Fatal(err)
}
s.tag = models.Tag{Name: "Mushrooms", Slug: "mushrooms"}
if err := gdb.Create(&s.tag).Error; err != nil {
t.Fatal(err)
}
return s
}
func contentKeys(types []pact.MenuItemType) string {
keys := []string{}
for _, t := range types {
keys = append(keys, t.Key)
}
return strings.Join(keys, " ")
}
func localeURLs(rec *pact.ContentRecord) string {
parts := []string{}
for _, l := range rec.Locales {
parts = append(parts, l.Locale+"="+l.URL)
}
return strings.Join(parts, " ")
}
// matchPost runs journal-post's Match directly with a pattern.
func matchPost(t *testing.T, reg *signpost.Registry, path, pattern string) *pact.ContentMatchResult {
t.Helper()
typ, ok := reg.ContentType(classes.MenuTypePost)
if !ok || typ.Content.Match == nil {
t.Fatal("journal-post has no Match")
}
res, err := typ.Content.Match(context.Background(), pact.ContentMatch{Path: path, Pattern: pattern})
if err != nil {
t.Fatal(err)
}
return res
}
// TestContentDescriptors (D-93, D-11, D-20c): Journal's post and category
// descriptors describe, page and match their records through signpost;
// strict segments make a wrong category, a stale locale prefix or another
// locale's slug inexact, and drafts never match.
func TestContentDescriptors(t *testing.T) {
requirePostgres(t)
ctx := context.Background()
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
s := seedContent(t, gdb)
if got := contentKeys(reg.ContentTypes()); got != "journal-category journal-post journal-tag" {
t.Fatalf("ContentTypes = %s", got)
}
for controller, key := range map[string]string{
"golem15.journal.posts": classes.MenuTypePost,
"golem15.journal.categories": classes.MenuTypeCategory,
"golem15.journal.tags": classes.MenuTypeTag,
} {
if typ, ok := reg.ContentTypeForController(controller); !ok || typ.Key != key {
t.Fatalf("ContentTypeForController(%s) = %q %v", controller, typ.Key, ok)
}
}
if typ, ok := reg.ContentTypeForModel(models.Category{}.MorphName()); !ok || typ.Key != classes.MenuTypeCategory {
t.Fatalf("category model maps to %q %v", typ.Key, ok)
}
t.Run("describe", func(t *testing.T) {
rec, err := reg.Describe(ctx, classes.MenuTypePost, id(s.hello.ID), "")
if err != nil || rec == nil {
t.Fatalf("Describe post = %+v, %v", rec, err)
}
if got := localeURLs(rec); got != "en=/blog/hello pl=/pl/blog/witaj" {
t.Fatalf("post URLs = %s", got)
}
en := rec.Locales[0]
if !rec.Published || en.Title != "Hello" || rec.Locales[1].Title != "Witaj" || en.Description != "Short & sweet" || en.Words == nil || *en.Words != 3 {
t.Fatalf("post record = %+v, en %+v", rec, en)
}
draft, err := reg.Describe(ctx, classes.MenuTypePost, id(s.draft.ID), "")
if err != nil || draft == nil || draft.Published {
t.Fatalf("Describe draft = %+v, %v", draft, err)
}
cat, err := reg.Describe(ctx, classes.MenuTypeCategory, id(s.news.ID), "")
if err != nil || cat == nil || localeURLs(cat) != "en=/blog/category/news pl=/pl/blog/category/wiadomosci" || !cat.Published {
t.Fatalf("Describe category = %+v, %v", cat, err)
}
if missing, err := reg.Describe(ctx, classes.MenuTypePost, "999999", ""); err != nil || missing != nil {
t.Fatalf("Describe of a missing post = %+v, %v", missing, err)
}
})
t.Run("records", func(t *testing.T) {
page, err := reg.Records(ctx, classes.MenuTypePost, "", 1, "")
if err != nil || len(page.Records) != 1 || page.Records[0].ID != id(s.hello.ID) || page.Next != id(s.hello.ID) {
t.Fatalf("first page = %+v, %v", page, err)
}
page, err = reg.Records(ctx, classes.MenuTypePost, page.Next, 1, "")
if err != nil || len(page.Records) != 1 || page.Records[0].ID != id(s.draft.ID) || page.Next != "" {
t.Fatalf("last page = %+v, %v", page, err)
}
// The migrations seed a default category before news.
cats, err := reg.Records(ctx, classes.MenuTypeCategory, "", 0, "")
if err != nil || len(cats.Records) == 0 || cats.Records[len(cats.Records)-1].ID != id(s.news.ID) || cats.Next != "" {
t.Fatalf("category page = %+v, %v", cats, err)
}
tags, err := reg.Records(ctx, classes.MenuTypeTag, "", 10, "")
if err != nil || len(tags.Records) != 1 || tags.Records[0].ID != id(s.tag.ID) {
t.Fatalf("tag page = %+v, %v", tags, err)
}
})
t.Run("match", func(t *testing.T) {
for _, tc := range []struct {
path, key, locale string
exact bool
}{
{"/blog/hello", classes.MenuTypePost, "en", true},
{"/pl/blog/witaj", classes.MenuTypePost, "pl", true},
{"/pl/blog/hello", classes.MenuTypePost, "pl", false},
{"/blog/witaj", classes.MenuTypePost, "en", false},
{"/en/blog/hello", classes.MenuTypePost, "en", false},
{"/blog/category/news", classes.MenuTypeCategory, "en", true},
{"/pl/blog/category/wiadomosci", classes.MenuTypeCategory, "pl", true},
{"/blog/tag/mushrooms", classes.MenuTypeTag, "en", true},
} {
key, res, err := reg.MatchPath(ctx, tc.path, "")
if err != nil || key != tc.key || res == nil || res.Locale != tc.locale || res.Exact != tc.exact {
t.Fatalf("MatchPath(%s) = %q %+v %v, want %s %s exact=%v", tc.path, key, res, err, tc.key, tc.locale, tc.exact)
}
}
for _, path := range []string{"/blog/draft", "/blog/nothing", "/blog/hello/extra", "/elsewhere"} {
if key, res, err := reg.MatchPath(ctx, path, ""); err != nil || res != nil {
t.Fatalf("MatchPath(%s) = %q %+v %v, want no match", path, key, res, err)
}
}
// Strict segments under a host pattern with the category.
if res := matchPost(t, reg, "/news/hello", "/:category/:slug"); res == nil || !res.Exact || res.ID != id(s.hello.ID) {
t.Fatalf("right category = %+v", res)
}
if res := matchPost(t, reg, "/sport/hello", "/:category/:slug"); res == nil || res.Exact || res.ID != id(s.hello.ID) {
t.Fatalf("wrong category = %+v, want the post, inexact", res)
}
if res := matchPost(t, reg, "/pl/wiadomosci/witaj", "/:category/:slug"); res == nil || !res.Exact || res.Locale != "pl" {
t.Fatalf("Polish category path = %+v", res)
}
if res := matchPost(t, reg, "/blog/"+id(s.hello.ID)+"-x", "/blog/:id-x"); res != nil {
t.Fatalf("a placeholder inside a segment matched: %+v", res)
}
if res := matchPost(t, reg, "/p/"+id(s.hello.ID), "/p/:id"); res == nil || !res.Exact {
t.Fatalf("id pattern = %+v", res)
}
if res := matchPost(t, reg, "/p/"+id(s.draft.ID), "/p/:id"); res != nil {
t.Fatalf("draft by id matched: %+v", res)
}
})
}
// TestJournalTagType (D-102, P-11): journal-tag lists tags as references,
// resolves to /blog/tag/<slug>, and is described and matched; with
// golem15.journal.tag_pages false its URLs are empty and its paths never
// match.
func TestJournalTagType(t *testing.T) {
requirePostgres(t)
ctx := context.Background()
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
s := seedContent(t, gdb)
typ, ok := reg.Type(classes.MenuTypeTag)
if !ok || typ.DefaultPattern != classes.DefaultTagPattern || typ.Label != "golem15.journal::lang.menuitem.journal_tag" || typ.Content == nil {
t.Fatalf("journal-tag = %+v %v", typ, ok)
}
refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypeTag)
if err != nil || !refs.Applies || len(refs.Options) != 1 || refs.Options[0].Value != id(s.tag.ID) || refs.Options[0].Label != "Mushrooms" {
t.Fatalf("tag references = %+v, %v", refs, err)
}
item := resolve(t, reg, classes.MenuTypeTag, id(s.tag.ID), "", false)
if item.URL != "/blog/tag/mushrooms" || item.Title != "Mushrooms" {
t.Fatalf("resolved tag = %+v", item)
}
rec, err := reg.Describe(ctx, classes.MenuTypeTag, id(s.tag.ID), "")
if err != nil || rec == nil || localeURLs(rec) != "en=/blog/tag/mushrooms pl=/pl/blog/tag/mushrooms" {
t.Fatalf("Describe tag = %+v, %v", rec, err)
}
_, offDB, off := bootMenuItemTypes(t, map[string]string{"golem15.yaml": "journal:\n tag_pages: false\n"})
offSeed := seedContent(t, offDB)
rec, err = off.Describe(ctx, classes.MenuTypeTag, id(offSeed.tag.ID), "")
if err != nil || rec == nil || localeURLs(rec) != "en= pl=" {
t.Fatalf("Describe tag without tag pages = %+v, %v", rec, err)
}
if key, res, err := off.MatchPath(ctx, "/blog/tag/mushrooms", ""); err != nil || res != nil {
t.Fatalf("tag path matched without tag pages: %q %+v %v", key, res, err)
}
if got, err := off.Resolve(ctx, classes.MenuTypeTag, id(offSeed.tag.ID), "", false, ""); err != nil || got != nil {
t.Fatalf("tag resolved without tag pages: %+v %v", got, err)
}
}
// TestContentDescriptorsMatrix (D-93, D-20c, D-58): the descriptor matrix
// for posts, categories and tags: Describe per locale with and without an
// unprefixed locale, Match exact and inexact per locale, Records paging,
// host patterns, and unknown or malformed ids.
func TestContentDescriptorsMatrix(t *testing.T) {
requirePostgres(t)
ctx := context.Background()
t.Run("describe and match with Polish unprefixed", func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
s := seedContent(t, gdb)
for _, tc := range []struct{ key, id, want string }{
{classes.MenuTypePost, id(s.hello.ID), "en=/en/blog/hello pl=/blog/witaj"},
{classes.MenuTypeCategory, id(s.news.ID), "en=/en/blog/category/news pl=/blog/category/wiadomosci"},
{classes.MenuTypeTag, id(s.tag.ID), "en=/en/blog/tag/mushrooms pl=/blog/tag/mushrooms"},
} {
rec, err := reg.Describe(ctx, tc.key, tc.id, "pl")
if err != nil || rec == nil || localeURLs(rec) != tc.want {
t.Fatalf("Describe(%s) = %+v, %v; want %s", tc.key, rec, err, tc.want)
}
}
for _, tc := range []struct {
path, key, locale string
exact bool
}{
{"/blog/witaj", classes.MenuTypePost, "pl", true},
{"/en/blog/hello", classes.MenuTypePost, "en", true},
{"/pl/blog/witaj", classes.MenuTypePost, "pl", false},
{"/blog/hello", classes.MenuTypePost, "pl", false},
{"/blog/category/wiadomosci", classes.MenuTypeCategory, "pl", true},
{"/en/blog/category/news", classes.MenuTypeCategory, "en", true},
{"/blog/category/news", classes.MenuTypeCategory, "pl", false},
{"/blog/tag/mushrooms", classes.MenuTypeTag, "pl", true},
{"/en/blog/tag/mushrooms", classes.MenuTypeTag, "en", true},
} {
key, res, err := reg.MatchPath(ctx, tc.path, "pl")
if err != nil || key != tc.key || res == nil || res.Locale != tc.locale || res.Exact != tc.exact {
t.Fatalf("MatchPath(%s, pl) = %q %+v %v, want %s %s exact=%v", tc.path, key, res, err, tc.key, tc.locale, tc.exact)
}
}
})
t.Run("match per locale without an unprefixed locale", func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
s := seedContent(t, gdb)
for _, tc := range []struct {
path, key, locale string
exact bool
}{
{"/blog/category/wiadomosci", classes.MenuTypeCategory, "en", false},
{"/pl/blog/category/news", classes.MenuTypeCategory, "pl", false},
{"/en/blog/category/news", classes.MenuTypeCategory, "en", false},
{"/pl/blog/tag/mushrooms", classes.MenuTypeTag, "pl", true},
} {
key, res, err := reg.MatchPath(ctx, tc.path, "")
if err != nil || key != tc.key || res == nil || res.Locale != tc.locale || res.Exact != tc.exact {
t.Fatalf("MatchPath(%s) = %q %+v %v, want %s %s exact=%v", tc.path, key, res, err, tc.key, tc.locale, tc.exact)
}
}
for _, path := range []string{"/pl/blog/draft", "/blog/tag/missing", "/blog/category/missing", "/blog/category/news/extra", "/blog/tag/"} {
if key, res, err := reg.MatchPath(ctx, path, ""); err != nil || res != nil {
t.Fatalf("MatchPath(%s) = %q %+v %v, want no match", path, key, res, err)
}
}
for _, tc := range []struct{ key, id string }{
{classes.MenuTypePost, "999999"}, {classes.MenuTypePost, "abc"},
{classes.MenuTypeCategory, "999999"}, {classes.MenuTypeTag, "999999"}, {classes.MenuTypeTag, ""},
} {
if rec, err := reg.Describe(ctx, tc.key, tc.id, ""); err != nil || rec != nil {
t.Fatalf("Describe(%s, %q) = %+v, %v; want nothing", tc.key, tc.id, rec, err)
}
}
draft, err := reg.Describe(ctx, classes.MenuTypePost, id(s.draft.ID), "")
if err != nil || draft == nil || draft.Published || localeURLs(draft) != "en=/blog/draft pl=/pl/blog/draft" {
t.Fatalf("Describe draft = %+v, %v", draft, err)
}
})
t.Run("records paging", func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
s := seedContent(t, gdb)
second := models.Tag{Name: "Forest", Slug: "forest"}
if err := gdb.Create(&second).Error; err != nil {
t.Fatal(err)
}
all, err := reg.Records(ctx, classes.MenuTypePost, "", 0, "")
if err != nil || len(all.Records) != 2 || all.Next != "" {
t.Fatalf("limit 0 = %+v, %v", all, err)
}
if again, err := reg.Records(ctx, classes.MenuTypePost, "not-an-id", 0, ""); err != nil || len(again.Records) != 2 {
t.Fatalf("a malformed cursor = %+v, %v; want the first page", again, err)
}
if end, err := reg.Records(ctx, classes.MenuTypePost, id(s.draft.ID), 10, ""); err != nil || len(end.Records) != 0 || end.Next != "" {
t.Fatalf("after the last post = %+v, %v", end, err)
}
first, err := reg.Records(ctx, classes.MenuTypeTag, "", 1, "")
if err != nil || len(first.Records) != 1 || first.Records[0].ID != id(s.tag.ID) || first.Next != id(s.tag.ID) {
t.Fatalf("first tag page = %+v, %v", first, err)
}
next, err := reg.Records(ctx, classes.MenuTypeTag, first.Next, 1, "")
if err != nil || len(next.Records) != 1 || next.Records[0].ID != id(second.ID) || next.Next != "" {
t.Fatalf("second tag page = %+v, %v", next, err)
}
if got := localeURLs(&next.Records[0]); got != "en=/blog/tag/forest pl=/pl/blog/tag/forest" {
t.Fatalf("paged tag URLs = %s", got)
}
if !all.Records[0].Published || all.Records[1].Published {
t.Fatalf("published flags = %v %v", all.Records[0].Published, all.Records[1].Published)
}
})
t.Run("host patterns", func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, map[string]string{"menu_items.yaml": "patterns:\n journal-category: /c/:slug\n journal-tag: /t/:slug\n journal-post: /:category/:slug\n"})
s := seedContent(t, gdb)
for _, tc := range []struct{ key, id, want string }{
{classes.MenuTypeCategory, id(s.news.ID), "en=/c/news pl=/pl/c/wiadomosci"},
{classes.MenuTypeTag, id(s.tag.ID), "en=/t/mushrooms pl=/pl/t/mushrooms"},
{classes.MenuTypePost, id(s.hello.ID), "en=/news/hello pl=/pl/wiadomosci/witaj"},
} {
rec, err := reg.Describe(ctx, tc.key, tc.id, "")
if err != nil || rec == nil || localeURLs(rec) != tc.want {
t.Fatalf("Describe(%s) = %+v, %v; want %s", tc.key, rec, err, tc.want)
}
}
for _, tc := range []struct {
path, key string
exact bool
}{
{"/c/news", classes.MenuTypeCategory, true},
{"/t/mushrooms", classes.MenuTypeTag, true},
{"/news/hello", classes.MenuTypePost, true},
{"/pl/wiadomosci/witaj", classes.MenuTypePost, true},
} {
key, res, err := reg.MatchPath(ctx, tc.path, "")
if err != nil || key != tc.key || res == nil || res.Exact != tc.exact {
t.Fatalf("MatchPath(%s) = %q %+v %v", tc.path, key, res, err)
}
}
if key, res, err := reg.MatchPath(ctx, "/blog/category/news", ""); err != nil || res != nil {
t.Fatalf("the default pattern still matched: %q %+v %v", key, res, err)
}
})
}

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"html" "html"
"net/http" "net/http"
@@ -16,6 +17,7 @@ import (
"git.golem15.com/golem15/summercms/modules/cooler" "git.golem15.com/golem15/summercms/modules/cooler"
"git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/wire" "git.golem15.com/golem15/summercms/modules/wire"
"gocloud.dev/blob" "gocloud.dev/blob"
"gorm.io/gorm" "gorm.io/gorm"
@@ -24,10 +26,17 @@ import (
const ( const (
defaultPerPage = 9 defaultPerPage = 9
maxPerPage = 30 maxPerPage = 30
// fieldsSuggest is the ?fields value of the suggestion rows.
fieldsSuggest = "suggest"
// suggestExcerptRunes is about how long a suggestion's excerpt runs.
suggestExcerptRunes = 160
) )
// Index is GET /_journal/api/v1/posts. Anonymous callers see published posts // Index is GET /_journal/api/v1/posts. Anonymous callers see published posts
// only; an editor with access_posts may include drafts unless ?published=true. // only; an editor with access_posts may include drafts unless ?published=true.
// With ?fields=suggest each row is the short suggestion shape
// (serializeSuggestion).
func Index(app *backpack.App) http.HandlerFunc { func Index(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
r = optionalBackendPrincipal(app, r) r = optionalBackendPrincipal(app, r)
@@ -42,8 +51,11 @@ func Index(app *backpack.App) http.HandlerFunc {
perPage := clampPerPage(r.URL.Query().Get("per_page")) perPage := clampPerPage(r.URL.Query().Get("per_page"))
page := max(1, atoiDefault(r.URL.Query().Get("page"), 1)) page := max(1, atoiDefault(r.URL.Query().Get("page"), 1))
q := db.WithContext(r.Context()).Model(&models.Post{}). suggest := r.URL.Query().Get("fields") == fieldsSuggest
Preload("User").Preload("Categories").Preload("Tags") q := db.WithContext(r.Context()).Model(&models.Post{})
if !suggest {
q = q.Preload("User").Preload("Categories").Preload("Tags")
}
publishedOnly := true publishedOnly := true
if editor { if editor {
@@ -77,8 +89,11 @@ func Index(app *backpack.App) http.HandlerFunc {
if len([]rune(search)) < 3 { if len([]rune(search)) < 3 {
search = "" search = ""
} }
// rank is the engine's order of the candidates, nil for a SQL search.
var rank []uint
if search != "" { if search != "" {
if ids, used := searchWithEngine(app, r, db.WithContext(r.Context()), search, publishedOnly, page, perPage); used { if ids, used := searchWithEngine(app, r, db.WithContext(r.Context()), search, publishedOnly); used {
rank = ids
if len(ids) == 0 { if len(ids) == 0 {
q = q.Where("1 = 0") q = q.Where("1 = 0")
} else { } else {
@@ -100,7 +115,11 @@ func Index(app *backpack.App) http.HandlerFunc {
} }
sort := strings.TrimSpace(r.URL.Query().Get("sort")) sort := strings.TrimSpace(r.URL.Query().Get("sort"))
order := listOrder(sort, queryBool(r.URL.Query().Get("pinned_first"))) pinnedFirst := queryBool(r.URL.Query().Get("pinned_first"))
order := listOrder(sort, pinnedFirst)
if len(rank) > 0 && sort == "" {
order = rankOrder(rank, pinnedFirst)
}
var posts []models.Post var posts []models.Post
if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil { if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error") writeAPIError(w, http.StatusInternalServerError, "Internal server error")
@@ -108,7 +127,11 @@ func Index(app *backpack.App) http.HandlerFunc {
} }
rows := make([]map[string]any, 0, len(posts)) rows := make([]map[string]any, 0, len(posts))
for i := range posts { for i := range posts {
rows = append(rows, serializePost(&posts[i], db.WithContext(r.Context()))) if suggest {
rows = append(rows, serializeSuggestion(&posts[i], db.WithContext(r.Context())))
} else {
rows = append(rows, serializePost(&posts[i], db.WithContext(r.Context())))
}
} }
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total)) wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
} }
@@ -127,7 +150,7 @@ func Show(app *backpack.App) http.HandlerFunc {
} }
db = db.WithContext(r.Context()) db = db.WithContext(r.Context())
slug := strings.TrimSpace(r.PathValue("slug")) slug := strings.TrimSpace(r.PathValue("slug"))
post, err := findPostBySlugOrID(db, slug) post, err := findPostBySlugOrID(db, slug, requestLocale(r))
if err != nil || post == nil { if err != nil || post == nil {
writeAPIError(w, http.StatusNotFound, errPostNotFound) writeAPIError(w, http.StatusNotFound, errPostNotFound)
return return
@@ -219,15 +242,26 @@ func Store(app *backpack.App) http.HandlerFunc {
writeAPIError(w, http.StatusForbidden, errPublishPermission) writeAPIError(w, http.StatusForbidden, errPublishPermission)
return return
} }
if err := db.WithContext(r.Context()).Create(post).Error; err != nil { // D-92/D-102: the row, its associations and translations commit
writeAPIError(w, http.StatusInternalServerError, "Internal server error") // together, and RecordSaved fires only once they have.
return err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
} if err := tx.Create(post).Error; err != nil {
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil { return err
writeAPIError(w, http.StatusInternalServerError, "Internal server error") }
return tags, err := applyPostAssocs(ctx, tx, post, body)
} if err != nil {
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil { return err
}
if err := loadPostAssocs(tx, post); err != nil {
return err
}
cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, nil, post, body))
for _, tag := range tags {
cabana.EmitRecordSaved(ctx, app, tx, tagSavedEvent(ctx, &tag))
}
return nil
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error") writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return return
} }
@@ -265,6 +299,9 @@ func Update(app *backpack.App) http.HandlerFunc {
writeAPIError(w, http.StatusBadRequest, errValidationFailed) writeAPIError(w, http.StatusBadRequest, errValidationFailed)
return return
} }
// D-92/D-102: the values before the write, for the RecordSaved
// subscribers hear (a slug changed over MCP gets its redirect).
before := snapshotPost(r.Context(), db.WithContext(r.Context()), post, body)
if verrs := applyPostUpdate(r, db.WithContext(r.Context()), post, body); verrs != nil { if verrs := applyPostUpdate(r, db.WithContext(r.Context()), post, body); verrs != nil {
writeValidation(w, verrs) writeValidation(w, verrs)
return return
@@ -273,15 +310,26 @@ func Update(app *backpack.App) http.HandlerFunc {
writeAPIError(w, http.StatusForbidden, errPublishPermission) writeAPIError(w, http.StatusForbidden, errPublishPermission)
return return
} }
if err := db.WithContext(r.Context()).Save(post).Error; err != nil { // D-92/D-102: a slug change commits with its translations and
writeAPIError(w, http.StatusInternalServerError, "Internal server error") // associations, or not at all, and RecordSaved follows the commit.
return err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
} if err := tx.Save(post).Error; err != nil {
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil { return err
writeAPIError(w, http.StatusInternalServerError, "Internal server error") }
return tags, err := applyPostAssocs(ctx, tx, post, body)
} if err != nil {
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil { return err
}
if err := loadPostAssocs(tx, post); err != nil {
return err
}
cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, before, post, body))
for _, tag := range tags {
cabana.EmitRecordSaved(ctx, app, tx, tagSavedEvent(ctx, &tag))
}
return nil
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error") writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return return
} }
@@ -319,6 +367,10 @@ func Destroy(app *backpack.App) http.HandlerFunc {
return return
} }
purgeSiteCache(r, app, post) purgeSiteCache(r, app, post)
cabana.EmitRecordDeleted(r.Context(), app, nil, pact.RecordDeleted{
Model: post.MorphName(),
ID: strconv.FormatUint(uint64(post.ID), 10),
})
wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgPostDeleted}) wire.WriteJSON(w, http.StatusOK, map[string]any{"message": msgPostDeleted})
} }
} }
@@ -554,10 +606,7 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
tags = append(tags, map[string]any{"id": t.ID, "name": t.Name, "slug": t.Slug}) tags = append(tags, map[string]any{"id": t.ID, "name": t.Name, "slug": t.Slug})
} }
featured := featuredImages(db, p) featured := featuredImages(db, p)
var featuredURL any featuredURL := firstImageURL(featured)
if len(featured) > 0 {
featuredURL = featured[0]["url"]
}
row := map[string]any{ row := map[string]any{
"id": p.ID, "id": p.ID,
"title": p.Title, "title": p.Title,
@@ -571,7 +620,7 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
"is_pinned": p.IsPinned, "is_pinned": p.IsPinned,
"metadata": jsonField(p.Metadata), "metadata": jsonField(p.Metadata),
"sources": jsonField(p.Sources), "sources": jsonField(p.Sources),
"author": serializeAuthor(p.User), "author": serializeAuthor(db, p.User),
"categories": cats, "categories": cats,
"tags": tags, "tags": tags,
"featured_images": featured, "featured_images": featured,
@@ -583,15 +632,78 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
return row return row
} }
func serializeAuthor(user *cabana.BackendUser) any { // serializeSuggestion is the ?fields=suggest row: a subset of serializePost's
// keys with the same value formats, except that excerpt is plain text cut to
// about suggestExcerptRunes on a word boundary, taken from the content when
// the post has no excerpt.
func serializeSuggestion(p *models.Post, db *gorm.DB) map[string]any {
if p == nil {
return map[string]any{}
}
return map[string]any{
"id": p.ID,
"title": p.Title,
"slug": p.Slug,
"excerpt": suggestionExcerpt(p),
"featured_image_url": firstImageURL(featuredImages(db, p)),
"published_at": carbonTime(p.PublishedAt),
}
}
func suggestionExcerpt(p *models.Post) string {
text := models.PlainText(p.Excerpt)
if text == "" {
body := p.ContentHTML
if strings.TrimSpace(body) == "" {
body = p.Content
}
text = models.PlainText(body)
}
return truncateWords(text, suggestExcerptRunes)
}
// truncateWords cuts s to at most n runes at the last space before the cut,
// with an ellipsis; s is returned whole when it fits.
func truncateWords(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
cut := runes[:n]
if i := strings.LastIndexFunc(string(cut), unicode.IsSpace); i > 0 {
return strings.TrimRightFunc(string(cut)[:i], func(r rune) bool { return unicode.IsSpace(r) || unicode.IsPunct(r) }) + "…"
}
return string(cut) + "…"
}
func firstImageURL(featured []map[string]any) any {
if len(featured) == 0 {
return nil
}
return featured[0]["url"]
}
// serializeAuthor is a post's author object. slug is the author's canonical
// identifier, as on GET /authors/{author}: the author slug when set, otherwise
// the login, so a consumer links to the author page by slug.
func serializeAuthor(db *gorm.DB, user *cabana.BackendUser) any {
if user == nil { if user == nil {
return nil return nil
} }
slug := user.Login
if db != nil {
var authorSlug string
if err := db.Table("backend_users").Select("COALESCE(golem15_bloghub_author_slug, '')").
Where("id = ?", user.ID).Scan(&authorSlug).Error; err == nil && authorSlug != "" {
slug = authorSlug
}
}
return map[string]any{ return map[string]any{
"id": user.ID, "id": user.ID,
"first_name": user.FirstName, "first_name": user.FirstName,
"last_name": user.LastName, "last_name": user.LastName,
"login": user.Login, "login": user.Login,
"slug": slug,
} }
} }

View File

@@ -16,8 +16,10 @@ import (
"git.golem15.com/golem15/sm-journal-plugin/classes" "git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/sm-journal-plugin/models"
translate "git.golem15.com/golem15/sm-translate-plugin/classes" translate "git.golem15.com/golem15/sm-translate-plugin/classes"
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -52,12 +54,29 @@ func ctypeDigit(s string) bool {
return true return true
} }
func findPostBySlugOrID(db *gorm.DB, slug string) (*models.Post, error) { // findPostBySlugOrID finds a post by its default-locale slug, then by its
// slug in locale (the request locale, D-58: translate's slug index), then
// by numeric id. Drafts are found too; the caller decides who may see them.
func findPostBySlugOrID(db *gorm.DB, slug, locale string) (*models.Post, error) {
q := postQuery(db) q := postQuery(db)
var post models.Post var post models.Post
if err := q.Where("slug = ?", slug).Take(&post).Error; err == nil { if err := q.Where("slug = ?", slug).Take(&post).Error; err == nil {
return &post, nil return &post, nil
} }
if locale != "" && slug != "" {
var ids []string
err := db.Session(&gorm.Session{NewDB: true, Context: db.Statement.Context}).
Table(translatemodels.Index{}.TableName()).
Where("model_type = ? AND locale = ? AND item = ? AND value = ?", models.Post{}.MorphName(), locale, "slug", slug).
Order("id ASC").
Pluck("model_id", &ids).Error
if err == nil && len(ids) > 0 {
var translated models.Post
if err := postQuery(db).Where("CAST(id AS TEXT) IN ?", ids).Order("id ASC").Take(&translated).Error; err == nil {
return &translated, nil
}
}
}
if ctypeDigit(slug) { if ctypeDigit(slug) {
id, err := strconv.ParseUint(slug, 10, 64) id, err := strconv.ParseUint(slug, 10, 64)
if err == nil && id > 0 { if err == nil && id > 0 {
@@ -676,54 +695,59 @@ func asUintSlice(raw any) ([]uint, bool) {
} }
} }
func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) error { // applyPostAssocs writes the post's categories, tags and translations the
// body submits. created are the tags the write created by name, for their
// record events.
func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) (created []models.Tag, err error) {
if _, has := body["categories"]; has { if _, has := body["categories"]; has {
ids, _ := asUintSlice(body["categories"]) ids, _ := asUintSlice(body["categories"])
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil { if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil {
return err return nil, err
} }
for _, id := range ids { for _, id := range ids {
if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil { if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil {
return err return nil, err
} }
} }
} }
if _, has := body["tags"]; has { if _, has := body["tags"]; has {
ids, err := resolveTagIDs(db, body["tags"]) var ids []uint
ids, created, err = resolveTagIDs(db, body["tags"])
if err != nil { if err != nil {
return err return nil, err
} }
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil { if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil {
return err return nil, err
} }
for _, id := range ids { for _, id := range ids {
if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil { if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil {
return err return nil, err
} }
} }
} }
if raw, has := body["translations"]; has { if raw, has := body["translations"]; has {
if err := syncTranslations(ctx, db, post, raw); err != nil { if err := syncTranslations(ctx, db, post, raw); err != nil {
return err return nil, err
} }
} }
return nil return created, nil
} }
func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) { // resolveTagIDs turns the submitted tags (ids or names) into tag ids; a name
// no tag has yet creates the tag, returned in created.
func resolveTagIDs(db *gorm.DB, raw any) (ids []uint, created []models.Tag, err error) {
items, ok := raw.([]any) items, ok := raw.([]any)
if !ok { if !ok {
if raw == nil { if raw == nil {
return nil, nil return nil, nil, nil
} }
return nil, fmt.Errorf("tags must be an array") return nil, nil, fmt.Errorf("tags must be an array")
} }
var ids []uint
for _, item := range items { for _, item := range items {
if id, ok := asUint(item); ok { if id, ok := asUint(item); ok {
var n int64 var n int64
if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil { if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil {
return nil, err return nil, nil, err
} }
if n > 0 { if n > 0 {
ids = append(ids, id) ids = append(ids, id)
@@ -746,11 +770,12 @@ func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) {
} }
tag = models.Tag{Name: name, Slug: slug} tag = models.Tag{Name: name, Slug: slug}
if err := db.Create(&tag).Error; err != nil { if err := db.Create(&tag).Error; err != nil {
return nil, err return nil, nil, err
} }
ids = append(ids, tag.ID) ids = append(ids, tag.ID)
created = append(created, tag)
} }
return ids, nil return ids, created, nil
} }
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error { func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error {
@@ -780,3 +805,21 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw a
} }
return nil return nil
} }
// requestLocale is the request's locale (towel, set by the router from the
// locale resolver or Accept-Language) as a lower-case language code: the
// first language of an Accept-Language list, without its region.
func requestLocale(r *http.Request) string {
if r == nil {
return ""
}
raw, _ := towel.Locale(r.Context())
raw = strings.TrimSpace(raw)
if i := strings.IndexAny(raw, ",;"); i >= 0 {
raw = raw[:i]
}
if i := strings.IndexAny(raw, "-_"); i >= 0 {
raw = raw[:i]
}
return strings.ToLower(strings.TrimSpace(raw))
}

View File

@@ -3,15 +3,27 @@ package api
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/beachcomber" "git.golem15.com/golem15/summercms/modules/beachcomber"
"git.golem15.com/golem15/summercms/modules/beachcomber/typesense"
"gorm.io/gorm" "gorm.io/gorm"
) )
func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search string, publishedOnly bool, page, perPage int) ([]uint, bool) { // searchWindow is how many engine candidates one search re-gates in SQL.
if app == nil || db == nil || !models.SearchGateEnabled() { // The list pages over the gated rows, so a page never depends on how the
// engine paged.
const searchWindow = typesense.MaxPerPage
// searchWithEngine returns the candidate post ids for search in the engine's
// rank order, and whether the engine answered. It asks for the whole result
// window (the first searchWindow hits), not one page: the caller re-gates
// the ids in SQL, counts them and pages over them. When it reports false the
// caller falls back to SQL search.
func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search string, publishedOnly bool) ([]uint, bool) {
if app == nil || db == nil {
return nil, false return nil, false
} }
svc, err := beachcomber.From(app) svc, err := beachcomber.From(app)
@@ -20,17 +32,27 @@ func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search st
} }
var settings models.Settings var settings models.Settings
if err := db.First(&settings, 1).Error; err != nil { if err := db.First(&settings, 1).Error; err != nil {
if !models.SearchGateEnabled() {
return nil, false
}
settings.SearchTitleWeight = 5 settings.SearchTitleWeight = 5
settings.SearchExcerptWeight = 3 settings.SearchExcerptWeight = 3
settings.SearchContentWeight = 1 settings.SearchContentWeight = 1
} else {
// The row is the gate: the in-memory one is set only once a sync
// has run since boot.
models.SetSearchGate(settings.SearchUseTypesense)
if !settings.SearchUseTypesense {
return nil, false
}
} }
q := beachcomber.Query{ q := beachcomber.Query{
Q: search, Q: search,
QueryBy: []string{"title", "excerpt", "content"}, QueryBy: []string{"title", "excerpt", "content"},
QueryByWeights: []int{settings.SearchTitleWeight, settings.SearchExcerptWeight, settings.SearchContentWeight}, QueryByWeights: []int{settings.SearchTitleWeight, settings.SearchExcerptWeight, settings.SearchContentWeight},
SortBy: "_text_match:desc,published_at:desc", SortBy: "_text_match:desc,published_at:desc",
Page: page, Page: 1,
PerPage: perPage, PerPage: searchWindow,
} }
if publishedOnly { if publishedOnly {
q.FilterBy = "published:=1" q.FilterBy = "published:=1"
@@ -40,6 +62,9 @@ func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search st
pluginLog.Warn("journal: Typesense search failed, falling back to DB search", "err", err) pluginLog.Warn("journal: Typesense search failed, falling back to DB search", "err", err)
return nil, false return nil, false
} }
if res.Found > searchWindow {
pluginLog.Debug("journal: search matched more posts than one window; keeping the first", "found", res.Found, "kept", searchWindow)
}
ids := make([]uint, 0, len(res.IDs)) ids := make([]uint, 0, len(res.IDs))
for _, raw := range res.IDs { for _, raw := range res.IDs {
n, err := strconv.ParseUint(raw, 10, 64) n, err := strconv.ParseUint(raw, 10, 64)
@@ -50,3 +75,18 @@ func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search st
} }
return ids, true return ids, true
} }
// rankOrder orders rows by their position in ids, the engine's rank, with
// pinned posts first when pinnedFirst. The ids are parsed integers, so the
// array literal carries no caller text.
func rankOrder(ids []uint, pinnedFirst bool) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.FormatUint(uint64(id), 10)
}
order := "array_position(ARRAY[" + strings.Join(parts, ",") + "]::bigint[], id), id DESC"
if pinnedFirst {
return "is_pinned DESC, " + order
}
return order
}

View File

@@ -0,0 +1,191 @@
package api
import (
"context"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-journal-plugin/models"
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
// postEventColumns are the post columns a record event reports, in order.
var postEventColumns = []struct {
name string
value func(p *models.Post) any
}{
{"user_id", func(p *models.Post) any { return derefValue(p.UserID) }},
{"redactor_id", func(p *models.Post) any { return derefValue(p.RedactorID) }},
{"title", func(p *models.Post) any { return p.Title }},
{"slug", func(p *models.Post) any { return p.Slug }},
{"excerpt", func(p *models.Post) any { return p.Excerpt }},
{"content", func(p *models.Post) any { return p.Content }},
{"content_html", func(p *models.Post) any { return p.ContentHTML }},
{"published_at", func(p *models.Post) any { return derefValue(p.PublishedAt) }},
{"published", func(p *models.Post) any { return p.Published }},
{"is_pinned", func(p *models.Post) any { return p.IsPinned }},
{"metadata", func(p *models.Post) any { return derefValue(p.Metadata) }},
{"sources", func(p *models.Post) any { return derefValue(p.Sources) }},
}
// translatedEventFields are the translated fields a write may submit under
// "translations" (syncTranslations).
var translatedEventFields = []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
// postSnapshot is a post's values before an API write: its columns and the
// stored translations of the fields the request submits per locale.
type postSnapshot struct {
columns map[string]any
translations map[string]map[string]any // locale -> field -> old (nil when missing)
}
// snapshotPost takes the values an update may change, before it changes
// them. A translation read error leaves that value out (reported as new).
func snapshotPost(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) *postSnapshot {
snap := &postSnapshot{columns: map[string]any{}, translations: map[string]map[string]any{}}
for _, col := range postEventColumns {
snap.columns[col.name] = col.value(post)
}
for locale, fields := range submittedTranslations(body) {
snap.translations[locale] = map[string]any{}
for _, field := range fields {
text, ok, err := translate.TranslatedExact(ctx, db, post, field, locale)
if err == nil && ok {
snap.translations[locale][field] = text
} else {
snap.translations[locale][field] = nil
}
}
}
return snap
}
// postSavedEvent is the RecordSaved of an API write, which the handler
// fires with cabana.EmitRecordSaved inside the write's transaction, so it
// reaches subscribers once the write commits. before is nil on create:
// every column with a value and every submitted translation is then
// reported with a nil Old. Controller is "": the write did not go through an
// admin form.
func postSavedEvent(ctx context.Context, db *gorm.DB, before *postSnapshot, post *models.Post, body map[string]any) pact.RecordSaved {
changes := []pact.RecordChange{}
for _, col := range postEventColumns {
next := col.value(post)
if before == nil {
if next == nil || reflect.ValueOf(next).IsZero() {
continue
}
changes = append(changes, pact.RecordChange{Attribute: col.name, New: next})
continue
}
prev := before.columns[col.name]
if sameEventValue(prev, next) {
continue
}
changes = append(changes, pact.RecordChange{Attribute: col.name, Old: prev, New: next})
}
submitted := submittedTranslations(body)
locales := make([]string, 0, len(submitted))
for locale := range submitted {
locales = append(locales, locale)
}
sort.Strings(locales)
for _, locale := range locales {
for _, field := range submitted[locale] {
text, ok, err := translate.TranslatedExact(ctx, db, post, field, locale)
if err != nil || !ok {
continue
}
var prev any
if before != nil {
prev = before.translations[locale][field]
}
if old, _ := prev.(string); old == text {
continue
}
changes = append(changes, pact.RecordChange{Attribute: field, Locale: locale, Old: prev, New: text})
}
}
locale, _ := towel.Locale(ctx)
return pact.RecordSaved{
Model: post.MorphName(),
ID: strconv.FormatUint(uint64(post.ID), 10),
Locale: locale,
Created: before == nil,
Changes: changes,
}
}
// tagSavedEvent is the RecordSaved of a tag a post write created by name
// (journal-tag is a content type, D-102): Created, with its name and slug.
func tagSavedEvent(ctx context.Context, tag *models.Tag) pact.RecordSaved {
locale, _ := towel.Locale(ctx)
return pact.RecordSaved{
Model: tag.MorphName(),
ID: strconv.FormatUint(uint64(tag.ID), 10),
Locale: locale,
Created: true,
Changes: []pact.RecordChange{
{Attribute: "name", New: tag.Name},
{Attribute: "slug", New: tag.Slug},
},
}
}
// submittedTranslations lists, per locale, the translated fields a request
// body submits with a value, in translatedEventFields order. content_html is
// listed whenever a non-blank content is, as the write derives it from the
// content (classes.FormatTranslation): the snapshot before the write and the
// event after it then cover the same fields, whether or not the write has
// already added content_html to the body.
func submittedTranslations(body map[string]any) map[string][]string {
out := map[string][]string{}
m, ok := body["translations"].(map[string]any)
if !ok {
return out
}
for locale, raw := range m {
attrs, ok := raw.(map[string]any)
if !ok {
continue
}
for _, field := range translatedEventFields {
v, has := attrs[field]
if (has && v != nil) || (field == "content_html" && derivesContentHTML(attrs)) {
out[locale] = append(out[locale], field)
}
}
}
return out
}
// derivesContentHTML reports whether a translation's attributes carry a
// content the write turns into content_html (a non-blank value).
func derivesContentHTML(attrs map[string]any) bool {
content, has := attrs["content"]
return has && content != nil && strings.TrimSpace(fmt.Sprint(content)) != ""
}
// derefValue is a pointer column's value, nil for a nil pointer.
func derefValue[T any](p *T) any {
if p == nil {
return nil
}
return *p
}
// sameEventValue compares two column values; times compare as instants.
func sameEventValue(a, b any) bool {
ta, okA := a.(time.Time)
tb, okB := b.(time.Time)
if okA && okB {
return ta.Equal(tb)
}
return reflect.DeepEqual(a, b)
}

8
go.mod
View File

@@ -5,7 +5,7 @@ go 1.27.0
require ( require (
git.golem15.com/golem15/sm-translate-plugin v0.1.0 git.golem15.com/golem15/sm-translate-plugin v0.1.0
git.golem15.com/golem15/sm-user-plugin v0.1.0 git.golem15.com/golem15/sm-user-plugin v0.1.0
git.golem15.com/golem15/summercms v0.5.0 git.golem15.com/golem15/summercms v0.6.0
github.com/go-gormigrate/gormigrate/v2 v2.1.7 github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/goccy/go-yaml v1.19.2 github.com/goccy/go-yaml v1.19.2
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
@@ -21,7 +21,6 @@ require (
require ( require (
charm.land/bubbles/v2 v2.2.1 // indirect charm.land/bubbles/v2 v2.2.1 // indirect
charm.land/bubbletea/v2 v2.1.0 // indirect charm.land/bubbletea/v2 v2.1.0 // indirect
charm.land/fang/v2 v2.0.1 // indirect
charm.land/huh/v2 v2.0.3 // indirect charm.land/huh/v2 v2.0.3 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect charm.land/lipgloss/v2 v2.0.6 // indirect
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
@@ -55,7 +54,6 @@ require (
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/x/ansi v0.11.9 // indirect github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -124,10 +122,6 @@ require (
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect

12
go.sum
View File

@@ -4,8 +4,6 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
@@ -114,8 +112,6 @@ github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
@@ -303,14 +299,6 @@ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtw
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=

View File

@@ -293,8 +293,16 @@ func TestJournalAuthorPosts(t *testing.T) {
t.Fatalf("author row keys = %v, want /posts row keys %v", got, want) t.Fatalf("author row keys = %v, want /posts row keys %v", got, want)
} }
author, _ := page.Data[0]["author"].(map[string]any) author, _ := page.Data[0]["author"].(map[string]any)
if author == nil || author["login"] != "jdoe" { if author == nil || author["login"] != "jdoe" || author["slug"] != "jane-doe" {
t.Fatalf("row author = %v, want login jdoe", page.Data[0]["author"]) t.Fatalf("row author = %v, want login jdoe and slug jane-doe", page.Data[0]["author"])
}
// Without an author slug, the row's slug is the login.
for _, row := range all.Data {
if row["slug"] == "b1" {
if a, _ := row["author"].(map[string]any); a == nil || a["slug"] != "bob" {
t.Fatalf("b1 author = %v, want slug bob", row["author"])
}
}
} }
page = getAuthorPosts(t, env, base+"?per_page=2&page=2", "") page = getAuthorPosts(t, env, base+"?per_page=2&page=2", "")

View File

@@ -137,6 +137,7 @@ menuitem:
journal_post: Journal post journal_post: Journal post
all_journal_posts: All journal posts all_journal_posts: All journal posts
category_journal_posts: Journal category posts category_journal_posts: Journal category posts
journal_tag: Journal tag
scopes: scopes:
journal: Journal journal: Journal
journal_description: Create, edit, publish and delete posts as you, within your Journal permissions. journal_description: Create, edit, publish and delete posts as you, within your Journal permissions.

View File

@@ -89,6 +89,7 @@ menuitem:
journal_post: Wpis (Journal) journal_post: Wpis (Journal)
all_journal_posts: Wszystkie wpisy (Journal) all_journal_posts: Wszystkie wpisy (Journal)
category_journal_posts: Wpisy kategorii (Journal) category_journal_posts: Wpisy kategorii (Journal)
journal_tag: Tag bloga
scopes: scopes:
journal: Journal journal: Journal
journal_description: Twórz, edytuj, publikuj i usuwaj posty jako Ty, w ramach swoich uprawnień Journal. journal_description: Twórz, edytuj, publikuj i usuwaj posty jako Ty, w ramach swoich uprawnień Journal.

13
manifest_test.go Normal file
View File

@@ -0,0 +1,13 @@
package journal
import (
"testing"
"git.golem15.com/golem15/summercms/modules/kiosk"
)
// TestPluginManifest fails when plugin.yaml is missing or no longer matches
// what `summer plugin:manifest` derives from this repository.
func TestPluginManifest(t *testing.T) {
kiosk.CheckT(t, ".")
}

View File

@@ -247,3 +247,89 @@ func resolve(t *testing.T, reg *signpost.Registry, key, ref, pattern string, nes
} }
return got return got
} }
// TestJournalTagTypePages (D-102, P-11): journal-tag with tag_pages on
// (the default and set explicitly) and off: references, Resolve, Describe
// and Match by slug, by id pattern and under the Polish prefix.
func TestJournalTagTypePages(t *testing.T) {
requirePostgres(t)
ctx := context.Background()
for _, tc := range []struct {
name string
files map[string]string
on bool
}{
{"default", map[string]string{}, true},
{"explicitly on", map[string]string{"golem15.yaml": "journal:\n tag_pages: true\n"}, true},
{"off", map[string]string{"golem15.yaml": "journal:\n tag_pages: false\n"}, false},
} {
t.Run(tc.name, func(t *testing.T) {
_, gdb, reg := bootMenuItemTypes(t, tc.files)
s := seedContent(t, gdb)
refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypeTag)
if err != nil || len(refs.Options) != 1 || refs.Options[0].Value != id(s.tag.ID) {
t.Fatalf("references = %+v, %v (listed whether or not the site has tag pages)", refs, err)
}
item, err := reg.Resolve(ctx, classes.MenuTypeTag, id(s.tag.ID), "", false, "")
if err != nil || (item != nil) != tc.on || (tc.on && (item.URL != "/blog/tag/mushrooms" || altString(item.Alternates) != "en=/blog/tag/mushrooms pl=/pl/blog/tag/mushrooms")) {
t.Fatalf("Resolve = %+v, %v", item, err)
}
rec, err := reg.Describe(ctx, classes.MenuTypeTag, id(s.tag.ID), "")
want := "en= pl="
if tc.on {
want = "en=/blog/tag/mushrooms pl=/pl/blog/tag/mushrooms"
}
if err != nil || rec == nil || localeURLs(rec) != want || rec.Locales[0].Title != "Mushrooms" {
t.Fatalf("Describe = %+v, %v; want %s", rec, err, want)
}
typ, _ := reg.ContentType(classes.MenuTypeTag)
for _, m := range []pact.ContentMatch{
{Path: "/blog/tag/mushrooms", Pattern: classes.DefaultTagPattern},
{Path: "/pl/blog/tag/mushrooms", Pattern: classes.DefaultTagPattern},
{Path: "/t/" + id(s.tag.ID), Pattern: "/t/:id"},
} {
res, err := typ.Content.Match(ctx, m)
if err != nil || (res != nil) != tc.on || (tc.on && (!res.Exact || res.ID != id(s.tag.ID))) {
t.Fatalf("Match(%s) = %+v, %v", m.Path, res, err)
}
}
if res, err := typ.Content.Match(ctx, pact.ContentMatch{Path: "/t/abc", Pattern: "/t/:id"}); err != nil || res != nil {
t.Fatalf("a malformed id matched: %+v %v", res, err)
}
})
}
}
// TestTranslatedSlugMenuItems (D-58): a post and a category resolve and
// match through their translated slugs in each locale, and the default
// slug stays the English address.
func TestTranslatedSlugMenuItems(t *testing.T) {
requirePostgres(t)
ctx := context.Background()
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
s := seedContent(t, gdb)
post := resolve(t, reg, classes.MenuTypePost, id(s.hello.ID), "", false)
if altString(post.Alternates) != "en=/blog/hello pl=/pl/blog/witaj" {
t.Fatalf("post alternates = %s", altString(post.Alternates))
}
cat := resolve(t, reg, classes.MenuTypeCategory, id(s.news.ID), "", false)
if altString(cat.Alternates) != "en=/blog/category/news pl=/pl/blog/category/wiadomosci" {
t.Fatalf("category alternates = %s", altString(cat.Alternates))
}
unprefixed := resolve(t, reg, classes.MenuTypePost, id(s.hello.ID), "", false, "pl")
if altString(unprefixed.Alternates) != "en=/en/blog/hello pl=/blog/witaj" {
t.Fatalf("unprefixed pl alternates = %s", altString(unprefixed.Alternates))
}
if key, res, err := reg.MatchPath(ctx, "/pl/blog/witaj", ""); err != nil || key != classes.MenuTypePost || res == nil || !res.Exact || res.ID != id(s.hello.ID) {
t.Fatalf("the translated slug = %q %+v %v", key, res, err)
}
if err := translate.SetTranslated(ctx, gdb, &s.hello, "slug", "pl", "witamy"); err != nil {
t.Fatal(err)
}
if key, res, err := reg.MatchPath(ctx, "/pl/blog/witamy", ""); err != nil || key != classes.MenuTypePost || res == nil || !res.Exact {
t.Fatalf("a changed translated slug = %q %+v %v", key, res, err)
}
if _, res, err := reg.MatchPath(ctx, "/pl/blog/witaj", ""); err != nil || res != nil {
t.Fatalf("the old translated slug still matches: %+v %v", res, err)
}
}

View File

@@ -2,14 +2,19 @@ package models
import ( import (
"context" "context"
"html"
"strconv" "strconv"
"strings"
"time" "time"
"git.golem15.com/golem15/summercms/modules/beachcomber" "git.golem15.com/golem15/summercms/modules/beachcomber"
"gorm.io/gorm" "gorm.io/gorm"
) )
var _ beachcomber.Searchable = (*Post)(nil) var (
_ beachcomber.Searchable = (*Post)(nil)
_ beachcomber.IndexSchemaProvider = (*Post)(nil)
)
// SearchableAs is the Typesense collection name (D-12). // SearchableAs is the Typesense collection name (D-12).
func (Post) SearchableAs() string { return "golem15_journal_posts" } func (Post) SearchableAs() string { return "golem15_journal_posts" }
@@ -75,6 +80,54 @@ func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]a
"tag_ids": tagIDs, "tag_ids": tagIDs,
"title": p.Title, "title": p.Title,
"excerpt": p.Excerpt, "excerpt": p.Excerpt,
"content": p.Content, "content": PlainText(p.searchContent()),
}, nil }, nil
} }
// searchContent is the post body to index: the rendered content_html, or
// content when nothing has been rendered.
func (p *Post) searchContent() string {
if strings.TrimSpace(p.ContentHTML) != "" {
return p.ContentHTML
}
return p.Content
}
// SearchIndexSchema is the Typesense collection schema. It applies only when
// the collection is created; journal:reindex --fresh recreates it.
func (Post) SearchIndexSchema() map[string]any {
return map[string]any{
"fields": []map[string]any{
{"name": "id", "type": "string"},
{"name": "title", "type": "string"},
{"name": "excerpt", "type": "string"},
{"name": "content", "type": "string"},
{"name": "published", "type": "int64"},
{"name": "published_at", "type": "int64"},
{"name": "is_pinned", "type": "int64"},
{"name": "user_id", "type": "int64"},
{"name": "created_at", "type": "int64"},
{"name": "category_ids", "type": "int64[]", "facet": false},
{"name": "tag_ids", "type": "int64[]", "facet": false},
},
}
}
// PlainText drops HTML tags and unescapes entities, with whitespace runs
// collapsed to one space. A tag counts as a word break.
func PlainText(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
b.WriteRune(' ')
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(html.UnescapeString(b.String())), " ")
}

View File

@@ -0,0 +1,59 @@
package models
import (
"sort"
"strings"
"testing"
"time"
)
func TestSearchDocumentIsPlainText(t *testing.T) {
past := time.Now().Add(-time.Hour)
p := &Post{
ID: 7,
Title: "Kurki",
Excerpt: "Zajawka",
Content: "## Kurki",
ContentHTML: `<h2 class="x">Kurki</h2><p>rosną&nbsp;w <a href="/las">lesie</a> &amp; pachną</p>`,
Published: true,
PublishedAt: &past,
}
doc, err := p.ToSearchableArray(t.Context(), nil)
if err != nil {
t.Fatal(err)
}
content, _ := doc["content"].(string)
if strings.ContainsAny(content, "<>") || strings.Contains(content, "href") || strings.Contains(content, "&amp;") {
t.Fatalf("content keeps markup: %q", content)
}
if content != "Kurki rosną w lesie & pachną" {
t.Fatalf("content = %q", content)
}
p.ContentHTML = ""
doc, _ = p.ToSearchableArray(t.Context(), nil)
if doc["content"] != "## Kurki" {
t.Fatalf("content without content_html = %q", doc["content"])
}
}
// TestSearchSchemaMatchesDocument: the schema declares exactly the keys
// ToSearchableArray emits.
func TestSearchSchemaMatchesDocument(t *testing.T) {
doc, err := (&Post{ID: 1}).ToSearchableArray(t.Context(), nil)
if err != nil {
t.Fatal(err)
}
var docKeys, schemaKeys []string
for k := range doc {
docKeys = append(docKeys, k)
}
for _, f := range (Post{}).SearchIndexSchema()["fields"].([]map[string]any) {
schemaKeys = append(schemaKeys, f["name"].(string))
}
sort.Strings(docKeys)
sort.Strings(schemaKeys)
if strings.Join(docKeys, ",") != strings.Join(schemaKeys, ",") {
t.Fatalf("document keys %v, schema fields %v", docKeys, schemaKeys)
}
}

View File

@@ -114,6 +114,7 @@ func (p *Plugin) Commands() []bonfire.Command {
return []bonfire.Command{ return []bonfire.Command{
console.ExportPosts(p.app), console.ExportPosts(p.app),
console.ImportPosts(p.app), console.ImportPosts(p.app),
console.Reindex(p.app),
} }
} }

12
plugin.yaml Normal file
View File

@@ -0,0 +1,12 @@
# Generated by summer plugin:manifest. Do not edit; rerun the command.
schema: 1
id: golem15.journal
module: git.golem15.com/golem15/sm-journal-plugin
package: git.golem15.com/golem15/sm-journal-plugin
name: Journal
description: "The compiled journal plugin for SummerCMS applications: translatable posts and categories, tags, a settings singleton, and the tables a host migrate creates for a blog."
framework: v0.6.0
requires:
golem15.translate: v0.1.0
overrides:
name: Journal

315
record_events_test.go Normal file
View File

@@ -0,0 +1,315 @@
package journal_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
)
// savedEvent is one RecordSaved a test listener heard, with the URL the
// subscriber read back through signpost.
type savedEvent struct {
ev pact.RecordSaved
key string
url string
err error
}
// recordListener collects the record events of an application.
type recordListener struct {
mu sync.Mutex
saved []savedEvent
deleted []pact.RecordDeleted
}
func (l *recordListener) savedEvents() []savedEvent {
l.mu.Lock()
defer l.mu.Unlock()
return append([]savedEvent(nil), l.saved...)
}
func (l *recordListener) deletedEvents() []pact.RecordDeleted {
l.mu.Lock()
defer l.mu.Unlock()
return append([]pact.RecordDeleted(nil), l.deleted...)
}
func (l *recordListener) reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.saved = nil
l.deleted = nil
}
// listenRecords registers a subscriber the way a shared plugin would: on
// RecordSaved it maps the event's model to its content type and describes
// the record, reading the URL of the first locale.
func listenRecords(t *testing.T, env *adminEnv) *recordListener {
t.Helper()
reg, ok := env.app.Lookup[*signpost.Registry]()
if !ok || reg == nil {
t.Fatal("signpost registry is not published")
}
l := &recordListener{}
env.app.Events.Listen("test.records", func(ctx context.Context, ev *pact.RecordSaved) error {
got := savedEvent{ev: *ev}
if typ, ok := reg.ContentTypeForModel(ev.Model); ok {
got.key = typ.Key
rec, err := reg.Describe(ctx, typ.Key, ev.ID, "")
got.err = err
if rec != nil && len(rec.Locales) > 0 {
got.url = rec.Locales[0].URL
}
}
l.mu.Lock()
l.saved = append(l.saved, got)
l.mu.Unlock()
return nil
})
env.app.Events.Listen("test.records", func(ctx context.Context, ev *pact.RecordDeleted) error {
l.mu.Lock()
l.deleted = append(l.deleted, *ev)
l.mu.Unlock()
return nil
})
return l
}
// findChange returns the change of attribute in locale.
func findChange(changes []pact.RecordChange, attribute, locale string) (pact.RecordChange, bool) {
for _, c := range changes {
if c.Attribute == attribute && c.Locale == locale {
return c, true
}
}
return pact.RecordChange{}, false
}
// TestRecordSavedDescribesNewURL is the D-92/D-93 tracer: an admin slug
// change on a Journal post reaches a subscriber after commit, and the
// subscriber reads the new URL through the post's content descriptor. A save
// that fails validation fires nothing.
func TestRecordSavedDescribesNewURL(t *testing.T) {
env := newPostsEnv(t)
token := env.admin(t,
controllers.PermissionAccessPosts,
controllers.PermissionAccessPublish,
controllers.PermissionAccessOtherPosts,
)
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
"title": map[string]any{"en": "Tracer", "pl": "Tropiciel"},
"slug": map[string]any{"en": "tracer-old", "pl": "tropiciel-stary"},
"content": map[string]any{"en": "<p>Body</p>"},
"excerpt": map[string]any{"en": "Excerpt"},
})
id := adminCreatedID(t, rec)
l := listenRecords(t, env)
path := adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, id))
env.expect(t, http.StatusOK, http.MethodPut, path, token, map[string]any{
"title": map[string]any{"en": "Tracer", "pl": "Tropiciel"},
"slug": map[string]any{"en": "tracer-new", "pl": "tropiciel-nowy"},
"content": map[string]any{"en": "<p>Body</p>"},
"excerpt": map[string]any{"en": "Excerpt"},
})
got := l.savedEvents()
if len(got) != 1 {
t.Fatalf("RecordSaved events = %d, want 1: %+v", len(got), got)
}
ev := got[0]
if ev.ev.Controller != controllers.PostsControllerID {
t.Fatalf("Controller = %q, want %q", ev.ev.Controller, controllers.PostsControllerID)
}
if ev.ev.Model != (models.Post{}).MorphName() {
t.Fatalf("Model = %q, want the Post morph name", ev.ev.Model)
}
if ev.ev.ID != fmt.Sprint(id) || ev.ev.Created {
t.Fatalf("ID = %q Created = %v, want %d and false", ev.ev.ID, ev.ev.Created, id)
}
slug, ok := findChange(ev.ev.Changes, "slug", "")
if !ok || slug.Old != "tracer-old" || slug.New != "tracer-new" {
t.Fatalf("default slug change = %+v (found %v), want tracer-old -> tracer-new; changes %+v", slug, ok, ev.ev.Changes)
}
plSlug, ok := findChange(ev.ev.Changes, "slug", "pl")
if !ok || plSlug.Old != "tropiciel-stary" || plSlug.New != "tropiciel-nowy" {
t.Fatalf("pl slug change = %+v (found %v), want tropiciel-stary -> tropiciel-nowy; changes %+v", plSlug, ok, ev.ev.Changes)
}
if _, ok := findChange(ev.ev.Changes, "title", ""); ok {
t.Fatalf("unchanged title reported: %+v", ev.ev.Changes)
}
if ev.key != classes.MenuTypePost || ev.err != nil {
t.Fatalf("content type = %q err = %v, want %q", ev.key, ev.err, classes.MenuTypePost)
}
if ev.url != "/blog/tracer-new" {
t.Fatalf("described URL = %q, want /blog/tracer-new", ev.url)
}
l.reset()
bad := env.call(t, http.MethodPut, path, token, map[string]any{
"slug": map[string]any{"en": ""},
})
if bad.Code != http.StatusUnprocessableEntity {
t.Fatalf("invalid save = %d, want 422: %s", bad.Code, bad.Body.String())
}
if got := l.savedEvents(); len(got) != 0 {
t.Fatalf("failed save fired %d events: %+v", len(got), got)
}
}
// TestAPIRecordEvents (D-102): the /_journal/api/v1 write handlers fire the
// same record events as the admin, so a slug changed through MCP reaches
// subscribers: POST fires RecordSaved with Created, PUT a slug change (the
// default locale on the row, a translated locale per locale), DELETE
// RecordDeleted. Controller is "" outside the admin forms.
func TestAPIRecordEvents(t *testing.T) {
env := newPostsEnv(t)
token := env.admin(t,
controllers.PermissionAccessPosts,
controllers.PermissionAccessPublish,
controllers.PermissionAccessOtherPosts,
)
l := listenRecords(t, env)
morph := (models.Post{}).MorphName()
rec := env.expect(t, http.StatusCreated, http.MethodPost, "/_journal/api/v1/posts", token, map[string]any{
"title": "API post",
"slug": "api-old",
"content": "Body",
"translations": map[string]any{"pl": map[string]any{"slug": "api-stary"}},
})
var created struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.Data.ID == 0 {
t.Fatalf("create body %s: %v", rec.Body.String(), err)
}
id := fmt.Sprint(created.Data.ID)
got := l.savedEvents()
if len(got) != 1 {
t.Fatalf("POST events = %+v, want one RecordSaved", got)
}
ev := got[0].ev
if ev.Controller != "" || ev.Model != morph || ev.ID != id || !ev.Created {
t.Fatalf("POST event = %+v", ev)
}
if c, ok := findChange(ev.Changes, "slug", ""); !ok || c.Old != nil || c.New != "api-old" {
t.Fatalf("POST slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if c, ok := findChange(ev.Changes, "slug", "pl"); !ok || c.Old != nil || c.New != "api-stary" {
t.Fatalf("POST pl slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if got[0].url != "/blog/api-old" {
t.Fatalf("POST described URL = %q", got[0].url)
}
l.reset()
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"slug": "api-new",
"translations": map[string]any{"pl": map[string]any{"slug": "api-nowy"}},
})
got = l.savedEvents()
if len(got) != 1 {
t.Fatalf("PUT events = %+v, want one RecordSaved", got)
}
ev = got[0].ev
if ev.Controller != "" || ev.Model != morph || ev.ID != id || ev.Created {
t.Fatalf("PUT event = %+v", ev)
}
if c, ok := findChange(ev.Changes, "slug", ""); !ok || c.Old != "api-old" || c.New != "api-new" {
t.Fatalf("PUT slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if c, ok := findChange(ev.Changes, "slug", "pl"); !ok || c.Old != "api-stary" || c.New != "api-nowy" {
t.Fatalf("PUT pl slug change = %+v (found %v); changes %+v", c, ok, ev.Changes)
}
if _, ok := findChange(ev.Changes, "title", ""); ok {
t.Fatalf("unchanged title reported: %+v", ev.Changes)
}
if got[0].url != "/blog/api-new" {
t.Fatalf("PUT described URL = %q", got[0].url)
}
// A translated content derives its content_html: the event reports the
// stored content_html as Old, not nil (WR-05).
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"translations": map[string]any{"pl": map[string]any{"content": "Stara treść"}},
})
l.reset()
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"translations": map[string]any{"pl": map[string]any{"content": "Nowa treść"}},
})
got = l.savedEvents()
if len(got) != 1 {
t.Fatalf("PUT content events = %+v, want one RecordSaved", got)
}
html, ok := findChange(got[0].ev.Changes, "content_html", "pl")
if old, _ := html.Old.(string); !ok || !strings.Contains(old, "Stara") || !strings.Contains(fmt.Sprint(html.New), "Nowa") {
t.Fatalf("pl content_html change = %+v (found %v); changes %+v", html, ok, got[0].ev.Changes)
}
// A tag the write creates by name fires its own RecordSaved (WR-06).
l.reset()
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"tags": []any{"Fresh event tag"},
})
got = l.savedEvents()
tagMorph := (models.Tag{}).MorphName()
var tagEvent *savedEvent
for i := range got {
if got[i].ev.Model == tagMorph {
tagEvent = &got[i]
}
}
if len(got) != 2 || tagEvent == nil || !tagEvent.ev.Created || tagEvent.ev.ID == "" || tagEvent.key != classes.MenuTypeTag {
t.Fatalf("PUT with a new tag events = %+v, want the post and the created tag", got)
}
if c, ok := findChange(tagEvent.ev.Changes, "name", ""); !ok || c.New != "Fresh event tag" {
t.Fatalf("tag name change = %+v (found %v)", c, ok)
}
l.reset()
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"tags": []any{"Fresh event tag"},
})
if got = l.savedEvents(); len(got) != 1 {
t.Fatalf("PUT with an existing tag events = %+v, want only the post", got)
}
// A write that fails after the row is saved rolls the row back and
// fires nothing (WR-04).
l.reset()
failed := env.call(t, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"slug": "api-lost",
"tags": "not a list",
})
if failed.Code != http.StatusInternalServerError {
t.Fatalf("failing PUT = %d, want 500: %s", failed.Code, failed.Body.String())
}
if saved := l.savedEvents(); len(saved) != 0 {
t.Fatalf("failing PUT fired RecordSaved: %+v", saved)
}
var slug string
if err := env.gdb.Raw("SELECT slug FROM golem15_journal_posts WHERE id = ?", id).Scan(&slug).Error; err != nil || slug != "api-new" {
t.Fatalf("slug after failing PUT = %q (err %v), want api-new", slug, err)
}
l.reset()
env.expect(t, http.StatusOK, http.MethodDelete, "/_journal/api/v1/posts/"+id, token, nil)
if saved := l.savedEvents(); len(saved) != 0 {
t.Fatalf("DELETE fired RecordSaved: %+v", saved)
}
deleted := l.deletedEvents()
if len(deleted) != 1 || deleted[0] != (pact.RecordDeleted{Model: morph, ID: id}) {
t.Fatalf("DELETE events = %+v", deleted)
}
}

347
search_engine_test.go Normal file
View File

@@ -0,0 +1,347 @@
package journal_test
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/console"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
const searchIndex = "golem15_journal_posts"
// typesenseStub stands in for a Typesense server: one collection that GET,
// POST and DELETE /collections create and drop, imports answered with one
// success line per document, and a search that answers with hits, the ids
// in rank order.
type typesenseStub struct {
mu sync.Mutex
exists bool
hits []string
requests []string
schema map[string]any
imported []string
removed []string
}
func (s *typesenseStub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
s.requests = append(s.requests, r.Method+" "+r.URL.Path)
coll := "/collections/" + searchIndex
switch {
case r.Method == http.MethodGet && r.URL.Path == coll:
if !s.exists {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = io.WriteString(w, `{}`)
case r.Method == http.MethodPost && r.URL.Path == "/collections":
_ = json.NewDecoder(r.Body).Decode(&s.schema)
s.exists = true
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{}`)
case r.Method == http.MethodDelete && r.URL.Path == coll:
if !s.exists {
w.WriteHeader(http.StatusNotFound)
return
}
s.exists = false
_, _ = io.WriteString(w, `{}`)
case r.Method == http.MethodPost && r.URL.Path == coll+"/documents/import":
sc := bufio.NewScanner(r.Body)
var answer []string
for sc.Scan() {
var doc map[string]any
if err := json.Unmarshal(sc.Bytes(), &doc); err == nil {
s.imported = append(s.imported, fmt.Sprint(doc["id"]))
}
answer = append(answer, `{"success":true}`)
}
_, _ = io.WriteString(w, strings.Join(answer, "\n"))
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, coll+"/documents/"):
s.removed = append(s.removed, strings.TrimPrefix(r.URL.Path, coll+"/documents/"))
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodGet && r.URL.Path == coll+"/documents/search":
hits := make([]string, len(s.hits))
for i, id := range s.hits {
hits[i] = `{"document":{"id":"` + id + `"}}`
}
_, _ = fmt.Fprintf(w, `{"found":%d,"hits":[%s]}`, len(s.hits), strings.Join(hits, ","))
default:
w.WriteHeader(http.StatusNotFound)
}
}
func (s *typesenseStub) sent() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.requests...)
}
func newSearchEnv(t *testing.T) (*adminEnv, *typesenseStub) {
t.Helper()
stub := &typesenseStub{}
srv := httptest.NewServer(stub)
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
env := newPostsEnv(t,
"SUMMER_SEARCH__DRIVER=typesense",
"SUMMER_SEARCH__TYPESENSE__API_KEY=test-only-typesense-key",
"SUMMER_SEARCH__TYPESENSE__HOST="+u.Hostname(),
"SUMMER_SEARCH__TYPESENSE__PORT="+u.Port(),
"SUMMER_SEARCH__TYPESENSE__PROTOCOL="+u.Scheme,
)
t.Cleanup(func() { models.SetSearchGate(false) })
return env, stub
}
func setSearchGate(t *testing.T, env *adminEnv, on bool) {
t.Helper()
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET search_use_typesense = ? WHERE id = 1`, on).Error; err != nil {
t.Fatal(err)
}
}
// seedSearchPosts creates n published posts titled "Grzyb N" and one draft,
// with the gate off so the writes send nothing, and returns their ids.
func seedSearchPosts(t *testing.T, env *adminEnv, n int) (published []uint, draft uint) {
t.Helper()
past := time.Now().UTC().Add(-time.Hour)
for i := 1; i <= n; i++ {
at := past.Add(-time.Duration(i) * time.Minute)
p := models.Post{
Title: fmt.Sprintf("Grzyb %02d", i),
Slug: fmt.Sprintf("grzyb-%02d", i),
Content: "o grzybach",
Published: true,
PublishedAt: &at,
}
if err := env.gdb.Create(&p).Error; err != nil {
t.Fatal(err)
}
published = append(published, p.ID)
}
d := models.Post{Title: "Grzyb szkic", Slug: "grzyb-szkic", Content: "o grzybach", Published: false}
if err := env.gdb.Create(&d).Error; err != nil {
t.Fatal(err)
}
return published, d.ID
}
type listBody struct {
Data []map[string]any `json:"data"`
Meta struct {
CurrentPage int `json:"current_page"`
LastPage int `json:"last_page"`
Total int64 `json:"total"`
} `json:"meta"`
}
func getList(t *testing.T, env *adminEnv, path string) listBody {
t.Helper()
rec := env.call(t, http.MethodGet, path, "", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d: %s", path, rec.Code, rec.Body.String())
}
var body listBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("GET %s body: %v", path, err)
}
return body
}
func slugsOf(rows []map[string]any) []string {
out := make([]string, len(rows))
for i, row := range rows {
out[i], _ = row["slug"].(string)
}
return out
}
// TestEngineSearchPages: the engine ranks 25 published posts and a draft;
// the list re-gates them in SQL, keeps the engine's order across pages and
// counts the gated rows only.
func TestEngineSearchPages(t *testing.T) {
env, stub := newSearchEnv(t)
published, draft := seedSearchPosts(t, env, 25)
// Rank: oldest first (the reverse of the date order), the draft in the
// middle.
var rank []string
for i := len(published) - 1; i >= 0; i-- {
rank = append(rank, strconv.FormatUint(uint64(published[i]), 10))
if i == 12 {
rank = append(rank, strconv.FormatUint(uint64(draft), 10))
}
}
stub.hits = rank
setSearchGate(t, env, true)
want := make([]string, 0, 25)
for i := 25; i >= 1; i-- {
want = append(want, fmt.Sprintf("grzyb-%02d", i))
}
for page, slice := range map[int][]string{1: want[:10], 2: want[10:20], 3: want[20:]} {
body := getList(t, env, fmt.Sprintf("/_journal/api/v1/posts?search=grzyb&per_page=10&page=%d", page))
if body.Meta.Total != 25 {
t.Fatalf("page %d total = %d, want 25 (draft re-gated out)", page, body.Meta.Total)
}
if got := slugsOf(body.Data); strings.Join(got, ",") != strings.Join(slice, ",") {
t.Fatalf("page %d = %v, want %v", page, got, slice)
}
}
for _, req := range stub.sent() {
if strings.HasSuffix(req, "/documents/search") {
continue
}
t.Fatalf("unexpected Typesense request %q", req)
}
// An explicit sort overrides relevance.
body := getList(t, env, "/_journal/api/v1/posts?search=grzyb&per_page=3&sort=title+asc")
if got := strings.Join(slugsOf(body.Data), ","); got != "grzyb-01,grzyb-02,grzyb-03" {
t.Fatalf("sort=title asc = %s", got)
}
}
// TestEngineSearchGateOff: with Typesense configured but the gate off, the
// list searches in SQL and sends nothing.
func TestEngineSearchGateOff(t *testing.T) {
env, stub := newSearchEnv(t)
seedSearchPosts(t, env, 3)
stub.hits = []string{"999"}
body := getList(t, env, "/_journal/api/v1/posts?search=grzyb")
if body.Meta.Total != 3 || len(body.Data) != 3 {
t.Fatalf("total=%d rows=%d, want the 3 SQL matches", body.Meta.Total, len(body.Data))
}
if got := strings.Join(slugsOf(body.Data), ","); got != "grzyb-01,grzyb-02,grzyb-03" {
t.Fatalf("SQL order = %s, want the date order", got)
}
if n := len(stub.sent()); n != 0 {
t.Fatalf("Typesense requests = %v, want none", stub.sent())
}
}
// TestSuggestRows: ?fields=suggest serves only the suggestion keys, with a
// plain-text excerpt cut on a word boundary or taken from the content.
func TestSuggestRows(t *testing.T) {
env := newPostsEnv(t)
past := time.Now().UTC().Add(-time.Hour)
long := strings.Repeat("borowik szlachetny ", 20)
for _, p := range []models.Post{
{Title: "Z zajawką", Slug: "z-zajawka", Excerpt: "<p>Krótka <b>zajawka</b></p>", Content: "treść", Published: true, PublishedAt: &past},
{Title: "Długa", Slug: "dluga", Excerpt: long, Content: "treść", Published: true, PublishedAt: &past},
{Title: "Bez zajawki", Slug: "bez-zajawki", Content: "x", ContentHTML: "<h2>Kurki</h2><p>rosną &amp; pachną</p>", Published: true, PublishedAt: &past},
} {
if err := env.gdb.Create(&p).Error; err != nil {
t.Fatal(err)
}
}
body := getList(t, env, "/_journal/api/v1/posts?fields=suggest&per_page=5&sort=title+asc")
if len(body.Data) != 3 || body.Meta.Total != 3 {
t.Fatalf("rows=%d total=%d", len(body.Data), body.Meta.Total)
}
wantKeys := "excerpt,featured_image_url,id,published_at,slug,title"
excerpts := map[string]string{}
for _, row := range body.Data {
keys := make([]string, 0, len(row))
for k := range row {
keys = append(keys, k)
}
sort.Strings(keys)
if got := strings.Join(keys, ","); got != wantKeys {
t.Fatalf("suggest keys = %s, want %s", got, wantKeys)
}
excerpts[row["slug"].(string)] = row["excerpt"].(string)
}
if got := excerpts["z-zajawka"]; got != "Krótka zajawka" {
t.Fatalf("excerpt = %q", got)
}
if got := excerpts["bez-zajawki"]; got != "Kurki rosną & pachną" {
t.Fatalf("content fallback = %q", got)
}
got := excerpts["dluga"]
cut, ok := strings.CutSuffix(got, "…")
if !ok || len([]rune(cut)) > 160 || !strings.HasPrefix(long, cut+" ") {
t.Fatalf("long excerpt = %q, want under 160 runes cut on a word boundary", got)
}
// Without fields=suggest the rows are the full ones.
full := getList(t, env, "/_journal/api/v1/posts?per_page=1")
if _, ok := full.Data[0]["content"]; !ok {
t.Fatal("full rows lost content")
}
}
// TestReindexFresh: --fresh drops the collection, recreates it from the
// schema, upserts the published posts and removes the draft's document.
func TestReindexFresh(t *testing.T) {
env, stub := newSearchEnv(t)
published, draft := seedSearchPosts(t, env, 3)
stub.exists = true
setSearchGate(t, env, true)
var buf strings.Builder
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
res, err := console.ReindexPosts(t.Context(), env.app, true, out)
if err != nil {
t.Fatalf("reindex: %v (%s)", err, buf.String())
}
if res.Synced != 3 || res.Removed != 1 || res.Failed != 0 {
t.Fatalf("result = %+v, want 3 synced, 1 removed", res)
}
sent := stub.sent()
if len(sent) < 3 || sent[0] != "DELETE /collections/"+searchIndex ||
sent[1] != "GET /collections/"+searchIndex || sent[2] != "POST /collections" {
t.Fatalf("requests = %v, want DELETE, GET, POST /collections first", sent)
}
fields, _ := stub.schema["fields"].([]any)
if stub.schema["name"] != searchIndex || len(fields) != len(models.Post{}.SearchIndexSchema()["fields"].([]map[string]any)) {
t.Fatalf("created schema = %v", stub.schema)
}
if len(stub.imported) != 3 {
t.Fatalf("imported = %v, want the 3 published posts", stub.imported)
}
for i, id := range published {
if stub.imported[i] != strconv.FormatUint(uint64(id), 10) {
t.Fatalf("imported = %v, want %v", stub.imported, published)
}
}
if len(stub.removed) != 1 || stub.removed[0] != strconv.FormatUint(uint64(draft), 10) {
t.Fatalf("removed = %v, want the draft %d", stub.removed, draft)
}
}
// TestReindexRefuses: with the gate off, or no engine configured, the
// command errors and sends nothing.
func TestReindexRefuses(t *testing.T) {
env, stub := newSearchEnv(t)
seedSearchPosts(t, env, 1)
if _, err := console.ReindexPosts(t.Context(), env.app, true, nil); err == nil || !strings.Contains(err.Error(), "search_use_typesense") {
t.Fatalf("gate off: err = %v", err)
}
if n := len(stub.sent()); n != 0 {
t.Fatalf("gate off sent %v", stub.sent())
}
plain := newPostsEnv(t)
setSearchGate(t, plain, true)
if _, err := console.ReindexPosts(t.Context(), plain.app, false, nil); err == nil || !strings.Contains(err.Error(), "not configured") {
t.Fatalf("null driver: err = %v", err)
}
}

65
translated_slug_test.go Normal file
View File

@@ -0,0 +1,65 @@
package journal_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.golem15.com/golem15/sm-journal-plugin/models"
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
)
// TestTranslatedSlug (D-58): GET /_journal/api/v1/posts/{slug} resolves a
// post by its slug in the request locale, still by the default slug and by
// id, and answers 404 for an unknown slug.
func TestTranslatedSlug(t *testing.T) {
env := newPostsEnv(t)
ctx := context.Background()
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
post := models.Post{Title: "Hello", Slug: "hello-d58", Content: "x", Published: true, PublishedAt: &past}
if err := env.gdb.Create(&post).Error; err != nil {
t.Fatal(err)
}
if err := translate.SetTranslated(ctx, env.gdb, &post, "slug", "pl", "witaj-d58"); err != nil {
t.Fatal(err)
}
get := func(slug, lang string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts/"+slug, nil)
req.Host = "example.com"
req.RemoteAddr = "198.51.100.7:4000"
req.Header.Set("Accept-Language", lang)
rec := httptest.NewRecorder()
env.h.ServeHTTP(rec, req)
return rec
}
postID := func(rec *httptest.ResponseRecorder) uint {
t.Helper()
var body struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body %s: %v", rec.Body.String(), err)
}
return body.Data.ID
}
for _, tc := range []struct{ slug, lang string }{
{"witaj-d58", "pl"},
{"hello-d58", "pl"},
{"hello-d58", "en"},
{id(post.ID), "en"},
} {
rec := get(tc.slug, tc.lang)
if rec.Code != http.StatusOK || postID(rec) != post.ID {
t.Fatalf("GET %s (%s) = %d %s", tc.slug, tc.lang, rec.Code, rec.Body.String())
}
}
if rec := get("nie-ma-takiego", "pl"); rec.Code != http.StatusNotFound {
t.Fatalf("unknown slug = %d %s", rec.Code, rec.Body.String())
}
}