diff --git a/models/categories.go b/models/categories.go new file mode 100644 index 0000000..5655009 --- /dev/null +++ b/models/categories.go @@ -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 +} diff --git a/models/categories_test.go b/models/categories_test.go new file mode 100644 index 0000000..facf08b --- /dev/null +++ b/models/categories_test.go @@ -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)) + }) + } +} diff --git a/models/errors.go b/models/errors.go index 997a952..eda780c 100644 --- a/models/errors.go +++ b/models/errors.go @@ -7,3 +7,4 @@ var PermissionError = errors.New("permission denied") var NotFoundError = errors.New("not found") var SiteRequiredError = errors.New("site required") 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") diff --git a/sql/schema/04_categories.up.sql b/sql/schema/04_categories.up.sql new file mode 100644 index 0000000..260d06b --- /dev/null +++ b/sql/schema/04_categories.up.sql @@ -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);