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; } /** * The attributes that are mass assignable. * * @var list */ 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 */ 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()); $converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension()); 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) { return '' . e($linkTitle) . ''; } else { return '' . e($linkTitle) . ''; } }, $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' ); } /** * ディレクトリパスを取得 * * @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); } } }