Spaces:
Running
Running
import axios from 'axios'; | |
class GitHubService { | |
constructor() { | |
// 延迟初始化,动态读取环境变量 | |
this.initialized = false; | |
this.useMemoryStorage = false; | |
this.memoryStorage = new Map(); | |
this.apiUrl = 'https://api.github.com'; | |
// 首次使用时再初始化 | |
this.initPromise = null; | |
// 添加错误重试配置 | |
this.retryConfig = { | |
maxRetries: 3, | |
retryDelay: 1000, | |
backoffMultiplier: 2 | |
}; | |
// API限制处理 | |
this.apiRateLimit = { | |
requestQueue: [], | |
processing: false, | |
minDelay: 100 // 最小请求间隔 | |
}; | |
} | |
// 动态初始化方法 | |
async initialize() { | |
if (this.initialized) { | |
return; | |
} | |
console.log('=== GitHub Service Configuration ==='); | |
try { | |
// 动态读取环境变量 | |
this.token = process.env.GITHUB_TOKEN; | |
this.repositories = process.env.GITHUB_REPOS | |
? process.env.GITHUB_REPOS.split(',').map(repo => repo.trim()) | |
: []; | |
console.log('Token configured:', !!this.token); | |
console.log('Token preview:', this.token ? `${this.token.substring(0, 8)}...` : 'Not set'); | |
console.log('Repositories:', this.repositories); | |
console.log('Repositories length:', this.repositories?.length || 0); | |
// Check if token is a placeholder | |
if (!this.token || this.token === 'your_github_token_here') { | |
console.warn('❌ GitHub token is missing or using placeholder value!'); | |
console.warn('⚠️ Switching to memory storage mode for development...'); | |
this.useMemoryStorage = true; | |
console.log('✅ Memory storage mode activated'); | |
this.initialized = true; | |
return; | |
} | |
if (!this.repositories || this.repositories.length === 0) { | |
console.warn('❌ No GitHub repositories configured!'); | |
console.warn('⚠️ Switching to memory storage mode...'); | |
this.useMemoryStorage = true; | |
console.log('✅ Memory storage mode activated'); | |
this.initialized = true; | |
return; | |
} | |
// Check for placeholder repository URLs | |
const validRepos = this.repositories.filter(repo => { | |
const isPlaceholder = repo.includes('your-username') || repo.includes('placeholder'); | |
if (isPlaceholder) { | |
console.warn(`⚠️ Skipping placeholder repository: ${repo}`); | |
return false; | |
} | |
return this.isValidRepoUrl(repo); | |
}); | |
if (validRepos.length === 0) { | |
console.warn('❌ No valid GitHub repositories found (all are placeholders)!'); | |
console.warn('⚠️ Switching to memory storage mode...'); | |
this.useMemoryStorage = true; | |
console.log('✅ Memory storage mode activated'); | |
this.initialized = true; | |
return; | |
} | |
this.repositories = validRepos; | |
// 验证每个仓库URL的格式 | |
this.repositories.forEach((repo, index) => { | |
if (!this.isValidRepoUrl(repo)) { | |
console.error(`❌ Invalid repository URL at index ${index}: ${repo}`); | |
throw new Error(`Invalid repository URL: ${repo}. Expected format: https://github.com/owner/repo`); | |
} | |
}); | |
console.log('✅ GitHub Service initialized successfully'); | |
this.initialized = true; | |
} catch (error) { | |
console.error('❌ GitHub Service initialization failed:', error); | |
console.warn('⚠️ Falling back to memory storage mode...'); | |
this.useMemoryStorage = true; | |
this.initialized = true; | |
} | |
} | |
// 确保在所有方法中先初始化 | |
async ensureInitialized() { | |
if (!this.initPromise) { | |
this.initPromise = this.initialize(); | |
} | |
await this.initPromise; | |
} | |
// 验证仓库URL格式 | |
isValidRepoUrl(repoUrl) { | |
const githubUrlPattern = /^https:\/\/github\.com\/[^\/]+\/[^\/]+\/?$/; | |
return githubUrlPattern.test(repoUrl.trim()); | |
} | |
// 带重试的API请求方法 | |
async makeGitHubRequest(requestFn, operation = 'GitHub API request', maxRetries = null) { | |
const retries = maxRetries || this.retryConfig.maxRetries; | |
let lastError = null; | |
for (let attempt = 0; attempt <= retries; attempt++) { | |
try { | |
if (attempt > 0) { | |
const delay = this.retryConfig.retryDelay * Math.pow(this.retryConfig.backoffMultiplier, attempt - 1); | |
console.log(`🔄 Retrying ${operation} (attempt ${attempt + 1}/${retries + 1}) after ${delay}ms...`); | |
await new Promise(resolve => setTimeout(resolve, delay)); | |
} | |
const result = await requestFn(); | |
if (attempt > 0) { | |
console.log(`✅ ${operation} succeeded on retry attempt ${attempt + 1}`); | |
} | |
return result; | |
} catch (error) { | |
lastError = error; | |
// 判断是否应该重试 | |
if (!this.shouldRetry(error) || attempt === retries) { | |
break; | |
} | |
console.warn(`⚠️ ${operation} failed (attempt ${attempt + 1}):`, error.message); | |
} | |
} | |
console.error(`❌ ${operation} failed after ${retries + 1} attempts:`, lastError.message); | |
throw lastError; | |
} | |
// 判断错误是否应该重试 | |
shouldRetry(error) { | |
if (!error.response) { | |
return true; // 网络错误,重试 | |
} | |
const status = error.response.status; | |
// 这些状态码不应该重试 | |
if ([400, 401, 403, 404, 422].includes(status)) { | |
return false; | |
} | |
// 5xx错误和429限制错误应该重试 | |
if (status >= 500 || status === 429) { | |
return true; | |
} | |
return false; | |
} | |
// 验证GitHub连接 | |
async validateConnection() { | |
await this.ensureInitialized(); | |
if (this.useMemoryStorage) { | |
console.log('📝 Memory storage mode active'); | |
return { | |
valid: true, | |
useMemoryStorage: true, | |
repositories: [], | |
message: 'Using memory storage mode for development' | |
}; | |
} | |
try { | |
// 测试GitHub API连接 | |
const response = await this.makeGitHubRequest(async () => { | |
return await axios.get(`${this.apiUrl}/user`, { | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
}); | |
}, 'GitHub user authentication'); | |
console.log('GitHub API connection successful:', response.data.login); | |
// 测试仓库访问和权限 | |
const repoResults = []; | |
for (const repoUrl of this.repositories) { | |
try { | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
// 检查仓库基本信息 | |
const repoResponse = await this.makeGitHubRequest(async () => { | |
return await axios.get(`${this.apiUrl}/repos/${owner}/${repo}`, { | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
}); | |
}, `Repository access check for ${owner}/${repo}`); | |
// 检查权限 | |
const permissions = repoResponse.data.permissions; | |
console.log(`Repository permissions for ${owner}/${repo}:`, permissions); | |
// 测试是否可以访问仓库内容 | |
let canAccessContents = false; | |
let repoEmpty = false; | |
try { | |
await axios.get(`${this.apiUrl}/repos/${owner}/${repo}/contents`, { | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 15000 | |
}); | |
canAccessContents = true; | |
} catch (contentsError) { | |
console.log(`Cannot access repository contents: ${contentsError.message}`); | |
// 如果是409错误,说明仓库为空 | |
if (contentsError.response?.status === 409) { | |
repoEmpty = true; | |
console.log('Repository appears to be empty, will initialize when needed'); | |
} | |
} | |
repoResults.push({ | |
url: repoUrl, | |
accessible: true, | |
name: repoResponse.data.full_name, | |
permissions, | |
canAccessContents, | |
repoEmpty, | |
defaultBranch: repoResponse.data.default_branch, | |
size: repoResponse.data.size | |
}); | |
} catch (error) { | |
console.error(`Repository access error for ${repoUrl}:`, error.response?.data || error.message); | |
repoResults.push({ url: repoUrl, accessible: false, error: error.message }); | |
} | |
} | |
return { valid: true, user: response.data.login, repositories: repoResults }; | |
} catch (error) { | |
console.error('GitHub connection validation failed:', error.message); | |
return { valid: false, reason: error.message }; | |
} | |
} | |
// 获取仓库信息 | |
parseRepoUrl(repoUrl) { | |
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/); | |
if (!match) throw new Error('Invalid GitHub repository URL'); | |
return { owner: match[1], repo: match[2] }; | |
} | |
// 初始化空仓库 | |
async initializeRepository(repoIndex = 0) { | |
await this.ensureInitialized(); | |
if (this.useMemoryStorage) { | |
console.log('📝 Memory storage mode - no repository initialization needed'); | |
return { success: true, message: 'Memory storage initialized' }; | |
} | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
try { | |
console.log(`🚀 Initializing repository: ${owner}/${repo}`); | |
// 创建初始README文件 | |
const readmeContent = `# PPT Storage Repository\n\nThis repository is used to store PPT data files.\n\nCreated: ${new Date().toISOString()}`; | |
const content = Buffer.from(readmeContent).toString('base64'); | |
await this.makeGitHubRequest(async () => { | |
return await axios.put( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/README.md`, | |
{ | |
message: 'Initialize PPT storage repository', | |
content: content | |
}, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
} | |
); | |
}, `Repository initialization for ${owner}/${repo}`); | |
console.log(`✅ Repository ${owner}/${repo} initialized successfully`); | |
return { success: true, message: 'Repository initialized' }; | |
} catch (error) { | |
console.error(`❌ Repository initialization failed:`, error); | |
throw new Error(`Failed to initialize repository: ${error.message}`); | |
} | |
} | |
// 兼容性方法:旧的getFile方法重定向到新的getPPT | |
async getFile(userId, fileName, repoIndex = 0) { | |
await this.ensureInitialized(); | |
const pptId = fileName.replace('.json', ''); | |
return await this.getPPT(userId, pptId, repoIndex); | |
} | |
// 兼容性方法:旧的saveFile方法重定向到新的savePPT | |
async saveFile(userId, fileName, data, repoIndex = 0) { | |
await this.ensureInitialized(); | |
const pptId = fileName.replace('.json', ''); | |
return await this.savePPT(userId, pptId, data, repoIndex); | |
} | |
// 数据格式标准化 | |
normalizeDataFormat(data) { | |
if (!data || typeof data !== 'object') { | |
throw new Error('Invalid data format provided'); | |
} | |
const normalized = { | |
id: data.id || data.pptId || `ppt-${Date.now()}`, | |
title: data.title || '未命名演示文稿', | |
slides: Array.isArray(data.slides) ? data.slides : [], | |
theme: data.theme || { | |
backgroundColor: '#ffffff', | |
themeColor: '#d14424', | |
fontColor: '#333333', | |
fontName: 'Microsoft YaHei' | |
}, | |
viewportSize: data.viewportSize || 1000, | |
viewportRatio: data.viewportRatio || 0.5625, | |
createdAt: data.createdAt || new Date().toISOString(), | |
updatedAt: new Date().toISOString() | |
}; | |
// 标准化slides数据 | |
normalized.slides = normalized.slides.map((slide, index) => { | |
if (!slide || typeof slide !== 'object') { | |
return { | |
id: `slide-${index}`, | |
elements: [], | |
background: { type: 'solid', color: '#ffffff' } | |
}; | |
} | |
return { | |
id: slide.id || `slide-${index}`, | |
elements: Array.isArray(slide.elements) ? slide.elements : [], | |
background: slide.background || { type: 'solid', color: '#ffffff' }, | |
...slide | |
}; | |
}); | |
// 保留原始数据的其他字段 | |
Object.keys(data).forEach(key => { | |
if (!normalized.hasOwnProperty(key) && key !== 'slides') { | |
normalized[key] = data[key]; | |
} | |
}); | |
return normalized; | |
} | |
// 兼容性:旧的deleteFile方法 | |
async deleteFile(userId, fileName, repoIndex = 0) { | |
await this.ensureInitialized(); | |
if (this.useMemoryStorage) { | |
const key = `users/${userId}/${fileName}`; | |
return this.memoryStorage.delete(key); | |
} | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
const path = `users/${userId}/${fileName}`; | |
try { | |
// 获取文件信息 | |
const response = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${path}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
} | |
); | |
}, `Get file info for deletion: ${fileName}`); | |
// 删除主文件 | |
await this.makeGitHubRequest(async () => { | |
return await axios.delete( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${path}`, | |
{ | |
data: { | |
message: `Delete legacy PPT: ${fileName}`, | |
sha: response.data.sha | |
}, | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
} | |
); | |
}, `Delete legacy file: ${fileName}`); | |
console.log(`✅ Legacy file deleted: ${fileName}`); | |
return true; | |
} catch (error) { | |
if (error.response?.status === 404) { | |
console.log(`📄 File not found: ${fileName}`); | |
return false; | |
} | |
console.error(`❌ Delete failed for ${fileName}:`, error); | |
throw new Error(`Failed to delete file: ${error.message}`); | |
} | |
} | |
// Memory storage methods - 兼容性方法 | |
async saveToMemory(userId, fileName, data) { | |
const pptId = fileName.replace('.json', ''); | |
return await this.savePPTToMemory(userId, pptId, data); | |
} | |
async getFromMemory(userId, fileName) { | |
const pptId = fileName.replace('.json', ''); | |
const result = await this.getPPTFromMemory(userId, pptId); | |
return result ? result.content : null; | |
} | |
// 新架构:获取PPT(文件夹模式) - 简化版本 | |
async getPPT(userId, pptId, repoIndex = 0) { | |
await this.ensureInitialized(); | |
if (this.useMemoryStorage) { | |
return await this.getPPTFromMemory(userId, pptId); | |
} | |
try { | |
console.log(`📂 Getting PPT folder: ${pptId} for user: ${userId}`); | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
const pptFolderPath = `users/${userId}/${pptId}`; | |
// 1. 获取PPT文件夹内容 - 带重试 | |
const folderResponse = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${pptFolderPath}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
} | |
); | |
}, `Get PPT folder contents for ${pptId}`); | |
// 2. 读取元数据文件 | |
const metaFile = folderResponse.data.find(file => file.name === 'meta.json'); | |
if (!metaFile) { | |
throw new Error('PPT metadata file not found'); | |
} | |
const metaResponse = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${metaFile.path}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 15000 | |
} | |
); | |
}, `Get PPT metadata for ${pptId}`); | |
let metadata; | |
try { | |
const metaContent = Buffer.from(metaResponse.data.content, 'base64').toString('utf8'); | |
metadata = JSON.parse(metaContent); | |
} catch (parseError) { | |
console.error('❌ Failed to parse metadata:', parseError); | |
throw new Error('Invalid PPT metadata format'); | |
} | |
// 3. 加载所有slide文件 | |
const allFiles = folderResponse.data; | |
const slides = []; | |
const failedSlides = []; | |
// 查找slide文件(只处理普通文件,忽略拆分文件) | |
const slideFiles = allFiles.filter(file => | |
file.name.startsWith('slide_') && | |
file.name.endsWith('.json') && | |
!file.name.includes('_main') && | |
!file.name.includes('_part_') | |
); | |
// 按序号排序 | |
const sortedSlideFiles = slideFiles.sort((a, b) => { | |
const aIndex = parseInt(a.name.match(/slide_(\d+)\.json/)?.[1] || '0'); | |
const bIndex = parseInt(b.name.match(/slide_(\d+)\.json/)?.[1] || '0'); | |
return aIndex - bIndex; | |
}); | |
for (const slideFile of sortedSlideFiles) { | |
try { | |
const slide = await this.loadSlideFile(slideFile, repoIndex); | |
const slideIndex = parseInt(slideFile.name.match(/slide_(\d+)\.json/)?.[1] || '0'); | |
slides[slideIndex] = slide; | |
} catch (slideError) { | |
console.warn(`⚠️ Failed to load slide ${slideFile.name}:`, slideError.message); | |
failedSlides.push(slideFile.name); | |
} | |
} | |
// 4. 组装完整PPT数据 | |
const finalSlides = slides.filter(slide => slide !== undefined); | |
const pptData = { | |
...metadata, | |
slides: finalSlides, | |
storage: { | |
type: 'folder', | |
slidesCount: finalSlides.length, | |
folderPath: pptFolderPath, | |
loadedAt: new Date().toISOString(), | |
failedSlides: failedSlides.length > 0 ? failedSlides : undefined | |
} | |
}; | |
if (failedSlides.length > 0) { | |
console.warn(`⚠️ PPT loaded with ${failedSlides.length} failed slides`); | |
} else { | |
console.log(`✅ PPT loaded successfully: ${finalSlides.length} slides`); | |
} | |
return { content: pptData }; | |
} catch (error) { | |
if (error.response?.status === 404) { | |
console.log(`📄 PPT folder not found: ${pptId}`); | |
return null; | |
} | |
console.error(`❌ Get PPT failed:`, error); | |
throw new Error(`Failed to get PPT: ${error.message}`); | |
} | |
} | |
// 新增:检查文件大小 | |
async checkFileSize(filePath, repoIndex) { | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
try { | |
const response = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${filePath}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 15000 | |
} | |
); | |
}, `Check file size for ${filePath}`); | |
return { | |
size: response.data.size, | |
sha: response.data.sha | |
}; | |
} catch (error) { | |
if (error.response?.status === 404) { | |
return null; | |
} | |
throw error; | |
} | |
} | |
// 新增:智能读取文件内容(根据大小选择策略) | |
async readFileContent(filePath, repoIndex, maxDirectSize = 1024 * 1024) { // 1MB限制 | |
const fileInfo = await this.checkFileSize(filePath, repoIndex); | |
if (!fileInfo) { | |
return null; | |
} | |
// 如果文件小于限制,直接读取 | |
if (fileInfo.size <= maxDirectSize) { | |
return await this.readFileContentDirect(filePath, repoIndex); | |
} | |
// 大文件:使用Git Blob API读取 | |
console.log(`📊 Large file detected (${(fileInfo.size / 1024 / 1024).toFixed(2)} MB), using blob API`); | |
return await this.readFileContentViaBlob(filePath, fileInfo.sha, repoIndex); | |
} | |
// 直接读取文件(小文件) | |
async readFileContentDirect(filePath, repoIndex) { | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
const response = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${filePath}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
} | |
); | |
}, `Read file content directly: ${filePath}`); | |
const content = Buffer.from(response.data.content, 'base64').toString('utf8'); | |
return JSON.parse(content); | |
} | |
// 通过Git Blob API读取大文件 | |
async readFileContentViaBlob(filePath, sha, repoIndex) { | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
try { | |
console.log(`🔍 Reading large file via blob API: ${filePath}`); | |
const response = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/git/blobs/${sha}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 120000 // 2分钟超时 | |
} | |
); | |
}, `Read blob for large file: ${filePath}`); | |
if (response.data.encoding === 'base64') { | |
const content = Buffer.from(response.data.content, 'base64').toString('utf8'); | |
return JSON.parse(content); | |
} else { | |
// 如果不是base64编码,直接解析 | |
return JSON.parse(response.data.content); | |
} | |
} catch (error) { | |
console.error(`❌ Blob API read failed for ${filePath}:`, error.message); | |
// 回退到分批读取策略 | |
console.log(`🔄 Falling back to chunked reading...`); | |
return await this.readLargeFileInChunks(filePath, repoIndex); | |
} | |
} | |
// 分批读取大文件(最后的回退策略) | |
async readLargeFileInChunks(filePath, repoIndex) { | |
console.log(`📦 Attempting chunked read for: ${filePath}`); | |
// 检查是否存在分块文件(PPT文件夹结构) | |
const folderPath = filePath.replace(/\/[^\/]+$/, ''); | |
try { | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
// 尝试读取文件夹内容 | |
const folderResponse = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${folderPath}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
} | |
); | |
}, `Read folder for chunked file: ${folderPath}`); | |
// 查找相关的分块文件 | |
const chunkFiles = folderResponse.data.filter(file => | |
file.name.includes('_part_') || file.name.includes('_chunk_') | |
); | |
if (chunkFiles.length > 0) { | |
console.log(`📊 Found ${chunkFiles.length} chunk files, reassembling...`); | |
return await this.reassembleChunkedFiles(chunkFiles, repoIndex); | |
} | |
throw new Error('No chunked files found, cannot read large file'); | |
} catch (error) { | |
console.error(`❌ Chunked read failed:`, error.message); | |
throw new Error(`Unable to read large file: ${filePath}. File may be too large for current GitHub API limits.`); | |
} | |
} | |
// 重组分块文件 | |
async reassembleChunkedFiles(chunkFiles, repoIndex) { | |
const chunks = []; | |
// 按顺序读取所有分块 | |
const sortedChunks = chunkFiles.sort((a, b) => { | |
const aIndex = parseInt(a.name.match(/_(\d+)(?:_|\.)/)?.[1] || '0'); | |
const bIndex = parseInt(b.name.match(/_(\d+)(?:_|\.)/)?.[1] || '0'); | |
return aIndex - bIndex; | |
}); | |
for (const chunkFile of sortedChunks) { | |
try { | |
const chunkContent = await this.readFileContentDirect(chunkFile.path, repoIndex); | |
chunks.push(chunkContent); | |
} catch (error) { | |
console.error(`⚠️ Failed to read chunk ${chunkFile.name}:`, error.message); | |
} | |
} | |
// 重组数据 | |
if (chunks.length === 0) { | |
throw new Error('No valid chunks found'); | |
} | |
// 假设第一个chunk包含基础结构 | |
const baseData = chunks[0]; | |
const allSlides = []; | |
chunks.forEach(chunk => { | |
if (chunk.slides && Array.isArray(chunk.slides)) { | |
allSlides.push(...chunk.slides); | |
} | |
}); | |
return { | |
...baseData, | |
slides: allSlides, | |
storage: { | |
type: 'reassembled', | |
chunksCount: chunks.length, | |
reassembledAt: new Date().toISOString() | |
} | |
}; | |
} | |
// 修改现有的loadSlideFile方法,使用新的智能读取 | |
async loadSlideFile(slideFile, repoIndex) { | |
try { | |
// 使用智能读取策略 | |
const slideContent = await this.readFileContent(slideFile.path, repoIndex); | |
return slideContent; | |
} catch (error) { | |
console.error(`❌ Failed to load slide file ${slideFile.name}:`, error.message); | |
throw error; | |
} | |
} | |
// slide压缩算法 - 保留轻量压缩 | |
async compressSlide(slide) { | |
let compressedSlide = JSON.parse(JSON.stringify(slide)); // 深拷贝 | |
// 压缩策略1:移除不必要的属性 | |
if (compressedSlide.elements) { | |
compressedSlide.elements = compressedSlide.elements.map(element => { | |
// 移除编辑状态属性 | |
element = this.removeUnnecessaryProps(element); | |
// 压缩文本内容(移除多余空白) | |
if (element.type === 'text' && element.content && typeof element.content === 'string') { | |
element.content = element.content.replace(/\s+/g, ' ').trim(); | |
} | |
return element; | |
}); | |
} | |
// 压缩策略2:精简数值精度 | |
compressedSlide = this.roundNumericValues(compressedSlide); | |
const compressedSize = Buffer.byteLength(JSON.stringify(compressedSlide), 'utf8'); | |
console.log(`🗜️ Light compression applied: ${(compressedSize / 1024).toFixed(2)} KB`); | |
return compressedSlide; | |
} | |
// 移除不必要的属性 | |
removeUnnecessaryProps(element) { | |
const unnecessaryProps = [ | |
'selected', 'editing', 'dragData', 'resizeData', | |
'tempData', 'cache', 'debug' | |
]; | |
unnecessaryProps.forEach(prop => { | |
if (element[prop] !== undefined) { | |
delete element[prop]; | |
} | |
}); | |
return element; | |
} | |
// 精简数值精度 - 保持高精度的关键属性 | |
roundNumericValues(obj, precision = 2) { | |
if (typeof obj !== 'object' || obj === null) { | |
return obj; | |
} | |
if (Array.isArray(obj)) { | |
return obj.map(item => this.roundNumericValues(item, precision)); | |
} | |
const rounded = {}; | |
for (const [key, value] of Object.entries(obj)) { | |
if (typeof value === 'number') { | |
// 🎯 关键布局属性保持高精度 | |
if (['left', 'top', 'width', 'height', 'x', 'y', 'viewportRatio', 'viewportSize'].includes(key)) { | |
rounded[key] = Math.round(value * 1000000000) / 1000000000; // 9位精度 | |
} | |
// 🎯 变换和旋转属性保持高精度 | |
else if (key.includes('transform') || key.includes('rotate') || key === 'rotate') { | |
rounded[key] = Math.round(value * 1000000000) / 1000000000; | |
} | |
// 📐 其他数值属性适度精简 | |
else { | |
rounded[key] = typeof value === 'number' && !isNaN(value) && isFinite(value) | |
? Math.round(value * 1000000) / 1000000 // 6位精度 | |
: value; | |
} | |
} else if (typeof value === 'object') { | |
rounded[key] = this.roundNumericValues(value, precision); | |
} else { | |
rounded[key] = value; | |
} | |
} | |
return rounded; | |
} | |
// 通用文件保存到仓库 - 简化版本 | |
async saveFileToRepo(filePath, data, commitMessage, repoIndex) { | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
try { | |
// 验证数据 | |
if (!data || typeof data !== 'object') { | |
throw new Error('Invalid data provided for file save'); | |
} | |
const content = Buffer.from(JSON.stringify(data)).toString('base64'); | |
const fileSize = Buffer.byteLength(JSON.stringify(data), 'utf8'); | |
// 检查文件大小(GitHub限制100MB) | |
if (fileSize > 100 * 1024 * 1024) { | |
throw new Error(`File too large: ${(fileSize / 1024 / 1024).toFixed(2)} MB exceeds GitHub's 100MB limit`); | |
} | |
console.log(`💾 Saving file: ${filePath} (${(fileSize / 1024).toFixed(2)} KB)`); | |
// 检查文件是否已存在 | |
let sha = null; | |
try { | |
const existingResponse = await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${filePath}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
} | |
); | |
sha = existingResponse.data.sha; | |
} catch (error) { | |
// 文件不存在,将创建新文件 | |
} | |
// 保存文件 | |
const response = await axios.put( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${filePath}`, | |
{ | |
message: commitMessage, | |
content: content, | |
...(sha && { sha }) | |
}, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 60000 | |
} | |
); | |
console.log(`✅ File saved successfully: ${filePath}`); | |
return response.data; | |
} catch (error) { | |
console.error(`❌ File save failed for ${filePath}:`, error.message); | |
if (error.response?.status === 422) { | |
if (error.response?.data?.message?.includes('file is too large')) { | |
throw new Error(`File too large: ${filePath} exceeds GitHub size limits`); | |
} | |
if (error.response?.data?.message?.includes('Invalid request')) { | |
throw new Error(`Invalid file format for: ${filePath}`); | |
} | |
} | |
if (error.response?.status === 409) { | |
throw new Error(`File conflict for: ${filePath}. File may have been modified by another process.`); | |
} | |
throw new Error(`Failed to save file ${filePath}: ${error.message}`); | |
} | |
} | |
// 单个slide保存 - 简化版本 | |
async saveSlideWithCompression(filePath, slide, slideIndex, repoIndex) { | |
try { | |
// 应用轻量压缩 | |
const compressedSlide = await this.compressSlide(slide); | |
await this.makeGitHubRequest(async () => { | |
return await this.saveFileToRepo( | |
filePath, | |
compressedSlide, | |
`Save slide ${slideIndex}`, | |
repoIndex | |
); | |
}, `Save slide ${slideIndex}`); | |
const finalSize = Buffer.byteLength(JSON.stringify(compressedSlide), 'utf8'); | |
return { | |
slideIndex, | |
finalSize, | |
compressed: true | |
}; | |
} catch (error) { | |
console.error(`❌ Failed to save slide ${slideIndex}:`, error); | |
throw error; | |
} | |
} | |
// 新架构:保存PPT(文件夹模式) - 简化版本 | |
async savePPT(userId, pptId, pptData, repoIndex = 0) { | |
await this.ensureInitialized(); | |
if (this.useMemoryStorage) { | |
return await this.savePPTToMemory(userId, pptId, pptData); | |
} | |
try { | |
console.log(`📂 Saving PPT to folder: ${pptId} for user: ${userId}`); | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
const pptFolderPath = `users/${userId}/${pptId}`; | |
// 验证输入数据 | |
if (!pptData || typeof pptData !== 'object') { | |
throw new Error('Invalid PPT data provided'); | |
} | |
// 1. 准备元数据(不包含slides) | |
const metadata = { | |
id: pptData.id || pptId, | |
title: pptData.title || '未命名演示文稿', | |
theme: pptData.theme || { | |
backgroundColor: '#ffffff', | |
themeColor: '#d14424', | |
fontColor: '#333333', | |
fontName: 'Microsoft YaHei' | |
}, | |
viewportSize: pptData.viewportSize || 1000, | |
viewportRatio: pptData.viewportRatio || 0.5625, | |
createdAt: pptData.createdAt || new Date().toISOString(), | |
updatedAt: new Date().toISOString(), | |
storage: { | |
type: 'folder', | |
version: '2.0', | |
slidesCount: pptData.slides?.length || 0 | |
} | |
}; | |
// 2. 保存元数据文件 | |
await this.makeGitHubRequest(async () => { | |
return await this.saveFileToRepo( | |
`${pptFolderPath}/meta.json`, | |
metadata, | |
`Update PPT metadata: ${metadata.title}`, | |
repoIndex | |
); | |
}, `Save metadata for PPT ${pptId}`); | |
console.log(`✅ Metadata saved for PPT: ${pptId}`); | |
// 3. 串行化保存每个slide文件 | |
const slides = Array.isArray(pptData.slides) ? pptData.slides : []; | |
const saveResults = []; | |
console.log(`📊 Starting to save ${slides.length} slides...`); | |
for (let i = 0; i < slides.length; i++) { | |
const slide = slides[i]; | |
const slideFileName = `slide_${String(i).padStart(3, '0')}.json`; | |
console.log(`💾 Saving slide ${i + 1}/${slides.length}: ${slideFileName}`); | |
try { | |
// 验证slide数据 | |
if (!slide || typeof slide !== 'object') { | |
throw new Error(`Invalid slide data at index ${i}`); | |
} | |
const result = await this.saveSlideWithCompression( | |
`${pptFolderPath}/${slideFileName}`, | |
slide, | |
i, | |
repoIndex | |
); | |
saveResults.push(result); | |
console.log(`✅ Slide ${i} saved: ${(result.finalSize / 1024).toFixed(2)} KB`); | |
// 添加延迟避免GitHub API速率限制 | |
if (i < slides.length - 1) { | |
await new Promise(resolve => setTimeout(resolve, this.apiRateLimit.minDelay)); | |
} | |
} catch (slideError) { | |
console.error(`❌ Failed to save slide ${i}: ${slideError.message}`); | |
throw slideError; // 任何slide保存失败都应该停止整个过程 | |
} | |
} | |
// 4. 计算保存统计 | |
const totalSize = saveResults.reduce((sum, r) => sum + r.finalSize, 0); | |
console.log(`🎉 PPT saved successfully: ${slides.length} slides, total size: ${(totalSize / 1024).toFixed(2)} KB`); | |
return { | |
success: true, | |
pptId: pptId, | |
storage: 'folder', | |
slidesCount: slides.length, | |
folderPath: pptFolderPath, | |
size: totalSize | |
}; | |
} catch (error) { | |
console.error(`❌ PPT save failed for ${pptId}: ${error.message}`); | |
throw new Error(`Failed to save PPT: ${error.message}`); | |
} | |
} | |
// Memory storage methods - 简化版本 | |
async getPPTFromMemory(userId, pptId) { | |
const metaKey = `users/${userId}/${pptId}/meta`; | |
const metadata = this.memoryStorage.get(metaKey); | |
if (!metadata) { | |
console.log(`📄 PPT not found in memory: ${pptId}`); | |
return null; | |
} | |
// 重组slides | |
const slides = []; | |
const slidesCount = metadata.storage?.slidesCount || 0; | |
for (let i = 0; i < slidesCount; i++) { | |
const slideKey = `users/${userId}/${pptId}/slide_${String(i).padStart(3, '0')}`; | |
const slide = this.memoryStorage.get(slideKey); | |
if (slide) { | |
slides.push(slide); | |
} | |
} | |
const pptData = { | |
...metadata, | |
slides: slides, | |
storage: { | |
...metadata.storage, | |
loadedAt: new Date().toISOString() | |
} | |
}; | |
console.log(`📖 Read PPT from memory: ${pptId} (${slides.length} slides)`); | |
return { content: pptData }; | |
} | |
// 简化内存存储 | |
async savePPTToMemory(userId, pptId, pptData) { | |
// 保存元数据 | |
const metadata = { | |
id: pptData.id || pptId, | |
title: pptData.title || '未命名演示文稿', | |
theme: pptData.theme, | |
viewportSize: pptData.viewportSize || 1000, | |
viewportRatio: pptData.viewportRatio || 0.5625, | |
createdAt: pptData.createdAt || new Date().toISOString(), | |
updatedAt: new Date().toISOString(), | |
storage: { | |
type: 'folder', | |
version: '2.0', | |
slidesCount: pptData.slides?.length || 0 | |
} | |
}; | |
const metaKey = `users/${userId}/${pptId}/meta`; | |
this.memoryStorage.set(metaKey, metadata); | |
// 保存slides | |
const slides = pptData.slides || []; | |
let totalSize = 0; | |
for (let i = 0; i < slides.length; i++) { | |
const slide = slides[i]; | |
const compressedSlide = await this.compressSlide(slide); | |
const slideKey = `users/${userId}/${pptId}/slide_${String(i).padStart(3, '0')}`; | |
this.memoryStorage.set(slideKey, compressedSlide); | |
totalSize += Buffer.byteLength(JSON.stringify(compressedSlide), 'utf8'); | |
} | |
console.log(`💾 Saved PPT to memory: ${pptId} (${slides.length} slides, ${(totalSize / 1024).toFixed(2)} KB)`); | |
return { | |
success: true, | |
storage: 'folder', | |
slidesCount: slides.length, | |
size: totalSize | |
}; | |
} | |
// 删除PPT时清理所有文件 | |
async deletePPTFromMemory(userId, pptId) { | |
let deleted = 0; | |
const prefix = `users/${userId}/${pptId}/`; | |
// 删除所有相关键 | |
for (const key of this.memoryStorage.keys()) { | |
if (key.startsWith(prefix)) { | |
this.memoryStorage.delete(key); | |
deleted++; | |
} | |
} | |
console.log(`📝 Memory storage: Deleted ${deleted} files for PPT ${pptId}`); | |
return deleted > 0; | |
} | |
// 更新用户PPT列表 | |
async getUserPPTListFromMemory(userId) { | |
const results = []; | |
const userPrefix = `users/${userId}/`; | |
const pptIds = new Set(); | |
// 收集所有PPT ID | |
for (const key of this.memoryStorage.keys()) { | |
if (key.startsWith(userPrefix) && key.includes('/meta')) { | |
const pptId = key.replace(userPrefix, '').replace('/meta', ''); | |
pptIds.add(pptId); | |
} | |
} | |
// 获取每个PPT的元数据 | |
for (const pptId of pptIds) { | |
const metaKey = `${userPrefix}${pptId}/meta`; | |
const metadata = this.memoryStorage.get(metaKey); | |
if (metadata) { | |
results.push({ | |
name: pptId, | |
title: metadata.title || '未命名演示文稿', | |
updatedAt: metadata.updatedAt || new Date().toISOString(), | |
slidesCount: metadata.storage?.slidesCount || 0, | |
isChunked: false, | |
storageType: 'folder', | |
size: JSON.stringify(metadata).length, | |
repoIndex: 0 | |
}); | |
} | |
} | |
console.log(`📋 Found ${results.length} PPTs in memory for user ${userId}`); | |
return results.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); | |
} | |
// 获取用户PPT列表(新架构) - 简化版本 | |
async getUserPPTList(userId) { | |
await this.ensureInitialized(); | |
if (this.useMemoryStorage) { | |
return await this.getUserPPTListFromMemory(userId); | |
} | |
const pptList = []; | |
// 检查所有仓库 | |
for (let repoIndex = 0; repoIndex < this.repositories.length; repoIndex++) { | |
try { | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
const userDirPath = `users/${userId}`; | |
const response = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${userDirPath}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
} | |
); | |
}, `Get user directory for ${userId} in repo ${repoIndex}`); | |
// 查找PPT文件夹 | |
const pptFolders = response.data.filter(item => | |
item.type === 'dir' // PPT存储为文件夹 | |
); | |
for (const folder of pptFolders) { | |
try { | |
const pptId = folder.name; | |
// 获取PPT元数据 | |
const metaResponse = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${userDirPath}/${pptId}/meta.json`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 15000 | |
} | |
); | |
}, `Get metadata for PPT ${pptId}`); | |
const metaContent = Buffer.from(metaResponse.data.content, 'base64').toString('utf8'); | |
const metadata = JSON.parse(metaContent); | |
pptList.push({ | |
name: pptId, | |
title: metadata.title || '未命名演示文稿', | |
updatedAt: metadata.updatedAt || new Date().toISOString(), | |
slidesCount: metadata.storage?.slidesCount || 0, | |
isChunked: false, | |
storageType: 'folder', | |
size: metaResponse.data.size || 0, | |
repoIndex: repoIndex | |
}); | |
} catch (error) { | |
console.warn(`跳过无效PPT文件夹 ${folder.name}:`, error.message); | |
} | |
} | |
// 兼容性:同时检查旧的单文件格式 | |
const jsonFiles = response.data.filter(file => | |
file.type === 'file' && | |
file.name.endsWith('.json') && | |
!file.name.includes('_chunk_') | |
); | |
for (const file of jsonFiles) { | |
try { | |
const pptId = file.name.replace('.json', ''); | |
// 避免重复添加(如果已经有文件夹版本) | |
if (pptList.some(p => p.name === pptId)) { | |
continue; | |
} | |
const fileResponse = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${userDirPath}/${file.name}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 15000 | |
} | |
); | |
}, `Get legacy PPT file ${file.name}`); | |
const content = Buffer.from(fileResponse.data.content, 'base64').toString('utf8'); | |
const pptData = JSON.parse(content); | |
pptList.push({ | |
name: pptId, | |
title: pptData.title || '未命名演示文稿', | |
updatedAt: pptData.updatedAt || new Date().toISOString(), | |
slidesCount: pptData.isChunked ? pptData.totalSlides : (pptData.slides?.length || 0), | |
isChunked: pptData.isChunked || false, | |
storageType: 'legacy', | |
size: file.size, | |
repoIndex: repoIndex | |
}); | |
} catch (error) { | |
console.warn(`跳过无效文件 ${file.name}:`, error.message); | |
} | |
} | |
} catch (error) { | |
console.warn(`仓库 ${repoIndex} 中没有找到用户目录或访问失败:`, error.message); | |
continue; | |
} | |
} | |
// 按更新时间排序 | |
return pptList.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); | |
} | |
// 删除PPT(新架构) - 简化版本 | |
async deletePPT(userId, pptId, repoIndex = 0) { | |
await this.ensureInitialized(); | |
if (this.useMemoryStorage) { | |
return await this.deletePPTFromMemory(userId, pptId); | |
} | |
const repoUrl = this.repositories[repoIndex]; | |
const { owner, repo } = this.parseRepoUrl(repoUrl); | |
const pptFolderPath = `users/${userId}/${pptId}`; | |
try { | |
console.log(`🗑️ Deleting PPT folder: ${pptFolderPath}`); | |
// 1. 获取文件夹内容 | |
const folderResponse = await this.makeGitHubRequest(async () => { | |
return await axios.get( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${pptFolderPath}`, | |
{ | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
} | |
); | |
}, `Get PPT folder contents for deletion: ${pptId}`); | |
// 2. 串行删除所有文件 | |
for (const file of folderResponse.data) { | |
try { | |
await this.makeGitHubRequest(async () => { | |
return await axios.delete( | |
`${this.apiUrl}/repos/${owner}/${repo}/contents/${file.path}`, | |
{ | |
data: { | |
message: `Delete PPT file: ${file.name}`, | |
sha: file.sha | |
}, | |
headers: { | |
'Authorization': `token ${this.token}`, | |
'Accept': 'application/vnd.github.v3+json' | |
}, | |
timeout: 30000 | |
} | |
); | |
}, `Delete file ${file.name}`); | |
console.log(`✅ Deleted file: ${file.name}`); | |
// 添加延迟避免API限制 | |
await new Promise(resolve => setTimeout(resolve, this.apiRateLimit.minDelay)); | |
} catch (error) { | |
console.warn(`⚠️ Failed to delete file ${file.name}:`, error.message); | |
} | |
} | |
console.log(`✅ PPT folder deleted successfully: ${pptId}`); | |
return true; | |
} catch (error) { | |
if (error.response?.status === 404) { | |
console.log(`📄 PPT folder not found, trying legacy format: ${pptId}`); | |
return await this.deleteFile(userId, `${pptId}.json`, repoIndex); | |
} | |
console.error(`❌ Delete PPT failed for ${pptId}:`, error); | |
throw new Error(`Failed to delete PPT: ${error.message}`); | |
} | |
} | |
} | |
export default new GitHubService(); |