Works up until actual import.

This commit is contained in:
James Cole 2016-07-02 23:08:47 +02:00
parent 162c762973
commit ae768a8525
12 changed files with 444 additions and 71 deletions

View File

@ -33,6 +33,27 @@ class ImportController extends Controller
View::share('title', trans('firefly.import_data')); View::share('title', trans('firefly.import_data'));
} }
/**
* This is the last step before the import starts.
*
* @param ImportJob $job
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws FireflyException
*/
public function complete(ImportJob $job)
{
Log::debug('Now in complete()', ['job' => $job->key]);
if (!$this->jobInCorrectStep($job, 'complete')) {
return $this->redirectToCorrectStep($job);
}
$importer = $this->makeImporter($job);
$subTitle = trans('firefy.import_complete');
$subTitleIcon = 'fa-star';
return view('import.complete', compact('job', 'subTitle', 'subTitleIcon'));
}
/** /**
* This is step 3. * This is step 3.
* This is the first step in configuring the job. It can only be executed * This is the first step in configuring the job. It can only be executed
@ -45,7 +66,10 @@ class ImportController extends Controller
*/ */
public function configure(ImportJob $job) public function configure(ImportJob $job)
{ {
Log::debug('Now at start of configure()');
if (!$this->jobInCorrectStep($job, 'configure')) { if (!$this->jobInCorrectStep($job, 'configure')) {
Log::debug('Job is not in correct state for configure()', ['status' => $job->status]);
return $this->redirectToCorrectStep($job); return $this->redirectToCorrectStep($job);
} }
@ -59,6 +83,35 @@ class ImportController extends Controller
return view('import.' . $job->file_type . '.configure', compact('data', 'job', 'subTitle', 'subTitleIcon')); return view('import.' . $job->file_type . '.configure', compact('data', 'job', 'subTitle', 'subTitleIcon'));
}
/**
* Generate a JSON file of the job's config and send it to the user.
*
* @param ImportJob $job
*
* @return mixed
*/
public function download(ImportJob $job)
{
Log::debug('Now in download()', ['job' => $job->key]);
$config = $job->configuration;
$config['column-roles-complete'] = false;
$config['column-mapping-complete'] = false;
$result = json_encode($config, JSON_PRETTY_PRINT);
$name = sprintf('"%s"', addcslashes('import-configuration-' . date('Y-m-d') . '.json', '"\\'));
return response($result, 200)
->header('Content-disposition', 'attachment; filename=' . $name)
->header('Content-Type', 'application/json')
->header('Content-Description', 'File Transfer')
->header('Connection', 'Keep-Alive')
->header('Expires', '0')
->header('Cache-Control', 'must-revalidate, post-check=0, pre-check=0')
->header('Pragma', 'public')
->header('Content-Length', strlen($result));
} }
/** /**
@ -68,6 +121,7 @@ class ImportController extends Controller
*/ */
public function index() public function index()
{ {
Log::debug('Now at index');
$subTitle = trans('firefly.import_data_index'); $subTitle = trans('firefly.import_data_index');
$subTitleIcon = 'fa-home'; $subTitleIcon = 'fa-home';
$importFileTypes = []; $importFileTypes = [];
@ -91,9 +145,11 @@ class ImportController extends Controller
*/ */
public function postConfigure(Request $request, ImportJob $job) public function postConfigure(Request $request, ImportJob $job)
{ {
Log::debug('Now in postConfigure()', ['job' => $job->key]);
if (!$this->jobInCorrectStep($job, 'process')) { if (!$this->jobInCorrectStep($job, 'process')) {
return $this->redirectToCorrectStep($job); return $this->redirectToCorrectStep($job);
} }
Log::debug('Continue postConfigure()', ['job' => $job->key]);
// actual code // actual code
$importer = $this->makeImporter($job); $importer = $this->makeImporter($job);
@ -122,6 +178,7 @@ class ImportController extends Controller
*/ */
public function postSettings(Request $request, ImportJob $job) public function postSettings(Request $request, ImportJob $job)
{ {
Log::debug('Now in postSettings()', ['job' => $job->key]);
if (!$this->jobInCorrectStep($job, 'store-settings')) { if (!$this->jobInCorrectStep($job, 'store-settings')) {
return $this->redirectToCorrectStep($job); return $this->redirectToCorrectStep($job);
} }
@ -143,29 +200,32 @@ class ImportController extends Controller
*/ */
public function settings(ImportJob $job) public function settings(ImportJob $job)
{ {
Log::debug('Now in settings()', ['job' => $job->key]);
if (!$this->jobInCorrectStep($job, 'settings')) { if (!$this->jobInCorrectStep($job, 'settings')) {
Log::debug('Job should not be in settings()');
return $this->redirectToCorrectStep($job); return $this->redirectToCorrectStep($job);
} }
Log::debug('Continue in settings()');
$importer = $this->makeImporter($job); $importer = $this->makeImporter($job);
$subTitle = trans('firefy.settings_for_import'); $subTitle = trans('firefy.settings_for_import');
$subTitleIcon = 'fa-wrench'; $subTitleIcon = 'fa-wrench';
// now show settings screen to user. // now show settings screen to user.
if ($importer->requireUserSettings()) { if ($importer->requireUserSettings()) {
Log::debug('Job requires user config.');
$data = $importer->getDataForSettings(); $data = $importer->getDataForSettings();
$view = $importer->getViewForSettings(); $view = $importer->getViewForSettings();
return view($view, compact('data', 'job', 'subTitle', 'subTitleIcon')); return view($view, compact('data', 'job', 'subTitle', 'subTitleIcon'));
} }
Log::debug('Job does NOT require user config.');
$job->status = 'settings_complete';
$job->save();
// if no more settings, save job and continue to process thing. // if no more settings, save job and continue to process thing.
return redirect(route('import.complete', [$job->key]));
echo 'now in settings (done)';
exit;
// actual code
// ask the importer for the requested action. // ask the importer for the requested action.
// for example pick columns or map data. // for example pick columns or map data.
@ -182,6 +242,7 @@ class ImportController extends Controller
*/ */
public function upload(ImportUploadRequest $request, ImportJobRepositoryInterface $repository) public function upload(ImportUploadRequest $request, ImportJobRepositoryInterface $repository)
{ {
Log::debug('Now in upload()');
// create import job: // create import job:
$type = $request->get('import_file_type'); $type = $request->get('import_file_type');
$job = $repository->create($type); $job = $repository->create($type);
@ -232,6 +293,7 @@ class ImportController extends Controller
*/ */
private function jobInCorrectStep(ImportJob $job, string $method): bool private function jobInCorrectStep(ImportJob $job, string $method): bool
{ {
Log::debug('Now in jobInCorrectStep()', ['job' => $job->key, 'method' => $method]);
switch ($method) { switch ($method) {
case 'configure': case 'configure':
case 'process': case 'process':
@ -241,6 +303,9 @@ class ImportController extends Controller
case 'store-settings': case 'store-settings':
return $job->status === 'import_configuration_saved'; return $job->status === 'import_configuration_saved';
break; break;
case 'complete':
return $job->status === 'settings_complete';
break;
} }
return false; return false;
@ -272,13 +337,23 @@ class ImportController extends Controller
*/ */
private function redirectToCorrectStep(ImportJob $job) private function redirectToCorrectStep(ImportJob $job)
{ {
Log::debug('Now in redirectToCorrectStep()', ['job' => $job->key]);
switch ($job->status) { switch ($job->status) {
case 'import_status_never_started': case 'import_status_never_started':
Log::debug('Will redirect to configure()');
return redirect(route('import.configure', [$job->key])); return redirect(route('import.configure', [$job->key]));
break; break;
case 'import_configuration_saved': case 'import_configuration_saved':
Log::debug('Will redirect to settings()');
return redirect(route('import.settings', [$job->key])); return redirect(route('import.settings', [$job->key]));
break; break;
case 'settings_complete':
Log::debug('Will redirect to complete()');
return redirect(route('import.complete', [$job->key]));
break;
} }
throw new FireflyException('Cannot redirect for job state ' . $job->status); throw new FireflyException('Cannot redirect for job state ' . $job->status);

View File

@ -229,6 +229,8 @@ Route::group(
Route::post('/import/configure/{importJob}', ['uses' => 'ImportController@postConfigure', 'as' => 'import.process_configuration']); Route::post('/import/configure/{importJob}', ['uses' => 'ImportController@postConfigure', 'as' => 'import.process_configuration']);
Route::get('/import/settings/{importJob}', ['uses' => 'ImportController@settings', 'as' => 'import.settings']); Route::get('/import/settings/{importJob}', ['uses' => 'ImportController@settings', 'as' => 'import.settings']);
Route::post('/import/settings/{importJob}', ['uses' => 'ImportController@postSettings', 'as' => 'import.postSettings']); Route::post('/import/settings/{importJob}', ['uses' => 'ImportController@postSettings', 'as' => 'import.postSettings']);
Route::get('/import/complete/{importJob}', ['uses' => 'ImportController@complete', 'as' => 'import.complete']);
Route::get('/import/download/{importJob}', ['uses' => 'ImportController@download', 'as' => 'import.download']);
/** /**

View File

@ -158,14 +158,17 @@ class CsvImporter implements ImporterInterface
*/ */
public function requireUserSettings(): bool public function requireUserSettings(): bool
{ {
// does the job have both a 'map' array and a 'columns' array. Log::debug('doColumnMapping is ' . ($this->doColumnMapping() ? 'true' : 'false'));
$config = $this->job->configuration; Log::debug('doColumnRoles is ' . ($this->doColumnRoles() ? 'true' : 'false'));
if (isset($config['map']) && isset($config['columns'])) { if ($this->doColumnMapping() || $this->doColumnRoles()) {
return false; Log::debug('Return true');
}
return true; return true;
} }
Log::debug('Return false');
return false;
}
/** /**
* @param array $data * @param array $data
@ -217,9 +220,12 @@ class CsvImporter implements ImporterInterface
public function storeSettings(Request $request) public function storeSettings(Request $request)
{ {
$config = $this->job->configuration; $config = $this->job->configuration;
$count = $config['column-count'];
$all = $request->all(); $all = $request->all();
$roleSet = 0; if ($request->get('settings') == 'roles') {
$count = $config['column-count'];
$roleSet = 0; // how many roles have been defined
$mapSet = 0; // how many columns must be mapped
for ($i = 0; $i < $count; $i++) { for ($i = 0; $i < $count; $i++) {
$selectedRole = $all['role'][$i] ?? '_ignore'; $selectedRole = $all['role'][$i] ?? '_ignore';
$doMapping = isset($all['map'][$i]) && $all['map'][$i] == '1' ? true : false; $doMapping = isset($all['map'][$i]) && $all['map'][$i] == '1' ? true : false;
@ -229,6 +235,9 @@ class CsvImporter implements ImporterInterface
if ($selectedRole != '_ignore') { if ($selectedRole != '_ignore') {
$roleSet++; $roleSet++;
} }
if ($doMapping === true) {
$mapSet++;
}
$config['column-roles'][$i] = $selectedRole; $config['column-roles'][$i] = $selectedRole;
$config['column-do-mapping'][$i] = $doMapping; $config['column-do-mapping'][$i] = $doMapping;
} }
@ -237,6 +246,27 @@ class CsvImporter implements ImporterInterface
$this->job->configuration = $config; $this->job->configuration = $config;
$this->job->save(); $this->job->save();
} }
if ($mapSet === 0) {
// skip setting of map:
$config['column-mapping-complete'] = true;
}
}
if ($request->get('settings') == 'map') {
foreach ($all['mapping'] as $index => $data) {
$config['column-mapping-config'][$index] = [];
foreach ($data as $value => $mapId) {
$mapId = intval($mapId);
if ($mapId !== 0) {
$config['column-mapping-config'][$index][$value] = intval($mapId);
}
}
}
// set thing to be completed.
$config['column-mapping-complete'] = true;
$this->job->configuration = $config;
$this->job->save();
}
} }
/** /**
@ -262,6 +292,7 @@ class CsvImporter implements ImporterInterface
{ {
$config = $this->job->configuration; $config = $this->job->configuration;
$data = []; $data = [];
$indexes = [];
foreach ($config['column-do-mapping'] as $index => $mustBeMapped) { foreach ($config['column-do-mapping'] as $index => $mustBeMapped) {
if ($mustBeMapped) { if ($mustBeMapped) {
@ -271,9 +302,11 @@ class CsvImporter implements ImporterInterface
$mapperName = '\FireflyIII\Import\Mapper\\' . config('csv.import_roles.' . $column . '.mapper'); $mapperName = '\FireflyIII\Import\Mapper\\' . config('csv.import_roles.' . $column . '.mapper');
/** @var MapperInterface $mapper */ /** @var MapperInterface $mapper */
$mapper = new $mapperName; $mapper = new $mapperName;
$indexes[] = $index;
$data[$index] = [ $data[$index] = [
'name' => $column, 'name' => $column,
'mapper' => $mapperName, 'mapper' => $mapperName,
'index' => $index,
'options' => $mapper->getMap(), 'options' => $mapper->getMap(),
'values' => [], 'values' => [],
]; ];
@ -281,15 +314,25 @@ class CsvImporter implements ImporterInterface
} }
} }
// in order to actually map we also need all possible values from the CSV file.
$content = $this->job->uploadFileContents();
$reader = Reader::createFromString($content);
$results = $reader->fetch();
echo '<pre>'; foreach ($results as $row) {
var_dump($data); //do something here
var_dump($config); foreach ($indexes as $index) {
$value = $row[$index];
if (strlen($value) > 0) {
exit; $data[$index]['values'][] = $row[$index];
}
}
}
foreach ($data as $index => $entry) {
$data[$index]['values'] = array_unique($data[$index]['values']);
}
return $data;
} }
/** /**

View File

@ -0,0 +1,46 @@
<?php
/**
* Bills.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\Mapper;
use FireflyIII\Models\Bill;
use FireflyIII\Repositories\Bill\BillRepositoryInterface;
/**
* Class Bills
*
* @package FireflyIII\Import\Mapper
*/
class Bills implements MapperInterface
{
/**
* @return array
*/
public function getMap(): array
{
/** @var BillRepositoryInterface $repository */
$repository = app(BillRepositoryInterface::class);
$result = $repository->getBills();
$list = [];
/** @var Bill $bill */
foreach ($result as $bill) {
$list[$bill->id] = $bill->name . ' [' . $bill->match . ']';
}
asort($list);
$list = [0 => trans('csv.do_not_map')] + $list;
return $list;
}
}

View File

@ -0,0 +1,47 @@
<?php
/**
* Budgets.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\Mapper;
use FireflyIII\Models\Budget;
use FireflyIII\Repositories\Budget\BudgetRepositoryInterface;
/**
* Class Budgets
*
* @package FireflyIII\Import\Mapper
*/
class Budgets implements MapperInterface
{
/**
* @return array
*/
public function getMap(): array
{
/** @var BudgetRepositoryInterface $repository */
$repository = app(BudgetRepositoryInterface::class);
$result = $repository->getBudgets();
$list = [];
/** @var Budget $budget */
foreach ($result as $budget) {
$list[$budget->id] = $budget->name;
}
asort($list);
$list = [0 => trans('csv.do_not_map')] + $list;
return $list;
}
}

View File

@ -0,0 +1,47 @@
<?php
/**
* Categories.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\Mapper;
use FireflyIII\Models\Category;
use FireflyIII\Repositories\Category\CategoryRepositoryInterface;
/**
* Class Categories
*
* @package FireflyIII\Import\Mapper
*/
class Categories implements MapperInterface
{
/**
* @return array
*/
public function getMap(): array
{
/** @var CategoryRepositoryInterface $repository */
$repository = app(CategoryRepositoryInterface::class);
$result = $repository->getCategories();
$list = [];
/** @var Category $category */
foreach ($result as $category) {
$list[$category->id] = $category->name;
}
asort($list);
$list = [0 => trans('csv.do_not_map')] + $list;
return $list;
}
}

View File

@ -0,0 +1,46 @@
<?php
/**
* Tags.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\Mapper;
use FireflyIII\Models\Tag;
use FireflyIII\Repositories\Tag\TagRepositoryInterface;
/**
* Class Tags
*
* @package FireflyIII\Import\Mapper
*/
class Tags implements MapperInterface
{
/**
* @return array
*/
public function getMap(): array
{
/** @var TagRepositoryInterface $repository */
$repository = app(TagRepositoryInterface::class);
$result = $repository->get();
$list = [];
/** @var Tag $tag */
foreach ($result as $tag) {
$list[$tag->id] = $tag->tag;
}
asort($list);
$list = [0 => trans('csv.do_not_map')] + $list;
return $list;
}
}

View File

@ -18,8 +18,9 @@ return [
'import_roles' => [ 'import_roles' => [
'_ignore' => [ '_ignore' => [
'mappable' => false, 'mappable' => false,
'converter' => 'Ignore',
'field' => 'ignored', 'field' => 'ignored',
'converter' => 'Ignore',
], ],
'bill-id' => [ 'bill-id' => [
'mappable' => false, 'mappable' => false,
@ -29,33 +30,33 @@ return [
], ],
'bill-name' => [ 'bill-name' => [
'mappable' => true, 'mappable' => true,
'converter' => 'BillName',
'field' => 'bill', 'field' => 'bill',
'converter' => 'BillName',
'mapper' => 'Bills', 'mapper' => 'Bills',
], ],
'currency-id' => [ 'currency-id' => [
'mappable' => true, 'mappable' => true,
'converter' => 'CurrencyId',
'field' => 'currency', 'field' => 'currency',
'mapper' => 'TransactionCurrencies' 'converter' => 'CurrencyId',
'mapper' => 'TransactionCurrencies',
], ],
'currency-name' => [ 'currency-name' => [
'mappable' => true, 'mappable' => true,
'converter' => 'CurrencyName', 'converter' => 'CurrencyName',
'field' => 'currency', 'field' => 'currency',
'mapper' => 'TransactionCurrencies' 'mapper' => 'TransactionCurrencies',
], ],
'currency-code' => [ 'currency-code' => [
'mappable' => true, 'mappable' => true,
'converter' => 'CurrencyCode', 'converter' => 'CurrencyCode',
'field' => 'currency', 'field' => 'currency',
'mapper' => 'TransactionCurrencies' 'mapper' => 'TransactionCurrencies',
], ],
'currency-symbol' => [ 'currency-symbol' => [
'mappable' => true, 'mappable' => true,
'converter' => 'CurrencySymbol', 'converter' => 'CurrencySymbol',
'field' => 'currency', 'field' => 'currency',
'mapper' => 'TransactionCurrencies' 'mapper' => 'TransactionCurrencies',
], ],
'description' => [ 'description' => [
'mappable' => false, 'mappable' => false,
@ -120,27 +121,28 @@ return [
], ],
'account-id' => [ 'account-id' => [
'mappable' => true, 'mappable' => true,
'mapper' => 'AssetAccountId',
'field' => 'asset-account-id', 'field' => 'asset-account-id',
'converter' => 'AssetAccounts' 'converter' => 'AssetAccountId',
'mapper' => 'AssetAccounts',
], ],
'account-name' => [ 'account-name' => [
'mappable' => true, 'mappable' => true,
'mapper' => 'AssetAccountName',
'field' => 'asset-account-name', 'field' => 'asset-account-name',
'converter' => 'AssetAccounts' 'converter' => 'AssetAccountName',
'mapper' => 'AssetAccounts',
], ],
'account-iban' => [ 'account-iban' => [
'mappable' => true, 'mappable' => true,
'converter' => 'AssetAccountIban',
'field' => 'asset-account-iban', 'field' => 'asset-account-iban',
'mapper' => 'AssetAccounts' 'converter' => 'AssetAccountIban',
'mapper' => 'AssetAccounts',
], ],
'account-number' => [ 'account-number' => [
'mappable' => true, 'mappable' => true,
'converter' => 'AssetAccountNumber',
'field' => 'asset-account-number', 'field' => 'asset-account-number',
'mapper' => 'AssetAccounts' 'converter' => 'AssetAccountNumber',
'mapper' => 'AssetAccounts',
], ],
'opposing-id' => [ 'opposing-id' => [
'mappable' => true, 'mappable' => true,
@ -171,11 +173,6 @@ return [
'converter' => 'Amount', 'converter' => 'Amount',
'field' => 'amount', 'field' => 'amount',
], ],
// 'amount-comma-separated' => [
// 'mappable' => false,
// 'converter' => 'AmountComma',
// 'field' => 'amount',
// ],
'sepa-ct-id' => [ 'sepa-ct-id' => [
'mappable' => false, 'mappable' => false,
'converter' => 'Description', 'converter' => 'Description',
@ -194,12 +191,6 @@ return [
], ],
/* /*

View File

@ -0,0 +1,33 @@
{% extends "./layout/default.twig" %}
{% block breadcrumbs %}
{{ Breadcrumbs.renderIfExists }}
{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12">
<div class="box box-default">
<div class="box-header with-border">
<h3 class="box-title">{{ 'import_complete'|_ }}</h3>
</div>
<div class="box-body">
<p>
{{ 'import_complete_text'|_ }}
</p>
<div class="row">
<div class="col-lg-2">
<a href="{{ route('import.download', [job.key]) }}" class="btn btn-default"><i class="fa fa-fw fa-download"></i> {{ 'import_download_config' }}</a>
</div>
<div class="col-lg-2">
<a href="#" class="btn btn-default"><i class="fa fa-fw fa-gears"></i> {{ 'import_start_import' }}</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{% endblock %}
{% block styles %}
{% endblock %}

View File

@ -24,6 +24,44 @@
</div> </div>
<form action="{{ route('import.postSettings', job.key) }}" method="post"> <form action="{{ route('import.postSettings', job.key) }}" method="post">
<input type="hidden" name="_token" value="{{ csrf_token() }}"/> <input type="hidden" name="_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="settings" value="map"/>
{% for field in data %}
<div class="row">
<div class="col-lg-12">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">{{ field.name }}</h3>
</div>
<div class="box-body no-padding">
<table class="table table-hover">
<thead>
<tr>
<th style="width:50%;">{{ trans('csv.field_value') }}</th>
<th>{{ trans('csv.field_mapped_to') }}</th>
</tr>
</thead>
<tbody>
{% for option in field.values %}
<tr>
<td>
<code>{{ option }}</code>
</td>
<td>
{{ Form.select('mapping['~field.index~']['~option~']',
field.options,
job.configuration['column-mapping-config'][field.index][option], {class: 'form-control'}) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endfor %}
{# {#

View File

@ -54,10 +54,16 @@
{% endif %} {% endif %}
<td> <td>
{{ Form.select(('role['~loop.index0~']'), data.available_roles,data.set_roles[index],{class: 'form-control'}) }} {{ Form.select(('role['~loop.index0~']'),
data.available_roles,
job.configuration['column-roles'][loop.index0],
{class: 'form-control'}) }}
</td> </td>
<td> <td>
{{ Form.checkbox(('map['~loop.index0~']'),1,map[index]) }} {{ Form.checkbox(('map['~loop.index0~']'),1,
job.configuration['column-do-mapping'][loop.index0]
) }}
</td> </td>
</tr> </tr>

View File

@ -6,7 +6,6 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-lg-12 col-md-12 col-sm-12"> <div class="col-lg-12 col-md-12 col-sm-12">
<!-- ACCOUNTS -->
<div class="box box-default"> <div class="box box-default">
<div class="box-header with-border"> <div class="box-header with-border">
<h3 class="box-title">{{ 'import'|_ }}</h3> <h3 class="box-title">{{ 'import'|_ }}</h3>