Files
sm-journal-plugin/classes/content_descriptors.go
Jakub Zych e17cebd761 feat: content descriptors for posts, categories and the new journal-tag type
- journal-post, journal-category and journal-tag carry pact.ContentDescriptor
  (controller, morph name, Describe, Records, Match) through signpost
- Match reads the locale prefix and the pattern with signpost.MatchPattern,
  finds the record by its slug in that locale, the default slug, another
  locale's slug or :id, and is exact only on the record's own URL (D-20c)
- journal-tag (/blog/tag/:slug, P-11) with references and Resolve; host key
  golem15.journal.tag_pages false empties tag URLs and stops tag matches
- TestContentDescriptors, TestJournalTagType; README documents both
2026-10-26 22:46:32 +01:00

762 lines
23 KiB
Go

package classes
import (
"context"
"html"
"strconv"
"strings"
"time"
"unicode"
"git.golem15.com/golem15/sm-journal-plugin/models"
translatemodels "git.golem15.com/golem15/sm-translate-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
)
// The admin controller IDs whose forms edit the described records. They
// repeat the controllers package constants, which this package cannot
// import (controllers imports classes).
const (
postsControllerID = "golem15.journal.posts"
categoriesControllerID = "golem15.journal.categories"
tagsControllerID = "golem15.journal.tags"
)
// contentDB is the database a content descriptor reads: the write
// transaction in ctx when there is one (cabana.TxFromContext), so code
// running inside a save sees what the save wrote, else the application's.
func (m menuTypes) contentDB(ctx context.Context) (*gorm.DB, error) {
if ctx == nil {
ctx = context.Background()
}
if tx, ok := cabana.TxFromContext(ctx); ok {
return tx.Session(&gorm.Session{NewDB: true, Context: ctx}), nil
}
return m.db(ctx)
}
// postContent is the D-93 descriptor of journal-post: posts are edited by
// the posts controller and carry the Post morph name.
func (m menuTypes) postContent() *pact.ContentDescriptor {
return &pact.ContentDescriptor{
Controller: postsControllerID,
Model: models.Post{}.MorphName(),
Records: m.listPosts,
Describe: m.describePost,
Match: m.matchPost,
}
}
// describePost describes one post, drafts included: Published tells
// whether visitors see it.
func (m menuTypes) describePost(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
id, ok := menuReferenceID(in.ID)
if !ok {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
var posts []models.Post
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil {
return nil, err
}
if len(posts) == 0 {
return nil, nil
}
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale)
if err != nil {
return nil, err
}
return &records[0], nil
}
// describePosts describes posts in the given order. Each locale's URL is
// the alternate resolvePost builds; Title is the translated title,
// Description the excerpt as plain text, Image the first featured image and
// Words the body's word count, each translated with the default value as
// fallback.
func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, pattern, unprefixed string) ([]pact.ContentRecord, error) {
in := pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}
items, err := resolvePostItems(ctx, db, posts, in)
if err != nil {
return nil, err
}
locales, err := loadMenuLocales(ctx, db, unprefixed)
if err != nil {
return nil, err
}
ids := make([]uint, 0, len(posts))
for _, p := range posts {
ids = append(ids, p.ID)
}
tr, err := loadMenuTranslations(ctx, db, models.Post{}.MorphName(), ids, locales)
if err != nil {
return nil, err
}
images, err := firstImages(ctx, db, models.Post{}.MorphName(), "featured_images", ids)
if err != nil {
return nil, err
}
out := make([]pact.ContentRecord, 0, len(posts))
for i := range posts {
p := &posts[i]
rec := pact.ContentRecord{
ID: strconv.FormatUint(uint64(p.ID), 10),
Published: postPublished(p),
LastMod: copyTime(p.UpdatedAt),
}
for _, alt := range items[i].Alternates {
locale := alt.Locale
if locale == xDefaultLocale {
locale = ""
}
body := tr.value(locale, p.ID, "content_html", p.ContentHTML)
if strings.TrimSpace(body) == "" {
body = tr.value(locale, p.ID, "content", p.Content)
}
words := wordCount(plainText(body))
rec.Locales = append(rec.Locales, pact.ContentLocale{
Locale: alt.Locale,
URL: alt.URL,
Title: alt.Title,
Description: strings.TrimSpace(plainText(tr.value(locale, p.ID, "excerpt", p.Excerpt))),
Image: images[p.ID],
Words: &words,
})
}
out = append(out, rec)
}
return out, nil
}
// postPublished is publishedPosts for one loaded post.
func postPublished(p *models.Post) bool {
return p.Published && p.PublishedAt != nil && p.PublishedAt.Before(time.Now())
}
// firstImages maps each record id to the public URL of its first file in
// field (sort order, then id).
func firstImages(ctx context.Context, db *gorm.DB, morph, field string, ids []uint) (map[uint]string, error) {
out := map[uint]string{}
if len(ids) == 0 {
return out, nil
}
keys := make([]string, 0, len(ids))
for _, id := range ids {
keys = append(keys, strconv.FormatUint(uint64(id), 10))
}
var files []attach.File
err := db.WithContext(ctx).
Where("attachment_type = ? AND field = ? AND attachment_id IN ?", morph, field, keys).
Order("sort_order ASC, id ASC").
Find(&files).Error
if err != nil {
return nil, err
}
for i := range files {
id, err := strconv.ParseUint(files[i].AttachmentID, 10, 32)
if err != nil {
continue
}
if _, seen := out[uint(id)]; !seen {
out[uint(id)] = files[i].URL()
}
}
return out, nil
}
func copyTime(t *time.Time) *time.Time {
if t == nil {
return nil
}
v := *t
return &v
}
// plainText drops HTML tags and unescapes entities, with whitespace runs
// collapsed to one space.
func plainText(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
b.WriteRune(' ')
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
return strings.Join(strings.Fields(html.UnescapeString(b.String())), " ")
}
// wordCount counts the whitespace-separated words of s.
func wordCount(s string) int {
n := 0
in := false
for _, r := range s {
if unicode.IsSpace(r) {
in = false
continue
}
if !in {
n++
in = true
}
}
return n
}
// TagPagesKey is the host configuration key that says whether the site has
// tag pages (default true). With false, journal-tag items resolve to
// nothing, tag descriptions carry empty URLs and tag paths never match.
const TagPagesKey = "golem15.journal.tag_pages"
// maxContentPage caps the page size of a descriptor's Records; a limit of 0
// or below asks for the cap.
const maxContentPage = 500
func pageLimit(n int) int {
if n <= 0 || n > maxContentPage {
return maxContentPage
}
return n
}
// afterID parses a Records After cursor: "" or an invalid value is the
// start.
func afterID(after string) uint {
id, _ := menuReferenceID(after)
return id
}
// tagPages reports TagPagesKey, true when unset.
func (m menuTypes) tagPages() bool {
if m.app == nil {
return true
}
app := m.app()
if app == nil || app.Config == nil || !app.Config.Has(TagPagesKey) {
return true
}
return app.Config.Bool(TagPagesKey)
}
// listPosts is the Records of journal-post: every post, drafts included, in
// id order.
func (m menuTypes) listPosts(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
db, err := m.contentDB(ctx)
if err != nil {
return pact.ContentPage{}, err
}
limit := pageLimit(in.Limit)
var posts []models.Post
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&posts).Error; err != nil {
return pact.ContentPage{}, err
}
more := len(posts) > limit
if more {
posts = posts[:limit]
}
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale)
if err != nil {
return pact.ContentPage{}, err
}
return contentPage(records, more), nil
}
func contentPage(records []pact.ContentRecord, more bool) pact.ContentPage {
page := pact.ContentPage{Records: records}
if page.Records == nil {
page.Records = []pact.ContentRecord{}
}
if more && len(records) > 0 {
page.Next = records[len(records)-1].ID
}
return page
}
// matchPost is the Match of journal-post. The post is found by its slug in
// the path's locale, else its default slug, else a slug of another locale,
// else by :id; drafts never match. The match is exact only when the path is
// the post's own URL in that locale, so every segment (category, date, id,
// locale prefix, slug) must fit the post.
func (m menuTypes) matchPost(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
morph := models.Post{}.MorphName()
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
return publishedPosts(q.Model(&models.Post{}))
})
}
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
var posts []models.Post
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil || len(posts) == 0 {
return nil, err
}
items, err := resolvePostItems(ctx, db, posts, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale})
if err != nil {
return nil, err
}
return items[0].Alternates, nil
}
return matchContent(ctx, db, in, find, urls)
}
// categoryContent is the D-93 descriptor of journal-category.
func (m menuTypes) categoryContent() *pact.ContentDescriptor {
return &pact.ContentDescriptor{
Controller: categoriesControllerID,
Model: models.Category{}.MorphName(),
Records: m.listCategories,
Describe: m.describeCategory,
Match: m.matchCategory,
}
}
func (m menuTypes) describeCategory(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
id, ok := menuReferenceID(in.ID)
if !ok {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
var cats []models.Category
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil {
return nil, err
}
if len(cats) == 0 {
return nil, nil
}
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale)
if err != nil {
return nil, err
}
return &records[0], nil
}
func (m menuTypes) listCategories(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
db, err := m.contentDB(ctx)
if err != nil {
return pact.ContentPage{}, err
}
limit := pageLimit(in.Limit)
var cats []models.Category
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&cats).Error; err != nil {
return pact.ContentPage{}, err
}
more := len(cats) > limit
if more {
cats = cats[:limit]
}
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale)
if err != nil {
return pact.ContentPage{}, err
}
return contentPage(records, more), nil
}
// describeCategories describes categories, always published: each locale's
// URL and title as resolveCategory builds them, the translated description
// as plain text and the word count of the category's content.
func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category, pattern, unprefixed string) ([]pact.ContentRecord, error) {
ids := make([]uint, 0, len(cats))
for _, c := range cats {
ids = append(ids, c.ID)
}
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}, ids)
if err != nil {
return nil, err
}
out := make([]pact.ContentRecord, 0, len(cats))
for _, c := range cats {
item := ci.item(c)
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(c.ID), 10), Published: true, LastMod: copyTime(c.UpdatedAt)}
var words *int
if c.Content != nil && strings.TrimSpace(*c.Content) != "" {
n := wordCount(plainText(*c.Content))
words = &n
}
host := ""
if c.Description != nil {
host = *c.Description
}
for _, alt := range item.Alternates {
locale := alt.Locale
if locale == xDefaultLocale {
locale = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{
Locale: alt.Locale,
URL: alt.URL,
Title: alt.Title,
Description: plainText(ci.tr.value(locale, c.ID, "description", host)),
Words: words,
})
}
out = append(out, rec)
}
return out, nil
}
// matchCategory is the Match of journal-category, by translated or default
// slug or :id, exact only on the category's own URL in the path's locale.
func (m menuTypes) matchCategory(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
morph := models.Category{}.MorphName()
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
return q.Model(&models.Category{})
})
}
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
var cats []models.Category
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil || len(cats) == 0 {
return nil, err
}
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale}, []uint{id})
if err != nil {
return nil, err
}
return ci.item(cats[0]).Alternates, nil
}
return matchContent(ctx, db, in, find, urls)
}
// tagContent is the D-93 descriptor of journal-tag.
func (m menuTypes) tagContent() *pact.ContentDescriptor {
return &pact.ContentDescriptor{
Controller: tagsControllerID,
Model: models.Tag{}.MorphName(),
Records: m.listTags,
Describe: m.describeTag,
Match: m.matchTag,
}
}
// tagReferences lists every tag by name, ties by id.
func (m menuTypes) tagReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
db, err := m.db(ctx)
if err != nil {
return nil, err
}
var tags []models.Tag
if err := db.Order("name ASC, id ASC").Find(&tags).Error; err != nil {
return nil, err
}
refs := make([]pact.MenuItemReference, 0, len(tags))
for _, tag := range tags {
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(tag.ID), 10), Label: tag.Name})
}
return refs, nil
}
// resolveTag resolves one journal-tag item; an unknown tag, and any tag on a
// host without tag pages, resolve to nothing.
func (m menuTypes) resolveTag(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
id, ok := menuReferenceID(in.Reference)
if !ok || !m.tagPages() {
return nil, nil
}
db, err := m.db(ctx)
if err != nil {
return nil, err
}
var tags []models.Tag
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, nil
}
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
tag := tags[0]
item := resolvedMenuItem(in.Pattern, locales, tag.Name, tag.UpdatedAt, tagParams(tag), func(string) string { return tag.Name })
return &item, nil
}
// tagParams are a tag's placeholder values; tags are not translatable.
func tagParams(tag models.Tag) func(string) [][2]string {
return func(string) [][2]string {
return [][2]string{{"id", strconv.FormatUint(uint64(tag.ID), 10)}, {"slug", tag.Slug}}
}
}
func (m menuTypes) describeTag(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
id, ok := menuReferenceID(in.ID)
if !ok {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
var tags []models.Tag
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, nil
}
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
if err != nil {
return nil, err
}
return &records[0], nil
}
func (m menuTypes) listTags(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
db, err := m.contentDB(ctx)
if err != nil {
return pact.ContentPage{}, err
}
limit := pageLimit(in.Limit)
var tags []models.Tag
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&tags).Error; err != nil {
return pact.ContentPage{}, err
}
more := len(tags) > limit
if more {
tags = tags[:limit]
}
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
if err != nil {
return pact.ContentPage{}, err
}
return contentPage(records, more), nil
}
// describeTags describes tags, always published, one locale per enabled
// locale with the same name and slug; URLs are empty without tag pages.
func (m menuTypes) describeTags(ctx context.Context, db *gorm.DB, tags []models.Tag, pattern, unprefixed string) ([]pact.ContentRecord, error) {
locales, err := loadMenuLocales(ctx, db, unprefixed)
if err != nil {
return nil, err
}
pages := m.tagPages()
out := make([]pact.ContentRecord, 0, len(tags))
for _, tag := range tags {
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(tag.ID), 10), Published: true, LastMod: copyTime(tag.UpdatedAt)}
desc := ""
if tag.Description != nil {
desc = plainText(*tag.Description)
}
for _, alt := range menuAlternates(pattern, locales, tagParams(tag), func(string) string { return tag.Name }) {
url := alt.URL
if !pages {
url = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: tag.Name, Description: desc})
}
out = append(out, rec)
}
return out, nil
}
// matchTag is the Match of journal-tag, by slug or :id; nothing matches on
// a host without tag pages.
func (m menuTypes) matchTag(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
if !m.tagPages() {
return nil, nil
}
db, err := m.contentDB(ctx)
if err != nil {
return nil, err
}
find := func(values map[string]string, _ string, _ menuLocales) (uint, error) {
q := db.Model(&models.Tag{})
switch {
case values["slug"] != "":
q = q.Where("slug = ?", values["slug"])
case values["id"] != "":
id, ok := menuReferenceID(values["id"])
if !ok {
return 0, nil
}
q = q.Where("id = ?", id)
default:
return 0, nil
}
var ids []uint
if err := q.Order("id ASC").Limit(1).Pluck("id", &ids).Error; err != nil || len(ids) == 0 {
return 0, err
}
return ids[0], nil
}
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
var tags []models.Tag
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil || len(tags) == 0 {
return nil, err
}
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
tag := tags[0]
return menuAlternates(in.Pattern, locales, tagParams(tag), func(string) string { return tag.Name }), nil
}
return matchContent(ctx, db, in, find, urls)
}
// pathReading is one way to read a public path: the locale it addresses,
// the path without its locale prefix, and whether the prefix is the one the
// locale's URLs carry.
type pathReading struct {
locale string
rest string
prefixOK bool
}
// readPath lists the readings of path: with a prefix of an enabled locale
// other than the unprefixed one, with the unprefixed locale's own code as a
// prefix (stale, never exact), and as a path of the unprefixed locale.
func readPath(path string, locales menuLocales) []pathReading {
out := []pathReading{}
seg, rest := path, "/"
if trimmed := strings.TrimPrefix(path, "/"); trimmed != path {
if i := strings.IndexByte(trimmed, '/'); i >= 0 {
seg, rest = trimmed[:i], trimmed[i:]
} else {
seg = trimmed
}
for _, code := range locales.codes {
if seg == code {
out = append(out, pathReading{locale: code, rest: rest, prefixOK: code != locales.unprefixed})
}
}
}
return append(out, pathReading{locale: locales.unprefixed, rest: path, prefixOK: true})
}
// matchContent reads the path in every way readPath lists, finds the record
// its placeholder values name (find) and compares the path with the
// record's URL in the reading's locale (urls). The first exact reading
// wins, else the first that found a record.
func matchContent(ctx context.Context, db *gorm.DB, in pact.ContentMatch, find func(values map[string]string, locale string, locales menuLocales) (uint, error), urls func(id uint) ([]pact.MenuItemAlternate, error)) (*pact.ContentMatchResult, error) {
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
if err != nil {
return nil, err
}
var fallback *pact.ContentMatchResult
for _, reading := range readPath(in.Path, locales) {
values, ok := signpost.MatchPattern(in.Pattern, reading.rest)
if !ok {
continue
}
id, err := find(values, reading.locale, locales)
if err != nil {
return nil, err
}
if id == 0 {
continue
}
alternates, err := urls(id)
if err != nil {
return nil, err
}
exact := false
for _, alt := range alternates {
if alt.Locale == reading.locale || (len(locales.codes) == 0 && alt.Locale == xDefaultLocale) {
exact = reading.prefixOK && alt.URL == in.Path
break
}
}
res := &pact.ContentMatchResult{ID: strconv.FormatUint(uint64(id), 10), Locale: reading.locale, Exact: exact}
if exact {
return res, nil
}
if fallback == nil {
fallback = res
}
}
return fallback, nil
}
// findMatchRecord finds the record a path's values name among scope: by
// :slug, first the slug of the path's locale (translate's slug index), then
// the default slug, then a slug of any other locale; else by :id. A value
// reaches the database only as a query parameter.
func findMatchRecord(ctx context.Context, db *gorm.DB, values map[string]string, locale string, locales menuLocales, morph string, scope func(*gorm.DB) *gorm.DB) (uint, error) {
first := func(ids []uint) (uint, error) {
if len(ids) == 0 {
return 0, nil
}
var found []uint
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("id IN ?", ids).Order("id ASC").Limit(1).Pluck("id", &found).Error; err != nil || len(found) == 0 {
return 0, err
}
return found[0], nil
}
if slug := values["slug"]; slug != "" {
if locale != "" && locale != locales.defaultCode {
ids, err := slugIndexIDs(ctx, db, morph, slug, locale)
if err != nil {
return 0, err
}
if id, err := first(ids); err != nil || id != 0 {
return id, err
}
}
var hosts []uint
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("slug = ?", slug).Order("id ASC").Pluck("id", &hosts).Error; err != nil {
return 0, err
}
if id, err := first(hosts); err != nil || id != 0 {
return id, err
}
ids, err := slugIndexIDs(ctx, db, morph, slug, "")
if err != nil {
return 0, err
}
return first(ids)
}
if raw := values["id"]; raw != "" {
id, ok := menuReferenceID(raw)
if !ok {
return 0, nil
}
return first([]uint{id})
}
return 0, nil
}
// slugIndexIDs are the ids of morph records whose translated slug is slug
// in locale ("" for any locale), from translate's index table.
func slugIndexIDs(ctx context.Context, db *gorm.DB, morph, slug, locale string) ([]uint, error) {
q := db.WithContext(ctx).Table(translatemodels.Index{}.TableName()).
Where("model_type = ? AND item = ? AND value = ?", morph, "slug", slug)
if locale != "" {
q = q.Where("locale = ?", locale)
}
var raw []string
if err := q.Order("id ASC").Pluck("model_id", &raw).Error; err != nil {
return nil, err
}
ids := make([]uint, 0, len(raw))
for _, r := range raw {
if id, ok := menuReferenceID(r); ok {
ids = append(ids, id)
}
}
return ids, nil
}