diff --git a/apps/desktop/app/hooks/export.ts b/apps/desktop/app/hooks/export.ts new file mode 100644 index 0000000..7868ebd --- /dev/null +++ b/apps/desktop/app/hooks/export.ts @@ -0,0 +1,20 @@ +import { useAdapter } from '~/hooks/useAdapter'; +import useSWRMutation from 'swr/mutation'; + +interface ExportKey { + type: 'export'; +} + +/** + * プロジェクトをエクスポートする(保存ダイアログを含む) + */ +export function useExportProject() { + const adapter = useAdapter(); + return useSWRMutation( + { type: 'export' }, + async (_, { arg: projectId }) => { + await adapter.export.exportProject(projectId); + return undefined; + }, + ); +} diff --git a/apps/desktop/app/routes/(private)/home/page.tsx b/apps/desktop/app/routes/(private)/home/page.tsx index 1644fdf..f27e0af 100644 --- a/apps/desktop/app/routes/(private)/home/page.tsx +++ b/apps/desktop/app/routes/(private)/home/page.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useNavigate, type MetaFunction } from 'react-router'; import { useProjects, useCreateProject } from '~/hooks/projects'; import { useLogout } from '~/hooks/auth'; +import { useExportProject } from '~/hooks/export'; import { ProjectList, Button, @@ -37,10 +38,14 @@ export default function Page() { const { data: projects, isLoading } = useProjects(); const { trigger: createProject, isMutating: isCreating } = useCreateProject(); const { trigger: logout, isMutating: isLoggingOut } = useLogout(); + const { trigger: exportProject } = useExportProject(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [newProjectTitle, setNewProjectTitle] = useState('untitled'); const [error, setError] = useState(null); + const [exportingProjectId, setExportingProjectId] = useState( + null, + ); const defaultWorkDir = '~/TsumugiProjects'; @@ -73,6 +78,15 @@ export default function Page() { navigate(`${PATH_WORKSPACE}/${encodeURIComponent(project.id)}`); }; + const handleExportProject = async (project: ProjectItem) => { + setExportingProjectId(project.id); + try { + await exportProject(project.id); + } finally { + setExportingProjectId(null); + } + }; + return (
@@ -122,6 +136,8 @@ export default function Page() { projects={toProjectItems(projects)} isLoading={isLoading} onSelect={handleSelectProject} + onExport={handleExportProject} + exportingProjectId={exportingProjectId} />
diff --git a/apps/desktop/app/routes/(private)/workspace/[projectId]/_components/editors/project-editor-wrapper.tsx b/apps/desktop/app/routes/(private)/workspace/[projectId]/_components/editors/project-editor-wrapper.tsx index 4143370..13bbf36 100644 --- a/apps/desktop/app/routes/(private)/workspace/[projectId]/_components/editors/project-editor-wrapper.tsx +++ b/apps/desktop/app/routes/(private)/workspace/[projectId]/_components/editors/project-editor-wrapper.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { useProject, useUpdateProject } from '~/hooks/projects'; +import { useExportProject } from '~/hooks/export'; import { ProjectEditor, type ProjectEditorData } from '@tsumugi/ui'; import { useDebouncedSave } from '~/routes/(private)/workspace/[projectId]/_hooks/useDebouncedSave'; import type { Project } from '@tsumugi/adapter'; @@ -30,6 +31,8 @@ export function ProjectEditorWrapper({ }: ProjectEditorWrapperProps) { const { data: project, mutate } = useProject(projectId, NO_REVALIDATE); const { trigger: updateProject } = useUpdateProject(projectId); + const { trigger: exportProject, isMutating: isExporting } = + useExportProject(); const onSave = useCallback( async (field: string, value: unknown) => { @@ -57,7 +60,18 @@ export function ProjectEditorWrapper({ [mutate, debouncedSave, onNameChange], ); + const handleExport = useCallback(async () => { + await exportProject(projectId); + }, [exportProject, projectId]); + if (!project) return null; - return ; + return ( + + ); } diff --git a/package-lock.json b/package-lock.json index 13b9499..96d7444 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7989,9 +7989,9 @@ } }, "node_modules/@tsumugi-chan/client": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@tsumugi-chan/client/-/client-1.0.7.tgz", - "integrity": "sha512-BKRUZ5L4ggAfbbB5GsWryUmfQ5NYkGBwnFPCg1z2h9h+7c+t00xri5CWr2m3kM8HJye9CqmLVlEpWKD/qVCe9A==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsumugi-chan/client/-/client-1.0.9.tgz", + "integrity": "sha512-2fWAOuS282Cxt9Sp6igALsv66vPNgee5zlHeFyalzAG17tdl7Ey2S0K3jA7v2fZdDimias5cWJjcm6xRleV/Qw==", "license": "MIT" }, "node_modules/@tsumugi/adapter": { @@ -11962,6 +11962,12 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -22476,7 +22482,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { - "@tsumugi-chan/client": "1.0.7", + "@tsumugi-chan/client": "1.0.9", "@tsumugi/adapter": "*" }, "devDependencies": { @@ -22497,6 +22503,7 @@ "@ai-sdk/openai": "3.0.30", "@tsumugi/adapter": "*", "ai": "6.0.94", + "fflate": "0.8.2", "zod": "4.3.6" }, "devDependencies": { diff --git a/packages/adapter/api/package.json b/packages/adapter/api/package.json index c72c4df..d519422 100644 --- a/packages/adapter/api/package.json +++ b/packages/adapter/api/package.json @@ -35,7 +35,7 @@ "test": "TZ=Asia/Tokyo jest" }, "dependencies": { - "@tsumugi-chan/client": "1.0.7", + "@tsumugi-chan/client": "1.0.9", "@tsumugi/adapter": "*" }, "devDependencies": { diff --git a/packages/adapter/api/src/adapter.ts b/packages/adapter/api/src/adapter.ts index 1d7941d..0253002 100644 --- a/packages/adapter/api/src/adapter.ts +++ b/packages/adapter/api/src/adapter.ts @@ -8,6 +8,7 @@ import { createMemoAdapter } from '@/adapters/memo'; import { createWritingAdapter } from '@/adapters/writing'; import { createSettingsAdapter } from '@/adapters/settings'; import { createAIAdapter } from '@/adapters/ai'; +import { createExportAdapter } from '@/adapters/export'; import { TokenManager } from '@/token-manager'; export function createAdapter(config: AdapterConfig = {}): Adapter { @@ -33,5 +34,6 @@ export function createAdapter(config: AdapterConfig = {}): Adapter { memos: createMemoAdapter(clients), writings: createWritingAdapter(clients), ai: createAIAdapter(clients), + export: createExportAdapter(clients), }; } diff --git a/packages/adapter/api/src/adapters/export.ts b/packages/adapter/api/src/adapters/export.ts new file mode 100644 index 0000000..341a32b --- /dev/null +++ b/packages/adapter/api/src/adapters/export.ts @@ -0,0 +1,38 @@ +import type { ExportAdapter, ExportOptions } from '@tsumugi/adapter'; +import type { ApiClients } from '@/client'; + +function triggerBrowserDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function extractFilename(headers: Headers): string { + const disposition = headers.get('Content-Disposition'); + if (disposition) { + const rfcMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (rfcMatch) return decodeURIComponent(rfcMatch[1]); + const match = disposition.match(/filename="?([^";\n]+)"?/i); + if (match) return match[1].trim(); + } + return 'export.zip'; +} + +export function createExportAdapter(clients: ApiClients): ExportAdapter { + return { + async exportProject( + projectId: string, + _options?: ExportOptions, + ): Promise { + const response = await clients.projects.exportProjectRaw({ projectId }); + const blob = await response.raw.blob(); + const filename = extractFilename(response.raw.headers); + triggerBrowserDownload(blob, filename); + }, + }; +} diff --git a/packages/adapter/api/tsconfig.json b/packages/adapter/api/tsconfig.json index 9f7c982..a44dc0e 100644 --- a/packages/adapter/api/tsconfig.json +++ b/packages/adapter/api/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2020", - "lib": ["ES2020"], + "lib": ["ES2020", "DOM"], "module": "ESNext", "moduleResolution": "bundler", "strict": true, diff --git a/packages/adapter/core/src/adapter.ts b/packages/adapter/core/src/adapter.ts index 46bd25f..266e515 100644 --- a/packages/adapter/core/src/adapter.ts +++ b/packages/adapter/core/src/adapter.ts @@ -17,6 +17,7 @@ import type { AIProjectUsage, AuthState, GoogleAuthUrl, + ExportOptions, } from './types'; /** @@ -243,6 +244,18 @@ export interface AuthAdapter { refreshAccessToken(): Promise; } +/** + * エクスポート操作のインターフェース + */ +export interface ExportAdapter { + /** + * プロジェクトをエクスポート + * @param projectId - エクスポートするプロジェクトID + * @param options - エクスポートオプション(省略時はすべてのコンテンツを zip-markdown でエクスポート) + */ + exportProject(projectId: string, options?: ExportOptions): Promise; +} + /** * 統合アダプター */ @@ -255,4 +268,5 @@ export interface Adapter { readonly memos: MemoAdapter; readonly writings: WritingAdapter; readonly ai: AIAdapter; + readonly export: ExportAdapter; } diff --git a/packages/adapter/core/src/stub.ts b/packages/adapter/core/src/stub.ts index 09c36b3..e205c6f 100644 --- a/packages/adapter/core/src/stub.ts +++ b/packages/adapter/core/src/stub.ts @@ -92,5 +92,8 @@ export function createAdapter(_: AdapterConfig = {}): Adapter { deleteMemory: () => notImplemented(), getUsage: () => notImplemented(), }, + export: { + exportProject: () => notImplemented(), + }, }; } diff --git a/packages/adapter/core/src/types.ts b/packages/adapter/core/src/types.ts index 519b291..2d6e3e2 100644 --- a/packages/adapter/core/src/types.ts +++ b/packages/adapter/core/src/types.ts @@ -507,3 +507,24 @@ export interface GoogleAuthUrl { /** Google OAuth認可URL */ url: string; } + +/** + * エクスポートのフォーマット + */ +export type ExportFormat = 'zip-markdown'; + +/** + * エクスポートオプション + */ +export interface ExportOptions { + /** エクスポートフォーマット(デフォルト: 'zip-markdown') */ + format?: ExportFormat; + /** 執筆データを含めるか(デフォルト: true) */ + includeWritings?: boolean; + /** プロットデータを含めるか(デフォルト: true) */ + includePlots?: boolean; + /** キャラクターデータを含めるか(デフォルト: true) */ + includeCharacters?: boolean; + /** メモデータを含めるか(デフォルト: true) */ + includeMemos?: boolean; +} diff --git a/packages/adapter/local/package.json b/packages/adapter/local/package.json index 04a0f1c..a00ab04 100644 --- a/packages/adapter/local/package.json +++ b/packages/adapter/local/package.json @@ -39,6 +39,7 @@ "@ai-sdk/openai": "3.0.30", "@tsumugi/adapter": "*", "ai": "6.0.94", + "fflate": "0.8.2", "zod": "4.3.6" }, "peerDependencies": { diff --git a/packages/adapter/local/src/adapter.ts b/packages/adapter/local/src/adapter.ts index 155d0b3..ce6fd14 100644 --- a/packages/adapter/local/src/adapter.ts +++ b/packages/adapter/local/src/adapter.ts @@ -7,6 +7,7 @@ import { createMemoAdapter } from '@/adapters/memo'; import { createWritingAdapter } from '@/adapters/writing'; import { createAIAdapter } from '@/adapters/ai'; import { createSettingsAdapter } from '@/adapters/settings'; +import { createExportAdapter } from '@/adapters/export'; export function createAdapter(config: AdapterConfig = {}): Adapter { const workDir = config.local?.workDir; @@ -32,5 +33,12 @@ export function createAdapter(config: AdapterConfig = {}): Adapter { memos, writings, }), + export: createExportAdapter({ + projects, + plots, + characters, + memos, + writings, + }), }; } diff --git a/packages/adapter/local/src/adapters/export.ts b/packages/adapter/local/src/adapters/export.ts new file mode 100644 index 0000000..7d92bcb --- /dev/null +++ b/packages/adapter/local/src/adapters/export.ts @@ -0,0 +1,92 @@ +import type { + ExportAdapter, + ExportOptions, + ProjectAdapter, + WritingAdapter, + PlotAdapter, + CharacterAdapter, + MemoAdapter, +} from '@tsumugi/adapter'; +import { zipSync, strToU8 } from 'fflate'; +import { save } from '@tauri-apps/plugin-dialog'; +import { + sanitizeFileName, + buildProjectReadme, + buildWritingEntries, + buildPlotEntries, + buildCharacterEntries, + buildMemoEntries, + type ZipEntry, +} from '@/internal/utils/export-markdown'; +import { writeBinaryFile } from '@/internal/utils/fs'; + +export interface ExportAdapterDeps { + projects: ProjectAdapter; + writings: WritingAdapter; + plots: PlotAdapter; + characters: CharacterAdapter; + memos: MemoAdapter; +} + +export function createExportAdapter(deps: ExportAdapterDeps): ExportAdapter { + const { projects, writings, plots, characters, memos } = deps; + + return { + async exportProject( + projectId: string, + options: ExportOptions = {}, + ): Promise { + const { + includeWritings = true, + includePlots = true, + includeCharacters = true, + includeMemos = true, + } = options; + + const project = await projects.getById(projectId); + if (!project) throw new Error(`Project not found: ${projectId}`); + + const entries: ZipEntry[] = [ + { path: 'README.md', content: buildProjectReadme(project) }, + ]; + + if (includeWritings) { + const ws = await writings.getByProjectId(projectId); + entries.push(...buildWritingEntries(ws)); + } + + if (includePlots) { + const ps = await plots.getByProjectId(projectId); + entries.push(...buildPlotEntries(ps)); + } + + if (includeCharacters) { + const cs = await characters.getByProjectId(projectId); + entries.push(...buildCharacterEntries(cs)); + } + + if (includeMemos) { + const ms = await memos.getByProjectId(projectId); + entries.push(...buildMemoEntries(ms)); + } + + const projectSlug = sanitizeFileName(project.name); + + const zipFiles: Record = {}; + for (const entry of entries) { + zipFiles[`${projectSlug}/${entry.path}`] = strToU8(entry.content); + } + + const data = zipSync(zipFiles); + const filename = `${projectSlug}-export.zip`; + + const savePath = await save({ + defaultPath: filename, + filters: [{ name: 'ZIP', extensions: ['zip'] }], + }); + if (!savePath) return; + + await writeBinaryFile(savePath, data); + }, + }; +} diff --git a/packages/adapter/local/src/internal/utils/export-markdown.spec.ts b/packages/adapter/local/src/internal/utils/export-markdown.spec.ts new file mode 100644 index 0000000..afa519d --- /dev/null +++ b/packages/adapter/local/src/internal/utils/export-markdown.spec.ts @@ -0,0 +1,483 @@ +import type { Project, Writing, Plot, Character, Memo } from '@tsumugi/adapter'; +import { + sanitizeFileName, + formatOrder, + buildSegment, + buildProjectReadme, + buildWritingMarkdown, + buildPlotMarkdown, + buildCharacterMarkdown, + buildMemoMarkdown, + buildWritingEntries, + buildPlotEntries, + buildCharacterEntries, + buildMemoEntries, +} from './export-markdown'; + +const baseTimestamps = { + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-02'), +}; + +const baseNode = { + projectId: '/projects/test', + parentId: null, + ...baseTimestamps, +}; + +function makeWriting( + overrides: Partial & Pick, +): Writing { + return { + ...baseNode, + nodeType: 'writing', + parentId: null, + content: '', + wordCount: 0, + ...overrides, + }; +} + +function makePlot( + overrides: Partial & Pick, +): Plot { + return { + ...baseNode, + nodeType: 'plot', + parentId: null, + ...overrides, + }; +} + +function makeCharacter( + overrides: Partial & Pick, +): Character { + return { + ...baseNode, + nodeType: 'character', + parentId: null, + ...overrides, + }; +} + +function makeMemo( + overrides: Partial & Pick, +): Memo { + return { + ...baseNode, + nodeType: 'memo', + parentId: null, + content: '', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// sanitizeFileName +// --------------------------------------------------------------------------- + +describe('sanitizeFileName', () => { + it('通常の文字列はそのまま返す', () => { + expect(sanitizeFileName('第一章')).toBe('第一章'); + }); + + it('スラッシュをハイフンに変換する', () => { + expect(sanitizeFileName('act/scene')).toBe('act-scene'); + }); + + it('バックスラッシュをハイフンに変換する', () => { + expect(sanitizeFileName('act\\scene')).toBe('act-scene'); + }); + + it('コロン等の特殊文字をハイフンに変換する', () => { + // スペースはそのまま保持、コロンのみハイフンに変換される + expect(sanitizeFileName('chapter: one')).toBe('chapter- one'); + // 連続する特殊文字は1つのハイフンにまとめる + expect(sanitizeFileName('chapter::one')).toBe('chapter-one'); + }); + + it('先頭・末尾のハイフンを除去する', () => { + expect(sanitizeFileName('/chapter/')).toBe('chapter'); + }); + + it('空文字列は untitled を返す', () => { + expect(sanitizeFileName('')).toBe('untitled'); + }); + + it('特殊文字のみは untitled を返す', () => { + expect(sanitizeFileName('///')).toBe('untitled'); + }); +}); + +// --------------------------------------------------------------------------- +// formatOrder +// --------------------------------------------------------------------------- + +describe('formatOrder', () => { + it('0 は "0000" になる', () => { + expect(formatOrder(0)).toBe('0000'); + }); + + it('42 は "0042" になる', () => { + expect(formatOrder(42)).toBe('0042'); + }); + + it('9999 は "9999" になる', () => { + expect(formatOrder(9999)).toBe('9999'); + }); + + it('10000 は 5桁になる(パディングは最低桁数のみ保証)', () => { + expect(formatOrder(10000)).toBe('10000'); + }); +}); + +// --------------------------------------------------------------------------- +// buildSegment +// --------------------------------------------------------------------------- + +describe('buildSegment', () => { + it('order と name を結合したセグメントを返す', () => { + expect(buildSegment(0, '第一章')).toBe('0000_第一章'); + expect(buildSegment(3, 'プロローグ')).toBe('0003_プロローグ'); + }); + + it('name に特殊文字が含まれる場合はサニタイズする', () => { + expect(buildSegment(1, 'act/scene')).toBe('0001_act-scene'); + }); +}); + +// --------------------------------------------------------------------------- +// buildProjectReadme +// --------------------------------------------------------------------------- + +describe('buildProjectReadme', () => { + const baseProject: Project = { + id: '/projects/test', + name: 'テストプロジェクト', + ...baseTimestamps, + }; + + it('name のみのプロジェクトは h1 タイトルと空行を返す', () => { + const result = buildProjectReadme(baseProject); + expect(result).toBe('# テストプロジェクト\n'); + }); + + it('synopsis があればセクションを含む', () => { + const project: Project = { ...baseProject, synopsis: 'あらすじ内容' }; + const result = buildProjectReadme(project); + expect(result).toContain('## あらすじ'); + expect(result).toContain('あらすじ内容'); + }); + + it('theme, goal, targetWordCount, targetAudience が全てある場合', () => { + const project: Project = { + ...baseProject, + synopsis: 'あらすじ', + theme: 'テーマ', + goal: 'ゴール', + targetWordCount: 80000, + targetAudience: '一般', + }; + const result = buildProjectReadme(project); + expect(result).toContain('## テーマ'); + expect(result).toContain('## ゴール'); + expect(result).toContain('## 目標文字数'); + expect(result).toContain('80000'); + expect(result).toContain('## 対象読者'); + expect(result).toContain('一般'); + }); +}); + +// --------------------------------------------------------------------------- +// buildWritingMarkdown +// --------------------------------------------------------------------------- + +describe('buildWritingMarkdown', () => { + it('name と content を含む markdown を生成する', () => { + const writing = makeWriting({ + id: '/writings/1.json', + name: 'プロローグ', + order: 0, + content: '物語のはじまり。', + }); + const result = buildWritingMarkdown(writing); + expect(result).toBe('# プロローグ\n\n物語のはじまり。'); + }); + + it('content が空の場合は h1 のみ', () => { + const writing = makeWriting({ + id: '/writings/1.json', + name: '空の章', + order: 0, + }); + const result = buildWritingMarkdown(writing); + expect(result).toBe('# 空の章\n'); + }); +}); + +// --------------------------------------------------------------------------- +// buildPlotMarkdown +// --------------------------------------------------------------------------- + +describe('buildPlotMarkdown', () => { + it('全フィールドがある場合は全セクションを含む', () => { + const plot = makePlot({ + id: '/plots/1.json', + name: 'メインプロット', + order: 0, + synopsis: 'あらすじ', + setting: '舞台設定', + theme: 'テーマ', + structure: '三幕構成', + conflict: '主人公 vs 敵', + resolution: 'ハッピーエンド', + notes: 'メモ', + }); + const result = buildPlotMarkdown(plot); + expect(result).toContain('# メインプロット'); + expect(result).toContain('## あらすじ'); + expect(result).toContain('## 舞台設定'); + expect(result).toContain('## テーマ'); + expect(result).toContain('## 構成'); + expect(result).toContain('## 葛藤'); + expect(result).toContain('## 解決'); + expect(result).toContain('## ノート'); + }); + + it('フィールドが空の場合はそのセクションを省略する', () => { + const plot = makePlot({ + id: '/plots/1.json', + name: 'シンプルプロット', + order: 0, + }); + const result = buildPlotMarkdown(plot); + expect(result).not.toContain('## あらすじ'); + }); +}); + +// --------------------------------------------------------------------------- +// buildCharacterMarkdown +// --------------------------------------------------------------------------- + +describe('buildCharacterMarkdown', () => { + it('基本情報がある場合はテーブルを含む', () => { + const character = makeCharacter({ + id: '/characters/1.json', + name: '主人公', + order: 0, + role: '探偵', + gender: '男性', + age: '30', + aliases: 'Mr.X', + }); + const result = buildCharacterMarkdown(character); + expect(result).toContain('| 項目 | 内容 |'); + expect(result).toContain('| 役割 | 探偵 |'); + expect(result).toContain('| 性別 | 男性 |'); + expect(result).toContain('| 年齢 | 30 |'); + expect(result).toContain('| 別名 | Mr.X |'); + }); + + it('基本情報が全てない場合はテーブルを省略する', () => { + const character = makeCharacter({ + id: '/characters/1.json', + name: '謎の人物', + order: 0, + }); + const result = buildCharacterMarkdown(character); + expect(result).not.toContain('| 項目 | 内容 |'); + }); + + it('詳細フィールドがある場合はセクションを含む', () => { + const character = makeCharacter({ + id: '/characters/1.json', + name: '主人公', + order: 0, + appearance: '黒髪', + personality: '誠実', + background: '幼少期に孤独', + motivation: '真実を追う', + relationships: '相棒がいる', + notes: '補足', + }); + const result = buildCharacterMarkdown(character); + expect(result).toContain('## 外見'); + expect(result).toContain('## 性格'); + expect(result).toContain('## 背景'); + expect(result).toContain('## 動機'); + expect(result).toContain('## 人間関係'); + expect(result).toContain('## ノート'); + }); +}); + +// --------------------------------------------------------------------------- +// buildMemoMarkdown +// --------------------------------------------------------------------------- + +describe('buildMemoMarkdown', () => { + it('タグありのメモを正しく出力する', () => { + const memo = makeMemo({ + id: '/memos/1.json', + name: '設定メモ', + order: 0, + content: '世界観の説明。', + tags: ['設定', '世界観'], + }); + const result = buildMemoMarkdown(memo); + expect(result).toContain('# 設定メモ'); + expect(result).toContain('タグ: 設定, 世界観'); + expect(result).toContain('世界観の説明。'); + }); + + it('タグなしのメモはタグ行を省略する', () => { + const memo = makeMemo({ + id: '/memos/1.json', + name: 'タグなし', + order: 0, + content: 'メモ内容', + }); + const result = buildMemoMarkdown(memo); + expect(result).not.toContain('タグ:'); + }); +}); + +// --------------------------------------------------------------------------- +// buildWritingEntries(ツリー構造) +// --------------------------------------------------------------------------- + +describe('buildWritingEntries', () => { + it('フラットなリストから zip パスを正しく生成する', () => { + const writings: Writing[] = [ + makeWriting({ id: 'id-1', name: 'プロローグ', order: 0 }), + makeWriting({ id: 'id-2', name: 'エピローグ', order: 1 }), + ]; + const entries = buildWritingEntries(writings); + expect(entries).toHaveLength(2); + expect(entries[0].path).toBe('writings/0000_プロローグ.md'); + expect(entries[1].path).toBe('writings/0001_エピローグ.md'); + }); + + it('フォルダノードは除外する', () => { + const writings: Writing[] = [ + makeWriting({ + id: 'folder-1', + name: '第一部', + order: 0, + nodeType: 'folder', + content: '', + }), + makeWriting({ + id: 'id-1', + name: '第一章', + order: 0, + parentId: 'folder-1', + }), + ]; + const entries = buildWritingEntries(writings); + expect(entries).toHaveLength(1); + expect(entries[0].path).toBe('writings/0000_第一部/0000_第一章.md'); + }); + + it('ネストしたフォルダ構造でパスが正しく生成される', () => { + const writings: Writing[] = [ + makeWriting({ + id: 'folder-1', + name: '第一部', + order: 0, + nodeType: 'folder', + content: '', + }), + makeWriting({ + id: 'folder-2', + name: '前半', + order: 0, + nodeType: 'folder', + content: '', + parentId: 'folder-1', + }), + makeWriting({ + id: 'id-1', + name: '第一章', + order: 0, + parentId: 'folder-2', + }), + ]; + const entries = buildWritingEntries(writings); + expect(entries).toHaveLength(1); + expect(entries[0].path).toBe( + 'writings/0000_第一部/0000_前半/0000_第一章.md', + ); + }); + + it('親が見つからない孤立ノードはルートに配置される', () => { + const writings: Writing[] = [ + makeWriting({ + id: 'id-1', + name: '孤立した章', + order: 2, + parentId: 'non-existent', + }), + ]; + const entries = buildWritingEntries(writings); + expect(entries).toHaveLength(1); + expect(entries[0].path).toBe('writings/0002_孤立した章.md'); + }); +}); + +// --------------------------------------------------------------------------- +// buildPlotEntries +// --------------------------------------------------------------------------- + +describe('buildPlotEntries', () => { + it('フォルダを除外しプロット一覧の zip パスを生成する', () => { + const plots: Plot[] = [ + makePlot({ + id: 'folder-1', + name: 'グループ', + order: 0, + nodeType: 'folder', + }), + makePlot({ + id: 'id-1', + name: 'メインプロット', + order: 0, + parentId: 'folder-1', + }), + ]; + const entries = buildPlotEntries(plots); + expect(entries).toHaveLength(1); + expect(entries[0].path).toBe('plots/0000_グループ/0000_メインプロット.md'); + }); +}); + +// --------------------------------------------------------------------------- +// buildCharacterEntries +// --------------------------------------------------------------------------- + +describe('buildCharacterEntries', () => { + it('フォルダを除外しキャラクター一覧の zip パスを生成する', () => { + const characters: Character[] = [ + makeCharacter({ id: 'id-1', name: '主人公', order: 0 }), + makeCharacter({ id: 'id-2', name: '敵役', order: 1 }), + ]; + const entries = buildCharacterEntries(characters); + expect(entries).toHaveLength(2); + expect(entries[0].path).toBe('characters/0000_主人公.md'); + expect(entries[1].path).toBe('characters/0001_敵役.md'); + }); +}); + +// --------------------------------------------------------------------------- +// buildMemoEntries +// --------------------------------------------------------------------------- + +describe('buildMemoEntries', () => { + it('フォルダを除外しメモ一覧の zip パスを生成する', () => { + const memos: Memo[] = [ + makeMemo({ id: 'id-1', name: '設定メモ', order: 0, tags: ['設定'] }), + ]; + const entries = buildMemoEntries(memos); + expect(entries).toHaveLength(1); + expect(entries[0].path).toBe('memos/0000_設定メモ.md'); + }); +}); diff --git a/packages/adapter/local/src/internal/utils/export-markdown.ts b/packages/adapter/local/src/internal/utils/export-markdown.ts new file mode 100644 index 0000000..2c47e40 --- /dev/null +++ b/packages/adapter/local/src/internal/utils/export-markdown.ts @@ -0,0 +1,189 @@ +import type { + Project, + Writing, + Plot, + Character, + Memo, + Node, +} from '@tsumugi/adapter'; + +export interface ZipEntry { + /** zip 内のパス(プロジェクト名ディレクトリより下の相対パス) */ + path: string; + content: string; +} + +/** + * ファイルシステムで安全なファイル名に変換する。 + * スラッシュ等の特殊文字は `-` に置換し、連続する `-` はまとめる。 + */ +export function sanitizeFileName(name: string): string { + return ( + name + .replace(/[/\\:*?"<>|]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .trim() || 'untitled' + ); +} + +/** + * order を4桁ゼロパディングでフォーマットする。 + * 例: 0 → "0000", 42 → "0042" + */ +export function formatOrder(order: number): string { + return String(order).padStart(4, '0'); +} + +/** + * ノードのパスセグメントを生成する(拡張子なし)。 + * 例: order=3, name="第一章" → "0003_第一章" + */ +export function buildSegment(order: number, name: string): string { + return `${formatOrder(order)}_${sanitizeFileName(name)}`; +} + +/** + * ノードの zip パスを再帰的に計算する(拡張子なし)。 + * 親ノードが存在する場合はそのパスをプレフィックスとして結合する。 + */ +function getNodeZipPath(node: Node, nodeMap: Map): string { + const segment = buildSegment(node.order, node.name); + if (node.parentId === null) return segment; + const parent = nodeMap.get(node.parentId); + if (!parent) return segment; + return `${getNodeZipPath(parent, nodeMap)}/${segment}`; +} + +/** + * プロジェクト概要の README.md コンテンツを生成する。 + */ +export function buildProjectReadme(project: Project): string { + const lines: string[] = [`# ${project.name}`, '']; + if (project.synopsis) lines.push('## あらすじ', '', project.synopsis, ''); + if (project.theme) lines.push('## テーマ', '', project.theme, ''); + if (project.goal) lines.push('## ゴール', '', project.goal, ''); + if (project.targetWordCount != null) + lines.push('## 目標文字数', '', String(project.targetWordCount), ''); + if (project.targetAudience) + lines.push('## 対象読者', '', project.targetAudience, ''); + return lines.join('\n'); +} + +/** + * Writing の markdown コンテンツを生成する。 + */ +export function buildWritingMarkdown(writing: Writing): string { + const lines: string[] = [`# ${writing.name}`, '']; + if (writing.content) lines.push(writing.content); + return lines.join('\n'); +} + +/** + * Plot の markdown コンテンツを生成する。 + */ +export function buildPlotMarkdown(plot: Plot): string { + const lines: string[] = [`# ${plot.name}`, '']; + if (plot.synopsis) lines.push('## あらすじ', '', plot.synopsis, ''); + if (plot.setting) lines.push('## 舞台設定', '', plot.setting, ''); + if (plot.theme) lines.push('## テーマ', '', plot.theme, ''); + if (plot.structure) lines.push('## 構成', '', plot.structure, ''); + if (plot.conflict) lines.push('## 葛藤', '', plot.conflict, ''); + if (plot.resolution) lines.push('## 解決', '', plot.resolution, ''); + if (plot.notes) lines.push('## ノート', '', plot.notes, ''); + return lines.join('\n'); +} + +/** + * Character の markdown コンテンツを生成する。 + * 基本情報はテーブル形式、詳細説明はセクション形式で出力する。 + */ +export function buildCharacterMarkdown(character: Character): string { + const lines: string[] = [`# ${character.name}`, '']; + + const tableRows: string[] = []; + if (character.role) tableRows.push(`| 役割 | ${character.role} |`); + if (character.gender) tableRows.push(`| 性別 | ${character.gender} |`); + if (character.age) tableRows.push(`| 年齢 | ${character.age} |`); + if (character.aliases) tableRows.push(`| 別名 | ${character.aliases} |`); + + if (tableRows.length > 0) { + lines.push('| 項目 | 内容 |', '|------|------|', ...tableRows, ''); + } + + if (character.appearance) lines.push('## 外見', '', character.appearance, ''); + if (character.personality) + lines.push('## 性格', '', character.personality, ''); + if (character.background) lines.push('## 背景', '', character.background, ''); + if (character.motivation) lines.push('## 動機', '', character.motivation, ''); + if (character.relationships) + lines.push('## 人間関係', '', character.relationships, ''); + if (character.notes) lines.push('## ノート', '', character.notes, ''); + + return lines.join('\n'); +} + +/** + * Memo の markdown コンテンツを生成する。 + */ +export function buildMemoMarkdown(memo: Memo): string { + const lines: string[] = [`# ${memo.name}`, '']; + if (memo.tags && memo.tags.length > 0) { + lines.push(`タグ: ${memo.tags.join(', ')}`, ''); + } + if (memo.content) lines.push(memo.content); + return lines.join('\n'); +} + +/** + * Writing のフラットリストから ZipEntry[] を生成する。 + * フォルダノードは除外し、コンテンツノードのみ出力する。 + */ +export function buildWritingEntries(writings: Writing[]): ZipEntry[] { + const nodeMap = new Map(writings.map((w) => [w.id, w])); + return writings + .filter((w) => w.nodeType !== 'folder') + .map((w) => ({ + path: `writings/${getNodeZipPath(w, nodeMap)}.md`, + content: buildWritingMarkdown(w), + })); +} + +/** + * Plot のフラットリストから ZipEntry[] を生成する。 + */ +export function buildPlotEntries(plots: Plot[]): ZipEntry[] { + const nodeMap = new Map(plots.map((p) => [p.id, p])); + return plots + .filter((p) => p.nodeType !== 'folder') + .map((p) => ({ + path: `plots/${getNodeZipPath(p, nodeMap)}.md`, + content: buildPlotMarkdown(p), + })); +} + +/** + * Character のフラットリストから ZipEntry[] を生成する。 + */ +export function buildCharacterEntries(characters: Character[]): ZipEntry[] { + const nodeMap = new Map(characters.map((c) => [c.id, c])); + return characters + .filter((c) => c.nodeType !== 'folder') + .map((c) => ({ + path: `characters/${getNodeZipPath(c, nodeMap)}.md`, + content: buildCharacterMarkdown(c), + })); +} + +/** + * Memo のフラットリストから ZipEntry[] を生成する。 + */ +export function buildMemoEntries(memos: Memo[]): ZipEntry[] { + const nodeMap = new Map(memos.map((m) => [m.id, m])); + return memos + .filter((m) => m.nodeType !== 'folder') + .map((m) => ({ + path: `memos/${getNodeZipPath(m, nodeMap)}.md`, + content: buildMemoMarkdown(m), + })); +} diff --git a/packages/adapter/local/src/internal/utils/fs.ts b/packages/adapter/local/src/internal/utils/fs.ts index 3a0a3c3..dca4387 100644 --- a/packages/adapter/local/src/internal/utils/fs.ts +++ b/packages/adapter/local/src/internal/utils/fs.ts @@ -1,6 +1,7 @@ import { readTextFile, writeTextFile, + writeFile, exists, mkdir, readDir, @@ -47,6 +48,13 @@ export async function listDir(path: string): Promise { return entries.map((e) => e.name); } +export async function writeBinaryFile( + path: string, + data: Uint8Array, +): Promise { + await writeFile(path, data); +} + export async function listSubDirs(path: string): Promise { if (!(await exists(path))) { return []; diff --git a/packages/ui/src/components/features/editor/project-editor.tsx b/packages/ui/src/components/features/editor/project-editor.tsx index 9922a29..ae06b08 100644 --- a/packages/ui/src/components/features/editor/project-editor.tsx +++ b/packages/ui/src/components/features/editor/project-editor.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { DownloadIcon, LoaderCircleIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; export interface ProjectEditorData { @@ -16,6 +18,8 @@ export interface ProjectEditorData { export interface ProjectEditorProps { data: ProjectEditorData; onChange?: (field: keyof ProjectEditorData, value: string) => void; + onExport?: () => void; + isExporting?: boolean; className?: string; readOnly?: boolean; } @@ -48,6 +52,8 @@ const textareaFields: { export function ProjectEditor({ data, onChange, + onExport, + isExporting = false, className, readOnly = false, }: ProjectEditorProps) { @@ -107,6 +113,23 @@ export function ProjectEditor({ + {onExport && ( +
+ +
+ )} ); } diff --git a/packages/ui/src/components/features/project-list/project-list.stories.tsx b/packages/ui/src/components/features/project-list/project-list.stories.tsx index 90a71f4..ee3f7e4 100644 --- a/packages/ui/src/components/features/project-list/project-list.stories.tsx +++ b/packages/ui/src/components/features/project-list/project-list.stories.tsx @@ -87,17 +87,28 @@ export const Loading: Story = { export const Interactive: StoryObj = { render: () => { const [selectedId, setSelectedId] = useState(null); + const [exportingProjectId, setExportingProjectId] = useState( + null, + ); const handleSelect = (project: ProjectItem) => { setSelectedId(project.id); console.log('Selected project:', project); }; + const handleExport = (project: ProjectItem) => { + setExportingProjectId(project.id); + console.log('Exporting project:', project); + setTimeout(() => setExportingProjectId(null), 2000); + }; + return ( ); }, diff --git a/packages/ui/src/components/features/project-list/project-list.tsx b/packages/ui/src/components/features/project-list/project-list.tsx index 785dc3a..8fd9017 100644 --- a/packages/ui/src/components/features/project-list/project-list.tsx +++ b/packages/ui/src/components/features/project-list/project-list.tsx @@ -8,6 +8,8 @@ import { CommandItem, } from '@/components/ui/command'; import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { DownloadIcon, LoaderCircleIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; export interface ProjectItem { @@ -21,6 +23,8 @@ export interface ProjectListProps { isLoading?: boolean; selectedId?: string | null; onSelect?: (project: ProjectItem) => void; + onExport?: (project: ProjectItem) => void; + exportingProjectId?: string | null; placeholder?: string; emptyMessage?: string; className?: string; @@ -70,6 +74,8 @@ export function ProjectList({ isLoading = false, selectedId, onSelect, + onExport, + exportingProjectId, placeholder = 'プロジェクトの検索', emptyMessage = 'プロジェクトが見つかりません', className, @@ -110,7 +116,7 @@ export function ProjectList({ > {getInitial(project.name)} -
+
{project.name} @@ -120,6 +126,24 @@ export function ProjectList({ )}
+ {onExport && ( + + )} ))}