From d80aacc180a225da45cd54f02ecdaf4b7d85d128 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 24 Mar 2026 11:08:51 +1100 Subject: [PATCH 01/15] Added a site picker plus options to create new sites --- assets/css/main.scss | 9 +++++ assets/js/main.js | 5 ++- cmds/server.go | 18 ++++++---- handlers/middleware/errlog.go | 2 +- handlers/middleware/site.go | 12 ++++++- handlers/sitesettings.go | 24 +++++++++++-- package-lock.json | 36 ++++++++++++++++--- package.json | 3 +- services/sites/services.go | 66 ++++++++++++++++++++++++++++++----- views/_common/nav.html | 20 ++++++++++- views/index/first-run.html | 2 +- views/pages/index.html | 2 +- views/sitesettings/new.html | 29 +++++++++++++++ views/uploads/index.html | 4 +++ 14 files changed, 203 insertions(+), 29 deletions(-) create mode 100644 views/sitesettings/new.html diff --git a/assets/css/main.scss b/assets/css/main.scss index dc6ad7d..2e0883a 100644 --- a/assets/css/main.scss +++ b/assets/css/main.scss @@ -10,6 +10,15 @@ $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 { diff --git a/assets/js/main.js b/assets/js/main.js index 28451fb..fcbe286 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,3 +1,4 @@ +import feather from "feather-icons/dist/feather.js"; import { Application } from "@hotwired/stimulus"; import ToastController from "./controllers/toast"; @@ -17,4 +18,6 @@ Stimulus.register("logout", LogoutController); Stimulus.register("first-run", FirstRunController); Stimulus.register("upload", UploadController); Stimulus.register("show-upload", ShowUploadController); -Stimulus.register("pagelist", PagelistController); \ No newline at end of file +Stimulus.register("pagelist", PagelistController); + +feather.replace(); \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index 89310bd..36e5923 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -119,7 +119,17 @@ Starting weiro without any arguments will start the server. app.Post("/login", lh.DoLogin) app.Post("/logout", lh.Logout) - siteGroup := app.Group("/sites/:siteID", middleware.LogErrors(), middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites)) + app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index) + app.Get("/first-run", ih.FirstRun) + app.Post("/first-run", ih.FirstRunSubmit) + + app.Get("/static/*", static.New("./static")) + + app.Use(middleware.LogErrors(), middleware.RequireUser(svcs.Auth)) + + app.Get("/sites/new", ssh.New) + app.Post("/sites", ssh.Create) + siteGroup := app.Group("/sites/:siteID", middleware.RequiresSite(svcs.Sites)) siteGroup.Get("/posts", ph.Index) siteGroup.Get("/posts/new", ph.New) @@ -158,12 +168,6 @@ Starting weiro without any arguments will start the server. siteGroup.Post("/pages/:pageID", pgh.Update) siteGroup.Post("/pages/:pageID/delete", pgh.Delete) - app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index) - app.Get("/first-run", ih.FirstRun) - app.Post("/first-run", ih.FirstRunSubmit) - - app.Get("/static/*", static.New("./static")) - if err := app.Listen(":3000"); err != nil { log.Println(err) } diff --git a/handlers/middleware/errlog.go b/handlers/middleware/errlog.go index 5b6dfa6..2acac04 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("error: %v\n", err) + log.Printf("%v: error: %v\n", c.Path(), err) return err } return nil diff --git a/handlers/middleware/site.go b/handlers/middleware/site.go index 54211bc..6f47430 100644 --- a/handlers/middleware/site.go +++ b/handlers/middleware/site.go @@ -32,9 +32,19 @@ 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) + + if pubTargets, err := sites.BestPubTarget(c.Context(), site); err == nil { + c.Locals("pubTarget", pubTargets) + } + return c.Next() } } diff --git a/handlers/sitesettings.go b/handlers/sitesettings.go index 0fe2100..e61ced4 100644 --- a/handlers/sitesettings.go +++ b/handlers/sitesettings.go @@ -12,10 +12,28 @@ type SiteSettingsHandler struct { SiteService *sites.Service } -func (s *SiteSettingsHandler) General(ctx fiber.Ctx) error { - site := ctx.Locals("site").(models.Site) +func (s *SiteSettingsHandler) New(c fiber.Ctx) error { + return c.Render("sitesettings/new", fiber.Map{}, "layouts/bare_with_scripts") +} - return ctx.Render("sitesettings/general", fiber.Map{ +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{ "site": site, "tzones": sites.ListZones(), }) diff --git a/package-lock.json b/package-lock.json index c4f391c..2068fd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,8 @@ "dependencies": { "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", - "esbuild-sass-plugin": "^3.6.0" + "esbuild-sass-plugin": "^3.6.0", + "feather-icons": "^4.29.2" }, "devDependencies": { "esbuild": "0.27.3" @@ -783,6 +784,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/colorjs.io": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", @@ -790,6 +797,17 @@ "license": "MIT", "peer": true }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -855,6 +873,16 @@ "sass-embedded": "^1.97.2" } }, + "node_modules/feather-icons": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.2.tgz", + "integrity": "sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.5", + "core-js": "^3.1.3" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -887,9 +915,9 @@ } }, "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "license": "MIT" }, "node_modules/is-core-module": { diff --git a/package.json b/package.json index 64e6fca..5a786ac 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "dependencies": { "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", - "esbuild-sass-plugin": "^3.6.0" + "esbuild-sass-plugin": "^3.6.0", + "feather-icons": "^4.29.2" } } diff --git a/services/sites/services.go b/services/sites/services.go index 86e34b2..4585d03 100644 --- a/services/sites/services.go +++ b/services/sites/services.go @@ -9,6 +9,7 @@ import ( "github.com/gofiber/fiber/v3" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/pkg/modash/moslice" ) type Service struct { @@ -25,6 +26,22 @@ func (s *Service) HasUsersAsSites(ctx context.Context) (bool, error) { return s.db.HasUsersAndSites(ctx) } +func (s *Service) ListSites(ctx context.Context) ([]models.Site, error) { + user, ok := models.GetUser(ctx) + if !ok { + return nil, models.UserRequiredError + } + + sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID) + if err != nil { + return nil, err + } else if len(sites) == 0 { + return nil, errors.New("no sites found") + } + + return sites, nil +} + func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, error) { sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID) if err != nil { @@ -36,16 +53,20 @@ func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, return sites[0], nil } -type FirstRunRequest struct { - Username string `form:"username"` - Password1 string `form:"password1"` - Password2 string `form:"password2"` +type CreateSiteParams struct { SiteName string `form:"siteName"` SiteURL string `form:"siteUrl"` NetlifySiteID string `form:"netlifySiteId"` NetlifyAPIKey string `form:"netlifyAPIToken"` } +type FirstRunRequest struct { + CreateSiteParams + Username string `form:"username"` + Password1 string `form:"password1"` + Password2 string `form:"password2"` +} + func (frr FirstRunRequest) Validate() error { return validation.ValidateStruct(&frr, validation.Field(&frr.Username, validation.Required, validation.Match(models.ValidUserName)), @@ -76,16 +97,31 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo return newUser, newSite, err } + ctx = models.WithUser(ctx, newUser) + newSite, err = s.CreateSite(ctx, req.CreateSiteParams) + if err != nil { + return newUser, newSite, err + } + + return newUser, newSite, nil +} + +func (s *Service) CreateSite(ctx context.Context, req CreateSiteParams) (newSite models.Site, _ error) { + user, ok := models.GetUser(ctx) + if !ok { + return newSite, models.UserRequiredError + } + newSite = models.Site{ Title: defaultIfEmpty(req.SiteName, "New Site"), GUID: models.NewNanoID(), - OwnerID: newUser.ID, + OwnerID: user.ID, Timezone: "UTC", PostsPerPage: 10, Created: time.Now(), } if err := s.db.SaveSite(ctx, &newSite); err != nil { - return newUser, newSite, err + return newSite, err } hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != "" @@ -100,11 +136,11 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo TargetKey: req.NetlifyAPIKey, } if err := s.db.SavePublishTarget(ctx, &target); err != nil { - return newUser, newSite, err + return newSite, err } } - return newUser, newSite, nil + return newSite, nil } func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, error) { @@ -166,3 +202,17 @@ 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/views/_common/nav.html b/views/_common/nav.html index e9c0de7..5005326 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -29,7 +29,25 @@ Publishing... --> - + +
+ {{ end }}
diff --git a/models/pubmodel/sites.go b/models/pubmodel/sites.go index 38ba614..9f25b2f 100644 --- a/models/pubmodel/sites.go +++ b/models/pubmodel/sites.go @@ -6,6 +6,7 @@ import ( "iter" "lmika.dev/lmika/weiro/models" + "lmika.dev/pkg/modash/moslice" ) type Site struct { @@ -20,3 +21,7 @@ type Site struct { CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error) Pages []*models.Page } + +func (s Site) NavItems() []*models.Page { + return moslice.Filter(s.Pages, func(p *models.Page) bool { return p.ShowInNav }) +} From 18f9f49c0a7dae500501208ef9153c7cbbb525ac Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 25 Mar 2026 21:09:57 +1100 Subject: [PATCH 03/15] Started UI for editing images --- assets/css/main.scss | 13 ++++-- assets/js/controllers/edit_upload.js | 59 ++++++++++++++++++++++++ assets/js/main.js | 2 + cmds/server.go | 1 + handlers/posts.go | 4 +- handlers/uploads.go | 21 +++++++++ package-lock.json | 67 +++++++++++++++++++++++++++- package.json | 3 +- views/posts/edit.html | 2 +- views/uploads/edit.html | 28 ++++++++++++ 10 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 assets/js/controllers/edit_upload.js create mode 100644 views/uploads/edit.html diff --git a/assets/css/main.scss b/assets/css/main.scss index 2e0883a..addf5ce 100644 --- a/assets/css/main.scss +++ b/assets/css/main.scss @@ -31,19 +31,24 @@ $container-max-widths: ( font-size: 0.9rem; } -// Post form +// Large editor +// +// Used for edit canvases which take up the entire window -// Post edit page styling -.post-edit-page { +.large-editor { height: 100vh; } -.post-edit-page main { +.large-editor main { display: flex; flex-direction: column; overflow: hidden; } +// Post form + +// Post edit page styling + .post-edit-page .post-form { flex: 1; display: flex; diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js new file mode 100644 index 0000000..11ed862 --- /dev/null +++ b/assets/js/controllers/edit_upload.js @@ -0,0 +1,59 @@ +import Handlebars from "handlebars"; +import {Controller} from "@hotwired/stimulus"; + +const processorFrame = Handlebars.compile(` +
+
+ {{name}} + X +
+
+ {{{props}}} +
+
+`); + +const processors = [ + { + name: "shadow", + label: "Shadow", + template: Handlebars.compile(`This processor has no properties.`), + }, + { + name: "resize", + label: "Resize", + template: Handlebars.compile(` +
+ + +
+
+ + +
+ `), + } +]; + +export default class UploadEditController extends Controller { + static targets = ['processList']; + + connect() { + this._rebuildProcessList(); + } + + _rebuildProcessList() { + let el = this.processListTarget; + + // TEMP + let cardTemplate = processors[0].template({ + "id": "shadow", + }); + let cardOuter = processorFrame({ + name: processors[0].label, + props: cardTemplate, + }); + el.innerHTML = cardOuter; + // END TEMP + } +} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index fcbe286..d3ff4c6 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -8,6 +8,7 @@ import LogoutController from "./controllers/logout"; import FirstRunController from "./controllers/firstrun"; import UploadController from "./controllers/upload"; import ShowUploadController from "./controllers/show_upload"; +import EditUploadController from "./controllers/edit_upload"; import PagelistController from "./controllers/pagelist"; window.Stimulus = Application.start() @@ -18,6 +19,7 @@ Stimulus.register("logout", LogoutController); Stimulus.register("first-run", FirstRunController); Stimulus.register("upload", UploadController); Stimulus.register("show-upload", ShowUploadController); +Stimulus.register("edit-upload", EditUploadController); Stimulus.register("pagelist", PagelistController); feather.replace(); \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index 36e5923..06f7352 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -149,6 +149,7 @@ Starting weiro without any arguments will start the server. siteGroup.Post("/uploads/pending/:guid", uh.UploadPart) siteGroup.Post("/uploads/pending/:guid/finalize", uh.UploadComplete) siteGroup.Delete("/uploads/:uploadID", uh.Delete) + siteGroup.Get("/uploads/:uploadID/edit", uh.Edit) siteGroup.Get("/settings", ssh.General) siteGroup.Post("/settings", ssh.UpdateGeneral) diff --git a/handlers/posts.go b/handlers/posts.go index 3326533..0e491aa 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": "post-edit-page", + "bodyClass": "large-editor", }) } @@ -116,7 +116,7 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error { "post": post, "categories": cats, "selectedCategories": selectedCategories, - "bodyClass": "post-edit-page", + "bodyClass": "large-editor", }) })) } diff --git a/handlers/uploads.go b/handlers/uploads.go index fa2cb98..3553b09 100644 --- a/handlers/uploads.go +++ b/handlers/uploads.go @@ -162,3 +162,24 @@ 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/package-lock.json b/package-lock.json index 2068fd3..eadf529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", "esbuild-sass-plugin": "^3.6.0", - "feather-icons": "^4.29.2" + "feather-icons": "^4.29.2", + "handlebars": "^4.7.8" }, "devDependencies": { "esbuild": "0.27.3" @@ -892,6 +893,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -958,6 +980,21 @@ "node": ">=0.10.0" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -1395,6 +1432,15 @@ "node": ">=14.0.0" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1462,12 +1508,31 @@ "license": "0BSD", "peer": true }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/varint": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", "license": "MIT", "peer": true + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 5a786ac..3455630 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", "esbuild-sass-plugin": "^3.6.0", - "feather-icons": "^4.29.2" + "feather-icons": "^4.29.2", + "handlebars": "^4.7.8" } } diff --git a/views/posts/edit.html b/views/posts/edit.html index b9f5ea7..fbb94fa 100644 --- a/views/posts/edit.html +++ b/views/posts/edit.html @@ -1,5 +1,5 @@ {{ $isPublished := ne .post.State 1 }} -
+
+
+
+
+ {{ .upload.Upload.Alt }} +
+
+
+ +
+ +
+
+
+
+
+ Actions go here +
+
\ No newline at end of file From 036b683eab6a9da873ea03c4ed6fd17cb5263497 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 25 Mar 2026 22:35:53 +1100 Subject: [PATCH 04/15] Have got session creation working --- assets/js/controllers/edit_upload.js | 25 +++++- cmds/server.go | 4 + handlers/imageedit.go | 67 ++++++++++++++++ models/errors.go | 1 + models/imgedit.go | 59 ++++++++++++++ services/imgedit/processing.go | 91 +++++++++++++++++++++ services/imgedit/service.go | 116 +++++++++++++++++++++++++++ services/imgedit/store.go | 66 +++++++++++++++ services/services.go | 4 + views/uploads/edit.html | 8 +- 10 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 handlers/imageedit.go create mode 100644 models/imgedit.go create mode 100644 services/imgedit/processing.go create mode 100644 services/imgedit/service.go create mode 100644 services/imgedit/store.go diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index 11ed862..35be2f7 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -36,10 +36,15 @@ const processors = [ ]; export default class UploadEditController extends Controller { - static targets = ['processList']; + static targets = ['processList', 'preview']; + static values = { + uploadId: Number, + siteId: Number, + }; connect() { this._rebuildProcessList(); + this._createSession(); } _rebuildProcessList() { @@ -56,4 +61,22 @@ export default class UploadEditController extends Controller { el.innerHTML = cardOuter; // END TEMP } + + async _createSession() { + try { + let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/`, { + method: 'POST', + body: JSON.stringify({ + "base_upload": this.uploadIdValue, + }) + }); + + this._state = await resp.json(); + this.previewTarget.src = this._state.preview_url; + + console.log("Session created"); + } catch (e) { + console.error(e); + } + } } \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index 06f7352..5870a8a 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -111,6 +111,7 @@ Starting weiro without any arguments will start the server. lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth} ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories} uh := handlers.UploadsHandler{UploadsService: svcs.Uploads} + ieh := handlers.ImageEditHandlers{ImageEditService: svcs.ImageEdit} ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} pgh := handlers.PagesHandler{PageService: svcs.Pages} @@ -151,6 +152,9 @@ Starting weiro without any arguments will start the server. siteGroup.Delete("/uploads/:uploadID", uh.Delete) siteGroup.Get("/uploads/:uploadID/edit", uh.Edit) + siteGroup.Post("/imageedit", ieh.Create) + siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) + siteGroup.Get("/settings", ssh.General) siteGroup.Post("/settings", ssh.UpdateGeneral) diff --git a/handlers/imageedit.go b/handlers/imageedit.go new file mode 100644 index 0000000..551776a --- /dev/null +++ b/handlers/imageedit.go @@ -0,0 +1,67 @@ +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 +} + +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 = struct { + Session models.ImageEditSession `json:"session"` + PreviewURL string `json:"preview_url"` + }{ + 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 + } + }) +} diff --git a/models/errors.go b/models/errors.go index 3efadbc..2c4ae68 100644 --- a/models/errors.go +++ b/models/errors.go @@ -8,3 +8,4 @@ var NotFoundError = errors.New("not found") var SiteRequiredError = errors.New("site required") var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds") var SlugConflictError = errors.New("a record with this slug already exists") +var UnsupportedImageFormat = errors.New("unsupported image format") diff --git a/models/imgedit.go b/models/imgedit.go new file mode 100644 index 0000000..88ec8be --- /dev/null +++ b/models/imgedit.go @@ -0,0 +1,59 @@ +package models + +import ( + "crypto/md5" + "encoding/json" + "fmt" + "strings" + "time" +) + +type ImageEditSession struct { + GUID string `json:"guid"` + SiteID int64 `json:"siteId"` + UserID int64 `json:"userId"` + BaseUploadID int64 `json:"baseUploadId"` + ImageExt string `json:"imageExt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Processors []ImageEditProcessor `json:"processors"` +} + +func (ieh ImageEditSession) PreviewURL() string { + return fmt.Sprintf("/sites/%v/imageedit/%v/preview/%v", ieh.SiteID, ieh.GUID, ieh.Processors[len(ieh.Processors)-1].VersionID) +} + +func (ieh *ImageEditSession) RecalcVersionIDs() { + for i, p := range ieh.Processors { + if i == 0 { + p.SetVersionID("") + } else { + p.SetVersionID(ieh.Processors[i-1].VersionID) + } + + ieh.Processors[i] = p + } +} + +type ImageEditProcessor struct { + Type string `json:"type"` + Props json.RawMessage `json:"props"` + + // VersionID is a unique hash of the particular processor. This includes the version ID of the previous processor, + // thereby causing a change of one processor to affect the version IDs of processors down the line. + VersionID string `json:"versionId"` +} + +func (ieh *ImageEditProcessor) SetVersionID(previousVersionID string) { + var sb strings.Builder + sb.WriteString(previousVersionID) + sb.WriteString("-") + sb.WriteString(ieh.Type) + sb.WriteString("-") + sb.WriteString(string(ieh.Props)) + ieh.VersionID = fmt.Sprintf("%x", md5.Sum([]byte(sb.String()))) +} + +type CopyUploadProps struct { + UploadID int64 `json:"uploadId"` +} diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go new file mode 100644 index 0000000..83e58a9 --- /dev/null +++ b/services/imgedit/processing.go @@ -0,0 +1,91 @@ +package imgedit + +import ( + "context" + "encoding/json" + "fmt" + "image" + "os" + "path/filepath" + + "github.com/disintegration/imaging" + "lmika.dev/lmika/weiro/models" +) + +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) + } + return nil, fmt.Errorf("unknown processor type: %v", processor.Type) +} + +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 +} diff --git a/services/imgedit/service.go b/services/imgedit/service.go new file mode 100644 index 0000000..0b4d080 --- /dev/null +++ b/services/imgedit/service.go @@ -0,0 +1,116 @@ +package imgedit + +import ( + "context" + "encoding/json" + "io" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/uploads" +) + +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) LoadImageVersion(ctx context.Context, sessionID string, versionID string) (mimeType string, rw func() (io.ReadCloser, error), err 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 s.sessionStore.getImage(session, versionID+"."+session.ImageExt) +} + +func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.ImageEditSession, error) { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.ImageEditSession{}, err + } + + upload, _, err := s.uploadService.OpenUpload(ctx, baseUploadID) + if err != nil { + return models.ImageEditSession{}, err + } + + var ext string + switch upload.MIMEType { + case "image/jpeg": + ext = "jpg" + case "image/png": + ext = "png" + default: + return models.ImageEditSession{}, 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{ + { + Type: "copy-upload", + Props: mustToJSON(models.CopyUploadProps{UploadID: baseUploadID}), + }, + }, + } + + newSession.RecalcVersionIDs() + if err := s.sessionStore.create(newSession); err != nil { + return models.ImageEditSession{}, err + } + + if _, err := s.reprocess(ctx, newSession); err != nil { + return models.ImageEditSession{}, err + } + + return newSession, 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/store.go b/services/imgedit/store.go new file mode 100644 index 0000000..b697faa --- /dev/null +++ b/services/imgedit/store.go @@ -0,0 +1,66 @@ +package imgedit + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + + "lmika.dev/lmika/weiro/models" +) + +type sessionStore struct { + baseDir string +} + +func (ss *sessionStore) create(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 models.ImageEditSession{}, err + } + + sessionData := models.ImageEditSession{} + if err := json.Unmarshal(sessionDataBts, &sessionData); err != nil { + return models.ImageEditSession{}, err + } + + return sessionData, nil +} + +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/services.go b/services/services.go index 852dea3..ab1a4ca 100644 --- a/services/services.go +++ b/services/services.go @@ -8,6 +8,7 @@ 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/pages" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" @@ -23,6 +24,7 @@ type Services struct { Posts *posts.Service Sites *sites.Service Uploads *uploads.Service + ImageEdit *imgedit.Service Categories *categories.Service Pages *pages.Service } @@ -41,6 +43,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) @@ -52,6 +55,7 @@ func New(cfg config.Config) (*Services, error) { Posts: postService, Sites: siteService, Uploads: uploadService, + ImageEdit: imageEditService, Categories: categoriesService, Pages: pagesService, }, nil diff --git a/views/uploads/edit.html b/views/uploads/edit.html index 18ff35f..3137e00 100644 --- a/views/uploads/edit.html +++ b/views/uploads/edit.html @@ -1,8 +1,12 @@
-
+
- {{ .upload.Upload.Alt }} + {{ .upload.Upload.Alt }}
From 599c72d465561a4f94b247fc0895dcfe05f8dec2 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 Mar 2026 21:16:50 +1100 Subject: [PATCH 05/15] Have got the processor plumbing working --- assets/js/controllers/edit_upload.js | 29 ++++++++++ cmds/server.go | 1 + handlers/imageedit.go | 34 +++++++++-- handlers/index.go | 8 +++ handlers/middleware/site.go | 4 ++ models/imgedit.go | 3 + services/imgedit/processing.go | 7 ++- services/imgedit/service.go | 87 ++++++++++++++++++++-------- services/imgedit/shadow.go | 35 +++++++++++ services/imgedit/store.go | 12 ++-- views/uploads/edit.html | 4 +- 11 files changed, 185 insertions(+), 39 deletions(-) create mode 100644 services/imgedit/shadow.go diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index 35be2f7..65b0234 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -47,6 +47,13 @@ export default class UploadEditController extends Controller { this._createSession(); } + async addProcessor(ev) { + ev.preventDefault(); + await this._addProcessor({ + type: "shadow" + }); + } + _rebuildProcessList() { let el = this.processListTarget; @@ -66,6 +73,10 @@ export default class UploadEditController extends Controller { try { let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/`, { method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, body: JSON.stringify({ "base_upload": this.uploadIdValue, }) @@ -79,4 +90,22 @@ export default class UploadEditController extends Controller { console.error(e); } } + + async _addProcessor(processor) { + try { + let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/processors`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(processor) + }); + + this._state = await resp.json(); + this.previewTarget.src = this._state.preview_url; + } catch (e) { + console.error(e); + } + } } \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index 5870a8a..d776cf3 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -153,6 +153,7 @@ Starting weiro without any arguments will start the server. siteGroup.Get("/uploads/:uploadID/edit", uh.Edit) siteGroup.Post("/imageedit", ieh.Create) + siteGroup.Post("/imageedit/:sessionID/processors", ieh.AddProcessor) siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) siteGroup.Get("/settings", ssh.General) diff --git a/handlers/imageedit.go b/handlers/imageedit.go index 551776a..1ca9817 100644 --- a/handlers/imageedit.go +++ b/handlers/imageedit.go @@ -15,6 +15,11 @@ 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"` @@ -29,10 +34,7 @@ func (ieh ImageEditHandlers) Create(c fiber.Ctx) error { return err } - var resp = struct { - Session models.ImageEditSession `json:"session"` - PreviewURL string `json:"preview_url"` - }{ + var resp = sessionResponse{ Session: res, PreviewURL: res.PreviewURL(), } @@ -65,3 +67,27 @@ func (ieh ImageEditHandlers) Preview(c fiber.Ctx) error { } }) } + +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(), + }) +} diff --git a/handlers/index.go b/handlers/index.go index 6062237..410c347 100644 --- a/handlers/index.go +++ b/handlers/index.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "log" "net/url" "regexp" @@ -37,6 +38,13 @@ 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/middleware/site.go b/handlers/middleware/site.go index 6f47430..1d3ddf2 100644 --- a/handlers/middleware/site.go +++ b/handlers/middleware/site.go @@ -5,6 +5,7 @@ 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" @@ -41,6 +42,9 @@ func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error { } 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) } diff --git a/models/imgedit.go b/models/imgedit.go index 88ec8be..b954402 100644 --- a/models/imgedit.go +++ b/models/imgedit.go @@ -36,6 +36,7 @@ func (ieh *ImageEditSession) RecalcVersionIDs() { } type ImageEditProcessor struct { + ID string `json:"id"` Type string `json:"type"` Props json.RawMessage `json:"props"` @@ -46,6 +47,8 @@ type ImageEditProcessor struct { func (ieh *ImageEditProcessor) SetVersionID(previousVersionID string) { var sb strings.Builder + sb.WriteString(ieh.ID) + sb.WriteString("-") sb.WriteString(previousVersionID) sb.WriteString("-") sb.WriteString(ieh.Type) diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go index 83e58a9..107bcb3 100644 --- a/services/imgedit/processing.go +++ b/services/imgedit/processing.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "image" + "image/color" "os" "path/filepath" @@ -12,7 +13,7 @@ import ( "lmika.dev/lmika/weiro/models" ) -func (s *Service) reprocess(ctx context.Context, session models.ImageEditSession) (imageSource, error) { +func (s *Service) reprocess(ctx context.Context, session *models.ImageEditSession) (imageSource, error) { var img imageSource for _, p := range session.Processors { @@ -68,6 +69,10 @@ func (s *Service) processImage(ctx context.Context, srcImg image.Image, processo defer f.Close() return imaging.Decode(f) + case "shadow": + shadow := makeBoxShadow(srcImg, color.Black, 4, 10, 0) + composit := imaging.OverlayCenter(shadow, srcImg, 1.0) + return composit, nil } return nil, fmt.Errorf("unknown processor type: %v", processor.Type) } diff --git a/services/imgedit/service.go b/services/imgedit/service.go index 0b4d080..fa6b795 100644 --- a/services/imgedit/service.go +++ b/services/imgedit/service.go @@ -27,31 +27,15 @@ func New( } } -func (s *Service) LoadImageVersion(ctx context.Context, sessionID string, versionID string) (mimeType string, rw func() (io.ReadCloser, error), err error) { +func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (*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 s.sessionStore.getImage(session, versionID+"."+session.ImageExt) -} - -func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.ImageEditSession, error) { - site, user, err := s.fetchSiteAndUser(ctx) - if err != nil { - return models.ImageEditSession{}, err + return nil, err } upload, _, err := s.uploadService.OpenUpload(ctx, baseUploadID) if err != nil { - return models.ImageEditSession{}, err + return nil, err } var ext string @@ -61,7 +45,7 @@ func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.Im case "image/png": ext = "png" default: - return models.ImageEditSession{}, models.UnsupportedImageFormat + return nil, models.UnsupportedImageFormat } newSession := models.ImageEditSession{ @@ -74,6 +58,7 @@ func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.Im UpdatedAt: time.Now().UTC(), Processors: []models.ImageEditProcessor{ { + ID: models.NewNanoID(), Type: "copy-upload", Props: mustToJSON(models.CopyUploadProps{UploadID: baseUploadID}), }, @@ -81,15 +66,67 @@ func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.Im } newSession.RecalcVersionIDs() - if err := s.sessionStore.create(newSession); err != nil { - return models.ImageEditSession{}, err + if err := s.sessionStore.save(&newSession); err != nil { + return nil, err } - if _, err := s.reprocess(ctx, newSession); err != nil { - return models.ImageEditSession{}, err + if _, err := s.reprocess(ctx, &newSession); err != nil { + return nil, err } - return newSession, nil + 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 + } + + // TODO: verify processor, etc. + session.Processors = append(session.Processors, models.ImageEditProcessor{ + ID: models.NewNanoID(), + Type: req.Type, + }) + + 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) 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) { diff --git a/services/imgedit/shadow.go b/services/imgedit/shadow.go new file mode 100644 index 0000000..4a308d0 --- /dev/null +++ b/services/imgedit/shadow.go @@ -0,0 +1,35 @@ +package imgedit + +import ( + "image" + "image/color" + + "github.com/disintegration/imaging" +) + +func makeBoxShadow(maskImg image.Image, shadowColor color.Color, sigma float64, shadowMargin, offsetY int) image.Image { + w, h := maskImg.Bounds().Dx(), maskImg.Bounds().Dy() + cr, cg, cb, _ := shadowColor.RGBA() + cr8, cg8, cb8 := uint8(cr>>8), uint8(cg>>8), uint8(cb>>8) + + // New box image + backing := image.NewNRGBA(image.Rect(0, 0, w+shadowMargin*2, h+shadowMargin*2+offsetY)) + newImg := image.NewNRGBA(image.Rect(0, 0, w+shadowMargin*2, h+shadowMargin*2+offsetY)) + for x := 0; x < w+shadowMargin*2; x++ { + for y := 0; y < h+shadowMargin*2; y++ { + var c = color.NRGBA{R: 255, G: 255, B: 255, A: 0} + if x >= shadowMargin-4 && y >= shadowMargin-4 && x <= w+shadowMargin+4 && y <= h+shadowMargin+4 { + _, _, _, a := maskImg.At(x-shadowMargin, y-shadowMargin).RGBA() + c = color.NRGBA{R: cr8, G: cg8, B: cb8, A: uint8(a >> 8)} + } + backing.SetNRGBA(x, y, color.NRGBA{R: 255, G: 255, B: 255, A: 0}) + newImg.SetNRGBA(x, y+offsetY, c) + } + } + + // Blur + blurredImage := imaging.Blur(newImg, sigma) + backing = imaging.OverlayCenter(backing, blurredImage, 0.6) + + return backing +} diff --git a/services/imgedit/store.go b/services/imgedit/store.go index b697faa..7638dbe 100644 --- a/services/imgedit/store.go +++ b/services/imgedit/store.go @@ -13,7 +13,7 @@ type sessionStore struct { baseDir string } -func (ss *sessionStore) create(newSession models.ImageEditSession) error { +func (ss *sessionStore) save(newSession *models.ImageEditSession) error { sessionMeta, err := json.Marshal(newSession) if err != nil { return err @@ -28,21 +28,21 @@ func (ss *sessionStore) create(newSession models.ImageEditSession) error { return nil } -func (ss *sessionStore) get(guid string) (models.ImageEditSession, error) { +func (ss *sessionStore) get(guid string) (*models.ImageEditSession, error) { sessionDataBts, err := os.ReadFile(filepath.Join(ss.baseDir, guid, "session.json")) if err != nil { - return models.ImageEditSession{}, err + return nil, err } sessionData := models.ImageEditSession{} if err := json.Unmarshal(sessionDataBts, &sessionData); err != nil { - return models.ImageEditSession{}, err + return nil, err } - return sessionData, nil + return &sessionData, nil } -func (ss *sessionStore) getImage(session models.ImageEditSession, imageFilename string) (string, func() (io.ReadCloser, error), error) { +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 diff --git a/views/uploads/edit.html b/views/uploads/edit.html index 3137e00..5c8cc2d 100644 --- a/views/uploads/edit.html +++ b/views/uploads/edit.html @@ -17,9 +17,7 @@ Add Processor
From 2d42a0ef909859a7b0fdd6fe249ab836103562c0 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 Mar 2026 21:44:20 +1100 Subject: [PATCH 06/15] Have got removing parameters working --- assets/js/controllers/edit_upload.js | 77 ++++++++++++++++++++-------- cmds/server.go | 1 + handlers/imageedit.go | 22 ++++++++ services/imgedit/processing.go | 3 ++ services/imgedit/service.go | 20 ++++++++ 5 files changed, 103 insertions(+), 20 deletions(-) diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index 65b0234..929b905 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -5,7 +5,10 @@ const processorFrame = Handlebars.compile(`
{{name}} - X + X
{{{props}}} @@ -13,14 +16,12 @@ const processorFrame = Handlebars.compile(`
`); -const processors = [ - { - name: "shadow", +const processorUIs = { + "shadow": { label: "Shadow", template: Handlebars.compile(`This processor has no properties.`), }, - { - name: "resize", + "resize": { label: "Resize", template: Handlebars.compile(`
@@ -32,8 +33,8 @@ const processors = [
`), - } -]; + }, +}; export default class UploadEditController extends Controller { static targets = ['processList', 'preview']; @@ -54,19 +55,33 @@ export default class UploadEditController extends Controller { }); } + async removeProcessor(ev) { + ev.preventDefault(); + let id = ev.params.id; + console.log(ev.params); + await this._removeProcessor(id); + } + _rebuildProcessList() { let el = this.processListTarget; - // TEMP - let cardTemplate = processors[0].template({ - "id": "shadow", - }); - let cardOuter = processorFrame({ - name: processors[0].label, - props: cardTemplate, - }); - el.innerHTML = cardOuter; - // END TEMP + if ((!this._state) || (!this._state.session) || (!this._state.session.processors)) { + return; + } + + el.innerHTML = ""; + for (let p of this._state.session.processors) { + let ui = processorUIs[p.type]; + if (!ui) { + continue; + } + let cardOuter = processorFrame({ + id: p.id, + name: ui.label, + props: ui.template(p), + }); + el.innerHTML += cardOuter; + } } async _createSession() { @@ -83,9 +98,9 @@ export default class UploadEditController extends Controller { }); this._state = await resp.json(); - this.previewTarget.src = this._state.preview_url; - console.log("Session created"); + this._rebuildProcessList(); + this.previewTarget.src = this._state.preview_url; } catch (e) { console.error(e); } @@ -103,9 +118,31 @@ export default class UploadEditController extends Controller { }); this._state = await resp.json(); + + this._rebuildProcessList(); this.previewTarget.src = this._state.preview_url; } catch (e) { console.error(e); } } + + async _removeProcessor(processorID) { + await this._doReturningState(async () => { + return (await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/processors/${processorID}`, { + method: 'DELETE', + })).json(); + }) + } + + async _doReturningState(fn) { + try { + this._state = await fn(); + + this._rebuildProcessList(); + this.previewTarget.src = this._state.preview_url; + } catch (e) { + console.error(e); + } + + } } \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index d776cf3..515f7a5 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -154,6 +154,7 @@ Starting weiro without any arguments will start the server. siteGroup.Post("/imageedit", ieh.Create) siteGroup.Post("/imageedit/:sessionID/processors", ieh.AddProcessor) + siteGroup.Delete("/imageedit/:sessionID/processors/:processorID", ieh.DeleteProcessor) siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) siteGroup.Get("/settings", ssh.General) diff --git a/handlers/imageedit.go b/handlers/imageedit.go index 1ca9817..8026c53 100644 --- a/handlers/imageedit.go +++ b/handlers/imageedit.go @@ -91,3 +91,25 @@ func (ieh ImageEditHandlers) AddProcessor(c fiber.Ctx) error { 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(), + }) +} diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go index 107bcb3..68f8b9d 100644 --- a/services/imgedit/processing.go +++ b/services/imgedit/processing.go @@ -6,6 +6,7 @@ import ( "fmt" "image" "image/color" + "log" "os" "path/filepath" @@ -46,6 +47,8 @@ func (s *Service) reprocess(ctx context.Context, session *models.ImageEditSessio img = imageImageSource{resImg} } + log.Printf("result of processed image: %T", img) + return img, nil } diff --git a/services/imgedit/service.go b/services/imgedit/service.go index fa6b795..97603a8 100644 --- a/services/imgedit/service.go +++ b/services/imgedit/service.go @@ -8,6 +8,7 @@ import ( "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/services/uploads" + "lmika.dev/pkg/modash/moslice" ) type Service struct { @@ -114,6 +115,25 @@ func (s *Service) AddProcessor(ctx context.Context, sessionID string, req AddPro 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 +} + func (s *Service) loadAndVerifySession(ctx context.Context, sessionID string) (*models.ImageEditSession, error) { site, user, err := s.fetchSiteAndUser(ctx) if err != nil { From 488942db2e55d673c4e44893e17f1f54f3a8a894 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 Mar 2026 22:14:57 +1100 Subject: [PATCH 07/15] Started working on proper parameters --- assets/js/controllers/edit_upload.js | 13 ++++++-- services/imgedit/processing.go | 48 +++++++++++++++++++++++++--- services/imgedit/service.go | 18 +++++++++-- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index 929b905..f1d472a 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -1,6 +1,10 @@ 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(`
@@ -11,7 +15,7 @@ const processorFrame = Handlebars.compile(` >X
- {{{props}}} + {{{props}}}
`); @@ -19,7 +23,12 @@ const processorFrame = Handlebars.compile(` const processorUIs = { "shadow": { label: "Shadow", - template: Handlebars.compile(`This processor has no properties.`), + template: Handlebars.compile(` +
+ + +
+ `), }, "resize": { label: "Resize", diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go index 68f8b9d..378ead5 100644 --- a/services/imgedit/processing.go +++ b/services/imgedit/processing.go @@ -14,6 +14,34 @@ import ( "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"` +} + +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) + + shadow := makeBoxShadow(srcImg, color.Black, 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 @@ -72,12 +100,22 @@ func (s *Service) processImage(ctx context.Context, srcImg image.Image, processo defer f.Close() return imaging.Decode(f) - case "shadow": - shadow := makeBoxShadow(srcImg, color.Black, 4, 10, 0) - composit := imaging.OverlayCenter(shadow, srcImg, 1.0) - return composit, nil + //case "shadow": + // shadow := makeBoxShadow(srcImg, color.Black, 4, 10, 0) + // composit := imaging.OverlayCenter(shadow, srcImg, 1.0) + // return composit, nil } - return nil, fmt.Errorf("unknown processor type: %v", processor.Type) + + 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 { diff --git a/services/imgedit/service.go b/services/imgedit/service.go index 97603a8..d9f3ba4 100644 --- a/services/imgedit/service.go +++ b/services/imgedit/service.go @@ -3,6 +3,7 @@ package imgedit import ( "context" "encoding/json" + "fmt" "io" "time" @@ -97,10 +98,21 @@ func (s *Service) AddProcessor(ctx context.Context, sessionID string, req AddPro return nil, err } - // TODO: verify processor, etc. + 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, + ID: models.NewNanoID(), + Type: req.Type, + Props: paramBytes, }) session.RecalcVersionIDs() From f9a65c8ca9b2d4c8808c960868d23e9b871c00b1 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Fri, 27 Mar 2026 21:43:03 +1100 Subject: [PATCH 08/15] Have got adjusting processor arguments working --- assets/js/controllers/edit_upload.js | 44 ++++++++++++++++++++++++--- cmds/server.go | 1 + handlers/imageedit.go | 29 ++++++++++++++++++ services/imgedit/processing.go | 45 ++++++++++++++++++++++++++-- services/imgedit/service.go | 29 ++++++++++++++++++ views/uploads/edit.html | 17 ++++++----- 6 files changed, 151 insertions(+), 14 deletions(-) diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index f1d472a..f575bea 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -24,9 +24,17 @@ const processorUIs = { "shadow": { label: "Shadow", template: Handlebars.compile(` -
- - +
+ +
+ +
+
+
+ +
+ +
`), }, @@ -67,10 +75,19 @@ export default class UploadEditController extends Controller { async removeProcessor(ev) { ev.preventDefault(); let id = ev.params.id; - console.log(ev.params); await this._removeProcessor(id); } + async updateProcessor(ev) { + ev.preventDefault(); + let id = ev.params.id; + + let paramParentEl = ev.target.closest('[data-role="processor-params"]'); + let params = Object.fromEntries(new FormData(paramParentEl).entries()); + + await this._updateProcessor(id, params); + } + _rebuildProcessList() { let el = this.processListTarget; @@ -135,6 +152,25 @@ export default class UploadEditController extends Controller { } } + async _updateProcessor(processorID, params) { + await this._doReturningState(async () => { + return (await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}`, { + method: 'PATCH', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + processor: { + id: processorID, + props: params, + } + }) + })).json(); + }) + } + + async _removeProcessor(processorID) { await this._doReturningState(async () => { return (await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/processors/${processorID}`, { diff --git a/cmds/server.go b/cmds/server.go index 515f7a5..7a1445a 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -153,6 +153,7 @@ Starting weiro without any arguments will start the server. siteGroup.Get("/uploads/:uploadID/edit", uh.Edit) siteGroup.Post("/imageedit", ieh.Create) + siteGroup.Patch("/imageedit/:sessionID", ieh.PatchSession) siteGroup.Post("/imageedit/:sessionID/processors", ieh.AddProcessor) siteGroup.Delete("/imageedit/:sessionID/processors/:processorID", ieh.DeleteProcessor) siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) diff --git a/handlers/imageedit.go b/handlers/imageedit.go index 8026c53..ced7f75 100644 --- a/handlers/imageedit.go +++ b/handlers/imageedit.go @@ -113,3 +113,32 @@ func (ieh ImageEditHandlers) DeleteProcessor(c fiber.Ctx) error { PreviewURL: res.PreviewURL(), }) } + +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/services/imgedit/processing.go b/services/imgedit/processing.go index 378ead5..c1f99bf 100644 --- a/services/imgedit/processing.go +++ b/services/imgedit/processing.go @@ -21,7 +21,7 @@ type imageProcessor struct { type shadowProcessorArgs struct { Color string `json:"color"` - OffsetY int `json:"offset_y"` + OffsetY int `json:"offset_y,string"` } var processors = map[string]imageProcessor{ @@ -35,7 +35,12 @@ var processors = map[string]imageProcessor{ processImage: func(ctx context.Context, srcImg image.Image, params any) (image.Image, error) { p := params.(*shadowProcessorArgs) - shadow := makeBoxShadow(srcImg, color.Black, 4, 10, p.OffsetY) + 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 }, @@ -135,3 +140,39 @@ type imageImageSource struct { 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 index d9f3ba4..c53a37e 100644 --- a/services/imgedit/service.go +++ b/services/imgedit/service.go @@ -146,6 +146,35 @@ func (s *Service) DeleteProcessor(ctx context.Context, sessionID, processorID st 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 +} + func (s *Service) loadAndVerifySession(ctx context.Context, sessionID string) (*models.ImageEditSession, error) { site, user, err := s.fetchSiteAndUser(ctx) if err != nil { diff --git a/views/uploads/edit.html b/views/uploads/edit.html index 5c8cc2d..21b41bb 100644 --- a/views/uploads/edit.html +++ b/views/uploads/edit.html @@ -1,12 +1,12 @@ -
+
-
+
- {{ .upload.Upload.Alt }} + {{ .upload.Upload.Alt }}
@@ -25,6 +25,7 @@
- Actions go here + +
\ No newline at end of file From c8a276b248902c41af64e254c04ef8ed8fc954ba Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 28 Mar 2026 21:42:35 +1100 Subject: [PATCH 09/15] Have got saving working --- assets/js/controllers/edit_upload.js | 46 +++++++++++- cmds/server.go | 1 + handlers/imageedit.go | 21 ++++++ providers/db/gen/sqlgen/categories.sql.go | 2 +- providers/db/gen/sqlgen/db.go | 2 +- providers/db/gen/sqlgen/models.go | 2 +- providers/db/gen/sqlgen/pages.sql.go | 2 +- .../db/gen/sqlgen/pending_uploads.sql.go | 2 +- providers/db/gen/sqlgen/posts.sql.go | 2 +- providers/db/gen/sqlgen/pubtargets.sql.go | 2 +- providers/db/gen/sqlgen/sites.sql.go | 2 +- providers/db/gen/sqlgen/uploads.sql.go | 26 +++++-- providers/db/gen/sqlgen/users.sql.go | 2 +- providers/db/uploads.go | 13 +++- providers/uploadfiles/provider.go | 5 ++ services/imgedit/service.go | 52 ++++++++++++++ services/imgedit/store.go | 4 ++ services/uploads/manage.go | 72 +++++++++++++++++++ sql/queries/uploads.sql | 5 +- views/uploads/edit.html | 2 +- views/uploads/show.html | 5 +- 21 files changed, 248 insertions(+), 22 deletions(-) diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index f575bea..95cbb1e 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -1,3 +1,4 @@ +import feather from "feather-icons/dist/feather.js"; import Handlebars from "handlebars"; import {Controller} from "@hotwired/stimulus"; @@ -7,12 +8,12 @@ Handlebars.registerHelper("submit_on", function (id, event) { const processorFrame = Handlebars.compile(`
-
+
{{name}} - X + >
{{{props}}}
@@ -78,6 +79,16 @@ export default class UploadEditController extends Controller { await this._removeProcessor(id); } + async saveUpload(ev) { + ev.preventDefault(); + await this._save("replace"); + } + + async saveNewUpload(ev) { + ev.preventDefault(); + await this._save("copy"); + } + async updateProcessor(ev) { ev.preventDefault(); let id = ev.params.id; @@ -108,6 +119,8 @@ export default class UploadEditController extends Controller { }); el.innerHTML += cardOuter; } + + feather.replace(); } async _createSession() { @@ -179,6 +192,33 @@ export default class UploadEditController extends Controller { }) } + async _save(mode) { + if (!this._state || !this._state.session) { + return; + } + + try { + let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/save`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ mode }) + }); + + if (!resp.ok) { + console.error("Save failed:", resp.statusText); + return; + } + + let result = await resp.json(); + window.location.href = `/sites/${this.siteIdValue}/uploads/${result.upload_id}`; + } catch (e) { + console.error(e); + } + } + async _doReturningState(fn) { try { this._state = await fn(); diff --git a/cmds/server.go b/cmds/server.go index 7a1445a..28e2ccc 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -156,6 +156,7 @@ Starting weiro without any arguments will start the server. siteGroup.Patch("/imageedit/:sessionID", ieh.PatchSession) siteGroup.Post("/imageedit/:sessionID/processors", ieh.AddProcessor) siteGroup.Delete("/imageedit/:sessionID/processors/:processorID", ieh.DeleteProcessor) + siteGroup.Post("/imageedit/:sessionID/save", ieh.Save) siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) siteGroup.Get("/settings", ssh.General) diff --git a/handlers/imageedit.go b/handlers/imageedit.go index ced7f75..27a01b0 100644 --- a/handlers/imageedit.go +++ b/handlers/imageedit.go @@ -114,6 +114,27 @@ func (ieh ImageEditHandlers) DeleteProcessor(c fiber.Ctx) error { }) } +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"` diff --git a/providers/db/gen/sqlgen/categories.sql.go b/providers/db/gen/sqlgen/categories.sql.go index d5bc40d..95a26e5 100644 --- a/providers/db/gen/sqlgen/categories.sql.go +++ b/providers/db/gen/sqlgen/categories.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: categories.sql package sqlgen diff --git a/providers/db/gen/sqlgen/db.go b/providers/db/gen/sqlgen/db.go index 8eab959..7d9d9e7 100644 --- a/providers/db/gen/sqlgen/db.go +++ b/providers/db/gen/sqlgen/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 package sqlgen diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 3df1193..348c1ab 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 package sqlgen diff --git a/providers/db/gen/sqlgen/pages.sql.go b/providers/db/gen/sqlgen/pages.sql.go index 1d53291..7dd5105 100644 --- a/providers/db/gen/sqlgen/pages.sql.go +++ b/providers/db/gen/sqlgen/pages.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: pages.sql package sqlgen diff --git a/providers/db/gen/sqlgen/pending_uploads.sql.go b/providers/db/gen/sqlgen/pending_uploads.sql.go index 63eeb60..a831bbe 100644 --- a/providers/db/gen/sqlgen/pending_uploads.sql.go +++ b/providers/db/gen/sqlgen/pending_uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: pending_uploads.sql package sqlgen diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index ef3d170..129a49a 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: posts.sql package sqlgen diff --git a/providers/db/gen/sqlgen/pubtargets.sql.go b/providers/db/gen/sqlgen/pubtargets.sql.go index 69c09df..cd5cfa6 100644 --- a/providers/db/gen/sqlgen/pubtargets.sql.go +++ b/providers/db/gen/sqlgen/pubtargets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: pubtargets.sql package sqlgen diff --git a/providers/db/gen/sqlgen/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go index 80ccbc0..797eaad 100644 --- a/providers/db/gen/sqlgen/sites.sql.go +++ b/providers/db/gen/sqlgen/sites.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: sites.sql package sqlgen diff --git a/providers/db/gen/sqlgen/uploads.sql.go b/providers/db/gen/sqlgen/uploads.sql.go index 189de2d..7ad3828 100644 --- a/providers/db/gen/sqlgen/uploads.sql.go +++ b/providers/db/gen/sqlgen/uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: uploads.sql package sqlgen @@ -18,7 +18,7 @@ func (q *Queries) DeleteUpload(ctx context.Context, id int64) error { return err } -const insertUpload = `-- name: InsertUpload :exec +const insertUpload = `-- name: InsertUpload :one INSERT INTO uploads ( site_id, guid, @@ -43,8 +43,8 @@ type InsertUploadParams struct { CreatedAt int64 } -func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) error { - _, err := q.db.ExecContext(ctx, insertUpload, +func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertUpload, arg.SiteID, arg.Guid, arg.MimeType, @@ -54,7 +54,9 @@ func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) erro arg.Alt, arg.CreatedAt, ) - return err + var id int64 + err := row.Scan(&id) + return id, err } const selectUploadByID = `-- name: SelectUploadByID :one @@ -154,3 +156,17 @@ func (q *Queries) UpdateUpload(ctx context.Context, arg UpdateUploadParams) erro _, err := q.db.ExecContext(ctx, updateUpload, arg.Alt, arg.ID) return err } + +const updateUploadFileSize = `-- name: UpdateUploadFileSize :exec +UPDATE uploads SET file_size = ? WHERE id = ? +` + +type UpdateUploadFileSizeParams struct { + FileSize int64 + ID int64 +} + +func (q *Queries) UpdateUploadFileSize(ctx context.Context, arg UpdateUploadFileSizeParams) error { + _, err := q.db.ExecContext(ctx, updateUploadFileSize, arg.FileSize, arg.ID) + return err +} diff --git a/providers/db/gen/sqlgen/users.sql.go b/providers/db/gen/sqlgen/users.sql.go index 6007589..a70a3bf 100644 --- a/providers/db/gen/sqlgen/users.sql.go +++ b/providers/db/gen/sqlgen/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: users.sql package sqlgen diff --git a/providers/db/uploads.go b/providers/db/uploads.go index 006b7cc..b3033ab 100644 --- a/providers/db/uploads.go +++ b/providers/db/uploads.go @@ -44,7 +44,7 @@ func (db *Provider) SelectUploadBySiteIDAndSlug(ctx context.Context, siteID int6 func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error { if upload.ID == 0 { - if err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{ + newID, err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{ SiteID: upload.SiteID, Guid: upload.GUID, MimeType: upload.MIMEType, @@ -53,9 +53,11 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error Slug: upload.Slug, Alt: upload.Alt, CreatedAt: upload.CreatedAt.Unix(), - }); err != nil { + }) + if err != nil { return err } + upload.ID = newID return nil } @@ -65,6 +67,13 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error }) } +func (db *Provider) UpdateUploadFileSize(ctx context.Context, id int64, fileSize int64) error { + return db.queries.UpdateUploadFileSize(ctx, sqlgen.UpdateUploadFileSizeParams{ + FileSize: fileSize, + ID: id, + }) +} + func (db *Provider) DeleteUpload(ctx context.Context, id int64) error { return db.queries.DeleteUpload(ctx, id) } diff --git a/providers/uploadfiles/provider.go b/providers/uploadfiles/provider.go index 2eb84e4..610a6f9 100644 --- a/providers/uploadfiles/provider.go +++ b/providers/uploadfiles/provider.go @@ -66,6 +66,11 @@ func copyFile(src, dst string) error { return err } +func (p *Provider) ReplaceFile(site models.Site, up models.Upload, srcPath string) error { + fullPath := p.uploadFileName(site, up) + return copyFile(srcPath, fullPath) +} + func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser, error) { fullPath := p.uploadFileName(site, up) return os.Open(fullPath) diff --git a/services/imgedit/service.go b/services/imgedit/service.go index c53a37e..926633c 100644 --- a/services/imgedit/service.go +++ b/services/imgedit/service.go @@ -175,6 +175,58 @@ func (s *Service) UpdateProcessor(ctx context.Context, sessionID string, req Upd 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 { diff --git a/services/imgedit/store.go b/services/imgedit/store.go index 7638dbe..df3403a 100644 --- a/services/imgedit/store.go +++ b/services/imgedit/store.go @@ -42,6 +42,10 @@ func (ss *sessionStore) get(guid string) (*models.ImageEditSession, error) { 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 { diff --git a/services/uploads/manage.go b/services/uploads/manage.go index 32debac..9cb24ea 100644 --- a/services/uploads/manage.go +++ b/services/uploads/manage.go @@ -6,7 +6,10 @@ import ( "html/template" "io" "log" + "os" + "path/filepath" "strings" + "time" "lmika.dev/lmika/weiro/models" ) @@ -67,6 +70,75 @@ func (s *Service) renderCopyTemplate(upload models.Upload) string { return sb.String() } +func (s *Service) ReplaceUploadFile(ctx context.Context, uploadID int64, srcPath string) (models.Upload, error) { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.Upload{}, err + } + + upload, err := s.db.SelectUploadByID(ctx, uploadID) + if err != nil { + return models.Upload{}, err + } else if upload.SiteID != site.ID { + return models.Upload{}, models.NotFoundError + } + + if err := s.up.ReplaceFile(site, upload, srcPath); err != nil { + return models.Upload{}, err + } + + stat, err := os.Stat(srcPath) + if err != nil { + return models.Upload{}, err + } + upload.FileSize = stat.Size() + + if err := s.db.UpdateUploadFileSize(ctx, upload.ID, upload.FileSize); err != nil { + return models.Upload{}, err + } + + return upload, nil +} + +func (s *Service) CreateUploadFromFile(ctx context.Context, srcPath string, filename string, mimeType string) (models.Upload, error) { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.Upload{}, err + } + + stat, err := os.Stat(srcPath) + if err != nil { + return models.Upload{}, err + } + + newUploadGUID := models.NewNanoID() + newTime := time.Now().UTC() + newSlug := filepath.Join( + fmt.Sprintf("%04d", newTime.Year()), + fmt.Sprintf("%02d", newTime.Month()), + newUploadGUID+filepath.Ext(filename), + ) + + newUpload := models.Upload{ + SiteID: site.ID, + GUID: models.NewNanoID(), + FileSize: stat.Size(), + MIMEType: mimeType, + Filename: filename, + CreatedAt: newTime, + Slug: newSlug, + } + if err := s.db.SaveUpload(ctx, &newUpload); err != nil { + return models.Upload{}, err + } + + if err := s.up.AdoptFile(site, newUpload, srcPath); err != nil { + return models.Upload{}, err + } + + return newUpload, nil +} + func (s *Service) ListUploads(ctx context.Context) (res []UploadWithURL, _ error) { site, _, err := s.fetchSiteAndUser(ctx) if err != nil { diff --git a/sql/queries/uploads.sql b/sql/queries/uploads.sql index fc8b82d..f661591 100644 --- a/sql/queries/uploads.sql +++ b/sql/queries/uploads.sql @@ -7,7 +7,7 @@ SELECT * FROM uploads WHERE id = ? LIMIT 1; -- name: SelectUploadBySiteIDAndSlug :one SELECT * FROM uploads WHERE site_id = ? AND slug = ? LIMIT 1; --- name: InsertUpload :exec +-- name: InsertUpload :one INSERT INTO uploads ( site_id, guid, @@ -23,5 +23,8 @@ RETURNING id; -- name: UpdateUpload :exec UPDATE uploads SET alt = ? WHERE id = ?; +-- name: UpdateUploadFileSize :exec +UPDATE uploads SET file_size = ? WHERE id = ?; + -- name: DeleteUpload :exec DELETE FROM uploads WHERE id = ?; \ No newline at end of file diff --git a/views/uploads/edit.html b/views/uploads/edit.html index 21b41bb..a7b27ab 100644 --- a/views/uploads/edit.html +++ b/views/uploads/edit.html @@ -24,7 +24,7 @@
-
+
diff --git a/views/uploads/show.html b/views/uploads/show.html index 087c10f..7b42a38 100644 --- a/views/uploads/show.html +++ b/views/uploads/show.html @@ -5,7 +5,10 @@ data-show-upload-site-id-value="{{ .upload.Upload.SiteID }}" data-show-upload-upload-id-value="{{ .upload.Upload.ID }}"> - + + Edit + +
From 98828a48498259d48cbc1a6faff70ab8809cd73f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 28 Mar 2026 21:45:54 +1100 Subject: [PATCH 10/15] Removed some unused code --- services/imgedit/processing.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go index c1f99bf..ec84199 100644 --- a/services/imgedit/processing.go +++ b/services/imgedit/processing.go @@ -6,7 +6,6 @@ import ( "fmt" "image" "image/color" - "log" "os" "path/filepath" @@ -80,8 +79,6 @@ func (s *Service) reprocess(ctx context.Context, session *models.ImageEditSessio img = imageImageSource{resImg} } - log.Printf("result of processed image: %T", img) - return img, nil } @@ -105,10 +102,6 @@ func (s *Service) processImage(ctx context.Context, srcImg image.Image, processo defer f.Close() return imaging.Decode(f) - //case "shadow": - // shadow := makeBoxShadow(srcImg, color.Black, 4, 10, 0) - // composit := imaging.OverlayCenter(shadow, srcImg, 1.0) - // return composit, nil } proc, ok := processors[processor.Type] From 023574aac6a039e65329b52a283c002b426c2549 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 09:33:24 +1100 Subject: [PATCH 11/15] Removed the login challenge --- handlers/login.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/handlers/login.go b/handlers/login.go index 30ed0b4..34c1e96 100644 --- a/handlers/login.go +++ b/handlers/login.go @@ -37,9 +37,8 @@ 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"` - LoginChallenge string `form:"_login_challenge"` + Username string `form:"username"` + Password string `form:"password"` } if err := c.Bind().Body(&req); err != nil { return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body") @@ -51,11 +50,6 @@ 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") From 9b20665d11b039d07f5142c393610e44cd25079b Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 10:45:45 +1100 Subject: [PATCH 12/15] Added support for footnotes and fixed category AJAX post --- assets/js/controllers/postedit.js | 10 ++++++++++ providers/markdown/renderer.go | 4 ++-- providers/sitebuilder/builder.go | 1 + providers/sitebuilder/processors.go | 5 +++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/assets/js/controllers/postedit.js b/assets/js/controllers/postedit.js index 71328e3..f800c44 100644 --- a/assets/js/controllers/postedit.js +++ b/assets/js/controllers/postedit.js @@ -60,6 +60,16 @@ export default class PosteditController extends Controller { try { const formData = new FormData(this.element); let data = Object.fromEntries(formData.entries()); + + // Special handling for categories + let categoryIDs = []; + for (let i of formData.entries()) { + if (i[0] === "category_ids") { + categoryIDs.push(parseInt(i[1])) + } + } + + data["category_ids"] = categoryIDs; data = {...data, action: action || 'save'}; const response = await fetch(this.element.getAttribute("action"), { diff --git a/providers/markdown/renderer.go b/providers/markdown/renderer.go index aedd184..828ba96 100644 --- a/providers/markdown/renderer.go +++ b/providers/markdown/renderer.go @@ -22,7 +22,7 @@ type Renderer struct { func NewRendererForUI() *Renderer { mdParser := goldmark.New( - goldmark.WithExtensions(extension.GFM), + goldmark.WithExtensions(extension.GFM, extension.Footnote), goldmark.WithRendererOptions( gm_html.WithUnsafe(), ), @@ -48,7 +48,7 @@ func NewRendererForUI() *Renderer { func NewRendererForSite() *Renderer { mdParser := goldmark.New( - goldmark.WithExtensions(extension.GFM), + goldmark.WithExtensions(extension.GFM, extension.Footnote), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 71ce926..d0bf17b 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -49,6 +49,7 @@ func New(site pubmodel.Site, opts Options) (*Builder, error) { mdRenderer: markdown.NewRendererForSite(), postMDProcessors: []postMDProcessor{ uploadAbsoluteURL, + removeFootnoteHRs, }, }, nil } diff --git a/providers/sitebuilder/processors.go b/providers/sitebuilder/processors.go index c699160..605d077 100644 --- a/providers/sitebuilder/processors.go +++ b/providers/sitebuilder/processors.go @@ -35,3 +35,8 @@ 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 +} From deca23b5995a3540b5d2bc489197641c2153c2b3 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 12:26:05 +1100 Subject: [PATCH 13/15] Fixed ordering of published posts --- providers/db/categories.go | 2 +- providers/db/gen/sqlgen/categories.sql.go | 8 ++-- providers/db/gen/sqlgen/posts.sql.go | 48 +++++++++++++++++++++++ providers/db/posts.go | 17 ++++++++ services/publisher/iter.go | 6 +-- services/publisher/service.go | 2 +- sql/queries/categories.sql | 2 +- sql/queries/posts.sql | 6 +++ 8 files changed, 81 insertions(+), 10 deletions(-) diff --git a/providers/db/categories.go b/providers/db/categories.go index 72fac94..f8db6d3 100644 --- a/providers/db/categories.go +++ b/providers/db/categories.go @@ -82,7 +82,7 @@ func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([ return cats, nil } -func (db *Provider) SelectPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) { +func (db *Provider) SelectPublishedPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) { rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{ CategoryID: categoryID, Limit: pp.Limit, diff --git a/providers/db/gen/sqlgen/categories.sql.go b/providers/db/gen/sqlgen/categories.sql.go index 95a26e5..f6a291f 100644 --- a/providers/db/gen/sqlgen/categories.sql.go +++ b/providers/db/gen/sqlgen/categories.sql.go @@ -227,7 +227,7 @@ func (q *Queries) SelectCategoryBySlugAndSite(ctx context.Context, arg SelectCat return i, err } -const selectPostsOfCategory = `-- name: SelectPostsOfCategory :many +const selectPublishedPostsOfCategory = `-- name: SelectPublishedPostsOfCategory :many SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at 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 @@ -235,14 +235,14 @@ ORDER BY p.published_at DESC LIMIT ? OFFSET ? ` -type SelectPostsOfCategoryParams struct { +type SelectPublishedPostsOfCategoryParams struct { CategoryID int64 Limit int64 Offset int64 } -func (q *Queries) SelectPostsOfCategory(ctx context.Context, arg SelectPostsOfCategoryParams) ([]Post, error) { - rows, err := q.db.QueryContext(ctx, selectPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset) +func (q *Queries) SelectPublishedPostsOfCategory(ctx context.Context, arg SelectPublishedPostsOfCategoryParams) ([]Post, error) { + rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset) if err != nil { return nil, err } diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index 129a49a..b1d3afb 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -200,6 +200,54 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSitePa return items, nil } +const selectPublishedPostsOfSite = `-- name: SelectPublishedPostsOfSite :many +SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at +FROM posts +WHERE site_id = ?1 AND state = 0 AND deleted_at = 0 +ORDER BY published_at DESC LIMIT ?3 OFFSET ?2 +` + +type SelectPublishedPostsOfSiteParams struct { + SiteID int64 + Offset int64 + Limit int64 +} + +func (q *Queries) SelectPublishedPostsOfSite(ctx context.Context, arg SelectPublishedPostsOfSiteParams) ([]Post, error) { + rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfSite, arg.SiteID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Post + for rows.Next() { + var i Post + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.State, + &i.Guid, + &i.Title, + &i.Body, + &i.Slug, + &i.CreatedAt, + &i.UpdatedAt, + &i.PublishedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const softDeletePost = `-- name: SoftDeletePost :exec UPDATE posts SET deleted_at = ? WHERE id = ? ` diff --git a/providers/db/posts.go b/providers/db/posts.go index 7f58d1a..3b86aaf 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -47,6 +47,23 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDel return posts, nil } +func (db *Provider) SelectPublishedPostsOfSite(ctx context.Context, siteID int64, pp PagingParams) ([]*models.Post, error) { + rows, err := db.queries.SelectPublishedPostsOfSite(ctx, sqlgen.SelectPublishedPostsOfSiteParams{ + SiteID: siteID, + Limit: pp.Limit, + Offset: pp.Offset, + }) + if err != nil { + return nil, err + } + + posts := make([]*models.Post, len(rows)) + for i, row := range rows { + posts[i] = dbPostToPost(row) + } + return posts, nil +} + func (db *Provider) SelectPost(ctx context.Context, postID int64) (*models.Post, error) { row, err := db.queries.SelectPost(ctx, postID) if err != nil { diff --git a/services/publisher/iter.go b/services/publisher/iter.go index ea70616..d07d4fe 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) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { +func (s *Publisher) publishedPostIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { return func(yield func(models.Maybe[*models.Post]) bool) { paging := db.PagingParams{Offset: 0, Limit: 50} - page, err := s.db.SelectPostsOfSite(ctx, site, false, paging) + page, err := s.db.SelectPublishedPostsOfSite(ctx, site, paging) if err != nil { yield(models.Maybe[*models.Post]{Err: err}) return @@ -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.SelectPostsOfCategory(ctx, categoryID, paging) + page, err := s.db.SelectPublishedPostsOfCategory(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 adfcdd7..a5072a5 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -79,7 +79,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.postIter(ctx, site.ID) + return p.publishedPostIter(ctx, site.ID) }, BaseURL: target.BaseURL, Uploads: uploads, diff --git a/sql/queries/categories.sql b/sql/queries/categories.sql index 4b48506..b8e0e64 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: SelectPostsOfCategory :many +-- name: SelectPublishedPostsOfCategory :many SELECT p.* FROM posts p INNER JOIN post_categories pc ON pc.post_id = p.id WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0 diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 5a4c18e..feaae7f 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -17,6 +17,12 @@ WHERE site_id = sqlc.arg(site_id) AND ( END ) ORDER BY created_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset); +-- name: SelectPublishedPostsOfSite :many +SELECT * +FROM posts +WHERE site_id = sqlc.arg(site_id) AND state = 0 AND deleted_at = 0 +ORDER BY published_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset); + -- name: SelectPost :one SELECT * FROM posts WHERE id = ? LIMIT 1; From d21aeadd5655adc431f5effa780e46909c28cd40 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 20:29:42 +1100 Subject: [PATCH 14/15] Fixed build --- providers/db/categories.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/db/categories.go b/providers/db/categories.go index f8db6d3..23a9e67 100644 --- a/providers/db/categories.go +++ b/providers/db/categories.go @@ -83,7 +83,7 @@ func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([ } func (db *Provider) SelectPublishedPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) { - rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{ + rows, err := db.queries.SelectPublishedPostsOfCategory(ctx, sqlgen.SelectPublishedPostsOfCategoryParams{ CategoryID: categoryID, Limit: pp.Limit, Offset: pp.Offset, From a3197f9b11f4802d7f6fc5e34223d4a0678cea3d Mon Sep 17 00:00:00 2001 From: lmika Date: Thu, 9 Apr 2026 11:40:52 +0000 Subject: [PATCH 15/15] Add Obsidian vault import feature (#8) - New 'Import Obsidian' action on site settings page - Upload a zip file of an Obsidian vault to import all notes as posts - Markdown notes imported with title from filename, published date from file timestamp, and body with front-matter stripped - Images and other attachments saved as Upload records - New obsimport service handles zip traversal and import logic - Unit tests for front-matter stripping Co-authored-by: Shelley Co-authored-by: exe.dev user Reviewed-on: https://lmika.dev/lmika/weiro/pulls/8 --- cmds/server.go | 4 + handlers/obsimport.go | 50 +++++++ services/obsimport/service.go | 229 +++++++++++++++++++++++++++++ services/obsimport/service_test.go | 51 +++++++ services/services.go | 4 + views/obsimport/form.html | 21 +++ views/obsimport/result.html | 10 ++ views/sitesettings/general.html | 7 + 8 files changed, 376 insertions(+) create mode 100644 handlers/obsimport.go create mode 100644 services/obsimport/service.go create mode 100644 services/obsimport/service_test.go create mode 100644 views/obsimport/form.html create mode 100644 views/obsimport/result.html diff --git a/cmds/server.go b/cmds/server.go index 28e2ccc..29a8c2a 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -115,6 +115,7 @@ Starting weiro without any arguments will start the server. ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} pgh := handlers.PagesHandler{PageService: svcs.Pages} + oih := handlers.ObsImportHandler{ObsImportService: svcs.ObsImport, ScratchDir: cfg.ScratchDir} app.Get("/login", lh.Login) app.Post("/login", lh.DoLogin) @@ -162,6 +163,9 @@ Starting weiro without any arguments will start the server. siteGroup.Get("/settings", ssh.General) siteGroup.Post("/settings", ssh.UpdateGeneral) + siteGroup.Get("/import/obsidian", oih.Form) + siteGroup.Post("/import/obsidian", oih.Upload) + siteGroup.Get("/categories", ch.Index) siteGroup.Get("/categories/new", ch.New) siteGroup.Get("/categories/:categoryID", ch.Edit) diff --git a/handlers/obsimport.go b/handlers/obsimport.go new file mode 100644 index 0000000..e20be77 --- /dev/null +++ b/handlers/obsimport.go @@ -0,0 +1,50 @@ +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/services/obsimport/service.go b/services/obsimport/service.go new file mode 100644 index 0000000..0852031 --- /dev/null +++ b/services/obsimport/service.go @@ -0,0 +1,229 @@ +package obsimport + +import ( + "archive/zip" + "bufio" + "context" + "fmt" + "io" + "log" + "mime" + "os" + "path/filepath" + "strings" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/providers/uploadfiles" + "lmika.dev/lmika/weiro/services/publisher" +) + +type Service struct { + db *db.Provider + up *uploadfiles.Provider + publisher *publisher.Queue + scratchDir string +} + +func New(db *db.Provider, up *uploadfiles.Provider, publisher *publisher.Queue, scratchDir string) *Service { + return &Service{ + db: db, + up: up, + publisher: publisher, + scratchDir: scratchDir, + } +} + +type ImportResult struct { + PostsImported int + UploadsImported int +} + +func (s *Service) ImportZip(ctx context.Context, zipPath string) (ImportResult, error) { + site, ok := models.GetSite(ctx) + if !ok { + return ImportResult{}, models.SiteRequiredError + } + + zr, err := zip.OpenReader(zipPath) + if err != nil { + return ImportResult{}, fmt.Errorf("open zip: %w", err) + } + defer zr.Close() + + var result ImportResult + + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + + ext := strings.ToLower(filepath.Ext(f.Name)) + if ext == ".md" || ext == ".markdown" { + if err := s.importNote(ctx, site, f); err != nil { + log.Printf("warn: skipping note %s: %v", f.Name, err) + continue + } + result.PostsImported++ + } else if isAttachment(ext) { + if err := s.importAttachment(ctx, site, f); err != nil { + log.Printf("warn: skipping attachment %s: %v", f.Name, err) + continue + } + result.UploadsImported++ + } + } + + s.publisher.Queue(site) + + return result, nil +} + +func (s *Service) importNote(ctx context.Context, site models.Site, f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return err + } + + body := stripFrontMatter(string(data)) + title := strings.TrimSuffix(filepath.Base(f.Name), filepath.Ext(f.Name)) + publishedAt := f.Modified + if publishedAt.IsZero() { + publishedAt = time.Now() + } + + renderTZ, err := time.LoadLocation(site.Timezone) + if err != nil { + renderTZ = time.UTC + } + publishedAt = publishedAt.In(renderTZ) + + post := &models.Post{ + SiteID: site.ID, + GUID: models.NewNanoID(), + State: models.StatePublished, + Title: title, + Body: body, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + PublishedAt: publishedAt, + } + post.Slug = post.BestSlug() + + return s.db.SavePost(ctx, post) +} + +func (s *Service) importAttachment(ctx context.Context, site models.Site, f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + // Write to a temp file in scratch dir + if err := os.MkdirAll(s.scratchDir, 0755); err != nil { + return err + } + + tmpFile, err := os.CreateTemp(s.scratchDir, "obsimport-*"+filepath.Ext(f.Name)) + if err != nil { + return err + } + tmpPath := tmpFile.Name() + + if _, err := io.Copy(tmpFile, rc); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return err + } + tmpFile.Close() + + filename := filepath.Base(f.Name) + mimeType := mime.TypeByExtension(filepath.Ext(filename)) + if mimeType == "" { + mimeType = "application/octet-stream" + } + + stat, err := os.Stat(tmpPath) + if err != nil { + os.Remove(tmpPath) + return err + } + + newUploadGUID := models.NewNanoID() + newTime := time.Now().UTC() + newSlug := filepath.Join( + fmt.Sprintf("%04d", newTime.Year()), + fmt.Sprintf("%02d", newTime.Month()), + newUploadGUID+filepath.Ext(filename), + ) + + newUpload := models.Upload{ + SiteID: site.ID, + GUID: models.NewNanoID(), + FileSize: stat.Size(), + MIMEType: mimeType, + Filename: filename, + CreatedAt: newTime, + Slug: newSlug, + } + if err := s.db.SaveUpload(ctx, &newUpload); err != nil { + os.Remove(tmpPath) + return err + } + + if err := s.up.AdoptFile(site, newUpload, tmpPath); err != nil { + os.Remove(tmpPath) + return err + } + + return nil +} + +// stripFrontMatter removes YAML front matter (delimited by ---) from markdown content. +func stripFrontMatter(content string) string { + scanner := bufio.NewScanner(strings.NewReader(content)) + + // Check if the first line is a front matter delimiter + if !scanner.Scan() { + return content + } + firstLine := strings.TrimSpace(scanner.Text()) + if firstLine != "---" { + return content + } + + // Skip until the closing --- + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) == "---" { + // Return everything after the closing delimiter + var rest strings.Builder + for scanner.Scan() { + rest.WriteString(scanner.Text()) + rest.WriteString("\n") + } + return strings.TrimLeft(rest.String(), "\n") + } + } + + // No closing delimiter found, return original content + return content +} + +var attachmentExts = map[string]bool{ + ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, + ".bmp": true, ".ico": true, ".tiff": true, ".tif": true, + ".mp3": true, ".mp4": true, ".wav": true, ".ogg": true, ".webm": true, + ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, +} + +func isAttachment(ext string) bool { + return attachmentExts[ext] +} diff --git a/services/obsimport/service_test.go b/services/obsimport/service_test.go new file mode 100644 index 0000000..51123de --- /dev/null +++ b/services/obsimport/service_test.go @@ -0,0 +1,51 @@ +package obsimport + +import "testing" + +func TestStripFrontMatter(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "no front matter", + input: "Hello world\nThis is a note", + want: "Hello world\nThis is a note", + }, + { + name: "with front matter", + input: "---\ntitle: Test\ntags: [a, b]\n---\nHello world\nThis is a note\n", + want: "Hello world\nThis is a note\n", + }, + { + name: "only front matter", + input: "---\ntitle: Test\n---\n", + want: "", + }, + { + name: "unclosed front matter", + input: "---\ntitle: Test\nno closing delimiter", + want: "---\ntitle: Test\nno closing delimiter", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "front matter with leading newlines stripped", + input: "---\nkey: val\n---\n\n\nBody here\n", + want: "Body here\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripFrontMatter(tt.input) + if got != tt.want { + t.Errorf("stripFrontMatter() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/services/services.go b/services/services.go index ab1a4ca..a79e903 100644 --- a/services/services.go +++ b/services/services.go @@ -9,6 +9,7 @@ import ( "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" @@ -27,6 +28,7 @@ type Services struct { ImageEdit *imgedit.Service Categories *categories.Service Pages *pages.Service + ObsImport *obsimport.Service } func New(cfg config.Config) (*Services, error) { @@ -46,6 +48,7 @@ func New(cfg config.Config) (*Services, error) { 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,6 +61,7 @@ func New(cfg config.Config) (*Services, error) { ImageEdit: imageEditService, Categories: categoriesService, Pages: pagesService, + ObsImport: obsImportService, }, nil } diff --git a/views/obsimport/form.html b/views/obsimport/form.html new file mode 100644 index 0000000..ccb27a5 --- /dev/null +++ b/views/obsimport/form.html @@ -0,0 +1,21 @@ +
+
+
Import from Obsidian
+

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.

+
+
+ +
+ +
+
+
+
+
+ + Cancel +
+
+
+
+
diff --git a/views/obsimport/result.html b/views/obsimport/result.html new file mode 100644 index 0000000..15ebe31 --- /dev/null +++ b/views/obsimport/result.html @@ -0,0 +1,10 @@ +
+
+
Import Complete
+
+

Successfully imported {{ .result.PostsImported }} post(s) and {{ .result.UploadsImported }} upload(s).

+
+ Go to Posts + Back to Settings +
+
diff --git a/views/sitesettings/general.html b/views/sitesettings/general.html index 6f1833b..c0989c5 100644 --- a/views/sitesettings/general.html +++ b/views/sitesettings/general.html @@ -66,5 +66,12 @@
+
+
+
+ Import Obsidian + Import posts and attachments from an Obsidian vault zip file. +
+
\ No newline at end of file