Refactor Document to read title/content via translations

Adds translations/defaultTranslation relations, current-locale accessors
with fallback to default_locale, isFallback/availableLocales helpers,
and search scope that delegates to DocumentTranslation.
This commit is contained in:
Yutaka Kurosaki
2026-05-10 12:17:31 +09:00
parent b7a70f74e5
commit 0c399c9f0f
2 changed files with 155 additions and 228 deletions
+78 -230
View File
@@ -3,65 +3,25 @@
namespace App\Models; namespace App\Models;
use App\Helpers\SlugHelper; use App\Helpers\SlugHelper;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; 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\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 class Document extends Model
{ {
use HasFactory, SoftDeletes; 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<string>
*/
protected $fillable = [ protected $fillable = [
'path', 'path',
'title',
'slug', 'slug',
'content', 'default_locale',
'rendered_html',
'frontmatter', 'frontmatter',
'file_size', 'file_size',
'file_hash', 'file_hash',
@@ -70,11 +30,6 @@ public function resolveRouteBinding($value, $field = null)
'updated_by', 'updated_by',
]; ];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
@@ -83,192 +38,64 @@ protected function casts(): array
]; ];
} }
/** public function getRouteKeyName(): string
* Frontmatterをパース(互換性のため残す)
*
* @param string $content
* @return array{frontmatter: array, content: string}
*/
protected static function parseFrontmatter(string $content): array
{ {
$frontmatter = []; return 'slug';
$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 [ public function resolveRouteBinding($value, $field = null)
'frontmatter' => $frontmatter, {
'content' => trim($bodyContent), $document = $this->where('slug', $value)->first();
];
if (!$document && is_numeric($value)) {
$document = $this->where('id', $value)->first();
}
return $document;
} }
/** /**
* Markdownをレンダリング * Backward-compatible static delegate so existing callers and tests
* * (e.g. MediaEmbedExtensionTest) keep working.
* @param string $markdown
* @return string
*/ */
public static function renderMarkdown(string $markdown): string public static function renderMarkdown(string $markdown): string
{ {
$converter = new CommonMarkConverter([ return DocumentTranslation::renderMarkdown($markdown);
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$converter->getEnvironment()->addExtension(new GithubFlavoredMarkdownExtension());
$converter->getEnvironment()->addExtension(new \App\Markdown\MediaEmbedExtension());
return $converter->convert($markdown)->getContent();
} }
/** // ----- Relations -----
* [[wiki-link]]を抽出してリンクテーブルに同期
* public function translations(): HasMany
* @return void
*/
public function syncLinks(): void
{ {
// 既存のリンクを削除 return $this->hasMany(DocumentTranslation::class);
$this->outgoingLinks()->delete();
// [[wiki-link]]を抽出
preg_match_all('/\[\[([^\]]+)\]\]/', $this->content, $matches);
if (empty($matches[1])) {
return;
} }
$position = 0; public function defaultTranslation(): HasOne
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( return $this->hasOne(DocumentTranslation::class)
'/\[\[([^\]]+)\]\]/', ->whereColumn('locale', 'documents.default_locale');
function ($matches) {
$linkTitle = trim($matches[1]);
$slug = SlugHelper::generate($linkTitle);
// リンク先のドキュメントを検索
$targetDocument = static::where('title', $linkTitle)
->orWhere('slug', $slug)
->first();
if ($targetDocument) {
return '<a href="' . route('documents.show', $targetDocument->slug) . '" class="wiki-link">' . e($linkTitle) . '</a>';
} else {
return '<a href="' . route('documents.create') . '?title=' . urlencode($linkTitle) . '" class="wiki-link wiki-link-new">' . e($linkTitle) . '</a>';
}
},
$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 public function creator(): BelongsTo
{ {
return $this->belongsTo(User::class, 'created_by'); return $this->belongsTo(User::class, 'created_by');
} }
/**
* 更新者リレーション
*
* @return BelongsTo
*/
public function updater(): BelongsTo public function updater(): BelongsTo
{ {
return $this->belongsTo(User::class, 'updated_by'); return $this->belongsTo(User::class, 'updated_by');
} }
/**
* 発リンク(このドキュメントから他へのリンク)
*
* @return HasMany
*/
public function outgoingLinks(): HasMany public function outgoingLinks(): HasMany
{ {
return $this->hasMany(DocumentLink::class, 'source_document_id'); return $this->hasMany(DocumentLink::class, 'source_document_id');
} }
/**
* 被リンク(他のドキュメントからこのドキュメントへのリンク)
*
* @return HasMany
*/
public function incomingLinks(): HasMany public function incomingLinks(): HasMany
{ {
return $this->hasMany(DocumentLink::class, 'target_document_id'); return $this->hasMany(DocumentLink::class, 'target_document_id');
} }
/**
* このドキュメントを最近閲覧したユーザー
*
* @return HasManyThrough
*/
public function recentByUsers(): HasManyThrough public function recentByUsers(): HasManyThrough
{ {
return $this->hasManyThrough( return $this->hasManyThrough(
@@ -281,57 +108,78 @@ public function recentByUsers(): HasManyThrough
); );
} }
/** // ----- Translation helpers -----
* Translations of this document, one per locale.
* (Other relation/accessor refactor lands in Task 4.) public function translationFor(string $locale, bool $fallback = true): ?DocumentTranslation
*/
public function translations(): \Illuminate\Database\Eloquent\Relations\HasMany
{ {
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 array<int, string>
*
* @return string
*/ */
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 public function getDirectoryAttribute(): string
{ {
return dirname($this->path); return dirname($this->path);
} }
/**
* ファイル名を取得
*
* @return string
*/
public function getFilenameAttribute(): string public function getFilenameAttribute(): string
{ {
return basename($this->path); return basename($this->path);
} }
/**
* 絶対パスを取得
*
* @return string
*/
public function getAbsolutePathAttribute(): string public function getAbsolutePathAttribute(): string
{ {
return Storage::disk('markdown')->path($this->path); return Storage::disk('markdown')->path($this->path);
} }
/** // ----- Search scope (delegates to translations) -----
* タイトルセット時にslugも自動生成
*
* @param string $value
* @return void
*/
public function setTitleAttribute(string $value): void
{
$this->attributes['title'] = $value;
if (empty($this->attributes['slug'])) { public function scopeSearch(Builder $query, string $term): Builder
$this->attributes['slug'] = SlugHelper::generate($value); {
} 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 . '%');
} }
} }
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Tests\Unit\Models;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
class DocumentTest extends TestCase
{
use RefreshDatabase;
public function test_title_accessor_returns_current_locale_translation(): void
{
$doc = Document::factory()->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' => '<p>English body</p>',
]);
App::setLocale('ja');
$fresh = $doc->fresh();
$this->assertSame('English body', $fresh->content);
$this->assertSame('<p>English body</p>', $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);
}
}