add cos support(wip-1)

This commit is contained in:
Xheldon 2025-07-29 19:35:29 +08:00
parent 4004668604
commit a03e57fe44
4 changed files with 1013 additions and 4 deletions

457
cos-service.ts Normal file
View file

@ -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<CosUploadResult> {
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<CosUploadResult> {
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<void> {
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<CosUploadResult> {
// 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<CosUploadResult> {
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<CosUploadResult> {
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<CosUploadResult> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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<string> {
// 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<string, string> }> {
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<string, string> }> {
// 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;
}

View file

@ -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})',
}
};

390
main.ts
View file

@ -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<void> {
// 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<void> {
// 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<void> {
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'

View file

@ -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
}