fix: deduplicate inspector panels (Relations, Backlinks, Referenced By) (#82)
* fix: deduplicate inspector panels by excluding frontmatter from outgoing links The extract_outgoing_links function was scanning the entire file content including YAML frontmatter, causing wikilinks in frontmatter fields (e.g. belongsTo, relatedTo) to appear in both the relationships map AND outgoingLinks. This made the same note show up in both "Referenced By" and "Backlinks" panels. Backend: pass gray_matter's parsed body (no frontmatter) to extract_outgoing_links instead of raw file content. Frontend: filter useBacklinks to exclude entries already shown in Referenced By, ensuring strictly non-overlapping panels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: retrigger after disk space cleanup [skip precommit] --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ pub struct VaultEntry {
|
||||
pub color: Option<String>,
|
||||
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
|
||||
pub order: Option<i64>,
|
||||
/// All wikilink targets found in the note content (body + frontmatter).
|
||||
/// All wikilink targets found in the note body (excludes frontmatter).
|
||||
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
|
||||
#[serde(rename = "outgoingLinks", default)]
|
||||
pub outgoing_links: Vec<String>,
|
||||
@@ -268,7 +268,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
|
||||
let title = extract_title(&parsed.content, &filename);
|
||||
let snippet = extract_snippet(&content);
|
||||
let outgoing_links = extract_outgoing_links(&content);
|
||||
let outgoing_links = extract_outgoing_links(&parsed.content);
|
||||
let (modified_at, file_size) = read_file_metadata(path)?;
|
||||
let created_at = parse_created_at(&frontmatter);
|
||||
let is_a = resolve_is_a(frontmatter.is_a, path);
|
||||
@@ -951,11 +951,11 @@ References:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_outgoing_links_includes_frontmatter_wikilinks() {
|
||||
fn test_outgoing_links_excludes_frontmatter_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nHas:\n - \"[[task/design]]\"\n---\n# Note\n\nSee [[person/bob]].";
|
||||
let entry = parse_test_entry(&dir, "note/test.md", content);
|
||||
assert!(entry.outgoing_links.contains(&"task/design".to_string()));
|
||||
assert!(!entry.outgoing_links.contains(&"task/design".to_string()));
|
||||
assert!(entry.outgoing_links.contains(&"person/bob".to_string()));
|
||||
}
|
||||
|
||||
|
||||
@@ -575,6 +575,47 @@ Status: Active
|
||||
expect(screen.getByText(/via Topics/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes entries from backlinks when already shown in referenced-by', () => {
|
||||
const noteA: VaultEntry = {
|
||||
path: '/Users/luca/Laputa/essay/on-writing.md',
|
||||
filename: 'on-writing.md',
|
||||
title: 'On Writing Well',
|
||||
isA: 'Essay',
|
||||
aliases: [],
|
||||
belongsTo: ['[[responsibility/grow-newsletter]]'],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
snippet: '',
|
||||
relationships: { 'Belongs to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/essay]]'] },
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
// Body text also links to grow-newsletter
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
}
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={targetEntry}
|
||||
content={targetContent}
|
||||
entries={[targetEntry, noteA]}
|
||||
/>
|
||||
)
|
||||
// noteA shows in Referenced By (via Belongs to)
|
||||
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
|
||||
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
|
||||
// But NOT in Backlinks (even though outgoingLinks matches)
|
||||
expect(screen.getByText('No backlinks')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show self-references', () => {
|
||||
const selfRef: VaultEntry = {
|
||||
...targetEntry,
|
||||
|
||||
@@ -25,7 +25,7 @@ interface InspectorProps {
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
}
|
||||
|
||||
function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[]): VaultEntry[] {
|
||||
function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], referencedBy: ReferencedByItem[]): VaultEntry[] {
|
||||
return useMemo(() => {
|
||||
if (!entry) return []
|
||||
const matchTargets = new Set([
|
||||
@@ -34,13 +34,16 @@ function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[]): VaultEnt
|
||||
entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, ''),
|
||||
])
|
||||
|
||||
const referencedByPaths = new Set(referencedBy.map((item) => item.entry.path))
|
||||
|
||||
return entries.filter((e) => {
|
||||
if (e.path === entry.path) return false
|
||||
if (referencedByPaths.has(e.path)) return false
|
||||
return e.outgoingLinks.some((target) =>
|
||||
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
|
||||
)
|
||||
})
|
||||
}, [entry, entries])
|
||||
}, [entry, entries, referencedBy])
|
||||
}
|
||||
|
||||
function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): ReferencedByItem[] {
|
||||
@@ -118,8 +121,8 @@ export function Inspector({
|
||||
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
}: InspectorProps) {
|
||||
const backlinks = useBacklinks(entry, entries)
|
||||
const referencedBy = useReferencedBy(entry, entries)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy)
|
||||
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
|
||||
const typeEntryMap = useMemo(() => {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
|
||||
Reference in New Issue
Block a user