# Bookstack

Alles zu meinen bisher durchgeführten Bookstack Anpassungen

# Theme System

# Anzeige der Revisionen in einem PDF Export

<p class="callout success">getestet mit Version **24.02**</p>

## Anforderung

Um die Revisionen und ggfs. den Changelog in einem PDF Export ganz zum Schluss anzeigen lassen zu können sind einige Anpassungen nötig.

Zuerst habe ich dafür einen zusätzlichen Link in das Export-Menü eingebaut um einmal eine Version ohne Revisionen und eine mit Revisionen exportieren zu können.

## betroffene Dateien

<p class="callout info">Dateien müssen sich in der entsprechenden Struktur unterhalb des Themes befinden.  
Ordner = *kursiv*  
Dateien = **fett**</p>

- *entities*
    - **export-menu.blade.php**
- *exports*
    - *parts*
        - **revisions-index-row-compact.blade.php**
    - **page.blade.php**

## Inhalte der Dateien

<details id="bkmrk-export-menu.blade.ph"><summary>export-menu.blade.php</summary>

Hier wurde ein weiterer Link in **Zeile 6** eingefügt, der mittels GET den Wert `history=true` übergibt. Diese GET Variable kann dann später abgefragt werden.

```html
[...]
    <ul refs="dropdown@menu" class="wide dropdown-menu" role="menu">
        <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_html') }}</span><span>.html</span></a></li>
        <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li>
        <li><a href="{{ $entity->getUrl('/export/pdf?qr=true') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }} + QR</span><span>.pdf</span></a></li>
        <li><a href="{{ $entity->getUrl('/export/pdf?history=true') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }} + History</span><span>.pdf</span></a></li>
        <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li>
        <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
    </ul>
[...]
```

</details><details id="bkmrk-revisions-index-row-"><summary>revisions-index-row-compact.blade.php</summary>

Ich habe in dem Ordner eine weitere Datei angelegt und mit folgendem Inhalt gefüllt:

```html
<tr>
    <td>{{ $revision->created_at->isoFormat('D MMMM Y') }}</td>
    <td>{{ $revision->revision_number == 0 ? '' : $revision->revision_number }}</td>
    <td>@if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif</td>
    <td>{{ $revision->summary }}</td>
</tr>
```

</details><details id="bkmrk-page.blade.php-in-di"><summary>page.blade.php</summary>

In dieser Datei habe ich an das Ende der Seite folgenden Code eingefügt und die alte Meta Ansicht deaktiviert. Der geänderte Code beginnt in **Zeile 7** (in diesem Codeschnipsel).

```html
[...]
        <div style="clear:left;"></div>

        {!! $page->renderedHTML ?? $page->html !!}
    </div>

    @if(request()->query('history'))
        <hr>    
        <h2>Dokumentenhistorie</h2>
        <table>
            <tr>
                <th>Datum</th>
                <th>Version</th>
                <th>Autor</th>
                <th>Anmerkungen</th>
            </tr>
            @if(count($page->revisions) > 0)
                @foreach($page->revisions as $index => $revision)
                    @include('exports.parts.revisions-index-row-compact', ['revision' => $revision, 'current' => $page->revision_count === $revision->revision_number])
                @endforeach
            @else
                <p>{{ trans('entities.pages_revisions_none') }}</p>
            @endif
        </table>
    @endif

    <!-- <hr>

    <div class="text-muted text-small">
        @include('exports.parts.meta', ['entity' => $page])
    </div> -->
@endsection

```

Der Codeabschnitt kann auch an jeder anderen Stelle hinterlegt werden, für mich hat es aber am Ende des Dokuments den meisten Sinn gemacht.

</details>Ich habe bei den Anpassungen auf Übersetzungen verzichtet, das würde sich aber problemlos ändern lassen.

## Screenshots

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/scaled-1680-/nsfimage.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/nsfimage.png)

# URL mit Mausklick kopieren

<p class="callout success">getestet mit Version **24.02**</p>

## Anforderung

Wenn man Bookstack als PWA nutzt ist es nur umständlich möglich, die URL zu kopieren wenn man diese jemandem schicken möchte.

## betroffene Dateien

<p class="callout info">Dateien müssen sich in der entsprechenden Struktur unterhalb des Themes befinden.  
Ordner = *kursiv*  
Dateien = **fett**</p>

- *entities*
    - **share-link.blade.php**
- *shelves*
    - **show.blade.php**
- *books*
    - **show.blade.php**
- *chapters*
    - **show.blade.php**
- *page*
    - **show.blade.php**

## Inhalte der Dateien

<details id="bkmrk-export-menu.blade.ph"><summary>share-link.blade.php</summary>

```html
<button type="button"
        id="share-link-button"
        data-success-text="Link copied to clipboard!"
        class="icon-list-item text-link">
    <span>@icon('share')</span>
    <span>{{ trans('common.share') }}</span>
</button>
<script nonce="{{ $cspNonce }}">
    (async function() {
        const shareButton = document.getElementById('share-link-button');
        shareButton.addEventListener('click', event => {
           copyTextToClipboard(window.location.href);
           window.$events.success(shareButton.dataset.successText);
        });

        async function copyTextToClipboard(text) {
            if (window.isSecureContext && navigator.clipboard) {
                await navigator.clipboard.writeText(text);
                return;
            }

            // Backup option where we can't use the navigator.clipboard API
            const tempInput = document.createElement('textarea');
            tempInput.style = 'position: absolute; left: -1000px; top: -1000px;';
            tempInput.value = text;
            document.body.appendChild(tempInput);
            tempInput.select();
            document.execCommand('copy');
            document.body.removeChild(tempInput);
        }
    })()
</script>
```

</details><details id="bkmrk-revisions-index-row-"><summary>show.blade.php (gleich für jede Datei)</summary>

Hier wurde die Zeile 10 hinzugefügt (Zeilennummer nur für diesen Ausschnitt)

```html
            @if($watchOptions->canWatch() && !$watchOptions->isWatching())
                @include('entities.watch-action', ['entity' => $page])
            @endif
            @if(user()->hasAppAccess())
                @include('entities.favourite-action', ['entity' => $page])
            @endif
            @if(userCan('content-export'))
                @include('entities.export-menu', ['entity' => $page])
            @endif
            @include('entities.share-link', ['entity' => $page])
        </div>

    </div>
@stop
```

</details>## Screenshots

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/scaled-1680-/fUFimage.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/fUFimage.png)

# tabellarische Darstellung der Tags in einem PDF Export

<p class="callout success">getestet mit Version **24.02**</p>

## Anforderung

Für diverse Zertifizierungsdokument wird ein "Dokumentenheader" benötigt. Da ich die Informationen in Tags versteckt habe lasse ich diese einfach als Tabelle bei einem PDF Export darstellen.

## betroffene Dateien

<p class="callout info">Dateien müssen sich in der entsprechenden Struktur unterhalb des Themes befinden.  
Ordner = *kursiv*  
Dateien = **fett**</p>

- *exports*
    - *parts*
        - **tag-export-table.blade.php**
    - **page.blade.php**

## Inhalte der Dateien

<details id="bkmrk-export-menu.blade.ph"><summary>tag-export-table.blade.php</summary>

```html
<tr>
    <th>{{ $tag->name }}</th>
    @if($tag->value)<td>{{$tag->value}}</td>@else<td>&nbsp;</td>@endif
</tr>
```

</details><details id="bkmrk-revisions-index-row-"><summary>page.blade.php</summary>

An der gewünschten Stelle muss folgender Code eingefügt werden:

```php
@if($page->tags->count() > 0)
    <h2>Dokumenteninformationen</h2>
    <table>
        @foreach($page->tags as $tag)
            @include('exports.parts.tag-export-table', ['tag' => $tag])
        @endforeach
    </table>
    </div>
    <hr>
@endif
```

</details>## Screenshots

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/scaled-1680-/d7Limage.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/d7Limage.png)

# PDF Export mit QR-Code

<p class="callout success">getestet mit Version **24.02**</p>

## Anforderung

Ich wollte gerne die Möglichkeit haben, exportierte Daten einfach wieder in der digitalen Welt zu finden. Was ist dazu besser geeignet als ein **QR-Code**.

## betroffene Dateien

<p class="callout info">Dateien müssen sich in der entsprechenden Struktur unterhalb des Themes befinden.  
Ordner = *kursiv*  
Dateien = **fett**</p>

- *layouts*
    - *parts*
        - **export-body-start.blade.php**
- *entities*
    - **export-menu.blade.php**

## Inhalte der Dateien

<details id="bkmrk-export-body-start.bl"><summary>export-body-start.blade.php</summary>

Vor dem ersten `div`-Container muss hier folgendes eingetragen werden:

```
@inject('totp', 'BookStack\Access\Mfa\TotpService')

@php
$qrCode = $totp->generateQrCodeSvg($page->getUrl());
$imgStr = 'data:image/svg+xml;base64,' . base64_encode($qrCode);
@endphp
```

Im Anschluss kann an einer beliebigen Stelle das Bild an die PDF übergeben werden.  
Dazu muss folgender Abschnitt hinzugefügt werden:

```php
@if(request()->query('qr'))
    <div style="float: right;">
        <img width="50" src="{{ $imgStr }}" alt="{{ $page->getUrl() }}">
    </div>
@endif
```

</details><details id="bkmrk-revisions-index-row-"><summary>export-menu.blade.php</summary>

Hier muss nun einfach nach der Zeile gesucht werden mit dem Inhalt `/export/pdf`.

Danach die Zeile kopieren und den String ?qr=true anhängen an die selbe Stelle.

Es sollte dann wie folgt aussehen:

```html
<ul refs="dropdown@menu" class="wide dropdown-menu" role="menu">
      <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_html') }}</span><span>.html</span></a></li>
      <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li>
      <li><a href="{{ $entity->getUrl('/export/pdf?qr=true') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }} + QR</span><span>.pdf</span></a></li>
      <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li>
      <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
  </ul>
```

In diesem Ausschnitt ist in Zeile 4 der neue Export Link hinzugefügt.

</details>## Screenshots

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-06/scaled-1680-/image.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-06/image.png)

# PDF Export anpassen mit Header und Footer

<p class="callout success">getestet mit Version **25.07**</p>

## Anforderung

Die Standard-PDF Seite ist nicht wirklich ansprechend, also werden hier Header und Footer sowie Seitenzahlen eingefügt.

## betroffene Dateien

<p class="callout info">Dateien müssen sich in der entsprechenden Struktur unterhalb des Themes befinden.  
Ordner = *kursiv*  
Dateien = **fett**</p>

- *layouts*
    - *parts*
        - **export-body-start.blade.php**

## Inhalte der Dateien

<details id="bkmrk-export-body-start.bl"><summary>export-body-start.blade.php</summary>

Die Datei muss am Ende wie folgt aussehen:

```html
@if ($format === 'pdf')
<style media="print">
    .print-header-footer {
        position: fixed;
        width: 100%;
    }
    .print-footer {
        position: fixed;
        bottom: -40px;
        width: 100%;
    }
    .print-header-footer-inner {
        max-width: 840px;
        margin: 0 auto;
        color: #666;
    }
    .print-page-number:after {
        content: "Seite "counter(page);
    }
    @page {
        margin-top: 100px;
        margin-bottom: 80px;
    }
</style>

<div class="print-header-footer" style="top: -60px;">
    <div class="print-header-footer-inner">
        <div style="float: left; opacity: 0.8;">
            <img height="50" src="data:image/png;base64,{{ base64_encode(file_get_contents(theme_path('images/logo.png'))) }}">
        </div>
    </div>
    <div style="clear:both;"></div>
    <hr style="color: #ccc;">
</div>

<div class="print-header-footer" style="bottom: -40px;">
    <div class="print-header-footer-inner">
        <div style="float: left; opacity: 0.8; font-size: 8pt; text-align: left">
            &copy; [Footer-Text links]
        </div>
        <div style="float: right; opacity: 0.8; font-size: 8pt; text-align: right">
            <div class="print-page-number" style="opacity: 1"></div>
        </div>
    </div>
    <div style="clear:both;"></div>
</div>
@endif
```

</details>

# News-Seite / schwarzes Brett

<p class="callout success">getestet mit Version 24.12</p>

## Anforderung

Zum Abbilden der Funktion eines schwarzen Bretts bzw. einer News-Seite um aktuelle Infos anzuzeigen.

## betroffene Dateien

<p class="callout info">Dateien müssen sich in der entsprechenden Struktur unterhalb des Themes befinden.  
Ordner = *kursiv*  
Dateien = **fett**</p>

- *home*
    - **specific-page.blade.php**

## Inhalte der Dateien

<details id="bkmrk-export-menu.blade.ph"><summary>specific-page.blade.php</summary>

Innerhalb der `section('left')` oder `section('right')`, je nach Präferenz, muss folgender `<div>`-Block eingefügt werden:

```html
<div class="card mb-xl">
    @php
    $newsBookId = 34;
    $newsItems = \BookStack\Entities\Models\Page::visible()
        ->where('book_id', $newsBookId)
        ->orderBy('created_at', 'desc')
        ->take(7)
        ->get();
    @endphp
    <h3 class="card-title" style="font-weight: bold; font-size: 15pt;">{{ trans('common.actualnews') }}</h3>
    <div class="px-m">
        @include('entities.list', [
            'entities' => $newsItems,
            'style' => 'compact',
        ])
    </div>
    <a href="{{ url('/books/firmen-aushange-schwarzes-brett')  }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
</div>
```

die ID für das Buch welches hier genutzt werden soll lässt sich einfach herausfinden, in dem ein Buch öffnet und dann die Entwicklertools startet (`F12`). Hier sucht man dann nach folgendem Begriff: `option:entity-search:entity-id`  
Direkt dahinter steht die ID des Buches, welches dann in der Anpassung hinterlegt werden muss.

</details>## Screenshots

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2025-01/scaled-1680-/image.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2025-01/image.png)

# Regale zu denen ein Buch gehört anzeigen

<p class="callout success">getestet mit Version **25.02**</p>

## Anforderung

Ich wollte sehen in welchen Regalen ein Buch steht, da ein Buch in mehreren Regalen stehen kann und weil die Brotkrumen Navigation das Regal nicht anzeigt wenn man nicht das Buch z.B. über die Suche öffnet.

## betroffene Dateien

<p class="callout info">Dateien müssen sich in der entsprechenden Struktur unterhalb des Themes befinden.  
Ordner = *kursiv*  
Dateien = **fett**</p>

- *books*
    - **show.blade.oph**

## Inhalte der Dateien

<details id="bkmrk-export-menu.blade.ph"><summary>show.blade.php</summary>

Unter `@section('left')` einfach an der gewünschten Stelle den folgenden Code einfügen

```html
[...]
    @if(count($bookParentShelves) > 0)
        <div class="actions mb-xl">
            <h5>{{ trans('entities.shelves') }}</h5>
            @include('entities.list', ['entities' => $bookParentShelves, 'style' => 'compact'])
        </div>
    @endif
[...]
```

</details>## Screenshots

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2025-03/scaled-1680-/image.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2025-03/image.png)

# BookStack Page Lock & PIN Protection

<p class="callout success">getestet mit Version **25.12.2**</p>

## Quelle

[https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack](https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack)

<details id="bkmrk-originale-anleitung-"><summary>originale Anleitung</summary>

# BookStack Page Lock &amp; PIN Protection

<div class="markdown-heading" dir="auto">[<svg aria-hidden="true" class="octicon octicon-link" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>](https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack/tree/main#bookstack-page-lock--pin-protection)</div>[![BookStack](https://camo.githubusercontent.com/d3a8965b17311941c3f7cfe16ab847b9b12304d5de0fee658881c9c313afcf1a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f426f6f6b537461636b2d4164646f6e2d3032383844313f7374796c653d666c61742d737175617265266c6f676f3d626f6f6b737461636b266c6f676f436f6c6f723d7768697465)](https://camo.githubusercontent.com/d3a8965b17311941c3f7cfe16ab847b9b12304d5de0fee658881c9c313afcf1a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f426f6f6b537461636b2d4164646f6e2d3032383844313f7374796c653d666c61742d737175617265266c6f676f3d626f6f6b737461636b266c6f676f436f6c6f723d7768697465) [![BookStack Version](https://camo.githubusercontent.com/3b55e116c1f17c7a6a5adfcae56701e8a548a32375c7d71fd303a3c01fa5a808/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f426f6f6b537461636b2d7632352e31322e322d6c69676874626c75653f7374796c653d666c61742d737175617265266c6f676f3d626f6f6b737461636b266c6f676f436f6c6f723d7768697465)](https://camo.githubusercontent.com/3b55e116c1f17c7a6a5adfcae56701e8a548a32375c7d71fd303a3c01fa5a808/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f426f6f6b537461636b2d7632352e31322e322d6c69676874626c75653f7374796c653d666c61742d737175617265266c6f676f3d626f6f6b737461636b266c6f676f436f6c6f723d7768697465) [![PHP](https://camo.githubusercontent.com/423698a15c259fe99627b4ab8328a052b729761f9c4b01dafc58b699b85f352b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e302532422d3737374242343f7374796c653d666c61742d737175617265266c6f676f3d706870266c6f676f436f6c6f723d7768697465)](https://camo.githubusercontent.com/423698a15c259fe99627b4ab8328a052b729761f9c4b01dafc58b699b85f352b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e302532422d3737374242343f7374796c653d666c61742d737175617265266c6f676f3d706870266c6f676f436f6c6f723d7768697465) [![Security](https://camo.githubusercontent.com/15401b22d514eddee1fa7ed2ef094a829e01d49af6ce51b7edfb3401f81799a4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f53656375726974792d50494e5f4c6f636b2d637269746963616c3f7374796c653d666c61742d737175617265)](https://camo.githubusercontent.com/15401b22d514eddee1fa7ed2ef094a829e01d49af6ce51b7edfb3401f81799a4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f53656375726974792d50494e5f4c6f636b2d637269746963616c3f7374796c653d666c61742d737175617265) [![License](https://camo.githubusercontent.com/422db9fd40f5831c765cf6530b6750c081b696bd18d904cf89554df98c676277/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e3f7374796c653d666c61742d737175617265)](https://camo.githubusercontent.com/422db9fd40f5831c765cf6530b6750c081b696bd18d904cf89554df98c676277/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d677265656e3f7374796c653d666c61742d737175617265)

A lightweight, PIN-based protection layer for [BookStack](https://www.bookstackapp.com/). This modification allows administrators to "lock" specific pages behind a custom PIN or a global Master PIN, effectively hiding the content until the correct code is entered.

---

## Features

<div class="markdown-heading" dir="auto">[<svg aria-hidden="true" class="octicon octicon-link" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>](https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack/tree/main#features)</div>- **Per-Page Locking:** Secure specific pages without altering global role permissions.
- **Dual PIN Modes:** Pages can use a specific "Custom Password" or fall back to a global "Master PIN" defined in your `.env` file.
- **Native UI Integration:** Controls are embedded directly into the Page "Permissions" view.
- **Visual Indicators:** Automatically adds a lock symbol (🔒) to page titles and status indicators in the UI.
- **Search &amp; Preview Scrubbing:** Prevents protected content from leaking into search results or list previews by clearing preview text for protected items.
- **CLI Management Tool:** Includes a terminal script to manage locks, update passwords, and remove protections.

---

## Installation

<div class="markdown-heading" dir="auto">[<svg aria-hidden="true" class="octicon octicon-link" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>](https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack/tree/main#installation)</div>### 1. File Placement

<div class="markdown-heading" dir="auto">[<svg aria-hidden="true" class="octicon octicon-link" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>](https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack/tree/main#1-file-placement)</div>This guide assumes you are using the [BookStack Logical Theme System](https://www.bookstackapp.com/docs/admin/themes/). Replace `themes/my-theme/` with your actual theme folder.

<table tabindex="0"><thead><tr><th align="left">Component</th><th align="left">Source File</th><th align="left">Destination</th><th align="left">Description</th></tr></thead><tbody><tr><td align="left">**Logic**</td><td align="left">`functions.php`</td><td align="left">`themes/my-theme/functions.php`</td><td align="left">Handles backend interception and PIN validation.</td></tr><tr><td align="left">**Footer**</td><td align="left">`footer.blade.php`</td><td align="left">`themes/my-theme/layouts/parts/footer.blade.php`</td><td align="left">Handles JS visual scrubbing of tags.</td></tr><tr><td align="left">**UI**</td><td align="left">`entity-permissions.blade.php`</td><td align="left">`themes/my-theme/form/entity-permissions.blade.php`</td><td align="left">Adds the lock interface to the permissions page.</td></tr><tr><td align="left">**CLI Tool**</td><td align="left">`ManageLocksCommand.php`</td><td align="left">`themes/my-theme/app/Console/Commands/ManageLocksCommand.php`</td><td align="left">**sudo php artisan bookstack:manage-locks**. Admin tool for managing locks.</td></tr></tbody></table>

### 2. Configuration

<div class="markdown-heading" dir="auto">[<svg aria-hidden="true" class="octicon octicon-link" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>](https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack/tree/main#2-configuration)</div>Open your BookStack `.env` file and add a Master PIN. This is used if a page is locked but no custom password is set.

```
# .env
SECURE_PAGE_PIN=123456
```

<div class="highlight highlight-source-shell notranslate position-relative overflow-auto" dir="auto"><div class="zeroclipboard-container"><svg aria-hidden="true" class="octicon octicon-copy js-clipboard-copy-icon" data-view-component="true" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg></div></div>### 3. Usage: Web Interface

<div class="markdown-heading" dir="auto">[<svg aria-hidden="true" class="octicon octicon-link" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>](https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack/tree/main#3-usage-web-interface)</div>- Navigate to the page you wish to protect.
- Click Permissions in the sidebar.
- Scroll down to the PIN Protection card.
- Enter a Custom Password (optional) and click Enable Lock.

<svg aria-hidden="true" class="octicon octicon-alert mr-2" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"></path></svg>Warning

If you leave the password blank, the page will require the SECURE\_PAGE\_PIN defined in your .env.

The page title will automatically update to include "🔒" to indicate its protected status.

### 4. Parent Deletion

<div class="markdown-heading" dir="auto">[<svg aria-hidden="true" class="octicon octicon-link" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>](https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack/tree/main#4-parent-deletion)</div>Users will be asked to Unlock, Move or delete locked pages before parents can be deleted (Shelf, Book, Chapter). This is to stop abuse of admin permissions if they haven't got access to the Locked file. It also goes under the prosumption that the file is locked for an important reason stopping loss of important data

### 5. Usage: CLI Tool

<div class="markdown-heading" dir="auto">[<svg aria-hidden="true" class="octicon octicon-link" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>](https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack/tree/main#5-usage-cli-tool)</div>You can manage locks directly from the terminal using `php artisan bookstack:manage-locks`. This is useful for auditing protected pages, resetting forgotten PINs, or bulk unlocking content.

How to run it Open your terminal and navigate to your BookStack root directory (e.g., /var/www/bookstack).

Run the script using PHP:

```
php artisan bookstack:manage-locks
```

<div class="highlight highlight-source-shell notranslate position-relative overflow-auto" dir="auto"><div class="zeroclipboard-container"><svg aria-hidden="true" class="octicon octicon-copy js-clipboard-copy-icon" data-view-component="true" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg></div></div><svg aria-hidden="true" class="octicon octicon-info mr-2" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"></path></svg>Note

When you run the command, you will see a list of all currently protected pages:

```
🔒 BookStack Secure Page Manager
================================
ID    | Page Title                               | Current Password    
----------------------------------------------------------------------
[1]   | Server Access Codes 🔒                  | [Master PIN]        
[2]   | HR Confidential 🔒                      | secret123           
----------------------------------------------------------------------
Current .env Master PIN: ****

```

<div class="snippet-clipboard-content notranslate position-relative overflow-auto"><div class="zeroclipboard-container"><svg aria-hidden="true" class="octicon octicon-copy js-clipboard-copy-icon" data-view-component="true" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg></div></div>```
Enter Page ID to edit, "M" to Change Master PIN, or press Enter to exit: 
To Edit a Lock:

Type the ID number (e.g., 2) and press Enter.

```

<div class="snippet-clipboard-content notranslate position-relative overflow-auto"><div class="zeroclipboard-container"><svg aria-hidden="true" class="octicon octicon-copy js-clipboard-copy-icon" data-view-component="true" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg></div></div>The command will show the current status and ask for a new password.

```
Selected: HR Confidential 🔒
Current Password: secret123
Enter NEW password (leave empty to use Master PIN, or type 'DELETE' to unlock):
Options:

```

<div class="snippet-clipboard-content notranslate position-relative overflow-auto"><div class="zeroclipboard-container"><svg aria-hidden="true" class="octicon octicon-copy js-clipboard-copy-icon" data-view-component="true" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg></div></div>Type a new password: Updates the lock to the new code.

Press `Enter` (Empty): Sets the page to use the global Master PIN.

Type `DELETE`: Removes the lock entirely and makes the page public again.

```
To Edit the master pin:

Type "M" and follow the prompts

```

<div class="snippet-clipboard-content notranslate position-relative overflow-auto"><div class="zeroclipboard-container"><svg aria-hidden="true" class="octicon octicon-copy js-clipboard-copy-icon" data-view-component="true" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg></div></div>**Full Changelog**: [https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack/commits/Bookstack-Hack](https://github.com/Rockyroad1827/Bookstacks-Password-Protect-Page-Hack/commits/Bookstack-Hack)

</details>## Anforderung

 lightweight, PIN-based protection layer for [BookStack](https://www.bookstackapp.com/). This modification allows administrators to "lock" specific pages behind a custom PIN or a global Master PIN, effectively hiding the content until the correct code is entered.

### Features

- **Per-Page Locking:** Secure specific pages without altering global role permissions.
- **Dual PIN Modes:** Pages can use a specific "Custom Password" or fall back to a global "Master PIN" defined in your `.env` file.
- **Native UI Integration:** Controls are embedded directly into the Page "Permissions" view.
- **Visual Indicators:** Automatically adds a lock symbol (🔒) to page titles and status indicators in the UI.
- **Search &amp; Preview Scrubbing:** Prevents protected content from leaking into search results or list previews by clearing preview text for protected items.
- **CLI Management Tool:** Includes a terminal script to manage locks, update passwords, and remove protections.

## betroffene Dateien

<p class="callout info">Dateien müssen sich in der entsprechenden Struktur unterhalb des Themes befinden.  
Ordner = *kursiv*  
Dateien = **fett**</p>

- **functions.php**
- *layouts*
    - *parts*
        - **footer.blade.php**
- *form*
    - **entity-permissions.blade.php**
- *app*
    - *Console*
        - *Commands*
            - **ManageLocksCommand.php**

## Inhalte der Dateien

<details id="bkmrk-export-menu.blade.ph"><summary>ManageLocksCommand.php</summary>

```php
<?php

namespace BookStack\Console\Commands;

use BookStack\Entities\Models\Page;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Artisan;

class ManageLocksCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'bookstack:manage-locks';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Manage PIN protection on BookStack pages';

    /**
     * Execute the console command.
     */
    public function handle(): int
    {
        $this->info("🔒  BookStack Secure Page Manager");
        $this->info("================================");

        // Fetch Protected Pages
        $protectedPages = Page::whereHas('tags', function ($query) {
            $query->where('name', 'Protected');
        })->get();

        if ($protectedPages->isEmpty()) {
            $this->warn("No protected pages found.");
        }

        // Build Table Data
        $headers = ['ID', 'Page Title', 'Current Password'];
        $rows = [];
        $map = [];

        foreach ($protectedPages as $page) {
            $tag = $page->tags->where('name', 'Protected')->first();
            $currentPass = $tag->value ? $tag->value : "<fg=yellow>[Master PIN]</>";
            
            $rows[] = [
                $page->id,
                $page->name,
                $currentPass
            ];
            
            // Map ID to Page Object for easy lookup
            $map[$page->id] = $page;
        }

        $this->table($headers, $rows);
        $this->line("Current .env Master PIN: <fg=green>" . env('SECURE_PAGE_PIN', '(Not Set)') . "</>");
        $this->line("");

        // Interactive Selection
        $choice = $this->ask('Enter Page ID to edit, "M" to Change Master PIN, or press Enter to exit');

        // --- OPTION M: CHANGE MASTER PIN ---
        if (strtoupper($choice) === 'M') {
            return $this->changeMasterPin($protectedPages);
        }

        // --- OPTION: EXIT ---
        if (!$choice || !isset($map[$choice])) {
            $this->info("Exiting.");
            return 0;
        }

        // --- OPTION: EDIT SINGLE PAGE ---
        $selectedPage = $map[$choice];
        $currentTag = $selectedPage->tags->where('name', 'Protected')->first();
        
        $this->line("");
        $this->info("Selected: <fg=cyan>{$selectedPage->name}</>");
        $this->line("Current Password: " . ($currentTag->value ?: "Master PIN"));

        // Update Logic
        $newPass = $this->ask("Enter NEW password (leave empty for Master PIN, or type 'DELETE' to unlock)");

        if ($newPass === 'DELETE') {
            // Remove the protection tag
            $currentTag->delete();

            // Remove the Lock Symbol from the Page Title
            $lockSymbol = '🔒';
            if (strpos($selectedPage->name, $lockSymbol) !== false) {
                $selectedPage->name = trim(str_replace($lockSymbol, '', $selectedPage->name));
                $selectedPage->save();
                $this->info("✅  Lock symbol removed from page title.");
            }

            $this->info("✅  Protection REMOVED from page.");
        } else {
            $currentTag->value = $newPass;
            $currentTag->save();
            
            $status = empty($newPass) ? "Master PIN" : $newPass;
            $this->info("✅  Password updated to: {$status}");
        }

        return 0;
    }

    /**
     * Handle the Master PIN update logic
     */
    protected function changeMasterPin($allProtectedPages)
    {
        $this->info("\n--- UPDATING MASTER PIN ---");
        
        // Capture the OLD Master PIN (from loaded env) before we change it
        $oldMasterPin = env('SECURE_PAGE_PIN');
        
        $newPin = $this->ask("Enter the NEW Master PIN");

        if (empty($newPin)) {
            $this->error("PIN cannot be empty.");
            return 1;
        }

        if (!$this->confirm("Are you sure you want to change the Master PIN to '{$newPin}'?")) {
            $this->info("Operation cancelled.");
            return 0;
        }

        // Update .env File
        $envPath = base_path('.env');
        if (File::exists($envPath)) {
            $content = File::get($envPath);
            $key = 'SECURE_PAGE_PIN';
            
            if (strpos($content, "$key=") !== false) {
                // Replace existing key
                $content = preg_replace("/^{$key}=.*/m", "{$key}={$newPin}", $content);
            } else {
                // Append new key
                $content .= "\n{$key}={$newPin}";
            }
            
            File::put($envPath, $content);
            $this->info("✅  Updated .env file.");
        } else {
            $this->error("Could not find .env file at: $envPath");
            return 1;
        }

        // Update Relevant Pages (Explicit + Implicit)
        $explicitUpdates = 0;
        $totalUpdated = 0;
        
        foreach ($allProtectedPages as $page) {
            $tag = $page->tags()->where('name', 'Protected')->first();
            if ($tag) {
                $val = $tag->value;

                // Explicitly using the OLD Master PIN -> Update DB to New PIN
                if (!empty($val) && $val == $oldMasterPin) {
                    $tag->value = $newPin; 
                    $tag->save();
                    $explicitUpdates++;
                    $totalUpdated++;
                }
                // Implicitly using Master PIN (empty value) -> Inherits New PIN automatically
                elseif (empty($val)) {
                    $totalUpdated++;
                }
                // Custom Password -> Ignored
            }
        }
        
        if ($explicitUpdates > 0) {
            $this->comment("ℹ️  Database updated for {$explicitUpdates} pages that explicitly used the old PIN.");
        }

        // Clear Config Cache
        $this->call('config:clear');
        
        // Final Summary
        $this->info("✅  Updated {$totalUpdated} total pages.");

        return 0;
    }
}
```

</details><details id="bkmrk-revisions-index-row-"><summary>functions.php</summary>

```php
<?php
use BookStack\Entities\Models\Page;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Event;
use Illuminate\Routing\Events\RouteMatched;

// --------------------------------------------------------------------------------
// Page Lock SEARCH BLOCKER (Raw Request Intercept)
// --------------------------------------------------------------------------------
// Immediately blocks search requests for "Protected" tags to prevent leaks.
if (isset($_SERVER['REQUEST_URI'])) {
    $rawUri = $_SERVER['REQUEST_URI'];
    $decodedUri = rawurldecode($rawUri);
    if (strpos($rawUri, '/search') !== false && stripos($decodedUri, 'Protected') !== false) {
        header("Location: /");
        exit();
    }
}

///////////////////////////////////////////////////////////////////////////////////
// Page Lock Logic
///////////////////////////////////////////////////////////////////////////////////

// --------------------------------------------------------------------------------
// HELPER: CHECK IF SPECIFIC PAGE IS UNLOCKED
// --------------------------------------------------------------------------------
if (!function_exists('isPageUnlocked')) {
    function isPageUnlocked($pageId) {
        // Retrieve the list of unlocked pages: [ PageID => Timestamp ]
        $unlockedPages = Session::get('secure_unlocked_pages', []);
        
        // Check if this ID exists and the timestamp is in the future
        if (isset($unlockedPages[$pageId]) && $unlockedPages[$pageId] > time()) {
            return true;
        }
        return false;
    }
}

// --------------------------------------------------------------------------------
// HELPER: GET REQUIRED PIN
// --------------------------------------------------------------------------------
if (!function_exists('getSecurePagePin')) {
    function getSecurePagePin($page) {
        $tag = $page->tags()->where('name', 'Protected')->first();
        if (!$tag) return null;
        return !empty($tag->value) ? $tag->value : env('SECURE_PAGE_PIN');
    }
}

// --------------------------------------------------------------------------------
// EXPORT INTERCEPTOR (PDF, HTML, TXT, MD, ZIP)
// --------------------------------------------------------------------------------
// Listens for ANY route that matches. If the URL contains '/export/', we verify access.
Event::listen(RouteMatched::class, function (RouteMatched $event) {
    $request = request();
    $path = $request->path();

    // Check if this is an export URL
    if (strpos($path, '/export/') !== false) {
        
        // Try to find the Page Slug in the route parameters
        // BookStack usually names this parameter 'pageSlug' or 'page'
        $slug = $event->route->parameter('pageSlug');

        if ($slug) {
            // Find the page in the DB
            $page = Page::where('slug', $slug)->first();

            // Check if Page exists, is Protected, and is NOT unlocked
            if ($page && $page->tags()->where('name', 'Protected')->exists()) {
                if (!isPageUnlocked($page->id)) {
                    
                    // Block Download & Redirect to Lock Screen
                    // We attach the CURRENT export URL as the "redirect_after_unlock" target.
                    // Once unlocked, the user will be bounced right back here to start the download.
                    $currentExportUrl = $request->fullUrl();
                    $lockScreenUrl = $page->getUrl() . '?redirect_after_unlock=' . urlencode($currentExportUrl);
                    
                    header("Location: " . $lockScreenUrl);
                    exit();
                }
            }
        }
    }
});

// --------------------------------------------------------------------------------
// BACKEND ROUTES (PIN LOGIC)
// --------------------------------------------------------------------------------
if (!app()->routesAreCached()) {
    // Check PIN
    Route::post('/secure-pin-check', function () {
        $input = request()->input('pin_code');
        $pageId = request()->input('page_id');
        $redirect = request()->input('redirect_to', '/');
        
        $targetPin = env('SECURE_PAGE_PIN');
        if ($pageId) {
            $page = Page::find($pageId);
            if ($page) {
                $targetPin = getSecurePagePin($page);
            }
        }

        if ($input && $targetPin && (string)$input === (string)$targetPin) {
            $unlockedPages = Session::get('secure_unlocked_pages', []);
            $unlockedPages[$pageId] = time() + 5; 
            Session::put('secure_unlocked_pages', $unlockedPages);
            Session::save();
            
            return redirect($redirect);
        }
        return redirect($redirect)->with('pin_error', 'Invalid Access Code');
    })->middleware('web');

    // Enable Lock & Add Emoji
    Route::post('/secure-lock-page', function () {
        $pageId = request()->input('page_id');
        $customPass = request()->input('custom_password');
        $redirectUrl = request()->input('redirect_to', '/');
        $page = Page::find($pageId);

        if ($page && userCan('page-update', $page)) {
            // 1. Add Tag
            if (!$page->tags()->where('name', 'Protected')->exists()) {
                $page->tags()->create(['name' => 'Protected', 'value' => $customPass]);
                
                // 2. Add Lock Emoji to Title
                if (strpos($page->name, '🔒') === false) {
                    $page->name = trim($page->name) . ' 🔒';
                    $page->save();
                }

                Session::flash('success', 'PIN protection enabled.');
            }
        }
        return redirect($redirectUrl);
    })->middleware('web');

    // Disable Lock & Remove Emoji
    Route::post('/secure-unlock-page', function () {
        $pageId = request()->input('page_id');
        $redirectUrl = request()->input('redirect_to', '/');
        $page = Page::find($pageId);

        if ($page && userCan('page-update', $page)) {
            $tag = $page->tags()->where('name', 'Protected')->first();
            if ($tag) {
                // 1. Remove Tag
                $tag->delete();

                // 2. Remove Lock Emoji from Title
                if (strpos($page->name, '🔒') !== false) {
                    $page->name = trim(str_replace('🔒', '', $page->name));
                    $page->save();
                }

                Session::flash('success', 'PIN protection removed.');
            }
        }
        return redirect($redirectUrl);
    })->middleware('web');
}

// --------------------------------------------------------------------------------
// LOCK SCREEN GENERATOR
// --------------------------------------------------------------------------------
if (!function_exists('renderSecureLockScreen')) {
    function renderSecureLockScreen($title = 'Protected Content', $pageId = null) {
        $errorHtml = Session::has('pin_error') ? 
            '<div class="text-neg bold mb-m" style="background: #ffebeb; border: 1px solid #cb2431; padding: 10px; border-radius: 4px;">' . Session::get('pin_error') . '</div>' : '';
        $pageIdInput = $pageId ? '<input type="hidden" name="page_id" value="' . $pageId . '">' : '';

        // Detect Redirect Intent
        $targetRedirect = request()->get('redirect_after_unlock');
        if (!$targetRedirect) {
            $targetRedirect = request()->fullUrl();
        }

        return '
        <div class="flex-fill flex-container-column justify-center items-center" style="min-height: 60vh;">
            <div class="card content-wrap auto-height" style="max-width: 500px; width: 100%; box-shadow: 0 4px 12px rgba(0,0,0,0.1);">
                <div class="text-center mb-l">
                    <div style="width: 80px; height: 80px; background-color: var(--color-primary); color: #FFF; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
                         <svg fill="currentColor" width="40" height="40" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
                    </div>
                    <h1 class="list-heading text-xl mb-s">' . $title . '</h1>
                    <p class="text-muted">This page is password protected.</p>
                </div>
                ' . $errorHtml . '
                <form method="POST" action="/secure-pin-check" class="stretch-inputs">
                    <input type="hidden" name="_token" value="' . csrf_token() . '">
                    ' . $pageIdInput . '
                    <input type="hidden" name="redirect_to" value="' . htmlspecialchars($targetRedirect) . '">
                    <div class="form-group mb-l">
                        <label for="pin_code" class="text-muted mb-xs">Access Code</label>
                        <input type="password" id="pin_code" name="pin_code" placeholder="Enter PIN..." class="input-base" style="font-size: 1.1em; padding: 10px;" autofocus>
                    </div>
                    <div class="form-group text-right">
                         <a href="/" class="button outline mr-m">Cancel</a>
                        <button type="submit" class="button primary">Unlock Page</button>
                    </div>
                </form>
            </div>
        </div>
        <style>.tri-layout-right, .tri-layout-left, .toolbar { display: none !important; } #main-content { width: 100% !important; margin: 0 !important; max-width: 100% !important; }</style>';
    }
}

// --------------------------------------------------------------------------------
// VIEW INTERCEPTOR: SHOW PAGE (Main Content)
// --------------------------------------------------------------------------------
View::composer(['pages.show'], function ($view) {
    $data = $view->getData();
    if (!isset($data['page'])) return;
    $page = $data['page'];
    
    if ($page->tags()->where('name', 'Protected')->exists() && !isPageUnlocked($page->id)) {
        $page->html = renderSecureLockScreen("Protected Content", $page->id);
    }
});

// --------------------------------------------------------------------------------
// VIEW INTERCEPTOR: ACTIONS (Edit, Copy, Move, etc.)
// --------------------------------------------------------------------------------
View::composer([
    'pages.edit', 'pages.move', 'pages.revisions', 'pages.delete', 'pages.copy', 'form.entity-permissions'
], function ($view) {
    $data = $view->getData();
    $page = $data['page'] ?? $data['model'] ?? null;

    if ($page instanceof \BookStack\Entities\Models\Page) {
         if ($page->tags()->where('name', 'Protected')->exists() && !isPageUnlocked($page->id)) {
             $currentActionUrl = request()->fullUrl();
             $redirectUrl = $page->getUrl() . '?redirect_after_unlock=' . urlencode($currentActionUrl);
             header("Location: " . $redirectUrl);
             exit();
         }
    }
});

// --------------------------------------------------------------------------------
// LIST VIEW SCRUBBER
// --------------------------------------------------------------------------------
View::composer([
    'partials.entity-list-item', 'partials.page-list-item', 'partials.book-content-list-item'
], function ($view) {
    $data = $view->getData();
    $entity = $data['entity'] ?? $data['page'] ?? null;

    if ($entity && isset($entity->tags)) {
        $hasProtectedTag = $entity->tags->contains(function($tag) {
            return strtolower($tag->name) === 'protected';
        });

        if ($hasProtectedTag) {
            $entity->text = '';
            $entity->html = '';
            $entity->preview_html = '';
        }

        $entity->tags = $entity->tags->filter(function($tag) {
            return strtolower($tag->name) !== 'protected';
        });
    }
});

// --------------------------------------------------------------------------------
// REGISTER CUSTOM ARTISAN COMMAND
// --------------------------------------------------------------------------------
if (app()->runningInConsole()) {
    // 1. Manually load the class file
    $commandFile = __DIR__ . '/app/Console/Commands/ManageLocksCommand.php';
    
    if (file_exists($commandFile)) {
        require_once $commandFile;

        // 2. Register the command with Laravel
        // FIX: Use the concrete Application class instead of the Artisan facade
        \Illuminate\Console\Application::starting(function ($artisan) {
            $artisan->resolveCommands([\BookStack\Console\Commands\ManageLocksCommand::class]);
        });
    }
}
// --------------------------------------------------------------------------------
// PARENT DELETION BLOCKER (Shelves, Books, Chapters)
// --------------------------------------------------------------------------------
// Prevents deletion if the entity contains any "Protected" pages.
View::composer(['shelves.delete', 'books.delete', 'chapters.delete'], function ($view) {
    $data = $view->getData();
    $entity = null;
    $protectedCount = 0;

    // Identify Entity Type and Count Protected Children
    if (isset($data['shelf'])) {
        $entity = $data['shelf'];
        foreach ($entity->books as $book) {
            $protectedCount += $book->pages()->whereHas('tags', function($q){
                $q->where('name', 'Protected');
            })->count();
        }
    } elseif (isset($data['book'])) {
        $entity = $data['book'];
        $protectedCount = $entity->pages()->whereHas('tags', function($q){
            $q->where('name', 'Protected');
        })->count();
    } elseif (isset($data['chapter'])) {
        $entity = $data['chapter'];
        $protectedCount = $entity->pages()->whereHas('tags', function($q){
            $q->where('name', 'Protected');
        })->count();
    }

    // If protected content is found, block access
    if ($entity && $protectedCount > 0) {
        // FLASH A SPECIAL SESSION KEY TO TRIGGER THE POPUP
        Session::flash('protected_deletion_blocked', $protectedCount);
        
        // CRITICAL FIX: Force Laravel to write the session before we exit
        Session::save();
        
        // Redirect back to the entity page
        header("Location: " . $entity->getUrl());
        exit();
    }
});
```

</details><details id="bkmrk-footer.blade.php-%3Cdi"><summary>footer.blade.php</summary>

```php
{{-- DELETION BLOCKED MODAL --}}
@if(session()->has('protected_deletion_blocked'))
    <div id="lock-block-modal" style="display: flex; align-items: center; justify-content: center; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 9999; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(2px);">
        <div class="card p-xl" style="max-width: 450px; width: 90%; border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.2);">
            <div class="text-center">
                <div class="text-neg mb-l" style="display: inline-flex; padding: 16px; background-color: #fff2f2; border-radius: 50%;">
                    <svg fill="currentColor" width="48" height="48" viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>
                </div>
                <h3 class="list-heading text-xl bold mb-s">Action Blocked</h3>
                <p class="text-muted mb-l" style="font-size: 1.1em; line-height: 1.5;">
                    This item contains <strong>{{ session('protected_deletion_blocked') }}</strong> PIN-protected page(s).<br>
                    <span class="small">You must unlock or move these pages before this item can be deleted.</span>
                </p>
                {{-- REMOVED inline onclick, ADDED ID --}}
                <button type="button" id="close-lock-modal" class="button primary">Understood</button>
            </div>
        </div>
    </div>
@endif

<div class="print-hidden">
    <footer class="px-xl py-m mt-xl border-top text-muted text-small">
        <div class="container">
        </div>
    </footer>

    <script nonce="{{ $cspNonce }}">
    (function() {
        console.log("Authorized Footer Script Running");

        // SETUP MODAL CLOSING LOGIC
        const closeBtn = document.getElementById('close-lock-modal');
        if (closeBtn) {
            closeBtn.addEventListener('click', function() {
                const modal = document.getElementById('lock-block-modal');
                if (modal) modal.remove();
            });
        }

        // HIDE TAGS LOGIC
        function hideProtectedTags() {
            const tags = document.querySelectorAll('.tag-item');
            
            tags.forEach(tag => {
                if (tag.dataset.protectedChecked) return;

                const tagText = tag.innerText.trim();
                if (tagText.startsWith('Protected')) {
                    tag.style.display = 'none';
                    tag.dataset.protectedHidden = "true";
                }
                tag.dataset.protectedChecked = "true";
            });
        }

        // HIDE CONTENT LOGIC
        function hideProtectedContent() {
            const trigger = "🔒"; 
            const items = document.querySelectorAll('.entity-list-item');

            items.forEach(item => {
                const titleElement = item.querySelector('.entity-list-item-name');
                
                if (titleElement && titleElement.textContent.includes(trigger)) {
                    
                    const desc = item.querySelector('.entity-list-item-desc');
                    if (desc) desc.style.display = 'none';

                    const snippet = item.querySelector('.entity-list-item-snippet'); 
                    if (snippet) {
                        snippet.style.display = 'none';
                    } else {
                        const mutedItems = item.querySelectorAll('.text-muted');
                        mutedItems.forEach(el => {
                            if (el.querySelector('a') === null) { 
                                el.style.display = 'none';
                            }
                        });
                    }
                }
            });
        }

        // EXECUTION
        hideProtectedTags();
        hideProtectedContent();

        document.addEventListener("DOMContentLoaded", function() {
            hideProtectedTags();
            hideProtectedContent();
        });

        const observer = new MutationObserver(function(mutations) {
            hideProtectedTags();
            hideProtectedContent();
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    })();
    </script>
</div>

```

</details><details id="bkmrk-entity-permissions.b-1"><summary>entity-permissions.blade.php</summary>

```php
<?php
/** @var \BookStack\Permissions\PermissionFormData $data */

// --- CHECK STATUS ---
$isProtected = false;
$hasCustomPass = false;

if($model instanceof \BookStack\Entities\Models\Page) {
    // Check for the tag
    $tag = $model->tags()->where('name', 'Protected')->first();
    $isProtected = !is_null($tag);
    $hasCustomPass = $isProtected && !empty($tag->value);
}
?>

{{-- MAIN PERMISSIONS FORM --}}
<form component="entity-permissions"
      option:entity-permissions:entity-type="{{ $model->getType() }}"
      action="{{ $model->getUrl('/permissions') }}"
      method="POST"
      id="main-permissions-form">
    {!! csrf_field() !!}
    <input type="hidden" name="_method" value="PUT">

    <div class="grid half left-focus v-end gap-m wrap">
        <div>
            <h1 class="list-heading">{{ $title }}</h1>
            <p class="text-muted mb-s">
                {{ trans('entities.permissions_desc') }}
                @if($model instanceof \BookStack\Entities\Models\Book)
                    <br> {{ trans('entities.permissions_book_cascade') }}
                @elseif($model instanceof \BookStack\Entities\Models\Chapter)
                    <br> {{ trans('entities.permissions_chapter_cascade') }}
                @endif
            </p>
        </div>
    </div>

    {{-- WARNINGS --}}
    @if($model instanceof \BookStack\Entities\Models\Bookshelf)
        <p class="text-warn">{{ trans('entities.shelves_permissions_cascade_warning') }}</p>
    @endif

    <div class="flex-container-row justify-flex-end">
        <div class="form-group mb-m">
            <label for="owner">{{ trans('entities.permissions_owner') }}</label>
            @include('form.user-select', ['user' => $model->ownedBy, 'name' => 'owned_by'])
        </div>
    </div>

    <hr>

    {{-- ROLE PERMISSIONS LIST --}}
    <div refs="entity-permissions@role-container" class="item-list mt-m mb-m">
        @foreach($data->permissionsWithRoles() as $permission)
            @include('form.entity-permissions-row', [
                'permission' => $permission,
                'role' => $permission->role,
                'entityType' => $model->getType(),
                'inheriting' => false,
            ])
        @endforeach
    </div>

    <div class="flex-container-row justify-flex-end mb-xl">
        <div class="flex-container-row items-center gap-m">
            <label for="role_select" class="m-none p-none"><span
                        class="bold">{{ trans('entities.permissions_role_override') }}</span></label>
            <select name="role_select" id="role_select" refs="entity-permissions@role-select">
                <option value="">{{ trans('common.select') }}</option>
                @foreach($data->rolesNotAssigned() as $role)
                    <option value="{{ $role->id }}">{{ $role->display_name }}</option>
                @endforeach
            </select>
        </div>
    </div>

    <div class="item-list mt-m mb-xl">
        @include('form.entity-permissions-row', [
                'role' => $data->everyoneElseRole(),
                'permission' => $data->everyoneElseEntityPermission(),
                'entityType' => $model->getType(),
                'inheriting' => !$model->permissions()->where('role_id', '=', 0)->exists(),
            ])
    </div>

    {{-- NATIVE-STYLE PIN CONTROL --}}
    @if($model instanceof \BookStack\Entities\Models\Page)
        <div class="mb-m mt-l">
            <div class="card p-m" style="border: 1px solid #ddd; box-shadow: 0 1px 3px rgba(0,0,0,0.05);">
                <div class="flex-container-row justify-space-between items-center wrap gap-m">
                    
                    {{-- Left Side: Status Text --}}
                    <div style="flex: 1;">
                        <div class="flex-container-row items-center gap-xs">
                            @if($isProtected)
                                <span class="text-neg bold">@icon('lock') PIN Protection Active</span>
                            @else
                                <span class="text-pos bold">@icon('lock-open') PIN Protection Inactive</span>
                            @endif
                        </div>
                        <p class="text-muted small mb-none mt-xs">
                            @if($isProtected)
                                This page is locked. 
                                @if($hasCustomPass)
                                    (Using <strong>Custom</strong> Password)
                                @else
                                    (Using <strong>Master</strong> PIN)
                                @endif
                            @else
                                Restrict access to this page with a password.
                            @endif
                        </p>
                    </div>
                    
                    {{-- Right Side: Controls --}}
                    <div>
                        @if($isProtected)
                            {{-- UNLOCK BUTTON --}}
                            <div style="text-align: right;">
                                <button type="submit" form="form-secure-unlock" class="button outline small" style="color: #c0392b; border-color: #c0392b;">
                                    @icon('close') Disable Lock
                                </button>
                            </div>
                        @else
                            {{-- LOCK FORM with Input --}}
                            <div class="flex-container-row gap-s items-center">
                                <input type="text" name="custom_password" form="form-secure-lock" placeholder="Custom Password (Optional)" class="input-base small" style="width: 200px; margin:0;" autocomplete="off">
                                <button type="submit" form="form-secure-lock" class="button small" style="background-color: #27ae60; border-color: #27ae60; color: #fff;">
                                    @icon('lock') Enable Lock
                                </button>
                            </div>
                        @endif
                    </div>

                </div>
            </div>
        </div>
    @endif
    {{-- END PIN CONTROL --}}

    <hr class="mb-m">

    <div class="flex-container-row justify-space-between gap-m wrap">
        <div class="flex min-width-m">
            @if($model instanceof \BookStack\Entities\Models\Bookshelf)
                <p class="small text-muted mb-none">
                    * {{ trans('entities.shelves_permissions_create') }}
                </p>
            @endif
        </div>
        <div class="text-right">
            <a href="{{ $model->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
            <button type="submit" class="button">{{ trans('entities.permissions_save') }}</button>
        </div>
    </div>
</form>

{{-- 
    HIDDEN FORMS
--}}
@if($model instanceof \BookStack\Entities\Models\Page)
    <form id="form-secure-lock" action="/secure-lock-page" method="POST" style="display: none;">
        {!! csrf_field() !!}
        <input type="hidden" name="page_id" value="{{ $model->id }}">
        {{-- Redirect back to this permissions page --}}
        <input type="hidden" name="redirect_to" value="{{ url()->current() }}">
    </form>

    <form id="form-secure-unlock" action="/secure-unlock-page" method="POST" style="display: none;">
        {!! csrf_field() !!}
        <input type="hidden" name="page_id" value="{{ $model->id }}">
        <input type="hidden" name="redirect_to" value="{{ url()->current() }}">
    </form>
@endif
```

</details>## Screenshots

# Anzeige von Buch, Kapitel und Seitentitel im Footer

<p class="callout success">getestet mit Version **25.12.8**</p>

## Anforderung

Zur besseren Nachverfolgung von Exports an welcher Stelle die exportierte Seite im BookStack zu finden sind.

## betroffene Dateien

<p class="callout info">Dateien müssen sich in der entsprechenden Struktur unterhalb des Themes befinden.  
Ordner = *kursiv*  
Dateien = **fett**</p>

- *layouts*
    - *parts*
        - **export-body-start.blade.php**

## Inhalte der Dateien

<details id="bkmrk-export-menu.blade.ph"><summary>export-body-start.blade.php</summary>

Hier wird folgender Bereich benötigt wenn nicht schon vorhanden bzw. muss im den Teil ergänzt werden wenn schon vorhanden:

```html
[...]
<div style="float: left; opacity: 0.8; font-size: 8pt; text-align: center">
    <!-- Anzeige von Buch, Kapitel und Seitentitel im Footer -->
    @if(isset($page))
        {{-- Buch & optional Kapitel & Seite --}}
        {{ trans('entities.book') }}: {{ $page->book->name ?? '' }}
        @if(isset($page->chapter))
            – {{ trans('entities.chapter') }}: {{ $page->chapter->name }}
        @endif
        <br>
        {{ trans('entities.pages_title') }}: {{ $page->name }}
    @elseif(isset($chapter))
        {{-- Export eines Kapitels --}}
        {{ trans('entities.book') }}: {{ $chapter->book->name ?? '' }}<br>
        {{ trans('entities.chapter') }}: {{ $chapter->name }}
    @elseif(isset($book))
        {{-- Export eines ganzen Buchs --}}
        {{ trans('entities.book') }}: {{ $book->name }}
    @endif
</div>
[...]
```

</details>## Screenshots

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2026-03/scaled-1680-/image.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2026-03/image.png)

# Personalisierungen

# aktuelles Datum mit Tastaturbefehl hinzufügen

Um an der Stelle des Cursors mit einem Tastenklick das aktuelle Datum einzufügen wird folgender Code benötigt:

```javascript
<script type="module">
    window.addEventListener('editor-tinymce::setup', event => {
        const editor = event.detail.editor;
        editor.shortcuts.add('f8', 'Insert Date', () => {
            const date = (new Date()).toLocaleString('de-DE');
            editor.insertContent(date);
        });
    });
</script>
```

Hier wird mit der Taste **F8** das Datum eingefügt, es kann aber auch jede andere Funktionstaste sein.

# Changelog verpflichtend machen mittels Tag

Um den Changelog zu einem Pflichtfeld zu machen muss folgender Code in die Personalisierung von Bookstack eingebaut werden:

```javascript
<script type="module">
    const requiredMessage = 'Bitte geben Sie einen Changelog ein.';
    const form = document.querySelector('#main-content > form');
    const changelogInput = document.getElementById("summary-input");
    let changelogRequired = false;

    function isChangelogEmpty() {
        return changelogInput.value.trim() === "";
    }

    function checkChangelogOnSubmit(event) {
        if (!isChangelogEmpty() || !changelogRequired) {
            return;
        }

        event.preventDefault();
        window.$events.error(requiredMessage);
    }

    async function isChangelogRequired() {
        const bookSlugRegex = /\/books\/(.*?)\/(page|draft)\//;
        const slugMatchResults = bookSlugRegex.exec(window.location.href);
        if (slugMatchResults === null) {
            return false;
        }

        const bookSlug = slugMatchResults[1];
        const bookResp = await window.$http.get(`/books/${bookSlug}`);
        const parser = new DOMParser();
        const pageDom = parser.parseFromString(bookResp.data, 'text/html');

        return pageDom.querySelector('body.tag-pair-changelog-required') !== null;
    }

    if (form && changelogInput) {
        form.addEventListener("submit", checkChangelogOnSubmit);
        window.addEventListener('DOMContentLoaded', () => {
            isChangelogRequired().then(required => {
                changelogRequired = required;
            });
        });
    }

    window.addEventListener('editor-tinymce::setup', event => {
        const editor = event.detail.editor;
        editor.on('init', () => {
            editor.shortcuts.remove('meta+13');
            editor.shortcuts.add('meta+13', '', () => {
                if (!isChangelogEmpty() || !changelogRequired) {
                    window.$events.emit('editor-save-page', {});
                } else {
                    window.$events.error(requiredMessage);
                }
            });
        });
    });
</script>
```

<p class="callout warning">Dieses Script nur dann aktiv, wenn ein Buch den **Tag** `Changelog` mit dem **Tagvalue** `required` besitzt.</p>

# DOCX Dateien per Drag&Drop importieren

Das geht mit folgendem Script:

```javascript
<script src="https://cdn.jsdelivr.net/npm/mammoth@1.5.1/mammoth.browser.min.js" defer></script>

<script type="module">
    // Convert the given "file" instance to HTML and insert the results
    // into the given TinyMCE "editor" instance.
    function convertAndInsertDocx(editor, file) {
        // Use a FileReader to handle conversion via an ArrayBuffer
        const reader = new FileReader();
        reader.onload = async function(loadEvent) {
            // Get and convert the ArrayBuffer version of the file
            const arrayBuffer = loadEvent.target.result;
            const {value: html, messages} = await window.mammoth.convertToHtml({arrayBuffer});
            // If warnings exists from conversion, log them to the browser console then
            // show a warning alert via BookStack's event system.
            if (messages.length > 0) {
                console.error(messages);
                window.$events.emit('warning', `${messages.length} warnings logged to browser console during conversion`);
            }
            // Insert the resulting HTML content insert the editor
            editor.insertContent(html);
        }
        reader.readAsArrayBuffer(file);
    }

    // Listen to BookStack emmitted WYSWIYG editor setup event
    window.addEventListener('editor-tinymce::setup', event => {
        // Get a reference to the editor and listen to file "drop" events
        const editor = event.detail.editor;
        editor.on('drop', event => {
            // For each of the files in the drop event, pass them, alonside the editor instance
            // to our "convertAndInsertDocx" function above if they're docx files.
            const files = event?.dataTransfer?.files || [];
            for (const file of files) {
                if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' && window.mammoth) {
                    convertAndInsertDocx(editor, file);
                }
            }
        });
    });
</script>
```

Quelle: [DOCX Import - Bookstack Hacks](https://bookstack.jelinek-rz.de/attachments/1)

# externe Links mit einem Symbol versehen

Um externe Links mit einem kleinen Symbol zu versehen muss folgender CSS Code hinterlegt werden:

```css
<style>
a[href*="//"]:not([href*="bookstack.jelinek-rz.de"])::after { 
  content: " " url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAVklEQVR4Xn3PgQkAMQhDUXfqTu7kTtkpd5RA8AInfArtQ2iRXFWT2QedAfttj2FsPIOE1eCOlEuoWWjgzYaB/IkeGOrxXhqB+uA9Bfcm0lAZuh+YIeAD+cAqSz4kCMUAAAAASUVORK5CYII=);
  margin: 0 3px 0 5px;
}
</style>
```

Wenn nun ein Link einen anderen Inhalt hat wie in diesem Beispiel bookstack.jelinek-rz.de, dann wird automatisch ein Symbol rechts neben dem Link erscheinen (an allen Stellen im gesamten Bookstack).

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/scaled-1680-/0q2image.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/0q2image.png)

# Fußnoten über den WSYIWYG Editor

Fußnoten können mit folgendem Code aktiviert werden:

```javascript
<script>
    // Take a footnote anchor and convert it to the HTML that would be expected
    // at the bottom of the page in the list of references.
    function footnoteToHtml(elem) {
        const newWrap = document.createElement('div');
        const newAnchor = document.createElement('a');
        const sup = document.createElement('sup');
        const text = document.createTextNode(' ' + elem.title.trim());
        sup.textContent = elem.textContent.trim();
        newAnchor.id = elem.getAttribute('href').replace('#', '');
        newAnchor.href = '#';
        newAnchor.append(sup);
        newWrap.append(newAnchor, text);
        return newWrap.outerHTML;
    }

    // Reset the numbering of all footnotes within the editor
    function resetFootnoteNumbering(editor) {
        const footnotes = editor.dom.select('a[href^="#bkmrk-footnote-"]');
        for (let i = 0; i < footnotes.length; i++) {
            const footnote = footnotes[i];
            const textEl = footnote.querySelector('sup') || footnote;
            textEl.textContent = String(i + 1);
        }
    }

    // Update the footnotes list at the bottom of the content.
    function updateFootnotes(editor) {
        // Filter out existing footnote blocks on parse
        const footnoteBlocks = editor.dom.select('body > div.footnotes');
        for (const blocks of footnoteBlocks) {
            blocks.remove();
        }

        // Gather our existing footnote references and return if nothing to add
        const footnotes = editor.dom.select('a[href^="#bkmrk-footnote-"]');
        if (footnotes.length === 0) {
            return;
        }

        // Build and append our footnote block
        resetFootnoteNumbering(editor);
        const footnoteHtml = [...footnotes].map(f => footnoteToHtml(f));
        editor.dom.add(editor.getBody(), 'div', {class: 'footnotes'}, '<hr/>' + footnoteHtml.join('\n'));
    }

    // Get the current selected footnote (if any)
    function getSelectedFootnote(editor) {
        return editor.selection.getNode().closest('a[href^="#bkmrk-footnote-"]');
    }

    // Insert a new footnote element within the editor at cursor position.
    function insertFootnote(editor, text) {
        const sup = editor.dom.create('sup', {}, '1');
        const anchor = editor.dom.create('a', {href: `#bkmrk-footnote-${Date.now()}`, title: text});
        anchor.append(sup);
        editor.selection.collapse(false);
        editor.insertContent(anchor.outerHTML + ' ');
    }

    function showFootnoteInsertDialog(editor) {
        const footnote = getSelectedFootnote(editor);

        // Show a custom form dialog window to edit the footnote text/label
        const dialog = editor.windowManager.open({
            title: 'Edit Footnote',
            body: {
                type: 'panel',
                items: [{type: 'input', name: 'text', label: 'Footnote Label/Text'}],
            },
            buttons: [
                {type: 'cancel', text: 'Cancel'},
                {type: 'submit', text: 'Save', primary: true},
            ],
            onSubmit(api) {
                // On submit update or insert a footnote element
                const {text} = api.getData();
                if (footnote) {
                    footnote.setAttribute('title', text);
                } else {
                    insertFootnote(editor, text);
                    editor.execCommand('RemoveFormat');
                }
                updateFootnotes(editor);
                api.close();
            },
        });

        if (footnote) {
            dialog.setData({text: footnote.getAttribute('title')});
        }
    }

    // Listen to pre-init event to customize TinyMCE config
    window.addEventListener('editor-tinymce::pre-init', event => {
        const tinyConfig = event.detail.config;
        // Add our custom footnote button to the toolbar
        tinyConfig.toolbar = tinyConfig.toolbar.replace('italic ', 'italic footnote ');
    });

    // Listen to setup event so we customize the editor.
    window.addEventListener('editor-tinymce::setup', event => {
        // Get a reference to the TinyMCE Editor instance
        const editor = event.detail.editor;

        // Add our custom footnote button
        editor.ui.registry.addToggleButton('footnote', {
            icon: 'footnote',
            tooltip: 'Add Footnote',
            active: false,
            onAction() {
                showFootnoteInsertDialog(editor);
            },
            onSetup(api) {
                editor.on('NodeChange', event => {
                    api.setActive(Boolean(getSelectedFootnote(editor)));
                });
            },
        });

        // Update footnotes before editor content is fetched
        editor.on('BeforeGetContent', () => {
            updateFootnotes(editor);
        });
    });
</script>
```

Das Ergebnis sieht dann wie folgt aus:

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/scaled-1680-/Vzoimage.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/Vzoimage.png)

Dabei wird an der Stelle des Cursors eine Fußnote[<sup>1</sup>](#bkmrk-footnote-1712777038554 "Erste Fußnote") erstellt, welche dann am Ende der Seite eingefügt wird.

Hier gibt es auch direkt das Beispiel[<sup>2</sup>](#bkmrk-footnote-1712777057653 "Beispiel") dazu.

Quelle[<sup>3</sup>](#bkmrk-footnote-1712777091897 "https://www.bookstackapp.com/hacks/wysiwyg-footnotes/")

<div class="footnotes" id="bkmrk-1-erste-fu%C3%9Fnote-2-be">---

<div>[<sup>1</sup>](#bkmrk-1-erste-fu%C3%9Fnote-2-be) Erste Fußnote</div><div>[<sup>2</sup>](#bkmrk-1-erste-fu%C3%9Fnote-2-be) Beispiel</div><div>[<sup>3</sup>](#bkmrk-1-erste-fu%C3%9Fnote-2-be) https://www.bookstackapp.com/hacks/wysiwyg-footnotes/</div></div>

# Seite verbreitern

Dazu muss lediglich folgender CSS Code eingetragen werden. Die Breite kann man mit der Pixelanzahl einfach ändern.

<p class="callout warning">zu breit ist aber nicht gut, da dann das Layout nicht mehr funktioniert.</p>

```css
<style>
@media screen and (min-width: 1400px) {
  .tri-layout-middle-contents {
      max-width: 1700px;
  }
}
.page-content { max-width: 1200px; } 
</style>
```

# Tabellenbearbeitungstools im WYSIWYG Editor aktivieren

Das klappt ganz einfach mit folgendem Code:

```javascript
<script>
  window.addEventListener('editor-tinymce::pre-init', event => {
    // Get the config object
    const mceConfig = event.detail.config;
        
    // Create a secondary toolbar for table editing
    mceConfig.toolbar = [
      mceConfig.toolbar,
      'table tablecellprops tableinsertdialog maketableresponsive | tablemergecells tablesplitcells | tableinsertrowbefore tableinsertrowafter tabledeleterow | tablecutrow tablecopyrow tablepasterowbefore tablepasterowafter | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tablerowheader tablecolheader | tablecellbackgroundcolor | tabledelete'
    ];
    
    // Modify the table context toolbar
    mceConfig.table_toolbar = 'tableprops tabledelete | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablerowheader tablecolheader';

    // Make tables responsive by default
    mceConfig.table_sizing_mode = 'responsive';
  });

  window.addEventListener('editor-tinymce::setup', event => {
    // Get the editor
    const editor = event.detail.editor;
    // Add a button to make tables responsive
    editor.ui.registry.addButton('maketableresponsive', {
      tooltip: 'Make Table Responsive',
      icon: 'maketableresponsiveicon',
      onAction() {
        let tableElement = editor.selection.getNode();
        while (tableElement.nodeName !== 'TABLE') {
          tableElement = tableElement.parentNode;
           if (tableElement.nodeName === 'BODY') { return; }
        }
        tinymce.DOM.setStyle(tableElement, 'width', null);
        const colGroup = tableElement.getElementsByTagName('colgroup')[0];
        const cols = Array.from(colGroup.getElementsByTagName('col'));
        cols.forEach((child) => {
          tinymce.DOM.setStyle(child, 'width', null);
        });
      }
    });

    // Register a custom icon for maketableresponsive
    editor.ui.registry.addIcon('maketableresponsiveicon', `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="rgb(12.941176%,12.941176%,12.941176%)" xmlns:v="https://vecta.io/nano"><path d="M15.578 11.25H8.422l.891-1c.273-.312.246-.785-.062-1.062s-.785-.246-1.062.063l-2 2.25a.76.76 0 0 0 0 1l2 2.25c.277.309.75.336 1.063.063s.336-.75.063-1.062l-.891-1h7.156l-.887 1c-.277.313-.25.785.059 1.063s.785.246 1.063-.062l2-2.25a.79.79 0 0 0 .188-.469v-.059a.75.75 0 0 0-.191-.473l-1.996-2.25c-.277-.309-.75-.336-1.062-.062a.75.75 0 0 0-.059 1.063zM17.75 3A3.25 3.25 0 0 1 21 6.25v11.5c0 1.793-1.457 3.25-3.25 3.25H6.25C4.457 21 3 19.543 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3zm1.75 3.25c0-.965-.785-1.75-1.75-1.75h-.25v5.148l-.941-1.062A1.75 1.75 0 0 0 16 8.168V4.5H8v3.668a1.75 1.75 0 0 0-.559.418L6.5 9.648V4.5h-.25c-.965 0-1.75.785-1.75 1.75v11.5c0 .965.785 1.75 1.75 1.75h.25v-5.148l.941 1.063a1.75 1.75 0 0 0 .559.418V19.5h8v-3.668a1.75 1.75 0 0 0 .559-.418l.941-1.062V19.5h.25c.965 0 1.75-.785 1.75-1.75zm0 0"/></svg>`);
  });
</script>
```

Das Ergebnis sieht dann wie folgt aus:

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/scaled-1680-/jS5image.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-04/jS5image.png)

# Tabellenüberschriften immer sichtbar beim scrollen

Um Tabellen-Header immer sichtbar zu haben wenn bei einer langen Tabelle gescrollt werden muss hilft der folgenden Code:

```css
<style>
table th { 
  position: sticky;
  top: 0;
  background: #eeeeee;
  font-weight: bold;
}
</style>
```

# Transparenz für linke und rechte Spalte deaktivieren

Um den Transparenz-Effekt abzuschalten wenn ein Feld nicht im Fokus ist wird folgender Code benötigt:

```css
<style>
.tri-layout-left-contents>*, .tri-layout-right-contents>* {
    opacity: 1 !important;
}
</style>
```

# Überschrift h1 in PDF Exports verkleinern

Mir war die Überschrift zu groß, also habe ich diese mit folgendem Eintrag verkleinert:

```css
<style>
.export-format-pdf h1 {
  font-size: 22px;
}
</style>
```

# sortierbare Tabelle im Editor

Im WYSIWYG Editor kann man mit diesem Script die Spalten in Tabellen mit einem Doppelklick auf den Header nach diesem sortieren. Die anderen Spalten werden entsprechend mitsortiert.

```javascript
<script>

    // Hook into the WYSIWYG editor setup event and add our logic once loaded
    window.addEventListener('editor-tinymce::setup', event => {
        const editor = event.detail.editor;
        setupTableSort(editor);
    });

    // Setup the required event handler, listening for double-click on table cells.
    function setupTableSort(editor) {
        editor.on('dblclick', event => {
             const target = event.target;
             const parentHeader = target.closest('table tr:first-child td, table tr:first-child th');
             if (parentHeader) {
                 // Sort out table within a transaction so this can be undone in the editor if required.
                 editor.undoManager.transact(() => {
                     sortTable(parentHeader, editor);
                 });
             }
        });
    }

    // Sort the parent table of the given header cell that was clicked.
    function sortTable(headerCell) {
        const table = headerCell.closest('table');
        // Exit if the table has a header row but the clicked cell was not part of that header
        if (table.querySelector('thead') && headerCell.closest('thead') === null) {
            return;
        }

        const headerRow = headerCell.parentNode;
        const headerIndex = [...headerRow.children].indexOf(headerCell);
        const tbody = table.querySelector('tbody');
        const rowsToSort = [...table.querySelectorAll('tbody tr')].filter(tr => tr !== headerRow);
        const invert = headerCell.dataset.sorted === 'true';

        // Sort the rows, detecting numeric values if possible.
        rowsToSort.sort((a, b) => {
            const aContent = a.children[headerIndex].textContent.toLowerCase();
            const bContent = b.children[headerIndex].textContent.toLowerCase();
            const numericA = Number(aContent);
            const numericB = Number(bContent);

            if (!Number.isNaN(numericA) && !Number.isNaN(numericB)) {
                return invert ? numericA - numericB : numericB - numericA;
            }

            return aContent === bContent ? 0 : (aContent < bContent ? (invert ? 1 : -1) : (invert ? -1 : 1));
        });

        // Re-append the rows in order
        for (const row of rowsToSort) {
            tbody.appendChild(row);
        }

        // Update the sorted status for later possible inversion of sort.
        headerCell.dataset.sorted = invert ? 'false' : 'true';
    }
</script>
```

# numerierte Überschriften

wer gerne mit numerierten Überschritften arbeitet, für den Könnte diese beiden Code Blöcke hilfreich sein:

## mit Prüfung auf einen Tag

### Prüfung ob Tag auf einem Buch gesetzt ist

Mit diesem Javascript wird geprüft, ob ein bestimmtes Tag-Paar auf dem Buch gesetzt ist. Das Tag-Paar lautet **numbered-active**:

```javascript
<script type="module">
	async function isNumberTagPresent(documentToCheck) {
		const bookSlugRegex = /\/books\/(.*?)\/(page|draft)\//;
		const slugMatchResults = bookSlugRegex.exec(window.location.href);
		if (slugMatchResults === null) {
			return false;
		}

		const bookSlug = slugMatchResults[1];
		const bookResp = await window.$http.get(`/books/${bookSlug}`);
		const parser = new DOMParser();
		const pageDom = parser.parseFromString(bookResp.data, 'text/html');

		// Check for the tag in the provided document
		return pageDom.querySelector('body.tag-pair-numbered-active') !== null;
	}

	async function toggleCssClass() {
		const isRequired = await isNumberTagPresent(document);
		if (isRequired) {
			document.body.classList.add('numbered-active');
		} else {
			document.body.classList.remove('numbered-active');
		}

		// Check if there are iframes in the document
		const iframes = document.querySelectorAll('iframe');
		for (const iframe of iframes) {
			if (iframe.contentDocument) {
				const isIframeRequired = await isNumberTagPresent(iframe.contentDocument);
				if (isIframeRequired) {
					iframe.contentDocument.body.classList.add('numbered-active');
				} else {
					iframe.contentDocument.body.classList.remove('numbered-active');
				}
			}
		}
	}

	document.addEventListener("DOMContentLoaded", function() {
		toggleCssClass();
	});
</script>
```

Ist der Tag gesetzt wird in die HTML "body"-Elemente eine Klasse geschrieben, die dann im folgenden CSS abgefragt wird.

### CSS nur setzen wenn CSS-Klasse vorhanden

Hier wird nun die numerierte Liste erstellt, aber nur wenn im HTML "body"-Element die entsprechende Klasse vorher mittels Javascript reingeschrieben wurde.

```css
<style>
    body.numbered-active, body.mce-content-body {
        counter-reset: h1counter;
    }
    body.numbered-active .page-content, body.numbered-active.mce-content-body {
        counter-reset: h2counter;
        counter-increment: h1counter;
    }
    body.numbered-active .page-content h1:not(.break-text):before, body.numbered-active.mce-content-body h1:not(.break-text):before {
        content: counter(h1counter) ".\0000a0\0000a0";
        counter-increment: h1counter;
    }
    body.numbered-active .page-content h1, body.numbered-active.mce-content-body h1 {
        counter-reset: h2counter;
    }
    
    body.numbered-active .page-content h2:before, body.numbered-active.mce-content-body h2:before {
        content: counter(h2counter) ".\0000a0\0000a0";
        counter-increment: h2counter;
    }
    body.numbered-active .page-content h2, body.numbered-active.mce-content-body h2 {
        counter-reset: h3counter;
    }
    
    body.numbered-active .page-content h3:before, body.numbered-active.mce-content-body h3:before {
        content: counter(h2counter) "." counter(h3counter) ".\0000a0\0000a0";
        counter-increment: h3counter;
    }
    body.numbered-active .page-content h3, body.numbered-active.mce-content-body h3 {
        counter-reset: h4counter;
    }

    body.numbered-active .page-content h4:before, body.numbered-active.mce-content-body h4:before {
        content: counter(h2counter) "." counter(h3counter) "." counter(h4counter) ".\0000a0\0000a0";
        counter-increment: h4counter;
    }
    body.numbered-active .page-content h4, body.numbered-active.mce-content-body h4 {
        counter-reset: h5counter;
    }

    body.numbered-active .page-content h5:before, body.numbered-active.mce-content-body h5:before {
        content: counter(h2counter) "." counter(h3counter) "." counter(h4counter) "." counter(h5counter) ".\0000a0\0000a0";
        counter-increment: h5counter;
    }
</style>
```

## Ohne Prüfung auf einen Tag (für alle Inhalte)

```css
<style>
    body {
        counter-reset: h1counter;
    }
    body.page-content.mce-content-body {
        counter-reset: h2counter;
        counter-increment: h1counter;
    }
    .page-content h1:not(.break-text):before {
        content: counter(h1counter) ".\0000a0\0000a0";
        counter-increment: h1counter;
    }
    .page-content h1 {
        counter-reset: h2counter;
    }
    
    .page-content h2:before {
        content: counter(h2counter) ".\0000a0\0000a0";
        counter-increment: h2counter;
    }
    .page-content h2 {
        counter-reset: h3counter;
    }
    
    .page-content h3:before {
        content: counter(h2counter) "." counter(h3counter) ".\0000a0\0000a0";
        counter-increment: h3counter;
    }
    .page-content h3{
            counter-reset: h4counter;
    }

    .page-content h4:before {
        content: counter(h2counter) "." counter(h3counter) "." counter(h4counter) ".\0000a0\0000a0";
        counter-increment: h4counter;
    }
    .page-content h4 {
        counter-reset: h5counter;
    }

    .page-content h5:before {
        content: counter(h2counter) "." counter(h3counter) "." counter(h4counter) "." counter(h5counter) ".\0000a0\0000a0";
        counter-increment: h5counter;
    }
</style>
```

# Hintergrund anpassen

Es gibt ebenfalls eine Möglichkeit den Hintergrund von Bookstack anzupassen, dazu ist folgender Code nötig:

```css
<style>
html {
  height: auto;
  background-color: #fff;
}

html.dark-mode {
  background-color: #000;
}

body:not(#tinymce) { 
  background: 
    linear-gradient(to bottom, rgba(255, 255, 255, 0) 80%, rgba(255, 255, 255, 1) 100%),
    url(https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-06/default-symmetrical-geometric-shapes-red-yellow-and-light-gree-2.jpg) top / 100% 100vh no-repeat fixed;
  background-color: #fff;
}

.dark-mode body:not(#tinymce) {
  background-color: #000;
  background: 
    linear-gradient(to bottom, rgba(0, 0, 0, 0) 80%, rgba(0, 0, 0, 1) 100%),
    url(https://bookstack.jelinek-rz.de/uploads/images/gallery/2024-06/default-symmetrical-geometric-shapes-red-yellow-and-light-gree-2.jpg) top / 100% 100vh no-repeat fixed;
}

.page-content .cm-editor:not(.dark-mode .cm-editor) {
  background-color: #F4F4F4;
}

.page-content hr {
  height: 2px;
}

#content #main-content.height-fill {
  height: 90vh !important;
}

.tri-layout-right, .tri-layout-left, .page-edit-toolbar {
  background-color: rgba(255, 255, 255, .74);
  border-radius: 8px;
  border-style: solid;
  border-width: 0px;
  border-color: rgb(229, 231, 235);
  box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.1) 0px 2px 4px -2px;
  box-sizing: border-box;
  backdrop-filter: blur(20px);
  margin-top: 35px;
}

.tri-layout-right, .tri-layout-left {
  height: 90%;
}

.dark-mode .tri-layout-right, .dark-mode .tri-layout-left, .dark-mode .page-edit-toolbar {
  background-color: rgba(0, 0, 0, .74);
  border-radius: 8px;
  border-style: solid;
  border-width: 0px;
  border-color: rgb(229, 231, 235);
  box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.1) 0px 2px 4px -2px;
  box-sizing: border-box;
  backdrop-filter: blur(20px);
  margin-top: 35px;
}

.dark-mode #main-content > div:nth-child(1) > nav {
  background-color: rgba(0, 0, 0, .74);
  border-radius: 8px;
  opacity: 1;
}

@media (min-width: 1750px) {
  .tri-layout-sides-content {
    grid-template-columns: 1.5fr 4fr 1.5fr !important;
  }
}

@media (max-width: 1400px) {
  div#sidebar.tri-layout-left.print-hidden {
    display: none;
  }
}

@media (max-width: 1000px) {
  .tri-layout-right {
margin-top: auto;
    }
      .tri-layout-container.show-info .tri-layout-right>*, .tri-layout-container.show-info .tri-layout-left>* {
        padding: 20px;
        margin-bottom: 40px;
        }
}

  
@media screen and (min-width: 1000px) {
  .tri-layout-left-contents>*, .tri-layout-right-contents>* {
    padding: 10px;
  }
}

#sibling-navigation {
  background-color: rgba(255, 255, 255, .6);
  border-radius: 8px;
  opacity: 1;
  backdrop-filter: blur(20px);
}

.dark-mode #sibling-navigation {
  background-color: rgba(0, 0, 0, .74);
  border-radius: 8px;
  color: #fff;
  opacity: 1;
  backdrop-filter: blur(20px);
}

.dark-mode #sibling-navigation a .text-muted {
  color: #fff !important;
}

.dark-mode .comments-container {
  border-radius: 8px;
  background-color: rgba(0, 0, 0, .74);
  padding: 30px 20px;
}

.dark-mode .comments-container button {
  background-color: rgba(0, 0, 0, .74);
  color: #fff !important;
}

.tri-layout-right-contents>*, .tri-layout-left-contents>* {
  opacity: 1 !important;
}

.button {
  background-color: rgba(255, 255, 255, .74);
}

button[type="submit"]:not(.icon-list-item, [aria-label="Search"]) {
  background-color: #000 !important;
}

#login-form button {
  background-color: #000;
}
</style>
```

# Inhaltsverzeichnis im Editor im Kommentarfeld anzeigen

```javascript
<script>
const generateTOC = () => {
    let out = document.createElement('div');
    out.id = "hack-toc-editor";
    // Bookstack doesn't use h1 inside the editor, only h2-h6
    let list = document.createElement('ul');
    let previousLevel = 2;
    let currentList = list;
    for (const x of tinymce.activeEditor.getBody().querySelectorAll('h2,h3,h4,h5,h6')) {
        // Create our link to content
        let link = document.createElement('a');
        link.setAttribute('data-id', x.id);
        link.textContent = x.textContent;
        link.addEventListener('click', onLinkClicked);
        let li = document.createElement('li');
        li.append(link);


        // Now let's find out where we insert it
        let level = Number(x.tagName.toLowerCase().replace('h', ''));
        // We need to go deeper, let's create new level(s) 
        // Will be triggered only if level > previousLevel
        for(let i = previousLevel; i < level; i++) {
            let newList = document.createElement('ul');
            currentList.append(newList);
            currentList = newList;
        }
        // We need to go back one (or more) level(s)
        // Will be triggered only if level < previousLevel
        for(let i = previousLevel; i > level; i--) {
            currentList = currentList.parentElement;
        }
        // Now, let's append our link at the right place
        currentList.append(li);
        previousLevel = level;
    }
    out.append(list);
    return out;
}
// Add TOC content inside comment sidebar
const populateTOC = () => {
    document.querySelector('.comment-container-compact').append(generateTOC());
}

const scrollToHeader = (id) => {
    // querySelector doesn't work for some ID which contains accentued characters, so use getElementByID which works fine
    tinymce.activeEditor.getDoc().getElementById(id).scrollIntoView();
}
const onLinkClicked = (event) => {
    let id = event.target.getAttribute('data-id');
    scrollToHeader(id);
}
// It's ugly bit it works : just wait until we find tinyMCE to populate ToC !
const checkForTiny = () => {
    if (window?.tinymce?.activeEditor?.getDoc()) {
        populateTOC();
    }
    else {
        setTimeout(checkForTiny, 1000);
    }
}

setTimeout(checkForTiny, 1000);
</script>

```

# Dynamic Glossary

```javascript
<script type="module">
    // The URL path to the default glossary page in your instance.
    // You should only need to change the "my-book" and "main-glossary"
    // parts, keep the other parts and the general format the same.
    const defaultGlossaryPage = '/books/glossar/page/glossar';

    // Get a normalised URL path, check if it's a glossary page, and page the page content
    const urlPath = window.location.pathname.replace(/^.*?\/books\//, '/books/');
    const isGlossaryPage = urlPath.endsWith('/page/glossar') || urlPath.endsWith(defaultGlossaryPage);
    const pageContentEl = document.querySelector('.page-content');

    if (isGlossaryPage && pageContentEl) {
        // Force re-index when viewing glossary pages
        addTermMapToStorage(urlPath, domToTermMap(pageContentEl));
    } else if (pageContentEl) {
        // Get glossaries and highlight when viewing non-glossary pages
        document.addEventListener('DOMContentLoaded', () => highlightTermsOnPage());
    }

    /**
     * Highlight glossary terms on the current page that's being viewed.
     * In this, we get our combined glossary, then walk each text node to then check
     * each word of the text against the glossary. Where exists, we split the text
     * and insert a new glossary term span element in its place.
     */
    async function highlightTermsOnPage() {
        const glossary = await getMergedGlossariesForPage(urlPath);
        const trimRegex = /^[.?:"',;]|[.?:"',;]$/g;
        const treeWalker = document.createTreeWalker(pageContentEl, NodeFilter.SHOW_TEXT);
        while (treeWalker.nextNode()) {
            const node = treeWalker.currentNode;
            const words = node.textContent.split(' ');
            const parent = node.parentNode;
            let parsedWords = [];
            let firstChange = true;
            for (const word of words) {
                const normalisedWord = word.toLowerCase().replace(trimRegex, '');
                const glossaryVal = glossary[normalisedWord];
                if (glossaryVal) {
                    const preText = parsedWords.join(' ');
                    const preTextNode = new Text((firstChange ? '' : ' ') + preText + ' ');
                    parent.insertBefore(preTextNode, node)
                    const termEl = createGlossaryNode(word, glossaryVal);
                    parent.insertBefore(termEl, node);
                    const toReplace = parsedWords.length ? preText + ' ' + word : word;
                    node.textContent = node.textContent.replace(toReplace, '');
                    parsedWords = [];
                    firstChange = false;
                    continue;
                }

                parsedWords.push(word);
            }
        }
    }

    /**
     * Create the element for a glossary term.
     * @param {string} term
     * @param {string} description
     * @returns {Element}
     */
    function createGlossaryNode(term, description) {
        const termEl = document.createElement('span');
        termEl.setAttribute('data-term', description.trim());
        termEl.setAttribute('class', 'glossary-term');
        termEl.textContent = term;
        return termEl;
    }

    /**
     * Get a merged glossary object for a given page.
     * Combines the terms for a same-book & global glossary.
     * @param {string} pagePath
     * @returns {Promise<Object<string, string>>}
     */
    async function getMergedGlossariesForPage(pagePath) {
        const [defaultGlossary, bookGlossary] = await Promise.all([
            getGlossaryFromPath(defaultGlossaryPage),
            getBookGlossary(pagePath),
        ]);

        return Object.assign({}, defaultGlossary, bookGlossary);
    }

    /**
     * Get the glossary for the book of page found at the given path.
     * @param {string} pagePath
     * @returns {Promise<Object<string, string>>}
     */
    async function getBookGlossary(pagePath) {
        const bookPath = pagePath.split('/page/')[0];
        const glossaryPath = bookPath + '/page/glossary';
        return await getGlossaryFromPath(glossaryPath);
    }

    /**
     * Get/build a glossary from the given page path.
     * Will fetch it from the localstorage cache first if existing.
     * Otherwise, will attempt the load it by fetching the page.
     * @param path
     * @returns {Promise<{}|any>}
     */
    async function getGlossaryFromPath(path) {
        const key = 'bsglossary:' + path;
        const storageVal = window.localStorage.getItem(key);
        if (storageVal) {
            return JSON.parse(storageVal);
        }

        let resp = null;
        try {
            resp = await window.$http.get(path);
        } catch (err) {
        }

        let map = {};
        if (resp && resp.status === 200 && typeof resp.data === 'string') {
            const doc = (new DOMParser).parseFromString(resp.data, 'text/html');
            const contentEl = doc.querySelector('.page-content');
            if (contentEl) {
                map = domToTermMap(contentEl);
            }
        }

        addTermMapToStorage(path, map);
        return map;
    }

    /**
     * Store a term map in storage for the given path.
     * @param {string} urlPath
     * @param {Object<string, string>} map
     */
    function addTermMapToStorage(urlPath, map) {
        window.localStorage.setItem('bsglossary:' + urlPath, JSON.stringify(map));
    }

    /**
     * Convert the text of the given DOM into a map of definitions by term.
     * @param {string} text
     * @return {Object<string, string>}
     */
    function domToTermMap(dom) {
        const textEls = Array.from(dom.querySelectorAll('p,h1,h2,h3,h4,h5,h6,blockquote'));
        const text = textEls.map(el => el.textContent).join('\n');
        const map = {};
        const lines = text.split('\n');
        for (const line of lines) {
            const split = line.trim().split(':');
            if (split.length > 1) {
                map[split[0].trim().toLowerCase()] = split.slice(1).join(':');
            }
        }
        return map;
    }
</script>
```

```css
<style>
    /**
     * These are the styles for the glossary terms and definition popups.
     * To keep things simple, the popups are not elements themselves, but
     * pseudo ":after" elements on the terms, which gain their text via
     * the "data-term" attribute on the term element.
     */
    .page-content .glossary-term {
        text-decoration: underline;
        text-decoration-style: dashed;
        text-decoration-color: var(--color-link);
        text-decoration-thickness: 1px;
        position: relative;
        cursor: help;
    }
    .page-content .glossary-term:hover:after {
        display: block;
    }

    .page-content .glossary-term:after {
        position: absolute;
        content: attr(data-term);
        background-color: #FFF;
        width: 200px;
        box-shadow: 0 1px 6px 0 rgba(0, 0, 0, 0.15);
        padding: 0.5rem 1rem;
        font-size: 12px;
        border-radius: 3px;
        z-index: 9999;
        top: 2em;
        inset-inline-start: 0;
        display: none;
    }
    .dark-mode .page-content .glossary-term:after {
        background-color: #000;
    }
    .page-content table td, .page-content table th {
        position: relative;
        overflow: visible;
    }
</style>
```

# Passwort beim Login mit "Auge" sichtbar machen

Mit der folgenden Anpassung lässt sich ein Schalter implementieren mit dem man das Passwort im Passwortfeld sichtbar machen kann.

```javascript
<style>
.password-toggle {
    position: relative;
}
.password-toggle input[type="password"] {
    padding-right: 40px;
}
.password-eye {
    position: absolute;
    right: 10px;
    top: 50%;
    transform: translateY(-50%);
    cursor: pointer;
    color: #666;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
    const passwordFields = document.querySelectorAll('input[type="password"]');
    passwordFields.forEach(function(field) {
        const wrapper = document.createElement('div');
        wrapper.className = 'password-toggle';
        field.parentNode.insertBefore(wrapper, field);
        wrapper.appendChild(field);
        
        const eye = document.createElement('i');
        eye.className = 'fas fa-eye password-eye';
        eye.onclick = function() {
            if (field.type === 'password') {
                field.type = 'text';
                eye.classList.remove('fa-eye');
                eye.classList.add('fa-eye-slash');
            } else {
                field.type = 'password';
                eye.classList.remove('fa-eye-slash');
                eye.classList.add('fa-eye');
            }
        };
        wrapper.appendChild(eye);
    });
});
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
```

Das Loginfeld sieht dann wie folgt aus:

[![image.png](https://bookstack.jelinek-rz.de/uploads/images/gallery/2026-01/scaled-1680-/image.png)](https://bookstack.jelinek-rz.de/uploads/images/gallery/2026-01/image.png)