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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user