FEATURE: Import and export themes in a .tar.gz format (#6916)

FEATURE: Import and export themes in a .tar.gz format (#6916)

diff --git a/.travis.yml b/.travis.yml
index 2c4b28c..6ff906b 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,5 +1,8 @@
 language: ruby
 
+git:
+  depth: false
+
 branches:
   only:
     - master
diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6
index 38d73d5..f572813 100644
--- a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6
+++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6
@@ -8,7 +8,7 @@ import { THEMES, COMPONENTS } from "admin/models/theme";
 const THEME_UPLOAD_VAR = 2;
 
 export default Ember.Controller.extend({
-  downloadUrl: url("model.id", "/admin/themes/%@"),
+  downloadUrl: url("model.id", "/admin/customize/themes/%@/export"),
   previewUrl: url("model.id", "/admin/themes/%@/preview"),
   addButtonDisabled: Ember.computed.empty("selectedChildThemeId"),
   editRouteName: "adminCustomizeThemes.edit",
@@ -203,7 +203,7 @@ export default Ember.Controller.extend({
     },
 
     editTheme() {
-      if (this.get("model.remote_theme")) {
+      if (this.get("model.remote_theme.is_git")) {
         bootbox.confirm(
           I18n.t("admin.customize.theme.edit_confirm"),
           result => {
diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs
index 47dab4d..5ca9c90 100644
--- a/app/assets/javascripts/admin/templates/customize-themes-show.hbs
+++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs
@@ -75,7 +75,7 @@
       </div>
     {{/if}}
 
-    {{#if model.remote_theme}}
+    {{#if model.remote_theme.is_git}}
       {{#if model.remote_theme.commits_behind}}
         {{#d-button action=(action "updateToLatest") icon="download" class='btn-primary'}}{{i18n "admin.customize.theme.update_to_latest"}}{{/d-button}}
       {{else}}
@@ -84,7 +84,7 @@
     {{/if}}
 
     {{#d-button action=(action "editTheme") class="btn btn-default edit"}}{{i18n 'admin.customize.theme.edit_css_html'}}{{/d-button}}
-    {{#if model.remote_theme}}
+    {{#if model.remote_theme.is_git}}
       <span class='status-message'>
         {{#if updatingRemote}}
           {{i18n 'admin.customize.theme.updating'}}
@@ -111,6 +111,10 @@
           <code>{{model.remoteError}}</code>
         </div>
       {{/if}}
+    {{else if model.remote_theme}}
+      <span class='status-message'>
+        {{d-icon "info-circle"}} {{i18n "admin.customize.theme.imported_from_archive"}}
+      </span>
     {{/if}}
   </div>
 
diff --git a/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs
index e252638..9d7c9a6 100644
--- a/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs
+++ b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs
@@ -4,7 +4,7 @@
     <label class="radio" for="local">{{i18n 'upload_selector.from_my_computer'}}</label>
     {{#if local}}
       <div class="inputs">
-        <input onchange={{action "uploadLocaleFile"}} type="file" id="file-input" accept='.dcstyle.json,application/json'><br>
+        <input onchange={{action "uploadLocaleFile"}} type="file" id="file-input" accept='.dcstyle.json,application/json,.tar.gz,application/x-gzip'><br>
         <span class="description">{{i18n 'admin.customize.theme.import_file_tip'}}</span>
       </div>
     {{/if}}
@@ -19,7 +19,7 @@
         <span class="description">{{i18n 'admin.customize.theme.import_web_tip'}}</span>
         </div>
         <div class='branch'>
-        {{input value=branch placeholder="beta"}}
+        {{input value=branch placeholder="master"}}
         <span class="description">{{i18n 'admin.customize.theme.remote_branch'}}</span>
         </div>
         <div class='check-private'>
diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb
index 45600be..4ee50cf 100644
--- a/app/controllers/admin/themes_controller.rb
+++ b/app/controllers/admin/themes_controller.rb
@@ -1,9 +1,10 @@
 require_dependency 'upload_creator'
+require_dependency 'theme_store/tgz_exporter'
 require 'base64'
 
 class Admin::ThemesController < Admin::AdminController
 
-  skip_before_action :check_xhr, only: [:show, :preview]
+  skip_before_action :check_xhr, only: [:show, :preview, :export]
 
   def preview
     @theme = Theme.find(params[:id])
@@ -38,7 +39,8 @@ class Admin::ThemesController < Admin::AdminController
 
   def import
     @theme = nil
-    if params[:theme]
+    if params[:theme] && params[:theme].content_type == "application/json"
+      # .dcstyle.json import. Deprecated, but still available to allow conversion
       json = JSON::parse(params[:theme].read)
       theme = json['theme']
 
@@ -79,19 +81,21 @@ class Admin::ThemesController < Admin::AdminController
         branch = params[:branch] ? params[:branch] : nil
         @theme = RemoteTheme.import_theme(params[:remote], current_user, private_key: params[:private_key], branch: branch)
         render json: @theme, status: :created
-      rescue RuntimeError => e
-        Discourse.warn_exception(e, message: "Error importing theme")
-        render_json_error I18n.t('themes.error_importing')
+      rescue RemoteTheme::ImportError => e
+        render_json_error e.message
       end
-    elsif params[:bundle]
+    elsif params[:bundle] || params[:theme] && params[:theme].content_type == "application/x-gzip"
+      # params[:bundle] used by theme CLI. params[:theme] used by admin UI
+      bundle = params[:bundle] || params[:theme]
       begin
-        @theme = RemoteTheme.update_tgz_theme(params[:bundle].path, user: current_user)
+        @theme = RemoteTheme.update_tgz_theme(bundle.path, match_theme: !!params[:bundle], user: current_user)
+        log_theme_change(nil, @theme)
         render json: @theme, status: :created
-      rescue RuntimeError
-        render_json_error I18n.t('themes.error_importing')
+      rescue RemoteTheme::ImportError => e
+        render_json_error e.message
       end
     else
-      render json: @theme.errors, status: :unprocessable_entity
+      render_json_error status: :unprocessable_entity
     end
   end
 
@@ -217,22 +221,20 @@ class Admin::ThemesController < Admin::AdminController
 
   def show
     @theme = Theme.find(params[:id])
+    render json: ThemeSerializer.new(@theme)
+  end
 
-    respond_to do |format|
-      format.json do
-        check_xhr
-        render json: ThemeSerializer.new(@theme)
-      end
-
-      format.any(:html, :text) do
-        raise RenderEmpty.new if request.xhr?
-
-        response.headers['Content-Disposition'] = "attachment; filename=#{@theme.name.parameterize}.dcstyle.json"
-        response.sending_file = true
-        render json: ::ThemeWithEmbeddedUploadsSerializer.new(@theme, root: 'theme')
-      end
-    end
+  def export
+    @theme = Theme.find(params[:id])
 
+    exporter = ThemeStore::TgzExporter.new(@theme)
+    file_path = exporter.package_filename
+    headers['Content-Length'] = File.size(file_path).to_s
+    send_data File.read(file_path),
+      filename: File.basename(file_path),
+      content_type: "application/x-gzip"
+  ensure
+    exporter.cleanup!
   end
 
   private
diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb
index 6b2e84d..0f1a28a 100644
--- a/app/models/remote_theme.rb
+++ b/app/models/remote_theme.rb
@@ -4,6 +4,8 @@ require_dependency 'upload_creator'
 
 class RemoteTheme < ActiveRecord::Base
 
+  class ImportError < StandardError; end
+
   ALLOWED_FIELDS = %w{scss embedded_scss head_tag header after_header body_tag footer}
 
   GITHUB_REGEXP = /^https?:\/\/github\.com\//
@@ -14,15 +16,22 @@ class RemoteTheme < ActiveRecord::Base
     joins("JOIN themes ON themes.remote_theme_id = remote_themes.id").where.not(remote_url: "")
   }
 
-  def self.update_tgz_theme(filename, user: Discourse.system_user)
+  def self.extract_theme_info(importer)

[... diff too long, it was truncated ...]

GitHub sha: afd44908

2 Likes

This commit has been mentioned on Discourse Meta. There might be relevant details there: