From 7909c3307424b315bc7066b03d570431327f7b6c Mon Sep 17 00:00:00 2001 From: Yutaka Kurosaki <> Date: Sun, 10 May 2026 12:23:14 +0900 Subject: [PATCH] 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. --- src/app/Models/Document.php | 8 + src/app/Services/DocumentService.php | 308 ++++++++---------- .../Unit/Services/DocumentServiceTest.php | 129 ++++++++ 3 files changed, 265 insertions(+), 180 deletions(-) create mode 100644 src/tests/Unit/Services/DocumentServiceTest.php diff --git a/src/app/Models/Document.php b/src/app/Models/Document.php index 5b0fb1b..a9dae22 100644 --- a/src/app/Models/Document.php +++ b/src/app/Models/Document.php @@ -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 + } } diff --git a/src/app/Services/DocumentService.php b/src/app/Services/DocumentService.php index f2f11f4..32dd577 100644 --- a/src/app/Services/DocumentService.php +++ b/src/app/Services/DocumentService.php @@ -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 - ); - } } diff --git a/src/tests/Unit/Services/DocumentServiceTest.php b/src/tests/Unit/Services/DocumentServiceTest.php new file mode 100644 index 0000000..d4f57fb --- /dev/null +++ b/src/tests/Unit/Services/DocumentServiceTest.php @@ -0,0 +1,129 @@ +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('

', $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); + } +}