Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,7 @@ def create_menu(self):
edit_menu.add_command(label="查找", command=self.show_search, accelerator="Ctrl+F")
edit_menu.add_command(label="替换", command=self.show_replace, accelerator="Ctrl+H")

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

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

self._bind_shortcuts()

def _build_view_menu(self, menubar):
view_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="视图", menu=view_menu)
view_menu.add_command(label="预览模式", command=self.toggle_preview)
view_menu.add_command(label="编辑/分栏/预览切换", command=self.cycle_view_mode)
view_menu.add_command(label="笔记任务清单", command=self.show_note_tasks)
view_menu.add_command(label="反向链接", command=self.show_backlinks)
view_menu.add_command(label="历史版本", command=self.show_history)
view_menu.add_command(label="全屏", accelerator="F11")

def _bind_shortcuts(self):
self.root.bind('<Control-n>', lambda e: self.create_note())
self.root.bind('<Control-s>', lambda e: self.save_current_note())
Expand Down Expand Up @@ -568,6 +572,8 @@ def _persist_current_note(self):

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

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

def show_backlinks(self):
if not self.current_note:
messagebox.showwarning("反向链接", "请先选择一篇笔记")
return
backlinks = self.note_manager.get_backlinks(self.current_note.id)
win = tk.Toplevel(self.root)
win.title(f"反向链接 - {self.current_note.title}")
win.geometry("360x320")
if not backlinks:
tk.Label(win, text="没有其它笔记链接到本笔记").pack(padx=12, pady=12)
return
listbox = tk.Listbox(win)
for n in backlinks:
listbox.insert(tk.END, n.title)
listbox.pack(fill='both', expand=True, padx=8, pady=8)
ids = [n.id for n in backlinks]

def open_sel(_e=None):
sel = listbox.curselection()
if sel:
self.load_note(ids[sel[0]])
listbox.bind('<Double-Button-1>', open_sel)

def show_note_tasks(self):
if not self.current_note:
messagebox.showwarning("任务清单", "请先选择一篇笔记")
Expand Down
6 changes: 6 additions & 0 deletions markdown_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,12 @@ def convert_to_plain_text(self, text: str) -> str:
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()

_wikilink_re = re.compile(r'\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]')

def extract_wikilinks(self, text: str) -> List[str]:
"""Return the target titles from [[title]] / [[title|alias]] links."""
return [m.group(1).strip() for m in self._wikilink_re.finditer(text)]

def make_snippet(self, text: str, query: str, context: int = 40):
"""Return (snippet, hit_spans) around the first match of query.

Expand Down
28 changes: 28 additions & 0 deletions note_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,34 @@ def get_linked_notes(self, note_id: str) -> List[Note]:
def get_backlinks(self, note_id: str) -> List[Note]:
return [note for note in self.notes.values() if note_id in note.links]

def resolve_title(self, title: str) -> Optional[str]:
"""Return the id of the (first) note with this title, or None."""
t = title.strip().lower()
for note in self.notes.values():
if note.title.lower() == t:
return note.id
return None

def sync_wikilinks(self, note_id: str) -> List[str]:
"""Parse [[title]] wikilinks in a note's content and set note.links to
the resolved target ids (de-duplicated, excluding self). Returns the
list of unresolved titles."""
from markdown_parser import MarkdownParser # noqa: PLC0415
note = self.get_note(note_id)
if not note:
return []
parser = MarkdownParser()
resolved, unresolved = [], []
for title in parser.extract_wikilinks(note.content):
target = self.resolve_title(title)
if target and target != note_id and target not in resolved:
resolved.append(target)
elif not target:
unresolved.append(title)
note.links = resolved
self.save_notes()
return unresolved

def get_all_tags(self) -> List[str]:
tags = set()
for note in self.get_all_notes():
Expand Down
35 changes: 35 additions & 0 deletions tests/test_wikilink.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Wikilink parsing + resolution to bidirectional note links + backlinks."""
from markdown_parser import MarkdownParser
from note_model import NoteManager


def test_extract_wikilinks():
mp = MarkdownParser()
links = mp.extract_wikilinks("see [[Alpha]] and [[Beta|the beta note]] end")
assert links == ["Alpha", "Beta"]


def test_sync_resolves_links_and_backlinks(tmp_path):
nm = NoteManager(tmp_path / "n.db", tmp_path)
target = nm.create_note("Target Note", "content")
src = nm.create_note("Source", "refer to [[Target Note]] here")
unresolved = nm.sync_wikilinks(src.id)
assert unresolved == []
assert target.id in nm.get_note(src.id).links
# backlink visible from the target
assert src.id in [n.id for n in nm.get_backlinks(target.id)]


def test_unresolved_titles_reported(tmp_path):
nm = NoteManager(tmp_path / "n.db", tmp_path)
src = nm.create_note("S", "link to [[Nonexistent]]")
unresolved = nm.sync_wikilinks(src.id)
assert unresolved == ["Nonexistent"]
assert nm.get_note(src.id).links == []


def test_no_self_link(tmp_path):
nm = NoteManager(tmp_path / "n.db", tmp_path)
s = nm.create_note("Self", "I reference [[Self]]")
nm.sync_wikilinks(s.id)
assert s.id not in nm.get_note(s.id).links
Loading