Simplify admin publishing pipeline
This commit is contained in:
parent
13e7e4026d
commit
9186801c7f
37 changed files with 750 additions and 3367 deletions
|
|
@ -1,33 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"osaet/backend/internal/postimport"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fs := flag.NewFlagSet("import-articles", flag.ExitOnError)
|
||||
file := fs.String("file", "articles.csv", "CSV file path")
|
||||
overwrite := fs.Bool("overwrite", false, "overwrite existing markdown files")
|
||||
postsDir := fs.String("posts-dir", "content/posts", "posts output directory relative to project root")
|
||||
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
result, err := postimport.Import(postimport.Options{
|
||||
CSVPath: *file,
|
||||
PostsDir: *postsDir,
|
||||
Overwrite: *overwrite,
|
||||
WorkingDir: "",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("imported %d post(s), skipped %d existing file(s), skipped %d non-post row(s)\n", result.Imported, result.SkippedExisting, result.SkippedNonPost)
|
||||
}
|
||||
|
|
@ -32,6 +32,13 @@ func run() error {
|
|||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
logCloser, err := admin.ConfigureLogging(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if logCloser != nil {
|
||||
defer logCloser.Close()
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
|
@ -49,8 +56,6 @@ func run() error {
|
|||
return admin.RunMigrations(ctx, db, cfg.MigrationsDir)
|
||||
case "create-user":
|
||||
return createUser(ctx, db)
|
||||
case "import-markdown":
|
||||
return importMarkdown(ctx, cfg, db)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", command)
|
||||
}
|
||||
|
|
@ -75,21 +80,6 @@ func createUser(ctx context.Context, db *pgxpool.Pool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func importMarkdown(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error {
|
||||
postsDir := cfg.PostsDir
|
||||
if len(os.Args) >= 3 {
|
||||
postsDir = os.Args[2]
|
||||
}
|
||||
|
||||
result, err := admin.NewStore(db).ImportMarkdownPosts(ctx, postsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "imported %d markdown post(s), skipped %d file(s)\n", result.Imported, result.Skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error {
|
||||
server := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"osaet/backend/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.Run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,8 @@ go 1.25.0
|
|||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/jackc/pgx/v5 v5.4.3
|
||||
golang.org/x/crypto v0.48.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.33.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -14,7 +14,6 @@ require (
|
|||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
|
|
@ -22,8 +21,6 @@ require (
|
|||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
|
|
@ -33,25 +30,16 @@ require (
|
|||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
|
|
@ -32,12 +30,6 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
|||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
|
|
@ -63,8 +55,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
|
|
@ -73,8 +63,6 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
|||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
|
@ -101,8 +89,6 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
|||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
|
|
@ -112,8 +98,6 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
@ -122,29 +106,3 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxAssetSizeBytes = 12 * 1024 * 1024
|
||||
|
||||
var safeAssetNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
type AssetUploader struct {
|
||||
store *Store
|
||||
assetsDir string
|
||||
}
|
||||
|
||||
func NewAssetUploader(store *Store, assetsDir string) *AssetUploader {
|
||||
return &AssetUploader{store: store, assetsDir: assetsDir}
|
||||
}
|
||||
|
||||
func (u *AssetUploader) Upload(ctx context.Context, file multipart.File, header *multipart.FileHeader) (Asset, error) {
|
||||
if header.Size > maxAssetSizeBytes {
|
||||
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxAssetSizeBytes+1))
|
||||
if err != nil {
|
||||
return Asset{}, fmt.Errorf("read asset: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxAssetSizeBytes {
|
||||
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(data)
|
||||
if !strings.HasPrefix(mimeType, "image/") {
|
||||
return Asset{}, fmt.Errorf("unsupported asset type: %s", mimeType)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
sha := hex.EncodeToString(sum[:])
|
||||
name := assetFilename(header.Filename, sha)
|
||||
if err := os.MkdirAll(u.assetsDir, 0o755); err != nil {
|
||||
return Asset{}, fmt.Errorf("create assets dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(u.assetsDir, name), data, 0o644); err != nil {
|
||||
return Asset{}, fmt.Errorf("write asset: %w", err)
|
||||
}
|
||||
|
||||
asset := Asset{
|
||||
Path: "/assets/" + name,
|
||||
OriginalName: header.Filename,
|
||||
MimeType: mimeType,
|
||||
SizeBytes: int64(len(data)),
|
||||
SHA256: sha,
|
||||
}
|
||||
return u.store.CreateAsset(ctx, asset)
|
||||
}
|
||||
|
||||
func assetFilename(original string, sha string) string {
|
||||
ext := strings.ToLower(filepath.Ext(original))
|
||||
base := strings.TrimSuffix(filepath.Base(original), filepath.Ext(original))
|
||||
base = strings.Trim(safeAssetNamePattern.ReplaceAllString(base, "-"), "-._")
|
||||
if base == "" {
|
||||
base = "image"
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
return fmt.Sprintf("%s-%s%s", base, sha[:12], ext)
|
||||
}
|
||||
|
|
@ -20,8 +20,11 @@ type Config struct {
|
|||
RepoRoot string
|
||||
PostsDir string
|
||||
SiteDir string
|
||||
AssetsDir string
|
||||
StaticDir string
|
||||
AdminDir string
|
||||
LogFile string
|
||||
LogMaxBytes int64
|
||||
LogMaxBackups int
|
||||
DeepSeek DeepSeekConfig
|
||||
LocalLLM LocalLLMConfig
|
||||
SlugProvider string
|
||||
|
|
@ -89,9 +92,9 @@ func LoadConfig() Config {
|
|||
siteDir = filepath.Join(repoRoot, "frontend", "site")
|
||||
}
|
||||
|
||||
assetsDir := os.Getenv("OSAET_ASSETS_DIR")
|
||||
if assetsDir == "" {
|
||||
assetsDir = filepath.Join(siteDir, "public", "assets")
|
||||
staticDir := os.Getenv("OSAET_STATIC_DIR")
|
||||
if staticDir == "" {
|
||||
staticDir = filepath.Join(repoRoot, "dist", "site")
|
||||
}
|
||||
|
||||
adminDir := os.Getenv("OSAET_ADMIN_DIR")
|
||||
|
|
@ -99,6 +102,11 @@ func LoadConfig() Config {
|
|||
adminDir = filepath.Join(repoRoot, "frontend", "admin", "dist", "admin", "browser")
|
||||
}
|
||||
|
||||
logFile := os.Getenv("OSAET_LOG_FILE")
|
||||
if logFile == "" {
|
||||
logFile = filepath.Join(repoRoot, ".osaet", "logs", "osaet-admin.log")
|
||||
}
|
||||
|
||||
local := loadLocalConfig(repoRoot)
|
||||
databaseURL := firstNonEmpty(os.Getenv("DATABASE_URL"), local.Database.PostgresDSN)
|
||||
|
||||
|
|
@ -118,8 +126,11 @@ func LoadConfig() Config {
|
|||
RepoRoot: repoRoot,
|
||||
PostsDir: postsDir,
|
||||
SiteDir: siteDir,
|
||||
AssetsDir: assetsDir,
|
||||
StaticDir: staticDir,
|
||||
AdminDir: adminDir,
|
||||
LogFile: logFile,
|
||||
LogMaxBytes: int64(firstNonZeroInt(envInt("OSAET_LOG_MAX_BYTES"), 10*1024*1024)),
|
||||
LogMaxBackups: firstNonZeroInt(envInt("OSAET_LOG_MAX_BACKUPS"), 5),
|
||||
SlugProvider: firstNonEmpty(os.Getenv("OSAET_SLUG_PROVIDER"), local.Slug.Provider, "deepseek"),
|
||||
DeepSeek: DeepSeekConfig{
|
||||
APIKey: deepSeekAPIKey,
|
||||
|
|
@ -236,8 +247,8 @@ func (c Config) Validate() error {
|
|||
if c.SiteDir == "" {
|
||||
return errors.New("site dir is required")
|
||||
}
|
||||
if c.AssetsDir == "" {
|
||||
return errors.New("assets dir is required")
|
||||
if c.StaticDir == "" {
|
||||
return errors.New("static dir is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
139
backend/internal/admin/logging.go
Normal file
139
backend/internal/admin/logging.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rotatingLogWriter struct {
|
||||
path string
|
||||
maxBytes int64
|
||||
maxBackups int
|
||||
file *os.File
|
||||
size int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func ConfigureLogging(cfg Config) (io.Closer, error) {
|
||||
if cfg.LogFile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
writer, err := newRotatingLogWriter(cfg.LogFile, cfg.LogMaxBytes, cfg.LogMaxBackups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
multi := io.MultiWriter(os.Stdout, writer)
|
||||
log.SetOutput(multi)
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.LUTC)
|
||||
return writer, nil
|
||||
}
|
||||
|
||||
func newRotatingLogWriter(path string, maxBytes int64, maxBackups int) (*rotatingLogWriter, error) {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 10 * 1024 * 1024
|
||||
}
|
||||
if maxBackups <= 0 {
|
||||
maxBackups = 5
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create log dir: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open log file: %w", err)
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, fmt.Errorf("stat log file: %w", err)
|
||||
}
|
||||
return &rotatingLogWriter{
|
||||
path: path,
|
||||
maxBytes: maxBytes,
|
||||
maxBackups: maxBackups,
|
||||
file: file,
|
||||
size: info.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *rotatingLogWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.size+int64(len(p)) > w.maxBytes {
|
||||
if err := w.rotateLocked(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
n, err := w.file.Write(p)
|
||||
w.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *rotatingLogWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := w.file.Close()
|
||||
w.file = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *rotatingLogWriter) rotateLocked() error {
|
||||
if w.file != nil {
|
||||
if err := w.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102T150405")
|
||||
rotated := fmt.Sprintf("%s.%s", w.path, stamp)
|
||||
if err := os.Rename(w.path, rotated); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("rotate log: %w", err)
|
||||
}
|
||||
if err := w.pruneLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(w.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open rotated log file: %w", err)
|
||||
}
|
||||
w.file = file
|
||||
w.size = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *rotatingLogWriter) pruneLocked() error {
|
||||
pattern := w.path + ".*"
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list rotated logs: %w", err)
|
||||
}
|
||||
if len(matches) <= w.maxBackups {
|
||||
return nil
|
||||
}
|
||||
sortStrings(matches)
|
||||
for _, path := range matches[:len(matches)-w.maxBackups] {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove old log %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortStrings(values []string) {
|
||||
for i := 1; i < len(values); i++ {
|
||||
value := values[i]
|
||||
j := i - 1
|
||||
for ; j >= 0 && values[j] > value; j-- {
|
||||
values[j+1] = values[j]
|
||||
}
|
||||
values[j+1] = value
|
||||
}
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type MarkdownImportResult struct {
|
||||
Imported int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
type markdownFrontmatter struct {
|
||||
Slug string `yaml:"slug"`
|
||||
Title string `yaml:"title"`
|
||||
Summary string `yaml:"summary"`
|
||||
Status string `yaml:"status"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Cover string `yaml:"cover"`
|
||||
Version int `yaml:"version"`
|
||||
SlugSource string `yaml:"slug_source"`
|
||||
SlugLocked bool `yaml:"slug_locked"`
|
||||
PublishedAt string `yaml:"published_at"`
|
||||
CreatedAt string `yaml:"created_at"`
|
||||
UpdatedAt string `yaml:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *Store) ImportMarkdownPosts(ctx context.Context, postsDir string) (MarkdownImportResult, error) {
|
||||
entries, err := os.ReadDir(postsDir)
|
||||
if err != nil {
|
||||
return MarkdownImportResult{}, fmt.Errorf("read posts dir: %w", err)
|
||||
}
|
||||
|
||||
var result MarkdownImportResult
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
post, err := readMarkdownPost(filepath.Join(postsDir, entry.Name()))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if post.Title == "" || post.Slug == "" {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if err := s.upsertImportedPost(ctx, post); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readMarkdownPost(path string) (Post, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("read markdown post %s: %w", path, err)
|
||||
}
|
||||
|
||||
frontmatterData, body, err := splitMarkdownFrontmatter(data)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("parse markdown post %s: %w", path, err)
|
||||
}
|
||||
|
||||
var meta markdownFrontmatter
|
||||
if err := yaml.Unmarshal(frontmatterData, &meta); err != nil {
|
||||
return Post{}, fmt.Errorf("parse frontmatter %s: %w", path, err)
|
||||
}
|
||||
|
||||
slug := strings.TrimSpace(meta.Slug)
|
||||
if slug == "" {
|
||||
slug = strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
}
|
||||
status := PostStatus(strings.TrimSpace(meta.Status))
|
||||
if !ValidPostStatus(status) || status == PostStatusDeleted {
|
||||
status = PostStatusDraft
|
||||
}
|
||||
version := meta.Version
|
||||
if version < 1 {
|
||||
version = 1
|
||||
}
|
||||
slugSource := strings.TrimSpace(meta.SlugSource)
|
||||
if slugSource == "" {
|
||||
slugSource = "manual"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
createdAt := parseFrontmatterTime(meta.CreatedAt, now)
|
||||
updatedAt := parseFrontmatterTime(meta.UpdatedAt, createdAt)
|
||||
var publishedAt *time.Time
|
||||
if parsed, ok := parseOptionalFrontmatterTime(meta.PublishedAt); ok {
|
||||
publishedAt = &parsed
|
||||
}
|
||||
|
||||
return Post{
|
||||
Slug: slug,
|
||||
Title: strings.TrimSpace(meta.Title),
|
||||
Summary: meta.Summary,
|
||||
BodyMarkdown: strings.TrimLeft(string(body), "\n"),
|
||||
Status: status,
|
||||
Tags: normalizeTagNames(meta.Tags),
|
||||
Cover: meta.Cover,
|
||||
Version: version,
|
||||
SlugSource: slugSource,
|
||||
SlugLocked: meta.SlugLocked,
|
||||
PublishedAt: publishedAt,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) upsertImportedPost(ctx context.Context, post Post) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin markdown import: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
imported, err := scanPost(tx.QueryRow(ctx, `
|
||||
INSERT INTO posts (
|
||||
slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (slug)
|
||||
DO UPDATE SET
|
||||
title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
body_markdown = excluded.body_markdown,
|
||||
status = excluded.status,
|
||||
cover = excluded.cover,
|
||||
version = GREATEST(posts.version, excluded.version),
|
||||
slug_source = excluded.slug_source,
|
||||
slug_locked = excluded.slug_locked,
|
||||
published_at = excluded.published_at,
|
||||
updated_at = excluded.updated_at,
|
||||
deleted_at = NULL
|
||||
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
|
||||
post.Slug,
|
||||
post.Title,
|
||||
post.Summary,
|
||||
post.BodyMarkdown,
|
||||
post.Status,
|
||||
post.Cover,
|
||||
post.Version,
|
||||
post.SlugSource,
|
||||
post.SlugLocked,
|
||||
post.PublishedAt,
|
||||
post.CreatedAt,
|
||||
post.UpdatedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert markdown post %s: %w", post.Slug, err)
|
||||
}
|
||||
if err := replacePostTags(ctx, tx, imported.ID, post.Tags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO post_versions (post_id, version, title, summary, body_markdown, status, reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (post_id, version) DO NOTHING`,
|
||||
imported.ID,
|
||||
imported.Version,
|
||||
imported.Title,
|
||||
imported.Summary,
|
||||
imported.BodyMarkdown,
|
||||
imported.Status,
|
||||
VersionReasonImport,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert markdown import version %s: %w", post.Slug, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit markdown import: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitMarkdownFrontmatter(data []byte) ([]byte, []byte, error) {
|
||||
if !bytes.HasPrefix(data, []byte("---\n")) {
|
||||
return nil, nil, errors.New("missing frontmatter opening marker")
|
||||
}
|
||||
|
||||
rest := data[len("---\n"):]
|
||||
idx := bytes.Index(rest, []byte("\n---"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("missing frontmatter closing marker")
|
||||
}
|
||||
|
||||
frontmatter := rest[:idx]
|
||||
body := rest[idx+len("\n---"):]
|
||||
if bytes.HasPrefix(body, []byte("\r\n")) {
|
||||
body = body[2:]
|
||||
} else if bytes.HasPrefix(body, []byte("\n")) {
|
||||
body = body[1:]
|
||||
}
|
||||
return frontmatter, body, nil
|
||||
}
|
||||
|
||||
func parseFrontmatterTime(value string, fallback time.Time) time.Time {
|
||||
if parsed, ok := parseOptionalFrontmatterTime(value); ok {
|
||||
return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseOptionalFrontmatterTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05.999999999Z07",
|
||||
"2006-01-02 15:04:05.999999Z07",
|
||||
"2006-01-02 15:04:05Z07",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -19,11 +20,11 @@ type Server struct {
|
|||
db *pgxpool.Pool
|
||||
store *Store
|
||||
builder *Builder
|
||||
uploader *AssetUploader
|
||||
deepSeek DeepSeekConfig
|
||||
localLLM LocalLLMConfig
|
||||
slugProvider string
|
||||
adminDir string
|
||||
staticDir string
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
|
|
@ -38,26 +39,22 @@ func NewServerWithConfig(db *pgxpool.Pool, cfg Config) *Server {
|
|||
func NewServerWithContext(ctx context.Context, db *pgxpool.Pool, cfg Config) *Server {
|
||||
var store *Store
|
||||
var builder *Builder
|
||||
var uploader *AssetUploader
|
||||
if db != nil {
|
||||
store = NewStore(db)
|
||||
if cfg.PostsDir != "" && cfg.SiteDir != "" {
|
||||
builder = NewBuilder(store, NewExporter(cfg.PostsDir), cfg.SiteDir)
|
||||
builder.Start(ctx)
|
||||
}
|
||||
if cfg.AssetsDir != "" {
|
||||
uploader = NewAssetUploader(store, cfg.AssetsDir)
|
||||
}
|
||||
}
|
||||
return &Server{
|
||||
db: db,
|
||||
store: store,
|
||||
builder: builder,
|
||||
uploader: uploader,
|
||||
deepSeek: cfg.DeepSeek,
|
||||
localLLM: cfg.LocalLLM,
|
||||
slugProvider: cfg.SlugProvider,
|
||||
adminDir: cfg.AdminDir,
|
||||
staticDir: cfg.StaticDir,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +64,7 @@ func (s *Server) Router() http.Handler {
|
|||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(requestLogger())
|
||||
|
||||
r.GET("/healthz", s.health)
|
||||
r.GET("/readyz", s.ready)
|
||||
|
|
@ -82,8 +80,8 @@ func (s *Server) Router() http.Handler {
|
|||
protected.Use(s.requireAuth)
|
||||
protected.GET("/me", s.me)
|
||||
protected.POST("/logout", s.logout)
|
||||
protected.POST("/assets", s.uploadAsset)
|
||||
protected.POST("/slug", s.generateSlug)
|
||||
protected.GET("/audit-logs", s.listAuditLogs)
|
||||
protected.GET("/posts", s.listPosts)
|
||||
protected.POST("/posts", s.createPost)
|
||||
protected.GET("/posts/:id", s.getPost)
|
||||
|
|
@ -93,6 +91,8 @@ func (s *Server) Router() http.Handler {
|
|||
protected.POST("/posts/:id/publish", s.publishPost)
|
||||
protected.GET("/build-jobs/:id", s.getBuildJob)
|
||||
|
||||
r.NoRoute(s.siteFile)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +119,17 @@ func (s *Server) adminFile(c *gin.Context) {
|
|||
s.adminPage(c)
|
||||
}
|
||||
|
||||
func (s *Server) siteFile(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if serveSiteFile(c, s.staticDir) {
|
||||
return
|
||||
}
|
||||
c.String(http.StatusNotFound, "not found")
|
||||
}
|
||||
|
||||
func (s *Server) ready(c *gin.Context) {
|
||||
if s.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
|
|
@ -190,10 +201,12 @@ func (s *Server) login(c *gin.Context) {
|
|||
|
||||
result, err := s.store.Login(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
s.audit(c, nil, "login_failed", "user", "", gin.H{"username": input.Username, "error": err.Error()})
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
SetSessionCookie(c, result.Token, result.ExpiresAt)
|
||||
s.audit(c, &result.User, "login", "user", result.User.ID, gin.H{"username": result.User.Username})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": result.User,
|
||||
"expiresAt": result.ExpiresAt,
|
||||
|
|
@ -214,12 +227,14 @@ func (s *Server) logout(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
user, _ := currentUser(c)
|
||||
token, _ := c.Cookie(SessionCookieName)
|
||||
if err := s.store.Logout(c.Request.Context(), token); err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
ClearSessionCookie(c)
|
||||
s.audit(c, &user, "logout", "user", user.ID, gin.H{"username": user.Username})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +267,7 @@ func (s *Server) createPost(c *gin.Context) {
|
|||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
s.auditCurrentUser(c, "post_create", "post", post.ID, postAuditDetails(post))
|
||||
c.JSON(http.StatusCreated, gin.H{"post": post})
|
||||
}
|
||||
|
||||
|
|
@ -271,6 +287,7 @@ func (s *Server) updatePost(c *gin.Context) {
|
|||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
s.auditCurrentUser(c, "post_update", "post", post.ID, postAuditDetails(post))
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
|
|
@ -285,33 +302,14 @@ func (s *Server) deletePost(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
s.enqueueBuildJob(job)
|
||||
details := gin.H{}
|
||||
if job != nil {
|
||||
details["buildJobId"] = job.ID
|
||||
}
|
||||
s.auditCurrentUser(c, "post_delete", "post", c.Param("id"), details)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "buildJob": job})
|
||||
}
|
||||
|
||||
func (s *Server) uploadAsset(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
if s.uploader == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "asset uploader is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
asset, err := s.uploader.Upload(c.Request.Context(), file, header)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"asset": asset})
|
||||
}
|
||||
|
||||
type GenerateSlugInput struct {
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
|
|
@ -345,9 +343,35 @@ func (s *Server) generateSlug(c *gin.Context) {
|
|||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
s.auditCurrentUser(c, "slug_generate", "post", input.PostID, gin.H{"title": input.Title, "slug": slug})
|
||||
c.JSON(http.StatusOK, gin.H{"slug": slug})
|
||||
}
|
||||
|
||||
func (s *Server) listAuditLogs(c *gin.Context) {
|
||||
if !s.requireStore(c) {
|
||||
return
|
||||
}
|
||||
|
||||
opts := AuditLogListOptions{
|
||||
Action: c.Query("action"),
|
||||
ResourceType: c.Query("resourceType"),
|
||||
Query: c.Query("query"),
|
||||
Limit: queryInt(c, "limit"),
|
||||
Offset: queryInt(c, "offset"),
|
||||
}
|
||||
logs, err := s.store.ListAuditLogs(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
total, err := s.store.CountAuditLogs(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total})
|
||||
}
|
||||
|
||||
func (s *Server) generateSlugBase(ctx context.Context, title string, summary string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s.slugProvider)) {
|
||||
case "", "deepseek":
|
||||
|
|
@ -385,6 +409,7 @@ func (s *Server) buildPost(c *gin.Context) {
|
|||
}
|
||||
|
||||
s.enqueueBuildJob(&job)
|
||||
s.auditCurrentUser(c, "build_create", "build_job", job.ID, gin.H{"postId": c.Param("id")})
|
||||
c.JSON(http.StatusAccepted, gin.H{"buildJob": job})
|
||||
}
|
||||
|
||||
|
|
@ -400,6 +425,12 @@ func (s *Server) publishPost(c *gin.Context) {
|
|||
}
|
||||
|
||||
s.enqueueBuildJob(&job)
|
||||
s.auditCurrentUser(c, "post_publish", "post", post.ID, gin.H{
|
||||
"title": post.Title,
|
||||
"slug": post.Slug,
|
||||
"status": post.Status,
|
||||
"buildJobId": job.ID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"post": post,
|
||||
|
|
@ -472,3 +503,58 @@ func writeStoreError(c *gin.Context, err error) {
|
|||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func currentUser(c *gin.Context) (User, bool) {
|
||||
user, ok := c.Request.Context().Value(userContextKey).(User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
func (s *Server) auditCurrentUser(c *gin.Context, action string, resourceType string, resourceID string, details map[string]any) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
s.audit(c, nil, action, resourceType, resourceID, details)
|
||||
return
|
||||
}
|
||||
s.audit(c, &user, action, resourceType, resourceID, details)
|
||||
}
|
||||
|
||||
func (s *Server) audit(c *gin.Context, user *User, action string, resourceType string, resourceID string, details map[string]any) {
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
var actorID *string
|
||||
actorUsername := ""
|
||||
if user != nil {
|
||||
actorID = &user.ID
|
||||
actorUsername = user.Username
|
||||
}
|
||||
if err := s.store.CreateAuditLog(c.Request.Context(), AuditLogInput{
|
||||
ActorID: actorID,
|
||||
ActorUsername: actorUsername,
|
||||
Action: action,
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
IPAddress: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
Details: details,
|
||||
}); err != nil {
|
||||
log.Printf("audit log failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requestLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
started := time.Now()
|
||||
c.Next()
|
||||
log.Printf("%s %s %d %s %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started), c.ClientIP())
|
||||
}
|
||||
}
|
||||
|
||||
func postAuditDetails(post Post) map[string]any {
|
||||
return map[string]any{
|
||||
"title": post.Title,
|
||||
"slug": post.Slug,
|
||||
"status": post.Status,
|
||||
"version": post.Version,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,33 @@ func serveAdminFile(c *gin.Context, adminDir string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
func serveSiteFile(c *gin.Context, staticDir string) bool {
|
||||
if strings.TrimSpace(staticDir) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
requested := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
if requested == "" {
|
||||
requested = "index.html"
|
||||
}
|
||||
cleaned := filepath.Clean(requested)
|
||||
if cleaned == "." || strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) {
|
||||
return false
|
||||
}
|
||||
|
||||
path := filepath.Join(staticDir, cleaned)
|
||||
info, err := os.Stat(path)
|
||||
if err == nil && info.IsDir() {
|
||||
path = filepath.Join(path, "index.html")
|
||||
info, err = os.Stat(path)
|
||||
}
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
http.ServeFile(c.Writer, c.Request, path)
|
||||
return true
|
||||
}
|
||||
|
||||
func rewriteAdminBase(page []byte) []byte {
|
||||
return []byte(strings.Replace(string(page), `<base href="/">`, `<base href="/admin/">`, 1))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package admin
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
|
@ -25,6 +26,25 @@ type PostListOptions struct {
|
|||
Offset int
|
||||
}
|
||||
|
||||
type AuditLogInput struct {
|
||||
ActorID *string
|
||||
ActorUsername string
|
||||
Action string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
Details map[string]any
|
||||
}
|
||||
|
||||
type AuditLogListOptions struct {
|
||||
Action string
|
||||
ResourceType string
|
||||
Query string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type PostInput struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
|
|
@ -389,28 +409,6 @@ WHERE id = $1`, id))
|
|||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAsset(ctx context.Context, asset Asset) (Asset, error) {
|
||||
created, err := scanAsset(s.db.QueryRow(ctx, `
|
||||
INSERT INTO assets (path, original_name, mime_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (path)
|
||||
DO UPDATE SET original_name = excluded.original_name,
|
||||
mime_type = excluded.mime_type,
|
||||
size_bytes = excluded.size_bytes,
|
||||
sha256 = excluded.sha256
|
||||
RETURNING id, path, original_name, mime_type, size_bytes, sha256, created_at, created_by`,
|
||||
asset.Path,
|
||||
asset.OriginalName,
|
||||
asset.MimeType,
|
||||
asset.SizeBytes,
|
||||
asset.SHA256,
|
||||
))
|
||||
if err != nil {
|
||||
return Asset{}, fmt.Errorf("create asset: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *Store) PublishedPostsForExport(ctx context.Context) ([]Post, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
||||
|
|
@ -473,6 +471,114 @@ WHERE id = $1`, id, log, message)
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAuditLog(ctx context.Context, input AuditLogInput) error {
|
||||
action := strings.TrimSpace(input.Action)
|
||||
if action == "" {
|
||||
return errors.New("audit action is required")
|
||||
}
|
||||
details := input.Details
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
detailsJSON, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode audit details: %w", err)
|
||||
}
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO audit_logs (actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
|
||||
input.ActorID,
|
||||
input.ActorUsername,
|
||||
action,
|
||||
strings.TrimSpace(input.ResourceType),
|
||||
strings.TrimSpace(input.ResourceID),
|
||||
strings.TrimSpace(input.IPAddress),
|
||||
strings.TrimSpace(input.UserAgent),
|
||||
string(detailsJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create audit log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAuditLogs(ctx context.Context, opts AuditLogListOptions) ([]AuditLog, error) {
|
||||
limit := opts.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset := opts.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
args := []any{limit, offset}
|
||||
where := "true"
|
||||
if strings.TrimSpace(opts.Action) != "" {
|
||||
args = append(args, strings.TrimSpace(opts.Action))
|
||||
where += fmt.Sprintf(" AND action = $%d", len(args))
|
||||
}
|
||||
if strings.TrimSpace(opts.ResourceType) != "" {
|
||||
args = append(args, strings.TrimSpace(opts.ResourceType))
|
||||
where += fmt.Sprintf(" AND resource_type = $%d", len(args))
|
||||
}
|
||||
if strings.TrimSpace(opts.Query) != "" {
|
||||
args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
|
||||
where += fmt.Sprintf(` AND (
|
||||
actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
|
||||
)`, len(args), len(args), len(args), len(args), len(args))
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details, created_at
|
||||
FROM audit_logs
|
||||
WHERE `+where+`
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []AuditLog
|
||||
for rows.Next() {
|
||||
log, err := scanAuditLog(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("audit log rows: %w", err)
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (s *Store) CountAuditLogs(ctx context.Context, opts AuditLogListOptions) (int, error) {
|
||||
args := []any{}
|
||||
where := "true"
|
||||
if strings.TrimSpace(opts.Action) != "" {
|
||||
args = append(args, strings.TrimSpace(opts.Action))
|
||||
where += fmt.Sprintf(" AND action = $%d", len(args))
|
||||
}
|
||||
if strings.TrimSpace(opts.ResourceType) != "" {
|
||||
args = append(args, strings.TrimSpace(opts.ResourceType))
|
||||
where += fmt.Sprintf(" AND resource_type = $%d", len(args))
|
||||
}
|
||||
if strings.TrimSpace(opts.Query) != "" {
|
||||
args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
|
||||
where += fmt.Sprintf(` AND (
|
||||
actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
|
||||
)`, len(args), len(args), len(args), len(args), len(args))
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(ctx, `SELECT count(*) FROM audit_logs WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("count audit logs: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func validatePostInput(input PostInput, creating bool) error {
|
||||
if strings.TrimSpace(input.Title) == "" {
|
||||
return errors.New("title is required")
|
||||
|
|
@ -780,24 +886,35 @@ func scanBuildJob(row postScanner) (BuildJob, error) {
|
|||
return job, nil
|
||||
}
|
||||
|
||||
func scanAsset(row postScanner) (Asset, error) {
|
||||
var asset Asset
|
||||
var createdBy sql.NullString
|
||||
func scanAuditLog(row postScanner) (AuditLog, error) {
|
||||
var log AuditLog
|
||||
var actorID sql.NullString
|
||||
var details []byte
|
||||
err := row.Scan(
|
||||
&asset.ID,
|
||||
&asset.Path,
|
||||
&asset.OriginalName,
|
||||
&asset.MimeType,
|
||||
&asset.SizeBytes,
|
||||
&asset.SHA256,
|
||||
&asset.CreatedAt,
|
||||
&createdBy,
|
||||
&log.ID,
|
||||
&actorID,
|
||||
&log.ActorUsername,
|
||||
&log.Action,
|
||||
&log.ResourceType,
|
||||
&log.ResourceID,
|
||||
&log.IPAddress,
|
||||
&log.UserAgent,
|
||||
&details,
|
||||
&log.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Asset{}, err
|
||||
return AuditLog{}, err
|
||||
}
|
||||
asset.CreatedBy = nullStringPtr(createdBy)
|
||||
return asset, nil
|
||||
log.ActorID = nullStringPtr(actorID)
|
||||
if len(details) > 0 {
|
||||
if err := json.Unmarshal(details, &log.Details); err != nil {
|
||||
return AuditLog{}, fmt.Errorf("decode audit details: %w", err)
|
||||
}
|
||||
}
|
||||
if log.Details == nil {
|
||||
log.Details = map[string]any{}
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func nullTimePtr(value sql.NullTime) *time.Time {
|
||||
|
|
|
|||
|
|
@ -76,17 +76,6 @@ type Tag struct {
|
|||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
OriginalName string `json:"originalName"`
|
||||
MimeType string `json:"mimeType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy *string `json:"createdBy"`
|
||||
}
|
||||
|
||||
type BuildJob struct {
|
||||
ID string `json:"id"`
|
||||
Trigger BuildJobTrigger `json:"trigger"`
|
||||
|
|
@ -99,3 +88,16 @@ type BuildJob struct {
|
|||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy *string `json:"createdBy"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID string `json:"id"`
|
||||
ActorID *string `json:"actorId"`
|
||||
ActorUsername string `json:"actorUsername"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
Details map[string]any `json:"details"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"osaet/backend/internal/staticserver"
|
||||
)
|
||||
|
||||
func runBuild(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("build", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
siteDir := fs.String("site-dir", defaultAstroDir, "Astro project directory")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sitePath := *siteDir
|
||||
if !filepath.IsAbs(sitePath) {
|
||||
sitePath = filepath.Join(root, sitePath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(sitePath, "package.json")); err != nil {
|
||||
return fmt.Errorf("Astro project not found at %s; run from repo root", *siteDir)
|
||||
}
|
||||
|
||||
cmd := exec.Command("npm", "run", "build")
|
||||
cmd.Dir = sitePath
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func runDev(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("dev", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
host := fs.String("host", "127.0.0.1", "host to bind")
|
||||
port := fs.String("port", "4321", "port to listen on")
|
||||
siteDir := fs.String("site-dir", defaultAstroDir, "Astro project directory")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sitePath := *siteDir
|
||||
if !filepath.IsAbs(sitePath) {
|
||||
sitePath = filepath.Join(root, sitePath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(sitePath, "package.json")); err != nil {
|
||||
return fmt.Errorf("Astro project not found at %s; run `osaetctl init` first", *siteDir)
|
||||
}
|
||||
|
||||
cmd := exec.Command("npm", "run", "dev", "--", "--host", *host, "--port", *port)
|
||||
cmd.Dir = sitePath
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func runServe(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
host := fs.String("host", "127.0.0.1", "host to bind")
|
||||
port := fs.String("port", "4321", "port to listen on")
|
||||
dir := fs.String("dir", defaultBuildOutDir, "static output directory")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
staticDir := *dir
|
||||
if !filepath.IsAbs(staticDir) {
|
||||
staticDir = filepath.Join(root, staticDir)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(staticDir); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("static directory %s does not exist; run `osaetctl build` first", *dir)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return staticserver.Serve(staticDir, *host, *port)
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func Run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
printUsage()
|
||||
return nil
|
||||
}
|
||||
|
||||
root, err := findProjectRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "init":
|
||||
return runInit(root, args[1:])
|
||||
case "posts":
|
||||
return runPosts(root, args[1:])
|
||||
case "tags":
|
||||
return runTags(root, args[1:])
|
||||
case "db":
|
||||
return runDB(root, args[1:])
|
||||
case "config":
|
||||
return runConfig(root, args[1:])
|
||||
case "build":
|
||||
return runBuild(root, args[1:])
|
||||
case "serve":
|
||||
return runServe(root, args[1:])
|
||||
case "dev":
|
||||
return runDev(root, args[1:])
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runInit(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("init", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dirs := []string{
|
||||
"config",
|
||||
defaultPostsDir,
|
||||
defaultAssetsDir,
|
||||
".osaet",
|
||||
defaultAstroDir,
|
||||
defaultBuildOutDir,
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeFileIfMissing(filepath.Join(root, "config/site.yaml"), defaultSiteConfig()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileIfMissing(filepath.Join(root, "config/local.example.yaml"), defaultLocalExampleConfig()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("initialized local project structure")
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Print(`osaetctl manages local blog content and static builds.
|
||||
|
||||
Usage:
|
||||
osaetctl init
|
||||
osaetctl posts slug --title "My Post" [--summary "..."]
|
||||
osaetctl posts new --title "My Post" [--slug my-post|--no-ai-slug] [--tag go] [--summary "..."] [--status draft]
|
||||
osaetctl posts list [--status draft|published]
|
||||
osaetctl posts show <slug>
|
||||
osaetctl posts publish <slug>
|
||||
osaetctl posts unpublish <slug>
|
||||
osaetctl posts delete <slug>
|
||||
osaetctl posts edit <slug>
|
||||
osaetctl posts import [--db .osaet/osaet.db]
|
||||
osaetctl posts export [--db .osaet/osaet.db] [--overwrite]
|
||||
osaetctl posts diff [--db .osaet/osaet.db]
|
||||
osaetctl posts sync [--from files|db|auto] [--yes] [--db .osaet/osaet.db]
|
||||
osaetctl tags list [--all]
|
||||
osaetctl db init [--path .osaet/osaet.db]
|
||||
osaetctl db status [--path .osaet/osaet.db]
|
||||
osaetctl config
|
||||
osaetctl dev [--host 127.0.0.1] [--port 4321]
|
||||
osaetctl build
|
||||
osaetctl serve [--host 127.0.0.1] [--port 4321] [--dir dist/site]
|
||||
`)
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func runConfig(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("site config is local-file-only; edit config/site.yaml directly")
|
||||
}
|
||||
return fmt.Errorf("site config is local-file-only; edit config/site.yaml directly (unsupported subcommand %q)", args[0])
|
||||
}
|
||||
|
||||
func loadLocalConfig(root string) (localConfig, error) {
|
||||
var config localConfig
|
||||
path := filepath.Join(root, "config/local.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return config, nil
|
||||
}
|
||||
return config, err
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return config, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func readSiteConfig(root string) (siteConfigFile, error) {
|
||||
var config siteConfigFile
|
||||
path := filepath.Join(root, "config/site.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return config, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func writeSiteConfig(root string, config siteConfigFile) error {
|
||||
var out bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&out)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(config); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(root, "config/site.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, out.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func defaultSiteConfig() string {
|
||||
return `meta:
|
||||
config_version: 1
|
||||
updated_at: "2026-05-28T12:00:00+08:00"
|
||||
updated_by: "cli"
|
||||
|
||||
site:
|
||||
title: "Osaet"
|
||||
description: "Personal blog"
|
||||
base_url: "http://localhost:4321"
|
||||
language: "zh-CN"
|
||||
timezone: "Asia/Shanghai"
|
||||
|
||||
content:
|
||||
posts_dir: "content/posts"
|
||||
assets_dir: "content/assets"
|
||||
|
||||
build:
|
||||
astro_project: "frontend/site"
|
||||
output_dir: "dist/site"
|
||||
`
|
||||
}
|
||||
|
||||
func defaultLocalExampleConfig() string {
|
||||
return `database:
|
||||
driver: "sqlite"
|
||||
sqlite_path: ".osaet/osaet.db"
|
||||
postgres_dsn: ""
|
||||
|
||||
deepseek:
|
||||
api_key: ""
|
||||
api_key_env: "DEEPSEEK_API_KEY"
|
||||
base_url: "https://api.deepseek.com"
|
||||
model: "deepseek-v4-pro"
|
||||
`
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunConfigReportsLocalOnly(t *testing.T) {
|
||||
err := runConfig(t.TempDir(), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "local-file-only") {
|
||||
t.Fatalf("expected local-file-only error, got %v", err)
|
||||
}
|
||||
|
||||
err = runConfig(t.TempDir(), []string{"sync"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported subcommand \"sync\"") {
|
||||
t.Fatalf("expected unsupported subcommand error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTime(t *testing.T) {
|
||||
if _, ok := parseTime("2026-05-28T12:00:00+08:00"); !ok {
|
||||
t.Fatal("expected RFC3339 time to parse")
|
||||
}
|
||||
if _, ok := parseTime("not-time"); ok {
|
||||
t.Fatal("expected invalid time")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func loadPosts(root string) ([]postFile, error) {
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
entries, err := os.ReadDir(postsDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var posts []postFile
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
post, err := readPostFile(filepath.Join(postsDir, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts = append(posts, post)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func loadPostBySlug(root string, slug string) (postFile, error) {
|
||||
cleanSlug := sanitizeSlug(slug)
|
||||
if cleanSlug == "" {
|
||||
return postFile{}, errors.New("missing slug")
|
||||
}
|
||||
|
||||
posts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return postFile{}, err
|
||||
}
|
||||
for _, post := range posts {
|
||||
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
|
||||
if post.Frontmatter.Slug == cleanSlug || fileSlug == cleanSlug {
|
||||
return post, nil
|
||||
}
|
||||
}
|
||||
return postFile{}, fmt.Errorf("post not found: %s", cleanSlug)
|
||||
}
|
||||
|
||||
func readPostFile(path string) (postFile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return postFile{}, err
|
||||
}
|
||||
|
||||
frontmatter, body, err := splitFrontmatter(data)
|
||||
if err != nil {
|
||||
return postFile{}, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
|
||||
var meta postFrontmatter
|
||||
if err := yaml.Unmarshal(frontmatter, &meta); err != nil {
|
||||
return postFile{}, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if meta.Slug == "" {
|
||||
meta.Slug = strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
}
|
||||
if meta.Status == "" {
|
||||
meta.Status = "draft"
|
||||
}
|
||||
|
||||
return postFile{
|
||||
Path: path,
|
||||
Frontmatter: meta,
|
||||
Body: strings.TrimPrefix(string(body), "\n"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writePostFile(post postFile) error {
|
||||
var frontmatter bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&frontmatter)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(post.Frontmatter); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
output.WriteString("---\n")
|
||||
output.Write(frontmatter.Bytes())
|
||||
output.WriteString("---\n\n")
|
||||
output.WriteString(strings.TrimLeft(post.Body, "\n"))
|
||||
|
||||
tmp := post.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, post.Path)
|
||||
}
|
||||
|
||||
func splitFrontmatter(data []byte) ([]byte, []byte, error) {
|
||||
if !bytes.HasPrefix(data, []byte("---\n")) {
|
||||
return nil, nil, errors.New("missing frontmatter opening marker")
|
||||
}
|
||||
|
||||
rest := data[len("---\n"):]
|
||||
idx := bytes.Index(rest, []byte("\n---"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("missing frontmatter closing marker")
|
||||
}
|
||||
|
||||
frontmatter := rest[:idx]
|
||||
body := rest[idx+len("\n---"):]
|
||||
if bytes.HasPrefix(body, []byte("\r\n")) {
|
||||
body = body[2:]
|
||||
} else if bytes.HasPrefix(body, []byte("\n")) {
|
||||
body = body[1:]
|
||||
}
|
||||
return frontmatter, body, nil
|
||||
}
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed sqlite_schema.sql
|
||||
var sqliteSchema string
|
||||
|
||||
func runDB(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing db subcommand")
|
||||
}
|
||||
switch args[0] {
|
||||
case "init":
|
||||
return runDBInit(root, args[1:])
|
||||
case "status":
|
||||
return runDBStatus(root, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown db subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runDBInit(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("db init", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
path := fs.String("path", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *path)
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := applySQLiteSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("initialized SQLite database: %s\n", mustRel(root, dbPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDBStatus(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("db status", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
path := fs.String("path", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *path)
|
||||
info, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Printf("database: %s\n", mustRel(root, dbPath))
|
||||
fmt.Println("exists: no")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fmt.Printf("database: %s\n", mustRel(root, dbPath))
|
||||
fmt.Println("exists: yes")
|
||||
fmt.Printf("size: %d bytes\n", info.Size())
|
||||
for _, table := range []string{"posts", "settings", "sync_state"} {
|
||||
ok, err := sqliteTableExists(db, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("table %-10s %s\n", table+":", yesNo(ok))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openSQLite(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func openProjectSQLite(root string, path string) (*sql.DB, string, error) {
|
||||
dbPath := resolveRootPath(root, path)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := applySQLiteSchema(db); err != nil {
|
||||
db.Close()
|
||||
return nil, "", err
|
||||
}
|
||||
return db, dbPath, nil
|
||||
}
|
||||
|
||||
func applySQLiteSchema(db *sql.DB) error {
|
||||
for _, statement := range strings.Split(sqliteSchema, ";") {
|
||||
statement = strings.TrimSpace(statement)
|
||||
if statement == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteTableExists(db *sql.DB, table string) (bool, error) {
|
||||
var count int
|
||||
err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadWritePostFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
publishedAt := "2026-05-28T12:00:00+08:00"
|
||||
post := postFile{
|
||||
Path: filepath.Join(dir, "hello.md"),
|
||||
Frontmatter: postFrontmatter{
|
||||
ID: "post-1",
|
||||
Slug: "hello",
|
||||
Title: "Hello",
|
||||
Summary: "Summary",
|
||||
Status: "published",
|
||||
Tags: []string{"go", "astro"},
|
||||
Version: 2,
|
||||
SlugSource: "manual",
|
||||
PublishedAt: &publishedAt,
|
||||
CreatedAt: "2026-05-28T11:00:00+08:00",
|
||||
UpdatedAt: "2026-05-28T12:00:00+08:00",
|
||||
},
|
||||
Body: "Body\n",
|
||||
}
|
||||
|
||||
if err := writePostFile(post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := readPostFile(post.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Frontmatter.ID != post.Frontmatter.ID || got.Frontmatter.Slug != post.Frontmatter.Slug {
|
||||
t.Fatalf("frontmatter mismatch: %#v", got.Frontmatter)
|
||||
}
|
||||
if got.Body != post.Body {
|
||||
t.Fatalf("body = %q, want %q", got.Body, post.Body)
|
||||
}
|
||||
if len(got.Frontmatter.Tags) != 2 || got.Frontmatter.Tags[1] != "astro" {
|
||||
t.Fatalf("tags = %#v", got.Frontmatter.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPostFileRequiresFrontmatter(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.md")
|
||||
if err := os.WriteFile(path, []byte("no frontmatter"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readPostFile(path); err == nil {
|
||||
t.Fatal("expected frontmatter error")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,368 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"osaet/backend/internal/ai"
|
||||
)
|
||||
|
||||
func runPosts(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing posts subcommand")
|
||||
}
|
||||
switch args[0] {
|
||||
case "new":
|
||||
return runPostsNew(root, args[1:])
|
||||
case "slug":
|
||||
return runPostsSlug(root, args[1:])
|
||||
case "list":
|
||||
return runPostsList(root, args[1:])
|
||||
case "show":
|
||||
return runPostsShow(root, args[1:])
|
||||
case "publish":
|
||||
return runPostsStatus(root, args[1:], "published")
|
||||
case "unpublish":
|
||||
return runPostsStatus(root, args[1:], "draft")
|
||||
case "delete":
|
||||
return runPostsDelete(root, args[1:])
|
||||
case "edit":
|
||||
return runPostsEdit(root, args[1:])
|
||||
case "import":
|
||||
return runPostsImport(root, args[1:])
|
||||
case "export":
|
||||
return runPostsExport(root, args[1:])
|
||||
case "diff":
|
||||
return runPostsDiff(root, args[1:])
|
||||
case "sync":
|
||||
return runPostsSync(root, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown posts subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runTags(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing tags subcommand")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return runTagsList(root, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown tags subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runTagsList(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("tags list", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
all := fs.Bool("all", false, "include draft posts")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
posts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, post := range posts {
|
||||
if !*all && post.Frontmatter.Status != "published" {
|
||||
continue
|
||||
}
|
||||
for _, tag := range post.Frontmatter.Tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
counts[tag]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range sortedKeys(counts) {
|
||||
fmt.Printf("%-24s %d\n", tag, counts[tag])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsNew(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts new", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
title := fs.String("title", "", "post title")
|
||||
slug := fs.String("slug", "", "post slug")
|
||||
status := fs.String("status", "draft", "post status: draft or published")
|
||||
summary := fs.String("summary", "", "post summary")
|
||||
body := fs.String("body", "", "initial markdown body")
|
||||
aiSlug := fs.Bool("ai-slug", true, "generate slug with DeepSeek when --slug is empty")
|
||||
noAISlug := fs.Bool("no-ai-slug", false, "disable AI slug generation")
|
||||
tags := stringListFlag{}
|
||||
fs.Var(&tags, "tag", "post tag; may be repeated")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(*title) == "" {
|
||||
return errors.New("missing required --title")
|
||||
}
|
||||
|
||||
cleanSlug := ""
|
||||
slugSource := "manual"
|
||||
if strings.TrimSpace(*slug) != "" {
|
||||
cleanSlug = sanitizeSlug(*slug)
|
||||
} else if *aiSlug && !*noAISlug {
|
||||
generatedSlug, err := generateDeepSeekSlug(context.Background(), root, *title, *summary)
|
||||
if err == nil {
|
||||
cleanSlug = generatedSlug
|
||||
slugSource = "ai"
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "warning: AI slug generation failed, using local fallback: %v\n", err)
|
||||
}
|
||||
}
|
||||
if cleanSlug == "" {
|
||||
cleanSlug = fallbackSlug(*title)
|
||||
}
|
||||
if cleanSlug == "" {
|
||||
return errors.New("could not derive slug; pass --slug")
|
||||
}
|
||||
cleanSlug, err := uniqueSlug(root, cleanSlug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *status != "draft" && *status != "published" {
|
||||
return errors.New("--status must be draft or published")
|
||||
}
|
||||
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := uniquePostPath(postsDir, cleanSlug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
publishedAt := "null"
|
||||
if *status == "published" {
|
||||
publishedAt = fmt.Sprintf("%q", now)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(*body)
|
||||
if content == "" {
|
||||
content = "Write your post here."
|
||||
}
|
||||
|
||||
post := fmt.Sprintf(`---
|
||||
id: "%s"
|
||||
slug: "%s"
|
||||
title: "%s"
|
||||
summary: "%s"
|
||||
status: "%s"
|
||||
tags: %s
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: "%s"
|
||||
slug_locked: false
|
||||
published_at: %s
|
||||
created_at: "%s"
|
||||
updated_at: "%s"
|
||||
---
|
||||
|
||||
%s
|
||||
`, randomID(), cleanSlug, escapeYAML(*title), escapeYAML(*summary), *status, formatYAMLStringList(tags.Values()), slugSource, publishedAt, now, now, content)
|
||||
|
||||
if err := os.WriteFile(path, []byte(post), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
rel = path
|
||||
}
|
||||
fmt.Println(rel)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsSlug(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts slug", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
title := fs.String("title", "", "post title")
|
||||
summary := fs.String("summary", "", "optional post summary")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(*title) == "" {
|
||||
return errors.New("missing required --title")
|
||||
}
|
||||
|
||||
slug, err := generateDeepSeekSlug(context.Background(), root, *title, *summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slug, err = uniqueSlug(root, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(slug)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsList(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts list", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
status := fs.String("status", "", "filter by status")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
posts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, post := range posts {
|
||||
if *status != "" && post.Frontmatter.Status != *status {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%-12s %-32s %-24s %s\n", post.Frontmatter.Status, post.Frontmatter.Slug, strings.Join(post.Frontmatter.Tags, ","), post.Frontmatter.Title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsShow(root string, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: osaetctl posts show <slug>")
|
||||
}
|
||||
|
||||
post, err := loadPostBySlug(root, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("title: %s\n", post.Frontmatter.Title)
|
||||
fmt.Printf("slug: %s\n", post.Frontmatter.Slug)
|
||||
fmt.Printf("status: %s\n", post.Frontmatter.Status)
|
||||
fmt.Printf("summary: %s\n", post.Frontmatter.Summary)
|
||||
fmt.Printf("tags: %s\n", strings.Join(post.Frontmatter.Tags, ", "))
|
||||
fmt.Printf("path: %s\n", mustRel(root, post.Path))
|
||||
fmt.Println()
|
||||
fmt.Println(post.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsStatus(root string, args []string, status string) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("usage: osaetctl posts %s <slug>", statusCommand(status))
|
||||
}
|
||||
|
||||
post, err := loadPostBySlug(root, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
post.Frontmatter.Status = status
|
||||
post.Frontmatter.UpdatedAt = now
|
||||
post.Frontmatter.Version++
|
||||
if status == "published" && post.Frontmatter.PublishedAt == nil {
|
||||
post.Frontmatter.PublishedAt = &now
|
||||
}
|
||||
if status == "draft" {
|
||||
post.Frontmatter.PublishedAt = nil
|
||||
}
|
||||
|
||||
if err := writePostFile(post); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s -> %s\n", post.Frontmatter.Slug, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsDelete(root string, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: osaetctl posts delete <slug>")
|
||||
}
|
||||
|
||||
post, err := loadPostBySlug(root, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trashDir := filepath.Join(root, defaultTrashDir)
|
||||
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target := uniquePath(filepath.Join(trashDir, filepath.Base(post.Path)))
|
||||
if err := os.Rename(post.Path, target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s -> %s\n", mustRel(root, post.Path), mustRel(root, target))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsEdit(root string, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: osaetctl posts edit <slug>")
|
||||
}
|
||||
|
||||
editor := strings.TrimSpace(os.Getenv("EDITOR"))
|
||||
if editor == "" {
|
||||
return errors.New("EDITOR is not set")
|
||||
}
|
||||
|
||||
post, err := loadPostBySlug(root, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command(editor, post.Path)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func generateDeepSeekSlug(ctx context.Context, root string, title string, summary string) (string, error) {
|
||||
config, err := loadLocalConfig(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
apiKeyEnv := strings.TrimSpace(config.DeepSeek.APIKeyEnv)
|
||||
if apiKeyEnv == "" {
|
||||
apiKeyEnv = "DEEPSEEK_API_KEY"
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(os.Getenv(apiKeyEnv))
|
||||
if apiKey == "" {
|
||||
apiKey = strings.TrimSpace(config.DeepSeek.APIKey)
|
||||
}
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("%s is not set and config/local.yaml deepseek.api_key is empty", apiKeyEnv)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("DEEPSEEK_BASE_URL")), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(config.DeepSeek.BaseURL), "/")
|
||||
}
|
||||
model := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(config.DeepSeek.Model)
|
||||
}
|
||||
return ai.GenerateSlug(ctx, ai.Config{
|
||||
APIKey: apiKey,
|
||||
BaseURL: baseURL,
|
||||
Model: model,
|
||||
}, title, summary)
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeSlug(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"Hello Astro": "hello-astro",
|
||||
" Go / Astro_Blog! ": "go-astro-blog",
|
||||
"Already--Clean": "already-clean",
|
||||
"喜欢你": "",
|
||||
"abc123": "abc123",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := sanitizeSlug(input); got != want {
|
||||
t.Fatalf("sanitizeSlug(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackSlug(t *testing.T) {
|
||||
if got := fallbackSlug("Hello Astro"); got != "hello-astro" {
|
||||
t.Fatalf("fallbackSlug english = %q", got)
|
||||
}
|
||||
|
||||
got := fallbackSlug("喜欢你")
|
||||
if len(got) <= len("post-") || got[:5] != "post-" {
|
||||
t.Fatalf("fallbackSlug non-ascii = %q, want post-*", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatYAMLStringList(t *testing.T) {
|
||||
got := formatYAMLStringList([]string{"go", `astro "site"`})
|
||||
want := `["go", "astro \"site\""]`
|
||||
if got != want {
|
||||
t.Fatalf("formatYAMLStringList = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
content_markdown TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
cover TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
slug_source TEXT NOT NULL DEFAULT 'manual',
|
||||
slug_locked INTEGER NOT NULL DEFAULT 0,
|
||||
published_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_published_at ON posts(published_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLitePostImportLoadExport(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
db, err := openSQLite(filepath.Join(root, ".osaet.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := applySQLiteSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
publishedAt := "2026-05-28T12:00:00+08:00"
|
||||
post := postFile{
|
||||
Path: filepath.Join(root, defaultPostsDir, "hello.md"),
|
||||
Frontmatter: postFrontmatter{
|
||||
ID: "post-1",
|
||||
Slug: "hello",
|
||||
Title: "Hello",
|
||||
Status: "published",
|
||||
Tags: []string{"go"},
|
||||
Version: 1,
|
||||
SlugSource: "manual",
|
||||
PublishedAt: &publishedAt,
|
||||
CreatedAt: "2026-05-28T11:00:00+08:00",
|
||||
UpdatedAt: "2026-05-28T12:00:00+08:00",
|
||||
},
|
||||
Body: "Body\n",
|
||||
}
|
||||
|
||||
if err := upsertSQLitePost(root, db, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
posts, err := loadSQLitePosts(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("loaded %d posts, want 1", len(posts))
|
||||
}
|
||||
if posts[0].Frontmatter.Slug != "hello" || posts[0].Body != "Body\n" {
|
||||
t.Fatalf("loaded post mismatch: %#v", posts[0])
|
||||
}
|
||||
|
||||
exported, skipped, err := exportPostsToFilesCount(root, posts, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exported != 1 || skipped != 0 {
|
||||
t.Fatalf("exported=%d skipped=%d", exported, skipped)
|
||||
}
|
||||
|
||||
read, err := readPostFile(filepath.Join(root, defaultPostsDir, "hello.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if read.Frontmatter.Title != "Hello" {
|
||||
t.Fatalf("exported title = %q", read.Frontmatter.Title)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,558 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runPostsImport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts import", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *dbPathFlag)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := applySQLiteSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
posts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, post := range posts {
|
||||
if post.Frontmatter.ID == "" {
|
||||
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
|
||||
}
|
||||
if post.Frontmatter.Slug == "" {
|
||||
return fmt.Errorf("%s: missing slug", mustRel(root, post.Path))
|
||||
}
|
||||
if err := upsertSQLitePost(root, db, post); err != nil {
|
||||
return err
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
fmt.Printf("imported %d post(s) into %s\n", imported, mustRel(root, dbPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsExport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts export", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
overwrite := fs.Bool("overwrite", false, "overwrite existing Markdown files")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *dbPathFlag)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
posts, err := loadSQLitePosts(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
exported, skipped, err := exportPostsToFilesCount(root, posts, *overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("exported %d post(s), skipped %d existing file(s)\n", exported, skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsDiff(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts diff", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *dbPathFlag)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
return printPostsDiff(root, db)
|
||||
}
|
||||
|
||||
func printPostsDiff(root string, db *sql.DB) error {
|
||||
filePosts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbPosts, err := loadSQLitePosts(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileByID := map[string]postFile{}
|
||||
dbByID := map[string]postFile{}
|
||||
for _, post := range filePosts {
|
||||
if post.Frontmatter.ID == "" {
|
||||
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
|
||||
}
|
||||
fileByID[post.Frontmatter.ID] = post
|
||||
}
|
||||
for _, post := range dbPosts {
|
||||
dbByID[post.Frontmatter.ID] = post
|
||||
}
|
||||
|
||||
ids := map[string]bool{}
|
||||
for id := range fileByID {
|
||||
ids[id] = true
|
||||
}
|
||||
for id := range dbByID {
|
||||
ids[id] = true
|
||||
}
|
||||
|
||||
summary := map[string]int{}
|
||||
for _, id := range sortedBoolKeys(ids) {
|
||||
filePost, inFile := fileByID[id]
|
||||
dbPost, inDB := dbByID[id]
|
||||
switch {
|
||||
case inFile && !inDB:
|
||||
summary["only-file"]++
|
||||
fmt.Printf("only-file %-32s %s\n", filePost.Frontmatter.Slug, filePost.Frontmatter.Title)
|
||||
case !inFile && inDB:
|
||||
summary["only-db"]++
|
||||
fmt.Printf("only-db %-32s %s\n", dbPost.Frontmatter.Slug, dbPost.Frontmatter.Title)
|
||||
default:
|
||||
if postsEquivalent(root, filePost, dbPost) {
|
||||
summary["same"]++
|
||||
} else {
|
||||
summary["changed"]++
|
||||
fmt.Printf("changed %-32s %s\n", filePost.Frontmatter.Slug, filePost.Frontmatter.Title)
|
||||
printPostDiffFields(root, filePost, dbPost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("summary: same=%d changed=%d only-file=%d only-db=%d\n",
|
||||
summary["same"],
|
||||
summary["changed"],
|
||||
summary["only-file"],
|
||||
summary["only-db"],
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsSync(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts sync", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
from := fs.String("from", "", "sync source: files, db, or auto")
|
||||
yes := fs.Bool("yes", false, "confirm automatic sync without prompting")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *dbPathFlag)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := applySQLiteSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch *from {
|
||||
case "files":
|
||||
return syncFromFiles(root, db, dbPath)
|
||||
case "db":
|
||||
return syncFromDB(root, db)
|
||||
case "auto":
|
||||
return syncAuto(root, db)
|
||||
case "":
|
||||
if err := printPostsDiff(root, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if !*yes && !confirm("Auto Sync by newer updated_at?") {
|
||||
fmt.Println("No changes applied.")
|
||||
fmt.Println("Use `osaetctl posts sync --from files` to write Markdown into SQLite.")
|
||||
fmt.Println("Use `osaetctl posts sync --from db` to write SQLite into Markdown.")
|
||||
return nil
|
||||
}
|
||||
return syncAuto(root, db)
|
||||
default:
|
||||
return errors.New("--from must be files, db, or auto")
|
||||
}
|
||||
}
|
||||
|
||||
func syncFromFiles(root string, db *sql.DB, dbPath string) error {
|
||||
posts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, post := range posts {
|
||||
if post.Frontmatter.ID == "" {
|
||||
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
|
||||
}
|
||||
if err := upsertSQLitePost(root, db, post); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Printf("synced %d file post(s) into %s\n", len(posts), mustRel(root, dbPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncFromDB(root string, db *sql.DB) error {
|
||||
posts, err := loadSQLitePosts(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exportPostsToFiles(root, posts, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("synced %d db post(s) into Markdown files\n", len(posts))
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncAuto(root string, db *sql.DB) error {
|
||||
filePosts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbPosts, err := loadSQLitePosts(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileByID := map[string]postFile{}
|
||||
dbByID := map[string]postFile{}
|
||||
ids := map[string]bool{}
|
||||
for _, post := range filePosts {
|
||||
if post.Frontmatter.ID == "" {
|
||||
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
|
||||
}
|
||||
fileByID[post.Frontmatter.ID] = post
|
||||
ids[post.Frontmatter.ID] = true
|
||||
}
|
||||
for _, post := range dbPosts {
|
||||
dbByID[post.Frontmatter.ID] = post
|
||||
ids[post.Frontmatter.ID] = true
|
||||
}
|
||||
|
||||
filesToDB := 0
|
||||
dbToFiles := 0
|
||||
for _, id := range sortedBoolKeys(ids) {
|
||||
filePost, inFile := fileByID[id]
|
||||
dbPost, inDB := dbByID[id]
|
||||
|
||||
switch {
|
||||
case inFile && !inDB:
|
||||
if err := upsertSQLitePost(root, db, filePost); err != nil {
|
||||
return err
|
||||
}
|
||||
filesToDB++
|
||||
case !inFile && inDB:
|
||||
if err := exportPostsToFiles(root, []postFile{dbPost}, true); err != nil {
|
||||
return err
|
||||
}
|
||||
dbToFiles++
|
||||
case inFile && inDB:
|
||||
if postsEquivalent(root, filePost, dbPost) {
|
||||
continue
|
||||
}
|
||||
fileTime, fileOK := parseTime(filePost.Frontmatter.UpdatedAt)
|
||||
dbTime, dbOK := parseTime(dbPost.Frontmatter.UpdatedAt)
|
||||
if !fileOK && !dbOK {
|
||||
fmt.Printf("skipped %s: cannot compare updated_at\n", filePost.Frontmatter.Slug)
|
||||
continue
|
||||
}
|
||||
if fileOK && (!dbOK || fileTime.After(dbTime)) {
|
||||
if err := upsertSQLitePost(root, db, filePost); err != nil {
|
||||
return err
|
||||
}
|
||||
filesToDB++
|
||||
} else if err := exportPostsToFiles(root, []postFile{dbPost}, true); err != nil {
|
||||
return err
|
||||
} else {
|
||||
dbToFiles++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("auto sync complete: files->db=%d db->files=%d\n", filesToDB, dbToFiles)
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSQLitePost(root string, db *sql.DB, post postFile) error {
|
||||
tagsJSON, err := json.Marshal(post.Frontmatter.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publishedAt := sql.NullString{}
|
||||
if post.Frontmatter.PublishedAt != nil && strings.TrimSpace(*post.Frontmatter.PublishedAt) != "" {
|
||||
publishedAt.Valid = true
|
||||
publishedAt.String = *post.Frontmatter.PublishedAt
|
||||
}
|
||||
|
||||
_, err = db.Exec(`INSERT INTO posts (
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
summary,
|
||||
content_markdown,
|
||||
status,
|
||||
tags_json,
|
||||
cover,
|
||||
file_path,
|
||||
version,
|
||||
content_hash,
|
||||
slug_source,
|
||||
slug_locked,
|
||||
published_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
slug = excluded.slug,
|
||||
title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
content_markdown = excluded.content_markdown,
|
||||
status = excluded.status,
|
||||
tags_json = excluded.tags_json,
|
||||
cover = excluded.cover,
|
||||
file_path = excluded.file_path,
|
||||
version = excluded.version,
|
||||
content_hash = excluded.content_hash,
|
||||
slug_source = excluded.slug_source,
|
||||
slug_locked = excluded.slug_locked,
|
||||
published_at = excluded.published_at,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at,
|
||||
deleted_at = NULL`,
|
||||
post.Frontmatter.ID,
|
||||
post.Frontmatter.Slug,
|
||||
post.Frontmatter.Title,
|
||||
post.Frontmatter.Summary,
|
||||
post.Body,
|
||||
post.Frontmatter.Status,
|
||||
string(tagsJSON),
|
||||
post.Frontmatter.Cover,
|
||||
mustRel(root, post.Path),
|
||||
post.Frontmatter.Version,
|
||||
contentHash(post.Body),
|
||||
post.Frontmatter.SlugSource,
|
||||
boolInt(post.Frontmatter.SlugLocked),
|
||||
publishedAt,
|
||||
post.Frontmatter.CreatedAt,
|
||||
post.Frontmatter.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadSQLitePosts(db *sql.DB) ([]postFile, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
summary,
|
||||
content_markdown,
|
||||
status,
|
||||
tags_json,
|
||||
cover,
|
||||
file_path,
|
||||
version,
|
||||
slug_source,
|
||||
slug_locked,
|
||||
published_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM posts
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY COALESCE(published_at, updated_at) DESC, title ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []postFile
|
||||
for rows.Next() {
|
||||
var post postFile
|
||||
var tagsJSON string
|
||||
var publishedAt sql.NullString
|
||||
var slugLocked int
|
||||
if err := rows.Scan(
|
||||
&post.Frontmatter.ID,
|
||||
&post.Frontmatter.Slug,
|
||||
&post.Frontmatter.Title,
|
||||
&post.Frontmatter.Summary,
|
||||
&post.Body,
|
||||
&post.Frontmatter.Status,
|
||||
&tagsJSON,
|
||||
&post.Frontmatter.Cover,
|
||||
&post.Path,
|
||||
&post.Frontmatter.Version,
|
||||
&post.Frontmatter.SlugSource,
|
||||
&slugLocked,
|
||||
&publishedAt,
|
||||
&post.Frontmatter.CreatedAt,
|
||||
&post.Frontmatter.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &post.Frontmatter.Tags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if publishedAt.Valid {
|
||||
post.Frontmatter.PublishedAt = &publishedAt.String
|
||||
}
|
||||
post.Frontmatter.SlugLocked = slugLocked != 0
|
||||
posts = append(posts, post)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func exportPostsToFiles(root string, posts []postFile, overwrite bool) error {
|
||||
_, _, err := exportPostsToFilesCount(root, posts, overwrite)
|
||||
return err
|
||||
}
|
||||
|
||||
func exportPostsToFilesCount(root string, posts []postFile, overwrite bool) (int, int, error) {
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
exported := 0
|
||||
skipped := 0
|
||||
for _, post := range posts {
|
||||
if post.Frontmatter.Slug == "" {
|
||||
return exported, skipped, fmt.Errorf("post %s has empty slug", post.Frontmatter.ID)
|
||||
}
|
||||
|
||||
path := filepath.Join(postsDir, sanitizeSlug(post.Frontmatter.Slug)+".md")
|
||||
post.Path = path
|
||||
if _, err := os.Stat(path); err == nil && !overwrite {
|
||||
skipped++
|
||||
continue
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return exported, skipped, err
|
||||
}
|
||||
|
||||
if err := writePostFile(post); err != nil {
|
||||
return exported, skipped, err
|
||||
}
|
||||
exported++
|
||||
}
|
||||
return exported, skipped, nil
|
||||
}
|
||||
|
||||
func postsEquivalent(root string, filePost postFile, dbPost postFile) bool {
|
||||
return len(changedPostFields(root, filePost, dbPost)) == 0
|
||||
}
|
||||
|
||||
func printPostDiffFields(root string, filePost postFile, dbPost postFile) {
|
||||
for _, field := range changedPostFields(root, filePost, dbPost) {
|
||||
fmt.Printf(" - %s\n", field)
|
||||
}
|
||||
}
|
||||
|
||||
func changedPostFields(root string, filePost postFile, dbPost postFile) []string {
|
||||
var fields []string
|
||||
if filePost.Frontmatter.Slug != dbPost.Frontmatter.Slug {
|
||||
fields = append(fields, "slug")
|
||||
}
|
||||
if filePost.Frontmatter.Title != dbPost.Frontmatter.Title {
|
||||
fields = append(fields, "title")
|
||||
}
|
||||
if filePost.Frontmatter.Summary != dbPost.Frontmatter.Summary {
|
||||
fields = append(fields, "summary")
|
||||
}
|
||||
if filePost.Frontmatter.Status != dbPost.Frontmatter.Status {
|
||||
fields = append(fields, "status")
|
||||
}
|
||||
if strings.Join(filePost.Frontmatter.Tags, "\x00") != strings.Join(dbPost.Frontmatter.Tags, "\x00") {
|
||||
fields = append(fields, "tags")
|
||||
}
|
||||
if filePost.Frontmatter.Cover != dbPost.Frontmatter.Cover {
|
||||
fields = append(fields, "cover")
|
||||
}
|
||||
if filePost.Frontmatter.Version != dbPost.Frontmatter.Version {
|
||||
fields = append(fields, "version")
|
||||
}
|
||||
if filePost.Frontmatter.SlugSource != dbPost.Frontmatter.SlugSource {
|
||||
fields = append(fields, "slug_source")
|
||||
}
|
||||
if filePost.Frontmatter.SlugLocked != dbPost.Frontmatter.SlugLocked {
|
||||
fields = append(fields, "slug_locked")
|
||||
}
|
||||
if stringPtrValue(filePost.Frontmatter.PublishedAt) != stringPtrValue(dbPost.Frontmatter.PublishedAt) {
|
||||
fields = append(fields, "published_at")
|
||||
}
|
||||
if filePost.Frontmatter.CreatedAt != dbPost.Frontmatter.CreatedAt {
|
||||
fields = append(fields, "created_at")
|
||||
}
|
||||
if filePost.Frontmatter.UpdatedAt != dbPost.Frontmatter.UpdatedAt {
|
||||
fields = append(fields, "updated_at")
|
||||
}
|
||||
if contentHash(filePost.Body) != contentHash(dbPost.Body) {
|
||||
fields = append(fields, "content")
|
||||
}
|
||||
filePath := mustRel(root, filePost.Path)
|
||||
if dbPost.Path != "" && filePath != dbPost.Path {
|
||||
fields = append(fields, "file_path")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
package cli
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
defaultPostsDir = "content/posts"
|
||||
defaultAssetsDir = "content/assets"
|
||||
defaultTrashDir = "content/.trash/posts"
|
||||
defaultAstroDir = "frontend/site"
|
||||
defaultBuildOutDir = "dist/site"
|
||||
defaultSQLitePath = ".osaet/osaet.db"
|
||||
)
|
||||
|
||||
type postFile struct {
|
||||
Path string
|
||||
Frontmatter postFrontmatter
|
||||
Body string
|
||||
}
|
||||
|
||||
type postFrontmatter struct {
|
||||
ID string `yaml:"id"`
|
||||
Slug string `yaml:"slug"`
|
||||
Title string `yaml:"title"`
|
||||
Summary string `yaml:"summary"`
|
||||
Status string `yaml:"status"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Cover string `yaml:"cover"`
|
||||
Version int `yaml:"version"`
|
||||
SlugSource string `yaml:"slug_source"`
|
||||
SlugLocked bool `yaml:"slug_locked"`
|
||||
PublishedAt *string `yaml:"published_at"`
|
||||
CreatedAt string `yaml:"created_at"`
|
||||
UpdatedAt string `yaml:"updated_at"`
|
||||
}
|
||||
|
||||
type localConfig struct {
|
||||
DeepSeek struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
APIKeyEnv string `yaml:"api_key_env"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Model string `yaml:"model"`
|
||||
} `yaml:"deepseek"`
|
||||
}
|
||||
|
||||
type siteConfigFile struct {
|
||||
Meta struct {
|
||||
ConfigVersion int `yaml:"config_version" json:"config_version"`
|
||||
UpdatedAt string `yaml:"updated_at" json:"updated_at"`
|
||||
UpdatedBy string `yaml:"updated_by" json:"updated_by"`
|
||||
} `yaml:"meta" json:"meta"`
|
||||
Site struct {
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
Language string `yaml:"language" json:"language"`
|
||||
Timezone string `yaml:"timezone" json:"timezone"`
|
||||
} `yaml:"site" json:"site"`
|
||||
Content struct {
|
||||
PostsDir string `yaml:"posts_dir" json:"posts_dir"`
|
||||
AssetsDir string `yaml:"assets_dir" json:"assets_dir"`
|
||||
} `yaml:"content" json:"content"`
|
||||
Build struct {
|
||||
AstroProject string `yaml:"astro_project" json:"astro_project"`
|
||||
OutputDir string `yaml:"output_dir" json:"output_dir"`
|
||||
} `yaml:"build" json:"build"`
|
||||
}
|
||||
|
||||
type stringListFlag struct {
|
||||
values []string
|
||||
}
|
||||
|
||||
func (f *stringListFlag) String() string {
|
||||
return strings.Join(f.values, ",")
|
||||
}
|
||||
|
||||
func (f *stringListFlag) Set(value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
f.values = append(f.values, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *stringListFlag) Values() []string {
|
||||
return append([]string(nil), f.values...)
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func statusCommand(status string) string {
|
||||
if status == "published" {
|
||||
return "publish"
|
||||
}
|
||||
return "unpublish"
|
||||
}
|
||||
|
||||
func uniquePath(path string) string {
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return path
|
||||
}
|
||||
|
||||
ext := filepath.Ext(path)
|
||||
base := strings.TrimSuffix(path, ext)
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d%s", base, i, ext)
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext)
|
||||
}
|
||||
|
||||
func mustRel(root string, path string) string {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
func stringPtrValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func parseTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
parsed, err = time.Parse("2006-01-02 15:04:05", value)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func confirm(prompt string) bool {
|
||||
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
|
||||
var answer string
|
||||
if _, err := fmt.Fscan(os.Stdin, &answer); err != nil {
|
||||
return false
|
||||
}
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
func resolveRootPath(root string, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(root, path)
|
||||
}
|
||||
|
||||
func yesNo(value bool) string {
|
||||
if value {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func contentHash(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]int) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func sortedBoolKeys(values map[string]bool) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func uniqueSlug(root string, slug string) (string, error) {
|
||||
base := sanitizeSlug(slug)
|
||||
if base == "" {
|
||||
return "", errors.New("empty slug")
|
||||
}
|
||||
if !slugExists(root, base) {
|
||||
return base, nil
|
||||
}
|
||||
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d", base, i)
|
||||
if !slugExists(root, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available slug for %q", base)
|
||||
}
|
||||
|
||||
func slugExists(root string, slug string) bool {
|
||||
posts, err := loadPosts(root)
|
||||
if err == nil {
|
||||
for _, post := range posts {
|
||||
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
|
||||
if post.Frontmatter.Slug == slug || fileSlug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(root, defaultPostsDir, slug+".md"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func uniquePostPath(postsDir string, slug string) (string, error) {
|
||||
candidate := filepath.Join(postsDir, slug+".md")
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate = filepath.Join(postsDir, fmt.Sprintf("%s-%d.md", slug, i))
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available filename for slug %q", slug)
|
||||
}
|
||||
|
||||
func findProjectRoot() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for {
|
||||
if isProjectRoot(wd) {
|
||||
return wd, nil
|
||||
}
|
||||
parent := filepath.Dir(wd)
|
||||
if parent == wd {
|
||||
return "", errors.New("could not find project root")
|
||||
}
|
||||
wd = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isProjectRoot(dir string) bool {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "cmd", "osaetctl")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "frontend", "site", "package.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fallbackSlug(title string) string {
|
||||
var words []string
|
||||
var b strings.Builder
|
||||
|
||||
flush := func() {
|
||||
if b.Len() > 0 {
|
||||
words = append(words, b.String())
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range strings.ToLower(title) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/':
|
||||
flush()
|
||||
default:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
if len(words) == 0 {
|
||||
return "post-" + time.Now().Format("20060102150405")
|
||||
}
|
||||
return sanitizeSlug(strings.Join(words, "-"))
|
||||
}
|
||||
|
||||
func sanitizeSlug(slug string) string {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
slug = re.ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
for strings.Contains(slug, "--") {
|
||||
slug = strings.ReplaceAll(slug, "--", "-")
|
||||
}
|
||||
if len(slug) > 80 {
|
||||
slug = strings.Trim(slug[:80], "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("post-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func escapeYAML(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
return s
|
||||
}
|
||||
|
||||
func formatYAMLStringList(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, value := range values {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(`"`)
|
||||
b.WriteString(escapeYAML(value))
|
||||
b.WriteString(`"`)
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeFileIfMissing(path string, content string) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
|
|
@ -1,460 +0,0 @@
|
|||
package postimport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultPostsDir = "content/posts"
|
||||
|
||||
type Options struct {
|
||||
CSVPath string
|
||||
PostsDir string
|
||||
Overwrite bool
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Imported int
|
||||
SkippedExisting int
|
||||
SkippedNonPost int
|
||||
}
|
||||
|
||||
type PostFile struct {
|
||||
Path string
|
||||
Frontmatter PostFrontmatter
|
||||
Body string
|
||||
}
|
||||
|
||||
type PostFrontmatter struct {
|
||||
ID string `yaml:"id"`
|
||||
Slug string `yaml:"slug"`
|
||||
Title string `yaml:"title"`
|
||||
Summary string `yaml:"summary"`
|
||||
Status string `yaml:"status"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Cover string `yaml:"cover"`
|
||||
Version int `yaml:"version"`
|
||||
SlugSource string `yaml:"slug_source"`
|
||||
SlugLocked bool `yaml:"slug_locked"`
|
||||
PublishedAt *string `yaml:"published_at"`
|
||||
CreatedAt string `yaml:"created_at"`
|
||||
UpdatedAt string `yaml:"updated_at"`
|
||||
}
|
||||
|
||||
type csvArticle struct {
|
||||
ID string
|
||||
Slug string
|
||||
Title string
|
||||
BodyMD string
|
||||
BodyHTML string
|
||||
Status string
|
||||
ArchiveID string
|
||||
AuthorID string
|
||||
PublishedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
Type string
|
||||
}
|
||||
|
||||
func Import(options Options) (Result, error) {
|
||||
root, err := findProjectRoot(options.WorkingDir)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
csvPath := resolveRootPath(root, firstNonEmpty(options.CSVPath, "articles.csv"))
|
||||
postsDir := resolveRootPath(root, firstNonEmpty(options.PostsDir, defaultPostsDir))
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
file, err := os.Open(csvPath)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.LazyQuotes = false
|
||||
|
||||
headers, err := reader.Read()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
index := map[string]int{}
|
||||
for i, header := range headers {
|
||||
index[strings.TrimSpace(header)] = i
|
||||
}
|
||||
|
||||
var result Result
|
||||
for rowNum := 2; ; rowNum++ {
|
||||
record, err := reader.Read()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return result, fmt.Errorf("%s row %d: %w", csvPath, rowNum, err)
|
||||
}
|
||||
|
||||
article := csvArticle{
|
||||
ID: csvValue(record, index, "id"),
|
||||
Slug: csvValue(record, index, "slug"),
|
||||
Title: csvValue(record, index, "title"),
|
||||
BodyMD: csvValue(record, index, "body_md"),
|
||||
BodyHTML: csvValue(record, index, "body_html"),
|
||||
Status: csvValue(record, index, "status"),
|
||||
ArchiveID: csvValue(record, index, "archive_id"),
|
||||
AuthorID: csvValue(record, index, "author_id"),
|
||||
PublishedAt: csvValue(record, index, "published_at"),
|
||||
CreatedAt: csvValue(record, index, "created_at"),
|
||||
UpdatedAt: csvValue(record, index, "updated_at"),
|
||||
Type: csvValue(record, index, "type"),
|
||||
}
|
||||
|
||||
if strings.TrimSpace(article.Type) != "" && strings.TrimSpace(article.Type) != "post" {
|
||||
result.SkippedNonPost++
|
||||
continue
|
||||
}
|
||||
|
||||
post, skippedExisting, err := articleToPost(root, postsDir, article, options.Overwrite)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err)
|
||||
}
|
||||
if skippedExisting {
|
||||
result.SkippedExisting++
|
||||
continue
|
||||
}
|
||||
if err := writePostFile(post); err != nil {
|
||||
return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err)
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func articleToPost(root string, postsDir string, article csvArticle, overwrite bool) (PostFile, bool, error) {
|
||||
id := strings.TrimSpace(article.ID)
|
||||
if id == "" {
|
||||
id = randomID()
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(article.Title)
|
||||
if title == "" {
|
||||
return PostFile{}, false, errors.New("missing title")
|
||||
}
|
||||
|
||||
slug := sanitizeSlug(article.Slug)
|
||||
if slug == "" {
|
||||
slug = fallbackSlug(title)
|
||||
}
|
||||
if slug == "" {
|
||||
return PostFile{}, false, errors.New("missing slug")
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(article.Status)
|
||||
if status != "published" && status != "draft" {
|
||||
status = "draft"
|
||||
}
|
||||
|
||||
createdAt, err := normalizeLegacyTime(article.CreatedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid created_at: %w", err)
|
||||
}
|
||||
updatedAt, err := normalizeLegacyTime(article.UpdatedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid updated_at: %w", err)
|
||||
}
|
||||
|
||||
var publishedAt *string
|
||||
if strings.TrimSpace(article.PublishedAt) != "" {
|
||||
normalized, err := normalizeLegacyTime(article.PublishedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid published_at: %w", err)
|
||||
}
|
||||
publishedAt = &normalized
|
||||
}
|
||||
|
||||
path := filepath.Join(postsDir, slug+".md")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if !overwrite {
|
||||
return PostFile{}, true, nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
|
||||
finalSlug := slug
|
||||
if !overwrite {
|
||||
finalSlug, err = uniqueSlug(root, slug)
|
||||
if err != nil {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
path, err = uniquePostPath(postsDir, finalSlug)
|
||||
if err != nil {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.TrimLeft(article.BodyMD, "\n")
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = strings.TrimLeft(article.BodyHTML, "\n")
|
||||
}
|
||||
|
||||
return PostFile{
|
||||
Path: path,
|
||||
Frontmatter: PostFrontmatter{
|
||||
ID: id,
|
||||
Slug: finalSlug,
|
||||
Title: title,
|
||||
Summary: "",
|
||||
Status: status,
|
||||
Tags: []string{},
|
||||
Cover: "",
|
||||
Version: 1,
|
||||
SlugSource: "manual",
|
||||
SlugLocked: true,
|
||||
PublishedAt: publishedAt,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
Body: body,
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
func csvValue(record []string, index map[string]int, key string) string {
|
||||
i, ok := index[key]
|
||||
if !ok || i >= len(record) {
|
||||
return ""
|
||||
}
|
||||
return record[i]
|
||||
}
|
||||
|
||||
func normalizeLegacyTime(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", errors.New("empty time")
|
||||
}
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05.999999999Z07",
|
||||
"2006-01-02 15:04:05.999999Z07",
|
||||
"2006-01-02 15:04:05Z07",
|
||||
time.RFC3339,
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, value)
|
||||
if err == nil {
|
||||
return parsed.Format(time.RFC3339), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unsupported time format %q", value)
|
||||
}
|
||||
|
||||
func writePostFile(post PostFile) error {
|
||||
var frontmatter bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&frontmatter)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(post.Frontmatter); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
output.WriteString("---\n")
|
||||
output.Write(frontmatter.Bytes())
|
||||
output.WriteString("---\n\n")
|
||||
output.WriteString(strings.TrimLeft(post.Body, "\n"))
|
||||
|
||||
tmp := post.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, post.Path)
|
||||
}
|
||||
|
||||
func loadPosts(root string, postsDir string) ([]PostFile, error) {
|
||||
dir := resolveRootPath(root, postsDir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var posts []PostFile
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, PostFile{Path: filepath.Join(dir, entry.Name()), Frontmatter: PostFrontmatter{Slug: strings.TrimSuffix(entry.Name(), ".md")}})
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func slugExists(root string, slug string) bool {
|
||||
posts, err := loadPosts(root, defaultPostsDir)
|
||||
if err == nil {
|
||||
for _, post := range posts {
|
||||
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
|
||||
if post.Frontmatter.Slug == slug || fileSlug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(root, defaultPostsDir, slug+".md"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func uniqueSlug(root string, slug string) (string, error) {
|
||||
base := sanitizeSlug(slug)
|
||||
if base == "" {
|
||||
return "", errors.New("empty slug")
|
||||
}
|
||||
if !slugExists(root, base) {
|
||||
return base, nil
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d", base, i)
|
||||
if !slugExists(root, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available slug for %q", base)
|
||||
}
|
||||
|
||||
func uniquePostPath(postsDir string, slug string) (string, error) {
|
||||
candidate := filepath.Join(postsDir, slug+".md")
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate = filepath.Join(postsDir, fmt.Sprintf("%s-%d.md", slug, i))
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available filename for slug %q", slug)
|
||||
}
|
||||
|
||||
func findProjectRoot(start string) (string, error) {
|
||||
wd := start
|
||||
if wd == "" {
|
||||
var err error
|
||||
wd, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
for {
|
||||
if isProjectRoot(wd) {
|
||||
return wd, nil
|
||||
}
|
||||
parent := filepath.Dir(wd)
|
||||
if parent == wd {
|
||||
return "", errors.New("could not find project root")
|
||||
}
|
||||
wd = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isProjectRoot(dir string) bool {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "cmd", "osaetctl")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "frontend", "site", "package.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveRootPath(root string, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(root, path)
|
||||
}
|
||||
|
||||
func fallbackSlug(title string) string {
|
||||
var words []string
|
||||
var b strings.Builder
|
||||
flush := func() {
|
||||
if b.Len() > 0 {
|
||||
words = append(words, b.String())
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
for _, r := range strings.ToLower(title) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/':
|
||||
flush()
|
||||
default:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if len(words) == 0 {
|
||||
return "post-" + time.Now().Format("20060102150405")
|
||||
}
|
||||
return sanitizeSlug(strings.Join(words, "-"))
|
||||
}
|
||||
|
||||
func sanitizeSlug(slug string) string {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
slug = re.ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
for strings.Contains(slug, "--") {
|
||||
slug = strings.ReplaceAll(slug, "--", "-")
|
||||
}
|
||||
if len(slug) > 80 {
|
||||
slug = strings.Trim(slug[:80], "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("post-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
package postimport
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeLegacyTime(t *testing.T) {
|
||||
got, err := normalizeLegacyTime("2026-01-13 01:25:27.486491+00")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "2026-01-13T01:25:27Z" {
|
||||
t.Fatalf("normalized time = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleToPostFallbackSlugAndBody(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
post, skipped, err := articleToPost(root, postsDir, csvArticle{
|
||||
Title: "Hello World",
|
||||
BodyMD: "\nBody\n",
|
||||
Status: "published",
|
||||
CreatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
UpdatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
PublishedAt: "2026-01-13 01:25:27.486491+00",
|
||||
Type: "post",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skipped {
|
||||
t.Fatal("expected importable post")
|
||||
}
|
||||
if post.Frontmatter.Slug != "hello-world" {
|
||||
t.Fatalf("slug = %q", post.Frontmatter.Slug)
|
||||
}
|
||||
if post.Body != "Body\n" {
|
||||
t.Fatalf("body = %q", post.Body)
|
||||
}
|
||||
if post.Frontmatter.PublishedAt == nil || *post.Frontmatter.PublishedAt != "2026-01-13T01:25:27Z" {
|
||||
t.Fatalf("published_at = %#v", post.Frontmatter.PublishedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleToPostSkipsExistingWithoutOverwrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(postsDir, "smoking.md"), []byte("existing"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, skipped, err := articleToPost(root, postsDir, csvArticle{
|
||||
Slug: "smoking",
|
||||
Title: "抽烟",
|
||||
BodyMD: "Body",
|
||||
Status: "published",
|
||||
CreatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
UpdatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
Type: "post",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !skipped {
|
||||
t.Fatal("expected existing file to be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportWritesMarkdown(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "backend", "cmd", "osaetctl"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "frontend", "site"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "frontend", "site", "package.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csvPath := filepath.Join(root, "articles.csv")
|
||||
csvContent := strings.Join([]string{
|
||||
"id,slug,title,body_md,body_html,status,archive_id,author_id,published_at,created_at,updated_at,type",
|
||||
"post-1,test-post,Test Post,\"Line 1\n\nLine 2\",,published,,,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,post",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(csvPath, []byte(csvContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := Import(Options{CSVPath: csvPath, PostsDir: defaultPostsDir, WorkingDir: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Imported != 1 || result.SkippedExisting != 0 || result.SkippedNonPost != 0 {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(root, defaultPostsDir, "test-post.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
for _, want := range []string{
|
||||
"slug: test-post",
|
||||
"title: Test Post",
|
||||
"status: published",
|
||||
"published_at: \"2026-01-13T01:25:27Z\"",
|
||||
"Line 1",
|
||||
"Line 2",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected output to contain %q\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package staticserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func Serve(dir string, host string, port string) error {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("static path %s is not a directory", dir)
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := filepath.Clean("/" + r.URL.Path)
|
||||
if path == "/" {
|
||||
path = "/index.html"
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, filepath.FromSlash(path))
|
||||
if info, err := os.Stat(fullPath); err == nil && info.IsDir() {
|
||||
fullPath = filepath.Join(fullPath, "index.html")
|
||||
} else if errors.Is(err, os.ErrNotExist) && filepath.Ext(fullPath) == "" {
|
||||
fullPath = filepath.Join(dir, filepath.FromSlash(path), "index.html")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, fullPath)
|
||||
})
|
||||
|
||||
fmt.Printf("serving %s at http://%s\n", dir, addr)
|
||||
return http.ListenAndServe(addr, handler)
|
||||
}
|
||||
17
backend/migrations/003_audit_logs.sql
Normal file
17
backend/migrations/003_audit_logs.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_username TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL DEFAULT '',
|
||||
resource_id TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
details JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_action_created_at ON audit_logs(action, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_created_at ON audit_logs(resource_type, resource_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created_at ON audit_logs(actor_id, created_at DESC);
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
database:
|
||||
driver: "sqlite"
|
||||
sqlite_path: ".osaet/osaet.db"
|
||||
postgres_dsn: ""
|
||||
postgres_dsn: "postgres://osaet:password@127.0.0.1:5432/osaet?sslmode=disable"
|
||||
|
||||
slug:
|
||||
provider: "deepseek" # deepseek | local
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
|
||||
import {
|
||||
AssetResponse,
|
||||
AuditLogsResponse,
|
||||
BuildJobResponse,
|
||||
LoginResponse,
|
||||
DeletePostResponse,
|
||||
|
|
@ -99,14 +99,6 @@ export class AdminApiService {
|
|||
});
|
||||
}
|
||||
|
||||
uploadAsset(file: File) {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
return this.http.post<AssetResponse>(`${this.baseUrl}/assets`, body, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
generateSlug(title: string, summary: string, postId?: string) {
|
||||
return this.http.post<SlugResponse>(
|
||||
`${this.baseUrl}/slug`,
|
||||
|
|
@ -114,4 +106,27 @@ export class AdminApiService {
|
|||
{ withCredentials: true }
|
||||
);
|
||||
}
|
||||
|
||||
listAuditLogs(action: string, resourceType: string, query: string, limit?: number, offset?: number) {
|
||||
let params = new HttpParams();
|
||||
if (action) {
|
||||
params = params.set('action', action);
|
||||
}
|
||||
if (resourceType) {
|
||||
params = params.set('resourceType', resourceType);
|
||||
}
|
||||
if (query) {
|
||||
params = params.set('query', query);
|
||||
}
|
||||
if (limit) {
|
||||
params = params.set('limit', String(limit));
|
||||
}
|
||||
if (offset) {
|
||||
params = params.set('offset', String(offset));
|
||||
}
|
||||
return this.http.get<AuditLogsResponse>(`${this.baseUrl}/audit-logs`, {
|
||||
params,
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,25 +23,6 @@ button:disabled {
|
|||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.7em;
|
||||
background: #fff;
|
||||
color: #2f4a63;
|
||||
cursor: pointer;
|
||||
padding: 0.72em 1.15em;
|
||||
}
|
||||
|
||||
.upload-button:hover {
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.upload-button input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
|
|
@ -312,6 +293,19 @@ textarea {
|
|||
padding: 0 1em;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.log-actions input {
|
||||
width: min(20em, 100%);
|
||||
min-height: 2.55em;
|
||||
border-radius: 0.65em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.editor-head h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -371,6 +365,39 @@ textarea {
|
|||
background: #fafafa;
|
||||
}
|
||||
|
||||
.log-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.log-table-head,
|
||||
.log-table-row {
|
||||
min-width: 58em;
|
||||
display: grid;
|
||||
grid-template-columns: 10em 7em 8em 14em minmax(14em, 1fr) 8em;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0.9em 0.75em;
|
||||
}
|
||||
|
||||
.log-table-head {
|
||||
color: #777b82;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.log-table-row {
|
||||
color: #777b82;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
|
||||
.log-details {
|
||||
overflow: hidden;
|
||||
color: #3d3d3f;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-row-title {
|
||||
overflow: hidden;
|
||||
font-weight: 700;
|
||||
|
|
@ -461,8 +488,7 @@ textarea {
|
|||
box-shadow: 0 0.08em 0.35em rgb(35 36 40 / 4%);
|
||||
}
|
||||
|
||||
.editor-actions button,
|
||||
.editor-actions .upload-button {
|
||||
.editor-actions button {
|
||||
min-height: 2.45em;
|
||||
border-radius: 0.62em;
|
||||
padding: 0 0.9em;
|
||||
|
|
@ -470,14 +496,12 @@ textarea {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-actions .upload-button,
|
||||
.editor-actions .save-button,
|
||||
.editor-actions .build-button {
|
||||
background: transparent;
|
||||
color: #2f4a63;
|
||||
}
|
||||
|
||||
.editor-actions .upload-button:hover,
|
||||
.editor-actions .save-button:hover,
|
||||
.editor-actions .build-button:hover {
|
||||
background: #243b53;
|
||||
|
|
@ -791,8 +815,7 @@ textarea {
|
|||
align-items: stretch;
|
||||
}
|
||||
|
||||
.editor-actions button,
|
||||
.editor-actions .upload-button {
|
||||
.editor-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@
|
|||
<span>/</span>
|
||||
@if (view === 'list') {
|
||||
<span>文章</span>
|
||||
} @else if (view === 'logs') {
|
||||
<button type="button" (click)="backToList()">文章</button>
|
||||
<span>/</span>
|
||||
<span>日志</span>
|
||||
} @else {
|
||||
<button type="button" (click)="backToList()">文章</button>
|
||||
<span>/</span>
|
||||
|
|
@ -45,6 +49,7 @@
|
|||
<details class="user-menu">
|
||||
<summary>{{ user.username }}</summary>
|
||||
<div class="user-menu-panel">
|
||||
<button type="button" (click)="showLogs()">日志</button>
|
||||
<button type="button" (click)="logout()">退出</button>
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -135,6 +140,82 @@
|
|||
</span>
|
||||
<button type="button" class="link-button" [disabled]="page >= totalPages" (click)="nextPage()">
|
||||
下一页 →
|
||||
</button>
|
||||
</nav>
|
||||
</section>
|
||||
} @else if (view === 'logs') {
|
||||
<section class="list-view">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Audit</p>
|
||||
<h1>操作日志</h1>
|
||||
</div>
|
||||
<div class="page-actions log-actions">
|
||||
<input
|
||||
name="auditQuery"
|
||||
placeholder="搜索用户、动作、资源、详情"
|
||||
[(ngModel)]="auditQuery"
|
||||
(keyup.enter)="changeAuditFilter()"
|
||||
/>
|
||||
<select aria-label="日志动作" [(ngModel)]="auditActionFilter" (change)="changeAuditFilter()">
|
||||
<option value="">全部动作</option>
|
||||
<option value="login">登录</option>
|
||||
<option value="login_failed">登录失败</option>
|
||||
<option value="logout">退出</option>
|
||||
<option value="post_create">新建文章</option>
|
||||
<option value="post_update">修改文章</option>
|
||||
<option value="post_delete">删除文章</option>
|
||||
<option value="post_publish">发布文章</option>
|
||||
<option value="build_create">提交构建</option>
|
||||
<option value="slug_generate">生成 Slug</option>
|
||||
</select>
|
||||
<select aria-label="资源类型" [(ngModel)]="auditResourceFilter" (change)="changeAuditFilter()">
|
||||
<option value="">全部资源</option>
|
||||
<option value="user">用户</option>
|
||||
<option value="post">文章</option>
|
||||
<option value="build_job">构建</option>
|
||||
</select>
|
||||
<button type="button" (click)="changeAuditFilter()">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-table log-table">
|
||||
@if (auditLogs.length === 0) {
|
||||
<p class="empty-message">暂无日志</p>
|
||||
} @else {
|
||||
<div class="log-table-head">
|
||||
<span>时间</span>
|
||||
<span>用户</span>
|
||||
<span>动作</span>
|
||||
<span>资源</span>
|
||||
<span>详情</span>
|
||||
<span>IP</span>
|
||||
</div>
|
||||
@for (log of auditLogs; track log.id) {
|
||||
<div class="log-table-row">
|
||||
<span>{{ formatDate(log.createdAt) }}</span>
|
||||
<span>{{ log.actorUsername || '匿名' }}</span>
|
||||
<span>{{ actionText(log.action) }}</span>
|
||||
<span>{{ log.resourceType || '-' }} {{ log.resourceId || '' }}</span>
|
||||
<span class="log-details">{{ detailsText(log) }}</span>
|
||||
<span>{{ log.ipAddress || '-' }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<nav class="pagination" aria-label="日志分页">
|
||||
<button type="button" class="link-button" [disabled]="auditPage <= 1" (click)="previousAuditPage()">
|
||||
← 上一页
|
||||
</button>
|
||||
<span>第 {{ auditPage }} / {{ totalAuditPages }} 页,共 {{ totalAuditLogs }} 条</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link-button"
|
||||
[disabled]="auditPage >= totalAuditPages"
|
||||
(click)="nextAuditPage()"
|
||||
>
|
||||
下一页 →
|
||||
</button>
|
||||
</nav>
|
||||
</section>
|
||||
|
|
@ -152,10 +233,6 @@
|
|||
</h1>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<label class="upload-button">
|
||||
{{ uploadingAsset ? '上传中' : '上传图片' }}
|
||||
<input type="file" accept="image/*" [disabled]="uploadingAsset" (change)="uploadAsset($event)" />
|
||||
</label>
|
||||
<button type="submit" class="save-button" [disabled]="saving">{{ saving ? '保存中' : '保存' }}</button>
|
||||
<button type="button" class="build-button" (click)="buildPost()">构建</button>
|
||||
</div>
|
||||
|
|
@ -248,7 +325,6 @@
|
|||
<div class="markdown-workspace" [class.split]="editorMode === 'split'">
|
||||
@if (editorMode !== 'preview') {
|
||||
<textarea
|
||||
#bodyTextarea
|
||||
name="bodyMarkdown"
|
||||
spellcheck="false"
|
||||
[(ngModel)]="draft.bodyMarkdown"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { Component, HostListener, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Subscription, catchError, firstValueFrom, interval, of, switchMap, takeWhile } from 'rxjs';
|
||||
|
||||
import { AdminApiService } from './admin-api.service';
|
||||
import { BuildJob, Post, PostInput, PostStatus, User } from './models';
|
||||
import { AuditLog, BuildJob, Post, PostInput, PostStatus, User } from './models';
|
||||
|
||||
type FeedbackTone = 'success' | 'info' | 'error';
|
||||
|
||||
|
|
@ -17,17 +17,23 @@ type FeedbackTone = 'success' | 'info' | 'error';
|
|||
})
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
private readonly api = inject(AdminApiService);
|
||||
@ViewChild('bodyTextarea') private bodyTextarea?: ElementRef<HTMLTextAreaElement>;
|
||||
|
||||
user: User | null = null;
|
||||
posts: Post[] = [];
|
||||
auditLogs: AuditLog[] = [];
|
||||
currentPost: Post | null = null;
|
||||
statusFilter: PostStatus | '' = '';
|
||||
view: 'list' | 'editor' = 'list';
|
||||
auditActionFilter = '';
|
||||
auditResourceFilter = '';
|
||||
auditQuery = '';
|
||||
view: 'list' | 'editor' | 'logs' = 'list';
|
||||
editorMode: 'edit' | 'preview' | 'split' = 'edit';
|
||||
page = 1;
|
||||
auditPage = 1;
|
||||
readonly pageSize = 12;
|
||||
readonly auditPageSize = 20;
|
||||
totalPosts = 0;
|
||||
totalAuditLogs = 0;
|
||||
|
||||
loginUsername = 'yarnom';
|
||||
loginPassword = '';
|
||||
|
|
@ -38,7 +44,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
showBuildLog = false;
|
||||
loading = true;
|
||||
saving = false;
|
||||
uploadingAsset = false;
|
||||
generatingSlug = false;
|
||||
autosaveStatus = '未修改';
|
||||
lastAutosavedAt: Date | null = null;
|
||||
|
|
@ -98,6 +103,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
await firstValueFrom(this.api.logout().pipe(catchError(() => of({ ok: true }))));
|
||||
this.user = null;
|
||||
this.posts = [];
|
||||
this.auditLogs = [];
|
||||
this.currentPost = null;
|
||||
this.draft = this.emptyDraft();
|
||||
this.tagsText = '';
|
||||
|
|
@ -117,6 +123,32 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
async loadAuditLogs() {
|
||||
const response = await firstValueFrom(
|
||||
this.api.listAuditLogs(
|
||||
this.auditActionFilter,
|
||||
this.auditResourceFilter,
|
||||
this.auditQuery.trim(),
|
||||
this.auditPageSize,
|
||||
(this.auditPage - 1) * this.auditPageSize
|
||||
)
|
||||
);
|
||||
this.auditLogs = response.logs ?? [];
|
||||
this.totalAuditLogs = response.total ?? 0;
|
||||
if (this.auditPage > this.totalAuditPages) {
|
||||
this.auditPage = this.totalAuditPages;
|
||||
await this.loadAuditLogs();
|
||||
}
|
||||
}
|
||||
|
||||
async showLogs() {
|
||||
if (!this.confirmDiscard()) {
|
||||
return;
|
||||
}
|
||||
this.view = 'logs';
|
||||
await this.loadAuditLogs();
|
||||
}
|
||||
|
||||
async selectPost(id: string) {
|
||||
if (!this.confirmDiscard()) {
|
||||
return;
|
||||
|
|
@ -188,6 +220,27 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
await this.loadPosts();
|
||||
}
|
||||
|
||||
async changeAuditFilter() {
|
||||
this.auditPage = 1;
|
||||
await this.loadAuditLogs();
|
||||
}
|
||||
|
||||
async previousAuditPage() {
|
||||
if (this.auditPage <= 1) {
|
||||
return;
|
||||
}
|
||||
this.auditPage -= 1;
|
||||
await this.loadAuditLogs();
|
||||
}
|
||||
|
||||
async nextAuditPage() {
|
||||
if (this.auditPage >= this.totalAuditPages) {
|
||||
return;
|
||||
}
|
||||
this.auditPage += 1;
|
||||
await this.loadAuditLogs();
|
||||
}
|
||||
|
||||
async changeStatusFilter() {
|
||||
this.page = 1;
|
||||
await this.loadPosts();
|
||||
|
|
@ -404,28 +457,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
return renderMarkdown(this.draft.bodyMarkdown);
|
||||
}
|
||||
|
||||
async uploadAsset(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadingAsset = true;
|
||||
this.editorMessage = '正在上传图片';
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.uploadAsset(file));
|
||||
this.insertMarkdown(``);
|
||||
this.editorMessage = '图片已插入';
|
||||
this.updateAutosaveStatus();
|
||||
} catch (error) {
|
||||
this.editorMessage = errorMessage(error);
|
||||
} finally {
|
||||
this.uploadingAsset = false;
|
||||
}
|
||||
}
|
||||
|
||||
async generateSlug() {
|
||||
const title = this.draft.title.trim();
|
||||
if (!title) {
|
||||
|
|
@ -458,10 +489,26 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
return Math.max(1, Math.ceil(this.totalPosts / this.pageSize));
|
||||
}
|
||||
|
||||
get totalAuditPages() {
|
||||
return Math.max(1, Math.ceil(this.totalAuditLogs / this.auditPageSize));
|
||||
}
|
||||
|
||||
get pageNumbers() {
|
||||
return Array.from({ length: this.totalPages }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
actionText(action: string) {
|
||||
return actionText(action);
|
||||
}
|
||||
|
||||
detailsText(log: AuditLog) {
|
||||
const details = log.details ?? {};
|
||||
const pairs = Object.entries(details)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([key, value]) => `${key}: ${String(value)}`);
|
||||
return pairs.length ? pairs.join(' / ') : '无详情';
|
||||
}
|
||||
|
||||
private async autosave() {
|
||||
if (!this.canAutosave()) {
|
||||
return;
|
||||
|
|
@ -555,29 +602,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
private insertMarkdown(markdown: string) {
|
||||
const textarea = this.bodyTextarea?.nativeElement;
|
||||
const current = this.draft.bodyMarkdown ?? '';
|
||||
if (!textarea) {
|
||||
this.draft.bodyMarkdown = current ? `${current}\n\n${markdown}\n` : `${markdown}\n`;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart ?? current.length;
|
||||
const end = textarea.selectionEnd ?? current.length;
|
||||
const prefix = current.slice(0, start);
|
||||
const suffix = current.slice(end);
|
||||
const before = prefix && !prefix.endsWith('\n') ? '\n\n' : '';
|
||||
const after = suffix && !suffix.startsWith('\n') ? '\n\n' : '\n';
|
||||
this.draft.bodyMarkdown = `${prefix}${before}${markdown}${after}${suffix}`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
const cursor = start + before.length + markdown.length + after.length;
|
||||
textarea.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
private showFeedback(title: string, message: string, tone: FeedbackTone = 'success') {
|
||||
this.feedback = { title, message, tone };
|
||||
if (this.feedbackTimer) {
|
||||
|
|
@ -618,10 +642,6 @@ function slugify(value: string) {
|
|||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function altText(filename: string) {
|
||||
return filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'image';
|
||||
}
|
||||
|
||||
function toDateTimeLocal(value?: string | null) {
|
||||
if (!value) {
|
||||
return '';
|
||||
|
|
@ -776,6 +796,22 @@ function buildStatusText(status: string) {
|
|||
)[status] ?? status;
|
||||
}
|
||||
|
||||
function actionText(action: string) {
|
||||
return (
|
||||
{
|
||||
login: '登录',
|
||||
login_failed: '登录失败',
|
||||
logout: '退出',
|
||||
post_create: '新建文章',
|
||||
post_update: '修改文章',
|
||||
post_delete: '删除文章',
|
||||
post_publish: '发布文章',
|
||||
build_create: '提交构建',
|
||||
slug_generate: '生成 Slug'
|
||||
} as Record<string, string>
|
||||
)[action] ?? action;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (typeof error === 'object' && error && 'error' in error) {
|
||||
const body = (error as { error?: { error?: string } }).error;
|
||||
|
|
|
|||
|
|
@ -53,15 +53,17 @@ export type BuildJob = {
|
|||
createdBy?: string | null;
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
export type AuditLog = {
|
||||
id: string;
|
||||
path: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
actorId?: string | null;
|
||||
actorUsername: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
details: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
createdBy?: string | null;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
|
|
@ -83,10 +85,6 @@ export type BuildJobResponse = {
|
|||
buildJob: BuildJob;
|
||||
};
|
||||
|
||||
export type AssetResponse = {
|
||||
asset: Asset;
|
||||
};
|
||||
|
||||
export type SlugResponse = {
|
||||
slug: string;
|
||||
};
|
||||
|
|
@ -95,3 +93,8 @@ export type DeletePostResponse = {
|
|||
ok: boolean;
|
||||
buildJob?: BuildJob | null;
|
||||
};
|
||||
|
||||
export type AuditLogsResponse = {
|
||||
logs: AuditLog[];
|
||||
total: number;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue