feat: added dataPath for projects

This commit is contained in:
2026-02-12 15:00:37 +01:00
parent 2b95f3d72c
commit 85d196e598
15 changed files with 263 additions and 49 deletions

View File

@@ -401,6 +401,14 @@ export class DatabaseConnection {
console.log('Tags table created successfully'); console.log('Tags table created successfully');
} }
// Migration: Add data_path column to projects table
const dataPathCol = await this.localClient.execute(
"SELECT name FROM pragma_table_info('projects') WHERE name = 'data_path'"
);
if (dataPathCol.rows.length === 0) {
await this.localClient.execute("ALTER TABLE projects ADD COLUMN data_path TEXT");
}
// Create default project if none exists // Create default project if none exists
const existingProjects = await this.localClient.execute('SELECT COUNT(*) as count FROM projects'); const existingProjects = await this.localClient.execute('SELECT COUNT(*) as count FROM projects');
if (existingProjects.rows[0] && (existingProjects.rows[0].count as number) === 0) { if (existingProjects.rows[0] && (existingProjects.rows[0].count as number) === 0) {

View File

@@ -6,6 +6,7 @@ export const projects = sqliteTable('projects', {
name: text('name').notNull(), name: text('name').notNull(),
slug: text('slug').notNull().unique(), slug: text('slug').notNull().unique(),
description: text('description'), description: text('description'),
dataPath: text('data_path'), // Custom path for project data (null = default userData/projects/{id})
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(false), isActive: integer('is_active', { mode: 'boolean' }).notNull().default(false),

View File

@@ -49,14 +49,20 @@ export interface MediaMetadata {
export class MediaEngine extends EventEmitter { export class MediaEngine extends EventEmitter {
private currentProjectId: string = 'default'; private currentProjectId: string = 'default';
private projectBaseDir: string | null = null;
constructor() { constructor() {
super(); super();
} }
private getMediaBaseDir(): string { private getProjectBaseDir(): string {
if (this.projectBaseDir) return this.projectBaseDir;
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'projects', this.currentProjectId, 'media'); return path.join(userDataPath, 'projects', this.currentProjectId);
}
private getMediaBaseDir(): string {
return path.join(this.getProjectBaseDir(), 'media');
} }
private getMediaDir(): string { private getMediaDir(): string {
@@ -85,8 +91,9 @@ export class MediaEngine extends EventEmitter {
return path.join(dir, `${id}${extension}`); return path.join(dir, `${id}${extension}`);
} }
setProjectContext(projectId: string): void { setProjectContext(projectId: string, baseDir?: string): void {
this.currentProjectId = projectId; this.currentProjectId = projectId;
this.projectBaseDir = baseDir || null;
} }
getProjectContext(): string { getProjectContext(): string {
@@ -101,8 +108,7 @@ export class MediaEngine extends EventEmitter {
* Get the thumbnails directory for the current project * Get the thumbnails directory for the current project
*/ */
private getThumbnailsDir(): string { private getThumbnailsDir(): string {
const userDataPath = app.getPath('userData'); return path.join(this.getProjectBaseDir(), 'thumbnails');
return path.join(userDataPath, 'projects', this.currentProjectId, 'thumbnails');
} }
/** /**

View File

@@ -12,6 +12,7 @@ import { posts, projects } from '../database/schema';
export interface ProjectMetadata { export interface ProjectMetadata {
name: string; name: string;
description?: string; description?: string;
dataPath?: string; // Custom path for project data
} }
/** /**
@@ -31,6 +32,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 projectBaseDir: string | null = null;
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;
@@ -40,13 +42,18 @@ export class MetaEngine extends EventEmitter {
super(); super();
} }
private getProjectBaseDir(): string {
if (this.projectBaseDir) return this.projectBaseDir;
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'projects', this.currentProjectId);
}
/** /**
* Get the meta directory path for the current project. * Get the meta directory path for the current project.
* Format: {userData}/projects/{projectId}/meta/ * Format: {baseDir}/meta/
*/ */
getMetaDir(): string { getMetaDir(): string {
const userDataPath = app.getPath('userData'); return path.join(this.getProjectBaseDir(), 'meta');
return path.join(userDataPath, 'projects', this.currentProjectId, 'meta');
} }
private getTagsFilePath(): string { private getTagsFilePath(): string {
@@ -61,8 +68,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, baseDir?: string): void {
this.currentProjectId = projectId; this.currentProjectId = projectId;
this.projectBaseDir = baseDir || 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();
@@ -334,10 +342,10 @@ export class MetaEngine extends EventEmitter {
/** /**
* Fetch the current project's data from the database. * Fetch the current project's data from the database.
*/ */
private async fetchProjectFromDatabase(): Promise<{ name: string; description: string | null } | null> { private async fetchProjectFromDatabase(): Promise<{ name: string; description: string | null; dataPath: string | null } | null> {
const db = getDatabase().getLocal(); const db = getDatabase().getLocal();
const project = await db const project = await db
.select({ name: projects.name, description: projects.description }) .select({ name: projects.name, description: projects.description, dataPath: projects.dataPath })
.from(projects) .from(projects)
.where(eq(projects.id, this.currentProjectId)) .where(eq(projects.id, this.currentProjectId))
.get(); .get();
@@ -459,6 +467,18 @@ export class MetaEngine extends EventEmitter {
// Handle project metadata // Handle project metadata
if (projectMetadataFileExists) { if (projectMetadataFileExists) {
await this.loadProjectMetadata(); await this.loadProjectMetadata();
// If project.json has a dataPath, sync it back to the database
if (this.projectMetadata?.dataPath !== undefined) {
const projectData = await this.fetchProjectFromDatabase();
if (projectData && projectData.dataPath !== this.projectMetadata.dataPath) {
const db = getDatabase().getLocal();
await db.update(projects)
.set({ dataPath: this.projectMetadata.dataPath || null })
.where(eq(projects.id, this.currentProjectId));
console.log(`[MetaEngine] Synced dataPath from project.json to database: ${this.projectMetadata.dataPath || '(default)'}`);
}
}
} else { } else {
// No file exists, fetch project data from database and create file // No file exists, fetch project data from database and create file
const projectData = await this.fetchProjectFromDatabase(); const projectData = await this.fetchProjectFromDatabase();
@@ -468,6 +488,7 @@ export class MetaEngine extends EventEmitter {
this.projectMetadata = { this.projectMetadata = {
name: projectData.name, name: projectData.name,
description: projectData.description || undefined, description: projectData.description || undefined,
dataPath: projectData.dataPath || undefined,
}; };
await this.saveProjectMetadata(); await this.saveProjectMetadata();
} }

View File

@@ -142,9 +142,16 @@ export class PostEngine extends EventEmitter {
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] }); await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
} }
private getPostsBaseDir(): string { private projectBaseDir: string | null = null;
private getProjectBaseDir(): string {
if (this.projectBaseDir) return this.projectBaseDir;
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'projects', this.currentProjectId, 'posts'); return path.join(userDataPath, 'projects', this.currentProjectId);
}
private getPostsBaseDir(): string {
return path.join(this.getProjectBaseDir(), 'posts');
} }
private getPostsDir(): string { private getPostsDir(): string {
@@ -172,8 +179,9 @@ export class PostEngine extends EventEmitter {
return path.join(dir, `${slug}.md`); return path.join(dir, `${slug}.md`);
} }
setProjectContext(projectId: string): void { setProjectContext(projectId: string, baseDir?: string): void {
this.currentProjectId = projectId; this.currentProjectId = projectId;
this.projectBaseDir = baseDir || null;
} }
getProjectContext(): string { getProjectContext(): string {

View File

@@ -12,6 +12,7 @@ export interface ProjectData {
name: string; name: string;
slug: string; slug: string;
description?: string; description?: string;
dataPath?: string; // Custom path for project data (undefined = default)
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
isActive: boolean; isActive: boolean;
@@ -29,18 +30,41 @@ export class ProjectEngine extends EventEmitter {
.replace(/^-|-$/g, ''); .replace(/^-|-$/g, '');
} }
private async ensureProjectDirectories(projectId: string): Promise<void> { /**
* Get the base directory for a project's data.
* If the project has a custom dataPath, use that; otherwise use the default.
*/
getProjectBaseDir(projectId: string, dataPath?: string | null): string {
if (dataPath) {
return dataPath;
}
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
const projectDir = path.join(userDataPath, 'projects', projectId); return path.join(userDataPath, 'projects', projectId);
}
/**
* Get the default base directory (in userData) for a project.
*/
getDefaultProjectBaseDir(projectId: string): string {
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'projects', projectId);
}
private async ensureProjectDirectories(projectId: string, dataPath?: string | null): Promise<void> {
const projectDir = this.getProjectBaseDir(projectId, dataPath);
const postsDir = path.join(projectDir, 'posts'); const postsDir = path.join(projectDir, 'posts');
const mediaDir = path.join(projectDir, 'media'); const mediaDir = path.join(projectDir, 'media');
const thumbnailsDir = path.join(projectDir, 'thumbnails');
const metaDir = path.join(projectDir, 'meta');
await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(postsDir, { recursive: true }); await fs.mkdir(postsDir, { recursive: true });
await fs.mkdir(mediaDir, { recursive: true }); await fs.mkdir(mediaDir, { recursive: true });
await fs.mkdir(thumbnailsDir, { recursive: true });
await fs.mkdir(metaDir, { recursive: true });
} }
async createProject(data: { name: string; description?: string; slug?: string }): Promise<ProjectData> { async createProject(data: { name: string; description?: string; slug?: string; dataPath?: string }): Promise<ProjectData> {
const db = getDatabase().getLocal(); const db = getDatabase().getLocal();
const now = new Date(); const now = new Date();
const id = uuidv4(); const id = uuidv4();
@@ -61,13 +85,14 @@ export class ProjectEngine extends EventEmitter {
name: data.name, name: data.name,
slug: finalSlug, slug: finalSlug,
description: data.description, description: data.description,
dataPath: data.dataPath,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
isActive: false, isActive: false,
}; };
// Create directories using project ID (not slug) // Create directories using project ID (not slug)
await this.ensureProjectDirectories(id); await this.ensureProjectDirectories(id, data.dataPath);
// Insert into database // Insert into database
const dbProject: NewProject = { const dbProject: NewProject = {
@@ -75,6 +100,7 @@ export class ProjectEngine extends EventEmitter {
name: project.name, name: project.name,
slug: project.slug, slug: project.slug,
description: project.description, description: project.description,
dataPath: project.dataPath,
createdAt: project.createdAt, createdAt: project.createdAt,
updatedAt: project.updatedAt, updatedAt: project.updatedAt,
isActive: project.isActive, isActive: project.isActive,
@@ -106,6 +132,7 @@ export class ProjectEngine extends EventEmitter {
name: updated.name, name: updated.name,
slug: updated.slug, slug: updated.slug,
description: updated.description, description: updated.description,
dataPath: updated.dataPath,
updatedAt: updated.updatedAt, updatedAt: updated.updatedAt,
isActive: updated.isActive, isActive: updated.isActive,
}) })
@@ -116,6 +143,7 @@ export class ProjectEngine extends EventEmitter {
name: updated.name, name: updated.name,
slug: updated.slug, slug: updated.slug,
description: updated.description || undefined, description: updated.description || undefined,
dataPath: updated.dataPath || undefined,
createdAt: updated.createdAt, createdAt: updated.createdAt,
updatedAt: updated.updatedAt, updatedAt: updated.updatedAt,
isActive: updated.isActive ?? false, isActive: updated.isActive ?? false,
@@ -196,6 +224,7 @@ export class ProjectEngine extends EventEmitter {
name: dbProject.name, name: dbProject.name,
slug: dbProject.slug, slug: dbProject.slug,
description: dbProject.description || undefined, description: dbProject.description || undefined,
dataPath: dbProject.dataPath || undefined,
createdAt: dbProject.createdAt, createdAt: dbProject.createdAt,
updatedAt: dbProject.updatedAt, updatedAt: dbProject.updatedAt,
isActive: dbProject.isActive ?? false, isActive: dbProject.isActive ?? false,
@@ -211,6 +240,7 @@ export class ProjectEngine extends EventEmitter {
name: p.name, name: p.name,
slug: p.slug, slug: p.slug,
description: p.description || undefined, description: p.description || undefined,
dataPath: p.dataPath || undefined,
createdAt: p.createdAt, createdAt: p.createdAt,
updatedAt: p.updatedAt, updatedAt: p.updatedAt,
isActive: p.isActive ?? false, isActive: p.isActive ?? false,
@@ -231,6 +261,7 @@ export class ProjectEngine extends EventEmitter {
name: dbProject.name, name: dbProject.name,
slug: dbProject.slug, slug: dbProject.slug,
description: dbProject.description || undefined, description: dbProject.description || undefined,
dataPath: dbProject.dataPath || undefined,
createdAt: dbProject.createdAt, createdAt: dbProject.createdAt,
updatedAt: dbProject.updatedAt, updatedAt: dbProject.updatedAt,
isActive: dbProject.isActive ?? false, isActive: dbProject.isActive ?? false,
@@ -255,13 +286,21 @@ export class ProjectEngine extends EventEmitter {
return project; return project;
} }
getProjectPaths(projectId: string): { posts: string; media: string } { getProjectPaths(projectId: string, dataPath?: string | null): { posts: string; media: string } {
const userDataPath = app.getPath('userData'); const baseDir = this.getProjectBaseDir(projectId, dataPath);
return { return {
posts: path.join(userDataPath, 'projects', projectId, 'posts'), posts: path.join(baseDir, 'posts'),
media: path.join(userDataPath, 'projects', projectId, 'media'), media: path.join(baseDir, 'media'),
}; };
} }
/**
* Get project paths by looking up the project's dataPath from the database.
*/
async getProjectPathsResolved(projectId: string): Promise<{ posts: string; media: string }> {
const project = await this.getProject(projectId);
return this.getProjectPaths(projectId, project?.dataPath);
}
} }
// Singleton instance // Singleton instance

View File

@@ -110,16 +110,24 @@ function isValidHexColor(color: string): boolean {
*/ */
export class TagEngine extends EventEmitter { export class TagEngine extends EventEmitter {
private currentProjectId: string = 'default'; private currentProjectId: string = 'default';
private projectBaseDir: string | null = null;
constructor() { constructor() {
super(); super();
} }
private getProjectBaseDir(): string {
if (this.projectBaseDir) return this.projectBaseDir;
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'projects', this.currentProjectId);
}
/** /**
* Set the current project context * Set the current project context
*/ */
setProjectContext(projectId: string): void { setProjectContext(projectId: string, baseDir?: string): void {
this.currentProjectId = projectId; this.currentProjectId = projectId;
this.projectBaseDir = baseDir || null;
} }
/** /**
@@ -133,8 +141,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 {
const userDataPath = app.getPath('userData'); return path.join(this.getProjectBaseDir(), 'meta', 'tags-metadata.json');
return path.join(userDataPath, 'projects', this.currentProjectId, 'meta', 'tags-metadata.json');
} }
/** /**

View File

@@ -50,14 +50,15 @@ 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 baseDir = projectEngine.getProjectBaseDir(project.id, project.dataPath);
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); postEngine.setProjectContext(project.id, baseDir);
mediaEngine.setProjectContext(project.id); mediaEngine.setProjectContext(project.id, baseDir);
metaEngine.setProjectContext(project.id); metaEngine.setProjectContext(project.id, baseDir);
tagEngine.setProjectContext(project.id); tagEngine.setProjectContext(project.id, baseDir);
// Sync meta on startup // Sync meta on startup
await metaEngine.syncOnStartup(); await metaEngine.syncOnStartup();
@@ -72,14 +73,15 @@ 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 baseDir = projectEngine.getProjectBaseDir(project.id, project.dataPath);
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); postEngine.setProjectContext(project.id, baseDir);
mediaEngine.setProjectContext(project.id); mediaEngine.setProjectContext(project.id, baseDir);
metaEngine.setProjectContext(project.id); metaEngine.setProjectContext(project.id, baseDir);
tagEngine.setProjectContext(project.id); tagEngine.setProjectContext(project.id, baseDir);
// Sync meta on project switch // Sync meta on project switch
await metaEngine.syncOnStartup(); await metaEngine.syncOnStartup();
@@ -383,7 +385,7 @@ export function registerIpcHandlers(): void {
const projectEngine = getProjectEngine(); const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject(); const activeProject = await projectEngine.getActiveProject();
const projectId = activeProject?.id || 'default'; const projectId = activeProject?.id || 'default';
const paths = projectEngine.getProjectPaths(projectId); const paths = projectEngine.getProjectPaths(projectId, activeProject?.dataPath);
const fullConfig: DropboxSyncConfig = { const fullConfig: DropboxSyncConfig = {
accessToken: config.accessToken, accessToken: config.accessToken,
@@ -480,7 +482,7 @@ export function registerIpcHandlers(): void {
const projectEngine = getProjectEngine(); const projectEngine = getProjectEngine();
const activeProject = await projectEngine.getActiveProject(); const activeProject = await projectEngine.getActiveProject();
const projectId = activeProject?.id || 'default'; const projectId = activeProject?.id || 'default';
const paths = projectEngine.getProjectPaths(projectId); const paths = projectEngine.getProjectPaths(projectId, activeProject?.dataPath);
return { return {
database: getDatabase().getDataPaths().database, database: getDatabase().getDataPaths().database,
posts: paths.posts, posts: paths.posts,
@@ -492,6 +494,22 @@ export function registerIpcHandlers(): void {
return shell.openPath(folderPath); return shell.openPath(folderPath);
}); });
ipcMain.handle('app:selectFolder', async (_, title?: string) => {
const result = await dialog.showOpenDialog({
title: title || 'Select Folder',
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return result.filePaths[0];
});
ipcMain.handle('app:getDefaultProjectPath', async (_, projectId: string) => {
const projectEngine = getProjectEngine();
return projectEngine.getDefaultProjectBaseDir(projectId);
});
ipcMain.handle('app:showItemInFolder', async (_, itemPath: string) => { ipcMain.handle('app:showItemInFolder', async (_, itemPath: string) => {
return shell.showItemInFolder(itemPath); return shell.showItemInFolder(itemPath);
}); });
@@ -553,7 +571,7 @@ export function registerIpcHandlers(): void {
return engine.getProjectMetadata(); return engine.getProjectMetadata();
}); });
ipcMain.handle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string }) => { ipcMain.handle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string }) => {
const engine = getMetaEngine(); const engine = getMetaEngine();
await engine.updateProjectMetadata(updates); await engine.updateProjectMetadata(updates);
return engine.getProjectMetadata(); return engine.getProjectMetadata();

View File

@@ -5,6 +5,7 @@ import { getDatabase } from './database';
import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc'; import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
import { media } from './database/schema'; import { media } from './database/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getMediaEngine } from './engine/MediaEngine';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
@@ -22,6 +23,15 @@ protocol.registerSchemesAsPrivileged([
corsEnabled: true, corsEnabled: true,
}, },
}, },
{
scheme: 'bds-thumb',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
},
},
]); ]);
function createWindow(): void { function createWindow(): void {
@@ -383,6 +393,39 @@ async function initialize(): Promise<void> {
} }
}); });
// Register custom protocol for serving thumbnail images
// URLs like bds-thumb://media-id will serve the small thumbnail webp
protocol.handle('bds-thumb', async (request) => {
try {
const url = new URL(request.url);
const mediaId = url.hostname;
const engine = getMediaEngine();
const thumbnails = await engine.getThumbnailPaths(mediaId);
if (thumbnails.small) {
return net.fetch(`file://${thumbnails.small}`);
}
// Fallback to full image if thumbnail doesn't exist
const database = getDatabase().getLocal();
const mediaItem = await database
.select()
.from(media)
.where(eq(media.id, mediaId))
.get();
if (mediaItem && mediaItem.filePath) {
return net.fetch(`file://${mediaItem.filePath}`);
}
return new Response('Thumbnail not found', { status: 404 });
} catch (error) {
console.error('Error serving thumbnail:', error);
return new Response('Internal server error', { status: 500 });
}
});
// Register IPC handlers // Register IPC handlers
registerIpcHandlers(); registerIpcHandlers();

View File

@@ -99,6 +99,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getDataPaths: () => ipcRenderer.invoke('app:getDataPaths'), getDataPaths: () => ipcRenderer.invoke('app:getDataPaths'),
openFolder: (folderPath: string) => ipcRenderer.invoke('app:openFolder', folderPath), openFolder: (folderPath: string) => ipcRenderer.invoke('app:openFolder', folderPath),
showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath), showItemInFolder: (itemPath: string) => ipcRenderer.invoke('app:showItemInFolder', itemPath),
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
}, },
// Meta (tags, categories, and project metadata) // Meta (tags, categories, and project metadata)
@@ -112,7 +114,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'), syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'), getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata), setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
updateProjectMetadata: (updates: { name?: string; description?: string }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
}, },
// Tag Management (advanced tag operations) // Tag Management (advanced tag operations)
@@ -267,6 +269,8 @@ export interface ElectronAPI {
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>; getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
openFolder: (folderPath: string) => Promise<string>; openFolder: (folderPath: string) => Promise<string>;
showItemInFolder: (itemPath: string) => Promise<void>; showItemInFolder: (itemPath: string) => Promise<void>;
selectFolder: (title?: string) => Promise<string | null>;
getDefaultProjectPath: (projectId: string) => Promise<string>;
}; };
meta: { meta: {
getTags: () => Promise<string[]>; getTags: () => Promise<string[]>;

View File

@@ -108,6 +108,8 @@ export const SettingsView: React.FC = () => {
// Project settings // Project settings
const [projectName, setProjectName] = useState(''); const [projectName, setProjectName] = useState('');
const [projectDescription, setProjectDescription] = useState(''); const [projectDescription, setProjectDescription] = useState('');
const [projectDataPath, setProjectDataPath] = useState('');
const [defaultProjectPath, setDefaultProjectPath] = useState('');
// Post categories management // Post categories management
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES); const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
@@ -135,6 +137,12 @@ export const SettingsView: React.FC = () => {
if (activeProject) { if (activeProject) {
setProjectName(activeProject.name); setProjectName(activeProject.name);
setProjectDescription(activeProject.description || ''); setProjectDescription(activeProject.description || '');
setProjectDataPath(activeProject.dataPath || '');
// Load the default path for reference
window.electronAPI?.app.getDefaultProjectPath(activeProject.id).then(path => {
setDefaultProjectPath(path);
});
} }
}, [activeProject]); }, [activeProject]);
@@ -285,10 +293,18 @@ export const SettingsView: React.FC = () => {
const updated = await window.electronAPI?.projects.update(activeProject.id, { const updated = await window.electronAPI?.projects.update(activeProject.id, {
name: projectName.trim() || activeProject.name, name: projectName.trim() || activeProject.name,
description: projectDescription.trim(), description: projectDescription.trim(),
dataPath: projectDataPath.trim() || undefined,
}); });
if (updated) { if (updated) {
setActiveProject(updated as any); setActiveProject(updated as any);
useAppStore.getState().updateProject(activeProject.id, updated as any); useAppStore.getState().updateProject(activeProject.id, updated as any);
// Also update project.json to keep dataPath in sync
await window.electronAPI?.meta.updateProjectMetadata({
name: projectName.trim() || activeProject.name,
description: projectDescription.trim(),
dataPath: projectDataPath.trim() || undefined,
} as any);
} }
showToast.success('Project settings saved'); showToast.success('Project settings saved');
} catch (error) { } catch (error) {
@@ -297,8 +313,19 @@ export const SettingsView: React.FC = () => {
} }
}; };
const handleBrowseDataPath = async () => {
const selected = await window.electronAPI?.app.selectFolder('Select Project Data Folder');
if (selected) {
setProjectDataPath(selected);
}
};
const handleResetDataPath = () => {
setProjectDataPath('');
};
// Keywords for each section for search filtering // Keywords for each section for search filtering
const projectKeywords = ['project', 'name', 'description', 'blog', 'site']; const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'path', 'folder', 'location', 'data'];
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual']; const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode']; const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode'];
@@ -341,6 +368,30 @@ export const SettingsView: React.FC = () => {
/> />
</SettingRow> </SettingRow>
<SettingRow
id="project-datapath"
label="Project Data Path"
description={`Custom folder for storing posts, media, and metadata. Leave empty to use the default location: ${defaultProjectPath}`}
>
<div className="setting-input-group">
<input
id="project-datapath"
type="text"
placeholder={defaultProjectPath || 'Default location'}
value={projectDataPath}
onChange={(e) => setProjectDataPath(e.target.value)}
/>
<button className="secondary" onClick={handleBrowseDataPath} title="Browse...">
Browse
</button>
{projectDataPath && (
<button className="secondary" onClick={handleResetDataPath} title="Reset to default">
Reset
</button>
)}
</div>
</SettingRow>
<div className="setting-actions"> <div className="setting-actions">
<button className="primary" onClick={handleSaveProject}> <button className="primary" onClick={handleSaveProject}>
Save Project Settings Save Project Settings

View File

@@ -604,7 +604,7 @@ const MediaList: React.FC = () => {
{item.mimeType.startsWith('image/') ? ( {item.mimeType.startsWith('image/') ? (
<div className="media-thumbnail"> <div className="media-thumbnail">
<img <img
src={`bds-media://${item.id}`} src={`bds-thumb://${item.id}`}
alt={item.alt || item.originalName} alt={item.alt || item.originalName}
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob: bds-media:; worker-src 'self' blob:; font-src 'self' data:;" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob: bds-media: bds-thumb:; worker-src 'self' blob:; font-src 'self' data:;" />
<title>Blogging Desktop Server</title> <title>Blogging Desktop Server</title>
</head> </head>
<body> <body>

View File

@@ -24,6 +24,7 @@ export interface ProjectData {
name: string; name: string;
slug: string; slug: string;
description?: string; description?: string;
dataPath?: string;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -322,7 +323,10 @@ export const useAppStore = create<AppState>()(
hasMorePosts: hasMore, hasMorePosts: hasMore,
}; };
}), }),
addPost: (post) => set((state) => ({ posts: [post, ...state.posts], totalPosts: state.totalPosts + 1 })), addPost: (post) => set((state) => {
if (state.posts.some(p => p.id === post.id)) return state;
return { posts: [post, ...state.posts], totalPosts: state.totalPosts + 1 };
}),
updatePost: (id, updatedPost) => set((state) => ({ updatePost: (id, updatedPost) => set((state) => ({
posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)), posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)),
})), })),

View File

@@ -3,6 +3,7 @@
export interface ProjectMetadata { export interface ProjectMetadata {
name: string; name: string;
description?: string; description?: string;
dataPath?: string;
} }
export interface ProjectData { export interface ProjectData {
@@ -10,6 +11,7 @@ export interface ProjectData {
name: string; name: string;
slug: string; slug: string;
description?: string; description?: string;
dataPath?: string;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -312,6 +314,8 @@ export interface ElectronAPI {
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>; getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
openFolder: (folderPath: string) => Promise<string>; openFolder: (folderPath: string) => Promise<string>;
showItemInFolder: (itemPath: string) => Promise<void>; showItemInFolder: (itemPath: string) => Promise<void>;
selectFolder: (title?: string) => Promise<string | null>;
getDefaultProjectPath: (projectId: string) => Promise<string>;
}; };
meta: { meta: {
getTags: () => Promise<string[]>; getTags: () => Promise<string[]>;
@@ -323,7 +327,7 @@ export interface ElectronAPI {
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>; syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
getProjectMetadata: () => Promise<ProjectMetadata | null>; getProjectMetadata: () => Promise<ProjectMetadata | null>;
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string }) => Promise<ProjectMetadata | null>; updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string }) => Promise<ProjectMetadata | null>;
}; };
tags: { tags: {
getAll: () => Promise<TagData[]>; getAll: () => Promise<TagData[]>;