feat(knowledge): index note relationships and maintain moved links

This commit is contained in:
eezd committed 2026-10-22 14:52:37 +08:00
1 parent d34d1f2183
commit a923c2dfb9
19 files changed
+545 -79

No files matched your search

+26
View File
@@ -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>
+2
View File
@@ -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>
+11
View File
@@ -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 -1
View File
@@ -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
+17
View File
@@ -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 : '关系索引读取失败'
}
}
+48 -5
View File
@@ -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()
})
}