import { App, Editor, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, TFile, EventRef } from "obsidian"; import { addIcons } from 'src/ui/icon'; import { i18nConfig } from "src/lang/I18n"; import ribbonCommands from "src/commands/NotionCommands"; import { ObsidianSettingTab, PluginSettings, DEFAULT_SETTINGS, DatabaseDetails } from "src/ui/settingTabs"; import { uploadCommandNext, uploadCommandGeneral, uploadCommandCustom } from "src/upload/uploadCommand"; import { DEFAULT_AUTO_SYNC_DATABASE_KEY, parseAutoSyncDatabaseList, resolveAutoSyncKey } from "src/utils/frontmatter"; // Remember to rename these classes and interfaces! export default class ObsidianSyncNotionPlugin extends Plugin { settings: PluginSettings; commands: ribbonCommands; app: App; modifyEventRef: EventRef | null = null; autoSyncTimeout: number | null = null; private syncingFiles: Set = new Set(); private lastFrontmatterCache: Map = new Map(); private lastContentHashCache: Map = new Map(); async onload() { await this.loadSettings(); // Log loaded settings for debugging console.log('[Plugin] Loaded settings:', { autoSync: this.settings.autoSync, autoSyncDelay: this.settings.autoSyncDelay, NotionLinkDisplay: this.settings.NotionLinkDisplay, bannerUrl: this.settings.bannerUrl ? 'set' : 'empty', notionUser: this.settings.notionUser ? 'set' : 'empty', databaseCount: Object.keys(this.settings.databaseDetails || {}).length }); this.commands = new ribbonCommands(this); addIcons(); // This creates an icon in the left ribbon. this.addRibbonIcon( "notion-logo", i18nConfig.ribbonIcon, async (evt: MouseEvent) => { // Called when the user clicks the icon. // await this.uploadCommand(); await this.commands.ribbonDisplay(); } ); // This adds a status bar item to the bottom of the app. Does not work on mobile apps. // const statusBarItemEl = this.addStatusBarItem(); // // statusBarItemEl.setText("share to notion"); // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new ObsidianSettingTab(this.app, this)); // Setup auto sync listener this.setupAutoSync(); } onunload() { if (this.modifyEventRef) { this.app.vault.offref(this.modifyEventRef); } if (this.autoSyncTimeout) { clearTimeout(this.autoSyncTimeout); } this.lastFrontmatterCache.clear(); this.lastContentHashCache.clear(); } async loadSettings() { const loadedData = await this.loadData(); // Merge loaded data with defaults, ensuring all fields exist this.settings = Object.assign( {}, DEFAULT_SETTINGS, loadedData || {} ); // Ensure critical fields have valid values if (typeof this.settings.autoSync !== 'boolean') { this.settings.autoSync = DEFAULT_SETTINGS.autoSync; } if (typeof this.settings.autoSyncDelay !== 'number' || this.settings.autoSyncDelay < 2) { this.settings.autoSyncDelay = DEFAULT_SETTINGS.autoSyncDelay; } if (typeof this.settings.NotionLinkDisplay !== 'boolean') { this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay; } if (typeof this.settings.autoCopyNotionLink !== 'boolean') { this.settings.autoCopyNotionLink = DEFAULT_SETTINGS.autoCopyNotionLink; } if (typeof this.settings.autoSyncFrontmatterKey !== 'string') { this.settings.autoSyncFrontmatterKey = DEFAULT_AUTO_SYNC_DATABASE_KEY; } this.settings.autoSyncFrontmatterKey = resolveAutoSyncKey(this.settings.autoSyncFrontmatterKey); // Ensure databaseDetails exists if (!this.settings.databaseDetails || typeof this.settings.databaseDetails !== 'object') { this.settings.databaseDetails = {}; } // Save settings if any migration was needed const needsSave = !loadedData || loadedData.autoSync === undefined || loadedData.autoSyncDelay === undefined || loadedData.NotionLinkDisplay === undefined || loadedData.autoCopyNotionLink === undefined || loadedData.autoSyncFrontmatterKey === undefined; if (needsSave) { const migratedFields = []; if (!loadedData) { console.log('[Settings] First-time setup, creating default settings'); } else { if (loadedData.autoSync === undefined) migratedFields.push('autoSync'); if (loadedData.autoSyncDelay === undefined) migratedFields.push('autoSyncDelay'); if (loadedData.NotionLinkDisplay === undefined) migratedFields.push('NotionLinkDisplay'); if (loadedData.autoCopyNotionLink === undefined) migratedFields.push('autoCopyNotionLink'); console.log('[Settings] Migrating settings, adding fields:', migratedFields.join(', ')); } await this.saveSettings(); // Notify user about settings migration (only for existing users, not first-time setup) if (loadedData && Object.keys(loadedData).length > 0 && migratedFields.length > 0) { new Notice(i18nConfig.SettingsMigrated, 6000); console.log('[Settings] Migration notice shown to user'); } } } async saveSettings() { // Validate settings before saving this.validateSettings(); await this.saveData(this.settings); console.log('[Settings] Settings saved successfully', { autoSync: this.settings.autoSync, autoSyncDelay: this.settings.autoSyncDelay, NotionLinkDisplay: this.settings.NotionLinkDisplay, autoSyncFrontmatterKey: this.settings.autoSyncFrontmatterKey, databaseCount: Object.keys(this.settings.databaseDetails || {}).length }); } validateSettings() { // Ensure all required fields have valid values if (typeof this.settings.autoSync !== 'boolean') { console.warn('[Settings] Invalid autoSync value, resetting to default'); this.settings.autoSync = DEFAULT_SETTINGS.autoSync; } if (typeof this.settings.autoSyncDelay !== 'number' || this.settings.autoSyncDelay < 2) { console.warn('[Settings] Invalid autoSyncDelay value, resetting to default'); this.settings.autoSyncDelay = DEFAULT_SETTINGS.autoSyncDelay; } if (typeof this.settings.NotionLinkDisplay !== 'boolean') { console.warn('[Settings] Invalid NotionLinkDisplay value, resetting to default'); this.settings.NotionLinkDisplay = DEFAULT_SETTINGS.NotionLinkDisplay; } if (typeof this.settings.autoCopyNotionLink !== 'boolean') { console.warn('[Settings] Invalid autoCopyNotionLink value, resetting to default'); this.settings.autoCopyNotionLink = DEFAULT_SETTINGS.autoCopyNotionLink; } if (!this.settings.databaseDetails || typeof this.settings.databaseDetails !== 'object') { console.warn('[Settings] Invalid databaseDetails, resetting to empty object'); this.settings.databaseDetails = {}; } if (typeof this.settings.autoSyncFrontmatterKey !== 'string' || this.settings.autoSyncFrontmatterKey.trim().length === 0) { console.warn('[Settings] Invalid autoSyncFrontmatterKey, resetting to default'); this.settings.autoSyncFrontmatterKey = DEFAULT_AUTO_SYNC_DATABASE_KEY; } else { this.settings.autoSyncFrontmatterKey = resolveAutoSyncKey(this.settings.autoSyncFrontmatterKey); } } getAutoSyncFrontmatterKey(): string { return resolveAutoSyncKey(this.settings.autoSyncFrontmatterKey); } async addDatabaseDetails(dbDetails: DatabaseDetails) { this.settings.databaseDetails = { ...this.settings.databaseDetails, [dbDetails.abName]: dbDetails, }; await this.saveSettings(); } async deleteDatabaseDetails(dbDetails: DatabaseDetails) { delete this.settings.databaseDetails[dbDetails.abName]; await this.saveSettings(); } async updateDatabaseDetails(dbDetails: DatabaseDetails) { // delete the old database details delete this.settings.databaseDetails[dbDetails.abName]; this.settings.databaseDetails = { ...this.settings.databaseDetails, [dbDetails.abName]: dbDetails, }; await this.saveSettings(); } setupAutoSync() { // Remove existing listener if any if (this.modifyEventRef) { this.app.vault.offref(this.modifyEventRef); } // Only setup if autoSync is enabled if (!this.settings.autoSync) { return; } // Listen for file modifications this.modifyEventRef = this.app.vault.on('modify', async (file: TFile) => { // Only process markdown files if (!(file instanceof TFile) || file.extension !== 'md') { return; } // Debounce: clear existing timeout if (this.autoSyncTimeout) { clearTimeout(this.autoSyncTimeout); } // Set a new timeout to trigger sync after user-configured delay (in seconds) const delayMs = (this.settings.autoSyncDelay || 2) * 1000; this.autoSyncTimeout = window.setTimeout(async () => { await this.autoSyncFile(file); }, delayMs); }); } onlyNotionIDChanged(oldFrontmatter: any, newFrontmatter: any): boolean { // Get all keys from both frontmatters const oldKeys = Object.keys(oldFrontmatter || {}); const newKeys = Object.keys(newFrontmatter || {}); // Filter out NotionID-related keys and Obsidian internal keys const isIgnoredKey = (key: string) => { return key.startsWith('NotionID-') || key === 'NotionID' || key === 'position'; // Obsidian's internal metadata }; const oldNonNotionKeys = oldKeys.filter(k => !isIgnoredKey(k)).sort(); const newNonNotionKeys = newKeys.filter(k => !isIgnoredKey(k)).sort(); // If number of non-NotionID keys changed, something else changed if (oldNonNotionKeys.length !== newNonNotionKeys.length) { console.log('[AutoSync] Frontmatter: Key count changed:', oldNonNotionKeys.length, '->', newNonNotionKeys.length); return false; } // Check if any non-NotionID key values changed for (const key of oldNonNotionKeys) { if (!newNonNotionKeys.includes(key)) { console.log('[AutoSync] Frontmatter: Key removed or added:', key); return false; // Key was removed or added } // Deep comparison for the value const oldValue = JSON.stringify(oldFrontmatter[key]); const newValue = JSON.stringify(newFrontmatter[key]); if (oldValue !== newValue) { console.log('[AutoSync] Frontmatter: Value changed for key "' + key + '"'); console.log(' Old:', oldValue.substring(0, 100)); console.log(' New:', newValue.substring(0, 100)); return false; // Value changed } } // Check if any new non-NotionID keys were added for (const key of newNonNotionKeys) { if (!oldNonNotionKeys.includes(key)) { console.log('[AutoSync] Frontmatter: New key added:', key); return false; // New key was added } } // Only NotionID fields changed (or nothing in frontmatter changed) return true; } simpleHash(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return hash.toString(); } async autoSyncFile(file: TFile) { // Check if file is already being synced if (this.syncingFiles.has(file.path)) { console.log(`[AutoSync] File ${file.path} is already being synced, skipping`); return; } try { this.syncingFiles.add(file.path); // Get file's frontmatter const frontMatter = this.app.metadataCache.getFileCache(file)?.frontmatter; if (!frontMatter) { console.log(`[AutoSync] No frontmatter found in ${file.path}`); return; } // Get file content hash for comparison const content = await this.app.vault.read(file); const contentHash = this.simpleHash(content); const lastContentHash = this.lastContentHashCache.get(file.path); // Check if only NotionID fields changed (to avoid sync loops) const lastFrontmatter = this.lastFrontmatterCache.get(file.path); if (lastFrontmatter && lastContentHash) { const frontmatterOnlyNotionIDChanged = this.onlyNotionIDChanged(lastFrontmatter, frontMatter); const contentUnchanged = contentHash === lastContentHash; console.log(`[AutoSync] Change analysis for ${file.basename}:`, { frontmatterOnlyNotionIDChanged, contentUnchanged, frontmatterHasRealChanges: !frontmatterOnlyNotionIDChanged, contentChanged: !contentUnchanged, willSync: !(frontmatterOnlyNotionIDChanged && contentUnchanged) }); // Only skip sync if BOTH conditions are true: // 1. Frontmatter only has NotionID changes (no real user changes) // 2. Content is completely unchanged if (frontmatterOnlyNotionIDChanged && contentUnchanged) { console.log(`[AutoSync] Only NotionID updated (from sync), content unchanged - skipping auto sync`); // Update cache even when skipping, so next comparison uses the current state this.lastFrontmatterCache.set(file.path, { ...frontMatter }); this.lastContentHashCache.set(file.path, contentHash); return; } if (!contentUnchanged) { console.log(`[AutoSync] Content changed - will sync`); } else if (!frontmatterOnlyNotionIDChanged) { console.log(`[AutoSync] Frontmatter changed - will sync`); } } const autoSyncKey = this.getAutoSyncFrontmatterKey(); const autoSyncTargets = parseAutoSyncDatabaseList(frontMatter[autoSyncKey]); // If no autosync-database field specified if (autoSyncTargets.length === 0) { // Check if file has any existing NotionID (meaning it was synced before) const hasExistingNotionID = Object.keys(frontMatter).some(key => key.startsWith('NotionID-') || key === 'NotionID' ); if (hasExistingNotionID) { // User likely forgot to add autosync-database - show a notice const message = i18nConfig.AutoSyncMissingDatabaseList.replace('{key}', autoSyncKey); new Notice(message, 8000); console.log(`[AutoSync] File ${file.path} has NotionID but missing autosync-database - showing notice`); } // Silently skip files without NotionID (new files that don't need auto-sync) return; } const dbByShortName = new Map(); for (const key in this.settings.databaseDetails) { const dbDetails = this.settings.databaseDetails[key]; dbByShortName.set(dbDetails.abName.toLowerCase(), dbDetails); } const foundDatabases: Array<{ dbDetails: DatabaseDetails, notionId: string | undefined }> = []; const unresolvedTargets: string[] = []; for (const target of autoSyncTargets) { const lookupKey = target.toLowerCase(); const dbDetails = dbByShortName.get(lookupKey); if (!dbDetails) { unresolvedTargets.push(target); continue; } const notionIDKey = `NotionID-${dbDetails.abName}`; // Include database even if no NotionID exists (for first-time upload) foundDatabases.push({ dbDetails, notionId: frontMatter[notionIDKey] ? String(frontMatter[notionIDKey]) : undefined }); } if (unresolvedTargets.length > 0) { console.log(`[AutoSync] Frontmatter auto sync targets not found in settings: ${unresolvedTargets.join(", ")}`); } // If no valid databases found in settings, skip if (foundDatabases.length === 0) { console.log(`[AutoSync] No matching databases found in settings for ${file.path}, skipping auto sync`); return; } // Notify user about multiple syncs if applicable if (foundDatabases.length > 1) { const message = i18nConfig.AutoSyncMultipleSync.replace('{count}', String(foundDatabases.length)); new Notice(message, 3000); console.log(`[AutoSync] Found ${foundDatabases.length} database targets in ${file.path}`); } // Sync to all found databases for (const { dbDetails, notionId } of foundDatabases) { const isFirstSync = !notionId; console.log(`[AutoSync] ${new Date().toISOString()} Auto syncing ${file.basename} to ${dbDetails.fullName} (${dbDetails.abName})${isFirstSync ? ' [First Upload]' : ''}`); try { // Trigger appropriate upload command based on database format if (dbDetails.format === 'next') { await uploadCommandNext(this, this.settings, dbDetails, this.app); } else if (dbDetails.format === 'general') { await uploadCommandGeneral(this, this.settings, dbDetails, this.app); } else if (dbDetails.format === 'custom') { await uploadCommandCustom(this, this.settings, dbDetails, this.app); } } catch (error) { console.error(`[AutoSync] Error syncing to ${dbDetails.fullName}:`, error); const message = i18nConfig.AutoSyncFailed .replace('{database}', dbDetails.fullName) .replace('{error}', error.message); new Notice(message, 5000); } } // After sync completes, update cache with the latest frontmatter (including updated NotionIDs) // Wait a bit for metadata cache to update window.setTimeout(async () => { const updatedFrontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter; const updatedContent = await this.app.vault.read(file); const updatedHash = this.simpleHash(updatedContent); if (updatedFrontmatter) { this.lastFrontmatterCache.set(file.path, { ...updatedFrontmatter }); this.lastContentHashCache.set(file.path, updatedHash); console.log(`[AutoSync] Cached updated frontmatter and content hash for ${file.path}`); } }, 500); } catch (error) { console.error(`[AutoSync] Error syncing file ${file.path}:`, error); const message = i18nConfig.AutoSyncError .replace('{filename}', file.basename) .replace('{error}', error.message); new Notice(message); } finally { this.syncingFiles.delete(file.path); } } }