diff --git a/gui.py b/gui.py index 1d301cf..049d4e0 100644 --- a/gui.py +++ b/gui.py @@ -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) @@ -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('', lambda e: self.create_note()) self.root.bind('', lambda e: self.save_current_note()) @@ -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() @@ -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('', open_sel) + def show_note_tasks(self): if not self.current_note: messagebox.showwarning("任务清单", "请先选择一篇笔记") diff --git a/markdown_parser.py b/markdown_parser.py index 208265b..40526ad 100644 --- a/markdown_parser.py +++ b/markdown_parser.py @@ -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. diff --git a/note_model.py b/note_model.py index 86a86da..ed34448 100644 --- a/note_model.py +++ b/note_model.py @@ -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(): diff --git a/tests/test_wikilink.py b/tests/test_wikilink.py new file mode 100644 index 0000000..82f0b61 --- /dev/null +++ b/tests/test_wikilink.py @@ -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