diff --git a/.github/workflows/addtoprojects.yml b/.github/workflows/addtoprojects.yml index 7bebb51..a2b965b 100644 --- a/.github/workflows/addtoprojects.yml +++ b/.github/workflows/addtoprojects.yml @@ -18,4 +18,4 @@ jobs: # You can target a repository in a different organization # to the issue project-url: https://github.com/orgs/emulsify-ds/projects/6 - github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + github-token: ${{ secrets.GH_TOKEN }} \ No newline at end of file diff --git a/config/install/emulsify_tools_favicon.settings.yml b/config/install/emulsify_tools_favicon.settings.yml new file mode 100644 index 0000000..1a2d49b --- /dev/null +++ b/config/install/emulsify_tools_favicon.settings.yml @@ -0,0 +1,3 @@ +themes: [] + + diff --git a/config/schema/emulsify_tools_favicon.schema.yml b/config/schema/emulsify_tools_favicon.schema.yml new file mode 100644 index 0000000..a39770e --- /dev/null +++ b/config/schema/emulsify_tools_favicon.schema.yml @@ -0,0 +1,20 @@ +emulsify_tools_favicon.emulsify_tools_favicon.*: + type: config_entity + label: 'Favicon config' + mapping: + id: + type: string + label: 'ID' + label: + type: label + label: 'Label' + uuid: + type: string + tags: + type: sequence + label: 'Tags' + archive: + type: sequence + label: 'Archive' + + diff --git a/emulsify_tools.info.yml b/emulsify_tools.info.yml index 071a839..bbceac1 100644 --- a/emulsify_tools.info.yml +++ b/emulsify_tools.info.yml @@ -4,6 +4,9 @@ description: Toolset of useful Twig extensions and a subtheme generation command core_version_requirement: ^10 || ^11 package: Emulsify +dependencies: + - drupal:file + # Information added by Drupal.org packaging script on 2023-02-16 version: '1.0.2' project: 'emulsify_tools' diff --git a/emulsify_tools.links.action.yml b/emulsify_tools.links.action.yml new file mode 100644 index 0000000..9a9310f --- /dev/null +++ b/emulsify_tools.links.action.yml @@ -0,0 +1,7 @@ +entity.emulsify_tools_favicon.add_form: + route_name: 'entity.emulsify_tools_favicon.add_form' + title: 'Add Favicon Package' + appears_on: + - entity.emulsify_tools_favicon.collection + + diff --git a/emulsify_tools.links.menu.yml b/emulsify_tools.links.menu.yml new file mode 100644 index 0000000..edafc4b --- /dev/null +++ b/emulsify_tools.links.menu.yml @@ -0,0 +1,6 @@ +entity.emulsify_tools_favicon.collection: + title: 'Favicons' + route_name: entity.emulsify_tools_favicon.collection + description: 'List Favicon packages' + parent: system.admin_structure + weight: 99 diff --git a/emulsify_tools.module b/emulsify_tools.module new file mode 100644 index 0000000..9ff454a --- /dev/null +++ b/emulsify_tools.module @@ -0,0 +1,75 @@ +' . t('About') . ''; + $output .= '

' . t('Emulsify Tools provides helpful developer utilities, including favicon management from realfavicongenerator.net.') . '

'; + return $output; + + default: + } +} + +/** + * Implements hook_page_attachments_alter(). + */ +function emulsify_tools_page_attachments_alter(array &$attachments) { + $theme = \Drupal::theme()->getActiveTheme()->getName(); + $faviconManager = \Drupal::service('emulsify_tools.favicon_manager'); + + if ($tags = $faviconManager->getTags($theme)) { + // Remove default favicon from html_head_link. + if (!empty($attachments['#attached']['html_head_link'])) { + foreach ($attachments['#attached']['html_head_link'] as $i => $item) { + if (!empty($item) && is_array($item)) { + foreach ($item as $ii => $iitem) { + if (isset($iitem['rel']) && in_array($iitem['rel'], ['shortcut icon', 'icon'])) { + unset($attachments['#attached']['html_head_link'][$i][$ii]); + } + } + if (empty($attachments['#attached']['html_head_link'][$i])) { + unset($attachments['#attached']['html_head_link'][$i]); + } + } + } + if (empty($attachments['#attached']['html_head_link'])) { + unset($attachments['#attached']['html_head_link']); + } + } + // Attach favicon tags. + $attachments['#attached']['html_head'][] = [ + [ + '#type' => 'markup', + '#markup' => $tags, + '#allowed_tags' => ['link', 'meta'], + '#cache' => [ + 'tags' => $faviconManager->getCacheTags(), + ], + ], + 'emulsify_tools_favicon', + ]; + } +} + +/** + * Load favicon by theme. + * + * @param string $theme_id + * The theme id. + * + * @return \Drupal\emulsify_tools\Entity\Favicon|null + * The Favicon entity. + */ +function emulsify_tools_favicon_load_by_theme($theme_id = NULL) { + if (empty($active_theme)) { + $active_theme = \Drupal::theme()->getActiveTheme()->getName(); + } + return \Drupal::service('emulsify_tools.favicon_manager')->loadFavicon($active_theme); +} diff --git a/emulsify_tools.services.yml b/emulsify_tools.services.yml index 238d8dc..43428aa 100644 --- a/emulsify_tools.services.yml +++ b/emulsify_tools.services.yml @@ -16,3 +16,6 @@ services: - { name: drush.command } emulsify_tools.subtheme_generator: class: Drupal\emulsify_tools\SubThemeGenerator + emulsify_tools.favicon_manager: + class: Drupal\emulsify_tools\FaviconManager + arguments: ['@entity_type.manager', '@config.factory', '@cache.data'] diff --git a/src/Entity/Favicon.php b/src/Entity/Favicon.php new file mode 100644 index 0000000..59eecc8 --- /dev/null +++ b/src/Entity/Favicon.php @@ -0,0 +1,237 @@ + $tag) { + $tags[$pos] = trim($tag); + } + $this->set('tags', $tags); + } + + public function getTagsAsString() { + $tags = $this->get('tags'); + return $tags ? implode(PHP_EOL, $tags) : ''; + } + + public function getTags() { + return $this->get('tags'); + } + + public function getManifest() { + if (empty($this->manifest)) { + $this->manifest = []; + $path = $this->getDirectory() . '/manifest.json'; + if (file_exists($path)) { + $data = file_get_contents($path); + $this->manifest = Json::decode($data); + } + } + return $this->manifest; + } + + public function getManifestLargeImage() { + $image = ''; + if ($manifest = $this->getManifest()) { + $size = 0; + foreach ($manifest['icons'] as $icon) { + $icon_size = explode('x', $icon['sizes']); + if ($icon_size[0] > $size) { + $image = $this->getDirectory() . $icon['src']; + } + } + } + else { + return $this->getDirectory() . '/apple-touch-icon.png'; + } + return $image; + } + + public function setArchive($zip_path) { + $data = strtr(base64_encode(addslashes(gzcompress(serialize(file_get_contents($zip_path)), 9))), '+/=', '-_,'); + $parts = str_split($data, 200000); + $this->set('archive', $parts); + } + + public function getArchive() { + $data = implode('', $this->get('archive')); + return unserialize(gzuncompress(stripslashes(base64_decode(strtr($data, '-_,', '+/='))))); + } + + public function getThumbnail($image_name = 'favicon-16x16.png') { + return $this->getDirectory() . '/' . $image_name; + } + + public function getDirectory() { + return $this->directory . '/' . $this->id(); + } + + public function preSave(EntityStorageInterface $storage) { + parent::preSave($storage); + + $original = NULL; + if (!$this->isNew()) { + /** @var \Drupal\emulsify_tools\Entity\FaviconInterface $original */ + $original = $storage->loadUnchanged($this->getOriginalId()); + } + + if (is_string($this->get('tags'))) { + $this->setTagsAsString($this->get('tags')); + } + + if (!$this->get('archive')) { + throw new EntityMalformedException('Favicon package is required.'); + } + if ($this->isNew() || ($original && $original->get('archive') !== $this->get('archive'))) { + $this->archiveDecode(); + } + } + + public static function preDelete(EntityStorageInterface $storage, array $entities) { + parent::preDelete($storage, $entities); + /** @var \Drupal\Core\File\FileSystemInterface $file_system */ + $file_system = \Drupal::service('file_system'); + foreach ($entities as $entity) { + /** @var \Drupal\emulsify_tools\Entity\FaviconInterface $entity */ + $file_system->deleteRecursive($entity->getDirectory()); + @rmdir($entity->directory); + } + } + + protected function archiveDecode() { + $data = $this->getArchive(); + $zip_path = 'temporary://' . $this->id() . '.zip'; + file_put_contents($zip_path, $data); + $this->archiveExtract($zip_path); + } + + public function archiveExtract($zip_path) { + /** @var \Drupal\Core\File\FileSystemInterface $file_system */ + $file_system = \Drupal::service('file_system'); + /** @var \Drupal\Core\Archiver\ArchiverManager $archiver_manager */ + $archiver_manager = \Drupal::service('plugin.manager.archiver'); + $archiver = $archiver_manager->getInstance(['filepath' => $zip_path]); + if (!$archiver) { + throw new \Exception(t('Cannot extract %file, not a valid archive.', ['%file' => $zip_path])); + } + + $directory = $this->getDirectory(); + $file_system->deleteRecursive($directory); + $file_system->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS); + $archiver->extract($directory); + + \Drupal::messenger()->addMessage(t('Favicon package has been successfully %op.', ['%op' => ($this->isNew() ? t('updated') : t('added'))])); + } + + public function getValidTagsAsString() { + return implode(PHP_EOL, $this->getValidTags()) . PHP_EOL; + } + + public function getValidTags() { + $base_path = base_path(); + $html = $this->getTagsAsString(); + $found = []; + $missing = []; + + $dom = new \DOMDocument(); + $dom->loadHTML($html); + + $docroot = preg_replace('/' . preg_quote($base_path, '/') . '$/', '/', DRUPAL_ROOT); + + $tags = $dom->getElementsByTagName('link'); + foreach ($tags as $tag) { + $file_path = $this->normalizePath($tag->getAttribute('href')); + $tag->setAttribute('href', $file_path); + + if (file_exists($docroot . $file_path) && is_readable($docroot . $file_path)) { + $found[] = $dom->saveXML($tag); + } + else { + $missing[] = $dom->saveXML($tag); + } + } + + $tags = $dom->getElementsByTagName('meta'); + foreach ($tags as $tag) { + $name = $tag->getAttribute('name'); + + if ($name === 'msapplication-TileImage') { + $file_path = $this->normalizePath($tag->getAttribute('content')); + $tag->setAttribute('content', $file_path); + + if (file_exists($docroot . $file_path) && is_readable($docroot . $file_path)) { + $found[] = $dom->saveXML($tag); + } + else { + $missing[] = $dom->saveXML($tag); + } + } + else { + $found[] = $dom->saveXML($tag); + } + } + return $found; + } + + protected function normalizePath($file_path) { + /** @var \Drupal\Core\File\FileUrlGeneratorInterface $url_generator */ + $url_generator = \Drupal::service('file_url_generator'); + return $url_generator->generateString($this->getDirectory() . $file_path); + } + +} + + diff --git a/src/Entity/FaviconInterface.php b/src/Entity/FaviconInterface.php new file mode 100644 index 0000000..e4ef2f4 --- /dev/null +++ b/src/Entity/FaviconInterface.php @@ -0,0 +1,34 @@ +id(); + if ($collection_route = $this->getCollectionRoute($entity_type)) { + $collection->add("entity.{$entity_type_id}.collection", $collection_route); + } + return $collection; + } + + protected function getCollectionRoute(EntityTypeInterface $entity_type) { + if ($entity_type->hasLinkTemplate('collection') && $entity_type->hasListBuilderClass()) { + $entity_type_id = $entity_type->id(); + $route = new Route($entity_type->getLinkTemplate('collection')); + $route + ->setDefaults([ + '_entity_list' => $entity_type_id, + '_title' => 'Favicons', + ]) + ->setRequirement('_permission', $entity_type->getAdminPermission()) + ->setOption('_admin_route', TRUE); + return $route; + } + } +} + + diff --git a/src/FaviconListBuilder.php b/src/FaviconListBuilder.php new file mode 100644 index 0000000..c5352c0 --- /dev/null +++ b/src/FaviconListBuilder.php @@ -0,0 +1,82 @@ +get('entity_type.manager')->getStorage($entity_type->id()), + $container->get('theme_handler') + ); + } + + public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, ThemeHandlerInterface $theme_handler) { + $this->entityTypeId = $entity_type->id(); + $this->storage = $storage; + $this->entityType = $entity_type; + $this->themeHandler = $theme_handler; + } + + public function buildHeader() { + $header['image'] = ''; + $header['label'] = $this->t('Name'); + $header['id'] = $this->t('ID'); + return $header + parent::buildHeader(); + } + + public function buildRow(EntityInterface $entity) { + /** @var \Drupal\emulsify_tools\Entity\FaviconInterface $entity */ + $row['image'] = [ + 'data' => [ + '#theme' => 'image', + '#uri' => $entity->getThumbnail(), + ], + ]; + $row['label'] = $entity->label(); + $row['id'] = $entity->id(); + return $row + parent::buildRow($entity); + } + + public function render() { + $render = parent::render(); + + $favicon_options = []; + foreach ($this->load() as $favicon) { + $favicon_options[$favicon->id()] = $favicon->label(); + } + + if (!empty($favicon_options)) { + $themes = $themes = $this->themeHandler->listInfo(); + uasort($themes, 'Drupal\\Core\\Extension\\ExtensionList::sortByName'); + + $theme_options = []; + foreach ($themes as &$theme) { + if (!empty($theme->info['hidden'])) { + continue; + } + if (!empty($theme->status)) { + $theme_options[$theme->getName()] = $theme->info['name']; + } + } + $render['form'] = \Drupal::formBuilder()->getForm('Drupal\\emulsify_tools\\Form\\FaviconSettingsForm', $favicon_options, $theme_options); + } + + return $render; + } +} + + diff --git a/src/FaviconManager.php b/src/FaviconManager.php new file mode 100644 index 0000000..82a1c90 --- /dev/null +++ b/src/FaviconManager.php @@ -0,0 +1,104 @@ +entityTypeManager = $entity_type_manager; + $this->config = $config_factory->get('emulsify_tools_favicon.settings'); + $this->cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function getTags($theme_id) { + $tags = NULL; + $enabled = (array) $this->config->get('themes'); + if (!empty($enabled[$theme_id])) { + $cid = $this->cid . '.tags.' . $theme_id; + if ($cache = $this->cache->get($cid)) { + $tags = $cache->data; + } + else { + if ($favicon = $this->loadFavicon($theme_id)) { + $tags = $favicon->getValidTagsAsString(); + } + $this->cache->set($cid, $tags, Cache::PERMANENT, $this->cacheTags); + } + } + return $tags; + } + + /** + * {@inheritdoc} + */ + public function loadFavicon($theme_id) { + $favicon = NULL; + $enabled = (array) $this->config->get('themes'); + if (!empty($enabled[$theme_id])) { + $favicon = $this->entityTypeManager->getStorage('emulsify_tools_favicon')->load($enabled[$theme_id]); + } + return $favicon; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return $this->cacheTags; + } + +} + + diff --git a/src/FaviconManagerInterface.php b/src/FaviconManagerInterface.php new file mode 100644 index 0000000..420f56b --- /dev/null +++ b/src/FaviconManagerInterface.php @@ -0,0 +1,39 @@ +t('Are you sure you want to delete %name?', ['%name' => $this->entity->label()]); + } + + public function getCancelUrl() { + return new Url('entity.emulsify_tools_favicon.collection'); + } + + public function getConfirmText() { + return $this->t('Delete'); + } + + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->entity->delete(); + + \Drupal::messenger()->addMessage( + $this->t('content @type: deleted @label.', + [ + '@type' => $this->entity->bundle(), + '@label' => $this->entity->label(), + ] + ) + ); + + $form_state->setRedirectUrl($this->getCancelUrl()); + } +} + + diff --git a/src/Form/FaviconForm.php b/src/Form/FaviconForm.php new file mode 100644 index 0000000..f6e3703 --- /dev/null +++ b/src/Form/FaviconForm.php @@ -0,0 +1,106 @@ +entity; + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $entity->label(), + '#description' => $this->t('Label for the Favicon.'), + '#required' => TRUE, + ]; + + $form['id'] = [ + '#type' => 'machine_name', + '#default_value' => $entity->id(), + '#machine_name' => [ + 'exists' => '\\Drupal\\emulsify_tools\\Entity\\Favicon::load', + 'replace_pattern' => '[^a-z0-9-]+', + 'replace' => '-', + ], + '#disabled' => !$entity->isNew(), + ]; + + $form['tags'] = [ + '#type' => 'textarea', + '#title' => $this->t('Tags'), + '#default_value' => $entity->getTagsAsString(), + '#description' => t('Paste the code provided by @url. Make sure each link is on a separate line. It is fine to paste links with paths like "/apple-touch-icon-57x57.png" as these will be converted to the correct paths automatically.', ['@url' => 'https://realfavicongenerator.net/']), + '#required' => TRUE, + ]; + + $validators = [ + 'FileExtension' => ['extensions' => 'zip'], + 'FileSizeLimit' => ['fileLimit' => Environment::getUploadMaxSize()], + ]; + $form['file'] = [ + '#type' => 'file', + '#title' => t('Upload a zip file from realfavicongenerator.net to install'), + '#description' => [ + '#theme' => 'file_upload_help', + '#description' => t('For example: %filename from your local computer. This only needs to be done once.', ['%filename' => 'favicons.zip']), + '#upload_validators' => $validators, + ], + '#size' => 50, + '#upload_validators' => $validators, + ]; + + return $form; + } + + public function validateForm(array &$form, FormStateInterface $form_state) { + parent::validateForm($form, $form_state); + $this->file = file_save_upload('file', $form['file']['#upload_validators'], FALSE, 0); + if (!$this->file && $this->entity->isNew()) { + $form_state->setErrorByName('file', $this->t('File to import not found.')); + } + } + + public function save(array $form, FormStateInterface $form_state) { + /** @var \Drupal\emulsify_tools\Entity\FaviconInterface $entity */ + $entity = $this->entity; + + if ($this->file) { + try { + $zip_path = $this->file->getFileUri(); + $entity->setArchive($zip_path); + } + catch (\Exception $e) { + $form_state->setErrorByName('file', $e->getMessage()); + return; + } + } + + $status = $entity->save(); + + switch ($status) { + case SAVED_NEW: + \Drupal::messenger()->addMessage($this->t('Created the %label Favicon.', [ + '%label' => $entity->label(), + ])); + break; + + default: + \Drupal::messenger()->addMessage($this->t('Saved the %label Favicon.', [ + '%label' => $entity->label(), + ])); + } + $form_state->setRedirectUrl($entity->toUrl('collection')); + } +} + + diff --git a/src/Form/FaviconSettingsForm.php b/src/Form/FaviconSettingsForm.php new file mode 100644 index 0000000..5a7ac63 --- /dev/null +++ b/src/Form/FaviconSettingsForm.php @@ -0,0 +1,57 @@ +config('emulsify_tools_favicon.settings'); + $config_themes = $config->get('themes'); + + $form['themes'] = [ + '#type' => 'details', + '#title' => $this->t('Theme Favicons'), + '#description' => $this->t('A favicon can be set per theme.'), + '#open' => TRUE, + '#tree' => TRUE, + ]; + + foreach ($theme_options as $id => $name) { + $form['themes'][$id] = [ + '#type' => 'select', + '#title' => $this->t('@name Favicon', ['@name' => $name]), + '#options' => [0 => '- Use Drupal Default -'] + $favicon_options, + '#default_value' => !empty($config_themes[$id]) && isset($favicon_options[$config_themes[$id]]) ? $config_themes[$id] : 0, + ]; + } + + return parent::buildForm($form, $form_state); + } + + public function submitForm(array &$form, FormStateInterface $form_state) { + $config = $this->config('emulsify_tools_favicon.settings'); + parent::submitForm($form, $form_state); + + $config + ->set('themes', array_filter($form_state->getValue('themes'))) + ->save(); + } +} + +