diff --git a/design/relazioni-bidirezionali.pen b/design/relazioni-bidirezionali.pen new file mode 100644 index 00000000..f1df35ad --- /dev/null +++ b/design/relazioni-bidirezionali.pen @@ -0,0 +1,775 @@ +{ + "children": [ + { + "type": "frame", + "id": "rbF01", + "x": 0, + "y": 0, + "name": "Inspector — Referenced By (Multiple)", + "theme": { "Mode": "Light" }, + "clip": true, + "width": 320, + "height": 700, + "fill": "$--background", + "layout": "vertical", + "gap": 16, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "rbF01h", + "name": "Inspector Header", + "width": "fill_container", + "height": 36, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rbF01hL", + "name": "leftGroup", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rbF01hI", + "name": "propIcon", + "width": 16, + "height": 16, + "iconFontName": "sliders-horizontal", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "rbF01hT", + "name": "inspTitle", + "fill": "$--muted-foreground", + "content": "Properties", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "icon_font", + "id": "rbF01hX", + "name": "closeIcon", + "width": 16, + "height": 16, + "iconFontName": "x", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "rbF01rel", + "name": "Relationships Section", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "rbF01relT", + "fill": "$--muted-foreground", + "content": "BELONGS TO", + "fontFamily": "IBM Plex Mono", + "fontSize": 10 + }, + { + "type": "frame", + "id": "rbF01relB", + "name": "relBtn", + "width": "fill_container", + "fill": "$--accent-blue-light", + "cornerRadius": 6, + "padding": [6, 10], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rbF01relBT", + "fill": "$--accent-blue", + "content": "Grow Newsletter", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "rbF01relBI", + "width": 14, + "height": 14, + "opacity": 0.5, + "iconFontName": "target", + "iconFontFamily": "phosphor", + "fill": "$--accent-blue" + } + ] + } + ] + }, + { + "type": "frame", + "id": "rbF01ref", + "name": "Referenced By Section", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "rbF01refH", + "name": "refByHeader", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rbF01refHT", + "fill": "$--muted-foreground", + "content": "REFERENCED BY", + "fontFamily": "IBM Plex Mono", + "fontSize": 10 + }, + { + "type": "text", + "id": "rbF01refHC", + "fill": "$--muted-foreground", + "content": "5", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "rbF01refG1", + "name": "refByGroup — via Belongs to", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "rbF01refG1T", + "fill": "$--muted-foreground", + "opacity": 0.7, + "content": "VIA BELONGS TO", + "fontFamily": "IBM Plex Mono", + "fontSize": 9 + }, + { + "type": "frame", + "id": "rbF01refG1B1", + "name": "refItem1", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "padding": [4, 0], + "children": [ + { + "type": "text", + "id": "rbF01refG1B1T", + "fill": "$--accent-orange", + "content": "Write Weekly Essays", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "rbF01refG1B1I", + "width": 14, + "height": 14, + "opacity": 0.5, + "iconFontName": "arrows-clockwise", + "iconFontFamily": "phosphor", + "fill": "$--accent-orange" + } + ] + }, + { + "type": "frame", + "id": "rbF01refG1B2", + "name": "refItem2", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "padding": [4, 0], + "children": [ + { + "type": "text", + "id": "rbF01refG1B2T", + "fill": "$--accent-purple", + "content": "On Writing Well", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "rbF01refG1B2I", + "width": 14, + "height": 14, + "opacity": 0.5, + "iconFontName": "file-text", + "iconFontFamily": "phosphor", + "fill": "$--accent-purple" + } + ] + }, + { + "type": "frame", + "id": "rbF01refG1B3", + "name": "refItem3", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "padding": [4, 0], + "children": [ + { + "type": "text", + "id": "rbF01refG1B3T", + "fill": "$--accent-purple", + "content": "AI Agents Primer", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "rbF01refG1B3I", + "width": 14, + "height": 14, + "opacity": 0.5, + "iconFontName": "file-text", + "iconFontFamily": "phosphor", + "fill": "$--accent-purple" + } + ] + } + ] + }, + { + "type": "frame", + "id": "rbF01refG2", + "name": "refByGroup — via Related to", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "rbF01refG2T", + "fill": "$--muted-foreground", + "opacity": 0.7, + "content": "VIA RELATED TO", + "fontFamily": "IBM Plex Mono", + "fontSize": 9 + }, + { + "type": "frame", + "id": "rbF01refG2B1", + "name": "refItem4", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "padding": [4, 0], + "children": [ + { + "type": "text", + "id": "rbF01refG2B1T", + "fill": "$--accent-red", + "content": "Failed SEO Experiment", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "rbF01refG2B1I", + "width": 14, + "height": 14, + "opacity": 0.5, + "iconFontName": "flask", + "iconFontFamily": "phosphor", + "fill": "$--accent-red" + } + ] + }, + { + "type": "frame", + "id": "rbF01refG2B2", + "name": "refItem5", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "padding": [4, 0], + "children": [ + { + "type": "text", + "id": "rbF01refG2B2T", + "fill": "$--accent-red", + "content": "Twitter Thread Experiment", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "rbF01refG2B2I", + "width": 14, + "height": 14, + "opacity": 0.5, + "iconFontName": "flask", + "iconFontFamily": "phosphor", + "fill": "$--accent-red" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "rbF01bl", + "name": "Backlinks Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "rbF01blT", + "fill": "$--muted-foreground", + "content": "BACKLINKS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10 + }, + { + "type": "text", + "id": "rbF01blI1", + "fill": "$--primary", + "content": "Write Weekly Essays", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "rbF02", + "x": 360, + "y": 0, + "name": "Inspector — Referenced By (Empty)", + "theme": { "Mode": "Light" }, + "clip": true, + "width": 320, + "height": 500, + "fill": "$--background", + "layout": "vertical", + "gap": 16, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "rbF02h", + "name": "Inspector Header", + "width": "fill_container", + "height": 36, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rbF02hL", + "name": "leftGroup", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rbF02hI", + "width": 16, + "height": 16, + "iconFontName": "sliders-horizontal", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "rbF02hT", + "fill": "$--muted-foreground", + "content": "Properties", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "icon_font", + "id": "rbF02hX", + "width": 16, + "height": 16, + "iconFontName": "x", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "rbF02rel", + "name": "Relationships Section", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "rbF02relT", + "fill": "$--muted-foreground", + "content": "No relationships", + "fontFamily": "Inter", + "fontSize": 13 + } + ] + }, + { + "type": "frame", + "id": "rbF02ref", + "name": "Referenced By Section (Empty)", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "rbF02refT", + "fill": "$--muted-foreground", + "content": "REFERENCED BY", + "fontFamily": "IBM Plex Mono", + "fontSize": 10 + }, + { + "type": "text", + "id": "rbF02refE", + "fill": "$--muted-foreground", + "content": "No references", + "fontFamily": "Inter", + "fontSize": 13 + } + ] + }, + { + "type": "frame", + "id": "rbF02bl", + "name": "Backlinks Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "rbF02blT", + "fill": "$--muted-foreground", + "content": "BACKLINKS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10 + }, + { + "type": "text", + "id": "rbF02blE", + "fill": "$--muted-foreground", + "content": "No backlinks", + "fontFamily": "Inter", + "fontSize": 13 + } + ] + } + ] + }, + { + "type": "frame", + "id": "rbF03", + "x": 720, + "y": 0, + "name": "Inspector — Referenced By (Many Backlinks)", + "theme": { "Mode": "Light" }, + "clip": true, + "width": 320, + "height": 800, + "fill": "$--background", + "layout": "vertical", + "gap": 16, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "rbF03h", + "name": "Inspector Header", + "width": "fill_container", + "height": 36, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rbF03hL", + "name": "leftGroup", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rbF03hI", + "width": 16, + "height": 16, + "iconFontName": "sliders-horizontal", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + }, + { + "type": "text", + "id": "rbF03hT", + "fill": "$--muted-foreground", + "content": "Properties", + "fontFamily": "Inter", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "icon_font", + "id": "rbF03hX", + "width": 16, + "height": 16, + "iconFontName": "x", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "rbF03rel", + "name": "Relationships Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "rbF03relG1", + "name": "relGroup — Has", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "rbF03relG1T", + "fill": "$--muted-foreground", + "content": "HAS", + "fontFamily": "IBM Plex Mono", + "fontSize": 10 + }, + { + "type": "frame", + "id": "rbF03relG1B1", + "width": "fill_container", + "fill": "$--accent-purple-light", + "cornerRadius": 6, + "padding": [6, 10], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { "type": "text", "id": "rbF03relG1B1T", "fill": "$--accent-purple", "content": "On Writing Well", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }, + { "type": "icon_font", "id": "rbF03relG1B1I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "file-text", "iconFontFamily": "phosphor", "fill": "$--accent-purple" } + ] + }, + { + "type": "frame", + "id": "rbF03relG1B2", + "width": "fill_container", + "fill": "$--accent-purple-light", + "cornerRadius": 6, + "padding": [6, 10], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { "type": "text", "id": "rbF03relG1B2T", "fill": "$--accent-purple", "content": "Engineering Leadership 101", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }, + { "type": "icon_font", "id": "rbF03relG1B2I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "file-text", "iconFontFamily": "phosphor", "fill": "$--accent-purple" } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "rbF03ref", + "name": "Referenced By Section (Many)", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "frame", + "id": "rbF03refH", + "name": "refByHeader", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rbF03refHT", + "fill": "$--muted-foreground", + "content": "REFERENCED BY", + "fontFamily": "IBM Plex Mono", + "fontSize": 10 + }, + { + "type": "text", + "id": "rbF03refHC", + "fill": "$--muted-foreground", + "content": "8", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "rbF03refG1", + "name": "refByGroup — via Belongs to", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { "type": "text", "id": "rbF03refG1T", "fill": "$--muted-foreground", "opacity": 0.7, "content": "VIA BELONGS TO", "fontFamily": "IBM Plex Mono", "fontSize": 9 }, + { + "type": "frame", "id": "rbF03refG1B1", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0], + "children": [ + { "type": "text", "id": "rbF03refG1B1T", "fill": "$--accent-orange", "content": "Write Weekly Essays", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }, + { "type": "icon_font", "id": "rbF03refG1B1I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "arrows-clockwise", "iconFontFamily": "phosphor", "fill": "$--accent-orange" } + ] + }, + { + "type": "frame", "id": "rbF03refG1B2", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0], + "children": [ + { "type": "text", "id": "rbF03refG1B2T", "fill": "$--accent-purple", "content": "On Writing Well", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }, + { "type": "icon_font", "id": "rbF03refG1B2I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "file-text", "iconFontFamily": "phosphor", "fill": "$--accent-purple" } + ] + }, + { + "type": "frame", "id": "rbF03refG1B3", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0], + "children": [ + { "type": "text", "id": "rbF03refG1B3T", "fill": "$--accent-purple", "content": "Engineering Leadership 101", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }, + { "type": "icon_font", "id": "rbF03refG1B3I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "file-text", "iconFontFamily": "phosphor", "fill": "$--accent-purple" } + ] + }, + { + "type": "frame", "id": "rbF03refG1B4", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0], + "children": [ + { "type": "text", "id": "rbF03refG1B4T", "fill": "$--accent-purple", "content": "AI Agents Primer", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }, + { "type": "icon_font", "id": "rbF03refG1B4I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "file-text", "iconFontFamily": "phosphor", "fill": "$--accent-purple" } + ] + } + ] + }, + { + "type": "frame", + "id": "rbF03refG2", + "name": "refByGroup — via Related to", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { "type": "text", "id": "rbF03refG2T", "fill": "$--muted-foreground", "opacity": 0.7, "content": "VIA RELATED TO", "fontFamily": "IBM Plex Mono", "fontSize": 9 }, + { + "type": "frame", "id": "rbF03refG2B1", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0], + "children": [ + { "type": "text", "id": "rbF03refG2B1T", "fill": "$--accent-red", "content": "Failed SEO Experiment", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }, + { "type": "icon_font", "id": "rbF03refG2B1I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "flask", "iconFontFamily": "phosphor", "fill": "$--accent-red" } + ] + }, + { + "type": "frame", "id": "rbF03refG2B2", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0], + "children": [ + { "type": "text", "id": "rbF03refG2B2T", "fill": "$--accent-red", "content": "Twitter Thread Experiment", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }, + { "type": "icon_font", "id": "rbF03refG2B2I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "flask", "iconFontFamily": "phosphor", "fill": "$--accent-red" } + ] + } + ] + }, + { + "type": "frame", + "id": "rbF03refG3", + "name": "refByGroup — via Topics", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { "type": "text", "id": "rbF03refG3T", "fill": "$--muted-foreground", "opacity": 0.7, "content": "VIA TOPICS", "fontFamily": "IBM Plex Mono", "fontSize": 9 }, + { + "type": "frame", "id": "rbF03refG3B1", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0], + "children": [ + { "type": "text", "id": "rbF03refG3B1T", "fill": "$--accent-blue", "content": "Build Laputa App", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }, + { "type": "icon_font", "id": "rbF03refG3B1I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "wrench", "iconFontFamily": "phosphor", "fill": "$--accent-blue" } + ] + }, + { + "type": "frame", "id": "rbF03refG3B2", "width": "fill_container", "justifyContent": "space_between", "alignItems": "center", "padding": [4, 0], + "children": [ + { "type": "text", "id": "rbF03refG3B2T", "fill": "$--accent-blue", "content": "Newsletter Strategy 2026", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500" }, + { "type": "icon_font", "id": "rbF03refG3B2I", "width": 14, "height": 14, "opacity": 0.5, "iconFontName": "wrench", "iconFontFamily": "phosphor", "fill": "$--accent-blue" } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "rbF03bl", + "name": "Backlinks Section", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "rbF03blH", + "gap": 6, + "alignItems": "center", + "children": [ + { "type": "text", "id": "rbF03blT", "fill": "$--muted-foreground", "content": "BACKLINKS", "fontFamily": "IBM Plex Mono", "fontSize": 10 }, + { "type": "text", "id": "rbF03blC", "fill": "$--muted-foreground", "content": "3", "fontFamily": "IBM Plex Mono", "fontSize": 10, "fontWeight": "normal" } + ] + }, + { "type": "text", "id": "rbF03blI1", "fill": "$--primary", "content": "Write Weekly Essays", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "normal" }, + { "type": "text", "id": "rbF03blI2", "fill": "$--primary", "content": "Monthly Review Template", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "normal" }, + { "type": "text", "id": "rbF03blI3", "fill": "$--primary", "content": "Q1 Planning", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "normal" } + ] + } + ] + } + ], + "variables": {} +} \ No newline at end of file diff --git a/src/components/Inspector.test.tsx b/src/components/Inspector.test.tsx index 0d771cfa..6ffc6abb 100644 --- a/src/components/Inspector.test.tsx +++ b/src/components/Inspector.test.tsx @@ -324,4 +324,250 @@ This is a test note with some words to count. expect(row.className).not.toContain('cursor-pointer') }) }) + + describe('Referenced By (bidirectional relationships)', () => { + const targetEntry: VaultEntry = { + path: '/Users/luca/Laputa/responsibility/grow-newsletter.md', + filename: 'grow-newsletter.md', + title: 'Grow Newsletter', + isA: 'Responsibility', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: 1707900000, + createdAt: null, + fileSize: 500, + snippet: '', + relationships: { 'Type': ['[[type/responsibility]]'] }, + icon: null, + color: null, + order: null, + } + + const essayEntry: 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, + } + + const procedureEntry: VaultEntry = { + path: '/Users/luca/Laputa/procedure/write-essays.md', + filename: 'write-essays.md', + title: 'Write Weekly Essays', + isA: 'Procedure', + aliases: [], + belongsTo: ['[[responsibility/grow-newsletter]]'], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: 1707900000, + createdAt: null, + fileSize: 400, + snippet: '', + relationships: { 'Belongs to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/procedure]]'] }, + icon: null, + color: null, + order: null, + } + + const experimentEntry: VaultEntry = { + path: '/Users/luca/Laputa/experiment/seo.md', + filename: 'seo.md', + title: 'SEO Experiment', + isA: 'Experiment', + aliases: [], + belongsTo: [], + relatedTo: ['[[responsibility/grow-newsletter]]'], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: 1707900000, + createdAt: null, + fileSize: 200, + snippet: '', + relationships: { 'Related to': ['[[responsibility/grow-newsletter]]'], 'Type': ['[[type/experiment]]'] }, + icon: null, + color: null, + order: null, + } + + const targetContent = `--- +title: Grow Newsletter +is_a: Responsibility +Status: Active +--- + +# Grow Newsletter +` + + it('shows entries that reference the current note via frontmatter relationships', () => { + render( + + ) + expect(screen.getByText('On Writing Well')).toBeInTheDocument() + expect(screen.getByText('Write Weekly Essays')).toBeInTheDocument() + expect(screen.getByText('SEO Experiment')).toBeInTheDocument() + }) + + it('groups referenced-by entries by relationship key', () => { + render( + + ) + expect(screen.getByText(/via Belongs to/)).toBeInTheDocument() + expect(screen.getByText(/via Related to/)).toBeInTheDocument() + }) + + it('shows count badge for referenced-by entries', () => { + render( + + ) + // 2 entries reference via Belongs to + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('shows "No references" when no entries reference the current note', () => { + render( + + ) + expect(screen.getByText('No references')).toBeInTheDocument() + }) + + it('navigates when clicking a referenced-by entry', () => { + const onNavigate = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('On Writing Well')) + expect(onNavigate).toHaveBeenCalledWith('On Writing Well') + }) + + it('skips Type relationships in referenced-by computation', () => { + const typeEntry: VaultEntry = { + ...targetEntry, + path: '/Users/luca/Laputa/type/responsibility.md', + filename: 'responsibility.md', + title: 'Responsibility', + isA: 'Type', + relationships: {}, + } + // essayEntry has Type: [[type/responsibility]] — should NOT show as referenced-by + render( + + ) + // On Writing Well references responsibility via "Belongs to" (path match), not via "Type" + // But the Type entry is at type/responsibility.md, so wikilinks to + // responsibility/grow-newsletter won't match. Should show "No references" + expect(screen.getByText('No references')).toBeInTheDocument() + }) + + it('resolves references via aliased wikilinks', () => { + const aliasedTarget: VaultEntry = { + ...targetEntry, + aliases: ['Newsletter'], + } + const referrer: VaultEntry = { + ...essayEntry, + relationships: { 'Topics': ['[[Newsletter]]'], 'Type': ['[[type/essay]]'] }, + } + render( + + ) + expect(screen.getByText('On Writing Well')).toBeInTheDocument() + expect(screen.getByText(/via Topics/)).toBeInTheDocument() + }) + + it('does not show self-references', () => { + const selfRef: VaultEntry = { + ...targetEntry, + relationships: { + ...targetEntry.relationships, + 'Notes': ['[[responsibility/grow-newsletter]]'], + }, + } + render( + + ) + expect(screen.getByText('No references')).toBeInTheDocument() + }) + }) }) diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx index edfe5693..f344a515 100644 --- a/src/components/Inspector.tsx +++ b/src/components/Inspector.tsx @@ -4,7 +4,8 @@ import { cn } from '@/lib/utils' import { SlidersHorizontal, X } from '@phosphor-icons/react' import { parseFrontmatter } from '../utils/frontmatter' import { DynamicPropertiesPanel } from './DynamicPropertiesPanel' -import { DynamicRelationshipsPanel, BacklinksPanel, GitHistoryPanel } from './InspectorPanels' +import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, wikilinkTarget } from './InspectorPanels' +import type { ReferencedByItem } from './InspectorPanels' export type FrontmatterValue = string | number | boolean | string[] | null @@ -40,6 +41,35 @@ function useBacklinks(entry: VaultEntry | null, entries: VaultEntry[], allConten }, [entry, entries, allContent]) } +function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): ReferencedByItem[] { + return useMemo(() => { + if (!entry) return [] + + const pathStem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '') + const filenameStem = entry.filename.replace(/\.md$/, '') + const matchTargets = new Set([pathStem, filenameStem, entry.title, ...entry.aliases]) + + const results: ReferencedByItem[] = [] + + for (const other of entries) { + if (other.path === entry.path) continue + + for (const [key, refs] of Object.entries(other.relationships)) { + if (key === 'Type') continue + for (const ref of refs) { + const target = wikilinkTarget(ref) + if (matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')) { + results.push({ entry: other, viaKey: key }) + break + } + } + } + } + + return results + }, [entry, entries]) +} + function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) { return (
@@ -65,6 +95,10 @@ function EmptyInspector() { <>

No note selected

No relationships

+
+

Referenced by

+

No references

+

Backlinks

No backlinks

@@ -82,6 +116,7 @@ export function Inspector({ onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, }: InspectorProps) { const backlinks = useBacklinks(entry, entries, allContent) + const referencedBy = useReferencedBy(entry, entries) const frontmatter = useMemo(() => parseFrontmatter(content), [content]) const handleUpdateProperty = useCallback((key: string, value: FrontmatterValue) => { @@ -111,6 +146,7 @@ export function Inspector({ onNavigate={onNavigate} /> + diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index d57dfdb4..18d6cd4b 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { DynamicRelationshipsPanel, BacklinksPanel, GitHistoryPanel } from './InspectorPanels' +import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels' +import type { ReferencedByItem } from './InspectorPanels' import type { VaultEntry, GitCommit } from '../types' const makeEntry = (overrides: Partial = {}): VaultEntry => ({ @@ -225,6 +226,69 @@ describe('BacklinksPanel', () => { }) }) +describe('ReferencedByPanel', () => { + const onNavigate = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows "No references" when items is empty', () => { + render() + expect(screen.getByText('No references')).toBeInTheDocument() + }) + + it('renders referenced-by entries grouped by relationship key', () => { + const items: ReferencedByItem[] = [ + { entry: makeEntry({ path: '/vault/proc/a.md', title: 'Write Essays', isA: 'Procedure' }), viaKey: 'Belongs to' }, + { entry: makeEntry({ path: '/vault/essay/b.md', title: 'On Writing Well', isA: 'Essay' }), viaKey: 'Belongs to' }, + { entry: makeEntry({ path: '/vault/exp/c.md', title: 'SEO Experiment', isA: 'Experiment' }), viaKey: 'Related to' }, + ] + render() + + expect(screen.getByText('Write Essays')).toBeInTheDocument() + expect(screen.getByText('On Writing Well')).toBeInTheDocument() + expect(screen.getByText('SEO Experiment')).toBeInTheDocument() + expect(screen.getByText(/via Belongs to/)).toBeInTheDocument() + expect(screen.getByText(/via Related to/)).toBeInTheDocument() + }) + + it('shows count badge when items exist', () => { + const items: ReferencedByItem[] = [ + { entry: makeEntry({ path: '/vault/a.md', title: 'A' }), viaKey: 'Has' }, + { entry: makeEntry({ path: '/vault/b.md', title: 'B' }), viaKey: 'Has' }, + { entry: makeEntry({ path: '/vault/c.md', title: 'C' }), viaKey: 'Topics' }, + ] + render() + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('navigates when clicking a referenced-by entry', () => { + const items: ReferencedByItem[] = [ + { entry: makeEntry({ path: '/vault/a.md', title: 'My Note' }), viaKey: 'Belongs to' }, + ] + render() + fireEvent.click(screen.getByText('My Note')) + expect(onNavigate).toHaveBeenCalledWith('My Note') + }) + + it('dims archived entries in referenced-by', () => { + const items: ReferencedByItem[] = [ + { entry: makeEntry({ path: '/vault/a.md', title: 'Old Note', archived: true }), viaKey: 'Has' }, + ] + render() + expect(screen.getByTitle('Archived')).toBeInTheDocument() + }) + + it('shows trashed indicator for trashed entries', () => { + const items: ReferencedByItem[] = [ + { entry: makeEntry({ path: '/vault/a.md', title: 'Trash Note', trashed: true }), viaKey: 'Has' }, + ] + render() + expect(screen.getByTitle('Trashed')).toBeInTheDocument() + }) +}) + describe('GitHistoryPanel', () => { const onViewCommitDiff = vi.fn() diff --git a/src/components/InspectorPanels.tsx b/src/components/InspectorPanels.tsx index c84693ac..12d8be91 100644 --- a/src/components/InspectorPanels.tsx +++ b/src/components/InspectorPanels.tsx @@ -32,7 +32,7 @@ function wikilinkDisplay(ref: string): string { return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) } -function wikilinkTarget(ref: string): string { +export function wikilinkTarget(ref: string): string { const inner = ref.replace(/^\[\[|\]\]$/g, '') const pipeIdx = inner.indexOf('|') return pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner @@ -217,6 +217,62 @@ export function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntr ) } +export interface ReferencedByItem { + entry: VaultEntry + viaKey: string +} + +export function ReferencedByPanel({ items, onNavigate }: { + items: ReferencedByItem[] + onNavigate: (target: string) => void +}) { + const grouped = useMemo(() => { + const map = new Map() + for (const item of items) { + const existing = map.get(item.viaKey) + if (existing) existing.push(item.entry) + else map.set(item.viaKey, [item.entry]) + } + return Array.from(map.entries()) + }, [items]) + + return ( +
+

+ Referenced by {items.length > 0 && {items.length}} +

+ {items.length === 0 + ?

No references

+ : ( +
+ {grouped.map(([viaKey, groupEntries]) => ( +
+ + via {viaKey} + +
+ {groupEntries.map((e) => ( + onNavigate(e.title)} + title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined} + TypeIcon={getTypeIcon(e.isA ?? undefined)} + /> + ))} +
+
+ ))} +
+ ) + } +
+ ) +} + function formatRelativeDate(timestamp: number): string { const now = Math.floor(Date.now() / 1000) const days = Math.floor((now - timestamp) / 86400)