diff --git a/.eslint_todo.ts b/.eslint_todo.ts index 3407522c9..d65430851 100644 --- a/.eslint_todo.ts +++ b/.eslint_todo.ts @@ -1,5 +1,5 @@ // This configuration was generated by `exe/eslint_autogen` -// on 2026-07-04 16:59:21 UTC. +// on 2026-07-06 17:55:24 UTC. // The point is for the user to remove these configuration records // one by one as the offenses are removed from the code base. @@ -97,7 +97,7 @@ const config: Linter.Config[] = [ "@stylistic/quote-props": "off", }, }, - // Offense count: 40 + // Offense count: 41 { files: [ "app/javascript/application.ts", @@ -198,7 +198,7 @@ const config: Linter.Config[] = [ "@typescript-eslint/no-unsafe-assignment": "off", }, }, - // Offense count: 177 + // Offense count: 181 { files: [ "app/javascript/application.ts", @@ -210,7 +210,7 @@ const config: Linter.Config[] = [ "@typescript-eslint/no-unsafe-call": "off", }, }, - // Offense count: 260 + // Offense count: 266 { files: [ "app/javascript/application.ts", @@ -267,10 +267,12 @@ const config: Linter.Config[] = [ "@typescript-eslint/strict-boolean-expressions": "off", }, }, - // Offense count: 17 + // Offense count: 21 { files: [ "app/javascript/application.ts", + "spec/javascript/controllers/story_refresh_controller_spec.ts", + "spec/javascript/helpers/api_spec.ts", "spec/javascript/spec/models/story_spec.ts", "spec/javascript/spec/views/story_view_spec.ts", ], @@ -326,7 +328,7 @@ const config: Linter.Config[] = [ "func-style": "off", }, }, - // Offense count: 9 + // Offense count: 10 { files: [ "app/javascript/application.ts", @@ -510,9 +512,11 @@ const config: Linter.Config[] = [ "vars-on-top": "off", }, }, - // Offense count: 3 + // Offense count: 6 { files: [ + "spec/javascript/controllers/story_refresh_controller_spec.ts", + "spec/javascript/helpers/api_spec.ts", "spec/javascript/spec/views/story_view_spec.ts", ], rules: { diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index c195f601b..50c036344 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -156,7 +156,7 @@ RSpec/LeakyLocalVariable: - 'spec/requests/stories_controller_spec.rb' - 'spec/utils/feed_discovery_spec.rb' -# Offense count: 18 +# Offense count: 19 # Configuration parameters: EnforcedStyle. # SupportedStyles: allow, expect RSpec/MessageExpectation: @@ -165,6 +165,7 @@ RSpec/MessageExpectation: - 'spec/integration/feed_importing_spec.rb' - 'spec/models/migration_status_spec.rb' - 'spec/repositories/story_repository_spec.rb' + - 'spec/system/stories_index_spec.rb' - 'spec/tasks/remove_old_stories_spec.rb' - 'spec/utils/i18n_support_spec.rb' @@ -207,12 +208,13 @@ RSpec/NoBeforeHook: - 'spec/system/authentication_spec.rb' - 'spec/system/profile_spec.rb' -# Offense count: 5 +# Offense count: 6 # Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. RSpec/VerifiedDoubles: Exclude: - 'spec/commands/feed/fetch_one_spec.rb' - 'spec/commands/feed/find_new_stories_spec.rb' + - 'spec/commands/story/refresh_from_feed_spec.rb' - 'spec/tasks/remove_old_stories_spec.rb' # Offense count: 1 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 0939a389c..91edb4852 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -258,7 +258,7 @@ li.story.open .story-preview { float: right; } -.story-keep-unread, .story-starred { +.story-keep-unread, .story-starred, .story-refresh { display: inline-block; cursor: pointer; -webkit-touch-callout: none; @@ -379,6 +379,8 @@ p.story-details { /* end Wordpress hacks */ .story-actions-container { + display: flex; + justify-content: space-between; border-top: 2px solid #FAF2E5; height: 28px; line-height: 30px; diff --git a/app/commands/story/refresh_from_feed.rb b/app/commands/story/refresh_from_feed.rb new file mode 100644 index 000000000..f15358cbd --- /dev/null +++ b/app/commands/story/refresh_from_feed.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module RefreshFromFeed + class << self + def call(story) + entry = find_entry(story) + return false if entry.nil? + + StoryRepository.update_from_entry(story, entry) + + true + rescue StandardError => e + Rails.logger.error( + "Something went wrong when refreshing story #{story.id}: #{e}" + ) + + false + end + + private + + def find_entry(story) + response = SafeFetch.body(story.feed.url) + raw_feed = Feedjira.parse(response) + + raw_feed.entries.find { |entry| entry.id == story.entry_id } + end + end +end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index 6dfcd18cf..4486f32d2 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -22,6 +22,16 @@ def update head(:no_content) end + def refresh + story = authorization.check(StoryRepository.fetch(params[:id])) + + if RefreshFromFeed.call(story) + render(json: story) + else + head(:unprocessable_content) + end + end + def mark_all_as_read stories = authorization.scope(Story.where(id: params[:story_ids])) MarkAllAsRead.call(stories.ids) diff --git a/app/javascript/application.ts b/app/javascript/application.ts index dd48e3b02..40154c6a0 100644 --- a/app/javascript/application.ts +++ b/app/javascript/application.ts @@ -130,6 +130,11 @@ var StoryView = Backbone.NativeView.extend({ this.model.set({is_read: detail.isRead, keep_unread: detail.keepUnread}, {silent: true}); this.model.trigger('change:is_read'); }); + this.el.addEventListener('story-refresh:refreshed', (e) => { + this.model.set(e.detail.story, {silent: true}); + this.render(); + this.itemOpened(); + }); }, itemOpened: function() { @@ -164,12 +169,13 @@ var StoryView = Backbone.NativeView.extend({ this.el.classList.add('keepUnread'); } Object.assign(this.el.dataset, { - controller: "star-toggle keep-unread-toggle", + controller: "star-toggle keep-unread-toggle story-refresh", keepUnreadToggleIdValue: String(jsonModel.id), keepUnreadToggleIsReadValue: String(jsonModel.is_read), keepUnreadToggleKeepUnreadValue: String(jsonModel.keep_unread), starToggleIdValue: String(jsonModel.id), starToggleStarredValue: String(jsonModel.is_starred), + storyRefreshIdValue: String(jsonModel.id), unreadCountTarget: "story", }); return this; diff --git a/app/javascript/controllers/index.ts b/app/javascript/controllers/index.ts index 6a53fd8da..8531a717c 100644 --- a/app/javascript/controllers/index.ts +++ b/app/javascript/controllers/index.ts @@ -21,5 +21,8 @@ application.register("mark-all-as-read", MarkAllAsReadController); import StarToggleController from "./star_toggle_controller"; application.register("star-toggle", StarToggleController); +import StoryRefreshController from "./story_refresh_controller"; +application.register("story-refresh", StoryRefreshController); + import UnreadCountController from "./unread_count_controller"; application.register("unread-count", UnreadCountController); diff --git a/app/javascript/controllers/story_refresh_controller.ts b/app/javascript/controllers/story_refresh_controller.ts new file mode 100644 index 000000000..a4a8cd0f8 --- /dev/null +++ b/app/javascript/controllers/story_refresh_controller.ts @@ -0,0 +1,19 @@ +import {Controller} from "@hotwired/stimulus"; + +import {refreshStory} from "helpers/api"; + +export default class extends Controller { + static override values = {id: String}; + + declare idValue: string; + + async refresh(): Promise { + const response = await refreshStory(this.idValue); + if (!response.ok) { + throw new Error(`Failed to refresh story ${this.idValue}`); + } + + const story: unknown = await response.json(); + this.dispatch("refreshed", {bubbles: true, detail: {story}}); + } +} diff --git a/app/javascript/helpers/api.ts b/app/javascript/helpers/api.ts index 24751b743..34168f17f 100644 --- a/app/javascript/helpers/api.ts +++ b/app/javascript/helpers/api.ts @@ -19,4 +19,11 @@ async function updateStory( }); } -export {updateStory}; +async function refreshStory(id: string): Promise { + return fetch(`/stories/${id}/refresh`, { + headers: {"X-CSRF-Token": csrfToken()}, + method: "POST", + }); +} + +export {refreshStory, updateStory}; diff --git a/app/repositories/story_repository.rb b/app/repositories/story_repository.rb index a46eb252c..69e15caf7 100644 --- a/app/repositories/story_repository.rb +++ b/app/repositories/story_repository.rb @@ -17,6 +17,17 @@ def self.add(entry, feed) ) end + def self.update_from_entry(story, entry) + feed = story.feed + + story.update!( + title: extract_title(entry), + permalink: extract_url(entry, feed), + enclosure_url: safe_normalize_url(entry.try(:enclosure_url), feed.url), + body: extract_content(entry) + ) + end + def self.fetch(id) Story.find(id) end diff --git a/app/views/stories/_templates.html.erb b/app/views/stories/_templates.html.erb index 39a727c58..5edd72714 100644 --- a/app/views/stories/_templates.html.erb +++ b/app/views/stories/_templates.html.erb @@ -32,19 +32,22 @@ {{= body }} -
-
+
+
-
+
<%= t('stories.keep_unread') %>
+
+ +
diff --git a/config/locales/en.yml b/config/locales/en.yml index 52f415fca..8259d5cb5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -167,6 +167,7 @@ en: sorry: Sorry, you haven't starred any stories yet! stories: keep_unread: Keep unread + refresh: Refresh story from feed time: formats: default: '%b %d, %H:%M' diff --git a/config/routes.rb b/config/routes.rb index 7f67603cf..6d567cd99 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,5 +37,6 @@ get "/setup/tutorial", to: "tutorials#index" get "/starred", to: "stories#starred" put "/stories/:id", to: "stories#update" + post "/stories/:id/refresh", to: "stories#refresh" post "/stories/mark_all_as_read", to: "stories#mark_all_as_read" end diff --git a/spec/commands/story/refresh_from_feed_spec.rb b/spec/commands/story/refresh_from_feed_spec.rb new file mode 100644 index 000000000..d4d5288da --- /dev/null +++ b/spec/commands/story/refresh_from_feed_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +RSpec.describe RefreshFromFeed do + def create_entry(**options) + entry = { + guid: "entry-guid", + published: Time.zone.now, + title: "Updated title", + url: "https://example.com/updated", + content: "Updated content", + enclosure_url: "https://example.com/updated.mp3", + **options + } + double(entry) + end + + def stub_raw_feed(feed, entries: []) + xml = GenerateXml.call(feed, entries) + stub_request(:get, feed.url).to_return(status: 200, body: xml) + end + + def stub_invalid_feed(feed) + stub_request(:get, feed.url).to_return(status: 200, body: "not a feed") + end + + def create_refreshable_story(**) + story = create(:story, entry_id: "entry-guid", **) + stub_raw_feed(story.feed, entries: [create_entry]) + story + end + + context "when the entry is still in the feed" do + it "updates the enclosure url" do + story = + create_refreshable_story(enclosure_url: "https://example.com/dead.mp3") + + expect { described_class.call(story) } + .to change_record(story, :enclosure_url) + .to("https://example.com/updated.mp3") + end + + it "updates the title and body" do + story = create_refreshable_story(title: "Old title") + + described_class.call(story) + + expect(story.reload) + .to have_attributes(title: "Updated title", body: "Updated content") + end + + it "updates the permalink" do + story = create_refreshable_story + + described_class.call(story) + + expect(story.reload.permalink).to eq("https://example.com/updated") + end + + it "returns true" do + story = create_refreshable_story + + expect(described_class.call(story)).to be(true) + end + end + + context "when the entry is no longer in the feed" do + it "returns false" do + story = create_refreshable_story(entry_id: "gone-guid") + + expect(described_class.call(story)).to be(false) + end + + it "does not change the story" do + story = + create_refreshable_story(entry_id: "gone-guid", title: "Old title") + + described_class.call(story) + + expect(story.reload.title).to eq("Old title") + end + end + + context "when the feed cannot be fetched or parsed" do + it "returns false" do + story = create(:story) + stub_invalid_feed(story.feed) + + expect(described_class.call(story)).to be(false) + end + + it "logs an error" do + story = create(:story) + stub_invalid_feed(story.feed) + + expect { described_class.call(story) } + .to invoke(:error).on(Rails.logger).with(/refreshing story/) + end + end +end diff --git a/spec/javascript/controllers/story_refresh_controller_spec.ts b/spec/javascript/controllers/story_refresh_controller_spec.ts new file mode 100644 index 000000000..71e13cf60 --- /dev/null +++ b/spec/javascript/controllers/story_refresh_controller_spec.ts @@ -0,0 +1,90 @@ +import type {MockInstance} from "vitest"; +import {bootStimulus, getController} from "support/stimulus"; +import StoryRefreshController from "controllers/story_refresh_controller"; +import {assert} from "helpers/assert"; + +function setupDOM(): void { + // Static test fixture — safe to use innerHTML + document.body.innerHTML = [ + "
  • ", + "
    story-refresh#refresh:stop\">", + " ", + "
    ", + "
  • ", + ].join("\n"); +} + +async function setupController(): Promise { + setupDOM(); + await bootStimulus("story-refresh", StoryRefreshController); +} + +const sel = "[data-controller='story-refresh']"; + +function element(): HTMLElement { + return assert(document.querySelector(sel)); +} + +function controller(): StoryRefreshController { + return getController(element(), "story-refresh", StoryRefreshController); +} + +function mockFetch(status = 200, body = "{}"): MockInstance { + return vi.spyOn(globalThis, "fetch"). + mockResolvedValue(new Response(body, {status})); +} + +function captureRefreshDetails(): unknown[] { + const details: unknown[] = []; + function listener(event: Event): void { + if (event instanceof CustomEvent) { + const detail: unknown = event.detail; + details.push(detail); + } + } + element().addEventListener("story-refresh:refreshed", listener); + return details; +} + +describe("refresh", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("posts to the refresh endpoint", async () => { + await setupController(); + const fetchSpy = mockFetch(); + + await controller().refresh(); + + expect(fetchSpy).toHaveBeenCalledWith( + "/stories/42/refresh", + expect.objectContaining({method: "POST"}), + ); + }); + + it("dispatches a refreshed event with the story", async () => { + await setupController(); + const story = {enclosure_url: "https://example.com/new.mp3", id: 42}; + mockFetch(200, JSON.stringify(story)); + const details = captureRefreshDetails(); + + await controller().refresh(); + + expect(details).toStrictEqual([{story}]); + }); + + it("throws when the request fails", async () => { + await setupController(); + mockFetch(500); + const details = captureRefreshDetails(); + + await expect(controller().refresh()). + rejects.toThrow("Failed to refresh story 42"); + + expect(details).toStrictEqual([]); + }); +}); diff --git a/spec/javascript/helpers/api_spec.ts b/spec/javascript/helpers/api_spec.ts index 0519bc8ab..6b8c29c08 100644 --- a/spec/javascript/helpers/api_spec.ts +++ b/spec/javascript/helpers/api_spec.ts @@ -1,7 +1,6 @@ -import {updateStory} from "helpers/api"; +import {refreshStory, updateStory} from "helpers/api"; describe("updateStory", () => { - // eslint-disable-next-line vitest/no-hooks afterEach(() => { vi.restoreAllMocks(); }); @@ -15,12 +14,9 @@ describe("updateStory", () => { meta.name = "csrf-token"; meta.content = "test-token"; document.head.appendChild(meta); - - // eslint-disable-next-line camelcase await updateStory("42", {is_starred: true}); expect(fetchSpy).toHaveBeenCalledWith("/stories/42", { - // eslint-disable-next-line camelcase body: JSON.stringify({is_starred: true}), headers: { "Content-Type": "application/json", @@ -35,8 +31,6 @@ describe("updateStory", () => { it("sends empty CSRF token when meta tag is missing", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"). mockResolvedValue(new Response()); - - // eslint-disable-next-line camelcase await updateStory("1", {is_starred: false}); expect(fetchSpy).toHaveBeenCalledTimes(1); @@ -46,3 +40,28 @@ describe("updateStory", () => { expect(headers).toHaveProperty("X-CSRF-Token", ""); }); }); + +describe("refreshStory", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends a POST with CSRF token", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"). + mockResolvedValue(new Response("{}", {status: 200})); + + const meta = document.createElement("meta"); + meta.name = "csrf-token"; + meta.content = "test-token"; + document.head.appendChild(meta); + + await refreshStory("42"); + + expect(fetchSpy).toHaveBeenCalledWith("/stories/42/refresh", { + headers: {"X-CSRF-Token": "test-token"}, + method: "POST", + }); + + meta.remove(); + }); +}); diff --git a/spec/requests/stories_controller_spec.rb b/spec/requests/stories_controller_spec.rb index 6ab6f0d32..d7fee5a79 100644 --- a/spec/requests/stories_controller_spec.rb +++ b/spec/requests/stories_controller_spec.rb @@ -151,6 +151,57 @@ end end + describe "#refresh" do + def updated_entry + double( + guid: "entry-guid", + published: Time.zone.now, + title: "Updated title", + url: "https://example.com/updated", + content: "Updated content", + enclosure_url: "https://example.com/updated.mp3" + ) + end + + def stub_updated_feed(feed) + xml = GenerateXml.call(feed, [updated_entry]) + stub_request(:get, feed.url).to_return(status: 200, body: xml) + end + + def stub_invalid_feed(feed) + stub_request(:get, feed.url).to_return(status: 200, body: "not a feed") + end + + it "updates the story from the feed" do + login_as(default_user) + story = create(:story, entry_id: "entry-guid") + stub_updated_feed(story.feed) + + expect { post("/stories/#{story.id}/refresh") } + .to change_record(story, :title).to("Updated title") + end + + it "returns the refreshed story as JSON" do + login_as(default_user) + story = create(:story, entry_id: "entry-guid") + stub_updated_feed(story.feed) + + post("/stories/#{story.id}/refresh") + + expect(response.parsed_body).to include("title" => "Updated title") + end + + it "responds with unprocessable content when the refresh fails" do + login_as(default_user) + story = create(:story) + stub_invalid_feed(story.feed) + + post("/stories/#{story.id}/refresh") + + expect(response).to have_http_status(:unprocessable_content) + end + end + describe "#mark_all_as_read" do it "marks all unread stories as read and reload the page" do login_as(default_user) diff --git a/spec/support/generate_xml.rb b/spec/support/generate_xml.rb index 659903869..90f2c683b 100644 --- a/spec/support/generate_xml.rb +++ b/spec/support/generate_xml.rb @@ -10,7 +10,7 @@ def call(feed, items) def build_feed(feed, items) Nokogiri::XML::Builder.new do |xml| - xml.rss(version: "2.0") do + xml.rss(**rss_attributes(items)) do xml.title(feed.name) xml.link(feed.url) items.each { |item| build_item(xml, item) } @@ -18,12 +18,31 @@ def build_feed(feed, items) end end + # Feedjira only parses enclosures on itunes feeds, so declare the + # namespace when an item carries one. + def rss_attributes(items) + attributes = { + version: "2.0", + "xmlns:content" => "http://purl.org/rss/1.0/modules/content/" + } + + if items.any? { |item| item.try(:enclosure_url) } + attributes["xmlns:itunes"] = "http://www.itunes.com/dtds/podcast-1.0.dtd" + end + + attributes + end + def build_item(xml, item) xml.item do xml.title(item.title) xml.link(item.url) + xml.guid(item.guid) if item.try(:guid) xml.pubDate(item.published) - xml.content(item.content) + xml["content"].encoded(item.content) + if item.try(:enclosure_url) + xml.enclosure(url: item.enclosure_url, type: "audio/mpeg", length: 0) + end end end end diff --git a/spec/system/stories_index_spec.rb b/spec/system/stories_index_spec.rb index 89fdf3b28..f2c493898 100644 --- a/spec/system/stories_index_spec.rb +++ b/spec/system/stories_index_spec.rb @@ -153,6 +153,51 @@ def open_story_and_find_unread_icon(story_title) expect(page).to have_no_css("a.story-enclosure") end + def updated_feed_entry(entry_id:, enclosure_url:) + double( + guid: entry_id, + title: "Podcast Episode", + url: "http://example.com/episode", + published: Time.zone.now, + content: "Show notes", + enclosure_url: + ) + end + + def serve_feed_with_entry(feed, **entry_options) + server = FeedServer.new + entry = updated_feed_entry(**entry_options) + server.response = GenerateXml.call(feed, [entry]) + feed.update!(url: server.url) + + # The local FeedServer binds to loopback, which the SSRF guard blocks. + # Treat addresses as public so the refresh fetch can reach it. + allow(PrivateAddressCheck) + .to receive(:resolves_to_private_address?).and_return(false) + end + + it "refreshes a story from its feed", :aggregate_failures do + story = create( + :story, + title: "Podcast Episode", + entry_id: "episode-1", + enclosure_url: "http://example.com/dead.mp3" + ) + serve_feed_with_entry( + story.feed, + entry_id: "episode-1", + enclosure_url: "http://example.com/fixed.mp3" + ) + login_as(default_user) + visit(news_path) + + find(".story-preview", text: "Podcast Episode").click + find(".story-actions .story-refresh").click + + expect(page).to have_link(href: "http://example.com/fixed.mp3") + expect(page).to have_no_link(href: "http://example.com/dead.mp3") + end + it "marks a story as read when opened" do create(:story, title: "My Story") login_as(default_user)