From 9559179409d2f65e68009a7159838a9f679c1161 Mon Sep 17 00:00:00 2001 From: Piotr Zalewa Date: Fri, 31 Aug 2012 18:32:32 +0200 Subject: [PATCH] Refactoring: Info about sdk is not needed to create a valid manifest Backend for downloading a zip file model is exporting source and creating a zip file views for prepare, check and provide zip file are created front-end added based on download XPI --- apps/jetpack/models.py | 128 ++++++++++++++++-- apps/jetpack/tasks.py | 27 +++- apps/jetpack/templates/addon_edit.html | 3 + apps/jetpack/tests/revision_tests.py | 27 ++++ apps/jetpack/tests/test_views.py | 44 ++++++ apps/jetpack/urls.py | 8 ++ apps/jetpack/views.py | 80 ++++++++++- apps/search/cron.py | 2 +- media/jetpack/css/UI.Editor_Menu.css | 4 + media/jetpack/img/editor-buttons.png | Bin 11111 -> 11352 bytes .../js/ide/controllers/PackageController.js | 99 ++++++++++++++ utils/zip.py | 19 +++ 12 files changed, 420 insertions(+), 21 deletions(-) create mode 100644 utils/zip.py diff --git a/apps/jetpack/models.py b/apps/jetpack/models.py index 064da90e..765392cc 100644 --- a/apps/jetpack/models.py +++ b/apps/jetpack/models.py @@ -51,6 +51,7 @@ from utils.helpers import (pathify, alphanum, alphanum_plus, get_random_string, sanitize_for_frontend) from utils.os_utils import make_path from utils.amo import AMOOAuth +from utils.zip import zipdir from xpi import xpi_utils from elasticutils.utils import retry_on_timeout @@ -425,6 +426,18 @@ class PackageRevision(BaseModel): raise Exception('XPI might be created only from an Add-on') return reverse('jp_addon_revision_test', args=[self.pk]) + def get_prepare_zip_url(self): + " returns URL to prepare ZIP " + return reverse('jp_revision_prepare_zip', args=[self.pk]) + + def get_check_zip_url(self, hashtag): + " returns URL to check ZIP " + return reverse('jp_revision_check_zip', args=[self.pk, hashtag]) + + def get_download_zip_url(self, hashtag, filename): + " returns URL to download ZIP " + return reverse('jp_revision_check_zip', args=[self.pk, hashtag, filename]) + def get_download_xpi_url(self): " returns URL to download Add-on's XPI " if self.package.type != 'a': @@ -472,11 +485,9 @@ class PackageRevision(BaseModel): for contributors in csv_r: return contributors - def get_dependencies_list(self, sdk=None): + def get_dependencies_list(self): " returns a list of dependencies names extended by default core " - # breaking possibility to build jetpack SDK 0.6 - deps = ["%s" % (dep.name) \ - for dep in self.dependencies.all()] + deps = ["%s" % (dep.name) for dep in self.dependencies.all()] deps.append('api-utils') if self.package.is_addon(): deps.append('addon-kit') @@ -493,8 +504,7 @@ class PackageRevision(BaseModel): " return description prepared for rendering " return "

%s

" % self.get_full_description().replace("\n", "
") - def get_manifest(self, test_in_browser=False, sdk=None, - package_overrides=None): + def get_manifest(self, test_in_browser=False, package_overrides=None): " returns manifest dictionary " version = self.get_version_name() if test_in_browser: @@ -521,7 +531,7 @@ class PackageRevision(BaseModel): else self.package.pk, 'version': version, 'main': self.module_main, - 'dependencies': self.get_dependencies_list(sdk), + 'dependencies': self.get_dependencies_list(), 'license': self.package.license, 'url': str(self.package.url), 'contributors': self.get_contributors_list(), @@ -538,9 +548,9 @@ class PackageRevision(BaseModel): manifest[key] = package_overrides.get(key, None) or value return manifest - def get_manifest_json(self, sdk=None, package_overrides=None, **kwargs): + def get_manifest_json(self, package_overrides=None, **kwargs): " returns manifest as JSOIN object " - return simplejson.dumps(self.get_manifest(sdk=sdk, + return simplejson.dumps(self.get_manifest( package_overrides=package_overrides, **kwargs)) def get_main_module(self): @@ -1293,6 +1303,97 @@ class PackageRevision(BaseModel): return self.sdk.kit_lib if self.sdk.kit_lib else self.sdk.core_lib + def export_source(self, modules=None, attachments=None, tstart=None, + temp_dir=None, package_overrides=None): + """ + Export source of the PackageRevision and all it's dependencies + + :param modules: list of modules from editor - potentially unsaved + :param attachments: list of aatachments from editor - potentially + unsaved + :rtype: String defining the path to exported source + """ + if not tstart: + tstart = time.time() + if not modules: + modules = [] + if not attachments: + attachments = [] + if not temp_dir: + temp_dir = tempfile.mkdtemp() + package_dir = self.make_dir(temp_dir) + # preparing manifest + self.export_manifest(package_dir, package_overrides=package_overrides) + t1 = (time.time() - tstart) * 1000 + + # export modules with ability to use edited code (from modules var) + lib_dir = os.path.join(package_dir, self.get_lib_dir()) + for mod in self.modules.all(): + mod_edited = False + for e_mod in modules: + if e_mod.pk == mod.pk: + mod_edited = True + e_mod.export_code(lib_dir) + if not mod_edited: + mod.export_code(lib_dir) + t2 = (time.time() - (t1 / 1000) - tstart) * 1000 + statsd.timing('export.modules', t2) + log.debug("[export] modules exported (time %dms)" % t2) + # export atts with ability to use edited code (from attachments var) + # XPI: memory/database/NFS to local + data_dir = os.path.join(package_dir, settings.JETPACK_DATA_DIR) + for att in self.attachments.all(): + att_edited = False + for e_att in attachments: + if e_att.pk == att.pk: + att_edited = True + e_att.export_code(data_dir) + if not att_edited: + att.export_file(data_dir) + t3 = (time.time() - (t2 / 1000) - tstart) * 1000 + statsd.timing('export.attachments', t3) + log.debug("[export] attachments exported (time %dms)" % t3) + + # XPI: copying to local from memory/db/files + self.export_dependencies(temp_dir) + t4 = (time.time() - (t3 / 1000) - tstart) * 1000 + statsd.timing('export.dependencies', t4) + log.debug("[export] dependencies exported (time %dms)" % t4) + if not os.path.isdir(temp_dir): + log.error("[export] An attempt to export add-on (%s) failed." % + self.get_version_name()) + raise IntegrityError("Failed to export source") + return temp_dir + + def zip_source(self, modules=None, attachments=None, hashtag=None, + tstart=None, package_overrides=None): + """ + Compress exported sources into a zip file, return path to the file + """ + if not tstart: + tstart = time.time() + if not hashtag: + log.error("[zip] Attempt to build add-on (%s) but it's missing a " + "hashtag. Failing." % self.get_version_name()) + raise IntegrityError("Hashtag is required to create an xpi.") + # export sources + temp_dir = self.export_source(modules, attachments, tstart) + # zip data + zip_targetname = "%s.zip" % hashtag + zip_targetpath = os.path.join(settings.XPI_TARGETDIR, zip_targetname) + t1 = (time.time() - tstart) * 1000 + try: + zipdir(temp_dir, zip_targetpath) + except Exception, err: + log.error("[zip] An attempt to compress add-on (%s) failed.\n%s" % ( + self.get_version_name(), err)) + raise + t2 = (time.time() - (t1 / 1000) - tstart) * 1000 + statsd.timing('zip.zipped', t2) + shutil.rmtree(temp_dir) + log.debug("[zip] directory compressed (time %dms)" % t2) + return zip_targetpath + def build_xpi(self, modules=None, attachments=None, hashtag=None, tstart=None, sdk=None, package_overrides=None): """ @@ -1344,8 +1445,7 @@ class PackageRevision(BaseModel): packages_dir = os.path.join(sdk_dir, 'packages') package_dir = self.make_dir(packages_dir) # XPI: create manifest (from memory to local) - self.export_manifest(package_dir, sdk=sdk, - package_overrides=package_overrides) + self.export_manifest(package_dir, package_overrides=package_overrides) # export modules with ability to use edited code (from modules var) # XPI: memory/database to local @@ -1402,11 +1502,11 @@ class PackageRevision(BaseModel): f.write('private-key:%s\n' % self.package.private_key) f.write('public-key:%s' % self.package.public_key) - def export_manifest(self, package_dir, sdk=None, package_overrides=None): + def export_manifest(self, package_dir, package_overrides=None): """Creates a file with an Add-on's manifest.""" manifest_file = "%s/package.json" % package_dir with codecs.open(manifest_file, mode='w', encoding='utf-8') as f: - f.write(self.get_manifest_json(sdk=sdk, + f.write(self.get_manifest_json( package_overrides=package_overrides)) def export_modules(self, lib_dir): @@ -1429,7 +1529,7 @@ class PackageRevision(BaseModel): package_dir = self.make_dir(packages_dir) if not package_dir: return - self.export_manifest(package_dir, sdk=sdk) + self.export_manifest(package_dir) self.export_modules( os.path.join(package_dir, self.get_lib_dir())) self.export_attachments( diff --git a/apps/jetpack/tasks.py b/apps/jetpack/tasks.py index 11d648bc..fbdf131e 100644 --- a/apps/jetpack/tasks.py +++ b/apps/jetpack/tasks.py @@ -1,8 +1,11 @@ import datetime import commonware.log +import time + +from statsd import statsd from celery.decorators import task -from jetpack.models import Package +from jetpack.models import Package, PackageRevision from elasticutils import get_es log = commonware.log.getLogger('f.celery') @@ -13,11 +16,25 @@ def calculate_activity_rating(pks,**kw): ids_str = ','.join(map(str, pks)) log.debug('ES starting calculate_activity_rating for packages: [%s]' % ids_str) - + for package in Package.objects.filter(pk__in=pks): package.activity_rating = package.calc_activity_rating() - package.save() - + package.save() + log.debug('ES completed calculate_activity_rating for packages: [%s]' % ids_str) - \ No newline at end of file + + +@task +def zip_source(pk, hashtag, tqueued=None, **kw): + if not hashtag: + log.critical("[zip] No hashtag provided") + return + tstart = time.time() + if tqueued: + tinqueue = (tstart - tqueued) * 1000 + statsd.timing('zip.queued', tinqueue) + log.info('[zip:%s] Addon job picked from queue (%dms)' % (hashtag, tinqueue)) + log.debug("[zip:%s] Compressing" % pk) + PackageRevision.objects.get(pk=pk).zip_source(hashtag=hashtag, tstart=tstart) + log.debug("[zip:%s] Compressed" % pk) diff --git a/apps/jetpack/templates/addon_edit.html b/apps/jetpack/templates/addon_edit.html index 3fc0c6c2..d91e512f 100644 --- a/apps/jetpack/templates/addon_edit.html +++ b/apps/jetpack/templates/addon_edit.html @@ -44,6 +44,9 @@
  • +
  • + +
  • {#
  • diff --git a/apps/jetpack/tests/revision_tests.py b/apps/jetpack/tests/revision_tests.py index d3ccaaed..8a28349e 100644 --- a/apps/jetpack/tests/revision_tests.py +++ b/apps/jetpack/tests/revision_tests.py @@ -2,6 +2,7 @@ import commonware import tempfile import os +import shutil import datetime import decimal @@ -34,10 +35,17 @@ class PackageRevisionTest(TestCase): self.hashtag = hashtag() self.xpi_file = os.path.join(settings.XPI_TARGETDIR, "%s.xpi" % self.hashtag) + self.zip_file = os.path.join(settings.XPI_TARGETDIR, + "%s.zip" % self.hashtag) + self.temp_dir = tempfile.mkdtemp() def tearDown(self): if os.path.exists(self.xpi_file): os.remove(self.xpi_file) + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + if os.path.exists(self.zip_file): + os.remove(self.zip_file) def test_first_revision_creation(self): addon = Package(author=self.author, type='a') @@ -496,6 +504,25 @@ class PackageRevisionTest(TestCase): assert validator.is_valid('alphanum', self.addon.latest.get_cache_hashtag()) + def test_export_source(self): + self.addon.latest.dependency_add(self.library.latest) + d = self.addon.latest.export_source(temp_dir=self.temp_dir) + eq_(d, self.temp_dir) + assert os.path.exists(os.path.join(d, self.addon.name)) + assert os.path.exists(os.path.join(d, self.addon.name, 'package.json')) + assert os.path.exists(os.path.join(d, self.library.name)) + assert os.path.exists(os.path.join(d, self.library.name, + 'package.json')) + + def test_zip_source(self): + self.addon.latest.zip_source(hashtag=self.hashtag) + assert os.path.isfile(self.zip_file) + + def test_zip_lib(self): + self.library.latest.zip_source(hashtag=self.hashtag) + assert os.path.isfile(self.zip_file) + + """ Althought not supported on view and front-end, there is no harm in these two diff --git a/apps/jetpack/tests/test_views.py b/apps/jetpack/tests/test_views.py index 9073fc1c..240f21a3 100644 --- a/apps/jetpack/tests/test_views.py +++ b/apps/jetpack/tests/test_views.py @@ -366,6 +366,19 @@ class TestEditing(TestCase): class TestRevision(TestCase): fixtures = ('mozilla_user', 'core_sdk', 'users', 'packages') + def setUp(self): + self.hashtag = hashtag() + self.xpi_file = os.path.join(settings.XPI_TARGETDIR, + "%s.xpi" % self.hashtag) + self.zip_file = os.path.join(settings.XPI_TARGETDIR, + "%s.zip" % self.hashtag) + + def tearDown(self): + if os.path.exists(self.xpi_file): + os.remove(self.xpi_file) + if os.path.exists(self.zip_file): + os.remove(self.zip_file) + def test_copy_revision(self): author = User.objects.get(username='john') addon = Package(author=author, type='a') @@ -474,3 +487,34 @@ class TestRevision(TestCase): # there should be other package with the name created from FIXABLE eq_(Package.objects.filter( author=author, full_name__contains='Integrity Error').count(), 2) + + def test_prepare_zip_file(self): + author = User.objects.get(username='john') + addon = Package(author=author, type='a') + addon.save() + prepare_url = addon.latest.get_prepare_zip_url() + response = self.client.post(prepare_url, {'hashtag': self.hashtag}) + eq_(response.status_code, 200) + eq_(response.content, '{"delayed": true}') + + def test_check_zip_file(self): + author = User.objects.get(username='john') + addon = Package(author=author, type='a') + addon.save() + check_url = reverse('jp_revision_check_zip', args=[self.hashtag,]) + response = self.client.get(check_url) + eq_(response.content, '{"ready": false}') + addon.latest.zip_source(hashtag=self.hashtag) + response = self.client.get(check_url) + eq_(response.status_code, 200) + eq_(response.content, '{"ready": true}') + + def test_download_zip_file(self): + author = User.objects.get(username='john') + addon = Package(author=author, type='a') + addon.save() + addon.latest.zip_source(hashtag=self.hashtag) + download_url = reverse('jp_revision_download_zip', args=[self.hashtag, 'x']) + response = self.client.get(download_url) + eq_(response.status_code, 200) + eq_(response['Content-Disposition'], 'attachment; filename="x.zip"') diff --git a/apps/jetpack/urls.py b/apps/jetpack/urls.py index b9075ebc..5e2c6e0d 100644 --- a/apps/jetpack/urls.py +++ b/apps/jetpack/urls.py @@ -135,4 +135,12 @@ urlpatterns = patterns('jetpack.views', # check libraries for latest versions url(r'package/check_latest_dependencies/(?P\d+)/$', 'latest_dependencies', name='jp_package_check_latest_dependencies'), + +# zip file + url(r'^revision/prepare_zip/(?P\d+)/$', + 'prepare_zip', name='jp_revision_prepare_zip'), + url(r'^revision/download_zip/(?P[a-zA-Z0-9]+)/(?P.*)/$', + 'get_zip', name='jp_revision_download_zip'), + url(r'^revision/check_zip/(?P[a-zA-Z0-9]+)/$', + 'check_zip', name='jp_revision_check_zip'), ) diff --git a/apps/jetpack/views.py b/apps/jetpack/views.py index 051c9605..eb6b7d35 100644 --- a/apps/jetpack/views.py +++ b/apps/jetpack/views.py @@ -7,10 +7,14 @@ import shutil import codecs import tempfile import urllib2 +import time + from simplejson import JSONDecodeError +from statsd import statsd from django.contrib import messages from django.core.urlresolvers import reverse +from django.core.cache import cache from django.db import transaction from django.views.static import serve from django.shortcuts import get_object_or_404 @@ -24,11 +28,13 @@ from django.db import IntegrityError, transaction from django.db.models import Q, ObjectDoesNotExist from django.views.decorators.cache import never_cache from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt from django.template.defaultfilters import escape from django.conf import settings from django.utils import simplejson from django.forms.fields import URLField +from tasks import zip_source from base.shortcuts import get_object_with_related_or_404 from utils import validator from utils.helpers import pathify, render, render_json @@ -1059,7 +1065,7 @@ def library_autocomplete(request): ids = (settings.MINIMUM_PACKAGE_ID, settings.MINIMUM_PACKAGE_ID - 1) notAddonKit = ~(F(id_number=ids[0]) | F(id_number=ids[1])) onlyMyPrivateLibs = (F(active=True) | F(author=request.user.id)) - + try: qs = (Package.search().query(or_=package_query(q)).filter(type='l') .filter(notAddonKit).filter(onlyMyPrivateLibs)) @@ -1218,3 +1224,75 @@ def get_revision_conflicting_modules_list(request, pk): revision = get_object_or_404(PackageRevision, pk=pk) return HttpResponse(simplejson.dumps( revision.get_conflicting_module_names()), mimetype="application/json") + +def _get_zip_cache_key(request, hashtag): + session = request.session.session_key + return 'zip:timing:queued:%s:%s' % (hashtag, session) + +@csrf_exempt +@require_POST +def prepare_zip(request, revision_id): + """ + Prepare download zip This package is built asynchronously and we assume + it works. It will be downloaded in %``get_zip`` + """ + revision = get_object_with_related_or_404(PackageRevision, pk=revision_id) + if (not revision.package.active and user != revision.package.author): + # pretend package doesn't exist as it's private + raise Http404() + hashtag = request.POST.get('hashtag') + if not hashtag: + return HttpResponseForbidden('Add-on Builder has been updated!' + 'We have updated this part of the application. Please ' + 'empty your cache and reload to get changes.') + if not validator.is_valid('alphanum', hashtag): + log.warning('[security] Wrong hashtag provided') + return HttpResponseBadRequest("{'error': 'Wrong hashtag'}") + log.info('[zip:%s] Addon added to queue' % hashtag) + # caching + tqueued = time.time() + tkey = _get_zip_cache_key(request, hashtag) + cache.set(tkey, tqueued, 120) + # create zip file + zip_source(pk=revision.pk, hashtag=hashtag, tqueued=tqueued) + return HttpResponse('{"delayed": true}') + +@never_cache +def get_zip(request, hashtag, filename): + """ + Download zip (it has to be ready) + """ + if not validator.is_valid('alphanum', hashtag): + log.warning('[security] Wrong hashtag provided') + return HttpResponseForbidden("{'error': 'Wrong hashtag'}") + path = os.path.join(settings.XPI_TARGETDIR, '%s.zip' % hashtag) + log.info('[zip:%s] Downloading Addon from %s' % (filename, path)) + + tend = time.time() + tkey = _get_zip_cache_key(request, hashtag) + tqueued = cache.get(tkey) + if tqueued: + ttotal = (tend - tqueued) * 1000 + statsd.timing('zip.total', ttotal) + total = '%dms' % ttotal + else: + total = 'n/a' + + log.info('[zip:%s] Downloading Add-on (%s)' % (hashtag, total)) + + response = serve(request, path, '/', show_indexes=False) + response['Content-Disposition'] = ('attachment; ' + 'filename="%s.zip"' % filename) + return response + +@never_cache +def check_zip(r, hashtag): + """Check if zip file is prepared.""" + if not validator.is_valid('alphanum', hashtag): + log.warning('[security] Wrong hashtag provided') + return HttpResponseForbidden("{'error': 'Wrong hashtag'}") + path = os.path.join(settings.XPI_TARGETDIR, '%s.zip' % hashtag) + # Check file if it exists + if os.path.isfile(path): + return HttpResponse('{"ready": true}') + return HttpResponse('{"ready": false}') diff --git a/apps/search/cron.py b/apps/search/cron.py index 79184471..922467e1 100644 --- a/apps/search/cron.py +++ b/apps/search/cron.py @@ -21,7 +21,7 @@ def index_all(): with establish_connection() as conn: for chunk in chunked(ids, 100): tasks.index_all.apply_async(args=[chunk], connection=conn) - + @cronjobs.register def setup_mapping(): diff --git a/media/jetpack/css/UI.Editor_Menu.css b/media/jetpack/css/UI.Editor_Menu.css index 7a60b79a..e3bb8b52 100644 --- a/media/jetpack/css/UI.Editor_Menu.css +++ b/media/jetpack/css/UI.Editor_Menu.css @@ -162,6 +162,10 @@ form.UI_Editor_Menu_Descendant { background-position: -34px 0; } + .UI_Editor_Menu_Button.Icon_zip a span { + background-position: -241px 0; + } + .UI_Editor_Menu_Button.Icon_upload a span { background-position: -212px 0; } diff --git a/media/jetpack/img/editor-buttons.png b/media/jetpack/img/editor-buttons.png index c74976f4a25e986467c0275dc1857fb28b30a8f6..91de667581ea0b77805f27a0c92e2d055d37ed3e 100644 GIT binary patch literal 11352 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 O7m$(s1}+yj3iuyh2?{v? literal 11111 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 diff --git a/media/jetpack/js/ide/controllers/PackageController.js b/media/jetpack/js/ide/controllers/PackageController.js index 2e003bbe..ba7f3672 100644 --- a/media/jetpack/js/ide/controllers/PackageController.js +++ b/media/jetpack/js/ide/controllers/PackageController.js @@ -45,6 +45,7 @@ module.exports = new Class({ copy_el: 'package-copy', test_el: 'try_in_browser', download_el: 'download', + zip_el: 'zip', console_el: 'error-console', save_el: 'package-save', menu_el: 'UI_Editor_Menu', @@ -144,7 +145,17 @@ module.exports = new Class({ } controller.downloadAddon(); }); + } + this.zip_el = dom.$(this.options.zip_el); + this.options.zip_url = this.zip_el.getElement('a').get('href'); + this.zip_el.addListener('click', function(e) { + e.preventDefault(); + if (this.hasClass(LOADING_CLASS)) { + return; + } + controller.zipRevision(); + }); this.copy_el = dom.$(this.options.copy_el); if (this.copy_el) { this.copy_el.addListener('click', function(e) { @@ -520,6 +531,94 @@ module.exports = new Class({ }).send(); }, + zipRevision: function() { + var el = dom.$(this.options.zip_el).getElement('a'); + var hashtag = this.options.hashtag; + var key = 'zip' + hashtag; + var filename = this.package_.get('name'); + var that = this; + if (el.hasClass('clicked')) { + return; + } + el.addClass('clicked'); + + fd().tests[key] = { + spinner: el.addClass('loading').addClass('small') + }; + var data = { + hashtag: hashtag, + filename: filename + }; + new Request({ + url: this.options.zip_url, + method: 'post', + data: data, + onComplete: function() { + el.removeClass('clicked'); + // remove spinner + el.removeClass('loading').removeClass('small'); + }, + onSuccess: function() { + var time = fd().options.request_interval; + log.debug('[zip] delayed .. try to load ever %d seconds', time / 1000); + fd().tests[key].download_request_number = 0; + fd().tests[key].zip_ID = setInterval(function() { + that.tryDownloadZip(hashtag, filename); + }, time); + } + }).send(); + }, + + /* + * Method: tryDownloadXPI + * + * Try to download XPI + * if finished - stop periodical, stop spinner + */ + tryDownloadZip: function (hashtag, filename) { + var zip_request = fd().tests['zip' + hashtag]; + if (!zip_request.download_zip_request || ( + zip_request.download_zip_request && + !zip_request.download_zip_request.isRunning())) { + zip_request.download_request_number++; + var url = '/revision/check_zip/' + hashtag + '/'; + log.debug('checking if ' + url + ' is prepared (attempt ' + + zip_request.download_request_number + '/50)'); + var r = zip_request.download_zip_request = new Request({ + method: 'get', + url: url, + timeout: fd().options.request_interval, + onSuccess: function(response) { + try { + response = JSON.parse(response); + } catch (jsonError) { + log.warning('JSON error: ', jsonError); + return; + } + if (response.ready || zip_request.download_request_number > 50) { + clearInterval(zip_request.zip_ID); + zip_request.spinner.removeClass('loading'); + if (!response.ready) { + fd.error.alert('ZIP download failed', + 'ZIP file is not yet prepared, giving up'); + } + } + if (response.ready) { + var url = '/revision/download_zip/'+hashtag+'/'+filename+'/'; + log.debug('downloading ' + filename + '.zip from ' + url ); + dom.window.getNode().location = url; + } + } + }); + + r.addListener('failure', function() { + clearInterval(zip_request.zip_ID); + zip_request.spinner.removeClass('loading'); + }); + r.send(); + } + }, + downloadAddon: function() { var el = dom.$(this.options.download_el).getElement('a'); if (el.hasClass('clicked')) { diff --git a/utils/zip.py b/utils/zip.py new file mode 100644 index 00000000..31c081c7 --- /dev/null +++ b/utils/zip.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python + +# from +# http://stackoverflow.com/questions/296499/how-do-i-zip-the-contents-of-a-folder-using-python-version-2-5 + +from contextlib import closing +from zipfile import ZipFile, ZIP_DEFLATED +import os + +def zipdir(basedir, archivename): + if not os.path.isdir(basedir): + raise OSError('No such directory', basedir) + with closing(ZipFile(archivename, "w", ZIP_DEFLATED)) as z: + for root, dirs, files in os.walk(basedir): + #NOTE: ignore empty directories + for fn in files: + absfn = os.path.join(root, fn) + zfn = absfn[len(basedir)+len(os.sep):] #XXX: relative path + z.write(absfn, zfn)