Add Delicious import

Since 2021, you can export again your data \o/

Also fix indentation in json fixtures files.
This commit is contained in:
Jeremy Benoist 2021-02-08 09:08:12 +01:00
parent 890c7d0bfa
commit dd9d6a4c64
No known key found for this signature in database
GPG key ID: BCA73962457ACC3C
23 changed files with 984 additions and 370 deletions

View file

@ -248,6 +248,11 @@ old_sound_rabbit_mq:
exchange_options:
name: 'wallabag.import.pinboard'
type: topic
import_delicious:
connection: default
exchange_options:
name: 'wallabag.import.delicious'
type: topic
import_instapaper:
connection: default
exchange_options:
@ -315,6 +320,15 @@ old_sound_rabbit_mq:
name: 'wallabag.import.pinboard'
callback: wallabag_import.consumer.amqp.pinboard
qos_options: {prefetch_count: "%rabbitmq_prefetch_count%"}
import_delicious:
connection: default
exchange_options:
name: 'wallabag.import.delicious'
type: topic
queue_options:
name: 'wallabag.import.delicious'
callback: wallabag_import.consumer.amqp.delicious
qos_options: {prefetch_count: "%rabbitmq_prefetch_count%"}
import_wallabag_v1:
connection: default
exchange_options:

View file

@ -517,6 +517,10 @@ import:
page_title: Import > Pinboard
description: This importer will import all your Pinboard articles. On the backup (https://pinboard.in/settings/backup) page, click on "JSON" in the "Bookmarks" section. A JSON file will be downloaded (like "pinboard_export").
how_to: Please select your Pinboard export and click on the below button to upload and import it.
delicious:
page_title: Import > del.icio.us
description: This importer will import all your Delicious bookmarks. Since 2021, you can export again your data from it using the export page (https://del.icio.us/export). Choose the "JSON" format and download it (like "delicious_export.2021.02.06_21.10.json").
how_to: Please select your Delicious export and click on the below button to upload and import it.
developer:
page_title: API clients management
welcome_message: Welcome to the wallabag API

View file

@ -19,7 +19,7 @@ class ImportCommand extends ContainerAwareCommand
->setDescription('Import entries from a JSON export')
->addArgument('username', InputArgument::REQUIRED, 'User to populate')
->addArgument('filepath', InputArgument::REQUIRED, 'Path to the JSON file')
->addOption('importer', null, InputOption::VALUE_OPTIONAL, 'The importer to use: v1, v2, instapaper, pinboard, readability, firefox or chrome', 'v1')
->addOption('importer', null, InputOption::VALUE_OPTIONAL, 'The importer to use: v1, v2, instapaper, pinboard, delicious, readability, firefox or chrome', 'v1')
->addOption('markAsRead', null, InputOption::VALUE_OPTIONAL, 'Mark all entries as read', false)
->addOption('useUserId', null, InputOption::VALUE_NONE, 'Use user id instead of username to find account')
->addOption('disableContentUpdate', null, InputOption::VALUE_NONE, 'Disable fetching updated content from URL')
@ -77,6 +77,9 @@ class ImportCommand extends ContainerAwareCommand
case 'pinboard':
$import = $this->getContainer()->get('wallabag_import.pinboard.import');
break;
case 'delicious':
$import = $this->getContainer()->get('wallabag_import.delicious.import');
break;
default:
$import = $this->getContainer()->get('wallabag_import.wallabag_v1.import');
}

View file

@ -17,7 +17,7 @@ class RedisWorkerCommand extends ContainerAwareCommand
$this
->setName('wallabag:import:redis-worker')
->setDescription('Launch Redis worker')
->addArgument('serviceName', InputArgument::REQUIRED, 'Service to use: wallabag_v1, wallabag_v2, pocket, readability, pinboard, firefox, chrome or instapaper')
->addArgument('serviceName', InputArgument::REQUIRED, 'Service to use: wallabag_v1, wallabag_v2, pocket, readability, pinboard, delicious, firefox, chrome or instapaper')
->addOption('maxIterations', '', InputOption::VALUE_OPTIONAL, 'Number of iterations before stoping', false)
;
}

View file

@ -0,0 +1,77 @@
<?php
namespace Wallabag\ImportBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Wallabag\ImportBundle\Form\Type\UploadImportType;
class DeliciousController extends Controller
{
/**
* @Route("/delicious", name="import_delicious")
*/
public function indexAction(Request $request)
{
$form = $this->createForm(UploadImportType::class);
$form->handleRequest($request);
$delicious = $this->get('wallabag_import.delicious.import');
$delicious->setUser($this->getUser());
if ($this->get('craue_config')->get('import_with_rabbitmq')) {
$delicious->setProducer($this->get('old_sound_rabbit_mq.import_delicious_producer'));
} elseif ($this->get('craue_config')->get('import_with_redis')) {
$delicious->setProducer($this->get('wallabag_import.producer.redis.delicious'));
}
if ($form->isSubmitted() && $form->isValid()) {
$file = $form->get('file')->getData();
$markAsRead = $form->get('mark_as_read')->getData();
$name = 'delicious_' . $this->getUser()->getId() . '.json';
if (null !== $file && \in_array($file->getClientMimeType(), $this->getParameter('wallabag_import.allow_mimetypes'), true) && $file->move($this->getParameter('wallabag_import.resource_dir'), $name)) {
$res = $delicious
->setFilepath($this->getParameter('wallabag_import.resource_dir') . '/' . $name)
->setMarkAsRead($markAsRead)
->import();
$message = 'flashes.import.notice.failed';
if (true === $res) {
$summary = $delicious->getSummary();
$message = $this->get('translator')->trans('flashes.import.notice.summary', [
'%imported%' => $summary['imported'],
'%skipped%' => $summary['skipped'],
]);
if (0 < $summary['queued']) {
$message = $this->get('translator')->trans('flashes.import.notice.summary_with_queue', [
'%queued%' => $summary['queued'],
]);
}
unlink($this->getParameter('wallabag_import.resource_dir') . '/' . $name);
}
$this->get('session')->getFlashBag()->add(
'notice',
$message
);
return $this->redirect($this->generateUrl('homepage'));
}
$this->get('session')->getFlashBag()->add(
'notice',
'flashes.import.notice.failed_on_file'
);
}
return $this->render('WallabagImportBundle:Delicious:index.html.twig', [
'form' => $form->createView(),
'import' => $delicious,
]);
}
}

View file

@ -43,6 +43,7 @@ class ImportController extends Controller
+ $this->getTotalMessageInRabbitQueue('chrome')
+ $this->getTotalMessageInRabbitQueue('instapaper')
+ $this->getTotalMessageInRabbitQueue('pinboard')
+ $this->getTotalMessageInRabbitQueue('delicious')
+ $this->getTotalMessageInRabbitQueue('elcurator')
;
} catch (\Exception $e) {
@ -60,6 +61,7 @@ class ImportController extends Controller
+ $redis->llen('wallabag.import.chrome')
+ $redis->llen('wallabag.import.instapaper')
+ $redis->llen('wallabag.import.pinboard')
+ $redis->llen('wallabag.import.delicious')
+ $redis->llen('wallabag.import.elcurator')
;
} catch (\Exception $e) {

View file

@ -0,0 +1,151 @@
<?php
namespace Wallabag\ImportBundle\Import;
use Wallabag\CoreBundle\Entity\Entry;
class DeliciousImport extends AbstractImport
{
private $filepath;
/**
* {@inheritdoc}
*/
public function getName()
{
return 'Delicious';
}
/**
* {@inheritdoc}
*/
public function getUrl()
{
return 'import_delicious';
}
/**
* {@inheritdoc}
*/
public function getDescription()
{
return 'import.delicious.description';
}
/**
* Set file path to the json file.
*
* @param string $filepath
*/
public function setFilepath($filepath)
{
$this->filepath = $filepath;
return $this;
}
/**
* {@inheritdoc}
*/
public function import()
{
if (!$this->user) {
$this->logger->error('DeliciousImport: user is not defined');
return false;
}
if (!file_exists($this->filepath) || !is_readable($this->filepath)) {
$this->logger->error('DeliciousImport: unable to read file', ['filepath' => $this->filepath]);
return false;
}
$data = json_decode(file_get_contents($this->filepath), true);
if (empty($data)) {
$this->logger->error('DeliciousImport: no entries in imported file');
return false;
}
if ($this->producer) {
$this->parseEntriesForProducer($data);
return true;
}
$this->parseEntries($data);
return true;
}
/**
* {@inheritdoc}
*/
public function validateEntry(array $importedEntry)
{
if (empty($importedEntry['url'])) {
return false;
}
return true;
}
/**
* {@inheritdoc}
*/
public function parseEntry(array $importedEntry)
{
$existingEntry = $this->em
->getRepository('WallabagCoreBundle:Entry')
->findByUrlAndUserId($importedEntry['url'], $this->user->getId());
if (false !== $existingEntry) {
++$this->skippedEntries;
return;
}
$data = [
'title' => $importedEntry['title'],
'url' => $importedEntry['url'],
'is_archived' => $this->markAsRead,
'is_starred' => false,
'created_at' => $importedEntry['created'],
'tags' => $importedEntry['tags'],
];
$entry = new Entry($this->user);
$entry->setUrl($data['url']);
$entry->setTitle($data['title']);
// update entry with content (in case fetching failed, the given entry will be return)
$this->fetchContent($entry, $data['url'], $data);
if (!empty($data['tags'])) {
$this->tagsAssigner->assignTagsToEntry(
$entry,
$data['tags'],
$this->em->getUnitOfWork()->getScheduledEntityInsertions()
);
}
$entry->updateArchived($data['is_archived']);
$entry->setStarred($data['is_starred']);
$entry->setCreatedAt(\DateTime::createFromFormat('U', $data['created_at']));
$this->em->persist($entry);
++$this->importedEntries;
return $entry;
}
/**
* {@inheritdoc}
*/
protected function setEntryAsRead(array $importedEntry)
{
return $importedEntry;
}
}

View file

@ -32,6 +32,14 @@ services:
- "@wallabag_import.pinboard.import"
- "@event_dispatcher"
- "@logger"
wallabag_import.consumer.amqp.delicious:
class: Wallabag\ImportBundle\Consumer\AMQPEntryConsumer
arguments:
- "@doctrine.orm.entity_manager"
- "@wallabag_user.user_repository"
- "@wallabag_import.delicious.import"
- "@event_dispatcher"
- "@logger"
wallabag_import.consumer.amqp.wallabag_v1:
class: Wallabag\ImportBundle\Consumer\AMQPEntryConsumer
arguments:

View file

@ -63,6 +63,27 @@ services:
- "@event_dispatcher"
- "@logger"
# delicious
wallabag_import.queue.redis.delicious:
class: Simpleue\Queue\RedisQueue
arguments:
- "@wallabag_core.redis.client"
- "wallabag.import.delicious"
wallabag_import.producer.redis.delicious:
class: Wallabag\ImportBundle\Redis\Producer
arguments:
- "@wallabag_import.queue.redis.delicious"
wallabag_import.consumer.redis.delicious:
class: Wallabag\ImportBundle\Consumer\RedisEntryConsumer
arguments:
- "@doctrine.orm.entity_manager"
- "@wallabag_user.user_repository"
- "@wallabag_import.delicious.import"
- "@event_dispatcher"
- "@logger"
# pocket
wallabag_import.queue.redis.pocket:
class: Simpleue\Queue\RedisQueue

View file

@ -96,6 +96,18 @@ services:
tags:
- { name: wallabag_import.import, alias: pinboard }
wallabag_import.delicious.import:
class: Wallabag\ImportBundle\Import\DeliciousImport
arguments:
- "@doctrine.orm.entity_manager"
- "@wallabag_core.content_proxy"
- "@wallabag_core.tags_assigner"
- "@event_dispatcher"
calls:
- [ setLogger, [ "@logger" ]]
tags:
- { name: wallabag_import.import, alias: delicious }
wallabag_import.firefox.import:
class: Wallabag\ImportBundle\Import\FirefoxImport
arguments:

View file

@ -0,0 +1,45 @@
{% extends "WallabagCoreBundle::layout.html.twig" %}
{% block title %}{{ 'import.delicious.page_title'|trans }}{% endblock %}
{% block content %}
<div class="row">
<div class="col s12">
<div class="card-panel settings">
{% include 'WallabagImportBundle:Import:_information.html.twig' %}
<div class="row">
<blockquote>{{ import.description|trans }}</blockquote>
<p>{{ 'import.delicious.how_to'|trans }}</p>
<div class="col s12">
{{ form_start(form, {'method': 'POST'}) }}
{{ form_errors(form) }}
<div class="row">
<div class="file-field input-field col s12">
{{ form_errors(form.file) }}
<div class="btn">
<span>{{ form.file.vars.label|trans }}</span>
{{ form_widget(form.file) }}
</div>
<div class="file-path-wrapper">
<input class="file-path validate" type="text">
</div>
</div>
<div class="input-field col s6 with-checkbox">
<h6>{{ 'import.form.mark_as_read_title'|trans }}</h6>
{{ form_widget(form.mark_as_read) }}
{{ form_label(form.mark_as_read) }}
</div>
</div>
{{ form_widget(form.save, { 'attr': {'class': 'btn waves-effect waves-light'} }) }}
{{ form_rest(form) }}
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,199 @@
<?php
namespace Tests\Wallabag\ImportBundle\Controller;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Tests\Wallabag\CoreBundle\WallabagCoreTestCase;
class DeliciousControllerTest extends WallabagCoreTestCase
{
public function testImportDelicious()
{
$this->logInAs('admin');
$client = $this->getClient();
$crawler = $client->request('GET', '/import/delicious');
$this->assertSame(200, $client->getResponse()->getStatusCode());
$this->assertSame(1, $crawler->filter('form[name=upload_import_file] > button[type=submit]')->count());
$this->assertSame(1, $crawler->filter('input[type=file]')->count());
}
public function testImportDeliciousWithRabbitEnabled()
{
$this->logInAs('admin');
$client = $this->getClient();
$client->getContainer()->get('craue_config')->set('import_with_rabbitmq', 1);
$crawler = $client->request('GET', '/import/delicious');
$this->assertSame(200, $client->getResponse()->getStatusCode());
$this->assertSame(1, $crawler->filter('form[name=upload_import_file] > button[type=submit]')->count());
$this->assertSame(1, $crawler->filter('input[type=file]')->count());
$client->getContainer()->get('craue_config')->set('import_with_rabbitmq', 0);
}
public function testImportDeliciousBadFile()
{
$this->logInAs('admin');
$client = $this->getClient();
$crawler = $client->request('GET', '/import/delicious');
$form = $crawler->filter('form[name=upload_import_file] > button[type=submit]')->form();
$data = [
'upload_import_file[file]' => '',
];
$client->submit($form, $data);
$this->assertSame(200, $client->getResponse()->getStatusCode());
}
public function testImportDeliciousWithRedisEnabled()
{
$this->checkRedis();
$this->logInAs('admin');
$client = $this->getClient();
$client->getContainer()->get('craue_config')->set('import_with_redis', 1);
$crawler = $client->request('GET', '/import/delicious');
$this->assertSame(200, $client->getResponse()->getStatusCode());
$this->assertSame(1, $crawler->filter('form[name=upload_import_file] > button[type=submit]')->count());
$this->assertSame(1, $crawler->filter('input[type=file]')->count());
$form = $crawler->filter('form[name=upload_import_file] > button[type=submit]')->form();
$file = new UploadedFile(__DIR__ . '/../fixtures/delicious_export.2021.02.06_21.10.json', 'delicious.json');
$data = [
'upload_import_file[file]' => $file,
];
$client->submit($form, $data);
$this->assertSame(302, $client->getResponse()->getStatusCode());
$crawler = $client->followRedirect();
$this->assertGreaterThan(1, $body = $crawler->filter('body')->extract(['_text']));
$this->assertStringContainsString('flashes.import.notice.summary', $body[0]);
$this->assertNotEmpty($client->getContainer()->get('wallabag_core.redis.client')->lpop('wallabag.import.delicious'));
$client->getContainer()->get('craue_config')->set('import_with_redis', 0);
}
public function testImportDeliciousWithFile()
{
$this->logInAs('admin');
$client = $this->getClient();
$crawler = $client->request('GET', '/import/delicious');
$form = $crawler->filter('form[name=upload_import_file] > button[type=submit]')->form();
$file = new UploadedFile(__DIR__ . '/../fixtures/delicious_export.2021.02.06_21.10.json', 'delicious.json');
$data = [
'upload_import_file[file]' => $file,
];
$client->submit($form, $data);
$this->assertSame(302, $client->getResponse()->getStatusCode());
$crawler = $client->followRedirect();
$content = $client->getContainer()
->get('doctrine.orm.entity_manager')
->getRepository('WallabagCoreBundle:Entry')
->findByUrlAndUserId(
'https://feross.org/spoofmac/',
$this->getLoggedInUserId()
);
$this->assertGreaterThan(1, $body = $crawler->filter('body')->extract(['_text']));
$this->assertStringContainsString('flashes.import.notice.summary', $body[0]);
$this->assertInstanceOf('Wallabag\CoreBundle\Entity\Entry', $content);
$tags = $content->getTags();
$this->assertContains('osx', $tags, 'It includes the "osx" tag');
$this->assertGreaterThanOrEqual(4, count($tags));
$this->assertInstanceOf(\DateTime::class, $content->getCreatedAt());
$this->assertSame('2013-01-17', $content->getCreatedAt()->format('Y-m-d'));
}
public function testImportDeliciousWithFileAndMarkAllAsRead()
{
$this->logInAs('admin');
$client = $this->getClient();
$crawler = $client->request('GET', '/import/delicious');
$form = $crawler->filter('form[name=upload_import_file] > button[type=submit]')->form();
$file = new UploadedFile(__DIR__ . '/../fixtures/delicious_export.2021.02.06_21.10.json', 'delicious-read.json');
$data = [
'upload_import_file[file]' => $file,
'upload_import_file[mark_as_read]' => 1,
];
$client->submit($form, $data);
$this->assertSame(302, $client->getResponse()->getStatusCode());
$crawler = $client->followRedirect();
$content1 = $client->getContainer()
->get('doctrine.orm.entity_manager')
->getRepository('WallabagCoreBundle:Entry')
->findByUrlAndUserId(
'https://stackoverflow.com/review/',
$this->getLoggedInUserId()
);
$this->assertInstanceOf('Wallabag\CoreBundle\Entity\Entry', $content1);
$content2 = $client->getContainer()
->get('doctrine.orm.entity_manager')
->getRepository('WallabagCoreBundle:Entry')
->findByUrlAndUserId(
'https://addyosmani.com/basket.js/',
$this->getLoggedInUserId()
);
$this->assertInstanceOf('Wallabag\CoreBundle\Entity\Entry', $content2);
$this->assertGreaterThan(1, $body = $crawler->filter('body')->extract(['_text']));
$this->assertStringContainsString('flashes.import.notice.summary', $body[0]);
}
public function testImportDeliciousWithEmptyFile()
{
$this->logInAs('admin');
$client = $this->getClient();
$crawler = $client->request('GET', '/import/delicious');
$form = $crawler->filter('form[name=upload_import_file] > button[type=submit]')->form();
$file = new UploadedFile(__DIR__ . '/../fixtures/test.txt', 'test.txt');
$data = [
'upload_import_file[file]' => $file,
];
$client->submit($form, $data);
$this->assertSame(302, $client->getResponse()->getStatusCode());
$crawler = $client->followRedirect();
$this->assertGreaterThan(1, $body = $crawler->filter('body')->extract(['_text']));
$this->assertStringContainsString('flashes.import.notice.failed', $body[0]);
}
}

View file

@ -2,13 +2,15 @@
"checksum": "f3aa0e9c0edad632a246f7e98ec64918",
"roots": {
"bookmark_bar": {
"children": [ {
"children": [
{
"date_added": "13118850929335823",
"id": "6",
"name": "\"La multiplication des chefs de projet est une catastrophe managériale majeure\", affirme le sociologue François Dupuy - Ressources humaines",
"type": "url",
"url": "http://www.usinenouvelle.com/article/la-multiplication-des-chefs-de-projet-est-une-catastrophe-manageriale-majeure-affirme-le-sociologue-francois-dupuy.N307730"
} ],
}
],
"date_added": "13118829474385693",
"date_modified": "13118850929335823",
"id": "1",
@ -16,7 +18,7 @@
"type": "folder"
},
"other": {
"children": [ ],
"children": [],
"date_added": "13118829474385701",
"date_modified": "0",
"id": "2",
@ -24,7 +26,7 @@
"type": "folder"
},
"synced": {
"children": [ ],
"children": [],
"date_added": "13118829474385702",
"date_modified": "0",
"id": "3",

View file

@ -0,0 +1,44 @@
[
{
"title": "basket.js - a simple script loader that caches scripts with localStorage",
"tags": [
"basket",
"javascript",
"loader",
"localStorage"
],
"url": "https://addyosmani.com/basket.js/",
"description": "\"A simple (proof-of-concept) script loader that caches scripts with localStorage\"",
"created": "1358531607",
"others": 9,
"owner": "maciej",
"private": "0"
},
{
"title": "Review - Stack Overflow",
"tags": [
""
],
"url": "https://stackoverflow.com/review/",
"description": "",
"created": "1358457437",
"others": 84,
"owner": "maciej",
"private": "0"
},
{
"title": "Announcing SpoofMAC - Spoof your MAC address in Mac OS X",
"tags": [
"MAC_address",
"osx",
"mac",
"spoof"
],
"url": "https://feross.org/spoofmac/",
"description": "",
"created": "1358425796",
"others": 6,
"owner": "maciej",
"private": "0"
}
]

View file

@ -32,7 +32,7 @@
"index": 1,
"dateAdded": 1388166091644000,
"lastModified": 1388166091644000,
"tags":"test,tag",
"tags": "test,tag",
"id": 4,
"type": "text/x-moz-place",
"uri": "http://lexpansion.lexpress.fr/high-tech/orange-offre-un-meilleur-reseau-mobile-que-bouygues-et-sfr-free-derriere_1811554.html"

View file

@ -1,3 +1,35 @@
[{"href":"https:\/\/developers.google.com\/web\/updates\/2016\/07\/infinite-scroller","description":"Complexities of an Infinite Scroller","extended":"TL;DR: Re-use your DOM elements and remove the ones that are far away from the viewport. Use placeholders to account for delayed data","meta":"21ff61c6f648901168f9e6119f53df7d","hash":"e69b65724cca1c585b446d4c47865d76","time":"2016-10-31T15:57:56Z","shared":"yes","toread":"no","tags":"infinite dom performance scroll"},
{"href":"https:\/\/ma.ttias.be\/varnish-explained\/","description":"Varnish (explained) for PHP developers","extended":"A few months ago, I gave a presentation at LaraconEU in Amsterdam titled \"Varnish for PHP developers\". The generic title of that presentation is actually Varnish Explained and this is a write-up of that presentation, the video and the slides.","meta":"d32ad9fac2ed29da4aec12c562e9afb1","hash":"21dd6bdda8ad62666a2c9e79f6e80f98","time":"2016-10-26T06:43:03Z","shared":"yes","toread":"no","tags":"varnish PHP"},
{"href":"https:\/\/ilia.ws\/files\/nginx_torontophpug.pdf","description":"Nginx Tricks for PHP Developers","extended":"","meta":"9adbb5c4ca6760e335b920800d88c70a","hash":"0189bb08f8bd0122c6544bed4624c546","time":"2016-10-05T07:11:27Z","shared":"yes","toread":"no","tags":"nginx PHP best_practice"}]
[
{
"href": "https://developers.google.com/web/updates/2016/07/infinite-scroller",
"description": "Complexities of an Infinite Scroller",
"extended": "TL;DR: Re-use your DOM elements and remove the ones that are far away from the viewport. Use placeholders to account for delayed data",
"meta": "21ff61c6f648901168f9e6119f53df7d",
"hash": "e69b65724cca1c585b446d4c47865d76",
"time": "2016-10-31T15:57:56Z",
"shared": "yes",
"toread": "no",
"tags": "infinite dom performance scroll"
},
{
"href": "https://ma.ttias.be/varnish-explained/",
"description": "Varnish (explained) for PHP developers",
"extended": "A few months ago, I gave a presentation at LaraconEU in Amsterdam titled \"Varnish for PHP developers\". The generic title of that presentation is actually Varnish Explained and this is a write-up of that presentation, the video and the slides.",
"meta": "d32ad9fac2ed29da4aec12c562e9afb1",
"hash": "21dd6bdda8ad62666a2c9e79f6e80f98",
"time": "2016-10-26T06:43:03Z",
"shared": "yes",
"toread": "no",
"tags": "varnish PHP"
},
{
"href": "https://ilia.ws/files/nginx_torontophpug.pdf",
"description": "Nginx Tricks for PHP Developers",
"extended": "",
"meta": "9adbb5c4ca6760e335b920800d88c70a",
"hash": "0189bb08f8bd0122c6544bed4624c546",
"time": "2016-10-05T07:11:27Z",
"shared": "yes",
"toread": "no",
"tags": "nginx PHP best_practice"
}
]

File diff suppressed because one or more lines are too long