Merge pull request #5794 from wallabag/2.5.0

Merge branch 2.5.0 in master
This commit is contained in:
Kevin Decherf 2022-05-14 16:44:13 +02:00 committed by GitHub
commit 5809d7b072
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1160 additions and 355 deletions

View file

@ -287,7 +287,7 @@ a.original:not(.waves-effect) {
flex-basis: 5em;
align-self: flex-end;
float: right;
max-width: 6em;
max-width: 8em;
}
.tags {

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

@ -69,11 +69,11 @@ security:
- { path: ^/logout, roles: [IS_AUTHENTICATED_ANONYMOUSLY, IS_AUTHENTICATED_2FA_IN_PROGRESS] }
- { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/resetting, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: /(unread|starred|archive|all).xml$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: /(unread|starred|archive|annotated|all).xml$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/locale, role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: /tags/(.*).xml$, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/feed, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: /(unread|starred|archive).xml$, roles: IS_AUTHENTICATED_ANONYMOUSLY } # For backwards compatibility
- { path: /(unread|starred|archive|annotated).xml$, roles: IS_AUTHENTICATED_ANONYMOUSLY } # For backwards compatibility
- { path: ^/share, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/settings, roles: ROLE_SUPER_ADMIN }
- { path: ^/annotations, roles: ROLE_USER }

View file

@ -277,12 +277,26 @@ class EntryController extends Controller
return $this->showEntries('untagged', $request, $page);
}
/**
* Shows entries with annotations for current user.
*
* @param int $page
*
* @Route("/annotated/list/{page}", name="annotated", defaults={"page" = "1"})
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function showWithAnnotationsEntriesAction(Request $request, $page)
{
return $this->showEntries('annotated', $request, $page);
}
/**
* Shows random entry depending on the given type.
*
* @param string $type
*
* @Route("/{type}/random", name="random_entry", requirements={"type": "unread|starred|archive|untagged|all"})
* @Route("/{type}/random", name="random_entry", requirements={"type": "unread|starred|archive|untagged|annotated|all"})
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
@ -517,6 +531,20 @@ class EntryController extends Controller
);
}
/**
* List the entries with the same domain as the current one.
*
* @param int $page
*
* @Route("/domain/{id}/{page}", requirements={"id" = ".+"}, defaults={"page" = 1}, name="same_domain")
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function getSameDomainEntries(Request $request, $page = 1)
{
return $this->showEntries('same-domain', $request, $page);
}
/**
* Global method to retrieve entries depending on the given type
* It returns the response to be send.
@ -549,10 +577,16 @@ class EntryController extends Controller
$qb = $repository->getBuilderForArchiveByUser($this->getUser()->getId());
$formOptions['filter_archived'] = true;
break;
case 'annotated':
$qb = $repository->getBuilderForAnnotationsByUser($this->getUser()->getId());
break;
case 'unread':
$qb = $repository->getBuilderForUnreadByUser($this->getUser()->getId());
$formOptions['filter_unread'] = true;
break;
case 'same-domain':
$qb = $repository->getBuilderForSameDomainByUser($this->getUser()->getId(), $request->get('id'));
break;
case 'all':
$qb = $repository->getBuilderForAllByUser($this->getUser()->getId());
break;

View file

@ -47,7 +47,7 @@ class ExportController extends Controller
*
* @Route("/export/{category}.{format}", name="export_entries", requirements={
* "format": "epub|mobi|pdf|json|xml|txt|csv",
* "category": "all|unread|starred|archive|tag_entries|untagged|search"
* "category": "all|unread|starred|archive|tag_entries|untagged|search|annotated|same_domain"
* })
*
* @return \Symfony\Component\HttpFoundation\Response
@ -80,6 +80,13 @@ class ExportController extends Controller
->getResult();
$title = 'Search ' . $searchTerm;
} elseif ('annotated' === $category) {
$entries = $repository->getBuilderForAnnotationsByUser(
$this->getUser()->getId()
)->getQuery()
->getResult();
$title = 'With annotations';
} else {
$entries = $repository
->$methodBuilder($this->getUser()->getId())

View file

@ -93,7 +93,7 @@ class EntryFilterType extends AbstractType
'apply_filter' => function (QueryInterface $filterQuery, $field, $values) {
$value = $values['value'];
if (\strlen($value) <= 2 || empty($value)) {
return;
return false;
}
$expression = $filterQuery->getExpr()->like($field, $filterQuery->getExpr()->lower($filterQuery->getExpr()->literal('%' . $value . '%')));
@ -105,7 +105,7 @@ class EntryFilterType extends AbstractType
'apply_filter' => function (QueryInterface $filterQuery, $field, $values) {
$value = $values['value'];
if (false === \array_key_exists($value, Response::$statusTexts)) {
return;
return false;
}
$paramName = sprintf('%s', str_replace('.', '_', $field));
@ -129,7 +129,7 @@ class EntryFilterType extends AbstractType
'data' => $options['filter_unread'],
'apply_filter' => function (QueryInterface $filterQuery, $field, $values) {
if (false === $values['value']) {
return;
return false;
}
$expression = $filterQuery->getExpr()->eq('e.isArchived', 'false');
@ -137,10 +137,22 @@ class EntryFilterType extends AbstractType
return $filterQuery->createCondition($expression);
},
])
->add('isAnnotated', CheckboxFilterType::class, [
'label' => 'entry.filters.annotated_label',
'data' => $options['filter_annotated'],
'apply_filter' => function (QueryInterface $filterQuery, $field, $values) {
if (false === $values['value']) {
return false;
}
$qb = $filterQuery->getQueryBuilder();
$qb->innerJoin('e.annotations', 'a');
},
])
->add('previewPicture', CheckboxFilterType::class, [
'apply_filter' => function (QueryInterface $filterQuery, $field, $values) {
if (false === $values['value']) {
return;
return false;
}
$expression = $filterQuery->getExpr()->isNotNull($field);
@ -152,7 +164,7 @@ class EntryFilterType extends AbstractType
->add('isPublic', CheckboxFilterType::class, [
'apply_filter' => function (QueryInterface $filterQuery, $field, $values) {
if (false === $values['value']) {
return;
return false;
}
// is_public isn't a real field
@ -183,6 +195,7 @@ class EntryFilterType extends AbstractType
'filter_archived' => false,
'filter_starred' => false,
'filter_unread' => false,
'filter_annotated' => false,
]);
}
}

View file

@ -39,7 +39,34 @@ class EntryRepository extends EntityRepository
return $this
->getSortedQueryBuilderByUser($userId)
->andWhere('e.isArchived = false')
;
;
}
/**
* Retrieves entries with the same domain.
*
* @param int $userId
* @param int $entryId
*
* @return QueryBuilder
*/
public function getBuilderForSameDomainByUser($userId, $entryId)
{
$queryBuilder = $this->createQueryBuilder('e');
return $this
->getSortedQueryBuilderByUser($userId)
->andWhere('e.id <> :entryId')->setParameter('entryId', $entryId)
->andWhere(
$queryBuilder->expr()->in(
'e.domainName',
$this
->createQueryBuilder('e2')
->select('e2.domainName')
->where('e2.id = :entryId')->setParameter('entryId', $entryId)
->getDQL()
)
);
}
/**
@ -115,6 +142,21 @@ class EntryRepository extends EntityRepository
return $this->sortQueryBuilder($this->getRawBuilderForUntaggedByUser($userId));
}
/**
* Retrieve entries with annotations for a user.
*
* @param int $userId
*
* @return QueryBuilder
*/
public function getBuilderForAnnotationsByUser($userId)
{
return $this
->getSortedQueryBuilderByUser($userId)
->innerJoin('e.annotations', 'a')
;
}
/**
* Retrieve untagged entries for a user.
*
@ -552,6 +594,10 @@ class EntryRepository extends EntityRepository
$qb->leftJoin('e.tags', 't');
$qb->andWhere('t.id is null');
break;
case 'annotated':
$qb->leftJoin('e.annotations', 'a');
$qb->andWhere('a.id is not null');
break;
}
$ids = $qb->getQuery()->getArrayResult();

View file

@ -19,6 +19,7 @@ menu:
starred: Starred
archive: Archive
all_articles: All entries
with_annotations: With annotations
config: Config
tags: Tags
internal_settings: Internal Settings
@ -220,10 +221,12 @@ entry:
starred: Starred entries
archived: Archived entries
filtered: Filtered entries
with_annotations: Entries with annotations
filtered_tags: 'Filtered by tags:'
filtered_search: 'Filtered by search:'
untagged: Untagged entries
all: All entries
same_domain: Same domain
list:
number_on_the_page: '{0} There are no entries.|{1} There is one entry.|]1,Inf[ There are %count% entries.'
reading_time: estimated reading time
@ -237,6 +240,7 @@ entry:
toogle_as_star: Toggle starred
delete: Delete
export_title: Export
show_same_domain: Show articles with the same domain
assign_search_tag: Assign this search as a tag to each result
filters:
title: Filters
@ -244,6 +248,7 @@ entry:
archived_label: Archived
starred_label: Starred
unread_label: Unread
annotated_label: Annotated
preview_picture_label: Has a preview picture
preview_picture_help: Preview picture
is_public_label: Has a public link
@ -481,12 +486,12 @@ import:
description: Pocket import isn't configured.
admin_message: You need to define %keyurls%a pocket_consumer_key%keyurle%.
user_message: Your server admin needs to define an API Key for Pocket.
authorize_message: You can import your data from your Pocket account. You just have to click on the below button and authorize the application to connect to getpocket.com.
authorize_message: You can import your data from your Pocket account. You just have to click on the button below and authorize the application to connect to getpocket.com.
connect_to_pocket: Connect to Pocket and import data
wallabag_v1:
page_title: Import > Wallabag v1
description: This importer will import all your wallabag v1 articles. On your config page, click on "JSON export" in the "Export your wallabag data" section. You will have a "wallabag-export-1-xxxx-xx-xx.json" file.
how_to: Please select your wallabag export and click on the below button to upload and import it.
how_to: Please select your wallabag export and click on the button below to upload and import it.
wallabag_v2:
page_title: Import > Wallabag v2
description: This importer will import all your wallabag v2 articles. Go to All articles, then, on the export sidebar, click on "JSON". You will have a "All articles.json" file.
@ -496,7 +501,7 @@ import:
readability:
page_title: Import > Readability
description: This importer will import all your Readability articles. On the tools (https://www.readability.com/tools/) page, click on "Export your data" in the "Data Export" section. You will received an email to download a json (which does not end with .json in fact).
how_to: Please select your Readability export and click on the below button to upload and import it.
how_to: Please select your Readability export and click on the button below to upload and import it.
worker:
enabled: 'Import is made asynchronously. Once the import task is started, an external worker will handle jobs one at a time. The current service is:'
download_images_warning: You enabled downloading images for your articles. Combined with classic import it can take ages to proceed (or maybe failed). We <strong>strongly recommend</strong> to enable asynchronous import to avoid errors.
@ -511,11 +516,15 @@ import:
instapaper:
page_title: Import > Instapaper
description: This importer will import all your Instapaper articles. On the settings (https://www.instapaper.com/user) page, click on "Download .CSV file" in the "Export" section. A CSV file will be downloaded (like "instapaper-export.csv").
how_to: Please select your Instapaper export and click on the below button to upload and import it.
how_to: Please select your Instapaper export and click on the button below to upload and import it.
pinboard:
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.
how_to: Please select your Pinboard export and click on the button below 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 button below to upload and import it.
developer:
page_title: API clients management
welcome_message: Welcome to the wallabag API

View file

@ -32,6 +32,7 @@ menu:
site_credentials: اعتبارنامه‌های وب‌گاه
users_management: مدیریت کاربران
developer: مدیریت کارخواه‌های API
quickstart: "Quickstart"
top:
add_new_entry: افزودن مقالهٔ تازه
search: جستجو

View file

@ -19,6 +19,7 @@ menu:
starred: Favoris
archive: Lus
all_articles: Tous les articles
with_annotations: Avec annotations
config: Configuration
tags: Étiquettes
internal_settings: Configuration interne
@ -220,6 +221,7 @@ entry:
starred: Articles favoris
archived: Articles lus
filtered: Articles filtrés
with_annotations: Articles avec annotations
filtered_tags: 'Articles filtrés par étiquettes :'
filtered_search: 'Articles filtrés par recherche :'
untagged: Article sans étiquette
@ -243,6 +245,7 @@ entry:
archived_label: Lus
starred_label: Favoris
unread_label: Non lus
annotated_label: Annotés
preview_picture_label: A une photo
preview_picture_help: Photo
is_public_label: A un lien public

View file

@ -12,6 +12,10 @@
{{ 'entry.page_titles.filtered_tags'|trans }} {{ filter }}
{% elseif currentRoute == 'untagged' %}
{{ 'entry.page_titles.untagged'|trans }}
{% elseif currentRoute == 'same_domain' %}
{{ 'entry.page_titles.same_domain'|trans }}
{% elseif currentRoute == 'annotated' %}
{{ 'entry.page_titles.with_annotations'|trans }}
{% else %}
{{ 'entry.page_titles.unread'|trans }}
{% endif %}

View file

@ -8,6 +8,9 @@
</div>
<ul class="tools right">
<li>
<a title="{{ 'entry.list.show_same_domain'|trans }}" class="tool grey-text" href="{{ path('same_domain', { 'id': entry.id }) }}" data-action="same_domain" data-entry-id="{{ entry.id }}"><i class="material-icons">language</i></a>
</li>
<li>
<a title="{{ 'entry.list.toogle_as_read'|trans }}" class="tool grey-text" href="{{ path('archive_entry', { 'id': entry.id }) }}" data-action="archived" data-entry-id="{{ entry.id }}"><i class="material-icons">{% if entry.isArchived == 0 %}done{% else %}unarchive{% endif %}</i></a>
</li>

View file

@ -9,6 +9,7 @@
{% include "@WallabagCore/themes/material/Entry/Card/_content.html.twig" with {'entry': entry, 'withMetadata': true, 'subClass': 'metadata'} only %}
<ul class="tools-list hide-on-small-only">
<li>
<a title="{{ 'entry.list.show_same_domain'|trans }}" class="tool grey-text" href="{{ path('same_domain', { 'id': entry.id }) }}"><i class="material-icons">language</i></a>
<a title="{{ 'entry.list.toogle_as_read'|trans }}" class="tool grey-text" href="{{ path('archive_entry', { 'id': entry.id }) }}"><i class="material-icons">{% if entry.isArchived == 0 %}done{% else %}unarchive{% endif %}</i></a>
<a title="{{ 'entry.list.toogle_as_star'|trans }}" class="tool grey-text" href="{{ path('star_entry', { 'id': entry.id }) }}"><i class="material-icons">{% if entry.isStarred == 0 %}star_border{% else %}star{% endif %}</i></a>
<a title="{{ 'entry.list.delete'|trans }}" onclick="return confirm('{{ 'entry.confirm.delete'|trans|escape('js') }}')" class="tool grey-text delete" href="{{ path('delete_entry', { 'id': entry.id }) }}"><i class="material-icons">delete</i></a>

View file

@ -134,6 +134,11 @@
{{ form_label(form.isUnread) }}
</div>
<div class="input-field col s12 with-checkbox">
{{ form_widget(form.isAnnotated) }}
{{ form_label(form.isAnnotated) }}
</div>
<div class="col s12">
<label>{{ 'entry.filters.preview_picture_help'|trans }}</label>
</div>

View file

@ -40,6 +40,8 @@
{% set activeRoute = null %}
{% if currentRoute == 'all' or currentRouteFromQueryParams == 'all' %}
{% set activeRoute = 'all' %}
{% elseif currentRoute == 'annotated' or currentRouteFromQueryParams == 'annotated' %}
{% set activeRoute = 'annotated' %}
{% elseif currentRoute == 'archive' or currentRouteFromQueryParams == 'archive' %}
{% set activeRoute = 'archive' %}
{% elseif currentRoute == 'starred' or currentRouteFromQueryParams == 'starred' %}
@ -59,6 +61,9 @@
<li class="bold {% if activeRoute == 'archive' %}active{% endif %}">
<a class="waves-effect" href="{{ path('archive') }}">{{ 'menu.left.archive'|trans }} <span class="numberItems grey-text">{{ count_entries('archive') }}</span></a>
</li>
<li class="bold {% if activeRoute == 'annotated' %}active{% endif %}">
<a class="waves-effect" href="{{ path('annotated') }}">{{ 'menu.left.with_annotations'|trans }} <span class="numberItems grey-text">{{ count_entries('annotated') }}</span></a>
</li>
<li class="bold {% if activeRoute == 'all' %}active{% endif %}">
<a class="waves-effect" href="{{ path('all') }}">{{ 'menu.left.all_articles'|trans }} <span class="numberItems grey-text">{{ count_entries('all') }}</span></a>
</li>

View file

@ -95,6 +95,9 @@ class WallabagExtension extends AbstractExtension implements GlobalsInterface
case 'unread':
$qb = $this->entryRepository->getBuilderForUnreadByUser($user->getId());
break;
case 'annotated':
$qb = $this->entryRepository->getBuilderForAnnotationsByUser($user->getId());
break;
case 'all':
$qb = $this->entryRepository->getBuilderForAllByUser($user->getId());
break;

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

@ -3,6 +3,7 @@
namespace Tests\Wallabag\CoreBundle\Controller;
use Tests\Wallabag\CoreBundle\WallabagCoreTestCase;
use Wallabag\AnnotationBundle\Entity\Annotation;
use Wallabag\CoreBundle\Entity\Config;
use Wallabag\CoreBundle\Entity\Entry;
use Wallabag\CoreBundle\Entity\SiteCredential;
@ -448,6 +449,16 @@ class EntryControllerTest extends WallabagCoreTestCase
$this->assertSame(200, $client->getResponse()->getStatusCode());
}
public function testWithAnnotations()
{
$this->logInAs('admin');
$client = $this->getClient();
$crawler = $client->request('GET', '/annotated/list');
$this->assertSame(200, $client->getResponse()->getStatusCode());
$this->assertCount(2, $crawler->filter('ol.entries > li'));
}
public function testRangeException()
{
$this->logInAs('admin');
@ -915,6 +926,44 @@ class EntryControllerTest extends WallabagCoreTestCase
$this->assertCount(0, $crawler->filter($this->entryDataTestAttribute));
}
public function testFilterOnAnnotatedStatus()
{
$this->logInAs('admin');
$client = $this->getClient();
$crawler = $client->request('GET', '/all/list');
$form = $crawler->filter('button[id=submit-filter]')->form();
$data = [
'entry_filter[isAnnotated]' => true,
];
$crawler = $client->submit($form, $data);
$this->assertCount(2, $crawler->filter('ol.entries > li'));
$entry = new Entry($this->getLoggedInUser());
$entry->setUrl($this->url);
$em = $this->getClient()->getContainer()->get('doctrine.orm.entity_manager');
$user = $em
->getRepository('WallabagUserBundle:User')
->findOneByUserName('admin');
$annotation = new Annotation($user);
$annotation->setEntry($entry);
$annotation->setText('This is my annotation /o/');
$annotation->setQuote('content');
$this->getEntityManager()->persist($entry);
$this->getEntityManager()->flush();
$crawler = $client->submit($form, $data);
$this->assertCount(3, $crawler->filter('ol.entries > li'));
}
public function testPaginationWithFilter()
{
$this->logInAs('admin');
@ -1627,6 +1676,10 @@ class EntryControllerTest extends WallabagCoreTestCase
$this->assertSame(302, $client->getResponse()->getStatusCode());
$this->assertStringContainsString('/view/', $client->getResponse()->getTargetUrl(), 'Untagged random');
$client->request('GET', '/annotated/random');
$this->assertSame(302, $client->getResponse()->getStatusCode());
$this->assertStringContainsString('/view/', $client->getResponse()->getTargetUrl(), 'With annotations random');
$client->request('GET', '/all/random');
$this->assertSame(302, $client->getResponse()->getStatusCode());
$this->assertStringContainsString('/view/', $client->getResponse()->getTargetUrl(), 'All random');
@ -1708,4 +1761,14 @@ class EntryControllerTest extends WallabagCoreTestCase
$client->request('GET', '/delete/' . $entry2->getId());
$this->assertSame(404, $client->getResponse()->getStatusCode());
}
public function testGetSameDomainEntries()
{
$this->logInAs('admin');
$client = $this->getClient();
$crawler = $client->request('GET', '/domain/1');
$this->assertSame(200, $client->getResponse()->getStatusCode());
$this->assertCount(4, $crawler->filter('ol.entries > li'));
}
}

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->getTagsLabel();
$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

@ -24,6 +24,6 @@ class ImportControllerTest extends WallabagCoreTestCase
$crawler = $client->request('GET', '/import/');
$this->assertSame(200, $client->getResponse()->getStatusCode());
$this->assertSame(9, $crawler->filter('blockquote')->count());
$this->assertSame(10, $crawler->filter('blockquote')->count());
}
}

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

@ -1,13 +1,13 @@
[
{
"created_at": "2015-09-09 11:10:32 UTC",
"title": "Qualité de code - Intégration de php-git-hooks dans Symfony2 - Experts Symfony et Drupal - Lexik",
"url": "https://devblog.lexik.fr/git/qualite-de-code-integration-de-php-git-hooks-dans-symfony2-2842",
"description": null,
"tags": [
"tag1",
"tag2"
],
"is_saved": true
}
{
"created_at": "2015-09-09 11:10:32 UTC",
"title": "Qualité de code - Intégration de php-git-hooks dans Symfony2 - Experts Symfony et Drupal - Lexik",
"url": "https://devblog.lexik.fr/git/qualite-de-code-integration-de-php-git-hooks-dans-symfony2-2842",
"description": null,
"tags": [
"tag1",
"tag2"
],
"is_saved": true
}
]

View file

@ -1,63 +1,63 @@
{
"guid": "root________",
"title": "",
"index": 0,
"dateAdded": 1388166091504000,
"lastModified": 1472897622350000,
"id": 1,
"type": "text/x-moz-place-container",
"root": "placesRoot",
"children": [
"guid": "root________",
"title": "",
"index": 0,
"dateAdded": 1388166091504000,
"lastModified": 1472897622350000,
"id": 1,
"type": "text/x-moz-place-container",
"root": "placesRoot",
"children": [
{
"guid": "toolbar_____",
"title": "Barre personnelle",
"index": 1,
"dateAdded": 1388166091504000,
"lastModified": 1472897622263000,
"id": 3,
"annos": [
{
"guid": "toolbar_____",
"title": "Barre personnelle",
"index": 1,
"dateAdded": 1388166091504000,
"lastModified": 1472897622263000,
"id": 3,
"annos": [
{
"name": "bookmarkProperties/description",
"flags": 0,
"expires": 4,
"value": "Ajoutez des marque-pages dans ce dossier pour les voir apparaître sur votre barre personnelle"
}
],
"type": "text/x-moz-place-container",
"root": "toolbarFolder",
"children": [
{
"guid": "tard77lzbC5H",
"title": "Orange offre un meilleur réseau mobile que Bouygues et SFR, Free derrière - L'Express L'Expansion",
"index": 1,
"dateAdded": 1388166091644000,
"lastModified": 1388166091644000,
"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"
},
{
"guid": "E385l9vZ_LVn",
"title": "Le journaliste et cinéaste Claude Lanzmann est mort",
"index": 1,
"dateAdded": 1388166091544000,
"lastModified": 1388166091545000,
"id": 5,
"type": "text/x-moz-place",
"uri": "https://www.lemonde.fr/disparitions/article/2018/07/05/le-journaliste-et-cineaste-claude-lanzmann-est-mort_5326313_3382.html"
}
]
"name": "bookmarkProperties/description",
"flags": 0,
"expires": 4,
"value": "Ajoutez des marque-pages dans ce dossier pour les voir apparaître sur votre barre personnelle"
}
],
"type": "text/x-moz-place-container",
"root": "toolbarFolder",
"children": [
{
"guid": "tard77lzbC5H",
"title": "Orange offre un meilleur réseau mobile que Bouygues et SFR, Free derrière - L'Express L'Expansion",
"index": 1,
"dateAdded": 1388166091644000,
"lastModified": 1388166091644000,
"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"
},
{
"guid": "unfiled_____",
"title": "Autres marque-pages",
"index": 3,
"dateAdded": 1388166091504000,
"lastModified": 1388166091542000,
"id": 6,
"type": "text/x-moz-place-container",
"root": "unfiledBookmarksFolder"
"guid": "E385l9vZ_LVn",
"title": "Le journaliste et cinéaste Claude Lanzmann est mort",
"index": 1,
"dateAdded": 1388166091544000,
"lastModified": 1388166091545000,
"id": 5,
"type": "text/x-moz-place",
"uri": "https://www.lemonde.fr/disparitions/article/2018/07/05/le-journaliste-et-cineaste-claude-lanzmann-est-mort_5326313_3382.html"
}
]
]
},
{
"guid": "unfiled_____",
"title": "Autres marque-pages",
"index": 3,
"dateAdded": 1388166091504000,
"lastModified": 1388166091542000,
"id": 6,
"type": "text/x-moz-place-container",
"root": "unfiledBookmarksFolder"
}
]
}

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"
}
]

View file

@ -1,25 +1,25 @@
{
"bookmarks": [
{
"article__excerpt": "This is a guest post from Moritz Beller from the Delft University of Technology in The Netherlands. His team produced amazing research on several million Travis CI builds, creating invaluable&hellip;",
"favorite": false,
"date_archived": "2016-08-02T06:49:30",
"article__url": "https://blog.travis-ci.com/2016-07-28-what-we-learned-from-analyzing-2-million-travis-builds/",
"date_added": "2016-08-01T05:24:16",
"date_favorited": null,
"article__title": "Travis",
"archive": true
},
{
"article__excerpt": "The GraphQL Type system describes the capabilities of a GraphQL server and is used to determine if a query is valid. The type system also describes the input types of query variables to determine if&hellip;",
"favorite": false,
"date_archived": "2016-07-19T06:48:31",
"article__url": "https://facebook.github.io/graphql/October2016/",
"date_added": "2016-06-24T17:50:16",
"date_favorited": null,
"article__title": "GraphQL",
"archive": true
}
],
"recommendations": []
"bookmarks": [
{
"article__excerpt": "This is a guest post from Moritz Beller from the Delft University of Technology in The Netherlands. His team produced amazing research on several million Travis CI builds, creating invaluable&hellip;",
"favorite": false,
"date_archived": "2016-08-02T06:49:30",
"article__url": "https://blog.travis-ci.com/2016-07-28-what-we-learned-from-analyzing-2-million-travis-builds/",
"date_added": "2016-08-01T05:24:16",
"date_favorited": null,
"article__title": "Travis",
"archive": true
},
{
"article__excerpt": "The GraphQL Type system describes the capabilities of a GraphQL server and is used to determine if a query is valid. The type system also describes the input types of query variables to determine if&hellip;",
"favorite": false,
"date_archived": "2016-07-19T06:48:31",
"article__url": "https://facebook.github.io/graphql/October2016/",
"date_added": "2016-06-24T17:50:16",
"date_favorited": null,
"article__title": "GraphQL",
"archive": true
}
],
"recommendations": []
}

View file

@ -1,29 +1,29 @@
{
"bookmarks": [
{
"article__excerpt": "When Twitter started it had so much promise to change the way we communicate. But now it has been ruined by the amount of garbage and hate we have to wade through. It&#x2019;s like that polluted&hellip;",
"favorite": false,
"date_archived": null,
"article__url": "https://venngage.com/blog/hashtags-are-worthless/",
"date_added": "2016-08-25T12:05:00",
"date_favorited": null,
"article__title": "We Looked At 167,943 Tweets & Found Out Hashtags Are Worthless",
"archive": false
},
{
"article__title": "No title found",
"article__url": "http://news.nationalgeographic.com/2016/02/160211-albatrosses-mothers-babies-animals-science/&sf20739758=1",
"archive": false,
"date_added": "2016-09-08T11:55:58+0200",
"favorite": true
},
{
"archive": 0,
"date_added": "2016-09-08T11:55:58+0200",
"favorite": 0,
"article__title": "Bordeaux: Poche, chocolatine… Une association traduit aux étudiants étrangers les mots du Sud-Ouest",
"article__url": "https://www.20minutes.fr/bordeaux/2120479-20170823-bordeaux-poche-chocolatine-association-traduit-etudiants-etrangers-mots-sud-ouest"
}
],
"recommendations": []
"bookmarks": [
{
"article__excerpt": "When Twitter started it had so much promise to change the way we communicate. But now it has been ruined by the amount of garbage and hate we have to wade through. It&#x2019;s like that polluted&hellip;",
"favorite": false,
"date_archived": null,
"article__url": "https://venngage.com/blog/hashtags-are-worthless/",
"date_added": "2016-08-25T12:05:00",
"date_favorited": null,
"article__title": "We Looked At 167,943 Tweets & Found Out Hashtags Are Worthless",
"archive": false
},
{
"article__title": "No title found",
"article__url": "http://news.nationalgeographic.com/2016/02/160211-albatrosses-mothers-babies-animals-science/&sf20739758=1",
"archive": false,
"date_added": "2016-09-08T11:55:58+0200",
"favorite": true
},
{
"archive": 0,
"date_added": "2016-09-08T11:55:58+0200",
"favorite": 0,
"article__title": "Bordeaux: Poche, chocolatine… Une association traduit aux étudiants étrangers les mots du Sud-Ouest",
"article__url": "https://www.20minutes.fr/bordeaux/2120479-20170823-bordeaux-poche-chocolatine-association-traduit-etudiants-etrangers-mots-sud-ouest"
}
],
"recommendations": []
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long