Start with the archive upload functionality (sponsored by Scilab);

add a new view and plain form to upload an archive; rename the internal
URLs, handlers and templates from submit to create for single downloads
and also add a help section about the new format as well as a detailed
FAQ entry. Archive files get a bigger upload limit (default: 20MB).

Next up: archive uploading, validation and processing.
This commit is contained in:
Thomas Keller 2011-11-02 00:15:33 +01:00
parent 34fbf6ec5f
commit c71ed2cecb
11 changed files with 404 additions and 10 deletions

View File

@ -0,0 +1,171 @@
<?php
/* -*- tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
/*
# ***** BEGIN LICENSE BLOCK *****
# This file is part of InDefero, an open source project management application.
# Copyright (C) 2008-2011 Céondo Ltd and contributors.
#
# InDefero is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# InDefero is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#
# ***** END LICENSE BLOCK ***** */
/**
* Upload and process an archive file.
*
*/
class IDF_Form_UploadArchive extends Pluf_Form
{
public $user = null;
public $project = null;
public function initFields($extra=array())
{
$this->user = $extra['user'];
$this->project = $extra['project'];
$this->fields['archive'] = new Pluf_Form_Field_File(
array('required' => true,
'label' => __('Archive file'),
'initial' => '',
'max_size' => Pluf::f('max_upload_archive_size', 20971520),
));
}
public function clean_archive()
{
$extra = strtolower(implode('|', explode(' ', Pluf::f('idf_extra_upload_ext'))));
if (strlen($extra)) $extra .= '|';
if (!preg_match('/\.('.$extra.'png|jpg|jpeg|gif|bmp|psd|tif|aiff|asf|avi|bz2|css|doc|eps|gz|jar|mdtext|mid|mov|mp3|mpg|ogg|pdf|ppt|ps|qt|ra|ram|rm|rtf|sdd|sdw|sit|sxi|sxw|swf|tgz|txt|wav|xls|xml|war|wmv|zip)$/i', $this->cleaned_data['file'])) {
@unlink(Pluf::f('upload_path').'/'.$this->project->shortname.'/files/'.$this->cleaned_data['file']);
throw new Pluf_Form_Invalid(__('For security reasons, you cannot upload a file with this extension.'));
}
return $this->cleaned_data['file'];
}
/**
* Validate the interconnection in the form.
*/
public function clean()
{
$conf = new IDF_Conf();
$conf->setProject($this->project);
$onemax = array();
foreach (explode(',', $conf->getVal('labels_download_one_max', IDF_Form_UploadConf::init_one_max)) as $class) {
if (trim($class) != '') {
$onemax[] = mb_strtolower(trim($class));
}
}
$count = array();
for ($i=1;$i<7;$i++) {
$this->cleaned_data['label'.$i] = trim($this->cleaned_data['label'.$i]);
if (strpos($this->cleaned_data['label'.$i], ':') !== false) {
list($class, $name) = explode(':', $this->cleaned_data['label'.$i], 2);
list($class, $name) = array(mb_strtolower(trim($class)),
trim($name));
} else {
$class = 'other';
$name = $this->cleaned_data['label'.$i];
}
if (!isset($count[$class])) $count[$class] = 1;
else $count[$class] += 1;
if (in_array($class, $onemax) and $count[$class] > 1) {
if (!isset($this->errors['label'.$i])) $this->errors['label'.$i] = array();
$this->errors['label'.$i][] = sprintf(__('You cannot provide more than label from the %s class to an issue.'), $class);
throw new Pluf_Form_Invalid(__('You provided an invalid label.'));
}
}
return $this->cleaned_data;
}
/**
* If we have uploaded a file, but the form failed remove it.
*
*/
function failed()
{
if (!empty($this->cleaned_data['file'])
and file_exists(Pluf::f('upload_path').'/'.$this->project->shortname.'/files/'.$this->cleaned_data['file'])) {
@unlink(Pluf::f('upload_path').'/'.$this->project->shortname.'/files/'.$this->cleaned_data['file']);
}
}
/**
* Save the model in the database.
*
* @param bool Commit in the database or not. If not, the object
* is returned but not saved in the database.
* @return Object Model with data set from the form.
*/
function save($commit=true)
{
if (!$this->isValid()) {
throw new Exception(__('Cannot save the model from an invalid form.'));
}
// Add a tag for each label
$tags = array();
for ($i=1;$i<7;$i++) {
if (strlen($this->cleaned_data['label'.$i]) > 0) {
if (strpos($this->cleaned_data['label'.$i], ':') !== false) {
list($class, $name) = explode(':', $this->cleaned_data['label'.$i], 2);
list($class, $name) = array(trim($class), trim($name));
} else {
$class = 'Other';
$name = trim($this->cleaned_data['label'.$i]);
}
$tags[] = IDF_Tag::add($name, $this->project, $class);
}
}
// Create the upload
$upload = new IDF_Upload();
$upload->project = $this->project;
$upload->submitter = $this->user;
$upload->summary = trim($this->cleaned_data['summary']);
$upload->changelog = trim($this->cleaned_data['changelog']);
$upload->file = $this->cleaned_data['file'];
$upload->filesize = filesize(Pluf::f('upload_path').'/'.$this->project->shortname.'/files/'.$this->cleaned_data['file']);
$upload->downloads = 0;
$upload->create();
foreach ($tags as $tag) {
$upload->setAssoc($tag);
}
// Send the notification
$upload->notify($this->project->getConf());
/**
* [signal]
*
* IDF_Upload::create
*
* [sender]
*
* IDF_Form_Upload
*
* [description]
*
* This signal allows an application to perform a set of tasks
* just after the upload of a file and after the notification run.
*
* [parameters]
*
* array('upload' => $upload);
*
*/
$params = array('upload' => $upload);
Pluf_Signal::send('IDF_Upload::create', 'IDF_Form_Upload',
$params);
return $upload;
}
}

View File

@ -292,6 +292,22 @@ class IDF_Views
}
/**
* Download archive FAQ.
*/
public function faqArchiveFormat($request, $match)
{
$title = __('InDefero Upload Archive Format');
$projects = self::getProjects($request->user);
return Pluf_Shortcuts_RenderToResponse('idf/faq-archive-format.html',
array(
'page_title' => $title,
'projects' => $projects,
),
$request);
}
/**
* API FAQ.
*/

View File

@ -224,11 +224,11 @@ class IDF_Views_Download
}
/**
* Submit a new file for download.
* Create a new file for download.
*/
public $submit_precond = array('IDF_Precondition::accessDownloads',
public $create_precond = array('IDF_Precondition::accessDownloads',
'IDF_Precondition::projectMemberOrOwner');
public function submit($request, $match)
public function create($request, $match)
{
$prj = $request->project;
$title = __('New Download');
@ -250,7 +250,7 @@ class IDF_Views_Download
array('project' => $prj,
'user' => $request->user));
}
return Pluf_Shortcuts_RenderToResponse('idf/downloads/submit.html',
return Pluf_Shortcuts_RenderToResponse('idf/downloads/create.html',
array(
'auto_labels' => self::autoCompleteArrays($prj),
'page_title' => $title,
@ -259,6 +259,39 @@ class IDF_Views_Download
$request);
}
/**
* Create new downloads from an uploaded archive.
*/
public $createFromArchive_precond = array('IDF_Precondition::accessDownloads',
'IDF_Precondition::projectMemberOrOwner');
public function createFromArchive($request, $match)
{
$prj = $request->project;
$title = __('New Downloads from Archive');
if ($request->method == 'POST') {
$form = new IDF_Form_UploadArchive(array_merge($request->POST, $request->FILES),
array('project' => $prj,
'user' => $request->user));
if ($form->isValid()) {
$upload = $form->save();
$request->user->setMessage(__('The archive has been uploaded and processed.'));
$url = Pluf_HTTP_URL_urlForView('IDF_Views_Download::index',
array($prj->shortname));
return new Pluf_HTTP_Response_Redirect($url);
}
} else {
$form = new IDF_Form_UploadArchive(null,
array('project' => $prj,
'user' => $request->user));
}
return Pluf_Shortcuts_RenderToResponse('idf/downloads/createFromArchive.html',
array(
'page_title' => $title,
'form' => $form,
),
$request);
}
/**
* Create the autocomplete arrays for the little AJAX stuff.
*/

View File

@ -495,6 +495,11 @@ $cfg['idf_strong_key_check'] = false;
# always have precedence.
# $cfg['max_upload_size'] = 2097152; // Size in bytes
# If a download archive is uploaded, the size of the archive is limited to 20MB.
# The php.ini upload_max_filesize and post_max_size configuration setting will
# always have precedence.
# $cfg['max_upload_archive_size'] = 20971520; // Size in bytes
# Older versions of Indefero submitted a POST request to a configured
# post-commit web hook when new revisions arrived, whereas a PUT request
# would have been more appropriate. Also, the payload's HMAC digest was

View File

@ -304,6 +304,11 @@ $ctl[] = array('regex' => '#^/p/([\-\w]+)/page/(.*)/$#',
// ---------- Downloads ------------------------------------
$ctl[] = array('regex' => '#^/help/archive-format/$#',
'base' => $base,
'model' => 'IDF_Views',
'method' => 'faqArchiveFormat');
$ctl[] = array('regex' => '#^/p/([\-\w]+)/downloads/$#',
'base' => $base,
'model' => 'IDF_Views_Download',
@ -332,7 +337,12 @@ $ctl[] = array('regex' => '#^/p/([\-\w]+)/downloads/(\d+)/get/$#',
$ctl[] = array('regex' => '#^/p/([\-\w]+)/downloads/create/$#',
'base' => $base,
'model' => 'IDF_Views_Download',
'method' => 'submit');
'method' => 'create');
$ctl[] = array('regex' => '#^/p/([\-\w]+)/downloads/create/archive/$#',
'base' => $base,
'model' => 'IDF_Views_Download',
'method' => 'createFromArchive');
$ctl[] = array('regex' => '#^/p/([\-\w]+)/downloads/(\d+)/delete/$#',
'base' => $base,

View File

@ -2,6 +2,10 @@
{block tabdownloads} class="active"{/block}
{block subtabs}
<div id="sub-tabs">
<a {if $inDownloads}class="active" {/if}href="{url 'IDF_Views_Download::index', array($project.shortname)}">{trans 'Downloads'}</a> {if $isOwner or $isMember}| <a {if $inSubmit}class="active" {/if}href="{url 'IDF_Views_Download::submit', array($project.shortname)}">{trans 'New Download'}</a> {/if}
<a {if $inDownloads}class="active" {/if}href="{url 'IDF_Views_Download::index', array($project.shortname)}">{trans 'Downloads'}</a>
{if $isOwner or $isMember}
| <a {if $inCreate}class="active" {/if}href="{url 'IDF_Views_Download::create', array($project.shortname)}">{trans 'New Download'}</a>
| <a {if $inCreateFromArchive}class="active" {/if}href="{url 'IDF_Views_Download::createFromArchive', array($project.shortname)}">{trans 'Upload Archive'}</a>
{/if}
</div>
{/block}

View File

@ -1,5 +1,5 @@
{extends "idf/downloads/base.html"}
{block docclass}yui-t3{assign $inSubmit=true}{/block}
{block docclass}yui-t3{assign $inCreate=true}{/block}
{block body}
{if $form.errors}
<div class="px-message-error">

View File

@ -0,0 +1,38 @@
{extends "idf/downloads/base.html"}
{block docclass}yui-t3{assign $inCreateFromArchive=true}{/block}
{block body}
{if $form.errors}
<div class="px-message-error">
<p>{trans 'The form contains some errors. Please correct them to submit the archive.'}</p>
{if $form.get_top_errors}
{$form.render_top_errors|unsafe}
{/if}
</div>
{/if}
<form method="post" enctype="multipart/form-data" action=".">
<table class="form" summary="">
<tr>
<th><strong>{$form.f.archive.labelTag}:</strong></th>
<td>{if $form.f.archive.errors}{$form.f.archive.fieldErrors}{/if}
{$form.f.archive|unsafe}
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td><input type="submit" value="{trans 'Submit Archive'}" name="submit" /> | <a href="{url 'IDF_Views_Download::index', array($project.shortname)}">{trans 'Cancel'}</a>
</td>
</tr>
</table>
</form>
{/block}
{block context}
<div class="issue-submit-info">
<h2>{trans 'Instructions'}</h2>
<p>{blocktrans}The archive must include a <code>manifest.xml</code> file with meta information about the
files to process inside the archive. All processed files must be unique or replace existing files explicitely.{/blocktrans}</p>
{aurl 'url', 'IDF_Views::faqArchiveFormat'}
<p>{blocktrans}You can learn more about the archive format <a href="{$url}">here</a>.{/blocktrans}</p>
</div>
{/block}

View File

@ -3,7 +3,7 @@
{block body}
{$downloads.render}
{if $isOwner or $isMember}
{aurl 'url', 'IDF_Views_Download::submit', array($project.shortname)}
{aurl 'url', 'IDF_Views_Download::create', array($project.shortname)}
<p><a href="{$url}"><img style="vertical-align: text-bottom;" src="{media '/idf/img/add.png'}" alt="+" align="bottom" /></a> <a href="{$url}">{trans 'New Download'}</a></p>{/if}
{/block}

View File

@ -0,0 +1,100 @@
{extends "idf/base-simple.html"}
{block docclass}yui-t3{/block}
{block body}
<p>At the moment, this documentation is only available in English.</p>
<ul>
<li><a href="#q-motivation">Motivation</a></li>
<li><a href="#q-manifest">The manifest format</a></li>
</ul>
<h2 id="q-motivation">Motivation</h2>
<p>
Adding multiple, individual downloads to a project for a release can be a tedious task if
one has to select each file manually, and then has to fill in the summary and correct labels
for each of these downloads individually.
</p>
<p>
InDefero therefor supports the upload of "archives" that contain multiple downloadable
files. These archives are standard PKZIP files with only one special property - they
contain an additional manifest file which describes the files that should be published.
</p>
<p>
Once such an archive has been uploaded and validated by InDefero, its files are extracted
and individual downloads are created for each of them. If the archive contains files
that should deprecate existing downloads, then InDefero takes care of this as well -
automatically.
</p>
<p>
An archive file and its manifest file can easily be compiled, either by hand with the help
of a text editor, or through an automated build system with the help of your build tool of
choice, such as Apache Ant.
</p>
<h2 id="q-manifest">The manifest format</h2>
<p>
The manifest is an XML file that follows a simple syntax. As it is always easier to look
at an example, here you have one:
</p>
<pre>
&lt;?xml version="1.0" encoding="UTF-8" ?&gt;
&lt;manifest>
&lt;file>
&lt;name>foo-1.2.tar.gz&lt;/name>
&lt;summary>Tarball&lt;/summary>
&lt;replaces>foo-1.1.tar.gz&lt;/replaces>
&lt;tags>
&lt;tag>Type:Archive&lt;/tag>
&lt;/tags>
&lt;/file>
&lt;file>
&lt;name>foo-1.2-installer.exe&lt;/name>
&lt;summary>Windows MSI Installer&lt;/summary>
&lt;description>This installer needs Windows XP SP2 or later.&lt;/description>
&lt;tags>
&lt;tag>Type:Installer&lt;/tag>
&lt;tag>OpSys:Windows&lt;/tag>
&lt;/tags>
&lt;/file>
&lt;/manifest>
</pre>
<p>
This is the DTD for the format:
</p>
<pre>
&lt;!DOCTYPE manifest [
&lt;!ELEMENT manifest (file+)>
&lt;!ELEMENT file (name,summary,replaces?,description?,tags?)>
&lt;!ELEMENT name (#PCDATA)>
&lt;!ELEMENT summary (#PCDATA)>
&lt;!ELEMENT replaces (#PCDATA)>
&lt;!ELEMENT description (#PCDATA)>
&lt;!ELEMENT tags (tag+)>
&lt;!ELEMENT tag (#PCDATA)>
]>
</pre>
<p>
The format is more or less self-explaining, all fields map to properties of a single download.
One special element has been introduced though, <code>replaces</code>. If this optional element
is given, InDefero looks for a file with that name in the project and deprecates it by attaching
the label <code>Other:Deprecated</code> to it. If no such file is found, the element is simply
ignored.
</p>
{/block}
{block context}
<p>{trans 'Here we are, just to help you.'}</p>
<h2>{trans 'Projects'}</h2>
<ul>{foreach $projects as $p}
<li><a href="{url 'IDF_Views_Project::home', array($p.shortname)}">{$p}</a></li>
{/foreach}</ul>
{/block}

View File

@ -5,6 +5,7 @@
<li><a href="#q-keyboard">{trans 'What are the keyboard shortcuts?'}</a></li>
<li><a href="#q-duplicate">{trans 'How to mark an issue as duplicate?'}</a></li>
<li><a href="#q-mugshot">{trans 'How can I display my head next to my comments?'}</a></li>
<li><a href="#q-archive-format">{trans 'What is this "Upload Archive" functionality about?'}</a></li>
<li><a href="#q-api">{trans 'What is the API and how is it used?'}</a></li>
</ul>
@ -48,11 +49,27 @@
<p>{blocktrans}You need to create an account on <a href="http://en.gravatar.com/">Gravatar</a>, this takes about 5 minutes and is free.{/blocktrans}</p>
<h2 id="q-archive-format">{trans 'What is this "Upload Archive" functionality about?'}</h2>
{blocktrans}<p>If you have to publish many files at once for a new release, it is a very tedious task
to upload them one after another and enter meta information like a summary, a description or additional
labels for each of them.</p>
<p>InDefero therefor supports a special archive format that is basically a standard zip file which comes with
some meta information. These meta information are kept in a special manifest file, which is distinctly kept from
the rest of the files in the archive that should be published.</p>
<p>Once this archive has been uploaded, InDefero reads in the meta information, unpacks the other files from
the archive and creates new individual downloads for each of them.</p>{/blocktrans}
{aurl 'url', 'IDF_Views::faqArchiveFormat'}
<p>{blocktrans}<a href="{$url}">Learn more about the archive format</a>.{/blocktrans}</p>
<h2 id="q-api">{trans 'What is the API and how is it used?'}</h2>
<p>{blocktrans}The API (Application Programming Interface) is used to interact with InDefero with another program. For example, this can be used to create a desktop program to submit new tickets easily.{/blocktrans}</p>{aurl 'url', 'IDF_Views::faqApi'}
<p>{blocktrans}The API (Application Programming Interface) is used to interact with InDefero with another program. For example, this can be used to create a desktop program to submit new tickets easily.{/blocktrans}</p>
{aurl 'url', 'IDF_Views::faqApi'}
<p>{blocktrans}<a href="{$url}">Learn more about the API</a>.{/blocktrans}</p>
{/block}
{block context}
<p>{trans 'Here we are, just to help you.'}</p>