diff --git a/src/app/Models/Document.php b/src/app/Models/Document.php index ae17803..5b0fb1b 100644 --- a/src/app/Models/Document.php +++ b/src/app/Models/Document.php @@ -3,65 +3,25 @@ namespace App\Models; use App\Helpers\SlugHelper; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; 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\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Facades\App; 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 { use HasFactory, SoftDeletes; - /** - * 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; - } - - /** - * The attributes that are mass assignable. - * - * @var list - */ protected $fillable = [ 'path', - 'title', 'slug', - 'content', - 'rendered_html', + 'default_locale', 'frontmatter', 'file_size', 'file_hash', @@ -70,11 +30,6 @@ public function resolveRouteBinding($value, $field = null) 'updated_by', ]; - /** - * Get the attributes that should be cast. - * - * @return array - */ protected function casts(): array { return [ @@ -83,192 +38,64 @@ protected function casts(): array ]; } - /** - * Frontmatterをパース(互換性のため残す) - * - * @param string $content - * @return array{frontmatter: array, content: string} - */ - protected static function parseFrontmatter(string $content): array + public function getRouteKeyName(): string { - $frontmatter = []; - $bodyContent = $content; + return 'slug'; + } - // Frontmatterの検出(--- で囲まれた部分) - if (preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)/s', $content, $matches)) { - $frontmatterText = $matches[1]; - $bodyContent = $matches[2]; + public function resolveRouteBinding($value, $field = null) + { + $document = $this->where('slug', $value)->first(); - // 簡易的なYAMLパース(key: value形式のみ) - $lines = explode("\n", $frontmatterText); - foreach ($lines as $line) { - if (preg_match('/^([^:]+):\s*(.*)$/', $line, $lineMatches)) { - $frontmatter[trim($lineMatches[1])] = trim($lineMatches[2]); - } - } + if (!$document && is_numeric($value)) { + $document = $this->where('id', $value)->first(); } - return [ - 'frontmatter' => $frontmatter, - 'content' => trim($bodyContent), - ]; + return $document; } /** - * Markdownをレンダリング - * - * @param string $markdown - * @return string + * Backward-compatible static delegate so existing callers and tests + * (e.g. MediaEmbedExtensionTest) keep working. */ 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(); + return DocumentTranslation::renderMarkdown($markdown); } - /** - * [[wiki-link]]を抽出してリンクテーブルに同期 - * - * @return void - */ - public function syncLinks(): void + // ----- Relations ----- + + public function translations(): HasMany { - // 既存のリンクを削除 - $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++, - ]); - } + return $this->hasMany(DocumentTranslation::class); } - /** - * [[wiki-link]]をHTMLリンクに変換 - * - * @return string - */ - public function processLinks(): string + public function defaultTranslation(): HasOne { - 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 - ); + return $this->hasOne(DocumentTranslation::class) + ->whereColumn('locale', 'documents.default_locale'); } - /** - * 全文検索スコープ - * - * @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( @@ -281,57 +108,78 @@ public function recentByUsers(): HasManyThrough ); } - /** - * Translations of this document, one per locale. - * (Other relation/accessor refactor lands in Task 4.) - */ - public function translations(): \Illuminate\Database\Eloquent\Relations\HasMany + // ----- Translation helpers ----- + + public function translationFor(string $locale, bool $fallback = true): ?DocumentTranslation { - return $this->hasMany(DocumentTranslation::class); + $translation = $this->translations->firstWhere('locale', $locale); + + if (!$translation && $fallback) { + $translation = $this->translations->firstWhere('locale', $this->default_locale); + } + + return $translation; + } + + public function isFallback(string $requestedLocale): bool + { + return $this->translations->firstWhere('locale', $requestedLocale) === null; } /** - * ディレクトリパスを取得 - * - * @return string + * @return array */ + public function availableLocales(): array + { + return $this->translations->pluck('locale')->all(); + } + + // ----- Accessors (current-locale → fallback) ----- + + public function getTitleAttribute(): string + { + return $this->translationFor(App::getLocale())?->title ?? ''; + } + + public function getContentAttribute(): string + { + return $this->translationFor(App::getLocale())?->content ?? ''; + } + + public function getRenderedHtmlAttribute(): ?string + { + return $this->translationFor(App::getLocale())?->rendered_html; + } + + // ----- Path helpers ----- + 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; + // ----- Search scope (delegates to translations) ----- - if (empty($this->attributes['slug'])) { - $this->attributes['slug'] = SlugHelper::generate($value); - } + public function scopeSearch(Builder $query, string $term): Builder + { + return $query->whereHas('translations', function (Builder $q) use ($term) { + DocumentTranslation::scopeSearch($q, $term); + }); + } + + public function scopeInDirectory(Builder $query, string $directory): Builder + { + $directory = rtrim($directory, '/') . '/'; + return $query->where('path', 'like', $directory . '%'); } } diff --git a/src/tests/Unit/Models/DocumentTest.php b/src/tests/Unit/Models/DocumentTest.php new file mode 100644 index 0000000..ba36287 --- /dev/null +++ b/src/tests/Unit/Models/DocumentTest.php @@ -0,0 +1,79 @@ +create(['default_locale' => 'en']); + $doc->translations()->where('locale', 'en')->update(['title' => 'Hello']); + DocumentTranslation::factory()->create([ + 'document_id' => $doc->id, + 'locale' => 'ja', + 'title' => 'こんにちは', + ]); + + App::setLocale('ja'); + $this->assertSame('こんにちは', $doc->fresh()->title); + + App::setLocale('en'); + $this->assertSame('Hello', $doc->fresh()->title); + } + + public function test_title_accessor_falls_back_to_default_locale(): void + { + $doc = Document::factory()->create(['default_locale' => 'en']); + $doc->translations()->where('locale', 'en')->update(['title' => 'Hello']); + + App::setLocale('ja'); + $this->assertSame('Hello', $doc->fresh()->title); + } + + public function test_content_and_rendered_html_accessors_fall_back(): void + { + $doc = Document::factory()->create(['default_locale' => 'en']); + $doc->translations()->where('locale', 'en')->update([ + 'content' => 'English body', + 'rendered_html' => '

English body

', + ]); + + App::setLocale('ja'); + $fresh = $doc->fresh(); + $this->assertSame('English body', $fresh->content); + $this->assertSame('

English body

', $fresh->rendered_html); + } + + public function test_is_fallback_returns_true_when_locale_missing(): void + { + $doc = Document::factory()->create(['default_locale' => 'en']); + $this->assertTrue($doc->isFallback('ja')); + $this->assertFalse($doc->isFallback('en')); + } + + public function test_translation_for_returns_null_when_fallback_disabled(): void + { + $doc = Document::factory()->create(['default_locale' => 'en']); + $this->assertNull($doc->translationFor('ja', fallback: false)); + $this->assertNotNull($doc->translationFor('ja', fallback: true)); + } + + public function test_available_locales_lists_existing_translations(): void + { + $doc = Document::factory()->create(['default_locale' => 'en']); + DocumentTranslation::factory()->create(['document_id' => $doc->id, 'locale' => 'ja']); + DocumentTranslation::factory()->create(['document_id' => $doc->id, 'locale' => 'fr']); + + $locales = $doc->fresh()->availableLocales(); + sort($locales); + $this->assertSame(['en', 'fr', 'ja'], $locales); + } +}