Skip to content

Commit 8d764c4

Browse files
committed
add story refresh button
Occasionally enclosure urls become invalid after feed providers delete the original file. In the case I found, the latest version of the feed had a valid enclosure url, so refreshing the story from the feed should work. This is the first time something like this has happened in my experience, so I think a manual refresh should be sufficient for these cases. I considered doing an automatic refresh, but wanted to minimize negative side effects from changing stories after the fact.
1 parent 07e2f6c commit 8d764c4

19 files changed

Lines changed: 445 additions & 24 deletions

File tree

.eslint_todo.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// This configuration was generated by `exe/eslint_autogen`
2-
// on 2026-07-04 16:59:21 UTC.
2+
// on 2026-07-06 17:55:24 UTC.
33
// The point is for the user to remove these configuration records
44
// one by one as the offenses are removed from the code base.
55

@@ -97,7 +97,7 @@ const config: Linter.Config[] = [
9797
"@stylistic/quote-props": "off",
9898
},
9999
},
100-
// Offense count: 40
100+
// Offense count: 41
101101
{
102102
files: [
103103
"app/javascript/application.ts",
@@ -198,7 +198,7 @@ const config: Linter.Config[] = [
198198
"@typescript-eslint/no-unsafe-assignment": "off",
199199
},
200200
},
201-
// Offense count: 177
201+
// Offense count: 181
202202
{
203203
files: [
204204
"app/javascript/application.ts",
@@ -210,7 +210,7 @@ const config: Linter.Config[] = [
210210
"@typescript-eslint/no-unsafe-call": "off",
211211
},
212212
},
213-
// Offense count: 260
213+
// Offense count: 266
214214
{
215215
files: [
216216
"app/javascript/application.ts",
@@ -267,10 +267,12 @@ const config: Linter.Config[] = [
267267
"@typescript-eslint/strict-boolean-expressions": "off",
268268
},
269269
},
270-
// Offense count: 17
270+
// Offense count: 21
271271
{
272272
files: [
273273
"app/javascript/application.ts",
274+
"spec/javascript/controllers/story_refresh_controller_spec.ts",
275+
"spec/javascript/helpers/api_spec.ts",
274276
"spec/javascript/spec/models/story_spec.ts",
275277
"spec/javascript/spec/views/story_view_spec.ts",
276278
],
@@ -326,7 +328,7 @@ const config: Linter.Config[] = [
326328
"func-style": "off",
327329
},
328330
},
329-
// Offense count: 9
331+
// Offense count: 10
330332
{
331333
files: [
332334
"app/javascript/application.ts",
@@ -510,9 +512,11 @@ const config: Linter.Config[] = [
510512
"vars-on-top": "off",
511513
},
512514
},
513-
// Offense count: 3
515+
// Offense count: 6
514516
{
515517
files: [
518+
"spec/javascript/controllers/story_refresh_controller_spec.ts",
519+
"spec/javascript/helpers/api_spec.ts",
516520
"spec/javascript/spec/views/story_view_spec.ts",
517521
],
518522
rules: {

.rubocop_todo.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ RSpec/LeakyLocalVariable:
156156
- 'spec/requests/stories_controller_spec.rb'
157157
- 'spec/utils/feed_discovery_spec.rb'
158158

159-
# Offense count: 18
159+
# Offense count: 19
160160
# Configuration parameters: EnforcedStyle.
161161
# SupportedStyles: allow, expect
162162
RSpec/MessageExpectation:
@@ -165,6 +165,7 @@ RSpec/MessageExpectation:
165165
- 'spec/integration/feed_importing_spec.rb'
166166
- 'spec/models/migration_status_spec.rb'
167167
- 'spec/repositories/story_repository_spec.rb'
168+
- 'spec/system/stories_index_spec.rb'
168169
- 'spec/tasks/remove_old_stories_spec.rb'
169170
- 'spec/utils/i18n_support_spec.rb'
170171

@@ -207,12 +208,13 @@ RSpec/NoBeforeHook:
207208
- 'spec/system/authentication_spec.rb'
208209
- 'spec/system/profile_spec.rb'
209210

210-
# Offense count: 5
211+
# Offense count: 6
211212
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
212213
RSpec/VerifiedDoubles:
213214
Exclude:
214215
- 'spec/commands/feed/fetch_one_spec.rb'
215216
- 'spec/commands/feed/find_new_stories_spec.rb'
217+
- 'spec/commands/story/refresh_from_feed_spec.rb'
216218
- 'spec/tasks/remove_old_stories_spec.rb'
217219

218220
# Offense count: 1

app/assets/stylesheets/application.css

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ li.story.open .story-preview {
258258
float: right;
259259
}
260260

261-
.story-keep-unread, .story-starred {
261+
.story-keep-unread, .story-starred, .story-refresh {
262262
display: inline-block;
263263
cursor: pointer;
264264
-webkit-touch-callout: none;
@@ -379,6 +379,8 @@ p.story-details {
379379
/* end Wordpress hacks */
380380

381381
.story-actions-container {
382+
display: flex;
383+
justify-content: space-between;
382384
border-top: 2px solid #FAF2E5;
383385
height: 28px;
384386
line-height: 30px;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
module RefreshFromFeed
4+
class << self
5+
def call(story)
6+
entry = find_entry(story)
7+
return false if entry.nil?
8+
9+
StoryRepository.update_from_entry(story, entry)
10+
11+
true
12+
rescue StandardError => e
13+
Rails.logger.error(
14+
"Something went wrong when refreshing story #{story.id}: #{e}"
15+
)
16+
17+
false
18+
end
19+
20+
private
21+
22+
def find_entry(story)
23+
response = SafeFetch.body(story.feed.url)
24+
raw_feed = Feedjira.parse(response)
25+
26+
raw_feed.entries.find { |entry| entry.id == story.entry_id }
27+
end
28+
end
29+
end

app/controllers/stories_controller.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ def update
2222
head(:no_content)
2323
end
2424

25+
def refresh
26+
story = authorization.check(StoryRepository.fetch(params[:id]))
27+
28+
if RefreshFromFeed.call(story)
29+
render(json: story)
30+
else
31+
head(:unprocessable_content)
32+
end
33+
end
34+
2535
def mark_all_as_read
2636
stories = authorization.scope(Story.where(id: params[:story_ids]))
2737
MarkAllAsRead.call(stories.ids)

app/javascript/application.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ var StoryView = Backbone.NativeView.extend({
130130
this.model.set({is_read: detail.isRead, keep_unread: detail.keepUnread}, {silent: true});
131131
this.model.trigger('change:is_read');
132132
});
133+
this.el.addEventListener('story-refresh:refreshed', (e) => {
134+
this.model.set(e.detail.story, {silent: true});
135+
this.render();
136+
this.itemOpened();
137+
});
133138
},
134139

135140
itemOpened: function() {
@@ -164,12 +169,13 @@ var StoryView = Backbone.NativeView.extend({
164169
this.el.classList.add('keepUnread');
165170
}
166171
Object.assign(this.el.dataset, {
167-
controller: "star-toggle keep-unread-toggle",
172+
controller: "star-toggle keep-unread-toggle story-refresh",
168173
keepUnreadToggleIdValue: String(jsonModel.id),
169174
keepUnreadToggleIsReadValue: String(jsonModel.is_read),
170175
keepUnreadToggleKeepUnreadValue: String(jsonModel.keep_unread),
171176
starToggleIdValue: String(jsonModel.id),
172177
starToggleStarredValue: String(jsonModel.is_starred),
178+
storyRefreshIdValue: String(jsonModel.id),
173179
unreadCountTarget: "story",
174180
});
175181
return this;

app/javascript/controllers/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,8 @@ application.register("mark-all-as-read", MarkAllAsReadController);
2121
import StarToggleController from "./star_toggle_controller";
2222
application.register("star-toggle", StarToggleController);
2323

24+
import StoryRefreshController from "./story_refresh_controller";
25+
application.register("story-refresh", StoryRefreshController);
26+
2427
import UnreadCountController from "./unread_count_controller";
2528
application.register("unread-count", UnreadCountController);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {Controller} from "@hotwired/stimulus";
2+
3+
import {refreshStory} from "helpers/api";
4+
5+
export default class extends Controller {
6+
static override values = {id: String};
7+
8+
declare idValue: string;
9+
10+
async refresh(): Promise<void> {
11+
const response = await refreshStory(this.idValue);
12+
if (!response.ok) {
13+
throw new Error(`Failed to refresh story ${this.idValue}`);
14+
}
15+
16+
const story: unknown = await response.json();
17+
this.dispatch("refreshed", {bubbles: true, detail: {story}});
18+
}
19+
}

app/javascript/helpers/api.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,11 @@ async function updateStory(
1919
});
2020
}
2121

22-
export {updateStory};
22+
async function refreshStory(id: string): Promise<Response> {
23+
return fetch(`/stories/${id}/refresh`, {
24+
headers: {"X-CSRF-Token": csrfToken()},
25+
method: "POST",
26+
});
27+
}
28+
29+
export {refreshStory, updateStory};

app/repositories/story_repository.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ def self.add(entry, feed)
1717
)
1818
end
1919

20+
def self.update_from_entry(story, entry)
21+
feed = story.feed
22+
23+
story.update!(
24+
title: extract_title(entry),
25+
permalink: extract_url(entry, feed),
26+
enclosure_url: safe_normalize_url(entry.try(:enclosure_url), feed.url),
27+
body: extract_content(entry)
28+
)
29+
end
30+
2031
def self.fetch(id)
2132
Story.find(id)
2233
end

0 commit comments

Comments
 (0)