feat(15-02): add Posts admin with mlmarkdown and FormatHTML

YAML-driven Posts screen stores translatable markdown and regenerates
content_html in the plugin. Publish writes without access_publish are
refused; list and form queries stay on the principal without
access_other_posts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-06 18:41:10 +02:00
parent 0bf2d39323
commit dea0934950
17 changed files with 1138 additions and 2 deletions

View File

@@ -1,10 +1,15 @@
package models
import (
"fmt"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gorm.io/gorm"
)
// Post is a journal entry stored on golem15_journal_posts.
@@ -57,6 +62,128 @@ func (Post) AttachRelations() []attach.Relation {
}
}
func (Post) Rules() map[string]string {
return map[string]string{
"title": "required",
"slug": "required",
"content": "required",
}
}
const (
// FilterPublished is the model scope PHP config_filter.yaml expressed as
// conditions. Cabana switch filters use column: published instead; the
// scope remains registered for FilterScopes completeness.
FilterPublished = "FilterPublished"
// FilterCategories is the config_filter.yaml category scope, including
// NestedTree children via nest_left/nest_right.
FilterCategories = "FilterCategories"
)
func (Post) FilterScopes() []string {
return []string{FilterPublished, FilterCategories}
}
func (Post) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil {
return db
}
switch name {
case FilterPublished:
on := parseBoolFilter(value)
if on {
return db.Where("published = ?", true)
}
return db.Where("published <> ?", true)
case FilterCategories:
id, ok := parseFilterID(value)
if !ok {
return db.Where("1 = 0")
}
ids := categoryIDsWithChildren(db, uint(id))
if len(ids) == 0 {
return db.Where("1 = 0")
}
return db.Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN (?))", ids)
default:
return db.Where("1 = 0")
}
}
func parseBoolFilter(value any) bool {
switch v := value.(type) {
case bool:
return v
case string:
s := strings.ToLower(strings.TrimSpace(v))
return s == "1" || s == "true" || s == "on"
default:
s := strings.ToLower(strings.TrimSpace(fmt.Sprint(value)))
return s == "1" || s == "true" || s == "on"
}
}
func parseFilterID(value any) (uint64, bool) {
text := strings.TrimSpace(fmt.Sprint(value))
if value == nil || text == "" {
return 0, false
}
id, err := strconv.ParseUint(text, 10, 32)
if err != nil || id == 0 {
return 0, false
}
return id, true
}
func categoryIDsWithChildren(db *gorm.DB, id uint) []uint {
var cat Category
if err := db.Session(&gorm.Session{NewDB: true}).Where("id = ?", id).Take(&cat).Error; err != nil {
return nil
}
ids := []uint{cat.ID}
if cat.NestLeft == nil || cat.NestRight == nil {
return ids
}
var kids []Category
if err := db.Session(&gorm.Session{NewDB: true}).
Where("nest_left > ? AND nest_right < ?", *cat.NestLeft, *cat.NestRight).
Find(&kids).Error; err != nil {
return ids
}
for _, kid := range kids {
ids = append(ids, kid.ID)
}
return ids
}
// CanEdit reports whether principal may edit this post: the owner, or an
// administrator with golem15.journal.access_other_posts.
func (p *Post) CanEdit(principal *bouncer.Principal) bool {
if p == nil || principal == nil {
return false
}
if cabana.Allows(principal, []string{"golem15.journal.access_other_posts"}) {
return true
}
return p.UserID != nil && *p.UserID == principal.ID
}
// PostCategory is the golem15_journal_posts_categories pivot.
type PostCategory struct {
PostID uint `gorm:"column:post_id;primaryKey"`
CategoryID uint `gorm:"column:category_id;primaryKey"`
}
func (PostCategory) TableName() string { return "golem15_journal_posts_categories" }
// PostTag is the golem15_journal_posts_tags pivot.
type PostTag struct {
PostID uint `gorm:"column:post_id;primaryKey"`
TagID uint `gorm:"column:tag_id;primaryKey"`
}
func (PostTag) TableName() string { return "golem15_journal_posts_tags" }
func init() {
Register(Post{})
}

25
models/post/columns.yaml Normal file
View File

@@ -0,0 +1,25 @@
columns:
title:
label: golem15.journal::lang.post.title
searchable: true
is_pinned:
label: Pinned
type: switch
sortable: true
categories:
label: golem15.journal::lang.post.categories
relation: categories
select: name
searchable: true
sortable: false
created_at:
label: golem15.journal::lang.post.created
type: date
invisible: true
updated_at:
label: golem15.journal::lang.post.updated
type: date
invisible: true
published_at:
label: golem15.journal::lang.post.published
type: date

67
models/post/fields.yaml Normal file
View File

@@ -0,0 +1,67 @@
fields:
title:
label: golem15.journal::lang.post.title
type: mltext
span: left
tab: golem15.journal::lang.post.tab_edit
required: true
slug:
label: golem15.journal::lang.post.slug
type: mltext
span: right
tab: golem15.journal::lang.post.tab_edit
required: true
content:
label: golem15.journal::lang.post.content
type: mlmarkdown
size: huge
tab: golem15.journal::lang.post.tab_edit
required: true
excerpt:
label: golem15.journal::lang.post.excerpt
type: mltext
size: small
tab: golem15.journal::lang.post.tab_manage
categories:
label: golem15.journal::lang.post.categories
type: relation
nameFrom: name
tab: golem15.journal::lang.post.tab_categories
comment: golem15.journal::lang.post.categories_comment
tags:
label: golem15.journal::lang.journal.tags
type: relation
nameFrom: name
tab: golem15.journal::lang.post.tab_categories
comment: golem15.journal::lang.post.tags_comment
published:
label: golem15.journal::lang.post.published
type: switch
span: left
tab: golem15.journal::lang.post.tab_manage
is_pinned:
label: Pinned
comment: Pin this post to appear first in listings
type: checkbox
span: left
tab: golem15.journal::lang.post.tab_manage
user:
label: golem15.journal::lang.post.published_by
type: relation
nameFrom: login
emptyOption: golem15.journal::lang.post.current_user
span: right
tab: golem15.journal::lang.post.tab_manage
published_at:
label: golem15.journal::lang.post.published_on
type: datepicker
mode: datetime
span: left
tab: golem15.journal::lang.post.tab_manage
featured_images:
label: golem15.journal::lang.post.featured_images
type: fileupload
mode: image
imageWidth: 200
imageHeight: 200
tab: golem15.journal::lang.post.tab_manage