Add Markdown and ZIP notebook import
This commit is contained in:
1 parent
903a617439
commit
34d2acbbd5
18 files changed
+1166
-16
No files matched your search
@@ -3,6 +3,7 @@
|
||||
const paths = {
|
||||
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||
noteAdd: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6M12 12v6M9 15h6',
|
||||
import: 'M12 3v12m-5-5 5 5 5-5M4 15v5h16v-5',
|
||||
folderAdd: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6',
|
||||
folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
|
||||
note: 'M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9zM14 3v6h6',
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ApiError, type ImportResult } from './api'
|
||||
import Icon from './Icon.vue'
|
||||
import { dirname } from './paths'
|
||||
import { expanded } from './tree'
|
||||
import { importNotes } from './workspace'
|
||||
|
||||
const props = defineProps<{ dir: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const files = ref<File[]>([])
|
||||
const folder = ref('')
|
||||
const names = ref<string[]>([])
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref<ImportResult | null>(null)
|
||||
const zipped = computed(() => files.value.length === 1 && /\.zip$/i.test(files.value[0].name))
|
||||
|
||||
const dialog = ref<HTMLElement>()
|
||||
const validation = computed(() => {
|
||||
if (!zipped.value && files.value.some((f) => !f.name.endsWith('.md'))) return '请选择 .md 文件,或单独选择一个 ZIP'
|
||||
if (files.value.reduce((sum, file) => sum + file.size, 0) > 100_000_000) return '导入文件总量不能超过 100 MB'
|
||||
return ''
|
||||
})
|
||||
onMounted(() => dialog.value?.focus())
|
||||
|
||||
function choose(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
files.value = [...(input.files ?? [])]
|
||||
names.value = files.value.map((f) => f.name)
|
||||
error.value = ''
|
||||
result.value = null
|
||||
if (zipped.value) folder.value = files.value[0].name.replace(/(?:\.md)?\.zip$/i, '')
|
||||
else folder.value = ''
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!busy.value) emit('close')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (busy.value || !files.value.length || validation.value || result.value) return
|
||||
if (zipped.value && !folder.value.trim()) { error.value = '请输入导入文件夹名称'; return }
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// 支持修改普通 Markdown 的目标文件名,以便处理同名冲突。
|
||||
const uploads = zipped.value ? files.value : files.value.map((file, i) => new File([file], names.value[i], { type: file.type }))
|
||||
result.value = await importNotes(props.dir, zipped.value ? folder.value.trim() : '', uploads)
|
||||
for (const p of result.value.paths) {
|
||||
for (let d = zipped.value ? p : dirname(p); d; d = dirname(d)) expanded.add(d)
|
||||
}
|
||||
// 导入文件成功后释放浏览器持有的文件,结果仍保留在弹窗中。
|
||||
files.value = []
|
||||
} catch (err) {
|
||||
error.value = err instanceof ApiError ? err.message : '网络错误,导入结果尚未确认,请检查目标目录后再重试'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rm-dialog-backdrop" @click.self="close" @keydown.esc.stop.prevent="close">
|
||||
<section ref="dialog" class="rm-dialog" tabindex="-1" role="dialog" aria-modal="true" aria-label="导入 Markdown" :aria-busy="busy">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>导入 Markdown</h2>
|
||||
<button class="rm-icon-btn" title="关闭" :disabled="busy" @click="close"><Icon name="close" /></button>
|
||||
</div>
|
||||
<form class="rm-import-body" @submit.prevent="submit">
|
||||
<p class="rm-import-target">导入到:{{ dir || '根目录' }}</p>
|
||||
<template v-if="!result">
|
||||
<label>选择 Markdown 文件或一个 ZIP
|
||||
<input type="file" accept=".md,.zip" multiple :disabled="busy" @change="choose" />
|
||||
</label>
|
||||
<label v-if="zipped">新建文件夹名称
|
||||
<input v-model="folder" :disabled="busy" required @input="error = ''" />
|
||||
</label>
|
||||
<div v-else-if="files.length" class="rm-import-files">
|
||||
<label v-for="(file, i) in files" :key="i">{{ file.name }}
|
||||
<input v-model="names[i]" :disabled="busy" required aria-label="导入文件名称" @input="error = ''" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="rm-import-hint">总上传上限 100 MB。ZIP 保留目录层级,本地图片转为笔记独立附件;不覆盖已有内容。单独导入 Markdown 不包含本地附件。</p>
|
||||
</template>
|
||||
<div v-if="result" role="status">
|
||||
<p>导入完成:{{ result.notes }} 篇笔记,{{ result.assets }} 个附件(含独立副本)。</p>
|
||||
<details v-if="result.warnings.length" class="rm-import-warnings">
|
||||
<summary>{{ result.warnings.length }} 篇笔记有链接提示</summary>
|
||||
<ul><li v-for="warning in result.warnings" :key="warning">{{ warning }}</li></ul>
|
||||
</details>
|
||||
</div>
|
||||
<p v-if="error || validation" class="rm-auth-error" role="alert">{{ error || validation }}</p>
|
||||
<p v-if="busy" role="status">正在上传并整理,请勿关闭页面…</p>
|
||||
<div class="rm-import-actions">
|
||||
<button type="button" class="rm-btn" :disabled="busy" @click="close">{{ result ? '完成' : '取消' }}</button>
|
||||
<button v-if="!result" type="submit" class="rm-btn rm-btn-primary" :disabled="busy || !files.length || !!validation">{{ busy ? '导入中…' : '导入' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rm-import-body { display: flex; flex-direction: column; gap: 16px; padding: 8px 20px 20px; overflow-y: auto; font-size: 14px; }
|
||||
.rm-import-body p { margin: 0; }
|
||||
.rm-import-body label { display: flex; flex-direction: column; gap: 8px; overflow-wrap: anywhere; }
|
||||
.rm-import-body input { min-width: 0; padding: 8px; border: 1px solid var(--rm-divider); border-radius: 6px; background: var(--rm-bg); color: var(--rm-text); font: inherit; }
|
||||
.rm-import-target, .rm-import-hint { color: var(--rm-text-muted); overflow-wrap: anywhere; }
|
||||
.rm-import-hint { font-size: 12px; line-height: 1.6; }
|
||||
.rm-import-files { display: flex; flex-direction: column; gap: 12px; }
|
||||
.rm-import-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.rm-import-warnings { line-height: 1.6; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -39,6 +39,7 @@ function onRootDrop(e: DragEvent) {
|
||||
</button>
|
||||
<button class="rm-icon-btn" title="新建笔记" @click="createNote('')"><Icon name="noteAdd" /></button>
|
||||
<button class="rm-icon-btn" title="新建文件夹" @click="createFolder('')"><Icon name="folderAdd" /></button>
|
||||
<button class="rm-icon-btn" title="导入" @click="ws.importing = ''"><Icon name="import" /></button>
|
||||
</div>
|
||||
<nav v-if="sidebarViews.size" class="rm-plugin-tabs" aria-label="侧栏视图">
|
||||
<button class="rm-btn" :class="{ on: !currentView }" @click="selectedView = ''">文件</button>
|
||||
|
||||
@@ -70,13 +70,14 @@ function commitRename() {
|
||||
if (target && target !== props.node.path) void move(props.node.path, target)
|
||||
}
|
||||
|
||||
function runMenu(action: 'note' | 'folder' | 'rename' | 'move' | 'share' | 'delete') {
|
||||
function runMenu(action: 'note' | 'folder' | 'import' | 'rename' | 'move' | 'share' | 'delete') {
|
||||
menuOpen.value = false
|
||||
const path = props.node.path
|
||||
if (action === 'note' || action === 'folder') {
|
||||
expanded.add(path)
|
||||
void (action === 'note' ? createNote(path) : createFolder(path))
|
||||
} else if (action === 'rename') ws.renaming = path
|
||||
} else if (action === 'import') ws.importing = path
|
||||
else if (action === 'rename') ws.renaming = path
|
||||
else if (action === 'share') ws.sharing = path
|
||||
else if (action === 'move') ws.moving = path
|
||||
else void remove(path)
|
||||
@@ -147,6 +148,7 @@ function onDrop(e: DragEvent) {
|
||||
<template v-if="node.dir">
|
||||
<button @click="runMenu('note')"><Icon name="noteAdd" />新建笔记</button>
|
||||
<button @click="runMenu('folder')"><Icon name="folderAdd" />新建文件夹</button>
|
||||
<button @click="runMenu('import')"><Icon name="import" />导入</button>
|
||||
</template>
|
||||
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
||||
<button @click="runMenu('move')"><Icon name="folder" />移动到…</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import EditorPane from './EditorPane.vue'
|
||||
import Icon from './Icon.vue'
|
||||
import MobileToolbar from './MobileToolbar.vue'
|
||||
import MoveDialog from './MoveDialog.vue'
|
||||
import ImportDialog from './ImportDialog.vue'
|
||||
import NoteBanner from './NoteBanner.vue'
|
||||
import OutlinePanel from './OutlinePanel.vue'
|
||||
import QuickSwitcher from './QuickSwitcher.vue'
|
||||
@@ -120,6 +121,7 @@ onBeforeUnmount(() => {
|
||||
<QuickSwitcher v-if="ws.switcher !== null" />
|
||||
<SettingsDialog v-if="ws.settings" />
|
||||
<ShareDialog v-if="ws.sharing" :path="ws.sharing" @close="ws.sharing = null" />
|
||||
<ImportDialog v-if="ws.importing !== null" :dir="ws.importing" @close="ws.importing = null" />
|
||||
<MoveDialog v-if="ws.moving" :path="ws.moving" @close="ws.moving = null" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,6 +7,13 @@ export interface Entry {
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
paths: string[]
|
||||
notes: number
|
||||
assets: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string
|
||||
name: string
|
||||
@@ -127,6 +134,14 @@ export const api = {
|
||||
// 按顶层键合并,值为 null 的键被删除
|
||||
patchSettings: (patch: Record<string, unknown>) => json<Record<string, unknown>>(request('PATCH', '/api/v1/settings', { json: patch })),
|
||||
|
||||
importNotes: (dir: string, folder: string, files: File[]) => {
|
||||
const body = new FormData()
|
||||
body.append('dir', dir)
|
||||
body.append('folder', folder)
|
||||
for (const file of files) body.append('files', file)
|
||||
return json<ImportResult>(request('POST', '/api/v1/import', { body }))
|
||||
},
|
||||
|
||||
tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries,
|
||||
search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results,
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api, ApiError } from './api'
|
||||
import { drafts, type Draft } from './drafts'
|
||||
import { connectEvents } from './events'
|
||||
import { pluginEvents } from '../plugins/registry'
|
||||
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, move, openNote, refreshTree, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
|
||||
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, importNotes, move, readyToReload, openNote, refreshTree, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
|
||||
|
||||
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
|
||||
|
||||
@@ -248,3 +248,30 @@ describe('保存与关闭', () => {
|
||||
await closeTab('draft-move.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('导入', () => {
|
||||
it('上传期间阻止刷新,成功后刷新目录,并保留真实的成功结果', async () => {
|
||||
ws.tabs = []
|
||||
const result = { paths: ['导入'], notes: 1, assets: 0, warnings: [] }
|
||||
let finish!: (value: typeof result) => void
|
||||
vi.spyOn(api, 'importNotes').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
|
||||
vi.spyOn(api, 'tree').mockResolvedValue([{ path: '导入', dir: true, size: 0, mtime: 0 }])
|
||||
const importing = importNotes('', '导入', [new File(['正文'], 'test.md')])
|
||||
expect(readyToReload()).toBe(false)
|
||||
finish(result)
|
||||
expect(await importing).toEqual(result)
|
||||
expect(ws.entries.some((e) => e.path === '导入')).toBe(true)
|
||||
expect(readyToReload()).toBe(true)
|
||||
vi.mocked(api.importNotes).mockResolvedValueOnce(result)
|
||||
vi.mocked(api.tree).mockRejectedValueOnce(new ApiError(500, '目录读取失败'))
|
||||
expect(await importNotes('', '导入', [])).toEqual(result)
|
||||
expect(ws.toasts.some((t) => t.text.includes('导入已完成,目录刷新失败'))).toBe(true)
|
||||
})
|
||||
|
||||
it('失败保留错误并释放操作占用', async () => {
|
||||
ws.tabs = []
|
||||
vi.spyOn(api, 'importNotes').mockRejectedValue(new ApiError(409, '目标已存在'))
|
||||
await expect(importNotes('', '导入', [])).rejects.toMatchObject({ status: 409, message: '目标已存在' })
|
||||
expect(readyToReload()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -62,6 +62,8 @@ export const ws = reactive({
|
||||
sharing: null as string | null,
|
||||
// 「移动到…」对话框(笔记或文件夹路径)
|
||||
moving: null as string | null,
|
||||
// 导入目标目录,空字符串为根目录
|
||||
importing: null as string | null,
|
||||
// 电脑上侧栏折叠
|
||||
sidebarCollapsed: layout.sidebarCollapsed ?? false,
|
||||
// 大纲面板(电脑上在右侧常驻,手机上为浮层)
|
||||
@@ -664,6 +666,18 @@ async function createNoteFile(path: string): Promise<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function importNotes(dir: string, folder: string, files: File[]) {
|
||||
// refreshTree 必须等 withOp 结束,否则会等待自身释放操作计数。
|
||||
const result = await withOp(() => api.importNotes(dir, folder, files))
|
||||
ws.searchOpen = false
|
||||
try {
|
||||
await refreshTree()
|
||||
} catch (err) {
|
||||
toast('导入已完成,目录刷新失败:' + errorText(err))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function createFolder(dir: string) {
|
||||
const path = uniqueName(dir, '新建文件夹', '')
|
||||
ws.entries.push({ path, dir: true, size: 0, mtime: Date.now() })
|
||||
|
||||
Reference in new issue
Block a user