canvas-website/src/lib/quartzSync.ts

328 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, any>
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<boolean> {
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('/')
console.log('🔧 GitHub sync details:', {
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
console.log('🔍 Checking for existing file:', filePath)
try {
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}`
console.log('🌐 Making API call to:', apiUrl)
const existingFile = await fetch(apiUrl, {
headers: {
'Authorization': `token ${githubToken}`,
'Accept': 'application/vnd.github.v3+json'
}
})
console.log('📡 API response status:', existingFile.status)
if (existingFile.ok) {
const fileData = await existingFile.json() as { sha: string }
sha = fileData.sha
console.log('✅ File exists, will update with SHA:', sha)
} else {
console.log(' File does not exist, will create new one')
}
} catch (error) {
// File doesn't exist, that's okay
console.log(' File does not exist, will create new one:', error)
}
// 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 } }
console.log('✅ Successfully synced note to GitHub:', note.title)
console.log('📁 File path:', filePath)
console.log('🔗 Commit SHA:', result.commit.sha)
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<boolean> {
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) {
console.log('✅ Successfully synced note to Cloudflare:', note.title)
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<boolean> {
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) {
console.log('✅ Successfully synced note to Quartz API:', note.title)
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<boolean> {
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) {
console.log('✅ Successfully sent note to webhook:', note.title)
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<boolean> {
console.log('🔄 Starting smart sync for note:', note.title)
console.log('🔧 Sync config available:', {
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 {
console.log('🔄 Attempting GitHub sync (preferred method)')
const result = await this.syncToGitHub(note)
if (result) {
console.log('✅ GitHub sync successful!')
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 {
console.log('⚠️ GitHub sync not available - missing token or repo')
}
// 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()
}
}