Categories
-| Name | -Slug | -Posts | -- |
|---|---|---|---|
| {{ .Name }} | -{{ .Slug }} |
- {{ .PostCount }} | -- Edit - | -
| No categories yet. | -|||
diff --git a/cmds/server.go b/cmds/server.go
index 56517e7..40c2690 100644
--- a/cmds/server.go
+++ b/cmds/server.go
@@ -109,10 +109,9 @@ Starting weiro without any arguments will start the server.
ih := handlers.IndexHandler{SiteService: svcs.Sites}
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
- ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories}
+ ph := handlers.PostsHandler{PostService: svcs.Posts}
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
- ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
app.Get("/login", lh.Login)
app.Post("/login", lh.DoLogin)
@@ -142,13 +141,6 @@ Starting weiro without any arguments will start the server.
siteGroup.Get("/settings", ssh.General)
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("/first-run", ih.FirstRun)
app.Post("/first-run", ih.FirstRunSubmit)
diff --git a/docs/superpowers/plans/2026-03-18-categories.md b/docs/superpowers/plans/2026-03-18-categories.md
deleted file mode 100644
index b4f3932..0000000
--- a/docs/superpowers/plans/2026-03-18-categories.md
+++ /dev/null
@@ -1,2036 +0,0 @@
-# Categories Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add flat, many-to-many categories to Weiro with admin CRUD, post assignment, static site archive pages, and per-category feeds.
-
-**Architecture:** New `categories` and `post_categories` tables in SQLite. New sqlc queries, DB provider methods, a `categories` service, and a `CategoriesHandler`. The site builder gains category index/archive page rendering and per-category feeds. Posts carry category associations managed via a join table with delete-and-reinsert on save.
-
-**Tech Stack:** Go 1.25, SQLite (sqlc), Fiber v3, Go html/template, Bootstrap 5, Stimulus.js
-
-**Spec:** `docs/superpowers/specs/2026-03-18-categories-design.md`
-
----
-
-## File Map
-
-| Action | File | Responsibility |
-|--------|------|---------------|
-| Create | `models/categories.go` | Category model + slug generation |
-| Create | `sql/schema/04_categories.up.sql` | Migration: categories + post_categories tables |
-| Create | `sql/queries/categories.sql` | All sqlc queries for categories |
-| Create | `providers/db/categories.go` | DB provider wrapper methods for categories |
-| Create | `services/categories/service.go` | Category service: CRUD + slug validation |
-| Create | `handlers/categories.go` | HTTP handlers for category admin pages |
-| Create | `views/categories/index.html` | Admin: category list page |
-| Create | `views/categories/edit.html` | Admin: category create/edit form |
-| Create | `layouts/simplecss/categories_list.html` | Published site: category index page template |
-| Create | `layouts/simplecss/categories_single.html` | Published site: category archive page template |
-| Modify | `models/errors.go` | Add `SlugConflictError` |
-| Modify | `providers/db/gen/sqlgen/*` | Regenerated by sqlc |
-| Modify | `providers/db/posts.go` | Add `SelectCategoriesOfPost`, `SetPostCategories` |
-| Modify | `providers/db/provider.go` | Expose `drvr` for transactions via `BeginTx` |
-| Modify | `models/pubmodel/sites.go` | Add `Categories`, `PostIterByCategory` fields |
-| Modify | `providers/sitebuilder/tmpls.go` | Add category template names + data structs |
-| Modify | `providers/sitebuilder/builder.go` | Render category pages + per-category feeds |
-| Modify | `services/posts/service.go` | Accept DB transaction support |
-| Modify | `services/posts/create.go` | Save category associations in transaction |
-| Modify | `services/publisher/service.go` | Populate category data on `pubmodel.Site` |
-| Modify | `services/publisher/iter.go` | Add `postIterByCategory` method |
-| Modify | `services/services.go` | Wire up categories service |
-| Modify | `cmds/server.go` | Register category routes + handler |
-| Modify | `views/posts/edit.html` | Add category sidebar with checkboxes |
-| Modify | `views/posts/index.html` | Show category badges on post list |
-| Modify | `views/_common/nav.html` | Add "Categories" nav link |
-
----
-
-## Task 1: Database Migration + Model
-
-**Files:**
-- Create: `sql/schema/04_categories.up.sql`
-- Create: `models/categories.go`
-- Modify: `models/errors.go`
-
-- [ ] **Step 1: Create the migration file**
-
-Create `sql/schema/04_categories.up.sql`:
-
-```sql
-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);
-```
-
-- [ ] **Step 2: Create the Category model**
-
-Create `models/categories.go`:
-
-```go
-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, "-")
-}
-```
-
-- [ ] **Step 3: Add SlugConflictError to models/errors.go**
-
-Add to `models/errors.go`:
-
-```go
-var SlugConflictError = errors.New("a category with this slug already exists")
-```
-
-- [ ] **Step 4: Write a test for GenerateCategorySlug**
-
-Create `models/categories_test.go`:
-
-```go
-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))
- })
- }
-}
-```
-
-- [ ] **Step 5: Run the test**
-
-Run: `go test ./models/ -run TestGenerateCategorySlug -v`
-Expected: PASS
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add models/categories.go models/categories_test.go models/errors.go sql/schema/04_categories.up.sql
-git commit -m "feat: add categories migration and model"
-```
-
----
-
-## Task 2: SQL Queries + sqlc Generation
-
-**Files:**
-- Create: `sql/queries/categories.sql`
-- Regenerate: `providers/db/gen/sqlgen/*`
-
-- [ ] **Step 1: Create the sqlc queries file**
-
-Create `sql/queries/categories.sql`:
-
-```sql
--- 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 = ?;
-```
-
-- [ ] **Step 2: Run sqlc generate**
-
-Run: `sqlc generate`
-Expected: No errors. New file `providers/db/gen/sqlgen/categories.sql.go` created and `models.go` updated with `Category` and `PostCategory` structs.
-
-- [ ] **Step 3: Verify the generated code compiles**
-
-Run: `go build ./providers/db/gen/sqlgen/`
-Expected: No errors.
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add sql/queries/categories.sql providers/db/gen/sqlgen/
-git commit -m "feat: add sqlc queries for categories"
-```
-
----
-
-## Task 3: DB Provider — Category Methods
-
-**Files:**
-- Create: `providers/db/categories.go`
-- Modify: `providers/db/provider.go`
-
-- [ ] **Step 1: Write failing test for category CRUD**
-
-Add to `providers/db/provider_test.go`:
-
-```go
-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) {
- // Publish the post (state=0)
- 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)
- })
-}
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `go test ./providers/db/ -run "TestProvider_Categories|TestProvider_PostCategories" -v`
-Expected: FAIL — `SaveCategory`, `SelectCategoriesOfSite`, etc. not defined.
-
-- [ ] **Step 3: Create the DB provider category methods**
-
-Create `providers/db/categories.go`:
-
-```go
-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.
-// It deletes existing associations and inserts the new ones.
-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(),
- }
-}
-```
-
-- [ ] **Step 4: Add BeginTx to provider for future transaction support**
-
-Add to `providers/db/provider.go`:
-
-```go
-import "database/sql"
-
-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),
- }
-}
-```
-
-- [ ] **Step 5: Run the tests**
-
-Run: `go test ./providers/db/ -run "TestProvider_Categories|TestProvider_PostCategories" -v`
-Expected: PASS
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add providers/db/categories.go providers/db/provider.go providers/db/provider_test.go
-git commit -m "feat: add DB provider methods for categories"
-```
-
----
-
-## Task 4: Categories Service
-
-**Files:**
-- Create: `services/categories/service.go`
-
-- [ ] **Step 1: Create the categories service**
-
-Create `services/categories/service.go`:
-
-```go
-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: briefDescription(cat.Description),
- }
- }
- return result, nil
-}
-
-func (s *Service) GetCategory(ctx context.Context, id int64) (*models.Category, error) {
- return s.db.SelectCategory(ctx, id)
-}
-
-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
- }
-
- 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
- }
-
- 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
-}
-
-// briefDescription returns the first sentence or line of the description.
-func briefDescription(desc string) string {
- if desc == "" {
- return ""
- }
- // Find first period followed by space, or first newline
- for i, c := range desc {
- if c == '\n' {
- return desc[:i]
- }
- if c == '.' && i+1 < len(desc) {
- return desc[:i+1]
- }
- }
- return desc
-}
-```
-
-- [ ] **Step 2: Verify it compiles**
-
-Run: `go build ./services/categories/`
-Expected: No errors.
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add services/categories/service.go
-git commit -m "feat: add categories service with CRUD and slug validation"
-```
-
----
-
-## Task 5: Wire Up Service + Categories Handler + Admin Routes
-
-**Files:**
-- Create: `handlers/categories.go`
-- Create: `views/categories/index.html`
-- Create: `views/categories/edit.html`
-- Modify: `services/services.go`
-- Modify: `cmds/server.go`
-- Modify: `views/_common/nav.html`
-
-- [ ] **Step 1: Wire up categories service in services.go**
-
-Modify `services/services.go` — add to the `Services` struct:
-
-```go
-Categories *categories.Service
-```
-
-Add to the `New` function (after `uploadService`):
-
-```go
-categoriesService := categories.New(dbp, publisherQueue)
-```
-
-Add to the return struct:
-
-```go
-Categories: categoriesService,
-```
-
-Add the import:
-
-```go
-"lmika.dev/lmika/weiro/services/categories"
-```
-
-- [ ] **Step 2: Create the categories handler**
-
-Create `handlers/categories.go`:
-
-```go
-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))
-}
-```
-
-- [ ] **Step 3: Create the category admin templates**
-
-Create `views/categories/index.html`:
-
-```html
-Categories
-
-
-
-
-
-
-
- {{ range .categories }}
- Name
- Slug
- Posts
-
-
-
- {{ else }}
- {{ .Name }}
-
- {{ .Slug }}{{ .PostCount }}
-
- Edit
-
-
-
- {{ end }}
-
- No categories yet.
- {{ if .isNew }}New Category{{ else }}Edit Category{{ end }}
-
- {{ range .Categories }} - {{ .Name }} - {{ end }} -
- {{ end }} -{{ end }} -``` - -- [ ] **Step 4: Update the post single template to show categories** - -Modify `layouts/simplecss/posts_single.html`: - -```html -{{ if .Post.Title }}- {{ range .Categories }} - {{ .Name }} - {{ end }} -
-{{ end }} -``` - -- [ ] **Step 5: Update the post list template to show categories** - -Modify `layouts/simplecss/posts_list.html`: - -```html -{{ range .Posts }} - {{ if .Post.Title }}- {{ range .Categories }} - {{ .Name }} - {{ end }} -
- {{ end }} -{{ end }} -``` - -- [ ] **Step 6: Register new templates in builder.go** - -Modify the `ParseFS` call in `sitebuilder.New()`: - -```go -tmpls, err := template.New(""). - Funcs(templateFns(site, opts)). - ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle) -``` - -- [ ] **Step 7: Add category rendering methods to builder.go** - -Add the following methods to `providers/sitebuilder/builder.go`: - -```go -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 { - return err - } - rp, err := b.renderPostWithCategories(goCtx, post) - if err != nil { - return err - } - posts = append(posts, rp) - } - - var descHTML bytes.Buffer - 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 - }) -} - -// 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) - 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 -} -``` - -- [ ] **Step 8: Update BuildSite to render categories and attach categories to posts** - -Modify `BuildSite` in `providers/sitebuilder/builder.go`. Update the post-writing goroutine and the post-list goroutine to use `renderPostWithCategories`. Add new goroutines for category pages: - -```go -func (b *Builder) BuildSite(outDir string) error { - buildCtx := buildContext{outDir: outDir} - - if err := os.RemoveAll(outDir); err != nil { - return err - } - - eg, ctx := errgroup.WithContext(context.Background()) - - eg.Go(func() error { - for mp := range b.site.PostIter(ctx) { - post, err := mp.Get() - if err != nil { - return err - } - 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 nil - }) - - eg.Go(func() error { - return b.renderPostListWithCategories(buildCtx, ctx) - }) - - eg.Go(func() error { - if err := b.renderFeeds(buildCtx, b.site.PostIter(ctx), feedOptions{ - targetNamePrefix: "/feed", - titlePrefix: "", - }); err != nil { - return err - } - - if err := b.renderFeeds(buildCtx, b.site.PostIter(ctx), feedOptions{ - targetNamePrefix: "/feeds/microblog-crosspost", - titlePrefix: "Devlog: ", - }); err != nil { - return err - } - return nil - }) - - // Category pages - eg.Go(func() error { - if err := b.renderCategoryList(buildCtx); err != nil { - return err - } - return b.renderCategoryPages(buildCtx, ctx) - }) - - // Copy uploads - eg.Go(func() error { - return b.writeUploads(buildCtx, b.site.Uploads) - }) - - return eg.Wait() -} - -func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error { - var posts []postSingleData - for mp := range b.site.PostIter(ctx) { - post, err := mp.Get() - if err != nil { - return err - } - rp, err := b.renderPostWithCategories(ctx, post) - if err != nil { - return err - } - posts = append(posts, rp) - } - - pl := postListData{ - commonData: commonData{Site: b.site}, - Posts: posts, - } - - return b.createAtPath(bctx, "", func(f io.Writer) error { - return b.renderTemplate(f, tmplNamePostList, pl) - }) -} -``` - -Remove the old `writePost` and `renderPostList` methods as they are replaced. - -- [ ] **Step 8b: Add category metadata to main feeds** - -The `feedhub.Item` struct has a `Category string` field. Update `renderFeeds` in `builder.go` to populate it. After the post is rendered, look up its categories and join the names: - -```go -// In renderFeeds, after renderedPost is created, add: -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, ", ") - } -} - -// Then in the feed.Items append, add: -Category: catName, -``` - -This adds category names to each post entry in the main RSS/JSON feeds. - -- [ ] **Step 9: Add postIterByCategory to publisher/iter.go** - -Add to `services/publisher/iter.go`: - -```go -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 - } - } -} -``` - -- [ ] **Step 10: Populate category data in publisher/service.go** - -In `services/publisher/service.go`, inside the `Publish` method, after fetching uploads and before the target loop, fetch categories: - -```go -// 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: briefDescription(cat.Description), - }) -} -``` - -Add the `briefDescription` helper (same as in categories service — or extract to models): - -```go -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 -} -``` - -Update the `pubSite` construction to include category fields: - -```go -pubSite := pubmodel.Site{ - Site: site, - PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { - return p.postIter(ctx, site.ID) - }, - BaseURL: target.BaseURL, - 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) { - return p.up.OpenUpload(site, u) - }, -} -``` - -- [ ] **Step 11: Move briefDescription to models package** - -To avoid duplication, move `briefDescription` to `models/categories.go` as an exported function `BriefDescription`, and update both `services/categories/service.go` and `services/publisher/service.go` to call `models.BriefDescription()`. - -In `models/categories.go`, rename/add: - -```go -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 -} -``` - -Update `services/categories/service.go` to use `models.BriefDescription()`. -Update `services/publisher/service.go` to use `models.BriefDescription()` and remove local copy. - -- [ ] **Step 12: Fix the existing builder test** - -The existing test in `providers/sitebuilder/builder_test.go` uses `pubmodel.Site.Posts` which no longer exists. Update it to use `PostIter` and add the new category templates to the template map: - -```go -func TestBuilder_BuildSite(t *testing.T) { - t.Run("build site", func(t *testing.T) { - tmpls := fstest.MapFS{ - "posts_single.html": {Data: []byte(`{{ .HTML }}`)}, - "posts_list.html": {Data: []byte(`{{ range .Posts}}{{.Post.Title}},{{ end }}`)}, - "layout_main.html": {Data: []byte(`{{ .Body }}`)}, - "categories_list.html": {Data: []byte(`{{ range .Categories}}{{.Name}},{{ end }}`)}, - "categories_single.html": {Data: []byte(`This is a test post
\n", - "2026/02/20/another-post/index.html": "This is another test post
\n", - "index.html": "Test Post,Another Post,", - } - - outDir := t.TempDir() - - b, err := sitebuilder.New(site, sitebuilder.Options{ - TemplatesFS: tmpls, - }) - assert.NoError(t, err) - - err = b.BuildSite(outDir) - assert.NoError(t, err) - - for file, content := range wantFiles { - filePath := filepath.Join(outDir, file) - fileContent, err := os.ReadFile(filePath) - assert.NoError(t, err) - assert.Equal(t, content, string(fileContent)) - } - }) -} -``` - -Add imports: `"context"`, `"iter"`. - -- [ ] **Step 13: Fix the existing DB test** - -Update calls to `SelectPostsOfSite` in `providers/db/provider_test.go` to include the `PagingParams` argument: - -Replace all occurrences of `p.SelectPostsOfSite(ctx,- {{ range .Categories }} - {{ .Name }} - {{ end }} -
- {{ end }} -{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/posts_list.html b/layouts/simplecss/posts_list.html index e6a77fe..944c5a1 100644 --- a/layouts/simplecss/posts_list.html +++ b/layouts/simplecss/posts_list.html @@ -2,11 +2,4 @@ {{ if .Post.Title }}- {{ range .Categories }} - {{ .Name }} - {{ end }} -
- {{ end }} {{ end }} \ No newline at end of file diff --git a/layouts/simplecss/posts_single.html b/layouts/simplecss/posts_single.html index cda9bb2..5fd9fcb 100644 --- a/layouts/simplecss/posts_single.html +++ b/layouts/simplecss/posts_single.html @@ -1,10 +1,3 @@ {{ if .Post.Title }}- {{ range .Categories }} - {{ .Name }} - {{ end }} -
-{{ end }} \ No newline at end of file +{{ format_date .Post.PublishedAt }} \ No newline at end of file diff --git a/models/categories.go b/models/categories.go deleted file mode 100644 index 5655009..0000000 --- a/models/categories.go +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index facf08b..0000000 --- a/models/categories_test.go +++ /dev/null @@ -1,28 +0,0 @@ -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 eda780c..997a952 100644 --- a/models/errors.go +++ b/models/errors.go @@ -7,4 +7,3 @@ 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/models/pubmodel/sites.go b/models/pubmodel/sites.go index a8862c4..a745885 100644 --- a/models/pubmodel/sites.go +++ b/models/pubmodel/sites.go @@ -11,11 +11,11 @@ import ( type Site struct { models.Site BaseURL string + //Posts []*models.Post Uploads []models.Upload - OpenUpload func(u models.Upload) (io.ReadCloser, error) - 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) + 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]] } diff --git a/providers/db/categories.go b/providers/db/categories.go deleted file mode 100644 index 72fac94..0000000 --- a/providers/db/categories.go +++ /dev/null @@ -1,132 +0,0 @@ -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(), - } -} diff --git a/providers/db/gen/sqlgen/categories.sql.go b/providers/db/gen/sqlgen/categories.sql.go deleted file mode 100644 index d5bc40d..0000000 --- a/providers/db/gen/sqlgen/categories.sql.go +++ /dev/null @@ -1,305 +0,0 @@ -// 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 -} diff --git a/providers/db/gen/sqlgen/db.go b/providers/db/gen/sqlgen/db.go index 8eab959..7d9d9e7 100644 --- a/providers/db/gen/sqlgen/db.go +++ b/providers/db/gen/sqlgen/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 package sqlgen diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 788c292..4f69bd0 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -1,20 +1,9 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 package sqlgen -type Category struct { - ID int64 - SiteID int64 - Guid string - Name string - Slug string - Description string - CreatedAt int64 - UpdatedAt int64 -} - type PendingUpload struct { ID int64 SiteID int64 @@ -40,11 +29,6 @@ type Post struct { DeletedAt int64 } -type PostCategory struct { - PostID int64 - CategoryID int64 -} - type PublishTarget struct { ID int64 SiteID int64 diff --git a/providers/db/gen/sqlgen/pending_uploads.sql.go b/providers/db/gen/sqlgen/pending_uploads.sql.go index 63eeb60..a831bbe 100644 --- a/providers/db/gen/sqlgen/pending_uploads.sql.go +++ b/providers/db/gen/sqlgen/pending_uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: pending_uploads.sql package sqlgen diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index 8bff191..d512941 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: posts.sql package sqlgen diff --git a/providers/db/gen/sqlgen/pubtargets.sql.go b/providers/db/gen/sqlgen/pubtargets.sql.go index 69c09df..cd5cfa6 100644 --- a/providers/db/gen/sqlgen/pubtargets.sql.go +++ b/providers/db/gen/sqlgen/pubtargets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: pubtargets.sql package sqlgen diff --git a/providers/db/gen/sqlgen/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go index bd80fb3..1a1b965 100644 --- a/providers/db/gen/sqlgen/sites.sql.go +++ b/providers/db/gen/sqlgen/sites.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: sites.sql package sqlgen diff --git a/providers/db/gen/sqlgen/uploads.sql.go b/providers/db/gen/sqlgen/uploads.sql.go index 189de2d..0433ae9 100644 --- a/providers/db/gen/sqlgen/uploads.sql.go +++ b/providers/db/gen/sqlgen/uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: uploads.sql package sqlgen diff --git a/providers/db/gen/sqlgen/users.sql.go b/providers/db/gen/sqlgen/users.sql.go index 6007589..a70a3bf 100644 --- a/providers/db/gen/sqlgen/users.sql.go +++ b/providers/db/gen/sqlgen/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: users.sql package sqlgen diff --git a/providers/db/provider.go b/providers/db/provider.go index cc35225..eda0513 100644 --- a/providers/db/provider.go +++ b/providers/db/provider.go @@ -40,17 +40,6 @@ func (db *Provider) Close() error { 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 { return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{ DeletedAt: time.Now().Unix(), diff --git a/providers/db/provider_test.go b/providers/db/provider_test.go index caf83d1..4781d61 100644 --- a/providers/db/provider_test.go +++ b/providers/db/provider_test.go @@ -158,7 +158,7 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, err) assert.NotZero(t, post.ID) - posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{}) + posts, err := p.SelectPostsOfSite(ctx, site.ID, false) require.NoError(t, err) require.Len(t, posts, 1) 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, post2)) - posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{}) + posts, err := p.SelectPostsOfSite(ctx, site2.ID, false) require.NoError(t, err) require.Len(t, posts, 2) 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)) - posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false, db.PagingParams{}) + posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false) require.NoError(t, err) assert.Empty(t, posts) }) @@ -283,165 +283,6 @@ 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 func TestProvider_UserPasswordEncoding(t *testing.T) { ctx := context.Background() diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 5775149..4908d61 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -31,7 +31,7 @@ type Builder struct { func New(site pubmodel.Site, opts Options) (*Builder, error) { tmpls, err := template.New(""). Funcs(templateFns(site, opts)). - ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle) + ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain) if err != nil { return nil, err } @@ -62,13 +62,7 @@ func (b *Builder) BuildSite(outDir string) error { if err != nil { return err } - 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 { + if err := b.writePost(buildCtx, post); err != nil { return err } } @@ -76,7 +70,10 @@ func (b *Builder) BuildSite(outDir string) error { }) eg.Go(func() error { - return b.renderPostListWithCategories(buildCtx, ctx) + if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil { + return err + } + return nil }) eg.Go(func() error { @@ -96,42 +93,43 @@ func (b *Builder) BuildSite(outDir string) error { return nil }) - // Category pages - eg.Go(func() error { - if err := b.renderCategoryList(buildCtx); err != nil { - return err - } - return b.renderCategoryPages(buildCtx, ctx) - }) - // Copy uploads eg.Go(func() error { - return b.writeUploads(buildCtx, b.site.Uploads) + if err := b.writeUploads(buildCtx, b.site.Uploads); err != nil { + 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() if err != nil { return err } - rp, err := b.renderPostWithCategories(ctx, post) - if err != nil { - return err - } - posts = append(posts, rp) + postCopy = append(postCopy, post) } pl := postListData{ 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(bctx, "", func(f io.Writer) error { + return b.createAtPath(ctx, "", func(f io.Writer) error { return b.renderTemplate(f, tmplNamePostList, pl) }) } @@ -158,29 +156,16 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[* 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 if postTitle != "" { postTitle = opts.titlePrefix + postTitle } feed.Items = append(feed.Items, &feedhub.Item{ - Id: filepath.Join(b.site.BaseURL, post.GUID), - Title: postTitle, - Link: &feedhub.Link{Href: renderedPost.PostURL}, - Content: string(renderedPost.HTML), - Category: catName, + Id: filepath.Join(b.site.BaseURL, post.GUID), + Title: postTitle, + Link: &feedhub.Link{Href: renderedPost.PostURL}, + Content: string(renderedPost.HTML), // TO FIX: Created should be first published Created: post.PublishedAt, Updated: post.UpdatedAt, @@ -258,142 +243,14 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) { }, nil } -// renderPostWithCategories renders a post and attaches its categories. -func (b *Builder) renderPostWithCategories(ctx context.Context, post *models.Post) (postSingleData, error) { +func (b *Builder) writePost(ctx buildContext, post *models.Post) error { 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 { - return err - } - rp, err := b.renderPostWithCategories(goCtx, post) - if err != nil { - return err - } - posts = append(posts, rp) - } - - var descHTML bytes.Buffer - 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 + return b.createAtPath(ctx, rp.Path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNamePostSingle, rp) }) } diff --git a/providers/sitebuilder/builder_test.go b/providers/sitebuilder/builder_test.go index cbe116b..2564f6d 100644 --- a/providers/sitebuilder/builder_test.go +++ b/providers/sitebuilder/builder_test.go @@ -1,8 +1,6 @@ package sitebuilder_test import ( - "context" - "iter" "os" "path/filepath" "testing" @@ -17,36 +15,24 @@ import ( func TestBuilder_BuildSite(t *testing.T) { t.Run("build site", func(t *testing.T) { tmpls := fstest.MapFS{ - "posts_single.html": {Data: []byte(`{{ .HTML }}`)}, - "posts_list.html": {Data: []byte(`{{ range .Posts}}{{.Post.Title}},{{ end }}`)}, - "layout_main.html": {Data: []byte(`{{ .Body }}`)}, - "categories_list.html": {Data: []byte(`{{ range .Categories}}{{.Name}},{{ end }}`)}, - "categories_single.html": {Data: []byte(`| Name | -Slug | -Posts | -- |
|---|---|---|---|
| {{ .Name }} | -{{ .Slug }} |
- {{ .PostCount }} | -- Edit - | -
| No categories yet. | -|||