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:
127
models/post.go
127
models/post.go
@@ -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
25
models/post/columns.yaml
Normal 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
67
models/post/fields.yaml
Normal 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
|
||||
Reference in New Issue
Block a user