aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--application/front/controller/admin/ExportController.php17
-rw-r--r--application/front/controller/admin/ImportController.php81
-rw-r--r--application/netscape/NetscapeBookmarkUtils.php15
-rw-r--r--doc/md/Translations.md2
-rw-r--r--index.php48
-rw-r--r--tests/front/controller/admin/ExportControllerTest.php6
-rw-r--r--tests/front/controller/admin/ImportControllerTest.php148
-rw-r--r--tests/netscape/BookmarkImportTest.php15
-rw-r--r--tpl/default/import.html2
-rw-r--r--tpl/default/tools.html2
-rw-r--r--tpl/vintage/import.html2
-rw-r--r--tpl/vintage/tools.html2
12 files changed, 256 insertions, 84 deletions
diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php
index 8e0e5a56..7afbfc23 100644
--- a/application/front/controller/admin/ExportController.php
+++ b/application/front/controller/admin/ExportController.php
@@ -33,6 +33,8 @@ class ExportController extends ShaarliAdminController
33 */ 33 */
34 public function export(Request $request, Response $response): Response 34 public function export(Request $request, Response $response): Response
35 { 35 {
36 $this->checkToken($request);
37
36 $selection = $request->getParam('selection'); 38 $selection = $request->getParam('selection');
37 39
38 if (empty($selection)) { 40 if (empty($selection)) {
@@ -74,19 +76,4 @@ class ExportController extends ShaarliAdminController
74 76
75 return $response->write($this->render('export.bookmarks')); 77 return $response->write($this->render('export.bookmarks'));
76 } 78 }
77
78 /**
79 * @param mixed[] $data Variables passed to the template engine
80 *
81 * @return mixed[] Template data after active plugins render_picwall hook execution.
82 */
83 protected function executeHooks(array $data): array
84 {
85 $this->container->pluginManager->executeHooks(
86 'render_tools',
87 $data
88 );
89
90 return $data;
91 }
92} 79}
diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php
new file mode 100644
index 00000000..8c5305b9
--- /dev/null
+++ b/application/front/controller/admin/ImportController.php
@@ -0,0 +1,81 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Psr\Http\Message\UploadedFileInterface;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class ImportController
13 *
14 * Slim controller used to display Shaarli data import page,
15 * and import bookmarks from Netscape Bookmarks file.
16 */
17class ImportController extends ShaarliAdminController
18{
19 /**
20 * GET /admin/import - Display import page
21 */
22 public function index(Request $request, Response $response): Response
23 {
24 $this->assignView(
25 'maxfilesize',
26 get_max_upload_size(
27 ini_get('post_max_size'),
28 ini_get('upload_max_filesize'),
29 false
30 )
31 );
32 $this->assignView(
33 'maxfilesizeHuman',
34 get_max_upload_size(
35 ini_get('post_max_size'),
36 ini_get('upload_max_filesize'),
37 true
38 )
39 );
40 $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
41
42 return $response->write($this->render('import'));
43 }
44
45 /**
46 * POST /admin/import - Process import file provided and create bookmarks
47 */
48 public function import(Request $request, Response $response): Response
49 {
50 $this->checkToken($request);
51
52 $file = ($request->getUploadedFiles() ?? [])['filetoupload'] ?? null;
53 if (!$file instanceof UploadedFileInterface) {
54 $this->saveErrorMessage(t('No import file provided.'));
55
56 return $this->redirect($response, '/admin/import');
57 }
58
59
60 // Import bookmarks from an uploaded file
61 if (0 === $file->getSize()) {
62 // The file is too big or some form field may be missing.
63 $msg = sprintf(
64 t(
65 'The file you are trying to upload is probably bigger than what this webserver can accept'
66 .' (%s). Please upload in smaller chunks.'
67 ),
68 get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
69 );
70 $this->saveErrorMessage($msg);
71
72 return $this->redirect($response, '/admin/import');
73 }
74
75 $status = $this->container->netscapeBookmarkUtils->import($request->getParams(), $file);
76
77 $this->saveSuccessMessage($status);
78
79 return $this->redirect($response, '/admin/import');
80 }
81}
diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php
index 8557cca2..b150f649 100644
--- a/application/netscape/NetscapeBookmarkUtils.php
+++ b/application/netscape/NetscapeBookmarkUtils.php
@@ -6,6 +6,7 @@ use DateTime;
6use DateTimeZone; 6use DateTimeZone;
7use Exception; 7use Exception;
8use Katzgrau\KLogger\Logger; 8use Katzgrau\KLogger\Logger;
9use Psr\Http\Message\UploadedFileInterface;
9use Psr\Log\LogLevel; 10use Psr\Log\LogLevel;
10use Shaarli\Bookmark\Bookmark; 11use Shaarli\Bookmark\Bookmark;
11use Shaarli\Bookmark\BookmarkServiceInterface; 12use Shaarli\Bookmark\BookmarkServiceInterface;
@@ -79,20 +80,20 @@ class NetscapeBookmarkUtils
79 /** 80 /**
80 * Imports Web bookmarks from an uploaded Netscape bookmark dump 81 * Imports Web bookmarks from an uploaded Netscape bookmark dump
81 * 82 *
82 * @param array $post Server $_POST parameters 83 * @param array $post Server $_POST parameters
83 * @param array $files Server $_FILES parameters 84 * @param UploadedFileInterface $file File in PSR-7 object format
84 * 85 *
85 * @return string Summary of the bookmark import status 86 * @return string Summary of the bookmark import status
86 */ 87 */
87 public function import($post, $files) 88 public function import($post, UploadedFileInterface $file)
88 { 89 {
89 $start = time(); 90 $start = time();
90 $filename = $files['filetoupload']['name']; 91 $filename = $file->getClientFilename();
91 $filesize = $files['filetoupload']['size']; 92 $filesize = $file->getSize();
92 $data = file_get_contents($files['filetoupload']['tmp_name']); 93 $data = (string) $file->getStream();
93 94
94 if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) { 95 if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) {
95 return self::importStatus($filename, $filesize); 96 return $this->importStatus($filename, $filesize);
96 } 97 }
97 98
98 // Overwrite existing bookmarks? 99 // Overwrite existing bookmarks?
diff --git a/doc/md/Translations.md b/doc/md/Translations.md
index af2c3daa..df86f4d4 100644
--- a/doc/md/Translations.md
+++ b/doc/md/Translations.md
@@ -40,7 +40,7 @@ http://<replace_domain>/admin/tools
40http://<replace_domain>/daily 40http://<replace_domain>/daily
41http://<replace_domain>/?post 41http://<replace_domain>/?post
42http://<replace_domain>/admin/export 42http://<replace_domain>/admin/export
43http://<replace_domain>/?do=import 43http://<replace_domain>/admin/import
44http://<replace_domain>/login 44http://<replace_domain>/login
45http://<replace_domain>/picture-wall 45http://<replace_domain>/picture-wall
46http://<replace_domain>/?do=pluginadmin 46http://<replace_domain>/?do=pluginadmin
diff --git a/index.php b/index.php
index 030fdfa3..47fef3ed 100644
--- a/index.php
+++ b/index.php
@@ -578,51 +578,7 @@ function renderPage($conf, $pluginManager, $bookmarkService, $history, $sessionM
578 } 578 }
579 579
580 if ($targetPage == Router::$PAGE_IMPORT) { 580 if ($targetPage == Router::$PAGE_IMPORT) {
581 // Upload a Netscape bookmark dump to import its contents 581 header('Location: ./admin/import');
582
583 if (! isset($_POST['token']) || ! isset($_FILES['filetoupload'])) {
584 // Show import dialog
585 $PAGE->assign(
586 'maxfilesize',
587 get_max_upload_size(
588 ini_get('post_max_size'),
589 ini_get('upload_max_filesize'),
590 false
591 )
592 );
593 $PAGE->assign(
594 'maxfilesizeHuman',
595 get_max_upload_size(
596 ini_get('post_max_size'),
597 ini_get('upload_max_filesize'),
598 true
599 )
600 );
601 $PAGE->assign('pagetitle', t('Import') .' - '. $conf->get('general.title', 'Shaarli'));
602 $PAGE->renderPage('import');
603 exit;
604 }
605
606 // Import bookmarks from an uploaded file
607 if (isset($_FILES['filetoupload']['size']) && $_FILES['filetoupload']['size'] == 0) {
608 // The file is too big or some form field may be missing.
609 $msg = sprintf(
610 t(
611 'The file you are trying to upload is probably bigger than what this webserver can accept'
612 .' (%s). Please upload in smaller chunks.'
613 ),
614 get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
615 );
616 echo '<script>alert("'. $msg .'");document.location=\'./?do='.Router::$PAGE_IMPORT .'\';</script>';
617 exit;
618 }
619 if (! $sessionManager->checkToken($_POST['token'])) {
620 die('Wrong token.');
621 }
622 $netscapeBookmarkUtils = new NetscapeBookmarkUtils($bookmarkService, $conf, $history);
623 $status = $netscapeBookmarkUtils->import($_POST, $_FILES);
624 echo '<script>alert("'.$status.'");document.location=\'./?do='
625 .Router::$PAGE_IMPORT .'\';</script>';
626 exit; 582 exit;
627 } 583 }
628 584
@@ -1064,6 +1020,8 @@ $app->group('', function () {
1064 $this->get('/admin/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ManageShaareController:pinBookmark'); 1020 $this->get('/admin/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ManageShaareController:pinBookmark');
1065 $this->get('/admin/export', '\Shaarli\Front\Controller\Admin\ExportController:index'); 1021 $this->get('/admin/export', '\Shaarli\Front\Controller\Admin\ExportController:index');
1066 $this->post('/admin/export', '\Shaarli\Front\Controller\Admin\ExportController:export'); 1022 $this->post('/admin/export', '\Shaarli\Front\Controller\Admin\ExportController:export');
1023 $this->get('/admin/import', '\Shaarli\Front\Controller\Admin\ImportController:index');
1024 $this->post('/admin/import', '\Shaarli\Front\Controller\Admin\ImportController:import');
1067 1025
1068 $this->get('/links-per-page', '\Shaarli\Front\Controller\Admin\SessionFilterController:linksPerPage'); 1026 $this->get('/links-per-page', '\Shaarli\Front\Controller\Admin\SessionFilterController:linksPerPage');
1069 $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility'); 1027 $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
diff --git a/tests/front/controller/admin/ExportControllerTest.php b/tests/front/controller/admin/ExportControllerTest.php
index e43a9626..50d9e378 100644
--- a/tests/front/controller/admin/ExportControllerTest.php
+++ b/tests/front/controller/admin/ExportControllerTest.php
@@ -2,14 +2,12 @@
2 2
3declare(strict_types=1); 3declare(strict_types=1);
4 4
5namespace front\controller\admin; 5namespace Shaarli\Front\Controller\Admin;
6 6
7use PHPUnit\Framework\TestCase; 7use PHPUnit\Framework\TestCase;
8use Shaarli\Bookmark\Bookmark; 8use Shaarli\Bookmark\Bookmark;
9use Shaarli\Formatter\BookmarkFormatter; 9use Shaarli\Formatter\BookmarkFormatter;
10use Shaarli\Formatter\BookmarkRawFormatter; 10use Shaarli\Formatter\BookmarkRawFormatter;
11use Shaarli\Front\Controller\Admin\ExportController;
12use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
13use Shaarli\Netscape\NetscapeBookmarkUtils; 11use Shaarli\Netscape\NetscapeBookmarkUtils;
14use Shaarli\Security\SessionManager; 12use Shaarli\Security\SessionManager;
15use Slim\Http\Request; 13use Slim\Http\Request;
@@ -117,7 +115,6 @@ class ExportControllerTest extends TestCase
117 $request = $this->createMock(Request::class); 115 $request = $this->createMock(Request::class);
118 $response = new Response(); 116 $response = new Response();
119 117
120 $this->container->sessionManager = $this->createMock(SessionManager::class);
121 $this->container->sessionManager 118 $this->container->sessionManager
122 ->expects(static::once()) 119 ->expects(static::once())
123 ->method('setSessionParameter') 120 ->method('setSessionParameter')
@@ -152,7 +149,6 @@ class ExportControllerTest extends TestCase
152 ->willThrowException(new \Exception($message = 'error message')); 149 ->willThrowException(new \Exception($message = 'error message'));
153 ; 150 ;
154 151
155 $this->container->sessionManager = $this->createMock(SessionManager::class);
156 $this->container->sessionManager 152 $this->container->sessionManager
157 ->expects(static::once()) 153 ->expects(static::once())
158 ->method('setSessionParameter') 154 ->method('setSessionParameter')
diff --git a/tests/front/controller/admin/ImportControllerTest.php b/tests/front/controller/admin/ImportControllerTest.php
new file mode 100644
index 00000000..eb31fad0
--- /dev/null
+++ b/tests/front/controller/admin/ImportControllerTest.php
@@ -0,0 +1,148 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use PHPUnit\Framework\TestCase;
8use Psr\Http\Message\UploadedFileInterface;
9use Shaarli\Netscape\NetscapeBookmarkUtils;
10use Shaarli\Security\SessionManager;
11use Slim\Http\Request;
12use Slim\Http\Response;
13use Slim\Http\UploadedFile;
14
15class ImportControllerTest extends TestCase
16{
17 use FrontAdminControllerMockHelper;
18
19 /** @var ImportController */
20 protected $controller;
21
22 public function setUp(): void
23 {
24 $this->createContainer();
25
26 $this->controller = new ImportController($this->container);
27 }
28
29 /**
30 * Test displaying import page
31 */
32 public function testIndex(): void
33 {
34 $assignedVariables = [];
35 $this->assignTemplateVars($assignedVariables);
36
37 $request = $this->createMock(Request::class);
38 $response = new Response();
39
40 $result = $this->controller->index($request, $response);
41
42 static::assertSame(200, $result->getStatusCode());
43 static::assertSame('import', (string) $result->getBody());
44
45 static::assertSame('Import - Shaarli', $assignedVariables['pagetitle']);
46 static::assertIsInt($assignedVariables['maxfilesize']);
47 static::assertRegExp('/\d+[KM]iB/', $assignedVariables['maxfilesizeHuman']);
48 }
49
50 /**
51 * Test importing a file with default and valid parameters
52 */
53 public function testImportDefault(): void
54 {
55 $parameters = [
56 'abc' => 'def',
57 'other' => 'param',
58 ];
59
60 $requestFile = new UploadedFile('file', 'name', 'type', 123);
61
62 $request = $this->createMock(Request::class);
63 $request->method('getParams')->willReturnCallback(function () use ($parameters) {
64 return $parameters;
65 });
66 $request->method('getUploadedFiles')->willReturn(['filetoupload' => $requestFile]);
67 $response = new Response();
68
69 $this->container->netscapeBookmarkUtils = $this->createMock(NetscapeBookmarkUtils::class);
70 $this->container->netscapeBookmarkUtils
71 ->expects(static::once())
72 ->method('import')
73 ->willReturnCallback(
74 function (
75 array $post,
76 UploadedFileInterface $file
77 ) use ($parameters, $requestFile): string {
78 static::assertSame($parameters, $post);
79 static::assertSame($requestFile, $file);
80
81 return 'status';
82 }
83 )
84 ;
85
86 $this->container->sessionManager
87 ->expects(static::once())
88 ->method('setSessionParameter')
89 ->with(SessionManager::KEY_SUCCESS_MESSAGES, ['status'])
90 ;
91
92 $result = $this->controller->import($request, $response);
93
94 static::assertSame(302, $result->getStatusCode());
95 static::assertSame(['/subfolder/admin/import'], $result->getHeader('location'));
96 }
97
98 /**
99 * Test posting an import request - without import file
100 */
101 public function testImportFileMissing(): void
102 {
103 $request = $this->createMock(Request::class);
104 $response = new Response();
105
106 $this->container->sessionManager
107 ->expects(static::once())
108 ->method('setSessionParameter')
109 ->with(SessionManager::KEY_ERROR_MESSAGES, ['No import file provided.'])
110 ;
111
112 $result = $this->controller->import($request, $response);
113
114 static::assertSame(302, $result->getStatusCode());
115 static::assertSame(['/subfolder/admin/import'], $result->getHeader('location'));
116 }
117
118 /**
119 * Test posting an import request - with an empty file
120 */
121 public function testImportEmptyFile(): void
122 {
123 $requestFile = new UploadedFile('file', 'name', 'type', 0);
124
125 $request = $this->createMock(Request::class);
126 $request->method('getUploadedFiles')->willReturn(['filetoupload' => $requestFile]);
127 $response = new Response();
128
129 $this->container->netscapeBookmarkUtils = $this->createMock(NetscapeBookmarkUtils::class);
130 $this->container->netscapeBookmarkUtils->expects(static::never())->method('filterAndFormat');
131
132 $this->container->sessionManager
133 ->expects(static::once())
134 ->method('setSessionParameter')
135 ->willReturnCallback(function (string $key, array $value): SessionManager {
136 static::assertSame(SessionManager::KEY_ERROR_MESSAGES, $key);
137 static::assertStringStartsWith('The file you are trying to upload is probably bigger', $value[0]);
138
139 return $this->container->sessionManager;
140 })
141 ;
142
143 $result = $this->controller->import($request, $response);
144
145 static::assertSame(302, $result->getStatusCode());
146 static::assertSame(['/subfolder/admin/import'], $result->getHeader('location'));
147 }
148}
diff --git a/tests/netscape/BookmarkImportTest.php b/tests/netscape/BookmarkImportTest.php
index 20b1c6f4..f678e26b 100644
--- a/tests/netscape/BookmarkImportTest.php
+++ b/tests/netscape/BookmarkImportTest.php
@@ -4,27 +4,28 @@ namespace Shaarli\Netscape;
4 4
5use DateTime; 5use DateTime;
6use PHPUnit\Framework\TestCase; 6use PHPUnit\Framework\TestCase;
7use Psr\Http\Message\UploadedFileInterface;
7use Shaarli\Bookmark\Bookmark; 8use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\BookmarkFileService; 9use Shaarli\Bookmark\BookmarkFileService;
9use Shaarli\Bookmark\BookmarkFilter; 10use Shaarli\Bookmark\BookmarkFilter;
10use Shaarli\Config\ConfigManager; 11use Shaarli\Config\ConfigManager;
11use Shaarli\History; 12use Shaarli\History;
13use Slim\Http\UploadedFile;
12 14
13/** 15/**
14 * Utility function to load a file's metadata in a $_FILES-like array 16 * Utility function to load a file's metadata in a $_FILES-like array
15 * 17 *
16 * @param string $filename Basename of the file 18 * @param string $filename Basename of the file
17 * 19 *
18 * @return array A $_FILES-like array 20 * @return UploadedFileInterface Upload file in PSR-7 compatible object
19 */ 21 */
20function file2array($filename) 22function file2array($filename)
21{ 23{
22 return array( 24 return new UploadedFile(
23 'filetoupload' => array( 25 __DIR__ . '/input/' . $filename,
24 'name' => $filename, 26 $filename,
25 'tmp_name' => __DIR__ . '/input/' . $filename, 27 null,
26 'size' => filesize(__DIR__ . '/input/' . $filename) 28 filesize(__DIR__ . '/input/' . $filename)
27 )
28 ); 29 );
29} 30}
30 31
diff --git a/tpl/default/import.html b/tpl/default/import.html
index 97203d93..156de71f 100644
--- a/tpl/default/import.html
+++ b/tpl/default/import.html
@@ -6,7 +6,7 @@
6<body> 6<body>
7{include="page.header"} 7{include="page.header"}
8 8
9<form method="POST" action="{$base_path}/?do=import" enctype="multipart/form-data" name="uploadform" id="uploadform"> 9<form method="POST" action="{$base_path}/admin/import" enctype="multipart/form-data" name="uploadform" id="uploadform">
10 <div class="pure-g"> 10 <div class="pure-g">
11 <div class="pure-u-lg-1-4 pure-u-1-24"></div> 11 <div class="pure-u-lg-1-4 pure-u-1-24"></div>
12 <div class="pure-u-lg-1-2 pure-u-22-24 page-form page-form-complete"> 12 <div class="pure-u-lg-1-2 pure-u-22-24 page-form page-form-complete">
diff --git a/tpl/default/tools.html b/tpl/default/tools.html
index fa007460..045defc9 100644
--- a/tpl/default/tools.html
+++ b/tpl/default/tools.html
@@ -33,7 +33,7 @@
33 </a> 33 </a>
34 </div> 34 </div>
35 <div class="tools-item"> 35 <div class="tools-item">
36 <a href="{$base_path}/?do=import" 36 <a href="{$base_path}/admin/import"
37 title="{'Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, delicious...)'|t}"> 37 title="{'Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, delicious...)'|t}">
38 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Import links'|t}</span> 38 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Import links'|t}</span>
39 </a> 39 </a>
diff --git a/tpl/vintage/import.html b/tpl/vintage/import.html
index a2e37751..7d6eac76 100644
--- a/tpl/vintage/import.html
+++ b/tpl/vintage/import.html
@@ -6,7 +6,7 @@
6 {include="page.header"} 6 {include="page.header"}
7 <div id="uploaddiv"> 7 <div id="uploaddiv">
8 Import Netscape HTML bookmarks (as exported from Firefox/Chrome/Opera/Delicious/Diigo...) (Max: {$maxfilesize}). 8 Import Netscape HTML bookmarks (as exported from Firefox/Chrome/Opera/Delicious/Diigo...) (Max: {$maxfilesize}).
9 <form method="POST" action="{$base_path}/?do=import" enctype="multipart/form-data" 9 <form method="POST" action="{$base_path}/admin/import" enctype="multipart/form-data"
10 name="uploadform" id="uploadform"> 10 name="uploadform" id="uploadform">
11 <input type="hidden" name="token" value="{$token}"> 11 <input type="hidden" name="token" value="{$token}">
12 <input type="hidden" name="MAX_FILE_SIZE" value="{$maxfilesize}"> 12 <input type="hidden" name="MAX_FILE_SIZE" value="{$maxfilesize}">
diff --git a/tpl/vintage/tools.html b/tpl/vintage/tools.html
index 67ebd175..95f89d8c 100644
--- a/tpl/vintage/tools.html
+++ b/tpl/vintage/tools.html
@@ -13,7 +13,7 @@
13 <br><br>{/if} 13 <br><br>{/if}
14 <a href="{$base_path}/admin/tags"><b>Rename/delete tags</b><span>: Rename or delete a tag in all links</span></a> 14 <a href="{$base_path}/admin/tags"><b>Rename/delete tags</b><span>: Rename or delete a tag in all links</span></a>
15 <br><br> 15 <br><br>
16 <a href="{$base_path}/?do=import"><b>Import</b><span>: Import Netscape html bookmarks (as exported from Firefox, Chrome, Opera, delicious...)</span></a> 16 <a href="{$base_path}/admin/import"><b>Import</b><span>: Import Netscape html bookmarks (as exported from Firefox, Chrome, Opera, delicious...)</span></a>
17 <br><br> 17 <br><br>
18 <a href="{$base_path}/admin/export"><b>Export</b><span>: Export Netscape html bookmarks (which can be imported in Firefox, Chrome, Opera, delicious...)</span></a> 18 <a href="{$base_path}/admin/export"><b>Export</b><span>: Export Netscape html bookmarks (which can be imported in Firefox, Chrome, Opera, delicious...)</span></a>
19 <br><br> 19 <br><br>