/** * Blog System Module * Handles blog storage, retrieval, and management * Supports dual-source: repo-based (JSON files) and database-based */ const fs = require('fs/promises'); const fsSync = require('fs'); const path = require('path'); // Blog configuration const BLOGS_REPO_DIR = path.join(process.cwd(), 'blogs'); const BLOGS_DB_FILE = path.join(process.cwd(), '.data', '.opencode-chat', 'blogs.db.json'); const BLOGS_UPLOAD_DIR = path.join(process.cwd(), 'public', 'blogs', 'images'); // In-memory cache let blogsCache = null; let categoriesCache = null; let lastCacheTime = 0; const CACHE_TTL = 60000; // 1 minute /** * Initialize blog storage */ async function initBlogStorage() { try { // Ensure upload directory exists await fs.mkdir(BLOGS_UPLOAD_DIR, { recursive: true }); // Ensure database file exists try { await fs.access(BLOGS_DB_FILE); } catch { await fs.mkdir(path.dirname(BLOGS_DB_FILE), { recursive: true }); await fs.writeFile(BLOGS_DB_FILE, JSON.stringify({ posts: [], categories: [ { id: 'general', name: 'General', slug: 'general' }, { id: 'updates', name: 'Updates', slug: 'updates' }, { id: 'tutorials', name: 'Tutorials', slug: 'tutorials' }, { id: 'wordpress', name: 'WordPress', slug: 'wordpress' } ], lastId: 0 }, null, 2)); } } catch (error) { console.error('Failed to initialize blog storage:', error); } } /** * Load database blogs */ async function loadDbBlogs() { try { const data = await fs.readFile(BLOGS_DB_FILE, 'utf8'); return JSON.parse(data); } catch (error) { return { posts: [], categories: [], lastId: 0 }; } } /** * Save database blogs */ async function saveDbBlogs(data) { await fs.writeFile(BLOGS_DB_FILE, JSON.stringify(data, null, 2)); } /** * Load repo-based blogs */ async function loadRepoBlogs() { const posts = []; try { // Load news blogs const newsDir = path.join(BLOGS_REPO_DIR, 'news'); if (fsSync.existsSync(newsDir)) { const newsFiles = await fs.readdir(newsDir); for (const file of newsFiles) { if (file.endsWith('.json')) { try { const content = await fs.readFile(path.join(newsDir, file), 'utf8'); const post = JSON.parse(content); post.source = 'repo'; post.type = 'news'; posts.push(post); } catch (e) { console.error(`Failed to load news blog ${file}:`, e.message); } } } } // Load SEO blogs const seoDir = path.join(BLOGS_REPO_DIR, 'seo'); if (fsSync.existsSync(seoDir)) { const seoFiles = await fs.readdir(seoDir); for (const file of seoFiles) { if (file.endsWith('.json')) { try { const content = await fs.readFile(path.join(seoDir, file), 'utf8'); const post = JSON.parse(content); post.source = 'repo'; post.type = 'seo'; posts.push(post); } catch (e) { console.error(`Failed to load SEO blog ${file}:`, e.message); } } } } } catch (error) { console.error('Failed to load repo blogs:', error); } return posts; } /** * Get all blogs (merged from repo and database) */ async function getAllBlogs(options = {}) { const { type, status, category, limit, offset, source } = options; // Load both sources const [dbData, repoPosts] = await Promise.all([ loadDbBlogs(), loadRepoBlogs() ]); // Merge posts (database takes precedence for same slug) const slugMap = new Map(); // Add repo posts first for (const post of repoPosts) { if (post.slug) slugMap.set(post.slug, post); } // Add database posts (overwrites repo posts with same slug) for (const post of dbData.posts) { post.source = 'database'; if (post.slug) slugMap.set(post.slug, post); } let blogs = Array.from(slugMap.values()); // Apply filters if (type) blogs = blogs.filter(b => b.type === type); if (status) blogs = blogs.filter(b => b.status === status); if (category) blogs = blogs.filter(b => b.category === category || (b.categories && b.categories.includes(category))); if (source) blogs = blogs.filter(b => b.source === source); // Sort by published date (newest first) blogs.sort((a, b) => { const dateA = new Date(a.published_at || a.created_at || 0); const dateB = new Date(b.published_at || b.created_at || 0); return dateB - dateA; }); // Apply pagination if (offset) blogs = blogs.slice(offset); if (limit) blogs = blogs.slice(0, limit); return blogs; } /** * Get single blog by slug */ async function getBlogBySlug(slug) { const blogs = await getAllBlogs(); return blogs.find(b => b.slug === slug); } /** * Get blog by ID */ async function getBlogById(id) { const dbData = await loadDbBlogs(); const post = dbData.posts.find(p => p.id === id); if (post) { post.source = 'database'; return post; } const allBlogs = await getAllBlogs(); return allBlogs.find(b => b.id === id); } /** * Create new blog post (database only) */ async function createBlog(postData) { const dbData = await loadDbBlogs(); const newPost = { id: `db-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, ...postData, source: 'database', created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; // Check for duplicate slug const exists = dbData.posts.find(p => p.slug === newPost.slug); if (exists) { throw new Error('A post with this slug already exists'); } dbData.posts.push(newPost); await saveDbBlogs(dbData); return newPost; } /** * Update blog post (database only) */ async function updateBlog(id, updates) { const dbData = await loadDbBlogs(); const index = dbData.posts.findIndex(p => p.id === id); if (index === -1) { throw new Error('Post not found'); } // Check if trying to update a repo-based blog if (dbData.posts[index].source === 'repo') { throw new Error('Cannot edit repository-based blogs from admin panel'); } // Check for slug conflict if (updates.slug && updates.slug !== dbData.posts[index].slug) { const exists = dbData.posts.find(p => p.slug === updates.slug && p.id !== id); if (exists) { throw new Error('A post with this slug already exists'); } } dbData.posts[index] = { ...dbData.posts[index], ...updates, updated_at: new Date().toISOString() }; await saveDbBlogs(dbData); return dbData.posts[index]; } /** * Delete blog post (database only) */ async function deleteBlog(id) { const dbData = await loadDbBlogs(); const index = dbData.posts.findIndex(p => p.id === id); if (index === -1) { throw new Error('Post not found'); } if (dbData.posts[index].source === 'repo') { throw new Error('Cannot delete repository-based blogs from admin panel'); } const deleted = dbData.posts.splice(index, 1)[0]; await saveDbBlogs(dbData); return deleted; } /** * Get categories */ async function getCategories() { const dbData = await loadDbBlogs(); return dbData.categories || []; } /** * Create category */ async function createCategory(categoryData) { const dbData = await loadDbBlogs(); const newCategory = { id: `cat-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, ...categoryData, created_at: new Date().toISOString() }; // Check for duplicate slug const exists = dbData.categories.find(c => c.slug === newCategory.slug); if (exists) { throw new Error('A category with this slug already exists'); } dbData.categories.push(newCategory); await saveDbBlogs(dbData); return newCategory; } /** * Update category */ async function updateCategory(id, updates) { const dbData = await loadDbBlogs(); const index = dbData.categories.findIndex(c => c.id === id); if (index === -1) { throw new Error('Category not found'); } // Check for slug conflict if (updates.slug && updates.slug !== dbData.categories[index].slug) { const exists = dbData.categories.find(c => c.slug === updates.slug && c.id !== id); if (exists) { throw new Error('A category with this slug already exists'); } } dbData.categories[index] = { ...dbData.categories[index], ...updates, updated_at: new Date().toISOString() }; await saveDbBlogs(dbData); return dbData.categories[index]; } /** * Delete category */ async function deleteCategory(id) { const dbData = await loadDbBlogs(); const index = dbData.categories.findIndex(c => c.id === id); if (index === -1) { throw new Error('Category not found'); } dbData.categories.splice(index, 1); await saveDbBlogs(dbData); return true; } /** * Generate slug from title */ function generateSlug(title) { return title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .substring(0, 100); } /** * Convert Editor.js blocks to HTML */ function blocksToHtml(blocks) { if (!blocks || !Array.isArray(blocks)) return ''; return blocks.map(block => { switch (block.type) { case 'header': const level = Math.min(Math.max(block.data.level || 2, 1), 6); return `${escapeHtml(block.data.text)}`; case 'paragraph': return `

${escapeHtml(block.data.text)}

`; case 'list': const tag = block.data.style === 'ordered' ? 'ol' : 'ul'; const items = (block.data.items || []).map(item => `
  • ${escapeHtml(item)}
  • `).join(''); return `<${tag}>${items}`; case 'quote': const caption = block.data.caption ? `${escapeHtml(block.data.caption)}` : ''; return `

    ${escapeHtml(block.data.text)}

    ${caption}
    `; case 'code': return `
    ${escapeHtml(block.data.code)}
    `; case 'image': const caption_text = block.data.caption ? `
    ${escapeHtml(block.data.caption)}
    ` : ''; return `
    ${escapeHtml(block.data.caption || '')}${caption_text}
    `; case 'embed': return `
    ${block.data.embed}
    `; case 'table': const rows = (block.data.content || []).map(row => `${row.map(cell => `${escapeHtml(cell)}`).join('')}` ).join(''); return `${rows}
    `; default: return ''; } }).join('\n'); } /** * Escape HTML entities */ function escapeHtml(text) { if (!text) return ''; return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } module.exports = { initBlogStorage, getAllBlogs, getBlogBySlug, getBlogById, createBlog, updateBlog, deleteBlog, getCategories, createCategory, updateCategory, deleteCategory, generateSlug, blocksToHtml, BLOGS_UPLOAD_DIR };