Skip to content
Open
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
40 changes: 40 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"name": "joplin-plugin-note-graph",
"version": "1.0.0",
"scripts": {
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive",
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --config webview.webpack.config.js && webpack --env joplin-plugin-config=createArchive",
"build:webview": "webpack --config webview.webpack.config.js",
"prepare": "npm run dist",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"updateVersion": "webpack --env joplin-plugin-config=updateVersion",
Expand Down Expand Up @@ -34,5 +35,9 @@
"typescript": "^4.8.2",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0"
},
"dependencies": {
"cytoscape": "^3.34.0",
"cytoscape-fcose": "^2.2.0"
}
}
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import joplin from 'api';
import { MenuItemLocation } from 'api/types';
import { initializeAiNoteGraphPanel, showAiNoteGraphPanel } from './ui/webview';
import { initializeAiNoteGraphPanel, showAiNoteGraphPanel, postGraphData } from './ui/webview';
import { NoteRepository } from './data/NoteRepository';
import { NotePreprocessor } from './data/NotePreprocessor';
import { Note } from './data/Types';
import { GraphBuilder } from './services/graph/GraphBuilder';

const SHOW_NOTE_GRAPH_COMMAND = 'showNoteGraph';
const SHOW_NOTE_GRAPH_MENU_ITEM = 'showNoteGraphMenuItem';
Expand All @@ -24,6 +25,9 @@ const noteGraphCommand = {
try {
const enrichedNotes = await loadNotes();
console.info(`Loaded ${enrichedNotes.length} notes.`);
const builder = new GraphBuilder();
const graphData = builder.build(enrichedNotes);
await postGraphData(graphData);
await showAiNoteGraphPanel();
} catch (error) {
console.error('Failed to load note graph:', error);
Expand Down
125 changes: 125 additions & 0 deletions src/services/graph/GraphBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { GraphBuilder } from './GraphBuilder';
import { EdgeFactory } from '../similarity/EdgeFactory';
import { Note } from '../../data/Types';

jest.mock('../similarity/EdgeFactory');

const MockEdgeFactory = EdgeFactory as jest.MockedClass<typeof EdgeFactory>;

function note(
id: string,
title: string,
links: string[] = []
): Note {
return {
id,
parent_id: 'p1',
title,
body: '',
created_time: 0,
updated_time: 1,
links,
tags: [],
};
}

describe('GraphBuilder', () => {
let builder: GraphBuilder;
let mockEdgeFactory: jest.Mocked<EdgeFactory>;

beforeEach(() => {
jest.clearAllMocks();
mockEdgeFactory = new MockEdgeFactory() as jest.Mocked<EdgeFactory>;
builder = new GraphBuilder(mockEdgeFactory);
});

it('creates nodes with degree 0 when no edges', () => {
mockEdgeFactory.createEdges.mockReturnValue([]);

const notes = [note('a', 'Note A'), note('b', 'Note B')];

const result = builder.build(notes);
expect(result.nodes).toHaveLength(2);
expect(result.nodes[0].data).toMatchObject({
id: 'a',
label: 'Note A',
noteId: 'a',
degree: 0,
});
expect(result.nodes[1].data).toMatchObject({
id: 'b',
label: 'Note B',
noteId: 'b',
degree: 0,
});
});

it('computes degree from edges', () => {
mockEdgeFactory.createEdges.mockReturnValue([
{ source: 'a', target: 'b', type: 'link' },
]);

const notes = [note('a', 'A'), note('b', 'B')];

const result = builder.build(notes);
expect(result.nodes[0].data.degree).toBe(1);
expect(result.nodes[1].data.degree).toBe(1);
expect(result.edges).toHaveLength(1);
expect(result.edges[0].data).toEqual({ source: 'a', target: 'b', type: 'link' });
});

it('truncates long node labels to 64 chars', () => {
mockEdgeFactory.createEdges.mockReturnValue([]);

const longTitle = 'A'.repeat(100);
const notes = [note('a', longTitle)];

const result = builder.build(notes);
const label = result.nodes[0].data.label;
expect(label).toHaveLength(64);
expect(label.endsWith('...')).toBe(true);
});

it('uses (untitled) for empty titles', () => {
mockEdgeFactory.createEdges.mockReturnValue([]);

const notes = [note('a', '')];

const result = builder.build(notes);
expect(result.nodes[0].data.label).toBe('(untitled)');
});

it('filters edges to non-existent nodes', () => {
mockEdgeFactory.createEdges.mockReturnValue([
{ source: 'a', target: 'missing', type: 'link' },
{ source: 'a', target: 'b', type: 'link' },
]);

const notes = [note('a', 'A'), note('b', 'B')];

const result = builder.build(notes);
expect(result.edges).toHaveLength(1);
expect(result.edges[0].data).toEqual({ source: 'a', target: 'b', type: 'link' });
});

it('computes correct degree for hub node', () => {
mockEdgeFactory.createEdges.mockReturnValue([
{ source: 'a', target: 'b', type: 'link' },
{ source: 'a', target: 'c', type: 'link' },
{ source: 'c', target: 'd', type: 'tag' },
]);

const notes = [
note('a', 'A'),
note('b', 'B'),
note('c', 'C'),
note('d', 'D'),
];

const result = builder.build(notes);
const nodeA = result.nodes.find((n) => n.data.id === 'a');
const nodeC = result.nodes.find((n) => n.data.id === 'c');
expect(nodeA?.data.degree).toBe(2);
expect(nodeC?.data.degree).toBe(2);
});
});
63 changes: 63 additions & 0 deletions src/services/graph/GraphBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Note } from '../../data/Types';
import { EdgeFactory } from '../similarity/EdgeFactory';
import { GraphData, GraphNode } from './types';

export class GraphBuilder {
private readonly edgeFactory: EdgeFactory;

public constructor(edgeFactory = new EdgeFactory()) {
this.edgeFactory = edgeFactory;
}

public build(notes: Note[]): GraphData {
const degreeMap = new Map<string, number>();
for (const note of notes) {
degreeMap.set(note.id, 0);
}

const edges = this.edgeFactory.createEdges(notes);

for (const edge of edges) {
degreeMap.set(edge.source, (degreeMap.get(edge.source) ?? 0) + 1);
degreeMap.set(edge.target, (degreeMap.get(edge.target) ?? 0) + 1);
}

const maxDegree = Math.max(1, ...degreeMap.values());

const nodes: Array<{ data: GraphNode }> = [];
for (const note of notes) {
const degree = degreeMap.get(note.id) ?? 0;
const label = note.title || '(untitled)';
nodes.push({
data: {
id: note.id,
label:
label.length > 64
? label.substring(0, 61) + '...'
: label,
noteId: note.id,
degree,
},
});
}

const nodeIdSet = new Set(nodes.map((n) => n.data.id));
const visibleEdges = edges.filter(
(e) => nodeIdSet.has(e.source) && nodeIdSet.has(e.target)
);

const visibleEdgeIds = new Set<string>();
for (const edge of visibleEdges) {
visibleEdgeIds.add(edge.source);
visibleEdgeIds.add(edge.target);
}
const isolatedCount = nodes.length - visibleEdgeIds.size;

console.info(
`Graph built: ${nodes.length} nodes, ${visibleEdges.length} edges ` +
`(${isolatedCount} isolated, max degree ${maxDegree})`
);

return { nodes, edges: visibleEdges.map((e) => ({ data: e })) };
}
}
19 changes: 19 additions & 0 deletions src/services/graph/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export type EdgeType = 'link' | 'tag';

export interface GraphNode {
id: string;
label: string;
noteId: string;
degree: number;
}

export interface GraphEdge {
source: string;
target: string;
type: EdgeType;
}

export interface GraphData {
nodes: Array<{ data: GraphNode }>;
edges: Array<{ data: GraphEdge }>;
}
Loading