+
+ <%= link_to "Export Data", my_data_exports_path, class: "settings-form__hackatime-btn settings-form__export-btn" %>
+ Download a ZIP of your projects, devlogs, and images
+
+
<%= f.submit "Save", class: "modal__actions-close modal__actions-close--primary" %>
diff --git a/app/controllers/my/data_exports_controller.rb b/app/controllers/my/data_exports_controller.rb
new file mode 100644
index 000000000..f4a184854
--- /dev/null
+++ b/app/controllers/my/data_exports_controller.rb
@@ -0,0 +1,46 @@
+class My::DataExportsController < ApplicationController
+ before_action :authenticate_user!
+
+ def index
+ authorize :my, :show_data_exports?
+
+ @body_class = "app-layout-page"
+ @data_exports = current_user.data_exports.order(created_at: :desc).limit(20)
+ end
+
+ def create
+ authorize :my, :create_data_export?
+
+ active_export = current_user.data_exports.pending_or_processing.last
+ if active_export
+ redirect_to my_data_exports_path, notice: "An export is already in progress. Please wait for it to complete."
+ return
+ end
+
+ data_export = current_user.data_exports.create!(status: "pending")
+ User::DataExportJob.perform_later(data_export.id)
+
+ redirect_to my_data_exports_path, notice: "Your data export has been queued. You'll be able to download it once it's ready."
+ end
+
+ def show
+ data_export = current_user.data_exports.find(params[:id])
+ authorize :my, :download_data_export?
+
+ unless data_export.download_available?
+ redirect_to my_data_exports_path, alert: "This export is not ready for download."
+ return
+ end
+
+ redirect_to rails_blob_path(data_export.zip_file, disposition: "attachment")
+ end
+
+ private
+
+ def authenticate_user!
+ return if current_user.present?
+
+ store_return_to
+ redirect_to root_path, alert: "Please sign in to continue."
+ end
+end
diff --git a/app/jobs/user/data_export_job.rb b/app/jobs/user/data_export_job.rb
new file mode 100644
index 000000000..bc4c1b47f
--- /dev/null
+++ b/app/jobs/user/data_export_job.rb
@@ -0,0 +1,140 @@
+require "zip"
+
+class User::DataExportJob < ApplicationJob
+ queue_as :background
+
+ def perform(data_export_id)
+ @data_export = User::DataExport.find_by(id: data_export_id)
+ return unless @data_export
+
+ @data_export.update!(status: "processing")
+
+ user = @data_export.user
+ zip_filename = "stardance-export-#{user.display_name.parameterize}-#{Time.current.strftime("%Y%m%d%H%M%S")}.zip"
+
+ temp_zip = Tempfile.new([ "stardance_export", ".zip" ])
+
+ begin
+ Zip::OutputStream.open(temp_zip.path) do |zip|
+ write_profile(zip, user)
+ write_projects(zip, user)
+ write_readme(zip, user)
+ end
+
+ @data_export.update!(status: "completed", zip_filename: zip_filename)
+ @data_export.zip_file.attach(
+ io: File.open(temp_zip.path),
+ filename: zip_filename,
+ content_type: "application/zip"
+ )
+ rescue StandardError => e
+ @data_export.update!(status: "failed", error_message: "#{e.class}: #{e.message}")
+ raise e
+ ensure
+ temp_zip.close
+ temp_zip.unlink
+ end
+ end
+
+ private
+
+ def write_profile(zip, user)
+ profile_data = {
+ id: user.id,
+ display_name: user.display_name,
+ email: user.email,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ bio: user.bio,
+ created_at: user.created_at,
+ updated_at: user.updated_at
+ }
+
+ zip.put_next_entry("profile.json")
+ zip.write(JSON.pretty_generate(profile_data))
+ end
+
+ def write_projects(zip, user)
+ user.projects.includes(:devlogs).find_each do |project|
+ safe_title = project.title.parameterize.presence || "project-#{project.id}"
+ project_dir = "projects/#{safe_title}"
+
+ project_data = {
+ id: project.id,
+ title: project.title,
+ description: project.description,
+ demo_url: project.demo_url,
+ repo_url: project.repo_url,
+ readme_url: project.readme_url,
+ ship_status: project.ship_status,
+ shipped_at: project.shipped_at,
+ created_at: project.created_at,
+ updated_at: project.updated_at
+ }
+
+ zip.put_next_entry("#{project_dir}/project.json")
+ zip.write(JSON.pretty_generate(project_data))
+
+ download_attachment(zip, project.banner, "#{project_dir}/banner") if project.banner.attached?
+ download_attachment(zip, project.demo_video, "#{project_dir}/demo-video") if project.demo_video.attached?
+
+ write_devlogs(zip, project, project_dir)
+ end
+ end
+
+ def write_devlogs(zip, project, project_dir)
+ project.devlogs.includes(:post).find_each do |devlog|
+ devlog_dir = "#{project_dir}/devlogs"
+
+ zip.put_next_entry("#{devlog_dir}/devlog-#{devlog.id}.md")
+ zip.write(devlog.body.to_s)
+
+ if devlog.attachments.attached?
+ devlog.attachments.each_with_index do |attachment, index|
+ ext = File.extname(attachment.filename.to_s).presence || ".bin"
+ download_attachment(zip, attachment, "#{devlog_dir}/attachments/#{index + 1}#{ext}")
+ end
+ end
+ end
+ end
+
+ def download_attachment(zip, attachment, entry_name)
+ blob = attachment.is_a?(ActiveStorage::Attached) ? attachment.blob : attachment
+ return unless blob
+
+ zip.put_next_entry(entry_name)
+ zip.write(blob.download)
+ rescue StandardError => e
+ Rails.logger.warn("DataExport: failed to download attachment #{blob&.filename}: #{e.message}")
+ end
+
+ def write_readme(zip, user)
+ project_count = user.projects.count
+ devlog_count = user.projects.joins(:devlog_posts).count
+
+ readme = <<~README
+ # Stardance Data Export
+
+ **User:** #{user.display_name}
+ **Exported:** #{Time.current.strftime("%B %d, %Y at %H:%M UTC")}
+
+ ## Contents
+
+ - `profile.json` - Your profile data
+ - `projects/` - Your projects, each containing:
+ - `project.json` - Project metadata
+ - `banner` - Project banner image (if uploaded)
+ - `demo-video` - Demo video (if uploaded)
+ - `devlogs/` - Development logs as Markdown files
+ - `attachments/` - Images and files from each devlog
+
+ ## Stats
+
+ - **Projects:** #{project_count}
+ - **Devlogs:** #{devlog_count}
+ README
+
+ zip.put_next_entry("README.md")
+ zip.write(readme)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 4a6e4c6ee..1b50aa2b4 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -103,6 +103,7 @@ class User < ApplicationRecord
has_many :project_follows, dependent: :destroy
has_many :followed_projects, through: :project_follows, source: :project
has_one :preference, class_name: "User::Preference", dependent: :destroy
+ has_many :data_exports, class_name: "User::DataExport", dependent: :destroy
has_many :follows_as_follower, class_name: "Follow", foreign_key: :follower_id, dependent: :destroy, inverse_of: :follower
has_many :follows_as_followed, class_name: "Follow", foreign_key: :followed_id, dependent: :destroy, inverse_of: :followed
diff --git a/app/models/user/data_export.rb b/app/models/user/data_export.rb
new file mode 100644
index 000000000..b652604cb
--- /dev/null
+++ b/app/models/user/data_export.rb
@@ -0,0 +1,50 @@
+# == Schema Information
+#
+# Table name: user_data_exports
+#
+# id :bigint not null, primary key
+# error_message :text
+# status :string default("pending"), not null
+# zip_filename :string
+# created_at :datetime not null
+# updated_at :datetime not null
+# user_id :bigint not null
+#
+# Indexes
+#
+# index_user_data_exports_on_user_id (user_id)
+# index_user_data_exports_on_user_id_and_status (user_id,status)
+#
+# Foreign Keys
+#
+# fk_rails_... (user_id => users.id)
+#
+class User::DataExport < ApplicationRecord
+ belongs_to :user
+ has_one_attached :zip_file
+
+ validates :status, inclusion: { in: %w[pending processing completed failed] }
+
+ scope :completed, -> { where(status: "completed") }
+ scope :pending_or_processing, -> { where(status: %w[pending processing]) }
+
+ def download_available?
+ completed? && zip_file.attached?
+ end
+
+ def completed?
+ status == "completed"
+ end
+
+ def failed?
+ status == "failed"
+ end
+
+ def processing?
+ status == "processing"
+ end
+
+ def pending?
+ status == "pending"
+ end
+end
diff --git a/app/policies/my_policy.rb b/app/policies/my_policy.rb
index 5b1a1697e..81ee9dc38 100644
--- a/app/policies/my_policy.rb
+++ b/app/policies/my_policy.rb
@@ -30,4 +30,16 @@ def show_notification_settings?
def update_notification_settings?
signed_in_any?
end
+
+ def show_data_exports?
+ signed_in_any?
+ end
+
+ def create_data_export?
+ signed_in_any?
+ end
+
+ def download_data_export?
+ signed_in_any?
+ end
end
diff --git a/app/views/my/data_exports/index.html.erb b/app/views/my/data_exports/index.html.erb
new file mode 100644
index 000000000..abb4a6e3b
--- /dev/null
+++ b/app/views/my/data_exports/index.html.erb
@@ -0,0 +1,56 @@
+<% content_for :title, "Export My Data" %>
+<% @active_nav_slug = "data_exports" %>
+
+
+
+
+
Export My Data
+
+ Download a ZIP file containing all your projects, devlogs, images, and profile data.
+