Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b38a3b20d | ||
| a6fc7cd954 | |||
| 7a03640b88 | |||
| 62c75b1fc2 |
@@ -23,7 +23,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
|
|||||||
- **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.
|
||||||
- **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`.
|
||||||
@@ -313,7 +313,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.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -220,20 +221,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 +289,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()))})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
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, ".")
|
||||||
|
}
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user