From de21f7e70f9a2663b9399353dbbd4de3262b2439 Mon Sep 17 00:00:00 2001 From: Robert Fletcher Date: Sun, 22 Feb 2026 21:03:00 -0800 Subject: [PATCH] Allow renaming file downloads --- app/controllers/profiles_controller.rb | 9 +- .../enclosure_download_controller.ts | 67 +++++++++ app/javascript/controllers/index.ts | 3 + app/models/user.rb | 11 ++ app/views/js/templates/_story.js.erb | 8 +- app/views/profiles/edit.html.erb | 2 + app/views/stories/_templates.html.erb | 8 +- config/locales/en.yml | 6 +- .../20260223045507_add_settings_to_users.rb | 7 + db/schema.rb | 127 +++++++++--------- .../enclosure_download_controller_spec.ts | 102 ++++++++++++++ spec/javascript/setup.ts | 8 +- spec/models/user_spec.rb | 21 +++ spec/requests/profiles_controller_spec.rb | 11 ++ 14 files changed, 322 insertions(+), 68 deletions(-) create mode 100644 app/javascript/controllers/enclosure_download_controller.ts create mode 100644 db/migrate/20260223045507_add_settings_to_users.rb create mode 100644 spec/javascript/controllers/enclosure_download_controller_spec.ts diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 7d80c917a..3f6d328c2 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -22,6 +22,13 @@ def update private def user_params - params.expect(user: [:username, :password_challenge, :stories_order]) + params.expect( + user: [ + :username, + :password_challenge, + :stories_order, + :enclosure_filename_format + ] + ) end end diff --git a/app/javascript/controllers/enclosure_download_controller.ts b/app/javascript/controllers/enclosure_download_controller.ts new file mode 100644 index 000000000..0e73656d2 --- /dev/null +++ b/app/javascript/controllers/enclosure_download_controller.ts @@ -0,0 +1,67 @@ +import {Controller} from "@hotwired/stimulus"; + +function sanitizeFilename(name: string): string { + return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_").trim(); +} + +function extractExtension(url: string): string { + try { + const pathname = new URL(url).pathname; + const match = pathname.match(/\.(\w+)$/); + return match ? `.${match[1]}` : ""; + } catch { + return ""; + } +} + +const MAX_FILENAME_LENGTH = 200; + +export default class extends Controller { + static values = {title: String, source: String, date: String, format: String}; + + declare titleValue: string; + declare sourceValue: string; + declare dateValue: string; + declare formatValue: string; + + connect(): void { + this.element.addEventListener("click", this.handleClick); + } + + disconnect(): void { + this.element.removeEventListener("click", this.handleClick); + } + + handleClick = (event: Event): void => { + if (this.formatValue !== "date_source_title") return; + + event.preventDefault(); + + const href = (this.element as HTMLAnchorElement).href; + const ext = extractExtension(href); + const basename = sanitizeFilename( + `${this.dateValue} - ${this.sourceValue} - ${this.titleValue}`, + ); + const filename = + basename.slice(0, MAX_FILENAME_LENGTH - ext.length) + ext; + + fetch(href) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.blob(); + }) + .then((blob) => { + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(objectUrl); + }) + .catch(() => { + window.open(href); + }); + }; +} diff --git a/app/javascript/controllers/index.ts b/app/javascript/controllers/index.ts index e5e83865a..7e6ee90e6 100644 --- a/app/javascript/controllers/index.ts +++ b/app/javascript/controllers/index.ts @@ -9,5 +9,8 @@ import {application} from "./application"; import DialogController from "./dialog_controller"; application.register("dialog", DialogController); +import EnclosureDownloadController from "./enclosure_download_controller"; +application.register("enclosure-download", EnclosureDownloadController); + import HotkeysController from "./hotkeys_controller"; application.register("hotkeys", HotkeysController); diff --git a/app/models/user.rb b/app/models/user.rb index 587a6cc93..1db614acd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -15,10 +15,21 @@ class User < ApplicationRecord enum :stories_order, { desc: "desc", asc: "asc" }, prefix: true + ENCLOSURE_FILENAME_FORMATS = ["original", "date_source_title"].freeze + + store_accessor :settings, :enclosure_filename_format + validates :enclosure_filename_format, + inclusion: { in: ENCLOSURE_FILENAME_FORMATS }, + allow_nil: true + attr_accessor :password_challenge # `password_challenge` logic should be able to be removed in Rails 7.1 # https://blog.appsignal.com/2023/02/15/whats-new-in-rails-7-1.html#password-challenge-via-has_secure_password + def enclosure_filename_format + super.presence || "original" + end + def password_challenge_matches return unless password_challenge diff --git a/app/views/js/templates/_story.js.erb b/app/views/js/templates/_story.js.erb index e4beaca22..cdf9356cf 100644 --- a/app/views/js/templates/_story.js.erb +++ b/app/views/js/templates/_story.js.erb @@ -25,7 +25,13 @@

{{= title }} {{ if (enclosure_url) { }} - + {{ } }} diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb index d8bc7c499..7cd14d144 100644 --- a/app/views/profiles/edit.html.erb +++ b/app/views/profiles/edit.html.erb @@ -5,6 +5,8 @@ <%= t(".stories_feed_settings") %> <%= form.label :stories_order %> <%= form.select :stories_order, User.stories_orders.transform_keys {|k| User.human_attribute_name("stories_order.#{k}") } %> + <%= form.label :enclosure_filename_format %> + <%= form.select :enclosure_filename_format, User::ENCLOSURE_FILENAME_FORMATS.map {|f| [User.human_attribute_name("enclosure_filename_format.#{f}"), f] } %> <%= form.submit("Update") %> <% end %> diff --git a/app/views/stories/_templates.html.erb b/app/views/stories/_templates.html.erb index b4bf0b723..85a09c5af 100644 --- a/app/views/stories/_templates.html.erb +++ b/app/views/stories/_templates.html.erb @@ -25,7 +25,13 @@

{{= title }} {{ if (enclosure_url) { }} - + {{ } }} diff --git a/config/locales/en.yml b/config/locales/en.yml index 0ad95644c..778f46590 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -191,6 +191,10 @@ en: attributes: user: stories_order: "Stories feed order" + enclosure_filename_format: "Download filename format" user/stories_order: asc: "Oldest first" - desc: "Newest first" \ No newline at end of file + desc: "Newest first" + user/enclosure_filename_format: + original: "Original" + date_source_title: "Date - Source - Title" \ No newline at end of file diff --git a/db/migrate/20260223045507_add_settings_to_users.rb b/db/migrate/20260223045507_add_settings_to_users.rb new file mode 100644 index 000000000..4d01d1c48 --- /dev/null +++ b/db/migrate/20260223045507_add_settings_to_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddSettingsToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :settings, :jsonb, default: {}, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 3563b1070..014e5b314 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,96 +10,96 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_07_09_172408) do +ActiveRecord::Schema[8.1].define(version: 2026_02_23_045507) do # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" - enable_extension "plpgsql" create_table "feeds", id: :serial, force: :cascade do |t| - t.string "name", limit: 255 - t.text "url" - t.datetime "last_fetched" t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.integer "status" t.integer "group_id" + t.datetime "last_fetched" + t.string "name", limit: 255 + t.integer "status" + t.datetime "updated_at", null: false + t.text "url" t.bigint "user_id", null: false t.index ["url", "user_id"], name: "index_feeds_on_url_and_user_id", unique: true t.index ["user_id"], name: "index_feeds_on_user_id" end create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.integer "callback_priority" + t.text "callback_queue_name" t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.text "description" - t.jsonb "serialized_properties" - t.text "on_finish" - t.text "on_success" - t.text "on_discard" - t.text "callback_queue_name" - t.integer "callback_priority" - t.datetime "enqueued_at" t.datetime "discarded_at" + t.datetime "enqueued_at" t.datetime "finished_at" + t.text "on_discard" + t.text "on_finish" + t.text "on_success" + t.jsonb "serialized_properties" + t.datetime "updated_at", null: false end create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.uuid "active_job_id", null: false - t.text "job_class" - t.text "queue_name" - t.jsonb "serialized_params" - t.datetime "scheduled_at" - t.datetime "finished_at" + t.datetime "created_at", null: false + t.interval "duration" t.text "error" - t.integer "error_event", limit: 2 t.text "error_backtrace", array: true + t.integer "error_event", limit: 2 + t.datetime "finished_at" + t.text "job_class" t.uuid "process_id" - t.interval "duration" + t.text "queue_name" + t.datetime "scheduled_at" + t.jsonb "serialized_params" + t.datetime "updated_at", null: false t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at" end create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.jsonb "state" t.integer "lock_type", limit: 2 + t.jsonb "state" + t.datetime "updated_at", null: false end create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.text "key" + t.datetime "updated_at", null: false t.jsonb "value" t.index ["key"], name: "index_good_job_settings_on_key", unique: true end create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "queue_name" - t.integer "priority" - t.jsonb "serialized_params" - t.datetime "scheduled_at" - t.datetime "performed_at" - t.datetime "finished_at" - t.text "error" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.uuid "active_job_id" + t.uuid "batch_callback_id" + t.uuid "batch_id" t.text "concurrency_key" - t.text "cron_key" - t.uuid "retried_good_job_id" + t.datetime "created_at", null: false t.datetime "cron_at" - t.uuid "batch_id" - t.uuid "batch_callback_id" - t.boolean "is_discrete" + t.text "cron_key" + t.text "error" + t.integer "error_event", limit: 2 t.integer "executions_count" + t.datetime "finished_at" + t.boolean "is_discrete" t.text "job_class" - t.integer "error_event", limit: 2 t.text "labels", array: true - t.uuid "locked_by_id" t.datetime "locked_at" + t.uuid "locked_by_id" + t.datetime "performed_at" + t.integer "priority" + t.text "queue_name" + t.uuid "retried_good_job_id" + t.datetime "scheduled_at" + t.jsonb "serialized_params" + t.datetime "updated_at", null: false t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" @@ -117,8 +117,8 @@ end create_table "groups", id: :serial, force: :cascade do |t| - t.string "name", limit: 255, null: false t.datetime "created_at", null: false + t.string "name", limit: 255, null: false t.datetime "updated_at", null: false t.bigint "user_id", null: false t.index ["name", "user_id"], name: "index_groups_on_name_and_user_id", unique: true @@ -126,51 +126,52 @@ end create_table "settings", force: :cascade do |t| - t.string "type", null: false - t.jsonb "data", default: {}, null: false t.datetime "created_at", null: false + t.jsonb "data", default: {}, null: false + t.string "type", null: false t.datetime "updated_at", null: false t.index ["type"], name: "index_settings_on_type", unique: true end create_table "stories", id: :serial, force: :cascade do |t| - t.text "title" - t.text "permalink" t.text "body" - t.integer "feed_id", null: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "published" + t.string "enclosure_url" + t.text "entry_id" + t.integer "feed_id", null: false t.boolean "is_read", default: false - t.boolean "keep_unread", default: false t.boolean "is_starred", default: false - t.text "entry_id" - t.string "enclosure_url" + t.boolean "keep_unread", default: false + t.text "permalink" + t.datetime "published" + t.text "title" + t.datetime "updated_at", null: false t.index ["entry_id", "feed_id"], name: "index_stories_on_entry_id_and_feed_id", unique: true end create_table "subscriptions", force: :cascade do |t| - t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "current_period_end", null: false + t.datetime "current_period_start", null: false + t.text "status", null: false t.text "stripe_customer_id", null: false t.text "stripe_subscription_id", null: false - t.text "status", null: false - t.datetime "current_period_start", null: false - t.datetime "current_period_end", null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "user_id", null: false t.index ["stripe_customer_id"], name: "index_subscriptions_on_stripe_customer_id", unique: true t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true t.index ["user_id"], name: "index_subscriptions_on_user_id", unique: true end create_table "users", id: :serial, force: :cascade do |t| - t.string "password_digest", limit: 255 + t.boolean "admin", null: false + t.string "api_key", limit: 255, null: false t.datetime "created_at", null: false + t.string "password_digest", limit: 255 + t.jsonb "settings", default: {}, null: false + t.string "stories_order", default: "desc" t.datetime "updated_at", null: false - t.string "api_key", limit: 255, null: false t.string "username", null: false - t.boolean "admin", null: false - t.string "stories_order", default: "desc" t.index ["api_key"], name: "index_users_on_api_key", unique: true t.index ["username"], name: "index_users_on_username", unique: true end diff --git a/spec/javascript/controllers/enclosure_download_controller_spec.ts b/spec/javascript/controllers/enclosure_download_controller_spec.ts new file mode 100644 index 000000000..89a002d76 --- /dev/null +++ b/spec/javascript/controllers/enclosure_download_controller_spec.ts @@ -0,0 +1,102 @@ +import {bootStimulus} from "support/stimulus"; +import EnclosureDownloadController from "controllers/enclosure_download_controller"; + +function buildLink(format: string): HTMLAnchorElement { + const link = document.createElement("a"); + link.href = "https://example.com/episodes/episode-42.mp3"; + link.className = "story-enclosure"; + link.setAttribute("data-controller", "enclosure-download"); + link.setAttribute("data-enclosure-download-title-value", "Episode 42"); + link.setAttribute("data-enclosure-download-source-value", "My Podcast"); + link.setAttribute("data-enclosure-download-date-value", "Feb 22, 10:00"); + link.setAttribute("data-enclosure-download-format-value", format); + + const icon = document.createElement("i"); + icon.className = "fa fa-download"; + link.appendChild(icon); + + document.body.appendChild(link); + return link; +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("EnclosureDownloadController", () => { + describe('when format is "original"', () => { + it("does not prevent default link behavior", async () => { + const link = buildLink("original"); + await bootStimulus("enclosure-download", EnclosureDownloadController); + + const event = new MouseEvent("click", {bubbles: true, cancelable: true}); + link.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + }); + }); + + describe('when format is "date_source_title"', () => { + it("fetches the file and triggers a download", async () => { + const link = buildLink("date_source_title"); + await bootStimulus("enclosure-download", EnclosureDownloadController); + + const blob = new Blob(["audio"], {type: "audio/mpeg"}); + const mockResponse = new Response(blob, {status: 200}); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(mockResponse); + + const revokeObjectURL = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => {}); + const createObjectURL = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:http://localhost/fake"); + + const clickedLinks: HTMLAnchorElement[] = []; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation( + function (this: HTMLAnchorElement) { + clickedLinks.push(this); + }, + ); + + const event = new MouseEvent("click", {bubbles: true, cancelable: true}); + link.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith(link.href); + + await vi.waitFor(() => { + expect(clickedLinks).toHaveLength(1); + }); + + const downloadLink = clickedLinks[0]!; + expect(downloadLink.download).toBe( + "Feb 22, 10_00 - My Podcast - Episode 42.mp3", + ); + expect(downloadLink.href).toBe("blob:http://localhost/fake"); + expect(createObjectURL).toHaveBeenCalledOnce(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:http://localhost/fake"); + }); + + it("falls back to window.open on fetch failure", async () => { + const link = buildLink("date_source_title"); + await bootStimulus("enclosure-download", EnclosureDownloadController); + + vi.spyOn(globalThis, "fetch").mockRejectedValue( + new TypeError("Failed to fetch"), + ); + const openSpy = vi + .spyOn(window, "open") + .mockImplementation(() => null); + + const event = new MouseEvent("click", {bubbles: true, cancelable: true}); + link.dispatchEvent(event); + + await vi.waitFor(() => { + expect(openSpy).toHaveBeenCalledWith(link.href); + }); + }); + }); +}); diff --git a/spec/javascript/setup.ts b/spec/javascript/setup.ts index a6853e1c0..fd5486c48 100644 --- a/spec/javascript/setup.ts +++ b/spec/javascript/setup.ts @@ -61,7 +61,13 @@ const templateHTML = [ "

", ' {{= title }}', " {{ if (enclosure_url) { }}", - ' ', + ' ', ' ', " ", " {{ } }}", diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a194f80b1..f855001b2 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1,6 +1,27 @@ # frozen_string_literal: true RSpec.describe User do + describe "#enclosure_filename_format" do + it "defaults to 'original' when not set" do + user = create(:user) + + expect(user.enclosure_filename_format).to eq("original") + end + + it "returns the stored value when set" do + user = create(:user) + user.update!(enclosure_filename_format: "date_source_title") + + expect(user.enclosure_filename_format).to eq("date_source_title") + end + + it "is invalid with an unrecognized format" do + user = build(:user, enclosure_filename_format: "invalid") + + expect(user).not_to be_valid + end + end + describe "#update_api_key" do it "updates the api key when the username changed" do user = create(:user, username: "stringer", password: "super-secret") diff --git a/spec/requests/profiles_controller_spec.rb b/spec/requests/profiles_controller_spec.rb index 182412f86..12d9c68f0 100644 --- a/spec/requests/profiles_controller_spec.rb +++ b/spec/requests/profiles_controller_spec.rb @@ -34,6 +34,17 @@ end end + context "when updating enclosure_filename_format" do + it "updates the setting" do + login_as(default_user) + params = { user: { enclosure_filename_format: "date_source_title" } } + + expect { patch(profile_path, params:) } + .to change { default_user.reload.enclosure_filename_format } + .from("original").to("date_source_title") + end + end + context "when the username is invalid" do it "displays an error message" do login_as(default_user)