10 Commits

Author SHA1 Message Date
Yutaka Kurosaki 1ce1fa23a4 Add documents.delete_translation lang key for editor button
The editor's delete-translation button used `__('messages.documents.delete_translation') ?? __('messages.documents.delete')`, but `__()` returns the key string (not null) on miss so the `??` fallback never fires — the button rendered the literal key. Adds the missing key to all 16 locales (en+ja human-translated, others mirror en) and simplifies the blade to a single `__()` call.

Plan doc also reflects the SQLite dropIndex requirement found during Task 2.
2026-05-10 12:50:57 +09:00
Yutaka Kurosaki 0c13ad1e64 Update DocumentSeeder to use DocumentService::createDocument
Removes direct Document::create() calls that referenced the dropped
title/content/rendered_html columns. Initial seed now creates the
default-locale translation through the service.
2026-05-10 12:45:06 +09:00
Yutaka Kurosaki c9586612f5 Make QuickSwitcher search across all locales
Delegates to DocumentService::search which queries DocumentTranslation
and collapses to distinct documents. Display titles use the Document
title accessor (current locale + fallback).
2026-05-10 12:43:01 +09:00
Yutaka Kurosaki 0100a0afb4 Add locale tabs to DocumentEditor
Editor accepts a locale URL parameter, loads the corresponding
translation (or empty form for new locales), and exposes
addTranslation/setDefaultLocale/deleteTranslation actions. Tab bar
shows existing locales with default-locale star and a + dropdown
for missing locales.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:40:54 +09:00
Yutaka Kurosaki 97171960bd Show fallback banner when current-locale translation is missing
DocumentViewer computes viewLocale and isFallback at mount; banner
links authenticated owners to the editor for the current UI locale.
Adds documents.fallback_notice + locale_names to all 16 lang files
(en+ja human-translated, others mirror en for now).
2026-05-10 12:36:25 +09:00
Yutaka Kurosaki 187349521d Add translation CRUD routes and controller
POST/DELETE for translations gated by can:update,document middleware.
Locale validated against SUPPORTED_LOCALES. Default-locale deletion
returns 422; duplicate-locale add returns 422. Flash messages added
to en/ja lang files (other locales updated in Task 9).
2026-05-10 12:28:25 +09:00
Yutaka Kurosaki 6d71f5fecf Re-implement syncLinks and processLinks via WikiLinkResolver
syncLinks parses the default-locale content; processLinks resolves
each [[link]] against the current locale at render time. Link labels
preserve original spelling; destination resolves to the same document
in the current locale (with fallback).
2026-05-10 12:25:19 +09:00
Yutaka Kurosaki 7909c33074 Make DocumentService locale-aware
createDocument/updateDocument now accept a \$locale parameter and
write to document_translations. Adds addTranslation, deleteTranslation,
setDefaultLocale (with path/slug regen), distinct-document search,
and findByTitle that delegates to WikiLinkResolver.
2026-05-10 12:23:14 +09:00
Yutaka Kurosaki d7522f592d Add WikiLinkResolver with deterministic 5-step resolution
Prefers current locale, then document default_locale, then any locale
(lowest document_id), then slug match (legacy).
2026-05-10 12:19:57 +09:00
Yutaka Kurosaki 0c399c9f0f Refactor Document to read title/content via translations
Adds translations/defaultTranslation relations, current-locale accessors
with fallback to default_locale, isFallback/availableLocales helpers,
and search scope that delegates to DocumentTranslation.
2026-05-10 12:17:31 +09:00
33 changed files with 1602 additions and 521 deletions
@@ -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'));
}
}
+86 -27
View File
@@ -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'),
]);
}
}
+13 -6
View File
@@ -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());
}
}
+13 -48
View File
@@ -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
View File
@@ -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
);
}
}
+128 -180
View File
@@ -2,143 +2,185 @@
namespace App\Services;
use App\Models\Document;
use App\Models\RecentDocument;
use App\Helpers\SlugHelper;
use Illuminate\Support\Facades\Storage;
use App\Models\Document;
use App\Models\DocumentTranslation;
use App\Models\RecentDocument;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class DocumentService
{
/**
* 新しいドキュメントを作成
*
* @param string $title
* @param string $content
* @param int|null $userId
* @param string|null $directory (deprecated - path is now auto-generated from title)
* @return Document
*/
public function createDocument(
string $title,
string $content,
?int $userId = null,
?string $directory = null
?string $locale = null,
): Document {
// タイトルからパスとスラッグを自動生成
// 例: "Laravel/Livewire/Components" → path="Laravel/Livewire/Components.md", slug="components"
$locale = $locale ?: App::getLocale();
[$path, $slug] = $this->generatePathAndSlug($title);
// ドキュメントをDBに作成
$document = Document::create([
'path' => $path,
'title' => $title,
'slug' => $slug,
'content' => $content,
'rendered_html' => Document::renderMarkdown($content),
'created_by' => $userId,
'updated_by' => $userId,
]);
return DB::transaction(function () use ($title, $content, $userId, $locale, $path, $slug) {
$document = Document::create([
'path' => $path,
'slug' => $slug,
'default_locale' => $locale,
'created_by' => $userId,
'updated_by' => $userId,
]);
// リンクを同期
$document->syncLinks();
DocumentTranslation::create([
'document_id' => $document->id,
'locale' => $locale,
'title' => $title,
'content' => $content,
'rendered_html' => DocumentTranslation::renderMarkdown($content),
'created_by' => $userId,
'updated_by' => $userId,
]);
return $document;
$document->load('translations');
$document->syncLinks();
return $document;
});
}
/**
* ドキュメントを更新
*
* @param Document $document
* @param string $title
* @param string $content
* @param int|null $userId
* @return Document
*/
public function updateDocument(
Document $document,
string $title,
string $content,
?int $userId = null
?int $userId = null,
?string $locale = null,
): Document {
// タイトルが変更された場合はパスとスラッグを再生成
if ($document->title !== $title) {
[$path, $slug] = $this->generatePathAndSlug($title, $document->id);
$document->path = $path;
$document->slug = $slug;
}
$locale = $locale ?: App::getLocale();
$document->title = $title;
$document->content = $content;
$document->rendered_html = Document::renderMarkdown($content);
$document->updated_by = $userId;
return DB::transaction(function () use ($document, $title, $content, $userId, $locale) {
$translation = $document->translations()->firstOrNew(['locale' => $locale]);
$translation->title = $title;
$translation->content = $content;
$translation->rendered_html = DocumentTranslation::renderMarkdown($content);
$translation->updated_by = $userId;
if (!$translation->exists) {
$translation->created_by = $userId;
}
$translation->save();
// DBに保存
$document->save();
$document->updated_by = $userId;
// リンクを再同期
$document->syncLinks();
// Path/slug regenerate only when editing the default-locale translation
if ($locale === $document->default_locale) {
[$path, $slug] = $this->generatePathAndSlug($title, $document->id);
$document->path = $path;
$document->slug = $slug;
}
return $document;
$document->save();
$document->load('translations');
$document->syncLinks();
return $document;
});
}
public function addTranslation(
Document $document,
string $locale,
string $title,
string $content,
?int $userId = null,
): DocumentTranslation {
if ($document->translations()->where('locale', $locale)->exists()) {
throw new \InvalidArgumentException("Translation for locale '$locale' already exists");
}
return DocumentTranslation::create([
'document_id' => $document->id,
'locale' => $locale,
'title' => $title,
'content' => $content,
'rendered_html' => DocumentTranslation::renderMarkdown($content),
'created_by' => $userId,
'updated_by' => $userId,
]);
}
public function deleteTranslation(Document $document, string $locale): void
{
if ($locale === $document->default_locale) {
throw new \InvalidArgumentException("Cannot delete default-locale translation '$locale'");
}
$document->translations()->where('locale', $locale)->delete();
}
public function setDefaultLocale(Document $document, string $locale): Document
{
$translation = $document->translations()->where('locale', $locale)->first();
if (!$translation) {
throw new \InvalidArgumentException("Cannot set default to '$locale': translation does not exist");
}
return DB::transaction(function () use ($document, $locale, $translation) {
$document->default_locale = $locale;
[$path, $slug] = $this->generatePathAndSlug($translation->title, $document->id);
$document->path = $path;
$document->slug = $slug;
$document->save();
return $document->fresh('translations');
});
}
/**
* ドキュメントを削除
*
* @param Document $document
* @return bool
*/
public function deleteDocument(Document $document): bool
{
// DBから削除(ソフトデリート)
return $document->delete();
}
/**
* 全文検索
*
* @param string $query
* @param int $limit
* @return \Illuminate\Database\Eloquent\Collection
* Locale-agnostic full-text search; returns distinct documents.
*/
public function search(string $query, int $limit = 20)
{
return Document::search($query)
->limit($limit)
->get();
$documentIds = DocumentTranslation::query()
->search($query)
->limit($limit * 5) // overscan to allow distinct collapse
->pluck('document_id')
->unique()
->values()
->take($limit);
if ($documentIds->isEmpty()) {
return Document::query()->whereRaw('1 = 0')->get();
}
return Document::with('translations')
->whereIn('id', $documentIds)
->get()
->sortBy(fn ($d) => $documentIds->search($d->id))
->values();
}
public function findByTitle(string $title, ?string $locale = null): ?Document
{
return (new WikiLinkResolver())->resolve($title, $locale ?: App::getLocale());
}
/**
* ディレクトリツリーを生成
*
* @return array
*/
public function getDirectoryTree(): array
{
$documents = Document::orderBy('path')->get();
$documents = Document::with('translations')->orderBy('path')->get();
$tree = [];
foreach ($documents as $document) {
$parts = explode('/', $document->path);
$current = &$tree;
foreach ($parts as $index => $part) {
$isFile = ($index === count($parts) - 1);
if ($isFile) {
// ファイル
if (!isset($current['_files'])) {
$current['_files'] = [];
}
$current['_files'][] = [
'name' => $part,
'document' => $document,
];
} else {
// ディレクトリ
if (!isset($current[$part])) {
$current[$part] = [];
}
@@ -146,67 +188,28 @@ public function getDirectoryTree(): array
}
}
}
return $tree;
}
/**
* ユーザーの最近閲覧したドキュメントを取得
*
* @param int $userId
* @param int $limit
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getRecentDocuments(int $userId, int $limit = 10)
{
return RecentDocument::getRecentForUser($userId, $limit);
}
/**
* ドキュメント閲覧を記録
*
* @param Document $document
* @param int $userId
* @return void
*/
public function recordDocumentAccess(Document $document, int $userId): void
{
RecentDocument::recordAccess($userId, $document->id);
}
/**
* 指定タイトルのドキュメントを検索
*
* @param string $title
* @return Document|null
*/
public function findByTitle(string $title): ?Document
{
return Document::where('title', $title)
->orWhere('slug', SlugHelper::generate($title))
->first();
}
/**
* 被リンク(バックリンク)を取得
*
* @param Document $document
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBacklinks(Document $document)
{
return $document->incomingLinks()
->with('sourceDocument')
->with('sourceDocument.translations')
->get()
->pluck('sourceDocument')
->filter();
}
/**
* 壊れたリンク(未作成ページへのリンク)を取得
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBrokenLinks()
{
return DB::table('document_links')
@@ -217,39 +220,13 @@ public function getBrokenLinks()
->get();
}
/**
* タイトルからパスとスラッグを生成
* タイトルに含まれる / をディレクトリ区切りとして扱う
*
* : "Laravel/Livewire/Components"
* path = "Laravel/Livewire/Components.md"
* slug = "components" (最後のコンポーネントから生成)
*
* @param string $title
* @param int|null $excludeDocumentId 更新時に除外するドキュメントID
* @return array [path, slug]
*/
private function generatePathAndSlug(string $title, ?int $excludeDocumentId = null): array
{
// タイトルをそのままパスとして使用(.md拡張子を追加)
$basePath = $title . '.md';
// 最後のパスコンポーネント(スラッシュで区切られた最後の部分)からスラッグを生成
$lastComponent = basename($title);
$baseSlug = SlugHelper::generate($lastComponent);
// ユニークなパスとスラッグを生成
$baseSlug = SlugHelper::generate(basename($title));
return $this->ensureUniquePath($basePath, $baseSlug, $excludeDocumentId);
}
/**
* パスとスラッグがユニークになるように調整
*
* @param string $basePath
* @param string $baseSlug
* @param int|null $excludeDocumentId
* @return array [path, slug]
*/
private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excludeDocumentId = null): array
{
$path = $basePath;
@@ -261,46 +238,17 @@ private function ensureUniquePath(string $basePath, string $baseSlug, ?int $excl
->where(function ($q) use ($path, $slug) {
$q->where('path', $path)->orWhere('slug', $slug);
});
if ($excludeDocumentId) {
$query->where('id', '!=', $excludeDocumentId);
}
if (!$query->exists()) {
break;
}
$counter++;
// パス: "title.md" → "title-2.md"
$path = preg_replace('/\.md$/', "-{$counter}.md", $basePath);
// スラッグ: "title" → "title-2"
$slug = $baseSlug . '-' . $counter;
}
return [$path, $slug];
}
/**
* 初期ドキュメントを作成
*
* @return void
*/
public function createInitialDocuments(): void
{
// ホームページ
$this->createDocument(
'Home',
"# Welcome to Knowledge Base\n\nThis is your personal knowledge base powered by Markdown.\n\n## Getting Started\n\n- Create new documents using [[wiki-links]]\n- Use Ctrl+K for quick switching\n- Full-text search is available\n\n## Example Links\n\n- [[Getting Started]]\n- [[Documentation]]\n- [[Notes]]",
null,
null
);
// Getting Startedページ
$this->createDocument(
'Getting Started',
"# Getting Started\n\nLearn how to use this knowledge base.\n\n## Creating Documents\n\nClick on any [[wiki-link]] to create a new document.\n\n## Editing\n\nClick the edit button to modify content.\n\nBack to [[Home]]",
null,
null
);
}
}
+64
View File
@@ -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;
}
}
+11 -32
View File
@@ -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!');
+27
View File
@@ -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',
+27
View File
@@ -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',
+27
View File
@@ -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',
+27
View File
@@ -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',
+27
View File
@@ -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' => 'प्रोफ़ाइल',
+27
View File
@@ -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',
+16
View File
@@ -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' => 'プロフィール',
+27
View File
@@ -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' => '프로필',
+27
View File
@@ -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',
+27
View File
@@ -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',
+27
View File
@@ -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' => 'Профиль',
+27
View File
@@ -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',
+27
View File
@@ -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' => 'Профіль',
+27
View File
@@ -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ơ',
+27
View File
@@ -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' => '个人资料',
+27
View File
@@ -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">
+9
View File
@@ -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');
});
// 公開ルート(動的ルートは最後に)
+130
View File
@@ -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));
}
}
+145
View File
@@ -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);
}
}