diff --git a/library/Icinga/Application/Web.php b/library/Icinga/Application/Web.php
index fca67901f..cd7a31aa6 100644
--- a/library/Icinga/Application/Web.php
+++ b/library/Icinga/Application/Web.php
@@ -217,7 +217,7 @@ class Web extends ApplicationBootstrap
if ($authenticationManager->isAuthenticated() === true) {
$user = $authenticationManager->getUser();
- $preferences = new Preferences();
+ $preferences = new Preferences(array());
$user->setPreferences($preferences);
$this->request->setUser($user);
diff --git a/library/Icinga/User/Preferences.php b/library/Icinga/User/Preferences.php
index 7151e8cf4..030a4571e 100644
--- a/library/Icinga/User/Preferences.php
+++ b/library/Icinga/User/Preferences.php
@@ -28,9 +28,203 @@
namespace Icinga\User;
+use \SplObjectStorage;
+use \SplObserver;
+use \SplSubject;
+use Icinga\User\Preferences\ChangeSet;
+use Icinga\Exception\ProgrammingError;
+
/**
* Handling retrieve and persist of user preferences
*/
-class Preferences
+class Preferences implements SplSubject, \Countable
{
+ /**
+ * Container for all preferences
+ *
+ * @var array
+ */
+ private $preferences = array();
+
+ /**
+ * All observers for changes
+ *
+ * @var SplObserver[]
+ */
+ private $observer = array();
+
+ /**
+ * Current change set
+ *
+ * @var ChangeSet
+ */
+ private $changeSet;
+
+ /**
+ * Flag how commits are handled
+ *
+ * @var bool
+ */
+ private $autoCommit = true;
+
+ /**
+ * Create a new instance
+ * @param array $initialPreferences
+ */
+ public function __construct(array $initialPreferences)
+ {
+ $this->preferences = $initialPreferences;
+ $this->changeSet = new ChangeSet();
+ }
+
+ /**
+ * (PHP 5 >= 5.1.0)
+ * Attach an SplObserver
+ * @link http://php.net/manual/en/splsubject.attach.php
+ * @param SplObserver $observer
+ * The SplObserver to attach.
+ *
+ * @return void
+ */
+ public function attach(SplObserver $observer)
+ {
+ $this->observer[] = $observer;
+ }
+
+ /**
+ * (PHP 5 >= 5.1.0)
+ * Detach an observer
+ * @link http://php.net/manual/en/splsubject.detach.php
+ * @param SplObserver $observer
+ * The SplObserver to detach.
+ *
+ * @return void
+ */
+ public function detach(SplObserver $observer)
+ {
+ $key = array_search($observer, $this->observer, true);
+ if ($key !== false) {
+ unset($this->observer[$key]);
+ }
+ }
+
+ /**
+ * (PHP 5 >= 5.1.0)
+ * Notify an observer
+ * @link http://php.net/manual/en/splsubject.notify.php
+ * @return void
+ */
+ public function notify()
+ {
+ /** @var SplObserver $observer */
+ $observer = null;
+ foreach ($this->observer as $observer) {
+ $observer->update($this);
+ }
+ }
+
+ /**
+ * (PHP 5 >= 5.1.0)
+ * Count elements of an object
+ * @link http://php.net/manual/en/countable.count.php
+ * @return int The custom count as an integer.
+ *
+ *
+ * The return value is cast to an integer.
+ */
+ public function count()
+ {
+ return count($this->preferences);
+ }
+
+ /**
+ * Getter for change set
+ * @return ChangeSet
+ */
+ public function getChangeSet()
+ {
+ return $this->changeSet;
+ }
+
+ public function has($key)
+ {
+ return array_key_exists($key, $this->preferences);
+ }
+
+ public function set($key, $value)
+ {
+ if ($this->has($key)) {
+ $oldValue = $this->get($key);
+
+ // Do not notify useless writes
+ if ($oldValue !== $value) {
+ $this->changeSet->appendUpdate($key, $value);
+ }
+ } else {
+ $this->changeSet->appendCreate($key, $value);
+ }
+
+ $this->processCommit();
+ }
+
+ public function get($key, $default = null)
+ {
+ if ($this->has($key)) {
+ return $this->preferences[$key];
+ }
+
+ return $default;
+ }
+
+ public function remove($key)
+ {
+ if ($this->has($key)) {
+ $this->changeSet->appendDelete($key);
+ $this->processCommit();
+ return true;
+ }
+
+ return false;
+ }
+
+ public function startTransaction()
+ {
+ $this->autoCommit = false;
+ }
+
+ public function commit()
+ {
+ $changeSet = $this->changeSet;
+
+ if ($this->autoCommit === false) {
+ $this->autoCommit = true;
+ }
+
+ if ($changeSet->hasChanges() === true) {
+ foreach ($changeSet->getCreate() as $key => $value) {
+ $this->preferences[$key] = $value;
+ }
+
+ foreach ($changeSet->getUpdate() as $key => $value) {
+ $this->preferences[$key] = $value;
+ }
+
+ foreach ($changeSet->getDelete() as $key) {
+ unset($this->preferences[$key]);
+ }
+
+ $this->notify();
+
+ $this->changeSet->clear();
+ } else {
+ throw new ProgrammingError('Nothing to commit');
+ }
+ }
+
+ private function processCommit()
+ {
+ if ($this->autoCommit === true) {
+ $this->commit();
+ }
+ }
}
diff --git a/library/Icinga/User/Preferences/ChangeSet.php b/library/Icinga/User/Preferences/ChangeSet.php
new file mode 100644
index 000000000..c50942eb1
--- /dev/null
+++ b/library/Icinga/User/Preferences/ChangeSet.php
@@ -0,0 +1,129 @@
+
+ * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2
+ * @author Icinga Development Team
+ */
+// {{{ICINGA_LICENSE_HEADER}}}
+
+namespace Icinga\User\Preferences;
+
+/**
+ * Voyager object to transport changes between consumers
+ */
+class ChangeSet
+{
+ /**
+ * Items to update
+ *
+ * @var array
+ */
+ private $update = array();
+
+ /**
+ * Items to delete
+ *
+ * @var array
+ */
+ private $delete = array();
+
+ /**
+ * Items to create
+ *
+ * @var array
+ */
+ private $create = array();
+
+ /**
+ * Push an update to stack
+ * @param string $key
+ * @param mixed $value
+ */
+ public function appendUpdate($key, $value)
+ {
+ $this->update[$key] = $value;
+ }
+
+ /**
+ * Getter for pending updates
+ * @return array
+ */
+ public function getUpdate()
+ {
+ return $this->update;
+ }
+
+ /**
+ * Push delete operation to stack
+ *
+ * @param string $key
+ */
+ public function appendDelete($key)
+ {
+ $this->delete[] = $key;
+ }
+
+ /**
+ * @return array
+ */
+ public function getDelete()
+ {
+ return $this->delete;
+ }
+
+ /**
+ * Push create operation to stack
+ *
+ * @param string $key
+ * @param mixed $value
+ */
+ public function appendCreate($key, $value)
+ {
+ $this->create[$key] = $value;
+ }
+
+ public function getCreate()
+ {
+ return $this->create;
+ }
+
+ /**
+ * Clear all changes
+ */
+ public function clear()
+ {
+ $this->update = array();
+ $this->delete = array();
+ $this->create = array();
+ }
+
+ /**
+ * Test for registered changes
+ *
+ * @return bool
+ */
+ public function hasChanges()
+ {
+ return (count($this->update) > 0) || (count($this->delete) > 0) || (count($this->create));
+ }
+}
diff --git a/library/Icinga/User/Preferences/FlushObserverInterface.php b/library/Icinga/User/Preferences/FlushObserverInterface.php
new file mode 100644
index 000000000..5499e1656
--- /dev/null
+++ b/library/Icinga/User/Preferences/FlushObserverInterface.php
@@ -0,0 +1,45 @@
+
+ * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2
+ * @author Icinga Development Team
+ */
+// {{{ICINGA_LICENSE_HEADER}}}
+
+namespace Icinga\User\Preferences;
+
+use Icinga\User;
+use \SplObserver;
+
+/**
+ * Handle preference updates
+ */
+interface FlushObserverInterface extends SplObserver
+{
+ /**
+ * Setter for user
+ *
+ * @param User $user
+ */
+ public function setUser(User $user);
+}
diff --git a/library/Icinga/User/Preferences/IniStore.php b/library/Icinga/User/Preferences/IniStore.php
new file mode 100644
index 000000000..f455eb738
--- /dev/null
+++ b/library/Icinga/User/Preferences/IniStore.php
@@ -0,0 +1,202 @@
+
+ * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2
+ * @author Icinga Development Team
+ */
+// {{{ICINGA_LICENSE_HEADER}}}
+
+namespace Icinga\User\Preferences;
+
+use \SplObserver;
+use \SplSubject;
+use Icinga\User;
+use Icinga\User\Preferences;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\ProgrammingError;
+use \Zend_Config;
+use \Zend_Config_Writer_Ini;
+
+/**
+ * Handle preferences in ini files
+ *
+ * Load and write values from user preferences to ini files
+ */
+class IniStore implements LoadInterface, FlushObserverInterface
+{
+ /**
+ * Name of preferences section in ini file
+ */
+ const DEFAULT_SECTION = 'preferences';
+
+ /**
+ * Path to ini configuration
+ *
+ * @var string
+ */
+ private $configDir;
+
+ /**
+ * Specific user file for preferences
+ *
+ * @var string
+ */
+ private $preferencesFile;
+
+ /**
+ * Config container
+ *
+ * @var Zend_Config
+ */
+ private $iniConfig;
+
+ /**
+ * Ini writer
+ *
+ * @var Zend_Config_Writer_Ini
+ */
+ private $iniWriter;
+
+ /**
+ * Current user
+ *
+ * @var User
+ */
+ private $user;
+
+ /**
+ * Create a new object
+ *
+ * @param string $configDir
+ */
+ public function __construct($configDir)
+ {
+ $this->setConfigDir($configDir);
+ }
+
+ /**
+ * Setter for config directory
+ *
+ * @param string $configDir
+ * @throws \Icinga\Exception\ConfigurationError
+ */
+ public function setConfigDir($configDir)
+ {
+ if (!is_dir($configDir)) {
+ throw new ConfigurationError('Config dir dos not exist: '. $configDir);
+ }
+
+ $this->configDir = $configDir;
+ }
+
+ /**
+ * Setter for user
+ *
+ * @param User $user
+ */
+ public function setUser(User $user)
+ {
+ $this->user = $user;
+
+ $this->preferencesFile = sprintf(
+ '%s/%s.ini',
+ $this->configDir,
+ $this->user->getUsername()
+ );
+
+ if (file_exists($this->preferencesFile) === false) {
+ $this->createDefaultIniFile();
+ }
+
+ $this->iniConfig = new Zend_Config(
+ parse_ini_file($this->preferencesFile),
+ true
+ );
+
+ $this->iniWriter = new Zend_Config_Writer_Ini(
+ array(
+ 'config' => $this->iniConfig,
+ 'filename' => $this->preferencesFile
+ )
+ );
+ }
+
+ /**
+ * Helper to create blank ini file
+ */
+ private function createDefaultIniFile()
+ {
+ $writer = new Zend_Config_Writer_Ini(
+ array(
+ 'config' => new Zend_Config(
+ array(
+ self::DEFAULT_SECTION => array()
+ )
+ ),
+ 'filename' => $this->preferencesFile
+ )
+ );
+ $writer->write();
+ }
+
+ /**
+ * Load preferences from source
+ *
+ * @return array
+ */
+ public function load()
+ {
+ return $this->iniConfig->toArray();
+ }
+
+ /**
+ * Receive update from subject
+ *
+ * @link http://php.net/manual/en/splobserver.update.php
+ * @param SplSubject $subject
+ * @throws ProgrammingError
+ */
+ public function update(SplSubject $subject)
+ {
+ if (!$subject instanceof Preferences) {
+ throw new ProgrammingError('Not compatible with '. get_class($subject));
+ }
+
+ $changeSet = $subject->getChangeSet();
+
+ foreach ($changeSet->getCreate() as $key => $value) {
+ $this->iniConfig->{$key} = $value;
+ }
+
+ foreach ($changeSet->getUpdate() as $key => $value) {
+ $this->iniConfig->{$key} = $value;
+ }
+
+ foreach ($changeSet->getDelete() as $key) {
+ unset($this->iniConfig->{$key});
+ }
+
+ // Persist changes to disk
+ $this->iniWriter->write();
+ }
+}
diff --git a/library/Icinga/User/Preferences/LoadInterface.php b/library/Icinga/User/Preferences/LoadInterface.php
new file mode 100644
index 000000000..9066d164d
--- /dev/null
+++ b/library/Icinga/User/Preferences/LoadInterface.php
@@ -0,0 +1,42 @@
+
+ * @license http://www.gnu.org/licenses/gpl-2.0.txt GPL, version 2
+ * @author Icinga Development Team
+ */
+// {{{ICINGA_LICENSE_HEADER}}}
+
+namespace Icinga\User\Preferences;
+
+/**
+ * Describe how to load preferences from data sources
+ */
+interface LoadInterface
+{
+ /**
+ * Load preferences from source
+ *
+ * @return array
+ */
+ public function load();
+}
diff --git a/test/php/library/Icinga/User/Preferences/ChangeSetTest.php b/test/php/library/Icinga/User/Preferences/ChangeSetTest.php
new file mode 100644
index 000000000..02b7e584d
--- /dev/null
+++ b/test/php/library/Icinga/User/Preferences/ChangeSetTest.php
@@ -0,0 +1,93 @@
+appendCreate('test.key1', 'ok1');
+ $changeSet->appendCreate('test.key2', 'ok2');
+
+ $creates = $changeSet->getCreate();
+
+ $this->assertCount(2, $creates);
+ $this->assertTrue($changeSet->hasChanges());
+
+ $this->assertEquals(
+ array(
+ 'test.key1' => 'ok1',
+ 'test.key2' => 'ok2'
+ ),
+ $creates
+ );
+ }
+
+ public function testAppendUpdate()
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->appendUpdate('test.key3', 'ok1');
+ $changeSet->appendUpdate('test.key4', 'ok2');
+ $changeSet->appendUpdate('test.key5', 'ok3');
+
+ $updates = $changeSet->getUpdate();
+
+ $this->assertCount(3, $updates);
+ $this->assertTrue($changeSet->hasChanges());
+
+ $this->assertEquals(
+ array(
+ 'test.key3' => 'ok1',
+ 'test.key4' => 'ok2',
+ 'test.key5' => 'ok3'
+ ),
+ $updates
+ );
+ }
+
+ public function testAppendDelete()
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->appendDelete('test.key6');
+ $changeSet->appendDelete('test.key7');
+ $changeSet->appendDelete('test.key8');
+ $changeSet->appendDelete('test.key9');
+
+ $deletes = $changeSet->getDelete();
+
+ $this->assertCount(4, $deletes);
+ $this->assertTrue($changeSet->hasChanges());
+
+ $this->assertEquals(
+ array(
+ 'test.key6',
+ 'test.key7',
+ 'test.key8',
+ 'test.key9',
+ ),
+ $deletes
+ );
+ }
+
+ public function testObjectReset()
+ {
+ $changeSet = new ChangeSet();
+ $changeSet->appendCreate('test.key1', 'ok');
+ $changeSet->appendCreate('test.key2', 'ok');
+ $changeSet->appendUpdate('test.key3', 'ok');
+ $changeSet->appendUpdate('test.key4', 'ok');
+ $changeSet->appendUpdate('test.key5', 'ok');
+ $changeSet->appendDelete('test.key6');
+ $changeSet->appendDelete('test.key7');
+ $changeSet->appendDelete('test.key8');
+
+ $this->assertTrue($changeSet->hasChanges());
+ $changeSet->clear();
+ $this->assertFalse($changeSet->hasChanges());
+ }
+}
\ No newline at end of file
diff --git a/test/php/library/Icinga/User/Preferences/IniStoreTest.php b/test/php/library/Icinga/User/Preferences/IniStoreTest.php
new file mode 100644
index 000000000..99412531c
--- /dev/null
+++ b/test/php/library/Icinga/User/Preferences/IniStoreTest.php
@@ -0,0 +1,130 @@
+tempDir = tempnam($tempDir, 'ini-store-test');
+ if (file_exists($this->tempDir)) {
+ unlink($this->tempDir);
+ }
+ mkdir($this->tempDir);
+ }
+
+ protected function tearDown()
+ {
+ if (is_dir($this->tempDir)) {
+ system('rm -rf '. $this->tempDir);
+ }
+ }
+
+ private function createTestConfig()
+ {
+ $user = new User('jdoe');
+ $iniStore = new IniStore($this->tempDir);
+ $iniStore->setUser($user);
+
+ $preferences = new User\Preferences(array());
+ $preferences->attach($iniStore);
+
+ $preferences->startTransaction();
+ $preferences->set('test.key1', 'ok1');
+ $preferences->set('test.key2', 'ok2');
+ $preferences->set('test.key3', 'ok3');
+ $preferences->set('test.key4', 'ok4');
+ $preferences->commit();
+
+ return $preferences;
+ }
+
+ public function testWritePreferencesToFile()
+ {
+ $user = new User('jdoe');
+ $iniStore = new IniStore($this->tempDir);
+ $iniStore->setUser($user);
+
+ $preferences = new User\Preferences(array());
+ $preferences->attach($iniStore);
+
+ $preferences->startTransaction();
+ $preferences->set('test.key1', 'ok1');
+ $preferences->set('test.key2', 'ok2');
+ $preferences->set('test.key3', 'ok3');
+ $preferences->commit();
+
+ $preferences->remove('test.key2');
+
+ $file = $this->tempDir. '/jdoe.ini';
+ $data = (object)parse_ini_file($file);
+
+ $this->assertAttributeEquals('ok1', 'test.key1', $data, 'ini contains test.key1');
+ $this->assertAttributeEquals('ok3', 'test.key3', $data, 'ini contains test.key3');
+ $this->assertObjectNotHasAttribute('test.key2', $data, 'ini does not contain key test.key2');
+ }
+
+ public function testUpdatePreferencesToFile()
+ {
+ $this->createTestConfig();
+
+ $user = new User('jdoe');
+ $iniStore = new IniStore($this->tempDir);
+ $iniStore->setUser($user);
+
+ $preferences = new User\Preferences($iniStore->load());
+ $preferences->attach($iniStore);
+
+ $preferences->startTransaction();
+ $preferences->remove('test.key1');
+ $preferences->remove('test.key2');
+ $preferences->remove('test.key3');
+ $preferences->set('test.key4', 'ok9898');
+
+ $this->assertCount(4, $preferences, 'Before commit we need 4 items');
+
+ $preferences->commit();
+ $this->assertCount(1, $preferences, 'After we need 1 item');
+
+ $this->assertEquals('ok9898', $preferences->get('test.key4'), 'After commit preference key has changed');
+ }
+
+ public function testLoadInterface()
+ {
+ $this->createTestConfig();
+
+ $user = new User('jdoe');
+ $iniStore = new IniStore($this->tempDir);
+ $iniStore->setUser($user);
+
+ $preferences = new User\Preferences($iniStore->load());
+ $this->assertEquals('ok4', $preferences->get('test.key4'), 'Test for test.key4');
+ $this->assertCount(4, $preferences, 'Count 4 items');
+ }
+
+ /**
+ * @expectedException Icinga\Exception\ConfigurationError
+ * @expectedExceptionMessage Config dir dos not exist: /path/does/not/exist
+ */
+ public function testInitializationFailure()
+ {
+ $iniStore = new IniStore('/path/does/not/exist');
+ }
+}
\ No newline at end of file
diff --git a/test/php/library/Icinga/User/PreferencesTest.php b/test/php/library/Icinga/User/PreferencesTest.php
new file mode 100644
index 000000000..1455d521d
--- /dev/null
+++ b/test/php/library/Icinga/User/PreferencesTest.php
@@ -0,0 +1,93 @@
+ 'ok1',
+ 'test.key2' => 'ok2'
+ )
+ );
+
+ $this->assertCount(2, $preferences);
+
+ $this->assertEquals('ok2', $preferences->get('test.key2'));
+ }
+
+ public function testGetDefaultValues()
+ {
+ $preferences = new Preferences(array());
+ $preferences->set('test.key223', 'ok223');
+ $preferences->set('test.key333', 'ok333');
+
+ $this->assertCount(2, $preferences);
+
+ $this->assertEquals('ok223', $preferences->get('test.key223'));
+
+ $this->assertNull($preferences->get('does.not.exist'));
+
+ $this->assertEquals(123123, $preferences->get('does.not.exist', 123123));
+ }
+
+ public function testTransactionalCommit()
+ {
+ $preferences = new Preferences(array());
+ $preferences->startTransaction();
+
+ $preferences->set('test.key1', 'ok1');
+ $preferences->set('test.key2', 'ok2');
+
+ $this->assertCount(0, $preferences);
+ $this->assertCount(2, $preferences->getChangeSet()->getCreate());
+
+ $preferences->commit();
+
+ $this->assertCount(2, $preferences);
+
+ $preferences->startTransaction();
+
+ $preferences->remove('test.key2');
+ $this->assertEquals('ok2', $preferences->get('test.key2'));
+ $this->assertCount(1, $preferences->getChangeSet()->getDelete());
+
+ $preferences->commit();
+ $this->assertNull($preferences->get('test.key2'));
+ }
+
+ /**
+ * @expectedException Icinga\Exception\ProgrammingError
+ * @expectedExceptionMessage Nothing to commit
+ */
+ public function testNothingToCommitException()
+ {
+ $preferences = new Preferences(array());
+ $preferences->commit();
+ }
+
+ public function testSetCreateOrUpdate()
+ {
+ $preferences = new Preferences(array());
+
+ $preferences->startTransaction();
+ $preferences->set('test.key1', 'ok1');
+ $this->assertCount(1, $preferences->getChangeSet()->getCreate());
+ $this->assertCount(0, $preferences->getChangeSet()->getUpdate());
+ $preferences->commit();
+
+ $preferences->startTransaction();
+ $preferences->set('test.key1', 'ok2');
+ $this->assertCount(0, $preferences->getChangeSet()->getCreate());
+ $this->assertCount(1, $preferences->getChangeSet()->getUpdate());
+ $preferences->commit();
+ }
+}
\ No newline at end of file