Skip to content

Commit 84fad1c

Browse files
authored
Merge pull request #50 from burqt/pr49-wikilinks
feat(graph): 解析 [[wikilink]] 双向链接并新增反链面板与图谱联动
2 parents 38fd1de + fdb34d1 commit 84fad1c

4 files changed

Lines changed: 105 additions & 7 deletions

File tree

gui.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,7 @@ def create_menu(self):
157157
edit_menu.add_command(label="查找", command=self.show_search, accelerator="Ctrl+F")
158158
edit_menu.add_command(label="替换", command=self.show_replace, accelerator="Ctrl+H")
159159

160-
view_menu = tk.Menu(menubar, tearoff=0)
161-
menubar.add_cascade(label="视图", menu=view_menu)
162-
view_menu.add_command(label="预览模式", command=self.toggle_preview)
163-
view_menu.add_command(label="编辑/分栏/预览切换", command=self.cycle_view_mode)
164-
view_menu.add_command(label="笔记任务清单", command=self.show_note_tasks)
165-
view_menu.add_command(label="历史版本", command=self.show_history)
166-
view_menu.add_command(label="全屏", accelerator="F11")
160+
self._build_view_menu(menubar)
167161

168162
tools_menu = tk.Menu(menubar, tearoff=0)
169163
menubar.add_cascade(label="工具", menu=tools_menu)
@@ -189,6 +183,16 @@ def create_menu(self):
189183

190184
self._bind_shortcuts()
191185

186+
def _build_view_menu(self, menubar):
187+
view_menu = tk.Menu(menubar, tearoff=0)
188+
menubar.add_cascade(label="视图", menu=view_menu)
189+
view_menu.add_command(label="预览模式", command=self.toggle_preview)
190+
view_menu.add_command(label="编辑/分栏/预览切换", command=self.cycle_view_mode)
191+
view_menu.add_command(label="笔记任务清单", command=self.show_note_tasks)
192+
view_menu.add_command(label="反向链接", command=self.show_backlinks)
193+
view_menu.add_command(label="历史版本", command=self.show_history)
194+
view_menu.add_command(label="全屏", accelerator="F11")
195+
192196
def _bind_shortcuts(self):
193197
self.root.bind('<Control-n>', lambda e: self.create_note())
194198
self.root.bind('<Control-s>', lambda e: self.save_current_note())
@@ -568,6 +572,8 @@ def _persist_current_note(self):
568572

569573
# Incremental, deferred-flush update instead of a full remove+add+rewrite.
570574
self.search_engine.update_document(self.current_note.id, self.current_note)
575+
# Resolve [[wikilinks]] to real note links on save.
576+
self.note_manager.sync_wikilinks(self.current_note.id)
571577

572578
self.is_modified = False
573579
self.load_notes_list()
@@ -878,6 +884,29 @@ def save():
878884
tk.Button(d, text="保存", command=save).grid(row=4, column=0, columnspan=2, pady=10)
879885
title_e.focus()
880886

887+
def show_backlinks(self):
888+
if not self.current_note:
889+
messagebox.showwarning("反向链接", "请先选择一篇笔记")
890+
return
891+
backlinks = self.note_manager.get_backlinks(self.current_note.id)
892+
win = tk.Toplevel(self.root)
893+
win.title(f"反向链接 - {self.current_note.title}")
894+
win.geometry("360x320")
895+
if not backlinks:
896+
tk.Label(win, text="没有其它笔记链接到本笔记").pack(padx=12, pady=12)
897+
return
898+
listbox = tk.Listbox(win)
899+
for n in backlinks:
900+
listbox.insert(tk.END, n.title)
901+
listbox.pack(fill='both', expand=True, padx=8, pady=8)
902+
ids = [n.id for n in backlinks]
903+
904+
def open_sel(_e=None):
905+
sel = listbox.curselection()
906+
if sel:
907+
self.load_note(ids[sel[0]])
908+
listbox.bind('<Double-Button-1>', open_sel)
909+
881910
def show_note_tasks(self):
882911
if not self.current_note:
883912
messagebox.showwarning("任务清单", "请先选择一篇笔记")

markdown_parser.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,12 @@ def convert_to_plain_text(self, text: str) -> str:
295295
text = re.sub(r'\n{3,}', '\n\n', text)
296296
return text.strip()
297297

298+
_wikilink_re = re.compile(r'\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]')
299+
300+
def extract_wikilinks(self, text: str) -> List[str]:
301+
"""Return the target titles from [[title]] / [[title|alias]] links."""
302+
return [m.group(1).strip() for m in self._wikilink_re.finditer(text)]
303+
298304
def make_snippet(self, text: str, query: str, context: int = 40):
299305
"""Return (snippet, hit_spans) around the first match of query.
300306

note_model.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,34 @@ def get_linked_notes(self, note_id: str) -> List[Note]:
266266
def get_backlinks(self, note_id: str) -> List[Note]:
267267
return [note for note in self.notes.values() if note_id in note.links]
268268

269+
def resolve_title(self, title: str) -> Optional[str]:
270+
"""Return the id of the (first) note with this title, or None."""
271+
t = title.strip().lower()
272+
for note in self.notes.values():
273+
if note.title.lower() == t:
274+
return note.id
275+
return None
276+
277+
def sync_wikilinks(self, note_id: str) -> List[str]:
278+
"""Parse [[title]] wikilinks in a note's content and set note.links to
279+
the resolved target ids (de-duplicated, excluding self). Returns the
280+
list of unresolved titles."""
281+
from markdown_parser import MarkdownParser # noqa: PLC0415
282+
note = self.get_note(note_id)
283+
if not note:
284+
return []
285+
parser = MarkdownParser()
286+
resolved, unresolved = [], []
287+
for title in parser.extract_wikilinks(note.content):
288+
target = self.resolve_title(title)
289+
if target and target != note_id and target not in resolved:
290+
resolved.append(target)
291+
elif not target:
292+
unresolved.append(title)
293+
note.links = resolved
294+
self.save_notes()
295+
return unresolved
296+
269297
def get_all_tags(self) -> List[str]:
270298
tags = set()
271299
for note in self.get_all_notes():

tests/test_wikilink.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Wikilink parsing + resolution to bidirectional note links + backlinks."""
2+
from markdown_parser import MarkdownParser
3+
from note_model import NoteManager
4+
5+
6+
def test_extract_wikilinks():
7+
mp = MarkdownParser()
8+
links = mp.extract_wikilinks("see [[Alpha]] and [[Beta|the beta note]] end")
9+
assert links == ["Alpha", "Beta"]
10+
11+
12+
def test_sync_resolves_links_and_backlinks(tmp_path):
13+
nm = NoteManager(tmp_path / "n.db", tmp_path)
14+
target = nm.create_note("Target Note", "content")
15+
src = nm.create_note("Source", "refer to [[Target Note]] here")
16+
unresolved = nm.sync_wikilinks(src.id)
17+
assert unresolved == []
18+
assert target.id in nm.get_note(src.id).links
19+
# backlink visible from the target
20+
assert src.id in [n.id for n in nm.get_backlinks(target.id)]
21+
22+
23+
def test_unresolved_titles_reported(tmp_path):
24+
nm = NoteManager(tmp_path / "n.db", tmp_path)
25+
src = nm.create_note("S", "link to [[Nonexistent]]")
26+
unresolved = nm.sync_wikilinks(src.id)
27+
assert unresolved == ["Nonexistent"]
28+
assert nm.get_note(src.id).links == []
29+
30+
31+
def test_no_self_link(tmp_path):
32+
nm = NoteManager(tmp_path / "n.db", tmp_path)
33+
s = nm.create_note("Self", "I reference [[Self]]")
34+
nm.sync_wikilinks(s.id)
35+
assert s.id not in nm.get_note(s.id).links

0 commit comments

Comments
 (0)