From e77cac2fd5e59248a94c15a3cbf1fbb9bc3da7c5 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 21 Feb 2026 10:22:10 +1100 Subject: [PATCH] Started working on the frontend - Added the new post frontend - Hooked up publishing of posts to the site publisher - Added an site exporter as a publishing target --- .air.toml | 46 ++ .gitignore | 2 + Makefile | 19 +- _test-site/posts/2026/02/18-first-post.md | 5 +- _test-site/posts/2026/02/19-about-a-db.md | 5 +- .../posts/2026/02/20-another-publish-tests.md | 8 + .../posts/2026/02/20-export-as-publish.md | 8 + .../posts/2026/02/20-first-attempt-at.md | 14 + _test-site/posts/2026/02/20-netlify.md | 9 +- _test-site/posts/2026/02/20-success.md | 13 + _test-site/site.yaml | 5 +- assets/css/main.css | 15 + go.mod | 23 +- go.sum | 37 ++ .../importexport}/models.go | 6 +- handlers/middleware/site.go | 41 ++ handlers/middleware/user.go | 24 + handlers/posts.go | 37 ++ main.go | 98 ++-- models/ctx.go | 11 + models/errors.go | 2 + models/ids.go | 8 + models/ids_test.go | 34 ++ models/posts.go | 66 ++- models/posts_test.go | 78 +++ package-lock.json | 529 ++++++++++++++++++ package.json | 8 + providers/db/errors.go | 11 + providers/db/gen/sqlgen/posts.sql.go | 48 ++ providers/db/posts.go | 42 +- providers/siteexporter/exporter.go | 80 +++ services/{importer => import}/service.go | 5 +- services/posts/service.go | 78 +++ services/publisher/service.go | 31 +- sql/queries/posts.sql | 13 +- sql/schema/01_init.up.sql | 3 +- views/_common/nav.html | 20 + views/layouts/main.html | 14 + views/posts/index.html | 1 + views/posts/new.html | 14 + 40 files changed, 1427 insertions(+), 84 deletions(-) create mode 100644 .air.toml create mode 100644 _test-site/posts/2026/02/20-another-publish-tests.md create mode 100644 _test-site/posts/2026/02/20-export-as-publish.md create mode 100644 _test-site/posts/2026/02/20-first-attempt-at.md create mode 100644 _test-site/posts/2026/02/20-success.md create mode 100644 assets/css/main.css rename {providers/sitereader => handlers/importexport}/models.go (85%) create mode 100644 handlers/middleware/site.go create mode 100644 handlers/middleware/user.go create mode 100644 handlers/posts.go create mode 100644 models/ids.go create mode 100644 models/ids_test.go create mode 100644 models/posts_test.go create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 providers/db/errors.go create mode 100644 providers/siteexporter/exporter.go rename services/{importer => import}/service.go (92%) create mode 100644 services/posts/service.go create mode 100644 views/_common/nav.html create mode 100644 views/layouts/main.html create mode 100644 views/posts/index.html create mode 100644 views/posts/new.html diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..e5f4efe --- /dev/null +++ b/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./build/weiro" + cmd = "make build" + delay = 1000 + exclude_dir = ["static", "build", "node_modules"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "css", "js"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = true + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore index 3703748..9117a62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ build/ .idea/ +node_modules/ +static/assets/ # Local Netlify folder .netlify diff --git a/Makefile b/Makefile index 0b1af52..69630b2 100644 --- a/Makefile +++ b/Makefile @@ -2,10 +2,27 @@ BUILD_DIR=build all: clean build +.Phony: init +init: + npm install --save-exact --save-dev esbuild + .Phony: clean clean: -rm -r $(BUILD_DIR) +.Phony: frontend +frontend: + npm install + npx esbuild --bundle ./assets/css/main.css --outfile=./static/assets/main.css + .Phony: gen gen: - go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 generate \ No newline at end of file + go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 generate + +.Phony: build +build: frontend + go build -o ./build/weiro + +.Phony: run +run: build + ./build/weiro \ No newline at end of file diff --git a/_test-site/posts/2026/02/18-first-post.md b/_test-site/posts/2026/02/18-first-post.md index 4843f0c..e1806ea 100644 --- a/_test-site/posts/2026/02/18-first-post.md +++ b/_test-site/posts/2026/02/18-first-post.md @@ -1,7 +1,10 @@ --- -date: 2026-02-18T22:17:00+11:00 +id: atHn1bZa7Z0D title: First Post +date: 2026-02-18T11:17:00Z +tags: [] slug: /2026/02/18/first-post + --- Hello World! diff --git a/_test-site/posts/2026/02/19-about-a-db.md b/_test-site/posts/2026/02/19-about-a-db.md index 7e386a6..6a7288f 100644 --- a/_test-site/posts/2026/02/19-about-a-db.md +++ b/_test-site/posts/2026/02/19-about-a-db.md @@ -1,7 +1,10 @@ --- -date: 2026-02-19T22:17:00+11:00 +id: ANRTjDSaCZcj title: About a DB +date: 2026-02-19T11:17:00Z +tags: [] slug: /2026/02/19/about-a-db + --- Hello again. diff --git a/_test-site/posts/2026/02/20-another-publish-tests.md b/_test-site/posts/2026/02/20-another-publish-tests.md new file mode 100644 index 0000000..afba58f --- /dev/null +++ b/_test-site/posts/2026/02/20-another-publish-tests.md @@ -0,0 +1,8 @@ +--- +id: XFpDGhCLRSiV +title: Another Publish Tests +date: 2026-02-20T22:51:32Z +tags: [] +slug: /2026/02/21/another-publish-tests +--- +Okay, that wasn't as smooth sailing as I was hoping for. I forgot to set the publish key so Netlify refused to publish the site. Let see how this attempt goes. \ No newline at end of file diff --git a/_test-site/posts/2026/02/20-export-as-publish.md b/_test-site/posts/2026/02/20-export-as-publish.md new file mode 100644 index 0000000..371bdc1 --- /dev/null +++ b/_test-site/posts/2026/02/20-export-as-publish.md @@ -0,0 +1,8 @@ +--- +id: yvLBWj26Zsgp +title: Export As Publish Target +date: 2026-02-20T23:17:26Z +tags: [] +slug: /2026/02/21/export-as-publish +--- +I've added a site exporter which will export the site as a series of Markdown files whenever I publish a post. With any luck, both the exporter and the Netlify publisher will work when I publish this. This will save the posts I've made so far, while allowing me to init the database without having to save new migrations. \ No newline at end of file diff --git a/_test-site/posts/2026/02/20-first-attempt-at.md b/_test-site/posts/2026/02/20-first-attempt-at.md new file mode 100644 index 0000000..c161c35 --- /dev/null +++ b/_test-site/posts/2026/02/20-first-attempt-at.md @@ -0,0 +1,14 @@ +--- +id: ARFyO2TiZ0Ju +title: First Attempt at Posting From the UI +date: 2026-02-20T22:48:58Z +tags: [] +slug: /2026/02/21/first-attempt-at +--- +If you're seeing this, then posting from the UI works. + +The UI is using service-side HTTP rendering, so there's nothing fancy going on here. I've chosen to use Boostrap for the frontend. It's a little traditional but hey, it looks good. I can't post a screenshot of the UI as Weiro doesn't have attachments yet, but I will once I have attachments working. + +The new post should have been saved in the Sqlite3 database, which should trigger a rebuild of the site and a publish to Netlify. The existing posts should still be there too. At the moment, the publishing is done inline, which won't be the case for long, but I want to make sure the publishing flow is working properly. + +Okay, here we go. \ No newline at end of file diff --git a/_test-site/posts/2026/02/20-netlify.md b/_test-site/posts/2026/02/20-netlify.md index 054c7ee..4e21b5c 100644 --- a/_test-site/posts/2026/02/20-netlify.md +++ b/_test-site/posts/2026/02/20-netlify.md @@ -1,8 +1,13 @@ --- -date: 2026-02-20T17:36:00+11:00 +id: Go4SilAxyruR title: Direct Publish To Netlify +date: 2026-02-20T06:36:00Z +tags: [] slug: /2026/02/20/netlify + --- Just a quick one right now. Integrated the Netlify client allowing direct publish to Netlify. Previous attempts were using the Netlify CLI, but after learning that Go has a Netlify client, -I have zero use for that now. \ No newline at end of file +I have zero use for that now. + +I think we're ready to start building the UI. \ No newline at end of file diff --git a/_test-site/posts/2026/02/20-success.md b/_test-site/posts/2026/02/20-success.md new file mode 100644 index 0000000..818199f --- /dev/null +++ b/_test-site/posts/2026/02/20-success.md @@ -0,0 +1,13 @@ +--- +id: f-mK1xJDUlUg +title: Success! +date: 2026-02-20T22:59:18Z +tags: [] +slug: /2026/02/21/success + +--- +Okay, publishing from the frontend works. + +Of course now I need to make sure all these posts are kept, as I would like to prevent a publish every time I make a change. So either enable a local preview mode or have a way to export the posts I save: both possible with the multiple publishing targets. And I want to be able to easily save posts as drafts, and also allow for local saves should the updated post be unable to reach the server. + +Basically, I want to make sure the writing and publishing flow is as good as it can be, otherwise I wouldn't be comfortable using it. And for any blogging CMS, having a way to write posts and be comfortable that your words won't be lost is paramount. \ No newline at end of file diff --git a/_test-site/site.yaml b/_test-site/site.yaml index 35d9cba..f3ca76b 100644 --- a/_test-site/site.yaml +++ b/_test-site/site.yaml @@ -1,6 +1,3 @@ title: Weiro tagline: A blogging CMS -base_url: https://jolly-boba-9e2486.netlify.app/ -public: - netlify: - site_id: 55c878a7-189e-42cf-aa02-5c60908143f3 \ No newline at end of file +base_url: https://jolly-boba-9e2486.netlify.app diff --git a/assets/css/main.css b/assets/css/main.css new file mode 100644 index 0000000..5b06150 --- /dev/null +++ b/assets/css/main.css @@ -0,0 +1,15 @@ +@import "bootstrap/dist/css/bootstrap.css"; + +.post-form { + display: grid; + grid-template-rows: min-content auto min-content; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; +} + +.post-form textarea { + height: 100%; +} \ No newline at end of file diff --git a/go.mod b/go.mod index c6248e4..2db351c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module lmika.dev/lmika/weiro -go 1.24.3 +go 1.25.0 require ( emperror.dev/errors v0.8.1 @@ -20,6 +20,7 @@ require ( github.com/Azure/go-autorest/tracing v0.5.0 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect github.com/cenkalti/backoff/v4 v4.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -36,27 +37,41 @@ require ( github.com/go-openapi/swag v0.19.12 // indirect github.com/go-openapi/validate v0.20.0 // indirect github.com/go-stack/stack v1.8.0 // indirect + github.com/gofiber/fiber/v3 v3.0.0 // indirect + github.com/gofiber/schema v1.6.0 // indirect + github.com/gofiber/template v1.8.3 // indirect + github.com/gofiber/template/html/v3 v3.0.2 // indirect + github.com/gofiber/template/v2 v2.1.0 // indirect + github.com/gofiber/utils/v2 v2.0.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f // indirect github.com/mailru/easyjson v0.7.6 // indirect + github.com/matoous/go-nanoid/v2 v2.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.4.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/netlify/open-api/v2 v2.49.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rsc/goversion v1.2.0 // indirect github.com/sirupsen/logrus v1.6.0 // indirect + github.com/tinylib/msgp v1.6.3 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.69.0 // indirect go.mongodb.org/mongo-driver v1.4.4 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect + golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.7.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.7.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index bdcba56..1cf5ed2 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4Rq github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= @@ -203,6 +205,18 @@ github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWe github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk= +github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY= +github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= +github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= +github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= +github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= +github.com/gofiber/template/html/v3 v3.0.2 h1:/Fh8UcEsB4uhf1QWNbYaAOwXxSORebJ2zXkb5tgG/TI= +github.com/gofiber/template/html/v3 v3.0.2/go.mod h1:9phaCZLPZq2nFNTZj9zrmR8FSA8ydtBQFL9SEsr4jqI= +github.com/gofiber/template/v2 v2.1.0 h1:vrLY6uEW2HdioJm6J5FGUpYZuapVQhHciNz21XQjR/4= +github.com/gofiber/template/v2 v2.1.0/go.mod h1:ohgpR/Ng90nJbK+IyNzrgR/XpnBNt862/oTF5G7SAmE= +github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM= +github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -286,6 +300,8 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= @@ -311,7 +327,11 @@ github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= +github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= @@ -344,6 +364,8 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9 github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -409,8 +431,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= @@ -451,6 +479,8 @@ golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -503,6 +533,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -548,6 +580,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -559,6 +593,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -592,6 +628,7 @@ golang.org/x/tools v0.0.0-20200612220849-54c614fe050c/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/providers/sitereader/models.go b/handlers/importexport/models.go similarity index 85% rename from providers/sitereader/models.go rename to handlers/importexport/models.go index c7114e0..35da57c 100644 --- a/providers/sitereader/models.go +++ b/handlers/importexport/models.go @@ -1,4 +1,4 @@ -package sitereader +package importexport import ( "time" @@ -11,13 +11,13 @@ type ReadSiteModels struct { Posts []*models.Post } -type siteMeta struct { +type Site struct { Title string `yaml:"title"` Tagline string `yaml:"tagline"` BaseURL string `yaml:"base_url"` } -type postMeta struct { +type Post struct { ID string `yaml:"id"` Title string `yaml:"title"` Date time.Time `yaml:"date"` diff --git a/handlers/middleware/site.go b/handlers/middleware/site.go new file mode 100644 index 0000000..0cc2b7a --- /dev/null +++ b/handlers/middleware/site.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "strconv" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" +) + +func RequiresSite(db *db.Provider) func(c fiber.Ctx) error { + return func(c fiber.Ctx) error { + siteIDStr := c.Params("siteID") + if siteIDStr == "" { + return fiber.ErrBadRequest + } + + siteID, err := strconv.ParseInt(siteIDStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + user, ok := models.GetUser(c.Context()) + if !ok { + return fiber.ErrUnauthorized + } + + site, err := db.SelectSiteByID(c.Context(), siteID) + if err != nil { + return fiber.ErrNotFound + } + + if site.OwnerID != user.ID { + return fiber.ErrForbidden + } + + c.Locals("site", site) + c.SetContext(models.WithSite(c.Context(), site)) + return c.Next() + } +} diff --git a/handlers/middleware/user.go b/handlers/middleware/user.go new file mode 100644 index 0000000..d36891d --- /dev/null +++ b/handlers/middleware/user.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "log" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" +) + +func AuthUser() func(c fiber.Ctx) error { + return func(c fiber.Ctx) error { + // TEMP - Actually do the auth here + user := models.User{ + ID: 1, + Username: "testuser", + } + + c.Locals("user", user) + c.SetContext(models.WithUser(c.Context(), user)) + log.Printf("User %s authenticated", user.Username) + + return c.Next() + } +} diff --git a/handlers/posts.go b/handlers/posts.go new file mode 100644 index 0000000..a2356dd --- /dev/null +++ b/handlers/posts.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "fmt" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/posts" +) + +type PostsHandler struct { + PostService *posts.Service +} + +func (ph PostsHandler) Index(c fiber.Ctx) error { + return c.Render("posts/index", fiber.Map{}) +} + +func (ph PostsHandler) New(c fiber.Ctx) error { + return c.Render("posts/new", fiber.Map{ + "guid": models.NewNanoID(), + }) +} + +func (ph PostsHandler) Update(c fiber.Ctx) error { + var req posts.CreatePostParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + post, err := ph.PostService.PublishPost(c.Context(), req) + if err != nil { + return err + } + + return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID)) +} diff --git a/main.go b/main.go index 2ba721e..e659e0c 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,16 @@ package main import ( - "context" "log" - "os" - "lmika.dev/lmika/weiro/models" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/static" + "github.com/gofiber/template/html/v3" + "lmika.dev/lmika/weiro/handlers" + "lmika.dev/lmika/weiro/handlers/middleware" "lmika.dev/lmika/weiro/providers/db" - "lmika.dev/lmika/weiro/services/importer" + "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" - _ "modernc.org/sqlite" ) @@ -20,41 +21,66 @@ func main() { } defer dbp.Close() - user, err := dbp.SelectUserByUsername(context.Background(), "testuser") - if err != nil { - user = models.User{ - Username: "testuser", - PasswordHashed: []byte("changeme"), - } - if err := dbp.SaveUser(context.Background(), &user); err != nil { - log.Fatal(err) - } - } - - importerSvc := importer.New(dbp) publisherSvc := publisher.New(dbp) - ctx := models.WithUser(context.Background(), user) + postService := posts.New(dbp, publisherSvc) - site, err := importerSvc.Import(ctx, "_test-site") - if err != nil { - log.Fatal(err) - } + //user, err := dbp.SelectUserByUsername(context.Background(), "testuser") + //if err != nil { + // user = models.User{ + // Username: "testuser", + // PasswordHashed: []byte("changeme"), + // } + // if err := dbp.SaveUser(context.Background(), &user); err != nil { + // log.Fatal(err) + // } + //} - target := models.SitePublishTarget{ - SiteID: site.ID, - BaseURL: "https://jolly-boba-9e2486.netlify.app", - TargetType: "netlify", - TargetRef: "55c878a7-189e-42cf-aa02-5c60908143f3", - TargetKey: os.Getenv("NETLIFY_AUTH_TOKEN"), - } - if err := dbp.SavePublishTarget(ctx, &target); err != nil { - log.Fatal(err) - } + app := fiber.New(fiber.Config{ + Views: html.New("./views", ".html"), + ViewsLayout: "layouts/main", + PassLocalsToViews: true, + }) - if err := publisherSvc.Publish(ctx, site.ID); err != nil { - log.Fatal(err) - } + siteGroup := app.Group("/sites/:siteID", middleware.AuthUser(), middleware.RequiresSite(dbp)) - log.Println("Done") + ph := handlers.PostsHandler{PostService: postService} + + siteGroup.Get("/posts", ph.Index) + siteGroup.Get("/posts/new", ph.New) + siteGroup.Post("/posts", ph.Update) + + app.Get("/", func(c fiber.Ctx) error { + return c.Redirect().To("/sites/1/posts") + }) + app.Get("/static/*", static.New("./static")) + + // TEMP + // + /* + dbp.SaveUser(context.Background(), &models.User{Username: "testuser"}) + + ctx := models.WithUser(context.Background(), models.User{ID: 1}) + site, err := importer.New(dbp).Import(ctx, "_test-site") + if err != nil { + log.Fatal(err) + } + + target := models.SitePublishTarget{ + SiteID: site.ID, + BaseURL: "https://jolly-boba-9e2486.netlify.app", + TargetType: "netlify", + TargetRef: "55c878a7-189e-42cf-aa02-5c60908143f3", + TargetKey: os.Getenv("NETLIFY_AUTH_TOKEN"), + } + + if err := dbp.SavePublishTarget(ctx, &target); err != nil { + log.Fatal(err) + } + */ + //if err := publisherSvc.Publish(ctx, site.ID); err != nil { + // log.Fatal(err) + //} + + log.Fatal(app.Listen(":3000")) } diff --git a/models/ctx.go b/models/ctx.go index 9ffe664..8eca001 100644 --- a/models/ctx.go +++ b/models/ctx.go @@ -3,8 +3,10 @@ package models import "context" type userKeyType struct{} +type siteKeyType struct{} var userKey = userKeyType{} +var siteKey = userKeyType{} func WithUser(ctx context.Context, user User) context.Context { return context.WithValue(ctx, userKey, user) @@ -14,3 +16,12 @@ func GetUser(ctx context.Context) (User, bool) { user, ok := ctx.Value(userKey).(User) return user, ok } + +func WithSite(ctx context.Context, site Site) context.Context { + return context.WithValue(ctx, siteKey, site) +} + +func GetSite(ctx context.Context) (Site, bool) { + site, ok := ctx.Value(siteKey).(Site) + return site, ok +} diff --git a/models/errors.go b/models/errors.go index 9b2f3c5..4a23c08 100644 --- a/models/errors.go +++ b/models/errors.go @@ -4,3 +4,5 @@ import "emperror.dev/errors" var UserRequiredError = errors.New("user required") var PermissionError = errors.New("permission denied") +var NotFoundError = errors.New("not found") +var SiteRequiredError = errors.New("site required") diff --git a/models/ids.go b/models/ids.go new file mode 100644 index 0000000..58dd928 --- /dev/null +++ b/models/ids.go @@ -0,0 +1,8 @@ +package models + +import "github.com/matoous/go-nanoid/v2" + +func NewNanoID() string { + id, _ := gonanoid.New(12) + return id +} diff --git a/models/ids_test.go b/models/ids_test.go new file mode 100644 index 0000000..e57daf0 --- /dev/null +++ b/models/ids_test.go @@ -0,0 +1,34 @@ +package models + +import ( + "testing" +) + +func TestNewNanoID(t *testing.T) { + id := NewNanoID() + + if len(id) != 12 { + t.Errorf("Expected ID length of 12, got %d", len(id)) + } + + if id == "" { + t.Error("Expected non-empty ID") + } +} + +func TestNewNanoID_Uniqueness(t *testing.T) { + ids := make(map[string]bool) + iterations := 1000 + + for i := 0; i < iterations; i++ { + id := NewNanoID() + if ids[id] { + t.Errorf("Duplicate ID generated: %s", id) + } + ids[id] = true + } + + if len(ids) != iterations { + t.Errorf("Expected %d unique IDs, got %d", iterations, len(ids)) + } +} diff --git a/models/posts.go b/models/posts.go index 9d85e6f..16184d6 100644 --- a/models/posts.go +++ b/models/posts.go @@ -1,6 +1,12 @@ package models -import "time" +import ( + "bufio" + "fmt" + "strings" + "time" + "unicode" +) type Post struct { ID int64 @@ -12,3 +18,61 @@ type Post struct { CreatedAt time.Time PublishedAt time.Time } + +func (p *Post) BestSlug() string { + if p.Slug != "" { + return p.Slug + } + + bestDateToUse := p.PublishedAt + if bestDateToUse.IsZero() { + bestDateToUse = p.CreatedAt + } + + slugPath := firstNWords(p.Title, 3, wordForSlug) + if slugPath == "" { + slugPath = firstNWords(p.Body, 3, wordForSlug) + } + if slugPath != "" { + slugPath = strings.Replace(strings.ToLower(slugPath), " ", "-", -1) + } else { + slugPath = p.GUID + if slugPath == "" { + slugPath = bestDateToUse.Format("150405") + } + } + + datePart := fmt.Sprintf("%04d/%02d/%02d", bestDateToUse.Year(), bestDateToUse.Month(), bestDateToUse.Day()) + return fmt.Sprintf("/%s/%s", datePart, slugPath) +} + +func wordForSlug(word string) string { + var sb strings.Builder + for _, c := range word { + if unicode.IsLetter(c) || unicode.IsNumber(c) { + sb.WriteRune(c) + } + } + return sb.String() +} + +func firstNWords(s string, n int, keepWord func(word string) string) string { + if n == 0 { + return "" + } + + suitableWords := make([]string, 0, n) + + scnr := bufio.NewScanner(strings.NewReader(s)) + scnr.Split(bufio.ScanWords) + for scnr.Scan() { + word := scnr.Text() + if w := keepWord(word); w != "" { + suitableWords = append(suitableWords, w) + if len(suitableWords) >= n { + break + } + } + } + return strings.Join(suitableWords, " ") +} diff --git a/models/posts_test.go b/models/posts_test.go new file mode 100644 index 0000000..fac60ea --- /dev/null +++ b/models/posts_test.go @@ -0,0 +1,78 @@ +package models + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestFirstNWords(t *testing.T) { + tests := []struct { + words int + input string + expected string + }{ + {words: 3, input: "This is a test string with multiple words", expected: "This is a"}, + {words: 5, input: "Short string", expected: "Short string"}, + {words: 0, input: "Empty string", expected: ""}, + {words: 3, input: " The rain in Spain etc.", expected: "The rain in"}, + {words: 3, input: " The? rain! in$ Spain etc.", expected: "The rain in"}, + {words: 3, input: " !!! The 23123 rain ++_+_+ in Spain etc.", expected: "The 23123 rain"}, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + result := firstNWords(test.input, test.words, wordForSlug) + assert.Equal(t, test.expected, result) + }) + } +} + +func TestPost_BestSlug(t *testing.T) { + postDate := time.Date(2023, time.January, 1, 15, 12, 11, 0, time.UTC) + + tests := []struct { + name string + post Post + expected string + }{ + { + name: "returns slug when slug is set", + post: Post{Slug: "my-custom-slug", Title: "My Title", PublishedAt: postDate}, + expected: "my-custom-slug", + }, + { + name: "use title when slug is empty", + post: Post{Slug: "", Title: "My Title", PublishedAt: postDate}, + expected: "/2023/01/01/my-title", + }, + { + name: "use body when slug is empty", + post: Post{Slug: "", Body: "My body", PublishedAt: postDate}, + expected: "/2023/01/01/my-body", + }, + { + name: "use guid when body is empty", + post: Post{GUID: "abc123", PublishedAt: postDate}, + expected: "/2023/01/01/abc123", + }, + { + name: "use time component when guid is empty", + post: Post{Slug: "", Title: "", PublishedAt: postDate}, + expected: "/2023/01/01/151211", + }, + { + name: "use created date if publish date is unset", + post: Post{Slug: "", Title: "a title", CreatedAt: postDate}, + expected: "/2023/01/01/a-title", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := test.post.BestSlug() + assert.Equal(t, test.expected, result) + }) + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9937257 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,529 @@ +{ + "name": "weiro", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "bootstrap": "^5.3.8" + }, + "devDependencies": { + "esbuild": "0.27.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1819071 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "devDependencies": { + "esbuild": "0.27.3" + }, + "dependencies": { + "bootstrap": "^5.3.8" + } +} diff --git a/providers/db/errors.go b/providers/db/errors.go new file mode 100644 index 0000000..79ce6f4 --- /dev/null +++ b/providers/db/errors.go @@ -0,0 +1,11 @@ +package db + +import ( + "database/sql" + + "emperror.dev/errors" +) + +func ErrorIsNoRows(err error) bool { + return errors.Is(err, sql.ErrNoRows) +} diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index 890c590..1ab30b1 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -47,6 +47,26 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, return id, err } +const selectPostByGUID = `-- name: SelectPostByGUID :one +SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE guid = ? LIMIT 1 +` + +func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, error) { + row := q.db.QueryRowContext(ctx, selectPostByGUID, guid) + var i Post + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Title, + &i.Body, + &i.Slug, + &i.CreatedAt, + &i.PublishedAt, + ) + return i, err +} + const selectPostsOfSite = `-- name: SelectPostsOfSite :many SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10 ` @@ -82,3 +102,31 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post, } return items, nil } + +const updatePost = `-- name: UpdatePost :exec +UPDATE posts SET + title = ?, + body = ?, + slug = ?, + published_at = ? +WHERE id = ? +` + +type UpdatePostParams struct { + Title string + Body string + Slug string + PublishedAt int64 + ID int64 +} + +func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error { + _, err := q.db.ExecContext(ctx, updatePost, + arg.Title, + arg.Body, + arg.Slug, + arg.PublishedAt, + arg.ID, + ) + return err +} diff --git a/providers/db/posts.go b/providers/db/posts.go index 13aae3d..215fbd4 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -16,20 +16,20 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*mod posts := make([]*models.Post, len(rows)) for i, row := range rows { - posts[i] = &models.Post{ - ID: row.ID, - SiteID: row.SiteID, - GUID: row.Guid, - Title: row.Title, - Body: row.Body, - Slug: row.Slug, - CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), - PublishedAt: time.Unix(row.PublishedAt, 0).UTC(), - } + posts[i] = dbPostToPost(row) } return posts, nil } +func (db *Provider) SelectPostByGUID(ctx context.Context, guid string) (*models.Post, error) { + row, err := db.queries.SelectPostByGUID(ctx, guid) + if err != nil { + return nil, err + } + + return dbPostToPost(row), nil +} + func (db *Provider) SavePost(ctx context.Context, post *models.Post) error { if post.ID == 0 { newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{ @@ -48,6 +48,24 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error { return nil } - // No update query defined in sqlgen yet - return nil + return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{ + ID: post.ID, + Title: post.Title, + Body: post.Body, + Slug: post.Slug, + PublishedAt: post.PublishedAt.Unix(), + }) +} + +func dbPostToPost(row sqlgen.Post) *models.Post { + return &models.Post{ + ID: row.ID, + SiteID: row.SiteID, + GUID: row.Guid, + Title: row.Title, + Body: row.Body, + Slug: row.Slug, + CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), + PublishedAt: time.Unix(row.PublishedAt, 0).UTC(), + } } diff --git a/providers/siteexporter/exporter.go b/providers/siteexporter/exporter.go new file mode 100644 index 0000000..6ef4f61 --- /dev/null +++ b/providers/siteexporter/exporter.go @@ -0,0 +1,80 @@ +package siteexporter + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" + "lmika.dev/lmika/weiro/handlers/importexport" + "lmika.dev/lmika/weiro/models" +) + +type SiteExporter struct { + site models.Site + baseURL string + baseDir string +} + +func New(site models.Site, baseURL, baseDir string) *SiteExporter { + return &SiteExporter{ + site: site, + baseDir: baseDir, + baseURL: baseURL, + } +} + +func (s *SiteExporter) WriteSiteYAML() error { + siteYAML := importexport.Site{ + Title: s.site.Title, + Tagline: s.site.Tagline, + BaseURL: s.baseURL, + } + + bytes, err := yaml.Marshal(siteYAML) + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(s.baseDir, "site.yaml"), bytes, 0644) +} + +func (s *SiteExporter) WritePost(post *models.Post) error { + postMeta := importexport.Post{ + ID: post.GUID, + Title: post.Title, + Date: post.PublishedAt, + Slug: post.Slug, + } + frontMatter, err := yaml.Marshal(postMeta) + + slugBasePath := filepath.Base(post.Slug) + postFilename := filepath.Join(s.baseDir, fmt.Sprintf("posts/%04d/%02d/%02d-%s.md", + post.PublishedAt.Year(), post.PublishedAt.Month(), post.PublishedAt.Day(), slugBasePath)) + + if err := os.MkdirAll(filepath.Dir(postFilename), 0755); err != nil { + return err + } + + f, err := os.Create(postFilename) + if err != nil { + return err + } + defer f.Close() + + if _, err := f.WriteString("---\n"); err != nil { + return err + } + + if _, err = f.Write(frontMatter); err != nil { + return err + } + + _, err = f.WriteString("---\n") + if err != nil { + return err + } + + _, err = f.WriteString(post.Body) + return err +} diff --git a/services/importer/service.go b/services/import/service.go similarity index 92% rename from services/importer/service.go rename to services/import/service.go index 473d54a..e4aee94 100644 --- a/services/importer/service.go +++ b/services/import/service.go @@ -1,4 +1,4 @@ -package importer +package _import import ( "context" @@ -42,6 +42,9 @@ func (s *Service) Import(ctx context.Context, sitePath string) (models.Site, err for _, post := range readSite.Posts { post.SiteID = site.ID + if post.GUID == "" { + post.GUID = models.NewNanoID() + } if err := s.db.SavePost(ctx, post); err != nil { return models.Site{}, errors.Wrap(err, "failed to save post") } diff --git a/services/posts/service.go b/services/posts/service.go new file mode 100644 index 0000000..931d569 --- /dev/null +++ b/services/posts/service.go @@ -0,0 +1,78 @@ +package posts + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/services/publisher" +) + +type Service struct { + db *db.Provider + publisher *publisher.Publisher +} + +func New(db *db.Provider, publisher *publisher.Publisher) *Service { + return &Service{ + db: db, + publisher: publisher, + } +} + +type CreatePostParams struct { + GUID string `form:"guid" json:"guid"` + Title string `form:"title" json:"title"` + Body string `form:"body" json:"body"` +} + +func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*models.Post, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + post, err := s.fetchOrCreatePost(ctx, site, params) + if err != nil { + return nil, err + } + + post.Title = params.Title + post.Body = params.Body + post.PublishedAt = time.Now() + post.Slug = post.BestSlug() + + if err := s.db.SavePost(ctx, post); err != nil { + return nil, err + } + + // TODO: do on separate thread + if err := s.publisher.Publish(ctx, site); err != nil { + return nil, err + } + + return post, nil +} + +func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, params CreatePostParams) (*models.Post, error) { + post, err := s.db.SelectPostByGUID(ctx, params.GUID) + if err == nil { + if post.SiteID != site.ID { + return nil, models.NotFoundError + } + + return post, nil + } else if !db.ErrorIsNoRows(err) { + return nil, err + } + + post = &models.Post{ + SiteID: site.ID, + GUID: params.GUID, + Title: params.Title, + Body: params.Body, + CreatedAt: time.Now(), + } + return post, nil +} diff --git a/services/publisher/service.go b/services/publisher/service.go index 33366cf..c9c5fe2 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -15,6 +15,7 @@ import ( "lmika.dev/lmika/weiro/models/pubmodel" "lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/providers/sitebuilder" + "lmika.dev/lmika/weiro/providers/siteexporter" ) type Publisher struct { @@ -27,27 +28,14 @@ func New(db *db.Provider) *Publisher { } } -func (p *Publisher) Publish(ctx context.Context, siteID int64) error { - // Fetch site, ensure user is owner - site, err := p.db.SelectSiteByID(ctx, siteID) - if err != nil { - return err - } - - user, ok := models.GetUser(ctx) - if !ok { - return models.UserRequiredError - } else if user.ID != site.OwnerID { - return models.PermissionError - } - - targets, err := p.db.SelectPublishTargetsOfSite(ctx, siteID) +func (p *Publisher) Publish(ctx context.Context, site models.Site) error { + targets, err := p.db.SelectPublishTargetsOfSite(ctx, site.ID) if err != nil { return err } // Fetch all content of site - posts, err := p.db.SelectPostsOfSite(ctx, siteID) + posts, err := p.db.SelectPostsOfSite(ctx, site.ID) if err != nil { return err } @@ -77,6 +65,17 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ } switch target.TargetType { + case "export": + exporter := siteexporter.New(pubSite.Site, target.BaseURL, target.TargetRef) + if err := exporter.WriteSiteYAML(); err != nil { + return err + } + for _, p := range pubSite.Posts { + if err := exporter.WritePost(p); err != nil { + return err + } + } + return nil case "localfs": log.Printf("Building site at %s", target.TargetRef) return sb.BuildSite(target.TargetRef) diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index b1644be..9308b45 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,6 +1,9 @@ -- name: SelectPostsOfSite :many SELECT * FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10; +-- name: SelectPostByGUID :one +SELECT * FROM posts WHERE guid = ? LIMIT 1; + -- name: InsertPost :one INSERT INTO posts ( site_id, @@ -11,4 +14,12 @@ INSERT INTO posts ( created_at, published_at ) VALUES (?, ?, ?, ?, ?, ?, ?) -RETURNING id; \ No newline at end of file +RETURNING id; + +-- name: UpdatePost :exec +UPDATE posts SET + title = ?, + body = ?, + slug = ?, + published_at = ? +WHERE id = ?; \ No newline at end of file diff --git a/sql/schema/01_init.up.sql b/sql/schema/01_init.up.sql index c7fb7e9..0c7ecc3 100644 --- a/sql/schema/01_init.up.sql +++ b/sql/schema/01_init.up.sql @@ -35,4 +35,5 @@ CREATE TABLE posts ( created_at INTEGER NOT NULL, published_at INTEGER NOT NULL ); -CREATE INDEX idx_post_site ON posts (site_id); \ No newline at end of file +CREATE INDEX idx_post_site ON posts (site_id); +CREATE UNIQUE INDEX idx_post_guid ON posts (guid); \ No newline at end of file diff --git a/views/_common/nav.html b/views/_common/nav.html new file mode 100644 index 0000000..bab3207 --- /dev/null +++ b/views/_common/nav.html @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/views/layouts/main.html b/views/layouts/main.html new file mode 100644 index 0000000..940528f --- /dev/null +++ b/views/layouts/main.html @@ -0,0 +1,14 @@ + + + + + Title + + + + + {{ template "_common/nav" . }} + + {{ embed }} + + \ No newline at end of file diff --git a/views/posts/index.html b/views/posts/index.html new file mode 100644 index 0000000..fd38502 --- /dev/null +++ b/views/posts/index.html @@ -0,0 +1 @@ +

Posts go here

\ No newline at end of file diff --git a/views/posts/new.html b/views/posts/new.html new file mode 100644 index 0000000..149d474 --- /dev/null +++ b/views/posts/new.html @@ -0,0 +1,14 @@ +
+
+ +
+ +
+
+ +
+
+ +
+
+
\ No newline at end of file