Let users download shared notes as Markdown

The shared notes had no way out: everything lived in the SQLite row and the
only reader was the panel itself. Add two export endpoints and buttons for
them at the bottom of the note panel.

GET /api/notes/{page_id}/export returns one page as .md, and
GET /api/notes/export returns every page as a zip laid out as
<section>/<NN>_<title>.md. Pages are not concatenated into a single file on
purpose - the headings inside a page would collide with whatever heading
level we used as a page separator, so each page stays a verbatim file and the
numeric prefix carries the tab order instead.

Titles reach the filesystem here, so strip the Windows-forbidden characters
and control characters, cap the length, and send Content-Disposition with an
RFC 5987 filename* so Japanese titles survive. The front end deliberately
omits the anchor's download attribute to let that name win.

notes store: flush() now returns a promise so the download can wait for
pending edits to be saved before asking the server for the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 15:33:08 +09:00
co-authored by Claude Opus 5
parent f89bc9bc9a
commit 216d38c7bd
8 changed files with 192 additions and 4 deletions
+23
View File
@@ -314,6 +314,29 @@ export async function deleteNotePage(pageId: number): Promise<void> {
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
}
/**
* ダウンロードのためだけにアンカーをクリックする。
* ファイル名はサーバーの Content-Disposition に任せたいので download 属性は付けない
* (ノートのタイトルには日本語やスラッシュが入りうるため、名前付けはサーバー側に一本化している)。
*/
function triggerDownload(url: string): void {
const a = document.createElement('a')
a.href = url
document.body.appendChild(a)
a.click()
a.remove()
}
/** 1ページを Markdown ファイルとしてダウンロードする。 */
export function exportNotePage(pageId: number): void {
triggerDownload(`${BASE}/api/notes/${pageId}/export`)
}
/** 全ページを ZIP(1ページ1 Markdown、セクションはフォルダ)としてダウンロードする。 */
export function exportAllNotePages(): void {
triggerDownload(`${BASE}/api/notes/export`)
}
/** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */
export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> {
const res = await fetch(`${BASE}/api/notes/sections/rename`, {
+36
View File
@@ -212,10 +212,26 @@ onUnmounted(() => {
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
</div>
<div class="actions">
<button
class="action-btn"
:disabled="store.activeId === null"
title="開いているページを Markdown ファイルとして保存"
@click="store.activeId !== null && store.downloadPage(store.activeId)"
>⬇ このページ (.md)</button>
<button
class="action-btn"
:disabled="store.pages.length === 0"
title="全ページを ZIP(1ページ1 Markdown、セクションはフォルダ)として保存"
@click="store.downloadAll()"
>⬇ すべて (.zip)</button>
</div>
<div class="hint">
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
タブはドラッグで並べ替え・他のセクションへの移動ができます。
⬇ でノートを手元にダウンロードできます(未保存の編集は保存してから書き出します)。
</div>
</div>
</template>
@@ -355,6 +371,26 @@ onUnmounted(() => {
font-size: 12px;
cursor: pointer;
}
.actions {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: 6px;
padding: 6px 8px;
border-top: 1px solid #2a2a2a;
}
.action-btn {
background: #1f1f1f;
color: #aaa;
border: 1px solid #333;
border-radius: 6px;
padding: 4px 10px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
}
.action-btn:hover:not(:disabled) { background: #2a2a2a; color: #93c5fd; border-color: #444; }
.action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.hint {
flex-shrink: 0;
padding: 8px 10px;
+22 -3
View File
@@ -5,6 +5,8 @@ import {
createNotePage,
updateNotePage,
deleteNotePage,
exportNotePage,
exportAllNotePages,
renameNoteSection,
reorderNotePages,
type NotePage,
@@ -140,13 +142,28 @@ export const useNotesStore = defineStore('notes', () => {
}
}
/** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */
function flush(): void {
/**
* 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。
* await すれば保存の完了まで待てる(save() は自分でエラーを握るので reject はしない)。
*/
function flush(): Promise<void> {
if (saveTimer !== null) {
clearTimeout(saveTimer)
saveTimer = null
}
for (const id of [...dirtyIds.value]) void save(id)
return Promise.all([...dirtyIds.value].map((id) => save(id))).then(() => undefined)
}
/** 編集中の内容を保存してから、そのページを Markdown としてダウンロードする。 */
async function downloadPage(id: number): Promise<void> {
await flush()
exportNotePage(id)
}
/** 編集中の内容を保存してから、全ページを ZIP としてダウンロードする。 */
async function downloadAll(): Promise<void> {
await flush()
exportAllNotePages()
}
return {
@@ -164,5 +181,7 @@ export const useNotesStore = defineStore('notes', () => {
renameSection,
reorder,
flush,
downloadPage,
downloadAll,
}
})