# 機能設計書 18-未公開エントリの保存

## 概要

本ドキュメントは、Decap CMSにおけるエディトリアルワークフロー下でのエントリ保存機能の設計を記述する。ブランチベースのPR運用による未公開エントリの保存処理の仕様を網羅する。

### 本機能の処理概要

未公開エントリの保存機能は、エディトリアルワークフローが有効な場合のエントリ保存処理を提供する。通常の保存（persistEntry）とは異なり、メインブランチではなく専用のブランチに保存し、PR（プルリクエスト）として管理する。

**業務上の目的・背景**：エディトリアルワークフローでは、エントリの変更を直接メインブランチにマージせず、レビュープロセスを経てから公開する。この機能はワークフローの「保存」ステップを担い、変更をブランチとして安全に保存する。

**機能の利用シーン**：エントリ編集画面またはエントリ新規作成画面で「保存」ボタンを押下した際、`publish_mode: editorial_workflow`設定が有効であれば、この機能が使用される。新規保存と既存未公開エントリの更新保存の2パターンがある。

**主要な処理内容**：
1. バリデーションエラーチェック（fieldsErrorsの確認）
2. 重複スラッグチェック（publishedSlugs + unpublishedSlugsの結合）
3. エントリデータのシリアライズ（ウィジェット値シリアライザー適用、i18nシリアライズ）
4. 新規 or 既存の判定に基づき、`persistEntry`または`persistUnpublishedEntry`をバックエンドで呼び出し
5. 保存成功後の通知、スラッグ変更時の画面遷移

**関連システム・外部連携**：Gitバックエンド（GitHub/GitLab/Bitbucket等）のブランチ作成・更新APIと連携。新規保存時はpersistEntry、既存更新時はpersistUnpublishedEntryが使い分けられる。

**権限による制御**：保存操作にはバックエンドリポジトリへの書き込み権限が必要。Open Authoringモードではフォーク先への書き込み権限が必要。

## 関連画面

| 画面No | 画面名 | 関連種別 | 関連する操作・処理 |
|--------|--------|----------|------------------|
| 6 | エントリ編集画面 | 主画面 | Editorial Workflow有効時の保存処理 |
| 7 | エントリ新規作成画面 | 主画面 | Editorial Workflow有効時のブランチベースでの未公開エントリ保存処理 |

## 機能種別

CRUD操作（Create/Update） - ブランチベースのエントリ保存

## 入力仕様

### 入力パラメータ

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| collection | Collection (Immutable.Map) | Yes | 保存対象のコレクション | - |
| existingUnpublishedEntry | boolean | Yes | 既存の未公開エントリかどうか | true: 更新保存、false: 新規保存 |

### 暗黙的入力

| データ | 型 | 取得元 | 説明 |
|--------|-----|--------|------|
| entryDraft | EntryDraft | Redux state.entryDraft | 現在のドラフトエントリ |
| fieldsErrors | Map | entryDraft.fieldsErrors | バリデーションエラー |
| usedSlugs | List<string> | publishedSlugs + unpublishedSlugs | 使用済みスラッグ一覧 |

### 入力データソース

エントリ編集/新規作成画面の「保存」ボタン操作。Redux状態からドラフトデータを取得する。

## 出力仕様

### 出力データ

| 項目名 | 型 | 説明 |
|--------|-----|------|
| newSlug | string | 保存後のエントリスラッグ（新規作成時にバックエンドから返却） |
| entry | EntryMap | シリアライズ済みのエントリデータ |

### 出力先

- バックエンドAPI（Git操作: ブランチ作成/更新、ファイル書き込み）
- Redux Store（`editorialWorkflow.entities`にエントリを追加/更新）
- UIトースト通知（保存成功/失敗メッセージ）

## 処理フロー

### 処理シーケンス

```
1. ユーザーが保存ボタンを押下
   └─ persistUnpublishedEntryアクションをディスパッチ
2. バリデーションエラーチェック
   └─ fieldsErrorsが空でない場合は保存中断（PRESENCE型ならトースト通知）
3. 未公開エントリ一覧が未読み込みの場合はloadUnpublishedEntriesを実行
4. 使用済みスラッグを収集（published + unpublished）
5. エントリデータをシリアライズ
   └─ getSerializedEntryでフィールド値をシリアライズ
   └─ i18n対応のシリアライズも実行
6. UNPUBLISHED_ENTRY_PERSIST_REQUESTをディスパッチ
7. 保存処理の分岐
   └─ existingUnpublishedEntry=true: backend.persistUnpublishedEntry
   └─ existingUnpublishedEntry=false: backend.persistEntry
8. 保存成功
   └─ UNPUBLISHED_ENTRY_PERSIST_SUCCESS dispatch
   └─ ui.toast.entrySaved通知
   └─ スラッグが変更された場合: loadUnpublishedEntry + navigateToEntry
9. 保存失敗
   └─ UNPUBLISHED_ENTRY_PERSIST_FAILURE dispatch
   └─ ui.toast.onFailToPersistエラー通知
```

### フローチャート

```mermaid
flowchart TD
    A[保存ボタン押下] --> B{fieldsErrorsが空?}
    B -->|No| C{PRESENCE型エラー?}
    C -->|Yes| D[missingRequiredFieldトースト]
    C -->|No| E[保存ブロック]
    D --> E
    B -->|Yes| F{entriesLoaded?}
    F -->|No| G[loadUnpublishedEntries実行]
    F -->|Yes| H[usedSlugs収集]
    G --> H
    H --> I[getSerializedEntry]
    I --> J[PERSIST_REQUEST dispatch]
    J --> K{existingUnpublishedEntry?}
    K -->|Yes| L[backend.persistUnpublishedEntry]
    K -->|No| M[backend.persistEntry]
    L --> N{成功?}
    M --> N
    N -->|Yes| O[PERSIST_SUCCESS + 通知]
    O --> P{スラッグ変更?}
    P -->|Yes| Q[loadUnpublishedEntry + navigateToEntry]
    P -->|No| R[完了]
    Q --> R
    N -->|No| S[PERSIST_FAILURE + エラー通知]
```

## ビジネスルール

### 業務ルール

| ルールNo | ルール名 | 内容 | 適用条件 |
|---------|---------|------|---------|
| BR-18-01 | バリデーション必須 | fieldsErrorsが空でない場合は保存をブロックする | persistUnpublishedEntry時 |
| BR-18-02 | スラッグ重複チェック | publishedSlugsとunpublishedSlugsの両方を結合してスラッグ重複を検査 | persistUnpublishedEntry時 |
| BR-18-03 | 保存方法分岐 | existingUnpublishedEntry=trueならpersistUnpublishedEntry、falseならpersistEntryを使用 | persistUnpublishedEntry時 |
| BR-18-04 | シリアライズ必須 | 保存前にウィジェット値のシリアライズとi18nデータのシリアライズを実行 | persistUnpublishedEntry時 |
| BR-18-05 | スラッグ変更対応 | 保存後にスラッグが変更された場合、新スラッグでエントリを再読み込みし画面遷移する | 保存成功後 |
| BR-18-06 | 未公開エントリ自動読み込み | entriesLoadedがfalseの場合、保存前に未公開エントリ一覧を読み込む | persistUnpublishedEntry時 |

### 計算ロジック

- usedSlugs: `publishedSlugs.concat(unpublishedSlugs)`
- シリアライズ: `getSerializedEntry(collection, entry)` → `serializeValues(data, fields)` + `serializeI18n()`

## データベース操作仕様

### 操作別データベース影響一覧

| 操作 | 対象 | 操作種別 | 概要 |
|-----|------|---------|------|
| persistEntry | Git API | INSERT | 新規ブランチ作成 + ファイル書き込み + PR作成 |
| persistUnpublishedEntry | Git API | UPDATE | 既存ブランチのファイル更新 |

### テーブル別操作詳細

Gitバックエンドの操作であり、テーブルではなくGitリポジトリのブランチ・ファイル・PR操作が行われる。

## エラー処理

### エラーケース一覧

| エラーコード | エラー種別 | 発生条件 | 対処方法 |
|------------|----------|---------|---------|
| UNPUBLISHED_ENTRY_PERSIST_FAILURE | 保存エラー | バックエンドAPIの失敗 | エラー通知、isPersisting=false |
| - | バリデーションエラー | fieldsErrorsが空でない | 保存ブロック、トースト通知 |
| - | スラッグ重複 | usedSlugsに同名スラッグが存在 | バックエンドが処理（スラッグにサフィックス追加等） |

### リトライ仕様

自動リトライは行わない。ユーザーが再度保存操作を実行する。

## トランザクション仕様

Reduxの楽観的更新は行わず、バックエンドの応答を待ってから状態を更新する。isPersistingフラグで保存中状態を管理し、成功/失敗に応じて状態を更新する。

## パフォーマンス要件

- バックエンドAPI（Git操作）の応答時間に依存
- シリアライズ処理はクライアントサイドで行われ、通常は即時完了
- 大量のメディアファイルを含む場合はアップロード時間が増加

## セキュリティ考慮事項

- 保存操作はバックエンドAPIの認証を必要とする
- シリアライズ処理でデータが改変される可能性があるため、シリアライザーの信頼性が重要

## 備考

- `persistEntry`と`persistUnpublishedEntry`はバックエンド側で異なるGit操作を行う。新規は新しいブランチを作成し、既存は既存ブランチを更新する。
- `.call(backend, ...)`の形式でthisコンテキストを明示的にバインドしている点に注意

---

## コードリーディングガイド

本機能を理解するために参照すべきファイルと、推奨する読み解き順序を以下に示す。

### 推奨読解順序

#### Step 1: データ構造を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | Entry.ts | `packages/decap-cms-core/src/valueObjects/Entry.ts` | EntryValueインターフェース（23-42行目） |
| 1-2 | editorialWorkflow.ts (reducer) | `packages/decap-cms-core/src/reducers/editorialWorkflow.ts` | PERSIST_REQUEST/SUCCESS/FAILUREケース（71-99行目）での状態管理 |

**読解のコツ**: 保存処理は2種類のバックエンドメソッドを使い分ける。`existingUnpublishedEntry`フラグで分岐するが、どちらも同じ引数インターフェースを使用する。

#### Step 2: アクションクリエータを理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | editorialWorkflow.ts (action) | `packages/decap-cms-core/src/actions/editorialWorkflow.ts` | persistUnpublishedEntry関数（323-409行目） |

**主要処理フロー**:
1. **326-327行目**: state/entryDraftの取得
2. **328-330行目**: usedSlugs の生成（published + unpublished）
3. **337-354行目**: fieldsErrorsバリデーションチェック
4. **362-363行目**: エントリシリアライズ
5. **366-368行目**: 保存メソッドの分岐（existingUnpublishedEntry）
6. **370-377行目**: `.call(backend, ...)` でバックエンドの保存メソッドを呼び出し
7. **389-392行目**: スラッグ変更時の再読み込みとナビゲーション

#### Step 3: リデューサーの状態管理を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | editorialWorkflow.ts (reducer) | `packages/decap-cms-core/src/reducers/editorialWorkflow.ts` | PERSIST関連ケース（71-99行目） |
| 3-2 | entryDraft.js | `packages/decap-cms-core/src/reducers/entryDraft.js` | isPersisting管理（136-168行目） |

**主要処理フロー**:
- **71-76行目**: PERSIST_REQUESTでisPersisting=true
- **78-93行目**: PERSIST_SUCCESSでエントリを楽観的にentitiesに追加、isPersistingを削除
- **95-99行目**: PERSIST_FAILUREでisPersisting=false

### プログラム呼び出し階層図

```
エントリ編集画面 / 新規作成画面（保存ボタン）
    │
    └─ persistUnpublishedEntry(collection, existingUnpublishedEntry)
           [actions/editorialWorkflow.ts:323-409]
           │
           ├─ fieldsErrors.isEmpty() チェック
           │      └─ 非空: トースト + Promise.reject()
           │
           ├─ loadUnpublishedEntries（entriesLoaded=false時）
           │
           ├─ getSerializedEntry(collection, entry)
           │      [actions/entries.ts:873-891]
           │      ├─ serializeValues(data, fields)
           │      └─ serializeI18n(collection, entry, serializeData)
           │
           ├─ dispatch(UNPUBLISHED_ENTRY_PERSIST_REQUEST)
           │
           ├─ persistAction.call(backend, { config, collection, entryDraft, assetProxies, usedSlugs })
           │      ├─ backend.persistEntry（新規）
           │      └─ backend.persistUnpublishedEntry（既存）
           │
           ├─ 成功: dispatch(UNPUBLISHED_ENTRY_PERSIST_SUCCESS)
           │      └─ スラッグ変更時: loadUnpublishedEntry + navigateToEntry
           │
           └─ 失敗: dispatch(UNPUBLISHED_ENTRY_PERSIST_FAILURE)
```

### データフロー図

```
[入力]                          [処理]                              [出力]

entryDraft.entry          ───▶  getSerializedEntry           ───▶  シリアライズ済みエントリ
(フィールド値)                   ├─ serializeValues
                                └─ serializeI18n

シリアライズ済みエントリ  ───▶  persistAction.call(backend)  ───▶  Gitリポジトリ
assetProxies                     ├─ persistEntry（新規）             ├─ ブランチ作成/更新
usedSlugs                        └─ persistUnpublishedEntry（既存）  └─ ファイル書き込み

                                         │
                                         ▼
newSlug                   ───▶  PERSIST_SUCCESS dispatch     ───▶  editorialWorkflow.entities
                                                                    entryDraft.isPersisting = false
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| editorialWorkflow.ts (action) | `packages/decap-cms-core/src/actions/editorialWorkflow.ts` | ソース | persistUnpublishedEntryアクション |
| editorialWorkflow.ts (reducer) | `packages/decap-cms-core/src/reducers/editorialWorkflow.ts` | ソース | PERSIST関連の状態管理 |
| entries.ts (action) | `packages/decap-cms-core/src/actions/entries.ts` | ソース | getSerializedEntry、getMediaAssets |
| entryDraft.js | `packages/decap-cms-core/src/reducers/entryDraft.js` | ソース | isPersisting管理 |
| serializeEntryValues.ts | `packages/decap-cms-core/src/lib/serializeEntryValues.ts` | ソース | serializeValues関数 |
| i18n.ts | `packages/decap-cms-core/src/lib/i18n.ts` | ソース | serializeI18n関数 |
