Add categories feature #3
|
|
@ -145,8 +145,7 @@ func (ph PostsHandler) Patch(c fiber.Ctx) error {
|
|||
return accepts(c, json(func() any {
|
||||
return struct{}{}
|
||||
}), html(func(c fiber.Ctx) error {
|
||||
|
||||
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts"))
|
||||
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", models.MustGetSite(c.Context()).ID))
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import (
|
|||
func TestNewNanoID(t *testing.T) {
|
||||
id := NewNanoID()
|
||||
|
||||
if len(id) != 12 {
|
||||
t.Errorf("Expected ID length of 12, got %d", len(id))
|
||||
if len(id) != 16 {
|
||||
t.Errorf("Expected ID length of 16, got %d", len(id))
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ func TestProvider_Sites(t *testing.T) {
|
|||
t.Run("select site by id", func(t *testing.T) {
|
||||
site := &models.Site{
|
||||
OwnerID: user.ID,
|
||||
GUID: models.NewNanoID(),
|
||||
Title: "Lookup Blog",
|
||||
Tagline: "Find me by ID",
|
||||
}
|
||||
|
|
@ -143,10 +144,11 @@ func TestProvider_Posts(t *testing.T) {
|
|||
require.NoError(t, p.SaveSite(ctx, site))
|
||||
|
||||
t.Run("save and select posts", func(t *testing.T) {
|
||||
guid := models.NewNanoID()
|
||||
now := time.Date(2026, 2, 19, 12, 0, 0, 0, time.UTC)
|
||||
post := &models.Post{
|
||||
SiteID: site.ID,
|
||||
GUID: "post-001",
|
||||
GUID: guid,
|
||||
Title: "First Post",
|
||||
Body: "Hello world",
|
||||
Slug: "/2026/02/19/first-post",
|
||||
|
|
@ -158,12 +160,12 @@ func TestProvider_Posts(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.NotZero(t, post.ID)
|
||||
|
||||
posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{})
|
||||
posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{Limit: 10, Offset: 0})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, posts, 1)
|
||||
assert.Equal(t, post.ID, posts[0].ID)
|
||||
assert.Equal(t, site.ID, posts[0].SiteID)
|
||||
assert.Equal(t, "post-001", posts[0].GUID)
|
||||
assert.Equal(t, guid, posts[0].GUID)
|
||||
assert.Equal(t, "First Post", posts[0].Title)
|
||||
assert.Equal(t, "Hello world", posts[0].Body)
|
||||
assert.Equal(t, "/2026/02/19/first-post", posts[0].Slug)
|
||||
|
|
@ -173,8 +175,10 @@ func TestProvider_Posts(t *testing.T) {
|
|||
|
||||
t.Run("posts ordered by created_at desc", func(t *testing.T) {
|
||||
// Create a second site to isolate this test
|
||||
guid := models.NewNanoID()
|
||||
site2 := &models.Site{
|
||||
OwnerID: user.ID,
|
||||
GUID: models.NewNanoID(),
|
||||
Title: "Second Blog",
|
||||
Tagline: "",
|
||||
}
|
||||
|
|
@ -185,7 +189,7 @@ func TestProvider_Posts(t *testing.T) {
|
|||
|
||||
post1 := &models.Post{
|
||||
SiteID: site2.ID,
|
||||
GUID: "old-post",
|
||||
GUID: guid,
|
||||
Title: "Old Post",
|
||||
Body: "old",
|
||||
Slug: "/old",
|
||||
|
|
@ -194,7 +198,7 @@ func TestProvider_Posts(t *testing.T) {
|
|||
}
|
||||
post2 := &models.Post{
|
||||
SiteID: site2.ID,
|
||||
GUID: "new-post",
|
||||
GUID: models.NewNanoID(),
|
||||
Title: "New Post",
|
||||
Body: "new",
|
||||
Slug: "/new",
|
||||
|
|
@ -205,7 +209,7 @@ func TestProvider_Posts(t *testing.T) {
|
|||
require.NoError(t, p.SavePost(ctx, post1))
|
||||
require.NoError(t, p.SavePost(ctx, post2))
|
||||
|
||||
posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{})
|
||||
posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{Limit: 10, Offset: 0})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, posts, 2)
|
||||
assert.Equal(t, "New Post", posts[0].Title)
|
||||
|
|
@ -215,6 +219,7 @@ func TestProvider_Posts(t *testing.T) {
|
|||
t.Run("select posts for site with no posts", func(t *testing.T) {
|
||||
emptySite := &models.Site{
|
||||
OwnerID: user.ID,
|
||||
GUID: models.NewNanoID(),
|
||||
Title: "Empty Blog",
|
||||
Tagline: "",
|
||||
}
|
||||
|
|
@ -239,6 +244,7 @@ func TestProvider_PublishTargets(t *testing.T) {
|
|||
|
||||
site := &models.Site{
|
||||
OwnerID: user.ID,
|
||||
GUID: models.NewNanoID(),
|
||||
Title: "My Blog",
|
||||
Tagline: "A test blog",
|
||||
}
|
||||
|
|
@ -272,6 +278,7 @@ func TestProvider_PublishTargets(t *testing.T) {
|
|||
t.Run("select targets for site with no targets", func(t *testing.T) {
|
||||
emptySite := &models.Site{
|
||||
OwnerID: user.ID,
|
||||
GUID: models.NewNanoID(),
|
||||
Title: "No Targets",
|
||||
Tagline: "",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
package sitereader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
fs fs.FS
|
||||
}
|
||||
|
||||
func New(fs fs.FS) *Provider {
|
||||
return &Provider{
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) ReadSite() (ReadSiteModels, error) {
|
||||
posts, err := p.ListPosts()
|
||||
if err != nil {
|
||||
return ReadSiteModels{}, err
|
||||
}
|
||||
|
||||
meta := siteMeta{}
|
||||
metaBytes, err := fs.ReadFile(p.fs, "site.yaml")
|
||||
if err != nil {
|
||||
return ReadSiteModels{}, err
|
||||
}
|
||||
if err := yaml.Unmarshal(metaBytes, &meta); err != nil {
|
||||
return ReadSiteModels{}, err
|
||||
}
|
||||
|
||||
site := models.Site{
|
||||
Title: meta.Title,
|
||||
Tagline: meta.Tagline,
|
||||
}
|
||||
|
||||
return ReadSiteModels{
|
||||
Site: site,
|
||||
Posts: posts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListPosts() (posts []*models.Post, err error) {
|
||||
err = fs.WalkDir(p.fs, "posts", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
} else if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
post, err := p.ReadPost(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
posts = append(posts, post)
|
||||
return nil
|
||||
})
|
||||
return posts, err
|
||||
}
|
||||
|
||||
func (p *Provider) ReadPost(path string) (*models.Post, error) {
|
||||
data, err := fs.ReadFile(p.fs, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Split front matter and content
|
||||
parts := bytes.SplitN(data, []byte("---"), 3)
|
||||
if len(parts) < 3 {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
var meta postMeta
|
||||
if err := yaml.Unmarshal(parts[1], &meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
post := models.Post{
|
||||
Slug: meta.Slug,
|
||||
Title: meta.Title,
|
||||
GUID: meta.ID,
|
||||
PublishedAt: meta.Date,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
post.Body = string(bytes.TrimPrefix(parts[2], []byte("\n")))
|
||||
return &post, nil
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
package sitereader_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"lmika.dev/lmika/weiro/providers/sitereader"
|
||||
)
|
||||
|
||||
func TestProvider_ReadPost(t *testing.T) {
|
||||
t.Run("with meta", func(t *testing.T) {
|
||||
testFS := fstest.MapFS{
|
||||
"site.yaml": {Data: []byte(`base_url: https://example.com`)},
|
||||
"posts/test.md": {Data: []byte(`---
|
||||
date: 2026-02-18T19:59:00Z
|
||||
title: Test Post Here
|
||||
tags: [test, example]
|
||||
---
|
||||
This is just a test post.
|
||||
`)},
|
||||
}
|
||||
|
||||
pr := sitereader.New(testFS)
|
||||
|
||||
post, err := pr.ReadPost("posts/test.md")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Test Post Here", post.Title)
|
||||
assert.Equal(t, time.Date(2026, 2, 18, 19, 59, 0, 0, time.UTC), post.PublishedAt)
|
||||
assert.Equal(t, "This is just a test post.\n", post.Body)
|
||||
})
|
||||
|
||||
t.Run("without meta", func(t *testing.T) {
|
||||
testFS := fstest.MapFS{
|
||||
"posts/test.md": {Data: []byte(`---
|
||||
---
|
||||
This is just a test post.
|
||||
`)},
|
||||
}
|
||||
|
||||
pr := sitereader.New(testFS)
|
||||
|
||||
post, err := pr.ReadPost("posts/test.md")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", post.Title)
|
||||
assert.Equal(t, "This is just a test post.\n", post.Body)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProvider_ListPosts(t *testing.T) {
|
||||
testFS := fstest.MapFS{
|
||||
"posts/01-post1.md": {Data: []byte(`---
|
||||
id: 111
|
||||
date: 2026-02-18T19:59:00Z
|
||||
title: Test Post Here
|
||||
tags: [test, example]
|
||||
---
|
||||
This is just a test post.
|
||||
`)},
|
||||
"posts/02-post2.md": {Data: []byte(`---
|
||||
id: 222
|
||||
---
|
||||
This is just a test post.
|
||||
`)},
|
||||
}
|
||||
|
||||
pr := sitereader.New(testFS)
|
||||
|
||||
posts, err := pr.ListPosts()
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, len(posts))
|
||||
|
||||
assert.Equal(t, "111", posts[0].GUID)
|
||||
assert.Equal(t, "222", posts[1].GUID)
|
||||
}
|
||||
|
||||
func TestProvider_ReadSite(t *testing.T) {
|
||||
testFS := fstest.MapFS{
|
||||
"site.yaml": {Data: []byte(`base_url: https://example.com`)},
|
||||
"posts/01-post1.md": {Data: []byte(`---
|
||||
id: 111
|
||||
date: 2026-02-18T19:59:00Z
|
||||
title: Test Post Here
|
||||
tags: [test, example]
|
||||
---
|
||||
This is just a test post.
|
||||
`)},
|
||||
"posts/02-post2.md": {Data: []byte(`---
|
||||
id: 222
|
||||
---
|
||||
This is just a test post.
|
||||
`)},
|
||||
}
|
||||
|
||||
pr := sitereader.New(testFS)
|
||||
|
||||
sites, err := pr.ReadSite()
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, len(sites.Posts))
|
||||
|
||||
assert.Equal(t, "111", sites.Posts[0].GUID)
|
||||
assert.Equal(t, "222", sites.Posts[1].GUID)
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package _import
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
"lmika.dev/lmika/weiro/providers/db"
|
||||
"lmika.dev/lmika/weiro/providers/sitereader"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *db.Provider
|
||||
}
|
||||
|
||||
func New(db *db.Provider) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Import(ctx context.Context, sitePath string) (models.Site, error) {
|
||||
user, ok := models.GetUser(ctx)
|
||||
if !ok {
|
||||
return models.Site{}, models.UserRequiredError
|
||||
}
|
||||
|
||||
sr := sitereader.New(os.DirFS(sitePath))
|
||||
|
||||
readSite, err := sr.ReadSite()
|
||||
if err != nil {
|
||||
return models.Site{}, errors.Wrap(err, "failed to read site")
|
||||
}
|
||||
|
||||
site := readSite.Site
|
||||
site.OwnerID = user.ID
|
||||
|
||||
if err := s.db.SaveSite(ctx, &site); err != nil {
|
||||
return models.Site{}, errors.Wrap(err, "failed to save site")
|
||||
}
|
||||
|
||||
for _, post := range readSite.Posts {
|
||||
post.SiteID = site.ID
|
||||
if post.GUID == "" {
|
||||
post.GUID = models.NewNanoID()
|
||||
}
|
||||
if err := s.db.SavePost(ctx, post); err != nil {
|
||||
return models.Site{}, errors.Wrap(err, "failed to save post")
|
||||
}
|
||||
}
|
||||
|
||||
return site, nil
|
||||
}
|
||||
Loading…
Reference in a new issue