]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
History mechanism
authorArthurHoaro <arthur@hoa.ro>
Mon, 16 Jan 2017 11:31:08 +0000 (12:31 +0100)
committerArthurHoaro <arthur@hoa.ro>
Tue, 21 Mar 2017 19:29:20 +0000 (20:29 +0100)
Use case: rest API service

  * saved by default in data/history
  * same format as datastore.php
  * traced events:
     * save/edit/delete link
     * change settings or plugins settings
     * rename tag

application/History.php [new file with mode: 0644]
application/NetscapeBookmarkUtils.php
application/config/ConfigManager.php
index.php
tests/HistoryTest.php [new file with mode: 0644]
tests/NetscapeBookmarkUtils/BookmarkImportTest.php

diff --git a/application/History.php b/application/History.php
new file mode 100644 (file)
index 0000000..c06067d
--- /dev/null
@@ -0,0 +1,183 @@
+<?php
+
+/**
+ * Class History
+ *
+ * Handle the history file tracing events in Shaarli.
+ * The history is stored as JSON in a file set by 'resource.history' setting.
+ *
+ * Available data:
+ *   - event: event key
+ *   - datetime: event date, in ISO8601 format.
+ *   - id: event item identifier (currently only link IDs).
+ *
+ * Available event keys:
+ *   - CREATED: new link
+ *   - UPDATED: link updated
+ *   - DELETED: link deleted
+ *   - SETTINGS: the settings have been updated through the UI.
+ *
+ * Note: new events are put at the beginning of the file and history array.
+ */
+class History
+{
+    /**
+     * @var string Action key: a new link has been created.
+     */
+    const CREATED = 'CREATED';
+
+    /**
+     * @var string Action key: a link has been updated.
+     */
+    const UPDATED = 'UPDATED';
+
+    /**
+     * @var string Action key: a link has been deleted.
+     */
+    const DELETED = 'DELETED';
+
+    /**
+     * @var string Action key: settings have been updated.
+     */
+    const SETTINGS = 'SETTINGS';
+
+    /**
+     * @var string History file path.
+     */
+    protected $historyFilePath;
+
+    /**
+     * @var array History data.
+     */
+    protected $history;
+
+    /**
+     * @var int History retention time in seconds (1 month).
+     */
+    protected $retentionTime = 2678400;
+
+    /**
+     * History constructor.
+     *
+     * @param string $historyFilePath History file path.
+     * @param int    $retentionTime   History content rentention time in seconds.
+     *
+     * @throws Exception if something goes wrong.
+     */
+    public function __construct($historyFilePath, $retentionTime = null)
+    {
+        $this->historyFilePath = $historyFilePath;
+        if ($retentionTime !== null) {
+            $this->retentionTime = $retentionTime;
+        }
+        $this->check();
+        $this->read();
+    }
+
+    /**
+     * Add Event: new link.
+     *
+     * @param array $link Link data.
+     */
+    public function addLink($link)
+    {
+        $this->addEvent(self::CREATED, $link['id']);
+    }
+
+    /**
+     * Add Event: update existing link.
+     *
+     * @param array $link Link data.
+     */
+    public function updateLink($link)
+    {
+        $this->addEvent(self::UPDATED, $link['id']);
+    }
+
+    /**
+     * Add Event: delete existing link.
+     *
+     * @param array $link Link data.
+     */
+    public function deleteLink($link)
+    {
+        $this->addEvent(self::DELETED, $link['id']);
+    }
+
+    /**
+     * Add Event: settings updated.
+     */
+    public function updateSettings()
+    {
+        $this->addEvent(self::SETTINGS);
+    }
+
+    /**
+     * Save a new event and write it in the history file.
+     *
+     * @param string $status Event key, should be defined as constant.
+     * @param mixed  $id     Event item identifier (e.g. link ID).
+     */
+    protected function addEvent($status, $id = null)
+    {
+        $item = [
+            'event' => $status,
+            'datetime' => (new DateTime())->format(DateTime::ATOM),
+            'id' => $id !== null ? $id : '',
+        ];
+        $this->history = array_merge([$item], $this->history);
+        $this->write();
+    }
+
+    /**
+     * Check that the history file is writable.
+     * Create the file if it doesn't exist.
+     *
+     * @throws Exception if it isn't writable.
+     */
+    protected function check()
+    {
+        if (! is_file($this->historyFilePath)) {
+            FileUtils::writeFlatDB($this->historyFilePath, []);
+        }
+
+        if (! is_writable($this->historyFilePath)) {
+            throw new Exception('History file isn\'t readable or writable');
+        }
+    }
+
+    /**
+     * Read JSON history file.
+     */
+    protected function read()
+    {
+        $this->history = FileUtils::readFlatDB($this->historyFilePath, []);
+        if ($this->history === false) {
+            throw new Exception('Could not parse history file');
+        }
+    }
+
+    /**
+     * Write JSON history file and delete old entries.
+     */
+    protected function write()
+    {
+        $comparaison = new DateTime('-'. $this->retentionTime . ' seconds');
+        foreach ($this->history as $key => $value) {
+            if (DateTime::createFromFormat(DateTime::ATOM, $value['datetime']) < $comparaison) {
+                unset($this->history[$key]);
+            }
+        }
+        FileUtils::writeFlatDB($this->historyFilePath, array_values($this->history));
+    }
+
+    /**
+     * Get the History.
+     *
+     * @return array
+     */
+    public function getHistory()
+    {
+        return $this->history;
+    }
+}
index ab346f81c2ada769d204ea79d8f5ed21efb90213..bbfde1386075c42b2deb61a57d8aaed1d39f336b 100644 (file)
@@ -95,10 +95,11 @@ class NetscapeBookmarkUtils
      * @param array         $files     Server $_FILES parameters
      * @param LinkDB        $linkDb    Loaded LinkDB instance
      * @param ConfigManager $conf      instance
+     * @param History       $history   History instance
      *
      * @return string Summary of the bookmark import status
      */
-    public static function import($post, $files, $linkDb, $conf)
+    public static function import($post, $files, $linkDb, $conf, $history)
     {
         $filename = $files['filetoupload']['name'];
         $filesize = $files['filetoupload']['size'];
@@ -182,6 +183,7 @@ class NetscapeBookmarkUtils
                 $linkDb[$existingLink['id']] = $newLink;
                 $importCount++;
                 $overwriteCount++;
+                $history->updateLink($newLink);
                 continue;
             }
 
@@ -193,6 +195,7 @@ class NetscapeBookmarkUtils
             $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']);
             $linkDb[$newLink['id']] = $newLink;
             $importCount++;
+            $history->addLink($newLink);
         }
 
         $linkDb->save($conf->get('resource.page_cache'));
index 7bfbfc729fe4b67c153d8c5c367d1bd763d074f5..86a917fb12c2fa0c23c5a4a83c99e4ab4afb44bd 100644 (file)
@@ -301,6 +301,7 @@ class ConfigManager
         $this->setEmpty('resource.updates', 'data/updates.txt');
         $this->setEmpty('resource.log', 'data/log.txt');
         $this->setEmpty('resource.update_check', 'data/lastupdatecheck.txt');
+        $this->setEmpty('resource.history', 'data/history.php');
         $this->setEmpty('resource.raintpl_tpl', 'tpl/');
         $this->setEmpty('resource.theme', 'default');
         $this->setEmpty('resource.raintpl_tmp', 'tmp/');
index cc7f3ca3497feaf96288b61f718997eb0c3a09cf..7f357c69ed942a826c155b6fdfa19c80b87872ec 100644 (file)
--- a/index.php
+++ b/index.php
@@ -65,6 +65,7 @@ require_once 'application/CachedPage.php';
 require_once 'application/config/ConfigPlugin.php';
 require_once 'application/FeedBuilder.php';
 require_once 'application/FileUtils.php';
+require_once 'application/History.php';
 require_once 'application/HttpUtils.php';
 require_once 'application/Languages.php';
 require_once 'application/LinkDB.php';
@@ -754,6 +755,12 @@ function renderPage($conf, $pluginManager, $LINKSDB)
         die($e->getMessage());
     }
 
+    try {
+        $history = new History($conf->get('resource.history'));
+    } catch(Exception $e) {
+        die($e->getMessage());
+    }
+
     $PAGE = new PageBuilder($conf);
     $PAGE->assign('linkcount', count($LINKSDB));
     $PAGE->assign('privateLinkcount', count_private($LINKSDB));
@@ -1146,6 +1153,7 @@ function renderPage($conf, $pluginManager, $LINKSDB)
             $conf->set('api.secret', escape($_POST['apiSecret']));
             try {
                 $conf->write(isLoggedIn());
+                $history->updateSettings();
                 invalidateCaches($conf->get('resource.page_cache'));
             }
             catch(Exception $e) {
@@ -1177,6 +1185,7 @@ function renderPage($conf, $pluginManager, $LINKSDB)
             $PAGE->assign('hide_public_links', $conf->get('privacy.hide_public_links', false));
             $PAGE->assign('api_enabled', $conf->get('api.enabled', true));
             $PAGE->assign('api_secret', $conf->get('api.secret'));
+            $history->updateSettings();
             $PAGE->renderPage('configure');
             exit;
         }
@@ -1206,6 +1215,7 @@ function renderPage($conf, $pluginManager, $LINKSDB)
                 unset($tags[array_search($needle,$tags)]); // Remove tag.
                 $value['tags']=trim(implode(' ',$tags));
                 $LINKSDB[$key]=$value;
+                $history->updateLink($LINKSDB[$key]);
             }
             $LINKSDB->save($conf->get('resource.page_cache'));
             echo '<script>alert("Tag was removed from '.count($linksToAlter).' links.");document.location=\'?do=changetag\';</script>';
@@ -1223,6 +1233,7 @@ function renderPage($conf, $pluginManager, $LINKSDB)
                 $tags[array_search($needle, $tags)] = trim($_POST['totag']);
                 $value['tags'] = implode(' ', array_unique($tags));
                 $LINKSDB[$key] = $value;
+                $history->updateLink($LINKSDB[$key]);
             }
             $LINKSDB->save($conf->get('resource.page_cache')); // Save to disk.
             echo '<script>alert("Tag was renamed in '.count($linksToAlter).' links.");document.location=\'?searchtags='.urlencode(escape($_POST['totag'])).'\';</script>';
@@ -1257,11 +1268,13 @@ function renderPage($conf, $pluginManager, $LINKSDB)
             $created = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $linkdate);
             $updated = new DateTime();
             $shortUrl = $LINKSDB[$id]['shorturl'];
+            $new = false;
         } else {
             // New link
             $created = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $linkdate);
             $updated = null;
             $shortUrl = link_small_hash($created, $id);
+            $new = true;
         }
 
         // Remove multiple spaces.
@@ -1300,6 +1313,11 @@ function renderPage($conf, $pluginManager, $LINKSDB)
 
         $LINKSDB[$id] = $link;
         $LINKSDB->save($conf->get('resource.page_cache'));
+        if ($new) {
+            $history->addLink($link);
+        } else {
+            $history->updateLink($link);
+        }
 
         // If we are called from the bookmarklet, we must close the popup:
         if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) {
@@ -1346,6 +1364,7 @@ function renderPage($conf, $pluginManager, $LINKSDB)
         $pluginManager->executeHooks('delete_link', $link);
         unset($LINKSDB[$id]);
         $LINKSDB->save($conf->get('resource.page_cache')); // save to disk
+        $history->deleteLink($link);
 
         // If we are called from the bookmarklet, we must close the popup:
         if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) { echo '<script>self.close();</script>'; exit; }
@@ -1528,7 +1547,8 @@ function renderPage($conf, $pluginManager, $LINKSDB)
             $_POST,
             $_FILES,
             $LINKSDB,
-            $conf
+            $conf,
+            $history
         );
         echo '<script>alert("'.$status.'");document.location=\'?do='
              .Router::$PAGE_IMPORT .'\';</script>';
@@ -1557,6 +1577,7 @@ function renderPage($conf, $pluginManager, $LINKSDB)
 
     // Plugin administration form action
     if ($targetPage == Router::$PAGE_SAVE_PLUGINSADMIN) {
+        $history->updateSettings();
         try {
             if (isset($_POST['parameters_form'])) {
                 unset($_POST['parameters_form']);
diff --git a/tests/HistoryTest.php b/tests/HistoryTest.php
new file mode 100644 (file)
index 0000000..7932224
--- /dev/null
@@ -0,0 +1,195 @@
+<?php
+
+require_once 'application/History.php';
+
+
+class HistoryTest extends PHPUnit_Framework_TestCase
+{
+    /**
+     * @var string History file path
+     */
+    protected static $historyFilePath = 'sandbox/history.php';
+
+    /**
+     * Delete history file.
+     */
+    public function tearDown()
+    {
+        @unlink(self::$historyFilePath);
+    }
+
+    /**
+     * Test that the history file is created if it doesn't exist.
+     */
+    public function testConstructFileCreated()
+    {
+        new History(self::$historyFilePath);
+        $this->assertFileExists(self::$historyFilePath);
+    }
+
+    /**
+     * Not writable history file: raise an exception.
+     *
+     * @expectedException Exception
+     * @expectedExceptionMessage History file isn't readable or writable
+     */
+    public function testConstructNotWritable()
+    {
+        touch(self::$historyFilePath);
+        chmod(self::$historyFilePath, 0440);
+        new History(self::$historyFilePath);
+    }
+
+    /**
+     * Not parsable history file: raise an exception.
+     *
+     * @expectedException Exception
+     * @expectedExceptionMessageRegExp /Could not parse history file/
+     */
+    public function testConstructNotParsable()
+    {
+        file_put_contents(self::$historyFilePath, 'not parsable');
+        // gzinflate generates a warning
+        @new History(self::$historyFilePath);
+    }
+
+    /**
+     * Test add link event
+     */
+    public function testAddLink()
+    {
+        $history = new History(self::$historyFilePath);
+        $history->addLink(['id' => 0]);
+        $actual = $history->getHistory()[0];
+        $this->assertEquals(History::CREATED, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEquals(0, $actual['id']);
+
+        $history = new History(self::$historyFilePath);
+        $history->addLink(['id' => 1]);
+        $actual = $history->getHistory()[0];
+        $this->assertEquals(History::CREATED, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEquals(1, $actual['id']);
+
+        $history = new History(self::$historyFilePath);
+        $history->addLink(['id' => 'str']);
+        $actual = $history->getHistory()[0];
+        $this->assertEquals(History::CREATED, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEquals('str', $actual['id']);
+    }
+
+    /**
+     * Test updated link event
+     */
+    public function testUpdateLink()
+    {
+        $history = new History(self::$historyFilePath);
+        $history->updateLink(['id' => 1]);
+        $actual = $history->getHistory()[0];
+        $this->assertEquals(History::UPDATED, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEquals(1, $actual['id']);
+    }
+
+    /**
+     * Test delete link event
+     */
+    public function testDeleteLink()
+    {
+        $history = new History(self::$historyFilePath);
+        $history->deleteLink(['id' => 1]);
+        $actual = $history->getHistory()[0];
+        $this->assertEquals(History::DELETED, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEquals(1, $actual['id']);
+    }
+
+    /**
+     * Test updated settings event
+     */
+    public function testUpdateSettings()
+    {
+        $history = new History(self::$historyFilePath);
+        $history->updateSettings();
+        $actual = $history->getHistory()[0];
+        $this->assertEquals(History::SETTINGS, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEmpty($actual['id']);
+    }
+
+    /**
+     * Make sure that new items are stored at the beginning
+     */
+    public function testHistoryOrder()
+    {
+        $history = new History(self::$historyFilePath);
+        $history->updateLink(['id' => 1]);
+        $actual = $history->getHistory()[0];
+        $this->assertEquals(History::UPDATED, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEquals(1, $actual['id']);
+
+        $history->addLink(['id' => 1]);
+        $actual = $history->getHistory()[0];
+        $this->assertEquals(History::CREATED, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEquals(1, $actual['id']);
+    }
+
+    /**
+     * Re-read history from file after writing an event
+     */
+    public function testHistoryRead()
+    {
+        $history = new History(self::$historyFilePath);
+        $history->updateLink(['id' => 1]);
+        $history = new History(self::$historyFilePath);
+        $actual = $history->getHistory()[0];
+        $this->assertEquals(History::UPDATED, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEquals(1, $actual['id']);
+    }
+
+    /**
+     * Re-read history from file after writing an event and make sure that the order is correct
+     */
+    public function testHistoryOrderRead()
+    {
+        $history = new History(self::$historyFilePath);
+        $history->updateLink(['id' => 1]);
+        $history->addLink(['id' => 1]);
+
+        $history = new History(self::$historyFilePath);
+        $actual = $history->getHistory()[0];
+        $this->assertEquals(History::CREATED, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEquals(1, $actual['id']);
+
+        $actual = $history->getHistory()[1];
+        $this->assertEquals(History::UPDATED, $actual['event']);
+        $this->assertTrue(new DateTime('-2 seconds') < DateTime::createFromFormat(DateTime::ATOM, $actual['datetime']));
+        $this->assertEquals(1, $actual['id']);
+    }
+
+    /**
+     * Test retention time: delete old entries.
+     */
+    public function testHistoryRententionTime()
+    {
+        $history = new History(self::$historyFilePath, 5);
+        $history->updateLink(['id' => 1]);
+        $this->assertEquals(1, count($history->getHistory()));
+        $arr = $history->getHistory();
+        $arr[0]['datetime'] = (new DateTime('-1 hour'))->format(DateTime::ATOM);
+        FileUtils::writeFlatDB(self::$historyFilePath, $arr);
+
+        $history = new History(self::$historyFilePath, 60);
+        $this->assertEquals(1, count($history->getHistory()));
+        $this->assertEquals(1, $history->getHistory()[0]['id']);
+        $history->updateLink(['id' => 2]);
+        $this->assertEquals(1, count($history->getHistory()));
+        $this->assertEquals(2, $history->getHistory()[0]['id']);
+    }
+}
index 5925a8e156865be6c3343ca0e6b937a08cb97cf0..f838f2594793a40be4e0110a20c4160d41c6b363 100644 (file)
@@ -33,6 +33,11 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
      */
     protected static $testDatastore = 'sandbox/datastore.php';
 
+    /**
+     * @var string History file path
+     */
+    protected static $historyFilePath = 'sandbox/history.php';
+
     /**
      * @var LinkDB private LinkDB instance
      */
@@ -48,6 +53,11 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
      */
     protected $conf;
 
+    /**
+     * @var History instance.
+     */
+    protected $history;
+
     /**
      * @var string Save the current timezone.
      */
@@ -73,6 +83,15 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->linkDb = new LinkDB(self::$testDatastore, true, false);
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.page_cache', $this->pagecache);
+        $this->history = new History(self::$historyFilePath);
+    }
+
+    /**
+     * Delete history file.
+     */
+    public function tearDown()
+    {
+        @unlink(self::$historyFilePath);
     }
 
     public static function tearDownAfterClass()
@@ -89,7 +108,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File empty.htm (0 bytes) has an unknown file format.'
             .' Nothing was imported.',
-            NetscapeBookmarkUtils::import(NULL, $files, NULL, $this->conf)
+            NetscapeBookmarkUtils::import(null, $files, null, $this->conf, $this->history)
         );
         $this->assertEquals(0, count($this->linkDb));
     }
@@ -102,7 +121,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $files = file2array('no_doctype.htm');
         $this->assertEquals(
             'File no_doctype.htm (350 bytes) has an unknown file format. Nothing was imported.',
-            NetscapeBookmarkUtils::import(NULL, $files, NULL, $this->conf)
+            NetscapeBookmarkUtils::import(null, $files, null, $this->conf, $this->history)
         );
         $this->assertEquals(0, count($this->linkDb));
     }
@@ -116,7 +135,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File internet_explorer_encoding.htm (356 bytes) was successfully processed:'
             .' 1 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(1, count($this->linkDb));
         $this->assertEquals(0, count_private($this->linkDb));
@@ -145,7 +164,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_nested.htm (1337 bytes) was successfully processed:'
             .' 8 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(8, count($this->linkDb));
         $this->assertEquals(2, count_private($this->linkDb));
@@ -267,7 +286,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import([], $files, $this->linkDb, $this->conf, $this->history)
         );
 
         $this->assertEquals(2, count($this->linkDb));
@@ -312,7 +331,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(1, count_private($this->linkDb));
@@ -356,7 +375,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(0, count_private($this->linkDb));
@@ -380,7 +399,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(2, count_private($this->linkDb));
@@ -406,7 +425,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(2, count_private($this->linkDb));
@@ -426,7 +445,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 2 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(0, count_private($this->linkDb));
@@ -452,7 +471,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(0, count_private($this->linkDb));
@@ -473,7 +492,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 2 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(2, count_private($this->linkDb));
@@ -497,7 +516,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(0, count_private($this->linkDb));
@@ -507,7 +526,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 0 links imported, 0 links overwritten, 2 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(0, count_private($this->linkDb));
@@ -526,7 +545,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(0, count_private($this->linkDb));
@@ -553,7 +572,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File netscape_basic.htm (482 bytes) was successfully processed:'
             .' 2 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(2, count($this->linkDb));
         $this->assertEquals(0, count_private($this->linkDb));
@@ -578,7 +597,7 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
         $this->assertEquals(
             'File same_date.htm (453 bytes) was successfully processed:'
             .' 3 links imported, 0 links overwritten, 0 links skipped.',
-            NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf)
+            NetscapeBookmarkUtils::import(array(), $files, $this->linkDb, $this->conf, $this->history)
         );
         $this->assertEquals(3, count($this->linkDb));
         $this->assertEquals(0, count_private($this->linkDb));
@@ -595,4 +614,32 @@ class BookmarkImportTest extends PHPUnit_Framework_TestCase
             $this->linkDb[2]['id']
         );
     }
+
+    public function testImportCreateUpdateHistory()
+    {
+        $post = [
+            'privacy' => 'public',
+            'overwrite' => 'true',
+        ];
+        $files = file2array('netscape_basic.htm');
+        $nbLinks = 2;
+        NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history);
+        $history = $this->history->getHistory();
+        $this->assertEquals($nbLinks, count($history));
+        foreach ($history as $value) {
+            $this->assertEquals(History::CREATED, $value['event']);
+            $this->assertTrue(new DateTime('-5 seconds') < DateTime::createFromFormat(DateTime::ATOM, $value['datetime']));
+            $this->assertTrue(is_int($value['id']));
+        }
+
+        // re-import as private, enable overwriting
+        NetscapeBookmarkUtils::import($post, $files, $this->linkDb, $this->conf, $this->history);
+        $history = $this->history->getHistory();
+        $this->assertEquals($nbLinks * 2, count($history));
+        for ($i = 0 ; $i < $nbLinks ; $i++) {
+            $this->assertEquals(History::UPDATED, $history[$i]['event']);
+            $this->assertTrue(new DateTime('-5 seconds') < DateTime::createFromFormat(DateTime::ATOM, $history[$i]['datetime']));
+            $this->assertTrue(is_int($history[$i]['id']));
+        }
+    }
 }