feat: reworked project location
This commit is contained in:
@@ -33,6 +33,7 @@ export const DEFAULT_CATEGORIES = ['article', 'picture', 'aside', 'page'];
|
|||||||
*/
|
*/
|
||||||
export class MetaEngine extends EventEmitter {
|
export class MetaEngine extends EventEmitter {
|
||||||
private currentProjectId: string = 'default';
|
private currentProjectId: string = 'default';
|
||||||
|
private dataDir: string | null = null; // Custom data directory (null = use internal userData)
|
||||||
private tags: Set<string> = new Set();
|
private tags: Set<string> = new Set();
|
||||||
private categories: Set<string> = new Set();
|
private categories: Set<string> = new Set();
|
||||||
private projectMetadata: ProjectMetadata | null = null;
|
private projectMetadata: ProjectMetadata | null = null;
|
||||||
@@ -43,24 +44,27 @@ export class MetaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always returns the internal project directory (in userData).
|
* Returns the default internal project directory (in userData).
|
||||||
* Meta files never live in an external dataPath.
|
|
||||||
*/
|
*/
|
||||||
private getInternalBaseDir(): string {
|
private getDefaultBaseDir(): string {
|
||||||
const userDataPath = app.getPath('userData');
|
const userDataPath = app.getPath('userData');
|
||||||
return path.join(userDataPath, 'projects', this.currentProjectId);
|
return path.join(userDataPath, 'projects', this.currentProjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the meta directory path for the current project.
|
* Returns the base directory for project data.
|
||||||
* Always in the internal directory (userData), never external.
|
* If a custom dataDir is set, uses that; otherwise uses internal userData.
|
||||||
*/
|
*/
|
||||||
getMetaDir(): string {
|
private getBaseDir(): string {
|
||||||
return path.join(this.getInternalBaseDir(), 'meta');
|
return this.dataDir || this.getDefaultBaseDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTagsFilePath(): string {
|
/**
|
||||||
return path.join(this.getMetaDir(), 'tags.json');
|
* Get the meta directory path for the current project.
|
||||||
|
* Uses custom dataDir if set, otherwise internal userData.
|
||||||
|
*/
|
||||||
|
getMetaDir(): string {
|
||||||
|
return path.join(this.getBaseDir(), 'meta');
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCategoriesFilePath(): string {
|
private getCategoriesFilePath(): string {
|
||||||
@@ -71,8 +75,9 @@ export class MetaEngine extends EventEmitter {
|
|||||||
return path.join(this.getMetaDir(), 'project.json');
|
return path.join(this.getMetaDir(), 'project.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
setProjectContext(projectId: string): void {
|
setProjectContext(projectId: string, dataDir?: string): void {
|
||||||
this.currentProjectId = projectId;
|
this.currentProjectId = projectId;
|
||||||
|
this.dataDir = dataDir || null;
|
||||||
// Reset in-memory cache when project changes
|
// Reset in-memory cache when project changes
|
||||||
this.tags.clear();
|
this.tags.clear();
|
||||||
this.categories.clear();
|
this.categories.clear();
|
||||||
@@ -134,25 +139,25 @@ export class MetaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new tag to the available tags list.
|
* Add a new tag to the available tags list (in-memory only).
|
||||||
|
* Note: Tag persistence is handled by TagEngine.
|
||||||
*/
|
*/
|
||||||
async addTag(tag: string): Promise<void> {
|
async addTag(tag: string): Promise<void> {
|
||||||
const normalizedTag = tag.trim().toLowerCase();
|
const normalizedTag = tag.trim().toLowerCase();
|
||||||
if (normalizedTag && !this.tags.has(normalizedTag)) {
|
if (normalizedTag && !this.tags.has(normalizedTag)) {
|
||||||
this.tags.add(normalizedTag);
|
this.tags.add(normalizedTag);
|
||||||
this.emit('tagsChanged', await this.getTags());
|
this.emit('tagsChanged', await this.getTags());
|
||||||
await this.saveTags();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a tag from the available tags list.
|
* Remove a tag from the available tags list (in-memory only).
|
||||||
|
* Note: Tag persistence is handled by TagEngine.
|
||||||
*/
|
*/
|
||||||
async removeTag(tag: string): Promise<void> {
|
async removeTag(tag: string): Promise<void> {
|
||||||
const normalizedTag = tag.trim().toLowerCase();
|
const normalizedTag = tag.trim().toLowerCase();
|
||||||
if (this.tags.delete(normalizedTag)) {
|
if (this.tags.delete(normalizedTag)) {
|
||||||
this.emit('tagsChanged', await this.getTags());
|
this.emit('tagsChanged', await this.getTags());
|
||||||
await this.saveTags();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,21 +184,6 @@ export class MetaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Save tags to the filesystem.
|
|
||||||
*/
|
|
||||||
async saveTags(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.ensureMetaDirExists();
|
|
||||||
const filePath = this.getTagsFilePath();
|
|
||||||
const content = JSON.stringify(Array.from(this.tags).sort(), null, 2);
|
|
||||||
await fs.writeFile(filePath, content, 'utf-8');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MetaEngine] Failed to save tags:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save categories to the filesystem.
|
* Save categories to the filesystem.
|
||||||
*/
|
*/
|
||||||
@@ -243,27 +233,6 @@ export class MetaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load tags from the filesystem.
|
|
||||||
*/
|
|
||||||
async loadTags(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const filePath = this.getTagsFilePath();
|
|
||||||
const content = await fs.readFile(filePath, 'utf-8');
|
|
||||||
const parsed = JSON.parse(content) as string[];
|
|
||||||
this.tags.clear();
|
|
||||||
for (const tag of parsed) {
|
|
||||||
this.tags.add(tag.trim().toLowerCase());
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
console.error('[MetaEngine] Failed to load tags:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
// File doesn't exist, that's OK
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load categories from the filesystem.
|
* Load categories from the filesystem.
|
||||||
*/
|
*/
|
||||||
@@ -383,19 +352,18 @@ export class MetaEngine extends EventEmitter {
|
|||||||
* Sync tags and categories on startup.
|
* Sync tags and categories on startup.
|
||||||
*
|
*
|
||||||
* Logic:
|
* Logic:
|
||||||
* 1. If files don't exist: export from database (posts) to files
|
* - Tags: populated from posts (TagEngine handles persistence with colors)
|
||||||
* 2. If files exist: read from files, merge with database, save any changes
|
* - Categories: read from file, merge with database
|
||||||
|
* - Project metadata: read from file or create from database
|
||||||
*/
|
*/
|
||||||
async syncOnStartup(): Promise<void> {
|
async syncOnStartup(): Promise<void> {
|
||||||
console.log(`[MetaEngine] Syncing metadata for project: ${this.currentProjectId}`);
|
console.log(`[MetaEngine] Syncing metadata for project: ${this.currentProjectId}`);
|
||||||
|
|
||||||
await this.ensureMetaDirExists();
|
await this.ensureMetaDirExists();
|
||||||
|
|
||||||
const tagsFilePath = this.getTagsFilePath();
|
|
||||||
const categoriesFilePath = this.getCategoriesFilePath();
|
const categoriesFilePath = this.getCategoriesFilePath();
|
||||||
const projectMetadataFilePath = this.getProjectMetadataFilePath();
|
const projectMetadataFilePath = this.getProjectMetadataFilePath();
|
||||||
|
|
||||||
const tagsFileExists = await this.fileExists(tagsFilePath);
|
|
||||||
const categoriesFileExists = await this.fileExists(categoriesFilePath);
|
const categoriesFileExists = await this.fileExists(categoriesFilePath);
|
||||||
const projectMetadataFileExists = await this.fileExists(projectMetadataFilePath);
|
const projectMetadataFileExists = await this.fileExists(projectMetadataFilePath);
|
||||||
|
|
||||||
@@ -403,32 +371,10 @@ export class MetaEngine extends EventEmitter {
|
|||||||
const dbTags = await this.collectTagsFromPosts();
|
const dbTags = await this.collectTagsFromPosts();
|
||||||
const dbCategories = await this.collectCategoriesFromPosts();
|
const dbCategories = await this.collectCategoriesFromPosts();
|
||||||
|
|
||||||
// Handle tags
|
// Handle tags - just populate from posts, TagEngine handles persistence
|
||||||
if (tagsFileExists) {
|
this.tags.clear();
|
||||||
// Load from file
|
for (const tag of dbTags) {
|
||||||
await this.loadTags();
|
this.tags.add(tag);
|
||||||
const fileTags = new Set(this.tags);
|
|
||||||
|
|
||||||
// Merge: add any tags from DB that aren't in file
|
|
||||||
let changed = false;
|
|
||||||
for (const tag of dbTags) {
|
|
||||||
if (!fileTags.has(tag)) {
|
|
||||||
this.tags.add(tag);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save if there were changes
|
|
||||||
if (changed) {
|
|
||||||
await this.saveTags();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No file exists, create from database
|
|
||||||
this.tags.clear();
|
|
||||||
for (const tag of dbTags) {
|
|
||||||
this.tags.add(tag);
|
|
||||||
}
|
|
||||||
await this.saveTags();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle categories
|
// Handle categories
|
||||||
|
|||||||
@@ -58,15 +58,16 @@ export class ProjectEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ensureProjectDirectories(projectId: string, dataPath?: string | null): Promise<void> {
|
private async ensureProjectDirectories(projectId: string, dataPath?: string | null): Promise<void> {
|
||||||
// Internal directories (always in userData)
|
// Determine base directory for all project data:
|
||||||
const internalDir = this.getInternalBaseDir(projectId);
|
// - If custom dataPath is provided, all project data lives there (allows cloud storage backup)
|
||||||
await fs.mkdir(path.join(internalDir, 'thumbnails'), { recursive: true });
|
// - If no dataPath (default project), use internal userData storage
|
||||||
await fs.mkdir(path.join(internalDir, 'meta'), { recursive: true });
|
|
||||||
|
|
||||||
// Data directories (may be external)
|
|
||||||
const dataDir = this.getDataDir(projectId, dataPath);
|
const dataDir = this.getDataDir(projectId, dataPath);
|
||||||
|
|
||||||
|
// Create all project directories in the data directory
|
||||||
await fs.mkdir(path.join(dataDir, 'posts'), { recursive: true });
|
await fs.mkdir(path.join(dataDir, 'posts'), { recursive: true });
|
||||||
await fs.mkdir(path.join(dataDir, 'media'), { recursive: true });
|
await fs.mkdir(path.join(dataDir, 'media'), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(dataDir, 'meta'), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(dataDir, 'thumbnails'), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async createProject(data: { name: string; description?: string; slug?: string; dataPath?: string }): Promise<ProjectData> {
|
async createProject(data: { name: string; description?: string; slug?: string; dataPath?: string }): Promise<ProjectData> {
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ function isValidHexColor(color: string): boolean {
|
|||||||
return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color);
|
return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portable tag format for filesystem serialization.
|
||||||
|
* Only stores name and optional color - no internal IDs.
|
||||||
|
*/
|
||||||
|
interface SerializedTag {
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TagEngine manages tag metadata and operations.
|
* TagEngine manages tag metadata and operations.
|
||||||
*
|
*
|
||||||
@@ -110,25 +119,34 @@ function isValidHexColor(color: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export class TagEngine extends EventEmitter {
|
export class TagEngine extends EventEmitter {
|
||||||
private currentProjectId: string = 'default';
|
private currentProjectId: string = 'default';
|
||||||
|
private dataDir: string | null = null; // Custom data directory (null = use internal userData)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Always returns the internal project directory (in userData).
|
* Returns the default internal project directory (in userData).
|
||||||
* Tag metadata never lives in an external dataPath.
|
|
||||||
*/
|
*/
|
||||||
private getInternalBaseDir(): string {
|
private getDefaultBaseDir(): string {
|
||||||
const userDataPath = app.getPath('userData');
|
const userDataPath = app.getPath('userData');
|
||||||
return path.join(userDataPath, 'projects', this.currentProjectId);
|
return path.join(userDataPath, 'projects', this.currentProjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the base directory for project data.
|
||||||
|
* If a custom dataDir is set, uses that; otherwise uses internal userData.
|
||||||
|
*/
|
||||||
|
private getBaseDir(): string {
|
||||||
|
return this.dataDir || this.getDefaultBaseDir();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the current project context
|
* Set the current project context
|
||||||
*/
|
*/
|
||||||
setProjectContext(projectId: string): void {
|
setProjectContext(projectId: string, dataDir?: string): void {
|
||||||
this.currentProjectId = projectId;
|
this.currentProjectId = projectId;
|
||||||
|
this.dataDir = dataDir || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,7 +160,7 @@ export class TagEngine extends EventEmitter {
|
|||||||
* Get the tags file path for filesystem persistence
|
* Get the tags file path for filesystem persistence
|
||||||
*/
|
*/
|
||||||
private getTagsFilePath(): string {
|
private getTagsFilePath(): string {
|
||||||
return path.join(this.getInternalBaseDir(), 'meta', 'tags-metadata.json');
|
return path.join(this.getBaseDir(), 'meta', 'tags.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -725,7 +743,8 @@ export class TagEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save tags metadata to filesystem for sync
|
* Save tags to filesystem in portable format (no internal IDs).
|
||||||
|
* Format: [{ name: "tag", color?: "#hex" }, ...]
|
||||||
*/
|
*/
|
||||||
private async saveTagsToFile(): Promise<void> {
|
private async saveTagsToFile(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -733,43 +752,61 @@ export class TagEngine extends EventEmitter {
|
|||||||
const filePath = this.getTagsFilePath();
|
const filePath = this.getTagsFilePath();
|
||||||
const dir = path.dirname(filePath);
|
const dir = path.dirname(filePath);
|
||||||
|
|
||||||
|
// Serialize to portable format - only name and optional color
|
||||||
|
const serialized: SerializedTag[] = tags.map(tag => {
|
||||||
|
const entry: SerializedTag = { name: tag.name };
|
||||||
|
if (tag.color) {
|
||||||
|
entry.color = tag.color;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await fs.writeFile(filePath, JSON.stringify(tags, null, 2), 'utf-8');
|
await fs.writeFile(filePath, JSON.stringify(serialized, null, 2), 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[TagEngine] Failed to save tags to file:', error);
|
console.error('[TagEngine] Failed to save tags to file:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load tags from filesystem (for initial sync)
|
* Load tags from filesystem (for initial sync).
|
||||||
|
* Handles both new portable format and legacy format with IDs.
|
||||||
*/
|
*/
|
||||||
async loadTagsFromFile(): Promise<void> {
|
async loadTagsFromFile(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const filePath = this.getTagsFilePath();
|
const filePath = this.getTagsFilePath();
|
||||||
const content = await fs.readFile(filePath, 'utf-8');
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
const tags: TagData[] = JSON.parse(content);
|
const rawTags: any[] = JSON.parse(content);
|
||||||
|
|
||||||
const client = getDatabase().getLocalClient();
|
const client = getDatabase().getLocalClient();
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
for (const tag of tags) {
|
const now = Date.now();
|
||||||
// Check if tag exists
|
|
||||||
|
for (const tag of rawTags) {
|
||||||
|
// Support both portable format { name, color? } and legacy format with id
|
||||||
|
const name = (tag.name || '').trim().toLowerCase();
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
const color = tag.color || null;
|
||||||
|
|
||||||
|
// Check if tag with this name already exists
|
||||||
const existing = await client.execute({
|
const existing = await client.execute({
|
||||||
sql: 'SELECT id FROM tags WHERE id = ? AND project_id = ?',
|
sql: 'SELECT id FROM tags WHERE project_id = ? AND LOWER(name) = LOWER(?)',
|
||||||
args: [tag.id, this.currentProjectId],
|
args: [this.currentProjectId, name],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing.rows.length === 0) {
|
if (existing.rows.length === 0) {
|
||||||
|
// Create new tag with fresh ID
|
||||||
await client.execute({
|
await client.execute({
|
||||||
sql: 'INSERT INTO tags (id, project_id, name, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
sql: 'INSERT INTO tags (id, project_id, name, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
args: [
|
args: [uuidv4(), this.currentProjectId, name, color, now, now],
|
||||||
tag.id,
|
});
|
||||||
this.currentProjectId,
|
} else if (color) {
|
||||||
tag.name,
|
// Update color if provided and tag exists
|
||||||
tag.color || null,
|
await client.execute({
|
||||||
tag.createdAt instanceof Date ? tag.createdAt.getTime() : tag.createdAt,
|
sql: 'UPDATE tags SET color = ?, updated_at = ? WHERE project_id = ? AND LOWER(name) = LOWER(?)',
|
||||||
tag.updatedAt instanceof Date ? tag.updatedAt.getTime() : tag.updatedAt,
|
args: [color, now, this.currentProjectId, name],
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ipcMain, dialog, shell } from 'electron';
|
import { ipcMain, dialog, shell } from 'electron';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fsPromises from 'fs/promises';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engine/PostEngine';
|
import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engine/PostEngine';
|
||||||
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
||||||
@@ -32,7 +34,7 @@ function safeHandle(channel: string, handler: (...args: any[]) => Promise<any>):
|
|||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ============ Project Handlers ============
|
// ============ Project Handlers ============
|
||||||
|
|
||||||
safeHandle('projects:create', async (_, data: { name: string; description?: string; slug?: string }) => {
|
safeHandle('projects:create', async (_, data: { name: string; description?: string; slug?: string; dataPath?: string }) => {
|
||||||
const engine = getProjectEngine();
|
const engine = getProjectEngine();
|
||||||
return engine.createProject(data);
|
return engine.createProject(data);
|
||||||
});
|
});
|
||||||
@@ -68,16 +70,17 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
// Ensure all engines have the correct project context
|
// Ensure all engines have the correct project context
|
||||||
if (project) {
|
if (project) {
|
||||||
const internalDir = projectEngine.getInternalBaseDir(project.id);
|
|
||||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||||
|
// For thumbnails and meta: use dataDir (whether custom or internal)
|
||||||
|
// This ensures all project data lives in the same location for backup
|
||||||
const postEngine = getPostEngine();
|
const postEngine = getPostEngine();
|
||||||
const mediaEngine = getMediaEngine();
|
const mediaEngine = getMediaEngine();
|
||||||
const metaEngine = getMetaEngine();
|
const metaEngine = getMetaEngine();
|
||||||
const tagEngine = getTagEngine();
|
const tagEngine = getTagEngine();
|
||||||
postEngine.setProjectContext(project.id, dataDir);
|
postEngine.setProjectContext(project.id, dataDir);
|
||||||
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
|
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||||
metaEngine.setProjectContext(project.id);
|
metaEngine.setProjectContext(project.id, dataDir);
|
||||||
tagEngine.setProjectContext(project.id);
|
tagEngine.setProjectContext(project.id, dataDir);
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = getPostMediaEngine();
|
||||||
postMediaEngine.setProjectContext(project.id);
|
postMediaEngine.setProjectContext(project.id);
|
||||||
|
|
||||||
@@ -94,16 +97,17 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
// Update all engines to use the new project context
|
// Update all engines to use the new project context
|
||||||
if (project) {
|
if (project) {
|
||||||
const internalDir = projectEngine.getInternalBaseDir(project.id);
|
|
||||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||||
|
// For thumbnails and meta: use dataDir (whether custom or internal)
|
||||||
|
// This ensures all project data lives in the same location for backup
|
||||||
const postEngine = getPostEngine();
|
const postEngine = getPostEngine();
|
||||||
const mediaEngine = getMediaEngine();
|
const mediaEngine = getMediaEngine();
|
||||||
const metaEngine = getMetaEngine();
|
const metaEngine = getMetaEngine();
|
||||||
const tagEngine = getTagEngine();
|
const tagEngine = getTagEngine();
|
||||||
postEngine.setProjectContext(project.id, dataDir);
|
postEngine.setProjectContext(project.id, dataDir);
|
||||||
mediaEngine.setProjectContext(project.id, dataDir, internalDir);
|
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||||
metaEngine.setProjectContext(project.id);
|
metaEngine.setProjectContext(project.id, dataDir);
|
||||||
tagEngine.setProjectContext(project.id);
|
tagEngine.setProjectContext(project.id, dataDir);
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = getPostMediaEngine();
|
||||||
postMediaEngine.setProjectContext(project.id);
|
postMediaEngine.setProjectContext(project.id);
|
||||||
|
|
||||||
@@ -578,6 +582,23 @@ export function registerIpcHandlers(): void {
|
|||||||
return shell.showItemInFolder(itemPath);
|
return shell.showItemInFolder(itemPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
safeHandle('app:readProjectMetadata', async (_, folderPath: string) => {
|
||||||
|
const metaPath = path.join(folderPath, 'meta', 'project.json');
|
||||||
|
try {
|
||||||
|
const content = await fsPromises.readFile(metaPath, 'utf-8');
|
||||||
|
const metadata = JSON.parse(content);
|
||||||
|
// Return metadata but exclude dataPath (will be set to selected folder)
|
||||||
|
return {
|
||||||
|
name: metadata.name || undefined,
|
||||||
|
description: metadata.description || undefined,
|
||||||
|
mainLanguage: metadata.mainLanguage || undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist or is invalid - return null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Meta Handlers ============
|
// ============ Meta Handlers ============
|
||||||
|
|
||||||
safeHandle('meta:getTags', async () => {
|
safeHandle('meta:getTags', async () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { contextBridge, ipcRenderer } from 'electron';
|
|||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Projects
|
// Projects
|
||||||
projects: {
|
projects: {
|
||||||
create: (data: { name: string; description?: string; slug?: string }) => ipcRenderer.invoke('projects:create', data),
|
create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => ipcRenderer.invoke('projects:create', data),
|
||||||
update: (id: string, data: unknown) => ipcRenderer.invoke('projects:update', id, data),
|
update: (id: string, data: unknown) => ipcRenderer.invoke('projects:update', id, data),
|
||||||
delete: (id: string) => ipcRenderer.invoke('projects:delete', id),
|
delete: (id: string) => ipcRenderer.invoke('projects:delete', id),
|
||||||
deleteWithData: (id: string) => ipcRenderer.invoke('projects:deleteWithData', id),
|
deleteWithData: (id: string) => ipcRenderer.invoke('projects:deleteWithData', id),
|
||||||
@@ -119,6 +119,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
|
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
|
||||||
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
|
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
|
||||||
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
|
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
|
||||||
|
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Meta (tags, categories, and project metadata)
|
// Meta (tags, categories, and project metadata)
|
||||||
@@ -248,7 +249,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// Type definitions for the exposed API
|
// Type definitions for the exposed API
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
projects: {
|
projects: {
|
||||||
create: (data: { name: string; description?: string; slug?: string }) => Promise<unknown>;
|
create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => Promise<unknown>;
|
||||||
update: (id: string, data: unknown) => Promise<unknown>;
|
update: (id: string, data: unknown) => Promise<unknown>;
|
||||||
delete: (id: string) => Promise<boolean>;
|
delete: (id: string) => Promise<boolean>;
|
||||||
get: (id: string) => Promise<unknown>;
|
get: (id: string) => Promise<unknown>;
|
||||||
|
|||||||
@@ -355,3 +355,81 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--vscode-foreground);
|
color: var(--vscode-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Folder picker styles */
|
||||||
|
.folder-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-path-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-path {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--vscode-editor-font-family, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-default-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary.btn-small {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin: 6px 0 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const ProjectSelector: React.FC = () => {
|
|||||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||||
const [newProjectName, setNewProjectName] = useState('');
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
const [newProjectDescription, setNewProjectDescription] = useState('');
|
const [newProjectDescription, setNewProjectDescription] = useState('');
|
||||||
|
const [newProjectDataPath, setNewProjectDataPath] = useState<string | null>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Load projects on mount
|
// Load projects on mount
|
||||||
@@ -125,12 +126,14 @@ export const ProjectSelector: React.FC = () => {
|
|||||||
const newProject = await window.electronAPI?.projects.create({
|
const newProject = await window.electronAPI?.projects.create({
|
||||||
name: newProjectName.trim(),
|
name: newProjectName.trim(),
|
||||||
description: newProjectDescription.trim() || undefined,
|
description: newProjectDescription.trim() || undefined,
|
||||||
|
dataPath: newProjectDataPath || undefined,
|
||||||
});
|
});
|
||||||
if (newProject) {
|
if (newProject) {
|
||||||
setProjects([...projects, newProject as ProjectData]);
|
setProjects([...projects, newProject as ProjectData]);
|
||||||
showToast.success(`Created project "${newProjectName}"`);
|
showToast.success(`Created project "${newProjectName}"`);
|
||||||
setNewProjectName('');
|
setNewProjectName('');
|
||||||
setNewProjectDescription('');
|
setNewProjectDescription('');
|
||||||
|
setNewProjectDataPath(null);
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
|
|
||||||
// Optionally switch to the new project
|
// Optionally switch to the new project
|
||||||
@@ -142,6 +145,42 @@ export const ProjectSelector: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectFolder = async () => {
|
||||||
|
try {
|
||||||
|
const selectedPath = await window.electronAPI?.app.selectFolder('Select Project Location');
|
||||||
|
if (selectedPath) {
|
||||||
|
setNewProjectDataPath(selectedPath);
|
||||||
|
|
||||||
|
// Check if the folder has existing project metadata
|
||||||
|
const existingMetadata = await window.electronAPI?.app.readProjectMetadata(selectedPath);
|
||||||
|
if (existingMetadata) {
|
||||||
|
// Pre-populate form fields from existing project.json (overwrite if found)
|
||||||
|
if (existingMetadata.name) {
|
||||||
|
setNewProjectName(existingMetadata.name);
|
||||||
|
}
|
||||||
|
if (existingMetadata.description) {
|
||||||
|
setNewProjectDescription(existingMetadata.description);
|
||||||
|
}
|
||||||
|
showToast.info('Found existing project settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to select folder:', error);
|
||||||
|
showToast.error('Failed to select folder');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFolder = () => {
|
||||||
|
setNewProjectDataPath(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseCreateModal = () => {
|
||||||
|
setNewProjectName('');
|
||||||
|
setNewProjectDescription('');
|
||||||
|
setNewProjectDataPath(null);
|
||||||
|
setShowCreateModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
const openDeleteModal = (e: React.MouseEvent, project: ProjectData) => {
|
const openDeleteModal = (e: React.MouseEvent, project: ProjectData) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setProjectToDelete(project);
|
setProjectToDelete(project);
|
||||||
@@ -244,11 +283,11 @@ export const ProjectSelector: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
|
<div className="modal-overlay" onClick={handleCloseCreateModal}>
|
||||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h3>Create New Project</h3>
|
<h3>Create New Project</h3>
|
||||||
<button className="modal-close" onClick={() => setShowCreateModal(false)}>
|
<button className="modal-close" onClick={handleCloseCreateModal}>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
|
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -277,9 +316,37 @@ export const ProjectSelector: React.FC = () => {
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label>Project Location</label>
|
||||||
|
<div className="folder-picker">
|
||||||
|
{newProjectDataPath ? (
|
||||||
|
<div className="folder-path-display">
|
||||||
|
<span className="folder-path" title={newProjectDataPath}>{newProjectDataPath}</span>
|
||||||
|
<button type="button" className="btn-icon" onClick={handleClearFolder} title="Use default location">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="folder-default-info">
|
||||||
|
<span className="default-label">Default (internal storage)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn-secondary btn-small" onClick={handleSelectFolder}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"/>
|
||||||
|
</svg>
|
||||||
|
Choose Folder...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="form-hint">
|
||||||
|
Choose a custom folder for cloud storage backup, or use the default internal storage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" className="btn-secondary" onClick={() => setShowCreateModal(false)}>
|
<button type="button" className="btn-secondary" onClick={handleCloseCreateModal}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-primary" disabled={!newProjectName.trim()}>
|
<button type="submit" className="btn-primary" disabled={!newProjectName.trim()}>
|
||||||
|
|||||||
3
src/renderer/types/electron.d.ts
vendored
3
src/renderer/types/electron.d.ts
vendored
@@ -268,7 +268,7 @@ export interface ChatTitleUpdate {
|
|||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
projects: {
|
projects: {
|
||||||
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
|
create: (data: { name: string; description?: string; slug?: string; dataPath?: string }) => Promise<ProjectData>;
|
||||||
update: (id: string, data: Partial<ProjectData>) => Promise<ProjectData | null>;
|
update: (id: string, data: Partial<ProjectData>) => Promise<ProjectData | null>;
|
||||||
delete: (id: string) => Promise<boolean>;
|
delete: (id: string) => Promise<boolean>;
|
||||||
deleteWithData: (id: string) => Promise<boolean>;
|
deleteWithData: (id: string) => Promise<boolean>;
|
||||||
@@ -367,6 +367,7 @@ export interface ElectronAPI {
|
|||||||
showItemInFolder: (itemPath: string) => Promise<void>;
|
showItemInFolder: (itemPath: string) => Promise<void>;
|
||||||
selectFolder: (title?: string) => Promise<string | null>;
|
selectFolder: (title?: string) => Promise<string | null>;
|
||||||
getDefaultProjectPath: (projectId: string) => Promise<string>;
|
getDefaultProjectPath: (projectId: string) => Promise<string>;
|
||||||
|
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>;
|
||||||
};
|
};
|
||||||
meta: {
|
meta: {
|
||||||
getTags: () => Promise<string[]>;
|
getTags: () => Promise<string[]>;
|
||||||
|
|||||||
@@ -167,24 +167,12 @@ describe('MetaEngine', () => {
|
|||||||
expect(tags).toContain('vue');
|
expect(tags).toContain('vue');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should persist tags to filesystem', async () => {
|
it('should keep tags in memory only (TagEngine handles persistence)', async () => {
|
||||||
await metaEngine.addTag('node');
|
await metaEngine.addTag('node');
|
||||||
await metaEngine.saveTags();
|
|
||||||
|
|
||||||
const metaDir = metaEngine.getMetaDir();
|
// Tags are now kept in memory only - TagEngine handles file persistence
|
||||||
const tagsPath = normalizePath(`${metaDir}/tags.json`);
|
|
||||||
expect(mockFiles.has(tagsPath)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should load tags from filesystem', async () => {
|
|
||||||
const metaDir = metaEngine.getMetaDir();
|
|
||||||
const tagsPath = normalizePath(`${metaDir}/tags.json`);
|
|
||||||
mockFiles.set(tagsPath, JSON.stringify(['saved-tag-1', 'saved-tag-2']));
|
|
||||||
|
|
||||||
await metaEngine.loadTags();
|
|
||||||
const tags = await metaEngine.getTags();
|
const tags = await metaEngine.getTags();
|
||||||
expect(tags).toContain('saved-tag-1');
|
expect(tags).toContain('node');
|
||||||
expect(tags).toContain('saved-tag-2');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,21 +255,18 @@ describe('MetaEngine', () => {
|
|||||||
expect(categories).toContain('cat3');
|
expect(categories).toContain('cat3');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge file tags with database tags', async () => {
|
it('should populate tags from database posts only', async () => {
|
||||||
// File has some tags
|
// Tags are now populated from posts only, no file merging
|
||||||
const metaDir = metaEngine.getMetaDir();
|
// (TagEngine handles tag persistence with colors)
|
||||||
mockFiles.set(normalizePath(`${metaDir}/tags.json`), JSON.stringify(['file-tag']));
|
|
||||||
|
|
||||||
// Posts have different tags
|
|
||||||
mockPosts = [
|
mockPosts = [
|
||||||
{ tags: JSON.stringify(['db-tag']) },
|
{ tags: JSON.stringify(['db-tag-1', 'db-tag-2']) },
|
||||||
];
|
];
|
||||||
|
|
||||||
await metaEngine.syncOnStartup();
|
await metaEngine.syncOnStartup();
|
||||||
|
|
||||||
const tags = await metaEngine.getTags();
|
const tags = await metaEngine.getTags();
|
||||||
expect(tags).toContain('file-tag');
|
expect(tags).toContain('db-tag-1');
|
||||||
expect(tags).toContain('db-tag');
|
expect(tags).toContain('db-tag-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge file categories with database categories', async () => {
|
it('should merge file categories with database categories', async () => {
|
||||||
@@ -307,13 +292,14 @@ describe('MetaEngine', () => {
|
|||||||
expect(fs.mkdir).toHaveBeenCalled();
|
expect(fs.mkdir).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should save merged results back to file', async () => {
|
it('should save category changes back to file', async () => {
|
||||||
const metaDir = metaEngine.getMetaDir();
|
const metaDir = metaEngine.getMetaDir();
|
||||||
mockFiles.set(normalizePath(`${metaDir}/tags.json`), JSON.stringify(['existing']));
|
mockFiles.set(normalizePath(`${metaDir}/categories.json`), JSON.stringify(['existing-cat']));
|
||||||
mockPosts = [{ tags: JSON.stringify(['new-from-db']), categories: JSON.stringify([]) }];
|
mockPosts = [{ tags: JSON.stringify([]), categories: JSON.stringify(['new-cat-from-db']) }];
|
||||||
|
|
||||||
await metaEngine.syncOnStartup();
|
await metaEngine.syncOnStartup();
|
||||||
|
|
||||||
|
// Categories are saved to file, tags are not (handled by TagEngine)
|
||||||
expect(fs.writeFile).toHaveBeenCalled();
|
expect(fs.writeFile).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -529,4 +529,83 @@ describe('ProjectEngine', () => {
|
|||||||
expect(project2.slug).toMatch(/^my-blog(-\d+)?$/);
|
expect(project2.slug).toMatch(/^my-blog(-\d+)?$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Custom dataPath', () => {
|
||||||
|
it('should create project with custom dataPath', async () => {
|
||||||
|
const customPath = '/Users/test/Documents/MyBlog';
|
||||||
|
const project = await projectEngine.createProject({
|
||||||
|
name: 'Custom Path Project',
|
||||||
|
dataPath: customPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(project.dataPath).toBe(customPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create meta and thumbnails directories in custom dataPath', async () => {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const customPath = '/Users/test/Documents/MyBlog';
|
||||||
|
|
||||||
|
await projectEngine.createProject({
|
||||||
|
name: 'Custom Dirs Project',
|
||||||
|
dataPath: customPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mkdirCalls = vi.mocked(fs.mkdir).mock.calls;
|
||||||
|
const createdPaths = mkdirCalls.map(call => call[0]);
|
||||||
|
|
||||||
|
// Should create meta/ and thumbnails/ in custom dataPath
|
||||||
|
expect(createdPaths).toContainEqual(expect.stringContaining(customPath));
|
||||||
|
expect(createdPaths.some(p => String(p).includes(customPath) && String(p).includes('meta'))).toBe(true);
|
||||||
|
expect(createdPaths.some(p => String(p).includes(customPath) && String(p).includes('thumbnails'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create posts and media directories in custom dataPath', async () => {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const customPath = '/Users/test/Documents/MyBlog';
|
||||||
|
|
||||||
|
await projectEngine.createProject({
|
||||||
|
name: 'Custom Data Project',
|
||||||
|
dataPath: customPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mkdirCalls = vi.mocked(fs.mkdir).mock.calls;
|
||||||
|
const createdPaths = mkdirCalls.map(call => call[0]);
|
||||||
|
|
||||||
|
// Should create posts/ and media/ in custom dataPath
|
||||||
|
expect(createdPaths.some(p => String(p).includes(customPath) && String(p).includes('posts'))).toBe(true);
|
||||||
|
expect(createdPaths.some(p => String(p).includes(customPath) && String(p).includes('media'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create meta and thumbnails in internal storage when no dataPath', async () => {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
|
||||||
|
await projectEngine.createProject({
|
||||||
|
name: 'Default Path Project',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mkdirCalls = vi.mocked(fs.mkdir).mock.calls;
|
||||||
|
const createdPaths = mkdirCalls.map(call => String(call[0]));
|
||||||
|
|
||||||
|
// Should create meta/ and thumbnails/ in internal (userData) path
|
||||||
|
expect(createdPaths.some(p => p.includes('meta'))).toBe(true);
|
||||||
|
expect(createdPaths.some(p => p.includes('thumbnails'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use getDataDir with custom dataPath', () => {
|
||||||
|
const projectId = 'test-id';
|
||||||
|
const customPath = '/Users/test/MyBlog';
|
||||||
|
|
||||||
|
const dataDir = projectEngine.getDataDir(projectId, customPath);
|
||||||
|
|
||||||
|
expect(dataDir).toBe(customPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use internal dir when dataPath is null', () => {
|
||||||
|
const projectId = 'test-id';
|
||||||
|
|
||||||
|
const dataDir = projectEngine.getDataDir(projectId, null);
|
||||||
|
|
||||||
|
expect(dataDir).toContain(projectId);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user