From 6a58d65d111e470ee202d9351c8f2066576b031a Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 4 Mar 2026 10:08:38 +0900 Subject: [PATCH 1/5] adapter-core --- packages/adapter/core/src/adapter.ts | 18 +++++++++++ packages/adapter/core/src/stub.ts | 3 ++ packages/adapter/core/src/types.ts | 47 ++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/packages/adapter/core/src/adapter.ts b/packages/adapter/core/src/adapter.ts index 46bd25f..54c5622 100644 --- a/packages/adapter/core/src/adapter.ts +++ b/packages/adapter/core/src/adapter.ts @@ -17,6 +17,8 @@ import type { AIProjectUsage, AuthState, GoogleAuthUrl, + ExportOptions, + ExportResult, } from './types'; /** @@ -243,6 +245,21 @@ export interface AuthAdapter { refreshAccessToken(): Promise; } +/** + * エクスポート操作のインターフェース + */ +export interface ExportAdapter { + /** + * プロジェクトをエクスポート + * @param projectId - エクスポートするプロジェクトID + * @param options - エクスポートオプション(省略時はすべてのコンテンツを zip-markdown でエクスポート) + */ + exportProject( + projectId: string, + options?: ExportOptions, + ): Promise; +} + /** * 統合アダプター */ @@ -255,4 +272,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..942a407 100644 --- a/packages/adapter/core/src/types.ts +++ b/packages/adapter/core/src/types.ts @@ -507,3 +507,50 @@ 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; +} + +/** + * エクスポート結果 + * + * zip 内のディレクトリ構成(zip-markdown): + * ``` + * {project-name}/ + * README.md ← プロジェクト概要(name, synopsis, theme, goal 等) + * writings/ + * {name}.md ← 執筆内容(フォルダ階層を反映) + * plots/ + * {name}.md ← プロット内容 + * characters/ + * {name}.md ← キャラクター詳細 + * memos/ + * {name}.md ← メモ内容(タグ含む) + * ``` + */ +export interface ExportResult { + /** zip ファイルのバイナリデータ */ + data: Uint8Array; + /** ファイル名(例: my-novel-export.zip) */ + filename: string; + /** MIMEタイプ */ + mimeType: string; +} From 2e826c93dc332113d47e240bb75e011b93731e3a Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 4 Mar 2026 10:20:00 +0900 Subject: [PATCH 2/5] adapter-local --- package-lock.json | 7 + packages/adapter/local/package.json | 1 + packages/adapter/local/src/adapter.ts | 8 + packages/adapter/local/src/adapters/export.ts | 86 ++++ .../internal/utils/export-markdown.spec.ts | 483 ++++++++++++++++++ .../src/internal/utils/export-markdown.ts | 189 +++++++ 6 files changed, 774 insertions(+) create mode 100644 packages/adapter/local/src/adapters/export.ts create mode 100644 packages/adapter/local/src/internal/utils/export-markdown.spec.ts create mode 100644 packages/adapter/local/src/internal/utils/export-markdown.ts diff --git a/package-lock.json b/package-lock.json index 13b9499..26f9ac6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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/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..ad3fb11 --- /dev/null +++ b/packages/adapter/local/src/adapters/export.ts @@ -0,0 +1,86 @@ +import type { + ExportAdapter, + ExportOptions, + ExportResult, + ProjectAdapter, + WritingAdapter, + PlotAdapter, + CharacterAdapter, + MemoAdapter, +} from '@tsumugi/adapter'; +import { zipSync, strToU8 } from 'fflate'; +import { + sanitizeFileName, + buildProjectReadme, + buildWritingEntries, + buildPlotEntries, + buildCharacterEntries, + buildMemoEntries, + type ZipEntry, +} from '@/internal/utils/export-markdown'; + +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); + + // プロジェクト名をトップレベルディレクトリとして zip を構築 + const zipFiles: Record = {}; + for (const entry of entries) { + zipFiles[`${projectSlug}/${entry.path}`] = strToU8(entry.content); + } + + const data = zipSync(zipFiles); + const filename = `${projectSlug}-export.zip`; + + return { data, filename, mimeType: 'application/zip' }; + }, + }; +} 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), + })); +} From 5350b85aa76d653be140cad5a3c92dbf57c424c1 Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 4 Mar 2026 10:28:35 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/app/hooks/export.ts | 18 +++++++++++++ .../app/routes/(private)/home/page.tsx | 19 ++++++++++++++ .../editors/project-editor-wrapper.tsx | 18 ++++++++++++- apps/desktop/app/utils/download.ts | 16 ++++++++++++ .../features/editor/project-editor.tsx | 23 ++++++++++++++++ .../project-list/project-list.stories.tsx | 11 ++++++++ .../features/project-list/project-list.tsx | 26 ++++++++++++++++++- 7 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/app/hooks/export.ts create mode 100644 apps/desktop/app/utils/download.ts diff --git a/apps/desktop/app/hooks/export.ts b/apps/desktop/app/hooks/export.ts new file mode 100644 index 0000000..0d06322 --- /dev/null +++ b/apps/desktop/app/hooks/export.ts @@ -0,0 +1,18 @@ +import { useAdapter } from '~/hooks/useAdapter'; +import useSWRMutation from 'swr/mutation'; +import type { ExportResult } from '@tsumugi/adapter'; + +interface ExportKey { + type: 'export'; +} + +/** + * プロジェクトをエクスポートする + */ +export function useExportProject() { + const adapter = useAdapter(); + return useSWRMutation( + { type: 'export' }, + (_, { arg: projectId }) => adapter.export.exportProject(projectId), + ); +} diff --git a/apps/desktop/app/routes/(private)/home/page.tsx b/apps/desktop/app/routes/(private)/home/page.tsx index 1644fdf..7a75551 100644 --- a/apps/desktop/app/routes/(private)/home/page.tsx +++ b/apps/desktop/app/routes/(private)/home/page.tsx @@ -2,6 +2,8 @@ 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 { downloadExportResult } from '~/utils/download'; import { ProjectList, Button, @@ -37,10 +39,15 @@ export default function Page() { const { data: projects, isLoading } = useProjects(); const { trigger: createProject, isMutating: isCreating } = useCreateProject(); const { trigger: logout, isMutating: isLoggingOut } = useLogout(); + const { trigger: exportProject, isMutating: isExporting } = + 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 +80,16 @@ export default function Page() { navigate(`${PATH_WORKSPACE}/${encodeURIComponent(project.id)}`); }; + const handleExportProject = async (project: ProjectItem) => { + setExportingProjectId(project.id); + try { + const result = await exportProject(project.id); + if (result) downloadExportResult(result); + } finally { + setExportingProjectId(null); + } + }; + return (
@@ -122,6 +139,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..92efd9c 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,7 @@ import { useCallback } from 'react'; import { useProject, useUpdateProject } from '~/hooks/projects'; +import { useExportProject } from '~/hooks/export'; +import { downloadExportResult } from '~/utils/download'; import { ProjectEditor, type ProjectEditorData } from '@tsumugi/ui'; import { useDebouncedSave } from '~/routes/(private)/workspace/[projectId]/_hooks/useDebouncedSave'; import type { Project } from '@tsumugi/adapter'; @@ -30,6 +32,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 +61,19 @@ export function ProjectEditorWrapper({ [mutate, debouncedSave, onNameChange], ); + const handleExport = useCallback(async () => { + const result = await exportProject(projectId); + if (result) downloadExportResult(result); + }, [exportProject, projectId]); + if (!project) return null; - return ; + return ( + + ); } diff --git a/apps/desktop/app/utils/download.ts b/apps/desktop/app/utils/download.ts new file mode 100644 index 0000000..060d3df --- /dev/null +++ b/apps/desktop/app/utils/download.ts @@ -0,0 +1,16 @@ +import type { ExportResult } from '@tsumugi/adapter'; + +/** + * ExportResult をブラウザのダウンロードとして保存する + */ +export function downloadExportResult(result: ExportResult): void { + const blob = new Blob([result.data], { type: result.mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} 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 && ( + + )} ))} From 9411addf84444097799ab8255c23869fb7d65029 Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 4 Mar 2026 10:59:20 +0900 Subject: [PATCH 4/5] =?UTF-8?q?API=E7=B5=8C=E7=94=B1=E3=81=A7=E3=83=80?= =?UTF-8?q?=E3=82=A6=E3=83=B3=E3=83=AD=E3=83=BC=E3=83=89=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/routes/(private)/home/page.tsx | 3 +- package-lock.json | 8 ++-- packages/adapter/api/package.json | 2 +- packages/adapter/api/src/adapter.ts | 2 + packages/adapter/api/src/adapters/export.ts | 40 +++++++++++++++++++ 5 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 packages/adapter/api/src/adapters/export.ts diff --git a/apps/desktop/app/routes/(private)/home/page.tsx b/apps/desktop/app/routes/(private)/home/page.tsx index 7a75551..09a96e6 100644 --- a/apps/desktop/app/routes/(private)/home/page.tsx +++ b/apps/desktop/app/routes/(private)/home/page.tsx @@ -39,8 +39,7 @@ export default function Page() { const { data: projects, isLoading } = useProjects(); const { trigger: createProject, isMutating: isCreating } = useCreateProject(); const { trigger: logout, isMutating: isLoggingOut } = useLogout(); - const { trigger: exportProject, isMutating: isExporting } = - useExportProject(); + const { trigger: exportProject } = useExportProject(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [newProjectTitle, setNewProjectTitle] = useState('untitled'); diff --git a/package-lock.json b/package-lock.json index 26f9ac6..fd85bf6 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.8", + "resolved": "https://registry.npmjs.org/@tsumugi-chan/client/-/client-1.0.8.tgz", + "integrity": "sha512-c0kt8yK4/bvxIC88pRwS6KYcZ8DW42kKQoA7wIrGiqLsClQxO72YW+cAaT+yXG7P7OZ0HdRM2yKAvInLTQOc4A==", "license": "MIT" }, "node_modules/@tsumugi/adapter": { @@ -22482,7 +22482,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { - "@tsumugi-chan/client": "1.0.7", + "@tsumugi-chan/client": "1.0.8", "@tsumugi/adapter": "*" }, "devDependencies": { diff --git a/packages/adapter/api/package.json b/packages/adapter/api/package.json index c72c4df..bd3fc96 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.8", "@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..367c790 --- /dev/null +++ b/packages/adapter/api/src/adapters/export.ts @@ -0,0 +1,40 @@ +import type { + ExportAdapter, + ExportOptions, + ExportResult, +} from '@tsumugi/adapter'; +import type { ApiClients } from '@/client'; + +function parseFilename(contentDisposition: string | null): string { + if (!contentDisposition) return 'export.zip'; + + // RFC 5987 形式: filename*=UTF-8''... + const rfcMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); + if (rfcMatch) { + return decodeURIComponent(rfcMatch[1]); + } + + // fallback: filename="..." + const match = contentDisposition.match(/filename="?([^";\n]+)"?/i); + return match ? match[1].trim() : 'export.zip'; +} + +export function createExportAdapter(clients: ApiClients): ExportAdapter { + return { + async exportProject( + projectId: string, + _options?: ExportOptions, + ): Promise { + const response = await clients.projects.exportProjectRaw({ projectId }); + const buffer = await response.raw.arrayBuffer(); + const filename = parseFilename( + response.raw.headers.get('Content-Disposition'), + ); + return { + data: new Uint8Array(buffer), + filename, + mimeType: 'application/zip', + }; + }, + }; +} From f341f89b84c85a16690b4993a524a13b504b668e Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 4 Mar 2026 11:15:31 +0900 Subject: [PATCH 5/5] =?UTF-8?q?API=E7=B5=8C=E7=94=B1=E3=81=A7=E3=83=80?= =?UTF-8?q?=E3=82=A6=E3=83=B3=E3=83=AD=E3=83=BC=E3=83=89=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/app/hooks/export.ts | 10 ++-- .../app/routes/(private)/home/page.tsx | 4 +- .../editors/project-editor-wrapper.tsx | 4 +- apps/desktop/app/utils/download.ts | 16 ------- package-lock.json | 8 ++-- packages/adapter/api/package.json | 2 +- packages/adapter/api/src/adapters/export.ts | 48 +++++++++---------- packages/adapter/api/tsconfig.json | 2 +- packages/adapter/core/src/adapter.ts | 6 +-- packages/adapter/core/src/types.ts | 26 ---------- packages/adapter/local/src/adapters/export.ts | 14 ++++-- .../adapter/local/src/internal/utils/fs.ts | 8 ++++ 12 files changed, 56 insertions(+), 92 deletions(-) delete mode 100644 apps/desktop/app/utils/download.ts diff --git a/apps/desktop/app/hooks/export.ts b/apps/desktop/app/hooks/export.ts index 0d06322..7868ebd 100644 --- a/apps/desktop/app/hooks/export.ts +++ b/apps/desktop/app/hooks/export.ts @@ -1,18 +1,20 @@ import { useAdapter } from '~/hooks/useAdapter'; import useSWRMutation from 'swr/mutation'; -import type { ExportResult } from '@tsumugi/adapter'; interface ExportKey { type: 'export'; } /** - * プロジェクトをエクスポートする + * プロジェクトをエクスポートする(保存ダイアログを含む) */ export function useExportProject() { const adapter = useAdapter(); - return useSWRMutation( + return useSWRMutation( { type: 'export' }, - (_, { arg: projectId }) => adapter.export.exportProject(projectId), + 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 09a96e6..f27e0af 100644 --- a/apps/desktop/app/routes/(private)/home/page.tsx +++ b/apps/desktop/app/routes/(private)/home/page.tsx @@ -3,7 +3,6 @@ import { useNavigate, type MetaFunction } from 'react-router'; import { useProjects, useCreateProject } from '~/hooks/projects'; import { useLogout } from '~/hooks/auth'; import { useExportProject } from '~/hooks/export'; -import { downloadExportResult } from '~/utils/download'; import { ProjectList, Button, @@ -82,8 +81,7 @@ export default function Page() { const handleExportProject = async (project: ProjectItem) => { setExportingProjectId(project.id); try { - const result = await exportProject(project.id); - if (result) downloadExportResult(result); + await exportProject(project.id); } finally { setExportingProjectId(null); } 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 92efd9c..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,7 +1,6 @@ import { useCallback } from 'react'; import { useProject, useUpdateProject } from '~/hooks/projects'; import { useExportProject } from '~/hooks/export'; -import { downloadExportResult } from '~/utils/download'; import { ProjectEditor, type ProjectEditorData } from '@tsumugi/ui'; import { useDebouncedSave } from '~/routes/(private)/workspace/[projectId]/_hooks/useDebouncedSave'; import type { Project } from '@tsumugi/adapter'; @@ -62,8 +61,7 @@ export function ProjectEditorWrapper({ ); const handleExport = useCallback(async () => { - const result = await exportProject(projectId); - if (result) downloadExportResult(result); + await exportProject(projectId); }, [exportProject, projectId]); if (!project) return null; diff --git a/apps/desktop/app/utils/download.ts b/apps/desktop/app/utils/download.ts deleted file mode 100644 index 060d3df..0000000 --- a/apps/desktop/app/utils/download.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ExportResult } from '@tsumugi/adapter'; - -/** - * ExportResult をブラウザのダウンロードとして保存する - */ -export function downloadExportResult(result: ExportResult): void { - const blob = new Blob([result.data], { type: result.mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = result.filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} diff --git a/package-lock.json b/package-lock.json index fd85bf6..96d7444 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7989,9 +7989,9 @@ } }, "node_modules/@tsumugi-chan/client": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@tsumugi-chan/client/-/client-1.0.8.tgz", - "integrity": "sha512-c0kt8yK4/bvxIC88pRwS6KYcZ8DW42kKQoA7wIrGiqLsClQxO72YW+cAaT+yXG7P7OZ0HdRM2yKAvInLTQOc4A==", + "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": { @@ -22482,7 +22482,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { - "@tsumugi-chan/client": "1.0.8", + "@tsumugi-chan/client": "1.0.9", "@tsumugi/adapter": "*" }, "devDependencies": { diff --git a/packages/adapter/api/package.json b/packages/adapter/api/package.json index bd3fc96..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.8", + "@tsumugi-chan/client": "1.0.9", "@tsumugi/adapter": "*" }, "devDependencies": { diff --git a/packages/adapter/api/src/adapters/export.ts b/packages/adapter/api/src/adapters/export.ts index 367c790..341a32b 100644 --- a/packages/adapter/api/src/adapters/export.ts +++ b/packages/adapter/api/src/adapters/export.ts @@ -1,22 +1,26 @@ -import type { - ExportAdapter, - ExportOptions, - ExportResult, -} from '@tsumugi/adapter'; +import type { ExportAdapter, ExportOptions } from '@tsumugi/adapter'; import type { ApiClients } from '@/client'; -function parseFilename(contentDisposition: string | null): string { - if (!contentDisposition) return 'export.zip'; +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); +} - // RFC 5987 形式: filename*=UTF-8''... - const rfcMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); - if (rfcMatch) { - return decodeURIComponent(rfcMatch[1]); +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(); } - - // fallback: filename="..." - const match = contentDisposition.match(/filename="?([^";\n]+)"?/i); - return match ? match[1].trim() : 'export.zip'; + return 'export.zip'; } export function createExportAdapter(clients: ApiClients): ExportAdapter { @@ -24,17 +28,11 @@ export function createExportAdapter(clients: ApiClients): ExportAdapter { async exportProject( projectId: string, _options?: ExportOptions, - ): Promise { + ): Promise { const response = await clients.projects.exportProjectRaw({ projectId }); - const buffer = await response.raw.arrayBuffer(); - const filename = parseFilename( - response.raw.headers.get('Content-Disposition'), - ); - return { - data: new Uint8Array(buffer), - filename, - mimeType: 'application/zip', - }; + 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 54c5622..266e515 100644 --- a/packages/adapter/core/src/adapter.ts +++ b/packages/adapter/core/src/adapter.ts @@ -18,7 +18,6 @@ import type { AuthState, GoogleAuthUrl, ExportOptions, - ExportResult, } from './types'; /** @@ -254,10 +253,7 @@ export interface ExportAdapter { * @param projectId - エクスポートするプロジェクトID * @param options - エクスポートオプション(省略時はすべてのコンテンツを zip-markdown でエクスポート) */ - exportProject( - projectId: string, - options?: ExportOptions, - ): Promise; + exportProject(projectId: string, options?: ExportOptions): Promise; } /** diff --git a/packages/adapter/core/src/types.ts b/packages/adapter/core/src/types.ts index 942a407..2d6e3e2 100644 --- a/packages/adapter/core/src/types.ts +++ b/packages/adapter/core/src/types.ts @@ -528,29 +528,3 @@ export interface ExportOptions { /** メモデータを含めるか(デフォルト: true) */ includeMemos?: boolean; } - -/** - * エクスポート結果 - * - * zip 内のディレクトリ構成(zip-markdown): - * ``` - * {project-name}/ - * README.md ← プロジェクト概要(name, synopsis, theme, goal 等) - * writings/ - * {name}.md ← 執筆内容(フォルダ階層を反映) - * plots/ - * {name}.md ← プロット内容 - * characters/ - * {name}.md ← キャラクター詳細 - * memos/ - * {name}.md ← メモ内容(タグ含む) - * ``` - */ -export interface ExportResult { - /** zip ファイルのバイナリデータ */ - data: Uint8Array; - /** ファイル名(例: my-novel-export.zip) */ - filename: string; - /** MIMEタイプ */ - mimeType: string; -} diff --git a/packages/adapter/local/src/adapters/export.ts b/packages/adapter/local/src/adapters/export.ts index ad3fb11..7d92bcb 100644 --- a/packages/adapter/local/src/adapters/export.ts +++ b/packages/adapter/local/src/adapters/export.ts @@ -1,7 +1,6 @@ import type { ExportAdapter, ExportOptions, - ExportResult, ProjectAdapter, WritingAdapter, PlotAdapter, @@ -9,6 +8,7 @@ import type { MemoAdapter, } from '@tsumugi/adapter'; import { zipSync, strToU8 } from 'fflate'; +import { save } from '@tauri-apps/plugin-dialog'; import { sanitizeFileName, buildProjectReadme, @@ -18,6 +18,7 @@ import { buildMemoEntries, type ZipEntry, } from '@/internal/utils/export-markdown'; +import { writeBinaryFile } from '@/internal/utils/fs'; export interface ExportAdapterDeps { projects: ProjectAdapter; @@ -34,7 +35,7 @@ export function createExportAdapter(deps: ExportAdapterDeps): ExportAdapter { async exportProject( projectId: string, options: ExportOptions = {}, - ): Promise { + ): Promise { const { includeWritings = true, includePlots = true, @@ -71,7 +72,6 @@ export function createExportAdapter(deps: ExportAdapterDeps): ExportAdapter { const projectSlug = sanitizeFileName(project.name); - // プロジェクト名をトップレベルディレクトリとして zip を構築 const zipFiles: Record = {}; for (const entry of entries) { zipFiles[`${projectSlug}/${entry.path}`] = strToU8(entry.content); @@ -80,7 +80,13 @@ export function createExportAdapter(deps: ExportAdapterDeps): ExportAdapter { const data = zipSync(zipFiles); const filename = `${projectSlug}-export.zip`; - return { data, filename, mimeType: 'application/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/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 [];