Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6823dcc049 | ||
|
|
b8d98cef2b | ||
|
|
cf13c606f8 | ||
|
|
a41d537e52 | ||
|
|
0a62090435 | ||
|
|
7662d54ab3 | ||
|
|
5f683cba31 | ||
|
|
0e739c882e | ||
|
|
45d66f6071 | ||
|
|
311c28af3a | ||
|
|
e7832a8d26 | ||
|
|
fc3c25530c | ||
|
|
4f5bd190e5 | ||
|
|
ee3cb263dc | ||
|
|
0edf6e1b67 | ||
|
|
4b09bd9312 | ||
|
|
391435b4bf | ||
|
|
805bfdb837 | ||
|
|
e8e77a154c | ||
|
|
af25eaa677 | ||
|
|
5210b64e0f | ||
|
|
faaa9b0f55 | ||
|
|
305f94afd9 | ||
|
|
f7bfb3313b | ||
|
|
08e3d82ecd | ||
|
|
5b38a3b20d | ||
| a6fc7cd954 | |||
| 7a03640b88 | |||
| 62c75b1fc2 | |||
| f5a0394d69 | |||
| fea88f8ad7 | |||
| c5b71472ac | |||
| a598a7c5e7 | |||
| e17cebd761 | |||
| 4b3a8bb049 | |||
| 08ab15338c |
227
README.md
227
README.md
@@ -18,16 +18,20 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **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 five rules for the Blog Editor's own output (Phase 16.2, Phase 33): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, `start` matching `^[0-9]+$` on `ol`, and `rel` on `a` holding only the tokens `nofollow`, `sponsored` and `ugc` (`^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$`; any other `rel` value is removed). 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`), `journal-tag` and `journal-author` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories, tags and author pages. Posts, categories, tags and authors 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. The Headings and links tab holds the H1 setting `content_h1_to_h2` (on by default) and the generic link texts `generic_anchors`; see [headings and links](#headings-and-links).
|
||||||
- **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.
|
- **Image variants.** After a featured image upload commits, the job `golem15.journal.image_variants` writes a thumbnail at each width of `golem15.journal.image_widths` (480, 800 and 1200 by default, never wider than the original, and never taller than 4096 px: such a width of a tall original is skipped with a warning and the job goes on) and a WebP copy of each next to it, recorded in `golem15_journal_image_variants`. The API lists them as `featured_images[].variants`; the upload request never encodes. `journal:image-variants` backfills. See [image variants](#image-variants).
|
||||||
- **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).
|
- **Headings and links.** While the H1 setting is on, every H1 saved in Journal rich text (post content and category pillar text) becomes an H2 on every write path, with a notice; `journal:fix-headings` and the MCP tool `journal_fix_headings` convert stored content on request. The Blog Editor follows the setting and adds a heading outline, link `rel` options for external links and image file naming. See [headings and links](#headings-and-links).
|
||||||
|
- **Search.** With a configured search engine and `search_use_typesense` on, published posts are kept in the Typesense collection `golem15_journal_posts` after every committed save, and `GET /posts?search=` answers in relevance order. Otherwise search is a SQL `ILIKE`. `?fields=suggest` serves short rows for a search box, and `journal:reindex` fills the index. See [search](#search).
|
||||||
|
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. `/posts/{slug}` finds the post by its default-locale slug, then by its slug in the request locale (the router's locale, from `Accept-Language` or the translate plugin's resolver), then by id, so a translated slug resolves. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal.
|
||||||
|
- **Record events.** Post writes through the admin fire the framework's record events (`pact.RecordSaved`, `pact.RecordDeleted`) after the commit, like every admin controller. The `/_journal/api/v1` post routes, which MCP tools call, fire the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`: `POST /posts` a RecordSaved with `Created`, `PUT /posts/{id}` a RecordSaved whose changes hold the post's changed columns and each submitted translation that changed (a translated slug per locale), `DELETE /posts/{id}` a RecordDeleted. A tag `POST` or `PUT` creates from a name in `tags` fires its own RecordSaved with `Created`. `POST` and `PUT` write the row, its categories, tags and translations in one transaction and fire the event after it commits, so a write that fails part-way changes nothing and fires nothing. Their `Controller` is empty, as they do not go through an admin form. Another plugin (an SEO plugin creating redirects, a search indexer) therefore hears about a slug changed over MCP without importing the Journal.
|
||||||
|
- **MCP tools.** `journal.Plugin.MCP` declares eleven 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`, `journal_get_settings`, `journal_update_settings`, `journal_fix_headings`, `journal_set_image_alt` 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` (`golem15.journal.manage_settings` for the settings and `fix_headings` tools); 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.
|
||||||
- **Schema.** Eight gormigrate steps create the six journal tables, the author-slug column and the category `content` column. AutoMigrate is not used. Table names are `golem15_journal_*` only.
|
- **Schema.** Ten gormigrate steps create the six journal tables, the author-slug column, the category `content` column, the settings columns `content_h1_to_h2` and `generic_anchors`, and `golem15_journal_image_variants`. AutoMigrate is not used. Table names are `golem15_journal_*` only.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ The admin Categories form edits content with the `blogeditor` field type: a save
|
|||||||
1. `backend_users.golem15_bloghub_author_slug`, the public author slug. A user with a slug resolves even before their first post.
|
1. `backend_users.golem15_bloghub_author_slug`, the public author slug. A user with a slug resolves even before their first post.
|
||||||
2. `backend_users.login`, but only for a user with at least one published post. That login is already public through the post `author` object, so other backend accounts cannot be enumerated.
|
2. `backend_users.login`, but only for a user with at least one published post. That login is already public through the post `author` object, so other backend accounts cannot be enumerated.
|
||||||
|
|
||||||
Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login and no slug.
|
Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login. Since v0.3.1 that object also carries `slug` (the same canonical identifier as below), and consumers link by it.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe","subtitle":"Editor","description":"Writes about releases.","avatar":"https://example.com/storage/app/uploads/public/012/345/678/0123456789abcdef01234567.webp"}}
|
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe","subtitle":"Editor","description":"Writes about releases.","avatar":"https://example.com/storage/app/uploads/public/012/345/678/0123456789abcdef01234567.webp"}}
|
||||||
@@ -126,7 +130,7 @@ An unknown author, a login without published posts, a soft-deleted user and a ma
|
|||||||
The response uses the `/posts` pagination envelope (rows trimmed here):
|
The response uses the `/posts` pagination envelope (rows trimmed here):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}}
|
{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe","slug":"jane-doe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}}
|
||||||
```
|
```
|
||||||
|
|
||||||
An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route.
|
An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route.
|
||||||
@@ -141,6 +145,76 @@ Content-Type: application/json
|
|||||||
{"title":"Hello","content":"A blog post"}
|
{"title":"Hello","content":"A blog post"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
`GET /_journal/api/v1/posts?search=<term>` filters the list by a term of 3 or more characters; a shorter term is ignored and the list comes back unfiltered.
|
||||||
|
|
||||||
|
- **SQL.** By default, and whenever the engine cannot be used, the term is matched with `ILIKE` against title, slug, excerpt and content, and the rows keep the list order (`sort`, default newest first).
|
||||||
|
- **Typesense.** When `search.driver` is `typesense` with an API key and the Journal setting `search_use_typesense` is on, the engine is asked for the first 250 candidates (`typesense.MaxPerPage`), ranked by text match with the setting weights for title, excerpt and content (default 5, 3, 1), then by date. The candidates are re-gated in SQL with the same filters as any list (published, `category`, `tag`, `author`), so a stale index entry never shows. `meta.total` counts the gated rows, the rows keep the engine's rank, with pinned posts first when `pinned_first` is set, and `page` and `per_page` page over them as over any list. An explicit `sort` overrides the rank. A search that matches more than 250 posts keeps the first 250. When the engine fails, the request is answered from SQL and the failure is logged at Warn.
|
||||||
|
|
||||||
|
The setting row is read on every engine search, so turning `search_use_typesense` off takes effect on the next request, without a restart.
|
||||||
|
|
||||||
|
`?fields=suggest` replaces each row with the short shape a search box needs, under the same pagination envelope, filters and limits:
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
|-----|-------|
|
||||||
|
| `id` | Post id. |
|
||||||
|
| `title` | Default-locale title, as in the full row. |
|
||||||
|
| `slug` | Default-locale slug, as in the full row. |
|
||||||
|
| `excerpt` | The excerpt as plain text, cut to about 160 characters on a word boundary with `…`; the content as plain text when the post has no excerpt. |
|
||||||
|
| `featured_image_url` | The first featured image URL, or `null`. |
|
||||||
|
| `published_at` | As in the full row. |
|
||||||
|
|
||||||
|
Any other `fields` value, or none, serves the full rows.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /_journal/api/v1/posts?search=borowik&fields=suggest&per_page=5
|
||||||
|
```
|
||||||
|
|
||||||
|
The search document holds the default-locale title, excerpt and content; content is indexed as plain text, from `content_html` (or `content` when nothing was rendered) with tags removed and entities decoded. The collection is created from `models.Post.SearchIndexSchema` when it does not exist. A collection created before the schema existed keeps its auto-detected fields until `journal:reindex --fresh` recreates it.
|
||||||
|
|
||||||
|
### Headings and links
|
||||||
|
|
||||||
|
Journal decides per site whether its rich text may hold an H1 (2026-10-28, D-50): a page template that shows the post title or the category name as the H1 needs the body to start at H2, and Journal cannot know which template a site uses.
|
||||||
|
|
||||||
|
- **The setting.** Settings › Journal, tab Headings and links, switch `content_h1_to_h2` (Convert H1 to H2 in post and category content), on by default. The settings row decides; the config key `golem15.journal.content_h1_to_h2` is used only when no row exists (`classes.EffectiveH1ToH2`). Switching it never rewrites stored content.
|
||||||
|
- **On.** Every H1 saved in Journal rich text becomes an H2 (`classes.ConvertH1`, on the `golang.org/x/net/html` tokenizer: only the tag name changes, attributes and every other byte are kept), on every write path: an admin save of a post or a category (notice `editor.h1_converted` or `editor.h1_converted_category` on the editor field), `POST` and `PUT /_journal/api/v1/posts` and so MCP (default and translated `content` and `content_html`; the answer gains `notices: [{message, count, locale}]`, `locale` only on a translation's notice), markdown content (its source is converted outside code only; `classes.ConvertContentH1` reads `content` as markdown, as the renderer does: an `<h1>` in a code block or code span keeps its tag, also when the body starts with an HTML block, and a raw `<h1>` elsewhere becomes `<h2>`, as in the rendered HTML) and `journal:import-posts` (it prints `Converted N H1 headings to H2.`). The bytes of an unfinished tag at the end of a text are kept. The editor offers H2 to H4.
|
||||||
|
- **Off.** H1 is stored as written, no notice appears, the editor offers H1 to H4 and `journal:fix-headings` refuses.
|
||||||
|
- **Stored content.** `journal:fix-headings` (a dry run unless `--apply`) and the MCP tool `journal_fix_headings` (`dry_run` default `true`) convert H1 already stored in post content (default and translated; in a markdown source outside code only) and in category pillar text, in one transaction, firing a `RecordSaved` per changed post and category; they answer the post, category and heading counts and refuse while the setting is off (exit code 1; `409` from the API).
|
||||||
|
- **The API flag.** `GET /_journal/api/v1/posts/{slug}` and every category row of `GET /_journal/api/v1/categories` carry `content_h1_to_h2` (boolean, an added key), so a frontend reads the setting from the Journal and not from its own option.
|
||||||
|
- **Generic link texts.** The `generic_anchors` setting (one per line; the English and Polish seed lists when empty, `classes.DefaultGenericAnchors`) is the list the editor's outline and link form warn about.
|
||||||
|
- **Settings API.** `GET` and `PUT /_journal/api/v1/settings` read and write every settings column for `golem15.journal.manage_settings` (`403` without it); `PUT` checks the submitted columns against `models.Settings.Rules` and ignores other keys.
|
||||||
|
- **Image alt.** `POST /_journal/api/v1/images/alt` `{dry_run, changes: [{post_id, locale, src, alt}]}` sets the alt of the image whose `src` equals `src` in the post's `content_html` and in `content`, in the default locale or in `locale`. `content` is read as markdown, as the renderer reads it (an HTML body included, whose `<img>` is a raw image outside code), and gets the alt in its inline image `` (with or without a title, outside code; `classes.SetMarkdownImageAlt`) or a raw `<img>` outside code (`classes.SetImageAltOutsideCode`), also when the markdown starts with an HTML tag, so the next content save keeps the alt; a locale whose `content` is blank (a translation that carries only `content_html`) gets the alt in `content_html` alone. An image the markdown does not hold that way (a reference-style image) refuses the change with `422` `changes.N.src` ("The image is not an inline image of the post's markdown; edit the post."); `alt: ""` marks it decorative. It needs `golem15.journal.access_posts` and edit rights on each post, refuses the whole batch with `422` when one image is not in its post, answers `{changes: [{post_id, locale, src, old_alt, alt, changed}], dry_run}` and fires a `RecordSaved` per changed post. Alt text is post content, so the bulk alt tool is Journal's (`journal_set_image_alt`), not the SEO plugin's.
|
||||||
|
|
||||||
|
### Related posts
|
||||||
|
|
||||||
|
`GET /_journal/api/v1/posts/{slug}` answers `related_posts` with the post's related posts and `previous_post` and `next_post` with its site-wide neighbours by publish time. The ranking is opt-in (D-82; `classes.ReadRelatedSettings`):
|
||||||
|
|
||||||
|
- **`golem15.journal.related_mode: category`** (the default): as before, up to three posts sharing a category with the post, newest first; none for a post without a category. The answer is unchanged.
|
||||||
|
- **`scored`**: posts sharing a tag or a category, ranked by 2 points per shared tag plus 1 point per shared category, ties by the newest publish time, `golem15.journal.related_count` of them (3 to 6, clamped; 3 when unset). A post that shares nothing is never related. One query computes the scores (`classes.ScoredRelatedPosts`). The answer adds `previous_in_category` and `next_in_category` (`{id, title, slug}` or `null`): the neighbours by publish time within the post's first category by name (`classes.PrimaryCategoryID`, `classes.CategoryNeighbour`), in the direction of `previous_post` and `next_post` (previous is the nearest post published after it, next the nearest one published before it).
|
||||||
|
|
||||||
|
Visitors only ever see published posts with a past publish time; an editor who may see drafts also gets drafts among the scored related posts, as the default mode does. The neighbours are published posts only.
|
||||||
|
|
||||||
|
### Image variants
|
||||||
|
|
||||||
|
Every entry of `featured_images` (on `GET /posts`, `GET /posts/{slug}` and the author lists) carries `variants`, the image's sizes by width, `[]` until they are made (additive key; the other keys are unchanged):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": 12, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg", "file_name": "cover.jpg", "file_size": 183402,
|
||||||
|
"variants": [
|
||||||
|
{"width": 480, "height": 288, "url": "/storage/uploads/5f1/d0c/2e9/thumb_12_480_288_0_0_exact.jpg", "webp_url": "/storage/uploads/5f1/d0c/2e9/thumb_12_480_288_0_0_exact.webp", "source_width": 1000, "source_height": 600},
|
||||||
|
{"width": 800, "height": 480, "url": "…_800_480_0_0_exact.jpg", "webp_url": "…_800_480_0_0_exact.webp", "source_width": 1000, "source_height": 600}
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`url` is the thumbnail in the original's format (a `.webp` original's thumbnail holds JPEG bytes), `webp_url` its WebP copy at quality 80, and `source_width` and `source_height` the original's size, so a page can list the original in a `srcset` too. One query loads the variants of a post's images.
|
||||||
|
|
||||||
|
- **When they are made.** `POST /_journal/api/v1/posts/{id}/featured-images` stores the original and queues the conga job `golem15.journal.image_variants` (`classes.ImageVariantsArgs`, three attempts) after the row is written; the request itself never resizes or encodes, and a queue that cannot take the job is logged without failing the upload. An admin save of a post queues the job, after the save commits, for its featured images that lack a width.
|
||||||
|
- **What is made.** For each width of `golem15.journal.image_widths` no larger than the original, `classes.GenerateImageVariants` writes the thumbnail (`attach.File.ThumbKey`, mode `exact`, the original's aspect ratio) and its WebP copy (`webp.Variant` of the framework's `modules/lagoon/attach/webp`). A width that has its row is skipped, so the job and the command can run any number of times. A missing or undecodable original, or one over the thumbnailer's 4096 by 4096 pixel limit, is skipped and logged.
|
||||||
|
- **Backfill.** `journal:image-variants` takes the images that lack a width no larger than their original; `--all` checks every featured image. It runs from the application's CLI too: without a running server it opens `database.dsn` and `storage.uploads.bucket_url` itself.
|
||||||
|
- **Not made.** AVIF (deferred). Images inserted into a post body keep their original file.
|
||||||
|
- **Server.** WebP is encoded in Go. With the system libwebp installed (`libwebp.so` and `libwebpdemux.so`, Debian `libwebp-dev`) it is about five times faster. A binary that mounts this plugin is dynamically linked against glibc, unless it is built with `-tags nodynamic` (see the framework's attachments documentation).
|
||||||
|
|
||||||
### Personal API tokens
|
### Personal API tokens
|
||||||
|
|
||||||
The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`):
|
The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`):
|
||||||
@@ -187,20 +261,26 @@ With the framework's MCP endpoint mounted (`summer.yaml` `build.tags: [summercms
|
|||||||
| `journal_create_post` | `POST /_journal/api/v1/posts` | `title`, `content`, optional `slug`, `excerpt`, `published`, `published_at`, `is_pinned`, `categories` (ids), `tags` (names) |
|
| `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_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) |
|
| `journal_delete_post` | `DELETE /_journal/api/v1/posts/{id}` | `id` (destructive) |
|
||||||
|
| `journal_get_settings` | `GET /_journal/api/v1/settings` | none (`golem15.journal.manage_settings`) |
|
||||||
|
| `journal_update_settings` | `PUT /_journal/api/v1/settings` | any settings column, for example `content_h1_to_h2` or `generic_anchors`; only the fields sent change (`golem15.journal.manage_settings`) |
|
||||||
|
| `journal_fix_headings` | `POST /_journal/api/v1/fix-headings` | `dry_run` (default `true`) (`golem15.journal.manage_settings`) |
|
||||||
|
| `journal_set_image_alt` | `POST /_journal/api/v1/images/alt` | `dry_run`, `changes` (`post_id`, `locale`, `src`, `alt`) (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.
|
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 (1 to 4 while the H1 setting is off), 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`).
|
||||||
|
|
||||||
The element follows the admin field type element contract: the `value` property holds the HTML string; the attributes are `field-name`, `record-id`, `locale`, `ui-locale`, `options`, `disabled` and `invalid`; a bubbling `summer-change` event with `detail.value` fires on user edits only. It styles itself only through the admin `--c-*` variables, in `blogeditor.css` under its tag (TipTap's injected stylesheet is off), and never receives or sends credentials.
|
The element follows the admin field type element contract: the `value` property holds the HTML string; the attributes are `field-name`, `record-id`, `locale`, `ui-locale`, `options`, `disabled` and `invalid`; a bubbling `summer-change` event with `detail.value` fires on user edits only. It styles itself only through the admin `--c-*` variables, in `blogeditor.css` under its tag (TipTap's injected stylesheet is off), and never receives or sends credentials.
|
||||||
|
|
||||||
- **Untouched values.** The element returns the exact string it was given until the user edits; an untouched imported post is saved byte for byte. Setting `value` to the string it currently returns is a no-op. After an edit, top-level blocks that are still equal to the loaded ones keep their original bytes, so editing one paragraph changes only that paragraph. Markup the schema does not model (`footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, `div` and similar blocks, and a `figure` that is not an image with an optional caption) becomes a raw HTML card that shows its source as text and is written back verbatim. On paragraphs, headings, lists, blockquotes, tables, cells, images and links, and on `span` elements, the view-safe unmodelled attributes survive an edit of their block: `class`, `title`, `alt`, `width`, `height`, `colspan`, `rowspan`, `start`, `rel`, `target` and `data-*` (`href` and `src` are kept by the link and image nodes, which check them). Every other attribute, such as `id`, `name`, `style`, `srcset`, `form`, `contenteditable`, `popover`, `itemprop` or an event handler (`on*`), is never rendered into the admin page and is dropped from a block once that block is edited; a `span` left with none of the kept attributes is dropped and its text kept. Untouched documents and untouched blocks keep every stored attribute.
|
- **Untouched values.** The element returns the exact string it was given until the user edits; an untouched imported post is saved byte for byte. Setting `value` to the string it currently returns is a no-op. After an edit, top-level blocks that are still equal to the loaded ones keep their original bytes, so editing one paragraph changes only that paragraph. Markup the schema does not model (`footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, `div` and similar blocks, and a `figure` that is not an image with an optional caption) becomes a raw HTML card that shows its source as text and is written back verbatim. On paragraphs, headings, lists, blockquotes, tables, cells, images and links, and on `span` elements, the view-safe unmodelled attributes survive an edit of their block: `class`, `title`, `alt`, `width`, `height`, `colspan`, `rowspan`, `start`, `rel`, `target` and `data-*` (`href` and `src` are kept by the link and image nodes, which check them). Every other attribute, such as `id`, `name`, `style`, `srcset`, `form`, `contenteditable`, `popover`, `itemprop` or an event handler (`on*`), is never rendered into the admin page and is dropped from a block once that block is edited; a `span` left with none of the kept attributes is dropped and its text kept. Untouched documents and untouched blocks keep every stored attribute.
|
||||||
- **Embeds.** The embed button takes a YouTube URL (`youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/`, `youtube.com/embed/`) and stores exactly `<figure data-embed="youtube" data-id="ID"></figure>`, where `ID` matches `^[A-Za-z0-9_-]{11}$`. The admin shows a card and never an iframe; the site layer expands the placeholder. Another provider is one entry in `EMBED_PROVIDERS` (`embed.ts`) and one in the layer.
|
- **Embeds.** The embed button takes a YouTube URL (`youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/`, `youtube.com/embed/`) and stores exactly `<figure data-embed="youtube" data-id="ID"></figure>`, where `ID` matches `^[A-Za-z0-9_-]{11}$`. The admin shows a card and never an iframe; the site layer expands the placeholder. Another provider is one entry in `EMBED_PROVIDERS` (`embed.ts`) and one in the layer.
|
||||||
- **Preview.** The Preview button (`Podgląd` for `ui-locale` `pl`) is off by default, belongs to each element on its own and shows its state through `aria-pressed`. It splits the editor 50/50; when the element is narrower than about 720px, the pane wraps below the editor. The pane renders the current value from the WYSIWYG or Source tab 300 ms (`PREVIEW_DEBOUNCE_MS` in `preview.ts`) after the last change, and each update reloads the frame. The frame is an `<iframe srcdoc>` with an empty `sandbox` attribute, so no scripts run and its origin is opaque. It has `referrerpolicy="no-referrer"` and a Content-Security-Policy meta (`PREVIEW_CSP`) that allows only inline styles and images. The preview copy leaves out `script`, `iframe`, `frame`, `frameset`, `object`, `embed`, `meta`, `base` and `link` elements and event handler attributes. YouTube placeholders show as thumbnail cards loaded from img.youtube.com, never as a player; these and the post's own images are the only network traffic the pane makes. The styling is post-body typography, with the `--c-*` values read from the element at render time or neutral defaults. Previewing never fires `summer-change` and never changes the value, so an untouched post is still saved byte for byte.
|
- **Preview.** The Preview button (`Podgląd` for `ui-locale` `pl`) is off by default, belongs to each element on its own and shows its state through `aria-pressed`. It splits the editor 50/50; when the element is narrower than about 720px, the pane wraps below the editor. The pane renders the current value from the WYSIWYG or Source tab 300 ms (`PREVIEW_DEBOUNCE_MS` in `preview.ts`) after the last change, and each update reloads the frame. The frame is an `<iframe srcdoc>` with an empty `sandbox` attribute, so no scripts run and its origin is opaque. It has `referrerpolicy="no-referrer"` and a Content-Security-Policy meta (`PREVIEW_CSP`) that allows only inline styles and images. The preview copy leaves out `script`, `iframe`, `frame`, `frameset`, `object`, `embed`, `meta`, `base` and `link` elements and event handler attributes. YouTube placeholders show as thumbnail cards loaded from img.youtube.com, never as a player; these and the post's own images are the only network traffic the pane makes. The styling is post-body typography, with the `--c-*` values read from the element at render time or neutral defaults. Previewing never fires `summer-change` and never changes the value, so an untouched post is still saved byte for byte.
|
||||||
- **Links.** The editor creates and keeps links only to `http`, `https`, `mailto` and relative URLs (`isPolicyUrl` in `extensions.ts`), the schemes both sanitizer policies keep; the scheme is read after removing control characters and spaces. A created link has `href` only; stored links keep their own `target` and `rel`, which the server strips on an edited save.
|
- **Headings (J1).** The element reads `headingLevels` from its `options` (the field types serve them per form request, see [headings and links](#headings-and-links)): `[2, 3, 4]` while the H1 setting is on, when the toolbar offers H2 to H4 and a pasted or loaded `<h1>` is parsed as an H2, so the editor never holds an H1; `[1, 2, 3, 4]` while it is off, with an H1 button first. Stored H5 and H6 still parse. The Source tab accepts any HTML; the server decides what is stored.
|
||||||
- **Images.** An image chosen with the file picker (JPEG, PNG, GIF, WebP) or dropped on the editor is handed to the admin page as a bubbling `summer-upload` event with `detail {id, file}`. The admin uploads it with its own session and answers on the element with `summer-upload-result` `{id, url}` or `{id, error}`. On a URL the editor asks for the alt text (required) and an optional caption and inserts `<img src alt>`, or `<figure>` with `<img>` and `<figcaption>` when a caption is given. An error, or no answer within 60 seconds, is shown in the status line and nothing is inserted.
|
- **Outline (J2).** A full-width bar between the editor and the status line shows the heading count and, when there are any, the problem count; it starts collapsed and never takes focus. Opened, it lists the headings in document order with their level, indented 12px per level, and flags a skipped level (the page title is the H1), an empty heading, a heading that is only a link and, while the H1 setting is on, an H1 in the Source tab; then the links whose whole text is on the generic list (`genericAnchors` option). It recomputes 300 ms (`OUTLINE_DEBOUNCE_MS`) after a change, from the Source text in the Source tab. Activating a heading puts the caret in it. It never edits the content.
|
||||||
|
- **Links.** The editor creates and keeps links only to `http`, `https`, `mailto` and relative URLs (`isPolicyUrl` in `extensions.ts`), the schemes both sanitizer policies keep; the scheme is read after removing control characters and spaces. A created link has `href` only, or `href` and `rel` (J3): for an absolute `http(s)` URL the link form offers `nofollow`, `sponsored (paid)` and `ugc (user content)` and Apply writes the checked ones; links within the site never get `rel`. When the link text is on the generic list the form warns (“click here” does not say where the link leads) without blocking Apply. Stored links keep their own `target` and `rel`; the server keeps only the three `rel` tokens.
|
||||||
|
- **Images.** An image chosen with the file picker (JPEG, PNG, GIF, WebP) or dropped on the editor opens the image form, one file at a time (J4): the file name, prefilled with the slug of the original name (lowercase, Polish letters transliterated, hyphens, at most 80 characters, the extension shown fixed after it), the alt text (required unless the image is marked decorative), the Decorative image checkbox (it clears and disables the alt and inserts `alt=""`) and an optional caption. Insert hands the renamed file to the admin page as a bubbling `summer-upload` event with `detail {id, file}`. The admin uploads it with its own session and answers on the element with `summer-upload-result` `{id, url}` or `{id, error}`. On the URL the editor inserts `<img src alt>`, or `<figure>` with `<img>` and `<figcaption>` when a caption is given. The status line shows the upload until the answer arrives; an error, or no answer within 60 seconds, is shown there and nothing is inserted.
|
||||||
- **Sanitizing.** The element never sanitizes. Every admin save goes through `classes.SanitizeHTMLReport`, which strips what the policy does not allow and reports it; the policy keeps all of the editor's own output.
|
- **Sanitizing.** The element never sanitizes. Every admin save goes through `classes.SanitizeHTMLReport`, which strips what the policy does not allow and reports it; the policy keeps all of the editor's own output.
|
||||||
|
|
||||||
The element is built by the plugin's own JS build into `assets/dist/blogeditor.js` and `assets/dist/blogeditor.css`. Both files are committed and embedded through `admin.go`, so a host application needs no Node:
|
The element is built by the plugin's own JS build into `assets/dist/blogeditor.js` and `assets/dist/blogeditor.css`. Both files are committed and embedded through `admin.go`, so a host application needs no Node:
|
||||||
@@ -214,7 +294,7 @@ bash scripts/check-dist.sh # fails when assets/dist differs from a fresh build
|
|||||||
|
|
||||||
After changing `assets/src`, run `npm run build` and commit `assets/dist`.
|
After changing `assets/src`, run `npm run build` and commit `assets/dist`.
|
||||||
|
|
||||||
`tests/blogeditor/fixtures/editor-corpus.json` holds the editor's own output, built through editor commands: an embed, images with and without a caption, a table with a header row, links, headings, lists (one starting at 3), a blockquote, inline marks, a code block, a horizontal rule, a hard break, and one document combining them. `tests/blogeditor/corpus.test.ts` rebuilds it and fails when the committed file differs; `UPDATE_CORPUS=1 npm test` rewrites it. The Go policy test and the site layer check the same entries against both sanitizer policies, so entry names stay stable.
|
`tests/blogeditor/fixtures/editor-corpus.json` holds the editor's own output, built through editor commands: an embed, images with and without a caption, a decorative image, a table with a header row, links, external links with `rel`, headings, lists (one starting at 3), a blockquote, inline marks, a code block, a horizontal rule, a hard break, and one document combining them. `tests/blogeditor/corpus.test.ts` rebuilds it and fails when the committed file differs; `UPDATE_CORPUS=1 npm test` rewrites it. The Go policy test and the site layer check the same entries against both sanitizer policies, so entry names stay stable.
|
||||||
|
|
||||||
### Admin: Blog Editor field types
|
### Admin: Blog Editor field types
|
||||||
|
|
||||||
@@ -225,6 +305,8 @@ The plugin registers two admin form field types (`journal.Plugin.FormFieldTypes`
|
|||||||
| `blogeditor` | One HTML string in a host column (category content). | `uploadFolder` |
|
| `blogeditor` | One HTML string in a host column (category content). | `uploadFolder` |
|
||||||
| `mlblogeditor` | One HTML string per content locale, stored like `mlmarkdown`: the default locale in the host column, the others as translations (post content). | `htmlColumn`, `uploadFolder` |
|
| `mlblogeditor` | One HTML string per content locale, stored like `mlmarkdown`: the default locale in the host column, the others as translations (post content). | `htmlColumn`, `uploadFolder` |
|
||||||
|
|
||||||
|
Both types also serve two options per form request (`pact.FormFieldType.RequestOptions`): `headingLevels` (`[2, 3, 4]` while the H1 setting is on, `[1, 2, 3, 4]` while it is off) and `genericAnchors` (the settings' generic link texts, `classes.GenericAnchors`). A `fields.yaml` option of the same name wins.
|
||||||
|
|
||||||
Both types show the element's Preview pane, and previewing never changes what a save stores.
|
Both types show the element's Preview pane, and previewing never changes what a save stores.
|
||||||
|
|
||||||
`htmlColumn` names the translatable attribute that holds the rendered HTML of a markdown value (`content_html` for posts). `uploadFolder` is the folder under `/journal/` that editor images are stored in (`posts`, `categories`; `editor` when unset); it follows the public upload route's folder rule (at most 100 letters, digits, `_`, `-` or `/`, no `..`) and is checked at boot.
|
`htmlColumn` names the translatable attribute that holds the rendered HTML of a markdown value (`content_html` for posts). `uploadFolder` is the folder under `/journal/` that editor images are stored in (`posts`, `categories`; `editor` when unset); it follows the public upload route's folder rule (at most 100 letters, digits, `_`, `-` or `/`, no `..`) and is checked at boot.
|
||||||
@@ -239,15 +321,15 @@ content:
|
|||||||
|
|
||||||
What an admin save stores:
|
What an admin save stores:
|
||||||
|
|
||||||
- Each submitted locale's value goes through `classes.SanitizeHTMLReport`. A value the policy keeps whole is stored byte for byte. Anything the policy does not allow is removed, the save goes through, and the response's `meta.notices` names what was removed per field and locale (for example `2 elements were removed: script, iframe`), in English or Polish. A save is never refused because of markup; a value that is not text is a `422` on the field.
|
- Each submitted locale's value goes through `classes.SanitizeHTMLReport`. While the H1 setting is on, every H1 left is then stored as an H2 (`classes.ConvertH1`) with the notice `1 H1 heading was converted to H2, because the page shows the post title as the H1.` (the category name, on a category). A value the policy keeps whole is stored byte for byte. Anything the policy does not allow is removed, the save goes through, and the response's `meta.notices` names what was removed per field and locale (for example `2 elements were removed: script, iframe`), in English or Polish. A save is never refused because of markup; a value that is not text is a `422` on the field.
|
||||||
- For posts, the stored HTML goes to both `content` and `content_html` of that locale: the host columns through the `FormBeforeCreate`/`FormBeforeUpdate` hooks, the other locales through `FormAfterCreate`/`FormAfterUpdate` and the translation writer. The admin path does not run goldmark.
|
- For posts, the stored HTML goes to both `content` and `content_html` of that locale: the host columns through the `FormBeforeCreate`/`FormBeforeUpdate` hooks, the other locales through `FormAfterCreate`/`FormAfterUpdate` and the translation writer. The admin path does not run goldmark.
|
||||||
- A locale whose content the administrator did not change is not written, so an imported post keeps both columns byte for byte until its body is edited.
|
- A locale whose content the administrator did not change is not written, so an imported post keeps both columns byte for byte until its body is edited.
|
||||||
- A post whose `content` does not start with `<` (`classes.LooksLikeHTML`) is a markdown post: the editor opens it with that locale's `content_html`, or with `FormatHTML(content)` when that is empty. Its first edited save stores HTML in both columns.
|
- A post whose `content` does not start with `<` (`classes.LooksLikeHTML`) is a markdown post: the editor opens it with that locale's `content_html`, or with `FormatHTML(content)` when that is empty. Its first edited save stores HTML in both columns.
|
||||||
- Category content follows the same rule and is no longer refused with a `422`.
|
- Category content follows the same rule and is no longer refused with a `422`.
|
||||||
|
|
||||||
The public Journal API is unchanged: `POST` and `PUT /_journal/api/v1/posts` still take markdown `content` and store `FormatHTML` output in `content_html`.
|
The public Journal API is unchanged: `POST` and `PUT /_journal/api/v1/posts` still take markdown `content` and store `FormatHTML` output in `content_html`. While the H1 setting is on they store every H1 of `content`, `content_html` and the submitted translations as an H2, and the answer gains a `notices` key (see [headings and links](#headings-and-links)).
|
||||||
|
|
||||||
Editor images are uploaded by the admin through the field's upload route and stored by `classes.StoreMedia`, the same storage as `POST /_journal/api/v1/media/upload`: the key is `/journal/<uploadFolder>/<name>` with the same numbered suffix when the name is taken and the same public URL. An editor upload must be a JPEG, PNG, GIF or WebP file whose first bytes sniff as the type its extension names (no SVG), at most 10240 KiB. Its name is lowercased and every character outside `a-z`, `0-9`, `.`, `_` and `-` becomes `-`, so the inserted `src` needs no escaping. The public upload route keeps its own naming, request fields, response and messages.
|
Editor images are uploaded by the admin through the field's upload route and stored by `classes.StoreMedia`, the same storage as `POST /_journal/api/v1/media/upload`: the key is `/journal/<uploadFolder>/<name>` with the same numbered suffix when the name is taken and the same public URL. An editor upload must be a JPEG, PNG, GIF or WebP file whose first bytes sniff as the type its extension names (no SVG), at most 10240 KiB. Both uploads store the name `classes.MediaFileName` makes (D-85): the stem lowercased, Polish letters transliterated, every other run of characters outside `a-z` and `0-9` one hyphen, at most 80 characters (`image` when nothing is left), and the extension lowercased, so `Lwia Grzywa – w lesie!.JPG` is stored as `lwia-grzywa-w-lesie.jpg` and the inserted `src` needs no escaping. The public upload route keeps its own request fields, response and messages.
|
||||||
|
|
||||||
### Menu item types
|
### Menu item types
|
||||||
|
|
||||||
@@ -260,13 +342,17 @@ 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. |
|
||||||
|
| `journal-author` | An author: a backend user with at least one published post (listed by name) | `/blog/author/:author` | | The author page's URL, `:author` being the author slug, else the login (the public author API's lookup order); nothing when the host has no author pages (`golem15.journal.author_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`, an author pattern `:author` and `:id`. 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.
|
||||||
|
|
||||||
|
The content descriptors (posts, categories, tags and authors, which feed the SEO plugin's hreflang and the sitemaps) keep one locale entry per enabled locale but give a locale an address only when the record has content in it: the default locale (translate's default, else the unprefixed locale, else the first enabled one) always; a post or a category when its own translation in that locale holds text (a post's title, content, content_html or excerpt, a category's name or description; a translated slug alone is no content); a tag or an author when at least one of its published posts has such a translation. An entry without content has the URL `""`, pact's "no public page", and the default locale's title, description and word count, so an untranslated locale is never advertised as an alternate. The menu item types' alternates above are unchanged.
|
||||||
|
|
||||||
A host changes the URLs in its config file `config/menu_items.yaml` (YAML only, because the keys contain dashes). Overriding the two record types is enough; the list types follow:
|
A host changes the URLs in its config file `config/menu_items.yaml` (YAML only, because the keys contain dashes). Overriding the two record types is enough; the list types follow:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -279,6 +365,15 @@ 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`, `journal-tag` and `journal-author` set `pact.MenuItemType.Content` (built in `classes/content_descriptors.go` and `classes/menu_item_types.go`): their admin controllers (`golem15.journal.posts`, `golem15.journal.categories`, `golem15.journal.tags`; none for authors, who are backend users with the morph name `Backend\Models\User`), 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.
|
||||||
|
- **Facts (Phase 33).** The descriptors also fill the optional content facts the SEO, sitemap and audit plugins read: `Kind` (`article` for posts, `collection` for categories and tags, `profile` for authors) and `Tools`, the MCP tools that fix a record (posts: `edit` `journal_update_post`, `headings` `journal_fix_headings`, `image_alt` `journal_set_image_alt`; categories: `headings`). A post record carries `PublishedAt`, its categories by name as `Parents`, its author in `Authors` (with a `journal-author` reference while author pages are on) and `TopHeading` (2 while the H1 setting is on, 0 while it is off); each locale lists `Images` (the featured image first, its alt the file's title or else the post title, then the body's images with their alt, `Decorative` for `alt=""` and the `width` and `height` attributes), `Videos` (YouTube embeds) and `Headings` (the body's outline). A child category's `Parents` is its parent, and a category carries its pillar text's `Headings` and the same `TopHeading`. The body facts are read once per locale with `golang.org/x/net/html`; the queries are batched per page. An author record has the author's name as every locale's title, the author URL per locale (empty without author pages) and the newest update of their published posts as `LastMod`.
|
||||||
|
- **Records.** Every record in id order, paged by the last id (at most 500 per page), drafts included; for `journal-author`, the authors with a published post.
|
||||||
|
- **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. An author is found by `:author` (the author slug, then the login) or `:id` among the authors with a published post. Drafts and future posts never match, tags never match without tag pages and authors never match without author pages. Path values reach the database only as query parameters.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Site cache purge
|
### Site cache purge
|
||||||
@@ -303,11 +398,42 @@ 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.
|
||||||
|
|
||||||
|
### Headings and links
|
||||||
|
|
||||||
|
| Key | Environment variable | Value |
|
||||||
|
|-----|----------------------|-------|
|
||||||
|
| `golem15.journal.content_h1_to_h2` | `SUMMER_GOLEM15__JOURNAL__CONTENT_H1_TO_H2` | The H1 setting used only when the Journal settings row does not exist, default `true` (`config/config.yaml`). The row's `content_h1_to_h2` (Settings › Journal, Headings and links) decides otherwise; `classes.EffectiveH1ToH2` is the one reader. |
|
||||||
|
|
||||||
|
### Related posts
|
||||||
|
|
||||||
|
| Key | Environment variable | Value |
|
||||||
|
|-----|----------------------|-------|
|
||||||
|
| `golem15.journal.related_mode` | `SUMMER_GOLEM15__JOURNAL__RELATED_MODE` | `category` (default, `config/config.yaml`): related posts as before. `scored`: ranked by shared tags and categories, plus `previous_in_category` and `next_in_category` on the post answer. See [related posts](#related-posts). |
|
||||||
|
| `golem15.journal.related_count` | `SUMMER_GOLEM15__JOURNAL__RELATED_COUNT` | The number of scored related posts, default `3`, clamped to 3 to 6. Read in `scored` mode only. |
|
||||||
|
|
||||||
|
### Image variants
|
||||||
|
|
||||||
|
| Key | Environment variable | Value |
|
||||||
|
|-----|----------------------|-------|
|
||||||
|
| `golem15.journal.image_widths` | `SUMMER_GOLEM15__JOURNAL__IMAGE_WIDTHS` | The widths in pixels of the featured image variants, default `[480, 800, 1200]` (`config/config.yaml`); the environment takes a comma-separated list such as `480,960`. Each is 16 to 4096; an invalid list falls back to the default. See [image variants](#image-variants). |
|
||||||
|
|
||||||
|
### Author pages
|
||||||
|
|
||||||
|
| Key | Environment variable | Value |
|
||||||
|
|-----|----------------------|-------|
|
||||||
|
| `golem15.journal.author_pages` | `SUMMER_GOLEM15__JOURNAL__AUTHOR_PAGES` | Whether the host site has author pages, default `true`. With `false`, `journal-author` items resolve to nothing, author descriptions carry empty URLs, author paths never match and post descriptors name their author without a `journal-author` reference. |
|
||||||
|
|
||||||
|
### 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 |
|
||||||
@@ -315,14 +441,26 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
|||||||
| `journal.Plugin` | Compiled plugin; `ID` is `golem15.journal`. |
|
| `journal.Plugin` | Compiled plugin; `ID` is `golem15.journal`. |
|
||||||
| `journal.Plugin.Requires` | Returns `golem15.translate` only. |
|
| `journal.Plugin.Requires` | Returns `golem15.translate` only. |
|
||||||
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
|
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
|
||||||
|
| `journal.Plugin.ConfigFS` | Ships `config/config.yaml` (`golem15.journal.content_h1_to_h2`, `golem15.journal.related_mode`, `golem15.journal.related_count`, `golem15.journal.image_widths`). |
|
||||||
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
|
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
|
||||||
| `journal.Plugin.Models` | Models registered by this plugin. |
|
| `journal.Plugin.Models` | Models registered by this plugin. |
|
||||||
| `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. |
|
| `journal.Plugin.Commands` | Registers `journal:export-posts`, `journal:import-posts`, `journal:reindex`, `journal:fix-headings` and `journal:image-variants`. |
|
||||||
|
| `journal.Plugin.Jobs` | Registers the image variants job `golem15.journal.image_variants` (`pact.HasJobs`). |
|
||||||
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
|
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
|
||||||
| `journal.Plugin.MCP` | The MCP tools and the `draft_post` prompt (`pact.HasMCP`), guard `backend`, permission `golem15.journal.access_posts`. |
|
| `journal.Plugin.MCP` | The MCP tools and the `draft_post` prompt (`pact.HasMCP`), guard `backend`, permission `golem15.journal.access_posts` (`golem15.journal.manage_settings` for the settings and `fix_headings` tools). |
|
||||||
| `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 Journal menu item types (`pact.HasMenuItemTypes`). |
|
||||||
|
| `console.FixHeadings` | `journal:fix-headings`. |
|
||||||
|
| `console.ImageVariants` | `journal:image-variants`. |
|
||||||
|
| `classes.ImageVariantsArgs`, `classes.ImageVariantsKind` | The image variants job's arguments (`FileIDs`) and kind `golem15.journal.image_variants`. |
|
||||||
|
| `classes.GenerateImageVariants`, `classes.VariantsResult` | Writes the thumbnails and WebP copies of files at the widths no larger than their originals and records them; what one run did. |
|
||||||
|
| `classes.ImageWidths`, `classes.ImageWidthsKey`, `classes.DefaultImageWidths`, `classes.MinImageWidth`, `classes.MaxImageWidth` | `golem15.journal.image_widths` read from the config (a list or a comma-separated text, 16 to 4096, sorted), its default 480, 800 and 1200. |
|
||||||
|
| `classes.ImageVariantsMode`, `classes.FeaturedImagesField` | The variants' thumbnail mode `exact` and the `featured_images` field. |
|
||||||
|
| `classes.FeaturedImageIDs`, `classes.DispatchImageVariants`, `classes.DispatchPostImageVariantsAfterCommit` | The featured images to backfill, and queueing the job for files or, after a commit, for a post's images that lack a width. |
|
||||||
|
| `models.ImageVariant` | A row of `golem15_journal_image_variants`: file, width, height, thumbnail and WebP keys, the original's size. |
|
||||||
|
| `console.ImportFromDirConfig`, `console.ImportStats.H1Converted` | The import with the config the H1 setting falls back to, and the number of H1 headings it stored as H2. |
|
||||||
|
| `controllers.PermissionManageSettings` | `golem15.journal.manage_settings`. |
|
||||||
| `journal.Plugin.Buckets` | Limiter buckets `journal-public-api` and `journal-api` (Max 120). |
|
| `journal.Plugin.Buckets` | Limiter buckets `journal-public-api` and `journal-api` (Max 120). |
|
||||||
| `models.Post` | Post row; `TableName` is `golem15_journal_posts`. |
|
| `models.Post` | Post row; `TableName` is `golem15_journal_posts`. |
|
||||||
| `models.Post.MorphName` | PHP class string `Golem15\Journal\Models\Post`. |
|
| `models.Post.MorphName` | PHP class string `Golem15\Journal\Models\Post`. |
|
||||||
@@ -330,23 +468,43 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
|||||||
| `models.Post.AttachRelations` | `featured_images` and `content_images` attachMany. |
|
| `models.Post.AttachRelations` | `featured_images` and `content_images` attachMany. |
|
||||||
| `models.Post.SearchableAs` | Index name `golem15_journal_posts`. |
|
| `models.Post.SearchableAs` | Index name `golem15_journal_posts`. |
|
||||||
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
|
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
|
||||||
| `models.Post.ToSearchableArray` | Typesense document built after a committed save. |
|
| `models.Post.ToSearchableArray` | Typesense document built after a committed save; content is plain text. |
|
||||||
|
| `models.Post.SearchIndexSchema` | The Typesense collection schema a missing collection is created with (`beachcomber.IndexSchemaProvider`). |
|
||||||
|
| `models.PlainText` | HTML to plain text: tags dropped, entities decoded, whitespace collapsed. |
|
||||||
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
|
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
|
||||||
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. |
|
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. |
|
||||||
| `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. |
|
| `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. |
|
||||||
| `models.Category.Fillable` | `name`, `slug`, `code`, `description`, `content`, `parent_id`. |
|
| `models.Category.Fillable` | `name`, `slug`, `code`, `description`, `content`, `parent_id`. |
|
||||||
| `models.Tag` | Tag row; `TableName` is `golem15_journal_tags`. |
|
| `models.Tag` | Tag row; `TableName` is `golem15_journal_tags`. |
|
||||||
| `models.Tag.Fillable` | `name`, `slug`, `description`. |
|
| `models.Tag.Fillable` | `name`, `slug`, `description`. |
|
||||||
| `models.Settings` | Singleton settings row; `TableName` is `golem15_journal_settings`. |
|
| `models.Settings` | Singleton settings row; `TableName` is `golem15_journal_settings`; `ContentH1ToH2` and `GenericAnchors` hold the Headings and links tab. |
|
||||||
| `models.All` | Models registered by this plugin. |
|
| `models.All` | Models registered by this plugin. |
|
||||||
| `updates.All` | gormigrate steps registered by this plugin. |
|
| `updates.All` | gormigrate steps registered by this plugin. |
|
||||||
| `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 and, while the H1 setting is on, `ConvertH1` with the conversion notice; Load with the markdown fallback, list text, the image upload and the per-request `headingLevels` and `genericAnchors` options. |
|
||||||
|
| `classes.EditorH1Converted`, `classes.EditorH1ConvertedCategory` | The phrase keys of the H1 conversion notice for a post and for a category. |
|
||||||
|
| `classes.H1ToH2Key` | `golem15.journal.content_h1_to_h2`, the config key of the H1 setting when no settings row exists. |
|
||||||
|
| `classes.EffectiveH1ToH2` | The one reader of the H1 setting: the settings row, else the config key, else true. |
|
||||||
|
| `classes.RelatedModeKey`, `classes.RelatedCountKey` | `golem15.journal.related_mode` and `golem15.journal.related_count`. |
|
||||||
|
| `classes.RelatedModeCategory`, `classes.RelatedModeScored`, `classes.RelatedCountDefault`, `classes.RelatedCountMin`, `classes.RelatedCountMax` | The related-posts modes and the count's default and bounds. |
|
||||||
|
| `classes.RelatedSettings`, `classes.ReadRelatedSettings` | The related-posts mode and count read from the config (`Scored` reports the scored mode). |
|
||||||
|
| `classes.ScoredRelatedPosts` | The scored mode's related posts: 2 points per shared tag plus 1 per shared category, ties by newest, at most the count, published only unless drafts are allowed. |
|
||||||
|
| `classes.PrimaryCategoryID`, `classes.CategoryNeighbour` | The post's first category by name, and its previous or next published post by publish time within that category. |
|
||||||
|
| `classes.GenericAnchors`, `classes.DefaultGenericAnchors` | The generic link texts: the settings row's list, one per line, lowercased, else the default English and Polish lists. |
|
||||||
|
| `classes.ConvertH1` | Turns every h1 of HTML into an h2 on the tokenizer, keeping every other byte; returns the converted count. |
|
||||||
|
| `classes.CountH1` | The number of h1 start tags `ConvertH1` would convert. |
|
||||||
|
| `classes.ConvertContentH1`, `classes.ConvertTranslationH1` | `ConvertH1` over a post's `content` (outside markdown code) and `content_html`, and over one locale's translated attributes. |
|
||||||
|
| `classes.FixStoredHeadings`, `classes.HeadingFix` | The stored-H1 conversion behind `journal:fix-headings` and `POST /fix-headings`: posts in every locale and category pillar text, counted or applied in one transaction with record events. |
|
||||||
|
| `classes.H1ConversionOffText`, `classes.ErrH1ConversionOff` | The refusal while the H1 setting is off. |
|
||||||
|
| `classes.SetImageAlt` | Sets the alt of the matching img through the tokenizer; returns the HTML, the match count and the old alt. |
|
||||||
|
| `classes.MediaFileName`, `classes.MaxMediaStem` | The D-85 slug rule for uploaded image names, at most 80 characters before the extension. |
|
||||||
| `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`, `classes.MenuTypeAuthor` | The type keys `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`, `journal-tag` and `journal-author`. |
|
||||||
| `classes.DefaultPostPattern`, `classes.DefaultCategoryPattern` | The default patterns `/blog/:slug` and `/blog/category/:slug`. |
|
| `classes.DefaultPostPattern`, `classes.DefaultCategoryPattern`, `classes.DefaultTagPattern`, `classes.DefaultAuthorPattern` | The default patterns `/blog/:slug`, `/blog/category/:slug`, `/blog/tag/:slug` and `/blog/author/:author`. |
|
||||||
|
| `classes.TagPagesKey` | `golem15.journal.tag_pages`, the host key that says whether the site has tag pages. |
|
||||||
|
| `classes.AuthorPagesKey` | `golem15.journal.author_pages`, the host key that says whether the site has author 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>/<MediaFileName>` 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`. |
|
||||||
| `classes.MediaError` | An upload `StoreMedia` refuses, with the request `Field` and the public route's `Message`. |
|
| `classes.MediaError` | An upload `StoreMedia` refuses, with the request `Field` and the public route's `Message`. |
|
||||||
| `classes.MediaMaxBytes` | The upload size limit, 10240 KiB. |
|
| `classes.MediaMaxBytes` | The upload size limit, 10240 KiB. |
|
||||||
@@ -366,24 +524,29 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
|||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
|
|
||||||
Both commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`.
|
The export and import commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`.
|
||||||
|
|
||||||
| Command | Arguments and flags | Effect |
|
| Command | Arguments and flags | Effect |
|
||||||
|---------|---------------------|--------|
|
|---------|---------------------|--------|
|
||||||
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations, with a progress bar over the posts. Attachments are omitted. Ends with `Exported N posts to: <path>`. |
|
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations, with a progress bar over the posts. Attachments are omitted. Ends with `Exported N posts to: <path>`. |
|
||||||
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files, one line per post. Existing slugs are skipped unless `--force` updates them. Ends with `Results: N created, N updated, N skipped, N failed`. |
|
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files, one line per post; while the H1 setting is on, H1 headings are stored as H2 and the count is printed. Existing slugs are skipped unless `--force` updates them. Ends with `Results: N created, N updated, N skipped, N failed`. |
|
||||||
|
| `journal:image-variants` | `--all` | Writes the thumbnails and WebP copies of featured images at `golem15.journal.image_widths` that are missing (with `--all`, checks every featured image) and prints `Checked N featured images, wrote N variants, skipped N.`, or `Every featured image has its variants.` |
|
||||||
|
| `journal:fix-headings` | `--apply`, `--dry-run` (the default) | Converts H1 to H2 in stored post content (every locale) and category pillar text while the H1 setting is on, firing a record event per changed post and category. Prints `Would convert N H1 headings in N posts and N categories. Run with --apply to save.`, `Converted N H1 headings in N posts and N categories.` or `No H1 headings found in post or category content.`; refuses with exit code 1 while the setting is off. Runs from the application CLI like `journal:image-variants`: without a published database it opens `database.dsn` (`SUMMER_DATABASE__DSN`). |
|
||||||
|
| `journal:reindex` | `--fresh` | Syncs every post into the search index in batches of 200: published posts are upserted, the others removed. `--fresh` first drops the collection and recreates it from the schema. Refuses, sending nothing, when the engine is not configured or `search_use_typesense` is off. Ends with `Reindexed: N synced, N removed, N failed` and exits non-zero on any failure. |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./bin/acme journal:export-posts --path journal-export
|
./bin/acme journal:export-posts --path journal-export
|
||||||
./bin/acme journal:import-posts --path journal-export --dry-run
|
./bin/acme journal:import-posts --path journal-export --dry-run
|
||||||
./bin/acme journal:import-posts --path journal-export --force
|
./bin/acme journal:import-posts --path journal-export --force
|
||||||
|
./bin/acme journal:reindex --fresh
|
||||||
|
./bin/acme journal:fix-headings --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
`./bin/acme help journal:import-posts` shows each command's long help and examples. The plugin logs through the component `journal` (`log.components.journal` in `config/log.yaml`).
|
`./bin/acme help journal:import-posts` shows each command's long help and examples. The plugin logs through the component `journal` (`log.components.journal` in `config/log.yaml`).
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- SummerCMS v0.5.0 modules: backpack, beachcomber, bonfire, cabana, cooler (site-cache purge), diary, lagoon/attach, pact, party, surf; signpost in tests.
|
- SummerCMS v0.6.1 modules (the content facts and `pact.FormFieldType.RequestOptions` are new in v0.6.1): backpack, beachcomber, bonfire, cabana, compass, conga (the image variants job), cooler (site-cache purge), diary, lagoon/attach, lagoon/attach/webp (WebP variants, through `github.com/gen2brain/webp` v0.6.4), pact, party, phrasebook, 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`.
|
||||||
@@ -400,7 +563,11 @@ 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.
|
Headings, links and media (Phase 33): `TestHeadingConversionTracer`, `TestHeadingConversion`, `TestFixHeadingsCommand`, `TestJournalSettingsTools`, `TestSetImageAlt` and `TestBlogEditorRequestOptions` (`headings_test.go`), `TestJournalDescriptorFields` and `TestJournalAuthorType` (`journal_descriptor_fields_test.go`) run on Postgres; `TestConvertH1`, `TestSetImageAlt` and `TestMediaFileName` (`classes`) are Docker-free; `TestSettingsHeadingsMigration` (`updates`) checks the migration and its rollback. The editor's J1 to J4 parts are covered by `tests/blogeditor/additions.test.ts`.
|
||||||
|
|
||||||
|
`TestJournalMCPSmoke` (root package, Docker-free) pins the eleven 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.
|
||||||
|
|
||||||
|
Related posts: `TestRelatedDefaultUnchanged` (the default and the explicit category mode answer as before, without the scored keys), `TestRelatedScored` (ranking, ties, a draft and a future post sharing every tag and category never shown, the category neighbours) and `TestRelatedScoredCountClamped` (`related_media_test.go`) run on Postgres. `TestImageVariants` (same file, Postgres) checks that an upload answers before any encoding and queues the job, that the job writes the default widths no wider than the original with a WebP copy of each, the API's `variants`, that a second run writes nothing, and that an admin save queues the job only for an image that lacks a width; `TestImageVariantsTallOriginal` that a 1000×9000 original followed by a normal one fails neither the job nor `journal:image-variants --all`.
|
||||||
|
|
||||||
`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.
|
||||||
|
|
||||||
|
|||||||
2
assets/dist/blogeditor.css
vendored
2
assets/dist/blogeditor.css
vendored
File diff suppressed because one or more lines are too long
94
assets/dist/blogeditor.js
vendored
94
assets/dist/blogeditor.js
vendored
File diff suppressed because one or more lines are too long
@@ -418,3 +418,179 @@ golem15-journal-blogeditor .ProseMirror-selectednode.blogeditor__embed,
|
|||||||
golem15-journal-blogeditor .ProseMirror img.ProseMirror-selectednode {
|
golem15-journal-blogeditor .ProseMirror img.ProseMirror-selectednode {
|
||||||
outline: 2px solid var(--c-ring);
|
outline: 2px solid var(--c-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* J2 outline (Phase 33, UI-SPEC C28): a full-width bar between the panes
|
||||||
|
* and the status line, and the panel it opens. Metrics copy the editor's
|
||||||
|
* own: 6x8px bar padding, 8px form padding, 12px per heading level.
|
||||||
|
*/
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline {
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--c-subtle);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-toggle:hover {
|
||||||
|
background: var(--c-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-problems,
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-flag,
|
||||||
|
golem15-journal-blogeditor .blogeditor__warning {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__alert {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-panel {
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-item {
|
||||||
|
padding-inline-start: calc(var(--blogeditor-outline-depth, 0) * 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A long heading wraps under its text, the level tag hanging beside it. */
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-target {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-level {
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--c-subtle);
|
||||||
|
font-family: "DM Mono", ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-text--empty,
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-flag,
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-empty,
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-heading {
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-flags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px 8px;
|
||||||
|
padding: 0 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-empty,
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__outline-heading {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* J3 link form: the rel checkboxes, their hint and the generic-text warning. */
|
||||||
|
golem15-journal-blogeditor .blogeditor__rel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__rel legend {
|
||||||
|
float: left;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form label.blogeditor__check {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__form input:disabled {
|
||||||
|
background: var(--c-subtle);
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__hint {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__warning {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* J4 image form: the file name with its fixed extension. */
|
||||||
|
golem15-journal-blogeditor .blogeditor__filename-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
golem15-journal-blogeditor .blogeditor__ext {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-muted);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Editor } from '@tiptap/core'
|
import { Editor } from '@tiptap/core'
|
||||||
import { parseEmbedUrl } from './embed'
|
import { parseEmbedUrl } from './embed'
|
||||||
import { EDIT_HTML_EVENT, isPolicyUrl, preservationExtensions } from './extensions'
|
import { DEFAULT_HEADING_LEVELS, EDIT_HTML_EVENT, isPolicyUrl, preservationExtensions } from './extensions'
|
||||||
|
import { buildImageForm, type ImageChoice, type ImageForm } from './imageform'
|
||||||
import { format, labelsFor, type Labels } from './labels'
|
import { format, labelsFor, type Labels } from './labels'
|
||||||
|
import { buildLinkExtras, type LinkExtras } from './linkform'
|
||||||
|
import { buildOutline, normalizeAnchor, type Outline } from './outline'
|
||||||
import { buildPreviewDocument, PREVIEW_DEBOUNCE_MS, PREVIEW_SANDBOX, resolvePreviewColors } from './preview'
|
import { buildPreviewDocument, PREVIEW_DEBOUNCE_MS, PREVIEW_SANDBOX, resolvePreviewColors } from './preview'
|
||||||
import { captureBlocks, serializeDocument, type Blocks } from './serializer'
|
import { captureBlocks, serializeDocument, type Blocks } from './serializer'
|
||||||
import { buildToolbar, type Toolbar } from './toolbar'
|
import { buildToolbar, type Toolbar } from './toolbar'
|
||||||
@@ -18,6 +21,48 @@ export const UPLOAD_TIMEOUT_MS = 60_000
|
|||||||
|
|
||||||
type Mode = 'clean' | 'wysiwyg' | 'source'
|
type Mode = 'clean' | 'wysiwyg' | 'source'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The generic link texts when the options carry none: the seed lists of the
|
||||||
|
* Journal settings (classes.DefaultGenericAnchors).
|
||||||
|
*/
|
||||||
|
export const DEFAULT_GENERIC_ANCHORS: readonly string[] = [
|
||||||
|
'click here', 'here', 'more', 'read more', 'learn more', 'this link', 'link',
|
||||||
|
'kliknij tutaj', 'tutaj', 'tu', 'więcej', 'czytaj więcej', 'czytaj dalej', 'ten link',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** The element options the editor reads (the field's typeOptions). */
|
||||||
|
export interface EditorOptions {
|
||||||
|
/** The offered heading levels (J1): [2, 3, 4], or [1, 2, 3, 4] while the H1 setting is off. */
|
||||||
|
headingLevels: number[]
|
||||||
|
/** The generic link texts, normalized (normalizeAnchor). */
|
||||||
|
genericAnchors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* readOptions reads the options attribute (JSON): headingLevels (integers 1
|
||||||
|
* to 6, default [2, 3, 4]) and genericAnchors (strings, default
|
||||||
|
* DEFAULT_GENERIC_ANCHORS). Anything else, or invalid JSON, is the default.
|
||||||
|
*/
|
||||||
|
export function readOptions(raw: string | null): EditorOptions {
|
||||||
|
let parsed: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
const value: unknown = raw ? JSON.parse(raw) : {}
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) parsed = value as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON: the defaults.
|
||||||
|
}
|
||||||
|
const levels = Array.isArray(parsed.headingLevels)
|
||||||
|
? [...new Set(parsed.headingLevels.filter((n): n is number => Number.isInteger(n) && n >= 1 && n <= 6))].sort()
|
||||||
|
: []
|
||||||
|
const anchors = Array.isArray(parsed.genericAnchors)
|
||||||
|
? parsed.genericAnchors.filter((t): t is string => typeof t === 'string')
|
||||||
|
: [...DEFAULT_GENERIC_ANCHORS]
|
||||||
|
return {
|
||||||
|
headingLevels: levels.length > 0 ? levels : [...DEFAULT_HEADING_LEVELS],
|
||||||
|
genericAnchors: [...new Set(anchors.map(normalizeAnchor).filter((t) => t !== ''))],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface InlineForm {
|
interface InlineForm {
|
||||||
root: HTMLDivElement
|
root: HTMLDivElement
|
||||||
error: HTMLParagraphElement
|
error: HTMLParagraphElement
|
||||||
@@ -58,11 +103,15 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
#status?: HTMLParagraphElement
|
#status?: HTMLParagraphElement
|
||||||
#fileInput?: HTMLInputElement
|
#fileInput?: HTMLInputElement
|
||||||
#linkForm?: InlineForm
|
#linkForm?: InlineForm
|
||||||
|
#linkExtras?: LinkExtras
|
||||||
#embedForm?: InlineForm
|
#embedForm?: InlineForm
|
||||||
#imageForm?: InlineForm
|
#imageForm?: ImageForm
|
||||||
|
#outline?: Outline
|
||||||
|
#options: EditorOptions = readOptions(null)
|
||||||
#showingSource = false
|
#showingSource = false
|
||||||
#pending = new Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>()
|
#pending = new Map<string, { name: string; choice: ImageChoice; timer: ReturnType<typeof setTimeout> }>()
|
||||||
#ready: { url: string; name: string }[] = []
|
/** The picked files waiting for their image form (J4). */
|
||||||
|
#queue: File[] = []
|
||||||
/** Whether this element shows the preview pane (per instance, off by default). */
|
/** Whether this element shows the preview pane (per instance, off by default). */
|
||||||
#previewOn = false
|
#previewOn = false
|
||||||
#panes?: HTMLDivElement
|
#panes?: HTMLDivElement
|
||||||
@@ -100,6 +149,7 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
if (this.#textarea) this.#textarea.value = v
|
if (this.#textarea) this.#textarea.value = v
|
||||||
this.#load(v)
|
this.#load(v)
|
||||||
this.#schedulePreview()
|
this.#schedulePreview()
|
||||||
|
this.#outline?.schedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The TipTap editor while the element is connected (toolbar and tests). */
|
/** The TipTap editor while the element is connected (toolbar and tests). */
|
||||||
@@ -113,6 +163,8 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
this.replaceChildren()
|
this.replaceChildren()
|
||||||
this.#labels = labelsFor(this.getAttribute('ui-locale'))
|
this.#labels = labelsFor(this.getAttribute('ui-locale'))
|
||||||
const labels = this.#labels
|
const labels = this.#labels
|
||||||
|
this.#options = readOptions(this.getAttribute('options'))
|
||||||
|
const options = this.#options
|
||||||
|
|
||||||
this.#toolbar = buildToolbar({
|
this.#toolbar = buildToolbar({
|
||||||
editor: () => this.#editor,
|
editor: () => this.#editor,
|
||||||
@@ -123,6 +175,7 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
insertRawHtml: () => this.#insertRawHtml(),
|
insertRawHtml: () => this.#insertRawHtml(),
|
||||||
showSource: (on) => this.showSource(on),
|
showSource: (on) => this.showSource(on),
|
||||||
togglePreview: () => this.showPreview(!this.#previewOn),
|
togglePreview: () => this.showPreview(!this.#previewOn),
|
||||||
|
headingLevels: options.headingLevels,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.#linkForm = this.#form('link', labels.linkUrl, [
|
this.#linkForm = this.#form('link', labels.linkUrl, [
|
||||||
@@ -130,11 +183,18 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
['remove', labels.linkRemove, () => this.#removeLink()],
|
['remove', labels.linkRemove, () => this.#removeLink()],
|
||||||
['cancel', labels.cancel, () => this.#close(this.#linkForm)],
|
['cancel', labels.cancel, () => this.#close(this.#linkForm)],
|
||||||
])
|
])
|
||||||
|
// J3: the rel checkboxes after the URL input, the generic-anchor
|
||||||
|
// warning above the buttons.
|
||||||
|
const extras = buildLinkExtras(labels, options.genericAnchors)
|
||||||
|
this.#linkExtras = extras
|
||||||
|
const urlField = this.#linkForm.root.querySelector('label')!
|
||||||
|
urlField.after(extras.fieldset, extras.warning)
|
||||||
|
urlField.querySelector('input')!.addEventListener('input', (event) => extras.setUrl((event.target as HTMLInputElement).value))
|
||||||
this.#embedForm = this.#form('embed', labels.embedUrl, [
|
this.#embedForm = this.#form('embed', labels.embedUrl, [
|
||||||
['insert', labels.embedInsert, () => this.#insertEmbed()],
|
['insert', labels.embedInsert, () => this.#insertEmbed()],
|
||||||
['cancel', labels.cancel, () => this.#close(this.#embedForm)],
|
['cancel', labels.cancel, () => this.#close(this.#embedForm)],
|
||||||
])
|
])
|
||||||
this.#imageForm = this.#imageFormElement()
|
this.#imageForm = buildImageForm(labels, () => this.#insertImage(), () => this.#nextImage())
|
||||||
|
|
||||||
const mount = document.createElement('div')
|
const mount = document.createElement('div')
|
||||||
mount.className = 'blogeditor__content'
|
mount.className = 'blogeditor__content'
|
||||||
@@ -182,12 +242,22 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
panes.append(main, previewPane)
|
panes.append(main, previewPane)
|
||||||
this.#panes = panes
|
this.#panes = panes
|
||||||
|
|
||||||
|
// J2: the outline bar between the panes and the status line.
|
||||||
|
this.#outline = buildOutline({
|
||||||
|
labels,
|
||||||
|
editor: () => this.#editor,
|
||||||
|
sourceText: () => (this.#showingSource && this.#textarea ? this.#textarea.value : null),
|
||||||
|
allowsH1: options.headingLevels.includes(1),
|
||||||
|
genericAnchors: options.genericAnchors,
|
||||||
|
})
|
||||||
|
|
||||||
this.append(
|
this.append(
|
||||||
this.#toolbar.element,
|
this.#toolbar.element,
|
||||||
this.#linkForm.root,
|
this.#linkForm.root,
|
||||||
this.#embedForm.root,
|
this.#embedForm.root,
|
||||||
this.#imageForm.root,
|
this.#imageForm.root,
|
||||||
panes,
|
panes,
|
||||||
|
this.#outline.element,
|
||||||
status,
|
status,
|
||||||
fileInput,
|
fileInput,
|
||||||
)
|
)
|
||||||
@@ -201,7 +271,7 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
element: mount,
|
element: mount,
|
||||||
injectCSS: false,
|
injectCSS: false,
|
||||||
editable: !this.hasAttribute('disabled'),
|
editable: !this.hasAttribute('disabled'),
|
||||||
extensions: preservationExtensions(labels),
|
extensions: preservationExtensions(labels, options.headingLevels),
|
||||||
content: initial,
|
content: initial,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
handleDrop: (_view, event, _slice, moved) => {
|
handleDrop: (_view, event, _slice, moved) => {
|
||||||
@@ -209,7 +279,7 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
const files = Array.from((event as DragEvent).dataTransfer?.files ?? []).filter(isImage)
|
const files = Array.from((event as DragEvent).dataTransfer?.files ?? []).filter(isImage)
|
||||||
if (files.length === 0) return false
|
if (files.length === 0) return false
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
for (const file of files) this.#upload(file)
|
this.#enqueue(files)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -218,6 +288,7 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
this.#mode = 'wysiwyg'
|
this.#mode = 'wysiwyg'
|
||||||
this.#emitChange()
|
this.#emitChange()
|
||||||
this.#schedulePreview()
|
this.#schedulePreview()
|
||||||
|
this.#outline?.schedule()
|
||||||
},
|
},
|
||||||
onTransaction: () => this.#toolbar?.refresh(),
|
onTransaction: () => this.#toolbar?.refresh(),
|
||||||
})
|
})
|
||||||
@@ -225,6 +296,7 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
this.#syncAttributes()
|
this.#syncAttributes()
|
||||||
this.showSource(false)
|
this.showSource(false)
|
||||||
if (this.#previewOn) this.showPreview(true)
|
if (this.#previewOn) this.showPreview(true)
|
||||||
|
this.#outline.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
@@ -234,7 +306,8 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
this.#pending.clear()
|
this.#pending.clear()
|
||||||
clearTimeout(this.#previewTimer)
|
clearTimeout(this.#previewTimer)
|
||||||
this.#previewTimer = undefined
|
this.#previewTimer = undefined
|
||||||
this.#ready = []
|
this.#outline?.stop()
|
||||||
|
this.#queue = []
|
||||||
// Keep an edited value readable while the element is detached.
|
// Keep an edited value readable while the element is detached.
|
||||||
if (this.#mode === 'wysiwyg' && this.#editor) {
|
if (this.#mode === 'wysiwyg' && this.#editor) {
|
||||||
this.#value = this.value
|
this.#value = this.value
|
||||||
@@ -267,6 +340,7 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
this.#close(this.#embedForm)
|
this.#close(this.#embedForm)
|
||||||
}
|
}
|
||||||
this.#toolbar?.selectTab(on)
|
this.#toolbar?.selectTab(on)
|
||||||
|
this.#outline?.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -350,6 +424,7 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
this.#mode = 'source'
|
this.#mode = 'source'
|
||||||
this.#emitChange()
|
this.#emitChange()
|
||||||
this.#schedulePreview()
|
this.#schedulePreview()
|
||||||
|
this.#outline?.schedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
#emitChange(): void {
|
#emitChange(): void {
|
||||||
@@ -449,9 +524,45 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
// Links ----------------------------------------------------------------------
|
// Links ----------------------------------------------------------------------
|
||||||
|
|
||||||
#openLink(): void {
|
#openLink(): void {
|
||||||
if (!this.#editor?.isEditable) return
|
const editor = this.#editor
|
||||||
const href = this.#editor.getAttributes('link').href
|
if (!editor?.isEditable) return
|
||||||
this.#open(this.#linkForm, typeof href === 'string' ? href : '')
|
const attrs = editor.getAttributes('link')
|
||||||
|
const href = typeof attrs.href === 'string' ? attrs.href : ''
|
||||||
|
this.#open(this.#linkForm, href)
|
||||||
|
this.#linkExtras?.setUrl(href)
|
||||||
|
this.#linkExtras?.setRel(attrs.rel)
|
||||||
|
this.#linkExtras?.setText(this.#linkText())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text the link form links: the existing link's text, else the selection. */
|
||||||
|
#linkText(): string {
|
||||||
|
const editor = this.#editor
|
||||||
|
if (!editor) return ''
|
||||||
|
const { state } = editor
|
||||||
|
let { from, to } = state.selection
|
||||||
|
if (editor.isActive('link')) {
|
||||||
|
const type = state.schema.marks.link
|
||||||
|
const $from = state.doc.resolve(from)
|
||||||
|
const start = $from.parent.childAfter($from.parentOffset)
|
||||||
|
if (type && start.node && type.isInSet(start.node.marks)) {
|
||||||
|
// The whole run of text carrying the same link mark.
|
||||||
|
const parentStart = $from.start()
|
||||||
|
let s0 = parentStart + start.offset
|
||||||
|
let e0 = s0 + start.node.nodeSize
|
||||||
|
const href = editor.getAttributes('link').href
|
||||||
|
$from.parent.forEach((child, offset) => {
|
||||||
|
const mark = child.marks.find((m) => m.type === type)
|
||||||
|
if (!mark || mark.attrs.href !== href) return
|
||||||
|
const a = parentStart + offset
|
||||||
|
const b = a + child.nodeSize
|
||||||
|
if (b === s0) s0 = a
|
||||||
|
if (a === e0) e0 = b
|
||||||
|
})
|
||||||
|
from = s0
|
||||||
|
to = e0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state.doc.textBetween(from, to, ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
#applyLink(): void {
|
#applyLink(): void {
|
||||||
@@ -466,17 +577,19 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
this.#linkForm.error.textContent = this.#labels.linkInvalid
|
this.#linkForm.error.textContent = this.#labels.linkInvalid
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// J3: rel only on an external link, with the checked tokens (D-80, D-83).
|
||||||
|
const rel = this.#linkExtras?.rel(url) ?? null
|
||||||
const { empty } = editor.state.selection
|
const { empty } = editor.state.selection
|
||||||
if (empty && !editor.isActive('link')) {
|
if (empty && !editor.isActive('link')) {
|
||||||
// Nothing selected: insert the URL as the link text.
|
// Nothing selected: insert the URL as the link text.
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.insertContent({ type: 'text', text: url, marks: [{ type: 'link', attrs: { href: url } }] })
|
.insertContent({ type: 'text', text: url, marks: [{ type: 'link', attrs: { href: url, rel } }] })
|
||||||
.unsetMark('link')
|
.unsetMark('link')
|
||||||
.run()
|
.run()
|
||||||
} else {
|
} else {
|
||||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url, rel }).run()
|
||||||
}
|
}
|
||||||
this.#close(this.#linkForm)
|
this.#close(this.#linkForm)
|
||||||
}
|
}
|
||||||
@@ -531,7 +644,7 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
if (at >= 0) textarea.setSelectionRange(at, at + html.length)
|
if (at >= 0) textarea.setSelectionRange(at, at + html.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uploads (D-18) -------------------------------------------------------------
|
// Uploads (D-18, J4) -----------------------------------------------------------
|
||||||
|
|
||||||
#picked(): void {
|
#picked(): void {
|
||||||
const input = this.#fileInput
|
const input = this.#fileInput
|
||||||
@@ -539,7 +652,7 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
const files = Array.from(input.files ?? [])
|
const files = Array.from(input.files ?? [])
|
||||||
const images = files.filter(isImage)
|
const images = files.filter(isImage)
|
||||||
if (images.length < files.length) this.#setStatus(this.#labels.notAnImage)
|
if (images.length < files.length) this.#setStatus(this.#labels.notAnImage)
|
||||||
for (const file of images) this.#upload(file)
|
this.#enqueue(images)
|
||||||
try {
|
try {
|
||||||
input.value = ''
|
input.value = ''
|
||||||
} catch {
|
} catch {
|
||||||
@@ -547,14 +660,20 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#upload(file: File): void {
|
/** Queues picked or dropped images; each gets its image form in turn (J4). */
|
||||||
|
#enqueue(files: File[]): void {
|
||||||
|
this.#queue.push(...files)
|
||||||
|
this.#showImageForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
#upload(file: File, choice: ImageChoice): void {
|
||||||
const id = uploadRequestId()
|
const id = uploadRequestId()
|
||||||
const name = file.name
|
const name = file.name
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (!this.#pending.delete(id)) return
|
if (!this.#pending.delete(id)) return
|
||||||
this.#setStatus(format(this.#labels.uploadTimeout, { name }))
|
this.#setStatus(format(this.#labels.uploadTimeout, { name }))
|
||||||
}, UPLOAD_TIMEOUT_MS)
|
}, UPLOAD_TIMEOUT_MS)
|
||||||
this.#pending.set(id, { name, timer })
|
this.#pending.set(id, { name, choice, timer })
|
||||||
this.#setStatus(format(this.#labels.uploading, { name }))
|
this.#setStatus(format(this.#labels.uploading, { name }))
|
||||||
this.dispatchEvent(new CustomEvent(UPLOAD_EVENT, { bubbles: true, composed: true, detail: { id, file } }))
|
this.dispatchEvent(new CustomEvent(UPLOAD_EVENT, { bubbles: true, composed: true, detail: { id, file } }))
|
||||||
}
|
}
|
||||||
@@ -573,66 +692,35 @@ export class BlogEditorElement extends HTMLElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.#setStatus('')
|
this.#setStatus('')
|
||||||
this.#ready.push({ url, name: pending.name })
|
const editor = this.#editor
|
||||||
this.#showImageForm()
|
if (!editor) return
|
||||||
}
|
const { alt, caption } = pending.choice
|
||||||
|
if (caption) editor.chain().focus().setFigure({ src: url, alt, caption }).run()
|
||||||
#imageFormElement(): InlineForm {
|
else editor.chain().focus().setImage({ src: url, alt }).run()
|
||||||
const labels = this.#labels
|
|
||||||
const root = document.createElement('div')
|
|
||||||
root.className = 'blogeditor__form'
|
|
||||||
root.dataset.form = 'image'
|
|
||||||
root.hidden = true
|
|
||||||
const field = (name: string, text: string) => {
|
|
||||||
const label = document.createElement('label')
|
|
||||||
const caption = document.createElement('span')
|
|
||||||
caption.textContent = text
|
|
||||||
const input = document.createElement('input')
|
|
||||||
input.type = 'text'
|
|
||||||
input.name = name
|
|
||||||
input.autocomplete = 'off'
|
|
||||||
label.append(caption, input)
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
root.append(field('alt', labels.altText), field('caption', labels.caption))
|
|
||||||
root.append(this.#action('insert', labels.insertImage, () => this.#insertImage()))
|
|
||||||
root.append(this.#action('cancel', labels.cancel, () => this.#nextImage()))
|
|
||||||
const error = document.createElement('p')
|
|
||||||
error.className = 'blogeditor__error'
|
|
||||||
error.dataset.error = ''
|
|
||||||
error.setAttribute('aria-live', 'polite')
|
|
||||||
root.append(error)
|
|
||||||
return { root, error }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#showImageForm(): void {
|
#showImageForm(): void {
|
||||||
const form = this.#imageForm
|
const form = this.#imageForm
|
||||||
if (!form || !form.root.hidden || this.#ready.length === 0) return
|
const file = this.#queue[0]
|
||||||
for (const input of Array.from(form.root.querySelectorAll('input'))) input.value = ''
|
if (!form || !form.root.hidden || !file) return
|
||||||
form.error.textContent = ''
|
form.open(file)
|
||||||
form.root.hidden = false
|
|
||||||
form.root.querySelector<HTMLInputElement>('input[name="alt"]')?.focus()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Insert: uploads the file under the chosen name; the image is inserted on the result. */
|
||||||
#insertImage(): void {
|
#insertImage(): void {
|
||||||
const editor = this.#editor
|
|
||||||
const form = this.#imageForm
|
const form = this.#imageForm
|
||||||
const ready = this.#ready[0]
|
const file = this.#queue[0]
|
||||||
if (!editor || !form || !ready) return
|
if (!this.#editor || !form || !file) return
|
||||||
const alt = this.#inputOf(form, 'alt')
|
const choice = form.choice()
|
||||||
const caption = this.#inputOf(form, 'caption')
|
if (!choice) return
|
||||||
if (!alt) {
|
const renamed = choice.fileName === file.name ? file : new File([file], choice.fileName, { type: file.type, lastModified: file.lastModified })
|
||||||
form.error.textContent = this.#labels.altRequired
|
this.#upload(renamed, choice)
|
||||||
return
|
|
||||||
}
|
|
||||||
if (caption) editor.chain().focus().setFigure({ src: ready.url, alt, caption }).run()
|
|
||||||
else editor.chain().focus().setImage({ src: ready.url, alt }).run()
|
|
||||||
this.#nextImage()
|
this.#nextImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
#nextImage(): void {
|
#nextImage(): void {
|
||||||
this.#ready.shift()
|
this.#queue.shift()
|
||||||
if (this.#imageForm) this.#close(this.#imageForm)
|
if (this.#imageForm) this.#imageForm.root.hidden = true
|
||||||
this.#showImageForm()
|
this.#showImageForm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -529,22 +529,62 @@ export const PlainTableHeader = TableHeader.extend({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** The heading levels the editor offers when no option says otherwise (J1). */
|
||||||
|
export const DEFAULT_HEADING_LEVELS: readonly number[] = [2, 3, 4]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* schemaHeadingLevels are the heading levels the schema holds for the
|
||||||
|
* offered ones: the offered levels and every lower level (5 and 6), so
|
||||||
|
* stored H5 and H6 survive an edit, and H1 only when it is offered.
|
||||||
|
*/
|
||||||
|
export function schemaHeadingLevels(offered: readonly number[]): (1 | 2 | 3 | 4 | 5 | 6)[] {
|
||||||
|
const allowsH1 = offered.includes(1)
|
||||||
|
return ([1, 2, 3, 4, 5, 6] as const).filter((level) => level > 1 || allowsH1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HeadingOneAsTwo parses an h1 (pasted or loaded) as a level 2 heading while
|
||||||
|
* the schema has no level 1 (the H1 setting is on, J1), so the editor never
|
||||||
|
* holds an H1. It is a parse rule for the heading node carried by a node
|
||||||
|
* type the parser never creates.
|
||||||
|
*/
|
||||||
|
export const HeadingOneAsTwo = Node.create({
|
||||||
|
name: 'headingOneAsTwo',
|
||||||
|
content: 'inline*',
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'h1',
|
||||||
|
node: 'heading',
|
||||||
|
getAttrs: (element) => ({ level: 2, htmlAttrs: captureAttributes(element as HTMLElement, []) }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
renderHTML() {
|
||||||
|
return ['h2', 0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* preservationExtensions is the editor schema (D-03, D-04): StarterKit
|
* preservationExtensions is the editor schema (D-03, D-04): StarterKit
|
||||||
* without the trailing node and with links that gain no target, rel or class
|
* without the trailing node and with links that gain no target, rel or class
|
||||||
* and accept only policy URLs; inline images without base64; tables; and
|
* and accept only policy URLs; inline images without base64; tables; and
|
||||||
* the preservation nodes and marks.
|
* the preservation nodes and marks. headingLevels are the offered heading
|
||||||
|
* levels (J1): without 1, an h1 is parsed as a level 2 heading.
|
||||||
*/
|
*/
|
||||||
export function preservationExtensions(labels: Labels = labelsFor('en')): AnyExtension[] {
|
export function preservationExtensions(labels: Labels = labelsFor('en'), headingLevels: readonly number[] = DEFAULT_HEADING_LEVELS): AnyExtension[] {
|
||||||
|
const levels = schemaHeadingLevels(headingLevels)
|
||||||
return [
|
return [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
trailingNode: false,
|
trailingNode: false,
|
||||||
|
heading: { levels },
|
||||||
link: {
|
link: {
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
HTMLAttributes: { target: null, rel: null, class: null },
|
HTMLAttributes: { target: null, rel: null, class: null },
|
||||||
isAllowedUri: (url) => isPolicyUrl(url),
|
isAllowedUri: (url) => isPolicyUrl(url),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
...(levels.includes(1) ? [] : [HeadingOneAsTwo]),
|
||||||
Image.configure({ inline: true, allowBase64: false }),
|
Image.configure({ inline: true, allowBase64: false }),
|
||||||
PlainTable,
|
PlainTable,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
|||||||
167
assets/src/blogeditor/imageform.ts
Normal file
167
assets/src/blogeditor/imageform.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import type { Labels } from './labels'
|
||||||
|
|
||||||
|
/** The longest file name stem mediaFileName keeps (D-85). */
|
||||||
|
export const MAX_FILE_STEM = 80
|
||||||
|
|
||||||
|
const POLISH: Record<string, string> = { ą: 'a', ć: 'c', ę: 'e', ł: 'l', ń: 'n', ó: 'o', ś: 's', ź: 'z', ż: 'z' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* slugFileStem is the J4 slug rule (D-85), the same as the server's
|
||||||
|
* classes.MediaFileName: lowercase, Polish diacritics transliterated, every
|
||||||
|
* other run of characters outside a-z and 0-9 one hyphen, trimmed, at most
|
||||||
|
* 80 characters, and "image" when nothing is left.
|
||||||
|
*/
|
||||||
|
export function slugFileStem(name: string): string {
|
||||||
|
let out = ''
|
||||||
|
let dash = false
|
||||||
|
for (const ch of name.toLowerCase()) {
|
||||||
|
const c = POLISH[ch] ?? ch
|
||||||
|
if (/^[a-z0-9]$/.test(c)) {
|
||||||
|
out += c
|
||||||
|
dash = false
|
||||||
|
} else if (!dash) {
|
||||||
|
out += '-'
|
||||||
|
dash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = out.replace(/^-+|-+$/g, '').slice(0, MAX_FILE_STEM).replace(/-+$/, '')
|
||||||
|
return out === '' ? 'image' : out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** splitFileName splits a file name into its stem and its lower-case extension (with the dot, or ""). */
|
||||||
|
export function splitFileName(name: string): { stem: string; ext: string } {
|
||||||
|
const base = name.replace(/\\/g, '/').split('/').pop() ?? ''
|
||||||
|
const dot = base.lastIndexOf('.')
|
||||||
|
if (dot >= 0) {
|
||||||
|
const ext = base.slice(dot).toLowerCase()
|
||||||
|
if (/^\.[a-z0-9]+$/.test(ext)) return { stem: base.slice(0, dot), ext }
|
||||||
|
}
|
||||||
|
return { stem: base, ext: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mediaFileName applies slugFileStem to a file name and keeps its extension, lowercased. */
|
||||||
|
export function mediaFileName(name: string): string {
|
||||||
|
const { stem, ext } = splitFileName(name)
|
||||||
|
return slugFileStem(stem) + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the image form answers on Insert. */
|
||||||
|
export interface ImageChoice {
|
||||||
|
/** The file name to upload under: the slugged stem and the fixed extension. */
|
||||||
|
fileName: string
|
||||||
|
alt: string
|
||||||
|
caption: string
|
||||||
|
decorative: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The J4 image form, one picked file at a time. */
|
||||||
|
export interface ImageForm {
|
||||||
|
root: HTMLDivElement
|
||||||
|
error: HTMLParagraphElement
|
||||||
|
/** Shows the form for file: the slug of its name, its extension, empty alt and caption. */
|
||||||
|
open: (file: File) => void
|
||||||
|
/** Validates and returns the choice, or shows altRequired and returns null. */
|
||||||
|
choice: () => ImageChoice | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildImageForm builds the J4 inline image form: fileName (prefilled with
|
||||||
|
* the slug of the original name, slugged again on blur and on Insert, the
|
||||||
|
* extension shown as fixed text after it, fileNameHint under it), altText
|
||||||
|
* (required unless the image is decorative), the decorative checkbox (it
|
||||||
|
* clears and disables the alt input; the image gets alt="") and caption.
|
||||||
|
*/
|
||||||
|
export function buildImageForm(labels: Labels, onInsert: () => void, onCancel: () => void): ImageForm {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.className = 'blogeditor__form'
|
||||||
|
root.dataset.form = 'image'
|
||||||
|
root.hidden = true
|
||||||
|
|
||||||
|
const textField = (name: string, caption: string) => {
|
||||||
|
const label = document.createElement('label')
|
||||||
|
const text = document.createElement('span')
|
||||||
|
text.textContent = caption
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'text'
|
||||||
|
input.name = name
|
||||||
|
input.autocomplete = 'off'
|
||||||
|
label.append(text, input)
|
||||||
|
return { label, input }
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = textField('fileName', labels.fileName)
|
||||||
|
name.label.classList.add('blogeditor__filename')
|
||||||
|
const nameRow = document.createElement('span')
|
||||||
|
nameRow.className = 'blogeditor__filename-row'
|
||||||
|
const ext = document.createElement('span')
|
||||||
|
ext.className = 'blogeditor__ext'
|
||||||
|
nameRow.append(name.input, ext)
|
||||||
|
const hint = document.createElement('span')
|
||||||
|
hint.className = 'blogeditor__hint'
|
||||||
|
hint.textContent = labels.fileNameHint
|
||||||
|
name.label.append(nameRow, hint)
|
||||||
|
name.input.addEventListener('blur', () => {
|
||||||
|
name.input.value = slugFileStem(name.input.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const alt = textField('alt', labels.altText)
|
||||||
|
const decorativeLabel = document.createElement('label')
|
||||||
|
decorativeLabel.className = 'blogeditor__check'
|
||||||
|
const decorative = document.createElement('input')
|
||||||
|
decorative.type = 'checkbox'
|
||||||
|
decorative.name = 'decorative'
|
||||||
|
decorativeLabel.append(decorative, document.createTextNode(labels.decorative))
|
||||||
|
decorative.addEventListener('change', () => {
|
||||||
|
if (decorative.checked) alt.input.value = ''
|
||||||
|
alt.input.disabled = decorative.checked
|
||||||
|
if (decorative.checked) error.textContent = ''
|
||||||
|
})
|
||||||
|
const caption = textField('caption', labels.caption)
|
||||||
|
|
||||||
|
const button = (action: string, text: string, run: () => void) => {
|
||||||
|
const b = document.createElement('button')
|
||||||
|
b.type = 'button'
|
||||||
|
b.dataset.action = action
|
||||||
|
b.textContent = text
|
||||||
|
b.addEventListener('click', run)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
const error = document.createElement('p')
|
||||||
|
error.className = 'blogeditor__error'
|
||||||
|
error.dataset.error = ''
|
||||||
|
error.setAttribute('aria-live', 'polite')
|
||||||
|
|
||||||
|
root.append(name.label, alt.label, decorativeLabel, caption.label, button('insert', labels.insertImage, onInsert), button('cancel', labels.cancel, onCancel), error)
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
error,
|
||||||
|
open: (file) => {
|
||||||
|
const parts = splitFileName(file.name)
|
||||||
|
name.input.value = slugFileStem(parts.stem)
|
||||||
|
ext.textContent = parts.ext
|
||||||
|
alt.input.value = ''
|
||||||
|
alt.input.disabled = false
|
||||||
|
decorative.checked = false
|
||||||
|
caption.input.value = ''
|
||||||
|
error.textContent = ''
|
||||||
|
root.hidden = false
|
||||||
|
alt.input.focus()
|
||||||
|
},
|
||||||
|
choice: () => {
|
||||||
|
name.input.value = slugFileStem(name.input.value)
|
||||||
|
const altText = alt.input.value.trim()
|
||||||
|
if (!decorative.checked && altText === '') {
|
||||||
|
error.textContent = labels.altRequired
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
error.textContent = ''
|
||||||
|
return {
|
||||||
|
fileName: name.input.value + ext.textContent,
|
||||||
|
alt: decorative.checked ? '' : altText,
|
||||||
|
caption: caption.input.value.trim(),
|
||||||
|
decorative: decorative.checked,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* A CLDR plural map: one/other in English, one/few/many/other in Polish
|
||||||
|
* (UI-SPEC zero-one-many); {count} is the number.
|
||||||
|
*/
|
||||||
|
export type Plural = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string }
|
||||||
|
|
||||||
/** Every label, prompt and error the Blog Editor shows. */
|
/** Every label, prompt and error the Blog Editor shows. */
|
||||||
export interface Labels {
|
export interface Labels {
|
||||||
|
/** The language the plural rules of this table follow. */
|
||||||
|
language: string
|
||||||
tabWysiwyg: string
|
tabWysiwyg: string
|
||||||
tabSource: string
|
tabSource: string
|
||||||
toolbar: string
|
toolbar: string
|
||||||
@@ -45,9 +53,35 @@ export interface Labels {
|
|||||||
embedCard: string
|
embedCard: string
|
||||||
preview: string
|
preview: string
|
||||||
previewFrame: string
|
previewFrame: string
|
||||||
|
heading1: string
|
||||||
|
outline: string
|
||||||
|
outlineShow: string
|
||||||
|
outlineHide: string
|
||||||
|
outlineCount: Plural
|
||||||
|
outlineProblems: Plural
|
||||||
|
outlineEmpty: string
|
||||||
|
flagSkipped: string
|
||||||
|
flagEmpty: string
|
||||||
|
flagLinkOnly: string
|
||||||
|
flagH1: string
|
||||||
|
outlineLinks: string
|
||||||
|
flagGeneric: string
|
||||||
|
linkRel: string
|
||||||
|
relNofollow: string
|
||||||
|
relSponsored: string
|
||||||
|
relUgc: string
|
||||||
|
relHint: string
|
||||||
|
genericWarning: string
|
||||||
|
fileName: string
|
||||||
|
fileNameHint: string
|
||||||
|
decorative: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The keys of the labels that are plain strings. */
|
||||||
|
export type TextLabel = { [K in keyof Labels]: Labels[K] extends string ? K : never }[keyof Labels]
|
||||||
|
|
||||||
const en: Labels = {
|
const en: Labels = {
|
||||||
|
language: 'en',
|
||||||
tabWysiwyg: 'WYSIWYG',
|
tabWysiwyg: 'WYSIWYG',
|
||||||
tabSource: 'Source',
|
tabSource: 'Source',
|
||||||
toolbar: 'Formatting',
|
toolbar: 'Formatting',
|
||||||
@@ -86,16 +120,39 @@ const en: Labels = {
|
|||||||
notAnImage: 'Only JPEG, PNG, GIF and WebP images can be uploaded.',
|
notAnImage: 'Only JPEG, PNG, GIF and WebP images can be uploaded.',
|
||||||
altText: 'Alt text (required)',
|
altText: 'Alt text (required)',
|
||||||
caption: 'Caption (optional)',
|
caption: 'Caption (optional)',
|
||||||
altRequired: 'Enter the alt text.',
|
altRequired: 'Enter the alt text, or mark the image as decorative.',
|
||||||
insertImage: 'Insert image',
|
insertImage: 'Insert image',
|
||||||
rawHtmlCard: 'HTML block',
|
rawHtmlCard: 'HTML block',
|
||||||
editAsHtml: 'Edit as HTML',
|
editAsHtml: 'Edit as HTML',
|
||||||
embedCard: 'Embedded video',
|
embedCard: 'Embedded video',
|
||||||
preview: 'Preview',
|
preview: 'Preview',
|
||||||
previewFrame: 'Post preview',
|
previewFrame: 'Post preview',
|
||||||
|
heading1: 'Heading 1',
|
||||||
|
outline: 'Outline',
|
||||||
|
outlineShow: 'Show outline',
|
||||||
|
outlineHide: 'Hide outline',
|
||||||
|
outlineCount: { one: '{count} heading', other: '{count} headings' },
|
||||||
|
outlineProblems: { one: '{count} problem', other: '{count} problems' },
|
||||||
|
outlineEmpty: 'No headings yet. Headings split the text into sections for readers and search engines.',
|
||||||
|
flagSkipped: 'Skipped level: H{from} → H{to}',
|
||||||
|
flagEmpty: 'Empty heading',
|
||||||
|
flagLinkOnly: 'Heading is only a link',
|
||||||
|
flagH1: 'H1 in the text: saved as H2',
|
||||||
|
outlineLinks: 'Links',
|
||||||
|
flagGeneric: 'Link text “{text}” does not say where it leads',
|
||||||
|
linkRel: 'Link attributes',
|
||||||
|
relNofollow: 'nofollow',
|
||||||
|
relSponsored: 'sponsored (paid)',
|
||||||
|
relUgc: 'ugc (user content)',
|
||||||
|
relHint: 'Only for links to other sites. Links within the site never get these.',
|
||||||
|
genericWarning: '“{text}” does not say where the link leads. Use words that describe the target page.',
|
||||||
|
fileName: 'File name',
|
||||||
|
fileNameHint: 'Lowercase words joined with hyphens, e.g. lion-s-mane-in-the-forest.',
|
||||||
|
decorative: 'Decorative image (no alt text)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const pl: Labels = {
|
const pl: Labels = {
|
||||||
|
language: 'pl',
|
||||||
tabWysiwyg: 'WYSIWYG',
|
tabWysiwyg: 'WYSIWYG',
|
||||||
tabSource: 'Źródło',
|
tabSource: 'Źródło',
|
||||||
toolbar: 'Formatowanie',
|
toolbar: 'Formatowanie',
|
||||||
@@ -134,13 +191,35 @@ const pl: Labels = {
|
|||||||
notAnImage: 'Można wysłać tylko obrazy JPEG, PNG, GIF i WebP.',
|
notAnImage: 'Można wysłać tylko obrazy JPEG, PNG, GIF i WebP.',
|
||||||
altText: 'Tekst alternatywny (wymagany)',
|
altText: 'Tekst alternatywny (wymagany)',
|
||||||
caption: 'Podpis (opcjonalny)',
|
caption: 'Podpis (opcjonalny)',
|
||||||
altRequired: 'Wpisz tekst alternatywny.',
|
altRequired: 'Wpisz tekst alternatywny lub oznacz obraz jako dekoracyjny.',
|
||||||
insertImage: 'Wstaw obraz',
|
insertImage: 'Wstaw obraz',
|
||||||
rawHtmlCard: 'Blok HTML',
|
rawHtmlCard: 'Blok HTML',
|
||||||
editAsHtml: 'Edytuj jako HTML',
|
editAsHtml: 'Edytuj jako HTML',
|
||||||
embedCard: 'Osadzone wideo',
|
embedCard: 'Osadzone wideo',
|
||||||
preview: 'Podgląd',
|
preview: 'Podgląd',
|
||||||
previewFrame: 'Podgląd wpisu',
|
previewFrame: 'Podgląd wpisu',
|
||||||
|
heading1: 'Nagłówek 1',
|
||||||
|
outline: 'Konspekt',
|
||||||
|
outlineShow: 'Pokaż konspekt',
|
||||||
|
outlineHide: 'Ukryj konspekt',
|
||||||
|
outlineCount: { one: '{count} nagłówek', few: '{count} nagłówki', many: '{count} nagłówków', other: '{count} nagłówka' },
|
||||||
|
outlineProblems: { one: '{count} problem', few: '{count} problemy', many: '{count} problemów', other: '{count} problemu' },
|
||||||
|
outlineEmpty: 'Brak nagłówków. Nagłówki dzielą tekst na części dla czytelników i wyszukiwarek.',
|
||||||
|
flagSkipped: 'Pominięty poziom: H{from} → H{to}',
|
||||||
|
flagEmpty: 'Pusty nagłówek',
|
||||||
|
flagLinkOnly: 'Nagłówek jest tylko linkiem',
|
||||||
|
flagH1: 'H1 w tekście: zostanie zapisany jako H2',
|
||||||
|
outlineLinks: 'Linki',
|
||||||
|
flagGeneric: 'Tekst linku „{text}” nie mówi, dokąd prowadzi',
|
||||||
|
linkRel: 'Atrybuty linku',
|
||||||
|
relNofollow: 'nofollow',
|
||||||
|
relSponsored: 'sponsored (płatny)',
|
||||||
|
relUgc: 'ugc (od użytkowników)',
|
||||||
|
relHint: 'Tylko dla linków do innych witryn. Linki w obrębie witryny nigdy ich nie dostają.',
|
||||||
|
genericWarning: '„{text}” nie mówi, dokąd prowadzi link. Użyj słów opisujących stronę docelową.',
|
||||||
|
fileName: 'Nazwa pliku',
|
||||||
|
fileNameHint: 'Małe litery i łączniki, np. lwia-grzywa-w-lesie.',
|
||||||
|
decorative: 'Obraz dekoracyjny (bez tekstu alternatywnego)',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABLES: Record<string, Labels> = { en, pl }
|
const TABLES: Record<string, Labels> = { en, pl }
|
||||||
@@ -158,3 +237,17 @@ export function labelsFor(uiLocale: string | null | undefined): Labels {
|
|||||||
export function format(label: string, values: Record<string, string>): string {
|
export function format(label: string, values: Record<string, string>): string {
|
||||||
return label.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
|
return label.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* plural picks the CLDR form of a plural label for count in the table's
|
||||||
|
* language and fills {count} (and any other placeholder in values).
|
||||||
|
*/
|
||||||
|
export function plural(labels: Labels, forms: Plural, count: number, values: Record<string, string> = {}): string {
|
||||||
|
let rule: Intl.LDMLPluralRule = 'other'
|
||||||
|
try {
|
||||||
|
rule = new Intl.PluralRules(labels.language).select(count)
|
||||||
|
} catch {
|
||||||
|
// An engine without Intl.PluralRules falls back to the other form.
|
||||||
|
}
|
||||||
|
return format(forms[rule] ?? forms.other, { ...values, count: String(count) })
|
||||||
|
}
|
||||||
|
|||||||
108
assets/src/blogeditor/linkform.ts
Normal file
108
assets/src/blogeditor/linkform.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { format, type Labels } from './labels'
|
||||||
|
import { alertIcon, isGenericAnchor } from './outline'
|
||||||
|
|
||||||
|
/** The rel tokens the link form writes and the server policy keeps (D-83). */
|
||||||
|
export const REL_TOKENS = ['nofollow', 'sponsored', 'ugc'] as const
|
||||||
|
|
||||||
|
export type RelToken = (typeof REL_TOKENS)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isExternalUrl reports whether url is an absolute http or https URL, the
|
||||||
|
* only links that may carry rel (D-80): links within the site never do.
|
||||||
|
*/
|
||||||
|
export function isExternalUrl(url: string): boolean {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!/^https?:\/\//i.test(trimmed)) return false
|
||||||
|
try {
|
||||||
|
return new URL(trimmed).host !== ''
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** relTokens reads the known rel tokens of a stored rel value, in REL_TOKENS order. */
|
||||||
|
export function relTokens(rel: unknown): RelToken[] {
|
||||||
|
if (typeof rel !== 'string') return []
|
||||||
|
const present = rel.toLowerCase().split(/\s+/)
|
||||||
|
return REL_TOKENS.filter((t) => present.includes(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The J3 additions to the link form. */
|
||||||
|
export interface LinkExtras {
|
||||||
|
/** The fieldset with the three rel checkboxes and the hint. */
|
||||||
|
fieldset: HTMLFieldSetElement
|
||||||
|
/** The generic-anchor warning line, placed above the buttons. */
|
||||||
|
warning: HTMLParagraphElement
|
||||||
|
/** Enables the checkboxes for an external URL, else disables them and shows the hint. */
|
||||||
|
setUrl: (url: string) => void
|
||||||
|
/** Checks the checkboxes of a stored rel value. */
|
||||||
|
setRel: (rel: unknown) => void
|
||||||
|
/** The rel value to write for url: the checked tokens of an external URL, else null. */
|
||||||
|
rel: (url: string) => string | null
|
||||||
|
/** Shows the warning when the link text is on the generic list, hides it otherwise. */
|
||||||
|
setText: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildLinkExtras builds the J3 part of the link form: a fieldset labelled
|
||||||
|
* linkRel with nofollow, sponsored and ugc checkboxes, enabled only for an
|
||||||
|
* absolute http(s) URL (relHint otherwise), and the genericWarning line
|
||||||
|
* (aria-live polite) shown when the link text is on the generic list. The
|
||||||
|
* warning never blocks Apply (D-81).
|
||||||
|
*/
|
||||||
|
export function buildLinkExtras(labels: Labels, genericAnchors: readonly string[]): LinkExtras {
|
||||||
|
const fieldset = document.createElement('fieldset')
|
||||||
|
fieldset.className = 'blogeditor__rel'
|
||||||
|
const legend = document.createElement('legend')
|
||||||
|
legend.textContent = labels.linkRel
|
||||||
|
fieldset.append(legend)
|
||||||
|
const boxes = new Map<RelToken, HTMLInputElement>()
|
||||||
|
const names: Record<RelToken, string> = { nofollow: labels.relNofollow, sponsored: labels.relSponsored, ugc: labels.relUgc }
|
||||||
|
for (const token of REL_TOKENS) {
|
||||||
|
const label = document.createElement('label')
|
||||||
|
label.className = 'blogeditor__check'
|
||||||
|
const box = document.createElement('input')
|
||||||
|
box.type = 'checkbox'
|
||||||
|
box.name = `rel-${token}`
|
||||||
|
box.value = token
|
||||||
|
label.append(box, document.createTextNode(names[token]))
|
||||||
|
fieldset.append(label)
|
||||||
|
boxes.set(token, box)
|
||||||
|
}
|
||||||
|
const hint = document.createElement('p')
|
||||||
|
hint.className = 'blogeditor__hint'
|
||||||
|
hint.textContent = labels.relHint
|
||||||
|
fieldset.append(hint)
|
||||||
|
|
||||||
|
const warning = document.createElement('p')
|
||||||
|
warning.className = 'blogeditor__warning'
|
||||||
|
warning.dataset.warning = ''
|
||||||
|
warning.setAttribute('aria-live', 'polite')
|
||||||
|
warning.hidden = true
|
||||||
|
|
||||||
|
const setUrl = (url: string) => {
|
||||||
|
const external = isExternalUrl(url)
|
||||||
|
for (const box of boxes.values()) box.disabled = !external
|
||||||
|
hint.hidden = external
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fieldset,
|
||||||
|
warning,
|
||||||
|
setUrl,
|
||||||
|
setRel: (rel) => {
|
||||||
|
const tokens = relTokens(rel)
|
||||||
|
for (const [token, box] of boxes) box.checked = tokens.includes(token)
|
||||||
|
},
|
||||||
|
rel: (url) => {
|
||||||
|
if (!isExternalUrl(url)) return null
|
||||||
|
const checked = REL_TOKENS.filter((t) => boxes.get(t)!.checked)
|
||||||
|
return checked.length > 0 ? checked.join(' ') : null
|
||||||
|
},
|
||||||
|
setText: (text) => {
|
||||||
|
const generic = isGenericAnchor(text, genericAnchors)
|
||||||
|
warning.hidden = !generic
|
||||||
|
warning.replaceChildren(...(generic ? [alertIcon(), document.createTextNode(format(labels.genericWarning, { text: text.trim() }))] : []))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
302
assets/src/blogeditor/outline.ts
Normal file
302
assets/src/blogeditor/outline.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import type { Editor } from '@tiptap/core'
|
||||||
|
import { format, plural, type Labels } from './labels'
|
||||||
|
|
||||||
|
/** How long after the last change the outline recomputes (J2). */
|
||||||
|
export const OUTLINE_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
/** The text-free alert icon the outline flags carry (triangle-alert). */
|
||||||
|
const ALERT_PATHS = ['m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3', 'M12 9v4', 'M12 17h.01']
|
||||||
|
|
||||||
|
/** One heading of the outline, in document order. */
|
||||||
|
export interface OutlineHeading {
|
||||||
|
level: number
|
||||||
|
text: string
|
||||||
|
/** Every non-blank character of the heading sits inside a link. */
|
||||||
|
linkOnly: boolean
|
||||||
|
/** The heading's document position in the WYSIWYG tab, -1 in the Source tab. */
|
||||||
|
pos: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The outline facts: headings and the texts of the links. */
|
||||||
|
export interface OutlineFacts {
|
||||||
|
headings: OutlineHeading[]
|
||||||
|
links: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the outline reads from the element. */
|
||||||
|
export interface OutlineHost {
|
||||||
|
labels: Labels
|
||||||
|
editor: () => Editor | undefined
|
||||||
|
/** The Source tab's text while it defines the value, null otherwise. */
|
||||||
|
sourceText: () => string | null
|
||||||
|
/** Whether level 1 is offered (the H1 setting is off). */
|
||||||
|
allowsH1: boolean
|
||||||
|
/** The generic link texts, normalized (normalizeAnchor). */
|
||||||
|
genericAnchors: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Outline {
|
||||||
|
element: HTMLDivElement
|
||||||
|
/** Recomputes OUTLINE_DEBOUNCE_MS after the last call. */
|
||||||
|
schedule: () => void
|
||||||
|
/** Recomputes now. */
|
||||||
|
refresh: () => void
|
||||||
|
/** Stops a pending recompute. */
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* normalizeAnchor is a link text as the generic list compares it: trimmed,
|
||||||
|
* lower-cased, punctuation and symbols removed, white space collapsed.
|
||||||
|
*/
|
||||||
|
export function normalizeAnchor(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\p{P}\p{S}]+/gu, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** isGenericAnchor reports whether a link text is on the normalized generic list. */
|
||||||
|
export function isGenericAnchor(text: string, list: readonly string[]): boolean {
|
||||||
|
const n = normalizeAnchor(text)
|
||||||
|
return n !== '' && list.includes(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** factsFromEditor reads the headings and links of the editor document. */
|
||||||
|
export function factsFromEditor(editor: Editor): OutlineFacts {
|
||||||
|
const headings: OutlineHeading[] = []
|
||||||
|
const links: string[] = []
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name === 'heading') {
|
||||||
|
let linked = 0
|
||||||
|
let all = 0
|
||||||
|
node.descendants((child) => {
|
||||||
|
if (!child.isText) return true
|
||||||
|
const chars = (child.text ?? '').replace(/\s+/g, '').length
|
||||||
|
all += chars
|
||||||
|
if (child.marks.some((m) => m.type.name === 'link')) linked += chars
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
headings.push({ level: Number(node.attrs.level) || 2, text: node.textContent.trim(), linkOnly: all > 0 && linked === all, pos })
|
||||||
|
}
|
||||||
|
if (node.isTextblock) {
|
||||||
|
let current = ''
|
||||||
|
let href: unknown = null
|
||||||
|
const flush = () => {
|
||||||
|
if (current.trim() !== '') links.push(current)
|
||||||
|
current = ''
|
||||||
|
href = null
|
||||||
|
}
|
||||||
|
node.forEach((child) => {
|
||||||
|
const link = child.isText ? child.marks.find((m) => m.type.name === 'link') : undefined
|
||||||
|
if (link && link.attrs.href === href) {
|
||||||
|
current += child.text ?? ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
if (link) {
|
||||||
|
current = child.text ?? ''
|
||||||
|
href = link.attrs.href
|
||||||
|
}
|
||||||
|
})
|
||||||
|
flush()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return { headings, links }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** factsFromSource reads the headings and links of HTML source text. */
|
||||||
|
export function factsFromSource(html: string): OutlineFacts {
|
||||||
|
const doc = document.implementation.createHTMLDocument('')
|
||||||
|
const host = doc.createElement('div')
|
||||||
|
host.innerHTML = html
|
||||||
|
const headings: OutlineHeading[] = []
|
||||||
|
for (const el of Array.from(host.querySelectorAll('h1, h2, h3, h4, h5, h6'))) {
|
||||||
|
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||||
|
const linkedText = Array.from(el.querySelectorAll('a'))
|
||||||
|
.map((a) => a.textContent ?? '')
|
||||||
|
.join('')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
headings.push({ level: Number(el.tagName.slice(1)), text, linkOnly: text !== '' && linkedText.length === text.replace(/\s+/g, '').length, pos: -1 })
|
||||||
|
}
|
||||||
|
const links = Array.from(host.querySelectorAll('a'))
|
||||||
|
.map((a) => a.textContent ?? '')
|
||||||
|
.filter((t) => t.trim() !== '')
|
||||||
|
return { headings, links }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One heading's flags as label texts. */
|
||||||
|
export function headingFlags(facts: OutlineFacts, labels: Labels, opts: { allowsH1: boolean; source: boolean }): string[][] {
|
||||||
|
let previous = 1 // the page title is the H1
|
||||||
|
return facts.headings.map((h) => {
|
||||||
|
const flags: string[] = []
|
||||||
|
if (h.level > previous + 1) flags.push(format(labels.flagSkipped, { from: String(previous), to: String(h.level) }))
|
||||||
|
if (h.text === '') flags.push(labels.flagEmpty)
|
||||||
|
else if (h.linkOnly) flags.push(labels.flagLinkOnly)
|
||||||
|
if (h.level === 1 && !opts.allowsH1 && opts.source) flags.push(labels.flagH1)
|
||||||
|
previous = h.level
|
||||||
|
return flags
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertIcon(): SVGSVGElement {
|
||||||
|
const ns = 'http://www.w3.org/2000/svg'
|
||||||
|
const svg = document.createElementNS(ns, 'svg')
|
||||||
|
svg.setAttribute('class', 'blogeditor__alert')
|
||||||
|
svg.setAttribute('viewBox', '0 0 24 24')
|
||||||
|
svg.setAttribute('width', '14')
|
||||||
|
svg.setAttribute('height', '14')
|
||||||
|
svg.setAttribute('fill', 'none')
|
||||||
|
svg.setAttribute('stroke', 'currentColor')
|
||||||
|
svg.setAttribute('stroke-width', '2')
|
||||||
|
svg.setAttribute('stroke-linecap', 'round')
|
||||||
|
svg.setAttribute('stroke-linejoin', 'round')
|
||||||
|
svg.setAttribute('aria-hidden', 'true')
|
||||||
|
for (const d of ALERT_PATHS) {
|
||||||
|
const path = document.createElementNS(ns, 'path')
|
||||||
|
path.setAttribute('d', d)
|
||||||
|
svg.append(path)
|
||||||
|
}
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
|
/** flagLine is one 13px muted flag with the alert icon. */
|
||||||
|
function flagLine(text: string): HTMLSpanElement {
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.className = 'blogeditor__outline-flag'
|
||||||
|
span.append(alertIcon(), document.createTextNode(text))
|
||||||
|
return span
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildOutline builds the J2 outline: a full-width toggle bar (collapsed by
|
||||||
|
* default, aria-expanded) with the heading count and, when there are any,
|
||||||
|
* the problem count, and a panel listing the headings in document order
|
||||||
|
* with their flags (skipped level, empty, only a link, an H1 in the Source
|
||||||
|
* tab while the H1 setting is on), then the links whose text is on the
|
||||||
|
* generic list. Activating a heading puts the caret in it. The outline
|
||||||
|
* reads the document and never edits it, and never takes focus by itself.
|
||||||
|
*/
|
||||||
|
export function buildOutline(host: OutlineHost): Outline {
|
||||||
|
const { labels } = host
|
||||||
|
const element = document.createElement('div')
|
||||||
|
element.className = 'blogeditor__outline'
|
||||||
|
|
||||||
|
const toggle = document.createElement('button')
|
||||||
|
toggle.type = 'button'
|
||||||
|
toggle.className = 'blogeditor__outline-toggle'
|
||||||
|
toggle.setAttribute('aria-expanded', 'false')
|
||||||
|
toggle.title = labels.outlineShow
|
||||||
|
const summary = document.createElement('span')
|
||||||
|
summary.className = 'blogeditor__outline-summary'
|
||||||
|
const problems = document.createElement('span')
|
||||||
|
problems.className = 'blogeditor__outline-problems'
|
||||||
|
problems.hidden = true
|
||||||
|
toggle.append(summary, problems)
|
||||||
|
|
||||||
|
const panel = document.createElement('div')
|
||||||
|
panel.className = 'blogeditor__outline-panel'
|
||||||
|
panel.hidden = true
|
||||||
|
panel.id = `blogeditor-outline-${Math.random().toString(36).slice(2)}`
|
||||||
|
toggle.setAttribute('aria-controls', panel.id)
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const open = panel.hidden
|
||||||
|
panel.hidden = !open
|
||||||
|
toggle.setAttribute('aria-expanded', String(open))
|
||||||
|
toggle.title = open ? labels.outlineHide : labels.outlineShow
|
||||||
|
if (open) refresh()
|
||||||
|
})
|
||||||
|
element.append(toggle, panel)
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
const render = (facts: OutlineFacts, source: boolean) => {
|
||||||
|
const flags = headingFlags(facts, labels, { allowsH1: host.allowsH1, source })
|
||||||
|
const generic = facts.links.filter((t) => isGenericAnchor(t, host.genericAnchors))
|
||||||
|
const problemCount = flags.reduce((n, f) => n + f.length, 0) + generic.length
|
||||||
|
summary.textContent = `${labels.outline} · ${plural(labels, labels.outlineCount, facts.headings.length)}`
|
||||||
|
problems.hidden = problemCount === 0
|
||||||
|
problems.replaceChildren(...(problemCount > 0 ? [alertIcon(), document.createTextNode(plural(labels, labels.outlineProblems, problemCount))] : []))
|
||||||
|
if (panel.hidden) return
|
||||||
|
|
||||||
|
const parts: HTMLElement[] = []
|
||||||
|
if (facts.headings.length === 0) {
|
||||||
|
const empty = document.createElement('p')
|
||||||
|
empty.className = 'blogeditor__outline-empty'
|
||||||
|
empty.textContent = labels.outlineEmpty
|
||||||
|
parts.push(empty)
|
||||||
|
} else {
|
||||||
|
const base = host.allowsH1 ? 1 : 2
|
||||||
|
const list = document.createElement('ol')
|
||||||
|
list.className = 'blogeditor__outline-list'
|
||||||
|
facts.headings.forEach((h, i) => {
|
||||||
|
const item = document.createElement('li')
|
||||||
|
item.className = 'blogeditor__outline-item'
|
||||||
|
item.style.setProperty('--blogeditor-outline-depth', String(Math.max(0, h.level - base)))
|
||||||
|
const target = document.createElement('button')
|
||||||
|
target.type = 'button'
|
||||||
|
target.className = 'blogeditor__outline-target'
|
||||||
|
const tag = document.createElement('span')
|
||||||
|
tag.className = 'blogeditor__outline-level'
|
||||||
|
tag.textContent = `H${h.level}`
|
||||||
|
const text = document.createElement('span')
|
||||||
|
text.className = h.text === '' ? 'blogeditor__outline-text blogeditor__outline-text--empty' : 'blogeditor__outline-text'
|
||||||
|
text.textContent = h.text === '' ? labels.flagEmpty : h.text
|
||||||
|
target.append(tag, text)
|
||||||
|
target.addEventListener('click', () => focusHeading(h))
|
||||||
|
item.append(target)
|
||||||
|
const shown = h.text === '' ? flags[i]!.filter((f) => f !== labels.flagEmpty) : flags[i]!
|
||||||
|
if (shown.length > 0) {
|
||||||
|
const line = document.createElement('span')
|
||||||
|
line.className = 'blogeditor__outline-flags'
|
||||||
|
line.append(...shown.map(flagLine))
|
||||||
|
item.append(line)
|
||||||
|
}
|
||||||
|
list.append(item)
|
||||||
|
})
|
||||||
|
parts.push(list)
|
||||||
|
}
|
||||||
|
if (generic.length > 0) {
|
||||||
|
const links = document.createElement('div')
|
||||||
|
links.className = 'blogeditor__outline-links'
|
||||||
|
const title = document.createElement('p')
|
||||||
|
title.className = 'blogeditor__outline-heading'
|
||||||
|
title.textContent = labels.outlineLinks
|
||||||
|
links.append(title, ...generic.map((t) => flagLine(format(labels.flagGeneric, { text: t.trim() }))))
|
||||||
|
parts.push(links)
|
||||||
|
}
|
||||||
|
panel.replaceChildren(...parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusHeading = (h: OutlineHeading) => {
|
||||||
|
const editor = host.editor()
|
||||||
|
if (!editor || h.pos < 0 || host.sourceText() !== null) return
|
||||||
|
editor.chain().focus().setTextSelection(h.pos + 1).scrollIntoView().run()
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
const source = host.sourceText()
|
||||||
|
const editor = host.editor()
|
||||||
|
if (source !== null) render(factsFromSource(source), true)
|
||||||
|
else if (editor) render(factsFromEditor(editor), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
schedule: () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(refresh, OUTLINE_DEBOUNCE_MS)
|
||||||
|
},
|
||||||
|
refresh,
|
||||||
|
stop: () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Editor } from '@tiptap/core'
|
import type { Editor } from '@tiptap/core'
|
||||||
import type { Labels } from './labels'
|
import type { Labels, TextLabel } from './labels'
|
||||||
|
|
||||||
/** What the toolbar needs from the element. */
|
/** What the toolbar needs from the element. */
|
||||||
export interface ToolbarHost {
|
export interface ToolbarHost {
|
||||||
@@ -12,6 +12,11 @@ export interface ToolbarHost {
|
|||||||
showSource: (on: boolean) => void
|
showSource: (on: boolean) => void
|
||||||
/** Switches the preview pane on or off. */
|
/** Switches the preview pane on or off. */
|
||||||
togglePreview: () => void
|
togglePreview: () => void
|
||||||
|
/**
|
||||||
|
* The heading levels the toolbar offers (the element's headingLevels
|
||||||
|
* option): 2 to 4, with 1 first when the H1 setting is off (J1).
|
||||||
|
*/
|
||||||
|
headingLevels: readonly number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Toolbar {
|
export interface Toolbar {
|
||||||
@@ -29,7 +34,7 @@ export interface Toolbar {
|
|||||||
interface ButtonSpec {
|
interface ButtonSpec {
|
||||||
command: string
|
command: string
|
||||||
glyph: string
|
glyph: string
|
||||||
label: keyof Labels
|
label: TextLabel
|
||||||
run: (editor: Editor, host: ToolbarHost) => void
|
run: (editor: Editor, host: ToolbarHost) => void
|
||||||
active?: (editor: Editor) => boolean
|
active?: (editor: Editor) => boolean
|
||||||
enabled?: (editor: Editor) => boolean
|
enabled?: (editor: Editor) => boolean
|
||||||
@@ -39,12 +44,32 @@ interface ButtonSpec {
|
|||||||
|
|
||||||
const inTable = (editor: Editor) => editor.isActive('table')
|
const inTable = (editor: Editor) => editor.isActive('table')
|
||||||
|
|
||||||
|
type Level = 1 | 2 | 3 | 4
|
||||||
|
|
||||||
|
/** headingButton is the toolbar button of one heading level. */
|
||||||
|
function headingButton(level: Level): ButtonSpec {
|
||||||
|
return {
|
||||||
|
command: `heading${level}`,
|
||||||
|
glyph: `H${level}`,
|
||||||
|
label: `heading${level}` as TextLabel,
|
||||||
|
run: (e) => e.chain().focus().toggleHeading({ level }).run(),
|
||||||
|
active: (e) => e.isActive('heading', { level }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** headingLevelOf is the level of a heading button's command, 0 otherwise. */
|
||||||
|
function headingLevelOf(command: string): number {
|
||||||
|
const m = /^heading([1-6])$/.exec(command)
|
||||||
|
return m ? Number(m[1]) : 0
|
||||||
|
}
|
||||||
|
|
||||||
const GROUPS: ButtonSpec[][] = [
|
const GROUPS: ButtonSpec[][] = [
|
||||||
[
|
[
|
||||||
{ command: 'paragraph', glyph: 'P', label: 'paragraph', run: (e) => e.chain().focus().setParagraph().run(), active: (e) => e.isActive('paragraph') },
|
{ command: 'paragraph', glyph: 'P', label: 'paragraph', run: (e) => e.chain().focus().setParagraph().run(), active: (e) => e.isActive('paragraph') },
|
||||||
{ command: 'heading2', glyph: 'H2', label: 'heading2', run: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(), active: (e) => e.isActive('heading', { level: 2 }) },
|
headingButton(1),
|
||||||
{ command: 'heading3', glyph: 'H3', label: 'heading3', run: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(), active: (e) => e.isActive('heading', { level: 3 }) },
|
headingButton(2),
|
||||||
{ command: 'heading4', glyph: 'H4', label: 'heading4', run: (e) => e.chain().focus().toggleHeading({ level: 4 }).run(), active: (e) => e.isActive('heading', { level: 4 }) },
|
headingButton(3),
|
||||||
|
headingButton(4),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{ command: 'bold', glyph: 'B', label: 'bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
|
{ command: 'bold', glyph: 'B', label: 'bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
|
||||||
@@ -77,7 +102,8 @@ const GROUPS: ButtonSpec[][] = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* buildToolbar builds the formatting toolbar and the WYSIWYG/Source tabs
|
* buildToolbar builds the formatting toolbar and the WYSIWYG/Source tabs
|
||||||
* (D-04): paragraph and headings 2 to 4, bold, italic, link, lists,
|
* (D-04): paragraph and the offered heading levels (2 to 4, with 1 first
|
||||||
|
* when the H1 setting is off, J1), bold, italic, link, lists,
|
||||||
* blockquote, tables, image, embed, raw HTML block, undo and redo, plus the
|
* blockquote, tables, image, embed, raw HTML block, undo and redo, plus the
|
||||||
* preview toggle.
|
* preview toggle.
|
||||||
*/
|
*/
|
||||||
@@ -96,6 +122,8 @@ export function buildToolbar(host: ToolbarHost): Toolbar {
|
|||||||
const wrap = document.createElement('div')
|
const wrap = document.createElement('div')
|
||||||
wrap.className = 'blogeditor__group'
|
wrap.className = 'blogeditor__group'
|
||||||
for (const spec of group) {
|
for (const spec of group) {
|
||||||
|
const level = headingLevelOf(spec.command)
|
||||||
|
if (level > 0 && !host.headingLevels.includes(level)) continue
|
||||||
const button = document.createElement('button')
|
const button = document.createElement('button')
|
||||||
button.type = 'button'
|
button.type = 'button'
|
||||||
button.dataset.command = spec.command
|
button.dataset.command = spec.command
|
||||||
|
|||||||
@@ -236,14 +236,14 @@ func TestCategoriesAdminBlogEditor(t *testing.T) {
|
|||||||
if err := json.Unmarshal(adminDecode(t, rec).Data, &result); err != nil {
|
if err := json.Unmarshal(adminDecode(t, rec).Data, &result); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.URL != attach.PublicURL("journal/categories/ok-adka.png") {
|
if result.URL != attach.PublicURL("journal/categories/okladka.png") {
|
||||||
t.Fatalf("url = %q", result.URL)
|
t.Fatalf("url = %q", result.URL)
|
||||||
}
|
}
|
||||||
bucket, ok := env.app.Lookup[*blob.Bucket]()
|
bucket, ok := env.app.Lookup[*blob.Bucket]()
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("no media bucket")
|
t.Fatal("no media bucket")
|
||||||
}
|
}
|
||||||
if data, err := bucket.ReadAll(t.Context(), "journal/categories/ok-adka.png"); err != nil || !bytes.Equal(data, editorPNG) {
|
if data, err := bucket.ReadAll(t.Context(), "journal/categories/okladka.png"); err != nil || !bytes.Equal(data, editorPNG) {
|
||||||
t.Fatalf("stored upload: %v", err)
|
t.Fatalf("stored upload: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"gocloud.dev/blob"
|
"gocloud.dev/blob"
|
||||||
xhtml "golang.org/x/net/html"
|
xhtml "golang.org/x/net/html"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BlogEditorElement is the custom element of the blogeditor and mlblogeditor
|
// BlogEditorElement is the custom element of the blogeditor and mlblogeditor
|
||||||
@@ -31,6 +34,12 @@ const (
|
|||||||
editorRemovedAttributes = "golem15.journal::lang.editor.removed_attributes"
|
editorRemovedAttributes = "golem15.journal::lang.editor.removed_attributes"
|
||||||
editorRemovedComments = "golem15.journal::lang.editor.removed_comments"
|
editorRemovedComments = "golem15.journal::lang.editor.removed_comments"
|
||||||
editorMarkupChanged = "golem15.journal::lang.editor.markup_changed"
|
editorMarkupChanged = "golem15.journal::lang.editor.markup_changed"
|
||||||
|
// EditorH1Converted is the notice of a save that turned H1 headings
|
||||||
|
// into H2 (Count is the number of headings).
|
||||||
|
EditorH1Converted = "golem15.journal::lang.editor.h1_converted"
|
||||||
|
// EditorH1ConvertedCategory is EditorH1Converted for a category's
|
||||||
|
// pillar text, which names the category name instead of the post title.
|
||||||
|
EditorH1ConvertedCategory = "golem15.journal::lang.editor.h1_converted_category"
|
||||||
)
|
)
|
||||||
|
|
||||||
// blogListValueRunes is how many runes of text a list column shows.
|
// blogListValueRunes is how many runes of text a list column shows.
|
||||||
@@ -79,6 +88,8 @@ var (
|
|||||||
// when unset).
|
// when unset).
|
||||||
func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
||||||
upload := uploadBlogImage(app)
|
upload := uploadBlogImage(app)
|
||||||
|
decode := decodeBlogHTMLFor(app)
|
||||||
|
options := blogRequestOptions(app)
|
||||||
return []pact.FormFieldType{
|
return []pact.FormFieldType{
|
||||||
{
|
{
|
||||||
Name: "blogeditor",
|
Name: "blogeditor",
|
||||||
@@ -87,10 +98,11 @@ func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
|||||||
Styles: []string{"assets/dist/blogeditor.css"},
|
Styles: []string{"assets/dist/blogeditor.css"},
|
||||||
Options: []string{"uploadFolder"},
|
Options: []string{"uploadFolder"},
|
||||||
CheckOptions: checkBlogOptions,
|
CheckOptions: checkBlogOptions,
|
||||||
Decode: decodeBlogHTML,
|
Decode: decode,
|
||||||
Load: loadBlogHTML,
|
Load: loadBlogHTML,
|
||||||
ListValue: blogListValue,
|
ListValue: blogListValue,
|
||||||
Upload: upload,
|
Upload: upload,
|
||||||
|
RequestOptions: options,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "mlblogeditor",
|
Name: "mlblogeditor",
|
||||||
@@ -100,10 +112,11 @@ func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
|||||||
Styles: []string{"assets/dist/blogeditor.css"},
|
Styles: []string{"assets/dist/blogeditor.css"},
|
||||||
Options: []string{"htmlColumn", "uploadFolder"},
|
Options: []string{"htmlColumn", "uploadFolder"},
|
||||||
CheckOptions: checkBlogOptions,
|
CheckOptions: checkBlogOptions,
|
||||||
Decode: decodeBlogHTML,
|
Decode: decode,
|
||||||
Load: loadBlogHTML,
|
Load: loadBlogHTML,
|
||||||
ListValue: blogListValue,
|
ListValue: blogListValue,
|
||||||
Upload: upload,
|
Upload: upload,
|
||||||
|
RequestOptions: options,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,6 +159,77 @@ func checkUploadFolder(folder string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeBlogHTMLFor is the Blog Editor types' Decode: decodeBlogHTML, then,
|
||||||
|
// while the H1 setting is on (EffectiveH1ToH2 on the save transaction and
|
||||||
|
// the booted application's config), ConvertH1 on the stored HTML with a
|
||||||
|
// notice counting the converted headings: EditorH1ConvertedCategory on a
|
||||||
|
// category's pillar text, EditorH1Converted otherwise. app may be nil
|
||||||
|
// or answer nil; the setting is then read from the database alone.
|
||||||
|
func decodeBlogHTMLFor(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||||
|
return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||||
|
out, err := decodeBlogHTML(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
text, isString := out.Value.(string)
|
||||||
|
if !isString || text == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if !EffectiveH1ToH2(ctx, in.DB, appConfig(app)) {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
converted, n := ConvertH1(text)
|
||||||
|
if n == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
out.Value = converted
|
||||||
|
out.Notices = append(out.Notices, pact.FormFieldNotice{Message: h1ConvertedNotice(in.Record), Count: n})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// h1ConvertedNotice is the conversion notice for the saved record: the
|
||||||
|
// category phrase for a category, the post phrase otherwise.
|
||||||
|
func h1ConvertedNotice(record any) string {
|
||||||
|
switch record.(type) {
|
||||||
|
case *models.Category, models.Category:
|
||||||
|
return EditorH1ConvertedCategory
|
||||||
|
}
|
||||||
|
return EditorH1Converted
|
||||||
|
}
|
||||||
|
|
||||||
|
// blogRequestOptions is the Blog Editor types' RequestOptions (33-01
|
||||||
|
// P33-02, UI-SPEC C28): headingLevels, [2, 3, 4] while the H1 setting is on
|
||||||
|
// and [1, 2, 3, 4] while it is off (J1), and genericAnchors, the settings'
|
||||||
|
// generic link texts (J2, J3). Both are read per schema request, so a
|
||||||
|
// changed setting reaches the next form that opens. db may be nil; the
|
||||||
|
// defaults then apply. fields.yaml options with the same names win.
|
||||||
|
func blogRequestOptions(app func() *backpack.App) func(context.Context, *gorm.DB) (map[string]any, error) {
|
||||||
|
return func(ctx context.Context, db *gorm.DB) (map[string]any, error) {
|
||||||
|
levels := []int{2, 3, 4}
|
||||||
|
if !EffectiveH1ToH2(ctx, db, appConfig(app)) {
|
||||||
|
levels = []int{1, 2, 3, 4}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"headingLevels": levels,
|
||||||
|
"genericAnchors": GenericAnchors(ctx, db),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appConfig is the config of the application app answers, nil when there
|
||||||
|
// is none.
|
||||||
|
func appConfig(app func() *backpack.App) *compass.Config {
|
||||||
|
if app == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a := app()
|
||||||
|
if a == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.Config
|
||||||
|
}
|
||||||
|
|
||||||
// decodeBlogHTML stores the HTML policy's output for a submitted value: the
|
// decodeBlogHTML stores the HTML policy's output for a submitted value: the
|
||||||
// exact submitted bytes with no notice when the policy keeps everything,
|
// exact submitted bytes with no notice when the policy keeps everything,
|
||||||
// otherwise the stripped HTML with notices naming what was removed. nil
|
// otherwise the stripped HTML with notices naming what was removed. nil
|
||||||
@@ -389,38 +473,9 @@ func uploadBlogImage(app func() *backpack.App) func(context.Context, pact.FormFi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeUploadName is the stored name of an editor upload: the base name,
|
// safeUploadName is the stored name of an editor upload: MediaFileName of
|
||||||
// lowercased, every character outside a-z, 0-9, '.', '_' and '-' turned
|
// its base name (D-85), so it needs no escaping in an src attribute. An
|
||||||
// into '-' with runs collapsed and no leading or trailing '-' or '.', and
|
// empty name becomes "image".
|
||||||
// the extension kept. An empty name becomes "image". The result needs no
|
|
||||||
// escaping in an src attribute.
|
|
||||||
func safeUploadName(filename string) string {
|
func safeUploadName(filename string) string {
|
||||||
base := path.Base(strings.ReplaceAll(filename, `\`, "/"))
|
return MediaFileName(filename)
|
||||||
ext := strings.ToLower(path.Ext(base))
|
|
||||||
if !blogUploadExtPattern.MatchString(ext) {
|
|
||||||
ext = ""
|
|
||||||
}
|
|
||||||
stem := strings.ToLower(strings.TrimSuffix(base, path.Ext(base)))
|
|
||||||
if ext == "" {
|
|
||||||
stem = strings.ToLower(base)
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
dash := false
|
|
||||||
for _, r := range stem {
|
|
||||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
|
|
||||||
if !ok || r == '-' {
|
|
||||||
if !dash {
|
|
||||||
b.WriteByte('-')
|
|
||||||
dash = true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteRune(r)
|
|
||||||
dash = false
|
|
||||||
}
|
|
||||||
stem = strings.Trim(b.String(), "-.")
|
|
||||||
if stem == "" {
|
|
||||||
stem = "image"
|
|
||||||
}
|
|
||||||
return stem + ext
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -699,17 +699,17 @@ func TestBlogEditorUpload(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("names are made safe", func(t *testing.T) {
|
t.Run("names are made safe", func(t *testing.T) {
|
||||||
for in, want := range map[string]string{
|
for in, want := range map[string]string{
|
||||||
"Zdjęcie 1.PNG": "zdj-cie-1.png",
|
"Zdjęcie 1.PNG": "zdjecie-1.png",
|
||||||
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
|
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
|
||||||
"../../etc/passwd.png": "passwd.png",
|
"../../etc/passwd.png": "passwd.png",
|
||||||
"...": "image",
|
"...": "image",
|
||||||
"": "image",
|
"": "image",
|
||||||
"-.-a-.png": "a.png",
|
"-.-a-.png": "a.png",
|
||||||
"a--b__c.webp": "a-b__c.webp",
|
"a--b__c.webp": "a-b-c.webp",
|
||||||
"<script>.gif": "script.gif",
|
"<script>.gif": "script.gif",
|
||||||
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
|
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
|
||||||
"name.with.dots.jpeg": "name.with.dots.jpeg",
|
"name.with.dots.jpeg": "name-with-dots.jpeg",
|
||||||
"weird.ext$": "weird.ext",
|
"weird.ext$": "weird-ext",
|
||||||
} {
|
} {
|
||||||
if got := safeUploadName(in); got != want {
|
if got := safeUploadName(in); got != want {
|
||||||
t.Fatalf("safeUploadName(%q) = %q, want %q", in, got, want)
|
t.Fatalf("safeUploadName(%q) = %q, want %q", in, got, want)
|
||||||
|
|||||||
860
classes/content_descriptors.go
Normal file
860
classes/content_descriptors.go
Normal file
@@ -0,0 +1,860 @@
|
|||||||
|
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/compass"
|
||||||
|
"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, m.facts(ctx, db))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &records[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// descriptorFacts are the site settings the descriptors' facts follow.
|
||||||
|
type descriptorFacts struct {
|
||||||
|
h1ToH2 bool // EffectiveH1ToH2: TopHeading is 2, else 0
|
||||||
|
authorPages bool // AuthorPagesKey: authors carry a journal-author reference
|
||||||
|
}
|
||||||
|
|
||||||
|
// facts reads the settings the descriptors follow, on db.
|
||||||
|
func (m menuTypes) facts(ctx context.Context, db *gorm.DB) descriptorFacts {
|
||||||
|
var cfg *compass.Config
|
||||||
|
if m.app != nil {
|
||||||
|
if app := m.app(); app != nil {
|
||||||
|
cfg = app.Config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return descriptorFacts{h1ToH2: EffectiveH1ToH2(ctx, db, cfg), authorPages: m.authorPages()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. The record carries its first publication, its categories by
|
||||||
|
// name (Parents), its author (with a journal-author reference while author
|
||||||
|
// pages are on) and TopHeading (2 while the H1 setting is on, else 0); each
|
||||||
|
// locale lists the featured image (alt: the file's title, else the
|
||||||
|
// translated post title, P33-06) and the body's images, YouTube embeds and
|
||||||
|
// headings (parseContentFacts). A locale in which the post has no content
|
||||||
|
// (no translated title, content, content_html or excerpt, P33-64) keeps its
|
||||||
|
// entry with URL "" (no public page, D-57). The queries are batched per
|
||||||
|
// page.
|
||||||
|
func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, pattern, unprefixed string, facts descriptorFacts) ([]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
|
||||||
|
}
|
||||||
|
featured, err := firstFiles(ctx, db, models.Post{}.MorphName(), "featured_images", ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parents, err := postCategoryRefs(ctx, db, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
userIDs := []uint{}
|
||||||
|
for _, p := range posts {
|
||||||
|
if p.UserID != nil {
|
||||||
|
userIDs = append(userIDs, *p.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authors, err := loadAuthors(ctx, db, userIDs)
|
||||||
|
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),
|
||||||
|
PublishedAt: copyTime(p.PublishedAt),
|
||||||
|
Parents: parents[p.ID],
|
||||||
|
TopHeading: topHeading(facts.h1ToH2),
|
||||||
|
}
|
||||||
|
if p.UserID != nil {
|
||||||
|
if a, ok := authors[*p.UserID]; ok {
|
||||||
|
person := pact.ContentPerson{Name: a.name()}
|
||||||
|
if facts.authorPages {
|
||||||
|
person.Ref = pact.ContentRef{Type: MenuTypeAuthor, ID: strconv.FormatUint(uint64(a.ID), 10)}
|
||||||
|
}
|
||||||
|
rec.Authors = []pact.ContentPerson{person}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file, hasFile := featured[p.ID]
|
||||||
|
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))
|
||||||
|
found := parseContentFacts(body)
|
||||||
|
var images []pact.ContentImage
|
||||||
|
image := ""
|
||||||
|
if hasFile {
|
||||||
|
image = file.URL()
|
||||||
|
altText := alt.Title
|
||||||
|
if file.Title != nil && strings.TrimSpace(*file.Title) != "" {
|
||||||
|
altText = strings.TrimSpace(*file.Title)
|
||||||
|
}
|
||||||
|
images = append(images, pact.ContentImage{URL: image, Alt: altText})
|
||||||
|
}
|
||||||
|
images = append(images, found.images...)
|
||||||
|
url := alt.URL
|
||||||
|
if !tr.hasContent(locales, alt.Locale, p.ID, postContentFields) {
|
||||||
|
// No translation in this locale (P33-64, D-57): no public
|
||||||
|
// page for it, so no hreflang or sitemap address.
|
||||||
|
url = ""
|
||||||
|
}
|
||||||
|
rec.Locales = append(rec.Locales, pact.ContentLocale{
|
||||||
|
Locale: alt.Locale,
|
||||||
|
URL: url,
|
||||||
|
Title: alt.Title,
|
||||||
|
Description: strings.TrimSpace(plainText(tr.value(locale, p.ID, "excerpt", p.Excerpt))),
|
||||||
|
Image: image,
|
||||||
|
Words: &words,
|
||||||
|
Images: images,
|
||||||
|
Videos: found.videos,
|
||||||
|
Headings: found.headings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
files, err := firstFiles(ctx, db, morph, field, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[uint]string, len(files))
|
||||||
|
for id, f := range files {
|
||||||
|
out[id] = f.URL()
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstFiles maps each record id to its first file in field (sort order,
|
||||||
|
// then id).
|
||||||
|
func firstFiles(ctx context.Context, db *gorm.DB, morph, field string, ids []uint) (map[uint]attach.File, error) {
|
||||||
|
out := map[uint]attach.File{}
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorPagesKey is the host configuration key that says whether the site
|
||||||
|
// has author pages (default true). With false, journal-author items resolve
|
||||||
|
// to nothing, author descriptions carry empty URLs, author paths never
|
||||||
|
// match and post authors carry no journal-author reference.
|
||||||
|
const AuthorPagesKey = "golem15.journal.author_pages"
|
||||||
|
|
||||||
|
// authorPages reports AuthorPagesKey, true when unset.
|
||||||
|
func (m menuTypes) authorPages() bool {
|
||||||
|
if m.app == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
app := m.app()
|
||||||
|
if app == nil || app.Config == nil || !app.Config.Has(AuthorPagesKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return app.Config.Bool(AuthorPagesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, m.facts(ctx, db))
|
||||||
|
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, m.facts(ctx, db))
|
||||||
|
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, m.facts(ctx, db))
|
||||||
|
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. A child
|
||||||
|
// category's Parents is its parent; every locale carries the pillar text's
|
||||||
|
// heading outline (content is not translatable) and TopHeading follows the
|
||||||
|
// H1 setting (2 on, 0 off), so the pillar text is checked as post content
|
||||||
|
// is (CONTEXT 2026-10-28 H1 conversion scope).
|
||||||
|
// A locale in which the category has no translated name or description
|
||||||
|
// (P33-64) gets URL "".
|
||||||
|
func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category, pattern, unprefixed string, facts descriptorFacts) ([]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),
|
||||||
|
TopHeading: topHeading(facts.h1ToH2)}
|
||||||
|
if c.ParentID != nil && *c.ParentID != 0 && *c.ParentID != c.ID {
|
||||||
|
rec.Parents = []pact.ContentRef{{Type: MenuTypeCategory, ID: strconv.FormatUint(uint64(*c.ParentID), 10)}}
|
||||||
|
}
|
||||||
|
var words *int
|
||||||
|
var headings []pact.ContentHeading
|
||||||
|
if c.Content != nil && strings.TrimSpace(*c.Content) != "" {
|
||||||
|
n := wordCount(plainText(*c.Content))
|
||||||
|
words = &n
|
||||||
|
headings = parseContentFacts(*c.Content).headings
|
||||||
|
}
|
||||||
|
host := ""
|
||||||
|
if c.Description != nil {
|
||||||
|
host = *c.Description
|
||||||
|
}
|
||||||
|
for _, alt := range item.Alternates {
|
||||||
|
locale := alt.Locale
|
||||||
|
if locale == xDefaultLocale {
|
||||||
|
locale = ""
|
||||||
|
}
|
||||||
|
url := alt.URL
|
||||||
|
if !ci.tr.hasContent(ci.locales, alt.Locale, c.ID, categoryContentFields) {
|
||||||
|
url = ""
|
||||||
|
}
|
||||||
|
rec.Locales = append(rec.Locales, pact.ContentLocale{
|
||||||
|
Locale: alt.Locale,
|
||||||
|
URL: url,
|
||||||
|
Title: alt.Title,
|
||||||
|
Description: plainText(ci.tr.value(locale, c.ID, "description", host)),
|
||||||
|
Words: words,
|
||||||
|
Headings: headings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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, and
|
||||||
|
// in a locale in which none of the tag's published posts has content
|
||||||
|
// (P33-64).
|
||||||
|
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()
|
||||||
|
ids := make([]uint, 0, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
ids = append(ids, tag.ID)
|
||||||
|
}
|
||||||
|
content, err := tagContentLocales(ctx, db, locales, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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 || !ownerHasContent(locales, content, tag.ID, alt.Locale) {
|
||||||
|
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
|
||||||
|
}
|
||||||
211
classes/content_facts.go
Normal file
211
classes/content_facts.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
xhtml "golang.org/x/net/html"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The MCP tools a Journal content descriptor names (pact.ContentDescriptor
|
||||||
|
// Tools, D-96): Journal fixes its own content.
|
||||||
|
const (
|
||||||
|
toolUpdatePost = "journal_update_post"
|
||||||
|
toolFixHeadings = "journal_fix_headings"
|
||||||
|
toolSetImageAlt = "journal_set_image_alt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contentFacts is what one locale's body says about itself: its images
|
||||||
|
// (src, alt, decorative, size attributes), its YouTube embeds and its
|
||||||
|
// heading outline, each in document order.
|
||||||
|
type contentFacts struct {
|
||||||
|
images []pact.ContentImage
|
||||||
|
videos []pact.ContentVideo
|
||||||
|
headings []pact.ContentHeading
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseContentFacts reads html once with the x/net/html tokenizer. An img
|
||||||
|
// without src is skipped; an img whose alt attribute is present and empty
|
||||||
|
// is decorative. A figure with data-embed="youtube" and a data-id is a
|
||||||
|
// video (the Blog Editor's embed placeholder). A heading's text is its
|
||||||
|
// text content with white space collapsed; a heading inside a heading is
|
||||||
|
// read as part of the outer one.
|
||||||
|
func parseContentFacts(html string) contentFacts {
|
||||||
|
var out contentFacts
|
||||||
|
if strings.TrimSpace(html) == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(html))
|
||||||
|
level := 0
|
||||||
|
var text strings.Builder
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
if tt == xhtml.ErrorToken {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch tt {
|
||||||
|
case xhtml.TextToken:
|
||||||
|
if level > 0 {
|
||||||
|
text.WriteString(z.Token().Data)
|
||||||
|
}
|
||||||
|
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
|
||||||
|
tok := z.Token()
|
||||||
|
switch tok.Data {
|
||||||
|
case "img":
|
||||||
|
if img, ok := contentImage(tok); ok {
|
||||||
|
out.images = append(out.images, img)
|
||||||
|
}
|
||||||
|
case "figure":
|
||||||
|
if attr(tok, "data-embed") == "youtube" {
|
||||||
|
if id := attr(tok, "data-id"); id != "" {
|
||||||
|
out.videos = append(out.videos, pact.ContentVideo{Provider: "youtube", ID: id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "h1", "h2", "h3", "h4", "h5", "h6":
|
||||||
|
if level == 0 && tt == xhtml.StartTagToken {
|
||||||
|
level = int(tok.Data[1] - '0')
|
||||||
|
text.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if level > 0 && tt == xhtml.StartTagToken && !inlineListTags[tok.Data] && !isHeading(tok.Data) {
|
||||||
|
text.WriteByte(' ')
|
||||||
|
}
|
||||||
|
case xhtml.EndTagToken:
|
||||||
|
tok := z.Token()
|
||||||
|
if level > 0 && tok.Data == "h"+strconv.Itoa(level) {
|
||||||
|
out.headings = append(out.headings, pact.ContentHeading{
|
||||||
|
Level: level,
|
||||||
|
Text: strings.Join(strings.Fields(text.String()), " "),
|
||||||
|
})
|
||||||
|
level = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHeading(name string) bool {
|
||||||
|
return len(name) == 2 && name[0] == 'h' && name[1] >= '1' && name[1] <= '6'
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentImage(tok xhtml.Token) (pact.ContentImage, bool) {
|
||||||
|
src := strings.TrimSpace(attr(tok, "src"))
|
||||||
|
if src == "" {
|
||||||
|
return pact.ContentImage{}, false
|
||||||
|
}
|
||||||
|
img := pact.ContentImage{URL: src}
|
||||||
|
if alt, has := attrOK(tok, "alt"); has {
|
||||||
|
img.Alt = strings.TrimSpace(alt)
|
||||||
|
img.Decorative = img.Alt == ""
|
||||||
|
}
|
||||||
|
img.Width, _ = strconv.Atoi(strings.TrimSpace(attr(tok, "width")))
|
||||||
|
img.Height, _ = strconv.Atoi(strings.TrimSpace(attr(tok, "height")))
|
||||||
|
if img.Width < 0 {
|
||||||
|
img.Width = 0
|
||||||
|
}
|
||||||
|
if img.Height < 0 {
|
||||||
|
img.Height = 0
|
||||||
|
}
|
||||||
|
return img, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func attr(tok xhtml.Token, key string) string {
|
||||||
|
v, _ := attrOK(tok, key)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func attrOK(tok xhtml.Token, key string) (string, bool) {
|
||||||
|
for _, a := range tok.Attr {
|
||||||
|
if a.Namespace == "" && a.Key == key {
|
||||||
|
return a.Val, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// topHeading is a record's TopHeading: 2 while the H1 setting is on (the
|
||||||
|
// page title is the H1), 0 (any level) while it is off.
|
||||||
|
func topHeading(h1ToH2 bool) int {
|
||||||
|
if h1ToH2 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// postCategoryRefs maps each post id to its categories as journal-category
|
||||||
|
// references, by category name, ties by id (the order :category takes its
|
||||||
|
// first category in).
|
||||||
|
func postCategoryRefs(ctx context.Context, db *gorm.DB, ids []uint) (map[uint][]pact.ContentRef, error) {
|
||||||
|
out := map[uint][]pact.ContentRef{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
PostID uint `gorm:"column:post_id"`
|
||||||
|
CategoryID uint `gorm:"column:category_id"`
|
||||||
|
}
|
||||||
|
err := db.WithContext(ctx).Table("golem15_journal_posts_categories AS pc").
|
||||||
|
Select("pc.post_id, pc.category_id").
|
||||||
|
Joins("JOIN golem15_journal_categories c ON c.id = pc.category_id").
|
||||||
|
Where("pc.post_id IN ?", ids).
|
||||||
|
Order("pc.post_id ASC, c.name ASC, c.id ASC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.PostID] = append(out[r.PostID], pact.ContentRef{Type: MenuTypeCategory, ID: strconv.FormatUint(uint64(r.CategoryID), 10)})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorRow is the part of a backend user a descriptor shows.
|
||||||
|
type authorRow struct {
|
||||||
|
ID uint `gorm:"column:id"`
|
||||||
|
FirstName string `gorm:"column:first_name"`
|
||||||
|
LastName string `gorm:"column:last_name"`
|
||||||
|
Login string `gorm:"column:login"`
|
||||||
|
AuthorSlug string `gorm:"column:author_slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorColumns is the only projection descriptors read from backend_users:
|
||||||
|
// no contact, credential or permission column.
|
||||||
|
const authorColumns = "id, COALESCE(first_name, '') AS first_name, COALESCE(last_name, '') AS last_name, " +
|
||||||
|
"login, COALESCE(golem15_bloghub_author_slug, '') AS author_slug"
|
||||||
|
|
||||||
|
// name is the author's display name: first and last name, else the login.
|
||||||
|
func (a authorRow) name() string {
|
||||||
|
if n := strings.TrimSpace(strings.TrimSpace(a.FirstName) + " " + strings.TrimSpace(a.LastName)); n != "" {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return a.Login
|
||||||
|
}
|
||||||
|
|
||||||
|
// slug is the :author value: the author slug, else the login (the author
|
||||||
|
// API's own lookup order).
|
||||||
|
func (a authorRow) slug() string {
|
||||||
|
if a.AuthorSlug != "" {
|
||||||
|
return a.AuthorSlug
|
||||||
|
}
|
||||||
|
return a.Login
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAuthors reads the live backend users with the given ids.
|
||||||
|
func loadAuthors(ctx context.Context, db *gorm.DB, ids []uint) (map[uint]authorRow, error) {
|
||||||
|
out := map[uint]authorRow{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var rows []authorRow
|
||||||
|
if err := db.WithContext(ctx).Table("backend_users").Select(authorColumns).
|
||||||
|
Where("deleted_at IS NULL AND id IN ?", ids).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.ID] = r
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
69
classes/content_facts_test.go
Normal file
69
classes/content_facts_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParseContentFacts pins what a body says about itself: images with
|
||||||
|
// alt, decorative (alt present and empty) and size attributes (negative
|
||||||
|
// and invalid sizes 0), an img without src skipped; YouTube embeds of the
|
||||||
|
// editor's figure; the heading outline with collapsed text, inline tags
|
||||||
|
// joined, block children spaced, a no-break space collapsed like any
|
||||||
|
// other, a heading inside a heading read as part
|
||||||
|
// of the outer one.
|
||||||
|
func TestParseContentFacts(t *testing.T) {
|
||||||
|
f := parseContentFacts(`<h2>Lwia <em>grzywa</em></h2>
|
||||||
|
<p><img src=" /m/a.jpg " alt=" Lion's mane " width="800" height="-1"><img src="/m/line.png" alt=""><img alt="no src"><img src="/m/b.jpg" width="x"></p>
|
||||||
|
<figure data-embed="youtube" data-id="abc123"></figure><figure data-embed="youtube"></figure><figure data-embed="vimeo" data-id="9"></figure>
|
||||||
|
<h3>Dawkowanie<br>i forma</h3><h4><span>Outer <h5>inner</h5> tail</span></h4><h2></h2>`)
|
||||||
|
wantImages := []pact.ContentImage{
|
||||||
|
{URL: "/m/a.jpg", Alt: "Lion's mane", Width: 800},
|
||||||
|
{URL: "/m/line.png", Decorative: true},
|
||||||
|
{URL: "/m/b.jpg"},
|
||||||
|
}
|
||||||
|
if len(f.images) != len(wantImages) {
|
||||||
|
t.Fatalf("images = %+v", f.images)
|
||||||
|
}
|
||||||
|
for i, w := range wantImages {
|
||||||
|
if f.images[i] != w {
|
||||||
|
t.Errorf("image %d = %+v, want %+v", i, f.images[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.videos) != 1 || f.videos[0] != (pact.ContentVideo{Provider: "youtube", ID: "abc123"}) {
|
||||||
|
t.Fatalf("videos = %+v", f.videos)
|
||||||
|
}
|
||||||
|
wantHeadings := []pact.ContentHeading{{Level: 2, Text: "Lwia grzywa"}, {Level: 3, Text: "Dawkowanie i forma"}, {Level: 4, Text: "Outer inner tail"}, {Level: 2, Text: ""}}
|
||||||
|
if len(f.headings) != len(wantHeadings) {
|
||||||
|
t.Fatalf("headings = %+v", f.headings)
|
||||||
|
}
|
||||||
|
for i, w := range wantHeadings {
|
||||||
|
if f.headings[i] != w {
|
||||||
|
t.Errorf("heading %d = %+v, want %+v", i, f.headings[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseContentFactsEmpty pins an empty body and the category pillar
|
||||||
|
// text read the same way (headings only).
|
||||||
|
func TestParseContentFactsEmpty(t *testing.T) {
|
||||||
|
if f := parseContentFacts(" "); f.images != nil || f.videos != nil || f.headings != nil {
|
||||||
|
t.Fatalf("blank = %+v", f)
|
||||||
|
}
|
||||||
|
pillar := parseContentFacts("<p>Intro</p><h2>Gatunki</h2><h4>Skipped</h4>")
|
||||||
|
if len(pillar.headings) != 2 || pillar.headings[1].Level != 4 {
|
||||||
|
t.Fatalf("pillar = %+v", pillar.headings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTopHeading pins the record's highest heading level: 2 while the
|
||||||
|
// H1 setting turns body H1s into H2s, 0 (any) otherwise.
|
||||||
|
func TestTopHeading(t *testing.T) {
|
||||||
|
if topHeading(true) != 2 || topHeading(false) != 0 {
|
||||||
|
t.Fatal("topHeading is wrong")
|
||||||
|
}
|
||||||
|
if toolUpdatePost != "journal_update_post" || toolFixHeadings != "journal_fix_headings" || toolSetImageAlt != "journal_set_image_alt" {
|
||||||
|
t.Fatal("the descriptor's tool names changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,8 +63,9 @@ func tagAttrs(doc, tag string) [][]string {
|
|||||||
|
|
||||||
// TestEditorOutputPassesPolicy pins Phase 16.2 D-05 (amended): the HTML
|
// TestEditorOutputPassesPolicy pins Phase 16.2 D-05 (amended): the HTML
|
||||||
// policy keeps every piece of markup the Blog Editor writes, byte for byte
|
// policy keeps every piece of markup the Blog Editor writes, byte for byte
|
||||||
// and with an empty removal report, and its four editor-output additions
|
// and with an empty removal report, and its editor-output additions
|
||||||
// (figure data-embed and data-id, img alt, ol start) admit nothing else.
|
// (figure data-embed and data-id, img alt, ol start, and since Phase 33 a rel
|
||||||
|
// of nofollow, sponsored and ugc tokens on a) admit nothing else.
|
||||||
func TestEditorOutputPassesPolicy(t *testing.T) {
|
func TestEditorOutputPassesPolicy(t *testing.T) {
|
||||||
t.Run("corpus is the editor's full output set", func(t *testing.T) {
|
t.Run("corpus is the editor's full output set", func(t *testing.T) {
|
||||||
corpus := editorCorpus(t)
|
corpus := editorCorpus(t)
|
||||||
@@ -78,7 +79,7 @@ func TestEditorOutputPassesPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
names[e.Name] = true
|
names[e.Name] = true
|
||||||
}
|
}
|
||||||
for _, want := range []string{"embed-youtube", "image-with-caption", "image-without-caption", "ordered-list-start-3", "table-header-row", "links", "combined"} {
|
for _, want := range []string{"embed-youtube", "image-with-caption", "image-without-caption", "ordered-list-start-3", "table-header-row", "links", "link-rel", "image-decorative", "combined"} {
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Fatalf("corpus has no %s entry", want)
|
t.Fatalf("corpus has no %s entry", want)
|
||||||
}
|
}
|
||||||
@@ -143,6 +144,12 @@ func TestEditorOutputPassesPolicy(t *testing.T) {
|
|||||||
{"embed attributes on section", `<section data-embed="youtube" data-id="dQw4w9WgXcQ">x</section>`, `<section>x</section>`},
|
{"embed attributes on section", `<section data-embed="youtube" data-id="dQw4w9WgXcQ">x</section>`, `<section>x</section>`},
|
||||||
{"embed attributes on a", `<a href="/x" data-embed="youtube" data-id="dQw4w9WgXcQ">x</a>`, `<a href="/x">x</a>`},
|
{"embed attributes on a", `<a href="/x" data-embed="youtube" data-id="dQw4w9WgXcQ">x</a>`, `<a href="/x">x</a>`},
|
||||||
{"start with a fraction", `<ol start="1.5"><li>a</li></ol>`, `<ol><li>a</li></ol>`},
|
{"start with a fraction", `<ol start="1.5"><li>a</li></ol>`, `<ol><li>a</li></ol>`},
|
||||||
|
// D-83: rel keeps only nofollow, sponsored and ugc, on a only.
|
||||||
|
{"rel noopener", `<a href="https://p.test/" rel="noopener">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||||
|
{"rel with another token", `<a href="https://p.test/" rel="nofollow noopener">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||||
|
{"rel with two spaces", `<a href="https://p.test/" rel="nofollow ugc">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||||
|
{"rel in upper case", `<a href="https://p.test/" rel="NOFOLLOW">x</a>`, `<a href="https://p.test/">x</a>`},
|
||||||
|
{"rel on p", `<p rel="nofollow">x</p>`, `<p>x</p>`},
|
||||||
}
|
}
|
||||||
for _, tc := range stripped {
|
for _, tc := range stripped {
|
||||||
t.Run("strips "+tc.name, func(t *testing.T) {
|
t.Run("strips "+tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ func newHTMLPolicy() *bluemonday.Policy {
|
|||||||
// TipTap's ordered-list input rule writes start when a list does not
|
// TipTap's ordered-list input rule writes start when a list does not
|
||||||
// begin at 1.
|
// begin at 1.
|
||||||
p.AllowAttrs("start").Matching(regexp.MustCompile(`^[0-9]+$`)).OnElements("ol")
|
p.AllowAttrs("start").Matching(regexp.MustCompile(`^[0-9]+$`)).OnElements("ol")
|
||||||
|
// The Blog Editor's link form writes rel on external links (Phase 33,
|
||||||
|
// D-83): only the nofollow, sponsored and ugc tokens, space-separated.
|
||||||
|
// Any other rel value is removed.
|
||||||
|
p.AllowAttrs("rel").Matching(regexp.MustCompile(`^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$`)).OnElements("a")
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
217
classes/heading_fix.go
Normal file
217
classes/heading_fix.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// H1ConversionOffText is the refusal of journal:fix-headings and of
|
||||||
|
// POST /_journal/api/v1/fix-headings while the H1 setting is off (UI-SPEC
|
||||||
|
// C27).
|
||||||
|
const H1ConversionOffText = "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings."
|
||||||
|
|
||||||
|
// ErrH1ConversionOff is returned by FixStoredHeadings callers that check
|
||||||
|
// the setting; its text is H1ConversionOffText.
|
||||||
|
var ErrH1ConversionOff = errors.New(H1ConversionOffText)
|
||||||
|
|
||||||
|
// headingFixBatch is how many posts one query of FixStoredHeadings reads.
|
||||||
|
const headingFixBatch = 200
|
||||||
|
|
||||||
|
// HeadingFix counts what FixStoredHeadings converted, or would convert:
|
||||||
|
// the posts and categories with at least one H1 and the H1 headings in
|
||||||
|
// them (default and translated post content, category pillar text).
|
||||||
|
type HeadingFix struct {
|
||||||
|
Posts int `json:"posts"`
|
||||||
|
Categories int `json:"categories"`
|
||||||
|
Headings int `json:"headings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FixStoredHeadings converts H1 to H2 in stored Journal rich text: post
|
||||||
|
// content and content_html in the default locale and in every translation,
|
||||||
|
// and category pillar text. Without apply it only counts. With apply it
|
||||||
|
// writes in one transaction, fires a RecordSaved per changed post and
|
||||||
|
// category after the commit (Controller empty) and queues the host site
|
||||||
|
// cache purge for each. It does not read the H1 setting; callers refuse
|
||||||
|
// while it is off (ErrH1ConversionOff). app may be nil (no events, no
|
||||||
|
// purge).
|
||||||
|
func FixStoredHeadings(ctx context.Context, app *backpack.App, db *gorm.DB, apply bool) (HeadingFix, error) {
|
||||||
|
if db == nil {
|
||||||
|
return HeadingFix{}, errors.New("journal: database is not configured")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if !apply {
|
||||||
|
return fixHeadings(ctx, nil, db.WithContext(ctx), false)
|
||||||
|
}
|
||||||
|
var out HeadingFix
|
||||||
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
out, err = fixHeadings(ctx, app, tx, true)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return HeadingFix{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixHeadings(ctx context.Context, app *backpack.App, db *gorm.DB, apply bool) (HeadingFix, error) {
|
||||||
|
var out HeadingFix
|
||||||
|
locale, _ := towel.Locale(ctx)
|
||||||
|
purger := cooler.SiteCacheFromApp(app)
|
||||||
|
morph := models.Post{}.MorphName()
|
||||||
|
|
||||||
|
var batch []models.Post
|
||||||
|
res := db.Model(&models.Post{}).Select("id", "slug", "content", "content_html").
|
||||||
|
FindInBatches(&batch, headingFixBatch, func(tx *gorm.DB, _ int) error {
|
||||||
|
ids := make([]string, 0, len(batch))
|
||||||
|
for _, p := range batch {
|
||||||
|
ids = append(ids, strconv.FormatUint(uint64(p.ID), 10))
|
||||||
|
}
|
||||||
|
var attrs []translatemodels.Attribute
|
||||||
|
if err := db.Model(&translatemodels.Attribute{}).
|
||||||
|
Where("model_type = ? AND model_id IN ?", morph, ids).
|
||||||
|
Order("locale ASC").Find(&attrs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byPost := map[string][]translatemodels.Attribute{}
|
||||||
|
for _, a := range attrs {
|
||||||
|
if a.ModelID != nil {
|
||||||
|
byPost[*a.ModelID] = append(byPost[*a.ModelID], a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range batch {
|
||||||
|
post := &batch[i]
|
||||||
|
id := strconv.FormatUint(uint64(post.ID), 10)
|
||||||
|
n, changes, err := fixPost(ctx, db, post, byPost[id], apply)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Posts++
|
||||||
|
out.Headings += n
|
||||||
|
if apply {
|
||||||
|
cabana.EmitRecordSaved(ctx, app, db, pact.RecordSaved{Model: morph, ID: id, Locale: locale, Changes: changes})
|
||||||
|
cooler.PurgeSiteCacheAfterCommit(ctx, db, purger, "post", post.Slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return HeadingFix{}, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
var cats []models.Category
|
||||||
|
if err := db.Model(&models.Category{}).Select("id", "slug", "content").
|
||||||
|
Where("content IS NOT NULL").Order("id ASC").Find(&cats).Error; err != nil {
|
||||||
|
return HeadingFix{}, err
|
||||||
|
}
|
||||||
|
catMorph := models.Category{}.MorphName()
|
||||||
|
for _, c := range cats {
|
||||||
|
converted, n := ConvertH1(*c.Content)
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Categories++
|
||||||
|
out.Headings += n
|
||||||
|
if !apply {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := db.Model(&models.Category{}).Where("id = ?", c.ID).Update("content", converted).Error; err != nil {
|
||||||
|
return HeadingFix{}, err
|
||||||
|
}
|
||||||
|
id := strconv.FormatUint(uint64(c.ID), 10)
|
||||||
|
cabana.EmitRecordSaved(ctx, app, db, pact.RecordSaved{Model: catMorph, ID: id, Locale: locale, Changes: []pact.RecordChange{
|
||||||
|
{Attribute: "content", Old: *c.Content, New: converted},
|
||||||
|
}})
|
||||||
|
cooler.PurgeSiteCacheAfterCommit(ctx, db, purger, "category", c.Slug)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixPost converts one post's host content and its translations (attrs,
|
||||||
|
// the post's translate rows) and, with apply, writes what changed. It
|
||||||
|
// returns the number of headings and the record changes.
|
||||||
|
func fixPost(ctx context.Context, db *gorm.DB, post *models.Post, attrs []translatemodels.Attribute, apply bool) (int, []pact.RecordChange, error) {
|
||||||
|
total := 0
|
||||||
|
var changes []pact.RecordChange
|
||||||
|
content, html, n := ConvertContentH1(post.Content, post.ContentHTML)
|
||||||
|
if n > 0 {
|
||||||
|
total += n
|
||||||
|
if content != post.Content {
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: "content", Old: post.Content, New: content})
|
||||||
|
}
|
||||||
|
if html != post.ContentHTML {
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: "content_html", Old: post.ContentHTML, New: html})
|
||||||
|
}
|
||||||
|
if apply {
|
||||||
|
if err := db.Model(&models.Post{}).Where("id = ?", post.ID).
|
||||||
|
Updates(map[string]any{"content": content, "content_html": html}).Error; err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range attrs {
|
||||||
|
if a.AttributeData == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(*a.AttributeData), &data); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
before := map[string]any{"content": data["content"], "content_html": data["content_html"]}
|
||||||
|
n := ConvertTranslationH1(data)
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += n
|
||||||
|
for _, field := range []string{"content", "content_html"} {
|
||||||
|
if data[field] != before[field] {
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: field, Locale: a.Locale, Old: before[field], New: data[field]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !apply {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The row is rewritten as it is, the other attributes kept: a
|
||||||
|
// translation in a locale disabled since keeps its content too.
|
||||||
|
raw, err := encodeAttributes(data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
if err := db.Model(&translatemodels.Attribute{}).Where("id = ?", a.ID).
|
||||||
|
Update("attribute_data", raw).Error; err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeAttributes encodes a translate row's attribute map as the translate
|
||||||
|
// plugin does: JSON without HTML escaping.
|
||||||
|
func encodeAttributes(data map[string]any) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(buf.String()), nil
|
||||||
|
}
|
||||||
140
classes/headings.go
Normal file
140
classes/headings.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
xhtml "golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConvertH1 turns every h1 element of html into an h2 and returns the
|
||||||
|
// result with the number of headings it converted. It works on the
|
||||||
|
// x/net/html tokenizer's raw tokens: only the tag name of an h1 start, end
|
||||||
|
// or self-closing tag changes, and every other byte (attributes, text,
|
||||||
|
// comments, the content of script or style) is copied as it is. Text that
|
||||||
|
// merely looks like a tag inside raw-text elements or comments is not a
|
||||||
|
// tag and is not touched. html without an h1 comes back unchanged with 0,
|
||||||
|
// and the bytes of an unfinished tag at the end of html are kept.
|
||||||
|
func ConvertH1(html string) (string, int) {
|
||||||
|
if !containsFold(html, "<h1") && !containsFold(html, "</h1") {
|
||||||
|
return html, 0
|
||||||
|
}
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(html))
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.Grow(len(html))
|
||||||
|
count := 0
|
||||||
|
consumed := 0
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
if tt == xhtml.ErrorToken {
|
||||||
|
// The input may end inside something the tokenizer reads as a
|
||||||
|
// tag ("a <b"): those bytes are kept as they are.
|
||||||
|
b.WriteString(html[min(consumed, len(html)):])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
raw := z.Raw()
|
||||||
|
consumed += len(raw)
|
||||||
|
switch tt {
|
||||||
|
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
|
||||||
|
if name, _ := z.TagName(); string(name) == "h1" && len(raw) >= 3 {
|
||||||
|
b.WriteString("<h2")
|
||||||
|
b.Write(raw[3:])
|
||||||
|
count++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case xhtml.EndTagToken:
|
||||||
|
if name, _ := z.TagName(); string(name) == "h1" && len(raw) >= 4 {
|
||||||
|
b.WriteString("</h2")
|
||||||
|
b.Write(raw[4:])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.Write(raw)
|
||||||
|
}
|
||||||
|
if count == 0 && b.String() == html {
|
||||||
|
return html, 0
|
||||||
|
}
|
||||||
|
return b.String(), count
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountH1 is the number of h1 start tags in html, as ConvertH1 counts them.
|
||||||
|
func CountH1(html string) int {
|
||||||
|
_, n := ConvertH1(html)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(s, sub string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(s), sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertContentH1 applies ConvertH1 to a post body and its rendered HTML
|
||||||
|
// (content and content_html) and returns both with the number of headings
|
||||||
|
// converted: content_html's count, or content's when content_html had none.
|
||||||
|
// content is read as markdown, as every render path reads it (FormatHTML
|
||||||
|
// passes raw HTML through, so an HTML body is HTML blocks and is converted
|
||||||
|
// as before): an <h1> inside a code block or code span keeps its tag
|
||||||
|
// whatever the body's first character, so a markdown post that opens with
|
||||||
|
// an HTML block keeps its code samples (33-REVIEW-GAPFIX WR-01), and a raw
|
||||||
|
// <h1> outside code becomes <h2>, as in the rendered HTML.
|
||||||
|
func ConvertContentH1(content, contentHTML string) (string, string, int) {
|
||||||
|
contentHTML, nHTML := ConvertH1(contentHTML)
|
||||||
|
content, nContent := convertH1OutsideCode(content)
|
||||||
|
if nHTML > 0 {
|
||||||
|
return content, contentHTML, nHTML
|
||||||
|
}
|
||||||
|
return content, contentHTML, nContent
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertH1OutsideCode applies ConvertH1 to the parts of markdown md
|
||||||
|
// outside its fenced and indented code blocks and code spans (goldmark's
|
||||||
|
// AST, the engine of FormatHTML): an h1 shown in a code sample keeps its
|
||||||
|
// tag. HTML blocks are not code, so an HTML body is converted whole. md
|
||||||
|
// without an h1 tag comes back unchanged with 0 without being parsed.
|
||||||
|
func convertH1OutsideCode(md string) (string, int) {
|
||||||
|
if !containsFold(md, "<h1") && !containsFold(md, "</h1") {
|
||||||
|
return md, 0
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(md))
|
||||||
|
count := 0
|
||||||
|
last := 0
|
||||||
|
apply := func(part string) {
|
||||||
|
out, n := ConvertH1(part)
|
||||||
|
count += n
|
||||||
|
b.WriteString(out)
|
||||||
|
}
|
||||||
|
for _, r := range markdownCodeRanges(md) {
|
||||||
|
apply(md[last:r[0]])
|
||||||
|
b.WriteString(md[r[0]:r[1]])
|
||||||
|
last = r[1]
|
||||||
|
}
|
||||||
|
apply(md[last:])
|
||||||
|
if count == 0 && b.String() == md {
|
||||||
|
return md, 0
|
||||||
|
}
|
||||||
|
return b.String(), count
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertTranslationH1 applies ConvertContentH1 to the string content and
|
||||||
|
// content_html of one locale's translated attributes, in place, and returns
|
||||||
|
// the number of headings converted. content is rewritten outside code
|
||||||
|
// only (ConvertContentH1). Other attributes and values that are not strings are
|
||||||
|
// left alone.
|
||||||
|
func ConvertTranslationH1(attrs map[string]any) int {
|
||||||
|
if attrs == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
content, hasContent := attrs["content"].(string)
|
||||||
|
html, hasHTML := attrs["content_html"].(string)
|
||||||
|
if !hasContent && !hasHTML {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
content, html, n := ConvertContentH1(content, html)
|
||||||
|
if hasContent {
|
||||||
|
attrs["content"] = content
|
||||||
|
}
|
||||||
|
if hasHTML {
|
||||||
|
attrs["content_html"] = html
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
143
classes/headings_test.go
Normal file
143
classes/headings_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConvertH1 pins ConvertH1's byte rule: only h1 tag names change.
|
||||||
|
func TestConvertH1(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in, want string
|
||||||
|
n int
|
||||||
|
}{
|
||||||
|
{"<h1>A</h1><p>x</p>", "<h2>A</h2><p>x</p>", 1},
|
||||||
|
{`<h1 class="x" id='y'>T</h1>`, `<h2 class="x" id='y'>T</h2>`, 1},
|
||||||
|
{"<H1>Up</H1>", "<h2>Up</h2>", 1},
|
||||||
|
{"<h1>a</h1>\n<h1>b</h1>", "<h2>a</h2>\n<h2>b</h2>", 2},
|
||||||
|
{"<p>no heading & text</p>", "<p>no heading & text</p>", 0},
|
||||||
|
{"<!-- <h1>c</h1> --><p>x</p>", "<!-- <h1>c</h1> --><p>x</p>", 0},
|
||||||
|
{"<script>var s = '<h1>';</script>", "<script>var s = '<h1>';</script>", 0},
|
||||||
|
{"<h10>x</h10><h1>y</h1>", "<h10>x</h10><h2>y</h2>", 1},
|
||||||
|
{"# Markdown <h1>raw</h1> a < b", "# Markdown <h2>raw</h2> a < b", 1},
|
||||||
|
{"<p>unterminated <h1>x", "<p>unterminated <h2>x", 1},
|
||||||
|
{"text <", "text <", 0},
|
||||||
|
{"", "", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, n := ConvertH1(c.in)
|
||||||
|
if got != c.want || n != c.n {
|
||||||
|
t.Errorf("ConvertH1(%q) = %q, %d; want %q, %d", c.in, got, n, c.want, c.n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// L1 WR-03: input without a match comes back identical, and an h1
|
||||||
|
// followed by an unfinished tag keeps the tail.
|
||||||
|
for _, in := range []string{"p a <b", "x<y", "<p>a <b class=\"c", "a < b"} {
|
||||||
|
if out, n := ConvertH1(in); out != in || n != 0 {
|
||||||
|
t.Errorf("ConvertH1(%q) = %q %d", in, out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, n := ConvertH1("<h1>x</h1><p>a <b"); out != "<h2>x</h2><p>a <b" || n != 1 {
|
||||||
|
t.Errorf("unfinished tail = %q %d", out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConvertContentH1 pins the body pair: content_html's count when its
|
||||||
|
// HTML had H1s, content's otherwise (a markdown body's H1 is only in its
|
||||||
|
// rendered HTML), both converted.
|
||||||
|
func TestConvertContentH1(t *testing.T) {
|
||||||
|
c, h, n := ConvertContentH1("# Title\n\ntext", "<h1>Title</h1><p>text</p>")
|
||||||
|
if c != "# Title\n\ntext" || h != "<h2>Title</h2><p>text</p>" || n != 1 {
|
||||||
|
t.Fatalf("markdown body = %q %q %d", c, h, n)
|
||||||
|
}
|
||||||
|
c, h, n = ConvertContentH1("<h1>a</h1><h1>b</h1>", "")
|
||||||
|
if c != "<h2>a</h2><h2>b</h2>" || h != "" || n != 2 {
|
||||||
|
t.Fatalf("HTML body without rendered HTML = %q %q %d", c, h, n)
|
||||||
|
}
|
||||||
|
if _, _, n := ConvertContentH1("<p>x</p>", "<p>x</p>"); n != 0 {
|
||||||
|
t.Fatal("no H1, nothing converted")
|
||||||
|
}
|
||||||
|
if CountH1(`<h1>a</h1><div><h1 class="x">b</h1></div><h2>c</h2>`) != 2 || CountH1("") != 0 {
|
||||||
|
t.Fatal("CountH1 is wrong")
|
||||||
|
}
|
||||||
|
// L1 CR-02: markdown with a fenced html sample and an inline code h1 is
|
||||||
|
// kept byte for byte; its rendered HTML has the real heading converted
|
||||||
|
// and the code still escaped.
|
||||||
|
md := "# Title\n\nExample:\n\n```html\n<h1>Hello</h1>\n```\n\nInline `<h1>` tag."
|
||||||
|
rendered, err := FormatHTML(md)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, h, n = ConvertContentH1(md, rendered)
|
||||||
|
if c != md {
|
||||||
|
t.Fatalf("the markdown source was rewritten:\n%s", c)
|
||||||
|
}
|
||||||
|
if n != 1 || !strings.Contains(h, "<h2") || strings.Contains(h, "<h1") || !strings.Contains(h, "<h1>Hello</h1>") || !strings.Contains(h, "<code><h1></code>") {
|
||||||
|
t.Fatalf("rendered HTML = %q n=%d", h, n)
|
||||||
|
}
|
||||||
|
// A raw h1 outside code is a heading in the rendered page, so it is
|
||||||
|
// converted in the markdown source too (as v0.3.1 did), and counted
|
||||||
|
// when there is no rendered HTML.
|
||||||
|
if c, h, n := ConvertContentH1("plain <h1>x</h1> text", ""); c != "plain <h2>x</h2> text" || h != "" || n != 1 {
|
||||||
|
t.Fatalf("markdown without HTML = %q %q %d", c, h, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConvertContentH1HTMLLead pins markdown that starts with an HTML
|
||||||
|
// block (33-REVIEW-GAPFIX WR-01): the first byte does not make it HTML,
|
||||||
|
// so the h1 samples in its code keep their tags and only a raw h1 outside
|
||||||
|
// code is converted.
|
||||||
|
func TestConvertContentH1HTMLLead(t *testing.T) {
|
||||||
|
probe := "<p class=\"lead\">Intro</p>\n\n```html\n<h1>Hello</h1>\n```"
|
||||||
|
rendered, err := FormatHTML(probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, html := range []string{rendered, ""} {
|
||||||
|
c, h, n := ConvertContentH1(probe, html)
|
||||||
|
if c != probe || h != html || n != 0 {
|
||||||
|
t.Fatalf("probe with content_html %q = %q %q n=%d", html, c, h, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mixed := "<p class=\"lead\">Intro</p>\n\n<h1>Real</h1>\n\n```html\n<h1>Hello</h1>\n```"
|
||||||
|
want := "<p class=\"lead\">Intro</p>\n\n<h2>Real</h2>\n\n```html\n<h1>Hello</h1>\n```"
|
||||||
|
if c, h, n := ConvertContentH1(mixed, ""); c != want || h != "" || n != 1 {
|
||||||
|
t.Fatalf("raw h1 outside code = %q %q n=%d", c, h, n)
|
||||||
|
}
|
||||||
|
span := "<p>x</p>\n\nUse `<h1>` here"
|
||||||
|
if c, _, n := ConvertContentH1(span, ""); c != span || n != 0 {
|
||||||
|
t.Fatalf("code span = %q n=%d", c, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConvertTranslationH1 pins a locale's translated attributes,
|
||||||
|
// changed in place: content and content_html converted, other attributes
|
||||||
|
// and values that are not strings left alone (a category's pillar text
|
||||||
|
// takes the same path as a post body).
|
||||||
|
func TestConvertTranslationH1(t *testing.T) {
|
||||||
|
attrs := map[string]any{"title": "<h1>not content</h1>", "content": "<h1>Pillar</h1><p>x</p>", "content_html": 5}
|
||||||
|
if n := ConvertTranslationH1(attrs); n != 1 || attrs["content"] != "<h2>Pillar</h2><p>x</p>" || attrs["title"] != "<h1>not content</h1>" || attrs["content_html"] != 5 {
|
||||||
|
t.Fatalf("attrs = %v n=%d", attrs, n)
|
||||||
|
}
|
||||||
|
both := map[string]any{"content": "# T", "content_html": "<h1>T</h1>"}
|
||||||
|
if n := ConvertTranslationH1(both); n != 1 || both["content_html"] != "<h2>T</h2>" || both["content"] != "# T" {
|
||||||
|
t.Fatalf("both = %v n=%d", both, n)
|
||||||
|
}
|
||||||
|
if ConvertTranslationH1(nil) != 0 || ConvertTranslationH1(map[string]any{"title": "x"}) != 0 {
|
||||||
|
t.Fatal("nothing to convert")
|
||||||
|
}
|
||||||
|
md := "Sample:\n\n```html\n<h1>Hi</h1>\n```\n"
|
||||||
|
tr := map[string]any{"content": md, "content_html": "<pre><code><h1>Hi</h1></code></pre>"}
|
||||||
|
if n := ConvertTranslationH1(tr); n != 0 || tr["content"] != md {
|
||||||
|
t.Fatalf("a markdown translation was rewritten: %v n=%d", tr, n)
|
||||||
|
}
|
||||||
|
probe := "<p class=\"lead\">Intro</p>\n\n```html\n<h1>Hello</h1>\n```"
|
||||||
|
rendered, err := FormatHTML(probe)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lead := map[string]any{"content": probe, "content_html": rendered}
|
||||||
|
if n := ConvertTranslationH1(lead); n != 0 || lead["content"] != probe {
|
||||||
|
t.Fatalf("markdown with an HTML lead was rewritten: %v n=%d", lead, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
431
classes/image_alt.go
Normal file
431
classes/image_alt.go
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
gmtext "github.com/yuin/goldmark/text"
|
||||||
|
"github.com/yuin/goldmark/util"
|
||||||
|
xhtml "golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetImageAlt sets the alt attribute of every img in html whose src
|
||||||
|
// attribute (unescaped) equals src, adding it when missing. It returns the
|
||||||
|
// new HTML, the number of matching images and the alt the first match had
|
||||||
|
// (nil when it had none). It works on the x/net/html tokenizer: a matching
|
||||||
|
// img tag is written again from its token, so every attribute value,
|
||||||
|
// alt included, is escaped by the tokenizer and never concatenated; every
|
||||||
|
// other byte of html is copied as it is, the bytes of an unfinished tag at
|
||||||
|
// the end included. An empty alt marks a decorative image (alt="").
|
||||||
|
func SetImageAlt(html, src, alt string) (string, int, *string) {
|
||||||
|
if src == "" || !containsFold(html, "<img") {
|
||||||
|
return html, 0, nil
|
||||||
|
}
|
||||||
|
z := xhtml.NewTokenizer(strings.NewReader(html))
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.Grow(len(html) + len(alt))
|
||||||
|
matches := 0
|
||||||
|
var old *string
|
||||||
|
consumed := 0
|
||||||
|
for {
|
||||||
|
tt := z.Next()
|
||||||
|
if tt == xhtml.ErrorToken {
|
||||||
|
// The bytes of an unfinished tag at the end are kept.
|
||||||
|
b.WriteString(html[min(consumed, len(html)):])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
raw := z.Raw()
|
||||||
|
consumed += len(raw)
|
||||||
|
if tt == xhtml.StartTagToken || tt == xhtml.SelfClosingTagToken {
|
||||||
|
// Raw is copied before Token, which reads the tag; Token is
|
||||||
|
// the only reader of the tag (TagName would consume it).
|
||||||
|
kept := append([]byte(nil), raw...)
|
||||||
|
tok := z.Token()
|
||||||
|
if tok.Data == "img" && imgSrc(tok) == src {
|
||||||
|
prev := setAlt(&tok, alt)
|
||||||
|
if matches == 0 {
|
||||||
|
old = prev
|
||||||
|
}
|
||||||
|
matches++
|
||||||
|
b.WriteString(tok.String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.Write(kept)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.Write(raw)
|
||||||
|
}
|
||||||
|
if matches == 0 {
|
||||||
|
return html, 0, nil
|
||||||
|
}
|
||||||
|
return b.String(), matches, old
|
||||||
|
}
|
||||||
|
|
||||||
|
func imgSrc(tok xhtml.Token) string {
|
||||||
|
for _, a := range tok.Attr {
|
||||||
|
if a.Namespace == "" && a.Key == "src" {
|
||||||
|
return a.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// setAlt sets tok's alt to alt and returns the previous value (nil when
|
||||||
|
// there was none). Repeated alt attributes collapse to one.
|
||||||
|
func setAlt(tok *xhtml.Token, alt string) *string {
|
||||||
|
var prev *string
|
||||||
|
attrs := tok.Attr[:0]
|
||||||
|
set := false
|
||||||
|
for _, a := range tok.Attr {
|
||||||
|
if a.Namespace == "" && a.Key == "alt" {
|
||||||
|
if prev == nil {
|
||||||
|
v := a.Val
|
||||||
|
prev = &v
|
||||||
|
}
|
||||||
|
if set {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.Val = alt
|
||||||
|
set = true
|
||||||
|
}
|
||||||
|
attrs = append(attrs, a)
|
||||||
|
}
|
||||||
|
if !set {
|
||||||
|
attrs = append(attrs, xhtml.Attribute{Key: "alt", Val: alt})
|
||||||
|
}
|
||||||
|
tok.Attr = attrs
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMarkdownImageAlt sets the alt text of every inline markdown image of
|
||||||
|
// md whose destination is src (P33-65):  and
|
||||||
|
// , outside fenced and indented code blocks, code
|
||||||
|
// spans and HTML blocks (the ranges come from goldmark's AST, the engine of
|
||||||
|
// FormatHTML). An image matches when its destination equals src as written,
|
||||||
|
// or as goldmark's HTML renderer escapes it into the img src attribute. The
|
||||||
|
// label is replaced by alt with \, [ and ] backslash-escaped and line breaks
|
||||||
|
// folded to spaces, so an alt can neither close the image nor add a link;
|
||||||
|
// every other byte of md is kept. It returns the new markdown, the number of
|
||||||
|
// matching images and the label the first match had. A reference-style
|
||||||
|
// image (![label][ref]) is not matched.
|
||||||
|
func SetMarkdownImageAlt(md, src, alt string) (string, int, *string) {
|
||||||
|
if src == "" || !strings.Contains(md, "![") {
|
||||||
|
return md, 0, nil
|
||||||
|
}
|
||||||
|
skip, images := markdownLayout(md)
|
||||||
|
if len(images) == 0 {
|
||||||
|
return md, 0, nil
|
||||||
|
}
|
||||||
|
label := escapeMarkdownLabel(alt)
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(md) + len(label))
|
||||||
|
matches := 0
|
||||||
|
var old *string
|
||||||
|
last := 0
|
||||||
|
for i := 0; i+1 < len(md); i++ {
|
||||||
|
if md[i] != '!' || md[i+1] != '[' || (i > 0 && md[i-1] == '\\') || inRanges(skip, i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
img, ok := scanInlineImage(md, i)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dest := md[img.destStart:img.destEnd]
|
||||||
|
escaped := string(util.URLEscape([]byte(dest), true))
|
||||||
|
if !images[dest] || (dest != src && escaped != src) {
|
||||||
|
i = img.end - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if matches == 0 {
|
||||||
|
v := md[img.labelStart:img.labelEnd]
|
||||||
|
old = &v
|
||||||
|
}
|
||||||
|
matches++
|
||||||
|
b.WriteString(md[last:img.labelStart])
|
||||||
|
b.WriteString(label)
|
||||||
|
last = img.labelEnd
|
||||||
|
i = img.end - 1
|
||||||
|
}
|
||||||
|
if matches == 0 {
|
||||||
|
return md, 0, nil
|
||||||
|
}
|
||||||
|
b.WriteString(md[last:])
|
||||||
|
return b.String(), matches, old
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetImageAltOutsideCode applies SetImageAlt to the parts of markdown md
|
||||||
|
// outside its fenced and indented code blocks and code spans, so a raw
|
||||||
|
// <img> in the markdown gets the alt while an <img> shown in a code sample
|
||||||
|
// keeps its bytes.
|
||||||
|
func SetImageAltOutsideCode(md, src, alt string) (string, int, *string) {
|
||||||
|
if src == "" || !containsFold(md, "<img") {
|
||||||
|
return md, 0, nil
|
||||||
|
}
|
||||||
|
code := markdownCodeRanges(md)
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(md) + len(alt))
|
||||||
|
matches := 0
|
||||||
|
var old *string
|
||||||
|
last := 0
|
||||||
|
apply := func(part string) {
|
||||||
|
out, n, prev := SetImageAlt(part, src, alt)
|
||||||
|
if n > 0 && matches == 0 {
|
||||||
|
old = prev
|
||||||
|
}
|
||||||
|
matches += n
|
||||||
|
b.WriteString(out)
|
||||||
|
}
|
||||||
|
for _, r := range code {
|
||||||
|
apply(md[last:r[0]])
|
||||||
|
b.WriteString(md[r[0]:r[1]])
|
||||||
|
last = r[1]
|
||||||
|
}
|
||||||
|
apply(md[last:])
|
||||||
|
if matches == 0 {
|
||||||
|
return md, 0, nil
|
||||||
|
}
|
||||||
|
return b.String(), matches, old
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeMarkdownLabel is alt as an image label: \, [ and ] escaped, line
|
||||||
|
// breaks folded to spaces.
|
||||||
|
func escapeMarkdownLabel(alt string) string {
|
||||||
|
alt = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ").Replace(alt)
|
||||||
|
return strings.NewReplacer(`\`, `\\`, "[", `\[`, "]", `\]`).Replace(alt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inlineImage is the byte layout of one  in a text.
|
||||||
|
type inlineImage struct {
|
||||||
|
labelStart, labelEnd int
|
||||||
|
destStart, destEnd int
|
||||||
|
end int
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanInlineImage reads the inline image starting at md[i] ("!["), as
|
||||||
|
// goldmark's link parser reads one: a label with balanced brackets and
|
||||||
|
// backslash escapes, "(", optional spaces, a destination in <...> or
|
||||||
|
// without spaces with balanced parentheses, an optional title in quotes or
|
||||||
|
// parentheses, and ")".
|
||||||
|
func scanInlineImage(md string, i int) (inlineImage, bool) {
|
||||||
|
var img inlineImage
|
||||||
|
img.labelStart = i + 2
|
||||||
|
depth := 0
|
||||||
|
j := img.labelStart
|
||||||
|
for ; j < len(md); j++ {
|
||||||
|
c := md[j]
|
||||||
|
if c == '\\' && j+1 < len(md) {
|
||||||
|
j++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '[' {
|
||||||
|
depth++
|
||||||
|
} else if c == ']' {
|
||||||
|
if depth == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if j >= len(md) || j+1 >= len(md) || md[j+1] != '(' {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
img.labelEnd = j
|
||||||
|
k := skipLinkSpace(md, j+2)
|
||||||
|
if k >= len(md) {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
if md[k] == '<' {
|
||||||
|
e := k + 1
|
||||||
|
for ; e < len(md) && md[e] != '>' && md[e] != '\n'; e++ {
|
||||||
|
if md[e] == '\\' && e+1 < len(md) {
|
||||||
|
e++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e >= len(md) || md[e] != '>' {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
img.destStart, img.destEnd = k+1, e
|
||||||
|
k = e + 1
|
||||||
|
} else {
|
||||||
|
opened := 0
|
||||||
|
e := k
|
||||||
|
for ; e < len(md); e++ {
|
||||||
|
c := md[e]
|
||||||
|
if c == '\\' && e+1 < len(md) {
|
||||||
|
e++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '(' {
|
||||||
|
opened++
|
||||||
|
} else if c == ')' {
|
||||||
|
if opened == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
opened--
|
||||||
|
} else if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e == k {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
img.destStart, img.destEnd = k, e
|
||||||
|
k = e
|
||||||
|
}
|
||||||
|
k = skipLinkSpace(md, k)
|
||||||
|
if k < len(md) && (md[k] == '"' || md[k] == '\'' || md[k] == '(') {
|
||||||
|
closer := md[k]
|
||||||
|
if closer == '(' {
|
||||||
|
closer = ')'
|
||||||
|
}
|
||||||
|
e := k + 1
|
||||||
|
for ; e < len(md) && md[e] != closer; e++ {
|
||||||
|
if md[e] == '\\' && e+1 < len(md) {
|
||||||
|
e++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e >= len(md) {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
k = skipLinkSpace(md, e+1)
|
||||||
|
}
|
||||||
|
if k >= len(md) || md[k] != ')' {
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
img.end = k + 1
|
||||||
|
return img, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipLinkSpace skips spaces, tabs and at most one line break.
|
||||||
|
func skipLinkSpace(md string, k int) int {
|
||||||
|
newline := false
|
||||||
|
for k < len(md) {
|
||||||
|
switch md[k] {
|
||||||
|
case ' ', '\t':
|
||||||
|
case '\n', '\r':
|
||||||
|
if newline {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
newline = true
|
||||||
|
default:
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// markdownLayout parses md as FormatHTML does and answers the byte ranges
|
||||||
|
// an image is never read from (code blocks, code spans and HTML blocks)
|
||||||
|
// and the destinations of its inline images.
|
||||||
|
func markdownLayout(md string) ([][2]int, map[string]bool) {
|
||||||
|
images := map[string]bool{}
|
||||||
|
ranges := markdownRanges(md, true, func(n ast.Node) {
|
||||||
|
if img, ok := n.(*ast.Image); ok {
|
||||||
|
images[string(img.Destination)] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return ranges, images
|
||||||
|
}
|
||||||
|
|
||||||
|
// markdownCodeRanges is the byte ranges of md's code blocks and code
|
||||||
|
// spans, in order.
|
||||||
|
func markdownCodeRanges(md string) [][2]int {
|
||||||
|
return markdownRanges(md, false, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// markdownRanges walks md's goldmark AST (parsed from the trimmed text, as
|
||||||
|
// FormatHTML converts it) and answers the sorted, merged byte ranges of its
|
||||||
|
// fenced and indented code blocks, its code spans and, with html, its HTML
|
||||||
|
// blocks; visit sees every node.
|
||||||
|
func markdownRanges(md string, html bool, visit func(ast.Node)) [][2]int {
|
||||||
|
trimmed := strings.TrimSpace(md)
|
||||||
|
lead := strings.Index(md, trimmed)
|
||||||
|
if trimmed == "" || lead < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
source := []byte(trimmed)
|
||||||
|
doc := markdownEngine.Parser().Parse(gmtext.NewReader(source))
|
||||||
|
var ranges [][2]int
|
||||||
|
lines := func(n ast.Node) {
|
||||||
|
l := n.Lines()
|
||||||
|
for i := 0; i < l.Len(); i++ {
|
||||||
|
s := l.At(i)
|
||||||
|
ranges = append(ranges, [2]int{lead + s.Start, lead + s.Stop})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
if visit != nil {
|
||||||
|
visit(n)
|
||||||
|
}
|
||||||
|
switch v := n.(type) {
|
||||||
|
case *ast.FencedCodeBlock:
|
||||||
|
lines(v)
|
||||||
|
if v.Info != nil {
|
||||||
|
ranges = append(ranges, [2]int{lead + v.Info.Segment.Start, lead + v.Info.Segment.Stop})
|
||||||
|
}
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
case *ast.CodeBlock:
|
||||||
|
lines(v)
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
case *ast.HTMLBlock:
|
||||||
|
if html {
|
||||||
|
lines(v)
|
||||||
|
}
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
case *ast.CodeSpan:
|
||||||
|
start, stop := -1, -1
|
||||||
|
for c := v.FirstChild(); c != nil; c = c.NextSibling() {
|
||||||
|
if t, ok := c.(*ast.Text); ok {
|
||||||
|
if start < 0 || t.Segment.Start < start {
|
||||||
|
start = t.Segment.Start
|
||||||
|
}
|
||||||
|
stop = max(stop, t.Segment.Stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start >= 0 {
|
||||||
|
// The backticks around the span belong to it.
|
||||||
|
for start > 0 && source[start-1] == '`' {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
for stop < len(source) && source[stop] == '`' {
|
||||||
|
stop++
|
||||||
|
}
|
||||||
|
ranges = append(ranges, [2]int{lead + start, lead + stop})
|
||||||
|
}
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
})
|
||||||
|
sort.Slice(ranges, func(i, j int) bool { return ranges[i][0] < ranges[j][0] })
|
||||||
|
var merged [][2]int
|
||||||
|
for _, r := range ranges {
|
||||||
|
if r[0] >= r[1] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n := len(merged); n > 0 && r[0] <= merged[n-1][1] {
|
||||||
|
merged[n-1][1] = max(merged[n-1][1], r[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged = append(merged, r)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// inRanges reports whether i lies in one of the sorted ranges.
|
||||||
|
func inRanges(ranges [][2]int, i int) bool {
|
||||||
|
for _, r := range ranges {
|
||||||
|
if i < r[0] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if i < r[1] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
109
classes/image_alt_test.go
Normal file
109
classes/image_alt_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSetImageAlt pins the alt rewrite: only the matching img tag is
|
||||||
|
// rewritten from its token, the alt is escaped, a missing alt is added and
|
||||||
|
// every other byte is kept (T-33-04).
|
||||||
|
func TestSetImageAlt(t *testing.T) {
|
||||||
|
const doc = `<p class="a">x & y</p><img src="https://cdn.example.test/a.jpg?x=1&y=2"><img src="b.jpg" alt="old"/>`
|
||||||
|
out, n, old := SetImageAlt(doc, "https://cdn.example.test/a.jpg?x=1&y=2", `Lion's "mane" <b>`)
|
||||||
|
want := `<p class="a">x & y</p><img src="https://cdn.example.test/a.jpg?x=1&y=2" alt="Lion's "mane" <b>"><img src="b.jpg" alt="old"/>`
|
||||||
|
if out != want || n != 1 || old != nil {
|
||||||
|
t.Fatalf("SetImageAlt = %q, %d, %v; want %q, 1, nil", out, n, old, want)
|
||||||
|
}
|
||||||
|
out, n, old = SetImageAlt(doc, "b.jpg", "")
|
||||||
|
if out != `<p class="a">x & y</p><img src="https://cdn.example.test/a.jpg?x=1&y=2"><img src="b.jpg" alt=""/>` || n != 1 || old == nil || *old != "old" {
|
||||||
|
t.Fatalf("decorative = %q, %d, %v", out, n, old)
|
||||||
|
}
|
||||||
|
if out, n, _ := SetImageAlt(doc, "missing.jpg", "x"); out != doc || n != 0 {
|
||||||
|
t.Fatalf("missing = %q, %d", out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetImageAltEdges pins the edges: every matching img is rewritten
|
||||||
|
// and counted (the first one's old alt answered), repeated alt attributes
|
||||||
|
// collapse to one, a src given escaped does not match the unescaped one,
|
||||||
|
// an empty src and a body without img change nothing, and text that
|
||||||
|
// looks like an img inside a comment or script is not touched.
|
||||||
|
func TestSetImageAltEdges(t *testing.T) {
|
||||||
|
doc := `<img src="a.jpg" alt="one"><p>t</p><img alt="x" src="a.jpg" alt="y"><!-- <img src="a.jpg"> --><script>"<img src=a.jpg>"</script>`
|
||||||
|
out, n, old := SetImageAlt(doc, "a.jpg", "new")
|
||||||
|
if n != 2 || old == nil || *old != "one" {
|
||||||
|
t.Fatalf("n=%d old=%v", n, old)
|
||||||
|
}
|
||||||
|
want := `<img src="a.jpg" alt="new"><p>t</p><img alt="new" src="a.jpg"><!-- <img src="a.jpg"> --><script>"<img src=a.jpg>"</script>`
|
||||||
|
if out != want {
|
||||||
|
t.Fatalf("out = %q\nwant %q", out, want)
|
||||||
|
}
|
||||||
|
if out, n, _ := SetImageAlt(`<img src="a.jpg?x=1&y=2">`, "a.jpg?x=1&y=2", "z"); n != 0 || out != `<img src="a.jpg?x=1&y=2">` {
|
||||||
|
t.Fatalf("escaped src matched: %q %d", out, n)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ html, src string }{{`<img src="a.jpg">`, ""}, {`<p>no images</p>`, "a.jpg"}, {"", "a.jpg"}} {
|
||||||
|
if out, n, old := SetImageAlt(tc.html, tc.src, "x"); out != tc.html || n != 0 || old != nil {
|
||||||
|
t.Errorf("SetImageAlt(%q, %q) = %q %d %v", tc.html, tc.src, out, n, old)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// L1 WR-03: the bytes of an unfinished tag at the end are kept.
|
||||||
|
for _, in := range []string{"p a <img", "x<y", `<img src="b.jpg"><p>a <b`} {
|
||||||
|
if out, n, _ := SetImageAlt(in, "a.jpg", "z"); out != in || n != 0 {
|
||||||
|
t.Errorf("SetImageAlt(%q) = %q %d", in, out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out, n, _ := SetImageAlt(`<img src="a.jpg"><p>a <b`, "a.jpg", "z"); out != `<img src="a.jpg" alt="z"><p>a <b` || n != 1 {
|
||||||
|
t.Errorf("match with an unfinished tail = %q %d", out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetMarkdownImageAlt pins P33-65 (L1 WR-05): inline images with or
|
||||||
|
// without a title get the alt; a destination written unescaped matches the
|
||||||
|
// src goldmark renders for it; an alt with ] and a line break is escaped
|
||||||
|
// and folded; the same image in a fence or a code span is left alone; a
|
||||||
|
// reference-style image is not matched.
|
||||||
|
func TestSetMarkdownImageAlt(t *testing.T) {
|
||||||
|
out, n, old := SetMarkdownImageAlt("A  and  and .", "/a.jpg", "Lion's mane")
|
||||||
|
if n != 2 || old == nil || *old != "" || out != "A  and  and ." {
|
||||||
|
t.Fatalf("plain = %q %d %v", out, n, old)
|
||||||
|
}
|
||||||
|
html, err := FormatHTML("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `src="/a%20b.jpg"`) {
|
||||||
|
t.Fatalf("goldmark src = %s", html)
|
||||||
|
}
|
||||||
|
if out, n, _ := SetMarkdownImageAlt("", "/a%20b.jpg", "y"); n != 1 || out != "" {
|
||||||
|
t.Fatalf("escaped src = %q %d", out, n)
|
||||||
|
}
|
||||||
|
if out, n, _ := SetMarkdownImageAlt("", "/%C5%BC.jpg", "y"); n != 1 || out != "" {
|
||||||
|
t.Fatalf("non-ASCII src = %q %d", out, n)
|
||||||
|
}
|
||||||
|
if out, _, _ := SetMarkdownImageAlt("", "/a.jpg", "a ] b\nc [d] \\e"); out != `![a \] b c \[d\] \\e](/a.jpg)` {
|
||||||
|
t.Fatalf("escaped alt = %q", out)
|
||||||
|
}
|
||||||
|
code := "```\n\n```\n\nInline `` and\n\n \n"
|
||||||
|
if out, n, _ := SetMarkdownImageAlt(code, "/a.jpg", "y"); n != 0 || out != code {
|
||||||
|
t.Fatalf("code = %q %d", out, n)
|
||||||
|
}
|
||||||
|
ref := "![x][a]\n\n[a]: /a.jpg\n"
|
||||||
|
if out, n, _ := SetMarkdownImageAlt(ref, "/a.jpg", "y"); n != 0 || out != ref {
|
||||||
|
t.Fatalf("reference = %q %d", out, n)
|
||||||
|
}
|
||||||
|
// The rendered alt follows the markdown.
|
||||||
|
out, _, _ = SetMarkdownImageAlt("Text\n\n\n", "/a.jpg", "Grzyb [leśny]")
|
||||||
|
if html, _ := FormatHTML(out); !strings.Contains(html, `alt="Grzyb [leśny]"`) {
|
||||||
|
t.Fatalf("rendered = %s", html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetImageAltOutsideCode: a raw img gets the alt, one in a code sample
|
||||||
|
// keeps its bytes.
|
||||||
|
raw := "<img src=\"/a.jpg\">\n\n```html\n<img src=\"/a.jpg\">\n```\n\nand `<img src=\"/a.jpg\">`\n"
|
||||||
|
got, n, _ := SetImageAltOutsideCode(raw, "/a.jpg", "z")
|
||||||
|
want := "<img src=\"/a.jpg\" alt=\"z\">\n\n```html\n<img src=\"/a.jpg\">\n```\n\nand `<img src=\"/a.jpg\">`\n"
|
||||||
|
if n != 1 || got != want {
|
||||||
|
t.Fatalf("outside code = %q %d", got, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
292
classes/image_variants.go
Normal file
292
classes/image_variants.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"io"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/conga"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/diary"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach/webp"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gocloud.dev/gcerrors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The image variants job and its configuration (2026-10-28 image formats,
|
||||||
|
// D-85, P33-43).
|
||||||
|
const (
|
||||||
|
// ImageVariantsKind is the conga job kind golem15.journal.image_variants.
|
||||||
|
ImageVariantsKind = "golem15.journal.image_variants"
|
||||||
|
// ImageWidthsKey is the config key of the variant widths, in pixels.
|
||||||
|
ImageWidthsKey = "golem15.journal.image_widths"
|
||||||
|
// ImageVariantsMode is the thumbnail mode of a variant: the exact size
|
||||||
|
// of the original's aspect ratio at the variant's width.
|
||||||
|
ImageVariantsMode = "exact"
|
||||||
|
// FeaturedImagesField is the system_files field of a post's featured
|
||||||
|
// images.
|
||||||
|
FeaturedImagesField = "featured_images"
|
||||||
|
// MinImageWidth and MaxImageWidth bound a configured width.
|
||||||
|
MinImageWidth = 16
|
||||||
|
MaxImageWidth = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// variantsLog logs the image variants as component journal.
|
||||||
|
var variantsLog = diary.For("journal")
|
||||||
|
|
||||||
|
// DefaultImageWidths are the variant widths without configuration.
|
||||||
|
var DefaultImageWidths = []int{480, 800, 1200}
|
||||||
|
|
||||||
|
// ImageVariantsArgs are the arguments of the image variants job: the
|
||||||
|
// system_files ids of the featured images to make variants of.
|
||||||
|
type ImageVariantsArgs struct {
|
||||||
|
FileIDs []uint `json:"file_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind is the job kind, golem15.journal.image_variants.
|
||||||
|
func (ImageVariantsArgs) Kind() string { return ImageVariantsKind }
|
||||||
|
|
||||||
|
// ImageWidths reads golem15.journal.image_widths: a list of widths or a
|
||||||
|
// comma-separated text (an environment override), each 16 to 4096, sorted
|
||||||
|
// and once each. An unset, empty or invalid value answers
|
||||||
|
// DefaultImageWidths.
|
||||||
|
func ImageWidths(cfg *compass.Config) []int {
|
||||||
|
if cfg == nil {
|
||||||
|
return slices.Clone(DefaultImageWidths)
|
||||||
|
}
|
||||||
|
raw, ok := cfg.Lookup(ImageWidthsKey)
|
||||||
|
if !ok || raw == nil {
|
||||||
|
return slices.Clone(DefaultImageWidths)
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case string:
|
||||||
|
parts = strings.Split(v, ",")
|
||||||
|
case []any:
|
||||||
|
for _, item := range v {
|
||||||
|
parts = append(parts, fmt.Sprint(item))
|
||||||
|
}
|
||||||
|
case []int:
|
||||||
|
for _, n := range v {
|
||||||
|
parts = append(parts, strconv.Itoa(n))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
parts = []string{fmt.Sprint(v)}
|
||||||
|
}
|
||||||
|
var out []int
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil || n < MinImageWidth || n > MaxImageWidth {
|
||||||
|
return slices.Clone(DefaultImageWidths)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return slices.Clone(DefaultImageWidths)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return slices.Compact(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VariantsResult is what one run of GenerateImageVariants did.
|
||||||
|
type VariantsResult struct {
|
||||||
|
Files int
|
||||||
|
Written int
|
||||||
|
Skipped int
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxHeaderBytes caps the bytes read for an original's size.
|
||||||
|
const maxHeaderBytes = 1 << 20
|
||||||
|
|
||||||
|
// sourceSize reads an original's size from its header; ok is false for a
|
||||||
|
// missing original, one that does not decode, or one over the thumbnailer's
|
||||||
|
// pixel limit (attach would store the broken-image picture for it).
|
||||||
|
func sourceSize(ctx context.Context, bucket *blob.Bucket, f attach.File) (w, h int, ok bool, err error) {
|
||||||
|
r, err := bucket.NewReader(ctx, attach.BlobKey(f.DiskName), nil)
|
||||||
|
if gcerrors.Code(err) == gcerrors.NotFound {
|
||||||
|
return 0, 0, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false, err
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(r, maxHeaderBytes))
|
||||||
|
closeErr := r.Close()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false, err
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return 0, 0, false, closeErr
|
||||||
|
}
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||||
|
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||||||
|
return 0, 0, false, nil
|
||||||
|
}
|
||||||
|
if int64(cfg.Width)*int64(cfg.Height) > attach.MaxImagePixels {
|
||||||
|
return 0, 0, false, nil
|
||||||
|
}
|
||||||
|
return cfg.Width, cfg.Height, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateImageVariants writes the variants of the files ids (D-85): for
|
||||||
|
// each width of widths no larger than the original's, the thumbnail at that
|
||||||
|
// width (attach.File.ThumbKey, mode exact, the original's aspect ratio) and
|
||||||
|
// its WebP copy (webp.Variant), recorded in golem15_journal_image_variants.
|
||||||
|
// A width that already has its row is skipped, so a second run writes
|
||||||
|
// nothing. A missing, undecodable or oversized original is skipped and
|
||||||
|
// logged. A storage or database error is returned (the job retries).
|
||||||
|
func GenerateImageVariants(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, widths []int, ids []uint) (VariantsResult, error) {
|
||||||
|
var out VariantsResult
|
||||||
|
if db == nil || bucket == nil {
|
||||||
|
return out, errors.New("journal: image variants need the database and the uploads bucket")
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var files []attach.File
|
||||||
|
if err := db.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&files).Error; err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
out.Files++
|
||||||
|
var have []int
|
||||||
|
if err := db.WithContext(ctx).Model(&models.ImageVariant{}).Where("file_id = ?", f.ID).Pluck("width", &have).Error; err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
var todo []int
|
||||||
|
for _, w := range widths {
|
||||||
|
if !slices.Contains(have, w) {
|
||||||
|
todo = append(todo, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(todo) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sw, sh, ok, err := sourceSize(ctx, bucket, f)
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("journal: read original %d: %w", f.ID, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
variantsLog.Warn("journal: no image variants for an unusable original", "file", f.ID)
|
||||||
|
out.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, w := range todo {
|
||||||
|
if w > sw {
|
||||||
|
out.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := max(1, int((int64(sh)*int64(w)+int64(sw)/2)/int64(sw)))
|
||||||
|
if h > MaxImageWidth {
|
||||||
|
// A tall original (an infographic): the variant would leave
|
||||||
|
// the thumbnailer's edge limit, so this width is skipped like
|
||||||
|
// one wider than the original, and the job goes on.
|
||||||
|
variantsLog.Warn("journal: no image variant taller than the edge limit", "file", f.ID, "width", w, "height", h, "max", MaxImageWidth)
|
||||||
|
out.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, err := f.ThumbKey(ctx, bucket, w, h, ImageVariantsMode)
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("journal: thumbnail %d at %d: %w", f.ID, w, err)
|
||||||
|
}
|
||||||
|
webpKey, err := webp.Variant(ctx, bucket, &f, w, h, ImageVariantsMode)
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("journal: webp %d at %d: %w", f.ID, w, err)
|
||||||
|
}
|
||||||
|
row := models.ImageVariant{FileID: f.ID, Width: w, Height: h, Key: key, WebPKey: webpKey, SourceWidth: sw, SourceHeight: sh, CreatedAt: time.Now().UTC()}
|
||||||
|
res := db.WithContext(ctx).Exec(`INSERT INTO golem15_journal_image_variants (file_id, width, height, key, webp_key, source_width, source_height, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (file_id, width) DO NOTHING`, row.FileID, row.Width, row.Height, row.Key, row.WebPKey, row.SourceWidth, row.SourceHeight, row.CreatedAt)
|
||||||
|
if res.Error != nil {
|
||||||
|
return out, res.Error
|
||||||
|
}
|
||||||
|
out.Written += int(res.RowsAffected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// featuredFiles is the featured images of the posts, or of every post when
|
||||||
|
// postIDs is nil; missing is true to keep only the images without any
|
||||||
|
// variant row, or without a row for a width no larger than their original.
|
||||||
|
func featuredFiles(db *gorm.DB, postIDs []uint, widths []int, missing bool) ([]uint, error) {
|
||||||
|
q := db.Model(&attach.File{}).Where("attachment_type = ? AND field = ?", (models.Post{}).MorphName(), FeaturedImagesField)
|
||||||
|
if postIDs != nil {
|
||||||
|
keys := make([]string, 0, len(postIDs))
|
||||||
|
for _, id := range postIDs {
|
||||||
|
keys = append(keys, strconv.FormatUint(uint64(id), 10))
|
||||||
|
}
|
||||||
|
q = q.Where("attachment_id IN ?", keys)
|
||||||
|
}
|
||||||
|
if missing {
|
||||||
|
list := make([]string, 0, len(widths))
|
||||||
|
for _, w := range widths {
|
||||||
|
list = append(list, strconv.Itoa(w))
|
||||||
|
}
|
||||||
|
q = q.Where(`NOT EXISTS (SELECT 1 FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id)
|
||||||
|
OR EXISTS (SELECT 1 FROM unnest(?::int[]) AS w(width)
|
||||||
|
WHERE w.width <= (SELECT MAX(v.source_width) FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM golem15_journal_image_variants v WHERE v.file_id = system_files.id AND v.width = w.width))`, "{"+strings.Join(list, ",")+"}")
|
||||||
|
}
|
||||||
|
var ids []uint
|
||||||
|
err := q.Order("id ASC").Pluck("id", &ids).Error
|
||||||
|
return ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeaturedImageIDs lists the featured images of every post (all) or only
|
||||||
|
// those missing a variant of a configured width no larger than their
|
||||||
|
// original (or without any variant yet).
|
||||||
|
func FeaturedImageIDs(db *gorm.DB, cfg *compass.Config, all bool) ([]uint, error) {
|
||||||
|
return featuredFiles(db, nil, ImageWidths(cfg), !all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchImageVariants queues the image variants job for the files ids.
|
||||||
|
// The upload or save that called it has committed; a queue that cannot
|
||||||
|
// take the job is logged and never fails that request (D-85).
|
||||||
|
func DispatchImageVariants(ctx context.Context, app *backpack.App, ids []uint) {
|
||||||
|
if app == nil || len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := conga.From(app)
|
||||||
|
if err == nil {
|
||||||
|
_, err = m.Dispatch(ctx, nil, ImageVariantsArgs{FileIDs: ids}, conga.DispatchOpts{Label: "Journal image variants"})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
variantsLog.Warn("journal: image variants job not queued", "files", len(ids), "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchPostImageVariantsAfterCommit queues the image variants job, once
|
||||||
|
// tx commits, for the post's featured images that lack a variant of some
|
||||||
|
// width (the admin save: the uploads it binds commit with it).
|
||||||
|
func DispatchPostImageVariantsAfterCommit(ctx context.Context, tx *gorm.DB, app *backpack.App, postID uint) {
|
||||||
|
if app == nil || postID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||||
|
db, ok := app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids, err := featuredFiles(db.WithContext(ctx), []uint{postID}, ImageWidths(app.Config), true)
|
||||||
|
if err != nil {
|
||||||
|
variantsLog.Warn("journal: image variants of a saved post", "post", postID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchImageVariants(ctx, app, ids)
|
||||||
|
})
|
||||||
|
}
|
||||||
84
classes/journal_settings.go
Normal file
84
classes/journal_settings.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// H1ToH2Key is the config key of the H1 setting, used only when the Journal
|
||||||
|
// settings row does not exist (EffectiveH1ToH2). Its shipped default is true.
|
||||||
|
const H1ToH2Key = "golem15.journal.content_h1_to_h2"
|
||||||
|
|
||||||
|
// DefaultGenericAnchors are the link texts the editor warns about when the
|
||||||
|
// settings row has no list of its own: the English and the Polish seed list,
|
||||||
|
// each entry once.
|
||||||
|
var DefaultGenericAnchors = []string{
|
||||||
|
"click here", "here", "more", "read more", "learn more", "this link", "link",
|
||||||
|
"kliknij tutaj", "tutaj", "tu", "więcej", "czytaj więcej", "czytaj dalej", "ten link",
|
||||||
|
}
|
||||||
|
|
||||||
|
// headingSettings is the part of the settings row the heading rules read.
|
||||||
|
type headingSettings struct {
|
||||||
|
ContentH1ToH2 bool
|
||||||
|
GenericAnchors *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHeadingSettings reads the settings row (ID 1); found is false when
|
||||||
|
// there is no row or no database.
|
||||||
|
func readHeadingSettings(ctx context.Context, db *gorm.DB) (headingSettings, bool) {
|
||||||
|
if db == nil {
|
||||||
|
return headingSettings{}, false
|
||||||
|
}
|
||||||
|
if ctx != nil {
|
||||||
|
db = db.WithContext(ctx)
|
||||||
|
}
|
||||||
|
var rows []headingSettings
|
||||||
|
if err := db.Model(&models.Settings{}).Select("content_h1_to_h2", "generic_anchors").
|
||||||
|
Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||||
|
return headingSettings{}, false
|
||||||
|
}
|
||||||
|
return rows[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveH1ToH2 is the one reader of the H1 setting (P33-04): the settings
|
||||||
|
// row's content_h1_to_h2 decides; without a row (or a database) the config
|
||||||
|
// key golem15.journal.content_h1_to_h2 does, and true when that is unset
|
||||||
|
// too. While it is true every H1 saved in Journal rich text becomes an H2.
|
||||||
|
func EffectiveH1ToH2(ctx context.Context, db *gorm.DB, cfg *compass.Config) bool {
|
||||||
|
if row, ok := readHeadingSettings(ctx, db); ok {
|
||||||
|
return row.ContentH1ToH2
|
||||||
|
}
|
||||||
|
if cfg != nil && cfg.Has(H1ToH2Key) {
|
||||||
|
return cfg.Bool(H1ToH2Key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenericAnchors is the list of generic link texts: the settings row's
|
||||||
|
// generic_anchors, one entry per line, trimmed and lowercased with blank
|
||||||
|
// lines dropped, or DefaultGenericAnchors when the row has no list (no
|
||||||
|
// row, NULL or only blank lines).
|
||||||
|
func GenericAnchors(ctx context.Context, db *gorm.DB) []string {
|
||||||
|
row, ok := readHeadingSettings(ctx, db)
|
||||||
|
if !ok || row.GenericAnchors == nil {
|
||||||
|
return append([]string(nil), DefaultGenericAnchors...)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(*row.GenericAnchors, "\n") {
|
||||||
|
entry := strings.ToLower(strings.TrimSpace(line))
|
||||||
|
if entry == "" || seen[entry] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[entry] = true
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return append([]string(nil), DefaultGenericAnchors...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
61
classes/media_names.go
Normal file
61
classes/media_names.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxMediaStem is the longest file name stem MediaFileName keeps (D-85).
|
||||||
|
const MaxMediaStem = 80
|
||||||
|
|
||||||
|
// mediaExtPattern is an extension MediaFileName keeps.
|
||||||
|
var mediaExtPattern = regexp.MustCompile(`^\.[a-z0-9]+$`)
|
||||||
|
|
||||||
|
// polishLetters transliterates the Polish diacritics (lower case).
|
||||||
|
var polishLetters = map[rune]rune{'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z'}
|
||||||
|
|
||||||
|
// MediaFileName is the stored name of an uploaded image (D-85, the Blog
|
||||||
|
// Editor's J4 rule, applied on the server whatever the client sends): the
|
||||||
|
// base name's stem lowercased, Polish diacritics transliterated, every other
|
||||||
|
// run of characters outside a-z and 0-9 turned into one hyphen, trimmed and
|
||||||
|
// cut to MaxMediaStem characters, "image" when nothing is left; and its
|
||||||
|
// extension lowercased when it is a dot and letters or digits (dropped
|
||||||
|
// otherwise). "Lwia Grzywa – w lesie!.JPG" is "lwia-grzywa-w-lesie.jpg".
|
||||||
|
// The result needs no escaping in a URL or an src attribute.
|
||||||
|
func MediaFileName(name string) string {
|
||||||
|
base := path.Base(strings.ReplaceAll(name, `\`, "/"))
|
||||||
|
if base == "." || base == "/" {
|
||||||
|
base = ""
|
||||||
|
}
|
||||||
|
stem, ext := base, ""
|
||||||
|
if dot := strings.LastIndexByte(base, '.'); dot >= 0 {
|
||||||
|
if e := strings.ToLower(base[dot:]); mediaExtPattern.MatchString(e) {
|
||||||
|
stem, ext = base[:dot], e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
dash := false
|
||||||
|
for _, r := range strings.ToLower(stem) {
|
||||||
|
if t, ok := polishLetters[r]; ok {
|
||||||
|
r = t
|
||||||
|
}
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
b.WriteRune(r)
|
||||||
|
dash = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !dash {
|
||||||
|
b.WriteByte('-')
|
||||||
|
dash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Trim(b.String(), "-")
|
||||||
|
if len(out) > MaxMediaStem {
|
||||||
|
out = strings.TrimRight(out[:MaxMediaStem], "-")
|
||||||
|
}
|
||||||
|
if out == "" {
|
||||||
|
out = "image"
|
||||||
|
}
|
||||||
|
return out + ext
|
||||||
|
}
|
||||||
30
classes/media_names_test.go
Normal file
30
classes/media_names_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMediaFileName pins the D-85 slug rule shared with the editor's J4
|
||||||
|
// form (UI-SPEC J4 backstop).
|
||||||
|
func TestMediaFileName(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"Lwia Grzywa – w lesie!.JPG": "lwia-grzywa-w-lesie.jpg",
|
||||||
|
"": "image",
|
||||||
|
".png": "image.png",
|
||||||
|
"...": "image",
|
||||||
|
"ŻÓŁĆ gęślą jaźń.webp": "zolc-gesla-jazn.webp",
|
||||||
|
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
|
||||||
|
"../../etc/passwd.png": "passwd.png",
|
||||||
|
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
|
||||||
|
"name.with.dots.jpeg": "name-with-dots.jpeg",
|
||||||
|
"weird.ext$": "weird-ext",
|
||||||
|
"Okładka.png": "okladka.png",
|
||||||
|
strings.Repeat("a", 81) + ".gif": strings.Repeat("a", 80) + ".gif",
|
||||||
|
strings.Repeat("a", 79) + " b.gif": strings.Repeat("a", 79) + ".gif",
|
||||||
|
} {
|
||||||
|
if got := MediaFileName(in); got != want {
|
||||||
|
t.Errorf("MediaFileName(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,8 +74,9 @@ func validMediaFolder(folder string) bool {
|
|||||||
// /journal/<folder>/<name>, with a numbered suffix when the name is taken,
|
// /journal/<folder>/<name>, with a numbered suffix when the name is taken,
|
||||||
// and returns its public URL and path. It is the storage of POST
|
// and returns its public URL and path. It is the storage of POST
|
||||||
// /_journal/api/v1/media/upload and of the Blog Editor's image upload. The
|
// /_journal/api/v1/media/upload and of the Blog Editor's image upload. The
|
||||||
// file name's base is kept; its extension must be jpg, jpeg, png, gif or
|
// file name is stored as MediaFileName makes it (lowercase, hyphens, at
|
||||||
// webp, and the body may hold at most MediaMaxBytes. A refused upload is a
|
// most 80 characters before the extension); its extension must be jpg,
|
||||||
|
// jpeg, png, gif or webp, and the body may hold at most MediaMaxBytes. A refused upload is a
|
||||||
// *MediaError with the public route's message; any other error (no bucket,
|
// *MediaError with the public route's message; any other error (no bucket,
|
||||||
// a read or write failure) is not. It never replaces an existing object:
|
// a read or write failure) is not. It never replaces an existing object:
|
||||||
// concurrent calls in this process reserve the key they chose, and the
|
// concurrent calls in this process reserve the key they chose, and the
|
||||||
@@ -85,8 +86,9 @@ func StoreMedia(ctx context.Context, bucket *blob.Bucket, folder, filename strin
|
|||||||
if !validMediaFolder(folder) {
|
if !validMediaFolder(folder) {
|
||||||
return StoredMedia{}, &MediaError{Field: "folder", Message: "The folder format is invalid."}
|
return StoredMedia{}, &MediaError{Field: "folder", Message: "The folder format is invalid."}
|
||||||
}
|
}
|
||||||
name := path.Base(strings.ReplaceAll(filename, `\`, "/"))
|
// D-85: the stored name is slugged whatever the client sends.
|
||||||
switch strings.ToLower(strings.TrimPrefix(path.Ext(name), ".")) {
|
name := MediaFileName(filename)
|
||||||
|
switch strings.TrimPrefix(path.Ext(name), ".") {
|
||||||
case "jpg", "jpeg", "png", "gif", "webp":
|
case "jpg", "jpeg", "png", "gif", "webp":
|
||||||
default:
|
default:
|
||||||
return StoredMedia{}, &MediaError{Field: "file", Message: "The file must be an image."}
|
return StoredMedia{}, &MediaError{Field: "file", Message: "The file must be an image."}
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ func TestStoreMedia(t *testing.T) {
|
|||||||
t.Run("allowed extensions, key layout and URL", func(t *testing.T) {
|
t.Run("allowed extensions, key layout and URL", func(t *testing.T) {
|
||||||
for _, tc := range []struct{ folder, name, path string }{
|
for _, tc := range []struct{ folder, name, path string }{
|
||||||
{"photos", "a.jpg", "/journal/photos/a.jpg"},
|
{"photos", "a.jpg", "/journal/photos/a.jpg"},
|
||||||
{"/journal/photos/", "b.JPEG", "/journal/photos/b.JPEG"},
|
{"/journal/photos/", "b.JPEG", "/journal/photos/b.jpeg"},
|
||||||
|
// D-85: the stored name is slugged.
|
||||||
|
{"photos", "Lwia Grzywa – w lesie!.JPG", "/journal/photos/lwia-grzywa-w-lesie.jpg"},
|
||||||
{"", "c.png", "/journal/c.png"},
|
{"", "c.png", "/journal/c.png"},
|
||||||
{"x//y", "d.gif", "/journal/x/y/d.gif"},
|
{"x//y", "d.gif", "/journal/x/y/d.gif"},
|
||||||
{"Journal", "e.webp", "/journal/e.webp"},
|
{"Journal", "e.webp", "/journal/e.webp"},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -24,6 +26,11 @@ 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"
|
||||||
|
// MenuTypeAuthor is one author's page; it has no PHP counterpart
|
||||||
|
// (Phase 33, D-43).
|
||||||
|
MenuTypeAuthor = "journal-author"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The phrase keys of the menu item type labels.
|
// The phrase keys of the menu item type labels.
|
||||||
@@ -33,6 +40,8 @@ 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"
|
||||||
|
menuLabelAuthor = "golem15.journal::lang.menuitem.journal_author"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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 +52,11 @@ 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"
|
||||||
|
// DefaultAuthorPattern is the author route of the vue-journal-layer;
|
||||||
|
// :author is the author slug, else the login (P33-07).
|
||||||
|
DefaultAuthorPattern = "/blog/author/:author"
|
||||||
)
|
)
|
||||||
|
|
||||||
// xDefaultLocale is the single alternate's locale when translate has no
|
// xDefaultLocale is the single alternate's locale when translate has no
|
||||||
@@ -54,6 +68,8 @@ 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"}
|
||||||
|
authorPlaceholders = []string{":author", ":id"}
|
||||||
)
|
)
|
||||||
|
|
||||||
// MenuItemTypes returns the Journal menu item types, the typed successors
|
// MenuItemTypes returns the Journal menu item types, the typed successors
|
||||||
@@ -74,6 +90,19 @@ 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-author: one author's page, picked from the backend users with
|
||||||
|
// at least one published post (References lists them by name); it
|
||||||
|
// resolves to /blog/author/:author by default, :author being the author
|
||||||
|
// slug, else the login, or to nothing when the host has no author pages
|
||||||
|
// (AuthorPagesKey false). It has no PHP counterpart (Phase 33, D-43).
|
||||||
|
//
|
||||||
|
// journal-category, journal-post, journal-tag and journal-author 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 +136,15 @@ 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,
|
||||||
|
Kind: pact.ContentKindCollection,
|
||||||
|
Tools: map[string]string{pact.ContentToolHeadings: toolFixHeadings},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Key: MenuTypeAllCategories,
|
Key: MenuTypeAllCategories,
|
||||||
@@ -124,6 +162,19 @@ 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,
|
||||||
|
Kind: pact.ContentKindArticle,
|
||||||
|
Tools: map[string]string{
|
||||||
|
pact.ContentToolEdit: toolUpdatePost,
|
||||||
|
pact.ContentToolHeadings: toolFixHeadings,
|
||||||
|
pact.ContentToolImageAlt: toolSetImageAlt,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Key: MenuTypeAllPosts,
|
Key: MenuTypeAllPosts,
|
||||||
@@ -144,6 +195,39 @@ 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,
|
||||||
|
Kind: pact.ContentKindCollection,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: MenuTypeAuthor,
|
||||||
|
Label: menuLabelAuthor,
|
||||||
|
DefaultPattern: DefaultAuthorPattern,
|
||||||
|
Placeholders: append([]string(nil), authorPlaceholders...),
|
||||||
|
References: m.authorReferences,
|
||||||
|
Resolve: m.resolveAuthor,
|
||||||
|
Content: &pact.ContentDescriptor{
|
||||||
|
// Authors are backend users; they have no Journal admin form.
|
||||||
|
Controller: "",
|
||||||
|
Model: cabana.BackendUserMorphType,
|
||||||
|
Records: m.listAuthors,
|
||||||
|
Describe: m.describeAuthor,
|
||||||
|
Match: m.matchAuthor,
|
||||||
|
Kind: pact.ContentKindProfile,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,3 +847,382 @@ func phpTruthy(v any) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publishedAuthors restricts a backend_users query to live users with at
|
||||||
|
// least one published post.
|
||||||
|
func publishedAuthors(db *gorm.DB) *gorm.DB {
|
||||||
|
return db.Table("backend_users").Select(authorColumns).
|
||||||
|
Where("deleted_at IS NULL").
|
||||||
|
Where("id IN (?)", publishedPosts(db.Session(&gorm.Session{NewDB: true}).Model(&models.Post{})).Select("user_id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorLastMods maps each author id to the newest update of their
|
||||||
|
// published posts.
|
||||||
|
func authorLastMods(ctx context.Context, db *gorm.DB, ids []uint) (map[uint]time.Time, error) {
|
||||||
|
out := map[uint]time.Time{}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
UserID uint `gorm:"column:user_id"`
|
||||||
|
LastMod *time.Time `gorm:"column:last_mod"`
|
||||||
|
}
|
||||||
|
if err := publishedPosts(db.WithContext(ctx).Model(&models.Post{})).
|
||||||
|
Select("user_id, MAX(updated_at) AS last_mod").Where("user_id IN ?", ids).
|
||||||
|
Group("user_id").Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.LastMod != nil {
|
||||||
|
out[r.UserID] = *r.LastMod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorParams are an author's placeholder values (not translatable).
|
||||||
|
func authorParams(a authorRow) func(string) [][2]string {
|
||||||
|
return func(string) [][2]string {
|
||||||
|
return [][2]string{{"author", a.slug()}, {"id", strconv.FormatUint(uint64(a.ID), 10)}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorReferences lists the authors with a published post by name, ties
|
||||||
|
// by id.
|
||||||
|
func (m menuTypes) authorReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||||
|
db, err := m.db(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rows []authorRow
|
||||||
|
if err := publishedAuthors(db).Find(&rows).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("golem15.journal: menu item author references: %w", err)
|
||||||
|
}
|
||||||
|
refs := make([]pact.MenuItemReference, 0, len(rows))
|
||||||
|
for _, a := range rows {
|
||||||
|
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(a.ID), 10), Label: a.name()})
|
||||||
|
}
|
||||||
|
sortReferences(refs)
|
||||||
|
return refs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortReferences orders references by label, ties by id.
|
||||||
|
func sortReferences(refs []pact.MenuItemReference) {
|
||||||
|
sort.SliceStable(refs, func(i, j int) bool {
|
||||||
|
if refs[i].Label != refs[j].Label {
|
||||||
|
return refs[i].Label < refs[j].Label
|
||||||
|
}
|
||||||
|
a, _ := strconv.ParseUint(refs[i].Value, 10, 64)
|
||||||
|
b, _ := strconv.ParseUint(refs[j].Value, 10, 64)
|
||||||
|
return a < b
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPublishedAuthor reads one author with a published post by id.
|
||||||
|
func findPublishedAuthor(db *gorm.DB, id uint) (*authorRow, error) {
|
||||||
|
var rows []authorRow
|
||||||
|
if err := publishedAuthors(db).Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rows[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAuthor resolves one journal-author item; an unknown author, one
|
||||||
|
// without a published post, and any author on a host without author pages
|
||||||
|
// resolve to nothing.
|
||||||
|
func (m menuTypes) resolveAuthor(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||||
|
id, ok := menuReferenceID(in.Reference)
|
||||||
|
if !ok || !m.authorPages() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
db, err := m.db(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a, err := findPublishedAuthor(db, id)
|
||||||
|
if err != nil || a == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mods, err := authorLastMods(ctx, db, []uint{a.ID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var mtime *time.Time
|
||||||
|
if t, ok := mods[a.ID]; ok {
|
||||||
|
mtime = &t
|
||||||
|
}
|
||||||
|
name := a.name()
|
||||||
|
item := resolvedMenuItem(in.Pattern, locales, name, mtime, authorParams(*a), func(string) string { return name })
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAuthors is the Records of journal-author: the authors with a
|
||||||
|
// published post, in id order.
|
||||||
|
func (m menuTypes) listAuthors(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 rows []authorRow
|
||||||
|
if err := publishedAuthors(db).Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&rows).Error; err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
more := len(rows) > limit
|
||||||
|
if more {
|
||||||
|
rows = rows[:limit]
|
||||||
|
}
|
||||||
|
records, err := m.describeAuthors(ctx, db, rows, true, in.Pattern, in.UnprefixedLocale)
|
||||||
|
if err != nil {
|
||||||
|
return pact.ContentPage{}, err
|
||||||
|
}
|
||||||
|
return contentPage(records, more), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeAuthor describes one backend user as an author page; Published
|
||||||
|
// tells whether they have a published post (the only authors with a page).
|
||||||
|
func (m menuTypes) describeAuthor(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 rows []authorRow
|
||||||
|
if err := db.Table("backend_users").Select(authorColumns).Where("deleted_at IS NULL AND id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
published, err := findPublishedAuthor(db, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
records, err := m.describeAuthors(ctx, db, rows, published != nil, in.Pattern, in.UnprefixedLocale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &records[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeAuthors describes authors: the name as every locale's Title, the
|
||||||
|
// author URL per locale (empty without author pages, and in a locale in
|
||||||
|
// which none of their published posts has content, P33-64) and the newest
|
||||||
|
// update of their published posts as LastMod.
|
||||||
|
func (m menuTypes) describeAuthors(ctx context.Context, db *gorm.DB, rows []authorRow, published bool, pattern, unprefixed string) ([]pact.ContentRecord, error) {
|
||||||
|
locales, err := loadMenuLocales(ctx, db, unprefixed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(rows))
|
||||||
|
for _, a := range rows {
|
||||||
|
ids = append(ids, a.ID)
|
||||||
|
}
|
||||||
|
mods, err := authorLastMods(ctx, db, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pages := m.authorPages()
|
||||||
|
content, err := authorContentLocales(ctx, db, locales, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]pact.ContentRecord, 0, len(rows))
|
||||||
|
for _, a := range rows {
|
||||||
|
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(a.ID), 10), Published: published}
|
||||||
|
if t, ok := mods[a.ID]; ok {
|
||||||
|
rec.LastMod = &t
|
||||||
|
}
|
||||||
|
name := a.name()
|
||||||
|
for _, alt := range menuAlternates(pattern, locales, authorParams(a), func(string) string { return name }) {
|
||||||
|
url := alt.URL
|
||||||
|
if !pages || !ownerHasContent(locales, content, a.ID, alt.Locale) {
|
||||||
|
url = ""
|
||||||
|
}
|
||||||
|
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: name})
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchAuthor is the Match of journal-author: by :author (the author slug,
|
||||||
|
// then the login) or :id among the authors with a published post, exact
|
||||||
|
// only on the author's own URL; nothing matches on a host without author
|
||||||
|
// pages.
|
||||||
|
func (m menuTypes) matchAuthor(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||||
|
if !m.authorPages() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
db, err := m.contentDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
find := func(values map[string]string, _ string, _ menuLocales) (uint, error) {
|
||||||
|
var rows []authorRow
|
||||||
|
switch {
|
||||||
|
case values["author"] != "":
|
||||||
|
for _, cond := range []string{"golem15_bloghub_author_slug = ?", "login = ?"} {
|
||||||
|
if err := publishedAuthors(db).Where(cond, values["author"]).Order("id ASC").Limit(1).Find(&rows).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(rows) > 0 {
|
||||||
|
return rows[0].ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
case values["id"] != "":
|
||||||
|
id, ok := menuReferenceID(values["id"])
|
||||||
|
if !ok {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
a, err := findPublishedAuthor(db, id)
|
||||||
|
if err != nil || a == nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return a.ID, nil
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
|
||||||
|
a, err := findPublishedAuthor(db, id)
|
||||||
|
if err != nil || a == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := a.name()
|
||||||
|
return menuAlternates(in.Pattern, locales, authorParams(*a), func(string) string { return name }), nil
|
||||||
|
}
|
||||||
|
return matchContent(ctx, db, in, find, urls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The text fields whose translation gives a post or a category content in
|
||||||
|
// a locale (P33-64).
|
||||||
|
var (
|
||||||
|
postContentFields = []string{"title", "content", "content_html", "excerpt"}
|
||||||
|
categoryContentFields = []string{"name", "description"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// hostLocale is the locale whose values are the host columns: translate's
|
||||||
|
// default locale, else the unprefixed one, else the first enabled one.
|
||||||
|
func (l menuLocales) hostLocale() string {
|
||||||
|
switch {
|
||||||
|
case l.defaultCode != "":
|
||||||
|
return l.defaultCode
|
||||||
|
case l.unprefixed != "":
|
||||||
|
return l.unprefixed
|
||||||
|
case len(l.codes) > 0:
|
||||||
|
return l.codes[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasContent reports whether record id has content in locale (P33-64, D-57):
|
||||||
|
// the host locale (and x-default, without enabled locales) always; another
|
||||||
|
// locale when the record's translation in it holds a truthy (phpTruthy)
|
||||||
|
// value of one of fields.
|
||||||
|
func (t menuTranslations) hasContent(locales menuLocales, locale string, id uint, fields []string) bool {
|
||||||
|
if locale == "" || locale == xDefaultLocale || locale == locales.hostLocale() || locale == t.defaultCode {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
row := t.data[locale][id]
|
||||||
|
for _, f := range fields {
|
||||||
|
if phpTruthy(row[f]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// postContentLocales maps each owner (a tag or an author) to the locales
|
||||||
|
// in which at least one of its published posts has content (P33-64): the
|
||||||
|
// posts are read with one query (postsOf answers owner id and post id
|
||||||
|
// pairs), their translations with one more.
|
||||||
|
func postContentLocales(ctx context.Context, db *gorm.DB, locales menuLocales, pairs []ownerPost) (map[uint]map[string]bool, error) {
|
||||||
|
out := map[uint]map[string]bool{}
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
postIDs := make([]uint, 0, len(pairs))
|
||||||
|
seen := map[uint]bool{}
|
||||||
|
for _, p := range pairs {
|
||||||
|
if !seen[p.PostID] {
|
||||||
|
seen[p.PostID] = true
|
||||||
|
postIDs = append(postIDs, p.PostID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr, err := loadMenuTranslations(ctx, db, models.Post{}.MorphName(), postIDs, locales)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range pairs {
|
||||||
|
for _, code := range locales.codes {
|
||||||
|
if tr.hasContent(locales, code, p.PostID, postContentFields) {
|
||||||
|
if out[p.OwnerID] == nil {
|
||||||
|
out[p.OwnerID] = map[string]bool{}
|
||||||
|
}
|
||||||
|
out[p.OwnerID][code] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerPost is one published post of a tag or an author.
|
||||||
|
type ownerPost struct {
|
||||||
|
OwnerID uint `gorm:"column:owner_id"`
|
||||||
|
PostID uint `gorm:"column:post_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagContentLocales is postContentLocales for tags, through the posts-tags
|
||||||
|
// pivot.
|
||||||
|
func tagContentLocales(ctx context.Context, db *gorm.DB, locales menuLocales, tagIDs []uint) (map[uint]map[string]bool, error) {
|
||||||
|
if len(tagIDs) == 0 {
|
||||||
|
return map[uint]map[string]bool{}, nil
|
||||||
|
}
|
||||||
|
var pairs []ownerPost
|
||||||
|
posts := publishedPosts(db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Model(&models.Post{})).Select("id")
|
||||||
|
if err := db.WithContext(ctx).Table(models.PostTag{}.TableName()).
|
||||||
|
Select("tag_id AS owner_id, post_id").
|
||||||
|
Where("tag_id IN ? AND post_id IN (?)", tagIDs, posts).
|
||||||
|
Scan(&pairs).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("golem15.journal: tag posts: %w", err)
|
||||||
|
}
|
||||||
|
return postContentLocales(ctx, db, locales, pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorContentLocales is postContentLocales for authors, by the posts'
|
||||||
|
// user_id.
|
||||||
|
func authorContentLocales(ctx context.Context, db *gorm.DB, locales menuLocales, userIDs []uint) (map[uint]map[string]bool, error) {
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return map[uint]map[string]bool{}, nil
|
||||||
|
}
|
||||||
|
var pairs []ownerPost
|
||||||
|
if err := publishedPosts(db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Model(&models.Post{})).
|
||||||
|
Select("user_id AS owner_id, id AS post_id").
|
||||||
|
Where("user_id IN ?", userIDs).
|
||||||
|
Scan(&pairs).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("golem15.journal: author posts: %w", err)
|
||||||
|
}
|
||||||
|
return postContentLocales(ctx, db, locales, pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerHasContent reports whether an owner (tag or author) has content in
|
||||||
|
// locale: the host locale (and x-default) always, another locale when one
|
||||||
|
// of its published posts has content in it.
|
||||||
|
func ownerHasContent(locales menuLocales, byOwner map[uint]map[string]bool, id uint, locale string) bool {
|
||||||
|
if locale == "" || locale == xDefaultLocale || locale == locales.hostLocale() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return byOwner[id][locale]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 seven
|
||||||
// registered types, the ordered placeholder fill of PHP buildUrlFromPattern,
|
// registered types (the five PHP ones, journal-tag and journal-author), 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 seven 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,8 @@ 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",
|
||||||
|
"journal-author|/blog/author/:author||nest=false|dyn=false|refs=true|:author,:id|golem15.journal::lang.menuitem.journal_author",
|
||||||
}
|
}
|
||||||
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"))
|
||||||
|
|||||||
176
classes/related.go
Normal file
176
classes/related.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Related posts and category neighbours of a post (D-82, P33-44). Opt-in:
|
||||||
|
// golem15.journal.related_mode is "category" by default, which keeps the
|
||||||
|
// post answer as it has always been (posts sharing a category, three,
|
||||||
|
// newest first). "scored" ranks the related posts by shared tags and shared
|
||||||
|
// categories and adds the neighbours within the post's first category.
|
||||||
|
const (
|
||||||
|
// RelatedModeKey is the config key of the related-posts mode.
|
||||||
|
RelatedModeKey = "golem15.journal.related_mode"
|
||||||
|
// RelatedCountKey is the config key of the number of scored related
|
||||||
|
// posts, clamped to RelatedCountMin..RelatedCountMax.
|
||||||
|
RelatedCountKey = "golem15.journal.related_count"
|
||||||
|
|
||||||
|
// RelatedModeCategory is the default: today's related posts.
|
||||||
|
RelatedModeCategory = "category"
|
||||||
|
// RelatedModeScored ranks by shared tags and categories.
|
||||||
|
RelatedModeScored = "scored"
|
||||||
|
|
||||||
|
RelatedCountDefault = 3
|
||||||
|
RelatedCountMin = 3
|
||||||
|
RelatedCountMax = 6
|
||||||
|
|
||||||
|
// Points per shared tag and per shared category.
|
||||||
|
relatedTagPoints = 2
|
||||||
|
relatedCategoryPoints = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelatedSettings is the related-posts configuration.
|
||||||
|
type RelatedSettings struct {
|
||||||
|
// Mode is RelatedModeCategory or RelatedModeScored.
|
||||||
|
Mode string
|
||||||
|
// Count is the number of scored related posts (3 to 6).
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scored reports whether the scored mode is on.
|
||||||
|
func (s RelatedSettings) Scored() bool { return s.Mode == RelatedModeScored }
|
||||||
|
|
||||||
|
// ReadRelatedSettings reads golem15.journal.related_mode (anything but
|
||||||
|
// "scored" is the category mode) and golem15.journal.related_count (unset
|
||||||
|
// or not a number: 3; otherwise clamped to 3..6).
|
||||||
|
func ReadRelatedSettings(cfg *compass.Config) RelatedSettings {
|
||||||
|
out := RelatedSettings{Mode: RelatedModeCategory, Count: RelatedCountDefault}
|
||||||
|
if cfg == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(cfg.String(RelatedModeKey)), RelatedModeScored) {
|
||||||
|
out.Mode = RelatedModeScored
|
||||||
|
}
|
||||||
|
if n := cfg.Int(RelatedCountKey); n > 0 {
|
||||||
|
out.Count = min(RelatedCountMax, max(RelatedCountMin, n))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishedClause is the visitor rule of the public API (applyPublished in
|
||||||
|
// controllers/api): published, with a publish time in the past.
|
||||||
|
const publishedClause = "p.published = ? AND p.published_at IS NOT NULL AND p.published_at < ?"
|
||||||
|
|
||||||
|
// ScoredRelatedPosts is the scored mode's related posts of post: other posts
|
||||||
|
// sharing a tag or a category, ranked by 2 points per shared tag plus 1
|
||||||
|
// point per shared category, ties by the newest publish time (then the
|
||||||
|
// higher id), at most count. Posts that share nothing are never related.
|
||||||
|
// Visitors see published posts only; editors (includeDrafts) also see
|
||||||
|
// drafts, as the category mode's related posts do. One query computes the
|
||||||
|
// scores; the rows come back with their author, categories and tags.
|
||||||
|
func ScoredRelatedPosts(ctx context.Context, db *gorm.DB, post *models.Post, count int, includeDrafts bool) ([]models.Post, error) {
|
||||||
|
if post == nil || count <= 0 {
|
||||||
|
return []models.Post{}, nil
|
||||||
|
}
|
||||||
|
db = db.WithContext(ctx)
|
||||||
|
q := db.Table("golem15_journal_posts AS p").
|
||||||
|
Select("p.id, ? * COALESCE(st.n, 0) + ? * COALESCE(sc.n, 0) AS score", relatedTagPoints, relatedCategoryPoints).
|
||||||
|
Joins(`LEFT JOIN (SELECT post_id, COUNT(*) AS n FROM golem15_journal_posts_tags
|
||||||
|
WHERE tag_id IN (SELECT tag_id FROM golem15_journal_posts_tags WHERE post_id = ?)
|
||||||
|
GROUP BY post_id) st ON st.post_id = p.id`, post.ID).
|
||||||
|
Joins(`LEFT JOIN (SELECT post_id, COUNT(*) AS n FROM golem15_journal_posts_categories
|
||||||
|
WHERE category_id IN (SELECT category_id FROM golem15_journal_posts_categories WHERE post_id = ?)
|
||||||
|
GROUP BY post_id) sc ON sc.post_id = p.id`, post.ID).
|
||||||
|
Where("p.id <> ?", post.ID).
|
||||||
|
Where("(st.n > 0 OR sc.n > 0)")
|
||||||
|
if !includeDrafts {
|
||||||
|
q = q.Where(publishedClause, true, time.Now())
|
||||||
|
}
|
||||||
|
var ranked []struct {
|
||||||
|
ID uint
|
||||||
|
Score int
|
||||||
|
}
|
||||||
|
if err := q.Order("score DESC, p.published_at DESC NULLS LAST, p.id DESC").Limit(count).Scan(&ranked).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(ranked) == 0 {
|
||||||
|
return []models.Post{}, nil
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(ranked))
|
||||||
|
for _, r := range ranked {
|
||||||
|
ids = append(ids, r.ID)
|
||||||
|
}
|
||||||
|
var rows []models.Post
|
||||||
|
if err := db.Model(&models.Post{}).Preload("User").Preload("Categories").Preload("Tags").
|
||||||
|
Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byID := make(map[uint]models.Post, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
byID[r.ID] = r
|
||||||
|
}
|
||||||
|
out := make([]models.Post, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if r, ok := byID[id]; ok {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrimaryCategoryID is the post's first category by name, ties by id (the
|
||||||
|
// order the :category URL param takes its category in); 0 without one.
|
||||||
|
func PrimaryCategoryID(ctx context.Context, db *gorm.DB, postID uint) uint {
|
||||||
|
var rows []struct{ ID uint }
|
||||||
|
err := db.WithContext(ctx).Table("golem15_journal_categories AS c").
|
||||||
|
Select("c.id").
|
||||||
|
Joins("JOIN golem15_journal_posts_categories AS pc ON pc.category_id = c.id").
|
||||||
|
Where("pc.post_id = ?", postID).
|
||||||
|
Order("c.name ASC, c.id ASC").
|
||||||
|
Limit(1).
|
||||||
|
Scan(&rows).Error
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return rows[0].ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryNeighbour is the post's neighbour by publish time within its
|
||||||
|
// first category, in the direction of the post answer's previous_post and
|
||||||
|
// next_post: previous is the nearest published post published after it,
|
||||||
|
// next the nearest one published before it (ties by id). nil without a
|
||||||
|
// category or a neighbour. Published posts only, as for previous_post.
|
||||||
|
func CategoryNeighbour(ctx context.Context, db *gorm.DB, post *models.Post, previous bool) *models.Post {
|
||||||
|
if post == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
category := PrimaryCategoryID(ctx, db, post.ID)
|
||||||
|
if category == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
op, dir := "<", "DESC"
|
||||||
|
if previous {
|
||||||
|
op, dir = ">", "ASC"
|
||||||
|
}
|
||||||
|
// The same query shape as the site-wide neighbours (siblingPost), plus
|
||||||
|
// the category, so the row is read the same way.
|
||||||
|
q := db.WithContext(ctx).Model(&models.Post{}).
|
||||||
|
Where("published = ? AND published_at IS NOT NULL AND published_at < ?", true, time.Now()).
|
||||||
|
Where("id <> ?", post.ID).
|
||||||
|
Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id = ?)", category)
|
||||||
|
if post.PublishedAt != nil {
|
||||||
|
q = q.Where("(published_at "+op+" ?) OR (published_at = ? AND id "+op+" ?)", post.PublishedAt, post.PublishedAt, post.ID)
|
||||||
|
}
|
||||||
|
var out models.Post
|
||||||
|
if err := q.Order("published_at " + dir + ", id " + dir).Take(&out).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &out
|
||||||
|
}
|
||||||
47
classes/related_test.go
Normal file
47
classes/related_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package classes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReadRelatedSettings pins the related-posts config: the category mode
|
||||||
|
// and 3 by default; "scored" in any case turns scoring on; the count
|
||||||
|
// clamped to 3..6, 0 or a non-number falling back to 3. The scoring and
|
||||||
|
// its ties by the newest post are TestJournalAPIMatrix's on the database.
|
||||||
|
func TestReadRelatedSettings(t *testing.T) {
|
||||||
|
if s := ReadRelatedSettings(nil); s.Mode != RelatedModeCategory || s.Count != 3 || s.Scored() {
|
||||||
|
t.Fatalf("nil config = %+v", s)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
mode string
|
||||||
|
count any
|
||||||
|
want RelatedSettings
|
||||||
|
}{
|
||||||
|
{"", nil, RelatedSettings{RelatedModeCategory, 3}},
|
||||||
|
{" Scored ", 5, RelatedSettings{RelatedModeScored, 5}},
|
||||||
|
{"scored", 1, RelatedSettings{RelatedModeScored, 3}},
|
||||||
|
{"scored", 99, RelatedSettings{RelatedModeScored, 6}},
|
||||||
|
{"category", 4, RelatedSettings{RelatedModeCategory, 4}},
|
||||||
|
{"tags", 0, RelatedSettings{RelatedModeCategory, 3}},
|
||||||
|
{"scored", "many", RelatedSettings{RelatedModeScored, 3}},
|
||||||
|
} {
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tc.mode != "" {
|
||||||
|
_ = cfg.Set(RelatedModeKey, tc.mode)
|
||||||
|
}
|
||||||
|
if tc.count != nil {
|
||||||
|
_ = cfg.Set(RelatedCountKey, tc.count)
|
||||||
|
}
|
||||||
|
if got := ReadRelatedSettings(cfg); got != tc.want {
|
||||||
|
t.Errorf("mode %q count %v = %+v, want %+v", tc.mode, tc.count, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if relatedTagPoints != 2 || relatedCategoryPoints != 1 {
|
||||||
|
t.Fatal("a shared tag is 2 points, a shared category 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
30
config/config.yaml
Normal file
30
config/config.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# golem15.journal defaults, read as golem15.journal.<key>.
|
||||||
|
#
|
||||||
|
# content_h1_to_h2 is the H1 setting used when the Journal settings row
|
||||||
|
# (Settings › Journal, Headings and links) does not exist; the row's own
|
||||||
|
# value decides otherwise. On, every H1 saved in Journal rich text (post
|
||||||
|
# content and category pillar text) becomes an H2 and the editor offers
|
||||||
|
# H2 to H4. Off, H1 headings are stored as written and the editor offers
|
||||||
|
# H1 to H4. Changing it never rewrites stored content; journal:fix-headings
|
||||||
|
# --apply does.
|
||||||
|
content_h1_to_h2: true
|
||||||
|
|
||||||
|
# Related posts on GET /_journal/api/v1/posts/{slug} (opt-in).
|
||||||
|
# related_mode "category" (the default) answers as before: up to three
|
||||||
|
# posts sharing a category with the post, newest first. "scored" ranks
|
||||||
|
# posts by shared tags (2 points each) and shared categories (1 point
|
||||||
|
# each), ties by the newest, returns related_count of them (3 to 6,
|
||||||
|
# clamped) and adds previous_in_category and next_in_category, the post's
|
||||||
|
# neighbours by publish time within its first category by name.
|
||||||
|
related_mode: category
|
||||||
|
related_count: 3
|
||||||
|
|
||||||
|
# Featured image variants (golem15.journal.image_variants job,
|
||||||
|
# journal:image-variants): after a featured image upload commits, a
|
||||||
|
# background job writes a thumbnail at each width (pixels, 16 to 4096,
|
||||||
|
# never wider than the original) and a WebP copy of each next to it. The
|
||||||
|
# API lists them as featured_images[].variants; the journal layer serves
|
||||||
|
# <picture> with WebP and the original format as fallback. An environment
|
||||||
|
# override takes a comma-separated list, e.g.
|
||||||
|
# SUMMER_GOLEM15__JOURNAL__IMAGE_WIDTHS=480,960.
|
||||||
|
image_widths: [480, 800, 1200]
|
||||||
55
console/database.go
Normal file
55
console/database.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lookupDB returns the app's database. The generated CLI publishes none for
|
||||||
|
// plugin commands (only serve and the framework's own commands open one),
|
||||||
|
// so without a published database it opens database.dsn and publishes it,
|
||||||
|
// as lagoon's migrate does. done closes a pool this call opened and is a
|
||||||
|
// no-op otherwise.
|
||||||
|
func lookupDB(ctx context.Context, app *backpack.App) (*gorm.DB, func(), error) {
|
||||||
|
if app == nil {
|
||||||
|
return nil, nil, errors.New("journal: app is nil")
|
||||||
|
}
|
||||||
|
if db := mustDB(app); db != nil {
|
||||||
|
return db, func() {}, nil
|
||||||
|
}
|
||||||
|
if lagoon.DSN(app.Config) == "" {
|
||||||
|
return nil, nil, errors.New("journal: database is not configured (set SUMMER_DATABASE__DSN)")
|
||||||
|
}
|
||||||
|
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return gdb, func() { _ = sqlDB.Close() }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupBucket returns the published uploads bucket, or opens
|
||||||
|
// storage.uploads.bucket_url when the process has none (the generated
|
||||||
|
// CLI). done closes a bucket this call opened and is a no-op otherwise.
|
||||||
|
func lookupBucket(ctx context.Context, app *backpack.App) (*blob.Bucket, func(), error) {
|
||||||
|
if app == nil {
|
||||||
|
return nil, nil, errors.New("journal: app is nil")
|
||||||
|
}
|
||||||
|
if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil {
|
||||||
|
return bucket, func() {}, nil
|
||||||
|
}
|
||||||
|
bucket, err := attach.OpenBucket(ctx, app.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return bucket, func() { _ = bucket.Close() }, nil
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -251,6 +252,14 @@ func isEmptyValue(v any) bool {
|
|||||||
return ok && s == ""
|
return ok && s == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appConfig is app's config, nil without an application.
|
||||||
|
func appConfig(app *backpack.App) *compass.Config {
|
||||||
|
if app == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Config
|
||||||
|
}
|
||||||
|
|
||||||
func mustDB(app *backpack.App) *gorm.DB {
|
func mustDB(app *backpack.App) *gorm.DB {
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
76
console/fix_headings.go
Normal file
76
console/fix_headings.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FixHeadings is journal:fix-headings: it converts H1 to H2 in stored
|
||||||
|
// Journal rich text (post content in every locale and category pillar text)
|
||||||
|
// while the Journal H1 setting is on. It is a dry run unless --apply is
|
||||||
|
// given; with the setting off it refuses with exit code 1. It runs from the
|
||||||
|
// generated application CLI: without a published database it opens
|
||||||
|
// database.dsn (lookupDB) and, without one, names SUMMER_DATABASE__DSN. Its
|
||||||
|
// output (UI-SPEC C27 with the P33-54 wording, English only):
|
||||||
|
//
|
||||||
|
// - dry run: "Would convert :count H1 headings in :posts posts and :categories categories. Run with --apply to save."
|
||||||
|
// - applied: "Converted :count H1 headings in :posts posts and :categories categories."
|
||||||
|
// - nothing to do: "No H1 headings found in post or category content."
|
||||||
|
// - refused (classes.H1ConversionOffText): "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings."
|
||||||
|
func FixHeadings(app *backpack.App) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "journal:fix-headings",
|
||||||
|
Description: "Convert H1 headings to H2 in stored post and category content",
|
||||||
|
Long: `Finds every H1 heading in stored Journal content: post content and
|
||||||
|
content_html in the default locale and in every translation, and the pillar
|
||||||
|
text of every category. Switching the Journal H1 setting (Settings › Journal,
|
||||||
|
Headings and links) on never rewrites stored content; this command does.
|
||||||
|
|
||||||
|
By default it only counts ("Would convert N H1 headings in N posts and N
|
||||||
|
categories. Run with --apply to save."). With --apply it converts them in
|
||||||
|
one transaction, fires a record event per changed post and category (so an
|
||||||
|
SEO or search plugin hears about it) and queues the site cache purge.
|
||||||
|
|
||||||
|
While the H1 setting is off the command prints "H1 to H2 conversion is
|
||||||
|
turned off in the Journal settings (Headings and links). Turn it on first, or
|
||||||
|
keep the H1 headings." and exits with code 1. The MCP tool
|
||||||
|
journal_fix_headings runs the same conversion.`,
|
||||||
|
Flags: []bonfire.Flag{
|
||||||
|
{Name: "apply", Description: "Save the converted content (default: dry run)", Bare: true},
|
||||||
|
{Name: "dry-run", Description: "Only count the H1 headings (the default)", Bare: true},
|
||||||
|
},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "journal:fix-headings", Description: "Count the H1 headings that would be converted"},
|
||||||
|
{Command: "journal:fix-headings --apply", Description: "Convert them and save"},
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
|
db, done, err := lookupDB(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer done()
|
||||||
|
if !classes.EffectiveH1ToH2(ctx, db, appConfig(app)) {
|
||||||
|
out.Error(classes.H1ConversionOffText)
|
||||||
|
return bonfire.Reported(classes.ErrH1ConversionOff)
|
||||||
|
}
|
||||||
|
apply := flagOn(in, "apply") && !flagOn(in, "dry-run")
|
||||||
|
res, err := classes.FixStoredHeadings(ctx, app, db, apply)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case res.Headings == 0:
|
||||||
|
out.Info("No H1 headings found in post or category content.")
|
||||||
|
case apply:
|
||||||
|
out.Success(fmt.Sprintf("Converted %d H1 headings in %d posts and %d categories.", res.Headings, res.Posts, res.Categories))
|
||||||
|
default:
|
||||||
|
out.Info(fmt.Sprintf("Would convert %d H1 headings in %d posts and %d categories. Run with --apply to save.", res.Headings, res.Posts, res.Categories))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
71
console/image_variants.go
Normal file
71
console/image_variants.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageVariants is journal:image-variants: it writes the thumbnails and
|
||||||
|
// WebP copies of the posts' featured images at golem15.journal.image_widths
|
||||||
|
// in this process (the backfill of the golem15.journal.image_variants job).
|
||||||
|
// Without --all it takes the images that lack a variant of some width.
|
||||||
|
func ImageVariants(app *backpack.App) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "journal:image-variants",
|
||||||
|
Description: "Write the WebP and resized variants of featured images",
|
||||||
|
Long: `Writes, for every post's featured image, a thumbnail at each width of
|
||||||
|
golem15.journal.image_widths (480, 800 and 1200 by default) that is not
|
||||||
|
wider than the original, and a WebP copy of each next to it, and records
|
||||||
|
them in golem15_journal_image_variants. The Journal API lists them as
|
||||||
|
featured_images[].variants and the journal layer serves them through
|
||||||
|
<picture> with the original format as fallback.
|
||||||
|
|
||||||
|
New uploads get their variants from the golem15.journal.image_variants job,
|
||||||
|
queued after the upload commits. This command is the backfill: without
|
||||||
|
--all it takes the images that lack a variant of some width (for example
|
||||||
|
after adding a width), with --all it checks every featured image. A
|
||||||
|
variant that exists is never written again, so the command can run any
|
||||||
|
number of times.
|
||||||
|
|
||||||
|
It runs from the application's CLI as well as next to serve: without a
|
||||||
|
running server it opens database.dsn and storage.uploads.bucket_url
|
||||||
|
itself.`,
|
||||||
|
Flags: []bonfire.Flag{
|
||||||
|
{Name: "all", Description: "Check every featured image (default: only those missing a width)", Bare: true},
|
||||||
|
},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "journal:image-variants", Description: "Write the variants images are missing"},
|
||||||
|
{Command: "journal:image-variants --all", Description: "Check every featured image"},
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
|
db, done, err := lookupDB(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer done()
|
||||||
|
bucket, closeBucket, err := lookupBucket(ctx, app)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer closeBucket()
|
||||||
|
ids, err := classes.FeaturedImageIDs(db.WithContext(ctx), appConfig(app), flagOn(in, "all"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
out.Info("Every featured image has its variants.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
res, err := classes.GenerateImageVariants(ctx, db, bucket, classes.ImageWidths(appConfig(app)), ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Success(fmt.Sprintf("Checked %d featured images, wrote %d variants, skipped %d.", res.Files, res.Written, res.Skipped))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +34,9 @@ type ImportStats struct {
|
|||||||
Updated int
|
Updated int
|
||||||
Skipped int
|
Skipped int
|
||||||
Failed int
|
Failed int
|
||||||
|
// H1Converted counts the H1 headings the import stored as H2 while the
|
||||||
|
// Journal H1 setting is on (default and translated content).
|
||||||
|
H1Converted int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportPosts is journal:import-posts (PHP ImportPosts.php).
|
// ImportPosts is journal:import-posts (PHP ImportPosts.php).
|
||||||
@@ -65,13 +69,16 @@ failed".`,
|
|||||||
path := flagOrDefault(in, "path", defaultExportPath)
|
path := flagOrDefault(in, "path", defaultExportPath)
|
||||||
force := flagOn(in, "force")
|
force := flagOn(in, "force")
|
||||||
dry := flagOn(in, "dry-run")
|
dry := flagOn(in, "dry-run")
|
||||||
stats, err := ImportFromDir(ctx, mustDB(app), path, force, dry, out, nil)
|
stats, err := ImportFromDirConfig(ctx, mustDB(app), appConfig(app), path, force, dry, out, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if dry {
|
if dry {
|
||||||
out.Info("Dry run complete (no changes made).")
|
out.Info("Dry run complete (no changes made).")
|
||||||
}
|
}
|
||||||
|
if stats.H1Converted > 0 {
|
||||||
|
out.Info(fmt.Sprintf("Converted %d H1 headings to H2.", stats.H1Converted))
|
||||||
|
}
|
||||||
out.Success(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
|
out.Success(fmt.Sprintf("Results: %d created, %d updated, %d skipped, %d failed",
|
||||||
stats.Created, stats.Updated, stats.Skipped, stats.Failed))
|
stats.Created, stats.Updated, stats.Skipped, stats.Failed))
|
||||||
return nil
|
return nil
|
||||||
@@ -80,7 +87,17 @@ failed".`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ImportFromDir reads *.json files from dir and creates or updates posts.
|
// ImportFromDir reads *.json files from dir and creates or updates posts.
|
||||||
|
// It is ImportFromDirConfig without a config: the Journal H1 setting is read
|
||||||
|
// from the settings row alone (on when there is none).
|
||||||
func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry bool, out bonfire.Output, guard ImportGuard) (ImportStats, error) {
|
func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry bool, out bonfire.Output, guard ImportGuard) (ImportStats, error) {
|
||||||
|
return ImportFromDirConfig(ctx, gdb, nil, dir, force, dry, out, guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportFromDirConfig is ImportFromDir with the application config the
|
||||||
|
// Journal H1 setting falls back to (classes.EffectiveH1ToH2). While the
|
||||||
|
// setting is on, every H1 the import stores becomes an H2, counted in
|
||||||
|
// ImportStats.H1Converted.
|
||||||
|
func ImportFromDirConfig(ctx context.Context, gdb *gorm.DB, cfg *compass.Config, dir string, force, dry bool, out bonfire.Output, guard ImportGuard) (ImportStats, error) {
|
||||||
var stats ImportStats
|
var stats ImportStats
|
||||||
if gdb == nil {
|
if gdb == nil {
|
||||||
return stats, fmt.Errorf("journal: database is not configured")
|
return stats, fmt.Errorf("journal: database is not configured")
|
||||||
@@ -100,6 +117,7 @@ func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry boo
|
|||||||
return stats, fmt.Errorf("no JSON files found in: %s", dir)
|
return stats, fmt.Errorf("no JSON files found in: %s", dir)
|
||||||
}
|
}
|
||||||
work := func(tx *gorm.DB) error {
|
work := func(tx *gorm.DB) error {
|
||||||
|
h1ToH2 := classes.EffectiveH1ToH2(ctx, tx, cfg)
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
raw, err := os.ReadFile(file)
|
raw, err := os.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,10 +142,11 @@ func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry boo
|
|||||||
stats.Failed++
|
stats.Failed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result, err := importPost(ctx, tx, data, force, dry, out, guard)
|
result, converted, err := importPost(ctx, tx, data, force, dry, h1ToH2, out, guard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
stats.H1Converted += converted
|
||||||
switch result {
|
switch result {
|
||||||
case "created":
|
case "created":
|
||||||
stats.Created++
|
stats.Created++
|
||||||
@@ -153,16 +172,18 @@ func ImportFromDir(ctx context.Context, gdb *gorm.DB, dir string, force, dry boo
|
|||||||
return stats, err
|
return stats, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry bool, out bonfire.Output, guard ImportGuard) (string, error) {
|
// importPost writes one post file. converted is the number of H1 headings
|
||||||
|
// stored as H2 (h1ToH2 on); a dry run converts nothing.
|
||||||
|
func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry, h1ToH2 bool, out bonfire.Output, guard ImportGuard) (string, int, error) {
|
||||||
var existing models.Post
|
var existing models.Post
|
||||||
err := db.Where("slug = ?", data.Slug).Take(&existing).Error
|
err := db.Where("slug = ?", data.Slug).Take(&existing).Error
|
||||||
found := err == nil
|
found := err == nil
|
||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
if found && !force {
|
if found && !force {
|
||||||
line(out, " [skip] %s (already exists)", data.Slug)
|
line(out, " [skip] %s (already exists)", data.Slug)
|
||||||
return "skipped", nil
|
return "skipped", 0, nil
|
||||||
}
|
}
|
||||||
post := &models.Post{}
|
post := &models.Post{}
|
||||||
label := "created"
|
label := "created"
|
||||||
@@ -184,44 +205,50 @@ func importPost(ctx context.Context, db *gorm.DB, data PostFile, force, dry bool
|
|||||||
fillPost(db, draft, data)
|
fillPost(db, draft, data)
|
||||||
if guard != nil {
|
if guard != nil {
|
||||||
if err := guard(draft); err != nil {
|
if err := guard(draft); err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found {
|
if found {
|
||||||
return "updated", nil
|
return "updated", 0, nil
|
||||||
}
|
}
|
||||||
return "created", nil
|
return "created", 0, nil
|
||||||
}
|
}
|
||||||
fillPost(db, post, data)
|
fillPost(db, post, data)
|
||||||
if guard != nil {
|
if guard != nil {
|
||||||
if err := guard(post); err != nil {
|
if err := guard(post); err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
html, err := classes.FormatHTML(post.Content)
|
html, err := classes.FormatHTML(post.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
post.ContentHTML = html
|
post.ContentHTML = html
|
||||||
|
converted := 0
|
||||||
|
if h1ToH2 {
|
||||||
|
post.Content, post.ContentHTML, converted = classes.ConvertContentH1(post.Content, post.ContentHTML)
|
||||||
|
}
|
||||||
if found {
|
if found {
|
||||||
if err := db.Save(post).Error; err != nil {
|
if err := db.Save(post).Error; err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := db.Create(post).Error; err != nil {
|
if err := db.Create(post).Error; err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := syncCategories(ctx, db, post, data, out); err != nil {
|
if err := syncCategories(ctx, db, post, data, out); err != nil {
|
||||||
return "failed", err
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
if err := syncTranslations(ctx, db, post, data.Translations); err != nil {
|
n, err := syncTranslations(ctx, db, post, data.Translations, h1ToH2)
|
||||||
return "failed", err
|
if err != nil {
|
||||||
|
return "failed", 0, err
|
||||||
}
|
}
|
||||||
|
converted += n
|
||||||
if found {
|
if found {
|
||||||
return "updated", nil
|
return "updated", converted, nil
|
||||||
}
|
}
|
||||||
return "created", nil
|
return "created", converted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fillPost(db *gorm.DB, post *models.Post, data PostFile) {
|
func fillPost(db *gorm.DB, post *models.Post, data PostFile) {
|
||||||
@@ -322,16 +349,22 @@ func titleFromSlug(slug string) string {
|
|||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, translations map[string]map[string]any) error {
|
// syncTranslations writes the file's translations and returns the number
|
||||||
|
// of H1 headings stored as H2 (h1ToH2 on).
|
||||||
|
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, translations map[string]map[string]any, h1ToH2 bool) (int, error) {
|
||||||
if len(translations) == 0 {
|
if len(translations) == 0 {
|
||||||
return nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
converted := 0
|
||||||
fields := []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
|
fields := []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
|
||||||
for locale, attrs := range translations {
|
for locale, attrs := range translations {
|
||||||
// A translated content_html is derived from content or sanitized,
|
// A translated content_html is derived from content or sanitized,
|
||||||
// as the host column is; Winter's ImportPosts stored it verbatim.
|
// as the host column is; Winter's ImportPosts stored it verbatim.
|
||||||
if err := classes.FormatTranslation(attrs); err != nil {
|
if err := classes.FormatTranslation(attrs); err != nil {
|
||||||
return err
|
return 0, err
|
||||||
|
}
|
||||||
|
if h1ToH2 {
|
||||||
|
converted += classes.ConvertTranslationH1(attrs)
|
||||||
}
|
}
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
val, ok := attrs[field]
|
val, ok := attrs[field]
|
||||||
@@ -344,11 +377,11 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, trans
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
|
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return converted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripUnpublishedTitlePrefix(title string) string {
|
func stripUnpublishedTitlePrefix(title string) string {
|
||||||
|
|||||||
7
console/logger.go
Normal file
7
console/logger.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import "git.golem15.com/golem15/summercms/modules/diary"
|
||||||
|
|
||||||
|
// pluginLog is the plugin's logger: its records carry component=journal, so
|
||||||
|
// config/log.yaml can raise or lower them (log.components.journal).
|
||||||
|
var pluginLog = diary.For("journal")
|
||||||
141
console/reindex.go
Normal file
141
console/reindex.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package console
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reindexBatch is how many posts one database read loads.
|
||||||
|
const reindexBatch = 200
|
||||||
|
|
||||||
|
// ReindexResult counts what a reindex did: posts upserted into the index,
|
||||||
|
// posts removed from it (unpublished or scheduled) and posts whose sync
|
||||||
|
// failed.
|
||||||
|
type ReindexResult struct {
|
||||||
|
Synced int
|
||||||
|
Removed int
|
||||||
|
Failed int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex is journal:reindex.
|
||||||
|
func Reindex(app *backpack.App) bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "journal:reindex",
|
||||||
|
Description: "Sync every journal post into the search index",
|
||||||
|
Long: `Upserts the search document of every published post and removes the
|
||||||
|
document of every unpublished or scheduled one, through the same path as the
|
||||||
|
sync after a save. Posts written before search was turned on reach the index
|
||||||
|
this way.
|
||||||
|
|
||||||
|
--fresh first drops the collection and recreates it from the post schema
|
||||||
|
(models.Post.SearchIndexSchema). Use it once on a collection that was
|
||||||
|
auto-created without the schema, or after the schema changed; search answers
|
||||||
|
from SQL while the collection is empty.
|
||||||
|
|
||||||
|
The command refuses, and sends nothing, when the search engine is not
|
||||||
|
configured (search.driver null, or no search.typesense.api_key) or the
|
||||||
|
Journal setting search_use_typesense is off. It ends with
|
||||||
|
"Reindexed: N synced, N removed, N failed" and exits non-zero when any post
|
||||||
|
failed.`,
|
||||||
|
Flags: []bonfire.Flag{
|
||||||
|
{Name: "fresh", Description: "Drop and recreate the collection from the schema before syncing", Bare: true},
|
||||||
|
},
|
||||||
|
Examples: []bonfire.Example{
|
||||||
|
{Command: "journal:reindex", Description: "Sync every post into the existing collection"},
|
||||||
|
{Command: "journal:reindex --fresh", Description: "Recreate the collection from the schema, then sync every post"},
|
||||||
|
},
|
||||||
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
|
res, err := ReindexPosts(ctx, app, flagOn(in, "fresh"), out)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf("Reindexed: %d synced, %d removed, %d failed", res.Synced, res.Removed, res.Failed)
|
||||||
|
if res.Failed > 0 {
|
||||||
|
out.Error(msg)
|
||||||
|
return fmt.Errorf("journal: %d posts failed to sync", res.Failed)
|
||||||
|
}
|
||||||
|
out.Success(msg)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReindexPosts syncs every post through beachcomber, in id batches. With
|
||||||
|
// fresh it drops the collection and recreates it from the schema first. It
|
||||||
|
// returns an error, before any request, when the engine is not configured
|
||||||
|
// or search_use_typesense is off. A post that fails to sync is counted and
|
||||||
|
// reported on out; the rest still run.
|
||||||
|
func ReindexPosts(ctx context.Context, app *backpack.App, fresh bool, out bonfire.Output) (ReindexResult, error) {
|
||||||
|
var res ReindexResult
|
||||||
|
gdb := mustDB(app)
|
||||||
|
if gdb == nil {
|
||||||
|
return res, errors.New("journal: database is not configured")
|
||||||
|
}
|
||||||
|
svc, err := beachcomber.From(app)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
engine := svc.Engine()
|
||||||
|
if engine == nil || !engine.Configured() {
|
||||||
|
return res, errors.New("journal: the search engine is not configured (set search.driver and search.typesense.api_key)")
|
||||||
|
}
|
||||||
|
var on bool
|
||||||
|
if err := gdb.WithContext(ctx).Raw(`SELECT search_use_typesense FROM golem15_journal_settings WHERE id = 1`).Scan(&on).Error; err != nil {
|
||||||
|
return res, fmt.Errorf("journal: read search_use_typesense: %w", err)
|
||||||
|
}
|
||||||
|
if !on {
|
||||||
|
return res, errors.New("journal: search is off; turn on search_use_typesense in the Journal settings first")
|
||||||
|
}
|
||||||
|
models.SetSearchGate(true)
|
||||||
|
|
||||||
|
index := svc.IndexName(&models.Post{})
|
||||||
|
schema := models.Post{}.SearchIndexSchema()
|
||||||
|
if fresh {
|
||||||
|
existed, err := beachcomber.DropIndex(ctx, engine, index)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("journal: drop %s: %w", index, err)
|
||||||
|
}
|
||||||
|
if existed {
|
||||||
|
line(out, "Dropped %s", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := beachcomber.EnsureIndex(ctx, engine, index, schema); err != nil {
|
||||||
|
return res, fmt.Errorf("journal: create %s: %w", index, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var batch []models.Post
|
||||||
|
err = gdb.WithContext(ctx).Model(&models.Post{}).
|
||||||
|
Select("id", "published", "published_at").
|
||||||
|
Order("id").
|
||||||
|
FindInBatches(&batch, reindexBatch, func(tx *gorm.DB, _ int) error {
|
||||||
|
for i := range batch {
|
||||||
|
post := &batch[i]
|
||||||
|
if err := svc.Sync(ctx, gdb, post); err != nil {
|
||||||
|
res.Failed++
|
||||||
|
if out != nil {
|
||||||
|
out.Warning(fmt.Sprintf("post %d: %v", post.ID, err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if post.ShouldBeSearchable() {
|
||||||
|
res.Synced++
|
||||||
|
} else {
|
||||||
|
res.Removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
pluginLog.Info("journal: reindexed posts", "index", index, "fresh", fresh,
|
||||||
|
"synced", res.Synced, "removed", res.Removed, "failed", res.Failed)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
505
content_descriptors_test.go
Normal file
505
content_descriptors_test.go
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The category and the tag have content in pl (P33-64): the category
|
||||||
|
// its own translated name, the tag through its published post Hello,
|
||||||
|
// translated into pl.
|
||||||
|
for field, value := range map[string]string{"slug": "wiadomosci", "name": "Wiadomości"} {
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &s.news, field, "pl", value); 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)
|
||||||
|
}
|
||||||
|
if err := gdb.Create(&models.PostTag{PostID: s.hello.ID, TagID: s.tag.ID}).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 journal-author" {
|
||||||
|
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), "")
|
||||||
|
// The draft has no pl translation (P33-64): no pl address.
|
||||||
|
if err != nil || draft == nil || draft.Published || localeURLs(draft) != "en=/blog/draft pl=" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// Forest has no published post with content in pl (P33-64).
|
||||||
|
if got := localeURLs(&next.Records[0]); got != "en=/blog/tag/forest pl=" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// localeTitles lists a record's locales with their URL and title.
|
||||||
|
func localeTitles(rec *pact.ContentRecord) string {
|
||||||
|
parts := []string{}
|
||||||
|
for _, l := range rec.Locales {
|
||||||
|
parts = append(parts, l.Locale+"="+l.URL+"|"+l.Title)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContentDescriptorsTranslatedLocales pins G3 (D-55, D-57, P33-64): with
|
||||||
|
// pl as translate's default and en and de enabled, the descriptors give a
|
||||||
|
// locale a URL only when the record has content in it: a post and a
|
||||||
|
// category by their own translation, a tag and an author through one of
|
||||||
|
// their published posts, the default locale always. Every enabled locale
|
||||||
|
// keeps its entry (URL "" and the default locale's values for one without
|
||||||
|
// content), and the menu item types' Resolve still lists all three.
|
||||||
|
func TestContentDescriptorsTranslatedLocales(t *testing.T) {
|
||||||
|
requirePostgres(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||||
|
if err := gdb.Exec(`DELETE FROM golem15_translate_locales`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, row := range []struct {
|
||||||
|
code string
|
||||||
|
def bool
|
||||||
|
order int
|
||||||
|
}{{"pl", true, 1}, {"en", false, 2}, {"de", false, 3}} {
|
||||||
|
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES (?, ?, ?, TRUE, ?)`,
|
||||||
|
row.code, row.code, row.def, row.order).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
ada := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada")
|
||||||
|
bob := seedAuthor(t, gdb, "bob", "Bob", "Builder", "bob")
|
||||||
|
translated := models.Post{Title: "Grzyby", Slug: "grzyby", Content: "Treść", Published: true, PublishedAt: &past, UserID: &ada}
|
||||||
|
plain := models.Post{Title: "Tylko po polsku", Slug: "tylko", Content: "Treść", Published: true, PublishedAt: &past, UserID: &bob}
|
||||||
|
for _, p := range []*models.Post{&translated, &plain} {
|
||||||
|
if err := gdb.Create(p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field, value := range map[string]string{"title": "Mushrooms", "content": "Body", "slug": "mushrooms"} {
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &translated, field, "en", value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A slug alone is no content.
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &plain, "slug", "de", "nur"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cat := menuCategory(t, gdb, "Leśne", "lesne", nil, 1, 2)
|
||||||
|
bare := menuCategory(t, gdb, "Puste", "puste", nil, 3, 4)
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &cat, "name", "en", "Forest"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
attachCategory(t, gdb, translated, cat)
|
||||||
|
tag := models.Tag{Name: "Las", Slug: "las"}
|
||||||
|
if err := gdb.Create(&tag).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gdb.Create(&models.PostTag{PostID: translated.ID, TagID: tag.ID}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
key, id, want string
|
||||||
|
}{
|
||||||
|
{classes.MenuTypePost, id(translated.ID), "pl=/blog/grzyby|Grzyby en=/en/blog/mushrooms|Mushrooms de=|Grzyby"},
|
||||||
|
{classes.MenuTypePost, id(plain.ID), "pl=/blog/tylko|Tylko po polsku en=|Tylko po polsku de=|Tylko po polsku"},
|
||||||
|
{classes.MenuTypeCategory, id(cat.ID), "pl=/blog/category/lesne|Leśne en=/en/blog/category/lesne|Forest de=|Leśne"},
|
||||||
|
{classes.MenuTypeCategory, id(bare.ID), "pl=/blog/category/puste|Puste en=|Puste de=|Puste"},
|
||||||
|
{classes.MenuTypeTag, id(tag.ID), "pl=/blog/tag/las|Las en=/en/blog/tag/las|Las de=|Las"},
|
||||||
|
{classes.MenuTypeAuthor, id(ada), "pl=/blog/author/ada|Ada Lovelace en=/en/blog/author/ada|Ada Lovelace de=|Ada Lovelace"},
|
||||||
|
{classes.MenuTypeAuthor, id(bob), "pl=/blog/author/bob|Bob Builder en=|Bob Builder de=|Bob Builder"},
|
||||||
|
} {
|
||||||
|
rec := describe(t, reg, tc.key, tc.id)
|
||||||
|
if got := localeTitles(rec); got != tc.want {
|
||||||
|
t.Errorf("Describe(%s, %s) = %s\nwant %s", tc.key, tc.id, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The untranslated locale keeps the default locale's word count.
|
||||||
|
if rec := describe(t, reg, classes.MenuTypePost, id(translated.ID)); rec.Locales[2].Words == nil || *rec.Locales[2].Words != *rec.Locales[0].Words {
|
||||||
|
t.Errorf("de words = %v, want the pl count", rec.Locales[2].Words)
|
||||||
|
}
|
||||||
|
// The menu item types' Resolve is unchanged: every enabled locale.
|
||||||
|
for key, ref := range map[string]string{classes.MenuTypePost: id(translated.ID), classes.MenuTypeCategory: id(cat.ID)} {
|
||||||
|
item, err := reg.Resolve(ctx, key, ref, "", false, "")
|
||||||
|
if err != nil || item == nil || len(item.Alternates) != 3 {
|
||||||
|
t.Fatalf("Resolve(%s) = %+v, %v", key, item, err)
|
||||||
|
}
|
||||||
|
for _, a := range item.Alternates {
|
||||||
|
if a.URL == "" {
|
||||||
|
t.Errorf("Resolve(%s) %s has no URL", key, a.Locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,14 +25,14 @@ func TestSerializeCategoryNodeContent(t *testing.T) {
|
|||||||
byParent := map[uint][]models.Category{1: {child}}
|
byParent := map[uint][]models.Category{1: {child}}
|
||||||
counts := map[uint]int64{1: 2}
|
counts := map[uint]int64{1: 2}
|
||||||
|
|
||||||
row := serializeCategoryNode(&root, byParent, counts, true)
|
row := serializeCategoryNode(&root, byParent, counts, true, true)
|
||||||
if row == nil {
|
if row == nil {
|
||||||
t.Fatal("root row is nil")
|
t.Fatal("root row is nil")
|
||||||
}
|
}
|
||||||
if got, ok := row["content"].(string); !ok || got != html {
|
if got, ok := row["content"].(string); !ok || got != html {
|
||||||
t.Fatalf("root content = %#v, want %q", row["content"], html)
|
t.Fatalf("root content = %#v, want %q", row["content"], html)
|
||||||
}
|
}
|
||||||
wantRoot := []string{"children", "content", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"}
|
wantRoot := []string{"children", "content", "content_h1_to_h2", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"}
|
||||||
if got := sortedCategoryKeys(row); !reflect.DeepEqual(got, wantRoot) {
|
if got := sortedCategoryKeys(row); !reflect.DeepEqual(got, wantRoot) {
|
||||||
t.Fatalf("root keys = %v, want %v", got, wantRoot)
|
t.Fatalf("root keys = %v, want %v", got, wantRoot)
|
||||||
}
|
}
|
||||||
@@ -44,12 +44,12 @@ func TestSerializeCategoryNodeContent(t *testing.T) {
|
|||||||
if !present || v != nil {
|
if !present || v != nil {
|
||||||
t.Fatalf("child content = %#v (present %v), want nil", v, present)
|
t.Fatalf("child content = %#v (present %v), want nil", v, present)
|
||||||
}
|
}
|
||||||
wantChild := []string{"content", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"}
|
wantChild := []string{"content", "content_h1_to_h2", "description", "id", "name", "nest_depth", "parent_id", "post_count", "slug"}
|
||||||
if got := sortedCategoryKeys(kids[0]); !reflect.DeepEqual(got, wantChild) {
|
if got := sortedCategoryKeys(kids[0]); !reflect.DeepEqual(got, wantChild) {
|
||||||
t.Fatalf("child keys = %v, want %v", got, wantChild)
|
t.Fatalf("child keys = %v, want %v", got, wantChild)
|
||||||
}
|
}
|
||||||
|
|
||||||
listed := serializeCategoryNode(&root, byParent, counts, false)
|
listed := serializeCategoryNode(&root, byParent, counts, false, true)
|
||||||
if listed == nil {
|
if listed == nil {
|
||||||
t.Fatal("root omitted without include_empty")
|
t.Fatal("root omitted without include_empty")
|
||||||
}
|
}
|
||||||
|
|||||||
344
controllers/api/maintenance.go
Normal file
344
controllers/api/maintenance.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxImageAltChanges caps one POST /images/alt batch.
|
||||||
|
const maxImageAltChanges = 500
|
||||||
|
|
||||||
|
// FixHeadings is POST /_journal/api/v1/fix-headings {dry_run}: the
|
||||||
|
// journal:fix-headings conversion of stored H1 headings (posts in every
|
||||||
|
// locale and category pillar text), for golem15.journal.manage_settings. It
|
||||||
|
// is a dry run unless dry_run is false, and answers {posts, categories,
|
||||||
|
// headings, dry_run}. While the H1 setting is off it refuses with 409 and
|
||||||
|
// the refusal phrase. MCP journal_fix_headings.
|
||||||
|
func FixHeadings(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||||
|
if !ok || !requireManageSettings(w, pr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := gdb(app)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := readJSONObject(r)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dry := true
|
||||||
|
if v, has := body["dry_run"]; has && v != nil {
|
||||||
|
dry = asBool(v)
|
||||||
|
}
|
||||||
|
if !classes.EffectiveH1ToH2(r.Context(), db, appConfig(app)) {
|
||||||
|
writeAPIError(w, http.StatusConflict, classes.H1ConversionOffText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := classes.FixStoredHeadings(r.Context(), app, db, !dry)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"posts": res.Posts,
|
||||||
|
"categories": res.Categories,
|
||||||
|
"headings": res.Headings,
|
||||||
|
"dry_run": dry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageAltChange is one requested alt change.
|
||||||
|
type imageAltChange struct {
|
||||||
|
postID uint
|
||||||
|
locale string
|
||||||
|
src string
|
||||||
|
alt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageAltText is the stored body of one post in one locale ("" is the
|
||||||
|
// host columns) while a batch is applied.
|
||||||
|
type imageAltText struct {
|
||||||
|
post *models.Post
|
||||||
|
locale string
|
||||||
|
content, contentHTML string
|
||||||
|
oldContent, oldHTML string
|
||||||
|
}
|
||||||
|
|
||||||
|
// errMarkdownImage refuses an alt for an image a markdown post's source
|
||||||
|
// does not hold as an inline image or a raw img.
|
||||||
|
const errMarkdownImage = "The image is not an inline image of the post's markdown; edit the post."
|
||||||
|
|
||||||
|
// errForbiddenPost stops a batch that names a post the caller may not edit.
|
||||||
|
var errForbiddenPost = errors.New("forbidden post")
|
||||||
|
|
||||||
|
// SetImageAlt is POST /_journal/api/v1/images/alt {dry_run, changes:
|
||||||
|
// [{post_id, locale, src, alt}]}: it sets the alt attribute of the img whose
|
||||||
|
// src equals src in the post's content_html, in the default locale when
|
||||||
|
// locale is empty and in that translation otherwise; alt "" marks a
|
||||||
|
// decorative image. The post's content gets it too, read as markdown (an
|
||||||
|
// HTML body included): its inline image  or a raw img outside
|
||||||
|
// code (classes.SetMarkdownImageAlt, classes.SetImageAltOutsideCode), so
|
||||||
|
// the next content save keeps it. An image the markdown does not hold that
|
||||||
|
// way (a reference-style image, for example) refuses the change with 422
|
||||||
|
// changes.N.src. A locale whose content is blank (a translation that
|
||||||
|
// carries only content_html) gets the alt in content_html alone. It needs
|
||||||
|
// golem15.journal.access_posts and may edit only the posts the caller may
|
||||||
|
// edit (403). The batch is all or nothing: a missing post or an src that is
|
||||||
|
// not in the post refuses it with 422 (errors keyed changes.N.field). It
|
||||||
|
// answers {changes: [{post_id, locale, src, old_alt, alt, changed}],
|
||||||
|
// dry_run}; a dry run (dry_run true) writes nothing. Applied changes write
|
||||||
|
// in one transaction and fire one RecordSaved per changed post after the
|
||||||
|
// commit. Alt text is post content, so it is a Journal tool (P33-05):
|
||||||
|
// MCP journal_set_image_alt.
|
||||||
|
func SetImageAlt(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||||
|
if !ok || !requireAccessPosts(w, pr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := gdb(app)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := readJSONObject(r)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dry := false
|
||||||
|
if v, has := body["dry_run"]; has && v != nil {
|
||||||
|
dry = asBool(v)
|
||||||
|
}
|
||||||
|
changes, verrs := parseImageAltChanges(body["changes"])
|
||||||
|
if len(verrs) > 0 {
|
||||||
|
writeValidation(w, verrs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var answer []map[string]any
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
texts := map[string]*imageAltText{}
|
||||||
|
posts := map[uint]*models.Post{}
|
||||||
|
answer = make([]map[string]any, 0, len(changes))
|
||||||
|
for i, c := range changes {
|
||||||
|
post, found := posts[c.postID]
|
||||||
|
if !found {
|
||||||
|
p, err := findPostByID(tx, strconv.FormatUint(uint64(c.postID), 10))
|
||||||
|
if err != nil || p == nil {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.post_id", i), "The selected post is invalid.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !p.CanEdit(pr) {
|
||||||
|
return errForbiddenPost
|
||||||
|
}
|
||||||
|
posts[c.postID], post = p, p
|
||||||
|
}
|
||||||
|
key := strconv.FormatUint(uint64(c.postID), 10) + "|" + c.locale
|
||||||
|
text, found := texts[key]
|
||||||
|
if !found {
|
||||||
|
text, err = loadImageAltText(ctx, tx, post, c.locale)
|
||||||
|
if err != nil {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.locale", i), "The locale is invalid.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
texts[key] = text
|
||||||
|
}
|
||||||
|
html, n, old := classes.SetImageAlt(text.contentHTML, c.src, c.alt)
|
||||||
|
if n == 0 {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), "The image is not in the post.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(text.content) == "":
|
||||||
|
// content_html is the body (a translation that holds
|
||||||
|
// only content_html, or a row without content): nothing
|
||||||
|
// else to keep in sync (33-REVIEW-GAPFIX WR-02).
|
||||||
|
default:
|
||||||
|
// content is read as markdown, as FormatHTML renders it
|
||||||
|
// on every API path, and the next save derives
|
||||||
|
// content_html from it again, so the alt must land in it
|
||||||
|
// (L1 WR-05): an inline image, or a raw img outside
|
||||||
|
// code. An HTML body (the rich editor's) is HTML blocks
|
||||||
|
// to goldmark, so its img is a raw img outside code and
|
||||||
|
// gets the alt here too. The first character no longer
|
||||||
|
// picks the branch, so markdown that opens with a tag
|
||||||
|
// keeps its code samples and is never answered changed
|
||||||
|
// and reverted later.
|
||||||
|
content, m, _ := classes.SetMarkdownImageAlt(text.content, c.src, c.alt)
|
||||||
|
content, r, _ := classes.SetImageAltOutsideCode(content, c.src, c.alt)
|
||||||
|
if m+r == 0 {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), errMarkdownImage)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text.content = content
|
||||||
|
}
|
||||||
|
text.contentHTML = html
|
||||||
|
var oldAlt any
|
||||||
|
if old != nil {
|
||||||
|
oldAlt = *old
|
||||||
|
}
|
||||||
|
answer = append(answer, map[string]any{
|
||||||
|
"post_id": c.postID, "locale": c.locale, "src": c.src,
|
||||||
|
"old_alt": oldAlt, "alt": c.alt, "changed": old == nil || *old != c.alt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(verrs) > 0 || dry {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return writeImageAltTexts(ctx, app, tx, texts)
|
||||||
|
})
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errForbiddenPost):
|
||||||
|
writeAPIError(w, http.StatusForbidden, errEditPermission)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
pluginLog.ErrorContext(r.Context(), "journal api: image alt failed", "error", err)
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
case len(verrs) > 0:
|
||||||
|
writeValidation(w, verrs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"changes": answer, "dry_run": dry})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImageAltChanges reads the changes array: post_id (a positive id),
|
||||||
|
// src (not blank) and alt (a string, "" allowed) are required, locale is
|
||||||
|
// optional.
|
||||||
|
func parseImageAltChanges(raw any) ([]imageAltChange, map[string][]string) {
|
||||||
|
items, ok := raw.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, map[string][]string{"changes": {"The changes must be an array."}}
|
||||||
|
}
|
||||||
|
if len(items) > maxImageAltChanges {
|
||||||
|
return nil, map[string][]string{"changes": {fmt.Sprintf("The changes may not have more than %d items.", maxImageAltChanges)}}
|
||||||
|
}
|
||||||
|
var verrs map[string][]string
|
||||||
|
out := make([]imageAltChange, 0, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
m, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d", i), "The change must be an object.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var c imageAltChange
|
||||||
|
id, ok := asUint(m["post_id"])
|
||||||
|
if !ok || id == 0 {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.post_id", i), "The post id field is required.")
|
||||||
|
}
|
||||||
|
c.postID = id
|
||||||
|
c.src = strings.TrimSpace(asString(m["src"]))
|
||||||
|
if c.src == "" {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.src", i), "The src field is required.")
|
||||||
|
}
|
||||||
|
alt, isString := m["alt"].(string)
|
||||||
|
if !isString {
|
||||||
|
verrs = addSettingsError(verrs, fmt.Sprintf("changes.%d.alt", i), "The alt must be a string.")
|
||||||
|
}
|
||||||
|
c.alt = strings.TrimSpace(alt)
|
||||||
|
c.locale = strings.TrimSpace(asString(m["locale"]))
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, verrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadImageAltText reads a post's content and content_html in locale: the
|
||||||
|
// host columns for "", the translation otherwise (the host columns for the
|
||||||
|
// default locale).
|
||||||
|
func loadImageAltText(ctx context.Context, tx *gorm.DB, post *models.Post, locale string) (*imageAltText, error) {
|
||||||
|
t := &imageAltText{post: post, locale: locale}
|
||||||
|
if locale == "" {
|
||||||
|
t.content, t.contentHTML = post.Content, post.ContentHTML
|
||||||
|
} else {
|
||||||
|
for _, field := range []string{"content", "content_html"} {
|
||||||
|
v, _, err := translate.TranslatedExact(ctx, tx, post, field, locale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text, _ := v.(string)
|
||||||
|
if field == "content" {
|
||||||
|
t.content = text
|
||||||
|
} else {
|
||||||
|
t.contentHTML = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.oldContent, t.oldHTML = t.content, t.contentHTML
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeImageAltTexts stores the changed bodies and fires one RecordSaved
|
||||||
|
// per changed post, with a site cache purge.
|
||||||
|
func writeImageAltTexts(ctx context.Context, app *backpack.App, tx *gorm.DB, texts map[string]*imageAltText) error {
|
||||||
|
keys := make([]string, 0, len(texts))
|
||||||
|
for key := range texts {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
byPost := map[uint][]pact.RecordChange{}
|
||||||
|
var order []*models.Post
|
||||||
|
for _, key := range keys {
|
||||||
|
t := texts[key]
|
||||||
|
var changes []pact.RecordChange
|
||||||
|
if t.content != t.oldContent {
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: "content", Locale: t.locale, Old: t.oldContent, New: t.content})
|
||||||
|
}
|
||||||
|
if t.contentHTML != t.oldHTML {
|
||||||
|
changes = append(changes, pact.RecordChange{Attribute: "content_html", Locale: t.locale, Old: t.oldHTML, New: t.contentHTML})
|
||||||
|
}
|
||||||
|
if len(changes) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.locale == "" {
|
||||||
|
if err := tx.Model(&models.Post{}).Where("id = ?", t.post.ID).
|
||||||
|
Updates(map[string]any{"content": t.content, "content_html": t.contentHTML}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, c := range changes {
|
||||||
|
if err := translate.SetTranslated(ctx, tx, t.post, c.Attribute, t.locale, c.New); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, seen := byPost[t.post.ID]; !seen {
|
||||||
|
order = append(order, t.post)
|
||||||
|
}
|
||||||
|
byPost[t.post.ID] = append(byPost[t.post.ID], changes...)
|
||||||
|
}
|
||||||
|
locale, _ := towel.Locale(ctx)
|
||||||
|
purger := cooler.SiteCacheFromApp(app)
|
||||||
|
for _, post := range order {
|
||||||
|
cabana.EmitRecordSaved(ctx, app, tx, pact.RecordSaved{
|
||||||
|
Model: post.MorphName(),
|
||||||
|
ID: strconv.FormatUint(uint64(post.ID), 10),
|
||||||
|
Locale: locale,
|
||||||
|
Changes: byPost[post.ID],
|
||||||
|
})
|
||||||
|
cooler.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,13 +10,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/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/compass"
|
||||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"git.golem15.com/golem15/summercms/modules/wire"
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
"gocloud.dev/blob"
|
"gocloud.dev/blob"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -24,10 +28,17 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultPerPage = 9
|
defaultPerPage = 9
|
||||||
maxPerPage = 30
|
maxPerPage = 30
|
||||||
|
|
||||||
|
// fieldsSuggest is the ?fields value of the suggestion rows.
|
||||||
|
fieldsSuggest = "suggest"
|
||||||
|
// suggestExcerptRunes is about how long a suggestion's excerpt runs.
|
||||||
|
suggestExcerptRunes = 160
|
||||||
)
|
)
|
||||||
|
|
||||||
// Index is GET /_journal/api/v1/posts. Anonymous callers see published posts
|
// Index is GET /_journal/api/v1/posts. Anonymous callers see published posts
|
||||||
// only; an editor with access_posts may include drafts unless ?published=true.
|
// only; an editor with access_posts may include drafts unless ?published=true.
|
||||||
|
// With ?fields=suggest each row is the short suggestion shape
|
||||||
|
// (serializeSuggestion).
|
||||||
func Index(app *backpack.App) http.HandlerFunc {
|
func Index(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
r = optionalBackendPrincipal(app, r)
|
r = optionalBackendPrincipal(app, r)
|
||||||
@@ -42,8 +53,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
perPage := clampPerPage(r.URL.Query().Get("per_page"))
|
perPage := clampPerPage(r.URL.Query().Get("per_page"))
|
||||||
page := max(1, atoiDefault(r.URL.Query().Get("page"), 1))
|
page := max(1, atoiDefault(r.URL.Query().Get("page"), 1))
|
||||||
|
|
||||||
q := db.WithContext(r.Context()).Model(&models.Post{}).
|
suggest := r.URL.Query().Get("fields") == fieldsSuggest
|
||||||
Preload("User").Preload("Categories").Preload("Tags")
|
q := db.WithContext(r.Context()).Model(&models.Post{})
|
||||||
|
if !suggest {
|
||||||
|
q = q.Preload("User").Preload("Categories").Preload("Tags")
|
||||||
|
}
|
||||||
|
|
||||||
publishedOnly := true
|
publishedOnly := true
|
||||||
if editor {
|
if editor {
|
||||||
@@ -77,8 +91,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
if len([]rune(search)) < 3 {
|
if len([]rune(search)) < 3 {
|
||||||
search = ""
|
search = ""
|
||||||
}
|
}
|
||||||
|
// rank is the engine's order of the candidates, nil for a SQL search.
|
||||||
|
var rank []uint
|
||||||
if search != "" {
|
if search != "" {
|
||||||
if ids, used := searchWithEngine(app, r, db.WithContext(r.Context()), search, publishedOnly, page, perPage); used {
|
if ids, used := searchWithEngine(app, r, db.WithContext(r.Context()), search, publishedOnly); used {
|
||||||
|
rank = ids
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
q = q.Where("1 = 0")
|
q = q.Where("1 = 0")
|
||||||
} else {
|
} else {
|
||||||
@@ -100,7 +117,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
||||||
order := listOrder(sort, queryBool(r.URL.Query().Get("pinned_first")))
|
pinnedFirst := queryBool(r.URL.Query().Get("pinned_first"))
|
||||||
|
order := listOrder(sort, pinnedFirst)
|
||||||
|
if len(rank) > 0 && sort == "" {
|
||||||
|
order = rankOrder(rank, pinnedFirst)
|
||||||
|
}
|
||||||
var posts []models.Post
|
var posts []models.Post
|
||||||
if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
@@ -108,8 +129,12 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
rows := make([]map[string]any, 0, len(posts))
|
rows := make([]map[string]any, 0, len(posts))
|
||||||
for i := range posts {
|
for i := range posts {
|
||||||
|
if suggest {
|
||||||
|
rows = append(rows, serializeSuggestion(&posts[i], db.WithContext(r.Context())))
|
||||||
|
} else {
|
||||||
rows = append(rows, serializePost(&posts[i], db.WithContext(r.Context())))
|
rows = append(rows, serializePost(&posts[i], db.WithContext(r.Context())))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +152,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
|
||||||
@@ -144,12 +169,32 @@ func Show(app *backpack.App) http.HandlerFunc {
|
|||||||
payload := serializePost(post, db)
|
payload := serializePost(post, db)
|
||||||
payload["previous_post"] = serializeSibling(previousPost(db, post))
|
payload["previous_post"] = serializeSibling(previousPost(db, post))
|
||||||
payload["next_post"] = serializeSibling(nextPost(db, post))
|
payload["next_post"] = serializeSibling(nextPost(db, post))
|
||||||
related := relatedPosts(db, post, isEditor(pr))
|
// golem15.journal.related_mode: the default category mode answers as
|
||||||
|
// it always has; scored (opt-in, P33-44) ranks the related posts by
|
||||||
|
// shared tags and categories and adds the neighbours within the
|
||||||
|
// post's first category (additive keys, scored mode only).
|
||||||
|
relatedCfg := classes.ReadRelatedSettings(appConfig(app))
|
||||||
|
var related []models.Post
|
||||||
|
if relatedCfg.Scored() {
|
||||||
|
related, err = classes.ScoredRelatedPosts(r.Context(), db, post, relatedCfg.Count, isEditor(pr))
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload["previous_in_category"] = serializeSibling(classes.CategoryNeighbour(r.Context(), db, post, true))
|
||||||
|
payload["next_in_category"] = serializeSibling(classes.CategoryNeighbour(r.Context(), db, post, false))
|
||||||
|
} else {
|
||||||
|
related = relatedPosts(db, post, isEditor(pr))
|
||||||
|
}
|
||||||
rows := make([]map[string]any, 0, len(related))
|
rows := make([]map[string]any, 0, len(related))
|
||||||
for i := range related {
|
for i := range related {
|
||||||
rows = append(rows, serializePost(&related[i], db))
|
rows = append(rows, serializePost(&related[i], db))
|
||||||
}
|
}
|
||||||
payload["related_posts"] = rows
|
payload["related_posts"] = rows
|
||||||
|
// Additive (2026-10-28 Journal H1 setting): whether H1 in post
|
||||||
|
// content is stored as H2, so a frontend knows whether the page
|
||||||
|
// title is the only H1.
|
||||||
|
payload["content_h1_to_h2"] = classes.EffectiveH1ToH2(r.Context(), db, appConfig(app))
|
||||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": payload})
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": payload})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +209,8 @@ func Categories(app *backpack.App) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
includeEmpty := queryBool(r.URL.Query().Get("include_empty"))
|
includeEmpty := queryBool(r.URL.Query().Get("include_empty"))
|
||||||
data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty)
|
h1ToH2 := classes.EffectiveH1ToH2(r.Context(), db, appConfig(app))
|
||||||
|
data, err := serializeCategoryTree(db.WithContext(r.Context()), includeEmpty, h1ToH2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
return
|
return
|
||||||
@@ -219,20 +265,36 @@ 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
|
var h1 *h1Conversion
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
// D-50: while the H1 setting is on, H1 in the stored HTML
|
||||||
|
// becomes H2, in the same transaction as the write.
|
||||||
|
h1 = newH1Conversion(ctx, tx, app)
|
||||||
|
h1.convertPost(post)
|
||||||
|
if err := tx.Create(post).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
|
tags, err := applyPostAssocs(ctx, tx, post, body, h1)
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
|
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
|
||||||
}
|
}
|
||||||
purgeSiteCache(r, app, post)
|
purgeSiteCache(r, app, post)
|
||||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
wire.WriteJSON(w, http.StatusCreated, h1.answer(r, app, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +327,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,20 +338,38 @@ 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
|
var h1 *h1Conversion
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
// D-50: only a submitted content is converted; an update
|
||||||
|
// that does not write the body leaves stored H1 alone.
|
||||||
|
h1 = newH1Conversion(ctx, tx, app)
|
||||||
|
if _, has := body["content"]; has {
|
||||||
|
h1.convertPost(post)
|
||||||
}
|
}
|
||||||
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
|
if err := tx.Save(post).Error; err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
|
tags, err := applyPostAssocs(ctx, tx, post, body, h1)
|
||||||
|
if 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
|
||||||
}
|
}
|
||||||
purgeSiteCache(r, app, post)
|
purgeSiteCache(r, app, post)
|
||||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
wire.WriteJSON(w, http.StatusOK, h1.answer(r, app, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +402,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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,6 +468,8 @@ func UploadFeaturedImage(app *backpack.App) http.HandlerFunc {
|
|||||||
stored.AttachmentID = idStr
|
stored.AttachmentID = idStr
|
||||||
stored.Field = "featured_images"
|
stored.Field = "featured_images"
|
||||||
purgeSiteCache(r, app, post)
|
purgeSiteCache(r, app, post)
|
||||||
|
// The variants are written by a job, never in this request (D-85).
|
||||||
|
classes.DispatchImageVariants(r.Context(), app, []uint{stored.ID})
|
||||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{
|
wire.WriteJSON(w, http.StatusCreated, map[string]any{
|
||||||
"data": map[string]any{
|
"data": map[string]any{
|
||||||
"id": stored.ID,
|
"id": stored.ID,
|
||||||
@@ -392,6 +481,14 @@ func UploadFeaturedImage(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appConfig is app's config, nil without an application.
|
||||||
|
func appConfig(app *backpack.App) *compass.Config {
|
||||||
|
if app == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.Config
|
||||||
|
}
|
||||||
|
|
||||||
// purgeSiteCache queues the host site's cache purge for post, as the admin
|
// purgeSiteCache queues the host site's cache purge for post, as the admin
|
||||||
// save hooks do. The API writes run outside a transaction, so the purge is
|
// save hooks do. The API writes run outside a transaction, so the purge is
|
||||||
// queued at once and never delays the response. Without site_cache.purge_url
|
// queued at once and never delays the response. Without site_cache.purge_url
|
||||||
@@ -440,6 +537,10 @@ func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc {
|
|||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The variants' blobs share the thumbnail prefix of BlobKeys.
|
||||||
|
if err := db.WithContext(r.Context()).Where("file_id = ?", file.ID).Delete(&models.ImageVariant{}).Error; err != nil {
|
||||||
|
pluginLog.Warn("journal: delete image variants", "file", file.ID, "error", err)
|
||||||
|
}
|
||||||
if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil {
|
if bucket, ok := app.Lookup[*blob.Bucket](); ok && bucket != nil {
|
||||||
_ = attach.DeleteKeys(r.Context(), bucket, keys)
|
_ = attach.DeleteKeys(r.Context(), bucket, keys)
|
||||||
}
|
}
|
||||||
@@ -554,10 +655,7 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
|||||||
tags = append(tags, map[string]any{"id": t.ID, "name": t.Name, "slug": t.Slug})
|
tags = append(tags, map[string]any{"id": t.ID, "name": t.Name, "slug": t.Slug})
|
||||||
}
|
}
|
||||||
featured := featuredImages(db, p)
|
featured := featuredImages(db, p)
|
||||||
var featuredURL any
|
featuredURL := firstImageURL(featured)
|
||||||
if len(featured) > 0 {
|
|
||||||
featuredURL = featured[0]["url"]
|
|
||||||
}
|
|
||||||
row := map[string]any{
|
row := map[string]any{
|
||||||
"id": p.ID,
|
"id": p.ID,
|
||||||
"title": p.Title,
|
"title": p.Title,
|
||||||
@@ -571,7 +669,7 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
|||||||
"is_pinned": p.IsPinned,
|
"is_pinned": p.IsPinned,
|
||||||
"metadata": jsonField(p.Metadata),
|
"metadata": jsonField(p.Metadata),
|
||||||
"sources": jsonField(p.Sources),
|
"sources": jsonField(p.Sources),
|
||||||
"author": serializeAuthor(p.User),
|
"author": serializeAuthor(db, p.User),
|
||||||
"categories": cats,
|
"categories": cats,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"featured_images": featured,
|
"featured_images": featured,
|
||||||
@@ -583,15 +681,78 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
|||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
func serializeAuthor(user *cabana.BackendUser) any {
|
// serializeSuggestion is the ?fields=suggest row: a subset of serializePost's
|
||||||
|
// keys with the same value formats, except that excerpt is plain text cut to
|
||||||
|
// about suggestExcerptRunes on a word boundary, taken from the content when
|
||||||
|
// the post has no excerpt.
|
||||||
|
func serializeSuggestion(p *models.Post, db *gorm.DB) map[string]any {
|
||||||
|
if p == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"id": p.ID,
|
||||||
|
"title": p.Title,
|
||||||
|
"slug": p.Slug,
|
||||||
|
"excerpt": suggestionExcerpt(p),
|
||||||
|
"featured_image_url": firstImageURL(featuredImages(db, p)),
|
||||||
|
"published_at": carbonTime(p.PublishedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestionExcerpt(p *models.Post) string {
|
||||||
|
text := models.PlainText(p.Excerpt)
|
||||||
|
if text == "" {
|
||||||
|
body := p.ContentHTML
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
body = p.Content
|
||||||
|
}
|
||||||
|
text = models.PlainText(body)
|
||||||
|
}
|
||||||
|
return truncateWords(text, suggestExcerptRunes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateWords cuts s to at most n runes at the last space before the cut,
|
||||||
|
// with an ellipsis; s is returned whole when it fits.
|
||||||
|
func truncateWords(s string, n int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cut := runes[:n]
|
||||||
|
if i := strings.LastIndexFunc(string(cut), unicode.IsSpace); i > 0 {
|
||||||
|
return strings.TrimRightFunc(string(cut)[:i], func(r rune) bool { return unicode.IsSpace(r) || unicode.IsPunct(r) }) + "…"
|
||||||
|
}
|
||||||
|
return string(cut) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstImageURL(featured []map[string]any) any {
|
||||||
|
if len(featured) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return featured[0]["url"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeAuthor is a post's author object. slug is the author's canonical
|
||||||
|
// identifier, as on GET /authors/{author}: the author slug when set, otherwise
|
||||||
|
// the login, so a consumer links to the author page by slug.
|
||||||
|
func serializeAuthor(db *gorm.DB, user *cabana.BackendUser) any {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
slug := user.Login
|
||||||
|
if db != nil {
|
||||||
|
var authorSlug string
|
||||||
|
if err := db.Table("backend_users").Select("COALESCE(golem15_bloghub_author_slug, '')").
|
||||||
|
Where("id = ?", user.ID).Scan(&authorSlug).Error; err == nil && authorSlug != "" {
|
||||||
|
slug = authorSlug
|
||||||
|
}
|
||||||
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"first_name": user.FirstName,
|
"first_name": user.FirstName,
|
||||||
"last_name": user.LastName,
|
"last_name": user.LastName,
|
||||||
"login": user.Login,
|
"login": user.Login,
|
||||||
|
"slug": slug,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,13 +767,48 @@ func featuredImages(db *gorm.DB, p *models.Post) []map[string]any {
|
|||||||
).Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
|
).Order("sort_order ASC, id ASC").Find(&files).Error; err != nil {
|
||||||
return []map[string]any{}
|
return []map[string]any{}
|
||||||
}
|
}
|
||||||
|
variants := imageVariants(db, files)
|
||||||
out := make([]map[string]any, 0, len(files))
|
out := make([]map[string]any, 0, len(files))
|
||||||
for i := range files {
|
for i := range files {
|
||||||
|
list := variants[files[i].ID]
|
||||||
|
if list == nil {
|
||||||
|
list = []map[string]any{}
|
||||||
|
}
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"id": files[i].ID,
|
"id": files[i].ID,
|
||||||
"url": files[i].URL(),
|
"url": files[i].URL(),
|
||||||
"file_name": files[i].FileName,
|
"file_name": files[i].FileName,
|
||||||
"file_size": files[i].FileSize,
|
"file_size": files[i].FileSize,
|
||||||
|
"variants": list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageVariants lists the variants of files in one query, by file and
|
||||||
|
// width (D-85): {width, height, url, webp_url} and, on each, the
|
||||||
|
// original's source_width and source_height.
|
||||||
|
func imageVariants(db *gorm.DB, files []attach.File) map[uint][]map[string]any {
|
||||||
|
out := map[uint][]map[string]any{}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
ids := make([]uint, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
ids = append(ids, f.ID)
|
||||||
|
}
|
||||||
|
var rows []models.ImageVariant
|
||||||
|
if err := db.Where("file_id IN ?", ids).Order("file_id ASC, width ASC").Find(&rows).Error; err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, v := range rows {
|
||||||
|
out[v.FileID] = append(out[v.FileID], map[string]any{
|
||||||
|
"width": v.Width,
|
||||||
|
"height": v.Height,
|
||||||
|
"url": attach.PublicURL(v.Key),
|
||||||
|
"webp_url": attach.PublicURL(v.WebPKey),
|
||||||
|
"source_width": v.SourceWidth,
|
||||||
|
"source_height": v.SourceHeight,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ 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/backpack"
|
||||||
"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/phrasebook"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,12 +56,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 {
|
||||||
@@ -152,7 +173,10 @@ func relatedPosts(db *gorm.DB, p *models.Post, editor bool) []models.Post {
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func serializeCategoryTree(db *gorm.DB, includeEmpty bool) ([]map[string]any, error) {
|
// serializeCategoryTree is the category tree of GET /categories. h1ToH2 is
|
||||||
|
// the effective H1 setting every row reports as content_h1_to_h2 (additive
|
||||||
|
// key), so a frontend knows whether pillar text keeps H1 headings.
|
||||||
|
func serializeCategoryTree(db *gorm.DB, includeEmpty, h1ToH2 bool) ([]map[string]any, error) {
|
||||||
var cats []models.Category
|
var cats []models.Category
|
||||||
if err := db.Order("nest_left ASC NULLS LAST, id ASC").Find(&cats).Error; err != nil {
|
if err := db.Order("nest_left ASC NULLS LAST, id ASC").Find(&cats).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -169,21 +193,21 @@ func serializeCategoryTree(db *gorm.DB, includeEmpty bool) ([]map[string]any, er
|
|||||||
}
|
}
|
||||||
out := make([]map[string]any, 0, len(roots))
|
out := make([]map[string]any, 0, len(roots))
|
||||||
for i := range roots {
|
for i := range roots {
|
||||||
if row := serializeCategoryNode(&roots[i], byParent, counts, includeEmpty); row != nil {
|
if row := serializeCategoryNode(&roots[i], byParent, counts, includeEmpty, h1ToH2); row != nil {
|
||||||
out = append(out, row)
|
out = append(out, row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Category, counts map[uint]int64, includeEmpty bool) map[string]any {
|
func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Category, counts map[uint]int64, includeEmpty, h1ToH2 bool) map[string]any {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
kids := byParent[c.ID]
|
kids := byParent[c.ID]
|
||||||
childRows := make([]map[string]any, 0, len(kids))
|
childRows := make([]map[string]any, 0, len(kids))
|
||||||
for i := range kids {
|
for i := range kids {
|
||||||
if row := serializeCategoryNode(&kids[i], byParent, counts, includeEmpty); row != nil {
|
if row := serializeCategoryNode(&kids[i], byParent, counts, includeEmpty, h1ToH2); row != nil {
|
||||||
childRows = append(childRows, row)
|
childRows = append(childRows, row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,6 +240,8 @@ func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Catego
|
|||||||
"parent_id": parent,
|
"parent_id": parent,
|
||||||
"nest_depth": depth,
|
"nest_depth": depth,
|
||||||
"post_count": postCount,
|
"post_count": postCount,
|
||||||
|
// Additive (2026-10-28 H1 conversion scope).
|
||||||
|
"content_h1_to_h2": h1ToH2,
|
||||||
}
|
}
|
||||||
if len(childRows) > 0 {
|
if len(childRows) > 0 {
|
||||||
row["children"] = childRows
|
row["children"] = childRows
|
||||||
@@ -676,54 +702,62 @@ 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.
|
||||||
|
//
|
||||||
|
// h1 converts H1 in the translated content while the H1 setting is on; it
|
||||||
|
// may be nil.
|
||||||
|
func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any, h1 *h1Conversion) (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, h1); 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,14 +780,15 @@ 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, h1 *h1Conversion) error {
|
||||||
m, ok := raw.(map[string]any)
|
m, ok := raw.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -770,6 +805,7 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw a
|
|||||||
if err := classes.FormatTranslation(attrs); err != nil {
|
if err := classes.FormatTranslation(attrs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
h1.convertTranslation(locale, attrs)
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
if val, has := attrs[field]; has && val != nil {
|
if val, has := attrs[field]; has && val != nil {
|
||||||
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
|
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
|
||||||
@@ -780,3 +816,90 @@ 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// h1Conversion converts H1 to H2 in the post content an API write stores
|
||||||
|
// while the Journal H1 setting is on (D-50), and collects one notice per
|
||||||
|
// locale that had headings converted. A nil *h1Conversion converts nothing.
|
||||||
|
type h1Conversion struct {
|
||||||
|
on bool
|
||||||
|
locales []string
|
||||||
|
counts []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newH1Conversion reads the H1 setting on tx (EffectiveH1ToH2).
|
||||||
|
func newH1Conversion(ctx context.Context, tx *gorm.DB, app *backpack.App) *h1Conversion {
|
||||||
|
return &h1Conversion{on: classes.EffectiveH1ToH2(ctx, tx, appConfig(app))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *h1Conversion) add(locale string, n int) {
|
||||||
|
if n > 0 {
|
||||||
|
h.locales = append(h.locales, locale)
|
||||||
|
h.counts = append(h.counts, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertPost converts the post's host content and content_html.
|
||||||
|
func (h *h1Conversion) convertPost(post *models.Post) {
|
||||||
|
if h == nil || !h.on || post == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
post.Content, post.ContentHTML, n = classes.ConvertContentH1(post.Content, post.ContentHTML)
|
||||||
|
h.add("", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertTranslation converts one locale's submitted content and
|
||||||
|
// content_html, after FormatTranslation derived or sanitized them.
|
||||||
|
func (h *h1Conversion) convertTranslation(locale string, attrs map[string]any) {
|
||||||
|
if h == nil || !h.on {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.add(locale, classes.ConvertTranslationH1(attrs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer adds the additive notices key to a write answer when headings
|
||||||
|
// were converted: one {message, count} per locale, message being
|
||||||
|
// golem15.journal::lang.editor.h1_converted in the request locale, with the
|
||||||
|
// locale key on a translation's notice. Without a conversion the answer is
|
||||||
|
// unchanged.
|
||||||
|
func (h *h1Conversion) answer(r *http.Request, app *backpack.App, payload map[string]any) map[string]any {
|
||||||
|
if h == nil || len(h.counts) == 0 {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
var tr *phrasebook.Translator
|
||||||
|
if app != nil {
|
||||||
|
tr, _ = app.Lookup[*phrasebook.Translator]()
|
||||||
|
}
|
||||||
|
notices := make([]map[string]any, 0, len(h.counts))
|
||||||
|
for i, n := range h.counts {
|
||||||
|
message := classes.EditorH1Converted
|
||||||
|
if tr != nil {
|
||||||
|
message = tr.Choice(r.Context(), classes.EditorH1Converted, n, nil)
|
||||||
|
}
|
||||||
|
notice := map[string]any{"message": message, "count": n}
|
||||||
|
if h.locales[i] != "" {
|
||||||
|
notice["locale"] = h.locales[i]
|
||||||
|
}
|
||||||
|
notices = append(notices, notice)
|
||||||
|
}
|
||||||
|
payload["notices"] = notices
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,15 +3,27 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/beachcomber/typesense"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search string, publishedOnly bool, page, perPage int) ([]uint, bool) {
|
// searchWindow is how many engine candidates one search re-gates in SQL.
|
||||||
if app == nil || db == nil || !models.SearchGateEnabled() {
|
// The list pages over the gated rows, so a page never depends on how the
|
||||||
|
// engine paged.
|
||||||
|
const searchWindow = typesense.MaxPerPage
|
||||||
|
|
||||||
|
// searchWithEngine returns the candidate post ids for search in the engine's
|
||||||
|
// rank order, and whether the engine answered. It asks for the whole result
|
||||||
|
// window (the first searchWindow hits), not one page: the caller re-gates
|
||||||
|
// the ids in SQL, counts them and pages over them. When it reports false the
|
||||||
|
// caller falls back to SQL search.
|
||||||
|
func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search string, publishedOnly bool) ([]uint, bool) {
|
||||||
|
if app == nil || db == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
svc, err := beachcomber.From(app)
|
svc, err := beachcomber.From(app)
|
||||||
@@ -20,17 +32,27 @@ func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search st
|
|||||||
}
|
}
|
||||||
var settings models.Settings
|
var settings models.Settings
|
||||||
if err := db.First(&settings, 1).Error; err != nil {
|
if err := db.First(&settings, 1).Error; err != nil {
|
||||||
|
if !models.SearchGateEnabled() {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
settings.SearchTitleWeight = 5
|
settings.SearchTitleWeight = 5
|
||||||
settings.SearchExcerptWeight = 3
|
settings.SearchExcerptWeight = 3
|
||||||
settings.SearchContentWeight = 1
|
settings.SearchContentWeight = 1
|
||||||
|
} else {
|
||||||
|
// The row is the gate: the in-memory one is set only once a sync
|
||||||
|
// has run since boot.
|
||||||
|
models.SetSearchGate(settings.SearchUseTypesense)
|
||||||
|
if !settings.SearchUseTypesense {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
q := beachcomber.Query{
|
q := beachcomber.Query{
|
||||||
Q: search,
|
Q: search,
|
||||||
QueryBy: []string{"title", "excerpt", "content"},
|
QueryBy: []string{"title", "excerpt", "content"},
|
||||||
QueryByWeights: []int{settings.SearchTitleWeight, settings.SearchExcerptWeight, settings.SearchContentWeight},
|
QueryByWeights: []int{settings.SearchTitleWeight, settings.SearchExcerptWeight, settings.SearchContentWeight},
|
||||||
SortBy: "_text_match:desc,published_at:desc",
|
SortBy: "_text_match:desc,published_at:desc",
|
||||||
Page: page,
|
Page: 1,
|
||||||
PerPage: perPage,
|
PerPage: searchWindow,
|
||||||
}
|
}
|
||||||
if publishedOnly {
|
if publishedOnly {
|
||||||
q.FilterBy = "published:=1"
|
q.FilterBy = "published:=1"
|
||||||
@@ -40,6 +62,9 @@ func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search st
|
|||||||
pluginLog.Warn("journal: Typesense search failed, falling back to DB search", "err", err)
|
pluginLog.Warn("journal: Typesense search failed, falling back to DB search", "err", err)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if res.Found > searchWindow {
|
||||||
|
pluginLog.Debug("journal: search matched more posts than one window; keeping the first", "found", res.Found, "kept", searchWindow)
|
||||||
|
}
|
||||||
ids := make([]uint, 0, len(res.IDs))
|
ids := make([]uint, 0, len(res.IDs))
|
||||||
for _, raw := range res.IDs {
|
for _, raw := range res.IDs {
|
||||||
n, err := strconv.ParseUint(raw, 10, 64)
|
n, err := strconv.ParseUint(raw, 10, 64)
|
||||||
@@ -50,3 +75,18 @@ func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search st
|
|||||||
}
|
}
|
||||||
return ids, true
|
return ids, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rankOrder orders rows by their position in ids, the engine's rank, with
|
||||||
|
// pinned posts first when pinnedFirst. The ids are parsed integers, so the
|
||||||
|
// array literal carries no caller text.
|
||||||
|
func rankOrder(ids []uint, pinnedFirst bool) string {
|
||||||
|
parts := make([]string, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
parts[i] = strconv.FormatUint(uint64(id), 10)
|
||||||
|
}
|
||||||
|
order := "array_position(ARRAY[" + strings.Join(parts, ",") + "]::bigint[], id), id DESC"
|
||||||
|
if pinnedFirst {
|
||||||
|
return "is_pinned DESC, " + order
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
219
controllers/api/settings.go
Normal file
219
controllers/api/settings.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/wire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// permManageSettings guards the settings and maintenance routes.
|
||||||
|
const permManageSettings = "golem15.journal.manage_settings"
|
||||||
|
|
||||||
|
// requireManageSettings answers 403 {"error":"Insufficient permissions"}
|
||||||
|
// without golem15.journal.manage_settings.
|
||||||
|
func requireManageSettings(w http.ResponseWriter, pr *bouncer.Principal) bool {
|
||||||
|
if !cabana.Allows(pr, []string{permManageSettings}) {
|
||||||
|
writeAPIError(w, http.StatusForbidden, errInsufficientPerms)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeSettings is the settings answer: every fillable column by name.
|
||||||
|
func serializeSettings(s models.Settings) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"show_all_posts": s.ShowAllPosts,
|
||||||
|
"use_rich_editor": s.UseRichEditor,
|
||||||
|
"search_use_typesense": s.SearchUseTypesense,
|
||||||
|
"search_title_weight": s.SearchTitleWeight,
|
||||||
|
"search_excerpt_weight": s.SearchExcerptWeight,
|
||||||
|
"search_content_weight": s.SearchContentWeight,
|
||||||
|
"rss_enabled": s.RSSEnabled,
|
||||||
|
"rss_include_content": s.RSSIncludeContent,
|
||||||
|
"rss_title": s.RSSTitle,
|
||||||
|
"rss_description": s.RSSDescription,
|
||||||
|
"rss_language": s.RSSLanguage,
|
||||||
|
"rss_copyright": s.RSSCopyright,
|
||||||
|
"rss_image_url": s.RSSImageURL,
|
||||||
|
"rss_posts_per_feed": s.RSSPostsPerFeed,
|
||||||
|
"content_h1_to_h2": s.ContentH1ToH2,
|
||||||
|
"generic_anchors": s.GenericAnchors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSettings reads the settings row (ID 1); without one it is the
|
||||||
|
// migration's defaults, not yet stored.
|
||||||
|
func loadSettings(db *gorm.DB) (models.Settings, error) {
|
||||||
|
var s models.Settings
|
||||||
|
err := db.First(&s, 1).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return models.Settings{
|
||||||
|
ID: 1, ShowAllPosts: true, SearchTitleWeight: 5, SearchExcerptWeight: 3, SearchContentWeight: 1,
|
||||||
|
RSSEnabled: true, RSSLanguage: "en-us", RSSPostsPerFeed: 20, ContentH1ToH2: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowSettings is GET /_journal/api/v1/settings: the Journal settings row,
|
||||||
|
// for golem15.journal.manage_settings (MCP journal_get_settings).
|
||||||
|
func ShowSettings(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||||
|
if !ok || !requireManageSettings(w, pr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := gdb(app)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := loadSettings(db.WithContext(r.Context()))
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializeSettings(s)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings is PUT /_journal/api/v1/settings: it writes the submitted
|
||||||
|
// fillable columns (models.Settings.Fillable) after checking them against
|
||||||
|
// the model's Rules, and answers the row. Other keys are ignored. Changing
|
||||||
|
// content_h1_to_h2 never rewrites stored content (D-50). MCP
|
||||||
|
// journal_update_settings.
|
||||||
|
func UpdateSettings(app *backpack.App) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r, pr, ok := requireBackendPrincipal(app, w, r)
|
||||||
|
if !ok || !requireManageSettings(w, pr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := gdb(app)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := readJSONObject(r)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(w, http.StatusBadRequest, errValidationFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fillable := models.Settings{}.Fillable()
|
||||||
|
values := map[string]any{}
|
||||||
|
for key, v := range body {
|
||||||
|
if slices.Contains(fillable, key) {
|
||||||
|
values[key] = plainJSON(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var tr *phrasebook.Translator
|
||||||
|
if app != nil {
|
||||||
|
tr, _ = app.Lookup[*phrasebook.Translator]()
|
||||||
|
}
|
||||||
|
var saved models.Settings
|
||||||
|
var verrs map[string][]string
|
||||||
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
|
s, err := loadSettings(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rules := map[string]string{}
|
||||||
|
for key, rule := range s.Rules() {
|
||||||
|
if _, has := values[key]; has {
|
||||||
|
rules[key], verrs = settingsRule(key, rule, values[key], verrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(verrs) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if verrs, err = lagoon.Validate(ctx, tx, &s, rules, values, tr); err != nil || len(verrs) > 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := lagoon.Fill(&s, fillable, values, true); err != nil {
|
||||||
|
var typeErr *lagoon.FillTypeError
|
||||||
|
if errors.As(err, &typeErr) {
|
||||||
|
verrs = map[string][]string{typeErr.Key: {"The " + typeErr.Key + " field is invalid."}}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.ID = 1
|
||||||
|
if err := tx.Save(&s).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
saved = s
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pluginLog.ErrorContext(r.Context(), "journal api: settings update failed", "error", err)
|
||||||
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(verrs) > 0 {
|
||||||
|
writeValidation(w, verrs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializeSettings(saved)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainJSON turns a json.Number (readJSONObject decodes with UseNumber)
|
||||||
|
// into an int64, or a float64 when it is not an integer.
|
||||||
|
func plainJSON(v any) any {
|
||||||
|
n, ok := v.(json.Number)
|
||||||
|
if !ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if i, err := n.Int64(); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if f, err := n.Float64(); err == nil {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsRule checks the string and url tokens of a Settings rule, which
|
||||||
|
// lagoon.Validate does not know, and returns the rule without them for
|
||||||
|
// lagoon.Validate. A failed check is added to verrs.
|
||||||
|
func settingsRule(key, rule string, value any, verrs map[string][]string) (string, map[string][]string) {
|
||||||
|
var kept []string
|
||||||
|
for _, tok := range strings.Split(rule, "|") {
|
||||||
|
switch tok {
|
||||||
|
case "string":
|
||||||
|
if _, ok := value.(string); !ok && value != nil {
|
||||||
|
verrs = addSettingsError(verrs, key, "The "+key+" must be a string.")
|
||||||
|
}
|
||||||
|
case "url":
|
||||||
|
if text, _ := value.(string); text != "" {
|
||||||
|
u, err := url.Parse(text)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
verrs = addSettingsError(verrs, key, "The "+key+" format is invalid.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
kept = append(kept, tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "|"), verrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSettingsError(verrs map[string][]string, key, msg string) map[string][]string {
|
||||||
|
if verrs == nil {
|
||||||
|
verrs = map[string][]string{}
|
||||||
|
}
|
||||||
|
verrs[key] = append(verrs[key], msg)
|
||||||
|
return verrs
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"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/compass"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,9 @@ const (
|
|||||||
// PermissionAccessImportExport gates journal:export-posts / import-posts
|
// PermissionAccessImportExport gates journal:export-posts / import-posts
|
||||||
// toolbar actions (D-13).
|
// toolbar actions (D-13).
|
||||||
PermissionAccessImportExport = "golem15.journal.access_import_export"
|
PermissionAccessImportExport = "golem15.journal.access_import_export"
|
||||||
|
// PermissionManageSettings is the Journal settings permission; the
|
||||||
|
// /_journal/api/v1 settings and fix-headings routes require it.
|
||||||
|
PermissionManageSettings = "golem15.journal.manage_settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type postsAdminController struct {
|
type postsAdminController struct {
|
||||||
@@ -260,6 +264,9 @@ func (c postsAdminController) afterPostWrite(ctx context.Context, model any) err
|
|||||||
if post, ok := model.(*models.Post); ok && post != nil {
|
if post, ok := model.(*models.Post); ok && post != nil {
|
||||||
purger, tx := siteCachePurger(ctx, c.app)
|
purger, tx := siteCachePurger(ctx, c.app)
|
||||||
classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
|
classes.PurgeSiteCacheAfterCommit(ctx, tx, purger, "post", post.Slug)
|
||||||
|
// Featured images uploaded in the form are bound by this save;
|
||||||
|
// their variants are queued once it commits (D-85).
|
||||||
|
classes.DispatchPostImageVariantsAfterCommit(ctx, tx, c.application(), post.ID)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -345,7 +352,11 @@ func (c postsAdminController) runExportPosts(ctx context.Context, in pact.AdminA
|
|||||||
|
|
||||||
func (c postsAdminController) runImportPosts(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
func (c postsAdminController) runImportPosts(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
||||||
path, dry, force := console.IOPayloadPath(in.Payload, "journal-export")
|
path, dry, force := console.IOPayloadPath(in.Payload, "journal-export")
|
||||||
stats, err := console.ImportFromDir(ctx, c.db(ctx), path, force, dry, nil, func(post *models.Post) error {
|
var cfg *compass.Config
|
||||||
|
if app := c.application(); app != nil {
|
||||||
|
cfg = app.Config
|
||||||
|
}
|
||||||
|
stats, err := console.ImportFromDirConfig(ctx, c.db(ctx), cfg, path, force, dry, nil, func(post *models.Post) error {
|
||||||
return refuseUnpublishedWrite(ctx, post)
|
return refuseUnpublishedWrite(ctx, post)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -496,10 +496,10 @@ func TestBlogEditorUploadSmoke(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.URL != attach.PublicURL("journal/posts/zdj-cie-1.png") {
|
if got.URL != attach.PublicURL("journal/posts/zdjecie-1.png") {
|
||||||
t.Fatalf("URL = %q", got.URL)
|
t.Fatalf("URL = %q", got.URL)
|
||||||
}
|
}
|
||||||
if !bytes.Equal(stored(t, "journal/posts/zdj-cie-1.png"), pngBytes) {
|
if !bytes.Equal(stored(t, "journal/posts/zdjecie-1.png"), pngBytes) {
|
||||||
t.Fatal("stored bytes differ from the upload")
|
t.Fatal("stored bytes differ from the upload")
|
||||||
}
|
}
|
||||||
for name, body := range map[string][]byte{"a.jpg": jpegBytes, "b.jpeg": jpegBytes, "c.gif": gifBytes, "d.webp": webpBytes} {
|
for name, body := range map[string][]byte{"a.jpg": jpegBytes, "b.jpeg": jpegBytes, "c.gif": gifBytes, "d.webp": webpBytes} {
|
||||||
|
|||||||
9
go.mod
9
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.5.0
|
git.golem15.com/golem15/summercms v0.6.1
|
||||||
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
|
||||||
@@ -77,6 +75,7 @@ require (
|
|||||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||||
|
github.com/gen2brain/webp v0.6.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
@@ -124,10 +123,6 @@ require (
|
|||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/mango v0.1.0 // indirect
|
|
||||||
github.com/muesli/mango-cobra v1.2.0 // indirect
|
|
||||||
github.com/muesli/mango-pflag v0.1.0 // indirect
|
|
||||||
github.com/muesli/roff v0.1.0 // indirect
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
|
|||||||
14
go.sum
14
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=
|
||||||
@@ -173,6 +169,8 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
|
|||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||||
|
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
|
||||||
|
github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
@@ -303,14 +301,6 @@ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtw
|
|||||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
|
|
||||||
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
|
|
||||||
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
|
|
||||||
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
|
|
||||||
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
|
|
||||||
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
|
|
||||||
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
|
||||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
|||||||
855
headings_test.go
Normal file
855
headings_test.go
Normal file
@@ -0,0 +1,855 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
)
|
||||||
|
|
||||||
|
const journalAPIPrefix = "/_journal/api/v1"
|
||||||
|
|
||||||
|
// setH1ToH2 switches the Journal H1 setting on the settings row.
|
||||||
|
func setH1ToH2(t *testing.T, env *adminEnv, on bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET content_h1_to_h2 = ? WHERE id = 1`, on).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishPost publishes a post with a past published_at.
|
||||||
|
func publishPost(t *testing.T, env *adminEnv, id uint) {
|
||||||
|
t.Helper()
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_posts SET published = TRUE, published_at = NOW() - INTERVAL '1 hour' WHERE id = ?`, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showH1Flag is GET /_journal/api/v1/posts/{slug}'s content_h1_to_h2.
|
||||||
|
func showH1Flag(t *testing.T, env *adminEnv, slug string) any {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, journalAPIPrefix+"/posts/"+slug, "", nil)
|
||||||
|
var body struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("show body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
flag, ok := body.Data["content_h1_to_h2"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("show answer has no content_h1_to_h2: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeadingConversionTracer is the 33-02 tracer: an editor saves a post
|
||||||
|
// whose content holds an H1 through the admin and gets an H2 back with the
|
||||||
|
// h1_converted notice, and the public post answer reports the setting.
|
||||||
|
// With the setting off the same save keeps the H1, adds no notice, and the
|
||||||
|
// answer reports false.
|
||||||
|
func TestHeadingConversionTracer(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
token := env.admin(t,
|
||||||
|
controllers.PermissionAccessPosts,
|
||||||
|
controllers.PermissionAccessPublish,
|
||||||
|
controllers.PermissionAccessOtherPosts,
|
||||||
|
)
|
||||||
|
const submitted = "<h1>A</h1><p>x</p>"
|
||||||
|
|
||||||
|
save := func(t *testing.T, slug string) (uint, []adminNotice) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{
|
||||||
|
"title": map[string]any{"en": "Heading " + slug, "pl": "Nagłówek " + slug},
|
||||||
|
"slug": map[string]any{"en": slug, "pl": slug + "-pl"},
|
||||||
|
"content": map[string]any{"en": submitted, "pl": submitted},
|
||||||
|
})
|
||||||
|
return adminCreatedID(t, rec), adminNotices(t, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("setting on", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
id, notices := save(t, "heading-on")
|
||||||
|
assertHostContent(t, env, id, "on", "<h2>A</h2><p>x</p>", "<h2>A</h2><p>x</p>")
|
||||||
|
pl := translatedAttrs(t, env, id, "pl")
|
||||||
|
if pl["content"] != "<h2>A</h2><p>x</p>" || pl["content_html"] != "<h2>A</h2><p>x</p>" {
|
||||||
|
t.Fatalf("pl content = %#v, content_html = %#v, want the H2", pl["content"], pl["content_html"])
|
||||||
|
}
|
||||||
|
for _, locale := range []string{"en", "pl"} {
|
||||||
|
found := false
|
||||||
|
for _, n := range notices {
|
||||||
|
if n.Field == "content" && n.Locale == locale &&
|
||||||
|
n.Message == "1 H1 heading was converted to H2, because the page shows the post title as the H1." {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("%s notices = %#v, want the h1_converted phrase with count 1", locale, notices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publishPost(t, env, id)
|
||||||
|
if flag := showH1Flag(t, env, "heading-on"); flag != true {
|
||||||
|
t.Fatalf("content_h1_to_h2 = %#v, want true", flag)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("setting off", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
t.Cleanup(func() { setH1ToH2(t, env, true) })
|
||||||
|
id, notices := save(t, "heading-off")
|
||||||
|
assertHostContent(t, env, id, "off", submitted, submitted)
|
||||||
|
for _, n := range notices {
|
||||||
|
if strings.Contains(n.Message, "H1") {
|
||||||
|
t.Fatalf("setting off: notice %#v", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if notices != nil {
|
||||||
|
t.Fatalf("setting off: clean save carries meta.notices %#v", notices)
|
||||||
|
}
|
||||||
|
publishPost(t, env, id)
|
||||||
|
if flag := showH1Flag(t, env, "heading-off"); flag != false {
|
||||||
|
t.Fatalf("content_h1_to_h2 = %#v, want false", flag)
|
||||||
|
}
|
||||||
|
// D-50: switching the setting never rewrites stored content.
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
var stored models.Post
|
||||||
|
if err := env.gdb.First(&stored, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Content != submitted || stored.ContentHTML != submitted {
|
||||||
|
t.Fatalf("switching the setting on rewrote content: %q / %q", stored.Content, stored.ContentHTML)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdInput is a bonfire.Input of flags only.
|
||||||
|
type cmdInput map[string]string
|
||||||
|
|
||||||
|
func (in cmdInput) Args() []string { return nil }
|
||||||
|
func (in cmdInput) Argument(string) (string, bool) { return "", false }
|
||||||
|
func (in cmdInput) Flags(string) []string { return nil }
|
||||||
|
func (in cmdInput) Flag(name string) (string, bool) { v, ok := in[name]; return v, ok }
|
||||||
|
|
||||||
|
// apiNotices decodes a Journal API write answer's notices, nil when absent.
|
||||||
|
func apiNotices(t *testing.T, rec *httptest.ResponseRecorder) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var body map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("api body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
raw, ok := body["notices"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
t.Fatalf("notices %s: %v", raw, err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiPostID is the id of a Journal API write answer's post.
|
||||||
|
func apiPostID(t *testing.T, 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 || body.Data.ID == 0 {
|
||||||
|
t.Fatalf("api answer %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return body.Data.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func storedPost(t *testing.T, env *adminEnv, id uint) models.Post {
|
||||||
|
t.Helper()
|
||||||
|
var p models.Post
|
||||||
|
if err := env.gdb.First(&p, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
const postH1Phrase = "1 H1 heading was converted to H2, because the page shows the post title as the H1."
|
||||||
|
|
||||||
|
// TestHeadingConversion covers every other Journal rich-text write path
|
||||||
|
// (D-50, CONTEXT 2026-10-28 H1 conversion scope): the API (and so MCP)
|
||||||
|
// create and update with default and translated content, the markdown
|
||||||
|
// path, journal:import-posts, a category's pillar text through the admin,
|
||||||
|
// and the content_h1_to_h2 flag of GET /categories. With the setting off
|
||||||
|
// nothing converts and no notice appears.
|
||||||
|
func TestHeadingConversion(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
token := env.admin(t, controllers.PermissionAccessPosts)
|
||||||
|
posts := journalAPIPrefix + "/posts"
|
||||||
|
|
||||||
|
t.Run("API create converts default and translated content", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{
|
||||||
|
"title": "API heading",
|
||||||
|
"content": `<h1 class="x">T</h1>`,
|
||||||
|
"translations": map[string]any{"pl": map[string]any{"title": "Nagłówek API", "content": `<h1 class="x">P</h1>`}},
|
||||||
|
})
|
||||||
|
id := apiPostID(t, rec)
|
||||||
|
p := storedPost(t, env, id)
|
||||||
|
if p.Content != `<h2 class="x">T</h2>` || !strings.Contains(p.ContentHTML, `<h2 class="x">T</h2>`) || strings.Contains(p.ContentHTML, "<h1") {
|
||||||
|
t.Fatalf("content = %q, content_html = %q, want the H2 with its class", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
pl := translatedAttrs(t, env, id, "pl")
|
||||||
|
if pl["content"] != `<h2 class="x">P</h2>` || !strings.Contains(pl["content_html"].(string), `<h2 class="x">P</h2>`) {
|
||||||
|
t.Fatalf("pl = %#v, want the H2", pl)
|
||||||
|
}
|
||||||
|
notices := apiNotices(t, rec)
|
||||||
|
if len(notices) != 2 || notices[0]["message"] != postH1Phrase || notices[0]["count"] != float64(1) ||
|
||||||
|
notices[1]["locale"] != "pl" || notices[1]["count"] != float64(1) {
|
||||||
|
t.Fatalf("notices = %#v, want the default and the pl phrase with count 1", notices)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("API update converts markdown; an update without content converts nothing", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{
|
||||||
|
"title": "Markdown heading", "content": "Body",
|
||||||
|
}))
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", posts, id), token, map[string]any{
|
||||||
|
"content": "# T\n\nText",
|
||||||
|
})
|
||||||
|
p := storedPost(t, env, id)
|
||||||
|
if p.Content != "# T\n\nText" || !strings.Contains(p.ContentHTML, "<h2") || strings.Contains(p.ContentHTML, "<h1") {
|
||||||
|
t.Fatalf("content = %q, content_html = %q, want markdown kept and an H2", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
if notices := apiNotices(t, rec); len(notices) != 1 || notices[0]["message"] != postH1Phrase {
|
||||||
|
t.Fatalf("notices = %#v", notices)
|
||||||
|
}
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_posts SET content_html = '<h1>Old</h1>' WHERE id = ?`, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec = env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", posts, id), token, map[string]any{"title": "Renamed"})
|
||||||
|
if notices := apiNotices(t, rec); notices != nil {
|
||||||
|
t.Fatalf("title-only update notices = %#v", notices)
|
||||||
|
}
|
||||||
|
if got := storedPost(t, env, id).ContentHTML; got != "<h1>Old</h1>" {
|
||||||
|
t.Fatalf("title-only update rewrote content_html: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("API with the setting off stores H1", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
t.Cleanup(func() { setH1ToH2(t, env, true) })
|
||||||
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{
|
||||||
|
"title": "Kept heading", "content": `<h1 class="x">T</h1>`,
|
||||||
|
})
|
||||||
|
p := storedPost(t, env, apiPostID(t, rec))
|
||||||
|
if p.Content != `<h1 class="x">T</h1>` || !strings.Contains(p.ContentHTML, `<h1 class="x">T</h1>`) {
|
||||||
|
t.Fatalf("content = %q, content_html = %q, want the H1 kept", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
if notices := apiNotices(t, rec); notices != nil {
|
||||||
|
t.Fatalf("setting off: notices %#v", notices)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("journal:import-posts converts and prints the count", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
dir := t.TempDir()
|
||||||
|
raw, err := json.Marshal(console.PostFile{
|
||||||
|
Slug: "imported-heading", Title: "Imported", Content: "<h1>I</h1><p>x</p>",
|
||||||
|
Translations: map[string]map[string]any{"pl": {"title": "Import", "content": "<h1>J</h1>"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "imported-heading.json"), raw, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||||
|
if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, out); err != nil {
|
||||||
|
t.Fatalf("import: %v (%s)", err, buf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "Converted 2 H1 headings to H2.") {
|
||||||
|
t.Fatalf("import output %q, want the converted count", buf.String())
|
||||||
|
}
|
||||||
|
var p models.Post
|
||||||
|
if err := env.gdb.Where("slug = ?", "imported-heading").Take(&p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Content != "<h2>I</h2><p>x</p>" || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h2>I</h2>") {
|
||||||
|
t.Fatalf("imported content = %q, content_html = %q", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
if pl := translatedAttrs(t, env, p.ID, "pl"); !strings.Contains(pl["content_html"].(string), "<h2>J</h2>") {
|
||||||
|
t.Fatalf("imported pl = %#v", pl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("markdown with a fenced h1 sample keeps its source", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
md := "# Sample\n\n```html\n<h1>Hello</h1>\n```\n\nInline `<h1>` tag."
|
||||||
|
id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{"title": "Markdown sample", "content": md}))
|
||||||
|
p := storedPost(t, env, id)
|
||||||
|
if p.Content != md || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h1>Hello</h1>") {
|
||||||
|
t.Fatalf("API save: content = %q, content_html = %q", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
raw, err := json.Marshal(console.PostFile{Slug: "imported-markdown", Title: "Imported markdown", Content: md})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "imported-markdown.json"), raw, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, bonfire.NewOutput(strings.NewReader(""), &buf, &buf)); err != nil {
|
||||||
|
t.Fatalf("import: %v (%s)", err, buf.String())
|
||||||
|
}
|
||||||
|
var imported models.Post
|
||||||
|
if err := env.gdb.Where("slug = ?", "imported-markdown").Take(&imported).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if imported.Content != md || strings.Contains(imported.ContentHTML, "<h1") || !strings.Contains(imported.ContentHTML, "<h2") {
|
||||||
|
t.Fatalf("import: content = %q, content_html = %q", imported.Content, imported.ContentHTML)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("markdown that starts with an HTML block keeps its h1 samples", func(t *testing.T) {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
md := "<p class=\"lead\">Intro</p>\n\n# Title\n\n```html\n<h1>Hello</h1>\n```"
|
||||||
|
id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{"title": "HTML lead", "content": md}))
|
||||||
|
p := storedPost(t, env, id)
|
||||||
|
if p.Content != md || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h1>Hello</h1>") || !strings.Contains(p.ContentHTML, "<h2") {
|
||||||
|
t.Fatalf("API save: content = %q, content_html = %q", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
raw, err := json.Marshal(console.PostFile{Slug: "imported-html-lead", Title: "Imported HTML lead", Content: md})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "imported-html-lead.json"), raw, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, bonfire.NewOutput(strings.NewReader(""), &buf, &buf)); err != nil {
|
||||||
|
t.Fatalf("import: %v (%s)", err, buf.String())
|
||||||
|
}
|
||||||
|
var imported models.Post
|
||||||
|
if err := env.gdb.Where("slug = ?", "imported-html-lead").Take(&imported).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if imported.Content != md || strings.Contains(imported.ContentHTML, "<h1") {
|
||||||
|
t.Fatalf("import: content = %q, content_html = %q", imported.Content, imported.ContentHTML)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("category pillar text through the admin", func(t *testing.T) {
|
||||||
|
catToken := env.admin(t, controllers.PermissionAccessCategories)
|
||||||
|
save := func(t *testing.T, slug string) (*string, []adminNotice) {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), catToken, map[string]any{
|
||||||
|
"name": map[string]any{"en": "Pillar " + slug, "pl": "Filar " + slug},
|
||||||
|
"slug": map[string]any{"en": slug, "pl": slug + "-pl"},
|
||||||
|
"content": "<h1>C</h1><p>x</p>",
|
||||||
|
})
|
||||||
|
var cat models.Category
|
||||||
|
if err := env.gdb.First(&cat, adminCreatedID(t, rec)).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cat.Content, adminNotices(t, rec)
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
content, notices := save(t, "pillar-on")
|
||||||
|
if content == nil || *content != "<h2>C</h2><p>x</p>" {
|
||||||
|
t.Fatalf("category content = %#v, want the H2", content)
|
||||||
|
}
|
||||||
|
if len(notices) != 1 || notices[0].Field != "content" ||
|
||||||
|
notices[0].Message != "1 H1 heading was converted to H2, because the page shows the category name as the H1." {
|
||||||
|
t.Fatalf("category notices = %#v", notices)
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
t.Cleanup(func() { setH1ToH2(t, env, true) })
|
||||||
|
content, notices = save(t, "pillar-off")
|
||||||
|
if content == nil || *content != "<h1>C</h1><p>x</p>" || notices != nil {
|
||||||
|
t.Fatalf("setting off: content = %#v, notices = %#v", content, notices)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET /categories reports the setting on every row", func(t *testing.T) {
|
||||||
|
for _, on := range []bool{true, false} {
|
||||||
|
setH1ToH2(t, env, on)
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, journalAPIPrefix+"/categories?include_empty=1", "", nil)
|
||||||
|
var body struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) == 0 {
|
||||||
|
t.Fatalf("categories %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
for _, row := range body.Data {
|
||||||
|
if row["content_h1_to_h2"] != on {
|
||||||
|
t.Fatalf("setting %v: row %#v", on, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const h1Refusal = "H1 to H2 conversion is turned off in the Journal settings (Headings and links). Turn it on first, or keep the H1 headings."
|
||||||
|
|
||||||
|
// seedStoredH1 stores, with the setting off, a post whose default and
|
||||||
|
// Polish content hold one H1 each and a category whose pillar text holds
|
||||||
|
// one H1, and switches the setting back on. It returns the post and the
|
||||||
|
// category id.
|
||||||
|
func seedStoredH1(t *testing.T, env *adminEnv, slug string) (uint, uint) {
|
||||||
|
t.Helper()
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessCategories)
|
||||||
|
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", token, map[string]any{
|
||||||
|
"title": "Stored " + slug, "slug": slug, "content": "<h1>A</h1><p>x</p>",
|
||||||
|
"translations": map[string]any{"pl": map[string]any{"title": "Zapisany " + slug, "content": "<h1>B</h1>"}},
|
||||||
|
}))
|
||||||
|
cat := adminCreatedID(t, env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminCategoriesPath), token, map[string]any{
|
||||||
|
"name": map[string]any{"en": "Stored " + slug, "pl": "Zapisana " + slug},
|
||||||
|
"slug": map[string]any{"en": slug + "-cat", "pl": slug + "-kat"},
|
||||||
|
"content": "<h1>C</h1>",
|
||||||
|
}))
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
return post, cat
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryContent(t *testing.T, env *adminEnv, id uint) string {
|
||||||
|
t.Helper()
|
||||||
|
var c models.Category
|
||||||
|
if err := env.gdb.First(&c, id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.Content == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *c.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFixHeadingsCommand: journal:fix-headings is a dry run by default,
|
||||||
|
// --apply converts default and translated post content and category pillar
|
||||||
|
// text and fires RecordSaved per changed record, a second run finds
|
||||||
|
// nothing, and with the setting off it refuses with exit code 1 (D-50,
|
||||||
|
// UI-SPEC C27 console copy, P33-54).
|
||||||
|
func TestFixHeadingsCommand(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
post, cat := seedStoredH1(t, env, "fix-cli")
|
||||||
|
run := func(t *testing.T, flags cmdInput) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
var buf strings.Builder
|
||||||
|
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||||
|
err := console.FixHeadings(env.app).Run(t.Context(), flags, out)
|
||||||
|
return buf.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := run(t, cmdInput{})
|
||||||
|
if err != nil || !strings.Contains(text, "Would convert 3 H1 headings in 1 posts and 1 categories. Run with --apply to save.") {
|
||||||
|
t.Fatalf("dry run = %q, %v", text, err)
|
||||||
|
}
|
||||||
|
if p := storedPost(t, env, post); p.ContentHTML != "<h1>A</h1><p>x</p>" || categoryContent(t, env, cat) != "<h1>C</h1>" {
|
||||||
|
t.Fatalf("dry run wrote: %q / %q", p.ContentHTML, categoryContent(t, env, cat))
|
||||||
|
}
|
||||||
|
|
||||||
|
records := listenRecords(t, env)
|
||||||
|
text, err = run(t, cmdInput{"apply": "true"})
|
||||||
|
if err != nil || !strings.Contains(text, "Converted 3 H1 headings in 1 posts and 1 categories.") {
|
||||||
|
t.Fatalf("apply = %q, %v", text, err)
|
||||||
|
}
|
||||||
|
p := storedPost(t, env, post)
|
||||||
|
if p.Content != "<h2>A</h2><p>x</p>" || p.ContentHTML != "<h2>A</h2><p>x</p>" {
|
||||||
|
t.Fatalf("applied post = %q / %q", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
if pl := translatedAttrs(t, env, post, "pl"); pl["content"] != "<h2>B</h2>" || strings.Contains(pl["content_html"].(string), "<h1") {
|
||||||
|
t.Fatalf("applied pl = %#v", pl)
|
||||||
|
}
|
||||||
|
if got := categoryContent(t, env, cat); got != "<h2>C</h2>" {
|
||||||
|
t.Fatalf("applied category = %q", got)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, ev := range records.savedEvents() {
|
||||||
|
seen[ev.ev.Model] = true
|
||||||
|
if ev.ev.Model == p.MorphName() {
|
||||||
|
if c, ok := findChange(ev.ev.Changes, "content_html", "pl"); !ok || c.New == nil {
|
||||||
|
t.Fatalf("post event without the pl content_html change: %#v", ev.ev.Changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen[p.MorphName()] || !seen[models.Category{}.MorphName()] {
|
||||||
|
t.Fatalf("RecordSaved models = %v, want the post and the category", seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
if text, err = run(t, cmdInput{"apply": "true"}); err != nil || !strings.Contains(text, "No H1 headings found in post or category content.") {
|
||||||
|
t.Fatalf("second run = %q, %v", text, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// L1 CR-02: a markdown post with a fenced h1 sample keeps its source;
|
||||||
|
// only its stored content_html is converted.
|
||||||
|
md := "# Sample\n\n```html\n<h1>Hello</h1>\n```\n"
|
||||||
|
rendered, err := classes.FormatHTML(md)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mdPost := models.Post{Title: "Markdown sample", Slug: "fix-cli-markdown", Content: md, ContentHTML: rendered}
|
||||||
|
if err := env.gdb.Create(&mdPost).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if text, err = run(t, cmdInput{"apply": "true"}); err != nil || !strings.Contains(text, "Converted 1 H1 headings in 1 posts and 0 categories.") {
|
||||||
|
t.Fatalf("markdown run = %q, %v", text, err)
|
||||||
|
}
|
||||||
|
if p := storedPost(t, env, mdPost.ID); p.Content != md || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h1>Hello</h1>") {
|
||||||
|
t.Fatalf("markdown post after --apply = %q / %q", p.Content, p.ContentHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
t.Cleanup(func() { setH1ToH2(t, env, true) })
|
||||||
|
text, err = run(t, cmdInput{"apply": "true"})
|
||||||
|
if err == nil || !errors.Is(err, bonfire.ErrReported) || !strings.Contains(text, h1Refusal) {
|
||||||
|
t.Fatalf("setting off = %q, %v; want the refusal and exit code 1", text, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalSettingsTools: GET and PUT /_journal/api/v1/settings and POST
|
||||||
|
// /fix-headings, as the MCP tools call them, with
|
||||||
|
// golem15.journal.manage_settings, and 401/403 without (T-33-05).
|
||||||
|
func TestJournalSettingsTools(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
settings := journalAPIPrefix + "/settings"
|
||||||
|
manager := env.admin(t, controllers.PermissionManageSettings)
|
||||||
|
editor := env.admin(t, controllers.PermissionAccessPosts)
|
||||||
|
|
||||||
|
env.expect(t, http.StatusUnauthorized, http.MethodGet, settings, "", nil)
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodGet, settings, editor, nil)
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPut, settings, editor, map[string]any{"content_h1_to_h2": false})
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPost, journalAPIPrefix+"/fix-headings", editor, map[string]any{})
|
||||||
|
|
||||||
|
decode := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var body struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data == nil {
|
||||||
|
t.Fatalf("settings body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return body.Data
|
||||||
|
}
|
||||||
|
got := decode(t, env.expect(t, http.StatusOK, http.MethodGet, settings, manager, nil))
|
||||||
|
if got["content_h1_to_h2"] != true || got["generic_anchors"] != nil || got["rss_enabled"] != true || got["rss_posts_per_feed"] != float64(20) {
|
||||||
|
t.Fatalf("settings = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = decode(t, env.expect(t, http.StatusOK, http.MethodPut, settings, manager, map[string]any{
|
||||||
|
"content_h1_to_h2": false, "generic_anchors": "click here\nhere",
|
||||||
|
}))
|
||||||
|
if got["content_h1_to_h2"] != false || got["generic_anchors"] != "click here\nhere" || got["rss_enabled"] != true {
|
||||||
|
t.Fatalf("updated settings = %#v", got)
|
||||||
|
}
|
||||||
|
var row models.Settings
|
||||||
|
if err := env.gdb.First(&row, 1).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if row.ContentH1ToH2 || row.GenericAnchors == nil || *row.GenericAnchors != "click here\nhere" {
|
||||||
|
t.Fatalf("stored settings = %+v", row)
|
||||||
|
}
|
||||||
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, settings, manager, map[string]any{"rss_posts_per_feed": 500})
|
||||||
|
if !strings.Contains(rec.Body.String(), "rss_posts_per_feed") {
|
||||||
|
t.Fatalf("invalid settings answer %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
for key, bad := range map[string]any{"rss_image_url": "not a url", "generic_anchors": 5} {
|
||||||
|
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, settings, manager, map[string]any{key: bad})
|
||||||
|
if !strings.Contains(rec.Body.String(), key) {
|
||||||
|
t.Fatalf("invalid %s answer %s", key, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post, _ := seedStoredH1(t, env, "fix-api")
|
||||||
|
fix := journalAPIPrefix + "/fix-headings"
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
rec = env.expect(t, http.StatusConflict, http.MethodPost, fix, manager, map[string]any{})
|
||||||
|
if !strings.Contains(rec.Body.String(), "H1 to H2 conversion is turned off") {
|
||||||
|
t.Fatalf("refusal %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
answer := func(rec *httptest.ResponseRecorder) map[string]any {
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("fix body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
dry := answer(env.expect(t, http.StatusOK, http.MethodPost, fix, manager, map[string]any{}))
|
||||||
|
if dry["dry_run"] != true || dry["posts"] != float64(1) || dry["categories"] != float64(1) || dry["headings"] != float64(3) {
|
||||||
|
t.Fatalf("dry run answer = %#v", dry)
|
||||||
|
}
|
||||||
|
if storedPost(t, env, post).ContentHTML != "<h1>A</h1><p>x</p>" {
|
||||||
|
t.Fatal("dry run wrote the post")
|
||||||
|
}
|
||||||
|
applied := answer(env.expect(t, http.StatusOK, http.MethodPost, fix, manager, map[string]any{"dry_run": false}))
|
||||||
|
if applied["dry_run"] != false || applied["headings"] != float64(3) {
|
||||||
|
t.Fatalf("applied answer = %#v", applied)
|
||||||
|
}
|
||||||
|
if got := storedPost(t, env, post).ContentHTML; got != "<h2>A</h2><p>x</p>" {
|
||||||
|
t.Fatalf("applied post content_html = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetImageAlt: POST /_journal/api/v1/images/alt sets alt on the
|
||||||
|
// matching img of a post's content_html (default or translated), answers
|
||||||
|
// the per-change diff, writes nothing on a dry run, refuses the whole batch
|
||||||
|
// with 422 when one src is not in the post, escapes the alt through the
|
||||||
|
// tokenizer (T-33-04) and needs golem15.journal.access_posts (D-85, P33-05).
|
||||||
|
func TestSetImageAlt(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
editor := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
|
||||||
|
manager := env.admin(t, controllers.PermissionManageSettings)
|
||||||
|
const a, b = "https://cdn.example.test/a.jpg", "https://cdn.example.test/b.jpg"
|
||||||
|
body := `<p><img src="` + a + `"></p><p><img src="` + b + `" alt="old"></p>`
|
||||||
|
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{
|
||||||
|
"title": "Images", "content": body,
|
||||||
|
"translations": map[string]any{"pl": map[string]any{"title": "Obrazy", "content": `<p><img src="` + a + `"></p>`}},
|
||||||
|
}))
|
||||||
|
path := journalAPIPrefix + "/images/alt"
|
||||||
|
const tricky = `Lion's "mane" <b>`
|
||||||
|
|
||||||
|
env.expect(t, http.StatusUnauthorized, http.MethodPost, path, "", map[string]any{"changes": []any{}})
|
||||||
|
env.expect(t, http.StatusForbidden, http.MethodPost, path, manager, map[string]any{"changes": []any{}})
|
||||||
|
|
||||||
|
decode := func(rec *httptest.ResponseRecorder) ([]map[string]any, any) {
|
||||||
|
var out struct {
|
||||||
|
Changes []map[string]any `json:"changes"`
|
||||||
|
DryRun any `json:"dry_run"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("alt body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return out.Changes, out.DryRun
|
||||||
|
}
|
||||||
|
|
||||||
|
changes, dry := decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"dry_run": true,
|
||||||
|
"changes": []any{map[string]any{"post_id": post, "src": a, "alt": tricky}},
|
||||||
|
}))
|
||||||
|
if dry != true || len(changes) != 1 || changes[0]["old_alt"] != nil || changes[0]["alt"] != tricky || changes[0]["changed"] != true {
|
||||||
|
t.Fatalf("dry run answer = %#v, %v", changes, dry)
|
||||||
|
}
|
||||||
|
if got := storedPost(t, env, post).ContentHTML; strings.Contains(got, "alt=\"Lion") {
|
||||||
|
t.Fatalf("dry run wrote: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{
|
||||||
|
map[string]any{"post_id": post, "src": a, "alt": "kept out"},
|
||||||
|
map[string]any{"post_id": post, "src": "https://cdn.example.test/missing.jpg", "alt": "x"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(rec.Body.String(), "changes.1.src") {
|
||||||
|
t.Fatalf("missing src answer %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := storedPost(t, env, post).ContentHTML; strings.Contains(got, "kept out") {
|
||||||
|
t.Fatalf("refused batch wrote: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := listenRecords(t, env)
|
||||||
|
changes, dry = decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{
|
||||||
|
map[string]any{"post_id": post, "src": a, "alt": tricky},
|
||||||
|
map[string]any{"post_id": post, "src": b, "alt": ""},
|
||||||
|
map[string]any{"post_id": post, "locale": "pl", "src": a, "alt": "Lew"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
if dry != false || len(changes) != 3 || changes[1]["old_alt"] != "old" || changes[2]["locale"] != "pl" {
|
||||||
|
t.Fatalf("applied answer = %#v, %v", changes, dry)
|
||||||
|
}
|
||||||
|
p := storedPost(t, env, post)
|
||||||
|
for _, html := range []string{p.ContentHTML, p.Content} {
|
||||||
|
if !strings.Contains(html, `alt="Lion's "mane" <b>"`) || strings.Contains(html, "<b>") || !strings.Contains(html, `alt=""`) {
|
||||||
|
t.Fatalf("applied default = %q", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pl := translatedAttrs(t, env, post, "pl")
|
||||||
|
if !strings.Contains(pl["content_html"].(string), `alt="Lew"`) {
|
||||||
|
t.Fatalf("applied pl = %#v", pl)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, ev := range records.savedEvents() {
|
||||||
|
if ev.ev.Model == p.MorphName() {
|
||||||
|
_, def := findChange(ev.ev.Changes, "content_html", "")
|
||||||
|
_, tr := findChange(ev.ev.Changes, "content_html", "pl")
|
||||||
|
found = def && tr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("no RecordSaved with the default and pl content_html: %#v", records.savedEvents())
|
||||||
|
}
|
||||||
|
|
||||||
|
// L1 WR-05: on a markdown post the alt lands in the markdown, outside
|
||||||
|
// code, and survives the next save of the same content.
|
||||||
|
sample := "```\n\n```\n"
|
||||||
|
md := "Intro\n\n\n\n" + sample
|
||||||
|
mdPost := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Markdown images", "content": md}))
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{map[string]any{"post_id": mdPost, "src": a, "alt": "Grzyb [leśny]"}},
|
||||||
|
})
|
||||||
|
stored := storedPost(t, env, mdPost)
|
||||||
|
if stored.Content != "Intro\n\n![Grzyb \\[leśny\\]]("+a+")\n\n"+sample || !strings.Contains(stored.ContentHTML, `alt="Grzyb [leśny]"`) {
|
||||||
|
t.Fatalf("markdown post after the alt = %q / %q", stored.Content, stored.ContentHTML)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/posts/%d", journalAPIPrefix, mdPost), editor, map[string]any{"content": stored.Content})
|
||||||
|
if again := storedPost(t, env, mdPost); !strings.Contains(again.ContentHTML, `alt="Grzyb [leśny]"`) {
|
||||||
|
t.Fatalf("the next content save lost the alt: %q", again.ContentHTML)
|
||||||
|
}
|
||||||
|
// A reference-style image is not rewritten: 422, nothing written.
|
||||||
|
ref := "![old][r]\n\n[r]: " + a + "\n"
|
||||||
|
refPost := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Reference image", "content": ref}))
|
||||||
|
before := storedPost(t, env, refPost)
|
||||||
|
rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{map[string]any{"post_id": refPost, "src": a, "alt": "new"}},
|
||||||
|
})
|
||||||
|
if !strings.Contains(rec.Body.String(), "changes.0.src") || !strings.Contains(rec.Body.String(), "not an inline image") {
|
||||||
|
t.Fatalf("reference-style answer %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if after := storedPost(t, env, refPost); after.Content != before.Content || after.ContentHTML != before.ContentHTML {
|
||||||
|
t.Fatalf("a refused markdown change wrote: %q / %q", after.Content, after.ContentHTML)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetImageAltBodyShapes pins the body rule of images/alt
|
||||||
|
// (33-REVIEW-GAPFIX WR-02): a locale whose content is blank (a translation
|
||||||
|
// that carries only content_html) gets the alt in content_html alone, and
|
||||||
|
// any other content is read as markdown, also when it starts with a tag.
|
||||||
|
func TestSetImageAltBodyShapes(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
editor := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessOtherPosts)
|
||||||
|
const a = "https://cdn.example.test/a.jpg"
|
||||||
|
path := journalAPIPrefix + "/images/alt"
|
||||||
|
decode := func(rec *httptest.ResponseRecorder) []map[string]any {
|
||||||
|
var out struct {
|
||||||
|
Changes []map[string]any `json:"changes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("alt body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
return out.Changes
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a translation that holds only content_html", func(t *testing.T) {
|
||||||
|
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{
|
||||||
|
"title": "HTML translation", "content": "Intro\n\n",
|
||||||
|
"translations": map[string]any{"pl": map[string]any{"title": "Obrazy", "content_html": `<p><img src="` + a + `"></p>`}},
|
||||||
|
}))
|
||||||
|
changes := decode(env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{
|
||||||
|
map[string]any{"post_id": post, "src": a, "alt": "Grzyb"},
|
||||||
|
map[string]any{"post_id": post, "locale": "pl", "src": a, "alt": "Lew"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
if len(changes) != 2 || changes[1]["locale"] != "pl" || changes[1]["changed"] != true || changes[1]["old_alt"] != nil {
|
||||||
|
t.Fatalf("answer = %#v", changes)
|
||||||
|
}
|
||||||
|
pl := translatedAttrs(t, env, post, "pl")
|
||||||
|
if html, _ := pl["content_html"].(string); !strings.Contains(html, `alt="Lew"`) {
|
||||||
|
t.Fatalf("pl = %#v", pl)
|
||||||
|
}
|
||||||
|
if c, has := pl["content"]; has {
|
||||||
|
if s, _ := c.(string); strings.TrimSpace(s) != "" {
|
||||||
|
t.Fatalf("pl content was written: %#v", pl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := storedPost(t, env, post).Content; got != "Intro\n\n" {
|
||||||
|
t.Fatalf("default content = %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("markdown that starts with a tag", func(t *testing.T) {
|
||||||
|
md := "<p class=\"lead\">Intro</p>\n\n\n\n```html\n<img src=\"" + a + "\">\n```\n"
|
||||||
|
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Tag lead", "content": md}))
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{map[string]any{"post_id": post, "src": a, "alt": "Grzyb"}},
|
||||||
|
})
|
||||||
|
want := "<p class=\"lead\">Intro</p>\n\n\n\n```html\n<img src=\"" + a + "\">\n```\n"
|
||||||
|
stored := storedPost(t, env, post)
|
||||||
|
if stored.Content != want || !strings.Contains(stored.ContentHTML, `alt="Grzyb"`) {
|
||||||
|
t.Fatalf("after the alt = %q / %q", stored.Content, stored.ContentHTML)
|
||||||
|
}
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/posts/%d", journalAPIPrefix, post), editor, map[string]any{"content": stored.Content})
|
||||||
|
if again := storedPost(t, env, post); !strings.Contains(again.ContentHTML, `alt="Grzyb"`) {
|
||||||
|
t.Fatalf("the next content save lost the alt: %q", again.ContentHTML)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("markdown that starts with a tag and holds the image by reference", func(t *testing.T) {
|
||||||
|
ref := "<p>Lead</p>\n\n![old][r]\n\n[r]: " + a + "\n"
|
||||||
|
post := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, journalAPIPrefix+"/posts", editor, map[string]any{"title": "Tag lead reference", "content": ref}))
|
||||||
|
before := storedPost(t, env, post)
|
||||||
|
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, path, editor, map[string]any{
|
||||||
|
"changes": []any{map[string]any{"post_id": post, "src": a, "alt": "new"}},
|
||||||
|
})
|
||||||
|
if !strings.Contains(rec.Body.String(), "changes.0.src") || !strings.Contains(rec.Body.String(), "not an inline image") {
|
||||||
|
t.Fatalf("reference answer %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if after := storedPost(t, env, post); after.Content != before.Content || after.ContentHTML != before.ContentHTML {
|
||||||
|
t.Fatalf("a refused change wrote: %q / %q", after.Content, after.ContentHTML)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlogEditorRequestOptions (J1, UI-SPEC C28): the post and category
|
||||||
|
// editor fields serve headingLevels and genericAnchors per form schema
|
||||||
|
// request, following the H1 setting and the settings' generic link texts.
|
||||||
|
func TestBlogEditorRequestOptions(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessCategories)
|
||||||
|
options := func(t *testing.T, path string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(path+"/schema/form"), token, nil)
|
||||||
|
var body struct {
|
||||||
|
Data struct {
|
||||||
|
Fields []map[string]any `json:"fields"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("schema %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
for _, f := range body.Data.Fields {
|
||||||
|
if f["name"] == "content" {
|
||||||
|
opts, _ := f["typeOptions"].(map[string]any)
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("schema has no content field: %s", rec.Body.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, path := range []string{adminPostsPath, adminCategoriesPath} {
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
opts := options(t, path)
|
||||||
|
anchors, _ := opts["genericAnchors"].([]any)
|
||||||
|
if fmt.Sprint(opts["headingLevels"]) != "[2 3 4]" || len(anchors) == 0 || anchors[0] != "click here" {
|
||||||
|
t.Fatalf("%s options with the setting on = %v", path, opts)
|
||||||
|
}
|
||||||
|
if opts["uploadFolder"] == nil {
|
||||||
|
t.Fatalf("%s lost its fields.yaml options: %v", path, opts)
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, false)
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET generic_anchors = ? WHERE id = 1`, "Tutaj\n\nmore info").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
opts = options(t, path)
|
||||||
|
if fmt.Sprint(opts["headingLevels"]) != "[1 2 3 4]" || fmt.Sprint(opts["genericAnchors"]) != "[tutaj more info]" {
|
||||||
|
t.Fatalf("%s options with the setting off = %v", path, opts)
|
||||||
|
}
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET generic_anchors = NULL WHERE id = 1`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setH1ToH2(t, env, true)
|
||||||
|
}
|
||||||
146
image_variants_cli_test.go
Normal file
146
image_variants_cli_test.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
_ "gocloud.dev/blob/fileblob"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageVariantsCommandCLI pins journal:image-variants in the
|
||||||
|
// generated CLI, where no database and no bucket are published: the
|
||||||
|
// command opens database.dsn and storage.uploads.bucket_url itself and
|
||||||
|
// writes the missing variants; without a DSN it names the setting.
|
||||||
|
func TestImageVariantsCommandCLI(t *testing.T) {
|
||||||
|
if testShort() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
var name string
|
||||||
|
if err := env.gdb.Raw(`SELECT current_database()`).Scan(&name).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u.Path = "/" + name
|
||||||
|
|
||||||
|
post := models.Post{Title: "Cli", Slug: "cli", Content: "<p>x</p>", Published: true}
|
||||||
|
if err := env.gdb.Create(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
disk := "cli123def456ghi789jkl0.jpg"
|
||||||
|
key := attach.BlobKey(disk)
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(key)), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, key), jpegOf(t, 900, 600), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := attach.File{DiskName: disk, FileName: "cli.jpg", ContentType: "image/jpeg", Field: "featured_images", AttachmentType: post.MorphName(), AttachmentID: strconv.FormatUint(uint64(post.ID), 10)}
|
||||||
|
if err := env.gdb.Create(&file).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func(environ ...string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
var buf strings.Builder
|
||||||
|
root, err := bonfire.NewRootIO("summer", []bonfire.Command{console.ImageVariants(app)}, strings.NewReader(""), &buf, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return bonfire.Execute(context.Background(), root, []string{"journal:image-variants"}), buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if code, out := run(); code == 0 || !strings.Contains(out, "database is not configured (set SUMMER_DATABASE__DSN)") {
|
||||||
|
t.Fatalf("without a DSN: %d\n%s", code, out)
|
||||||
|
}
|
||||||
|
code, out := run("SUMMER_DATABASE__DSN="+u.String(), "SUMMER_STORAGE__UPLOADS__BUCKET_URL=file://"+dir,
|
||||||
|
"SUMMER_APP__KEY="+base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("k"), 32)))
|
||||||
|
if code != 0 || !strings.Contains(out, "Checked 1 featured images, wrote 2 variants") {
|
||||||
|
t.Fatalf("journal:image-variants from the CLI: %d\n%s", code, out)
|
||||||
|
}
|
||||||
|
var variants []models.ImageVariant
|
||||||
|
if err := env.gdb.Where("file_id = ?", file.ID).Order("width ASC").Find(&variants).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(variants) != 2 || variants[0].Width != 480 || variants[1].Width != 800 {
|
||||||
|
t.Fatalf("variants = %+v, want 480 and 800 (1200 is wider than the original)", variants)
|
||||||
|
}
|
||||||
|
bucket, err := blob.OpenBucket(t.Context(), "file://"+dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer bucket.Close()
|
||||||
|
if ok, err := bucket.Exists(t.Context(), variants[1].WebPKey); err != nil || !ok {
|
||||||
|
t.Fatalf("the CLI's WebP %s is not in the bucket: %v", variants[1].WebPKey, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFixHeadingsCommandCLI pins L1 WR-01 (D-50): journal:fix-headings in
|
||||||
|
// the generated CLI, where no database is published: without a DSN it
|
||||||
|
// names the setting, with one it opens database.dsn and prints the dry run.
|
||||||
|
func TestFixHeadingsCommandCLI(t *testing.T) {
|
||||||
|
if testShort() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
var name string
|
||||||
|
if err := env.gdb.Raw(`SELECT current_database()`).Scan(&name).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u, err := url.Parse(adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u.Path = "/" + name
|
||||||
|
post := models.Post{Title: "Cli heading", Slug: "cli-heading", Content: "<h1>A</h1><p>x</p>", ContentHTML: "<h1>A</h1><p>x</p>"}
|
||||||
|
if err := env.gdb.Create(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run := func(environ ...string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app := backpack.New(cfg)
|
||||||
|
var buf strings.Builder
|
||||||
|
root, err := bonfire.NewRootIO("summer", []bonfire.Command{console.FixHeadings(app)}, strings.NewReader(""), &buf, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return bonfire.Execute(context.Background(), root, []string{"journal:fix-headings"}), buf.String()
|
||||||
|
}
|
||||||
|
if code, out := run(); code == 0 || !strings.Contains(out, "database is not configured (set SUMMER_DATABASE__DSN)") {
|
||||||
|
t.Fatalf("without a DSN: %d\n%s", code, out)
|
||||||
|
}
|
||||||
|
code, out := run("SUMMER_DATABASE__DSN="+u.String(), "SUMMER_APP__KEY="+base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("k"), 32)))
|
||||||
|
if code != 0 || !strings.Contains(out, "Would convert 1 H1 headings in 1 posts and 0 categories. Run with --apply to save.") {
|
||||||
|
t.Fatalf("journal:fix-headings from the CLI: %d\n%s", code, out)
|
||||||
|
}
|
||||||
|
if p := storedPost(t, env, post.ID); p.Content != "<h1>A</h1><p>x</p>" {
|
||||||
|
t.Fatalf("the dry run wrote %q", p.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
jobs.go
Normal file
42
jobs.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package journal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/conga"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ pact.HasJobs = (*Plugin)(nil)
|
||||||
|
|
||||||
|
// Jobs registers the image variants job (golem15.journal.image_variants,
|
||||||
|
// D-85): thumbnails and WebP copies of featured images, outside the upload
|
||||||
|
// request, three attempts.
|
||||||
|
func (p *Plugin) Jobs() []pact.Job {
|
||||||
|
return []pact.Job{conga.Job(p.imageVariants, conga.MaxAttempts(3))}
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageVariants runs one image variants job.
|
||||||
|
func (p *Plugin) imageVariants(ctx context.Context, args classes.ImageVariantsArgs) error {
|
||||||
|
if p.app == nil {
|
||||||
|
return errors.New("journal: app is not booted")
|
||||||
|
}
|
||||||
|
db, ok := p.app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || db == nil {
|
||||||
|
return errors.New("journal: database is not configured")
|
||||||
|
}
|
||||||
|
bucket, ok := p.app.Lookup[*blob.Bucket]()
|
||||||
|
if !ok || bucket == nil {
|
||||||
|
return errors.New("journal: the uploads bucket is not configured")
|
||||||
|
}
|
||||||
|
res, err := classes.GenerateImageVariants(ctx, db, bucket, classes.ImageWidths(p.app.Config), args.FileIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pluginLog.Info("journal: image variants", "files", res.Files, "written", res.Written, "skipped", res.Skipped)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -293,8 +293,16 @@ func TestJournalAuthorPosts(t *testing.T) {
|
|||||||
t.Fatalf("author row keys = %v, want /posts row keys %v", got, want)
|
t.Fatalf("author row keys = %v, want /posts row keys %v", got, want)
|
||||||
}
|
}
|
||||||
author, _ := page.Data[0]["author"].(map[string]any)
|
author, _ := page.Data[0]["author"].(map[string]any)
|
||||||
if author == nil || author["login"] != "jdoe" {
|
if author == nil || author["login"] != "jdoe" || author["slug"] != "jane-doe" {
|
||||||
t.Fatalf("row author = %v, want login jdoe", page.Data[0]["author"])
|
t.Fatalf("row author = %v, want login jdoe and slug jane-doe", page.Data[0]["author"])
|
||||||
|
}
|
||||||
|
// Without an author slug, the row's slug is the login.
|
||||||
|
for _, row := range all.Data {
|
||||||
|
if row["slug"] == "b1" {
|
||||||
|
if a, _ := row["author"].(map[string]any); a == nil || a["slug"] != "bob" {
|
||||||
|
t.Fatalf("b1 author = %v, want slug bob", row["author"])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
page = getAuthorPosts(t, env, base+"?per_page=2&page=2", "")
|
page = getAuthorPosts(t, env, base+"?per_page=2&page=2", "")
|
||||||
|
|||||||
272
journal_descriptor_fields_test.go
Normal file
272
journal_descriptor_fields_test.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"reflect"
|
||||||
|
"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/lagoon/attach"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedLocales enables en (default) and pl.
|
||||||
|
func seedLocales(t *testing.T, gdb *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAuthor inserts a backend user with an optional author slug.
|
||||||
|
func seedAuthor(t *testing.T, gdb *gorm.DB, login, first, last, slug string) uint {
|
||||||
|
t.Helper()
|
||||||
|
var userID uint
|
||||||
|
if err := gdb.Raw(`INSERT INTO backend_users (login, email, password, first_name, last_name, is_activated, is_superuser, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'x', ?, ?, TRUE, FALSE, NOW(), NOW()) RETURNING id`, login, login+"@example.test", first, last).Scan(&userID).Error; err != nil || userID == 0 {
|
||||||
|
t.Fatalf("backend user %s: %d %v", login, userID, err)
|
||||||
|
}
|
||||||
|
if slug != "" {
|
||||||
|
if err := gdb.Exec(`UPDATE backend_users SET golem15_bloghub_author_slug = ? WHERE id = ?`, slug, userID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func describe(t *testing.T, reg *signpost.Registry, key, recordID string) *pact.ContentRecord {
|
||||||
|
t.Helper()
|
||||||
|
rec, err := reg.Describe(context.Background(), key, recordID, "")
|
||||||
|
if err != nil || rec == nil {
|
||||||
|
t.Fatalf("Describe %s %s = %+v, %v", key, recordID, rec, err)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalDescriptorFields (D-93, D-43, D-96, P33-06): posts describe
|
||||||
|
// their publication, categories in order, authors, featured and body
|
||||||
|
// images, YouTube embeds, heading outline, top heading level, kind and
|
||||||
|
// tools; categories their parent, pillar outline, top heading and tools;
|
||||||
|
// tags their kind. TopHeading follows the H1 setting.
|
||||||
|
func TestJournalDescriptorFields(t *testing.T) {
|
||||||
|
requirePostgres(t)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
files map[string]string
|
||||||
|
authorPages bool
|
||||||
|
}{
|
||||||
|
{"author pages on", map[string]string{}, true},
|
||||||
|
{"author pages off", map[string]string{"golem15.yaml": "journal:\n author_pages: false\n"}, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, tc.files)
|
||||||
|
seedLocales(t, gdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
alpha := menuCategory(t, gdb, "Alpha", "alpha", nil, 1, 4)
|
||||||
|
beta := menuCategory(t, gdb, "Beta", "beta", nil, 5, 6)
|
||||||
|
child := menuCategory(t, gdb, "Child", "child", &alpha.ID, 2, 3)
|
||||||
|
pillar := "<h1>Pillar</h1><p>x</p><h2>Part</h2>"
|
||||||
|
if err := gdb.Model(&models.Category{}).Where("id = ?", alpha.ID).Update("content", pillar).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
author := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada-l")
|
||||||
|
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
body := `<h1>Top</h1><p>x</p><h2> Sub heading </h2>` +
|
||||||
|
`<p><img src="/a.jpg" alt="A" width="640" height="480"><img src="/d.jpg" alt=""></p>` +
|
||||||
|
`<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure><h3><a href="/x">Linked</a></h3>`
|
||||||
|
post := models.Post{Title: "Hello", Slug: "hello", Content: body, ContentHTML: body, Published: true, PublishedAt: &past, UserID: &author}
|
||||||
|
if err := gdb.Create(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
attachCategory(t, gdb, post, beta)
|
||||||
|
attachCategory(t, gdb, post, alpha)
|
||||||
|
for field, value := range map[string]string{"slug": "witaj", "title": "Witaj", "content_html": `<h2>PL</h2><img src="/p.jpg" alt="P">`} {
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &post, field, "pl", value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cover := attach.File{DiskName: "cover.jpg", FileName: "cover.jpg", ContentType: "image/jpeg",
|
||||||
|
AttachmentType: post.MorphName(), AttachmentID: id(post.ID), Field: "featured_images"}
|
||||||
|
if err := gdb.Create(&cover).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tag := models.Tag{Name: "Mushrooms", Slug: "mushrooms"}
|
||||||
|
if err := gdb.Create(&tag).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
typ, ok := reg.ContentType(classes.MenuTypePost)
|
||||||
|
wantTools := map[string]string{
|
||||||
|
pact.ContentToolEdit: "journal_update_post",
|
||||||
|
pact.ContentToolHeadings: "journal_fix_headings",
|
||||||
|
pact.ContentToolImageAlt: "journal_set_image_alt",
|
||||||
|
}
|
||||||
|
if !ok || typ.Content.Kind != pact.ContentKindArticle || !reflect.DeepEqual(typ.Content.Tools, wantTools) {
|
||||||
|
t.Fatalf("post descriptor kind %q tools %v", typ.Content.Kind, typ.Content.Tools)
|
||||||
|
}
|
||||||
|
rec := describe(t, reg, classes.MenuTypePost, id(post.ID))
|
||||||
|
if rec.PublishedAt == nil || !rec.PublishedAt.Equal(past) {
|
||||||
|
t.Fatalf("PublishedAt = %v", rec.PublishedAt)
|
||||||
|
}
|
||||||
|
wantParents := []pact.ContentRef{{Type: classes.MenuTypeCategory, ID: id(alpha.ID)}, {Type: classes.MenuTypeCategory, ID: id(beta.ID)}}
|
||||||
|
if !reflect.DeepEqual(rec.Parents, wantParents) {
|
||||||
|
t.Fatalf("Parents = %+v, want %+v", rec.Parents, wantParents)
|
||||||
|
}
|
||||||
|
wantRef := pact.ContentRef{}
|
||||||
|
if tc.authorPages {
|
||||||
|
wantRef = pact.ContentRef{Type: classes.MenuTypeAuthor, ID: id(author)}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(rec.Authors, []pact.ContentPerson{{Name: "Ada Lovelace", Ref: wantRef}}) {
|
||||||
|
t.Fatalf("Authors = %+v", rec.Authors)
|
||||||
|
}
|
||||||
|
if rec.TopHeading != 2 {
|
||||||
|
t.Fatalf("TopHeading = %d, want 2 while the setting is on", rec.TopHeading)
|
||||||
|
}
|
||||||
|
en, pl := rec.Locales[0], rec.Locales[1]
|
||||||
|
wantImages := []pact.ContentImage{
|
||||||
|
{URL: cover.URL(), Alt: "Hello"},
|
||||||
|
{URL: "/a.jpg", Alt: "A", Width: 640, Height: 480},
|
||||||
|
{URL: "/d.jpg", Alt: "", Decorative: true},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(en.Images, wantImages) {
|
||||||
|
t.Fatalf("en Images = %+v, want %+v", en.Images, wantImages)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(pl.Images, []pact.ContentImage{{URL: cover.URL(), Alt: "Witaj"}, {URL: "/p.jpg", Alt: "P"}}) {
|
||||||
|
t.Fatalf("pl Images = %+v", pl.Images)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(en.Videos, []pact.ContentVideo{{Provider: "youtube", ID: "dQw4w9WgXcQ"}}) || len(pl.Videos) != 0 {
|
||||||
|
t.Fatalf("Videos = %+v / %+v", en.Videos, pl.Videos)
|
||||||
|
}
|
||||||
|
wantHeadings := []pact.ContentHeading{{Level: 1, Text: "Top"}, {Level: 2, Text: "Sub heading"}, {Level: 3, Text: "Linked"}}
|
||||||
|
if !reflect.DeepEqual(en.Headings, wantHeadings) || !reflect.DeepEqual(pl.Headings, []pact.ContentHeading{{Level: 2, Text: "PL"}}) {
|
||||||
|
t.Fatalf("Headings = %+v / %+v", en.Headings, pl.Headings)
|
||||||
|
}
|
||||||
|
|
||||||
|
catType, _ := reg.ContentType(classes.MenuTypeCategory)
|
||||||
|
if catType.Content.Kind != pact.ContentKindCollection || !reflect.DeepEqual(catType.Content.Tools, map[string]string{pact.ContentToolHeadings: "journal_fix_headings"}) {
|
||||||
|
t.Fatalf("category descriptor kind %q tools %v", catType.Content.Kind, catType.Content.Tools)
|
||||||
|
}
|
||||||
|
kid := describe(t, reg, classes.MenuTypeCategory, id(child.ID))
|
||||||
|
if !reflect.DeepEqual(kid.Parents, []pact.ContentRef{{Type: classes.MenuTypeCategory, ID: id(alpha.ID)}}) || len(kid.Locales[0].Headings) != 0 {
|
||||||
|
t.Fatalf("child category = %+v", kid)
|
||||||
|
}
|
||||||
|
top := describe(t, reg, classes.MenuTypeCategory, id(alpha.ID))
|
||||||
|
if len(top.Parents) != 0 || top.TopHeading != 2 ||
|
||||||
|
!reflect.DeepEqual(top.Locales[0].Headings, []pact.ContentHeading{{Level: 1, Text: "Pillar"}, {Level: 2, Text: "Part"}}) {
|
||||||
|
t.Fatalf("pillar category = %+v", top)
|
||||||
|
}
|
||||||
|
tagType, _ := reg.ContentType(classes.MenuTypeTag)
|
||||||
|
if tagType.Content.Kind != pact.ContentKindCollection {
|
||||||
|
t.Fatalf("tag kind %q", tagType.Content.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gdb.Exec(`UPDATE golem15_journal_settings SET content_h1_to_h2 = FALSE WHERE id = 1`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := describe(t, reg, classes.MenuTypePost, id(post.ID)).TopHeading; got != 0 {
|
||||||
|
t.Fatalf("post TopHeading with the setting off = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if got := describe(t, reg, classes.MenuTypeCategory, id(alpha.ID)).TopHeading; got != 0 {
|
||||||
|
t.Fatalf("category TopHeading with the setting off = %d, want 0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalAuthorType (D-43, D-34, P33-07): journal-author lists the
|
||||||
|
// authors with a published post, describes them as profiles at
|
||||||
|
// /blog/author/:author (the author slug, else the login) per locale, matches
|
||||||
|
// exactly on the pattern, and with golem15.journal.author_pages false
|
||||||
|
// describes empty URLs and never matches.
|
||||||
|
func TestJournalAuthorType(t *testing.T) {
|
||||||
|
requirePostgres(t)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
files map[string]string
|
||||||
|
on bool
|
||||||
|
}{
|
||||||
|
{"default", map[string]string{}, true},
|
||||||
|
{"off", map[string]string{"golem15.yaml": "journal:\n author_pages: false\n"}, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, gdb, reg := bootMenuItemTypes(t, tc.files)
|
||||||
|
seedLocales(t, gdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||||
|
ada := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada-l")
|
||||||
|
bob := seedAuthor(t, gdb, "bob", "Bob", "", "")
|
||||||
|
seedAuthor(t, gdb, "carl", "Carl", "Idle", "carl")
|
||||||
|
for _, p := range []models.Post{
|
||||||
|
{Title: "By Ada", Slug: "by-ada", Content: "x", Published: true, PublishedAt: &past, UserID: &ada},
|
||||||
|
{Title: "By Bob", Slug: "by-bob", Content: "x", Published: true, PublishedAt: &past, UserID: &bob},
|
||||||
|
{Title: "Carl draft", Slug: "carl-draft", Content: "x", Published: false, PublishedAt: &past},
|
||||||
|
} {
|
||||||
|
p := p
|
||||||
|
if err := gdb.Create(&p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Ada's post is translated into pl, Bob's is not (P33-64).
|
||||||
|
if p.UserID != nil && *p.UserID == ada {
|
||||||
|
if err := translate.SetTranslated(ctx, gdb, &p, "title", "pl", "Od Ady"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typ, ok := reg.ContentType(classes.MenuTypeAuthor)
|
||||||
|
if !ok || typ.Content.Kind != pact.ContentKindProfile || typ.Content.Controller != "" || typ.Content.Model != "Backend\\Models\\User" {
|
||||||
|
t.Fatalf("journal-author descriptor = %+v", typ.Content)
|
||||||
|
}
|
||||||
|
page, err := reg.Records(ctx, classes.MenuTypeAuthor, "", 0, "")
|
||||||
|
if err != nil || len(page.Records) != 2 || page.Records[0].ID != id(ada) || page.Records[1].ID != id(bob) {
|
||||||
|
t.Fatalf("Records = %+v, %v; want Ada and Bob only", page, err)
|
||||||
|
}
|
||||||
|
rec := describe(t, reg, classes.MenuTypeAuthor, id(ada))
|
||||||
|
want := "en= pl="
|
||||||
|
if tc.on {
|
||||||
|
want = "en=/blog/author/ada-l pl=/pl/blog/author/ada-l"
|
||||||
|
}
|
||||||
|
if localeURLs(rec) != want || rec.Locales[0].Title != "Ada Lovelace" || !rec.Published {
|
||||||
|
t.Fatalf("Describe Ada = %+v; want URLs %s", rec, want)
|
||||||
|
}
|
||||||
|
if tc.on {
|
||||||
|
// Bob has no post with content in pl: no pl address.
|
||||||
|
if got := localeURLs(describe(t, reg, classes.MenuTypeAuthor, id(bob))); got != "en=/blog/author/bob pl=" {
|
||||||
|
t.Fatalf("login fallback URLs = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/blog/author/ada-l", "/pl/blog/author/ada-l"} {
|
||||||
|
key, res, err := reg.MatchPath(ctx, path, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tc.on && (key != classes.MenuTypeAuthor || res == nil || !res.Exact || res.ID != id(ada)) {
|
||||||
|
t.Fatalf("MatchPath(%s) = %q %+v", path, key, res)
|
||||||
|
}
|
||||||
|
if !tc.on && res != nil && key == classes.MenuTypeAuthor {
|
||||||
|
t.Fatalf("MatchPath(%s) matched with author pages off: %+v", path, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key, res, err := reg.MatchPath(ctx, "/blog/author/carl", ""); err != nil || (res != nil && key == classes.MenuTypeAuthor) {
|
||||||
|
t.Fatalf("an author without a published post matched: %q %+v %v", key, res, err)
|
||||||
|
}
|
||||||
|
item, err := reg.Resolve(ctx, classes.MenuTypeAuthor, id(ada), "", false, "")
|
||||||
|
if err != nil || (item != nil) != tc.on || (tc.on && item.URL != "/blog/author/ada-l") {
|
||||||
|
t.Fatalf("Resolve = %+v, %v", item, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,11 @@ journal:
|
|||||||
rss_image_url_comment: URL to an image/logo displayed in feed readers.
|
rss_image_url_comment: URL to an image/logo displayed in feed readers.
|
||||||
rss_posts_per_feed_label: Posts per feed
|
rss_posts_per_feed_label: Posts per feed
|
||||||
rss_posts_per_feed_comment: Default number of posts in the RSS feed. Can be overridden per component.
|
rss_posts_per_feed_comment: Default number of posts in the RSS feed. Can be overridden per component.
|
||||||
|
tab_headings: Headings and links
|
||||||
|
h1_to_h2_label: Convert H1 to H2 in post and category content
|
||||||
|
h1_to_h2_comment: 'Turn it off only when the page template does not show the post title or the category name as the H1. On: the editor offers H2–H4, and every H1 saved through the admin, the API, MCP or an import becomes an H2. Off: content keeps its H1 headings, and the site shows them unchanged.'
|
||||||
|
generic_anchors_label: Generic link texts
|
||||||
|
generic_anchors_comment: One per line. The editor warns when the whole text of a link is one of these.
|
||||||
posts:
|
posts:
|
||||||
list_title: Manage the journal posts
|
list_title: Manage the journal posts
|
||||||
filter_category: Category
|
filter_category: Category
|
||||||
@@ -137,6 +142,8 @@ 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
|
||||||
|
journal_author: Journal author
|
||||||
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.
|
||||||
@@ -156,3 +163,9 @@ editor:
|
|||||||
one: ":count HTML comment was removed"
|
one: ":count HTML comment was removed"
|
||||||
other: ":count HTML comments were removed"
|
other: ":count HTML comments were removed"
|
||||||
markup_changed: The HTML filter changed some markup.
|
markup_changed: The HTML filter changed some markup.
|
||||||
|
h1_converted:
|
||||||
|
one: ":count H1 heading was converted to H2, because the page shows the post title as the H1."
|
||||||
|
other: ":count H1 headings were converted to H2, because the page shows the post title as the H1."
|
||||||
|
h1_converted_category:
|
||||||
|
one: ":count H1 heading was converted to H2, because the page shows the category name as the H1."
|
||||||
|
other: ":count H1 headings were converted to H2, because the page shows the category name as the H1."
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ journal:
|
|||||||
search_excerpt_weight_comment: Waga trafności dla dopasowań w zajawce posta (domyślnie 3).
|
search_excerpt_weight_comment: Waga trafności dla dopasowań w zajawce posta (domyślnie 3).
|
||||||
search_content_weight_label: Waga treści
|
search_content_weight_label: Waga treści
|
||||||
search_content_weight_comment: Waga trafności dla dopasowań w treści posta (domyślnie 1).
|
search_content_weight_comment: Waga trafności dla dopasowań w treści posta (domyślnie 1).
|
||||||
|
tab_headings: Nagłówki i linki
|
||||||
|
h1_to_h2_label: Zamieniaj H1 na H2 w treści wpisów i kategorii
|
||||||
|
h1_to_h2_comment: 'Wyłącz tylko wtedy, gdy szablon strony nie wyświetla tytułu wpisu ani nazwy kategorii jako H1. Włączone: edytor oferuje H2–H4, a każdy H1 zapisany przez panel, API, MCP lub import staje się H2. Wyłączone: treść zachowuje nagłówki H1, a witryna wyświetla je bez zmian.'
|
||||||
|
generic_anchors_label: Ogólnikowe teksty linków
|
||||||
|
generic_anchors_comment: Jeden w wierszu. Edytor ostrzega, gdy cały tekst linku jest jednym z nich.
|
||||||
posts:
|
posts:
|
||||||
list_title: Zarządzaj postami
|
list_title: Zarządzaj postami
|
||||||
filter_category: Kategoria
|
filter_category: Kategoria
|
||||||
@@ -89,6 +94,8 @@ 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
|
||||||
|
journal_author: Autor (Journal)
|
||||||
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.
|
||||||
@@ -114,3 +121,13 @@ editor:
|
|||||||
many: "Usunięto :count komentarzy HTML"
|
many: "Usunięto :count komentarzy HTML"
|
||||||
other: "Usunięto :count komentarza HTML"
|
other: "Usunięto :count komentarza HTML"
|
||||||
markup_changed: Filtr HTML zmienił część znaczników.
|
markup_changed: Filtr HTML zmienił część znaczników.
|
||||||
|
h1_converted:
|
||||||
|
one: "Zamieniono :count nagłówek H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
|
||||||
|
few: "Zamieniono :count nagłówki H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
|
||||||
|
many: "Zamieniono :count nagłówków H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
|
||||||
|
other: "Zamieniono :count nagłówka H1 na H2, bo strona wyświetla tytuł wpisu jako H1."
|
||||||
|
h1_converted_category:
|
||||||
|
one: "Zamieniono :count nagłówek H1 na H2, bo strona wyświetla nazwę kategorii jako H1."
|
||||||
|
few: "Zamieniono :count nagłówki H1 na H2, bo strona wyświetla nazwę kategorii jako H1."
|
||||||
|
many: "Zamieniono :count nagłówków H1 na H2, bo strona wyświetla nazwę kategorii jako H1."
|
||||||
|
other: "Zamieniono :count nagłówka H1 na H2, bo strona wyświetla nazwę kategorii jako H1."
|
||||||
|
|||||||
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, ".")
|
||||||
|
}
|
||||||
93
mcp.go
93
mcp.go
@@ -69,12 +69,58 @@ type mcpDeletePostInput struct {
|
|||||||
ID uint `json:"id" path:"id" jsonschema:"the post id"`
|
ID uint `json:"id" path:"id" jsonschema:"the post id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mcpManageSettings is the permission of the settings and heading tools.
|
||||||
|
const mcpManageSettings = "golem15.journal.manage_settings"
|
||||||
|
|
||||||
|
// mcpUpdateSettingsInput is PUT /_journal/api/v1/settings: only the fields
|
||||||
|
// sent are changed.
|
||||||
|
type mcpUpdateSettingsInput struct {
|
||||||
|
ContentH1ToH2 *bool `json:"content_h1_to_h2,omitempty" jsonschema:"convert H1 to H2 in post and category content on every save; the editor then offers H2 to H4 (switching it never rewrites stored content, journal_fix_headings does)"`
|
||||||
|
GenericAnchors *string `json:"generic_anchors,omitempty" jsonschema:"the link texts the editor warns about, one per line"`
|
||||||
|
ShowAllPosts *bool `json:"show_all_posts,omitempty" jsonschema:"show drafts to administrators on the site"`
|
||||||
|
UseRichEditor *bool `json:"use_rich_editor,omitempty" jsonschema:"use the rich editor for post content"`
|
||||||
|
SearchUseTypesense *bool `json:"search_use_typesense,omitempty" jsonschema:"search posts with the configured search engine"`
|
||||||
|
SearchTitleWeight *int `json:"search_title_weight,omitempty" jsonschema:"relevance weight of the title, 0 to 100"`
|
||||||
|
SearchExcerptWeight *int `json:"search_excerpt_weight,omitempty" jsonschema:"relevance weight of the excerpt, 0 to 100"`
|
||||||
|
SearchContentWeight *int `json:"search_content_weight,omitempty" jsonschema:"relevance weight of the content, 0 to 100"`
|
||||||
|
RSSEnabled *bool `json:"rss_enabled,omitempty" jsonschema:"serve the RSS feed"`
|
||||||
|
RSSIncludeContent *bool `json:"rss_include_content,omitempty" jsonschema:"include the full content in the feed"`
|
||||||
|
RSSTitle *string `json:"rss_title,omitempty" jsonschema:"the feed title"`
|
||||||
|
RSSDescription *string `json:"rss_description,omitempty" jsonschema:"the feed description"`
|
||||||
|
RSSLanguage *string `json:"rss_language,omitempty" jsonschema:"the feed language code, for example en-us"`
|
||||||
|
RSSCopyright *string `json:"rss_copyright,omitempty" jsonschema:"the feed copyright notice"`
|
||||||
|
RSSImageURL *string `json:"rss_image_url,omitempty" jsonschema:"the feed image URL"`
|
||||||
|
RSSPostsPerFeed *int `json:"rss_posts_per_feed,omitempty" jsonschema:"posts in the feed, 1 to 100"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpFixHeadingsInput is POST /_journal/api/v1/fix-headings.
|
||||||
|
type mcpFixHeadingsInput struct {
|
||||||
|
DryRun *bool `json:"dry_run,omitempty" jsonschema:"only count the H1 headings (the default); false converts and saves them"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpImageAltChange is one alt change of journal_set_image_alt.
|
||||||
|
type mcpImageAltChange struct {
|
||||||
|
PostID uint `json:"post_id" jsonschema:"the post id"`
|
||||||
|
Locale string `json:"locale,omitempty" jsonschema:"the content locale; the default locale when omitted"`
|
||||||
|
Src string `json:"src" jsonschema:"the src of the image in the post content, exactly as stored"`
|
||||||
|
Alt string `json:"alt" jsonschema:"the alt text; empty marks the image as decorative"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpSetImageAltInput is POST /_journal/api/v1/images/alt.
|
||||||
|
type mcpSetImageAltInput struct {
|
||||||
|
DryRun *bool `json:"dry_run,omitempty" jsonschema:"show the changes without saving them"`
|
||||||
|
Changes []mcpImageAltChange `json:"changes" jsonschema:"the alt changes; all are refused when one names an image that is not in its post"`
|
||||||
|
}
|
||||||
|
|
||||||
// MCP declares the Journal's MCP tools and prompt (D-15, D-16): the post,
|
// 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
|
// 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
|
// linked to the MCP user and listed only when that account has
|
||||||
// golem15.journal.access_posts. The routes enforce their own permissions
|
// golem15.journal.access_posts; the settings, fix_headings and
|
||||||
// (publishing, other authors' posts) on every call. Media and featured-image
|
// set_image_alt tools of the content fixes (D-10, D-85) are listed with
|
||||||
// uploads (multipart) are not tools.
|
// golem15.journal.manage_settings (set_image_alt with 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 {
|
func (p *Plugin) MCP() pact.MCP {
|
||||||
destructive := true
|
destructive := true
|
||||||
return pact.MCP{
|
return pact.MCP{
|
||||||
@@ -150,6 +196,47 @@ func (p *Plugin) MCP() pact.MCP {
|
|||||||
Permissions: []string{mcpAccessPosts},
|
Permissions: []string{mcpAccessPosts},
|
||||||
Destructive: &destructive,
|
Destructive: &destructive,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "get_settings",
|
||||||
|
Title: "Get Journal settings",
|
||||||
|
Description: "Shows the Journal settings: the H1 to H2 conversion, the generic link texts, search and RSS.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/_journal/api/v1/settings",
|
||||||
|
Input: struct{}{},
|
||||||
|
Permissions: []string{mcpManageSettings},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update_settings",
|
||||||
|
Title: "Update Journal settings",
|
||||||
|
Description: "Changes the Journal settings sent, such as the H1 to H2 conversion or the generic link texts.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodPut,
|
||||||
|
Path: "/_journal/api/v1/settings",
|
||||||
|
Input: mcpUpdateSettingsInput{},
|
||||||
|
Permissions: []string{mcpManageSettings},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fix_headings",
|
||||||
|
Title: "Fix stored H1 headings",
|
||||||
|
Description: "Converts H1 headings to H2 in stored post and category content, like journal:fix-headings; a dry run unless dry_run is false. Refused while the H1 setting is off.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/_journal/api/v1/fix-headings",
|
||||||
|
Input: mcpFixHeadingsInput{},
|
||||||
|
Permissions: []string{mcpManageSettings},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "set_image_alt",
|
||||||
|
Title: "Set image alt texts",
|
||||||
|
Description: "Sets the alt text of images in post content, several at once; an empty alt marks an image as decorative. In a markdown post the alt is written into the markdown source (an inline image , or a raw img tag outside code); an image the markdown does not hold that way (a reference-style image) is refused with changes.N.src: edit that post instead.",
|
||||||
|
Guard: mcpGuard,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/_journal/api/v1/images/alt",
|
||||||
|
Input: mcpSetImageAltInput{},
|
||||||
|
Permissions: []string{mcpAccessPosts},
|
||||||
|
Destructive: &destructive,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Prompts: []pact.MCPPrompt{{
|
Prompts: []pact.MCPPrompt{{
|
||||||
Name: "draft_post",
|
Name: "draft_post",
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestJournalMCPSmoke pins the Journal's MCP declaration (P-17, D-15, D-16):
|
// 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,
|
// exactly the seven post, category and tag tools, the four settings and
|
||||||
// each with Guard backend and golem15.journal.access_posts, delete marked
|
// content-fix tools (33-02) and the draft_post prompt, each with Guard
|
||||||
|
// backend and golem15.journal.access_posts (manage_settings for the
|
||||||
|
// settings and fix_headings tools), delete and set_image_alt marked
|
||||||
// destructive, and every tool bound to a route the plugin registers (the
|
// destructive, and every tool bound to a route the plugin registers (the
|
||||||
// same method and pattern in a router built from the booted plugins).
|
// same method and pattern in a router built from the booted plugins).
|
||||||
// Runs under -short (no database).
|
// Runs under -short (no database).
|
||||||
@@ -26,17 +28,25 @@ func TestJournalMCPSmoke(t *testing.T) {
|
|||||||
var _ pact.HasMCP = (*journal.Plugin)(nil)
|
var _ pact.HasMCP = (*journal.Plugin)(nil)
|
||||||
decl := (&journal.Plugin{}).MCP()
|
decl := (&journal.Plugin{}).MCP()
|
||||||
|
|
||||||
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post"}
|
wantTools := []string{"list_posts", "show_post", "list_categories", "list_tags", "create_post", "update_post", "delete_post",
|
||||||
|
"get_settings", "update_settings", "fix_headings", "set_image_alt"}
|
||||||
|
// The settings and heading tools need manage_settings (33-02, D-10).
|
||||||
|
manageSettings := map[string]bool{"get_settings": true, "update_settings": true, "fix_headings": true}
|
||||||
|
destructive := map[string]bool{"delete_post": true, "set_image_alt": true}
|
||||||
var names []string
|
var names []string
|
||||||
for _, tool := range decl.Tools {
|
for _, tool := range decl.Tools {
|
||||||
names = append(names, tool.Name)
|
names = append(names, tool.Name)
|
||||||
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, []string{"golem15.journal.access_posts"}) {
|
want := []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 manageSettings[tool.Name] {
|
||||||
|
want = []string{"golem15.journal.manage_settings"}
|
||||||
|
}
|
||||||
|
if tool.Guard != "backend" || !slices.Equal(tool.Permissions, want) {
|
||||||
|
t.Errorf("tool %s guard %q permissions %v, want backend and %v", tool.Name, tool.Guard, tool.Permissions, want)
|
||||||
}
|
}
|
||||||
if tool.Title == "" || tool.Description == "" || tool.Input == nil {
|
if tool.Title == "" || tool.Description == "" || tool.Input == nil {
|
||||||
t.Errorf("tool %s lacks a title, description or input", tool.Name)
|
t.Errorf("tool %s lacks a title, description or input", tool.Name)
|
||||||
}
|
}
|
||||||
if (tool.Destructive != nil && *tool.Destructive) != (tool.Name == "delete_post") {
|
if (tool.Destructive != nil && *tool.Destructive) != destructive[tool.Name] {
|
||||||
t.Errorf("tool %s destructive = %v", tool.Name, tool.Destructive)
|
t.Errorf("tool %s destructive = %v", tool.Name, tool.Destructive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
mcp_test.go
16
mcp_test.go
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
journal "git.golem15.com/golem15/sm-journal-plugin"
|
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/compass"
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
"git.golem15.com/golem15/summercms/modules/party"
|
"git.golem15.com/golem15/summercms/modules/party"
|
||||||
@@ -24,6 +25,13 @@ import (
|
|||||||
var (
|
var (
|
||||||
postBodyKeys = []string{"categories", "content", "excerpt", "is_pinned", "metadata", "published", "published_at", "slug", "sources", "tags", "title", "translations"}
|
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"}
|
postQueryKeys = []string{"author", "category", "include_empty", "page", "per_page", "pinned_first", "published", "search", "sort", "tag"}
|
||||||
|
// maintenanceBodyKeys are the body keys of the settings and content
|
||||||
|
// fix routes, by path.
|
||||||
|
maintenanceBodyKeys = map[string][]string{
|
||||||
|
"/_journal/api/v1/settings": models.Settings{}.Fillable(),
|
||||||
|
"/_journal/api/v1/fix-headings": {"dry_run"},
|
||||||
|
"/_journal/api/v1/images/alt": {"dry_run", "changes"},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// mountHost adapts a built surf router to pier.Host for a compile-only mount.
|
// mountHost adapts a built surf router to pier.Host for a compile-only mount.
|
||||||
@@ -102,7 +110,7 @@ func TestJournalMCP(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("pier was not mounted")
|
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"}
|
want := []string{"journal_create_post", "journal_delete_post", "journal_fix_headings", "journal_get_settings", "journal_list_categories", "journal_list_posts", "journal_list_tags", "journal_set_image_alt", "journal_show_post", "journal_update_post", "journal_update_settings"}
|
||||||
if got := p.ToolNames(); !slices.Equal(got, want) {
|
if got := p.ToolNames(); !slices.Equal(got, want) {
|
||||||
t.Fatalf("compiled tools %v, want %v", got, want)
|
t.Fatalf("compiled tools %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
@@ -127,7 +135,11 @@ func TestJournalMCP(t *testing.T) {
|
|||||||
t.Errorf("%s.%s: query key %q is not one the list reads", tool.Name, f.Name, 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:
|
default:
|
||||||
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(postBodyKeys, key) {
|
keys := postBodyKeys
|
||||||
|
if routeKeys, ok := maintenanceBodyKeys[tool.Path]; ok {
|
||||||
|
keys = routeKeys
|
||||||
|
}
|
||||||
|
if tool.Method == http.MethodGet || tool.Method == http.MethodDelete || !slices.Contains(keys, key) {
|
||||||
t.Errorf("%s.%s: body key %q is not one %s %s reads", tool.Name, f.Name, key, tool.Method, tool.Path)
|
t.Errorf("%s.%s: body key %q is not one %s %s reads", tool.Name, f.Name, key, tool.Method, tool.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import (
|
|||||||
// MenuItemTypes registers the Journal menu item types under their PHP keys
|
// MenuItemTypes registers the Journal menu item types under their PHP keys
|
||||||
// (classes.MenuItemTypes) with a lazy accessor of the booted app, so menus
|
// (classes.MenuItemTypes) with a lazy accessor of the booted app, so menus
|
||||||
// and sitemaps can address Journal posts and categories through the
|
// and sitemaps can address Journal posts and categories through the
|
||||||
// framework's menu item type registry.
|
// framework's menu item type registry; posts, categories, tags and author
|
||||||
|
// pages (journal-author) carry content descriptors.
|
||||||
func (p *Plugin) MenuItemTypes() []pact.MenuItemType {
|
func (p *Plugin) MenuItemTypes() []pact.MenuItemType {
|
||||||
return classes.MenuItemTypes(func() *backpack.App {
|
return classes.MenuItemTypes(func() *backpack.App {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
27
models/image_variant.go
Normal file
27
models/image_variant.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ImageVariant is one size of a featured image on
|
||||||
|
// golem15_journal_image_variants (P33-43): the thumbnail of the original at
|
||||||
|
// Width by Height (Key, in the original's format) and its WebP copy
|
||||||
|
// (WebPKey), both blob keys of the uploads bucket. SourceWidth and
|
||||||
|
// SourceHeight are the original's size. One row per file and width, so the
|
||||||
|
// API lists a post's variants with one query instead of storage lookups.
|
||||||
|
type ImageVariant struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
FileID uint `gorm:"column:file_id"`
|
||||||
|
Width int `gorm:"column:width"`
|
||||||
|
Height int `gorm:"column:height"`
|
||||||
|
Key string `gorm:"column:key"`
|
||||||
|
WebPKey string `gorm:"column:webp_key"`
|
||||||
|
SourceWidth int `gorm:"column:source_width"`
|
||||||
|
SourceHeight int `gorm:"column:source_height"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ImageVariant) TableName() string { return "golem15_journal_image_variants" }
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(ImageVariant{})
|
||||||
|
}
|
||||||
@@ -2,14 +2,19 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"html"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ beachcomber.Searchable = (*Post)(nil)
|
var (
|
||||||
|
_ beachcomber.Searchable = (*Post)(nil)
|
||||||
|
_ beachcomber.IndexSchemaProvider = (*Post)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
// SearchableAs is the Typesense collection name (D-12).
|
// SearchableAs is the Typesense collection name (D-12).
|
||||||
func (Post) SearchableAs() string { return "golem15_journal_posts" }
|
func (Post) SearchableAs() string { return "golem15_journal_posts" }
|
||||||
@@ -75,6 +80,54 @@ func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]a
|
|||||||
"tag_ids": tagIDs,
|
"tag_ids": tagIDs,
|
||||||
"title": p.Title,
|
"title": p.Title,
|
||||||
"excerpt": p.Excerpt,
|
"excerpt": p.Excerpt,
|
||||||
"content": p.Content,
|
"content": PlainText(p.searchContent()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// searchContent is the post body to index: the rendered content_html, or
|
||||||
|
// content when nothing has been rendered.
|
||||||
|
func (p *Post) searchContent() string {
|
||||||
|
if strings.TrimSpace(p.ContentHTML) != "" {
|
||||||
|
return p.ContentHTML
|
||||||
|
}
|
||||||
|
return p.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchIndexSchema is the Typesense collection schema. It applies only when
|
||||||
|
// the collection is created; journal:reindex --fresh recreates it.
|
||||||
|
func (Post) SearchIndexSchema() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"fields": []map[string]any{
|
||||||
|
{"name": "id", "type": "string"},
|
||||||
|
{"name": "title", "type": "string"},
|
||||||
|
{"name": "excerpt", "type": "string"},
|
||||||
|
{"name": "content", "type": "string"},
|
||||||
|
{"name": "published", "type": "int64"},
|
||||||
|
{"name": "published_at", "type": "int64"},
|
||||||
|
{"name": "is_pinned", "type": "int64"},
|
||||||
|
{"name": "user_id", "type": "int64"},
|
||||||
|
{"name": "created_at", "type": "int64"},
|
||||||
|
{"name": "category_ids", "type": "int64[]", "facet": false},
|
||||||
|
{"name": "tag_ids", "type": "int64[]", "facet": false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlainText drops HTML tags and unescapes entities, with whitespace runs
|
||||||
|
// collapsed to one space. A tag counts as a word break.
|
||||||
|
func PlainText(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
inTag := false
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r == '<':
|
||||||
|
inTag = true
|
||||||
|
b.WriteRune(' ')
|
||||||
|
case r == '>':
|
||||||
|
inTag = false
|
||||||
|
case !inTag:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(html.UnescapeString(b.String())), " ")
|
||||||
|
}
|
||||||
|
|||||||
59
models/post_search_test.go
Normal file
59
models/post_search_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSearchDocumentIsPlainText(t *testing.T) {
|
||||||
|
past := time.Now().Add(-time.Hour)
|
||||||
|
p := &Post{
|
||||||
|
ID: 7,
|
||||||
|
Title: "Kurki",
|
||||||
|
Excerpt: "Zajawka",
|
||||||
|
Content: "## Kurki",
|
||||||
|
ContentHTML: `<h2 class="x">Kurki</h2><p>rosną w <a href="/las">lesie</a> & pachną</p>`,
|
||||||
|
Published: true,
|
||||||
|
PublishedAt: &past,
|
||||||
|
}
|
||||||
|
doc, err := p.ToSearchableArray(t.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content, _ := doc["content"].(string)
|
||||||
|
if strings.ContainsAny(content, "<>") || strings.Contains(content, "href") || strings.Contains(content, "&") {
|
||||||
|
t.Fatalf("content keeps markup: %q", content)
|
||||||
|
}
|
||||||
|
if content != "Kurki rosną w lesie & pachną" {
|
||||||
|
t.Fatalf("content = %q", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ContentHTML = ""
|
||||||
|
doc, _ = p.ToSearchableArray(t.Context(), nil)
|
||||||
|
if doc["content"] != "## Kurki" {
|
||||||
|
t.Fatalf("content without content_html = %q", doc["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSearchSchemaMatchesDocument: the schema declares exactly the keys
|
||||||
|
// ToSearchableArray emits.
|
||||||
|
func TestSearchSchemaMatchesDocument(t *testing.T) {
|
||||||
|
doc, err := (&Post{ID: 1}).ToSearchableArray(t.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var docKeys, schemaKeys []string
|
||||||
|
for k := range doc {
|
||||||
|
docKeys = append(docKeys, k)
|
||||||
|
}
|
||||||
|
for _, f := range (Post{}).SearchIndexSchema()["fields"].([]map[string]any) {
|
||||||
|
schemaKeys = append(schemaKeys, f["name"].(string))
|
||||||
|
}
|
||||||
|
sort.Strings(docKeys)
|
||||||
|
sort.Strings(schemaKeys)
|
||||||
|
if strings.Join(docKeys, ",") != strings.Join(schemaKeys, ",") {
|
||||||
|
t.Fatalf("document keys %v, schema fields %v", docKeys, schemaKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,12 @@ type Settings struct {
|
|||||||
RSSCopyright *string `gorm:"column:rss_copyright"`
|
RSSCopyright *string `gorm:"column:rss_copyright"`
|
||||||
RSSImageURL *string `gorm:"column:rss_image_url"`
|
RSSImageURL *string `gorm:"column:rss_image_url"`
|
||||||
RSSPostsPerFeed int `gorm:"column:rss_posts_per_feed"`
|
RSSPostsPerFeed int `gorm:"column:rss_posts_per_feed"`
|
||||||
|
// ContentH1ToH2 turns H1 in Journal rich text (post content and
|
||||||
|
// category pillar text) into H2 on every write path.
|
||||||
|
ContentH1ToH2 bool `gorm:"column:content_h1_to_h2"`
|
||||||
|
// GenericAnchors lists the link texts the editor warns about, one per
|
||||||
|
// line; nil means the built-in lists.
|
||||||
|
GenericAnchors *string `gorm:"column:generic_anchors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Settings) TableName() string { return "golem15_journal_settings" }
|
func (Settings) TableName() string { return "golem15_journal_settings" }
|
||||||
@@ -27,6 +33,7 @@ func (Settings) Fillable() []string {
|
|||||||
"search_title_weight", "search_excerpt_weight", "search_content_weight",
|
"search_title_weight", "search_excerpt_weight", "search_content_weight",
|
||||||
"rss_enabled", "rss_include_content", "rss_title", "rss_description",
|
"rss_enabled", "rss_include_content", "rss_title", "rss_description",
|
||||||
"rss_language", "rss_copyright", "rss_image_url", "rss_posts_per_feed",
|
"rss_language", "rss_copyright", "rss_image_url", "rss_posts_per_feed",
|
||||||
|
"content_h1_to_h2", "generic_anchors",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +53,8 @@ func (Settings) Rules() map[string]string {
|
|||||||
"rss_copyright": "nullable|string|max:255",
|
"rss_copyright": "nullable|string|max:255",
|
||||||
"rss_image_url": "nullable|url|max:500",
|
"rss_image_url": "nullable|url|max:500",
|
||||||
"rss_posts_per_feed": "nullable|integer|min:1|max:100",
|
"rss_posts_per_feed": "nullable|integer|min:1|max:100",
|
||||||
|
"content_h1_to_h2": "boolean",
|
||||||
|
"generic_anchors": "nullable|string|max:5000",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,21 @@ fields:
|
|||||||
type: switch
|
type: switch
|
||||||
default: false
|
default: false
|
||||||
tab: golem15.journal::lang.journal.tab_general
|
tab: golem15.journal::lang.journal.tab_general
|
||||||
|
content_h1_to_h2:
|
||||||
|
span: left
|
||||||
|
label: golem15.journal::lang.journal.h1_to_h2_label
|
||||||
|
comment: golem15.journal::lang.journal.h1_to_h2_comment
|
||||||
|
type: switch
|
||||||
|
default: true
|
||||||
|
tab: golem15.journal::lang.journal.tab_headings
|
||||||
|
generic_anchors:
|
||||||
|
span: full
|
||||||
|
label: golem15.journal::lang.journal.generic_anchors_label
|
||||||
|
comment: golem15.journal::lang.journal.generic_anchors_comment
|
||||||
|
type: textarea
|
||||||
|
size: small
|
||||||
|
default: "click here\nhere\nmore\nread more\nlearn more\nthis link\nlink\nkliknij tutaj\ntutaj\ntu\nwięcej\nczytaj więcej\nczytaj dalej\nten link"
|
||||||
|
tab: golem15.journal::lang.journal.tab_headings
|
||||||
search_use_typesense:
|
search_use_typesense:
|
||||||
span: left
|
span: left
|
||||||
label: golem15.journal::lang.journal.search_use_typesense_label
|
label: golem15.journal::lang.journal.search_use_typesense_label
|
||||||
|
|||||||
10
plugin.go
10
plugin.go
@@ -25,6 +25,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
_ party.Plugin = (*Plugin)(nil)
|
_ party.Plugin = (*Plugin)(nil)
|
||||||
|
_ pact.HasConfig = (*Plugin)(nil)
|
||||||
_ pact.HasMigrations = (*Plugin)(nil)
|
_ pact.HasMigrations = (*Plugin)(nil)
|
||||||
_ pact.HasModels = (*Plugin)(nil)
|
_ pact.HasModels = (*Plugin)(nil)
|
||||||
_ pact.HasLang = (*Plugin)(nil)
|
_ pact.HasLang = (*Plugin)(nil)
|
||||||
@@ -33,6 +34,9 @@ var (
|
|||||||
_ surf.BucketProvider = (*Plugin)(nil)
|
_ surf.BucketProvider = (*Plugin)(nil)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed config
|
||||||
|
var configFS embed.FS
|
||||||
|
|
||||||
//go:embed lang
|
//go:embed lang
|
||||||
var langFS embed.FS
|
var langFS embed.FS
|
||||||
|
|
||||||
@@ -104,6 +108,9 @@ func (p *Plugin) wireSiteCache(app *backpack.App) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigFS ships config/config.yaml (golem15.journal.content_h1_to_h2).
|
||||||
|
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||||||
|
|
||||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||||
|
|
||||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||||
@@ -114,6 +121,9 @@ func (p *Plugin) Commands() []bonfire.Command {
|
|||||||
return []bonfire.Command{
|
return []bonfire.Command{
|
||||||
console.ExportPosts(p.app),
|
console.ExportPosts(p.app),
|
||||||
console.ImportPosts(p.app),
|
console.ImportPosts(p.app),
|
||||||
|
console.Reindex(p.app),
|
||||||
|
console.FixHeadings(p.app),
|
||||||
|
console.ImageVariants(p.app),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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.1
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
517
related_media_test.go
Normal file
517
related_media_test.go
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
// Related posts and category neighbours on GET /posts/{slug} (D-82, P33-44):
|
||||||
|
// the default answer is today's (same category, three, newest first, no new
|
||||||
|
// keys); golem15.journal.related_mode scored ranks by shared tags (2 points
|
||||||
|
// each) and shared categories (1 point each), ties by newest, counts 3 to 6
|
||||||
|
// (golem15.journal.related_count, clamped), and adds previous_in_category
|
||||||
|
// and next_in_category within the post's first category by name.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
journal "git.golem15.com/golem15/sm-journal-plugin"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/conga"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||||
|
"gocloud.dev/blob"
|
||||||
|
)
|
||||||
|
|
||||||
|
// relatedFixture plants the target post and its candidates. Each candidate's
|
||||||
|
// score against "target" is in its comment; the newer of two equal scores
|
||||||
|
// ranks first.
|
||||||
|
type relatedFixture struct {
|
||||||
|
byName map[string]uint
|
||||||
|
}
|
||||||
|
|
||||||
|
func plantRelated(t *testing.T, env *adminEnv) relatedFixture {
|
||||||
|
t.Helper()
|
||||||
|
db := env.gdb
|
||||||
|
now := time.Now().UTC()
|
||||||
|
cat := func(name, slug string) uint {
|
||||||
|
c := models.Category{Name: name, Slug: slug}
|
||||||
|
if err := db.Create(&c).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c.ID
|
||||||
|
}
|
||||||
|
tag := func(name string) uint {
|
||||||
|
tg := models.Tag{Name: name, Slug: name}
|
||||||
|
if err := db.Create(&tg).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tg.ID
|
||||||
|
}
|
||||||
|
// "Alpha" sorts before "Beta", so Alpha is the target's first category.
|
||||||
|
alpha, beta, other := cat("Alpha", "alpha"), cat("Beta", "beta"), cat("Other", "other")
|
||||||
|
t1, t2, t3 := tag("t1"), tag("t2"), tag("t3")
|
||||||
|
f := relatedFixture{byName: map[string]uint{}}
|
||||||
|
post := func(slug string, age time.Duration, published bool, future bool, cats []uint, tags []uint) {
|
||||||
|
at := now.Add(-age)
|
||||||
|
if future {
|
||||||
|
at = now.Add(age)
|
||||||
|
}
|
||||||
|
p := models.Post{Title: "Post " + slug, Slug: slug, Content: "body " + slug, Published: published, PublishedAt: &at}
|
||||||
|
if err := db.Create(&p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range cats {
|
||||||
|
if err := db.Create(&models.PostCategory{PostID: p.ID, CategoryID: c}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tg := range tags {
|
||||||
|
if err := db.Create(&models.PostTag{PostID: p.ID, TagID: tg}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.byName[slug] = p.ID
|
||||||
|
}
|
||||||
|
hour := time.Hour
|
||||||
|
post("target", 50*hour, true, false, []uint{alpha, beta}, []uint{t1, t2})
|
||||||
|
post("a", 90*hour, true, false, []uint{alpha}, []uint{t1, t2}) // 5
|
||||||
|
post("b", 80*hour, true, false, []uint{alpha, beta}, []uint{t1}) // 4 (older than c)
|
||||||
|
post("c", 10*hour, true, false, nil, []uint{t1, t2}) // 4
|
||||||
|
post("g", 70*hour, true, false, nil, []uint{t2}) // 2
|
||||||
|
post("d", 20*hour, true, false, []uint{beta}, nil) // 1 (newer than h)
|
||||||
|
post("h", 95*hour, true, false, []uint{alpha}, nil) // 1
|
||||||
|
post("e", 5*hour, true, false, []uint{other}, []uint{t3}) // 0: never related
|
||||||
|
post("draft", 1*hour, false, false, []uint{alpha, beta}, []uint{t1, t2}) // 6, unpublished
|
||||||
|
post("later", 30*hour, true, true, []uint{alpha, beta}, []uint{t1, t2}) // 6, published in the future
|
||||||
|
// In Alpha, by published_at: h (95 h ago), a (90), b (80), target (50);
|
||||||
|
// the draft and the future post never count. Site-wide, target's nearest
|
||||||
|
// newer published post is d (20 h ago) and its nearest older one g (70).
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
type showAnswer struct {
|
||||||
|
Data map[string]json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func showPost(t *testing.T, env *adminEnv, slug string) showAnswer {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.call(t, http.MethodGet, "/_journal/api/v1/posts/"+slug, "", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("show %s = %d: %s", slug, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var out showAnswer
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func relatedSlugs(t *testing.T, a showAnswer) []string {
|
||||||
|
t.Helper()
|
||||||
|
var rows []struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(a.Data["related_posts"], &rows); err != nil {
|
||||||
|
t.Fatalf("related_posts: %v", err)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, r.Slug)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func siblingSlug(t *testing.T, a showAnswer, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
raw, ok := a.Data[key]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("answer lacks %s", key)
|
||||||
|
}
|
||||||
|
if string(raw) == "null" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &s); err != nil {
|
||||||
|
t.Fatalf("%s: %v", key, err)
|
||||||
|
}
|
||||||
|
if s.ID == 0 || s.Title == "" {
|
||||||
|
t.Fatalf("%s lacks id or title: %s", key, raw)
|
||||||
|
}
|
||||||
|
return s.Slug
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelatedDefaultUnchanged(t *testing.T) {
|
||||||
|
for name, env := range map[string]*adminEnv{
|
||||||
|
"no config": newPostsEnv(t),
|
||||||
|
"category mode": newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__RELATED_MODE=category", "SUMMER_GOLEM15__JOURNAL__RELATED_COUNT=6"),
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
plantRelated(t, env)
|
||||||
|
a := showPost(t, env, "target")
|
||||||
|
// Today's rule: posts sharing a category, newest first, three,
|
||||||
|
// whatever their tags; drafts and future posts left out for a
|
||||||
|
// visitor.
|
||||||
|
if got, want := relatedSlugs(t, a), []string{"d", "b", "a"}; !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("related_posts = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"previous_in_category", "next_in_category"} {
|
||||||
|
if _, ok := a.Data[key]; ok {
|
||||||
|
t.Fatalf("default answer has %s: %s", key, a.Data[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"previous_post", "next_post", "related_posts", "content_h1_to_h2"} {
|
||||||
|
if _, ok := a.Data[key]; !ok {
|
||||||
|
t.Fatalf("default answer lacks %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The site-wide neighbours keep their meaning.
|
||||||
|
if got := siblingSlug(t, a, "previous_post"); got != "d" {
|
||||||
|
t.Fatalf("previous_post = %q, want d", got)
|
||||||
|
}
|
||||||
|
if got := siblingSlug(t, a, "next_post"); got != "g" {
|
||||||
|
t.Fatalf("next_post = %q, want g", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelatedScored(t *testing.T) {
|
||||||
|
env := newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__RELATED_MODE=scored", "SUMMER_GOLEM15__JOURNAL__RELATED_COUNT=5")
|
||||||
|
plantRelated(t, env)
|
||||||
|
a := showPost(t, env, "target")
|
||||||
|
// 2 per shared tag, 1 per shared category, ties by newest; e scores 0,
|
||||||
|
// the draft and the future post never appear for a visitor (T-33-70).
|
||||||
|
if got, want := relatedSlugs(t, a), []string{"a", "c", "b", "g", "d"}; !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("related_posts = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
// Neighbours in the first category (Alpha), with the same direction as
|
||||||
|
// previous_post and next_post: previous is the next newer published
|
||||||
|
// post, next the next older one.
|
||||||
|
if got := siblingSlug(t, a, "previous_in_category"); got != "" {
|
||||||
|
t.Fatalf("previous_in_category = %q, want none (no newer published post in Alpha)", got)
|
||||||
|
}
|
||||||
|
if got := siblingSlug(t, a, "next_in_category"); got != "b" {
|
||||||
|
t.Fatalf("next_in_category = %q, want b", got)
|
||||||
|
}
|
||||||
|
// The site-wide neighbours are unchanged in scored mode.
|
||||||
|
if got := siblingSlug(t, a, "previous_post"); got != "d" {
|
||||||
|
t.Fatalf("previous_post = %q, want d", got)
|
||||||
|
}
|
||||||
|
b := showPost(t, env, "b")
|
||||||
|
if got := siblingSlug(t, b, "previous_in_category"); got != "target" {
|
||||||
|
t.Fatalf("b previous_in_category = %q, want target", got)
|
||||||
|
}
|
||||||
|
if got := siblingSlug(t, b, "next_in_category"); got != "a" {
|
||||||
|
t.Fatalf("b next_in_category = %q, want a", got)
|
||||||
|
}
|
||||||
|
for _, r := range relatedSlugs(t, b) {
|
||||||
|
if r == "draft" || r == "later" || r == "b" {
|
||||||
|
t.Fatalf("b related_posts holds %q: %v", r, relatedSlugs(t, b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A post without tags or categories in common with anything has none.
|
||||||
|
if got := relatedSlugs(t, showPost(t, env, "e")); len(got) != 0 {
|
||||||
|
t.Fatalf("e related_posts = %v, want none", got)
|
||||||
|
}
|
||||||
|
if got := siblingSlug(t, showPost(t, env, "c"), "next_in_category"); got != "" {
|
||||||
|
t.Fatalf("c (no category) next_in_category = %q, want none", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelatedScoredCountClamped(t *testing.T) {
|
||||||
|
for count, want := range map[string][]string{
|
||||||
|
"10": {"a", "c", "b", "g", "d", "h"},
|
||||||
|
"1": {"a", "c", "b"},
|
||||||
|
"x": {"a", "c", "b"},
|
||||||
|
} {
|
||||||
|
t.Run(count, func(t *testing.T) {
|
||||||
|
env := newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__RELATED_MODE=scored", "SUMMER_GOLEM15__JOURNAL__RELATED_COUNT="+count)
|
||||||
|
plantRelated(t, env)
|
||||||
|
if got := relatedSlugs(t, showPost(t, env, "target")); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("related_count %s: related_posts = %v, want %v", count, got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jpegOf is a JPEG picture of w by h.
|
||||||
|
func jpegOf(t *testing.T, w, h int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
img.Set(x, y, color.RGBA{uint8(x), uint8(y), 90, 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketKeys lists every key of the uploads bucket.
|
||||||
|
func bucketKeys(t *testing.T, env *adminEnv) []string {
|
||||||
|
t.Helper()
|
||||||
|
bucket, ok := env.app.Lookup[*blob.Bucket]()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no uploads bucket")
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
iter := bucket.List(nil)
|
||||||
|
for {
|
||||||
|
obj, err := iter.Next(t.Context())
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out = append(out, obj.Key)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// queuedVariantJobs counts the image variants jobs on the queue.
|
||||||
|
func queuedVariantJobs(t *testing.T, env *adminEnv) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var n int64
|
||||||
|
if err := env.gdb.Raw(`SELECT COUNT(*) FROM river_job WHERE kind = ?`, classes.ImageVariantsKind).Scan(&n).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// runVariantsJob runs the Journal's image variants job.
|
||||||
|
func runVariantsJob(t *testing.T, env *adminEnv, ids ...uint) {
|
||||||
|
t.Helper()
|
||||||
|
for _, p := range env.plugins {
|
||||||
|
if jp, ok := p.(*journal.Plugin); ok {
|
||||||
|
for _, j := range jp.Jobs() {
|
||||||
|
if info, ok := conga.Describe(j); ok && info.Kind == classes.ImageVariantsKind {
|
||||||
|
if err := j.Work(t.Context(), classes.ImageVariantsArgs{FileIDs: ids}); err != nil {
|
||||||
|
t.Fatalf("image variants job: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("the Journal registers no image variants job")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageVariants covers the featured image variants (D-85, P33-43): an
|
||||||
|
// upload answers before any encoding and queues the job; the job writes
|
||||||
|
// the default widths no wider than the original with a WebP copy of each;
|
||||||
|
// the post API lists them as featured_images[].variants; a second run
|
||||||
|
// writes nothing; an admin save of a post whose image lacks variants
|
||||||
|
// queues the job after the commit.
|
||||||
|
func TestImageVariants(t *testing.T) {
|
||||||
|
if got := classes.ImageWidths(nil); !slices.Equal(got, []int{480, 800, 1200}) {
|
||||||
|
t.Fatalf("default widths = %v", got)
|
||||||
|
}
|
||||||
|
if testShort() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
env := newPostsEnv(t, "SUMMER_GOLEM15__JOURNAL__IMAGE_WIDTHS=1200, 480,800,480")
|
||||||
|
if got := classes.ImageWidths(env.app.Config); !slices.Equal(got, []int{480, 800, 1200}) {
|
||||||
|
t.Fatalf("widths from the environment = %v", got)
|
||||||
|
}
|
||||||
|
token := env.admin(t, controllers.PermissionAccessPosts, controllers.PermissionAccessPublish, controllers.PermissionAccessOtherPosts)
|
||||||
|
post := models.Post{Title: "Mushrooms", Slug: "mushrooms", Content: "<p>x</p>", Published: true}
|
||||||
|
at := time.Now().UTC().Add(-time.Hour)
|
||||||
|
post.PublishedAt = &at
|
||||||
|
if err := env.gdb.Create(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := queuedVariantJobs(t, env)
|
||||||
|
rec := env.callMultipart(t, http.MethodPost, fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", post.ID), token, nil, "file", "cover.jpg", jpegOf(t, 1000, 600))
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("upload = %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var up struct {
|
||||||
|
Data struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil || up.Data.ID == 0 {
|
||||||
|
t.Fatalf("upload body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
// The request encoded nothing: one key (the original), no rows, one job.
|
||||||
|
if keys := bucketKeys(t, env); len(keys) != 1 {
|
||||||
|
t.Fatalf("after the upload the bucket holds %v, want only the original", keys)
|
||||||
|
}
|
||||||
|
var rows int64
|
||||||
|
env.gdb.Model(&models.ImageVariant{}).Count(&rows)
|
||||||
|
if rows != 0 || queuedVariantJobs(t, env) != before+1 {
|
||||||
|
t.Fatalf("after the upload: %d variant rows, %d jobs (was %d)", rows, queuedVariantJobs(t, env), before)
|
||||||
|
}
|
||||||
|
|
||||||
|
runVariantsJob(t, env, up.Data.ID)
|
||||||
|
var variants []models.ImageVariant
|
||||||
|
if err := env.gdb.Where("file_id = ?", up.Data.ID).Order("width ASC").Find(&variants).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(variants) != 2 || variants[0].Width != 480 || variants[0].Height != 288 || variants[1].Width != 800 || variants[1].Height != 480 {
|
||||||
|
t.Fatalf("variants = %+v, want 480x288 and 800x480 (1200 is wider than the original)", variants)
|
||||||
|
}
|
||||||
|
bucket, _ := env.app.Lookup[*blob.Bucket]()
|
||||||
|
for _, v := range variants {
|
||||||
|
raw, err := bucket.ReadAll(t.Context(), v.WebPKey)
|
||||||
|
if err != nil || len(raw) < 12 || string(raw[0:4]) != "RIFF" || string(raw[8:12]) != "WEBP" {
|
||||||
|
t.Fatalf("webp %s: err=%v", v.WebPKey, err)
|
||||||
|
}
|
||||||
|
if ok, err := bucket.Exists(t.Context(), v.Key); err != nil || !ok || !strings.HasSuffix(v.WebPKey, ".webp") || v.SourceWidth != 1000 || v.SourceHeight != 600 {
|
||||||
|
t.Fatalf("variant %+v: thumbnail exists %v err=%v", v, ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys := bucketKeys(t, env)
|
||||||
|
|
||||||
|
show := showPost(t, env, "mushrooms")
|
||||||
|
var images []struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Variants []struct {
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
WebPURL string `json:"webp_url"`
|
||||||
|
SourceWidth int `json:"source_width"`
|
||||||
|
SourceHeight int `json:"source_height"`
|
||||||
|
} `json:"variants"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(show.Data["featured_images"], &images); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(images) != 1 || len(images[0].Variants) != 2 {
|
||||||
|
t.Fatalf("featured_images = %s", show.Data["featured_images"])
|
||||||
|
}
|
||||||
|
v := images[0].Variants[1]
|
||||||
|
if v.Width != 800 || v.Height != 480 || !strings.HasSuffix(v.URL, "_800_480_0_0_exact.jpg") || !strings.HasSuffix(v.WebPURL, "_800_480_0_0_exact.webp") || v.SourceWidth != 1000 {
|
||||||
|
t.Fatalf("variant answer = %+v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second run writes nothing.
|
||||||
|
runVariantsJob(t, env, up.Data.ID)
|
||||||
|
if again := bucketKeys(t, env); !slices.Equal(again, keys) {
|
||||||
|
t.Fatalf("second run changed the bucket: %v -> %v", keys, again)
|
||||||
|
}
|
||||||
|
env.gdb.Model(&models.ImageVariant{}).Count(&rows)
|
||||||
|
if rows != 2 {
|
||||||
|
t.Fatalf("variant rows after the second run = %d", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin save of a post whose image has no variants queues the job.
|
||||||
|
other := models.Post{Title: "Other", Slug: "other", Content: "<p>y</p>"}
|
||||||
|
if err := env.gdb.Create(&other).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := bucket.WriteAll(t.Context(), attach.BlobKey("abc123def456ghi789jkl0.jpg"), jpegOf(t, 600, 400), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := attach.File{DiskName: "abc123def456ghi789jkl0.jpg", FileName: "b.jpg", ContentType: "image/jpeg", Field: "featured_images", AttachmentType: other.MorphName(), AttachmentID: strconv.FormatUint(uint64(other.ID), 10)}
|
||||||
|
if err := env.gdb.Create(&file).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
editor := token
|
||||||
|
queued := queuedVariantJobs(t, env)
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, other.ID)), editor, map[string]any{
|
||||||
|
"title": map[string]any{"en": "Other"}, "slug": map[string]any{"en": "other"}, "content": map[string]any{"en": "<p>y</p>"},
|
||||||
|
})
|
||||||
|
if got := queuedVariantJobs(t, env); got != queued+1 {
|
||||||
|
t.Fatalf("admin save queued %d jobs, want 1", got-queued)
|
||||||
|
}
|
||||||
|
// Saving the first post again queues nothing: its image has every width
|
||||||
|
// no wider than the original.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI(fmt.Sprintf("%s/%d", adminPostsPath, post.ID)), editor, map[string]any{
|
||||||
|
"title": map[string]any{"en": "Mushrooms"}, "slug": map[string]any{"en": "mushrooms"}, "content": map[string]any{"en": "<p>x</p>"},
|
||||||
|
})
|
||||||
|
if got := queuedVariantJobs(t, env); got != queued+1 {
|
||||||
|
t.Fatalf("saving a post whose variants exist queued a job (%d)", got-queued)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting the image deletes its variant rows and blobs.
|
||||||
|
env.expect(t, http.StatusOK, http.MethodDelete, fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images/%d", post.ID, up.Data.ID), token, nil)
|
||||||
|
env.gdb.Model(&models.ImageVariant{}).Where("file_id = ?", up.Data.ID).Count(&rows)
|
||||||
|
if rows != 0 {
|
||||||
|
t.Fatalf("variant rows after the delete = %d", rows)
|
||||||
|
}
|
||||||
|
for _, k := range bucketKeys(t, env) {
|
||||||
|
if strings.Contains(k, fmt.Sprintf("thumb_%d_", up.Data.ID)) {
|
||||||
|
t.Fatalf("a variant blob survived the delete: %s", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImageVariantsTallOriginal pins L1 CR-01 (D-85): a width whose
|
||||||
|
// variant would be taller than the thumbnailer's 4096 px edge limit is
|
||||||
|
// skipped and counted, and the job and journal:image-variants go on with
|
||||||
|
// the next widths and images instead of failing on the tall original.
|
||||||
|
func TestImageVariantsTallOriginal(t *testing.T) {
|
||||||
|
if testShort() {
|
||||||
|
t.Skip("Postgres")
|
||||||
|
}
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
bucket, _ := env.app.Lookup[*blob.Bucket]()
|
||||||
|
post := models.Post{Title: "Infographic", Slug: "infographic", Content: "<p>x</p>", Published: true}
|
||||||
|
if err := env.gdb.Create(&post).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
upload := func(disk string, w, h int) attach.File {
|
||||||
|
t.Helper()
|
||||||
|
if err := bucket.WriteAll(t.Context(), attach.BlobKey(disk), jpegOf(t, w, h), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := attach.File{DiskName: disk, FileName: disk, ContentType: "image/jpeg", Field: classes.FeaturedImagesField, AttachmentType: post.MorphName(), AttachmentID: strconv.FormatUint(uint64(post.ID), 10)}
|
||||||
|
if err := env.gdb.Create(&f).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
tall := upload("tall23def456ghi789jkl0.jpg", 1000, 9000)
|
||||||
|
wide := upload("wide23def456ghi789jkl0.jpg", 900, 600)
|
||||||
|
|
||||||
|
res, err := classes.GenerateImageVariants(t.Context(), env.gdb, bucket, classes.ImageWidths(env.app.Config), []uint{tall.ID, wide.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the job failed on the tall original: %v", err)
|
||||||
|
}
|
||||||
|
if res.Skipped < 3 {
|
||||||
|
t.Fatalf("result = %+v, want the tall original's three widths skipped (and the normal one's 1200)", res)
|
||||||
|
}
|
||||||
|
var tallRows, wideRows []models.ImageVariant
|
||||||
|
env.gdb.Where("file_id = ?", tall.ID).Find(&tallRows)
|
||||||
|
env.gdb.Where("file_id = ?", wide.ID).Order("width ASC").Find(&wideRows)
|
||||||
|
for _, v := range tallRows {
|
||||||
|
if v.Height > classes.MaxImageWidth {
|
||||||
|
t.Fatalf("a variant above the edge limit: %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(wideRows) != 2 || wideRows[0].Width != 480 || wideRows[1].Width != 800 {
|
||||||
|
t.Fatalf("variants of the image after the tall one = %+v", wideRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
root, err := bonfire.NewRootIO("summer", []bonfire.Command{console.ImageVariants(env.app)}, strings.NewReader(""), &buf, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if code := bonfire.Execute(t.Context(), root, []string{"journal:image-variants", "--all"}); code != 0 {
|
||||||
|
t.Fatalf("journal:image-variants --all: %d\n%s", code, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ func (p *Plugin) Routes(r pact.Router) error {
|
|||||||
g.Delete("/posts/{id}/featured-images/{fileId}", apicontrollers.DeleteFeaturedImage(p.app))
|
g.Delete("/posts/{id}/featured-images/{fileId}", apicontrollers.DeleteFeaturedImage(p.app))
|
||||||
g.Where("id", `[0-9]+`)
|
g.Where("id", `[0-9]+`)
|
||||||
g.Where("fileId", `[0-9]+`)
|
g.Where("fileId", `[0-9]+`)
|
||||||
|
g.Get("/settings", apicontrollers.ShowSettings(p.app))
|
||||||
|
g.Put("/settings", apicontrollers.UpdateSettings(p.app))
|
||||||
|
g.Post("/fix-headings", apicontrollers.FixHeadings(p.app))
|
||||||
|
g.Post("/images/alt", apicontrollers.SetImageAlt(p.app))
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
347
search_engine_test.go
Normal file
347
search_engine_test.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
package journal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||||
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
)
|
||||||
|
|
||||||
|
const searchIndex = "golem15_journal_posts"
|
||||||
|
|
||||||
|
// typesenseStub stands in for a Typesense server: one collection that GET,
|
||||||
|
// POST and DELETE /collections create and drop, imports answered with one
|
||||||
|
// success line per document, and a search that answers with hits, the ids
|
||||||
|
// in rank order.
|
||||||
|
type typesenseStub struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
exists bool
|
||||||
|
hits []string
|
||||||
|
requests []string
|
||||||
|
schema map[string]any
|
||||||
|
imported []string
|
||||||
|
removed []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *typesenseStub) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.requests = append(s.requests, r.Method+" "+r.URL.Path)
|
||||||
|
coll := "/collections/" + searchIndex
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == coll:
|
||||||
|
if !s.exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/collections":
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&s.schema)
|
||||||
|
s.exists = true
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = io.WriteString(w, `{}`)
|
||||||
|
case r.Method == http.MethodDelete && r.URL.Path == coll:
|
||||||
|
if !s.exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.exists = false
|
||||||
|
_, _ = io.WriteString(w, `{}`)
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == coll+"/documents/import":
|
||||||
|
sc := bufio.NewScanner(r.Body)
|
||||||
|
var answer []string
|
||||||
|
for sc.Scan() {
|
||||||
|
var doc map[string]any
|
||||||
|
if err := json.Unmarshal(sc.Bytes(), &doc); err == nil {
|
||||||
|
s.imported = append(s.imported, fmt.Sprint(doc["id"]))
|
||||||
|
}
|
||||||
|
answer = append(answer, `{"success":true}`)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, strings.Join(answer, "\n"))
|
||||||
|
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, coll+"/documents/"):
|
||||||
|
s.removed = append(s.removed, strings.TrimPrefix(r.URL.Path, coll+"/documents/"))
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == coll+"/documents/search":
|
||||||
|
hits := make([]string, len(s.hits))
|
||||||
|
for i, id := range s.hits {
|
||||||
|
hits[i] = `{"document":{"id":"` + id + `"}}`
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `{"found":%d,"hits":[%s]}`, len(s.hits), strings.Join(hits, ","))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *typesenseStub) sent() []string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append([]string(nil), s.requests...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSearchEnv(t *testing.T) (*adminEnv, *typesenseStub) {
|
||||||
|
t.Helper()
|
||||||
|
stub := &typesenseStub{}
|
||||||
|
srv := httptest.NewServer(stub)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
u, err := url.Parse(srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := newPostsEnv(t,
|
||||||
|
"SUMMER_SEARCH__DRIVER=typesense",
|
||||||
|
"SUMMER_SEARCH__TYPESENSE__API_KEY=test-only-typesense-key",
|
||||||
|
"SUMMER_SEARCH__TYPESENSE__HOST="+u.Hostname(),
|
||||||
|
"SUMMER_SEARCH__TYPESENSE__PORT="+u.Port(),
|
||||||
|
"SUMMER_SEARCH__TYPESENSE__PROTOCOL="+u.Scheme,
|
||||||
|
)
|
||||||
|
t.Cleanup(func() { models.SetSearchGate(false) })
|
||||||
|
return env, stub
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSearchGate(t *testing.T, env *adminEnv, on bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET search_use_typesense = ? WHERE id = 1`, on).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSearchPosts creates n published posts titled "Grzyb N" and one draft,
|
||||||
|
// with the gate off so the writes send nothing, and returns their ids.
|
||||||
|
func seedSearchPosts(t *testing.T, env *adminEnv, n int) (published []uint, draft uint) {
|
||||||
|
t.Helper()
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
for i := 1; i <= n; i++ {
|
||||||
|
at := past.Add(-time.Duration(i) * time.Minute)
|
||||||
|
p := models.Post{
|
||||||
|
Title: fmt.Sprintf("Grzyb %02d", i),
|
||||||
|
Slug: fmt.Sprintf("grzyb-%02d", i),
|
||||||
|
Content: "o grzybach",
|
||||||
|
Published: true,
|
||||||
|
PublishedAt: &at,
|
||||||
|
}
|
||||||
|
if err := env.gdb.Create(&p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
published = append(published, p.ID)
|
||||||
|
}
|
||||||
|
d := models.Post{Title: "Grzyb szkic", Slug: "grzyb-szkic", Content: "o grzybach", Published: false}
|
||||||
|
if err := env.gdb.Create(&d).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return published, d.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
type listBody struct {
|
||||||
|
Data []map[string]any `json:"data"`
|
||||||
|
Meta struct {
|
||||||
|
CurrentPage int `json:"current_page"`
|
||||||
|
LastPage int `json:"last_page"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
} `json:"meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getList(t *testing.T, env *adminEnv, path string) listBody {
|
||||||
|
t.Helper()
|
||||||
|
rec := env.call(t, http.MethodGet, path, "", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d: %s", path, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body listBody
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("GET %s body: %v", path, err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func slugsOf(rows []map[string]any) []string {
|
||||||
|
out := make([]string, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
out[i], _ = row["slug"].(string)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEngineSearchPages: the engine ranks 25 published posts and a draft;
|
||||||
|
// the list re-gates them in SQL, keeps the engine's order across pages and
|
||||||
|
// counts the gated rows only.
|
||||||
|
func TestEngineSearchPages(t *testing.T) {
|
||||||
|
env, stub := newSearchEnv(t)
|
||||||
|
published, draft := seedSearchPosts(t, env, 25)
|
||||||
|
// Rank: oldest first (the reverse of the date order), the draft in the
|
||||||
|
// middle.
|
||||||
|
var rank []string
|
||||||
|
for i := len(published) - 1; i >= 0; i-- {
|
||||||
|
rank = append(rank, strconv.FormatUint(uint64(published[i]), 10))
|
||||||
|
if i == 12 {
|
||||||
|
rank = append(rank, strconv.FormatUint(uint64(draft), 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stub.hits = rank
|
||||||
|
setSearchGate(t, env, true)
|
||||||
|
|
||||||
|
want := make([]string, 0, 25)
|
||||||
|
for i := 25; i >= 1; i-- {
|
||||||
|
want = append(want, fmt.Sprintf("grzyb-%02d", i))
|
||||||
|
}
|
||||||
|
for page, slice := range map[int][]string{1: want[:10], 2: want[10:20], 3: want[20:]} {
|
||||||
|
body := getList(t, env, fmt.Sprintf("/_journal/api/v1/posts?search=grzyb&per_page=10&page=%d", page))
|
||||||
|
if body.Meta.Total != 25 {
|
||||||
|
t.Fatalf("page %d total = %d, want 25 (draft re-gated out)", page, body.Meta.Total)
|
||||||
|
}
|
||||||
|
if got := slugsOf(body.Data); strings.Join(got, ",") != strings.Join(slice, ",") {
|
||||||
|
t.Fatalf("page %d = %v, want %v", page, got, slice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, req := range stub.sent() {
|
||||||
|
if strings.HasSuffix(req, "/documents/search") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Fatalf("unexpected Typesense request %q", req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit sort overrides relevance.
|
||||||
|
body := getList(t, env, "/_journal/api/v1/posts?search=grzyb&per_page=3&sort=title+asc")
|
||||||
|
if got := strings.Join(slugsOf(body.Data), ","); got != "grzyb-01,grzyb-02,grzyb-03" {
|
||||||
|
t.Fatalf("sort=title asc = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEngineSearchGateOff: with Typesense configured but the gate off, the
|
||||||
|
// list searches in SQL and sends nothing.
|
||||||
|
func TestEngineSearchGateOff(t *testing.T) {
|
||||||
|
env, stub := newSearchEnv(t)
|
||||||
|
seedSearchPosts(t, env, 3)
|
||||||
|
stub.hits = []string{"999"}
|
||||||
|
body := getList(t, env, "/_journal/api/v1/posts?search=grzyb")
|
||||||
|
if body.Meta.Total != 3 || len(body.Data) != 3 {
|
||||||
|
t.Fatalf("total=%d rows=%d, want the 3 SQL matches", body.Meta.Total, len(body.Data))
|
||||||
|
}
|
||||||
|
if got := strings.Join(slugsOf(body.Data), ","); got != "grzyb-01,grzyb-02,grzyb-03" {
|
||||||
|
t.Fatalf("SQL order = %s, want the date order", got)
|
||||||
|
}
|
||||||
|
if n := len(stub.sent()); n != 0 {
|
||||||
|
t.Fatalf("Typesense requests = %v, want none", stub.sent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSuggestRows: ?fields=suggest serves only the suggestion keys, with a
|
||||||
|
// plain-text excerpt cut on a word boundary or taken from the content.
|
||||||
|
func TestSuggestRows(t *testing.T) {
|
||||||
|
env := newPostsEnv(t)
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
long := strings.Repeat("borowik szlachetny ", 20)
|
||||||
|
for _, p := range []models.Post{
|
||||||
|
{Title: "Z zajawką", Slug: "z-zajawka", Excerpt: "<p>Krótka <b>zajawka</b></p>", Content: "treść", Published: true, PublishedAt: &past},
|
||||||
|
{Title: "Długa", Slug: "dluga", Excerpt: long, Content: "treść", Published: true, PublishedAt: &past},
|
||||||
|
{Title: "Bez zajawki", Slug: "bez-zajawki", Content: "x", ContentHTML: "<h2>Kurki</h2><p>rosną & pachną</p>", Published: true, PublishedAt: &past},
|
||||||
|
} {
|
||||||
|
if err := env.gdb.Create(&p).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := getList(t, env, "/_journal/api/v1/posts?fields=suggest&per_page=5&sort=title+asc")
|
||||||
|
if len(body.Data) != 3 || body.Meta.Total != 3 {
|
||||||
|
t.Fatalf("rows=%d total=%d", len(body.Data), body.Meta.Total)
|
||||||
|
}
|
||||||
|
wantKeys := "excerpt,featured_image_url,id,published_at,slug,title"
|
||||||
|
excerpts := map[string]string{}
|
||||||
|
for _, row := range body.Data {
|
||||||
|
keys := make([]string, 0, len(row))
|
||||||
|
for k := range row {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
if got := strings.Join(keys, ","); got != wantKeys {
|
||||||
|
t.Fatalf("suggest keys = %s, want %s", got, wantKeys)
|
||||||
|
}
|
||||||
|
excerpts[row["slug"].(string)] = row["excerpt"].(string)
|
||||||
|
}
|
||||||
|
if got := excerpts["z-zajawka"]; got != "Krótka zajawka" {
|
||||||
|
t.Fatalf("excerpt = %q", got)
|
||||||
|
}
|
||||||
|
if got := excerpts["bez-zajawki"]; got != "Kurki rosną & pachną" {
|
||||||
|
t.Fatalf("content fallback = %q", got)
|
||||||
|
}
|
||||||
|
got := excerpts["dluga"]
|
||||||
|
cut, ok := strings.CutSuffix(got, "…")
|
||||||
|
if !ok || len([]rune(cut)) > 160 || !strings.HasPrefix(long, cut+" ") {
|
||||||
|
t.Fatalf("long excerpt = %q, want under 160 runes cut on a word boundary", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without fields=suggest the rows are the full ones.
|
||||||
|
full := getList(t, env, "/_journal/api/v1/posts?per_page=1")
|
||||||
|
if _, ok := full.Data[0]["content"]; !ok {
|
||||||
|
t.Fatal("full rows lost content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReindexFresh: --fresh drops the collection, recreates it from the
|
||||||
|
// schema, upserts the published posts and removes the draft's document.
|
||||||
|
func TestReindexFresh(t *testing.T) {
|
||||||
|
env, stub := newSearchEnv(t)
|
||||||
|
published, draft := seedSearchPosts(t, env, 3)
|
||||||
|
stub.exists = true
|
||||||
|
setSearchGate(t, env, true)
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
|
||||||
|
res, err := console.ReindexPosts(t.Context(), env.app, true, out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reindex: %v (%s)", err, buf.String())
|
||||||
|
}
|
||||||
|
if res.Synced != 3 || res.Removed != 1 || res.Failed != 0 {
|
||||||
|
t.Fatalf("result = %+v, want 3 synced, 1 removed", res)
|
||||||
|
}
|
||||||
|
sent := stub.sent()
|
||||||
|
if len(sent) < 3 || sent[0] != "DELETE /collections/"+searchIndex ||
|
||||||
|
sent[1] != "GET /collections/"+searchIndex || sent[2] != "POST /collections" {
|
||||||
|
t.Fatalf("requests = %v, want DELETE, GET, POST /collections first", sent)
|
||||||
|
}
|
||||||
|
fields, _ := stub.schema["fields"].([]any)
|
||||||
|
if stub.schema["name"] != searchIndex || len(fields) != len(models.Post{}.SearchIndexSchema()["fields"].([]map[string]any)) {
|
||||||
|
t.Fatalf("created schema = %v", stub.schema)
|
||||||
|
}
|
||||||
|
if len(stub.imported) != 3 {
|
||||||
|
t.Fatalf("imported = %v, want the 3 published posts", stub.imported)
|
||||||
|
}
|
||||||
|
for i, id := range published {
|
||||||
|
if stub.imported[i] != strconv.FormatUint(uint64(id), 10) {
|
||||||
|
t.Fatalf("imported = %v, want %v", stub.imported, published)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stub.removed) != 1 || stub.removed[0] != strconv.FormatUint(uint64(draft), 10) {
|
||||||
|
t.Fatalf("removed = %v, want the draft %d", stub.removed, draft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReindexRefuses: with the gate off, or no engine configured, the
|
||||||
|
// command errors and sends nothing.
|
||||||
|
func TestReindexRefuses(t *testing.T) {
|
||||||
|
env, stub := newSearchEnv(t)
|
||||||
|
seedSearchPosts(t, env, 1)
|
||||||
|
if _, err := console.ReindexPosts(t.Context(), env.app, true, nil); err == nil || !strings.Contains(err.Error(), "search_use_typesense") {
|
||||||
|
t.Fatalf("gate off: err = %v", err)
|
||||||
|
}
|
||||||
|
if n := len(stub.sent()); n != 0 {
|
||||||
|
t.Fatalf("gate off sent %v", stub.sent())
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := newPostsEnv(t)
|
||||||
|
setSearchGate(t, plain, true)
|
||||||
|
if _, err := console.ReindexPosts(t.Context(), plain.app, false, nil); err == nil || !strings.Contains(err.Error(), "not configured") {
|
||||||
|
t.Fatalf("null driver: err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
209
tests/blogeditor/additions.test.ts
Normal file
209
tests/blogeditor/additions.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { readOptions } from '../../assets/src/blogeditor/element'
|
||||||
|
import { mediaFileName, slugFileStem } from '../../assets/src/blogeditor/imageform'
|
||||||
|
import { labelsFor, plural } from '../../assets/src/blogeditor/labels'
|
||||||
|
import { isExternalUrl } from '../../assets/src/blogeditor/linkform'
|
||||||
|
import { normalizeAnchor, OUTLINE_DEBOUNCE_MS } from '../../assets/src/blogeditor/outline'
|
||||||
|
import { button, chooseImage, click, fill, form, insertAtBlock, mount, status } from './helpers'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
const ON = JSON.stringify({ headingLevels: [2, 3, 4], genericAnchors: ['click here', 'more', 'kliknij tutaj'] })
|
||||||
|
const OFF = JSON.stringify({ headingLevels: [1, 2, 3, 4], genericAnchors: ['click here'] })
|
||||||
|
|
||||||
|
function outlineToggle(el: HTMLElement): HTMLButtonElement {
|
||||||
|
return el.querySelector<HTMLButtonElement>('.blogeditor__outline-toggle')!
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOutline(el: HTMLElement): HTMLElement {
|
||||||
|
click(outlineToggle(el))
|
||||||
|
return el.querySelector<HTMLElement>('.blogeditor__outline-panel')!
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('options', () => {
|
||||||
|
it('reads headingLevels and genericAnchors with defaults', () => {
|
||||||
|
expect(readOptions(null)).toEqual({ headingLevels: [2, 3, 4], genericAnchors: expect.arrayContaining(['click here', 'czytaj więcej']) })
|
||||||
|
expect(readOptions('not json').headingLevels).toEqual([2, 3, 4])
|
||||||
|
expect(readOptions(JSON.stringify({ headingLevels: [4, 1, 9, 'x', 2.5, 2], genericAnchors: [' Read More! ', 3] }))).toEqual({
|
||||||
|
headingLevels: [1, 2, 4],
|
||||||
|
genericAnchors: ['read more'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('J1 heading levels', () => {
|
||||||
|
it('offers H2 to H4 and parses an H1 as an H2 while the H1 setting is on', () => {
|
||||||
|
const { el, editor } = mount('<h1 class="lead">Title</h1><p>x</p><h5>Small</h5>', { options: ON })
|
||||||
|
expect(el.querySelector('button[data-command="heading1"]')).toBeNull()
|
||||||
|
expect(button(el, 'heading2')).toBeTruthy()
|
||||||
|
const first = editor.state.doc.firstChild!
|
||||||
|
expect(first.type.name).toBe('heading')
|
||||||
|
expect(first.attrs.level).toBe(2)
|
||||||
|
// Untouched, the value is the exact string (D-03).
|
||||||
|
expect(el.value).toBe('<h1 class="lead">Title</h1><p>x</p><h5>Small</h5>')
|
||||||
|
insertAtBlock(editor, 1, 'y')
|
||||||
|
expect(el.value).toBe('<h1 class="lead">Title</h1><p>yx</p><h5>Small</h5>')
|
||||||
|
insertAtBlock(editor, 0, 'A ')
|
||||||
|
expect(el.value).toBe('<h2 class="lead">A Title</h2><p>yx</p><h5>Small</h5>')
|
||||||
|
expect(editor.can().toggleHeading({ level: 1 as 2 })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers H1 to H4 and keeps an H1 while the H1 setting is off', () => {
|
||||||
|
const { el, editor } = mount('<h1>Title</h1>', { options: OFF })
|
||||||
|
const commands = Array.from(el.querySelectorAll<HTMLButtonElement>('.blogeditor__group button')).map((b) => b.dataset.command)
|
||||||
|
expect(commands.slice(0, 5)).toEqual(['paragraph', 'heading1', 'heading2', 'heading3', 'heading4'])
|
||||||
|
expect(button(el, 'heading1').getAttribute('aria-label')).toBe('Heading 1')
|
||||||
|
insertAtBlock(editor, 0, 'A ')
|
||||||
|
expect(el.value).toBe('<h1>A Title</h1>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('J2 outline', () => {
|
||||||
|
it('starts collapsed, never takes focus and counts headings and problems', () => {
|
||||||
|
const { el } = mount('<h3>Deep</h3><h2></h2><h2><a href="/x">Only link</a></h2><p><a href="/y">click here</a></p>', { options: ON })
|
||||||
|
const toggle = outlineToggle(el)
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||||
|
expect(document.activeElement).not.toBe(toggle)
|
||||||
|
expect(el.querySelector<HTMLElement>('.blogeditor__outline-panel')!.hidden).toBe(true)
|
||||||
|
expect(toggle.textContent).toBe('Outline · 3 headings4 problems')
|
||||||
|
const panel = openOutline(el)
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||||
|
const items = Array.from(panel.querySelectorAll('li'))
|
||||||
|
expect(items.map((li) => li.querySelector('.blogeditor__outline-level')!.textContent)).toEqual(['H3', 'H2', 'H2'])
|
||||||
|
expect(items[0]!.textContent).toContain('Skipped level: H1 → H3')
|
||||||
|
expect(items[1]!.textContent).toContain('Empty heading')
|
||||||
|
expect(items[2]!.textContent).toContain('Heading is only a link')
|
||||||
|
expect(items[0]!.style.getPropertyValue('--blogeditor-outline-depth')).toBe('1')
|
||||||
|
expect(panel.querySelector('.blogeditor__outline-links')!.textContent).toBe('LinksLink text “click here” does not say where it leads')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows outlineEmpty without headings and uses Polish plurals', () => {
|
||||||
|
const { el } = mount('<p>Tekst</p>', { options: ON, 'ui-locale': 'pl' })
|
||||||
|
expect(outlineToggle(el).textContent).toBe('Konspekt · 0 nagłówków')
|
||||||
|
expect(openOutline(el).textContent).toBe('Brak nagłówków. Nagłówki dzielą tekst na części dla czytelników i wyszukiwarek.')
|
||||||
|
const pl = labelsFor('pl')
|
||||||
|
expect(plural(pl, pl.outlineCount, 1)).toBe('1 nagłówek')
|
||||||
|
expect(plural(pl, pl.outlineCount, 3)).toBe('3 nagłówki')
|
||||||
|
expect(plural(pl, pl.outlineCount, 5)).toBe('5 nagłówków')
|
||||||
|
expect(plural(pl, pl.outlineProblems, 22)).toBe('22 problemy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recomputes 300 ms after a change and flags an H1 in the Source tab while the setting is on', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const { el, editor, changes } = mount('<h2>One</h2>', { options: ON })
|
||||||
|
openOutline(el)
|
||||||
|
insertAtBlock(editor, 0, 'x')
|
||||||
|
expect(outlineToggle(el).textContent).toBe('Outline · 1 heading')
|
||||||
|
el.showSource(true)
|
||||||
|
fill(el.querySelector('textarea'), '<h1>Top</h1><h2>Two</h2>')
|
||||||
|
vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS - 1)
|
||||||
|
expect(outlineToggle(el).textContent).toBe('Outline · 1 heading')
|
||||||
|
vi.advanceTimersByTime(1)
|
||||||
|
expect(outlineToggle(el).textContent).toBe('Outline · 2 headings1 problem')
|
||||||
|
expect(el.querySelector('.blogeditor__outline-panel li')!.textContent).toContain('H1 in the text: saved as H2')
|
||||||
|
// The outline never edits: only the two user edits changed the value.
|
||||||
|
expect(changes).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag an H1 while the setting is off', () => {
|
||||||
|
const { el } = mount('<h1>Top</h1><h2>Two</h2>', { options: OFF })
|
||||||
|
el.showSource(true)
|
||||||
|
expect(outlineToggle(el).textContent).toBe('Outline · 2 headings')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('J3 link form', () => {
|
||||||
|
it('offers rel only for absolute http(s) URLs and writes the checked tokens', () => {
|
||||||
|
const { el, editor } = mount('<p>Partner</p>', { options: ON })
|
||||||
|
editor.commands.setTextSelection({ from: 1, to: 8 })
|
||||||
|
click(button(el, 'link'))
|
||||||
|
const linkForm = form(el, 'link')!
|
||||||
|
const box = (token: string) => linkForm.querySelector<HTMLInputElement>(`input[name="rel-${token}"]`)!
|
||||||
|
expect(linkForm.querySelector('legend')!.textContent).toBe('Link attributes')
|
||||||
|
expect(box('nofollow').disabled).toBe(true)
|
||||||
|
expect(linkForm.querySelector<HTMLElement>('.blogeditor__hint')!.hidden).toBe(false)
|
||||||
|
fill(linkForm.querySelector('input[name="url"]'), 'https://partner.test/')
|
||||||
|
expect(box('nofollow').disabled).toBe(false)
|
||||||
|
expect(linkForm.querySelector<HTMLElement>('.blogeditor__hint')!.hidden).toBe(true)
|
||||||
|
box('nofollow').checked = true
|
||||||
|
box('sponsored').checked = true
|
||||||
|
click(linkForm.querySelector('[data-action="apply"]'))
|
||||||
|
expect(el.value).toBe('<p><a rel="nofollow sponsored" href="https://partner.test/">Partner</a></p>')
|
||||||
|
|
||||||
|
editor.commands.setTextSelection(3)
|
||||||
|
click(button(el, 'link'))
|
||||||
|
const again = form(el, 'link')!
|
||||||
|
expect(again.querySelector<HTMLInputElement>('input[name="rel-sponsored"]')!.checked).toBe(true)
|
||||||
|
fill(again.querySelector('input[name="url"]'), '/blog/partner')
|
||||||
|
click(again.querySelector('[data-action="apply"]'))
|
||||||
|
expect(el.value).toBe('<p><a href="/blog/partner">Partner</a></p>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warns about a generic link text without blocking Apply', () => {
|
||||||
|
const { el, editor } = mount('<p>Click here!</p>', { options: ON })
|
||||||
|
editor.commands.setTextSelection({ from: 1, to: 12 })
|
||||||
|
click(button(el, 'link'))
|
||||||
|
const linkForm = form(el, 'link')!
|
||||||
|
const warning = linkForm.querySelector<HTMLElement>('[data-warning]')!
|
||||||
|
expect(warning.hidden).toBe(false)
|
||||||
|
expect(warning.getAttribute('aria-live')).toBe('polite')
|
||||||
|
expect(warning.textContent).toBe('“Click here!” does not say where the link leads. Use words that describe the target page.')
|
||||||
|
fill(linkForm.querySelector('input[name="url"]'), '/blog/x')
|
||||||
|
click(linkForm.querySelector('[data-action="apply"]'))
|
||||||
|
expect(el.value).toBe('<p><a href="/blog/x">Click here!</a></p>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats only absolute http(s) URLs as external', () => {
|
||||||
|
for (const url of ['https://a.test', 'HTTP://a.test/x']) expect(isExternalUrl(url), url).toBe(true)
|
||||||
|
for (const url of ['/a', 'a.test', 'mailto:a@b.test', '//a.test', 'https://', '#x']) expect(isExternalUrl(url), url).toBe(false)
|
||||||
|
expect(normalizeAnchor(' Read MORE… ')).toBe('read more')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('J4 image naming', () => {
|
||||||
|
const pick = (el: HTMLElement, files: File[]) => {
|
||||||
|
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||||
|
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
||||||
|
input.dispatchEvent(new Event('change'))
|
||||||
|
}
|
||||||
|
const answer = (el: HTMLElement, detail: Record<string, string>) => el.dispatchEvent(new CustomEvent('summer-upload-result', { detail }))
|
||||||
|
|
||||||
|
it('slugs the file name before the upload (UI-SPEC J4 backstop)', () => {
|
||||||
|
expect(mediaFileName('Lwia Grzywa – w lesie!.JPG')).toBe('lwia-grzywa-w-lesie.jpg')
|
||||||
|
expect(mediaFileName('.png')).toBe('image.png')
|
||||||
|
expect(slugFileStem('')).toBe('image')
|
||||||
|
expect(slugFileStem('ŻÓŁĆ gęślą jaźń')).toBe('zolc-gesla-jazn')
|
||||||
|
expect(slugFileStem('a'.repeat(81))).toHaveLength(80)
|
||||||
|
expect(slugFileStem('a'.repeat(79) + ' b')).toBe('a'.repeat(79))
|
||||||
|
|
||||||
|
const { el, uploads } = mount('', { options: ON })
|
||||||
|
pick(el, [new File([new Uint8Array([255, 216, 255])], 'Lwia Grzywa – w lesie!.JPG', { type: 'image/jpeg' })])
|
||||||
|
const imageForm = form(el, 'image')!
|
||||||
|
const name = imageForm.querySelector<HTMLInputElement>('input[name="fileName"]')!
|
||||||
|
expect(name.value).toBe('lwia-grzywa-w-lesie')
|
||||||
|
expect(imageForm.querySelector('.blogeditor__ext')!.textContent).toBe('.jpg')
|
||||||
|
fill(name, 'Lew w Lesie')
|
||||||
|
name.dispatchEvent(new Event('blur'))
|
||||||
|
expect(name.value).toBe('lew-w-lesie')
|
||||||
|
chooseImage(el, { alt: 'Lew' })
|
||||||
|
expect(uploads[0]!.file.name).toBe('lew-w-lesie.jpg')
|
||||||
|
expect(uploads[0]!.file.type).toBe('image/jpeg')
|
||||||
|
expect(status(el)).toBe('Uploading lew-w-lesie.jpg…')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts alt="" for a decorative image', () => {
|
||||||
|
const { el, uploads } = mount('', { options: ON })
|
||||||
|
pick(el, [new File([new Uint8Array([137, 80, 78, 71])], 'kreska.png', { type: 'image/png' })])
|
||||||
|
const imageForm = form(el, 'image')!
|
||||||
|
const alt = imageForm.querySelector<HTMLInputElement>('input[name="alt"]')!
|
||||||
|
fill(alt, 'temporary')
|
||||||
|
chooseImage(el, { decorative: true })
|
||||||
|
expect(alt.value).toBe('')
|
||||||
|
expect(alt.disabled).toBe(true)
|
||||||
|
answer(el, { id: uploads[0]!.id, url: '/kreska.png' })
|
||||||
|
expect(el.value).toBe('<p><img src="/kreska.png" alt=""></p>')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -32,15 +32,18 @@ function startBlock(editor: Editor): void {
|
|||||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
|
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** link types text and links it through setLink, then leaves the link. */
|
/** link types text and links it through setLink (with rel, as the link form writes it), then leaves the link. */
|
||||||
function link(editor: Editor, text: string, href: string): void {
|
function link(editor: Editor, text: string, href: string, rel?: string): void {
|
||||||
const from = editor.state.selection.from
|
const from = editor.state.selection.from
|
||||||
type(editor, text)
|
type(editor, text)
|
||||||
const to = editor.state.selection.from
|
const to = editor.state.selection.from
|
||||||
expect(editor.chain().setTextSelection({ from, to }).setLink({ href }).run(), href).toBe(true)
|
expect(editor.chain().setTextSelection({ from, to }).setLink(rel ? { href, rel } : { href }).run(), href).toBe(true)
|
||||||
editor.chain().setTextSelection(to).unsetMark('link').run()
|
editor.chain().setTextSelection(to).unsetMark('link').run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The rel the link form writes (J3, D-83): the three tokens, external links only. */
|
||||||
|
const REL_VALUE = /^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$/
|
||||||
|
|
||||||
function mark(editor: Editor, toggle: () => boolean, text: string): void {
|
function mark(editor: Editor, toggle: () => boolean, text: string): void {
|
||||||
expect(toggle()).toBe(true)
|
expect(toggle()).toBe(true)
|
||||||
type(editor, text)
|
type(editor, text)
|
||||||
@@ -153,6 +156,20 @@ const builders: Record<string, (editor: Editor) => void> = {
|
|||||||
expect(editor.commands.setHardBreak()).toBe(true)
|
expect(editor.commands.setHardBreak()).toBe(true)
|
||||||
type(editor, 'Druga linia')
|
type(editor, 'Druga linia')
|
||||||
},
|
},
|
||||||
|
// J3: an external link with the rel tokens of the link form (D-83).
|
||||||
|
'link-rel': (editor) => {
|
||||||
|
startBlock(editor)
|
||||||
|
type(editor, 'Partner: ')
|
||||||
|
link(editor, 'sklep', 'https://partner.example.test/', 'nofollow sponsored')
|
||||||
|
type(editor, ', ')
|
||||||
|
link(editor, 'forum', 'https://forum.example.test/watek', 'ugc')
|
||||||
|
type(editor, '.')
|
||||||
|
},
|
||||||
|
// J4: a decorative image carries alt="" (D-85).
|
||||||
|
'image-decorative': (editor) => {
|
||||||
|
startBlock(editor)
|
||||||
|
expect(editor.commands.setImage({ src: IMAGE_SRC, alt: '' })).toBe(true)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function build(steps: ((editor: Editor) => void)[]): string {
|
function build(steps: ((editor: Editor) => void)[]): string {
|
||||||
@@ -184,11 +201,11 @@ describe('editor corpus (D-05)', () => {
|
|||||||
expect(readFileSync(CORPUS, 'utf8')).toBe(text)
|
expect(readFileSync(CORPUS, 'utf8')).toBe(text)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('holds no iframe, handler, target, rel or style and only policy URLs', () => {
|
it('holds no iframe, handler, target or style, rel only on external links, and only policy URLs', () => {
|
||||||
expect(entries.length).toBeGreaterThanOrEqual(15)
|
expect(entries.length).toBeGreaterThanOrEqual(15)
|
||||||
const doc = document.implementation.createHTMLDocument('')
|
const doc = document.implementation.createHTMLDocument('')
|
||||||
for (const { name, html } of entries) {
|
for (const { name, html } of entries) {
|
||||||
expect(html, name).not.toMatch(/<iframe|target=|rel=| on[a-z]+=|style=/i)
|
expect(html, name).not.toMatch(/<iframe|target=| on[a-z]+=|style=/i)
|
||||||
expect(html, name).not.toBe('')
|
expect(html, name).not.toBe('')
|
||||||
expect(html, name).not.toMatch(/<p><\/p>$/)
|
expect(html, name).not.toMatch(/<p><\/p>$/)
|
||||||
const host = doc.createElement('div')
|
const host = doc.createElement('div')
|
||||||
@@ -198,7 +215,14 @@ describe('editor corpus (D-05)', () => {
|
|||||||
expect(isPolicyUrl(url), `${name}: ${url}`).toBe(true)
|
expect(isPolicyUrl(url), `${name}: ${url}`).toBe(true)
|
||||||
}
|
}
|
||||||
for (const a of Array.from(host.querySelectorAll('a'))) {
|
for (const a of Array.from(host.querySelectorAll('a'))) {
|
||||||
|
const rel = a.getAttribute('rel')
|
||||||
|
if (rel === null) {
|
||||||
expect(a.getAttributeNames(), name).toEqual(['href'])
|
expect(a.getAttributeNames(), name).toEqual(['href'])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expect(a.getAttributeNames().sort(), name).toEqual(['href', 'rel'])
|
||||||
|
expect(rel, name).toMatch(REL_VALUE)
|
||||||
|
expect(a.getAttribute('href'), name).toMatch(/^https?:\/\//)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const html = entries.map((e) => e.html).join('\n')
|
const html = entries.map((e) => e.html).join('\n')
|
||||||
@@ -206,5 +230,7 @@ describe('editor corpus (D-05)', () => {
|
|||||||
expect(html).toContain('<figcaption>')
|
expect(html).toContain('<figcaption>')
|
||||||
expect(html).toContain('<table>')
|
expect(html).toContain('<table>')
|
||||||
expect(html).toContain('start="3"')
|
expect(html).toContain('start="3"')
|
||||||
|
expect(html).toContain('rel="nofollow sponsored"')
|
||||||
|
expect(html).toContain('alt=""')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { EMBED_PROVIDERS, isEmbedProvider, parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
import { EMBED_PROVIDERS, isEmbedProvider, parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
||||||
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
||||||
import { format, labelsFor } from '../../assets/src/blogeditor/labels'
|
import { format, labelsFor } from '../../assets/src/blogeditor/labels'
|
||||||
import { button, click, fill, form, mount, status } from './helpers'
|
import { button, chooseImage, click, fill, form, mount, status } from './helpers'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
@@ -179,33 +179,36 @@ describe('upload flow', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('ignores an answer with an id it did not send', () => {
|
it('ignores an answer with an id it did not send', () => {
|
||||||
const { el, uploads, changes } = mount('<p>a</p>')
|
const { el, uploads } = mount('<p>a</p>')
|
||||||
pick(el, [png()])
|
pick(el, [png()])
|
||||||
|
expect(uploads).toHaveLength(0)
|
||||||
|
chooseImage(el, { alt: 'Grzyb' })
|
||||||
answer(el, { id: 'not-ours', url: '/x.png' })
|
answer(el, { id: 'not-ours', url: '/x.png' })
|
||||||
answer(el, { id: 'not-ours', error: 'nope' })
|
answer(el, { id: 'not-ours', error: 'nope' })
|
||||||
expect(form(el, 'image')).toBeNull()
|
|
||||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||||
|
expect(el.value).toBe('<p>a</p>')
|
||||||
answer(el, { id: uploads[0]!.id, url: '/ours.png' })
|
answer(el, { id: uploads[0]!.id, url: '/ours.png' })
|
||||||
expect(form(el, 'image')).not.toBeNull()
|
expect(el.value).toContain('<img src="/ours.png" alt="Grzyb">')
|
||||||
expect(changes).toEqual([])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('times out after 60 seconds and ignores the late answer', () => {
|
it('times out after 60 seconds and ignores the late answer', () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const { el, uploads } = mount('')
|
const { el, uploads } = mount('')
|
||||||
pick(el, [png()])
|
pick(el, [png()])
|
||||||
|
chooseImage(el, { alt: 'Grzyb' })
|
||||||
vi.advanceTimersByTime(59_999)
|
vi.advanceTimersByTime(59_999)
|
||||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||||
vi.advanceTimersByTime(1)
|
vi.advanceTimersByTime(1)
|
||||||
expect(status(el)).toBe('The upload of grzyb.png took too long.')
|
expect(status(el)).toBe('The upload of grzyb.png took too long.')
|
||||||
answer(el, { id: uploads[0]!.id, url: '/late.png' })
|
answer(el, { id: uploads[0]!.id, url: '/late.png' })
|
||||||
expect(form(el, 'image')).toBeNull()
|
|
||||||
expect(el.value).toBe('')
|
expect(el.value).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('answers each of several uploads by its own id', () => {
|
it('answers each of several uploads by its own id', () => {
|
||||||
const { el, uploads } = mount('')
|
const { el, uploads } = mount('')
|
||||||
pick(el, [png(), new File([new Uint8Array([255, 216, 255])], 'b.jpg', { type: 'image/jpeg' })])
|
pick(el, [png(), new File([new Uint8Array([255, 216, 255])], 'b.jpg', { type: 'image/jpeg' })])
|
||||||
|
chooseImage(el, { alt: 'A' })
|
||||||
|
chooseImage(el, { alt: 'B' })
|
||||||
expect(uploads).toHaveLength(2)
|
expect(uploads).toHaveLength(2)
|
||||||
expect(uploads[0]!.id).not.toBe(uploads[1]!.id)
|
expect(uploads[0]!.id).not.toBe(uploads[1]!.id)
|
||||||
answer(el, { id: uploads[1]!.id, error: 'Too big' })
|
answer(el, { id: uploads[1]!.id, error: 'Too big' })
|
||||||
@@ -215,6 +218,7 @@ describe('upload flow', () => {
|
|||||||
it('refuses an uploaded URL outside the policy', () => {
|
it('refuses an uploaded URL outside the policy', () => {
|
||||||
const { el, uploads } = mount('')
|
const { el, uploads } = mount('')
|
||||||
pick(el, [png()])
|
pick(el, [png()])
|
||||||
|
chooseImage(el, { alt: 'Grzyb' })
|
||||||
answer(el, { id: uploads[0]!.id, url: 'javascript:alert(1)' })
|
answer(el, { id: uploads[0]!.id, url: 'javascript:alert(1)' })
|
||||||
expect(form(el, 'image')).toBeNull()
|
expect(form(el, 'image')).toBeNull()
|
||||||
expect(status(el)).toMatch(/^Upload failed/)
|
expect(status(el)).toMatch(/^Upload failed/)
|
||||||
|
|||||||
@@ -55,8 +55,16 @@
|
|||||||
"name": "hard-break",
|
"name": "hard-break",
|
||||||
"html": "<p>Pierwsza linia<br>Druga linia</p>"
|
"html": "<p>Pierwsza linia<br>Druga linia</p>"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "link-rel",
|
||||||
|
"html": "<p>Partner: <a rel=\"nofollow sponsored\" href=\"https://partner.example.test/\">sklep</a>, <a rel=\"ugc\" href=\"https://forum.example.test/watek\">forum</a>.</p>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image-decorative",
|
||||||
|
"html": "<p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"\"></p>"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "combined",
|
"name": "combined",
|
||||||
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p>"
|
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to "grzyb odporności"?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p><p>Partner: <a rel=\"nofollow sponsored\" href=\"https://partner.example.test/\">sklep</a>, <a rel=\"ugc\" href=\"https://forum.example.test/watek\">forum</a>.</p><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"\"></p>"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
116
tests/blogeditor/forms.test.ts
Normal file
116
tests/blogeditor/forms.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { mediaFileName, slugFileStem } from '../../assets/src/blogeditor/imageform'
|
||||||
|
import { isExternalUrl } from '../../assets/src/blogeditor/linkform'
|
||||||
|
import { button, chooseImage, click, fill, form, mount } from './helpers'
|
||||||
|
|
||||||
|
// J3 link form and J4 image form (UI-SPEC J3, J4): the rel checkboxes
|
||||||
|
// follow the URL as it is typed, the generic-text warning comes and goes
|
||||||
|
// without blocking, the file-name slug rule's examples, and a decorative
|
||||||
|
// image clears and locks its alt. The first cases are in additions.test.ts.
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
const ON = JSON.stringify({ headingLevels: [2, 3, 4], genericAnchors: ['click here', 'czytaj więcej'] })
|
||||||
|
|
||||||
|
function openLink(text: string) {
|
||||||
|
const m = mount(`<p>${text}</p>`, { options: ON })
|
||||||
|
m.editor.commands.setTextSelection({ from: 1, to: 1 + text.length })
|
||||||
|
click(button(m.el, 'link'))
|
||||||
|
const linkForm = form(m.el, 'link')!
|
||||||
|
const box = (token: string) => linkForm.querySelector<HTMLInputElement>(`input[name="rel-${token}"]`)!
|
||||||
|
return { ...m, linkForm, box, url: linkForm.querySelector<HTMLInputElement>('input[name="url"]')! }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('J3 rel checkboxes', () => {
|
||||||
|
it('enable for an absolute http(s) URL and disable again for an internal one', () => {
|
||||||
|
const { box, url } = openLink('Partner')
|
||||||
|
for (const token of ['nofollow', 'sponsored', 'ugc']) expect(box(token).disabled, token).toBe(true)
|
||||||
|
fill(url, 'https://partner.test')
|
||||||
|
for (const token of ['nofollow', 'sponsored', 'ugc']) expect(box(token).disabled, token).toBe(false)
|
||||||
|
box('ugc').checked = true
|
||||||
|
fill(url, '/blog/partner')
|
||||||
|
expect(box('ugc').disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes only the checked tokens, in order, for an external link', () => {
|
||||||
|
const { el, box, url, linkForm } = openLink('Partner')
|
||||||
|
fill(url, 'https://partner.test/x')
|
||||||
|
box('ugc').checked = true
|
||||||
|
box('nofollow').checked = true
|
||||||
|
click(linkForm.querySelector('[data-action="apply"]'))
|
||||||
|
expect(el.value).toMatch(/^<p><a rel="nofollow ugc" href="https:\/\/partner\.test\/x">Partner<\/a><\/p>$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes no rel for an internal link even when a box was checked', () => {
|
||||||
|
const { el, box, url, linkForm } = openLink('Partner')
|
||||||
|
fill(url, 'https://partner.test')
|
||||||
|
box('sponsored').checked = true
|
||||||
|
fill(url, '/oferta')
|
||||||
|
click(linkForm.querySelector('[data-action="apply"]'))
|
||||||
|
expect(el.value).toBe('<p><a href="/oferta">Partner</a></p>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('J3 generic link text', () => {
|
||||||
|
it('warns for a listed text in any case or punctuation and stays quiet otherwise', () => {
|
||||||
|
const generic = openLink('Czytaj więcej…')
|
||||||
|
expect(generic.linkForm.querySelector<HTMLElement>('[data-warning]')!.hidden).toBe(false)
|
||||||
|
fill(generic.url, '/blog/a')
|
||||||
|
click(generic.linkForm.querySelector('[data-action="apply"]'))
|
||||||
|
expect(generic.el.value).toBe('<p><a href="/blog/a">Czytaj więcej…</a></p>')
|
||||||
|
document.body.replaceChildren()
|
||||||
|
const specific = openLink('Badania nad chagą')
|
||||||
|
expect(specific.linkForm.querySelector<HTMLElement>('[data-warning]')!.hidden).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads external URLs by scheme only', () => {
|
||||||
|
for (const [u, want] of [
|
||||||
|
['https://a.test/x?y=1', true],
|
||||||
|
['http://a.test', true],
|
||||||
|
['/kontakt', false],
|
||||||
|
['./a', false],
|
||||||
|
['ftp://a.test', false],
|
||||||
|
['javascript:alert(1)', false],
|
||||||
|
['', false],
|
||||||
|
] as const) {
|
||||||
|
expect(isExternalUrl(u), u).toBe(want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('J4 file names', () => {
|
||||||
|
it('follows the slug rule examples', () => {
|
||||||
|
for (const [name, want] of [
|
||||||
|
['Chaga – zdjęcie 1.JPEG', 'chaga-zdjecie-1.jpeg'],
|
||||||
|
[' Kurki w koszyku .png', 'kurki-w-koszyku.png'],
|
||||||
|
['IMG_2024.06.01.webp', 'img-2024-06-01.webp'],
|
||||||
|
['___.gif', 'image.gif'],
|
||||||
|
['bez-rozszerzenia', 'bez-rozszerzenia'],
|
||||||
|
] as const) {
|
||||||
|
expect(mediaFileName(name), name).toBe(want)
|
||||||
|
}
|
||||||
|
expect(slugFileStem('Łódź & Kraków')).toBe('lodz-krakow')
|
||||||
|
expect(slugFileStem('--a--b--')).toBe('a-b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears and locks the alt of a decorative image and frees it again', () => {
|
||||||
|
const { el } = mount('', { options: ON })
|
||||||
|
const input = el.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||||
|
Object.defineProperty(input, 'files', { value: [new File([new Uint8Array([137, 80, 78, 71])], 'linia.png', { type: 'image/png' })], configurable: true })
|
||||||
|
input.dispatchEvent(new Event('change'))
|
||||||
|
const imageForm = form(el, 'image')!
|
||||||
|
const alt = imageForm.querySelector<HTMLInputElement>('input[name="alt"]')!
|
||||||
|
const decorative = imageForm.querySelector<HTMLInputElement>('input[name="decorative"]')!
|
||||||
|
fill(alt, 'Linia')
|
||||||
|
decorative.checked = true
|
||||||
|
decorative.dispatchEvent(new Event('change'))
|
||||||
|
expect(alt.value).toBe('')
|
||||||
|
expect(alt.disabled).toBe(true)
|
||||||
|
decorative.checked = false
|
||||||
|
decorative.dispatchEvent(new Event('change'))
|
||||||
|
expect(alt.disabled).toBe(false)
|
||||||
|
chooseImage(el, { alt: 'Linia' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -76,3 +76,21 @@ export function fill(input: Element | null | undefined, value: string): void {
|
|||||||
export function status(el: HTMLElement): string {
|
export function status(el: HTMLElement): string {
|
||||||
return el.querySelector('[role="status"]')?.textContent ?? ''
|
return el.querySelector('[role="status"]')?.textContent ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* chooseImage fills the open J4 image form (alt, caption, decorative, file
|
||||||
|
* name) and presses Insert, which uploads the file.
|
||||||
|
*/
|
||||||
|
export function chooseImage(el: HTMLElement, choice: { alt?: string; caption?: string; decorative?: boolean; fileName?: string } = {}): void {
|
||||||
|
const imageForm = form(el, 'image')
|
||||||
|
if (!imageForm) throw new Error('the image form is not open')
|
||||||
|
if (choice.fileName !== undefined) fill(imageForm.querySelector('input[name="fileName"]'), choice.fileName)
|
||||||
|
if (choice.decorative) {
|
||||||
|
const box = imageForm.querySelector<HTMLInputElement>('input[name="decorative"]')!
|
||||||
|
box.checked = true
|
||||||
|
box.dispatchEvent(new Event('change', { bubbles: true }))
|
||||||
|
}
|
||||||
|
if (choice.alt !== undefined) fill(imageForm.querySelector('input[name="alt"]'), choice.alt)
|
||||||
|
if (choice.caption !== undefined) fill(imageForm.querySelector('input[name="caption"]'), choice.caption)
|
||||||
|
click(imageForm.querySelector('[data-action="insert"]'))
|
||||||
|
}
|
||||||
|
|||||||
92
tests/blogeditor/outline.test.ts
Normal file
92
tests/blogeditor/outline.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { labelsFor } from '../../assets/src/blogeditor/labels'
|
||||||
|
import { factsFromEditor, factsFromSource, headingFlags, isGenericAnchor, normalizeAnchor, OUTLINE_DEBOUNCE_MS } from '../../assets/src/blogeditor/outline'
|
||||||
|
import { fill, insertAtBlock, mount } from './helpers'
|
||||||
|
|
||||||
|
// J2 outline (UI-SPEC J2): the facts read from the editor and from the
|
||||||
|
// Source tab, the flags per heading and the debounce. The rendered panel
|
||||||
|
// is in additions.test.ts.
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
document.body.replaceChildren()
|
||||||
|
})
|
||||||
|
|
||||||
|
const en = labelsFor('en')
|
||||||
|
const ON = JSON.stringify({ headingLevels: [2, 3, 4], genericAnchors: ['click here', 'more'] })
|
||||||
|
const OFF = JSON.stringify({ headingLevels: [1, 2, 3, 4], genericAnchors: ['click here'] })
|
||||||
|
|
||||||
|
describe('outline facts', () => {
|
||||||
|
it('reads headings and link texts from HTML source', () => {
|
||||||
|
const facts = factsFromSource('<h2> Intro text </h2><h4><a href="/a">Only</a> <a href="/b">links</a></h4><h3></h3><p>See <a href="/c">click here</a> and <a href="/d"> </a></p>')
|
||||||
|
expect(facts.headings).toEqual([
|
||||||
|
{ level: 2, text: 'Intro text', linkOnly: false, pos: -1 },
|
||||||
|
{ level: 4, text: 'Only links', linkOnly: true, pos: -1 },
|
||||||
|
{ level: 3, text: '', linkOnly: false, pos: -1 },
|
||||||
|
])
|
||||||
|
expect(facts.links).toEqual(['Only', 'links', 'click here'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the same facts from the editor document, adjacent marks of one link joined', () => {
|
||||||
|
const { editor } = mount('<h2>Intro</h2><h3><a href="/x">All link</a></h3><p><a href="/y">click </a><a href="/y"><strong>here</strong></a> <a href="/z">more</a></p>', { options: ON })
|
||||||
|
const facts = factsFromEditor(editor)
|
||||||
|
expect(facts.headings.map((h) => [h.level, h.text, h.linkOnly])).toEqual([
|
||||||
|
[2, 'Intro', false],
|
||||||
|
[3, 'All link', true],
|
||||||
|
])
|
||||||
|
expect(facts.headings.every((h) => h.pos >= 0)).toBe(true)
|
||||||
|
// A link inside a heading is a link text too.
|
||||||
|
expect(facts.links).toEqual(['All link', 'click here', 'more'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('heading flags', () => {
|
||||||
|
const facts = factsFromSource('<h1>Top</h1><h3>Skip</h3><h2></h2><h2><a href="/x">Link</a></h2><h4>Deep</h4>')
|
||||||
|
|
||||||
|
it('flags skipped levels from the page title H1, empty and link-only headings', () => {
|
||||||
|
const flags = headingFlags(facts, en, { allowsH1: true, source: true })
|
||||||
|
expect(flags[0]).toEqual([])
|
||||||
|
expect(flags[1]).toEqual(['Skipped level: H1 → H3'])
|
||||||
|
expect(flags[2]).toEqual([en.flagEmpty])
|
||||||
|
expect(flags[3]).toEqual([en.flagLinkOnly])
|
||||||
|
expect(flags[4]).toEqual(['Skipped level: H2 → H4'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags an H1 only in the Source tab while level 1 is not offered', () => {
|
||||||
|
expect(headingFlags(facts, en, { allowsH1: false, source: true })[0]).toEqual([en.flagH1])
|
||||||
|
expect(headingFlags(facts, en, { allowsH1: false, source: false })[0]).toEqual([])
|
||||||
|
expect(headingFlags(facts, en, { allowsH1: true, source: true })[0]).toEqual([])
|
||||||
|
expect(headingFlags({ headings: [], links: [] }, en, { allowsH1: false, source: true })).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generic link texts', () => {
|
||||||
|
it('normalises and matches the generic list', () => {
|
||||||
|
expect(normalizeAnchor(' Kliknij TUTAJ!!! ')).toBe('kliknij tutaj')
|
||||||
|
expect(normalizeAnchor('»Więcej«')).toBe('więcej')
|
||||||
|
expect(isGenericAnchor('Click here.', ['click here'])).toBe(true)
|
||||||
|
expect(isGenericAnchor('Click here to read the study', ['click here'])).toBe(false)
|
||||||
|
expect(isGenericAnchor('!!!', [''])).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('outline timing', () => {
|
||||||
|
it('recomputes the Source tab facts only after the 300 ms debounce and never edits the value', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
expect(OUTLINE_DEBOUNCE_MS).toBe(300)
|
||||||
|
const { el, editor, changes } = mount('<h2>One</h2>', { options: OFF })
|
||||||
|
const toggle = el.querySelector<HTMLButtonElement>('.blogeditor__outline-toggle')!
|
||||||
|
insertAtBlock(editor, 0, 'a')
|
||||||
|
vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS)
|
||||||
|
el.showSource(true)
|
||||||
|
fill(el.querySelector('textarea'), '<h2>One</h2><h2>Two</h2><h2>Three</h2>')
|
||||||
|
vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS - 50)
|
||||||
|
expect(toggle.textContent).toBe('Outline · 1 heading')
|
||||||
|
fill(el.querySelector('textarea'), '<h2>One</h2><h2>Two</h2>')
|
||||||
|
vi.advanceTimersByTime(OUTLINE_DEBOUNCE_MS - 50)
|
||||||
|
expect(toggle.textContent).toBe('Outline · 1 heading')
|
||||||
|
vi.advanceTimersByTime(50)
|
||||||
|
expect(toggle.textContent).toBe('Outline · 2 headings')
|
||||||
|
expect(changes).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import { BlogEditorElement, TAG } from '../../assets/src/blogeditor/index'
|
|||||||
import { uploadRequestId } from '../../assets/src/blogeditor/element'
|
import { uploadRequestId } from '../../assets/src/blogeditor/element'
|
||||||
import { parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
import { parseEmbedUrl } from '../../assets/src/blogeditor/embed'
|
||||||
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
import { isPolicyUrl } from '../../assets/src/blogeditor/extensions'
|
||||||
import { button, click, fill, form, insertAtBlock, insertAtEveryTextblock, mount, status } from './helpers'
|
import { button, chooseImage, click, fill, form, insertAtBlock, insertAtEveryTextblock, mount, status } from './helpers'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
@@ -320,11 +320,12 @@ describe('image uploads', () => {
|
|||||||
const { el, uploads } = mount('')
|
const { el, uploads } = mount('')
|
||||||
click(button(el, 'image'))
|
click(button(el, 'image'))
|
||||||
pick(el, [png()])
|
pick(el, [png()])
|
||||||
|
chooseImage(el, { alt: 'Grzyb' })
|
||||||
expect(uploads).toHaveLength(1)
|
expect(uploads).toHaveLength(1)
|
||||||
expect(uploads[0]!.id).toMatch(uuid)
|
expect(uploads[0]!.id).toMatch(uuid)
|
||||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||||
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
|
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
|
||||||
expect(form(el, 'image')).not.toBeNull()
|
expect(el.value).toContain('grzyb.png')
|
||||||
|
|
||||||
vi.stubGlobal('crypto', undefined)
|
vi.stubGlobal('crypto', undefined)
|
||||||
const plain = [uploadRequestId(), uploadRequestId()]
|
const plain = [uploadRequestId(), uploadRequestId()]
|
||||||
@@ -336,44 +337,45 @@ describe('image uploads', () => {
|
|||||||
expect(globalThis.crypto).toBe(real)
|
expect(globalThis.crypto).toBe(real)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends a picked image as summer-upload and inserts img with alt text', () => {
|
it('opens the image form for a picked image and uploads it on Insert with the alt text (J4)', () => {
|
||||||
const { el, uploads } = mount('')
|
const { el, uploads } = mount('')
|
||||||
click(button(el, 'image'))
|
click(button(el, 'image'))
|
||||||
pick(el, [png()])
|
pick(el, [png()])
|
||||||
|
expect(uploads).toHaveLength(0)
|
||||||
|
const imageForm = form(el, 'image')!
|
||||||
|
expect((imageForm.querySelector('input[name="fileName"]') as HTMLInputElement).value).toBe('grzyb')
|
||||||
|
expect(imageForm.querySelector('.blogeditor__ext')!.textContent).toBe('.png')
|
||||||
|
click(imageForm.querySelector('[data-action="insert"]'))
|
||||||
|
expect(imageForm.querySelector('[data-error]')!.textContent).toBe('Enter the alt text, or mark the image as decorative.')
|
||||||
|
expect(uploads).toHaveLength(0)
|
||||||
|
|
||||||
|
chooseImage(el, { alt: 'Grzyb: czy to "Turkey Tail"?' })
|
||||||
|
expect(form(el, 'image')).toBeNull()
|
||||||
expect(uploads).toHaveLength(1)
|
expect(uploads).toHaveLength(1)
|
||||||
expect(uploads[0]!.file.name).toBe('grzyb.png')
|
expect(uploads[0]!.file.name).toBe('grzyb.png')
|
||||||
expect(uploads[0]!.id).toMatch(/[0-9a-f-]{36}/)
|
expect(uploads[0]!.id).toMatch(/[0-9a-f-]{36}/)
|
||||||
expect(status(el)).toBe('Uploading grzyb.png…')
|
expect(status(el)).toBe('Uploading grzyb.png…')
|
||||||
|
|
||||||
answer(el, { id: 'someone-else', url: '/x.png' })
|
|
||||||
expect(form(el, 'image')).toBeNull()
|
|
||||||
|
|
||||||
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
|
|
||||||
const imageForm = form(el, 'image')!
|
|
||||||
click(imageForm.querySelector('[data-action="insert"]'))
|
|
||||||
expect(imageForm.querySelector('[data-error]')!.textContent).toBe('Enter the alt text.')
|
|
||||||
expect(el.value).toBe('')
|
expect(el.value).toBe('')
|
||||||
|
|
||||||
fill(imageForm.querySelector('input[name="alt"]'), 'Grzyb: czy to "Turkey Tail"?')
|
answer(el, { id: 'someone-else', url: '/x.png' })
|
||||||
click(imageForm.querySelector('[data-action="insert"]'))
|
expect(el.value).toBe('')
|
||||||
expect(form(el, 'image')).toBeNull()
|
answer(el, { id: uploads[0]!.id, url: '/storage/app/uploads/public/journal/grzyb.png' })
|
||||||
|
expect(status(el)).toBe('')
|
||||||
expect(el.value).toBe('<p><img src="/storage/app/uploads/public/journal/grzyb.png" alt="Grzyb: czy to "Turkey Tail"?"></p>')
|
expect(el.value).toBe('<p><img src="/storage/app/uploads/public/journal/grzyb.png" alt="Grzyb: czy to "Turkey Tail"?"></p>')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('inserts figure with figcaption when a caption is given', () => {
|
it('inserts figure with figcaption when a caption is given', () => {
|
||||||
const { el, uploads } = mount('')
|
const { el, uploads } = mount('')
|
||||||
pick(el, [png()])
|
pick(el, [png()])
|
||||||
|
chooseImage(el, { alt: 'Owocniki', caption: 'Owocniki na pniu.' })
|
||||||
answer(el, { id: uploads[0]!.id, url: '/a.png' })
|
answer(el, { id: uploads[0]!.id, url: '/a.png' })
|
||||||
const imageForm = form(el, 'image')!
|
|
||||||
fill(imageForm.querySelector('input[name="alt"]'), 'Owocniki')
|
|
||||||
fill(imageForm.querySelector('input[name="caption"]'), 'Owocniki na pniu.')
|
|
||||||
click(imageForm.querySelector('[data-action="insert"]'))
|
|
||||||
expect(el.value).toBe('<figure><img src="/a.png" alt="Owocniki"><figcaption>Owocniki na pniu.</figcaption></figure>')
|
expect(el.value).toBe('<figure><img src="/a.png" alt="Owocniki"><figcaption>Owocniki na pniu.</figcaption></figure>')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows an upload error and inserts nothing', () => {
|
it('shows an upload error and inserts nothing', () => {
|
||||||
const { el, uploads, changes } = mount('<p>a</p>')
|
const { el, uploads, changes } = mount('<p>a</p>')
|
||||||
pick(el, [png()])
|
pick(el, [png()])
|
||||||
|
chooseImage(el, { alt: 'Grzyb' })
|
||||||
answer(el, { id: uploads[0]!.id, error: 'File too large' })
|
answer(el, { id: uploads[0]!.id, error: 'File too large' })
|
||||||
expect(form(el, 'image')).toBeNull()
|
expect(form(el, 'image')).toBeNull()
|
||||||
expect(status(el)).toBe('Upload failed: File too large')
|
expect(status(el)).toBe('Upload failed: File too large')
|
||||||
@@ -385,13 +387,14 @@ describe('image uploads', () => {
|
|||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const { el, uploads } = mount('')
|
const { el, uploads } = mount('')
|
||||||
pick(el, [png()])
|
pick(el, [png()])
|
||||||
|
chooseImage(el, { alt: 'Grzyb' })
|
||||||
vi.advanceTimersByTime(60_000)
|
vi.advanceTimersByTime(60_000)
|
||||||
expect(status(el)).toBe('The upload of grzyb.png took too long.')
|
expect(status(el)).toBe('The upload of grzyb.png took too long.')
|
||||||
answer(el, { id: uploads[0]!.id, url: '/late.png' })
|
answer(el, { id: uploads[0]!.id, url: '/late.png' })
|
||||||
expect(form(el, 'image')).toBeNull()
|
expect(el.value).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uploads image files dropped on the editor', () => {
|
it('opens the image form for image files dropped on the editor', () => {
|
||||||
const { el, editor, uploads } = mount('<p>a</p>')
|
const { el, editor, uploads } = mount('<p>a</p>')
|
||||||
let prevented = false
|
let prevented = false
|
||||||
const event = {
|
const event = {
|
||||||
@@ -403,6 +406,8 @@ describe('image uploads', () => {
|
|||||||
const handled = editor.view.someProp('handleDrop', (f) => f(editor.view, event, editor.state.doc.slice(0, 0), false))
|
const handled = editor.view.someProp('handleDrop', (f) => f(editor.view, event, editor.state.doc.slice(0, 0), false))
|
||||||
expect(handled).toBe(true)
|
expect(handled).toBe(true)
|
||||||
expect(prevented).toBe(true)
|
expect(prevented).toBe(true)
|
||||||
|
expect(form(el, 'image')).not.toBeNull()
|
||||||
|
chooseImage(el, { alt: 'Grzyb' })
|
||||||
expect(uploads.map((u) => u.file.name)).toEqual(['grzyb.png'])
|
expect(uploads.map((u) => u.file.name)).toEqual(['grzyb.png'])
|
||||||
expect(el.value).toBe('<p>a</p>')
|
expect(el.value).toBe('<p>a</p>')
|
||||||
})
|
})
|
||||||
|
|||||||
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addSettingsHeadings adds the Headings and links settings to the
|
||||||
|
// golem15_journal_settings row: content_h1_to_h2 (on by default, so a site
|
||||||
|
// keeps converting H1 in Journal rich text to H2) and generic_anchors (the
|
||||||
|
// link texts the editor warns about, one per line; NULL means the built-in
|
||||||
|
// lists). Adding the columns rewrites no stored content.
|
||||||
|
var addSettingsHeadings = []*gormigrate.Migration{
|
||||||
|
{
|
||||||
|
ID: "202610280001_add_golem15_journal_settings_headings",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`ALTER TABLE golem15_journal_settings ADD COLUMN content_h1_to_h2 BOOLEAN NOT NULL DEFAULT TRUE`,
|
||||||
|
`ALTER TABLE golem15_journal_settings ADD COLUMN generic_anchors TEXT NULL`,
|
||||||
|
} {
|
||||||
|
if err := tx.Exec(stmt).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`ALTER TABLE golem15_journal_settings DROP COLUMN IF EXISTS generic_anchors`,
|
||||||
|
`ALTER TABLE golem15_journal_settings DROP COLUMN IF EXISTS content_h1_to_h2`,
|
||||||
|
} {
|
||||||
|
if err := tx.Exec(stmt).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(addSettingsHeadings...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createImageVariants creates golem15_journal_image_variants (P33-43): one
|
||||||
|
// row per featured image and width with the thumbnail's key, its WebP
|
||||||
|
// copy's key and the original's size. file_id names a system_files row
|
||||||
|
// without a foreign key (the plugin's migrations run without the framework
|
||||||
|
// tables in its tests); the API deletes the rows with the file, and rows
|
||||||
|
// of a file removed elsewhere are never listed.
|
||||||
|
var createImageVariants = []*gormigrate.Migration{
|
||||||
|
{
|
||||||
|
ID: "202610280002_create_golem15_journal_image_variants",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`CREATE TABLE golem15_journal_image_variants (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
file_id BIGINT NOT NULL,
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
webp_key TEXT NOT NULL,
|
||||||
|
source_width INTEGER NOT NULL,
|
||||||
|
source_height INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) NULL
|
||||||
|
)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec(`CREATE UNIQUE INDEX golem15_journal_image_variants_file_width ON golem15_journal_image_variants (file_id, width)`).Error
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
return tx.Exec(`DROP TABLE IF EXISTS golem15_journal_image_variants`).Error
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(createImageVariants...)
|
||||||
|
}
|
||||||
@@ -28,9 +28,20 @@ func TestCategoryContentMigration(t *testing.T) {
|
|||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
all := All()
|
// The content column's step and the one before it: later migrations
|
||||||
if last := all[len(all)-1].ID; last != "202610090001_add_content_to_golem15_journal_categories" {
|
// are rolled back with it, so the test does not depend on being last.
|
||||||
t.Fatalf("last migration = %s, want 202610090001_add_content_to_golem15_journal_categories", last)
|
const contentStep = "202610090001_add_content_to_golem15_journal_categories"
|
||||||
|
previous := ""
|
||||||
|
for i, mig := range All() {
|
||||||
|
if mig.ID == contentStep {
|
||||||
|
if i == 0 {
|
||||||
|
t.Fatalf("%s is the first migration", contentStep)
|
||||||
|
}
|
||||||
|
previous = All()[i-1].ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if previous == "" {
|
||||||
|
t.Fatalf("migration %s is not registered", contentStep)
|
||||||
}
|
}
|
||||||
if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") {
|
||||||
t.Fatal("golem15_journal_categories.content missing")
|
t.Fatal("golem15_journal_categories.content missing")
|
||||||
@@ -49,8 +60,8 @@ func TestCategoryContentMigration(t *testing.T) {
|
|||||||
if err := gdb.Exec(`UPDATE golem15_journal_categories SET content = '<p>x</p>' WHERE slug = 'uncategorized'`).Error; err != nil {
|
if err := gdb.Exec(`UPDATE golem15_journal_categories SET content = '<p>x</p>' WHERE slug = 'uncategorized'`).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := m.RollbackLast(); err != nil {
|
if err := m.RollbackTo(previous); err != nil {
|
||||||
t.Fatalf("rollback last: %v", err)
|
t.Fatalf("rollback to %s: %v", previous, err)
|
||||||
}
|
}
|
||||||
if !gdb.Migrator().HasTable("golem15_journal_categories") {
|
if !gdb.Migrator().HasTable("golem15_journal_categories") {
|
||||||
t.Fatal("golem15_journal_categories dropped by the content rollback")
|
t.Fatal("golem15_journal_categories dropped by the content rollback")
|
||||||
|
|||||||
72
updates/settings_headings_migration_test.go
Normal file
72
updates/settings_headings_migration_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package updates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSettingsHeadingsMigration checks the Headings and links columns:
|
||||||
|
// content_h1_to_h2 defaults to true on the existing settings row,
|
||||||
|
// generic_anchors is NULL, and the rollback drops both and keeps the row.
|
||||||
|
func TestSettingsHeadingsMigration(t *testing.T) {
|
||||||
|
sqlDB := dedicatedDB(t, fmt.Sprintf("journal_settings_headings_%d", time.Now().UnixNano()))
|
||||||
|
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend := gormigrate.New(gdb, &gormigrate.Options{
|
||||||
|
TableName: "summer_migrations_summercms_cabana",
|
||||||
|
UseTransaction: true,
|
||||||
|
}, lagoon.BackendAdminMigrations)
|
||||||
|
if err := backend.Migrate(); err != nil {
|
||||||
|
t.Fatalf("backend_users migrations: %v", err)
|
||||||
|
}
|
||||||
|
m := journalMigrator(gdb)
|
||||||
|
if err := m.Migrate(); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
assertUdt(t, gdb, "golem15_journal_settings", "content_h1_to_h2", "bool")
|
||||||
|
assertUdt(t, gdb, "golem15_journal_settings", "generic_anchors", "text")
|
||||||
|
var on bool
|
||||||
|
var anchors sql.NullString
|
||||||
|
if err := gdb.Raw(`SELECT content_h1_to_h2, generic_anchors FROM golem15_journal_settings WHERE id = 1`).Row().Scan(&on, &anchors); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !on || anchors.Valid {
|
||||||
|
t.Fatalf("settings row: content_h1_to_h2 = %v, generic_anchors = %v, want true and NULL", on, anchors)
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = "202610280001_add_golem15_journal_settings_headings"
|
||||||
|
previous := ""
|
||||||
|
for i, mig := range All() {
|
||||||
|
if mig.ID == step && i > 0 {
|
||||||
|
previous = All()[i-1].ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if previous == "" {
|
||||||
|
t.Fatalf("migration %s is not registered after another step", step)
|
||||||
|
}
|
||||||
|
if err := m.RollbackTo(previous); err != nil {
|
||||||
|
t.Fatalf("rollback to %s: %v", previous, err)
|
||||||
|
}
|
||||||
|
for _, column := range []string{"content_h1_to_h2", "generic_anchors"} {
|
||||||
|
if gdb.Migrator().HasColumn("golem15_journal_settings", column) {
|
||||||
|
t.Fatalf("%s survived rollback", column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var rows int64
|
||||||
|
if err := gdb.Raw(`SELECT COUNT(*) FROM golem15_journal_settings WHERE id = 1`).Scan(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rows != 1 {
|
||||||
|
t.Fatalf("settings rows after rollback = %d, want 1", rows)
|
||||||
|
}
|
||||||
|
if err := m.Migrate(); err != nil {
|
||||||
|
t.Fatalf("re-migrate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user