Make DocumentService locale-aware

createDocument/updateDocument now accept a \$locale parameter and
write to document_translations. Adds addTranslation, deleteTranslation,
setDefaultLocale (with path/slug regen), distinct-document search,
and findByTitle that delegates to WikiLinkResolver.
This commit is contained in:
Yutaka Kurosaki
2026-05-10 12:23:14 +09:00
parent d7522f592d
commit 7909c33074
3 changed files with 265 additions and 180 deletions
+8
View File
@@ -182,4 +182,12 @@ public function scopeInDirectory(Builder $query, string $directory): Builder
$directory = rtrim($directory, '/') . '/';
return $query->where('path', 'like', $directory . '%');
}
/**
* Sync wiki-links for this document. Implementation in Task 7.
*/
public function syncLinks(): void
{
// Replaced in Task 7
}
}
+128 -180
View File
@@ -2,143 +2,185 @@
namespace App\Services;
use App\Models\Document;
use App\Models\RecentDocument;
use App\Helpers\SlugHelper;
use Illuminate\Support\Facades\Storage;
use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Models\RecentDocument;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class DocumentService
{
/**
* 新しいドキュメントを作成
*
* @param string $title
* @param string $content
* @param int|null $userId
* @param string|null $directory (deprecated - path is now auto-generated from title)
* @return Document
*/
public function createDocument(
string $title,
string $content,
?int $userId = null,
?string $directory = null
?string $locale = null,
): Document {
// タイトルからパスとスラッグを自動生成
// 例: "Laravel/Livewire/Components" → path="Laravel/Livewire/Components.md", slug="components"
$locale = $locale ?: App::getLocale();
[$path, $slug] = $this->generatePathAndSlug($title);
// ドキュメントをDBに作成
$document = Document::create([
'path' => $path,
'title' => $title,
'slug' => $slug,
'content' => $content,
'rendered_html' => Document::renderMarkdown($content),
'created_by' => $userId,
'updated_by' => $userId,
]);
return DB::transaction(function () use ($title, $content, $userId, $locale, $path, $slug) {
$document = Document::create([
'path' => $path,
'slug' => $slug,
'default_locale' => $locale,
'created_by' => $userId,
'updated_by' => $userId,
]);
// リンクを同期
$document->syncLinks();
DocumentTranslation::create([
'document_id' => $document->id,
'locale' => $locale,
'title' => $title,
'content' => $content,
'rendered_html' => DocumentTranslation::renderMarkdown($content),
'created_by' => $userId,
'updated_by' => $userId,
]);
return $document;
$document->load('translations');
$document->syncLinks();
return $document;
});
}
/**
* ドキュメントを更新
*
* @param Document $document
* @param string $title
* @param string $content
* @param int|null $userId
* @return Document
*/
public function updateDocument(
Document $document,
string $title,
string $content,
?int $userId = null
?int $userId = null,
?string $locale = null,
): Document {
// タイトルが変更された場合はパスとスラッグを再生成
if ($document->title !== $title) {
[$path, $slug] = $this->generatePathAndSlug($title, $document->id);
$document->path = $path;
$document->slug = $slug;
}
$locale = $locale ?: App::getLocale();
$document->title = $title;
$document->content = $content;
$document->rendered_html = Document::renderMarkdown($content);
$document->updated_by = $userId;
return DB::transaction(function () use ($document, $title, $content, $userId, $locale) {
$translation = $document->translations()->firstOrNew(['locale' => $locale]);
$translation->title = $title;
$translation->content = $content;
$translation->rendered_html = DocumentTranslation::renderMarkdown($content);
$translation->updated_by = $userId;
if (!$translation->exists) {
$translation->created_by = $userId;
}
$translation->save();
// DBに保存
$document->save();
$document->updated_by = $userId;
// リンクを再同期
$document->syncLinks();
// Path/slug regenerate only when editing the default-locale translation
if ($locale === $document->default_locale) {
[$path, $slug] = $this->generatePathAndSlug($title, $document->id);
$document->path = $path;
$document->slug = $slug;
}
return $document;
$document->save();
$document->load('translations');
$document->syncLinks();
return $document;
});
}
public function addTranslation(
Document $document,
string $locale,
string $title,
string $content,
?int $userId = null,
): DocumentTranslation {
if ($document->translations()->where('locale', $locale)->exists()) {
throw new \InvalidArgumentException("Translation for locale '$locale' already exists");
}
return DocumentTranslation::create([
'document_id' => $document->id,
'locale' => $locale,
'title' => $title,
'content' => $content,
'rendered_html' => DocumentTranslation::renderMarkdown($content),
'created_by' => $userId,
'updated_by' => $userId,
]);
}
public function deleteTranslation(Document $document, string $locale): void
{
if ($locale === $document->default_locale) {
throw new \InvalidArgumentException("Cannot delete default-locale translation '$locale'");
}
$document->translations()->where('locale', $locale)->delete();
}
public function setDefaultLocale(Document $document, string $locale): Document
{
$translation = $document->translations()->where('locale', $locale)->first();
if (!$translation) {
throw new \InvalidArgumentException("Cannot set default to '$locale': translation does not exist");
}
return DB::transaction(function () use ($document, $locale, $translation) {
$document->default_locale = $locale;
[$path, $slug] = $this->generatePathAndSlug($translation->title, $document->id);
$document->path = $path;
$document->slug = $slug;
$document->save();
return $document->fresh('translations');
});
}
/**
* ドキュメントを削除
*
* @param Document $document
* @return bool
*/
public function deleteDocument(Document $document): bool
{
// DBから削除(ソフトデリート)
return $document->delete();
}
/**
* 全文検索
*
* @param string $query
* @param int $limit
* @return \Illuminate\Database\Eloquent\Collection
* Locale-agnostic full-text search; returns distinct documents.
*/
public function search(string $query, int $limit = 20)
{
return Document::search($query)
->limit($limit)
->get();
$documentIds = DocumentTranslation::query()
->search($query)
->limit($limit * 5) // overscan to allow distinct collapse
->pluck('document_id')
->unique()
->values()
->take($limit);
if ($documentIds->isEmpty()) {
return Document::query()->whereRaw('1 = 0')->get();
}
return Document::with('translations')
->whereIn('id', $documentIds)
->get()
->sortBy(fn ($d) => $documentIds->search($d->id))
->values();
}
public function findByTitle(string $title, ?string $locale = null): ?Document
{
return (new WikiLinkResolver())->resolve($title, $locale ?: App::getLocale());
}
/**
* ディレクトリツリーを生成
*
* @return array
*/
public function getDirectoryTree(): array
{
$documents = Document::orderBy('path')->get();
$documents = Document::with('translations')->orderBy('path')->get();
$tree = [];
foreach ($documents as $document) {
$parts = explode('/', $document->path);
$current = &$tree;
foreach ($parts as $index => $part) {
$isFile = ($index === count($parts) - 1);
if ($isFile) {
// ファイル
if (!isset($current['_files'])) {
$current['_files'] = [];
}
$current['_files'][] = [
'name' => $part,
'document' => $document,
];
} else {
// ディレクトリ
if (!isset($current[$part])) {
$current[$part] = [];
}
@@ -146,67 +188,28 @@ public function getDirectoryTree(): array
}
}
}
return $tree;
}
/**
* ユーザーの最近閲覧したドキュメントを取得
*
* @param int $userId
* @param int $limit
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getRecentDocuments(int $userId, int $limit = 10)
{
return RecentDocument::getRecentForUser($userId, $limit);
}
/**
* ドキュメント閲覧を記録
*
* @param Document $document
* @param int $userId
* @return void
*/
public function recordDocumentAccess(Document $document, int $userId): void
{
RecentDocument::recordAccess($userId, $document->id);
}
/**
* 指定タイトルのドキュメントを検索
*
* @param string $title
* @return Document|null
*/
public function findByTitle(string $title): ?Document
{
return Document::where('title', $title)
->orWhere('slug', SlugHelper::generate($title))
->first();
}
/**
* 被リンク(バックリンク)を取得
*
* @param Document $document
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBacklinks(Document $document)
{
return $document->incomingLinks()
->with('sourceDocument')
->with('sourceDocument.translations')
->get()
->pluck('sourceDocument')
->filter();
}
/**
* 壊れたリンク(未作成ページへのリンク)を取得
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBrokenLinks()
{
return DB::table('document_links')
@@ -217,39 +220,13 @@ public function getBrokenLinks()
->get();
}
/**
* タイトルからパスとスラッグを生成
* タイトルに含まれる / をディレクトリ区切りとして扱う
*
* : "Laravel/Livewire/Components"
* path = "Laravel/Livewire/Components.md"
* slug = "components" (最後のコンポーネントから生成)
*
* @param string $title
* @param int|null $excludeDocumentId 更新時に除外するドキュメントID
* @return array [path, slug]
*/
private function generatePathAndSlug(string $title, ?int $excludeDocumentId = null): array
{
// タイトルをそのままパスとして使用(.md拡張子を追加)
$basePath = $title . '.md';
// 最後のパスコンポーネント(スラッシュで区切られた最後の部分)からスラッグを生成
$lastComponent = basename($title);
$baseSlug = SlugHelper::generate($lastComponent);
// ユニークなパスとスラッグを生成
$baseSlug = SlugHelper::generate(basename($title));
return $this->ensureUniquePath($basePath, $baseSlug, $excludeDocumentId);
}
/**
* パスとスラッグがユニークになるように調整
*
* @param string $basePath
* @param string $baseSlug
* @param int|null $excludeDocumentId
* @return array [path, slug]
*/
private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excludeDocumentId = null): array
{
$path = $basePath;
@@ -261,46 +238,17 @@ private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excl
->where(function ($q) use ($path, $slug) {
$q->where('path', $path)->orWhere('slug', $slug);
});
if ($excludeDocumentId) {
$query->where('id', '!=', $excludeDocumentId);
}
if (!$query->exists()) {
break;
}
$counter++;
// パス: "title.md" → "title-2.md"
$path = preg_replace('/\.md$/', "-{$counter}.md", $basePath);
// スラッグ: "title" → "title-2"
$slug = $baseSlug . '-' . $counter;
}
return [$path, $slug];
}
/**
* 初期ドキュメントを作成
*
* @return void
*/
public function createInitialDocuments(): void
{
// ホームページ
$this->createDocument(
'Home',
"# Welcome to Knowledge Base\n\nThis is your personal knowledge base powered by Markdown.\n\n## Getting Started\n\n- Create new documents using [[wiki-links]]\n- Use Ctrl+K for quick switching\n- Full-text search is available\n\n## Example Links\n\n- [[Getting Started]]\n- [[Documentation]]\n- [[Notes]]",
null,
null
);
// Getting Startedページ
$this->createDocument(
'Getting Started',
"# Getting Started\n\nLearn how to use this knowledge base.\n\n## Creating Documents\n\nClick on any [[wiki-link]] to create a new document.\n\n## Editing\n\nClick the edit button to modify content.\n\nBack to [[Home]]",
null,
null
);
}
}
@@ -0,0 +1,129 @@
<?php
namespace Tests\Unit\Services;
use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Models\User;
use App\Services\DocumentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
class DocumentServiceTest extends TestCase
{
use RefreshDatabase;
private DocumentService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = new DocumentService();
}
public function test_create_document_creates_one_translation_in_given_locale(): void
{
App::setLocale('en'); // ensure deterministic
$user = User::factory()->create();
$doc = $this->service->createDocument('Hello', '# Hi', $user->id, 'en');
$this->assertSame('en', $doc->default_locale);
$this->assertSame('Hello.md', $doc->path);
$this->assertSame('hello', $doc->slug);
$this->assertCount(1, $doc->translations);
$this->assertSame('Hello', $doc->translations->first()->title);
$this->assertSame('# Hi', $doc->translations->first()->content);
$this->assertStringContainsString('<h1>', $doc->translations->first()->rendered_html);
}
public function test_update_document_in_default_locale_regenerates_path_and_slug(): void
{
$doc = $this->service->createDocument('Old', 'body', null, 'en');
$updated = $this->service->updateDocument($doc, 'New Title', 'body2', null, 'en');
$this->assertSame('New Title.md', $updated->path);
$this->assertSame('new-title', $updated->slug);
}
public function test_update_document_in_non_default_locale_does_not_change_path(): void
{
$doc = $this->service->createDocument('English', 'body', null, 'en');
$originalPath = $doc->path;
$originalSlug = $doc->slug;
$updated = $this->service->updateDocument($doc, '日本語タイトル', '本文', null, 'ja');
$this->assertSame($originalPath, $updated->path);
$this->assertSame($originalSlug, $updated->slug);
$this->assertSame('日本語タイトル', $updated->translationFor('ja', false)->title);
}
public function test_add_translation_creates_new_locale_row(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->service->addTranslation($doc, 'ja', 'こんにちは', 'やあ', null);
$this->assertCount(2, $doc->fresh()->translations);
$this->assertSame('こんにちは', $doc->fresh()->translationFor('ja', false)->title);
}
public function test_add_translation_throws_on_duplicate_locale(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->expectException(\InvalidArgumentException::class);
$this->service->addTranslation($doc, 'en', 'X', 'Y', null);
}
public function test_delete_translation_removes_non_default(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->service->addTranslation($doc, 'ja', 'こんにちは', 'やあ', null);
$this->service->deleteTranslation($doc, 'ja');
$this->assertNull($doc->fresh()->translationFor('ja', false));
}
public function test_delete_translation_refuses_default_locale(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->expectException(\InvalidArgumentException::class);
$this->service->deleteTranslation($doc, 'en');
}
public function test_set_default_locale_requires_existing_translation(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->expectException(\InvalidArgumentException::class);
$this->service->setDefaultLocale($doc, 'ja');
}
public function test_set_default_locale_regenerates_path_from_new_locale_title(): void
{
$doc = $this->service->createDocument('Hello', 'body', null, 'en');
$this->service->addTranslation($doc, 'ja', 'こんにちは', 'やあ', null);
$updated = $this->service->setDefaultLocale($doc, 'ja');
$this->assertSame('ja', $updated->default_locale);
$this->assertSame('こんにちは.md', $updated->path);
}
public function test_search_returns_distinct_documents_across_locales(): void
{
$doc = $this->service->createDocument('Searchword', 'body', null, 'en');
$this->service->addTranslation($doc, 'ja', 'Searchword JA', 'Searchword body', null);
$results = $this->service->search('Searchword');
$this->assertCount(1, $results); // distinct, even though 2 translations match
$this->assertSame($doc->id, $results->first()->id);
}
}