From a03e57fe44058fcb812b16a50d6d9740aa762759 Mon Sep 17 00:00:00 2001 From: Xheldon Date: Tue, 29 Jul 2025 19:35:29 +0800 Subject: [PATCH] add cos support(wip-1) --- cos-service.ts | 457 +++++++++++++++++++++++++++++++++++++++++++++++++ i18n-simple.ts | 108 ++++++++++++ main.ts | 390 ++++++++++++++++++++++++++++++++++++++++- types.ts | 62 ++++++- 4 files changed, 1013 insertions(+), 4 deletions(-) create mode 100644 cos-service.ts diff --git a/cos-service.ts b/cos-service.ts new file mode 100644 index 0000000..b4e00fe --- /dev/null +++ b/cos-service.ts @@ -0,0 +1,457 @@ +import { CosConfig, CosUploadResult } from './types'; +import { t } from './i18n-simple'; +import { requestUrl } from 'obsidian'; + +export class CosService { + private config: CosConfig; + + constructor(config: CosConfig) { + this.config = config; + } + + /** + * Upload file to COS + */ + async uploadFile(file: File, remotePath: string): Promise { + try { + switch (this.config.provider) { + case 'aliyun': + return await this.uploadToAliyun(file, remotePath); + case 'tencent': + return await this.uploadToTencent(file, remotePath); + case 'aws': + return await this.uploadToAWS(file, remotePath); + case 'cloudflare': + return await this.uploadToCloudflare(file, remotePath); + default: + return { + success: false, + message: t('cos.error.unsupported.provider', { provider: this.config.provider }) + }; + } + } catch (error) { + console.error('COS upload error:', error); + return { + success: false, + message: t('cos.error.upload.failed', { error: error.message }) + }; + } + } + + /** + * Test COS configuration + */ + async testConnection(): Promise { + try { + // Create a small test file + const testContent = 'test'; + const testFile = new File([testContent], 'test.txt', { type: 'text/plain' }); + const testPath = `test-${Date.now()}.txt`; + + const result = await this.uploadFile(testFile, testPath); + + if (result.success) { + // Try to delete the test file after successful upload + await this.deleteFile(testPath).catch(console.warn); + return { + success: true, + message: t('cos.test.success') + }; + } + + return result; + } catch (error) { + console.error('COS test connection error:', error); + return { + success: false, + message: t('cos.test.failed', { error: error.message }) + }; + } + } + + /** + * Delete file from COS + */ + private async deleteFile(remotePath: string): Promise { + switch (this.config.provider) { + case 'aliyun': + await this.deleteFromAliyun(remotePath); + break; + case 'tencent': + await this.deleteFromTencent(remotePath); + break; + case 'aws': + await this.deleteFromAWS(remotePath); + break; + case 'cloudflare': + await this.deleteFromCloudflare(remotePath); + break; + } + } + + /** + * Upload to Aliyun OSS + */ + private async uploadToAliyun(file: File, remotePath: string): Promise { + // For Aliyun OSS, construct endpoint from region + const endpoint = `oss-${this.config.region}.aliyuncs.com`; + const url = `https://${this.config.bucket}.${endpoint}/${remotePath}`; + const date = new Date().toUTCString(); + + // Generate signature for Aliyun OSS + const signature = await this.generateAliyunSignature('PUT', remotePath, date, file.type); + + // Convert File to ArrayBuffer for requestUrl + const fileBuffer = await file.arrayBuffer(); + + const response = await requestUrl({ + url: url, + method: 'PUT', + headers: { + 'Date': date, + 'Content-Type': file.type, + 'Authorization': `OSS ${this.config.secretId}:${signature}` + }, + body: fileBuffer + }); + + if (response.status >= 200 && response.status < 300) { + const finalUrl = this.config.cdnUrl ? + `${this.config.cdnUrl}/${remotePath}` : url; + + return { + success: true, + message: t('cos.upload.success'), + url: finalUrl, + key: remotePath + }; + } else { + const errorText = response.text; + return { + success: false, + message: t('cos.error.aliyun.upload.failed', { + status: response.status, + error: errorText + }) + }; + } + } + + /** + * Upload to Tencent COS + */ + private async uploadToTencent(file: File, remotePath: string): Promise { + const url = `https://${this.config.bucket}.cos.${this.config.region}.myqcloud.com/${remotePath}`; + const date = new Date().toUTCString(); + + // Generate signature for Tencent COS + const signature = await this.generateTencentSignature('PUT', remotePath, date, file.type); + + // Convert File to ArrayBuffer for requestUrl + const fileBuffer = await file.arrayBuffer(); + + const response = await requestUrl({ + url: url, + method: 'PUT', + headers: { + 'Date': date, + 'Content-Type': file.type, + 'Authorization': signature + }, + body: fileBuffer + }); + + if (response.status >= 200 && response.status < 300) { + const finalUrl = this.config.cdnUrl ? + `${this.config.cdnUrl}/${remotePath}` : url; + + return { + success: true, + message: t('cos.upload.success'), + url: finalUrl, + key: remotePath + }; + } else { + const errorText = response.text; + return { + success: false, + message: t('cos.error.tencent.upload.failed', { + status: response.status, + error: errorText + }) + }; + } + } + + /** + * Upload to AWS S3 + */ + private async uploadToAWS(file: File, remotePath: string): Promise { + const url = `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${remotePath}`; + const date = new Date(); + + // Generate signature for AWS S3 (V4) + const signature = await this.generateAWSSignature('PUT', remotePath, date, file.type); + + // Convert File to ArrayBuffer for requestUrl + const fileBuffer = await file.arrayBuffer(); + + const response = await requestUrl({ + url: url, + method: 'PUT', + headers: { + 'Content-Type': file.type, + ...signature.headers + }, + body: fileBuffer + }); + + if (response.status >= 200 && response.status < 300) { + const finalUrl = this.config.cdnUrl ? + `${this.config.cdnUrl}/${remotePath}` : url; + + return { + success: true, + message: t('cos.upload.success'), + url: finalUrl, + key: remotePath + }; + } else { + const errorText = response.text; + return { + success: false, + message: t('cos.error.aws.upload.failed', { + status: response.status, + error: errorText + }) + }; + } + } + + /** + * Upload to Cloudflare R2 (using S3 compatible API) + */ + private async uploadToCloudflare(file: File, remotePath: string): Promise { + const url = `${this.config.endpoint}/${remotePath}`; + const date = new Date(); + + // Generate signature for Cloudflare R2 (S3 compatible) + const signature = await this.generateCloudflareSignature('PUT', remotePath, date, file.type); + + // Convert File to ArrayBuffer for requestUrl + const fileBuffer = await file.arrayBuffer(); + + const response = await requestUrl({ + url: url, + method: 'PUT', + headers: { + 'Content-Type': file.type, + ...signature.headers + }, + body: fileBuffer + }); + + if (response.status >= 200 && response.status < 300) { + const finalUrl = this.config.cdnUrl ? + `${this.config.cdnUrl}/${remotePath}` : url; + + return { + success: true, + message: t('cos.upload.success'), + url: finalUrl, + key: remotePath + }; + } else { + const errorText = response.text; + return { + success: false, + message: t('cos.error.cloudflare.upload.failed', { + status: response.status, + error: errorText + }) + }; + } + } + + /** + * Delete from Aliyun OSS + */ + private async deleteFromAliyun(remotePath: string): Promise { + const endpoint = `oss-${this.config.region}.aliyuncs.com`; + const url = `https://${this.config.bucket}.${endpoint}/${remotePath}`; + const date = new Date().toUTCString(); + const signature = await this.generateAliyunSignature('DELETE', remotePath, date); + + await requestUrl({ + url: url, + method: 'DELETE', + headers: { + 'Date': date, + 'Authorization': `OSS ${this.config.secretId}:${signature}` + } + }); + } + + /** + * Delete from Tencent COS + */ + private async deleteFromTencent(remotePath: string): Promise { + const url = `https://${this.config.bucket}.cos.${this.config.region}.myqcloud.com/${remotePath}`; + const date = new Date().toUTCString(); + const signature = await this.generateTencentSignature('DELETE', remotePath, date); + + await requestUrl({ + url: url, + method: 'DELETE', + headers: { + 'Date': date, + 'Authorization': signature + } + }); + } + + /** + * Delete from AWS S3 + */ + private async deleteFromAWS(remotePath: string): Promise { + const url = `https://${this.config.bucket}.s3.${this.config.region}.amazonaws.com/${remotePath}`; + const date = new Date(); + const signature = await this.generateAWSSignature('DELETE', remotePath, date); + + await requestUrl({ + url: url, + method: 'DELETE', + headers: signature.headers + }); + } + + /** + * Delete from Cloudflare R2 + */ + private async deleteFromCloudflare(remotePath: string): Promise { + const url = `${this.config.endpoint}/${remotePath}`; + const date = new Date(); + const signature = await this.generateCloudflareSignature('DELETE', remotePath, date); + + await requestUrl({ + url: url, + method: 'DELETE', + headers: signature.headers + }); + } + + /** + * Generate signature for Aliyun OSS + */ + private async generateAliyunSignature(method: string, path: string, date: string, contentType?: string): Promise { + const stringToSign = `${method}\n\n${contentType || ''}\n${date}\n/${this.config.bucket}/${path}`; + + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(this.config.secretKey), + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(stringToSign)); + return btoa(String.fromCharCode(...new Uint8Array(signature))); + } + + /** + * Generate signature for Tencent COS + */ + private async generateTencentSignature(method: string, path: string, date: string, contentType?: string): Promise { + // Simplified Tencent COS signature - in production, you might want to use their SDK + const stringToSign = `${method}\n/${this.config.bucket}/${path}\n\ndate=${date}&host=${this.config.bucket}.cos.${this.config.region}.myqcloud.com`; + + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(this.config.secretKey), + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(stringToSign)); + const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature))); + + return `q-sign-algorithm=sha1&q-ak=${this.config.secretId}&q-sign-time=0;32503651200&q-key-time=0;32503651200&q-header-list=date;host&q-url-param-list=&q-signature=${signatureB64}`; + } + + /** + * Generate signature for AWS S3 V4 + */ + private async generateAWSSignature(method: string, path: string, date: Date, contentType?: string): Promise<{ headers: Record }> { + const isoDate = date.toISOString().replace(/[:\-]|\.\d{3}/g, ''); + const dateStamp = isoDate.substring(0, 8); + + // This is a simplified implementation + // In production, you should use AWS SDK or a complete V4 signature implementation + const headers = { + 'X-Amz-Date': isoDate, + 'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD', + 'Authorization': `AWS4-HMAC-SHA256 Credential=${this.config.secretId}/${dateStamp}/${this.config.region}/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=placeholder` + }; + + return { headers }; + } + + /** + * Generate signature for Cloudflare R2 (S3 compatible) + */ + private async generateCloudflareSignature(method: string, path: string, date: Date, contentType?: string): Promise<{ headers: Record }> { + // Similar to AWS S3 V4 signature + const isoDate = date.toISOString().replace(/[:\-]|\.\d{3}/g, ''); + + const headers = { + 'X-Amz-Date': isoDate, + 'X-Amz-Content-Sha256': 'UNSIGNED-PAYLOAD', + 'Authorization': `AWS4-HMAC-SHA256 Credential=${this.config.secretId}/${isoDate.substring(0, 8)}/auto/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=placeholder` + }; + + return { headers }; + } +} + +/** + * Parse image upload path template + */ +export function parseImagePath(template: string, context: { + currentFilePath: string; + fileName: string; + date?: Date +}): string { + const date = context.date || new Date(); + const year = date.getFullYear().toString(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + + // Extract path components from current file path + const pathParts = context.currentFilePath.split('/'); + const fileName = pathParts.pop()?.replace(/\.[^/.]+$/, '') || ''; // Remove extension + const folderPath = pathParts.join('/'); + const folderName = pathParts[pathParts.length - 1] || ''; + + // Use regex to replace placeholders in curly braces + let result = template + .replace(/\{YYYY\}/g, year) + .replace(/\{MM\}/g, month) + .replace(/\{DD\}/g, day) + .replace(/\{PATH\}/g, folderPath) + .replace(/\{FILENAME\}/g, fileName) + .replace(/\{FOLDER\}/g, folderName); + + // Ensure the path ends with the image file name + if (!result.endsWith('/')) { + result += '/'; + } + result += context.fileName; + + // Clean up the path (remove double slashes, etc.) + result = result.replace(/\/+/g, '/').replace(/^\//, ''); + + return result; +} \ No newline at end of file diff --git a/i18n-simple.ts b/i18n-simple.ts index 6f95f14..e8920b4 100644 --- a/i18n-simple.ts +++ b/i18n-simple.ts @@ -48,6 +48,45 @@ const translations = { 'settings.clear.cache.desc': '清空所有文件状态缓存,下次查看时将重新从GitHub获取', 'settings.clear.cache.button': '清空缓存', + // COS settings + 'settings.cos.section.title': '图片上传设置', + 'settings.cos.provider.name': '云存储服务商', + 'settings.cos.provider.desc': '选择要使用的云对象存储服务商', + 'settings.cos.provider.aliyun': '阿里云 OSS', + 'settings.cos.provider.tencent': '腾讯云 COS', + 'settings.cos.provider.aws': 'AWS S3', + 'settings.cos.provider.cloudflare': 'Cloudflare R2', + 'settings.cos.secret.id.name': 'Access Key ID / Secret ID', + 'settings.cos.secret.id.desc': '云存储服务的访问密钥ID', + 'settings.cos.secret.id.placeholder': '输入Access Key ID或Secret ID', + 'settings.cos.secret.key.name': 'Access Key Secret / Secret Key', + 'settings.cos.secret.key.desc': '云存储服务的访问密钥', + 'settings.cos.secret.key.placeholder': '输入Access Key Secret或Secret Key', + 'settings.cos.bucket.name': 'Bucket名称', + 'settings.cos.bucket.desc': '存储桶名称', + 'settings.cos.bucket.placeholder': '输入bucket名称', + 'settings.cos.endpoint.name': 'Endpoint', + 'settings.cos.endpoint.desc': '服务端点地址(仅Cloudflare R2需要)', + 'settings.cos.endpoint.placeholder': '例如:https://accountid.r2.cloudflarestorage.com', + 'settings.cos.region.name': 'Region', + 'settings.cos.region.desc': '区域设置(所有服务商都需要)', + 'settings.cos.region.placeholder': '例如:cn-hangzhou、ap-beijing、us-east-1', + 'settings.cos.cdn.name': 'CDN地址', + 'settings.cos.cdn.desc': 'CDN域名,用于生成访问链接', + 'settings.cos.cdn.placeholder': '例如:https://cdn.example.com', + 'settings.cos.keep.local.name': '保留图片在本地', + 'settings.cos.keep.local.desc': '上传后是否在本地保留图片副本', + 'settings.cos.local.path.name': '本地存储路径', + 'settings.cos.local.path.desc': '本地保存图片的目录(相对于vault根目录)', + 'settings.cos.local.path.placeholder': '例如:assets', + 'settings.cos.upload.path.name': '上传路径模板', + 'settings.cos.upload.path.desc': '支持占位符:{PATH}(当前文件路径)、{FILENAME}(文件名)、{FOLDER}(文件夹名)、{YYYY}(年)、{MM}(月)、{DD}(日)', + 'settings.cos.upload.path.placeholder': '例如:images/{YYYY}/{MM}/{DD}', + 'settings.cos.test.name': '测试COS配置', + 'settings.cos.test.desc': '测试当前COS配置是否可用', + 'settings.cos.test.button': '测试连接', + 'settings.cos.test.loading': '测试中...', + // Notification messages 'notice.settings.saved': '设置已保存', 'notice.config.required': '请先配置GitHub Token和仓库地址', @@ -143,6 +182,21 @@ const translations = { 'path.info.folder': ' 的 {path} 文件夹下', 'path.info.root': ' 的根目录下', 'path.info.error': '路径格式不正确,请使用: https://github.com/用户名/仓库名/路径 或 用户名/仓库名/路径', + + // COS upload messages + 'cos.upload.success': '图片上传成功', + 'cos.upload.failed': '图片上传失败:{error}', + 'cos.upload.uploading': '正在上传图片...', + 'cos.test.success': 'COS配置测试成功', + 'cos.test.failed': 'COS配置测试失败:{error}', + 'cos.error.unsupported.provider': '不支持的云存储服务商:{provider}', + 'cos.error.upload.failed': '上传失败:{error}', + 'cos.error.aliyun.upload.failed': '阿里云OSS上传失败({status}):{error}', + 'cos.error.tencent.upload.failed': '腾讯云COS上传失败({status}):{error}', + 'cos.error.aws.upload.failed': 'AWS S3上传失败({status}):{error}', + 'cos.error.cloudflare.upload.failed': 'Cloudflare R2上传失败({status}):{error}', + 'cos.error.config.invalid': 'COS配置不完整,请检查配置项', + 'cos.paste.placeholder': '![上传中...]({filename})', }, en: { // Settings interface @@ -188,6 +242,45 @@ const translations = { 'settings.clear.cache.desc': 'Clear all file status cache, will fetch from GitHub on next view', 'settings.clear.cache.button': 'Clear Cache', + // COS settings + 'settings.cos.section.title': 'Image Upload Settings', + 'settings.cos.provider.name': 'Cloud Storage Provider', + 'settings.cos.provider.desc': 'Select the cloud object storage service to use', + 'settings.cos.provider.aliyun': 'Aliyun OSS', + 'settings.cos.provider.tencent': 'Tencent COS', + 'settings.cos.provider.aws': 'AWS S3', + 'settings.cos.provider.cloudflare': 'Cloudflare R2', + 'settings.cos.secret.id.name': 'Access Key ID / Secret ID', + 'settings.cos.secret.id.desc': 'Access key ID for cloud storage service', + 'settings.cos.secret.id.placeholder': 'Enter Access Key ID or Secret ID', + 'settings.cos.secret.key.name': 'Access Key Secret / Secret Key', + 'settings.cos.secret.key.desc': 'Access key secret for cloud storage service', + 'settings.cos.secret.key.placeholder': 'Enter Access Key Secret or Secret Key', + 'settings.cos.bucket.name': 'Bucket Name', + 'settings.cos.bucket.desc': 'Storage bucket name', + 'settings.cos.bucket.placeholder': 'Enter bucket name', + 'settings.cos.endpoint.name': 'Endpoint', + 'settings.cos.endpoint.desc': 'Service endpoint address (only required for Cloudflare R2)', + 'settings.cos.endpoint.placeholder': 'e.g.: https://accountid.r2.cloudflarestorage.com', + 'settings.cos.region.name': 'Region', + 'settings.cos.region.desc': 'Region setting (required for all providers)', + 'settings.cos.region.placeholder': 'e.g.: cn-hangzhou, ap-beijing, us-east-1', + 'settings.cos.cdn.name': 'CDN URL', + 'settings.cos.cdn.desc': 'CDN domain for generating access links', + 'settings.cos.cdn.placeholder': 'e.g.: https://cdn.example.com', + 'settings.cos.keep.local.name': 'Keep Images Locally', + 'settings.cos.keep.local.desc': 'Whether to keep a local copy of images after upload', + 'settings.cos.local.path.name': 'Local Storage Path', + 'settings.cos.local.path.desc': 'Directory to save images locally (relative to vault root)', + 'settings.cos.local.path.placeholder': 'e.g.: assets', + 'settings.cos.upload.path.name': 'Upload Path Template', + 'settings.cos.upload.path.desc': 'Supports placeholders: {PATH} (current file path), {FILENAME} (file name), {FOLDER} (folder name), {YYYY} (year), {MM} (month), {DD} (day)', + 'settings.cos.upload.path.placeholder': 'e.g.: images/{YYYY}/{MM}/{DD}', + 'settings.cos.test.name': 'Test COS Configuration', + 'settings.cos.test.desc': 'Test if current COS configuration is available', + 'settings.cos.test.button': 'Test Connection', + 'settings.cos.test.loading': 'Testing...', + // Notification messages 'notice.settings.saved': 'Settings saved', 'notice.config.required': 'Please configure GitHub Token and repository address first', @@ -283,6 +376,21 @@ const translations = { 'path.info.folder': ' under {path} folder', 'path.info.root': ' under root directory', 'path.info.error': 'Path format is incorrect, please use: https://github.com/username/repo/path or username/repo/path', + + // COS upload messages + 'cos.upload.success': 'Image uploaded successfully', + 'cos.upload.failed': 'Image upload failed: {error}', + 'cos.upload.uploading': 'Uploading image...', + 'cos.test.success': 'COS configuration test successful', + 'cos.test.failed': 'COS configuration test failed: {error}', + 'cos.error.unsupported.provider': 'Unsupported cloud storage provider: {provider}', + 'cos.error.upload.failed': 'Upload failed: {error}', + 'cos.error.aliyun.upload.failed': 'Aliyun OSS upload failed ({status}): {error}', + 'cos.error.tencent.upload.failed': 'Tencent COS upload failed ({status}): {error}', + 'cos.error.aws.upload.failed': 'AWS S3 upload failed ({status}): {error}', + 'cos.error.cloudflare.upload.failed': 'Cloudflare R2 upload failed ({status}): {error}', + 'cos.error.config.invalid': 'COS configuration is incomplete, please check settings', + 'cos.paste.placeholder': '![Uploading...]({filename})', } }; diff --git a/main.ts b/main.ts index 4fdf679..9fd221f 100644 --- a/main.ts +++ b/main.ts @@ -1,8 +1,9 @@ import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile, Menu, TFolder } from 'obsidian'; -import { GitSyncSettings, DEFAULT_SETTINGS, SyncResult } from './types'; +import { GitSyncSettings, DEFAULT_SETTINGS, SyncResult, CosConfig, ImagePasteContext } from './types'; import { GitHubService } from './github-service'; import { setLanguage, t, getSupportedLanguages, getActualLanguage } from './i18n-simple'; import { FileCacheService } from './file-cache'; +import { CosService, parseImagePath } from './cos-service'; export default class GitSyncPlugin extends Plugin { settings: GitSyncSettings; @@ -58,6 +59,9 @@ export default class GitSyncPlugin extends Plugin { }) ); + // Listen for paste events to handle image uploads + this.registerDomEvent(document, 'paste', this.handlePasteEvent.bind(this)); + // Add sync button to note editing interface this.addCommand({ id: 'show-sync-menu', @@ -354,8 +358,25 @@ export default class GitSyncPlugin extends Plugin { const allFiles = this.app.vault.getFiles(); console.log('All files in vault:', allFiles.map(f => f.path)); - // Filter out files in .obsidian folder - const filteredFiles = allFiles.filter(file => !file.path.startsWith('.obsidian')); + // Filter out files in .obsidian folder and local image path (if configured) + const filteredFiles = allFiles.filter(file => { + // Always exclude .obsidian folder + if (file.path.startsWith('.obsidian')) { + return false; + } + + // Exclude local image path if keepLocalImages is enabled and localImagePath is set + if (this.settings.keepLocalImages && this.settings.localImagePath) { + const normalizedImagePath = this.settings.localImagePath.replace(/^\/+|\/+$/g, ''); // Remove leading/trailing slashes + if (normalizedImagePath && file.path.startsWith(normalizedImagePath + '/')) { + console.log(`Excluding file from sync (in image directory): ${file.path}`); + return false; + } + } + + return true; + }); + console.log('Filtered files:', filteredFiles.map(f => f.path)); return filteredFiles; @@ -633,6 +654,174 @@ export default class GitSyncPlugin extends Plugin { return date.toLocaleDateString('zh-CN'); } } + + /** + * Handle paste events for image upload + */ + async handlePasteEvent(event: ClipboardEvent): Promise { + // Only handle paste events in markdown editor + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!activeView || !this.currentFile) { + return; + } + + // Check if clipboard contains files + const clipboardData = event.clipboardData; + if (!clipboardData || !clipboardData.files || clipboardData.files.length === 0) { + return; + } + + // Check if COS is configured + if (!this.isCosConfigured()) { + return; + } + + // Process each file in clipboard + for (let i = 0; i < clipboardData.files.length; i++) { + const file = clipboardData.files[i]; + + // Only handle image files + if (!file.type.startsWith('image/')) { + continue; + } + + // Prevent default paste behavior for images + event.preventDefault(); + + try { + await this.handleImageUpload(file, activeView.editor); + } catch (error) { + console.error('Error handling image upload:', error); + new Notice(t('cos.upload.failed', { error: error.message })); + } + } + } + + /** + * Check if COS is properly configured + */ + private isCosConfigured(): boolean { + const requiredFields = [ + this.settings.cosSecretId, + this.settings.cosSecretKey, + this.settings.cosBucket, + this.settings.cosRegion // Region is required for all providers + ]; + + // Add provider-specific required fields + if (this.settings.cosProvider === 'cloudflare') { + requiredFields.push(this.settings.cosEndpoint); // Endpoint only required for Cloudflare R2 + } + + return requiredFields.every(field => field && field.trim().length > 0); + } + + /** + * Handle single image upload + */ + private async handleImageUpload(file: File, editor: Editor): Promise { + // Generate unique filename + const timestamp = Date.now(); + const extension = file.name.split('.').pop() || 'png'; + const fileName = `image-${timestamp}.${extension}`; + + // Parse upload path + const remotePath = parseImagePath(this.settings.imageUploadPath, { + currentFilePath: this.currentFile!.path, + fileName: fileName + }); + + // Create placeholder text + const placeholder = t('cos.paste.placeholder', { filename: fileName }); + const cursorPos = editor.getCursor(); + + // Insert placeholder at cursor position + editor.replaceRange(placeholder, cursorPos); + + // Show upload notification + const uploadNotice = new Notice(t('cos.upload.uploading'), 0); + + try { + // Create COS configuration + const cosConfig: CosConfig = { + provider: this.settings.cosProvider, + secretId: this.settings.cosSecretId, + secretKey: this.settings.cosSecretKey, + bucket: this.settings.cosBucket, + endpoint: this.settings.cosEndpoint, + cdnUrl: this.settings.cosCdnUrl, + region: this.settings.cosRegion + }; + + // Upload to COS + const cosService = new CosService(cosConfig); + const uploadResult = await cosService.uploadFile(file, remotePath); + + if (uploadResult.success && uploadResult.url) { + // Replace placeholder with actual image link + const imageMarkdown = `![${fileName}](${uploadResult.url})`; + const currentContent = editor.getValue(); + const updatedContent = currentContent.replace(placeholder, imageMarkdown); + editor.setValue(updatedContent); + + // Save local copy if enabled + if (this.settings.keepLocalImages && this.settings.localImagePath) { + await this.saveLocalImageCopy(file, fileName); + } + + // Hide upload notification and show success + uploadNotice.hide(); + new Notice(t('cos.upload.success')); + + console.log('Image uploaded successfully:', uploadResult.url); + } else { + throw new Error(uploadResult.message || 'Upload failed'); + } + } catch (error) { + // Remove placeholder on error + const currentContent = editor.getValue(); + const updatedContent = currentContent.replace(placeholder, ''); + editor.setValue(updatedContent); + + // Hide upload notification and show error + uploadNotice.hide(); + new Notice(t('cos.upload.failed', { error: error.message })); + + console.error('Image upload failed:', error); + } + } + + /** + * Save image copy locally if enabled + */ + private async saveLocalImageCopy(file: File, fileName: string): Promise { + try { + const localPath = parseImagePath(this.settings.localImagePath, { + currentFilePath: this.currentFile!.path, + fileName: fileName + }); + + // Create directory if it doesn't exist + const dirPath = localPath.substring(0, localPath.lastIndexOf('/')); + if (dirPath) { + try { + await this.app.vault.createFolder(dirPath); + } catch (error) { + // Folder might already exist, ignore error + } + } + + // Convert File to ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + // Save file to vault + await this.app.vault.createBinary(localPath, arrayBuffer); + + console.log('Local image copy saved:', localPath); + } catch (error) { + console.warn('Failed to save local image copy:', error); + } + } } class GitSyncSettingTab extends PluginSettingTab { @@ -732,6 +921,201 @@ class GitSyncSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); + // COS settings section + containerEl.createEl('h2', { + text: t('settings.cos.section.title'), + cls: 'git-sync-section-title' + }); + + // Cloud storage provider + new Setting(containerEl) + .setName(t('settings.cos.provider.name')) + .setDesc(t('settings.cos.provider.desc')) + .addDropdown(dropdown => { + dropdown.addOption('aliyun', t('settings.cos.provider.aliyun')); + dropdown.addOption('tencent', t('settings.cos.provider.tencent')); + dropdown.addOption('aws', t('settings.cos.provider.aws')); + dropdown.addOption('cloudflare', t('settings.cos.provider.cloudflare')); + + dropdown.setValue(this.plugin.settings.cosProvider); + dropdown.onChange(async (value) => { + this.plugin.settings.cosProvider = value as 'aliyun' | 'tencent' | 'aws' | 'cloudflare'; + await this.plugin.saveSettings(); + // Re-render settings to show/hide region field + this.display(); + }); + }); + + // Secret ID + new Setting(containerEl) + .setName(t('settings.cos.secret.id.name') + ' *') + .setDesc(t('settings.cos.secret.id.desc')) + .addText(text => text + .setPlaceholder(t('settings.cos.secret.id.placeholder')) + .setValue(this.plugin.settings.cosSecretId) + .onChange(async (value) => { + this.plugin.settings.cosSecretId = value; + await this.plugin.saveSettings(); + })); + + // Secret Key + new Setting(containerEl) + .setName(t('settings.cos.secret.key.name') + ' *') + .setDesc(t('settings.cos.secret.key.desc')) + .addText(text => text + .setPlaceholder(t('settings.cos.secret.key.placeholder')) + .setValue(this.plugin.settings.cosSecretKey) + .onChange(async (value) => { + this.plugin.settings.cosSecretKey = value; + await this.plugin.saveSettings(); + })); + + // Bucket + new Setting(containerEl) + .setName(t('settings.cos.bucket.name') + ' *') + .setDesc(t('settings.cos.bucket.desc')) + .addText(text => text + .setPlaceholder(t('settings.cos.bucket.placeholder')) + .setValue(this.plugin.settings.cosBucket) + .onChange(async (value) => { + this.plugin.settings.cosBucket = value; + await this.plugin.saveSettings(); + })); + + // Region (required for all providers) + new Setting(containerEl) + .setName(t('settings.cos.region.name') + ' *') + .setDesc(t('settings.cos.region.desc')) + .addText(text => text + .setPlaceholder(t('settings.cos.region.placeholder')) + .setValue(this.plugin.settings.cosRegion) + .onChange(async (value) => { + this.plugin.settings.cosRegion = value; + await this.plugin.saveSettings(); + })); + + // Endpoint (only for Cloudflare R2) + if (this.plugin.settings.cosProvider === 'cloudflare') { + new Setting(containerEl) + .setName(t('settings.cos.endpoint.name') + ' *') + .setDesc(t('settings.cos.endpoint.desc')) + .addText(text => text + .setPlaceholder(t('settings.cos.endpoint.placeholder')) + .setValue(this.plugin.settings.cosEndpoint) + .onChange(async (value) => { + this.plugin.settings.cosEndpoint = value; + await this.plugin.saveSettings(); + })); + } + + // CDN URL + new Setting(containerEl) + .setName(t('settings.cos.cdn.name')) + .setDesc(t('settings.cos.cdn.desc')) + .addText(text => text + .setPlaceholder(t('settings.cos.cdn.placeholder')) + .setValue(this.plugin.settings.cosCdnUrl) + .onChange(async (value) => { + this.plugin.settings.cosCdnUrl = value; + await this.plugin.saveSettings(); + })); + + // Keep images locally + new Setting(containerEl) + .setName(t('settings.cos.keep.local.name')) + .setDesc(t('settings.cos.keep.local.desc')) + .addToggle(toggle => toggle + .setValue(this.plugin.settings.keepLocalImages) + .onChange(async (value) => { + this.plugin.settings.keepLocalImages = value; + await this.plugin.saveSettings(); + // Re-render to show/hide local path setting + this.display(); + })); + + // Local storage path (only when keepLocalImages is true) + if (this.plugin.settings.keepLocalImages) { + new Setting(containerEl) + .setName(t('settings.cos.local.path.name')) + .setDesc(t('settings.cos.local.path.desc')) + .addText(text => text + .setPlaceholder(t('settings.cos.local.path.placeholder')) + .setValue(this.plugin.settings.localImagePath) + .onChange(async (value) => { + this.plugin.settings.localImagePath = value; + await this.plugin.saveSettings(); + })); + } + + // Upload path template + new Setting(containerEl) + .setName(t('settings.cos.upload.path.name')) + .setDesc(t('settings.cos.upload.path.desc')) + .addText(text => text + .setPlaceholder(t('settings.cos.upload.path.placeholder')) + .setValue(this.plugin.settings.imageUploadPath) + .onChange(async (value) => { + this.plugin.settings.imageUploadPath = value; + await this.plugin.saveSettings(); + })); + + // Test COS configuration + new Setting(containerEl) + .setName(t('settings.cos.test.name')) + .setDesc(t('settings.cos.test.desc')) + .addButton(button => button + .setButtonText(t('settings.cos.test.button')) + .onClick(async () => { + // Validate required fields based on provider + const requiredFields = [ + this.plugin.settings.cosSecretId, + this.plugin.settings.cosSecretKey, + this.plugin.settings.cosBucket, + this.plugin.settings.cosRegion // Region is required for all providers + ]; + + // Add provider-specific required fields + if (this.plugin.settings.cosProvider === 'cloudflare') { + requiredFields.push(this.plugin.settings.cosEndpoint); // Endpoint only required for Cloudflare R2 + } + + if (requiredFields.some(field => !field.trim())) { + new Notice(t('cos.error.config.invalid')); + return; + } + + // Set loading state + button.setButtonText(t('settings.cos.test.loading')); + button.setDisabled(true); + + try { + const cosConfig = { + provider: this.plugin.settings.cosProvider, + secretId: this.plugin.settings.cosSecretId, + secretKey: this.plugin.settings.cosSecretKey, + bucket: this.plugin.settings.cosBucket, + endpoint: this.plugin.settings.cosEndpoint, + cdnUrl: this.plugin.settings.cosCdnUrl, + region: this.plugin.settings.cosRegion + }; + + const cosService = new (await import('./cos-service')).CosService(cosConfig); + const result = await cosService.testConnection(); + + if (result.success) { + new Notice(t('cos.test.success')); + } else { + new Notice(t('cos.test.failed', { error: result.message })); + } + } catch (error) { + console.error('COS test error:', error); + new Notice(t('cos.test.failed', { error: error.message })); + } finally { + button.setButtonText(t('settings.cos.test.button')); + button.setDisabled(false); + } + })); + // Save settings button - put below basic settings area const saveButtonContainer = containerEl.createEl('div', { cls: 'git-sync-save-button-container' diff --git a/types.ts b/types.ts index 3e83785..b299575 100644 --- a/types.ts +++ b/types.ts @@ -4,6 +4,22 @@ export interface GitSyncSettings { lastSyncTime: number; showRibbonIcon: boolean; // Whether to show sidebar button language: 'zh' | 'en' | 'auto'; // Language setting, auto means follow system + + // COS (Cloud Object Storage) settings + cosProvider: 'aliyun' | 'tencent' | 'aws' | 'cloudflare'; // Cloud service provider + cosSecretId: string; // Access Key ID / Secret ID + cosSecretKey: string; // Access Key Secret / Secret Key + cosBucket: string; // Bucket name + cosEndpoint: string; // Service endpoint + cosCdnUrl: string; // CDN domain for accessing uploaded files + cosRegion: string; // Region (for AWS/Cloudflare) + + // Local image storage settings + keepLocalImages: boolean; // Whether to keep images locally after upload + localImagePath: string; // Local path for storing images (relative to vault root) + + // Image upload path template + imageUploadPath: string; // Path template with placeholders: PATH, FILENAME, FOLDER, YYYY, MM, DD } export const DEFAULT_SETTINGS: GitSyncSettings = { @@ -11,7 +27,23 @@ export const DEFAULT_SETTINGS: GitSyncSettings = { repositoryUrl: '', lastSyncTime: 0, showRibbonIcon: true, - language: 'auto' // Follow Obsidian language setting + language: 'auto', // Follow Obsidian language setting + + // COS default settings + cosProvider: 'aliyun', + cosSecretId: '', + cosSecretKey: '', + cosBucket: '', + cosEndpoint: '', + cosCdnUrl: '', + cosRegion: '', + + // Local image storage defaults + keepLocalImages: false, + localImagePath: 'assets', + + // Image upload path template default + imageUploadPath: 'images/{YYYY}/{MM}/{DD}' }; export interface GitHubFile { @@ -55,4 +87,32 @@ export interface FileCacheManager { clearCache(): void; isCacheValid(cache: FileCache, maxAge?: number): boolean; getAllCachedFiles(): FileCache[]; +} + +// COS upload result +export interface CosUploadResult { + success: boolean; + message: string; + url?: string; // Final accessible URL + key?: string; // Object key in COS +} + +// COS configuration for different providers +export interface CosConfig { + provider: 'aliyun' | 'tencent' | 'aws' | 'cloudflare'; + secretId: string; + secretKey: string; + bucket: string; + endpoint: string; + cdnUrl: string; + region: string; +} + +// Image paste context +export interface ImagePasteContext { + file: File; + fileName: string; + currentFilePath: string; // Current markdown file path + localPath?: string; // Local storage path (if keepLocalImages is true) + remotePath: string; // Remote COS path } \ No newline at end of file