]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'markdown-export' of https://github.com/nikhiljha/BookStack-1 into nikhi...
authorDan Brown <redacted>
Tue, 22 Jun 2021 18:12:24 +0000 (19:12 +0100)
committerDan Brown <redacted>
Tue, 22 Jun 2021 18:12:24 +0000 (19:12 +0100)
1  2 
app/Entities/Tools/ExportFormatter.php
app/Http/Controllers/BookExportController.php
app/Http/Controllers/ChapterExportController.php
app/Http/Controllers/PageExportController.php
composer.json
dev/docker/Dockerfile
resources/lang/en/entities.php
resources/views/partials/entity-export-menu.blade.php
routes/web.php

index eb8f6862f23fe76b703c8f0a2514b2f5d61acae9,b0e88b18bb7db42b9cfbd05242230740c15f48a9..b462abec5671666ebb806d953ab1dd34a7c3b558
@@@ -1,15 -1,16 +1,17 @@@
 -<?php namespace BookStack\Entities;
 +<?php namespace BookStack\Entities\Tools;
  
 -use BookStack\Entities\Managers\BookContents;
 -use BookStack\Entities\Managers\PageContent;
 +use BookStack\Entities\Models\Book;
 +use BookStack\Entities\Models\Chapter;
 +use BookStack\Entities\Models\Page;
  use BookStack\Uploads\ImageService;
  use DomPDF;
  use Exception;
  use SnappyPDF;
+ use League\HTMLToMarkdown\HtmlConverter;
  use Throwable;
+ use ZipArchive;
  
 -class ExportService
 +class ExportFormatter
  {
  
      protected $imageService;
      protected function containHtml(string $htmlContent): string
      {
          $imageTagsOutput = [];
 -        preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
 +        preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
  
          // Replace image src with base64 encoded image strings
          if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
      {
          $text = $chapter->name . "\n\n";
          $text .= $chapter->description . "\n\n";
 -        foreach ($chapter->pages as $page) {
 +        foreach ($chapter->getVisiblePages() as $page) {
              $text .= $this->pageToPlainText($page);
          }
          return $text;
       */
      public function bookToPlainText(Book $book): string
      {
 -        $bookTree = (new BookContents($book))->getTree(false, true);
 +        $bookTree = (new BookContents($book))->getTree(false, false);
          $text = $book->name . "\n\n";
          foreach ($bookTree as $bookChild) {
              if ($bookChild->isA('chapter')) {
          }
          return $text;
      }
+     /**
+      * Convert a page to a Markdown file.
+      * @throws Throwable
+      */
+     public function pageToMarkdown(Page $page)
+     {
+         if (property_exists($page, 'markdown') && $page->markdown != '') {
+             return "# " . $page->name . "\n\n" . $page->markdown;
+         } else {
+             $converter = new HtmlConverter();
+             return "# " . $page->name . "\n\n" . $converter->convert($page->html);
+         }
+     }
+     /**
+      * Convert a chapter to a Markdown file.
+      * @throws Throwable
+      */
+     public function chapterToMarkdown(Chapter $chapter)
+     {
+         $text = "# " . $chapter->name . "\n\n";
+         $text .= $chapter->description . "\n\n";
+         foreach ($chapter->pages as $page) {
+             $text .= $this->pageToMarkdown($page);
+         }
+         return $text;
+     }
+     /**
+      * Convert a book into a plain text string.
+      */
+     public function bookToMarkdown(Book $book): string
+     {
+         $bookTree = (new BookContents($book))->getTree(false, true);
+         $text = "# " . $book->name . "\n\n";
+         foreach ($bookTree as $bookChild) {
+             if ($bookChild->isA('chapter')) {
+                 $text .= $this->chapterToMarkdown($bookChild);
+             } else {
+                 $text .= $this->pageToMarkdown($bookChild);
+             }
+         }
+         return $text;
+     }
+     /**
+      * Convert a book into a zip file.
+      */
+     public function bookToZip(Book $book): string
+     {
+         // TODO: Is not unlinking the file a security risk?
+         $z = new ZipArchive();
+         $z->open("book.zip", \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
+         $bookTree = (new BookContents($book))->getTree(false, true);
+         foreach ($bookTree as $bookChild) {
+             if ($bookChild->isA('chapter')) {
+                 $z->addEmptyDir($bookChild->name);
+                 foreach ($bookChild->pages as $page) {
+                     $filename = $bookChild->name . "/" . $page->name . ".md";
+                     $z->addFromString($filename, $this->pageToMarkdown($page));
+                 }
+             } else {
+                 $z->addFromString($bookChild->name . ".md", $this->pageToMarkdown($bookChild));
+             }
+         }
+         return "book.zip";
+     }
  }
index 1c1f124422f962020d31e4f35595b2e65d0f80cf,a92d94cc94f4f7a0b368dfb47b2a13f7009c8848..58868fa5c03036711d47a345cb00584954ab79bb
@@@ -2,7 -2,8 +2,7 @@@
  
  namespace BookStack\Http\Controllers;
  
 -use BookStack\Entities\Managers\BookContents;
 -use BookStack\Entities\ExportService;
 +use BookStack\Entities\Tools\ExportFormatter;
  use BookStack\Entities\Repos\BookRepo;
  use Throwable;
  
@@@ -10,15 -11,16 +10,15 @@@ class BookExportController extends Cont
  {
  
      protected $bookRepo;
 -    protected $exportService;
 +    protected $exportFormatter;
  
      /**
       * BookExportController constructor.
       */
 -    public function __construct(BookRepo $bookRepo, ExportService $exportService)
 +    public function __construct(BookRepo $bookRepo, ExportFormatter $exportFormatter)
      {
          $this->bookRepo = $bookRepo;
 -        $this->exportService = $exportService;
 -        parent::__construct();
 +        $this->exportFormatter = $exportFormatter;
      }
  
      /**
@@@ -28,7 -30,7 +28,7 @@@
      public function pdf(string $bookSlug)
      {
          $book = $this->bookRepo->getBySlug($bookSlug);
 -        $pdfContent = $this->exportService->bookToPdf($book);
 +        $pdfContent = $this->exportFormatter->bookToPdf($book);
          return $this->downloadResponse($pdfContent, $bookSlug . '.pdf');
      }
  
@@@ -39,7 -41,7 +39,7 @@@
      public function html(string $bookSlug)
      {
          $book = $this->bookRepo->getBySlug($bookSlug);
 -        $htmlContent = $this->exportService->bookToContainedHtml($book);
 +        $htmlContent = $this->exportFormatter->bookToContainedHtml($book);
          return $this->downloadResponse($htmlContent, $bookSlug . '.html');
      }
  
      public function plainText(string $bookSlug)
      {
          $book = $this->bookRepo->getBySlug($bookSlug);
 -        $textContent = $this->exportService->bookToPlainText($book);
 +        $textContent = $this->exportFormatter->bookToPlainText($book);
          return $this->downloadResponse($textContent, $bookSlug . '.txt');
      }
+     /**
+      * Export a book as a markdown file.
+      */
+     public function markdown(string $bookSlug)
+     {
+         $book = $this->bookRepo->getBySlug($bookSlug);
+         $textContent = $this->exportService->bookToMarkdown($book);
+         return $this->downloadResponse($textContent, $bookSlug . '.md');
+     }
+     /**
+      * Export a book as a zip file, made of markdown files.
+      */
+     public function zip(string $bookSlug)
+     {
+         $book = $this->bookRepo->getBySlug($bookSlug);
+         $filename = $this->exportService->bookToZip($book);
+         return response()->download($filename);
+     }
  }
index 52d087442ab287eb2d533365ed6cfd8cce5da642,c0fa9fad94fad64fb85df88416cc24750be2dafd..bc709771bd2f04e653fbad58c2562cec5632d18f
@@@ -1,6 -1,6 +1,6 @@@
  <?php namespace BookStack\Http\Controllers;
  
 -use BookStack\Entities\ExportService;
 +use BookStack\Entities\Tools\ExportFormatter;
  use BookStack\Entities\Repos\ChapterRepo;
  use BookStack\Exceptions\NotFoundException;
  use Throwable;
@@@ -9,15 -9,16 +9,15 @@@ class ChapterExportController extends C
  {
  
      protected $chapterRepo;
 -    protected $exportService;
 +    protected $exportFormatter;
  
      /**
       * ChapterExportController constructor.
       */
 -    public function __construct(ChapterRepo $chapterRepo, ExportService $exportService)
 +    public function __construct(ChapterRepo $chapterRepo, ExportFormatter $exportFormatter)
      {
          $this->chapterRepo = $chapterRepo;
 -        $this->exportService = $exportService;
 -        parent::__construct();
 +        $this->exportFormatter = $exportFormatter;
      }
  
      /**
@@@ -28,7 -29,7 +28,7 @@@
      public function pdf(string $bookSlug, string $chapterSlug)
      {
          $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
 -        $pdfContent = $this->exportService->chapterToPdf($chapter);
 +        $pdfContent = $this->exportFormatter->chapterToPdf($chapter);
          return $this->downloadResponse($pdfContent, $chapterSlug . '.pdf');
      }
  
@@@ -40,7 -41,7 +40,7 @@@
      public function html(string $bookSlug, string $chapterSlug)
      {
          $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
 -        $containedHtml = $this->exportService->chapterToContainedHtml($chapter);
 +        $containedHtml = $this->exportFormatter->chapterToContainedHtml($chapter);
          return $this->downloadResponse($containedHtml, $chapterSlug . '.html');
      }
  
      public function plainText(string $bookSlug, string $chapterSlug)
      {
          $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
 -        $chapterText = $this->exportService->chapterToPlainText($chapter);
 +        $chapterText = $this->exportFormatter->chapterToPlainText($chapter);
          return $this->downloadResponse($chapterText, $chapterSlug . '.txt');
      }
+     /**
+      * Export a chapter to a simple markdown file.
+      * @throws NotFoundException
+      */
+     public function markdown(string $bookSlug, string $chapterSlug)
+     {
+         // TODO: This should probably export to a zip file.
+         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
+         $chapterText = $this->exportService->chapterToMarkdown($chapter);
+         return $this->downloadResponse($chapterText, $chapterSlug . '.md');
+     }
  }
index e5e027fe72cd2f5cec19418d9ea81901238e2eb7,037f84e3be80b2413faa54ad5949970104f5d4ae..d9cc5ba489afcb279aad905d36d3b275a764dadd
@@@ -2,8 -2,8 +2,8 @@@
  
  namespace BookStack\Http\Controllers;
  
 -use BookStack\Entities\ExportService;
 -use BookStack\Entities\Managers\PageContent;
 +use BookStack\Entities\Tools\ExportFormatter;
 +use BookStack\Entities\Tools\PageContent;
  use BookStack\Entities\Repos\PageRepo;
  use BookStack\Exceptions\NotFoundException;
  use Throwable;
@@@ -12,15 -12,18 +12,15 @@@ class PageExportController extends Cont
  {
  
      protected $pageRepo;
 -    protected $exportService;
 +    protected $exportFormatter;
  
      /**
       * PageExportController constructor.
 -     * @param PageRepo $pageRepo
 -     * @param ExportService $exportService
       */
 -    public function __construct(PageRepo $pageRepo, ExportService $exportService)
 +    public function __construct(PageRepo $pageRepo, ExportFormatter $exportFormatter)
      {
          $this->pageRepo = $pageRepo;
 -        $this->exportService = $exportService;
 -        parent::__construct();
 +        $this->exportFormatter = $exportFormatter;
      }
  
      /**
@@@ -33,7 -36,7 +33,7 @@@
      {
          $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
          $page->html = (new PageContent($page))->render();
 -        $pdfContent = $this->exportService->pageToPdf($page);
 +        $pdfContent = $this->exportFormatter->pageToPdf($page);
          return $this->downloadResponse($pdfContent, $pageSlug . '.pdf');
      }
  
@@@ -46,7 -49,7 +46,7 @@@
      {
          $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
          $page->html = (new PageContent($page))->render();
 -        $containedHtml = $this->exportService->pageToContainedHtml($page);
 +        $containedHtml = $this->exportFormatter->pageToContainedHtml($page);
          return $this->downloadResponse($containedHtml, $pageSlug . '.html');
      }
  
      public function plainText(string $bookSlug, string $pageSlug)
      {
          $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
 -        $pageText = $this->exportService->pageToPlainText($page);
 +        $pageText = $this->exportFormatter->pageToPlainText($page);
          return $this->downloadResponse($pageText, $pageSlug . '.txt');
      }
+     /**
+      * Export a page to a simple markdown .md file.
+      * @throws NotFoundException
+      */
+     public function markdown(string $bookSlug, string $pageSlug)
+     {
+         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
+         $pageText = $this->exportService->pageToMarkdown($page);
+         return $this->downloadResponse($pageText, $pageSlug . '.md');
+     }
  }
diff --combined composer.json
index 8450a2f9250205109ecaeebdca2ac3538c86fc69,68802e935ce41c6e4b53d45e8985654e7d562184..8124ccbca3197e55a02c1b71418acb6831c84e4a
@@@ -5,43 -5,45 +5,44 @@@
      "license": "MIT",
      "type": "project",
      "require": {
 -        "php": "^7.2",
 +        "php": "^7.3|^8.0",
          "ext-curl": "*",
          "ext-dom": "*",
 +        "ext-fileinfo": "*",
          "ext-gd": "*",
          "ext-json": "*",
          "ext-mbstring": "*",
 -        "ext-tidy": "*",
          "ext-xml": "*",
 -        "barryvdh/laravel-dompdf": "^0.8.5",
 -        "barryvdh/laravel-snappy": "^0.4.5",
 -        "doctrine/dbal": "^2.9",
 -        "facade/ignition": "^1.4",
 -        "fideloper/proxy": "^4.0",
 -        "gathercontent/htmldiff": "^0.2.1",
 -        "intervention/image": "^2.5",
 -        "laravel/framework": "^6.12",
 -        "laravel/socialite": "^4.3.2",
 -        "league/commonmark": "^1.4",
 -        "league/flysystem-aws-s3-v3": "^1.0",
 +        "barryvdh/laravel-dompdf": "^0.9.0",
 +        "barryvdh/laravel-snappy": "^0.4.8",
 +        "doctrine/dbal": "^2.12.1",
 +        "facade/ignition": "^1.16.4",
 +        "fideloper/proxy": "^4.4.1",
 +        "intervention/image": "^2.5.1",
 +        "laravel/framework": "^6.20.16",
 +        "laravel/socialite": "^5.1",
 +        "league/commonmark": "^1.5",
 +        "league/flysystem-aws-s3-v3": "^1.0.29",
+         "league/html-to-markdown": "^4.9",
 -        "nunomaduro/collision": "^3.0",
 -        "onelogin/php-saml": "^3.3",
 -        "predis/predis": "^1.1",
 -        "socialiteproviders/discord": "^2.0",
 -        "socialiteproviders/gitlab": "^3.0",
 -        "socialiteproviders/microsoft-azure": "^3.0",
 -        "socialiteproviders/okta": "^1.0",
 -        "socialiteproviders/slack": "^3.0",
 -        "socialiteproviders/twitch": "^5.0"
 +        "nunomaduro/collision": "^3.1",
 +        "onelogin/php-saml": "^4.0",
 +        "predis/predis": "^1.1.6",
 +        "socialiteproviders/discord": "^4.1",
 +        "socialiteproviders/gitlab": "^4.1",
 +        "socialiteproviders/microsoft-azure": "^4.1",
 +        "socialiteproviders/okta": "^4.1",
 +        "socialiteproviders/slack": "^4.1",
 +        "socialiteproviders/twitch": "^5.3",
 +        "ssddanbrown/htmldiff": "^v1.0.1"
      },
      "require-dev": {
 -        "barryvdh/laravel-debugbar": "^3.2.8",
 -        "barryvdh/laravel-ide-helper": "^2.6.4",
 -        "fzaninotto/faker": "^1.4",
 -        "laravel/browser-kit-testing": "^5.1",
 -        "mockery/mockery": "^1.0",
 -        "phpunit/phpunit": "^8.0",
 -        "squizlabs/php_codesniffer": "^3.4",
 -        "wnx/laravel-stats": "^2.0"
 +        "barryvdh/laravel-debugbar": "^3.5.1",
 +        "barryvdh/laravel-ide-helper": "^2.8.2",
 +        "fakerphp/faker": "^1.13.0",
 +        "laravel/browser-kit-testing": "^5.2",
 +        "mockery/mockery": "^1.3.3",
 +        "phpunit/phpunit": "^9.5.3",
 +        "squizlabs/php_codesniffer": "^3.5.8"
      },
      "autoload": {
          "classmap": [
          ],
          "psr-4": {
              "BookStack\\": "app/"
 -        }
 +        },
 +              "files": [
 +                      "app/helpers.php"
 +              ]
      },
      "autoload-dev": {
          "psr-4": {
          "post-create-project-cmd": [
              "@php artisan key:generate --ansi"
          ],
 -        "pre-update-cmd": [
 -            "@php -r \"!file_exists('bootstrap/cache/services.php') || @unlink('bootstrap/cache/services.php');\"",
 -            "@php -r \"!file_exists('bootstrap/cache/compiled.php') || @unlink('bootstrap/cache/compiled.php');\""
 -        ],
          "pre-install-cmd": [
 -            "@php -r \"!file_exists('bootstrap/cache/services.php') || @unlink('bootstrap/cache/services.php');\"",
 -            "@php -r \"!file_exists('bootstrap/cache/compiled.php') || @unlink('bootstrap/cache/compiled.php');\""
 +            "@php -r \"!file_exists('bootstrap/cache/services.php') || @unlink('bootstrap/cache/services.php');\""
          ],
          "post-install-cmd": [
              "@php artisan cache:clear",
@@@ -88,7 -92,7 +89,7 @@@
          "preferred-install": "dist",
          "sort-packages": true,
          "platform": {
 -            "php": "7.2.0"
 +            "php": "7.3.0"
          }
      },
      "extra": {
diff --combined dev/docker/Dockerfile
index 895ad595ae7b73cf4077a76bf717092c5146a2ff,be5af9ed9359cc24e6a6799d8308e3106fa8bd22..178ea9a6c865481f40d07529de0915d86553152e
@@@ -1,23 -1,16 +1,23 @@@
 -FROM php:7.3-apache
 +FROM php:7.4-apache
  
  ENV APACHE_DOCUMENT_ROOT /app/public
  WORKDIR /app
  
 +# Install additional dependacnies and configure apache
  RUN apt-get update -y \
-     && apt-get install -y git zip unzip libpng-dev libldap2-dev wait-for-it \
 -    && apt-get install -y git zip unzip libtidy-dev libpng-dev libldap2-dev libxml++2.6-dev wait-for-it libzip-dev \
++    && apt-get install -y git zip unzip libpng-dev libldap2-dev libzip-dev wait-for-it \
      && docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
-     && docker-php-ext-install pdo_mysql gd ldap \
 -    && docker-php-ext-install pdo pdo_mysql tidy dom xml mbstring gd ldap zip \
++    && docker-php-ext-install pdo_mysql gd ldap zip \
      && a2enmod rewrite \
      && sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
 -    && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \
 -    && php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
 +    && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
 +
 +# Install composer
 +RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
      && php composer-setup.php \
      && mv composer.phar /usr/bin/composer \
      && php -r "unlink('composer-setup.php');"
-     && sed -i 's/memory_limit = 128M/memory_limit = 512M/g' "$PHP_INI_DIR/php.ini"
 +
 +# Use the default production configuration and update it as required
 +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
++    && sed -i 's/memory_limit = 128M/memory_limit = 512M/g' "$PHP_INI_DIR/php.ini"
index 462402f33f407b458700e80e2c15f22257ceba52,b459c3d4bd0840fc3ce87591e7d6323e207bd325..1d4632bce352c632bbb066cfd36fbfac953db7da
@@@ -22,13 -22,10 +22,13 @@@ return 
      'meta_created_name' => 'Created :timeLength by :user',
      'meta_updated' => 'Updated :timeLength',
      'meta_updated_name' => 'Updated :timeLength by :user',
 +    'meta_owned_name' => 'Owned by :user',
      'entity_select' => 'Entity Select',
      'images' => 'Images',
      'my_recent_drafts' => 'My Recent Drafts',
      'my_recently_viewed' => 'My Recently Viewed',
 +    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
 +    'my_favourites' => 'My Favourites',
      'no_pages_viewed' => 'You have not viewed any pages',
      'no_pages_recently_created' => 'No pages have been recently created',
      'no_pages_recently_updated' => 'No pages have been recently updated',
      'export_html' => 'Contained Web File',
      'export_pdf' => 'PDF File',
      'export_text' => 'Plain Text File',
+     'export_md' => 'Markdown File',
  
      // Permissions and restrictions
      'permissions' => 'Permissions',
      'permissions_intro' => 'Once enabled, These permissions will take priority over any set role permissions.',
      'permissions_enable' => 'Enable Custom Permissions',
      'permissions_save' => 'Save Permissions',
 +    'permissions_owner' => 'Owner',
  
      // Search
      'search_results' => 'Search Results',
@@@ -51,8 -48,7 +52,8 @@@
      'search_no_pages' => 'No pages matched this search',
      'search_for_term' => 'Search for :term',
      'search_more' => 'More Results',
 -    'search_filters' => 'Search Filters',
 +    'search_advanced' => 'Advanced Search',
 +    'search_terms' => 'Search Terms',
      'search_content_type' => 'Content Type',
      'search_exact_matches' => 'Exact Matches',
      'search_tags' => 'Tag Searches',
@@@ -62,7 -58,6 +63,7 @@@
      'search_permissions_set' => 'Permissions set',
      'search_created_by_me' => 'Created by me',
      'search_updated_by_me' => 'Updated by me',
 +    'search_owned_by_me' => 'Owned by me',
      'search_date_options' => 'Date Options',
      'search_updated_before' => 'Updated before',
      'search_updated_after' => 'Updated after',
      'chapters_create' => 'Create New Chapter',
      'chapters_delete' => 'Delete Chapter',
      'chapters_delete_named' => 'Delete Chapter :chapterName',
 -    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages will be removed and added directly to the parent book.',
 +    'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
      'chapters_delete_confirm' => 'Are you sure you want to delete this chapter?',
      'chapters_edit' => 'Edit Chapter',
      'chapters_edit_named' => 'Edit Chapter :chapterName',
      'pages_revisions' => 'Page Revisions',
      'pages_revisions_named' => 'Page Revisions for :pageName',
      'pages_revision_named' => 'Page Revision for :pageName',
 +    'pages_revision_restored_from' => 'Restored from #:id; :summary',
      'pages_revisions_created_by' => 'Created By',
      'pages_revisions_date' => 'Revision Date',
      'pages_revisions_number' => '#',
      'attachments_upload' => 'Upload File',
      'attachments_link' => 'Attach Link',
      'attachments_set_link' => 'Set Link',
 -    'attachments_delete_confirm' => 'Click delete again to confirm you want to delete this attachment.',
 +    'attachments_delete' => 'Are you sure you want to delete this attachment?',
      'attachments_dropzone' => 'Drop files or click here to attach a file',
      'attachments_no_files' => 'No files have been uploaded',
      'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.',
      'attachments_link_url' => 'Link to file',
      'attachments_link_url_hint' => 'Url of site or file',
      'attach' => 'Attach',
 +    'attachments_insert_link' => 'Add Attachment Link to Page',
      'attachments_edit_file' => 'Edit File',
      'attachments_edit_file_name' => 'File Name',
      'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite',
      'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
      'revision_delete_success' => 'Revision deleted',
      'revision_cannot_delete_latest' => 'Cannot delete the latest revision.'
 -];
 +];
index 6d23af07c24bc4ae046edf3ee9b4b3cd063783a3,42c2eb79a91b18f42ea2657cae4f31df808f1580..2b0f5c19dd84b9b88130afe5ee289545eae0100a
@@@ -1,12 -1,13 +1,13 @@@
 -<div dropdown class="dropdown-container" id="export-menu">
 -    <div dropdown-toggle class="icon-list-item"
 +<div component="dropdown" class="dropdown-container" id="export-menu">
 +    <div refs="dropdown@toggle" class="icon-list-item"
           aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('entities.export') }}" tabindex="0">
          <span>@icon('export')</span>
          <span>{{ trans('entities.export') }}</span>
      </div>
 -    <ul class="wide dropdown-menu" role="menu">
 -        <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
 -        <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
 -        <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
 -        <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank">{{ trans('entities.export_md') }} <span class="text-muted float right">.md</span></a></li>
 +    <ul refs="dropdown@menu" class="wide dropdown-menu" role="menu">
 +        <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank" rel="noopener">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
 +        <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" rel="noopener">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
 +        <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" rel="noopener">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
++        <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" rel="noopener">{{ trans('entities.export_md') }} <span class="text-muted float right">.md</span></a></li>
      </ul>
--</div>
++</div>
diff --combined routes/web.php
index 72d089078f701ed49b9821e8a9ad908f8bc6695a,4d00b5ff6191aa93b3e381a89c1389b9c0e8af54..2bba3e2cfd074abace5d50f979ef31a77f590928
@@@ -1,6 -1,5 +1,6 @@@
  <?php
  
 +Route::get('/status', 'StatusController@show');
  Route::get('/robots.txt', 'HomeController@getRobots');
  
  // Authenticated routes...
@@@ -14,7 -13,7 +14,7 @@@ Route::group(['middleware' => 'auth'], 
  
      // Shelves
      Route::get('/create-shelf', 'BookshelfController@create');
 -    Route::group(['prefix' => 'shelves'], function() {
 +    Route::group(['prefix' => 'shelves'], function () {
          Route::get('/', 'BookshelfController@index');
          Route::post('/', 'BookshelfController@store');
          Route::get('/{slug}/edit', 'BookshelfController@edit');
@@@ -48,6 -47,8 +48,8 @@@
          Route::put('/{bookSlug}/sort', 'BookSortController@update');
          Route::get('/{bookSlug}/export/html', 'BookExportController@html');
          Route::get('/{bookSlug}/export/pdf', 'BookExportController@pdf');
+         Route::get('/{bookSlug}/export/markdown', 'BookExportController@markdown');
+         Route::get('/{bookSlug}/export/zip', 'BookExportController@zip');
          Route::get('/{bookSlug}/export/plaintext', 'BookExportController@plainText');
  
          // Pages
@@@ -58,6 -59,7 +60,7 @@@
          Route::get('/{bookSlug}/page/{pageSlug}', 'PageController@show');
          Route::get('/{bookSlug}/page/{pageSlug}/export/pdf', 'PageExportController@pdf');
          Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageExportController@html');
+         Route::get('/{bookSlug}/page/{pageSlug}/export/markdown', 'PageExportController@markdown');
          Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageExportController@plainText');
          Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit');
          Route::get('/{bookSlug}/page/{pageSlug}/move', 'PageController@showMove');
@@@ -92,6 -94,7 +95,7 @@@
          Route::get('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@showPermissions');
          Route::get('/{bookSlug}/chapter/{chapterSlug}/export/pdf', 'ChapterExportController@pdf');
          Route::get('/{bookSlug}/chapter/{chapterSlug}/export/html', 'ChapterExportController@html');
+         Route::get('/{bookSlug}/chapter/{chapterSlug}/export/markdown', 'ChapterExportController@markdown');
          Route::get('/{bookSlug}/chapter/{chapterSlug}/export/plaintext', 'ChapterExportController@plainText');
          Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@permissions');
          Route::get('/{bookSlug}/chapter/{chapterSlug}/delete', 'ChapterController@showDelete');
      });
  
      // User Profile routes
 -    Route::get('/user/{userId}', 'UserController@showProfilePage');
 +    Route::get('/user/{slug}', 'UserProfileController@show');
  
      // Image routes
 -    Route::group(['prefix' => 'images'], function () {
 -
 -        // Gallery
 -        Route::get('/gallery', 'Images\GalleryImageController@list');
 -        Route::post('/gallery', 'Images\GalleryImageController@create');
 -
 -        // Drawio
 -        Route::get('/drawio', 'Images\DrawioImageController@list');
 -        Route::get('/drawio/base64/{id}', 'Images\DrawioImageController@getAsBase64');
 -        Route::post('/drawio', 'Images\DrawioImageController@create');
 -
 -        // Shared gallery & draw.io endpoint
 -        Route::get('/usage/{id}', 'Images\ImageController@usage');
 -        Route::put('/{id}', 'Images\ImageController@update');
 -        Route::delete('/{id}', 'Images\ImageController@destroy');
 -    });
 +    Route::get('/images/gallery', 'Images\GalleryImageController@list');
 +    Route::post('/images/gallery', 'Images\GalleryImageController@create');
 +    Route::get('/images/drawio', 'Images\DrawioImageController@list');
 +    Route::get('/images/drawio/base64/{id}', 'Images\DrawioImageController@getAsBase64');
 +    Route::post('/images/drawio', 'Images\DrawioImageController@create');
 +    Route::get('/images/edit/{id}', 'Images\ImageController@edit');
 +    Route::put('/images/{id}', 'Images\ImageController@update');
 +    Route::delete('/images/{id}', 'Images\ImageController@destroy');
  
      // Attachments routes
      Route::get('/attachments/{id}', 'AttachmentController@get');
      Route::post('/attachments/upload/{id}', 'AttachmentController@uploadUpdate');
      Route::post('/attachments/link', 'AttachmentController@attachLink');
      Route::put('/attachments/{id}', 'AttachmentController@update');
 +    Route::get('/attachments/edit/{id}', 'AttachmentController@getUpdateForm');
      Route::get('/attachments/get/page/{pageId}', 'AttachmentController@listForPage');
      Route::put('/attachments/sort/page/{pageId}', 'AttachmentController@sortForPage');
      Route::delete('/attachments/{id}', 'AttachmentController@delete');
      Route::delete('/ajax/page/{id}', 'PageController@ajaxDestroy');
  
      // Tag routes (AJAX)
 -    Route::group(['prefix' => 'ajax/tags'], function() {
 -        Route::get('/get/{entityType}/{entityId}', 'TagController@getForEntity');
 +    Route::group(['prefix' => 'ajax/tags'], function () {
          Route::get('/suggest/names', 'TagController@getNameSuggestions');
          Route::get('/suggest/values', 'TagController@getValueSuggestions');
      });
      Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
  
      // Comments
 -    Route::post('/ajax/page/{pageId}/comment', 'CommentController@savePageComment');
 -    Route::put('/ajax/comment/{id}', 'CommentController@update');
 -    Route::delete('/ajax/comment/{id}', 'CommentController@destroy');
 +    Route::post('/comment/{pageId}', 'CommentController@savePageComment');
 +    Route::put('/comment/{id}', 'CommentController@update');
 +    Route::delete('/comment/{id}', 'CommentController@destroy');
  
      // Links
      Route::get('/link/{id}', 'PageController@redirectFromLink');
      Route::get('/search/chapter/{bookId}', 'SearchController@searchChapter');
      Route::get('/search/entity/siblings', 'SearchController@searchSiblings');
  
 +    // User Search
 +    Route::get('/search/users/select', 'UserSearchController@forSelect');
 +
 +    // Template System
      Route::get('/templates', 'PageTemplateController@list');
      Route::get('/templates/{templateId}', 'PageTemplateController@get');
  
 +    // Favourites
 +    Route::get('/favourites', 'FavouriteController@index');
 +    Route::post('/favourites/add', 'FavouriteController@add');
 +    Route::post('/favourites/remove', 'FavouriteController@remove');
 +
      // Other Pages
      Route::get('/', 'HomeController@index');
      Route::get('/home', 'HomeController@index');
          Route::post('/', 'SettingController@update');
  
          // Maintenance
 -        Route::get('/maintenance', 'SettingController@showMaintenance');
 -        Route::delete('/maintenance/cleanup-images', 'SettingController@cleanupImages');
 -        Route::post('/maintenance/send-test-email', 'SettingController@sendTestEmail');
 +        Route::get('/maintenance', 'MaintenanceController@index');
 +        Route::delete('/maintenance/cleanup-images', 'MaintenanceController@cleanupImages');
 +        Route::post('/maintenance/send-test-email', 'MaintenanceController@sendTestEmail');
 +
 +        // Recycle Bin
 +        Route::get('/recycle-bin', 'RecycleBinController@index');
 +        Route::post('/recycle-bin/empty', 'RecycleBinController@empty');
 +        Route::get('/recycle-bin/{id}/destroy', 'RecycleBinController@showDestroy');
 +        Route::delete('/recycle-bin/{id}', 'RecycleBinController@destroy');
 +        Route::get('/recycle-bin/{id}/restore', 'RecycleBinController@showRestore');
 +        Route::post('/recycle-bin/{id}/restore', 'RecycleBinController@restore');
 +
 +        // Audit Log
 +        Route::get('/audit', 'AuditLogController@index');
  
          // Users
          Route::get('/users', 'UserController@index');
          Route::delete('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@destroy');
  
          // Roles
 -        Route::get('/roles', 'PermissionController@listRoles');
 -        Route::get('/roles/new', 'PermissionController@createRole');
 -        Route::post('/roles/new', 'PermissionController@storeRole');
 -        Route::get('/roles/delete/{id}', 'PermissionController@showDeleteRole');
 -        Route::delete('/roles/delete/{id}', 'PermissionController@deleteRole');
 -        Route::get('/roles/{id}', 'PermissionController@editRole');
 -        Route::put('/roles/{id}', 'PermissionController@updateRole');
 +        Route::get('/roles', 'RoleController@list');
 +        Route::get('/roles/new', 'RoleController@create');
 +        Route::post('/roles/new', 'RoleController@store');
 +        Route::get('/roles/delete/{id}', 'RoleController@showDelete');
 +        Route::delete('/roles/delete/{id}', 'RoleController@delete');
 +        Route::get('/roles/{id}', 'RoleController@edit');
 +        Route::put('/roles/{id}', 'RoleController@update');
      });
  
  });
  
  // Social auth routes
 -Route::get('/login/service/{socialDriver}', 'Auth\SocialController@getSocialLogin');
 -Route::get('/login/service/{socialDriver}/callback', 'Auth\SocialController@socialCallback');
 +Route::get('/login/service/{socialDriver}', 'Auth\SocialController@login');
 +Route::get('/login/service/{socialDriver}/callback', 'Auth\SocialController@callback');
  Route::group(['middleware' => 'auth'], function () {
 -    Route::get('/login/service/{socialDriver}/detach', 'Auth\SocialController@detachSocialAccount');
 +    Route::post('/login/service/{socialDriver}/detach', 'Auth\SocialController@detach');
  });
 -Route::get('/register/service/{socialDriver}', 'Auth\SocialController@socialRegister');
 +Route::get('/register/service/{socialDriver}', 'Auth\SocialController@register');
  
  // Login/Logout routes
  Route::get('/login', 'Auth\LoginController@getLogin');
@@@ -260,4 -251,4 +264,4 @@@ Route::post('/password/email', 'Auth\Fo
  Route::get('/password/reset/{token}', 'Auth\ResetPasswordController@showResetForm');
  Route::post('/password/reset', 'Auth\ResetPasswordController@reset');
  
 -Route::fallback('HomeController@getNotFound');
 +Route::fallback('HomeController@getNotFound')->name('fallback');