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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "joplin-plugin-note-categorization",
"version": "0.1.9",
"version": "0.1.10",
"scripts": {
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && npm run copyAssets && webpack --env joplin-plugin-config=createArchive",
"prepare": "npm run dist",
Expand Down
2 changes: 1 addition & 1 deletion src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifest_version": 1,
"id": "com.harsh16gupta.notecategorization",
"app_min_version": "3.5",
"version": "0.1.9",
"version": "0.1.10",
"name": "Note Categorization Plugin",
"description": "AI-based note categorisation: clusters notes semantically, suggests tags and notebook structures, and detects stale notes.",
"author": "Harsh Gupta",
Expand Down
1 change: 1 addition & 0 deletions src/panel/setupPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export async function setupPanel(operationState: OperationState): Promise<string
return {
'categorization.parentNotebook': await joplin.settings.value('categorization.parentNotebook'),
'categorization.changeLog': await joplin.settings.value('categorization.changeLog'),
'categorization.applyMethod': await joplin.settings.value('categorization.applyMethod'),
};

case 'updateSetting':
Expand Down
14 changes: 14 additions & 0 deletions src/settings/registerSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ export async function registerPluginSettings(operationState: OperationState): Pr
description:
'Default parent notebook where newly categorized sub-notebooks will be created (leave empty for root).',
},
'categorization.applyMethod': {
value: 'both',
type: SettingType.String,
section: 'aiCategorization',
public: true,
isEnum: true,
options: {
both: 'Both (Notebooks & Tags)',
tags: 'Tags only',
notebooks: 'Notebooks only',
},
label: 'Categorization Apply Method',
description: 'Choose whether categorization moves notes to notebooks, applies tags, or both.',
},
'categorization.changeLog': {
value: '',
type: SettingType.String,
Expand Down
1 change: 1 addition & 0 deletions src/webview/context/AppStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface AppStateContextType {
settings: {
parentNotebook: string;
changeLog: string;
applyMethod: 'both' | 'tags' | 'notebooks';
};
updateSetting: (key: string, value: string) => Promise<void>;
fetchSettings: () => Promise<void>;
Expand Down
9 changes: 8 additions & 1 deletion src/webview/context/useSettingsState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import * as React from 'react';
interface SettingsResponse {
'categorization.parentNotebook': string;
'categorization.changeLog': string;
'categorization.applyMethod'?: 'both' | 'tags' | 'notebooks';
}

export function useSettingsState() {
const [settings, setSettings] = React.useState({
const [settings, setSettings] = React.useState<{
parentNotebook: string;
changeLog: string;
applyMethod: 'both' | 'tags' | 'notebooks';
}>({
parentNotebook: '',
changeLog: '',
applyMethod: 'both',
});

const fetchSettings = React.useCallback(async () => {
Expand All @@ -20,6 +26,7 @@ export function useSettingsState() {
setSettings({
parentNotebook: data['categorization.parentNotebook'] || '',
changeLog: data['categorization.changeLog'] || '',
applyMethod: data['categorization.applyMethod'] || 'both',
});
}
} catch (err) {
Expand Down
42 changes: 39 additions & 3 deletions src/webview/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const DashboardPage: React.FC = () => {
undoError,
undoSuccess,
settings,
updateSetting,
isNativeAiUsed,
isAiNamingUsed,
} = useAppState();
Expand All @@ -40,6 +41,19 @@ export const DashboardPage: React.FC = () => {
const [isNativeAiDismissed, setIsNativeAiDismissed] = React.useState(false);
const [isAiNamingDismissed, setIsAiNamingDismissed] = React.useState(false);

const [applyMethod, setApplyMethod] = React.useState<'both' | 'tags' | 'notebooks'>(settings.applyMethod || 'both');

React.useEffect(() => {
if (settings.applyMethod) {
setApplyMethod(settings.applyMethod);
}
}, [settings.applyMethod]);

const handleApplyMethodChange = (newMethod: 'both' | 'tags' | 'notebooks') => {
setApplyMethod(newMethod);
updateSetting('categorization.applyMethod', newMethod);
};

const { clusters, noise, sortedClusterIds } = React.useMemo(() => {
const clusters: { [key: number]: number[] } = {};
const noise: number[] = [];
Expand Down Expand Up @@ -86,7 +100,7 @@ export const DashboardPage: React.FC = () => {
return;
}
applyChanges({
method: 'both',
method: applyMethod,
parentNotebookName: settings.parentNotebook || '',
});
};
Expand Down Expand Up @@ -197,10 +211,32 @@ export const DashboardPage: React.FC = () => {
<div className="apply-header">
<div className="apply-title">Apply the new categorization</div>
<div className="apply-subtitle">
This will automatically move notes into their corresponding notebooks and apply the semantic
tags.
{applyMethod === 'both' &&
'This will automatically move notes into their corresponding notebooks and apply the semantic tags.'}
{applyMethod === 'tags' &&
'This will apply cluster and keyword tags to notes without moving them or creating notebooks.'}
{applyMethod === 'notebooks' &&
'This will create notebooks and move notes into them without creating or adding tags.'}
</div>
</div>

<div className="apply-method-row">
<label htmlFor="apply-method-select" className="apply-method-label">
Apply Method:
</label>
<select
id="apply-method-select"
className="apply-method-select"
value={applyMethod}
onChange={(e) => handleApplyMethodChange(e.target.value as 'both' | 'tags' | 'notebooks')}
disabled={isApplying || isUndoing || applySuccess}
>
<option value="both">Both (Notebooks &amp; Tags)</option>
<option value="tags">Tags only</option>
<option value="notebooks">Notebooks only</option>
</select>
</div>

<div className="apply-action-row">
<button
className="btn-apply-primary"
Expand Down
8 changes: 8 additions & 0 deletions src/webview/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export const SettingsPage: React.FC = () => {
<div className="config-card-item">
• <strong>Target Notebook:</strong> {settings.parentNotebook || '(Root Notebooks)'}
</div>
<div className="config-card-item">
• <strong>Apply Method:</strong>{' '}
{settings.applyMethod === 'both'
? 'Both (Notebooks & Tags)'
: settings.applyMethod === 'notebooks'
? 'Notebooks only'
: 'Tags only'}
</div>
<div className="config-card-item">
• <strong>Embedding Model:</strong> all-MiniLM-L6-v2 (384-dim)
</div>
Expand Down
38 changes: 38 additions & 0 deletions src/webview/panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,43 @@ body {
line-height: 1.45;
}

.apply-method-row {
display: flex;
align-items: center;
gap: 10px;
}

.apply-method-label {
font-size: 0.82em;
font-weight: 600;
opacity: 0.7;
white-space: nowrap;
}

.apply-method-select {
flex: 1;
padding: 5px 8px;
border: 1px solid var(--joplin-divider-color);
border-radius: 6px;
background: var(--joplin-background-color);
color: var(--joplin-color);
font-size: 0.84em;
font-weight: 500;
font-family: inherit;
cursor: pointer;
outline: none;
transition: border-color 150ms ease;
}

.apply-method-select:focus {
border-color: color-mix(in srgb, var(--joplin-color) 30%, var(--joplin-divider-color));
}

.apply-method-select:disabled {
opacity: 0.4;
cursor: not-allowed;
}

.apply-action-row {
display: flex;
}
Expand Down Expand Up @@ -807,6 +844,7 @@ body {
.btn-apply-primary:focus-visible,
.btn-undo:focus-visible,
.strategy-select:focus-visible,
.apply-method-select:focus-visible,
.nav-tab:focus-visible {
outline: 2px solid color-mix(in srgb, var(--joplin-color) 40%, transparent);
outline-offset: 2px;
Expand Down
105 changes: 101 additions & 4 deletions test/commands/applyChanges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ import {
initializeClusterNotebooks,
moveNoteToFolder,
restoreNotebook,
deleteCreatedFolders,
cleanUpFolders,
} from '../../src/commands/applyNotebooks';
import {
fetchExistingTags,
initializeClusterTags,
applyTagsToNote,
removeTagsFromNote,
deleteCreatedTags,
} from '../../src/commands/applyTags';

jest.mock('api', () => ({
__esModule: true,
Expand Down Expand Up @@ -45,7 +53,7 @@ describe('applyChanges commands', () => {
jest.clearAllMocks();
});

it('applyCategorizationChanges runs auto-cleanup after note moves', async () => {
it('applyCategorizationChanges runs auto-cleanup after note moves in notebooks mode', async () => {
(fetchAllFolders as jest.Mock).mockResolvedValue({
byKey: new Map(),
byId: new Map([['orig-folder-id', { title: 'Orig Folder', parent_id: 'grandparent-id' }]]),
Expand All @@ -69,25 +77,91 @@ describe('applyChanges commands', () => {
expect(cleanUpFolders).toHaveBeenCalledTimes(1);
const calledSet = (cleanUpFolders as jest.Mock).mock.calls[0][0];
expect(calledSet).toEqual(new Set(['orig-folder-id']));
expect(initializeClusterTags).not.toHaveBeenCalled();
expect(applyTagsToNote).not.toHaveBeenCalled();
});

it('applyCategorizationChanges skips cleanup for tags-only method', async () => {
it('applyCategorizationChanges applies tags and skips folder operations in tags mode', async () => {
(fetchExistingTags as jest.Mock).mockResolvedValue(new Map());
(fetchAllFolders as jest.Mock).mockResolvedValue({
byKey: new Map(),
byId: new Map(),
});
(joplin.data.get as jest.Mock).mockResolvedValue({ parent_id: 'orig-folder-id', title: 'Note 1', body: '' });
(initializeClusterTags as jest.Mock).mockResolvedValue({ 0: 'tag-1' });
(applyTagsToNote as jest.Mock).mockResolvedValue(['tag-1', 'tag-kw']);
(joplin.data.get as jest.Mock).mockResolvedValue({
parent_id: 'orig-folder-id',
title: 'Note 1',
body: 'body content',
});

await applyCategorizationChanges(
{ method: 'tags', parentNotebookName: '' },
[{ noteId: 'note-1', title: 'Note 1' }],
[0],
{ 0: 'Cluster 1' },
{},
{ 0: ['tag-kw'] },
jest.fn(),
);

expect(initializeClusterTags).toHaveBeenCalledTimes(1);
expect(applyTagsToNote).toHaveBeenCalledTimes(1);
expect(initializeClusterNotebooks).not.toHaveBeenCalled();
expect(moveNoteToFolder).not.toHaveBeenCalled();
expect(cleanUpFolders).not.toHaveBeenCalled();

const setValueCalls = (joplin.settings.setValue as jest.Mock).mock.calls;
const changeLogCall = setValueCalls.find((call) => call[0] === 'categorization.changeLog');
expect(changeLogCall).toBeDefined();

const changeLog = JSON.parse(changeLogCall[1]);
expect(changeLog.method).toBe('tags');
expect(changeLog.notes[0].addedTagIds).toEqual(['tag-1', 'tag-kw']);
expect(changeLog.notes[0].originalParentId).toBeUndefined();
});

it('applyCategorizationChanges applies both notebooks and tags in both mode', async () => {
(fetchExistingTags as jest.Mock).mockResolvedValue(new Map());
(fetchAllFolders as jest.Mock).mockResolvedValue({
byKey: new Map(),
byId: new Map([['orig-folder-id', { title: 'Orig Folder', parent_id: 'grandparent-id' }]]),
});
(initializeClusterTags as jest.Mock).mockResolvedValue({ 0: 'tag-1' });
(initializeClusterNotebooks as jest.Mock).mockResolvedValue({
folderMap: { 0: 'target-folder-id' },
uncategorizedFolderId: '',
});
(applyTagsToNote as jest.Mock).mockResolvedValue(['tag-1']);
(joplin.data.get as jest.Mock).mockResolvedValue({
parent_id: 'orig-folder-id',
title: 'Note 1',
body: 'body',
});
(moveNoteToFolder as jest.Mock).mockResolvedValue({ originalParentId: 'orig-folder-id', modified: true });

await applyCategorizationChanges(
{ method: 'both', parentNotebookName: '' },
[{ noteId: 'note-1', title: 'Note 1' }],
[0],
{ 0: 'Cluster 1' },
{ 0: [] },
jest.fn(),
);

expect(initializeClusterTags).toHaveBeenCalledTimes(1);
expect(initializeClusterNotebooks).toHaveBeenCalledTimes(1);
expect(applyTagsToNote).toHaveBeenCalledTimes(1);
expect(moveNoteToFolder).toHaveBeenCalledTimes(1);
expect(cleanUpFolders).toHaveBeenCalledTimes(1);

const setValueCalls = (joplin.settings.setValue as jest.Mock).mock.calls;
const changeLogCall = setValueCalls.find((call) => call[0] === 'categorization.changeLog');
expect(changeLogCall).toBeDefined();

const changeLog = JSON.parse(changeLogCall[1]);
expect(changeLog.method).toBe('both');
expect(changeLog.notes[0].addedTagIds).toEqual(['tag-1']);
expect(changeLog.notes[0].originalParentId).toBe('orig-folder-id');
});

it('applyCategorizationChanges stores folder metadata in change log', async () => {
Expand Down Expand Up @@ -178,4 +252,27 @@ describe('applyChanges commands', () => {
false,
);
});

it('undoCategorizationChanges cleanly removes tags without touching folders for tags-only history', async () => {
const mockLog = {
timestamp: Date.now(),
method: 'tags',
notes: [
{
noteId: 'note-1',
addedTagIds: ['created-tag-1', 'created-tag-2'],
},
],
createdFolderIds: [],
createdTagIds: ['created-tag-1', 'created-tag-2'],
};
(joplin.settings.value as jest.Mock).mockResolvedValue(JSON.stringify(mockLog));

await undoCategorizationChanges(jest.fn());

expect(removeTagsFromNote).toHaveBeenCalledWith('note-1', ['created-tag-1', 'created-tag-2']);
expect(deleteCreatedTags).toHaveBeenCalledWith(['created-tag-1', 'created-tag-2']);
expect(restoreNotebook).not.toHaveBeenCalled();
expect(deleteCreatedFolders).not.toHaveBeenCalled();
});
});
Loading
Loading