2025-11-29 09:41:38 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Models;
|
|
|
|
|
|
|
|
|
|
use App\Helpers\SlugHelper;
|
2026-05-10 11:53:33 +09:00
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
2025-11-29 09:41:38 +09:00
|
|
|
use Illuminate\Database\Eloquent\Model;
|
|
|
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
|
|
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
|
|
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
|
use Illuminate\Support\Str;
|
|
|
|
|
use League\CommonMark\CommonMarkConverter;
|
|
|
|
|
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
|
|
|
|
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
|
|
|
|
|
|
|
|
|
class Document extends Model
|
|
|
|
|
{
|
2026-05-10 11:53:33 +09:00
|
|
|
use HasFactory, SoftDeletes;
|
2025-11-29 09:41:38 +09:00
|
|
|
|
2025-11-29 17:22:17 +09:00
|
|
|
/**
|
|
|
|
|
* Get the route key for the model.
|
|
|
|
|
*
|
|
|
|
|
* @return string
|
|
|
|
|
*/
|
|
|
|
|
public function getRouteKeyName(): string
|
|
|
|
|
{
|
|
|
|
|
return 'slug';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Retrieve the model for a bound value.
|
|
|
|
|
* Supports both slug and ID for backwards compatibility.
|
|
|
|
|
*
|
|
|
|
|
* @param mixed $value
|
|
|
|
|
* @param string|null $field
|
|
|
|
|
* @return \Illuminate\Database\Eloquent\Model|null
|
|
|
|
|
*/
|
|
|
|
|
public function resolveRouteBinding($value, $field = null)
|
|
|
|
|
{
|
|
|
|
|
// First try to find by slug
|
|
|
|
|
$document = $this->where('slug', $value)->first();
|
|
|
|
|
|
|
|
|
|
// If not found by slug, try by ID (for backwards compatibility)
|
|
|
|
|
if (!$document && is_numeric($value)) {
|
|
|
|
|
$document = $this->where('id', $value)->first();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $document;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 09:41:38 +09:00
|
|
|
/**
|
|
|
|
|
* The attributes that are mass assignable.
|
|
|
|
|
*
|
|
|
|
|
* @var list<string>
|
|
|
|
|
*/
|
|
|
|
|
protected $fillable = [
|
|
|
|
|
'path',
|
|
|
|
|
'title',
|
|
|
|
|
'slug',
|
|
|
|
|
'content',
|
|
|
|
|
'rendered_html',
|
|
|
|
|
'frontmatter',
|
|
|
|
|
'file_size',
|
|
|
|
|
'file_hash',
|
|
|
|
|
'file_modified_at',
|
|
|
|
|
'created_by',
|
|
|
|
|
'updated_by',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the attributes that should be cast.
|
|
|
|
|
*
|
|
|
|
|
* @return array<string, string>
|
|
|
|
|
*/
|
|
|
|
|
protected function casts(): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'frontmatter' => 'array',
|
|
|
|
|
'file_modified_at' => 'datetime',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Frontmatterをパース(互換性のため残す)
|
|
|
|
|
*
|
|
|
|
|
* @param string $content
|
|
|
|
|
* @return array{frontmatter: array, content: string}
|
|
|
|
|
*/
|
|
|
|
|
protected static function parseFrontmatter(string $content): array
|
|
|
|
|
{
|
|
|
|
|
$frontmatter = [];
|
|
|
|
|
$bodyContent = $content;
|
|
|
|
|
|
|
|
|
|
// Frontmatterの検出(--- で囲まれた部分)
|
|
|
|
|
if (preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)/s', $content, $matches)) {
|
|
|
|
|
$frontmatterText = $matches[1];
|
|
|
|
|
$bodyContent = $matches[2];
|
|
|
|
|
|
|
|
|
|
// 簡易的なYAMLパース(key: value形式のみ)
|
|
|
|
|
$lines = explode("\n", $frontmatterText);
|
|
|
|
|
foreach ($lines as $line) {
|
|
|
|
|
if (preg_match('/^([^:]+):\s*(.*)$/', $line, $lineMatches)) {
|
|
|
|
|
$frontmatter[trim($lineMatches[1])] = trim($lineMatches[2]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'frontmatter' => $frontmatter,
|
|
|
|
|
'content' => trim($bodyContent),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Markdownをレンダリング
|
|
|
|
|
*
|
|
|
|
|
* @param string $markdown
|
|
|
|
|
* @return string
|
|
|
|
|
*/
|
|
|
|
|
public static function renderMarkdown(string $markdown): string
|
|
|
|
|
{
|
|
|
|
|
$converter = new CommonMarkConverter([
|
|
|
|
|
'html_input' => 'strip',
|
|
|
|
|
'allow_unsafe_links' => false,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
|
2026-05-09 11:03:09 +09:00
|
|
|
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
|
2025-11-29 09:41:38 +09:00
|
|
|
|
|
|
|
|
return $converter->convert($markdown)->getContent();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* [[wiki-link]]を抽出してリンクテーブルに同期
|
|
|
|
|
*
|
|
|
|
|
* @return void
|
|
|
|
|
*/
|
|
|
|
|
public function syncLinks(): void
|
|
|
|
|
{
|
|
|
|
|
// 既存のリンクを削除
|
|
|
|
|
$this->outgoingLinks()->delete();
|
|
|
|
|
|
|
|
|
|
// [[wiki-link]]を抽出
|
|
|
|
|
preg_match_all('/\[\[([^\]]+)\]\]/', $this->content, $matches);
|
|
|
|
|
|
|
|
|
|
if (empty($matches[1])) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$position = 0;
|
|
|
|
|
foreach ($matches[1] as $linkTitle) {
|
|
|
|
|
$linkTitle = trim($linkTitle);
|
|
|
|
|
|
|
|
|
|
// リンク先のドキュメントを検索
|
|
|
|
|
$targetDocument = static::where('title', $linkTitle)
|
|
|
|
|
->orWhere('slug', SlugHelper::generate($linkTitle))
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
DocumentLink::create([
|
|
|
|
|
'source_document_id' => $this->id,
|
|
|
|
|
'target_document_id' => $targetDocument?->id,
|
|
|
|
|
'target_title' => $linkTitle,
|
|
|
|
|
'position' => $position++,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* [[wiki-link]]をHTMLリンクに変換
|
|
|
|
|
*
|
|
|
|
|
* @return string
|
|
|
|
|
*/
|
|
|
|
|
public function processLinks(): string
|
|
|
|
|
{
|
|
|
|
|
return preg_replace_callback(
|
|
|
|
|
'/\[\[([^\]]+)\]\]/',
|
|
|
|
|
function ($matches) {
|
|
|
|
|
$linkTitle = trim($matches[1]);
|
|
|
|
|
$slug = SlugHelper::generate($linkTitle);
|
|
|
|
|
|
|
|
|
|
// リンク先のドキュメントを検索
|
|
|
|
|
$targetDocument = static::where('title', $linkTitle)
|
|
|
|
|
->orWhere('slug', $slug)
|
|
|
|
|
->first();
|
|
|
|
|
|
|
|
|
|
if ($targetDocument) {
|
2025-11-29 17:14:46 +09:00
|
|
|
return '<a href="' . route('documents.show', $targetDocument->slug) . '" class="wiki-link">' . e($linkTitle) . '</a>';
|
2025-11-29 09:41:38 +09:00
|
|
|
} else {
|
2025-11-29 17:14:46 +09:00
|
|
|
return '<a href="' . route('documents.create') . '?title=' . urlencode($linkTitle) . '" class="wiki-link wiki-link-new">' . e($linkTitle) . '</a>';
|
2025-11-29 09:41:38 +09:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
$this->rendered_html
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 全文検索スコープ
|
|
|
|
|
*
|
|
|
|
|
* @param Builder $query
|
|
|
|
|
* @param string $searchTerm
|
|
|
|
|
* @return Builder
|
|
|
|
|
*/
|
|
|
|
|
public function scopeSearch(Builder $query, string $searchTerm): Builder
|
|
|
|
|
{
|
|
|
|
|
return $query->whereRaw(
|
|
|
|
|
'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)',
|
|
|
|
|
[$searchTerm]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ディレクトリ内検索スコープ
|
|
|
|
|
*
|
|
|
|
|
* @param Builder $query
|
|
|
|
|
* @param string $directory
|
|
|
|
|
* @return Builder
|
|
|
|
|
*/
|
|
|
|
|
public function scopeInDirectory(Builder $query, string $directory): Builder
|
|
|
|
|
{
|
|
|
|
|
$directory = rtrim($directory, '/') . '/';
|
|
|
|
|
return $query->where('path', 'like', $directory . '%');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 作成者リレーション
|
|
|
|
|
*
|
|
|
|
|
* @return BelongsTo
|
|
|
|
|
*/
|
|
|
|
|
public function creator(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(User::class, 'created_by');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 更新者リレーション
|
|
|
|
|
*
|
|
|
|
|
* @return BelongsTo
|
|
|
|
|
*/
|
|
|
|
|
public function updater(): BelongsTo
|
|
|
|
|
{
|
|
|
|
|
return $this->belongsTo(User::class, 'updated_by');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 発リンク(このドキュメントから他へのリンク)
|
|
|
|
|
*
|
|
|
|
|
* @return HasMany
|
|
|
|
|
*/
|
|
|
|
|
public function outgoingLinks(): HasMany
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(DocumentLink::class, 'source_document_id');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 被リンク(他のドキュメントからこのドキュメントへのリンク)
|
|
|
|
|
*
|
|
|
|
|
* @return HasMany
|
|
|
|
|
*/
|
|
|
|
|
public function incomingLinks(): HasMany
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(DocumentLink::class, 'target_document_id');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* このドキュメントを最近閲覧したユーザー
|
|
|
|
|
*
|
|
|
|
|
* @return HasManyThrough
|
|
|
|
|
*/
|
|
|
|
|
public function recentByUsers(): HasManyThrough
|
|
|
|
|
{
|
|
|
|
|
return $this->hasManyThrough(
|
|
|
|
|
User::class,
|
|
|
|
|
RecentDocument::class,
|
|
|
|
|
'document_id',
|
|
|
|
|
'id',
|
|
|
|
|
'id',
|
|
|
|
|
'user_id'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 12:14:24 +09:00
|
|
|
/**
|
|
|
|
|
* Translations of this document, one per locale.
|
|
|
|
|
* (Other relation/accessor refactor lands in Task 4.)
|
|
|
|
|
*/
|
|
|
|
|
public function translations(): \Illuminate\Database\Eloquent\Relations\HasMany
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(DocumentTranslation::class);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 09:41:38 +09:00
|
|
|
/**
|
|
|
|
|
* ディレクトリパスを取得
|
|
|
|
|
*
|
|
|
|
|
* @return string
|
|
|
|
|
*/
|
|
|
|
|
public function getDirectoryAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return dirname($this->path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ファイル名を取得
|
|
|
|
|
*
|
|
|
|
|
* @return string
|
|
|
|
|
*/
|
|
|
|
|
public function getFilenameAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return basename($this->path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 絶対パスを取得
|
|
|
|
|
*
|
|
|
|
|
* @return string
|
|
|
|
|
*/
|
|
|
|
|
public function getAbsolutePathAttribute(): string
|
|
|
|
|
{
|
|
|
|
|
return Storage::disk('markdown')->path($this->path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* タイトルセット時にslugも自動生成
|
|
|
|
|
*
|
|
|
|
|
* @param string $value
|
|
|
|
|
* @return void
|
|
|
|
|
*/
|
|
|
|
|
public function setTitleAttribute(string $value): void
|
|
|
|
|
{
|
|
|
|
|
$this->attributes['title'] = $value;
|
|
|
|
|
|
|
|
|
|
if (empty($this->attributes['slug'])) {
|
|
|
|
|
$this->attributes['slug'] = SlugHelper::generate($value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|