| - | Title | -Slug | -Nav | -
|---|---|---|---|
| ☰ | -{{ .Title }} | -{{ .Slug }} |
- {{ if .ShowInNav }}Yes{{ end }} | -
diff --git a/assets/css/main.scss b/assets/css/main.scss index addf5ce..dc6ad7d 100644 --- a/assets/css/main.scss +++ b/assets/css/main.scss @@ -10,15 +10,6 @@ $container-max-widths: ( @import "bootstrap/scss/bootstrap.scss"; -// Navbar - -.navbar-site-visit { - display: inline-block; - line-height: 2em; - margin-bottom: 4px; - margin-right: 10px; -} - // Post list .postlist .post img { @@ -31,24 +22,19 @@ $container-max-widths: ( font-size: 0.9rem; } -// Large editor -// -// Used for edit canvases which take up the entire window +// Post form -.large-editor { +// Post edit page styling +.post-edit-page { height: 100vh; } -.large-editor main { +.post-edit-page main { display: flex; flex-direction: column; overflow: hidden; } -// Post form - -// Post edit page styling - .post-edit-page .post-form { flex: 1; display: flex; diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js deleted file mode 100644 index 95cbb1e..0000000 --- a/assets/js/controllers/edit_upload.js +++ /dev/null @@ -1,233 +0,0 @@ -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(` -| - | 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 deleted file mode 100644 index 9b44775..0000000 --- a/docs/superpowers/plans/2026-03-22-paging.md +++ /dev/null @@ -1,888 +0,0 @@ -# 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() diff --git a/providers/sitebuilder/processors.go b/providers/sitebuilder/processors.go index 605d077..c699160 100644 --- a/providers/sitebuilder/processors.go +++ b/providers/sitebuilder/processors.go @@ -35,8 +35,3 @@ func uploadAbsoluteURL(site pubmodel.Site, dom *goquery.Document) error { }) 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 deleted file mode 100644 index 6183088..0000000 --- a/providers/sitebuilder/render_pages.go +++ /dev/null @@ -1,31 +0,0 @@ -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/tmpls.go b/providers/sitebuilder/tmpls.go index 029cab0..cea02f5 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -26,16 +26,12 @@ const ( // 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 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. + BasePosts string // BasePosts is the base path for posts. + 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 @@ -65,10 +61,7 @@ type postSingleData struct { type postListData struct { commonData - Posts []postSingleData - PageInfo models.PageInfo - PrevURL string - NextURL string + Posts []postSingleData } type layoutData struct { @@ -92,13 +85,4 @@ type categorySingleData struct { 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/uploadfiles/provider.go b/providers/uploadfiles/provider.go index 610a6f9..2eb84e4 100644 --- a/providers/uploadfiles/provider.go +++ b/providers/uploadfiles/provider.go @@ -66,11 +66,6 @@ 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/imgedit/processing.go b/services/imgedit/processing.go deleted file mode 100644 index ec84199..0000000 --- a/services/imgedit/processing.go +++ /dev/null @@ -1,171 +0,0 @@ -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 deleted file mode 100644 index 926633c..0000000 --- a/services/imgedit/service.go +++ /dev/null @@ -1,266 +0,0 @@ -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 deleted file mode 100644 index 4a308d0..0000000 --- a/services/imgedit/shadow.go +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index df3403a..0000000 --- a/services/imgedit/store.go +++ /dev/null @@ -1,70 +0,0 @@ -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/obsimport/service.go b/services/obsimport/service.go deleted file mode 100644 index 0852031..0000000 --- a/services/obsimport/service.go +++ /dev/null @@ -1,229 +0,0 @@ -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 deleted file mode 100644 index 51123de..0000000 --- a/services/obsimport/service_test.go +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 8a82bc0..0000000 --- a/services/pages/service.go +++ /dev/null @@ -1,198 +0,0 @@ -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/list.go b/services/posts/list.go index dd25bae..15e14d3 100644 --- a/services/posts/list.go +++ b/services/posts/list.go @@ -12,36 +12,29 @@ type PostWithCategories struct { Categories []*models.Category } -type ListPostsResult struct { - Posts []*PostWithCategories - TotalCount int64 -} - -func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) { +func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) { site, ok := models.GetSite(ctx) if !ok { - return ListPostsResult{}, models.SiteRequiredError + return nil, models.SiteRequiredError } - posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging) + posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{ + Offset: 0, + Limit: 25, + }) if err != nil { - return ListPostsResult{}, err - } - - count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted) - if err != nil { - return ListPostsResult{}, err + return nil, 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 + return nil, err } result[i] = &PostWithCategories{Post: post, Categories: cats} } - return ListPostsResult{Posts: result, TotalCount: count}, nil + return result, nil } func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) { diff --git a/services/publisher/iter.go b/services/publisher/iter.go index d07d4fe..ea70616 100644 --- a/services/publisher/iter.go +++ b/services/publisher/iter.go @@ -9,10 +9,10 @@ import ( ) // 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]] { +func (s *Publisher) postIter(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.SelectPublishedPostsOfSite(ctx, site, paging) + page, err := s.db.SelectPostsOfSite(ctx, site, false, paging) if err != nil { yield(models.Maybe[*models.Post]{Err: err}) return @@ -45,7 +45,7 @@ func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) it 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) + page, err := s.db.SelectPostsOfCategory(ctx, categoryID, paging) if err != nil { yield(models.Maybe[*models.Post]{Err: err}) return diff --git a/services/publisher/service.go b/services/publisher/service.go index a5072a5..939817a 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -65,12 +65,6 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { }) } - // Fetch pages - sitePages, err := p.db.SelectPagesOfSite(ctx, site.ID) - if err != nil { - return err - } - for _, target := range targets { if !target.Enabled { continue @@ -79,7 +73,7 @@ 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.publishedPostIter(ctx, site.ID) + return p.postIter(ctx, site.ID) }, BaseURL: target.BaseURL, Uploads: uploads, @@ -90,7 +84,6 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { 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) }, @@ -121,14 +114,13 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ } sb, err := sitebuilder.New(pubSite, sitebuilder.Options{ - BasePosts: "/posts", - BasePostList: "/pages", - BaseUploads: "/uploads", - BaseStatic: "/static", - TemplatesFS: templateFS, - StaticFS: staticFS, - FeedItems: 30, - RenderTZ: renderTZ, + BasePosts: "/posts", + 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 a79e903..beb6727 100644 --- a/services/services.go +++ b/services/services.go @@ -8,9 +8,6 @@ import ( "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" @@ -25,10 +22,7 @@ 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) { @@ -45,10 +39,7 @@ 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, @@ -58,10 +49,7 @@ 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 4585d03..06afe15 100644 --- a/services/sites/services.go +++ b/services/sites/services.go @@ -9,7 +9,6 @@ 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 { @@ -26,22 +25,6 @@ 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 { @@ -53,20 +36,16 @@ func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, return sites[0], nil } -type CreateSiteParams struct { +type FirstRunRequest struct { + Username string `form:"username"` + Password1 string `form:"password1"` + Password2 string `form:"password2"` 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)), @@ -97,31 +76,15 @@ 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: user.ID, - Timezone: "UTC", - PostsPerPage: 10, - Created: time.Now(), + Title: defaultIfEmpty(req.SiteName, "New Site"), + GUID: models.NewNanoID(), + OwnerID: newUser.ID, + Timezone: "UTC", + Created: time.Now(), } if err := s.db.SaveSite(ctx, &newSite); err != nil { - return newSite, err + return newUser, newSite, err } hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != "" @@ -136,11 +99,11 @@ func (s *Service) CreateSite(ctx context.Context, req CreateSiteParams) (newSite TargetKey: req.NetlifyAPIKey, } if err := s.db.SavePublishTarget(ctx, &target); err != nil { - return newSite, err + return newUser, newSite, err } } - return newSite, nil + return newUser, newSite, nil } func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, error) { @@ -166,11 +129,10 @@ func (s *Service) ListAllSitesWithOwners(ctx context.Context) ([]db.SiteWithOwne } type UpdateSiteSettingsParams struct { - SiteID int64 `form:"siteID"` - Name string `form:"name"` - Tagline string `form:"tagline"` - Timezone string `form:"timezone"` - PostsPerPage int `form:"postsPerPage"` + SiteID int64 `form:"siteID"` + Name string `form:"name"` + Tagline string `form:"tagline"` + Timezone string `form:"timezone"` } func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) { @@ -184,17 +146,9 @@ func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSetti 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 @@ -202,17 +156,3 @@ func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSetti 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/uploads/manage.go b/services/uploads/manage.go index 9cb24ea..32debac 100644 --- a/services/uploads/manage.go +++ b/services/uploads/manage.go @@ -6,10 +6,7 @@ import ( "html/template" "io" "log" - "os" - "path/filepath" "strings" - "time" "lmika.dev/lmika/weiro/models" ) @@ -70,75 +67,6 @@ 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 index b8e0e64..4b48506 100644 --- a/sql/queries/categories.sql +++ b/sql/queries/categories.sql @@ -17,7 +17,7 @@ INNER JOIN post_categories pc ON pc.category_id = c.id WHERE pc.post_id = ? ORDER BY c.name ASC; --- name: SelectPublishedPostsOfCategory :many +-- name: SelectPostsOfCategory :many SELECT p.* FROM posts p INNER JOIN post_categories pc ON pc.post_id = p.id WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0 diff --git a/sql/queries/pages.sql b/sql/queries/pages.sql deleted file mode 100644 index 0df22ff..0000000 --- a/sql/queries/pages.sql +++ /dev/null @@ -1,34 +0,0 @@ --- 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 feaae7f..dae1f39 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,12 +1,3 @@ --- 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 @@ -17,12 +8,6 @@ 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 0609b12..8fe2469 100644 --- a/sql/queries/sites.sql +++ b/sql/queries/sites.sql @@ -14,16 +14,15 @@ INSERT INTO sites ( 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 = ?; +UPDATE sites SET title = ?, tagline = ?, timezone = ? WHERE id = ?; -- name: SelectAllSitesWithOwners :many SELECT s.id, s.guid, s.title, s.owner_id, u.username diff --git a/sql/queries/uploads.sql b/sql/queries/uploads.sql index f661591..fc8b82d 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 :one +-- name: InsertUpload :exec INSERT INTO uploads ( site_id, guid, @@ -23,8 +23,5 @@ 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/05_posts_per_page.up.sql b/sql/schema/05_posts_per_page.up.sql deleted file mode 100644 index 1bea8f9..0000000 --- a/sql/schema/05_posts_per_page.up.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 5090456..0000000 --- a/sql/schema/06_pages.up.sql +++ /dev/null @@ -1,17 +0,0 @@ -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 5005326..e8bce30 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -11,14 +11,11 @@ PostsEnter the details of your blog if you know them.
All fields are optional and can be changed later.
Enter the details of your blog, if you know them.
All fields are optional, and can be changed later.
Select an Obsidian vault exported as a Zip file. All Markdown notes will be imported as posts, and any images or attachments will be imported as uploads.
- -Successfully imported {{ .result.PostsImported }} post(s) and {{ .result.UploadsImported }} upload(s).
-| - | Title | -Slug | -Nav | -
|---|---|---|---|
| ☰ | -{{ .Title }} | -{{ .Slug }} |
- {{ if .ShowInNav }}Yes{{ end }} | -