9 Commits

Author SHA1 Message Date
Yutaka Kurosaki b7a70f74e5 Add DocumentTranslation model with renderMarkdown and search scope
Includes a minimal Document::translations() HasMany relation so that
DocumentFactory's afterCreating callback (which calls
$document->translations()->count()) works. The full Document model
refactor (accessors, fallback helpers, default-translation accessor)
lands in Task 4.
2026-05-10 12:14:24 +09:00
Yutaka Kurosaki 4a8622c385 Harden migration: transaction, chunking, lossy-down doc, data-preservation test
- Wrap the data copy in DB::transaction (FULLTEXT ALTER stays outside)
- Switch to chunkById(500) so the migration scales
- Document down() as irreversible for non-default-locale translations
- Add test_existing_documents_data_is_copied_to_translations to cover
  the data copy itself (the only previously-untested behavior)
- Drop unused Migrator import in DocumentMigrationTest
- Also restore title index in down() so up() can be re-run cleanly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:11:31 +09:00
Yutaka Kurosaki f2bdb6a069 Add document_translations table and migrate existing data
documents.{title,content,rendered_html} move to document_translations
keyed by (document_id, locale). Existing rows are copied to a single
translation in config('app.locale'). documents gains default_locale.

Also guard the original FULLTEXT ALTER TABLE with a MySQL driver check
so that the SQLite test environment can run all migrations cleanly.
2026-05-10 12:04:05 +09:00
Yutaka Kurosaki e83bd6981d Fix DocumentFactory withoutTranslations + path trailing period
afterCreating appends rather than replaces, so a no-op closure does not
override configure(). Use withoutAfterCreating() to actually clear the
translation-creation callback (otherwise DocumentTranslation::factory()
recurses through Document::factory()->withoutTranslations()).

Also use words() instead of sentence() to avoid Faker's trailing period
producing paths like "Foo bar..md".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:58:41 +09:00
Yutaka Kurosaki 7f2f8a2248 Add Document and DocumentTranslation factories 2026-05-10 11:53:33 +09:00
Yutaka Kurosaki ab846b71b2 Add implementation plan for article-level i18n
12 TDD tasks: factories, migration with data move, DocumentTranslation
model, Document refactor with locale-aware accessors, WikiLinkResolver,
DocumentService rewrite, syncLinks/processLinks via resolver, translation
CRUD routes/controller, viewer fallback banner, editor locale tabs,
QuickSwitcher cross-locale search, and seeder cleanup. Each task includes
exact file paths, failing tests, minimal implementation, and a commit step.
2026-05-10 10:10:42 +09:00
Yutaka Kurosaki 01fb8b9fcf Add design spec for article-level i18n with default-locale fallback
Documents the data model (1 doc + document_translations table), service
layer changes, routing/editor UX, wiki-link resolution order, search,
and migration plan. Article URLs stay locale-independent; display falls
back to each document's default_locale when the requested locale lacks
a translation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:46:29 +09:00
Yutaka Kurosaki ba25b544f5 Add implementation plan for admin media manager
Eleven TDD tasks from migration through editor integration: model,
service (store/rename/delete + auto-suffix), admin CRUD controller,
real Blade UI, auth-gated download, listener Phase-1 logical-path
rewrite for Image+Link nodes, and ImageUploadController rewiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:54:54 +09:00
Yutaka Kurosaki 3c185fac37 Add design spec for admin media manager
Defines logical/physical filename split, MD reference resolution policy,
component structure, data flow, and test coverage for the new
upload/rename/download/delete flows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:42:18 +09:00
12 changed files with 5575 additions and 2 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,192 @@
# Media Manager — Design Spec
Date: 2026-05-09
## Goal
Add a simple media manager to the admin panel so users can upload, rename, download, and delete arbitrary files (images, video, audio, PDF, ZIP) and reference them from Markdown documents by a stable logical filename or path.
## Non-Goals
- Folder management UI (explicit folder create/move/rename). Folders are expressed implicitly via slashes in the logical path.
- Versioning or revision history of media files.
- Automatic rewriting of existing Markdown when a media file is renamed or deleted (broken-link warning is left to manual operation).
- Migration of existing `storage/app/public/images/` files into the new media table.
## Core Design
### Logical / Physical Separation
- **Logical path** (DB-managed): user-facing identifier used in Markdown, e.g., `report.png` or `2026/spec.pdf`. UNIQUE.
- **Physical path** (filesystem): `media/{uuid}.{ext}` under the `public` disk. Filename is a UUID; extension preserved from the original upload.
- Renaming changes only the logical path; the physical file is never moved.
- Deletion removes the DB row and the physical file.
### Markdown Reference Resolution
- Markdown reference syntax stays standard: `![alt](report.png)` or `![alt](2026/spec.pdf)`.
- Resolution policy: **exact match only** on `logical_path`. No basename fallback, no fuzzy matching.
- Only schema-less, non-absolute paths are looked up. `https://…`, `http://…`, and paths starting with `/` are passed through to the existing pipeline (YouTube/Vimeo/video/audio handlers).
### Editor Drag-and-Drop Integration
- The existing `POST /images/upload` endpoint (used by EasyMDE) is rerouted through the same `MediaService`.
- The endpoint returns the **logical path** (e.g., `report.png`) as `data.filePath`, so the inserted Markdown becomes `![alt](report.png)` rather than an absolute UUID URL.
- Logical-path collisions during editor uploads are auto-resolved server-side by appending `-2`, `-3`, … to the basename (max 100 attempts before erroring). Collisions in the manual admin upload form are surfaced as validation errors instead.
## Data Model
### Table `media_files`
| Column | Type | Notes |
|--|--|--|
| `id` | bigint, pk | |
| `logical_path` | varchar(512), UNIQUE | Validated: no leading/trailing `/`, no `..`, no empty segments, no consecutive `/`, non-empty |
| `physical_path` | varchar(255) | `media/{uuid}.{ext}` (relative to the public disk) |
| `original_name` | varchar(255) | Original filename at upload time. Used as the Content-Disposition filename on download |
| `mime_type` | varchar(127) | Detected at upload |
| `size` | unsigned bigint | Bytes |
| `uploaded_by` | foreign key → `users.id`, nullable | ON DELETE SET NULL |
| `created_at` / `updated_at` | timestamp | |
Indexes: `UNIQUE(logical_path)`, plain index on `uploaded_by`.
### Eloquent Model `App\Models\MediaFile`
- `publicUrl()`: returns `Storage::disk('public')->url($this->physical_path)`.
- Override `delete()` to remove the physical file before deleting the DB row. If the physical delete fails, the DB row is **kept** and the failure is logged so it can be retried.
## Components
### Backend
| Role | File | Responsibility |
|--|--|--|
| Routes | `routes/web.php` (additions) | Admin resource routes for media; standalone `auth`-only download route |
| Admin controller | `app/Http/Controllers/Admin/MediaController.php` | index / store / edit / update / destroy. Inline `validate()`; no FormRequest |
| Service | `app/Services/MediaService.php` | `store(UploadedFile, ?string $logicalPath, bool $autoSuffixOnConflict = false)`, `rename(MediaFile, string $newLogicalPath)`, `delete(MediaFile)`. Owns logical-path normalization, collision detection, and physical I/O |
| Listener update | `app/Markdown/MediaEmbedListener.php` | Add a Phase-1 logical-path rewrite that walks `Image` and `Link` nodes and replaces matching `logical_path` URLs with the physical public URL before the existing video/audio/YouTube/Vimeo detection runs |
| Editor integration | `app/Http/Controllers/ImageUploadController.php` | Rewritten to call `MediaService::store(..., autoSuffixOnConflict: true)` and return `data.filePath = <logical_path>` |
### Frontend
| View | Content |
|--|--|
| `resources/views/admin/media/index.blade.php` | Search box, inline upload form, paginated table (logical path / type icon or thumbnail / size / updated_at / actions). Actions: rename, download, delete |
| `resources/views/admin/media/edit.blade.php` | Rename-only form (edits `logical_path`) |
Navigation entry added to `resources/views/layouts/navigation.blade.php` under the admin dropdown.
### Routing
```php
Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
Route::resource('users', AdminUserController::class)->except(['show']);
Route::resource('media', AdminMediaController::class)->except(['show', 'create']);
});
Route::middleware('auth')->group(function () {
// existing routes...
Route::get('/media/{media}/download', [AdminMediaController::class, 'download'])->name('media.download');
});
```
Rationale: CRUD is admin-only; download is reachable by any authenticated user (so document readers can grab attached PDFs/ZIPs). The physical file under `/storage/media/{uuid}.ext` is already publicly readable via the public disk — the `download` route only adds the `original_name` Content-Disposition.
## Data Flow
### Upload (admin form)
1. `POST /admin/media` (multipart) → `MediaController::store`
2. Validate: `file` required; allowed MIME (image jpeg/png/gif/webp/svg, video mp4/webm, audio mp3/m4a/ogg/wav, application/pdf, application/zip); `max:102400` (100 MB); `logical_path` optional.
3. `MediaService::store($file, $logicalPath, autoSuffixOnConflict: false)`:
- Normalize logical path (trim, reject `..`, reject leading/trailing `/`, reject empty/consecutive segments, default to `original_name` if blank).
- Check `logical_path` UNIQUE; on conflict throw a validation exception.
- Save physical file to `media/{uuid}.{ext}` via `Storage::disk('public')->putFileAs`.
- Create the `media_files` row with `uploaded_by = auth()->id()`.
4. Redirect to `admin.media.index` with a success flash.
### Rename
1. `PUT /admin/media/{media}``MediaController::update`
2. Validate the new `logical_path` (same normalization + UNIQUE excluding self).
3. Update the DB row only. Physical file is untouched.
4. **Side effect**: any existing Markdown referring to the old logical path becomes a broken link. No automatic rewrite in this iteration.
### Download
1. `GET /media/{media}/download``MediaController::download` (auth-only).
2. Returns `Storage::disk('public')->download($physical_path, $original_name)`.
### Delete
1. `DELETE /admin/media/{media}``MediaController::destroy`
2. `MediaService::delete($media)`: try to delete the physical file; on success delete the DB row. On physical-delete failure, log it and surface a warning flash; the DB row is preserved so the operation can be retried.
3. Existing Markdown references to the deleted file become broken links. No warning surfaced in this iteration.
### Markdown Render-Time Resolution
The `MediaEmbedListener` walks both `Image` and `Link` nodes during `DocumentParsedEvent` in two ordered phases:
**Phase 1 — Logical-path rewrite** (new):
For each `Image` or `Link` node, take its URL.
- If the URL has a scheme (`http(s)://`, etc.) or starts with `/`, skip it.
- Otherwise look up `MediaFile::where('logical_path', $url)->first()`.
- On hit, replace the node's URL with `$media->publicUrl()` (e.g., `/storage/media/{uuid}.png`). The node type is unchanged.
**Phase 2 — Embed detection** (existing, unchanged):
The existing `MediaEmbedListener` logic runs against the (possibly rewritten) URL. `Image` nodes whose URL ends in a video/audio extension or matches YouTube/Vimeo get replaced with a `MediaEmbedNode` rendered as `<video>` / `<audio>` / iframe. Everything else falls through to CommonMark's default rendering.
Net behavior:
- `![alt](report.png)` → image extension, no embed rewrite, `<img src="/storage/media/{uuid}.png" alt="alt">`.
- `![](demo.mp4)` → URL rewritten to physical, then video extension detected, replaced with `<video>` tag.
- `[manual](manual.pdf)` → Link node, URL rewritten to physical, CommonMark renders `<a href="/storage/media/{uuid}.pdf">manual</a>`.
- `![](https://example.com/foo.png)` → has scheme, Phase 1 skips; Phase 2 also has nothing to do; CommonMark renders default `<img>`.
`MediaUrlResolver` itself stays focused on URL→embed-HTML mapping for video/audio/YouTube/Vimeo (i.e., its public surface does not change). The logical-path rewrite is a separate concern handled in the listener.
### Editor Drag-and-Drop
1. EasyMDE → `POST /images/upload` (existing endpoint).
2. `ImageUploadController` calls `MediaService::store($file, null, autoSuffixOnConflict: true)`.
3. Response: `{ data: { filePath: <logical_path>, altText: <basename without ext> } }`.
4. EasyMDE inserts `![<basename>](<logical_path>)` into the Markdown.
## Validation & Error Handling
| Case | Behavior |
|--|--|
| Invalid logical path (`..`, leading/trailing `/`, empty/consecutive segments, > 512 chars, empty after trim) | Validation error |
| Logical-path collision (admin form) | Validation error: "同じパスのファイルが既に存在します" |
| Logical-path collision (editor D&D) | Auto-suffix `-2`, `-3`, … up to 100 attempts; on exhaustion return a 422 |
| Disallowed MIME / extension | Validation error |
| File over 100 MB | Validation error. `php.ini` `upload_max_filesize` / `post_max_size` must be ≥ 100 MB; document this as an environment requirement |
| Physical-delete failure | Log + warning flash; DB row kept |
| Deleted/renamed media still referenced in MD | No warning; the resolver misses, the link renders as a broken link |
| Markdown URL is absolute (`http(s)://`, leading `/`) | Resolver passes through to existing video/audio/YouTube/Vimeo logic |
| Authorization | All CRUD under `auth + admin` middleware; download under `auth` only |
## Testing
PHPUnit feature tests using a fake `public` disk so real file I/O is exercised in temp storage.
| Test | Coverage |
|--|--|
| `MediaServiceTest::store` | with explicit logical path, with default (original name), conflict rejection, invalid-path rejection, `uploaded_by` recorded |
| `MediaServiceTest::rename` | DB updated, physical file unchanged, conflict rejection |
| `MediaServiceTest::delete` | physical + DB removed; on physical-fail DB row kept |
| `MediaServiceTest::auto_suffix` | editor path: `report.png` collision → `report-2.png` |
| `Admin\MediaControllerTest` | non-admin → 403 on index/store/update/destroy |
| `MediaDownloadTest` | unauth → redirect/401, auth → file with `original_name` |
| `MediaEmbedListenerTest` (extended) | logical-path hit rewrites Image/Link URL to physical; miss leaves URL untouched; absolute URL (with scheme or leading `/`) skipped |
| `DocumentRenderIntegrationTest` | `![](report.png)` rendered to `<img src="/storage/media/{uuid}.png">` after a `MediaFile` row exists |
Coverage target: the rows above. Not aiming for 100%.
## Out-of-Scope (explicit)
- Bulk operations (multi-select delete, bulk rename).
- Quota/usage tracking per user.
- CDN / signed URLs.
- Migration of existing `images/` content into `media_files`.
- Rewriting existing Markdown when a logical path is renamed or deleted.
@@ -0,0 +1,275 @@
# 記事の多言語対応 + デフォルト言語フォールバック
- **Date:** 2026-05-10
- **Branch:** `feature/article-i18n``main` から分岐)
- **Status:** Design approved, ready for implementation plan
## 背景
UI言語切り替えは16言語対応済み(`SetLocale` ミドルウェア + `users.locale` カラム + セッション/Cookie)。
一方、記事本体(`documents` テーブル)は単一言語のまま:1ドキュメント = 1行で `title`/`content`/`rendered_html` を直接保持し、locale非対応。
本設計は、記事自体を多言語化し、ユーザーのUI言語に応じた翻訳を提示する。翻訳が無ければ「その記事の原語版」へフォールバックする。
## 決定事項サマリ
| 論点 | 決定 |
|---|---|
| 翻訳のデータモデル | 1記事 + `document_translations` 子テーブル |
| デフォルト言語 | 記事ごとに `documents.default_locale` を持つ |
| URL | デフォルト言語のslug固定(言語非依存) |
| 表示title | 現在locale → default_locale でフォールバック |
| 編集UX | 1記事の編集画面に言語タブ |
| `[[wiki-link]]` 解決 | 全言語のtitleを対象、現在locale優先の決定論的順序 |
| 検索 | 全言語のtitle/contentを対象、表示titleは現在locale |
| マイグレーション | 既存記事は `config('app.locale')``default_locale` として移行 |
| フォールバック表示 | 上部バナー + 「翻訳を追加」ボタン |
## Section 1: データモデル
### `documents` テーブル(変更)
| 操作 | カラム | 備考 |
|---|---|---|
| 追加 | `default_locale` VARCHAR(10) NOT NULL | フォールバック先となる原語 |
| 削除 | `title` | `document_translations` へ移管 |
| 削除 | `content` | 同上 |
| 削除 | `rendered_html` | 同上 |
| 維持 | `path`, `slug`, `frontmatter`, `file_size`, `file_hash`, `file_modified_at`, `created_by`, `updated_by`, timestamps, `softDeletes` | |
`path`/`slug` は引き続き「default_locale のtitle」から生成。タイトル変更で再生成される既存ロジックも default_locale のtitleに連動。
### `document_translations` テーブル(新規)
```
id BIGINT PK
document_id FK → documents (cascade delete)
locale VARCHAR(10)
title VARCHAR(255)
content TEXT
rendered_html TEXT NULLABLE
created_by FK → users (nullOnDelete)
updated_by FK → users (nullOnDelete)
created_at, updated_at
UNIQUE (document_id, locale)
INDEX (locale, title) -- wiki-link / QuickSwitcher 用
FULLTEXT (title, content) WITH PARSER ngram -- MySQL のみ(既存と同条件)
```
- `(locale, title)` には**unique制約は付けない**。既存 `documents.title` も unique でないため挙動互換性を保つ。代わりに wiki-link 解決順序を決定論にする。
- SQLite(テスト環境)では FULLTEXT インデックス作成をスキップ(既存マイグレーションと同じ条件分岐)。
### マイグレーション手順
1. `documents.default_locale` カラム追加(デフォルト値 `config('app.locale')`、nullable=false
2. `document_translations` テーブル作成
3. データ移行:既存各 `documents` 行から `(title, content, rendered_html, created_by, updated_by)``document_translations` に複製、`locale = config('app.locale')` をセット
4. `documents` テーブルの旧FULLTEXTインデックス(`documents_search_index`)を削除
5. `documents.title`, `documents.content`, `documents.rendered_html` カラムを削除
`down()` は対称的に逆順で復元するが、データ復元は best-effort(複数translation存在時はdefault_localeのものを採用)。
## Section 2: モデル & サービス層
### `Document` モデル
- `title`/`content`/`rendered_html` をfillable/直接プロパティから削除
- 新リレーション
- `translations(): HasMany``DocumentTranslation`
- `defaultTranslation(): HasOne``where('locale', $this->default_locale)`
- アクセサ(**現在 `App::getLocale()` のtranslation → 無ければdefault_localeのtranslation**
- `getTitleAttribute(): string`
- `getContentAttribute(): string`
- `getRenderedHtmlAttribute(): ?string`
- 新メソッド
- `translationFor(string $locale, bool $fallback = true): ?DocumentTranslation`
- `isFallback(string $requestedLocale): bool` — バナー判定用
- `availableLocales(): array` — タブ生成用、translation存在のlocale一覧
### `DocumentTranslation` モデル(新規)
```php
fillable: [document_id, locale, title, content, rendered_html, created_by, updated_by]
casts: timestamps
relations: document(): BelongsTo, creator(), updater()
scope: scopeSearch(Builder, string) -- MATCH(title, content) AGAINST(?)
static: renderMarkdown(string): string -- Document から移管(純粋関数)
```
### `DocumentService` 改修
| メソッド | 変更内容 |
|---|---|
| `createDocument($title, $content, $userId, $locale = null)` | `$locale` 未指定なら `App::getLocale()`。document を作成し `default_locale = $locale`、translationを1件作成。path/slug は title から生成 |
| `updateDocument(Document $doc, $title, $content, $userId, $locale)` | 指定 `$locale` のtranslationをupsert。`$locale === default_locale` のときだけ path/slug 再生成 |
| `addTranslation(Document $doc, $locale, $title, $content, $userId): DocumentTranslation` | 新言語のtranslation追加。重複locale時は422 |
| `deleteTranslation(Document $doc, string $locale): void` | default_locale の翻訳は削除拒否(例外) |
| `setDefaultLocale(Document $doc, string $locale): Document` | `default_locale` 切り替え。新しいdefault_localeのtranslationが存在しない場合422。切替後 path/slug を新default_localeのtitleから再生成 |
| `findByTitle($title, $locale = null): ?Document` | locale指定優先。後述の解決順序を実装 |
| `search(string $query, int $limit = 20)` | `DocumentTranslation::search()` ベース、結果documentをdistinct化 |
| `getDirectoryTree()` | path/階層構造はdocumentレベルのまま、表示titleはアクセサ経由で現在locale + フォールバック |
### `WikiLinkResolver`(新規 `app/Services/WikiLinkResolver.php`
`processLinks()``syncLinks()` から共通利用される解決ロジック:
```
resolve(string $linkText, string $currentLocale): ?Document
1. translations WHERE locale = $currentLocale AND title = $linkText
2. translations WHERE locale = document.default_locale AND title = $linkText
3. translations WHERE title = $linkText
ORDER BY document_id ASC LIMIT 1
4. documents WHERE slug = SlugHelper::generate($linkText)
5. null
```
- `Document::syncLinks()``Document::processLinks()` はResolver利用に書き換え
- `processLinks()` のリンク**表示ラベル**は原文のまま(例: 英語本文中の `[[Getting Started]]` はja表示時もラベル「Getting Started」)。クリック後の遷移先記事は現在localeで表示される
- 既存の `DocumentLink` テーブルはスキーマ無変更(`target_title` を保存しているだけなのでlocale非依存で運用可能)
## Section 3: ルーティング & コントローラ層
### 既存ルート(変更なし)
- `GET /documents/{document}` → slug一致でdocumentバインド
- `GET /documents/{document}/edit`
- `GET /documents/create`
URLは言語非依存。表示言語は `SetLocale` ミドルウェアが解決した `App::getLocale()` に従う。
### `Document::resolveRouteBinding()`
slugは `documents` テーブル直下のため既存ロジックそのまま。
翻訳title(例: `はじめに`)でURLを直接叩いた場合は404のまま(検索/QuickSwitcher経由で見つける想定)。
### 新規ルート: 翻訳の管理
| メソッド | URL | 名前 | 機能 |
|---|---|---|---|
| GET | `/documents/{document}/translations/{locale}/edit` | `documents.translations.edit` | 既存翻訳の編集(`DocumentEditor` を再利用) |
| POST | `/documents/{document}/translations` | `documents.translations.store` | 新言語追加 |
| DELETE | `/documents/{document}/translations/{locale}` | `documents.translations.destroy` | 翻訳削除(default_locale 不可) |
- `{locale}``SetLocale::SUPPORTED_LOCALES` のキーで route constraint
- すべて `auth` + `can:update,document` ミドルウェア配下
### `/` ルート
`slug = home` の document を探してリダイレクト(既存通り、slugは言語非依存のため変更不要)。
## Section 4: Livewire コンポーネント & ビュー
### `DocumentViewer`(改修)
```php
public Document $document;
public string $viewLocale; // 実際に表示中のlocale(フォールバック後)
public bool $isFallback; // バナー表示判定
public string $renderedContent; // wiki-link処理済みHTML
public $backlinks;
```
`mount()` フロー:
1. `$current = App::getLocale()`
2. `$translation = $document->translationFor($current, fallback: true)`
3. `$this->viewLocale = $translation->locale`
4. `$this->isFallback = ($current !== $translation->locale)`
5. `WikiLinkResolver``$translation->rendered_html` 内のwiki-linkを現在locale基準で再解決
ビュー `livewire/document-viewer.blade.php`:
- 上部にフォールバックバナー(`$isFallback === true` のみ)
- 文言は `lang/{locale}/messages.php` に追加(例: `documents.fallback_notice``documents.add_translation`
- 「翻訳を追加」ボタン → `documents.translations.store` への小フォーム(locale = 現在UI locale
- バックリンク表示titleは `$backlink->title` アクセサ経由(自動でフォールバック)
### `DocumentEditor`(改修)
```php
public ?Document $document; // 既存編集時、新規作成時はnull
public string $editingLocale; // 現在編集中のタブ
public string $title; // editingLocale の title
public string $content; // editingLocale の content
public array $availableLocales; // この記事で既に翻訳がある locale 一覧
public bool $isNewLocale; // 既存翻訳の編集 or 新規追加
```
メソッド:
- `switchTab(string $locale)` — 未保存変更があれば確認ダイアログ、translation取得 or 空フォーム
- `save()` — 既存ならupdate、新規ならaddTranslation
- `delete()` — 翻訳削除(default_locale以外)
- `setAsDefault(string $locale)` — default_locale変更(path/slug再生成)
ビュー `livewire/document-editor.blade.php`:
- 上部にタブバー: `[JA*] [EN] [zh-CN] [+ 翻訳を追加 ▾]`
- `*` は default_locale 印
- `+` ドロップダウンで未追加の `SUPPORTED_LOCALES` を選択可能
- 既存のEasyMDE設定はそのまま流用、`wire:model` の単一入力構造を維持
- default_locale変更UI(タブの隣にメニュー or 「この言語をデフォルトにする」ボタン)
### `SidebarTree`(軽微改修)
- `getDirectoryTree()` の戻り値内 `name`path basename)はpathベースのまま
- ファイル名表示は `$document->title`(アクセサ経由)に切り替え
- treeのキャッシュキー(もしあれば)に `App::getLocale()` を含めて、locale切替で再構築
### `QuickSwitcher`(改修)
- 検索クエリを `DocumentTranslation::search()` に投げ、結果documentをdistinct化
- 表示行は `$doc->title`(アクセサ)+ ヒットしたtranslationが現在locale以外なら小さなlocaleバッジ
## Section 5: テスト計画 & 受け入れ基準
### ユニットテスト(追加)
| ファイル | 検証内容 |
|---|---|
| `tests/Unit/Models/DocumentTest.php` | `title`/`content`/`rendered_html` アクセサが現在localeを返す/無ければdefault_localeにフォールバック/`isFallback()` 真偽/`availableLocales()` |
| `tests/Unit/Models/DocumentTranslationTest.php` | `(document_id, locale)` uniquecascade delete`renderMarkdown()` |
| `tests/Unit/Services/WikiLinkResolverTest.php` | 解決順序5段階すべて/同名タイトル衝突時の決定論性/slug fallbacknullケース |
| `tests/Unit/Services/DocumentServiceTest.php` | `createDocument` がtranslationを1件生成/`updateDocument` がdefault_locale時のみpath再生成/`addTranslation``deleteTranslation`default_locale削除拒否)/`setDefaultLocale`(未存在translation時422 + path再生成)/`search` のdistinct化 |
### フィーチャーテスト(追加)
| ファイル | 検証内容 |
|---|---|
| `tests/Feature/DocumentI18nTest.php` | 現在locale=ja で英語のみの記事を表示→英語版が表示されバナーが出る/ja版を追加後はバナーなし/QuickSwitcherで日本語クエリ・英語クエリどちらも同記事ヒット |
| `tests/Feature/DocumentTranslationCrudTest.php` | 翻訳追加POST/編集GET/PATCH/削除DELETEdefault_localeは422)/非権限ユーザは403 |
| `tests/Feature/DocumentMigrationTest.php` | マイグレーション前の擬似データ→migrate実行→translationsへ正しく移行/documentsの旧カラムが消去 |
### 既存テストの修正範囲
- `DocumentService` を呼ぶ既存テストはfactoryで `default_locale` 指定が必要
- `Document::factory()``DocumentTranslationFactory` と連動
- 既存の `processLinks` / `syncLinks` テストは `WikiLinkResolver` 経由に書き換え
### 受け入れ基準(DoD
1. UI言語をjaにして英語のみ記事を開く → 英語コンテンツが表示され、上部に日本語バナー+「翻訳を追加」ボタンが出る
2. 「翻訳を追加」→ 編集画面でja版を作成・保存 → 同URLを再表示 → ja版がバナーなしで表示
3. `[[Getting Started]]``[[はじめに]]` の両方が同一記事へリンクする
4. QuickSwitcherで「はじめに」「Getting Started」どちらでも同記事がヒットする
5. 編集画面の言語タブを切り替えて、各言語のtitle/contentを独立に編集・保存できる
6. default_locale設定の翻訳は削除できない(UIで削除ボタン非表示+バックエンドで422)
7. 既存の `composer test` がすべて緑
8. マイグレーション後、既存記事すべてが `default_locale = config('app.locale')` で正しく翻訳化されている
## ブランチ & コミット計画
- ブランチ: `feature/article-i18n`**`main` から分岐**`feature/media-manager` ではない)
- コミット粒度の目安(1 PR想定で7コミット):
1. マイグレーション(`document_translations` 作成 + 既存データ移行 + 旧カラム削除)
2. `DocumentTranslation` モデル + factory
3. `Document` モデルアクセサ/リレーション改修
4. `WikiLinkResolver` + `DocumentService` 改修
5. ルート + 翻訳CRUDコントローラ
6. `DocumentViewer` + バナー + lang追加
7. `DocumentEditor` タブUI + `SidebarTree`/`QuickSwitcher` 調整
## スコープ外(YAGNI
- 翻訳の自動生成(DeepL等の外部API連携)
- 翻訳の進捗ダッシュボード(カバレッジ表示)
- 言語ごとの別URL`/ja/documents/...`
- title翻訳に応じたslugの言語別生成
- RTL言語サポート
+11 -1
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Helpers\SlugHelper;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -17,7 +18,7 @@
class Document extends Model
{
use SoftDeletes;
use HasFactory, SoftDeletes;
/**
* Get the route key for the model.
@@ -280,6 +281,15 @@ 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
{
return $this->hasMany(DocumentTranslation::class);
}
/**
* ディレクトリパスを取得
*
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
class DocumentTranslation extends Model
{
use HasFactory;
protected $fillable = [
'document_id',
'locale',
'title',
'content',
'rendered_html',
'created_by',
'updated_by',
];
public function document(): BelongsTo
{
return $this->belongsTo(Document::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
/**
* Full-text search scope. Falls back to LIKE on non-MySQL drivers
* (notably SQLite in tests, which lacks FULLTEXT).
*/
public function scopeSearch(Builder $query, string $term): Builder
{
if ($query->getConnection()->getDriverName() === 'mysql') {
return $query->whereRaw(
'MATCH(title, content) AGAINST(? IN BOOLEAN MODE)',
[$term]
);
}
return $query->where(function (Builder $q) use ($term) {
$like = '%' . $term . '%';
$q->where('title', 'like', $like)->orWhere('content', 'like', $like);
});
}
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();
}
}
@@ -0,0 +1,65 @@
<?php
namespace Database\Factories;
use App\Helpers\SlugHelper;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Document>
*/
class DocumentFactory extends Factory
{
protected $model = Document::class;
public function definition(): array
{
$title = rtrim(fake()->unique()->words(3, true), '.');
return [
'path' => $title . '.md',
'slug' => SlugHelper::generate($title),
'default_locale' => 'en',
'file_size' => 0,
'file_hash' => str_repeat('0', 64),
'file_modified_at' => now(),
];
}
/**
* After creating, attach a translation in the document's default_locale
* (skipped if a translation was already created via state, or if the
* caller used withoutTranslations()).
*/
public function configure(): static
{
return $this->afterCreating(function (Document $document) {
if ($document->translations()->count() === 0) {
DocumentTranslation::factory()->create([
'document_id' => $document->id,
'locale' => $document->default_locale,
]);
}
});
}
/**
* Override the default_locale (auto-translation will be created in this locale).
*/
public function defaultLocale(string $locale): static
{
return $this->state(['default_locale' => $locale]);
}
/**
* Suppress automatic translation creation. Uses Laravel's built-in
* withoutAfterCreating() to clear callbacks rather than appending a no-op
* (afterCreating appends, so a no-op closure does NOT override the configure() callback).
*/
public function withoutTranslations(): static
{
return $this->withoutAfterCreating();
}
}
@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<DocumentTranslation>
*/
class DocumentTranslationFactory extends Factory
{
protected $model = DocumentTranslation::class;
public function definition(): array
{
$title = fake()->sentence(3);
$content = fake()->paragraphs(3, true);
return [
'document_id' => Document::factory()->withoutTranslations(),
'locale' => 'en',
'title' => $title,
'content' => $content,
'rendered_html' => '<p>' . e($content) . '</p>',
];
}
}
@@ -2,6 +2,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
@@ -27,7 +28,9 @@ public function up(): void
// FULLTEXT検索インデックス(MySQL 5.7以降)
// ngramトークナイザーは日本語対応に必要だが、設定が必要
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram');
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram');
}
// 検索パフォーマンス向上用インデックス
Schema::table('documents', function (Blueprint $table) {
@@ -0,0 +1,115 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. Add default_locale to documents
Schema::table('documents', function (Blueprint $table) {
$table->string('default_locale', 10)
->default(config('app.locale', 'en'))
->after('slug');
});
// 2. Create document_translations
Schema::create('document_translations', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained('documents')->cascadeOnDelete();
$table->string('locale', 10);
$table->string('title');
$table->text('content');
$table->text('rendered_html')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['document_id', 'locale']);
$table->index(['locale', 'title']);
});
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE document_translations ADD FULLTEXT INDEX document_translations_search_index (title, content) WITH PARSER ngram');
}
// 3. Migrate existing data
// Note: step 4 (ALTER TABLE … DROP INDEX) must remain OUTSIDE this
// transaction because MySQL's ALTER TABLE causes an implicit commit.
$defaultLocale = config('app.locale', 'en');
$now = now();
DB::transaction(function () use ($defaultLocale, $now) {
DB::table('documents')->orderBy('id')->chunkById(500, function ($rows) use ($defaultLocale, $now) {
foreach ($rows as $row) {
DB::table('document_translations')->insert([
'document_id' => $row->id,
'locale' => $defaultLocale,
'title' => $row->title ?? '',
'content' => $row->content ?? '',
'rendered_html' => $row->rendered_html,
'created_by' => $row->created_by ?? null,
'updated_by' => $row->updated_by ?? null,
'created_at' => $row->created_at ?? $now,
'updated_at' => $row->updated_at ?? $now,
]);
DB::table('documents')->where('id', $row->id)->update(['default_locale' => $defaultLocale]);
}
});
});
// 4. Drop the old FULLTEXT index on documents (MySQL only)
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documents DROP INDEX documents_search_index');
}
// 5. Drop translatable columns from documents
// Drop the title index first (SQLite requires this before dropping the column)
Schema::table('documents', function (Blueprint $table) {
$table->dropIndex(['title']);
$table->dropColumn(['title', 'content', 'rendered_html']);
});
}
public function down(): void
{
// IRREVERSIBLE for non-default-locale translations: only the row matching
// each document's default_locale is restored to the legacy columns; any
// other-locale translations are dropped along with document_translations.
// Re-add columns (with the title index that up() expects to drop)
Schema::table('documents', function (Blueprint $table) {
$table->string('title')->nullable()->index()->after('default_locale');
$table->text('content')->nullable()->after('title');
$table->text('rendered_html')->nullable()->after('content');
});
// Restore data from default_locale translation
$rows = DB::table('document_translations as t')
->join('documents as d', 'd.id', '=', 't.document_id')
->whereColumn('t.locale', 'd.default_locale')
->select('t.document_id', 't.title', 't.content', 't.rendered_html')
->get();
foreach ($rows as $row) {
DB::table('documents')->where('id', $row->document_id)->update([
'title' => $row->title,
'content' => $row->content,
'rendered_html' => $row->rendered_html,
]);
}
// Restore FULLTEXT on documents (MySQL only)
if (DB::connection()->getDriverName() === 'mysql') {
DB::statement('ALTER TABLE documents ADD FULLTEXT INDEX documents_search_index (title, content) WITH PARSER ngram');
}
Schema::dropIfExists('document_translations');
Schema::table('documents', function (Blueprint $table) {
$table->dropColumn('default_locale');
});
}
};
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class DocumentMigrationTest extends TestCase
{
use RefreshDatabase;
public function test_documents_table_has_default_locale_after_migration(): void
{
$this->assertTrue(Schema::hasColumn('documents', 'default_locale'));
}
public function test_documents_table_no_longer_has_translatable_columns(): void
{
$this->assertFalse(Schema::hasColumn('documents', 'title'));
$this->assertFalse(Schema::hasColumn('documents', 'content'));
$this->assertFalse(Schema::hasColumn('documents', 'rendered_html'));
}
public function test_document_translations_table_exists_with_required_columns(): void
{
$this->assertTrue(Schema::hasTable('document_translations'));
foreach (['document_id', 'locale', 'title', 'content', 'rendered_html', 'created_by', 'updated_by', 'created_at', 'updated_at'] as $col) {
$this->assertTrue(
Schema::hasColumn('document_translations', $col),
"document_translations missing column: $col"
);
}
}
public function test_document_translations_unique_document_locale(): void
{
DB::table('documents')->insert([
'path' => 'A.md',
'slug' => 'a',
'default_locale' => 'en',
'file_size' => 0,
'file_hash' => str_repeat('0', 64),
'file_modified_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$docId = DB::table('documents')->where('slug', 'a')->value('id');
DB::table('document_translations')->insert([
'document_id' => $docId,
'locale' => 'en',
'title' => 'A',
'content' => '...',
'rendered_html' => '<p>...</p>',
'created_at' => now(),
'updated_at' => now(),
]);
$this->expectException(\Illuminate\Database\QueryException::class);
DB::table('document_translations')->insert([
'document_id' => $docId,
'locale' => 'en',
'title' => 'duplicate',
'content' => '...',
'rendered_html' => '<p>...</p>',
'created_at' => now(),
'updated_at' => now(),
]);
}
public function test_existing_documents_data_is_copied_to_translations(): void
{
// Roll the new migration back so the legacy columns exist again
\Illuminate\Support\Facades\Artisan::call('migrate:rollback', ['--step' => 1]);
$this->assertTrue(\Illuminate\Support\Facades\Schema::hasColumn('documents', 'title'));
// Seed a legacy document row directly
\Illuminate\Support\Facades\DB::table('documents')->insert([
'path' => 'Legacy.md',
'title' => 'Legacy Title',
'slug' => 'legacy-title',
'content' => '# Legacy body',
'rendered_html' => '<h1>Legacy body</h1>',
'file_size' => 0,
'file_hash' => str_repeat('0', 64),
'file_modified_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$docId = \Illuminate\Support\Facades\DB::table('documents')->where('slug', 'legacy-title')->value('id');
// Re-run the migration
\Illuminate\Support\Facades\Artisan::call('migrate');
// Verify the data was copied to document_translations
$translation = \Illuminate\Support\Facades\DB::table('document_translations')
->where('document_id', $docId)
->first();
$this->assertNotNull($translation, 'Translation row should have been created from legacy data');
$this->assertSame('Legacy Title', $translation->title);
$this->assertSame('# Legacy body', $translation->content);
$this->assertSame('<h1>Legacy body</h1>', $translation->rendered_html);
$this->assertSame(config('app.locale', 'en'), $translation->locale);
// Verify documents.default_locale was set
$defaultLocale = \Illuminate\Support\Facades\DB::table('documents')->where('id', $docId)->value('default_locale');
$this->assertSame(config('app.locale', 'en'), $defaultLocale);
}
}
@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit\Models;
use App\Models\Document;
use App\Models\DocumentTranslation;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DocumentTranslationTest extends TestCase
{
use RefreshDatabase;
public function test_belongs_to_a_document(): void
{
$doc = Document::factory()->create();
$translation = $doc->translations()->first();
$this->assertInstanceOf(Document::class, $translation->document);
$this->assertSame($doc->id, $translation->document->id);
}
public function test_unique_document_locale_constraint(): void
{
$doc = Document::factory()->create(['default_locale' => 'en']);
$this->expectException(QueryException::class);
DocumentTranslation::create([
'document_id' => $doc->id,
'locale' => 'en',
'title' => 'Duplicate',
'content' => 'x',
'rendered_html' => '<p>x</p>',
]);
}
public function test_cascade_delete_when_document_deleted(): void
{
$doc = Document::factory()->create();
$translationId = $doc->translations()->first()->id;
$doc->forceDelete();
$this->assertNull(DocumentTranslation::find($translationId));
}
public function test_render_markdown_converts_basic_markdown(): void
{
$html = DocumentTranslation::renderMarkdown('# Hello');
$this->assertStringContainsString('<h1>', $html);
$this->assertStringContainsString('Hello', $html);
}
}