initial commit
This commit is contained in:
440
.github/copilot-cli-instructions.md
vendored
Normal file
440
.github/copilot-cli-instructions.md
vendored
Normal 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
665
.github/copilot-instructions.md
vendored
Normal 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
134
.gitignore
vendored
Normal 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
114
PLAN.md
Normal 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
135
README.md
Normal 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
4
assets/icon.svg
Normal 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
10
drizzle.config.ts
Normal 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
10604
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
package.json
Normal file
73
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/main/database/connection.ts
Normal file
200
src/main/database/connection.ts
Normal 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;
|
||||||
|
}
|
||||||
2
src/main/database/index.ts
Normal file
2
src/main/database/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './schema';
|
||||||
|
export * from './connection';
|
||||||
70
src/main/database/schema.ts
Normal file
70
src/main/database/schema.ts
Normal 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;
|
||||||
442
src/main/engine/MediaEngine.ts
Normal file
442
src/main/engine/MediaEngine.ts
Normal 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;
|
||||||
|
}
|
||||||
386
src/main/engine/PostEngine.ts
Normal file
386
src/main/engine/PostEngine.ts
Normal 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;
|
||||||
|
}
|
||||||
324
src/main/engine/SyncEngine.ts
Normal file
324
src/main/engine/SyncEngine.ts
Normal 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;
|
||||||
|
}
|
||||||
166
src/main/engine/TaskManager.ts
Normal file
166
src/main/engine/TaskManager.ts
Normal 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
4
src/main/engine/index.ts
Normal 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
219
src/main/ipc/handlers.ts
Normal 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
1
src/main/ipc/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { registerIpcHandlers } from './handlers';
|
||||||
335
src/main/main.ts
Normal file
335
src/main/main.ts
Normal 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
118
src/main/preload.ts
Normal 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
20
src/renderer/App.css
Normal 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
263
src/renderer/App.tsx
Normal 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;
|
||||||
82
src/renderer/components/ActivityBar/ActivityBar.css
Normal file
82
src/renderer/components/ActivityBar/ActivityBar.css
Normal 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;
|
||||||
|
}
|
||||||
76
src/renderer/components/ActivityBar/ActivityBar.tsx
Normal file
76
src/renderer/components/ActivityBar/ActivityBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/renderer/components/ActivityBar/index.ts
Normal file
1
src/renderer/components/ActivityBar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ActivityBar } from './ActivityBar';
|
||||||
298
src/renderer/components/Editor/Editor.css
Normal file
298
src/renderer/components/Editor/Editor.css
Normal 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);
|
||||||
|
}
|
||||||
405
src/renderer/components/Editor/Editor.tsx
Normal file
405
src/renderer/components/Editor/Editor.tsx
Normal 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 />;
|
||||||
|
};
|
||||||
1
src/renderer/components/Editor/index.ts
Normal file
1
src/renderer/components/Editor/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Editor } from './Editor';
|
||||||
155
src/renderer/components/Panel/Panel.css
Normal file
155
src/renderer/components/Panel/Panel.css
Normal 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); }
|
||||||
|
}
|
||||||
69
src/renderer/components/Panel/Panel.tsx
Normal file
69
src/renderer/components/Panel/Panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/renderer/components/Panel/index.ts
Normal file
1
src/renderer/components/Panel/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Panel } from './Panel';
|
||||||
203
src/renderer/components/Sidebar/Sidebar.css
Normal file
203
src/renderer/components/Sidebar/Sidebar.css
Normal 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;
|
||||||
|
}
|
||||||
288
src/renderer/components/Sidebar/Sidebar.tsx
Normal file
288
src/renderer/components/Sidebar/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/renderer/components/Sidebar/index.ts
Normal file
1
src/renderer/components/Sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Sidebar } from './Sidebar';
|
||||||
91
src/renderer/components/StatusBar/StatusBar.css
Normal file
91
src/renderer/components/StatusBar/StatusBar.css
Normal 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;
|
||||||
|
}
|
||||||
73
src/renderer/components/StatusBar/StatusBar.tsx
Normal file
73
src/renderer/components/StatusBar/StatusBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/renderer/components/StatusBar/index.ts
Normal file
1
src/renderer/components/StatusBar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { StatusBar } from './StatusBar';
|
||||||
5
src/renderer/components/index.ts
Normal file
5
src/renderer/components/index.ts
Normal 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
13
src/renderer/index.html
Normal 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
10
src/renderer/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
160
src/renderer/store/appStore.ts
Normal file
160
src/renderer/store/appStore.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
1
src/renderer/store/index.ts
Normal file
1
src/renderer/store/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useAppStore, type PostData, type MediaData, type TaskProgress } from './appStore';
|
||||||
286
src/renderer/styles/global.css
Normal file
286
src/renderer/styles/global.css
Normal 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
109
src/renderer/types/electron.d.ts
vendored
Normal 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 {};
|
||||||
419
tests/engine/MediaEngine.test.ts
Normal file
419
tests/engine/MediaEngine.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
436
tests/engine/PostEngine.test.ts
Normal file
436
tests/engine/PostEngine.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
568
tests/engine/SyncEngine.test.ts
Normal file
568
tests/engine/SyncEngine.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
371
tests/engine/TaskManager.test.ts
Normal file
371
tests/engine/TaskManager.test.ts
Normal 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
70
tests/setup.ts
Normal 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
313
tests/utils/factories.ts
Normal 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
6
tests/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Test utilities index
|
||||||
|
* Re-exports all test utilities for convenient importing
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './factories';
|
||||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal 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
20
tsconfig.main.json
Normal 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
10
tsconfig.node.json
Normal 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
24
vite.config.ts
Normal 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
29
vitest.config.ts
Normal 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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user