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 @@
<%= 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)