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:
+77
-229
@@ -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 . '%');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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