Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7bfb3313b | ||
|
|
08e3d82ecd | ||
|
|
5b38a3b20d | ||
| a6fc7cd954 | |||
| 7a03640b88 | |||
| 62c75b1fc2 |
47
README.md
47
README.md
@@ -22,8 +22,9 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
|
|||||||
- **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`) and `journal-tag` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories and tags. Posts, categories and tags carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors).
|
- **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) and `journal-tag` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories and tags. Posts, categories and tags carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors).
|
||||||
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense.
|
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense.
|
||||||
|
- **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.
|
- **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. 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.
|
- **Record events.** Post writes through the admin fire the framework's record events (`pact.RecordSaved`, `pact.RecordDeleted`) after the commit, like every admin controller. The `/_journal/api/v1` post routes, which MCP tools call, fire the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`: `POST /posts` a RecordSaved with `Created`, `PUT /posts/{id}` a RecordSaved whose changes hold the post's changed columns and each submitted translation that changed (a translated slug per locale), `DELETE /posts/{id}` a RecordDeleted. A tag `POST` or `PUT` creates from a name in `tags` fires its own RecordSaved with `Created`. `POST` and `PUT` write the row, its categories, tags and translations in one transaction and fire the event after it commits, so a write that fails part-way changes nothing and fires nothing. Their `Controller` is empty, as they do not go through an admin form. Another plugin (an SEO plugin creating redirects, a search indexer) therefore hears about a slug changed over MCP without importing the Journal.
|
||||||
- **MCP tools.** `journal.Plugin.MCP` declares seven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools).
|
- **MCP tools.** `journal.Plugin.MCP` declares seven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools).
|
||||||
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.
|
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.
|
||||||
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.
|
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.
|
||||||
@@ -102,7 +103,7 @@ The admin Categories form edits content with the `blogeditor` field type: a save
|
|||||||
1. `backend_users.golem15_bloghub_author_slug`, the public author slug. A user with a slug resolves even before their first post.
|
1. `backend_users.golem15_bloghub_author_slug`, the public author slug. A user with a slug resolves even before their first post.
|
||||||
2. `backend_users.login`, but only for a user with at least one published post. That login is already public through the post `author` object, so other backend accounts cannot be enumerated.
|
2. `backend_users.login`, but only for a user with at least one published post. That login is already public through the post `author` object, so other backend accounts cannot be enumerated.
|
||||||
|
|
||||||
Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login and no slug.
|
Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login. Since v0.3.1 that object also carries `slug` (the same canonical identifier as below), and consumers link by it.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe","subtitle":"Editor","description":"Writes about releases.","avatar":"https://example.com/storage/app/uploads/public/012/345/678/0123456789abcdef01234567.webp"}}
|
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe","subtitle":"Editor","description":"Writes about releases.","avatar":"https://example.com/storage/app/uploads/public/012/345/678/0123456789abcdef01234567.webp"}}
|
||||||
@@ -127,7 +128,7 @@ An unknown author, a login without published posts, a soft-deleted user and a ma
|
|||||||
The response uses the `/posts` pagination envelope (rows trimmed here):
|
The response uses the `/posts` pagination envelope (rows trimmed here):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}}
|
{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe","slug":"jane-doe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}}
|
||||||
```
|
```
|
||||||
|
|
||||||
An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route.
|
An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route.
|
||||||
@@ -142,6 +143,34 @@ Content-Type: application/json
|
|||||||
{"title":"Hello","content":"A blog post"}
|
{"title":"Hello","content":"A blog post"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
`GET /_journal/api/v1/posts?search=<term>` filters the list by a term of 3 or more characters; a shorter term is ignored and the list comes back unfiltered.
|
||||||
|
|
||||||
|
- **SQL.** By default, and whenever the engine cannot be used, the term is matched with `ILIKE` against title, slug, excerpt and content, and the rows keep the list order (`sort`, default newest first).
|
||||||
|
- **Typesense.** When `search.driver` is `typesense` with an API key and the Journal setting `search_use_typesense` is on, the engine is asked for the first 250 candidates (`typesense.MaxPerPage`), ranked by text match with the setting weights for title, excerpt and content (default 5, 3, 1), then by date. The candidates are re-gated in SQL with the same filters as any list (published, `category`, `tag`, `author`), so a stale index entry never shows. `meta.total` counts the gated rows, the rows keep the engine's rank, with pinned posts first when `pinned_first` is set, and `page` and `per_page` page over them as over any list. An explicit `sort` overrides the rank. A search that matches more than 250 posts keeps the first 250. When the engine fails, the request is answered from SQL and the failure is logged at Warn.
|
||||||
|
|
||||||
|
The setting row is read on every engine search, so turning `search_use_typesense` off takes effect on the next request, without a restart.
|
||||||
|
|
||||||
|
`?fields=suggest` replaces each row with the short shape a search box needs, under the same pagination envelope, filters and limits:
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
|-----|-------|
|
||||||
|
| `id` | Post id. |
|
||||||
|
| `title` | Default-locale title, as in the full row. |
|
||||||
|
| `slug` | Default-locale slug, as in the full row. |
|
||||||
|
| `excerpt` | The excerpt as plain text, cut to about 160 characters on a word boundary with `…`; the content as plain text when the post has no excerpt. |
|
||||||
|
| `featured_image_url` | The first featured image URL, or `null`. |
|
||||||
|
| `published_at` | As in the full row. |
|
||||||
|
|
||||||
|
Any other `fields` value, or none, serves the full rows.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /_journal/api/v1/posts?search=borowik&fields=suggest&per_page=5
|
||||||
|
```
|
||||||
|
|
||||||
|
The search document holds the default-locale title, excerpt and content; content is indexed as plain text, from `content_html` (or `content` when nothing was rendered) with tags removed and entities decoded. The collection is created from `models.Post.SearchIndexSchema` when it does not exist. A collection created before the schema existed keeps its auto-detected fields until `journal:reindex --fresh` recreates it.
|
||||||
|
|
||||||
### Personal API tokens
|
### Personal API tokens
|
||||||
|
|
||||||
The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`):
|
The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`):
|
||||||
@@ -313,7 +342,7 @@ 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.
|
||||||
@@ -333,7 +362,7 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
|||||||
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
|
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
|
||||||
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
|
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
|
||||||
| `journal.Plugin.Models` | Models registered by this plugin. |
|
| `journal.Plugin.Models` | Models registered by this plugin. |
|
||||||
| `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. |
|
| `journal.Plugin.Commands` | Registers `journal:export-posts`, `journal:import-posts` and `journal:reindex`. |
|
||||||
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
|
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
|
||||||
| `journal.Plugin.MCP` | The MCP tools and the `draft_post` prompt (`pact.HasMCP`), guard `backend`, permission `golem15.journal.access_posts`. |
|
| `journal.Plugin.MCP` | The MCP tools and the `draft_post` prompt (`pact.HasMCP`), guard `backend`, permission `golem15.journal.access_posts`. |
|
||||||
| `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). |
|
| `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). |
|
||||||
@@ -346,7 +375,9 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
|||||||
| `models.Post.AttachRelations` | `featured_images` and `content_images` attachMany. |
|
| `models.Post.AttachRelations` | `featured_images` and `content_images` attachMany. |
|
||||||
| `models.Post.SearchableAs` | Index name `golem15_journal_posts`. |
|
| `models.Post.SearchableAs` | Index name `golem15_journal_posts`. |
|
||||||
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
|
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
|
||||||
| `models.Post.ToSearchableArray` | Typesense document built after a committed save. |
|
| `models.Post.ToSearchableArray` | Typesense document built after a committed save; content is plain text. |
|
||||||
|
| `models.Post.SearchIndexSchema` | The Typesense collection schema a missing collection is created with (`beachcomber.IndexSchemaProvider`). |
|
||||||
|
| `models.PlainText` | HTML to plain text: tags dropped, entities decoded, whitespace collapsed. |
|
||||||
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
|
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
|
||||||
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. |
|
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. |
|
||||||
| `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. |
|
| `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. |
|
||||||
@@ -383,17 +414,19 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
|||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
|
|
||||||
Both commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`.
|
The export and import commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`.
|
||||||
|
|
||||||
| Command | Arguments and flags | Effect |
|
| Command | Arguments and flags | Effect |
|
||||||
|---------|---------------------|--------|
|
|---------|---------------------|--------|
|
||||||
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations, with a progress bar over the posts. Attachments are omitted. Ends with `Exported N posts to: <path>`. |
|
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations, with a progress bar over the posts. Attachments are omitted. Ends with `Exported N posts to: <path>`. |
|
||||||
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files, one line per post. Existing slugs are skipped unless `--force` updates them. Ends with `Results: N created, N updated, N skipped, N failed`. |
|
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files, one line per post. Existing slugs are skipped unless `--force` updates them. Ends with `Results: N created, N updated, N skipped, N failed`. |
|
||||||
|
| `journal:reindex` | `--fresh` | Syncs every post into the search index in batches of 200: published posts are upserted, the others removed. `--fresh` first drops the collection and recreates it from the schema. Refuses, sending nothing, when the engine is not configured or `search_use_typesense` is off. Ends with `Reindexed: N synced, N removed, N failed` and exits non-zero on any failure. |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./bin/acme journal:export-posts --path journal-export
|
./bin/acme journal:export-posts --path journal-export
|
||||||
./bin/acme journal:import-posts --path journal-export --dry-run
|
./bin/acme journal:import-posts --path journal-export --dry-run
|
||||||
./bin/acme journal:import-posts --path journal-export --force
|
./bin/acme journal:import-posts --path journal-export --force
|
||||||
|
./bin/acme journal:reindex --fresh
|
||||||
```
|
```
|
||||||
|
|
||||||
`./bin/acme help journal:import-posts` shows each command's long help and examples. The plugin logs through the component `journal` (`log.components.journal` in `config/log.yaml`).
|
`./bin/acme help journal:import-posts` shows each command's long help and examples. The plugin logs through the component `journal` (`log.components.journal` in `config/log.yaml`).
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -25,10 +26,17 @@ import (
|
|||||||
const (
|
const (
|
||||||
defaultPerPage = 9
|
defaultPerPage = 9
|
||||||
maxPerPage = 30
|
maxPerPage = 30
|
||||||
|
|
||||||
|
// fieldsSuggest is the ?fields value of the suggestion rows.
|
||||||
|
fieldsSuggest = "suggest"
|
||||||
|
// suggestExcerptRunes is about how long a suggestion's excerpt runs.
|
||||||
|
suggestExcerptRunes = 160
|
||||||
)
|
)
|
||||||
|
|
||||||
// Index is GET /_journal/api/v1/posts. Anonymous callers see published posts
|
// Index is GET /_journal/api/v1/posts. Anonymous callers see published posts
|
||||||
// only; an editor with access_posts may include drafts unless ?published=true.
|
// only; an editor with access_posts may include drafts unless ?published=true.
|
||||||
|
// With ?fields=suggest each row is the short suggestion shape
|
||||||
|
// (serializeSuggestion).
|
||||||
func Index(app *backpack.App) http.HandlerFunc {
|
func Index(app *backpack.App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
r = optionalBackendPrincipal(app, r)
|
r = optionalBackendPrincipal(app, r)
|
||||||
@@ -43,8 +51,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
perPage := clampPerPage(r.URL.Query().Get("per_page"))
|
perPage := clampPerPage(r.URL.Query().Get("per_page"))
|
||||||
page := max(1, atoiDefault(r.URL.Query().Get("page"), 1))
|
page := max(1, atoiDefault(r.URL.Query().Get("page"), 1))
|
||||||
|
|
||||||
q := db.WithContext(r.Context()).Model(&models.Post{}).
|
suggest := r.URL.Query().Get("fields") == fieldsSuggest
|
||||||
Preload("User").Preload("Categories").Preload("Tags")
|
q := db.WithContext(r.Context()).Model(&models.Post{})
|
||||||
|
if !suggest {
|
||||||
|
q = q.Preload("User").Preload("Categories").Preload("Tags")
|
||||||
|
}
|
||||||
|
|
||||||
publishedOnly := true
|
publishedOnly := true
|
||||||
if editor {
|
if editor {
|
||||||
@@ -78,8 +89,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
if len([]rune(search)) < 3 {
|
if len([]rune(search)) < 3 {
|
||||||
search = ""
|
search = ""
|
||||||
}
|
}
|
||||||
|
// rank is the engine's order of the candidates, nil for a SQL search.
|
||||||
|
var rank []uint
|
||||||
if search != "" {
|
if search != "" {
|
||||||
if ids, used := searchWithEngine(app, r, db.WithContext(r.Context()), search, publishedOnly, page, perPage); used {
|
if ids, used := searchWithEngine(app, r, db.WithContext(r.Context()), search, publishedOnly); used {
|
||||||
|
rank = ids
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
q = q.Where("1 = 0")
|
q = q.Where("1 = 0")
|
||||||
} else {
|
} else {
|
||||||
@@ -101,7 +115,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
sort := strings.TrimSpace(r.URL.Query().Get("sort"))
|
||||||
order := listOrder(sort, queryBool(r.URL.Query().Get("pinned_first")))
|
pinnedFirst := queryBool(r.URL.Query().Get("pinned_first"))
|
||||||
|
order := listOrder(sort, pinnedFirst)
|
||||||
|
if len(rank) > 0 && sort == "" {
|
||||||
|
order = rankOrder(rank, pinnedFirst)
|
||||||
|
}
|
||||||
var posts []models.Post
|
var posts []models.Post
|
||||||
if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
@@ -109,7 +127,11 @@ func Index(app *backpack.App) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
rows := make([]map[string]any, 0, len(posts))
|
rows := make([]map[string]any, 0, len(posts))
|
||||||
for i := range posts {
|
for i := range posts {
|
||||||
rows = append(rows, serializePost(&posts[i], db.WithContext(r.Context())))
|
if suggest {
|
||||||
|
rows = append(rows, serializeSuggestion(&posts[i], db.WithContext(r.Context())))
|
||||||
|
} else {
|
||||||
|
rows = append(rows, serializePost(&posts[i], db.WithContext(r.Context())))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total))
|
||||||
}
|
}
|
||||||
@@ -220,20 +242,30 @@ func Store(app *backpack.App) http.HandlerFunc {
|
|||||||
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.WithContext(r.Context()).Create(post).Error; err != nil {
|
// D-92/D-102: the row, its associations and translations commit
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
// together, and RecordSaved fires only once they have.
|
||||||
return
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
}
|
if err := tx.Create(post).Error; err != nil {
|
||||||
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
|
return err
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
}
|
||||||
return
|
tags, err := applyPostAssocs(ctx, tx, post, body)
|
||||||
}
|
if err != nil {
|
||||||
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
|
return err
|
||||||
|
}
|
||||||
|
if err := loadPostAssocs(tx, post); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, nil, post, body))
|
||||||
|
for _, tag := range tags {
|
||||||
|
cabana.EmitRecordSaved(ctx, app, tx, tagSavedEvent(ctx, &tag))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
purgeSiteCache(r, app, post)
|
purgeSiteCache(r, app, post)
|
||||||
cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), nil, post, body))
|
|
||||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,20 +310,30 @@ func Update(app *backpack.App) http.HandlerFunc {
|
|||||||
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
writeAPIError(w, http.StatusForbidden, errPublishPermission)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.WithContext(r.Context()).Save(post).Error; err != nil {
|
// D-92/D-102: a slug change commits with its translations and
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
// associations, or not at all, and RecordSaved follows the commit.
|
||||||
return
|
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||||
}
|
if err := tx.Save(post).Error; err != nil {
|
||||||
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil {
|
return err
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
}
|
||||||
return
|
tags, err := applyPostAssocs(ctx, tx, post, body)
|
||||||
}
|
if err != nil {
|
||||||
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil {
|
return err
|
||||||
|
}
|
||||||
|
if err := loadPostAssocs(tx, post); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, before, post, body))
|
||||||
|
for _, tag := range tags {
|
||||||
|
cabana.EmitRecordSaved(ctx, app, tx, tagSavedEvent(ctx, &tag))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
purgeSiteCache(r, app, post)
|
purgeSiteCache(r, app, post)
|
||||||
cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), before, post, body))
|
|
||||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -564,10 +606,7 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
|||||||
tags = append(tags, map[string]any{"id": t.ID, "name": t.Name, "slug": t.Slug})
|
tags = append(tags, map[string]any{"id": t.ID, "name": t.Name, "slug": t.Slug})
|
||||||
}
|
}
|
||||||
featured := featuredImages(db, p)
|
featured := featuredImages(db, p)
|
||||||
var featuredURL any
|
featuredURL := firstImageURL(featured)
|
||||||
if len(featured) > 0 {
|
|
||||||
featuredURL = featured[0]["url"]
|
|
||||||
}
|
|
||||||
row := map[string]any{
|
row := map[string]any{
|
||||||
"id": p.ID,
|
"id": p.ID,
|
||||||
"title": p.Title,
|
"title": p.Title,
|
||||||
@@ -581,7 +620,7 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
|||||||
"is_pinned": p.IsPinned,
|
"is_pinned": p.IsPinned,
|
||||||
"metadata": jsonField(p.Metadata),
|
"metadata": jsonField(p.Metadata),
|
||||||
"sources": jsonField(p.Sources),
|
"sources": jsonField(p.Sources),
|
||||||
"author": serializeAuthor(p.User),
|
"author": serializeAuthor(db, p.User),
|
||||||
"categories": cats,
|
"categories": cats,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"featured_images": featured,
|
"featured_images": featured,
|
||||||
@@ -593,15 +632,78 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any {
|
|||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
func serializeAuthor(user *cabana.BackendUser) any {
|
// serializeSuggestion is the ?fields=suggest row: a subset of serializePost's
|
||||||
|
// keys with the same value formats, except that excerpt is plain text cut to
|
||||||
|
// about suggestExcerptRunes on a word boundary, taken from the content when
|
||||||
|
// the post has no excerpt.
|
||||||
|
func serializeSuggestion(p *models.Post, db *gorm.DB) map[string]any {
|
||||||
|
if p == nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"id": p.ID,
|
||||||
|
"title": p.Title,
|
||||||
|
"slug": p.Slug,
|
||||||
|
"excerpt": suggestionExcerpt(p),
|
||||||
|
"featured_image_url": firstImageURL(featuredImages(db, p)),
|
||||||
|
"published_at": carbonTime(p.PublishedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestionExcerpt(p *models.Post) string {
|
||||||
|
text := models.PlainText(p.Excerpt)
|
||||||
|
if text == "" {
|
||||||
|
body := p.ContentHTML
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
body = p.Content
|
||||||
|
}
|
||||||
|
text = models.PlainText(body)
|
||||||
|
}
|
||||||
|
return truncateWords(text, suggestExcerptRunes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateWords cuts s to at most n runes at the last space before the cut,
|
||||||
|
// with an ellipsis; s is returned whole when it fits.
|
||||||
|
func truncateWords(s string, n int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cut := runes[:n]
|
||||||
|
if i := strings.LastIndexFunc(string(cut), unicode.IsSpace); i > 0 {
|
||||||
|
return strings.TrimRightFunc(string(cut)[:i], func(r rune) bool { return unicode.IsSpace(r) || unicode.IsPunct(r) }) + "…"
|
||||||
|
}
|
||||||
|
return string(cut) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstImageURL(featured []map[string]any) any {
|
||||||
|
if len(featured) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return featured[0]["url"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeAuthor is a post's author object. slug is the author's canonical
|
||||||
|
// identifier, as on GET /authors/{author}: the author slug when set, otherwise
|
||||||
|
// the login, so a consumer links to the author page by slug.
|
||||||
|
func serializeAuthor(db *gorm.DB, user *cabana.BackendUser) any {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
slug := user.Login
|
||||||
|
if db != nil {
|
||||||
|
var authorSlug string
|
||||||
|
if err := db.Table("backend_users").Select("COALESCE(golem15_bloghub_author_slug, '')").
|
||||||
|
Where("id = ?", user.ID).Scan(&authorSlug).Error; err == nil && authorSlug != "" {
|
||||||
|
slug = authorSlug
|
||||||
|
}
|
||||||
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"first_name": user.FirstName,
|
"first_name": user.FirstName,
|
||||||
"last_name": user.LastName,
|
"last_name": user.LastName,
|
||||||
"login": user.Login,
|
"login": user.Login,
|
||||||
|
"slug": slug,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -695,54 +695,59 @@ func asUintSlice(raw any) ([]uint, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) error {
|
// applyPostAssocs writes the post's categories, tags and translations the
|
||||||
|
// body submits. created are the tags the write created by name, for their
|
||||||
|
// record events.
|
||||||
|
func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) (created []models.Tag, err error) {
|
||||||
if _, has := body["categories"]; has {
|
if _, has := body["categories"]; has {
|
||||||
ids, _ := asUintSlice(body["categories"])
|
ids, _ := asUintSlice(body["categories"])
|
||||||
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil {
|
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil {
|
if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, has := body["tags"]; has {
|
if _, has := body["tags"]; has {
|
||||||
ids, err := resolveTagIDs(db, body["tags"])
|
var ids []uint
|
||||||
|
ids, created, err = resolveTagIDs(db, body["tags"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil {
|
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil {
|
if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if raw, has := body["translations"]; has {
|
if raw, has := body["translations"]; has {
|
||||||
if err := syncTranslations(ctx, db, post, raw); err != nil {
|
if err := syncTranslations(ctx, db, post, raw); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) {
|
// resolveTagIDs turns the submitted tags (ids or names) into tag ids; a name
|
||||||
|
// no tag has yet creates the tag, returned in created.
|
||||||
|
func resolveTagIDs(db *gorm.DB, raw any) (ids []uint, created []models.Tag, err error) {
|
||||||
items, ok := raw.([]any)
|
items, ok := raw.([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
if raw == nil {
|
if raw == nil {
|
||||||
return nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("tags must be an array")
|
return nil, nil, fmt.Errorf("tags must be an array")
|
||||||
}
|
}
|
||||||
var ids []uint
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if id, ok := asUint(item); ok {
|
if id, ok := asUint(item); ok {
|
||||||
var n int64
|
var n int64
|
||||||
if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
@@ -765,11 +770,12 @@ func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) {
|
|||||||
}
|
}
|
||||||
tag = models.Tag{Name: name, Slug: slug}
|
tag = models.Tag{Name: name, Slug: slug}
|
||||||
if err := db.Create(&tag).Error; err != nil {
|
if err := db.Create(&tag).Error; err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
ids = append(ids, tag.ID)
|
ids = append(ids, tag.ID)
|
||||||
|
created = append(created, tag)
|
||||||
}
|
}
|
||||||
return ids, nil
|
return ids, created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error {
|
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||||
@@ -66,8 +68,8 @@ func snapshotPost(ctx context.Context, db *gorm.DB, post *models.Post, body map[
|
|||||||
}
|
}
|
||||||
|
|
||||||
// postSavedEvent is the RecordSaved of an API write, which the handler
|
// postSavedEvent is the RecordSaved of an API write, which the handler
|
||||||
// fires with cabana.EmitRecordSaved once the write is committed (the API
|
// fires with cabana.EmitRecordSaved inside the write's transaction, so it
|
||||||
// writes run outside a transaction, so at once). before is nil on create:
|
// reaches subscribers once the write commits. before is nil on create:
|
||||||
// every column with a value and every submitted translation is then
|
// 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
|
// reported with a nil Old. Controller is "": the write did not go through an
|
||||||
// admin form.
|
// admin form.
|
||||||
@@ -120,8 +122,28 @@ func postSavedEvent(ctx context.Context, db *gorm.DB, before *postSnapshot, post
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// submittedTranslations lists, per locale, the translated fields a request
|
||||||
// body submits with a value, in translatedEventFields order.
|
// 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 {
|
func submittedTranslations(body map[string]any) map[string][]string {
|
||||||
out := map[string][]string{}
|
out := map[string][]string{}
|
||||||
m, ok := body["translations"].(map[string]any)
|
m, ok := body["translations"].(map[string]any)
|
||||||
@@ -134,7 +156,8 @@ func submittedTranslations(body map[string]any) map[string][]string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, field := range translatedEventFields {
|
for _, field := range translatedEventFields {
|
||||||
if v, has := attrs[field]; has && v != nil {
|
v, has := attrs[field]
|
||||||
|
if (has && v != nil) || (field == "content_html" && derivesContentHTML(attrs)) {
|
||||||
out[locale] = append(out[locale], field)
|
out[locale] = append(out[locale], field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,6 +165,13 @@ func submittedTranslations(body map[string]any) map[string][]string {
|
|||||||
return out
|
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.
|
// derefValue is a pointer column's value, nil for a nil pointer.
|
||||||
func derefValue[T any](p *T) any {
|
func derefValue[T any](p *T) any {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
|
|||||||
8
go.mod
8
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.1
|
git.golem15.com/golem15/summercms v0.6.0
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||||
github.com/goccy/go-yaml v1.19.2
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
@@ -21,7 +21,6 @@ require (
|
|||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.2.1 // indirect
|
charm.land/bubbles/v2 v2.2.1 // indirect
|
||||||
charm.land/bubbletea/v2 v2.1.0 // indirect
|
charm.land/bubbletea/v2 v2.1.0 // indirect
|
||||||
charm.land/fang/v2 v2.0.1 // indirect
|
|
||||||
charm.land/huh/v2 v2.0.3 // indirect
|
charm.land/huh/v2 v2.0.3 // indirect
|
||||||
charm.land/lipgloss/v2 v2.0.6 // indirect
|
charm.land/lipgloss/v2 v2.0.6 // indirect
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
@@ -55,7 +54,6 @@ require (
|
|||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
|
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
@@ -124,10 +122,6 @@ require (
|
|||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/mango v0.1.0 // indirect
|
|
||||||
github.com/muesli/mango-cobra v1.2.0 // indirect
|
|
||||||
github.com/muesli/mango-pflag v0.1.0 // indirect
|
|
||||||
github.com/muesli/roff v0.1.0 // indirect
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
|
|||||||
12
go.sum
12
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=
|
||||||
@@ -303,14 +299,6 @@ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtw
|
|||||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
|
|
||||||
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
|
|
||||||
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
|
|
||||||
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
|
|
||||||
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
|
|
||||||
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
|
|
||||||
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
|
||||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
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, ".")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,7 @@ func (p *Plugin) Commands() []bonfire.Command {
|
|||||||
return []bonfire.Command{
|
return []bonfire.Command{
|
||||||
console.ExportPosts(p.app),
|
console.ExportPosts(p.app),
|
||||||
console.ImportPosts(p.app),
|
console.ImportPosts(p.app),
|
||||||
|
console.Reindex(p.app),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
plugin.yaml
Normal file
12
plugin.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Generated by summer plugin:manifest. Do not edit; rerun the command.
|
||||||
|
schema: 1
|
||||||
|
id: golem15.journal
|
||||||
|
module: git.golem15.com/golem15/sm-journal-plugin
|
||||||
|
package: git.golem15.com/golem15/sm-journal-plugin
|
||||||
|
name: Journal
|
||||||
|
description: "The compiled journal plugin for SummerCMS applications: translatable posts and categories, tags, a settings singleton, and the tables a host migrate creates for a blog."
|
||||||
|
framework: v0.6.0
|
||||||
|
requires:
|
||||||
|
golem15.translate: v0.1.0
|
||||||
|
overrides:
|
||||||
|
name: Journal
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -239,6 +240,69 @@ func TestAPIRecordEvents(t *testing.T) {
|
|||||||
t.Fatalf("PUT described URL = %q", got[0].url)
|
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()
|
l.reset()
|
||||||
env.expect(t, http.StatusOK, http.MethodDelete, "/_journal/api/v1/posts/"+id, token, nil)
|
env.expect(t, http.StatusOK, http.MethodDelete, "/_journal/api/v1/posts/"+id, token, nil)
|
||||||
if saved := l.savedEvents(); len(saved) != 0 {
|
if saved := l.savedEvents(); len(saved) != 0 {
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user