feat(knowledge): index note relationships and maintain moved links
This commit is contained in:
1 parent
d34d1f2183
commit
a923c2dfb9
19 files changed
+545
-79
No files matched your search
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { metadata } from './metadata'
|
||||
import { openReference, ws } from './workspace'
|
||||
const incoming = computed(() => metadata.notes.flatMap(note => note.links.filter(link => link.target === ws.active).map(link => ({ ...link, path: note.path, title: note.title }))))
|
||||
const note = computed(() => metadata.notes.find(note => note.path === ws.active))
|
||||
</script>
|
||||
<template>
|
||||
<section class="rm-note-relations" aria-label="笔记关联">
|
||||
<div v-if="note?.tags.length" class="rm-note-tags"><span v-for="tag in note.tags" :key="tag">#{{ tag }}</span></div>
|
||||
<h3>反向链接 <small>{{ incoming.length }}</small></h3>
|
||||
<p v-if="metadata.error" role="alert">{{ metadata.error }}</p>
|
||||
<p v-else-if="!metadata.ready">关系索引准备中…</p>
|
||||
<button v-for="link in incoming" :key="link.path + ':' + link.line" @click="openReference(link.path, link.line)"><strong>{{ link.title }}</strong><small>{{ link.path }} · 第 {{ link.line }} 行</small></button>
|
||||
<p v-if="metadata.ready && !incoming.length">暂无其他笔记链接到这里</p>
|
||||
<p v-for="warning in note?.warnings" :key="warning" role="alert">{{ warning }}</p>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.rm-note-relations { padding: 20px 16px; border-top: 1px solid var(--rm-divider); overflow: auto; max-height: 40%; }
|
||||
h3 { font-size: 11px; margin: 0 0 12px; } h3 small { color: var(--rm-text-muted); margin-left: 6px; }
|
||||
p { font-size: 10px; color: var(--rm-text-muted); line-height: 1.8; }
|
||||
button { display: flex; flex-direction: column; gap: 5px; width: 100%; text-align: left; padding: 9px; border: 0; border-radius: 8px; background: none; cursor: pointer; }
|
||||
button:hover { background: var(--rm-hover); } strong { font-size: 11px; font-weight: 500; } small { font-size: 9px; color: var(--rm-text-muted); overflow-wrap: anywhere; }
|
||||
.rm-note-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; } .rm-note-tags span { font-size: 9px; padding: 4px 7px; border-radius: 5px; background: var(--rm-selection); color: var(--rm-accent); }
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import type { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Icon from './Icon.vue'
|
||||
import NoteRelations from './NoteRelations.vue'
|
||||
import { activeTab, editorView, onEditorUpdate, ws } from './workspace'
|
||||
|
||||
// 大纲:当前笔记的标题列表,点击跳转;高亮光标所在的章节
|
||||
@@ -102,6 +103,7 @@ const minLevel = () => Math.min(...headings.value.map((h) => h.level))
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="rm-tree-empty">没有标题</p>
|
||||
<NoteRelations />
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,6 +14,16 @@ export interface ImportResult {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface NoteMetadata {
|
||||
path: string
|
||||
rev: string
|
||||
title: string
|
||||
tags: string[]
|
||||
links: { target: string; line: number }[]
|
||||
assets: { target: string; line: number }[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string
|
||||
name: string
|
||||
@@ -145,6 +155,7 @@ export const api = {
|
||||
|
||||
exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }),
|
||||
|
||||
metadata: () => json<{ notes: NoteMetadata[]; ready: boolean }>(request('GET', '/api/v1/metadata')),
|
||||
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,
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 订阅服务端的仓库变更事件;断线后指数退避重连,每次连接成功时触发全量同步
|
||||
|
||||
export interface VaultEvent {
|
||||
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins'
|
||||
type: 'tree' | 'changed' | 'deleted' | 'moved' | 'settings' | 'plugins' | 'indexed'
|
||||
path?: string
|
||||
from?: string
|
||||
rev?: string
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { reactive } from 'vue'
|
||||
import { api, type NoteMetadata } from './api'
|
||||
|
||||
export const metadata = reactive({ notes: [] as NoteMetadata[], ready: false, error: '' })
|
||||
let request = 0
|
||||
export async function refreshMetadata() {
|
||||
const current = ++request
|
||||
try {
|
||||
const result = await api.metadata()
|
||||
if (current !== request) return
|
||||
metadata.notes = result.notes
|
||||
metadata.ready = result.ready
|
||||
metadata.error = ''
|
||||
} catch (error) {
|
||||
if (current === request) metadata.error = error instanceof Error ? error.message : '关系索引读取失败'
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { createEditorState, insertImageFiles, pasteImages, languageCompartment,
|
||||
import { handleShortcut, registerCommand } from './commands'
|
||||
import { api, ApiError, encodePath, type Entry } from './api'
|
||||
import { drafts } from './drafts'
|
||||
import { refreshMetadata } from './metadata'
|
||||
import { connectEvents, type VaultEvent } from './events'
|
||||
import { basename, dirname, joinPath, isImage, NOTE_EXT, resolveAssetUrl } from './paths'
|
||||
import { loadSettings, settings } from './settings'
|
||||
@@ -201,6 +202,7 @@ function buildState(path: string, doc: string): EditorState {
|
||||
spellcheck: settings.spellcheck,
|
||||
tabSize: settings.tabSize,
|
||||
sourceMode: ws.sourceMode,
|
||||
openLink: (href) => { void openMarkdownLink(ref.path, href) },
|
||||
resolveImage: (src) => {
|
||||
const url = resolveAssetUrl(ref.path, src)
|
||||
const entry = ws.entries.find((e) => url === '/api/v1/raw/' + encodePath(e.path))
|
||||
@@ -743,9 +745,8 @@ export async function move(from: string, to: string): Promise<FileResult> {
|
||||
return operationFailed(`“${basename(to)}”已存在`)
|
||||
}
|
||||
const result = await withOp<FileResult>(async () => {
|
||||
// 图片改名会更新仓库中的引用,先保存已打开笔记,沿用版本冲突保护。
|
||||
const imageMove = isImage(from) && ws.entries.some((e) => e.path === from && !e.dir)
|
||||
const impacted = () => imageMove ? ws.tabs.map((t) => t.path) : affected(from)
|
||||
// 移动会更新仓库中的相对链接,先保存全部已打开笔记,沿用版本冲突保护。
|
||||
const impacted = () => ws.tabs.map((t) => t.path)
|
||||
const busy = () => impacted().some((p) => slots.get(p)?.uploads || slots.get(p)?.paused)
|
||||
if (busy()) {
|
||||
return operationFailed('笔记正在处理粘贴、上传或移动,请完成后再移动')
|
||||
@@ -792,7 +793,7 @@ export async function move(from: string, to: string): Promise<FileResult> {
|
||||
paused.forEach((s) => s.paused--)
|
||||
}
|
||||
// 笔记改名时服务器会改写正文中的图片链接,版本随之变化,需要重新同步
|
||||
await Promise.all((imageMove ? ws.tabs.map((t) => t.path) : affected(to)).map(async (p) => {
|
||||
await Promise.all(ws.tabs.map((t) => t.path).map(async (p) => {
|
||||
const tab = tabOf(p)
|
||||
if (!tab) return
|
||||
if (tab.dirty) {
|
||||
@@ -877,7 +878,8 @@ async function syncOpenTab(tab: Tab) {
|
||||
|
||||
async function handleEvents(evs: VaultEvent[]) {
|
||||
for (const ev of evs) {
|
||||
if (ev.type === 'settings') {
|
||||
if (ev.type === 'indexed') void refreshMetadata()
|
||||
else if (ev.type === 'settings') {
|
||||
void loadSettings().catch((err) => toast('同步设置失败:' + errorText(err)))
|
||||
void refreshPlugins()
|
||||
}
|
||||
@@ -916,6 +918,7 @@ async function handleEvents(evs: VaultEvent[]) {
|
||||
}
|
||||
|
||||
async function resync() {
|
||||
void refreshMetadata()
|
||||
await refreshTree()
|
||||
await Promise.all(ws.tabs.map(syncOpenTab))
|
||||
pluginEvents.dispatchEvent(new CustomEvent('resync'))
|
||||
@@ -1128,3 +1131,43 @@ export function stopWorkspace() {
|
||||
disconnect?.()
|
||||
disconnect = undefined
|
||||
}
|
||||
|
||||
|
||||
async function openMarkdownLink(note: string, href: string) {
|
||||
const decoded = href.replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g, '$1')
|
||||
let url: URL
|
||||
try { url = new URL(decoded, 'https://rawmark.local/' + encodePath(note)) }
|
||||
catch { toast('链接格式无效'); return }
|
||||
if (url.origin !== 'https://rawmark.local') {
|
||||
if (['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol)) window.open(url.href, '_blank', 'noopener')
|
||||
else toast('不支持的链接协议')
|
||||
return
|
||||
}
|
||||
const target = decodeURIComponent(url.pathname.slice(1))
|
||||
if (!ws.entries.some(e => e.path === target && !e.dir)) { toast('链接目标不存在:' + target); return }
|
||||
if (!target.endsWith('.md')) { window.open('/api/v1/raw/' + encodePath(target), '_blank', 'noopener'); return }
|
||||
await openNote(target)
|
||||
if (url.hash) requestAnimationFrame(() => {
|
||||
const state = editorStateOf(target), v = editorView()
|
||||
if (!state || !v || ws.active !== target) return
|
||||
const heading = decodeURIComponent(url.hash.slice(1)).toLocaleLowerCase()
|
||||
for (let i = 1; i <= state.doc.lines; i++) {
|
||||
const line = state.doc.line(i)
|
||||
const title = line.text.replace(/^#{1,6}\s+/, '').trim().toLocaleLowerCase()
|
||||
if (/^#{1,6}\s/.test(line.text) && (title === heading || title.replace(/\s+/g, '-') === heading)) {
|
||||
v.dispatch({ selection: { anchor: line.from }, effects: EditorView.scrollIntoView(line.from, { y: 'start' }) }); break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function openReference(path: string, line: number) {
|
||||
await openNote(path)
|
||||
requestAnimationFrame(() => {
|
||||
const v = editorView()
|
||||
if (!v || ws.active !== path) return
|
||||
const pos = v.state.doc.line(Math.min(line, v.state.doc.lines)).from
|
||||
v.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: 'center' }) })
|
||||
v.focus()
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { renderedBlocks } from './blocks'
|
||||
import { codeBlockHighlight } from './codeblocks'
|
||||
import { editorFocus } from './focus'
|
||||
import { defaultEditorKeys } from './shortcuts'
|
||||
import { livePreview, resolveImageUrl } from './livepreview'
|
||||
import { livePreview, resolveImageUrl, openLink } from './livepreview'
|
||||
import { tableEditing } from './table'
|
||||
import { markdownTheme } from './theme'
|
||||
import { editorExtensions, markdownSyntax, reportEditorFailure } from '../plugins/registry'
|
||||
@@ -40,6 +40,7 @@ export interface EditorStateOptions {
|
||||
sourceMode?: boolean
|
||||
shortcuts?: KeyBinding[]
|
||||
resolveImage?: (src: string) => string
|
||||
openLink?: (href: string) => void
|
||||
// 文档变化回调,仅对挂载在视图上的状态触发;未激活标签页的状态由调用方直接更新
|
||||
onChange?: (update: ViewUpdate) => void
|
||||
extensions?: Extension[]
|
||||
@@ -70,6 +71,7 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
|
||||
]),
|
||||
EditorView.contentAttributes.of({ autocapitalize: 'sentences' }),
|
||||
]
|
||||
if (opts.openLink) extensions.push(openLink.of(opts.openLink))
|
||||
if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage))
|
||||
if (opts.onChange) {
|
||||
const onChange = opts.onChange
|
||||
|
||||
@@ -21,6 +21,8 @@ export const resolveImageUrl = Facet.define<(src: string) => string, (src: strin
|
||||
combine: (values) => values[0] ?? ((src) => src),
|
||||
})
|
||||
|
||||
export const openLink = Facet.define<(href: string) => void, ((href: string) => void) | undefined>({ combine: values => values[0] })
|
||||
|
||||
// 图片文件原位替换时重建预览,不改变正文、选区或撤销历史。
|
||||
export const refreshImages = StateEffect.define<null>()
|
||||
|
||||
@@ -307,7 +309,21 @@ function linkAt(state: EditorState, pos: number): string | null {
|
||||
if (node.name === 'URL') return state.sliceDoc(node.from, node.to).replace(/^<|>$/g, '')
|
||||
if (node.name === 'Link' || node.name === 'Autolink') {
|
||||
const url = node.getChild('URL')
|
||||
return url ? state.sliceDoc(url.from, url.to).replace(/^<|>$/g, '') : null
|
||||
if (url) return state.sliceDoc(url.from, url.to).replace(/^<|>$/g, '')
|
||||
const text = (n: SyntaxNode) => state.sliceDoc(n.from, n.to)
|
||||
const label = node.getChild('LinkLabel')
|
||||
const close = node.getChildren('LinkMark').find(mark => text(mark) === ']')
|
||||
if (!close) return null
|
||||
const normalize = (value: string) => value.replace(/^\[|\]$/g, '').trim().replace(/\s+/g, ' ').toUpperCase()
|
||||
const key = normalize(label && text(label) !== '[]' ? text(label) : state.sliceDoc(node.from, close.to))
|
||||
let destination: string | null = null
|
||||
syntaxTree(state).iterate({ enter(ref) {
|
||||
if (ref.name !== 'LinkReference') return
|
||||
const refLabel = ref.node.getChild('LinkLabel'), refUrl = ref.node.getChild('URL')
|
||||
if (destination === null && refLabel && refUrl && normalize(text(refLabel)) === key) destination = text(refUrl).replace(/^<|>$/g, '')
|
||||
return false
|
||||
} })
|
||||
return destination
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -352,7 +368,9 @@ const livePreviewPlugin = ViewPlugin.fromClass(
|
||||
const href = pos === null ? null : linkAt(view.state, pos)
|
||||
if (!href) return false
|
||||
e.preventDefault()
|
||||
window.open(href, '_blank', 'noopener')
|
||||
const handler = view.state.facet(openLink)
|
||||
if (handler) handler(href)
|
||||
else if (/^(https?:|mailto:|tel:)/i.test(href)) window.open(href, '_blank', 'noopener')
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user