Add links/backlinks feature

This commit is contained in:
brufdev
2025-02-09 16:59:05 +00:00
parent c20b41f26d
commit 45a3f48a72
12 changed files with 251 additions and 35 deletions

View File

@@ -53,6 +53,8 @@ final readonly class DeleteVaultNode
}
}
$node->links()->detach();
$node->backlinks()->detach();
$node->delete();
return $deletedNodes;

View File

@@ -28,6 +28,10 @@ final readonly class ProcessImportedFile
}
$node = new CreateVaultNode()->handle($vault, $attributes);
if ($node->extension === 'md') {
new ProcessVaultNodeLinks()->handle($node);
}
$relativePath = new GetPathFromVaultNode()->handle($node);
$pathInfo = pathinfo($relativePath);
$savePath = $pathInfo['dirname'] ?? '';

View File

@@ -70,5 +70,7 @@ final readonly class ProcessImportedVault
}
$zip->close();
new ProcessVaultLinks()->handle($vault);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Models\Vault;
final readonly class ProcessVaultLinks
{
public function handle(Vault $vault): void
{
$nodes = $vault->nodes()->where('is_file', true)->where('extension', 'md')->get();
foreach ($nodes as $node) {
new ProcessVaultNodeLinks()->handle($node);
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Models\VaultNode;
final readonly class ProcessVaultNodeLinks
{
public function handle(VaultNode $node): void
{
$node->links()->detach();
if ((string) $node->content === '') {
return;
}
$pattern = <<<REGEX
/
(?<!\!) # Negative lookbehind: Ensure the link is not preceded by "!"
\[.+?\] # Match a markdown-style link text [any text]
\( # Match opening parenthesis "("
(.*?\.md) # Capture group 1: Match a Markdown file ending with '.md'
(?:\s".+")? # Optional: Match a title in quotes after the filename
\) # Match closing parenthesis ")"
/xi
REGEX;
/** @var string $content */
$content = $node->content;
preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE);
if ($matches[1] === []) {
return;
}
$linkPaths = array_column($matches[1], 0);
$linkPositions = array_column($matches[0], 1);
$links = array_map(
fn (string $path, int $position): array => ['path' => $path, 'position' => $position],
$linkPaths,
$linkPositions,
);
foreach ($links as $link) {
/**
* @var string $fullPath
*
* @phpstan-ignore-next-line larastan.noUnnecessaryCollectionCall
*/
$fullPath = $node->ancestorsAndSelf()->get()->last()->full_path;
$path = new ResolveTwoPaths()->handle($fullPath, $link['path']);
$destinationNode = new GetVaultNodeFromPath()->handle($node->vault_id, $path, $node->parent_id);
if (is_null($destinationNode)) {
continue;
}
$node->links()->attach($destinationNode->id, ['position' => $link['position']]);
}
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Livewire\Forms;
use App\Actions\CreateVaultNode;
use App\Actions\ProcessVaultNodeLinks;
use App\Actions\UpdateVaultNode;
use App\Models\Vault;
use App\Models\VaultNode;
@@ -75,6 +76,11 @@ final class VaultNodeForm extends Form
'extension' => $this->is_file ? 'md' : null,
'content' => $this->content,
]);
if ($node->is_file && $node->extension === 'md') {
new ProcessVaultNodeLinks()->handle($node);
}
$this->reset(['name']);
return $node;
@@ -96,5 +102,9 @@ final class VaultNodeForm extends Form
'extension' => $this->node->extension,
'content' => $this->content,
]);
if ($this->node->is_file && $this->node->extension === 'md') {
new ProcessVaultNodeLinks()->handle($this->node);
}
}
}

View File

@@ -38,6 +38,12 @@ final class Show extends Component
public ?string $selectedFileUrl = null;
/** @var Collection<int, VaultNode> */
public ?Collection $selectedFileLinks = null;
/** @var Collection<int, VaultNode> */
public ?Collection $selectedFileBacklinks = null;
public bool $isEditMode = true;
public function mount(Vault $vault): void
@@ -77,9 +83,7 @@ final class Show extends Component
return;
}
$this->selectedFile = $node->id;
$this->selectedFileUrl = new GetUrlFromVaultNode()->handle($node);
$this->nodeForm->setNode($node);
$this->setNode($node);
if ($node->extension === 'md') {
$this->dispatch('file-render-markup');
@@ -114,13 +118,12 @@ final class Show extends Component
return;
}
$this->selectedFileUrl = new GetUrlFromVaultNode()->handle($node);
$this->nodeForm->setNode($node);
$this->setNode($node);
}
public function closeFile(): void
{
$this->reset(['selectedFile', 'selectedFileUrl']);
$this->reset(['selectedFile', 'selectedFileUrl', 'selectedFileLinks', 'selectedFileBacklinks']);
$this->nodeForm->reset('node');
}
@@ -135,16 +138,19 @@ final class Show extends Component
public function updated(string $name): void
{
if (!str_starts_with($name, 'nodeForm')) {
$node = $this->nodeForm->node;
if (!str_starts_with($name, 'nodeForm') || is_null($node)) {
return;
}
$this->nodeForm->update();
$this->setNode($node);
if ($this->nodeForm->node && $this->nodeForm->node->wasChanged(['parent_id', 'name'])) {
if ($node->wasChanged(['parent_id', 'name'])) {
$this->dispatch('node-updated');
if ($this->nodeForm->node->parent_id === $this->vault->templates_node_id) {
if ($node->parent_id === $this->vault->templates_node_id) {
$this->getTemplates();
}
}
@@ -252,4 +258,13 @@ final class Show extends Component
{
return view('livewire.vault.show');
}
private function setNode(VaultNode $node): void
{
$this->selectedFile = $node->id;
$this->selectedFileUrl = new GetUrlFromVaultNode()->handle($node);
$this->selectedFileLinks = $node->links()->get();
$this->selectedFileBacklinks = $node->backlinks()->get();
$this->nodeForm->setNode($node);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships;
@@ -41,7 +42,7 @@ final class VaultNode extends Model
}
/**
* Get the nodes for the vault.
* Get the childs for the node.
*
* @return HasMany<VaultNode, $this>
*/
@@ -50,6 +51,26 @@ final class VaultNode extends Model
return $this->hasMany(self::class, 'parent_id');
}
/**
* The nodes that are linked to the node.
*
* @return BelongsToMany<VaultNode, $this>
*/
public function links(): BelongsToMany
{
return $this->belongsToMany(self::class, null, 'source_id', 'destination_id');
}
/**
* The nodes that are backlinked to the node.
*
* @return BelongsToMany<VaultNode, $this>
*/
public function backlinks(): BelongsToMany
{
return $this->belongsToMany(self::class, null, 'destination_id', 'source_id');
}
/**
* Get the custom paths for the model.
*

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('vault_node_vault_node', function (Blueprint $table): void {
$table->foreignId('source_id')->constrained('vault_nodes');
$table->foreignId('destination_id')->constrained('vault_nodes');
$table->unsignedMediumInteger('position');
$table->primary(['source_id', 'destination_id', 'position']);
});
}
};

View File

@@ -0,0 +1,4 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@@ -0,0 +1,4 @@
<svg {{ $attributes }} data-slot="icon" aria-hidden="true" fill="none" stroke-width="1.5" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3.75 6.75h16.5M3.75 12h16.5M12 17.25h8.25" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@@ -1,36 +1,44 @@
<div class="flex flex-col h-dvh">
<x-layouts.appHeader>
<div class="flex items-center gap-4">
<button type="button" @click="$dispatch('sidebar-left-toggle')" class="hover:text-light-base-950 dark:hover:text-base-50">
<x-icons.folder class="w-5 h-5" />
</button>
<button type="button" @click="$wire.dispatchTo('modals.search-node', 'open-modal')" class="hover:text-light-base-950 dark:hover:text-base-50">
<x-icons.magnifyingGlass class="w-5 h-5" />
<button type="button" class="hover:text-light-base-950 dark:hover:text-base-50"
@click="$dispatch('left-panel-toggle')">
<x-icons.bars3BottomLeft class="w-5 h-5" />
</button>
</div>
<div class="flex items-center gap-4">
<livewire:layout.user-menu />
<button type="button" class="hover:text-light-base-950 dark:hover:text-base-50"
@click="$wire.dispatchTo('modals.search-node', 'open-modal')">
<x-icons.magnifyingGlass class="w-5 h-5" />
</button>
<div class="flex items-center gap-4">
<livewire:layout.user-menu />
</div>
<button type="button" class="hover:text-light-base-950 dark:hover:text-base-50"
@click="$dispatch('right-panel-toggle')">
<x-icons.bars3BottomRight class="w-5 h-5" />
</button>
</div>
</x-layouts.appHeader>
<x-layouts.appMain>
<div x-data="vault" x-cloak @sidebar-left-toggle.window="isSidebarOpen = !isSidebarOpen"
class="relative flex w-full">
<div x-data="vault" x-cloak class="relative flex w-full"
@left-panel-toggle.window="isLeftPanelOpen = !isLeftPanelOpen"
@right-panel-toggle.window="isRightPanelOpen = !isRightPanelOpen">
<div wire:loading wire:target.except="nodeForm.name, nodeForm.content"
class="fixed inset-0 z-40 opacity-50 bg-light-base-200 dark:bg-base-950">
<div class="flex items-center justify-center h-full">
<x-icons.spinner class="w-5 h-5 animate-spin" />
</div>
</div>
<div x-show="isSidebarOpen && isSmallDevice" @click="closeSideBar"
<div x-show="(isLeftPanelOpen || isRightPanelOpen) && isSmallDevice" @click="closePanels"
class="fixed inset-0 z-20 opacity-50 bg-light-base-200 dark:bg-base-950"
x-transition:enter="ease-out duration-300" x-transition:leave="ease-in duration-200">
</div>
<div class="absolute top-0 left-0 z-30 flex flex-col h-full overflow-hidden overflow-y-auto transition-all w-60 bg-light-base-200 dark:bg-base-950"
:class="{ 'translate-x-0': isSidebarOpen, '-translate-x-full hidden': !isSidebarOpen }">
<div class="sticky top-0 z-[5] flex justify-between p-4 bg-light-base-200 dark:bg-base-950">
<div class="absolute top-0 left-0 z-30 flex flex-col h-full overflow-hidden overflow-y-auto transition-all w-60 bg-light-base-50 dark:bg-base-900"
:class="{ 'translate-x-0': isLeftPanelOpen, '-translate-x-full hidden': !isLeftPanelOpen }">
<div class="sticky top-0 z-[5] flex justify-between p-4 bg-light-base-50 dark:bg-base-900">
<h3>{{ $vault->name }}</h3>
<div class="flex items-center">
@@ -85,11 +93,11 @@
<livewire:vault.tree-view lazy="on-load" :$vault />
</div>
<div class="absolute top-0 bottom-0 right-0 flex flex-col w-full overflow-y-auto transition-all text-start md:pl-60"
:class="{ 'md:pl-60': isSidebarOpen, '': !isSidebarOpen }" id="nodeContainer">
<div class="absolute top-0 bottom-0 right-0 flex flex-col w-full overflow-y-auto transition-all text-start bg-light-base-200 dark:bg-base-950"
:class="{ 'md:pl-60': isLeftPanelOpen, 'md:pr-60': isRightPanelOpen }" id="nodeContainer">
<div class="flex flex-col h-full w-full max-w-[48rem] mx-auto p-4">
<div class="flex flex-col w-full h-full gap-4" x-show="$wire.selectedFile">
<div class="z-[5] bg-light-base-50 dark:bg-base-900">
<div class="z-[5]">
<div class="flex justify-between">
<input type="text" wire:model.live.debounce.500ms="nodeForm.name"
class="flex flex-grow p-0 px-1 text-lg bg-transparent border-0 focus:ring-0 focus:outline-0" />
@@ -116,15 +124,19 @@
</x-menu.item>
</x-menu.close>
</x-modal.open>
<x-modal.panel title="{{ __('Choose a template') }}">
@if ($templates && count($templates))
<ul class="flex flex-col gap-2" wire:loading.class="opacity-50">
<ul class="flex flex-col gap-2"
wire:loading.class="opacity-50">
@foreach ($templates as $template)
<li>
<button type="button" wire:click="insertTemplate({{ $template->id }}); modalOpen = false"
<button type="button"
wire:click="insertTemplate({{ $template->id }}); modalOpen = false"
class="flex w-full gap-2 py-1 hover:text-light-base-950 dark:hover:text-base-50">
<span class="overflow-hidden whitespace-nowrap text-ellipsis" title="{{ $template->name }}">
<span
class="overflow-hidden whitespace-nowrap text-ellipsis"
title="{{ $template->name }}">
{{ $template->name }}
</span>
</button>
@@ -190,6 +202,40 @@
</div>
</div>
</div>
<div class="absolute top-0 right-0 z-30 flex flex-col h-full overflow-hidden overflow-y-auto transition-all w-60 bg-light-base-50 dark:bg-base-900"
:class="{ 'translate-x-0': isRightPanelOpen, '-translate-x-full hidden': !isRightPanelOpen }">
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col w-full gap-2">
<h3>Links</h3>
<div class="flex flex-col gap-2 text-sm">
@if ($selectedFileLinks && $selectedFileLinks->count())
@foreach ($selectedFileLinks as $link)
<a class="text-primary-400 dark:text-primary-500 hover:text-primary-300 dark:hover:text-primary-600"
href="" @click.prevent="openFile({{ $link->id }})"
>{{ $link->name }}</a>
@endforeach
@else
<p>{{ __('No links found') }}</p>
@endif
</div>
</div>
<div class="flex flex-col w-full gap-2">
<h3>Backlinks</h3>
<div class="flex flex-col gap-2 text-sm">
@if ($selectedFileBacklinks && $selectedFileBacklinks->count())
@foreach ($selectedFileBacklinks as $link)
<a class="text-primary-400 dark:text-primary-500 hover:text-primary-300 dark:hover:text-primary-600"
href="" @click.prevent="openFile({{ $link->id }})"
>{{ $link->name }}</a>
@endforeach
@else
<p>{{ __('No backlinks found') }}</p>
@endif
</div>
</div>
</div>
</div>
</div>
</x-layouts.appMain>
@@ -203,7 +249,8 @@
@script
<script>
Alpine.data('vault', () => ({
isSidebarOpen: false,
isLeftPanelOpen: false,
isRightPanelOpen: false,
isEditMode: $wire.entangle('isEditMode'),
selectedFile: $wire.entangle('selectedFile'),
html: '',
@@ -225,7 +272,7 @@
this.html = this.markdownToHtml();
});
this.isSidebarOpen = !this.isSmallDevice();
this.isLeftPanelOpen = !this.isSmallDevice();
let markedRender = new marked.Renderer;
markedRender.parser = new marked.Parser;
this.renderListitem = markedRender.listitem.bind(markedRender);
@@ -235,8 +282,9 @@
return window.innerWidth < 768;
},
closeSideBar() {
this.isSidebarOpen = false;
closePanels() {
this.isLeftPanelOpen = false;
this.isRightPanelOpen = false;
},
toggleEditMode() {
@@ -247,7 +295,7 @@
$wire.openFile(node);
if (this.isSmallDevice()) {
this.closeSideBar();
this.closePanels();
}
this.resetScrollPosition();