diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..48d5f81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c9b122 --- /dev/null +++ b/.gitignore @@ -0,0 +1,174 @@ +# VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace +# Local History for Visual Studio Code +.history/ + +# Common credential files +**/credentials.json +**/client_secrets.json +**/client_secret.json +*creds* +*.dat +*password* +*.httr-oauth* + +# Private Node Modules +node_modules/ +creds.js + +# Private Files +*.json +*.csv +*.csv.gz +*.tsv +*.tsv.gz +*.xlsx + + +# Mac/OSX +.DS_Store + + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ \ No newline at end of file diff --git a/Backup.php b/Backup.php new file mode 100644 index 0000000..178cef4 --- /dev/null +++ b/Backup.php @@ -0,0 +1,23 @@ + © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +namespace FreePBX\modules\PhoneMiddleware; + +use FreePBX\modules\Backup as Base; + +class Backup extends Base\BackupBase +{ + public function runBackup($id, $transaction) + { + $Core = $this->FreePBX->PhoneMiddleware->getCore(); //get instance beacuse I cannot access statically here + + if (method_exists($Core, 'run_backup')) { + $Core->run_backup($this); + } else + throw new \Exception(_('Backup is not implemented!')); + } +} diff --git a/CoreInterface.php b/CoreInterface.php new file mode 100644 index 0000000..5c58cf0 --- /dev/null +++ b/CoreInterface.php @@ -0,0 +1,118 @@ + © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +/******************************************************************************/ +/*** These are the methods to implement to create a working Core class ***/ +/******************************************************************************/ + +interface CoreInterface +{ + public static function getInstance(): self; //return a Core instance + public function init(): void; //initialize anything you might need to + + //getter and setter for various parameters settable in UI. BMO class will get the values and pass them back through these methods. Any logic should be implemented at your wish. + public static function get_url(): string; //server url + public static function set_url(string $url): void; + public static function get_carddav_addressbooks(): array; //addressbooks URIs + public static function set_carddav_addressbooks(array $carddav_addressbooks): void; + public static function get_auth(): array; //carddav auth info + public static function set_auth(string $username, string $password): void; + public static function get_cache_expire(): int; //cache duration + public static function set_cache_expire(int $expire): void; + public static function get_country_code(): string; //country code + public static function set_country_code(string $code): void; + public static function get_output_construct(): string; //format of the CNAM output + public static function set_output_construct(string $output_construct): void; + public static function get_max_cnam_length(): int; //CNAM max allowed length + public static function set_max_cnam_length(int $max_cnam_length): void; + public static function get_phone_type(): int; //user device type + public static function set_phone_type(int $phone_type): void; + public static function get_mail_level(): array; //importance level when notifications will be sent also via mail + public static function set_mail_level(array $types): void; + public static function get_superfecta_compat(): bool; //superfecta compatibility switch + public static function set_superfecta_compat(bool $superfecta_compat): void; + public static function get_spam_match(): bool; //spam match report + public static function set_spam_match(bool $spam_match): void; + + public function store_config(): bool; //called when the UI class wants to store the data (i.e. when the user clicks "Apply") + public static function delete_cache(): bool; //if you have any cache, this method is called to invalidate it if there is any breaking change. + + public function getXMLforPhones(bool $force = false): string; //returns a well formatted xml phonebook that can be read by a phone/other device. $force to force refresh + public function getCNFromPhone(string $number, bool $force = false); //returns a CNAM (name) given a phone number. You should really not use $force because the result must come as fast as possible - output type should be string|null (not supported in PHP7) + + public function discover_addressbooks_to_display(): array; //returns an array of addressbooks based on the current url, username and password. This s used by the UI in conjunction with get_carddav_addressbooks() to create a list of the current active/inactive addressbooks. + public static function sendUINotification(int $type, string $message, int $id, int $flags): void; //send a notification that will be displayed in the top right corner of the UI. $type = the type (see constants below), $message = the text of the notification, $id = unique id, $flags = flags (see constants below). + public static function retrieveUINotifications(): array; //retrieve an array of UI notifications so that UI can display it + public static function deleteUINotification(int $id): bool; //delete a notification by ID + public static function deleteAllUINotifications(): bool; //delete all the notifications + + /******************************************************************************/ + /*** Do not modify/delete constants! ***/ + /******************************************************************************/ + + //if new are added remember to update getXMLforPhones + BMO class with new languages. MUST BE CONSECUTIVE. MUST START AT 1 (NOT 0). + public const PHONE_TYPE_NO_LIMITS = 1; + public const PHONE_TYPE_FANVIL = 2; + public const PHONE_TYPE_SNOM = 3; + public const PHONE_TYPES = [self::PHONE_TYPE_NO_LIMITS, self::PHONE_TYPE_FANVIL, self::PHONE_TYPE_SNOM]; + + //notification type and options constants. You can put below fixed notification IDs (> 0 && < 1000) + public const NOTIFICATION_TYPE_VERBOSE = 1; //this is a verbose message + public const NOTIFICATION_TYPE_ERROR = 2; //this is an error message + public const NOTIFICATION_TYPE_INFO = 3; //this is an info message + public const NOTIFICATION_FLAG_NO_MAIL = 0b0001; //overwrite mail send if needed +} + +/******************************************************************************/ +/*** These are optional methods of your Core class ***/ +/******************************************************************************/ + +interface UIAddons +{ + public static function get_module_name(): string; //return here your module name to be displayed in UI + public static function get_author(): string; //return here your name to be displayed in UI + public static function get_readme_url(): string; //return here your readme URL if you have one. Must be a valid HTML + public static function get_license_to_display(): array; //if you have a license, you should have this method too. returns ['description' => intestation with '%linkstart' and '%Linkend' anchors to be replaced for license link, 'text' => full text of the license, 'title' => title of the dialog] + public static function get_libraries_to_display(): array; //any library you used. returns array of arrays containing ['name' => library name, 'url' => library url] + public static function get_additional_footer(): string; //any additional information to print in footer + public static function get_help(): array; //return an array of raw strings containing help informations to print for the user. You should use the sandard format: title:text... +} + +interface Activation +{ + public static function get_purchase_buttons(): string; //return a well formatted HTML string containing your purchase/donation button(s). Include any '; + + //submit settings handling + if (isset($_POST['submit'])) { + $this->Core->set_cache_expire(isset($_POST['cache_expire']) ? (int) $_POST['cache_expire'] : 0); + $this->Core->set_country_code(isset($_POST['country_code']) ? $_POST['country_code'] : ''); + $this->Core->set_output_construct(isset($_POST['output_construct']) ? $_POST['output_construct'] : ''); + if (isset($_POST['max_cnam_length_enable']) && $_POST['max_cnam_length_enable'] == 'on') + $this->Core->set_max_cnam_length(isset($_POST['max_cnam_length']) ? (int) $_POST['max_cnam_length'] : 0); + else + $this->Core->set_max_cnam_length(0); + $this->Core->set_phone_type(isset($_POST['phone_type']) ? (int) $_POST['phone_type'] : Core::PHONE_TYPE_NO_LIMITS); + $this->Core->set_mail_level(isset($_POST['mail_level']) ? $_POST['mail_level'] : []); + $this->Core->set_superfecta_compat(isset($_POST['superfecta_compat']) ? ($_POST['superfecta_compat'] == 'on' ? true : false) : false); + $this->Core->set_spam_match(isset($_POST['spam_match']) ? ($_POST['spam_match'] == 'on' ? true : false) : false); + + if (!$this->Core->store_config()) { + if (!is_array($_POST['errors'])) + $_POST['errors'] = []; + + array_push($_POST['errors'], _('Something went wrong! Failed to save settings.')); + } + } + } + + /** + * Return the resolved XML Phonebook URL + * + * @return string XML Phonebook URL + * @throws Exception If this doesn't run inside a page + */ + public static function getXmlPhonebookURL() + { + if (self::$xmlPhonebookURL == null) + throw new Exception(_('This is only available inside a page')); + + return self::$xmlPhonebookURL; + } + + /** + * Return the resolved NumberToCNAM URL + * + * @return string NumberToCNAM URL + * @throws Exception If this doesn't run inside a page + */ + public static function getNumberToCnamURL() + { + if (self::$numberToCnamURL == null) + throw new Exception(_('This is only available inside a page')); + + return self::$numberToCnamURL; + } + + /** + * Return the resolved email addresses + * + * @return array Array of email addresses with "To" (if available) and "From" + * @throws Exception If this doesn't run inside a page + */ + public static function getEmailAddresses() + { + if (self::$emailAddresses == null) + throw new Exception(_('This is only available inside a page')); + + return self::$emailAddresses; + } + + + /** + * Set which ajax requests should be allowed + * + * @param string $req The request string from JS + * @return void + */ + public function ajaxRequest($req, &$setting) + { + switch ($req) { + case 'savecarddav': + case 'validatecarddav': + case 'deletenotification': + case 'deleteallnotifications': + case 'superfectainit': + case 'superfectareorder': + case 'outcnamsetup': + case 'inboundroutesetup': + case 'createorder': + case 'validatepurchase': + case 'restorepurchase': + return true; + default: + return false; + } + } + + /** + * Handle ajax request and return a result back in JSON + * + * @return void + */ + public function ajaxHandler() + { + //set default timeout (10s) for those connections to stop the user wait too much in case of problems + ini_set('default_socket_timeout', 10); + + switch ($_REQUEST['command']) { + case 'savecarddav': + $this->Core->set_url($_POST['carddav_url']); + $this->Core->set_auth($_POST['carddav_user'], $_POST['carddav_psw']); + $this->Core->set_carddav_addressbooks($_POST['carddav_addressbooks']); + + try { + if (!$this->Core->store_config()) //store config with the updated values above + throw new Exception(); + } catch (Exception $e) { + throw new Exception(_('Failed to save the addressbook settings!')); + } + return true; + case 'validatecarddav': + if (empty($_POST['carddav_url'])) + throw new Exception(_('URL must be set.')); //url must be there + + //load all the values from the server + $this->Core->set_url($_POST['carddav_url']); + $this->Core->set_auth($_POST['carddav_user'], $_POST['carddav_psw']); + $this->Core->init(); //force new init + $result = $this->Core->discover_addressbooks_to_display(); + $uris = $this->Core->get_carddav_addressbooks(); + + //then reorder the array as we saved it. Position of not checked values will be obviously not be preserved as nobody cares. + for ($i = count($uris) - 1; $i >= 0; --$i) { + $uri = $uris[$i]; + if (array_key_exists($uri, $result)) { //if the book is still there (read: if not deleted from the server) + //check the element + $result[$uri]['checked'] = true; + + //move the element to the top. The array is iterated in reverse to correctly push the first value to the first place. + $temp = $result[$uri]; + unset($result[$uri]); + array_unshift($result, $temp); + } + } + + return array_values($result); //js doesn't care about array keys. It is simpler to give it a normal indexed array + case 'deletenotification': + try { + $this->Core->deleteUINotification($_POST['id']); + } catch (Exception $e) { + throw new Exception(_('Failed to delete the notification(s)!')); + } + return true; + case 'deleteallnotifications': + try { + $this->Core->deleteAllUINotifications(); + } catch (Exception $e) { + throw new Exception(_('Failed to delete the notification(s)!')); + } + return true; + case 'superfectainit': + //create scheme + $r = new \ReflectionObject($this->FreePBX->Superfecta); + $p = $r->getProperty('schemeDefaults'); + $p->setAccessible(true); + + $message = _('Updated scheme.'); + + $scheme = $p->getValue($this->FreePBX->Superfecta); + $scheme['scheme_name'] = Utilities::SUPERFECTA_SCHEME; + $scheme['destination'] = ''; //missing value in defaults. So add empty + $scheme['Curl_Timeout'] = 5; + $scheme['SPAM_Text'] = '[SPAM] '; + $scheme['SPAM_threshold'] = 1; + if (!$this->FreePBX->Superfecta->getScheme(Utilities::SUPERFECTA_SCHEME)) { + if (!$this->FreePBX->Superfecta->addScheme(Utilities::SUPERFECTA_SCHEME, $scheme)['status']) + return ['status' => false, 'message' => _('An unknown exception occured.')]; + else + $message = _('Created scheme.'); + } + + if ($this->FreePBX->Superfecta->updateScheme(Utilities::SUPERFECTA_SCHEME, $scheme)) + return ['status' => true, 'message' => $message]; + else + return ['status' => false, 'message' => _('An unknown exception occured.')]; + case 'superfectareorder': + //hacky way to get to the result. Beacuse in the next steps I set my scheme as the only one, this isn't really needed but I leave it here for completeness. + try { + //first get global var and unset everything that is inside to prevent any problem + global $_REQUEST; + global $_POST; + unset($_REQUEST); + unset($_POST); + + //put it on top + $_REQUEST['command'] = 'sort'; + $_REQUEST['scheme'] = Utilities::SUPERFECTA_SCHEME; + $_POST['position'] = 'up'; + $executions = 0; //prevent infinite loops + while ($this->FreePBX->Superfecta->getScheme(Utilities::SUPERFECTA_SCHEME)['order'] > 10) { + $this->FreePBX->Superfecta->ajaxHandler(); + + if ($executions >= 99) //I hope nobody has more than 100 schemes! + break; + ++$executions; + } + + return ['status' => true, 'message' => _('Moved scheme at the top.')]; + } catch (\Throwable $t) { + return ['status' => false, 'message' => _('Unable to move scheme at the top. Proceeding anyway...')]; + } + case 'outcnamsetup': + //hacky way to get to the result + $data['enable_cdr'] = 'CHECKED'; + $data['enable_rpid'] = 'CHECKED'; + $data['scheme'] = 'base_' . Utilities::SUPERFECTA_SCHEME; //for strange reasons superfecta prepends "base_" to the scheme name. Simply hardcode it here + $this->FreePBX->OutCNAM->editConfig(1, $data); //ID is always 1, see https://github.com/POSSA/freepbx-Outbound_CNAM/blob/master/Outcnam.class.php#L49 + return ['status' => true, 'message' => _('OutCNAM configured.')]; //outcnam does not return any useful information so as long as there is no errors this will be the return value + case 'inboundroutesetup': + //hacky way to get to the result. + $route = json_decode(file_get_contents("php://input")); + if (!$route) + throw new Exception(_('Data is invalid.')); //the data is not valid + + //reset cidlookup + $this->FreePBX->Modules->loadFunctionsInc('cidlookup'); + if (function_exists('cidlookup_did_del')) + cidlookup_did_del($route->extension, $route->cidnum); //viewing_itemid is unused here so doesn't matter + else + throw new Exception(_('Required function not found!')); + + //set superfecta + $settings['sf_enable'] = 'true'; + $settings['sf_scheme'] = Utilities::SUPERFECTA_SCHEME; + $settings['extension'] = $route->extension; + $settings['cidnum'] = $route->cidnum; + + $this->FreePBX->Superfecta->bulkhandler_superfecta_cfg($settings); //viewing_itemid is unused here so doesn't matter + + return ['status' => true, 'message' => _('Inbound route updated.')]; //sadly there is no way (simple enough) to know if this was successful or not + } + } + + /** + * Handle ajax request and return a result back in plain text + * + * @return void + */ + public function ajaxCustomHandler() + { + switch ($_REQUEST['command']) { + case 'createorder': + if (method_exists(Core::class, 'create_order')) { + $res = Core::create_order(); + echo $res['message']; + return $res['result']; + } else + throw new Exception(_('Command is not implemented')); + case 'validatepurchase': + if (method_exists(Core::class, 'validate_purchase')) { + $res = Core::validate_purchase($_POST, file_get_contents("php://input")); + echo $res['message']; + return $res['result']; + } else + throw new Exception(_('Command is not implemented')); + case 'restorepurchase': + if (method_exists(Core::class, 'restore_purchase')) { + $res = Core::restore_purchase($_POST, file_get_contents("php://input")); + echo $res['message']; + return $res['result']; + } else + throw new Exception(_('Command is not implemented')); + } + } + + /** + * Method that is executed on install/upgrade. die() using the red span from page.modules.php instead of throwing exceptions here to better present errors to the user + * + * @return void + */ + public function install() + { + //delete www folder + try { + //to support upgrade from previous versions i need to check both for is_link because I switched the two (is_link check always first) + if ((is_link($this->WWW_MODULE_DIR_OLD) && !unlink($this->WWW_MODULE_DIR_OLD)) || (file_exists($this->WWW_MODULE_DIR_OLD) && !Utilities::delete_dir($this->WWW_MODULE_DIR_OLD))) + throw new Exception(); + if ((is_link($this->WWW_MODULE_SYMLINK_OLD) && !unlink($this->WWW_MODULE_SYMLINK_OLD)) || (file_exists($this->WWW_MODULE_SYMLINK_OLD) && !Utilities::delete_dir($this->WWW_MODULE_SYMLINK_OLD))) + throw new Exception(); + if (file_exists($this->WWW_MODULE_DIR_NEW) && !Utilities::delete_dir($this->WWW_MODULE_DIR_NEW)) + throw new Exception(); + } catch (Exception $e) { + die('' . str_replace('%folder', 'root', _('Installation failed: Unable to delete %folder folder. Make sure the module has read/write permissions.')) . ''); + } + + //recreate www folder and symlinks + if ( + !mkdir($this->WWW_MODULE_DIR_NEW) || + !symlink($this->WWW_MODULE_DIR_NEW, $this->WWW_MODULE_DIR_OLD) || //symlink new folder to old ones + !symlink($this->WWW_MODULE_DIR_NEW, $this->WWW_MODULE_SYMLINK_OLD) || //symlink new folder to old ones + !symlink(__DIR__ . '/carddavtoxml.php', $this->WWW_MODULE_DIR_NEW . '/carddavtoxml.php') || //symlink carddavtoxml + !symlink(__DIR__ . '/carddavtoxml.php', $this->WWW_MODULE_DIR_NEW . '/carddavtoXML.php') || //symlink carddavToXML (for backward compatibility) + !symlink(__DIR__ . '/numbertocnam.php', $this->WWW_MODULE_DIR_NEW . '/numbertocnam.php') || //symlink numbertocnam + !symlink(__DIR__ . '/numbertocnam.php', $this->WWW_MODULE_DIR_NEW . '/numberToCNAM.php') || //symlink numberToCNAM (for backward compatibility) + !file_exists($this->ASSETS_SYMLINK) && !symlink(__DIR__ . '/assets/', $this->ASSETS_SYMLINK) //symlink assets folder if not already there (not done automatically by fpbx, and seems like it is right this way) + ) + die('' . _('Failed to initialize working directory. The module won\'t work. Make sure the module has read/write permissions.') . ''); + + //add Job + FreePBX::Job()->addClass('phonemiddleware', 'job', 'FreePBX\modules\PhoneMiddleware\Job', '* * * * *'); + + //post install hooks from core, if providen. Excpetions are not catched here, if you care you must catch them yourself. + if (method_exists(Core::class, 'post_install_hook')) + Core::post_install_hook($this->FreePBX); + } + + /** + * Method that is executed on uninstall + * + * @return void + */ + public function uninstall() + { + $isException = false; + + //delete main root folder (because this function is 2.0.0+ only, the folder is already lowercase only). + //also if this is executed in a non-consinsent state it doesn't matter because will be removed on next install. + try { + if (!Utilities::delete_dir($this->WWW_MODULE_DIR_NEW)) + throw new Exception(); + } catch (Exception $e) { + $isException = true; + out(str_replace('%folder', 'root', _('Unable to delete %folder folder.')) . ' ' . _('Try to delete it manually to completely remove the module.')); + } + + //delete www (symlink) folder + try { + //to support upgrade from previous versions i need to check both for is_link because I switched the two (is_link check always first) + if ((is_link($this->WWW_MODULE_DIR_OLD) && !unlink($this->WWW_MODULE_DIR_OLD)) || (file_exists($this->WWW_MODULE_DIR_OLD) && !Utilities::delete_dir($this->WWW_MODULE_DIR_OLD))) + throw new Exception(); + if ((is_link($this->WWW_MODULE_SYMLINK_OLD) && !unlink($this->WWW_MODULE_SYMLINK_OLD)) || (file_exists($this->WWW_MODULE_SYMLINK_OLD) && !Utilities::delete_dir($this->WWW_MODULE_SYMLINK_OLD))) + throw new Exception(); + } catch (Exception $e) { + $isException = true; + out(str_replace('%folder', 'symlink root', _('Unable to delete %folder folder.')) . ' ' . _('Try to delete it manually to completely remove the module.')); + } + + //pre uninstall hooks from core, if providen. Excpetions are catched here and a generic warning will be printed at the end. + try { + if (method_exists(Core::class, 'post_uninstall_hook')) + Core::post_uninstall_hook($this->FreePBX); + } catch (\Throwable $t) { + $isException = true; + } + + if ($isException) + throw new Exception(_('Some error(s) occurred during the process. Please check above for error messages.')); //this will be printed in red automatically and is not an exception that causes the page to die + + //symlink folder as well as Jobs are automatically removed by freepbx + } + + /** + * Return an associative array of all country codes. Thanks to https://gist.github.com/josephilipraja/8341837?permalink_comment_id=3883690#gistcomment-3883690 + * + * @return array Country code list with phone number and friendly name + */ + public static function get_country_codes() + { + return [ + 'AD' => ['name' => 'ANDORRA', 'code' => '376'], + 'AE' => ['name' => 'UNITED ARAB EMIRATES', 'code' => '971'], + 'AF' => ['name' => 'AFGHANISTAN', 'code' => '93'], + 'AG' => ['name' => 'ANTIGUA AND BARBUDA', 'code' => '1268'], + 'AI' => ['name' => 'ANGUILLA', 'code' => '1264'], + 'AL' => ['name' => 'ALBANIA', 'code' => '355'], + 'AM' => ['name' => 'ARMENIA', 'code' => '374'], + 'AN' => ['name' => 'NETHERLANDS ANTILLES', 'code' => '599'], + 'AO' => ['name' => 'ANGOLA', 'code' => '244'], + 'AQ' => ['name' => 'ANTARCTICA', 'code' => '672'], + 'AR' => ['name' => 'ARGENTINA', 'code' => '54'], + 'AS' => ['name' => 'AMERICAN SAMOA', 'code' => '1684'], + 'AT' => ['name' => 'AUSTRIA', 'code' => '43'], + 'AU' => ['name' => 'AUSTRALIA', 'code' => '61'], + 'AW' => ['name' => 'ARUBA', 'code' => '297'], + 'AX' => ['name' => 'ÅLAND ISLANDS', 'code' => '358'], + 'AZ' => ['name' => 'AZERBAIJAN', 'code' => '994'], + 'BA' => ['name' => 'BOSNIA AND HERZEGOVINA', 'code' => '387'], + 'BB' => ['name' => 'BARBADOS', 'code' => '1246'], + 'BD' => ['name' => 'BANGLADESH', 'code' => '880'], + 'BE' => ['name' => 'BELGIUM', 'code' => '32'], + 'BF' => ['name' => 'BURKINA FASO', 'code' => '226'], + 'BG' => ['name' => 'BULGARIA', 'code' => '359'], + 'BH' => ['name' => 'BAHRAIN', 'code' => '973'], + 'BI' => ['name' => 'BURUNDI', 'code' => '257'], + 'BJ' => ['name' => 'BENIN', 'code' => '229'], + 'BL' => ['name' => 'SAINT BARTHELEMY', 'code' => '590'], + 'BM' => ['name' => 'BERMUDA', 'code' => '1441'], + 'BN' => ['name' => 'BRUNEI DARUSSALAM', 'code' => '673'], + 'BO' => ['name' => 'BOLIVIA', 'code' => '591'], + 'BQ' => ['name' => 'CARIBEAN NETHERLANDS', 'code' => '599'], + 'BR' => ['name' => 'BRAZIL', 'code' => '55'], + 'BS' => ['name' => 'BAHAMAS', 'code' => '1242'], + 'BT' => ['name' => 'BHUTAN', 'code' => '975'], + 'BV' => ['name' => 'BOUVET ISLAND', 'code' => '55'], + 'BW' => ['name' => 'BOTSWANA', 'code' => '267'], + 'BY' => ['name' => 'BELARUS', 'code' => '375'], + 'BZ' => ['name' => 'BELIZE', 'code' => '501'], + 'CA' => ['name' => 'CANADA', 'code' => '1'], + 'CC' => ['name' => 'COCOS (KEELING) ISLANDS', 'code' => '61'], + 'CD' => ['name' => 'CONGO, THE DEMOCRATIC REPUBLIC OF THE', 'code' => '243'], + 'CF' => ['name' => 'CENTRAL AFRICAN REPUBLIC', 'code' => '236'], + 'CG' => ['name' => 'CONGO', 'code' => '242'], + 'CH' => ['name' => 'SWITZERLAND', 'code' => '41'], + 'CI' => ['name' => 'COTE D IVOIRE', 'code' => '225'], + 'CK' => ['name' => 'COOK ISLANDS', 'code' => '682'], + 'CL' => ['name' => 'CHILE', 'code' => '56'], + 'CM' => ['name' => 'CAMEROON', 'code' => '237'], + 'CN' => ['name' => 'CHINA', 'code' => '86'], + 'CO' => ['name' => 'COLOMBIA', 'code' => '57'], + 'CR' => ['name' => 'COSTA RICA', 'code' => '506'], + 'CU' => ['name' => 'CUBA', 'code' => '53'], + 'CV' => ['name' => 'CAPE VERDE', 'code' => '238'], + 'CW' => ['name' => 'CURAÇAO', 'code' => '599'], + 'CX' => ['name' => 'CHRISTMAS ISLAND', 'code' => '61'], + 'CY' => ['name' => 'CYPRUS', 'code' => '357'], + 'CZ' => ['name' => 'CZECH REPUBLIC', 'code' => '420'], + 'DE' => ['name' => 'GERMANY', 'code' => '49'], + 'DJ' => ['name' => 'DJIBOUTI', 'code' => '253'], + 'DK' => ['name' => 'DENMARK', 'code' => '45'], + 'DM' => ['name' => 'DOMINICA', 'code' => '1767'], + 'DO' => ['name' => 'DOMINICAN REPUBLIC', 'code' => '1809'], + 'DZ' => ['name' => 'ALGERIA', 'code' => '213'], + 'EC' => ['name' => 'ECUADOR', 'code' => '593'], + 'EE' => ['name' => 'ESTONIA', 'code' => '372'], + 'EG' => ['name' => 'EGYPT', 'code' => '20'], + 'EH' => ['name' => 'WESTERN SAHARA', 'code' => '212'], + 'ER' => ['name' => 'ERITREA', 'code' => '291'], + 'ES' => ['name' => 'SPAIN', 'code' => '34'], + 'ET' => ['name' => 'ETHIOPIA', 'code' => '251'], + 'FI' => ['name' => 'FINLAND', 'code' => '358'], + 'FJ' => ['name' => 'FIJI', 'code' => '679'], + 'FK' => ['name' => 'FALKLAND ISLANDS (MALVINAS)', 'code' => '500'], + 'FM' => ['name' => 'MICRONESIA, FEDERATED STATES OF', 'code' => '691'], + 'FO' => ['name' => 'FAROE ISLANDS', 'code' => '298'], + 'FR' => ['name' => 'FRANCE', 'code' => '33'], + 'GA' => ['name' => 'GABON', 'code' => '241'], + 'GB' => ['name' => 'UNITED KINGDOM', 'code' => '44'], + 'GD' => ['name' => 'GRENADA', 'code' => '1473'], + 'GE' => ['name' => 'GEORGIA', 'code' => '995'], + 'GF' => ['name' => 'FRENCH GUIANA', 'code' => '594'], + 'GG' => ['name' => 'GUERNSEY', 'code' => '44'], + 'GH' => ['name' => 'GHANA', 'code' => '233'], + 'GI' => ['name' => 'GIBRALTAR', 'code' => '350'], + 'GL' => ['name' => 'GREENLAND', 'code' => '299'], + 'GM' => ['name' => 'GAMBIA', 'code' => '220'], + 'GN' => ['name' => 'GUINEA', 'code' => '224'], + 'GP' => ['name' => 'GUADELOUPE', 'code' => '590'], + 'GQ' => ['name' => 'EQUATORIAL GUINEA', 'code' => '240'], + 'GR' => ['name' => 'GREECE', 'code' => '30'], + 'GS' => ['name' => 'SOUTH GEORGIA & SOUTH SANDWICH ISLANDS', 'code' => '500'], + 'GT' => ['name' => 'GUATEMALA', 'code' => '502'], + 'GU' => ['name' => 'GUAM', 'code' => '1671'], + 'GW' => ['name' => 'GUINEA-BISSAU', 'code' => '245'], + 'GY' => ['name' => 'GUYANA', 'code' => '592'], + 'HK' => ['name' => 'HONG KONG', 'code' => '852'], + 'HM' => ['name' => 'HEARD & MCDONALD ISLANDS', 'code' => '672'], + 'HN' => ['name' => 'HONDURAS', 'code' => '504'], + 'HR' => ['name' => 'CROATIA', 'code' => '385'], + 'HT' => ['name' => 'HAITI', 'code' => '509'], + 'HU' => ['name' => 'HUNGARY', 'code' => '36'], + 'ID' => ['name' => 'INDONESIA', 'code' => '62'], + 'IE' => ['name' => 'IRELAND', 'code' => '353'], + 'IL' => ['name' => 'ISRAEL', 'code' => '972'], + 'IM' => ['name' => 'ISLE OF MAN', 'code' => '44'], + 'IN' => ['name' => 'INDIA', 'code' => '91'], + 'IO' => ['name' => 'BRITISH INDIAN OCEAN TERRITORY', 'code' => '246'], + 'IQ' => ['name' => 'IRAQ', 'code' => '964'], + 'IR' => ['name' => 'IRAN, ISLAMIC REPUBLIC OF', 'code' => '98'], + 'IS' => ['name' => 'ICELAND', 'code' => '354'], + 'IT' => ['name' => 'ITALY', 'code' => '39'], + 'JE' => ['name' => 'JERSEY', 'code' => '44'], + 'JM' => ['name' => 'JAMAICA', 'code' => '1876'], + 'JO' => ['name' => 'JORDAN', 'code' => '962'], + 'JP' => ['name' => 'JAPAN', 'code' => '81'], + 'KE' => ['name' => 'KENYA', 'code' => '254'], + 'KG' => ['name' => 'KYRGYZSTAN', 'code' => '996'], + 'KH' => ['name' => 'CAMBODIA', 'code' => '855'], + 'KI' => ['name' => 'KIRIBATI', 'code' => '686'], + 'KM' => ['name' => 'COMOROS', 'code' => '269'], + 'KN' => ['name' => 'SAINT KITTS AND NEVIS', 'code' => '1869'], + 'KP' => ['name' => 'KOREA DEMOCRATIC PEOPLES REPUBLIC OF', 'code' => '850'], + 'KR' => ['name' => 'KOREA REPUBLIC OF', 'code' => '82'], + 'KW' => ['name' => 'KUWAIT', 'code' => '965'], + 'KY' => ['name' => 'CAYMAN ISLANDS', 'code' => '1345'], + 'KZ' => ['name' => 'KAZAKSTAN', 'code' => '7'], + 'LA' => ['name' => 'LAO PEOPLES DEMOCRATIC REPUBLIC', 'code' => '856'], + 'LB' => ['name' => 'LEBANON', 'code' => '961'], + 'LC' => ['name' => 'SAINT LUCIA', 'code' => '1758'], + 'LI' => ['name' => 'LIECHTENSTEIN', 'code' => '423'], + 'LK' => ['name' => 'SRI LANKA', 'code' => '94'], + 'LR' => ['name' => 'LIBERIA', 'code' => '231'], + 'LS' => ['name' => 'LESOTHO', 'code' => '266'], + 'LT' => ['name' => 'LITHUANIA', 'code' => '370'], + 'LU' => ['name' => 'LUXEMBOURG', 'code' => '352'], + 'LV' => ['name' => 'LATVIA', 'code' => '371'], + 'LY' => ['name' => 'LIBYAN ARAB JAMAHIRIYA', 'code' => '218'], + 'MA' => ['name' => 'MOROCCO', 'code' => '212'], + 'MC' => ['name' => 'MONACO', 'code' => '377'], + 'MD' => ['name' => 'MOLDOVA, REPUBLIC OF', 'code' => '373'], + 'ME' => ['name' => 'MONTENEGRO', 'code' => '382'], + 'MF' => ['name' => 'SAINT MARTIN', 'code' => '1599'], + 'MG' => ['name' => 'MADAGASCAR', 'code' => '261'], + 'MH' => ['name' => 'MARSHALL ISLANDS', 'code' => '692'], + 'MK' => ['name' => 'MACEDONIA, THE FORMER YUGOSLAV REPUBLIC OF', 'code' => '389'], + 'ML' => ['name' => 'MALI', 'code' => '223'], + 'MM' => ['name' => 'MYANMAR', 'code' => '95'], + 'MN' => ['name' => 'MONGOLIA', 'code' => '976'], + 'MO' => ['name' => 'MACAU', 'code' => '853'], + 'MP' => ['name' => 'NORTHERN MARIANA ISLANDS', 'code' => '1670'], + 'MQ' => ['name' => 'MARTINIQUE', 'code' => '596'], + 'MR' => ['name' => 'MAURITANIA', 'code' => '222'], + 'MS' => ['name' => 'MONTSERRAT', 'code' => '1664'], + 'MT' => ['name' => 'MALTA', 'code' => '356'], + 'MU' => ['name' => 'MAURITIUS', 'code' => '230'], + 'MV' => ['name' => 'MALDIVES', 'code' => '960'], + 'MW' => ['name' => 'MALAWI', 'code' => '265'], + 'MX' => ['name' => 'MEXICO', 'code' => '52'], + 'MY' => ['name' => 'MALAYSIA', 'code' => '60'], + 'MZ' => ['name' => 'MOZAMBIQUE', 'code' => '258'], + 'NA' => ['name' => 'NAMIBIA', 'code' => '264'], + 'NC' => ['name' => 'NEW CALEDONIA', 'code' => '687'], + 'NE' => ['name' => 'NIGER', 'code' => '227'], + 'NF' => ['name' => 'NORFOLK ISLAND', 'code' => '672'], + 'NG' => ['name' => 'NIGERIA', 'code' => '234'], + 'NI' => ['name' => 'NICARAGUA', 'code' => '505'], + 'NL' => ['name' => 'NETHERLANDS', 'code' => '31'], + 'NO' => ['name' => 'NORWAY', 'code' => '47'], + 'NP' => ['name' => 'NEPAL', 'code' => '977'], + 'NR' => ['name' => 'NAURU', 'code' => '674'], + 'NU' => ['name' => 'NIUE', 'code' => '683'], + 'NZ' => ['name' => 'NEW ZEALAND', 'code' => '64'], + 'OM' => ['name' => 'OMAN', 'code' => '968'], + 'PA' => ['name' => 'PANAMA', 'code' => '507'], + 'PE' => ['name' => 'PERU', 'code' => '51'], + 'PF' => ['name' => 'FRENCH POLYNESIA', 'code' => '689'], + 'PG' => ['name' => 'PAPUA NEW GUINEA', 'code' => '675'], + 'PH' => ['name' => 'PHILIPPINES', 'code' => '63'], + 'PK' => ['name' => 'PAKISTAN', 'code' => '92'], + 'PL' => ['name' => 'POLAND', 'code' => '48'], + 'PM' => ['name' => 'SAINT PIERRE AND MIQUELON', 'code' => '508'], + 'PN' => ['name' => 'PITCAIRN', 'code' => '870'], + 'PR' => ['name' => 'PUERTO RICO', 'code' => '1'], + 'PS' => ['name' => 'PALESTINE', 'code' => '970'], + 'PT' => ['name' => 'PORTUGAL', 'code' => '351'], + 'PW' => ['name' => 'PALAU', 'code' => '680'], + 'PY' => ['name' => 'PARAGUAY', 'code' => '595'], + 'QA' => ['name' => 'QATAR', 'code' => '974'], + 'RE' => ['name' => 'RÉUNION', 'code' => '262'], + 'RO' => ['name' => 'ROMANIA', 'code' => '40'], + 'RS' => ['name' => 'SERBIA', 'code' => '381'], + 'RU' => ['name' => 'RUSSIAN FEDERATION', 'code' => '7'], + 'RW' => ['name' => 'RWANDA', 'code' => '250'], + 'SA' => ['name' => 'SAUDI ARABIA', 'code' => '966'], + 'SB' => ['name' => 'SOLOMON ISLANDS', 'code' => '677'], + 'SC' => ['name' => 'SEYCHELLES', 'code' => '248'], + 'SD' => ['name' => 'SUDAN', 'code' => '249'], + 'SE' => ['name' => 'SWEDEN', 'code' => '46'], + 'SG' => ['name' => 'SINGAPORE', 'code' => '65'], + 'SH' => ['name' => 'SAINT HELENA', 'code' => '290'], + 'SI' => ['name' => 'SLOVENIA', 'code' => '386'], + 'SJ' => ['name' => 'SVALBARD & JAN MAYEN', 'code' => '47'], + 'SK' => ['name' => 'SLOVAKIA', 'code' => '421'], + 'SL' => ['name' => 'SIERRA LEONE', 'code' => '232'], + 'SM' => ['name' => 'SAN MARINO', 'code' => '378'], + 'SN' => ['name' => 'SENEGAL', 'code' => '221'], + 'SO' => ['name' => 'SOMALIA', 'code' => '252'], + 'SR' => ['name' => 'SURINAME', 'code' => '597'], + 'SS' => ['name' => 'SOUTH SUDAN', 'code' => '211'], + 'ST' => ['name' => 'SAO TOME AND PRINCIPE', 'code' => '239'], + 'SV' => ['name' => 'EL SALVADOR', 'code' => '503'], + 'SX' => ['name' => 'SINT MAARTEN', 'code' => '1721'], + 'SY' => ['name' => 'SYRIAN ARAB REPUBLIC', 'code' => '963'], + 'SZ' => ['name' => 'SWAZILAND', 'code' => '268'], + 'TC' => ['name' => 'TURKS AND CAICOS ISLANDS', 'code' => '1649'], + 'TD' => ['name' => 'CHAD', 'code' => '235'], + 'TF' => ['name' => 'FRENCH SOUTHERN TERRITORIES ', 'code' => '262'], + 'TG' => ['name' => 'TOGO', 'code' => '228'], + 'TH' => ['name' => 'THAILAND', 'code' => '66'], + 'TJ' => ['name' => 'TAJIKISTAN', 'code' => '992'], + 'TK' => ['name' => 'TOKELAU', 'code' => '690'], + 'TL' => ['name' => 'TIMOR-LESTE', 'code' => '670'], + 'TM' => ['name' => 'TURKMENISTAN', 'code' => '993'], + 'TN' => ['name' => 'TUNISIA', 'code' => '216'], + 'TO' => ['name' => 'TONGA', 'code' => '676'], + 'TR' => ['name' => 'TURKEY', 'code' => '90'], + 'TT' => ['name' => 'TRINIDAD AND TOBAGO', 'code' => '1868'], + 'TV' => ['name' => 'TUVALU', 'code' => '688'], + 'TW' => ['name' => 'TAIWAN, PROVINCE OF CHINA', 'code' => '886'], + 'TZ' => ['name' => 'TANZANIA, UNITED REPUBLIC OF', 'code' => '255'], + 'UA' => ['name' => 'UKRAINE', 'code' => '380'], + 'UG' => ['name' => 'UGANDA', 'code' => '256'], + 'UM' => ['name' => 'U.S. OUTLYING ISLANDS', 'code' => '1'], + 'US' => ['name' => 'UNITED STATES', 'code' => '1'], + 'UY' => ['name' => 'URUGUAY', 'code' => '598'], + 'UZ' => ['name' => 'UZBEKISTAN', 'code' => '998'], + 'VA' => ['name' => 'HOLY SEE (VATICAN CITY STATE)', 'code' => '39'], + 'VC' => ['name' => 'SAINT VINCENT AND THE GRENADINES', 'code' => '1784'], + 'VE' => ['name' => 'VENEZUELA', 'code' => '58'], + 'VG' => ['name' => 'VIRGIN ISLANDS, BRITISH', 'code' => '1284'], + 'VI' => ['name' => 'VIRGIN ISLANDS, U.S.', 'code' => '1340'], + 'VN' => ['name' => 'VIETNAM', 'code' => '84'], + 'VU' => ['name' => 'VANUATU', 'code' => '678'], + 'WF' => ['name' => 'WALLIS AND FUTUNA', 'code' => '681'], + 'WS' => ['name' => 'SAMOA', 'code' => '685'], + 'XK' => ['name' => 'KOSOVO', 'code' => '383'], + 'YE' => ['name' => 'YEMEN', 'code' => '967'], + 'YT' => ['name' => 'MAYOTTE', 'code' => '262'], + 'ZA' => ['name' => 'SOUTH AFRICA', 'code' => '27'], + 'ZM' => ['name' => 'ZAMBIA', 'code' => '260'], + 'ZW' => ['name' => 'ZIMBABWE', 'code' => '263'], + ]; + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..5161ba3 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ + + + + + +
+ Module icon + +

CardDAV Middleware UI for FreePBX (formerly PhoneMiddleware)

+
+ +# For the users: All the downloads moved to
Releases + +## About this +This is not the complete module and *will not work as is!* This is only meant for developers who wants to build their backends and release a module based on that. So if you are an end user and you still reading this, well.. you shouldn't! Download the ready-to-use module from here + +## Development +First pull the main repo or, even better, one of the tagged versions available, then start developing by having a look at the main files in the project: +- `core/CoreInterface.php` is the main interface that defines all the mandatory and optional mathods you can implement. Have a look inside for all the comments beside the declarations. Implement your logic following the requirements of the UI too. Your core class **MUST** be `Core` and the file **MUST** be `core.php` +- `numbertocnam.php` and `carddavtoxml.php` are the files that are exposed to the web server, the user will call those URLs to get a CNAM or a phonebook XML respectively +- When you have tested everything and ready to deploy the module, start the build script inside a shell, you will be guided step-by-step and you'll receive a nice packaged module back + +## Donation +If you like to support me, you can donate. Any help is greatly appreciated. Thank you! + +paypal + +**Bitcoin:** 1Pig6XJxXGBj1v3r6uLrFUSHwyX8GPopRs + +**Monero:** 89qdmpDsMm9MUvpsG69hbRMcGWeG2D26BdATw1iXXZwi8DqSEstJGcWNkenrXtThCAAJTpjkUNtZuQvxK1N5xSyb18eXzPD + +## License +`SPDX-License-Identifier: CC-BY-NC-ND-4.0`
+This work is licensed under Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International
+By using this module, building your own one by extending it or any other similar matter you agree to the terms. Licenses for the included libraries are available below, credits go to the original author only. +- [Tagify](https://github.com/yairEO/tagify) +- [DragSort](https://github.com/yairEO/dragsort) \ No newline at end of file diff --git a/Restore.php b/Restore.php new file mode 100644 index 0000000..43c65f0 --- /dev/null +++ b/Restore.php @@ -0,0 +1,23 @@ + © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +namespace FreePBX\modules\PhoneMiddleware; + +use FreePBX\modules\Backup as Base; + +class Restore extends Base\RestoreBase +{ + public function runRestore() + { + $Core = $this->FreePBX->PhoneMiddleware->getCore(); //get instance beacuse I cannot access statically here + + if (method_exists($Core, 'run_restore')) { + $Core->run_restore($this, $this->getVersion()); + } else + throw new \Exception(_('Retore is not implemented!')); + } +} diff --git a/assets/css/dragsort.css b/assets/css/dragsort.css new file mode 100644 index 0000000..8efaf00 --- /dev/null +++ b/assets/css/dragsort.css @@ -0,0 +1 @@ +.dragsort--noAnim,.dragsort--noAnim~[draggable]{transition:none!important}.dragsort--dragStart .dragsort--dragElem{opacity:0}.dragsort--dragStart .dragsort--hide{width:0!important;height:0!important;padding:0!important;margin:0!important;border-width:0!important}.dragsort--dragStart>*{transition:var(--dragsort-trans-dur, .18s) cubic-bezier(.6, .1, .4, 1.2)}.dragsort--dragStart>*>*{pointer-events:none} \ No newline at end of file diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..b14a026 --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,809 @@ +/* + * CardDAV Middleware UI + * Written by Massi-X © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +/* override default dragsort styles to prevent glithes. See also js or https://github.com/yairEO/dragsort/issues/5#issuecomment-1004465926 */ +.dragsort--dragStart .dragsort--hide { + display: none !important; +} + +.dragsort--dragStart>* { + transition: margin .15s ease-in-out !important; +} + +/* set a maximum width for dialogs + prettier close icon */ +.ui-dialog { + max-width: 95vw; +} + +.ui-dialog-titlebar-close { + width: 20px !important; + padding: 0 !important; + height: 20px !important; + top: 17.5px !important; + right: 15px !important; + margin: 0 !important; + opacity: 1 !important; +} + +.ui-dialog-titlebar-close::before { + font-family: 'FontAwesome'; + content: "\f057"; + line-height: 20px; + font-size: 20px; + opacity: 1; + color: #f44336; +} + +.ui-dialog-titlebar-close:hover::before { + color: #d32f2f; +} + +.ui-dialog-titlebar-close:active::before { + color: #b71c1c; +} + +/* fix Sangoma's own footer... */ +#footer { + height: auto !important; + min-height: 100px; +} + +@media screen and (max-width: 991px) { + #footer #footer_content #footer_logo { + float: initial !important; + padding-right: 0 !important; + } +} + +/* fix row height */ +.element-container { + min-height: 42px; +} + +/* fix input hiding */ +input[hidden] { + display: none !important; +} + +/* fix button style per my taste */ +.btn:focus, +.btn:active:focus, +.btn.active:focus, +.btn.focus, +.btn:active.focus, +.btn.active.focus { + outline: none; +} + +/* radioset tabulation selection/focus/active colors */ +.radioset label:focus-visible { + outline: none; +} + +.radioset>input:not(:checked)+label:focus-visible { + background: #d2ddd8 !important; +} + +.radioset>input:checked+label:focus-visible { + background: #4a7d64 !important; +} + +/* fix dialog height */ +.ui-dialog { + max-height: calc(100vh - 30px); + overflow-y: auto; +} + +/* keep dialog at center of screen */ +.ui-dialog { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); +} + +/* adjust dialog list per my taste */ +.ui-dialog ul li { + margin-bottom: 5px; +} + +/* fix fpbx-tagify conflict see issue https://github.com/yairEO/tagify/issues/1110 */ +.tagify { + height: auto; + --tag-inset-shadow-size: 1.3em !important; +} + +/* adjust select (single-value mode) style of tagify per my needs */ +.phone_type__tagify .tagify__input::selection { + background: transparent; +} + +.tagify--select .tagify__tag__removeBtn { + display: none !important; +} + +/* overcome tagify visibility hidden after loading */ +.tagify { + visibility: visible !important; +} + +/* align tagify and inputs to the same styles */ +.tagify { + --tags-focus-border-color: rgba(94, 156, 125, 0.9) !important; + --tags-hover-border-color: rgba(94, 156, 125, 0.9) !important; + --tag-border-radius: 4px !important; +} + +:root { + --tagify-dd-color-primary: rgba(94, 156, 125, 0.9) !important; +} + +#page_body input.form-control, +.ui-dialog input.form-control { + min-height: 37px; + border-color: #ddd; +} + +#page_body input.form-control:hover:enabled, +.ui-dialog input.form-control:hover:enabled, +#page_body input.form-control:focus:enabled, +.ui-dialog input.form-control:focus:enabled { + box-shadow: none; + border-color: rgba(94, 156, 125, 0.9); +} + +#page_body input.form-control:focus, +.ui-dialog input.form-control:focus { + box-shadow: none; +} + +/* adjust input style of tagify per my needs */ +.phone_type__tagify, +.output_construct__tagify { + cursor: pointer !important; +} + +/* To be used together to place a button beside an input */ +.col-md-9-flex { + display: flex; +} + +.form-control-flex { + flex: 1; +} + +.btn-input-flex { + margin-left: 10px; +} + +/* custom checkbox */ +.ph-checkbox { + display: block; + user-select: none; + cursor: pointer; + height: 25px; + width: 25px; +} + +.ph-checkbox>input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.ph-checkbox>span { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + border-radius: 0.3em; + border: 1px solid #9e9e9e; + background-color: #fafafa; +} + +.ph-checkbox>span:after { + font: normal normal normal 14px/1 FontAwesome; + content: "\f00c"; + position: absolute; + color: #9E9E9E; + left: 5px; + top: 5px; + width: 5px; + height: 10px; +} + +.ph-checkbox:hover input:not(:checked)~span { + background-color: #eee; +} + +.ph-checkbox:active input:not(:checked)~span, +.ph-checkbox>input:not(:checked):focus-visible~span { + background-color: #e0e0e0; +} + +.ph-checkbox>input:checked~span { + background-color: rgba(94, 156, 125, 0.9); + border-color: #4a7d64; +} + +.ph-checkbox:hover input:checked~span { + background-color: #5e9c7d; +} + +.ph-checkbox:active input:checked~span, +.ph-checkbox>input:checked:focus-visible~span { + background-color: #4a7d64; +} + +.ph-checkbox>input:checked~span:after { + display: block; + color: #fff; +} + +/* checkbox inside max cnam output */ +#max_cnam_container { + position: relative; +} + +#max_cnam_container>input[type="number"] { + padding-left: 37px; + transition: outline-color 0.25s ease; + outline: solid 1px transparent; +} + +.input-invalid-blink { + border-color: #F44336 !important; + outline-color: #F44336 !important; +} + +#max_cnam_container>.ph-checkbox { + position: absolute; + margin: 6px 6px; +} + +/* Other styles */ +pre { + white-space: pre-line; +} + +.notvisible { + visibility: hidden; +} + +details { + padding: 5px; + background: #fffde7; + border: 1px solid #fff59d; + border-radius: 4px; +} + +summary { + cursor: pointer; + color: #616161; +} + +summary::-webkit-details-marker { + display: none; +} + +summary:before { + content: "►"; + font-size: 10px; + padding-right: 5px; +} + +details[open] summary:before { + content: "▼"; +} + +.col-md-9.flexible { + display: flex; + align-items: center; +} + +.header-img { + height: 30px; +} + +.ph-header { + display: flex; + align-items: center; + margin-bottom: 3px; +} + +.ph-header>.title, +.ph-header>.activation { + display: flex; + align-items: center; +} + +.ph-header>div>*:not(script):not(style) { + display: inline-flex; + flex-shrink: 0; +} + +.ph-header>div>*:not(:first-child) { + margin-left: 8px; +} + +.ph-header>.title { + flex: 1; + order: 1; +} + +.ph-header>.activation { + order: 2; +} + +.ph-header>.title>h2 { + margin-bottom: 0; +} + +ul.numeric-list { + list-style: decimal; +} + +ul.numeric-list> ::marker { + font-weight: bold; +} + +.item-info { + font-size: 12px; + color: #9e9e9e; + margin-left: 10px; +} + +.element-container.flexible { + display: flex; + align-items: center; + justify-content: space-between; +} + +.btn-magic { + display: inline-block; + margin: 5px 5px; +} + +.btn-magic>* { + text-decoration: none !important; +} + +.btn-magic>i { + padding-right: 9px; + font-size: 18px; + line-height: 18px; + vertical-align: middle; +} + +.btn-popup { + display: inline-block; + font-weight: bold; + text-decoration: underline; + color: #2196f3; +} + +.btn-popup:visited { + color: #2196f3; +} + +.btn-popup>i { + text-decoration: underline; + font-weight: bold; +} + +.btn-submit { + float: right; + margin: 10px 10px 5px 0; +} + +#restoredonationPopup>form>input { + min-width: 300px; +} + +.help-section { + display: inline-flex; + margin: 5px; + background: #f1fbfc; + border: 1px solid #b2ebf2; + border-radius: 4px; +} + +.help-section>* { + padding: 8px; +} + +.help-section>*:nth-child(odd) { + border-right: 1px solid #b2ebf2; +} + +.footer { + width: 100%; + margin: 0; + padding: 10px 0; + border: none; + font-size: 11px; + text-align: center; +} + +.footer>b:first-of-type { + display: inline-block; + margin-bottom: 4px; +} + +a:hover>i.fa { + text-decoration: underline; +} + +/* Nice rotating animation for spinners */ +.fa.fa-spinner.letsspin { + animation: 1.4s linear; + animation-name: spinner-fa; + animation-iteration-count: infinite; + animation-direction: normal; +} + +@keyframes spinner-fa { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* Carddav setup popup custom styles */ +#setupCarddav input[type=text], +#setupCarddav input[type=password] { + width: 100%; + min-width: 300px; +} + +.ui-dialog-content>form>.btn { + float: right; + margin: 10px 0; + font-size: inherit; +} + +#carddav_result { + user-select: none; + width: calc(100% - 4px); + border-collapse: collapse; + margin: 0 2px; +} + +#carddav_result tr { + height: 23px; + cursor: default; +} + +#carddav_result th, +#carddav_result td { + border: 1px solid #bdbdbd; + padding: 0 5px; +} + +#carddav_result th:nth-child(1), +#carddav_result th:nth-child(2), +#carddav_result td:nth-child(1), +#carddav_result td:nth-child(2) { + width: 0; + text-align: center; +} + +#carddav_result th:last-child, +#carddav_result td:last-child { + word-break: break-all; +} + +#carddav_result td>i.fa { + line-height: 23px; + font-size: 16px; + vertical-align: middle; + cursor: grab; +} + +.carddav-setup-line label { + margin-bottom: 0; + margin-right: 10px; +} + +#carddav_parameters tbody tr { + width: 100%; + display: inline-grid; + margin-bottom: 10px; +} + +.carddav_info { + margin: 3px 5px 0 5px; + font-size: 12px; + color: #757575; +} + +/* Notification UI */ +#notification-ui { + display: flex; + order: 3; + position: relative; + margin: 0 5px; +} + +#notification-ui p { + margin: 0; +} + +#notification-header { + cursor: pointer; + font-size: 25px; + border-radius: 0.3em; +} + +#notification-count { + position: absolute; + pointer-events: none; + display: flex; + justify-content: center; + vertical-align: middle; + height: 17px; + width: 17px; + right: -5px; + top: -3px; + z-index: 101; + background: #f44336; + color: #fff; + font-size: 12px; + line-height: 17px; + font-weight: bold; + border-radius: 17px; +} + +#notification-container { + position: absolute; + display: flex; + flex-direction: column; + pointer-events: none; + z-index: 99; + top: 38px; + right: -4px; + width: 35vw; + min-width: 600px; + max-width: 800px; + max-height: 60vh; + opacity: 0; + visibility: hidden; + border: 1px solid #bdbdbd; + border-radius: 0.3em; + background: #fff; + transition: all .25s ease; +} + +#notification-container::after, +#notification-container::before { + content: ''; + position: absolute; + display: block; + right: 5px; + width: 0; + height: 0; + border-style: solid; +} + +#notification-container::after { + top: -20px; + border-color: transparent transparent #fff transparent; + border-width: 10px; +} + +#notification-container::before { + top: -21px; + border-color: transparent transparent #bdbdbd transparent; + border-width: 10px; +} + +#notification-container>.delete-all { + pointer-events: all; + padding: 10px 14px; +} + +.bubble-container { + pointer-events: all; + max-height: calc(60vh - 20px); + padding: 0 10px 7px 10px; + overflow: scroll; +} + +#notification-ui.open>#notification-container { + visibility: visible; + opacity: 1; +} + +.notification-bubble { + padding: 10px 15px; + margin-bottom: 3px; + border-radius: 0.3em; + font-size: 14px; + line-height: 25px; + border: 1px solid #f5f5f5; + word-break: break-word; +} + +.notification-bubble.info { + background-color: #f1f8e9; +} + +.notification-bubble.error { + background-color: #ffcdd2; +} + +.notification-bubble.verbose { + background-color: #ddd; +} + +.notification-bubble>button, +#notification-container>.delete-all>button { + float: right; + border-radius: 12.5px; + font-weight: bold; + color: #fff; +} + +.notification-bubble>button { + display: inline-flex; + justify-content: center; + align-items: center; + height: 26px; + width: 26px; + margin: 0 -6px; +} + +.notification-bubble.info>button { + background-color: #b0ca93; + border: 1px solid #a6bf8b; +} + +.notification-bubble.error>button, +#notification-container>.delete-all>button { + background-color: #e57373; + border: 1px solid #ef9a9a; +} + +.notification-bubble.verbose>button { + background-color: #9e9e9e; + border: 1px solid #919191; +} + +.notification-bubble>button:hover, +#notification-container>.delete-all>button:hover { + background: #f44336; + border: 1px solid #f44336; +} + +.notification-bubble>button:focus, +#notification-container>.delete-all>button:focus { + background: #c15c5c; + border: 1px solid #c15c5c; +} + +.bell-shake { + animation: 10s linear; + animation-name: bell-shake; + animation-iteration-count: infinite; + animation-direction: normal; +} + +@keyframes bell-shake { + 0% { + transform: rotate(0deg); + } + + 94% { + transform: rotate(0deg); + } + + 95% { + transform: rotate(-5deg); + } + + 96% { + transform: rotate(5deg); + } + + 97% { + transform: rotate(-5deg); + } + + 98% { + transform: rotate(5deg); + } + + 99% { + transform: rotate(-5deg); + } + + 100% { + transform: rotate(0deg); + } +} + +@media screen and (max-width: 1023px) { + + /* set a minimum width for dialogs on small screens to prevent them from being ridiculous */ + .ui-dialog { + min-width: 80vw; + } +} + +@media screen and (min-width: 768px) { + .visible-mobile { + display: none; + } +} + +@media screen and (max-width: 767px) { + .visible-desktop { + display: none; + } +} + +@media screen and (max-width: 991px) { + #notification-ui { + order: 2; + } + + .ph-header>.activation { + order: 3; + } + + .ph-header { + flex-wrap: wrap; + } + + .ph-header>.activation { + margin-top: 8px; + } + + .ph-header>.title, + .ph-header>.activation { + width: 100%; + } + + #notification-ui { + margin-left: auto; + } + + #notification-container { + width: 94.4vw; + min-width: 0; + } + + .help-section { + display: flex; + justify-content: space-around; + } + + .help-section>*:nth-child(odd) { + border: none; + } +} + +/* Autoselect trick used for config popup */ +.autoselect_container { + width: 100% !important; + margin-top: 5px !important; + padding: 4px 10px !important; + border-radius: 3em !important; + border: 1px solid #e0e0e0 !important; + background: #fafafa !important; + box-shadow: none !important; + outline: none !important; + font-family: inherit !important; + font-size: inherit !important; + color: inherit !important; + text-overflow: ellipsis; +} \ No newline at end of file diff --git a/assets/css/tagify.css b/assets/css/tagify.css new file mode 100644 index 0000000..3516030 --- /dev/null +++ b/assets/css/tagify.css @@ -0,0 +1 @@ +@charset "UTF-8";:root{--tagify-dd-color-primary:rgb(53,149,246);--tagify-dd-bg-color:white;--tagify-dd-item-pad:.3em .5em}.tagify{--tags-disabled-bg:#F1F1F1;--tags-border-color:#DDD;--tags-hover-border-color:#CCC;--tags-focus-border-color:#3595f6;--tag-border-radius:3px;--tag-bg:#E5E5E5;--tag-hover:#D3E2E2;--tag-text-color:black;--tag-text-color--edit:black;--tag-pad:0.3em 0.5em;--tag-inset-shadow-size:1.1em;--tag-invalid-color:#D39494;--tag-invalid-bg:rgba(211, 148, 148, 0.5);--tag-remove-bg:rgba(211, 148, 148, 0.3);--tag-remove-btn-color:black;--tag-remove-btn-bg:none;--tag-remove-btn-bg--hover:#c77777;--input-color:inherit;--tag--min-width:1ch;--tag--max-width:auto;--tag-hide-transition:0.3s;--placeholder-color:rgba(0, 0, 0, 0.4);--placeholder-color-focus:rgba(0, 0, 0, 0.25);--loader-size:.8em;--readonly-striped:1;display:inline-flex;align-items:flex-start;flex-wrap:wrap;border:1px solid var(--tags-border-color);padding:0;line-height:0;cursor:text;outline:0;position:relative;box-sizing:border-box;transition:.1s}@keyframes tags--bump{30%{transform:scale(1.2)}}@keyframes rotateLoader{to{transform:rotate(1turn)}}.tagify:hover:not(.tagify--focus):not(.tagify--invalid){--tags-border-color:var(--tags-hover-border-color)}.tagify[disabled]{background:var(--tags-disabled-bg);filter:saturate(0);opacity:.5;pointer-events:none}.tagify[disabled].tagify--select,.tagify[readonly].tagify--select{pointer-events:none}.tagify[disabled]:not(.tagify--mix):not(.tagify--select),.tagify[readonly]:not(.tagify--mix):not(.tagify--select){cursor:default}.tagify[disabled]:not(.tagify--mix):not(.tagify--select)>.tagify__input,.tagify[readonly]:not(.tagify--mix):not(.tagify--select)>.tagify__input{visibility:hidden;width:0;margin:5px 0}.tagify[disabled]:not(.tagify--mix):not(.tagify--select) .tagify__tag>div,.tagify[readonly]:not(.tagify--mix):not(.tagify--select) .tagify__tag>div{padding:var(--tag-pad)}.tagify[disabled]:not(.tagify--mix):not(.tagify--select) .tagify__tag>div::before,.tagify[readonly]:not(.tagify--mix):not(.tagify--select) .tagify__tag>div::before{animation:readonlyStyles 1s calc(-1s * (var(--readonly-striped) - 1)) paused}@keyframes readonlyStyles{0%{background:linear-gradient(45deg,var(--tag-bg) 25%,transparent 25%,transparent 50%,var(--tag-bg) 50%,var(--tag-bg) 75%,transparent 75%,transparent) 0/5px 5px;box-shadow:none;filter:brightness(.95)}}.tagify[disabled] .tagify__tag__removeBtn,.tagify[readonly] .tagify__tag__removeBtn{display:none}.tagify--loading .tagify__input>br:last-child{display:none}.tagify--loading .tagify__input::before{content:none}.tagify--loading .tagify__input::after{content:"";vertical-align:middle;opacity:1;width:.7em;height:.7em;width:var(--loader-size);height:var(--loader-size);min-width:0;border:3px solid;border-color:#eee #bbb #888 transparent;border-radius:50%;animation:rotateLoader .4s infinite linear;content:""!important;margin:-2px 0 -2px .5em}.tagify--loading .tagify__input:empty::after{margin-left:0}.tagify+input,.tagify+textarea{position:absolute!important;left:-9999em!important;transform:scale(0)!important}.tagify__tag{display:inline-flex;align-items:center;margin:5px 0 5px 5px;position:relative;z-index:1;outline:0;line-height:normal;cursor:default;transition:.13s ease-out}.tagify__tag>div{vertical-align:top;box-sizing:border-box;max-width:100%;padding:var(--tag-pad);color:var(--tag-text-color);line-height:inherit;border-radius:var(--tag-border-radius);white-space:nowrap;transition:.13s ease-out}.tagify__tag>div>*{white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;display:inline-block;vertical-align:top;min-width:var(--tag--min-width);max-width:var(--tag--max-width);transition:.8s ease,.1s color}.tagify__tag>div>[contenteditable]{outline:0;-webkit-user-select:text;user-select:text;cursor:text;margin:-2px;padding:2px;max-width:350px}.tagify__tag>div::before{content:"";position:absolute;border-radius:inherit;inset:var(--tag-bg-inset,0);z-index:-1;pointer-events:none;transition:120ms ease;animation:tags--bump .3s ease-out 1;box-shadow:0 0 0 var(--tag-inset-shadow-size) var(--tag-bg) inset}.tagify__tag:focus div::before,.tagify__tag:hover:not([readonly]) div::before{--tag-bg-inset:-2.5px;--tag-bg:var(--tag-hover)}.tagify__tag--loading{pointer-events:none}.tagify__tag--loading .tagify__tag__removeBtn{display:none}.tagify__tag--loading::after{--loader-size:.4em;content:"";vertical-align:middle;opacity:1;width:.7em;height:.7em;width:var(--loader-size);height:var(--loader-size);min-width:0;border:3px solid;border-color:#eee #bbb #888 transparent;border-radius:50%;animation:rotateLoader .4s infinite linear;margin:0 .5em 0 -.1em}.tagify__tag--flash div::before{animation:none}.tagify__tag--hide{width:0!important;padding-left:0;padding-right:0;margin-left:0;margin-right:0;opacity:0;transform:scale(0);transition:var(--tag-hide-transition);pointer-events:none}.tagify__tag--hide>div>*{white-space:nowrap}.tagify__tag.tagify--noAnim>div::before{animation:none}.tagify__tag.tagify--notAllowed:not(.tagify__tag--editable) div>span{opacity:.5}.tagify__tag.tagify--notAllowed:not(.tagify__tag--editable) div::before{--tag-bg:var(--tag-invalid-bg);transition:.2s}.tagify__tag[readonly] .tagify__tag__removeBtn{display:none}.tagify__tag[readonly]>div::before{animation:readonlyStyles 1s calc(-1s * (var(--readonly-striped) - 1)) paused}@keyframes readonlyStyles{0%{background:linear-gradient(45deg,var(--tag-bg) 25%,transparent 25%,transparent 50%,var(--tag-bg) 50%,var(--tag-bg) 75%,transparent 75%,transparent) 0/5px 5px;box-shadow:none;filter:brightness(.95)}}.tagify__tag--editable>div{color:var(--tag-text-color--edit)}.tagify__tag--editable>div::before{box-shadow:0 0 0 2px var(--tag-hover) inset!important}.tagify__tag--editable>.tagify__tag__removeBtn{pointer-events:none}.tagify__tag--editable>.tagify__tag__removeBtn::after{opacity:0;transform:translateX(100%) translateX(5px)}.tagify__tag--editable.tagify--invalid>div::before{box-shadow:0 0 0 2px var(--tag-invalid-color) inset!important}.tagify__tag__removeBtn{order:5;display:inline-flex;align-items:center;justify-content:center;border-radius:50px;cursor:pointer;font:14px/1 Arial;background:var(--tag-remove-btn-bg);color:var(--tag-remove-btn-color);width:14px;height:14px;margin-right:4.6666666667px;margin-left:auto;overflow:hidden;transition:.2s ease-out}.tagify__tag__removeBtn::after{content:"×";transition:.3s,color 0s}.tagify__tag__removeBtn:hover{color:#fff;background:var(--tag-remove-btn-bg--hover)}.tagify__tag__removeBtn:hover+div>span{opacity:.5}.tagify__tag__removeBtn:hover+div::before{box-shadow:0 0 0 var(--tag-inset-shadow-size) var(--tag-remove-bg,rgba(211,148,148,.3)) inset!important;transition:box-shadow .2s}.tagify:not(.tagify--mix) .tagify__input br{display:none}.tagify:not(.tagify--mix) .tagify__input *{display:inline;white-space:nowrap}.tagify__input{flex-grow:1;display:inline-block;min-width:110px;margin:5px;padding:var(--tag-pad);line-height:normal;position:relative;white-space:pre-wrap;color:var(--input-color);box-sizing:inherit}.tagify__input:empty::before{position:static}.tagify__input:focus{outline:0}.tagify__input:focus::before{transition:.2s ease-out;opacity:0;transform:translatex(6px)}@supports (-ms-ime-align:auto){.tagify__input:focus::before{display:none}}.tagify__input:focus:empty::before{transition:.2s ease-out;opacity:1;transform:none;color:rgba(0,0,0,.25);color:var(--placeholder-color-focus)}@-moz-document url-prefix(){.tagify__input:focus:empty::after{display:none}}.tagify__input::before{content:attr(data-placeholder);height:1em;line-height:1em;margin:auto 0;z-index:1;color:var(--placeholder-color);white-space:nowrap;pointer-events:none;opacity:0;position:absolute}.tagify__input::after{content:attr(data-suggest);display:inline-block;vertical-align:middle;position:absolute;min-width:calc(100% - 1.5em);text-overflow:ellipsis;overflow:hidden;white-space:pre;color:var(--tag-text-color);opacity:.3;pointer-events:none;max-width:100px}.tagify__input .tagify__tag{margin:0 1px}.tagify--mix{display:block}.tagify--mix .tagify__input{padding:5px;margin:0;width:100%;height:100%;line-height:1.5;display:block}.tagify--mix .tagify__input::before{height:auto;display:none;line-height:inherit}.tagify--mix .tagify__input::after{content:none}.tagify--select::after{content:">";opacity:.5;position:absolute;top:50%;right:0;bottom:0;font:16px monospace;line-height:8px;height:8px;pointer-events:none;transform:translate(-150%,-50%) scaleX(1.2) rotate(90deg);transition:.2s ease-in-out}.tagify--select[aria-expanded=true]::after{transform:translate(-150%,-50%) rotate(270deg) scaleY(1.2)}.tagify--select .tagify__tag{position:absolute;top:0;right:1.8em;bottom:0}.tagify--select .tagify__tag div{display:none}.tagify--select .tagify__input{width:100%}.tagify--empty .tagify__input::before{transition:.2s ease-out;opacity:1;transform:none;display:inline-block;width:auto}.tagify--mix .tagify--empty .tagify__input::before{display:inline-block}.tagify--focus{--tags-border-color:var(--tags-focus-border-color);transition:0s}.tagify--invalid{--tags-border-color:#D39494}.tagify__dropdown{position:absolute;z-index:9999;transform:translateY(1px);overflow:hidden}.tagify__dropdown[placement=top]{margin-top:0;transform:translateY(-100%)}.tagify__dropdown[placement=top] .tagify__dropdown__wrapper{border-top-width:1.1px;border-bottom-width:0}.tagify__dropdown[position=text]{box-shadow:0 0 0 3px rgba(var(--tagify-dd-color-primary),.1);font-size:.9em}.tagify__dropdown[position=text] .tagify__dropdown__wrapper{border-width:1px}.tagify__dropdown__wrapper{max-height:300px;overflow:auto;overflow-x:hidden;background:var(--tagify-dd-bg-color);border:1px solid;border-color:var(--tagify-dd-color-primary);border-bottom-width:1.5px;border-top-width:0;box-shadow:0 2px 4px -2px rgba(0,0,0,.2);transition:.25s cubic-bezier(0,1,.5,1)}.tagify__dropdown__header:empty{display:none}.tagify__dropdown__footer{display:inline-block;margin-top:.5em;padding:var(--tagify-dd-item-pad);font-size:.7em;font-style:italic;opacity:.5}.tagify__dropdown__footer:empty{display:none}.tagify__dropdown--initial .tagify__dropdown__wrapper{max-height:20px;transform:translateY(-1em)}.tagify__dropdown--initial[placement=top] .tagify__dropdown__wrapper{transform:translateY(2em)}.tagify__dropdown__item{box-sizing:border-box;padding:var(--tagify-dd-item-pad);margin:1px;cursor:pointer;border-radius:2px;position:relative;outline:0;max-height:60px;max-width:100%}.tagify__dropdown__item--active{background:var(--tagify-dd-color-primary);color:#fff}.tagify__dropdown__item:active{filter:brightness(105%)}.tagify__dropdown__item--hidden{padding-top:0;padding-bottom:0;margin:0 1px;pointer-events:none;overflow:hidden;max-height:0;transition:var(--tagify-dd-item--hidden-duration,.3s)!important}.tagify__dropdown__item--hidden>*{transform:translateY(-100%);opacity:0;transition:inherit} \ No newline at end of file diff --git a/assets/css/timepicker.css b/assets/css/timepicker.css new file mode 100644 index 0000000..368a8ab --- /dev/null +++ b/assets/css/timepicker.css @@ -0,0 +1,143 @@ +/* + * CardDAV Middleware UI + * Written by Massi-X © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +.__timepicker-container { + display: flex; + height: 39px; + padding: 0; + background: #fff; + border: 1px solid #DDD; +} + +.__timepicker-container:hover, +.__timepicker-container:focus-within { + border-color: rgba(94, 156, 125, 0.9) !important; +} + +.__timepicker-div { + position: relative; + margin: 0; + flex: 1; + border-right: 1px solid #ddd; +} + +.__timepicker-div:last-child { + border-right: 0; +} + +.__timepicker-div>input { + width: 100%; + height: 100%; + pointer-events: none; + background: transparent; + border: 0; + padding-left: 66px; + outline: none !important; + box-shadow: none !important; + color: #000; +} + +.__timepicker-div>span { + position: absolute; + height: calc(100% - 3px); + cursor: default; + background: #fff; + right: 0; + margin-top: 3px; + margin-right: 1px; + padding: 0 6px; + color: #757575; +} + +.__timepicker-div>input::selection, +.__timepicker-div>span::selection { + background: transparent; +} + +.__timepicker-div>span:before { + content: ""; + display: inline-block; + height: 100%; + vertical-align: middle; +} + +.__timepicker-button-container { + position: absolute; + margin: 7px 9px; +} + +.__timepicker-button-container label { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + width: 24px; + cursor: pointer; + margin: 0; + border: 1px solid #959595; + border-radius: 0; + background: #fafafa; + color: #616161; +} + +.__timepicker-button-container label:hover { + background: #eee; +} + +.__timepicker-button-container label:active, +.__timepicker-button-container label:focus-visible { + outline: none; + background: #ddd; +} + +.__timepicker-button-container label:last-child { + border-left: 0; + border-top-right-radius: 0.2em; + border-bottom-right-radius: 0.2em; +} + +.__timepicker-button-container label:first-child { + border-top-left-radius: 0.2em; + border-bottom-left-radius: 0.2em; +} + +.__timepicker-arrow-down, +.__timepicker-arrow-up { + width: 0; + height: 0; + border-left: 7px solid transparent; + border-right: 7px solid transparent; +} + +.__timepicker-arrow-up { + border-bottom: 7px solid #616161; +} + +.__timepicker-arrow-down { + border-top: 7px solid #616161; +} + +@media screen and (max-width: 767px) { + .__timepicker-container { + display: block; + height: 117px; + } + + .__timepicker-div { + height: 39px; + border-bottom: 1px solid#ddd; + border-right: 0; + } + + .__timepicker-div:last-child { + border-bottom: 0; + } + + .__timepicker-div>span { + height: calc(100% - 9px); + margin-top: 5px; + } +} \ No newline at end of file diff --git a/assets/images/icon.png b/assets/images/icon.png new file mode 100644 index 0000000..9609c5d Binary files /dev/null and b/assets/images/icon.png differ diff --git a/assets/js/dragsort.js b/assets/js/dragsort.js new file mode 100644 index 0000000..1f7c551 --- /dev/null +++ b/assets/js/dragsort.js @@ -0,0 +1,318 @@ +;(function(root, factory){ + var define = define || {}; + if( typeof define === 'function' && define.amd ) + define([], factory); + else if( typeof exports === 'object' && typeof module === 'object' ) + module.exports = factory(); + else if(typeof exports === 'object') + exports["DragSort"] = factory() + else + root.DragSort = factory() +}(this, function(){ + var _id = 0, + _current = {}, // currently-dragged element + _instances = {} + + https://stackoverflow.com/a/14570614/104380 + var observeDOM = (function(){ + var MutationObserver = window.MutationObserver || window.WebKitMutationObserver; + + return function( obj, callback ){ + if( !obj || obj.nodeType !== 1 ) return; // validation + + if( MutationObserver ){ + // define a new observer + var obs = new MutationObserver(function(mutations, observer){ + callback(mutations) + }) + + obs.observe(obj, {childList:true, subtree:false}) + } + + else if( window.addEventListener ) + obj.addEventListener('DOMNodeInserted', callback, false) + } + })() + + function DragSort(elm, settings) { + if (!elm) return this; + + settings = settings || {} + this.parentElm = elm; + this.uid = settings.uid; + + this.settings = { + selector: '*', + callbacks: {} + } + + Object.assign(this.settings, settings) + + this.setup() + observeDOM(this.parentElm, this.setup.bind(this)) + this.bindEvents() + } + + DragSort.prototype = { + namespace: 'dragsort', + + setup() { + // remove non-element nodes + [...this.parentElm.childNodes].forEach(elm => { + if (elm.nodeType != 1) + return elm.parentNode.removeChild(elm) + // set the "draggable" property on what's left + // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/draggable + + if( elm.matches(this.settings.selector) ) + elm.draggable = true + }) + + this.gap = this.getItemsGap(this.parentElm.firstElementChild) + }, + + throttle(cb, limit) { + var wait = false; + var that = this; + return function (args) { + if (!wait) { + cb.call(that, args); + wait = true; + setTimeout(() => wait = false, limit) + } + } + }, + + getDraggableElm(elm) { + if( !elm.closest ) return null + + var draggableElm = elm.closest('[draggable="true"]') + // only allow dragging/dropping inside the same parent element + return (this.uid == _current.uid) ? draggableElm : null + }, + + dragstart(e, elm) { + _current = this + + var draggableElm = this.getDraggableElm(elm), + clientRect; + + if (!draggableElm) { + _current = {} + return + } + + this.source = this.getInitialState() + this.target = this.getInitialState() + + clientRect = draggableElm.getBoundingClientRect() // more accurate than offsetWidth/offsetHeight (not rounded) + + this.source.elm = draggableElm + this.source.idx = this.getNodeIndex(draggableElm) + this.source.size.width = clientRect.width + this.source.size.height = clientRect.height + + // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/effectAllowed + e.dataTransfer.effectAllowed = 'move' + + this.settings.callbacks.dragStart && this.settings.callbacks.dragStart(this.source.elm, e) + + // https://stackoverflow.com/q/19639969/104380 + setTimeout(this.afterDragStart.bind(this)) + }, + + afterDragStart() { + var prop = this.settings.mode == 'vertical' ? 'height' : 'width' + + this.parentElm.classList.add(`${this.namespace}--dragStart`) + + // hiding the source element with transition, the initial "width" is set to occupy the same space + this.source.elm.style[prop] = this.source.size[prop] + 'px' + + this.source.elm.classList.add(`${this.namespace}--dragElem`) + }, + + dragover(e) { + e.preventDefault(); + e.stopPropagation(); + + var elm = e.target; + + elm = this.getDraggableElm(elm) + + if (!elm || !this.target) return; + + var prevTarget = { + elm: this.target.elm, + hoverDirection: this.target.hoverDirection + } + + e.dataTransfer.dropEffect = "move"; + + this.target.hoverDirection = this.getTargetDirection(e); + // Continue only if there was a reason for a change + if (prevTarget.elm != elm || prevTarget.hoverDirection != this.target.hoverDirection) + this.directionAwareDragEnter(e, elm); + }, + + dragenter(e, elm) { + elm = this.getDraggableElm(elm); + + if (!elm || !this.target) return; + + if (!this.isValidElm(elm) || this.source.elm == elm || !this.source.elm) + return; + + this.target.bounding = elm.getBoundingClientRect(); + }, + + // only gets called once the mouse direction is knowsn (entering from left/right) + directionAwareDragEnter(e, elm) { + e.preventDefault(); + e.stopPropagation(); + + var idxDelta; + + e.dataTransfer.dropEffect = 'none'; + + if (!this.isValidElm(elm) || this.source.elm == elm || !this.source.elm) + return; + + e.dataTransfer.dropEffect = 'move'; // See the section on the DataTransfer object. + this.cleanupLastTarget(); + this.target.elm = elm; + this.target.idx = this.getNodeIndex(elm); + elm.classList.add('over'); + + // if target is same as the source, un-hide the source + idxDelta = Math.abs(this.target.idx - this.source.idx); + + this.source.elm.classList.toggle(`${this.namespace}--hide`, idxDelta > 0); + + // if( this.isTargetLastChild() && this.target.hoverDirection ) + // return; + + if (this.settings.mode == 'vertical') + this.target.elm.style[this.target.hoverDirection ? 'marginBottom' : 'marginTop'] = this.source.size.height + this.gap + 'px'; + else + this.target.elm.style[this.target.hoverDirection ? 'marginRight' : 'marginLeft'] = this.source.size.width + this.gap + 'px'; + }, + + dragend(e) { + clearTimeout(this.dragoverTimeout) + this.dragoverTimeout = null + this.parentElm.classList.remove(`${this.namespace}--dragStart`) + + if (!this.isValidElm(this.target.elm)) { + return this.cleanup() + } + + var insertBeforeElm = this.target.hoverDirection ? this.target.elm.nextElementSibling : this.target.elm; + + + if (this.source.elm != this.target.elm && this.target.elm) { + this.target.elm.classList.add(`${this.namespace}--noAnim`) + this.cleanup(); + this.parentElm.insertBefore(this.source.elm, insertBeforeElm); + } + + this.source.elm && this.source.elm.classList.remove(`${this.namespace}--dragElem`, `${this.namespace}--hide`) + this.settings.callbacks.dragEnd && this.settings.callbacks.dragEnd(this.source.elm) + + return this + }, + + ///////////////////////////// + + isTargetLastChild() { + return this.parentElm.lastElementChild == this.target.elm; + }, + + getTargetDirection(e) { + if (!this.target.bounding) return; + return this.settings.mode == 'vertical' ? + e.pageY > (this.target.bounding.top + this.target.bounding.height / 2) ? 1 : 0 : + e.pageX > (this.target.bounding.left + this.target.bounding.width / 2) ? 1 : 0 + }, + + getNodeIndex(node) { + var index = 0; + while ((node = node.previousSibling)) + if (node.nodeType != 3 || !/^\s*$/.test(node.data)) + index++; + return index; + }, + + isValidElm(elm) { + return elm && elm.nodeType && elm.parentNode == this.parentElm; + }, + + cleanup() { + _current = {}; + + [...this.parentElm.children].forEach(elm => { + elm.removeAttribute('style') + setTimeout(() => { + elm.classList.remove(`${this.namespace}--over`, `${this.namespace}--noAnim`, `${this.namespace}--dragElem`) + }, 50) + }) + + return; + }, + + cleanupLastTarget() { + if (this.target.elm) { + this.target.elm.classList.remove(`${this.namespace}--hide`, `${this.namespace}--over`) + this.target.elm.removeAttribute('style'); + } + }, + + getInitialState() { + return { + elm: null, + size: {} + } + }, + + getItemsGap(elm) { + var itemStyles = getComputedStyle(elm), + parentStyles = getComputedStyle(elm.parentNode), + v = this.settings.mode == 'vertical', + gap = parseInt(parentStyles.gap) || 0, // parent might be a flexbox witha a gap + marginGap = parseInt(itemStyles[`margin${v ? 'Top' : 'Left'}`]) + + parseInt(itemStyles[`margin${v ? 'Bottom' : 'Right'}`]) + + return marginGap + gap; + }, + + bindEvents(unbind) { + this.listeners = this.listeners || { + dragstart: e => this.dragstart(e, e.target), + dragenter: e => this.dragenter(e, e.target), + dragend : e => this.dragend(e, e.target), + dragover : this.throttle(this.dragover, 0), // see https://github.com/yairEO/dragsort/issues/5#issuecomment-1004465926 + } + + for (var method in this.listeners) + this.parentElm[unbind ? "removeEventListener" : "addEventListener"](method, this.listeners[method]) + }, + + destroy() { + this.cleanup() + this.bindEvents(true) + delete _instances[this.uid] + } + } + + ///////////////////////////////////// + // Factory + return function (elm, settings) { + // if this "elm" has already been initialized with DragSort, return last DragSort instance and do not create a new one + _instances[++_id] = elm["DragSort"] + ? _instances[elm["DragSort"]] + : new DragSort(elm, {...settings, uid: _id}) + + elm["DragSort"] = _id + return _instances[_id] + } +})); \ No newline at end of file diff --git a/assets/js/dragsort.js_LICENSE b/assets/js/dragsort.js_LICENSE new file mode 100644 index 0000000..fb11f0b --- /dev/null +++ b/assets/js/dragsort.js_LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 vsync + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/js/magicconfig.js b/assets/js/magicconfig.js new file mode 100644 index 0000000..e19a6cf --- /dev/null +++ b/assets/js/magicconfig.js @@ -0,0 +1,273 @@ +/* + * CardDAV Middleware UI + * Written by Massi-X © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +//declaration: new TimePicker(target log container, language array) +class MagicConfig { + #popup; + #startButton; + #logContainer; + #language; + + //call it to start the magic + constructor(popup, language) { + if (window.magicIsRunning) + return; + + window.magicIsRunning = true; + this.#language = language; + this.#popup = popup; + + //disallow closing the popup + this.#popup.dialog({ + beforeClose: () => false + }); + + this.#startButton = popup[0].querySelector('#magic_start'); //no check for existence + this.#logContainer = popup[0].querySelector('#magic_pre_container'); //no check for existence + + this.#startButton.setAttribute('disabled', 'disabled'); //disable start button + this.#logContainer.innerHTML = ''; //reset container + + this._step1(); //start + } + + //create/update superfecta scheme and print message step 1 + _step1() { + this.#logContainer.innerHTML += this.#language['JS_magic_step1'] + '\n'; + this.#logContainer.innerHTML += this.#language['JS_magic_step1_1'] + '\n'; + this.#logContainer.scrollIntoView(false); //scroll to bottom + + //send request + fetch('ajax.php?' + new URLSearchParams({ + module: phonemiddleware['ajax_name'], + command: 'superfectainit' + }), []) + .then(response => { + return this._resp_handler(response); + }) + .then(data => { + this._step2(data); + }, error => { + this._error(error.error.message); + }); + } + + //move superfecta scheme at top + _step2(data) { + if (data.status) { + this.#logContainer.innerHTML += data.message + '\n'; + this.#logContainer.scrollIntoView(false); //scroll to bottom + + fetch('ajax.php?' + new URLSearchParams({ + module: phonemiddleware['ajax_name'], + command: 'superfectareorder' + }), []).then(response => { + return this._resp_handler(response); + }).then(data => { + this._step3(data); + }, error => { + this._error(error.error.message); + }); + } + else this._error(data.message); + } + + //enable superfecta regex_2 + _step3(data) { + this.#logContainer.innerHTML += data.message + '\n'; //don't care about errors. For info why see Phonemiddleware.class.php + this.#logContainer.scrollIntoView(false); //scroll to bottom + + let formData = new FormData(); + formData.append('data[]', 'Regular_Expressions_2'); + let options = { + method: 'post', + body: formData + }; + + fetch('ajax.php?' + new URLSearchParams({ + module: 'superfecta', + command: 'update_sources', + scheme: phonemiddleware['SUPERFECTA_SCHEME'] + }), options).then(response => { + return this._resp_handler(response); + }).then(data => { + this._step4(data); + }, error => { + this._error(error.error.message); + }); + } + + //setup superfecta regex_2 + _step4(data) { + if (data.success) { + this.#logContainer.innerHTML += this.#language['JS_magic_step1_2'] + '\n'; + this.#logContainer.scrollIntoView(false); //scroll to bottom + + let formData = new FormData(); + formData.append('URL', phonemiddleware['numberToCnamURL']); + formData.append('Enable_SPAM_Match', 'on'); + phonemiddleware['SUPERFECTA_SCHEME_CONFIG'].forEach(elem => { + switch (elem['key']) { + case 'POST_Data': + formData.append('POST_Data', elem['value']); + break; + case 'Regular_Expressions': + formData.append('Regular_Expressions', elem['value']); + break; + case 'SPAM_Regular_Expressions': + formData.append('SPAM_Regular_Expressions', elem['value']); + break; + } + }); + let options = { + method: 'post', + body: formData + }; + + fetch('ajax.php?' + new URLSearchParams({ + module: 'superfecta', + command: 'save_options', + scheme: phonemiddleware['SUPERFECTA_SCHEME'], + source: 'Regular_Expressions_2' + }), options).then(response => { + return this._resp_handler(response); + }).then(data => { + this._step5(data); + }, error => { + this._error(error.error.message); + }); + } + else this._error(data.message); + } + + //setup outcnam and print message step 2 + _step5(data) { + if (data.status) { + this.#logContainer.innerHTML += this.#language['JS_magic_step1_3'] + '\n'; + this.#logContainer.innerHTML += '' + this.#language['JS_magic_step1_4'] + '\n'; + this.#logContainer.innerHTML += this.#language['JS_magic_step2'] + '\n'; + this.#logContainer.scrollIntoView(false); //scroll to bottom + + fetch('ajax.php?' + new URLSearchParams({ + module: phonemiddleware['ajax_name'], + command: 'outcnamsetup' + }), []).then(response => { + return this._resp_handler(response); + }).then(data => { + this._step6(data); + }, error => { + this._error(error.error.message); + }); + } + else this._error(data.message); + } + + //retrieve all inbound routes, iterate over them and print step 3 + _step6(data) { + if (data.status) { + this.#logContainer.innerHTML += data.message + '\n'; + this.#logContainer.innerHTML += this.#language['JS_magic_step3'] + '\n'; + this.#logContainer.innerHTML += this.#language['JS_magic_step3_1'] + '\n'; + this.#logContainer.scrollIntoView(false); //scroll to bottom + + fetch('ajax.php?' + new URLSearchParams({ + module: 'core', + command: 'getJSON', + jdata: 'allDID' + }), []).then(response => { + return this._resp_handler(response); + }).then(async data => { + if (Array.isArray(data)) + for (let i = 0; i < data.length; i++) { + const res = await this._step7Recursive(data[i]); + if (!res) { + window.magicIsRunning = false; + return; + } + } + else + this.#logContainer.innerHTML += '' + this.#language['JS_magic_step3_notfound'] + '\n'; + + this.#logContainer.innerHTML += '' + this.#language['JS_magic_completed'] + '\n'; + + //standard freepbx function to apply changes + fpbx_reload(); + + //re-enable everything + window.magicIsRunning = false; + this.#popup.dialog({ + beforeClose: () => true + }); + this.#startButton.removeAttribute('disabled'); + }, error => { + this._error(error.error.message); + }); + } + else this._error(data.message); + } + + //apply changes to the current inbound route + async _step7Recursive(route) { + //decode first + route['cidnum'] = decodeURIComponent(route['cidnum']); + route['extension'] = decodeURIComponent(route['extension']); + + this.#logContainer.innerHTML += this.#language['JS_magic_step3_2'] + .replace('%cid', route['cidnum'] ? route['cidnum'] : this.#language['undefined']) + .replace('%did', route['extension'] ? route['extension'] : this.#language['undefined']) + '\n'; + + let options = { + method: 'post', + body: JSON.stringify(route) + }; + + const res = await fetch('ajax.php?' + new URLSearchParams({ + module: phonemiddleware['ajax_name'], + command: 'inboundroutesetup' + }), options).then(response => { + return this._resp_handler(response); + }).then(data => { + if (data.status) { + this.#logContainer.innerHTML += data.message + '\n'; + this.#logContainer.scrollIntoView(false); //scroll to bottom + return true; + } + else { + this._error(data.message); + return false; + } + }, error => { + this._error(error.error.message); + return false; + }); + + return res; + } + + //error handler: print message + disable lock + _error(msg) { + this.#logContainer.innerHTML += '' + msg + '\n'; + this.#logContainer.innerHTML += '' + this.#language['JS_magic_error'] + '\n'; + this.#logContainer.scrollIntoView(false); //scroll to bottom + + //re-enable everything + window.magicIsRunning = false; + this.#popup.dialog({ + beforeClose: () => true + }); + this.#startButton.removeAttribute('disabled'); + } + + //response handler, convenient shortcut to reduce code + _resp_handler(response) { + if (!response.ok) { + return response.json().then(json => { + return Promise.reject(json); + }); + } else + return response.json(); + } +} \ No newline at end of file diff --git a/assets/js/scripts.js b/assets/js/scripts.js new file mode 100644 index 0000000..40e95e7 --- /dev/null +++ b/assets/js/scripts.js @@ -0,0 +1,520 @@ +/* + * CardDAV Middleware UI + * Written by Massi-X © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +//no check is done on the pm_language or other necessary variables. Please make sure they are present when including this file +document.addEventListener('DOMContentLoaded', () => { + /******************** GET ELEMENTS ********************/ + carddav_url = document.getElementById('carddav_url'); + carddav_user = document.getElementById('carddav_user'); + carddav_psw = document.getElementById('carddav_psw'); + carddav_validate = document.getElementById('carddav_validate'); + carddav_display_url = document.getElementById('carddav_display_url'); + carddav_result_tbody = document.getElementById('carddav_result').getElementsByTagName('tbody')[0]; + + /******************** START TAGIFY ********************/ + document.addEventListener('dragover', e => e.preventDefault()); + + //tagify output_construct + var output_construct = new Tagify(document.getElementById('output_construct'), { + whitelist: [{ + "value": "fn", + "name": pm_language['JS_fn'] + " (fn)" + }, + { + "value": "n", + "name": pm_language['Name'] + " (n)" + }, + { + "value": "nickname", + "name": pm_language['Nickname'] + " (nickname)" + }, + { + "value": "bday", + "name": pm_language['Birthday'] + " (bday)" + }, + { + "value": "adr", + "name": pm_language['Address'] + " (adr)" + }, + { + "value": "email", + "name": pm_language['JS_email_adr'] + " (email)" + }, + { + "value": "title", + "name": pm_language['Title'] + " (title)" + }, + { + "value": "role", + "name": pm_language['Role'] + " (role)" + }, + { + "value": "org", + "name": pm_language['JS_org'] + " (org)" + }, + ], + tagTextProp: 'name', + dropdown: { + mapValueTo: 'name', + maxItems: Infinity + }, + enforceWhitelist: true, + backspace: false, + userInput: false, + originalInputValueFormat: valuesArr => valuesArr.map(item => item.value).join(',') + }); + + //custom class for css styles + output_construct.toggleClass('output_construct__tagify'); + + //dragsort for output_construct + new DragSort(output_construct.DOM.scope, { + selector: '.' + output_construct.settings.classNames.tag, + callbacks: { + dragEnd: () => output_construct.updateValueByDOMTags() + } + }); + + //generate whitelist for phone_type + phone_type_whitelist = []; + let i = 1; + while (true) { + let name = pm_language['PHONE_TYPE_' + i]; + if (name === undefined) + break; + phone_type_whitelist.push({ 'value': i, 'name': name }); + ++i; + } + + //tagify phone_type + var tagify = new Tagify(document.getElementById('phone_type'), { + enforceWhitelist: true, + whitelist: phone_type_whitelist, + mode: "select", + backspace: false, + userInput: false, + tagTextProp: 'name', + dropdown: { + mapValueTo: 'name', + maxItems: Infinity + }, + originalInputValueFormat: valuesArr => valuesArr.map(item => item.value) + }); + + //custom class for css styles + tagify.toggleClass('phone_type__tagify'); + + tagify = new Tagify(document.getElementById('country_code'), { + enforceWhitelist: true, + whitelist: phonemiddleware['country_codes'], + callbacks: { + remove: e => { + e.detail.tagify['valueBeforeRemove'] = e.detail.data.value; + }, + blur: e => { + if (e.detail.tagify.value.length === 0) { + e.detail.tagify.state.dropdown.visible = false; //prevent dropdown from rifocusing everything up + e.detail.tagify.addTags(e.detail.tagify['valueBeforeRemove']); + } + }, + }, + mode: "select", + tagTextProp: 'name', + dropdown: { + mapValueTo: 'name', + searchKeys: ['name', 'value'], + maxItems: Infinity + }, + originalInputValueFormat: valuesArr => valuesArr.map(item => item.value) + }); + /******************** END TAGIFY ********************/ + + /******************** CARDDAV VALIDATION & SAVE ********************/ + //load data + validateCarddav(); + + //listener for sortable to set element width on window resize + window.resizeWidth = window.innerWidth; + window.addEventListener('resize', () => { + clearTimeout(window.resizeTimer); + window.resizeTimer = setTimeout(() => { + if (window.innerWidth !== window.resizeWidth) { + window.resizeWidth = window.innerWidth; + sortableListResize(); + } + }, 50); + }); + + //listener for sortable to set element width on dialog open + $('#setupCarddav').on('dialogopen', (event, ui) => { + sortableListResize(); + }); + /******************** END CARDDAV VALIDATION & SAVE ********************/ + + /******************** NOTIFICATION UI ********************/ + notificationHeader = document.getElementById('notification-header'); + notificationUI = document.getElementById('notification-ui'); + notificationCount = document.getElementById('notification-count'); + + //close notifications on click outside + window.addEventListener('click', ({ target }) => { + const popup = target.closest('#notification-ui'); + + if (notificationUI.classList.contains('open') && popup == null) + toggleNotification(true); + }); + + //parse timestamp into date-time + document.querySelectorAll('.notification-timestamp').forEach(elem => { + let timestamp = elem.innerHTML * 1000; //convert to millisecond + var date = new Date(timestamp); + + elem.innerHTML = date.toLocaleDateString() + ' ' + date.toLocaleTimeString(); + }); + /******************** END NOTIFICATION UI ********************/ + + /******************** INIT POPUPS ********************/ + $('#setupCarddav').dialog({ + autoOpen: false, + modal: true, + resizable: false, + draggable: false, + height: 'auto', + width: 'auto' + }); + $('#errorPopup').dialog({ + autoOpen: false, + modal: true, + resizable: false, + draggable: false, + height: 'auto', + width: 'auto' + }); + $('#licensePopupUI').dialog({ + autoOpen: false, + modal: true, + resizable: false, + draggable: false, + height: 'auto', + width: 'auto' + }); + $('#licensePopupCore').dialog({ + autoOpen: false, + modal: true, + resizable: false, + draggable: false, + height: 'auto', + width: 'auto' + }); + $('#librariesPopup').dialog({ + autoOpen: false, + modal: true, + resizable: false, + draggable: false, + height: 'auto', + width: 'auto' + }); + window.magicPopup = $('#magicPopup').dialog({ + autoOpen: false, + modal: true, + resizable: false, + draggable: false, + height: 'auto', + width: 'auto' + }); + /******************** END INIT POPUPS ********************/ + + /******************** LISTEN MAX_CNAM_OUTPUT CHECKBOX ********************/ + document.getElementById('max_cnam_length').onblur = e => { + let elem = e.target; + let min = parseInt(elem.getAttribute('min')); + let max = parseInt(elem.getAttribute('max')); + + if (!min && !max) + return; + + if (elem.value < min || elem.value > max) { + if (elem.value < min) + elem.value = min; + else if (elem.value > max) + elem.value = max; + + elem.classList.add('input-invalid-blink'); + setTimeout(() => elem.classList.remove('input-invalid-blink'), 1000); + } + }; + + document.querySelectorAll('input[type="checkbox"][data-onchange]').forEach(elem => { + let target = document.getElementById(elem.getAttribute('data-onchange')); + + elem.onchange = e => { + e.target.checked ? target.removeAttribute('disabled') : target.setAttribute('disabled', 'disabled'); + target.value = target.getAttribute('min'); + } + }); + /******************** END LISTEN MAX_CNAM_OUTPUT CHECKBOX ********************/ + + /******************** MISC ********************/ + //enable timepicker on cache_expire + new TimePicker(document.getElementById('cache_expire'), 15); + + //update notification title text + updateNotificationText(); + + //tabindex helper + document.querySelectorAll('.radioset > label').forEach(elem => { + elem.onkeydown = e => { + if (e.key === 'Enter') + elem.click(); + }; + }); + + //disallow spaces inside addressbook dialog inputs + carddav_url.oninput = carddav_user.oninput = carddav_psw.oninput = e => e.target.value = e.target.value.replace(/\s/g, ''); +}, false); + +/******************** CARDDAV VALIDATION & SAVE ********************/ +function validateCarddav() { + let isSave = false; + let checkboxes = document.querySelectorAll('input[type=checkbox][name="carddav_addressbooks[]"]'); //this changes at every request + let request_type = 'ajax.php?module=' + phonemiddleware['ajax_name'] + '&command='; + let formData = new FormData(); + let options = { + method: 'post', + body: formData + }; + + checkboxes.forEach(elem => { //determine if this is save + if (elem.checked) + isSave = true; + }); + + //construct base formdata request + formData.append('carddav_url', carddav_url.value); + formData.append('carddav_user', carddav_user.value); + formData.append('carddav_psw', carddav_psw.value); + + if (isSave) { + carddav_validate.innerHTML = ' ' + pm_language['Saving_dots']; + request_type += 'savecarddav'; + + for (let i = 0; i < checkboxes.length; ++i) //add checked URLs to request + if (checkboxes[i].checked) + formData.append('carddav_addressbooks[]', checkboxes[i].getAttribute('data-uri')); + } else { + if (carddav_result_tbody.classList.contains('ui-sortable')) + $(carddav_result_tbody).sortable("destroy"); //detach sortable before updating content + carddav_validate.innerHTML = ' ' + pm_language['Validating_dots']; + carddav_result_tbody.innerHTML = '' + pm_language['Loading_dots'] + ''; + request_type += 'validatecarddav'; + } + + //disable every input + carddav_validate.setAttribute('disabled', 'disabled'); + carddav_url.disabled = carddav_user.disabled = carddav_psw.disabled = 'disabled'; + + //send request + fetch(request_type, options) //absolute path to www folder SAME AS UNINSTALL.PHP AND INSTALL.PHP + .then(response => { + if (!response.ok) { + return response.json().then(json => { + return Promise.reject(json); + }); + } else + return response.json(); + }) + .then(data => { + if (isSave) { + carddav_display_url.value = carddav_url.value; + $('#setupCarddav').dialog('close'); + } else { + carddav_result_tbody.innerHTML = ''; //reset content + + if (data.message) //if some error is raised from above + carddav_result_tbody.innerHTML = '' + data.message + ''; + else if (data.length == 0) //or if nothing is found + carddav_result_tbody.innerHTML = '' + pm_language['No_addresbook_found'] + ''; + else { + data.forEach(item => { + carddav_result_tbody.innerHTML += + '' + item['name'] + '' + item['uri'] + ''; + }); + sortableListResize(); + initSortableList(); + } + } + changeCarddavButton(); + }, error => { + if (isSave) //only if there is an error during save alert the user + alert(error.error.message); + else //else we only show a generic no address book found inside the table + carddav_result_tbody.innerHTML = '' + pm_language['No_addresbook_found'] + ''; + changeCarddavButton(); + }); +} + +//this changed the button according to the current situation +function changeCarddavButton() { + carddav_validate.innerHTML = pm_language['Validate']; + carddav_validate.removeAttribute('disabled'); + carddav_url.disabled = carddav_user.disabled = carddav_psw.disabled = ''; + + document.querySelectorAll('input[type=checkbox][name="carddav_addressbooks[]"]').forEach(elem => { //this change at every request + if (elem.checked) { + carddav_validate.innerHTML = pm_language['Save']; + carddav_url.disabled = carddav_user.disabled = carddav_psw.disabled = 'disabled'; + } + }); +} + +//init sortable UI on carddav addressbooks +function initSortableList() { + $(carddav_result_tbody).sortable({ + //fix row width unwanted shrink + start: (event, ui) => { + //thanks to someone on SO for this (sorry lost the link!) + var cellCount = 0; + $('td, th', ui.helper).each(() => { + var colspan = 1; + var colspanAttr = $(this).attr('colspan'); + if (colspanAttr > 1) + colspan = colspanAttr; + cellCount += colspan; + }); + ui.placeholder.html(' '); + + //fix table "jump" + var height = ui.helper.outerHeight(); + ui.placeholder.height(height); + }, + containment: "parent", //prevent the row from going outside the table + axis: 'y', //only y movement + tolerance: "pointer", //pointer on other row will trigger the switch + revert: 100, //animation when drag ends + handle: '.fa' //self explains + }).disableSelection(); +} + +//handle window resize + initialization to avoid jumps when moving rows +function sortableListResize() { + document.querySelectorAll('#carddav_result th, #carddav_result td').forEach(elem => { + elem.style.width = ''; + }); + document.querySelectorAll('#carddav_result th, #carddav_result td').forEach(elem => { + elem.style.width = elem.offsetWidth + 'px'; + }); + document.querySelectorAll('#carddav_result tr').forEach(elem => { + elem.style.height = ''; + }); + document.querySelectorAll('#carddav_result tr').forEach(elem => { + elem.style.height = elem.offsetHeight + 'px'; + }); +} +/******************** END CARDDAV VALIDATION & SAVE ********************/ + +/******************** NOTIFICATION UI ********************/ +function toggleNotification(close) { //open/close notifications + if (close === undefined) + close = notificationUI.classList.contains('open'); + + if (close) { + if (!notificationUI.classList.contains('open')) + return; + + notificationUI.classList.toggle('open'); + } + else /* if (open) */ { + if (parseInt(notificationCount.getAttribute('data-count')) == 0 || notificationUI.classList.contains('open')) + return; + + notificationUI.classList.toggle('open'); + } +} + +function updateNotificationText(reset, decrementValue) { //update title text according to current count + if (decrementValue === undefined) + decrementValue = 0; + + let notificationTotal = parseInt(notificationCount.getAttribute('data-count')); + if (reset) + notificationTotal = 0; + else if (decrementValue < 0) + notificationTotal += decrementValue; + + notificationCount.setAttribute('data-count', notificationTotal); + notificationHeader.classList.add('bell-shake'); + notificationCount.innerHTML = notificationTotal; + + if (notificationTotal == 0) { + notificationHeader.classList.remove('bell-shake'); + toggleNotification(true); //close notification UI if count == 0 + } else if (notificationTotal > 9) + notificationCount.innerHTML = '9+'; +} + +function deleteNotification(elem) { //delete notification + //no confirm + let formData = new FormData(); + + formData.append('id', elem.getAttribute('data-notificationid')); + + let options = { + method: 'post', + body: formData + }; + + //send request + fetch('ajax.php?' + new URLSearchParams({ + module: phonemiddleware['ajax_name'], + command: 'deletenotification' + }), options).then(response => { + if (!response.ok) { + return response.json().then(json => { + return Promise.reject(json); + }); + } else + return response.json(); + }).then(() => { + elem.closest('.notification-bubble').remove(); + updateNotificationText(false, -1); + }, error => { + alert(error.error.message); + }); +} + +function deleteAllNotifications() { //delete ALL notifications + if (!confirm(pm_language['JS_confirm_delete_notifications'])) + return; + + let formData = new FormData(); + let options = { + method: 'post', + body: formData + }; + + //send request + fetch('ajax.php?' + new URLSearchParams({ + module: phonemiddleware['ajax_name'], + command: 'deleteallnotifications' + }), options).then(response => { + if (!response.ok) { + return response.json().then(json => { + return Promise.reject(json); + }); + } else + return response.json(); + }).then(() => { + document.querySelectorAll('.notification-bubble').forEach(elem => { + elem.remove(); + }); + updateNotificationText(true); + }, error => { + alert(error.error.message); + }); +} +/******************** END NOTIFICATION UI ********************/ diff --git a/assets/js/tagify.min.js b/assets/js/tagify.min.js new file mode 100644 index 0000000..111b776 --- /dev/null +++ b/assets/js/tagify.min.js @@ -0,0 +1,26 @@ +/** + * Tagify (v 4.16.4) - tags input component + * By undefined + * https://github.com/yairEO/tagify + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * THE SOFTWARE IS NOT PERMISSIBLE TO BE SOLD. + */ + + !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Tagify=e()}(this,(function(){"use strict";function t(t,e){var i=Object.keys(t);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(t);e&&(s=s.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),i.push.apply(i,s)}return i}function e(e){for(var s=1;s(t=""+t,e=""+e,s&&(t=t.trim(),e=e.trim()),i?t==e:t.toLowerCase()==e.toLowerCase()),a=(t,e)=>t&&Array.isArray(t)&&t.map((t=>n(t,e)));function n(t,e){var i,s={};for(i in t)e.indexOf(i)<0&&(s[i]=t[i]);return s}function o(t){var e=document.createElement("div");return t.replace(/\&#?[0-9a-z]+;/gi,(function(t){return e.innerHTML=t,e.innerText}))}function r(t){return(new DOMParser).parseFromString(t.trim(),"text/html").body.firstElementChild}function l(t,e){for(e=e||"previous";t=t[e+"Sibling"];)if(3==t.nodeType)return t}function d(t){return"string"==typeof t?t.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/`|'/g,"'"):t}function h(t){var e=Object.prototype.toString.call(t).split(" ")[1].slice(0,-1);return t===Object(t)&&"Array"!=e&&"Function"!=e&&"RegExp"!=e&&"HTMLUnknownElement"!=e}function g(t,e,i){function s(t,e){for(var i in e)if(e.hasOwnProperty(i)){if(h(e[i])){h(t[i])?s(t[i],e[i]):t[i]=Object.assign({},e[i]);continue}if(Array.isArray(e[i])){t[i]=Object.assign([],e[i]);continue}t[i]=e[i]}}return t instanceof Object||(t={}),s(t,e),i&&s(t,i),t}function p(){const t=[],e={};for(let i of arguments)for(let s of i)h(s)?e[s.value]||(t.push(s),e[s.value]=1):t.includes(s)||t.push(s);return t}function c(t){return String.prototype.normalize?"string"==typeof t?t.normalize("NFD").replace(/[\u0300-\u036f]/g,""):void 0:t}var u=()=>/(?=.*chrome)(?=.*android)/i.test(navigator.userAgent);function m(){return([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,(t=>(t^crypto.getRandomValues(new Uint8Array(1))[0]&15>>t/4).toString(16)))}function v(t){return t&&t.classList&&t.classList.contains(this.settings.classNames.tag)}var f={delimiters:",",pattern:null,tagTextProp:"value",maxTags:1/0,callbacks:{},addTagOnBlur:!0,onChangeAfterBlur:!0,duplicates:!1,whitelist:[],blacklist:[],enforceWhitelist:!1,userInput:!0,keepInvalidTags:!1,createInvalidTags:!0,mixTagsAllowedAfter:/,|\.|\:|\s/,mixTagsInterpolator:["[[","]]"],backspace:!0,skipInvalid:!1,pasteAsTags:!0,editTags:{clicks:2,keepInvalid:!0},transformTag:()=>{},trim:!0,a11y:{focusableTags:!1},mixMode:{insertAfterTag:" "},autoComplete:{enabled:!0,rightKey:!1},classNames:{namespace:"tagify",mixMode:"tagify--mix",selectMode:"tagify--select",input:"tagify__input",focus:"tagify--focus",tagNoAnimation:"tagify--noAnim",tagInvalid:"tagify--invalid",tagNotAllowed:"tagify--notAllowed",scopeLoading:"tagify--loading",hasMaxTags:"tagify--hasMaxTags",hasNoTags:"tagify--noTags",empty:"tagify--empty",inputInvalid:"tagify__input--invalid",dropdown:"tagify__dropdown",dropdownWrapper:"tagify__dropdown__wrapper",dropdownHeader:"tagify__dropdown__header",dropdownFooter:"tagify__dropdown__footer",dropdownItem:"tagify__dropdown__item",dropdownItemActive:"tagify__dropdown__item--active",dropdownItemHidden:"tagify__dropdown__item--hidden",dropdownInital:"tagify__dropdown--initial",tag:"tagify__tag",tagText:"tagify__tag-text",tagX:"tagify__tag__removeBtn",tagLoading:"tagify__tag--loading",tagEditing:"tagify__tag--editable",tagFlash:"tagify__tag--flash",tagHide:"tagify__tag--hide"},dropdown:{classname:"",enabled:2,maxItems:10,searchKeys:["value","searchBy"],fuzzySearch:!0,caseSensitive:!1,accentedSearch:!0,includeSelectedTags:!1,highlightFirst:!1,closeOnSelect:!0,clearOnSelect:!0,position:"all",appendTarget:null},hooks:{beforeRemoveTag:()=>Promise.resolve(),beforePaste:()=>Promise.resolve(),suggestionClick:()=>Promise.resolve()}};function T(){this.dropdown={};for(let t in this._dropdown)this.dropdown[t]="function"==typeof this._dropdown[t]?this._dropdown[t].bind(this):this._dropdown[t];this.dropdown.refs()}var w={refs(){this.DOM.dropdown=this.parseTemplate("dropdown",[this.settings]),this.DOM.dropdown.content=this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-wrapper']")},getHeaderRef(){return this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-header']")},getFooterRef(){return this.DOM.dropdown.querySelector("[data-selector='tagify-suggestions-footer']")},getAllSuggestionsRefs(){return[...this.DOM.dropdown.content.querySelectorAll(this.settings.classNames.dropdownItemSelector)]},show(t){var e,i,a,n=this.settings,o="mix"==n.mode&&!n.enforceWhitelist,r=!n.whitelist||!n.whitelist.length,l="manual"==n.dropdown.position;if(t=void 0===t?this.state.inputText:t,!(r&&!o&&!n.templates.dropdownItemNoMatch||!1===n.dropdown.enable||this.state.isLoading||this.settings.readonly)){if(clearTimeout(this.dropdownHide__bindEventsTimeout),this.suggestedListItems=this.dropdown.filterListItems(t),t&&!this.suggestedListItems.length&&(this.trigger("dropdown:noMatch",t),n.templates.dropdownItemNoMatch&&(a=n.templates.dropdownItemNoMatch.call(this,{value:t}))),!a){if(this.suggestedListItems.length)t&&o&&!this.state.editing.scope&&!s(this.suggestedListItems[0].value,t)&&this.suggestedListItems.unshift({value:t});else{if(!t||!o||this.state.editing.scope)return this.input.autocomplete.suggest.call(this),void this.dropdown.hide();this.suggestedListItems=[{value:t}]}i=""+(h(e=this.suggestedListItems[0])?e.value:e),n.autoComplete&&i&&0==i.indexOf(t)&&this.input.autocomplete.suggest.call(this,e)}this.dropdown.fill(a),n.dropdown.highlightFirst&&this.dropdown.highlightOption(this.DOM.dropdown.content.querySelector(n.classNames.dropdownItemSelector)),this.state.dropdown.visible||setTimeout(this.dropdown.events.binding.bind(this)),this.state.dropdown.visible=t||!0,this.state.dropdown.query=t,this.setStateSelection(),l||setTimeout((()=>{this.dropdown.position(),this.dropdown.render()})),setTimeout((()=>{this.trigger("dropdown:show",this.DOM.dropdown)}))}},hide(t){var e=this.DOM,i=e.scope,s=e.dropdown,a="manual"==this.settings.dropdown.position&&!t;if(s&&document.body.contains(s)&&!a)return window.removeEventListener("resize",this.dropdown.position),this.dropdown.events.binding.call(this,!1),i.setAttribute("aria-expanded",!1),s.parentNode.removeChild(s),setTimeout((()=>{this.state.dropdown.visible=!1}),100),this.state.dropdown.query=this.state.ddItemData=this.state.ddItemElm=this.state.selection=null,this.state.tag&&this.state.tag.value.length&&(this.state.flaggedTags[this.state.tag.baseOffset]=this.state.tag),this.trigger("dropdown:hide",s),this},toggle(t){this.dropdown[this.state.dropdown.visible&&!t?"hide":"show"]()},render(){var t,e,i,s=(t=this.DOM.dropdown,(i=t.cloneNode(!0)).style.cssText="position:fixed; top:-9999px; opacity:0",document.body.appendChild(i),e=i.clientHeight,i.parentNode.removeChild(i),e),a=this.settings;return"number"==typeof a.dropdown.enabled&&a.dropdown.enabled>=0?(this.DOM.scope.setAttribute("aria-expanded",!0),document.body.contains(this.DOM.dropdown)||(this.DOM.dropdown.classList.add(a.classNames.dropdownInital),this.dropdown.position(s),a.dropdown.appendTarget.appendChild(this.DOM.dropdown),setTimeout((()=>this.DOM.dropdown.classList.remove(a.classNames.dropdownInital)))),this):this},fill(t){t="string"==typeof t?t:this.dropdown.createListHTML(t||this.suggestedListItems);var e,i=this.settings.templates.dropdownContent.call(this,t);this.DOM.dropdown.content.innerHTML=(e=i)?e.replace(/\>[\r\n ]+\<").replace(/(<.*?>)|\s+/g,((t,e)=>e||" ")):""},fillHeaderFooter(){this.settings.templates;var t=this.dropdown.filterListItems(this.state.dropdown.query),e=this.parseTemplate("dropdownHeader",[t]),i=this.parseTemplate("dropdownFooter",[t]),s=this.dropdown.getHeaderRef(),a=this.dropdown.getFooterRef();e&&s?.parentNode.replaceChild(e,s),i&&a?.parentNode.replaceChild(i,a)},refilter(t){t=t||this.state.dropdown.query||"",this.suggestedListItems=this.dropdown.filterListItems(t),this.dropdown.fill(),this.suggestedListItems.length||this.dropdown.hide(),this.trigger("dropdown:updated",this.DOM.dropdown)},position(t){var e=this.settings.dropdown;if("manual"!=e.position){var i,s,a,n,o,r,l=this.DOM.dropdown,d=e.placeAbove,h=e.appendTarget===document.body,g=h?window.pageYOffset:e.appendTarget.scrollTop,p=document.fullscreenElement||document.webkitFullscreenElement||document.documentElement,c=p.clientHeight,u=Math.max(p.clientWidth||0,window.innerWidth||0)>480?e.position:"all",m=this.DOM["input"==u?"input":"scope"];if(t=t||l.clientHeight,this.state.dropdown.visible){if("text"==u?(a=(i=this.getCaretGlobalPosition()).bottom,s=i.top,n=i.left,o="auto"):(r=function(t){for(var e=0,i=0;t&&t!=p;)e+=t.offsetLeft||0,i+=t.offsetTop||0,t=t.parentNode;return{left:e,top:i}}(e.appendTarget),s=(i=m.getBoundingClientRect()).top-r.top,a=i.bottom-1-r.top,n=i.left-r.left,o=i.width+"px"),!h){let t=function(){for(var t=0,i=e.appendTarget.parentNode;i;)t+=i.scrollTop||0,i=i.parentNode;return t}();s+=t,a+=t}s=Math.floor(s),a=Math.ceil(a),d=void 0===d?c-i.bottom0&&void 0!==arguments[0])||arguments[0];var e=this.dropdown.events.callbacks,i=this.listeners.dropdown=this.listeners.dropdown||{position:this.dropdown.position.bind(this,null),onKeyDown:e.onKeyDown.bind(this),onMouseOver:e.onMouseOver.bind(this),onMouseLeave:e.onMouseLeave.bind(this),onClick:e.onClick.bind(this),onScroll:e.onScroll.bind(this)},s=t?"addEventListener":"removeEventListener";"manual"!=this.settings.dropdown.position&&(document[s]("scroll",i.position,!0),window[s]("resize",i.position),window[s]("keydown",i.onKeyDown)),this.DOM.dropdown[s]("mouseover",i.onMouseOver),this.DOM.dropdown[s]("mouseleave",i.onMouseLeave),this.DOM.dropdown[s]("mousedown",i.onClick),this.DOM.dropdown.content[s]("scroll",i.onScroll)},callbacks:{onKeyDown(t){if(this.state.hasFocus&&!this.state.composing){var e=this.DOM.dropdown.querySelector(this.settings.classNames.dropdownItemActiveSelector),i=this.dropdown.getSuggestionDataByNode(e);switch(t.key){case"ArrowDown":case"ArrowUp":case"Down":case"Up":t.preventDefault();var s=this.dropdown.getAllSuggestionsRefs(),a="ArrowUp"==t.key||"Up"==t.key;e&&(e=this.dropdown.getNextOrPrevOption(e,!a)),e&&e.matches(this.settings.classNames.dropdownItemSelector)||(e=s[a?s.length-1:0]),i=this.dropdown.getSuggestionDataByNode(e),this.dropdown.highlightOption(e,!0);break;case"Escape":case"Esc":this.dropdown.hide();break;case"ArrowRight":if(this.state.actions.ArrowLeft)return;case"Tab":if("mix"!=this.settings.mode&&e&&!this.settings.autoComplete.rightKey&&!this.state.editing){t.preventDefault();var n=this.dropdown.getMappedValue(i);return this.input.autocomplete.set.call(this,n),!1}return!0;case"Enter":t.preventDefault(),this.settings.hooks.suggestionClick(t,{tagify:this,tagData:i,suggestionElm:e}).then((()=>{if(e)return this.dropdown.selectOption(e),e=this.dropdown.getNextOrPrevOption(e,!a),void this.dropdown.highlightOption(e);this.dropdown.hide(),"mix"!=this.settings.mode&&this.addTags(this.state.inputText.trim(),!0)})).catch((t=>t));break;case"Backspace":{if("mix"==this.settings.mode||this.state.editing.scope)return;const t=this.input.raw.call(this);""!=t&&8203!=t.charCodeAt(0)||(!0===this.settings.backspace?this.removeTags():"edit"==this.settings.backspace&&setTimeout(this.editTag.bind(this),0))}}}},onMouseOver(t){var e=t.target.closest(this.settings.classNames.dropdownItemSelector);e&&this.dropdown.highlightOption(e)},onMouseLeave(t){this.dropdown.highlightOption()},onClick(t){if(0==t.button&&t.target!=this.DOM.dropdown&&t.target!=this.DOM.dropdown.content){var e=t.target.closest(this.settings.classNames.dropdownItemSelector),i=this.dropdown.getSuggestionDataByNode(e);this.state.actions.selectOption=!0,setTimeout((()=>this.state.actions.selectOption=!1),50),this.settings.hooks.suggestionClick(t,{tagify:this,tagData:i,suggestionElm:e}).then((()=>{e?this.dropdown.selectOption(e,t):this.dropdown.hide()})).catch((t=>console.warn(t)))}},onScroll(t){var e=t.target,i=e.scrollTop/(e.scrollHeight-e.parentNode.clientHeight)*100;this.trigger("dropdown:scroll",{percentage:Math.round(i)})}}},getSuggestionDataByNode(t){var e=t&&t.getAttribute("value");return this.suggestedListItems.find((t=>t.value==e))||null},getNextOrPrevOption(t){let e=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];var i=this.dropdown.getAllSuggestionsRefs(),s=i.findIndex((e=>e===t));return e?i[s+1]:i[s-1]},highlightOption(t,e){var i,s=this.settings.classNames.dropdownItemActive;if(this.state.ddItemElm&&(this.state.ddItemElm.classList.remove(s),this.state.ddItemElm.removeAttribute("aria-selected")),!t)return this.state.ddItemData=null,this.state.ddItemElm=null,void this.input.autocomplete.suggest.call(this);i=this.dropdown.getSuggestionDataByNode(t),this.state.ddItemData=i,this.state.ddItemElm=t,t.classList.add(s),t.setAttribute("aria-selected",!0),e&&(t.parentNode.scrollTop=t.clientHeight+t.offsetTop-t.parentNode.clientHeight),this.settings.autoComplete&&(this.input.autocomplete.suggest.call(this,i),this.dropdown.position())},selectOption(t,e){var i=this.settings.dropdown,s=i.clearOnSelect,a=i.closeOnSelect;if(!t)return this.addTags(this.state.inputText,!0),void(a&&this.dropdown.hide());e=e||{};var n=t.getAttribute("value"),o="noMatch"==n,r=this.suggestedListItems.find((t=>(t.value||t)==n));this.trigger("dropdown:select",{data:r,elm:t,event:e}),n&&(r||o)?(this.state.editing?this.onEditTagDone(null,g({__isValid:!0},this.normalizeTags([r])[0])):this["mix"==this.settings.mode?"addMixTags":"addTags"]([r||this.input.raw.call(this)],s),this.DOM.input.parentNode&&(setTimeout((()=>{this.DOM.input.focus(),this.toggleFocusClass(!0),this.setRangeAtStartEnd(!1)})),a&&setTimeout(this.dropdown.hide.bind(this)),t.addEventListener("transitionend",(()=>{this.dropdown.fillHeaderFooter(),setTimeout((()=>t.remove()),100)}),{once:!0}),t.classList.add(this.settings.classNames.dropdownItemHidden))):a&&setTimeout(this.dropdown.hide.bind(this))},selectAll(t){this.suggestedListItems.length=0,this.dropdown.hide(),this.dropdown.filterListItems("");var e=this.dropdown.filterListItems("");return t||(e=this.state.dropdown.suggestions),this.addTags(e,!0),this},filterListItems(t,e){var i,s,a,n,o,r=this.settings,l=r.dropdown,d=(e=e||{},[]),g=[],p=r.whitelist,u=l.maxItems>=0?l.maxItems:1/0,m=l.searchKeys,v=0;if(!(t="select"==r.mode&&this.value.length&&this.value[0][r.tagTextProp]==t?"":t)||!m.length)return d=l.includeSelectedTags?p:p.filter((t=>!this.isTagDuplicate(h(t)?t.value:t))),this.state.dropdown.suggestions=d,d.slice(0,u);function f(t,e){return e.toLowerCase().split(" ").every((e=>t.includes(e.toLowerCase())))}for(o=l.caseSensitive?""+t:(""+t).toLowerCase();vm.includes(t)))?["value"]:m;l.fuzzySearch&&!e.exact?(a=u.reduce(((t,e)=>t+" "+(i[e]||"")),"").toLowerCase().trim(),l.accentedSearch&&(a=c(a),o=c(o)),t=0==a.indexOf(o),r=a===o,s=f(a,o)):(t=!0,s=u.some((t=>{var s=""+(i[t]||"");return l.accentedSearch&&(s=c(s),o=c(o)),l.caseSensitive||(s=s.toLowerCase()),r=s===o,e.exact?s===o:0==s.indexOf(o)}))),n=!l.includeSelectedTags&&this.isTagDuplicate(h(i)?i.value:i),s&&!n&&(r&&t?g.push(i):"startsWith"==l.sortby&&t?d.unshift(i):d.push(i))}return this.state.dropdown.suggestions=g.concat(d),"function"==typeof l.sortby?l.sortby(g.concat(d),o):g.concat(d).slice(0,u)},getMappedValue(t){var e=this.settings.dropdown.mapValueTo;return e?"function"==typeof e?e(t):t[e]||t.value:t.value},createListHTML(t){return g([],t).map(((t,i)=>{"string"!=typeof t&&"number"!=typeof t||(t={value:t});var s=this.dropdown.getMappedValue(t);return s="string"==typeof s?d(s):s,this.settings.templates.dropdownItem.apply(this,[e(e({},t),{},{mappedValue:s}),this])})).join("")}};const b="@yaireo/tagify/";var y,x={empty:"empty",exceed:"number of tags exceeded",pattern:"pattern mismatch",duplicate:"already exists",notAllowed:"not allowed"},D={wrapper:(t,e)=>`\n \n ​\n `,tag(t,e){let i=e.settings;return`\n \n
\n ${t[i.tagTextProp]||t.value}\n
\n
`},dropdown(t){var e=t.dropdown,i="manual"==e.position,s=`${t.classNames.dropdown}`;return`
\n
\n
`},dropdownContent(t){var e=this.settings,i=this.state.dropdown.suggestions;return`\n ${e.templates.dropdownHeader.call(this,i)}\n ${t}\n ${e.templates.dropdownFooter.call(this,i)}\n `},dropdownItem(t){return`
${t.mappedValue||t.value}
`},dropdownHeader(t){return`
`},dropdownFooter(t){var e=t.length-this.settings.dropdown.maxItems;return e>0?`
\n ${e} more items. Refine your search.\n
`:""},dropdownItemNoMatch:null};var O={customBinding(){this.customEventsList.forEach((t=>{this.on(t,this.settings.callbacks[t])}))},binding(){let t=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];var e,i=this.events.callbacks,s=t?"addEventListener":"removeEventListener";if(!this.state.mainEvents||!t){for(var a in this.state.mainEvents=t,t&&!this.listeners.main&&(this.events.bindGlobal.call(this),this.settings.isJQueryPlugin&&jQuery(this.DOM.originalInput).on("tagify.removeAllTags",this.removeAllTags.bind(this))),e=this.listeners.main=this.listeners.main||{focus:["input",i.onFocusBlur.bind(this)],keydown:["input",i.onKeydown.bind(this)],click:["scope",i.onClickScope.bind(this)],dblclick:["scope",i.onDoubleClickScope.bind(this)],paste:["input",i.onPaste.bind(this)],drop:["input",i.onDrop.bind(this)],compositionstart:["input",i.onCompositionStart.bind(this)],compositionend:["input",i.onCompositionEnd.bind(this)]})this.DOM[e[a][0]][s](a,e[a][1]);clearInterval(this.listeners.main.originalInputValueObserverInterval),this.listeners.main.originalInputValueObserverInterval=setInterval(i.observeOriginalInputValue.bind(this),500);var n=this.listeners.main.inputMutationObserver||new MutationObserver(i.onInputDOMChange.bind(this));n&&n.disconnect(),"mix"==this.settings.mode&&n.observe(this.DOM.input,{childList:!0})}},bindGlobal(t){var e,i=this.events.callbacks,s=t?"removeEventListener":"addEventListener";if(t||!this.listeners.global)for(e of(this.listeners.global=this.listeners&&this.listeners.global||[{type:this.isIE?"keydown":"input",target:this.DOM.input,cb:i[this.isIE?"onInputIE":"onInput"].bind(this)},{type:"keydown",target:window,cb:i.onWindowKeyDown.bind(this)},{type:"blur",target:this.DOM.input,cb:i.onFocusBlur.bind(this)}],this.listeners.global))e.target[s](e.type,e.cb)},unbindGlobal(){this.events.bindGlobal.call(this,!0)},callbacks:{onFocusBlur(t){var e=this.settings,i=t.target?this.trim(t.target.textContent):"",s=this.value?.[0]?.[e.tagTextProp],a=t.type,n=e.dropdown.enabled>=0,o={relatedTarget:t.relatedTarget},r=this.state.actions.selectOption&&(n||!e.dropdown.closeOnSelect),l=this.state.actions.addNew&&n,d=t.relatedTarget&&v.call(this,t.relatedTarget)&&this.DOM.scope.contains(t.relatedTarget);if("blur"==a){if(t.relatedTarget===this.DOM.scope)return this.dropdown.hide(),void this.DOM.input.focus();this.postUpdate(),e.onChangeAfterBlur&&this.triggerChangeEvent()}if(!r&&!l)if(this.state.hasFocus="focus"==a&&+new Date,this.toggleFocusClass(this.state.hasFocus),"mix"!=e.mode){if("focus"==a)return this.trigger("focus",o),void(0!==e.dropdown.enabled&&e.userInput||this.dropdown.show(this.value.length?"":void 0));"blur"==a&&(this.trigger("blur",o),this.loading(!1),"select"==e.mode&&(d&&(this.removeTags(),i=""),s===i&&(i="")),i&&!this.state.actions.selectOption&&e.addTagOnBlur&&this.addTags(i,!0)),this.DOM.input.removeAttribute("style"),this.dropdown.hide()}else"focus"==a?this.trigger("focus",o):"blur"==t.type&&(this.trigger("blur",o),this.loading(!1),this.dropdown.hide(),this.state.dropdown.visible=void 0,this.setStateSelection())},onCompositionStart(t){this.state.composing=!0},onCompositionEnd(t){this.state.composing=!1},onWindowKeyDown(t){var e,i=document.activeElement;if(v.call(this,i)&&this.DOM.scope.contains(document.activeElement))switch(e=i.nextElementSibling,t.key){case"Backspace":this.settings.readonly||(this.removeTags(i),(e||this.DOM.input).focus());break;case"Enter":setTimeout(this.editTag.bind(this),0,i)}},onKeydown(t){var e=this.settings;if(!this.state.composing&&e.userInput){"select"==e.mode&&e.enforceWhitelist&&this.value.length&&"Tab"!=t.key&&t.preventDefault();var i=this.trim(t.target.textContent);if(this.trigger("keydown",{event:t}),"mix"==e.mode){switch(t.key){case"Left":case"ArrowLeft":this.state.actions.ArrowLeft=!0;break;case"Delete":case"Backspace":if(this.state.editing)return;var s,a,n,r=document.getSelection(),d="Delete"==t.key&&r.anchorOffset==(r.anchorNode.length||0),h=r.anchorNode.previousSibling,g=1==r.anchorNode.nodeType||!r.anchorOffset&&h&&1==h.nodeType&&r.anchorNode.previousSibling,p=o(this.DOM.input.innerHTML),c=this.getTagElms();if("edit"==e.backspace&&g)return s=1==r.anchorNode.nodeType?null:r.anchorNode.previousElementSibling,setTimeout(this.editTag.bind(this),0,s),void t.preventDefault();if(u()&&g)return n=l(g),g.hasAttribute("readonly")||g.remove(),this.DOM.input.focus(),void setTimeout((()=>{this.placeCaretAfterNode(n),this.DOM.input.click()}));if("BR"==r.anchorNode.nodeName)return;if((d||g)&&1==r.anchorNode.nodeType?a=0==r.anchorOffset?d?c[0]:null:c[Math.min(c.length,r.anchorOffset)-1]:d?a=r.anchorNode.nextElementSibling:g&&(a=g),3==r.anchorNode.nodeType&&!r.anchorNode.nodeValue&&r.anchorNode.previousElementSibling&&t.preventDefault(),(g||d)&&!e.backspace)return void t.preventDefault();if("Range"!=r.type&&!r.anchorOffset&&r.anchorNode==this.DOM.input&&"Delete"!=t.key)return void t.preventDefault();if("Range"!=r.type&&a&&a.hasAttribute("readonly"))return void this.placeCaretAfterNode(l(a));clearTimeout(y),y=setTimeout((()=>{var t=document.getSelection(),e=o(this.DOM.input.innerHTML),i=!d&&t.anchorNode.previousSibling;if(e.length>=p.length&&i)if(v.call(this,i)&&!i.hasAttribute("readonly")){if(this.removeTags(i),this.fixFirefoxLastTagNoCaret(),2==this.DOM.input.children.length&&"BR"==this.DOM.input.children[1].tagName)return this.DOM.input.innerHTML="",this.value.length=0,!0}else i.remove();this.value=[].map.call(c,((t,e)=>{var i=this.tagData(t);if(t.parentNode||i.readonly)return i;this.trigger("remove",{tag:t,index:e,data:i})})).filter((t=>t))}),20)}return!0}switch(t.key){case"Backspace":"select"==e.mode&&e.enforceWhitelist&&this.value.length?this.removeTags():this.state.dropdown.visible&&"manual"!=e.dropdown.position||""!=t.target.textContent&&8203!=i.charCodeAt(0)||(!0===e.backspace?this.removeTags():"edit"==e.backspace&&setTimeout(this.editTag.bind(this),0));break;case"Esc":case"Escape":if(this.state.dropdown.visible)return;t.target.blur();break;case"Down":case"ArrowDown":this.state.dropdown.visible||this.dropdown.show();break;case"ArrowRight":{let t=this.state.inputSuggestion||this.state.ddItemData;if(t&&e.autoComplete.rightKey)return void this.addTags([t],!0);break}case"Tab":{let s="select"==e.mode;if(!i||s)return!0;t.preventDefault()}case"Enter":if(this.state.dropdown.visible&&"manual"!=e.dropdown.position)return;t.preventDefault(),setTimeout((()=>{this.state.actions.selectOption||this.addTags(i,!0)}))}}},onInput(t){this.postUpdate();var e=this.settings;if("mix"==e.mode)return this.events.callbacks.onMixTagsInput.call(this,t);var i=this.input.normalize.call(this),s=i.length>=e.dropdown.enabled,a={value:i,inputElm:this.DOM.input},n=this.validateTag({value:i});"select"==e.mode&&this.toggleScopeValidation(n),a.isValid=n,this.state.inputText!=i&&(this.input.set.call(this,i,!1),-1!=i.search(e.delimiters)?this.addTags(i)&&this.input.set.call(this):e.dropdown.enabled>=0&&this.dropdown[s?"show":"hide"](i),this.trigger("input",a))},onMixTagsInput(t){var e,i,s,a,n,o,r,l,d=this.settings,h=this.value.length,p=this.getTagElms(),c=document.createDocumentFragment(),m=window.getSelection().getRangeAt(0),v=[].map.call(p,(t=>this.tagData(t).value));if("deleteContentBackward"==t.inputType&&u()&&this.events.callbacks.onKeydown.call(this,{target:t.target,key:"Backspace"}),this.value.slice().forEach((t=>{t.readonly&&!v.includes(t.value)&&c.appendChild(this.createTagElem(t))})),c.childNodes.length&&(m.insertNode(c),this.setRangeAtStartEnd(!1,c.lastChild)),p.length!=h)return this.value=[].map.call(this.getTagElms(),(t=>this.tagData(t))),void this.update({withoutChangeEvent:!0});if(this.hasMaxTags())return!0;if(window.getSelection&&(o=window.getSelection()).rangeCount>0&&3==o.anchorNode.nodeType){if((m=o.getRangeAt(0).cloneRange()).collapse(!0),m.setStart(o.focusNode,0),s=(e=m.toString().slice(0,m.endOffset)).split(d.pattern).length-1,(i=e.match(d.pattern))&&(a=e.slice(e.lastIndexOf(i[i.length-1]))),a){if(this.state.actions.ArrowLeft=!1,this.state.tag={prefix:a.match(d.pattern)[0],value:a.replace(d.pattern,"")},this.state.tag.baseOffset=o.baseOffset-this.state.tag.value.length,l=this.state.tag.value.match(d.delimiters))return this.state.tag.value=this.state.tag.value.replace(d.delimiters,""),this.state.tag.delimiters=l[0],this.addTags(this.state.tag.value,d.dropdown.clearOnSelect),void this.dropdown.hide();n=this.state.tag.value.length>=d.dropdown.enabled;try{r=(r=this.state.flaggedTags[this.state.tag.baseOffset]).prefix==this.state.tag.prefix&&r.value[0]==this.state.tag.value[0],this.state.flaggedTags[this.state.tag.baseOffset]&&!this.state.tag.value&&delete this.state.flaggedTags[this.state.tag.baseOffset]}catch(t){}(r||s{this.update({withoutChangeEvent:!0}),this.trigger("input",g({},this.state.tag,{textContent:this.DOM.input.textContent})),this.state.tag&&this.dropdown[n?"show":"hide"](this.state.tag.value)}),10)},onInputIE(t){var e=this;setTimeout((function(){e.events.callbacks.onInput.call(e,t)}))},observeOriginalInputValue(){this.DOM.originalInput.parentNode||this.destroy(),this.DOM.originalInput.value!=this.DOM.originalInput.tagifyValue&&this.loadOriginalValues()},onClickScope(t){var e=this.settings,i=t.target.closest("."+e.classNames.tag),s=+new Date-this.state.hasFocus;if(t.target!=this.DOM.scope){if(!t.target.classList.contains(e.classNames.tagX))return i?(this.trigger("click",{tag:i,index:this.getNodeIndex(i),data:this.tagData(i),event:t}),void(1!==e.editTags&&1!==e.editTags.clicks||this.events.callbacks.onDoubleClickScope.call(this,t))):void(t.target==this.DOM.input&&("mix"==e.mode&&this.fixFirefoxLastTagNoCaret(),s>500)?this.state.dropdown.visible?this.dropdown.hide():0===e.dropdown.enabled&&"mix"!=e.mode&&this.dropdown.show(this.value.length?"":void 0):"select"==e.mode&&!this.state.dropdown.visible&&this.dropdown.show());this.removeTags(t.target.parentNode)}else this.state.hasFocus||this.DOM.input.focus()},onPaste(t){t.preventDefault();var e,i,s=this.settings;if("select"==s.mode&&s.enforceWhitelist||!s.userInput)return!1;s.readonly||(e=t.clipboardData||window.clipboardData,i=e.getData("Text"),s.hooks.beforePaste(t,{tagify:this,pastedText:i,clipboardData:e}).then((e=>{void 0===e&&(e=i),e&&(this.injectAtCaret(e,window.getSelection().getRangeAt(0)),"mix"==this.settings.mode?this.events.callbacks.onMixTagsInput.call(this,t):this.settings.pasteAsTags?this.addTags(this.state.inputText+e,!0):this.state.inputText=e)})).catch((t=>t)))},onDrop(t){t.preventDefault()},onEditTagInput(t,e){var i=t.closest("."+this.settings.classNames.tag),s=this.getNodeIndex(i),a=this.tagData(i),n=this.input.normalize.call(this,t),o={[this.settings.tagTextProp]:n,__tagId:a.__tagId},r=this.validateTag(o);this.editTagChangeDetected(g(a,o))||!0!==t.originalIsValid||(r=!0),i.classList.toggle(this.settings.classNames.tagInvalid,!0!==r),a.__isValid=r,i.title=!0===r?a.title||a.value:r,n.length>=this.settings.dropdown.enabled&&(this.state.editing&&(this.state.editing.value=n),this.dropdown.show(n)),this.trigger("edit:input",{tag:i,index:s,data:g({},this.value[s],{newValue:n}),event:e})},onEditTagFocus(t){this.state.editing={scope:t,input:t.querySelector("[contenteditable]")}},onEditTagBlur(t){if(this.state.hasFocus||this.toggleFocusClass(),this.DOM.scope.contains(t)){var e,i,s=this.settings,a=t.closest("."+s.classNames.tag),n=this.input.normalize.call(this,t),o=this.tagData(a),r=o.__originalData,l=this.editTagChangeDetected(o),d=this.validateTag({[s.tagTextProp]:n,__tagId:o.__tagId});if(n)if(l){if(e=this.hasMaxTags(),i=g({},r,{[s.tagTextProp]:this.trim(n),__isValid:d}),s.transformTag.call(this,i,r),!0!==(d=(!e||!0===r.__isValid)&&this.validateTag(i))){if(this.trigger("invalid",{data:i,tag:a,message:d}),s.editTags.keepInvalid)return;s.keepInvalidTags?i.__isValid=d:i=r}else s.keepInvalidTags&&(delete i.title,delete i["aria-invalid"],delete i.class);this.onEditTagDone(a,i)}else this.onEditTagDone(a,r);else this.onEditTagDone(a)}},onEditTagkeydown(t,e){if(!this.state.composing)switch(this.trigger("edit:keydown",{event:t}),t.key){case"Esc":case"Escape":e.parentNode.replaceChild(e.__tagifyTagData.__originalHTML,e),this.state.editing=!1;case"Enter":case"Tab":t.preventDefault(),t.target.blur()}},onDoubleClickScope(t){var e,i,s=t.target.closest("."+this.settings.classNames.tag),a=this.tagData(s),n=this.settings;s&&n.userInput&&!1!==a.editable&&(e=s.classList.contains(this.settings.classNames.tagEditing),i=s.hasAttribute("readonly"),"select"==n.mode||n.readonly||e||i||!this.settings.editTags||this.editTag(s),this.toggleFocusClass(!0),this.trigger("dblclick",{tag:s,index:this.getNodeIndex(s),data:this.tagData(s)}))},onInputDOMChange(t){t.forEach((t=>{t.addedNodes.forEach((t=>{if("

"==t.outerHTML)t.replaceWith(document.createElement("br"));else if(1==t.nodeType&&t.querySelector(this.settings.classNames.tagSelector)){let e=document.createTextNode("");3==t.childNodes[0].nodeType&&"BR"!=t.previousSibling.nodeName&&(e=document.createTextNode("\n")),t.replaceWith(e,...[...t.childNodes].slice(0,-1)),this.placeCaretAfterNode(e)}else if(v.call(this,t)&&(3!=t.previousSibling?.nodeType||t.previousSibling.textContent||t.previousSibling.remove(),t.previousSibling&&"BR"==t.previousSibling.nodeName)){t.previousSibling.replaceWith("\n​");let e=t.nextSibling,i="";for(;e;)i+=e.textContent,e=e.nextSibling;i.trim()&&this.placeCaretAfterNode(t.previousSibling)}})),t.removedNodes.forEach((t=>{t&&"BR"==t.nodeName&&v.call(this,e)&&(this.removeTags(e),this.fixFirefoxLastTagNoCaret())}))}));var e=this.DOM.input.lastChild;e&&""==e.nodeValue&&e.remove(),e&&"BR"==e.nodeName||this.DOM.input.appendChild(document.createElement("br"))}}};function M(t,e){if(!t){console.warn("Tagify:","input element not found",t);const e=new Proxy(this,{get:()=>()=>e});return e}if(t.__tagify)return console.warn("Tagify: ","input element is already Tagified - Same instance is returned.",t),t.__tagify;var i;g(this,function(t){var e=document.createTextNode("");function i(t,i,s){s&&i.split(/\s+/g).forEach((i=>e[t+"EventListener"].call(e,i,s)))}return{off(t,e){return i("remove",t,e),this},on(t,e){return e&&"function"==typeof e&&i("add",t,e),this},trigger(i,s,a){var n;if(a=a||{cloneData:!0},i)if(t.settings.isJQueryPlugin)"remove"==i&&(i="removeTag"),jQuery(t.DOM.originalInput).triggerHandler(i,[s]);else{try{var o="object"==typeof s?s:{value:s};if((o=a.cloneData?g({},o):o).tagify=this,s.event&&(o.event=this.cloneEvent(s.event)),s instanceof Object)for(var r in s)s[r]instanceof HTMLElement&&(o[r]=s[r]);n=new CustomEvent(i,{detail:o})}catch(t){console.warn(t)}e.dispatchEvent(n)}}}}(this)),this.isFirefox="undefined"!=typeof InstallTrigger,this.isIE=window.document.documentMode,e=e||{},this.getPersistedData=(i=e.id,t=>{let e,s="/"+t;if(1==localStorage.getItem(b+i+"/v",1))try{e=JSON.parse(localStorage[b+i+s])}catch(t){}return e}),this.setPersistedData=(t=>t?(localStorage.setItem(b+t+"/v",1),(e,i)=>{let s="/"+i,a=JSON.stringify(e);e&&i&&(localStorage.setItem(b+t+s,a),dispatchEvent(new Event("storage")))}):()=>{})(e.id),this.clearPersistedData=(t=>e=>{const i=b+"/"+t+"/";if(e)localStorage.removeItem(i+e);else for(let t in localStorage)t.includes(i)&&localStorage.removeItem(t)})(e.id),this.applySettings(t,e),this.state={inputText:"",editing:!1,composing:!1,actions:{},mixMode:{},dropdown:{},flaggedTags:{}},this.value=[],this.listeners={},this.DOM={},this.build(t),T.call(this),this.getCSSVars(),this.loadOriginalValues(),this.events.customBinding.call(this),this.events.binding.call(this),t.autofocus&&this.DOM.input.focus(),t.__tagify=this}return M.prototype={_dropdown:w,helpers:{sameStr:s,removeCollectionProp:a,omit:n,isObject:h,parseHTML:r,escapeHTML:d,extend:g,concatWithoutDups:p,getUID:m,isNodeTag:v},customEventsList:["change","add","remove","invalid","input","click","keydown","focus","blur","edit:input","edit:beforeUpdate","edit:updated","edit:start","edit:keydown","dropdown:show","dropdown:hide","dropdown:select","dropdown:updated","dropdown:noMatch","dropdown:scroll"],dataProps:["__isValid","__removed","__originalData","__originalHTML","__tagId"],trim(t){return this.settings.trim&&t&&"string"==typeof t?t.trim():t},parseHTML:r,templates:D,parseTemplate(t,e){return t=this.settings.templates[t]||t,this.parseHTML(t.apply(this,e))},set whitelist(t){const e=t&&Array.isArray(t);this.settings.whitelist=e?t:[],this.setPersistedData(e?t:[],"whitelist")},get whitelist(){return this.settings.whitelist},generateClassSelectors(t){for(let e in t){let i=e;Object.defineProperty(t,i+"Selector",{get(){return"."+this[i].split(" ")[0]}})}},applySettings(t,i){f.templates=this.templates;var s=this.settings=g({},f,i);if(s.disabled=t.hasAttribute("disabled"),s.readonly=s.readonly||t.hasAttribute("readonly"),s.placeholder=d(t.getAttribute("placeholder")||s.placeholder||""),s.required=t.hasAttribute("required"),this.generateClassSelectors(s.classNames),void 0===s.dropdown.includeSelectedTags&&(s.dropdown.includeSelectedTags=s.duplicates),this.isIE&&(s.autoComplete=!1),["whitelist","blacklist"].forEach((e=>{var i=t.getAttribute("data-"+e);i&&(i=i.split(s.delimiters))instanceof Array&&(s[e]=i)})),"autoComplete"in i&&!h(i.autoComplete)&&(s.autoComplete=f.autoComplete,s.autoComplete.enabled=i.autoComplete),"mix"==s.mode&&(s.autoComplete.rightKey=!0,s.delimiters=i.delimiters||null,s.tagTextProp&&!s.dropdown.searchKeys.includes(s.tagTextProp)&&s.dropdown.searchKeys.push(s.tagTextProp)),t.pattern)try{s.pattern=new RegExp(t.pattern)}catch(t){}if(s.delimiters){s._delimiters=s.delimiters;try{s.delimiters=new RegExp(this.settings.delimiters,"g")}catch(t){}}s.disabled&&(s.userInput=!1),this.TEXTS=e(e({},x),s.texts||{}),"select"!=s.mode&&s.userInput||(s.dropdown.enabled=0),s.dropdown.appendTarget=i.dropdown&&i.dropdown.appendTarget?i.dropdown.appendTarget:document.body;let a=this.getPersistedData("whitelist");Array.isArray(a)&&(this.whitelist=Array.isArray(s.whitelist)?p(s.whitelist,a):a)},getAttributes(t){var e,i=this.getCustomAttributes(t),s="";for(e in i)s+=" "+e+(void 0!==t[e]?`="${i[e]}"`:"");return s},getCustomAttributes(t){if(!h(t))return"";var e,i={};for(e in t)"__"!=e.slice(0,2)&&"class"!=e&&t.hasOwnProperty(e)&&void 0!==t[e]&&(i[e]=d(t[e]));return i},setStateSelection(){var t=window.getSelection(),e={anchorOffset:t.anchorOffset,anchorNode:t.anchorNode,range:t.getRangeAt&&t.rangeCount&&t.getRangeAt(0)};return this.state.selection=e,e},getCaretGlobalPosition(){const t=document.getSelection();if(t.rangeCount){const e=t.getRangeAt(0),i=e.startContainer,s=e.startOffset;let a,n;if(s>0)return n=document.createRange(),n.setStart(i,s-1),n.setEnd(i,s),a=n.getBoundingClientRect(),{left:a.right,top:a.top,bottom:a.bottom};if(i.getBoundingClientRect)return i.getBoundingClientRect()}return{left:-9999,top:-9999}},getCSSVars(){var t=getComputedStyle(this.DOM.scope,null);var e;this.CSSVars={tagHideTransition:(t=>{let e=t.value;return"s"==t.unit?1e3*e:e})(function(t){if(!t)return{};var e=(t=t.trim().split(" ")[0]).split(/\d+/g).filter((t=>t)).pop().trim();return{value:+t.split(e).filter((t=>t))[0].trim(),unit:e}}((e="tag-hide-transition",t.getPropertyValue("--"+e))))}},build(t){var e=this.DOM;this.settings.mixMode.integrated?(e.originalInput=null,e.scope=t,e.input=t):(e.originalInput=t,e.originalInput_tabIndex=t.tabIndex,e.scope=this.parseTemplate("wrapper",[t,this.settings]),e.input=e.scope.querySelector(this.settings.classNames.inputSelector),t.parentNode.insertBefore(e.scope,t),t.tabIndex=-1)},destroy(){this.events.unbindGlobal.call(this),this.DOM.scope.parentNode.removeChild(this.DOM.scope),this.DOM.originalInput.tabIndex=this.DOM.originalInput_tabIndex,delete this.DOM.originalInput.__tagify,this.dropdown.hide(!0),clearTimeout(this.dropdownHide__bindEventsTimeout),clearInterval(this.listeners.main.originalInputValueObserverInterval)},loadOriginalValues(t){var e,i=this.settings;if(this.state.blockChangeEvent=!0,void 0===t){const e=this.getPersistedData("value");t=e&&!this.DOM.originalInput.value?e:i.mixMode.integrated?this.DOM.input.textContent:this.DOM.originalInput.value}if(this.removeAllTags(),t)if("mix"==i.mode)this.parseMixTags(this.trim(t)),(e=this.DOM.input.lastChild)&&"BR"==e.tagName||this.DOM.input.insertAdjacentHTML("beforeend","
");else{try{JSON.parse(t)instanceof Array&&(t=JSON.parse(t))}catch(t){}this.addTags(t,!0).forEach((t=>t&&t.classList.add(i.classNames.tagNoAnimation)))}else this.postUpdate();this.state.lastOriginalValueReported=i.mixMode.integrated?"":this.DOM.originalInput.value,this.state.blockChangeEvent=!1},cloneEvent(t){var e={};for(var i in t)"path"!=i&&(e[i]=t[i]);return e},loading(t){return this.state.isLoading=t,this.DOM.scope.classList[t?"add":"remove"](this.settings.classNames.scopeLoading),this},tagLoading(t,e){return t&&t.classList[e?"add":"remove"](this.settings.classNames.tagLoading),this},toggleClass(t,e){"string"==typeof t&&this.DOM.scope.classList.toggle(t,e)},toggleScopeValidation(t){var e=!0===t||void 0===t;!this.settings.required&&t&&t===this.TEXTS.empty&&(e=!0),this.toggleClass(this.settings.classNames.tagInvalid,!e),this.DOM.scope.title=e?"":t},toggleFocusClass(t){this.toggleClass(this.settings.classNames.focus,!!t)},triggerChangeEvent:function(){if(!this.settings.mixMode.integrated){var t=this.DOM.originalInput,e=this.state.lastOriginalValueReported!==t.value,i=new CustomEvent("change",{bubbles:!0});e&&(this.state.lastOriginalValueReported=t.value,i.simulated=!0,t._valueTracker&&t._valueTracker.setValue(Math.random()),t.dispatchEvent(i),this.trigger("change",this.state.lastOriginalValueReported),t.value=this.state.lastOriginalValueReported)}},events:O,fixFirefoxLastTagNoCaret(){},placeCaretAfterNode(t){if(t&&t.parentNode){var e=t,i=window.getSelection(),s=i.getRangeAt(0);i.rangeCount&&(s.setStartAfter(e||t),s.collapse(!0),i.removeAllRanges(),i.addRange(s))}},insertAfterTag(t,e){if(e=e||this.settings.mixMode.insertAfterTag,t&&t.parentNode&&e)return e="string"==typeof e?document.createTextNode(e):e,t.parentNode.insertBefore(e,t.nextSibling),e},editTagChangeDetected(t){var e=t.__originalData;for(var i in e)if(!this.dataProps.includes(i)&&t[i]!=e[i])return!0;return!1},getTagTextNode(t){return t.querySelector(this.settings.classNames.tagTextSelector)},setTagTextNode(t,e){this.getTagTextNode(t).innerHTML=d(e)},editTag(t,e){t=t||this.getLastTag(),e=e||{},this.dropdown.hide();var i=this.settings,s=this.getTagTextNode(t),a=this.getNodeIndex(t),n=this.tagData(t),o=this.events.callbacks,r=this,l=!0;if(s){if(!(n instanceof Object&&"editable"in n)||n.editable)return n=this.tagData(t,{__originalData:g({},n),__originalHTML:t.cloneNode(!0)}),this.tagData(n.__originalHTML,n.__originalData),s.setAttribute("contenteditable",!0),t.classList.add(i.classNames.tagEditing),s.addEventListener("focus",o.onEditTagFocus.bind(this,t)),s.addEventListener("blur",(function(){setTimeout((()=>o.onEditTagBlur.call(r,r.getTagTextNode(t))))})),s.addEventListener("input",o.onEditTagInput.bind(this,s)),s.addEventListener("keydown",(e=>o.onEditTagkeydown.call(this,e,t))),s.addEventListener("compositionstart",o.onCompositionStart.bind(this)),s.addEventListener("compositionend",o.onCompositionEnd.bind(this)),e.skipValidation||(l=this.editTagToggleValidity(t)),s.originalIsValid=l,this.trigger("edit:start",{tag:t,index:a,data:n,isValid:l}),s.focus(),this.setRangeAtStartEnd(!1,s),this}else console.warn("Cannot find element in Tag template: .",i.classNames.tagTextSelector)},editTagToggleValidity(t,e){var i;if(e=e||this.tagData(t))return(i=!("__isValid"in e)||!0===e.__isValid)||this.removeTagsFromValue(t),this.update(),t.classList.toggle(this.settings.classNames.tagNotAllowed,!i),e.__isValid;console.warn("tag has no data: ",t,e)},onEditTagDone(t,e){e=e||{};var i={tag:t=t||this.state.editing.scope,index:this.getNodeIndex(t),previousData:this.tagData(t),data:e};this.trigger("edit:beforeUpdate",i,{cloneData:!1}),this.state.editing=!1,delete e.__originalData,delete e.__originalHTML,t&&e[this.settings.tagTextProp]?(t=this.replaceTag(t,e),this.editTagToggleValidity(t,e),this.settings.a11y.focusableTags?t.focus():this.placeCaretAfterNode(t)):t&&this.removeTags(t),this.trigger("edit:updated",i),this.dropdown.hide(),this.settings.keepInvalidTags&&this.reCheckInvalidTags()},replaceTag(t,e){e&&e.value||(e=t.__tagifyTagData),e.__isValid&&1!=e.__isValid&&g(e,this.getInvalidTagAttrs(e,e.__isValid));var i=this.createTagElem(e);return t.parentNode.replaceChild(i,t),this.updateValueByDOMTags(),i},updateValueByDOMTags(){this.value.length=0,[].forEach.call(this.getTagElms(),(t=>{t.classList.contains(this.settings.classNames.tagNotAllowed.split(" ")[0])||this.value.push(this.tagData(t))})),this.update()},setRangeAtStartEnd(t,e){t="number"==typeof t?t:!!t,e=(e=e||this.DOM.input).lastChild||e;var i=document.getSelection();try{i.rangeCount>=1&&["Start","End"].forEach((s=>i.getRangeAt(0)["set"+s](e,t||e.length)))}catch(t){}},injectAtCaret(t,e){return!(e=e||this.state.selection?.range)&&t?(this.appendMixTags(t),this):("string"==typeof t&&(t=document.createTextNode(t)),e.deleteContents(),e.insertNode(t),this.setRangeAtStartEnd(!1,t),this.updateValueByDOMTags(),this.update(),this)},input:{set(){let t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",e=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];var i=this.settings.dropdown.closeOnSelect;this.state.inputText=t,e&&(this.DOM.input.innerHTML=d(""+t)),!t&&i&&this.dropdown.hide.bind(this),this.input.autocomplete.suggest.call(this),this.input.validate.call(this)},raw(){return this.DOM.input.textContent},validate(){var t=!this.state.inputText||!0===this.validateTag({value:this.state.inputText});return this.DOM.input.classList.toggle(this.settings.classNames.inputInvalid,!t),t},normalize(t){var e=t||this.DOM.input,i=[];e.childNodes.forEach((t=>3==t.nodeType&&i.push(t.nodeValue))),i=i.join("\n");try{i=i.replace(/(?:\r\n|\r|\n)/g,this.settings.delimiters.source.charAt(0))}catch(t){}return i=i.replace(/\s/g," "),this.trim(i)},autocomplete:{suggest(t){if(this.settings.autoComplete.enabled){"string"==typeof(t=t||{})&&(t={value:t});var e=t.value?""+t.value:"",i=e.substr(0,this.state.inputText.length).toLowerCase(),s=e.substring(this.state.inputText.length);e&&this.state.inputText&&i==this.state.inputText.toLowerCase()?(this.DOM.input.setAttribute("data-suggest",s),this.state.inputSuggestion=t):(this.DOM.input.removeAttribute("data-suggest"),delete this.state.inputSuggestion)}},set(t){var e=this.DOM.input.getAttribute("data-suggest"),i=t||(e?this.state.inputText+e:null);return!!i&&("mix"==this.settings.mode?this.replaceTextWithNode(document.createTextNode(this.state.tag.prefix+i)):(this.input.set.call(this,i),this.setRangeAtStartEnd()),this.input.autocomplete.suggest.call(this),this.dropdown.hide(),!0)}}},getTagIdx(t){return this.value.findIndex((e=>e.__tagId==(t||{}).__tagId))},getNodeIndex(t){var e=0;if(t)for(;t=t.previousElementSibling;)e++;return e},getTagElms(){for(var t=arguments.length,e=new Array(t),i=0;it?(e&&(t.__tagifyTagData=i?e:g({},t.__tagifyTagData||{},e)),t.__tagifyTagData):(console.warn("tag element doesn't exist",t,e),e),isTagDuplicate(t,e,i){var a=0;if("select"==this.settings.mode)return!1;for(let n of this.value){s(this.trim(""+t),n.value,e)&&i!=n.__tagId&&a++}return a},getTagIndexByValue(t){var e=[];return this.getTagElms().forEach(((i,a)=>{s(this.trim(i.textContent),t,this.settings.dropdown.caseSensitive)&&e.push(a)})),e},getTagElmByValue(t){var e=this.getTagIndexByValue(t)[0];return this.getTagElms()[e]},flashTag(t){t&&(t.classList.add(this.settings.classNames.tagFlash),setTimeout((()=>{t.classList.remove(this.settings.classNames.tagFlash)}),100))},isTagBlacklisted(t){return t=this.trim(t.toLowerCase()),this.settings.blacklist.filter((e=>(""+e).toLowerCase()==t)).length},isTagWhitelisted(t){return!!this.getWhitelistItem(t)},getWhitelistItem(t,e,i){e=e||"value";var a,n=this.settings;return(i=i||n.whitelist).some((i=>{var o="string"==typeof i?i:i[e]||i.value;if(s(o,t,n.dropdown.caseSensitive,n.trim))return a="string"==typeof i?{value:i}:i,!0})),a||"value"!=e||"value"==n.tagTextProp||(a=this.getWhitelistItem(t,n.tagTextProp,i)),a},validateTag(t){var e=this.settings,i="value"in t?"value":e.tagTextProp,s=this.trim(t[i]+"");return(t[i]+"").trim()?e.pattern&&e.pattern instanceof RegExp&&!e.pattern.test(s)?this.TEXTS.pattern:!e.duplicates&&this.isTagDuplicate(s,e.dropdown.caseSensitive,t.__tagId)?this.TEXTS.duplicate:this.isTagBlacklisted(s)||e.enforceWhitelist&&!this.isTagWhitelisted(s)?this.TEXTS.notAllowed:!e.validate||e.validate(t):this.TEXTS.empty},getInvalidTagAttrs(t,e){return{"aria-invalid":!0,class:`${t.class||""} ${this.settings.classNames.tagNotAllowed}`.trim(),title:e}},hasMaxTags(){return this.value.length>=this.settings.maxTags&&this.TEXTS.exceed},setReadonly(t,e){var i=this.settings;document.activeElement.blur(),i[e||"readonly"]=t,this.DOM.scope[(t?"set":"remove")+"Attribute"](e||"readonly",!0),this.setContentEditable(!t)},setContentEditable(t){this.settings.userInput&&(this.DOM.input.contentEditable=t,this.DOM.input.tabIndex=t?0:-1)},setDisabled(t){this.setReadonly(t,"disabled")},normalizeTags(t){var e=this.settings,i=e.whitelist,s=e.delimiters,a=e.mode,n=e.tagTextProp;e.enforceWhitelist;var o=[],r=!!i&&i[0]instanceof Object,l=Array.isArray(t),d=l&&t[0].value,h=t=>(t+"").split(s).filter((t=>t)).map((t=>({[n]:this.trim(t),value:this.trim(t)})));if("number"==typeof t&&(t=t.toString()),"string"==typeof t){if(!t.trim())return[];t=h(t)}else l&&(t=[].concat(...t.map((t=>t.value?t:h(t)))));return r&&!d&&(t.forEach((t=>{var e=o.map((t=>t.value)),i=this.dropdown.filterListItems.call(this,t[n],{exact:!0});this.settings.duplicates||(i=i.filter((t=>!e.includes(t.value))));var s=i.length>1?this.getWhitelistItem(t[n],n,i):i[0];s&&s instanceof Object?o.push(s):"mix"!=a&&(null==t.value&&(t.value=t[n]),o.push(t))})),o.length&&(t=o)),t},parseMixTags(t){var e=this.settings,i=e.mixTagsInterpolator,s=e.duplicates,a=e.transformTag,n=e.enforceWhitelist,o=e.maxTags,r=e.tagTextProp,l=[];return t=t.split(i[0]).map(((t,e)=>{var d,h,g,p=t.split(i[1]),c=p[0],u=l.length==o;try{if(c==+c)throw Error;h=JSON.parse(c)}catch(t){h=this.normalizeTags(c)[0]||{value:c}}if(a.call(this,h),u||!(p.length>1)||n&&!this.isTagWhitelisted(h.value)||!s&&this.isTagDuplicate(h.value)){if(t)return e?i[0]+t:t}else h[d=h[r]?r:"value"]=this.trim(h[d]),g=this.createTagElem(h),l.push(h),g.classList.add(this.settings.classNames.tagNoAnimation),p[0]=g.outerHTML,this.value.push(h);return p.join("")})).join(""),this.DOM.input.innerHTML=t,this.DOM.input.appendChild(document.createTextNode("")),this.DOM.input.normalize(),this.getTagElms().forEach(((t,e)=>this.tagData(t,l[e]))),this.update({withoutChangeEvent:!0}),t},replaceTextWithNode(t,e){if(this.state.tag||e){e=e||this.state.tag.prefix+this.state.tag.value;var i,s,a=this.state.selection||window.getSelection(),n=a.anchorNode,o=this.state.tag.delimiters?this.state.tag.delimiters.length:0;return n.splitText(a.anchorOffset-o),-1==(i=n.nodeValue.lastIndexOf(e))?!0:(s=n.splitText(i),t&&n.parentNode.replaceChild(t,s),!0)}},selectTag(t,e){var i=this.settings;if(!i.enforceWhitelist||this.isTagWhitelisted(e.value)){this.input.set.call(this,e[i.tagTextProp]||e.value,!0),this.state.actions.selectOption&&setTimeout(this.setRangeAtStartEnd.bind(this));var s=this.getLastTag();return s?this.replaceTag(s,e):this.appendTag(t),this.value[0]=e,this.update(),this.trigger("add",{tag:t,data:e}),[t]}},addEmptyTag(t){var e=g({value:""},t||{}),i=this.createTagElem(e);this.tagData(i,e),this.appendTag(i),this.editTag(i,{skipValidation:!0})},addTags(t,e,i){var s=[],a=this.settings,n=[],o=document.createDocumentFragment();if(i=i||a.skipInvalid,!t||0==t.length)return s;switch(t=this.normalizeTags(t),a.mode){case"mix":return this.addMixTags(t);case"select":e=!1,this.removeAllTags()}return this.DOM.input.removeAttribute("style"),t.forEach((t=>{var e,r={},l=Object.assign({},t,{value:t.value+""});if(t=Object.assign({},l),a.transformTag.call(this,t),t.__isValid=this.hasMaxTags()||this.validateTag(t),!0!==t.__isValid){if(i)return;if(g(r,this.getInvalidTagAttrs(t,t.__isValid),{__preInvalidData:l}),t.__isValid==this.TEXTS.duplicate&&this.flashTag(this.getTagElmByValue(t.value)),!a.createInvalidTags)return void n.push(t.value)}if("readonly"in t&&(t.readonly?r["aria-readonly"]=!0:delete t.readonly),e=this.createTagElem(t,r),s.push(e),"select"==a.mode)return this.selectTag(e,t);o.appendChild(e),t.__isValid&&!0===t.__isValid?(this.value.push(t),this.trigger("add",{tag:e,index:this.value.length-1,data:t})):(this.trigger("invalid",{data:t,index:this.value.length,tag:e,message:t.__isValid}),a.keepInvalidTags||setTimeout((()=>this.removeTags(e,!0)),1e3)),this.dropdown.position()})),this.appendTag(o),this.update(),t.length&&e&&(this.input.set.call(this,a.createInvalidTags?"":n.join(a._delimiters)),this.setRangeAtStartEnd()),a.dropdown.enabled&&this.dropdown.refilter(),s},addMixTags(t){if((t=this.normalizeTags(t))[0].prefix||this.state.tag)return this.prefixedTextToTag(t[0]);"string"==typeof t&&(t=[{value:t}]),this.state.selection;var e=document.createDocumentFragment();return t.forEach((t=>{var i=this.createTagElem(t);e.appendChild(i),this.insertAfterTag(i)})),this.appendMixTags(e),e},appendMixTags(t){var e=!!this.state.selection;e?this.injectAtCaret(t):(this.DOM.input.focus(),(e=this.setStateSelection()).range.setStart(this.DOM.input,e.range.endOffset),e.range.setEnd(this.DOM.input,e.range.endOffset),this.DOM.input.appendChild(t),this.updateValueByDOMTags(),this.update())},prefixedTextToTag(t){var e,i=this.settings,s=this.state.tag.delimiters;if(i.transformTag.call(this,t),t.prefix=t.prefix||this.state.tag?this.state.tag.prefix:(i.pattern.source||i.pattern)[0],e=this.createTagElem(t),this.replaceTextWithNode(e)||this.DOM.input.appendChild(e),setTimeout((()=>e.classList.add(this.settings.classNames.tagNoAnimation)),300),this.value.push(t),this.update(),!s){var a=this.insertAfterTag(e)||e;this.placeCaretAfterNode(a)}return this.state.tag=null,this.trigger("add",g({},{tag:e},{data:t})),e},appendTag(t){var e=this.DOM,i=e.input;i===e.input?e.scope.insertBefore(t,i):e.scope.appendChild(t)},createTagElem(t,i){t.__tagId=m();var s,a=g({},t,e({value:d(t.value+"")},i));return function(t){for(var e,i=document.createNodeIterator(t,NodeFilter.SHOW_TEXT,null,!1);e=i.nextNode();)e.textContent.trim()||e.parentNode.removeChild(e)}(s=this.parseTemplate("tag",[a,this])),this.tagData(s,t),s},reCheckInvalidTags(){var t=this.settings;this.getTagElms(t.classNames.tagNotAllowed).forEach(((e,i)=>{var s=this.tagData(e),a=this.hasMaxTags(),n=this.validateTag(s),o=!0===n&&!a;if("select"==t.mode&&this.toggleScopeValidation(n),o)return s=s.__preInvalidData?s.__preInvalidData:{value:s.value},this.replaceTag(e,s);e.title=a||n}))},removeTags(t,e,i){var s,a=this.settings;if(t=t&&t instanceof HTMLElement?[t]:t instanceof Array?t:t?[t]:[this.getLastTag()],s=t.reduce(((t,e)=>{e&&"string"==typeof e&&(e=this.getTagElmByValue(e));var i=this.tagData(e);return e&&i&&!i.readonly&&t.push({node:e,idx:this.getTagIdx(i),data:this.tagData(e,{__removed:!0})}),t}),[]),i="number"==typeof i?i:this.CSSVars.tagHideTransition,"select"==a.mode&&(i=0,this.input.set.call(this)),1==s.length&&"select"!=a.mode&&s[0].node.classList.contains(a.classNames.tagNotAllowed)&&(e=!0),s.length)return a.hooks.beforeRemoveTag(s,{tagify:this}).then((()=>{function t(t){t.node.parentNode&&(t.node.parentNode.removeChild(t.node),e?a.keepInvalidTags&&this.trigger("remove",{tag:t.node,index:t.idx}):(this.trigger("remove",{tag:t.node,index:t.idx,data:t.data}),this.dropdown.refilter(),this.dropdown.position(),this.DOM.input.normalize(),a.keepInvalidTags&&this.reCheckInvalidTags()))}i&&i>10&&1==s.length?function(e){e.node.style.width=parseFloat(window.getComputedStyle(e.node).width)+"px",document.body.clientTop,e.node.classList.add(a.classNames.tagHide),setTimeout(t.bind(this),i,e)}.call(this,s[0]):s.forEach(t.bind(this)),e||(this.removeTagsFromValue(s.map((t=>t.node))),this.update(),"select"==a.mode&&this.setContentEditable(!0))})).catch((t=>{}))},removeTagsFromDOM(){[].slice.call(this.getTagElms()).forEach((t=>t.parentNode.removeChild(t)))},removeTagsFromValue(t){(t=Array.isArray(t)?t:[t]).forEach((t=>{var e=this.tagData(t),i=this.getTagIdx(e);i>-1&&this.value.splice(i,1)}))},removeAllTags(t){t=t||{},this.value=[],"mix"==this.settings.mode?this.DOM.input.innerHTML="":this.removeTagsFromDOM(),this.dropdown.refilter(),this.dropdown.position(),this.state.dropdown.visible&&setTimeout((()=>{this.DOM.input.focus()})),"select"==this.settings.mode&&(this.input.set.call(this),this.setContentEditable(!0)),this.update(t)},postUpdate(){var t=this.settings,e=t.classNames,i="mix"==t.mode?t.mixMode.integrated?this.DOM.input.textContent:this.DOM.originalInput.value.trim():this.value.length+this.input.raw.call(this).length;this.toggleClass(e.hasMaxTags,this.value.length>=t.maxTags),this.toggleClass(e.hasNoTags,!this.value.length),this.toggleClass(e.empty,!i),"select"==t.mode&&this.toggleScopeValidation(this.value?.[0]?.__isValid)},setOriginalInputValue(t){var e=this.DOM.originalInput;this.settings.mixMode.integrated||(e.value=t,e.tagifyValue=e.value,this.setPersistedData(t,"value"))},update(t){var e=this.getInputValue();this.setOriginalInputValue(e),this.postUpdate(),this.settings.onChangeAfterBlur&&(t||{}).withoutChangeEvent||this.state.blockChangeEvent||this.triggerChangeEvent()},getInputValue(){var t=this.getCleanValue();return"mix"==this.settings.mode?this.getMixedTagsAsString(t):t.length?this.settings.originalInputValueFormat?this.settings.originalInputValueFormat(t):JSON.stringify(t):""},getCleanValue(t){return a(t||this.value,this.dataProps)},getMixedTagsAsString(){var t="",e=this,i=this.settings,s=i.originalInputValueFormat||JSON.stringify,a=i.mixTagsInterpolator;return function i(o){o.childNodes.forEach((o=>{if(1==o.nodeType){const r=e.tagData(o);if("BR"==o.tagName&&(t+="\r\n"),r&&v.call(e,o)){if(r.__removed)return;t+=a[0]+s(n(r,e.dataProps))+a[1]}else o.getAttribute("style")||["B","I","U"].includes(o.tagName)?t+=o.textContent:"DIV"!=o.tagName&&"P"!=o.tagName||(t+="\r\n",i(o))}else t+=o.textContent}))}(this.DOM.input),t}},M.prototype.removeTag=M.prototype.removeTags,M})); \ No newline at end of file diff --git a/assets/js/tagify.min.js_LICENSE b/assets/js/tagify.min.js_LICENSE new file mode 100644 index 0000000..c95c06b --- /dev/null +++ b/assets/js/tagify.min.js_LICENSE @@ -0,0 +1,19 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +THE SOFTWARE IS NOT PERMISSIBLE TO BE SOLD. \ No newline at end of file diff --git a/assets/js/timepicker.js b/assets/js/timepicker.js new file mode 100644 index 0000000..45bb7ba --- /dev/null +++ b/assets/js/timepicker.js @@ -0,0 +1,183 @@ +/* + * CardDAV Middleware UI + * Written by Massi-X © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +//declaration: new TimePicker(target element, -optional- minutesStep) +class TimePicker { + #hiddenInput; + #daysInput; + #hoursInput; + #minutesInput; + #minutesStep; + #previousMinutes; + + constructor(elem, minutesStep) { + if (!(/^input$/i).test(elem.nodeName)) //only allow inputs + return; + + if (minutesStep == undefined) + minutesStep = 1; + + this.#minutesStep = minutesStep; + this.#hiddenInput = elem; + + let parts = this._calculatePiecesFromValue(this.#hiddenInput.value); + + parts[2] = Math.round(parts[2] / minutesStep) * minutesStep; //normalize to step + this.#previousMinutes = parts[2]; + + //create new element + let timepicker = document.createElement('div'); + timepicker.className = '__timepicker-container'; + if (elem.classList != null) //add existing classes if != null + timepicker.classList.add(elem.classList); + + timepicker.insertAdjacentHTML('beforeend', + '
' + + '
' + + '' + + '' + + '
' + + '' + + 'Days' + + '
'); + timepicker.insertAdjacentHTML('beforeend', + '
' + + '
' + + '' + + '' + + '
' + + '' + + 'Hours' + + '
'); + timepicker.insertAdjacentHTML('beforeend', + '
' + + '
' + + '' + + '' + + '
' + + '' + + 'Minutes' + + '
'); + + elem.parentNode.insertBefore(timepicker, elem.nextSibling); //indert into DOM + elem.type = 'hidden'; //hide original input + + //save inputs + this.#daysInput = timepicker.querySelectorAll('[data-timepicker-days]')[0]; + this.#hoursInput = timepicker.querySelectorAll('[data-timepicker-hours]')[0]; + this.#minutesInput = timepicker.querySelectorAll('[data-timepicker-minutes]')[0]; + + //listeners + timepicker.querySelectorAll('[data-timepicker-function]').forEach((elem) => { + var func = elem.getAttribute('data-timepicker-function').split(" "); + var self = this; + + elem.intervalTime = 600; + elem.onmouseup = elem.onmouseleave = elem.onkeyup = () => { + clearTimeout(elem.timeoutId) + elem.intervalTime = 600; + }; + elem.onkeydown = e => { + if (e.key === 'Enter') + elem.dispatchEvent(new Event('mousedown')); + }; + + var repeat = (type, decrease = false) => { + if (elem.intervalTime > 100) + elem.intervalTime /= 3.5; + + if (type == "days") + decrease ? self.#daysInput.value-- : self.#daysInput.value++; + else if (type == "hours") + decrease ? self.#hoursInput.value-- : self.#hoursInput.value++; + else if (type == "minutes") + decrease ? self.#minutesInput.value-- : self.#minutesInput.value++; + + timepicker.dispatchEvent(new Event('input')); + }; + + if ("increment" == func[0]) + elem.onmousedown = () => { + elem.timeoutId = setTimeout(() => { elem.dispatchEvent(new Event('mousedown')) }, elem.intervalTime); + repeat(func[1]); + }; + else if ("decrement" == func[0]) + elem.onmousedown = () => { + elem.timeoutId = setTimeout(() => { elem.dispatchEvent(new Event('mousedown')) }, elem.intervalTime); + repeat(func[1], true); + }; + }); + timepicker.oninput = () => { + //calculate step + if (this.#minutesInput.value != -1) { + if (this.#minutesInput.value - this.#previousMinutes == 1) //increment + this.#minutesInput.value = +this.#minutesInput.value + this.#minutesStep - 1; + else if (this.#minutesInput.value - this.#previousMinutes == -1) //decrement + this.#minutesInput.value = +this.#minutesInput.value - this.#minutesStep + 1; + } + + //thanks to https://codepen.io/denilsonsa/pen/ZGYEEpD + if (this.#minutesInput.value == -1) { + if (this.#hoursInput.value > 0 || this.#daysInput.value > 0) { + this.#hoursInput.value--; + this.#minutesInput.value = 60 - this.#minutesStep; + } else { + this.#minutesInput.value = 0; + } + } else if (this.#minutesInput.value == 60) { + this.#hoursInput.value++; + this.#minutesInput.value = 0; + } + + if (this.#hoursInput.value == -1) { + if (this.#daysInput.value > 0) { + this.#daysInput.value--; + this.#hoursInput.value = 23; + } else { + this.#hoursInput.value = 0; + } + } else if (this.#hoursInput.value == 24) { + this.#daysInput.value++; + this.#hoursInput.value = 0; + } + + if (this.#daysInput.value == -1) { + this.#daysInput.value = 0; + } + + this._updateHiddenInput(); + + //check if it's over the max + if (+this.#hiddenInput.value > +this.#hiddenInput.max) { + let parts = this._calculatePiecesFromValue(this.#hiddenInput.max); + + this.#daysInput.value = parts[0]; + this.#hoursInput.value = parts[1]; + this.#minutesInput.value = parts[2]; + + this._updateHiddenInput(); + } + + this.#previousMinutes = this.#minutesInput.value; + }; + } + + _updateHiddenInput() { + this.#hiddenInput.value = this.#daysInput.value * 24 * 60 + this.#hoursInput.value * 60 + this.#minutesInput.value * 1; //by 1 to convert to integer + if (this.#hiddenInput.value < 0) + this.#hiddenInput.value = 0; + } + + _calculatePiecesFromValue(val) { + let days = val / (24 * 60); + let daysTruncated = Math.trunc(days); + let hours = (days - daysTruncated) * 24; + let hoursTruncated = Math.trunc(hours); + let minutesTruncated = Math.round((hours - hoursTruncated) * 60); //needs rounding for precision errors + + return [daysTruncated, hoursTruncated, minutesTruncated]; + } +} \ No newline at end of file diff --git a/build b/build new file mode 100755 index 0000000..01acf82 --- /dev/null +++ b/build @@ -0,0 +1,570 @@ +#!/usr/bin/env php + © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +/** + * This build script handles package creation, versioning, signing and everything. + */ + +require 'build_config'; +$rawname = 'temp'; //rawname is needed for create the tar archive (will change later to the chosen one) + +//Thanks to https://stackoverflow.com/a/15322457 for the interactive shell hint +//Note that $allowed_answers is incompatible with $multiline +function interactive_shell($question, $type = null, $allowed_answers = null, $multiline = false) +{ + $str = $question . ' '; + $res = ''; + + //print allowed answers + if (!empty($allowed_answers)) { + $str .= '('; + foreach ($allowed_answers as $answer) + $str .= $answer . '/'; + $str = substr($str, 0, -1) . ') '; + } + + //echo message + echo colorLog($str, $type); + + //read input + if (!$multiline) + $res = readline(); + else + while ($tmp = readline()) + $res .= $tmp . "\n"; + + //keep looping till we get an allowed answer + if (!$multiline && !empty($allowed_answers)) { + foreach ($allowed_answers as $answer) { + if (strcasecmp($res, $answer) == 0) + return $res; + } + echo colorLog("I did not understand, please try again.\n", 'e'); + return interactive_shell($question, $type, $allowed_answers); + } + + return $res; +} + +//Thanks to https://stackoverflow.com/a/66075475 +function colorLog($str, $type) +{ + switch ($type) { + case 'e': //error + echo "\033[31m$str\033[0m"; + break; + case 's': //success + echo "\033[32m$str\033[0m"; + break; + case 'w': //warning + echo "\033[33m$str\033[0m"; + break; + case 'i': //info + echo "\033[36m$str\033[0m"; + break; + case 'b': //boring + echo "\033[37m$str\033[0m"; + break; + default: + echo $str; + break; + } +} + +//redefined copy to recurse folders +function copy_i($from, $to, $exclude_list = []) +{ + //don't copy hidden files + if (substr($from, 0, 1) == '.') + return true; + + if (is_dir($from)) { + //don't need to array_diff with '.', '..' because hidden files will be removed anyway + $fileList = scandir($from); + if (!$fileList || !mkdir("$to")) + return false; + + $fileList = array_diff($fileList, $exclude_list); + foreach ($fileList as $file) { + if (substr($file, 0, 1) == '.') + continue; + + if (!copy_i("$from/$file", "$to/$file")) + return false; + } + + return true; + } else + return copy($from, $to); +} + +//die with error color +function die_i($msg) +{ + die(colorLog($msg, 'e')); +} + +/*****************************************************************/ +/* ACTUAL SCRIPT */ +/*****************************************************************/ + +echo colorLog("Welcome to the build script. A compress file will be created containing the module ready to be published.\n", 'i'); +echo colorLog("REMEMBER THAT HIDDEN FILES WILL NOT BE INCLUDED IN THE OUTPUT!\n", 'i'); +sleep(1); + +//first change to the correct dir +if (!chdir(__DIR__)) + die_i("Failed to change dir!\n"); + +if (!file_exists('core/core.php')) + die_i("Unable to find core class! ABORTING!\n"); + +if (file_exists('output.tar.gz')) { + $res = interactive_shell('output.tar.gz already exists, if you continue it will be overwritten! Are you sure?', 'w', ['Y', 'N']); + + if ($res != 'Y' && $res != 'y') + die_i("ABORTING!\n"); + + echo "Deleting output.tar.gz...\n"; + if (!unlink(__DIR__ . '/output.tar.gz')) + die_i("Cannot delete output.tar.gz!\n"); +} + +//create random folder +$tmpFolder = 'ph_' . uniqid(); +while (true) { + if (file_exists($tmpFolder) || is_link($tmpFolder)) { + $tmpFolder = 'ph_' . uniqid(); + continue; + } + + $tmpFolder = sys_get_temp_dir() . '/' . $tmpFolder; + if (!@mkdir($tmpFolder . '/' . $rawname, 0777, true) || !@is_dir($tmpFolder . '/' . $rawname)) + die_i("Cannot create tmp dir!\n"); + + break; +} + +//list all the files in this dir +$fileList = scandir(__DIR__); +if (!$fileList) + die_i("Cannot scan dir!\n"); + +//remove excluded files from root (these are only mine, so $excludedLibraryFiles) +$fileList = array_diff($fileList, $excludedLibraryFiles); + +//now copy everything and pass $excludedGenericFiles here to exclude unnecessary files in core too +foreach ($fileList as $file) + copy_i($file, "$tmpFolder/$rawname/$file", $excludedGenericFiles); + +echo colorLog("Copied temporary files.\n", 's'); + +//ask some questions about the module details (that will be included in module.xml) +echo colorLog("It's now time to get some information about the module... If you want to skip any section press enter.\n", 'i'); +echo colorLog("(PS. If you want to skip this for future releases, you can create a file called config (inside \"core\" directory) and it will be automatically loaded!)\n", 'i'); + +//if config exists, preload all the values +$config = false; +if (file_exists("$tmpFolder/$rawname/core/config")) { + $config = @include "$tmpFolder/$rawname/core/config"; + if ($config === false) + die_i('Unable to include config file!'); + + if (!unlink("$tmpFolder/$rawname/core/config")) + die_i("Unable to delete config file!\n"); + + echo colorLog("Using the provded config file\n", 's'); +} + +//container for the xml below +$output_xml = new SimpleXMLElement(''); + +//add meta tag with UI version +$output_xml->addChild('meta-cmui-version', $UI_version); + +//rawname +if (!$config) + $res = interactive_shell('What is the rawname of the module?'); +else + $res = @$config['rawname']; + +if (!empty($res)) { + echo colorLog("Setting rawname to \"$res\"\n", 'b'); +} else { + $res = (empty($modulexml['rawname']) ? 'none' : $modulexml['rawname']); + echo colorLog("Setting rawname to default \"$res\"\n", 'b'); +} + +$output_xml->addChild('rawname', htmlspecialchars(trim($res))); + +//now move the files to the new directory +if (!@rename("$tmpFolder/$rawname", $tmpFolder . '/' . trim($res))) + die_i("Unable to rename tmp folder!\n"); +$rawname = trim($res); + +//name +if (!$config) + $res = interactive_shell('What is the name (to display) of the module?'); +else + $res = @$config['name']; + +if (!empty($res)) { + echo colorLog("Setting name to \"$res\"\n", 'b'); +} else { + $res = (empty($modulexml['name']) ? 'none' : $modulexml['name']); + echo colorLog("Setting name to default \"$res\"\n", 'b'); +} + +$output_xml->addChild('name', htmlspecialchars(trim($res))); + +//version +if (!$config) + $res = interactive_shell('Which version is this? (use three digits, ex. 1.0.0):'); +else + $res = @$config['version']; + +if (!empty($res)) + echo colorLog("Setting version to \"$res\"\n", 'b'); +else { + $res = (empty($modulexml['version']) ? 'none' : $modulexml['version']); + echo colorLog("Setting version to default \"$res\"\n", 'b'); +} + +$output_xml->addChild('version', htmlspecialchars(trim($res))); + +//category +$categories = [ + 0 => 'Uncategorized', + 1 => 'Admin', + 2 => 'Applications', + 3 => 'Connectivity', + 4 => 'Reports', + 5 => 'Settings' +]; + +if (!$config) { + echo "Which category does your module belongs to?\n"; + foreach ($categories as $index => $cat) + echo "[$index] $cat\n"; + $res = interactive_shell('Input your choice:', null, array_keys($categories)); +} else { + $res = @$config['category']; + if (!empty($res) && is_string($res)) { + $key = array_search($res, $categories); + if (is_int($key)) + $res = $key; + else { + $res = null; + echo colorLog("Config file contains an invalid value for category, skipping.\n", 'e'); + } + } +} + +if (!empty($res) && strcasecmp('0', trim($res)) != 0) { + echo colorLog('Setting category to "' . $categories[$res] . "\"\n", 'b'); + $output_xml->addChild('category', htmlspecialchars($categories[$res])); +} else + echo colorLog("Skipping category\n", 'b'); + +//menu items +$menu_to_add = []; + +if (isset($modulexml['menuitems'])) + $menu_to_add = array_merge($modulexml['menuitems'], []); //add default values + +if (!$config) { + //print info for user (adding default menu items) + if (!empty($menu_to_add)) { + echo "Adding default menu items...\n"; + foreach ($menu_to_add as $key => $val) + echo colorLog("Adding \"$key\" => \"$val\"\n", 'b'); + } + + $res = 'y'; + while ($res != null) { + $res = interactive_shell('Does your module have any menu items? Then input here the (php) page name or skip with enter:'); + if (!empty($res)) { + $key = $res; + $res = interactive_shell("OK, what is the name for the entry \"$res\"?"); + if (!empty($res)) { + echo colorLog((isset($menu_to_add[$key]) ? 'Overwriting' : 'Adding') . " menu item \"$key\" => \"$res\"\n", 'b'); + $menu_to_add[$key] = $res; + } else + echo colorLog("Invalid value, please retry.\n", 'w'); + } else + $res = null; + } +} else { + $arr = @$config['menuitems']; + if (!is_array($arr)) + echo colorLog("Config file contains an invalid array for menuitems, skipping.\n", 'e'); + else { + foreach ($arr as $key => $val) + $menu_to_add[$key] = $val; + } +} + +//add all the values declared before (if any) +if (count($menu_to_add)) { + $menuitems_xml = $output_xml->addChild('menuitems'); //subnode for menu items + + foreach ($menu_to_add as $key => $val) { + echo colorLog("Adding menuitem \"$key\" => \"$val\"\n", 'b'); + $menuitems_xml->addChild(trim($key), htmlspecialchars(trim($val))); + } + + echo colorLog("Menu items set.\n", 'b'); +} else + echo colorLog("Skipping menu items\n", 'b'); + +//publisher +if (!$config) + $res = interactive_shell('Do you want to be added as a publisher? Input your name then:'); +else + $res = @$config['publisher']; + +if (!empty($res)) { + $res = $res . ' w/ ' . $modulexml['publisher']; + echo colorLog("Setting publisher to \"$res\"\n", 'b'); +} else { + $res = (empty($modulexml['publisher']) ? 'none' : $modulexml['publisher']); + echo colorLog("Setting publisher to default \"$res\"\n", 'b'); +} + +$output_xml->addChild('publisher', htmlspecialchars(trim($res))); + +//description +if (!$config) + $res = interactive_shell('What is the short description of the module?'); +else + $res = @$config['description']; + +if (!empty($res)) + echo colorLog("Setting description to \"$res\"\n", 'b'); +else { + $res = (empty($modulexml['description']) ? 'none' : $modulexml['description']); + echo colorLog("Setting description to default \"$res\"\n", 'b'); +} + +$output_xml->addChild('description', htmlspecialchars(trim($res))); + +//license +if (!$config) + $res = interactive_shell('What license are you using?'); +else + $res = @$config['license']; + +if (!empty($res)) { + echo colorLog("Setting license to \"$res\"\n", 'b'); + $output_xml->addChild('license', htmlspecialchars(trim($res))); + + //license link is subject to license itself + if (!$config) + $res = interactive_shell("Please now input a link pointing to the license text of $res:"); + else + $res = @$config['licenselink']; + + if (!empty($res)) { + echo colorLog("Setting license link to \"$res\"\n", 'b'); + $output_xml->addChild('licenselink', htmlspecialchars(trim($res))); + } else + die_i("You MUST include a license link in case you choose a different license.\n"); +} else { + $license = (empty($modulexml['license']) ? 'none' : $modulexml['license']); + echo colorLog("Setting license to default \"$license\"\n", 'b'); + + $output_xml->addChild('license', htmlspecialchars(trim($license))); + $output_xml->addChild('licenselink', htmlspecialchars(trim($modulexml['licenselink']))); +} + +//more-info +if (!$config) + $res = interactive_shell('Do you have a link with more info about the module? Please input it then:'); +else + $res = @$config['more-info']; + +if (!empty($res)) + echo colorLog("Setting more-info to \"$res\"\n", 'b'); +else { + $res = (empty($modulexml['more-info']) ? 'none' : $modulexml['more-info']); + echo colorLog("Setting more-info to default \"$res\"\n", 'b'); +} + +$output_xml->addChild('more-info', htmlspecialchars(trim($res))); + +//updateurl +if (!$config) + $res = interactive_shell('Do you have an update json for automatic updates? Please input the link then:'); +else + $res = @$config['updateurl']; + +if (!empty($res)) + echo colorLog("Setting updateurl to \"$res\"\n", 'b'); +else { + $res = (empty($modulexml['updateurl']) ? 'none' : $modulexml['updateurl']); + echo colorLog("Setting updateurl to default \"$res\"\n", 'b'); +} + +$output_xml->addChild('updateurl', htmlspecialchars(trim($res))); + +//changelog +if (!$config) + $res = interactive_shell('Do you want to insert any release notes? Please input them now (to finish hit enter twice):', null, null, true); +else + $res = @$config['changelog']; + +if (!empty($res)) + $modulexml['changelog'] .= $res; + +echo colorLog("Setting changelog to:\n" . trim(preg_replace('/\t+/', '', (empty($modulexml['changelog']) ? 'none' : $modulexml['changelog']))) . "\n", 'b'); +$output_xml->addChild('changelog', htmlspecialchars(trim($modulexml['changelog']))); + +//dependencies +$depends_xml = $output_xml->addChild('depends'); //subnode for depends + +$depends_to_add = []; +if (isset($modulexml['depends'])) + $depends_to_add = array_merge($modulexml['depends'], []); //store the used keys to print an info for the user (except 'module' that can have multiple ones) + set default keys + +if (!$config) { + //print info for user (adding default dependencies) + echo "Adding default dependecies...\n"; + foreach ($depends_to_add as $key => $val) + echo colorLog("Adding \"$key\" => \"$val\"\n", 'b'); + + $res = 'y'; + while ($res != null) { + $res = interactive_shell('Does your module have any dependecies? If yes input it here (version/module/phpversion...) or skip with enter:'); + if (!empty($res)) { + $key = $res; + $res = interactive_shell("OK, what is the description/version for dependecy \"$res\"?"); + if (!empty($res)) { + if (strcasecmp($key, 'version') == 0 && version_compare($minimum_fpbx, $res) == 1) //disallow versions < $minimum_fpbx + echo colorLog("Minimum version is $minimum_fpbx, please retry.\n", 'e'); + else { + if (strcasecmp($key, 'module') == 0) { //module is the only one that accepts multiple entries + echo colorLog("Adding dependecy \"$key\" => \"$res\"\n", 'b'); + $depends_xml->addChild(trim($key), htmlspecialchars(trim($res))); + } else { + echo colorLog((isset($depends_to_add[$key]) ? 'Overwriting' : 'Adding') . " dependecy \"$key\" => \"$res\"\n", 'b'); + $depends_to_add[$key] = $res; + } + } + } else + echo colorLog("Invalid value, please retry.\n", 'w'); + } else + $res = null; + } + + //add all the values declared before (apart from module) + foreach ($depends_to_add as $key => $val) + $depends_xml->addChild(trim($key), htmlspecialchars(trim($val))); +} else { + //build the xml from an array + function arraywalk($arr, $is_default = false) + { + global $depends_xml; + global $minimum_fpbx; + + foreach ($arr as $key => $value) { + if (!empty($key)) { + if (!empty($value)) { + if (strcasecmp($key, 'version') == 0 && version_compare($minimum_fpbx, $value) == 1) //disallow versions < $minimum_fpbx + echo colorLog("You were trying to set a minimum version below $minimum_fpbx but this is not allowed, skipping.\n", 'e'); + else { + if (strcasecmp($key, 'module') == 0) { //module is the only one that accepts multiple entries + if (!is_array($value)) + $value = [$value]; + + foreach ($value as $val) { + echo colorLog("Adding " . ($is_default ? 'default ' : '') . "dependecy \"module\" => \"$val\"\n", 'b'); + $depends_xml->addChild('module', htmlspecialchars(trim($val))); + } + } else { //no need to check for duplicates here. It is impossible for the array to have any + echo colorLog("Adding " . ($is_default ? 'default ' : '') . "dependecy \"$key\" => \"$value\"\n", 'b'); + $depends_xml->addChild(trim($key), htmlspecialchars(trim($value))); + } + } + } else + echo colorLog("$key is empty, skipping.\n", 'w'); + } + } + } + + if (!is_array(@$config['depends'])) //fail if depends is not an array + echo colorLog("Config file contains an invalid array for depends, skipping.\n", 'e'); + else { + //remove overwritten default values + foreach ($depends_to_add as $key => $val) { + if (array_key_exists($key, $config['depends'])) + unset($depends_to_add[$key]); + } + + arraywalk($depends_to_add, true); //add default dependencies + arraywalk($config['depends']); //add 'core' dependencies + } +} + +echo colorLog("Dependencies set.\n", 'b'); //no need to skip this if no dependency is set (it will never happen because version is always there) + +//remove module.xml from output (if present)... +if (file_exists("$tmpFolder/$rawname/module.xml") && !unlink("$tmpFolder/$rawname/module.xml")) + die_i("Unable to delete module.xml!\n"); + +//...and replace it with our generated one +try { + $result = $output_xml->asXML("$tmpFolder/$rawname/module.xml"); + file_put_contents("$tmpFolder/$rawname/module.xml", file_get_contents("$tmpFolder/$rawname/module.xml") . ""); //put comment at the end for xml parser +} catch (Throwable $t) { + die_i("Unable to write module.xml!\n"); +} + +if ($config) + echo colorLog("Finished loading values from config file. Please read above to assure everything is correct\n", 's'); + +//signing is optional +$res = interactive_shell('Do you want to sign the module?', null, ['Y', 'N']); +if ($res == 'Y' || $res == 'y') { + //checks to be performed before everything else + if (!`which gpg`) + die_i('Please run `brew install gpg` to enable signing.'); + + if (!file_exists('devtools') || !is_dir('devtools')) + die_i("Unable to find devtools folder. Please download it from https://github.com/FreePBX/devtools and unzip it in the main directory (the same as this script) to continue.\n"); + + //a key ID is needed + $key = interactive_shell('Please provide now your GPG key ID to sign the module:'); + + if (empty($key)) + die_i("GPG key cannot be empty! ABORTING!\n"); + + //exec the sign and echo every output + echo colorLog(exec("GPG_TTY=$(tty) && export GPG_TTY && php devtools/sign.php $tmpFolder/$rawname " . escapeshellarg($key)) . "\n", 'i'); +} + +//tar.gz the file (thanks to https://stackoverflow.com/a/20062628) +try { + $phar = new PharData(__DIR__ . "/output.tar"); + + $phar->buildFromDirectory("$tmpFolder"); + + //tar archive will be created only after closing object + $phar->compress(Phar::GZ); + + // NOTE THAT BOTH FILES WILL EXISTS. SO IF YOU WANT YOU CAN UNLINK archive.tar + unlink(__DIR__ . "/output.tar"); +} catch (Exception $e) { + die_i("Failed to create tar file!\n"); +} + +//delete tmp folder (don't care about exeptions) +@exec("rm -rf $tmpFolder"); + +//open output directory +@exec('open ' . escapeshellarg(__DIR__)); //don't care about exceptions +echo colorLog("Done, you can find the tar inside the source directory.\n", 's'); diff --git a/build_config b/build_config new file mode 100644 index 0000000..a20e53f --- /dev/null +++ b/build_config @@ -0,0 +1,44 @@ + © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +//Dear Reader, DO NOT EDIT VALUES HERE USE INSTEAD THE CONFIG FILE! + +$minimum_fpbx = '15.0'; //minimum compatible FreePBX +$UI_version = '1.0.0'; //version to change in case of new releases +$readme = 'https://github.com/Massi-X/freepbx-phonemiddleware/#readme'; //readme URL + +//list of files from this library that should not end up in the finished module +$excludedLibraryFiles = [ + 'build', + 'build_config', + 'devtools' +]; + +//list of files that are not needed even if in core +$excludedGenericFiles = [ + //not used for now + //'composer.json', + //'composer.lock' + //config is automatically removed after loading values, no need to include it here +]; + +//default module xml array +$modulexml = [ + 'rawname' => 'carddavmiddleware', + 'name' => 'CardDAV Middleware UI', + 'version' => '1.0.0', + 'publisher' => 'Massi-X', + 'license' => 'CC-BY-NC-ND-4.0', + 'licenselink' => 'https://github.com/Massi-X/freepbx-phonemiddleware/blob/main/LICENSE', + 'description' => 'This is the UI for CardDAV Middleware. Please change this when releasing.', + 'more-info' => $readme, + 'updateurl' => '', + 'changelog' => "*Powered by CardDAV Middleware UI v.$UI_version* More info at $readme\n", //changelog is one line. Do not add anything more than that + 'depends' => [ + 'version' => $minimum_fpbx + ] +]; diff --git a/carddavtoxml.php b/carddavtoxml.php new file mode 100644 index 0000000..86e2b83 --- /dev/null +++ b/carddavtoxml.php @@ -0,0 +1,23 @@ + © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +header('Content-type: text/xml'); +require __DIR__ . '/core/core.php'; + +use Core; + +try { + $instance = Core::getInstance(); + echo $instance->getXMLforPhones($instance::get_cache_expire() == 0); +} catch (Exception $e) { + //send real message to the UI + Core::sendUINotification(Core::NOTIFICATION_TYPE_ERROR, $e->getMessage()); + //and print a generic one here + $xml = new SimpleXMLElement(''); + $xml->addChild('error', _('Something went wrong while retrieving the addressbook(s). Please log into the UI to see a more detailed error.')); + echo $xml->asXML(); +} diff --git a/module.xml b/module.xml new file mode 100644 index 0000000..05ab7e8 --- /dev/null +++ b/module.xml @@ -0,0 +1,5 @@ + + + 2.0.0 + \ No newline at end of file diff --git a/numbertocnam.php b/numbertocnam.php new file mode 100644 index 0000000..f774944 --- /dev/null +++ b/numbertocnam.php @@ -0,0 +1,42 @@ + © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +header('Content-type: text/xml'); +require __DIR__ . '/core/core.php'; + +use Core; + +//handy shortcut to print a basic xml message +function printBasicXML($key, $text) +{ + $xml = new SimpleXMLElement(''); + $xml->addChild($key, $text); + echo $xml->asXML(); +} + +//legacy warning so users will switch to POST +if (isset($_GET['number'])) { + //send real message to the UI + Core::sendUINotification(Core::NOTIFICATION_TYPE_ERROR, _('Passing parameters to numbertocnam with GET is deprecated. Please use "Auto Configure" again to fix this for you.')); + //and print a generic one here + printBasicXML('name', '[W!] ' . _('GET is deprecated, please switch to POST.')); +} + +//actual code +try { + if (isset($_POST['number'])) { + $instance = Core::getInstance(); + echo $instance->getCNFromPhone($_POST['number'], $instance::get_cache_expire() == 0); + } else if (!Core::get_superfecta_compat()) + printBasicXML('name', _('Unknown')); +} catch (Exception $e) { + //send real message to the UI + Core::sendUINotification(Core::NOTIFICATION_TYPE_ERROR, $e->getMessage()); + //and print a generic [W!] with number + if (!Core::get_superfecta_compat()) + printBasicXML('name', '[W!] ' . $_POST['number']); +} diff --git a/page.carddavmiddleware.php b/page.carddavmiddleware.php new file mode 100644 index 0000000..5b996ee --- /dev/null +++ b/page.carddavmiddleware.php @@ -0,0 +1,540 @@ + + + +
+
+ getCore(); + //print all the errors (if any) + if (isset($_POST['errors']) && is_array($_POST['errors'])) { + echo ''; + } + ?> + + +
+
+ +

+ +

+
+ +
+ + +
+ + +
+ retrieveUINotifications(); + + echo ''; //count handled by js + echo '
'; + echo '
'; + echo '
'; + + foreach ($notifications as $notification) { + $type = 'info'; + switch ($notification['level']) { + case Core::NOTIFICATION_TYPE_ERROR: + $type = 'error'; + break; + case Core::NOTIFICATION_TYPE_VERBOSE: + $type = 'verbose'; + break; + } + + //timestamp is converted to locale in js + echo '

' . $notification['timestamp'] . ''; + //repetitions + if ($notification['repeated'] > 1 && $notification['repeated'] < 10) + echo ' [Repeated ' . $notification['repeated'] . ' times]'; + else if ($notification['repeated'] >= 10) + echo ' [Repeated many times]'; + //message + close div + echo '

' . $notification['message'] . '
'; + } + + echo '
'; + ?> +
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ + +
+
+
+
+
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ + get_max_cnam_length() == 0 ? 'disabled' : ''; ?>> +
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+ + You can try with some other manufacturer if yours is not listed, but if this still isn\'t working reach out to the developer.'); ?> + +
+
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+ + (not reccomended). Max 30 days.'); ?> + +
+
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+ + ' . _('ISO format') . '', _('Default country code used for parsing the numbers in %url. This is only used as a fallback if the number contains no country code.')); ?> + +
+
+
+ +
+
+
+
+
+
+ + +
+
+ + get_superfecta_compat()) echo 'checked'; ?>> + + get_superfecta_compat()) echo 'checked'; ?>> + + +
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+
+ + +
+
+ + get_spam_match()) echo 'checked'; ?>> + + get_spam_match()) echo 'checked'; ?>> + + +
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+
+ + +
+
+ + get_mail_level())) echo 'checked' ?>> + + get_mail_level())) echo 'checked' ?>> + + + + getEmailAddresses()['To']; ?> + +
+
+
+
+
+
+
+ + The email will be sent to the registered address in "Module Admin", section "Scheduler and Alerts", the "From" address is that of "System Admin" section "Notifications Settings". This requires a well set and working postfix configuration or the use of the commercial "System Admin" module. If something is missing or misconfigured, e-mail won\'t work!'); ?> + +
+
+
+ +
+ + +
+
+
+
+
+ + +
+ + +
+ + + + + + + + + + + + + + +
+
\ No newline at end of file diff --git a/utilities.php b/utilities.php new file mode 100644 index 0000000..2478960 --- /dev/null +++ b/utilities.php @@ -0,0 +1,172 @@ + © 2023 + * This file is protected under CC-BY-NC-ND-4.0, please see "LICENSE" file for more information + */ + +/** + * Helper class to manage some BMO functions and other small useful things. + **/ + +class Utilities +{ + //list of strings that will be replaced by the script when sending an email (for now only one) + public const MAIL_SERVER_NAME = '%server_name'; + + //other constants + const SUPERFECTA_SCHEME = 'carddavmiddleware'; + const SUPERFECTA_SCHEME_CONFIG = [ + 'POST_Data' => '"number" = "$thenumber"', + 'Regular_Expressions' => '/(.*?)<\\/name>/m', + 'SPAM_Regular_Expressions' => '/(true)<\\/spam>[\\s\\S]*(.*?)<\\/threshold>/m' + ]; + + /** + * Get module version. Returns 0 in case of unknown failure + * + * @return string version number + */ + public static function get_version() + { + $xml = simplexml_load_file(__DIR__ . "/module.xml"); //this loads the value set by build script in CM UI + return $xml ? $xml->version : 0; + } + + /** + * Same as file_put_contents, but automatically handle folder creation and related errors. + * + * @param string $filename See @file_put_contents for description + * @param mixed $data See @file_put_contents for description + * @param int $flags See @file_put_contents for description + * @param $context See @file_put_contents for description + * @return int|false The function returns false in case of failure creating the folder structure or returns the result of @file_put_contents. + */ + public static function file_put_contents_i($filename, $data, $flags = 0, $context = null) + { + $dirname = dirname($filename); + + if (!is_dir($dirname) && !mkdir($dirname)) + return false; + + return file_put_contents($filename, $data, $flags, $context); + } + + /** + * Deletes a directory recursively. Does not follow symlinks. + * + * @param string $dirPath Path to dir + * @return boolean true if success, false otherwise + * @throws Exception If the provided path is not a directory + */ + public static function delete_dir($dirPath) + { + if (!is_dir($dirPath) || is_link($dirPath)) + throw new Exception("$dirPath must be a directory"); + + if (substr($dirPath, strlen($dirPath) - 1, 1) != '/') + $dirPath .= '/'; + + $files = array_diff(scandir($dirPath), array('..', '.')); + + foreach ($files as $file) { + $file = $dirPath . $file; + if (!is_link($file) && is_dir($file)) + self::delete_dir($file); + else + unlink($file); + } + return rmdir($dirPath); + } + + /** + * BMO Function + * Get email "To" and "From" addresses from fpbx config + * + * @param FreePBX $FreePBX BMO object + * @return array the registered email address in the format ["To" => "...", "From" => "..."] + * @throws Exception If the "To" (and only "To") email address is not set + */ + public static function get_fpbx_registered_email_config($FreePBX) + { + if (!is_a($FreePBX, 'FreePBX', true)) + throw new Exception(_('Not given a FreePBX Object')); + + $to = (new FreePBX\Builtin\UpdateManager())->getCurrentUpdateSettings()['notification_emails']; + if (empty($to)) + throw new Exception(_('"To" field is empty.')); + + $from = $FreePBX->Config()->get('AMPUSERMANEMAILFROM'); + if (empty($from)) + $from = null; //make sure to null it so the caller will have a consistent result + + return ["To" => $to, "From" => $from]; + } + + /** + * BMO Function + * Returns the server name extracted from shell. + * + * @param FreePBX $FreePBX BMO object + * @return string Server name or 'Unknown' in case of errors + */ + public static function get_server_name($FreePBX) + { + if (!is_a($FreePBX, 'FreePBX', true)) + throw new Exception(_('Not given a FreePBX Object')); + + $serverName = trim($FreePBX->Config()->get('FREEPBX_SYSTEM_IDENT')); + + if (empty($serverName)) + $serverName = 'Unknown'; + + return $serverName; + } + + /** + * BMO Function + * Construct the email body. + * + * @param FreePBX $FreePBX BMO object + * @param string $to "To" address + * @param string $subject Subject of email + * @param string $html_txt Message of email formatted in html + * @param string $from_name "From" name. Defaults to the email itself (WARNING! only the name, not the formatted "name ") + * @return boolean True in case of success, false otherwise + */ + public static function send_mail($FreePBX, $to, $subject, $html_txt, $from_name = '') + { + if (!is_a($FreePBX, 'FreePBX', true)) + throw new Exception(_('Not given a FreePBX Object')); + + //this is all taken from BMO/Mail.class.php + $from_email = get_current_user() . '@' . gethostname(); + + //sysadmin allows to change "from" address + if (function_exists('sysadmin_get_storage_email')) { + $emails = call_user_func('sysadmin_get_storage_email'); + //Check that what we got back above is a email address + if (!empty($emails['fromemail']) && filter_var($emails['fromemail'], FILTER_VALIDATE_EMAIL)) { + //Fallback address + $from_email = $emails['fromemail']; + } + } + + //set sender name to the address if nothing provided + if (empty($from_name)) + $from_name = $from_email; + + //replace strings matching the pattern (yeah for now I have only this so it's rather simple...) + $to = str_replace(self::MAIL_SERVER_NAME, self::get_server_name($FreePBX), $to); + $subject = str_replace(self::MAIL_SERVER_NAME, self::get_server_name($FreePBX), $subject); + $html_txt = str_replace(self::MAIL_SERVER_NAME, self::get_server_name($FreePBX), $html_txt); + $from_name = str_replace(self::MAIL_SERVER_NAME, self::get_server_name($FreePBX), $from_name); + + $headers = + "MIME-Version: 1.0\r\n" . + "Content-type: text/html; charset=UTF-8\r\n" . + "From: $from_name <$from_email>\r\n"; + + return mail($to, $subject, $html_txt, $headers); + } +}