From ba12398d2f2284f4cb9d94de565bb1b1d0871396 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 16 Feb 2025 11:43:22 +1100 Subject: [PATCH] Started working on pages --- gen/sqlc/dbq/bundles.sql.go | 81 +++++++++++ gen/sqlc/dbq/models.go | 87 +++++++++++- gen/sqlc/dbq/pages.sql.go | 233 ++++++++++++++++++++++++++++++++ gen/sqlc/dbq/posts.sql.go | 75 +++++----- go.mod | 2 +- go.sum | 2 + handlers/page.go | 123 +++++++++++++++++ main.go | 10 ++ models/bundle.go | 40 ++++++ models/posts.go | 18 +-- models/theme.go | 4 +- providers/db/bundles.go | 50 +++++++ providers/db/page.go | 105 ++++++++++++++ providers/db/posts.go | 51 +++---- providers/themes/meta.go | 30 ++-- services/pages/services.go | 183 +++++++++++++++++++++++++ services/posts/services.go | 10 +- services/sitebuilder/pages.go | 105 ++++++++++++++ services/sitebuilder/posts.go | 37 +++-- services/sitebuilder/service.go | 5 + services/sitebuilder/tracker.go | 32 +++++ services/sites/create.go | 55 ++++++++ services/sites/service.go | 21 --- sql/queries/bundles.sql | 13 ++ sql/queries/pages.sql | 47 +++++++ sql/queries/posts.sql | 15 +- sql/schema/1_init.up.sql | 68 ++++++++-- templates/fs.go | 1 + templates/pages/edit.html | 13 ++ templates/pages/index.html | 20 +++ 30 files changed, 1391 insertions(+), 145 deletions(-) create mode 100644 gen/sqlc/dbq/bundles.sql.go create mode 100644 gen/sqlc/dbq/pages.sql.go create mode 100644 handlers/page.go create mode 100644 models/bundle.go create mode 100644 providers/db/bundles.go create mode 100644 providers/db/page.go create mode 100644 services/pages/services.go create mode 100644 services/sitebuilder/pages.go create mode 100644 services/sitebuilder/tracker.go create mode 100644 services/sites/create.go create mode 100644 sql/queries/bundles.sql create mode 100644 sql/queries/pages.sql create mode 100644 templates/pages/edit.html create mode 100644 templates/pages/index.html diff --git a/gen/sqlc/dbq/bundles.sql.go b/gen/sqlc/dbq/bundles.sql.go new file mode 100644 index 0000000..d6f11d8 --- /dev/null +++ b/gen/sqlc/dbq/bundles.sql.go @@ -0,0 +1,81 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: bundles.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getBundleWithID = `-- name: GetBundleWithID :one +SELECT id, site_id, name, created_at, updated_at FROM bundles WHERE id = $1 +` + +func (q *Queries) GetBundleWithID(ctx context.Context, id int64) (Bundle, error) { + row := q.db.QueryRow(ctx, getBundleWithID, id) + var i Bundle + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertBundle = `-- name: InsertBundle :one +INSERT INTO bundles ( + site_id, + name, + created_at, + updated_at +) VALUES ($1, $2, $3, $3) RETURNING id +` + +type InsertBundleParams struct { + SiteID int64 + Name string + CreatedAt pgtype.Timestamp +} + +func (q *Queries) InsertBundle(ctx context.Context, arg InsertBundleParams) (int64, error) { + row := q.db.QueryRow(ctx, insertBundle, arg.SiteID, arg.Name, arg.CreatedAt) + var id int64 + err := row.Scan(&id) + return id, err +} + +const listBundles = `-- name: ListBundles :many +SELECT id, site_id, name, created_at, updated_at FROM bundles WHERE site_id = $1 +` + +func (q *Queries) ListBundles(ctx context.Context, siteID int64) ([]Bundle, error) { + rows, err := q.db.Query(ctx, listBundles, siteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Bundle + for rows.Next() { + var i Bundle + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/gen/sqlc/dbq/models.go b/gen/sqlc/dbq/models.go index d4052a2..9a3d13b 100644 --- a/gen/sqlc/dbq/models.go +++ b/gen/sqlc/dbq/models.go @@ -11,6 +11,49 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type PageNameProvenance string + +const ( + PageNameProvenanceUser PageNameProvenance = "user" + PageNameProvenanceTitle PageNameProvenance = "title" + PageNameProvenanceDate PageNameProvenance = "date" +) + +func (e *PageNameProvenance) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = PageNameProvenance(s) + case string: + *e = PageNameProvenance(s) + default: + return fmt.Errorf("unsupported scan type for PageNameProvenance: %T", src) + } + return nil +} + +type NullPageNameProvenance struct { + PageNameProvenance PageNameProvenance + Valid bool // Valid is true if PageNameProvenance is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullPageNameProvenance) Scan(value interface{}) error { + if value == nil { + ns.PageNameProvenance, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.PageNameProvenance.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullPageNameProvenance) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.PageNameProvenance), nil +} + type PostState string const ( @@ -135,15 +178,47 @@ func (ns NullTargetType) Value() (driver.Value, error) { return string(ns.TargetType), nil } -type Post struct { +type Bundle struct { ID int64 SiteID int64 - Title pgtype.Text - Body string - State PostState - Props []byte - PostDate pgtype.Timestamptz + Name string CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp +} + +type Page struct { + ID int64 + SiteID int64 + BundleID int64 + Name string + NameProvenance PageNameProvenance + Title pgtype.Text + Role pgtype.Int8 + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp +} + +type Post struct { + ID int64 + SiteID int64 + Title pgtype.Text + Role pgtype.Int8 + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp +} + +type PostRole struct { + ID int64 + SiteID int64 + LayoutName string } type PublishTarget struct { diff --git a/gen/sqlc/dbq/pages.sql.go b/gen/sqlc/dbq/pages.sql.go new file mode 100644 index 0000000..20d4023 --- /dev/null +++ b/gen/sqlc/dbq/pages.sql.go @@ -0,0 +1,233 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: pages.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deletePageWithID = `-- name: DeletePageWithID :exec +DELETE FROM pages WHERE id = $1 +` + +func (q *Queries) DeletePageWithID(ctx context.Context, id int64) error { + _, err := q.db.Exec(ctx, deletePageWithID, id) + return err +} + +const getPageWithID = `-- name: GetPageWithID :one +SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at FROM pages WHERE id = $1 +` + +func (q *Queries) GetPageWithID(ctx context.Context, id int64) (Page, error) { + row := q.db.QueryRow(ctx, getPageWithID, id) + var i Page + err := row.Scan( + &i.ID, + &i.SiteID, + &i.BundleID, + &i.Name, + &i.NameProvenance, + &i.Title, + &i.Role, + &i.Body, + &i.State, + &i.Props, + &i.PublishDate, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertPage = `-- name: InsertPage :one +INSERT INTO pages ( + site_id, + bundle_id, + name, + name_provenance, + title, + role, + body, + state, + props, + publish_date, + created_at, + updated_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) +RETURNING id +` + +type InsertPageParams struct { + SiteID int64 + BundleID int64 + Name string + NameProvenance PageNameProvenance + Title pgtype.Text + Role pgtype.Int8 + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp +} + +func (q *Queries) InsertPage(ctx context.Context, arg InsertPageParams) (int64, error) { + row := q.db.QueryRow(ctx, insertPage, + arg.SiteID, + arg.BundleID, + arg.Name, + arg.NameProvenance, + arg.Title, + arg.Role, + arg.Body, + arg.State, + arg.Props, + arg.PublishDate, + arg.CreatedAt, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const listPages = `-- name: ListPages :many +SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at FROM pages WHERE site_id = $1 ORDER BY name ASC LIMIT 25 +` + +func (q *Queries) ListPages(ctx context.Context, siteID int64) ([]Page, error) { + rows, err := q.db.Query(ctx, listPages, siteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Page + for rows.Next() { + var i Page + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.BundleID, + &i.Name, + &i.NameProvenance, + &i.Title, + &i.Role, + &i.Body, + &i.State, + &i.Props, + &i.PublishDate, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPublishablePages = `-- name: ListPublishablePages :many +SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at +FROM pages +WHERE id > $1 AND site_id = $2 AND state = 'published' +ORDER BY id LIMIT 100 +` + +type ListPublishablePagesParams struct { + ID int64 + SiteID int64 +} + +func (q *Queries) ListPublishablePages(ctx context.Context, arg ListPublishablePagesParams) ([]Page, error) { + rows, err := q.db.Query(ctx, listPublishablePages, arg.ID, arg.SiteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Page + for rows.Next() { + var i Page + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.BundleID, + &i.Name, + &i.NameProvenance, + &i.Title, + &i.Role, + &i.Body, + &i.State, + &i.Props, + &i.PublishDate, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updatePage = `-- name: UpdatePage :exec +UPDATE pages SET + site_id = $2, + bundle_id = $3, + name = $4, + name_provenance = $5, + title = $6, + role = $7, + body = $8, + state = $9, + props = $10, + publish_date = $11, + created_at = $12, + updated_at = $13 +WHERE id = $1 +` + +type UpdatePageParams struct { + ID int64 + SiteID int64 + BundleID int64 + Name string + NameProvenance PageNameProvenance + Title pgtype.Text + Role pgtype.Int8 + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp +} + +func (q *Queries) UpdatePage(ctx context.Context, arg UpdatePageParams) error { + _, err := q.db.Exec(ctx, updatePage, + arg.ID, + arg.SiteID, + arg.BundleID, + arg.Name, + arg.NameProvenance, + arg.Title, + arg.Role, + arg.Body, + arg.State, + arg.Props, + arg.PublishDate, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} diff --git a/gen/sqlc/dbq/posts.sql.go b/gen/sqlc/dbq/posts.sql.go index 6724a64..2f55e3a 100644 --- a/gen/sqlc/dbq/posts.sql.go +++ b/gen/sqlc/dbq/posts.sql.go @@ -21,7 +21,7 @@ func (q *Queries) DeletePost(ctx context.Context, id int64) error { } const getPostWithID = `-- name: GetPostWithID :one -SELECT id, site_id, title, body, state, props, post_date, created_at FROM posts WHERE id = $1 LIMIT 1 +SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at FROM posts WHERE id = $1 LIMIT 1 ` func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) { @@ -31,11 +31,13 @@ func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) { &i.ID, &i.SiteID, &i.Title, + &i.Role, &i.Body, &i.State, &i.Props, - &i.PostDate, + &i.PublishDate, &i.CreatedAt, + &i.UpdatedAt, ) return i, err } @@ -47,20 +49,22 @@ INSERT INTO posts ( body, state, props, - post_date, - created_at -) VALUES ($1, $2, $3, $4, $5, $6, $7) + publish_date, + created_at, + updated_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id ` type InsertPostParams struct { - SiteID int64 - Title pgtype.Text - Body string - State PostState - Props []byte - PostDate pgtype.Timestamptz - CreatedAt pgtype.Timestamp + SiteID int64 + Title pgtype.Text + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp } func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) { @@ -70,8 +74,9 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, arg.Body, arg.State, arg.Props, - arg.PostDate, + arg.PublishDate, arg.CreatedAt, + arg.UpdatedAt, ) var id int64 err := row.Scan(&id) @@ -79,7 +84,7 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, } const listPosts = `-- name: ListPosts :many -SELECT id, site_id, title, body, state, props, post_date, created_at FROM posts WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25 +SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at FROM posts WHERE site_id = $1 ORDER BY publish_date DESC LIMIT 25 ` func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { @@ -95,11 +100,13 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { &i.ID, &i.SiteID, &i.Title, + &i.Role, &i.Body, &i.State, &i.Props, - &i.PostDate, + &i.PublishDate, &i.CreatedAt, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -112,20 +119,20 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { } const listPublishablePosts = `-- name: ListPublishablePosts :many -SELECT id, site_id, title, body, state, props, post_date, created_at +SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at FROM posts -WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3 +WHERE id > $1 AND site_id = $2 AND state = 'published' AND publish_date <= $3 ORDER BY id LIMIT 100 ` type ListPublishablePostsParams struct { - ID int64 - SiteID int64 - PostDate pgtype.Timestamptz + ID int64 + SiteID int64 + PublishDate pgtype.Timestamptz } func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishablePostsParams) ([]Post, error) { - rows, err := q.db.Query(ctx, listPublishablePosts, arg.ID, arg.SiteID, arg.PostDate) + rows, err := q.db.Query(ctx, listPublishablePosts, arg.ID, arg.SiteID, arg.PublishDate) if err != nil { return nil, err } @@ -137,11 +144,13 @@ func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishableP &i.ID, &i.SiteID, &i.Title, + &i.Role, &i.Body, &i.State, &i.Props, - &i.PostDate, + &i.PublishDate, &i.CreatedAt, + &i.UpdatedAt, ); err != nil { return nil, err } @@ -160,19 +169,20 @@ UPDATE posts SET body = $4, state = $5, props = $6, - post_date = $7 - -- updated_at = $7 + publish_date = $7, + updated_at = $8 WHERE id = $1 ` type UpdatePostParams struct { - ID int64 - SiteID int64 - Title pgtype.Text - Body string - State PostState - Props []byte - PostDate pgtype.Timestamptz + ID int64 + SiteID int64 + Title pgtype.Text + Body string + State PostState + Props []byte + PublishDate pgtype.Timestamptz + UpdatedAt pgtype.Timestamp } func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error { @@ -183,7 +193,8 @@ func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error { arg.Body, arg.State, arg.Props, - arg.PostDate, + arg.PublishDate, + arg.UpdatedAt, ) return err } diff --git a/go.mod b/go.mod index d032137..9e10103 100644 --- a/go.mod +++ b/go.mod @@ -48,5 +48,5 @@ require ( 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-20250201221851-97d4b9b4a1ac // indirect + lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d // indirect ) diff --git a/go.sum b/go.sum index cc84584..fcd8439 100644 --- a/go.sum +++ b/go.sum @@ -99,3 +99,5 @@ lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1 h1:Seqp9vlIw3uJBL0V/eWIM lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= lmika.dev/pkg/modash v0.0.0-20250201221851-97d4b9b4a1ac h1:i/C+DYDCVQTQHtv7w1O8m20RMez6YS9fUIlhAGjTZhU= lmika.dev/pkg/modash v0.0.0-20250201221851-97d4b9b4a1ac/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= +lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d h1:x5aMBOkCr4cjJyFmq+qJVUsByfffD9k56HYDx1yZSR4= +lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= diff --git a/handlers/page.go b/handlers/page.go new file mode 100644 index 0000000..e9a3f7f --- /dev/null +++ b/handlers/page.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "errors" + "fmt" + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/lmika/hugo-cms/services/pages" + "net/http" +) + +type Pages struct { + Svc *pages.Service +} + +func (h *Pages) Index(c fiber.Ctx) error { + site := GetSite(c) + + pages, err := h.Svc.ListPagesOfSite(c.Context(), site) + if err != nil { + return err + } + + return c.Render("pages/index", fiber.Map{ + "pages": pages, + }, "layouts/site") +} + +func (h *Pages) New(c fiber.Ctx) error { + return c.Render("pages/edit", fiber.Map{ + "page": models.Page{}, + }, "layouts/site") +} + +func (h *Pages) Create(c fiber.Ctx) error { + site := GetSite(c) + + var req struct { + Title string `json:"title" form:"title"` + Body string `json:"body" form:"body"` + } + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err := h.Svc.Create(c.Context(), site, pages.NewPost{ + Title: req.Title, + Body: req.Body, + }) + if err != nil { + return err + } + + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (h *Pages) Edit(c fiber.Ctx) error { + site := GetSite(c) + + pageID := fiber.Params[int](c, "pageId") + if pageID == 0 { + return errors.New("pageId is required") + } + + page, err := h.Svc.GetPage(c.Context(), pageID) + if err != nil { + return err + } else if page.SiteID != site.ID { + return fmt.Errorf("page id %v not equal to site id %v", pageID, site.ID) + } + + return c.Render("pages/edit", fiber.Map{ + "page": page, + }, "layouts/site") +} + +func (h *Pages) Update(c fiber.Ctx) error { + site := GetSite(c) + + pageID := fiber.Params[int](c, "pageId") + if pageID == 0 { + return errors.New("pageId is required") + } + + var req struct { + Title string `json:"title" form:"title"` + Body string `json:"body" form:"body"` + } + if err := c.Bind().Body(&req); err != nil { + return err + } + + if _, err := h.Svc.Update(c.Context(), site, int64(pageID), pages.NewPost{ + Title: req.Title, + Body: req.Body, + }); err != nil { + return err + } + + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (h *Pages) Delete(c fiber.Ctx) error { + site := GetSite(c) + + pageID := fiber.Params[int](c, "pageId") + if pageID == 0 { + return errors.New("pageID is required") + } + + if err := h.Svc.DeletePage(c.Context(), site, pageID); err != nil { + return err + } + + return Select(c, + HTMX(func(c fiber.Ctx) error { + return c.Status(http.StatusOK).SendString("") + }), + Otherwise(func(c fiber.Ctx) error { + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) + }), + ) +} diff --git a/main.go b/main.go index d267d7e..5ff1f4b 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "lmika.dev/lmika/hugo-cms/providers/netlify" "lmika.dev/lmika/hugo-cms/providers/themes" "lmika.dev/lmika/hugo-cms/services/jobs" + "lmika.dev/lmika/hugo-cms/services/pages" "lmika.dev/lmika/hugo-cms/services/posts" "lmika.dev/lmika/hugo-cms/services/sitebuilder" "lmika.dev/lmika/hugo-cms/services/sites" @@ -85,10 +86,12 @@ func main() { siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService) postService := posts.New(dbp, siteBuilderService, jobService) + pageService := pages.New(dbp, siteBuilderService, jobService) indexHandlers := handlers.IndexHandler{} siteHandlers := handlers.Site{Site: siteService, Bus: bus} postHandlers := handlers.Post{Post: postService} + pageHandlers := handlers.Pages{Svc: pageService} authHandlers := handlers.AuthHandler{UserService: userService} tmplEngine := html.NewFileSystem(http.FS(templates.FS), ".html") @@ -137,6 +140,13 @@ func main() { sr.Post("/posts/:postId", postHandlers.Update) sr.Delete("/posts/:postId", postHandlers.Delete) + sr.Get("/pages", pageHandlers.Index) + sr.Get("/pages/new", pageHandlers.New) + sr.Post("/pages", pageHandlers.Create) + sr.Get("/pages/:pageId", pageHandlers.Edit) + sr.Post("/pages/:pageId", pageHandlers.Update) + sr.Delete("/pages/:pageId", pageHandlers.Delete) + sr.Get("/settings", siteHandlers.Settings) sr.Post("/settings", siteHandlers.SaveSettings) sr.Get("/sse", siteHandlers.SSE) diff --git a/models/bundle.go b/models/bundle.go new file mode 100644 index 0000000..2d6a1b1 --- /dev/null +++ b/models/bundle.go @@ -0,0 +1,40 @@ +package models + +import "time" + +const ( + RootBundleName = "_root" +) + +// NameProvenance encodes where the name came from, whether it was set by the user or autogenerated in some way +type NameProvenance int + +const ( + UserNameProvenance NameProvenance = iota + TitleNameProvenance NameProvenance = iota + DateNameProvenance NameProvenance = iota +) + +type Bundle struct { + ID int64 + SiteID int64 + Name string + CreatedAt time.Time + UpdatedAt time.Time +} + +type Page struct { + ID int64 + SiteID int64 + BundleID int64 + Name string + NameProvenance NameProvenance + Title string + Role int64 + Body string + State PostState + Props []byte + PublishDate time.Time + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/models/posts.go b/models/posts.go index a95fb36..83bb663 100644 --- a/models/posts.go +++ b/models/posts.go @@ -10,13 +10,13 @@ const ( ) type Post struct { - ID int64 - SiteID int64 - OwnerID int64 - Title string - Body string - State PostState - PostDate time.Time - CreatedAt time.Time - UpdatedAt time.Time + ID int64 + SiteID int64 + OwnerID int64 + Title string + Body string + State PostState + PublishDate time.Time + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/models/theme.go b/models/theme.go index 3b07e0e..e832748 100644 --- a/models/theme.go +++ b/models/theme.go @@ -8,6 +8,6 @@ type ThemeMeta struct { // Indicates that this theme prefers posts have titles. PreferTitle bool - // Content directory for "blog" posts - PostDir string `json:"post_dir"` + // Page bundle for "blog" posts + BlogPostBundle string `json:"post_dir"` } diff --git a/providers/db/bundles.go b/providers/db/bundles.go new file mode 100644 index 0000000..5b724c1 --- /dev/null +++ b/providers/db/bundles.go @@ -0,0 +1,50 @@ +package db + +import ( + "context" + "github.com/jackc/pgx/v5/pgtype" + "lmika.dev/lmika/hugo-cms/gen/sqlc/dbq" + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/pkg/modash/moslice" +) + +func (db *DB) InsertBundle(ctx context.Context, bundle *models.Bundle) error { + id, err := db.q.InsertBundle(ctx, dbq.InsertBundleParams{ + SiteID: bundle.SiteID, + Name: bundle.Name, + CreatedAt: pgtype.Timestamp{Time: bundle.CreatedAt, Valid: true}, + }) + if err != nil { + return err + } + bundle.ID = id + return nil +} + +func (db *DB) ListBundles(ctx context.Context, siteID int64) ([]models.Bundle, error) { + res, err := db.q.ListBundles(ctx, siteID) + if err != nil { + return nil, err + } + + return moslice.Map(res, dbBundleToBundle), nil +} + +func (db *DB) GetBundleWithID(ctx context.Context, id int64) (models.Bundle, error) { + res, err := db.q.GetBundleWithID(ctx, id) + if err != nil { + return models.Bundle{}, err + } + + return dbBundleToBundle(res), nil +} + +func dbBundleToBundle(b dbq.Bundle) models.Bundle { + return models.Bundle{ + ID: b.ID, + SiteID: b.SiteID, + Name: b.Name, + CreatedAt: b.CreatedAt.Time, + UpdatedAt: b.UpdatedAt.Time, + } +} diff --git a/providers/db/page.go b/providers/db/page.go new file mode 100644 index 0000000..4b209d4 --- /dev/null +++ b/providers/db/page.go @@ -0,0 +1,105 @@ +package db + +import ( + "context" + "github.com/jackc/pgx/v5/pgtype" + "lmika.dev/lmika/hugo-cms/gen/sqlc/dbq" + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/pkg/modash/momap" + "lmika.dev/pkg/modash/moslice" +) + +var nameProvenanceToDBNameProvenance = map[models.NameProvenance]dbq.PageNameProvenance{ + models.UserNameProvenance: dbq.PageNameProvenanceUser, + models.TitleNameProvenance: dbq.PageNameProvenanceTitle, + models.DateNameProvenance: dbq.PageNameProvenanceDate, +} +var dbNameProvenanceToNameProvenance = momap.ReverseMap(nameProvenanceToDBNameProvenance) + +func (db *DB) InsertPage(ctx context.Context, page *models.Page) error { + + id, err := db.q.InsertPage(ctx, dbq.InsertPageParams{ + SiteID: page.SiteID, + BundleID: page.BundleID, + Name: page.Name, + NameProvenance: nameProvenanceToDBNameProvenance[page.NameProvenance], + Title: pgtype.Text{String: page.Title, Valid: page.Title != ""}, + Body: page.Body, + State: dbq.PostState(page.State), + Props: []byte(`{}`), + PublishDate: pgtype.Timestamptz{Time: page.PublishDate, Valid: !page.PublishDate.IsZero()}, + CreatedAt: pgtype.Timestamp{Time: page.CreatedAt, Valid: true}, + }) + if err != nil { + return err + } + page.ID = id + return nil +} + +func (db *DB) UpdatePage(ctx context.Context, page *models.Page) error { + return db.q.UpdatePage(ctx, dbq.UpdatePageParams{ + ID: page.ID, + SiteID: page.SiteID, + BundleID: page.BundleID, + Name: page.Name, + NameProvenance: nameProvenanceToDBNameProvenance[page.NameProvenance], + Title: pgtype.Text{String: page.Title, Valid: page.Title != ""}, + Body: page.Body, + State: dbq.PostState(page.State), + Props: []byte(`{}`), + PublishDate: pgtype.Timestamptz{Time: page.PublishDate, Valid: true}, + CreatedAt: pgtype.Timestamp{Time: page.CreatedAt, Valid: true}, + UpdatedAt: pgtype.Timestamp{Time: page.UpdatedAt, Valid: true}, + }) +} + +func (db *DB) ListPagesOfSite(ctx context.Context, siteID int64) ([]models.Page, error) { + res, err := db.q.ListPages(ctx, siteID) + if err != nil { + return nil, err + } + + return moslice.Map(res, dbPageToPage), nil +} + +func (db *DB) ListPublishablePages(ctx context.Context, fromID, siteID int64) ([]models.Page, error) { + res, err := db.q.ListPublishablePages(ctx, dbq.ListPublishablePagesParams{ + ID: fromID, + SiteID: siteID, + }) + if err != nil { + return nil, err + } + + return moslice.Map(res, dbPageToPage), nil +} + +func (db *DB) GetPage(ctx context.Context, postID int64) (models.Page, error) { + res, err := db.q.GetPageWithID(ctx, postID) + if err != nil { + return models.Page{}, err + } + + return dbPageToPage(res), nil +} + +func (db *DB) DeletePage(ctx context.Context, pageID int64) error { + + return db.q.DeletePageWithID(ctx, pageID) +} + +func dbPageToPage(p dbq.Page) models.Page { + return models.Page{ + ID: p.ID, + SiteID: p.SiteID, + BundleID: p.BundleID, + Name: p.Name, + NameProvenance: dbNameProvenanceToNameProvenance[p.NameProvenance], + Title: p.Title.String, + Body: p.Body, + State: models.PostState(p.State), + PublishDate: p.PublishDate.Time, + CreatedAt: p.CreatedAt.Time, + } +} diff --git a/providers/db/posts.go b/providers/db/posts.go index a647453..45b9924 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -33,9 +33,9 @@ func (db *DB) DeletePost(ctx context.Context, postID int64) error { func (db *DB) ListPublishablePosts(ctx context.Context, fromID, siteID int64, now time.Time) ([]models.Post, error) { res, err := db.q.ListPublishablePosts(ctx, dbq.ListPublishablePostsParams{ - ID: fromID, - SiteID: siteID, - PostDate: pgtype.Timestamptz{Time: now, Valid: true}, + ID: fromID, + SiteID: siteID, + PublishDate: pgtype.Timestamptz{Time: now, Valid: true}, }) if err != nil { return nil, err @@ -46,13 +46,14 @@ func (db *DB) ListPublishablePosts(ctx context.Context, fromID, siteID int64, no 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()}, + SiteID: p.SiteID, + Title: pgtype.Text{String: p.Title, Valid: p.Title != ""}, + Body: p.Body, + State: dbq.PostState(p.State), + Props: []byte(`{}`), + PublishDate: pgtype.Timestamptz{Time: p.PublishDate, Valid: !p.PublishDate.IsZero()}, + CreatedAt: pgtype.Timestamp{Time: p.CreatedAt, Valid: !p.CreatedAt.IsZero()}, + UpdatedAt: pgtype.Timestamp{Time: p.UpdatedAt, Valid: !p.UpdatedAt.IsZero()}, }) if err != nil { return err @@ -64,25 +65,25 @@ func (db *DB) InsertPost(ctx context.Context, p *models.Post) error { func (db *DB) UpdatePost(ctx context.Context, p *models.Post) error { return db.q.UpdatePost(ctx, dbq.UpdatePostParams{ - ID: p.ID, - 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()}, + ID: p.ID, + SiteID: p.SiteID, + Title: pgtype.Text{String: p.Title, Valid: p.Title != ""}, + Body: p.Body, + State: dbq.PostState(p.State), + Props: []byte(`{}`), + PublishDate: pgtype.Timestamptz{Time: p.PublishDate, Valid: !p.PublishDate.IsZero()}, + UpdatedAt: pgtype.Timestamp{Time: p.UpdatedAt, Valid: !p.UpdatedAt.IsZero()}, }) } func dbPostToPost(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, + ID: p.ID, + SiteID: p.SiteID, + Title: p.Title.String, + Body: p.Body, + State: models.PostState(p.State), + PublishDate: p.PublishDate.Time, + CreatedAt: p.CreatedAt.Time, } } diff --git a/providers/themes/meta.go b/providers/themes/meta.go index c0a48a2..c2bf058 100644 --- a/providers/themes/meta.go +++ b/providers/themes/meta.go @@ -4,24 +4,24 @@ import "lmika.dev/lmika/hugo-cms/models" var themes = []models.ThemeMeta{ { - ID: "bear", - Name: "Bear", - URL: "https://github.com/janraasch/hugo-bearblog", - PreferTitle: true, - PostDir: "blog", + ID: "bear", + Name: "Bear", + URL: "https://github.com/janraasch/hugo-bearblog", + PreferTitle: true, + BlogPostBundle: "blog", }, { - ID: "terminal", - Name: "Terminal", - URL: "https://github.com/panr/hugo-theme-terminal", - PreferTitle: true, - PostDir: "posts", + ID: "terminal", + Name: "Terminal", + URL: "https://github.com/panr/hugo-theme-terminal", + PreferTitle: true, + BlogPostBundle: "posts", }, { - ID: "yingyang", - Name: "Yingyang", - URL: "https://github.com/joway/hugo-theme-yinyang", - PreferTitle: true, - PostDir: "posts", + ID: "yingyang", + Name: "Yingyang", + URL: "https://github.com/joway/hugo-theme-yinyang", + PreferTitle: true, + BlogPostBundle: "posts", }, } diff --git a/services/pages/services.go b/services/pages/services.go new file mode 100644 index 0000000..82df64d --- /dev/null +++ b/services/pages/services.go @@ -0,0 +1,183 @@ +package pages + +import ( + "context" + "errors" + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/lmika/hugo-cms/providers/db" + "lmika.dev/lmika/hugo-cms/services/jobs" + "lmika.dev/lmika/hugo-cms/services/sitebuilder" + "lmika.dev/pkg/modash/moslice" + "strings" + "time" + "unicode" +) + +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) ListPagesOfSite(ctx context.Context, site models.Site) ([]models.Page, error) { + return s.db.ListPagesOfSite(ctx, site.ID) +} + +func (s *Service) GetPage(ctx context.Context, id int) (models.Page, error) { + post, err := s.db.GetPage(ctx, int64(id)) + if err != nil { + return models.Page{}, err + } + + return post, nil +} + +func (s *Service) DeletePage(ctx context.Context, site models.Site, id int) error { + post, err := s.db.GetPage(ctx, int64(id)) + if err != nil { + return err + } + + if err := s.db.DeletePage(ctx, int64(id)); err != nil { + return err + } + + return s.jobs.Queue(ctx, s.sb.DeletePage(site, post)) +} + +func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (models.Page, error) { + siteBundles, err := s.db.ListBundles(ctx, site.ID) + if err != nil { + return models.Page{}, err + } else if len(siteBundles) == 0 { + return models.Page{}, errors.New("no bundles found") + } + + rootBundle, ok := moslice.FindWhere(siteBundles, func(t models.Bundle) bool { + return t.Name == models.RootBundleName + }) + if !ok { + return models.Page{}, errors.New("root bundle not found") + } + + publishTime := time.Now() + + name := s.normalizePageName(req.Title) + nameProvenance := models.TitleNameProvenance + if name == "" { + // Use the timestamp as the name + name = publishTime.Format("2006-01-02-150405") + nameProvenance = models.DateNameProvenance + } + + post := models.Page{ + SiteID: site.ID, + BundleID: rootBundle.ID, + Name: s.normalizePageName(req.Title), + NameProvenance: nameProvenance, + Title: req.Title, + Body: req.Body, + State: models.PostStatePublished, + PublishDate: time.Now(), + } + + if err := s.save(ctx, site, rootBundle, &post); err != nil { + return models.Page{}, err + } + + return post, nil +} + +func (s *Service) Update(ctx context.Context, site models.Site, pageID int64, req NewPost) (models.Page, error) { + page, err := s.db.GetPage(ctx, pageID) + if err != nil { + return models.Page{}, err + } + + if page.SiteID != site.ID { + return models.Page{}, errors.New("page not found") + } + + bundle, err := s.db.GetBundleWithID(ctx, page.BundleID) + if err != nil { + return models.Page{}, err + } else if bundle.SiteID != site.ID { + return models.Page{}, errors.New("page not found") + } + + // Update the title if it wasn't set by the user + if page.NameProvenance != models.UserNameProvenance { + if req.Title == "" { + page.Name = page.PublishDate.Format("2006-01-02-150405") + page.NameProvenance = models.DateNameProvenance + } else { + page.Name = s.normalizePageName(req.Title) + page.NameProvenance = models.TitleNameProvenance + } + } + page.Title = req.Title + page.Body = req.Body + + if err := s.save(ctx, site, bundle, &page); err != nil { + return models.Page{}, err + } + + return page, nil +} + +func (s *Service) save(ctx context.Context, site models.Site, bundle models.Bundle, page *models.Page) error { + page.SiteID = site.ID + + if page.ID == 0 { + page.CreatedAt = time.Now() + page.UpdatedAt = time.Now() + if err := s.db.InsertPage(ctx, page); err != nil { + return err + } + } else { + page.UpdatedAt = time.Now() + if err := s.db.UpdatePage(ctx, page); err != nil { + return err + } + } + + return s.jobs.Queue(ctx, s.sb.WritePage(site, bundle, *page)) +} + +func (s *Service) normalizePageName(title string) string { + var sb strings.Builder + lastSpace := false + for _, r := range title { + switch { + case unicode.IsSpace(r): + if !lastSpace { + sb.WriteRune('-') + lastSpace = true + } + case unicode.IsNumber(r): + lastSpace = false + sb.WriteRune(r) + case unicode.IsLetter(r): + lastSpace = false + sb.WriteRune(unicode.ToLower(r)) + } + } + return sb.String() +} + +type NewPost struct { + Title string + Body string +} diff --git a/services/posts/services.go b/services/posts/services.go index 902c14c..cecb0b9 100644 --- a/services/posts/services.go +++ b/services/posts/services.go @@ -55,11 +55,11 @@ func (s *Service) DeletePost(ctx context.Context, site models.Site, id int) erro func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (models.Post, error) { post := models.Post{ - SiteID: site.ID, - Title: req.Title, - Body: req.Body, - State: models.PostStatePublished, - PostDate: time.Now(), + SiteID: site.ID, + Title: req.Title, + Body: req.Body, + State: models.PostStatePublished, + PublishDate: time.Now(), } if err := s.Save(ctx, site, &post); err != nil { diff --git a/services/sitebuilder/pages.go b/services/sitebuilder/pages.go new file mode 100644 index 0000000..f551d76 --- /dev/null +++ b/services/sitebuilder/pages.go @@ -0,0 +1,105 @@ +package sitebuilder + +import ( + "context" + "fmt" + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/pkg/modash/momap" + "os" + "time" +) + +func (s *Service) WritePage(site models.Site, bundle models.Bundle, page models.Page) models.Job { + return models.Job{ + Do: func(ctx context.Context) error { + s.signalSiteBuildingStarted(ctx, site) + defer s.signalSiteBuildingFinished(ctx, site) + + rbn, err := s.fullRebuildNecessary(ctx, site) + if err != nil { + return err + } else if rbn { + return s.rebuildSite(ctx, site, site) + } + + themeMeta, ok := s.themes.Lookup(site.Theme) + if !ok { + return fmt.Errorf("theme %s not found in themes", site.Theme) + } + + if err := s.writePage(site, themeMeta, bundle, page); err != nil { + return err + } + return s.publish(ctx, site) + }, + } +} + +func (s *Service) DeletePage(site models.Site, page models.Page) models.Job { + return models.Job{ + Do: func(ctx context.Context) error { + s.signalSiteBuildingStarted(ctx, site) + defer s.signalSiteBuildingFinished(ctx, site) + + bundle, err := s.db.GetBundleWithID(ctx, page.BundleID) + if err != nil { + return err + } + + postFilename := s.pageFilename(site, bundle, page) + + if os.Remove(postFilename) != nil { + return nil + } + // TODO: if dir is empty, delete it + + return s.publish(ctx, site) + }, + } +} + +func (s *Service) writeAllPages(ctx context.Context, site models.Site) error { + themeMeta, ok := s.themes.Lookup(site.Theme) + if !ok { + return fmt.Errorf("theme %s not found in themes", site.Theme) + } + + bundles, err := s.db.ListBundles(ctx, site.ID) + if err != nil { + return err + } + + bundlesByID := momap.FromSlice(bundles, func(b models.Bundle) (int64, models.Bundle) { return b.ID, b }) + + var startId int64 + for { + pages, err := s.db.ListPublishablePages(ctx, int64(startId), site.ID) + if err != nil { + return err + } else if len(pages) == 0 { + return nil + } + + for _, page := range pages { + if err := s.writePage(site, themeMeta, bundlesByID[page.BundleID], page); err != nil { + return err + } + } + startId = pages[len(pages)-1].ID + } +} + +func (s *Service) writePage(site models.Site, themeMeta models.ThemeMeta, bundle models.Bundle, page models.Page) error { + postFilename := s.pageFilename(site, bundle, page) + + frontMatter := map[string]any{ + "date": page.PublishDate.Format(time.RFC3339), + } + if page.Title != "" { + frontMatter["title"] = page.Title + } else if themeMeta.PreferTitle { + frontMatter["title"] = page.PublishDate.Format(time.ANSIC) + } + + return s.writeMarkdownFile(postFilename, frontMatter, page.Body) +} diff --git a/services/sitebuilder/posts.go b/services/sitebuilder/posts.go index 72ac747..9789092 100644 --- a/services/sitebuilder/posts.go +++ b/services/sitebuilder/posts.go @@ -6,7 +6,6 @@ import ( "gopkg.in/yaml.v3" "lmika.dev/lmika/hugo-cms/models" "lmika.dev/lmika/hugo-cms/providers/hugo" - "log" "os" "path/filepath" "time" @@ -104,19 +103,21 @@ func (s *Service) writePost(site models.Site, post models.Post) error { postFilename := s.postFilename(site, themeMeta, post) - 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), + frontMatter := map[string]any{ + "date": post.PublishDate.Format(time.RFC3339), } if post.Title != "" { frontMatter["title"] = post.Title } else if themeMeta.PreferTitle { - frontMatter["title"] = post.PostDate.Format(time.ANSIC) + frontMatter["title"] = post.PublishDate.Format(time.ANSIC) + } + + return s.writeMarkdownFile(postFilename, frontMatter, post.Body) +} + +func (s *Service) writeMarkdownFile(outFile string, frontMatter map[string]any, body string) error { + if err := os.MkdirAll(filepath.Dir(outFile), 0755); err != nil { + return err } fmBytes, err := yaml.Marshal(frontMatter) @@ -124,7 +125,7 @@ func (s *Service) writePost(site models.Site, post models.Post) error { return err } - f, err := os.Create(postFilename) + f, err := os.Create(outFile) if err != nil { return err } @@ -139,7 +140,7 @@ func (s *Service) writePost(site models.Site, post models.Post) error { if _, err := f.WriteString("---\n"); err != nil { return err } - if _, err := f.WriteString(post.Body); err != nil { + if _, err := f.WriteString(body); err != nil { return err } @@ -147,5 +148,15 @@ func (s *Service) writePost(site models.Site, post models.Post) error { } func (s *Service) postFilename(site models.Site, themeMeta models.ThemeMeta, post models.Post) string { - return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.PostDir, post.CreatedAt.Format("2006-01-02-150405.md")) + return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.BlogPostBundle, post.CreatedAt.Format("2006-01-02-150405.md")) +} + +func (s *Service) pageFilename(site models.Site, bundle models.Bundle, page models.Page) string { + bundleDir := "" + if bundle.Name != models.RootBundleName { + bundleDir = bundle.Name + } + + pageName := page.Name + ".md" + return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), bundleDir, pageName) } diff --git a/services/sitebuilder/service.go b/services/sitebuilder/service.go index 914f901..b7c7faa 100644 --- a/services/sitebuilder/service.go +++ b/services/sitebuilder/service.go @@ -79,6 +79,10 @@ func (s *Service) rebuildSite(ctx context.Context, oldSite, newSite models.Site) return err } + if err := s.writeAllPages(ctx, newSite); err != nil { + return err + } + return s.publish(ctx, newSite) } @@ -148,3 +152,4 @@ func (s *Service) signalSiteBuildingStarted(ctx context.Context, site models.Sit func (s *Service) signalSiteBuildingFinished(ctx context.Context, site models.Site) { s.bus.Fire(models.Event{Type: models.EventSiteBuildingDone, Data: site}) } + diff --git a/services/sitebuilder/tracker.go b/services/sitebuilder/tracker.go new file mode 100644 index 0000000..51204a7 --- /dev/null +++ b/services/sitebuilder/tracker.go @@ -0,0 +1,32 @@ +package sitebuilder + +import ( + "lmika.dev/lmika/hugo-cms/models" + "lmika.dev/lmika/hugo-cms/providers/bus" +) + +type SiteBuildingTracker struct { + bus *bus.Bus + isBuildingState map[int64]models.Site +} + +func NewSiteBuildingTracker(bus *bus.Bus) *SiteBuildingTracker { + return &SiteBuildingTracker{ + bus: bus, + isBuildingState: map[int64]models.Site{}, + } +} + +func (sbt *SiteBuildingTracker) Listen() { + sub := sbt.bus.Subscribe() + + for e := range sub.C { + switch e.Type { + case models.EventSiteBuildingStart: + site := e.Data.(models.Site) + sbt.isBuildingState[site.ID] = site + case models.EventSiteBuildingDone: + delete(sbt.isBuildingState, e.Data.(models.Site).ID) + } + } +} diff --git a/services/sites/create.go b/services/sites/create.go new file mode 100644 index 0000000..b8a679d --- /dev/null +++ b/services/sites/create.go @@ -0,0 +1,55 @@ +package sites + +import ( + "context" + "errors" + "lmika.dev/lmika/hugo-cms/models" + "time" +) + +func (s *Service) CreateSite(ctx context.Context, user models.User, name string) (models.Site, error) { + // Create a new site + newSite := models.Site{ + Name: normaliseName(name), + OwnerUserID: user.ID, + Title: name, + Theme: "bear", + } + + _, ok := s.themes.Lookup(newSite.Theme) + if !ok { + return models.Site{}, errors.New("theme not found") + } + + if err := s.db.InsertSite(ctx, &newSite); err != nil { + return models.Site{}, err + } + + // Add the default page bundle + rootBundle := models.Bundle{ + SiteID: newSite.ID, + Name: models.RootBundleName, + CreatedAt: time.Now(), + } + if err := s.db.InsertBundle(ctx, &rootBundle); err != nil { + return models.Site{}, err + } + + // TEMP: Add a home page + homePage := models.Page{ + SiteID: newSite.ID, + BundleID: rootBundle.ID, + Name: "index", + Title: "Welcome to the home page", + Body: "This is the home page", + State: models.PostStatePublished, + PublishDate: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.db.InsertPage(ctx, &homePage); err != nil { + return models.Site{}, err + } + + return newSite, s.jobs.Queue(ctx, s.sb.RebuildSite(newSite, newSite)) +} diff --git a/services/sites/service.go b/services/sites/service.go index 8848b29..9380ff1 100644 --- a/services/sites/service.go +++ b/services/sites/service.go @@ -46,27 +46,6 @@ func (s *Service) GetProdTargetOfSite(ctx context.Context, siteID int) (models.P return s.db.GetPublishTargetBySiteRole(ctx, int64(siteID), models.TargetRoleProduction) } -func (s *Service) CreateSite(ctx context.Context, user models.User, name string) (models.Site, error) { - newSite := models.Site{ - Name: normaliseName(name), - OwnerUserID: user.ID, - Title: name, - Theme: "bear", - //Theme: "yingyang", - } - - _, ok := s.themes.Lookup(newSite.Theme) - if !ok { - return models.Site{}, errors.New("theme not found") - } - - if err := s.db.InsertSite(ctx, &newSite); err != nil { - return models.Site{}, err - } - - return newSite, s.jobs.Queue(ctx, s.sb.CreateNewSite(newSite)) -} - func (s *Service) SaveSettings(ctx context.Context, site models.Site, newSettings NewSettings) error { _, ok := s.themes.Lookup(newSettings.SiteTheme) if !ok { diff --git a/sql/queries/bundles.sql b/sql/queries/bundles.sql new file mode 100644 index 0000000..6d2a497 --- /dev/null +++ b/sql/queries/bundles.sql @@ -0,0 +1,13 @@ +-- name: InsertBundle :one +INSERT INTO bundles ( + site_id, + name, + created_at, + updated_at +) VALUES ($1, $2, $3, $3) RETURNING id; + +-- name: ListBundles :many +SELECT * FROM bundles WHERE site_id = $1; + +-- name: GetBundleWithID :one +SELECT * FROM bundles WHERE id = $1; \ No newline at end of file diff --git a/sql/queries/pages.sql b/sql/queries/pages.sql new file mode 100644 index 0000000..d3b77db --- /dev/null +++ b/sql/queries/pages.sql @@ -0,0 +1,47 @@ +-- name: InsertPage :one +INSERT INTO pages ( + site_id, + bundle_id, + name, + name_provenance, + title, + role, + body, + state, + props, + publish_date, + created_at, + updated_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) +RETURNING id; + +-- name: UpdatePage :exec +UPDATE pages SET + site_id = $2, + bundle_id = $3, + name = $4, + name_provenance = $5, + title = $6, + role = $7, + body = $8, + state = $9, + props = $10, + publish_date = $11, + created_at = $12, + updated_at = $13 +WHERE id = $1; + +-- name: ListPublishablePages :many +SELECT * +FROM pages +WHERE id > $1 AND site_id = $2 AND state = 'published' +ORDER BY id LIMIT 100; + +-- name: ListPages :many +SELECT * FROM pages WHERE site_id = $1 ORDER BY name ASC LIMIT 25; + +-- name: GetPageWithID :one +SELECT * FROM pages WHERE id = $1; + +-- name: DeletePageWithID :exec +DELETE FROM pages WHERE id = $1; \ No newline at end of file diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 1ddf3cf..f33bbfa 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,5 +1,5 @@ -- name: ListPosts :many -SELECT * FROM posts WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25; +SELECT * FROM posts WHERE site_id = $1 ORDER BY publish_date DESC LIMIT 25; -- name: GetPostWithID :one SELECT * FROM posts WHERE id = $1 LIMIT 1; @@ -7,7 +7,7 @@ SELECT * FROM posts WHERE id = $1 LIMIT 1; -- name: ListPublishablePosts :many SELECT * FROM posts -WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3 +WHERE id > $1 AND site_id = $2 AND state = 'published' AND publish_date <= $3 ORDER BY id LIMIT 100; -- name: InsertPost :one @@ -17,9 +17,10 @@ INSERT INTO posts ( body, state, props, - post_date, - created_at -) VALUES ($1, $2, $3, $4, $5, $6, $7) + publish_date, + created_at, + updated_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id; -- name: UpdatePost :exec @@ -29,8 +30,8 @@ UPDATE posts SET body = $4, state = $5, props = $6, - post_date = $7 - -- updated_at = $7 + publish_date = $7, + updated_at = $8 WHERE id = $1; -- name: DeletePost :exec diff --git a/sql/schema/1_init.up.sql b/sql/schema/1_init.up.sql index 38492d1..6524422 100644 --- a/sql/schema/1_init.up.sql +++ b/sql/schema/1_init.up.sql @@ -11,6 +11,12 @@ CREATE TYPE target_type AS ENUM ( 'netlify' ); +CREATE TYPE page_name_provenance AS ENUM ( + 'user', + 'title', + 'date' +); + CREATE TABLE users ( id BIGSERIAL NOT NULL PRIMARY KEY, email TEXT NOT NULL UNIQUE, @@ -28,19 +34,63 @@ CREATE TABLE sites ( FOREIGN KEY (owner_user_id) REFERENCES users (id) ); -CREATE TABLE posts ( - 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, +-- Post role is used to describe a specific kind of post, such as a link. +-- When set, it specifies the layout to use for the page +CREATE TABLE post_roles ( + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, + layout_name TEXT NOT NULL, FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE ); +CREATE TABLE posts ( + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, + title TEXT, + role BIGINT, + body TEXT NOT NULL, + state post_state NOT NULL, + props JSON NOT NULL, + publish_date TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + FOREIGN KEY (role) REFERENCES post_roles (id) ON DELETE CASCADE, + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); + +CREATE TABLE bundles ( + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); + +CREATE TABLE pages ( + id BIGSERIAL NOT NULL PRIMARY KEY, + site_id BIGINT NOT NULL, + bundle_id BIGINT NOT NULL, + name TEXT NOT NULL, + name_provenance page_name_provenance NOT NULL, + title TEXT, + role BIGINT, + body TEXT NOT NULL, + state post_state NOT NULL, + props JSON NOT NULL, + publish_date TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + + UNIQUE (bundle_id, name), + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE, + FOREIGN KEY (role) REFERENCES post_roles (id) ON DELETE CASCADE, + FOREIGN KEY (bundle_id) REFERENCES sites (id) ON DELETE CASCADE +); + CREATE TABLE publish_targets ( id BIGSERIAL NOT NULL PRIMARY KEY, site_id BIGINT NOT NULL, diff --git a/templates/fs.go b/templates/fs.go index a8b1117..9fa97a7 100644 --- a/templates/fs.go +++ b/templates/fs.go @@ -6,5 +6,6 @@ import "embed" //go:embed auth/*.html //go:embed layouts/*.html //go:embed posts/*.html +//go:embed pages/*.html //go:embed sites/*.html var FS embed.FS diff --git a/templates/pages/edit.html b/templates/pages/edit.html new file mode 100644 index 0000000..608201c --- /dev/null +++ b/templates/pages/edit.html @@ -0,0 +1,13 @@ +{{- $postTarget := printf "/sites/%v/pages" .site.ID -}} +{{- if (ne .page.ID 0) -}} + {{- $postTarget = printf "/sites/%v/pages/%v" .site.ID .page.ID -}} +{{- end -}} +
+ + + + +
+ +
+
diff --git a/templates/pages/index.html b/templates/pages/index.html new file mode 100644 index 0000000..acc7262 --- /dev/null +++ b/templates/pages/index.html @@ -0,0 +1,20 @@ +
+ New Page +
+ +{{range .pages}} +
+ {{if .Title}} +

{{.Title}}

+ {{end}} + + {{.Body | markdown}} + +
+ Edit | + Delete +
+
+{{else}} +

No pages yet

+{{end}} \ No newline at end of file