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:
@@ -97,6 +97,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
|||||||
- **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される
|
- **ツール実行の流れ**: `llm.resolve_tool_calls()` が本番の応答生成前にツール呼び出しを解決する。`web_search` と共通の仕組みで、ノート系ツールは `db` が渡されたときだけモデルに提示される
|
||||||
- **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる
|
- **UI への反映**: AI がノートを書き換えた場合、SSE で `notes_updated` を送ってフロント側のノートパネルを再読み込みさせる
|
||||||
- **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない
|
- **編集の衝突回避**: フロントは未保存の編集があるページ(`dirtyIds`)をサーバーの値で上書きしない
|
||||||
|
- **ダウンロード**: `GET /api/notes/{page_id}/export` が 1 ページを Markdown、`GET /api/notes/export` が全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号で `position` 順を保持)で返す。ファイル名はサーバーが決めるのでフロントは `download` 属性を付けず `Content-Disposition`(日本語タイトルのため RFC 5987 を併記)に任せる
|
||||||
- **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`)
|
- **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`)
|
||||||
|
|
||||||
### SSE ストリーミング
|
### SSE ストリーミング
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from datetime import datetime
|
import io
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
@@ -46,11 +50,73 @@ class ReorderRequest(BaseModel):
|
|||||||
order: list[int]
|
order: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
# ファイル名に使えない文字(Windows の禁止文字。制御文字は _safe_filename 側で弾く)
|
||||||
|
_UNSAFE_FILENAME_CHARS = '\\/:*?"<>|'
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str, fallback: str) -> str:
|
||||||
|
"""タイトルやセクション名をファイル名に使える形へ落とす。空になったら fallback を使う。"""
|
||||||
|
cleaned = "".join("_" if c in _UNSAFE_FILENAME_CHARS or ord(c) < 32 else c for c in name)
|
||||||
|
cleaned = cleaned.strip().strip(".")
|
||||||
|
return cleaned[:80] or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_headers(filename: str) -> dict[str, str]:
|
||||||
|
"""日本語のファイル名は Content-Disposition にそのまま置けないため RFC 5987 形式を併記する。"""
|
||||||
|
ascii_fallback = filename.encode("ascii", "replace").decode("ascii").replace("?", "_")
|
||||||
|
return {
|
||||||
|
"Content-Disposition": (
|
||||||
|
f'attachment; filename="{ascii_fallback}"; '
|
||||||
|
f"filename*=UTF-8''{quote(filename, safe='')}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[NotePageResponse])
|
@router.get("", response_model=list[NotePageResponse])
|
||||||
def list_pages(db: DBSession = Depends(get_db)):
|
def list_pages(db: DBSession = Depends(get_db)):
|
||||||
return db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
return db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
def export_all_pages(db: DBSession = Depends(get_db)):
|
||||||
|
"""全ページを 1ページ1ファイルの Markdown にして ZIP でダウンロードさせる。
|
||||||
|
|
||||||
|
本文はそのまま書き出す(1つのファイルに連結すると、ページ内の見出しの階層が
|
||||||
|
ページ間の見出しと混ざってしまうため)。タブの並び順を残すためファイル名には
|
||||||
|
通し番号を付け、セクションはフォルダにする。
|
||||||
|
"""
|
||||||
|
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for i, page in enumerate(pages, start=1):
|
||||||
|
entry = f"{i:02d}_{_safe_filename(page.title, f'page_{page.id}')}.md"
|
||||||
|
if page.section:
|
||||||
|
entry = f"{_safe_filename(page.section, 'section')}/{entry}"
|
||||||
|
archive.writestr(entry, page.content)
|
||||||
|
|
||||||
|
filename = f"notes_{datetime.now(timezone.utc).strftime('%Y%m%d')}.zip"
|
||||||
|
return Response(
|
||||||
|
content=buffer.getvalue(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers=_attachment_headers(filename),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{page_id}/export")
|
||||||
|
def export_page(page_id: int, db: DBSession = Depends(get_db)):
|
||||||
|
"""1ページを Markdown ファイルとしてダウンロードさせる。本文はそのまま、ファイル名がタイトルになる。"""
|
||||||
|
page = db.get(NotePage, page_id)
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(status_code=404, detail="Note page not found")
|
||||||
|
filename = f"{_safe_filename(page.title, f'page_{page.id}')}.md"
|
||||||
|
return Response(
|
||||||
|
content=page.content,
|
||||||
|
media_type="text/markdown; charset=utf-8",
|
||||||
|
headers=_attachment_headers(filename),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=NotePageResponse, status_code=201)
|
@router.post("", response_model=NotePageResponse, status_code=201)
|
||||||
def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
|
def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
|
||||||
title = req.title.strip() or "新しいページ"
|
title = req.title.strip() or "新しいページ"
|
||||||
|
|||||||
+22
@@ -187,6 +187,28 @@ SSE でストリーミング応答を返す。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### GET /api/notes/export
|
||||||
|
全ページを ZIP でダウンロードさせる(`Content-Disposition: attachment`)。
|
||||||
|
1 ページ 1 ファイルの Markdown で、本文はそのまま。エントリ名は `<セクション>/<通し番号>_<タイトル>.md`
|
||||||
|
(セクション無しのページは ZIP 直下)。通し番号は `position` 順なのでタブの並びが残る。
|
||||||
|
ファイル名は `notes_YYYYMMDD.zip`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/notes/{page_id}/export
|
||||||
|
1 ページを Markdown ファイルとしてダウンロードさせる。本文はそのままで、ファイル名がタイトルになる。
|
||||||
|
ページが存在しない場合は `404`。
|
||||||
|
|
||||||
|
日本語タイトルを扱うため `Content-Disposition` には RFC 5987 形式を併記する:
|
||||||
|
|
||||||
|
```
|
||||||
|
attachment; filename="______.md"; filename*=UTF-8''%E8%B2%B7%E3%81%84%E7%89%A9%E3%83%AA%E3%82%B9%E3%83%88.md
|
||||||
|
```
|
||||||
|
|
||||||
|
ファイル名に使えない文字(`\ / : * ? " < > |` と制御文字)は `_` に置き換え、80 文字で切る。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ログ
|
## ログ
|
||||||
|
|
||||||
### GET /logs
|
### GET /logs
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
|||||||
- AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。
|
- AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。
|
||||||
AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。
|
AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。
|
||||||
- 自分が編集中(未保存)のページは AI の更新で上書きされない。
|
- 自分が編集中(未保存)のページは AI の更新で上書きされない。
|
||||||
|
- **ダウンロード**: パネル下部の ⬇ ボタンで、開いているページを `.md` として、または全ページを
|
||||||
|
ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号でタブ順を保持)として
|
||||||
|
手元に保存できる。未保存の編集は書き出す前に保存される。
|
||||||
|
|
||||||
## 8-9. 自動タグ付けとワードプール
|
## 8-9. 自動タグ付けとワードプール
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,28 @@ API 仕様は `docs/api.md` の「共有ノート」節を参照。
|
|||||||
| タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) |
|
| タイトルを変更 | タブを**ダブルクリック**すると入力欄になるので書き換えて Enter(または欄外をクリック) |
|
||||||
| ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) |
|
| ページを削除 | タブ内の **×** をクリック(確認ダイアログあり) |
|
||||||
| 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 |
|
| 本文を編集 | ページを開いた状態で下の編集エリアに直接入力 |
|
||||||
|
| ページを1つ保存 | パネル下部の **⬇ このページ (.md)** をクリック |
|
||||||
|
| 全ページを保存 | パネル下部の **⬇ すべて (.zip)** をクリック |
|
||||||
|
|
||||||
本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。
|
本文は入力を止めてから少し待つと自動保存される。保存待ちの間はタブに **●**(未保存マーク)が付く。
|
||||||
ページが1つも無い場合は「+ 最初のページを作成」から始められる。
|
ページが1つも無い場合は「+ 最初のページを作成」から始められる。
|
||||||
|
|
||||||
|
## 手元にダウンロードする
|
||||||
|
|
||||||
|
パネル下部の ⬇ ボタンでノートをファイルとして保存できる。未保存の編集があっても、
|
||||||
|
保存してから書き出すので取りこぼしはない。
|
||||||
|
|
||||||
|
| ボタン | 出てくるもの |
|
||||||
|
|---|---|
|
||||||
|
| **⬇ このページ (.md)** | 開いているページの本文そのままの Markdown ファイル。ファイル名はページのタイトル |
|
||||||
|
| **⬇ すべて (.zip)** | 全ページをまとめた ZIP。1 ページ 1 Markdown で、セクションはフォルダになる |
|
||||||
|
|
||||||
|
ZIP の中のファイル名には `01_`, `02_` … と通し番号が付く。タブの並び順を残すためで、
|
||||||
|
展開したときも画面と同じ順番で並ぶ。ファイル名に使えない文字(`/` や `:` など)は `_` に置き換わる。
|
||||||
|
|
||||||
|
複数ページを 1 つのファイルに連結せず ZIP にしているのは、ページ内の見出し(`#`)が
|
||||||
|
ページ間の見出しと混ざって階層が壊れるのを避けるため。
|
||||||
|
|
||||||
## セクションでまとめる
|
## セクションでまとめる
|
||||||
|
|
||||||
複数のページを1つのグループとしてタブ上にまとめられる。
|
複数のページを1つのグループとしてタブ上にまとめられる。
|
||||||
|
|||||||
@@ -314,6 +314,29 @@ export async function deleteNotePage(pageId: number): Promise<void> {
|
|||||||
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
|
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ならグループ解除。 */
|
/** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */
|
||||||
export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> {
|
export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> {
|
||||||
const res = await fetch(`${BASE}/api/notes/sections/rename`, {
|
const res = await fetch(`${BASE}/api/notes/sections/rename`, {
|
||||||
|
|||||||
@@ -212,10 +212,26 @@ onUnmounted(() => {
|
|||||||
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
|
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
|
||||||
</div>
|
</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">
|
<div class="hint">
|
||||||
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
|
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
|
||||||
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
|
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
|
||||||
タブはドラッグで並べ替え・他のセクションへの移動ができます。
|
タブはドラッグで並べ替え・他のセクションへの移動ができます。
|
||||||
|
⬇ でノートを手元にダウンロードできます(未保存の編集は保存してから書き出します)。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -355,6 +371,26 @@ onUnmounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
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 {
|
.hint {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
createNotePage,
|
createNotePage,
|
||||||
updateNotePage,
|
updateNotePage,
|
||||||
deleteNotePage,
|
deleteNotePage,
|
||||||
|
exportNotePage,
|
||||||
|
exportAllNotePages,
|
||||||
renameNoteSection,
|
renameNoteSection,
|
||||||
reorderNotePages,
|
reorderNotePages,
|
||||||
type NotePage,
|
type NotePage,
|
||||||
@@ -140,13 +142,28 @@ export const useNotesStore = defineStore('notes', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */
|
/**
|
||||||
function flush(): void {
|
* 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。
|
||||||
|
* await すれば保存の完了まで待てる(save() は自分でエラーを握るので reject はしない)。
|
||||||
|
*/
|
||||||
|
function flush(): Promise<void> {
|
||||||
if (saveTimer !== null) {
|
if (saveTimer !== null) {
|
||||||
clearTimeout(saveTimer)
|
clearTimeout(saveTimer)
|
||||||
saveTimer = null
|
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 {
|
return {
|
||||||
@@ -164,5 +181,7 @@ export const useNotesStore = defineStore('notes', () => {
|
|||||||
renameSection,
|
renameSection,
|
||||||
reorder,
|
reorder,
|
||||||
flush,
|
flush,
|
||||||
|
downloadPage,
|
||||||
|
downloadAll,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user