Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b38a3b20d | ||
| a6fc7cd954 | |||
| 7a03640b88 | |||
| 62c75b1fc2 | |||
| f5a0394d69 | |||
| fea88f8ad7 | |||
| c5b71472ac | |||
| a598a7c5e7 | |||
| e17cebd761 | |||
| 4b3a8bb049 | |||
| 08ab15338c | |||
| 7fc07d8822 | |||
| 521df9a29d | |||
| 2aeeccc15f | |||
|
|
b1299021cd | ||
|
|
a9a9b8098d | ||
|
|
4ab94da66c |
53
README.md
53
README.md
@@ -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.
|
- **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).
|
||||||
- **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`.
|
||||||
- **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON.
|
- **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON.
|
||||||
@@ -159,7 +161,7 @@ Content-Type: application/json
|
|||||||
{"title":"Agent draft","content":"Written with a journal token"}
|
{"title":"Agent draft","content":"Written with a journal token"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional-auth reads (`GET /posts` and `GET /posts/{slug}`) unlock drafts the token owner may see when the Bearer token is a valid `journal` or `journal:read` token. A missing, invalid, expired, revoked or out-of-cover token on those GETs is treated as anonymous (no 401). Write routes stay Bearer-only: missing or bad credentials answer `401 {"error":"Authentication required"}`; a `journal:read` token on a write, or a token whose scopes do not cover `golem15.journal.*`, answers `403 {"error":"Insufficient permissions"}`.
|
Optional-auth reads (`GET /posts` and `GET /posts/{slug}`) unlock drafts the token owner may see when the Bearer token is a valid `journal` or `journal:read` token, or when the MCP endpoint dispatches the request in-process as a backend principal (no `Authorization` header then; `cabana.ResolveBackend` reads the dispatch marker). A missing, invalid, expired, revoked or out-of-cover token on those GETs is treated as anonymous (no 401). Write routes stay Bearer-only: missing or bad credentials answer `401 {"error":"Authentication required"}`; a `journal:read` token on a write, or a token whose scopes do not cover `golem15.journal.*`, answers `403 {"error":"Insufficient permissions"}`.
|
||||||
|
|
||||||
This reverses Phase 15 D-15 (JWT-only Journal writes): principals resolve through `cabana.ResolveBackend` with cover `golem15.journal.*`, so the admin cookie never authenticates a Journal request. Featured-image upload and delete still authorize only through `Post.CanEdit` (PHP parity — no separate `access_posts` check on those routes). Because the resolver uses the admin guard's compiled registry, the Journal API now honors code-declared role grants (`pact.Permission.Roles`, for example the developer role on `golem15.journal.access_posts`); previously a nil registry meant only row grants applied. The `journal-api` throttle bucket still keys by client IP (it runs before the handler resolves the principal).
|
This reverses Phase 15 D-15 (JWT-only Journal writes): principals resolve through `cabana.ResolveBackend` with cover `golem15.journal.*`, so the admin cookie never authenticates a Journal request. Featured-image upload and delete still authorize only through `Post.CanEdit` (PHP parity — no separate `access_posts` check on those routes). Because the resolver uses the admin guard's compiled registry, the Journal API now honors code-declared role grants (`pact.Permission.Roles`, for example the developer role on `golem15.journal.access_posts`); previously a nil registry meant only row grants applied. The `journal-api` throttle bucket still keys by client IP (it runs before the handler resolves the principal).
|
||||||
|
|
||||||
@@ -173,6 +175,22 @@ func (Post) TranslatableIndexes() []string { return []string{"slug"} }
|
|||||||
func (Post) MorphName() string { return `Golem15\Journal\Models\Post` }
|
func (Post) MorphName() string { return `Golem15\Journal\Models\Post` }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### MCP tools
|
||||||
|
|
||||||
|
With the framework's MCP endpoint mounted (`summer.yaml` `build.tags: [summercms_mcp]`, `mcp.enabled: true`), an MCP client connected through the application's OAuth server with the `backend` scope and a verified link to an administrator sees these tools when that administrator has `golem15.journal.access_posts`:
|
||||||
|
|
||||||
|
| Tool | Route | Input |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `journal_list_posts` | `GET /_journal/api/v1/posts` | query `page`, `per_page`, `search`, `category`, `tag`, `author`, `published`, `sort` |
|
||||||
|
| `journal_show_post` | `GET /_journal/api/v1/posts/{slug}` | `slug` |
|
||||||
|
| `journal_list_categories` | `GET /_journal/api/v1/categories` | none |
|
||||||
|
| `journal_list_tags` | `GET /_journal/api/v1/tags` | none |
|
||||||
|
| `journal_create_post` | `POST /_journal/api/v1/posts` | `title`, `content`, optional `slug`, `excerpt`, `published`, `published_at`, `is_pinned`, `categories` (ids), `tags` (names) |
|
||||||
|
| `journal_update_post` | `PUT /_journal/api/v1/posts/{id}` | `id` and any of the create fields; only the fields sent change |
|
||||||
|
| `journal_delete_post` | `DELETE /_journal/api/v1/posts/{id}` | `id` (destructive) |
|
||||||
|
|
||||||
|
The prompt `journal_draft_post` (arguments `topic`, required, and `tone`) asks the agent to draft a post, reuse existing categories and tags and save it with `journal_create_post` as a draft; it is marked `Writes`, so it is offered only to grants that may write. A grant without the `write` scope lists only the read tools and no prompt. Publishing still needs `golem15.journal.access_publish` and editing another author's post `golem15.journal.access_other_posts`; the route answers its usual error body, which the agent receives as the tool error. The media and featured-image uploads (multipart) are not tools.
|
||||||
|
|
||||||
### Blog Editor
|
### Blog Editor
|
||||||
|
|
||||||
`assets/src/blogeditor` is the Blog Editor admin element, the custom element `golem15-journal-blogeditor` (`BlogEditorElement`). It is a WYSIWYG HTML editor on TipTap (ProseMirror, MIT) with a Source tab that shows and edits the exact HTML string. The toolbar offers paragraphs and headings 2 to 4, bold, italic, bullet and ordered lists, links, blockquotes, tables, images, YouTube embeds, a raw HTML block, undo and redo, plus a Preview toggle. Labels follow the `ui-locale` attribute (`en`, `pl`).
|
`assets/src/blogeditor` is the Blog Editor admin element, the custom element `golem15-journal-blogeditor` (`BlogEditorElement`). It is a WYSIWYG HTML editor on TipTap (ProseMirror, MIT) with a Source tab that shows and edits the exact HTML string. The toolbar offers paragraphs and headings 2 to 4, bold, italic, bullet and ordered lists, links, blockquotes, tables, images, YouTube embeds, a raw HTML block, undo and redo, plus a Preview toggle. Labels follow the `ui-locale` attribute (`en`, `pl`).
|
||||||
@@ -243,10 +261,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.
|
||||||
|
|
||||||
@@ -262,6 +281,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
|
||||||
@@ -286,11 +313,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 |
|
||||||
@@ -302,6 +335,7 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
|||||||
| `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` and `journal:import-posts`. |
|
||||||
| `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.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). |
|
| `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). |
|
||||||
| `journal.Plugin.FormFieldTypes` | Registers the `blogeditor` and `mlblogeditor` admin field types. |
|
| `journal.Plugin.FormFieldTypes` | Registers the `blogeditor` and `mlblogeditor` admin field types. |
|
||||||
| `journal.Plugin.MenuItemTypes` | Registers the five Journal menu item types (`pact.HasMenuItemTypes`). |
|
| `journal.Plugin.MenuItemTypes` | Registers the five Journal menu item types (`pact.HasMenuItemTypes`). |
|
||||||
@@ -325,8 +359,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`. |
|
||||||
@@ -365,7 +400,7 @@ Both commands write JSON files (one per post slug), matching the PHP `journal:ex
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- SummerCMS v0.4.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`.
|
||||||
@@ -382,6 +417,8 @@ Postgres integration smokes start Docker unless `-short`. Missing Docker is a fa
|
|||||||
|
|
||||||
Personal API token coverage: `TestResolveErrorMapping` (Docker-free, `controllers/api`) maps resolver errors to the PHP bodies; `TestJournalTokenTracer` and `TestJournalTokenExpansion` (root package, Postgres) mint tokens through the admin account API and exercise write, read-only, unrelated, revoked, featured-image and code-declared-role paths.
|
Personal API token coverage: `TestResolveErrorMapping` (Docker-free, `controllers/api`) maps resolver errors to the PHP bodies; `TestJournalTokenTracer` and `TestJournalTokenExpansion` (root package, Postgres) mint tokens through the admin account API and exercise write, read-only, unrelated, revoked, featured-image and code-declared-role paths.
|
||||||
|
|
||||||
|
`TestJournalMCPSmoke` (root package, Docker-free) pins the seven tools and the prompt (guard, permission, destructive delete, the prompt's arguments and text) and checks that each tool's method and path is a route the plugin registers; `TestOptionalBackendPrincipalDispatch` (`controllers/api`) checks that the list and show handlers see a dispatched backend principal and stay anonymous without one. `TestJournalPromptWrites` (root package, Docker-free) checks that `draft_post` declares `Writes` and, through a served pier endpoint, that a read-only grant's `prompts/list` omits `journal_draft_post` while a read and write grant lists it.
|
||||||
|
|
||||||
`TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule.
|
`TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule.
|
||||||
|
|
||||||
The site cache purge has unit tests of the deprecated keys in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`; the request rules are tested in the framework's cooler) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache` on the deprecated keys, `TestSiteCacheFrameworkKeysWin` with both sets) that point the plugin at a local receiver and check one authorized request after each commit.
|
The site cache purge has unit tests of the deprecated keys in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`; the request rules are tested in the framework's cooler) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache` on the deprecated keys, `TestSiteCacheFrameworkKeysWin` with both sets) that point the plugin at a local receiver and check one authorized request after each commit.
|
||||||
|
|||||||
727
classes/content_descriptors.go
Normal file
727
classes/content_descriptors.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
395
content_descriptors_test.go
Normal file
395
content_descriptors_test.go
Normal 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 & 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -63,11 +63,14 @@ func resolveError(w http.ResponseWriter, err error) {
|
|||||||
writeAuthRequired(w, err)
|
writeAuthRequired(w, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// optionalBackendPrincipal verifies a backend JWT or personal API token only
|
// optionalBackendPrincipal verifies a backend JWT or personal API token when
|
||||||
// when Authorization Bearer is present. Failure is anonymous: public GET must
|
// one is presented, and honours the in-process dispatch marker the MCP
|
||||||
// not 401. Uses cabana.ResolveBackend with cover golem15.journal.*.
|
// endpoint sets (no Authorization header then, D-17). Failure is anonymous:
|
||||||
|
// public GET must not 401 (ResolveBackend answers ErrUnauthenticated for a
|
||||||
|
// request without a bearer). Uses cabana.ResolveBackend with cover
|
||||||
|
// golem15.journal.*.
|
||||||
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request {
|
||||||
if r == nil || !hasBearer(r) {
|
if r == nil {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
out, _, err := cabana.ResolveBackend(app, r, journalCover)
|
out, _, err := cabana.ResolveBackend(app, r, journalCover)
|
||||||
|
|||||||
41
controllers/api/optional_principal_test.go
Normal file
41
controllers/api/optional_principal_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOptionalBackendPrincipalDispatch pins the D-17 fix: a request the MCP
|
||||||
|
// endpoint dispatches in-process carries no Authorization header but the
|
||||||
|
// backend dispatch marker, and the list and show handlers must see its
|
||||||
|
// principal; a request with neither stays anonymous.
|
||||||
|
func TestOptionalBackendPrincipalDispatch(t *testing.T) {
|
||||||
|
app := backpack.New(nil)
|
||||||
|
admin := &bouncer.Principal{ID: 42, Backend: true}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil)
|
||||||
|
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: admin}))
|
||||||
|
out := optionalBackendPrincipal(app, req)
|
||||||
|
if pr, ok := bouncer.User(out.Context()); !ok || pr != admin {
|
||||||
|
t.Fatalf("dispatched request principal = %+v (%v), want the marker's admin", pr, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
anon := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil)
|
||||||
|
if out := optionalBackendPrincipal(app, anon); out != anon {
|
||||||
|
t.Fatal("a request without a bearer or marker was changed")
|
||||||
|
}
|
||||||
|
if _, ok := bouncer.User(anon.Context()); ok {
|
||||||
|
t.Fatal("an anonymous request gained a principal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A marker for another guard is not the backend's.
|
||||||
|
other := httptest.NewRequest(http.MethodGet, "/_journal/api/v1/posts", nil)
|
||||||
|
other = other.WithContext(bouncer.WithDispatch(other.Context(), &bouncer.Dispatch{Guards: []string{"api_token"}, Principal: &bouncer.Principal{ID: 7}}))
|
||||||
|
if out := optionalBackendPrincipal(app, other); out != other {
|
||||||
|
t.Fatal("a marker of another guard yielded a backend principal")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -127,7 +129,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 +221,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 +278,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 +289,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 +346,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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
191
controllers/api/record_events.go
Normal file
191
controllers/api/record_events.go
Normal 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)
|
||||||
|
}
|
||||||
15
go.mod
15
go.mod
@@ -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.4.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
|
||||||
@@ -85,6 +83,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/google/wire v0.7.0 // indirect
|
github.com/google/wire v0.7.0 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||||
@@ -121,11 +120,8 @@ require (
|
|||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.1.0 // indirect
|
||||||
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/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
|
||||||
@@ -138,6 +134,8 @@ require (
|
|||||||
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
||||||
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||||
github.com/spf13/cobra v1.10.2 // indirect
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
@@ -151,6 +149,7 @@ require (
|
|||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/wneessen/go-mail v0.8.1 // indirect
|
github.com/wneessen/go-mail v0.8.1 // indirect
|
||||||
github.com/xo/terminfo v1.0.0 // indirect
|
github.com/xo/terminfo v1.0.0 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||||
@@ -163,10 +162,12 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/crypto v0.57.0 // indirect
|
golang.org/x/crypto v0.57.0 // indirect
|
||||||
golang.org/x/image v0.46.0 // indirect
|
golang.org/x/image v0.46.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/term v0.46.0 // indirect
|
golang.org/x/term v0.46.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||||
google.golang.org/api v0.272.0 // indirect
|
google.golang.org/api v0.272.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||||
|
|||||||
24
go.sum
24
go.sum
@@ -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=
|
||||||
@@ -207,6 +203,8 @@ github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV
|
|||||||
github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI=
|
github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI=
|
||||||
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
|
github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk=
|
||||||
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg=
|
github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg=
|
||||||
|
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
|
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
@@ -297,16 +295,10 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
|||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||||
|
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=
|
||||||
@@ -337,6 +329,10 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
|||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||||
@@ -377,6 +373,8 @@ github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pc
|
|||||||
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
|
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
|
||||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
@@ -440,6 +438,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
|||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
13
manifest_test.go
Normal 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, ".")
|
||||||
|
}
|
||||||
188
mcp.go
Normal file
188
mcp.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package journal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ pact.HasMCP = (*Plugin)(nil)
|
||||||
|
|
||||||
|
// mcpGuard is the guard the Journal tools run as: the backend account linked
|
||||||
|
// to the MCP user (the routes resolve the backend principal themselves).
|
||||||
|
const mcpGuard = "backend"
|
||||||
|
|
||||||
|
// mcpAccessPosts is the permission every Journal tool and the prompt are
|
||||||
|
// listed with (the routes check it, and access_publish, themselves).
|
||||||
|
const mcpAccessPosts = "golem15.journal.access_posts"
|
||||||
|
|
||||||
|
// mcpListPostsInput is the query of GET /_journal/api/v1/posts.
|
||||||
|
type mcpListPostsInput struct {
|
||||||
|
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
|
||||||
|
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"posts per page"`
|
||||||
|
Search string `json:"search,omitempty" query:"search" jsonschema:"a phrase of at least three characters the title, slug, excerpt or content contains"`
|
||||||
|
Category string `json:"category,omitempty" query:"category" jsonschema:"a category slug or id"`
|
||||||
|
Tag string `json:"tag,omitempty" query:"tag" jsonschema:"a tag slug or id"`
|
||||||
|
Author string `json:"author,omitempty" query:"author" jsonschema:"the id of the administrator who wrote the posts"`
|
||||||
|
Published *bool `json:"published,omitempty" query:"published" jsonschema:"true for published posts only, false for drafts too (the default for an editor)"`
|
||||||
|
Sort string `json:"sort,omitempty" query:"sort" jsonschema:"the order: a column (published_at, created_at, updated_at, title or id) and optionally asc, for example title asc; newest published first by default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpShowPostInput names one post by slug.
|
||||||
|
type mcpShowPostInput struct {
|
||||||
|
Slug string `json:"slug" path:"slug" jsonschema:"the post slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpCreatePostInput is the body of POST /_journal/api/v1/posts.
|
||||||
|
type mcpCreatePostInput struct {
|
||||||
|
Title string `json:"title" jsonschema:"the post title"`
|
||||||
|
Content string `json:"content" jsonschema:"the post body (Markdown)"`
|
||||||
|
Slug string `json:"slug,omitempty" jsonschema:"the URL slug; derived from the title when omitted"`
|
||||||
|
Excerpt string `json:"excerpt,omitempty" jsonschema:"a short summary shown in lists"`
|
||||||
|
Published *bool `json:"published,omitempty" jsonschema:"publish at once (needs the publish permission); a draft when omitted"`
|
||||||
|
PublishedAt string `json:"published_at,omitempty" jsonschema:"the publication time (RFC 3339 or YYYY-MM-DD)"`
|
||||||
|
IsPinned *bool `json:"is_pinned,omitempty" jsonschema:"pin the post at the top of lists"`
|
||||||
|
Categories []uint `json:"categories,omitempty" jsonschema:"category ids"`
|
||||||
|
Tags []string `json:"tags,omitempty" jsonschema:"tag names; unknown tags are created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpUpdatePostInput is PUT /_journal/api/v1/posts/{id}: only the fields sent
|
||||||
|
// are changed.
|
||||||
|
type mcpUpdatePostInput struct {
|
||||||
|
ID uint `json:"id" path:"id" jsonschema:"the post id"`
|
||||||
|
Title *string `json:"title,omitempty" jsonschema:"the post title"`
|
||||||
|
Content *string `json:"content,omitempty" jsonschema:"the post body (Markdown)"`
|
||||||
|
Slug *string `json:"slug,omitempty" jsonschema:"the URL slug"`
|
||||||
|
Excerpt *string `json:"excerpt,omitempty" jsonschema:"a short summary shown in lists"`
|
||||||
|
Published *bool `json:"published,omitempty" jsonschema:"published (needs the publish permission) or a draft"`
|
||||||
|
PublishedAt *string `json:"published_at,omitempty" jsonschema:"the publication time (RFC 3339 or YYYY-MM-DD)"`
|
||||||
|
IsPinned *bool `json:"is_pinned,omitempty" jsonschema:"pin the post at the top of lists"`
|
||||||
|
Categories *[]uint `json:"categories,omitempty" jsonschema:"category ids; replaces the post's categories"`
|
||||||
|
Tags *[]string `json:"tags,omitempty" jsonschema:"tag names; replaces the post's tags, unknown tags are created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpDeletePostInput names one post by id.
|
||||||
|
type mcpDeletePostInput struct {
|
||||||
|
ID uint `json:"id" path:"id" jsonschema:"the post id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP declares the Journal's MCP tools and prompt (D-15, D-16): the post,
|
||||||
|
// category and tag routes of /_journal/api/v1, run as the backend account
|
||||||
|
// linked to the MCP user and listed only when that account has
|
||||||
|
// golem15.journal.access_posts. The routes enforce their own permissions
|
||||||
|
// (publishing, other authors' posts) on every call. Media and featured-image
|
||||||
|
// uploads (multipart) are not tools.
|
||||||
|
func (p *Plugin) MCP() pact.MCP {
|
||||||
|
destructive := true
|
||||||
|
return pact.MCP{
|
||||||
|
Tools: []pact.MCPTool{
|
||||||
|
{
|
||||||
|
Name: "list_posts",
|
||||||
|
Title: "List posts",
|
||||||
|
Description: "Lists Journal posts, drafts included, newest first, with filters for a phrase, category, tag and author.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_journal/api/v1/posts",
|
||||||
|
Input: mcpListPostsInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "show_post",
|
||||||
|
Title: "Show post",
|
||||||
|
Description: "Shows one Journal post by slug, a draft included.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_journal/api/v1/posts/{slug}",
|
||||||
|
Input: mcpShowPostInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list_categories",
|
||||||
|
Title: "List categories",
|
||||||
|
Description: "Lists the Journal categories with their ids and slugs.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_journal/api/v1/categories",
|
||||||
|
Input: struct{}{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list_tags",
|
||||||
|
Title: "List tags",
|
||||||
|
Description: "Lists the Journal tags with their ids and slugs.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_journal/api/v1/tags",
|
||||||
|
Input: struct{}{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create_post",
|
||||||
|
Title: "Create post",
|
||||||
|
Description: "Creates a Journal post; a draft unless published is true.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/_journal/api/v1/posts",
|
||||||
|
Input: mcpCreatePostInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update_post",
|
||||||
|
Title: "Update post",
|
||||||
|
Description: "Changes the fields sent of one Journal post by id.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Path: "/_journal/api/v1/posts/{id}",
|
||||||
|
Input: mcpUpdatePostInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "delete_post",
|
||||||
|
Title: "Delete post",
|
||||||
|
Description: "Deletes one Journal post by id.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
Path: "/_journal/api/v1/posts/{id}",
|
||||||
|
Input: mcpDeletePostInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
Destructive: &destructive,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Prompts: []pact.MCPPrompt{{
|
||||||
|
Name: "draft_post",
|
||||||
|
Title: "Draft a post",
|
||||||
|
Description: "Drafts a Journal post on a topic and saves it as a draft.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
// It directs journal_create_post, so a read-only grant does
|
||||||
|
// not see it.
|
||||||
|
Writes: true,
|
||||||
|
Arguments: []pact.MCPPromptArgument{
|
||||||
|
{Name: "topic", Description: "what the post is about", Required: true},
|
||||||
|
{Name: "tone", Description: "the tone of voice, for example friendly or formal"},
|
||||||
|
},
|
||||||
|
Render: renderDraftPost,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDraftPost builds the draft_post prompt's one user message.
|
||||||
|
func renderDraftPost(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
|
||||||
|
topic := strings.TrimSpace(args["topic"])
|
||||||
|
if topic == "" {
|
||||||
|
return nil, fmt.Errorf("draft_post: topic is required")
|
||||||
|
}
|
||||||
|
tone := strings.TrimSpace(args["tone"])
|
||||||
|
if tone == "" {
|
||||||
|
tone = "clear and friendly"
|
||||||
|
}
|
||||||
|
text := "Write a Journal post about: " + topic + "\n\n" +
|
||||||
|
"Tone: " + tone + ".\n" +
|
||||||
|
"Style: a short, specific title; an excerpt of one or two sentences; the body in Markdown with short paragraphs and subheadings where they help; no filler.\n" +
|
||||||
|
"Before writing, call journal_list_categories and journal_list_tags and reuse fitting categories and tags.\n" +
|
||||||
|
"Then save it with journal_create_post with published set to false, so an editor reviews the draft before it goes live, and reply with the new post's slug."
|
||||||
|
return []pact.MCPPromptMessage{{Role: "user", Text: text}}, nil
|
||||||
|
}
|
||||||
194
mcp_prompt_writes_test.go
Normal file
194
mcp_prompt_writes_test.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pier"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// servingHost is mountHost that also keeps the endpoint pier mounts, so
|
||||||
|
// the test can talk to it.
|
||||||
|
type servingHost struct {
|
||||||
|
mountHost
|
||||||
|
path string
|
||||||
|
handlers map[string]http.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *servingHost) MountStream(method, path string, handler http.Handler, _ ...string) error {
|
||||||
|
h.handlers[method] = handler
|
||||||
|
h.path = path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *servingHost) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if handler, ok := h.handlers[r.Method]; ok && r.URL.Path == h.path {
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptGrants verifies two OAuth grants of one linked user: read only and
|
||||||
|
// read+write, both with the backend scope and consented for the linked
|
||||||
|
// backend user 1 (pact.MCPGrant.BackendUserID).
|
||||||
|
type promptGrants struct{}
|
||||||
|
|
||||||
|
func (promptGrants) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) {
|
||||||
|
scopes := map[string][]string{"oas_at_reader": {"read", "backend"}, "oas_at_writer": {"read", "write", "backend"}}[raw]
|
||||||
|
if scopes == nil {
|
||||||
|
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
|
||||||
|
}
|
||||||
|
return pact.MCPGrant{UserID: 5, ClientID: "agent", TokenID: 9, Scopes: scopes, ExpiresAt: time.Now().Add(time.Hour), BackendUserID: 1}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (promptGrants) MCPResource() string { return "https://app.example.test/mcp" }
|
||||||
|
|
||||||
|
// linkedAdmin links frontend user 5 to backend user 1.
|
||||||
|
type linkedAdmin struct{}
|
||||||
|
|
||||||
|
func (linkedAdmin) LinkedBackendUser(context.Context, uint) (pact.LinkedBackendUser, bool, error) {
|
||||||
|
return pact.LinkedBackendUser{ID: 1, Login: "editor"}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowingBackend stands in for the backend guard provider (which needs the
|
||||||
|
// admin database): it allows the linked admin everything.
|
||||||
|
type allowingBackend struct{}
|
||||||
|
|
||||||
|
func (allowingBackend) MCPGuardName() string { return pier.BackendGuard }
|
||||||
|
func (allowingBackend) MCPTokenGuarded(mw []string) bool {
|
||||||
|
return slices.Contains(mw, "backend")
|
||||||
|
}
|
||||||
|
func (allowingBackend) MCPRequirements([]string) (pact.MCPRequirements, error) {
|
||||||
|
return pact.MCPRequirements{}, nil
|
||||||
|
}
|
||||||
|
func (allowingBackend) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func (allowingBackend) MCPDispatch(_ context.Context, s pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
||||||
|
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: s.UserID, Backend: true}, Guards: []string{pier.BackendGuard}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptsFor opens a stateful session with bearer and answers the prompt
|
||||||
|
// names prompts/list returns.
|
||||||
|
func promptsFor(t *testing.T, url, bearer string) []string {
|
||||||
|
t.Helper()
|
||||||
|
post := func(session, body string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||||
|
req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
|
||||||
|
if session != "" {
|
||||||
|
req.Header.Set("Mcp-Session-Id", session)
|
||||||
|
}
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode >= 300 {
|
||||||
|
raw, _ := io.ReadAll(res.Body)
|
||||||
|
t.Fatalf("POST %s = %d %s", body, res.StatusCode, raw)
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(res.Body)
|
||||||
|
scanner.Buffer(make([]byte, 1<<20), 1<<20)
|
||||||
|
for scanner.Scan() {
|
||||||
|
if line := scanner.Text(); strings.HasPrefix(line, "data: ") {
|
||||||
|
return res.Header.Get("Mcp-Session-Id"), strings.TrimPrefix(line, "data: ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.Header.Get("Mcp-Session-Id"), ""
|
||||||
|
}
|
||||||
|
session, _ := post("", `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"journal-test","version":"1"}}}`)
|
||||||
|
if session == "" {
|
||||||
|
t.Fatal("initialize opened no session")
|
||||||
|
}
|
||||||
|
post(session, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
||||||
|
_, body := post(session, `{"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}`)
|
||||||
|
var out struct {
|
||||||
|
Result struct {
|
||||||
|
Prompts []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"prompts"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||||||
|
t.Fatalf("prompts/list = %s (%v)", body, err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, p := range out.Result.Prompts {
|
||||||
|
names = append(names, p.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalPromptWrites pins plan 08's prompt ceiling for the blog plugin
|
||||||
|
// (C-IN-01): draft_post declares Writes, and through pier's catalog (the
|
||||||
|
// TestJournalMCP harness, served) a read-only grant's prompts/list omits
|
||||||
|
// journal_draft_post while a read and write grant lists it. Runs under
|
||||||
|
// -short.
|
||||||
|
func TestJournalPromptWrites(t *testing.T) {
|
||||||
|
decl := (&journal.Plugin{}).MCP()
|
||||||
|
t.Run("draft_post declares Writes", func(t *testing.T) {
|
||||||
|
i := slices.IndexFunc(decl.Prompts, func(p pact.MCPPrompt) bool { return p.Name == "draft_post" })
|
||||||
|
if i < 0 || !decl.Prompts[i].Writes {
|
||||||
|
t.Fatalf("draft_post = %+v, want Writes", decl.Prompts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prompts/list follows the read/write ceiling", func(t *testing.T) {
|
||||||
|
app, plugins := journalApp(t)
|
||||||
|
router, err := surf.BuildRouter(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Publish[pact.MCPTokenVerifier](promptGrants{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Publish[pact.MCPLinkedBackendResolver](linkedAdmin{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
guards := pact.NewMCPGuards()
|
||||||
|
if err := guards.Register(allowingBackend{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Publish(guards); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set(pier.EnabledKey, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
host := &servingHost{mountHost: mountHost{r: router}, handlers: map[string]http.Handler{}}
|
||||||
|
if err := pier.Mount(host, app, plugins); err != nil {
|
||||||
|
t.Fatalf("pier.Mount: %v", err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(host)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
endpoint := srv.URL + host.path
|
||||||
|
|
||||||
|
if got := promptsFor(t, endpoint, "oas_at_reader"); slices.Contains(got, "journal_draft_post") {
|
||||||
|
t.Fatalf("a read-only grant lists %v", got)
|
||||||
|
}
|
||||||
|
if got := promptsFor(t, endpoint, "oas_at_writer"); !slices.Contains(got, "journal_draft_post") {
|
||||||
|
t.Fatalf("a read and write grant lists %v, want journal_draft_post", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
93
mcp_smoke_test.go
Normal file
93
mcp_smoke_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJournalMCPSmoke pins the Journal's MCP declaration (P-17, D-15, D-16):
|
||||||
|
// exactly the seven post, category and tag tools and the draft_post prompt,
|
||||||
|
// each with Guard backend and golem15.journal.access_posts, delete marked
|
||||||
|
// destructive, and every tool bound to a route the plugin registers (the
|
||||||
|
// same method and pattern in a router built from the booted plugins).
|
||||||
|
// Runs under -short (no database).
|
||||||
|
func TestJournalMCPSmoke(t *testing.T) {
|
||||||
|
var _ pact.HasMCP = (*journal.Plugin)(nil)
|
||||||
|
decl := (&journal.Plugin{}).MCP()
|
||||||
|
|
||||||
|
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post"}
|
||||||
|
var names []string
|
||||||
|
for _, tool := range decl.Tools {
|
||||||
|
names = append(names, tool.Name)
|
||||||
|
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, []string{"golem15.journal.access_posts"}) {
|
||||||
|
t.Errorf("tool %s guard %q permissions %v, want backend and access_posts", tool.Name, tool.Guard, tool.Permissions)
|
||||||
|
}
|
||||||
|
if tool.Title == "" || tool.Description == "" || tool.Input == nil {
|
||||||
|
t.Errorf("tool %s lacks a title, description or input", tool.Name)
|
||||||
|
}
|
||||||
|
if (tool.Destructive != nil && *tool.Destructive) != (tool.Name == "delete_post") {
|
||||||
|
t.Errorf("tool %s destructive = %v", tool.Name, tool.Destructive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(names, wantTools) {
|
||||||
|
t.Fatalf("tools %v, want %v", names, wantTools)
|
||||||
|
}
|
||||||
|
if len(decl.Prompts) != 1 || decl.Prompts[0].Name != "draft_post" || decl.Prompts[0].Guard != "backend" ||
|
||||||
|
!slices.Equal(decl.Prompts[0].Permissions, []string{"golem15.journal.access_posts"}) {
|
||||||
|
t.Fatalf("prompts %+v, want draft_post (backend, access_posts)", decl.Prompts)
|
||||||
|
}
|
||||||
|
prompt := decl.Prompts[0]
|
||||||
|
if len(prompt.Arguments) != 2 || prompt.Arguments[0].Name != "topic" || !prompt.Arguments[0].Required || prompt.Arguments[1].Name != "tone" || prompt.Arguments[1].Required {
|
||||||
|
t.Fatalf("draft_post arguments %+v, want topic (required) and tone", prompt.Arguments)
|
||||||
|
}
|
||||||
|
msgs, err := prompt.Render(context.Background(), map[string]string{"topic": "Release notes", "tone": "formal"})
|
||||||
|
if err != nil || len(msgs) != 1 || msgs[0].Role != "user" ||
|
||||||
|
!strings.Contains(msgs[0].Text, "Release notes") || !strings.Contains(msgs[0].Text, "formal") ||
|
||||||
|
!strings.Contains(msgs[0].Text, "journal_create_post") || !strings.Contains(msgs[0].Text, "published set to false") {
|
||||||
|
t.Fatalf("draft_post render = %+v, %v", msgs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every tool's method and path is a route the plugin registers.
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-mcp\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608} {
|
||||||
|
if err := cfg.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rt, err := surf.BuildRouter(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("surf.BuildRouter: %v", err)
|
||||||
|
}
|
||||||
|
routes := map[string]string{}
|
||||||
|
for _, r := range rt.Routes() {
|
||||||
|
routes[r.Method+" "+r.Pattern] = r.PluginID
|
||||||
|
}
|
||||||
|
for _, tool := range decl.Tools {
|
||||||
|
if owner, ok := routes[tool.Method+" "+tool.Path]; !ok || owner != "golem15.journal" {
|
||||||
|
t.Errorf("tool %s targets %s %s, which golem15.journal does not register (owner %q)", tool.Name, tool.Method, tool.Path, owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
mcp_test.go
Normal file
149
mcp_test.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pier"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The JSON keys the post routes read (controllers/api/posts_helpers.go:
|
||||||
|
// body["..."] in the create and update validation, and the list query).
|
||||||
|
// A tool input field outside these would be sent and silently ignored.
|
||||||
|
var (
|
||||||
|
postBodyKeys = []string{"categories", "content", "excerpt", "is_pinned", "metadata", "published", "published_at", "slug", "sources", "tags", "title", "translations"}
|
||||||
|
postQueryKeys = []string{"author", "category", "include_empty", "page", "per_page", "pinned_first", "published", "search", "sort", "tag"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// mountHost adapts a built surf router to pier.Host for a compile-only mount.
|
||||||
|
type mountHost struct{ r *surf.Router }
|
||||||
|
|
||||||
|
func (h mountHost) Routes() []pier.RouteInfo {
|
||||||
|
var out []pier.RouteInfo
|
||||||
|
for _, ri := range h.r.Routes() {
|
||||||
|
out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func (h mountHost) Handler(method, pattern string) (http.Handler, bool) {
|
||||||
|
return h.r.Handler(method, pattern)
|
||||||
|
}
|
||||||
|
func (mountHost) MountStream(string, string, http.Handler, ...string) error { return nil }
|
||||||
|
func (mountHost) MountJSON(string, string, http.Handler) error { return nil }
|
||||||
|
func (h mountHost) Owner(method, path string) (string, bool) {
|
||||||
|
for _, ri := range h.r.Routes() {
|
||||||
|
if ri.Method == method && ri.Pattern == path {
|
||||||
|
return ri.PluginID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalApp boots the translate and journal plugins on a config without a
|
||||||
|
// database.
|
||||||
|
func journalApp(t *testing.T) (*backpack.App, []party.Plugin) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: journal-mcp\nlocale: en\nfallback_locale: en\nurl: https://app.example.test\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminJWTSecret}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608} {
|
||||||
|
if err := cfg.Set(key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
plugins, err := party.Activate(app, []string{"golem15.translate", "golem15.journal"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app, plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalMCP (D-15, D-16, SC-2) extends TestJournalMCPSmoke: the
|
||||||
|
// declaration compiles through pier's own catalog against the routes the
|
||||||
|
// plugin registers (prefix journal_, the backend guard, path and query
|
||||||
|
// tags matching the patterns); every input field is a key the route reads
|
||||||
|
// (path segment, list query key or create/update body key); the draft
|
||||||
|
// prompt renders with and without its optional tone. The dispatch marker
|
||||||
|
// on the optional-principal list and show routes is pinned by
|
||||||
|
// controllers/api TestOptionalBackendPrincipalDispatch. Runs under -short.
|
||||||
|
func TestJournalMCP(t *testing.T) {
|
||||||
|
decl := (&journal.Plugin{}).MCP()
|
||||||
|
|
||||||
|
t.Run("compiles through pier against the plugin's routes", func(t *testing.T) {
|
||||||
|
app, plugins := journalApp(t)
|
||||||
|
router, err := surf.BuildRouter(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.Config.Set(pier.EnabledKey, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pier.Mount(mountHost{r: router}, app, plugins); err != nil {
|
||||||
|
t.Fatalf("pier.Mount: %v", err)
|
||||||
|
}
|
||||||
|
p, ok := app.Lookup[*pier.Pier]()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("pier was not mounted")
|
||||||
|
}
|
||||||
|
want := []string{"journal_create_post", "journal_delete_post", "journal_list_categories", "journal_list_posts", "journal_list_tags", "journal_show_post", "journal_update_post"}
|
||||||
|
if got := p.ToolNames(); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("compiled tools %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("input fields are keys the routes read", func(t *testing.T) {
|
||||||
|
for _, tool := range decl.Tools {
|
||||||
|
rt := reflect.TypeOf(tool.Input)
|
||||||
|
for rt.Kind() == reflect.Pointer {
|
||||||
|
rt = rt.Elem()
|
||||||
|
}
|
||||||
|
for i := 0; i < rt.NumField(); i++ {
|
||||||
|
f := rt.Field(i)
|
||||||
|
key, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
||||||
|
switch {
|
||||||
|
case f.Tag.Get("path") != "":
|
||||||
|
if !strings.Contains(tool.Path, "{"+f.Tag.Get("path")+"}") {
|
||||||
|
t.Errorf("%s.%s: path tag %q not in %s", tool.Name, f.Name, f.Tag.Get("path"), tool.Path)
|
||||||
|
}
|
||||||
|
case f.Tag.Get("query") != "":
|
||||||
|
if tool.Method != http.MethodGet || !slices.Contains(postQueryKeys, f.Tag.Get("query")) {
|
||||||
|
t.Errorf("%s.%s: query key %q is not one the list reads", tool.Name, f.Name, f.Tag.Get("query"))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(postBodyKeys, key) {
|
||||||
|
t.Errorf("%s.%s: body key %q is not one %s %s reads", tool.Name, f.Name, key, tool.Method, tool.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("draft prompt with and without tone", func(t *testing.T) {
|
||||||
|
prompt := decl.Prompts[0]
|
||||||
|
with, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release", "tone": "playful"})
|
||||||
|
if err != nil || len(with) != 1 || !strings.Contains(with[0].Text, "Spring release") || !strings.Contains(with[0].Text, "playful") {
|
||||||
|
t.Fatalf("with tone = %+v, %v", with, err)
|
||||||
|
}
|
||||||
|
without, err := prompt.Render(context.Background(), map[string]string{"topic": "Spring release"})
|
||||||
|
if err != nil || len(without) != 1 || !strings.Contains(without[0].Text, "Spring release") || strings.Contains(without[0].Text, "playful") {
|
||||||
|
t.Fatalf("without tone = %+v, %v", without, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
12
plugin.yaml
Normal file
12
plugin.yaml
Normal 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
315
record_events_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
translated_slug_test.go
Normal file
65
translated_slug_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user