A translated content_html sent through the Journal API (syncTranslations
formatted only when content_html was empty) or through
journal:import-posts (no check) was stored verbatim and served by the
anonymous GET /_journal/api/v1/posts and /posts/{slug}. A translation
keyed on the default locale also wrote the host content_html column
past FormatHTML.
classes.FormatTranslation now sets content_html for every translated
locale before it is stored: rendered from a non-blank content with
FormatHTML (host-column parity; a supplied content_html is replaced),
otherwise a supplied content_html is sanitized with the new
classes.SanitizeHTML, which FormatHTML reuses. Both write paths call it.
PHP parity note: Winter's PostApiController::syncTranslations called
Post::formatHtml only when content_html was empty and otherwise stored
the supplied content_html verbatim; ImportPosts::syncTranslations stored
it verbatim; the MLJournalMarkdown admin widget always derived it from
content via formatHtml. The Go port now follows the admin widget on
every path. Response shapes and status codes for valid input are
unchanged; only the stored content_html of unsafe or content-mismatched
translations differs.
783 lines
19 KiB
Go
783 lines
19 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"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"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
|
|
}
|
|
|
|
func findPostBySlugOrID(db *gorm.DB, slug 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 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
|
|
}
|