904 lines
29 KiB
TypeScript
904 lines
29 KiB
TypeScript
import { EventEmitter } from 'events';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import { app } from 'electron';
|
|
import { eq } from 'drizzle-orm';
|
|
import { getDatabase } from '../database';
|
|
import { posts, projects } from '../database/schema';
|
|
import { sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes';
|
|
import {
|
|
normalizeTaxonomyTerm,
|
|
normalizeNonEmptyTaxonomyTerm,
|
|
collectNormalizedTermsFromJsonValues,
|
|
} from './taxonomyUtils';
|
|
|
|
/**
|
|
* Project metadata stored in meta/project.json
|
|
*/
|
|
export interface ProjectMetadata {
|
|
name: string;
|
|
description?: string;
|
|
dataPath?: string; // Custom path for project data
|
|
publicUrl?: string; // Public base URL for the published blog (e.g., https://example.com)
|
|
mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es')
|
|
defaultAuthor?: string; // Default author for new posts and media
|
|
maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50)
|
|
blogmarkCategory?: string; // Category used for externally captured bookmark posts
|
|
pythonRuntimeMode?: 'webworker' | 'main-thread'; // Runtime mode for Python script execution
|
|
picoTheme?: PicoThemeName; // Selected Pico CSS theme for preview/rendering
|
|
categoryMetadata?: Record<string, CategoryMetadata>; // Per-category metadata for UI/rendering
|
|
categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences
|
|
}
|
|
|
|
export interface CategoryRenderSettings {
|
|
renderInLists: boolean;
|
|
showTitle: boolean;
|
|
postTemplateSlug?: string;
|
|
listTemplateSlug?: string;
|
|
}
|
|
|
|
/**
|
|
* Publishing preferences stored in meta/publishing.json.
|
|
* Contains only non-secret connection details that can be shared among collaborators.
|
|
*/
|
|
export interface PublishingPreferences {
|
|
sshHost: string;
|
|
sshUser: string;
|
|
sshRemotePath: string;
|
|
sshMode: 'scp' | 'rsync';
|
|
}
|
|
|
|
export interface CategoryMetadata extends CategoryRenderSettings {
|
|
title: string;
|
|
}
|
|
|
|
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
|
const MIN_MAX_POSTS_PER_PAGE = 1;
|
|
const MAX_MAX_POSTS_PER_PAGE = 500;
|
|
|
|
function sanitizeMaxPostsPerPage(value: unknown): number | undefined {
|
|
if (value === undefined || value === null || value === '') {
|
|
return undefined;
|
|
}
|
|
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) {
|
|
return DEFAULT_MAX_POSTS_PER_PAGE;
|
|
}
|
|
|
|
const rounded = Math.floor(numeric);
|
|
if (rounded < MIN_MAX_POSTS_PER_PAGE) {
|
|
return DEFAULT_MAX_POSTS_PER_PAGE;
|
|
}
|
|
if (rounded > MAX_MAX_POSTS_PER_PAGE) {
|
|
return MAX_MAX_POSTS_PER_PAGE;
|
|
}
|
|
|
|
return rounded;
|
|
}
|
|
|
|
function sanitizePublicUrl(value: unknown): string | undefined {
|
|
if (value === undefined || value === null) {
|
|
return undefined;
|
|
}
|
|
|
|
const trimmed = String(value).trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
function normalizePublishingPreferences(prefs: PublishingPreferences): PublishingPreferences {
|
|
return {
|
|
sshHost: String(prefs.sshHost ?? '').trim(),
|
|
sshUser: String(prefs.sshUser ?? '').trim(),
|
|
sshRemotePath: String(prefs.sshRemotePath ?? '').trim(),
|
|
sshMode: prefs.sshMode === 'rsync' ? 'rsync' : 'scp',
|
|
};
|
|
}
|
|
|
|
function sanitizeCategoryTitle(value: unknown, fallback: string): string {
|
|
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
return trimmed.length > 0 ? trimmed : fallback;
|
|
}
|
|
|
|
type RawCategoryMetadataInput = Record<string, CategoryMetadata | CategoryRenderSettings>;
|
|
|
|
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
|
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
|
|
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
|
|
const blogmarkCategory = typeof metadata.blogmarkCategory === 'string'
|
|
? normalizeNonEmptyTaxonomyTerm(metadata.blogmarkCategory) ?? undefined
|
|
: undefined;
|
|
const pythonRuntimeMode = metadata.pythonRuntimeMode === 'main-thread' ? 'main-thread' : 'webworker';
|
|
const picoTheme = sanitizePicoTheme(metadata.picoTheme);
|
|
const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings);
|
|
return {
|
|
...metadata,
|
|
publicUrl,
|
|
maxPostsPerPage,
|
|
blogmarkCategory,
|
|
pythonRuntimeMode,
|
|
picoTheme,
|
|
categoryMetadata,
|
|
categorySettings: undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Default categories for new projects (from VISION.md)
|
|
*/
|
|
export const DEFAULT_CATEGORIES = ['article', 'picture', 'aside', 'page'];
|
|
|
|
export function getDefaultCategorySettings(): Record<string, CategoryRenderSettings> {
|
|
const defaults = getDefaultCategoryMetadata();
|
|
return Object.fromEntries(
|
|
Object.entries(defaults).map(([category, value]) => [
|
|
category,
|
|
{ renderInLists: value.renderInLists, showTitle: value.showTitle },
|
|
]),
|
|
);
|
|
}
|
|
|
|
export function getDefaultCategoryMetadata(): Record<string, CategoryMetadata> {
|
|
return {
|
|
article: { renderInLists: true, showTitle: true, title: 'article' },
|
|
picture: { renderInLists: true, showTitle: true, title: 'picture' },
|
|
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
|
page: { renderInLists: false, showTitle: true, title: 'page' },
|
|
};
|
|
}
|
|
|
|
function normalizeCategoryMetadata(value: unknown): Record<string, CategoryMetadata> {
|
|
const defaults = getDefaultCategoryMetadata();
|
|
if (!value || typeof value !== 'object') {
|
|
return defaults;
|
|
}
|
|
|
|
const normalized: Record<string, CategoryMetadata> = { ...defaults };
|
|
for (const [rawCategory, rawSettings] of Object.entries(value as RawCategoryMetadataInput)) {
|
|
const category = normalizeTaxonomyTerm(rawCategory);
|
|
if (!category || !rawSettings || typeof rawSettings !== 'object') {
|
|
continue;
|
|
}
|
|
|
|
const settings = rawSettings as unknown as {
|
|
renderInLists?: unknown;
|
|
showTitle?: unknown;
|
|
title?: unknown;
|
|
};
|
|
normalized[category] = {
|
|
renderInLists: settings.renderInLists !== false,
|
|
showTitle: settings.showTitle !== false,
|
|
title: sanitizeCategoryTitle(settings.title, category),
|
|
postTemplateSlug: typeof (settings as any).postTemplateSlug === 'string' ? (settings as any).postTemplateSlug : undefined,
|
|
listTemplateSlug: typeof (settings as any).listTemplateSlug === 'string' ? (settings as any).listTemplateSlug : undefined,
|
|
};
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeCategorySettings(value: unknown): Record<string, CategoryRenderSettings> {
|
|
const metadata = normalizeCategoryMetadata(value);
|
|
return Object.fromEntries(
|
|
Object.entries(metadata).map(([category, data]) => [
|
|
category,
|
|
{
|
|
renderInLists: data.renderInLists,
|
|
showTitle: data.showTitle,
|
|
postTemplateSlug: data.postTemplateSlug,
|
|
listTemplateSlug: data.listTemplateSlug,
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
|
|
function isJsonParseError(error: unknown): boolean {
|
|
return error instanceof SyntaxError;
|
|
}
|
|
|
|
/**
|
|
* MetaEngine manages project metadata like available tags and categories.
|
|
*
|
|
* It keeps metadata in sync between:
|
|
* - The database (derived from posts)
|
|
* - The filesystem (meta/tags.json, meta/categories.json)
|
|
*
|
|
* This enables offline-first operation where all metadata is available
|
|
* from the local filesystem per project.
|
|
*/
|
|
export class MetaEngine extends EventEmitter {
|
|
private currentProjectId: string = 'default';
|
|
private dataDir: string | null = null; // Custom data directory (null = use internal userData)
|
|
private tags: Set<string> = new Set();
|
|
private categories: Set<string> = new Set();
|
|
private projectMetadata: ProjectMetadata | null = null;
|
|
private publishingPreferences: PublishingPreferences | null = null;
|
|
private initialized: boolean = false;
|
|
private startupSyncPromise: Promise<void> | null = null;
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
/**
|
|
* Returns the default internal project directory (in userData).
|
|
*/
|
|
private getDefaultBaseDir(): string {
|
|
const userDataPath = app.getPath('userData');
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
return path.join(this.getMetaDir(), 'categories.json');
|
|
}
|
|
|
|
private getProjectMetadataFilePath(): string {
|
|
return path.join(this.getMetaDir(), 'project.json');
|
|
}
|
|
|
|
private getCategoryMetadataFilePath(): string {
|
|
return path.join(this.getMetaDir(), 'category-meta.json');
|
|
}
|
|
|
|
private getPublishingPreferencesFilePath(): string {
|
|
return path.join(this.getMetaDir(), 'publishing.json');
|
|
}
|
|
|
|
setProjectContext(projectId: string, dataDir?: string): void {
|
|
const nextDataDir = dataDir || null;
|
|
if (this.currentProjectId === projectId && this.dataDir === nextDataDir) {
|
|
return;
|
|
}
|
|
|
|
this.currentProjectId = projectId;
|
|
this.dataDir = nextDataDir;
|
|
// Reset in-memory cache when project changes
|
|
this.tags.clear();
|
|
this.categories.clear();
|
|
this.projectMetadata = null;
|
|
this.publishingPreferences = null;
|
|
this.initialized = false;
|
|
this.startupSyncPromise = null;
|
|
}
|
|
|
|
getProjectContext(): string {
|
|
return this.currentProjectId;
|
|
}
|
|
|
|
/**
|
|
* Get all available tags.
|
|
*/
|
|
async getTags(): Promise<string[]> {
|
|
return Array.from(this.tags).sort();
|
|
}
|
|
|
|
/**
|
|
* Get all available categories.
|
|
*/
|
|
async getCategories(): Promise<string[]> {
|
|
return Array.from(this.categories).sort();
|
|
}
|
|
|
|
/**
|
|
* Get the project metadata.
|
|
*/
|
|
async getProjectMetadata(): Promise<ProjectMetadata | null> {
|
|
return this.projectMetadata;
|
|
}
|
|
|
|
/**
|
|
* Set the project metadata (replaces existing).
|
|
*/
|
|
async setProjectMetadata(metadata: ProjectMetadata): Promise<void> {
|
|
this.projectMetadata = normalizeProjectMetadata({ ...metadata });
|
|
this.projectMetadata.categoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
|
this.projectMetadata.categoryMetadata,
|
|
);
|
|
await this.saveProjectMetadata();
|
|
await this.saveCategoryMetadata();
|
|
this.emit('projectMetadataChanged', this.projectMetadata);
|
|
}
|
|
|
|
/**
|
|
* Update specific fields of project metadata.
|
|
*/
|
|
async updateProjectMetadata(updates: Partial<ProjectMetadata>): Promise<void> {
|
|
const normalizedUpdates: Partial<ProjectMetadata> = { ...updates };
|
|
if (updates.maxPostsPerPage !== undefined) {
|
|
normalizedUpdates.maxPostsPerPage = sanitizeMaxPostsPerPage(updates.maxPostsPerPage);
|
|
}
|
|
if (updates.picoTheme !== undefined) {
|
|
normalizedUpdates.picoTheme = sanitizePicoTheme(updates.picoTheme);
|
|
}
|
|
|
|
if (updates.categoryMetadata !== undefined || updates.categorySettings !== undefined) {
|
|
normalizedUpdates.categoryMetadata = normalizeCategoryMetadata(
|
|
updates.categoryMetadata ?? updates.categorySettings,
|
|
);
|
|
normalizedUpdates.categorySettings = undefined;
|
|
}
|
|
|
|
if (!this.projectMetadata) {
|
|
this.projectMetadata = normalizeProjectMetadata({
|
|
name: normalizedUpdates.name || '',
|
|
description: normalizedUpdates.description,
|
|
dataPath: normalizedUpdates.dataPath,
|
|
publicUrl: normalizedUpdates.publicUrl,
|
|
mainLanguage: normalizedUpdates.mainLanguage,
|
|
defaultAuthor: normalizedUpdates.defaultAuthor,
|
|
maxPostsPerPage: normalizedUpdates.maxPostsPerPage,
|
|
blogmarkCategory: normalizedUpdates.blogmarkCategory,
|
|
pythonRuntimeMode: normalizedUpdates.pythonRuntimeMode,
|
|
picoTheme: normalizedUpdates.picoTheme,
|
|
categoryMetadata: normalizedUpdates.categoryMetadata,
|
|
});
|
|
} else {
|
|
this.projectMetadata = normalizeProjectMetadata({
|
|
...this.projectMetadata,
|
|
...normalizedUpdates,
|
|
});
|
|
}
|
|
this.projectMetadata.categoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
|
this.projectMetadata.categoryMetadata,
|
|
);
|
|
await this.saveProjectMetadata();
|
|
await this.saveCategoryMetadata();
|
|
this.emit('projectMetadataChanged', this.projectMetadata);
|
|
}
|
|
|
|
// ── Publishing Preferences ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Get publishing preferences for the current project.
|
|
*/
|
|
async getPublishingPreferences(): Promise<PublishingPreferences | null> {
|
|
return this.publishingPreferences;
|
|
}
|
|
|
|
/**
|
|
* Set publishing preferences for the current project.
|
|
* Persists to meta/publishing.json so they can be shared across collaborators.
|
|
*/
|
|
async setPublishingPreferences(prefs: PublishingPreferences): Promise<void> {
|
|
this.publishingPreferences = normalizePublishingPreferences(prefs);
|
|
await this.savePublishingPreferences();
|
|
this.emit('publishingPreferencesChanged', this.publishingPreferences);
|
|
}
|
|
|
|
/**
|
|
* Clear publishing preferences for the current project.
|
|
* Removes meta/publishing.json.
|
|
*/
|
|
async clearPublishingPreferences(): Promise<void> {
|
|
this.publishingPreferences = null;
|
|
try {
|
|
const filePath = this.getPublishingPreferencesFilePath();
|
|
await fs.unlink(filePath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.error('[MetaEngine] Failed to delete publishing preferences:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
this.emit('publishingPreferencesChanged', null);
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
const normalizedTag = normalizeTaxonomyTerm(tag);
|
|
if (normalizedTag && !this.tags.has(normalizedTag)) {
|
|
this.tags.add(normalizedTag);
|
|
this.emit('tagsChanged', await this.getTags());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a tag from the available tags list (in-memory only).
|
|
* Note: Tag persistence is handled by TagEngine.
|
|
*/
|
|
async removeTag(tag: string): Promise<void> {
|
|
const normalizedTag = normalizeTaxonomyTerm(tag);
|
|
if (this.tags.delete(normalizedTag)) {
|
|
this.emit('tagsChanged', await this.getTags());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a new category to the available categories list.
|
|
*/
|
|
async addCategory(category: string): Promise<void> {
|
|
const normalizedCategory = normalizeTaxonomyTerm(category);
|
|
if (normalizedCategory && !this.categories.has(normalizedCategory)) {
|
|
this.categories.add(normalizedCategory);
|
|
const currentMetadata = this.projectMetadata;
|
|
if (currentMetadata) {
|
|
const currentCategoryMetadata = normalizeCategoryMetadata(currentMetadata.categoryMetadata ?? currentMetadata.categorySettings);
|
|
if (!currentCategoryMetadata[normalizedCategory]) {
|
|
currentCategoryMetadata[normalizedCategory] = {
|
|
renderInLists: true,
|
|
showTitle: true,
|
|
title: normalizedCategory,
|
|
};
|
|
this.projectMetadata = normalizeProjectMetadata({
|
|
...currentMetadata,
|
|
categoryMetadata: currentCategoryMetadata,
|
|
});
|
|
await this.saveProjectMetadata();
|
|
await this.saveCategoryMetadata();
|
|
}
|
|
}
|
|
this.emit('categoriesChanged', await this.getCategories());
|
|
await this.saveCategories();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a category from the available categories list.
|
|
*/
|
|
async removeCategory(category: string): Promise<void> {
|
|
const normalizedCategory = normalizeTaxonomyTerm(category);
|
|
if (this.categories.delete(normalizedCategory)) {
|
|
const currentMetadata = this.projectMetadata;
|
|
const currentCategoryMetadata = currentMetadata
|
|
? normalizeCategoryMetadata(currentMetadata.categoryMetadata ?? currentMetadata.categorySettings)
|
|
: null;
|
|
if (currentMetadata && currentCategoryMetadata?.[normalizedCategory]) {
|
|
const nextCategoryMetadata = { ...currentCategoryMetadata };
|
|
delete nextCategoryMetadata[normalizedCategory];
|
|
this.projectMetadata = normalizeProjectMetadata({
|
|
...currentMetadata,
|
|
categoryMetadata: nextCategoryMetadata,
|
|
});
|
|
await this.saveProjectMetadata();
|
|
await this.saveCategoryMetadata();
|
|
}
|
|
this.emit('categoriesChanged', await this.getCategories());
|
|
await this.saveCategories();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save categories to the filesystem.
|
|
*/
|
|
async saveCategories(): Promise<void> {
|
|
try {
|
|
await this.ensureMetaDirExists();
|
|
const filePath = this.getCategoriesFilePath();
|
|
await this.writeJsonFileAtomically(filePath, Array.from(this.categories).sort());
|
|
} catch (error) {
|
|
console.error('[MetaEngine] Failed to save categories:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save project metadata to the filesystem.
|
|
*/
|
|
async saveProjectMetadata(): Promise<void> {
|
|
try {
|
|
await this.ensureMetaDirExists();
|
|
const filePath = this.getProjectMetadataFilePath();
|
|
const {
|
|
dataPath: _dataPath,
|
|
categoryMetadata: _categoryMetadata,
|
|
categorySettings: _categorySettings,
|
|
...persistedMetadata
|
|
} = this.projectMetadata || {};
|
|
await this.writeJsonFileAtomically(filePath, persistedMetadata);
|
|
} catch (error) {
|
|
console.error('[MetaEngine] Failed to save project metadata:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save category metadata to the filesystem.
|
|
*/
|
|
async saveCategoryMetadata(): Promise<void> {
|
|
try {
|
|
await this.ensureMetaDirExists();
|
|
const filePath = this.getCategoryMetadataFilePath();
|
|
const metadata = this.ensureCategoryMetadataForKnownCategories(
|
|
this.projectMetadata?.categoryMetadata,
|
|
);
|
|
await this.writeJsonFileAtomically(filePath, metadata);
|
|
} catch (error) {
|
|
console.error('[MetaEngine] Failed to save category metadata:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save publishing preferences to the filesystem.
|
|
*/
|
|
private async savePublishingPreferences(): Promise<void> {
|
|
if (!this.publishingPreferences) {
|
|
return;
|
|
}
|
|
try {
|
|
await this.ensureMetaDirExists();
|
|
const filePath = this.getPublishingPreferencesFilePath();
|
|
await this.writeJsonFileAtomically(filePath, this.publishingPreferences);
|
|
} catch (error) {
|
|
console.error('[MetaEngine] Failed to save publishing preferences:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load publishing preferences from the filesystem.
|
|
*/
|
|
private async loadPublishingPreferences(): Promise<void> {
|
|
try {
|
|
const filePath = this.getPublishingPreferencesFilePath();
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
const parsed = JSON.parse(content) as PublishingPreferences;
|
|
this.publishingPreferences = normalizePublishingPreferences(parsed);
|
|
} catch (error) {
|
|
if (isJsonParseError(error)) {
|
|
console.warn('[MetaEngine] Failed to parse publishing preferences JSON, using null:', error);
|
|
this.publishingPreferences = null;
|
|
return;
|
|
}
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.error('[MetaEngine] Failed to load publishing preferences:', error);
|
|
throw error;
|
|
}
|
|
// File doesn't exist, that's OK
|
|
this.publishingPreferences = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load project metadata from the filesystem.
|
|
*/
|
|
async loadProjectMetadata(): Promise<void> {
|
|
try {
|
|
const filePath = this.getProjectMetadataFilePath();
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
const parsed = JSON.parse(content) as ProjectMetadata;
|
|
this.projectMetadata = normalizeProjectMetadata(parsed);
|
|
} catch (error) {
|
|
if (isJsonParseError(error)) {
|
|
console.warn('[MetaEngine] Failed to parse project metadata JSON, using null metadata:', error);
|
|
this.projectMetadata = null;
|
|
return;
|
|
}
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.error('[MetaEngine] Failed to load project metadata:', error);
|
|
throw error;
|
|
}
|
|
// File doesn't exist, that's OK
|
|
this.projectMetadata = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load category metadata from the filesystem.
|
|
*/
|
|
async loadCategoryMetadata(): Promise<Record<string, CategoryMetadata> | null> {
|
|
try {
|
|
const filePath = this.getCategoryMetadataFilePath();
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
return normalizeCategoryMetadata(parsed);
|
|
} catch (error) {
|
|
if (isJsonParseError(error)) {
|
|
console.warn('[MetaEngine] Failed to parse category metadata JSON, using default metadata merge:', error);
|
|
return null;
|
|
}
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.error('[MetaEngine] Failed to load category metadata:', error);
|
|
throw error;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load categories from the filesystem.
|
|
*/
|
|
async loadCategories(): Promise<void> {
|
|
try {
|
|
const filePath = this.getCategoriesFilePath();
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
const parsed = JSON.parse(content) as string[];
|
|
this.categories.clear();
|
|
for (const cat of parsed) {
|
|
const normalizedCategory = normalizeNonEmptyTaxonomyTerm(cat);
|
|
if (normalizedCategory) {
|
|
this.categories.add(normalizedCategory);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (isJsonParseError(error)) {
|
|
console.warn('[MetaEngine] Failed to parse categories JSON, treating as empty and rebuilding from DB/defaults:', error);
|
|
this.categories.clear();
|
|
return;
|
|
}
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.error('[MetaEngine] Failed to load categories:', error);
|
|
throw error;
|
|
}
|
|
// File doesn't exist, that's OK
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Collect all unique tags from posts in the database.
|
|
*/
|
|
async collectTagsFromPosts(): Promise<string[]> {
|
|
const db = getDatabase().getLocal();
|
|
const dbPosts = await db
|
|
.select({ tags: posts.tags })
|
|
.from(posts)
|
|
.where(eq(posts.projectId, this.currentProjectId))
|
|
.all();
|
|
|
|
return collectNormalizedTermsFromJsonValues(dbPosts.map((row) => row.tags));
|
|
}
|
|
|
|
/**
|
|
* Collect all unique categories from posts in the database.
|
|
*/
|
|
async collectCategoriesFromPosts(): Promise<string[]> {
|
|
const db = getDatabase().getLocal();
|
|
const dbPosts = await db
|
|
.select({ categories: posts.categories })
|
|
.from(posts)
|
|
.where(eq(posts.projectId, this.currentProjectId))
|
|
.all();
|
|
|
|
return collectNormalizedTermsFromJsonValues(dbPosts.map((row) => row.categories));
|
|
}
|
|
|
|
/**
|
|
* Fetch the current project's data from the database.
|
|
*/
|
|
private async fetchProjectFromDatabase(): Promise<{ name: string; description: string | null; dataPath: string | null } | null> {
|
|
const db = getDatabase().getLocal();
|
|
const project = await db
|
|
.select({ name: projects.name, description: projects.description, dataPath: projects.dataPath })
|
|
.from(projects)
|
|
.where(eq(projects.id, this.currentProjectId))
|
|
.get();
|
|
|
|
return project || null;
|
|
}
|
|
|
|
/**
|
|
* Ensure the meta directory exists.
|
|
*/
|
|
private async ensureMetaDirExists(): Promise<void> {
|
|
const metaDir = this.getMetaDir();
|
|
try {
|
|
await fs.access(metaDir);
|
|
} catch {
|
|
await fs.mkdir(metaDir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a file exists.
|
|
*/
|
|
private async fileExists(filePath: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async writeJsonFileAtomically(filePath: string, value: unknown): Promise<void> {
|
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
const content = JSON.stringify(value, null, 2);
|
|
|
|
await fs.writeFile(tempPath, content, 'utf-8');
|
|
|
|
try {
|
|
await fs.rename(tempPath, filePath);
|
|
} catch (error) {
|
|
try {
|
|
await fs.unlink(tempPath);
|
|
} catch {
|
|
// Ignore cleanup errors.
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private ensureCategoryMetadataForKnownCategories(
|
|
categoryMetadata: Record<string, CategoryMetadata> | undefined,
|
|
): Record<string, CategoryMetadata> {
|
|
const merged = normalizeCategoryMetadata(categoryMetadata);
|
|
|
|
for (const category of this.categories) {
|
|
if (!merged[category]) {
|
|
merged[category] = {
|
|
renderInLists: true,
|
|
showTitle: true,
|
|
title: category,
|
|
};
|
|
} else if (!merged[category].title || merged[category].title.trim().length === 0) {
|
|
merged[category].title = category;
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Sync tags and categories on startup.
|
|
*
|
|
* Logic:
|
|
* - Tags: populated from posts (TagEngine handles persistence with colors)
|
|
* - Categories: read from file, merge with database
|
|
* - Project metadata: read from file or create from database
|
|
*/
|
|
async syncOnStartup(): Promise<void> {
|
|
if (this.initialized) {
|
|
return;
|
|
}
|
|
|
|
if (this.startupSyncPromise) {
|
|
await this.startupSyncPromise;
|
|
return;
|
|
}
|
|
|
|
this.startupSyncPromise = this.performSyncOnStartup();
|
|
try {
|
|
await this.startupSyncPromise;
|
|
} finally {
|
|
this.startupSyncPromise = null;
|
|
}
|
|
}
|
|
|
|
private async performSyncOnStartup(): Promise<void> {
|
|
console.log(`[MetaEngine] Syncing metadata for project: ${this.currentProjectId}`);
|
|
|
|
await this.ensureMetaDirExists();
|
|
|
|
const categoriesFilePath = this.getCategoriesFilePath();
|
|
const projectMetadataFilePath = this.getProjectMetadataFilePath();
|
|
const categoryMetadataFilePath = this.getCategoryMetadataFilePath();
|
|
|
|
const categoriesFileExists = await this.fileExists(categoriesFilePath);
|
|
const projectMetadataFileExists = await this.fileExists(projectMetadataFilePath);
|
|
const categoryMetadataFileExists = await this.fileExists(categoryMetadataFilePath);
|
|
|
|
// Collect tags/categories from database (posts)
|
|
const dbTags = await this.collectTagsFromPosts();
|
|
const dbCategories = await this.collectCategoriesFromPosts();
|
|
|
|
// Handle tags - just populate from posts, TagEngine handles persistence
|
|
this.tags.clear();
|
|
for (const tag of dbTags) {
|
|
this.tags.add(tag);
|
|
}
|
|
|
|
// Handle categories
|
|
if (categoriesFileExists) {
|
|
// Load from file
|
|
await this.loadCategories();
|
|
const fileCategories = new Set(this.categories);
|
|
|
|
// Merge: add any categories from DB that aren't in file
|
|
let changed = false;
|
|
for (const cat of dbCategories) {
|
|
if (!fileCategories.has(cat)) {
|
|
this.categories.add(cat);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// Save if there were changes
|
|
if (changed) {
|
|
await this.saveCategories();
|
|
}
|
|
} else {
|
|
// No file exists, create from database or use defaults
|
|
this.categories.clear();
|
|
if (dbCategories.length > 0) {
|
|
for (const cat of dbCategories) {
|
|
this.categories.add(cat);
|
|
}
|
|
} else {
|
|
// New project with no posts - use default categories
|
|
for (const cat of DEFAULT_CATEGORIES) {
|
|
this.categories.add(cat);
|
|
}
|
|
}
|
|
await this.saveCategories();
|
|
}
|
|
|
|
// Handle project metadata
|
|
if (projectMetadataFileExists) {
|
|
await this.loadProjectMetadata();
|
|
if (!this.projectMetadata) {
|
|
const projectData = await this.fetchProjectFromDatabase();
|
|
if (!projectData) {
|
|
throw new Error(`Project not found in database: ${this.currentProjectId}`);
|
|
}
|
|
this.projectMetadata = {
|
|
name: projectData.name,
|
|
description: projectData.description || undefined,
|
|
maxPostsPerPage: DEFAULT_MAX_POSTS_PER_PAGE,
|
|
};
|
|
await this.saveProjectMetadata();
|
|
}
|
|
if (this.projectMetadata?.dataPath !== undefined) {
|
|
const { dataPath: _dataPath, ...metadataWithoutDataPath } = this.projectMetadata;
|
|
this.projectMetadata = metadataWithoutDataPath;
|
|
await this.saveProjectMetadata();
|
|
console.log('[MetaEngine] Removed deprecated dataPath from project.json');
|
|
}
|
|
} else {
|
|
// No file exists, fetch project data from database and create file
|
|
const projectData = await this.fetchProjectFromDatabase();
|
|
if (!projectData) {
|
|
throw new Error(`Project not found in database: ${this.currentProjectId}`);
|
|
}
|
|
this.projectMetadata = {
|
|
name: projectData.name,
|
|
description: projectData.description || undefined,
|
|
maxPostsPerPage: DEFAULT_MAX_POSTS_PER_PAGE,
|
|
};
|
|
await this.saveProjectMetadata();
|
|
}
|
|
|
|
if (this.projectMetadata) {
|
|
const legacyCategoryMetadata = normalizeCategoryMetadata(
|
|
this.projectMetadata.categoryMetadata ?? this.projectMetadata.categorySettings,
|
|
);
|
|
const fileCategoryMetadata = categoryMetadataFileExists
|
|
? await this.loadCategoryMetadata()
|
|
: null;
|
|
const mergedCategoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
|
fileCategoryMetadata ?? legacyCategoryMetadata,
|
|
);
|
|
|
|
this.projectMetadata = normalizeProjectMetadata({
|
|
...this.projectMetadata,
|
|
categoryMetadata: mergedCategoryMetadata,
|
|
});
|
|
|
|
await this.saveProjectMetadata();
|
|
await this.saveCategoryMetadata();
|
|
}
|
|
|
|
// Handle publishing preferences (load from file if it exists)
|
|
await this.loadPublishingPreferences();
|
|
|
|
this.initialized = true;
|
|
console.log(`[MetaEngine] Sync complete. Tags: ${this.tags.size}, Categories: ${this.categories.size}`);
|
|
}
|
|
|
|
/**
|
|
* Check if the engine has been initialized (synced on startup).
|
|
*/
|
|
isInitialized(): boolean {
|
|
return this.initialized;
|
|
}
|
|
}
|
|
|