More code for import.

This commit is contained in:
James Cole 2016-06-24 14:24:34 +02:00
parent 9ffc0936ee
commit 3d201db6fc
16 changed files with 416 additions and 80 deletions

View File

@ -3,11 +3,13 @@
namespace FireflyIII\Http\Controllers; namespace FireflyIII\Http\Controllers;
use Crypt; use Crypt;
use FireflyIII\Exceptions\FireflyException;
use FireflyIII\Http\Requests; use FireflyIII\Http\Requests;
use FireflyIII\Http\Requests\ImportUploadRequest; use FireflyIII\Http\Requests\ImportUploadRequest;
use FireflyIII\Import\Importer\ImporterInterface; use FireflyIII\Import\Importer\ImporterInterface;
use FireflyIII\Models\ImportJob; use FireflyIII\Models\ImportJob;
use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface; use FireflyIII\Repositories\ImportJob\ImportJobRepositoryInterface;
use Illuminate\Http\Request;
use SplFileObject; use SplFileObject;
use Storage; use Storage;
use View; use View;
@ -30,26 +32,34 @@ class ImportController extends Controller
} }
/** /**
* This is step 3.
* This is the first step in configuring the job. It can only be executed
* when the job is set to "import_status_never_started".
*
* @param ImportJob $job * @param ImportJob $job
* *
* @return View * @return View
* @throws FireflyException
*/ */
public function configure(ImportJob $job) public function configure(ImportJob $job)
{ {
// create proper importer (depends on job) if (!$this->jobInCorrectStep($job, 'configure')) {
$type = $job->file_type; return $this->redirectToCorrectStep($job);
/** @var ImporterInterface $importer */ }
$importer = app('FireflyIII\Import\Importer\\' . ucfirst($type) . 'Importer');
$importer->setJob($job); // actual code
$importer = $this->makeImporter($job);
$importer->configure(); $importer->configure();
$data = $importer->getConfigurationData(); $data = $importer->getConfigurationData();
return view('import.' . $type . '.configure', compact('data', 'job')); return view('import.' . $job->file_type . '.configure', compact('data', 'job'));
} }
/** /**
* This is step 1. Upload a file.
*
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/ */
public function index() public function index()
@ -67,6 +77,68 @@ class ImportController extends Controller
} }
/** /**
* Step 4. Save the configuration.
*
* @param Request $request
* @param ImportJob $job
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws FireflyException
*/
public function process(Request $request, ImportJob $job)
{
if (!$this->jobInCorrectStep($job, 'process')) {
return $this->redirectToCorrectStep($job);
}
// actual code
$importer = $this->makeImporter($job);
$data = $request->all();
$importer->saveImportConfiguration($data);
// update job:
$job->status = 'import_configuration_saved';
$job->save();
// return redirect to settings.
// this could loop until the user is done.
return redirect(route('import.settings', $job->key));
}
/**
* Step 5. Depending on the importer, this will show the user settings to
* fill in.
*
* @param ImportJob $job
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws FireflyException
*/
public function settings(ImportJob $job)
{
if (!$this->jobInCorrectStep($job, 'settings')) {
return $this->redirectToCorrectStep($job);
}
$importer = $this->makeImporter($job);
// now
echo 'now in settings';
exit;
// actual code
// ask the importer for the requested action.
// for example pick columns or map data.
// depends of course on the data in the job.
}
/**
* This is step 2. It creates an Import Job. Stores the import.
*
* @param ImportUploadRequest $request * @param ImportUploadRequest $request
* @param ImportJobRepositoryInterface $repository * @param ImportJobRepositoryInterface $repository
* *
@ -88,4 +160,64 @@ class ImportController extends Controller
return redirect(route('import.configure', [$job->key])); return redirect(route('import.configure', [$job->key]));
} }
/**
* @param ImportJob $job
* @param string $method
*
* @return bool
*/
private function jobInCorrectStep(ImportJob $job, string $method): bool
{
switch ($method) {
case 'configure':
case 'process':
return $job->status === 'import_status_never_started';
break;
case 'settings':
return $job->status === 'import_configuration_saved';
break;
}
return false;
}
/**
* @param ImportJob $job
*
* @return ImporterInterface
*/
private function makeImporter(ImportJob $job): ImporterInterface
{
// create proper importer (depends on job)
$type = $job->file_type;
/** @var ImporterInterface $importer */
$importer = app('FireflyIII\Import\Importer\\' . ucfirst($type) . 'Importer');
$importer->setJob($job);
return $importer;
}
/**
* @param ImportJob $job
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws FireflyException
*/
private function redirectToCorrectStep(ImportJob $job)
{
switch ($job->status) {
case 'import_status_never_started':
return redirect(route('import.configure', [$job->key]));
break;
case 'import_configuration_saved':
return redirect(route('import.settings', [$job->key]));
break;
}
throw new FireflyException('Cannot redirect for job state ' . $job->status);
}
} }

View File

@ -223,9 +223,12 @@ Route::group(
/** /**
* IMPORT CONTROLLER * IMPORT CONTROLLER
*/ */
Route::get('/import', ['uses' => 'ImportController@index','as' => 'import.index']); Route::get('/import', ['uses' => 'ImportController@index', 'as' => 'import.index']);
Route::post('/import/upload', ['uses' => 'ImportController@upload','as' => 'import.upload']); Route::post('/import/upload', ['uses' => 'ImportController@upload', 'as' => 'import.upload']);
Route::get('/import/configure/{importJob}', ['uses' => 'ImportController@configure','as' => 'import.configure']); Route::get('/import/configure/{importJob}', ['uses' => 'ImportController@configure', 'as' => 'import.configure']);
Route::post('/import/process/{importJob}', ['uses' => 'ImportController@process', 'as' => 'import.process_configuration']);
Route::get('/import/settings/{importJob}', ['uses' => 'ImportController@settings', 'as' => 'import.settings']);
/** /**
* Help Controller * Help Controller

View File

@ -13,6 +13,7 @@ namespace FireflyIII\Import\Importer;
use ExpandedForm; use ExpandedForm;
use FireflyIII\Crud\Account\AccountCrud;
use FireflyIII\Import\Role\Map; use FireflyIII\Import\Role\Map;
use FireflyIII\Models\AccountType; use FireflyIII\Models\AccountType;
use FireflyIII\Models\ImportJob; use FireflyIII\Models\ImportJob;
@ -50,12 +51,23 @@ class CsvImporter implements ImporterInterface
'tab' => trans('form.csv_tab'), 'tab' => trans('form.csv_tab'),
]; ];
$specifics = [];
// collect specifics.
foreach (config('firefly.csv_import_specifics') as $name => $className) {
$specifics[$name] = [
'name' => $className::getName(),
'description' => $className::getDescription(),
];
}
$data = [ $data = [
'accounts' => ExpandedForm::makeSelectList($accounts), 'accounts' => ExpandedForm::makeSelectList($accounts),
'specifix' => [], 'specifix' => [],
'delimiters' => $delimiters, 'delimiters' => $delimiters,
'upload_path' => storage_path('upload'), 'upload_path' => storage_path('upload'),
'is_upload_possible' => is_writable(storage_path('upload')), 'is_upload_possible' => is_writable(storage_path('upload')),
'specifics' => $specifics,
]; ];
return $data; return $data;
@ -73,6 +85,40 @@ class CsvImporter implements ImporterInterface
exit; exit;
} }
/**
* @param array $data
*
* @return bool
*/
public function saveImportConfiguration(array $data): bool
{
/** @var AccountCrud $repository */
$repository = app(AccountCrud::class);
$account = $repository->find(intval($data['csv_import_account']));
$configuration = [
'date_format' => $data['date_format'],
'csv_delimiter' => $data['csv_delimiter'],
'csv_import_account' => 0,
'specifics' => [],
];
if (!is_null($account->id)) {
$configuration['csv_import_account'] = $account->id;
}
// loop specifics.
if (is_array($data['specifics'])) {
foreach ($data['specifics'] as $name => $enabled) {
$configuration['specifics'][] = $name;
}
}
$this->job->configuration = $configuration;
$this->job->save();
return true;
}
/** /**
* @param ImportJob $job * @param ImportJob $job
*/ */

View File

@ -36,6 +36,13 @@ interface ImporterInterface
*/ */
public function getConfigurationData(): array; public function getConfigurationData(): array;
/**
* @param array $data
*
* @return bool
*/
public function saveImportConfiguration(array $data): bool;
/** /**
* Returns a Map thing used to allow the user to * Returns a Map thing used to allow the user to
* define roles for each entry. * define roles for each entry.

View File

@ -0,0 +1,37 @@
<?php
/**
* AbnAmroDescription.php
* Copyright (C) 2016 thegrumpydictator@gmail.com
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
declare(strict_types = 1);
namespace FireflyIII\Import\Specifics;
/**
* Class AbnAmroDescription
*
* @package FireflyIII\Import\Specifics
*/
class AbnAmroDescription implements SpecificInterface
{
/**
* @return string
*/
static public function getName(): string
{
return 'ABN Amro description';
}
/**
* @return string
*/
static public function getDescription(): string
{
return 'Fixes possible problems with ABN Amro descriptions.';
}
}

View File

@ -0,0 +1,36 @@
<?php
/**
* RabobankDescription.php
* Copyright (C) 2016 thegrumpydictator@gmail.com
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
declare(strict_types = 1);
namespace FireflyIII\Import\Specifics;
/**
* Class RabobankDescription
*
* @package FireflyIII\Import\Specifics
*/
class RabobankDescription implements SpecificInterface
{
/**
* @return string
*/
static public function getName(): string
{
return 'Rabobank description';
}
/**
* @return string
*/
static public function getDescription(): string
{
return 'Fixes possible problems with Rabobank descriptions.';
}
}

View File

@ -0,0 +1,31 @@
<?php
/**
* SpecificInterface.php
* Copyright (C) 2016 thegrumpydictator@gmail.com
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
declare(strict_types = 1);
namespace FireflyIII\Import\Specifics;
/**
* Interface SpecificInterface
*
* @package FireflyIII\Import\Specifics
*/
interface SpecificInterface
{
/**
* @return string
*/
static public function getName(): string;
/**
* @return string
*/
static public function getDescription(): string;
}

View File

@ -58,6 +58,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\Bill whereNameEncrypted($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\Bill whereNameEncrypted($value)
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\Bill whereMatchEncrypted($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\Bill whereMatchEncrypted($value)
* @mixin \Eloquent * @mixin \Eloquent
* @property string $deleted_at
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\Bill whereDeletedAt($value)
*/ */
class Bill extends Model class Bill extends Model
{ {

View File

@ -46,6 +46,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\Budget whereEncrypted($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\Budget whereEncrypted($value)
* @mixin \Eloquent * @mixin \Eloquent
* @property-read \Illuminate\Database\Eloquent\Collection|\FireflyIII\Models\Transaction[] $transactions * @property-read \Illuminate\Database\Eloquent\Collection|\FireflyIII\Models\Transaction[] $transactions
* @property-read \Illuminate\Database\Eloquent\Collection|\FireflyIII\Models\LimitRepetition[] $limitrepetitions
*/ */
class Budget extends Model class Budget extends Model
{ {

View File

@ -15,17 +15,18 @@ use Auth;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/** /**
* Class ImportJob * FireflyIII\Models\ImportJob
* *
* @package FireflyIII\Models * @property integer $id
* @property integer $id * @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at
* @property \Carbon\Carbon $updated_at * @property integer $user_id
* @property integer $user_id * @property string $key
* @property string $key * @property string $file_type
* @property string $file_type * @property string $status
* @property string $status * @property string $configuration
* @property-read \FireflyIII\User $user * @property-read \FireflyIII\User $user
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereId($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereId($value)
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereCreatedAt($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereCreatedAt($value)
@ -34,6 +35,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereKey($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereKey($value)
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereFileType($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereFileType($value)
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereStatus($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereStatus($value)
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\ImportJob whereConfiguration($value)
* @mixin \Eloquent * @mixin \Eloquent
*/ */
class ImportJob extends Model class ImportJob extends Model
@ -72,4 +74,27 @@ class ImportJob extends Model
{ {
return $this->belongsTo('FireflyIII\User'); return $this->belongsTo('FireflyIII\User');
} }
/**
* @param $value
*
* @return mixed
*/
public function getConfigurationAttribute($value)
{
if (strlen($value) == 0) {
return [];
}
return json_decode($value);
}
/**
* @param $value
*/
public function setConfigurationAttribute($value)
{
$this->attributes['configuration'] = json_encode($value);
}
} }

View File

@ -51,6 +51,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\PiggyBank whereDeletedAt($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\PiggyBank whereDeletedAt($value)
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\PiggyBank whereEncrypted($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\PiggyBank whereEncrypted($value)
* @mixin \Eloquent * @mixin \Eloquent
* @property boolean $active
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\Models\PiggyBank whereActive($value)
*/ */
class PiggyBank extends Model class PiggyBank extends Model
{ {

View File

@ -55,6 +55,8 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
* @method static \Illuminate\Database\Query\Builder|\FireflyIII\User whereBlockedCode($value) * @method static \Illuminate\Database\Query\Builder|\FireflyIII\User whereBlockedCode($value)
* @mixin \Eloquent * @mixin \Eloquent
* @property-read \Illuminate\Database\Eloquent\Collection|\FireflyIII\Models\ImportJob[] $importjobs * @property-read \Illuminate\Database\Eloquent\Collection|\FireflyIII\Models\ImportJob[] $importjobs
* @property-read \Illuminate\Database\Eloquent\Collection|\FireflyIII\Models\PiggyBank[] $piggyBanks
* @property-read \Illuminate\Database\Eloquent\Collection|\FireflyIII\Models\Transaction[] $transactions
*/ */
class User extends Authenticatable class User extends Authenticatable
{ {

View File

@ -11,11 +11,15 @@ return [
'resend_confirmation' => 3600, 'resend_confirmation' => 3600,
'confirmation_age' => 14400, // four hours 'confirmation_age' => 14400, // four hours
'export_formats' => [ 'export_formats' => [
'csv' => 'FireflyIII\Export\Exporter\CsvExporter', 'csv' => 'FireflyIII\Export\Exporter\CsvExporter',
// mt940 FireflyIII Export Exporter MtExporter // mt940 FireflyIII Export Exporter MtExporter
], ],
'import_formats' => [ 'csv_import_specifics' => [
'RabobankDescription' => 'FireflyIII\Import\Specifics\RabobankDescription',
'AbnAmroDescription' => 'FireflyIII\Import\Specifics\AbnAmroDescription',
],
'import_formats' => [
'csv' => 'FireflyIII\Import\Importer\CsvImporter', 'csv' => 'FireflyIII\Import\Importer\CsvImporter',
// mt940 FireflyIII Import Importer MtImporter // mt940 FireflyIII Import Importer MtImporter
], ],

View File

@ -1,44 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
/**
* Class ChangesForV391
*/
class ChangesForV391 extends Migration
{
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('import_jobs');
}
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// new table "import_jobs"
Schema::create(
'import_jobs', function (Blueprint $table) {
$table->increments('id');
$table->timestamps();
$table->integer('user_id')->unsigned();
$table->string('key', 12)->unique();
$table->string('file_type', 12);
$table->string('status', 45);
// connect rule groups to users
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
}
);
}
}

View File

@ -13,13 +13,13 @@ class CreateMainTables extends Migration
*/ */
public function down() public function down()
{ {
//
Schema::drop('account_meta'); Schema::drop('account_meta');
Schema::drop('piggy_bank_repetitions'); Schema::drop('piggy_bank_repetitions');
Schema::drop('attachments'); Schema::drop('attachments');
Schema::drop('limit_repetitions'); Schema::drop('limit_repetitions');
Schema::drop('budget_limits'); Schema::drop('budget_limits');
Schema::drop('export_jobs'); Schema::drop('export_jobs');
Schema::drop('import_jobs');
Schema::drop('preferences'); Schema::drop('preferences');
Schema::drop('role_user'); Schema::drop('role_user');
Schema::drop('rule_actions'); Schema::drop('rule_actions');
@ -109,6 +109,9 @@ class CreateMainTables extends Migration
} }
} }
/**
*
*/
private function createAttachmentsTable() private function createAttachmentsTable()
{ {
@ -139,6 +142,9 @@ class CreateMainTables extends Migration
} }
} }
/**
*
*/
private function createBillsTable() private function createBillsTable()
{ {
if (!Schema::hasTable('bills')) { if (!Schema::hasTable('bills')) {
@ -167,6 +173,9 @@ class CreateMainTables extends Migration
} }
} }
/**
*
*/
private function createBudgetTables() private function createBudgetTables()
{ {
@ -224,6 +233,9 @@ class CreateMainTables extends Migration
} }
} }
/**
*
*/
private function createCategoriesTable() private function createCategoriesTable()
{ {
if (!Schema::hasTable('categories')) { if (!Schema::hasTable('categories')) {
@ -244,6 +256,9 @@ class CreateMainTables extends Migration
} }
} }
/**
*
*/
private function createExportJobsTable() private function createExportJobsTable()
{ {
if (!Schema::hasTable('export_jobs')) { if (!Schema::hasTable('export_jobs')) {
@ -254,16 +269,31 @@ class CreateMainTables extends Migration
$table->integer('user_id', false, true); $table->integer('user_id', false, true);
$table->string('key', 12); $table->string('key', 12);
$table->string('status', 255); $table->string('status', 255);
// link user id to users table
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
} }
); );
} }
if (!Schema::hasTable('import_jobs')) {
Schema::create(
'import_jobs', function (Blueprint $table) {
$table->increments('id');
$table->timestamps();
$table->integer('user_id')->unsigned();
$table->string('key', 12)->unique();
$table->string('file_type', 12);
$table->string('status', 45);
$table->text('configuration');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
}
);
}
} }
/**
*
*/
private function createPiggyBanksTable() private function createPiggyBanksTable()
{ {
if (!Schema::hasTable('piggy_banks')) { if (!Schema::hasTable('piggy_banks')) {
@ -305,6 +335,9 @@ class CreateMainTables extends Migration
} }
/**
*
*/
private function createPreferencesTable() private function createPreferencesTable()
{ {
if (!Schema::hasTable('preferences')) { if (!Schema::hasTable('preferences')) {
@ -322,6 +355,9 @@ class CreateMainTables extends Migration
} }
} }
/**
*
*/
private function createRoleTable() private function createRoleTable()
{ {
@ -342,6 +378,9 @@ class CreateMainTables extends Migration
} }
/**
*
*/
private function createRuleTables() private function createRuleTables()
{ {
if (!Schema::hasTable('rule_groups')) { if (!Schema::hasTable('rule_groups')) {
@ -454,6 +493,9 @@ class CreateMainTables extends Migration
} }
} }
/**
*
*/
private function createTransactionTables() private function createTransactionTables()
{ {
@ -607,6 +649,4 @@ class CreateMainTables extends Migration
); );
} }
} }
} }

View File

@ -22,7 +22,7 @@
</div> </div>
</div> </div>
<form class="form-horizontal" action="#" method="post" enctype="multipart/form-data"> <form class="form-horizontal" action="{{ route('import.process_configuration', job.key) }}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_token" value="{{ csrf_token() }}"/> <input type="hidden" name="_token" value="{{ csrf_token() }}"/>
<div class="row"> <div class="row">
@ -41,9 +41,21 @@
{{ ExpandedForm.select('csv_import_account', data.accounts, 0, {helpText: 'csv_import_account_help'|_} ) }} {{ ExpandedForm.select('csv_import_account', data.accounts, 0, {helpText: 'csv_import_account_help'|_} ) }}
{{ ExpandedForm.multiCheckbox('specifix', data.specifix) }} {% for type, specific in data.specifics %}
<div class="form-group">
<label for="{{ type }}_label" class="col-sm-4 control-label">
{{ specific.name }}
</label>
<div class="col-sm-8">
<div class="radio"><label>
{{ Form.checkbox('specifics['~type~']', '1', Input.old('specifics')[type] == '1', {'id': type ~ '_label'}) }}
{{ specific.description }}
</label>
</div>
</div>
</div>
{% endfor %}
{% if not data.is_upload_possible %} {% if not data.is_upload_possible %}
<div class="form-group" id="csv_holder"> <div class="form-group" id="csv_holder">
@ -65,17 +77,17 @@
</div> </div>
</div> </div>
{% if data.is_upload_possible %} {% if data.is_upload_possible %}
<div class="row"> <div class="row">
<div class="col-lg-12"> <div class="col-lg-12">
<div class="box"> <div class="box">
<div class="box-body"> <div class="box-body">
<button type="submit" class="pull-right btn btn-success"> <button type="submit" class="pull-right btn btn-success">
{{ 'csv_upload_button'|_ }} {{ 'csv_upload_button'|_ }}
</button> </button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
{% endif %} {% endif %}
</form> </form>