Pin notes to a chat, and import notes from files
Two additions to the shared notes, both about getting content in and out of the prompt. Per-chat pinning. Notes normally reach the model as a title list only, and the body arrives just if the model decides to call read_note. A 📌 on the tab now puts a page's full body in the system prompt for the chat that is open, every turn, no tool round trip. The selection is per session (new table session_note_pages, unique on session_id + page_id), so different conversations carry different pages. The title list marks pinned pages so the model does not read them twice, and bodies are capped per page by SESSION_NOTES_CONTENT_LIMIT. A new chat has no session row until its first message, so the front end cannot PUT the selection yet. POST /chat/stream therefore accepts note_page_ids and applies it before building the context, which makes the pins count from the very first turn. File import. Dropping .md/.txt on the editor - or picking them with the new ⬆ button - creates pages, and a .zip is unpacked into one page per text entry with the first path component as the section. That is exactly the shape the export writes, so a downloaded zip round-trips back in. Titles that collide get a " (2)" suffix rather than overwriting, since restoring a backup into a live workspace should not destroy anything. UploadFile needs python-multipart, added to the lockfile. Toasts grew a kind: the import success message was rendering in the red error toast, as was the existing session-import message. Both are green now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,24 @@ export async function fetchMessages(sessionId: string): Promise<MessageRecord[]>
|
||||
return parseJson<MessageRecord[]>(res, 'メッセージ取得')
|
||||
}
|
||||
|
||||
/** このセッションで本文をプロンプトに載せるよう指定されたノートのページID。 */
|
||||
export async function fetchSessionNotes(sessionId: string): Promise<number[]> {
|
||||
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/notes`)
|
||||
const body = await parseJson<{ page_ids: number[] }>(res, '参照ノート取得')
|
||||
return body.page_ids
|
||||
}
|
||||
|
||||
/** セッションに紐付けるノートを丸ごと置き換える。戻り値は実際に紐付いたページID。 */
|
||||
export async function updateSessionNotes(sessionId: string, pageIds: number[]): Promise<number[]> {
|
||||
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/notes`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ page_ids: pageIds }),
|
||||
})
|
||||
const body = await parseJson<{ page_ids: number[] }>(res, '参照ノート更新')
|
||||
return body.page_ids
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -225,6 +243,8 @@ export function streamChat(
|
||||
message: string,
|
||||
sessionId: string | null,
|
||||
model: string,
|
||||
/** 本文をプロンプトに載せるノートのページID。新規セッションではここでしか指定できない。 */
|
||||
notePageIds: number[],
|
||||
onChunk: (chunk: SSEChunk) => void,
|
||||
onError: (err: Error) => void,
|
||||
): () => void {
|
||||
@@ -233,7 +253,7 @@ export function streamChat(
|
||||
fetch(`${BASE}/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, session_id: sessionId, model }),
|
||||
body: JSON.stringify({ message, session_id: sessionId, model, note_page_ids: notePageIds }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
@@ -314,6 +334,17 @@ export async function deleteNotePage(pageId: number): Promise<void> {
|
||||
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* ファイルをページとして取り込む。.md / .txt は1ファイル1ページ、.zip は中身を展開して複数ページ。
|
||||
* 取り込めるファイルが1つも無ければサーバーが 400 を返す。
|
||||
*/
|
||||
export async function importNotePages(files: File[]): Promise<NotePage[]> {
|
||||
const form = new FormData()
|
||||
for (const file of files) form.append('files', file)
|
||||
const res = await fetch(`${BASE}/api/notes/import`, { method: 'POST', body: form })
|
||||
return parseJson<NotePage[]>(res, 'ノート取り込み')
|
||||
}
|
||||
|
||||
/**
|
||||
* ダウンロードのためだけにアンカーをクリックする。
|
||||
* ファイル名はサーバーの Content-Disposition に任せたいので download 属性は付けない
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, nextTick, computed } from 'vue'
|
||||
import { useNotesStore } from '../stores/notes'
|
||||
import { useChatStore } from '../stores/chat'
|
||||
import type { NotePage } from '../api/client'
|
||||
|
||||
interface TabGroup {
|
||||
@@ -9,7 +10,11 @@ interface TabGroup {
|
||||
}
|
||||
|
||||
const store = useNotesStore()
|
||||
const chat = useChatStore()
|
||||
const renamingId = ref<number | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
/** ファイルを本文エリアの上までドラッグしてきているか(取り込み用のハイライト表示) */
|
||||
const fileOver = ref(false)
|
||||
// ref="renameInput" は入れ子の v-for の中にあるため、Vue 3 では単一要素ではなく配列になる
|
||||
const renameInput = ref<HTMLInputElement[]>([])
|
||||
|
||||
@@ -34,6 +39,40 @@ const groups = computed<TabGroup[]>(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
/** ファイルをページとして取り込む。取り込めた件数はトーストで知らせる。 */
|
||||
async function importFiles(files: FileList | File[] | null) {
|
||||
const list = [...(files ?? [])]
|
||||
if (list.length === 0) return
|
||||
const created = await store.importFiles(list)
|
||||
if (created.length > 0) {
|
||||
chat.pushToast(`${created.length}ページを取り込みました: ${created.map((p) => p.title).join(', ')}`, 'success')
|
||||
}
|
||||
}
|
||||
|
||||
function onPickFiles(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
void importFiles(input.files)
|
||||
input.value = '' // 同じファイルを続けて選び直せるようにする
|
||||
}
|
||||
|
||||
/** タブのドラッグ&ドロップ(draggingId が立つ)とは別物。ファイルのドロップだけを拾う。 */
|
||||
function isFileDrag(e: DragEvent): boolean {
|
||||
return !!e.dataTransfer && [...e.dataTransfer.types].includes('Files')
|
||||
}
|
||||
|
||||
function onFileDragOver(e: DragEvent) {
|
||||
if (!isFileDrag(e)) return
|
||||
e.preventDefault()
|
||||
fileOver.value = true
|
||||
}
|
||||
|
||||
function onFileDrop(e: DragEvent) {
|
||||
if (!isFileDrag(e)) return
|
||||
e.preventDefault()
|
||||
fileOver.value = false
|
||||
void importFiles(e.dataTransfer!.files)
|
||||
}
|
||||
|
||||
async function startRename(id: number) {
|
||||
renamingId.value = id
|
||||
await nextTick()
|
||||
@@ -155,6 +194,7 @@ onUnmounted(() => {
|
||||
class="tab"
|
||||
:class="{
|
||||
active: page.id === store.activeId,
|
||||
attached: store.attachedIds.has(page.id),
|
||||
dragging: draggingId === page.id,
|
||||
'drop-before': dropTarget?.id === page.id && dropTarget.place === 'before',
|
||||
'drop-after': dropTarget?.id === page.id && dropTarget.place === 'after',
|
||||
@@ -181,6 +221,14 @@ onUnmounted(() => {
|
||||
<template v-else>
|
||||
<span class="tab-title" :title="page.title">{{ page.title }}</span>
|
||||
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
|
||||
<button
|
||||
class="tab-pin"
|
||||
:class="{ on: store.attachedIds.has(page.id) }"
|
||||
:title="store.attachedIds.has(page.id)
|
||||
? 'この会話のプロンプトに本文を載せている(クリックで解除)'
|
||||
: 'この会話のプロンプトに本文を載せる'"
|
||||
@click.stop="store.toggleAttached(page.id)"
|
||||
>📌</button>
|
||||
<button class="tab-tag" title="セクションに割り当て" @click.stop="assignSection(page)">🏷</button>
|
||||
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
|
||||
</template>
|
||||
@@ -198,21 +246,44 @@ onUnmounted(() => {
|
||||
|
||||
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
|
||||
|
||||
<textarea
|
||||
v-if="store.activePage"
|
||||
class="editor"
|
||||
:value="store.activePage.content"
|
||||
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。"
|
||||
spellcheck="false"
|
||||
@input="onContentInput"
|
||||
@blur="store.flush()"
|
||||
/>
|
||||
<div v-else class="empty">
|
||||
<p>ページがありません。</p>
|
||||
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
|
||||
<div
|
||||
class="body"
|
||||
:class="{ 'file-over': fileOver }"
|
||||
@dragover="onFileDragOver"
|
||||
@dragleave="fileOver = false"
|
||||
@drop="onFileDrop"
|
||||
>
|
||||
<textarea
|
||||
v-if="store.activePage"
|
||||
class="editor"
|
||||
:value="store.activePage.content"
|
||||
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。 .md / .txt / .zip をここにドロップすると新しいページとして取り込みます。"
|
||||
spellcheck="false"
|
||||
@input="onContentInput"
|
||||
@blur="store.flush()"
|
||||
/>
|
||||
<div v-else class="empty">
|
||||
<p>ページがありません。</p>
|
||||
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
|
||||
<p class="empty-hint">.md / .txt / .zip をここにドロップしても取り込めます。</p>
|
||||
</div>
|
||||
<div v-if="fileOver" class="drop-overlay">ドロップしてページとして取り込む</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="file-input"
|
||||
multiple
|
||||
accept=".md,.markdown,.txt,.text,.zip"
|
||||
@change="onPickFiles"
|
||||
>
|
||||
<button
|
||||
class="action-btn"
|
||||
title=".md / .txt / .zip をページとして取り込む"
|
||||
@click="fileInput?.click()"
|
||||
>⬆ 取り込み</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="store.activeId === null"
|
||||
@@ -232,6 +303,11 @@ onUnmounted(() => {
|
||||
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
|
||||
タブはドラッグで並べ替え・他のセクションへの移動ができます。
|
||||
⬇ でノートを手元にダウンロードできます(未保存の編集は保存してから書き出します)。
|
||||
⬆ または本文エリアへのドロップで .md / .txt / .zip を取り込めます(⬇ で出した ZIP を戻せばセクションごと復元されます)。
|
||||
<span class="hint-pin">
|
||||
📌 を付けたページは、<strong>今開いているチャット</strong>のプロンプトに本文がそのまま載ります
|
||||
(AIが read_note で読みに行くのを待たずに確実に参照されます)。指定はチャットごとに保存されます。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -295,6 +371,8 @@ onUnmounted(() => {
|
||||
max-width: 160px;
|
||||
}
|
||||
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
|
||||
/* プロンプトに本文を載せているページ。タブ上端の線で「積んでいる」ことを示す */
|
||||
.tab.attached { box-shadow: inset 0 2px 0 #eab308; }
|
||||
.tab.dragging { opacity: 0.4; }
|
||||
.tab.drop-before { box-shadow: inset 2px 0 0 #93c5fd; }
|
||||
.tab.drop-after { box-shadow: inset -2px 0 0 #93c5fd; }
|
||||
@@ -308,7 +386,7 @@ onUnmounted(() => {
|
||||
.drop-end.active { background: #93c5fd; opacity: 0.5; }
|
||||
.tab-title { overflow: hidden; text-overflow: ellipsis; }
|
||||
.dot { color: #eab308; font-size: 8px; }
|
||||
.tab-tag, .tab-del, .tab-add {
|
||||
.tab-pin, .tab-tag, .tab-del, .tab-add {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
@@ -317,9 +395,13 @@ onUnmounted(() => {
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
.tab-tag { font-size: 11px; opacity: 0; transition: opacity 0.15s; }
|
||||
.tab-pin, .tab-tag { font-size: 11px; opacity: 0; transition: opacity 0.15s; }
|
||||
.tab:hover .tab-pin, .tab.active .tab-pin,
|
||||
.tab:hover .tab-tag, .tab.active .tab-tag { opacity: 0.8; }
|
||||
.tab-tag:hover { opacity: 1 !important; }
|
||||
.tab-pin:hover, .tab-tag:hover { opacity: 1 !important; }
|
||||
/* 載せているページのピンは、タブが非アクティブでも常に見えるようにする */
|
||||
.tab-pin.on { opacity: 1; filter: none; }
|
||||
.tab-pin:not(.on) { filter: grayscale(1); }
|
||||
.tab-del:hover, .tab-add:hover { color: #f87171; }
|
||||
.tab-add { color: #888; padding: 0 8px; align-self: center; }
|
||||
.tab-add:hover { color: #93c5fd; }
|
||||
@@ -339,6 +421,25 @@ onUnmounted(() => {
|
||||
background: #2a1414;
|
||||
border-bottom: 1px solid #7f1d1d;
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.body.file-over { outline: 2px dashed #93c5fd; outline-offset: -6px; }
|
||||
.drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(22, 22, 22, 0.85);
|
||||
color: #93c5fd;
|
||||
font-size: 13px;
|
||||
pointer-events: none; /* dragleave が誤発火しないように、当たり判定を持たせない */
|
||||
}
|
||||
.file-input { display: none; }
|
||||
.editor {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
@@ -352,6 +453,7 @@ onUnmounted(() => {
|
||||
line-height: 1.6;
|
||||
}
|
||||
.editor::placeholder { color: #555; }
|
||||
.empty-hint { color: #555; font-size: 11px; }
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -391,6 +493,8 @@ onUnmounted(() => {
|
||||
}
|
||||
.action-btn:hover:not(:disabled) { background: #2a2a2a; color: #93c5fd; border-color: #444; }
|
||||
.action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.hint-pin { display: block; margin-top: 4px; color: #8a7a3a; }
|
||||
.hint-pin strong { color: #eab308; font-weight: 600; }
|
||||
.hint {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px;
|
||||
|
||||
@@ -9,10 +9,10 @@ const store = useChatStore()
|
||||
<div
|
||||
v-for="toast in store.toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="['toast', toast.kind]"
|
||||
@click="store.dismissToast(toast.id)"
|
||||
>
|
||||
<span class="icon">⚠</span>
|
||||
<span class="icon">{{ toast.kind === 'success' ? '✓' : '⚠' }}</span>
|
||||
<span class="msg">{{ toast.message }}</span>
|
||||
<button class="close">✕</button>
|
||||
</div>
|
||||
@@ -46,9 +46,10 @@ const store = useChatStore()
|
||||
animation: slide-in 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
}
|
||||
.toast.success { background: #0a1f14; border-color: #166534; color: #6ee7b7; }
|
||||
.icon { flex-shrink: 0; }
|
||||
.msg { flex: 1; word-break: break-word; }
|
||||
.close { background: none; border: none; color: #f87171; cursor: pointer; font-size: 11px; padding: 0; }
|
||||
.close { background: none; border: none; color: inherit; cursor: pointer; font-size: 11px; padding: 0; }
|
||||
@keyframes slide-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Message {
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
kind: 'error' | 'success'
|
||||
}
|
||||
|
||||
let toastSeq = 0
|
||||
@@ -34,9 +35,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
sessions.value.find((s) => s.id === currentSessionId.value) ?? null,
|
||||
)
|
||||
|
||||
function pushToast(message: string, durationMs = 5000) {
|
||||
function pushToast(message: string, kind: Toast['kind'] = 'error', durationMs = 5000) {
|
||||
const id = ++toastSeq
|
||||
toasts.value.push({ id, message })
|
||||
toasts.value.push({ id, message, kind })
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}, durationMs)
|
||||
@@ -53,6 +54,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
async function selectSession(sessionId: string | null) {
|
||||
currentSessionId.value = sessionId
|
||||
messages.value = []
|
||||
// プロンプトに載せるノートはセッションごとの設定なので、切り替えのたびに読み直す
|
||||
useNotesStore().loadAttached(sessionId).catch(() => {})
|
||||
if (!sessionId) return
|
||||
try {
|
||||
const records = await fetchMessages(sessionId)
|
||||
@@ -98,7 +101,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const session = await importSession(payload)
|
||||
await loadSessions()
|
||||
await selectSession(session.id)
|
||||
pushToast(`「${session.title}」をインポートしました`)
|
||||
pushToast(`「${session.title}」をインポートしました`, 'success')
|
||||
} catch (e) {
|
||||
pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
@@ -132,13 +135,18 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持
|
||||
isStreaming.value = true
|
||||
|
||||
const notesStore = useNotesStore()
|
||||
streamChat(
|
||||
text,
|
||||
currentSessionId.value,
|
||||
selectedModel.value,
|
||||
[...notesStore.attachedIds],
|
||||
(chunk) => {
|
||||
if (chunk.type === 'session_id' && chunk.value) {
|
||||
currentSessionId.value = chunk.value as string
|
||||
// 新規セッションはこの時点で作られる。送信時に渡したノートの紐付けが
|
||||
// サーバー側に保存されているので、その保存先を控え直す
|
||||
notesStore.loadAttached(chunk.value as string).catch(() => {})
|
||||
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
|
||||
} else if (chunk.type === 'chunk' && chunk.value) {
|
||||
messages.value[msgIdx].content += chunk.value as string
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
exportAllNotePages,
|
||||
renameNoteSection,
|
||||
reorderNotePages,
|
||||
fetchSessionNotes,
|
||||
updateSessionNotes,
|
||||
importNotePages,
|
||||
type NotePage,
|
||||
} from '../api/client'
|
||||
|
||||
@@ -20,6 +23,13 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
const error = ref<string | null>(null)
|
||||
/** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */
|
||||
const dirtyIds = ref<Set<number>>(new Set())
|
||||
/**
|
||||
* 本文をシステムプロンプトに載せるページID。チャットセッションごとの設定で、
|
||||
* セッションを切り替えるたびに loadAttached() で読み直す。
|
||||
*/
|
||||
const attachedIds = ref<Set<number>>(new Set())
|
||||
/** attachedIds がどのセッションのものか。null は「まだ保存先が無い新規チャット」。 */
|
||||
const attachedSessionId = ref<string | null>(null)
|
||||
|
||||
let saveTimer: number | null = null
|
||||
|
||||
@@ -99,11 +109,50 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** セッションに紐付いたページを読み込む。sessionId が null(新規チャット)なら選択を空にする。 */
|
||||
async function loadAttached(sessionId: string | null): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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) {
|
||||
@@ -154,6 +203,24 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
return Promise.all([...dirtyIds.value].map((id) => save(id))).then(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* ファイルをページとして取り込み、最初に作られたページを開く。
|
||||
* 戻り値は作成されたページ(失敗時は空配列)。
|
||||
*/
|
||||
async function importFiles(files: File[]): Promise<NotePage[]> {
|
||||
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<void> {
|
||||
await flush()
|
||||
@@ -172,6 +239,10 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
activePage,
|
||||
error,
|
||||
dirtyIds,
|
||||
attachedIds,
|
||||
attachedSessionId,
|
||||
loadAttached,
|
||||
toggleAttached,
|
||||
load,
|
||||
selectPage,
|
||||
addPage,
|
||||
@@ -181,6 +252,7 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
renameSection,
|
||||
reorder,
|
||||
flush,
|
||||
importFiles,
|
||||
downloadPage,
|
||||
downloadAll,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user