Add categories feature #3
61
models/categories.go
Normal file
61
models/categories.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SiteID int64 `json:"site_id"`
|
||||||
|
GUID string `json:"guid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CategoryWithCount is a Category plus the count of published posts in it.
|
||||||
|
type CategoryWithCount struct {
|
||||||
|
Category
|
||||||
|
PostCount int
|
||||||
|
DescriptionBrief string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCategorySlug creates a URL-safe slug from a category name.
|
||||||
|
// e.g. "Go Programming" -> "go-programming"
|
||||||
|
func GenerateCategorySlug(name string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
prevDash := false
|
||||||
|
for _, c := range strings.TrimSpace(name) {
|
||||||
|
if unicode.IsLetter(c) || unicode.IsNumber(c) {
|
||||||
|
sb.WriteRune(unicode.ToLower(c))
|
||||||
|
prevDash = false
|
||||||
|
} else if unicode.IsSpace(c) || c == '-' || c == '_' {
|
||||||
|
if !prevDash && sb.Len() > 0 {
|
||||||
|
sb.WriteRune('-')
|
||||||
|
prevDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := sb.String()
|
||||||
|
return strings.TrimRight(result, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BriefDescription returns the first sentence or line of the description.
|
||||||
|
func BriefDescription(desc string) string {
|
||||||
|
if desc == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i, c := range desc {
|
||||||
|
if c == '\n' {
|
||||||
|
return desc[:i]
|
||||||
|
}
|
||||||
|
if c == '.' && i+1 < len(desc) {
|
||||||
|
return desc[:i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return desc
|
||||||
|
}
|
||||||
28
models/categories_test.go
Normal file
28
models/categories_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package models_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateCategorySlug(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Go Programming", "go-programming"},
|
||||||
|
{" Travel ", "travel"},
|
||||||
|
{"hello---world", "hello-world"},
|
||||||
|
{"UPPER CASE", "upper-case"},
|
||||||
|
{"one", "one"},
|
||||||
|
{"with_underscores", "with-underscores"},
|
||||||
|
{"special!@#chars", "specialchars"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, models.GenerateCategorySlug(tt.name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,4 @@ var PermissionError = errors.New("permission denied")
|
||||||
var NotFoundError = errors.New("not found")
|
var NotFoundError = errors.New("not found")
|
||||||
var SiteRequiredError = errors.New("site required")
|
var SiteRequiredError = errors.New("site required")
|
||||||
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")
|
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")
|
||||||
|
var SlugConflictError = errors.New("a category with this slug already exists")
|
||||||
|
|
|
||||||
23
sql/schema/04_categories.up.sql
Normal file
23
sql/schema/04_categories.up.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
guid TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_categories_site ON categories (site_id);
|
||||||
|
CREATE UNIQUE INDEX idx_categories_guid ON categories (guid);
|
||||||
|
CREATE UNIQUE INDEX idx_categories_site_slug ON categories (site_id, slug);
|
||||||
|
|
||||||
|
CREATE TABLE post_categories (
|
||||||
|
post_id INTEGER NOT NULL,
|
||||||
|
category_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (post_id, category_id),
|
||||||
|
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_post_categories_category ON post_categories (category_id);
|
||||||
Loading…
Reference in a new issue