fix: resolve translated post slugs (D-58)

GET /_journal/api/v1/posts/{slug} now looks the slug up in translate's
slug index for the request locale after the default-locale slug and
before the numeric id. TestTranslatedSlug covers the translated slug,
the default slug, the id and an unknown slug.
This commit is contained in:
2026-10-26 22:46:49 +01:00
parent e17cebd761
commit a598a7c5e7
4 changed files with 105 additions and 3 deletions

View File

@@ -22,7 +22,7 @@ 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`.
- **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.
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal.
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. `/posts/{slug}` finds the post by its default-locale slug, then by its slug in the request locale (the router's locale, from `Accept-Language` or the translate plugin's resolver), then by id, so a translated slug resolves. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal.
- **Record events.** Post writes through the admin fire the framework's record events (`pact.RecordSaved`, `pact.RecordDeleted`) after the commit, like every admin controller. The `/_journal/api/v1` post routes, which MCP tools call, fire the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`: `POST /posts` a RecordSaved with `Created`, `PUT /posts/{id}` a RecordSaved whose changes hold the post's changed columns and each submitted translation that changed (a translated slug per locale), `DELETE /posts/{id}` a RecordDeleted. Their `Controller` is empty, as they do not go through an admin form. Another plugin (an SEO plugin creating redirects, a search indexer) therefore hears about a slug changed over MCP without importing the Journal.
- **MCP tools.** `journal.Plugin.MCP` declares seven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools).
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.

View File

@@ -128,7 +128,7 @@ func Show(app *backpack.App) http.HandlerFunc {
}
db = db.WithContext(r.Context())
slug := strings.TrimSpace(r.PathValue("slug"))
post, err := findPostBySlugOrID(db, slug)
post, err := findPostBySlugOrID(db, slug, requestLocale(r))
if err != nil || post == nil {
writeAPIError(w, http.StatusNotFound, errPostNotFound)
return

View File

@@ -16,8 +16,10 @@ import (
"git.golem15.com/golem15/sm-journal-plugin/classes"
"git.golem15.com/golem15/sm-journal-plugin/models"
translate "git.golem15.com/golem15/sm-translate-plugin/classes"
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
@@ -52,12 +54,29 @@ func ctypeDigit(s string) bool {
return true
}
func findPostBySlugOrID(db *gorm.DB, slug string) (*models.Post, error) {
// findPostBySlugOrID finds a post by its default-locale slug, then by its
// slug in locale (the request locale, D-58: translate's slug index), then
// by numeric id. Drafts are found too; the caller decides who may see them.
func findPostBySlugOrID(db *gorm.DB, slug, locale string) (*models.Post, error) {
q := postQuery(db)
var post models.Post
if err := q.Where("slug = ?", slug).Take(&post).Error; err == nil {
return &post, nil
}
if locale != "" && slug != "" {
var ids []string
err := db.Session(&gorm.Session{NewDB: true, Context: db.Statement.Context}).
Table(translatemodels.Index{}.TableName()).
Where("model_type = ? AND locale = ? AND item = ? AND value = ?", models.Post{}.MorphName(), locale, "slug", slug).
Order("id ASC").
Pluck("model_id", &ids).Error
if err == nil && len(ids) > 0 {
var translated models.Post
if err := postQuery(db).Where("CAST(id AS TEXT) IN ?", ids).Order("id ASC").Take(&translated).Error; err == nil {
return &translated, nil
}
}
}
if ctypeDigit(slug) {
id, err := strconv.ParseUint(slug, 10, 64)
if err == nil && id > 0 {
@@ -780,3 +799,21 @@ func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw a
}
return nil
}
// requestLocale is the request's locale (towel, set by the router from the
// locale resolver or Accept-Language) as a lower-case language code: the
// first language of an Accept-Language list, without its region.
func requestLocale(r *http.Request) string {
if r == nil {
return ""
}
raw, _ := towel.Locale(r.Context())
raw = strings.TrimSpace(raw)
if i := strings.IndexAny(raw, ",;"); i >= 0 {
raw = raw[:i]
}
if i := strings.IndexAny(raw, "-_"); i >= 0 {
raw = raw[:i]
}
return strings.ToLower(strings.TrimSpace(raw))
}

65
translated_slug_test.go Normal file
View File

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