Skip to content

Commit 43a2e23

Browse files
committed
🧪 Add experimental search scope feature for existing notes (v1.5.0)
- Add flexible search scope options: Sync Directory Only (default), Entire Vault, or Specific Folders - Implement comprehensive duplicate prevention and management tools - Add 'Find Duplicate Notes' button to scan for existing duplicates - Add 'Re-enable Auto-Sync' button for safe auto-sync restart after testing - Create experimental features section with prominent backup warnings - Prevent auto-sync from triggering when changing search scope settings - Add recursive folder search with comprehensive validation - Update documentation with detailed usage instructions and safety guidelines Fixes #4 - Skip sync if file with given granola-id exists anywhere in vault
1 parent 77575c2 commit 43a2e23

File tree

5 files changed

+358
-10
lines changed

5 files changed

+358
-10
lines changed

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [1.5.0]
6+
### Added
7+
- **🧪 Experimental: Search Scope for Existing Notes**: Control where the plugin searches for existing notes when checking for duplicates by granola-id
8+
- **Flexible Search Options**: Choose between "Sync Directory Only" (default), "Entire Vault", or "Specific Folders"
9+
- **Duplicate Prevention Tools**: Added "Find Duplicate Notes" button to scan for and identify existing duplicates
10+
- **Auto-Sync Safety**: New "Re-enable Auto-Sync" button to safely restart auto-sync after testing new settings
11+
- **Enhanced Settings Safety**: Search scope settings now save without triggering auto-sync to prevent accidental duplicates
12+
13+
### Enhanced
14+
- **Experimental Features Section**: Clear UI separation for experimental features with backup warnings
15+
- **User Safety**: Prominent warnings about backing up vault before using experimental features
16+
- **Duplicate Management**: Added comprehensive duplicate detection and management tools
17+
- **Error Prevention**: Auto-sync temporarily disabled when changing search scope settings
18+
19+
### Technical
20+
- **Recursive Folder Search**: Added support for searching all markdown files within specified folders and subfolders
21+
- **Safe Settings Management**: New `saveSettingsWithoutSync()` method to prevent unwanted auto-sync triggers
22+
- **Validation Improvements**: Enhanced folder path validation with user-friendly error messages
23+
- **Search Scope Flexibility**: Infrastructure for different search strategies based on user needs
24+
525
## [1.4.0]
626
### Added
727
- **Customizable Attendee Tag Structure**: New setting to customize how attendee tags are formatted and organized

main.js

Lines changed: 292 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ const DEFAULT_SETTINGS = {
2929
myName: 'Danny McClelland',
3030
includeFolderTags: false,
3131
includeGranolaUrl: false,
32-
attendeeTagTemplate: 'person/{name}'
32+
attendeeTagTemplate: 'person/{name}',
33+
existingNoteSearchScope: 'syncDirectory', // 'syncDirectory', 'entireVault', 'specificFolders'
34+
specificSearchFolders: [] // Array of folder paths to search in when existingNoteSearchScope is 'specificFolders'
3335
};
3436

3537
class GranolaSyncPlugin extends obsidian.Plugin {
@@ -83,6 +85,15 @@ class GranolaSyncPlugin extends obsidian.Plugin {
8385
}
8486
}
8587

88+
async saveSettingsWithoutSync() {
89+
try {
90+
await this.saveData(this.settings);
91+
this.updateRibbonIcon();
92+
} catch (error) {
93+
console.error('Failed to save settings:', error);
94+
}
95+
}
96+
8697
updateRibbonIcon() {
8798
// Remove existing ribbon icon if it exists
8899
if (this.ribbonIconEl) {
@@ -464,15 +475,60 @@ class GranolaSyncPlugin extends obsidian.Plugin {
464475
return filename;
465476
}
466477

478+
/**
479+
* Finds an existing note by its Granola ID based on the configured search scope.
480+
*
481+
* Search scope options:
482+
* - 'syncDirectory' (default): Only searches within the configured sync directory
483+
* - 'entireVault': Searches all markdown files in the vault
484+
* - 'specificFolders': Searches within user-specified folders (including subfolders)
485+
*
486+
* This allows users to move their Granola notes to different folders while still
487+
* avoiding duplicates when "Skip Existing Notes" is enabled.
488+
*
489+
* @param {string} granolaId - The Granola ID to search for
490+
* @returns {TFile|null} The found file or null if not found
491+
*/
467492
async findExistingNoteByGranolaId(granolaId) {
468-
const folder = this.app.vault.getAbstractFileByPath(this.settings.syncDirectory);
469-
if (!folder || !(folder instanceof obsidian.TFolder)) {
470-
return null;
493+
let filesToSearch = [];
494+
495+
if (this.settings.existingNoteSearchScope === 'entireVault') {
496+
// Search all markdown files in the vault
497+
filesToSearch = this.app.vault.getMarkdownFiles();
498+
console.log('Searching for existing note with granola-id', granolaId, 'across entire vault (' + filesToSearch.length + ' files)');
499+
} else if (this.settings.existingNoteSearchScope === 'specificFolders') {
500+
// Search in specific folders
501+
console.log('Searching for existing note with granola-id', granolaId, 'in specific folders:', this.settings.specificSearchFolders);
502+
503+
if (this.settings.specificSearchFolders.length === 0) {
504+
console.log('Warning: No specific folders configured for search. No files will be searched.');
505+
return null;
506+
}
507+
508+
for (const folderPath of this.settings.specificSearchFolders) {
509+
const folder = this.app.vault.getAbstractFileByPath(folderPath);
510+
if (folder && folder instanceof obsidian.TFolder) {
511+
const folderFiles = this.getAllMarkdownFilesInFolder(folder);
512+
filesToSearch = filesToSearch.concat(folderFiles);
513+
console.log('Found', folderFiles.length, 'files in folder:', folderPath);
514+
} else {
515+
console.log('Folder not found or not a folder:', folderPath);
516+
}
517+
}
518+
console.log('Total files to search:', filesToSearch.length);
519+
} else {
520+
// Default: search only in sync directory
521+
console.log('Searching for existing note with granola-id', granolaId, 'in sync directory only:', this.settings.syncDirectory);
522+
const folder = this.app.vault.getAbstractFileByPath(this.settings.syncDirectory);
523+
if (!folder || !(folder instanceof obsidian.TFolder)) {
524+
console.log('Sync directory not found:', this.settings.syncDirectory);
525+
return null;
526+
}
527+
filesToSearch = folder.children.filter(file => file instanceof obsidian.TFile && file.extension === 'md');
528+
console.log('Found', filesToSearch.length, 'files in sync directory');
471529
}
472530

473-
const files = folder.children.filter(file => file instanceof obsidian.TFile && file.extension === 'md');
474-
475-
for (const file of files) {
531+
for (const file of filesToSearch) {
476532
try {
477533
const content = await this.app.vault.read(file);
478534
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
@@ -482,6 +538,7 @@ class GranolaSyncPlugin extends obsidian.Plugin {
482538
const granolaIdMatch = frontmatter.match(/granola_id:\s*(.+)$/m);
483539

484540
if (granolaIdMatch && granolaIdMatch[1].trim() === granolaId) {
541+
console.log('Found existing note with granola-id', granolaId, 'at:', file.path);
485542
return file;
486543
}
487544
}
@@ -490,9 +547,127 @@ class GranolaSyncPlugin extends obsidian.Plugin {
490547
}
491548
}
492549

550+
console.log('No existing note found with granola-id:', granolaId);
493551
return null;
494552
}
495553

554+
getAllMarkdownFilesInFolder(folder) {
555+
let files = [];
556+
557+
// Safety check - ensure folder exists and has children
558+
if (!folder || !folder.children) {
559+
return files;
560+
}
561+
562+
// Add direct children that are markdown files
563+
const directFiles = folder.children.filter(file => file instanceof obsidian.TFile && file.extension === 'md');
564+
files = files.concat(directFiles);
565+
566+
// Recursively add files from subfolders
567+
const subfolders = folder.children.filter(child => child instanceof obsidian.TFolder);
568+
for (const subfolder of subfolders) {
569+
const subfolderFiles = this.getAllMarkdownFilesInFolder(subfolder);
570+
files = files.concat(subfolderFiles);
571+
}
572+
573+
return files;
574+
}
575+
576+
/**
577+
* Gets all folder paths in the vault (useful for future UI improvements)
578+
* @returns {string[]} Array of folder paths
579+
*/
580+
getAllFolderPaths() {
581+
const folders = [];
582+
const allFolders = this.app.vault.getAllLoadedFiles().filter(file => file instanceof obsidian.TFolder);
583+
584+
for (const folder of allFolders) {
585+
folders.push(folder.path);
586+
}
587+
588+
return folders.sort();
589+
}
590+
591+
async findDuplicateNotes() {
592+
try {
593+
console.log('Searching for duplicate Granola notes...');
594+
595+
// Get all markdown files in the vault
596+
const allFiles = this.app.vault.getMarkdownFiles();
597+
const granolaFiles = {};
598+
const duplicates = [];
599+
600+
// Check each file for granola-id
601+
for (const file of allFiles) {
602+
try {
603+
const content = await this.app.vault.read(file);
604+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
605+
606+
if (frontmatterMatch) {
607+
const frontmatter = frontmatterMatch[1];
608+
const granolaIdMatch = frontmatter.match(/granola_id:\s*(.+)$/m);
609+
610+
if (granolaIdMatch) {
611+
const granolaId = granolaIdMatch[1].trim();
612+
613+
if (granolaFiles[granolaId]) {
614+
// Found a duplicate
615+
if (!duplicates.some(d => d.granolaId === granolaId)) {
616+
duplicates.push({
617+
granolaId: granolaId,
618+
files: [granolaFiles[granolaId], file]
619+
});
620+
} else {
621+
// Add to existing duplicate group
622+
const duplicate = duplicates.find(d => d.granolaId === granolaId);
623+
duplicate.files.push(file);
624+
}
625+
} else {
626+
granolaFiles[granolaId] = file;
627+
}
628+
}
629+
}
630+
} catch (error) {
631+
console.error('Error reading file:', file.path, error);
632+
}
633+
}
634+
635+
if (duplicates.length === 0) {
636+
new obsidian.Notice('No duplicate Granola notes found! 🎉');
637+
} else {
638+
console.log('Found', duplicates.length, 'sets of duplicate notes');
639+
640+
// Create a summary message
641+
let message = `Found ${duplicates.length} set(s) of duplicate Granola notes:\n\n`;
642+
643+
for (const duplicate of duplicates) {
644+
message += `Granola ID: ${duplicate.granolaId}\n`;
645+
for (const file of duplicate.files) {
646+
message += ` • ${file.path}\n`;
647+
}
648+
message += '\n';
649+
}
650+
651+
message += 'Check the console for full details. You can manually delete the duplicates you don\'t want to keep.';
652+
653+
new obsidian.Notice(message, 10000); // Show for 10 seconds
654+
655+
// Also log detailed information to console
656+
console.log('Duplicate Granola notes found:');
657+
for (const duplicate of duplicates) {
658+
console.log(`Granola ID: ${duplicate.granolaId}`);
659+
for (const file of duplicate.files) {
660+
console.log(` - ${file.path}`);
661+
}
662+
}
663+
}
664+
665+
} catch (error) {
666+
console.error('Error finding duplicate notes:', error);
667+
new obsidian.Notice('Error finding duplicate notes. Check console for details.');
668+
}
669+
}
670+
496671
async processDocument(doc) {
497672
try {
498673
const title = doc.title || 'Untitled Granola Note';
@@ -1251,6 +1426,95 @@ class GranolaSyncSettingTab extends obsidian.PluginSettingTab {
12511426
});
12521427
});
12531428

1429+
// Create experimental section header
1430+
containerEl.createEl('h4', { text: '🧪 Experimental Features' });
1431+
1432+
const experimentalWarning = containerEl.createEl('div', { cls: 'setting-item' });
1433+
experimentalWarning.createEl('div', { cls: 'setting-item-info' });
1434+
const warningNameEl = experimentalWarning.createEl('div', { cls: 'setting-item-name' });
1435+
warningNameEl.setText('⚠️ Please Backup Your Vault');
1436+
const warningDescEl = experimentalWarning.createEl('div', { cls: 'setting-item-description' });
1437+
warningDescEl.setText('The features below are experimental and may create duplicate notes if not used carefully. Please backup your vault before changing these settings.');
1438+
warningDescEl.style.color = 'var(--text-error)';
1439+
warningDescEl.style.fontSize = '0.9em';
1440+
warningDescEl.style.fontWeight = 'bold';
1441+
1442+
new obsidian.Setting(containerEl)
1443+
.setName('Search Scope for Existing Notes')
1444+
.setDesc('Choose where to search for existing notes when checking granola-id. "Sync Directory Only" (default) only checks the configured sync folder. "Entire Vault" allows you to move notes anywhere in your vault. "Specific Folders" lets you choose which folders to search.')
1445+
.addDropdown(dropdown => {
1446+
dropdown.addOption('syncDirectory', 'Sync Directory Only (Default)');
1447+
dropdown.addOption('entireVault', 'Entire Vault');
1448+
dropdown.addOption('specificFolders', 'Specific Folders');
1449+
1450+
dropdown.setValue(this.plugin.settings.existingNoteSearchScope);
1451+
dropdown.onChange(async (value) => {
1452+
const oldValue = this.plugin.settings.existingNoteSearchScope;
1453+
this.plugin.settings.existingNoteSearchScope = value;
1454+
1455+
// Save settings without triggering auto-sync to prevent duplicates
1456+
await this.plugin.saveSettingsWithoutSync();
1457+
1458+
// Show warning if search scope changed
1459+
if (oldValue !== value) {
1460+
new obsidian.Notice('Search scope changed. Consider running a manual sync to test the new settings before relying on auto-sync.');
1461+
}
1462+
1463+
this.display(); // Refresh the settings display
1464+
});
1465+
});
1466+
1467+
// Show folder selection only when 'specificFolders' is selected
1468+
if (this.plugin.settings.existingNoteSearchScope === 'specificFolders') {
1469+
new obsidian.Setting(containerEl)
1470+
.setName('Specific Search Folders')
1471+
.setDesc('Enter folder paths to search (one per line). Leave empty to search all folders.')
1472+
.addTextArea(text => {
1473+
text.setPlaceholder('Examples:\nMeetings\nProjects/Work\nDaily Notes');
1474+
text.setValue(this.plugin.settings.specificSearchFolders.join('\n'));
1475+
1476+
// Save settings immediately on change (without validation and without auto-sync)
1477+
text.onChange(async (value) => {
1478+
const folders = value.split('\n').map(f => f.trim()).filter(f => f.length > 0);
1479+
this.plugin.settings.specificSearchFolders = folders;
1480+
await this.plugin.saveSettingsWithoutSync();
1481+
});
1482+
1483+
// Validate only when user finishes editing (on blur)
1484+
text.inputEl.addEventListener('blur', () => {
1485+
const value = text.getValue();
1486+
const folders = value.split('\n').map(f => f.trim()).filter(f => f.length > 0);
1487+
1488+
if (folders.length === 0) {
1489+
return; // Don't validate if no folders specified
1490+
}
1491+
1492+
// Validate folder paths
1493+
const invalidFolders = [];
1494+
for (const folderPath of folders) {
1495+
const folder = this.app.vault.getAbstractFileByPath(folderPath);
1496+
if (!folder || !(folder instanceof obsidian.TFolder)) {
1497+
invalidFolders.push(folderPath);
1498+
}
1499+
}
1500+
1501+
if (invalidFolders.length > 0) {
1502+
new obsidian.Notice('Warning: These folders do not exist: ' + invalidFolders.join(', '));
1503+
}
1504+
});
1505+
});
1506+
}
1507+
1508+
// Add info section about avoiding duplicates
1509+
const infoEl = containerEl.createEl('div', { cls: 'setting-item' });
1510+
infoEl.createEl('div', { cls: 'setting-item-info' });
1511+
const infoNameEl = infoEl.createEl('div', { cls: 'setting-item-name' });
1512+
infoNameEl.setText('⚠️ Avoiding Duplicates');
1513+
const infoDescEl = infoEl.createEl('div', { cls: 'setting-item-description' });
1514+
infoDescEl.setText('When changing search scope, existing notes in other locations won\'t be found and may be recreated. To avoid duplicates: 1) Move your existing notes to the new search location first, or 2) Use "Entire Vault" to search everywhere, or 3) Run a manual sync after changing settings to test before auto-sync runs.');
1515+
infoDescEl.style.color = 'var(--text-muted)';
1516+
infoDescEl.style.fontSize = '0.9em';
1517+
12541518
// Create a heading for metadata settings
12551519
containerEl.createEl('h3', { text: 'Note Metadata & Tags' });
12561520

@@ -1378,6 +1642,27 @@ class GranolaSyncSettingTab extends obsidian.PluginSettingTab {
13781642
await this.plugin.syncNotes();
13791643
});
13801644
});
1645+
1646+
new obsidian.Setting(containerEl)
1647+
.setName('Find Duplicate Notes')
1648+
.setDesc('Find and list notes with the same granola-id (helpful after changing search scope settings)')
1649+
.addButton(button => {
1650+
button.setButtonText('Find Duplicates');
1651+
button.onClick(async () => {
1652+
await this.plugin.findDuplicateNotes();
1653+
});
1654+
});
1655+
1656+
new obsidian.Setting(containerEl)
1657+
.setName('Re-enable Auto-Sync')
1658+
.setDesc('Re-enable auto-sync after testing new search scope settings (this will restart the auto-sync timer)')
1659+
.addButton(button => {
1660+
button.setButtonText('Re-enable Auto-Sync');
1661+
button.onClick(async () => {
1662+
await this.plugin.saveSettings(); // This will call setupAutoSync()
1663+
new obsidian.Notice('Auto-sync re-enabled with current settings');
1664+
});
1665+
});
13811666
}
13821667
}
13831668

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "granola-sync",
33
"name": "Granola Sync",
4-
"version": "1.4.0",
4+
"version": "1.5.0",
55
"minAppVersion": "0.15.0",
66
"description": "Sync your Granola AI notes to your vault with customizable settings.",
77
"author": "Danny McClelland",

0 commit comments

Comments
 (0)