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.
820 lines
20 KiB
Go
820 lines
20 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"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"
|
|
)
|
|
|
|
var slugPattern = regexp.MustCompile(`(?i)^[a-z0-9/\:_\-\*\[\]\+\?\|]*$`)
|
|
|
|
func postIsPublished(p *models.Post) bool {
|
|
if p == nil || !p.Published || p.PublishedAt == nil {
|
|
return false
|
|
}
|
|
return p.PublishedAt.Before(time.Now())
|
|
}
|
|
|
|
func canViewDraft(p *models.Post, pr *bouncer.Principal) bool {
|
|
if p == nil || pr == nil {
|
|
return false
|
|
}
|
|
if p.UserID != nil && *p.UserID == pr.ID {
|
|
return true
|
|
}
|
|
return cabana.Allows(pr, []string{permAccessOtherPosts})
|
|
}
|
|
|
|
func ctypeDigit(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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 {
|
|
var byID models.Post
|
|
if err := postQuery(db).Where("id = ?", id).Take(&byID).Error; err == nil {
|
|
return &byID, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
|
|
func findPostByID(db *gorm.DB, raw string) (*models.Post, error) {
|
|
id, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 64)
|
|
if err != nil || id == 0 {
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
var post models.Post
|
|
if err := postQuery(db).Where("id = ?", id).Take(&post).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &post, nil
|
|
}
|
|
|
|
func postQuery(db *gorm.DB) *gorm.DB {
|
|
return db.Model(&models.Post{}).Preload("User").Preload("Categories").Preload("Tags")
|
|
}
|
|
|
|
func loadPostAssocs(db *gorm.DB, post *models.Post) error {
|
|
if post == nil {
|
|
return nil
|
|
}
|
|
return db.Preload("User").Preload("Categories").Preload("Tags").First(post, post.ID).Error
|
|
}
|
|
|
|
func serializeSibling(p *models.Post) any {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return map[string]any{"id": p.ID, "title": p.Title, "slug": p.Slug}
|
|
}
|
|
|
|
func previousPost(db *gorm.DB, p *models.Post) *models.Post {
|
|
return siblingPost(db, p, true)
|
|
}
|
|
|
|
func nextPost(db *gorm.DB, p *models.Post) *models.Post {
|
|
return siblingPost(db, p, false)
|
|
}
|
|
|
|
func siblingPost(db *gorm.DB, p *models.Post, previous bool) *models.Post {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
op, dir := "<", "DESC"
|
|
if previous {
|
|
op, dir = ">", "ASC"
|
|
}
|
|
q := applyPublished(db.Model(&models.Post{})).Where("id <> ?", p.ID)
|
|
if p.PublishedAt != nil {
|
|
q = q.Where(
|
|
"(published_at "+op+" ?) OR (published_at = ? AND id "+op+" ?)",
|
|
p.PublishedAt, p.PublishedAt, p.ID,
|
|
)
|
|
}
|
|
var out models.Post
|
|
if err := q.Order("published_at " + dir + ", id " + dir).Take(&out).Error; err != nil {
|
|
return nil
|
|
}
|
|
return &out
|
|
}
|
|
|
|
func relatedPosts(db *gorm.DB, p *models.Post, editor bool) []models.Post {
|
|
if p == nil || len(p.Categories) == 0 {
|
|
return []models.Post{}
|
|
}
|
|
ids := make([]uint, 0, len(p.Categories))
|
|
for _, c := range p.Categories {
|
|
ids = append(ids, c.ID)
|
|
}
|
|
q := db.Model(&models.Post{}).
|
|
Preload("User").Preload("Categories").Preload("Tags").
|
|
Where("id <> ?", p.ID).
|
|
Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN (?))", ids)
|
|
if !editor {
|
|
q = applyPublished(q)
|
|
}
|
|
var rows []models.Post
|
|
if err := q.Order("published_at DESC").Limit(3).Find(&rows).Error; err != nil {
|
|
return []models.Post{}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func serializeCategoryTree(db *gorm.DB, includeEmpty bool) ([]map[string]any, error) {
|
|
var cats []models.Category
|
|
if err := db.Order("nest_left ASC NULLS LAST, id ASC").Find(&cats).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
counts := publishedPostCounts(db, "golem15_journal_posts_categories", "category_id")
|
|
byParent := map[uint][]models.Category{}
|
|
var roots []models.Category
|
|
for _, c := range cats {
|
|
if c.ParentID == nil || *c.ParentID == 0 {
|
|
roots = append(roots, c)
|
|
continue
|
|
}
|
|
byParent[*c.ParentID] = append(byParent[*c.ParentID], c)
|
|
}
|
|
out := make([]map[string]any, 0, len(roots))
|
|
for i := range roots {
|
|
if row := serializeCategoryNode(&roots[i], byParent, counts, includeEmpty); row != nil {
|
|
out = append(out, row)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func serializeCategoryNode(c *models.Category, byParent map[uint][]models.Category, counts map[uint]int64, includeEmpty bool) map[string]any {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
kids := byParent[c.ID]
|
|
childRows := make([]map[string]any, 0, len(kids))
|
|
for i := range kids {
|
|
if row := serializeCategoryNode(&kids[i], byParent, counts, includeEmpty); row != nil {
|
|
childRows = append(childRows, row)
|
|
}
|
|
}
|
|
postCount := counts[c.ID]
|
|
if !includeEmpty && postCount == 0 && len(childRows) == 0 {
|
|
return nil
|
|
}
|
|
var desc any
|
|
if c.Description != nil {
|
|
desc = *c.Description
|
|
}
|
|
var content any
|
|
if c.Content != nil {
|
|
content = *c.Content
|
|
}
|
|
var parent any
|
|
if c.ParentID != nil {
|
|
parent = *c.ParentID
|
|
}
|
|
var depth any
|
|
if c.NestDepth != nil {
|
|
depth = *c.NestDepth
|
|
}
|
|
row := map[string]any{
|
|
"id": c.ID,
|
|
"name": c.Name,
|
|
"slug": c.Slug,
|
|
"description": desc,
|
|
"content": content,
|
|
"parent_id": parent,
|
|
"nest_depth": depth,
|
|
"post_count": postCount,
|
|
}
|
|
if len(childRows) > 0 {
|
|
row["children"] = childRows
|
|
}
|
|
return row
|
|
}
|
|
|
|
func serializeTagList(db *gorm.DB) ([]map[string]any, error) {
|
|
var tags []models.Tag
|
|
if err := db.Order("name ASC, id ASC").Find(&tags).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
counts := publishedPostCounts(db, "golem15_journal_posts_tags", "tag_id")
|
|
out := make([]map[string]any, 0, len(tags))
|
|
for _, tag := range tags {
|
|
out = append(out, map[string]any{
|
|
"id": tag.ID,
|
|
"name": tag.Name,
|
|
"slug": tag.Slug,
|
|
"post_count": counts[tag.ID],
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func publishedPostCounts(db *gorm.DB, pivot, fk string) map[uint]int64 {
|
|
type row struct {
|
|
ID uint
|
|
Count int64
|
|
}
|
|
var rows []row
|
|
q := fmt.Sprintf(
|
|
`SELECT p.%s AS id, COUNT(*) AS count
|
|
FROM %s p
|
|
INNER JOIN golem15_journal_posts posts ON posts.id = p.post_id
|
|
WHERE posts.published = TRUE AND posts.published_at IS NOT NULL AND posts.published_at < ?
|
|
GROUP BY p.%s`,
|
|
fk, pivot, fk,
|
|
)
|
|
_ = db.Raw(q, time.Now()).Scan(&rows).Error
|
|
out := map[uint]int64{}
|
|
for _, r := range rows {
|
|
out[r.ID] = r.Count
|
|
}
|
|
return out
|
|
}
|
|
|
|
func translationsOf(db *gorm.DB, p *models.Post) any {
|
|
if db == nil || p == nil || p.ID == 0 {
|
|
return []any{}
|
|
}
|
|
var locales []struct {
|
|
Code string
|
|
IsDefault bool
|
|
}
|
|
if err := db.Table("golem15_translate_locales").
|
|
Select("code, is_default").
|
|
Where("is_enabled = ?", true).
|
|
Find(&locales).Error; err != nil {
|
|
return []any{}
|
|
}
|
|
out := map[string]any{}
|
|
for _, loc := range locales {
|
|
if loc.IsDefault {
|
|
continue
|
|
}
|
|
attrs := map[string]any{}
|
|
ctx := db.Statement.Context
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
for _, field := range []string{"title", "slug", "content", "content_html", "excerpt", "metadata"} {
|
|
val, ok, err := translate.TranslatedExact(ctx, db, p, field, loc.Code)
|
|
if err != nil || !ok || val == nil {
|
|
continue
|
|
}
|
|
s := strings.TrimSpace(fmt.Sprint(val))
|
|
if s == "" {
|
|
continue
|
|
}
|
|
attrs[field] = val
|
|
}
|
|
if len(attrs) > 0 {
|
|
out[loc.Code] = attrs
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return []any{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func readJSONObject(r *http.Request) (map[string]any, error) {
|
|
if r == nil || r.Body == nil {
|
|
return map[string]any{}, nil
|
|
}
|
|
defer r.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(strings.TrimSpace(string(raw))) == 0 {
|
|
return map[string]any{}, nil
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
|
dec.UseNumber()
|
|
var body map[string]any
|
|
if err := dec.Decode(&body); err != nil {
|
|
return nil, err
|
|
}
|
|
if body == nil {
|
|
body = map[string]any{}
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func publishedFlag(body map[string]any) bool {
|
|
v, ok := body["published"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
return asBool(v)
|
|
}
|
|
|
|
func asBool(v any) bool {
|
|
switch t := v.(type) {
|
|
case bool:
|
|
return t
|
|
case json.Number:
|
|
n, _ := t.Int64()
|
|
return n != 0
|
|
case float64:
|
|
return t != 0
|
|
case string:
|
|
s := strings.ToLower(strings.TrimSpace(t))
|
|
return s == "1" || s == "true" || s == "on" || s == "yes"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func asString(v any) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case json.Number:
|
|
return t.String()
|
|
default:
|
|
return strings.TrimSpace(fmt.Sprint(v))
|
|
}
|
|
}
|
|
|
|
func asUint(v any) (uint, bool) {
|
|
switch t := v.(type) {
|
|
case json.Number:
|
|
n, err := t.Int64()
|
|
if err != nil || n <= 0 {
|
|
return 0, false
|
|
}
|
|
return uint(n), true
|
|
case float64:
|
|
if t <= 0 || t != float64(uint64(t)) {
|
|
return 0, false
|
|
}
|
|
return uint(t), true
|
|
case string:
|
|
n, err := strconv.ParseUint(strings.TrimSpace(t), 10, 64)
|
|
if err != nil || n == 0 {
|
|
return 0, false
|
|
}
|
|
return uint(n), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func parsePublishedAt(v any) (*time.Time, error) {
|
|
if v == nil {
|
|
return nil, nil
|
|
}
|
|
s := strings.TrimSpace(asString(v))
|
|
if s == "" {
|
|
return nil, nil
|
|
}
|
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
|
|
if ts, err := time.Parse(layout, s); err == nil {
|
|
return &ts, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("invalid date")
|
|
}
|
|
|
|
func jsonPtr(v any) *string {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
switch t := v.(type) {
|
|
case string:
|
|
s := strings.TrimSpace(t)
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
default:
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
s := string(raw)
|
|
return &s
|
|
}
|
|
}
|
|
|
|
func slugify(title string) string {
|
|
s := strings.ToLower(strings.TrimSpace(title))
|
|
var b strings.Builder
|
|
prevDash := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
prevDash = false
|
|
case unicode.IsLetter(r) || unicode.IsNumber(r):
|
|
b.WriteRune(unicode.ToLower(r))
|
|
prevDash = false
|
|
default:
|
|
if !prevDash && b.Len() > 0 {
|
|
b.WriteByte('-')
|
|
prevDash = true
|
|
}
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), "-")
|
|
}
|
|
|
|
func generateUniqueSlug(db *gorm.DB, title string, exceptID uint) string {
|
|
base := slugify(title)
|
|
if base == "" {
|
|
base = "post"
|
|
}
|
|
slug := base
|
|
for n := 1; ; n++ {
|
|
q := db.Model(&models.Post{}).Where("slug = ?", slug)
|
|
if exceptID > 0 {
|
|
q = q.Where("id <> ?", exceptID)
|
|
}
|
|
var count int64
|
|
_ = q.Count(&count).Error
|
|
if count == 0 {
|
|
return slug
|
|
}
|
|
slug = fmt.Sprintf("%s-%d", base, n)
|
|
}
|
|
}
|
|
|
|
func buildNewPost(r *http.Request, db *gorm.DB, pr *bouncer.Principal, body map[string]any) (*models.Post, map[string][]string) {
|
|
errs := map[string][]string{}
|
|
title := strings.TrimSpace(asString(body["title"]))
|
|
if title == "" {
|
|
errs["title"] = []string{"The title field is required."}
|
|
} else if utf8.RuneCountInString(title) > 255 {
|
|
errs["title"] = []string{"The title may not be greater than 255 characters."}
|
|
}
|
|
content := asString(body["content"])
|
|
if strings.TrimSpace(content) == "" {
|
|
errs["content"] = []string{"The content field is required."}
|
|
}
|
|
slug := strings.TrimSpace(asString(body["slug"]))
|
|
if slug == "" {
|
|
slug = generateUniqueSlug(db, title, 0)
|
|
} else if !slugPattern.MatchString(slug) {
|
|
errs["slug"] = []string{"The slug format is invalid."}
|
|
} else if slugTaken(db, slug, 0) {
|
|
errs["slug"] = []string{"The slug has already been taken."}
|
|
}
|
|
if _, has := body["published_at"]; has {
|
|
if _, err := parsePublishedAt(body["published_at"]); err != nil {
|
|
errs["published_at"] = []string{"The published at is not a valid date."}
|
|
}
|
|
}
|
|
if catErr := validateCategoryIDs(db, body["categories"]); catErr != nil {
|
|
errs["categories"] = []string{catErr.Error()}
|
|
}
|
|
if transErr := validateTranslations(body["translations"]); transErr != nil {
|
|
errs["translations"] = []string{transErr.Error()}
|
|
}
|
|
if len(errs) > 0 {
|
|
return nil, errs
|
|
}
|
|
html, err := classes.FormatHTML(content)
|
|
if err != nil {
|
|
return nil, map[string][]string{"content": {err.Error()}}
|
|
}
|
|
userID := pr.ID
|
|
post := &models.Post{
|
|
Title: title,
|
|
Slug: slug,
|
|
Content: content,
|
|
ContentHTML: html,
|
|
Excerpt: strings.TrimSpace(asString(body["excerpt"])),
|
|
Published: asBool(body["published"]),
|
|
IsPinned: asBool(body["is_pinned"]),
|
|
UserID: &userID,
|
|
}
|
|
if _, has := body["published_at"]; has {
|
|
ts, _ := parsePublishedAt(body["published_at"])
|
|
post.PublishedAt = ts
|
|
}
|
|
if _, has := body["metadata"]; has {
|
|
post.Metadata = jsonPtr(body["metadata"])
|
|
}
|
|
if _, has := body["sources"]; has {
|
|
post.Sources = jsonPtr(body["sources"])
|
|
}
|
|
_ = r
|
|
return post, nil
|
|
}
|
|
|
|
func applyPostUpdate(r *http.Request, db *gorm.DB, post *models.Post, body map[string]any) map[string][]string {
|
|
errs := map[string][]string{}
|
|
if _, has := body["title"]; has {
|
|
title := strings.TrimSpace(asString(body["title"]))
|
|
if title == "" {
|
|
errs["title"] = []string{"The title field is required."}
|
|
} else if utf8.RuneCountInString(title) > 255 {
|
|
errs["title"] = []string{"The title may not be greater than 255 characters."}
|
|
} else {
|
|
post.Title = title
|
|
}
|
|
}
|
|
if _, has := body["slug"]; has {
|
|
slug := strings.TrimSpace(asString(body["slug"]))
|
|
if slug != "" && !slugPattern.MatchString(slug) {
|
|
errs["slug"] = []string{"The slug format is invalid."}
|
|
} else if slug != "" && slugTaken(db, slug, post.ID) {
|
|
errs["slug"] = []string{"The slug has already been taken."}
|
|
} else if slug != "" {
|
|
post.Slug = slug
|
|
}
|
|
}
|
|
if _, has := body["content"]; has {
|
|
content := asString(body["content"])
|
|
if strings.TrimSpace(content) == "" {
|
|
errs["content"] = []string{"The content field is required."}
|
|
} else {
|
|
html, err := classes.FormatHTML(content)
|
|
if err != nil {
|
|
errs["content"] = []string{err.Error()}
|
|
} else {
|
|
post.Content = content
|
|
post.ContentHTML = html
|
|
}
|
|
}
|
|
}
|
|
if _, has := body["excerpt"]; has {
|
|
post.Excerpt = strings.TrimSpace(asString(body["excerpt"]))
|
|
}
|
|
if _, has := body["published"]; has {
|
|
post.Published = asBool(body["published"])
|
|
}
|
|
if _, has := body["is_pinned"]; has {
|
|
post.IsPinned = asBool(body["is_pinned"])
|
|
}
|
|
if _, has := body["published_at"]; has {
|
|
ts, err := parsePublishedAt(body["published_at"])
|
|
if err != nil {
|
|
errs["published_at"] = []string{"The published at is not a valid date."}
|
|
} else {
|
|
post.PublishedAt = ts
|
|
}
|
|
}
|
|
if _, has := body["metadata"]; has {
|
|
post.Metadata = jsonPtr(body["metadata"])
|
|
}
|
|
if _, has := body["sources"]; has {
|
|
post.Sources = jsonPtr(body["sources"])
|
|
}
|
|
if catErr := validateCategoryIDs(db, body["categories"]); catErr != nil {
|
|
errs["categories"] = []string{catErr.Error()}
|
|
}
|
|
if transErr := validateTranslations(body["translations"]); transErr != nil {
|
|
errs["translations"] = []string{transErr.Error()}
|
|
}
|
|
if len(errs) > 0 {
|
|
return errs
|
|
}
|
|
_ = r
|
|
return nil
|
|
}
|
|
|
|
func slugTaken(db *gorm.DB, slug string, exceptID uint) bool {
|
|
q := db.Model(&models.Post{}).Where("slug = ?", slug)
|
|
if exceptID > 0 {
|
|
q = q.Where("id <> ?", exceptID)
|
|
}
|
|
var n int64
|
|
_ = q.Count(&n).Error
|
|
return n > 0
|
|
}
|
|
|
|
func validateCategoryIDs(db *gorm.DB, raw any) error {
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
ids, ok := asUintSlice(raw)
|
|
if !ok {
|
|
return fmt.Errorf("The categories must be an array.")
|
|
}
|
|
for _, id := range ids {
|
|
var n int64
|
|
if err := db.Model(&models.Category{}).Where("id = ?", id).Count(&n).Error; err != nil || n == 0 {
|
|
return fmt.Errorf("The selected categories is invalid.")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTranslations(raw any) error {
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
m, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("The translations must be an array.")
|
|
}
|
|
for _, v := range m {
|
|
attrs, ok := v.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if slug := strings.TrimSpace(asString(attrs["slug"])); slug != "" && !slugPattern.MatchString(slug) {
|
|
return fmt.Errorf("The translations slug format is invalid.")
|
|
}
|
|
if title := asString(attrs["title"]); utf8.RuneCountInString(title) > 255 {
|
|
return fmt.Errorf("The translations title may not be greater than 255 characters.")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func asUintSlice(raw any) ([]uint, bool) {
|
|
switch t := raw.(type) {
|
|
case []any:
|
|
out := make([]uint, 0, len(t))
|
|
for _, item := range t {
|
|
id, ok := asUint(item)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
out = append(out, id)
|
|
}
|
|
return out, true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) error {
|
|
if _, has := body["categories"]; has {
|
|
ids, _ := asUintSlice(body["categories"])
|
|
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, id := range ids {
|
|
if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if _, has := body["tags"]; has {
|
|
ids, err := resolveTagIDs(db, body["tags"])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, id := range ids {
|
|
if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if raw, has := body["translations"]; has {
|
|
if err := syncTranslations(ctx, db, post, raw); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) {
|
|
items, ok := raw.([]any)
|
|
if !ok {
|
|
if raw == nil {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("tags must be an array")
|
|
}
|
|
var ids []uint
|
|
for _, item := range items {
|
|
if id, ok := asUint(item); ok {
|
|
var n int64
|
|
if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if n > 0 {
|
|
ids = append(ids, id)
|
|
}
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(asString(item))
|
|
if name == "" {
|
|
continue
|
|
}
|
|
slug := slugify(name)
|
|
var tag models.Tag
|
|
err := db.Where("name = ? OR slug = ?", name, slug).Take(&tag).Error
|
|
if err == nil {
|
|
ids = append(ids, tag.ID)
|
|
continue
|
|
}
|
|
if slug == "" {
|
|
slug = "tag"
|
|
}
|
|
tag = models.Tag{Name: name, Slug: slug}
|
|
if err := db.Create(&tag).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
ids = append(ids, tag.ID)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error {
|
|
m, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
fields := []string{"title", "slug", "content", "content_html", "excerpt", "metadata"}
|
|
for locale, v := range m {
|
|
attrs, ok := v.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Every translated content_html goes through the host column's
|
|
// policy: derived from content, or sanitized when only content_html
|
|
// is supplied. Winter stored a supplied content_html verbatim.
|
|
if err := classes.FormatTranslation(attrs); err != nil {
|
|
return err
|
|
}
|
|
for _, field := range fields {
|
|
if val, has := attrs[field]; has && val != nil {
|
|
if err := translate.SetTranslated(ctx, db, post, field, locale, val); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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))
|
|
}
|