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