@@ -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
3537class 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 ( / g r a n o l a _ i d : \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 ( / g r a n o l a _ i d : \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
0 commit comments