Categories
+| Name | +Slug | +Posts | ++ |
|---|---|---|---|
| {{ .Name }} | +{{ .Slug }} |
+ {{ .PostCount }} | ++ Edit + | +
| No categories yet. | +|||
diff --git a/assets/css/main.scss b/assets/css/main.scss index c8f0344..addf5ce 100644 --- a/assets/css/main.scss +++ b/assets/css/main.scss @@ -10,21 +10,16 @@ $container-max-widths: ( @import "bootstrap/scss/bootstrap.scss"; -// Local classes +// Navbar -.post-form { - display: grid; - grid-template-rows: min-content auto min-content; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; +.navbar-site-visit { + display: inline-block; + line-height: 2em; + margin-bottom: 4px; + margin-right: 10px; } -.post-form textarea { - height: 100%; -} +// Post list .postlist .post img { max-width: 300px; @@ -32,6 +27,54 @@ $container-max-widths: ( max-height: 300px; } +.postlist .post-date { + font-size: 0.9rem; +} + +// Large editor +// +// Used for edit canvases which take up the entire window + +.large-editor { + height: 100vh; +} + +.large-editor main { + display: flex; + flex-direction: column; + overflow: hidden; +} + +// Post form + +// Post edit page styling + +.post-edit-page .post-form { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.post-edit-page .post-form .row { + flex: 1; + display: flex; + min-height: 0; +} + +.post-edit-page .post-form .col-md-9 { + display: flex; + flex-direction: column; +} + +.post-edit-page .post-form textarea { + flex: 1; + resize: vertical; + min-height: 300px; +} + + + .show-upload figure img { max-width: 100vw; height: auto; diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js new file mode 100644 index 0000000..95cbb1e --- /dev/null +++ b/assets/js/controllers/edit_upload.js @@ -0,0 +1,233 @@ +import feather from "feather-icons/dist/feather.js"; +import Handlebars from "handlebars"; +import {Controller} from "@hotwired/stimulus"; + +Handlebars.registerHelper("submit_on", function (id, event) { + return `data-action="${event}->edit-upload#updateProcessor" data-edit-upload-id-param="${id}"` +}); + +const processorFrame = Handlebars.compile(` +
+`); + +const processorUIs = { + "shadow": { + label: "Shadow", + template: Handlebars.compile(` +| Name | +Slug | +Posts | ++ |
|---|---|---|---|
| {{ .Name }} | +{{ .Slug }} |
+ {{ .PostCount }} | ++ Edit + | +
| No categories yet. | +|||
+ {{ 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,| + | Title | +Slug | +Nav | +
|---|---|---|---|
| ☰ | +{{ .Title }} | +{{ .Slug }} |
+ {{ if .ShowInNav }}Yes{{ end }} | +
About this site
\n", +``` + +- [ ] **Step 6: Run the builder test** + +Run: `go test ./providers/sitebuilder/ -v` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add providers/sitebuilder/render_pages.go providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/pages_single.html providers/sitebuilder/builder_test.go +git commit -m "feat(pages): render pages in site builder after all other content" +``` + +--- + +### Task 10: Integration Test - Full Compile and Verify + +**Files:** None (verification only) + +- [ ] **Step 1: Run all tests** + +Run: `go test ./...` +Expected: All tests pass. + +- [ ] **Step 2: Verify clean build** + +Run: `go build ./...` +Expected: Clean compile, no errors. + +- [ ] **Step 3: Commit any fixes if needed** + +Only if previous steps required adjustments. diff --git a/docs/superpowers/plans/2026-03-22-paging.md b/docs/superpowers/plans/2026-03-22-paging.md new file mode 100644 index 0000000..9b44775 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-paging.md @@ -0,0 +1,888 @@ +# Paging Feature 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 offset-based pagination to the admin post list and the generated static site (posts and category listings). + +**Architecture:** Add a `posts_per_page` column to the `sites` table for configurable page size on the generated site. Admin uses a hardcoded page size of 25. The existing `db.PagingParams` and `LIMIT/OFFSET` SQL infrastructure is reused. A shared `models.PageInfo` type carries pagination state to templates. + +**Tech Stack:** Go, SQLite, sqlc, Fiber v3, html/template, Bootstrap + +--- + +### Task 1: Add `posts_per_page` column and regenerate sqlc + +**Files:** +- Create: `sql/schema/05_posts_per_page.up.sql` +- Modify: `sql/queries/sites.sql:10-19` (InsertSite query) +- Modify: `sql/queries/sites.sql:24-25` (UpdateSite query) +- Regenerate: `providers/db/gen/sqlgen/` (sqlc output) + +- [ ] **Step 1: Create migration file** + +Create `sql/schema/05_posts_per_page.up.sql`: +```sql +ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10; +``` + +- [ ] **Step 2: Update the InsertSite SQL query** + +In `sql/queries/sites.sql`, update the InsertSite query (lines 10-19) to include `posts_per_page`: +```sql +-- name: InsertSite :one +INSERT INTO sites ( + owner_id, + guid, + title, + tagline, + timezone, + posts_per_page, + created_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id; +``` + +- [ ] **Step 3: Update the UpdateSite SQL query** + +In `sql/queries/sites.sql`, update line 24-25: +```sql +-- name: UpdateSite :exec +UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?; +``` + +- [ ] **Step 4: Regenerate sqlc** + +Run: `sqlc generate` +Expected: `providers/db/gen/sqlgen/` files updated with new `PostsPerPage` field on `Site` struct, updated `InsertSiteParams` and `UpdateSiteParams`. + +- [ ] **Step 5: Run tests to verify nothing broke** + +Run: `go test ./...` +Expected: All existing tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add sql/schema/05_posts_per_page.up.sql sql/queries/sites.sql providers/db/gen/sqlgen/ +git commit -m "feat: add posts_per_page column to sites table" +``` + +--- + +### Task 2: Update Site model and DB provider for `PostsPerPage` + +**Files:** +- Modify: `models/sites.go:24-33` (Site struct) +- Modify: `providers/db/sites.go:42-65` (SaveSite) +- Modify: `providers/db/sites.go:102-112` (dbSiteToSite) + +- [ ] **Step 1: Add `PostsPerPage` to `models.Site`** + +In `models/sites.go`, add to the `Site` struct (after `Timezone`): +```go +PostsPerPage int +``` + +- [ ] **Step 2: Update `dbSiteToSite` in `providers/db/sites.go`** + +In `providers/db/sites.go`, update `dbSiteToSite` (line 102) to map the new field: +```go +func dbSiteToSite(row sqlgen.Site) models.Site { + return models.Site{ + ID: row.ID, + OwnerID: row.OwnerID, + GUID: row.Guid, + Title: row.Title, + Timezone: row.Timezone, + Tagline: row.Tagline, + PostsPerPage: int(row.PostsPerPage), + Created: time.Unix(row.CreatedAt, 0).UTC(), + } +} +``` + +- [ ] **Step 3: Update `SaveSite` to include `PostsPerPage`** + +In `providers/db/sites.go`, update the `InsertSite` call (line 44) to include `PostsPerPage`: +```go +newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{ + OwnerID: site.OwnerID, + Guid: site.GUID, + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + PostsPerPage: int64(site.PostsPerPage), + CreatedAt: timeToInt(site.Created), +}) +``` + +Update the `UpdateSite` call (line 59) to include `PostsPerPage`: +```go +return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{ + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + PostsPerPage: int64(site.PostsPerPage), + ID: site.ID, +}) +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./...` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add models/sites.go providers/db/sites.go sql/queries/sites.sql providers/db/gen/sqlgen/ +git commit -m "feat: add PostsPerPage to Site model and DB provider" +``` + +--- + +### Task 3: Add `CountPostsOfSite` SQL query and DB method + +**Files:** +- Modify: `sql/queries/posts.sql` (add count query) +- Modify: `providers/db/posts.go` (add CountPostsOfSite method) +- Modify: `providers/db/provider_test.go` (add test) +- Regenerate: `providers/db/gen/sqlgen/` + +- [ ] **Step 1: Write the failing test** + +Add to `providers/db/provider_test.go` inside `TestProvider_Posts`: +```go +t.Run("count posts of site", func(t *testing.T) { + countSite := &models.Site{ + OwnerID: user.ID, + GUID: models.NewNanoID(), + Title: "Count Blog", + } + require.NoError(t, p.SaveSite(ctx, countSite)) + + now := time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC) + for i := 0; i < 3; i++ { + post := &models.Post{ + SiteID: countSite.ID, + GUID: models.NewNanoID(), + Title: fmt.Sprintf("Post %d", i), + Body: "body", + Slug: fmt.Sprintf("/post-%d", i), + CreatedAt: now, + } + require.NoError(t, p.SavePost(ctx, post)) + } + + count, err := p.CountPostsOfSite(ctx, countSite.ID, false) + require.NoError(t, err) + assert.Equal(t, int64(3), count) + + // Soft-delete one post + posts, err := p.SelectPostsOfSite(ctx, countSite.ID, false, db.PagingParams{Limit: 10, Offset: 0}) + require.NoError(t, err) + require.NoError(t, p.SoftDeletePost(ctx, posts[0].ID)) + + count, err = p.CountPostsOfSite(ctx, countSite.ID, false) + require.NoError(t, err) + assert.Equal(t, int64(2), count) + + count, err = p.CountPostsOfSite(ctx, countSite.ID, true) + require.NoError(t, err) + assert.Equal(t, int64(1), count) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v` +Expected: FAIL β `CountPostsOfSite` method does not exist. + +- [ ] **Step 3: Add SQL query** + +Add to `sql/queries/posts.sql`: +```sql +-- name: CountPostsOfSite :one +SELECT COUNT(*) FROM posts +WHERE site_id = sqlc.arg(site_id) AND ( + CASE CAST (sqlc.arg(post_filter) AS TEXT) + WHEN 'deleted' THEN deleted_at > 0 + ELSE deleted_at = 0 + END +); +``` + +Run: `sqlc generate` + +- [ ] **Step 4: Add DB provider method** + +Add to `providers/db/posts.go`: +```go +func (db *Provider) CountPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) (int64, error) { + filter := "active" + if showDeleted { + filter = "deleted" + } + return db.queries.CountPostsOfSite(ctx, sqlgen.CountPostsOfSiteParams{ + SiteID: siteID, + PostFilter: filter, + }) +} +``` + +Note: check the generated `sqlgen.CountPostsOfSiteParams` struct name and fields after `sqlc generate` β adjust if the field names differ. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v` +Expected: PASS + +- [ ] **Step 6: Run all tests** + +Run: `go test ./...` +Expected: All pass. + +- [ ] **Step 7: Commit** + +```bash +git add sql/queries/posts.sql providers/db/posts.go providers/db/provider_test.go providers/db/gen/sqlgen/ +git commit -m "feat: add CountPostsOfSite query and DB method" +``` + +--- + +### Task 4: Add `models.PageInfo` type + +**Files:** +- Create: `models/paging.go` + +- [ ] **Step 1: Create `models/paging.go`** + +```go +package models + +// PageInfo carries pagination state for templates. +type PageInfo struct { + CurrentPage int + TotalPages int + PostsPerPage int +} + +// HasPrevious returns true if there is a previous page. +func (p PageInfo) HasPrevious() bool { + return p.CurrentPage > 1 +} + +// HasNext returns true if there is a next page. +func (p PageInfo) HasNext() bool { + return p.CurrentPage < p.TotalPages +} + +// PreviousPage returns the previous page number. +func (p PageInfo) PreviousPage() int { + return p.CurrentPage - 1 +} + +// NextPage returns the next page number. +func (p PageInfo) NextPage() int { + return p.CurrentPage + 1 +} +``` + +- [ ] **Step 2: Run tests** + +Run: `go test ./...` +Expected: All pass (no tests yet for this type, but it should compile). + +- [ ] **Step 3: Commit** + +```bash +git add models/paging.go +git commit -m "feat: add PageInfo model for pagination" +``` + +--- + +### Task 5: Add pagination to admin post list (service + handler) + +**Files:** +- Modify: `services/posts/list.go:15-38` (ListPosts signature and implementation) +- Modify: `handlers/posts.go:18-39` (Index handler) + +- [ ] **Step 1: Update `ListPosts` to accept paging params and return count** + +Replace `services/posts/list.go` `ListPosts` method: +```go +type ListPostsResult struct { + Posts []*PostWithCategories + TotalCount int64 +} + +func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) { + site, ok := models.GetSite(ctx) + if !ok { + return ListPostsResult{}, models.SiteRequiredError + } + + posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging) + if err != nil { + return ListPostsResult{}, err + } + + count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted) + if err != nil { + return ListPostsResult{}, err + } + + result := make([]*PostWithCategories, len(posts)) + for i, post := range posts { + cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID) + if err != nil { + return ListPostsResult{}, err + } + result[i] = &PostWithCategories{Post: post, Categories: cats} + } + return ListPostsResult{Posts: result, TotalCount: count}, nil +} +``` + +- [ ] **Step 2: Update the admin handler** + +Replace `handlers/posts.go` `Index` method: +```go +func (ph PostsHandler) Index(c fiber.Ctx) error { + var req struct { + Filter string `query:"filter"` + Page int `query:"page"` + } + if err := c.Bind().Query(&req); err != nil { + return fiber.ErrBadRequest + } + + const perPage = 25 + if req.Page < 1 { + req.Page = 1 + } + + result, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted", db.PagingParams{ + Offset: int64((req.Page - 1) * perPage), + Limit: perPage, + }) + if err != nil { + return err + } + + totalPages := int(result.TotalCount+int64(perPage)-1) / perPage + if totalPages < 1 { + totalPages = 1 + } + + pageInfo := models.PageInfo{ + CurrentPage: req.Page, + TotalPages: totalPages, + PostsPerPage: perPage, + } + + return accepts(c, json(func() any { + return result.Posts + }), html(func(c fiber.Ctx) error { + return c.Render("posts/index", fiber.Map{ + "req": req, + "posts": result.Posts, + "pageInfo": pageInfo, + }) + })) +} +``` + +Note: add `"lmika.dev/lmika/weiro/providers/db"` and `"lmika.dev/lmika/weiro/models"` to imports in `handlers/posts.go`. + +- [ ] **Step 3: Verify it compiles** + +Run: `go build ./...` +Expected: Compiles successfully. + +- [ ] **Step 4: Run tests** + +Run: `go test ./...` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add services/posts/list.go handlers/posts.go +git commit -m "feat: add pagination to admin post list handler and service" +``` + +--- + +### Task 6: Add pagination UI to admin post list template + +**Files:** +- Modify: `views/posts/index.html` + +- [ ] **Step 1: Add pagination controls to admin template** + +Add pagination controls after the post list in `views/posts/index.html`. Insert before the closing `` tag: + +```html +{{ if gt .pageInfo.TotalPages 1 }} + +{{ end }} +``` + +- [ ] **Step 2: Add `Pages` method to `PageInfo`** + +Add to `models/paging.go`: +```go +// Pages returns a slice of page numbers for rendering numbered pagination. +func (p PageInfo) Pages() []int { + pages := make([]int, p.TotalPages) + for i := range pages { + pages[i] = i + 1 + } + return pages +} +``` + +- [ ] **Step 3: Verify it compiles and test manually** + +Run: `go build ./...` +Expected: Compiles. + +- [ ] **Step 4: Commit** + +```bash +git add views/posts/index.html models/paging.go +git commit -m "feat: add pagination controls to admin post list" +``` + +--- + +### Task 7: Add site settings form for `PostsPerPage` + +**Files:** +- Modify: `views/sitesettings/general.html:17-48` (form) +- Modify: `services/sites/services.go:131-158` (UpdateSiteSettingsParams and UpdateSiteSettings) + +- [ ] **Step 1: Add `PostsPerPage` to `UpdateSiteSettingsParams`** + +In `services/sites/services.go`, update the struct (line 131): +```go +type UpdateSiteSettingsParams struct { + SiteID int64 `form:"siteID"` + Name string `form:"name"` + Tagline string `form:"tagline"` + Timezone string `form:"timezone"` + PostsPerPage int `form:"postsPerPage"` +} +``` + +- [ ] **Step 2: Update `UpdateSiteSettings` to handle `PostsPerPage`** + +In `services/sites/services.go`, update `UpdateSiteSettings` (line 138) to validate and set the new field: +```go +func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) { + site, err := s.GetSiteByID(ctx, params.SiteID) + if err != nil { + return models.Site{}, err + } + + _, err = time.LoadLocation(params.Timezone) + if err != nil { + return models.Site{}, errors.Wrap(err, "invalid timezone") + } + + postsPerPage := params.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 1 + } else if postsPerPage > 100 { + postsPerPage = 100 + } + + site.Title = params.Name + site.Tagline = params.Tagline + site.Timezone = params.Timezone + site.PostsPerPage = postsPerPage + + if err := s.db.SaveSite(ctx, &site); err != nil { + return models.Site{}, err + } + + return site, nil +} +``` + +- [ ] **Step 3: Add form field to settings template** + +In `views/sitesettings/general.html`, add after the Timezone field (after line 43, before the submit button row): +```html +{{ .Site.Tagline }}
+ {{ if .Site.NavItems }} + + {{ end }}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,", + "about/index.html": "About this site
\n", } outDir := t.TempDir() @@ -58,5 +78,4 @@ func TestBuilder_BuildSite(t *testing.T) { assert.Equal(t, content, string(fileContent)) } }) - } diff --git a/providers/sitebuilder/models.go b/providers/sitebuilder/models.go new file mode 100644 index 0000000..30419c6 --- /dev/null +++ b/providers/sitebuilder/models.go @@ -0,0 +1,10 @@ +package sitebuilder + +type buildContext struct { + outDir string +} + +type feedOptions struct { + targetNamePrefix string + titlePrefix string +} diff --git a/providers/sitebuilder/processors.go b/providers/sitebuilder/processors.go new file mode 100644 index 0000000..605d077 --- /dev/null +++ b/providers/sitebuilder/processors.go @@ -0,0 +1,42 @@ +package sitebuilder + +import ( + "net/url" + "strings" + + "github.com/PuerkitoBio/goquery" + "lmika.dev/lmika/weiro/models/pubmodel" +) + +type postMDProcessor func(site pubmodel.Site, dom *goquery.Document) error + +func uploadAbsoluteURL(site pubmodel.Site, dom *goquery.Document) error { + siteURL, err := url.Parse(site.BaseURL) + if err != nil { + return err + } + + dom.Find("img").Each(func(i int, s *goquery.Selection) { + srcUrl := s.AttrOr("src", "") + if site.BaseURL == "" { + return + } else if strings.HasPrefix(srcUrl, "http:") || strings.HasPrefix(srcUrl, "https:") { + return + } + + pu, err := url.Parse(srcUrl) + if err != nil { + return + } + + absURL := siteURL.ResolveReference(pu) + + s.SetAttr("src", absURL.String()) + }) + return nil +} + +func removeFootnoteHRs(site pubmodel.Site, dom *goquery.Document) error { + dom.Find("div.footnotes > hr").Remove() + return nil +} diff --git a/providers/sitebuilder/render_pages.go b/providers/sitebuilder/render_pages.go new file mode 100644 index 0000000..6183088 --- /dev/null +++ b/providers/sitebuilder/render_pages.go @@ -0,0 +1,31 @@ +package sitebuilder + +import ( + "bytes" + "context" + "html/template" + "io" +) + +func (b *Builder) renderPages(bctx buildContext) error { + for _, page := range b.site.Pages { + var md bytes.Buffer + if err := b.mdRenderer.RenderTo(context.Background(), &md, page.Body); err != nil { + return err + } + + data := pageSingleData{ + commonData: commonData{Site: b.site}, + Page: page, + HTML: template.HTML(md.String()), + } + + path := "/" + page.Slug + if err := b.createAtPath(bctx, path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNamePageSingle, data) + }); err != nil { + return err + } + } + return nil +} diff --git a/providers/sitebuilder/tmplfns.go b/providers/sitebuilder/tmplfns.go index 4b69a57..c6adc58 100644 --- a/providers/sitebuilder/tmplfns.go +++ b/providers/sitebuilder/tmplfns.go @@ -3,7 +3,7 @@ package sitebuilder import ( "html/template" "net/url" - "path/filepath" + "strings" "time" "lmika.dev/lmika/weiro/models/pubmodel" @@ -20,7 +20,7 @@ func templateFns(site pubmodel.Site, opts Options) template.FuncMap { if err != nil { return "", err } - pu.Path = filepath.Join(pu.Path, basePath) + pu.Path = joinPath(pu.Path, basePath) return pu.String(), nil }, "format_date": func(date time.Time) string { @@ -32,3 +32,7 @@ func templateFns(site pubmodel.Site, opts Options) template.FuncMap { }, } } + +func joinPath(basePath, path string) string { + return strings.TrimSuffix(basePath, "/") + "/" + strings.TrimPrefix(path, "/") +} diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index fa70b6d..029cab0 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -20,15 +20,30 @@ const ( // tmplNameLayoutMain is the template for the main layout (layoutMainData) tmplNameLayoutMain = "layout_main.html" + + // tmplNameCategoryList is the template for the category index page + tmplNameCategoryList = "categories_list.html" + + // tmplNameCategorySingle is the template for a single category page + tmplNameCategorySingle = "categories_single.html" + + // tmplNamePageSingle is the template for a single page (pageSingleData) + tmplNamePageSingle = "pages_single.html" ) type Options struct { - // BasePosts is the base path for posts. - BasePosts string + BasePosts string // BasePosts is the base path for posts. + BasePostList string // BasePostList is the base path for post lists. + BaseUploads string // BaseUploads is the base path for uploads. + BaseStatic string // BaseStatic is the base path for static assets. // TemplatesFS provides the raw templates for rendering the site. TemplatesFS fs.FS + // StaticFS provides the raw assets for the site. This will be written as is + // from the BaseStatic dir. + StaticFS fs.FS + // FeedItems holds the number of posts to show in the feed. FeedItems int @@ -41,18 +56,49 @@ type commonData struct { type postSingleData struct { commonData - Post *models.Post - HTML template.HTML - Path string - PostURL string + Post *models.Post + HTML template.HTML + Path string + PostURL string + Categories []*models.Category } type postListData struct { commonData - Posts []postSingleData + Posts []postSingleData + PageInfo models.PageInfo + PrevURL string + NextURL string } type layoutData struct { commonData Body template.HTML } + +type categoryListData struct { + commonData + Categories []categoryListItem +} + +type categoryListItem struct { + models.CategoryWithCount + Path string +} + +type categorySingleData struct { + commonData + Category *models.Category + DescriptionHTML template.HTML + Posts []postSingleData + Path string + PageInfo models.PageInfo + PrevURL string + NextURL string +} + +type pageSingleData struct { + commonData + Page *models.Page + HTML template.HTML +} diff --git a/providers/sitereader/provider.go b/providers/sitereader/provider.go deleted file mode 100644 index 1365d4b..0000000 --- a/providers/sitereader/provider.go +++ /dev/null @@ -1,94 +0,0 @@ -package sitereader - -import ( - "bytes" - "io" - "io/fs" - "time" - - "gopkg.in/yaml.v3" - "lmika.dev/lmika/weiro/models" -) - -type Provider struct { - fs fs.FS -} - -func New(fs fs.FS) *Provider { - return &Provider{ - fs: fs, - } -} - -func (p *Provider) ReadSite() (ReadSiteModels, error) { - posts, err := p.ListPosts() - if err != nil { - return ReadSiteModels{}, err - } - - meta := siteMeta{} - metaBytes, err := fs.ReadFile(p.fs, "site.yaml") - if err != nil { - return ReadSiteModels{}, err - } - if err := yaml.Unmarshal(metaBytes, &meta); err != nil { - return ReadSiteModels{}, err - } - - site := models.Site{ - Title: meta.Title, - Tagline: meta.Tagline, - } - - return ReadSiteModels{ - Site: site, - Posts: posts, - }, nil -} - -func (p *Provider) ListPosts() (posts []*models.Post, err error) { - err = fs.WalkDir(p.fs, "posts", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } else if d.IsDir() { - return nil - } - - post, err := p.ReadPost(path) - if err != nil { - return err - } - posts = append(posts, post) - return nil - }) - return posts, err -} - -func (p *Provider) ReadPost(path string) (*models.Post, error) { - data, err := fs.ReadFile(p.fs, path) - if err != nil { - return nil, err - } - - // Split front matter and content - parts := bytes.SplitN(data, []byte("---"), 3) - if len(parts) < 3 { - return nil, io.ErrUnexpectedEOF - } - - var meta postMeta - if err := yaml.Unmarshal(parts[1], &meta); err != nil { - return nil, err - } - - post := models.Post{ - Slug: meta.Slug, - Title: meta.Title, - GUID: meta.ID, - PublishedAt: meta.Date, - CreatedAt: time.Now(), - } - - post.Body = string(bytes.TrimPrefix(parts[2], []byte("\n"))) - return &post, nil -} diff --git a/providers/sitereader/provider_test.go b/providers/sitereader/provider_test.go deleted file mode 100644 index 0b012eb..0000000 --- a/providers/sitereader/provider_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package sitereader_test - -import ( - "testing" - "testing/fstest" - "time" - - "github.com/stretchr/testify/assert" - "lmika.dev/lmika/weiro/providers/sitereader" -) - -func TestProvider_ReadPost(t *testing.T) { - t.Run("with meta", func(t *testing.T) { - testFS := fstest.MapFS{ - "site.yaml": {Data: []byte(`base_url: https://example.com`)}, - "posts/test.md": {Data: []byte(`--- -date: 2026-02-18T19:59:00Z -title: Test Post Here -tags: [test, example] ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - post, err := pr.ReadPost("posts/test.md") - assert.NoError(t, err) - assert.Equal(t, "Test Post Here", post.Title) - assert.Equal(t, time.Date(2026, 2, 18, 19, 59, 0, 0, time.UTC), post.PublishedAt) - assert.Equal(t, "This is just a test post.\n", post.Body) - }) - - t.Run("without meta", func(t *testing.T) { - testFS := fstest.MapFS{ - "posts/test.md": {Data: []byte(`--- ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - post, err := pr.ReadPost("posts/test.md") - assert.NoError(t, err) - assert.Equal(t, "", post.Title) - assert.Equal(t, "This is just a test post.\n", post.Body) - }) -} - -func TestProvider_ListPosts(t *testing.T) { - testFS := fstest.MapFS{ - "posts/01-post1.md": {Data: []byte(`--- -id: 111 -date: 2026-02-18T19:59:00Z -title: Test Post Here -tags: [test, example] ---- -This is just a test post. -`)}, - "posts/02-post2.md": {Data: []byte(`--- -id: 222 ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - posts, err := pr.ListPosts() - assert.NoError(t, err) - - assert.Equal(t, 2, len(posts)) - - assert.Equal(t, "111", posts[0].GUID) - assert.Equal(t, "222", posts[1].GUID) -} - -func TestProvider_ReadSite(t *testing.T) { - testFS := fstest.MapFS{ - "site.yaml": {Data: []byte(`base_url: https://example.com`)}, - "posts/01-post1.md": {Data: []byte(`--- -id: 111 -date: 2026-02-18T19:59:00Z -title: Test Post Here -tags: [test, example] ---- -This is just a test post. -`)}, - "posts/02-post2.md": {Data: []byte(`--- -id: 222 ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - sites, err := pr.ReadSite() - assert.NoError(t, err) - - assert.Equal(t, 2, len(sites.Posts)) - - assert.Equal(t, "111", sites.Posts[0].GUID) - assert.Equal(t, "222", sites.Posts[1].GUID) -} diff --git a/providers/uploadfiles/provider.go b/providers/uploadfiles/provider.go index 2eb84e4..610a6f9 100644 --- a/providers/uploadfiles/provider.go +++ b/providers/uploadfiles/provider.go @@ -66,6 +66,11 @@ func copyFile(src, dst string) error { return err } +func (p *Provider) ReplaceFile(site models.Site, up models.Upload, srcPath string) error { + fullPath := p.uploadFileName(site, up) + return copyFile(srcPath, fullPath) +} + func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser, error) { fullPath := p.uploadFileName(site, up) return os.Open(fullPath) diff --git a/services/categories/service.go b/services/categories/service.go new file mode 100644 index 0000000..c45280e --- /dev/null +++ b/services/categories/service.go @@ -0,0 +1,178 @@ +package categories + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/services/publisher" +) + +type CreateCategoryParams struct { + GUID string `form:"guid" json:"guid"` + Name string `form:"name" json:"name"` + Slug string `form:"slug" json:"slug"` + Description string `form:"description" json:"description"` +} + +type Service struct { + db *db.Provider + publisher *publisher.Queue +} + +func New(db *db.Provider, publisher *publisher.Queue) *Service { + return &Service{db: db, publisher: publisher} +} + +func (s *Service) ListCategories(ctx context.Context) ([]*models.Category, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + return s.db.SelectCategoriesOfSite(ctx, site.ID) +} + +// ListCategoriesWithCounts returns all categories for the site with published post counts. +func (s *Service) ListCategoriesWithCounts(ctx context.Context) ([]models.CategoryWithCount, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + cats, err := s.db.SelectCategoriesOfSite(ctx, site.ID) + if err != nil { + return nil, err + } + + result := make([]models.CategoryWithCount, len(cats)) + for i, cat := range cats { + count, err := s.db.CountPostsOfCategory(ctx, cat.ID) + if err != nil { + return nil, err + } + result[i] = models.CategoryWithCount{ + Category: *cat, + PostCount: int(count), + DescriptionBrief: models.BriefDescription(cat.Description), + } + } + return result, nil +} + +func (s *Service) GetCategory(ctx context.Context, id int64) (*models.Category, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + cat, err := s.db.SelectCategory(ctx, id) + if err != nil { + return nil, err + } + if cat.SiteID != site.ID { + return nil, models.NotFoundError + } + return cat, nil +} + +func (s *Service) CreateCategory(ctx context.Context, params CreateCategoryParams) (*models.Category, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + now := time.Now() + slug := params.Slug + if slug == "" { + slug = models.GenerateCategorySlug(params.Name) + } + + // Check for slug collision + if _, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil { + return nil, models.SlugConflictError + } else if !db.ErrorIsNoRows(err) { + return nil, err + } + + cat := &models.Category{ + SiteID: site.ID, + GUID: params.GUID, + Name: params.Name, + Slug: slug, + Description: params.Description, + CreatedAt: now, + UpdatedAt: now, + } + if cat.GUID == "" { + cat.GUID = models.NewNanoID() + } + + if err := s.db.SaveCategory(ctx, cat); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return cat, nil +} + +func (s *Service) UpdateCategory(ctx context.Context, id int64, params CreateCategoryParams) (*models.Category, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + cat, err := s.db.SelectCategory(ctx, id) + if err != nil { + return nil, err + } + if cat.SiteID != site.ID { + return nil, models.NotFoundError + } + + slug := params.Slug + if slug == "" { + slug = models.GenerateCategorySlug(params.Name) + } + + // Check slug collision (exclude self) + if existing, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != cat.ID { + return nil, models.SlugConflictError + } else if err != nil && !db.ErrorIsNoRows(err) { + return nil, err + } + + cat.Name = params.Name + cat.Slug = slug + cat.Description = params.Description + cat.UpdatedAt = time.Now() + + if err := s.db.SaveCategory(ctx, cat); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return cat, nil +} + +func (s *Service) DeleteCategory(ctx context.Context, id int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + cat, err := s.db.SelectCategory(ctx, id) + if err != nil { + return err + } + if cat.SiteID != site.ID { + return models.NotFoundError + } + + if err := s.db.DeleteCategory(ctx, id); err != nil { + return err + } + + s.publisher.Queue(site) + return nil +} diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go new file mode 100644 index 0000000..ec84199 --- /dev/null +++ b/services/imgedit/processing.go @@ -0,0 +1,171 @@ +package imgedit + +import ( + "context" + "encoding/json" + "fmt" + "image" + "image/color" + "os" + "path/filepath" + + "github.com/disintegration/imaging" + "lmika.dev/lmika/weiro/models" +) + +type imageProcessor struct { + newParams func() any + processImage func(ctx context.Context, srcImg image.Image, params any) (image.Image, error) +} + +type shadowProcessorArgs struct { + Color string `json:"color"` + OffsetY int `json:"offset_y,string"` +} + +var processors = map[string]imageProcessor{ + "shadow": { + newParams: func() any { + return &shadowProcessorArgs{ + Color: "#000000", + OffsetY: 0, + } + }, + processImage: func(ctx context.Context, srcImg image.Image, params any) (image.Image, error) { + p := params.(*shadowProcessorArgs) + + shadowColor, err := parseHexColor(p.Color) + if err != nil { + return nil, fmt.Errorf("invalid shadow color: %w", err) + } + + shadow := makeBoxShadow(srcImg, shadowColor, 4, 10, p.OffsetY) + composit := imaging.OverlayCenter(shadow, srcImg, 1.0) + return composit, nil + }, + }, +} + +func (s *Service) reprocess(ctx context.Context, session *models.ImageEditSession) (imageSource, error) { + var img imageSource + + for _, p := range session.Processors { + // Check if there's currently a cached image of this processor + cachedImageFile := filepath.Join(s.scratchDir, session.GUID, fmt.Sprintf("%v.%v", p.VersionID, session.ImageExt)) + if s, err := os.Stat(cachedImageFile); err == nil && !s.IsDir() { + img = fileImageSource(cachedImageFile) + continue + } + + // Need to process the image + var srcImg image.Image + if img != nil { + var err error + srcImg, err = img.image() + if err != nil { + return nil, err + } + } + + resImg, err := s.processImage(ctx, srcImg, p) + if err != nil { + return nil, err + } + + // Cache the processed image + if err := imaging.Save(resImg, cachedImageFile); err != nil { + return nil, err + } + img = imageImageSource{resImg} + } + + return img, nil +} + +func (s *Service) processImage(ctx context.Context, srcImg image.Image, processor models.ImageEditProcessor) (image.Image, error) { + switch processor.Type { + case "copy-upload": + var p models.CopyUploadProps + if err := json.Unmarshal(processor.Props, &p); err != nil { + return nil, err + } + + _, rc, err := s.uploadService.OpenUpload(ctx, p.UploadID) + if err != nil { + return nil, err + } + + f, err := rc() + if err != nil { + return nil, err + } + defer f.Close() + + return imaging.Decode(f) + } + + proc, ok := processors[processor.Type] + if !ok { + return nil, fmt.Errorf("unknown processor type: %v", processor.Type) + } + + paramType := proc.newParams() + if err := json.Unmarshal(processor.Props, paramType); err != nil { + return nil, err + } + return proc.processImage(ctx, srcImg, paramType) +} + +type imageSource interface { + image() (image.Image, error) +} + +type fileImageSource string + +func (f fileImageSource) image() (image.Image, error) { + return imaging.Open(string(f)) +} + +type imageImageSource struct { + img image.Image +} + +func (i imageImageSource) image() (image.Image, error) { + return i.img, nil +} + +func parseHexColor(s string) (color.Color, error) { + // Remove leading hash if present + if len(s) > 0 && s[0] == '#' { + s = s[1:] + } + + // Parse based on length + var r, g, b, a uint8 + switch len(s) { + case 6: + // RGB format + var rgb uint32 + if _, err := fmt.Sscanf(s, "%06x", &rgb); err != nil { + return nil, fmt.Errorf("invalid hex color format: %w", err) + } + r = uint8((rgb >> 16) & 0xFF) + g = uint8((rgb >> 8) & 0xFF) + b = uint8(rgb & 0xFF) + a = 0xFF + case 8: + // RGBA format + var rgba uint32 + if _, err := fmt.Sscanf(s, "%08x", &rgba); err != nil { + return nil, fmt.Errorf("invalid hex color format: %w", err) + } + r = uint8((rgba >> 24) & 0xFF) + g = uint8((rgba >> 16) & 0xFF) + b = uint8((rgba >> 8) & 0xFF) + a = uint8(rgba & 0xFF) + default: + return nil, fmt.Errorf("invalid hex color length: expected 6 or 8 characters, got %d", len(s)) + } + + return color.RGBA{R: r, G: g, B: b, A: a}, nil +} diff --git a/services/imgedit/service.go b/services/imgedit/service.go new file mode 100644 index 0000000..926633c --- /dev/null +++ b/services/imgedit/service.go @@ -0,0 +1,266 @@ +package imgedit + +import ( + "context" + "encoding/json" + "fmt" + "io" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/uploads" + "lmika.dev/pkg/modash/moslice" +) + +type Service struct { + scratchDir string + uploadService *uploads.Service + sessionStore *sessionStore +} + +func New( + uploadService *uploads.Service, + scratchDir string, +) *Service { + return &Service{ + scratchDir: scratchDir, + uploadService: uploadService, + sessionStore: &sessionStore{baseDir: scratchDir}, + } +} + +func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (*models.ImageEditSession, error) { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return nil, err + } + + upload, _, err := s.uploadService.OpenUpload(ctx, baseUploadID) + if err != nil { + return nil, err + } + + var ext string + switch upload.MIMEType { + case "image/jpeg": + ext = "jpg" + case "image/png": + ext = "png" + default: + return nil, models.UnsupportedImageFormat + } + + newSession := models.ImageEditSession{ + GUID: models.NewNanoID(), + SiteID: site.ID, + UserID: user.ID, + BaseUploadID: baseUploadID, + ImageExt: ext, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + Processors: []models.ImageEditProcessor{ + { + ID: models.NewNanoID(), + Type: "copy-upload", + Props: mustToJSON(models.CopyUploadProps{UploadID: baseUploadID}), + }, + }, + } + + newSession.RecalcVersionIDs() + if err := s.sessionStore.save(&newSession); err != nil { + return nil, err + } + + if _, err := s.reprocess(ctx, &newSession); err != nil { + return nil, err + } + + return &newSession, nil +} + +func (s *Service) LoadImageVersion(ctx context.Context, sessionID string, versionID string) (mimeType string, rw func() (io.ReadCloser, error), err error) { + session, err := s.loadAndVerifySession(ctx, sessionID) + if err != nil { + return "", nil, err + } + + return s.sessionStore.getImage(session, versionID+"."+session.ImageExt) +} + +type AddProcessorReq struct { + Type string `json:"type"` +} + +func (s *Service) AddProcessor(ctx context.Context, sessionID string, req AddProcessorReq) (*models.ImageEditSession, error) { + session, err := s.loadAndVerifySession(ctx, sessionID) + if err != nil { + return nil, err + } + + proc, ok := processors[req.Type] + if !ok { + return nil, fmt.Errorf("unknown processor type: %v", req.Type) + } + + paramType := proc.newParams() + paramBytes, err := json.Marshal(paramType) + if err != nil { + return nil, err + } + + session.Processors = append(session.Processors, models.ImageEditProcessor{ + ID: models.NewNanoID(), + Type: req.Type, + Props: paramBytes, + }) + + session.RecalcVersionIDs() + if err := s.sessionStore.save(session); err != nil { + return nil, err + } + + if _, err := s.reprocess(ctx, session); err != nil { + return nil, err + } + + return session, nil +} + +func (s *Service) DeleteProcessor(ctx context.Context, sessionID, processorID string) (*models.ImageEditSession, error) { + session, err := s.loadAndVerifySession(ctx, sessionID) + if err != nil { + return nil, err + } + + session.Processors = moslice.Filter(session.Processors, func(p models.ImageEditProcessor) bool { return p.ID != processorID }) + session.RecalcVersionIDs() + if err := s.sessionStore.save(session); err != nil { + return nil, err + } + + if _, err := s.reprocess(ctx, session); err != nil { + return nil, err + } + + return session, nil +} + +type UpdateProcessorReq struct { + ID string `json:"id"` + Props json.RawMessage `json:"props"` +} + +func (s *Service) UpdateProcessor(ctx context.Context, sessionID string, req UpdateProcessorReq) (*models.ImageEditSession, error) { + session, err := s.loadAndVerifySession(ctx, sessionID) + if err != nil { + return nil, err + } + + for i, p := range session.Processors { + if p.ID == req.ID { + session.Processors[i].Props = req.Props + break + } + } + + session.RecalcVersionIDs() + if err := s.sessionStore.save(session); err != nil { + return nil, err + } + if _, err := s.reprocess(ctx, session); err != nil { + return nil, err + } + + return session, nil +} + +type SaveResult struct { + UploadID int64 `json:"upload_id"` +} + +func (s *Service) Save(ctx context.Context, sessionID string, mode string) (*SaveResult, error) { + session, err := s.loadAndVerifySession(ctx, sessionID) + if err != nil { + return nil, err + } + + if len(session.Processors) == 0 { + return nil, fmt.Errorf("no processors in session") + } + + lastProc := session.Processors[len(session.Processors)-1] + finalImagePath := fmt.Sprintf("%v/%v/%v.%v", s.scratchDir, session.GUID, lastProc.VersionID, session.ImageExt) + + var mimeType string + switch session.ImageExt { + case "jpg", "jpeg": + mimeType = "image/jpeg" + case "png": + mimeType = "image/png" + } + + var uploadID int64 + switch mode { + case "replace": + upload, err := s.uploadService.ReplaceUploadFile(ctx, session.BaseUploadID, finalImagePath) + if err != nil { + return nil, err + } + uploadID = upload.ID + case "copy": + baseUpload, _, err := s.uploadService.OpenUpload(ctx, session.BaseUploadID) + if err != nil { + return nil, err + } + upload, err := s.uploadService.CreateUploadFromFile(ctx, finalImagePath, baseUpload.Filename, mimeType) + if err != nil { + return nil, err + } + uploadID = upload.ID + default: + return nil, fmt.Errorf("unknown save mode: %v", mode) + } + + s.sessionStore.delete(session.GUID) + + return &SaveResult{UploadID: uploadID}, nil +} + +func (s *Service) loadAndVerifySession(ctx context.Context, sessionID string) (*models.ImageEditSession, error) { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return nil, err + } + + session, err := s.sessionStore.get(sessionID) + if err != nil { + return nil, err + } else if session.SiteID != site.ID || session.UserID != user.ID { + return nil, models.PermissionError + } + return session, nil +} + +func (s *Service) fetchSiteAndUser(ctx context.Context) (models.Site, models.User, error) { + user, ok := models.GetUser(ctx) + if !ok { + return models.Site{}, models.User{}, models.UserRequiredError + } + + site, ok := models.GetSite(ctx) + if !ok { + return models.Site{}, models.User{}, models.SiteRequiredError + } + + if site.OwnerID != user.ID { + return models.Site{}, models.User{}, models.PermissionError + } + + return site, user, nil +} + +func mustToJSON(a any) json.RawMessage { + b, _ := json.Marshal(a) + return b +} diff --git a/services/imgedit/shadow.go b/services/imgedit/shadow.go new file mode 100644 index 0000000..4a308d0 --- /dev/null +++ b/services/imgedit/shadow.go @@ -0,0 +1,35 @@ +package imgedit + +import ( + "image" + "image/color" + + "github.com/disintegration/imaging" +) + +func makeBoxShadow(maskImg image.Image, shadowColor color.Color, sigma float64, shadowMargin, offsetY int) image.Image { + w, h := maskImg.Bounds().Dx(), maskImg.Bounds().Dy() + cr, cg, cb, _ := shadowColor.RGBA() + cr8, cg8, cb8 := uint8(cr>>8), uint8(cg>>8), uint8(cb>>8) + + // New box image + backing := image.NewNRGBA(image.Rect(0, 0, w+shadowMargin*2, h+shadowMargin*2+offsetY)) + newImg := image.NewNRGBA(image.Rect(0, 0, w+shadowMargin*2, h+shadowMargin*2+offsetY)) + for x := 0; x < w+shadowMargin*2; x++ { + for y := 0; y < h+shadowMargin*2; y++ { + var c = color.NRGBA{R: 255, G: 255, B: 255, A: 0} + if x >= shadowMargin-4 && y >= shadowMargin-4 && x <= w+shadowMargin+4 && y <= h+shadowMargin+4 { + _, _, _, a := maskImg.At(x-shadowMargin, y-shadowMargin).RGBA() + c = color.NRGBA{R: cr8, G: cg8, B: cb8, A: uint8(a >> 8)} + } + backing.SetNRGBA(x, y, color.NRGBA{R: 255, G: 255, B: 255, A: 0}) + newImg.SetNRGBA(x, y+offsetY, c) + } + } + + // Blur + blurredImage := imaging.Blur(newImg, sigma) + backing = imaging.OverlayCenter(backing, blurredImage, 0.6) + + return backing +} diff --git a/services/imgedit/store.go b/services/imgedit/store.go new file mode 100644 index 0000000..df3403a --- /dev/null +++ b/services/imgedit/store.go @@ -0,0 +1,70 @@ +package imgedit + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + + "lmika.dev/lmika/weiro/models" +) + +type sessionStore struct { + baseDir string +} + +func (ss *sessionStore) save(newSession *models.ImageEditSession) error { + sessionMeta, err := json.Marshal(newSession) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Join(ss.baseDir, newSession.GUID), 0755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(ss.baseDir, newSession.GUID, "session.json"), sessionMeta, 0644); err != nil { + return err + } + return nil +} + +func (ss *sessionStore) get(guid string) (*models.ImageEditSession, error) { + sessionDataBts, err := os.ReadFile(filepath.Join(ss.baseDir, guid, "session.json")) + if err != nil { + return nil, err + } + + sessionData := models.ImageEditSession{} + if err := json.Unmarshal(sessionDataBts, &sessionData); err != nil { + return nil, err + } + + return &sessionData, nil +} + +func (ss *sessionStore) delete(guid string) { + os.RemoveAll(filepath.Join(ss.baseDir, guid)) +} + +func (ss *sessionStore) getImage(session *models.ImageEditSession, imageFilename string) (string, func() (io.ReadCloser, error), error) { + fullPath := filepath.Join(ss.baseDir, session.GUID, imageFilename) + if s, err := os.Stat(fullPath); err != nil { + return "", nil, err + } else if s.IsDir() { + return "", nil, os.ErrNotExist + } + + var mimeType string + switch filepath.Ext(imageFilename) { + case ".jpg", ".jpeg": + mimeType = "image/jpeg" + case ".png": + mimeType = "image/png" + default: + return "", nil, models.UnsupportedImageFormat + } + + return mimeType, func() (io.ReadCloser, error) { + return os.Open(fullPath) + }, nil +} diff --git a/services/import/service.go b/services/import/service.go deleted file mode 100644 index e4aee94..0000000 --- a/services/import/service.go +++ /dev/null @@ -1,54 +0,0 @@ -package _import - -import ( - "context" - "os" - - "emperror.dev/errors" - "lmika.dev/lmika/weiro/models" - "lmika.dev/lmika/weiro/providers/db" - "lmika.dev/lmika/weiro/providers/sitereader" -) - -type Service struct { - db *db.Provider -} - -func New(db *db.Provider) *Service { - return &Service{ - db: db, - } -} - -func (s *Service) Import(ctx context.Context, sitePath string) (models.Site, error) { - user, ok := models.GetUser(ctx) - if !ok { - return models.Site{}, models.UserRequiredError - } - - sr := sitereader.New(os.DirFS(sitePath)) - - readSite, err := sr.ReadSite() - if err != nil { - return models.Site{}, errors.Wrap(err, "failed to read site") - } - - site := readSite.Site - site.OwnerID = user.ID - - if err := s.db.SaveSite(ctx, &site); err != nil { - return models.Site{}, errors.Wrap(err, "failed to save site") - } - - for _, post := range readSite.Posts { - post.SiteID = site.ID - if post.GUID == "" { - post.GUID = models.NewNanoID() - } - if err := s.db.SavePost(ctx, post); err != nil { - return models.Site{}, errors.Wrap(err, "failed to save post") - } - } - - return site, nil -} diff --git a/services/obsimport/service.go b/services/obsimport/service.go new file mode 100644 index 0000000..0852031 --- /dev/null +++ b/services/obsimport/service.go @@ -0,0 +1,229 @@ +package obsimport + +import ( + "archive/zip" + "bufio" + "context" + "fmt" + "io" + "log" + "mime" + "os" + "path/filepath" + "strings" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/providers/uploadfiles" + "lmika.dev/lmika/weiro/services/publisher" +) + +type Service struct { + db *db.Provider + up *uploadfiles.Provider + publisher *publisher.Queue + scratchDir string +} + +func New(db *db.Provider, up *uploadfiles.Provider, publisher *publisher.Queue, scratchDir string) *Service { + return &Service{ + db: db, + up: up, + publisher: publisher, + scratchDir: scratchDir, + } +} + +type ImportResult struct { + PostsImported int + UploadsImported int +} + +func (s *Service) ImportZip(ctx context.Context, zipPath string) (ImportResult, error) { + site, ok := models.GetSite(ctx) + if !ok { + return ImportResult{}, models.SiteRequiredError + } + + zr, err := zip.OpenReader(zipPath) + if err != nil { + return ImportResult{}, fmt.Errorf("open zip: %w", err) + } + defer zr.Close() + + var result ImportResult + + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + + ext := strings.ToLower(filepath.Ext(f.Name)) + if ext == ".md" || ext == ".markdown" { + if err := s.importNote(ctx, site, f); err != nil { + log.Printf("warn: skipping note %s: %v", f.Name, err) + continue + } + result.PostsImported++ + } else if isAttachment(ext) { + if err := s.importAttachment(ctx, site, f); err != nil { + log.Printf("warn: skipping attachment %s: %v", f.Name, err) + continue + } + result.UploadsImported++ + } + } + + s.publisher.Queue(site) + + return result, nil +} + +func (s *Service) importNote(ctx context.Context, site models.Site, f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return err + } + + body := stripFrontMatter(string(data)) + title := strings.TrimSuffix(filepath.Base(f.Name), filepath.Ext(f.Name)) + publishedAt := f.Modified + if publishedAt.IsZero() { + publishedAt = time.Now() + } + + renderTZ, err := time.LoadLocation(site.Timezone) + if err != nil { + renderTZ = time.UTC + } + publishedAt = publishedAt.In(renderTZ) + + post := &models.Post{ + SiteID: site.ID, + GUID: models.NewNanoID(), + State: models.StatePublished, + Title: title, + Body: body, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + PublishedAt: publishedAt, + } + post.Slug = post.BestSlug() + + return s.db.SavePost(ctx, post) +} + +func (s *Service) importAttachment(ctx context.Context, site models.Site, f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + // Write to a temp file in scratch dir + if err := os.MkdirAll(s.scratchDir, 0755); err != nil { + return err + } + + tmpFile, err := os.CreateTemp(s.scratchDir, "obsimport-*"+filepath.Ext(f.Name)) + if err != nil { + return err + } + tmpPath := tmpFile.Name() + + if _, err := io.Copy(tmpFile, rc); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return err + } + tmpFile.Close() + + filename := filepath.Base(f.Name) + mimeType := mime.TypeByExtension(filepath.Ext(filename)) + if mimeType == "" { + mimeType = "application/octet-stream" + } + + stat, err := os.Stat(tmpPath) + if err != nil { + os.Remove(tmpPath) + return err + } + + newUploadGUID := models.NewNanoID() + newTime := time.Now().UTC() + newSlug := filepath.Join( + fmt.Sprintf("%04d", newTime.Year()), + fmt.Sprintf("%02d", newTime.Month()), + newUploadGUID+filepath.Ext(filename), + ) + + newUpload := models.Upload{ + SiteID: site.ID, + GUID: models.NewNanoID(), + FileSize: stat.Size(), + MIMEType: mimeType, + Filename: filename, + CreatedAt: newTime, + Slug: newSlug, + } + if err := s.db.SaveUpload(ctx, &newUpload); err != nil { + os.Remove(tmpPath) + return err + } + + if err := s.up.AdoptFile(site, newUpload, tmpPath); err != nil { + os.Remove(tmpPath) + return err + } + + return nil +} + +// stripFrontMatter removes YAML front matter (delimited by ---) from markdown content. +func stripFrontMatter(content string) string { + scanner := bufio.NewScanner(strings.NewReader(content)) + + // Check if the first line is a front matter delimiter + if !scanner.Scan() { + return content + } + firstLine := strings.TrimSpace(scanner.Text()) + if firstLine != "---" { + return content + } + + // Skip until the closing --- + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) == "---" { + // Return everything after the closing delimiter + var rest strings.Builder + for scanner.Scan() { + rest.WriteString(scanner.Text()) + rest.WriteString("\n") + } + return strings.TrimLeft(rest.String(), "\n") + } + } + + // No closing delimiter found, return original content + return content +} + +var attachmentExts = map[string]bool{ + ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, + ".bmp": true, ".ico": true, ".tiff": true, ".tif": true, + ".mp3": true, ".mp4": true, ".wav": true, ".ogg": true, ".webm": true, + ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, +} + +func isAttachment(ext string) bool { + return attachmentExts[ext] +} diff --git a/services/obsimport/service_test.go b/services/obsimport/service_test.go new file mode 100644 index 0000000..51123de --- /dev/null +++ b/services/obsimport/service_test.go @@ -0,0 +1,51 @@ +package obsimport + +import "testing" + +func TestStripFrontMatter(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "no front matter", + input: "Hello world\nThis is a note", + want: "Hello world\nThis is a note", + }, + { + name: "with front matter", + input: "---\ntitle: Test\ntags: [a, b]\n---\nHello world\nThis is a note\n", + want: "Hello world\nThis is a note\n", + }, + { + name: "only front matter", + input: "---\ntitle: Test\n---\n", + want: "", + }, + { + name: "unclosed front matter", + input: "---\ntitle: Test\nno closing delimiter", + want: "---\ntitle: Test\nno closing delimiter", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "front matter with leading newlines stripped", + input: "---\nkey: val\n---\n\n\nBody here\n", + want: "Body here\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripFrontMatter(tt.input) + if got != tt.want { + t.Errorf("stripFrontMatter() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/services/pages/service.go b/services/pages/service.go new file mode 100644 index 0000000..8a82bc0 --- /dev/null +++ b/services/pages/service.go @@ -0,0 +1,198 @@ +package pages + +import ( + "context" + "strings" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/services/publisher" +) + +type CreatePageParams struct { + GUID string `form:"guid" json:"guid"` + Title string `form:"title" json:"title"` + Slug string `form:"slug" json:"slug"` + Body string `form:"body" json:"body"` + PageType int `form:"page_type" json:"page_type"` + ShowInNav bool `form:"show_in_nav" json:"show_in_nav"` +} + +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) ListPages(ctx context.Context) ([]*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + return s.db.SelectPagesOfSite(ctx, site.ID) +} + +func (s *Service) GetPage(ctx context.Context, id int64) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return nil, err + } + if page.SiteID != site.ID { + return nil, models.NotFoundError + } + return page, nil +} + +func (s *Service) CreatePage(ctx context.Context, params CreatePageParams) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + now := time.Now() + slug := params.Slug + if slug == "" { + slug = models.GeneratePageSlug(params.Title) + } + + if !strings.HasPrefix(slug, "/") { + slug = "/" + slug + } + + // Check slug collision + if _, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil { + return nil, models.SlugConflictError + } else if !db.ErrorIsNoRows(err) { + return nil, err + } + + // Determine sort order: place at end + existingPages, err := s.db.SelectPagesOfSite(ctx, site.ID) + if err != nil { + return nil, err + } + sortOrder := len(existingPages) + + page := &models.Page{ + SiteID: site.ID, + GUID: params.GUID, + Title: params.Title, + Slug: slug, + Body: params.Body, + PageType: params.PageType, + ShowInNav: params.ShowInNav, + SortOrder: sortOrder, + CreatedAt: now, + UpdatedAt: now, + } + if page.GUID == "" { + page.GUID = models.NewNanoID() + } + + if err := s.db.SavePage(ctx, page); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return page, nil +} + +func (s *Service) UpdatePage(ctx context.Context, id int64, params CreatePageParams) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return nil, err + } + if page.SiteID != site.ID { + return nil, models.NotFoundError + } + + slug := params.Slug + if slug == "" { + slug = models.GeneratePageSlug(params.Title) + } + + if !strings.HasPrefix(slug, "/") { + slug = "/" + slug + } + + // Check slug collision (exclude self) + if existing, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != page.ID { + return nil, models.SlugConflictError + } else if err != nil && !db.ErrorIsNoRows(err) { + return nil, err + } + + page.Title = params.Title + page.Slug = slug + page.Body = params.Body + page.PageType = params.PageType + page.ShowInNav = params.ShowInNav + page.UpdatedAt = time.Now() + + if err := s.db.SavePage(ctx, page); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return page, nil +} + +func (s *Service) DeletePage(ctx context.Context, id int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return err + } + if page.SiteID != site.ID { + return models.NotFoundError + } + + if err := s.db.DeletePage(ctx, id); err != nil { + return err + } + + s.publisher.Queue(site) + return nil +} + +func (s *Service) ReorderPages(ctx context.Context, pageIDs []int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + // Verify all pages belong to this site + for i, id := range pageIDs { + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return err + } + if page.SiteID != site.ID { + return models.NotFoundError + } + if err := s.db.UpdatePageSortOrder(ctx, id, i); err != nil { + return err + } + } + + s.publisher.Queue(site) + return nil +} diff --git a/services/posts/create.go b/services/posts/create.go index 4c99b82..b1a6466 100644 --- a/services/posts/create.go +++ b/services/posts/create.go @@ -10,10 +10,11 @@ import ( ) type CreatePostParams struct { - GUID string `form:"guid" json:"guid"` - Title string `form:"title" json:"title"` - Body string `form:"body" json:"body"` - Action string `form:"action" json:"action"` + GUID string `form:"guid" json:"guid"` + Title string `form:"title" json:"title"` + Body string `form:"body" json:"body"` + Action string `form:"action" json:"action"` + CategoryIDs []int64 `form:"category_ids" json:"category_ids"` } func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) { @@ -32,13 +33,20 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod post.Title = params.Title post.Body = params.Body post.UpdatedAt = now - post.Slug = post.BestSlug() oldState := post.State switch strings.ToLower(params.Action) { case "publish": post.State = models.StatePublished - post.PublishedAt = now + + // Set the published at with the site timezone, and reset the slug, so that the date + // is in the site timezone. + renderTZ, err := time.LoadLocation(site.Timezone) + if err != nil { + renderTZ = time.UTC + } + post.PublishedAt = now.In(renderTZ) + post.Slug = post.BestSlug() case "save draft": post.State = models.StateDraft post.PublishedAt = time.Time{} @@ -46,7 +54,21 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod // Leave unchanged } - if err := s.db.SavePost(ctx, post); err != nil { + // Use a transaction for atomicity of post save + category reassignment + tx, err := s.db.BeginTx(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback() + + txDB := s.db.QueriesWithTx(tx) + if err := txDB.SavePost(ctx, post); err != nil { + return nil, err + } + if err := txDB.SetPostCategories(ctx, post.ID, params.CategoryIDs); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { return nil, err } @@ -79,3 +101,13 @@ func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, param } return post, nil } + +// TEMP - to move +func (s *Service) RebuildSite(ctx context.Context) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + s.publisher.Queue(site) + return nil +} diff --git a/services/posts/list.go b/services/posts/list.go index ae70e1c..dd25bae 100644 --- a/services/posts/list.go +++ b/services/posts/list.go @@ -7,21 +7,41 @@ import ( "lmika.dev/lmika/weiro/providers/db" ) -func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) { +type PostWithCategories struct { + *models.Post + Categories []*models.Category +} + +type ListPostsResult struct { + Posts []*PostWithCategories + TotalCount int64 +} + +func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) { site, ok := models.GetSite(ctx) if !ok { - return nil, models.SiteRequiredError + return ListPostsResult{}, models.SiteRequiredError } - posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{ - Offset: 0, - Limit: 25, - }) + posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging) if err != nil { - return nil, err + return ListPostsResult{}, err } - return posts, nil + count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted) + if err != nil { + return ListPostsResult{}, err + } + + result := make([]*PostWithCategories, len(posts)) + for i, post := range posts { + cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID) + if err != nil { + return ListPostsResult{}, err + } + result[i] = &PostWithCategories{Post: post, Categories: cats} + } + return ListPostsResult{Posts: result, TotalCount: count}, nil } func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) { @@ -32,3 +52,7 @@ func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) return post, nil } + +func (s *Service) GetPostCategories(ctx context.Context, postID int64) ([]*models.Category, error) { + return s.db.SelectCategoriesOfPost(ctx, postID) +} diff --git a/services/publisher/iter.go b/services/publisher/iter.go index a125fb1..d07d4fe 100644 --- a/services/publisher/iter.go +++ b/services/publisher/iter.go @@ -8,11 +8,11 @@ import ( "lmika.dev/lmika/weiro/providers/db" ) -// PostIter returns a post iterator which returns posts in reverse chronological order. -func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { +// postIter returns a post iterator which returns posts in reverse chronological order. +func (s *Publisher) publishedPostIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { return func(yield func(models.Maybe[*models.Post]) bool) { paging := db.PagingParams{Offset: 0, Limit: 50} - page, err := s.db.SelectPostsOfSite(ctx, site, false, paging) + page, err := s.db.SelectPublishedPostsOfSite(ctx, site, paging) if err != nil { yield(models.Maybe[*models.Post]{Err: err}) return @@ -20,6 +20,10 @@ func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Ma for { for _, post := range page { + if post.State != models.StatePublished { + continue + } + if !yield(models.Maybe[*models.Post]{Value: post}) { return } @@ -35,3 +39,26 @@ func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Ma } } } + +// postIterByCategory returns a post iterator for posts in a specific category. +func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] { + return func(yield func(models.Maybe[*models.Post]) bool) { + paging := db.PagingParams{Offset: 0, Limit: 50} + for { + page, err := s.db.SelectPublishedPostsOfCategory(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 + } + } +} diff --git a/services/publisher/service.go b/services/publisher/service.go index ec9834e..a5072a5 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -3,9 +3,11 @@ package publisher import ( "context" "io" + "io/fs" "iter" "log" "os" + "time" "emperror.dev/errors" "github.com/go-openapi/runtime" @@ -45,6 +47,30 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { return err } + // Fetch categories with counts + cats, err := p.db.SelectCategoriesOfSite(ctx, site.ID) + if err != nil { + return err + } + var catsWithCounts []models.CategoryWithCount + for _, cat := range cats { + count, err := p.db.CountPostsOfCategory(ctx, cat.ID) + if err != nil { + return err + } + catsWithCounts = append(catsWithCounts, models.CategoryWithCount{ + Category: *cat, + PostCount: int(count), + DescriptionBrief: models.BriefDescription(cat.Description), + }) + } + + // Fetch pages + sitePages, err := p.db.SelectPagesOfSite(ctx, site.ID) + if err != nil { + return err + } + for _, target := range targets { if !target.Enabled { continue @@ -53,10 +79,18 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { pubSite := pubmodel.Site{ Site: site, PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { - return p.postIter(ctx, site.ID) + return p.publishedPostIter(ctx, site.ID) }, - BaseURL: target.BaseURL, - Uploads: uploads, + 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) + }, + Pages: sitePages, OpenUpload: func(u models.Upload) (io.ReadCloser, error) { return p.up.OpenUpload(site, u) }, @@ -71,10 +105,30 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { } func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, target models.SitePublishTarget) error { + renderTZ, err := time.LoadLocation(pubSite.Timezone) + if err != nil { + renderTZ = time.UTC + } + + templateFS, err := fs.Sub(simplecss.FS, "templates") + if err != nil { + return err + } + + staticFS, err := fs.Sub(simplecss.FS, "static") + if err != nil { + return err + } + sb, err := sitebuilder.New(pubSite, sitebuilder.Options{ - BasePosts: "/posts", - TemplatesFS: simplecss.FS, - FeedItems: 30, + BasePosts: "/posts", + BasePostList: "/pages", + BaseUploads: "/uploads", + BaseStatic: "/static", + TemplatesFS: templateFS, + StaticFS: staticFS, + FeedItems: 30, + RenderTZ: renderTZ, }) if err != nil { return err diff --git a/services/services.go b/services/services.go index 606e932..a79e903 100644 --- a/services/services.go +++ b/services/services.go @@ -7,6 +7,10 @@ import ( "lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/providers/uploadfiles" "lmika.dev/lmika/weiro/services/auth" + "lmika.dev/lmika/weiro/services/categories" + "lmika.dev/lmika/weiro/services/imgedit" + "lmika.dev/lmika/weiro/services/obsimport" + "lmika.dev/lmika/weiro/services/pages" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" "lmika.dev/lmika/weiro/services/sites" @@ -21,6 +25,10 @@ type Services struct { Posts *posts.Service Sites *sites.Service Uploads *uploads.Service + ImageEdit *imgedit.Service + Categories *categories.Service + Pages *pages.Service + ObsImport *obsimport.Service } func New(cfg config.Config) (*Services, error) { @@ -37,6 +45,10 @@ func New(cfg config.Config) (*Services, error) { postService := posts.New(dbp, publisherQueue) siteService := sites.New(dbp) uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending")) + imageEditService := imgedit.New(uploadService, filepath.Join(cfg.ScratchDir, "imageedit")) + categoriesService := categories.New(dbp, publisherQueue) + pagesService := pages.New(dbp, publisherQueue) + obsImportService := obsimport.New(dbp, ufp, publisherQueue, filepath.Join(cfg.ScratchDir, "obsimport")) return &Services{ DB: dbp, @@ -46,6 +58,10 @@ func New(cfg config.Config) (*Services, error) { Posts: postService, Sites: siteService, Uploads: uploadService, + ImageEdit: imageEditService, + Categories: categoriesService, + Pages: pagesService, + ObsImport: obsImportService, }, nil } diff --git a/services/sites/services.go b/services/sites/services.go index 22e3916..4585d03 100644 --- a/services/sites/services.go +++ b/services/sites/services.go @@ -9,6 +9,7 @@ import ( "github.com/gofiber/fiber/v3" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/pkg/modash/moslice" ) type Service struct { @@ -25,6 +26,22 @@ func (s *Service) HasUsersAsSites(ctx context.Context) (bool, error) { return s.db.HasUsersAndSites(ctx) } +func (s *Service) ListSites(ctx context.Context) ([]models.Site, error) { + user, ok := models.GetUser(ctx) + if !ok { + return nil, models.UserRequiredError + } + + sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID) + if err != nil { + return nil, err + } else if len(sites) == 0 { + return nil, errors.New("no sites found") + } + + return sites, nil +} + func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, error) { sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID) if err != nil { @@ -36,16 +53,20 @@ func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, return sites[0], nil } -type FirstRunRequest struct { - Username string `form:"username"` - Password1 string `form:"password1"` - Password2 string `form:"password2"` +type CreateSiteParams struct { SiteName string `form:"siteName"` SiteURL string `form:"siteUrl"` NetlifySiteID string `form:"netlifySiteId"` NetlifyAPIKey string `form:"netlifyAPIToken"` } +type FirstRunRequest struct { + CreateSiteParams + Username string `form:"username"` + Password1 string `form:"password1"` + Password2 string `form:"password2"` +} + func (frr FirstRunRequest) Validate() error { return validation.ValidateStruct(&frr, validation.Field(&frr.Username, validation.Required, validation.Match(models.ValidUserName)), @@ -76,14 +97,31 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo return newUser, newSite, err } + ctx = models.WithUser(ctx, newUser) + newSite, err = s.CreateSite(ctx, req.CreateSiteParams) + if err != nil { + return newUser, newSite, err + } + + return newUser, newSite, nil +} + +func (s *Service) CreateSite(ctx context.Context, req CreateSiteParams) (newSite models.Site, _ error) { + user, ok := models.GetUser(ctx) + if !ok { + return newSite, models.UserRequiredError + } + newSite = models.Site{ - Title: defaultIfEmpty(req.SiteName, "New Site"), - GUID: models.NewNanoID(), - OwnerID: newUser.ID, - Created: time.Now(), + Title: defaultIfEmpty(req.SiteName, "New Site"), + GUID: models.NewNanoID(), + OwnerID: user.ID, + Timezone: "UTC", + PostsPerPage: 10, + Created: time.Now(), } if err := s.db.SaveSite(ctx, &newSite); err != nil { - return newUser, newSite, err + return newSite, err } hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != "" @@ -98,11 +136,11 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo TargetKey: req.NetlifyAPIKey, } if err := s.db.SavePublishTarget(ctx, &target); err != nil { - return newUser, newSite, err + return newSite, err } } - return newUser, newSite, nil + return newSite, nil } func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, error) { @@ -126,3 +164,55 @@ func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, e func (s *Service) ListAllSitesWithOwners(ctx context.Context) ([]db.SiteWithOwner, error) { return s.db.SelectAllSitesWithOwners(ctx) } + +type UpdateSiteSettingsParams struct { + SiteID int64 `form:"siteID"` + Name string `form:"name"` + Tagline string `form:"tagline"` + Timezone string `form:"timezone"` + PostsPerPage int `form:"postsPerPage"` +} + +func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) { + site, err := s.GetSiteByID(ctx, params.SiteID) + if err != nil { + return models.Site{}, err + } + + _, err = time.LoadLocation(params.Timezone) + if err != nil { + return models.Site{}, errors.Wrap(err, "invalid timezone") + } + + postsPerPage := params.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 1 + } else if postsPerPage > 100 { + postsPerPage = 100 + } + + site.Title = params.Name + site.Tagline = params.Tagline + site.Timezone = params.Timezone + site.PostsPerPage = postsPerPage + + if err := s.db.SaveSite(ctx, &site); err != nil { + return models.Site{}, err + } + + return site, nil +} + +func (s *Service) BestPubTarget(ctx context.Context, site models.Site) (models.SitePublishTarget, error) { + pubTargets, err := s.db.SelectPublishTargetsOfSite(ctx, site.ID) + if err != nil { + return models.SitePublishTarget{}, err + } + + enabledPubTargets := moslice.Filter(pubTargets, func(pubTarget models.SitePublishTarget) bool { return pubTarget.Enabled }) + if len(enabledPubTargets) == 0 { + return models.SitePublishTarget{}, errors.New("no publish targets found") + } + + return enabledPubTargets[0], nil +} diff --git a/services/sites/tzones.go b/services/sites/tzones.go new file mode 100644 index 0000000..a61f208 --- /dev/null +++ b/services/sites/tzones.go @@ -0,0 +1,23 @@ +package sites + +import ( + "embed" + "strings" + "sync" +) + +//go:embed tzones.txt +var tzonesFS embed.FS + +var loadZones = sync.OnceValue(func() []string { + zones, err := tzonesFS.ReadFile("tzones.txt") + if err != nil { + return nil + } + + return strings.Split(string(zones), "\n") +}) + +func ListZones() []string { + return loadZones() +} diff --git a/services/sites/tzones.txt b/services/sites/tzones.txt new file mode 100644 index 0000000..1e65bff --- /dev/null +++ b/services/sites/tzones.txt @@ -0,0 +1,606 @@ +Africa/Abidjan +Africa/Accra +Africa/Addis_Ababa +Africa/Algiers +Africa/Asmara +Africa/Asmera +Africa/Bamako +Africa/Bangui +Africa/Banjul +Africa/Bissau +Africa/Blantyre +Africa/Brazzaville +Africa/Bujumbura +Africa/Cairo +Africa/Casablanca +Africa/Ceuta +Africa/Conakry +Africa/Dakar +Africa/Dar_es_Salaam +Africa/Djibouti +Africa/Douala +Africa/El_Aaiun +Africa/Freetown +Africa/Gaborone +Africa/Harare +Africa/Johannesburg +Africa/Juba +Africa/Kampala +Africa/Khartoum +Africa/Kigali +Africa/Kinshasa +Africa/Lagos +Africa/Libreville +Africa/Lome +Africa/Luanda +Africa/Lubumbashi +Africa/Lusaka +Africa/Malabo +Africa/Maputo +Africa/Maseru +Africa/Mbabane +Africa/Mogadishu +Africa/Monrovia +Africa/Nairobi +Africa/Ndjamena +Africa/Niamey +Africa/Nouakchott +Africa/Ouagadougou +Africa/Porto-Novo +Africa/Sao_Tome +Africa/Timbuktu +Africa/Tripoli +Africa/Tunis +Africa/Windhoek +America/Adak +America/Anchorage +America/Anguilla +America/Antigua +America/Araguaina +America/Argentina/Buenos_Aires +America/Argentina/Catamarca +America/Argentina/ComodRivadavia +America/Argentina/Cordoba +America/Argentina/Jujuy +America/Argentina/La_Rioja +America/Argentina/Mendoza +America/Argentina/Rio_Gallegos +America/Argentina/Salta +America/Argentina/San_Juan +America/Argentina/San_Luis +America/Argentina/Tucuman +America/Argentina/Ushuaia +America/Aruba +America/Asuncion +America/Atikokan +America/Atka +America/Bahia +America/Bahia_Banderas +America/Barbados +America/Belem +America/Belize +America/Blanc-Sablon +America/Boa_Vista +America/Bogota +America/Boise +America/Buenos_Aires +America/Cambridge_Bay +America/Campo_Grande +America/Cancun +America/Caracas +America/Catamarca +America/Cayenne +America/Cayman +America/Chicago +America/Chihuahua +America/Coral_Harbour +America/Cordoba +America/Costa_Rica +America/Creston +America/Cuiaba +America/Curacao +America/Danmarkshavn +America/Dawson +America/Dawson_Creek +America/Denver +America/Detroit +America/Dominica +America/Edmonton +America/Eirunepe +America/El_Salvador +America/Ensenada +America/Fort_Nelson +America/Fort_Wayne +America/Fortaleza +America/Glace_Bay +America/Godthab +America/Goose_Bay +America/Grand_Turk +America/Grenada +America/Guadeloupe +America/Guatemala +America/Guayaquil +America/Guyana +America/Halifax +America/Havana +America/Hermosillo +America/Indiana/Indianapolis +America/Indiana/Knox +America/Indiana/Marengo +America/Indiana/Petersburg +America/Indiana/Tell_City +America/Indiana/Vevay +America/Indiana/Vincennes +America/Indiana/Winamac +America/Indianapolis +America/Inuvik +America/Iqaluit +America/Jamaica +America/Jujuy +America/Juneau +America/Kentucky/Louisville +America/Kentucky/Monticello +America/Knox_IN +America/Kralendijk +America/La_Paz +America/Lima +America/Los_Angeles +America/Louisville +America/Lower_Princes +America/Maceio +America/Managua +America/Manaus +America/Marigot +America/Martinique +America/Matamoros +America/Mazatlan +America/Mendoza +America/Menominee +America/Merida +America/Metlakatla +America/Mexico_City +America/Miquelon +America/Moncton +America/Monterrey +America/Montevideo +America/Montreal +America/Montserrat +America/Nassau +America/New_York +America/Nipigon +America/Nome +America/Noronha +America/North_Dakota/Beulah +America/North_Dakota/Center +America/North_Dakota/New_Salem +America/Ojinaga +America/Panama +America/Pangnirtung +America/Paramaribo +America/Phoenix +America/Port-au-Prince +America/Port_of_Spain +America/Porto_Acre +America/Porto_Velho +America/Puerto_Rico +America/Punta_Arenas +America/Rainy_River +America/Rankin_Inlet +America/Recife +America/Regina +America/Resolute +America/Rio_Branco +America/Rosario +America/Santa_Isabel +America/Santarem +America/Santiago +America/Santo_Domingo +America/Sao_Paulo +America/Scoresbysund +America/Shiprock +America/Sitka +America/St_Barthelemy +America/St_Johns +America/St_Kitts +America/St_Lucia +America/St_Thomas +America/St_Vincent +America/Swift_Current +America/Tegucigalpa +America/Thule +America/Thunder_Bay +America/Tijuana +America/Toronto +America/Tortola +America/Vancouver +America/Virgin +America/Whitehorse +America/Winnipeg +America/Yakutat +America/Yellowknife +Antarctica/Casey +Antarctica/Davis +Antarctica/DumontDUrville +Antarctica/Macquarie +Antarctica/Mawson +Antarctica/McMurdo +Antarctica/Palmer +Antarctica/Rothera +Antarctica/South_Pole +Antarctica/Syowa +Antarctica/Troll +Antarctica/Vostok +Arctic/Longyearbyen +Asia/Aden +Asia/Almaty +Asia/Amman +Asia/Anadyr +Asia/Aqtau +Asia/Aqtobe +Asia/Ashgabat +Asia/Ashkhabad +Asia/Atyrau +Asia/Baghdad +Asia/Bahrain +Asia/Baku +Asia/Bangkok +Asia/Barnaul +Asia/Beirut +Asia/Bishkek +Asia/Brunei +Asia/Calcutta +Asia/Chita +Asia/Choibalsan +Asia/Chongqing +Asia/Chungking +Asia/Colombo +Asia/Dacca +Asia/Damascus +Asia/Dhaka +Asia/Dili +Asia/Dubai +Asia/Dushanbe +Asia/Famagusta +Asia/Gaza +Asia/Harbin +Asia/Hebron +Asia/Ho_Chi_Minh +Asia/Hong_Kong +Asia/Hovd +Asia/Irkutsk +Asia/Istanbul +Asia/Jakarta +Asia/Jayapura +Asia/Jerusalem +Asia/Kabul +Asia/Kamchatka +Asia/Karachi +Asia/Kashgar +Asia/Kathmandu +Asia/Katmandu +Asia/Khandyga +Asia/Kolkata +Asia/Krasnoyarsk +Asia/Kuala_Lumpur +Asia/Kuching +Asia/Kuwait +Asia/Macao +Asia/Macau +Asia/Magadan +Asia/Makassar +Asia/Manila +Asia/Muscat +Asia/Nicosia +Asia/Novokuznetsk +Asia/Novosibirsk +Asia/Omsk +Asia/Oral +Asia/Phnom_Penh +Asia/Pontianak +Asia/Pyongyang +Asia/Qatar +Asia/Qyzylorda +Asia/Rangoon +Asia/Riyadh +Asia/Saigon +Asia/Sakhalin +Asia/Samarkand +Asia/Seoul +Asia/Shanghai +Asia/Singapore +Asia/Srednekolymsk +Asia/Taipei +Asia/Tashkent +Asia/Tbilisi +Asia/Tehran +Asia/Tel_Aviv +Asia/Thimbu +Asia/Thimphu +Asia/Tokyo +Asia/Tomsk +Asia/Ujung_Pandang +Asia/Ulaanbaatar +Asia/Ulan_Bator +Asia/Urumqi +Asia/Ust-Nera +Asia/Vientiane +Asia/Vladivostok +Asia/Yakutsk +Asia/Yangon +Asia/Yekaterinburg +Asia/Yerevan +Atlantic/Azores +Atlantic/Bermuda +Atlantic/Canary +Atlantic/Cape_Verde +Atlantic/Faeroe +Atlantic/Faroe +Atlantic/Jan_Mayen +Atlantic/Madeira +Atlantic/Reykjavik +Atlantic/South_Georgia +Atlantic/St_Helena +Atlantic/Stanley +Australia/ACT +Australia/Adelaide +Australia/Brisbane +Australia/Broken_Hill +Australia/Canberra +Australia/Currie +Australia/Darwin +Australia/Eucla +Australia/Hobart +Australia/LHI +Australia/Lindeman +Australia/Lord_Howe +Australia/Melbourne +Australia/NSW +Australia/North +Australia/Perth +Australia/Queensland +Australia/South +Australia/Sydney +Australia/Tasmania +Australia/Victoria +Australia/West +Australia/Yancowinna +Brazil/Acre +Brazil/DeNoronha +Brazil/East +Brazil/West +CET +CST6CDT +Canada/Atlantic +Canada/Central +Canada/Eastern +Canada/Mountain +Canada/Newfoundland +Canada/Pacific +Canada/Saskatchewan +Canada/Yukon +Chile/Continental +Chile/EasterIsland +Cuba +EET +EST +EST5EDT +Egypt +Eire +Etc/GMT +Etc/GMT+0 +Etc/GMT+1 +Etc/GMT+10 +Etc/GMT+11 +Etc/GMT+12 +Etc/GMT+2 +Etc/GMT+3 +Etc/GMT+4 +Etc/GMT+5 +Etc/GMT+6 +Etc/GMT+7 +Etc/GMT+8 +Etc/GMT+9 +Etc/GMT-0 +Etc/GMT-1 +Etc/GMT-10 +Etc/GMT-11 +Etc/GMT-12 +Etc/GMT-13 +Etc/GMT-14 +Etc/GMT-2 +Etc/GMT-3 +Etc/GMT-4 +Etc/GMT-5 +Etc/GMT-6 +Etc/GMT-7 +Etc/GMT-8 +Etc/GMT-9 +Etc/GMT0 +Etc/Greenwich +Etc/UCT +Etc/UTC +Etc/Universal +Etc/Zulu +Europe/Amsterdam +Europe/Andorra +Europe/Astrakhan +Europe/Athens +Europe/Belfast +Europe/Belgrade +Europe/Berlin +Europe/Bratislava +Europe/Brussels +Europe/Bucharest +Europe/Budapest +Europe/Busingen +Europe/Chisinau +Europe/Copenhagen +Europe/Dublin +Europe/Gibraltar +Europe/Guernsey +Europe/Helsinki +Europe/Isle_of_Man +Europe/Istanbul +Europe/Jersey +Europe/Kaliningrad +Europe/Kiev +Europe/Kirov +Europe/Lisbon +Europe/Ljubljana +Europe/London +Europe/Luxembourg +Europe/Madrid +Europe/Malta +Europe/Mariehamn +Europe/Minsk +Europe/Monaco +Europe/Moscow +Europe/Nicosia +Europe/Oslo +Europe/Paris +Europe/Podgorica +Europe/Prague +Europe/Riga +Europe/Rome +Europe/Samara +Europe/San_Marino +Europe/Sarajevo +Europe/Saratov +Europe/Simferopol +Europe/Skopje +Europe/Sofia +Europe/Stockholm +Europe/Tallinn +Europe/Tirane +Europe/Tiraspol +Europe/Ulyanovsk +Europe/Uzhgorod +Europe/Vaduz +Europe/Vatican +Europe/Vienna +Europe/Vilnius +Europe/Volgograd +Europe/Warsaw +Europe/Zagreb +Europe/Zaporozhye +Europe/Zurich +Factory +GB +GB-Eire +GMT +GMT+0 +GMT-0 +GMT0 +Greenwich +HST +Hongkong +Iceland +Indian/Antananarivo +Indian/Chagos +Indian/Christmas +Indian/Cocos +Indian/Comoro +Indian/Kerguelen +Indian/Mahe +Indian/Maldives +Indian/Mauritius +Indian/Mayotte +Indian/Reunion +Iran +Israel +Jamaica +Japan +Kwajalein +Libya +MET +MST +MST7MDT +Mexico/BajaNorte +Mexico/BajaSur +Mexico/General +NZ +NZ-CHAT +Navajo +PRC +PST8PDT +Pacific/Apia +Pacific/Auckland +Pacific/Bougainville +Pacific/Chatham +Pacific/Chuuk +Pacific/Easter +Pacific/Efate +Pacific/Enderbury +Pacific/Fakaofo +Pacific/Fiji +Pacific/Funafuti +Pacific/Galapagos +Pacific/Gambier +Pacific/Guadalcanal +Pacific/Guam +Pacific/Honolulu +Pacific/Johnston +Pacific/Kiritimati +Pacific/Kosrae +Pacific/Kwajalein +Pacific/Majuro +Pacific/Marquesas +Pacific/Midway +Pacific/Nauru +Pacific/Niue +Pacific/Norfolk +Pacific/Noumea +Pacific/Pago_Pago +Pacific/Palau +Pacific/Pitcairn +Pacific/Pohnpei +Pacific/Ponape +Pacific/Port_Moresby +Pacific/Rarotonga +Pacific/Saipan +Pacific/Samoa +Pacific/Tahiti +Pacific/Tarawa +Pacific/Tongatapu +Pacific/Truk +Pacific/Wake +Pacific/Wallis +Pacific/Yap +Poland +Portugal +ROC +ROK +Singapore +SystemV/AST4 +SystemV/AST4ADT +SystemV/CST6 +SystemV/CST6CDT +SystemV/EST5 +SystemV/EST5EDT +SystemV/HST10 +SystemV/MST7 +SystemV/MST7MDT +SystemV/PST8 +SystemV/PST8PDT +SystemV/YST9 +SystemV/YST9YDT +Turkey +UCT +US/Alaska +US/Aleutian +US/Arizona +US/Central +US/East-Indiana +US/Eastern +US/Hawaii +US/Indiana-Starke +US/Michigan +US/Mountain +US/Pacific +US/Pacific-New +US/Samoa +UTC +Universal +W-SU +WET +Zulu diff --git a/services/uploads/manage.go b/services/uploads/manage.go index 32debac..9cb24ea 100644 --- a/services/uploads/manage.go +++ b/services/uploads/manage.go @@ -6,7 +6,10 @@ import ( "html/template" "io" "log" + "os" + "path/filepath" "strings" + "time" "lmika.dev/lmika/weiro/models" ) @@ -67,6 +70,75 @@ func (s *Service) renderCopyTemplate(upload models.Upload) string { return sb.String() } +func (s *Service) ReplaceUploadFile(ctx context.Context, uploadID int64, srcPath string) (models.Upload, error) { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.Upload{}, err + } + + upload, err := s.db.SelectUploadByID(ctx, uploadID) + if err != nil { + return models.Upload{}, err + } else if upload.SiteID != site.ID { + return models.Upload{}, models.NotFoundError + } + + if err := s.up.ReplaceFile(site, upload, srcPath); err != nil { + return models.Upload{}, err + } + + stat, err := os.Stat(srcPath) + if err != nil { + return models.Upload{}, err + } + upload.FileSize = stat.Size() + + if err := s.db.UpdateUploadFileSize(ctx, upload.ID, upload.FileSize); err != nil { + return models.Upload{}, err + } + + return upload, nil +} + +func (s *Service) CreateUploadFromFile(ctx context.Context, srcPath string, filename string, mimeType string) (models.Upload, error) { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.Upload{}, err + } + + stat, err := os.Stat(srcPath) + if err != nil { + return models.Upload{}, err + } + + newUploadGUID := models.NewNanoID() + newTime := time.Now().UTC() + newSlug := filepath.Join( + fmt.Sprintf("%04d", newTime.Year()), + fmt.Sprintf("%02d", newTime.Month()), + newUploadGUID+filepath.Ext(filename), + ) + + newUpload := models.Upload{ + SiteID: site.ID, + GUID: models.NewNanoID(), + FileSize: stat.Size(), + MIMEType: mimeType, + Filename: filename, + CreatedAt: newTime, + Slug: newSlug, + } + if err := s.db.SaveUpload(ctx, &newUpload); err != nil { + return models.Upload{}, err + } + + if err := s.up.AdoptFile(site, newUpload, srcPath); err != nil { + return models.Upload{}, err + } + + return newUpload, nil +} + func (s *Service) ListUploads(ctx context.Context) (res []UploadWithURL, _ error) { site, _, err := s.fetchSiteAndUser(ctx) if err != nil { diff --git a/sql/queries/categories.sql b/sql/queries/categories.sql new file mode 100644 index 0000000..b8e0e64 --- /dev/null +++ b/sql/queries/categories.sql @@ -0,0 +1,53 @@ +-- name: SelectCategoriesOfSite :many +SELECT * FROM categories +WHERE site_id = ? ORDER BY name ASC; + +-- name: SelectCategory :one +SELECT * FROM categories WHERE id = ? LIMIT 1; + +-- name: SelectCategoryByGUID :one +SELECT * FROM categories WHERE guid = ? LIMIT 1; + +-- name: SelectCategoryBySlugAndSite :one +SELECT * FROM categories WHERE site_id = ? AND slug = ? LIMIT 1; + +-- name: SelectCategoriesOfPost :many +SELECT c.* FROM categories c +INNER JOIN post_categories pc ON pc.category_id = c.id +WHERE pc.post_id = ? +ORDER BY c.name ASC; + +-- name: SelectPublishedPostsOfCategory :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 = ?; diff --git a/sql/queries/pages.sql b/sql/queries/pages.sql new file mode 100644 index 0000000..0df22ff --- /dev/null +++ b/sql/queries/pages.sql @@ -0,0 +1,34 @@ +-- name: SelectPagesOfSite :many +SELECT * FROM pages +WHERE site_id = ? ORDER BY sort_order ASC; + +-- name: SelectPage :one +SELECT * FROM pages WHERE id = ? LIMIT 1; + +-- name: SelectPageByGUID :one +SELECT * FROM pages WHERE guid = ? LIMIT 1; + +-- name: SelectPageBySlugAndSite :one +SELECT * FROM pages WHERE site_id = ? AND slug = ? LIMIT 1; + +-- name: InsertPage :one +INSERT INTO pages ( + site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id; + +-- name: UpdatePage :exec +UPDATE pages SET + title = ?, + slug = ?, + body = ?, + page_type = ?, + show_in_nav = ?, + updated_at = ? +WHERE id = ?; + +-- name: UpdatePageSortOrder :exec +UPDATE pages SET sort_order = ? WHERE id = ?; + +-- name: DeletePage :exec +DELETE FROM pages WHERE id = ?; diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index dae1f39..feaae7f 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,3 +1,12 @@ +-- name: CountPostsOfSite :one +SELECT COUNT(*) FROM posts +WHERE site_id = sqlc.arg(site_id) AND ( + CASE CAST (sqlc.arg(post_filter) AS TEXT) + WHEN 'deleted' THEN deleted_at > 0 + ELSE deleted_at = 0 + END +); + -- name: SelectPostsOfSite :many SELECT * FROM posts @@ -8,6 +17,12 @@ WHERE site_id = sqlc.arg(site_id) AND ( END ) ORDER BY created_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset); +-- name: SelectPublishedPostsOfSite :many +SELECT * +FROM posts +WHERE site_id = sqlc.arg(site_id) AND state = 0 AND deleted_at = 0 +ORDER BY published_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset); + -- name: SelectPost :one SELECT * FROM posts WHERE id = ? LIMIT 1; diff --git a/sql/queries/sites.sql b/sql/queries/sites.sql index 92e7ccb..0609b12 100644 --- a/sql/queries/sites.sql +++ b/sql/queries/sites.sql @@ -13,13 +13,18 @@ INSERT INTO sites ( guid, title, tagline, + timezone, + posts_per_page, created_at -) VALUES (?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id; -- name: HasUsersAndSites :one SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites; +-- name: UpdateSite :exec +UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?; + -- name: SelectAllSitesWithOwners :many SELECT s.id, s.guid, s.title, s.owner_id, u.username FROM sites s diff --git a/sql/queries/uploads.sql b/sql/queries/uploads.sql index fc8b82d..f661591 100644 --- a/sql/queries/uploads.sql +++ b/sql/queries/uploads.sql @@ -7,7 +7,7 @@ SELECT * FROM uploads WHERE id = ? LIMIT 1; -- name: SelectUploadBySiteIDAndSlug :one SELECT * FROM uploads WHERE site_id = ? AND slug = ? LIMIT 1; --- name: InsertUpload :exec +-- name: InsertUpload :one INSERT INTO uploads ( site_id, guid, @@ -23,5 +23,8 @@ RETURNING id; -- name: UpdateUpload :exec UPDATE uploads SET alt = ? WHERE id = ?; +-- name: UpdateUploadFileSize :exec +UPDATE uploads SET file_size = ? WHERE id = ?; + -- name: DeleteUpload :exec DELETE FROM uploads WHERE id = ?; \ No newline at end of file diff --git a/sql/schema/03_add_loc_to_site.up.sql b/sql/schema/03_add_loc_to_site.up.sql new file mode 100644 index 0000000..2798610 --- /dev/null +++ b/sql/schema/03_add_loc_to_site.up.sql @@ -0,0 +1 @@ +ALTER TABLE sites ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'; \ No newline at end of file diff --git a/sql/schema/04_categories.up.sql b/sql/schema/04_categories.up.sql new file mode 100644 index 0000000..260d06b --- /dev/null +++ b/sql/schema/04_categories.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL, + guid TEXT NOT NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); +CREATE INDEX idx_categories_site ON categories (site_id); +CREATE UNIQUE INDEX idx_categories_guid ON categories (guid); +CREATE UNIQUE INDEX idx_categories_site_slug ON categories (site_id, slug); + +CREATE TABLE post_categories ( + post_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + PRIMARY KEY (post_id, category_id), + FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE +); +CREATE INDEX idx_post_categories_category ON post_categories (category_id); diff --git a/sql/schema/05_posts_per_page.up.sql b/sql/schema/05_posts_per_page.up.sql new file mode 100644 index 0000000..1bea8f9 --- /dev/null +++ b/sql/schema/05_posts_per_page.up.sql @@ -0,0 +1 @@ +ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10; diff --git a/sql/schema/06_pages.up.sql b/sql/schema/06_pages.up.sql new file mode 100644 index 0000000..5090456 --- /dev/null +++ b/sql/schema/06_pages.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL, + guid TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + body TEXT NOT NULL, + page_type INTEGER NOT NULL DEFAULT 0, + show_in_nav INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); +CREATE INDEX idx_pages_site ON pages (site_id); +CREATE UNIQUE INDEX idx_pages_guid ON pages (guid); +CREATE UNIQUE INDEX idx_pages_site_slug ON pages (site_id, slug); diff --git a/views/_common/nav.html b/views/_common/nav.html index 7b8bd16..5005326 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -10,9 +10,18 @@