/** * Quartz Sync Integration * Provides multiple approaches for syncing notes back to Quartz sites */ export interface QuartzSyncConfig { githubToken?: string githubRepo?: string quartzUrl?: string cloudflareApiKey?: string cloudflareAccountId?: string } export interface QuartzNote { id: string title: string content: string tags: string[] frontmatter: Record filePath: string lastModified: Date } export class QuartzSync { private config: QuartzSyncConfig constructor(config: QuartzSyncConfig) { this.config = config } /** * Approach 1: GitHub API Integration * Sync directly to the GitHub repository that powers the Quartz site */ async syncToGitHub(note: QuartzNote): Promise { if (!this.config.githubToken || !this.config.githubRepo) { throw new Error('GitHub token and repository required for GitHub sync') } try { const { githubToken, githubRepo } = this.config const [owner, repo] = githubRepo.split('/') owner, repo, noteTitle: note.title, noteFilePath: note.filePath }) // Get the current file content to check if it exists const filePath = `content/${note.filePath}` let sha: string | undefined try { const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}` const existingFile = await fetch(apiUrl, { headers: { 'Authorization': `token ${githubToken}`, 'Accept': 'application/vnd.github.v3+json' } }) if (existingFile.ok) { const fileData = await existingFile.json() as { sha: string } sha = fileData.sha } else { } } catch (error) { // File doesn't exist, that's okay } // Create the markdown content const frontmatter = Object.entries(note.frontmatter) .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) .join('\n') const content = `--- ${frontmatter} --- ${note.content}` // Encode content to base64 const encodedContent = btoa(unescape(encodeURIComponent(content))) // Create or update the file const response = await fetch( `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`, { method: 'PUT', headers: { 'Authorization': `token ${githubToken}`, 'Accept': 'application/vnd.github.v3+json', 'Content-Type': 'application/json' }, body: JSON.stringify({ message: `Update note: ${note.title}`, content: encodedContent, ...(sha && { sha }) // Include SHA if updating existing file }) } ) if (response.ok) { const result = await response.json() as { commit: { sha: string } } return true } else { const error = await response.text() let errorMessage = `GitHub API error: ${response.status}` try { const errorData = JSON.parse(error) if (errorData.message) { errorMessage += ` - ${errorData.message}` } } catch (e) { errorMessage += ` - ${error}` } throw new Error(errorMessage) } } catch (error) { console.error('❌ Failed to sync to GitHub:', error) throw error } } /** * Approach 2: Cloudflare R2 + Durable Objects * Use the existing Cloudflare infrastructure for persistent storage */ async syncToCloudflare(note: QuartzNote): Promise { if (!this.config.cloudflareApiKey || !this.config.cloudflareAccountId) { throw new Error('Cloudflare credentials required for Cloudflare sync') } try { // Store in Cloudflare R2 const response = await fetch('/api/quartz/sync', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.config.cloudflareApiKey}` }, body: JSON.stringify({ note, accountId: this.config.cloudflareAccountId }) }) if (response.ok) { return true } else { throw new Error(`Cloudflare sync failed: ${response.statusText}`) } } catch (error) { console.error('❌ Failed to sync to Cloudflare:', error) throw error } } /** * Approach 3: Direct Quartz API (if available) * Some Quartz sites may expose APIs for content updates */ async syncToQuartzAPI(note: QuartzNote): Promise { if (!this.config.quartzUrl) { throw new Error('Quartz URL required for API sync') } try { const response = await fetch(`${this.config.quartzUrl}/api/notes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(note) }) if (response.ok) { return true } else { throw new Error(`Quartz API error: ${response.statusText}`) } } catch (error) { console.error('❌ Failed to sync to Quartz API:', error) throw error } } /** * Approach 4: Webhook Integration * Send updates to a webhook that can process and sync to Quartz */ async syncViaWebhook(note: QuartzNote, webhookUrl: string): Promise { try { const response = await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'note_update', note, timestamp: new Date().toISOString() }) }) if (response.ok) { return true } else { throw new Error(`Webhook error: ${response.statusText}`) } } catch (error) { console.error('❌ Failed to sync via webhook:', error) throw error } } /** * Smart sync - tries multiple approaches in order of preference * Prioritizes GitHub integration for Quartz sites */ async smartSync(note: QuartzNote): Promise { hasGitHubToken: !!this.config.githubToken, hasGitHubRepo: !!this.config.githubRepo, hasCloudflareApiKey: !!this.config.cloudflareApiKey, hasCloudflareAccountId: !!this.config.cloudflareAccountId, hasQuartzUrl: !!this.config.quartzUrl }) // Check if GitHub integration is available and preferred if (this.config.githubToken && this.config.githubRepo) { try { const result = await this.syncToGitHub(note) if (result) { return true } } catch (error) { console.warn('⚠️ GitHub sync failed, trying other methods:', error) console.warn('⚠️ GitHub sync error details:', { message: error instanceof Error ? error.message : 'Unknown error', stack: error instanceof Error ? error.stack : 'No stack trace' }) } } else { } // Fallback to other methods const fallbackMethods = [ () => this.syncToCloudflare(note), () => this.syncToQuartzAPI(note) ] for (const syncMethod of fallbackMethods) { try { const result = await syncMethod() if (result) return true } catch (error) { console.warn('Sync method failed, trying next:', error) continue } } throw new Error('All sync methods failed') } } /** * Utility function to create a Quartz note from an ObsNote shape */ export function createQuartzNoteFromShape(shape: any): QuartzNote { const title = shape.props.title || 'Untitled' const content = shape.props.content || '' const tags = shape.props.tags || [] // Use stored filePath if available to maintain filename consistency // Otherwise, generate from title let filePath: string if (shape.props.filePath && shape.props.filePath.trim() !== '') { filePath = shape.props.filePath // Ensure it ends with .md if it doesn't already if (!filePath.endsWith('.md')) { filePath = filePath.endsWith('/') ? `${filePath}${title}.md` : `${filePath}.md` } } else { // Generate from title, ensuring it's a valid filename const sanitizedTitle = title.replace(/[^a-zA-Z0-9\s-]/g, '').trim().replace(/\s+/g, '-') filePath = `${sanitizedTitle}.md` } return { id: shape.props.noteId || title, title, content, tags: tags.map((tag: string) => tag.replace('#', '')), frontmatter: { title: title, tags: tags.map((tag: string) => tag.replace('#', '')), created: new Date().toISOString(), modified: new Date().toISOString() }, filePath: filePath, lastModified: new Date() } }