Skip to content

Commit 40d0cea

Browse files
authored
Merge pull request #16 from ryoupr/feature/boxnote-converter
feat: add BoxNote to Markdown converter tool
2 parents 562259b + e5ffdc8 commit 40d0cea

4 files changed

Lines changed: 295 additions & 1 deletion

File tree

src/app/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CsvGraphViewerPage } from './pages/tools/CsvGraphViewerPage';
77
import IconGeneratorPage from './pages/tools/IconGeneratorPage';
88
import { YabaneSchedulePage } from './pages/tools/YabaneSchedulePage';
99
import { SlideBuilderPage } from './pages/tools/SlideBuilderPage';
10+
import { BoxNoteConverterPage } from './pages/tools/BoxNoteConverterPage';
1011

1112
export default function App() {
1213
return (
@@ -20,6 +21,7 @@ export default function App() {
2021
<Route path="/tools/icon-generator" element={<IconGeneratorPage />} />
2122
<Route path="/tools/yabane-schedule" element={<YabaneSchedulePage />} />
2223
<Route path="/tools/slide-builder" element={<SlideBuilderPage />} />
24+
<Route path="/tools/boxnote-converter" element={<BoxNoteConverterPage />} />
2325
</Routes>
2426
</BrowserRouter>
2527
);

src/app/pages/ToolsPage.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from 'react';
22
import { Link } from 'react-router-dom';
3-
import { BarChart2, Box, Calendar, Presentation } from 'lucide-react';
3+
import { BarChart2, Box, Calendar, FileText, Presentation } from 'lucide-react';
44

55
export function ToolsPage() {
66
useEffect(() => { document.title = 'Tools | ryoupr'; }, []);
@@ -136,6 +136,34 @@ export function ToolsPage() {
136136
</div>
137137
</div>
138138
</Link>
139+
140+
{/* BoxNote Converter */}
141+
<Link to="/tools/boxnote-converter">
142+
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow">
143+
<div className="flex items-center gap-3 mb-3">
144+
<div className="p-2 bg-emerald-100 dark:bg-emerald-900 rounded-lg">
145+
<FileText className="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
146+
</div>
147+
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
148+
BoxNote → Markdown
149+
</h2>
150+
</div>
151+
<p className="text-gray-600 dark:text-gray-300 mb-4">
152+
Box Notesの.boxnoteファイルをMarkdownに変換。見出し、リスト、テーブル、リンク等の書式を保持したまま変換できます。
153+
</p>
154+
<div className="flex flex-wrap gap-2">
155+
<span className="px-2 py-1 bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 text-xs rounded-full">
156+
BoxNote
157+
</span>
158+
<span className="px-2 py-1 bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 text-xs rounded-full">
159+
Markdown
160+
</span>
161+
<span className="px-2 py-1 bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 text-xs rounded-full">
162+
フォーマット変換
163+
</span>
164+
</div>
165+
</div>
166+
</Link>
139167
</div>
140168
</div>
141169
</div>
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { Card, CardContent } from '../../components/ui/card';
4+
import { convertBoxNoteToMarkdown } from './boxnote/convert';
5+
6+
export function BoxNoteConverterPage() {
7+
const [markdown, setMarkdown] = useState('');
8+
const [error, setError] = useState('');
9+
const [copied, setCopied] = useState(false);
10+
11+
useEffect(() => {
12+
document.title = 'BoxNote → Markdown | ryoupr';
13+
}, []);
14+
15+
const handleFile = useCallback((file: File) => {
16+
setError('');
17+
setMarkdown('');
18+
const reader = new FileReader();
19+
reader.onload = (e) => {
20+
try {
21+
const result = convertBoxNoteToMarkdown(e.target?.result as string);
22+
setMarkdown(result);
23+
} catch {
24+
setError('変換に失敗しました。有効な .boxnote ファイルか確認してください。');
25+
}
26+
};
27+
reader.readAsText(file);
28+
}, []);
29+
30+
const handleDrop = useCallback(
31+
(e: React.DragEvent) => {
32+
e.preventDefault();
33+
const file = e.dataTransfer.files[0];
34+
if (file) handleFile(file);
35+
},
36+
[handleFile]
37+
);
38+
39+
const handleCopy = useCallback(() => {
40+
navigator.clipboard.writeText(markdown);
41+
setCopied(true);
42+
setTimeout(() => setCopied(false), 2000);
43+
}, [markdown]);
44+
45+
const handleDownload = useCallback(() => {
46+
const blob = new Blob([markdown], { type: 'text/markdown' });
47+
const a = document.createElement('a');
48+
a.href = URL.createObjectURL(blob);
49+
a.download = 'converted.md';
50+
a.click();
51+
URL.revokeObjectURL(a.href);
52+
}, [markdown]);
53+
54+
return (
55+
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-gray-900 dark:to-gray-800">
56+
<div className="container mx-auto px-4 py-8">
57+
<div className="max-w-4xl mx-auto">
58+
<div className="mb-6">
59+
<Link
60+
to="/tools"
61+
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 transition-colors"
62+
>
63+
← ツール一覧に戻る
64+
</Link>
65+
</div>
66+
67+
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
68+
BoxNote → Markdown 変換
69+
</h1>
70+
<p className="text-gray-600 dark:text-gray-400 mb-8">
71+
.boxnote ファイルをドラッグ&ドロップまたは選択して、Markdownに変換します
72+
</p>
73+
74+
<Card className="mb-6">
75+
<CardContent className="pt-6">
76+
<div
77+
onDrop={handleDrop}
78+
onDragOver={(e) => e.preventDefault()}
79+
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-12 text-center cursor-pointer hover:border-primary-400 transition-colors"
80+
onClick={() => document.getElementById('file-input')?.click()}
81+
onKeyDown={(e) => {
82+
if (e.key === 'Enter' || e.key === ' ')
83+
document.getElementById('file-input')?.click();
84+
}}
85+
role="button"
86+
tabIndex={0}
87+
>
88+
<p className="text-gray-500 dark:text-gray-400 text-lg">
89+
📄 .boxnote ファイルをドロップ、またはクリックして選択
90+
</p>
91+
<input
92+
id="file-input"
93+
type="file"
94+
accept=".boxnote"
95+
className="hidden"
96+
onChange={(e) => {
97+
const f = e.target.files?.[0];
98+
if (f) handleFile(f);
99+
}}
100+
/>
101+
</div>
102+
{error && (
103+
<p className="mt-4 text-red-600 dark:text-red-400">{error}</p>
104+
)}
105+
</CardContent>
106+
</Card>
107+
108+
{markdown && (
109+
<Card>
110+
<CardContent className="pt-6">
111+
<div className="flex justify-between items-center mb-4">
112+
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
113+
変換結果
114+
</h2>
115+
<div className="flex gap-2">
116+
<button
117+
type="button"
118+
onClick={handleCopy}
119+
className="px-3 py-1.5 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
120+
>
121+
{copied ? '✓ コピー済み' : 'コピー'}
122+
</button>
123+
<button
124+
type="button"
125+
onClick={handleDownload}
126+
className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 transition-colors"
127+
>
128+
.md ダウンロード
129+
</button>
130+
</div>
131+
</div>
132+
<pre className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4 overflow-auto max-h-[600px] text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap">
133+
{markdown}
134+
</pre>
135+
</CardContent>
136+
</Card>
137+
)}
138+
</div>
139+
</div>
140+
</div>
141+
);
142+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// BoxNote JSON → Markdown converter
2+
3+
interface BoxNode {
4+
type: string;
5+
attrs?: Record<string, unknown>;
6+
content?: BoxNode[];
7+
text?: string;
8+
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
9+
}
10+
11+
function renderMarks(text: string, marks?: BoxNode['marks']): string {
12+
if (!marks) return text;
13+
let r = text;
14+
for (const m of marks) {
15+
if (m.type === 'strong') r = `**${r}**`;
16+
else if (m.type === 'strikethrough') r = `~~${r}~~`;
17+
else if (m.type === 'code') r = `\`${r}\``;
18+
else if (m.type === 'link') r = `[${r}](${m.attrs?.href ?? ''})`;
19+
}
20+
return r;
21+
}
22+
23+
function renderInline(nodes?: BoxNode[]): string {
24+
if (!nodes) return '';
25+
return nodes
26+
.map((n) => {
27+
if (n.type === 'text') return renderMarks(n.text ?? '', n.marks);
28+
if (n.type === 'hard_break') return '\n';
29+
if (n.type === 'image')
30+
return `![${n.attrs?.alt ?? ''}](${n.attrs?.src ?? ''})`;
31+
return '';
32+
})
33+
.join('');
34+
}
35+
36+
function renderTable(node: BoxNode): string {
37+
const rows = (node.content ?? []).filter((r) => r.type === 'table_row');
38+
if (rows.length === 0) return '';
39+
const matrix = rows.map((row) =>
40+
(row.content ?? []).map((cell) => {
41+
const text = (cell.content ?? [])
42+
.map((c) => renderInline(c.content))
43+
.join(' ')
44+
.replace(/\n/g, ' ')
45+
.trim();
46+
return text;
47+
})
48+
);
49+
const cols = Math.max(...matrix.map((r) => r.length));
50+
const pad = (arr: string[]) => {
51+
while (arr.length < cols) arr.push('');
52+
return arr;
53+
};
54+
const lines: string[] = [];
55+
lines.push(`| ${pad(matrix[0]).join(' | ')} |`);
56+
lines.push(`| ${Array(cols).fill('---').join(' | ')} |`);
57+
for (let i = 1; i < matrix.length; i++) {
58+
lines.push(`| ${pad(matrix[i]).join(' | ')} |`);
59+
}
60+
return lines.join('\n') + '\n';
61+
}
62+
63+
function renderBlock(node: BoxNode, indent = 0): string {
64+
const pfx = ' '.repeat(indent);
65+
switch (node.type) {
66+
case 'doc':
67+
return (node.content ?? []).map((c) => renderBlock(c)).join('\n');
68+
case 'heading': {
69+
const lvl = (node.attrs?.level as number) ?? 1;
70+
const t = renderInline(node.content);
71+
return t ? `${'#'.repeat(lvl)} ${t}\n` : '';
72+
}
73+
case 'paragraph': {
74+
const t = renderInline(node.content);
75+
return `${pfx}${t}\n`;
76+
}
77+
case 'bullet_list':
78+
case 'ordered_list':
79+
return (node.content ?? []).map((c) => renderBlock(c, indent)).join('');
80+
case 'list_item': {
81+
const parts: string[] = [];
82+
for (const child of node.content ?? []) {
83+
if (child.type === 'paragraph')
84+
parts.push(`${pfx}- ${renderInline(child.content).trim()}`);
85+
else if (
86+
child.type === 'bullet_list' ||
87+
child.type === 'ordered_list'
88+
)
89+
parts.push(renderBlock(child, indent + 1));
90+
else parts.push(renderBlock(child, indent + 1));
91+
}
92+
return parts.join('\n') + '\n';
93+
}
94+
case 'table':
95+
return renderTable(node) + '\n';
96+
case 'code_block': {
97+
const lang = (node.attrs?.language as string) ?? '';
98+
return `\`\`\`${lang}\n${renderInline(node.content)}\n\`\`\`\n`;
99+
}
100+
case 'blockquote': {
101+
const inner = (node.content ?? []).map((c) => renderBlock(c)).join('');
102+
return (
103+
inner
104+
.split('\n')
105+
.map((l) => (l ? `> ${l}` : '>'))
106+
.join('\n') + '\n'
107+
);
108+
}
109+
case 'horizontal_rule':
110+
return '---\n';
111+
default:
112+
return (node.content ?? []).map((c) => renderBlock(c, indent)).join('');
113+
}
114+
}
115+
116+
export function convertBoxNoteToMarkdown(json: string): string {
117+
const parsed = JSON.parse(json) as { doc?: BoxNode } & BoxNode;
118+
const doc = parsed.doc ?? parsed;
119+
const raw = renderBlock(doc);
120+
// collapse 3+ consecutive blank lines into 2
121+
return raw.replace(/\n{3,}/g, '\n\n').trim() + '\n';
122+
}

0 commit comments

Comments
 (0)