Compare commits
10 commits
847e8e76d0
...
9efa40879f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9efa40879f | ||
|
|
6c69131b03 | ||
|
|
4c2ce7272d | ||
|
|
ffa86b12e9 | ||
|
|
3c80f63a55 | ||
|
|
15bc6b7f73 | ||
|
|
d47095a902 | ||
|
|
641b402d4a | ||
|
|
41c8d1e2f5 | ||
|
|
9a02a2f8af |
|
|
@ -109,9 +109,10 @@ Starting weiro without any arguments will start the server.
|
||||||
|
|
||||||
ih := handlers.IndexHandler{SiteService: svcs.Sites}
|
ih := handlers.IndexHandler{SiteService: svcs.Sites}
|
||||||
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
|
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
|
||||||
ph := handlers.PostsHandler{PostService: svcs.Posts}
|
ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories}
|
||||||
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
|
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
|
||||||
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
|
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
|
||||||
|
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
|
||||||
|
|
||||||
app.Get("/login", lh.Login)
|
app.Get("/login", lh.Login)
|
||||||
app.Post("/login", lh.DoLogin)
|
app.Post("/login", lh.DoLogin)
|
||||||
|
|
@ -141,6 +142,13 @@ Starting weiro without any arguments will start the server.
|
||||||
siteGroup.Get("/settings", ssh.General)
|
siteGroup.Get("/settings", ssh.General)
|
||||||
siteGroup.Post("/settings", ssh.UpdateGeneral)
|
siteGroup.Post("/settings", ssh.UpdateGeneral)
|
||||||
|
|
||||||
|
siteGroup.Get("/categories", ch.Index)
|
||||||
|
siteGroup.Get("/categories/new", ch.New)
|
||||||
|
siteGroup.Get("/categories/:categoryID", ch.Edit)
|
||||||
|
siteGroup.Post("/categories", ch.Create)
|
||||||
|
siteGroup.Post("/categories/:categoryID", ch.Update)
|
||||||
|
siteGroup.Post("/categories/:categoryID/delete", ch.Delete)
|
||||||
|
|
||||||
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
|
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
|
||||||
app.Get("/first-run", ih.FirstRun)
|
app.Get("/first-run", ih.FirstRun)
|
||||||
app.Post("/first-run", ih.FirstRunSubmit)
|
app.Post("/first-run", ih.FirstRunSubmit)
|
||||||
|
|
|
||||||
2036
docs/superpowers/plans/2026-03-18-categories.md
Normal file
2036
docs/superpowers/plans/2026-03-18-categories.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@ CREATE TABLE categories (
|
||||||
slug TEXT NOT NULL,
|
slug TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
|
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_categories_site ON categories (site_id);
|
CREATE INDEX idx_categories_site ON categories (site_id);
|
||||||
|
|
@ -44,11 +45,13 @@ type Category struct {
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `slug` is auto-generated from `name` (e.g. "Go Programming" -> `go-programming`), editable by the user.
|
- `slug` is auto-generated from `name` (e.g. "Go Programming" -> `go-programming`), editable by the user.
|
||||||
- `description` is Markdown, rendered on the category archive page. Defaults to empty string.
|
- `description` is Markdown, rendered on the category archive page. Defaults to empty string.
|
||||||
|
- DB provider must use the existing `timeToInt()`/`time.Unix()` helpers for timestamp conversion, consistent with how posts are handled.
|
||||||
|
|
||||||
## Admin UI
|
## Admin UI
|
||||||
|
|
||||||
|
|
@ -66,7 +69,7 @@ Templates: `views/categories/index.html`, `views/categories/edit.html`.
|
||||||
|
|
||||||
### Post Edit Form Changes
|
### Post Edit Form Changes
|
||||||
|
|
||||||
- A multi-select checkbox list of all available categories, displayed in a **right sidebar** alongside the main title/body editing area on the left.
|
- A multi-select checkbox list of all available categories (sorted alphabetically by name), displayed in a **right sidebar** alongside the main title/body editing area on the left.
|
||||||
- Selected category IDs sent with the form submission.
|
- Selected category IDs sent with the form submission.
|
||||||
- `CreatePostParams` gains `CategoryIDs []int64`.
|
- `CreatePostParams` gains `CategoryIDs []int64`.
|
||||||
|
|
||||||
|
|
@ -114,8 +117,8 @@ New file: `sql/queries/categories.sql`
|
||||||
- `SelectCategory` — single category by ID
|
- `SelectCategory` — single category by ID
|
||||||
- `SelectCategoryByGUID` — single category by GUID
|
- `SelectCategoryByGUID` — single category by GUID
|
||||||
- `SelectCategoriesOfPost` — categories for a given post (via join table)
|
- `SelectCategoriesOfPost` — categories for a given post (via join table)
|
||||||
- `SelectPostsOfCategory` — published, non-deleted posts in a category, ordered by `published_at` desc
|
- `SelectPostsOfCategory` — published, non-deleted posts in a category (`state = 0 AND deleted_at = 0`), ordered by `published_at` desc
|
||||||
- `CountPostsOfCategory` — count of published posts per category
|
- `CountPostsOfCategory` — count of published posts per category (same `state = 0 AND deleted_at = 0` filter)
|
||||||
- `InsertCategory` / `UpdateCategory` / `DeleteCategory` — CRUD
|
- `InsertCategory` / `UpdateCategory` / `DeleteCategory` — CRUD
|
||||||
- `InsertPostCategory` / `DeletePostCategory` — manage the join table
|
- `InsertPostCategory` / `DeletePostCategory` — manage the join table
|
||||||
- `DeletePostCategoriesByPost` — clear all categories for a post (delete-then-reinsert on save)
|
- `DeletePostCategoriesByPost` — clear all categories for a post (delete-then-reinsert on save)
|
||||||
|
|
@ -128,25 +131,28 @@ New file: `sql/queries/categories.sql`
|
||||||
|
|
||||||
- `ListCategories(ctx) ([]Category, error)` — all categories for the current site (from context)
|
- `ListCategories(ctx) ([]Category, error)` — all categories for the current site (from context)
|
||||||
- `GetCategory(ctx, id) (*Category, error)`
|
- `GetCategory(ctx, id) (*Category, error)`
|
||||||
- `CreateCategory(ctx, params) (*Category, error)` — auto-generates slug from name
|
- `CreateCategory(ctx, params) (*Category, error)` — auto-generates slug from name. If the slug collides with an existing one for the same site, return a validation error.
|
||||||
- `UpdateCategory(ctx, params) (*Category, error)`
|
- `UpdateCategory(ctx, params) (*Category, error)` — same slug collision check on update.
|
||||||
- `DeleteCategory(ctx, id) error` — deletes category and post associations, queues site rebuild
|
- `DeleteCategory(ctx, id) error` — deletes category and post associations, queues site rebuild
|
||||||
|
|
||||||
|
All mutation methods verify site ownership (same pattern as post service authorization checks).
|
||||||
|
|
||||||
### Changes to `services/posts`
|
### Changes to `services/posts`
|
||||||
|
|
||||||
- `UpdatePost` — after saving the post, deletes existing `post_categories` rows and re-inserts for the selected category IDs
|
- `UpdatePost` — after saving the post, deletes existing `post_categories` rows and re-inserts for the selected category IDs. The post save and category reassignment must run within a single database transaction to ensure atomicity.
|
||||||
- `GetPost` / `ListPosts` — loads each post's categories for admin display
|
- `GetPost` / `ListPosts` — loads each post's categories for admin display
|
||||||
|
|
||||||
### Changes to Publishing Pipeline
|
### Changes to Publishing Pipeline
|
||||||
|
|
||||||
- `pubmodel.Site` gains new fields:
|
- `pubmodel.Site` gains new fields:
|
||||||
- Category list (with post counts and description excerpts for the index page)
|
- `Categories []CategoryWithCount` — category list with post counts and description excerpts for the index page
|
||||||
- A function to iterate published posts by category
|
- `PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]]` — iterator for posts in a specific category
|
||||||
- `sitebuilder.Builder.BuildSite` gains additional goroutines for:
|
- `sitebuilder.Builder.BuildSite` gains additional goroutines for:
|
||||||
- Rendering the category index page
|
- Rendering the category index page
|
||||||
- Rendering each category archive page
|
- Rendering each category archive page
|
||||||
- Rendering per-category feeds
|
- Rendering per-category feeds
|
||||||
- New templates: `tmplNameCategoryList`, `tmplNameCategorySingle`
|
- New templates: `tmplNameCategoryList`, `tmplNameCategorySingle` (must be added to the `ParseFS` call in `sitebuilder.New()`)
|
||||||
|
- `postSingleData` gains a `Categories []Category` field so post templates can render category links
|
||||||
|
|
||||||
### Rebuild Triggers
|
### Rebuild Triggers
|
||||||
|
|
||||||
|
|
@ -155,3 +161,9 @@ Saving or deleting a category queues a site rebuild, same as post state changes.
|
||||||
## DB Provider
|
## DB Provider
|
||||||
|
|
||||||
`providers/db/` gains wrapper methods for all new sqlc queries, following the same pattern as existing post methods (e.g. `SaveCategory`, `SelectCategoriesOfPost`, etc.).
|
`providers/db/` gains wrapper methods for all new sqlc queries, following the same pattern as existing post methods (e.g. `SaveCategory`, `SelectCategoriesOfPost`, etc.).
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
- **Hard delete for categories** — unlike posts which use soft-delete, categories are hard-deleted. They are simpler entities and don't need a trash/restore workflow.
|
||||||
|
- **No sort_order column** — categories are sorted alphabetically by name. Manual ordering can be added later if needed.
|
||||||
|
- **Existing microblog-crosspost feed** — kept as-is. Per-category feeds are a separate, additive feature.
|
||||||
|
|
|
||||||
101
handlers/categories.go
Normal file
101
handlers/categories.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/services/categories"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CategoriesHandler struct {
|
||||||
|
CategoryService *categories.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch CategoriesHandler) Index(c fiber.Ctx) error {
|
||||||
|
cats, err := ch.CategoryService.ListCategoriesWithCounts(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Render("categories/index", fiber.Map{
|
||||||
|
"categories": cats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch CategoriesHandler) New(c fiber.Ctx) error {
|
||||||
|
cat := models.Category{
|
||||||
|
GUID: models.NewNanoID(),
|
||||||
|
}
|
||||||
|
return c.Render("categories/edit", fiber.Map{
|
||||||
|
"category": cat,
|
||||||
|
"isNew": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch CategoriesHandler) Edit(c fiber.Ctx) error {
|
||||||
|
catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := ch.CategoryService.GetCategory(c.Context(), catID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Render("categories/edit", fiber.Map{
|
||||||
|
"category": cat,
|
||||||
|
"isNew": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch CategoriesHandler) Create(c fiber.Ctx) error {
|
||||||
|
var req categories.CreateCategoryParams
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ch.CategoryService.CreateCategory(c.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
site := models.MustGetSite(c.Context())
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch CategoriesHandler) Update(c fiber.Ctx) error {
|
||||||
|
catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
var req categories.CreateCategoryParams
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ch.CategoryService.UpdateCategory(c.Context(), catID, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
site := models.MustGetSite(c.Context())
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ch CategoriesHandler) Delete(c fiber.Ctx) error {
|
||||||
|
catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ch.CategoryService.DeleteCategory(c.Context(), catID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
site := models.MustGetSite(c.Context())
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID))
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,13 @@ import (
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/services/categories"
|
||||||
"lmika.dev/lmika/weiro/services/posts"
|
"lmika.dev/lmika/weiro/services/posts"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PostsHandler struct {
|
type PostsHandler struct {
|
||||||
PostService *posts.Service
|
PostService *posts.Service
|
||||||
|
CategoryService *categories.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ph PostsHandler) Index(c fiber.Ctx) error {
|
func (ph PostsHandler) Index(c fiber.Ctx) error {
|
||||||
|
|
@ -42,8 +44,15 @@ func (ph PostsHandler) New(c fiber.Ctx) error {
|
||||||
State: models.StateDraft,
|
State: models.StateDraft,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cats, err := ph.CategoryService.ListCategories(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return c.Render("posts/edit", fiber.Map{
|
return c.Render("posts/edit", fiber.Map{
|
||||||
"post": p,
|
"post": p,
|
||||||
|
"categories": cats,
|
||||||
|
"selectedCategories": map[int64]bool{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,11 +71,28 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cats, err := ph.CategoryService.ListCategories(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
postCats, err := ph.PostService.GetPostCategories(c.Context(), postID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCategories := make(map[int64]bool)
|
||||||
|
for _, pc := range postCats {
|
||||||
|
selectedCategories[pc.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
return accepts(c, json(func() any {
|
return accepts(c, json(func() any {
|
||||||
return post
|
return post
|
||||||
}), html(func(c fiber.Ctx) error {
|
}), html(func(c fiber.Ctx) error {
|
||||||
return c.Render("posts/edit", fiber.Map{
|
return c.Render("posts/edit", fiber.Map{
|
||||||
"post": post,
|
"post": post,
|
||||||
|
"categories": cats,
|
||||||
|
"selectedCategories": selectedCategories,
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
layouts/simplecss/categories_list.html
Normal file
9
layouts/simplecss/categories_list.html
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<h2>Categories</h2>
|
||||||
|
<ul>
|
||||||
|
{{ range .Categories }}
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_abs .Path }}">{{ .Name }}</a> ({{ .PostCount }})
|
||||||
|
{{ if .DescriptionBrief }}<br><small>{{ .DescriptionBrief }}</small>{{ end }}
|
||||||
|
</li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
16
layouts/simplecss/categories_single.html
Normal file
16
layouts/simplecss/categories_single.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<h2>{{ .Category.Name }}</h2>
|
||||||
|
{{ if .DescriptionHTML }}
|
||||||
|
<div>{{ .DescriptionHTML }}</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ range .Posts }}
|
||||||
|
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
|
||||||
|
{{ .HTML }}
|
||||||
|
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
|
||||||
|
{{ if .Categories }}
|
||||||
|
<p>
|
||||||
|
{{ range .Categories }}
|
||||||
|
<a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
@ -2,4 +2,11 @@
|
||||||
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
|
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
|
||||||
{{ .HTML }}
|
{{ .HTML }}
|
||||||
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
|
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
|
||||||
|
{{ if .Categories }}
|
||||||
|
<p>
|
||||||
|
{{ range .Categories }}
|
||||||
|
<a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
|
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
|
||||||
{{ .HTML }}
|
{{ .HTML }}
|
||||||
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
|
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
|
||||||
|
{{ if .Categories }}
|
||||||
|
<p>
|
||||||
|
{{ range .Categories }}
|
||||||
|
<a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@ import (
|
||||||
type Site struct {
|
type Site struct {
|
||||||
models.Site
|
models.Site
|
||||||
BaseURL string
|
BaseURL string
|
||||||
//Posts []*models.Post
|
|
||||||
Uploads []models.Upload
|
Uploads []models.Upload
|
||||||
|
|
||||||
OpenUpload func(u models.Upload) (io.ReadCloser, error)
|
OpenUpload func(u models.Upload) (io.ReadCloser, error)
|
||||||
|
|
||||||
// PostItr returns a new post iterator
|
|
||||||
PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]]
|
PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]]
|
||||||
|
Categories []models.CategoryWithCount
|
||||||
|
PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]]
|
||||||
|
CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
providers/db/categories.go
Normal file
132
providers/db/categories.go
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *Provider) SelectCategoriesOfSite(ctx context.Context, siteID int64) ([]*models.Category, error) {
|
||||||
|
rows, err := db.queries.SelectCategoriesOfSite(ctx, siteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cats := make([]*models.Category, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
cats[i] = dbCategoryToCategory(row)
|
||||||
|
}
|
||||||
|
return cats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) SelectCategory(ctx context.Context, id int64) (*models.Category, error) {
|
||||||
|
row, err := db.queries.SelectCategory(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dbCategoryToCategory(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) SelectCategoryBySlugAndSite(ctx context.Context, siteID int64, slug string) (*models.Category, error) {
|
||||||
|
row, err := db.queries.SelectCategoryBySlugAndSite(ctx, sqlgen.SelectCategoryBySlugAndSiteParams{
|
||||||
|
SiteID: siteID,
|
||||||
|
Slug: slug,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dbCategoryToCategory(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) SaveCategory(ctx context.Context, cat *models.Category) error {
|
||||||
|
if cat.ID == 0 {
|
||||||
|
newID, err := db.queries.InsertCategory(ctx, sqlgen.InsertCategoryParams{
|
||||||
|
SiteID: cat.SiteID,
|
||||||
|
Guid: cat.GUID,
|
||||||
|
Name: cat.Name,
|
||||||
|
Slug: cat.Slug,
|
||||||
|
Description: cat.Description,
|
||||||
|
CreatedAt: timeToInt(cat.CreatedAt),
|
||||||
|
UpdatedAt: timeToInt(cat.UpdatedAt),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cat.ID = newID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.queries.UpdateCategory(ctx, sqlgen.UpdateCategoryParams{
|
||||||
|
ID: cat.ID,
|
||||||
|
Name: cat.Name,
|
||||||
|
Slug: cat.Slug,
|
||||||
|
Description: cat.Description,
|
||||||
|
UpdatedAt: timeToInt(cat.UpdatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) DeleteCategory(ctx context.Context, id int64) error {
|
||||||
|
return db.queries.DeleteCategory(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([]*models.Category, error) {
|
||||||
|
rows, err := db.queries.SelectCategoriesOfPost(ctx, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cats := make([]*models.Category, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
cats[i] = dbCategoryToCategory(row)
|
||||||
|
}
|
||||||
|
return cats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) SelectPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) {
|
||||||
|
rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{
|
||||||
|
CategoryID: categoryID,
|
||||||
|
Limit: pp.Limit,
|
||||||
|
Offset: pp.Offset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
posts := make([]*models.Post, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
posts[i] = dbPostToPost(row)
|
||||||
|
}
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) CountPostsOfCategory(ctx context.Context, categoryID int64) (int64, error) {
|
||||||
|
return db.queries.CountPostsOfCategory(ctx, categoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPostCategories replaces all category associations for a post.
|
||||||
|
func (db *Provider) SetPostCategories(ctx context.Context, postID int64, categoryIDs []int64) error {
|
||||||
|
if err := db.queries.DeletePostCategoriesByPost(ctx, postID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, catID := range categoryIDs {
|
||||||
|
if err := db.queries.InsertPostCategory(ctx, sqlgen.InsertPostCategoryParams{
|
||||||
|
PostID: postID,
|
||||||
|
CategoryID: catID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbCategoryToCategory(row sqlgen.Category) *models.Category {
|
||||||
|
return &models.Category{
|
||||||
|
ID: row.ID,
|
||||||
|
SiteID: row.SiteID,
|
||||||
|
GUID: row.Guid,
|
||||||
|
Name: row.Name,
|
||||||
|
Slug: row.Slug,
|
||||||
|
Description: row.Description,
|
||||||
|
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
|
||||||
|
UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
305
providers/db/gen/sqlgen/categories.sql.go
Normal file
305
providers/db/gen/sqlgen/categories.sql.go
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.28.0
|
||||||
|
// source: categories.sql
|
||||||
|
|
||||||
|
package sqlgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const countPostsOfCategory = `-- name: CountPostsOfCategory :one
|
||||||
|
SELECT COUNT(*) FROM posts p
|
||||||
|
INNER JOIN post_categories pc ON pc.post_id = p.id
|
||||||
|
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountPostsOfCategory(ctx context.Context, categoryID int64) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, countPostsOfCategory, categoryID)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCategory = `-- name: DeleteCategory :exec
|
||||||
|
DELETE FROM categories WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteCategory(ctx context.Context, id int64) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteCategory, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePostCategoriesByPost = `-- name: DeletePostCategoriesByPost :exec
|
||||||
|
DELETE FROM post_categories WHERE post_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeletePostCategoriesByPost(ctx context.Context, postID int64) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deletePostCategoriesByPost, postID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertCategory = `-- name: InsertCategory :one
|
||||||
|
INSERT INTO categories (
|
||||||
|
site_id, guid, name, slug, description, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertCategoryParams struct {
|
||||||
|
SiteID int64
|
||||||
|
Guid string
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
Description string
|
||||||
|
CreatedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertCategory(ctx context.Context, arg InsertCategoryParams) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, insertCategory,
|
||||||
|
arg.SiteID,
|
||||||
|
arg.Guid,
|
||||||
|
arg.Name,
|
||||||
|
arg.Slug,
|
||||||
|
arg.Description,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
var id int64
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertPostCategory = `-- name: InsertPostCategory :exec
|
||||||
|
INSERT OR IGNORE INTO post_categories (post_id, category_id) VALUES (?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertPostCategoryParams struct {
|
||||||
|
PostID int64
|
||||||
|
CategoryID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertPostCategory(ctx context.Context, arg InsertPostCategoryParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, insertPostCategory, arg.PostID, arg.CategoryID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCategoriesOfPost = `-- name: SelectCategoriesOfPost :many
|
||||||
|
SELECT c.id, c.site_id, c.guid, c.name, c.slug, c.description, c.created_at, c.updated_at FROM categories c
|
||||||
|
INNER JOIN post_categories pc ON pc.category_id = c.id
|
||||||
|
WHERE pc.post_id = ?
|
||||||
|
ORDER BY c.name ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SelectCategoriesOfPost(ctx context.Context, postID int64) ([]Category, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, selectCategoriesOfPost, postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Category
|
||||||
|
for rows.Next() {
|
||||||
|
var i Category
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Name,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCategoriesOfSite = `-- name: SelectCategoriesOfSite :many
|
||||||
|
SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories
|
||||||
|
WHERE site_id = ? ORDER BY name ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SelectCategoriesOfSite(ctx context.Context, siteID int64) ([]Category, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, selectCategoriesOfSite, siteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Category
|
||||||
|
for rows.Next() {
|
||||||
|
var i Category
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Name,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCategory = `-- name: SelectCategory :one
|
||||||
|
SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE id = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SelectCategory(ctx context.Context, id int64) (Category, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, selectCategory, id)
|
||||||
|
var i Category
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Name,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCategoryByGUID = `-- name: SelectCategoryByGUID :one
|
||||||
|
SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE guid = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SelectCategoryByGUID(ctx context.Context, guid string) (Category, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, selectCategoryByGUID, guid)
|
||||||
|
var i Category
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Name,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCategoryBySlugAndSite = `-- name: SelectCategoryBySlugAndSite :one
|
||||||
|
SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE site_id = ? AND slug = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type SelectCategoryBySlugAndSiteParams struct {
|
||||||
|
SiteID int64
|
||||||
|
Slug string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SelectCategoryBySlugAndSite(ctx context.Context, arg SelectCategoryBySlugAndSiteParams) (Category, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, selectCategoryBySlugAndSite, arg.SiteID, arg.Slug)
|
||||||
|
var i Category
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Name,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Description,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectPostsOfCategory = `-- name: SelectPostsOfCategory :many
|
||||||
|
SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at FROM posts p
|
||||||
|
INNER JOIN post_categories pc ON pc.post_id = p.id
|
||||||
|
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
||||||
|
ORDER BY p.published_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type SelectPostsOfCategoryParams struct {
|
||||||
|
CategoryID int64
|
||||||
|
Limit int64
|
||||||
|
Offset int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SelectPostsOfCategory(ctx context.Context, arg SelectPostsOfCategoryParams) ([]Post, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, selectPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Post
|
||||||
|
for rows.Next() {
|
||||||
|
var i Post
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.State,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Title,
|
||||||
|
&i.Body,
|
||||||
|
&i.Slug,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.PublishedAt,
|
||||||
|
&i.DeletedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCategory = `-- name: UpdateCategory :exec
|
||||||
|
UPDATE categories SET
|
||||||
|
name = ?,
|
||||||
|
slug = ?,
|
||||||
|
description = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateCategoryParams struct {
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
Description string
|
||||||
|
UpdatedAt int64
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateCategory(ctx context.Context, arg UpdateCategoryParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updateCategory,
|
||||||
|
arg.Name,
|
||||||
|
arg.Slug,
|
||||||
|
arg.Description,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
arg.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.28.0
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.28.0
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
ID int64
|
||||||
|
SiteID int64
|
||||||
|
Guid string
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
Description string
|
||||||
|
CreatedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
type PendingUpload struct {
|
type PendingUpload struct {
|
||||||
ID int64
|
ID int64
|
||||||
SiteID int64
|
SiteID int64
|
||||||
|
|
@ -29,6 +40,11 @@ type Post struct {
|
||||||
DeletedAt int64
|
DeletedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PostCategory struct {
|
||||||
|
PostID int64
|
||||||
|
CategoryID int64
|
||||||
|
}
|
||||||
|
|
||||||
type PublishTarget struct {
|
type PublishTarget struct {
|
||||||
ID int64
|
ID int64
|
||||||
SiteID int64
|
SiteID int64
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.28.0
|
||||||
// source: pending_uploads.sql
|
// source: pending_uploads.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.28.0
|
||||||
// source: posts.sql
|
// source: posts.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.28.0
|
||||||
// source: pubtargets.sql
|
// source: pubtargets.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.28.0
|
||||||
// source: sites.sql
|
// source: sites.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.28.0
|
||||||
// source: uploads.sql
|
// source: uploads.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.28.0
|
||||||
// source: users.sql
|
// source: users.sql
|
||||||
|
|
||||||
package sqlgen
|
package sqlgen
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,17 @@ func (db *Provider) Close() error {
|
||||||
return db.drvr.Close()
|
return db.drvr.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *Provider) BeginTx(ctx context.Context) (*sql.Tx, error) {
|
||||||
|
return db.drvr.BeginTx(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) QueriesWithTx(tx *sql.Tx) *Provider {
|
||||||
|
return &Provider{
|
||||||
|
drvr: db.drvr,
|
||||||
|
queries: db.queries.WithTx(tx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (db *Provider) SoftDeletePost(ctx context.Context, postID int64) error {
|
func (db *Provider) SoftDeletePost(ctx context.Context, postID int64) error {
|
||||||
return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{
|
return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{
|
||||||
DeletedAt: time.Now().Unix(),
|
DeletedAt: time.Now().Unix(),
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ func TestProvider_Posts(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotZero(t, post.ID)
|
assert.NotZero(t, post.ID)
|
||||||
|
|
||||||
posts, err := p.SelectPostsOfSite(ctx, site.ID, false)
|
posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, posts, 1)
|
require.Len(t, posts, 1)
|
||||||
assert.Equal(t, post.ID, posts[0].ID)
|
assert.Equal(t, post.ID, posts[0].ID)
|
||||||
|
|
@ -205,7 +205,7 @@ func TestProvider_Posts(t *testing.T) {
|
||||||
require.NoError(t, p.SavePost(ctx, post1))
|
require.NoError(t, p.SavePost(ctx, post1))
|
||||||
require.NoError(t, p.SavePost(ctx, post2))
|
require.NoError(t, p.SavePost(ctx, post2))
|
||||||
|
|
||||||
posts, err := p.SelectPostsOfSite(ctx, site2.ID, false)
|
posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, posts, 2)
|
require.Len(t, posts, 2)
|
||||||
assert.Equal(t, "New Post", posts[0].Title)
|
assert.Equal(t, "New Post", posts[0].Title)
|
||||||
|
|
@ -220,7 +220,7 @@ func TestProvider_Posts(t *testing.T) {
|
||||||
}
|
}
|
||||||
require.NoError(t, p.SaveSite(ctx, emptySite))
|
require.NoError(t, p.SaveSite(ctx, emptySite))
|
||||||
|
|
||||||
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false)
|
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false, db.PagingParams{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, posts)
|
assert.Empty(t, posts)
|
||||||
})
|
})
|
||||||
|
|
@ -283,6 +283,165 @@ func TestProvider_PublishTargets(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProvider_Categories(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
p := newTestDB(t)
|
||||||
|
|
||||||
|
user := &models.User{Username: "testuser", PasswordHashed: []byte("password")}
|
||||||
|
require.NoError(t, p.SaveUser(ctx, user))
|
||||||
|
|
||||||
|
site := &models.Site{OwnerID: user.ID, Title: "My Blog", Tagline: "test"}
|
||||||
|
require.NoError(t, p.SaveSite(ctx, site))
|
||||||
|
|
||||||
|
t.Run("save and select categories", func(t *testing.T) {
|
||||||
|
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
|
||||||
|
cat := &models.Category{
|
||||||
|
SiteID: site.ID,
|
||||||
|
GUID: "cat-001",
|
||||||
|
Name: "Go Programming",
|
||||||
|
Slug: "go-programming",
|
||||||
|
Description: "Posts about Go",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.SaveCategory(ctx, cat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, cat.ID)
|
||||||
|
|
||||||
|
cats, err := p.SelectCategoriesOfSite(ctx, site.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cats, 1)
|
||||||
|
assert.Equal(t, "Go Programming", cats[0].Name)
|
||||||
|
assert.Equal(t, "go-programming", cats[0].Slug)
|
||||||
|
assert.Equal(t, "Posts about Go", cats[0].Description)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("update category", func(t *testing.T) {
|
||||||
|
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
|
||||||
|
cat := &models.Category{
|
||||||
|
SiteID: site.ID,
|
||||||
|
GUID: "cat-002",
|
||||||
|
Name: "Original",
|
||||||
|
Slug: "original",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
require.NoError(t, p.SaveCategory(ctx, cat))
|
||||||
|
|
||||||
|
cat.Name = "Updated"
|
||||||
|
cat.Slug = "updated"
|
||||||
|
cat.UpdatedAt = now.Add(time.Hour)
|
||||||
|
require.NoError(t, p.SaveCategory(ctx, cat))
|
||||||
|
|
||||||
|
got, err := p.SelectCategory(ctx, cat.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Updated", got.Name)
|
||||||
|
assert.Equal(t, "updated", got.Slug)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("delete category", func(t *testing.T) {
|
||||||
|
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
|
||||||
|
cat := &models.Category{
|
||||||
|
SiteID: site.ID,
|
||||||
|
GUID: "cat-003",
|
||||||
|
Name: "ToDelete",
|
||||||
|
Slug: "to-delete",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
require.NoError(t, p.SaveCategory(ctx, cat))
|
||||||
|
|
||||||
|
err := p.DeleteCategory(ctx, cat.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = p.SelectCategory(ctx, cat.ID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_PostCategories(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
p := newTestDB(t)
|
||||||
|
|
||||||
|
user := &models.User{Username: "testuser", PasswordHashed: []byte("password")}
|
||||||
|
require.NoError(t, p.SaveUser(ctx, user))
|
||||||
|
|
||||||
|
site := &models.Site{OwnerID: user.ID, Title: "My Blog", Tagline: "test"}
|
||||||
|
require.NoError(t, p.SaveSite(ctx, site))
|
||||||
|
|
||||||
|
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
|
||||||
|
post := &models.Post{
|
||||||
|
SiteID: site.ID,
|
||||||
|
GUID: "post-pc-001",
|
||||||
|
Title: "Test Post",
|
||||||
|
Body: "body",
|
||||||
|
Slug: "/test",
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
require.NoError(t, p.SavePost(ctx, post))
|
||||||
|
|
||||||
|
cat1 := &models.Category{SiteID: site.ID, GUID: "cat-pc-1", Name: "Alpha", Slug: "alpha", CreatedAt: now, UpdatedAt: now}
|
||||||
|
cat2 := &models.Category{SiteID: site.ID, GUID: "cat-pc-2", Name: "Beta", Slug: "beta", CreatedAt: now, UpdatedAt: now}
|
||||||
|
require.NoError(t, p.SaveCategory(ctx, cat1))
|
||||||
|
require.NoError(t, p.SaveCategory(ctx, cat2))
|
||||||
|
|
||||||
|
t.Run("set and get post categories", func(t *testing.T) {
|
||||||
|
err := p.SetPostCategories(ctx, post.ID, []int64{cat1.ID, cat2.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cats, 2)
|
||||||
|
assert.Equal(t, "Alpha", cats[0].Name)
|
||||||
|
assert.Equal(t, "Beta", cats[1].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("replace post categories", func(t *testing.T) {
|
||||||
|
err := p.SetPostCategories(ctx, post.ID, []int64{cat2.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cats, 1)
|
||||||
|
assert.Equal(t, "Beta", cats[0].Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("clear post categories", func(t *testing.T) {
|
||||||
|
err := p.SetPostCategories(ctx, post.ID, []int64{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, cats)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("count posts of category", func(t *testing.T) {
|
||||||
|
post.State = models.StatePublished
|
||||||
|
post.PublishedAt = now
|
||||||
|
require.NoError(t, p.SavePost(ctx, post))
|
||||||
|
require.NoError(t, p.SetPostCategories(ctx, post.ID, []int64{cat1.ID}))
|
||||||
|
|
||||||
|
count, err := p.CountPostsOfCategory(ctx, cat1.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(1), count)
|
||||||
|
|
||||||
|
count, err = p.CountPostsOfCategory(ctx, cat2.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(0), count)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cascade delete category removes associations", func(t *testing.T) {
|
||||||
|
require.NoError(t, p.SetPostCategories(ctx, post.ID, []int64{cat1.ID, cat2.ID}))
|
||||||
|
require.NoError(t, p.DeleteCategory(ctx, cat1.ID))
|
||||||
|
|
||||||
|
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cats, 1)
|
||||||
|
assert.Equal(t, "Beta", cats[0].Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Verify that password encoding roundtrips correctly through base64
|
// Verify that password encoding roundtrips correctly through base64
|
||||||
func TestProvider_UserPasswordEncoding(t *testing.T) {
|
func TestProvider_UserPasswordEncoding(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ type Builder struct {
|
||||||
func New(site pubmodel.Site, opts Options) (*Builder, error) {
|
func New(site pubmodel.Site, opts Options) (*Builder, error) {
|
||||||
tmpls, err := template.New("").
|
tmpls, err := template.New("").
|
||||||
Funcs(templateFns(site, opts)).
|
Funcs(templateFns(site, opts)).
|
||||||
ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain)
|
ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,13 @@ func (b *Builder) BuildSite(outDir string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := b.writePost(buildCtx, post); err != nil {
|
rp, err := b.renderPostWithCategories(ctx, post)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := b.createAtPath(buildCtx, rp.Path, func(f io.Writer) error {
|
||||||
|
return b.renderTemplate(f, tmplNamePostSingle, rp)
|
||||||
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,10 +76,7 @@ func (b *Builder) BuildSite(outDir string) error {
|
||||||
})
|
})
|
||||||
|
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil {
|
return b.renderPostListWithCategories(buildCtx, ctx)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
|
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
|
|
@ -93,43 +96,42 @@ func (b *Builder) BuildSite(outDir string) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Category pages
|
||||||
|
eg.Go(func() error {
|
||||||
|
if err := b.renderCategoryList(buildCtx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.renderCategoryPages(buildCtx, ctx)
|
||||||
|
})
|
||||||
|
|
||||||
// Copy uploads
|
// Copy uploads
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
if err := b.writeUploads(buildCtx, b.site.Uploads); err != nil {
|
return b.writeUploads(buildCtx, b.site.Uploads)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
if err := eg.Wait(); err != nil {
|
|
||||||
return err
|
return eg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error {
|
||||||
}
|
var posts []postSingleData
|
||||||
|
for mp := range b.site.PostIter(ctx) {
|
||||||
func (b *Builder) renderPostList(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error {
|
|
||||||
// TODO: paging
|
|
||||||
postCopy := make([]*models.Post, 0)
|
|
||||||
for mp := range postIter {
|
|
||||||
post, err := mp.Get()
|
post, err := mp.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
postCopy = append(postCopy, post)
|
rp, err := b.renderPostWithCategories(ctx, post)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
posts = append(posts, rp)
|
||||||
}
|
}
|
||||||
|
|
||||||
pl := postListData{
|
pl := postListData{
|
||||||
commonData: commonData{Site: b.site},
|
commonData: commonData{Site: b.site},
|
||||||
}
|
Posts: posts,
|
||||||
for _, post := range postCopy {
|
|
||||||
rp, err := b.renderPost(post)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pl.Posts = append(pl.Posts, rp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.createAtPath(ctx, "", func(f io.Writer) error {
|
return b.createAtPath(bctx, "", func(f io.Writer) error {
|
||||||
return b.renderTemplate(f, tmplNamePostList, pl)
|
return b.renderTemplate(f, tmplNamePostList, pl)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -156,6 +158,18 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var catName string
|
||||||
|
if b.site.CategoriesOfPost != nil {
|
||||||
|
cats, err := b.site.CategoriesOfPost(context.Background(), post.ID)
|
||||||
|
if err == nil && len(cats) > 0 {
|
||||||
|
names := make([]string, len(cats))
|
||||||
|
for i, c := range cats {
|
||||||
|
names[i] = c.Name
|
||||||
|
}
|
||||||
|
catName = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
postTitle := post.Title
|
postTitle := post.Title
|
||||||
if postTitle != "" {
|
if postTitle != "" {
|
||||||
postTitle = opts.titlePrefix + postTitle
|
postTitle = opts.titlePrefix + postTitle
|
||||||
|
|
@ -166,6 +180,7 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*
|
||||||
Title: postTitle,
|
Title: postTitle,
|
||||||
Link: &feedhub.Link{Href: renderedPost.PostURL},
|
Link: &feedhub.Link{Href: renderedPost.PostURL},
|
||||||
Content: string(renderedPost.HTML),
|
Content: string(renderedPost.HTML),
|
||||||
|
Category: catName,
|
||||||
// TO FIX: Created should be first published
|
// TO FIX: Created should be first published
|
||||||
Created: post.PublishedAt,
|
Created: post.PublishedAt,
|
||||||
Updated: post.UpdatedAt,
|
Updated: post.UpdatedAt,
|
||||||
|
|
@ -243,14 +258,142 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Builder) writePost(ctx buildContext, post *models.Post) error {
|
// renderPostWithCategories renders a post and attaches its categories.
|
||||||
|
func (b *Builder) renderPostWithCategories(ctx context.Context, post *models.Post) (postSingleData, error) {
|
||||||
rp, err := b.renderPost(post)
|
rp, err := b.renderPost(post)
|
||||||
|
if err != nil {
|
||||||
|
return postSingleData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if b.site.CategoriesOfPost != nil {
|
||||||
|
cats, err := b.site.CategoriesOfPost(ctx, post.ID)
|
||||||
|
if err != nil {
|
||||||
|
return postSingleData{}, err
|
||||||
|
}
|
||||||
|
rp.Categories = cats
|
||||||
|
}
|
||||||
|
|
||||||
|
return rp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Builder) renderCategoryList(ctx buildContext) error {
|
||||||
|
var items []categoryListItem
|
||||||
|
for _, cwc := range b.site.Categories {
|
||||||
|
if cwc.PostCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, categoryListItem{
|
||||||
|
CategoryWithCount: cwc,
|
||||||
|
Path: fmt.Sprintf("/categories/%s", cwc.Slug),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data := categoryListData{
|
||||||
|
commonData: commonData{Site: b.site},
|
||||||
|
Categories: items,
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.createAtPath(ctx, "/categories", func(f io.Writer) error {
|
||||||
|
return b.renderTemplate(f, tmplNameCategoryList, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) error {
|
||||||
|
for _, cwc := range b.site.Categories {
|
||||||
|
if cwc.PostCount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var posts []postSingleData
|
||||||
|
for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) {
|
||||||
|
post, err := mp.Get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
rp, err := b.renderPostWithCategories(goCtx, post)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
posts = append(posts, rp)
|
||||||
|
}
|
||||||
|
|
||||||
return b.createAtPath(ctx, rp.Path, func(f io.Writer) error {
|
var descHTML bytes.Buffer
|
||||||
return b.renderTemplate(f, tmplNamePostSingle, rp)
|
if cwc.Description != "" {
|
||||||
|
if err := b.mdRenderer.RenderTo(goCtx, &descHTML, cwc.Description); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := categorySingleData{
|
||||||
|
commonData: commonData{Site: b.site},
|
||||||
|
Category: &cwc.Category,
|
||||||
|
DescriptionHTML: template.HTML(descHTML.String()),
|
||||||
|
Posts: posts,
|
||||||
|
Path: fmt.Sprintf("/categories/%s", cwc.Slug),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.createAtPath(ctx, data.Path, func(f io.Writer) error {
|
||||||
|
return b.renderTemplate(f, tmplNameCategorySingle, data)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-category feeds
|
||||||
|
if err := b.renderCategoryFeed(ctx, cwc, posts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Builder) renderCategoryFeed(ctx buildContext, cwc models.CategoryWithCount, posts []postSingleData) error {
|
||||||
|
now := time.Now()
|
||||||
|
feed := &feedhub.Feed{
|
||||||
|
Title: b.site.Title + " - " + cwc.Name,
|
||||||
|
Link: &feedhub.Link{Href: b.site.BaseURL},
|
||||||
|
Description: cwc.DescriptionBrief,
|
||||||
|
Created: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, rp := range posts {
|
||||||
|
if i >= b.opts.FeedItems {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
feed.Items = append(feed.Items, &feedhub.Item{
|
||||||
|
Id: filepath.Join(b.site.BaseURL, rp.Post.GUID),
|
||||||
|
Title: rp.Post.Title,
|
||||||
|
Link: &feedhub.Link{Href: rp.PostURL},
|
||||||
|
Content: string(rp.HTML),
|
||||||
|
Created: rp.Post.PublishedAt,
|
||||||
|
Updated: rp.Post.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := fmt.Sprintf("/categories/%s/feed", cwc.Slug)
|
||||||
|
|
||||||
|
if err := b.createAtPath(ctx, prefix+".xml", func(f io.Writer) error {
|
||||||
|
rss, err := feed.ToRss()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.WriteString(f, rss)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.createAtPath(ctx, prefix+".json", func(f io.Writer) error {
|
||||||
|
j, err := feed.ToJSON()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.WriteString(f, j)
|
||||||
|
return err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package sitebuilder_test
|
package sitebuilder_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"iter"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -18,11 +20,11 @@ func TestBuilder_BuildSite(t *testing.T) {
|
||||||
"posts_single.html": {Data: []byte(`{{ .HTML }}`)},
|
"posts_single.html": {Data: []byte(`{{ .HTML }}`)},
|
||||||
"posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Post.Title}}</a>,{{ end }}`)},
|
"posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Post.Title}}</a>,{{ end }}`)},
|
||||||
"layout_main.html": {Data: []byte(`{{ .Body }}`)},
|
"layout_main.html": {Data: []byte(`{{ .Body }}`)},
|
||||||
|
"categories_list.html": {Data: []byte(`{{ range .Categories}}<a href="{{url_abs .Path}}">{{.Name}}</a>,{{ end }}`)},
|
||||||
|
"categories_single.html": {Data: []byte(`<h2>{{.Category.Name}}</h2>`)},
|
||||||
}
|
}
|
||||||
|
|
||||||
site := pubmodel.Site{
|
posts := []*models.Post{
|
||||||
BaseURL: "https://example.com",
|
|
||||||
Posts: []*models.Post{
|
|
||||||
{
|
{
|
||||||
Title: "Test Post",
|
Title: "Test Post",
|
||||||
Slug: "/2026/02/18/test-post",
|
Slug: "/2026/02/18/test-post",
|
||||||
|
|
@ -33,6 +35,18 @@ func TestBuilder_BuildSite(t *testing.T) {
|
||||||
Slug: "/2026/02/20/another-post",
|
Slug: "/2026/02/20/another-post",
|
||||||
Body: "This is **another** test post",
|
Body: "This is **another** test post",
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
site := pubmodel.Site{
|
||||||
|
BaseURL: "https://example.com",
|
||||||
|
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
|
||||||
|
return func(yield func(models.Maybe[*models.Post]) bool) {
|
||||||
|
for _, p := range posts {
|
||||||
|
if !yield(models.Maybe[*models.Post]{Value: p}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
wantFiles := map[string]string{
|
wantFiles := map[string]string{
|
||||||
|
|
@ -58,5 +72,4 @@ func TestBuilder_BuildSite(t *testing.T) {
|
||||||
assert.Equal(t, content, string(fileContent))
|
assert.Equal(t, content, string(fileContent))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ const (
|
||||||
|
|
||||||
// tmplNameLayoutMain is the template for the main layout (layoutMainData)
|
// tmplNameLayoutMain is the template for the main layout (layoutMainData)
|
||||||
tmplNameLayoutMain = "layout_main.html"
|
tmplNameLayoutMain = "layout_main.html"
|
||||||
|
|
||||||
|
// tmplNameCategoryList is the template for the category index page
|
||||||
|
tmplNameCategoryList = "categories_list.html"
|
||||||
|
|
||||||
|
// tmplNameCategorySingle is the template for a single category page
|
||||||
|
tmplNameCategorySingle = "categories_single.html"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
|
|
@ -45,6 +51,7 @@ type postSingleData struct {
|
||||||
HTML template.HTML
|
HTML template.HTML
|
||||||
Path string
|
Path string
|
||||||
PostURL string
|
PostURL string
|
||||||
|
Categories []*models.Category
|
||||||
}
|
}
|
||||||
|
|
||||||
type postListData struct {
|
type postListData struct {
|
||||||
|
|
@ -56,3 +63,21 @@ type layoutData struct {
|
||||||
commonData
|
commonData
|
||||||
Body template.HTML
|
Body template.HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type categoryListData struct {
|
||||||
|
commonData
|
||||||
|
Categories []categoryListItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type categoryListItem struct {
|
||||||
|
models.CategoryWithCount
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
type categorySingleData struct {
|
||||||
|
commonData
|
||||||
|
Category *models.Category
|
||||||
|
DescriptionHTML template.HTML
|
||||||
|
Posts []postSingleData
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
|
||||||
178
services/categories/service.go
Normal file
178
services/categories/service.go
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package categories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/providers/db"
|
||||||
|
"lmika.dev/lmika/weiro/services/publisher"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateCategoryParams struct {
|
||||||
|
GUID string `form:"guid" json:"guid"`
|
||||||
|
Name string `form:"name" json:"name"`
|
||||||
|
Slug string `form:"slug" json:"slug"`
|
||||||
|
Description string `form:"description" json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *db.Provider
|
||||||
|
publisher *publisher.Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *db.Provider, publisher *publisher.Queue) *Service {
|
||||||
|
return &Service{db: db, publisher: publisher}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListCategories(ctx context.Context) ([]*models.Category, error) {
|
||||||
|
site, ok := models.GetSite(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, models.SiteRequiredError
|
||||||
|
}
|
||||||
|
return s.db.SelectCategoriesOfSite(ctx, site.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCategoriesWithCounts returns all categories for the site with published post counts.
|
||||||
|
func (s *Service) ListCategoriesWithCounts(ctx context.Context) ([]models.CategoryWithCount, error) {
|
||||||
|
site, ok := models.GetSite(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, models.SiteRequiredError
|
||||||
|
}
|
||||||
|
|
||||||
|
cats, err := s.db.SelectCategoriesOfSite(ctx, site.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]models.CategoryWithCount, len(cats))
|
||||||
|
for i, cat := range cats {
|
||||||
|
count, err := s.db.CountPostsOfCategory(ctx, cat.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[i] = models.CategoryWithCount{
|
||||||
|
Category: *cat,
|
||||||
|
PostCount: int(count),
|
||||||
|
DescriptionBrief: models.BriefDescription(cat.Description),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetCategory(ctx context.Context, id int64) (*models.Category, error) {
|
||||||
|
site, ok := models.GetSite(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, models.SiteRequiredError
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := s.db.SelectCategory(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cat.SiteID != site.ID {
|
||||||
|
return nil, models.NotFoundError
|
||||||
|
}
|
||||||
|
return cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateCategory(ctx context.Context, params CreateCategoryParams) (*models.Category, error) {
|
||||||
|
site, ok := models.GetSite(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, models.SiteRequiredError
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
slug := params.Slug
|
||||||
|
if slug == "" {
|
||||||
|
slug = models.GenerateCategorySlug(params.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for slug collision
|
||||||
|
if _, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil {
|
||||||
|
return nil, models.SlugConflictError
|
||||||
|
} else if !db.ErrorIsNoRows(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cat := &models.Category{
|
||||||
|
SiteID: site.ID,
|
||||||
|
GUID: params.GUID,
|
||||||
|
Name: params.Name,
|
||||||
|
Slug: slug,
|
||||||
|
Description: params.Description,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if cat.GUID == "" {
|
||||||
|
cat.GUID = models.NewNanoID()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.SaveCategory(ctx, cat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publisher.Queue(site)
|
||||||
|
return cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateCategory(ctx context.Context, id int64, params CreateCategoryParams) (*models.Category, error) {
|
||||||
|
site, ok := models.GetSite(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, models.SiteRequiredError
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := s.db.SelectCategory(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cat.SiteID != site.ID {
|
||||||
|
return nil, models.NotFoundError
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := params.Slug
|
||||||
|
if slug == "" {
|
||||||
|
slug = models.GenerateCategorySlug(params.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check slug collision (exclude self)
|
||||||
|
if existing, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != cat.ID {
|
||||||
|
return nil, models.SlugConflictError
|
||||||
|
} else if err != nil && !db.ErrorIsNoRows(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cat.Name = params.Name
|
||||||
|
cat.Slug = slug
|
||||||
|
cat.Description = params.Description
|
||||||
|
cat.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := s.db.SaveCategory(ctx, cat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publisher.Queue(site)
|
||||||
|
return cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteCategory(ctx context.Context, id int64) error {
|
||||||
|
site, ok := models.GetSite(ctx)
|
||||||
|
if !ok {
|
||||||
|
return models.SiteRequiredError
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := s.db.SelectCategory(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cat.SiteID != site.ID {
|
||||||
|
return models.NotFoundError
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.DeleteCategory(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publisher.Queue(site)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ type CreatePostParams struct {
|
||||||
Title string `form:"title" json:"title"`
|
Title string `form:"title" json:"title"`
|
||||||
Body string `form:"body" json:"body"`
|
Body string `form:"body" json:"body"`
|
||||||
Action string `form:"action" json:"action"`
|
Action string `form:"action" json:"action"`
|
||||||
|
CategoryIDs []int64 `form:"category_ids" json:"category_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
|
func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
|
||||||
|
|
@ -53,7 +54,21 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod
|
||||||
// Leave unchanged
|
// Leave unchanged
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.SavePost(ctx, post); err != nil {
|
// Use a transaction for atomicity of post save + category reassignment
|
||||||
|
tx, err := s.db.BeginTx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
txDB := s.db.QueriesWithTx(tx)
|
||||||
|
if err := txDB.SavePost(ctx, post); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := txDB.SetPostCategories(ctx, post.ID, params.CategoryIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,12 @@ import (
|
||||||
"lmika.dev/lmika/weiro/providers/db"
|
"lmika.dev/lmika/weiro/providers/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) {
|
type PostWithCategories struct {
|
||||||
|
*models.Post
|
||||||
|
Categories []*models.Category
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) {
|
||||||
site, ok := models.GetSite(ctx)
|
site, ok := models.GetSite(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, models.SiteRequiredError
|
return nil, models.SiteRequiredError
|
||||||
|
|
@ -21,7 +26,15 @@ func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Po
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return posts, nil
|
result := make([]*PostWithCategories, len(posts))
|
||||||
|
for i, post := range posts {
|
||||||
|
cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[i] = &PostWithCategories{Post: post, Categories: cats}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) {
|
func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) {
|
||||||
|
|
@ -32,3 +45,7 @@ func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error)
|
||||||
|
|
||||||
return post, nil
|
return post, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetPostCategories(ctx context.Context, postID int64) ([]*models.Category, error) {
|
||||||
|
return s.db.SelectCategoriesOfPost(ctx, postID)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"lmika.dev/lmika/weiro/providers/db"
|
"lmika.dev/lmika/weiro/providers/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostIter returns a post iterator which returns posts in reverse chronological order.
|
// postIter returns a post iterator which returns posts in reverse chronological order.
|
||||||
func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
|
func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
|
||||||
return func(yield func(models.Maybe[*models.Post]) bool) {
|
return func(yield func(models.Maybe[*models.Post]) bool) {
|
||||||
paging := db.PagingParams{Offset: 0, Limit: 50}
|
paging := db.PagingParams{Offset: 0, Limit: 50}
|
||||||
|
|
@ -39,3 +39,26 @@ func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Ma
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postIterByCategory returns a post iterator for posts in a specific category.
|
||||||
|
func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] {
|
||||||
|
return func(yield func(models.Maybe[*models.Post]) bool) {
|
||||||
|
paging := db.PagingParams{Offset: 0, Limit: 50}
|
||||||
|
for {
|
||||||
|
page, err := s.db.SelectPostsOfCategory(ctx, categoryID, paging)
|
||||||
|
if err != nil {
|
||||||
|
yield(models.Maybe[*models.Post]{Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(page) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, post := range page {
|
||||||
|
if !yield(models.Maybe[*models.Post]{Value: post}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paging.Offset += paging.Limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,24 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch categories with counts
|
||||||
|
cats, err := p.db.SelectCategoriesOfSite(ctx, site.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var catsWithCounts []models.CategoryWithCount
|
||||||
|
for _, cat := range cats {
|
||||||
|
count, err := p.db.CountPostsOfCategory(ctx, cat.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
catsWithCounts = append(catsWithCounts, models.CategoryWithCount{
|
||||||
|
Category: *cat,
|
||||||
|
PostCount: int(count),
|
||||||
|
DescriptionBrief: models.BriefDescription(cat.Description),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
if !target.Enabled {
|
if !target.Enabled {
|
||||||
continue
|
continue
|
||||||
|
|
@ -58,6 +76,13 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
|
||||||
},
|
},
|
||||||
BaseURL: target.BaseURL,
|
BaseURL: target.BaseURL,
|
||||||
Uploads: uploads,
|
Uploads: uploads,
|
||||||
|
Categories: catsWithCounts,
|
||||||
|
PostIterByCategory: func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] {
|
||||||
|
return p.postIterByCategory(ctx, categoryID)
|
||||||
|
},
|
||||||
|
CategoriesOfPost: func(ctx context.Context, postID int64) ([]*models.Category, error) {
|
||||||
|
return p.db.SelectCategoriesOfPost(ctx, postID)
|
||||||
|
},
|
||||||
OpenUpload: func(u models.Upload) (io.ReadCloser, error) {
|
OpenUpload: func(u models.Upload) (io.ReadCloser, error) {
|
||||||
return p.up.OpenUpload(site, u)
|
return p.up.OpenUpload(site, u)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"lmika.dev/lmika/weiro/providers/db"
|
"lmika.dev/lmika/weiro/providers/db"
|
||||||
"lmika.dev/lmika/weiro/providers/uploadfiles"
|
"lmika.dev/lmika/weiro/providers/uploadfiles"
|
||||||
"lmika.dev/lmika/weiro/services/auth"
|
"lmika.dev/lmika/weiro/services/auth"
|
||||||
|
"lmika.dev/lmika/weiro/services/categories"
|
||||||
"lmika.dev/lmika/weiro/services/posts"
|
"lmika.dev/lmika/weiro/services/posts"
|
||||||
"lmika.dev/lmika/weiro/services/publisher"
|
"lmika.dev/lmika/weiro/services/publisher"
|
||||||
"lmika.dev/lmika/weiro/services/sites"
|
"lmika.dev/lmika/weiro/services/sites"
|
||||||
|
|
@ -21,6 +22,7 @@ type Services struct {
|
||||||
Posts *posts.Service
|
Posts *posts.Service
|
||||||
Sites *sites.Service
|
Sites *sites.Service
|
||||||
Uploads *uploads.Service
|
Uploads *uploads.Service
|
||||||
|
Categories *categories.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config) (*Services, error) {
|
func New(cfg config.Config) (*Services, error) {
|
||||||
|
|
@ -37,6 +39,7 @@ func New(cfg config.Config) (*Services, error) {
|
||||||
postService := posts.New(dbp, publisherQueue)
|
postService := posts.New(dbp, publisherQueue)
|
||||||
siteService := sites.New(dbp)
|
siteService := sites.New(dbp)
|
||||||
uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending"))
|
uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending"))
|
||||||
|
categoriesService := categories.New(dbp, publisherQueue)
|
||||||
|
|
||||||
return &Services{
|
return &Services{
|
||||||
DB: dbp,
|
DB: dbp,
|
||||||
|
|
@ -46,6 +49,7 @@ func New(cfg config.Config) (*Services, error) {
|
||||||
Posts: postService,
|
Posts: postService,
|
||||||
Sites: siteService,
|
Sites: siteService,
|
||||||
Uploads: uploadService,
|
Uploads: uploadService,
|
||||||
|
Categories: categoriesService,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
53
sql/queries/categories.sql
Normal file
53
sql/queries/categories.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
-- name: SelectCategoriesOfSite :many
|
||||||
|
SELECT * FROM categories
|
||||||
|
WHERE site_id = ? ORDER BY name ASC;
|
||||||
|
|
||||||
|
-- name: SelectCategory :one
|
||||||
|
SELECT * FROM categories WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: SelectCategoryByGUID :one
|
||||||
|
SELECT * FROM categories WHERE guid = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: SelectCategoryBySlugAndSite :one
|
||||||
|
SELECT * FROM categories WHERE site_id = ? AND slug = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: SelectCategoriesOfPost :many
|
||||||
|
SELECT c.* FROM categories c
|
||||||
|
INNER JOIN post_categories pc ON pc.category_id = c.id
|
||||||
|
WHERE pc.post_id = ?
|
||||||
|
ORDER BY c.name ASC;
|
||||||
|
|
||||||
|
-- name: SelectPostsOfCategory :many
|
||||||
|
SELECT p.* FROM posts p
|
||||||
|
INNER JOIN post_categories pc ON pc.post_id = p.id
|
||||||
|
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
||||||
|
ORDER BY p.published_at DESC
|
||||||
|
LIMIT ? OFFSET ?;
|
||||||
|
|
||||||
|
-- name: CountPostsOfCategory :one
|
||||||
|
SELECT COUNT(*) FROM posts p
|
||||||
|
INNER JOIN post_categories pc ON pc.post_id = p.id
|
||||||
|
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0;
|
||||||
|
|
||||||
|
-- name: InsertCategory :one
|
||||||
|
INSERT INTO categories (
|
||||||
|
site_id, guid, name, slug, description, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
RETURNING id;
|
||||||
|
|
||||||
|
-- name: UpdateCategory :exec
|
||||||
|
UPDATE categories SET
|
||||||
|
name = ?,
|
||||||
|
slug = ?,
|
||||||
|
description = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteCategory :exec
|
||||||
|
DELETE FROM categories WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: InsertPostCategory :exec
|
||||||
|
INSERT OR IGNORE INTO post_categories (post_id, category_id) VALUES (?, ?);
|
||||||
|
|
||||||
|
-- name: DeletePostCategoriesByPost :exec
|
||||||
|
DELETE FROM post_categories WHERE post_id = ?;
|
||||||
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);
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a>
|
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/categories">Categories</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/uploads">Uploads</a>
|
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/uploads">Uploads</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
47
views/categories/edit.html
Normal file
47
views/categories/edit.html
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<main class="container">
|
||||||
|
<div class="my-4">
|
||||||
|
<h4>{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .isNew }}
|
||||||
|
<form method="post" action="/sites/{{ .site.ID }}/categories">
|
||||||
|
{{ else }}
|
||||||
|
<form method="post" action="/sites/{{ .site.ID }}/categories/{{ .category.ID }}">
|
||||||
|
{{ end }}
|
||||||
|
<input type="hidden" name="guid" value="{{ .category.GUID }}">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for="catName" class="col-sm-2 col-form-label">Name</label>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<input type="text" class="form-control" id="catName" name="name" value="{{ .category.Name }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for="catSlug" class="col-sm-2 col-form-label">Slug</label>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<input type="text" class="form-control" id="catSlug" name="slug" value="{{ .category.Slug }}">
|
||||||
|
<div class="form-text">Auto-generated from name if left blank.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for="catDesc" class="col-sm-2 col-form-label">Description</label>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<textarea class="form-control" id="catDesc" name="description" rows="5">{{ .category.Description }}</textarea>
|
||||||
|
<div class="form-text">Markdown supported. Displayed on the category archive page.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-sm-2"></div>
|
||||||
|
<div class="col-sm-9">
|
||||||
|
<button type="submit" class="btn btn-primary">{{ if .isNew }}Create{{ else }}Save{{ end }}</button>
|
||||||
|
{{ if not .isNew }}
|
||||||
|
<button type="button" class="btn btn-outline-danger ms-2"
|
||||||
|
onclick="if(confirm('Delete this category? Posts will not be deleted.')) { document.getElementById('delete-form').submit(); }">Delete</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{ if not .isNew }}
|
||||||
|
<form id="delete-form" method="post" action="/sites/{{ .site.ID }}/categories/{{ .category.ID }}/delete" style="display:none;"></form>
|
||||||
|
{{ end }}
|
||||||
|
</main>
|
||||||
35
views/categories/index.html
Normal file
35
views/categories/index.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<main class="container">
|
||||||
|
<div class="my-4 d-flex justify-content-between align-items-baseline">
|
||||||
|
<h4>Categories</h4>
|
||||||
|
<div>
|
||||||
|
<a href="/sites/{{ .site.ID }}/categories/new" class="btn btn-success">New Category</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Posts</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .categories }}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/sites/{{ $.site.ID }}/categories/{{ .ID }}">{{ .Name }}</a></td>
|
||||||
|
<td><code>{{ .Slug }}</code></td>
|
||||||
|
<td>{{ .PostCount }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/sites/{{ $.site.ID }}/categories/{{ .ID }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted py-4">No categories yet.</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
data-controller="postedit"
|
data-controller="postedit"
|
||||||
data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
|
data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
|
||||||
data-postedit-save-action-value="{{ if $isPublished }}Update{{ else }}Save Draft{{ end }}">
|
data-postedit-save-action-value="{{ if $isPublished }}Update{{ else }}Save Draft{{ end }}">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-9">
|
||||||
<input type="hidden" name="guid" value="{{ .post.GUID }}">
|
<input type="hidden" name="guid" value="{{ .post.GUID }}">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
|
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
|
||||||
|
|
@ -19,5 +21,24 @@
|
||||||
<input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
|
<input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Categories</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ range .categories }}
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="category_ids"
|
||||||
|
value="{{ .ID }}" id="cat-{{ .ID }}"
|
||||||
|
{{ if index $.selectedCategories .ID }}checked{{ end }}>
|
||||||
|
<label class="form-check-label" for="cat-{{ .ID }}">{{ .Name }}</label>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-muted">No categories yet.</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -26,11 +26,14 @@
|
||||||
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
|
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
|
||||||
{{ markdown $p.Body $.site }}
|
{{ markdown $p.Body $.site }}
|
||||||
|
|
||||||
<div class="mb-3 d-flex align-items-center">
|
<div class="mb-3 d-flex align-items-center flex-wrap gap-1">
|
||||||
{{ if eq .State 1 }}
|
{{ if eq $p.State 1 }}
|
||||||
<span class="text-muted">{{ $.user.FormatTime .UpdatedAt }}</span> <span class="ms-3 badge text-primary-emphasis bg-primary-subtle border border-primary-subtle">Draft</span>
|
<span class="text-muted">{{ $.user.FormatTime $p.UpdatedAt }}</span> <span class="ms-3 badge text-primary-emphasis bg-primary-subtle border border-primary-subtle">Draft</span>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<span class="text-muted">{{ $.user.FormatTime .PublishedAt }}</span>
|
<span class="text-muted">{{ $.user.FormatTime $p.PublishedAt }}</span>
|
||||||
|
{{ end }}
|
||||||
|
{{ range $p.Categories }}
|
||||||
|
<span class="ms-1 badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">{{ .Name }}</span>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue