Skip to content

Commit 0a47b26

Browse files
committed
Implement blog syncing as a command
1 parent da709da commit 0a47b26

13 files changed

Lines changed: 764 additions & 28 deletions

File tree

backend/command/auth.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,20 +68,20 @@ func (cmd *Command) SignOut(sessionID string) error {
6868
return cmd.repo.WithTransaction(func(tx *repository.Repository) error {
6969
session, err := tx.Session().ReadBySessionID(sessionID)
7070
if err != nil {
71-
if !errors.Is(err, postgres.ErrNotFound) {
72-
return err
71+
if errors.Is(err, postgres.ErrNotFound) {
72+
return ErrSessionNotFound
7373
}
7474

75-
return ErrSessionNotFound
75+
return err
7676
}
7777

7878
err = tx.Session().Delete(session)
7979
if err != nil {
80-
if !errors.Is(err, postgres.ErrNotFound) {
81-
return err
80+
if errors.Is(err, postgres.ErrNotFound) {
81+
return ErrSessionNotFound
8282
}
8383

84-
return ErrSessionNotFound
84+
return err
8585
}
8686

8787
return nil
@@ -100,9 +100,11 @@ func (cmd *Command) DeleteExpiredSessions(now time.Time) error {
100100
err := tx.Session().Delete(session)
101101
if err != nil {
102102
// Ignore any "not found" errors here.
103-
if !errors.Is(err, postgres.ErrNotFound) {
104-
return err
103+
if errors.Is(err, postgres.ErrNotFound) {
104+
continue
105105
}
106+
107+
return err
106108
}
107109
}
108110

backend/command/blog.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package command
2+
3+
import (
4+
"errors"
5+
"log/slog"
6+
7+
"github.com/google/uuid"
8+
9+
"github.com/theandrew168/bloggulus/backend/postgres"
10+
"github.com/theandrew168/bloggulus/backend/repository"
11+
)
12+
13+
var ErrBlogNotFound = errors.New("blog not found")
14+
15+
func (cmd *Command) DeleteBlog(blogID uuid.UUID) error {
16+
return cmd.repo.WithTransaction(func(tx *repository.Repository) error {
17+
blog, err := tx.Blog().Read(blogID)
18+
if err != nil {
19+
if errors.Is(err, postgres.ErrNotFound) {
20+
return ErrBlogNotFound
21+
}
22+
23+
return err
24+
}
25+
26+
err = tx.Blog().Delete(blog)
27+
if err != nil {
28+
if errors.Is(err, postgres.ErrNotFound) {
29+
return ErrBlogNotFound
30+
}
31+
32+
return err
33+
}
34+
35+
slog.Info("blog deleted",
36+
"blog_id", blog.ID(),
37+
"blog_title", blog.Title(),
38+
)
39+
40+
return nil
41+
})
42+
}

backend/command/command.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
package command
22

33
import (
4+
"github.com/theandrew168/bloggulus/backend/fetch"
45
"github.com/theandrew168/bloggulus/backend/repository"
56
)
67

78
type Command struct {
8-
repo *repository.Repository
9+
repo *repository.Repository
10+
feedFetcher fetch.FeedFetcher
911
}
1012

11-
func New(repo *repository.Repository) *Command {
13+
func New(repo *repository.Repository, feedFetcher fetch.FeedFetcher) *Command {
1214
cmd := Command{
13-
repo: repo,
15+
repo: repo,
16+
feedFetcher: feedFetcher,
1417
}
1518
return &cmd
1619
}

backend/command/sync.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package command
2+
3+
import (
4+
"errors"
5+
6+
"github.com/theandrew168/bloggulus/backend/command/sync"
7+
"github.com/theandrew168/bloggulus/backend/postgres"
8+
)
9+
10+
// Sync a new or existing Blog based on the provided feed URL.
11+
func (cmd *Command) SyncBlog(feedURL string) error {
12+
blog, err := cmd.repo.Blog().ReadByFeedURL(feedURL)
13+
if err != nil {
14+
if !errors.Is(err, postgres.ErrNotFound) {
15+
return err
16+
}
17+
18+
// An ErrNotFound is acceptable (and expected) here. The only difference
19+
// is that we won't be able to include the ETag and Last-Modified headers
20+
// in the request. This is fine for new blogs (an unconditional fetch).
21+
return sync.SyncNewBlog(cmd.repo, cmd.feedFetcher, feedURL)
22+
}
23+
24+
return sync.SyncExistingBlog(cmd.repo, cmd.feedFetcher, blog)
25+
}

backend/command/sync/sync.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package sync
2+
3+
import (
4+
"log/slog"
5+
6+
"github.com/theandrew168/bloggulus/backend/feed"
7+
"github.com/theandrew168/bloggulus/backend/fetch"
8+
"github.com/theandrew168/bloggulus/backend/model"
9+
"github.com/theandrew168/bloggulus/backend/repository"
10+
"github.com/theandrew168/bloggulus/backend/timeutil"
11+
)
12+
13+
// UpdateCacheHeaders updates the ETag and Last-Modified headers for a blog if they have changed.
14+
func UpdateCacheHeaders(blog *model.Blog, response fetch.FetchFeedResponse) bool {
15+
headersChanged := false
16+
if response.ETag != "" && response.ETag != blog.ETag() {
17+
headersChanged = true
18+
blog.SetETag(response.ETag)
19+
}
20+
21+
if response.LastModified != "" && response.LastModified != blog.LastModified() {
22+
headersChanged = true
23+
blog.SetLastModified(response.LastModified)
24+
}
25+
26+
return headersChanged
27+
}
28+
29+
type ComparePostsResult struct {
30+
PostsToCreate []*model.Post
31+
PostsToUpdate []*model.Post
32+
}
33+
34+
// ComparePosts compares a list of known posts to a list of feed posts and returns
35+
// a list of posts to create and a list of posts to update.
36+
func ComparePosts(blog *model.Blog, knownPosts []*model.Post, feedPosts []feed.Post) (ComparePostsResult, error) {
37+
// Create a map of URLs to posts for quick lookups.
38+
knownPostsByURL := make(map[string]*model.Post)
39+
for _, post := range knownPosts {
40+
knownPostsByURL[post.URL()] = post
41+
}
42+
43+
var postsToCreate []*model.Post
44+
var postsToUpdate []*model.Post
45+
46+
// Compare each post in the feed to the posts in the database.
47+
for _, feedPost := range feedPosts {
48+
knownPost, ok := knownPostsByURL[feedPost.URL]
49+
if !ok {
50+
// The post is new so we need to create it.
51+
postToCreate, err := model.NewPost(
52+
blog,
53+
feedPost.URL,
54+
feedPost.Title,
55+
feedPost.Content,
56+
feedPost.PublishedAt,
57+
)
58+
if err != nil {
59+
return ComparePostsResult{}, err
60+
}
61+
62+
postsToCreate = append(postsToCreate, postToCreate)
63+
} else {
64+
// The post already exists but we might need to update it.
65+
knownPostShouldBeUpdated := false
66+
67+
// Check if the post's title has changed.
68+
if feedPost.Title != "" && feedPost.Title != knownPost.Title() {
69+
knownPost.SetTitle(feedPost.Title)
70+
knownPostShouldBeUpdated = true
71+
}
72+
73+
// Check if the post's content has changed.
74+
if feedPost.Content != "" && feedPost.Content != knownPost.Content() {
75+
knownPost.SetContent(feedPost.Content)
76+
knownPostShouldBeUpdated = true
77+
}
78+
79+
// Check if the post's publishedAt date has changed.
80+
if feedPost.PublishedAt != knownPost.PublishedAt() {
81+
knownPost.SetPublishedAt(feedPost.PublishedAt)
82+
knownPostShouldBeUpdated = true
83+
}
84+
85+
// If any post data has changed, add it to the list of posts to update.
86+
if knownPostShouldBeUpdated {
87+
postsToUpdate = append(postsToUpdate, knownPost)
88+
}
89+
}
90+
}
91+
92+
result := ComparePostsResult{
93+
PostsToCreate: postsToCreate,
94+
PostsToUpdate: postsToUpdate,
95+
}
96+
return result, nil
97+
}
98+
99+
func SyncNewBlog(repo *repository.Repository, feedFetcher fetch.FeedFetcher, feedURL string) error {
100+
// Make an unconditional fetch for the blog's feed.
101+
req := fetch.FetchFeedRequest{
102+
URL: feedURL,
103+
}
104+
resp, err := feedFetcher.FetchFeed(req)
105+
if err != nil {
106+
return err
107+
}
108+
109+
// No feed data from a new blog is an error.
110+
if resp.Feed == "" {
111+
return fetch.ErrUnreachableFeed
112+
}
113+
114+
feedBlog, err := feed.Parse(feedURL, resp.Feed)
115+
if err != nil {
116+
return err
117+
}
118+
119+
// Create a new blog based on the feed data.
120+
blog, err := model.NewBlog(
121+
feedBlog.FeedURL,
122+
feedBlog.SiteURL,
123+
feedBlog.Title,
124+
resp.ETag,
125+
resp.LastModified,
126+
timeutil.Now(),
127+
)
128+
if err != nil {
129+
return err
130+
}
131+
132+
err = repo.Blog().Create(blog)
133+
if err != nil {
134+
return err
135+
}
136+
137+
err = SyncPosts(repo, blog, feedBlog.Posts)
138+
if err != nil {
139+
return err
140+
}
141+
142+
return nil
143+
}
144+
145+
func SyncExistingBlog(repo *repository.Repository, feedFetcher fetch.FeedFetcher, blog *model.Blog) error {
146+
// Make a conditional fetch for the blog's feed.
147+
req := fetch.FetchFeedRequest{
148+
URL: blog.FeedURL(),
149+
ETag: blog.ETag(),
150+
LastModified: blog.LastModified(),
151+
}
152+
resp, err := feedFetcher.FetchFeed(req)
153+
if err != nil {
154+
return err
155+
}
156+
157+
// Update the blog's cache headers if they have changed.
158+
headersChanged := UpdateCacheHeaders(blog, resp)
159+
if headersChanged {
160+
err = repo.Blog().Update(blog)
161+
if err != nil {
162+
return err
163+
}
164+
}
165+
166+
if resp.Feed == "" {
167+
slog.Info("skipping blog (no feed content)", "title", blog.Title(), "id", blog.ID())
168+
return nil
169+
}
170+
171+
feedBlog, err := feed.Parse(blog.FeedURL(), resp.Feed)
172+
if err != nil {
173+
return err
174+
}
175+
176+
err = SyncPosts(repo, blog, feedBlog.Posts)
177+
if err != nil {
178+
return err
179+
}
180+
181+
return nil
182+
}
183+
184+
func SyncPosts(repo *repository.Repository, blog *model.Blog, feedPosts []feed.Post) error {
185+
// List all known posts for the current blog.
186+
knownPosts, err := repo.Post().ListByBlog(blog)
187+
if err != nil {
188+
return err
189+
}
190+
191+
// Compare the known posts to the feed posts.
192+
result, err := ComparePosts(blog, knownPosts, feedPosts)
193+
if err != nil {
194+
return err
195+
}
196+
197+
// Create any posts that are new.
198+
for _, post := range result.PostsToCreate {
199+
err = repo.Post().Create(post)
200+
if err != nil {
201+
slog.Warn("failed to create post", "url", post.URL(), "error", err.Error())
202+
}
203+
}
204+
205+
// Update any posts that have changed.
206+
for _, post := range result.PostsToUpdate {
207+
err = repo.Post().Update(post)
208+
if err != nil {
209+
slog.Warn("failed to update post", "url", post.URL(), "error", err.Error())
210+
}
211+
}
212+
213+
return nil
214+
}

0 commit comments

Comments
 (0)