/**
* バックアップセッション管理
*/
import { readFile, writeFile, mkdir, readdir, rm } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import type { BackupSession, BackupItem, TransformedItem, DiffReport } from '../types/index.js';
// バックアップディレクトリ
const BACKUP_DIR = join(homedir(), '.config', 'mt-content-refactor', 'backups');
// 現在のセッションをメモリに保持
let currentSession: BackupSession | null = null;
let transformedItems: Map<string, TransformedItem> = new Map();
/**
* バックアップディレクトリを初期化
*/
async function ensureBackupDir(): Promise<void> {
if (!existsSync(BACKUP_DIR)) {
await mkdir(BACKUP_DIR, { recursive: true });
}
}
/**
* セッションIDを生成
*/
function generateSessionId(): string {
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const random = Math.random().toString(36).slice(2, 8);
return `${timestamp}_${random}`;
}
/**
* セッションのディレクトリパス
*/
function getSessionDir(sessionId: string): string {
return join(BACKUP_DIR, sessionId);
}
/**
* 新しいバックアップセッションを作成
*/
export async function createBackupSession(connectionId: string): Promise<BackupSession> {
await ensureBackupDir();
const sessionId = generateSessionId();
const session: BackupSession = {
sessionId,
connectionId,
createdAt: new Date().toISOString(),
items: [],
status: 'created',
};
// セッションディレクトリを作成
const sessionDir = getSessionDir(sessionId);
await mkdir(sessionDir, { recursive: true });
// マニフェストを保存
await writeFile(
join(sessionDir, 'manifest.json'),
JSON.stringify(session, null, 2)
);
currentSession = session;
transformedItems.clear();
return session;
}
/**
* バックアップアイテムを追加
*/
export async function addBackupItem(item: BackupItem): Promise<void> {
if (!currentSession) {
throw new Error('アクティブなバックアップセッションがありません');
}
const sessionDir = getSessionDir(currentSession.sessionId);
const itemDir = join(sessionDir, `${item.type}_${item.id}`);
await mkdir(itemDir, { recursive: true });
// before.html を保存
await writeFile(join(itemDir, 'before.html'), item.beforeHtml);
// meta.json を保存
await writeFile(
join(itemDir, 'meta.json'),
JSON.stringify({
type: item.type,
id: item.id,
siteId: item.siteId,
title: item.title,
fieldName: item.fieldName,
}, null, 2)
);
currentSession.items.push(item);
// マニフェストを更新
await writeFile(
join(sessionDir, 'manifest.json'),
JSON.stringify(currentSession, null, 2)
);
}
/**
* 変換後のHTMLを設定
*/
export async function setTransformedHtml(
itemType: string,
itemId: number,
afterHtml: string
): Promise<void> {
if (!currentSession) {
throw new Error('アクティブなバックアップセッションがありません');
}
const key = `${itemType}_${itemId}`;
const item = currentSession.items.find(i => i.type === itemType && i.id === itemId);
if (!item) {
throw new Error(`アイテム ${key} がバックアップに見つかりません`);
}
const transformedItem: TransformedItem = {
...item,
afterHtml,
};
transformedItems.set(key, transformedItem);
// after.html を保存
const sessionDir = getSessionDir(currentSession.sessionId);
const itemDir = join(sessionDir, key);
await writeFile(join(itemDir, 'after.html'), afterHtml);
}
/**
* 現在のセッションを取得
*/
export function getCurrentSession(): BackupSession | null {
return currentSession;
}
/**
* 変換済みアイテムを取得
*/
export function getTransformedItems(): TransformedItem[] {
return Array.from(transformedItems.values());
}
/**
* 差分レポートを生成
*/
export async function generateDiffReport(): Promise<DiffReport> {
if (!currentSession) {
throw new Error('アクティブなバックアップセッションがありません');
}
const items: DiffReport['items'] = [];
let changed = 0;
let unchanged = 0;
for (const item of currentSession.items) {
const key = `${item.type}_${item.id}`;
const transformed = transformedItems.get(key);
const afterHtml = transformed?.afterHtml || item.beforeHtml;
const hasChanges = afterHtml !== item.beforeHtml;
if (hasChanges) {
changed++;
} else {
unchanged++;
}
items.push({
type: item.type,
id: item.id,
title: item.title,
fieldName: item.fieldName,
beforeHtml: item.beforeHtml,
afterHtml,
hasChanges,
});
}
const report: DiffReport = {
sessionId: currentSession.sessionId,
generatedAt: new Date().toISOString(),
items,
summary: {
total: items.length,
changed,
unchanged,
},
};
// レポートをファイルに保存
const sessionDir = getSessionDir(currentSession.sessionId);
await writeFile(
join(sessionDir, 'diff-report.json'),
JSON.stringify(report, null, 2)
);
return report;
}
/**
* HTMLの差分レポートを生成(差分ハイライト付き)
*/
export async function generateHtmlDiffReport(): Promise<string> {
const report = await generateDiffReport();
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>差分レポート - ${report.sessionId}</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
.container { max-width: 1400px; margin: 0 auto; }
h1 { color: #333; }
.summary { background: #fff; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.summary-stats { display: flex; gap: 20px; margin-top: 10px; }
.stat { padding: 10px 20px; border-radius: 4px; }
.stat-total { background: #e3f2fd; color: #1565c0; }
.stat-changed { background: #fff3e0; color: #e65100; }
.stat-unchanged { background: #e8f5e9; color: #2e7d32; }
.item { background: #fff; border-radius: 8px; margin-bottom: 20px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.item-header { padding: 15px 20px; background: #f8f9fa; border-bottom: 1px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; }
.item-title { font-weight: 600; color: #333; }
.item-meta { color: #666; font-size: 0.9em; }
.badge { padding: 4px 8px; border-radius: 4px; font-size: 0.8em; font-weight: 500; }
.badge-changed { background: #fff3e0; color: #e65100; }
.badge-unchanged { background: #e8f5e9; color: #2e7d32; }
.diff-container { display: grid; grid-template-columns: 1fr 1fr; }
.diff-pane { padding: 15px; }
.diff-pane-before { background: #fff5f5; border-right: 1px solid #dee2e6; }
.diff-pane-after { background: #f5fff5; }
.diff-label { font-size: 0.8em; color: #666; margin-bottom: 10px; font-weight: 500; text-transform: uppercase; }
.diff-content { font-family: Monaco, Consolas, monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; background: #fff; padding: 10px; border-radius: 4px; border: 1px solid #dee2e6; max-height: 500px; overflow: auto; }
/* 差分ハイライト */
.diff-line { display: block; padding: 2px 4px; margin: 0 -4px; border-radius: 2px; }
.diff-line-removed { background: #ffcdd2; text-decoration: line-through; color: #b71c1c; }
.diff-line-added { background: #c8e6c9; color: #1b5e20; }
.diff-line-unchanged { color: #666; }
.diff-inline-removed { background: #ef9a9a; padding: 1px 3px; border-radius: 2px; text-decoration: line-through; }
.diff-inline-added { background: #a5d6a7; padding: 1px 3px; border-radius: 2px; }
</style>
</head>
<body>
<div class="container">
<h1>📋 差分レポート</h1>
<div class="summary">
<div><strong>セッションID:</strong> ${report.sessionId}</div>
<div><strong>生成日時:</strong> ${new Date(report.generatedAt).toLocaleString('ja-JP')}</div>
<div class="summary-stats">
<div class="stat stat-total"><strong>${report.summary.total}</strong> 件</div>
<div class="stat stat-changed"><strong>${report.summary.changed}</strong> 件変更</div>
<div class="stat stat-unchanged"><strong>${report.summary.unchanged}</strong> 件変更なし</div>
</div>
</div>
${report.items.map(item => {
const diffHtml = generateLineDiff(item.beforeHtml, item.afterHtml);
return `
<div class="item">
<div class="item-header">
<div>
<span class="item-title">${escapeHtml(item.title)}</span>
<span class="item-meta">(${item.type} #${item.id} - ${item.fieldName})</span>
</div>
<span class="badge ${item.hasChanges ? 'badge-changed' : 'badge-unchanged'}">
${item.hasChanges ? '変更あり' : '変更なし'}
</span>
</div>
<div class="diff-container">
<div class="diff-pane diff-pane-before">
<div class="diff-label">変更前</div>
<div class="diff-content">${diffHtml.before}</div>
</div>
<div class="diff-pane diff-pane-after">
<div class="diff-label">変更後</div>
<div class="diff-content">${diffHtml.after}</div>
</div>
</div>
</div>
`;
}).join('')}
</div>
</body>
</html>`;
// HTMLファイルを保存
const sessionDir = getSessionDir(currentSession!.sessionId);
const htmlPath = join(sessionDir, 'diff-report.html');
await writeFile(htmlPath, html);
return htmlPath;
}
/**
* 行単位の差分を生成してHTMLを返す
*/
function generateLineDiff(before: string, after: string): { before: string; after: string } {
const beforeLines = before.split('\n');
const afterLines = after.split('\n');
// LCS(最長共通部分列)ベースの差分検出
const lcs = computeLCS(beforeLines, afterLines);
let beforeHtml = '';
let afterHtml = '';
let bi = 0, ai = 0, li = 0;
while (bi < beforeLines.length || ai < afterLines.length) {
if (li < lcs.length && bi < beforeLines.length && ai < afterLines.length &&
beforeLines[bi] === lcs[li] && afterLines[ai] === lcs[li]) {
// 共通行
beforeHtml += `<span class="diff-line diff-line-unchanged">${escapeHtml(beforeLines[bi])}</span>\n`;
afterHtml += `<span class="diff-line diff-line-unchanged">${escapeHtml(afterLines[ai])}</span>\n`;
bi++; ai++; li++;
} else if (bi < beforeLines.length && (li >= lcs.length || beforeLines[bi] !== lcs[li])) {
// 削除行
beforeHtml += `<span class="diff-line diff-line-removed">${escapeHtml(beforeLines[bi])}</span>\n`;
bi++;
} else if (ai < afterLines.length && (li >= lcs.length || afterLines[ai] !== lcs[li])) {
// 追加行
afterHtml += `<span class="diff-line diff-line-added">${escapeHtml(afterLines[ai])}</span>\n`;
ai++;
}
}
return { before: beforeHtml || escapeHtml(before), after: afterHtml || escapeHtml(after) };
}
/**
* LCS(最長共通部分列)を計算
*/
function computeLCS(a: string[], b: string[]): string[] {
const m = a.length;
const n = b.length;
const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// LCSを復元
const lcs: string[] = [];
let i = m, j = n;
while (i > 0 && j > 0) {
if (a[i - 1] === b[j - 1]) {
lcs.unshift(a[i - 1]);
i--; j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return lcs;
}
/**
* HTMLエスケープ
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* セッション一覧を取得
*/
export async function listBackupSessions(): Promise<BackupSession[]> {
await ensureBackupDir();
const dirs = await readdir(BACKUP_DIR);
const sessions: BackupSession[] = [];
for (const dir of dirs) {
const manifestPath = join(BACKUP_DIR, dir, 'manifest.json');
if (existsSync(manifestPath)) {
const content = await readFile(manifestPath, 'utf-8');
sessions.push(JSON.parse(content));
}
}
return sessions.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
/**
* セッションを読み込み
*/
export async function loadBackupSession(sessionId: string): Promise<BackupSession | null> {
const manifestPath = join(BACKUP_DIR, sessionId, 'manifest.json');
if (!existsSync(manifestPath)) {
return null;
}
const content = await readFile(manifestPath, 'utf-8');
const session: BackupSession = JSON.parse(content);
currentSession = session;
transformedItems.clear();
// 変換済みアイテムを読み込み
for (const item of session.items) {
const key = `${item.type}_${item.id}`;
const afterPath = join(BACKUP_DIR, sessionId, key, 'after.html');
if (existsSync(afterPath)) {
const afterHtml = await readFile(afterPath, 'utf-8');
transformedItems.set(key, { ...item, afterHtml });
}
}
return session;
}
/**
* セッションを削除
*/
export async function deleteBackupSession(sessionId: string): Promise<boolean> {
const sessionDir = getSessionDir(sessionId);
if (!existsSync(sessionDir)) {
return false;
}
await rm(sessionDir, { recursive: true });
if (currentSession?.sessionId === sessionId) {
currentSession = null;
transformedItems.clear();
}
return true;
}
/**
* セッションのステータスを更新
*/
export async function updateSessionStatus(status: BackupSession['status']): Promise<void> {
if (!currentSession) {
throw new Error('アクティブなバックアップセッションがありません');
}
currentSession.status = status;
const sessionDir = getSessionDir(currentSession.sessionId);
await writeFile(
join(sessionDir, 'manifest.json'),
JSON.stringify(currentSession, null, 2)
);
}