diff --git a/dev/App/Admin.jsx b/dev/App/Admin.jsx index 283d99dbf..c767f7fd3 100644 --- a/dev/App/Admin.jsx +++ b/dev/App/Admin.jsx @@ -39,10 +39,11 @@ class AdminApp extends AbstractApp DomainStore.domains.loading(false); if (StorageResultType.Success === result && data && data.Result) { - DomainStore.domains(_.map(data.Result, (enabled, name) => { + DomainStore.domains(_.map(data.Result, ([enabled, alias], name) => { return { name: name, disabled: ko.observable(!enabled), + alias: alias, deleteAccess: ko.observable(false) }; })); diff --git a/dev/Remote/Admin/Ajax.js b/dev/Remote/Admin/Ajax.js index 5173daeb0..6cdb5fe1a 100644 --- a/dev/Remote/Admin/Ajax.js +++ b/dev/Remote/Admin/Ajax.js @@ -6,6 +6,8 @@ var _ = require('_'), + Utils = require('Common/Utils'), + AbstractAjaxRemote = require('Remote/AbstractAjax') ; @@ -54,10 +56,14 @@ /** * @param {?Function} fCallback + * @param {boolean=} bIncludeAliases = true */ - RemoteAdminStorage.prototype.domainList = function (fCallback) + RemoteAdminStorage.prototype.domainList = function (fCallback, bIncludeAliases) { - this.defaultRequest(fCallback, 'AdminDomainList'); + bIncludeAliases = Utils.isUnd(bIncludeAliases) ? true : bIncludeAliases; + this.defaultRequest(fCallback, 'AdminDomainList', { + 'IncludeAliases': bIncludeAliases ? '1' : '0' + }); }; /** @@ -208,6 +214,14 @@ }); }; + RemoteAdminStorage.prototype.createDomainAlias = function (fCallback, sName, sAlias) + { + this.defaultRequest(fCallback, 'AdminDomainAliasSave', { + 'Name': sName, + 'Alias': sAlias + }); + }; + RemoteAdminStorage.prototype.createOrUpdateDomain = function (fCallback, bCreate, sName, sIncHost, iIncPort, sIncSecure, bIncShortLogin, diff --git a/dev/Settings/Admin/Domains.js b/dev/Settings/Admin/Domains.js index 7fe4c6488..f48331fcd 100644 --- a/dev/Settings/Admin/Domains.js +++ b/dev/Settings/Admin/Domains.js @@ -35,6 +35,11 @@ require('Knoin/Knoin').showScreenPopup(require('View/Popup/Domain')); }; + DomainsAdminSettings.prototype.createDomainAlias = function () + { + require('Knoin/Knoin').showScreenPopup(require('View/Popup/DomainAlias')); + }; + DomainsAdminSettings.prototype.deleteDomain = function (oDomain) { this.domains.remove(oDomain); diff --git a/dev/Stores/Admin/Domain.js b/dev/Stores/Admin/Domain.js index 9ac93ef21..8ce1e2773 100644 --- a/dev/Stores/Admin/Domain.js +++ b/dev/Stores/Admin/Domain.js @@ -14,6 +14,10 @@ { this.domains = ko.observableArray([]); this.domains.loading = ko.observable(false).extend({'throttle': 100}); + + this.domainsWithoutAliases = this.domains.filter(function (oItem) { + return oItem && !oItem.alias; + }); } module.exports = new DomainAdminStore(); diff --git a/dev/Styles/AdminDomain.less b/dev/Styles/AdminDomain.less index 43b947cab..183ab45a0 100644 --- a/dev/Styles/AdminDomain.less +++ b/dev/Styles/AdminDomain.less @@ -1,3 +1,16 @@ +.b-domain-alias-content { + &.modal { + width: 330px; + } + .modal-header { + background-color: #fff; + } + + .error-desc { + color: red; + } +} + .b-domain-content { &.modal { @@ -40,7 +53,6 @@ .error-desc { color: red; - margin-left: 10px; } .testing-done { diff --git a/dev/Styles/AdminDomains.less b/dev/Styles/AdminDomains.less index de7320c7d..40d6e94de 100644 --- a/dev/Styles/AdminDomains.less +++ b/dev/Styles/AdminDomains.less @@ -24,8 +24,17 @@ box-sizing: border-box; } - &.disabled .domain-name { + .domain-alias { + display: inline-block; + box-sizing: border-box; color: #bbb; + padding-left: 5px; + } + + &.disabled { + .domain-name, .domain-alias { + color: #bbb; + } } .button-delete { diff --git a/dev/View/Popup/Domain.js b/dev/View/Popup/Domain.js index ec41232b8..d5383d59c 100644 --- a/dev/View/Popup/Domain.js +++ b/dev/View/Popup/Domain.js @@ -89,6 +89,7 @@ this.smtpAuth = ko.observable(true); this.smtpPhpMail = ko.observable(false); this.whiteList = ko.observable(''); + this.aliasName = ko.observable(''); this.enableSmartPorts = ko.observable(false); @@ -97,10 +98,30 @@ }, this); this.headerText = ko.computed(function () { - var sName = this.name(); - return this.edit() ? Translator.i18n('POPUPS_DOMAIN/TITLE_EDIT_DOMAIN', {'NAME': sName}) : - ('' === sName ? Translator.i18n('POPUPS_DOMAIN/TITLE_ADD_DOMAIN') : - Translator.i18n('POPUPS_DOMAIN/TITLE_ADD_DOMAIN_WITH_NAME', {'NAME': sName})); + + var + sName = this.name(), + sAliasName = this.aliasName(), + sResult = '' + ; + + if (this.edit()) + { + sResult = Translator.i18n('POPUPS_DOMAIN/TITLE_EDIT_DOMAIN', {'NAME': sName}); + if (sAliasName) + { + sResult += ' ← ' + sAliasName; + } + } + else + { + sResult = ('' === sName ? Translator.i18n('POPUPS_DOMAIN/TITLE_ADD_DOMAIN') : + Translator.i18n('POPUPS_DOMAIN/TITLE_ADD_DOMAIN_WITH_NAME', {'NAME': sName})) + ; + } + + return sResult; + }, this); this.domainDesc = ko.computed(function () { @@ -418,6 +439,7 @@ this.smtpAuth(!!oDomain.OutAuth); this.smtpPhpMail(!!oDomain.OutUsePhpMail); this.whiteList(Utils.trim(oDomain.WhiteList)); + this.aliasName(Utils.trim(oDomain.AliasName)); this.enableSmartPorts(true); } @@ -464,6 +486,7 @@ this.smtpPhpMail(false); this.whiteList(''); + this.aliasName(''); this.enableSmartPorts(true); }; diff --git a/dev/View/Popup/DomainAlias.js b/dev/View/Popup/DomainAlias.js new file mode 100644 index 000000000..083c61b70 --- /dev/null +++ b/dev/View/Popup/DomainAlias.js @@ -0,0 +1,117 @@ + +(function () { + + 'use strict'; + + var + _ = require('_'), + ko = require('ko'), + + Enums = require('Common/Enums'), + Globals = require('Common/Globals'), + Utils = require('Common/Utils'), + + Translator = require('Common/Translator'), + + DomainStore = require('Stores/Admin/Domain'), + + Remote = require('Remote/Admin/Ajax'), + + kn = require('Knoin/Knoin'), + AbstractView = require('Knoin/AbstractView') + ; + + /** + * @constructor + * @extends AbstractView + */ + function DomainAliasPopupView() + { + AbstractView.call(this, 'Popups', 'PopupsDomainAlias'); + + this.saving = ko.observable(false); + this.savingError = ko.observable(''); + + this.name = ko.observable(''); + this.name.focused = ko.observable(false); + + this.alias = ko.observable(''); + + this.domains = DomainStore.domainsWithoutAliases; + + this.domainsOptions = ko.computed(function () { + return _.map(this.domains(), function(item) { + return { + optValue: item.name, + optText: item.name + }; + }); + }, this); + + this.canBeSaved = ko.computed(function () { + return !this.saving() && '' !== this.name() && '' !== this.alias(); + }, this); + + this.createCommand = Utils.createCommand(this, function () { + this.saving(true); + Remote.createDomainAlias( + _.bind(this.onDomainAliasCreateOrSaveResponse, this), + this.name(), + this.alias() + ); + }, this.canBeSaved); + + kn.constructorEnd(this); + } + + kn.extendAsViewModel(['View/Popup/DomainAlias', 'PopupsDomainAliasViewModel'], DomainAliasPopupView); + _.extend(DomainAliasPopupView.prototype, AbstractView.prototype); + + DomainAliasPopupView.prototype.onDomainAliasCreateOrSaveResponse = function (sResult, oData) + { + this.saving(false); + if (Enums.StorageResultType.Success === sResult && oData) + { + if (oData.Result) + { + require('App/Admin').default.reloadDomainList(); + this.closeCommand(); + } + else if (Enums.Notification.DomainAlreadyExists === oData.ErrorCode) + { + this.savingError(Translator.i18n('ERRORS/DOMAIN_ALREADY_EXISTS')); + } + } + else + { + this.savingError(Translator.i18n('ERRORS/UNKNOWN_ERROR')); + } + }; + + DomainAliasPopupView.prototype.onShow = function () + { + this.clearForm(); + }; + + DomainAliasPopupView.prototype.onShowWithDelay = function () + { + if ('' === this.name() && !Globals.bMobile) + { + this.name.focused(true); + } + }; + + DomainAliasPopupView.prototype.clearForm = function () + { + this.saving(false); + this.savingError(''); + + this.name(''); + this.name.focused(false); + + this.alias(''); + }; + + module.exports = DomainAliasPopupView; + +}()); \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js index 4899b026d..eb69bf951 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -189,7 +189,7 @@ cfg.paths.js = { 'vendors/routes/hasher.min.js', 'vendors/routes/crossroads.min.js', 'vendors/knockout/knockout-3.4.0.js', - 'vendors/knockout-projections/knockout-projections-1.0.0.min.js', + 'vendors/knockout-projections/knockout-projections-1.1.0.min.js', 'vendors/knockout-sortable/knockout-sortable.min.js', 'vendors/ssm/ssm.min.js', 'vendors/jua/jua.min.js', diff --git a/rainloop/v/0.0.0/app/libraries/RainLoop/Actions.php b/rainloop/v/0.0.0/app/libraries/RainLoop/Actions.php index 13b5633c5..6b1f8175c 100644 --- a/rainloop/v/0.0.0/app/libraries/RainLoop/Actions.php +++ b/rainloop/v/0.0.0/app/libraries/RainLoop/Actions.php @@ -4082,25 +4082,14 @@ NewThemeLink IncludeCss LoadingDescriptionEsc TemplatesLink LangLink IncludeBack $iOffset = (int) $this->GetActionParam('Offset', 0); $iLimit = (int) $this->GetActionParam('Limit', 20); $sSearch = (string) $this->GetActionParam('Search', ''); + $bIncludeAliases = '1' === (string) $this->GetActionParam('IncludeAliases', '1'); $iOffset = 0; $sSearch = ''; $iLimit = $this->Config()->Get('labs', 'domain_list_limit', 99); - $sSearch = \trim($sSearch); - - if ($iOffset < 0) - { - $iOffset = 0; - } - - if ($iLimit < 20) - { - $iLimit = 20; - } - return $this->DefaultResponse(__FUNCTION__, - $this->DomainProvider()->GetList($iOffset, $iLimit, $sSearch)); + $this->DomainProvider()->GetList($iOffset, $iLimit, $sSearch, $bIncludeAliases)); } /** @@ -4140,6 +4129,19 @@ NewThemeLink IncludeCss LoadingDescriptionEsc TemplatesLink LangLink IncludeBack $oDomain instanceof \RainLoop\Model\Domain ? $this->DomainProvider()->Save($oDomain) : false); } + /** + * @return array + */ + public function DoAdminDomainAliasSave() + { + $this->IsAdminLoggined(); + + return $this->DefaultResponse(__FUNCTION__, $this->DomainProvider()->SaveAlias( + (string) $this->GetActionParam('Name', ''), + (string) $this->GetActionParam('Alias', '') + )); + } + /** * @return array */ diff --git a/rainloop/v/0.0.0/app/libraries/RainLoop/Model/Domain.php b/rainloop/v/0.0.0/app/libraries/RainLoop/Model/Domain.php index 5ba397ce9..6f219ad4b 100644 --- a/rainloop/v/0.0.0/app/libraries/RainLoop/Model/Domain.php +++ b/rainloop/v/0.0.0/app/libraries/RainLoop/Model/Domain.php @@ -93,6 +93,11 @@ class Domain */ private $sWhiteList; + /** + * @var string + */ + private $sAliasName; + /** * @param string $sName * @param string $sIncHost @@ -138,6 +143,7 @@ class Domain $this->bSieveAllowRaw = false; $this->sWhiteList = \trim($sWhiteList); + $this->sAliasName = ''; } /** @@ -510,6 +516,25 @@ class Domain return $this->sWhiteList; } + /** + * @return string + */ + public function AliasName() + { + return $this->sAliasName; + } + + /** + * @param string $sAliasName + * @return self + */ + public function SetAliasName($sAliasName) + { + $this->sAliasName = $sAliasName; + + return $this; + } + /** * @param string $sEmail * @param string $sLogin = '' @@ -558,7 +583,8 @@ class Domain 'OutShortLogin' => $this->OutShortLogin(), 'OutAuth' => $this->OutAuth(), 'OutUsePhpMail' => $this->OutUsePhpMail(), - 'WhiteList' => $this->WhiteList() + 'WhiteList' => $this->WhiteList(), + 'AliasName' => $this->AliasName() ); } } diff --git a/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain.php b/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain.php index 1c39461bd..a816059ce 100644 --- a/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain.php +++ b/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain.php @@ -44,12 +44,13 @@ class Domain extends \RainLoop\Providers\AbstractProvider * @param string $sName * @param bool $bFindWithWildCard = false * @param bool $bCheckDisabled = true + * @param bool $bCheckAliases = true * * @return \RainLoop\Model\Domain|null */ - public function Load($sName, $bFindWithWildCard = false, $bCheckDisabled = true) + public function Load($sName, $bFindWithWildCard = false, $bCheckDisabled = true, $bCheckAliases = true) { - $oDomain = $this->oDriver->Load($sName, $bFindWithWildCard, $bCheckDisabled); + $oDomain = $this->oDriver->Load($sName, $bFindWithWildCard, $bCheckDisabled, $bCheckAliases); if ($oDomain instanceof \RainLoop\Model\Domain) { $this->oPlugins->RunHook('filter.domain', array(&$oDomain)); @@ -68,6 +69,22 @@ class Domain extends \RainLoop\Providers\AbstractProvider return $this->bAdmin ? $this->oDriver->Save($oDomain) : false; } + /** + * @param string $sName + * @param string $sAlias + * + * @return bool + */ + public function SaveAlias($sName, $sAlias) + { + if ($this->Load($sName, false, false)) + { + throw new \RainLoop\Exceptions\ClientException(\RainLoop\Notifications::DomainAlreadyExists); + } + + return $this->bAdmin ? $this->oDriver->SaveAlias($sName, $sAlias) : false; + } + /** * @param string $sName * @@ -93,12 +110,25 @@ class Domain extends \RainLoop\Providers\AbstractProvider * @param int $iOffset = 0 * @param int $iLimit = 20 * @param string $sSearch = '' + * @param bool $bIncludeAliases = true * * @return array */ - public function GetList($iOffset = 0, $iLimit = 20, $sSearch = '') + public function GetList($iOffset = 0, $iLimit = 20, $sSearch = '', $bIncludeAliases = true) { - return $this->bAdmin ? $this->oDriver->GetList($iOffset, $iLimit, $sSearch) : array(); + $sSearch = \trim($sSearch); + + if ($iOffset < 0) + { + $iOffset = 0; + } + + if ($iLimit < 20) + { + $iLimit = 20; + } + + return $this->bAdmin ? $this->oDriver->GetList($iOffset, $iLimit, $sSearch, $bIncludeAliases) : array(); } /** @@ -184,6 +214,79 @@ class Domain extends \RainLoop\Providers\AbstractProvider return $oDomain; } + /** + * @param \RainLoop\Actions $oActions + * @param string $sNameForTest = '' + * + * @return \RainLoop\Model\Domain | null + */ + public function CreateNewAliasFromAction(\RainLoop\Actions $oActions, $sNameForTest = '') + { + $oDomain = null; + + if ($this->bAdmin) + { + $bCreate = '1' === (string) $oActions->GetActionParam('Create', '0'); + $sName = (string) $oActions->GetActionParam('Name', ''); + $sIncHost = (string) $oActions->GetActionParam('IncHost', ''); + $iIncPort = (int) $oActions->GetActionParam('IncPort', 143); + $iIncSecure = (int) $oActions->GetActionParam('IncSecure', \MailSo\Net\Enumerations\ConnectionSecurityType::NONE); + $bIncShortLogin = '1' === (string) $oActions->GetActionParam('IncShortLogin', '0'); + $bUseSieve = '1' === (string) $oActions->GetActionParam('UseSieve', '0'); + $bSieveAllowRaw = '1' === (string) $oActions->GetActionParam('SieveAllowRaw', '0'); + $sSieveHost = (string) $oActions->GetActionParam('SieveHost', ''); + $iSievePort = (int) $oActions->GetActionParam('SievePort', 4190); + $iSieveSecure = (int) $oActions->GetActionParam('SieveSecure', \MailSo\Net\Enumerations\ConnectionSecurityType::NONE); + $sOutHost = (string) $oActions->GetActionParam('OutHost', ''); + $iOutPort = (int) $oActions->GetActionParam('OutPort', 25); + $iOutSecure = (int) $oActions->GetActionParam('OutSecure', \MailSo\Net\Enumerations\ConnectionSecurityType::NONE); + $bOutShortLogin = '1' === (string) $oActions->GetActionParam('OutShortLogin', '0'); + $bOutAuth = '1' === (string) $oActions->GetActionParam('OutAuth', '1'); + $bOutUsePhpMail = '1' === (string) $oActions->GetActionParam('OutUsePhpMail', '0'); + $sWhiteList = (string) $oActions->GetActionParam('WhiteList', ''); + + if (0 < \strlen($sName) && 0 < strlen($sNameForTest) && false === \strpos($sName, '*')) + { + $sNameForTest = ''; + } + + if (0 < strlen($sName) || 0 < strlen($sNameForTest)) + { + $oDomain = 0 < strlen($sNameForTest) ? null : $this->Load($sName); + if ($oDomain instanceof \RainLoop\Model\Domain) + { + if ($bCreate) + { + throw new \RainLoop\Exceptions\ClientException(\RainLoop\Notifications::DomainAlreadyExists); + } + else + { + $oDomain->UpdateInstance( + $sIncHost, $iIncPort, $iIncSecure, $bIncShortLogin, + $bUseSieve, $sSieveHost, $iSievePort, $iSieveSecure, + $sOutHost, $iOutPort, $iOutSecure, $bOutShortLogin, $bOutAuth, $bOutUsePhpMail, + $sWhiteList); + } + } + else + { + $oDomain = \RainLoop\Model\Domain::NewInstance(0 < strlen($sNameForTest) ? $sNameForTest : $sName, + $sIncHost, $iIncPort, $iIncSecure, $bIncShortLogin, + $bUseSieve, $sSieveHost, $iSievePort, $iSieveSecure, + $sOutHost, $iOutPort, $iOutSecure, $bOutShortLogin, $bOutAuth, $bOutUsePhpMail, + $sWhiteList); + } + } + + if ($oDomain) + { + $oDomain->SetSieveAllowRaw($bSieveAllowRaw); + } + } + + return $oDomain; + } + /** * @return bool */ diff --git a/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain/DefaultDomain.php b/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain/DefaultDomain.php index 3ce150c5e..e30ba876c 100644 --- a/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain/DefaultDomain.php +++ b/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain/DefaultDomain.php @@ -55,44 +55,6 @@ class DefaultDomain implements \RainLoop\Providers\Domain\DomainAdminInterface return $bBack ? \str_replace('_wildcard_', '*', $sName) : \str_replace('*', '_wildcard_', $sName); } - /** - * @param string $sName - * @param bool $bDisable - * - * @return bool - */ - public function Disable($sName, $bDisable) - { - $sName = \MailSo\Base\Utils::IdnToAscii($sName, true); - - $sFile = ''; - if (\file_exists($this->sDomainPath.'/disabled')) - { - $sFile = @\file_get_contents($this->sDomainPath.'/disabled'); - } - - $aResult = array(); - $aNames = \explode(',', $sFile); - if ($bDisable) - { - \array_push($aNames, $sName); - $aResult = $aNames; - } - else - { - foreach ($aNames as $sItem) - { - if ($sName !== $sItem) - { - $aResult[] = $sItem; - } - } - } - - $aResult = \array_unique($aResult); - return false !== \file_put_contents($this->sDomainPath.'/disabled', \trim(\implode(',', $aResult), ', ')); - } - /** * @return string */ @@ -146,10 +108,11 @@ class DefaultDomain implements \RainLoop\Providers\Domain\DomainAdminInterface * @param string $sName * @param bool $bFindWithWildCard = false * @param bool $bCheckDisabled = true + * @param bool $bCheckAliases = true * * @return \RainLoop\Model\Domain|null */ - public function Load($sName, $bFindWithWildCard = false, $bCheckDisabled = true) + public function Load($sName, $bFindWithWildCard = false, $bCheckDisabled = true, $bCheckAliases = true) { $mResult = null; @@ -167,6 +130,17 @@ class DefaultDomain implements \RainLoop\Providers\Domain\DomainAdminInterface (!$bCheckDisabled || 0 === \strlen($sDisabled) || false === \strpos(','.$sDisabled.',', ','.\MailSo\Base\Utils::IdnToAscii($sName, true).','))) { $aDomain = \RainLoop\Utils::CustomParseIniFile($this->sDomainPath.'/'.$sRealFileName.'.ini'); +// if ($bCheckAliases && !empty($aDomain['alias'])) +// { +// $oDomain = $this->Load($aDomain['alias'], false, false, false); +// if ($oDomain && $oDomain instanceof \RainLoop\Model\Domain) +// { +// $oDomain->SetAliasName($sName); +// } +// +// return $oDomain; +// } + // fix misspellings (#119) if (\is_array($aDomain)) { @@ -194,6 +168,21 @@ class DefaultDomain implements \RainLoop\Providers\Domain\DomainAdminInterface $mResult = \RainLoop\Model\Domain::NewInstanceFromDomainConfigArray($sName, $aDomain); } + else if ($bCheckAliases && \file_exists($this->sDomainPath.'/'.$sRealFileName.'.alias') && + (!$bCheckDisabled || 0 === \strlen($sDisabled) || false === \strpos(','.$sDisabled.',', ','.\MailSo\Base\Utils::IdnToAscii($sName, true).','))) + { + $sAlias = \trim(\file_get_contents($this->sDomainPath.'/'.$sRealFileName.'.alias')); + if (!empty($sAlias)) + { + $oDomain = $this->Load($sAlias, false, false, false); + if ($oDomain && $oDomain instanceof \RainLoop\Model\Domain) + { + $oDomain->SetAliasName($sName); + } + + return $oDomain; + } + } else if ($bFindWithWildCard) { $sNames = $this->getWildcardDomainsLine(); @@ -231,6 +220,63 @@ class DefaultDomain implements \RainLoop\Providers\Domain\DomainAdminInterface return \is_int($mResult) && 0 < $mResult; } + /** + * @param string $sName + * @param string $sAlias + * + * @return bool + */ + public function SaveAlias($sName, $sAlias) + { + $sRealFileName = $this->codeFileName($sName); + + if ($this->oCacher) + { + $this->oCacher->Delete($this->wildcardDomainsCacheKey()); + } + + $mResult = \file_put_contents($this->sDomainPath.'/'.$sRealFileName.'.alias', $sAlias); + return \is_int($mResult) && 0 < $mResult; + } + + /** + * @param string $sName + * @param bool $bDisable + * + * @return bool + */ + public function Disable($sName, $bDisable) + { + $sName = \MailSo\Base\Utils::IdnToAscii($sName, true); + + $sFile = ''; + if (\file_exists($this->sDomainPath.'/disabled')) + { + $sFile = @\file_get_contents($this->sDomainPath.'/disabled'); + } + + $aResult = array(); + $aNames = \explode(',', $sFile); + if ($bDisable) + { + \array_push($aNames, $sName); + $aResult = $aNames; + } + else + { + foreach ($aNames as $sItem) + { + if ($sName !== $sItem) + { + $aResult[] = $sItem; + } + } + } + + $aResult = \array_unique($aResult); + return false !== \file_put_contents($this->sDomainPath.'/disabled', \trim(\implode(',', $aResult), ', ')); + } + /** * @param string $sName * @@ -241,9 +287,16 @@ class DefaultDomain implements \RainLoop\Providers\Domain\DomainAdminInterface $bResult = true; $sRealFileName = $this->codeFileName($sName); - if (0 < \strlen($sName) && \file_exists($this->sDomainPath.'/'.$sRealFileName.'.ini')) + if (0 < \strlen($sName)) { - $bResult = \unlink($this->sDomainPath.'/'.$sRealFileName.'.ini'); + if (\file_exists($this->sDomainPath.'/'.$sRealFileName.'.ini')) + { + $bResult = \unlink($this->sDomainPath.'/'.$sRealFileName.'.ini'); + } + else if (\file_exists($this->sDomainPath.'/'.$sRealFileName.'.alias')) + { + $bResult = \unlink($this->sDomainPath.'/'.$sRealFileName.'.alias'); + } } if ($bResult) @@ -260,36 +313,51 @@ class DefaultDomain implements \RainLoop\Providers\Domain\DomainAdminInterface } /** - * @param int $iOffset + * @param int $iOffset = 0 * @param int $iLimit = 20 + * @param int $sSearch = '' + * @param bool $bIncludeAliases = true * * @return array */ - public function GetList($iOffset, $iLimit = 20) + public function GetList($iOffset = 0, $iLimit = 20, $sSearch = '', $bIncludeAliases = true) { $aResult = array(); $aWildCards = array(); - $aList = \glob($this->sDomainPath.'/*.ini'); + $aAliases = array(); + + $aList = \glob($this->sDomainPath.'/*.{ini,alias}', GLOB_BRACE); foreach ($aList as $sFile) { - $sName = \substr(\basename($sFile), 0, -4); + $sName = \basename($sFile); + $bAlias = '.alias' === \substr($sName, -6); + + $sName = \preg_replace('/\.(ini|alias)$/', '', $sName); $sName = $this->codeFileName($sName, true); - if (false === \strpos($sName, '*')) + if ($bAlias) { - $aResult[] = $sName; + if ($bIncludeAliases) + { + $aAliases[] = $sName; + } + } + else if (false !== \strpos($sName, '*')) + { + $aWildCards[] = $sName; } else { - $aWildCards[] = $sName; + $aResult[] = $sName; } } \sort($aResult, SORT_STRING); + \sort($aAliases, SORT_STRING); \rsort($aWildCards, SORT_STRING); - $aResult = \array_merge($aResult, $aWildCards); + $aResult = \array_merge($aResult, $aAliases, $aWildCards); $iOffset = (0 > $iOffset) ? 0 : $iOffset; $iLimit = (0 > $iLimit) ? 0 : ((999 < $iLimit) ? 999 : $iLimit); @@ -314,7 +382,10 @@ class DefaultDomain implements \RainLoop\Providers\Domain\DomainAdminInterface $aReturn = array(); foreach ($aResult as $sName) { - $aReturn[$sName] = !\in_array($sName, $aDisabledNames); + $aReturn[$sName] = array( + !\in_array($sName, $aDisabledNames), + \in_array($sName, $aAliases) + ); } return $aReturn; @@ -322,11 +393,12 @@ class DefaultDomain implements \RainLoop\Providers\Domain\DomainAdminInterface /** * @param string $sSearch = '' + * @param bool $bIncludeAliases = true * * @return int */ - public function Count() + public function Count($sSearch = '', $bIncludeAliases = true) { - return \count($this->GetList(0, 999)); + return \count($this->GetList(0, 999, $sSearch, $bIncludeAliases)); } } \ No newline at end of file diff --git a/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain/DomainAdminInterface.php b/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain/DomainAdminInterface.php index c33ba664a..cd40df347 100644 --- a/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain/DomainAdminInterface.php +++ b/rainloop/v/0.0.0/app/libraries/RainLoop/Providers/Domain/DomainAdminInterface.php @@ -37,15 +37,20 @@ interface DomainAdminInterface extends DomainInterface public function Delete($sName); /** - * @param int $iOffset + * @param int $iOffset = 0 * @param int $iLimit = 20 + * @param int $sSearch = '' + * @param bool $bIncludeAliases = true * * @return array */ - public function GetList($iOffset, $iLimit = 20); + public function GetList($iOffset = 0, $iLimit = 20, $sSearch = '', $bIncludeAliases = true); /** + * @param string $sSearch = '' + * @param bool $bIncludeAliases = true + * * @return int */ - public function Count(); + public function Count($sSearch = '', $bIncludeAliases = true); } \ No newline at end of file diff --git a/rainloop/v/0.0.0/app/localization/admin/_source.en.yml b/rainloop/v/0.0.0/app/localization/admin/_source.en.yml index d1af676f9..d5e29597e 100644 --- a/rainloop/v/0.0.0/app/localization/admin/_source.en.yml +++ b/rainloop/v/0.0.0/app/localization/admin/_source.en.yml @@ -89,6 +89,7 @@ en: TAB_DOMAINS: LEGEND_DOMAINS: "Domains" BUTTON_ADD_DOMAIN: "Add Domain" + BUTTON_ADD_ALIAS: "Add Alias" DELETE_ARE_YOU_SURE: "Are you sure?" HTML_DOMAINS_HELPER: | List of domains webmail is allowed to access. @@ -186,6 +187,12 @@ en: Note that subscription key can be activated for a single domain only.

Once started, the process of activation cannot be aborted or cancelled. + POPUPS_DOMAIN_ALIAS: + TITLE_ADD_DOMAIN_ALIAS: "Add Alias" + LABEL_ALIAS: "Alias" + LABEL_DOMAIN: "Domain" + BUTTON_CLOSE: "Close" + BUTTON_ADD: "Add" POPUPS_DOMAIN: TITLE_ADD_DOMAIN: "Add Domain" TITLE_ADD_DOMAIN_WITH_NAME: "Add Domain \"%NAME%\"" diff --git a/rainloop/v/0.0.0/app/templates/Views/Admin/AdminSettingsDomainListItem.html b/rainloop/v/0.0.0/app/templates/Views/Admin/AdminSettingsDomainListItem.html index 85e616302..6d32c4368 100644 --- a/rainloop/v/0.0.0/app/templates/Views/Admin/AdminSettingsDomainListItem.html +++ b/rainloop/v/0.0.0/app/templates/Views/Admin/AdminSettingsDomainListItem.html @@ -1,6 +1,7 @@ + (alias) diff --git a/rainloop/v/0.0.0/app/templates/Views/Admin/AdminSettingsDomains.html b/rainloop/v/0.0.0/app/templates/Views/Admin/AdminSettingsDomains.html index 4e94b7443..fb75112f2 100644 --- a/rainloop/v/0.0.0/app/templates/Views/Admin/AdminSettingsDomains.html +++ b/rainloop/v/0.0.0/app/templates/Views/Admin/AdminSettingsDomains.html @@ -6,6 +6,12 @@    +    + + +    + +

diff --git a/rainloop/v/0.0.0/app/templates/Views/Admin/PopupsDomainAlias.html b/rainloop/v/0.0.0/app/templates/Views/Admin/PopupsDomainAlias.html new file mode 100644 index 000000000..0426d8d1e --- /dev/null +++ b/rainloop/v/0.0.0/app/templates/Views/Admin/PopupsDomainAlias.html @@ -0,0 +1,53 @@ +
+ +
\ No newline at end of file diff --git a/rainloop/v/0.0.0/app/templates/Views/Common/PopupsKeyboardShortcutsHelp.html b/rainloop/v/0.0.0/app/templates/Views/Common/PopupsKeyboardShortcutsHelp.html index dc3579c36..d8fe5d5d3 100644 --- a/rainloop/v/0.0.0/app/templates/Views/Common/PopupsKeyboardShortcutsHelp.html +++ b/rainloop/v/0.0.0/app/templates/Views/Common/PopupsKeyboardShortcutsHelp.html @@ -39,7 +39,7 @@ - + @@ -62,7 +62,7 @@ - + @@ -75,9 +75,9 @@
Ctrl + A, Cmd + A
Ctrl + A, ⌘ + A
Z
Delete, Shift + Delete, #
T
Enter
B
Ctrl + P, Cmd + P
Ctrl + P, ⌘ + P
Esc
Esc
Tab, Shift + Tab, Esc
- - - + + + diff --git a/rainloop/v/0.0.0/app/templates/Views/Components/Input.html b/rainloop/v/0.0.0/app/templates/Views/Components/Input.html index 12215d19d..d05f962a3 100644 --- a/rainloop/v/0.0.0/app/templates/Views/Components/Input.html +++ b/rainloop/v/0.0.0/app/templates/Views/Components/Input.html @@ -1,5 +1,5 @@ + data-bind="textInput: value, attr: {'placeholder': placeholder}, enable: enable, css: className" />   diff --git a/vendors/knockout-projections/CONTRIBUTING.md b/vendors/knockout-projections/CONTRIBUTING.md new file mode 100644 index 000000000..6042532ed --- /dev/null +++ b/vendors/knockout-projections/CONTRIBUTING.md @@ -0,0 +1,15 @@ +Thanks for your interest in contributing to Knockout Projections! + +# Issues + +If you think you've found a bug or limitation in this plugin, please report it using GitHub's [issues](https://github.com/SteveSanderson/knockout-projections/issues/) page. + +# Pull requests not currently accepted + +Very sorry, but I can't currently accept pull requests into this specific plugin due to licensing terms. This limitation *only* applies to Knockout-Projections - it *does not* apply to [Knockout.js itself](http://knockoutjs.com/), which does accept pull requests :) + +If you have a feature suggestion, then either: + + * [Post it as an issue](https://github.com/SteveSanderson/knockout-projections/issues/). Please don't provide a suggested implementation, but instead just describe the requirements in words so that I can implement it if agreed. + + * Or, fork this repo and implement your feature in a separate fork. The beauty of distributed source control is that you're not dependent on a central repo to share and collaborate on enhancements! diff --git a/vendors/knockout-projections/bower.json b/vendors/knockout-projections/bower.json new file mode 100644 index 000000000..da5ff2858 --- /dev/null +++ b/vendors/knockout-projections/bower.json @@ -0,0 +1,28 @@ +{ + "name": "knockout-projections", + "version": "1.1.0", + "homepage": "https://github.com/SteveSanderson/knockout-projections", + "authors": [ + "Microsoft Corporation" + ], + "description": "Observable maps and filters for Knockout observable arrays", + "main": "dist/knockout-projections.min.js", + "moduleType": [ + "amd", + "globals", + "node" + ], + "keywords": [ + "Knockout" + ], + "license": "Apache 2.0", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "spec" + ], + "dependencies": { + "knockout": "~3.1.0" + } +} diff --git a/vendors/knockout-projections/knockout-projections-1.1.0.js b/vendors/knockout-projections/knockout-projections-1.1.0.js new file mode 100644 index 000000000..e5db81d92 --- /dev/null +++ b/vendors/knockout-projections/knockout-projections-1.1.0.js @@ -0,0 +1,391 @@ +/*! Knockout projections plugin - version 1.1.0 +------------------------------------------------------------------------------ +Copyright (c) Microsoft Corporation +All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +THIS CODE IS PROVIDED *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABLITY OR NON-INFRINGEMENT. +See the Apache Version 2.0 License for specific language governing permissions and limitations under the License. +------------------------------------------------------------------------------ +*/ + +(function(global, undefined) { + 'use strict'; + + var exclusionMarker = {}; + + function StateItem(ko, inputItem, initialStateArrayIndex, initialOutputArrayIndex, mappingOptions, arrayOfState, outputObservableArray) { + // Capture state for later use + this.inputItem = inputItem; + this.stateArrayIndex = initialStateArrayIndex; + this.mappingOptions = mappingOptions; + this.arrayOfState = arrayOfState; + this.outputObservableArray = outputObservableArray; + this.outputArray = this.outputObservableArray.peek(); + this.isIncluded = null; // Means 'not yet determined' + this.suppressNotification = false; // TODO: Instead of this technique, consider raising a sparse diff with a "mutated" entry when a single item changes, and not having any other change logic inside StateItem + + // Set up observables + this.outputArrayIndex = ko.observable(initialOutputArrayIndex); // When excluded, it's the position the item would go if it became included + this.disposeFuncFromMostRecentMapping = null; + this.mappedValueComputed = ko.computed(this.mappingEvaluator, this); + this.mappedValueComputed.subscribe(this.onMappingResultChanged, this); + this.previousMappedValue = this.mappedValueComputed.peek(); + } + + StateItem.prototype.dispose = function() { + this.mappedValueComputed.dispose(); + this.disposeResultFromMostRecentEvaluation(); + }; + + StateItem.prototype.disposeResultFromMostRecentEvaluation = function() { + if (this.disposeFuncFromMostRecentMapping) { + this.disposeFuncFromMostRecentMapping(); + this.disposeFuncFromMostRecentMapping = null; + } + + if (this.mappingOptions.disposeItem) { + var mappedItem = this.mappedValueComputed(); + this.mappingOptions.disposeItem(mappedItem); + } + }; + + StateItem.prototype.mappingEvaluator = function() { + if (this.isIncluded !== null) { // i.e., not first run + // This is a replace-in-place, so call any dispose callbacks + // we have for the earlier value + this.disposeResultFromMostRecentEvaluation(); + } + + var mappedValue; + if (this.mappingOptions.mapping) { + mappedValue = this.mappingOptions.mapping(this.inputItem, this.outputArrayIndex); + } else if (this.mappingOptions.mappingWithDisposeCallback) { + var mappedValueWithDisposeCallback = this.mappingOptions.mappingWithDisposeCallback(this.inputItem, this.outputArrayIndex); + if (!('mappedValue' in mappedValueWithDisposeCallback)) { + throw new Error('Return value from mappingWithDisposeCallback should have a \'mappedItem\' property.'); + } + mappedValue = mappedValueWithDisposeCallback.mappedValue; + this.disposeFuncFromMostRecentMapping = mappedValueWithDisposeCallback.dispose; + } else { + throw new Error('No mapping callback given.'); + } + + var newInclusionState = mappedValue !== exclusionMarker; + + // Inclusion state changes can *only* happen as a result of changing an individual item. + // Structural changes to the array can't cause this (because they don't cause any remapping; + // they only map newly added items which have no earlier inclusion state to change). + if (this.isIncluded !== newInclusionState) { + if (this.isIncluded !== null) { // i.e., not first run + this.moveSubsequentItemsBecauseInclusionStateChanged(newInclusionState); + } + + this.isIncluded = newInclusionState; + } + + return mappedValue; + }; + + StateItem.prototype.onMappingResultChanged = function(newValue) { + if (newValue !== this.previousMappedValue) { + if (this.isIncluded) { + this.outputArray.splice(this.outputArrayIndex.peek(), 1, newValue); + } + + if (!this.suppressNotification) { + this.outputObservableArray.valueHasMutated(); + } + + this.previousMappedValue = newValue; + } + }; + + StateItem.prototype.moveSubsequentItemsBecauseInclusionStateChanged = function(newInclusionState) { + var outputArrayIndex = this.outputArrayIndex.peek(), + iterationIndex, + stateItem; + + if (newInclusionState) { + // Shift all subsequent items along by one space, and increment their indexes. + // Note that changing their indexes might cause remapping, but won't affect their + // inclusion status (by definition, inclusion status must not be affected by index, + // otherwise you get undefined results) so there's no risk of a chain reaction. + this.outputArray.splice(outputArrayIndex, 0, null); + for (iterationIndex = this.stateArrayIndex + 1; iterationIndex < this.arrayOfState.length; iterationIndex++) { + stateItem = this.arrayOfState[iterationIndex]; + stateItem.setOutputArrayIndexSilently(stateItem.outputArrayIndex.peek() + 1); + } + } else { + // Shift all subsequent items back by one space, and decrement their indexes + this.outputArray.splice(outputArrayIndex, 1); + for (iterationIndex = this.stateArrayIndex + 1; iterationIndex < this.arrayOfState.length; iterationIndex++) { + stateItem = this.arrayOfState[iterationIndex]; + stateItem.setOutputArrayIndexSilently(stateItem.outputArrayIndex.peek() - 1); + } + } + }; + + StateItem.prototype.setOutputArrayIndexSilently = function(newIndex) { + // We only want to raise one output array notification per input array change, + // so during processing, we suppress notifications + this.suppressNotification = true; + this.outputArrayIndex(newIndex); + this.suppressNotification = false; + }; + + function getDiffEntryPostOperationIndex(diffEntry, editOffset) { + // The diff algorithm's "index" value refers to the output array for additions, + // but the "input" array for deletions. Get the output array position. + if (!diffEntry) { return null; } + switch (diffEntry.status) { + case 'added': + return diffEntry.index; + case 'deleted': + return diffEntry.index + editOffset; + default: + throw new Error('Unknown diff status: ' + diffEntry.status); + } + } + + function insertOutputItem(ko, diffEntry, movedStateItems, stateArrayIndex, outputArrayIndex, mappingOptions, arrayOfState, outputObservableArray, outputArray) { + // Retain the existing mapped value if this is a move, otherwise perform mapping + var isMoved = typeof diffEntry.moved === 'number', + stateItem = isMoved ? + movedStateItems[diffEntry.moved] : + new StateItem(ko, diffEntry.value, stateArrayIndex, outputArrayIndex, mappingOptions, arrayOfState, outputObservableArray); + arrayOfState.splice(stateArrayIndex, 0, stateItem); + if (stateItem.isIncluded) { + outputArray.splice(outputArrayIndex, 0, stateItem.mappedValueComputed.peek()); + } + + // Update indexes + if (isMoved) { + // We don't change the index until *after* updating this item's position in outputObservableArray, + // because changing the index may trigger re-mapping, which in turn would cause the new + // value to be written to the 'index' position in the output array + stateItem.stateArrayIndex = stateArrayIndex; + stateItem.setOutputArrayIndexSilently(outputArrayIndex); + } + + return stateItem; + } + + function deleteOutputItem(diffEntry, arrayOfState, stateArrayIndex, outputArrayIndex, outputArray) { + var stateItem = arrayOfState.splice(stateArrayIndex, 1)[0]; + if (stateItem.isIncluded) { + outputArray.splice(outputArrayIndex, 1); + } + if (typeof diffEntry.moved !== 'number') { + // Be careful to dispose only if this item really was deleted and not moved + stateItem.dispose(); + } + } + + function updateRetainedOutputItem(stateItem, stateArrayIndex, outputArrayIndex) { + // Just have to update its indexes + stateItem.stateArrayIndex = stateArrayIndex; + stateItem.setOutputArrayIndexSilently(outputArrayIndex); + + // Return the new value for outputArrayIndex + return outputArrayIndex + (stateItem.isIncluded ? 1 : 0); + } + + function makeLookupOfMovedStateItems(diff, arrayOfState) { + // Before we mutate arrayOfComputedMappedValues at all, grab a reference to each moved item + var movedStateItems = {}; + for (var diffIndex = 0; diffIndex < diff.length; diffIndex++) { + var diffEntry = diff[diffIndex]; + if (diffEntry.status === 'added' && (typeof diffEntry.moved === 'number')) { + movedStateItems[diffEntry.moved] = arrayOfState[diffEntry.moved]; + } + } + return movedStateItems; + } + + function getFirstModifiedOutputIndex(firstDiffEntry, arrayOfState, outputArray) { + // Work out where the first edit will affect the output array + // Then we can update outputArrayIndex incrementally while walking the diff list + if (!outputArray.length || !arrayOfState[firstDiffEntry.index]) { + // The first edit is beyond the end of the output or state array, so we must + // just be appending items. + return outputArray.length; + } else { + // The first edit corresponds to an existing state array item, so grab + // the first output array index from it. + return arrayOfState[firstDiffEntry.index].outputArrayIndex.peek(); + } + } + + function respondToArrayStructuralChanges(ko, inputObservableArray, arrayOfState, outputArray, outputObservableArray, mappingOptions) { + return inputObservableArray.subscribe(function(diff) { + if (!diff.length) { + return; + } + + var movedStateItems = makeLookupOfMovedStateItems(diff, arrayOfState), + diffIndex = 0, + diffEntry = diff[0], + editOffset = 0, // A running total of (num(items added) - num(items deleted)) not accounting for filtering + outputArrayIndex = diffEntry && getFirstModifiedOutputIndex(diffEntry, arrayOfState, outputArray); + + // Now iterate over the state array, at each stage checking whether the current item + // is the next one to have been edited. We can skip all the state array items whose + // indexes are less than the first edit index (i.e., diff[0].index). + for (var stateArrayIndex = diffEntry.index; diffEntry || (stateArrayIndex < arrayOfState.length); stateArrayIndex++) { + // Does the current diffEntry correspond to this position in the state array? + if (getDiffEntryPostOperationIndex(diffEntry, editOffset) === stateArrayIndex) { + // Yes - insert or delete the corresponding state and output items + switch (diffEntry.status) { + case 'added': + // Add to output, and update indexes + var stateItem = insertOutputItem(ko, diffEntry, movedStateItems, stateArrayIndex, outputArrayIndex, mappingOptions, arrayOfState, outputObservableArray, outputArray); + if (stateItem.isIncluded) { + outputArrayIndex++; + } + editOffset++; + break; + case 'deleted': + // Just erase from the output, and update indexes + deleteOutputItem(diffEntry, arrayOfState, stateArrayIndex, outputArrayIndex, outputArray); + editOffset--; + stateArrayIndex--; // To compensate for the "for" loop incrementing it + break; + default: + throw new Error('Unknown diff status: ' + diffEntry.status); + } + + // We're done with this diff entry. Move on to the next one. + diffIndex++; + diffEntry = diff[diffIndex]; + } else if (stateArrayIndex < arrayOfState.length) { + // No - the current item was retained. Just update its index. + outputArrayIndex = updateRetainedOutputItem(arrayOfState[stateArrayIndex], stateArrayIndex, outputArrayIndex); + } + } + + outputObservableArray.valueHasMutated(); + }, null, 'arrayChange'); + } + + // Mapping + function observableArrayMap(ko, mappingOptions) { + var inputObservableArray = this, + arrayOfState = [], + outputArray = [], + outputObservableArray = ko.observableArray(outputArray), + originalInputArrayContents = inputObservableArray.peek(); + + // Shorthand syntax - just pass a function instead of an options object + if (typeof mappingOptions === 'function') { + mappingOptions = { mapping: mappingOptions }; + } + + // Validate the options + if (mappingOptions.mappingWithDisposeCallback) { + if (mappingOptions.mapping || mappingOptions.disposeItem) { + throw new Error('\'mappingWithDisposeCallback\' cannot be used in conjunction with \'mapping\' or \'disposeItem\'.'); + } + } else if (!mappingOptions.mapping) { + throw new Error('Specify either \'mapping\' or \'mappingWithDisposeCallback\'.'); + } + + // Initial state: map each of the inputs + for (var i = 0; i < originalInputArrayContents.length; i++) { + var inputItem = originalInputArrayContents[i], + stateItem = new StateItem(ko, inputItem, i, outputArray.length, mappingOptions, arrayOfState, outputObservableArray), + mappedValue = stateItem.mappedValueComputed.peek(); + arrayOfState.push(stateItem); + + if (stateItem.isIncluded) { + outputArray.push(mappedValue); + } + } + + // If the input array changes structurally (items added or removed), update the outputs + var inputArraySubscription = respondToArrayStructuralChanges(ko, inputObservableArray, arrayOfState, outputArray, outputObservableArray, mappingOptions); + + // Return value is a readonly computed which can track its own changes to permit chaining. + // When disposed, it cleans up everything it created. + var returnValue = ko.computed(outputObservableArray).extend({ trackArrayChanges: true }), + originalDispose = returnValue.dispose; + returnValue.dispose = function() { + inputArraySubscription.dispose(); + ko.utils.arrayForEach(arrayOfState, function(stateItem) { + stateItem.dispose(); + }); + originalDispose.call(this, arguments); + }; + + // Make projections chainable + addProjectionFunctions(ko, returnValue); + + return returnValue; + } + + // Filtering + function observableArrayFilter(ko, predicate) { + return observableArrayMap.call(this, ko, function(item) { + return predicate(item) ? item : exclusionMarker; + }); + } + + // Attaching projection functions + // ------------------------------ + // + // Builds a collection of projection functions that can quickly be attached to any object. + // The functions are predefined to retain 'this' and prefix the arguments list with the + // relevant 'ko' instance. + + var projectionFunctionsCacheName = '_ko.projections.cache'; + + function attachProjectionFunctionsCache(ko) { + // Wraps callback so that, when invoked, its arguments list is prefixed by 'ko' and 'this' + function makeCaller(ko, callback) { + return function() { + return callback.apply(this, [ko].concat(Array.prototype.slice.call(arguments, 0))); + }; + } + ko[projectionFunctionsCacheName] = { + map: makeCaller(ko, observableArrayMap), + filter: makeCaller(ko, observableArrayFilter) + }; + } + + function addProjectionFunctions(ko, target) { + ko.utils.extend(target, ko[projectionFunctionsCacheName]); + return target; // Enable chaining + } + + // Module initialisation + // --------------------- + // + // When this script is first evaluated, it works out what kind of module loading scenario + // it is in (Node.js or a browser `
W, C
` (tilde), Ctrl + `, Cmd + `
Ctrl + S, Cmd + S
` (tilde), Ctrl + `, ⌘ + `
Ctrl + S, ⌘ + S
Esc
Shift + Esc