Compare commits
10 Commits
b7a70f74e5
...
1ce1fa23a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ce1fa23a4 | |||
| 0c13ad1e64 | |||
| c9586612f5 | |||
| 0100a0afb4 | |||
| 97171960bd | |||
| 187349521d | |||
| 6d71f5fecf | |||
| 7909c33074 | |||
| d7522f592d | |||
| 0c399c9f0f |
@@ -369,8 +369,10 @@ return new class extends Migration
|
||||
DB::statement('ALTER TABLE documents DROP INDEX documents_search_index');
|
||||
}
|
||||
|
||||
// 5. Drop translatable columns from documents
|
||||
// 5. Drop translatable columns from documents.
|
||||
// SQLite requires explicit dropIndex on the title index before dropColumn.
|
||||
Schema::table('documents', function (Blueprint $table) {
|
||||
$table->dropIndex(['title']);
|
||||
$table->dropColumn(['title', 'content', 'rendered_html']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use App\Models\Document;
|
||||
use App\Services\DocumentService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class DocumentTranslationController extends Controller
|
||||
{
|
||||
public function __construct(private DocumentService $service) {}
|
||||
|
||||
public function store(Request $request, Document $document)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'locale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service->addTranslation(
|
||||
$document,
|
||||
$validated['locale'],
|
||||
$validated['title'],
|
||||
$validated['content'],
|
||||
Auth::id(),
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return redirect()->route('documents.show', $document)
|
||||
->with('message', __('messages.documents.translation_added'));
|
||||
}
|
||||
|
||||
public function destroy(Document $document, string $locale)
|
||||
{
|
||||
if (!array_key_exists($locale, SetLocale::SUPPORTED_LOCALES)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service->deleteTranslation($document, $locale);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return redirect()->route('documents.show', $document)
|
||||
->with('message', __('messages.documents.translation_deleted'));
|
||||
}
|
||||
}
|
||||
@@ -2,30 +2,44 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use App\Models\Document;
|
||||
use App\Services\DocumentService;
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class DocumentEditor extends Component
|
||||
{
|
||||
public ?Document $document = null;
|
||||
public $title = '';
|
||||
public $content = '';
|
||||
public $directory = '';
|
||||
public $isEditMode = false;
|
||||
public string $title = '';
|
||||
public string $content = '';
|
||||
public string $editingLocale = '';
|
||||
public bool $isEditMode = false;
|
||||
public bool $isNewLocale = false;
|
||||
public array $availableLocales = [];
|
||||
|
||||
public function mount(?Document $document = null)
|
||||
public function mount(?Document $document = null, ?string $locale = null)
|
||||
{
|
||||
if ($document) {
|
||||
$this->authorize('update', $document);
|
||||
|
||||
$this->document = $document;
|
||||
$this->title = $document->title;
|
||||
$this->content = $document->content;
|
||||
$this->directory = $document->directory;
|
||||
$this->document = $document->load('translations');
|
||||
$this->isEditMode = true;
|
||||
$this->availableLocales = $document->availableLocales();
|
||||
$this->editingLocale = $locale ?: ($document->default_locale ?? App::getLocale());
|
||||
|
||||
$translation = $document->translations->firstWhere('locale', $this->editingLocale);
|
||||
if ($translation) {
|
||||
$this->title = $translation->title;
|
||||
$this->content = $translation->content;
|
||||
$this->isNewLocale = false;
|
||||
} else {
|
||||
$this->title = '';
|
||||
$this->content = '';
|
||||
$this->isNewLocale = true;
|
||||
}
|
||||
} else {
|
||||
$this->editingLocale = App::getLocale();
|
||||
$titleParam = request()->query('title');
|
||||
if ($titleParam) {
|
||||
$this->title = $titleParam;
|
||||
@@ -35,53 +49,96 @@ public function mount(?Document $document = null)
|
||||
|
||||
public function save(DocumentService $documentService)
|
||||
{
|
||||
$this->validate([
|
||||
$validated = $this->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'content' => 'required|string',
|
||||
'editingLocale' => ['required', 'string', 'in:' . implode(',', array_keys(SetLocale::SUPPORTED_LOCALES))],
|
||||
]);
|
||||
|
||||
try {
|
||||
if ($this->isEditMode && $this->document) {
|
||||
$this->authorize('update', $this->document);
|
||||
|
||||
$this->document = $documentService->updateDocument(
|
||||
$this->document,
|
||||
$this->title,
|
||||
$this->content,
|
||||
Auth::id()
|
||||
);
|
||||
if ($this->isNewLocale) {
|
||||
$documentService->addTranslation(
|
||||
$this->document,
|
||||
$this->editingLocale,
|
||||
$this->title,
|
||||
$this->content,
|
||||
Auth::id(),
|
||||
);
|
||||
$this->document->refresh()->load('translations');
|
||||
} else {
|
||||
$this->document = $documentService->updateDocument(
|
||||
$this->document,
|
||||
$this->title,
|
||||
$this->content,
|
||||
Auth::id(),
|
||||
$this->editingLocale,
|
||||
);
|
||||
}
|
||||
|
||||
session()->flash('message', 'Document updated successfully!');
|
||||
session()->flash('message', __('messages.documents.update_success'));
|
||||
return $this->redirect(route('documents.show', $this->document));
|
||||
} else {
|
||||
$this->document = $documentService->createDocument(
|
||||
$this->title,
|
||||
$this->content,
|
||||
Auth::id(),
|
||||
$this->directory ?: null
|
||||
$this->editingLocale,
|
||||
);
|
||||
|
||||
session()->flash('message', 'Document created successfully!');
|
||||
session()->flash('message', __('messages.documents.create_success'));
|
||||
return $this->redirect(route('documents.show', $this->document));
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
} catch (\Exception $e) {
|
||||
session()->flash('error', 'Error saving document: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteTranslation(DocumentService $documentService)
|
||||
{
|
||||
if (!$this->isEditMode || !$this->document || $this->isNewLocale) {
|
||||
return;
|
||||
}
|
||||
$this->authorize('update', $this->document);
|
||||
|
||||
try {
|
||||
$documentService->deleteTranslation($this->document, $this->editingLocale);
|
||||
session()->flash('message', __('messages.documents.translation_deleted'));
|
||||
return $this->redirect(route('documents.show', $this->document));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function setAsDefault(DocumentService $documentService)
|
||||
{
|
||||
if (!$this->isEditMode || !$this->document) {
|
||||
return;
|
||||
}
|
||||
$this->authorize('update', $this->document);
|
||||
|
||||
try {
|
||||
$this->document = $documentService->setDefaultLocale($this->document, $this->editingLocale);
|
||||
session()->flash('message', __('messages.documents.update_success'));
|
||||
return $this->redirect(route('documents.show', $this->document));
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(DocumentService $documentService)
|
||||
{
|
||||
if (!$this->isEditMode || !$this->document) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->authorize('delete', $this->document);
|
||||
|
||||
try {
|
||||
$documentService->deleteDocument($this->document);
|
||||
session()->flash('message', 'Document deleted successfully!');
|
||||
|
||||
// Try to redirect to home document, or root if not found
|
||||
session()->flash('message', __('messages.documents.delete_success'));
|
||||
$homeDocument = Document::where('slug', 'home')->first();
|
||||
if ($homeDocument) {
|
||||
return redirect()->route('documents.show', $homeDocument);
|
||||
@@ -96,7 +153,9 @@ public function render()
|
||||
{
|
||||
return view('livewire.document-editor')
|
||||
->layout('layouts.knowledge-base', [
|
||||
'title' => $this->isEditMode ? 'Edit: ' . $this->title : 'New Document'
|
||||
'title' => $this->isEditMode
|
||||
? __('messages.documents.edit_document') . ': ' . $this->title
|
||||
: __('messages.documents.new_document'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,32 @@
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Services\DocumentService;
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Component;
|
||||
|
||||
class DocumentViewer extends Component
|
||||
{
|
||||
public Document $document;
|
||||
public $backlinks = [];
|
||||
public $renderedContent = '';
|
||||
public string $renderedContent = '';
|
||||
public string $viewLocale = '';
|
||||
public bool $isFallback = false;
|
||||
|
||||
public function mount(Document $document, DocumentService $documentService)
|
||||
{
|
||||
$this->document = $document;
|
||||
$this->document = $document->load('translations');
|
||||
|
||||
$this->renderedContent = $this->document->processLinks();
|
||||
$current = App::getLocale();
|
||||
$translation = $document->translationFor($current, fallback: true);
|
||||
|
||||
$this->backlinks = $documentService->getBacklinks($this->document);
|
||||
$this->viewLocale = $translation?->locale ?? $document->default_locale;
|
||||
$this->isFallback = ($current !== $this->viewLocale);
|
||||
$this->renderedContent = $document->processLinks();
|
||||
$this->backlinks = $documentService->getBacklinks($document);
|
||||
|
||||
if (Auth::check()) {
|
||||
$documentService->recordDocumentAccess($this->document, Auth::id());
|
||||
$documentService->recordDocumentAccess($document, Auth::id());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,65 +4,32 @@
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Services\DocumentService;
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class QuickSwitcher extends Component
|
||||
{
|
||||
public $search = '';
|
||||
public $selectedIndex = 0;
|
||||
public string $search = '';
|
||||
public int $selectedIndex = 0;
|
||||
|
||||
#[Computed]
|
||||
public function results()
|
||||
{
|
||||
if (empty($this->search)) {
|
||||
return Document::select('id', 'title', 'slug', 'path', 'updated_at')
|
||||
$documents = Document::with('translations')
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn($doc) => [
|
||||
'id' => $doc->id,
|
||||
'title' => $doc->title,
|
||||
'slug' => $doc->slug,
|
||||
'directory' => dirname($doc->path),
|
||||
])
|
||||
->toArray();
|
||||
->get();
|
||||
} else {
|
||||
$documents = app(DocumentService::class)->search($this->search, 10);
|
||||
}
|
||||
|
||||
// FULLTEXT検索を使用(日本語対応)
|
||||
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at')
|
||||
->whereRaw('MATCH(title, content) AGAINST(? IN BOOLEAN MODE)', [$this->search])
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn($doc) => [
|
||||
'id' => $doc->id,
|
||||
'title' => $doc->title,
|
||||
'slug' => $doc->slug,
|
||||
'directory' => dirname($doc->path),
|
||||
])
|
||||
->toArray();
|
||||
|
||||
// FULLTEXT検索で結果がない場合は LIKE 検索にフォールバック
|
||||
if (empty($results)) {
|
||||
$results = Document::select('id', 'title', 'slug', 'path', 'updated_at')
|
||||
->where(function($query) {
|
||||
$query->where('title', 'like', '%' . $this->search . '%')
|
||||
->orWhere('content', 'like', '%' . $this->search . '%');
|
||||
})
|
||||
->orderBy('updated_at', 'desc')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(fn($doc) => [
|
||||
'id' => $doc->id,
|
||||
'title' => $doc->title,
|
||||
'slug' => $doc->slug,
|
||||
'directory' => dirname($doc->path),
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
return $results;
|
||||
return $documents->map(fn ($doc) => [
|
||||
'id' => $doc->id,
|
||||
'title' => $doc->title,
|
||||
'slug' => $doc->slug,
|
||||
'directory' => dirname($doc->path),
|
||||
])->values()->toArray();
|
||||
}
|
||||
|
||||
public function updated($propertyName)
|
||||
@@ -92,8 +59,6 @@ public function selectDocument()
|
||||
$results = $this->results;
|
||||
if (isset($results[$this->selectedIndex])) {
|
||||
$document = $results[$this->selectedIndex];
|
||||
|
||||
// slug が存在することを確認
|
||||
if (!empty($document['slug'])) {
|
||||
return $this->redirect(route('documents.show', $document['slug']));
|
||||
}
|
||||
|
||||
+139
-227
@@ -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,142 @@ 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 . '%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract [[wiki-links]] from the default-locale translation's content
|
||||
* and persist them via DocumentLink.
|
||||
*/
|
||||
public function syncLinks(): void
|
||||
{
|
||||
$this->outgoingLinks()->delete();
|
||||
|
||||
$translation = $this->translationFor($this->default_locale, fallback: false);
|
||||
if (!$translation || !$translation->content) {
|
||||
return;
|
||||
}
|
||||
|
||||
preg_match_all('/\[\[([^\]]+)\]\]/', $translation->content, $matches);
|
||||
if (empty($matches[1])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$resolver = new \App\Services\WikiLinkResolver();
|
||||
$position = 0;
|
||||
foreach ($matches[1] as $linkTitle) {
|
||||
$linkTitle = trim($linkTitle);
|
||||
$target = $resolver->resolve($linkTitle, $this->default_locale);
|
||||
|
||||
DocumentLink::create([
|
||||
'source_document_id' => $this->id,
|
||||
'target_document_id' => $target?->id,
|
||||
'target_title' => $linkTitle,
|
||||
'position' => $position++,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert [[wiki-links]] in the current-locale rendered_html to anchor tags.
|
||||
* Link labels stay in the original language; the destination document is
|
||||
* resolved against the current locale (with fallback).
|
||||
*/
|
||||
public function processLinks(): string
|
||||
{
|
||||
$html = $this->rendered_html ?? '';
|
||||
if ($html === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$resolver = new \App\Services\WikiLinkResolver();
|
||||
$currentLocale = App::getLocale();
|
||||
|
||||
return preg_replace_callback(
|
||||
'/\[\[([^\]]+)\]\]/',
|
||||
function ($matches) use ($resolver, $currentLocale) {
|
||||
$linkText = trim($matches[1]);
|
||||
$target = $resolver->resolve($linkText, $currentLocale);
|
||||
|
||||
if ($target) {
|
||||
return '<a href="' . route('documents.show', $target->slug) . '" class="wiki-link">' . e($linkText) . '</a>';
|
||||
}
|
||||
|
||||
return '<a href="' . route('documents.create') . '?title=' . urlencode($linkText) . '" class="wiki-link wiki-link-new">' . e($linkText) . '</a>';
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Helpers\SlugHelper;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentTranslation;
|
||||
|
||||
class WikiLinkResolver
|
||||
{
|
||||
/**
|
||||
* Resolve [[wiki-link]] text to a Document, preferring the current locale.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. translations WHERE locale = $currentLocale AND title = $linkText
|
||||
* 2. translations WHERE locale = document.default_locale AND title = $linkText
|
||||
* 3. translations WHERE title = $linkText (lowest document_id wins)
|
||||
* 4. documents WHERE slug = SlugHelper::generate($linkText)
|
||||
* 5. null
|
||||
*/
|
||||
public function resolve(string $linkText, string $currentLocale): ?Document
|
||||
{
|
||||
$linkText = trim($linkText);
|
||||
|
||||
// 1. Current-locale exact title match
|
||||
$byCurrent = DocumentTranslation::where('locale', $currentLocale)
|
||||
->where('title', $linkText)
|
||||
->orderBy('document_id')
|
||||
->first();
|
||||
if ($byCurrent) {
|
||||
return $byCurrent->document;
|
||||
}
|
||||
|
||||
// 2. Document's default-locale title match
|
||||
$byDefault = DocumentTranslation::query()
|
||||
->join('documents', 'documents.id', '=', 'document_translations.document_id')
|
||||
->whereColumn('document_translations.locale', 'documents.default_locale')
|
||||
->where('document_translations.title', $linkText)
|
||||
->orderBy('document_translations.document_id')
|
||||
->select('document_translations.*')
|
||||
->first();
|
||||
if ($byDefault) {
|
||||
return $byDefault->document;
|
||||
}
|
||||
|
||||
// 3. Any-locale title match (lowest document_id wins)
|
||||
$byAny = DocumentTranslation::where('title', $linkText)
|
||||
->orderBy('document_id')
|
||||
->first();
|
||||
if ($byAny) {
|
||||
return $byAny->document;
|
||||
}
|
||||
|
||||
// 4. Slug match (legacy)
|
||||
$slug = SlugHelper::generate($linkText);
|
||||
$bySlug = Document::where('slug', $slug)->first();
|
||||
if ($bySlug) {
|
||||
return $bySlug;
|
||||
}
|
||||
|
||||
// 5. Nothing
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Document;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DocumentSeeder extends Seeder
|
||||
@@ -12,43 +11,23 @@ class DocumentSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// 既存のドキュメントがある場合はスキップ
|
||||
if (Document::count() > 0) {
|
||||
if (\App\Models\Document::count() > 0) {
|
||||
$this->command->info('Documents already exist. Skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
$documents = [
|
||||
[
|
||||
'title' => 'Home',
|
||||
'path' => 'Home.md',
|
||||
'slug' => 'home',
|
||||
'content' => $this->getHomeContent(),
|
||||
],
|
||||
[
|
||||
'title' => 'Getting Started',
|
||||
'path' => 'Getting Started.md',
|
||||
'slug' => 'getting-started',
|
||||
'content' => $this->getGettingStartedContent(),
|
||||
],
|
||||
[
|
||||
'title' => 'Markdown Guide',
|
||||
'path' => 'Markdown Guide.md',
|
||||
'slug' => 'markdown-guide',
|
||||
'content' => $this->getMarkdownGuideContent(),
|
||||
],
|
||||
$service = app(\App\Services\DocumentService::class);
|
||||
$defaultLocale = config('app.locale', 'en');
|
||||
|
||||
$docs = [
|
||||
['title' => 'Home', 'content' => $this->getHomeContent()],
|
||||
['title' => 'Getting Started', 'content' => $this->getGettingStartedContent()],
|
||||
['title' => 'Markdown Guide', 'content' => $this->getMarkdownGuideContent()],
|
||||
];
|
||||
|
||||
foreach ($documents as $doc) {
|
||||
Document::create([
|
||||
'title' => $doc['title'],
|
||||
'path' => $doc['path'],
|
||||
'slug' => $doc['slug'],
|
||||
'content' => $doc['content'],
|
||||
'rendered_html' => Document::renderMarkdown($doc['content']),
|
||||
]);
|
||||
|
||||
$this->command->info("Created: {$doc['title']}");
|
||||
foreach ($docs as $d) {
|
||||
$service->createDocument($d['title'], $d['content'], null, $defaultLocale);
|
||||
$this->command->info("Created: {$d['title']}");
|
||||
}
|
||||
|
||||
$this->command->info('Initial documents created successfully!');
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'Inhalt',
|
||||
'content_placeholder' => 'Schreiben Sie hier Ihren Markdown...',
|
||||
'saving' => 'Speichern...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -123,6 +131,25 @@
|
||||
'back_to_home' => 'Zurück zur Startseite',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Profil',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'Content',
|
||||
'content_placeholder' => 'Write your markdown here...',
|
||||
'saving' => 'Saving...',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -123,6 +131,25 @@
|
||||
'back_to_home' => 'Back to Home',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Profile',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'Contenido',
|
||||
'content_placeholder' => 'Escriba su markdown aquí...',
|
||||
'saving' => 'Guardando...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -123,6 +131,25 @@
|
||||
'back_to_home' => 'Volver al inicio',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Perfil',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'Contenu',
|
||||
'content_placeholder' => 'Écrivez votre markdown ici...',
|
||||
'saving' => 'Enregistrement...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -123,6 +131,25 @@
|
||||
'back_to_home' => 'Retour à l\'accueil',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Profil',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'सामग्री',
|
||||
'content_placeholder' => 'यहां अपना मार्कडाउन लिखें...',
|
||||
'saving' => 'सहेजा जा रहा है...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -124,6 +132,25 @@
|
||||
'back_to_home' => 'होम पर वापस जाएं',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'प्रोफ़ाइल',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'Contenuto',
|
||||
'content_placeholder' => 'Scrivi il tuo markdown qui...',
|
||||
'saving' => 'Salvataggio...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -124,6 +132,25 @@
|
||||
'back_to_home' => 'Torna alla Home',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Profilo',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => '本文',
|
||||
'content_placeholder' => 'Markdownで記述してください...',
|
||||
'saving' => '保存中...',
|
||||
'translation_added' => '翻訳を追加しました。',
|
||||
'translation_deleted' => '翻訳を削除しました。',
|
||||
'fallback_notice' => 'この記事には選択した言語の翻訳がありません。元の言語版を表示しています。',
|
||||
'add_translation' => '翻訳を追加',
|
||||
'set_as_default' => 'デフォルトに設定',
|
||||
'delete_translation' => '翻訳を削除',
|
||||
'delete_translation_blocked' => 'デフォルト言語の翻訳は削除できません。',
|
||||
'translation_tabs_label' => '言語',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -123,6 +131,14 @@
|
||||
'back_to_home' => 'ホームに戻る',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => '英語', 'ja' => '日本語',
|
||||
'zh-CN' => '簡体字中国語', 'zh-TW' => '繁体字中国語',
|
||||
'ko' => '韓国語', 'hi' => 'ヒンディー語', 'vi' => 'ベトナム語', 'tr' => 'トルコ語',
|
||||
'de' => 'ドイツ語', 'fr' => 'フランス語', 'es' => 'スペイン語', 'pt-BR' => 'ポルトガル語(ブラジル)',
|
||||
'ru' => 'ロシア語', 'uk' => 'ウクライナ語', 'it' => 'イタリア語', 'pl' => 'ポーランド語',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'プロフィール',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => '내용',
|
||||
'content_placeholder' => '여기에 마크다운을 작성하세요...',
|
||||
'saving' => '저장 중...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -123,6 +131,25 @@
|
||||
'back_to_home' => '홈으로 돌아가기',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => '프로필',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'Treść',
|
||||
'content_placeholder' => 'Napisz swój markdown tutaj...',
|
||||
'saving' => 'Zapisywanie...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -124,6 +132,25 @@
|
||||
'back_to_home' => 'Wróć do strony głównej',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Profil',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'Conteúdo',
|
||||
'content_placeholder' => 'Escreva seu markdown aqui...',
|
||||
'saving' => 'Salvando...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -124,6 +132,25 @@
|
||||
'back_to_home' => 'Voltar para Início',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Perfil',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'Содержимое',
|
||||
'content_placeholder' => 'Напишите здесь ваш markdown...',
|
||||
'saving' => 'Сохранение...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -124,6 +132,25 @@
|
||||
'back_to_home' => 'Вернуться на главную',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Профиль',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'İçerik',
|
||||
'content_placeholder' => 'Markdown\'ınızı buraya yazın...',
|
||||
'saving' => 'Kaydediliyor...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -124,6 +132,25 @@
|
||||
'back_to_home' => 'Ana Sayfaya Dön',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Profil',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'Вміст',
|
||||
'content_placeholder' => 'Напишіть тут ваш markdown...',
|
||||
'saving' => 'Збереження...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -124,6 +132,25 @@
|
||||
'back_to_home' => 'Повернутися на головну',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Профіль',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => 'Nội dung',
|
||||
'content_placeholder' => 'Viết markdown của bạn ở đây...',
|
||||
'saving' => 'Đang lưu...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -124,6 +132,25 @@
|
||||
'back_to_home' => 'Quay lại trang chủ',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => 'Hồ sơ',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => '内容',
|
||||
'content_placeholder' => '在此输入Markdown内容...',
|
||||
'saving' => '保存中...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -123,6 +131,25 @@
|
||||
'back_to_home' => '返回首页',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => '个人资料',
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
'content_label' => '內容',
|
||||
'content_placeholder' => '在此輸入Markdown內容...',
|
||||
'saving' => '儲存中...',
|
||||
'fallback_notice' => 'A translation in your selected language is not available. Showing the :locale version.',
|
||||
'add_translation' => 'Add translation',
|
||||
'translation_added' => 'Translation added.',
|
||||
'translation_deleted' => 'Translation deleted.',
|
||||
'set_as_default' => 'Set as default',
|
||||
'delete_translation' => 'Delete translation',
|
||||
'delete_translation_blocked' => 'The default-language translation cannot be deleted.',
|
||||
'translation_tabs_label' => 'Languages',
|
||||
],
|
||||
|
||||
// Quick Switcher
|
||||
@@ -123,6 +131,25 @@
|
||||
'back_to_home' => '返回首頁',
|
||||
],
|
||||
|
||||
'locale_names' => [
|
||||
'en' => 'English',
|
||||
'ja' => 'Japanese',
|
||||
'zh-CN' => 'Simplified Chinese',
|
||||
'zh-TW' => 'Traditional Chinese',
|
||||
'ko' => 'Korean',
|
||||
'hi' => 'Hindi',
|
||||
'vi' => 'Vietnamese',
|
||||
'tr' => 'Turkish',
|
||||
'de' => 'German',
|
||||
'fr' => 'French',
|
||||
'es' => 'Spanish',
|
||||
'pt-BR' => 'Portuguese (Brazil)',
|
||||
'ru' => 'Russian',
|
||||
'uk' => 'Ukrainian',
|
||||
'it' => 'Italian',
|
||||
'pl' => 'Polish',
|
||||
],
|
||||
|
||||
// Profile
|
||||
'profile' => [
|
||||
'title' => '個人資料',
|
||||
|
||||
@@ -56,6 +56,65 @@ class="inline-flex items-center justify-center px-3 sm:px-4 py-2 bg-indigo-600 b
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($isEditMode && $document)
|
||||
<div class="mb-4 border-b border-gray-200" role="tablist" aria-label="{{ __('messages.documents.translation_tabs_label') }}">
|
||||
<nav class="-mb-px flex flex-wrap gap-x-2">
|
||||
@php $allLocales = \App\Http\Middleware\SetLocale::SUPPORTED_LOCALES; @endphp
|
||||
@foreach($availableLocales as $loc)
|
||||
@php $isActive = ($loc === $editingLocale); @endphp
|
||||
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => $loc]) }}"
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 {{ $isActive ? 'border-indigo-500 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}">
|
||||
{{ __('messages.locale_names.' . $loc, [], 'en') }}
|
||||
@if($loc === $document->default_locale)
|
||||
<span class="ml-1 text-xs text-gray-400">★</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
@if($isNewLocale && $editingLocale)
|
||||
<span class="px-3 py-2 text-sm font-medium border-b-2 border-indigo-500 text-indigo-600">
|
||||
{{ __('messages.locale_names.' . $editingLocale, [], 'en') }}
|
||||
<span class="ml-1 text-xs text-gray-400">({{ __('messages.documents.new_document') }})</span>
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@php $missingLocales = array_diff(array_keys($allLocales), $availableLocales, $isNewLocale ? [$editingLocale] : []); @endphp
|
||||
@if(!empty($missingLocales))
|
||||
<div x-data="{ open: false }" class="relative">
|
||||
<button type="button" @click="open = !open"
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
+ {{ __('messages.documents.add_translation') }}
|
||||
</button>
|
||||
<div x-show="open" @click.outside="open = false" x-cloak
|
||||
class="absolute right-0 z-10 mt-2 w-48 max-h-64 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
|
||||
@foreach($missingLocales as $loc)
|
||||
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => $loc]) }}"
|
||||
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
{{ $allLocales[$loc] }}
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</nav>
|
||||
|
||||
@if($editingLocale !== $document->default_locale && !$isNewLocale)
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button wire:click="setAsDefault" type="button"
|
||||
class="px-2 py-1 text-xs text-indigo-600 border border-indigo-300 rounded hover:bg-indigo-50">
|
||||
{{ __('messages.documents.set_as_default') }}
|
||||
</button>
|
||||
<button wire:click="deleteTranslation"
|
||||
wire:confirm="{{ __('messages.documents.delete_confirm') }}"
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50">
|
||||
{{ __('messages.documents.delete_translation') }}
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Form -->
|
||||
<form wire:submit.prevent="save" class="space-y-6">
|
||||
<!-- Title -->
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
<div class="max-w-4xl mx-auto p-4 sm:p-6 lg:p-8">
|
||||
@if($isFallback)
|
||||
<div class="mb-4 p-4 bg-amber-50 border border-amber-300 rounded-md flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
{{ __('messages.documents.fallback_notice', ['locale' => __('messages.locale_names.' . $viewLocale)]) }}
|
||||
</p>
|
||||
@auth
|
||||
@can('update', $document)
|
||||
<a href="{{ route('documents.translations.edit', ['document' => $document, 'locale' => app()->getLocale()]) }}"
|
||||
class="inline-flex items-center justify-center px-3 py-2 bg-amber-600 text-white text-sm font-medium rounded-md hover:bg-amber-700">
|
||||
{{ __('messages.documents.add_translation') }}
|
||||
</a>
|
||||
@endcan
|
||||
@endauth
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Document Header -->
|
||||
<div class="mb-6 sm:mb-8">
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-4">
|
||||
|
||||
@@ -47,6 +47,15 @@
|
||||
Route::get('/{document}/edit', DocumentEditor::class)
|
||||
->middleware('can:update,document')
|
||||
->name('edit');
|
||||
Route::post('/{document}/translations', [\App\Http\Controllers\DocumentTranslationController::class, 'store'])
|
||||
->middleware('can:update,document')
|
||||
->name('translations.store');
|
||||
Route::delete('/{document}/translations/{locale}', [\App\Http\Controllers\DocumentTranslationController::class, 'destroy'])
|
||||
->middleware('can:update,document')
|
||||
->name('translations.destroy');
|
||||
Route::get('/{document}/translations/{locale}/edit', \App\Livewire\DocumentEditor::class)
|
||||
->middleware('can:update,document')
|
||||
->name('translations.edit');
|
||||
});
|
||||
|
||||
// 公開ルート(動的ルートは最後に)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentTranslation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DocumentI18nTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_viewer_shows_current_locale_translation(): void
|
||||
{
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'hello']);
|
||||
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'Hi', 'rendered_html' => '<p>Hi</p>']);
|
||||
DocumentTranslation::factory()->create([
|
||||
'document_id' => $doc->id,
|
||||
'locale' => 'ja',
|
||||
'title' => 'こんにちは',
|
||||
'content' => 'やあ',
|
||||
'rendered_html' => '<p>やあ</p>',
|
||||
]);
|
||||
|
||||
session()->put('locale', 'ja');
|
||||
$response = $this->get(route('documents.show', $doc));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('こんにちは');
|
||||
$response->assertSee('やあ', false);
|
||||
}
|
||||
|
||||
public function test_viewer_falls_back_with_banner_when_locale_missing(): void
|
||||
{
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'fb']);
|
||||
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'Hi', 'rendered_html' => '<p>Hi</p>']);
|
||||
|
||||
session()->put('locale', 'ja');
|
||||
$response = $this->get(route('documents.show', $doc));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('Hello'); // fallback content
|
||||
// banner present (use the JA translation key value)
|
||||
$response->assertSeeText(__('messages.documents.fallback_notice', [], 'ja'));
|
||||
}
|
||||
|
||||
public function test_no_banner_when_translation_exists(): void
|
||||
{
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'nb']);
|
||||
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'Hi', 'rendered_html' => '<p>Hi</p>']);
|
||||
DocumentTranslation::factory()->create([
|
||||
'document_id' => $doc->id,
|
||||
'locale' => 'ja',
|
||||
'title' => 'こんにちは',
|
||||
'content' => 'やあ',
|
||||
'rendered_html' => '<p>やあ</p>',
|
||||
]);
|
||||
|
||||
session()->put('locale', 'ja');
|
||||
$response = $this->get(route('documents.show', $doc));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertDontSeeText(__('messages.documents.fallback_notice', [], 'ja'));
|
||||
}
|
||||
|
||||
public function test_editor_loads_existing_translation_for_locale(): void
|
||||
{
|
||||
$owner = \App\Models\User::factory()->create();
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id, 'slug' => 'editor']);
|
||||
$doc->translations()->where('locale', 'en')->update(['title' => 'Hello', 'content' => 'EN body']);
|
||||
DocumentTranslation::factory()->create([
|
||||
'document_id' => $doc->id,
|
||||
'locale' => 'ja',
|
||||
'title' => 'こんにちは',
|
||||
'content' => 'JA body',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($owner)->get(route('documents.translations.edit', [
|
||||
'document' => $doc,
|
||||
'locale' => 'ja',
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('こんにちは');
|
||||
$response->assertSee('JA body');
|
||||
}
|
||||
|
||||
public function test_editor_for_missing_locale_shows_empty_form_with_new_locale_state(): void
|
||||
{
|
||||
$owner = \App\Models\User::factory()->create();
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id, 'slug' => 'editor2']);
|
||||
|
||||
$response = $this->actingAs($owner)->get(route('documents.translations.edit', [
|
||||
'document' => $doc,
|
||||
'locale' => 'ja',
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
// The blade should render a tab marked active for ja with empty inputs
|
||||
$response->assertSeeText(__('messages.locale_names.ja', [], 'en'));
|
||||
}
|
||||
|
||||
public function test_quick_switcher_finds_documents_by_any_locale_title(): void
|
||||
{
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'qs']);
|
||||
$doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started', 'content' => 'EN body']);
|
||||
DocumentTranslation::factory()->create([
|
||||
'document_id' => $doc->id,
|
||||
'locale' => 'ja',
|
||||
'title' => 'はじめに',
|
||||
'content' => '本文',
|
||||
]);
|
||||
|
||||
$component = \Livewire\Livewire::test(\App\Livewire\QuickSwitcher::class)
|
||||
->set('search', 'はじめに');
|
||||
|
||||
$results = $component->get('results');
|
||||
$this->assertCount(1, $results);
|
||||
$this->assertSame($doc->id, $results[0]['id']);
|
||||
|
||||
$component2 = \Livewire\Livewire::test(\App\Livewire\QuickSwitcher::class)
|
||||
->set('search', 'Getting');
|
||||
$results2 = $component2->get('results');
|
||||
$this->assertCount(1, $results2);
|
||||
$this->assertSame($doc->id, $results2[0]['id']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DocumentTranslationCrudTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_owner_can_add_a_translation(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
|
||||
|
||||
$response = $this->actingAs($owner)->post(
|
||||
route('documents.translations.store', $doc),
|
||||
['locale' => 'ja', 'title' => 'こんにちは', 'content' => '本文']
|
||||
);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertNotNull($doc->fresh()->translationFor('ja', false));
|
||||
}
|
||||
|
||||
public function test_non_owner_cannot_add_translation(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
|
||||
|
||||
$response = $this->actingAs($other)->post(
|
||||
route('documents.translations.store', $doc),
|
||||
['locale' => 'ja', 'title' => 'こんにちは', 'content' => '本文']
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_invalid_locale_is_rejected(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
|
||||
|
||||
$response = $this->actingAs($owner)->post(
|
||||
route('documents.translations.store', $doc),
|
||||
['locale' => 'xx', 'title' => 'X', 'content' => 'Y']
|
||||
);
|
||||
|
||||
$response->assertSessionHasErrors('locale');
|
||||
}
|
||||
|
||||
public function test_duplicate_locale_returns_422(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
|
||||
|
||||
$response = $this->actingAs($owner)->post(
|
||||
route('documents.translations.store', $doc),
|
||||
['locale' => 'en', 'title' => 'X', 'content' => 'Y']
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_owner_can_delete_non_default_translation(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
|
||||
app(\App\Services\DocumentService::class)->addTranslation($doc, 'ja', 'JA', 'body', $owner->id);
|
||||
|
||||
$response = $this->actingAs($owner)->delete(
|
||||
route('documents.translations.destroy', ['document' => $doc, 'locale' => 'ja'])
|
||||
);
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertNull($doc->fresh()->translationFor('ja', false));
|
||||
}
|
||||
|
||||
public function test_default_locale_translation_cannot_be_deleted(): void
|
||||
{
|
||||
$owner = User::factory()->create();
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'created_by' => $owner->id]);
|
||||
|
||||
$response = $this->actingAs($owner)->delete(
|
||||
route('documents.translations.destroy', ['document' => $doc, 'locale' => 'en'])
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$this->assertNotNull($doc->fresh()->translationFor('en', false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
public function test_sync_links_creates_outgoing_links_with_resolved_targets(): void
|
||||
{
|
||||
$target = Document::factory()->create(['default_locale' => 'en']);
|
||||
$target->translations()->where('locale', 'en')->update(['title' => 'Target']);
|
||||
|
||||
$source = Document::factory()->create(['default_locale' => 'en']);
|
||||
$source->translations()->where('locale', 'en')->update([
|
||||
'content' => 'See [[Target]] for details.',
|
||||
]);
|
||||
|
||||
$source->fresh('translations')->syncLinks();
|
||||
|
||||
$links = $source->fresh()->outgoingLinks;
|
||||
$this->assertCount(1, $links);
|
||||
$this->assertSame($target->id, $links->first()->target_document_id);
|
||||
$this->assertSame('Target', $links->first()->target_title);
|
||||
}
|
||||
|
||||
public function test_sync_links_records_unresolved_links_with_null_target(): void
|
||||
{
|
||||
$source = Document::factory()->create();
|
||||
$source->translations()->first()->update([
|
||||
'content' => 'Goes to [[NoSuchPage]].',
|
||||
]);
|
||||
|
||||
$source->fresh('translations')->syncLinks();
|
||||
|
||||
$links = $source->fresh()->outgoingLinks;
|
||||
$this->assertCount(1, $links);
|
||||
$this->assertNull($links->first()->target_document_id);
|
||||
}
|
||||
|
||||
public function test_process_links_replaces_wiki_link_with_anchor_keeping_label(): void
|
||||
{
|
||||
$target = Document::factory()->create(['default_locale' => 'en', 'slug' => 'target-doc']);
|
||||
$target->translations()->where('locale', 'en')->update(['title' => 'Target']);
|
||||
\Illuminate\Support\Facades\App::setLocale('ja');
|
||||
\App\Models\DocumentTranslation::factory()->create([
|
||||
'document_id' => $target->id,
|
||||
'locale' => 'ja',
|
||||
'title' => 'ターゲット',
|
||||
]);
|
||||
|
||||
$source = Document::factory()->create();
|
||||
$source->translations()->first()->update([
|
||||
'rendered_html' => '<p>See [[Target]].</p>',
|
||||
]);
|
||||
|
||||
$html = $source->fresh()->processLinks();
|
||||
|
||||
$this->assertStringContainsString('href="' . route('documents.show', 'target-doc') . '"', $html);
|
||||
$this->assertStringContainsString('>Target<', $html); // label preserved
|
||||
$this->assertStringContainsString('class="wiki-link"', $html);
|
||||
}
|
||||
|
||||
public function test_process_links_marks_unresolved_links_as_new(): void
|
||||
{
|
||||
$source = Document::factory()->create();
|
||||
$source->translations()->first()->update([
|
||||
'rendered_html' => '<p>Click [[Ghost]].</p>',
|
||||
]);
|
||||
|
||||
$html = $source->fresh()->processLinks();
|
||||
$this->assertStringContainsString('wiki-link-new', $html);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentTranslation;
|
||||
use App\Services\WikiLinkResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WikiLinkResolverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private WikiLinkResolver $resolver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->resolver = new WikiLinkResolver();
|
||||
}
|
||||
|
||||
public function test_resolves_via_current_locale_title(): void
|
||||
{
|
||||
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'getting-started']);
|
||||
$doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started']);
|
||||
DocumentTranslation::factory()->create([
|
||||
'document_id' => $doc->id,
|
||||
'locale' => 'ja',
|
||||
'title' => 'はじめに',
|
||||
]);
|
||||
|
||||
$resolved = $this->resolver->resolve('はじめに', 'ja');
|
||||
$this->assertSame($doc->id, $resolved->id);
|
||||
}
|
||||
|
||||
public function test_resolves_via_default_locale_when_current_locale_missing(): void
|
||||
{
|
||||
$doc = Document::factory()->create(['default_locale' => 'en']);
|
||||
$doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started']);
|
||||
|
||||
$resolved = $this->resolver->resolve('Getting Started', 'ja');
|
||||
$this->assertSame($doc->id, $resolved->id);
|
||||
}
|
||||
|
||||
public function test_resolves_via_any_locale_deterministically(): void
|
||||
{
|
||||
// Two documents both have a 'fr' translation titled "Bonjour", neither is current/default
|
||||
$docA = Document::factory()->create(['default_locale' => 'en']);
|
||||
DocumentTranslation::factory()->create(['document_id' => $docA->id, 'locale' => 'fr', 'title' => 'Bonjour']);
|
||||
|
||||
$docB = Document::factory()->create(['default_locale' => 'en']);
|
||||
DocumentTranslation::factory()->create(['document_id' => $docB->id, 'locale' => 'fr', 'title' => 'Bonjour']);
|
||||
|
||||
$resolved = $this->resolver->resolve('Bonjour', 'ja');
|
||||
// Lower id wins (deterministic)
|
||||
$this->assertSame($docA->id, $resolved->id);
|
||||
}
|
||||
|
||||
public function test_resolves_via_slug_when_no_title_match(): void
|
||||
{
|
||||
$doc = Document::factory()->create(['slug' => 'unique-slug', 'default_locale' => 'en']);
|
||||
$doc->translations()->where('locale', 'en')->update(['title' => 'Whatever']);
|
||||
|
||||
$resolved = $this->resolver->resolve('unique-slug', 'ja');
|
||||
$this->assertSame($doc->id, $resolved->id);
|
||||
}
|
||||
|
||||
public function test_returns_null_when_nothing_matches(): void
|
||||
{
|
||||
$this->assertNull($this->resolver->resolve('Nonexistent', 'en'));
|
||||
}
|
||||
|
||||
public function test_current_locale_wins_over_default(): void
|
||||
{
|
||||
// Doc A has en title "Setup"; Doc B has ja title "Setup"
|
||||
$docA = Document::factory()->create(['default_locale' => 'en']);
|
||||
$docA->translations()->where('locale', 'en')->update(['title' => 'Setup']);
|
||||
|
||||
$docB = Document::factory()->create(['default_locale' => 'en']);
|
||||
$docB->translations()->where('locale', 'en')->update(['title' => 'Different']);
|
||||
DocumentTranslation::factory()->create(['document_id' => $docB->id, 'locale' => 'ja', 'title' => 'Setup']);
|
||||
|
||||
// Browsing in ja: ja-locale match (Doc B) should win over default-locale match (Doc A)
|
||||
$resolved = $this->resolver->resolve('Setup', 'ja');
|
||||
$this->assertSame($docB->id, $resolved->id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user