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
303 changes: 59 additions & 244 deletions src/commands/applyChanges.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import joplin from 'api';
import { ApplyOptions, PanelNote, PanelMessage } from '../types/panel';
import { log } from '../utils/logger';
import {
fetchExistingTags,
initializeClusterTags,
applyTagsToNote,
removeTagsFromNote,
deleteCreatedTags,
} from './applyTags';
import {
fetchExistingFolders,
initializeClusterNotebooks,
moveNoteToFolder,
restoreNotebook,
deleteCreatedFolders,
cleanUpFolders,
} from './applyNotebooks';

export interface ChangeLogEntry {
timestamp: number;
Expand All @@ -15,21 +30,6 @@ export interface ChangeLogEntry {
createdTagIds?: string[];
}

function matchesKeyword(title: string, body: string, keyword: string): boolean {
const lowerKeyword = keyword.toLowerCase();
const lowerTitle = title.toLowerCase();
const lowerBody = body.toLowerCase();

try {
const escaped = lowerKeyword.replace(/[-\x2f\\^$*+?.()|[\]{}]/g, '\\$&');
// Unicode-aware word boundary matching
const regex = new RegExp(`(?:^|[^\\p{L}\\p{N}])` + escaped + `(?:$|[^\\p{L}\\p{N}])`, 'u');
return regex.test(lowerTitle) || regex.test(lowerBody);
} catch (e) {
return lowerTitle.includes(lowerKeyword) || lowerBody.includes(lowerKeyword);
}
}

export async function applyCategorizationChanges(
options: ApplyOptions,
notes: PanelNote[],
Expand All @@ -41,104 +41,33 @@ export async function applyCategorizationChanges(
try {
setPanelState({ type: 'apply_status', text: 'Fetching existing folders and tags...' });

const MAX_PAGES = 500;

// Fetch all existing tags once to map titles to IDs
const allTags: any[] = [];
let tagPage = 1;
while (tagPage <= MAX_PAGES) {
const res = await joplin.data.get(['tags'], { page: tagPage, limit: 100, fields: ['id', 'title'] });
allTags.push(...res.items);
if (!res.has_more) break;
tagPage++;
}
const existingTagsMap = new Map<string, string>(allTags.map((t: any) => [t.title.toLowerCase(), t.id]));

// Fetch all folders once to map titles and parent_ids to IDs
const allFoldersList: any[] = [];
let folderPage = 1;
while (folderPage <= MAX_PAGES) {
const res = await joplin.data.get(['folders'], {
page: folderPage,
limit: 100,
fields: ['id', 'title', 'parent_id'],
});
allFoldersList.push(...res.items);
if (!res.has_more) break;
folderPage++;
}
const existingFoldersMap = new Map<string, string>(
allFoldersList.map((f: any) => [`${f.title.toLowerCase()}\x1F${f.parent_id || ''}`, f.id]),
);

// Optimized helper to get or create tag
const getOrCreateTagOptimized = async (title: string): Promise<{ id: string; created: boolean }> => {
const lowerTitle = title.toLowerCase();
const cachedId = existingTagsMap.get(lowerTitle);
if (cachedId) {
return { id: cachedId, created: false };
}
const created = await joplin.data.post(['tags'], null, { title });
existingTagsMap.set(lowerTitle, created.id);
return { id: created.id, created: true };
};

// Optimized helper to get or create folder
const getOrCreateFolderOptimized = async (
title: string,
parentId = '',
): Promise<{ id: string; created: boolean }> => {
const key = `${title.toLowerCase()}\x1F${parentId}`;
const cachedId = existingFoldersMap.get(key);
if (cachedId) {
return { id: cachedId, created: false };
}
const created = await joplin.data.post(['folders'], null, {
title,
parent_id: parentId || undefined,
});
existingFoldersMap.set(key, created.id);
return { id: created.id, created: true };
};
// Fetch existing items using modular helpers
const existingTagsMap = await fetchExistingTags();
const existingFoldersMap = await fetchExistingFolders();

const uniqueClusterIds = Array.from(new Set(assignments.filter((id) => id >= 0)));

// 1. Create/retrieve tags if needed
const tagMap: { [clusterId: number]: string } = {};
const createdTagIds: string[] = [];
let tagMap: { [clusterId: number]: string } = {};
if (options.method === 'tags' || options.method === 'both') {
for (const clusterId of uniqueClusterIds) {
const clusterName = clusterNames[clusterId] || `Cluster ${clusterId + 1}`;
const tagName = clusterName;
const { id: tagId, created } = await getOrCreateTagOptimized(tagName);
tagMap[clusterId] = tagId;
if (created) {
createdTagIds.push(tagId);
}
}
tagMap = await initializeClusterTags(uniqueClusterIds, clusterNames, existingTagsMap, createdTagIds);
}

// 2. Create folders if needed
const folderMap: { [clusterId: number]: string } = {};
const createdFolderIds: string[] = [];
let folderMap: { [clusterId: number]: string } = {};
let uncategorizedFolderId = '';
if (options.method === 'notebooks' || options.method === 'both') {
for (const clusterId of uniqueClusterIds) {
const clusterName = clusterNames[clusterId] || `Cluster ${clusterId + 1}`;
const { id: childFolderId, created } = await getOrCreateFolderOptimized(clusterName);
folderMap[clusterId] = childFolderId;
if (created) {
createdFolderIds.push(childFolderId);
}
}

if (assignments.includes(-1)) {
const { id: noiseFolderId, created } = await getOrCreateFolderOptimized('Uncategorized');
uncategorizedFolderId = noiseFolderId;
if (created) {
createdFolderIds.push(noiseFolderId);
}
}
const initFolders = await initializeClusterNotebooks(
uniqueClusterIds,
clusterNames,
assignments,
existingFoldersMap,
createdFolderIds,
);
folderMap = initFolders.folderMap;
uncategorizedFolderId = initFolders.uncategorizedFolderId;
}

// 3. Process notes & build change log
Expand Down Expand Up @@ -183,67 +112,37 @@ export async function applyCategorizationChanges(
continue;
}

// Handle tags
// Handle tags using modular helper
if ((options.method === 'tags' || options.method === 'both') && clusterId >= 0) {
const addedTagIds: string[] = [];

// Apply the main cluster name tag (as grouping tag)
const mainTagId = tagMap[clusterId];
if (mainTagId) {
try {
await joplin.data.post(['tags', mainTagId, 'notes'], null, { id: note.noteId });
addedTagIds.push(mainTagId);
} catch (tagErr) {
log(`Tag ${mainTagId} may already be on note ${note.noteId}: ${tagErr}`);
}
}

// Get all extracted specific tags for this cluster
const specificTags = clusterTags[clusterId] || [];
for (const tagText of specificTags) {
// Don't duplicate the main cluster tag if it's already applied
const clusterName = clusterNames[clusterId] || `Cluster ${clusterId + 1}`;
if (tagText.toLowerCase() === clusterName.toLowerCase()) {
continue;
}

if (matchesKeyword(noteTitle, noteBody, tagText)) {
const { id: tagId, created } = await getOrCreateTagOptimized(tagText);
try {
await joplin.data.post(['tags', tagId, 'notes'], null, { id: note.noteId });
addedTagIds.push(tagId);
if (created) {
createdTagIds.push(tagId);
}
} catch (tagErr) {
log(`Tag ${tagId} may already be on note ${note.noteId}: ${tagErr}`);
}
}
}

const addedTagIds = await applyTagsToNote(
note,
clusterId,
noteTitle,
noteBody,
clusterNames,
clusterTags,
tagMap,
existingTagsMap,
createdTagIds,
);
if (addedTagIds.length > 0) {
changeEntry.addedTagIds = addedTagIds;
modified = true;
}
}

// Handle folders
// Handle folders using modular helper
if (options.method === 'notebooks' || options.method === 'both') {
let targetFolderId = '';
if (clusterId >= 0) {
targetFolderId = folderMap[clusterId];
} else if (clusterId === -1 && uncategorizedFolderId) {
targetFolderId = uncategorizedFolderId;
}

if (targetFolderId && targetFolderId !== originalParentId) {
try {
await joplin.data.put(['notes', note.noteId], null, { parent_id: targetFolderId });
changeEntry.originalParentId = originalParentId;
modified = true;
} catch (moveErr) {
log(`Failed to move note ${note.noteId} to folder ${targetFolderId}: ${moveErr}`);
}
const folderResult = await moveNoteToFolder(
note.noteId,
clusterId,
originalParentId,
folderMap,
uncategorizedFolderId,
);
if (folderResult.modified) {
changeEntry.originalParentId = folderResult.originalParentId;
modified = true;
}
}

Expand Down Expand Up @@ -296,33 +195,17 @@ export async function undoCategorizationChanges(setPanelState: (state: PanelMess

// Remove added tag from note (old format)
if (entry.addedTagId) {
try {
await joplin.data.delete(['tags', entry.addedTagId, 'notes', entry.noteId]);
} catch (tagErr) {
log(`Undo: tag ${entry.addedTagId} removal failed for note ${entry.noteId}: ${tagErr}`);
}
await removeTagsFromNote(entry.noteId, [entry.addedTagId]);
}

// Remove added tags from note (new format)
if (entry.addedTagIds) {
for (const tagId of entry.addedTagIds) {
try {
await joplin.data.delete(['tags', tagId, 'notes', entry.noteId]);
} catch (tagErr) {
log(`Undo: tag ${tagId} removal failed for note ${entry.noteId}: ${tagErr}`);
}
}
await removeTagsFromNote(entry.noteId, entry.addedTagIds);
}

// Restore parent notebook
if (entry.originalParentId) {
try {
await joplin.data.put(['notes', entry.noteId], null, { parent_id: entry.originalParentId });
} catch (folderErr) {
log(
`Undo: restoring folder ${entry.originalParentId} failed for note ${entry.noteId}: ${folderErr}`,
);
}
await restoreNotebook(entry.noteId, entry.originalParentId);
}

setPanelState({
Expand All @@ -335,52 +218,13 @@ export async function undoCategorizationChanges(setPanelState: (state: PanelMess
// 2. Delete created folders if they exist (check for notes AND subfolders)
if (changeLog.createdFolderIds && changeLog.createdFolderIds.length > 0) {
setPanelState({ type: 'undo_status', text: 'Deleting created notebooks...' });

// Fetch all folders once to check for subfolders in memory
const undoAllFolders: any[] = [];
let undoPage = 1;
while (undoPage <= 500) {
const res = await joplin.data.get(['folders'], {
page: undoPage,
limit: 100,
fields: ['id', 'parent_id'],
});
undoAllFolders.push(...res.items);
if (!res.has_more) break;
undoPage++;
}
const undoParentIds = new Set<string>(undoAllFolders.map((f: any) => f.parent_id).filter((pid) => !!pid));

for (const folderId of changeLog.createdFolderIds) {
try {
// Skip if folder has subfolders
if (undoParentIds.has(folderId)) {
log(`Undo: skipping folder ${folderId} — has subfolders`);
continue;
}
// Skip if folder still has notes
const notesInFolder = await joplin.data.get(['folders', folderId, 'notes'], { limit: 1 });
if (notesInFolder.items.length > 0) {
log(`Undo: skipping non-empty folder ${folderId}`);
continue;
}
await joplin.data.delete(['folders', folderId]);
} catch (folderErr) {
log(`Undo: failed to delete created folder ${folderId}: ${folderErr}`);
}
}
await deleteCreatedFolders(changeLog.createdFolderIds);
}

// 3. Delete created tags if they exist
if (changeLog.createdTagIds && changeLog.createdTagIds.length > 0) {
setPanelState({ type: 'undo_status', text: 'Deleting created tags...' });
for (const tagId of changeLog.createdTagIds) {
try {
await joplin.data.delete(['tags', tagId]);
} catch (tagErr) {
log(`Undo: failed to delete created tag ${tagId}: ${tagErr}`);
}
}
await deleteCreatedTags(changeLog.createdTagIds);
}

// Clear change log
Expand Down Expand Up @@ -419,36 +263,7 @@ export async function cleanUpEmptyNotebooks(setPanelState: (state: PanelMessage)
return;
}

let deletedCount = 0;

// Fetch all folders once to map parent-child relationships in memory
const allFolders: any[] = [];
let page = 1;
while (page <= 500) {
const res = await joplin.data.get(['folders'], { page, limit: 100, fields: ['id', 'parent_id'] });
allFolders.push(...res.items);
if (!res.has_more) break;
page++;
}
const parentFolderIds = new Set<string>(allFolders.map((f: any) => f.parent_id).filter((pid) => !!pid));

for (const folderId of originalParentIds) {
try {
// 1. Check if it has subfolders in memory
if (parentFolderIds.has(folderId)) {
continue;
}

// 2. Check notes count in this folder
const notesInFolder = await joplin.data.get(['folders', folderId, 'notes'], { limit: 1 });
if (notesInFolder.items.length === 0) {
await joplin.data.delete(['folders', folderId]);
deletedCount++;
}
} catch (err) {
log(`Cleanup: failed to check or delete folder ${folderId}: ${err}`);
}
}
const deletedCount = await cleanUpFolders(originalParentIds);

setPanelState({
type: 'cleanup_complete',
Expand Down
Loading
Loading