Support live recursive folder shares and follow subject moves
This commit is contained in:
1 parent
89472fe6fd
commit
84e89c8fe4
17 files changed
+620
-127
No files matched your search
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Icon from './Icon.vue'
|
||||
import { noteTitle } from './paths'
|
||||
import { basename } from './paths'
|
||||
import SharePanel from './SharePanel.vue'
|
||||
|
||||
defineProps<{ path: string }>()
|
||||
@@ -9,9 +9,9 @@ const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
<template>
|
||||
<div class="rm-dialog-backdrop" @click.self="emit('close')">
|
||||
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${noteTitle(path)}”`">
|
||||
<div class="rm-dialog rm-share-dialog" role="dialog" :aria-label="`分享“${basename(path)}”`">
|
||||
<div class="rm-dialog-head">
|
||||
<h2>分享“{{ noteTitle(path) }}”</h2>
|
||||
<h2>分享“{{ basename(path) }}”</h2>
|
||||
<button class="rm-icon-btn" title="关闭" @click="emit('close')"><Icon name="close" /></button>
|
||||
</div>
|
||||
<SharePanel :path="path" />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api, ApiError, type Share } from './api'
|
||||
import Icon from './Icon.vue'
|
||||
import { toast } from './workspace'
|
||||
|
||||
// path 为空时列出全部分享(管理列表),否则管理这一篇笔记的分享
|
||||
// path 为空时列出全部分享,否则管理此文件或文件夹的分享。
|
||||
const props = defineProps<{ path: string | null }>()
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
@@ -77,21 +77,21 @@ onMounted(load)
|
||||
</select>
|
||||
<button class="rm-btn rm-btn-primary" type="submit" :disabled="busy">创建链接</button>
|
||||
</form>
|
||||
<p class="rm-share-note">分享页只读,显示笔记的当前内容及其 .assets 中的图片;拿到链接的人都能访问。</p>
|
||||
<p class="rm-share-note">分享只读并显示最新内容。分享文件夹会公开其中所有文件和子目录,包括以后新增或移入的内容;移出的内容不再公开。分享主体移动后链接保持有效。</p>
|
||||
<p v-if="failed" class="rm-tree-empty">读取分享失败</p>
|
||||
<p v-else-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何笔记' }}</p>
|
||||
<p v-else-if="shares && !shares.length" class="rm-tree-empty">{{ path ? '还没有分享链接' : '还没有分享任何文件或文件夹' }}</p>
|
||||
<ul v-else class="rm-trash-list">
|
||||
<li v-for="s in shares ?? []" :key="s.token">
|
||||
<Icon name="link" />
|
||||
<div class="rm-trash-info">
|
||||
<span v-if="!path" class="rm-trash-path">{{ s.path.replace(/\.md$/, '') }}</span>
|
||||
<span v-if="!path" class="rm-trash-path">{{ s.path }}</span>
|
||||
<span class="rm-share-link" :class="{ 'rm-trash-path': path }">{{ linkOf(s) }}</span>
|
||||
<span class="rm-trash-time">
|
||||
{{ fmt.format(s.createdAt) }} 创建
|
||||
<template v-if="s.hasPassword"> · 有密码</template>
|
||||
<template v-if="expired(s)"> · <span class="rm-share-bad">已过期</span></template>
|
||||
<template v-else-if="s.expiresAt"> · {{ fmt.format(s.expiresAt) }} 到期</template>
|
||||
<template v-if="!s.exists"> · <span class="rm-share-bad">笔记不存在</span></template>
|
||||
<template v-if="!s.exists"> · <span class="rm-share-bad">文件或文件夹不存在</span></template>
|
||||
</span>
|
||||
</div>
|
||||
<button class="rm-btn" @click="copy(s)">复制链接</button>
|
||||
|
||||
@@ -150,7 +150,7 @@ function onDrop(e: DragEvent) {
|
||||
</template>
|
||||
<button @click="runMenu('rename')"><Icon name="edit" />重命名</button>
|
||||
<button @click="runMenu('move')"><Icon name="folder" />移动到…</button>
|
||||
<button v-if="!node.dir" @click="runMenu('share')"><Icon name="link" />分享</button>
|
||||
<button @click="runMenu('share')"><Icon name="link" />分享</button>
|
||||
<button class="danger" @click="runMenu('delete')"><Icon name="trash" />删除</button>
|
||||
</div>
|
||||
<div v-if="menuOpen" class="rm-menu-backdrop" @click.stop="menuOpen = false" @contextmenu.prevent.stop="menuOpen = false" />
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export interface Share {
|
||||
// 0 为永久
|
||||
expiresAt: number
|
||||
hasPassword: boolean
|
||||
// 笔记是否仍存在
|
||||
// 分享的文件或文件夹是否仍存在
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,28 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('事件同步', () => {
|
||||
it('目录移动映射整个已打开的子树,并把未保存输入写入新路径', async () => {
|
||||
const tree = vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'from/note.md', dir: false, size: 0, mtime: 0 }])
|
||||
vi.spyOn(api, 'settings').mockResolvedValue({})
|
||||
vi.spyOn(api, 'plugins').mockResolvedValue([])
|
||||
vi.spyOn(drafts, 'all').mockResolvedValue([])
|
||||
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
|
||||
const saved = new Map<string, string>()
|
||||
vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { saved.set(path, content); return 'r2' })
|
||||
await startWorkspace()
|
||||
const v = await edit('from/note.md')
|
||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: '尚未保存的输入' } })
|
||||
tree.mockResolvedValue([{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }])
|
||||
vi.mocked(connectEvents).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }, { type: 'tree' }])
|
||||
await vi.waitFor(() => expect(ws.active).toBe('to/note.md'))
|
||||
expect(editorStateOf('from/note.md')).toBeUndefined()
|
||||
expect(editorStateOf('to/note.md')!.doc.toString()).toBe('尚未保存的输入')
|
||||
await flush('to/note.md')
|
||||
expect(saved.get('to/note.md')).toBe('尚未保存的输入')
|
||||
expect(saved.has('from/note.md')).toBe(false)
|
||||
await closeTab('to/note.md')
|
||||
})
|
||||
|
||||
it('正文同步后才通知插件,同步失败时不广播成功且不阻塞后续批次', async () => {
|
||||
const entries = [{ path: 'sync.md', dir: false, size: 0, mtime: 0 }]
|
||||
vi.spyOn(api, 'tree').mockResolvedValue(entries)
|
||||
|
||||
@@ -783,7 +783,7 @@ async function handleEvents(evs: VaultEvent[]) {
|
||||
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
|
||||
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
|
||||
} else if (ev.type === 'moved') {
|
||||
if (tabOf(ev.from!) && !tabOf(ev.path!)) remapOpenNotes(ev.from!, ev.path!)
|
||||
if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!)
|
||||
remapRecent(ev.from!, ev.path!)
|
||||
} else if (ev.type === 'deleted') {
|
||||
const tab = tabOf(ev.path!)
|
||||
|
||||
+88
-7
@@ -8,13 +8,29 @@ import './share.css'
|
||||
|
||||
interface SharedNote {
|
||||
title: string
|
||||
dir: false
|
||||
content: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface SharedDirectory {
|
||||
title: string
|
||||
dir: true
|
||||
entries: { path: string; dir: boolean }[]
|
||||
}
|
||||
|
||||
interface SharedFile {
|
||||
title: string
|
||||
dir: false
|
||||
}
|
||||
|
||||
const root = document.getElementById('share')!
|
||||
const token = decodeURIComponent(location.pathname.split('/')[2] ?? '')
|
||||
const api = '/api/v1/public/shares/' + encodeURIComponent(token)
|
||||
const selectedPath = new URL(location.href).searchParams.get('path') ?? ''
|
||||
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
|
||||
const pageUrl = (path: string) => location.pathname + (path ? '?path=' + encodeURIComponent(path) : '')
|
||||
const downloadUrl = () => api + '/assets/' + encodePath(selectedPath) + '?download=1'
|
||||
|
||||
function message(text: string) {
|
||||
root.replaceChildren(Object.assign(document.createElement('p'), { className: 'rm-share-message', textContent: text }))
|
||||
@@ -23,13 +39,13 @@ function message(text: string) {
|
||||
async function load() {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(api, { credentials: 'same-origin', cache: 'no-store' })
|
||||
res = await fetch(api + (selectedPath ? '?path=' + encodeURIComponent(selectedPath) : ''), { credentials: 'same-origin', cache: 'no-store' })
|
||||
} catch {
|
||||
message('网络错误,请检查连接后刷新')
|
||||
return
|
||||
}
|
||||
if (res.ok) {
|
||||
const note = (await res.json()) as SharedNote
|
||||
const content = (await res.json()) as SharedNote | SharedDirectory | SharedFile
|
||||
let appearanceError = false
|
||||
try {
|
||||
const response = await fetch(api + '/appearance', { cache: 'no-store' })
|
||||
@@ -42,11 +58,20 @@ async function load() {
|
||||
} catch {
|
||||
appearanceError = true
|
||||
}
|
||||
render(note)
|
||||
if (content.dir) renderDirectory(content)
|
||||
else if ('content' in content) render(content)
|
||||
else {
|
||||
document.title = content.title
|
||||
const link = Object.assign(document.createElement('a'), { href: downloadUrl(), download: content.title, textContent: '下载 ' + content.title })
|
||||
root.replaceChildren(navigation(), link)
|
||||
}
|
||||
if (appearanceError) root.prepend(Object.assign(document.createElement('p'), { className: 'rm-share-error', textContent: '外观加载失败,可刷新重试' }))
|
||||
}
|
||||
else if (res.status === 401) askPassword()
|
||||
else if (res.status === 404) message('分享不存在、已过期或已被撤销')
|
||||
else if (res.status === 404) {
|
||||
message(selectedPath ? '此文件或文件夹已不存在,或不在分享范围内' : '分享不存在、已过期或已被撤销')
|
||||
if (selectedPath) root.prepend(navigation())
|
||||
}
|
||||
else message(`加载失败(${res.status})`)
|
||||
}
|
||||
|
||||
@@ -83,6 +108,34 @@ function askPassword(error = '') {
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
function navigation() {
|
||||
const nav = document.createElement('nav')
|
||||
nav.className = 'rm-share-nav'
|
||||
nav.setAttribute('aria-label', '分享导航')
|
||||
nav.append(Object.assign(document.createElement('a'), { href: pageUrl(''), textContent: '分享首页' }))
|
||||
const parts = selectedPath.split('/').filter(Boolean)
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
nav.append(' / ', Object.assign(document.createElement('a'), { href: pageUrl(parts.slice(0, i + 1).join('/')), textContent: parts[i] }))
|
||||
}
|
||||
return nav
|
||||
}
|
||||
|
||||
function renderDirectory(directory: SharedDirectory) {
|
||||
document.title = directory.title
|
||||
const heading = Object.assign(document.createElement('h1'), { textContent: directory.title })
|
||||
const list = document.createElement('ul')
|
||||
list.className = 'rm-share-files'
|
||||
for (const entry of directory.entries) {
|
||||
const li = document.createElement('li')
|
||||
li.style.paddingLeft = (entry.path.split('/').length - 1) * 16 + 'px'
|
||||
const path = selectedPath ? selectedPath + '/' + entry.path : entry.path
|
||||
li.append(Object.assign(document.createElement('a'), { href: pageUrl(path), textContent: entry.path.split('/').at(-1) + (entry.dir ? '/' : '') }))
|
||||
list.append(li)
|
||||
}
|
||||
root.replaceChildren(navigation(), heading, list)
|
||||
if (!directory.entries.length) root.append(Object.assign(document.createElement('p'), { textContent: '此文件夹为空' }))
|
||||
}
|
||||
|
||||
function render(note: SharedNote) {
|
||||
document.title = note.title
|
||||
const header = document.createElement('header')
|
||||
@@ -95,9 +148,10 @@ function render(note: SharedNote) {
|
||||
article.className = 'rm-share-content'
|
||||
// 安全模式:原始 HTML 被转义,危险协议的链接被过滤
|
||||
article.innerHTML = renderMarkdown(note.content)
|
||||
root.replaceChildren(header, article)
|
||||
root.replaceChildren(...(selectedPath ? [navigation()] : []), header, article)
|
||||
|
||||
resolveImages(article)
|
||||
resolveLinks(article)
|
||||
buildToc(article)
|
||||
hydrate(article)
|
||||
for (const code of article.querySelectorAll<HTMLElement>('pre > code')) void decorateCode(code).catch((error) => {
|
||||
@@ -106,17 +160,44 @@ function render(note: SharedNote) {
|
||||
})
|
||||
}
|
||||
|
||||
// 相对地址的图片(笔记的 .assets)经分享接口读取;服务器只允许读取本篇的图片目录
|
||||
// 相对引用始终经当前分享读取,范围由服务器按分享主体校验。
|
||||
function resourceUrl(src: string): URL | null {
|
||||
try {
|
||||
const prefix = new URL(api + '/assets/', location.origin)
|
||||
const target = new URL(src, new URL(encodePath(selectedPath), prefix))
|
||||
return target.origin === prefix.origin && target.pathname.startsWith(prefix.pathname) ? target : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function resolveImages(article: HTMLElement) {
|
||||
for (const img of article.querySelectorAll('img')) {
|
||||
const src = img.getAttribute('src') ?? ''
|
||||
if (src && !/^[a-z][a-z\d+.-]*:/i.test(src) && !src.startsWith('/') && !src.startsWith('#')) {
|
||||
img.src = api + '/assets/' + src.replace(/^(\.\/)+/, '')
|
||||
img.src = resourceUrl(src)?.href ?? '#'
|
||||
}
|
||||
img.loading = 'lazy'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLinks(article: HTMLElement) {
|
||||
for (const link of article.querySelectorAll<HTMLAnchorElement>('a[href]')) {
|
||||
const href = link.getAttribute('href')!
|
||||
if (!href || /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith('/') || href.startsWith('#')) continue
|
||||
const target = resourceUrl(href)
|
||||
if (!target) { link.href = '#'; continue }
|
||||
if (selectedPath) {
|
||||
try {
|
||||
const path = decodeURIComponent(target.pathname.slice((api + '/assets/').length)).replace(/\/$/, '')
|
||||
link.href = pageUrl(path) + target.hash
|
||||
} catch { link.href = '#' }
|
||||
} else {
|
||||
target.searchParams.set('download', '1')
|
||||
link.href = target.href
|
||||
link.download = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildToc(article: HTMLElement) {
|
||||
const headings = [...article.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')]
|
||||
const used = new Set<string>()
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
}
|
||||
|
||||
.rm-share-message { margin-top: 30vh; text-align: center; color: var(--rm-text-muted); }
|
||||
.rm-share-nav { margin-bottom: 16px; color: var(--rm-text-muted); }
|
||||
.rm-share-nav a, .rm-share-files a { color: var(--rm-accent-strong); }
|
||||
.rm-share-files { list-style: none; padding: 0; }
|
||||
.rm-share-files li { margin: 8px 0; }
|
||||
|
||||
.rm-share-password { display: flex; flex-direction: column; gap: 12px; max-width: 300px; margin: 25vh auto 0; }
|
||||
.rm-share-password p { margin: 0; }
|
||||
|
||||
Reference in new issue
Block a user