| - | 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/specs/2026-03-22-pages-design.md b/docs/superpowers/specs/2026-03-22-pages-design.md deleted file mode 100644 index cc17417..0000000 --- a/docs/superpowers/specs/2026-03-22-pages-design.md +++ /dev/null @@ -1,148 +0,0 @@ -# Arbitrary Pages Feature Design - -## Overview - -Allow users to create arbitrary pages for their site. Each page has a title, user-editable slug, markdown body, page type, nav visibility flag, and sort order. Pages are a separate entity from posts with their own admin section and generated site template. Pages rendered at conflicting slugs silently override auto-generated content. - -## Data Layer - -### New `pages` table - -```sql -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); -``` - -### Model - -```go -type Page struct { - ID int64 - SiteID int64 - GUID string - Title string - Slug string - Body string - PageType int - ShowInNav bool - SortOrder int - CreatedAt time.Time - UpdatedAt time.Time -} -``` - -Page type constants: `PageTypeNormal = 0` (extensible later for archive, search, etc.). - -### SQL queries - -- `SelectPagesOfSite(siteID)` — all pages for a site, ordered by `sort_order ASC` -- `SelectPage(id)` — single page by ID -- `SelectPageByGUID(guid)` — single page by GUID -- `InsertPage` — create new page, returns ID -- `UpdatePage` — update page fields -- `DeletePage(id)` — delete page -- `UpdatePageSortOrder(id, sortOrder)` — update sort order for a single page - -## Admin Section - -### Navigation - -Add "Pages" item to the admin nav bar (`views/_common/nav.html`), linking to `/sites/:siteID/pages`. - -### Routes - -``` -GET /sites/:siteID/pages - List pages -GET /sites/:siteID/pages/new - New page form -GET /sites/:siteID/pages/:pageID - Edit page form -POST /sites/:siteID/pages - Create/update page -DELETE /sites/:siteID/pages/:pageID - Delete page -POST /sites/:siteID/pages/reorder - Update sort order (AJAX) -``` - -### Page list view (`views/pages/index.html`) - -- Lists pages ordered by `sort_order` -- Each row shows title, slug, and nav visibility indicator -- Drag-and-drop reordering via Stimulus + HTML drag API -- On drop, sends new order to `POST /pages/reorder` via AJAX -- "New Page" button - -### Page edit form (`views/pages/edit.html`) - -Two-column layout mirroring the post edit form: - -**Main area (left):** -- Title input -- Body textarea (markdown) - -**Sidebar (right):** -- Slug (editable text input, auto-derived from title via client-side JS, user can override) -- Page Type (select dropdown, just "Normal" for now) -- Show in Nav (checkbox) - -Save button below. - -### Service layer (`services/pages/`) - -- `Service` struct with DB provider dependency -- `CreatePage(ctx, params)` — generates GUID, derives slug from title if not provided, sets timestamps -- `UpdatePage(ctx, params)` — updates fields, sets `updated_at` -- `DeletePage(ctx, pageID)` — deletes page -- `ListPages(ctx)` — returns all pages for the site from context, ordered by `sort_order` -- `GetPage(ctx, pageID)` — returns single page -- `ReorderPages(ctx, pageIDs []int64)` — accepts ordered list of page IDs, updates `sort_order` for each (sort_order = index in list) - -### Handler (`handlers/pages.go`) - -- `PagesHandler` struct with `PageService` -- Standard CRUD handlers following the existing posts handler pattern -- `Reorder` handler accepts JSON array of page IDs, calls `ReorderPages` - -## Generated Site - -### Template - -New template `pages_single.html` — receives rendered page HTML, rendered inside `layout_main.html` (same wrapping as posts). - -Template data: -```go -type pageSingleData struct { - commonData - Page *models.Page - HTML template.HTML -} -``` - -### Builder changes - -New method `renderPages` on the builder: -- Iterates all pages from `pubmodel.Site.Pages` -- For each page, renders markdown body and writes to the page's slug path using `createAtPath` -- Pages are rendered **after** all other content (posts, post lists, categories, feeds, uploads, static assets) -- This ensures pages at conflicting slugs silently overwrite auto-generated content -- Implementation: `renderPages` runs as a sequential step after `eg.Wait()` returns in `BuildSite` - -### Publisher changes - -- `pubmodel.Site` gets a new `Pages []models.Page` field -- The publisher fetches all pages for the site via `SelectPagesOfSite` and populates this field - -## Approach - -Pages are a separate entity from posts with their own table, service, handler, and templates. The override mechanism is file-system-based: the site builder renders pages last, so any page slug that conflicts with an auto-generated path wins by overwriting the file. The `show_in_nav` field is stored and editable in admin but not yet consumed by the generated site layout — that integration is deferred for a future change. diff --git a/handlers/imageedit.go b/handlers/imageedit.go deleted file mode 100644 index 27a01b0..0000000 --- a/handlers/imageedit.go +++ /dev/null @@ -1,165 +0,0 @@ -package handlers - -import ( - "bufio" - "io" - "log" - "net/http" - - "github.com/gofiber/fiber/v3" - "lmika.dev/lmika/weiro/models" - "lmika.dev/lmika/weiro/services/imgedit" -) - -type ImageEditHandlers struct { - ImageEditService *imgedit.Service -} - -type sessionResponse struct { - Session *models.ImageEditSession `json:"session"` - PreviewURL string `json:"preview_url"` -} - -func (ieh ImageEditHandlers) Create(c fiber.Ctx) error { - var req struct { - BaseUploadID int64 `json:"base_upload"` - } - - if err := c.Bind().JSON(&req); err != nil { - return err - } - - res, err := ieh.ImageEditService.NewSession(c.Context(), req.BaseUploadID) - if err != nil { - return err - } - - var resp = sessionResponse{ - Session: res, - PreviewURL: res.PreviewURL(), - } - - return c.Status(http.StatusCreated).JSON(resp) -} - -func (ieh ImageEditHandlers) Preview(c fiber.Ctx) error { - log.Printf("Previewing image edit session %v/%v", c.Params("sessionID"), c.Params("versionID")) - sessionID := c.Params("sessionID") - versionID := c.Params("versionID") - - mimeTime, rw, err := ieh.ImageEditService.LoadImageVersion(c.Context(), sessionID, versionID) - if err != nil { - return err - } - - c.Set("Content-Type", mimeTime) - c.Status(http.StatusOK) - return c.SendStreamWriter(func(w *bufio.Writer) { - rw, err := rw() - if err != nil { - return - } - defer rw.Close() - - _, err = io.Copy(w, rw) - if err != nil { - return - } - }) -} - -func (ieh ImageEditHandlers) AddProcessor(c fiber.Ctx) error { - sessionID := c.Params("sessionID") - if sessionID == "" { - log.Println("No session ID") - return fiber.ErrBadRequest - } - - var req imgedit.AddProcessorReq - if err := c.Bind().Body(&req); err != nil { - log.Printf("Failed to parse request body: %v", err) - return fiber.ErrBadRequest - } - - res, err := ieh.ImageEditService.AddProcessor(c.Context(), sessionID, req) - if err != nil { - return err - } - - return c.Status(http.StatusOK).JSON(sessionResponse{ - Session: res, - PreviewURL: res.PreviewURL(), - }) -} - -func (ieh ImageEditHandlers) DeleteProcessor(c fiber.Ctx) error { - sessionID := c.Params("sessionID") - if sessionID == "" { - return fiber.ErrBadRequest - } - - processorID := c.Params("processorID") - if processorID == "" { - return fiber.ErrBadRequest - } - - res, err := ieh.ImageEditService.DeleteProcessor(c.Context(), sessionID, processorID) - if err != nil { - return err - } - - return c.Status(http.StatusOK).JSON(sessionResponse{ - Session: res, - PreviewURL: res.PreviewURL(), - }) -} - -func (ieh ImageEditHandlers) Save(c fiber.Ctx) error { - sessionID := c.Params("sessionID") - if sessionID == "" { - return fiber.ErrBadRequest - } - - var req struct { - Mode string `json:"mode"` - } - if err := c.Bind().JSON(&req); err != nil { - return fiber.ErrBadRequest - } - - result, err := ieh.ImageEditService.Save(c.Context(), sessionID, req.Mode) - if err != nil { - return err - } - - return c.Status(http.StatusOK).JSON(result) -} - -func (ieh ImageEditHandlers) PatchSession(c fiber.Ctx) error { - var req struct { - UpdateProc *imgedit.UpdateProcessorReq `json:"processor"` - } - - sessionID := c.Params("sessionID") - if sessionID == "" { - return fiber.ErrBadRequest - } - - if err := c.Bind().Body(&req); err != nil { - return err - } - log.Printf("Got request: %v", *req.UpdateProc) - - if req.UpdateProc != nil { - res, err := ieh.ImageEditService.UpdateProcessor(c.Context(), sessionID, *req.UpdateProc) - if err != nil { - return err - } - return c.Status(http.StatusOK).JSON(sessionResponse{ - Session: res, - PreviewURL: res.PreviewURL(), - }) - } - - return fiber.ErrBadRequest -} diff --git a/handlers/index.go b/handlers/index.go index 410c347..6062237 100644 --- a/handlers/index.go +++ b/handlers/index.go @@ -2,7 +2,6 @@ package handlers import ( "fmt" - "log" "net/url" "regexp" @@ -38,13 +37,6 @@ func (h IndexHandler) Index(c fiber.Ctx) error { } } - sess := session.FromContext(c) - lastSiteID, ok := sess.Get("last_site_id").(int64) - log.Printf("last site id: %v", lastSiteID) - if ok { - return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", lastSiteID)) - } - site, err := h.SiteService.BestSite(c.Context(), user) if err != nil { return err diff --git a/handlers/login.go b/handlers/login.go index 34c1e96..30ed0b4 100644 --- a/handlers/login.go +++ b/handlers/login.go @@ -37,8 +37,9 @@ func (lh *LoginHandler) Logout(c fiber.Ctx) error { func (lh *LoginHandler) DoLogin(c fiber.Ctx) error { var req struct { - Username string `form:"username"` - Password string `form:"password"` + Username string `form:"username"` + Password string `form:"password"` + LoginChallenge string `form:"_login_challenge"` } if err := c.Bind().Body(&req); err != nil { return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body") @@ -50,6 +51,11 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error { sess := session.FromContext(c) + challenge, _ := sess.Get("_login_challenge").(string) + if challenge != req.LoginChallenge { + return c.Redirect().To("/login") + } + user, err := lh.AuthService.Login(c.Context(), req.Username, req.Password) if err != nil { return c.Status(fiber.StatusInternalServerError).SendString("Failed to login") diff --git a/handlers/middleware/errlog.go b/handlers/middleware/errlog.go index 2acac04..5b6dfa6 100644 --- a/handlers/middleware/errlog.go +++ b/handlers/middleware/errlog.go @@ -9,7 +9,7 @@ import ( func LogErrors() func(c fiber.Ctx) error { return func(c fiber.Ctx) error { if err := c.Next(); err != nil { - log.Printf("%v: error: %v\n", c.Path(), err) + log.Printf("error: %v\n", err) return err } return nil diff --git a/handlers/middleware/site.go b/handlers/middleware/site.go index 1d3ddf2..54211bc 100644 --- a/handlers/middleware/site.go +++ b/handlers/middleware/site.go @@ -5,7 +5,6 @@ import ( "emperror.dev/errors" "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/middleware/session" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/services/sites" @@ -33,22 +32,9 @@ func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error { return err } } + c.Locals("site", site) c.SetContext(models.WithSite(c.Context(), site)) - - sitesOwnedByUser, err := sites.ListSites(c.Context()) - if err != nil { - return err - } - c.Locals("allSites", sitesOwnedByUser) - - sess := session.FromContext(c) - sess.Set("last_site_id", siteID) - - if pubTargets, err := sites.BestPubTarget(c.Context(), site); err == nil { - c.Locals("pubTarget", pubTargets) - } - return c.Next() } } diff --git a/handlers/obsimport.go b/handlers/obsimport.go deleted file mode 100644 index e20be77..0000000 --- a/handlers/obsimport.go +++ /dev/null @@ -1,50 +0,0 @@ -package handlers - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/gofiber/fiber/v3" - "lmika.dev/lmika/weiro/models" - "lmika.dev/lmika/weiro/services/obsimport" -) - -type ObsImportHandler struct { - ObsImportService *obsimport.Service - ScratchDir string -} - -func (h ObsImportHandler) Form(c fiber.Ctx) error { - return c.Render("obsimport/form", fiber.Map{}) -} - -func (h ObsImportHandler) Upload(c fiber.Ctx) error { - site := c.Locals("site").(models.Site) - - fileHeader, err := c.FormFile("zipfile") - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "no file provided") - } - - // Save uploaded file to scratch dir - if err := os.MkdirAll(h.ScratchDir, 0755); err != nil { - return err - } - - dstPath := filepath.Join(h.ScratchDir, models.NewNanoID()+".zip") - if err := c.SaveFile(fileHeader, dstPath); err != nil { - return err - } - defer os.Remove(dstPath) - - result, err := h.ObsImportService.ImportZip(c.Context(), dstPath) - if err != nil { - return err - } - - return c.Render("obsimport/result", fiber.Map{ - "result": result, - "siteURL": fmt.Sprintf("/sites/%v/posts", site.ID), - }) -} diff --git a/handlers/pages.go b/handlers/pages.go deleted file mode 100644 index abefb41..0000000 --- a/handlers/pages.go +++ /dev/null @@ -1,118 +0,0 @@ -package handlers - -import ( - "fmt" - "strconv" - - "github.com/gofiber/fiber/v3" - "lmika.dev/lmika/weiro/models" - "lmika.dev/lmika/weiro/services/pages" -) - -type PagesHandler struct { - PageService *pages.Service -} - -func (ph PagesHandler) Index(c fiber.Ctx) error { - pagesList, err := ph.PageService.ListPages(c.Context()) - if err != nil { - return err - } - - return c.Render("pages/index", fiber.Map{ - "pages": pagesList, - }) -} - -func (ph PagesHandler) New(c fiber.Ctx) error { - page := models.Page{ - GUID: models.NewNanoID(), - } - return c.Render("pages/edit", fiber.Map{ - "page": page, - "isNew": true, - "bodyClass": "post-edit-page", - }) -} - -func (ph PagesHandler) Edit(c fiber.Ctx) error { - pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) - if err != nil { - return fiber.ErrBadRequest - } - - page, err := ph.PageService.GetPage(c.Context(), pageID) - if err != nil { - return err - } - - return c.Render("pages/edit", fiber.Map{ - "page": page, - "isNew": false, - "bodyClass": "post-edit-page", - }) -} - -func (ph PagesHandler) Create(c fiber.Ctx) error { - var req pages.CreatePageParams - if err := c.Bind().Body(&req); err != nil { - return err - } - - _, err := ph.PageService.CreatePage(c.Context(), req) - if err != nil { - return err - } - - site := models.MustGetSite(c.Context()) - return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) -} - -func (ph PagesHandler) Update(c fiber.Ctx) error { - pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) - if err != nil { - return fiber.ErrBadRequest - } - - var req pages.CreatePageParams - if err := c.Bind().Body(&req); err != nil { - return err - } - - _, err = ph.PageService.UpdatePage(c.Context(), pageID, req) - if err != nil { - return err - } - - site := models.MustGetSite(c.Context()) - return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) -} - -func (ph PagesHandler) Delete(c fiber.Ctx) error { - pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) - if err != nil { - return fiber.ErrBadRequest - } - - if err := ph.PageService.DeletePage(c.Context(), pageID); err != nil { - return err - } - - site := models.MustGetSite(c.Context()) - return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) -} - -func (ph PagesHandler) Reorder(c fiber.Ctx) error { - var req struct { - PageIDs []int64 `json:"page_ids"` - } - if err := c.Bind().Body(&req); err != nil { - return err - } - - if err := ph.PageService.ReorderPages(c.Context(), req.PageIDs); err != nil { - return err - } - - return c.JSON(fiber.Map{"ok": true}) -} diff --git a/handlers/posts.go b/handlers/posts.go index 0e491aa..3326533 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -75,7 +75,7 @@ func (ph PostsHandler) New(c fiber.Ctx) error { "post": p, "categories": cats, "selectedCategories": map[int64]bool{}, - "bodyClass": "large-editor", + "bodyClass": "post-edit-page", }) } @@ -116,7 +116,7 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error { "post": post, "categories": cats, "selectedCategories": selectedCategories, - "bodyClass": "large-editor", + "bodyClass": "post-edit-page", }) })) } diff --git a/handlers/sitesettings.go b/handlers/sitesettings.go index e61ced4..0fe2100 100644 --- a/handlers/sitesettings.go +++ b/handlers/sitesettings.go @@ -12,28 +12,10 @@ type SiteSettingsHandler struct { SiteService *sites.Service } -func (s *SiteSettingsHandler) New(c fiber.Ctx) error { - return c.Render("sitesettings/new", fiber.Map{}, "layouts/bare_with_scripts") -} +func (s *SiteSettingsHandler) General(ctx fiber.Ctx) error { + site := ctx.Locals("site").(models.Site) -func (s *SiteSettingsHandler) Create(c fiber.Ctx) error { - var params sites.CreateSiteParams - if err := c.Bind().Body(¶ms); err != nil { - return err - } - - newSite, err := s.SiteService.CreateSite(c.Context(), params) - if err != nil { - return err - } - - return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", newSite.ID)) -} - -func (s *SiteSettingsHandler) General(c fiber.Ctx) error { - site := c.Locals("site").(models.Site) - - return c.Render("sitesettings/general", fiber.Map{ + return ctx.Render("sitesettings/general", fiber.Map{ "site": site, "tzones": sites.ListZones(), }) diff --git a/handlers/uploads.go b/handlers/uploads.go index 3553b09..fa2cb98 100644 --- a/handlers/uploads.go +++ b/handlers/uploads.go @@ -162,24 +162,3 @@ func (uh UploadsHandler) UploadComplete(c fiber.Ctx) error { return c.Status(fiber.StatusAccepted).JSON(fiber.Map{}) } - -func (uh UploadsHandler) Edit(c fiber.Ctx) error { - uploadIDStr := c.Params("uploadID") - if uploadIDStr == "" { - return fiber.ErrBadRequest - } - uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64) - if err != nil { - return fiber.ErrBadRequest - } - - upload, err := uh.UploadsService.FetchUpload(c.Context(), uploadID) - if err != nil { - return err - } - - return c.Render("uploads/edit", fiber.Map{ - "upload": upload, - "bodyClass": "large-editor", - }) -} diff --git a/layouts/simplecss/templates/categories_single.html b/layouts/simplecss/templates/categories_single.html index 133ad8d..e9e7116 100644 --- a/layouts/simplecss/templates/categories_single.html +++ b/layouts/simplecss/templates/categories_single.html @@ -11,7 +11,7 @@ {{ end }} {{ if or .PrevURL .NextURL }} {{ end }} diff --git a/layouts/simplecss/templates/layout_main.html b/layouts/simplecss/templates/layout_main.html index d3d27bd..4aa5199 100644 --- a/layouts/simplecss/templates/layout_main.html +++ b/layouts/simplecss/templates/layout_main.html @@ -13,13 +13,6 @@{{ .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..e0ece37 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 @@ -96,9 +92,3 @@ type categorySingleData struct { 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/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..86e34b2 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,16 @@ 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, + OwnerID: newUser.ID, Timezone: "UTC", PostsPerPage: 10, 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 +100,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) { @@ -202,17 +166,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..5a4c18e 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -17,12 +17,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/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/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 }} | -