mirror of
https://github.com/brufdev/many-notes.git
synced 2026-01-25 12:28:58 -06:00
Add links/backlinks feature
This commit is contained in:
@@ -53,6 +53,8 @@ final readonly class DeleteVaultNode
|
||||
}
|
||||
}
|
||||
|
||||
$node->links()->detach();
|
||||
$node->backlinks()->detach();
|
||||
$node->delete();
|
||||
|
||||
return $deletedNodes;
|
||||
|
||||
@@ -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'] ?? '';
|
||||
|
||||
@@ -70,5 +70,7 @@ final readonly class ProcessImportedVault
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
new ProcessVaultLinks()->handle($vault);
|
||||
}
|
||||
}
|
||||
|
||||
19
app/Actions/ProcessVaultLinks.php
Normal file
19
app/Actions/ProcessVaultLinks.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/Actions/ProcessVaultNodeLinks.php
Normal file
63
app/Actions/ProcessVaultNodeLinks.php
Normal 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']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user