From a598a7c5e76f90e307a9fd44776c711f5698d9b6 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 26 Oct 2026 22:46:49 +0100 Subject: [PATCH] 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. --- README.md | 2 +- controllers/api/posts.go | 2 +- controllers/api/posts_helpers.go | 39 ++++++++++++++++++- translated_slug_test.go | 65 ++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 translated_slug_test.go diff --git a/README.md b/README.md index 1ce2297..0182303 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/controllers/api/posts.go b/controllers/api/posts.go index cdaa7c9..37895ca 100644 --- a/controllers/api/posts.go +++ b/controllers/api/posts.go @@ -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 diff --git a/controllers/api/posts_helpers.go b/controllers/api/posts_helpers.go index 084c198..2332753 100644 --- a/controllers/api/posts_helpers.go +++ b/controllers/api/posts_helpers.go @@ -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)) +} diff --git a/translated_slug_test.go b/translated_slug_test.go new file mode 100644 index 0000000..13436cb --- /dev/null +++ b/translated_slug_test.go @@ -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()) + } +}