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
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
80 changes: 80 additions & 0 deletions src/services/graph/GraphBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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', 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 note labels to 64 chars', () => {
mockEdgeFactory.createEdges.mockReturnValue([]);
const longTitle = 'A'.repeat(100);
const result = builder.build([note('a', longTitle)]);
expect(result.nodes[0].data.label).toHaveLength(64);
expect(result.nodes[0].data.label.endsWith('...')).toBe(true);
});

it('uses (untitled) for empty titles', () => {
mockEdgeFactory.createEdges.mockReturnValue([]);
const result = builder.build([note('a', '')]);
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' });
});
});
60 changes: 60 additions & 0 deletions src/services/graph/GraphBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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);
}
Comment on lines +20 to +23

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 connectedIds = new Set<string>();
for (const edge of visibleEdges) {
connectedIds.add(edge.source);
connectedIds.add(edge.target);
}
const isolatedCount = nodes.length - connectedIds.size;

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

return { nodes, edges: visibleEdges.map((e) => ({ data: e })) };
}
}
20 changes: 20 additions & 0 deletions src/services/graph/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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;
tagName?: string;
}

export interface GraphData {
nodes: Array<{ data: GraphNode }>;
edges: Array<{ data: GraphEdge }>;
}
104 changes: 104 additions & 0 deletions src/services/similarity/EdgeFactory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { EdgeFactory } from './EdgeFactory';
import { Note } from '../../data/Types';

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

describe('EdgeFactory', () => {
let factory: EdgeFactory;

beforeEach(() => {
factory = new EdgeFactory();
});

it('returns empty for empty notes', () => {
expect(factory.createEdges([])).toEqual([]);
});

it('returns empty for notes with no links or tags', () => {
expect(factory.createEdges([note('a', 'A'), note('b', 'B')])).toEqual([]);
});

it('ignores resource links that are not note IDs', () => {
const notes = [
note('a', 'A', ['resource123']),
note('b', 'B', ['resource123']),
];
expect(factory.createEdges(notes)).toEqual([]);
});

it('creates link edge when note body references another note ID', () => {
const edges = factory.createEdges([
note('a', 'A', ['b']),
note('b', 'B', []),
]);
expect(edges).toEqual([{ source: 'a', target: 'b', type: 'link' }]);
});

it('creates bidirectional links when notes reference each other', () => {
const edges = factory.createEdges([
note('a', 'A', ['b']),
note('b', 'B', ['a']),
]);
expect(edges).toHaveLength(2);
expect(edges).toContainEqual({ source: 'a', target: 'b', type: 'link' });
expect(edges).toContainEqual({ source: 'b', target: 'a', type: 'link' });
});

it('creates tag edge with tagName for shared tags', () => {
const edges = factory.createEdges([
note('a', 'A', [], ['shared']),
note('b', 'B', [], ['shared']),
]);
expect(edges).toEqual([
{ source: 'a', target: 'b', type: 'tag', tagName: 'shared' },
]);
});

it('merges multiple shared tag names into one edge', () => {
const edges = factory.createEdges([
note('a', 'A', [], ['t1', 't2']),
note('b', 'B', [], ['t1', 't2']),
]);
expect(edges).toHaveLength(1);
expect(edges[0].type).toBe('tag');
expect(edges[0].tagName).toBe('t1, t2');
});

it('creates separate tag edges for different pairs', () => {
const edges = factory.createEdges([
note('a', 'A', [], ['t1']),
note('b', 'B', [], ['t1']),
note('c', 'C', [], ['t1']),
]);
expect(edges).toHaveLength(3);
});

it('creates both link and tag edges for the same pair', () => {
const edges = factory.createEdges([
note('a', 'A', ['b'], ['shared']),
note('b', 'B', [], ['shared']),
]);
expect(edges).toContainEqual({ source: 'a', target: 'b', type: 'link' });
expect(edges).toContainEqual({ source: 'a', target: 'b', type: 'tag', tagName: 'shared' });
});

it('ignores self-referencing links', () => {
expect(factory.createEdges([note('a', 'A', ['a'])])).toEqual([]);
});
});
Loading