Files
sm-journal-plugin/models/post.go
Jakub Zych dea0934950 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>
2026-10-06 18:41:10 +02:00

190 lines
5.5 KiB
Go

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.
type Post struct {
ID uint `gorm:"column:id;primaryKey"`
UserID *uint `gorm:"column:user_id"`
RedactorID *uint `gorm:"column:redactor_id"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
Excerpt string `gorm:"column:excerpt"`
Content string `gorm:"column:content"`
ContentHTML string `gorm:"column:content_html"`
PublishedAt *time.Time `gorm:"column:published_at"`
Published bool `gorm:"column:published"`
IsPinned bool `gorm:"column:is_pinned"`
Metadata *string `gorm:"column:metadata;type:jsonb"`
Sources *string `gorm:"column:sources;type:jsonb"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
User *cabana.BackendUser `gorm:"foreignKey:UserID" json:"-"`
Categories []Category `gorm:"many2many:golem15_journal_posts_categories;" json:"-"`
Tags []Tag `gorm:"many2many:golem15_journal_posts_tags;" json:"-"`
}
func (Post) TableName() string { return "golem15_journal_posts" }
// MorphName is the PHP class string stored on translate attributes and system_files.
func (Post) MorphName() string { return `Golem15\Journal\Models\Post` }
func (Post) Translatable() []string {
return []string{"title", "content", "content_html", "excerpt", "metadata", "slug"}
}
func (Post) TranslatableIndexes() []string { return []string{"slug"} }
// Fillable is the tracer allow-list. redactor_id, content_html, and user_id
// are never mass-assigned from a map.
func (Post) Fillable() []string {
return []string{
"title", "slug", "excerpt", "content",
"published", "published_at", "is_pinned", "metadata", "sources",
}
}
func (Post) AttachRelations() []attach.Relation {
return []attach.Relation{
{Name: "featured_images", Many: true, Public: true},
{Name: "content_images", Many: true, Public: true},
}
}
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{})
}