diff options
| author | 2023-01-18 10:12:21 +0100 | |
|---|---|---|
| committer | 2023-01-18 10:12:21 +0100 | |
| commit | daaa391e33c5d92e3dd91bb0b81ac420abed7097 (patch) | |
| tree | a3263c26ac90fb3115627e156eba580826acfd4f /lib | |
| parent | 216e39c3cc43061686981b96328796765d264d29 (diff) | |
tec: Update the lib_opml (#4403)
* fix: Fix undefined GLOB_BRACE on Alpine
The manual states that:
> Note: The GLOB_BRACE flag is not available on some non GNU systems,
> like Solaris or Alpine Linux.
This generated an error on Alpine.
Reference: https://www.php.net/manual/function.glob.php
* fix: List details of feeds for OPML exportation
The details are necessary to export the XPath information, the CSS full
content path and read actions filters.
* Update LibOpml to 0.4.0
* Refactor OPML importation to be more robust
First, it fixes two regressions introduced by the update of lib_opml:
- title attribute is used when text attribute is missing;
- the OPML category attribute is used as a fallback for feeds categories.
In a related way, if also fixes a problem when a feed had both a parent
category outline and a category attribute. Before, it only considered the
attribute as its category, but now it considers the parent outline.
Then, it counts category limit correctly by not increasing
`$nb_categories` if the category already exists.
* Exclude lib_opml from the CodeSniffer
* Fix variable names when logging some errors
* Fix catch of LibOpml Exception
* Make sure to declare the category
* Exclude lib_opml from PHPStan analyze
* Disable markdownlint for lib_opml
* Fix typos
* Use auto-loading and allow updates via Composer
* Fix broken links to lib_opml
* Bring back the ability to import the OPML frss:opmlUrl attribute
* Refactor the logs of OPML errors
* Update lib_opml to the version 0.5.0
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/.gitignore | 8 | ||||
| -rw-r--r-- | lib/composer.json | 1 | ||||
| -rw-r--r-- | lib/lib_opml.php | 353 | ||||
| -rw-r--r-- | lib/lib_rss.php | 5 | ||||
| -rw-r--r-- | lib/marienfressinaud/lib_opml/.gitattributes | 8 | ||||
| -rw-r--r-- | lib/marienfressinaud/lib_opml/.gitignore | 2 | ||||
| -rw-r--r-- | lib/marienfressinaud/lib_opml/CHANGELOG.md | 63 | ||||
| -rw-r--r-- | lib/marienfressinaud/lib_opml/LICENSE | 21 | ||||
| -rw-r--r-- | lib/marienfressinaud/lib_opml/README.md | 338 | ||||
| -rw-r--r-- | lib/marienfressinaud/lib_opml/composer.json | 35 | ||||
| -rw-r--r-- | lib/marienfressinaud/lib_opml/src/LibOpml/Exception.php | 15 | ||||
| -rw-r--r-- | lib/marienfressinaud/lib_opml/src/LibOpml/LibOpml.php | 770 |
12 files changed, 1266 insertions, 353 deletions
diff --git a/lib/.gitignore b/lib/.gitignore index 812bbfe76..a1df80381 100644 --- a/lib/.gitignore +++ b/lib/.gitignore @@ -1,6 +1,14 @@ autoload.php composer.lock composer/ +marienfressinaud/lib_opml/.git/ +marienfressinaud/lib_opml/.gitlab-ci.yml +marienfressinaud/lib_opml/.gitlab/ +marienfressinaud/lib_opml/ci/ +marienfressinaud/lib_opml/examples/ +marienfressinaud/lib_opml/Makefile +marienfressinaud/lib_opml/src/functions.php +marienfressinaud/lib_opml/tests/ phpgt/cssxpath/.* phpgt/cssxpath/composer.json phpgt/cssxpath/CONTRIBUTING.md diff --git a/lib/composer.json b/lib/composer.json index 4e4e1c051..6e9e0ee32 100644 --- a/lib/composer.json +++ b/lib/composer.json @@ -12,6 +12,7 @@ ], "require": { "php": ">=7.2.0", + "marienfressinaud/lib_opml": "0.5.0", "phpgt/cssxpath": "dev-master#4fbe420aba3d9e729940107ded4236a835a1a132", "phpmailer/phpmailer": "6.6.0" }, diff --git a/lib/lib_opml.php b/lib/lib_opml.php deleted file mode 100644 index f86d780b7..000000000 --- a/lib/lib_opml.php +++ /dev/null @@ -1,353 +0,0 @@ -<?php - -/** - * lib_opml is a free library to manage OPML format in PHP. - * - * By default, it takes in consideration version 2.0 but can be compatible with - * OPML 1.0. More information on http://dev.opml.org. - * Difference is "text" attribute is optional in version 1.0. It is highly - * recommended to use this attribute. - * - * lib_opml requires SimpleXML (php.net/simplexml) and DOMDocument (php.net/domdocument) - * - * @author Marien Fressinaud <dev@marienfressinaud.fr> - * @link https://github.com/marienfressinaud/lib_opml - * @version 0.2-FreshRSS~1.20.0 - * @license public domain - * - * Usages: - * > include('lib_opml.php'); - * > $filename = 'my_opml_file.xml'; - * > $opml_array = libopml_parse_file($filename); - * > print_r($opml_array); - * - * > $opml_string = [...]; - * > $opml_array = libopml_parse_string($opml_string); - * > print_r($opml_array); - * - * > $opml_array = [...]; - * > $opml_string = libopml_render($opml_array); - * > $opml_object = libopml_render($opml_array, true); - * > echo $opml_string; - * > print_r($opml_object); - * - * You can set $strict argument to false if you want to bypass "text" attribute - * requirement. - * - * If parsing fails for any reason (e.g. not an XML string, does not match with - * the specifications), a LibOPML_Exception is raised. - * - * lib_opml array format is described here: - * $array = array( - * 'head' => array( // 'head' element is optional (but recommended) - * 'key' => 'value', // key must be a part of available OPML head elements - * ), - * 'body' => array( // body is required - * array( // this array represents an outline (at least one) - * 'text' => 'value', // 'text' element is required if $strict is true - * 'key' => 'value', // key and value are what you want (optional) - * '@outlines' = array( // @outlines is a special value and represents sub-outlines - * array( - * [...] // where [...] is a valid outline definition - * ), - * ), - * ), - * array( // other outline definitions - * [...] - * ), - * [...], - * ) - * ) - * - */ - -/** - * A simple Exception class which represents any kind of OPML problem. - * Message should precise the current problem. - */ -class LibOPML_Exception extends Exception {} - - -// Define the list of available head attributes. All of them are optional. -define('HEAD_ELEMENTS', serialize(array( - 'title', 'dateCreated', 'dateModified', 'ownerName', 'ownerEmail', - 'ownerId', 'docs', 'expansionState', 'vertScrollState', 'windowTop', - 'windowLeft', 'windowBottom', 'windowRight' -))); - - -/** - * Parse an XML object as an outline object and return corresponding array - * - * @param SimpleXMLElement $outline_xml the XML object we want to parse - * @param bool $strict true if "text" attribute is required, false else - * @return array corresponding to an outline and following format described above - * @throws LibOPML_Exception - * @access private - */ -function libopml_parse_outline($outline_xml, $strict = true) { - $outline = array(); - - // An outline may contain any kind of attributes but "text" attribute is - // required ! - $text_is_present = false; - - $elem = dom_import_simplexml($outline_xml); - /** @var DOMAttr $attr */ - foreach ($elem->attributes as $attr) { - $key = $attr->localName; - - if ($attr->namespaceURI == '') { - $outline[$key] = $attr->value; - } else { - $outline[$key] = [ - 'namespace' => $attr->namespaceURI, - 'value' => $attr->value, - ]; - } - - if ($key === 'text') { - $text_is_present = true; - } - } - - if (!$text_is_present && $strict) { - throw new LibOPML_Exception( - 'Outline does not contain any text attribute' - ); - } - - if (empty($outline['text']) && isset($outline['title'])) { - $outline['text'] = $outline['title']; - } - - foreach ($outline_xml->children() as $key => $value) { - // An outline may contain any number of outline children - if ($key === 'outline') { - $outline['@outlines'][] = libopml_parse_outline($value, $strict); - } - } - - return $outline; -} - -/** - * Reformat the XML document as a hierarchy when - * the OPML 2.0 category attribute is used - */ -function preprocessing_categories($doc) { - $outline_categories = array(); - $body = $doc->getElementsByTagName('body')->item(0); - $xpath = new DOMXpath($doc); - $outlines = $xpath->query('/opml/body/outline[@category]'); - foreach ($outlines as $outline) { - $category = trim($outline->getAttribute('category')); - if ($category != '') { - $outline_category = null; - if (!isset($outline_categories[$category])) { - $outline_category = $doc->createElement('outline'); - $outline_category->setAttribute('text', $category); - $body->insertBefore($outline_category, $body->firstChild); - $outline_categories[$category] = $outline_category; - } else { - $outline_category = $outline_categories[$category]; - } - $outline->parentNode->removeChild($outline); - $outline_category->appendChild($outline); - } - } -} - -/** - * Parse a string as a XML one and returns the corresponding array - * - * @param string $xml is the string we want to parse - * @param bool $strict true to perform some validation (e.g. require "text" attribute), false to relax - * @return array corresponding to the XML string and following format described above - * @throws LibOPML_Exception - * @access public - */ -function libopml_parse_string($xml, $strict = true) { - $dom = new DOMDocument(); - $dom->recover = true; - $dom->strictErrorChecking = false; - $dom->loadXML($xml); - $dom->encoding = 'UTF-8'; - - //Partial compatibility with the category attribute of OPML 2.0 - preprocessing_categories($dom); - - $opml = simplexml_import_dom($dom); - - if (!$opml) { - throw new LibOPML_Exception(); - } - - $array = array( - 'version' => (string)$opml['version'], - 'head' => array(), - 'body' => array() - ); - - if (isset($opml->head)) { - // We get all "head" elements. Head is required but its sub-elements are optional. - foreach ($opml->head->children() as $key => $value) { - if (in_array($key, unserialize(HEAD_ELEMENTS), true)) { - $array['head'][$key] = (string)$value; - } elseif ($strict) { - throw new LibOPML_Exception($key . ' is not part of the OPML 2.0 specification'); - } - } - } elseif ($strict) { - throw new LibOPML_Exception('Required OPML head element is missing!'); - } - - // Then, we get body oulines. Body must contain at least one outline - // element. - $at_least_one_outline = false; - foreach ($opml->body->children() as $key => $value) { - if ($key === 'outline') { - $at_least_one_outline = true; - $array['body'][] = libopml_parse_outline($value, $strict); - } - } - - if (!$at_least_one_outline) { - throw new LibOPML_Exception( - 'OPML body must contain at least one outline element' - ); - } - - return $array; -} - - -/** - * Parse a string contained into a file as a XML string and returns the corresponding array - * - * @param string $filename should indicates a valid XML file - * @param bool $strict true if "text" attribute is required, false else - * @return array corresponding to the file content and following format described above - * @throws LibOPML_Exception - * @access public - */ -function libopml_parse_file($filename, $strict = true) { - $file_content = file_get_contents($filename); - - if ($file_content === false) { - throw new LibOPML_Exception( - $filename . ' cannot be found' - ); - } - - return libopml_parse_string($file_content, $strict); -} - - -/** - * Create a XML outline object in a parent object. - * - * @param SimpleXMLElement $parent_elt is the parent object of current outline - * @param array $outline array representing an outline object - * @param bool $strict true if "text" attribute is required, false else - * @throws LibOPML_Exception - * @access private - */ -function libopml_render_outline($parent_elt, $outline, $strict) { - // Outline MUST be an array! - if (!is_array($outline)) { - throw new LibOPML_Exception( - 'Outline element must be defined as array' - ); - } - - $outline_elt = $parent_elt->addChild('outline'); - $text_is_present = false; - /** @var string|array<string,mixed> $value */ - foreach ($outline as $key => $value) { - // Only outlines can be an array and so we consider children are also - // outline elements. - if ($key === '@outlines' && is_array($value)) { - foreach ($value as $outline_child) { - libopml_render_outline($outline_elt, $outline_child, $strict); - } - } elseif (is_array($value) && !isset($value['namespace'])) { - throw new LibOPML_Exception( - 'Type of outline elements cannot be array (except for providing a namespace): ' . $key - ); - } else { - // Detect text attribute is present, that's good :) - if ($key === 'text') { - $text_is_present = true; - } - if (is_array($value)) { - if (!empty($value['namespace']) && !empty($value['value'])) { - $outline_elt->addAttribute($key, $value['value'], $value['namespace']); - } - } else { - $outline_elt->addAttribute($key, $value); - } - } - } - - if (!$text_is_present && $strict) { - throw new LibOPML_Exception( - 'You must define at least a text element for all outlines' - ); - } -} - - -/** - * Render an array as an OPML string or a XML object. - * - * @param array $array is the array we want to render and must follow structure defined above - * @param bool $as_xml_object false if function must return a string, true for a XML object - * @param bool $strict true if "text" attribute is required, false else - * @return string|SimpleXMLElement XML string corresponding to $array or XML object - * @throws LibOPML_Exception - * @access public - */ -function libopml_render($array, $as_xml_object = false, $strict = true) { - $opml = new SimpleXMLElement('<opml></opml>'); - $opml->addAttribute('version', $strict ? '2.0' : '1.0'); - - // Create head element. $array['head'] is optional but head element will - // exist in the final XML object. - $head = $opml->addChild('head'); - if (isset($array['head'])) { - foreach ($array['head'] as $key => $value) { - if (in_array($key, unserialize(HEAD_ELEMENTS), true)) { - $head->addChild($key, $value); - } - } - } - - // Check body is set and contains at least one element - if (!isset($array['body'])) { - throw new LibOPML_Exception( - '$array must contain a body element' - ); - } - if (count($array['body']) <= 0) { - throw new LibOPML_Exception( - 'Body element must contain at least one element (array)' - ); - } - - // Create outline elements - $body = $opml->addChild('body'); - foreach ($array['body'] as $outline) { - libopml_render_outline($body, $outline, $strict); - } - - // And return the final result - if ($as_xml_object) { - return $opml; - } else { - $dom = dom_import_simplexml($opml)->ownerDocument; - $dom->formatOutput = true; - $dom->encoding = 'UTF-8'; - return $dom->saveXML(); - } -} diff --git a/lib/lib_rss.php b/lib/lib_rss.php index cbdfff773..e5362bc5c 100644 --- a/lib/lib_rss.php +++ b/lib/lib_rss.php @@ -57,6 +57,11 @@ function classAutoloader($class) { $base_dir = LIB_PATH . '/phpgt/cssxpath/src/'; $relative_class_name = substr($class, strlen($prefix)); require $base_dir . str_replace('\\', '/', $relative_class_name) . '.php'; + } elseif (str_starts_with($class, 'marienfressinaud\\LibOpml\\')) { + $prefix = 'marienfressinaud\\LibOpml\\'; + $base_dir = LIB_PATH . '/marienfressinaud/lib_opml/src/LibOpml/'; + $relative_class_name = substr($class, strlen($prefix)); + require $base_dir . str_replace('\\', '/', $relative_class_name) . '.php'; } elseif (str_starts_with($class, 'PHPMailer\\PHPMailer\\')) { $prefix = 'PHPMailer\\PHPMailer\\'; $base_dir = LIB_PATH . '/phpmailer/phpmailer/src/'; diff --git a/lib/marienfressinaud/lib_opml/.gitattributes b/lib/marienfressinaud/lib_opml/.gitattributes new file mode 100644 index 000000000..669ea8c8d --- /dev/null +++ b/lib/marienfressinaud/lib_opml/.gitattributes @@ -0,0 +1,8 @@ +/.* export-ignore + +/ci export-ignore +/examples export-ignore +/tests export-ignore + +/CHANGELOG.md export-ignore +/Makefile export-ignore diff --git a/lib/marienfressinaud/lib_opml/.gitignore b/lib/marienfressinaud/lib_opml/.gitignore new file mode 100644 index 000000000..ca9baaf91 --- /dev/null +++ b/lib/marienfressinaud/lib_opml/.gitignore @@ -0,0 +1,2 @@ +/coverage +/vendor diff --git a/lib/marienfressinaud/lib_opml/CHANGELOG.md b/lib/marienfressinaud/lib_opml/CHANGELOG.md new file mode 100644 index 000000000..ee9245e7e --- /dev/null +++ b/lib/marienfressinaud/lib_opml/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog of lib\_opml + +## 2022-07-25 - v0.5.0 + +- BREAKING CHANGE: Reverse parameters in `libopml_render()` +- BREAKING CHANGE: Validate email and URL address elements +- Add support for PHP 7.2+ +- Add a .gitattributes file +- Improve the documentation about usage +- Add a note about stability in README +- Fix a PHPDoc annotation +- Homogeneize tests with "Newspapers" examples + +## 2022-06-04 - v0.4.0 + +- Refactor the LibOpml class to be not static +- Parse or render attributes according to their types +- Add support for namespaces +- Don't require text attribute if OPML version is 1.0 +- Check that outline text attribute is not empty +- Verify that xmlUrl and url attributes are present according to the type + attribute +- Accept a version attribute in render method +- Handle OPML 1.1 as 1.0 +- Fail if version, head or body is missing +- Fail if OPML version is not supported +- Fail if head contains invalid elements +- Fail if sub-outlines are not arrays when rendering +- Make parsing less strict by default +- Don't raise most parsing errors when strict is false +- Force type attribute to lowercase +- Remove SimpleXML as a requirement +- Homogenize exception messages +- Close pre tags in the example file +- Improve documentation in the README +- Improve comments in the source code +- Add a MR checklist item about changes +- Update the description in composer.json +- Update dev dependencies + +## 2022-04-23 - v0.3.0 + +- Reorganize the architecture of code (using namespaces and classes) +- Change PHP minimum version to 7.4 +- Move to Framagit instead of GitHub +- Change the license to MIT +- Configure lib\_opml with Composer +- Add PHPUnit tests for all the methods and functions +- Add a linter to the project +- Provide a Makefile +- Configure Gitlab CI instead of Travis +- Add a merge request template +- Improve the comments, documentation and examples + +## 2014-03-31 - v0.2.0 + +- Allow to make optional the `text` attribute +- Improve and complete documentation +- Fix examples + +## 2014-03-29 - v0.1.0 + +First version diff --git a/lib/marienfressinaud/lib_opml/LICENSE b/lib/marienfressinaud/lib_opml/LICENSE new file mode 100644 index 000000000..2ad7f2db4 --- /dev/null +++ b/lib/marienfressinaud/lib_opml/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Marien Fressinaud + +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/lib/marienfressinaud/lib_opml/README.md b/lib/marienfressinaud/lib_opml/README.md new file mode 100644 index 000000000..34026bc14 --- /dev/null +++ b/lib/marienfressinaud/lib_opml/README.md @@ -0,0 +1,338 @@ +# lib\_opml + +lib\_opml is a library to read and write OPML in PHP. + +OPML is a standard designed to store and exchange outlines (i.e. a tree +structure arranged to show hierarchical relationships). It is mainly used to +exchange list of feeds between feed aggregators. The specification is +available at [opml.org](http://opml.org). + +lib\_opml has been tested with PHP 7.2+. It requires [DOMDocument](https://www.php.net/manual/book.dom.php) +to work. + +It supports versions 1.0 and 2.0 of OPML since these are the only published +versions. Version 1.1 is treated as version 1.0, as stated by the specification. + +It is licensed under the [MIT license](/LICENSE). + +## Installation + +lib\_opml is available on [Packagist](https://packagist.org/packages/marienfressinaud/lib_opml) +and it is recommended to install it with Composer: + +```console +$ composer require marienfressinaud/lib_opml +``` + +If you don’t use Composer, you can download [the ZIP archive](https://framagit.org/marienfressinaud/lib_opml/-/archive/main/lib_opml-main.zip) +and copy the content of the `src/` folder in your project. Then, load the files +manually: + +```php +<?php +require 'path/to/lib_opml/LibOpml/Exception.php'; +require 'path/to/lib_opml/LibOpml/LibOpml.php'; +require 'path/to/lib_opml/functions.php'; +``` + +## Usage + +### Parse OPML + +Let’s say that you have an OPML file named `my_opml_file.xml`: + +```xml +<?xml version="1.0" encoding="UTF-8" ?> +<opml version="2.0"> + <head> + <title>My OPML</title> + </head> + <body> + <outline text="Newspapers"> + <outline text="El País" /> + <outline text="Le Monde" /> + <outline text="The Guardian" /> + <outline text="The New York Times" /> + </outline> + </body> +</opml> +``` + +You can load it with: + +```php +$opml_array = libopml_parse_file('my_opml_file.xml'); +``` + +lib\_opml parses the file and returns an array: + +```php +[ + 'version' => '2.0', + 'namespaces' => [], + 'head' => [ + 'title' => 'My OPML' + ], + 'body' => [ // each entry of the body is an outline + [ + 'text' => 'Newspapers', + '@outlines' => [ // sub-outlines are accessible with the @outlines key + ['text' => 'El País'], + ['text' => 'Le Monde'], + ['text' => 'The Guardian'], + ['text' => 'The New York Times'] + ] + ] + ] +] +``` + +Since it's just an array, it's very simple to manipulate: + +```php +foreach ($opml_array['body'] as $outline) { + echo $outline['text']; +} +``` + +You also can load directly an OPML string: + +```php +$opml_string = '<opml>...</opml>'; +$opml_array = libopml_parse_string($opml_string); +``` + +### Render OPML + +lib\_opml is able to render an OPML string from an array. It checks that the +data is valid and respects the specification. + +```php +$opml_array = [ + 'head' => [ + 'title' => 'My OPML', + ], + 'body' => [ + [ + 'text' => 'Newspapers', + '@outlines' => [ + ['text' => 'El País'], + ['text' => 'Le Monde'], + ['text' => 'The Guardian'], + ['text' => 'The New York Times'] + ] + ] + ] +]; + +$opml_string = libopml_render($opml_array); + +file_put_contents('my_opml_file.xml', $opml_string); +``` + +### Handle errors + +If rendering (or parsing) fails for any reason (e.g. empty `body`, missing +`text` attribute, wrong element type), a `\marienfressinaud\LibOpml\Exception` +is raised: + +```php +try { + $opml_array = libopml_render([ + 'body' => [] + ]); +} catch (\marienfressinaud\LibOpml\Exception $e) { + echo $e->getMessage(); +} +``` + +### Class style + +lib\_opml can also be used with a class style: + +```php +use marienfressinaud\LibOpml; + +$libopml = new LibOpml\LibOpml(); + +$opml_array = $libopml->parseFile($filename); +$opml_array = $libopml->parseString($opml_string); +$opml_string = $libopml->render($opml_array); +``` + +### Special elements and attributes + +Some elements have special meanings according to the specification, which means +they can be parsed to a specific type by lib\_opml. In the other way, when +rendering an OPML string, you must pass these elements with their correct +types. + +Head elements: + +- `dateCreated` is parsed to a `\DateTime`; +- `dateModified` is parsed to a `\DateTime`; +- `expansionState` is parsed to an array of integers; +- `vertScrollState` is parsed to an integer; +- `windowTop` is parsed to an integer; +- `windowLeft` is parsed to an integer; +- `windowBottom` is parsed to an integer; +- `windowRight` is parsed to an integer. + +Outline attributes: + +- `created` is parsed to a `\DateTime`; +- `category` is parsed to an array of strings; +- `isComment` is parsed to a boolean; +- `isBreakpoint` is parsed to a boolean. + +If one of these elements is not of the correct type, an Exception is raised. + +Finally, there are additional checks based on the outline type attribute: + +- if `type="rss"`, then the `xmlUrl` attribute is required; +- if `type="link"`, then the `url` attribute is required; +- if `type="include"`, then the `url` attribute is required. + +Note that the `type` attribute is case-insensitive and will always be lowercased. + +### Namespaces + +OPML can be extended with namespaces: + +> An OPML file may contain elements and attributes not described on this page, +> only if those elements are defined in a namespace, as specified by the W3C. + +When rendering an OPML, you can include a `namespaces` key to specify +namespaces: + +```php +$opml_array = [ + 'namespaces' => [ + 'test' => 'https://example.com/test', + ], + 'body' => [ + ['text' => 'My outline', 'test:path' => '/some/example/path'], + ], +]; + +$opml_string = libopml_render($opml_array); +echo $opml_string; +``` + +This will output: + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<opml xmlns:test="https://example.com/test" version="2.0"> + <head/> + <body> + <outline text="My outline" test:path="/some/example/path"/> + </body> +</opml> +``` + +### Strictness + +You can tell lib\_opml to be less or more strict when parsing or rendering OPML. +This is done by passing an optional `$strict` attribute to the functions. When +strict is `false`, most of the specification requirements are simply ignored +and lib\_opml will do its best to parse (or generate) an OPML. + +By default, parsing is not strict so you’ll be able to read most of the files +out there. If you want the parsing to be strict (to validate a file for +instance), pass `true` to `libopml_parse_file()` or `libopml_parse_string()`: + +```php +$opml_array = libopml_parse_file($filename, true); +$opml_array = libopml_parse_string($opml_string, true); +``` + +On the other side, reading is strict by default, so you are encouraged to +generate valid OPMLs. If you need to relax the strictness, pass `false` to +`libopml_render()`: + +```php +$opml_string = libopml_render($opml_array, false); +``` + +Please note that when using the class form, strict is passed during the object +instantiation: + +```php +use marienfressinaud\LibOpml; + +// lib_opml will be strict for both parsing and rendering! +$libopml = new LibOpml\LibOpml(true); + +$opml_array = $libopml->parseString($opml_string); +$opml_string = $libopml->render($opml_array); +``` + +## Examples and documented source code + +See the [`examples/`](/examples) folder for concrete examples. + +You are encouraged to read the source code to learn more about lib\_opml. Thus, +the full documentation is available as comments in the code: + +- [`src/LibOpml/LibOpml.php`](src/LibOpml/LibOpml.php) +- [`src/LibOpml/Exception.php`](src/LibOpml/Exception.php) +- [`src/functions.php`](src/functions.php) + +## Changelog + +See [CHANGELOG.md](/CHANGELOG.md). + +## Support and stability + +Today, lib\_opml covers all the aspects of the OPML specification. Since the +spec didn't change for more than 15 years, it is expected for the library to +not change a lot in the future. Thus, I plan to release the v1.0 in a near +future. I'm only waiting for more tests to be done on its latest version (in +particular in FreshRSS, see [FreshRSS/FreshRSS#4403](https://github.com/FreshRSS/FreshRSS/pull/4403)). +I would also wait for clarifications about the specification (see [scripting/opml.org#3](https://github.com/scripting/opml.org/issues/3)), +but it isn't a hard requirement. + +After the release of 1.0, lib\_opml will be considered as “finished”. This +means I will not add new features, nor break the existing code. However, I +commit myself to continue to support the library to fix security issues, bugs, +or to add support to new PHP versions. + +In consequence, you can expect lib\_opml to be stable. + +## Tests and linters + +This section is for developers of lib\_opml. + +To run the tests, you’ll have to install Composer first (see [the official +documentation](https://getcomposer.org/doc/00-intro.md)). Then, install the +dependencies: + +```console +$ make install +``` + +You should now have a `vendor/` folder containing the development dependencies. + +Run the tests with: + +```console +$ make test +``` + +Run the linter with: + +```console +$ make lint +$ make lint-fix +``` + +## Contributing + +Please submit bug reports and merge requests to the [Framagit repository](https://framagit.org/marienfressinaud/lib_opml). + +There’s not a lot to do, but the documentation and examples could probably be +improved. + +Merge requests require that you fill a short checklist to save me time while +reviewing your changes. You also must make sure the test suite succeeds. diff --git a/lib/marienfressinaud/lib_opml/composer.json b/lib/marienfressinaud/lib_opml/composer.json new file mode 100644 index 000000000..ba48d16ed --- /dev/null +++ b/lib/marienfressinaud/lib_opml/composer.json @@ -0,0 +1,35 @@ +{ + "name": "marienfressinaud/lib_opml", + "description": "A library to read and write OPML in PHP.", + "license": "MIT", + "authors": [ + { + "name": "Marien Fressinaud", + "email": "dev@marienfressinaud.fr" + } + ], + "require": { + "php": ">=7.2.0", + "ext-dom": "*" + }, + "config": { + "platform": { + "php": "7.2.0" + } + }, + "support": { + "issues": "https://framagit.org/marienfressinaud/lib_opml/-/issues" + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "marienfressinaud\\": "src/" + } + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.6", + "phpunit/phpunit": "^8" + } +} diff --git a/lib/marienfressinaud/lib_opml/src/LibOpml/Exception.php b/lib/marienfressinaud/lib_opml/src/LibOpml/Exception.php new file mode 100644 index 000000000..27c3287a2 --- /dev/null +++ b/lib/marienfressinaud/lib_opml/src/LibOpml/Exception.php @@ -0,0 +1,15 @@ +<?php + +namespace marienfressinaud\LibOpml; + +/** + * A simple Exception class which represents any kind of OPML problem. + * Message precises the current problem. + * + * @author Marien Fressinaud <dev@marienfressinaud.fr> + * @link https://framagit.org/marienfressinaud/lib_opml + * @license MIT + */ +class Exception extends \Exception +{ +} diff --git a/lib/marienfressinaud/lib_opml/src/LibOpml/LibOpml.php b/lib/marienfressinaud/lib_opml/src/LibOpml/LibOpml.php new file mode 100644 index 000000000..4ba0df821 --- /dev/null +++ b/lib/marienfressinaud/lib_opml/src/LibOpml/LibOpml.php @@ -0,0 +1,770 @@ +<?php + +namespace marienfressinaud\LibOpml; + +/** + * The LibOpml class provides the methods to read and write OPML files and + * strings. It transforms OPML files or strings to PHP arrays (or the reverse). + * + * How to read this file? + * + * The first methods are dedicated to the parsing, and the next ones to the + * reading. The three last methods are helpful methods, but you don't have to + * worry too much about them. + * + * The main methods are the public ones: parseFile, parseString and render. + * They call the other parse* and render* methods internally. + * + * These three main methods are available as functions (see the src/functions.php + * file). + * + * What's the array format? + * + * As said before, LibOpml transforms OPML to PHP arrays, or the reverse. The + * format is pretty simple. It contains four keys: + * + * - version: the version of the OPML; + * - namespaces: an array of namespaces used in the OPML, if any; + * - head: an array of OPML head elements, where keys are the names of the + * elements; + * - body: an array of arrays representing OPML outlines, where keys are the + * name of the attributes (the special @outlines key contains the sub-outlines). + * + * When rendering, only the body key is required (version will default to 2.0). + * + * Example: + * + * [ + * version => '2.0', + * namespaces => [], + * head => [ + * title => 'An OPML file' + * ], + * body => [ + * [ + * text => 'Newspapers', + * @outlines => [ + * [text => 'El País'], + * [text => 'Le Monde'], + * [text => 'The Guardian'], + * [text => 'The New York Times'], + * ] + * ] + * ] + * ] + * + * @see http://opml.org/spec2.opml + * + * @author Marien Fressinaud <dev@marienfressinaud.fr> + * @link https://framagit.org/marienfressinaud/lib_opml + * @license MIT + */ +class LibOpml +{ + /** + * The list of valid head elements. + */ + public const HEAD_ELEMENTS = [ + 'title', 'dateCreated', 'dateModified', 'ownerName', 'ownerEmail', + 'ownerId', 'docs', 'expansionState', 'vertScrollState', 'windowTop', + 'windowLeft', 'windowBottom', 'windowRight' + ]; + + /** + * The list of numeric head elements. + */ + public const NUMERIC_HEAD_ELEMENTS = [ + 'vertScrollState', + 'windowTop', + 'windowLeft', + 'windowBottom', + 'windowRight', + ]; + + /** @var boolean */ + private $strict = true; + + /** @var string */ + private $version = '2.0'; + + /** @var string[] */ + private $namespaces = []; + + /** + * @param bool $strict + * Set to true (default) to check for violations of the specification, + * false otherwise. + */ + public function __construct($strict = true) + { + $this->strict = $strict; + } + + /** + * Parse a XML file and return the corresponding array. + * + * @param string $filename + * The XML file to parse. + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the file cannot be read. See also exceptions raised by the + * parseString method. + * + * @return array + * An array reflecting the OPML (the structure is described above). + */ + public function parseFile($filename) + { + $file_content = @file_get_contents($filename); + + if ($file_content === false) { + throw new Exception("OPML file {$filename} cannot be found or read"); + } + + return $this->parseString($file_content); + } + + /** + * Parse a XML string and return the corresponding array. + * + * @param string $xml + * The XML string to parse. + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the XML cannot be parsed, if version is missing or + * invalid, if head is missing or contains invalid (or not parsable) + * elements, or if body is missing, empty or contain non outline + * elements. The exceptions (except XML parsing errors) are not raised + * if strict is false. See also exceptions raised by the parseOutline + * method. + * + * @return array + * An array reflecting the OPML (the structure is described above). + */ + public function parseString($xml) + { + $dom = new \DOMDocument(); + $dom->recover = true; + $dom->encoding = 'UTF-8'; + + try { + $result = @$dom->loadXML($xml); + } catch (\Exception | \Error $e) { + $result = false; + } + + if (!$result) { + throw new Exception('OPML string is not valid XML'); + } + + $opml_element = $dom->documentElement; + + // Load the custom namespaces of the document + $xpath = new \DOMXPath($dom); + $this->namespaces = []; + foreach ($xpath->query('//namespace::*') as $node) { + if ($node->prefix === 'xml') { + // This is the base namespace, we don't need to store it + continue; + } + + $this->namespaces[$node->prefix] = $node->namespaceURI; + } + + // Get the version of the document + $version = $opml_element->getAttribute('version'); + if (!$version) { + $this->throwExceptionIfStrict('OPML version attribute is required'); + } + + $version = trim($version); + if ($version === '1.1') { + $version = '1.0'; + } + + if ($version !== '1.0' && $version !== '2.0') { + $this->throwExceptionIfStrict('OPML supported versions are 1.0 and 2.0'); + } + + $this->version = $version; + + // Get head and body child elements + $head_elements = $opml_element->getElementsByTagName('head'); + $child_head_elements = []; + if (count($head_elements) === 1) { + $child_head_elements = $head_elements[0]->childNodes; + } else { + $this->throwExceptionIfStrict('OPML must contain one and only one head element'); + } + + $body_elements = $opml_element->getElementsByTagName('body'); + $child_body_elements = []; + if (count($body_elements) === 1) { + $child_body_elements = $body_elements[0]->childNodes; + } else { + $this->throwExceptionIfStrict('OPML must contain one and only one body element'); + } + + $array = [ + 'version' => $this->version, + 'namespaces' => $this->namespaces, + 'head' => [], + 'body' => [], + ]; + + // Load the child head elements in the head array + foreach ($child_head_elements as $child_head_element) { + if ($child_head_element->nodeType !== XML_ELEMENT_NODE) { + continue; + } + + $name = $child_head_element->nodeName; + $value = $child_head_element->nodeValue; + $namespaced = $child_head_element->namespaceURI !== null; + + if (!in_array($name, self::HEAD_ELEMENTS) && !$namespaced) { + $this->throwExceptionIfStrict( + "OPML head {$name} element is not part of the specification" + ); + } + + if ($name === 'dateCreated' || $name === 'dateModified') { + try { + $value = $this->parseDate($value); + } catch (\DomainException $e) { + $this->throwExceptionIfStrict( + "OPML head {$name} element must be a valid RFC822 or RFC1123 date" + ); + } + } elseif ($name === 'ownerEmail') { + // Testing email validity is hard. PHP filter_var() function is + // too strict compared to the RFC 822, so we can't use it. + if (strpos($value, '@') === false) { + $this->throwExceptionIfStrict( + 'OPML head ownerEmail element must be an email address' + ); + } + } elseif ($name === 'ownerId' || $name === 'docs') { + if (!$this->checkHttpAddress($value)) { + $this->throwExceptionIfStrict( + "OPML head {$name} element must be a HTTP address" + ); + } + } elseif ($name === 'expansionState') { + $numbers = explode(',', $value); + $value = array_map(function ($str_number) { + if (is_numeric($str_number)) { + return intval($str_number); + } else { + $this->throwExceptionIfStrict( + 'OPML head expansionState element must be a list of numbers' + ); + return $str_number; + } + }, $numbers); + } elseif (in_array($name, self::NUMERIC_HEAD_ELEMENTS)) { + if (is_numeric($value)) { + $value = intval($value); + } else { + $this->throwExceptionIfStrict("OPML head {$name} element must be a number"); + } + } + + $array['head'][$name] = $value; + } + + // Load the child body elements in the body array + foreach ($child_body_elements as $child_body_element) { + if ($child_body_element->nodeType !== XML_ELEMENT_NODE) { + continue; + } + + if ($child_body_element->nodeName === 'outline') { + $array['body'][] = $this->parseOutline($child_body_element); + } else { + $this->throwExceptionIfStrict( + 'OPML body element can only contain outline elements' + ); + } + } + + if (empty($array['body'])) { + $this->throwExceptionIfStrict( + 'OPML body element must contain at least one outline element' + ); + } + + return $array; + } + + /** + * Parse a XML element as an outline element and return the corresponding array. + * + * @param \DOMElement $outline_element + * The element to parse. + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the outline contains non-outline elements, if it doesn't + * contain a text attribute (or if empty), if a special attribute is + * not parsable, or if type attribute requirements are not met. The + * exceptions are not raised if strict is false. The exception about + * missing text attribute is not raised if version is 1.0. + * + * @return array + * An array reflecting the OPML outline (the structure is described above). + */ + private function parseOutline($outline_element) + { + $outline = []; + + // Load the element attributes in the outline array + foreach ($outline_element->attributes as $outline_attribute) { + $name = $outline_attribute->nodeName; + $value = $outline_attribute->nodeValue; + + if ($name === 'created') { + try { + $value = $this->parseDate($value); + } catch (\DomainException $e) { + $this->throwExceptionIfStrict( + 'OPML outline created attribute must be a valid RFC822 or RFC1123 date' + ); + } + } elseif ($name === 'category') { + $categories = explode(',', $value); + $categories = array_map(function ($category) { + return trim($category); + }, $categories); + $value = $categories; + } elseif ($name === 'isComment' || $name === 'isBreakpoint') { + if ($value === 'true' || $value === 'false') { + $value = $value === 'true'; + } else { + $this->throwExceptionIfStrict( + "OPML outline {$name} attribute must be a boolean (true or false)" + ); + } + } elseif ($name === 'type') { + // type attribute is case-insensitive + $value = strtolower($value); + } + + $outline[$name] = $value; + } + + if (empty($outline['text']) && $this->version !== '1.0') { + $this->throwExceptionIfStrict( + 'OPML outline text attribute is required' + ); + } + + // Perform additional check based on the type of the outline + $type = $outline['type'] ?? ''; + if ($type === 'rss') { + if (empty($outline['xmlUrl'])) { + $this->throwExceptionIfStrict( + 'OPML outline xmlUrl attribute is required when type is "rss"' + ); + } elseif (!$this->checkHttpAddress($outline['xmlUrl'])) { + $this->throwExceptionIfStrict( + 'OPML outline xmlUrl attribute must be a HTTP address when type is "rss"' + ); + } + } elseif ($type === 'link' || $type === 'include') { + if (empty($outline['url'])) { + $this->throwExceptionIfStrict( + "OPML outline url attribute is required when type is \"{$type}\"" + ); + } elseif (!$this->checkHttpAddress($outline['url'])) { + $this->throwExceptionIfStrict( + "OPML outline url attribute must be a HTTP address when type is \"{$type}\"" + ); + } + } + + // Load the sub-outlines in a @outlines array + foreach ($outline_element->childNodes as $child_outline_element) { + if ($child_outline_element->nodeType !== XML_ELEMENT_NODE) { + continue; + } + + if ($child_outline_element->nodeName === 'outline') { + $outline['@outlines'][] = $this->parseOutline($child_outline_element); + } else { + $this->throwExceptionIfStrict( + 'OPML body element can only contain outline elements' + ); + } + } + + return $outline; + } + + /** + * Parse a value as a date. + * + * @param string $value + * + * @throws \DomainException + * Raised if the value cannot be parsed. + * + * @return \DateTime + */ + private function parseDate($value) + { + $formats = [ + \DateTimeInterface::RFC822, + \DateTimeInterface::RFC1123, + ]; + + foreach ($formats as $format) { + $date = date_create_from_format($format, $value); + if ($date !== false) { + return $date; + } + } + + throw new \DomainException('The argument cannot be parsed as a date'); + } + + /** + * Render an OPML array as a string or a \DOMDocument. + * + * @param array $array + * The array to render, it must follow the structure defined above. + * @param bool $as_dom_document + * Set to false (default) to return the array as a string, true to + * return as a \DOMDocument. + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the `head` array contains unknown or invalid elements + * (i.e. not of correct type), or if the `body` array is missing or + * empty. The exceptions are not raised if strict is false. See also + * exceptions raised by the renderOutline method. + * + * @return string|\DOMDocument + * The XML string or DOM document corresponding to the given array. + */ + public function render($array, $as_dom_document = false) + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $opml_element = new \DOMElement('opml'); + $dom->appendChild($opml_element); + + // Set the version attribute of the OPML document + $version = $array['version'] ?? '2.0'; + + if ($version === '1.1') { + $version = '1.0'; + } + + if ($version !== '1.0' && $version !== '2.0') { + $this->throwExceptionIfStrict('OPML supported versions are 1.0 and 2.0'); + } + + $this->version = $version; + $opml_element->setAttribute('version', $this->version); + + // Declare the namespace on the opml element + $this->namespaces = $array['namespaces'] ?? []; + foreach ($this->namespaces as $prefix => $namespace) { + $opml_element->setAttributeNS( + 'http://www.w3.org/2000/xmlns/', + "xmlns:{$prefix}", + $namespace + ); + } + + // Add the head element to the OPML document. $array['head'] is + // optional but head tag will always exist in the final XML. + $head_element = new \DOMElement('head'); + $opml_element->appendChild($head_element); + if (isset($array['head'])) { + foreach ($array['head'] as $name => $value) { + $namespace = $this->getNamespace($name); + + if (!in_array($name, self::HEAD_ELEMENTS, true) && !$namespace) { + $this->throwExceptionIfStrict( + "OPML head {$name} element is not part of the specification" + ); + } + + if ($name === 'dateCreated' || $name === 'dateModified') { + if ($value instanceof \DateTimeInterface) { + $value = $value->format(\DateTimeInterface::RFC1123); + } else { + $this->throwExceptionIfStrict( + "OPML head {$name} element must be a DateTime" + ); + } + } elseif ($name === 'ownerEmail') { + // Testing email validity is hard. PHP filter_var() function is + // too strict compared to the RFC 822, so we can't use it. + if (strpos($value, '@') === false) { + $this->throwExceptionIfStrict( + 'OPML head ownerEmail element must be an email address' + ); + } + } elseif ($name === 'ownerId' || $name === 'docs') { + if (!$this->checkHttpAddress($value)) { + $this->throwExceptionIfStrict( + "OPML head {$name} element must be a HTTP address" + ); + } + } elseif ($name === 'expansionState') { + if (is_array($value)) { + foreach ($value as $number) { + if (!is_int($number)) { + $this->throwExceptionIfStrict( + 'OPML head expansionState element must be an array of integers' + ); + } + } + + $value = implode(', ', $value); + } else { + $this->throwExceptionIfStrict( + 'OPML head expansionState element must be an array of integers' + ); + } + } elseif (in_array($name, self::NUMERIC_HEAD_ELEMENTS)) { + if (!is_int($value)) { + $this->throwExceptionIfStrict( + "OPML head {$name} element must be an integer" + ); + } + } + + $child_head_element = new \DOMElement($name, $value, $namespace); + $head_element->appendChild($child_head_element); + } + } + + // Check body is set and contains at least one element + if (!isset($array['body'])) { + $this->throwExceptionIfStrict('OPML array must contain a body key'); + } + + $array_body = $array['body'] ?? []; + if (count($array_body) <= 0) { + $this->throwExceptionIfStrict( + 'OPML body element must contain at least one outline array' + ); + } + + // Create outline elements in the body element + $body_element = new \DOMElement('body'); + $opml_element->appendChild($body_element); + foreach ($array_body as $outline) { + $this->renderOutline($body_element, $outline); + } + + // And return the final result + if ($as_dom_document) { + return $dom; + } else { + $dom->formatOutput = true; + return $dom->saveXML(); + } + } + + /** + * Transform an outline array to a \DOMElement and add it to a parent element. + * + * @param \DOMElement $parent_element + * The DOM parent element of the current outline. + * @param array $outline + * The outline array to transform in a \DOMElement, it must follow the + * structure defined above. + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the outline is not an array, if it doesn't contain a text + * attribute (or if empty), if the `@outlines` key is not an array, if + * a special attribute does not match its corresponding type, or if + * `type` key requirements are not met. The exceptions (except errors + * about outline or suboutlines not being arrays) are not raised if + * strict is false. The exception about missing text attribute is not + * raised if version is 1.0. + */ + private function renderOutline($parent_element, $outline) + { + // Perform initial checks to verify the outline is correctly declared + if (!is_array($outline)) { + throw new Exception( + 'OPML outline element must be defined as an array' + ); + } + + if (empty($outline['text']) && $this->version !== '1.0') { + $this->throwExceptionIfStrict( + 'OPML outline text attribute is required' + ); + } + + if (isset($outline['type'])) { + $type = strtolower($outline['type']); + + if ($type === 'rss') { + if (empty($outline['xmlUrl'])) { + $this->throwExceptionIfStrict( + 'OPML outline xmlUrl attribute is required when type is "rss"' + ); + } elseif (!$this->checkHttpAddress($outline['xmlUrl'])) { + $this->throwExceptionIfStrict( + 'OPML outline xmlUrl attribute must be a HTTP address when type is "rss"' + ); + } + } elseif ($type === 'link' || $type === 'include') { + if (empty($outline['url'])) { + $this->throwExceptionIfStrict( + "OPML outline url attribute is required when type is \"{$type}\"" + ); + } elseif (!$this->checkHttpAddress($outline['url'])) { + $this->throwExceptionIfStrict( + "OPML outline url attribute must be a HTTP address when type is \"{$type}\"" + ); + } + } + } + + // Create the outline element and add it to the parent + $outline_element = new \DOMElement('outline'); + $parent_element->appendChild($outline_element); + + // Load the sub-outlines as child elements + if (isset($outline['@outlines'])) { + $outline_children = $outline['@outlines']; + + if (!is_array($outline_children)) { + throw new Exception( + 'OPML outline element must be defined as an array' + ); + } + + foreach ($outline_children as $outline_child) { + $this->renderOutline($outline_element, $outline_child); + } + + // We don't want the sub-outlines to be loaded as attributes, so we + // remove the key from the array. + unset($outline['@outlines']); + } + + // Load the other elements of the array as attributes + foreach ($outline as $name => $value) { + $namespace = $this->getNamespace($name); + + if ($name === 'created') { + if ($value instanceof \DateTimeInterface) { + $value = $value->format(\DateTimeInterface::RFC1123); + } else { + $this->throwExceptionIfStrict( + 'OPML outline created attribute must be a DateTime' + ); + } + } elseif ($name === 'isComment' || $name === 'isBreakpoint') { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } else { + $this->throwExceptionIfStrict( + "OPML outline {$name} attribute must be a boolean" + ); + } + } elseif (is_array($value)) { + $value = implode(', ', $value); + } + + $outline_element->setAttributeNS($namespace, $name, $value); + } + } + + /** + * Return wether a value is a valid HTTP address or not. + * + * HTTP address is not strictly defined by the OPML spec, so it is assumed: + * + * - it can be parsed by parse_url + * - it has a host part + * - scheme is http or https + * + * filter_var is not used because it would reject internationalized URLs + * (i.e. with non ASCII chars). An alternative would be to punycode such + * URLs, but it's more work to do it properly, and lib_opml needs to stay + * simple. + * + * @param string $value + * + * @return boolean + * Return true if the value is a valid HTTP address, false otherwise. + */ + public function checkHttpAddress($value) + { + $value = trim($value); + $parsed_url = parse_url($value); + if (!$parsed_url) { + return false; + } + + if ( + !isset($parsed_url['scheme']) || + !isset($parsed_url['host']) + ) { + return false; + } + + if ( + $parsed_url['scheme'] !== 'http' && + $parsed_url['scheme'] !== 'https' + ) { + return false; + } + + return true; + } + + /** + * Return the namespace of a qualified name. An empty string is returned if + * the name is not namespaced. + * + * @param string $qualified_name + * + * @throws \marienfressinaud\LibOpml\Exception + * Raised if the namespace prefix isn't declared. + * + * @return string + */ + private function getNamespace($qualified_name) + { + $split_name = explode(':', $qualified_name, 2); + // count will always be 1 or 2. + if (count($split_name) === 1) { + // If 1, there's no prefix, thus no namespace + return ''; + } else { + // If 2, it means it has a namespace prefix, so we get the + // namespace from the declared ones. + $namespace_prefix = $split_name[0]; + if (!isset($this->namespaces[$namespace_prefix])) { + throw new Exception( + "OPML namespace {$namespace_prefix} is not declared" + ); + } + + return $this->namespaces[$namespace_prefix]; + } + } + + /** + * Raise an exception only if strict is true. + * + * @param string $message + * + * @throws \marienfressinaud\LibOpml\Exception + */ + private function throwExceptionIfStrict($message) + { + if ($this->strict) { + throw new Exception($message); + } + } +} |
