Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ gem "mission_control-jobs"
gem "view_component"
gem "phlex-rails"
gem "aws-sdk-s3"
gem "rubyzip", "~> 3.0"
gem "faraday"
gem "faraday-retry"

Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,7 @@ DEPENDENCIES
rspec-rails
rubocop
rubocop-rails-omakase
rubyzip (~> 3.0)
selenium-webdriver
sentry-rails (~> 6.6)
sentry-ruby (~> 6.6)
Expand Down
1 change: 1 addition & 0 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,6 @@
@use "pages/raffle";
@use "pages/notifications" as notifications_page;
@use "pages/notification_settings" as notification_settings_page;
@use "pages/data_exports" as data_exports_page;
@use "pages/rng";
@use "landing/landing_footer";
8 changes: 8 additions & 0 deletions app/assets/stylesheets/components/_settings-form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
border-radius: 999px;
padding: var(--space-xxs) var(--space-m);
cursor: pointer;
text-decoration: none;
transition:
border-color 160ms ease,
background-color 160ms ease;
Expand All @@ -146,4 +147,11 @@
border-color: var(--color-border-input-focus);
}
}

&__export-btn {
display: block;
width: 100%;
text-align: center;
text-decoration: none;
}
}
92 changes: 92 additions & 0 deletions app/assets/stylesheets/pages/_data_exports.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
.data-exports-page {
max-width: 720px;
width: 100%;
margin: 0 auto;
color: var(--color-space-text);

&__header {
margin-bottom: var(--space-m);
}

&__title {
font-family: var(--font-family-subtitle);
font-size: var(--font-size-xxl);
margin: 0 0 var(--space-xxs);
}

&__description {
margin: 0;
color: var(--color-space-text-muted);
font-size: var(--font-size-s);
}

&__actions {
margin-bottom: var(--space-l);
}

&__list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}

&__empty {
padding: var(--space-xl) var(--space-l);
text-align: center;
margin-top: var(--space-m);
}

&__empty-title {
font-family: var(--font-family-subtitle);
font-size: var(--font-size-l);
margin: 0 0 var(--space-xxs);
color: var(--color-space-text);
}

&__empty-hint {
margin: 0;
color: var(--color-space-text-muted);
font-size: var(--font-size-s);
}
}

.data-export-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-s);
padding: var(--space-s) var(--space-m);
color: var(--color-space-text);
background: rgba(5, 4, 24, 0.35);
border: 2px solid var(--color-space-border);
border-radius: var(--profile-radius, var(--border-radius));

&__info {
display: flex;
align-items: center;
gap: var(--space-s);
min-width: 0;
}

&__date {
opacity: 0.5;
font-weight: 400;
font-family: var(--font-family-text);
font-size: var(--font-size-s);
}

&__error {
color: var(--color-brand-salmon);
font-size: var(--font-size-xs);
}

&__actions {
flex: 0 0 auto;
}

&__processing {
opacity: 0.5;
font-size: var(--font-size-s);
font-style: italic;
}
}
6 changes: 6 additions & 0 deletions app/components/sidebar_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@
<small class="settings-form__hint">Links your coding time from Hackatime to your projects</small>
</div>

<div class="settings-form__field">
<label class="settings-form__label">Export Data</label>
<%= link_to "Export Data", my_data_exports_path, class: "settings-form__hackatime-btn settings-form__export-btn" %>
<small class="settings-form__hint">Download a ZIP of your projects, devlogs, and images</small>
</div>

<div class="modal__actions">
<button type="button" class="modal__actions-close" onclick="document.getElementById('settings-form').reset(); document.getElementById('settings-modal').close()">Cancel</button>
<%= f.submit "Save", class: "modal__actions-close modal__actions-close--primary" %>
Expand Down
46 changes: 46 additions & 0 deletions app/controllers/my/data_exports_controller.rb
Original file line number Diff line number Diff line change
@@ -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
140 changes: 140 additions & 0 deletions app/jobs/user/data_export_job.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions app/models/user/data_export.rb
Original file line number Diff line number Diff line change
@@ -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
Loading