summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBernhard Posselt <dev@bernhard-posselt.com>2014-11-05 11:30:27 +0100
committerBernhard Posselt <dev@bernhard-posselt.com>2014-11-05 11:30:38 +0100
commit95530f62513a82c385d9378b4a59da57d74092d9 (patch)
treea4d17994548999b42e99371f381da8c256ee39bf
parent57163ad25a7dc63abd8aff8663c185ddad398466 (diff)
update picofeed, add max size setting, fix #642
-rw-r--r--3rdparty/autoload.php2
-rw-r--r--3rdparty/composer/autoload_real.php10
-rw-r--r--3rdparty/composer/installed.json12
-rw-r--r--3rdparty/fguillot/picofeed/.gitignore3
-rw-r--r--3rdparty/fguillot/picofeed/.travis.yml4
-rw-r--r--3rdparty/fguillot/picofeed/README.markdown9
-rw-r--r--3rdparty/fguillot/picofeed/docs/feed-parsing.markdown269
-rw-r--r--3rdparty/fguillot/picofeed/docs/installation.markdown71
-rw-r--r--3rdparty/fguillot/picofeed/docs/tests.markdown14
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Client.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Client.php)85
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/ClientException.php16
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Curl.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Clients/Curl.php)64
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Favicon.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Favicon.php)33
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Grabber.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Grabber.php)15
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/InvalidCertificateException.php13
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/InvalidUrlException.php13
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/MaxRedirectException.php13
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/MaxSizeException.php13
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Stream.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Clients/Stream.php)15
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/TimeoutException.php13
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Url.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Url.php)4
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Config/Config.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Config.php)46
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Encoding/Encoding.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Encoding.php)2
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Attribute.php27
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Filter.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Filter.php)43
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Html.php15
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Tag.php2
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Logging/Logging.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Logging.php)16
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Atom.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Atom.php)63
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Feed.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Feed.php)4
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Item.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Item.php)4
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/MalformedXmlException.php13
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Parser.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Parser.php)259
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/ParserException.php16
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss10.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss10.php)33
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss20.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss20.php)67
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss91.php13
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss92.php13
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/XmlParser.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/XmlParser.php)4
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss91.php17
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss92.php17
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/PicoFeed.php25
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/PicoFeedException.php15
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Reader.php310
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/Reader.php232
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/ReaderException.php16
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/SubscriptionNotFoundException.php13
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/UnsupportedFeedFormatException.php13
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Serialization/Export.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Export.php)4
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Serialization/Import.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Import.php)7
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Syndication/Atom.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Writers/Atom.php)5
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Syndication/Rss20.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Writers/Rss20.php)5
-rw-r--r--3rdparty/fguillot/picofeed/lib/PicoFeed/Syndication/Writer.php (renamed from 3rdparty/fguillot/picofeed/lib/PicoFeed/Writer.php)4
-rw-r--r--3rdparty/fguillot/picofeed/phpunit.xml2
-rwxr-xr-x3rdparty/fguillot/picofeed/picofeed6
-rw-r--r--3rdparty/fguillot/picofeed/tests/Client/ClientTest.php (renamed from 3rdparty/fguillot/picofeed/tests/ClientTest.php)10
-rw-r--r--3rdparty/fguillot/picofeed/tests/Client/CurlTest.php (renamed from 3rdparty/fguillot/picofeed/tests/CurlTest.php)41
-rw-r--r--3rdparty/fguillot/picofeed/tests/Client/FaviconTest.php (renamed from 3rdparty/fguillot/picofeed/tests/FaviconTest.php)5
-rw-r--r--3rdparty/fguillot/picofeed/tests/Client/GrabberTest.php (renamed from 3rdparty/fguillot/picofeed/tests/GrabberTest.php)18
-rw-r--r--3rdparty/fguillot/picofeed/tests/Client/StreamTest.php (renamed from 3rdparty/fguillot/picofeed/tests/StreamTest.php)34
-rw-r--r--3rdparty/fguillot/picofeed/tests/Client/UrlTest.php (renamed from 3rdparty/fguillot/picofeed/tests/UrlTest.php)4
-rw-r--r--3rdparty/fguillot/picofeed/tests/Filter/AttributeFilterTest.php (renamed from 3rdparty/fguillot/picofeed/tests/AttributeFilterTest.php)7
-rw-r--r--3rdparty/fguillot/picofeed/tests/Filter/FilterTest.php (renamed from 3rdparty/fguillot/picofeed/tests/FilterTest.php)7
-rw-r--r--3rdparty/fguillot/picofeed/tests/Filter/HtmlFilterTest.php (renamed from 3rdparty/fguillot/picofeed/tests/HtmlFilterTest.php)4
-rw-r--r--3rdparty/fguillot/picofeed/tests/Filter/TagFilterTest.php (renamed from 3rdparty/fguillot/picofeed/tests/TagFilterTest.php)4
-rw-r--r--3rdparty/fguillot/picofeed/tests/Parser/AtomParserTest.php (renamed from 3rdparty/fguillot/picofeed/tests/AtomParserTest.php)76
-rw-r--r--3rdparty/fguillot/picofeed/tests/Parser/ParserTest.php (renamed from 3rdparty/fguillot/picofeed/tests/ParserTest.php)30
-rw-r--r--3rdparty/fguillot/picofeed/tests/Parser/Rss10ParserTest.php (renamed from 3rdparty/fguillot/picofeed/tests/Rss10ParserTest.php)39
-rw-r--r--3rdparty/fguillot/picofeed/tests/Parser/Rss20ParserTest.php (renamed from 3rdparty/fguillot/picofeed/tests/Rss20ParserTest.php)114
-rw-r--r--3rdparty/fguillot/picofeed/tests/Parser/Rss91ParserTest.php (renamed from 3rdparty/fguillot/picofeed/tests/Rss91ParserTest.php)8
-rw-r--r--3rdparty/fguillot/picofeed/tests/Parser/Rss92ParserTest.php (renamed from 3rdparty/fguillot/picofeed/tests/Rss92ParserTest.php)17
-rw-r--r--3rdparty/fguillot/picofeed/tests/Parser/XmlParserTest.php (renamed from 3rdparty/fguillot/picofeed/tests/XmlParserTest.php)6
-rw-r--r--3rdparty/fguillot/picofeed/tests/Reader/ReaderTest.php157
-rw-r--r--3rdparty/fguillot/picofeed/tests/ReaderTest.php108
-rw-r--r--3rdparty/fguillot/picofeed/tests/Serialization/ExportTest.php (renamed from 3rdparty/fguillot/picofeed/tests/ExportTest.php)4
-rw-r--r--3rdparty/fguillot/picofeed/tests/Serialization/ImportTest.php (renamed from 3rdparty/fguillot/picofeed/tests/ImportTest.php)4
-rw-r--r--3rdparty/fguillot/picofeed/tests/Syndication/AtomWriterTest.php (renamed from 3rdparty/fguillot/picofeed/tests/AtomWriterTest.php)4
-rw-r--r--3rdparty/fguillot/picofeed/tests/Syndication/Rss20WriterTest.php (renamed from 3rdparty/fguillot/picofeed/tests/Rss20WriterTest.php)4
-rw-r--r--3rdparty/fguillot/picofeed/tests/fixtures/groovehq.xml1767
-rw-r--r--3rdparty/fguillot/picofeed/tests/fixtures/womensweardaily.xml63
-rw-r--r--CHANGELOG.md1
-rw-r--r--README.md1
-rw-r--r--appinfo/application.php12
-rw-r--r--composer.json2
-rw-r--r--composer.lock12
-rw-r--r--config/config.php17
-rw-r--r--controller/admincontroller.php6
-rw-r--r--fetcher/feedfetcher.php81
-rw-r--r--js/admin/Admin.js5
-rw-r--r--service/feedservice.php8
-rw-r--r--templates/admin.php17
-rw-r--r--tests/unit/articleenhancer/XPathArticleEnhancerTest.php2
-rw-r--r--tests/unit/config/ConfigTest.php4
-rw-r--r--tests/unit/controller/AdminControllerTest.php13
-rw-r--r--tests/unit/fetcher/FeedFetcherTest.php111
-rw-r--r--tests/unit/service/FeedServiceTest.php2
-rw-r--r--utility/picofeedclientfactory.php4
-rw-r--r--utility/picofeedfaviconfactory.php4
-rw-r--r--utility/picofeedreaderfactory.php38
99 files changed, 3424 insertions, 1473 deletions
diff --git a/3rdparty/autoload.php b/3rdparty/autoload.php
index 120b16acc..3cd25b0d2 100644
--- a/3rdparty/autoload.php
+++ b/3rdparty/autoload.php
@@ -4,4 +4,4 @@
require_once __DIR__ . '/composer' . '/autoload_real.php';
-return ComposerAutoloaderInit7bb1478f65d3f193519a3262170cb8bf::getLoader();
+return ComposerAutoloaderInit4750e3a2a6327c742e19653287d1e34f::getLoader();
diff --git a/3rdparty/composer/autoload_real.php b/3rdparty/composer/autoload_real.php
index 1a9ed9123..16fe7ad69 100644
--- a/3rdparty/composer/autoload_real.php
+++ b/3rdparty/composer/autoload_real.php
@@ -2,7 +2,7 @@
// autoload_real.php @generated by Composer
-class ComposerAutoloaderInit7bb1478f65d3f193519a3262170cb8bf
+class ComposerAutoloaderInit4750e3a2a6327c742e19653287d1e34f
{
private static $loader;
@@ -19,9 +19,9 @@ class ComposerAutoloaderInit7bb1478f65d3f193519a3262170cb8bf
return self::$loader;
}
- spl_autoload_register(array('ComposerAutoloaderInit7bb1478f65d3f193519a3262170cb8bf', 'loadClassLoader'), true, true);
+ spl_autoload_register(array('ComposerAutoloaderInit4750e3a2a6327c742e19653287d1e34f', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader();
- spl_autoload_unregister(array('ComposerAutoloaderInit7bb1478f65d3f193519a3262170cb8bf', 'loadClassLoader'));
+ spl_autoload_unregister(array('ComposerAutoloaderInit4750e3a2a6327c742e19653287d1e34f', 'loadClassLoader'));
$includePaths = require __DIR__ . '/include_paths.php';
array_push($includePaths, get_include_path());
@@ -46,14 +46,14 @@ class ComposerAutoloaderInit7bb1478f65d3f193519a3262170cb8bf
$includeFiles = require __DIR__ . '/autoload_files.php';
foreach ($includeFiles as $file) {
- composerRequire7bb1478f65d3f193519a3262170cb8bf($file);
+ composerRequire4750e3a2a6327c742e19653287d1e34f($file);
}
return $loader;
}
}
-function composerRequire7bb1478f65d3f193519a3262170cb8bf($file)
+function composerRequire4750e3a2a6327c742e19653287d1e34f($file)
{
require $file;
}
diff --git a/3rdparty/composer/installed.json b/3rdparty/composer/installed.json
index cc63486b6..f7a314210 100644
--- a/3rdparty/composer/installed.json
+++ b/3rdparty/composer/installed.json
@@ -114,23 +114,23 @@
},
{
"name": "fguillot/picofeed",
- "version": "dev-master",
- "version_normalized": "9999999-dev",
+ "version": "dev-0.1.0-dev",
+ "version_normalized": "dev-0.1.0-dev",
"source": {
"type": "git",
"url": "https://github.com/fguillot/picoFeed.git",
- "reference": "dd5c122aea0a95ec2c932ee487a8fb4fd307cc6f"
+ "reference": "e7e32522b487256c3164eeece30203313b09456a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/fguillot/picoFeed/zipball/dd5c122aea0a95ec2c932ee487a8fb4fd307cc6f",
- "reference": "dd5c122aea0a95ec2c932ee487a8fb4fd307cc6f",
+ "url": "https://api.github.com/repos/fguillot/picoFeed/zipball/e7e32522b487256c3164eeece30203313b09456a",
+ "reference": "e7e32522b487256c3164eeece30203313b09456a",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
- "time": "2014-10-19 18:18:06",
+ "time": "2014-11-05 01:21:29",
"type": "library",
"installation-source": "dist",
"autoload": {
diff --git a/3rdparty/fguillot/picofeed/.gitignore b/3rdparty/fguillot/picofeed/.gitignore
index 496ee2ca6..b0ef0680a 100644
--- a/3rdparty/fguillot/picofeed/.gitignore
+++ b/3rdparty/fguillot/picofeed/.gitignore
@@ -1 +1,2 @@
-.DS_Store \ No newline at end of file
+.DS_Store
+vendor/ \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/.travis.yml b/3rdparty/fguillot/picofeed/.travis.yml
index 83334e8c4..0c3d0fe55 100644
--- a/3rdparty/fguillot/picofeed/.travis.yml
+++ b/3rdparty/fguillot/picofeed/.travis.yml
@@ -7,4 +7,6 @@ php:
- "5.3"
before_script: wget https://phar.phpunit.de/phpunit.phar
-script: php phpunit.phar
+script:
+ - composer dump-autoload
+ - php phpunit.phar
diff --git a/3rdparty/fguillot/picofeed/README.markdown b/3rdparty/fguillot/picofeed/README.markdown
index 99ef68237..8f01b4e8e 100644
--- a/3rdparty/fguillot/picofeed/README.markdown
+++ b/3rdparty/fguillot/picofeed/README.markdown
@@ -38,17 +38,20 @@ Authors
-------
- Original author: [Frédéric Guillot](http://fredericguillot.com/)
-- Contributors: See pull-requests, issues tracker and commit history
+- Major Contributors:
+ - [Bernhard Posselt](https://github.com/Raydiation)
+ - [David Pennington](https://github.com/Xeoncross)
Documentation
-------------
- [Installation](docs/installation.markdown)
-- [OPML file importation](docs/opml-import.markdown)
-- [OPML file exportation](docs/opml-export.markdown)
+- [Running unit tests](docs/tests.markdown)
- [Feed parsing](docs/feed-parsing.markdown)
- [Feed creation](docs/feed-creation.markdown)
- [Favicon fetcher](docs/favicon.markdown)
+- [OPML file importation](docs/opml-import.markdown)
+- [OPML file exportation](docs/opml-export.markdown)
- [Web scraping](docs/grabber.markdown)
- [Debugging](docs/debugging.markdown)
- [Configuration](docs/config.markdown)
diff --git a/3rdparty/fguillot/picofeed/docs/feed-parsing.markdown b/3rdparty/fguillot/picofeed/docs/feed-parsing.markdown
index 10f20d31a..22f84339b 100644
--- a/3rdparty/fguillot/picofeed/docs/feed-parsing.markdown
+++ b/3rdparty/fguillot/picofeed/docs/feed-parsing.markdown
@@ -1,205 +1,164 @@
Feed parsing
============
-Download and parse a feed
--------------------------
-
-Try this example from a command line script:
+Parsing a subscription
+----------------------
```php
-<?php
-
-require 'path/to/PicoFeed.php';
-
-use PicoFeed\Reader;
+use PicoFeed\Reader\Reader;
+use PicoFeed\PicoFeedException;
-$reader = new Reader;
+try {
-// Try to discover the XML feed automatically
-$reader->download('http://bbc.co.uk/news');
+ $reader = new Reader;
-$parser = $reader->getParser();
+ // Return a resource
+ $resource = $reader->download('https://linuxfr.org/news.atom');
-if ($parser !== false) {
+ // Return the right parser instance according to the feed format
+ $parser = $reader->getParser(
+ $resource->getUrl(),
+ $resource->getContent(),
+ $resource->getEncoding()
+ );
+ // Return a Feed object
$feed = $parser->execute();
- if ($feed !== false) {
- echo $feed;
- }
+ // Print the feed properties with the magic method __toString()
+ echo $feed;
+}
+catch (PicoFeedException $e) {
+ // Do Something...
}
```
-- The method `getParser()` return `false` when there is something wrong during the download or the feed detection
-- The call `$parser->execute()` return `false` when there is a parsing error
-
-In your terminal you will got an output like that:
-
-```
-Feed::id = http://www.bbc.co.uk/news/#sa-ns_mchannel=rss&ns_source=PublicRSS20-sa
-Feed::title = BBC News - Home
-Feed::url = http://www.bbc.co.uk/news/#sa-ns_mchannel=rss&ns_source=PublicRSS20-sa
-Feed::date = 1399934742
-Feed::language = en-gb
-Feed::items = 84 items
+- The Reader class is the entry point for feed reading
+- The method `download()` fetch the remote content and return a resource, an instance of `PicoFeed\Client\Client`
+- The method `getParser()` returns a Parser instance according to the feed format Atom, Rss 2.0...
+- The parser itself returns a `Feed` object that contains feed and item properties
+
+Output:
+
+```bash
+Feed::id = tag:linuxfr.org,2005:/news
+Feed::title = LinuxFr.org : les dépêches
+Feed::url = http://linuxfr.org/news
+Feed::date = 1415138079
+Feed::language = en-US
+Feed::description =
+Feed::logo =
+Feed::items = 15 items
----
-Item::id = e411a646
-Item::title = Nigeria rejects captive girls 'swap'
-Item::url = http://www.bbc.co.uk/news/world-africa-27386285#sa-ns_mchannel=rss&ns_source=PublicRSS20-sa
-Item::date = 1399933404
-Item::language = en-gb
-Item::author =
+Item::id = 38d8f48284fb03940cbb3aff9101089b81e44efb1281641bdd7c3e7e4bf3b0cd
+Item::title = openSUSE 13.2 : nouvelle version du caméléon disponible !
+Item::url = http://linuxfr.org/news/opensuse-13-2-nouvelle-version-du-cameleon-disponible
+Item::date = 1415122640
+Item::language = en-US
+Item::author = Syvolc
Item::enclosure_url =
Item::enclosure_type =
-Item::content = <p>Nigeria insists i... (152 bytes)
+Item::content = 18307 bytes
----
-Item::id = 6c50fcf2
-Item::title = Woman tells of Harris 'assaults'
-Item::url = http://www.bbc.co.uk/news/uk-27371573#sa-ns_mchannel=rss&ns_source=PublicRSS20-sa
-Item::date = 1399908906
-Item::language = en-gb
-Item::author =
+Item::id = d0ebddc90bfc3f109f9be00a3bb0b4a770af7a647cdc88454fe15d79168e0dea
+Item::title = Fuzix OS, parce que les petites choses sont belles
+Item::url = http://linuxfr.org/news/fuzix-os-parce-que-les-petites-choses-sont-belles
+Item::date = 1415112167
+Item::language = en-US
+Item::author = Thomas DEBESSE
Item::enclosure_url =
Item::enclosure_type =
-Item::content = <p>A woman tells the... (142 bytes)
-...........
-```
-
-This ouput is generated by the magic method `__toString()` of the class `Feed` and `Item`.
-All properties are public and they are also available with getter methods:
-
-```php
-
-// Examples for the feed:
-echo $feed->getId(); // Unique feed id
-echo $feed->getTitle(); // Feed title
-echo $feed->getUrl(); // Feed url
-echo $feed->getDate(); // Feed last updated date
-echo $feed->getLanguage(); // Feed language
-echo $feed->getDescription(); // Feed description
-echo $feed->getLogo(); // Feed logo (can be a large image, different from icon)
-echo $feed->getItems(); // List of items
-
-// Examples for items:
-echo $feed->items[0]->getId();
-echo $feed->items[0]->getTitle();
-echo $feed->items[0]->getUrl();
-echo $feed->items[0]->getDate();
-echo $feed->items[0]->getLanguage();
-echo $feed->items[0]->getAuthor();
-echo $feed->items[0]->getEnclosureUrl();
-echo $feed->items[0]->getEnclosureType();
-echo $feed->items[0]->getContent();
+Item::content = 6104 bytes
+....
```
-Handle HTTP cache
------------------
-
-To avoid downloading and parsing the feed each time, it's a good idea to handle the HTTP caching:
+Get the list of available subscriptions for a website
+-----------------------------------------------------
-1. After the first HTTP request, we save somewhere (in a database) the headers Etag and Last-Modified for the next checks
-2. If the feed is not modified, we don't need to parse again the feed
-
-Example:
+The example below will returns all available subscriptions for the website:
```php
-use PicoFeed\Reader;
-
-$reader = new Reader;
+use PicoFeed\Reader\Reader;
-// Get last modified infos from previous requests
-$lastModified = '...';
-$etag = '...';
+try {
-// Download directly the feed
-$resource = $reader->download('http://linuxfr.org/news.atom', $lastModified, $etag);
+ $reader = new Reader;
+ $resource = $reader->download('http://www.cnn.com');
-// Return true is the feed has changed
-if ($resource->isModified()) {
+ $feeds = $reader->find(
+ $resource->getUrl(),
+ $resource->getContent()
+ );
- $parser = $reader->getParser();
-
- if ($parser !== false) {
-
- $feed = $parser->execute();
-
- if ($feed !== false) {
-
- // Save cache infos for the next request
- $lastModified = $resource->getLastModified();
- $etag = $resource->getEtag();
- }
- }
+ print_r($feeds);
+}
+catch (PicoFeedException $e) {
+ // Do something...
}
```
-Use a custom user agent
------------------------
-
-You have to define a custom configuration for that:
+Output:
```php
-use PicoFeed\Reader;
-use PicoFeed\Config;
-
-$config = new Config;
-$config->setClientUserAgent('My RSS Reader');
-
-$reader = new Reader($config);
-...
+Array
+(
+ [0] => http://rss.cnn.com/rss/cnn_topstories.rss
+ [1] => http://rss.cnn.com/rss/cnn_latest.rss
+)
```
-The complete config parameters are [described here](config.markdown).
+Feed discovery and parsing
+--------------------------
-Set a custom timezone
----------------------
-
-By default, the timezone used is UTC but you can define a custom timezone for the logging and item parsing.
+This example will discover automatically the subscription and parse the feed:
```php
-use PicoFeed\Reader;
-use PicoFeed\Config;
-
-$config = new Config;
-$config->setTimezone('Europe/Paris');
+try {
-$reader = new Reader($config);
-...
-```
-
-[List of supported TimeZones](http://php.net/manual/en/timezones.php)
-
-Disable content filtering
--------------------------
+ $reader = new Reader;
+ $resource = $reader->discover('http://linuxfr.org');
-If you want to disable the internal filtering system to use an external library like [HTMLPurifier](http://htmlpurifier.org):
-
-```php
-use PicoFeed\Reader;
-use PicoFeed\Config;
+ $parser = $reader->getParser(
+ $resource->getUrl(),
+ $resource->getContent(),
+ $resource->getEncoding()
+ );
-$config = new Config;
-$config->setTimezone('Europe/Paris');
-$config->setContentFiltering(false);
-
-$reader = new Reader($config);
-...
+ $feed = $parser->execute();
+ echo $feed;
+}
+catch (PicoFeedException $e) {
+}
```
-or
+HTTP caching
+------------
-```php
-use PicoFeed\Reader;
+TODO
-$reader = new Reader;
-$reader->download('http://.....');
-$parser = $reader->getParser();
+Feed and item properties
+------------------------
-if ($parser !== false) {
-
- $parser->disableContentFiltering(); // <= Disable content filtering
- $feed = $parser->execute();
- // ...
-}
+```php
+// Feed object
+$feed->getId(); // Unique feed id
+$feed->getTitle(); // Feed title
+$feed->getUrl(); // Website url
+$feed->getDate(); // Feed last updated date
+$feed->getLanguage(); // Feed language
+$feed->getDescription(); // Feed description
+$feed->getLogo(); // Feed logo (can be a large image, different from icon)
+$feed->getItems(); // List of item objects
+
+// Item object
+$feed->items[0]->getId(); // Item unique id (hash)
+$feed->items[0]->getTitle(); // Item title
+$feed->items[0]->getUrl(); // Item url
+$feed->items[0]->getDate(); // Item published date (timestamp)
+$feed->items[0]->getLanguage(); // Item language
+$feed->items[0]->getAuthor(); // Item author
+$feed->items[0]->getEnclosureUrl(); // Enclosure url
+$feed->items[0]->getEnclosureType(); // Enclosure mime-type (audio/mp3, image/png...)
+$feed->items[0]->getContent(); // Item content (filtered or raw)
```
diff --git a/3rdparty/fguillot/picofeed/docs/installation.markdown b/3rdparty/fguillot/picofeed/docs/installation.markdown
index d88c590e1..827908f75 100644
--- a/3rdparty/fguillot/picofeed/docs/installation.markdown
+++ b/3rdparty/fguillot/picofeed/docs/installation.markdown
@@ -1,23 +1,38 @@
Installation
============
-Installation with Composer and AutoLoading
-------------------------------------------
+Versions
+--------
+
+- Development version: branch master
+- Available versions:
+ - v0.1.0 (stable)
+ - v0.0.2
+ - v0.0.1
+
+Installation with Composer
+--------------------------
Configure your `composer.json`:
```json
{
"require": {
- "fguillot/picofeed": "dev-master"
+ "fguillot/picofeed": "0.1.0"
}
}
```
+Or simply:
+
+```bash
+composer require fguillot/picofeed:0.1.0
+```
+
And download the code:
```bash
-php composer.phar install # or update
+composer install # or update
```
Usage example with the Composer autoloading:
@@ -27,48 +42,24 @@ Usage example with the Composer autoloading:
require 'vendor/autoload.php';
-use PicoFeed\Reader;
+use PicoFeed\Reader\Reader;
-$reader = new Reader;
-$reader->download('http://linuxfr.org/news.atom');
+try {
-$parser = $reader->getParser();
+ $reader = new Reader;
+ $resource = $reader->download('https://linuxfr.org/news.atom');
-if ($parser !== false) {
+ $parser = $reader->getParser(
+ $resource->getUrl(),
+ $resource->getContent(),
+ $resource->getEncoding()
+ );
$feed = $parser->execute();
- if ($feed !== false) {
- echo $feed->title;
- }
+ echo $feed;
}
-```
-
-Installation without AutoLoading
---------------------------------
-
-If you don't want to use an autoloader, you can include the file `PicoFeed.php`.
-
-Example:
-
-```php
-<?php
-
-require 'path/to/PicoFeed.php';
-
-use PicoFeed\Reader;
-
-$reader = new Reader;
-$reader->download('http://linuxfr.org/news.atom');
-
-$parser = $reader->getParser();
-
-if ($parser !== false) {
-
- $feed = $parser->execute();
-
- if ($feed !== false) {
- echo $feed->title;
- }
+catch (Exception $e) {
+ // Do something...
}
```
diff --git a/3rdparty/fguillot/picofeed/docs/tests.markdown b/3rdparty/fguillot/picofeed/docs/tests.markdown
new file mode 100644
index 000000000..72bb48b0f
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/docs/tests.markdown
@@ -0,0 +1,14 @@
+Running unit tests
+==================
+
+If the autoloader is not yet installed run:
+
+```php
+composer dump-autoload
+```
+
+Then run:
+
+```php
+phpunit tests
+```
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Client.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Client.php
index 59e9aa9ce..7328b2c75 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Client.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Client.php
@@ -1,10 +1,9 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Client;
use LogicException;
-use Clients\Curl;
-use Clients\Stream;
+use PicoFeed\Logging\Logging;
/**
* Client class
@@ -23,14 +22,6 @@ abstract class Client
private $is_modified = true;
/**
- * Flag that say if the resource is a 404
- *
- * @access private
- * @var bool
- */
- private $is_not_found = false;
-
- /**
* HTTP encoding
*
* @access private
@@ -135,23 +126,28 @@ abstract class Client
protected $max_body_size = 2097152; // 2MB
/**
+ * Do the HTTP request
+ *
+ * @abstract
+ * @access public
+ * @return array
+ */
+ abstract public function doRequest();
+
+ /**
* Get client instance: curl or stream driver
*
* @static
* @access public
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public static function getInstance()
{
if (function_exists('curl_init')) {
-
- require_once __DIR__.'/Clients/Curl.php';
- return new Clients\Curl;
+ return new Curl;
}
else if (ini_get('allow_url_fopen')) {
-
- require_once __DIR__.'/Clients/Stream.php';
- return new Clients\Stream;
+ return new Stream;
}
throw new LogicException('You must have "allow_url_fopen=1" or curl extension installed');
@@ -162,7 +158,7 @@ abstract class Client
*
* @access public
* @param string $url URL
- * @return bool
+ * @return Client
*/
public function execute($url = '')
{
@@ -176,14 +172,11 @@ abstract class Client
$response = $this->doRequest();
- if (is_array($response)) {
- $this->handleNotModifiedResponse($response);
- $this->handleNotFoundResponse($response);
- $this->handleNormalResponse($response);
- return true;
- }
+ $this->handleNotModifiedResponse($response);
+ $this->handleNotFoundResponse($response);
+ $this->handleNormalResponse($response);
- return false;
+ return $this;
}
/**
@@ -224,8 +217,7 @@ abstract class Client
public function handleNotFoundResponse(array $response)
{
if ($response['status'] == 404) {
- $this->is_not_found = true;
- Logging::setMessage(get_called_class().' Resource not found');
+ throw new InvalidUrlException('Resource not found');
}
}
@@ -319,7 +311,7 @@ abstract class Client
*
* @access public
* @param string $last_modified Header value
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setLastModified($last_modified)
{
@@ -343,7 +335,7 @@ abstract class Client
*
* @access public
* @param string $etag Etag HTTP header value
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setEtag($etag)
{
@@ -378,7 +370,7 @@ abstract class Client
*
* @access public
* @return string
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setUrl($url)
{
@@ -420,22 +412,11 @@ abstract class Client
}
/**
- * Return true if the remote resource is not found
- *
- * @access public
- * @return bool
- */
- public function isNotFound()
- {
- return $this->is_not_found;
- }
-
- /**
* Set connection timeout
*
* @access public
* @param integer $timeout Connection timeout
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setTimeout($timeout)
{
@@ -448,7 +429,7 @@ abstract class Client
*
* @access public
* @param string $user_agent User Agent
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setUserAgent($user_agent)
{
@@ -461,7 +442,7 @@ abstract class Client
*
* @access public
* @param integer $max Maximum
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setMaxRedirections($max)
{
@@ -474,7 +455,7 @@ abstract class Client
*
* @access public
* @param integer $max Maximum
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setMaxBodySize($max)
{
@@ -487,7 +468,7 @@ abstract class Client
*
* @access public
* @param string $hostname Proxy hostname
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setProxyHostname($hostname)
{
@@ -500,7 +481,7 @@ abstract class Client
*
* @access public
* @param integer $port Proxy port
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setProxyPort($port)
{
@@ -513,7 +494,7 @@ abstract class Client
*
* @access public
* @param string $username Proxy username
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setProxyUsername($username)
{
@@ -526,7 +507,7 @@ abstract class Client
*
* @access public
* @param string $password Password
- * @return \PicoFeed\Client
+ * @return \PicoFeed\Client\Client
*/
public function setProxyPassword($password)
{
@@ -538,8 +519,8 @@ abstract class Client
* Set config object
*
* @access public
- * @param \PicoFeed\Config $config Config instance
- * @return \PicoFeed\Client
+ * @param \PicoFeed\Config\Config $config Config instance
+ * @return \PicoFeed\Config\Config
*/
public function setConfig($config)
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/ClientException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/ClientException.php
new file mode 100644
index 000000000..0e27452ed
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/ClientException.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace PicoFeed\Client;
+
+use PicoFeed\PicoFeedException;
+
+
+/**
+ * ClientException Exception
+ *
+ * @author Frederic Guillot
+ * @package Client
+ */
+abstract class ClientException extends PicoFeedException
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Clients/Curl.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Curl.php
index 055f72e4f..9cf3eb6f4 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Clients/Curl.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Curl.php
@@ -1,15 +1,14 @@
<?php
-namespace PicoFeed\Clients;
+namespace PicoFeed\Client;
-use \PicoFeed\Logging;
-use \PicoFeed\Client;
+use PicoFeed\Logging\Logging;
/**
* cURL HTTP client
*
* @author Frederic Guillot
- * @package client
+ * @package Client
*/
class Curl extends Client
{
@@ -159,8 +158,6 @@ class Curl extends Client
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $this->url);
- curl_setopt($ch, CURLOPT_FRESH_CONNECT, true);
- curl_setopt($ch, CURLOPT_FORBID_REUSE, true);
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->timeout);
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
@@ -168,7 +165,6 @@ class Curl extends Client
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, ini_get('open_basedir') === '');
curl_setopt($ch, CURLOPT_MAXREDIRS, $this->max_redirects);
curl_setopt($ch, CURLOPT_ENCODING, '');
- curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // For auto-signed certificates...
curl_setopt($ch, CURLOPT_WRITEFUNCTION, array($this, 'readBody'));
curl_setopt($ch, CURLOPT_HEADERFUNCTION, array($this, 'readHeaders'));
curl_setopt($ch, CURLOPT_COOKIEJAR, 'php://memory');
@@ -183,7 +179,6 @@ class Curl extends Client
* Execute curl context
*
* @access private
- * @return resource
*/
private function executeContext()
{
@@ -196,15 +191,16 @@ class Curl extends Client
Logging::setMessage(get_called_class().' cURL speed download: '.curl_getinfo($ch, CURLINFO_SPEED_DOWNLOAD));
Logging::setMessage(get_called_class().' cURL effective url: '.curl_getinfo($ch, CURLINFO_EFFECTIVE_URL));
- if (curl_errno($ch)) {
+ $curl_errno = curl_errno($ch);
+
+ if ($curl_errno) {
Logging::setMessage(get_called_class().' cURL error: '.curl_error($ch));
curl_close($ch);
- return false;
+
+ $this->handleError($curl_errno);
}
curl_close($ch);
-
- return true;
}
/**
@@ -216,9 +212,7 @@ class Curl extends Client
*/
public function doRequest($follow_location = true)
{
- if (! $this->executeContext()) {
- return false;
- }
+ $this->executeContext();
list($status, $headers) = $this->parseHeaders(explode("\r\n", $this->headers[$this->headers_counter - 1]));
@@ -287,4 +281,44 @@ class Curl extends Client
return false;
}
+
+ /**
+ * Handle cURL errors (throw individual exceptions)
+ *
+ * We don't use constants because they are not necessary always available
+ * (depends of the version of libcurl linked to php)
+ *
+ * @see http://curl.haxx.se/libcurl/c/libcurl-errors.html
+ * @access private
+ * @param integer $errno cURL error code
+ */
+ private function handleError($errno)
+ {
+ switch ($errno) {
+ case 78: // CURLE_REMOTE_FILE_NOT_FOUND
+ throw new InvalidUrlException('Resource not found');
+ case 6: // CURLE_COULDNT_RESOLVE_HOST
+ throw new InvalidUrlException('Unable to resolve hostname');
+ case 7: // CURLE_COULDNT_CONNECT
+ throw new InvalidUrlException('Unable to connect to the remote host');
+ case 28: // CURLE_OPERATION_TIMEDOUT
+ throw new TimeoutException('Operation timeout');
+ case 35: // CURLE_SSL_CONNECT_ERROR
+ case 51: // CURLE_PEER_FAILED_VERIFICATION
+ case 58: // CURLE_SSL_CERTPROBLEM
+ case 60: // CURLE_SSL_CACERT
+ case 59: // CURLE_SSL_CIPHER
+ case 64: // CURLE_USE_SSL_FAILED
+ case 66: // CURLE_SSL_ENGINE_INITFAILED
+ case 77: // CURLE_SSL_CACERT_BADFILE
+ case 83: // CURLE_SSL_ISSUER_ERROR
+ throw new InvalidCertificateException('Invalid SSL certificate');
+ case 47: // CURLE_TOO_MANY_REDIRECTS
+ throw new MaxRedirectException('Maximum number of redirections reached');
+ case 63: // CURLE_FILESIZE_EXCEEDED
+ throw new MaxSizeException('Maximum response size exceeded');
+ default:
+ throw new InvalidUrlException('Unable to fetch the URL');
+ }
+ }
}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Favicon.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Favicon.php
index ec8753107..b6d3b6d26 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Favicon.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Favicon.php
@@ -1,16 +1,20 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Client;
use DOMXpath;
+use PicoFeed\Config\Config;
+use PicoFeed\Logging\Logging;
+use PicoFeed\Parser\XmlParser;
+
/**
* Favicon class
*
* https://en.wikipedia.org/wiki/Favicon
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Client
*/
class Favicon
{
@@ -18,9 +22,9 @@ class Favicon
* Config class instance
*
* @access private
- * @var \PicoFeed\Config
+ * @var \PicoFeed\Config\Config
*/
- private $config = null;
+ private $config;
/**
* Icon content
@@ -34,7 +38,7 @@ class Favicon
* Constructor
*
* @access public
- * @param \PicoFeed\Config $config Config class instance
+ * @param \PicoFeed\Config\Config $config Config class instance
*/
public function __construct(Config $config = null)
{
@@ -61,16 +65,19 @@ class Favicon
*/
public function download($url)
{
- Logging::setMessage(get_called_class().' Download => '.$url);
+ try {
+
+ Logging::setMessage(get_called_class().' Download => '.$url);
- $client = Client::getInstance();
- $client->setConfig($this->config);
+ $client = Client::getInstance();
+ $client->setConfig($this->config);
+ $client->execute($url);
- if ($client->execute($url) && ! $client->isNotFound()) {
return $client->getContent();
}
-
- return '';
+ catch (ClientException $e) {
+ return '';
+ }
}
/**
@@ -116,8 +123,8 @@ class Favicon
* Convert icon links to absolute url
*
* @access public
- * @param \PicoFeed\Url $website Website url
- * @param \PicoFeed\Url $icon Icon url
+ * @param \PicoFeed\Client\Url $website Website url
+ * @param \PicoFeed\Client\Url $icon Icon url
* @return string
*/
public function convertLink(Url $website, Url $icon)
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Grabber.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Grabber.php
index 97f1e0574..a072fc805 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Grabber.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Grabber.php
@@ -1,14 +1,19 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Client;
use DOMXPath;
+use PicoFeed\Encoding\Encoding;
+use PicoFeed\Logging\Logging;
+use PicoFeed\Filter\Filter;
+use PicoFeed\Parser\XmlParser;
+
/**
* Grabber class
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Client
*/
class Grabber
{
@@ -119,9 +124,9 @@ class Grabber
* Config object
*
* @access private
- * @var \PicoFeed\Config
+ * @var \PicoFeed\Config\Config
*/
- private $config = null;
+ private $config;
/**
* Constructor
@@ -259,7 +264,7 @@ class Grabber
foreach ($files as $file) {
- $filename = __DIR__.'/Rules/'.$file.'.php';
+ $filename = __DIR__.'/../Rules/'.$file.'.php';
if (file_exists($filename)) {
Logging::setMessage(get_called_class().' Load rule: '.$file);
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/InvalidCertificateException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/InvalidCertificateException.php
new file mode 100644
index 000000000..ece3f303f
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/InvalidCertificateException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * InvalidCertificateException Exception
+ *
+ * @author Frederic Guillot
+ * @package Client
+ */
+class InvalidCertificateException extends ClientException
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/InvalidUrlException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/InvalidUrlException.php
new file mode 100644
index 000000000..0298f0dc8
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/InvalidUrlException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * InvalidUrlException Exception
+ *
+ * @author Frederic Guillot
+ * @package Client
+ */
+class InvalidUrlException extends ClientException
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/MaxRedirectException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/MaxRedirectException.php
new file mode 100644
index 000000000..1651d7f6b
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/MaxRedirectException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * MaxRedirectException Exception
+ *
+ * @author Frederic Guillot
+ * @package Client
+ */
+class MaxRedirectException extends ClientException
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/MaxSizeException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/MaxSizeException.php
new file mode 100644
index 000000000..60bb9f132
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/MaxSizeException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * MaxSizeException Exception
+ *
+ * @author Frederic Guillot
+ * @package Client
+ */
+class MaxSizeException extends ClientException
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Clients/Stream.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Stream.php
index f16952fc7..bc9809c4d 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Clients/Stream.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Stream.php
@@ -1,15 +1,14 @@
<?php
-namespace PicoFeed\Clients;
+namespace PicoFeed\Client;
-use \PicoFeed\Logging;
-use \PicoFeed\Client;
+use PicoFeed\Logging\Logging;
/**
* Stream context HTTP client
*
* @author Frederic Guillot
- * @package client
+ * @package Client
*/
class Stream extends Client
{
@@ -96,7 +95,7 @@ class Stream extends Client
// Make HTTP request
$stream = @fopen($this->url, 'r', false, $context);
if (! is_resource($stream)) {
- return false;
+ throw new InvalidUrlException('Unable to establish a connection');
}
// Get the entire body until the max size
@@ -104,12 +103,16 @@ class Stream extends Client
// If the body size is too large abort everything
if (strlen($body) > $this->max_body_size) {
- return false;
+ throw new MaxSizeException('Content size too large');
}
// Get HTTP headers response
$metadata = stream_get_meta_data($stream);
+ if ($metadata['timed_out']) {
+ throw new TimeoutException('Operation timeout');
+ }
+
list($status, $headers) = $this->parseHeaders($metadata['wrapper_data']);
fclose($stream);
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/TimeoutException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/TimeoutException.php
new file mode 100644
index 000000000..6ba5cbee2
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/TimeoutException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace PicoFeed\Client;
+
+/**
+ * TimeoutException Exception
+ *
+ * @author Frederic Guillot
+ * @package Client
+ */
+class TimeoutException extends ClientException
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Url.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Url.php
index d16a447ca..90d7fb6f7 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Url.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Client/Url.php
@@ -1,12 +1,12 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Client;
/**
* URL class
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Client
*/
class Url
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Config.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Config/Config.php
index 283ce2384..298b9a2d7 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Config.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Config/Config.php
@@ -1,6 +1,6 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Config;
/**
* Config class
@@ -8,28 +8,28 @@ namespace PicoFeed;
* @author Frederic Guillot
* @package picofeed
*
- * @method \PicoFeed\Config setClientTimeout(integer $value)
- * @method \PicoFeed\Config setClientUserAgent(string $value)
- * @method \PicoFeed\Config setMaxRedirections(integer $value)
- * @method \PicoFeed\Config setMaxBodySize(integer $value)
- * @method \PicoFeed\Config setProxyHostname(string $value)
- * @method \PicoFeed\Config setProxyPort(integer $value)
- * @method \PicoFeed\Config setProxyUsername(string $value)
- * @method \PicoFeed\Config setProxyPassword(string $value)
- * @method \PicoFeed\Config setGrabberTimeout(integer $value)
- * @method \PicoFeed\Config setGrabberUserAgent(string $value)
- * @method \PicoFeed\Config setParserHashAlgo(string $value)
- * @method \PicoFeed\Config setContentFiltering(boolean $value)
- * @method \PicoFeed\Config setTimezone(string $value)
- * @method \PicoFeed\Config setFilterIframeWhitelist(array $value)
- * @method \PicoFeed\Config setFilterIntegerAttributes(array $value)
- * @method \PicoFeed\Config setFilterAttributeOverrides(array $value)
- * @method \PicoFeed\Config setFilterRequiredAttributes(array $value)
- * @method \PicoFeed\Config setFilterMediaBlacklist(array $value)
- * @method \PicoFeed\Config setFilterMediaAttributes(array $value)
- * @method \PicoFeed\Config setFilterSchemeWhitelist(array $value)
- * @method \PicoFeed\Config setFilterWhitelistedTags(array $value)
- * @method \PicoFeed\Config setFilterBlacklistedTags(array $value)
+ * @method \PicoFeed\Config\Config setClientTimeout(integer $value)
+ * @method \PicoFeed\Config\Config setClientUserAgent(string $value)
+ * @method \PicoFeed\Config\Config setMaxRedirections(integer $value)
+ * @method \PicoFeed\Config\Config setMaxBodySize(integer $value)
+ * @method \PicoFeed\Config\Config setProxyHostname(string $value)
+ * @method \PicoFeed\Config\Config setProxyPort(integer $value)
+ * @method \PicoFeed\Config\Config setProxyUsername(string $value)
+ * @method \PicoFeed\Config\Config setProxyPassword(string $value)
+ * @method \PicoFeed\Config\Config setGrabberTimeout(integer $value)
+ * @method \PicoFeed\Config\Config setGrabberUserAgent(string $value)
+ * @method \PicoFeed\Config\Config setParserHashAlgo(string $value)
+ * @method \PicoFeed\Config\Config setContentFiltering(boolean $value)
+ * @method \PicoFeed\Config\Config setTimezone(string $value)
+ * @method \PicoFeed\Config\Config setFilterIframeWhitelist(array $value)
+ * @method \PicoFeed\Config\Config setFilterIntegerAttributes(array $value)
+ * @method \PicoFeed\Config\Config setFilterAttributeOverrides(array $value)
+ * @method \PicoFeed\Config\Config setFilterRequiredAttributes(array $value)
+ * @method \PicoFeed\Config\Config setFilterMediaBlacklist(array $value)
+ * @method \PicoFeed\Config\Config setFilterMediaAttributes(array $value)
+ * @method \PicoFeed\Config\Config setFilterSchemeWhitelist(array $value)
+ * @method \PicoFeed\Config\Config setFilterWhitelistedTags(array $value)
+ * @method \PicoFeed\Config\Config setFilterBlacklistedTags(array $value)
*
* @method integer getClientTimeout()
* @method string getClientUserAgent()
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Encoding.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Encoding/Encoding.php
index b93bfcfe4..d6296c0b6 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Encoding.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Encoding/Encoding.php
@@ -1,6 +1,6 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Encoding;
/**
* @author "Sebastián Grignoli" <grignoli@framework2.com.ar>
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Attribute.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Attribute.php
index 8fe4b7199..23b1103ad 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Attribute.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Attribute.php
@@ -2,14 +2,13 @@
namespace PicoFeed\Filter;
-use \PicoFeed\Url;
-use \PicoFeed\Filter;
+use \PicoFeed\Client\Url;
/**
* Attribute Filter class
*
* @author Frederic Guillot
- * @package filter
+ * @package Filter
*/
class Attribute
{
@@ -215,15 +214,15 @@ class Attribute
* Add attributes to specified tags
*
* @access private
- * @var \PicoFeed\Url
+ * @var \PicoFeed\Client\Url
*/
- private $website = null;
+ private $website;
/**
* Constructor
*
* @access public
- * @param \PicoFeed\Url $website Website url instance
+ * @param \PicoFeed\Client\Url $website Website url instance
*/
public function __construct(Url $website)
{
@@ -489,7 +488,7 @@ class Attribute
*
* @access public
* @param array $values List of tags: ['video' => ['src', 'cover'], 'img' => ['src']]
- * @return \PicoFeed\Filter
+ * @return \PicoFeed\Filter\Filter
*/
public function setWhitelistedAttributes(array $values)
{
@@ -502,7 +501,7 @@ class Attribute
*
* @access public
* @param array $values List of scheme: ['http://', 'ftp://']
- * @return \PicoFeed\Filter
+ * @return \PicoFeed\Filter\Filter
*/
public function setSchemeWhitelist(array $values)
{
@@ -515,7 +514,7 @@ class Attribute
*
* @access public
* @param array $values List of values: ['src', 'href']
- * @return \PicoFeed\Filter
+ * @return \PicoFeed\Filter\Filter
*/
public function setMediaAttributes(array $values)
{
@@ -528,7 +527,7 @@ class Attribute
*
* @access public
* @param array $values List of tags: ['http://google.com/', '...']
- * @return \PicoFeed\Filter
+ * @return \PicoFeed\Filter\Filter
*/
public function setMediaBlacklist(array $values)
{
@@ -541,7 +540,7 @@ class Attribute
*
* @access public
* @param array $values List of tags: ['img' => 'src']
- * @return \PicoFeed\Filter
+ * @return \PicoFeed\Filter\Filter
*/
public function setRequiredAttributes(array $values)
{
@@ -554,7 +553,7 @@ class Attribute
*
* @access public
* @param array $values List of tags: ['a' => 'target="_blank"']
- * @return \PicoFeed\Filter
+ * @return \PicoFeed\Filter\Filter
*/
public function setAttributeOverrides(array $values)
{
@@ -567,7 +566,7 @@ class Attribute
*
* @access public
* @param array $values List of tags: ['width', 'height']
- * @return \PicoFeed\Filter
+ * @return \PicoFeed\Filter\Filter
*/
public function setIntegerAttributes(array $values)
{
@@ -580,7 +579,7 @@ class Attribute
*
* @access public
* @param array $values List of tags: ['http://www.youtube.com']
- * @return \PicoFeed\Filter
+ * @return \PicoFeed\Filter\Filter
*/
public function setIframeWhitelist(array $values)
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Filter.php
index fab392662..0490e2f49 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Filter.php
@@ -1,14 +1,12 @@
<?php
-namespace PicoFeed;
-
-use PicoFeed\Filter\Html;
+namespace PicoFeed\Filter;
/**
* Filter class
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Filter
*/
class Filter
{
@@ -110,9 +108,10 @@ class Filter
*/
public static function stripWhiteSpace($value)
{
- $value = str_replace("\r", "", $value);
- $value = str_replace("\t", "", $value);
- $value = str_replace("\n", "", $value);
+ $value = str_replace("\r", ' ', $value);
+ $value = str_replace("\t", ' ', $value);
+ $value = str_replace("\n", ' ', $value);
+ // $value = preg_replace('/\s+/', ' ', $value); <= break utf-8
return trim($value);
}
@@ -138,4 +137,34 @@ class Filter
return $data;
}
+
+ /**
+ * Get the first XML tag
+ *
+ * @static
+ * @access public
+ * @param string $data Feed content
+ * @return string
+ */
+ public static function getFirstTag($data)
+ {
+ // Strip HTML comments (max of 5,000 characters long to prevent crashing)
+ $data = preg_replace('/<!--(.{0,5000}?)-->/Uis', '', $data);
+
+ /* Strip Doctype:
+ * Doctype needs to be within the first 100 characters. (Ideally the first!)
+ * If it's not found by then, we need to stop looking to prevent PREG
+ * from reaching max backtrack depth and crashing.
+ */
+ $data = preg_replace('/^.{0,100}<!DOCTYPE([^>]*)>/Uis', '', $data);
+
+ // Strip <?xml version....
+ $data = self::stripXmlTag($data);
+
+ // Find the first tag
+ $open_tag = strpos($data, '<');
+ $close_tag = strpos($data, '>');
+
+ return substr($data, $open_tag, $close_tag);
+ }
}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Html.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Html.php
index 4a76ca45f..f09a10e3a 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Html.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Html.php
@@ -2,15 +2,14 @@
namespace PicoFeed\Filter;
-use \PicoFeed\Url;
-use \PicoFeed\Filter;
-use \PicoFeed\XmlParser;
+use \PicoFeed\Client\Url;
+use \PicoFeed\Parser\XmlParser;
/**
* HTML Filter class
*
* @author Frederic Guillot
- * @package filter
+ * @package Filter
*/
class Html
{
@@ -18,9 +17,9 @@ class Html
* Config object
*
* @access private
- * @var \PicoFeed\Config
+ * @var \PicoFeed\Config\Config
*/
- private $config = null;
+ private $config;
/**
* Unfiltered XML data
@@ -89,8 +88,8 @@ class Html
* Set config object
*
* @access public
- * @param \PicoFeed\Config $config Config instance
- * @return \PicoFeed\Html
+ * @param \PicoFeed\Config\Config $config Config instance
+ * @return \PicoFeed\Filter\Html
*/
public function setConfig($config)
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Tag.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Tag.php
index 83bd1b9e4..dbeffe7a4 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Tag.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Filter/Tag.php
@@ -6,7 +6,7 @@ namespace PicoFeed\Filter;
* Tag Filter class
*
* @author Frederic Guillot
- * @package filter
+ * @package Filter
*/
class Tag
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Logging.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Logging/Logging.php
index 86c88c9c4..bc465ce7d 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Logging.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Logging/Logging.php
@@ -1,6 +1,6 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Logging;
use DateTime;
use DateTimeZone;
@@ -9,7 +9,7 @@ use DateTimeZone;
* Logging class
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Logging
*/
class Logging
{
@@ -80,4 +80,16 @@ class Logging
{
self::$timezone = $timezone ?: self::$timezone;
}
+
+ /**
+ * Get all messages serialized into a string
+ *
+ * @static
+ * @access public
+ * @return string
+ */
+ public static function toString()
+ {
+ return implode(PHP_EOL, self::$messages).PHP_EOL;
+ }
}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Atom.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Atom.php
index 8a86b815c..feaf0e376 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Atom.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Atom.php
@@ -1,21 +1,17 @@
<?php
-namespace PicoFeed\Parsers;
+namespace PicoFeed\Parser;
use SimpleXMLElement;
-use PicoFeed\Parser;
-use PicoFeed\XmlParser;
-use PicoFeed\Logging;
-use PicoFeed\Feed;
-use PicoFeed\Filter;
-use PicoFeed\Item;
-use PicoFeed\Url;
+use PicoFeed\Logging\Logging;
+use PicoFeed\Filter\Filter;
+use PicoFeed\Client\Url;
/**
* Atom parser
*
* @author Frederic Guillot
- * @package parser
+ * @package Parser
*/
class Atom extends Parser
{
@@ -36,7 +32,7 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedUrl(SimpleXMLElement $xml, Feed $feed)
{
@@ -48,7 +44,7 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedDescription(SimpleXMLElement $xml, Feed $feed)
{
@@ -60,7 +56,7 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedLogo(SimpleXMLElement $xml, Feed $feed)
{
@@ -72,7 +68,7 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedTitle(SimpleXMLElement $xml, Feed $feed)
{
@@ -84,7 +80,7 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedLanguage(SimpleXMLElement $xml, Feed $feed)
{
@@ -96,7 +92,7 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedId(SimpleXMLElement $xml, Feed $feed)
{
@@ -108,7 +104,7 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedDate(SimpleXMLElement $xml, Feed $feed)
{
@@ -149,7 +145,7 @@ class Atom extends Parser
* @access public
* @param SimpleXMLElement $xml Feed
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
+ * @param \PicoFeed\Parser\Item $item Item object
*/
public function findItemAuthor(SimpleXMLElement $xml, SimpleXMLElement $entry, Item $item)
{
@@ -166,7 +162,7 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
+ * @param \PicoFeed\Parser\Item $item Item object
*/
public function findItemContent(SimpleXMLElement $entry, Item $item)
{
@@ -178,7 +174,7 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
+ * @param \PicoFeed\Parser\Item $item Item object
*/
public function findItemUrl(SimpleXMLElement $entry, Item $item)
{
@@ -190,28 +186,21 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findItemId(SimpleXMLElement $entry, Item $item, Feed $feed)
{
$id = (string) $entry->id;
- if ($id !== $item->url) {
- $item_permalink = $id;
+ if ($id) {
+ $item->id = $this->generateId($id);
}
else {
- $item_permalink = $item->url;
+ $item->id = $this->generateId(
+ $item->getTitle(), $item->getUrl(), $item->getContent()
+ );
}
-
- if ($this->isExcludedFromId($feed->url)) {
- $feed_permalink = '';
- }
- else {
- $feed_permalink = $feed->url;
- }
-
- $item->id = $this->generateId($item_permalink, $feed_permalink);
}
/**
@@ -219,8 +208,8 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findItemEnclosure(SimpleXMLElement $entry, Item $item, Feed $feed)
{
@@ -239,8 +228,8 @@ class Atom extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findItemLanguage(SimpleXMLElement $entry, Item $item, Feed $feed)
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Feed.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Feed.php
index 6bd63928f..77a6f0c97 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Feed.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Feed.php
@@ -1,12 +1,12 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Parser;
/**
* Feed
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Parser
*/
class Feed
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Item.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Item.php
index 4a446d4f3..1731f5a29 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Item.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Item.php
@@ -1,12 +1,12 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Parser;
/**
* Feed Item
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Parser
*/
class Item
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/MalformedXmlException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/MalformedXmlException.php
new file mode 100644
index 000000000..8464e9cac
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/MalformedXmlException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace PicoFeed\Parser;
+
+/**
+ * MalformedXmlException Exception
+ *
+ * @author Frederic Guillot
+ * @package Parser
+ */
+class MalformedXmlException extends ParserException
+{
+} \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Parser.php
index d49452830..6954c2ffc 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Parser.php
@@ -1,15 +1,22 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Parser;
+use SimpleXMLElement;
use DateTime;
use DateTimeZone;
+use PicoFeed\Encoding\Encoding;
+use PicoFeed\Filter\Filter;
+use PicoFeed\Logging\Logging;
+use PicoFeed\Client\Url;
+use PicoFeed\Client\Grabber;
+
/**
* Base parser class
*
* @author Frederic Guillot
- * @package parser
+ * @package Parser
*/
abstract class Parser
{
@@ -17,9 +24,9 @@ abstract class Parser
* Config object
*
* @access private
- * @var \PicoFeed\Config
+ * @var \PicoFeed\Config\Config
*/
- private $config = null;
+ private $config;
/**
* Hash algorithm used to generate item id, any value supported by PHP, see hash_algos()
@@ -27,7 +34,7 @@ abstract class Parser
* @access private
* @var string
*/
- private $hash_algo = 'crc32b'; // crc32b seems to be faster and shorter than other hash algorithms
+ private $hash_algo = 'sha256';
/**
* Timezone used to parse feed dates
@@ -46,6 +53,14 @@ abstract class Parser
protected $content = '';
/**
+ * Fallback url
+ *
+ * @access protected
+ * @var string
+ */
+ protected $fallback_url = '';
+
+ /**
* XML namespaces
*
* @access protected
@@ -81,11 +96,13 @@ abstract class Parser
* Constructor
*
* @access public
- * @param string $content Feed content
- * @param string $http_encoding HTTP encoding (headers)
+ * @param string $content Feed content
+ * @param string $http_encoding HTTP encoding (headers)
+ * @param string $base_url Fallback url when the feed provide relative or broken url
*/
- public function __construct($content, $http_encoding = '')
+ public function __construct($content, $http_encoding = '', $fallback_url = '')
{
+ $this->fallback_url = $fallback_url;
$xml_encoding = XmlParser::getEncodingFromXmlTag($content);
// Strip XML tag to avoid multiple encoding/decoding in the next XML processing
@@ -103,7 +120,7 @@ abstract class Parser
* Parse the document
*
* @access public
- * @return mixed \PicoFeed\Feed instance or false
+ * @return \PicoFeed\Parser\Feed
*/
public function execute()
{
@@ -114,13 +131,16 @@ abstract class Parser
if ($xml === false) {
Logging::setMessage(get_called_class().': XML parsing error');
Logging::setMessage(XmlParser::getErrors());
- return false;
+ throw new MalformedXmlException('XML parsing error');
}
$this->namespaces = $xml->getNamespaces(true);
$feed = new Feed;
+
$this->findFeedUrl($xml, $feed);
+ $this->checkFeedUrl($feed);
+
$this->findFeedTitle($xml, $feed);
$this->findFeedDescription($xml, $feed);
$this->findFeedLanguage($xml, $feed);
@@ -132,11 +152,17 @@ abstract class Parser
$item = new Item;
$this->findItemAuthor($xml, $entry, $item);
+
$this->findItemUrl($entry, $item);
+ $this->checkItemUrl($feed, $item);
+
$this->findItemTitle($entry, $item);
+ $this->findItemContent($entry, $item);
+
+ // Id generation can use the item url/title/content (order is important)
$this->findItemId($entry, $item, $feed);
+
$this->findItemDate($entry, $item);
- $this->findItemContent($entry, $item);
$this->findItemEnclosure($entry, $item, $feed);
$this->findItemLanguage($entry, $item, $feed);
@@ -152,6 +178,37 @@ abstract class Parser
}
/**
+ * Check if the feed url is correct
+ *
+ * @access public
+ * @param Feed $feed Feed object
+ */
+ public function checkFeedUrl(Feed $feed)
+ {
+ $url = new Url($feed->getUrl());
+
+ if ($url->isRelativeUrl()) {
+ $feed->url = $this->fallback_url;
+ }
+ }
+
+ /**
+ * Check if the item url is correct
+ *
+ * @access public
+ * @param Feed $feed Feed object
+ * @param Item $item Item object
+ */
+ public function checkItemUrl(Feed $feed, Item $item)
+ {
+ $url = new Url($item->getUrl());
+
+ if ($url->isRelativeUrl()) {
+ $item->url = Url::resolve($item->getUrl(), $feed->getUrl());
+ }
+ }
+
+ /**
* Fetch item content with the content grabber
*
* @access public
@@ -283,24 +340,6 @@ abstract class Parser
}
/**
- * Hardcoded list of hostname/token to exclude from id generation
- *
- * @access public
- * @param string $url URL
- * @return boolean
- */
- public function isExcludedFromId($url)
- {
- $exclude_list = array('ap.org', 'jacksonville.com');
-
- foreach ($exclude_list as $token) {
- if (strpos($url, $token) !== false) return true;
- }
-
- return false;
- }
-
- /**
* Return true if the given language is "Right to Left"
*
* @static
@@ -337,7 +376,7 @@ abstract class Parser
*
* @access public
* @param string $algo Algorithm name
- * @return \PicoFeed\Parser
+ * @return \PicoFeed\Parser\Parser
*/
public function setHashAlgo($algo)
{
@@ -351,7 +390,7 @@ abstract class Parser
* @see http://php.net/manual/en/timezones.php
* @access public
* @param string $timezone Timezone
- * @return \PicoFeed\Parser
+ * @return \PicoFeed\Parser\Parser
*/
public function setTimezone($timezone)
{
@@ -363,8 +402,8 @@ abstract class Parser
* Set config object
*
* @access public
- * @param \PicoFeed\Config $config Config instance
- * @return \PicoFeed\Parser
+ * @param \PicoFeed\Config\Config $config Config instance
+ * @return \PicoFeed\Parser\Parser
*/
public function setConfig($config)
{
@@ -376,7 +415,7 @@ abstract class Parser
* Enable the content grabber
*
* @access public
- * @return \PicoFeed\Parser
+ * @return \PicoFeed\Parser\Parser
*/
public function disableContentFiltering()
{
@@ -402,7 +441,7 @@ abstract class Parser
* Enable the content grabber
*
* @access public
- * @return \PicoFeed\Parser
+ * @return \PicoFeed\Parser\Parser
*/
public function enableContentGrabber()
{
@@ -414,10 +453,158 @@ abstract class Parser
*
* @access public
* @param array $urls URLs
- * @return \PicoFeed\Parser
+ * @return \PicoFeed\Parser\Parser
*/
public function setGrabberIgnoreUrls(array $urls)
{
$this->grabber_ignore_urls = $urls;
}
+
+ /**
+ * Find the feed url
+ *
+ * @access public
+ * @param SimpleXMLElement $xml Feed xml
+ * @param \PicoFeed\Parser\Feed $feed Feed object
+ */
+ public abstract function findFeedUrl(SimpleXMLElement $xml, Feed $feed);
+
+ /**
+ * Find the feed title
+ *
+ * @access public
+ * @param SimpleXMLElement $xml Feed xml
+ * @param \PicoFeed\Parser\Feed $feed Feed object
+ */
+ public abstract function findFeedTitle(SimpleXMLElement $xml, Feed $feed);
+
+ /**
+ * Find the feed description
+ *
+ * @access public
+ * @param SimpleXMLElement $xml Feed xml
+ * @param \PicoFeed\Parser\Feed $feed Feed object
+ */
+ public abstract function findFeedDescription(SimpleXMLElement $xml, Feed $feed);
+
+ /**
+ * Find the feed language
+ *
+ * @access public
+ * @param SimpleXMLElement $xml Feed xml
+ * @param \PicoFeed\Parser\Feed $feed Feed object
+ */
+ public abstract function findFeedLanguage(SimpleXMLElement $xml, Feed $feed);
+
+ /**
+ * Find the feed id
+ *
+ * @access public
+ * @param SimpleXMLElement $xml Feed xml
+ * @param \PicoFeed\Parser\Feed $feed Feed object
+ */
+ public abstract function findFeedId(SimpleXMLElement $xml, Feed $feed);
+
+ /**
+ * Find the feed date
+ *
+ * @access public
+ * @param SimpleXMLElement $xml Feed xml
+ * @param \PicoFeed\Parser\Feed $feed Feed object
+ */
+ public abstract function findFeedDate(SimpleXMLElement $xml, Feed $feed);
+
+ /**
+ * Find the feed logo url
+ *
+ * @access public
+ * @param SimpleXMLElement $xml Feed xml
+ * @param \PicoFeed\Parser\Feed $feed Feed object
+ */
+ public abstract function findFeedLogo(SimpleXMLElement $xml, Feed $feed);
+
+ /**
+ * Get the path to the items XML tree
+ *
+ * @access public
+ * @param SimpleXMLElement $xml Feed xml
+ * @return SimpleXMLElement
+ */
+ public abstract function getItemsTree(SimpleXMLElement $xml);
+
+ /**
+ * Find the item author
+ *
+ * @access public
+ * @param SimpleXMLElement $xml Feed
+ * @param SimpleXMLElement $entry Feed item
+ * @param \PicoFeed\Parser\Item $item Item object
+ */
+ public abstract function findItemAuthor(SimpleXMLElement $xml, SimpleXMLElement $entry, Item $item);
+
+ /**
+ * Find the item URL
+ *
+ * @access public
+ * @param SimpleXMLElement $entry Feed item
+ * @param \PicoFeed\Parser\Item $item Item object
+ */
+ public abstract function findItemUrl(SimpleXMLElement $entry, Item $item);
+
+ /**
+ * Find the item title
+ *
+ * @access public
+ * @param SimpleXMLElement $entry Feed item
+ * @param \PicoFeed\Parser\Item $item Item object
+ */
+ public abstract function findItemTitle(SimpleXMLElement $entry, Item $item);
+
+ /**
+ * Genereate the item id
+ *
+ * @access public
+ * @param SimpleXMLElement $entry Feed item
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
+ */
+ public abstract function findItemId(SimpleXMLElement $entry, Item $item, Feed $feed);
+
+ /**
+ * Find the item date
+ *
+ * @access public
+ * @param SimpleXMLElement $entry Feed item
+ * @param \PicoFeed\Parser\Item $item Item object
+ */
+ public abstract function findItemDate(SimpleXMLElement $entry, Item $item);
+
+ /**
+ * Find the item content
+ *
+ * @access public
+ * @param SimpleXMLElement $entry Feed item
+ * @param \PicoFeed\Parser\Item $item Item object
+ */
+ public abstract function findItemContent(SimpleXMLElement $entry, Item $item);
+
+ /**
+ * Find the item enclosure
+ *
+ * @access public
+ * @param SimpleXMLElement $entry Feed item
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
+ */
+ public abstract function findItemEnclosure(SimpleXMLElement $entry, Item $item, Feed $feed);
+
+ /**
+ * Find the item language
+ *
+ * @access public
+ * @param SimpleXMLElement $entry Feed item
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
+ */
+ public abstract function findItemLanguage(SimpleXMLElement $entry, Item $item, Feed $feed);
}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/ParserException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/ParserException.php
new file mode 100644
index 000000000..40e48abc0
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/ParserException.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace PicoFeed\Parser;
+
+use PicoFeed\PicoFeedException;
+
+
+/**
+ * ParserException Exception
+ *
+ * @author Frederic Guillot
+ * @package Parser
+ */
+abstract class ParserException extends PicoFeedException
+{
+} \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss10.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss10.php
index 728c79206..2e763fb45 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss10.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss10.php
@@ -1,20 +1,14 @@
<?php
-namespace PicoFeed\Parsers;
-
-require_once __DIR__.'/Rss20.php';
+namespace PicoFeed\Parser;
use SimpleXMLElement;
-use PicoFeed\Feed;
-use PicoFeed\Item;
-use PicoFeed\XmlParser;
-use PicoFeed\Parsers\Rss20;
/**
* RSS 1.0 parser
*
* @author Frederic Guillot
- * @package parser
+ * @package Parser
*/
class Rss10 extends Rss20
{
@@ -35,7 +29,7 @@ class Rss10 extends Rss20
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedDate(SimpleXMLElement $xml, Feed $feed)
{
@@ -47,7 +41,7 @@ class Rss10 extends Rss20
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedLanguage(SimpleXMLElement $xml, Feed $feed)
{
@@ -59,19 +53,14 @@ class Rss10 extends Rss20
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findItemId(SimpleXMLElement $entry, Item $item, Feed $feed)
{
- if ($this->isExcludedFromId($feed->url)) {
- $feed_permalink = '';
- }
- else {
- $feed_permalink = $feed->url;
- }
-
- $item->id = $this->generateId($item->url, $feed_permalink);
+ $item->id = $this->generateId(
+ $item->getTitle(), $item->getUrl(), $item->getContent()
+ );
}
/**
@@ -79,8 +68,8 @@ class Rss10 extends Rss20
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findItemEnclosure(SimpleXMLElement $entry, Item $item, Feed $feed)
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss20.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss20.php
index 255c6e589..231864bfc 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss20.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss20.php
@@ -1,21 +1,17 @@
<?php
-namespace PicoFeed\Parsers;
+namespace PicoFeed\Parser;
use SimpleXMLElement;
-use PicoFeed\Parser;
-use PicoFeed\XmlParser;
-use PicoFeed\Logging;
-use PicoFeed\Feed;
-use PicoFeed\Filter;
-use PicoFeed\Item;
-use PicoFeed\Url;
+use PicoFeed\Logging\Logging;
+use PicoFeed\Filter\Filter;
+use PicoFeed\Client\Url;
/**
* RSS 2.0 Parser
*
* @author Frederic Guillot
- * @package parser
+ * @package Parser
*/
class Rss20 extends Parser
{
@@ -36,7 +32,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedUrl(SimpleXMLElement $xml, Feed $feed)
{
@@ -63,7 +59,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedDescription(SimpleXMLElement $xml, Feed $feed)
{
@@ -75,7 +71,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedLogo(SimpleXMLElement $xml, Feed $feed)
{
@@ -89,7 +85,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedTitle(SimpleXMLElement $xml, Feed $feed)
{
@@ -101,7 +97,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedLanguage(SimpleXMLElement $xml, Feed $feed)
{
@@ -113,7 +109,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedId(SimpleXMLElement $xml, Feed $feed)
{
@@ -125,7 +121,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $xml Feed xml
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findFeedDate(SimpleXMLElement $xml, Feed $feed)
{
@@ -138,7 +134,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
+ * @param \PicoFeed\Parser\Item $item Item object
*/
public function findItemDate(SimpleXMLElement $entry, Item $item)
{
@@ -160,7 +156,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
+ * @param \PicoFeed\Parser\Item $item Item object
*/
public function findItemTitle(SimpleXMLElement $entry, Item $item)
{
@@ -177,7 +173,7 @@ class Rss20 extends Parser
* @access public
* @param SimpleXMLElement $xml Feed
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
+ * @param \PicoFeed\Parser\Item $item Item object
*/
public function findItemAuthor(SimpleXMLElement $xml, SimpleXMLElement $entry, Item $item)
{
@@ -198,7 +194,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
+ * @param \PicoFeed\Parser\Item $item Item object
*/
public function findItemContent(SimpleXMLElement $entry, Item $item)
{
@@ -216,7 +212,7 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
+ * @param \PicoFeed\Parser\Item $item Item object
*/
public function findItemUrl(SimpleXMLElement $entry, Item $item)
{
@@ -240,25 +236,20 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findItemId(SimpleXMLElement $entry, Item $item, Feed $feed)
{
- $item_permalink = $item->url;
+ $id = (string) $entry->guid;
- if ($this->isExcludedFromId($feed->url)) {
- $feed_permalink = '';
+ if ($id) {
+ $item->id = $this->generateId($id);
}
else {
- $feed_permalink = $feed->url;
- }
-
- if ($entry->guid->count() > 0 && ((string) $entry->guid['isPermaLink'] === 'false' || ! isset($entry->guid['isPermaLink']))) {
- $item->id = $this->generateId($item_permalink, $feed_permalink, (string) $entry->guid);
- }
- else {
- $item->id = $this->generateId($item_permalink, $feed_permalink);
+ $item->id = $this->generateId(
+ $item->getTitle(), $item->getUrl(), $item->getContent()
+ );
}
}
@@ -267,8 +258,8 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findItemEnclosure(SimpleXMLElement $entry, Item $item, Feed $feed)
{
@@ -290,8 +281,8 @@ class Rss20 extends Parser
*
* @access public
* @param SimpleXMLElement $entry Feed item
- * @param \PicoFeed\Item $item Item object
- * @param \PicoFeed\Feed $feed Feed object
+ * @param \PicoFeed\Parser\Item $item Item object
+ * @param \PicoFeed\Parser\Feed $feed Feed object
*/
public function findItemLanguage(SimpleXMLElement $entry, Item $item, Feed $feed)
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss91.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss91.php
new file mode 100644
index 000000000..69f175313
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss91.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace PicoFeed\Parser;
+
+/**
+ * RSS 0.91 Parser
+ *
+ * @author Frederic Guillot
+ * @package Parser
+ */
+class Rss91 extends Rss20
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss92.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss92.php
new file mode 100644
index 000000000..e80847374
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/Rss92.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace PicoFeed\Parser;
+
+/**
+ * RSS 0.92 Parser
+ *
+ * @author Frederic Guillot
+ * @package Parser
+ */
+class Rss92 extends Rss20
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/XmlParser.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/XmlParser.php
index fce8f7d39..580b66574 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/XmlParser.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parser/XmlParser.php
@@ -1,6 +1,6 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Parser;
use DomDocument;
use DOMXPath;
@@ -12,7 +12,7 @@ use SimpleXmlElement;
* Checks for XML eXternal Entity (XXE) and XML Entity Expansion (XEE) attacks on XML documents
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Parser
*/
class XmlParser
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss91.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss91.php
deleted file mode 100644
index 8df3ce0b5..000000000
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss91.php
+++ /dev/null
@@ -1,17 +0,0 @@
-<?php
-
-namespace PicoFeed\Parsers;
-
-require_once __DIR__.'/Rss20.php';
-
-use PicoFeed\Parsers\Rss20;
-
-/**
- * RSS 0.91 Parser
- *
- * @author Frederic Guillot
- * @package parser
- */
-class Rss91 extends Rss20
-{
-}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss92.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss92.php
deleted file mode 100644
index 71478a06f..000000000
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Parsers/Rss92.php
+++ /dev/null
@@ -1,17 +0,0 @@
-<?php
-
-namespace PicoFeed\Parsers;
-
-require_once __DIR__.'/Rss20.php';
-
-use PicoFeed\Parsers\Rss20;
-
-/**
- * RSS 0.92 Parser
- *
- * @author Frederic Guillot
- * @package parser
- */
-class Rss92 extends Rss20
-{
-}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/PicoFeed.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/PicoFeed.php
deleted file mode 100644
index 073c34877..000000000
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/PicoFeed.php
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-
-// Include this file if you don't want to use an autoloader
-
-require __DIR__.'/Config.php';
-require __DIR__.'/Logging.php';
-require __DIR__.'/Url.php';
-require __DIR__.'/Item.php';
-require __DIR__.'/Feed.php';
-require __DIR__.'/Client.php';
-require __DIR__.'/Filter.php';
-require __DIR__.'/Filter/Attribute.php';
-require __DIR__.'/Filter/Tag.php';
-require __DIR__.'/Filter/Html.php';
-require __DIR__.'/XmlParser.php';
-require __DIR__.'/Encoding.php';
-require __DIR__.'/Grabber.php';
-require __DIR__.'/Reader.php';
-require __DIR__.'/Import.php';
-require __DIR__.'/Export.php';
-require __DIR__.'/Writer.php';
-require __DIR__.'/Writers/Rss20.php';
-require __DIR__.'/Writers/Atom.php';
-require __DIR__.'/Parser.php';
-require __DIR__.'/Favicon.php';
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/PicoFeedException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/PicoFeedException.php
new file mode 100644
index 000000000..11f898648
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/PicoFeedException.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace PicoFeed;
+
+use Exception;
+
+/**
+ * PicoFeedException Exception
+ *
+ * @author Frederic Guillot
+ * @package exception
+ */
+abstract class PicoFeedException extends Exception
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader.php
deleted file mode 100644
index 2f5d786e5..000000000
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader.php
+++ /dev/null
@@ -1,310 +0,0 @@
-<?php
-
-namespace PicoFeed;
-
-use DOMXPath;
-use PicoFeed\Config;
-use PicoFeed\XmlParser;
-use PicoFeed\Logging;
-use PicoFeed\Filter;
-use PicoFeed\Client;
-use PicoFeed\Parser;
-use PicoFeed\Url;
-
-/**
- * Reader class
- *
- * @author Frederic Guillot
- * @package picofeed
- */
-class Reader
-{
- /**
- * Feed or site URL
- *
- * @access private
- * @var string
- */
- private $url = '';
-
- /**
- * Feed content
- *
- * @access private
- * @var string
- */
- private $content = '';
-
- /**
- * HTTP encoding
- *
- * @access private
- * @var string
- */
- private $encoding = '';
-
- /**
- * Config class instance
- *
- * @access private
- * @var \PicoFeed\Config
- */
- private $config = null;
-
- /**
- * Constructor
- *
- * @access public
- * @param \PicoFeed\Config $config Config class instance
- */
- public function __construct(Config $config = null)
- {
- $this->config = $config ?: new Config;
- Logging::setTimezone($this->config->getTimezone());
- }
-
- /**
- * Download a feed
- *
- * @access public
- * @param string $url Feed content
- * @param string $last_modified Last modified HTTP header
- * @param string $etag Etag HTTP header
- * @return \PicoFeed\Client
- */
- public function download($url, $last_modified = '', $etag = '')
- {
- if (strpos($url, 'http') !== 0) {
- $url = 'http://'.$url;
- }
-
- $client = Client::getInstance();
- $client->setConfig($this->config)
- ->setLastModified($last_modified)
- ->setEtag($etag);
-
- if ($client->execute($url)) {
- $this->content = $client->getContent();
- $this->url = $client->getUrl();
- $this->encoding = $client->getEncoding();
- }
-
- return $client;
- }
-
- /**
- * Get a parser instance with a custom config
- *
- * @access public
- * @param string $name Parser name
- * @return \PicoFeed\Parser
- */
- public function getParserInstance($name)
- {
- require_once __DIR__.'/Parsers/'.ucfirst($name).'.php';
- $name = '\PicoFeed\Parsers\\'.$name;
-
- $parser = new $name($this->content, $this->encoding);
- $parser->setHashAlgo($this->config->getParserHashAlgo());
- $parser->setTimezone($this->config->getTimezone());
- $parser->setConfig($this->config);
-
- return $parser;
- }
-
- /**
- * Get the first XML tag
- *
- * @access public
- * @param string $data Feed content
- * @return string
- */
- public function getFirstTag($data)
- {
- // Strip HTML comments (max of 5,000 characters long to prevent crashing)
- $data = preg_replace('/<!--(.{0,5000}?)-->/Uis', '', $data);
-
- /* Strip Doctype:
- * Doctype needs to be within the first 100 characters. (Ideally the first!)
- * If it's not found by then, we need to stop looking to prevent PREG
- * from reaching max backtrack depth and crashing.
- */
- $data = preg_replace('/^.{0,100}<!DOCTYPE([^>]*)>/Uis', '', $data);
-
- // Strip <?xml version....
- $data = Filter::stripXmlTag($data);
-
- // Find the first tag
- $open_tag = strpos($data, '<');
- $close_tag = strpos($data, '>');
-
- return substr($data, $open_tag, $close_tag);
- }
-
- /**
- * Detect the feed format
- *
- * @access public
- * @param string $parser_name Parser name
- * @param string $haystack First XML tag
- * @param array $needles List of strings that need to be there
- * @return mixed False on failure or Parser instance
- */
- public function detectFormat($parser_name, $haystack, array $needles)
- {
- $results = array();
-
- foreach ($needles as $needle) {
- $results[] = strpos($haystack, $needle) !== false;
- }
-
- if (! in_array(false, $results, true)) {
- Logging::setMessage(get_called_class().': Format detected => '.$parser_name);
- return $this->getParserInstance($parser_name);
- }
-
- return false;
- }
-
- /**
- * Discover feed format and return a parser instance
- *
- * @access public
- * @param boolean $discover Enable feed autodiscovery in HTML document
- * @return mixed False on failure or Parser instance
- */
- public function getParser($discover = false)
- {
- $formats = array(
- array('parser' => 'Atom', 'needles' => array('<feed')),
- array('parser' => 'Rss20', 'needles' => array('<rss', '2.0')),
- array('parser' => 'Rss92', 'needles' => array('<rss', '0.92')),
- array('parser' => 'Rss91', 'needles' => array('<rss', '0.91')),
- array('parser' => 'Rss10', 'needles' => array('<rdf:'/*, 'xmlns="http://purl.org/rss/1.0/"'*/)),
- );
-
- $first_tag = $this->getFirstTag($this->content);
-
- foreach ($formats as $format) {
-
- $parser = $this->detectFormat($format['parser'], $first_tag, $format['needles']);
-
- if ($parser !== false) {
- return $parser;
- }
- }
-
- if ($discover === true) {
-
- Logging::setMessage(get_called_class().': Format not supported or feed malformed');
- Logging::setMessage(get_called_class().': Content => '.PHP_EOL.$this->content);
-
- return false;
- }
- else if ($this->discover()) {
- return $this->getParser(true);
- }
-
- Logging::setMessage(get_called_class().': Subscription not found');
- Logging::setMessage(get_called_class().': Content => '.PHP_EOL.$this->content);
-
- return false;
- }
-
- /**
- * Discover the feed url inside a HTML document and download the feed
- *
- * @access public
- * @return boolean
- */
- public function discover()
- {
- if (! $this->content) {
- return false;
- }
-
- Logging::setMessage(get_called_class().': Try to discover a subscription');
-
- $dom = XmlParser::getHtmlDocument($this->content);
- $xpath = new DOMXPath($dom);
-
- $queries = array(
- '//link[@type="application/rss+xml"]',
- '//link[@type="application/atom+xml"]',
- );
-
- foreach ($queries as $query) {
-
- $nodes = $xpath->query($query);
-
- if ($nodes->length !== 0) {
-
- $link = $nodes->item(0)->getAttribute('href');
-
- if (! empty($link)) {
-
- $feedUrl = new Url($link);
- $siteUrl = new Url($this->url);
-
- $link = $feedUrl->getAbsoluteUrl($feedUrl->isRelativeUrl() ? $siteUrl->getBaseUrl() : '');
-
- Logging::setMessage(get_called_class().': Find subscription link: '.$link);
-
- $this->download($link);
-
- return true;
- }
- }
- }
-
- return false;
- }
-
- /**
- * Get the downloaded content
- *
- * @access public
- * @return string
- */
- public function getContent()
- {
- return $this->content;
- }
-
- /**
- * Set the page content
- *
- * @access public
- * @param string $content Page content
- * @return \PicoFeed\Reader
- */
- public function setContent($content)
- {
- $this->content = $content;
- return $this;
- }
-
- /**
- * Get final URL
- *
- * @access public
- * @return string
- */
- public function getUrl()
- {
- return $this->url;
- }
-
- /**
- * Set the URL
- *
- * @access public
- * @param string $url URL
- * @return \PicoFeed\Reader
- */
- public function setUrl($url)
- {
- $this->url = $url;
- return $this;
- }
-}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/Reader.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/Reader.php
new file mode 100644
index 000000000..ef6df4c32
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/Reader.php
@@ -0,0 +1,232 @@
+<?php
+
+namespace PicoFeed\Reader;
+
+use DOMXPath;
+
+use PicoFeed\Config\Config;
+use PicoFeed\Client\Client;
+use PicoFeed\Client\Url;
+use PicoFeed\Logging\Logging;
+use PicoFeed\Filter\Filter;
+use PicoFeed\Parser\XmlParser;
+
+/**
+ * Reader class
+ *
+ * @author Frederic Guillot
+ * @package Reader
+ */
+class Reader
+{
+ /**
+ * Feed formats for detection
+ *
+ * @access private
+ * @var array
+ */
+ private $formats = array(
+ 'Atom' => array('<feed'),
+ 'Rss20' => array('<rss', '2.0'),
+ 'Rss92' => array('<rss', '0.92'),
+ 'Rss91' => array('<rss', '0.91'),
+ 'Rss10' => array('<rdf:'),
+ );
+
+ /**
+ * Config class instance
+ *
+ * @access private
+ * @var \PicoFeed\Config\Config
+ */
+ private $config;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param \PicoFeed\Config $config Config class instance
+ */
+ public function __construct(Config $config = null)
+ {
+ $this->config = $config ?: new Config;
+ Logging::setTimezone($this->config->getTimezone());
+ }
+
+ /**
+ * Download a feed (no discovery)
+ *
+ * @access public
+ * @param string $url Feed url
+ * @param string $last_modified Last modified HTTP header
+ * @param string $etag Etag HTTP header
+ * @return \PicoFeed\Client\Client
+ */
+ public function download($url, $last_modified = '', $etag = '')
+ {
+ $url = $this->prependScheme($url);
+
+ return Client::getInstance()
+ ->setConfig($this->config)
+ ->setLastModified($last_modified)
+ ->setEtag($etag)
+ ->execute($url);
+ }
+
+ /**
+ * Discover and download a feed
+ *
+ * @access public
+ * @param string $url Feed or website url
+ * @param string $last_modified Last modified HTTP header
+ * @param string $etag Etag HTTP header
+ * @return \PicoFeed\Client\Client
+ */
+ public function discover($url, $last_modified = '', $etag = '')
+ {
+ $client = $this->download($url, $last_modified, $etag);
+
+ // It's already a feed
+ if ($this->detectFormat($client->getContent())) {
+ return $client;
+ }
+
+ // Try to find a subscription
+ $links = $this->find($client->getUrl(), $client->getContent());
+
+ if (empty($links)) {
+ throw new SubscriptionNotFoundException('Unable to find a subscription');
+ }
+
+ return $this->download($links[0], $last_modified, $etag);
+ }
+
+ /**
+ * Find feed urls inside a HTML document
+ *
+ * @access public
+ * @param string $url Website url
+ * @param string $html HTML content
+ * @return array List of feed links
+ */
+ public function find($url, $html)
+ {
+ Logging::setMessage(get_called_class().': Try to discover subscriptions');
+
+ $dom = XmlParser::getHtmlDocument($html);
+ $xpath = new DOMXPath($dom);
+ $links = array();
+
+ $queries = array(
+ '//link[@type="application/rss+xml"]',
+ '//link[@type="application/atom+xml"]',
+ );
+
+ foreach ($queries as $query) {
+
+ $nodes = $xpath->query($query);
+
+ foreach ($nodes as $node) {
+
+ $link = $node->getAttribute('href');
+
+ if (! empty($link)) {
+
+ $feedUrl = new Url($link);
+ $siteUrl = new Url($url);
+
+ $links[] = $feedUrl->getAbsoluteUrl($feedUrl->isRelativeUrl() ? $siteUrl->getBaseUrl() : '');
+ }
+ }
+ }
+
+ Logging::setMessage(get_called_class().': '.implode(', ', $links));
+
+ return $links;
+ }
+
+ /**
+ * Get a parser instance
+ *
+ * @access public
+ * @param string $url Site url
+ * @param string $content Feed content
+ * @param string $encoding HTTP encoding
+ * @return \PicoFeed\Parser\Parser
+ */
+ public function getParser($url, $content, $encoding)
+ {
+ $format = $this->detectFormat($content);
+
+ if (empty($format)) {
+ throw new UnsupportedFeedFormatException('Unable to detect feed format');
+ }
+
+ $className = '\PicoFeed\Parser\\'.$format;
+
+ $parser = new $className($content, $encoding, $url);
+ $parser->setHashAlgo($this->config->getParserHashAlgo());
+ $parser->setTimezone($this->config->getTimezone());
+ $parser->setConfig($this->config);
+
+ return $parser;
+ }
+
+ /**
+ * Detect the feed format
+ *
+ * @access public
+ * @param string $content Feed content
+ * @return string
+ */
+ public function detectFormat($content)
+ {
+ $first_tag = Filter::getFirstTag($content);
+
+ Logging::setMessage(get_called_class().': DetectFormat(): '.$first_tag);
+
+ foreach ($this->formats as $parser => $needles) {
+
+ if ($this->contains($first_tag, $needles)) {
+ return $parser;
+ }
+ }
+
+ return '';
+ }
+
+ /**
+ * Return true if all needles are found in the haystack
+ *
+ * @access private
+ * @param string $haystack Haystack
+ * @param string $needles Needles to find
+ * @return boolean
+ */
+ private function contains($haystack, array $needles)
+ {
+ $results = array();
+
+ foreach ($needles as $needle) {
+ $results[] = strpos($haystack, $needle) !== false;
+ }
+
+ return ! in_array(false, $results, true);
+ }
+
+ /**
+ * Add the prefix "http://" if the end-user just enter a domain name
+ *
+ * @access public
+ * @param string $url Url
+ * @retunr string
+ */
+ public function prependScheme($url)
+ {
+ if (! preg_match('%^https?://%', $url)) {
+ $url = 'http://' . $url;
+ }
+
+ return $url;
+ }
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/ReaderException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/ReaderException.php
new file mode 100644
index 000000000..a8e973f8a
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/ReaderException.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace PicoFeed\Reader;
+
+use PicoFeed\PicoFeedException;
+
+
+/**
+ * ReaderException Exception
+ *
+ * @author Frederic Guillot
+ * @package Reader
+ */
+abstract class ReaderException extends PicoFeedException
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/SubscriptionNotFoundException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/SubscriptionNotFoundException.php
new file mode 100644
index 000000000..1121fdf0e
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/SubscriptionNotFoundException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace PicoFeed\Reader;
+
+/**
+ * SubscriptionNotFoundException Exception
+ *
+ * @author Frederic Guillot
+ * @package Reader
+ */
+class SubscriptionNotFoundException extends ReaderException
+{
+}
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/UnsupportedFeedFormatException.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/UnsupportedFeedFormatException.php
new file mode 100644
index 000000000..7d4df080e
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Reader/UnsupportedFeedFormatException.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace PicoFeed\Reader;
+
+/**
+ * UnsupportedFeedFormatException Exception
+ *
+ * @author Frederic Guillot
+ * @package Reader
+ */
+class UnsupportedFeedFormatException extends ReaderException
+{
+} \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Export.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Serialization/Export.php
index 5fa0c4bf5..4a3b978fa 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Export.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Serialization/Export.php
@@ -1,6 +1,6 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Serialization;
use SimpleXMLElement;
@@ -8,7 +8,7 @@ use SimpleXMLElement;
* OPML export class
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Serialization
*/
class Export
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Import.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Serialization/Import.php
index 1d246c01b..8de2d8511 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Import.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Serialization/Import.php
@@ -1,15 +1,18 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Serialization;
use SimpleXmlElement;
use StdClass;
+use PicoFeed\Logging\Logging;
+use PicoFeed\Parser\XmlParser;
+
/**
* OPML Import
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Serialization
*/
class Import
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Writers/Atom.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Syndication/Atom.php
index ba49d3fa1..a379056b4 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Writers/Atom.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Syndication/Atom.php
@@ -1,17 +1,16 @@
<?php
-namespace PicoFeed\Writers;
+namespace PicoFeed\Syndication;
use DomDocument;
use DomElement;
use DomAttr;
-use PicoFeed\Writer;
/**
* Atom writer class
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Syndication
*/
class Atom extends Writer
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Writers/Rss20.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Syndication/Rss20.php
index 497452414..efb2dd489 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Writers/Rss20.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Syndication/Rss20.php
@@ -1,17 +1,16 @@
<?php
-namespace PicoFeed\Writers;
+namespace PicoFeed\Syndication;
use DomDocument;
use DomAttr;
use DomElement;
-use PicoFeed\Writer;
/**
* Rss 2.0 writer class
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Syndication
*/
class Rss20 extends Writer
{
diff --git a/3rdparty/fguillot/picofeed/lib/PicoFeed/Writer.php b/3rdparty/fguillot/picofeed/lib/PicoFeed/Syndication/Writer.php
index 92a1a3583..3b4557dad 100644
--- a/3rdparty/fguillot/picofeed/lib/PicoFeed/Writer.php
+++ b/3rdparty/fguillot/picofeed/lib/PicoFeed/Syndication/Writer.php
@@ -1,6 +1,6 @@
<?php
-namespace PicoFeed;
+namespace PicoFeed\Syndication;
use RuntimeException;
@@ -8,7 +8,7 @@ use RuntimeException;
* Base writer class
*
* @author Frederic Guillot
- * @package picofeed
+ * @package Syndication
* @property string $description Feed description
*/
abstract class Writer
diff --git a/3rdparty/fguillot/picofeed/phpunit.xml b/3rdparty/fguillot/picofeed/phpunit.xml
index 87ce0a172..806316326 100644
--- a/3rdparty/fguillot/picofeed/phpunit.xml
+++ b/3rdparty/fguillot/picofeed/phpunit.xml
@@ -1,4 +1,4 @@
-<phpunit>
+<phpunit bootstrap="./vendor/autoload.php">
<testsuites>
<testsuite name="PicoFeed">
<directory>tests</directory>
diff --git a/3rdparty/fguillot/picofeed/picofeed b/3rdparty/fguillot/picofeed/picofeed
index b202b4172..525331197 100755
--- a/3rdparty/fguillot/picofeed/picofeed
+++ b/3rdparty/fguillot/picofeed/picofeed
@@ -1,10 +1,10 @@
#!/usr/bin/env php
<?php
-require 'lib/PicoFeed/PicoFeed.php';
+require_once './vendor/autoload.php';
-use PicoFeed\Reader;
-use PicoFeed\Logging;
+use PicoFeed\Reader\Reader;
+use PicoFeed\Logging\Logging;
function get_feed($url, $disable_filtering = false)
{
diff --git a/3rdparty/fguillot/picofeed/tests/ClientTest.php b/3rdparty/fguillot/picofeed/tests/Client/ClientTest.php
index 10bf49b69..98a963644 100644
--- a/3rdparty/fguillot/picofeed/tests/ClientTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Client/ClientTest.php
@@ -1,8 +1,8 @@
<?php
+namespace PicoFeed\Client;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Client;
class ClientTest extends PHPUnit_Framework_TestCase
{
@@ -72,14 +72,12 @@ class ClientTest extends PHPUnit_Framework_TestCase
{
$client = Client::getInstance();
$client->setUrl('http://php.net/');
-
- $this->assertTrue($client->execute());
+ $client->execute();
$this->assertEquals('utf-8', $client->getEncoding());
$client = Client::getInstance();
$client->setUrl('http://php.net/robots.txt');
-
- $this->assertTrue($client->execute());
+ $client->execute();
$this->assertEquals('', $client->getEncoding());
}
} \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/tests/CurlTest.php b/3rdparty/fguillot/picofeed/tests/Client/CurlTest.php
index e8b3b70f0..668816036 100644
--- a/3rdparty/fguillot/picofeed/tests/CurlTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Client/CurlTest.php
@@ -1,9 +1,8 @@
<?php
+namespace PicoFeed\Client;
-require_once 'lib/PicoFeed/PicoFeed.php';
-require_once 'lib/PicoFeed/Clients/Curl.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Clients\Curl;
class CurlTest extends PHPUnit_Framework_TestCase
{
@@ -32,33 +31,23 @@ class CurlTest extends PHPUnit_Framework_TestCase
$this->assertEquals('text/html; charset=utf-8', $result['headers']['Content-Type']);
}
+ /**
+ * @expectedException PicoFeed\Client\InvalidCertificateException
+ */
+ public function testSSL()
+ {
+ $client = new Curl;
+ $client->setUrl('https://www.mjvmobile.com.br');
+ $client->doRequest();
+ }
- // public function testInfiniteRedirect()
- // {
- // $client = new Curl;
- // $client->url = 'http://www.accupass.com/home/rss/%E8%AA%B2%E7%A8%8B%E8%AC%9B%E5%BA%A7';
- // $result = $client->doRequest();
-
- // $this->assertFalse($result);
- // }
-
-
+ /**
+ * @expectedException PicoFeed\Client\InvalidUrlException
+ */
public function testBadUrl()
{
$client = new Curl;
$client->setUrl('http://12345gfgfgf');
- $result = $client->doRequest();
-
- $this->assertFalse($result);
+ $client->doRequest();
}
-
-
- // public function testAbortOnLargeBody()
- // {
- // $client = new Curl;
- // $client->setUrl('http://duga.jp/ror.xml');
- // $result = $client->doRequest();
-
- // $this->assertFalse($result);
- // }
} \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/tests/FaviconTest.php b/3rdparty/fguillot/picofeed/tests/Client/FaviconTest.php
index 46ac13461..c0ac11ac6 100644
--- a/3rdparty/fguillot/picofeed/tests/FaviconTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Client/FaviconTest.php
@@ -1,9 +1,8 @@
<?php
+namespace PicoFeed\Client;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Favicon;
-use PicoFeed\Url;
class FaviconTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/GrabberTest.php b/3rdparty/fguillot/picofeed/tests/Client/GrabberTest.php
index 001b1e084..5aec5ca11 100644
--- a/3rdparty/fguillot/picofeed/tests/GrabberTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Client/GrabberTest.php
@@ -1,11 +1,10 @@
<?php
+namespace PicoFeed\Client;
-require_once 'lib/PicoFeed/PicoFeed.php';
-require_once 'lib/PicoFeed/Grabber.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Reader;
-use PicoFeed\Grabber;
-use PicoFeed\Logging;
+use PicoFeed\Reader\Reader;
+use PicoFeed\Logging\Logging;
class GrabberTest extends PHPUnit_Framework_TestCase
{
@@ -50,12 +49,9 @@ class GrabberTest extends PHPUnit_Framework_TestCase
public function testRssGrabContent()
{
$reader = new Reader;
- $reader->download('http://www.egscomics.com/rss.php');
-
- $parser = $reader->getParser();
- $this->assertTrue($parser !== false);
-
- $parser->grabber = true;
+ $client = $reader->download('http://www.egscomics.com/rss.php');
+ $parser = $reader->getParser($client->getUrl(), $client->getContent(), $client->getEncoding());
+ $parser->enableContentGrabber();
$feed = $parser->execute();
$this->assertTrue(is_array($feed->items));
diff --git a/3rdparty/fguillot/picofeed/tests/StreamTest.php b/3rdparty/fguillot/picofeed/tests/Client/StreamTest.php
index 49bd3d083..8b2e2f8b8 100644
--- a/3rdparty/fguillot/picofeed/tests/StreamTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Client/StreamTest.php
@@ -1,9 +1,8 @@
<?php
+namespace PicoFeed\Client;
-require_once 'lib/PicoFeed/PicoFeed.php';
-require_once 'lib/PicoFeed/Clients/Stream.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Clients\Stream;
class StreamTest extends PHPUnit_Framework_TestCase
{
@@ -13,7 +12,6 @@ class StreamTest extends PHPUnit_Framework_TestCase
$client->setUrl('http://www.reddit.com/r/dwarffortress/.rss');
$result = $client->doRequest();
- $this->assertNotFalse($result);
$this->assertEquals('</rss>', substr($result['body'], -6));
}
@@ -23,7 +21,6 @@ class StreamTest extends PHPUnit_Framework_TestCase
$client->setUrl('https://github.com/fguillot/picoFeed');
$result = $client->doRequest();
- $this->assertNotFalse($result);
$this->assertEquals(200, $result['status']);
$this->assertEquals('text/html; charset=utf-8', $result['headers']['Content-Type']);
$this->assertEquals('<!DOCTYPE html>', substr(trim($result['body']), 0, 15));
@@ -36,40 +33,22 @@ class StreamTest extends PHPUnit_Framework_TestCase
$client->setUrl('http://github.com/fguillot/picoFeed');
$result = $client->doRequest();
- $this->assertNotFalse($result);
$this->assertEquals(200, $result['status']);
$this->assertEquals('<!DOCTYPE html>', substr(trim($result['body']), 0, 15));
$this->assertEquals('text/html; charset=utf-8', $result['headers']['Content-Type']);
}
-/*
- public function testInfiniteRedirect()
- {
- $client = new Stream;
- $client->url = 'http://www.accupass.com/home/rss/%E8%AA%B2%E7%A8%8B%E8%AC%9B%E5%BA%A7';
- $result = $client->doRequest();
- $this->assertFalse($result);
- }
-*/
+ /**
+ * @expectedException PicoFeed\Client\InvalidUrlException
+ */
public function testBadUrl()
{
$client = new Stream;
$client->setUrl('http://12345gfgfgf');
$client->setTimeout(1);
- $result = $client->doRequest();
-
- $this->assertFalse($result);
+ $client->doRequest();
}
- /*public function testAbortOnLargeBody()
- {
- $client = new Stream;
- $client->url = 'http://duga.jp/ror.xml';
- $result = $client->doRequest();
-
- $this->assertFalse($result);
- }*/
-
public function testDecodeGzip()
{
if (function_exists('gzdecode')) {
@@ -77,7 +56,6 @@ class StreamTest extends PHPUnit_Framework_TestCase
$client->setUrl('https://github.com/fguillot/picoFeed');
$result = $client->doRequest();
- $this->assertNotFalse($result);
$this->assertEquals('gzip', $result['headers']['Content-Encoding']);
$this->assertEquals('<!DOC', substr(trim($result['body']), 0, 5));
}
diff --git a/3rdparty/fguillot/picofeed/tests/UrlTest.php b/3rdparty/fguillot/picofeed/tests/Client/UrlTest.php
index 35395914d..a07898778 100644
--- a/3rdparty/fguillot/picofeed/tests/UrlTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Client/UrlTest.php
@@ -1,8 +1,8 @@
<?php
+namespace PicoFeed\Client;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Url;
class UrlTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/AttributeFilterTest.php b/3rdparty/fguillot/picofeed/tests/Filter/AttributeFilterTest.php
index 58e1b114d..e4de74aaf 100644
--- a/3rdparty/fguillot/picofeed/tests/AttributeFilterTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Filter/AttributeFilterTest.php
@@ -1,9 +1,10 @@
<?php
+namespace PicoFeed\Filter;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
+
+use PicoFeed\Client\Url;
-use PicoFeed\Url;
-use PicoFeed\Filter\Attribute;
class AttributeFilterTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/FilterTest.php b/3rdparty/fguillot/picofeed/tests/Filter/FilterTest.php
index 2fe0d10a3..f3b736dfb 100644
--- a/3rdparty/fguillot/picofeed/tests/FilterTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Filter/FilterTest.php
@@ -1,9 +1,10 @@
<?php
+namespace PicoFeed\Filter;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
+
+use PicoFeed\Config\Config;
-use PicoFeed\Filter;
-use PicoFeed\Config;
class FilterTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/HtmlFilterTest.php b/3rdparty/fguillot/picofeed/tests/Filter/HtmlFilterTest.php
index 5d6aaa3b2..271167481 100644
--- a/3rdparty/fguillot/picofeed/tests/HtmlFilterTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Filter/HtmlFilterTest.php
@@ -1,8 +1,8 @@
<?php
+namespace PicoFeed\Filter;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Filter\Html;
class HtmlFilterTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/TagFilterTest.php b/3rdparty/fguillot/picofeed/tests/Filter/TagFilterTest.php
index b66c7098b..86911bbb4 100644
--- a/3rdparty/fguillot/picofeed/tests/TagFilterTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Filter/TagFilterTest.php
@@ -1,8 +1,8 @@
<?php
+namespace PicoFeed\Filter;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Filter\Tag;
class TagFilterTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/AtomParserTest.php b/3rdparty/fguillot/picofeed/tests/Parser/AtomParserTest.php
index 09cd4bea2..491c7e3e0 100644
--- a/3rdparty/fguillot/picofeed/tests/AtomParserTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Parser/AtomParserTest.php
@@ -1,30 +1,28 @@
<?php
+namespace PicoFeed\Parser;
-require_once 'lib/PicoFeed/PicoFeed.php';
-require_once 'lib/PicoFeed/Parsers/Atom.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Parsers\Atom;
class AtomParserTest extends PHPUnit_Framework_TestCase
{
+ /**
+ * @expectedException PicoFeed\Parser\MalformedXmlException
+ */
public function testBadInput()
{
$parser = new Atom('boo');
- $this->assertFalse($parser->execute());
+ $parser->execute();
}
public function testFeedTitle()
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('The Official Google Blog', $feed->getTitle());
$parser = new Atom(file_get_contents('tests/fixtures/atomsample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('Example Feed', $feed->getTitle());
}
@@ -32,14 +30,10 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('Insights from Googlers into our products, technology, and the Google culture.', $feed->getDescription());
$parser = new Atom(file_get_contents('tests/fixtures/atomsample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('', $feed->getDescription());
}
@@ -47,14 +41,10 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('', $feed->getLogo());
$parser = new Atom(file_get_contents('tests/fixtures/bbc_urdu.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('http://www.bbc.co.uk/urdu/images/gel/rss_logo.gif', $feed->getLogo());
}
@@ -62,20 +52,14 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('http://googleblog.blogspot.com/', $feed->getUrl());
$parser = new Atom(file_get_contents('tests/fixtures/atomsample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('http://example.org/', $feed->getUrl());
$parser = new Atom(file_get_contents('tests/fixtures/lagrange.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('http://www.la-grange.net/', $feed->getUrl());
}
@@ -83,14 +67,10 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('tag:blogger.com,1999:blog-10861780', $feed->getId());
$parser = new Atom(file_get_contents('tests/fixtures/atomsample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6', $feed->getId());
}
@@ -98,14 +78,10 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals(1360148333, $feed->getDate());
$parser = new Atom(file_get_contents('tests/fixtures/atomsample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals(1071340202, $feed->getDate());
}
@@ -113,23 +89,17 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('', $feed->getLanguage());
$this->assertEquals('', $feed->items[0]->getLanguage());
$parser = new Atom(file_get_contents('tests/fixtures/bbc_urdu.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('ur', $feed->getLanguage());
$this->assertEquals('ur', $feed->items[0]->getLanguage());
$parser = new Atom(file_get_contents('tests/fixtures/lagrange.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('fr', $feed->getLanguage());
$this->assertEquals('fr', $feed->items[0]->getLanguage());
@@ -139,18 +109,14 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atomsample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
- $this->assertEquals('34ce9ad8', $feed->items[0]->getId());
+ $this->assertEquals('3841e5cf232f5111fc5841e9eba5f4b26d95e7d7124902e0f7272729d65601a6', $feed->items[0]->getId());
}
public function testItemUrl()
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('http://feedproxy.google.com/~r/blogspot/MKuf/~3/S_hccisqTW8/a-chrome-experiment-made-with-some.html', $feed->items[0]->getUrl());
}
@@ -159,8 +125,6 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('Safer Internet Day: How we help you stay secure online', $feed->items[1]->getTitle());
}
@@ -169,15 +133,11 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals(1360011661, $feed->items[1]->getDate());
$parser = new Atom(file_get_contents('tests/fixtures/atomsample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals(1071340202, $feed->items[0]->getDate());
}
@@ -186,8 +146,6 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('', $feed->items[1]->getLanguage());
}
@@ -196,15 +154,11 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('Emily Wood', $feed->items[1]->getAuthor());
$parser = new Atom(file_get_contents('tests/fixtures/atomsample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('John Doe', $feed->items[0]->getAuthor());
}
@@ -213,28 +167,12 @@ class AtomParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Atom(file_get_contents('tests/fixtures/atom.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertTrue(strpos($feed->items[1]->getContent(), '<p>Technology can') === 0);
$parser = new Atom(file_get_contents('tests/fixtures/atomsample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertTrue(strpos($feed->items[0]->getContent(), '<p>Some text.') === 0);
}
-/*
- public function testItemEnclosure()
- {
- $parser = new Atom(file_get_contents('tests/fixtures/rue89.xml'));
- $feed = $parser->execute();
-
- $this->assertNotFalse($feed);
- $this->assertNotEmpty($feed->items);
- $this->assertEquals('http://rue89.feedsportal.com/c/33822/f/608948/e/1/s/2a687021/l/0L0Srue890N0Csites0Cnews0Cfiles0Cstyles0Cmosaic0Cpublic0Czapnet0Cthumbnail0Isquare0C20A130C0A40Ccahuzac0I10Bpng/cahuzac_1.png', $feed->items[0]->getEnclosureUrl());
- $this->assertEquals('image/png', $feed->items[0]->getEnclosureType());
- }
-*/
} \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/tests/ParserTest.php b/3rdparty/fguillot/picofeed/tests/Parser/ParserTest.php
index 502e4d4fd..3be864507 100644
--- a/3rdparty/fguillot/picofeed/tests/ParserTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Parser/ParserTest.php
@@ -1,11 +1,8 @@
<?php
+namespace PicoFeed\Parser;
-require_once 'lib/PicoFeed/PicoFeed.php';
-require_once 'lib/PicoFeed/Parsers/Rss20.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\XmlParser;
-use PicoFeed\Parser;
-use PicoFeed\Parsers\Rss20;
class ParserTest extends PHPUnit_Framework_TestCase
{
@@ -48,31 +45,10 @@ class ParserTest extends PHPUnit_Framework_TestCase
$this->assertEquals(time(), $parser->parseDate('+0400'));
}
-/*
- public function testNormalizeData()
- {
- $parser = new Rss20('');
-
- $data = '<title> Police &#039;Like&#039; Wanted Suspect&#039;s Facebook Post</title>
- <link rel="alternate" type="text/html" href="http://www.huffingtonpost.com/huff-wires/20140121/us-odd--police-like-facebook-post/?utm_hp_ref=travel&ir=travel" />
- <id>http://www.huffingtonpost.com/2014/01/22/anthony-lescowitch-facebook_n_4643239.html</id>
- <truc href="blabla &amp;"/>';
-
- $result = $parser->replaceEntityAttribute($data);
-
- $expected = '<title> Police &#039;Like&#039; Wanted Suspect&#039;s Facebook Post</title>
- <link rel="alternate" type="text/html" href="http://www.huffingtonpost.com/huff-wires/20140121/us-odd--police-like-facebook-post/?utm_hp_ref=travel&amp;ir=travel" />
- <id>http://www.huffingtonpost.com/2014/01/22/anthony-lescowitch-facebook_n_4643239.html</id>
- <truc href="blabla &amp;"/>';
-
- $this->assertEquals($expected, $result);
- }
-*/
-
public function testChangeHashAlgo()
{
$parser = new Rss20('');
- $this->assertEquals('9e83486d', $parser->generateId('a', 'b'));
+ $this->assertEquals('fb8e20fc2e4c3f248c60c39bd652f3c1347298bb977b8b4d5903b85055620603', $parser->generateId('a', 'b'));
$parser->setHashAlgo('sha1');
$this->assertEquals('da23614e02469a0d7c7bd1bdab5c9c474b1904dc', $parser->generateId('a', 'b'));
diff --git a/3rdparty/fguillot/picofeed/tests/Rss10ParserTest.php b/3rdparty/fguillot/picofeed/tests/Parser/Rss10ParserTest.php
index cd79226fb..bc0824502 100644
--- a/3rdparty/fguillot/picofeed/tests/Rss10ParserTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Parser/Rss10ParserTest.php
@@ -1,24 +1,24 @@
<?php
+namespace PicoFeed\Parser;
-require_once 'lib/PicoFeed/PicoFeed.php';
-require_once 'lib/PicoFeed/Parsers/Rss10.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Parsers\Rss10;
class Rss10ParserTest extends PHPUnit_Framework_TestCase
{
+ /**
+ * @expectedException PicoFeed\Parser\MalformedXmlException
+ */
public function testBadInput()
{
$parser = new Rss10('boo');
- $this->assertFalse($parser->execute());
+ $parser->execute();
}
public function testFeedTitle()
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals("Planète jQuery : l'actualité jQuery, plugins jQuery et tutoriels jQuery en français", $feed->getTitle());
}
@@ -26,8 +26,6 @@ class Rss10ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('http://planete-jquery.fr', $feed->getUrl());
}
@@ -35,8 +33,6 @@ class Rss10ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('http://planete-jquery.fr', $feed->getId());
}
@@ -44,8 +40,6 @@ class Rss10ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals(1363752990, $feed->getDate());
}
@@ -53,8 +47,6 @@ class Rss10ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('fr', $feed->getLanguage());
$this->assertEquals('fr', $feed->items[0]->getLanguage());
}
@@ -62,19 +54,18 @@ class Rss10ParserTest extends PHPUnit_Framework_TestCase
public function testItemId()
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
+ $parser->disableContentFiltering();
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
- $this->assertEquals($parser->generateId($feed->items[0]->getUrl(), $feed->getUrl()), $feed->items[0]->getId());
+
+ $item = $feed->items[0];
+ $this->assertEquals($parser->generateId($item->getTitle(), $item->getUrl(), $item->getContent()), $item->getId());
}
public function testItemUrl()
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('http://www.mathieurobin.com/2013/03/chroniques-jquery-episode-108/', $feed->items[0]->getUrl());
}
@@ -83,8 +74,6 @@ class Rss10ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('LaFermeDuWeb : PowerTip - Des tooltips aux fonctionnalités avancées', $feed->items[1]->getTitle());
}
@@ -93,8 +82,6 @@ class Rss10ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals(1362647700, $feed->items[1]->getDate());
}
@@ -103,8 +90,6 @@ class Rss10ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('fr', $feed->items[1]->getLanguage());
}
@@ -113,8 +98,6 @@ class Rss10ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('LaFermeDuWeb', $feed->items[1]->getAuthor());
}
@@ -123,8 +106,6 @@ class Rss10ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss10(file_get_contents('tests/fixtures/planete-jquery.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertTrue(strpos($feed->items[1]->getContent(), '<a href="http://www.lafermeduweb.net') === 0);
}
diff --git a/3rdparty/fguillot/picofeed/tests/Rss20ParserTest.php b/3rdparty/fguillot/picofeed/tests/Parser/Rss20ParserTest.php
index 190e7f325..b06821c27 100644
--- a/3rdparty/fguillot/picofeed/tests/Rss20ParserTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Parser/Rss20ParserTest.php
@@ -1,30 +1,28 @@
<?php
+namespace PicoFeed\Parser;
-require_once 'lib/PicoFeed/PicoFeed.php';
-require_once 'lib/PicoFeed/Parsers/Rss20.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Parsers\Rss20;
class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
+ /**
+ * @expectedException PicoFeed\Parser\MalformedXmlException
+ */
public function testBadInput()
{
$parser = new Rss20('boo');
- $this->assertFalse($parser->execute());
+ $parser->execute();
}
public function testFeedTitle()
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('WordPress News', $feed->getTitle());
$parser = new Rss20(file_get_contents('tests/fixtures/pcinpact.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('PC INpact', $feed->getTitle());
}
@@ -32,20 +30,14 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('WordPress News', $feed->getDescription());
$parser = new Rss20(file_get_contents('tests/fixtures/pcinpact.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('Actualités Informatique', $feed->getDescription());
$parser = new Rss20(file_get_contents('tests/fixtures/sametmax.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('Deux développeurs en vadrouille qui se sortent les doigts du code', $feed->getDescription());
}
@@ -53,14 +45,10 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('', $feed->getLogo());
$parser = new Rss20(file_get_contents('tests/fixtures/radio-france.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('http://media.radiofrance-podcast.net/podcast09/RF_OMM_0000006330_ITE.jpg', $feed->getLogo());
}
@@ -68,14 +56,10 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('http://wordpress.org/news', $feed->getUrl());
$parser = new Rss20(file_get_contents('tests/fixtures/pcinpact.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('http://www.pcinpact.com/', $feed->getUrl());
}
@@ -83,8 +67,6 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('http://wordpress.org/news', $feed->getId());
}
@@ -92,14 +74,10 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals(1359066183, $feed->getDate());
$parser = new Rss20(file_get_contents('tests/fixtures/fulltextrss.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals(time(), $feed->getDate());
}
@@ -107,22 +85,16 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('en-US', $feed->getLanguage());
$this->assertEquals('en-US', $feed->items[0]->getLanguage());
$parser = new Rss20(file_get_contents('tests/fixtures/zoot_egkty.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('ur', $feed->getLanguage());
$this->assertEquals('ur', $feed->items[0]->getLanguage());
$parser = new Rss20(file_get_contents('tests/fixtures/ibash.ru.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertEquals('ru', $feed->getLanguage());
$this->assertEquals('ru', $feed->items[0]->getLanguage());
}
@@ -131,32 +103,24 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
- $this->assertEquals($parser->generateId($feed->items[0]->getUrl(), $feed->getUrl(), 'http://wordpress.org/news/?p=2531'), $feed->items[0]->getId());
+ $this->assertEquals('de679f14fc4774f0d6dfe73c3f8c8368ab85da18addf101a2af8c32ac6320f9f', $feed->items[0]->getId());
$parser = new Rss20(file_get_contents('tests/fixtures/pcinpact.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
- $this->assertEquals($parser->generateId($feed->items[0]->getUrl(), $feed->getUrl(), '78872'), $feed->items[0]->getId());
+ $this->assertEquals(hash('sha256', '78872'), $feed->items[0]->getId());
$parser = new Rss20(file_get_contents('tests/fixtures/fulltextrss.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
- $this->assertEquals($parser->generateId($feed->items[0]->getUrl(), $feed->getUrl()), $feed->items[0]->getId());
+ $this->assertEquals(hash('sha256', 'http://www.numerama.com/magazine/25669-brevets-un-juge-doute-de-la-bonne-volonte-de-google-et-apple.html'), $feed->items[0]->getId());
$parser = new Rss20(file_get_contents('tests/fixtures/debug_show.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
- $this->assertEquals($parser->generateId($feed->items[1]->getUrl(), $feed->getUrl(), '38DC2FF1-4207-4C04-93F3-2DAFB0E559D9'), $feed->items[1]->getId());
- $this->assertEquals($parser->generateId($feed->items[2]->getUrl(), $feed->getUrl(), '3FA03A63-BEA2-4199-A1E4-D2963845F3F6'), $feed->items[2]->getId());
+ $this->assertEquals(hash('sha256', '38DC2FF1-4207-4C04-93F3-2DAFB0E559D9'), $feed->items[1]->getId());
+ $this->assertEquals(hash('sha256', '3FA03A63-BEA2-4199-A1E4-D2963845F3F6'), $feed->items[2]->getId());
$this->assertEquals($feed->items[1]->getUrl(), $feed->items[2]->getUrl());
$this->assertNotEquals($feed->items[1]->getId(), $feed->items[2]->getId());
}
@@ -165,15 +129,11 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('http://wordpress.org/news/2013/01/wordpress-3-5-1/', $feed->items[0]->getUrl());
$parser = new Rss20(file_get_contents('tests/fixtures/pcinpact.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('http://www.pcinpact.com/breve/78872-la-dcri-purge-wikipedia-par-menace-bel-effet-streisand-a-cle.htm?utm_source=PCi_RSS_Feed&utm_medium=news&utm_campaign=pcinpact', $feed->items[0]->getUrl());
}
@@ -182,25 +142,24 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('2012: A Look Back', $feed->items[1]->getTitle());
+
+ $parser = new Rss20(file_get_contents('tests/fixtures/womensweardaily.xml'));
+ $feed = $parser->execute();
+ $this->assertNotEmpty($feed->items);
+ $this->assertEquals('They Are Wearing: Frieze London Photo by Marcus Dawes', $feed->items[3]->getTitle());
}
public function testItemDate()
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals(1357006940, $feed->items[1]->getDate());
$parser = new Rss20(file_get_contents('tests/fixtures/fulltextrss.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals(1365781095, $feed->items[0]->getDate());
}
@@ -209,8 +168,6 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('en-US', $feed->items[1]->getLanguage());
}
@@ -219,15 +176,11 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('Jen Mylo', $feed->items[1]->getAuthor());
$parser = new Rss20(file_get_contents('tests/fixtures/rss2sample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('webmaster@example.com', $feed->items[2]->getAuthor());
}
@@ -236,22 +189,16 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rss20.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertTrue(strpos($feed->items[1]->getContent(), '<p>Another year is coming') === 0);
$parser = new Rss20(file_get_contents('tests/fixtures/rss2sample.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertTrue(strpos($feed->items[1]->getContent(), '<p>Sky watchers in Europe') === 0);
$parser = new Rss20(file_get_contents('tests/fixtures/ibash.ru.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertTrue(strpos($feed->items[0]->getContent(), '<p>Хабр, обсуждение фейлов на работе: reaferon: Интернет') === 0);
}
@@ -260,8 +207,6 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/rue89.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('http://rue89.feedsportal.com/c/33822/f/608948/e/1/s/2a687021/l/0L0Srue890N0Csites0Cnews0Cfiles0Cstyles0Cmosaic0Cpublic0Czapnet0Cthumbnail0Isquare0C20A130C0A40Ccahuzac0I10Bpng/cahuzac_1.png', $feed->items[0]->getEnclosureUrl());
$this->assertEquals('image/png', $feed->items[0]->getEnclosureType());
@@ -271,64 +216,37 @@ class Rss20ParserTest extends PHPUnit_Framework_TestCase
{
$parser = new Rss20(file_get_contents('tests/fixtures/biertaucher.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals(177, count($feed->items));
$parser = new Rss20(file_get_contents('tests/fixtures/radio-france.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals(52, count($feed->items));
$parser = new Rss20(file_get_contents('tests/fixtures/fanboys.fm_episodes.all.mp3.rss'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$parser = new Rss20(file_get_contents('tests/fixtures/geekstammtisch.de_episodes.mp3.rss'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$this->assertEquals('http://geekstammtisch.de#GST001', $feed->items[1]->getUrl());
$parser = new Rss20(file_get_contents('tests/fixtures/lincoln_loop.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$parser = new Rss20(file_get_contents('tests/fixtures/next_inpact_full.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$parser = new Rss20(file_get_contents('tests/fixtures/jeux-linux.fr.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
$parser = new Rss20(file_get_contents('tests/fixtures/cercle.psy.xml'));
$feed = $parser->execute();
-
- $this->assertNotFalse($feed);
- $this->assertNotEmpty($feed->items);
-
- $parser = new Rss20(file_get_contents('tests/fixtures/resorts.xml'));
- $feed = $parser->execute();
-
- $this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
- $this->assertEquals('Hyatt Rates', $feed->getTitle());
- $this->assertEquals('http://www.hyatt.com/rss/edeals/.jhtml', $feed->getUrl());
- $this->assertEquals(1, count($feed->getItems()));
- $this->assertEquals('Tuesday Jul 07,2009-Sunday Jul 19,2009', $feed->items[0]->getTitle());
- $this->assertEquals('http://www.hyatt.com/rss/edeals/.jhtml?19Jul09', $feed->items[0]->getUrl());
}
}
diff --git a/3rdparty/fguillot/picofeed/tests/Rss91ParserTest.php b/3rdparty/fguillot/picofeed/tests/Parser/Rss91ParserTest.php
index 1746803e5..8f10f2ea5 100644
--- a/3rdparty/fguillot/picofeed/tests/Rss91ParserTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Parser/Rss91ParserTest.php
@@ -1,9 +1,8 @@
<?php
+namespace PicoFeed\Parser;
-require_once 'lib/PicoFeed/PicoFeed.php';
-require_once 'lib/PicoFeed/Parsers/Rss91.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Parsers\Rss91;
class Rss91ParserTest extends PHPUnit_Framework_TestCase
{
@@ -23,8 +22,9 @@ class Rss91ParserTest extends PHPUnit_Framework_TestCase
$this->assertEquals('Giving the world a pluggable Gnutella', $feed->items[0]->getTitle());
$this->assertEquals('http://writetheweb.com/read.php?item=24', $feed->items[0]->getUrl());
- $this->assertEquals('5f9fc1c2', $feed->items[0]->getId());
+ $this->assertEquals('085a9133a75542f878fa73ee2afbb6a2350b6c4fb125e6d8ca09478c47702111', $feed->items[0]->getId());
$this->assertEquals(time(), $feed->items[0]->getDate());
$this->assertEquals('webmaster@writetheweb.com', $feed->items[0]->getAuthor());
+ $this->assertTrue(strpos($feed->items[1]->getContent(), '<p>After a period of dormancy') === 0);
}
} \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/tests/Rss92ParserTest.php b/3rdparty/fguillot/picofeed/tests/Parser/Rss92ParserTest.php
index d6add7f08..1d67c2252 100644
--- a/3rdparty/fguillot/picofeed/tests/Rss92ParserTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Parser/Rss92ParserTest.php
@@ -1,9 +1,8 @@
<?php
+namespace PicoFeed\Parser;
-require_once 'lib/PicoFeed/PicoFeed.php';
-require_once 'lib/PicoFeed/Parsers/Rss92.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Parsers\Rss92;
class Rss92ParserTest extends PHPUnit_Framework_TestCase
{
@@ -15,15 +14,15 @@ class Rss92ParserTest extends PHPUnit_Framework_TestCase
$this->assertNotFalse($feed);
$this->assertNotEmpty($feed->items);
- $this->assertEquals('Univers Freebox', $feed->title);
- $this->assertEquals('http://www.universfreebox.com', $feed->url);
- $this->assertEquals('http://www.universfreebox.com', $feed->id);
+ $this->assertEquals('Univers Freebox', $feed->getTitle());
+ $this->assertEquals('http://www.universfreebox.com', $feed->getUrl());
+ $this->assertEquals('http://www.universfreebox.com', $feed->getId());
$this->assertEquals(time(), $feed->date);
$this->assertEquals(30, count($feed->items));
$this->assertEquals('Retour de Xavier Niel sur Twitter, « sans initiative privée, pas de révolution #Born2code »', $feed->items[0]->title);
- $this->assertEquals('http://www.universfreebox.com/article20302.html', $feed->items[0]->url);
- $this->assertEquals('4e8596dc', $feed->items[0]->id);
- $this->assertEquals('', $feed->items[0]->author);
+ $this->assertEquals('http://www.universfreebox.com/article20302.html', $feed->items[0]->getUrl());
+ $this->assertEquals('ad23a45af194cc46d5151a9a062c5841b03405e456595c30b742d827e08af2e0', $feed->items[0]->getId());
+ $this->assertEquals('', $feed->items[0]->getAuthor());
}
} \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/tests/XmlParserTest.php b/3rdparty/fguillot/picofeed/tests/Parser/XmlParserTest.php
index c02116e44..b20b3f635 100644
--- a/3rdparty/fguillot/picofeed/tests/XmlParserTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Parser/XmlParserTest.php
@@ -1,8 +1,10 @@
<?php
+namespace PicoFeed\Parser;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use DOMDocument;
+
+use PHPUnit_Framework_TestCase;
-use PicoFeed\XmlParser;
class XmlParserTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/Reader/ReaderTest.php b/3rdparty/fguillot/picofeed/tests/Reader/ReaderTest.php
new file mode 100644
index 000000000..c060de0aa
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/tests/Reader/ReaderTest.php
@@ -0,0 +1,157 @@
+<?php
+namespace PicoFeed\Reader;
+
+use PHPUnit_Framework_TestCase;
+
+
+class ReaderTest extends PHPUnit_Framework_TestCase
+{
+ public function testPrependScheme()
+ {
+ $reader = new Reader;
+ $this->assertEquals('http://http.com', $reader->prependScheme('http.com'));
+ $this->assertEquals('http://boo.com', $reader->prependScheme('boo.com'));
+ $this->assertEquals('http://google.com', $reader->prependScheme('http://google.com'));
+ $this->assertEquals('https://google.com', $reader->prependScheme('https://google.com'));
+ }
+
+
+ public function testDownload()
+ {
+ $reader = new Reader;
+ $feed = $reader->download('http://wordpress.org/news/feed/')->getContent();
+ $this->assertNotEmpty($feed);
+ }
+
+
+ public function testDownloadWithCache()
+ {
+ $reader = new Reader;
+ $resource = $reader->download('http://linuxfr.org/robots.txt');
+ $this->assertTrue($resource->isModified());
+
+ $lastModified = $resource->getLastModified();
+ $etag = $resource->getEtag();
+
+ $reader = new Reader;
+ $resource = $reader->download('http://linuxfr.org/robots.txt', $lastModified, $etag);
+ $this->assertFalse($resource->isModified());
+ }
+
+
+ public function testDetectFormat()
+ {
+ $reader = new Reader;
+ $this->assertEquals('Rss20', $reader->detectFormat(file_get_contents('tests/fixtures/jeux-linux.fr.xml')));
+
+ $reader = new Reader;
+ $this->assertEquals('Rss20', $reader->detectFormat(file_get_contents('tests/fixtures/sametmax.xml')));
+
+ $reader = new Reader;
+ $this->assertEquals('Rss92', $reader->detectFormat(file_get_contents('tests/fixtures/rss_0.92.xml')));
+
+ $reader = new Reader;
+ $this->assertEquals('Rss91', $reader->detectFormat(file_get_contents('tests/fixtures/rss_0.91.xml')));
+
+ $reader = new Reader;
+ $this->assertEquals('Rss10', $reader->detectFormat(file_get_contents('tests/fixtures/planete-jquery.xml')));
+
+ $reader = new Reader;
+ $this->assertEquals('Rss20', $reader->detectFormat(file_get_contents('tests/fixtures/rss2sample.xml')));
+
+ $reader = new Reader;
+ $this->assertEquals('Atom', $reader->detectFormat(file_get_contents('tests/fixtures/atomsample.xml')));
+
+ $reader = new Reader;
+ $this->assertEquals('Rss20', $reader->detectFormat(file_get_contents('tests/fixtures/cercle.psy.xml')));
+
+ $reader = new Reader;
+ $this->assertEquals('Rss20', $reader->detectFormat(file_get_contents('tests/fixtures/ezrss.it')));
+
+ $reader = new Reader;
+ $this->assertEquals('Rss20', $reader->detectFormat(file_get_contents('tests/fixtures/grotte_barbu.xml')));
+
+ $content = '<?xml version="1.0" encoding="UTF-8"?>
+<?xml-stylesheet type="text/xsl" media="screen" href="/~d/styles/rss2titles.xsl"?><?xml-stylesheet type="text/css" media="screen" href="http://feeds.feedburner.com/~d/styles/itemtitles.css"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:media="http://search.yahoo.com/mrss/" xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" version="2.0">';
+
+ $reader = new Reader;
+ $this->assertEquals('Rss20', $reader->detectFormat($content));
+ }
+
+
+ public function testFind()
+ {
+ $reader = new Reader;
+ $resource = $reader->download('http://miniflux.net/');
+ $feeds = $reader->find($resource->getUrl(), $resource->getContent());
+ $this->assertTrue(is_array($feeds));
+ $this->assertNotEmpty($feeds);
+ $this->assertEquals('http://miniflux.net/feed', $feeds[0]);
+
+ $reader = new Reader;
+ $resource = $reader->download('http://www.bbc.com/news/');
+ $feeds = $reader->find($resource->getUrl(), $resource->getContent());
+ $this->assertTrue(is_array($feeds));
+ $this->assertNotEmpty($feeds);
+ $this->assertEquals('http://feeds.bbci.co.uk/news/rss.xml', $feeds[0]);
+
+ $reader = new Reader;
+ $resource = $reader->download('http://www.cnn.com/services/rss/');
+ $feeds = $reader->find($resource->getUrl(), $resource->getContent());
+ $this->assertTrue(is_array($feeds));
+ $this->assertNotEmpty($feeds);
+ $this->assertTrue(count($feeds) > 1);
+ $this->assertEquals('http://rss.cnn.com/rss/cnn_topstories.rss', $feeds[0]);
+ $this->assertEquals('http://rss.cnn.com/rss/cnn_world.rss', $feeds[1]);
+ }
+
+
+ public function testDiscover()
+ {
+ $reader = new Reader;
+ $client = $reader->discover('http://www.universfreebox.com/');
+ $this->assertEquals('http://www.universfreebox.com/backend.php', $client->getUrl());
+ $this->assertInstanceOf('PicoFeed\Parser\Rss20', $reader->getParser($client->getUrl(), $client->getContent(), $client->getEncoding()));
+
+ $reader = new Reader;
+ $client = $reader->discover('http://planete-jquery.fr');
+ $this->assertInstanceOf('PicoFeed\Parser\Rss20', $reader->getParser($client->getUrl(), $client->getContent(), $client->getEncoding()));
+
+ $reader = new Reader;
+ $client = $reader->discover('http://cabinporn.com/');
+ $this->assertEquals('http://cabinporn.com/rss', $client->getUrl());
+ $this->assertInstanceOf('PicoFeed\Parser\Rss20', $reader->getParser($client->getUrl(), $client->getContent(), $client->getEncoding()));
+
+ $reader = new Reader;
+ $client = $reader->discover('http://linuxfr.org/');
+ $this->assertEquals('http://linuxfr.org/news.atom', $client->getUrl());
+ $this->assertInstanceOf('PicoFeed\Parser\Atom', $reader->getParser($client->getUrl(), $client->getContent(), $client->getEncoding()));
+ }
+
+
+ public function testFeedsReportedAsNotWorking()
+ {
+ $reader = new Reader;
+ $this->assertInstanceOf('PicoFeed\Parser\Rss20', $reader->getParser('http://blah', file_get_contents('tests/fixtures/cercle.psy.xml'), 'utf-8'));
+
+ $reader = new Reader;
+ $this->assertInstanceOf('PicoFeed\Parser\Rss20', $reader->getParser('http://blah', file_get_contents('tests/fixtures/ezrss.it'), 'utf-8'));
+
+ $reader = new Reader;
+ $this->assertInstanceOf('PicoFeed\Parser\Rss20', $reader->getParser('http://blah', file_get_contents('tests/fixtures/grotte_barbu.xml'), 'utf-8'));
+
+ $reader = new Reader;
+ $client = $reader->download('http://www.groovehq.com/blog/feed');
+
+ $parser = $reader->getParser($client->getUrl(), $client->getContent(), $client->getEncoding());
+ $this->assertInstanceOf('PicoFeed\Parser\Atom', $parser);
+
+ $feed = $parser->execute();
+
+ $this->assertEquals('http://www.groovehq.com/blog/feed', $client->getUrl());
+ $this->assertEquals('http://www.groovehq.com/blog/feed', $feed->getUrl());
+ $this->assertNotEquals('http://www.groovehq.com/blog/feed', $feed->items[0]->getUrl());
+ $this->assertTrue(strpos($feed->items[0]->getUrl(), 'http://') === 0);
+ $this->assertTrue(strpos($feed->items[0]->getUrl(), 'feed') === false);
+ }
+} \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/tests/ReaderTest.php b/3rdparty/fguillot/picofeed/tests/ReaderTest.php
deleted file mode 100644
index 2ecd0b327..000000000
--- a/3rdparty/fguillot/picofeed/tests/ReaderTest.php
+++ /dev/null
@@ -1,108 +0,0 @@
-<?php
-
-require_once 'lib/PicoFeed/PicoFeed.php';
-
-use PicoFeed\Reader;
-
-class ReaderTest extends PHPUnit_Framework_TestCase
-{
- public function testDownload()
- {
- $reader = new Reader;
- $feed = $reader->download('http://wordpress.org/news/feed/')->getContent();
- $this->assertNotEmpty($feed);
- }
-
-
- public function testDownloadWithCache()
- {
- $reader = new Reader;
- $resource = $reader->download('http://linuxfr.org/robots.txt');
- $this->assertTrue($resource->isModified());
-
- $lastModified = $resource->getLastModified();
- $etag = $resource->getEtag();
-
- $reader = new Reader;
- $resource = $reader->download('http://linuxfr.org/robots.txt', $lastModified, $etag);
- $this->assertFalse($resource->isModified());
- }
-
-
- public function testDetectFormat()
- {
- $reader = new Reader;
- $reader->setContent(file_get_contents('tests/fixtures/jeux-linux.fr.xml'));
- $this->assertInstanceOf('PicoFeed\Parsers\Rss20', $reader->getParser());
-
- $reader = new Reader;
- $reader->setContent(file_get_contents('tests/fixtures/sametmax.xml'));
- $this->assertInstanceOf('PicoFeed\Parsers\Rss20', $reader->getParser());
-
- $reader = new Reader;
- $reader->setContent(file_get_contents('tests/fixtures/rss_0.92.xml'));
- $this->assertInstanceOf('PicoFeed\Parsers\Rss92', $reader->getParser());
-
- $reader = new Reader;
- $reader->setContent(file_get_contents('tests/fixtures/rss_0.91.xml'));
- $this->assertInstanceOf('PicoFeed\Parsers\Rss91', $reader->getParser());
-
- $reader = new Reader;
- $reader->setContent(file_get_contents('tests/fixtures/planete-jquery.xml'));
- $this->assertInstanceOf('PicoFeed\Parsers\Rss10', $reader->getParser());
-
- $reader = new Reader;
- $reader->setContent(file_get_contents('tests/fixtures/rss2sample.xml'));
- $this->assertInstanceOf('PicoFeed\Parsers\Rss20', $reader->getParser());
-
- $reader = new Reader;
- $reader->setContent(file_get_contents('tests/fixtures/atomsample.xml'));
- $this->assertInstanceOf('PicoFeed\Parsers\Atom', $reader->getParser());
-
- $reader = new Reader;
- $this->assertFalse($reader->getParser());
-
- $reader = new Reader;
- $reader->setContent('<?xml version="1.0" encoding="UTF-8"?>
-<?xml-stylesheet type="text/xsl" media="screen" href="/~d/styles/rss2titles.xsl"?><?xml-stylesheet type="text/css" media="screen" href="http://feeds.feedburner.com/~d/styles/itemtitles.css"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:media="http://search.yahoo.com/mrss/" xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#" xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0" version="2.0">');
- $this->assertInstanceOf('PicoFeed\Parsers\Rss20', $reader->getParser());
- }
-
-
- public function testRemoteDetection()
- {
- $reader = new Reader;
- $reader->download('http://www.universfreebox.com/');
- $this->assertInstanceOf('PicoFeed\Parsers\Rss20', $reader->getParser());
-
- $reader = new Reader;
- $reader->download('http://planete-jquery.fr');
- $this->assertTrue($reader->discover());
-
- $reader = new Reader;
- $reader->download('http://cabinporn.com/');
- $this->assertTrue($reader->discover());
- $this->assertEquals('http://cabinporn.com/rss', $reader->getUrl());
-
- $reader = new Reader;
- $reader->download('http://linuxfr.org/');
- $this->assertTrue($reader->discover());
- $this->assertEquals('http://linuxfr.org/news.atom', $reader->getUrl());
- }
-
-
- public function testFeedsReportedAsNotWorking()
- {
- $reader = new Reader;
- $reader->setContent(file_get_contents('tests/fixtures/cercle.psy.xml'));
- $this->assertInstanceOf('PicoFeed\Parsers\Rss20', $reader->getParser());
-
- $reader = new Reader;
- $reader->setContent(file_get_contents('tests/fixtures/ezrss.it'));
- $this->assertInstanceOf('PicoFeed\Parsers\Rss20', $reader->getParser());
-
- $reader = new Reader;
- $reader->setContent(file_get_contents('tests/fixtures/grotte_barbu.xml'));
- $this->assertInstanceOf('PicoFeed\Parsers\Rss20', $reader->getParser());
- }
-} \ No newline at end of file
diff --git a/3rdparty/fguillot/picofeed/tests/ExportTest.php b/3rdparty/fguillot/picofeed/tests/Serialization/ExportTest.php
index b90fea06d..fa68c05ca 100644
--- a/3rdparty/fguillot/picofeed/tests/ExportTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Serialization/ExportTest.php
@@ -1,8 +1,8 @@
<?php
+namespace PicoFeed\Serialization;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Export;
class ExportTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/ImportTest.php b/3rdparty/fguillot/picofeed/tests/Serialization/ImportTest.php
index 784a00d82..8fd010486 100644
--- a/3rdparty/fguillot/picofeed/tests/ImportTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Serialization/ImportTest.php
@@ -1,8 +1,8 @@
<?php
+namespace PicoFeed\Serialization;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Import;
class ImportTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/AtomWriterTest.php b/3rdparty/fguillot/picofeed/tests/Syndication/AtomWriterTest.php
index f3541bc0f..9d263fd2c 100644
--- a/3rdparty/fguillot/picofeed/tests/AtomWriterTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Syndication/AtomWriterTest.php
@@ -1,8 +1,8 @@
<?php
+namespace PicoFeed\Syndication;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Writers\Atom;
class AtomWriterTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/Rss20WriterTest.php b/3rdparty/fguillot/picofeed/tests/Syndication/Rss20WriterTest.php
index af453b56a..2c61b8537 100644
--- a/3rdparty/fguillot/picofeed/tests/Rss20WriterTest.php
+++ b/3rdparty/fguillot/picofeed/tests/Syndication/Rss20WriterTest.php
@@ -1,8 +1,8 @@
<?php
+namespace PicoFeed\Syndication;
-require_once 'lib/PicoFeed/PicoFeed.php';
+use PHPUnit_Framework_TestCase;
-use PicoFeed\Writers\Rss20;
class Rss20WriterTest extends PHPUnit_Framework_TestCase
{
diff --git a/3rdparty/fguillot/picofeed/tests/fixtures/groovehq.xml b/3rdparty/fguillot/picofeed/tests/fixtures/groovehq.xml
new file mode 100644
index 000000000..dd6eda3fd
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/tests/fixtures/groovehq.xml
@@ -0,0 +1,1767 @@
+<?xml version='1.0' encoding='utf-8' ?>
+<feed xmlns='http://www.w3.org/2005/Atom'>
+<title type='text'>Groove Blog</title>
+<generator uri='http://nestacms.com'>Nesta</generator>
+<id>tag:blog1.groovehq.com,2009:/</id>
+<link href='/articles.xml' rel='self' />
+<link href='/' rel='alternate' />
+<subtitle type='text'>Configure your subtitle</subtitle>
+<updated>2014-10-16T00:00:00+00:00</updated>
+<entry>
+<title>SEO for Startups — How We Got Over Our Fear of SEO</title>
+<link href='/blog/seo-for-startups' rel='alternate' type='text/html' />
+<id>tag:blog1.groovehq.com,2014-10-16:/blog/seo-for-startups</id>
+<content type='html'>&lt;p&gt;I used to think of SEO as a “scammy” strategy for startups. Here’s&lt;br&gt;why I changed my mind.&lt;/p&gt;
+
+&lt;p&gt;This is a post about being wrong.&lt;/p&gt;
+
+&lt;p&gt;About totally misjudging something, and waiting too long to try it because of preconceived notions.&lt;/p&gt;
+
+&lt;p&gt;And about how finally digging into the potential value of doing SEO “right” convinced me that it was worth pursuing.&lt;/p&gt;
+
+&lt;p&gt;If you’re in the same boat — that is, curious about SEO but not really sure where to start or why — then this post is for you.&lt;/p&gt;
+
+&lt;h2&gt;Three Reasons We Didn’t Do Any SEO Before&lt;/h2&gt;
+
+&lt;p&gt;There are a number of reasons we hadn’t given much thought to SEO in the past. Looking back, some of them were completely valid, and others totally misguided…&lt;/p&gt;
+
+&lt;h3&gt;1) Focus&lt;/h3&gt;
+
+&lt;p&gt;Our team is big on &lt;a href=&quot;http://www.groovehq.com/blog/focus&quot;&gt;focus&lt;/a&gt;. We believe in optimizing our time to spend it on the things that we know will drive results, and cutting mercilessly in the areas that don’t bring much of a return.&lt;/p&gt;
+
+&lt;p&gt;That’s why we &lt;a href=&quot;http://www.groovehq.com/blog/focus&quot;&gt;deleted our Facebook page&lt;/a&gt; last month.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/deleted-facebook.jpg&quot; title=&quot;Focus&quot; alt=&quot;Focus&quot; /&gt;
+&lt;b&gt;Focus&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Things were already going well, and in a world where we’re spending hundreds of team hours per week on product, blogging, content promotion, support and customer development, we didn’t really have the capacity to shift focus to SEO.&lt;/p&gt;
+
+&lt;p&gt;At least, I didn’t &lt;em&gt;think&lt;/em&gt; we did.&lt;/p&gt;
+
+&lt;h3&gt;2) Lack of Knowledge&lt;/h3&gt;
+
+&lt;p&gt;I’ve started four businesses, and grew them all without even thinking about SEO.&lt;/p&gt;
+
+&lt;p&gt;I don’t say that to brag; I say that to explain that SEO simply isn’t something I’ve come across in my career. It’s not something I’ve ever worried about.&lt;/p&gt;
+
+&lt;p&gt;Because of that, I knew next to nothing about it until I hired Jordan, our CTO, a self-taught search marketer who has successfully used SEO and SEM in his own businesses since 2000. Jordan has led the charge and taught our team a lot about doing SEO “right,” but before that, I didn’t really know much about it.&lt;/p&gt;
+
+&lt;p&gt;Which leads me to…&lt;/p&gt;
+
+&lt;h3&gt;3) “SEO Is Scammy”&lt;/h3&gt;
+
+&lt;p&gt;I have no doubt that I’m going to piss off some SEO experts by saying this.&lt;/p&gt;
+
+&lt;p&gt;But frankly — probably because I didn’t know anything about it — before last year, I had the pre-existing notion that SEO was not a whole lot more than a scammy tactic to “game” Google.&lt;/p&gt;
+
+&lt;p&gt;My experiences with “SEO” mostly consisted of:&lt;/p&gt;
+
+&lt;ul&gt;
+&lt;li&gt;Struggling to finish reading blog posts and company websites that were obviously built to house keywords, and &lt;em&gt;not&lt;/em&gt; interesting content.&lt;/li&gt;
+&lt;li&gt;&lt;p&gt;Seeing (and deleting) posts with generic comments and links back to business sites on this blog, every single week.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/spammy-comments.png&quot; title=&quot;Spammy comments&quot; alt=&quot;Spammy comments&quot; /&gt;
+&lt;b&gt;Spammy comments&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;(Note: I actually don’t mind people linking to their business on our blog &lt;i&gt;at all&lt;/i&gt;. If you’re adding value to our community, I’m all about spreading the good word. It’s those who don’t even take the time to read or contribute before spamming us with their links that I can’t stand.)&lt;/p&gt;&lt;/li&gt;
+&lt;li&gt;&lt;p&gt;Getting pitch after pitch from offshore SEO “agencies” offering to write keyword-optimized articles and submit them to hundreds of sites around the web.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/black-seo-offer.png&quot; title=&quot;“SEO”&quot; alt=&quot;“SEO”&quot; /&gt;
+&lt;b&gt;“SEO”&lt;/b&gt;&lt;/p&gt;&lt;/li&gt;
+&lt;/ul&gt;
+
+
+&lt;p&gt;Unfortunately, this was a case of &lt;em&gt;only&lt;/em&gt; seeing the bad side and assuming the worst. And even more unfortunately, that ignorance was costing us traffic.&lt;/p&gt;
+
+&lt;h2&gt;Why We Decided to Optimize Our Website&lt;/h2&gt;
+
+&lt;p&gt;We first began to consider the idea of optimizing our marketing site for Google when we did our last &lt;a href=&quot;http://www.groovehq.com/blog/long-form-landing-page&quot;&gt;redesign&lt;/a&gt;. And while we didn’t do it then, I was warming up to the idea.&lt;/p&gt;
+
+&lt;p&gt;The more I read about &lt;em&gt;real&lt;/em&gt; SEO — and not the scammy stuff I had come across — the more I began to see the real value in taking this on.&lt;/p&gt;
+
+&lt;p&gt;Some of the resources I found invaluable were:&lt;/p&gt;
+
+&lt;ul&gt;
+&lt;li&gt;Neil Patel’s &lt;cite&gt;&lt;a href=&quot;https://blog.kissmetrics.com/seo-guide/&quot;&gt;SEO: A Comprehensive Guide for Beginners&lt;/a&gt;&lt;/cite&gt;.&lt;/li&gt;
+&lt;li&gt;David Zheng’s guest post on OkDork, &lt;cite&gt;&lt;a href=&quot;http://okdork.com/2014/03/26/how-we-grew-okdork-200-with-these-exact-seo-tips/&quot;&gt;How We Grew OkDork 200% With These Exact SEO Tips&lt;/a&gt;&lt;/cite&gt;.&lt;/li&gt;
+&lt;li&gt;Brian Clark’s &lt;cite&gt;&lt;a href=&quot;http://scribecontent.com/downloads/How-to-Create-Compelling-Content.pdf&quot;&gt;How To Create Compelling Content That Ranks Well In Search Engines&lt;/a&gt;&lt;/cite&gt;.&lt;/li&gt;
+&lt;li&gt;&lt;cite&gt;&lt;a href=&quot;http://unbounce.com/seo/the-adaptive-seo-approach/&quot;&gt;The Adaptive SEO Approach&lt;/a&gt;&lt;/cite&gt; by Yomar Lopez on the Unbounce blog.&lt;/li&gt;
+&lt;/ul&gt;
+
+
+&lt;p&gt;Finally, I looked at our own conversion numbers, and what I found sealed the deal.&lt;/p&gt;
+
+&lt;p&gt;Visitors from external sources were signing up at a rate of 2.9%.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/conversions-external.png&quot; title=&quot;Conversions: External Sources&quot; alt=&quot;Conversions: External Sources&quot; /&gt;
+&lt;b&gt;Conversions: External Sources&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Traffic from the blog was converting at just over 5%.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/conversions-blog.png&quot; title=&quot;Conversions: Blog&quot; alt=&quot;Conversions: Blog&quot; /&gt;
+&lt;b&gt;Conversions: Blog&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;But traffic from organic search? A whopping 9.4%.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/conversions-organic.png&quot; title=&quot;Conversions: Organic Search&quot; alt=&quot;Conversions: Organic Search&quot; /&gt;
+&lt;b&gt;Conversions: Organic Search&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;A new goal became clear: we needed to increase our search traffic.&lt;/p&gt;
+
+&lt;h2&gt;Our Strategy: How We Built a Solid SEO Foundation&lt;/h2&gt;
+
+&lt;p&gt;I want to be &lt;em&gt;very&lt;/em&gt; clear: this is NOT an expert-level plan for SEO.&lt;/p&gt;
+
+&lt;p&gt;This isn’t even an &lt;em&gt;intermediate&lt;/em&gt; list of the things that you could do.&lt;/p&gt;
+
+&lt;p&gt;This is how we, as a startup that was doing literally &lt;em&gt;nothing&lt;/em&gt; for SEO, began to build a foundation to increase organic search traffic to our marketing site.&lt;/p&gt;
+
+&lt;p&gt;If you’re an SEO expert, this will be &lt;em&gt;very&lt;/em&gt; basic. But if you’re interested in taking the first steps — and seeing how we got &lt;em&gt;awesome&lt;/em&gt; results from a simple process&amp;nbsp;— then read on to see what we did.&lt;/p&gt;
+
+&lt;h3&gt;Step 1: Identify the Problem&lt;/h3&gt;
+
+&lt;p&gt;We had a single-page marketing site that, while converting reasonably well, wasn’t doing us any favors in search engines. In a crowded space, we were often falling onto the third, fourth or fifth page for searches relevant to our customers.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/falling-behind.png&quot; title=&quot;Falling Behind&quot; alt=&quot;Falling Behind&quot; /&gt;
+&lt;b&gt;Falling Behind&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; While our site was doing well when it came to conversions, we were leaving money on the table with a single-page design by not giving search engines anything to pick up.&lt;/p&gt;
+
+&lt;h3&gt;Step 2: See What Prospects Are Searching for&lt;/h3&gt;
+
+&lt;p&gt;We had a &lt;em&gt;bit&lt;/em&gt; of a head start here, as we had done similar research for a small AdWords test last year. But essentially, we used &lt;a href=&quot;https://adwords.google.com/KeywordPlanner&quot;&gt;Google’s Keyword Planner&lt;/a&gt; to check how frequently people were searching hundreds of different terms (and variations of those terms).&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/google-keyword-planner.png&quot; title=&quot;Google Keyword Planner&quot; alt=&quot;Google Keyword Planner&quot; /&gt;
+&lt;b&gt;Google Keyword Planner&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;For some of the terms, we simply guessed, but for many, we used records from my &lt;a href=&quot;http://www.groovehq.com/blog/customer-development&quot;&gt;customer development conversations&lt;/a&gt;, which continued to pay off. As it turned out, many of the challenges and goals our customers described to me were high-quality targeted keywords for us.&lt;/p&gt;
+
+&lt;p&gt;We also used &lt;a href=&quot;http://keywordtool.io&quot;&gt;Keyword Tool&lt;/a&gt;, which generates a list of Google’s autocomplete suggestions for any search, to find long-tail keywords that people were searching for.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/keyword-tool.png&quot; title=&quot;Keyword Tool&quot; alt=&quot;Keyword Tool&quot; /&gt;
+&lt;b&gt;Keyword Tool&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;This research also proved to be invaluable for the strategy of our new &lt;a href=&quot;http://www.groovehq.com/support&quot;&gt;customer service blog&lt;/a&gt;, which we were building at around the same time. I’ll dive much more deeply into the development of that blog in a future post.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Keyword research is a “get your hands dirty” process, but well worth it. Try to think like your customers, or better yet, actually talk to your customers to learn how they think. There are tools to make this easier.&lt;/p&gt;
+
+&lt;h3&gt;Step 3: Plan the Sitemap&lt;/h3&gt;
+
+&lt;p&gt;We ignored any keyword that had many tens of thousands of searches per month (e.g., customer service), and did our best to focus on smaller to medium sized terms (a few thousand searches per month).&lt;/p&gt;
+
+&lt;p&gt;Why?&lt;/p&gt;
+
+&lt;p&gt;Because ranking for a term like “help desk software” would not only be a &lt;em&gt;huge&lt;/em&gt; uphill climb for us, but it would hardly yield the most targeted prospects (there are many, &lt;em&gt;many&lt;/em&gt; people who search for “customer service” who will never buy customer service software).&lt;/p&gt;
+
+&lt;p&gt;On the other hand, the smaller keywords (e.g., “help desk for saas startup”), while they didn’t have nearly as many searches, would yield far, far more targeted leads.&lt;/p&gt;
+
+&lt;p&gt;Plus, by focusing on 100 smaller terms rather than one or two big ones, we would “diversify” our targeting so that the success of our site wouldn’t be dependant on the fluctuating interest in a single term.&lt;/p&gt;
+
+&lt;p&gt;We took our list of keywords and began to build the sitemap. Our goal was to create enough pages so that we could target the most important keywords, but to stop before we began creating duplicate content; something that, aside from damaging the visitor experience, is a sign of those “scammy” tactics and an instant turn-off when I see it on a marketing site.&lt;/p&gt;
+
+&lt;p&gt;We housed our map in a simple Google Spreadsheet to help us keep track of which keywords we’d need to hit for each page, along with titles and meta descriptions.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/sitemap-spreadsheet.png&quot; title=&quot;Sitemap Spreadsheet&quot; alt=&quot;Sitemap Spreadsheet&quot; /&gt;
+&lt;b&gt;Sitemap Spreadsheet&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Each page had one or two “primary” keywords, along with long-tail keywords that we used to capture hyper-targeted searches. We would try to make sure that our primary keywords were included across the headers for each page.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; There are a number of guidelines and best practices for building a sitemap, but it comes down to picking the most high-value keywords and building content that people will want to read.&lt;/p&gt;
+
+&lt;h3&gt;Step 4: Wireframe&lt;/h3&gt;
+
+&lt;p&gt;We built simple wireframes for each page. Complete enough to give us some idea of what kind of copy we’d need, but basic enough that the copy could still take the stage without worrying about where it would “fit.”&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/wireframes.png&quot; title=&quot;Wireframes&quot; alt=&quot;Wireframes&quot; /&gt;
+&lt;b&gt;Wireframes&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; We’re big believers in “copy first” design, so while we mocked up basic wireframes, we left ourselves plenty of room to let the copy be the star.&lt;/p&gt;
+
+&lt;h3&gt;Step 5: Copy&lt;/h3&gt;
+
+&lt;p&gt;Even though the goal of this effort was to improve our SEO, our keywords still came &lt;em&gt;second&lt;/em&gt; in our copy.&lt;/p&gt;
+
+&lt;p&gt;We were sensitive to our fear of our site moving away from the customer-friendly messaging we have and losing our “voice” at the expense of trying to force keywords into our copy.&lt;/p&gt;
+
+&lt;p&gt;So first, we focused on doing all of the things we learned how to do in our first redesign. We used language from our customer development interviews and tried to talk like our customers do. We hit pain points, goals, and important benefits; including many of the ones we knew were successful from tests on our existing site.&lt;/p&gt;
+
+&lt;p&gt;And while we had the keywords in mind as we developed the copy, we didn’t worry about whether or not we “checked them off” along the way.&lt;/p&gt;
+
+&lt;p&gt;Only after we were happy with the way everything read did we look at ways to incorporate:&lt;/p&gt;
+
+&lt;ul&gt;
+&lt;li&gt;Primary keywords into headers&lt;/li&gt;
+&lt;li&gt;Secondary keywords into subheads&lt;/li&gt;
+&lt;li&gt;Long-tail keywords into copy&lt;/li&gt;
+&lt;/ul&gt;
+
+
+&lt;p&gt;In addition, anywhere we linked to other pages within the site, we would try to include the primary keywords for the linked page &lt;em&gt;around&lt;/em&gt; the hyperlink.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/optimizing-links.png&quot; title=&quot;Optimizing Links&quot; alt=&quot;Optimizing Links&quot; /&gt;
+&lt;b&gt;Optimizing Links&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;In the end, we were satisfied that we were able to maintain our voice and tone while improving the copy.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; By writing interesting, quality content &lt;em&gt;first&lt;/em&gt;, we were able to incorporate our keywords afterwards and still maintain messaging that resonates with our customers.&lt;/p&gt;
+
+&lt;h3&gt;Step 6: Design&lt;/h3&gt;
+
+&lt;p&gt;After putting the pieces together, we were left with a site that looked and felt good enough to launch.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/final-result.jpg&quot; title=&quot;Final Design&quot; alt=&quot;Final Design&quot; /&gt;
+&lt;b&gt;Final Design&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Our friends at &lt;a href=&quot;http://lessfilms.com/&quot;&gt;Less Films&lt;/a&gt; also created an awesome product video for the homepage that incorporated everything we’d learned since the first time we made a Groove video. The making of the video was an in-depth and fascinating process with tons of research and background work involved, and I’ll definitely be writing about the experience here in the future.&lt;/p&gt;
+
+&lt;p style=&quot;position: relative&quot;&gt;
+&lt;iframe style=&quot;display: block; margin: 40px auto 40px -28px; border: 8px solid #FFF; background-color: #FFF; box-shadow: 0px 1px 5px rgba(0, 0, 1, 0.3), 0px 0px 18px rgba(0, 0, 0, 0.1) inset; padding: 60px 20px 20px;&quot; width=&quot;690&quot; height=&quot;390&quot; src=&quot;//fast.wistia.net/embed/iframe/ipah6liii5&quot; frameborder=&quot;0&quot; allowfullscreen&gt;&lt;/iframe&gt;&lt;b style=&quot;display: block; color: #000; position: absolute; width: 100%; text-align: center; font-family: Helvetica,'sans-serif'; font-size: 24px; font-weight: normal; top: 30px; left: 0px;&quot;&gt;New Groove Product Video&lt;/b&gt;
+&lt;/p&gt;
+
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Our total time from start to finish was just a few weeks. A simple design process let us ship a solid site quickly and iterate from there.&lt;/p&gt;
+
+&lt;h2&gt;The Results&lt;/h2&gt;
+
+&lt;p&gt;It’s early, but the results have been promising.&lt;/p&gt;
+
+&lt;p&gt;A week after launch, we were ranking on the front page for a number of our targeted terms.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/result-search.png&quot; title=&quot;Results: Search&quot; alt=&quot;Results: Search&quot; /&gt;
+&lt;b&gt;Results: Search&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;And with the lift from organic search, overall conversions were boosted, too.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/seo-for-startups/result-conversions.png&quot; title=&quot;Results: Conversions&quot; alt=&quot;Results: Conversions&quot; /&gt;
+&lt;b&gt;Results: Conversions&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Note: these results might not be typical. We’ve spent more than a year building this blog, and our site has quite a bit of SEO power because of the number of links that it gets. But with time, you can do exactly the same.&lt;/p&gt;
+
+&lt;h3&gt;How to Apply This to Your Business&lt;/h3&gt;
+
+&lt;p&gt;We’ve still got a &lt;em&gt;long&lt;/em&gt; way to go. And plenty left to do when it comes to fortifying our SEO strategy.&lt;/p&gt;
+
+&lt;p&gt;But by just taking a few simple steps, we’ve managed to get some &lt;em&gt;very&lt;/em&gt; exciting results.&lt;/p&gt;
+
+&lt;p&gt;As I said, this isn’t an advanced, or even intermediate strategy. In fact, there’s a good chance that you know more about SEO than I do.&lt;/p&gt;
+
+&lt;p&gt;This is meant to serve as a basic primer for businesses who were in the same position as us: afraid and unaware of how to actually do SEO right without becoming “those people.”&lt;/p&gt;
+
+&lt;p&gt;If you haven’t started doing any SEO because you don’t know &lt;em&gt;where&lt;/em&gt; to start, then I hope this post has inspired you to give it a try.&lt;/p&gt;
+
+&lt;p&gt;It was certainly worth it for us.&lt;/p&gt;
+</content>
+<published>2014-10-16T00:00:00+00:00</published>
+<updated>2014-10-16T00:00:00+00:00</updated>
+</entry>
+<entry>
+<title>Customer Development for Startups: What I Learned Talking to 500 Customers in 4 Weeks</title>
+<link href='/blog/customer-development' rel='alternate' type='text/html' />
+<id>tag:blog1.groovehq.com,2014-10-09:/blog/customer-development</id>
+<content type='html'>&lt;p&gt;I recently spent more than 100 hours talking to Groove customers.&lt;br&gt;Here’s what I learned…&lt;/p&gt;
+
+&lt;p&gt;In some movies, top military commanders have red phones that they only pick up when things start to go wrong.&lt;/p&gt;
+
+&lt;p&gt;They’ll usually see that an issue is getting out of hand, and they’ll grab the phone (without dialing, of course), yelling something dramatic like “get me the President!”&lt;/p&gt;
+
+&lt;p&gt;While I have no idea if this emergency phone exists, I &lt;em&gt;do&lt;/em&gt; believe that something similar exists for startup founders.&lt;/p&gt;
+
+&lt;p&gt;When your core metrics start to lag behind your goals — in our case, I wasn’t happy to see churn creeping up close to 3% as our customer base grew — there’s a lot you can do to start to right the ship.&lt;/p&gt;
+
+&lt;p&gt;You can, and &lt;em&gt;should&lt;/em&gt;, dig deep into your metrics to spot the weak points. You can, and &lt;em&gt;should&lt;/em&gt;, ask the smart people around you for advice. You can, and &lt;em&gt;should&lt;/em&gt;, test new tactics and approaches to improve.&lt;/p&gt;
+
+&lt;p&gt;But the hypothetical “red phone” that always seems to help us the most connects directly to our customers.&lt;/p&gt;
+
+&lt;p&gt;In the very early days, we spent many hours talking to every single one of our customers. We didn’t have a choice; exhaustive feedback was the only way to make our product good enough to reach Product/Market Fit.&lt;/p&gt;
+
+&lt;p&gt;And we’ve continued to believe strongly in the power of qualitative research; we’ve done a ton of it, from collecting feedback in &lt;a href=&quot;http://www.groovehq.com/blog/non-scaleable-growth-tactics&quot;&gt;onboarding emails&lt;/a&gt; to &lt;a href=&quot;http://www.groovehq.com/blog/long-form-landing-page&quot;&gt;Qualaroo widgets&lt;/a&gt; to &lt;a href=&quot;http://www.groovehq.com/blog/net-promoter-score&quot;&gt;Net Promoter Score&lt;/a&gt; surveys.&lt;/p&gt;
+
+&lt;p&gt;But it had been a while since I dove in to hardcore customer development interviews. In-depth one-on-one conversations to help us understand the experience of our users like no survey ever could.&lt;/p&gt;
+
+&lt;p&gt;And with a core metric slipping too far for comfort, it was time to pick up the red phone again.&lt;/p&gt;
+
+&lt;h2&gt;How I Had 500 Customer Conversations in Four Weeks&lt;/h2&gt;
+
+&lt;p&gt;On September 10&lt;sup&gt;th&lt;/sup&gt;, I sent this email to every Groove customer:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/non-scaleable-growth-tactics/a-request.png&quot; title=&quot;The Ask&quot; alt=&quot;The Ask&quot; /&gt;
+&lt;b&gt;The Ask&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;The response blew me away. I expected a couple hundred people to write back over the following week, but my inbox quickly began to fill.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/customer-development/uh-oh.png&quot; title=&quot;Uh oh.&quot; alt=&quot;Uh oh.&quot; /&gt;
+&lt;b&gt;Uh oh.&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;There was no way I’d be able to schedule all of these without drowning under a heap of back-and-forth emails. Scrambling, I signed up for a &lt;a href=&quot;http://www.doodle.com&quot;&gt;Doodle&lt;/a&gt; account, which let me send a link to people who were willing to chat, giving them the chance to schedule their call at a time that worked for them.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/customer-development/doodle.png&quot; title=&quot;Doodle&quot; alt=&quot;Doodle&quot; /&gt;
+&lt;b&gt;Doodle&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Slots quickly began to fill up (I had to go back and add more spots four times). While I only asked for ten minutes, I booked the calls in 30-minute blocks just in case they went long, and to give myself some breathing room to compile notes and digest each call afterwards.&lt;/p&gt;
+
+&lt;p&gt;I used &lt;a href=&quot;http://www.skype.com&quot;&gt;Skype&lt;/a&gt; — or my cell phone — for the calls, &lt;a href=&quot;http://www.join.me&quot;&gt;Join.me&lt;/a&gt; for screen shares to walk through Groove with the customers when I needed to, and old-school paper and pen for taking notes.&lt;/p&gt;
+
+&lt;p&gt;I compiled data in a simple Google Spreadsheet, which you can &lt;a href=&quot;https://docs.google.com/spreadsheets/d/1DB5Jippw-09583Qcu7ka7Zl5vhSGz6yE35e5pkWLPQw/edit?usp=sharing&quot;&gt;find and copy here&lt;/a&gt;.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/customer-development/tools.png&quot; title=&quot;The Tools&quot; alt=&quot;The Tools&quot; /&gt;
+&lt;b&gt;The Tools&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;In all, I ended up spending more than 100 hours over four weeks on customer development calls, which are still ongoing. When I shared this with a founder friend of mine, he asked a fair, and obvious, question: &lt;em&gt;why didn’t I have someone else do it, or split the calls with other team members?&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;Here’s the thing: I trust my team members tremendously. &lt;a href=&quot;http://www.groovehq.com/blog/building-a-team&quot;&gt;I don’t hire fast&lt;/a&gt; — I only hire people after I know I can rely on them to be a valuable asset to our company and a great fit for our team. It’s certainly not that I don’t trust anyone on my team enough to do customer development.&lt;/p&gt;
+
+&lt;p&gt;It’s just that I consider customer development to be &lt;em&gt;such&lt;/em&gt; a core part of building a company, that it’s simply the CEO’s job at this stage. It’s just as important as making strategy decisions or meeting with investors.&lt;/p&gt;
+
+&lt;p&gt;Plus, talking to customers isn’t the same as reading the answers someone else recorded on a spreadsheet. I wanted to &lt;em&gt;feel&lt;/em&gt; and &lt;em&gt;internalize&lt;/em&gt; our customers’ perspectives so that they could drive the other decisions I need to make.&lt;/p&gt;
+
+&lt;p&gt;And that’s why I tackled it on my own.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; You don’t need many tools to talk to your customers. And while it’s a time-consuming task, it’s one of the highest-ROI efforts you can tackle as a startup CEO.&lt;/p&gt;
+
+&lt;h2&gt;What Questions Did I Ask?&lt;/h2&gt;
+
+&lt;p&gt;I considered using a scripted series of survey questions, but ultimately decided against it.&lt;/p&gt;
+
+&lt;p&gt;I wanted raw, off-the-cuff insights into how our customers think and feel about Groove… &lt;em&gt;not&lt;/em&gt; how they think about specific questions regarding the features and elements that &lt;em&gt;we&lt;/em&gt; think are important. I didn’t want to influence any of the feedback I got with leading questions.&lt;/p&gt;
+
+&lt;p&gt;Instead, at the beginning of each call, I simply said:&lt;/p&gt;
+
+&lt;blockquote&gt;&lt;p&gt;Hey, thanks so much for agreeing to chat. I won’t take too much of your time. The conversations I’ve been having with customers have been invaluable in helping us shape the product and our plans for the future, so I’m excited to get your feedback.
+&lt;br&gt;&lt;br&gt;
+My goal is to get an overall feel of how you’re using the app, what you like, what you don’t like, and what we can do to make it better. I’ll let you take the floor.&lt;/p&gt;&lt;/blockquote&gt;
+
+&lt;p&gt;Usually, the very first thing that people told me turned out to be the most important part of their user experience, from their perspective. And often, those important elements didn’t line up &lt;em&gt;at all&lt;/em&gt; with what I had assumed people would say.&lt;/p&gt;
+
+&lt;p&gt;There were more than a few surprises, including bugs we didn’t know existed, minor (to us) features that turned out to be hugely valuable for some users, and use cases for Groove that we had never considered.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; There isn’t necessarily one “right” way to structure the conversations, but there is a clear wrong way: influencing your customers’ feedback with leading questions won’t get you the results you’re looking for.&lt;/p&gt;
+
+&lt;h2&gt;7 Big Wins From Talking to 500 of Our Customers&lt;/h2&gt;
+
+&lt;p&gt;The ultimate “win” from customer development is &lt;em&gt;deep&lt;/em&gt; insights into how our customers think, feel and use our app. That insight is absolutely critical to the growth of any business, and it’s the biggest reason I took this project on. It had an immediate impact on how we approach our product roadmap and day-to-day decisions.&lt;/p&gt;
+
+&lt;p&gt;Even if there were no other benefits, that benefit one alone would make it worthwhile.&lt;/p&gt;
+
+&lt;p&gt;With that said, there were quite a few more big wins that ended up coming about from the effort…&lt;/p&gt;
+
+&lt;h3&gt;1) We Learned That We Need Better Second-Tier Onboarding.&lt;/h3&gt;
+
+&lt;p&gt;In more than a few of the calls, customers would mention particular challenges they faced that could be solved with new features or functionality. Thing is, sometimes they were features &lt;em&gt;we already had&lt;/em&gt;; for example, third-party app integration (when looking at support tickets, users can choose to bring in data about their customers from other apps like Stripe and CRM tools). When I showed them the feature, I’d hear a painful — but valuable — reaction:&lt;/p&gt;
+
+&lt;blockquote&gt;&lt;p&gt;Wow! I didn’t know that existed.&lt;/p&gt;&lt;/blockquote&gt;
+
+&lt;p&gt;To me, that’s a clear sign that we need to improve our onboarding as users get more deeply engaged with Groove so that they can better discover some of the more advanced features. We’ve already updated our onboarding email sequence to address this, and are working on building the guidance into the app.&lt;/p&gt;
+
+&lt;h3&gt;2) We Turned Unhappy Customers Into Happy Customers.&lt;/h3&gt;
+
+&lt;p&gt;I was able to repair a handful of relationships with customers who were unhappy with the product. In once case, a customer wrote me an email criticising Groove.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/customer-development/not-happy.png&quot; title=&quot;Not Happy&quot; alt=&quot;Not Happy&quot; /&gt;
+&lt;b&gt;Not Happy&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;I responded:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/customer-development/the-ask.png&quot; title=&quot;The Ask&quot; alt=&quot;The Ask&quot; /&gt;
+&lt;b&gt;The Ask&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;I was a bit surprised when he agreed to get on the phone with me, but once he did, I explained that I wanted to understand why he felt the way he did, and what we could do to make it better.&lt;/p&gt;
+
+&lt;p&gt;As it turned out, he was upset about the lack of a couple of features that we had planned to build in the immediate weeks ahead. When I shared that with him, he quickly warmed up, and he’s now a much happier customer.&lt;/p&gt;
+
+&lt;p&gt;Note: it’s important to be honest here. No product is &lt;em&gt;perfect&lt;/em&gt;, and there are parts of Groove that we wish were better. Those are the parts we’re working on. But never try to convince a customer that a shitty part of your app doesn’t actually suck. You’ll lose their trust in a heartbeat.&lt;/p&gt;
+
+&lt;h3&gt;3) We Better Understood the Personas of Our Customer Base (With Some Surprises).&lt;/h3&gt;
+
+&lt;p&gt;We’ve always had (tested) assumptions about the personas of our customers. And many of them held true in these conversations. But as we’ve grown, things sure have changed.&lt;/p&gt;
+
+&lt;p&gt;I learned about several new use cases for Groove that I hadn’t considered before. For example, several of our customers are schools that use Groove to offer IT support to students and faculty.&lt;/p&gt;
+
+&lt;p&gt;For some of the newly discovered personas, there were enough examples that we’ve decided to build case studies to try and attract more users that fit those personas, or at least test the market to see if there’s a strong fit.&lt;/p&gt;
+
+&lt;h3&gt;4) We Built Better Relationships With Hundreds of Customers.&lt;/h3&gt;
+
+&lt;p&gt;This benefit can’t be understated enough: the number of positive reactions, even from customers who complained about bugs or issues, was huge. Surprisingly, I heard from many of our customers that no other businesses that they used were doing this, and that the gesture of asking them for their thoughts — not just with a mass-emailed survey, but by reaching out for a one-on-one conversation — meant a lot to them.&lt;/p&gt;
+
+&lt;p&gt;It’s amazing how easy it is to stand out with a bit of effort.&lt;/p&gt;
+
+&lt;h3&gt;5) We Got the Chance for Some Quick Customer WOW’s.&lt;/h3&gt;
+
+&lt;p&gt;Sometimes, things that bugged customers were easy fixes or updates that they had never reached out to tell us about. For example, one customer told me that about an issue they were having CC’ing people from a certain domain. This was a weird bug, but something we could fix in just a few minutes, and we ended up pushing a fix for her issue that night.&lt;/p&gt;
+
+&lt;p&gt;Her response?&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/customer-development/customer-wow.png&quot; title=&quot;Customer WOW&quot; alt=&quot;Customer WOW&quot; /&gt;
+&lt;b&gt;Customer WOW&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;An easy win that helped us delight a valuable customer.&lt;/p&gt;
+
+&lt;h3&gt;6) We Learned How to Improve Our Marketing Copy.&lt;/h3&gt;
+
+&lt;p&gt;We’re always working to improve the way we position and write about Groove (see our &lt;a href=&quot;http://www.groovehq.com/blog/long-form-landing-page&quot;&gt;landing page design&lt;/a&gt; post for more). Hearing our customers talk about the app and its benefits, along with their personal stories, challenges and goals, is the only way we can write marketing copy that actually connects.&lt;/p&gt;
+
+&lt;p&gt;&lt;em&gt;Talking to our customers is the only way to talk like our customers talk.&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;While I heard a lot of phrases that I was &lt;em&gt;very&lt;/em&gt; familiar with already (“Zendesk was just too complicated,” for example), I also spotted some new trends that you’ll see on our marketing site very soon.&lt;/p&gt;
+
+&lt;h3&gt;7) We Got Great Feedback Even When We Didn’t Get to Chat.&lt;/h3&gt;
+
+&lt;p&gt;Some customers couldn’t — or wouldn’t — get on the phone with me. And I completely understand; there’s nothing more valuable than time, and it’s a huge ask to disrupt someone’s day, even if for a few minutes, to talk about a product they use.&lt;/p&gt;
+
+&lt;p&gt;But while there were those I couldn’t schedule talks with, many customers chose to email me their thoughts instead.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/customer-development/email-feedback.png&quot; title=&quot;Email Feedback&quot; alt=&quot;Email Feedback&quot; /&gt;
+&lt;b&gt;Email Feedback&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;These were, in many cases, just as valuable as the conversations I had.&lt;/p&gt;
+
+&lt;h2&gt;How to Act on Customer Development Feedback&lt;/h2&gt;
+
+&lt;p&gt;The feedback you get from customer development, just like any data, is useless if you don’t act on it. In fact, it’s &lt;em&gt;worse&lt;/em&gt; than useless, since you wasted no small amount of hours collecting it.&lt;/p&gt;
+
+&lt;p&gt;So to ensure that we got value out of this exercise, here are the steps we’ve taken — and are still taking — to make use of the feedback we’ve gathered:&lt;/p&gt;
+
+&lt;h3&gt;Step 1: Organize Feedback to Help You Spot Trends&lt;/h3&gt;
+
+&lt;p&gt;After each conversation, I added labels (e.g., &lt;em&gt;Search, Mailbox, Support, Automation, Pricing&lt;/em&gt;) to capture the most important things covered in each conversation.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/customer-development/organization.png&quot; title=&quot;Organization&quot; alt=&quot;Organization&quot; /&gt;
+&lt;b&gt;Organization&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;This has helped us go through the data and see which topics trended throughout the conversations, so we know what customers are most vocal about.&lt;/p&gt;
+
+&lt;h3&gt;Step 2: Process the Data&lt;/h3&gt;
+
+&lt;p&gt;Once things were organized, it was easier to go through and decide how to act on various trends. Core fixes and feature requests that bubbled to the top were added to the roadmap. More ancillary features or less popular ones that had potential were added to our wishlist for future releases; we’ll continue to collect data on these requests.&lt;/p&gt;
+
+&lt;h3&gt;Step 3: Line Up Customer Case Studies&lt;/h3&gt;
+
+&lt;p&gt;In my conversations, I unearthed quite a few customers who were having a lot of success with Groove, as well as (like I mentioned) new personas that we hadn’t been targeting before. Those are both great candidates for new case studies to feature as example of Groove’s value, and we’ve already reached out to several of these customers to make it happen.&lt;/p&gt;
+
+&lt;h3&gt;Step 4: Send Thank You Emails&lt;/h3&gt;
+
+&lt;p&gt;If a customer takes time out of their day to give you feedback on their app, it’s a gift. They have a thousand other better uses (from their perspective) of their time. So thanking them is important.&lt;/p&gt;
+
+&lt;p&gt;I’ve always appreciated a thank you more when it was personal and made me feel like my contribution was valuable, so I try to do that with my own thank-you’s.&lt;/p&gt;
+
+&lt;p&gt;Each thank you notes included a brief recap of our conversation, along with any action I’m taking because of it, if any.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/customer-development/thank-you.png&quot; title=&quot;Thank you.&quot; alt=&quot;Thank you.&quot; /&gt;
+&lt;b&gt;Thank you.&lt;/b&gt;&lt;/p&gt;
+
+&lt;h3&gt;Step 5: Write About the Experience&lt;/h3&gt;
+
+&lt;p&gt;This one is pretty meta, I’ll admit.&lt;/p&gt;
+
+&lt;p&gt;But as hopeful as I am that sharing my experience will be for you, it’s also incredibly valuable for me, giving me a chance to reflect on the results — and importance — of customer development. As I’ve researched this post, I’ve caught a number of things that I missed the first time I looked at my notes.&lt;/p&gt;
+
+&lt;h3&gt;Step 6: Make It a Habit&lt;/h3&gt;
+
+&lt;p&gt;We’ve now added a call to action for a customer development chat into our onboarding emails for every new customer.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/customer-development/talking-to-every-customer.png&quot; title=&quot;Talking to every customer.&quot; alt=&quot;Talking to every customer.&quot; /&gt;
+&lt;b&gt;Talking to every customer.&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Thankfully, it’ll be a lot easier to schedule calls one at a time than 2,000 at a time.&lt;/p&gt;
+
+&lt;h2&gt;How to Apply This to Your Business&lt;/h2&gt;
+
+&lt;p&gt;Getting qualitative feedback isn’t a tactic. It’s a way of doing business that startups need to live and breathe.&lt;/p&gt;
+
+&lt;p&gt;There are dozens of ways to get qualitative feedback from your customers:&lt;/p&gt;
+
+&lt;ul&gt;
+&lt;li&gt;Surveys&lt;/li&gt;
+&lt;li&gt;Net Promoter Surveys&lt;/li&gt;
+&lt;li&gt;Emails&lt;/li&gt;
+&lt;li&gt;Live Chat&lt;/li&gt;
+&lt;/ul&gt;
+
+
+&lt;p&gt;And we use all of those strategies. But none has been quite as mindblowingly valuable as actually taking the time to talk to our customers. It has changed our product, our business and the way we think. It’s certainly been responsible for any growth we’ve had.&lt;/p&gt;
+
+&lt;p&gt;You don’t have to go on a mission to talk to every single customer. But reach out to a handful today. You might learn something that will change your business.&lt;/p&gt;
+</content>
+<published>2014-10-09T00:00:00+00:00</published>
+<updated>2014-10-09T00:00:00+00:00</updated>
+</entry>
+<entry>
+<title>We Deleted Our Facebook Page. Here’s Why.</title>
+<link href='/blog/focus' rel='alternate' type='text/html' />
+<id>tag:blog1.groovehq.com,2014-10-02:/blog/focus</id>
+<content type='html'>&lt;p&gt;There are lots of tactics you’re “supposed” to use. Here’s why that’s dangerous…&lt;/p&gt;
+
+&lt;p&gt;&lt;em&gt;“Screw it. Let’s just delete the thing.”&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;Something felt &lt;em&gt;odd&lt;/em&gt; about saying that.&lt;/p&gt;
+
+&lt;p&gt;Like we were about to break the rules.&lt;/p&gt;
+
+&lt;p&gt;But the more we discussed it, the more obvious the choice became: our company Facebook account had to go.&lt;/p&gt;
+
+&lt;p&gt;There were two major factors that drove the call:&lt;/p&gt;
+
+&lt;h3&gt;1) Frankly, It Was &lt;em&gt;Embarrassing&lt;/em&gt;.&lt;/h3&gt;
+
+&lt;p&gt;We have more than 2,000 customers, 20,000 blog subscribers and many thousands of unique visitors each week. And yet Groove had just under 200 “Likes” on Facebook.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/focus/under-200-likes.jpg&quot; title=&quot;197 Likes&quot; alt=&quot;197 Likes&quot; /&gt;
+&lt;b&gt;197 Likes&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Not really something I want people searching for us on Facebook to see.&lt;/p&gt;
+
+&lt;h3&gt;2) It Was a Waste of Time for Us.&lt;/h3&gt;
+
+&lt;p&gt;Now, I’m NOT saying that Facebook is a waste of time for businesses. Many companies use Facebook very successfully to grow.&lt;/p&gt;
+
+&lt;p&gt;But we were spending an hour or so each week updating the page. Obviously, we weren’t getting any results.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/focus/no-results.png&quot; title=&quot;No Results&quot; alt=&quot;No Results&quot; /&gt;
+&lt;b&gt;No Results&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;And when we spent time discussing it and thinking about &lt;em&gt;why we were doing it in the first place&lt;/em&gt;, the answer was simple, straightforward, and just as embarrassing as our Like count.&lt;/p&gt;
+
+&lt;p&gt;We were on Facebook because everybody else was. It was what we were “supposed” to be doing.&lt;/p&gt;
+
+&lt;p&gt;And that’s just not good enough.&lt;/p&gt;
+
+&lt;h2&gt;Using Time Wisely&lt;/h2&gt;
+
+&lt;p&gt;Like most other startups and small businesses, we have limited resources.&lt;/p&gt;
+
+&lt;p&gt;So when we got together to build our &lt;a href=&quot;http://www.groovehq.com/blog/12-month-growth-strategy&quot;&gt;12-month growth strategy&lt;/a&gt;, the question wasn’t &lt;em&gt;“what are the things we could be doing?”&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;The question was &lt;em&gt;“what efforts would be the highest and best use of every team member’s time?”&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;That is, what can we do that will drive the biggest growth for Groove?&lt;/p&gt;
+
+&lt;p&gt;For example, we &lt;em&gt;know&lt;/em&gt; that blogging helps us grow, because we track the numbers carefully.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/doubling-down-on-content/blog-signups-now.png&quot; title=&quot;The ROI of Blogging&quot; alt=&quot;The ROI of Blogging&quot; /&gt;
+&lt;b&gt;The ROI of Blogging&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;On the other hand, we can’t tie our Facebook efforts to any revenue at all.&lt;/p&gt;
+
+&lt;p&gt;Every hour that we spend managing the Facebook page is an hour that we could spend building the blog. An hour each week may seem insignificant, but that’s 52 hours in a year.&lt;/p&gt;
+
+&lt;p&gt;The amount of traffic and signups we could get by spending 52 more hours on the blog is significant.&lt;/p&gt;
+
+&lt;p&gt;And yet, we were robbing the blog of 52 hours of added time because of our blind, knee-jerk tendency to do what we were “supposed” to.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; It can be surprising to learn how much time you’re wasting without even knowing it. It certainly was for us. Do the math and figure out the opportunity cost of doing things that don’t work.&lt;/p&gt;
+
+&lt;h2&gt;Three Things We Don’t Do That We’re “Supposed” To&lt;/h2&gt;
+
+&lt;p&gt;There are dozens — probably hundreds — of tactics out there that one expert or another will claim as being a “must-do” for every business.&lt;/p&gt;
+
+&lt;p&gt;And so, so many businesses do those things. That’s why it’s so hard, on a mental level, to wrap our heads around the fact that often, most of those tactics probably aren’t that useful to us.&lt;/p&gt;
+
+&lt;p&gt;It’s something I’ve struggled with a lot.&lt;/p&gt;
+
+&lt;p&gt;As metrics-driven as I like to think we are, it’s tough to pull away from doing the things we think we’re supposed to be doing. I’d be lying if I said I didn’t feel a little bit guilty deleting the Facebook page.&lt;/p&gt;
+
+&lt;p&gt;But in the end, it’s a win for the thing that matters most: the performance of the business.&lt;/p&gt;
+
+&lt;p&gt;Facebook isn’t the only “must-do” tactic that we’ve dropped over the past few months:&lt;/p&gt;
+
+&lt;h3&gt;1) Networking Events&lt;/h3&gt;
+
+&lt;p&gt;Early on, a lot of people told me that I needed to get out there and build relationships, and that the best way to do that was by going to networking events.&lt;/p&gt;
+
+&lt;p&gt;I found that while the first part was absolutely 100% true, the latter was not. I met some interesting folks at events, but of the most high-value relationships I have, zero of them started at networking events.&lt;/p&gt;
+
+&lt;h3&gt;2) Conferences&lt;/h3&gt;
+
+&lt;p&gt;Having a booth with your logo on it at a conference like DreamForce or South By Southwest is almost considered a rite of passage for growing tech startups.&lt;/p&gt;
+
+&lt;p&gt;While it’s nice to see your name up there, we’ve experimented with trade shows, and they’ve never driven the sorts of high-quality leads that we get from our other efforts. Plus, they cost a lot more time and money.&lt;/p&gt;
+
+&lt;h3&gt;3) PR&lt;/h3&gt;
+
+&lt;p&gt;When we launched, we had put quite a bit of time and effort into building relationships with journalists, and it &lt;em&gt;did&lt;/em&gt; &lt;a href=&quot;http://thenextweb.com/apps/2011/07/12/groove-the-new-app-is-a-breath-of-fresh-air-for-customer-support/&quot;&gt;pay off&lt;/a&gt;.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/focus/press.png&quot; title=&quot;Press&quot; alt=&quot;Press&quot; /&gt;
+&lt;b&gt;Press&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;As it does for many businesses, getting mentioned in a high-profile publication drove traffic and got us a big handful of signups.&lt;/p&gt;
+
+&lt;p&gt;But as we grew, the return on the PR traffic splashes began to lessen. The signups were often of lower quality, churning faster than users who signed up via the blog or other channels. Eventually, we pulled the plug.&lt;/p&gt;
+
+&lt;p&gt;I think it’s important to note something here, because I can picture the angry comments we’re going to get from social media consultants and event organizers. The above isn’t a list of “growth strategies that don’t work.”&lt;/p&gt;
+
+&lt;p&gt;In fact, almost the opposite is true: they’ve worked so well for some people that they’ve somehow been added to a sacred list of things that every startup “must”&lt;em&gt; &lt;/em&gt;be doing.&lt;/p&gt;
+
+&lt;p&gt;We’ve consciously decided &lt;em&gt;not&lt;/em&gt; to do those things, and it’s helped us. What works for others may be different.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Don’t let “must-do” lists dictate the way you use your time. Instead, run tests, figure out what actually works for you, and focus as many of your resources as you can on those winners.&lt;/p&gt;
+
+&lt;h2&gt;Our Three “Focus” Tactics Today&lt;/h2&gt;
+
+&lt;p&gt;There are tactics we’re focusing as many of our resources as possible right now.&lt;/p&gt;
+
+&lt;p&gt;In fact, every so often, someone will comment on how much time we spend on the blog.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/focus/making-time.png&quot; title=&quot;Making Time&quot; alt=&quot;Making Time&quot; /&gt;
+&lt;b&gt;Making Time&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;But just as I believe in cutting mercilessly when it comes to non-performing tactics, I believe in making massive amounts of time available to do the things that work. Fortunately, with enough work on the former goal, the latter becomes easier.&lt;/p&gt;
+
+&lt;p&gt;While there are other things we’re working on, these are the three big “focus” tactics that we’re giving a disproportionate amount of our resources to today:&lt;/p&gt;
+
+&lt;h3&gt;1) Blogging&lt;/h3&gt;
+
+&lt;p&gt;It may seem crazy to spend more than 20% of my time on it, but the &lt;a href=&quot;http://www.groovehq.com/blog/roi-of-blog&quot;&gt;ROI of this blog&lt;/a&gt; speaks for itself. And that’s the reason we’re &lt;a href=&quot;http://www.groovehq.com/blog/doubling-down-on-content&quot;&gt;doubling down on content&lt;/a&gt;, too.&lt;/p&gt;
+
+&lt;h3&gt;2) Customer Development&lt;/h3&gt;
+
+&lt;p&gt;We’ve gotten such high returns from talking to our customers one-on-one that I’m &lt;a href=&quot;http://www.groovehq.com/blog/non-scaleable-growth-tactics&quot;&gt;dedicating hundreds of hours&lt;/a&gt; over the next few months to having customer conversations. Again, it may sound like a ridiculous amount of time, but if anything is important enough, we’ll all make time for it.&lt;/p&gt;
+
+&lt;h3&gt;3) Metrics&lt;/h3&gt;
+
+&lt;p&gt;Next week, I’ll publish a post that dives deep into how we used core metrics to change the way we run our business, and transformed our growth as a result. That never would have happened if I hadn’t pulled one of our engineers off of product development for more than a week to set up a thorough tracking system.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Don’t be scared of spending “too much” time on something, as long as there’s a payoff. There’s no good guideline for how much time to spend on tactics X, Y and Z, because there’s no business that operates exactly like yours.&lt;/p&gt;
+
+&lt;h2&gt;How to Apply This to Your Business&lt;/h2&gt;
+
+&lt;p&gt;I hesitated to publish the actual lists of tactics that we do and don’t use, because I think that they’re secondary to — and possibly distracting from — the main takeaway of this post.&lt;/p&gt;
+
+&lt;p&gt;In the end, I kept them because I think they serve as helpful examples, but I hope that what you’ll take away is this: your time is far too precious (and failure nearly always too near) to spend even an hour of it spinning your wheels.&lt;/p&gt;
+
+&lt;p&gt;Doing things that don’t work isn’t a bad thing on it’s own. In fact, it’s the only way we grow and find what actually &lt;em&gt;does&lt;/em&gt; work.&lt;/p&gt;
+
+&lt;p&gt;But doing things that don’t work over and over again, simply because you think you’re “supposed” to be doing them, is actively and aggressively damaging to your business.&lt;/p&gt;
+
+&lt;p&gt;Not too long ago, I needed a reminder of that. I hope that my reminder is helpful to you, too.&lt;/p&gt;
+</content>
+<published>2014-10-02T00:00:00+00:00</published>
+<updated>2014-10-02T00:00:00+00:00</updated>
+</entry>
+<entry>
+<title>How We Got 2,000+ Customers by Doing Things That Didn’t Scale</title>
+<link href='/blog/non-scaleable-growth-tactics' rel='alternate' type='text/html' />
+<id>tag:blog1.groovehq.com,2014-09-25:/blog/non-scaleable-growth-tactics</id>
+<content type='html'>&lt;p&gt;Some of our growth tactics will never scale. Here’s why we’re okay with that…&lt;/p&gt;
+
+&lt;p&gt;“I could tell you what we’re doing, but it wouldn’t help you.”&lt;/p&gt;
+
+&lt;p&gt;When I was getting ready to launch Groove, I spent a lot of time talking to other founders. And I would almost &lt;em&gt;always&lt;/em&gt; start with the wrong question:&lt;/p&gt;
+
+&lt;p&gt;&lt;em&gt;What are you guys doing for user acquisition?&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;Sometimes, they’d play along and clue me in to what they were up to.&lt;/p&gt;
+
+&lt;p&gt;Invariably, they were the types of things that help later-stage companies become very successful: referrals, upselling, advertising. The spectrum was &lt;em&gt;huge&lt;/em&gt;, and I was a little overwhelmed, though planning on trying everything I could.&lt;/p&gt;
+
+&lt;p&gt;Until finally, one founder graciously called me out.&lt;/p&gt;
+
+&lt;p&gt;“Look, I could tell you what we’re doing, but it wouldn’t help you. We have 10,000 customers. You have zero. You need to focus on your first &lt;em&gt;five&lt;/em&gt; customers.”&lt;/p&gt;
+
+&lt;p&gt;He went on to share some of the things that he did when they were working to get their first handful of users.&lt;/p&gt;
+
+&lt;p&gt;I hadn’t heard &lt;em&gt;anything&lt;/em&gt; like it in my other conversations.&lt;/p&gt;
+
+&lt;p&gt;They scrapped, clawed and fought hard for every single customer in their early days. The founder would spend many hours with every single customer, learning, coaching and making sure that they had a positive experience.&lt;/p&gt;
+
+&lt;p&gt;None of it was scaleable, but it didn’t matter. Without it, he told me, they’d never get the &lt;em&gt;chance&lt;/em&gt; to scale.&lt;/p&gt;
+
+&lt;p&gt;That chat changed the way I thought about growth.&lt;/p&gt;
+
+&lt;p&gt;By now, nearly everyone in the startup space has read Paul Graham’s brilliant essay, &lt;em&gt;&lt;a href=&quot;http://paulgraham.com/ds.html&quot;&gt;Do Things That Don’t Scale&lt;/a&gt;&lt;/em&gt;. And if you haven’t, you absolutely should. He shares some great examples of things that now-successful startups did to get customers in their early days; tactics that would &lt;em&gt;never&lt;/em&gt; work for a larger, high-volume business.&lt;/p&gt;
+
+&lt;p&gt;We’ve also done a number of things at Groove that are far from scaleable. We now have 2,000+ companies signed up, but our growth approach has been to get one customer at a time.&lt;/p&gt;
+
+&lt;p&gt;Below are six of the most valuable non-scaleable growth tactics we’ve used to get customers for Groove:&lt;/p&gt;
+
+&lt;h3&gt;1) “You’re In” Email&lt;/h3&gt;
+
+&lt;p&gt;I’ve mentioned this before, but one of our biggest onboarding wins has come from our “You’re In” email.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/testimonials/you-are-in.png&quot; title=&quot;“You’re In” Email&quot; alt=&quot;“You’re In” Email&quot; /&gt;
+&lt;b&gt;“You’re In” Email&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;The insights we’ve gotten early on from the responses to that email have been game-changing.&lt;/p&gt;
+
+&lt;p&gt;We’ve been able to transform our messaging based on what we learned is most important to new customers, and we’ve been able to build deeper relationships with those customers by helping them with whatever unique goals or challenges drove them to sign up.&lt;/p&gt;
+
+&lt;p&gt;I still read — and act on — every single response I get.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Learning why new customers decided to sign up is incredibly valuable. It informs your marketing and makes your customers’ experiences better. This is a lot easier with a handful of customers than with many.&lt;/p&gt;
+
+&lt;h3&gt;2) Customer Development&lt;/h3&gt;
+
+&lt;p&gt;Earlier this month, I sent an email to our customers:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/non-scaleable-growth-tactics/a-request.png&quot; title=&quot;A Request&quot; alt=&quot;A Request&quot; /&gt;
+&lt;b&gt;A Request&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Over the years, there’s nothing that’s been more valuable for us as a growth tool than one on one conversations with our customers.&lt;/p&gt;
+
+&lt;p&gt;And over the next few months, I’m blocking off hundreds of hours of time to talk to every single one of them.&lt;/p&gt;
+
+&lt;p&gt;I had nearly 30 of these calls last week, and this isn’t the first time we’ve done this. I’ve already gotten some feedback that we’re using to improve the product.&lt;/p&gt;
+
+&lt;p&gt;At 2,000 customers, me talking to all of them is probably crazy. At 5,000, it’s practically impossible.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Early on, there’s nothing you can do that’ll inform your strategy better than talking to your customers. There’s no other way to deeply understand their challenges, and get a true sense for their experience with your product.&lt;/p&gt;
+
+&lt;h3&gt;3) Content Promotion&lt;/h3&gt;
+
+&lt;p&gt;When we first launched this blog, we built our audience &lt;em&gt;&lt;a href=&quot;http://www.groovehq.com/blog/1000-subscribers&quot;&gt;one influencer at a time&lt;/a&gt;&lt;/em&gt;.&lt;/p&gt;
+
+&lt;p&gt;I spent many, many hours emailing people and building relationships to help us get our content into people’s hands.&lt;/p&gt;
+
+&lt;p&gt;There’s no doubt in my mind that &lt;a href=&quot;http://www.groovehq.com/blog/doubling-down-on-content&quot;&gt;it was worth it&lt;/a&gt;.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/non-scaleable-growth-tactics/worth-it.png&quot; title=&quot;Worth It&quot; alt=&quot;Worth It&quot; /&gt;
+&lt;b&gt;Worth It&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;And now, with our new &lt;a href=&quot;http://www.groovehq.com/support&quot;&gt;customer support blog&lt;/a&gt;, I’m at it again, emailing just about everyone I know.&lt;/p&gt;
+
+&lt;p&gt;Len, who’s writing the support blog, is doing the same.&lt;/p&gt;
+
+&lt;p&gt;The early results look good, but they’re also stalling just about everything else that Len and I need to be doing on a day-to-day basis at Groove.&lt;/p&gt;
+
+&lt;p&gt;Still, we’re not going to slow down.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Content promotion is one of the most time-consuming and non-scaleable efforts we do, but the results speak for themselves.&lt;/p&gt;
+
+&lt;h3&gt;4) Community Engagement&lt;/h3&gt;
+
+&lt;p&gt;More than once, people have told me that they were surprised that I respond to every comment on this blog.&lt;/p&gt;
+
+&lt;p&gt;Sometimes it takes me a little while, but I think it’s important. When people take the time to read what we publish, and post a thoughtful comment about it, I can’t imagine not acknowledging that.&lt;/p&gt;
+
+&lt;p&gt;And more than that, it’s helped me build great relationships with some of the readers of this blog. Some of those commenters have turned into customers precisely &lt;em&gt;because&lt;/em&gt; I engage with them.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/non-scaleable-growth-tactics/value-of-engagement.png&quot; title=&quot;The Value of Engagement&quot; alt=&quot;The Value of Engagement&quot; /&gt;
+&lt;b&gt;The Value of Engagement&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;We get anywhere from 40 to 200 comments on any given post, so it can certainly be a time-consuming task.&lt;/p&gt;
+
+&lt;p&gt;If and when the blog grows and that number doubles or triples, I’m honestly not sure how I’ll possibly be able to keep up.&lt;/p&gt;
+
+&lt;p&gt;But for now, I’m not worrying about that.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; I’ve gotten massive value from engaging with the readers of this blog, and I suggest that every founder who blogs does the same.&lt;/p&gt;
+
+&lt;h3&gt;5) Onboarding/Nurturing&lt;/h3&gt;
+
+&lt;p&gt;A couple of weeks ago, James Altucher published — as always — &lt;a href=&quot;https://www.facebook.com/james.altucher/posts/10152285492485636&quot;&gt;a deep and introspective post&lt;/a&gt; about a entrepreneurs’ event that he went to.&lt;/p&gt;
+
+&lt;p&gt;In it, he mentions a point that &lt;a href=&quot;https://twitter.com/JoeyColeman&quot;&gt;Joey Coleman&lt;/a&gt; made in his talk:&lt;/p&gt;
+
+&lt;blockquote&gt;&lt;p&gt;Joey’s point was very simple: he had THE 100-day RULE.
+&lt;br&gt;&lt;br&gt;
+If you hand-hold the client for 100 days, that’s all you need to do. Then they are your client for life. FOR LIFE.&lt;/p&gt;&lt;/blockquote&gt;
+
+&lt;p&gt;As I read that, I couldn’t help but nod my head in agreement. We’ve found a similar trend to hold true at Groove; when we hold our customers’ hands for the first two months, they’re far, &lt;em&gt;far&lt;/em&gt; more likely to stay with us after that time.&lt;/p&gt;
+
+&lt;p&gt;So during the first two months of a customer’s time with us, it’s &lt;em&gt;everyone’s&lt;/em&gt; job to make that customer happy.&lt;/p&gt;
+
+&lt;p&gt;Now, that’s not to say that customers are forgotten about after that. Generally, after that time, we see support requests drop off naturally, so there’s less of a need for the all-hands-on-deck approach. But in the early days, it’s critical.&lt;/p&gt;
+
+&lt;p&gt;On top of our regular support, our developers will jump in and help with any technical questions, and I’ll almost always be involved in support during that time window.&lt;/p&gt;
+
+&lt;p&gt;Obviously, I wouldn’t be able to do that so easily if we quadrupled our customer base.&lt;/p&gt;
+
+&lt;p&gt;But for now, I’m thrilled to be able to.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Getting your customers to “wow” might be a time- and team-consuming effort, but until your product is established enough to speak for itself, there’s no way around it.&lt;/p&gt;
+
+&lt;h3&gt;6) Scrapping&lt;/h3&gt;
+
+&lt;p&gt;A while ago, I stumbled on this blog post:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/non-scaleable-growth-tactics/help-desk-comparison.png&quot; title=&quot;Help Desk Comparison&quot; alt=&quot;Help Desk Comparison&quot; /&gt;
+&lt;b&gt;Help Desk Comparison&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;In the post, Tyler put together a detailed comparison of Groove, Helpscout, Zendesk and Desk.&lt;/p&gt;
+
+&lt;p&gt;I was happy about the mention, and then I saw…&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/non-scaleable-growth-tactics/decision.png&quot; title=&quot;The Decision&quot; alt=&quot;The Decision&quot; /&gt;
+&lt;b&gt;The Decision&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Right away, I emailed Tyler.&lt;/p&gt;
+
+&lt;p&gt;Here’s the thing: his rundown and his decision were totally sharp and well-reasoned. I respected his decision to go with Help Scout, and I wasn’t emailing him just to change his mind.&lt;/p&gt;
+
+&lt;p&gt;I wanted to learn more about his experience with Groove, and what we could do better to start winning that battle.&lt;/p&gt;
+
+&lt;p&gt;We went back and forth for a bit, and I was grateful that Tyler was so open about sharing his thoughts. Fortunately, the bugs that cost us Tyler’s business the first time around had been fixed, so I asked him if he’d be willing to give us another chance.&lt;/p&gt;
+
+&lt;p&gt;A few weeks later, he published this update to the post:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/non-scaleable-growth-tactics/tyler-returns.png&quot; title=&quot;Tyler Returns&quot; alt=&quot;Tyler Returns&quot; /&gt;
+&lt;b&gt;Tyler Returns&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Is scrapping for every “one that got away” a scalable approach? Absolutely not. But it helped us win a happy customer early on, and to me, there’s no question that that’s worth it.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; When you’re an early-stage startup, you’ll lose a lot of customers because you don’t have everything worked out yet. When you do work things out, those lost customers might come back, and it’s worth fighting for every single one.&lt;/p&gt;
+
+&lt;h2&gt;How to Apply This to Your Business&lt;/h2&gt;
+
+&lt;p&gt;I’ve talked to a lot of early-stage founders who are struggling to get customers. Many of them are taking the “long view.” That is, trying to use acquisition strategies that they’ll be able to use when they have 500, 1,000, 5,000 or 20,000 customers.&lt;/p&gt;
+
+&lt;p&gt;The trouble is, this approach hardly ever works.&lt;/p&gt;
+
+&lt;p&gt;Startup growth isn’t as linear or neat as we’d like to think, and there are a lot of &lt;em&gt;very&lt;/em&gt; valuable things you can do early on that definitely won’t work later. And that’s okay, because there’s a good chance that &lt;em&gt;without&lt;/em&gt; those early battles, you won’t &lt;em&gt;get&lt;/em&gt; a later.&lt;/p&gt;
+
+&lt;p&gt;And while I can’t guarantee that the non-scaleable tactics that worked for us will work for you, I hope that you’ll try at least some of them.&lt;/p&gt;
+
+&lt;p&gt;Or at the very least, I hope that you’ll be convinced to give non-scaleable growth a shot.&lt;/p&gt;
+</content>
+<published>2014-09-25T00:00:00+00:00</published>
+<updated>2014-09-25T00:00:00+00:00</updated>
+</entry>
+<entry>
+<title>Why We’re Doubling Down on Content (Plus, a Big Announcement)</title>
+<link href='/blog/doubling-down-on-content' rel='alternate' type='text/html' />
+<id>tag:blog1.groovehq.com,2014-09-18:/blog/doubling-down-on-content</id>
+<content type='html'>&lt;p&gt;We’ve decided to make a big change to our marketing strategy. Here’s why, and how we’re going to do it…&lt;/p&gt;
+
+&lt;p&gt;I couldn’t believe it.&lt;/p&gt;
+
+&lt;p&gt;There we were, eight months after publishing our first post on this blog, and everything had changed.&lt;/p&gt;
+
+&lt;p&gt;The Groove team was on our weekly call, and we were reviewing the previous month’s numbers.&lt;/p&gt;
+
+&lt;p&gt;Now, we’re far from a success story, and as a founder, part of my job is never being satisfied with where we are, but it was unmistakable: things were looking pretty good.&lt;/p&gt;
+
+&lt;p&gt;And it was (almost) all thanks to this very blog.&lt;/p&gt;
+
+&lt;p&gt;To be sure, it wasn’t a “magic bullet.” There &lt;em&gt;is&lt;/em&gt; no magic bullet.&lt;/p&gt;
+
+&lt;p&gt;It was months and months of hard work, committing many hours each week to producing the very best content we possibly could. It was grueling, and it cost us a lot of opportunities to attack other growth strategies.&lt;/p&gt;
+
+&lt;p&gt;But it certainly paid off.&lt;/p&gt;
+
+&lt;p&gt;So when it came time to talk about how we were going to develop a strategy to meet our 12-month goals, one choice, among many, was obvious…&lt;/p&gt;
+
+&lt;h2&gt;Five Big Wins From Content Marketing&lt;/h2&gt;
+
+&lt;p&gt;I’ve said this before, but it’s worth noting for anyone thinking about their own business growth: content marketing has been, without a close second, our most effective strategy for growing Groove.&lt;/p&gt;
+
+&lt;p&gt;We’ve grown our:&lt;/p&gt;
+
+&lt;h3&gt;1) Traffic&lt;/h3&gt;
+
+&lt;p&gt;There’s no question that the blog has delivered huge traffic for us.&lt;/p&gt;
+
+&lt;p&gt;The numbers speak for themselves, and don’t really need much of an explanation. Here’s a look at our numbers back in April of last year, before we started taking blogging seriously:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/doubling-down-on-content/traffic-year-ago.png&quot; title=&quot;Traffic a year ago&quot; alt=&quot;Traffic a year ago&quot; /&gt;
+&lt;b&gt;Traffic a year ago&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;And again this April, a year later:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/doubling-down-on-content/traffic-now.png&quot; title=&quot;Traffic today&quot; alt=&quot;Traffic today&quot; /&gt;
+&lt;b&gt;Traffic today&lt;/b&gt;&lt;/p&gt;
+
+&lt;h3&gt;2) Thought Leadership&lt;/h3&gt;
+
+&lt;p&gt;When we started, virtually nobody knew who Groove was.&lt;/p&gt;
+
+&lt;p&gt;Now, I get almost daily emails with interview and speaking requests, and bloggers asking for info about Groove that they can feature in their content.&lt;/p&gt;
+
+&lt;p&gt;The thought leadership we’ve built through the blog has scored us many thousands of dollars of free PR.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/doubling-down-on-content/thought-leadership.png&quot; title=&quot;Thought Leadership&quot; alt=&quot;Thought Leadership&quot; /&gt;
+&lt;b&gt;Thought Leadership&lt;/b&gt;&lt;/p&gt;
+
+&lt;h3&gt;3) Trial Signups&lt;/h3&gt;
+
+&lt;p&gt;As our traffic grew, our trial signups grew, too.&lt;/p&gt;
+
+&lt;p&gt;Here’s a snapshot from a 7-day period last April:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/doubling-down-on-content/blog-signups-year-ago.png&quot; title=&quot;Signups a year ago&quot; alt=&quot;Signups a year ago&quot; /&gt;
+&lt;b&gt;Signups a year ago&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;And another one from a year later:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/doubling-down-on-content/blog-signups-now.png&quot; title=&quot;Signups today&quot; alt=&quot;Signups today&quot; /&gt;
+&lt;b&gt;Signups today&lt;/b&gt;&lt;/p&gt;
+
+&lt;h3&gt;4) Community&lt;/h3&gt;
+
+&lt;p&gt;The community that lives in our blog comments is an active and passionate one. We have folks from every corner of the world who come to participate on every post, sharing their own insights and reflections on whatever we’re discussing that week.&lt;/p&gt;
+
+&lt;p&gt;We’ve gotten some powerful advice from commenters that has given us new ideas for our own growth efforts.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/doubling-down-on-content/ideas-from-community.png&quot; title=&quot;Ideas from the community&quot; alt=&quot;Ideas from the community&quot; /&gt;
+&lt;b&gt;Ideas from the community&lt;/b&gt;&lt;/p&gt;
+
+&lt;h3&gt;5) Bottom Line&lt;/h3&gt;
+
+&lt;p&gt;The most important benefit of all: the blog has helped us go from $28,525 in monthly recurring revenue to more than $81,000 as of this week.&lt;/p&gt;
+
+&lt;p&gt;That’s nearly triple the revenue, and it’s all organic: no ads, no promotions, nothing but careful planning, hustle and persistence.&lt;/p&gt;
+
+&lt;h2&gt;Where This Blog Falls Short&lt;/h2&gt;
+
+&lt;p&gt;To be sure, this blog has been &lt;em&gt;amazing&lt;/em&gt; for our business.&lt;/p&gt;
+
+&lt;p&gt;And we have no plans &lt;em&gt;at all&lt;/em&gt; to take our foot off of the gas here.&lt;/p&gt;
+
+&lt;p&gt;But as we’ve grown the blog, one interesting challenge has become very clear.&lt;/p&gt;
+
+&lt;p&gt;Thousands of businesses now know about Groove.&lt;/p&gt;
+
+&lt;p&gt;That’s a very good thing.&lt;/p&gt;
+
+&lt;p&gt;Many of them, unfortunately, still don’t know what we do.&lt;/p&gt;
+
+&lt;p&gt;That’s not so good.&lt;/p&gt;
+
+&lt;p&gt;One recent blog post described us as a “CRM company:”&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/doubling-down-on-content/ouch.png&quot; title=&quot;Ouch.&quot; alt=&quot;Ouch.&quot; /&gt;
+&lt;b&gt;Ouch.&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;When I read that, I couldn’t help but wince.&lt;/p&gt;
+
+&lt;p&gt;And while it’s always the responsibility of the writer to get their facts right, &lt;em&gt;I couldn’t blame them, because it was our fault.&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;We’ve done very little on this blog to get people to think of Groove as a customer support company.&lt;/p&gt;
+
+&lt;p&gt;So as we look to double down on content, the natural direction for us to go seems very clear.&lt;/p&gt;
+
+&lt;p&gt;Except…&lt;/p&gt;
+
+&lt;h2&gt;We’ve Tried This Before (And Failed)&lt;/h2&gt;
+
+&lt;p&gt;Back in April, we introduced the &lt;a href=&quot;http://www.groovehq.com/learn&quot;&gt;Customer Support Academy&lt;/a&gt;.&lt;/p&gt;
+
+&lt;p&gt;For a while, we published weekly support “tips,” sharing the strategies we’ve used to deliver better customer support.&lt;/p&gt;
+
+&lt;p&gt;Eight weeks later, we had a whopping 500 subscribers.&lt;/p&gt;
+
+&lt;p&gt;But that’s not the bad part. If we only had a handful of subscribers, but &lt;em&gt;great&lt;/em&gt; content, I’d be fine with it, because I’d know that we have the experience and skills we need to grow our blog to success.&lt;/p&gt;
+
+&lt;p&gt;What made us abandon the blog after two months was a simple, but painful truth: we weren’t proud of it.&lt;/p&gt;
+
+&lt;p&gt;It was an inexcusable, shameful half-assed effort.&lt;/p&gt;
+
+&lt;p&gt;Our posts were short, shallow and less-than-interesting.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/doubling-down-on-content/not-good-enough.png&quot; title=&quot;Not good enough.&quot; alt=&quot;Not good enough.&quot; /&gt;
+&lt;b&gt;Not good enough.&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;The blog had no name or voice behind it. With the $100K blog, I didn’t have time to write a second blog, so the support blog was a sloppily cobbled together team effort from all of us.&lt;/p&gt;
+
+&lt;p&gt;We didn’t employ any of the &lt;a href=&quot;http://www.groovehq.com/blog/1000-subscribers&quot;&gt;influencer engagement strategies&lt;/a&gt; that we &lt;em&gt;knew&lt;/em&gt; worked.&lt;/p&gt;
+
+&lt;p&gt;Thinking back, the decision not to promote it was probably subconscious, as we weren’t creating content that we were excited to share with the world.&lt;/p&gt;
+
+&lt;p&gt;The blog had no heart, and the shitty results made that clear. We didn’t succeed because we didn’t &lt;em&gt;deserve&lt;/em&gt; to.&lt;/p&gt;
+
+&lt;p&gt;This blog is successful because each week, we work hard to earn our right into people’s inboxes, reading lists and Twitter feeds.&lt;/p&gt;
+
+&lt;p&gt;Looking back, there’s no way we could say the same about the Support Academy.&lt;/p&gt;
+
+&lt;h2&gt;This Time, We’re Swinging for the Fences.&lt;/h2&gt;
+
+&lt;p&gt;This new blog hasn’t been a few days in the making, or a few weeks.&lt;/p&gt;
+
+&lt;p&gt;We’ve been working for &lt;em&gt;months&lt;/em&gt; to put together actionable, useful and &lt;em&gt;interesting&lt;/em&gt; content to help startups and small businesses get better at customer support, and understand how to use it to grow their bottom line.&lt;/p&gt;
+
+&lt;p&gt;We’re investing our time and resources in high-quality content, art and promotion.&lt;/p&gt;
+
+&lt;p&gt;The new blog also has a new voice: Len, our new content marketer, is heading up the support blog.&lt;/p&gt;
+
+&lt;p&gt;It’s his baby, and he’s going to be giving the blog the time and attention that it deserves, but that I don’t have.&lt;/p&gt;
+
+&lt;p&gt;I’m thrilled with the content he’s put together, and I’m confident that it’s going to be a valuable resource for anyone interested in building better relationships with their customers.&lt;/p&gt;
+
+&lt;p&gt;We’ll be publishing new posts every Wednesday, and also trying new types of content that we haven’t explored on this blog. For one thing, we’ve got some &lt;em&gt;incredible&lt;/em&gt; guest content lined up from top entrepreneurs and support experts. If you’re interested in contributing, email Len (Len at groovehq.com).&lt;/p&gt;
+
+&lt;p&gt;I hope you’ll go read the first post, and let us know what you think in the comments.&lt;/p&gt;
+
+&lt;p&gt;We’ll be taking every piece of feedback seriously, and appreciate your help as we get this new effort off of the ground.&lt;/p&gt;
+
+&lt;p&gt;&lt;a href=&quot;http://www.groovehq.com/support/good-customer-support&quot; class=&quot;bb-image-link&quot;&gt;
+ &lt;img src=&quot;/attachments/blog/doubling-down-on-content/new-groove-blog.png&quot; alt=&quot;Introducing the NEW Groove Support Blog&quot; title=&quot;Introducing the NEW Groove Support Blog&quot;&gt;
+ &lt;b&gt;Introducing the NEW Groove Support Blog&lt;/b&gt;
+ &lt;span class=&quot;bb-image-link_text&quot;&gt;Click here to read “Three Principles For Getting Customers For Life”&lt;/span&gt;
+&lt;/a&gt;&lt;/p&gt;
+
+&lt;p&gt;To read the first post, click here: &lt;a href=&quot;http://www.groovehq.com/support/good-customer-support&quot;&gt;What Is Good Customer Service? Three Principles for Getting Customers for Life&lt;/a&gt;.&lt;/p&gt;
+
+&lt;h2&gt;How to Apply This to Your Business&lt;/h2&gt;
+
+&lt;p&gt;Will this work?&lt;/p&gt;
+
+&lt;p&gt;I have no idea.&lt;/p&gt;
+
+&lt;p&gt;But we’re not doing it half-heartedly this time. We’re taking the same approach that worked on this blog, and putting everything we’ve got behind building the best customer support blog on the planet.&lt;/p&gt;
+
+&lt;p&gt;If anything, I hope you’ll learn from our failure: if you’re going to try &lt;em&gt;anything&lt;/em&gt;, it makes no sense to half-ass it.&lt;/p&gt;
+
+&lt;p&gt;Releasing &lt;em&gt;anything&lt;/em&gt; that sucks doesn’t count as “testing.” The results you get from a poor effort tell you &lt;em&gt;nothing&lt;/em&gt; about the results you’d get if you did something right.&lt;/p&gt;
+
+&lt;p&gt;Go big or go home.&lt;/p&gt;
+</content>
+<published>2014-09-18T00:00:00+00:00</published>
+<updated>2014-09-18T00:00:00+00:00</updated>
+</entry>
+<entry>
+<title>How I’ve Become A Better Founder By Practicing Patience</title>
+<link href='/blog/patience' rel='alternate' type='text/html' />
+<id>tag:blog1.groovehq.com,2014-09-11:/blog/patience</id>
+<content type='html'>&lt;p&gt;“Move fast and break stuff” is a startup mantra. Here’s a different take on things…&lt;/p&gt;
+
+&lt;p&gt;“This doesn’t work.”&lt;/p&gt;
+
+&lt;p&gt;The first email came a few minutes after we pushed it live.&lt;/p&gt;
+
+&lt;p&gt;“Looks like it’s broken for me.”&lt;/p&gt;
+
+&lt;p&gt;“The widget isn’t showing up on our site.”&lt;/p&gt;
+
+&lt;p&gt;“How do I turn it on?”&lt;/p&gt;
+
+&lt;p&gt;Two years ago, we released an updated version of our (&lt;a href=&quot;http://www.groovehq.com/blog/discontinuing-live-chat&quot;&gt;now-discontinued&lt;/a&gt;) live chat app.&lt;/p&gt;
+
+&lt;p&gt;Within half an hour, our support mailbox was flooded with complaints about bugs and technical issues.&lt;/p&gt;
+
+&lt;p&gt;It wasn’t ready. And there was nobody to blame but me.&lt;/p&gt;
+
+&lt;p&gt;In the never-ending battle to balance our team’s time with the list of high-priority tasks we needed to accomplish, I had gotten impatient with our weeks-long effort to get this new version of live chat in our customers’ hands.&lt;/p&gt;
+
+&lt;p&gt;It seemed to work fine for me, and despite our developers’ recommendations that we spend more time testing, I made the call: “Let’s just get this out there.”&lt;/p&gt;
+
+&lt;p&gt;The ensuing mess cost us more than $10,000 in lost productivity as we worked to answer emails and pulled the app down to fix it. Worse, it cost us the trust of customers who had taken a chance on a young startup, believing that we would reward their risk with a product that worked the way we said it would.&lt;/p&gt;
+
+&lt;p&gt;It was a painful but important lesson for me: patience is one of the most valuable skills to develop as an entrepreneur.&lt;/p&gt;
+
+&lt;h2&gt;The Power of Patience in Business&lt;/h2&gt;
+
+&lt;p&gt;There’s plenty of research that supports the value of patience.&lt;/p&gt;
+
+&lt;p&gt;A number of studies have shown that &lt;a href=&quot;http://forumblog.org/2014/08/patience-children-research-lifetime-outcomes/&quot;&gt;people who are patient tend to be more healthy, happy and successful&lt;/a&gt;.&lt;/p&gt;
+
+&lt;p&gt;Anecdotally, I &lt;em&gt;know&lt;/em&gt; that impatience has a negative impact on my mood, and more importantly, my ability to make decisions.&lt;/p&gt;
+
+&lt;p&gt;When I’m feeling impatient, I’m more impulsive. If we’re building something that I’ve been antsy to release for weeks, and the only thing standing between us and going live is a bit of polish, it’s tempting to say “fuck it” and push the feature out.&lt;/p&gt;
+
+&lt;p&gt;Sometimes, that can be a good thing. We’ve used the lean approach to many of our releases in the past, and it’s helped us get early feedback and make fast improvements.&lt;/p&gt;
+
+&lt;p&gt;But it’s not always useful to “just ship it.”&lt;/p&gt;
+
+&lt;p&gt;With marketing, you don’t get a second chance. We spend many hours on every blog post, every email, every piece of copy, to make them as good as we possibly can.&lt;/p&gt;
+
+&lt;p&gt;The same is often true with UX changes, especially those that impact the onboarding experience. New customers aren’t as forgiving as those who have been with you for years, and delivering a less-than-perfect experience can easily be the difference between retention and churn.&lt;/p&gt;
+
+&lt;p&gt;And as I recounted at the beginning of this post, shipping too early has hurt us &lt;em&gt;badly&lt;/em&gt; in the past.&lt;/p&gt;
+
+&lt;p&gt;Shipping something before it’s ready can be dangerous, but I’m human, and impatience can — and sometimes does — still get the best of me. It’s been a tough lesson to learn over the years, but I know that actively working on developing patience has made me a better entrepreneur.&lt;/p&gt;
+
+&lt;h2&gt;Four Ways I’ve Built — and Continue to Build — Patience&lt;/h2&gt;
+
+&lt;h3&gt;1) Being Honest About the Consequences&lt;/h3&gt;
+
+&lt;p&gt;I can’t count the number of times I’ve said: “We &lt;em&gt;need&lt;/em&gt; to get this out by Friday.”&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/patience/tick-tock.jpg&quot; title=&quot;Tick Tock&quot; alt=&quot;Tick Tock&quot; /&gt;
+&lt;b&gt;Tick Tock&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;But of those times, I can only recall a few where I was able to follow that statement up with “because…”&lt;/p&gt;
+
+&lt;p&gt;We often set arbitrary deadlines, and that can be a &lt;em&gt;very&lt;/em&gt; good thing for keeping ourselves motivated and productive.&lt;/p&gt;
+
+&lt;p&gt;But things aren’t always in our control, and external factors can cause us to miss those deadlines.&lt;/p&gt;
+
+&lt;p&gt;Here’s the thing: &lt;em&gt;I can’t think of a single time where missing a deadline has had a long-term, negative impact on our business. I can think of multiple times where shipping a buggy or unpolished feature &lt;strong&gt;has&lt;/strong&gt; hurt us. I’d much rather do the former than the latter.&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;I’m not advocating laziness, or a casual attitude toward deadlines. We hustle &lt;em&gt;hard&lt;/em&gt; every single day, and we work overtime to hit deadlines when we need to.&lt;/p&gt;
+
+&lt;p&gt;But there are times when a deadline isn’t absolute, and when we — and our customers — benefit from me being a little bit more patient and taking a bit more time to get things right.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Deadlines are a valuable tool for productivity, but if you’re not going to hit your deadline, be honest with yourself: are you better off shipping something that’s not quite done? In many cases, for us, that answer has been no.&lt;/p&gt;
+
+&lt;h3&gt;2) Taking Lessons From Other Areas of Life&lt;/h3&gt;
+
+&lt;p&gt;As a Rhode Island boy, I’ve been surfing since I was 15 years old.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/patience/patience-on-the-water.jpg&quot; title=&quot;Patience on the water&quot; alt=&quot;Patience on the water&quot; /&gt;
+&lt;b&gt;Patience on the water&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Funny thing about the ocean: it doesn’t give a damn about your schedule.&lt;/p&gt;
+
+&lt;p&gt;I’ve waited hours for a good wave.&lt;/p&gt;
+
+&lt;p&gt;I’ve waited hours and gone home disappointed that a good wave never came.&lt;/p&gt;
+
+&lt;p&gt;I’ve waited hours and been rewarded with 10 seconds of pure bliss that put me in an amazing mood for days.&lt;/p&gt;
+
+&lt;p&gt;When I was younger, surfing taught me patience, and that the wait for a &lt;em&gt;great&lt;/em&gt; wave pays off in spades.&lt;/p&gt;
+
+&lt;p&gt;As I got older and busier, I had less time to spend on the beach, and didn’t get to appreciate that constant, unavoidable reminder of the value of patience.&lt;/p&gt;
+
+&lt;p&gt;At Groove, I’ve forced myself to make a little more time for &lt;a href=&quot;http://www.groovehq.com/blog/staying-sane-working-solo&quot;&gt;play&lt;/a&gt;, and surfing is a big part of that.&lt;/p&gt;
+
+&lt;p&gt;And every time I’m out there at Ruggles, I re-learn a valuable lesson that I can instantly apply to my work.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Many hobbies take patience to learn and get good at, but even though we’ve developed that patience, we don’t think to apply it to our business lives. Being more aware of how patience helps you in all areas of life can help you become a more patient person at work, too.&lt;/p&gt;
+
+&lt;h3&gt;3) Not Measuring Against Someone Else’s Yardstick&lt;/h3&gt;
+
+&lt;p&gt;It’s &lt;em&gt;ridiculously&lt;/em&gt; easy to look at a competitor and think, “They released [Feature X] last week. We need to build it NOW!”&lt;/p&gt;
+
+&lt;p&gt;There are multiple reasons why that attitude is a poor way to make product choices, but it’s a tough thought to avoid. I know I’m guilty of it.&lt;/p&gt;
+
+&lt;p&gt;There’s a quote that I love, though I’ve seen it attributed to so many different people that I have no idea who’s ultimately responsible for it: &lt;a href=&quot;https://twitter.com/home?status=%E2%80%9CNever%20compare%20your%20beginning%20to%20someone%20else%E2%80%99s%20middle%E2%80%9D%20http://www.groovehq.com/blog/patience%20via%20@groove&quot;&gt;“Never compare your beginning to someone else’s middle”&lt;/a&gt;.&lt;/p&gt;
+
+&lt;p&gt;My natural impulse is to measure my progress against people who are more successful than I am, and who have been at this game for far longer.&lt;/p&gt;
+
+&lt;p&gt;And while that’s a great driver for motivation, it’s a terrible way to build patience.&lt;/p&gt;
+
+&lt;p&gt;We often see the end result (e.g., a competitor releasing a specific feature), but not the amount of work that went into achieving that result (the many weeks they spent building and testing that feature). Trying to shortcut our way to achieving that result is a great way to guarantee that we’ll never be as good as the people we’re competing against.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Don’t let other people’s progress make you lose sight of your own path. Comparing your beginning to someone else’s middle can be a quick path to losing patience and falling behind.&lt;/p&gt;
+
+&lt;h3&gt;4) Track — and Celebrate — Little Wins&lt;/h3&gt;
+
+&lt;p&gt;When you spend weeks working towards a goal, it’s easy to think of the results as binary: either we accomplished that goal, or we didn’t.&lt;/p&gt;
+
+&lt;p&gt;But that, for me, is a dangerous mindset, because if we don’t hit our deadline, then the binary perspective makes our whole project a failure, even if we had a number of smaller wins during the process.&lt;/p&gt;
+
+&lt;p&gt;I’ve found it immensely valuable to break down every project into smaller micro-goals to help us track those smaller wins.&lt;/p&gt;
+
+&lt;p&gt;For example, we finished our last &lt;a href=&quot;http://www.groovehq.com/blog/long-form-landing-page&quot;&gt;website redesign&lt;/a&gt; a few days late.&lt;/p&gt;
+
+&lt;p&gt;But along the way, we tracked a number of small wins that made our business stronger:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/patience/small-wins.png&quot; title=&quot;Small wins along the way&quot; alt=&quot;Small wins along the way&quot; /&gt;
+&lt;b&gt;Small wins along the way&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Having the progress be so visible makes it easier to be patient about the ultimate result, and seeing the little wins helps motivate our team to keep hustling.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Don’t think of your deadlines as pass/fail only. Remember to track and celebrate the little wins along the way. It’ll make you more patient and productive.&lt;/p&gt;
+
+&lt;h2&gt;How to Apply This to Your Business&lt;/h2&gt;
+
+&lt;p&gt;Patience is one of the toughest skills to develop, yet one of the most valuable assets I’ve built as an entrepreneur.&lt;/p&gt;
+
+&lt;p&gt;It’s not &lt;em&gt;always&lt;/em&gt; a tool you’ll want to use: there are situations where overtime, a bit of extra hustle, and putting pressure on the people around you to move faster &lt;em&gt;are&lt;/em&gt; necessary.&lt;/p&gt;
+
+&lt;p&gt;But for me, and for the sustainable growth of our business, I’ve found that those situations are better off as the exceptions, and not the rule.&lt;/p&gt;
+
+&lt;p&gt;I hope that these techniques help you develop the patience to wait when you need to, and to ultimately make better decisions for your business.&lt;/p&gt;
+</content>
+<published>2014-09-11T00:00:00+00:00</published>
+<updated>2014-09-11T00:00:00+00:00</updated>
+</entry>
+<entry>
+<title>The Power of Testimonials (And How We Get Great Ones)</title>
+<link href='/blog/testimonials' rel='alternate' type='text/html' />
+<id>tag:blog1.groovehq.com,2014-09-04:/blog/testimonials</id>
+<content type='html'>&lt;p&gt;One of the best ways to connect with prospects is by using stories from existing customers. Here’s how we do that…&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/testimonials/a-letter.png&quot; alt=&quot;A letter from potential customer who decided to go with Zendesk&quot; /&gt;&lt;/p&gt;
+
+&lt;p&gt;It’s frustrating, infuriating and agonizing at the same time.&lt;/p&gt;
+
+&lt;p&gt;Every startup with a big competitor knows this battle: when you’re the little guy, you’re &lt;em&gt;not&lt;/em&gt; the “safe” choice.&lt;/p&gt;
+
+&lt;p&gt;You’re the risk. You’re the one that has to scrap harder to get picked.&lt;/p&gt;
+
+&lt;p&gt;That saying, “nobody ever got fired for hiring IBM”?&lt;/p&gt;
+
+&lt;p&gt;In our world, nobody ever got fired for signing up for Zendesk.&lt;/p&gt;
+
+&lt;p&gt;When something as critical as customer support is on the line, people want to know that they can &lt;em&gt;trust&lt;/em&gt; the company they’re hiring. They want to know that our product will work for them, &lt;em&gt;specifically&lt;/em&gt;.&lt;/p&gt;
+
+&lt;p&gt;And if they choose Zendesk, it’s often because there are tens of thousands of others &lt;em&gt;just like them&lt;/em&gt; using Zendesk, too. It sure makes the decision a lot easier to justify.&lt;/p&gt;
+
+&lt;p&gt;We can’t fault prospects for that: I’ll almost &lt;em&gt;always&lt;/em&gt; take the safe choice, too.&lt;/p&gt;
+
+&lt;p&gt;The challenge, then, is: how does the scrappy, unproven startup become more of a sure thing?&lt;/p&gt;
+
+&lt;p&gt;While we’re always learning and we still have a long way to go, we’ve gotten pretty good at making that case over the last couple of years, and one of the things that’s helped us the &lt;em&gt;most&lt;/em&gt; is using testimonials to help prospects overcome those “uncertainty” objections.&lt;/p&gt;
+
+&lt;h2&gt;The Power of Testimonials&lt;/h2&gt;
+
+&lt;p&gt;It’s no secret: people tend to follow others like them.&lt;/p&gt;
+
+&lt;p&gt;Marketers call it &lt;a href=&quot;http://en.wikipedia.org/wiki/Social_proof&quot;&gt;social proof&lt;/a&gt;: when we see lots of others doing something, we assume that that’s the correct behavior.&lt;/p&gt;
+
+&lt;p&gt;There have been dozens of studies on social proof. &lt;a href=&quot;http://psycnet.apa.org/journals/psp/13/2/79/&quot;&gt;This&lt;/a&gt; is one of my favorites, in which a psychologist placed people standing on a sidewalk staring up at a building, and observed hundreds of passerby stopping to stare up when they saw his actors, too.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/testimonials/what-is-that.jpg&quot; title=&quot;What IS that?&quot; alt=&quot;What IS that?&quot; /&gt;
+&lt;b&gt;What IS that?&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;But social proof is only one side of it.&lt;/p&gt;
+
+&lt;p&gt;How many times have we been like the people at the beginning of this post?&lt;/p&gt;
+
+&lt;p&gt;When we see a product that looks like it works well, we sometimes think: &lt;em&gt;great, but it probably won’t work for me because I’m (insert any unique trait or condition here).&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;We build objections to any marketing pitch we see, and testimonials help to overcome those by showing us that &lt;em&gt;yes&lt;/em&gt;, this product &lt;em&gt;does&lt;/em&gt; work for people just like us.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; The psychology of testimonials is deep and powerful, and lies on two important pillars: social proof and overcoming the objection that your product won’t work for a particular customer.&lt;/p&gt;
+
+&lt;h2&gt;What Makes a Good Testimonial?&lt;/h2&gt;
+
+&lt;p&gt;At Groove, we’ve found that good testimonials increase conversions by up to 15% on our homepage, guest post landing pages and email marketing.&lt;/p&gt;
+
+&lt;p&gt;What’s a &lt;em&gt;good&lt;/em&gt; testimonial?&lt;/p&gt;
+
+&lt;p&gt;Hint: it’s not a fluffy, gushing “Groove is amazing and changed my life” statement. It’s much more nuanced than that.&lt;/p&gt;
+
+&lt;p&gt;I encourage everyone to read Sean D’Souza’s two-part Copyblogger series on &lt;em&gt;The Secret Life of Testimonials&lt;/em&gt; (&lt;em&gt;&lt;a href=&quot;http://www.copyblogger.com/testimonials-part-1/&quot;&gt;Part One&lt;/a&gt; and &lt;a href=&quot;http://www.copyblogger.com/testimonials-part-2/&quot;&gt;Part Two&lt;/a&gt;&lt;/em&gt;), but what we’ve found is that the best-testing testimonials are &lt;em&gt;specific about who the testimonial writer is,&lt;/em&gt; and &lt;em&gt;what problem Groove solved for them&lt;/em&gt;.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/testimonials/example-mini-testimonial.jpg&quot; title=&quot;Example Mini-Testimonial&quot; alt=&quot;Example Mini-Testimonial&quot; /&gt;
+&lt;b&gt;Example Mini-Testimonial&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;The first part helps the reader put themselves in the shoes of the testimonial writer. As a SaaS founder, I’m a lot more likely to relate, for example, to Allan Branch, another SaaS founder, than the anonymous “John S., Boston, MA” that I see offering up testimonials all over the web.&lt;/p&gt;
+
+&lt;p&gt;The second part, &lt;em&gt;specificity about a problem&lt;/em&gt;, demonstrates to the reader &lt;em&gt;not&lt;/em&gt; just that your product is generally good (that’s not enough), but that you can solve &lt;em&gt;their&lt;/em&gt; problem.&lt;/p&gt;
+
+&lt;p&gt;In the example above, one of the most pressing problems we’ve found in our customer development is that enterprise help desk users feel bogged down by the complexity of the software, so we need to make sure we hit that pain point in our testimonials.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Good testimonials aren’t fluffy; they communicate &lt;em&gt;very specifically&lt;/em&gt; the type of person the testimonial writer is and the type of problem they’ve been able to overcome. This helps readers put themselves in the storyteller’s shoes.&lt;/p&gt;
+
+&lt;h2&gt;How We Get Good Testimonials&lt;/h2&gt;
+
+&lt;p&gt;Unfortunately, it’s not as simple as saying “could you please provide a testimonial?”&lt;/p&gt;
+
+&lt;p&gt;Sure, that’ll get you a testimonial, but it’ll probably be a weak, generic and canned-sounding blurb that won’t help you any more than &lt;em&gt;not&lt;/em&gt; having testimonials.&lt;/p&gt;
+
+&lt;p&gt;But we’ve found that while it’s not &lt;em&gt;that&lt;/em&gt; simple, it is fairly straightforward to get good testimonials by following a few basic approaches.&lt;/p&gt;
+
+&lt;p&gt;&lt;em&gt;Note: in all of the examples below, we &lt;strong&gt;never&lt;/strong&gt; post a testimonial without first asking the customer for permission.&lt;/em&gt;&lt;/p&gt;
+
+&lt;h3&gt;1) Capturing Objections&lt;/h3&gt;
+
+&lt;p&gt;Every single person who signs up for Groove gets this email:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/testimonials/you-are-in.png&quot; title=&quot;“You’re In” Email&quot; alt=&quot;“You’re In” Email&quot; /&gt;
+&lt;b&gt;“You’re In” Email&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;It’s not just amazingly valuable for collecting qualitative data about the “conversion triggers” that worked in getting people to sign up, but it gives us profound insight into the objections and obstacles people had to overcome to make the choice to sign up for Groove.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/testimonials/objections-and-challenges.png&quot; title=&quot;Objections and Challenges&quot; alt=&quot;Objections and Challenges&quot; /&gt;
+&lt;b&gt;Objections and Challenges&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;&lt;em&gt;(Alex note: the person who sent that email above has now been a customer for six months).&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;Often we’ll go back in a few weeks or months and follow up with customers to see how they’re doing. Using those stories (customers who went from big challenges to being successful using Groove) in our testimonials helps us connect deeply with prospects going through the same emotions.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway: &lt;/strong&gt;Good testimonials don’t just capture the &lt;em&gt;end result&lt;/em&gt;. They capture the struggles and objections at the beginning, too.&lt;/p&gt;
+
+&lt;h3&gt;2) Listening to Customers&lt;/h3&gt;
+
+&lt;p&gt;If you’ve been following the blog, you know that we spend a lot of time talking to our customers.&lt;/p&gt;
+
+&lt;p&gt;Mo, our head of support, does it for 8+ hours per day. The rest of our team engages with customers, too. I devote at least a quarter of my time to talking to Groove customers.&lt;/p&gt;
+
+&lt;p&gt;(In fact, one of my goals for the next few months is to talk to every single one of our customers about their experiences and how we can improve.)&lt;/p&gt;
+
+&lt;p&gt;And while the goal of our conversations is &lt;em&gt;always&lt;/em&gt; to help the customer do better with Groove, we’ve also learned to listen for the underlying stories they share about their experiences.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/testimonials/underlying-stories.png&quot; title=&quot;Underlying Stories&quot; alt=&quot;Underlying Stories&quot; /&gt;
+&lt;b&gt;Underlying Stories&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;It’s usually in these natural conversations — and not the canned requests for testimonials — that we get the best, most compelling customer stories.&lt;/p&gt;
+
+&lt;p&gt;Once the conversation is over or the support issue is resolved, we’ll go back and ask the customer if we can share their story.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; There are a lot of important reasons to always be talking with your customers. Being able to spot and extract powerful testimonials is just one of them.&lt;/p&gt;
+
+&lt;h3&gt;3) The Straight Ask&lt;/h3&gt;
+
+&lt;p&gt;Sometimes, customers don’t necessarily &lt;em&gt;need&lt;/em&gt; to talk to you; they’re doing just fine on their own.&lt;/p&gt;
+
+&lt;p&gt;And if they’re busy, it can be hard to get them on the phone with you.&lt;/p&gt;
+
+&lt;p&gt;But if we know someone is succeeding with Groove and that their story might make a great testimonial, we’ll send them an email that looks like this:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/testimonials/testimonial-request.png&quot; title=&quot;Testimonial Request&quot; alt=&quot;Testimonial Request&quot; /&gt;
+&lt;b&gt;Testimonial Request&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Notice how we don’t just ask for a testimonial, but &lt;em&gt;walk them through the steps required&lt;/em&gt; to hit the most important traits of a great testimonial.&lt;/p&gt;
+
+&lt;p&gt;The script above is yours to use as you’d like; I hope it nets you some powerful stories.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; The way you ask for a testimonial can mean the difference between a crappy testimonial and an amazing one. It takes a bit more work, but it’s worth doing right.&lt;/p&gt;
+
+&lt;h2&gt;How to Apply This to Your Business&lt;/h2&gt;
+
+&lt;p&gt;Testimonials can be a powerful tool, and if you’re not already using them, I hope this post inspires you to test them in your marketing.&lt;/p&gt;
+
+&lt;p&gt;If you &lt;em&gt;are&lt;/em&gt; using them, but your testimonials aren’t as good as they could be — this is almost always the case, as we’re also always working to improve our customer stories — I hope you’ll revisit them now.&lt;/p&gt;
+
+&lt;p&gt;Feel free to use the scripts and strategies above to tell better stories, connect more deeply with your prospects and improve your conversions.&lt;/p&gt;
+</content>
+<published>2014-09-04T00:00:00+00:00</published>
+<updated>2014-09-04T00:00:00+00:00</updated>
+</entry>
+<entry>
+<title>Lessons Learned Building a Startup Team</title>
+<link href='/blog/building-a-team' rel='alternate' type='text/html' />
+<id>tag:blog1.groovehq.com,2014-08-28:/blog/building-a-team</id>
+<content type='html'>&lt;p&gt;Building a team that works well together isn't easy. Here's how we've approached hiring at Groove…&lt;/p&gt;
+
+&lt;p&gt;At first, it was just me.&lt;/p&gt;
+
+&lt;p&gt;I hired an engineering team at an agency to build Groove’s beta product, and went to work doing &lt;em&gt;everything&lt;/em&gt; else: product spec, sales, marketing, QA, customer support, research, project management, investor relations.&lt;/p&gt;
+
+&lt;p&gt;Then, it was Edmond and me. Edmond was a developer I hired to bring the app “in house” after MojoTech was finished with it.&lt;/p&gt;
+
+&lt;p&gt;When you’re a team of one or two, you don’t worry about hiring. &lt;em&gt;Every&lt;/em&gt; job is yours, and you find a way to get it done, whether you know how to do it or not.&lt;/p&gt;
+
+&lt;p&gt;But eventually, with a ton of hustle and some good luck, you get a chance to grow. We were fortunate in that regard, and it was soon time to figure out how to build a small team.&lt;/p&gt;
+
+&lt;p&gt;Now, two years later, we’re a full-time team of six.&lt;/p&gt;
+
+&lt;p&gt;Granted, in the scheme of things, we’re still &lt;em&gt;tiny&lt;/em&gt;.&lt;/p&gt;
+
+&lt;p&gt;In fact, people have asked me incredulously how we support so many customers with so few employees. But if you think about it, it’s actually not crazy at all.&lt;/p&gt;
+
+&lt;p&gt;Take a company like &lt;a href=&quot;http://www.basecamp.com&quot;&gt;Basecamp&lt;/a&gt;, which has 35 employees and supports &lt;a href=&quot;http://www.quora.com/How-many-paid-customers-does-Basecamp-have&quot;&gt;more than 300,000 paying customers&lt;/a&gt;. That’s one employee for every 9,000+ customers.&lt;/p&gt;
+
+&lt;p&gt;Or &lt;a href=&quot;http://www.bufferapp.com&quot;&gt;Buffer&lt;/a&gt;, a 23-person team supporting &lt;a href=&quot;http://open.bufferapp.com/buffers-july-content-marketing-report/&quot;&gt;nearly 700,000 users&lt;/a&gt;, at one employee per &lt;em&gt;30,000+&lt;/em&gt; customers.&lt;/p&gt;
+
+&lt;p&gt;Sure makes our 1-employee-per-333-customers seem like small potatoes.&lt;/p&gt;
+
+&lt;p&gt;But even building a tiny team, we’ve learned valuable lessons, made some mistakes, and scored big wins to get to where we are.&lt;/p&gt;
+
+&lt;p&gt;So when I got this email from a reader…&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/building-a-team/hiring-question.png&quot; title=&quot;Hiring Question&quot; alt=&quot;Hiring Question&quot; /&gt;
+&lt;b&gt;Hiring Question&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;I thought it would be helpful to share some of the lessons we’ve learned along the way:&lt;/p&gt;
+
+&lt;h3&gt;1) At First, Hire for Immediate Needs Only.&lt;/h3&gt;
+
+&lt;p&gt;When we first started, Groove needed to accomplish two things: make a product, and get it into people’s hands.&lt;/p&gt;
+
+&lt;p&gt;For better or worse, we weren’t too worried about accounting, legal filings, operations or HR. Yet.&lt;/p&gt;
+
+&lt;p&gt;So we focused on hiring people who could help us accomplish our two main goals.&lt;/p&gt;
+
+&lt;p&gt;I wasn’t looking for anyone that could be trained to do a great job &lt;em&gt;tomorrow&lt;/em&gt; (more on how that’s changed below), but instead I wanted people who had the skills and experience to get us closer to where we wanted to go &lt;em&gt;today&lt;/em&gt;.&lt;/p&gt;
+
+&lt;p&gt;At the time, we were working on transitioning our development from &lt;a href=&quot;http://www.mojotech.com&quot;&gt;MojoTech&lt;/a&gt;, the agency who &lt;a href=&quot;http://www.groovehq.com/blog/technical-co-founder&quot;&gt;built our first iteration&lt;/a&gt;. We needed developers who could deeply understand the existing codebase and build the features we wanted to build immediately.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/building-a-team/old-job-posting.png&quot; title=&quot;An Old Job Posting&quot; alt=&quot;An Old Job Posting&quot; /&gt;
+&lt;b&gt;An Old Job Posting&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;So my first hire was Edmund, a full-stack developer, and a bit later, Chris, a back-end engineer.&lt;/p&gt;
+
+&lt;p&gt;Not long after that, I hired Jordan, another full-stack developer.&lt;/p&gt;
+
+&lt;p&gt;While Edmund had to take off for personal reasons, Jordan and Chris are still part of our team today.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; When you’re starting out, don’t worry about who you’ll need in six months or a year. Focus on getting the people who can create progress &lt;em&gt;today.&lt;/em&gt;&lt;/p&gt;
+
+&lt;h3&gt;2) Once Tomorrow Is Secure, Hire for the Future.&lt;/h3&gt;
+
+&lt;p&gt;Early on, we didn’t really have the luxury of planning for next month, let alone next year.&lt;/p&gt;
+
+&lt;p&gt;But when we turned a corner and hit Product/Market Fit, Groove began to grow fast. We were hitting the milestones on our product roadmap, and building at a good pace.&lt;/p&gt;
+
+&lt;p&gt;We had the runway to plan for the future, and so our hiring changed a bit to reflect that.&lt;/p&gt;
+
+&lt;p&gt;In general, the approach we’ve taken is this: it’s time to hire for a position when the pain of &lt;em&gt;not&lt;/em&gt; having that person on your team is bigger than the cost of adding them.&lt;/p&gt;
+
+&lt;p&gt;Here’s an example: In our first year, I was pounding the pavement, selling Groove to anyone who would listen. Over time, the need for a customer support person became more and more pressing. I couldn’t continue doing &lt;em&gt;all&lt;/em&gt; of the support and marketing at the same time.&lt;/p&gt;
+
+&lt;p&gt;That’s when I hired Adam, one of my childhood best friends, to join us as our Head of Customer Success.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; After you turn a corner and have the benefit of being able to think months — and years — ahead, that’s when you should start to make the hires that will help you achieve the goals you’re setting out.&lt;/p&gt;
+
+&lt;h3&gt;3) Turnover Will Happen. It Won’t Be as Bad as You Think.&lt;/h3&gt;
+
+&lt;p&gt;Several months ago, Adam left Groove to return to the finance world.&lt;/p&gt;
+
+&lt;p&gt;It was a smart move; he has a new baby, and needed more stability and income than a startup could provide.&lt;/p&gt;
+
+&lt;p&gt;When he told me he was leaving, I couldn’t help but panic.&lt;/p&gt;
+
+&lt;p&gt;It’s not that I expected him to stay forever; in fact, early on, we had talked about this being a temporary arrangement while we got the company off of the ground.&lt;/p&gt;
+
+&lt;p&gt;But over two years of working together, &lt;em&gt;we&lt;/em&gt; — not &lt;em&gt;I&lt;/em&gt; — had become Groove.&lt;/p&gt;
+
+&lt;p&gt;When your company is two, three, four, five or six people — people who battle in the trenches together every single day — it can be hard to envision the business without those team members. Thinking about losing them can be a tough shock to the system.&lt;/p&gt;
+
+&lt;p&gt;Plus, there’s always the fear: &lt;em&gt;what will people think? Our customers talk to Adam every day, are they going to be upset that he’s gone? Will everyone think we’re in trouble because our first employee is leaving?&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;As a &lt;a href=&quot;http://www.groovehq.com/blog/fear&quot;&gt;founder with many fears&lt;/a&gt;, it can be paralyzing.&lt;/p&gt;
+
+&lt;p&gt;But, as with most things, it never ends up being as bad as you think it’ll be.&lt;/p&gt;
+
+&lt;p&gt;Adam was gracious to give us more than a month’s notice, and helped us find and train Mo, our new Head of Customer Success, who’s been an amazing addition to our team (our customers agree).&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/building-a-team/alive-and-well.png&quot; title=&quot;Alive and Well&quot; alt=&quot;Alive and Well&quot; /&gt;
+&lt;b&gt;Alive and Well&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;(For anyone interested, Adam is still one of my best friends.)&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Losing a team member is scary for a startup, but it won’t end up being nearly as bad as you fear. Try to make the most of their final weeks and have them help you train their replacement. Either way, life — and business — goes on.&lt;/p&gt;
+
+&lt;h3&gt;4) Supplement With Part-time Help.&lt;/h3&gt;
+
+&lt;p&gt;Not every need requires a full-time effort to fill.&lt;/p&gt;
+
+&lt;p&gt;There’s a lot of resistance among founders I’ve talked to when it comes to hiring part-time help. They say things like “we want someone who’s going to be part of the team,” and end up hiring full-time employees to fill part-time needs.&lt;/p&gt;
+
+&lt;p&gt;It doesn’t have to be all-or-nothing.&lt;/p&gt;
+
+&lt;p&gt;Along our journey, I’ve supplemented the Groove team with part time help, and it’s allowed us to stay lean as we grow. In fact, we still use a part-time designer for the header art on this blog.&lt;/p&gt;
+
+&lt;p&gt;It’s also opened up big opportunities for us: sometimes, the people you want on your team aren’t necessarily &lt;em&gt;available&lt;/em&gt; for a full-time gig.&lt;/p&gt;
+
+&lt;p&gt;Len, our head of marketing, was consulting for a number of companies when he first joined us to work part-time with copy and messaging. Over time, he’s helped us with content strategy, messaging and copy for our &lt;a href=&quot;http://www.groovehq.com/blog/long-form-landing-page&quot;&gt;site redesign&lt;/a&gt;. It wasn’t until two years later that the stars aligned and he wrapped up his other projects to come join our team full-time.&lt;/p&gt;
+
+&lt;p&gt;A bit of a teaser: Len’s hiring also has &lt;em&gt;a lot&lt;/em&gt; to do with &lt;em&gt;Lesson 3&lt;/em&gt; above, as he’s going to be heading up a new blog we’re excited to announce soon and helping us to &lt;a href=&quot;http://www.groovehq.com/blog/12-month-growth-strategy&quot;&gt;double down on content&lt;/a&gt;.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/building-a-team/coming-soon.png&quot; title=&quot;Coming Soon&quot; alt=&quot;Coming Soon&quot; /&gt;
+&lt;b&gt;Coming Soon&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Serg, our front-end developer, started out working just a few hours per week, helping us to code our blog posts. Now, several months later, he’s a big part of the team, coding everything from our marketing site to our app UI.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Don’t be afraid to lean on part-time help. If you don’t need a full-time employee, it can save you money. If you &lt;em&gt;are&lt;/em&gt; looking for a full-time solution, it can plug the gap while you search. And often, it can end up becoming a full-time arrangement in the future.&lt;/p&gt;
+
+&lt;h3&gt;5) Reduce the Risk for Everyone.&lt;/h3&gt;
+
+&lt;p&gt;A bad hire is always costly. For a startup, it can be devastating.&lt;/p&gt;
+
+&lt;p&gt;When I say &lt;em&gt;bad hire,&lt;/em&gt; I’m not referring to the person you’re hiring. I’m referring to the &lt;em&gt;decision&lt;/em&gt; to hire someone that’s not the right fit for your team, and then the &lt;em&gt;passive&lt;/em&gt; decision to keep them there.&lt;/p&gt;
+
+&lt;p&gt;It’s a mistake that’s burned me in the past, and I was determined not to let it happen with Groove.&lt;/p&gt;
+
+&lt;p&gt;That’s why we use the trial-to-hire method: every new employee joins us for a “trial project” — something they can do during nights and weekends while keeping their current job — of 2-4 weeks. After the project is done — although usually, it’s apparent much sooner — we can evaluate whether we’re the best fit for each other.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/building-a-team/our-hiring-process.png&quot; title=&quot;Our Hiring Process&quot; alt=&quot;Our Hiring Process&quot; /&gt;
+&lt;b&gt;Our Hiring Process&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;This approach has helped us slowly but effectively build a team that works — and fits — well together.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Hiring someone is a big investment, and can be risky for both parties. Interviews can only tell you so much. Use trials to make sure that every new team member fits in well.&lt;/p&gt;
+
+&lt;h3&gt;6) Don’t Be Too Slow to Spot a Poor Fit.&lt;/h3&gt;
+
+&lt;p&gt;There are few management cliches more often-repeated than “hire fast, fire faster.”&lt;/p&gt;
+
+&lt;p&gt;And while I don’t necessarily agree with the first part (we hire pretty carefully and methodically), I know that I could use some help internalizing the second part.&lt;/p&gt;
+
+&lt;p&gt;Sometimes, you make mistakes.&lt;/p&gt;
+
+&lt;p&gt;You hire people, and it doesn’t work out. Maybe they’re not a great fit for the team. Maybe their strengths aren’t what you thought they would be. Maybe you misjudged the need for a full-time person in their position.&lt;/p&gt;
+
+&lt;p&gt;Whatever it is, these mistakes can be very costly.&lt;/p&gt;
+
+&lt;p&gt;Here’s the problem: a person is not an app. Regardless of whether you have a “pay-as-you-go” contract with them or not, cutting ties with and employee is a much more difficult and emotional act than canceling an app subscription.&lt;/p&gt;
+
+&lt;p&gt;In my entire career, I’ve had to fire dozens of people. People with families and responsibilities. It’s devastating, and over the years, it hasn’t gotten any easier.&lt;/p&gt;
+
+&lt;p&gt;If anything, it’s gotten &lt;em&gt;harder&lt;/em&gt;, as I get angry with myself for continuing to make hiring mistakes from time to time, costing people their jobs.&lt;/p&gt;
+
+&lt;p&gt;But at the end of the day, keeping an employee who isn’t a good fit for the team can be &lt;em&gt;crippling&lt;/em&gt;.&lt;/p&gt;
+
+&lt;p&gt;It brings down the whole team, and ties up cash you could be using for better investments in your business.&lt;/p&gt;
+
+&lt;p&gt;It’s tempting to &lt;em&gt;try and make it work&lt;/em&gt;; to brainstorm and try to figure out ways to make the fit &lt;em&gt;better&lt;/em&gt;.&lt;/p&gt;
+
+&lt;p&gt;But I’ve never been good enough to do that successfully.&lt;/p&gt;
+
+&lt;p&gt;One of the things I’ve learned — and worked on a lot over the past year — is being much faster to spot whether a new team member is a good fit or not.&lt;/p&gt;
+
+&lt;p&gt;It takes some unpleasant brutal honesty with yourself, but in the long run, it’s critical to your business’ future.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Firing people is hard. Really hard. But if you want to hire and manage a successful team, you need to learn how to determine whether or not someone is going to be a strong part of your team’s makeup in the long term, and if the answer is no, you need to take action as soon as you can.&lt;/p&gt;
+
+&lt;h2&gt;How to apply this to your business&lt;/h2&gt;
+
+&lt;p&gt;I don’t know if this is the &lt;em&gt;best&lt;/em&gt; way to build a startup team.&lt;/p&gt;
+
+&lt;p&gt;But it’s certainly worked well for us.&lt;/p&gt;
+
+&lt;p&gt;If you’re at a crossroads with hiring and thinking about how to move forward, I hope that our experiences can help shine some light on one possible approach.&lt;/p&gt;
+
+&lt;p&gt;I’d also be interested to hear about your own hiring lessons learned: just leave a note in the comments below.&lt;/p&gt;
+</content>
+<published>2014-08-28T00:00:00+00:00</published>
+<updated>2014-08-28T00:00:00+00:00</updated>
+</entry>
+<entry>
+<title>14 Ways Our Remote Team Stays Sane Working From Home</title>
+<link href='/blog/staying-sane-working-solo' rel='alternate' type='text/html' />
+<id>tag:blog1.groovehq.com,2014-08-21:/blog/staying-sane-working-solo</id>
+<content type='html'>&lt;p&gt;Like most founders, I can’t say that I consider myself completely “sane.”&lt;/p&gt;
+
+&lt;p&gt;By the very nature of our jobs, we’re taking big risks, and our dreams are far beyond what the data suggests we can reasonably expect.&lt;/p&gt;
+
+&lt;p&gt;To take that plunge, I think you &lt;em&gt;have&lt;/em&gt; to be a little bit strange.&lt;/p&gt;
+
+&lt;p&gt;I have quirks, &lt;a href=&quot;http://www.groovehq.com/blog/fear&quot;&gt;paralyzing fears&lt;/a&gt; and &lt;a href=&quot;http://www.groovehq.com/blog/single-founder-loneliness&quot;&gt;near-breakdowns&lt;/a&gt;, and many of the founders I know do, too.&lt;/p&gt;
+
+&lt;p&gt;That’s all made worse by the fact that for most of my working hours, there’s not a single person in the physical space around me.&lt;/p&gt;
+
+&lt;p&gt;We’re a &lt;a href=&quot;http://www.groovehq.com/blog/being-a-remote-team&quot;&gt;remote team&lt;/a&gt;, so it’s something that everyone at Groove deals with.&lt;/p&gt;
+
+&lt;p&gt;For some — including me — working solo is the &lt;em&gt;best&lt;/em&gt; way to go. I’m still happier and more productive than I’ve ever been working from a shared office.&lt;/p&gt;
+
+&lt;p&gt;But still, the isolation can get to you.&lt;/p&gt;
+
+&lt;p&gt;Over the years, I’ve become much better at spotting when the isolation is about to get to me. And I’ve developed a number of ways to stop it in its tracks.&lt;/p&gt;
+
+&lt;p&gt;In 3 years of working solo, here’s what I’ve found works best to help me stay sane working from home:&lt;/p&gt;
+
+&lt;h2&gt;1) Playing&lt;/h2&gt;
+
+&lt;p&gt;I work hard. We all do.&lt;/p&gt;
+
+&lt;p&gt;So when I look out my window and see that the surf is looking particularly good that day, I feel no guilt about taking my board to the beach for a couple of hours.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/staying-sane-working-solo/alex-surfing.jpg&quot; title=&quot;Taking a break&quot; alt=&quot;Taking a break&quot; /&gt;
+&lt;b&gt;Taking a break&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;It’s a welcome release, and doing something I love helps me get out of my “work” head. More often than not, I come back to work refreshed, relaxed and ready to tackle the next big task.&lt;/p&gt;
+
+&lt;h2&gt;2) Walking the Dog&lt;/h2&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/staying-sane-working-solo/the-dog.jpg&quot; title=&quot;The Honey Badger&quot; alt=&quot;The Honey Badger&quot; /&gt;
+&lt;b&gt;The Honey Badger&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Working from home is absolutely NOT a good-enough reason to get a dog (or any pet). Caring for a dog takes a lot of time and effort; everything people say about dog ownership being a big commitment is true.&lt;/p&gt;
+
+&lt;p&gt;But I will say this: having a dog &lt;em&gt;forces&lt;/em&gt; me to take daily breaks that I might not otherwise take, and that’s a very, &lt;em&gt;very&lt;/em&gt; powerful benefit. It gets me out of the house, and while I don’t know if I’d call my leisurely strolls exercise, they certainly make me feel better.&lt;/p&gt;
+
+&lt;h2&gt;3) Team Chat (Not Just for Work)&lt;/h2&gt;
+
+&lt;p&gt;We’re on Slack all day at Groove, and more than 95% of our team’s communication takes place there (with the other 5% being Screenhero and Skype).&lt;/p&gt;
+
+&lt;p&gt;Team chat is a huge asset to any remote team, but what many people don’t talk about is the &lt;em&gt;social&lt;/em&gt; aspect of it. We have the “water cooler” conversations in our Slack room that we’d otherwise use for casual social interaction in an office, and it’s a lot of fun.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/staying-sane-working-solo/the-water-cooler.jpg&quot; title=&quot;The Water Cooler&quot; alt=&quot;The Water Cooler&quot; /&gt;
+&lt;b&gt;The Water Cooler&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;It certainly helps us feel like we’re not always working.&lt;/p&gt;
+
+&lt;h2&gt;4) Having Regular Calls (Even When You Don’t Have To)&lt;/h2&gt;
+
+&lt;p&gt;To me, hearing another person’s voice helps me feel like I’m not the only one in the room.&lt;/p&gt;
+
+&lt;p&gt;And while we have weekly team calls, and I’m almost always on Skype with one or more of our employees every day, sometimes that’s not enough.&lt;/p&gt;
+
+&lt;p&gt;So I schedule calls to connect with other founders and startup folks. It helps me build my network and learn from others, while giving me the benefit of actually &lt;em&gt;connecting&lt;/em&gt; with other people while I sit at home.&lt;/p&gt;
+
+&lt;h2&gt;5) Sleeping Well&lt;/h2&gt;
+
+&lt;p&gt;There’s been so much written about the value of sleep, and anecdotally, there’s no doubt in my mind that when I have a good night’s sleep, I’m happier and more productive than when I don’t.&lt;/p&gt;
+
+&lt;p&gt;I also know that when I spend all evening working, I sleep much worse than when I give myself time to wind down and relax. That’s why I disconnect around 7PM: disabling push notifications on my phone, closing my email client and stopping myself from checking Twitter “just because.”&lt;/p&gt;
+
+&lt;h2&gt;6) Listening to Music&lt;/h2&gt;
+
+&lt;p&gt;There’s hardly a time when I’m working that Pandora isn’t on. Like many people I know, having light background noise helps me focus, and it’s a lot more fun than working in silence.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/staying-sane-working-solo/working-to-music.jpg&quot; title=&quot;Working to music&quot; alt=&quot;Working to music&quot; /&gt;
+&lt;b&gt;Working to music&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Some of my favorite Pandora stations to work to are Van Morrison, Bob Marley, Moby, Kings of Leon, Adele, Avett Brothers, Bruce Springsteen and Bon Iver.&lt;/p&gt;
+
+&lt;h2&gt;7) Standing Desk&lt;/h2&gt;
+
+&lt;p&gt;About two years ago, I switched to working from a standing desk.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/single-founder-loneliness/standingdesk.jpg&quot; title=&quot;Standing Desk&quot; alt=&quot;Standing Desk&quot; /&gt;
+&lt;b&gt;Standing Desk&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Aside from the &lt;a href=&quot;http://www.smithsonianmag.com/science-nature/five-health-benefits-standing-desks-180950259/?no-ist&quot;&gt;health benefits&lt;/a&gt; — which, in fairness, there’s debate over — I find that it simply makes me move more. I’m a lot more likely to pace, or walk to the kitchen for a glass of water, than I would be if I were sitting comfortably. And moving around helps me feel less closed in.&lt;/p&gt;
+
+&lt;h2&gt;8) Sitting Desk&lt;/h2&gt;
+
+&lt;p&gt;As much as I love my standing desk, I also love changing things up.&lt;/p&gt;
+
+&lt;p&gt;Every couple of days, I move my workspace over to the kitchen table.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/staying-sane-working-solo/sitting-desk.jpg&quot; title=&quot;Sitting Desk&quot; alt=&quot;Sitting desk&quot; /&gt;
+&lt;b&gt;Sitting desk&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;The change of scenery stimulates me, and keeps my environment from feeling stale.&lt;/p&gt;
+
+&lt;h2&gt;9) Exercise&lt;/h2&gt;
+
+&lt;p&gt;Just like sleep, the &lt;a href=&quot;https://blog.bufferapp.com/why-exercising-makes-us-happier&quot;&gt;benefits of exercise&lt;/a&gt; have been discussed ad nauseum.&lt;/p&gt;
+
+&lt;p&gt;What I’ve found to be most is to pick something you actually enjoy; if you hate running, why force yourself to run? You’ll be less likely to make it a habit if you don’t look forward to it. You’re better off playing tennis or basketball or doing something else that makes you happy.&lt;/p&gt;
+
+&lt;p&gt;I actually &lt;em&gt;enjoy&lt;/em&gt; running, so that’s usually what I go with.&lt;/p&gt;
+
+&lt;h2&gt;10) Stretching&lt;/h2&gt;
+
+&lt;p&gt;This is probably the simplest, easiest thing I do that helps me stay sane while working from home.&lt;/p&gt;
+
+&lt;p&gt;It’s also probably something that many people at offices feel less than comfortable doing.&lt;/p&gt;
+
+&lt;p&gt;Every hour or so, I step back from my desk and spend five minutes doing &lt;a href=&quot;http://www.mayoclinic.org/healthy-living/adult-health/multimedia/stretching/sls-20076525&quot;&gt;stretches&lt;/a&gt;. I like how it makes my body feel, but it also helps to have something that keeps you from overworking by building breaks into your day.&lt;/p&gt;
+
+&lt;p&gt;&lt;em&gt;I also asked the Groove team for their best working-solo advice, and got some great tips:&lt;/em&gt;&lt;/p&gt;
+
+&lt;h2&gt;11) Playtime With the Cats&lt;/h2&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/staying-sane-working-solo/domino-and-gorilla.jpg&quot; title=&quot;Cats Domino and Gorilla&quot; alt=&quot;Cats Domino and Gorilla&quot; /&gt;
+&lt;b&gt;Cats Domino and Gorilla&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;&lt;em&gt;Mo:&lt;/em&gt; Like Alex’s dog walking, I enjoy spending some quality cuddle time with my own two furry coworkers: Cats Domino and Gorilla. They are the best kind of coworkers in that they don’t distract from getting deep in the work zone when I need to put my head down and crank out tickets, but always remind me when it’s time to take a brain break to chase a string or play fetch with a stuffed mouse (yes, my cats fetch…)&lt;/p&gt;
+
+&lt;h2&gt;12) Meditating&lt;/h2&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/staying-sane-working-solo/meditation-coach.jpg&quot; title=&quot;Len With His Meditation Coach&quot; alt=&quot;Len With His Meditation Coach&quot; /&gt;
+&lt;b&gt;Len With His Meditation Coach&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;&lt;em&gt;Len:&lt;/em&gt; Meditation doesn’t have to be a religious thing or a spiritual thing. For me, it’s just a great way to step back and relax my brain for a few minutes. I use the &lt;a href=&quot;https://www.headspace.com/&quot;&gt;Headspace&lt;/a&gt; app, which has been absolutely amazing; for 10 minutes a day, it teaches you how to meditate in 10 days.&lt;/p&gt;
+
+&lt;h2&gt;13) Family Time&lt;/h2&gt;
+
+&lt;p&gt;&lt;em&gt;Jordan:&lt;/em&gt; With a two-year old son at home, a change of pace is never far away. My breaks usually involve big trucks, blocks, and a giant sock monkey.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/staying-sane-working-solo/family-time.jpg&quot; title=&quot;Family Time&quot; alt=&quot;Family Time&quot; /&gt;
+&lt;b&gt;Family Time&lt;/b&gt;&lt;/p&gt;
+
+&lt;h2&gt;14) Playing a Musical Instrument&lt;/h2&gt;
+
+&lt;p&gt;&lt;em&gt;Chris:&lt;/em&gt; I like to keep my saxophone or a guitar sitting close by for those times when I need to clear my head. The really hard problems require whipping out some early Metallica at full volume, more subtle issues will inspire some John Coltrane on the sax. If it’s a really happy day, the neighbors (the local moose family) &lt;em&gt;[Alex note: Chris lives in the Colorado Rockies]&lt;/em&gt; might be tapping their hooves to &lt;em&gt;Let it Go&lt;/em&gt; from Frozen, even in winter. After all, the cold never bothered me anyway :-)&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/staying-sane-working-solo/jamming.jpg&quot; title=&quot;Jamming&quot; alt=&quot;Jamming&quot; /&gt;
+&lt;b&gt;Jamming&lt;/b&gt;&lt;/p&gt;
+
+&lt;h2&gt;How to Apply This to Your Life&lt;/h2&gt;
+
+&lt;p&gt;Not all of these tips will be interesting or useful to you.&lt;/p&gt;
+
+&lt;p&gt;But it doesn’t take 14 tips to make an impact. Pick 2-3 that you could see yourself doing, and work on making them regular habits.&lt;/p&gt;
+
+&lt;p&gt;Whether you work from home or in an office, I hope this helps you feel better and get through your day in a more productive and positive way.&lt;/p&gt;
+</content>
+<published>2014-08-21T00:00:00+00:00</published>
+<updated>2014-08-21T00:00:00+00:00</updated>
+</entry>
+<entry>
+<title>How Sharing Feature Release Dates Turned Us Into Liars</title>
+<link href='/blog/feature-release-dates' rel='alternate' type='text/html' />
+<id>tag:blog1.groovehq.com,2014-08-13:/blog/feature-release-dates</id>
+<content type='html'>&lt;p&gt;We used to share planned feature release dates with our customers. Here's how that ended up hurting us…&lt;/p&gt;
+
+&lt;p&gt;&lt;em&gt;“I feel like you guys lied to me.”&lt;/em&gt;&lt;/p&gt;
+
+&lt;p&gt;Ouch.&lt;/p&gt;
+
+&lt;p&gt;This one was going to be tough to explain.&lt;/p&gt;
+
+&lt;p&gt;Just two weeks before, a customer had emailed us. He was a new user, and was having a bit of difficulty using Groove. His business had a pretty unique need that our feature set didn’t support… yet.&lt;/p&gt;
+
+&lt;p&gt;But - we were working on a product update at that very moment — an enhancement to our Rich Text Editor — that would solve his problem.&lt;/p&gt;
+
+&lt;p&gt;I was excited to share that with him, so when I heard about his issue, I checked in with our developers about the status of the development.&lt;/p&gt;
+
+&lt;p&gt;We were almost finished, and right on schedule, with the release expected to be ready in a week.&lt;/p&gt;
+
+&lt;p&gt;So that’s what we told the customer.&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/feature-release-dates/dangerous-promise.png&quot; title=&quot;A Dangerous Promise&quot; alt=&quot;A Dangerous Promise&quot; /&gt;
+&lt;b&gt;A Dangerous Promise&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;Experienced product folks are shaking their heads right now, because we &lt;em&gt;know&lt;/em&gt; what happens next.&lt;/p&gt;
+
+&lt;p&gt;A week later, we hit a snag in the final stages of testing and find a series of nasty bugs that render the update too unstable to release.&lt;/p&gt;
+
+&lt;p&gt;Because our small team has to balance that project with the everyday work of maintaining the app, supporting our customers and fixing other critical issues, the bugs take another week and half to diagnose and eliminate.&lt;/p&gt;
+
+&lt;p&gt;And while we kept our concerned customer — and everyone else who had requested the feature — updated, it was clear that the episode didn’t make us look very good.&lt;/p&gt;
+
+&lt;p&gt;In fact, he was right. Even though it wasn’t on purpose, we lied.&lt;/p&gt;
+
+&lt;p&gt;It wasn’t the first time something like this had happened — we should’ve known better — but having a customer call us out so directly was a big learning experience for our whole team, and we certainly haven’t let it happen again.&lt;/p&gt;
+
+&lt;h2&gt;Why We No Longer Share Release Dates With Our Customers&lt;/h2&gt;
+
+&lt;p&gt;This may sound obvious to some, or shady and deceptive to others, but in fact, the opposite is true.&lt;/p&gt;
+
+&lt;p&gt;Let me explain.&lt;/p&gt;
+
+&lt;p&gt;When you share a release date, and it turns out to be wrong, &lt;em&gt;you lose your customers’ trust&lt;/em&gt;.&lt;/p&gt;
+
+&lt;p&gt;As product teams, we should &lt;em&gt;know&lt;/em&gt; that unexpected issues happen quite often, and that planned release dates aren’t always accurate. While we do our best to plan our efforts well and forecast our progress accurately, things don’t always go the way we hope they do.&lt;/p&gt;
+
+&lt;p&gt;So if we promise a delivery date to our customers, even if we hit our milestones more often than not — which we do — just one missed goal turns us into liars.&lt;/p&gt;
+
+&lt;p&gt;So by &lt;em&gt;not&lt;/em&gt; sharing release dates, we’re being more honest — the truth is, &lt;em&gt;we don’t know&lt;/em&gt; exactly when the release will be — than the alternative.&lt;/p&gt;
+
+&lt;p&gt;In business, a customer’s trust is what we work hardest to gain. Once you have it, it’s easy to lose, and incredibly difficult to get back.&lt;/p&gt;
+
+&lt;p&gt;We’re always working to get better at hitting our development milestones, and frankly, we’ve gotten &lt;em&gt;much&lt;/em&gt; better at it.&lt;/p&gt;
+
+&lt;p&gt;Still, we can’t — and won’t — risk letting down our customers by misleading them on our feature roadmap. It’s not just a development issue, but a communications one.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Not sharing release dates may seem dishonest, but it’s not. In our case, we know that we don’t hit our milestones 100% of the time, so we’d rather be honest about not being able to perfectly predict the future, than use our goals to make promises that we may be forced to break.&lt;/p&gt;
+
+&lt;h2&gt;Three Steps We’ve Taken to Solve This Problem&lt;/h2&gt;
+
+&lt;h3&gt;1) No Product Announcements Until the Product Is Ready.&lt;/h3&gt;
+
+&lt;p&gt;This is, by far, the easiest and best way to protect your business from accidentally lying to your customers.&lt;/p&gt;
+
+&lt;p&gt;As startups, we run into &lt;em&gt;a lot&lt;/em&gt; of obstacles. And unfortunately, there’s often a lot of bad news.&lt;/p&gt;
+
+&lt;p&gt;We can’t build everything we want, and we can’t fix everything we want to fix as quickly as every customer wants us to fix it.&lt;/p&gt;
+
+&lt;p&gt;Some days, there’s nothing we want more than to give a frustrated customer good news; to tell them that their issue would be fixed tomorrow, or next week.&lt;/p&gt;
+
+&lt;p&gt;It’s tempting, but it’s simply too risky. That’s why we’ve decided to &lt;em&gt;never&lt;/em&gt; announce new features until they’re staged and functioning well enough to release to our customers.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; As tempting as it is, don’t announce &lt;em&gt;anything&lt;/em&gt; until it’s ready. This one simple rule can guarantee that you’ll never lie to your customers about release dates.&lt;/p&gt;
+
+&lt;h3&gt;2) Only Give Customers Info You Know to Be 100% True.&lt;/h3&gt;
+
+&lt;p&gt;While we won’t give release dates, we &lt;em&gt;are&lt;/em&gt; honest and transparent about what we’re working on.&lt;/p&gt;
+
+&lt;p&gt;We publish frequent development updates on our &lt;a href=&quot;http://www.groovehq.com/better&quot;&gt;Better blog&lt;/a&gt;, and we do our best to communicate to customers that we’re working hard to solve their issues, even if we can’t give them a specific time that it’ll be fixed.&lt;/p&gt;
+
+&lt;p&gt;As an example, this is what we recently told a customer who’s running into a problem that’ll be solved by a feature currently in development:&lt;/p&gt;
+
+&lt;p&gt;&lt;img src=&quot;/attachments/blog/feature-release-dates/new-approach.png&quot; title=&quot;A New Approach&quot; alt=&quot;A New Approach&quot; /&gt;
+&lt;b&gt;A New Approach&lt;/b&gt;&lt;/p&gt;
+
+&lt;p&gt;I have no doubt that this approach costs us some customers with critical issues who are on their way out the door.&lt;/p&gt;
+
+&lt;p&gt;And while there’s nothing I hate more than having a customer leave — it feels like a punch in the gut, and it never, ever, ever gets easier — I’d rather lose them (and potentially have them come back when we can better solve their problem) than lose their trust and business forever.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; Not sharing release dates doesn’t mean that you can’t — and shouldn’t — be completely honest and upfront about what your development team is working on. You should still let customers know that you’re working hard to help them.&lt;/p&gt;
+
+&lt;h3&gt;3) Better Communications Between Development and Support.&lt;/h3&gt;
+
+&lt;p&gt;We’ve always focused on communication. As a remote team, you &lt;em&gt;have to&lt;/em&gt; if you want to have any hope of success.&lt;/p&gt;
+
+&lt;p&gt;But in this instance, there was a specific communication gap that we needed to fill to solve this problem.&lt;/p&gt;
+
+&lt;p&gt;On our weekly team calls, we’ve started diving deeper into the development roadmap — not just that week’s to-do’s, but how the future roadmap looks, and whether or not it’s changed from the week before — so that our whole team has a better understanding of the features we’re working on and releasing.&lt;/p&gt;
+
+&lt;p&gt;And Mo, our head of customer support, has become &lt;em&gt;very&lt;/em&gt; involved in our development roadmap, spending quite a bit of time logging issues in Pivotal Tracker so that the dev team always knows where the biggest customer pain points and opportunities are. We recently shared that &lt;a href=&quot;http://www.groovehq.com/blog/managing-bugs-and-feature-requests&quot;&gt;workflow&lt;/a&gt; on this blog.&lt;/p&gt;
+
+&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; This isn’t just a &lt;em&gt;customer&lt;/em&gt; communication issue, but a &lt;em&gt;team&lt;/em&gt; communication issue, too. Make sure that your developers and support team are on the same page and supporting one another to help your customers in the most thorough way they can.&lt;/p&gt;
+
+&lt;h2&gt;How to Apply This to Your Business&lt;/h2&gt;
+
+&lt;p&gt;If you hit your development milestones 100% of the time with zero unexpected delays, and know for a fact that you’ll continue to do so forever, then you probably don’t need this advice.&lt;/p&gt;
+
+&lt;p&gt;But unfortunately, for most startups and small businesses, this simply isn’t the reality.&lt;/p&gt;
+
+&lt;p&gt;It can be tempting to try and keep a customer happy by promising them a solution by a certain date, but &lt;em&gt;don’t do it&lt;/em&gt;.&lt;/p&gt;
+
+&lt;p&gt;If you turn out to be right, the customer is pleased.&lt;/p&gt;
+
+&lt;p&gt;If you turn out to be wrong, you may lose their trust forever.&lt;/p&gt;
+
+&lt;p&gt;As obvious as it seems, it’s an issue that’ve been battling and we were finally forced to face. I’m glad we did, and I hope that our experience helps you do the same.&lt;/p&gt;
+</content>
+<published>2014-08-13T00:00:00+00:00</published>
+<updated>2014-08-13T00:00:00+00:00</updated>
+</entry>
+</feed>
diff --git a/3rdparty/fguillot/picofeed/tests/fixtures/womensweardaily.xml b/3rdparty/fguillot/picofeed/tests/fixtures/womensweardaily.xml
new file mode 100644
index 000000000..70208c27b
--- /dev/null
+++ b/3rdparty/fguillot/picofeed/tests/fixtures/womensweardaily.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0"><channel><description>The WWD Official Tumblr Page!
+
+Women’s Wear Daily (WWD) is the daily media of record for senior executives in the global women’s and men’s fashion, retail and beauty communities and the consumer media that cover the market.
+
+WWD On:
+
+Facebook
+www.facebook.com/womensweardaily
+
+Twitter
+www.twitter.com/womensweardaily
+www.twitter.com/wwdmarketplace
+
+YouTube
+www.youtube.com/wwd</description><title>http://womensweardaily.tumblr.com/</title><generator>Tumblr (3.0; @womensweardaily)</generator><link>http://womensweardaily.tumblr.com/</link><item><title>Sue Wong RTW Spring 2015
+The designer’s spring collection...</title><description>&lt;img src="http://31.media.tumblr.com/cd505251ce02d3de1c603f560e7d9b69/tumblr_ndywlqVRKT1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/runway/spring-ready-to-wear-2015/review/sue-wong&gt;?src=tumblr"&gt;Sue Wong RTW Spring 2015&lt;/a&gt;&lt;/h1&gt;
+&lt;p&gt;The designer’s spring collection bore the influence of her infatuation with Art Deco and old Hollywood. &lt;strong&gt;&lt;a href="http://www.wwd.com/runway/spring-ready-to-wear-2015/review/sue-wong&gt;?src=tumblr"&gt;For More&lt;/a&gt;&lt;br/&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-features/thats-totally-fine-by-rose-la-grua-rtw-spring-2015-7980675" data-ls-seen="1"&gt;&lt;br/&gt;&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100993521734</link><guid>http://womensweardaily.tumblr.com/post/100993521734</guid><pubDate>Sun, 26 Oct 2014 10:00:16 -0400</pubDate><category>Sue Wong</category><category>RTW Spring 2015</category><category>Fashion</category><category>LAFW</category></item><item><title>TAW: Shanghai Fashion Week
+Photo by Dave Tacon</title><description>&lt;img src="http://38.media.tumblr.com/68973faac180b432aba4f2686c3dc4ff/tumblr_ndyffbM8HW1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/they-are-wearing/they-are-wearing-shanghai-fashion-week-7999283/slideshow?src=tumblr"&gt;TAW: Shanghai Fashion Week&lt;/a&gt;&lt;/h1&gt;
+&lt;h4 id="slide-credit"&gt;&lt;em&gt;Photo by Dave Tacon&lt;/em&gt;&lt;/h4&gt;</description><link>http://womensweardaily.tumblr.com/post/100990373888</link><guid>http://womensweardaily.tumblr.com/post/100990373888</guid><pubDate>Sun, 26 Oct 2014 09:00:13 -0400</pubDate><category>Shanghai Fashion Week</category><category>Fashion</category><category>Street Style</category><category>They Are Wearing</category></item><item><title>Frances Caine RTW Spring 2015
+Husband-and-wife hipster duo...</title><description>&lt;img src="http://31.media.tumblr.com/028449c66357f16100397819e9255c87/tumblr_ndyn1kEO5Q1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-features/frances-caine-rtw-spring-2015-7999202?src=tumblr"&gt;Frances Caine RTW Spring 2015&lt;/a&gt;&lt;/h1&gt;
+&lt;p&gt;Husband-and-wife hipster duo Travis Caine and Katherine Kin are not just design partners, they also have a band called Von Haze.  &lt;a href="http://www.wwd.com/fashion-news/fashion-features/frances-caine-rtw-spring-2015-7999202?src=tumblr"&gt;&lt;strong&gt;For More&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100980752157</link><guid>http://womensweardaily.tumblr.com/post/100980752157</guid><pubDate>Sun, 26 Oct 2014 05:00:09 -0400</pubDate><category>Frances Caine</category><category>RTW Spring 2015</category><category>Fashion</category><category>LAFW</category></item><item><title>They Are Wearing: Frieze London
+Photo by Marcus Dawes</title><description>&lt;img src="http://33.media.tumblr.com/15580b6192ae0158914c25b9d7d796fd/tumblr_ndwvz5Nea41qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/they-are-wearing/they-are-wearing-frieze-london-7994824/slideshow?src=tumblr"&gt;They Are Wearing: Frieze London&lt;/a&gt;&lt;/h1&gt;
+&lt;h4 id="slide-credit"&gt;Photo by Marcus Dawes&lt;/h4&gt;</description><link>http://womensweardaily.tumblr.com/post/100975969488</link><guid>http://womensweardaily.tumblr.com/post/100975969488</guid><pubDate>Sun, 26 Oct 2014 03:00:08 -0400</pubDate><category>They Are Wearing</category><category>Frieze London</category><category>Fashion</category><category>Street Style</category></item><item><title>Margot Robbie at FGI’s Night of Stars</title><description>&lt;img src="http://33.media.tumblr.com/b7bf4c8890e61772235c7f9ab226f931/tumblr_ndyg9bBFTL1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h3 id="slide-caption"&gt;&lt;a href="http://www.wwd.com/eye/parties/fashion-group-international-night-of-stars-honors-dvf-peter-copping-8000144/slideshow?src=tumblr"&gt;Margot Robbie at FGI’s Night of Stars&lt;/a&gt;&lt;/h3&gt;</description><link>http://womensweardaily.tumblr.com/post/100973079335</link><guid>http://womensweardaily.tumblr.com/post/100973079335</guid><pubDate>Sun, 26 Oct 2014 02:00:08 -0400</pubDate><category>Margot Robbie</category><category>FGI's Night of Stars</category><category>Fashion</category><category>celebs</category></item><item><title>CM2K by Cheryl Koo RTW Spring 2015
+In yet another...</title><description>&lt;img src="http://33.media.tumblr.com/23fc081fdb1d29020833e6f5a8795d15/tumblr_ndymlzJbE21qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short"&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-features/cm2k-by-cheryl-koo-rtw-spring-2015-7999205?src=tumblr"&gt;CM2K by Cheryl Koo RTW Spring 2015&lt;/a&gt;&lt;/h1&gt;
+&lt;p&gt;In yet another performance-art presentation, a perennial trend on the L.A. Fashion Week circuit, Cheryl Koo used professional dancers as models. &lt;a href="http://www.wwd.com/fashion-news/fashion-features/cm2k-by-cheryl-koo-rtw-spring-2015-7999205?src=tumblr"&gt;&lt;strong&gt;For More&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100969643522</link><guid>http://womensweardaily.tumblr.com/post/100969643522</guid><pubDate>Sun, 26 Oct 2014 01:00:08 -0400</pubDate><category>CM2K by Cheryl Koo</category><category>RTW Spring 2015</category><category>Fashion</category><category>LAFW</category></item><item><title>Kinsman RTW Spring 2015
+Joanna Kinsman got wild with her...</title><description>&lt;img src="http://33.media.tumblr.com/fe3cb231f47409252dbc68b738909d5d/tumblr_ndynaoRjDm1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-features/kinsman-rtw-spring-2015-7994087?src=tumblr"&gt;Kinsman RTW Spring 2015&lt;/a&gt;&lt;/h1&gt;
+&lt;p&gt;Joanna Kinsman got wild with her Brazilian-cut bikinis done in fur, shearling, a dark-pink animal print and embossed leather.  &lt;a href="http://www.wwd.com/fashion-news/fashion-features/kinsman-rtw-spring-2015-7994087?src=tumblr"&gt;&lt;strong&gt;For More&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100957532620</link><guid>http://womensweardaily.tumblr.com/post/100957532620</guid><pubDate>Sat, 25 Oct 2014 22:00:06 -0400</pubDate><category>Kinsman</category><category>RTW Spring 2015</category><category>Fashion</category><category>LAFW</category></item><item><title>They Are Wearing: Frieze London
+Photo by Marcus Dawes</title><description>&lt;img src="http://33.media.tumblr.com/f3117347a369634ad5cc9055c727e4d1/tumblr_nduzowYGxf1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/they-are-wearing/they-are-wearing-frieze-london-7994824/slideshow?src=tumblr"&gt;They Are Wearing: Frieze London&lt;/a&gt;&lt;/h1&gt;
+&lt;h4 id="slide-credit"&gt;Photo by Marcus Dawes&lt;/h4&gt;</description><link>http://womensweardaily.tumblr.com/post/100953624966</link><guid>http://womensweardaily.tumblr.com/post/100953624966</guid><pubDate>Sat, 25 Oct 2014 21:00:09 -0400</pubDate><category>They Are Wearing</category><category>Frieze London</category><category>Fashion</category><category>Street Style</category></item><item><title>Spring 2015 Trend: Do the Shag
+Photo by Isa Wipfli
+Designers...</title><description>&lt;img src="http://33.media.tumblr.com/d79b278d0555f8fc64b9b5261166638c/tumblr_ndwk9s0iwk1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/trends/spring-2015-trend-do-the-shag-7996294/slideshow?src=tumblr"&gt;Spring 2015 Trend: Do the Shag&lt;/a&gt;&lt;/h1&gt;
+&lt;h4 id="slide-credit"&gt;&lt;em&gt;Photo by Isa Wipfli&lt;/em&gt;&lt;/h4&gt;
+&lt;p&gt;&lt;span class="mandelbrot_refrag"&gt;&lt;span class="mandelbrot_refrag"&gt;Designers&lt;/span&gt;&lt;/span&gt; toughened up the Sixties groove for spring with hardware details on dresses, graphic textures and a decidedly rock-star attitude. Here, Faith Connexion’s leather jacket and AllSaints’ cotton jeans.&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100949761246</link><guid>http://womensweardaily.tumblr.com/post/100949761246</guid><pubDate>Sat, 25 Oct 2014 20:00:08 -0400</pubDate><category>Spring 2015</category><category>Trend</category><category>Sixties</category><category>Faith Connexion</category><category>AllSaints</category><category>Fashion</category></item><item><title>Aeneas Erlking RTW Spring 2015
+Designer Aeneas Zhou Erlking...</title><description>&lt;img src="http://33.media.tumblr.com/020ff3752be38bdfa63735f9c0047eae/tumblr_ndylioeQfp1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-features/aeneas-erlking-rtw-spring-2015-7999207?src=tumblr"&gt;Aeneas Erlking RTW Spring 2015&lt;/a&gt;&lt;/h1&gt;
+&lt;p&gt;Designer Aeneas Zhou Erlking showed 11 looks for resort 2015 in a collection called “Pretty In Punk.” &lt;a href="http://www.wwd.com/fashion-news/fashion-features/aeneas-erlking-rtw-spring-2015-7999207?src=tumblr"&gt;&lt;strong&gt;For More&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100941659863</link><guid>http://womensweardaily.tumblr.com/post/100941659863</guid><pubDate>Sat, 25 Oct 2014 18:00:06 -0400</pubDate><category>Aeneas Erlking</category><category>RTW Spring 2015</category><category>Fashion</category><category>LAFW</category></item><item><title>TAW: Shanghai Fashion Week
+Photo by Dave Tacon</title><description>&lt;img src="http://38.media.tumblr.com/5d5f621a28afd229173c0edc666c8007/tumblr_ndyfiaTdGJ1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/they-are-wearing/they-are-wearing-shanghai-fashion-week-7999283/slideshow?src=tumblr"&gt;TAW: Shanghai Fashion Week&lt;/a&gt;&lt;/h1&gt;
+&lt;h4 id="slide-credit"&gt;&lt;em&gt;Photo by Dave Tacon&lt;/em&gt;&lt;/h4&gt;</description><link>http://womensweardaily.tumblr.com/post/100937329296</link><guid>http://womensweardaily.tumblr.com/post/100937329296</guid><pubDate>Sat, 25 Oct 2014 17:00:08 -0400</pubDate><category>Shanghai Fashion Week</category><category>Fashion</category><category>They Are Wearing</category><category>Street Style</category></item><item><title>Skintone RTW Spring 2015
+Using only raw hand-woven cotton in...</title><description>&lt;img src="http://31.media.tumblr.com/273113c485118fbfc7ef9ae8b7ec3c9b/tumblr_ndyplgAkeS1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-features/skintone-rtw-spring-2015-7991646?src=tumblr"&gt;Skintone RTW Spring 2015&lt;/a&gt;&lt;/h1&gt;
+&lt;p&gt;Using only raw hand-woven cotton in ivory, this line of casual tank tops, sundresses and drawstring-waist skirts appeared comfortable but bland.  &lt;a href="http://www.wwd.com/fashion-news/fashion-features/skintone-rtw-spring-2015-7991646?src=tumblr"&gt;&lt;strong&gt;For More&lt;br/&gt;&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100932937084</link><guid>http://womensweardaily.tumblr.com/post/100932937084</guid><pubDate>Sat, 25 Oct 2014 16:00:39 -0400</pubDate><category>Skintone</category><category>RTW Spring 2015</category><category>Fashion</category><category>LAFW</category></item><item><title>They Are Wearing: Frieze London
+Photo by Marcus Dawes</title><description>&lt;img src="http://38.media.tumblr.com/39cdfb9cff640d24c9b76802346b8fe9/tumblr_ndww24360a1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/they-are-wearing/they-are-wearing-frieze-london-7994824/slideshow?src=tumblr"&gt;They Are Wearing: Frieze London&lt;/a&gt;&lt;/h1&gt;
+&lt;h4 id="slide-credit"&gt;Photo by Marcus Dawes&lt;/h4&gt;</description><link>http://womensweardaily.tumblr.com/post/100928590055</link><guid>http://womensweardaily.tumblr.com/post/100928590055</guid><pubDate>Sat, 25 Oct 2014 15:00:30 -0400</pubDate><category>They Are Wearing</category><category>Frieze London</category><category>Fashion</category><category>Street Style</category></item><item><title>A look from the Zara Terez
+Collection: Candy Crush.
+Courtesy...</title><description>&lt;img src="http://33.media.tumblr.com/3c48ac34c36431a0ce7735cf86141344/tumblr_ndr6g0i2vc1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 id="slide-caption"&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-scoops/candy-crush-teams-up-with-zara-terez-7991211?src=tumblr"&gt;A look from the Zara Terez &lt;/a&gt;&lt;/h1&gt;
+&lt;h1&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-scoops/candy-crush-teams-up-with-zara-terez-7991211?src=tumblr"&gt;Collection: Candy Crush.&lt;/a&gt;&lt;/h1&gt;
+&lt;h4 id="slide-credit"&gt;&lt;em&gt;Courtesy Photo&lt;/em&gt;&lt;/h4&gt;</description><link>http://womensweardaily.tumblr.com/post/100924265060</link><guid>http://womensweardaily.tumblr.com/post/100924265060</guid><pubDate>Sat, 25 Oct 2014 14:00:30 -0400</pubDate><category>Zara Terez Collection: Candy Crush</category><category>Zara Terez</category><category>Candy Crush</category><category>Fashion</category></item><item><title>Altaf Maaneshia RTW Spring 2015
+The designer returned to his...</title><description>&lt;img src="http://31.media.tumblr.com/3fe391d7e0c06dc9ab376bf07e660a8b/tumblr_ndylygFlHw1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-features/altaf-maaneshia-rtw-spring-2015-7994092?src=tumblr"&gt;Altaf Maaneshia RTW Spring 2015&lt;/a&gt;&lt;/h1&gt;
+&lt;p&gt;The designer returned to his favorite Forties-inspired dresses marked by extreme shoulders and nipped waists. &lt;a href="http://www.wwd.com/fashion-news/fashion-features/altaf-maaneshia-rtw-spring-2015-7994092?src=tumblr"&gt;&lt;strong&gt;For More&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100919982150</link><guid>http://womensweardaily.tumblr.com/post/100919982150</guid><pubDate>Sat, 25 Oct 2014 13:00:29 -0400</pubDate><category>Altaf Maaneshia</category><category>RTW Spring 2015</category><category>Fashion</category><category>LAFW</category></item><item><title>Yirantian Guo RTW Spring 2015
+Courtesy Photo
+The...</title><description>&lt;img src="http://33.media.tumblr.com/099f60f25d240211b9a6feeffc93aa5b/tumblr_ndyjh287br1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-features/yirantian-guo-rtw-spring-2015-7999476?src=tumblr"&gt;Yirantian Guo RTW Spring 2015&lt;/a&gt;&lt;/h1&gt;
+&lt;h4 id="slide-credit"&gt;&lt;em&gt;Courtesy Photo&lt;/em&gt;&lt;/h4&gt;
+&lt;p&gt;The designer’s deconstructed, modernist style has been on the radar of China fashion watchers since she launched her own collection back in 2012. &lt;a href="http://www.wwd.com/fashion-news/fashion-features/yirantian-guo-rtw-spring-2015-7999476?src=tumblr"&gt;&lt;strong&gt;For More&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100915824047</link><guid>http://womensweardaily.tumblr.com/post/100915824047</guid><pubDate>Sat, 25 Oct 2014 12:00:31 -0400</pubDate><category>Yirantian Guo</category><category>RTW Spring 2015</category><category>Fashion</category><category>Shanghai Fashion Week</category></item><item><title>Rosie Huntington-Whiteley’s on a Roll
+Photo by Donato...</title><description>&lt;img src="http://33.media.tumblr.com/cb124dc307258a0ba44a4a1cdb59b0b1/tumblr_ndyi6tQuZS1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-scoops/rosie-on-a-roll-8000063/slideshow?src=tumblr"&gt;Rosie Huntington-Whiteley’s on a Roll&lt;/a&gt;&lt;/h1&gt;
+&lt;h4 id="slide-credit"&gt;&lt;em&gt;Photo by Donato Sardella/Getty Images/Courtesy Photo&lt;/em&gt;&lt;/h4&gt;
+&lt;p&gt;Huntington-Whiteley hosted a dinner at the Sunset Tower Hotel with e-commerce site Forward by Elyse Walker. &lt;strong&gt;Liberty Ross&lt;/strong&gt;, &lt;strong&gt;Abbey Lee&lt;/strong&gt; &lt;strong&gt;Kershaw&lt;/strong&gt; and &lt;strong&gt;Cher Coulter&lt;/strong&gt; were among the guests who came to celebrate designer &lt;strong&gt;Anthony Vaccarello&lt;/strong&gt;, whose label retails on the site. &lt;a href="http://www.wwd.com/fashion-news/fashion-scoops/rosie-on-a-roll-8000063/slideshow?src=tumblr"&gt;&lt;strong&gt;For More&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100911850898</link><guid>http://womensweardaily.tumblr.com/post/100911850898</guid><pubDate>Sat, 25 Oct 2014 11:00:28 -0400</pubDate><category>Rosie Huntington-Whiteley</category><category>Anthony Vaccarello</category><category>Abbey Lee Kershaw</category><category>Fashion</category><category>celebs</category><category>Forward by Elyse Walker</category><category>Sunset Tower Hotel</category></item><item><title>Dar Sara RTW Spring 2015
+Ballerinas by way of Bollywood informed...</title><description>&lt;img src="http://38.media.tumblr.com/6b314d36441c4a7c74722d63e4ee6852/tumblr_ndymudmmfW1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/fashion-features/dar-sara-rtw-spring-2015-7991801?src=tumblr"&gt;Dar Sara RTW Spring 2015&lt;/a&gt;&lt;/h1&gt;
+&lt;p&gt;Ballerinas by way of Bollywood informed Dar Sara Fashion’s inventive spring lineup. &lt;a href="http://www.wwd.com/fashion-news/fashion-features/dar-sara-rtw-spring-2015-7991801?src=tumblr"&gt;&lt;strong&gt;For More&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100908254264</link><guid>http://womensweardaily.tumblr.com/post/100908254264</guid><pubDate>Sat, 25 Oct 2014 10:00:27 -0400</pubDate><category>Dar Sara</category><category>RTW Spring 2015</category><category>Fashion</category><category>LAFW</category></item><item><title>TAW: Shanghai Fashion Week
+Photo by Dave Tacon</title><description>&lt;img src="http://38.media.tumblr.com/e81cf4f010a35b94cd0dea8f67f45ff0/tumblr_ndyfddFKBJ1qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/they-are-wearing/they-are-wearing-shanghai-fashion-week-7999283/slideshow?src=tumblr"&gt;TAW: Shanghai Fashion Week&lt;/a&gt;&lt;/h1&gt;
+&lt;h4 id="slide-credit"&gt;&lt;em&gt;Photo by Dave Tacon&lt;/em&gt;&lt;/h4&gt;</description><link>http://womensweardaily.tumblr.com/post/100905126460</link><guid>http://womensweardaily.tumblr.com/post/100905126460</guid><pubDate>Sat, 25 Oct 2014 09:00:23 -0400</pubDate><category>Shanghai Fashion Week</category><category>Fashion</category><category>Street Style</category><category>They Are Wearing</category></item><item><title>Spring 2015 Denim Trend: A Denim
+ Love Story
+Calvin...</title><description>&lt;img src="http://38.media.tumblr.com/278ab96e4584d645e9c0402ee7d3fc4e/tumblr_ndwnd6Lb221qa7p1yo1_500.jpg"/&gt;&lt;br/&gt;&lt;br/&gt;&lt;h1 class="window-wide title-short" id="slideshow-title"&gt;&lt;a href="http://www.wwd.com/fashion-news/trends/spring-2015-denim-trend-a-denim-love-story-7993360/slideshow?src=tumblr"&gt;Spring 2015 Denim Trend: A Denim&lt;/a&gt;&lt;/h1&gt;
+&lt;h1 class="window-wide title-short"&gt;&lt;a href="http://www.wwd.com/fashion-news/trends/spring-2015-denim-trend-a-denim-love-story-7993360/slideshow?src=tumblr"&gt; Love Story&lt;/a&gt;&lt;/h1&gt;
+&lt;p&gt;Calvin Rucker’s polyester crinkled peasant top; MiH’s cotton denim skirt. Prima Donna leather fringe bag.&lt;/p&gt;</description><link>http://womensweardaily.tumblr.com/post/100902488286</link><guid>http://womensweardaily.tumblr.com/post/100902488286</guid><pubDate>Sat, 25 Oct 2014 08:00:46 -0400</pubDate><category>Spring 2015</category><category>Denim</category><category>Trend</category><category>Calvin Rucker</category><category>MiH</category><category>Prima Donna</category><category>Fashion</category><category>seventies</category></item></channel></rss>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d8e7b111..9e45d2572 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@ owncloud-news (4.0.0)
* **Backwards incompatible change**: Get rid of cacheCuration setting
* **Backwards incompatible change**: Use three numbers for versioning because core bug with versions seems fixed
* **Enhancement**: Add maxRedirects setting in config.ini
+* **Enhancement**: Add maxSize setting in config.ini
* **Enhancement**: Get rid SimplePie feed parser library and switch to PicoFeed because SimplePie is unmaintained and full of bugs
* **Enhancement**: Faster feed updates due to proper HTTP cache headers thanks to picoFeed
* **Enhancement**: Use ownCloud internal proxy settings
diff --git a/README.md b/README.md
index 6bf44450c..959a0a7e7 100644
--- a/README.md
+++ b/README.md
@@ -180,6 +180,7 @@ useCronUpdates = true
* **autoPurgeMinimumInterval**: Minimum amount of seconds after deleted feeds and folders are removed from the database. Values below 60 seconds are ignored
* **autoPurgeCount**: Defines the minimum amount of articles that can be unread per feed before they get deleted, a negative value will turn off deleting articles completely
* **maxRedirects**: How many redirects the updater should follow
+* **maxSize**: Maximum feed size in bytes. If the RSS/Atom page is bigger than this value, the update will be aborted
* **feedFetcherTimeout**: Maximum number of seconds to wait for an RSS or Atom feed to load. If a feed takes longer than that number of seconds to update, the update will be aborted
* **useCronUpdates**: To use a custom update/cron script you need to disable the cronjob which is run by ownCloud by default by setting this to false
diff --git a/appinfo/application.php b/appinfo/application.php
index af9b93a09..8716feac9 100644
--- a/appinfo/application.php
+++ b/appinfo/application.php
@@ -15,7 +15,8 @@ namespace OCA\News\AppInfo;
require_once __DIR__ . '/autoload.php';
-use \PicoFeed\Config as PicoFeedConfig;
+use \PicoFeed\Config\Config as PicoFeedConfig;
+use \PicoFeed\Reader\Reader as PicoFeedReader;
use \OC\Files\View;
use \OCP\AppFramework\App;
@@ -48,7 +49,6 @@ use \OCA\News\Db\MapperFactory;
use \OCA\News\Utility\OPMLExporter;
use \OCA\News\Utility\Updater;
use \OCA\News\Utility\PicoFeedClientFactory;
-use \OCA\News\Utility\PicoFeedReaderFactory;
use \OCA\News\Utility\PicoFeedFaviconFactory;
use \OCA\News\Utility\ProxyConfigParser;
@@ -421,6 +421,7 @@ class Application extends App {
$pico->setClientUserAgent($userAgent)
->setClientTimeout($config->getFeedFetcherTimeout())
->setMaxRedirections($config->getMaxRedirects())
+ ->setMaxBodySize($config->getMaxSize())
->setContentFiltering(false)
->setParserHashAlgo('md5');
@@ -447,8 +448,8 @@ class Application extends App {
return $pico;
});
- $container->registerService('PicoFeedReaderFactory', function($c) {
- return new PicoFeedReaderFactory($c->query('PicoFeedConfig'));
+ $container->registerService('PicoFeedReader', function($c) {
+ return new PicoFeedReader($c->query('PicoFeedConfig'));
});
$container->registerService('PicoFeedClientFactory', function($c) {
@@ -471,8 +472,9 @@ class Application extends App {
$container->registerService('FeedFetcher', function($c) {
return new FeedFetcher(
- $c->query('PicoFeedReaderFactory'),
+ $c->query('PicoFeedReader'),
$c->query('PicoFeedFaviconFactory'),
+ $c->query('L10N'),
$c->query('TimeFactory')
);
});
diff --git a/composer.json b/composer.json
index 2f959058f..7574cae15 100644
--- a/composer.json
+++ b/composer.json
@@ -35,6 +35,6 @@
"require": {
"pear/net_url2": "~2.1",
"ezyang/htmlpurifier": "~4.6",
- "fguillot/picofeed": "dev-master"
+ "fguillot/picofeed": "0.1.0-dev-dev"
}
}
diff --git a/composer.lock b/composer.lock
index d37e3f641..ce730f2ab 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
- "hash": "99effcbef78c8f71c708a44f2a312812",
+ "hash": "a50b15fc49d316cb5b2db3e9e7ea78b3",
"packages": [
{
"name": "ezyang/htmlpurifier",
@@ -53,16 +53,16 @@
},
{
"name": "fguillot/picofeed",
- "version": "dev-master",
+ "version": "dev-0.1.0-dev",
"source": {
"type": "git",
"url": "https://github.com/fguillot/picoFeed.git",
- "reference": "dd5c122aea0a95ec2c932ee487a8fb4fd307cc6f"
+ "reference": "e7e32522b487256c3164eeece30203313b09456a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/fguillot/picoFeed/zipball/dd5c122aea0a95ec2c932ee487a8fb4fd307cc6f",
- "reference": "dd5c122aea0a95ec2c932ee487a8fb4fd307cc6f",
+ "url": "https://api.github.com/repos/fguillot/picoFeed/zipball/e7e32522b487256c3164eeece30203313b09456a",
+ "reference": "e7e32522b487256c3164eeece30203313b09456a",
"shasum": ""
},
"require": {
@@ -86,7 +86,7 @@
],
"description": "Modern library to write or read feeds (RSS/Atom)",
"homepage": "http://fguillot.github.io/picoFeed",
- "time": "2014-10-19 18:18:06"
+ "time": "2014-11-05 01:21:29"
},
{
"name": "pear/net_url2",
diff --git a/config/config.php b/config/config.php
index 8b11f0bd9..9cdfa1b0c 100644
--- a/config/config.php
+++ b/config/config.php
@@ -29,6 +29,7 @@ class Config {
private $useCronUpdates; // turn off updates run by owncloud cronjob
private $logger;
private $loggerParams;
+ private $maxSize;
public function __construct($fileSystem, ILogger $logger, $loggerParams) {
@@ -36,6 +37,7 @@ class Config {
$this->autoPurgeMinimumInterval = 60;
$this->autoPurgeCount = 200;
$this->maxRedirects = 10;
+ $this->maxSize = 100*1024*1024; // 100Mb
$this->feedFetcherTimeout = 60;
$this->useCronUpdates = true;
$this->logger = $logger;
@@ -99,6 +101,11 @@ class Config {
}
+ public function getMaxSize() {
+ return $this->maxSize;
+ }
+
+
public function setAutoPurgeMinimumInterval($value) {
$this->autoPurgeMinimumInterval = $value;
}
@@ -128,19 +135,27 @@ class Config {
$this->proxyPort = $value;
}
+
public function setProxyHost($value) {
$this->proxyHost = $value;
}
+
public function setProxyUser($value) {
$this->proxyUser = $value;
}
+
public function setProxyPassword($value) {
$this->proxyPassword = $value;
}
+ public function setMaxSize($value) {
+ $this->maxSize = $value;
+ }
+
+
public function read($configPath, $createIfNotExists=false) {
if($createIfNotExists && !$this->fileSystem->file_exists($configPath)) {
@@ -185,6 +200,8 @@ class Config {
$this->autoPurgeCount . "\n" .
'maxRedirects = ' .
$this->maxRedirects . "\n" .
+ 'maxSize = ' .
+ $this->maxSize . "\n" .
'feedFetcherTimeout = ' .
$this->feedFetcherTimeout . "\n" .
'useCronUpdates = ' .
diff --git a/controller/admincontroller.php b/controller/admincontroller.php
index e421c44e3..379d83770 100644
--- a/controller/admincontroller.php
+++ b/controller/admincontroller.php
@@ -41,6 +41,7 @@ class AdminController extends Controller {
'maxRedirects' => $this->config->getMaxRedirects(),
'feedFetcherTimeout' => $this->config->getFeedFetcherTimeout(),
'useCronUpdates' => $this->config->getUseCronUpdates(),
+ 'maxSize' => $this->config->getMaxSize(),
];
return new TemplateResponse($this->appName, 'admin', $data, 'blank');
}
@@ -51,15 +52,17 @@ class AdminController extends Controller {
* @param int $autoPurgeCount
* @param int $maxRedirects
* @param int $feedFetcherTimeout
+ * @param int $maxSize
* @param bool $useCronUpdates
* @return array with the updated values
*/
public function update($autoPurgeMinimumInterval, $autoPurgeCount,
- $maxRedirects, $feedFetcherTimeout,
+ $maxRedirects, $feedFetcherTimeout, $maxSize,
$useCronUpdates) {
$this->config->setAutoPurgeMinimumInterval($autoPurgeMinimumInterval);
$this->config->setAutoPurgeCount($autoPurgeCount);
$this->config->setMaxRedirects($maxRedirects);
+ $this->config->setMaxSize($maxSize);
$this->config->setFeedFetcherTimeout($feedFetcherTimeout);
$this->config->setUseCronUpdates($useCronUpdates);
$this->config->write($this->configPath);
@@ -69,6 +72,7 @@ class AdminController extends Controller {
$this->config->getAutoPurgeMinimumInterval(),
'autoPurgeCount' => $this->config->getAutoPurgeCount(),
'maxRedirects' => $this->config->getMaxRedirects(),
+ 'maxSize' => $this->config->getMaxSize(),
'feedFetcherTimeout' => $this->config->getFeedFetcherTimeout(),
'useCronUpdates' => $this->config->getUseCronUpdates(),
];
diff --git a/fetcher/feedfetcher.php b/fetcher/feedfetcher.php
index 733506187..d59761b26 100644
--- a/fetcher/feedfetcher.php
+++ b/fetcher/feedfetcher.php
@@ -13,6 +13,18 @@
namespace OCA\News\Fetcher;
+use \PicoFeed\Parser\MalFormedXmlException;
+use \PicoFeed\Reader\Reader;
+use \PicoFeed\Reader\SubscriptionNotFoundException;
+use \PicoFeed\Reader\UnsupportedFeedFormatException;
+use \PicoFeed\Client\InvalidCertificateException;
+use \PicoFeed\Client\InvalidUrlException;
+use \PicoFeed\Client\MaxRedirectException;
+use \PicoFeed\Client\MaxSizeException;
+use \PicoFeed\Client\TimeoutException;
+
+use \OCP\IL10N;
+
use \OCA\News\Db\Item;
use \OCA\News\Db\Feed;
use \OCA\News\Utility\PicoFeedFaviconFactory;
@@ -21,15 +33,18 @@ use \OCA\News\Utility\PicoFeedReaderFactory;
class FeedFetcher implements IFeedFetcher {
private $faviconFactory;
- private $readerFactory;
+ private $reader;
+ private $l10n;
private $time;
- public function __construct(PicoFeedReaderFactory $readerFactory,
+ public function __construct(Reader $reader,
PicoFeedFaviconFactory $faviconFactory,
+ IL10N $l10n,
$time){
$this->faviconFactory = $faviconFactory;
- $this->readerFactory = $readerFactory;
+ $this->reader = $reader;
$this->time = $time;
+ $this->l10n = $l10n;
}
@@ -58,46 +73,56 @@ class FeedFetcher implements IFeedFetcher {
*/
public function fetch($url, $getFavicon=true, $lastModified=null,
$etag=null) {
- $reader = $this->readerFactory->build();
- $resource = $reader->download($url, $lastModified, $etag);
+ try {
+ $resource = $this->reader->discover($url, $lastModified, $etag);
- $modified = $resource->getLastModified();
- $etag = $resource->getEtag();
- $location = $resource->getUrl();
+ if (!$resource->isModified()) {
+ return [null, null];
+ }
- if (!$resource->isModified()) {
- return [null, null];
- }
+ $location = $resource->getUrl();
+ $etag = $resource->getEtag();
+ $content = $resource->getContent();
+ $encoding = $resource->getEncoding();
+ $lastModified = $resource->getLastModified();
- try {
- $parser = $reader->getParser();
+ $parser = $this->reader->getParser($location, $content, $encoding);
- if (!$parser) {
- throw new \Exception(
- 'Could not find a valid feed at url ' . $url
- );
- }
$parsedFeed = $parser->execute();
- if (!$parsedFeed) {
- throw new \Exception(
- 'Could not parse feed ' . $url
- );
- }
+ $feed = $this->buildFeed(
+ $parsedFeed, $url, $getFavicon, $lastModified, $etag, $location
+ );
$items = [];
foreach($parsedFeed->getItems() as $item) {
$items[] = $this->buildItem($item);
}
- $feed = $this->buildFeed(
- $parsedFeed, $url, $getFavicon, $modified, $etag, $location
- );
-
return [$feed, $items];
} catch(\Exception $ex){
- throw new FetcherException($ex->getMessage());
+ $msg = $ex->getMessage();
+
+ if ($ex instanceof MalFormedXmlException) {
+ $msg = $this->l10n->t('Feed contains invalid XML');
+ } else if ($ex instanceof SubscriptionNotFoundException) {
+ $msg = $this->l10n->t('Could not find a feed');
+ } else if ($ex instanceof UnsupportedFeedFormatException) {
+ $msg = $this->l10n->t('Detected feed format is not supported');
+ } else if ($ex instanceof InvalidCertificateException) {
+ $msg = $this->l10n->t('SSL Certificate is invalid');
+ } else if ($ex instanceof InvalidUrlException) {
+ $msg = $this->l10n->t('Website not found');
+ } else if ($ex instanceof MaxRedirectException) {
+ $msg = $this->l10n->t('More redirects than allowed, aborting');
+ } else if ($ex instanceof MaxSizeException) {
+ $msg = $this->l10n->t('Bigger than maximum allowed size');
+ } else if ($ex instanceof TimeoutException) {
+ $msg = $this->l10n->t('Request timed out');
+ }
+
+ throw new FetcherException($msg);
}
}
diff --git a/js/admin/Admin.js b/js/admin/Admin.js
index 83d25962a..056add22a 100644
--- a/js/admin/Admin.js
+++ b/js/admin/Admin.js
@@ -25,6 +25,8 @@
$('#news input[name="news-max-redirects"]');
var feedFetcherTimeoutInput =
$('#news input[name="news-feed-fetcher-timeout"]');
+ var maxSizeInput =
+ $('#news input[name="news-max-size"]');
var savedMessage = $('#news-saved-message');
var saved = function () {
@@ -44,6 +46,7 @@
var autoPurgeCount = autoPurgeCountInput.val();
var maxRedirects = maxRedirectsInput.val();
var feedFetcherTimeout = feedFetcherTimeoutInput.val();
+ var maxSize = maxSizeInput.val();
var useCronUpdates = useCronUpdatesInput.is(':checked');
var data = {
@@ -52,6 +55,7 @@
autoPurgeCount: parseInt(autoPurgeCount, 10),
maxRedirects: parseInt(maxRedirects, 10),
feedFetcherTimeout: parseInt(feedFetcherTimeout, 10),
+ maxSize: parseInt(maxSize, 10),
useCronUpdates: useCronUpdates
};
@@ -69,6 +73,7 @@
.val(data.autoPurgeMinimumInterval);
autoPurgeCountInput.val(data.autoPurgeCount);
maxRedirectsInput.val(data.maxRedirects);
+ maxSizeInput.val(data.maxSize);
feedFetcherTimeoutInput.val(data.feedFetcherTimeout);
useCronUpdatesInput.prop('checked', data.useCronUpdates);
});
diff --git a/service/feedservice.php b/service/feedservice.php
index e0514b35f..a2eec7883 100644
--- a/service/feedservice.php
+++ b/service/feedservice.php
@@ -147,13 +147,7 @@ class FeedService extends Service {
return $feed;
} catch(FetcherException $ex){
$this->logger->debug($ex->getMessage(), $this->loggerParams);
- throw new ServiceNotFoundException(
- $this->l10n->t(
- 'Can not add feed: URL does not exist, ' .
- 'SSL Certificate can not be validated ' .
- 'or feed has invalid xml'
- )
- );
+ throw new ServiceNotFoundException($ex->getMessage());
}
}
diff --git a/templates/admin.php b/templates/admin.php
index bf1810cb6..672ea8d64 100644
--- a/templates/admin.php
+++ b/templates/admin.php
@@ -72,6 +72,23 @@ style('news', 'admin');
</div>
<div class="form-line">
<p>
+ <label for="news-max-size">
+ <?php p($l->t('Maximum feed page size')); ?>
+ </label>
+ </p>
+ <p>
+ <em>
+ <?php p($l->t(
+ 'Maximum feed size in bytes. If the RSS/Atom page is ' .
+ 'bigger than this value, the update will be aborted'
+ )); ?>
+ </em>
+ </p>
+ <p><input type="text" name="news-max-size"
+ value="<?php p($_['maxSize']); ?>"></p>
+ </div>
+ <div class="form-line">
+ <p>
<label for="news-feed-fetcher-timeout">
<?php p($l->t('Feed fetcher timeout')); ?>
</label>
diff --git a/tests/unit/articleenhancer/XPathArticleEnhancerTest.php b/tests/unit/articleenhancer/XPathArticleEnhancerTest.php
index 33f0e75ab..e133f6e16 100644
--- a/tests/unit/articleenhancer/XPathArticleEnhancerTest.php
+++ b/tests/unit/articleenhancer/XPathArticleEnhancerTest.php
@@ -29,7 +29,7 @@ class XPathArticleEnhancerTest extends \PHPUnit_Framework_TestCase {
->disableOriginalConstructor()
->getMock();
$this->client = $this
- ->getMockBuilder('\PicoFeed\Client')
+ ->getMockBuilder('\PicoFeed\Client\Client')
->disableOriginalConstructor()
->getMock();
diff --git a/tests/unit/config/ConfigTest.php b/tests/unit/config/ConfigTest.php
index a2739de59..bdffeb0d1 100644
--- a/tests/unit/config/ConfigTest.php
+++ b/tests/unit/config/ConfigTest.php
@@ -50,6 +50,7 @@ class ConfigTest extends \PHPUnit_Framework_TestCase {
$this->assertEquals(null, $this->config->getProxyAuth());
$this->assertEquals('', $this->config->getProxyUser());
$this->assertEquals('', $this->config->getProxyPassword());
+ $this->assertEquals(1024*1024*100, $this->config->getMaxSize());
}
@@ -129,6 +130,7 @@ class ConfigTest extends \PHPUnit_Framework_TestCase {
$json = 'autoPurgeMinimumInterval = 60' . "\n" .
'autoPurgeCount = 3' . "\n" .
'maxRedirects = 10' . "\n" .
+ 'maxSize = 399' . "\n" .
'feedFetcherTimeout = 60' . "\n" .
'useCronUpdates = true';
$this->config->setAutoPurgeCount(3);
@@ -136,6 +138,7 @@ class ConfigTest extends \PHPUnit_Framework_TestCase {
$this->config->setProxyPort(12);
$this->config->setProxyUser('this is a test');
$this->config->setProxyPassword('se');
+ $this->config->setMaxSize(399);
$this->fileSystem->expects($this->once())
->method('file_put_contents')
@@ -162,6 +165,7 @@ class ConfigTest extends \PHPUnit_Framework_TestCase {
$json = 'autoPurgeMinimumInterval = 60' . "\n" .
'autoPurgeCount = 200' . "\n" .
'maxRedirects = 10' . "\n" .
+ 'maxSize = 104857600' . "\n" .
'feedFetcherTimeout = 60' . "\n" .
'useCronUpdates = false';
diff --git a/tests/unit/controller/AdminControllerTest.php b/tests/unit/controller/AdminControllerTest.php
index 4a9bf7764..347e0a4a5 100644
--- a/tests/unit/controller/AdminControllerTest.php
+++ b/tests/unit/controller/AdminControllerTest.php
@@ -47,7 +47,8 @@ class AdminControllerTest extends \PHPUnit_Framework_TestCase {
'autoPurgeCount' => 2,
'maxRedirects' => 3,
'feedFetcherTimeout' => 4,
- 'useCronUpdates' => 5
+ 'useCronUpdates' => 5,
+ 'maxSize' => 7
];
$this->config->expects($this->once())
->method('getAutoPurgeMinimumInterval')
@@ -64,6 +65,9 @@ class AdminControllerTest extends \PHPUnit_Framework_TestCase {
$this->config->expects($this->once())
->method('getUseCronUpdates')
->will($this->returnValue($expected['useCronUpdates']));
+ $this->config->expects($this->once())
+ ->method('getMaxSize')
+ ->will($this->returnValue($expected['maxSize']));
$response = $this->controller->index();
$data = $response->getParams();
@@ -82,7 +86,8 @@ class AdminControllerTest extends \PHPUnit_Framework_TestCase {
'autoPurgeCount' => 2,
'maxRedirects' => 3,
'feedFetcherTimeout' => 4,
- 'useCronUpdates' => 5
+ 'useCronUpdates' => 5,
+ 'maxSize' => 7,
];
$this->config->expects($this->once())
@@ -119,12 +124,16 @@ class AdminControllerTest extends \PHPUnit_Framework_TestCase {
$this->config->expects($this->once())
->method('getUseCronUpdates')
->will($this->returnValue($expected['useCronUpdates']));
+ $this->config->expects($this->once())
+ ->method('getMaxSize')
+ ->will($this->returnValue($expected['maxSize']));
$response = $this->controller->update(
$expected['autoPurgeMinimumInterval'],
$expected['autoPurgeCount'],
$expected['maxRedirects'],
$expected['feedFetcherTimeout'],
+ $expected['maxSize'],
$expected['useCronUpdates']
);
diff --git a/tests/unit/fetcher/FeedFetcherTest.php b/tests/unit/fetcher/FeedFetcherTest.php
index 3f837cdd8..a9e29de0c 100644
--- a/tests/unit/fetcher/FeedFetcherTest.php
+++ b/tests/unit/fetcher/FeedFetcherTest.php
@@ -26,10 +26,12 @@ class FeedFetcherTest extends \PHPUnit_Framework_TestCase {
private $faviconFetcher;
private $parsedFeed;
private $faviconFactory;
- private $readerFactory;
+ private $l10n;
private $url;
private $time;
private $item;
+ private $content;
+ private $encoding;
// items
private $permalink;
@@ -50,32 +52,32 @@ class FeedFetcherTest extends \PHPUnit_Framework_TestCase {
private $location;
protected function setUp(){
+ $this->l10n = $this->getMockBuilder(
+ '\OCP\IL10N')
+ ->disableOriginalConstructor()
+ ->getMock();
$this->reader = $this->getMockBuilder(
- '\PicoFeed\Reader')
+ '\PicoFeed\Reader\Reader')
->disableOriginalConstructor()
->getMock();
$this->parser = $this->getMockBuilder(
- '\PicoFeed\Parser')
+ '\PicoFeed\Parser\Parser')
->disableOriginalConstructor()
->getMock();
$this->client = $this->getMockBuilder(
- '\PicoFeed\Client')
+ '\PicoFeed\Client\Client')
->disableOriginalConstructor()
->getMock();
$this->parsedFeed = $this->getMockBuilder(
- '\PicoFeed\Feed')
+ '\PicoFeed\Parser\Feed')
->disableOriginalConstructor()
->getMock();
$this->item = $this->getMockBuilder(
- '\PicoFeed\Item')
+ '\PicoFeed\Parser\Item')
->disableOriginalConstructor()
->getMock();
$this->faviconFetcher = $this->getMockBuilder(
- '\PicoFeed\Favicon')
- ->disableOriginalConstructor()
- ->getMock();
- $this->readerFactory = $this->getMockBuilder(
- '\OCA\News\Utility\PicoFeedReaderFactory')
+ '\PicoFeed\Client\Favicon')
->disableOriginalConstructor()
->getMock();
$this->faviconFactory = $this->getMockBuilder(
@@ -88,9 +90,11 @@ class FeedFetcherTest extends \PHPUnit_Framework_TestCase {
$timeFactory->expects($this->any())
->method('getTime')
->will($this->returnValue($this->time));
- $this->fetcher = new FeedFetcher($this->readerFactory,
- $this->faviconFactory,
- $timeFactory);
+ $this->fetcher = new FeedFetcher(
+ $this->reader,
+ $this->faviconFactory,
+ $this->l10n,
+ $timeFactory);
$this->url = 'http://tests';
$this->permalink = 'http://permalink';
@@ -110,6 +114,8 @@ class FeedFetcherTest extends \PHPUnit_Framework_TestCase {
$this->authorMail = 'doe@joes.com';
$this->modified = 3;
$this->etag = 'yo';
+ $this->content = 'some content';
+ $this->encoding = 'UTF-8';
}
@@ -119,51 +125,57 @@ class FeedFetcherTest extends \PHPUnit_Framework_TestCase {
$this->assertTrue($this->fetcher->canHandle($url));
}
- private function setUpReader($url='', $modified=true, $noParser=false,
- $noFeed=false) {
- $this->readerFactory->expects($this->once())
- ->method('build')
- ->will($this->returnValue($this->reader));
+ private function setUpReader($url='', $modified=true, $noParser=false) {
$this->reader->expects($this->once())
- ->method('download')
+ ->method('discover')
->with($this->equalTo($url))
->will($this->returnValue($this->client));
$this->client->expects($this->once())
- ->method('getLastModified')
- ->will($this->returnValue($this->modified));
- $this->client->expects($this->once())
- ->method('getEtag')
- ->will($this->returnValue($this->etag));
- $this->client->expects($this->once())
- ->method('getUrl')
- ->will($this->returnValue($this->location));
+ ->method('isModified')
+ ->will($this->returnValue($modified));
if (!$modified) {
$this->reader->expects($this->never())
->method('getParser');
- } else if ($noParser) {
- $this->reader->expects($this->once())
- ->method('getParser')
- ->will($this->returnValue(false));
} else {
- $this->reader->expects($this->once())
- ->method('getParser')
- ->will($this->returnValue($this->parser));
-
- if ($noFeed) {
- $this->parser->expects($this->once())
- ->method('execute')
- ->will($this->returnValue(false));
+ $this->client->expects($this->once())
+ ->method('getLastModified')
+ ->will($this->returnValue($this->modified));
+ $this->client->expects($this->once())
+ ->method('getEtag')
+ ->will($this->returnValue($this->etag));
+ $this->client->expects($this->once())
+ ->method('getUrl')
+ ->will($this->returnValue($this->location));
+ $this->client->expects($this->once())
+ ->method('getContent')
+ ->will($this->returnValue($this->content));
+ $this->client->expects($this->once())
+ ->method('getEncoding')
+ ->will($this->returnValue($this->encoding));
+
+ if ($noParser) {
+ $this->reader->expects($this->once())
+ ->method('getParser')
+ ->will($this->throwException(
+ new \PicoFeed\Reader\SubscriptionNotFoundException()
+ ));
} else {
- $this->parser->expects($this->once())
- ->method('execute')
- ->will($this->returnValue($this->parsedFeed));
+ $this->reader->expects($this->once())
+ ->method('getParser')
+ ->with(
+ $this->equalTo($this->location),
+ $this->equalTo($this->content),
+ $this->equalTo($this->encoding)
+ )
+ ->will($this->returnValue($this->parser));
}
+
+ $this->parser->expects($this->once())
+ ->method('execute')
+ ->will($this->returnValue($this->parsedFeed));
}
- $this->client->expects($this->once())
- ->method('isModified')
- ->will($this->returnValue($modified));
}
@@ -249,13 +261,6 @@ class FeedFetcherTest extends \PHPUnit_Framework_TestCase {
}
- public function testFetchThrowsExceptionWhenParsingFailed() {
- $this->setUpReader($this->url, true, true, false);
-
- $this->setExpectedException('\OCA\News\Fetcher\FetcherException');
- $this->fetcher->fetch($this->url, false);
- }
-
public function testNoFetchIfNotModified(){
$this->setUpReader($this->url, false);;
$result = $this->fetcher->fetch($this->url, false);
diff --git a/tests/unit/service/FeedServiceTest.php b/tests/unit/service/FeedServiceTest.php
index 2b7205dba..ff00104e9 100644
--- a/tests/unit/service/FeedServiceTest.php
+++ b/tests/unit/service/FeedServiceTest.php
@@ -102,8 +102,6 @@ class FeedServiceTest extends \PHPUnit_Framework_TestCase {
public function testCreateDoesNotFindFeed(){
$ex = new FetcherException('hi');
$url = 'test';
- $this->l10n->expects($this->once())
- ->method('t');
$this->fetcher->expects($this->once())
->method('fetch')
->with($this->equalTo($url))
diff --git a/utility/picofeedclientfactory.php b/utility/picofeedclientfactory.php
index 527d0e13c..a57f7f022 100644
--- a/utility/picofeedclientfactory.php
+++ b/utility/picofeedclientfactory.php
@@ -14,8 +14,8 @@
namespace OCA\News\Utility;
-use \PicoFeed\Config;
-use \PicoFeed\Client;
+use \PicoFeed\Config\Config;
+use \PicoFeed\Client\Client;
class PicoFeedClientFactory {
diff --git a/utility/picofeedfaviconfactory.php b/utility/picofeedfaviconfactory.php
index 8438765cd..4509b9e06 100644
--- a/utility/picofeedfaviconfactory.php
+++ b/utility/picofeedfaviconfactory.php
@@ -14,8 +14,8 @@
namespace OCA\News\Utility;
-use \PicoFeed\Config;
-use \PicoFeed\Favicon;
+use \PicoFeed\Config\Config;
+use \PicoFeed\Client\Favicon;
class PicoFeedFaviconFactory {
diff --git a/utility/picofeedreaderfactory.php b/utility/picofeedreaderfactory.php
deleted file mode 100644
index 844a0e402..000000000
--- a/utility/picofeedreaderfactory.php
+++ /dev/null
@@ -1,38 +0,0 @@
-<?php
-/**
- * ownCloud - News
- *
- * This file is licensed under the Affero General Public License version 3 or
- * later. See the COPYING file.
- *
- * @author Alessandro Cosentino <cosenal@gmail.com>
- * @author Bernhard Posselt <dev@bernhard-posselt.com>
- * @copyright Alessandro Cosentino 2012
- * @copyright Bernhard Posselt 2012, 2014
- */
-
-
-namespace OCA\News\Utility;
-
-use \PicoFeed\Config;
-use \PicoFeed\Reader;
-
-class PicoFeedReaderFactory {
-
- private $config;
-
- public function __construct(Config $config) {
- $this->config = $config;
- }
-
-
- /**
- * Returns a new instance of an PicoFeed Http Reader
- * @return \PicoFeed\Reader instance
- */
- public function build() {
- return new Reader($this->config);
- }
-
-
-} \ No newline at end of file