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:
+76
-228
@@ -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<string>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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 '<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
|
||||
);
|
||||
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<int, 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
|
||||
{
|
||||
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 . '%');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user