import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { fetchNotePages, createNotePage, updateNotePage, deleteNotePage, exportNotePage, exportAllNotePages, renameNoteSection, reorderNotePages, fetchSessionNotes, updateSessionNotes, importNotePages, type NotePage, } from '../api/client' const SAVE_DEBOUNCE_MS = 800 export const useNotesStore = defineStore('notes', () => { const pages = ref([]) const activeId = ref(null) const error = ref(null) /** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */ const dirtyIds = ref>(new Set()) /** * 本文をシステムプロンプトに載せるページID。チャットセッションごとの設定で、 * セッションを切り替えるたびに loadAttached() で読み直す。 */ const attachedIds = ref>(new Set()) /** attachedIds がどのセッションのものか。null は「まだ保存先が無い新規チャット」。 */ const attachedSessionId = ref(null) let saveTimer: number | null = null const activePage = computed(() => pages.value.find((p) => p.id === activeId.value) ?? null) async function load(): Promise { error.value = null try { const fetched = await fetchNotePages() // 編集中のページはローカルの内容を優先し、サーバーの値で潰さない pages.value = fetched.map((page) => { if (!dirtyIds.value.has(page.id)) return page const local = pages.value.find((p) => p.id === page.id) return local ? { ...page, title: local.title, content: local.content } : page }) if (activeId.value === null || !pages.value.some((p) => p.id === activeId.value)) { activeId.value = pages.value[0]?.id ?? null } } catch (e) { error.value = e instanceof Error ? e.message : 'ノートの取得に失敗しました' } } function selectPage(id: number): void { flush() activeId.value = id } async function addPage(title = '新しいページ', section: string | null = null): Promise { error.value = null try { const page = await createNotePage(title, section) // サーバー側でセクションの並びに合わせて再配置されている可能性があるため一覧を取り直す await load() activeId.value = page.id } catch (e) { error.value = e instanceof Error ? e.message : 'ページの作成に失敗しました' } } /** ページの所属セクションを変更する。section が空/nullならグループ解除。並び順もサーバー側で調整されるため再取得する。 */ async function setSection(id: number, section: string | null): Promise { error.value = null flush() try { await updateNotePage(id, { section }) await load() } catch (e) { error.value = e instanceof Error ? e.message : 'セクションの変更に失敗しました' } } /** oldName セクションに属する全ページを newName セクションへ一括で付け替える。newName が空ならグループ解除。 */ async function renameSection(oldName: string, newName: string | null): Promise { error.value = null flush() try { await renameNoteSection(oldName, newName) await load() } catch (e) { error.value = e instanceof Error ? e.message : 'セクション名の変更に失敗しました' } } /** * タブのドラッグ&ドロップ用。order には全ページIDをドラッグ後の新しい順序で渡す。 * section にドロップ先タブの所属セクションを渡せば、並べ替えと同時にセクション間の移動も反映される。 */ async function reorder(pageId: number, section: string | null, order: number[]): Promise { error.value = null flush() try { await reorderNotePages(pageId, section, order) await load() } catch (e) { error.value = e instanceof Error ? e.message : '並べ替えに失敗しました' } } /** セッションに紐付いたページを読み込む。sessionId が null(新規チャット)なら選択を空にする。 */ async function loadAttached(sessionId: string | null): Promise { attachedSessionId.value = sessionId if (sessionId === null) { attachedIds.value = new Set() return } try { attachedIds.value = new Set(await fetchSessionNotes(sessionId)) } catch (e) { error.value = e instanceof Error ? e.message : '参照ノートの取得に失敗しました' } } /** * ページをプロンプトに載せる/載せないを切り替える。 * まだセッションが無い新規チャットではローカルに保持するだけで、最初の送信時にまとめて保存される。 */ async function toggleAttached(id: number): Promise { const next = new Set(attachedIds.value) if (next.has(id)) next.delete(id) else next.add(id) attachedIds.value = next const sessionId = attachedSessionId.value if (sessionId === null) return try { attachedIds.value = new Set(await updateSessionNotes(sessionId, [...next])) } catch (e) { error.value = e instanceof Error ? e.message : '参照ノートの更新に失敗しました' } } async function removePage(id: number): Promise { error.value = null try { await deleteNotePage(id) dirtyIds.value.delete(id) // 紐付けはサーバー側でもカスケード削除される if (attachedIds.value.has(id)) { const next = new Set(attachedIds.value) next.delete(id) attachedIds.value = next } pages.value = pages.value.filter((p) => p.id !== id) if (activeId.value === id) activeId.value = pages.value[0]?.id ?? null } catch (e) { error.value = e instanceof Error ? e.message : 'ページの削除に失敗しました' } } /** ローカルの内容を即座に更新し、少し待ってからサーバーへ保存する。 */ function edit(id: number, patch: { title?: string; content?: string }): void { const page = pages.value.find((p) => p.id === id) if (!page) return Object.assign(page, patch) dirtyIds.value.add(id) if (saveTimer !== null) clearTimeout(saveTimer) saveTimer = window.setTimeout(() => { saveTimer = null void save(id) }, SAVE_DEBOUNCE_MS) } async function save(id: number): Promise { const page = pages.value.find((p) => p.id === id) if (!page || !dirtyIds.value.has(id)) return const { title, content } = page try { const saved = await updateNotePage(id, { title, content }) // 保存の往復中にさらに編集されていた場合は dirty のままにしておく if (page.title === title && page.content === content) { dirtyIds.value.delete(id) page.updated_at = saved.updated_at } error.value = null } catch (e) { error.value = e instanceof Error ? e.message : 'ノートの保存に失敗しました' } } /** * 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 * await すれば保存の完了まで待てる(save() は自分でエラーを握るので reject はしない)。 */ function flush(): Promise { if (saveTimer !== null) { clearTimeout(saveTimer) saveTimer = null } return Promise.all([...dirtyIds.value].map((id) => save(id))).then(() => undefined) } /** * ファイルをページとして取り込み、最初に作られたページを開く。 * 戻り値は作成されたページ(失敗時は空配列)。 */ async function importFiles(files: File[]): Promise { error.value = null await flush() try { const created = await importNotePages(files) await load() if (created.length > 0) activeId.value = created[0].id return created } catch (e) { error.value = e instanceof Error ? e.message : 'ファイルの取り込みに失敗しました' return [] } } /** 編集中の内容を保存してから、そのページを Markdown としてダウンロードする。 */ async function downloadPage(id: number): Promise { await flush() exportNotePage(id) } /** 編集中の内容を保存してから、全ページを ZIP としてダウンロードする。 */ async function downloadAll(): Promise { await flush() exportAllNotePages() } return { pages, activeId, activePage, error, dirtyIds, attachedIds, attachedSessionId, loadAttached, toggleAttached, load, selectPage, addPage, removePage, edit, setSection, renameSection, reorder, flush, importFiles, downloadPage, downloadAll, } })