diff --git a/README.md b/README.md index 7188ede..18b114e 100644 --- a/README.md +++ b/README.md @@ -11,38 +11,14 @@ entity within any other Drupal entity. This module provides Instagram integration for Media entity (i.e. media type provider plugin). -### Without Instagram API -If you need just to embembed instagrams you can use this module without using Instagram's API. That will give you access to the shortcode field available from the url/embed code. - +### Instagram API +This module uses Instagrams oembed API to fetch the instagram html and all the metadata. You will need to: - Create a Media bundle with the type provider "Instagram". - On that bundle create a field for the Instagram url/source (this should be a plain text or link field). - Return to the bundle configuration and set "Field with source information" to use that field. -**IMPORTANT:** beware that there is limit on the number of request that can be made for free. [Read more](http://instagram.com/developer/endpoints/) - - -### With Instagram API -If you need to get other fields, you will need to use Instagram's API. To get this working follow the steps below: - -- Download and enable [composer_manager](https://www.drupal.org/project/composer_manager). Also make sure you have [drush](https://github.com/drush-ops/drush) installed. -- Run the following commands from within your Drupal root directory to download the [library](https://github.com/galen/PHP-Instagram-API) that will handle the communication: - -``` - // Rebuild the composer.json file with updated dependencies. - $ drush composer-json-rebuild - - // Install the required packages. - $ drush composer-manager install -``` -- Create a instagram app on the instagram [developer site](http://instagram.com/developer/register/) -- Enable read access for your instagram app -- Grab your client ID from the instagram developer site -- In your Instagram bundle configuration set "Whether to use Instagram api to fetch instagrams or not" to "Yes"" and paste in the "Client ID" - -**NOTE:** We are currently using a patched version of the library with the ability to get the media by shortcode. This is the pull request for it: https://github.com/galen/PHP-Instagram-API/pull/46/files - ### Storing field values If you want to store the fields that are retrieved from Instagram you should create appropriate fields on the created media bundle (id) and map this to the fields provided by Instagram.php. @@ -62,15 +38,12 @@ description: 'Instagram photo/video to be used with content.' type: instagram type_configuration: source_field: link - use_instagram_api: '1' - client_id: YOUR_CLIENT_ID field_map: id: instagram_id type: instagram_type thumbnail: instagram_thumbnail username: instagram_username caption: instagram_caption - tags: instagram_tags ``` Project page: http://drupal.org/project/media_entity_instagram diff --git a/config/schema/media_entity_instagram.schema.yml b/config/schema/media_entity_instagram.schema.yml index 0c894ed..9d74d44 100644 --- a/config/schema/media_entity_instagram.schema.yml +++ b/config/schema/media_entity_instagram.schema.yml @@ -13,20 +13,14 @@ media_entity.bundle.type.instagram: source_field: type: string label: 'Field with embed code/URL' - use_instagram_api: - type: boolean - label: 'Whether to use instagram api or not' - client_id: - type: string - label: 'Field with client id' field.formatter.settings.instagram_embed: type: mapping - label: 'Image field display format settings' + label: 'Instagram field display format settings' mapping: width: type: integer - label: 'Width' - height: - type: integer - label: 'Height' + label: 'Max-Width' + hidecaption: + type: boolean + label: 'Caption hidden' diff --git a/media_entity_instagram.libraries.yml b/media_entity_instagram.libraries.yml new file mode 100644 index 0000000..5b25e3a --- /dev/null +++ b/media_entity_instagram.libraries.yml @@ -0,0 +1,8 @@ +instagram.embeds: + remote: //platform.instagram.com/en_US/embeds.js + version: 1.0.0 + license: + name: MIT + gpl-compatible: true + js: + //platform.instagram.com/en_US/embeds.js: { type: external, minified: true } diff --git a/media_entity_instagram.module b/media_entity_instagram.module index b3d9bbc..13f8bc3 100644 --- a/media_entity_instagram.module +++ b/media_entity_instagram.module @@ -1 +1,15 @@ [ + 'variables' => [ + 'post' => NULL, + 'shortcode' => NULL, + ], + ], + ]; +} diff --git a/media_entity_instagram.services.yml b/media_entity_instagram.services.yml new file mode 100644 index 0000000..4a233b3 --- /dev/null +++ b/media_entity_instagram.services.yml @@ -0,0 +1,15 @@ +services: + media_entity_instagram.instagram_embed_fetcher: + class: '\Drupal\media_entity_instagram\InstagramEmbedFetcher' + arguments: + - '@http_client' + - '@logger.factory' + - '@media_entity_instagram.cache.instagram_posts' + + media_entity_instagram.cache.instagram_posts: + class: '\Drupal\Core\Cache\CacheBackendInterface' + tags: + - { name: cache.bin, default_backend: cache.backend.chainedfast } + factory: cache_factory:get + arguments: + - instagram_posts diff --git a/src/InstagramEmbedFetcher.php b/src/InstagramEmbedFetcher.php new file mode 100644 index 0000000..1eb8e0c --- /dev/null +++ b/src/InstagramEmbedFetcher.php @@ -0,0 +1,111 @@ +httpClient = $client; + $this->loggerFactory = $loggerFactory; + $this->cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function fetchInstagramEmbed($shortcode, $hidecaption = FALSE, $maxWidth = NULL) { + + $options = [ + 'url' => self::INSTAGRAM_URL . $shortcode . '/', + 'hidecaption' => (int) $hidecaption, + 'omitscript' => 1, + ]; + + if ($maxWidth) { + $options['maxwidth'] = $maxWidth; + } + + // Tweets don't change much, so pull it out of the cache (if we have one) + // if this one has already been fetched. + $cacheKey = md5(serialize($options)); + if ($this->cache && $cached_instagram_post = $this->cache->get($cacheKey)) { + return $cached_instagram_post->data; + } + + $queryParameter = UrlHelper::buildQuery($options); + + try { + $response = $this->httpClient->request('GET', self::INSTAGRAM_API . '?' . $queryParameter); + if ($response->getStatusCode() === 200) { + $data = Json::decode($response->getBody()->getContents()); + } + } + catch (RequestException $e) { + $this->loggerFactory->get('media_entity_instagram')->error("Could not retrieve Instagram post $shortcode.", Error::decodeException($e)); + } + + // If we got data from Instagram oEmbed request, return data. + if (isset($data)) { + + // If we have a cache, store the response for future use. + if ($this->cache) { + // Instagram posts don't change often, so the response should expire + // from the cache on its own in 90 days. + $this->cache->set($cacheKey, $data, time() + (86400 * 90)); + } + + return $data; + } + return FALSE; + } + +} diff --git a/src/InstagramEmbedFetcherInterface.php b/src/InstagramEmbedFetcherInterface.php new file mode 100644 index 0000000..c601075 --- /dev/null +++ b/src/InstagramEmbedFetcherInterface.php @@ -0,0 +1,25 @@ +fetcher = $fetcher; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['label'], + $configuration['view_mode'], + $configuration['third_party_settings'], + $container->get('media_entity_instagram.instagram_embed_fetcher') + ); + } + + /** * {@inheritdoc} */ public function viewElements(FieldItemListInterface $items, $langcode) { @@ -31,29 +66,37 @@ class InstagramEmbedFormatter extends FormatterBase { $settings = $this->getSettings(); foreach ($items as $delta => $item) { $matches = []; + foreach (Instagram::$validationRegexp as $pattern => $key) { - if (preg_match($pattern, $this->getEmbedCode($item), $matches)) { - break; + if (preg_match($pattern, $this->getEmbedCode($item), $item_matches)) { + $matches[] = $item_matches; } } + if (!empty($matches)) { + $matches = reset($matches); + } + if (!empty($matches['shortcode'])) { - $element[$delta] = [ - '#type' => 'html_tag', - '#tag' => 'iframe', - '#attributes' => [ - 'allowtransparency' => 'true', - 'frameborder' => 0, - 'position' => 'absolute', - 'scrolling' => 'no', - 'src' => '//instagram.com/p/' . $matches['shortcode'] . '/embed/', - 'width' => $settings['width'], - 'height' => $settings['height'], - ], - ]; + + if ($instagram = $this->fetcher->fetchInstagramEmbed($matches['shortcode'], $settings['hidecaption'], $settings['width'])) { + $element[$delta] = [ + '#theme' => 'media_entity_instagram_post', + '#post' => (string) $instagram['html'], + '#shortcode' => $matches['shortcode'], + ]; + } } } + if (!empty($element)) { + $element['#attached'] = [ + 'library' => [ + 'media_entity_instagram/instagram.embeds', + ], + ]; + } + return $element; } @@ -62,8 +105,8 @@ class InstagramEmbedFormatter extends FormatterBase { */ public static function defaultSettings() { return array( - 'width' => '480', - 'height' => '640', + 'width' => NULL, + 'hidecaption' => FALSE, ) + parent::defaultSettings(); } @@ -77,16 +120,15 @@ class InstagramEmbedFormatter extends FormatterBase { '#type' => 'number', '#title' => $this->t('Width'), '#default_value' => $this->getSetting('width'), - '#min' => 1, - '#description' => $this->t('Width of instagram.'), + '#min' => 320, + '#description' => $this->t('Max width of instagram.'), ); - $elements['height'] = array( - '#type' => 'number', - '#title' => $this->t('Height'), - '#default_value' => $this->getSetting('height'), - '#min' => 1, - '#description' => $this->t('Height of instagram.'), + $elements['hidecaption'] = array( + '#type' => 'checkbox', + '#title' => $this->t('Caption hidden'), + '#default_value' => $this->getSetting('hidecaption'), + '#description' => $this->t('Enable to hide caption of Instagram posts.'), ); return $elements; @@ -96,14 +138,21 @@ class InstagramEmbedFormatter extends FormatterBase { * {@inheritdoc} */ public function settingsSummary() { - return [ - $this->t('Width: @width px', [ + $settings = $this->getSettings(); + + $summary = []; + + if ($this->getSetting('width')) { + $summary[] = $this->t('Width: @width px', [ '@width' => $this->getSetting('width'), - ]), - $this->t('Height: @height px', [ - '@height' => $this->getSetting('height'), - ]), - ]; + ]); + } + + $summary[] = $this->t('Caption: @hidecaption', [ + '@hidecaption' => $settings['hidecaption'] ? $this->t('Hidden') : $this->t('Visible'), + ]); + + return $summary; } } diff --git a/src/Plugin/MediaEntity/Type/Instagram.php b/src/Plugin/MediaEntity/Type/Instagram.php index 04e3756..8d6f8b6 100644 --- a/src/Plugin/MediaEntity/Type/Instagram.php +++ b/src/Plugin/MediaEntity/Type/Instagram.php @@ -9,6 +9,8 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\media_entity\MediaInterface; use Drupal\media_entity\MediaTypeBase; use Drupal\media_entity\MediaTypeException; +use Drupal\media_entity_instagram\InstagramEmbedFetcher; +use GuzzleHttp\Client; use Symfony\Component\DependencyInjection\ContainerInterface; use Instagram\Instagram as InstagramApi; @@ -31,6 +33,20 @@ class Instagram extends MediaTypeBase { protected $configFactory; /** + * The instagram fetcher. + * + * @var InstagramEmbedFetcher + */ + protected $fetcher; + + /** + * Guzzle client. + * + * @var Client + */ + protected $httpClient; + + /** * Constructs a new class instance. * * @param array $configuration @@ -45,10 +61,14 @@ class Instagram extends MediaTypeBase { * Entity field manager service. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * Config factory service. + * @param InstagramEmbedFetcher $fetcher + * Instagram fetcher service. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, ConfigFactoryInterface $config_factory) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, ConfigFactoryInterface $config_factory, InstagramEmbedFetcher $fetcher, Client $httpClient) { parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $entity_field_manager, $config_factory->get('media_entity.settings')); $this->configFactory = $config_factory; + $this->fetcher = $fetcher; + $this->httpClient = $httpClient; } /** @@ -61,20 +81,13 @@ class Instagram extends MediaTypeBase { $plugin_definition, $container->get('entity_type.manager'), $container->get('entity_field.manager'), - $container->get('config.factory') + $container->get('config.factory'), + $container->get('media_entity_instagram.instagram_embed_fetcher'), + $container->get('http_client') ); } /** - * {@inheritdoc} - */ - public function defaultConfiguration() { - return [ - 'use_instagram_api' => FALSE, - ]; - } - - /** * List of validation regular expressions. * * @var array @@ -88,24 +101,16 @@ class Instagram extends MediaTypeBase { * {@inheritdoc} */ public function providedFields() { - $fields = array( + return [ 'shortcode' => $this->t('Instagram shortcode'), - ); - - if ($this->configuration['use_instagram_api']) { - $fields += array( - 'id' => $this->t('Media ID'), - 'type' => $this->t('Media type: image or video'), - 'thumbnail' => $this->t('Link to the thumbnail'), - 'thumbnail_local' => $this->t("Copies thumbnail locally and return it's URI"), - 'thumbnail_local_uri' => $this->t('Returns local URI of the thumbnail'), - 'username' => $this->t('Author of the post'), - 'caption' => $this->t('Caption'), - 'tags' => $this->t('Tags'), - ); - } - - return $fields; + 'id' => $this->t('Media ID'), + 'type' => $this->t('Media type: image or video'), + 'thumbnail' => $this->t('Link to the thumbnail'), + 'thumbnail_local' => $this->t("Copies thumbnail locally and return it's URI"), + 'thumbnail_local_uri' => $this->t('Returns local URI of the thumbnail'), + 'username' => $this->t('Author of the post'), + 'caption' => $this->t('Caption'), + ]; } /** @@ -123,64 +128,66 @@ class Instagram extends MediaTypeBase { } // If we have auth settings return the other fields. - if ($this->configuration['use_instagram_api'] && $instagram = $this->fetchInstagram($matches['shortcode'])) { + if ($instagram = $this->fetcher->fetchInstagramEmbed($matches['shortcode'])) { switch ($name) { case 'id': - if (isset($instagram->id)) { - return $instagram->id; + if (isset($instagram['media_id'])) { + return $instagram['media_id']; } return FALSE; case 'type': - if (isset($instagram->type)) { - return $instagram->type; + if (isset($instagram['type'])) { + return $instagram['type']; } return FALSE; case 'thumbnail': - if (isset($instagram->images->thumbnail->url)) { - return $instagram->images->thumbnail->url; + if (isset($instagram['thumbnail_url'])) { + return $instagram['thumbnail_url']; } return FALSE; case 'thumbnail_local': - if (isset($instagram->images->thumbnail->url)) { - $local_uri = $this->configFactory->get('media_entity_instagram.settings')->get('local_images') . '/' . $matches['shortcode'] . '.' . pathinfo($instagram->images->thumbnail->url, PATHINFO_EXTENSION); + $local_uri = $this->getField($media, 'thumbnail_local_uri'); + + if ($local_uri) { + if (file_exists($local_uri)) { + return $local_uri; + } + else { - if (!file_exists($local_uri)) { - file_prepare_directory($local_uri, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS); + $directory = dirname($local_uri); + file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS); - $image = file_get_contents($local_uri); - file_unmanaged_save_data($image, $local_uri, FILE_EXISTS_REPLACE); + $image_url = $this->getField($media, 'thumbnail'); - return $local_uri; + $response = $this->httpClient->get($image_url); + if ($response->getStatusCode() == 200) { + return file_unmanaged_save_data($response->getBody(), $local_uri, FILE_EXISTS_REPLACE); + } } } return FALSE; case 'thumbnail_local_uri': - if (isset($instagram->images->thumbnail->url)) { - return $this->configFactory->get('media_entity_instagram.settings')->get('local_images') . '/' . $matches['shortcode'] . '.' . pathinfo($instagram->images->thumbnail->url, PATHINFO_EXTENSION); + if (isset($instagram['thumbnail_url'])) { + return $this->configFactory->get('media_entity_instagram.settings')->get('local_images') . '/' . $matches['shortcode'] . '.' . pathinfo(parse_url($instagram['thumbnail_url'], PHP_URL_PATH), PATHINFO_EXTENSION); } return FALSE; case 'username': - if (isset($instagram->user->username)) { - return $instagram->user->username; + if (isset($instagram['author_name'])) { + return $instagram['author_name']; } return FALSE; case 'caption': - if (isset($instagram->caption->text)) { - return $instagram->caption->text; + if (isset($instagram['title'])) { + return $instagram['title']; } return FALSE; - case 'tags': - if (isset($instagram->tags)) { - return implode(" ", $instagram->tags); - } - return FALSE; } } @@ -208,29 +215,6 @@ class Instagram extends MediaTypeBase { '#options' => $options, ]; - $form['use_instagram_api'] = [ - '#type' => 'select', - '#title' => $this->t('Whether to use Instagram api to fetch instagrams or not.'), - '#description' => $this->t("In order to use Instagram's api you have to create a developer account and an application. For more information consult the readme file."), - '#default_value' => empty($this->configuration['use_instagram_api']) ? 0 : $this->configuration['use_instagram_api'], - '#options' => [ - 0 => $this->t('No'), - 1 => $this->t('Yes'), - ], - ]; - - // @todo Evaluate if this should be a site-wide configuration. - $form['client_id'] = [ - '#type' => 'textfield', - '#title' => $this->t('Client ID'), - '#default_value' => empty($this->configuration['client_id']) ? NULL : $this->configuration['client_id'], - '#states' => [ - 'visible' => [ - ':input[name="type_configuration[instagram][use_instagram_api]"]' => ['value' => '1'], - ], - ], - ]; - return $form; } @@ -281,44 +265,6 @@ class Instagram extends MediaTypeBase { } /** - * Get a single instagram. - * - * @param string $shortcode - * The instagram shortcode. - */ - protected function fetchInstagram($shortcode) { - $instagram = &drupal_static(__FUNCTION__); - - if (!isset($instagram)) { - // Check for dependencies. - // @todo There is perhaps a better way to do that. - if (!class_exists('\Instagram\Instagram')) { - drupal_set_message($this->t('Instagram library is not available. Consult the README.md for installation instructions.'), 'error'); - return; - } - - if (!isset($this->configuration['client_id'])) { - drupal_set_message($this->t('The client ID is not available. Consult the README.md for installation instructions.'), 'error'); - return; - } - if (empty($this->configuration['client_id'])) { - drupal_set_message($this->t('The client ID is missing. Please add it in your Instagram settings.'), 'error'); - return; - } - $instagram_object = new InstagramApi(); - $instagram_object->setClientID($this->configuration['client_id']); - $result = $instagram_object->getMediaByShortcode($shortcode)->getData(); - - if ($result) { - return $result; - } - else { - throw new MediaTypeException(NULL, 'The media could not be retrieved.'); - } - } - } - - /** * {@inheritdoc} */ public function getDefaultThumbnail() { @@ -342,7 +288,6 @@ class Instagram extends MediaTypeBase { public function getDefaultName(MediaInterface $media) { // Try to get some fields that need the API, if not available, just use the // shortcode as default name. - $username = $this->getField($media, 'username'); $id = $this->getField($media, 'id'); if ($username && $id) { diff --git a/src/Tests/InstagramEmbedFormatterTest.php b/src/Tests/InstagramEmbedFormatterTest.php index 032aeea..d95eeb3 100644 --- a/src/Tests/InstagramEmbedFormatterTest.php +++ b/src/Tests/InstagramEmbedFormatterTest.php @@ -12,8 +12,6 @@ use Drupal\media_entity\Tests\MediaTestTrait; */ class InstagramEmbedFormatterTest extends WebTestBase { - use MediaTestTrait; - /** * Modules to enable. * @@ -28,6 +26,8 @@ class InstagramEmbedFormatterTest extends WebTestBase { 'block', ); + use MediaTestTrait; + /** * The test user. * @@ -138,24 +138,36 @@ class InstagramEmbedFormatterTest extends WebTestBase { $this->drupalPostForm(NULL, $edit, t('Save')); $this->assertText('Your settings have been saved.'); + // First set absolute size of the embed. + $this->drupalPostAjaxForm(NULL, [], 'field_embed_code_settings_edit'); + $edit = [ + 'fields[field_embed_code][settings_edit_form][settings][hidecaption]' => FALSE, + ]; + $this->drupalPostAjaxForm(NULL, $edit, 'field_embed_code_plugin_settings_update'); + $this->drupalPostForm(NULL, [], t('Save')); + $this->assertText('Your settings have been saved.'); + $this->assertText('Caption: Visible'); + // Create and save the media with an instagram media code. $this->drupalGet('media/add/' . $bundle->id()); - // Random image from instagram. - $instagram = '

Weekend Hashtag Project: #WHParchitecture The goal this weekend is to photograph architecture, and will be curated by Shoair Mavlian (@shoair_m), photography curator of the Tate Modern museum (@tate) in London, which is celebrating the opening of its new building, the Switch House, today. “Architecture is present everywhere in our everyday surroundings — from cutting-edge museum buildings and towering glass office blocks to bus stops, schools and sports stadiums,” says Shoair. “The world looks very different if we stop, rethink (and photograph!) the everyday examples which surround us.” Here is how Shoair says to get started: Focus on the different ways buildings and landmarks affect you. “Architectural structures play an important role in our everyday lives, from the private spaces we share with family to communal spaces we work or study in to shared public spaces we use to gather and come together,” says Shoair. Step back to show buildings within the context of their landscapes and surroundings (whether a skyscraper towering over an urban skyline or a thatched-roof house in the middle of an open field). Don’t forget to go inside the spaces you photograph to capture interesting details in walls, doorways and stairwells — as well as the people that inhabit or utilize the space. PROJECT RULES: Please add the #WHParchitecture hashtag only to photos and videos taken over this weekend and only submit your own visuals to the project. If you include music in your submissions, please only use music to which you own the rights. Any tagged visual taken over the weekend is eligible to be featured next week. Photo of @tate by @freepy

A photo posted by Instagram (@instagram) on

'; + // Example instagram from https://www.instagram.com/developer/embedding/ + $instagram = 'https://www.instagram.com/p/bNd86MSFv6/'; $edit = [ - 'name[0][value]' => 'Title', + 'name[0][value]' => 'My test instagram', 'field_embed_code[0][value]' => $instagram, ]; $this->drupalPostForm(NULL, $edit, t('Save and publish')); // Assert that the media has been successfully saved. - $this->assertText('Title'); + $this->assertText('My test instagram'); $this->assertText('Embed code'); - // Assert that the formatter exists on this page. - $this->assertFieldByXPath('//iframe'); + // Assert that the formatter exists on this page and that it has absolute + // size. + $this->assertFieldByXPath('//blockquote'); + $this->assertRaw('platform.instagram.com/en_US/embeds.js'); } } diff --git a/templates/media-entity-instagram-post.html.twig b/templates/media-entity-instagram-post.html.twig new file mode 100644 index 0000000..604f750 --- /dev/null +++ b/templates/media-entity-instagram-post.html.twig @@ -0,0 +1,9 @@ +{# +/** + * @file + * Default theme implementation to display a post. + * + * @ingroup themeable + */ +#} +{{ post|raw }}