diff --git a/gen/sqlc/dbq/models.go b/gen/sqlc/dbq/models.go index bdff214..b816be7 100644 --- a/gen/sqlc/dbq/models.go +++ b/gen/sqlc/dbq/models.go @@ -4,6 +4,66 @@ package dbq +import ( + "database/sql/driver" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" +) + +type PostState string + +const ( + PostStateDraft PostState = "draft" + PostStatePublished PostState = "published" +) + +func (e *PostState) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = PostState(s) + case string: + *e = PostState(s) + default: + return fmt.Errorf("unsupported scan type for PostState: %T", src) + } + return nil +} + +type NullPostState struct { + PostState PostState + Valid bool // Valid is true if PostState is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullPostState) Scan(value interface{}) error { + if value == nil { + ns.PostState, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.PostState.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullPostState) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.PostState), nil +} + +type Post struct { + ID int64 + SiteID int64 + Title pgtype.Text + Body string + State PostState + Props []byte + PostDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp +} + type Site struct { ID int64 Name string diff --git a/gen/sqlc/dbq/posts.sql.go b/gen/sqlc/dbq/posts.sql.go new file mode 100644 index 0000000..a6232ca --- /dev/null +++ b/gen/sqlc/dbq/posts.sql.go @@ -0,0 +1,83 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: posts.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const insertPost = `-- name: InsertPost :one +INSERT INTO post ( + site_id, + title, + body, + state, + props, + post_date, + created_at +) VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id +` + +type InsertPostParams struct { + SiteID int64 + Title pgtype.Text + Body string + State PostState + Props []byte + PostDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp +} + +func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) { + row := q.db.QueryRow(ctx, insertPost, + arg.SiteID, + arg.Title, + arg.Body, + arg.State, + arg.Props, + arg.PostDate, + arg.CreatedAt, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const listPosts = `-- name: ListPosts :many +SELECT id, site_id, title, body, state, props, post_date, created_at FROM post WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25 +` + +func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { + rows, err := q.db.Query(ctx, listPosts, siteID) + 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.Title, + &i.Body, + &i.State, + &i.Props, + &i.PostDate, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/go.mod b/go.mod index 71910fe..1006f39 100644 --- a/go.mod +++ b/go.mod @@ -41,4 +41,6 @@ require ( golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1 // indirect ) diff --git a/go.sum b/go.sum index 7cbf0e1..e25c36a 100644 --- a/go.sum +++ b/go.sum @@ -81,3 +81,7 @@ golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1 h1:Seqp9vlIw3uJBL0V/eWIM3dAnSuToJ/cztkRQtl3g20= +lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= diff --git a/handlers/ctx.go b/handlers/ctx.go new file mode 100644 index 0000000..74a6711 --- /dev/null +++ b/handlers/ctx.go @@ -0,0 +1,14 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" + "lmika.dev/lmika/hugo-crm/models" +) + +type siteKeyType struct{} + +var siteKey siteKeyType + +func GetSite(c *fiber.Ctx) models.Site { + return c.UserContext().Value(siteKey).(models.Site) +} diff --git a/handlers/post.go b/handlers/post.go new file mode 100644 index 0000000..b63852e --- /dev/null +++ b/handlers/post.go @@ -0,0 +1,49 @@ +package handlers + +import ( + "fmt" + "github.com/gofiber/fiber/v2" + "lmika.dev/lmika/hugo-crm/services/posts" +) + +type Post struct { + Post *posts.Service +} + +func (h *Post) Posts() fiber.Handler { + return func(c *fiber.Ctx) error { + site := GetSite(c) + + posts, err := h.Post.ListPostOfSite(c.UserContext(), site) + if err != nil { + return err + } + + return c.Render("sites/posts", fiber.Map{ + "site": site, + "posts": posts, + }, "layouts/main") + } +} + +func (h *Post) Create() fiber.Handler { + type Req struct { + Body string `json:"body" form:"body"` + } + + return func(c *fiber.Ctx) error { + site := GetSite(c) + + var req Req + if err := c.BodyParser(&req); err != nil { + return err + } + + _, err := h.Post.Create(c.UserContext(), site, req.Body) + if err != nil { + return err + } + + return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID)) + } +} diff --git a/handlers/site.go b/handlers/site.go index 280c5f9..43b3fda 100644 --- a/handlers/site.go +++ b/handlers/site.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "fmt" "github.com/gofiber/fiber/v2" "lmika.dev/lmika/hugo-crm/services/sites" @@ -30,9 +31,29 @@ func (s *Site) Show() fiber.Handler { } site, err := s.Site.GetSite(c.UserContext(), id) + if err != nil { + return err + } return c.Render("sites/index", fiber.Map{ "site": site, }, "layouts/main") } } + +func (s *Site) WithSite() fiber.Handler { + return func(c *fiber.Ctx) error { + id, err := c.ParamsInt("siteId") + if err != nil { + return err + } + + site, err := s.Site.GetSite(c.UserContext(), id) + if err != nil { + return err + } + + c.SetUserContext(context.WithValue(c.UserContext(), siteKey, site)) + return c.Next() + } +} diff --git a/main.go b/main.go index c8d5a0c..f347cab 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "lmika.dev/lmika/hugo-crm/providers/hugo" "lmika.dev/lmika/hugo-crm/providers/themes" "lmika.dev/lmika/hugo-crm/services/jobs" + "lmika.dev/lmika/hugo-crm/services/posts" "lmika.dev/lmika/hugo-crm/services/sitebuilder" "lmika.dev/lmika/hugo-crm/services/sites" "lmika.dev/lmika/hugo-crm/templates" @@ -41,8 +42,10 @@ func main() { siteBuilderService := sitebuilder.New(themesProvider, gitProvider, hugoProvider) siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService) + postService := posts.New(dbp, siteBuilderService, jobService) siteHandlers := handlers.Site{Site: siteService} + postHandlers := handlers.Post{Post: postService} log.Println("Connected to database") if err := dbp.Migrate(context.Background()); err != nil { @@ -63,6 +66,12 @@ func main() { app.Post("/sites", siteHandlers.Create()) app.Get("/sites/:siteId", siteHandlers.Show()) + siteGroup := app.Group("/sites/:siteId") + siteGroup.Use(siteHandlers.WithSite()) + + siteGroup.Get("/posts", postHandlers.Posts()) + siteGroup.Post("/posts", postHandlers.Create()) + jobService.Start() defer jobService.Stop() diff --git a/models/posts.go b/models/posts.go new file mode 100644 index 0000000..20d8c9a --- /dev/null +++ b/models/posts.go @@ -0,0 +1,20 @@ +package models + +import "time" + +type PostState string + +const ( + PostStateDraft PostState = "draft" + PostStatePublished PostState = "published" +) + +type Post struct { + ID int64 + SiteID int64 + Title string + Body string + State PostState + PostDate time.Time + CreatedAt time.Time +} diff --git a/models/theme.go b/models/theme.go index 51b9f88..72094a8 100644 --- a/models/theme.go +++ b/models/theme.go @@ -3,4 +3,10 @@ package models type ThemeMeta struct { Name string `json:"name"` URL string `json:"repo"` + + // Indicates that this theme prefers posts have titles. + PreferTitle bool + + // Content directory for "blog" posts + PostDir string `json:"post_dir"` } diff --git a/providers/db/posts.go b/providers/db/posts.go new file mode 100644 index 0000000..c875c14 --- /dev/null +++ b/providers/db/posts.go @@ -0,0 +1,46 @@ +package db + +import ( + "context" + "github.com/jackc/pgx/v5/pgtype" + "lmika.dev/lmika/hugo-crm/gen/sqlc/dbq" + "lmika.dev/lmika/hugo-crm/models" + "lmika.dev/pkg/modash/moslice" +) + +func (db *DB) ListPostsOfSite(ctx context.Context, siteID int64) ([]models.Post, error) { + res, err := db.q.ListPosts(ctx, siteID) + if err != nil { + return nil, err + } + + return moslice.Map(res, func(p dbq.Post) models.Post { + return models.Post{ + ID: p.ID, + SiteID: p.SiteID, + Title: p.Title.String, + Body: p.Body, + State: models.PostState(p.State), + PostDate: p.PostDate.Time, + CreatedAt: p.CreatedAt.Time, + } + }), nil +} + +func (db *DB) InsertPost(ctx context.Context, p *models.Post) error { + res, err := db.q.InsertPost(ctx, dbq.InsertPostParams{ + SiteID: p.SiteID, + Title: pgtype.Text{String: p.Title, Valid: p.Title != ""}, + Body: p.Body, + State: dbq.PostState(p.State), + Props: []byte(`{}`), + PostDate: pgtype.Timestamptz{Time: p.PostDate, Valid: !p.PostDate.IsZero()}, + CreatedAt: pgtype.Timestamp{Time: p.CreatedAt, Valid: !p.CreatedAt.IsZero()}, + }) + if err != nil { + return err + } + + p.ID = res + return nil +} diff --git a/providers/hugo/dirs.go b/providers/hugo/dirs.go index 15d6483..4c312e3 100644 --- a/providers/hugo/dirs.go +++ b/providers/hugo/dirs.go @@ -3,6 +3,7 @@ package hugo type SiteDir string const ( - BaseSiteDir SiteDir = "base" - ThemeSiteDir SiteDir = "theme" + BaseSiteDir SiteDir = "base" + ThemeSiteDir SiteDir = "theme" + ContentSiteDir SiteDir = "content" ) diff --git a/providers/hugo/provider.go b/providers/hugo/provider.go index 48cec47..a1b26bb 100644 --- a/providers/hugo/provider.go +++ b/providers/hugo/provider.go @@ -36,7 +36,7 @@ func (p *Provider) SiteStagingDir(site models.Site, what SiteDir) string { case ThemeSiteDir: return filepath.Join(p.stagingDir, site.Name, "themes", site.Theme) } - return "" + return filepath.Join(p.stagingDir, site.Name, string(what)) } func (p *Provider) NewSite(ctx context.Context, site models.Site) error { diff --git a/providers/themes/meta.go b/providers/themes/meta.go index f65158d..b0921e6 100644 --- a/providers/themes/meta.go +++ b/providers/themes/meta.go @@ -4,7 +4,9 @@ import "lmika.dev/lmika/hugo-crm/models" var themes = map[string]models.ThemeMeta{ "bear": models.ThemeMeta{ - Name: "bear", - URL: "https://github.com/janraasch/hugo-bearblog", + Name: "bear", + URL: "https://github.com/janraasch/hugo-bearblog", + PreferTitle: true, + PostDir: "blog", }, } diff --git a/services/posts/services.go b/services/posts/services.go new file mode 100644 index 0000000..78d0bd3 --- /dev/null +++ b/services/posts/services.go @@ -0,0 +1,47 @@ +package posts + +import ( + "context" + "lmika.dev/lmika/hugo-crm/models" + "lmika.dev/lmika/hugo-crm/providers/db" + "lmika.dev/lmika/hugo-crm/services/jobs" + "lmika.dev/lmika/hugo-crm/services/sitebuilder" + "time" +) + +type Service struct { + db *db.DB + sb *sitebuilder.Service + jobs *jobs.Service +} + +func New( + db *db.DB, + sb *sitebuilder.Service, + jobs *jobs.Service, +) *Service { + return &Service{ + db: db, + sb: sb, + jobs: jobs, + } +} + +func (s *Service) ListPostOfSite(ctx context.Context, site models.Site) ([]models.Post, error) { + return s.db.ListPostsOfSite(ctx, site.ID) +} + +func (s *Service) Create(ctx context.Context, site models.Site, body string) (models.Post, error) { + post := models.Post{ + SiteID: site.ID, + Body: body, + State: models.PostStatePublished, + PostDate: time.Now(), + CreatedAt: time.Now(), + } + if err := s.db.InsertPost(ctx, &post); err != nil { + return models.Post{}, err + } + + return post, s.jobs.Queue(ctx, s.sb.WritePost(site, post)) +} diff --git a/services/sitebuilder/service.go b/services/sitebuilder/service.go index 39102a5..74f8f65 100644 --- a/services/sitebuilder/service.go +++ b/services/sitebuilder/service.go @@ -3,11 +3,15 @@ package sitebuilder import ( "context" "errors" + "gopkg.in/yaml.v3" "lmika.dev/lmika/hugo-crm/models" "lmika.dev/lmika/hugo-crm/providers/git" "lmika.dev/lmika/hugo-crm/providers/hugo" "lmika.dev/lmika/hugo-crm/providers/themes" "log" + "os" + "path/filepath" + "time" ) type Service struct { @@ -55,3 +59,57 @@ func (s *Service) CreateNewSite(site models.Site) models.Job { }, } } + +func (s *Service) WritePost(site models.Site, post models.Post) models.Job { + return models.Job{ + Do: func(ctx context.Context) error { + themeMeta, ok := s.themes.Lookup(site.Theme) + if !ok { + return errors.New("theme not found") + } + + postFilename := filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.PostDir, post.CreatedAt.Format("2006-01-02-150405.md")) + + log.Printf(" .. post %v", postFilename) + + if err := os.MkdirAll(filepath.Dir(postFilename), 0755); err != nil { + return err + } + + frontMatter := map[string]string{ + "date": post.PostDate.Format(time.RFC3339), + } + if post.Title != "" { + frontMatter["title"] = post.Title + } else if themeMeta.PreferTitle { + frontMatter["title"] = post.PostDate.Format(time.ANSIC) + } + + fmBytes, err := yaml.Marshal(frontMatter) + if err != nil { + return err + } + + f, err := os.Create(postFilename) + if err != nil { + return err + } + defer f.Close() + + if _, err := f.WriteString("---\n"); err != nil { + return err + } + if _, err := f.Write(fmBytes); err != nil { + return err + } + if _, err := f.WriteString("---\n"); err != nil { + return err + } + if _, err := f.WriteString(post.Body); err != nil { + return err + } + + return nil + }, + } +} diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql new file mode 100644 index 0000000..1ace262 --- /dev/null +++ b/sql/queries/posts.sql @@ -0,0 +1,14 @@ +-- name: ListPosts :many +SELECT * FROM post WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25; + +-- name: InsertPost :one +INSERT INTO post ( + site_id, + title, + body, + state, + props, + post_date, + created_at +) VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id; \ No newline at end of file diff --git a/sql/schema/1_init.up.sql b/sql/schema/1_init.up.sql index 130583d..1e63525 100644 --- a/sql/schema/1_init.up.sql +++ b/sql/schema/1_init.up.sql @@ -1,8 +1,26 @@ +CREATE TYPE post_state AS ENUM ( + 'draft', + 'published' +); + CREATE TABLE site ( id BIGSERIAL NOT NULL PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - title TEXT NOT NULL, - url TEXT NOT NULL, - theme TEXT NOT NULL, - props JSON NOT NULL + name TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + url TEXT NOT NULL, + theme TEXT NOT NULL, + props JSON NOT NULL ); + +CREATE TABLE post ( + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, + title TEXT, + body TEXT NOT NULL, + state post_state NOT NULL, + props JSON NOT NULL, + post_date TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP NOT NULL, + + FOREIGN KEY (site_id) REFERENCES site(id) +); \ No newline at end of file diff --git a/templates/sites/index.html b/templates/sites/index.html deleted file mode 100644 index a87256f..0000000 --- a/templates/sites/index.html +++ /dev/null @@ -1 +0,0 @@ -
No posts yet
+{{end}} \ No newline at end of file