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
464 changes: 464 additions & 0 deletions src/commands/applyChanges.ts

Large diffs are not rendered by default.

115 changes: 114 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,54 @@
import joplin from 'api';
import { MenuItemLocation, ToolbarButtonLocation } from 'api/types';
import { MenuItemLocation, ToolbarButtonLocation, SettingItemType as SettingType } from 'api/types';
import { runTestEmbed } from './commands/testEmbed';
import { runPipeline } from './pipeline/runPipeline';
import { PanelMessage, WebviewMessage } from './types/panel';
import { log } from './utils/logger';
import { applyCategorizationChanges, undoCategorizationChanges, cleanUpEmptyNotebooks } from './commands/applyChanges';

joplin.plugins.register({
onStart: async function () {
log('Plugin started');

// Register setting section
await joplin.settings.registerSection('aiCategorization', {
label: 'AI Categorization',
iconName: 'fas fa-brain',
});

// Register setting items
await joplin.settings.registerSettings({
'categorization.metric': {
value: 'cosine',
type: SettingType.String,
section: 'aiCategorization',
public: true,
isEnum: true,
options: {
cosine: 'Cosine Similarity',
euclidean: 'Euclidean Distance',
},
label: 'Distance Metric',
description: 'The metric used to compute distances between note embeddings.',
},
'categorization.parentNotebook': {
value: 'AI Categorized Notes',
type: SettingType.String,
section: 'aiCategorization',
public: true,
label: 'Parent Notebook',
description: 'The parent notebook name where categorized folders will be placed.',
},
'categorization.changeLog': {
value: '',
type: SettingType.String,
section: 'aiCategorization',
public: false,
label: 'Change Log',
description: 'Stores previous states of moved and tagged notes for undo operations.',
},
});

const installDir = await joplin.plugins.installationDir();

await joplin.commands.register({
Expand All @@ -33,6 +73,7 @@ joplin.plugins.register({
// Pipeline state shared between the onMessage handler and pipeline callbacks.
// The webview polls this state via { type: 'poll' } messages.
let panelState: PanelMessage | { type: 'idle' } = { type: 'idle' };
let operationInProgress = false;

await joplin.views.panels.onMessage(panel, async (msg: WebviewMessage) => {
switch (msg.type) {
Expand Down Expand Up @@ -66,6 +107,78 @@ joplin.plugins.register({
await joplin.commands.execute('openNote', msg.noteId);
}
return;

case 'getSettings':
return {
'categorization.metric': await joplin.settings.value('categorization.metric'),
'categorization.parentNotebook': await joplin.settings.value('categorization.parentNotebook'),
'categorization.changeLog': await joplin.settings.value('categorization.changeLog'),
};

case 'updateSetting':
await joplin.settings.setValue(msg.key, msg.value);
return { success: true };

case 'apply':
if (operationInProgress) {
return { type: 'apply_error', message: 'Another operation is already in progress.' };
}
operationInProgress = true;
panelState = { type: 'apply_status', text: 'Initializing application of categorization...' };
applyCategorizationChanges(
msg.options,
msg.notes,
msg.assignments,
msg.clusterNames,
msg.clusterTags,
(state) => {
panelState = state;
},
)
.catch((err) => {
log('Error in apply background task: ' + err);
panelState = { type: 'apply_error', message: err.message || String(err) };
})
.finally(() => {
operationInProgress = false;
});
return panelState;

case 'undo':
if (operationInProgress) {
return { type: 'undo_error', message: 'Another operation is already in progress.' };
}
operationInProgress = true;
panelState = { type: 'undo_status', text: 'Initializing undo...' };
undoCategorizationChanges((state) => {
panelState = state;
})
.catch((err) => {
log('Error in undo background task: ' + err);
panelState = { type: 'undo_error', message: err.message || String(err) };
})
.finally(() => {
operationInProgress = false;
});
return panelState;

case 'cleanUpEmptyNotebooks':
if (operationInProgress) {
return { type: 'cleanup_error', message: 'Another operation is already in progress.' };
}
operationInProgress = true;
panelState = { type: 'cleanup_status', text: 'Checking empty notebooks...' };
cleanUpEmptyNotebooks((state) => {
panelState = state;
})
.catch((err) => {
log('Error in cleanup background task: ' + err);
panelState = { type: 'cleanup_error', message: err.message || String(err) };
})
.finally(() => {
operationInProgress = false;
});
return panelState;
}
});

Expand Down
37 changes: 35 additions & 2 deletions src/types/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,45 @@ export interface ProgressState {
skipped: number;
}

export interface ApplyOptions {
method: 'tags' | 'notebooks' | 'both';
parentNotebookName: string;
}

export interface ApplyMessage {
type: 'apply';
options: ApplyOptions;
notes: PanelNote[];
assignments: number[];
clusterNames: { [clusterId: number]: string };
clusterTags: { [clusterId: number]: string[] };
}

// Plugin → Webview
export type PanelMessage =
| { type: 'status'; text: string }
| { type: 'progress'; current: number; total: number; cached: number; skipped: number }
| { type: 'results'; strategies: BenchmarkResult[]; notes: PanelNote[] }
| { type: 'error'; message: string };
| { type: 'error'; message: string }
| { type: 'apply_status'; text: string }
| { type: 'apply_progress'; current: number; total: number }
| { type: 'apply_complete' }
| { type: 'apply_error'; message: string }
| { type: 'undo_status'; text: string }
| { type: 'undo_progress'; current: number; total: number }
| { type: 'undo_complete' }
| { type: 'undo_error'; message: string }
| { type: 'cleanup_status'; text: string }
| { type: 'cleanup_complete'; message: string }
| { type: 'cleanup_error'; message: string };

// Webview → Plugin
export type WebviewMessage = { type: 'run' } | { type: 'poll' } | { type: 'openNote'; noteId: string };
export type WebviewMessage =
| { type: 'run' }
| { type: 'poll' }
| { type: 'openNote'; noteId: string }
| { type: 'getSettings' }
| { type: 'updateSetting'; key: string; value: any }
| ApplyMessage
| { type: 'undo' }
| { type: 'cleanUpEmptyNotebooks' };
14 changes: 14 additions & 0 deletions src/webview/components/ClusterCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ export const ClusterCard: React.FC<ClusterCardProps> = ({ title, noteIndices, no
if (!note) return null;
return (
<div key={note.noteId} className="note-item" onClick={() => handleNoteClick(note.noteId)}>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ opacity: 0.6, flexShrink: 0 }}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span className="note-title">{note.title || 'Untitled'}</span>
</div>
);
Expand Down
14 changes: 14 additions & 0 deletions src/webview/components/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import * as React from 'react';
export const EmptyState: React.FC = () => {
return (
<div className="empty-state">
<svg
className="empty-illustration"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<div className="empty-title">No categories yet</div>
<div className="empty-subtitle">Click Run to categorize your notes using on-device AI.</div>
</div>
Expand Down
31 changes: 29 additions & 2 deletions src/webview/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,36 @@ interface HeaderProps {
export const Header: React.FC<HeaderProps> = ({ isRunning, onRun }) => {
return (
<div className="panel-header">
<div className="panel-header-title">Note Categorizer</div>
<div className="panel-header-title">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" strokeWidth="2.5" />
</svg>
Note Categorizer
</div>
<button id="btn-run" className="btn-run" onClick={onRun} disabled={isRunning}>
Run
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
{isRunning ? 'Running...' : 'Run'}
</button>
</div>
);
Expand Down
Loading
Loading