initial commit

This commit is contained in:
2026-02-10 11:04:44 +01:00
commit 5979fa3374
57 changed files with 19344 additions and 0 deletions

440
.github/copilot-cli-instructions.md vendored Normal file
View File

@@ -0,0 +1,440 @@
# GitHub Copilot CLI Instructions for bDS
Quick reference for using GitHub Copilot CLI (`gh copilot`) with this Electron + TypeScript + SQLite project.
## Project Context for CLI Queries
When asking Copilot CLI for help, provide this context for better responses:
```
This is an Electron app using TypeScript, React, Drizzle ORM, and @libsql/client for SQLite.
```
---
## Common CLI Patterns
### Build & Development
```bash
# Ask how to run in development
gh copilot suggest "how to run electron app in dev mode with vite HMR"
# Ask about TypeScript compilation
gh copilot suggest "compile typescript for electron main process with tsconfig"
# Build commands for this project
npm run build:main # TypeScript -> JavaScript for main process
npm run build:renderer # Vite build for React renderer
npm run build # Both
npm start # Launch Electron
```
### Database Operations
```bash
# Generate Drizzle migrations
gh copilot suggest "generate drizzle orm migration for sqlite"
# Query patterns
gh copilot suggest "drizzle orm query to select with where clause typescript"
# Turso sync
gh copilot suggest "sync local sqlite with turso remote database libsql"
```
### Electron Commands
```bash
# IPC patterns
gh copilot suggest "electron ipc invoke handler with typescript types"
# Window management
gh copilot suggest "electron create browser window with preload script"
# Menu creation
gh copilot suggest "electron application menu with keyboard shortcuts"
```
---
## TypeScript Quick Fixes
### Type Errors
```bash
# When you see "Type X is not assignable to type Y"
gh copilot explain "typescript error Type X is not assignable to type Y"
# For generic type issues
gh copilot suggest "typescript generic function that returns same type as input"
# Promise/async issues
gh copilot suggest "typescript async function return type Promise"
```
### Common Patterns
```bash
# Discriminated unions
gh copilot suggest "typescript discriminated union for status types"
# Type guards
gh copilot suggest "typescript type guard function to narrow union type"
# Utility types
gh copilot suggest "typescript Pick Omit Partial for interface modification"
```
---
## SQLite & Drizzle Queries
### Schema Definition
```bash
# Create new table
gh copilot suggest "drizzle orm sqlite table schema with text integer columns"
# Add index
gh copilot suggest "drizzle orm create index on sqlite table"
# Relations
gh copilot suggest "drizzle orm one to many relation sqlite"
```
### Query Building
```bash
# Select with conditions
gh copilot suggest "drizzle orm select where equals and order by"
# Insert with returning
gh copilot suggest "drizzle orm insert returning inserted row"
# Update with conditions
gh copilot suggest "drizzle orm update set where condition"
# Transaction
gh copilot suggest "drizzle orm transaction with multiple operations"
```
### LibSQL Specifics
```bash
# Local file database
gh copilot suggest "libsql client connect to local sqlite file"
# Remote Turso connection
gh copilot suggest "libsql client connect turso with auth token"
# Execute multiple statements
gh copilot suggest "libsql execute multiple sql statements batch"
```
---
## Sync Implementation
### Conflict Detection
```bash
gh copilot suggest "detect sync conflicts using checksums typescript"
gh copilot suggest "last-write-wins conflict resolution pattern"
gh copilot suggest "vector clock for distributed sync"
```
### Offline Queue
```bash
gh copilot suggest "queue offline operations for later sync typescript"
gh copilot suggest "exponential backoff retry logic async"
gh copilot suggest "detect online offline status in electron"
```
### Sync Status
```bash
gh copilot suggest "track sync status pending synced error per record"
gh copilot suggest "sync log table for audit trail"
```
---
## React & Zustand
### Store Patterns
```bash
gh copilot suggest "zustand store with typescript typed actions"
gh copilot suggest "zustand selector to prevent unnecessary rerenders"
gh copilot suggest "zustand persist middleware for local storage"
```
### Component Patterns
```bash
gh copilot suggest "react component with zustand store hook"
gh copilot suggest "react useEffect cleanup for ipc listener"
gh copilot suggest "react context provider with typescript"
```
---
## Electron Security
```bash
# Secure IPC
gh copilot suggest "electron context bridge expose api safely"
# Input validation
gh copilot suggest "validate ipc handler input zod typescript"
# Content Security Policy
gh copilot suggest "electron content security policy meta tag"
```
---
## File Operations
### Markdown with Frontmatter
```bash
gh copilot suggest "read markdown file with yaml frontmatter gray-matter"
gh copilot suggest "write markdown file with yaml frontmatter node"
gh copilot suggest "parse yaml frontmatter to typescript type"
```
### Media Files
```bash
gh copilot suggest "copy file to destination directory nodejs"
gh copilot suggest "get image dimensions from file nodejs"
gh copilot suggest "calculate file checksum md5 nodejs"
```
### Sidecar Pattern
```bash
gh copilot suggest "json sidecar metadata file for binary asset"
gh copilot suggest "read write json file atomic operation"
```
---
## Error Handling
```bash
# Custom errors
gh copilot suggest "typescript custom error class with error code"
# Result types
gh copilot suggest "typescript result type success or error pattern"
# Async error handling
gh copilot suggest "try catch async await with typed error"
```
---
## Test-Driven Development (TDD)
**This project requires TDD. Write tests BEFORE implementation.**
### TDD Workflow
```bash
# Step 1: Write failing test first
gh copilot suggest "vitest test for function that does X should return Y"
# Step 2: Make test pass with minimal code
gh copilot suggest "minimal implementation to make vitest test pass"
# Step 3: Refactor while keeping tests green
gh copilot suggest "refactor function for readability typescript"
```
### Test Commands
```bash
npm run test # Run all tests once
npm run test:watch # Watch mode (re-run on changes)
npm run test:coverage # Generate coverage report
npm run test:ui # Open Vitest UI in browser
```
### Writing Tests
```bash
# Create unit test
gh copilot suggest "vitest unit test for typescript async function"
# Test with mocks
gh copilot suggest "vitest mock module fs promises typescript"
# Test event emitters
gh copilot suggest "vitest test eventemitter emit and listen"
# Test error cases
gh copilot suggest "vitest test async function throws error"
```
### Mock Patterns
```bash
# Mock database
gh copilot suggest "vitest mock drizzle orm database connection"
# Mock file system
gh copilot suggest "vitest mock fs promises readFile writeFile"
# Mock Electron
gh copilot suggest "vitest mock electron app ipcMain"
# Factory functions
gh copilot suggest "typescript factory function create test data with overrides"
```
### Coverage
```bash
# Check coverage
gh copilot suggest "vitest coverage v8 configuration"
# Coverage thresholds
gh copilot suggest "vitest minimum coverage threshold configuration"
```
### Testing Specific Scenarios
```bash
# Task management tests
gh copilot suggest "vitest test async task queue with progress callback"
# Sync engine tests
gh copilot suggest "vitest test sync conflict detection by checksum"
# Post engine tests
gh copilot suggest "vitest test markdown frontmatter parse and serialize"
# Media tests
gh copilot suggest "vitest test file checksum calculation md5"
```
---
## Git Operations
```bash
# Commits
gh copilot suggest "git commit message for feature typescript"
# Branching
gh copilot suggest "git branch naming convention feature bugfix"
# Stashing
gh copilot suggest "git stash changes and apply later"
```
---
## Project-Specific Commands
### Quick Reference
| Task | Command |
|------|---------|
| Install deps | `npm install` |
| Dev mode | `npm run dev` (runs Vite + TSC watch) |
| Build all | `npm run build` |
| Start app | `npm start` |
| Run tests | `npm run test` |
| Watch tests | `npm run test:watch` |
| Coverage | `npm run test:coverage` |
| Build main only | `npm run build:main` |
| Build renderer only | `npm run build:renderer` |
### File Locations
| What | Where |
|------|-------|
| Electron main | `src/main/` |
| React renderer | `src/renderer/` |
| Database schema | `src/main/database/schema.ts` |
| Engine classes | `src/main/engine/` |
| IPC handlers | `src/main/ipc/handlers.ts` |
| React components | `src/renderer/components/` |
| State store | `src/renderer/store/appStore.ts` |
### Adding a Feature Checklist
```bash
# 1. Schema (if new data)
gh copilot suggest "add column to drizzle sqlite table"
# 2. Engine method
gh copilot suggest "engine class method with event emitter typescript"
# 3. IPC handler
gh copilot suggest "electron ipc handle invoke pattern"
# 4. Preload exposure
gh copilot suggest "electron preload contextBridge expose"
# 5. Store action
gh copilot suggest "zustand action to update state"
# 6. UI component
gh copilot suggest "react component call electron api"
```
---
## Troubleshooting
### Common Issues
```bash
# Module not found
gh copilot explain "cannot find module typescript error electron"
# IPC not working
gh copilot explain "electron ipc invoke not receiving response"
# Database locked
gh copilot explain "sqlite database is locked error"
# Build fails
gh copilot explain "vite build error cannot resolve module"
```
### Debugging
```bash
# Enable source maps
gh copilot suggest "typescript source maps for electron debugging"
# DevTools
gh copilot suggest "open chrome devtools in electron app"
# Logging
gh copilot suggest "electron log to file in main process"
```
---
## Best Practices Summary
When asking Copilot CLI for code in this project:
1. **Specify TypeScript** - Always mention "typescript" for typed responses
2. **Mention Electron context** - "main process" or "renderer process"
3. **Reference Drizzle** - Use "drizzle orm" not "raw sql"
4. **Async patterns** - Request "async/await" not callbacks
5. **Type safety** - Ask for "typed" or "with types" versions
Example well-formed query:
```bash
gh copilot suggest "typescript async function to update drizzle orm record with transaction in electron main process"
```

665
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,665 @@
# GitHub Copilot Instructions for Blogging Desktop Server (bDS)
This document provides context and best practices for GitHub Copilot when working on this Electron + TypeScript + SQLite blogging application.
## Project Overview
**Blogging Desktop Server (bDS)** is a desktop blogging application built with:
- **Electron** v28+ for cross-platform desktop
- **TypeScript** for all code (strict mode)
- **React** for the renderer UI
- **Drizzle ORM** for type-safe database access
- **@libsql/client** for SQLite (local) and Turso (cloud sync)
- **Zustand** for React state management
## Architecture Principles
### Separation of Concerns
1. **Engine Classes** (`src/main/engine/`): All business logic lives here
- `PostEngine`: Blog post CRUD, file I/O, markdown handling
- `MediaEngine`: Media import, metadata management
- `SyncEngine`: Remote database synchronization
- `TaskManager`: Async task queue with progress tracking
2. **IPC Layer** (`src/main/ipc/`): Bridge between main and renderer
- Handlers call engine methods, never contain business logic
- Always use `ipcMain.handle` for async operations
3. **UI Components** (`src/renderer/components/`): Presentation only
- Components should be stateless where possible
- Use Zustand store for shared state
- Never call IPC directly from deeply nested components
### File Storage Pattern
- **Posts**: Markdown files with YAML frontmatter in `posts/` directory
- **Media**: Binary files with `.meta` JSON sidecar files in `media/` directory
- **Database**: Index/cache only, source of truth is filesystem
## TypeScript Best Practices
### Type Safety
```typescript
// ✅ DO: Use explicit return types for public methods
async function getPost(id: string): Promise<PostData | null> {
// ...
}
// ✅ DO: Use discriminated unions for status types
type SyncStatus = 'pending' | 'syncing' | 'synced' | 'error';
// ✅ DO: Use Drizzle's inferred types
import { posts, type Post, type NewPost } from './schema';
// ❌ DON'T: Use `any` type
function processData(data: any) { } // Bad
// ❌ DON'T: Ignore null/undefined possibilities
const post = await getPost(id);
console.log(post.title); // Bad - post might be null
```
### Async/Await Patterns
```typescript
// ✅ DO: Use try-catch with specific error handling
try {
await fileOperation();
} catch (error) {
if (error instanceof Error && error.message.includes('ENOENT')) {
// Handle file not found
}
throw error;
}
// ✅ DO: Use Promise.all for concurrent operations
const [posts, media] = await Promise.all([
postEngine.getAllPosts(),
mediaEngine.getAllMedia()
]);
// ❌ DON'T: Forget to await async operations
savePost(post); // Bad - fire and forget
```
### Error Handling
```typescript
// ✅ DO: Create typed error classes
class SyncError extends Error {
constructor(
message: string,
public readonly code: 'AUTH_FAILED' | 'NETWORK_ERROR' | 'CONFLICT'
) {
super(message);
this.name = 'SyncError';
}
}
// ✅ DO: Return result objects for operations that can fail
interface OperationResult<T> {
success: boolean;
data?: T;
error?: string;
}
```
## Electron Best Practices
### IPC Communication
```typescript
// ✅ DO: Use contextBridge for secure IPC exposure
// preload.ts
contextBridge.exposeInMainWorld('electronAPI', {
posts: {
getAll: () => ipcRenderer.invoke('posts:getAll'),
create: (data: CreatePostInput) => ipcRenderer.invoke('posts:create', data),
}
});
// ✅ DO: Validate all IPC inputs in main process
ipcMain.handle('posts:create', async (_event, data: unknown) => {
const validated = validateCreatePostInput(data);
return postEngine.createPost(validated);
});
// ❌ DON'T: Use nodeIntegration: true
// ❌ DON'T: Expose Node.js APIs directly to renderer
```
### Window Management
```typescript
// ✅ DO: Check window existence before sending messages
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('event', data);
}
// ✅ DO: Clean up IPC handlers on window close
mainWindow.on('closed', () => {
ipcMain.removeHandler('posts:getAll');
});
```
### Process Separation
```typescript
// Main process: File system, database, native APIs
// Renderer process: UI only, no direct fs/db access
// ✅ DO: All file operations in main process
// src/main/engine/PostEngine.ts
await fs.writeFile(filePath, content);
// ❌ DON'T: Import 'fs' in renderer
// src/renderer/components/Editor.tsx
import fs from 'fs'; // Bad!
```
## SQLite & Drizzle ORM Best Practices
### Schema Definition
```typescript
// ✅ DO: Use proper column types and constraints
export const posts = sqliteTable('posts', {
id: text('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
syncStatus: text('sync_status').$type<'pending' | 'synced'>().default('pending'),
});
// ✅ DO: Create indexes for frequently queried columns
// CREATE INDEX idx_posts_slug ON posts(slug);
// CREATE INDEX idx_posts_sync_status ON posts(sync_status);
```
### Query Patterns
```typescript
import { eq, and, desc, sql } from 'drizzle-orm';
// ✅ DO: Use Drizzle query builder
const drafts = await db
.select()
.from(posts)
.where(eq(posts.status, 'draft'))
.orderBy(desc(posts.updatedAt));
// ✅ DO: Use transactions for multi-table updates
await db.transaction(async (tx) => {
await tx.update(posts).set({ status: 'published' }).where(eq(posts.id, id));
await tx.insert(syncLog).values({ entityId: id, operation: 'update' });
});
// ❌ DON'T: Write raw SQL unless absolutely necessary
// ❌ DON'T: Use string interpolation in queries
const result = db.run(`SELECT * FROM posts WHERE id = '${id}'`); // SQL injection risk!
```
### Connection Management
```typescript
// ✅ DO: Use singleton pattern for database connection
let dbInstance: DatabaseConnection | null = null;
export function getDatabase(): DatabaseConnection {
if (!dbInstance) {
dbInstance = new DatabaseConnection();
}
return dbInstance;
}
// ✅ DO: Close connection gracefully on app quit
app.on('before-quit', async () => {
await getDatabase().close();
});
```
## Remote Sync Best Practices (Turso/LibSQL)
### Sync Strategy
```typescript
// ✅ DO: Track sync status per entity
interface SyncableEntity {
syncStatus: 'pending' | 'syncing' | 'synced' | 'conflict';
syncedAt: number | null;
checksum: string;
}
// ✅ DO: Use checksums to detect conflicts
const localChecksum = calculateChecksum(localContent);
const remoteChecksum = await fetchRemoteChecksum(id);
if (localChecksum !== remoteChecksum) {
// Handle conflict
}
// ✅ DO: Implement retry logic with exponential backoff
async function syncWithRetry(maxAttempts = 3): Promise<void> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await sync();
return;
} catch (error) {
if (attempt === maxAttempts) throw error;
await sleep(Math.pow(2, attempt) * 1000);
}
}
}
```
### Offline-First Approach
```typescript
// ✅ DO: Always write to local first
async function savePost(post: PostData): Promise<void> {
// 1. Save to local file system
await writePostFile(post);
// 2. Update local database
await updateLocalDatabase(post);
// 3. Mark for sync (don't wait for network)
await markForSync(post.id);
}
// ✅ DO: Queue sync operations
const syncQueue: SyncOperation[] = [];
function queueSync(operation: SyncOperation): void {
syncQueue.push(operation);
processSyncQueue(); // Non-blocking
}
// ✅ DO: Handle network state changes
window.addEventListener('online', () => {
syncEngine.processPendingChanges();
});
```
### Conflict Resolution
```typescript
// ✅ DO: Implement clear conflict resolution strategy
type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual';
interface ConflictInfo {
entityId: string;
localVersion: EntityVersion;
remoteVersion: EntityVersion;
resolution?: ConflictResolution;
}
// ✅ DO: Allow user to resolve conflicts manually when needed
async function resolveConflict(
conflict: ConflictInfo,
resolution: ConflictResolution
): Promise<void> {
switch (resolution) {
case 'local-wins':
await pushLocalToRemote(conflict.entityId);
break;
case 'remote-wins':
await pullRemoteToLocal(conflict.entityId);
break;
case 'manual':
await showConflictResolutionUI(conflict);
break;
}
}
```
### Sync Logging
```typescript
// ✅ DO: Log all sync operations for debugging
await db.insert(syncLog).values({
id: generateId(),
entityType: 'post',
entityId: postId,
operation: 'push',
status: 'success',
timestamp: Date.now(),
});
// ✅ DO: Implement sync status visibility in UI
const pendingCount = await syncEngine.getPendingChangesCount();
statusBar.setSyncStatus({ pending: pendingCount });
```
## React & State Management
### Zustand Patterns
```typescript
// ✅ DO: Keep store slices focused
interface AppState {
// UI state
activeView: 'posts' | 'media' | 'settings';
sidebarVisible: boolean;
// Data
posts: PostData[];
media: MediaData[];
// Actions
setActiveView: (view: AppState['activeView']) => void;
setPosts: (posts: PostData[]) => void;
}
// ✅ DO: Use selectors for derived state
const draftCount = useAppStore((state) =>
state.posts.filter(p => p.status === 'draft').length
);
// ❌ DON'T: Store derived data
interface BadState {
posts: PostData[];
draftCount: number; // Bad - derived from posts
}
```
### Component Patterns
```typescript
// ✅ DO: Use composition over prop drilling
<EditorProvider postId={selectedPostId}>
<EditorToolbar />
<EditorContent />
<EditorStatusBar />
</EditorProvider>
// ✅ DO: Separate container and presentation components
// Container: handles data fetching/IPC
// Presentation: pure rendering based on props
// ❌ DON'T: Put IPC calls in deeply nested components
const DeepComponent = () => {
// Bad - hard to test and maintain
const handleClick = async () => {
await window.electronAPI.posts.save(data);
};
};
```
## File Naming Conventions
```
src/
main/
database/
schema.ts # Drizzle schema definitions
connection.ts # Database connection management
engine/
PostEngine.ts # PascalCase for classes
TaskManager.ts
ipc/
handlers.ts # camelCase for modules
renderer/
components/
Editor/
Editor.tsx # PascalCase for components
Editor.css # Matching CSS file
index.ts # Barrel exports
store/
appStore.ts # camelCase for stores
types/
electron.d.ts # Type declarations
```
## Test-Driven Development (TDD) Requirements
**This project follows strict TDD practices. All new features MUST have tests written BEFORE implementation.**
### TDD Workflow (Red-Green-Refactor)
```typescript
// 1. RED: Write a failing test first
describe('PostEngine.createPost', () => {
it('should create a post with generated slug from title', async () => {
const result = await postEngine.createPost({ title: 'Hello World' });
expect(result.slug).toBe('hello-world');
});
});
// 2. GREEN: Write minimal code to pass the test
// 3. REFACTOR: Improve the code while keeping tests green
```
### Test File Structure
```
tests/
├── setup.ts # Global test configuration
├── utils/
│ ├── factories.ts # Mock data factories
│ └── index.ts # Utility exports
└── engine/
├── TaskManager.test.ts # TaskManager unit tests
├── PostEngine.test.ts # PostEngine unit tests
├── MediaEngine.test.ts # MediaEngine unit tests
└── SyncEngine.test.ts # SyncEngine unit tests
```
### Test Naming Conventions
```typescript
// ✅ DO: Use descriptive test names that explain behavior
it('should generate slug from title with lowercase and hyphens', () => {});
it('should emit taskCompleted event when task finishes', () => {});
it('should return null when post not found', () => {});
// ❌ DON'T: Use vague test names
it('works', () => {});
it('test createPost', () => {});
```
### Mock Factory Patterns
```typescript
// ✅ DO: Use factory functions with overrides
import { createMockPost, createMockMedia } from '../utils/factories';
const post = createMockPost({
title: 'Custom Title',
status: 'published'
});
// ✅ DO: Create specialized factories for common scenarios
const draftPost = createMockPost({ status: 'draft' });
const publishedPost = createMockPublishedPost();
const imageMedia = createMockMedia({ mimeType: 'image/jpeg' });
const pdfMedia = createMockPdfMedia();
```
### Testing Async Code
```typescript
// ✅ DO: Use async/await in tests
it('should save post to filesystem', async () => {
const post = createMockPost();
await postEngine.createPost(post);
expect(fs.writeFile).toHaveBeenCalled();
});
// ✅ DO: Test event emissions
it('should emit postCreated event', async () => {
const handler = vi.fn();
postEngine.on('postCreated', handler);
await postEngine.createPost({ title: 'Test' });
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Test' })
);
});
// ✅ DO: Test error cases
it('should throw when file write fails', async () => {
vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('ENOSPC'));
await expect(postEngine.createPost({ title: 'Test' }))
.rejects.toThrow('ENOSPC');
});
```
### Mocking Dependencies
```typescript
// ✅ DO: Mock at module level for consistent behavior
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => mockDatabase),
}));
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
}));
// ✅ DO: Reset mocks between tests
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
```
### Test Coverage Requirements
- **Minimum coverage**: 80% for engine classes
- **Critical paths**: 100% coverage for data mutations
- **Edge cases**: Null handling, empty arrays, error conditions
```bash
# Run tests with coverage
npm run test:coverage
# View coverage report
open coverage/index.html
```
### When to Write Tests
| Scenario | Test Requirement |
|----------|-----------------|
| New engine method | Unit tests BEFORE implementation |
| Bug fix | Failing test that reproduces bug FIRST |
| Refactoring | Ensure existing tests pass BEFORE and AFTER |
| IPC handler | Integration test with mocked engine |
| UI component | Behavior tests for user interactions |
### Test Commands
```bash
npm run test # Run all tests once
npm run test:watch # Run tests in watch mode
npm run test:coverage # Generate coverage report
npm run test:ui # Open Vitest UI
```
### Vitest Configuration
Tests are configured in `vitest.config.ts` with:
- Global test utilities (describe, it, expect, vi)
- Node environment for main process tests
- Coverage reports via v8
- Custom setup file for Electron mocks
## Testing Considerations
```typescript
// ✅ DO: Design for testability
// - Engine classes should accept dependencies via constructor
// - Use interfaces for external dependencies
// - Keep pure business logic separate from I/O
class PostEngine {
constructor(
private db: DatabaseConnection,
private fs: FileSystemAdapter, // Mockable
private eventEmitter: EventEmitter
) {}
}
// ✅ DO: Use factory functions for test data
function createTestPost(overrides?: Partial<PostData>): PostData {
return {
id: 'test-id',
title: 'Test Post',
status: 'draft',
...overrides,
};
}
// ✅ DO: Test pure functions in isolation
describe('generateSlug', () => {
it('should convert to lowercase', () => {
expect(generateSlug('Hello World')).toBe('hello-world');
});
it('should replace special characters', () => {
expect(generateSlug('Hello, World!')).toBe('hello-world');
});
});
```
## Common Patterns in This Codebase
### Creating a New Engine Method
```typescript
// 1. Add method to engine class
async newOperation(input: InputType): Promise<OutputType> {
// Validate input
// Perform operation
// Emit event for UI updates
this.emit('operationCompleted', result);
return result;
}
// 2. Add IPC handler
ipcMain.handle('entity:operation', async (_event, input) => {
return engine.newOperation(input);
});
// 3. Expose in preload
entity: {
operation: (input) => ipcRenderer.invoke('entity:operation', input),
}
// 4. Update store and UI
```
### Adding a New Database Table
```typescript
// 1. Define in schema.ts with proper types
export const newTable = sqliteTable('new_table', {
id: text('id').primaryKey(),
// ... columns
});
// 2. Export types
export type NewTableRow = typeof newTable.$inferSelect;
export type NewTableInsert = typeof newTable.$inferInsert;
// 3. Add CREATE TABLE to migrations in connection.ts
// 4. Create corresponding engine class
```
## Security Reminders
- Never log sensitive data (auth tokens, passwords)
- Validate all IPC inputs before processing
- Use `contextIsolation: true` and `sandbox: false` only when necessary
- Store Turso auth tokens in secure storage, not in code
- Sanitize user input before rendering (XSS prevention)

134
.gitignore vendored Normal file
View File

@@ -0,0 +1,134 @@
# ===================
# Dependencies
# ===================
node_modules/
.pnp/
.pnp.js
.yarn/
# ===================
# Build Outputs
# ===================
dist/
release/
out/
build/
*.tgz
# Electron-specific
*.asar
# ===================
# Database Files
# ===================
# SQLite databases (local development data)
*.db
*.db-journal
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
# Drizzle ORM
drizzle/
migrations/
# ===================
# Environment & Secrets
# ===================
.env
.env.local
.env.development
.env.test
.env.production
.env.*.local
# Turso/LibSQL credentials
turso-credentials.json
.turso/
# ===================
# IDE & Editor
# ===================
.idea/
*.swp
*.swo
*.sublime-workspace
*.sublime-project
*.code-workspace
# Keep .vscode for shared settings but ignore personal files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# ===================
# OS Generated Files
# ===================
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# ===================
# TypeScript
# ===================
*.tsbuildinfo
tsconfig.tsbuildinfo
# ===================
# Logs & Debug
# ===================
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.npm/
# Electron crash reports
crash-reports/
# ===================
# Testing
# ===================
coverage/
.nyc_output/
*.lcov
test-results/
playwright-report/
# ===================
# Temporary Files
# ===================
tmp/
temp/
*.tmp
*.temp
*.bak
# User data (created at runtime)
userData/
# ===================
# Package Manager Locks (keep one)
# ===================
# Uncomment if you want to ignore lock files
# package-lock.json
# yarn.lock
# pnpm-lock.yaml
# ===================
# Optional: macOS/Linux Electron builds on Windows
# ===================
# *.dmg
# *.AppImage
# *.deb
# *.rpm

114
PLAN.md Normal file
View File

@@ -0,0 +1,114 @@
# blogging Desktop Server
Back in the day I had a tool named Python Desktop Server that was a self-contained blogging engine with
an integrated webserver and database that allowed management of blog posts in an easy local way and a
sync to a cloud system for syncing data and also rendering the full blog.
## Vision
create a electron app in this folder that uses typescript for all the logic code and sqlite and a proper database framework around it for local storage of data and turso/libsql to sync against a cloud location for having offline work capabilities with syncing. The UI should be aligned with the UI patterns used by vscode. The name of the application is "blogging Desktop Server" and the shortname is bDS. Start with default layout for edit and view menues and things like that. I don't want the app to use raw SQL, I want some proper layer between those and proper wiring where all actual functional code is kept in engine classes and the UI realy just does presentation and reacts to state changes properly, so that long-running processes can properly integrate as async tasks.
Blog post metadata should be managed in the SQLite database in the user local folder, so it persists application runs properly. for blog posts, create a subfolder /posts/ there where each post is stored as a markdown file with a properties segment in the top of the file with YAML like property definitions, so all metadata can always be reconstructed from posts. Do the same with images, keeping them in /media/ under the user local path, in that case storing the image file sand for each image file a properties sidecar file that uses the same header structure as for posts.
The application must be able to support multiple projects (ie web sites), so there must be a way to create
new projects and select current project. The UI is only showing all data of the current selected project and
all tools are only working against the selected project. I can imagine that projects will be separate
databases and folders for post and media.
I want proper full-text search for posts based on the integrated sqlite database using fts5, so that I can quickly find posts. So build proper text search index update into the core model right away, so that regardless how posts come into the system, they are always properly indexed.
Additionally bring in a good markdown library, because all posts will be formatted in markdown for easy portability to future systems. Media files can be attached to posts and can be referenced with standard markdown notation with a post-relative path, so it is easy for the user to include images. The post editor should support both a wysiwyg editor and raw markdown editor, so the user does not have to know markdown, but everything is handled by the editor, but for complex parts, markdown is available for power users.
Integrate toasts as notification mechanism that will be used whenever anything has to communicate success/failure to the user.
Integrated images in posts should be shown with a lightbox effect als galleries when there are multiple photos, or just as single images with lightbox when there is only one. The wysiwyg editor should support this at least on a basic level.
## Organizing
Blog posts should be organized in the app in the main post view where the sidebar lists posts and the main
tab area can have multiple posts (new or selected from the sidebar) open, just like vscode manages text files.
The sidebar for posts must also include a calendar view at the top that allows to go to specific dates or
date ranges (by selecting a whole month of a year for example) and then accordingly filter down the post
list in the sidebar.
Also there must be a way to filter posts by tags and category to look at subsets of posts efficiently.
And there must be a way to use markdown links of a specific short-form to reference other posts, so that
I can link to other places, if needed, and those references should also be part of the information about
posts. So each post should give a "links to" part in the UI (right sidebar or lower area of left sidebar
like with vscode?) and a "linked to by" part where incoming links are shown.
## Migrating
Prepare a proper mass-data importer that can read wordpress backup files, so the user can bring in old wordpress blogs easily. That importer should run asynchronously and properly communicate progress to the user while it is running in the background. The import has to rebuild all metadata properly, so check if we have all the metadata in our model set up in a similar way as Wordpress handles it, so that we have a seamless integration. Posts in Wordpress backups are html, but should be interpreted and transformed into proper markdown in the import.
Additionally we need a way to traverse a full HTML website and deduct post structure from that website
and rebuild posts in the database based on such a web traversal. To be able to do that, use copilot SDK
to integrate copilot directly, so that HTML pages can be directly inspected and turned into actual blog
posts in proper structure and proper markdown, despite the source being HTML.
For this AI support during import to work, the blog application needs to provide post management and media
management functionality as proper SDK tools to the copilot instance, so that it will be able to work
on those posts.
The AI importing agent must discover the language of a post and put that in an attribute. Posts must have
the database structure of translations, so that a post that is discovered as being german can be automatically
translated to english and vice-versa. After import, all posts are available in two languages.
Import runs get an ID that has a generated name based on a sequence of two random words (like session plans
of claude code) based on random adjective and animal name and a date. This identifier is then used as a tag
(ravenous-wombat-2026-02-20 for example), so posts and media from one import can be recognized easily.
There must be a mass-delete for such tags, so that the tag and all content related to that tag is deleted
again, so that imports can be reverted and redone in case of problems.
Imports must be able to recognize duplicates of posts to some degree, so that we don't create additional
posts from new import runs if the original posts are already there. In the case of recognized duplicates,
the original post will just be linked to the same tag of the new import, so that the user can see it was
referenced by multiple imports.
Import runs can be shown in the main panel, so that the user can see what came with what import and can
manage posts and media from imports that way. Migration is the main interesting part of this tool, because
migrating blogs is hard work and needs to be properly supported.
## Organizing II
Since we have an AI assistant planned for the migration phase, we also want an AI chat feature as another
view in the app, so we can activate it and use the AI assistant to query blog posts and media files in the
system and also create blog posts with AI support.
All SDK tools must also be made available as MCP server that is hosted inside the application, so that I
can hook the app into a normal AI coding agent.
## Publishing
Publishing should target static HTML/CSS/JavaScript situations. There must be a asnyc exporter, that will render
all affected pages based on the structure of the export and auto-update affected files when the posts or media
that are used in the page were changed. This requires a cross-reference table that links posts and media entries
with actual HTML files that are referencing them. This needs to be based on how templates use posts in the
export pipeline.
The main driver is a proper blog structure with templates. For this I want proper templates I can manage and
edit in the application itself. Template editing should provide proper syntax highlighting, so something like
monaco is important. Choose a good solid template engine for node-js based tools that is especialy targeted
to easy template creation.
For the styling I want the system to be based on bootstrap templates, so that the look can be easily swapped
to the wish of the user. There should be a selection of light and dark themes bundled with the application,
so that starting is simple. New bootstrap css templates must be easily integrateable into the application,
maybe even with easy importing from a central bootstrap site or something like that.
Check the site https://hugo.rfc1437.de/ for its structure, this is the structure of blog I want to be
capable of building with this tooling. So we need templates for overview pages and ways to manage menues
that reference overview pages and structure the menu according to site structure. Also support calendar views
to allow users to go to specific months and years of the blog.
Categories and tags must be able to define a template selection for post templates, so that different types
can be represented differently.
There must be way to open a browser tab in the application that then uses the applicaiton itself and does
dynamic rendering of the content, using the same templates and everything else, so that the user can do
a propoer preview before deciding to update the remote static web storage. The browser tab will of course use
the correct styling of the website.
Publishing of files can be configured to be done via FTP or SSH, connection data must be configureable in
preferences for the website.

135
README.md Normal file
View File

@@ -0,0 +1,135 @@
# Blogging Desktop Server (bDS)
A desktop blogging application with offline-first capabilities and cloud sync via Turso/LibSQL.
## Features
- **Offline-First**: All data is stored locally in SQLite, works without internet
- **Cloud Sync**: Synchronize with Turso (LibSQL) for multi-device access
- **VS Code-Inspired UI**: Familiar, clean interface with activity bar, sidebar, and editor
- **Markdown Posts**: Write blog posts in Markdown with YAML frontmatter
- **Media Management**: Import and manage images with metadata sidecar files
- **Clean Architecture**: Engine classes handle business logic, UI is purely presentational
## Architecture
```
src/
├── main/ # Electron main process
│ ├── database/ # Drizzle ORM schema and connection
│ ├── engine/ # Business logic engines
│ │ ├── PostEngine # Post CRUD, file operations
│ │ ├── MediaEngine # Media import/management
│ │ ├── SyncEngine # Turso sync logic
│ │ └── TaskManager # Async task handling
│ ├── ipc/ # IPC handlers for renderer communication
│ └── main.ts # App entry point
└── renderer/ # Electron renderer process (React)
├── components/ # UI components (VS Code style)
├── store/ # Zustand state management
└── styles/ # Global CSS variables
```
## Data Storage
All user data is stored in the application's user data folder:
- **Database**: `{userData}/bds.db` - SQLite database with post/media metadata
- **Posts**: `{userData}/posts/*.md` - Markdown files with YAML frontmatter
- **Media**: `{userData}/media/` - Image files with `.meta` sidecar files
### Post Format
```markdown
---
id: uuid-here
title: "My Blog Post"
slug: my-blog-post
status: draft
author: John Doe
createdAt: 2024-01-15T10:30:00.000Z
updatedAt: 2024-01-15T10:30:00.000Z
tags: ["javascript", "tutorial"]
categories: ["development"]
---
# My Blog Post
Your markdown content here...
```
### Media Sidecar Format
```yaml
---
id: uuid-here
originalName: "photo.jpg"
mimeType: image/jpeg
size: 102400
width: 1920
height: 1080
alt: "A beautiful sunset"
caption: "Sunset over the mountains"
createdAt: 2024-01-15T10:30:00.000Z
updatedAt: 2024-01-15T10:30:00.000Z
tags: ["nature", "sunset"]
---
```
## Development
### Prerequisites
- Node.js 18+
- npm or yarn
### Setup
```bash
# Install dependencies
npm install
# Start development mode
npm run dev
# In another terminal, start Electron
npm start
```
### Building
```bash
# Build for production
npm run build
# Package for distribution (uses electron-builder)
npx electron-builder
```
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| Ctrl+N | New Post |
| Ctrl+S | Save |
| Ctrl+B | Toggle Sidebar |
| Ctrl+J | Toggle Panel |
| Ctrl+1 | View Posts |
| Ctrl+2 | View Media |
| Ctrl+Shift+P | Publish Selected |
| Ctrl+Shift+S | Sync Now |
## Cloud Sync Setup
1. Create a Turso database at https://turso.tech
2. Get your database URL and auth token
3. Go to Settings in the app
4. Enter your Turso credentials
5. Click "Enable Sync"
Auto-sync runs every 5 minutes when configured.
## License
MIT

4
assets/icon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect width="256" height="256" rx="32" fill="#1e1e1e"/>
<text x="128" y="160" font-family="system-ui, -apple-system, sans-serif" font-size="100" font-weight="bold" fill="#007acc" text-anchor="middle">bDS</text>
</svg>

After

Width:  |  Height:  |  Size: 310 B

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/main/database/schema.ts',
out: './drizzle',
driver: 'libsql',
dbCredentials: {
url: 'file:./data/bds.db',
},
} satisfies Config;

10604
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
package.json Normal file
View File

@@ -0,0 +1,73 @@
{
"name": "blogging-desktop-server",
"productName": "Blogging Desktop Server",
"version": "1.0.0",
"description": "A desktop blogging application with offline-first capabilities and cloud sync",
"main": "dist/main/main.js",
"scripts": {
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"",
"dev:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json --watch",
"dev:renderer": "node ./node_modules/vite/bin/vite.js",
"build": "npm run build:main && npm run build:renderer",
"build:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json",
"build:renderer": "node ./node_modules/vite/bin/vite.js build",
"start": "node ./node_modules/electron/cli.js .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"db:generate": "node ./node_modules/drizzle-kit/bin.cjs generate",
"db:migrate": "node ./node_modules/tsx/dist/cli.mjs src/main/database/migrate.ts",
"db:studio": "node ./node_modules/drizzle-kit/bin.cjs studio"
},
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.10.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.0",
"@vitest/coverage-v8": "^1.0.0",
"@vitest/ui": "^1.0.0",
"concurrently": "^8.2.2",
"drizzle-kit": "^0.20.0",
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"memfs": "^4.6.0",
"tsx": "^4.6.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vitest": "^1.0.0"
},
"dependencies": {
"@libsql/client": "^0.4.0",
"drizzle-orm": "^0.29.0",
"electron-store": "^8.1.0",
"gray-matter": "^4.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"uuid": "^9.0.1",
"zustand": "^4.4.7"
},
"build": {
"appId": "com.bds.blogging-desktop-server",
"productName": "Blogging Desktop Server",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"node_modules/**/*"
],
"win": {
"target": "nsis"
},
"mac": {
"target": "dmg"
},
"linux": {
"target": "AppImage"
}
}
}

View File

@@ -0,0 +1,200 @@
import { createClient, Client } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import * as schema from './schema';
import { app } from 'electron';
import * as path from 'path';
import * as fs from 'fs';
export interface DatabaseConfig {
localPath: string;
tursoUrl?: string;
tursoAuthToken?: string;
}
type DrizzleDB = ReturnType<typeof drizzle>;
export class DatabaseConnection {
private localDb: DrizzleDB | null = null;
private remoteDb: DrizzleDB | null = null;
private localClient: Client | null = null;
private remoteClient: Client | null = null;
private config: DatabaseConfig;
constructor(config?: Partial<DatabaseConfig>) {
const userDataPath = app.getPath('userData');
this.config = {
localPath: config?.localPath || path.join(userDataPath, 'bds.db'),
tursoUrl: config?.tursoUrl,
tursoAuthToken: config?.tursoAuthToken,
};
// Ensure user data directory exists
const dataDir = path.dirname(this.config.localPath);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// Ensure posts and media directories exist
const postsDir = path.join(userDataPath, 'posts');
const mediaDir = path.join(userDataPath, 'media');
if (!fs.existsSync(postsDir)) {
fs.mkdirSync(postsDir, { recursive: true });
}
if (!fs.existsSync(mediaDir)) {
fs.mkdirSync(mediaDir, { recursive: true });
}
}
async initializeLocal(): Promise<DrizzleDB> {
if (this.localDb) {
return this.localDb;
}
// Use file: URL for local SQLite database via libsql
this.localClient = createClient({
url: `file:${this.config.localPath}`,
});
this.localDb = drizzle(this.localClient, { schema });
// Run migrations
await this.runMigrations();
return this.localDb;
}
async initializeRemote(): Promise<DrizzleDB | null> {
if (!this.config.tursoUrl || !this.config.tursoAuthToken) {
return null;
}
if (this.remoteDb) {
return this.remoteDb;
}
this.remoteClient = createClient({
url: this.config.tursoUrl,
authToken: this.config.tursoAuthToken,
});
this.remoteDb = drizzle(this.remoteClient, { schema });
return this.remoteDb;
}
getLocal(): DrizzleDB {
if (!this.localDb) {
throw new Error('Local database not initialized. Call initializeLocal() first.');
}
return this.localDb;
}
getRemote(): DrizzleDB | null {
return this.remoteDb;
}
private async runMigrations(): Promise<void> {
if (!this.localClient) return;
// Create tables if they don't exist using batch execution
await this.localClient.executeMultiple(`
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
excerpt TEXT,
status TEXT NOT NULL DEFAULT 'draft',
author TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
file_path TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'pending',
synced_at INTEGER,
checksum TEXT,
tags TEXT,
categories TEXT
);
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
alt TEXT,
caption TEXT,
file_path TEXT NOT NULL,
sidecar_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'pending',
synced_at INTEGER,
checksum TEXT,
tags TEXT
);
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
operation TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
timestamp INTEGER NOT NULL,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status);
CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status);
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
`);
}
async close(): Promise<void> {
if (this.localClient) {
this.localClient.close();
this.localClient = null;
this.localDb = null;
}
if (this.remoteClient) {
this.remoteClient.close();
this.remoteClient = null;
this.remoteDb = null;
}
}
getDataPaths() {
const userDataPath = app.getPath('userData');
return {
database: this.config.localPath,
posts: path.join(userDataPath, 'posts'),
media: path.join(userDataPath, 'media'),
};
}
}
// Singleton instance
let dbConnection: DatabaseConnection | null = null;
export function getDatabase(): DatabaseConnection {
if (!dbConnection) {
dbConnection = new DatabaseConnection();
}
return dbConnection;
}
export function initDatabase(config?: Partial<DatabaseConfig>): DatabaseConnection {
dbConnection = new DatabaseConnection(config);
return dbConnection;
}

View File

@@ -0,0 +1,2 @@
export * from './schema';
export * from './connection';

View File

@@ -0,0 +1,70 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
// Posts table - stores metadata for blog posts
export const posts = sqliteTable('posts', {
id: text('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
excerpt: text('excerpt'),
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
author: text('author'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
publishedAt: integer('published_at', { mode: 'timestamp' }),
filePath: text('file_path').notNull(),
syncStatus: text('sync_status', { enum: ['pending', 'synced', 'conflict'] }).notNull().default('pending'),
syncedAt: integer('synced_at', { mode: 'timestamp' }),
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
categories: text('categories'), // JSON array stored as text
});
// Media table - stores metadata for images and other media
export const media = sqliteTable('media', {
id: text('id').primaryKey(),
filename: text('filename').notNull(),
originalName: text('original_name').notNull(),
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
width: integer('width'),
height: integer('height'),
alt: text('alt'),
caption: text('caption'),
filePath: text('file_path').notNull(),
sidecarPath: text('sidecar_path').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
syncStatus: text('sync_status', { enum: ['pending', 'synced', 'conflict'] }).notNull().default('pending'),
syncedAt: integer('synced_at', { mode: 'timestamp' }),
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
});
// Sync log - tracks sync operations
export const syncLog = sqliteTable('sync_log', {
id: text('id').primaryKey(),
entityType: text('entity_type', { enum: ['post', 'media'] }).notNull(),
entityId: text('entity_id').notNull(),
operation: text('operation', { enum: ['create', 'update', 'delete'] }).notNull(),
status: text('status', { enum: ['pending', 'completed', 'failed'] }).notNull().default('pending'),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
errorMessage: text('error_message'),
retryCount: integer('retry_count').notNull().default(0),
});
// App settings - stores application configuration
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Types for TypeScript
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type Media = typeof media.$inferSelect;
export type NewMedia = typeof media.$inferInsert;
export type SyncLogEntry = typeof syncLog.$inferSelect;
export type NewSyncLogEntry = typeof syncLog.$inferInsert;
export type Setting = typeof settings.$inferSelect;
export type NewSetting = typeof settings.$inferInsert;

View File

@@ -0,0 +1,442 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
import { eq } from 'drizzle-orm';
import { getDatabase } from '../database';
import { media, Media, NewMedia } from '../database/schema';
import { taskManager, Task } from './TaskManager';
export interface MediaData {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: Date;
updatedAt: Date;
tags: string[];
}
export interface MediaMetadata {
id: string;
originalName: string;
mimeType: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: string;
updatedAt: string;
tags: string[];
}
export class MediaEngine extends EventEmitter {
private mediaDir: string;
constructor() {
super();
this.mediaDir = getDatabase().getDataPaths().media;
}
private calculateChecksum(buffer: Buffer): string {
return crypto.createHash('md5').update(buffer).digest('hex');
}
private async writeSidecarFile(mediaData: MediaData, mediaPath: string): Promise<string> {
const sidecarPath = `${mediaPath}.meta`;
const metadata: MediaMetadata = {
id: mediaData.id,
originalName: mediaData.originalName,
mimeType: mediaData.mimeType,
size: mediaData.size,
width: mediaData.width,
height: mediaData.height,
alt: mediaData.alt,
caption: mediaData.caption,
createdAt: mediaData.createdAt.toISOString(),
updatedAt: mediaData.updatedAt.toISOString(),
tags: mediaData.tags,
};
// Write YAML-like format consistent with posts
const lines = [
'---',
`id: ${metadata.id}`,
`originalName: "${metadata.originalName}"`,
`mimeType: ${metadata.mimeType}`,
`size: ${metadata.size}`,
];
if (metadata.width) lines.push(`width: ${metadata.width}`);
if (metadata.height) lines.push(`height: ${metadata.height}`);
if (metadata.alt) lines.push(`alt: "${metadata.alt}"`);
if (metadata.caption) lines.push(`caption: "${metadata.caption}"`);
lines.push(`createdAt: ${metadata.createdAt}`);
lines.push(`updatedAt: ${metadata.updatedAt}`);
lines.push(`tags: [${metadata.tags.map(t => `"${t}"`).join(', ')}]`);
lines.push('---');
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
return sidecarPath;
}
private async readSidecarFile(sidecarPath: string): Promise<MediaMetadata | null> {
try {
const content = await fs.readFile(sidecarPath, 'utf-8');
const lines = content.split('\n');
const metadata: Partial<MediaMetadata> = {
tags: [],
};
for (const line of lines) {
if (line === '---') continue;
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const key = line.substring(0, colonIndex).trim();
let value = line.substring(colonIndex + 1).trim();
// Remove quotes
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
switch (key) {
case 'id':
metadata.id = value;
break;
case 'originalName':
metadata.originalName = value;
break;
case 'mimeType':
metadata.mimeType = value;
break;
case 'size':
metadata.size = parseInt(value, 10);
break;
case 'width':
metadata.width = parseInt(value, 10);
break;
case 'height':
metadata.height = parseInt(value, 10);
break;
case 'alt':
metadata.alt = value;
break;
case 'caption':
metadata.caption = value;
break;
case 'createdAt':
metadata.createdAt = value;
break;
case 'updatedAt':
metadata.updatedAt = value;
break;
case 'tags':
// Parse array format: ["tag1", "tag2"]
const match = value.match(/\[(.*)\]/);
if (match) {
metadata.tags = match[1]
.split(',')
.map(t => t.trim().replace(/"/g, ''))
.filter(t => t.length > 0);
}
break;
}
}
if (!metadata.id || !metadata.originalName || !metadata.mimeType) {
return null;
}
return metadata as MediaMetadata;
} catch (error) {
console.error(`Failed to read sidecar file: ${sidecarPath}`, error);
return null;
}
}
private getMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
};
return mimeTypes[ext] || 'application/octet-stream';
}
async importMedia(sourcePath: string, metadata?: Partial<MediaData>): Promise<MediaData> {
const db = getDatabase().getLocal();
const id = uuidv4();
const now = new Date();
const sourceBuffer = await fs.readFile(sourcePath);
const originalName = path.basename(sourcePath);
const ext = path.extname(originalName);
const filename = `${id}${ext}`;
const destPath = path.join(this.mediaDir, filename);
// Copy file to media directory
await fs.writeFile(destPath, sourceBuffer);
const mediaData: MediaData = {
id,
filename,
originalName,
mimeType: metadata?.mimeType || this.getMimeType(originalName),
size: sourceBuffer.length,
width: metadata?.width,
height: metadata?.height,
alt: metadata?.alt,
caption: metadata?.caption,
createdAt: now,
updatedAt: now,
tags: metadata?.tags || [],
};
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
const checksum = this.calculateChecksum(sourceBuffer);
const dbMedia: NewMedia = {
id: mediaData.id,
filename: mediaData.filename,
originalName: mediaData.originalName,
mimeType: mediaData.mimeType,
size: mediaData.size,
width: mediaData.width,
height: mediaData.height,
alt: mediaData.alt,
caption: mediaData.caption,
filePath: destPath,
sidecarPath,
createdAt: mediaData.createdAt,
updatedAt: mediaData.updatedAt,
syncStatus: 'pending',
checksum,
tags: JSON.stringify(mediaData.tags),
};
await db.insert(media).values(dbMedia);
this.emit('mediaImported', mediaData);
return mediaData;
}
async updateMedia(id: string, data: Partial<MediaData>): Promise<MediaData | null> {
const db = getDatabase().getLocal();
const existing = await this.getMedia(id);
if (!existing) {
return null;
}
const updated: MediaData = {
...existing,
...data,
id, // Ensure ID doesn't change
updatedAt: new Date(),
};
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
if (!dbMedia) return null;
await this.writeSidecarFile(updated, dbMedia.filePath);
await db.update(media)
.set({
alt: updated.alt,
caption: updated.caption,
updatedAt: updated.updatedAt,
syncStatus: 'pending',
tags: JSON.stringify(updated.tags),
})
.where(eq(media.id, id));
this.emit('mediaUpdated', updated);
return updated;
}
async deleteMedia(id: string): Promise<boolean> {
const db = getDatabase().getLocal();
const existing = await db.select().from(media).where(eq(media.id, id)).get();
if (!existing) {
return false;
}
// Delete media file
try {
await fs.unlink(existing.filePath);
} catch {
// File might not exist
}
// Delete sidecar file
try {
await fs.unlink(existing.sidecarPath);
} catch {
// File might not exist
}
await db.delete(media).where(eq(media.id, id));
this.emit('mediaDeleted', id);
return true;
}
async getMedia(id: string): Promise<MediaData | null> {
const db = getDatabase().getLocal();
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
if (!dbMedia) {
return null;
}
return {
id: dbMedia.id,
filename: dbMedia.filename,
originalName: dbMedia.originalName,
mimeType: dbMedia.mimeType,
size: dbMedia.size,
width: dbMedia.width || undefined,
height: dbMedia.height || undefined,
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
createdAt: dbMedia.createdAt,
updatedAt: dbMedia.updatedAt,
tags: JSON.parse(dbMedia.tags || '[]'),
};
}
async getAllMedia(): Promise<MediaData[]> {
const db = getDatabase().getLocal();
const dbMediaList = await db.select().from(media).all();
return dbMediaList.map(dbMedia => ({
id: dbMedia.id,
filename: dbMedia.filename,
originalName: dbMedia.originalName,
mimeType: dbMedia.mimeType,
size: dbMedia.size,
width: dbMedia.width || undefined,
height: dbMedia.height || undefined,
alt: dbMedia.alt || undefined,
caption: dbMedia.caption || undefined,
createdAt: dbMedia.createdAt,
updatedAt: dbMedia.updatedAt,
tags: JSON.parse(dbMedia.tags || '[]'),
}));
}
getMediaPath(id: string): string {
return path.join(this.mediaDir, id);
}
async rebuildDatabaseFromFiles(): Promise<void> {
const task: Task<void> = {
id: uuidv4(),
name: 'Rebuild database from media files',
execute: async (onProgress) => {
const db = getDatabase().getLocal();
onProgress(0, 'Scanning media directory...');
const files = await fs.readdir(this.mediaDir);
const metaFiles = files.filter(f => f.endsWith('.meta'));
onProgress(10, `Found ${metaFiles.length} media sidecar files`);
for (let i = 0; i < metaFiles.length; i++) {
const metaFile = metaFiles[i];
const sidecarPath = path.join(this.mediaDir, metaFile);
const mediaFilePath = sidecarPath.replace('.meta', '');
onProgress(10 + (80 * (i / metaFiles.length)), `Processing ${metaFile}...`);
const metadata = await this.readSidecarFile(sidecarPath);
if (metadata) {
try {
const stats = await fs.stat(mediaFilePath);
const buffer = await fs.readFile(mediaFilePath);
const checksum = this.calculateChecksum(buffer);
const filename = path.basename(mediaFilePath);
const existing = await db.select().from(media).where(eq(media.id, metadata.id)).get();
if (existing) {
await db.update(media)
.set({
originalName: metadata.originalName,
mimeType: metadata.mimeType,
size: stats.size,
width: metadata.width,
height: metadata.height,
alt: metadata.alt,
caption: metadata.caption,
updatedAt: new Date(metadata.updatedAt),
checksum,
tags: JSON.stringify(metadata.tags),
})
.where(eq(media.id, metadata.id));
} else {
await db.insert(media).values({
id: metadata.id,
filename,
originalName: metadata.originalName,
mimeType: metadata.mimeType,
size: stats.size,
width: metadata.width,
height: metadata.height,
alt: metadata.alt,
caption: metadata.caption,
filePath: mediaFilePath,
sidecarPath,
createdAt: new Date(metadata.createdAt),
updatedAt: new Date(metadata.updatedAt),
syncStatus: 'pending',
checksum,
tags: JSON.stringify(metadata.tags),
});
}
} catch (error) {
console.error(`Media file not found for sidecar: ${sidecarPath}`, error);
}
}
}
onProgress(100, 'Database rebuild complete');
this.emit('databaseRebuilt');
},
};
await taskManager.runTask(task);
}
}
// Singleton instance
let mediaEngine: MediaEngine | null = null;
export function getMediaEngine(): MediaEngine {
if (!mediaEngine) {
mediaEngine = new MediaEngine();
}
return mediaEngine;
}

View File

@@ -0,0 +1,386 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as crypto from 'crypto';
import matter from 'gray-matter';
import { eq } from 'drizzle-orm';
import { getDatabase } from '../database';
import { posts, Post, NewPost } from '../database/schema';
import { taskManager, Task } from './TaskManager';
export interface PostData {
id: string;
title: string;
slug: string;
excerpt?: string;
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
createdAt: Date;
updatedAt: Date;
publishedAt?: Date;
tags: string[];
categories: string[];
}
export interface PostMetadata {
title: string;
slug: string;
excerpt?: string;
status: 'draft' | 'published' | 'archived';
author?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
tags: string[];
categories: string[];
id: string;
}
export class PostEngine extends EventEmitter {
private postsDir: string;
constructor() {
super();
this.postsDir = getDatabase().getDataPaths().posts;
}
private generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
private calculateChecksum(content: string): string {
return crypto.createHash('md5').update(content).digest('hex');
}
private async writePostFile(post: PostData): Promise<string> {
const metadata: PostMetadata = {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
publishedAt: post.publishedAt?.toISOString(),
tags: post.tags,
categories: post.categories,
};
const fileContent = matter.stringify(post.content, metadata);
const filePath = path.join(this.postsDir, `${post.slug}.md`);
await fs.writeFile(filePath, fileContent, 'utf-8');
return filePath;
}
private async readPostFile(filePath: string): Promise<PostData | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const { data, content: body } = matter(content);
const metadata = data as PostMetadata;
return {
id: metadata.id,
title: metadata.title,
slug: metadata.slug,
excerpt: metadata.excerpt,
content: body,
status: metadata.status,
author: metadata.author,
createdAt: new Date(metadata.createdAt),
updatedAt: new Date(metadata.updatedAt),
publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined,
tags: metadata.tags || [],
categories: metadata.categories || [],
};
} catch (error) {
console.error(`Failed to read post file: ${filePath}`, error);
return null;
}
}
async createPost(data: Partial<PostData>): Promise<PostData> {
const db = getDatabase().getLocal();
const now = new Date();
const id = uuidv4();
const slug = data.slug || this.generateSlug(data.title || 'untitled');
const post: PostData = {
id,
title: data.title || 'Untitled',
slug,
excerpt: data.excerpt,
content: data.content || '',
status: data.status || 'draft',
author: data.author,
createdAt: now,
updatedAt: now,
publishedAt: data.publishedAt,
tags: data.tags || [],
categories: data.categories || [],
};
// Write to filesystem first
const filePath = await this.writePostFile(post);
const checksum = this.calculateChecksum(post.content);
// Then update database
const dbPost: NewPost = {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
filePath,
syncStatus: 'pending',
checksum,
tags: JSON.stringify(post.tags),
categories: JSON.stringify(post.categories),
};
await db.insert(posts).values(dbPost);
this.emit('postCreated', post);
return post;
}
async updatePost(id: string, data: Partial<PostData>): Promise<PostData | null> {
const db = getDatabase().getLocal();
const existing = await this.getPost(id);
if (!existing) {
return null;
}
const updated: PostData = {
...existing,
...data,
id, // Ensure ID doesn't change
updatedAt: new Date(),
};
// Handle slug change - need to rename file
if (data.slug && data.slug !== existing.slug) {
const oldPath = path.join(this.postsDir, `${existing.slug}.md`);
try {
await fs.unlink(oldPath);
} catch {
// Old file might not exist
}
}
const filePath = await this.writePostFile(updated);
const checksum = this.calculateChecksum(updated.content);
await db.update(posts)
.set({
title: updated.title,
slug: updated.slug,
excerpt: updated.excerpt,
status: updated.status,
author: updated.author,
updatedAt: updated.updatedAt,
publishedAt: updated.publishedAt,
filePath,
syncStatus: 'pending',
checksum,
tags: JSON.stringify(updated.tags),
categories: JSON.stringify(updated.categories),
})
.where(eq(posts.id, id));
this.emit('postUpdated', updated);
return updated;
}
async deletePost(id: string): Promise<boolean> {
const db = getDatabase().getLocal();
const existing = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!existing) {
return false;
}
// Delete file
try {
await fs.unlink(existing.filePath);
} catch {
// File might not exist
}
// Delete from database
await db.delete(posts).where(eq(posts.id, id));
this.emit('postDeleted', id);
return true;
}
async getPost(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!dbPost) {
return null;
}
// Read content from file
const postData = await this.readPostFile(dbPost.filePath);
if (!postData) {
// File doesn't exist, reconstruct from database
return {
id: dbPost.id,
title: dbPost.title,
slug: dbPost.slug,
excerpt: dbPost.excerpt || undefined,
content: '',
status: dbPost.status as 'draft' | 'published' | 'archived',
author: dbPost.author || undefined,
createdAt: dbPost.createdAt,
updatedAt: dbPost.updatedAt,
publishedAt: dbPost.publishedAt || undefined,
tags: JSON.parse(dbPost.tags || '[]'),
categories: JSON.parse(dbPost.categories || '[]'),
};
}
return postData;
}
async getAllPosts(): Promise<PostData[]> {
const db = getDatabase().getLocal();
const dbPosts = await db.select().from(posts).all();
const result: PostData[] = [];
for (const dbPost of dbPosts) {
const postData = await this.getPost(dbPost.id);
if (postData) {
result.push(postData);
}
}
return result;
}
async getPostsByStatus(status: 'draft' | 'published' | 'archived'): Promise<PostData[]> {
const db = getDatabase().getLocal();
const dbPosts = await db.select().from(posts).where(eq(posts.status, status)).all();
const result: PostData[] = [];
for (const dbPost of dbPosts) {
const postData = await this.getPost(dbPost.id);
if (postData) {
result.push(postData);
}
}
return result;
}
async publishPost(id: string): Promise<PostData | null> {
return this.updatePost(id, {
status: 'published',
publishedAt: new Date(),
});
}
async unpublishPost(id: string): Promise<PostData | null> {
return this.updatePost(id, {
status: 'draft',
publishedAt: undefined,
});
}
async rebuildDatabaseFromFiles(): Promise<void> {
const task: Task<void> = {
id: uuidv4(),
name: 'Rebuild database from post files',
execute: async (onProgress) => {
const db = getDatabase().getLocal();
onProgress(0, 'Scanning posts directory...');
const files = await fs.readdir(this.postsDir);
const mdFiles = files.filter(f => f.endsWith('.md'));
onProgress(10, `Found ${mdFiles.length} post files`);
for (let i = 0; i < mdFiles.length; i++) {
const file = mdFiles[i];
const filePath = path.join(this.postsDir, file);
onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${file}...`);
const postData = await this.readPostFile(filePath);
if (postData) {
const existing = await db.select().from(posts).where(eq(posts.id, postData.id)).get();
const checksum = this.calculateChecksum(postData.content);
if (existing) {
await db.update(posts)
.set({
title: postData.title,
slug: postData.slug,
excerpt: postData.excerpt,
status: postData.status,
author: postData.author,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt,
filePath,
checksum,
tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
})
.where(eq(posts.id, postData.id));
} else {
await db.insert(posts).values({
id: postData.id,
title: postData.title,
slug: postData.slug,
excerpt: postData.excerpt,
status: postData.status,
author: postData.author,
createdAt: postData.createdAt,
updatedAt: postData.updatedAt,
publishedAt: postData.publishedAt,
filePath,
syncStatus: 'pending',
checksum,
tags: JSON.stringify(postData.tags),
categories: JSON.stringify(postData.categories),
});
}
}
}
onProgress(100, 'Database rebuild complete');
this.emit('databaseRebuilt');
},
};
await taskManager.runTask(task);
}
}
// Singleton instance
let postEngine: PostEngine | null = null;
export function getPostEngine(): PostEngine {
if (!postEngine) {
postEngine = new PostEngine();
}
return postEngine;
}

View File

@@ -0,0 +1,324 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import { eq, and } from 'drizzle-orm';
import { getDatabase } from '../database';
import { syncLog, posts, media, NewSyncLogEntry } from '../database/schema';
import { taskManager, Task } from './TaskManager';
import { getPostEngine } from './PostEngine';
import { getMediaEngine } from './MediaEngine';
export type SyncDirection = 'push' | 'pull' | 'bidirectional';
export type SyncStatus = 'idle' | 'syncing' | 'error';
export interface SyncConfig {
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean;
syncInterval: number; // in minutes
}
export interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
export class SyncEngine extends EventEmitter {
private syncStatus: SyncStatus = 'idle';
private syncConfig: SyncConfig | null = null;
private syncIntervalId: NodeJS.Timeout | null = null;
constructor() {
super();
}
getSyncStatus(): SyncStatus {
return this.syncStatus;
}
isConfigured(): boolean {
return this.syncConfig !== null &&
!!this.syncConfig.tursoUrl &&
!!this.syncConfig.tursoAuthToken;
}
async configure(config: SyncConfig): Promise<void> {
this.syncConfig = config;
// Stop existing auto-sync
if (this.syncIntervalId) {
clearInterval(this.syncIntervalId);
this.syncIntervalId = null;
}
// Start auto-sync if enabled
if (config.autoSync && config.syncInterval > 0) {
this.syncIntervalId = setInterval(
() => this.sync('bidirectional'),
config.syncInterval * 60 * 1000
);
}
// Initialize remote database connection
const db = getDatabase();
await db.initializeRemote();
this.emit('configured', config);
}
async sync(direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
if (!this.isConfigured()) {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Sync not configured'],
};
}
if (this.syncStatus === 'syncing') {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Sync already in progress'],
};
}
const task: Task<SyncResult> = {
id: uuidv4(),
name: `Sync (${direction})`,
execute: async (onProgress) => {
this.syncStatus = 'syncing';
this.emit('syncStarted', direction);
const result: SyncResult = {
success: true,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: [],
};
try {
const db = getDatabase();
const localDb = db.getLocal();
const remoteDb = db.getRemote();
if (!remoteDb) {
throw new Error('Remote database not initialized');
}
onProgress(10, 'Fetching pending changes...');
if (direction === 'push' || direction === 'bidirectional') {
// Get pending posts
const pendingPosts = await localDb
.select()
.from(posts)
.where(eq(posts.syncStatus, 'pending'))
.all();
onProgress(20, `Pushing ${pendingPosts.length} posts...`);
for (const post of pendingPosts) {
try {
// Push to remote (simplified - in production would handle conflicts)
await remoteDb.insert(posts).values(post).onConflictDoUpdate({
target: posts.id,
set: {
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
checksum: post.checksum,
tags: post.tags,
categories: post.categories,
},
});
// Mark as synced locally
await localDb
.update(posts)
.set({ syncStatus: 'synced', syncedAt: new Date() })
.where(eq(posts.id, post.id));
result.pushed++;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to push post ${post.id}: ${errorMsg}`);
// Log the error
await this.logSyncOperation(post.id, 'post', 'update', 'failed', errorMsg);
}
}
// Get pending media
const pendingMedia = await localDb
.select()
.from(media)
.where(eq(media.syncStatus, 'pending'))
.all();
onProgress(50, `Pushing ${pendingMedia.length} media items...`);
for (const item of pendingMedia) {
try {
await remoteDb.insert(media).values(item).onConflictDoUpdate({
target: media.id,
set: {
alt: item.alt,
caption: item.caption,
updatedAt: item.updatedAt,
checksum: item.checksum,
tags: item.tags,
},
});
await localDb
.update(media)
.set({ syncStatus: 'synced', syncedAt: new Date() })
.where(eq(media.id, item.id));
result.pushed++;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to push media ${item.id}: ${errorMsg}`);
await this.logSyncOperation(item.id, 'media', 'update', 'failed', errorMsg);
}
}
}
if (direction === 'pull' || direction === 'bidirectional') {
onProgress(70, 'Pulling remote changes...');
// In a real implementation, we would:
// 1. Fetch all remote records with syncedAt > local last sync
// 2. Compare checksums to detect conflicts
// 3. Apply or merge changes
// For now, this is a placeholder
onProgress(90, 'Pull complete');
}
onProgress(100, 'Sync complete');
this.syncStatus = 'idle';
this.emit('syncCompleted', result);
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
result.success = false;
result.errors.push(errorMsg);
this.syncStatus = 'error';
this.emit('syncFailed', errorMsg);
return result;
}
},
};
return taskManager.runTask(task);
}
private async logSyncOperation(
entityId: string,
entityType: 'post' | 'media',
operation: 'create' | 'update' | 'delete',
status: 'pending' | 'completed' | 'failed',
errorMessage?: string
): Promise<void> {
const db = getDatabase().getLocal();
const logEntry: NewSyncLogEntry = {
id: uuidv4(),
entityType,
entityId,
operation,
status,
timestamp: new Date(),
errorMessage,
retryCount: 0,
};
await db.insert(syncLog).values(logEntry);
}
async getPendingChangesCount(): Promise<{ posts: number; media: number }> {
const db = getDatabase().getLocal();
const pendingPosts = await db
.select()
.from(posts)
.where(eq(posts.syncStatus, 'pending'))
.all();
const pendingMedia = await db
.select()
.from(media)
.where(eq(media.syncStatus, 'pending'))
.all();
return {
posts: pendingPosts.length,
media: pendingMedia.length,
};
}
async getSyncLog(limit = 50): Promise<Array<{
id: string;
entityType: string;
entityId: string;
operation: string;
status: string;
timestamp: Date;
errorMessage?: string;
}>> {
const db = getDatabase().getLocal();
const logs = await db
.select()
.from(syncLog)
.orderBy(syncLog.timestamp)
.limit(limit)
.all();
return logs.map(log => ({
id: log.id,
entityType: log.entityType,
entityId: log.entityId,
operation: log.operation,
status: log.status,
timestamp: log.timestamp,
errorMessage: log.errorMessage || undefined,
}));
}
stopAutoSync(): void {
if (this.syncIntervalId) {
clearInterval(this.syncIntervalId);
this.syncIntervalId = null;
}
this.emit('autoSyncStopped');
}
}
// Singleton instance
let syncEngine: SyncEngine | null = null;
export function getSyncEngine(): SyncEngine {
if (!syncEngine) {
syncEngine = new SyncEngine();
}
return syncEngine;
}

View File

@@ -0,0 +1,166 @@
import { EventEmitter } from 'events';
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
export interface TaskProgress {
taskId: string;
status: TaskStatus;
progress: number; // 0-100
message: string;
startTime: Date;
endTime?: Date;
error?: string;
}
export interface Task<T = unknown> {
id: string;
name: string;
execute: (onProgress: (progress: number, message: string) => void) => Promise<T>;
cancel?: () => void;
}
export class TaskManager extends EventEmitter {
private tasks: Map<string, TaskProgress> = new Map();
private runningTasks: Map<string, AbortController> = new Map();
private maxConcurrentTasks = 3;
private taskQueue: Task[] = [];
constructor() {
super();
}
getTaskStatus(taskId: string): TaskProgress | undefined {
return this.tasks.get(taskId);
}
getAllTasks(): TaskProgress[] {
return Array.from(this.tasks.values());
}
getRunningTasks(): TaskProgress[] {
return Array.from(this.tasks.values()).filter(t => t.status === 'running');
}
async runTask<T>(task: Task<T>): Promise<T> {
const progress: TaskProgress = {
taskId: task.id,
status: 'pending',
progress: 0,
message: 'Waiting to start...',
startTime: new Date(),
};
this.tasks.set(task.id, progress);
this.emit('taskCreated', progress);
// Check if we can run immediately or need to queue
if (this.runningTasks.size >= this.maxConcurrentTasks) {
this.taskQueue.push(task as Task);
this.emit('taskQueued', progress);
// Wait until we can run
await new Promise<void>(resolve => {
const checkQueue = () => {
if (this.runningTasks.size < this.maxConcurrentTasks) {
const queueIndex = this.taskQueue.findIndex(t => t.id === task.id);
if (queueIndex !== -1) {
this.taskQueue.splice(queueIndex, 1);
}
resolve();
} else {
setTimeout(checkQueue, 100);
}
};
checkQueue();
});
}
const abortController = new AbortController();
this.runningTasks.set(task.id, abortController);
progress.status = 'running';
progress.message = 'Starting...';
this.emit('taskStarted', progress);
try {
const result = await task.execute((progressValue, message) => {
if (abortController.signal.aborted) {
throw new Error('Task cancelled');
}
progress.progress = progressValue;
progress.message = message;
this.emit('taskProgress', progress);
});
progress.status = 'completed';
progress.progress = 100;
progress.message = 'Completed';
progress.endTime = new Date();
this.emit('taskCompleted', progress);
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage === 'Task cancelled') {
progress.status = 'cancelled';
progress.message = 'Cancelled';
} else {
progress.status = 'failed';
progress.error = errorMessage;
progress.message = `Failed: ${errorMessage}`;
}
progress.endTime = new Date();
this.emit('taskFailed', progress);
throw error;
} finally {
this.runningTasks.delete(task.id);
this.processQueue();
}
}
cancelTask(taskId: string): boolean {
const controller = this.runningTasks.get(taskId);
if (controller) {
controller.abort();
return true;
}
// Check if in queue
const queueIndex = this.taskQueue.findIndex(t => t.id === taskId);
if (queueIndex !== -1) {
this.taskQueue.splice(queueIndex, 1);
const progress = this.tasks.get(taskId);
if (progress) {
progress.status = 'cancelled';
progress.message = 'Cancelled (was queued)';
progress.endTime = new Date();
this.emit('taskCancelled', progress);
}
return true;
}
return false;
}
private processQueue(): void {
if (this.taskQueue.length > 0 && this.runningTasks.size < this.maxConcurrentTasks) {
// Queue processing happens automatically via the waiting promises
this.emit('queueProcessing');
}
}
clearCompletedTasks(): void {
for (const [id, task] of this.tasks) {
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
this.tasks.delete(id);
}
}
this.emit('tasksCleared');
}
}
// Singleton instance
export const taskManager = new TaskManager();

4
src/main/engine/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager';
export { PostEngine, getPostEngine, type PostData } from './PostEngine';
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';

219
src/main/ipc/handlers.ts Normal file
View File

@@ -0,0 +1,219 @@
import { ipcMain, dialog, shell } from 'electron';
import { getPostEngine, PostData } from '../engine/PostEngine';
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database';
export function registerIpcHandlers(): void {
// ============ Post Handlers ============
ipcMain.handle('posts:create', async (_, data: Partial<PostData>) => {
const engine = getPostEngine();
return engine.createPost(data);
});
ipcMain.handle('posts:update', async (_, id: string, data: Partial<PostData>) => {
const engine = getPostEngine();
return engine.updatePost(id, data);
});
ipcMain.handle('posts:delete', async (_, id: string) => {
const engine = getPostEngine();
return engine.deletePost(id);
});
ipcMain.handle('posts:get', async (_, id: string) => {
const engine = getPostEngine();
return engine.getPost(id);
});
ipcMain.handle('posts:getAll', async () => {
const engine = getPostEngine();
return engine.getAllPosts();
});
ipcMain.handle('posts:getByStatus', async (_, status: 'draft' | 'published' | 'archived') => {
const engine = getPostEngine();
return engine.getPostsByStatus(status);
});
ipcMain.handle('posts:publish', async (_, id: string) => {
const engine = getPostEngine();
return engine.publishPost(id);
});
ipcMain.handle('posts:unpublish', async (_, id: string) => {
const engine = getPostEngine();
return engine.unpublishPost(id);
});
ipcMain.handle('posts:rebuildFromFiles', async () => {
const engine = getPostEngine();
return engine.rebuildDatabaseFromFiles();
});
// ============ Media Handlers ============
ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => {
const engine = getMediaEngine();
return engine.importMedia(sourcePath, metadata);
});
ipcMain.handle('media:importDialog', async () => {
const result = await dialog.showOpenDialog({
title: 'Import Media',
filters: [
{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile', 'multiSelections'],
});
if (result.canceled || result.filePaths.length === 0) {
return [];
}
const engine = getMediaEngine();
const imported: MediaData[] = [];
for (const filePath of result.filePaths) {
try {
const media = await engine.importMedia(filePath);
imported.push(media);
} catch (error) {
console.error(`Failed to import ${filePath}:`, error);
}
}
return imported;
});
ipcMain.handle('media:update', async (_, id: string, data: Partial<MediaData>) => {
const engine = getMediaEngine();
return engine.updateMedia(id, data);
});
ipcMain.handle('media:delete', async (_, id: string) => {
const engine = getMediaEngine();
return engine.deleteMedia(id);
});
ipcMain.handle('media:get', async (_, id: string) => {
const engine = getMediaEngine();
return engine.getMedia(id);
});
ipcMain.handle('media:getAll', async () => {
const engine = getMediaEngine();
return engine.getAllMedia();
});
ipcMain.handle('media:rebuildFromFiles', async () => {
const engine = getMediaEngine();
return engine.rebuildDatabaseFromFiles();
});
// ============ Sync Handlers ============
ipcMain.handle('sync:configure', async (_, config: SyncConfig) => {
const engine = getSyncEngine();
return engine.configure(config);
});
ipcMain.handle('sync:start', async (_, direction: SyncDirection = 'bidirectional') => {
const engine = getSyncEngine();
return engine.sync(direction);
});
ipcMain.handle('sync:getStatus', async () => {
const engine = getSyncEngine();
return engine.getSyncStatus();
});
ipcMain.handle('sync:isConfigured', async () => {
const engine = getSyncEngine();
return engine.isConfigured();
});
ipcMain.handle('sync:getPendingCount', async () => {
const engine = getSyncEngine();
return engine.getPendingChangesCount();
});
ipcMain.handle('sync:getLog', async (_, limit?: number) => {
const engine = getSyncEngine();
return engine.getSyncLog(limit);
});
ipcMain.handle('sync:stopAutoSync', async () => {
const engine = getSyncEngine();
return engine.stopAutoSync();
});
// ============ Task Handlers ============
ipcMain.handle('tasks:getAll', async () => {
return taskManager.getAllTasks();
});
ipcMain.handle('tasks:getRunning', async () => {
return taskManager.getRunningTasks();
});
ipcMain.handle('tasks:cancel', async (_, taskId: string) => {
return taskManager.cancelTask(taskId);
});
ipcMain.handle('tasks:clearCompleted', async () => {
return taskManager.clearCompletedTasks();
});
// ============ App Handlers ============
ipcMain.handle('app:getDataPaths', async () => {
return getDatabase().getDataPaths();
});
ipcMain.handle('app:openFolder', async (_, folderPath: string) => {
return shell.openPath(folderPath);
});
ipcMain.handle('app:showItemInFolder', async (_, itemPath: string) => {
return shell.showItemInFolder(itemPath);
});
// ============ Event Forwarding ============
// Forward engine events to renderer
const postEngine = getPostEngine();
const mediaEngine = getMediaEngine();
const syncEngine = getSyncEngine();
const forwardEvent = (eventName: string) => {
return (...args: unknown[]) => {
// Will be sent to renderer via webContents when window is available
ipcMain.emit('forward-to-renderer', eventName, ...args);
};
};
postEngine.on('postCreated', forwardEvent('post:created'));
postEngine.on('postUpdated', forwardEvent('post:updated'));
postEngine.on('postDeleted', forwardEvent('post:deleted'));
postEngine.on('databaseRebuilt', forwardEvent('posts:databaseRebuilt'));
mediaEngine.on('mediaImported', forwardEvent('media:imported'));
mediaEngine.on('mediaUpdated', forwardEvent('media:updated'));
mediaEngine.on('mediaDeleted', forwardEvent('media:deleted'));
mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt'));
syncEngine.on('syncStarted', forwardEvent('sync:started'));
syncEngine.on('syncCompleted', forwardEvent('sync:completed'));
syncEngine.on('syncFailed', forwardEvent('sync:failed'));
taskManager.on('taskCreated', forwardEvent('task:created'));
taskManager.on('taskStarted', forwardEvent('task:started'));
taskManager.on('taskProgress', forwardEvent('task:progress'));
taskManager.on('taskCompleted', forwardEvent('task:completed'));
taskManager.on('taskFailed', forwardEvent('task:failed'));
}

1
src/main/ipc/index.ts Normal file
View File

@@ -0,0 +1 @@
export { registerIpcHandlers } from './handlers';

335
src/main/main.ts Normal file
View File

@@ -0,0 +1,335 @@
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain } from 'electron';
import * as path from 'path';
import * as fs from 'fs';
import { getDatabase } from './database';
import { registerIpcHandlers } from './ipc';
let mainWindow: BrowserWindow | null = null;
// Check if dev server is likely running (only in development)
const isDev = process.env.NODE_ENV === 'development';
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
title: 'Blogging Desktop Server',
backgroundColor: '#1e1e1e', // VS Code dark background
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
},
icon: path.join(__dirname, '../../assets/icon.png'),
});
// Set up the application menu
const menu = createApplicationMenu();
Menu.setApplicationMenu(menu);
// Load the app - use built files unless explicitly in dev mode
const rendererPath = path.join(__dirname, '../renderer/index.html');
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else if (fs.existsSync(rendererPath)) {
mainWindow.loadFile(rendererPath);
} else {
// Fallback to dev server if built files don't exist
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
}
// Forward events to renderer
ipcMain.on('forward-to-renderer', (_event, eventName: string, ...args: unknown[]) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(eventName, ...args);
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
function createApplicationMenu(): Menu {
const template: MenuItemConstructorOptions[] = [
{
label: 'File',
submenu: [
{
label: 'New Post',
accelerator: 'CmdOrCtrl+N',
click: () => {
mainWindow?.webContents.send('menu:newPost');
},
},
{
label: 'Import Media...',
accelerator: 'CmdOrCtrl+I',
click: () => {
mainWindow?.webContents.send('menu:importMedia');
},
},
{ type: 'separator' },
{
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click: () => {
mainWindow?.webContents.send('menu:save');
},
},
{ type: 'separator' },
{
label: 'Open Data Folder',
click: async () => {
const { shell } = require('electron');
const paths = getDatabase().getDataPaths();
shell.openPath(path.dirname(paths.database));
},
},
{ type: 'separator' },
{
label: 'Exit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Alt+F4',
click: () => {
app.quit();
},
},
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Find',
accelerator: 'CmdOrCtrl+F',
click: () => {
mainWindow?.webContents.send('menu:find');
},
},
{
label: 'Replace',
accelerator: 'CmdOrCtrl+H',
click: () => {
mainWindow?.webContents.send('menu:replace');
},
},
],
},
{
label: 'View',
submenu: [
{
label: 'Posts',
accelerator: 'CmdOrCtrl+1',
click: () => {
mainWindow?.webContents.send('menu:viewPosts');
},
},
{
label: 'Media',
accelerator: 'CmdOrCtrl+2',
click: () => {
mainWindow?.webContents.send('menu:viewMedia');
},
},
{ type: 'separator' },
{
label: 'Toggle Sidebar',
accelerator: 'CmdOrCtrl+B',
click: () => {
mainWindow?.webContents.send('menu:toggleSidebar');
},
},
{
label: 'Toggle Panel',
accelerator: 'CmdOrCtrl+J',
click: () => {
mainWindow?.webContents.send('menu:togglePanel');
},
},
{ type: 'separator' },
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Blog',
submenu: [
{
label: 'Publish Selected',
accelerator: 'CmdOrCtrl+Shift+P',
click: () => {
mainWindow?.webContents.send('menu:publishSelected');
},
},
{
label: 'Unpublish Selected',
click: () => {
mainWindow?.webContents.send('menu:unpublishSelected');
},
},
{ type: 'separator' },
{
label: 'Preview Post',
accelerator: 'CmdOrCtrl+Shift+V',
click: () => {
mainWindow?.webContents.send('menu:previewPost');
},
},
{ type: 'separator' },
{
label: 'Rebuild Database from Files',
click: () => {
mainWindow?.webContents.send('menu:rebuildDatabase');
},
},
],
},
{
label: 'Sync',
submenu: [
{
label: 'Sync Now',
accelerator: 'CmdOrCtrl+Shift+S',
click: () => {
mainWindow?.webContents.send('menu:syncNow');
},
},
{
label: 'Push Changes',
click: () => {
mainWindow?.webContents.send('menu:pushChanges');
},
},
{
label: 'Pull Changes',
click: () => {
mainWindow?.webContents.send('menu:pullChanges');
},
},
{ type: 'separator' },
{
label: 'Configure Sync...',
click: () => {
mainWindow?.webContents.send('menu:configureSync');
},
},
{
label: 'View Sync Log',
click: () => {
mainWindow?.webContents.send('menu:viewSyncLog');
},
},
],
},
{
label: 'Help',
submenu: [
{
label: 'About Blogging Desktop Server',
click: () => {
mainWindow?.webContents.send('menu:about');
},
},
{ type: 'separator' },
{
label: 'View on GitHub',
click: async () => {
const { shell } = require('electron');
await shell.openExternal('https://github.com/bds/blogging-desktop-server');
},
},
{
label: 'Report Issue',
click: async () => {
const { shell } = require('electron');
await shell.openExternal('https://github.com/bds/blogging-desktop-server/issues');
},
},
],
},
];
// macOS specific menu adjustments
if (process.platform === 'darwin') {
template.unshift({
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
});
}
return Menu.buildFromTemplate(template);
}
async function initialize(): Promise<void> {
// Initialize database
const db = getDatabase();
await db.initializeLocal();
// Register IPC handlers
registerIpcHandlers();
}
// App lifecycle
app.whenReady().then(async () => {
await initialize();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', async () => {
const db = getDatabase();
await db.close();
});
// Handle any uncaught exceptions
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise, 'reason:', reason);
});

118
src/main/preload.ts Normal file
View File

@@ -0,0 +1,118 @@
import { contextBridge, ipcRenderer } from 'electron';
// Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electronAPI', {
// Posts
posts: {
create: (data: unknown) => ipcRenderer.invoke('posts:create', data),
update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data),
delete: (id: string) => ipcRenderer.invoke('posts:delete', id),
get: (id: string) => ipcRenderer.invoke('posts:get', id),
getAll: () => ipcRenderer.invoke('posts:getAll'),
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
unpublish: (id: string) => ipcRenderer.invoke('posts:unpublish', id),
rebuildFromFiles: () => ipcRenderer.invoke('posts:rebuildFromFiles'),
},
// Media
media: {
import: (sourcePath: string, metadata?: unknown) => ipcRenderer.invoke('media:import', sourcePath, metadata),
importDialog: () => ipcRenderer.invoke('media:importDialog'),
update: (id: string, data: unknown) => ipcRenderer.invoke('media:update', id, data),
delete: (id: string) => ipcRenderer.invoke('media:delete', id),
get: (id: string) => ipcRenderer.invoke('media:get', id),
getAll: () => ipcRenderer.invoke('media:getAll'),
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
},
// Sync
sync: {
configure: (config: unknown) => ipcRenderer.invoke('sync:configure', config),
start: (direction?: string) => ipcRenderer.invoke('sync:start', direction),
getStatus: () => ipcRenderer.invoke('sync:getStatus'),
isConfigured: () => ipcRenderer.invoke('sync:isConfigured'),
getPendingCount: () => ipcRenderer.invoke('sync:getPendingCount'),
getLog: (limit?: number) => ipcRenderer.invoke('sync:getLog', limit),
stopAutoSync: () => ipcRenderer.invoke('sync:stopAutoSync'),
},
// Tasks
tasks: {
getAll: () => ipcRenderer.invoke('tasks:getAll'),
getRunning: () => ipcRenderer.invoke('tasks:getRunning'),
cancel: (taskId: string) => ipcRenderer.invoke('tasks:cancel', taskId),
clearCompleted: () => ipcRenderer.invoke('tasks:clearCompleted'),
},
// App
app: {
getDataPaths: () => ipcRenderer.invoke('app:getDataPaths'),
openFolder: (folderPath: string) => ipcRenderer.invoke('app:openFolder', folderPath),
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
},
// Event listeners
on: (channel: string, callback: (...args: unknown[]) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args);
ipcRenderer.on(channel, subscription);
return () => ipcRenderer.removeListener(channel, subscription);
},
once: (channel: string, callback: (...args: unknown[]) => void) => {
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
},
});
// Type definitions for the exposed API
export interface ElectronAPI {
posts: {
create: (data: unknown) => Promise<unknown>;
update: (id: string, data: unknown) => Promise<unknown>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<unknown>;
getAll: () => Promise<unknown[]>;
getByStatus: (status: string) => Promise<unknown[]>;
publish: (id: string) => Promise<unknown>;
unpublish: (id: string) => Promise<unknown>;
rebuildFromFiles: () => Promise<void>;
};
media: {
import: (sourcePath: string, metadata?: unknown) => Promise<unknown>;
importDialog: () => Promise<unknown[]>;
update: (id: string, data: unknown) => Promise<unknown>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<unknown>;
getAll: () => Promise<unknown[]>;
rebuildFromFiles: () => Promise<void>;
};
sync: {
configure: (config: unknown) => Promise<void>;
start: (direction?: string) => Promise<unknown>;
getStatus: () => Promise<string>;
isConfigured: () => Promise<boolean>;
getPendingCount: () => Promise<{ posts: number; media: number }>;
getLog: (limit?: number) => Promise<unknown[]>;
stopAutoSync: () => Promise<void>;
};
tasks: {
getAll: () => Promise<unknown[]>;
getRunning: () => Promise<unknown[]>;
cancel: (taskId: string) => Promise<boolean>;
clearCompleted: () => Promise<void>;
};
app: {
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
openFolder: (folderPath: string) => Promise<string>;
showItemInFolder: (itemPath: string) => Promise<void>;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}

20
src/renderer/App.css Normal file
View File

@@ -0,0 +1,20 @@
.app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
}
.app-main {
flex: 1;
display: flex;
overflow: hidden;
}
.app-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}

263
src/renderer/App.tsx Normal file
View File

@@ -0,0 +1,263 @@
import React, { useEffect } from 'react';
import { ActivityBar, Sidebar, Editor, StatusBar, Panel } from './components';
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import './App.css';
const App: React.FC = () => {
const {
setPosts,
setMedia,
addPost,
updatePost,
removePost,
addMedia,
updateMedia,
removeMedia,
setTasks,
updateTask,
setSyncStatus,
setSyncConfigured,
setPendingChanges,
setLoading,
toggleSidebar,
togglePanel,
setActiveView,
setSelectedPost,
} = useAppStore();
// Load initial data
useEffect(() => {
const loadData = async () => {
setLoading(true);
try {
// Load posts
const posts = await window.electronAPI?.posts.getAll();
if (posts) {
setPosts(posts as PostData[]);
}
// Load media
const media = await window.electronAPI?.media.getAll();
if (media) {
setMedia(media as MediaData[]);
}
// Check sync status
const syncConfigured = await window.electronAPI?.sync.isConfigured();
setSyncConfigured(syncConfigured || false);
// Get pending changes count
const pending = await window.electronAPI?.sync.getPendingCount();
if (pending) {
setPendingChanges(pending);
}
// Load tasks
const tasks = await window.electronAPI?.tasks.getAll();
if (tasks) {
setTasks(tasks as TaskProgress[]);
}
} catch (error) {
console.error('Failed to load initial data:', error);
} finally {
setLoading(false);
}
};
loadData();
}, []);
// Set up event listeners for real-time updates
useEffect(() => {
const unsubscribers: Array<() => void> = [];
// Post events
unsubscribers.push(
window.electronAPI?.on('post:created', (post: unknown) => {
addPost(post as PostData);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('post:updated', (post: unknown) => {
const p = post as PostData;
updatePost(p.id, p);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('post:deleted', (id: unknown) => {
removePost(id as string);
}) || (() => {})
);
// Media events
unsubscribers.push(
window.electronAPI?.on('media:imported', (media: unknown) => {
addMedia(media as MediaData);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('media:updated', (media: unknown) => {
const m = media as MediaData;
updateMedia(m.id, m);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('media:deleted', (id: unknown) => {
removeMedia(id as string);
}) || (() => {})
);
// Sync events
unsubscribers.push(
window.electronAPI?.on('sync:started', () => {
setSyncStatus('syncing');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('sync:completed', async () => {
setSyncStatus('idle');
const pending = await window.electronAPI?.sync.getPendingCount();
if (pending) {
setPendingChanges(pending);
}
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('sync:failed', () => {
setSyncStatus('error');
}) || (() => {})
);
// Task events
unsubscribers.push(
window.electronAPI?.on('task:progress', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('task:completed', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('task:failed', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
}) || (() => {})
);
// Menu events
unsubscribers.push(
window.electronAPI?.on('menu:newPost', async () => {
const post = await window.electronAPI?.posts.create({
title: 'New Post',
content: '# New Post\n\nStart writing...',
});
if (post) {
setSelectedPost((post as PostData).id);
setActiveView('posts');
}
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:importMedia', () => {
window.electronAPI?.media.importDialog();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:toggleSidebar', () => {
toggleSidebar();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:togglePanel', () => {
togglePanel();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:viewPosts', () => {
setActiveView('posts');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:viewMedia', () => {
setActiveView('media');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:syncNow', () => {
window.electronAPI?.sync.start('bidirectional');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:pushChanges', () => {
window.electronAPI?.sync.start('push');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:pullChanges', () => {
window.electronAPI?.sync.start('pull');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:configureSync', () => {
setActiveView('settings');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:rebuildDatabase', async () => {
await window.electronAPI?.posts.rebuildFromFiles();
await window.electronAPI?.media.rebuildFromFiles();
// Reload data
const posts = await window.electronAPI?.posts.getAll();
if (posts) {
setPosts(posts as PostData[]);
}
const media = await window.electronAPI?.media.getAll();
if (media) {
setMedia(media as MediaData[]);
}
}) || (() => {})
);
return () => {
unsubscribers.forEach(unsub => unsub());
};
}, []);
return (
<div className="app">
<div className="app-main">
<ActivityBar />
<Sidebar />
<div className="app-content">
<Editor />
<Panel />
</div>
</div>
<StatusBar />
</div>
);
};
export default App;

View File

@@ -0,0 +1,82 @@
.activity-bar {
width: 48px;
height: 100%;
background-color: var(--vscode-activityBar-background);
display: flex;
flex-direction: column;
justify-content: space-between;
border-right: 1px solid var(--vscode-panel-border);
}
.activity-bar-top,
.activity-bar-bottom {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 0;
}
.activity-bar-item {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--vscode-activityBar-foreground);
opacity: 0.6;
cursor: pointer;
position: relative;
padding: 0;
border-radius: 0;
}
.activity-bar-item:hover {
opacity: 1;
background: transparent;
}
.activity-bar-item.active {
opacity: 1;
}
.activity-bar-item.active::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--vscode-activityBar-foreground);
}
.activity-bar-item.syncing svg {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.activity-bar-badge {
position: absolute;
top: 8px;
right: 8px;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
font-weight: 600;
background-color: var(--vscode-activityBarBadge-background);
color: var(--vscode-activityBarBadge-foreground);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { useAppStore } from '../../store';
import './ActivityBar.css';
// Simple SVG icons
const PostsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/>
<path d="M8 12h8v2H8zm0 4h8v2H8z"/>
</svg>
);
const MediaIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
);
const SettingsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
</svg>
);
const SyncIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
</svg>
);
export const ActivityBar: React.FC = () => {
const { activeView, setActiveView, syncStatus, pendingChanges } = useAppStore();
const totalPending = pendingChanges.posts + pendingChanges.media;
return (
<div className="activity-bar">
<div className="activity-bar-top">
<button
className={`activity-bar-item ${activeView === 'posts' ? 'active' : ''}`}
onClick={() => setActiveView('posts')}
title="Posts"
>
<PostsIcon />
</button>
<button
className={`activity-bar-item ${activeView === 'media' ? 'active' : ''}`}
onClick={() => setActiveView('media')}
title="Media"
>
<MediaIcon />
</button>
</div>
<div className="activity-bar-bottom">
<button
className={`activity-bar-item ${syncStatus === 'syncing' ? 'syncing' : ''}`}
onClick={() => window.electronAPI?.sync.start()}
title={`Sync (${totalPending} pending)`}
>
<SyncIcon />
{totalPending > 0 && (
<span className="activity-bar-badge">{totalPending}</span>
)}
</button>
<button
className={`activity-bar-item ${activeView === 'settings' ? 'active' : ''}`}
onClick={() => setActiveView('settings')}
title="Settings"
>
<SettingsIcon />
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { ActivityBar } from './ActivityBar';

View File

@@ -0,0 +1,298 @@
.editor {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
overflow: hidden;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
height: 35px;
background-color: var(--vscode-tab-activeBackground);
border-bottom: 1px solid var(--vscode-panel-border);
}
.editor-tabs {
display: flex;
align-items: center;
gap: 2px;
}
.editor-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: var(--vscode-tab-inactiveBackground);
color: var(--vscode-tab-inactiveForeground);
font-size: 13px;
border-radius: 4px 4px 0 0;
}
.editor-tab.active {
background-color: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
.editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground);
font-size: 10px;
}
.editor-actions {
display: flex;
align-items: center;
gap: 8px;
}
.status-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.status-badge.status-draft {
background-color: rgba(204, 167, 0, 0.2);
color: var(--vscode-notificationsWarningIcon-foreground);
}
.status-badge.status-published {
background-color: rgba(115, 201, 145, 0.2);
color: var(--vscode-testing-iconPassed);
}
.status-badge.status-archived {
background-color: rgba(133, 133, 133, 0.2);
color: var(--vscode-descriptionForeground);
}
.editor-actions button {
padding: 4px 10px;
font-size: 12px;
}
.editor-actions button.danger:hover {
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
overflow-y: auto;
gap: 16px;
}
.editor-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.editor-field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 200px;
}
.editor-field label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.editor-field input,
.editor-field textarea {
padding: 8px 10px;
border-radius: 4px;
}
.editor-field input.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.editor-field-row {
display: flex;
gap: 12px;
}
.editor-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 300px;
}
.editor-body label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.editor-body textarea {
flex: 1;
resize: none;
font-family: var(--vscode-editor-font-family);
font-size: var(--vscode-editor-font-size);
line-height: 1.5;
padding: 12px;
border-radius: 4px;
}
.editor-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
border-top: 1px solid var(--vscode-panel-border);
background-color: var(--vscode-sideBar-background);
}
/* Media Editor */
.media-editor {
flex-direction: row;
gap: 24px;
}
.media-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-input-background);
border-radius: 8px;
min-height: 300px;
}
.media-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vscode-descriptionForeground);
}
.media-details {
width: 320px;
display: flex;
flex-direction: column;
gap: 12px;
}
.media-details textarea {
resize: vertical;
}
/* Empty State / Welcome */
.editor-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-editor-background);
}
.welcome-content {
max-width: 600px;
text-align: center;
}
.welcome-content h1 {
font-size: 28px;
font-weight: 400;
margin-bottom: 8px;
color: var(--vscode-editor-foreground);
}
.welcome-content > p {
margin-bottom: 40px;
}
.welcome-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 40px;
}
.welcome-action {
padding: 20px;
background-color: var(--vscode-sideBar-background);
border-radius: 8px;
text-align: left;
}
.welcome-action h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: var(--vscode-editor-foreground);
}
.welcome-action p {
font-size: 12px;
color: var(--vscode-descriptionForeground);
margin-bottom: 16px;
line-height: 1.5;
}
.welcome-action button {
width: 100%;
}
.welcome-shortcuts {
text-align: left;
background-color: var(--vscode-sideBar-background);
padding: 20px;
border-radius: 8px;
}
.welcome-shortcuts h4 {
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.shortcut-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.shortcut-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.shortcut-item kbd {
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
font-size: 11px;
}
.shortcut-item span {
color: var(--vscode-descriptionForeground);
}

View File

@@ -0,0 +1,405 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore, PostData } from '../../store';
import './Editor.css';
interface PostEditorProps {
post: PostData;
}
const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const { updatePost } = useAppStore();
const [title, setTitle] = useState(post.title);
const [content, setContent] = useState(post.content);
const [tags, setTags] = useState(post.tags.join(', '));
const [isDirty, setIsDirty] = useState(false);
// Reset when post changes
useEffect(() => {
setTitle(post.title);
setContent(post.content);
setTags(post.tags.join(', '));
setIsDirty(false);
}, [post.id]);
// Track changes
useEffect(() => {
const hasChanges =
title !== post.title ||
content !== post.content ||
tags !== post.tags.join(', ');
setIsDirty(hasChanges);
}, [title, content, tags, post]);
const handleSave = useCallback(async () => {
if (!isDirty) return;
try {
const updated = await window.electronAPI?.posts.update(post.id, {
title,
content,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
});
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
setIsDirty(false);
}
} catch (error) {
console.error('Failed to save post:', error);
}
}, [post.id, title, content, tags, isDirty, updatePost]);
const handlePublish = async () => {
await handleSave();
try {
const updated = await window.electronAPI?.posts.publish(post.id);
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
}
} catch (error) {
console.error('Failed to publish post:', error);
}
};
const handleUnpublish = async () => {
try {
const updated = await window.electronAPI?.posts.unpublish(post.id);
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
}
} catch (error) {
console.error('Failed to unpublish post:', error);
}
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this post?')) {
try {
await window.electronAPI?.posts.delete(post.id);
useAppStore.getState().removePost(post.id);
} catch (error) {
console.error('Failed to delete post:', error);
}
}
};
// Save on Ctrl+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSave]);
// Listen for menu events
useEffect(() => {
const unsubscribeSave = window.electronAPI?.on('menu:save', handleSave);
const unsubscribePublish = window.electronAPI?.on('menu:publishSelected', handlePublish);
const unsubscribeUnpublish = window.electronAPI?.on('menu:unpublishSelected', handleUnpublish);
return () => {
unsubscribeSave?.();
unsubscribePublish?.();
unsubscribeUnpublish?.();
};
}, [handleSave]);
return (
<div className="editor">
<div className="editor-header">
<div className="editor-tabs">
<div className={`editor-tab active ${isDirty ? 'dirty' : ''}`}>
<span className="editor-tab-title">{post.title || 'Untitled'}</span>
{isDirty && <span className="editor-tab-dirty"></span>}
</div>
</div>
<div className="editor-actions">
<span className={`status-badge status-${post.status}`}>
{post.status}
</span>
{post.status === 'draft' ? (
<button onClick={handlePublish} title="Publish">Publish</button>
) : (
<button onClick={handleUnpublish} className="secondary" title="Unpublish">
Unpublish
</button>
)}
<button onClick={handleSave} disabled={!isDirty} title="Save (Ctrl+S)">
Save
</button>
<button onClick={handleDelete} className="secondary danger" title="Delete">
Delete
</button>
</div>
</div>
<div className="editor-content">
<div className="editor-meta">
<div className="editor-field">
<label>Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
/>
</div>
<div className="editor-field">
<label>Slug</label>
<input
type="text"
value={post.slug}
disabled
className="disabled"
/>
</div>
<div className="editor-field">
<label>Tags (comma-separated)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="tag1, tag2, tag3"
/>
</div>
</div>
<div className="editor-body">
<label>Content (Markdown)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your post content in Markdown..."
spellCheck
/>
</div>
</div>
<div className="editor-footer">
<span className="text-muted text-small">
Created: {new Date(post.createdAt).toLocaleString()}
</span>
<span className="text-muted text-small">
Updated: {new Date(post.updatedAt).toLocaleString()}
</span>
{post.publishedAt && (
<span className="text-muted text-small">
Published: {new Date(post.publishedAt).toLocaleString()}
</span>
)}
</div>
</div>
);
};
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { media, updateMedia } = useAppStore();
const item = media.find(m => m.id === mediaId);
const [alt, setAlt] = useState(item?.alt || '');
const [caption, setCaption] = useState(item?.caption || '');
const [tags, setTags] = useState(item?.tags.join(', ') || '');
useEffect(() => {
if (item) {
setAlt(item.alt || '');
setCaption(item.caption || '');
setTags(item.tags.join(', '));
}
}, [item?.id]);
if (!item) {
return <div className="editor-empty">Media not found</div>;
}
const handleSave = async () => {
try {
const updated = await window.electronAPI?.media.update(item.id, {
alt,
caption,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
});
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
}
} catch (error) {
console.error('Failed to update media:', error);
}
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this media file?')) {
try {
await window.electronAPI?.media.delete(item.id);
useAppStore.getState().removeMedia(item.id);
} catch (error) {
console.error('Failed to delete media:', error);
}
}
};
return (
<div className="editor">
<div className="editor-header">
<div className="editor-tabs">
<div className="editor-tab active">
<span className="editor-tab-title">{item.originalName}</span>
</div>
</div>
<div className="editor-actions">
<button onClick={handleSave}>Save</button>
<button onClick={handleDelete} className="secondary danger">Delete</button>
</div>
</div>
<div className="editor-content media-editor">
<div className="media-preview">
{item.mimeType.startsWith('image/') ? (
<div className="media-preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
<span>{item.originalName}</span>
</div>
) : (
<div className="media-preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
</svg>
<span>{item.originalName}</span>
</div>
)}
</div>
<div className="media-details">
<div className="editor-field">
<label>File Name</label>
<input type="text" value={item.originalName} disabled className="disabled" />
</div>
<div className="editor-field">
<label>Type</label>
<input type="text" value={item.mimeType} disabled className="disabled" />
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>Size</label>
<input type="text" value={`${(item.size / 1024).toFixed(1)} KB`} disabled className="disabled" />
</div>
{item.width && item.height && (
<div className="editor-field">
<label>Dimensions</label>
<input type="text" value={`${item.width} × ${item.height}`} disabled className="disabled" />
</div>
)}
</div>
<div className="editor-field">
<label>Alt Text</label>
<input
type="text"
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder="Describe the image for accessibility"
/>
</div>
<div className="editor-field">
<label>Caption</label>
<textarea
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Image caption"
rows={3}
/>
</div>
<div className="editor-field">
<label>Tags (comma-separated)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="tag1, tag2, tag3"
/>
</div>
</div>
</div>
</div>
);
};
const WelcomeScreen: React.FC = () => {
return (
<div className="editor-empty">
<div className="welcome-content">
<h1>Blogging Desktop Server</h1>
<p className="text-muted">bDS - Your offline-first blogging platform</p>
<div className="welcome-actions">
<div className="welcome-action">
<h3>Create a New Post</h3>
<p>Start writing your next blog post with Markdown support.</p>
<button onClick={() => window.electronAPI?.posts.create({ title: 'New Post' })}>
New Post
</button>
</div>
<div className="welcome-action">
<h3>Import Media</h3>
<p>Add images and files to use in your posts.</p>
<button className="secondary" onClick={() => window.electronAPI?.media.importDialog()}>
Import Media
</button>
</div>
<div className="welcome-action">
<h3>Configure Sync</h3>
<p>Connect to Turso for cloud synchronization.</p>
<button className="secondary" onClick={() => useAppStore.getState().setActiveView('settings')}>
Open Settings
</button>
</div>
</div>
<div className="welcome-shortcuts">
<h4>Keyboard Shortcuts</h4>
<div className="shortcut-list">
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>N</kbd>
<span>New Post</span>
</div>
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>S</kbd>
<span>Save</span>
</div>
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>B</kbd>
<span>Toggle Sidebar</span>
</div>
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd>
<span>Publish</span>
</div>
</div>
</div>
</div>
</div>
);
};
export const Editor: React.FC = () => {
const { activeView, selectedPostId, selectedMediaId, posts } = useAppStore();
if (activeView === 'posts' && selectedPostId) {
const post = posts.find(p => p.id === selectedPostId);
if (post) {
return <PostEditor post={post} />;
}
}
if (activeView === 'media' && selectedMediaId) {
return <MediaEditor mediaId={selectedMediaId} />;
}
return <WelcomeScreen />;
};

View File

@@ -0,0 +1 @@
export { Editor } from './Editor';

View File

@@ -0,0 +1,155 @@
.panel {
height: 200px;
display: flex;
flex-direction: column;
background-color: var(--vscode-panel-background);
border-top: 1px solid var(--vscode-panel-border);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 35px;
padding: 0 8px;
background-color: var(--vscode-sideBar-background);
border-bottom: 1px solid var(--vscode-panel-border);
}
.panel-tabs {
display: flex;
gap: 2px;
}
.panel-tab {
padding: 6px 12px;
font-size: 12px;
color: var(--vscode-tab-inactiveForeground);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.panel-tab:hover {
color: var(--vscode-tab-activeForeground);
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
border-bottom-color: var(--vscode-focusBorder);
}
.panel-close {
background: transparent;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 18px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
padding: 0;
}
.panel-close:hover {
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.panel-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.task-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background-color: var(--vscode-sideBar-background);
border-radius: 4px;
font-size: 12px;
}
.task-status {
width: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.task-spinner {
width: 12px;
height: 12px;
border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-focusBorder);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.task-check {
color: var(--vscode-testing-iconPassed);
font-weight: bold;
}
.task-error {
color: var(--vscode-notificationsErrorIcon-foreground);
font-weight: bold;
}
.task-pending {
color: var(--vscode-descriptionForeground);
}
.task-info {
flex: 1;
min-width: 0;
}
.task-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-progress-bar {
height: 3px;
background-color: var(--vscode-input-background);
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
background-color: var(--vscode-focusBorder);
transition: width 0.2s ease;
}
.task-cancel {
padding: 2px 8px;
font-size: 11px;
background-color: var(--vscode-button-secondaryBackground);
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { useAppStore } from '../../store';
import './Panel.css';
export const Panel: React.FC = () => {
const { panelVisible, tasks } = useAppStore();
if (!panelVisible) {
return null;
}
const recentTasks = tasks.slice(-10).reverse();
return (
<div className="panel">
<div className="panel-header">
<div className="panel-tabs">
<div className="panel-tab active">Tasks</div>
<div className="panel-tab">Output</div>
<div className="panel-tab">Sync Log</div>
</div>
<button
className="panel-close"
onClick={() => useAppStore.getState().togglePanel()}
title="Close Panel"
>
×
</button>
</div>
<div className="panel-content">
{recentTasks.length === 0 ? (
<div className="panel-empty">No recent tasks</div>
) : (
<div className="task-list">
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item status-${task.status}`}>
<div className="task-status">
{task.status === 'running' && <span className="task-spinner" />}
{task.status === 'completed' && <span className="task-check"></span>}
{task.status === 'failed' && <span className="task-error"></span>}
{task.status === 'pending' && <span className="task-pending"></span>}
</div>
<div className="task-info">
<div className="task-message">{task.message}</div>
{task.status === 'running' && (
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
)}
</div>
{task.status === 'running' && (
<button
className="task-cancel"
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
>
Cancel
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { Panel } from './Panel';

View File

@@ -0,0 +1,203 @@
.sidebar {
width: 280px;
height: 100%;
background-color: var(--vscode-sideBar-background);
border-right: 1px solid var(--vscode-sideBar-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-section {
margin-bottom: 4px;
}
.sidebar-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-sideBar-foreground);
}
.sidebar-action {
background: transparent;
border: none;
padding: 2px;
color: var(--vscode-sideBar-foreground);
cursor: pointer;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
}
.sidebar-action:hover {
opacity: 1;
background-color: var(--vscode-list-hoverBackground);
}
.sidebar-section-title {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.section-icon {
font-size: 8px;
}
.sidebar-list {
display: flex;
flex-direction: column;
}
.sidebar-item {
padding: 6px 12px 6px 24px;
cursor: pointer;
border-left: 2px solid transparent;
}
.sidebar-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.sidebar-item.selected {
background-color: var(--vscode-list-activeSelectionBackground);
border-left-color: var(--vscode-focusBorder);
}
.sidebar-item-title {
font-size: 13px;
color: var(--vscode-sideBar-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-top: 2px;
}
.sidebar-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
color: var(--vscode-descriptionForeground);
}
.sidebar-empty p {
margin-bottom: 16px;
}
/* Media Grid */
.media-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2px;
padding: 4px;
}
.media-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: 4px;
}
.media-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.media-item.selected {
background-color: var(--vscode-list-activeSelectionBackground);
}
.media-thumbnail {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-input-background);
border-radius: 4px;
flex-shrink: 0;
}
.media-item-info {
flex: 1;
min-width: 0;
}
.media-item-name {
font-size: 12px;
color: var(--vscode-sideBar-foreground);
}
.media-item-size {
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
/* Settings Panel */
.settings-panel {
padding: 0 12px 12px;
}
.settings-group {
margin-bottom: 24px;
}
.settings-group h3 {
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
color: var(--vscode-sideBar-foreground);
}
.settings-field {
margin-bottom: 12px;
}
.settings-field label {
display: block;
font-size: 12px;
margin-bottom: 4px;
color: var(--vscode-descriptionForeground);
}
.settings-field input {
width: 100%;
padding: 6px 8px;
}
.settings-group button {
width: 100%;
margin-bottom: 8px;
}
.settings-status {
font-size: 12px;
margin-top: 8px;
}

View File

@@ -0,0 +1,288 @@
import React from 'react';
import { useAppStore, PostData } from '../../store';
import './Sidebar.css';
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const PostsList: React.FC = () => {
const { posts, selectedPostId, setSelectedPost } = useAppStore();
const handleCreatePost = async () => {
try {
const newPost = await window.electronAPI?.posts.create({
title: 'Untitled Post',
content: '# New Post\n\nStart writing your content here...',
});
if (newPost) {
setSelectedPost((newPost as PostData).id);
}
} catch (error) {
console.error('Failed to create post:', error);
}
};
const groupedPosts = {
draft: posts.filter(p => p.status === 'draft'),
published: posts.filter(p => p.status === 'published'),
archived: posts.filter(p => p.status === 'archived'),
};
return (
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>POSTS</span>
<button className="sidebar-action" onClick={handleCreatePost} title="New Post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
</div>
</div>
{groupedPosts.draft.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-draft"></span>
Drafts ({groupedPosts.draft.length})
</div>
<div className="sidebar-list">
{groupedPosts.draft.map(post => (
<div
key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div>
))}
</div>
</div>
)}
{groupedPosts.published.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-published"></span>
Published ({groupedPosts.published.length})
</div>
<div className="sidebar-list">
{groupedPosts.published.map(post => (
<div
key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
</div>
))}
</div>
</div>
)}
{groupedPosts.archived.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-archived"></span>
Archived ({groupedPosts.archived.length})
</div>
<div className="sidebar-list">
{groupedPosts.archived.map(post => (
<div
key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div>
))}
</div>
</div>
)}
{posts.length === 0 && (
<div className="sidebar-empty">
<p>No posts yet</p>
<button onClick={handleCreatePost}>Create your first post</button>
</div>
)}
</div>
);
};
const MediaList: React.FC = () => {
const { media, selectedMediaId, setSelectedMedia } = useAppStore();
const handleImportMedia = async () => {
try {
await window.electronAPI?.media.importDialog();
} catch (error) {
console.error('Failed to import media:', error);
}
};
return (
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>MEDIA</span>
<button className="sidebar-action" onClick={handleImportMedia} title="Import Media">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
</div>
</div>
<div className="sidebar-list media-grid">
{media.map(item => (
<div
key={item.id}
className={`media-item ${selectedMediaId === item.id ? 'selected' : ''}`}
onClick={() => setSelectedMedia(item.id)}
title={item.originalName}
>
{item.mimeType.startsWith('image/') ? (
<div className="media-thumbnail">
{/* Would load actual image in production */}
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" opacity="0.5">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
</div>
) : (
<div className="media-thumbnail">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" opacity="0.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
</svg>
</div>
)}
<div className="media-item-info">
<div className="media-item-name truncate">{item.originalName}</div>
<div className="media-item-size">{formatFileSize(item.size)}</div>
</div>
</div>
))}
</div>
{media.length === 0 && (
<div className="sidebar-empty">
<p>No media files</p>
<button onClick={handleImportMedia}>Import media</button>
</div>
)}
</div>
);
};
const SettingsPanel: React.FC = () => {
const { syncConfigured } = useAppStore();
const [tursoUrl, setTursoUrl] = React.useState('');
const [tursoToken, setTursoToken] = React.useState('');
const handleSaveSync = async () => {
try {
await window.electronAPI?.sync.configure({
tursoUrl,
tursoAuthToken: tursoToken,
autoSync: true,
syncInterval: 5,
});
} catch (error) {
console.error('Failed to configure sync:', error);
}
};
return (
<div className="sidebar-content settings-panel">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>SETTINGS</span>
</div>
</div>
<div className="settings-group">
<h3>Cloud Sync (Turso/LibSQL)</h3>
<div className="settings-field">
<label>Turso Database URL</label>
<input
type="text"
placeholder="libsql://your-db.turso.io"
value={tursoUrl}
onChange={(e) => setTursoUrl(e.target.value)}
/>
</div>
<div className="settings-field">
<label>Auth Token</label>
<input
type="password"
placeholder="Your auth token"
value={tursoToken}
onChange={(e) => setTursoToken(e.target.value)}
/>
</div>
<button onClick={handleSaveSync}>
{syncConfigured ? 'Update Sync Settings' : 'Enable Sync'}
</button>
{syncConfigured && (
<p className="settings-status status-published"> Sync is configured</p>
)}
</div>
<div className="settings-group">
<h3>Data Management</h3>
<button
className="secondary"
onClick={() => window.electronAPI?.posts.rebuildFromFiles()}
>
Rebuild Posts Database
</button>
<button
className="secondary"
onClick={() => window.electronAPI?.media.rebuildFromFiles()}
>
Rebuild Media Database
</button>
<button
className="secondary"
onClick={async () => {
const paths = await window.electronAPI?.app.getDataPaths();
if (paths) {
window.electronAPI?.app.openFolder(paths.posts);
}
}}
>
Open Data Folder
</button>
</div>
</div>
);
};
export const Sidebar: React.FC = () => {
const { activeView, sidebarVisible } = useAppStore();
if (!sidebarVisible) {
return null;
}
return (
<div className="sidebar">
{activeView === 'posts' && <PostsList />}
{activeView === 'media' && <MediaList />}
{activeView === 'settings' && <SettingsPanel />}
</div>
);
};

View File

@@ -0,0 +1 @@
export { Sidebar } from './Sidebar';

View File

@@ -0,0 +1,91 @@
.status-bar {
height: 22px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--vscode-statusBar-background);
color: var(--vscode-statusBar-foreground);
font-size: 12px;
padding: 0 8px;
user-select: none;
}
.status-bar-left,
.status-bar-right {
display: flex;
align-items: center;
gap: 4px;
}
.status-bar-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 100%;
}
.status-bar-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.status-bar-item.warning {
background-color: var(--vscode-notificationsWarningIcon-foreground);
}
.sync-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--vscode-testing-iconPassed);
}
.sync-indicator.syncing {
animation: pulse 1s infinite;
background-color: var(--vscode-notificationsInfoIcon-foreground);
}
.sync-indicator.error {
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.task-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-dot.status-draft {
background-color: var(--vscode-notificationsWarningIcon-foreground);
}
.status-dot.status-published {
background-color: var(--vscode-testing-iconPassed);
}
.status-dot.status-archived {
background-color: var(--vscode-descriptionForeground);
}
.status-bar-item.brand {
font-weight: 600;
letter-spacing: 0.5px;
}

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { useAppStore } from '../../store';
import './StatusBar.css';
export const StatusBar: React.FC = () => {
const {
syncStatus,
syncConfigured,
pendingChanges,
posts,
media,
tasks,
selectedPostId,
} = useAppStore();
const runningTasks = tasks.filter(t => t.status === 'running');
const totalPending = pendingChanges.posts + pendingChanges.media;
const selectedPost = posts.find(p => p.id === selectedPostId);
return (
<div className="status-bar">
<div className="status-bar-left">
{/* Sync Status */}
<div className={`status-bar-item ${!syncConfigured ? 'warning' : ''}`}>
<span className={`sync-indicator ${syncStatus}`} />
{!syncConfigured ? (
<span>Sync not configured</span>
) : syncStatus === 'syncing' ? (
<span>Syncing...</span>
) : totalPending > 0 ? (
<span>{totalPending} pending</span>
) : (
<span>Synced</span>
)}
</div>
{/* Running Tasks */}
{runningTasks.length > 0 && (
<div className="status-bar-item">
<span className="task-spinner" />
<span>{runningTasks[0].message}</span>
{runningTasks.length > 1 && (
<span className="text-muted">+{runningTasks.length - 1} more</span>
)}
</div>
)}
</div>
<div className="status-bar-right">
{/* Current Post Info */}
{selectedPost && (
<div className="status-bar-item">
<span className={`status-dot status-${selectedPost.status}`} />
<span>{selectedPost.status}</span>
</div>
)}
{/* Stats */}
<div className="status-bar-item">
<span>{posts.length} posts</span>
</div>
<div className="status-bar-item">
<span>{media.length} media</span>
</div>
{/* App Name */}
<div className="status-bar-item brand">
<span>bDS</span>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { StatusBar } from './StatusBar';

View File

@@ -0,0 +1,5 @@
export { ActivityBar } from './ActivityBar';
export { Sidebar } from './Sidebar';
export { Editor } from './Editor';
export { StatusBar } from './StatusBar';
export { Panel } from './Panel';

13
src/renderer/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file:;" />
<title>Blogging Desktop Server</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

10
src/renderer/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,160 @@
import { create } from 'zustand';
// Types
export interface PostData {
id: string;
title: string;
slug: string;
excerpt?: string;
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
tags: string[];
categories: string[];
}
export interface MediaData {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: string;
updatedAt: string;
tags: string[];
}
export interface TaskProgress {
taskId: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
message: string;
startTime: string;
endTime?: string;
error?: string;
}
// App State Store
interface AppState {
// UI State
activeView: 'posts' | 'media' | 'settings';
sidebarVisible: boolean;
panelVisible: boolean;
selectedPostId: string | null;
selectedMediaId: string | null;
// Data
posts: PostData[];
media: MediaData[];
tasks: TaskProgress[];
// Sync
syncStatus: 'idle' | 'syncing' | 'error';
syncConfigured: boolean;
pendingChanges: { posts: number; media: number };
// Loading states
isLoading: boolean;
error: string | null;
// Actions
setActiveView: (view: 'posts' | 'media' | 'settings') => void;
toggleSidebar: () => void;
togglePanel: () => void;
setSelectedPost: (id: string | null) => void;
setSelectedMedia: (id: string | null) => void;
setPosts: (posts: PostData[]) => void;
addPost: (post: PostData) => void;
updatePost: (id: string, post: Partial<PostData>) => void;
removePost: (id: string) => void;
setMedia: (media: MediaData[]) => void;
addMedia: (media: MediaData) => void;
updateMedia: (id: string, media: Partial<MediaData>) => void;
removeMedia: (id: string) => void;
setTasks: (tasks: TaskProgress[]) => void;
updateTask: (taskId: string, task: Partial<TaskProgress>) => void;
setSyncStatus: (status: 'idle' | 'syncing' | 'error') => void;
setSyncConfigured: (configured: boolean) => void;
setPendingChanges: (changes: { posts: number; media: number }) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
// Initial UI State
activeView: 'posts',
sidebarVisible: true,
panelVisible: false,
selectedPostId: null,
selectedMediaId: null,
// Initial Data
posts: [],
media: [],
tasks: [],
// Initial Sync State
syncStatus: 'idle',
syncConfigured: false,
pendingChanges: { posts: 0, media: 0 },
// Initial Loading State
isLoading: false,
error: null,
// UI Actions
setActiveView: (view) => set({ activeView: view }),
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
setSelectedPost: (id) => set({ selectedPostId: id }),
setSelectedMedia: (id) => set({ selectedMediaId: id }),
// Post Actions
setPosts: (posts) => set({ posts }),
addPost: (post) => set((state) => ({ posts: [...state.posts, post] })),
updatePost: (id, updatedPost) => set((state) => ({
posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)),
})),
removePost: (id) => set((state) => ({
posts: state.posts.filter((p) => p.id !== id),
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
})),
// Media Actions
setMedia: (media) => set({ media }),
addMedia: (media) => set((state) => ({ media: [...state.media, media] })),
updateMedia: (id, updatedMedia) => set((state) => ({
media: state.media.map((m) => (m.id === id ? { ...m, ...updatedMedia } : m)),
})),
removeMedia: (id) => set((state) => ({
media: state.media.filter((m) => m.id !== id),
selectedMediaId: state.selectedMediaId === id ? null : state.selectedMediaId,
})),
// Task Actions
setTasks: (tasks) => set({ tasks }),
updateTask: (taskId, task) => set((state) => ({
tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)),
})),
// Sync Actions
setSyncStatus: (syncStatus) => set({ syncStatus }),
setSyncConfigured: (syncConfigured) => set({ syncConfigured }),
setPendingChanges: (pendingChanges) => set({ pendingChanges }),
// Loading Actions
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
}));

View File

@@ -0,0 +1 @@
export { useAppStore, type PostData, type MediaData, type TaskProgress } from './appStore';

View File

@@ -0,0 +1,286 @@
/* VS Code-inspired CSS Variables and Global Styles */
:root {
/* Background colors */
--vscode-editor-background: #1e1e1e;
--vscode-sideBar-background: #252526;
--vscode-activityBar-background: #333333;
--vscode-panel-background: #1e1e1e;
--vscode-titleBar-activeBackground: #3c3c3c;
--vscode-statusBar-background: #007acc;
--vscode-tab-activeBackground: #1e1e1e;
--vscode-tab-inactiveBackground: #2d2d2d;
--vscode-list-hoverBackground: #2a2d2e;
--vscode-list-activeSelectionBackground: #094771;
--vscode-list-inactiveSelectionBackground: #37373d;
--vscode-input-background: #3c3c3c;
--vscode-dropdown-background: #3c3c3c;
--vscode-button-background: #0e639c;
--vscode-button-hoverBackground: #1177bb;
--vscode-button-secondaryBackground: #3a3d41;
/* Foreground colors */
--vscode-editor-foreground: #d4d4d4;
--vscode-sideBar-foreground: #cccccc;
--vscode-activityBar-foreground: #ffffff;
--vscode-statusBar-foreground: #ffffff;
--vscode-tab-activeForeground: #ffffff;
--vscode-tab-inactiveForeground: #969696;
--vscode-input-foreground: #cccccc;
--vscode-input-placeholderForeground: #a6a6a6;
--vscode-descriptionForeground: #858585;
--vscode-button-foreground: #ffffff;
/* Border colors */
--vscode-panel-border: #80808059;
--vscode-sideBar-border: #80808059;
--vscode-tab-border: #252526;
--vscode-input-border: #3c3c3c;
--vscode-focusBorder: #007fd4;
/* Status colors */
--vscode-notificationsInfoIcon-foreground: #75beff;
--vscode-notificationsWarningIcon-foreground: #cca700;
--vscode-notificationsErrorIcon-foreground: #f48771;
--vscode-testing-iconPassed: #73c991;
--vscode-testing-iconFailed: #f14c4c;
/* Badge colors */
--vscode-badge-background: #4d4d4d;
--vscode-badge-foreground: #ffffff;
--vscode-activityBarBadge-background: #007acc;
--vscode-activityBarBadge-foreground: #ffffff;
/* Scrollbar */
--vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4);
--vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7);
--vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4);
/* Font settings */
--vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--vscode-editor-font-family: 'Consolas', 'Courier New', monospace;
--vscode-font-size: 13px;
--vscode-editor-font-size: 14px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-editor-foreground);
background-color: var(--vscode-editor-background);
overflow: hidden;
user-select: none;
}
#root {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--vscode-scrollbarSlider-background);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--vscode-scrollbarSlider-hoverBackground);
}
::-webkit-scrollbar-thumb:active {
background: var(--vscode-scrollbarSlider-activeBackground);
}
/* Input styles */
input, textarea, select {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-input-foreground);
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
padding: 4px 8px;
outline: none;
}
input:focus, textarea:focus, select:focus {
border-color: var(--vscode-focusBorder);
}
input::placeholder, textarea::placeholder {
color: var(--vscode-input-placeholderForeground);
}
/* Button styles */
button {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border: none;
padding: 6px 14px;
cursor: pointer;
border-radius: 2px;
}
button:hover {
background-color: var(--vscode-button-hoverBackground);
}
button:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
button.secondary {
background-color: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background-color: #4a4d51;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Icon button */
.icon-button {
background: transparent;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
}
.icon-button:hover {
background-color: var(--vscode-list-hoverBackground);
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
font-size: 11px;
font-weight: 500;
background-color: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
border-radius: 9px;
}
.badge.primary {
background-color: var(--vscode-activityBarBadge-background);
}
/* Status indicators */
.status-draft {
color: var(--vscode-notificationsWarningIcon-foreground);
}
.status-published {
color: var(--vscode-testing-iconPassed);
}
.status-archived {
color: var(--vscode-descriptionForeground);
}
/* Text styles */
.text-muted {
color: var(--vscode-descriptionForeground);
}
.text-small {
font-size: 11px;
}
/* Utility classes */
.flex {
display: flex;
}
.flex-1 {
flex: 1;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-1 {
gap: 4px;
}
.gap-2 {
gap: 8px;
}
.gap-3 {
gap: 12px;
}
.p-1 {
padding: 4px;
}
.p-2 {
padding: 8px;
}
.p-3 {
padding: 12px;
}
.px-2 {
padding-left: 8px;
padding-right: 8px;
}
.py-1 {
padding-top: 4px;
padding-bottom: 4px;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-auto {
overflow: auto;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

109
src/renderer/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,109 @@
// Type definitions for the Electron API exposed via preload
export interface PostData {
id: string;
title: string;
slug: string;
excerpt?: string;
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
tags: string[];
categories: string[];
}
export interface MediaData {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: string;
updatedAt: string;
tags: string[];
}
export interface TaskProgress {
taskId: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
message: string;
startTime: string;
endTime?: string;
error?: string;
}
export interface SyncConfig {
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean;
syncInterval: number;
}
export interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
export interface ElectronAPI {
posts: {
create: (data: Partial<PostData>) => Promise<PostData>;
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<PostData | null>;
getAll: () => Promise<PostData[]>;
getByStatus: (status: string) => Promise<PostData[]>;
publish: (id: string) => Promise<PostData | null>;
unpublish: (id: string) => Promise<PostData | null>;
rebuildFromFiles: () => Promise<void>;
};
media: {
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
importDialog: () => Promise<MediaData[]>;
update: (id: string, data: Partial<MediaData>) => Promise<MediaData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<MediaData | null>;
getAll: () => Promise<MediaData[]>;
rebuildFromFiles: () => Promise<void>;
};
sync: {
configure: (config: SyncConfig) => Promise<void>;
start: (direction?: 'push' | 'pull' | 'bidirectional') => Promise<SyncResult>;
getStatus: () => Promise<'idle' | 'syncing' | 'error'>;
isConfigured: () => Promise<boolean>;
getPendingCount: () => Promise<{ posts: number; media: number }>;
getLog: (limit?: number) => Promise<unknown[]>;
stopAutoSync: () => Promise<void>;
};
tasks: {
getAll: () => Promise<TaskProgress[]>;
getRunning: () => Promise<TaskProgress[]>;
cancel: (taskId: string) => Promise<boolean>;
clearCompleted: () => Promise<void>;
};
app: {
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
openFolder: (folderPath: string) => Promise<string>;
showItemInFolder: (itemPath: string) => Promise<void>;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};

View File

@@ -0,0 +1,419 @@
/**
* MediaEngine Unit Tests
*
* Tests for media file management including:
* - Media import and storage
* - Sidecar metadata file handling
* - MIME type detection
* - Checksum calculation
* - Filename generation
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMockMedia, createMockPdfMedia, resetMockCounters } from '../utils/factories';
// Mock dependencies
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
orderBy: vi.fn(() => Promise.resolve([])),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
getRemote: vi.fn(() => null),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
})),
}));
vi.mock('fs/promises');
describe('MediaEngine', () => {
beforeEach(() => {
vi.clearAllMocks();
resetMockCounters();
});
describe('Media Data Validation', () => {
it('should create valid media with required fields', () => {
const media = createMockMedia();
expect(media.id).toBeDefined();
expect(media.filename).toBeDefined();
expect(media.originalName).toBeDefined();
expect(media.mimeType).toBeDefined();
expect(media.size).toBeGreaterThan(0);
expect(media.createdAt).toBeInstanceOf(Date);
expect(media.updatedAt).toBeInstanceOf(Date);
});
it('should allow optional dimension fields', () => {
const imageMedia = createMockMedia({ width: 1920, height: 1080 });
const pdfMedia = createMockPdfMedia();
expect(imageMedia.width).toBe(1920);
expect(imageMedia.height).toBe(1080);
expect(pdfMedia.width).toBeUndefined();
expect(pdfMedia.height).toBeUndefined();
});
it('should allow optional alt and caption', () => {
const withAlt = createMockMedia({ alt: 'Description', caption: 'A caption' });
const withoutAlt = createMockMedia({ alt: undefined, caption: undefined });
expect(withAlt.alt).toBe('Description');
expect(withAlt.caption).toBe('A caption');
expect(withoutAlt.alt).toBeUndefined();
expect(withoutAlt.caption).toBeUndefined();
});
});
describe('MIME Type Handling', () => {
it('should recognize common image MIME types', () => {
const imageMimeTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
];
imageMimeTypes.forEach(mimeType => {
const media = createMockMedia({ mimeType });
expect(media.mimeType).toBe(mimeType);
expect(media.mimeType.startsWith('image/')).toBe(true);
});
});
it('should recognize document MIME types', () => {
const docMimeTypes = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
docMimeTypes.forEach(mimeType => {
const media = createMockMedia({ mimeType });
expect(media.mimeType).toBe(mimeType);
});
});
it('should identify image types by MIME prefix', () => {
const isImage = (mimeType: string) => mimeType.startsWith('image/');
expect(isImage('image/jpeg')).toBe(true);
expect(isImage('image/png')).toBe(true);
expect(isImage('application/pdf')).toBe(false);
expect(isImage('video/mp4')).toBe(false);
});
});
describe('Filename Generation', () => {
it('should generate unique filenames', () => {
const generateFilename = (originalName: string, id: string): string => {
const ext = originalName.split('.').pop() || '';
return `${id}.${ext}`;
};
const filename1 = generateFilename('photo.jpg', 'media-1');
const filename2 = generateFilename('photo.jpg', 'media-2');
expect(filename1).toBe('media-1.jpg');
expect(filename2).toBe('media-2.jpg');
expect(filename1).not.toBe(filename2);
});
it('should preserve file extension', () => {
const getExtension = (filename: string): string => {
const parts = filename.split('.');
return parts.length > 1 ? parts.pop()!.toLowerCase() : '';
};
expect(getExtension('photo.jpg')).toBe('jpg');
expect(getExtension('document.PDF')).toBe('pdf');
expect(getExtension('archive.tar.gz')).toBe('gz');
expect(getExtension('noextension')).toBe('');
});
it('should sanitize original filename', () => {
const sanitizeFilename = (filename: string): string => {
return filename.replace(/[^a-zA-Z0-9.-]/g, '_');
};
expect(sanitizeFilename('my file (1).jpg')).toBe('my_file__1_.jpg');
expect(sanitizeFilename('résumé.pdf')).toBe('r_sum_.pdf');
expect(sanitizeFilename('normal-file.png')).toBe('normal-file.png');
});
});
describe('Sidecar Metadata', () => {
it('should generate sidecar path from media path', () => {
const getSidecarPath = (mediaPath: string): string => `${mediaPath}.meta`;
expect(getSidecarPath('/media/photo.jpg')).toBe('/media/photo.jpg.meta');
expect(getSidecarPath('/media/doc.pdf')).toBe('/media/doc.pdf.meta');
});
it('should create correct sidecar content structure', () => {
const media = createMockMedia({
id: 'media-123',
originalName: 'vacation-photo.jpg',
mimeType: 'image/jpeg',
size: 1024000,
width: 1920,
height: 1080,
alt: 'Vacation photo',
caption: 'Summer vacation 2024',
tags: ['vacation', 'summer'],
});
const sidecarContent = {
id: media.id,
originalName: media.originalName,
mimeType: media.mimeType,
size: media.size,
width: media.width,
height: media.height,
alt: media.alt,
caption: media.caption,
createdAt: media.createdAt.toISOString(),
updatedAt: media.updatedAt.toISOString(),
tags: media.tags,
};
expect(sidecarContent.id).toBe('media-123');
expect(sidecarContent.width).toBe(1920);
expect(sidecarContent.tags).toEqual(['vacation', 'summer']);
});
it('should handle missing optional fields in sidecar', () => {
const media = createMockPdfMedia({
alt: undefined,
caption: undefined,
});
const sidecarContent = {
id: media.id,
originalName: media.originalName,
mimeType: media.mimeType,
size: media.size,
width: media.width,
height: media.height,
alt: media.alt,
caption: media.caption,
createdAt: media.createdAt.toISOString(),
updatedAt: media.updatedAt.toISOString(),
tags: media.tags,
};
expect(sidecarContent.width).toBeUndefined();
expect(sidecarContent.height).toBeUndefined();
expect(sidecarContent.alt).toBeUndefined();
expect(sidecarContent.caption).toBeUndefined();
});
});
describe('Checksum Calculation', () => {
it('should calculate consistent checksum for same content', () => {
const crypto = require('crypto');
const calculateChecksum = (buffer: Buffer): string => {
return crypto.createHash('md5').update(buffer).digest('hex');
};
const buffer = Buffer.from('test content');
const checksum1 = calculateChecksum(buffer);
const checksum2 = calculateChecksum(buffer);
expect(checksum1).toBe(checksum2);
});
it('should calculate different checksums for different content', () => {
const crypto = require('crypto');
const calculateChecksum = (buffer: Buffer): string => {
return crypto.createHash('md5').update(buffer).digest('hex');
};
const buffer1 = Buffer.from('content A');
const buffer2 = Buffer.from('content B');
expect(calculateChecksum(buffer1)).not.toBe(calculateChecksum(buffer2));
});
});
describe('File Size Formatting', () => {
it('should format bytes correctly', () => {
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
expect(formatFileSize(0)).toBe('0 B');
expect(formatFileSize(500)).toBe('500 B');
expect(formatFileSize(1024)).toBe('1 KB');
expect(formatFileSize(1536)).toBe('1.5 KB');
expect(formatFileSize(1048576)).toBe('1 MB');
expect(formatFileSize(1073741824)).toBe('1 GB');
});
});
describe('Media Tags', () => {
it('should handle empty tags array', () => {
const media = createMockMedia({ tags: [] });
expect(media.tags).toEqual([]);
});
it('should preserve tag order', () => {
const tags = ['first', 'second', 'third'];
const media = createMockMedia({ tags });
expect(media.tags).toEqual(tags);
});
it('should serialize tags to JSON', () => {
const tags = ['photo', 'landscape', '2024'];
const serialized = JSON.stringify(tags);
const deserialized = JSON.parse(serialized);
expect(deserialized).toEqual(tags);
});
});
describe('Image Dimensions', () => {
it('should store width and height for images', () => {
const media = createMockMedia({
mimeType: 'image/jpeg',
width: 3840,
height: 2160,
});
expect(media.width).toBe(3840);
expect(media.height).toBe(2160);
});
it('should calculate aspect ratio', () => {
const getAspectRatio = (width: number, height: number): string => {
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
const divisor = gcd(width, height);
return `${width / divisor}:${height / divisor}`;
};
expect(getAspectRatio(1920, 1080)).toBe('16:9');
expect(getAspectRatio(1024, 768)).toBe('4:3');
expect(getAspectRatio(1080, 1080)).toBe('1:1');
});
it('should not require dimensions for non-images', () => {
const pdf = createMockPdfMedia();
expect(pdf.width).toBeUndefined();
expect(pdf.height).toBeUndefined();
});
});
});
describe('MediaEngine Integration Helpers', () => {
describe('Database Record Conversion', () => {
it('should convert MediaData to database record', () => {
const media = createMockMedia({
tags: ['tag1', 'tag2'],
});
const dbRecord = {
id: media.id,
filename: media.filename,
originalName: media.originalName,
mimeType: media.mimeType,
size: media.size,
width: media.width,
height: media.height,
alt: media.alt,
caption: media.caption,
filePath: `/mock/userData/media/${media.filename}`,
sidecarPath: `/mock/userData/media/${media.filename}.meta`,
createdAt: media.createdAt,
updatedAt: media.updatedAt,
syncStatus: 'pending' as const,
syncedAt: null,
checksum: 'abc123',
tags: JSON.stringify(media.tags),
};
expect(dbRecord.tags).toBe('["tag1","tag2"]');
expect(dbRecord.sidecarPath).toContain('.meta');
});
it('should convert database record to MediaData', () => {
const dbRecord = {
id: 'media-1',
filename: 'photo.jpg',
originalName: 'original.jpg',
mimeType: 'image/jpeg',
size: 102400,
width: 800,
height: 600,
alt: 'A photo',
caption: 'Caption text',
filePath: '/mock/path/photo.jpg',
sidecarPath: '/mock/path/photo.jpg.meta',
createdAt: new Date(),
updatedAt: new Date(),
syncStatus: 'pending' as const,
syncedAt: null,
checksum: 'abc123',
tags: '["tag1","tag2"]',
};
const mediaData = {
id: dbRecord.id,
filename: dbRecord.filename,
originalName: dbRecord.originalName,
mimeType: dbRecord.mimeType,
size: dbRecord.size,
width: dbRecord.width,
height: dbRecord.height,
alt: dbRecord.alt,
caption: dbRecord.caption,
createdAt: dbRecord.createdAt,
updatedAt: dbRecord.updatedAt,
tags: JSON.parse(dbRecord.tags) as string[],
};
expect(mediaData.tags).toEqual(['tag1', 'tag2']);
});
});
describe('File Path Generation', () => {
it('should generate correct media file path', () => {
const mediaDir = '/mock/userData/media';
const filename = 'media-123.jpg';
const filePath = `${mediaDir}/${filename}`;
expect(filePath).toBe('/mock/userData/media/media-123.jpg');
});
it('should generate correct sidecar path', () => {
const filePath = '/mock/userData/media/media-123.jpg';
const sidecarPath = `${filePath}.meta`;
expect(sidecarPath).toBe('/mock/userData/media/media-123.jpg.meta');
});
});
});

View File

@@ -0,0 +1,436 @@
/**
* PostEngine Unit Tests
*
* Tests for blog post management including:
* - Post CRUD operations
* - Slug generation
* - Markdown with YAML frontmatter handling
* - Checksum calculation
* - Event emissions
*/
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { createMockPost, createMockFileSystem, createMockDatabase, resetMockCounters } from '../utils/factories';
// Mock the database module before importing PostEngine
vi.mock('../../src/main/database', () => {
const mockDb = {
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
orderBy: vi.fn(() => Promise.resolve([])),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
getRemote: vi.fn(() => null),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(),
close: vi.fn(),
};
return {
getDatabase: vi.fn(() => mockDb),
};
});
// Mock fs/promises
vi.mock('fs/promises', () => createMockFileSystem());
describe('PostEngine', () => {
beforeEach(() => {
vi.clearAllMocks();
resetMockCounters();
});
describe('Slug Generation', () => {
it('should generate slug from title with lowercase', () => {
// Test the slug generation logic
const generateSlug = (title: string): string => {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
expect(generateSlug('Hello World')).toBe('hello-world');
});
it('should replace special characters with hyphens', () => {
const generateSlug = (title: string): string => {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
expect(generateSlug('Hello, World!')).toBe('hello-world');
expect(generateSlug('Test @ Post #1')).toBe('test-post-1');
});
it('should remove leading and trailing hyphens', () => {
const generateSlug = (title: string): string => {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
expect(generateSlug('---Hello World---')).toBe('hello-world');
expect(generateSlug(' Spaces Around ')).toBe('spaces-around');
});
it('should handle unicode characters', () => {
const generateSlug = (title: string): string => {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
expect(generateSlug('Café & Résumé')).toBe('caf-r-sum');
});
it('should handle empty string', () => {
const generateSlug = (title: string): string => {
if (!title) return 'untitled';
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'untitled';
};
expect(generateSlug('')).toBe('untitled');
});
});
describe('Checksum Calculation', () => {
it('should generate consistent checksums', () => {
const crypto = require('crypto');
const calculateChecksum = (content: string): string => {
return crypto.createHash('md5').update(content).digest('hex');
};
const content = 'Hello, World!';
const checksum1 = calculateChecksum(content);
const checksum2 = calculateChecksum(content);
expect(checksum1).toBe(checksum2);
expect(checksum1).toHaveLength(32); // MD5 hex length
});
it('should generate different checksums for different content', () => {
const crypto = require('crypto');
const calculateChecksum = (content: string): string => {
return crypto.createHash('md5').update(content).digest('hex');
};
const checksum1 = calculateChecksum('Content A');
const checksum2 = calculateChecksum('Content B');
expect(checksum1).not.toBe(checksum2);
});
it('should handle empty content', () => {
const crypto = require('crypto');
const calculateChecksum = (content: string): string => {
return crypto.createHash('md5').update(content).digest('hex');
};
const checksum = calculateChecksum('');
expect(checksum).toHaveLength(32);
});
});
describe('Post Data Validation', () => {
it('should create a valid post with default values', () => {
const createPostData = (data: Partial<ReturnType<typeof createMockPost>>) => {
const now = new Date();
const id = data.id || 'generated-id';
const slug = data.slug || 'untitled';
return {
id,
title: data.title || 'Untitled',
slug,
excerpt: data.excerpt,
content: data.content || '',
status: data.status || 'draft',
author: data.author,
createdAt: data.createdAt || now,
updatedAt: data.updatedAt || now,
publishedAt: data.publishedAt,
tags: data.tags || [],
categories: data.categories || [],
};
};
const post = createPostData({});
expect(post.title).toBe('Untitled');
expect(post.status).toBe('draft');
expect(post.tags).toEqual([]);
expect(post.categories).toEqual([]);
});
it('should preserve provided values', () => {
const post = createMockPost({
title: 'My Custom Title',
status: 'published',
tags: ['custom', 'tag'],
});
expect(post.title).toBe('My Custom Title');
expect(post.status).toBe('published');
expect(post.tags).toEqual(['custom', 'tag']);
});
});
describe('YAML Frontmatter Format', () => {
it('should create valid frontmatter structure', () => {
const post = createMockPost({
title: 'Test Post',
slug: 'test-post',
status: 'draft',
author: 'John Doe',
tags: ['tech', 'tutorial'],
categories: ['programming'],
});
// Simulate frontmatter generation
const frontmatter = {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
publishedAt: post.publishedAt?.toISOString(),
tags: post.tags,
categories: post.categories,
};
expect(frontmatter.title).toBe('Test Post');
expect(frontmatter.tags).toEqual(['tech', 'tutorial']);
expect(frontmatter.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('should handle optional fields correctly', () => {
const post = createMockPost({
excerpt: undefined,
author: undefined,
publishedAt: undefined,
});
const frontmatter = {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
publishedAt: post.publishedAt?.toISOString(),
tags: post.tags,
categories: post.categories,
};
expect(frontmatter.excerpt).toBeUndefined();
expect(frontmatter.author).toBeUndefined();
expect(frontmatter.publishedAt).toBeUndefined();
});
});
describe('Post Status Transitions', () => {
it('should allow valid status values', () => {
const validStatuses = ['draft', 'published', 'archived'] as const;
validStatuses.forEach(status => {
const post = createMockPost({ status });
expect(post.status).toBe(status);
});
});
it('should set publishedAt when publishing', () => {
const now = new Date();
const post = createMockPost({
status: 'published',
publishedAt: now,
});
expect(post.status).toBe('published');
expect(post.publishedAt).toEqual(now);
});
it('should not require publishedAt for drafts', () => {
const post = createMockPost({
status: 'draft',
publishedAt: undefined,
});
expect(post.status).toBe('draft');
expect(post.publishedAt).toBeUndefined();
});
});
describe('File Path Generation', () => {
it('should generate correct file path from slug', () => {
const postsDir = '/mock/userData/posts';
const slug = 'my-first-post';
const filePath = `${postsDir}/${slug}.md`;
expect(filePath).toBe('/mock/userData/posts/my-first-post.md');
});
it('should handle slugs with numbers', () => {
const postsDir = '/mock/userData/posts';
const slug = 'post-123-test';
const filePath = `${postsDir}/${slug}.md`;
expect(filePath).toBe('/mock/userData/posts/post-123-test.md');
});
});
describe('Tags and Categories', () => {
it('should serialize tags to JSON', () => {
const tags = ['javascript', 'typescript', 'node'];
const serialized = JSON.stringify(tags);
expect(serialized).toBe('["javascript","typescript","node"]');
expect(JSON.parse(serialized)).toEqual(tags);
});
it('should handle empty arrays', () => {
const tags: string[] = [];
const serialized = JSON.stringify(tags);
expect(serialized).toBe('[]');
expect(JSON.parse(serialized)).toEqual([]);
});
it('should handle tags with special characters', () => {
const tags = ['c#', 'c++', 'node.js'];
const serialized = JSON.stringify(tags);
expect(JSON.parse(serialized)).toEqual(tags);
});
});
describe('Date Handling', () => {
it('should use ISO format for dates', () => {
const date = new Date('2024-01-15T10:30:00.000Z');
const isoString = date.toISOString();
expect(isoString).toBe('2024-01-15T10:30:00.000Z');
});
it('should parse ISO dates correctly', () => {
const isoString = '2024-01-15T10:30:00.000Z';
const date = new Date(isoString);
expect(date.getUTCFullYear()).toBe(2024);
expect(date.getUTCMonth()).toBe(0); // January
expect(date.getUTCDate()).toBe(15);
});
it('should handle updatedAt being later than createdAt', () => {
const createdAt = new Date('2024-01-15T10:00:00.000Z');
const updatedAt = new Date('2024-01-16T15:30:00.000Z');
const post = createMockPost({ createdAt, updatedAt });
expect(post.updatedAt.getTime()).toBeGreaterThan(post.createdAt.getTime());
});
});
});
describe('PostEngine Integration Helpers', () => {
describe('Database Record Conversion', () => {
it('should convert PostData to database record format', () => {
const post = createMockPost({
tags: ['a', 'b'],
categories: ['c'],
});
const dbRecord = {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
createdAt: post.createdAt,
updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
filePath: `/mock/userData/posts/${post.slug}.md`,
syncStatus: 'pending' as const,
checksum: 'abc123',
tags: JSON.stringify(post.tags),
categories: JSON.stringify(post.categories),
};
expect(dbRecord.tags).toBe('["a","b"]');
expect(dbRecord.categories).toBe('["c"]');
});
it('should convert database record to PostData format', () => {
const dbRecord = {
id: 'post-1',
title: 'Test Post',
slug: 'test-post',
excerpt: 'An excerpt',
status: 'draft' as const,
author: 'Author',
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: null,
filePath: '/mock/path.md',
syncStatus: 'pending' as const,
syncedAt: null,
checksum: 'abc123',
tags: '["a","b"]',
categories: '["c"]',
};
const postData = {
id: dbRecord.id,
title: dbRecord.title,
slug: dbRecord.slug,
excerpt: dbRecord.excerpt,
status: dbRecord.status,
author: dbRecord.author,
createdAt: dbRecord.createdAt,
updatedAt: dbRecord.updatedAt,
publishedAt: dbRecord.publishedAt || undefined,
tags: JSON.parse(dbRecord.tags) as string[],
categories: JSON.parse(dbRecord.categories) as string[],
content: '', // Would be read from file
};
expect(postData.tags).toEqual(['a', 'b']);
expect(postData.categories).toEqual(['c']);
expect(postData.publishedAt).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,568 @@
/**
* SyncEngine Unit Tests
*
* Tests for remote synchronization including:
* - Sync configuration
* - Push/pull operations
* - Conflict detection and resolution
* - Sync status tracking
* - Retry logic
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { resetMockCounters } from '../utils/factories';
// Mock dependencies
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
orderBy: vi.fn(() => Promise.resolve([])),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
getRemote: vi.fn(() => null),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
})),
}));
vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({
getAllPosts: vi.fn(() => Promise.resolve([])),
createPost: vi.fn(),
updatePost: vi.fn(),
deletePost: vi.fn(),
})),
}));
vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({
getAllMedia: vi.fn(() => Promise.resolve([])),
importMedia: vi.fn(),
updateMedia: vi.fn(),
deleteMedia: vi.fn(),
})),
}));
describe('SyncEngine', () => {
beforeEach(() => {
vi.clearAllMocks();
resetMockCounters();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('Sync Configuration', () => {
it('should validate sync config structure', () => {
interface SyncConfig {
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean;
syncInterval: number;
}
const validConfig: SyncConfig = {
tursoUrl: 'libsql://mydb.turso.io',
tursoAuthToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
autoSync: true,
syncInterval: 5,
};
expect(validConfig.tursoUrl).toMatch(/^libsql:\/\//);
expect(validConfig.tursoAuthToken).toBeDefined();
expect(validConfig.autoSync).toBe(true);
expect(validConfig.syncInterval).toBeGreaterThan(0);
});
it('should detect unconfigured state', () => {
const isConfigured = (config: { tursoUrl?: string; tursoAuthToken?: string } | null): boolean => {
return config !== null &&
!!config.tursoUrl &&
!!config.tursoAuthToken;
};
expect(isConfigured(null)).toBe(false);
expect(isConfigured({ tursoUrl: '', tursoAuthToken: '' })).toBe(false);
expect(isConfigured({ tursoUrl: 'url', tursoAuthToken: '' })).toBe(false);
expect(isConfigured({ tursoUrl: 'url', tursoAuthToken: 'token' })).toBe(true);
});
it('should calculate sync interval in milliseconds', () => {
const minutesToMs = (minutes: number): number => minutes * 60 * 1000;
expect(minutesToMs(1)).toBe(60000);
expect(minutesToMs(5)).toBe(300000);
expect(minutesToMs(15)).toBe(900000);
});
});
describe('Sync Direction', () => {
it('should support push direction', () => {
type SyncDirection = 'push' | 'pull' | 'bidirectional';
const direction: SyncDirection = 'push';
expect(['push', 'pull', 'bidirectional']).toContain(direction);
});
it('should support pull direction', () => {
type SyncDirection = 'push' | 'pull' | 'bidirectional';
const direction: SyncDirection = 'pull';
expect(['push', 'pull', 'bidirectional']).toContain(direction);
});
it('should support bidirectional sync', () => {
type SyncDirection = 'push' | 'pull' | 'bidirectional';
const direction: SyncDirection = 'bidirectional';
expect(['push', 'pull', 'bidirectional']).toContain(direction);
});
});
describe('Sync Status', () => {
it('should track sync status states', () => {
type SyncStatus = 'idle' | 'syncing' | 'error';
const validStatuses: SyncStatus[] = ['idle', 'syncing', 'error'];
validStatuses.forEach(status => {
expect(['idle', 'syncing', 'error']).toContain(status);
});
});
it('should prevent concurrent syncs', () => {
let syncStatus: 'idle' | 'syncing' | 'error' = 'idle';
const canSync = (): boolean => syncStatus !== 'syncing';
expect(canSync()).toBe(true);
syncStatus = 'syncing';
expect(canSync()).toBe(false);
syncStatus = 'idle';
expect(canSync()).toBe(true);
});
});
describe('Sync Result', () => {
it('should create sync result structure', () => {
interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
const successResult: SyncResult = {
success: true,
pushed: 5,
pulled: 3,
conflicts: 0,
errors: [],
};
expect(successResult.success).toBe(true);
expect(successResult.pushed + successResult.pulled).toBe(8);
expect(successResult.errors).toHaveLength(0);
});
it('should report errors in result', () => {
interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
const errorResult: SyncResult = {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Network timeout', 'Authentication failed'],
};
expect(errorResult.success).toBe(false);
expect(errorResult.errors).toHaveLength(2);
expect(errorResult.errors).toContain('Network timeout');
});
it('should track conflicts', () => {
interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
const conflictResult: SyncResult = {
success: true, // Partial success
pushed: 4,
pulled: 2,
conflicts: 2,
errors: [],
};
expect(conflictResult.conflicts).toBeGreaterThan(0);
});
});
describe('Entity Sync Status', () => {
it('should track sync status per entity', () => {
type EntitySyncStatus = 'pending' | 'syncing' | 'synced' | 'conflict';
interface SyncableEntity {
id: string;
syncStatus: EntitySyncStatus;
syncedAt: Date | null;
checksum: string;
}
const entity: SyncableEntity = {
id: 'post-1',
syncStatus: 'pending',
syncedAt: null,
checksum: 'abc123',
};
expect(entity.syncStatus).toBe('pending');
expect(entity.syncedAt).toBeNull();
});
it('should update sync status after successful sync', () => {
interface SyncableEntity {
syncStatus: 'pending' | 'syncing' | 'synced' | 'conflict';
syncedAt: Date | null;
}
const entity: SyncableEntity = {
syncStatus: 'pending',
syncedAt: null,
};
// Simulate sync completion
entity.syncStatus = 'synced';
entity.syncedAt = new Date();
expect(entity.syncStatus).toBe('synced');
expect(entity.syncedAt).toBeInstanceOf(Date);
});
});
describe('Conflict Detection', () => {
it('should detect conflict by checksum mismatch', () => {
const detectConflict = (localChecksum: string, remoteChecksum: string): boolean => {
return localChecksum !== remoteChecksum;
};
expect(detectConflict('abc123', 'abc123')).toBe(false);
expect(detectConflict('abc123', 'xyz789')).toBe(true);
});
it('should create conflict info structure', () => {
interface ConflictInfo {
entityId: string;
entityType: 'post' | 'media';
localChecksum: string;
remoteChecksum: string;
localUpdatedAt: Date;
remoteUpdatedAt: Date;
}
const conflict: ConflictInfo = {
entityId: 'post-1',
entityType: 'post',
localChecksum: 'local123',
remoteChecksum: 'remote456',
localUpdatedAt: new Date('2024-01-15T10:00:00Z'),
remoteUpdatedAt: new Date('2024-01-15T11:00:00Z'),
};
expect(conflict.localChecksum).not.toBe(conflict.remoteChecksum);
});
});
describe('Conflict Resolution', () => {
it('should support local-wins strategy', () => {
type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual';
const resolveConflict = (strategy: ConflictResolution): 'local' | 'remote' | 'prompt' => {
switch (strategy) {
case 'local-wins': return 'local';
case 'remote-wins': return 'remote';
case 'manual': return 'prompt';
}
};
expect(resolveConflict('local-wins')).toBe('local');
});
it('should support remote-wins strategy', () => {
type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual';
const resolveConflict = (strategy: ConflictResolution): 'local' | 'remote' | 'prompt' => {
switch (strategy) {
case 'local-wins': return 'local';
case 'remote-wins': return 'remote';
case 'manual': return 'prompt';
}
};
expect(resolveConflict('remote-wins')).toBe('remote');
});
it('should support last-write-wins based on timestamp', () => {
const lastWriteWins = (localTime: Date, remoteTime: Date): 'local' | 'remote' => {
return localTime.getTime() > remoteTime.getTime() ? 'local' : 'remote';
};
const earlier = new Date('2024-01-15T10:00:00Z');
const later = new Date('2024-01-15T11:00:00Z');
expect(lastWriteWins(later, earlier)).toBe('local');
expect(lastWriteWins(earlier, later)).toBe('remote');
});
});
describe('Retry Logic', () => {
it('should calculate exponential backoff delay', () => {
const getBackoffDelay = (attempt: number, baseDelay: number = 1000): number => {
return Math.pow(2, attempt) * baseDelay;
};
expect(getBackoffDelay(1)).toBe(2000); // 2^1 * 1000
expect(getBackoffDelay(2)).toBe(4000); // 2^2 * 1000
expect(getBackoffDelay(3)).toBe(8000); // 2^3 * 1000
expect(getBackoffDelay(4)).toBe(16000); // 2^4 * 1000
});
it('should cap maximum retry delay', () => {
const getBackoffDelay = (attempt: number, baseDelay: number = 1000, maxDelay: number = 30000): number => {
const delay = Math.pow(2, attempt) * baseDelay;
return Math.min(delay, maxDelay);
};
expect(getBackoffDelay(5)).toBe(30000); // Capped at max
expect(getBackoffDelay(10)).toBe(30000); // Still capped
});
it('should track retry count', () => {
interface RetryState {
attempts: number;
maxAttempts: number;
lastError: string | null;
}
const state: RetryState = {
attempts: 0,
maxAttempts: 3,
lastError: null,
};
const shouldRetry = (): boolean => state.attempts < state.maxAttempts;
expect(shouldRetry()).toBe(true);
state.attempts = 3;
expect(shouldRetry()).toBe(false);
});
});
describe('Sync Log', () => {
it('should create sync log entry structure', () => {
interface SyncLogEntry {
id: string;
entityType: 'post' | 'media';
entityId: string;
operation: 'push' | 'pull' | 'conflict';
status: 'pending' | 'success' | 'failed';
timestamp: Date;
errorMessage?: string;
}
const logEntry: SyncLogEntry = {
id: 'log-1',
entityType: 'post',
entityId: 'post-123',
operation: 'push',
status: 'success',
timestamp: new Date(),
};
expect(logEntry.operation).toBe('push');
expect(logEntry.status).toBe('success');
expect(logEntry.errorMessage).toBeUndefined();
});
it('should log errors', () => {
interface SyncLogEntry {
id: string;
entityType: 'post' | 'media';
entityId: string;
operation: 'push' | 'pull' | 'conflict';
status: 'pending' | 'success' | 'failed';
timestamp: Date;
errorMessage?: string;
}
const errorLogEntry: SyncLogEntry = {
id: 'log-2',
entityType: 'post',
entityId: 'post-456',
operation: 'push',
status: 'failed',
timestamp: new Date(),
errorMessage: 'Network timeout after 30s',
};
expect(errorLogEntry.status).toBe('failed');
expect(errorLogEntry.errorMessage).toBeDefined();
});
});
describe('Pending Changes', () => {
it('should count pending changes', () => {
const entities = [
{ id: '1', syncStatus: 'pending' },
{ id: '2', syncStatus: 'synced' },
{ id: '3', syncStatus: 'pending' },
{ id: '4', syncStatus: 'conflict' },
];
const pendingCount = entities.filter(e => e.syncStatus === 'pending').length;
expect(pendingCount).toBe(2);
});
it('should identify entities needing sync', () => {
type SyncStatus = 'pending' | 'syncing' | 'synced' | 'conflict';
const needsSync = (status: SyncStatus): boolean => {
return status === 'pending' || status === 'conflict';
};
expect(needsSync('pending')).toBe(true);
expect(needsSync('conflict')).toBe(true);
expect(needsSync('synced')).toBe(false);
expect(needsSync('syncing')).toBe(false);
});
});
describe('Auto Sync', () => {
it('should start auto sync interval', () => {
const setIntervalSpy = vi.spyOn(global, 'setInterval');
const startAutoSync = (intervalMinutes: number, callback: () => void): NodeJS.Timeout => {
return setInterval(callback, intervalMinutes * 60 * 1000);
};
const callback = vi.fn();
const intervalId = startAutoSync(5, callback);
expect(setIntervalSpy).toHaveBeenCalledWith(callback, 300000);
clearInterval(intervalId);
});
it('should stop auto sync interval', () => {
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
const intervalId = setInterval(() => {}, 1000);
clearInterval(intervalId);
expect(clearIntervalSpy).toHaveBeenCalled();
});
it('should trigger sync on interval', () => {
const syncCallback = vi.fn();
const intervalId = setInterval(syncCallback, 1000);
vi.advanceTimersByTime(3000);
expect(syncCallback).toHaveBeenCalledTimes(3);
clearInterval(intervalId);
});
});
});
describe('SyncEngine Error Handling', () => {
describe('Network Errors', () => {
it('should identify network errors', () => {
const isNetworkError = (error: Error): boolean => {
const networkErrorPatterns = [
'network',
'timeout',
'ECONNREFUSED',
'ENOTFOUND',
'fetch failed',
];
return networkErrorPatterns.some(pattern =>
error.message.toLowerCase().includes(pattern.toLowerCase())
);
};
expect(isNetworkError(new Error('Network timeout'))).toBe(true);
expect(isNetworkError(new Error('ECONNREFUSED'))).toBe(true);
expect(isNetworkError(new Error('Invalid data'))).toBe(false);
});
it('should create user-friendly error messages', () => {
const getUserFriendlyMessage = (error: Error): string => {
if (error.message.includes('timeout')) {
return 'Connection timed out. Please check your internet connection.';
}
if (error.message.includes('ECONNREFUSED')) {
return 'Unable to connect to sync server. Please try again later.';
}
if (error.message.includes('401') || error.message.includes('unauthorized')) {
return 'Authentication failed. Please check your sync credentials.';
}
return 'An unexpected error occurred during sync.';
};
expect(getUserFriendlyMessage(new Error('timeout'))).toContain('timed out');
expect(getUserFriendlyMessage(new Error('401 unauthorized'))).toContain('Authentication');
});
});
describe('Authentication Errors', () => {
it('should detect auth errors', () => {
const isAuthError = (error: Error | { status?: number }): boolean => {
if ('status' in error) {
return error.status === 401 || error.status === 403;
}
const message = (error as Error).message.toLowerCase();
return message.includes('unauthorized') ||
message.includes('forbidden') ||
message.includes('auth');
};
expect(isAuthError({ status: 401 })).toBe(true);
expect(isAuthError({ status: 403 })).toBe(true);
expect(isAuthError({ status: 500 })).toBe(false);
expect(isAuthError(new Error('Unauthorized'))).toBe(true);
});
});
});

View File

@@ -0,0 +1,371 @@
/**
* TaskManager Unit Tests
*
* Tests for the async task management system including:
* - Task execution and progress tracking
* - Task queuing and concurrency limits
* - Task cancellation
* - Event emissions
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TaskManager, Task, TaskProgress, TaskStatus } from '../../src/main/engine/TaskManager';
import {
createMockTask,
createMockSlowTask,
createMockFailingTask,
resetMockCounters
} from '../utils/factories';
describe('TaskManager', () => {
let taskManager: TaskManager;
beforeEach(() => {
taskManager = new TaskManager();
resetMockCounters();
});
describe('Task Execution', () => {
it('should execute a task successfully', async () => {
const task = createMockTask<string>(async (onProgress) => {
onProgress(50, 'Halfway');
onProgress(100, 'Done');
return 'result';
});
const result = await taskManager.runTask(task);
expect(result).toBe('result');
});
it('should track task progress', async () => {
const progressUpdates: { progress: number; message: string }[] = [];
taskManager.on('taskProgress', (taskProgress: TaskProgress) => {
progressUpdates.push({
progress: taskProgress.progress,
message: taskProgress.message,
});
});
const task = createMockTask(async (onProgress) => {
onProgress(25, 'Step 1');
onProgress(50, 'Step 2');
onProgress(75, 'Step 3');
onProgress(100, 'Complete');
});
await taskManager.runTask(task);
expect(progressUpdates.length).toBe(4);
expect(progressUpdates[0]).toEqual({ progress: 25, message: 'Step 1' });
expect(progressUpdates[3]).toEqual({ progress: 100, message: 'Complete' });
});
it('should emit taskCreated event when task starts', async () => {
const createdHandler = vi.fn();
taskManager.on('taskCreated', createdHandler);
const task = createMockTask();
await taskManager.runTask(task);
// The handler is called at some point during task lifecycle
expect(createdHandler).toHaveBeenCalled();
expect(createdHandler.mock.calls[0][0].taskId).toBe(task.id);
});
it('should emit taskStarted event when task begins execution', async () => {
const startedHandler = vi.fn();
taskManager.on('taskStarted', startedHandler);
const task = createMockTask();
await taskManager.runTask(task);
// The handler is called at some point during task lifecycle
expect(startedHandler).toHaveBeenCalled();
expect(startedHandler.mock.calls[0][0].taskId).toBe(task.id);
});
it('should emit taskCompleted event when task finishes', async () => {
const completedHandler = vi.fn();
taskManager.on('taskCompleted', completedHandler);
const task = createMockTask();
await taskManager.runTask(task);
expect(completedHandler).toHaveBeenCalledTimes(1);
expect(completedHandler).toHaveBeenCalledWith(
expect.objectContaining({
taskId: task.id,
status: 'completed',
progress: 100,
})
);
});
it('should set endTime when task completes', async () => {
const task = createMockTask();
await taskManager.runTask(task);
const status = taskManager.getTaskStatus(task.id);
expect(status?.endTime).toBeInstanceOf(Date);
});
});
describe('Task Failure', () => {
it('should handle task failure gracefully', async () => {
const task = createMockFailingTask('Test error');
await expect(taskManager.runTask(task)).rejects.toThrow('Test error');
});
it('should emit taskFailed event on error', async () => {
const failedHandler = vi.fn();
taskManager.on('taskFailed', failedHandler);
const task = createMockFailingTask('Something went wrong');
try {
await taskManager.runTask(task);
} catch {
// Expected
}
expect(failedHandler).toHaveBeenCalledTimes(1);
expect(failedHandler).toHaveBeenCalledWith(
expect.objectContaining({
taskId: task.id,
status: 'failed',
error: 'Something went wrong',
})
);
});
it('should set task status to failed on error', async () => {
const task = createMockFailingTask();
try {
await taskManager.runTask(task);
} catch {
// Expected
}
const status = taskManager.getTaskStatus(task.id);
expect(status?.status).toBe('failed');
});
});
describe('Task Status Queries', () => {
it('should return task status by id', async () => {
const task = createMockTask();
// Before running
expect(taskManager.getTaskStatus(task.id)).toBeUndefined();
await taskManager.runTask(task);
// After running
const status = taskManager.getTaskStatus(task.id);
expect(status).toBeDefined();
expect(status?.taskId).toBe(task.id);
});
it('should return all tasks', async () => {
const task1 = createMockTask();
const task2 = createMockTask();
await Promise.all([
taskManager.runTask(task1),
taskManager.runTask(task2),
]);
const allTasks = taskManager.getAllTasks();
expect(allTasks.length).toBe(2);
});
it('should return only running tasks', async () => {
// Create a slow task that will still be running
let resolveTask: () => void;
const slowTask = createMockTask(async (onProgress) => {
onProgress(10, 'Working...');
await new Promise<void>(resolve => {
resolveTask = resolve;
});
});
const fastTask = createMockTask();
// Start slow task (don't await)
const slowPromise = taskManager.runTask(slowTask);
// Run fast task
await taskManager.runTask(fastTask);
const runningTasks = taskManager.getRunningTasks();
expect(runningTasks.length).toBe(1);
expect(runningTasks[0].taskId).toBe(slowTask.id);
// Cleanup
resolveTask!();
await slowPromise;
});
});
describe('Task Cancellation', () => {
it('should cancel a running task that checks for abort', async () => {
// Task that polls for cancellation via onProgress check
const task = createMockTask(async (onProgress) => {
for (let i = 0; i < 20; i++) {
// onProgress will throw if task was cancelled
onProgress(i * 5, `Step ${i}`);
await new Promise(resolve => setTimeout(resolve, 5));
}
});
const taskPromise = taskManager.runTask(task);
// Give task time to start and make some progress
await new Promise(resolve => setTimeout(resolve, 20));
const cancelled = taskManager.cancelTask(task.id);
expect(cancelled).toBe(true);
// The task should throw 'Task cancelled' error
await expect(taskPromise).rejects.toThrow('Task cancelled');
});
it('should return false when cancelling non-existent task', () => {
const cancelled = taskManager.cancelTask('non-existent-id');
expect(cancelled).toBe(false);
});
it('should set task status to cancelled after cancel', async () => {
// Task that polls for cancellation
const task = createMockTask(async (onProgress) => {
for (let i = 0; i < 20; i++) {
onProgress(i * 5, `Step ${i}`);
await new Promise(resolve => setTimeout(resolve, 5));
}
});
const taskPromise = taskManager.runTask(task);
await new Promise(resolve => setTimeout(resolve, 20));
taskManager.cancelTask(task.id);
try {
await taskPromise;
} catch {
// Expected
}
const status = taskManager.getTaskStatus(task.id);
expect(status?.status).toBe('cancelled');
});
});
describe('Clear Completed Tasks', () => {
it('should remove completed tasks', async () => {
const task = createMockTask();
await taskManager.runTask(task);
expect(taskManager.getAllTasks().length).toBe(1);
taskManager.clearCompletedTasks();
expect(taskManager.getAllTasks().length).toBe(0);
});
it('should remove failed tasks', async () => {
const task = createMockFailingTask();
try {
await taskManager.runTask(task);
} catch {
// Expected
}
expect(taskManager.getAllTasks().length).toBe(1);
taskManager.clearCompletedTasks();
expect(taskManager.getAllTasks().length).toBe(0);
});
it('should emit tasksCleared event', async () => {
const clearedHandler = vi.fn();
taskManager.on('tasksCleared', clearedHandler);
const task = createMockTask();
await taskManager.runTask(task);
taskManager.clearCompletedTasks();
expect(clearedHandler).toHaveBeenCalledTimes(1);
});
});
describe('Task Return Values', () => {
it('should return typed results from tasks', async () => {
interface TaskResult {
count: number;
items: string[];
}
const task = createMockTask<TaskResult>(async () => ({
count: 3,
items: ['a', 'b', 'c'],
}));
const result = await taskManager.runTask(task);
expect(result.count).toBe(3);
expect(result.items).toEqual(['a', 'b', 'c']);
});
it('should handle void tasks', async () => {
const task = createMockTask<void>(async () => {
// No return
});
const result = await taskManager.runTask(task);
expect(result).toBeUndefined();
});
});
});
describe('TaskManager Concurrency', () => {
let taskManager: TaskManager;
const MAX_CONCURRENT = 3;
beforeEach(() => {
taskManager = new TaskManager();
resetMockCounters();
});
it('should run tasks up to concurrency limit', async () => {
// Create fast tasks that complete quickly
const tasks = Array.from({ length: 5 }, () =>
createMockTask(async (onProgress) => {
onProgress(50, 'Working');
await new Promise(resolve => setTimeout(resolve, 10));
})
);
// Start all tasks
const promises = tasks.map(t => taskManager.runTask(t));
// Since max concurrent is 3, we should never exceed that
// The first 3 will start immediately, others will queue
const runningCount = taskManager.getRunningTasks().length;
expect(runningCount).toBeLessThanOrEqual(MAX_CONCURRENT);
// Wait for all to complete
await Promise.all(promises);
// All should be completed now
expect(taskManager.getAllTasks().every(t => t.status === 'completed')).toBe(true);
});
});

70
tests/setup.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Test setup file for Vitest
* Configures mocks and global test utilities
*/
import { vi, beforeEach, afterEach } from 'vitest';
// Mock Electron app module
vi.mock('electron', () => ({
app: {
getPath: vi.fn((name: string) => {
const paths: Record<string, string> = {
userData: '/mock/userData',
appData: '/mock/appData',
temp: '/mock/temp',
};
return paths[name] || '/mock/unknown';
}),
isPackaged: false,
quit: vi.fn(),
on: vi.fn(),
},
ipcMain: {
handle: vi.fn(),
on: vi.fn(),
removeHandler: vi.fn(),
},
ipcRenderer: {
invoke: vi.fn(),
on: vi.fn(),
send: vi.fn(),
},
BrowserWindow: vi.fn().mockImplementation(() => ({
loadURL: vi.fn(),
loadFile: vi.fn(),
on: vi.fn(),
webContents: {
send: vi.fn(),
openDevTools: vi.fn(),
},
isDestroyed: vi.fn(() => false),
})),
Menu: {
buildFromTemplate: vi.fn(),
setApplicationMenu: vi.fn(),
},
}));
// Reset mocks between tests
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// Global test utilities
declare global {
// Add any global test utilities here
var testUtils: {
wait: (ms: number) => Promise<void>;
createMockDate: (date: string) => Date;
};
}
globalThis.testUtils = {
wait: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)),
createMockDate: (date: string) => new Date(date),
};

313
tests/utils/factories.ts Normal file
View File

@@ -0,0 +1,313 @@
/**
* Test utilities and mock factories
* Following TDD best practices with reusable test data generators
*/
import { vi } from 'vitest';
import type { PostData } from '../src/main/engine/PostEngine';
import type { MediaData } from '../src/main/engine/MediaEngine';
import type { Task, TaskProgress } from '../src/main/engine/TaskManager';
// ============================================
// Post Mock Factory
// ============================================
let postIdCounter = 1;
export function createMockPost(overrides?: Partial<PostData>): PostData {
const id = `post-${postIdCounter++}`;
const now = new Date();
return {
id,
title: `Test Post ${id}`,
slug: `test-post-${id}`,
excerpt: 'This is a test excerpt',
content: '# Test Content\n\nThis is test content.',
status: 'draft',
author: 'Test Author',
createdAt: now,
updatedAt: now,
publishedAt: undefined,
tags: ['test', 'mock'],
categories: ['testing'],
...overrides,
};
}
export function createMockPublishedPost(overrides?: Partial<PostData>): PostData {
const now = new Date();
return createMockPost({
status: 'published',
publishedAt: now,
...overrides,
});
}
// ============================================
// Media Mock Factory
// ============================================
let mediaIdCounter = 1;
export function createMockMedia(overrides?: Partial<MediaData>): MediaData {
const id = `media-${mediaIdCounter++}`;
const now = new Date();
return {
id,
filename: `${id}.jpg`,
originalName: `original-${id}.jpg`,
mimeType: 'image/jpeg',
size: 1024 * 100, // 100KB
width: 800,
height: 600,
alt: 'Test image',
caption: 'A test image caption',
createdAt: now,
updatedAt: now,
tags: ['test', 'image'],
...overrides,
};
}
export function createMockPdfMedia(overrides?: Partial<MediaData>): MediaData {
return createMockMedia({
filename: 'document.pdf',
originalName: 'document.pdf',
mimeType: 'application/pdf',
width: undefined,
height: undefined,
...overrides,
});
}
// ============================================
// Task Mock Factory
// ============================================
let taskIdCounter = 1;
export function createMockTask<T = void>(
executor?: (onProgress: (progress: number, message: string) => void) => Promise<T>,
overrides?: Partial<Task<T>>
): Task<T> {
const id = `task-${taskIdCounter++}`;
return {
id,
name: `Test Task ${id}`,
execute: executor || (async (onProgress) => {
onProgress(50, 'Processing...');
onProgress(100, 'Done');
return undefined as T;
}),
...overrides,
};
}
export function createMockSlowTask(durationMs: number): Task<void> {
return createMockTask(async (onProgress) => {
const steps = 10;
const stepDuration = durationMs / steps;
for (let i = 1; i <= steps; i++) {
await new Promise(resolve => setTimeout(resolve, stepDuration));
onProgress(i * 10, `Step ${i}/${steps}`);
}
});
}
export function createMockFailingTask(errorMessage: string = 'Task failed'): Task<void> {
return createMockTask(async () => {
throw new Error(errorMessage);
});
}
export function createMockTaskProgress(overrides?: Partial<TaskProgress>): TaskProgress {
return {
taskId: `task-${taskIdCounter++}`,
status: 'pending',
progress: 0,
message: 'Waiting...',
startTime: new Date(),
...overrides,
};
}
// ============================================
// Database Mock Utilities
// ============================================
export function createMockDatabase() {
const data = {
posts: new Map<string, PostData>(),
media: new Map<string, MediaData>(),
};
return {
data,
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => Promise.resolve([])),
orderBy: vi.fn(() => Promise.resolve([])),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
getRemote: vi.fn(() => null),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(),
close: vi.fn(),
};
}
// ============================================
// File System Mock Utilities
// ============================================
export function createMockFileSystem() {
const files = new Map<string, string | Buffer>();
const directories = new Set<string>(['/mock', '/mock/userData', '/mock/userData/posts', '/mock/userData/media']);
return {
files,
directories,
readFile: vi.fn(async (path: string) => {
const content = files.get(path);
if (content === undefined) {
const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
return content;
}),
writeFile: vi.fn(async (path: string, content: string | Buffer) => {
files.set(path, content);
}),
unlink: vi.fn(async (path: string) => {
if (!files.has(path)) {
const error = new Error(`ENOENT: no such file or directory, unlink '${path}'`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
files.delete(path);
}),
mkdir: vi.fn(async (path: string) => {
directories.add(path);
}),
readdir: vi.fn(async (path: string) => {
const entries: string[] = [];
for (const filePath of files.keys()) {
if (filePath.startsWith(path + '/')) {
const relativePath = filePath.slice(path.length + 1);
const firstSegment = relativePath.split('/')[0];
if (!entries.includes(firstSegment)) {
entries.push(firstSegment);
}
}
}
return entries;
}),
stat: vi.fn(async (path: string) => {
if (files.has(path)) {
const content = files.get(path)!;
return {
isFile: () => true,
isDirectory: () => false,
size: typeof content === 'string' ? content.length : content.length,
};
}
if (directories.has(path)) {
return {
isFile: () => false,
isDirectory: () => true,
size: 0,
};
}
const error = new Error(`ENOENT: no such file or directory, stat '${path}'`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}),
copyFile: vi.fn(async (src: string, dest: string) => {
const content = files.get(src);
if (content === undefined) {
const error = new Error(`ENOENT: no such file or directory, copyfile '${src}'`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
files.set(dest, content);
}),
access: vi.fn(async (path: string) => {
if (!files.has(path) && !directories.has(path)) {
const error = new Error(`ENOENT: no such file or directory, access '${path}'`);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
}),
};
}
// ============================================
// Reset Utilities
// ============================================
export function resetMockCounters(): void {
postIdCounter = 1;
mediaIdCounter = 1;
taskIdCounter = 1;
}
// ============================================
// Assertion Helpers
// ============================================
export function expectPostToMatchPartial(
actual: PostData,
expected: Partial<PostData>
): void {
for (const [key, value] of Object.entries(expected)) {
if (value instanceof Date) {
expect((actual as Record<string, unknown>)[key]).toBeInstanceOf(Date);
} else {
expect((actual as Record<string, unknown>)[key]).toEqual(value);
}
}
}
export function expectMediaToMatchPartial(
actual: MediaData,
expected: Partial<MediaData>
): void {
for (const [key, value] of Object.entries(expected)) {
if (value instanceof Date) {
expect((actual as Record<string, unknown>)[key]).toBeInstanceOf(Date);
} else {
expect((actual as Record<string, unknown>)[key]).toEqual(value);
}
}
}

6
tests/utils/index.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* Test utilities index
* Re-exports all test utilities for convenient importing
*/
export * from './factories';

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/renderer"],
"references": [{ "path": "./tsconfig.node.json" }]
}

20
tsconfig.main.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist/main",
"rootDir": "./src/main",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node"
},
"include": ["src/main/**/*"],
"exclude": ["node_modules"]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

24
vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
root: 'src/renderer',
base: './',
build: {
outDir: '../../dist/renderer',
emptyOutDir: true,
rollupOptions: {
input: resolve(__dirname, 'src/renderer/index.html'),
},
},
resolve: {
alias: {
'@': resolve(__dirname, 'src/renderer'),
},
},
server: {
port: 5173,
},
});

29
vitest.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/main/**/*.ts'],
exclude: [
'src/main/main.ts',
'src/main/preload.ts',
'src/**/*.test.ts',
],
},
setupFiles: ['./tests/setup.ts'],
testTimeout: 10000,
},
resolve: {
alias: {
'@main': path.resolve(__dirname, 'src/main'),
'@renderer': path.resolve(__dirname, 'src/renderer'),
},
},
});