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:
parent
34fbf6ec5f
commit
c71ed2cecb
171
src/IDF/Form/UploadArchive.php
Normal file
171
src/IDF/Form/UploadArchive.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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}
|
||||
|
@ -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">
|
38
src/IDF/templates/idf/downloads/createFromArchive.html
Normal file
38
src/IDF/templates/idf/downloads/createFromArchive.html
Normal 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> </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}
|
@ -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}
|
||||
|
100
src/IDF/templates/idf/faq-archive-format.html
Normal file
100
src/IDF/templates/idf/faq-archive-format.html
Normal 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>
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<manifest>
|
||||
<file>
|
||||
<name>foo-1.2.tar.gz</name>
|
||||
<summary>Tarball</summary>
|
||||
<replaces>foo-1.1.tar.gz</replaces>
|
||||
<tags>
|
||||
<tag>Type:Archive</tag>
|
||||
</tags>
|
||||
</file>
|
||||
<file>
|
||||
<name>foo-1.2-installer.exe</name>
|
||||
<summary>Windows MSI Installer</summary>
|
||||
<description>This installer needs Windows XP SP2 or later.</description>
|
||||
<tags>
|
||||
<tag>Type:Installer</tag>
|
||||
<tag>OpSys:Windows</tag>
|
||||
</tags>
|
||||
</file>
|
||||
</manifest>
|
||||
</pre>
|
||||
|
||||
<p>
|
||||
This is the DTD for the format:
|
||||
</p>
|
||||
|
||||
<pre>
|
||||
<!DOCTYPE manifest [
|
||||
<!ELEMENT manifest (file+)>
|
||||
<!ELEMENT file (name,summary,replaces?,description?,tags?)>
|
||||
<!ELEMENT name (#PCDATA)>
|
||||
<!ELEMENT summary (#PCDATA)>
|
||||
<!ELEMENT replaces (#PCDATA)>
|
||||
<!ELEMENT description (#PCDATA)>
|
||||
<!ELEMENT tags (tag+)>
|
||||
<!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}
|
@ -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,9 +49,25 @@
|
||||
|
||||
<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}
|
||||
|
Loading…
Reference in New Issue
Block a user