Fix/typescript problems (#60)
* fix: extended typescript checking to main and fixed all typescript errors * fix: removed unnecessary type --------- Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
@@ -132,18 +132,24 @@ export class DatabaseConnection {
|
||||
}
|
||||
|
||||
async getActiveProject(): Promise<{ id: string; name: string; slug: string } | null> {
|
||||
if (!this.localDb) return null;
|
||||
if (!this.localDb) {
|
||||
return null;
|
||||
}
|
||||
const rows = await this.localDb
|
||||
.select({ id: projects.id, name: projects.name, slug: projects.slug })
|
||||
.from(projects)
|
||||
.where(eq(projects.isActive, true))
|
||||
.limit(1);
|
||||
if (rows.length === 0) return null;
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async setActiveProject(projectId: string): Promise<void> {
|
||||
if (!this.localDb) return;
|
||||
if (!this.localDb) {
|
||||
return;
|
||||
}
|
||||
// Deactivate all projects
|
||||
await this.localDb
|
||||
.update(projects)
|
||||
|
||||
@@ -23,8 +23,12 @@ export function buildApplyValidationArchives(posts: PostData[]): {
|
||||
const yearMonthDays = new Map<string, Date>();
|
||||
|
||||
for (const post of posts) {
|
||||
for (const category of post.categories || []) allCategories.add(category);
|
||||
for (const tag of post.tags || []) allTags.add(tag);
|
||||
for (const category of post.categories || []) {
|
||||
allCategories.add(category);
|
||||
}
|
||||
for (const tag of post.tags || []) {
|
||||
allTags.add(tag);
|
||||
}
|
||||
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const updatedAt = post.updatedAt;
|
||||
@@ -34,13 +38,16 @@ export function buildApplyValidationArchives(posts: PostData[]): {
|
||||
const ymKey = `${year}/${month}`;
|
||||
const ymdKey = `${year}/${month}/${day}`;
|
||||
|
||||
if (!years.has(year) || updatedAt > years.get(year)!) {
|
||||
const existingYear = years.get(year);
|
||||
if (!existingYear || updatedAt > existingYear) {
|
||||
years.set(year, updatedAt);
|
||||
}
|
||||
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
|
||||
const existingYearMonth = yearMonths.get(ymKey);
|
||||
if (!existingYearMonth || updatedAt > existingYearMonth) {
|
||||
yearMonths.set(ymKey, updatedAt);
|
||||
}
|
||||
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
|
||||
const existingYearMonthDay = yearMonthDays.get(ymdKey);
|
||||
if (!existingYearMonthDay || updatedAt > existingYearMonthDay) {
|
||||
yearMonthDays.set(ymdKey, updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { GenerationPostIndex } from './GenerationPostIndexService';
|
||||
import type { TargetedValidationPlan } from './ValidationApplyPlannerService';
|
||||
import type {
|
||||
GenerationWorkerTask,
|
||||
SerializedPostData,
|
||||
SerializedMediaData,
|
||||
SerializedBlogGenerationOptions,
|
||||
} from './GenerationWorkerData';
|
||||
@@ -26,10 +25,15 @@ export interface ApplyValidationWorkerParams {
|
||||
maxPostsPerPage: number;
|
||||
htmlDir: string;
|
||||
mediaItems: SerializedMediaData[];
|
||||
backlinksRecord: Record<string, Array<{ id: string; title: string; slug: string }>>;
|
||||
backlinksRecord: Record<
|
||||
string,
|
||||
Array<{ id: string; title: string; slug: string }>
|
||||
>;
|
||||
hashMapEntries: Array<[string, string]>;
|
||||
postFilePathEntries: Array<[string, string]>;
|
||||
postMediaLinksEntries: Array<[string, Array<{ mediaId: string; sortOrder: number }>]>;
|
||||
postMediaLinksEntries: Array<
|
||||
[string, Array<{ mediaId: string; sortOrder: number }>]
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ApplyValidationLanguageParams {
|
||||
@@ -48,7 +52,12 @@ export function buildApplyValidationWorkerTasks(
|
||||
base: ApplyValidationWorkerParams,
|
||||
lang: ApplyValidationLanguageParams,
|
||||
): GenerationWorkerTask[] {
|
||||
const { targetedPlan, publishedRoutePosts, publishedListPosts, generationPostIndex } = lang;
|
||||
const {
|
||||
targetedPlan,
|
||||
publishedRoutePosts,
|
||||
publishedListPosts,
|
||||
generationPostIndex,
|
||||
} = lang;
|
||||
|
||||
const serializedRoutePosts = publishedRoutePosts.map(serializePostData);
|
||||
const serializedListPosts = publishedListPosts.map(serializePostData);
|
||||
@@ -71,8 +80,11 @@ export function buildApplyValidationWorkerTasks(
|
||||
|
||||
const tasks: GenerationWorkerTask[] = [];
|
||||
let taskCounter = 0;
|
||||
const langSuffix = lang.languagePrefix ? `-${lang.languagePrefix.replace(/^\//, '')}` : '';
|
||||
const nextTaskId = (section: string) => `apply-${section}${langSuffix}-${++taskCounter}`;
|
||||
const langSuffix = lang.languagePrefix
|
||||
? `-${lang.languagePrefix.replace(/^\//, '')}`
|
||||
: '';
|
||||
const nextTaskId = (section: string) =>
|
||||
`apply-${section}${langSuffix}-${++taskCounter}`;
|
||||
|
||||
// Core (root + page routes)
|
||||
if (targetedPlan.requestRootRoutes) {
|
||||
@@ -146,48 +158,62 @@ export function buildApplyValidationWorkerTasks(
|
||||
}
|
||||
|
||||
// Date archives
|
||||
const { requestedYears, requestedYearMonths, requestedYearMonthDays } = targetedPlan;
|
||||
const hasDateRequests = requestedYears.size > 0
|
||||
|| requestedYearMonths.size > 0
|
||||
|| requestedYearMonthDays.size > 0;
|
||||
const { requestedYears, requestedYearMonths, requestedYearMonthDays } =
|
||||
targetedPlan;
|
||||
const hasDateRequests =
|
||||
requestedYears.size > 0 ||
|
||||
requestedYearMonths.size > 0 ||
|
||||
requestedYearMonthDays.size > 0;
|
||||
|
||||
if (hasDateRequests) {
|
||||
// Filter archive maps to only the requested keys
|
||||
const filteredYears = new Map<number, Date>();
|
||||
for (const year of requestedYears) {
|
||||
const lastmod = lang.years.get(year);
|
||||
if (lastmod) filteredYears.set(year, lastmod);
|
||||
if (lastmod) {
|
||||
filteredYears.set(year, lastmod);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredYearMonths = new Map<string, Date>();
|
||||
for (const ym of requestedYearMonths) {
|
||||
const lastmod = lang.yearMonths.get(ym);
|
||||
if (lastmod) filteredYearMonths.set(ym, lastmod);
|
||||
if (lastmod) {
|
||||
filteredYearMonths.set(ym, lastmod);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredYearMonthDays = new Map<string, Date>();
|
||||
for (const ymd of requestedYearMonthDays) {
|
||||
const lastmod = lang.yearMonthDays.get(ymd);
|
||||
if (lastmod) filteredYearMonthDays.set(ymd, lastmod);
|
||||
if (lastmod) {
|
||||
filteredYearMonthDays.set(ymd, lastmod);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter post-index entries to only the requested date keys
|
||||
const filteredPostsByYear = new Map<number, PostData[]>();
|
||||
for (const year of requestedYears) {
|
||||
const posts = generationPostIndex.postsByYear.get(year);
|
||||
if (posts) filteredPostsByYear.set(year, posts);
|
||||
if (posts) {
|
||||
filteredPostsByYear.set(year, posts);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPostsByYearMonth = new Map<string, PostData[]>();
|
||||
for (const ym of requestedYearMonths) {
|
||||
const posts = generationPostIndex.postsByYearMonth.get(ym);
|
||||
if (posts) filteredPostsByYearMonth.set(ym, posts);
|
||||
if (posts) {
|
||||
filteredPostsByYearMonth.set(ym, posts);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPostsByYearMonthDay = new Map<string, PostData[]>();
|
||||
for (const ymd of requestedYearMonthDays) {
|
||||
const posts = generationPostIndex.postsByYearMonthDay.get(ymd);
|
||||
if (posts) filteredPostsByYearMonthDay.set(ymd, posts);
|
||||
if (posts) {
|
||||
filteredPostsByYearMonthDay.set(ymd, posts);
|
||||
}
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -164,8 +164,15 @@ async function getConfiguredPythonRuntimeModeFromEngine(metaEngine: MetaEngine):
|
||||
return resolvePythonRuntimeMode((metadata as { pythonRuntimeMode?: unknown } | null)?.pythonRuntimeMode);
|
||||
}
|
||||
|
||||
type PyodideLikeRuntime = {
|
||||
globals: {
|
||||
set: (name: string, value: unknown) => void;
|
||||
};
|
||||
runPythonAsync: (code: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
class PythonBlogmarkTransformExecutor implements BlogmarkTransformExecutor {
|
||||
private runtimePromise: Promise<any> | null = null;
|
||||
private runtimePromise: Promise<PyodideLikeRuntime> | null = null;
|
||||
|
||||
async runTransform(script: BlogmarkTransformScriptRecord, input: BlogmarkTransformInput): Promise<unknown> {
|
||||
const runtime = await this.getRuntime();
|
||||
@@ -222,15 +229,16 @@ json.dumps(_result)
|
||||
};
|
||||
}
|
||||
|
||||
private async getRuntime(): Promise<any> {
|
||||
private async getRuntime(): Promise<PyodideLikeRuntime> {
|
||||
if (!this.runtimePromise) {
|
||||
this.runtimePromise = (async () => {
|
||||
const pyodideModule = await import('pyodide');
|
||||
return pyodideModule.loadPyodide();
|
||||
return (await pyodideModule.loadPyodide()) as unknown as PyodideLikeRuntime;
|
||||
})();
|
||||
}
|
||||
|
||||
return this.runtimePromise;
|
||||
const runtimePromise = this.runtimePromise;
|
||||
return runtimePromise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,9 +276,10 @@ export class BlogmarkTransformService {
|
||||
post: parsedInput,
|
||||
};
|
||||
|
||||
const scriptEngine = this.dependencies.scriptEngine;
|
||||
const provider = this.dependencies.provider
|
||||
?? (this.dependencies.scriptEngine
|
||||
? { getScripts: (): Promise<ScriptData[]> => this.dependencies.scriptEngine!.getAllScripts() }
|
||||
?? (scriptEngine
|
||||
? { getScripts: (): Promise<ScriptData[]> => scriptEngine.getAllScripts() }
|
||||
: { getScripts: async () => [] });
|
||||
const executor = this.dependencies.executor ?? await this.resolveExecutorForConfiguredRuntime();
|
||||
|
||||
@@ -340,9 +349,10 @@ export class BlogmarkTransformService {
|
||||
}
|
||||
|
||||
private async resolveExecutorForConfiguredRuntime(): Promise<BlogmarkTransformExecutor> {
|
||||
const metaEngine = this.dependencies.metaEngine;
|
||||
const resolveMode = this.dependencies.resolvePythonRuntimeMode
|
||||
?? (this.dependencies.metaEngine
|
||||
? () => getConfiguredPythonRuntimeModeFromEngine(this.dependencies.metaEngine!)
|
||||
?? (metaEngine
|
||||
? () => getConfiguredPythonRuntimeModeFromEngine(metaEngine)
|
||||
: () => Promise.resolve<PythonRuntimeMode>('webworker'));
|
||||
const mode = await resolveMode();
|
||||
const executors = this.dependencies.executors ?? {};
|
||||
|
||||
@@ -21,7 +21,10 @@ export interface CliNotifier {
|
||||
|
||||
/** Used by the Electron app. All notify calls are instant no-ops. */
|
||||
export class NoopNotifier implements CliNotifier {
|
||||
async notify(_entity: NotifyEntity, _id: string, _action: NotifyAction): Promise<void> {
|
||||
async notify(entity: NotifyEntity, id: string, action: NotifyAction): Promise<void> {
|
||||
void entity;
|
||||
void id;
|
||||
void action;
|
||||
// intentional no-op
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* so no database access is needed for post/media queries.
|
||||
*/
|
||||
import type { PostData, PostFilter, PostTranslationData } from './PostEngine';
|
||||
import type { PublishedTranslationVariant } from './BlogGenerationEngine';
|
||||
import type { MediaData } from './MediaEngine';
|
||||
import { readPostFile } from './postFileUtils';
|
||||
import { readPostTranslationFile } from './postTranslationFileUtils';
|
||||
@@ -18,7 +19,10 @@ export interface DataBackedPostEngineInit {
|
||||
/** All posts (published snapshots + translation variants). */
|
||||
allPosts: PostData[];
|
||||
/** Pre-resolved backlinks: postId → linking posts. */
|
||||
backlinksMap?: Map<string, Array<{ id: string; title: string; slug: string }>>;
|
||||
backlinksMap?: Map<
|
||||
string,
|
||||
Array<{ id: string; title: string; slug: string }>
|
||||
>;
|
||||
/** Post file paths for lazy content loading from filesystem: postId → absoluteFilePath. */
|
||||
postFilePaths?: Map<string, string>;
|
||||
}
|
||||
@@ -28,15 +32,27 @@ export interface DataBackedPostEngineContract {
|
||||
getPublishedVersion: (id: string) => Promise<PostData | null>;
|
||||
getPost: (id: string) => Promise<PostData | null>;
|
||||
hasPublishedVersion: (id: string) => Promise<boolean>;
|
||||
findPublishedBySlug: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
|
||||
getLinkedBy: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||
getAllBacklinks: () => Promise<Map<string, Array<{ id: string; title: string; slug: string }>>>;
|
||||
getPostTranslation: (postId: string, language: string) => Promise<PostTranslationData | null>;
|
||||
findPublishedBySlug: (
|
||||
slug: string,
|
||||
dateFilter?: { year: number; month: number },
|
||||
) => Promise<PostData | null>;
|
||||
getLinkedBy: (
|
||||
postId: string,
|
||||
) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||
getAllBacklinks: () => Promise<
|
||||
Map<string, Array<{ id: string; title: string; slug: string }>>
|
||||
>;
|
||||
getPostTranslation: (
|
||||
postId: string,
|
||||
language: string,
|
||||
) => Promise<PostTranslationData | null>;
|
||||
getPostTranslations: (postId: string) => Promise<PostTranslationData[]>;
|
||||
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||
}
|
||||
|
||||
export function createDataBackedPostEngine(init: DataBackedPostEngineInit): DataBackedPostEngineContract {
|
||||
export function createDataBackedPostEngine(
|
||||
init: DataBackedPostEngineInit,
|
||||
): DataBackedPostEngineContract {
|
||||
const { allPosts, backlinksMap, postFilePaths } = init;
|
||||
|
||||
// Build indexes for fast lookups
|
||||
@@ -53,37 +69,54 @@ export function createDataBackedPostEngine(init: DataBackedPostEngineInit): Data
|
||||
}
|
||||
|
||||
function matchesFilter(post: PostData, filter: PostFilter): boolean {
|
||||
if (filter.status && post.status !== filter.status) return false;
|
||||
if (filter.status && post.status !== filter.status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
|
||||
const excluded = new Set(filter.excludeCategories);
|
||||
if (post.categories.some((c) => excluded.has(c))) return false;
|
||||
if (post.categories.some((c) => excluded.has(c))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.categories && filter.categories.length > 0) {
|
||||
if (!filter.categories.some((c) => post.categories.includes(c))) return false;
|
||||
if (!filter.categories.some((c) => post.categories.includes(c)))
|
||||
{return false;}
|
||||
}
|
||||
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
if (!filter.tags.every((t) => post.tags.includes(t))) return false;
|
||||
if (!filter.tags.every((t) => post.tags.includes(t))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.language && post.language !== filter.language) return false;
|
||||
if (filter.language && post.language !== filter.language) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.year !== undefined) {
|
||||
if (post.createdAt.getFullYear() !== filter.year) return false;
|
||||
if (post.createdAt.getFullYear() !== filter.year) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.month !== undefined) {
|
||||
if (post.createdAt.getMonth() + 1 !== filter.month) return false;
|
||||
if (post.createdAt.getMonth() + 1 !== filter.month) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.startDate) {
|
||||
if (post.createdAt < filter.startDate) return false;
|
||||
if (post.createdAt < filter.startDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.endDate) {
|
||||
if (post.createdAt > filter.endDate) return false;
|
||||
if (post.createdAt > filter.endDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -91,10 +124,14 @@ export function createDataBackedPostEngine(init: DataBackedPostEngineInit): Data
|
||||
|
||||
// Shared lazy content loader for posts with empty content
|
||||
async function lazyLoadContent(post: PostData): Promise<void> {
|
||||
if (post.content || !postFilePaths) return;
|
||||
if (post.content || !postFilePaths) {
|
||||
return;
|
||||
}
|
||||
const variant = post as PostData & { translationFilePath?: string };
|
||||
if (variant.translationFilePath) {
|
||||
const fileData = await readPostTranslationFile(variant.translationFilePath);
|
||||
const fileData = await readPostTranslationFile(
|
||||
variant.translationFilePath,
|
||||
);
|
||||
if (fileData) {
|
||||
post.content = fileData.content;
|
||||
}
|
||||
@@ -113,7 +150,8 @@ export function createDataBackedPostEngine(init: DataBackedPostEngineInit): Data
|
||||
async getPostsFiltered(filter: PostFilter): Promise<PostData[]> {
|
||||
const filtered = allPosts
|
||||
.filter((post) => {
|
||||
const tss = (post as any).translationSourceSlug;
|
||||
const tss = (post as PublishedTranslationVariant)
|
||||
.translationSourceSlug;
|
||||
// Keep canonical posts and resolved posts (slug === tss).
|
||||
// Exclude translation variant route posts (slug !== tss, e.g. "my-post.en").
|
||||
return !tss || post.slug === tss;
|
||||
@@ -130,7 +168,9 @@ export function createDataBackedPostEngine(init: DataBackedPostEngineInit): Data
|
||||
|
||||
async getPublishedVersion(id: string): Promise<PostData | null> {
|
||||
const post = byId.get(id);
|
||||
if (!post) return null;
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await lazyLoadContent(post);
|
||||
|
||||
@@ -149,36 +189,58 @@ export function createDataBackedPostEngine(init: DataBackedPostEngineInit): Data
|
||||
return byId.has(id);
|
||||
},
|
||||
|
||||
async findPublishedBySlug(slug: string, dateFilter?: { year: number; month: number }): Promise<PostData | null> {
|
||||
async findPublishedBySlug(
|
||||
slug: string,
|
||||
dateFilter?: { year: number; month: number },
|
||||
): Promise<PostData | null> {
|
||||
const candidates = bySlug.get(slug);
|
||||
if (!candidates || candidates.length === 0) return null;
|
||||
if (!candidates || candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!dateFilter) return candidates[0];
|
||||
if (!dateFilter) {
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
return candidates.find((p) =>
|
||||
p.createdAt.getFullYear() === dateFilter.year
|
||||
&& p.createdAt.getMonth() === dateFilter.month - 1,
|
||||
) ?? null;
|
||||
return (
|
||||
candidates.find(
|
||||
(p) =>
|
||||
p.createdAt.getFullYear() === dateFilter.year &&
|
||||
p.createdAt.getMonth() === dateFilter.month - 1,
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
|
||||
async getLinkedBy(postId: string): Promise<Array<{ id: string; title: string; slug: string }>> {
|
||||
async getLinkedBy(
|
||||
postId: string,
|
||||
): Promise<Array<{ id: string; title: string; slug: string }>> {
|
||||
return backlinksMap?.get(postId) ?? [];
|
||||
},
|
||||
|
||||
async getAllBacklinks(): Promise<Map<string, Array<{ id: string; title: string; slug: string }>>> {
|
||||
async getAllBacklinks(): Promise<
|
||||
Map<string, Array<{ id: string; title: string; slug: string }>>
|
||||
> {
|
||||
return backlinksMap ?? new Map();
|
||||
},
|
||||
|
||||
async getPostTranslation(_postId: string, _language: string): Promise<PostTranslationData | null> {
|
||||
async getPostTranslation(
|
||||
postId: string,
|
||||
language: string,
|
||||
): Promise<PostTranslationData | null> {
|
||||
void postId;
|
||||
void language;
|
||||
// Translation variants are already included as separate route posts
|
||||
return null;
|
||||
},
|
||||
|
||||
async getPostTranslations(_postId: string): Promise<PostTranslationData[]> {
|
||||
async getPostTranslations(postId: string): Promise<PostTranslationData[]> {
|
||||
void postId;
|
||||
return [];
|
||||
},
|
||||
|
||||
setProjectContext(_projectId: string, _dataDir?: string): void {
|
||||
setProjectContext(projectId: string, dataDir?: string): void {
|
||||
void projectId;
|
||||
void dataDir;
|
||||
// No-op — data is already loaded
|
||||
},
|
||||
};
|
||||
@@ -190,7 +252,11 @@ export function createDataBackedPostEngine(init: DataBackedPostEngineInit): Data
|
||||
|
||||
export interface DataBackedMediaEngineContract {
|
||||
getAllMedia: () => Promise<MediaData[]>;
|
||||
setProjectContext: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
||||
setProjectContext: (
|
||||
projectId: string,
|
||||
dataDir?: string,
|
||||
internalDir?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function createDataBackedMediaEngine(
|
||||
@@ -200,7 +266,14 @@ export function createDataBackedMediaEngine(
|
||||
async getAllMedia() {
|
||||
return mediaItems;
|
||||
},
|
||||
setProjectContext(_projectId: string, _dataDir?: string, _internalDir?: string): void {
|
||||
setProjectContext(
|
||||
projectId: string,
|
||||
dataDir?: string,
|
||||
internalDir?: string,
|
||||
): void {
|
||||
void projectId;
|
||||
void dataDir;
|
||||
void internalDir;
|
||||
// No-op
|
||||
},
|
||||
};
|
||||
@@ -217,11 +290,17 @@ export interface DataBackedPostMediaEngineInit {
|
||||
|
||||
export interface DataBackedPostMediaEngineContract {
|
||||
setProjectContext: (projectId: string) => void;
|
||||
getLinkedMediaForPost: (postId: string) => Promise<Array<{ mediaId: string; sortOrder: number }>>;
|
||||
getLinkedMediaDataForPost: (postId: string) => Promise<Array<{ media: MediaData }>>;
|
||||
getLinkedMediaForPost: (
|
||||
postId: string,
|
||||
) => Promise<Array<{ mediaId: string; sortOrder: number }>>;
|
||||
getLinkedMediaDataForPost: (
|
||||
postId: string,
|
||||
) => Promise<Array<{ media: MediaData }>>;
|
||||
}
|
||||
|
||||
export function createDataBackedPostMediaEngine(init: DataBackedPostMediaEngineInit): DataBackedPostMediaEngineContract {
|
||||
export function createDataBackedPostMediaEngine(
|
||||
init: DataBackedPostMediaEngineInit,
|
||||
): DataBackedPostMediaEngineContract {
|
||||
const { mediaItems, postMediaLinks } = init;
|
||||
const mediaById = new Map<string, MediaData>();
|
||||
for (const m of mediaItems) {
|
||||
@@ -229,13 +308,18 @@ export function createDataBackedPostMediaEngine(init: DataBackedPostMediaEngineI
|
||||
}
|
||||
|
||||
return {
|
||||
setProjectContext(_projectId: string): void {
|
||||
setProjectContext(projectId: string): void {
|
||||
void projectId;
|
||||
// No-op
|
||||
},
|
||||
async getLinkedMediaForPost(postId: string): Promise<Array<{ mediaId: string; sortOrder: number }>> {
|
||||
async getLinkedMediaForPost(
|
||||
postId: string,
|
||||
): Promise<Array<{ mediaId: string; sortOrder: number }>> {
|
||||
return postMediaLinks.get(postId) ?? [];
|
||||
},
|
||||
async getLinkedMediaDataForPost(postId: string): Promise<Array<{ media: MediaData }>> {
|
||||
async getLinkedMediaDataForPost(
|
||||
postId: string,
|
||||
): Promise<Array<{ media: MediaData }>> {
|
||||
const links = postMediaLinks.get(postId) ?? [];
|
||||
const result: Array<{ media: MediaData }> = [];
|
||||
for (const link of links) {
|
||||
|
||||
@@ -89,7 +89,9 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
// Lifecycle
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.pipeline) return;
|
||||
if (this.pipeline) {
|
||||
return;
|
||||
}
|
||||
if (this.pipelineLoadPromise) {
|
||||
await this.pipelineLoadPromise;
|
||||
return;
|
||||
@@ -144,7 +146,9 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
// Project switching
|
||||
|
||||
async setProjectContext(projectId: string): Promise<void> {
|
||||
if (this.currentProjectId === projectId) return;
|
||||
if (this.currentProjectId === projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save and unload current index
|
||||
if (this.index && this.currentProjectId) {
|
||||
@@ -163,8 +167,12 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
private async ensureIndexLoaded(): Promise<void> {
|
||||
if (this.index) return;
|
||||
if (!this.currentProjectId) return;
|
||||
if (this.index) {
|
||||
return;
|
||||
}
|
||||
if (!this.currentProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { Index, MetricKind, ScalarKind } = await import('usearch');
|
||||
this.index = new Index({
|
||||
@@ -221,14 +229,16 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
await this.initialize();
|
||||
await this.ensureIndexLoaded();
|
||||
|
||||
if (!this.index || !this.pipeline || !this.currentProjectId) return;
|
||||
if (!this.index || !this.pipeline || !this.currentProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawText = `${title}\n\n${content}`;
|
||||
const hash = this.computeHash(rawText);
|
||||
|
||||
// Check if already indexed with same hash (no-op)
|
||||
const db = getDatabase().getLocal();
|
||||
const existing = await db
|
||||
const existingKey = await db
|
||||
.select()
|
||||
.from(embeddingKeys)
|
||||
.where(
|
||||
@@ -236,15 +246,16 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
eq(embeddingKeys.postId, postId),
|
||||
eq(embeddingKeys.projectId, this.currentProjectId),
|
||||
),
|
||||
);
|
||||
)
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (existing.length > 0 && existing[0]!.contentHash === hash) {
|
||||
if (existingKey && existingKey.contentHash === hash) {
|
||||
return; // Unchanged, skip re-embedding
|
||||
}
|
||||
|
||||
// Remove old vector if exists
|
||||
if (existing.length > 0) {
|
||||
const oldLabel = BigInt(existing[0]!.label);
|
||||
if (existingKey) {
|
||||
const oldLabel = BigInt(existingKey.label);
|
||||
try {
|
||||
this.index.remove(oldLabel);
|
||||
} catch {
|
||||
@@ -286,10 +297,14 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
|
||||
async removePost(postId: string): Promise<void> {
|
||||
await this.ensureIndexLoaded();
|
||||
if (!this.index || !this.currentProjectId) return;
|
||||
if (!this.index || !this.currentProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = this.postIdToLabel.get(postId);
|
||||
if (label === undefined) return;
|
||||
if (label === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.index.remove(label);
|
||||
@@ -313,28 +328,46 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
|
||||
async findSimilar(postId: string, k = 5): Promise<SimilarPost[]> {
|
||||
await this.ensureIndexLoaded();
|
||||
if (!this.index || !this.currentProjectId) return [];
|
||||
if (!this.index || !this.currentProjectId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.postIdToLabel.has(postId)) return [];
|
||||
if (!this.postIdToLabel.has(postId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Guard against empty index (USearch throws on empty index search)
|
||||
if (this.postIdToLabel.size < 2) return [];
|
||||
if (this.postIdToLabel.size < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get or compute vector for this post
|
||||
const vector = await this.getOrComputeVector(postId);
|
||||
if (!vector) return [];
|
||||
if (!vector) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Search for k+1 (to exclude self) with HNSW
|
||||
const result = this.index.search(vector, k + 1, 0);
|
||||
if (!result) return [];
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: SimilarPost[] = [];
|
||||
for (let i = 0; i < result.keys.length; i++) {
|
||||
const foundLabel = result.keys[i]!;
|
||||
const foundLabel = result.keys[i];
|
||||
if (foundLabel === undefined) {
|
||||
continue;
|
||||
}
|
||||
const foundPostId = this.labelToPostId.get(foundLabel);
|
||||
if (!foundPostId || foundPostId === postId) continue;
|
||||
if (!foundPostId || foundPostId === postId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = result.distances[i]!;
|
||||
const distance = result.distances[i];
|
||||
if (distance === undefined) {
|
||||
continue;
|
||||
}
|
||||
// USearch cosine metric returns distance (0=identical), convert to similarity
|
||||
const similarity = Math.max(0, 1 - distance);
|
||||
results.push({ postId: foundPostId, similarity });
|
||||
@@ -349,16 +382,24 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
*/
|
||||
async computeSimilarities(sourcePostId: string, targetPostIds: string[]): Promise<Record<string, number>> {
|
||||
await this.ensureIndexLoaded();
|
||||
if (!this.index || !this.currentProjectId || targetPostIds.length === 0) return {};
|
||||
if (!this.index || !this.currentProjectId || targetPostIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sourceVec = await this.getOrComputeVector(sourcePostId);
|
||||
if (!sourceVec) return {};
|
||||
if (!sourceVec) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
for (const targetId of targetPostIds) {
|
||||
if (targetId === sourcePostId) continue;
|
||||
if (targetId === sourcePostId) {
|
||||
continue;
|
||||
}
|
||||
const targetVec = await this.getOrComputeVector(targetId);
|
||||
if (!targetVec) continue;
|
||||
if (!targetVec) {
|
||||
continue;
|
||||
}
|
||||
result[targetId] = this.cosineSimilarity(sourceVec, targetVec);
|
||||
}
|
||||
return result;
|
||||
@@ -367,9 +408,11 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
private cosineSimilarity(a: Float32Array, b: Float32Array): number {
|
||||
let dot = 0, normA = 0, normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i]! * b[i]!;
|
||||
normA += a[i]! * a[i]!;
|
||||
normB += b[i]! * b[i]!;
|
||||
const valueA = a[i] ?? 0;
|
||||
const valueB = b[i] ?? 0;
|
||||
dot += valueA * valueB;
|
||||
normA += valueA * valueA;
|
||||
normB += valueB * valueB;
|
||||
}
|
||||
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
return denom === 0 ? 0 : Math.max(0, dot / denom);
|
||||
@@ -379,9 +422,13 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
|
||||
async suggestTags(postId: string, excludeTags: string[]): Promise<TagSuggestion[]> {
|
||||
const similar = await this.findSimilar(postId, 10);
|
||||
if (similar.length === 0) return [];
|
||||
if (similar.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.currentProjectId) return [];
|
||||
if (!this.currentProjectId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get tags for similar posts
|
||||
const similarPostIds = similar.map((s) => s.postId);
|
||||
@@ -396,11 +443,15 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
|
||||
for (const row of postRows) {
|
||||
const simItem = similar.find((s) => s.postId === row.id);
|
||||
if (!simItem) continue;
|
||||
if (!simItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const postTags: string[] = JSON.parse(row.tags || '[]');
|
||||
for (const tag of postTags) {
|
||||
if (excludeSet.has(tag.toLowerCase())) continue;
|
||||
if (excludeSet.has(tag.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
const current = tagScores.get(tag) || 0;
|
||||
tagScores.set(tag, current + simItem.similarity);
|
||||
}
|
||||
@@ -414,7 +465,9 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
|
||||
async findDuplicates(threshold = 0.92, onProgress?: (checked: number, total: number) => void): Promise<DuplicatePair[]> {
|
||||
await this.ensureIndexLoaded();
|
||||
if (!this.index || !this.currentProjectId) return [];
|
||||
if (!this.index || !this.currentProjectId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const projectId = this.currentProjectId;
|
||||
const db = getDatabase().getLocal();
|
||||
@@ -432,7 +485,9 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
|
||||
// Get post info for all indexed posts
|
||||
const allPostIds = Array.from(this.postIdToLabel.keys());
|
||||
if (allPostIds.length === 0) return [];
|
||||
if (allPostIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const postRows = await db
|
||||
.select({
|
||||
@@ -453,9 +508,13 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
const bodyCache = new Map<string, string>();
|
||||
const getBody = async (postId: string): Promise<string> => {
|
||||
const cached = bodyCache.get(postId);
|
||||
if (cached !== undefined) return cached;
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const post = postMap.get(postId);
|
||||
if (!post) { bodyCache.set(postId, ''); return ''; }
|
||||
if (!post) {
|
||||
bodyCache.set(postId, ''); return '';
|
||||
}
|
||||
// Draft content is in the DB; published content is on the filesystem
|
||||
if (post.content) {
|
||||
bodyCache.set(postId, post.content);
|
||||
@@ -480,30 +539,51 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
const seenPairs = new Set<string>();
|
||||
|
||||
for (let idx = 0; idx < allPostIds.length; idx++) {
|
||||
const postId = allPostIds[idx]!;
|
||||
const postId = allPostIds[idx];
|
||||
if (!postId) {
|
||||
continue;
|
||||
}
|
||||
onProgress?.(idx + 1, allPostIds.length);
|
||||
const vector = await this.getOrComputeVector(postId);
|
||||
if (!vector) continue;
|
||||
if (!vector) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = this.index.search(vector, 21, 0);
|
||||
if (!result) continue;
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let i = 0; i < result.keys.length; i++) {
|
||||
const otherLabel = result.keys[i]!;
|
||||
const otherLabel = result.keys[i];
|
||||
if (otherLabel === undefined) {
|
||||
continue;
|
||||
}
|
||||
const otherPostId = this.labelToPostId.get(otherLabel);
|
||||
if (!otherPostId || otherPostId === postId) continue;
|
||||
if (!otherPostId || otherPostId === postId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = result.distances[i]!;
|
||||
const distance = result.distances[i];
|
||||
if (distance === undefined) {
|
||||
continue;
|
||||
}
|
||||
const similarity = Math.max(0, 1 - distance);
|
||||
if (similarity < threshold) continue;
|
||||
if (similarity < threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = this.pairKey(postId, otherPostId);
|
||||
if (seenPairs.has(key) || dismissedSet.has(key)) continue;
|
||||
if (seenPairs.has(key) || dismissedSet.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seenPairs.add(key);
|
||||
|
||||
const postA = postMap.get(postId);
|
||||
const postB = postMap.get(otherPostId);
|
||||
if (!postA || !postB) continue;
|
||||
if (!postA || !postB) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pairs.push({
|
||||
postA: {
|
||||
@@ -537,14 +617,20 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
return pairs.sort((a, b) => {
|
||||
if (a.exactMatch && !b.exactMatch) return -1;
|
||||
if (!a.exactMatch && b.exactMatch) return 1;
|
||||
if (a.exactMatch && !b.exactMatch) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.exactMatch && b.exactMatch) {
|
||||
return 1;
|
||||
}
|
||||
return b.similarity - a.similarity;
|
||||
});
|
||||
}
|
||||
|
||||
async dismissPair(postIdA: string, postIdB: string): Promise<void> {
|
||||
if (!this.currentProjectId) return;
|
||||
if (!this.currentProjectId) {
|
||||
return;
|
||||
}
|
||||
const db = getDatabase().getLocal();
|
||||
const [a, b] = this.sortedPairIds(postIdA, postIdB);
|
||||
await db.insert(dismissedDuplicatePairs).values({
|
||||
@@ -557,12 +643,15 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
async dismissPairs(pairIds: Array<[string, string]>): Promise<void> {
|
||||
if (!this.currentProjectId) return;
|
||||
if (!this.currentProjectId) {
|
||||
return;
|
||||
}
|
||||
const db = getDatabase().getLocal();
|
||||
const now = new Date();
|
||||
const currentProjectId = this.currentProjectId;
|
||||
const rows = pairIds.map(([idA, idB]) => {
|
||||
const [a, b] = this.sortedPairIds(idA, idB);
|
||||
return { id: uuidv4(), projectId: this.currentProjectId!, postIdA: a, postIdB: b, dismissedAt: now };
|
||||
return { id: uuidv4(), projectId: currentProjectId, postIdA: a, postIdB: b, dismissedAt: now };
|
||||
});
|
||||
// Insert in batches of 100 to avoid SQLite variable limits
|
||||
for (let i = 0; i < rows.length; i += 100) {
|
||||
@@ -573,7 +662,9 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
// Indexing management
|
||||
|
||||
async getIndexingProgress(): Promise<{ indexed: number; total: number }> {
|
||||
if (!this.currentProjectId) return { indexed: 0, total: 0 };
|
||||
if (!this.currentProjectId) {
|
||||
return { indexed: 0, total: 0 };
|
||||
}
|
||||
await this.ensureIndexLoaded();
|
||||
|
||||
const db = getDatabase().getLocal();
|
||||
@@ -589,7 +680,9 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
|
||||
async reindexAll(onProgress?: (indexed: number, total: number) => void): Promise<void> {
|
||||
await this.ensureIndexLoaded();
|
||||
if (!this.currentProjectId) return;
|
||||
if (!this.currentProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
@@ -616,7 +709,9 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
async indexUnindexedPosts(onProgress?: (indexed: number, total: number) => void): Promise<void> {
|
||||
await this.initialize();
|
||||
await this.ensureIndexLoaded();
|
||||
if (!this.currentProjectId) return;
|
||||
if (!this.currentProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDatabase().getLocal();
|
||||
const allPosts = await db
|
||||
@@ -688,7 +783,9 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
if (!this.index || !this.currentProjectId) return;
|
||||
if (!this.index || !this.currentProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indexPath = this.deps.getIndexPath(this.currentProjectId);
|
||||
const dir = path.dirname(indexPath);
|
||||
@@ -697,7 +794,9 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
private scheduleSave(): void {
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
}
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.save().catch((err) => console.error('[EmbeddingEngine] save error:', err));
|
||||
}, this.SAVE_DEBOUNCE_MS);
|
||||
@@ -711,14 +810,20 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
*/
|
||||
private async getOrComputeVector(postId: string): Promise<Float32Array | null> {
|
||||
const cached = this.vectorCache.get(postId);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Re-embed from post content
|
||||
await this.initialize();
|
||||
if (!this.pipeline || !this.currentProjectId) return null;
|
||||
if (!this.pipeline || !this.currentProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved = await this.resolvePostContent(postId);
|
||||
if (!resolved) return null;
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawText = `${resolved.title}\n\n${resolved.content}`;
|
||||
const text = `query: ${rawText}`;
|
||||
@@ -728,7 +833,9 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
private async embedText(text: string): Promise<Float32Array> {
|
||||
if (!this.pipeline) throw new Error('EmbeddingEngine not initialized');
|
||||
if (!this.pipeline) {
|
||||
throw new Error('EmbeddingEngine not initialized');
|
||||
}
|
||||
return this.pipeline.embed(text);
|
||||
}
|
||||
|
||||
@@ -737,15 +844,24 @@ export class EmbeddingEngine extends EventEmitter {
|
||||
* Draft posts have content in the DB; published posts have it on the filesystem.
|
||||
*/
|
||||
private async resolvePostContent(postId: string): Promise<{ title: string; content: string } | null> {
|
||||
if (!this.currentProjectId) return null;
|
||||
if (!this.currentProjectId) {
|
||||
return null;
|
||||
}
|
||||
const db = getDatabase().getLocal();
|
||||
const rows = await db
|
||||
.select({ title: posts.title, content: posts.content, filePath: posts.filePath })
|
||||
.from(posts)
|
||||
.where(and(eq(posts.id, postId), eq(posts.projectId, this.currentProjectId)));
|
||||
if (rows.length === 0) return null;
|
||||
const post = rows[0]!;
|
||||
if (post.content) return { title: post.title, content: post.content };
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const post = rows[0];
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
if (post.content) {
|
||||
return { title: post.title, content: post.content };
|
||||
}
|
||||
if (post.filePath) {
|
||||
try {
|
||||
const raw = await fs.readFile(post.filePath, 'utf-8');
|
||||
|
||||
@@ -15,7 +15,9 @@ async function resolvePublishedVersions(
|
||||
postEngine: GenerationSnapshotPostEngine,
|
||||
ids: string[],
|
||||
): Promise<Map<string, PostData>> {
|
||||
if (ids.length === 0) return new Map();
|
||||
if (ids.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
if (postEngine.getPublishedVersionsBulk) {
|
||||
return postEngine.getPublishedVersionsBulk(ids);
|
||||
@@ -29,7 +31,9 @@ async function resolvePublishedVersions(
|
||||
}),
|
||||
);
|
||||
for (const { id, version } of entries) {
|
||||
if (version) result.set(id, version);
|
||||
if (version) {
|
||||
result.set(id, version);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -42,8 +46,12 @@ export async function loadPublishedGenerationSets(
|
||||
const draftCandidates = await postEngine.getPostsFiltered({ status: 'draft' });
|
||||
|
||||
const allIds = new Set<string>();
|
||||
for (const p of publishedCandidates) allIds.add(p.id);
|
||||
for (const p of draftCandidates) allIds.add(p.id);
|
||||
for (const p of publishedCandidates) {
|
||||
allIds.add(p.id);
|
||||
}
|
||||
for (const p of draftCandidates) {
|
||||
allIds.add(p.id);
|
||||
}
|
||||
|
||||
const publishedVersions = await resolvePublishedVersions(postEngine, Array.from(allIds));
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { CategoryRenderSettings, HtmlRewriteContext } from './PageRenderer'
|
||||
import { buildCanonicalPostPath, mapToRecord } from './PageRenderer';
|
||||
import type { MenuDocument } from './MenuEngine';
|
||||
import type { ProjectMetadata } from './MetaEngine';
|
||||
import type { PostData } from './PostEngine';
|
||||
import type { PostData, PostFilter } from './PostEngine';
|
||||
import type { MediaData } from './MediaEngine';
|
||||
import type { PicoThemeName } from '../shared/picoThemes';
|
||||
import type { CategoryMetadata } from './BlogGenerationEngine';
|
||||
import { PreviewServer } from './PreviewServer';
|
||||
@@ -63,7 +64,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
||||
languagePrefix?: string;
|
||||
engines: {
|
||||
postEngine: {
|
||||
getPostsFiltered: (filter: Parameters<PreviewServer['renderRouteForContext']>[1] extends never ? never : any) => Promise<PostData[]>;
|
||||
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
||||
getPublishedVersion: (postId: string) => Promise<PostData | null>;
|
||||
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
|
||||
getPost: (postId: string) => Promise<PostData | null>;
|
||||
@@ -74,7 +75,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
||||
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||
};
|
||||
mediaEngine: {
|
||||
getAllMedia: () => Promise<unknown[]>;
|
||||
getAllMedia: () => Promise<MediaData[]>;
|
||||
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
||||
};
|
||||
postMediaEngine: {
|
||||
@@ -185,7 +186,9 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (!match) return null;
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lazily resolve content from file when needed
|
||||
if (!match.content) {
|
||||
@@ -206,20 +209,22 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
||||
return match;
|
||||
},
|
||||
getPost: (postId: string) => params.engines.postEngine.getPost(postId),
|
||||
getPostTranslation: params.engines.postEngine.getPostTranslation
|
||||
? (postId: string, language: string) => params.engines.postEngine.getPostTranslation!(postId, language)
|
||||
: undefined,
|
||||
getPostTranslation: params.engines.postEngine.getPostTranslation,
|
||||
hasPublishedVersion: (postId: string) => params.engines.postEngine.hasPublishedVersion(postId),
|
||||
getLinkedBy: params.engines.postEngine.getAllBacklinks
|
||||
? (() => {
|
||||
const backlinksCachePromise = params.engines.postEngine.getAllBacklinks!();
|
||||
return async (postId: string) => {
|
||||
const backlinksMap = await backlinksCachePromise;
|
||||
return backlinksMap.get(postId) ?? [];
|
||||
};
|
||||
})()
|
||||
const getAllBacklinks = params.engines.postEngine.getAllBacklinks;
|
||||
if (!getAllBacklinks) {
|
||||
return undefined;
|
||||
}
|
||||
const backlinksCachePromise = getAllBacklinks();
|
||||
return async (postId: string) => {
|
||||
const backlinksMap = await backlinksCachePromise;
|
||||
return backlinksMap.get(postId) ?? [];
|
||||
};
|
||||
})()
|
||||
: params.engines.postEngine.getLinkedBy
|
||||
? (postId: string) => params.engines.postEngine.getLinkedBy!(postId)
|
||||
? params.engines.postEngine.getLinkedBy
|
||||
: undefined,
|
||||
setProjectContext: (projectId: string, dataDir?: string) => {
|
||||
params.engines.postEngine.setProjectContext(projectId, dataDir);
|
||||
|
||||
@@ -68,7 +68,7 @@ function buildCanonicalPreviewPath(createdAt: Date, slug: string): string {
|
||||
}
|
||||
|
||||
function escapeXml(value: unknown): string {
|
||||
const str = typeof value === 'string' ? value : value == null ? '' : String(value);
|
||||
const str = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
@@ -245,8 +245,12 @@ export function collectSitemapArchiveMetadata(params: {
|
||||
}
|
||||
|
||||
for (const post of publishedListPosts) {
|
||||
for (const tag of post.tags || []) allTags.add(tag);
|
||||
for (const category of post.categories || []) allCategories.add(category);
|
||||
for (const tag of post.tags || []) {
|
||||
allTags.add(tag);
|
||||
}
|
||||
for (const category of post.categories || []) {
|
||||
allCategories.add(category);
|
||||
}
|
||||
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const updatedAt = post.updatedAt;
|
||||
@@ -257,13 +261,16 @@ export function collectSitemapArchiveMetadata(params: {
|
||||
const ymKey = `${year}/${month}`;
|
||||
const ymdKey = `${year}/${month}/${day}`;
|
||||
|
||||
if (!yearMonths.has(ymKey) || updatedAt > yearMonths.get(ymKey)!) {
|
||||
const existingYearMonth = yearMonths.get(ymKey);
|
||||
if (!existingYearMonth || updatedAt > existingYearMonth) {
|
||||
yearMonths.set(ymKey, updatedAt);
|
||||
}
|
||||
if (!years.has(year) || updatedAt > years.get(year)!) {
|
||||
const existingYear = years.get(year);
|
||||
if (!existingYear || updatedAt > existingYear) {
|
||||
years.set(year, updatedAt);
|
||||
}
|
||||
if (!yearMonthDays.has(ymdKey) || updatedAt > yearMonthDays.get(ymdKey)!) {
|
||||
const existingYearMonthDay = yearMonthDays.get(ymdKey);
|
||||
if (!existingYearMonthDay || updatedAt > existingYearMonthDay) {
|
||||
yearMonthDays.set(ymdKey, updatedAt);
|
||||
}
|
||||
}
|
||||
@@ -405,7 +412,9 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
|
||||
` <guid isPermaLink="true">${escapeXml(permalink)}</guid>`,
|
||||
` <pubDate>${(post.publishedAt || post.updatedAt).toUTCString()}</pubDate>`,
|
||||
post.author ? ` <author>${escapeXml(post.author)}</author>` : null,
|
||||
(post as { language?: string }).language ? ` <dc:language>${escapeXml((post as { language?: string }).language!)}</dc:language>` : null,
|
||||
(post as { language?: string }).language
|
||||
? ` <dc:language>${escapeXml((post as { language?: string }).language)}</dc:language>`
|
||||
: null,
|
||||
` <description><![CDATA[${escapeCdata(excerptXhtml)}]]></description>`,
|
||||
` <content:encoded><![CDATA[${escapeCdata(contentXhtml)}]]></content:encoded>`,
|
||||
...categories.map((entry) => ` ${entry}`),
|
||||
@@ -440,7 +449,10 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
|
||||
...(post.categories || []).map((category) => `<category term="${escapeXml(category)}" />`),
|
||||
];
|
||||
|
||||
const postLanguageAttr = (post as { language?: string }).language ? ` xml:lang="${escapeXml((post as { language?: string }).language!)}"` : '';
|
||||
const postLanguage = (post as { language?: string }).language;
|
||||
const postLanguageAttr = postLanguage
|
||||
? ` xml:lang="${escapeXml(postLanguage)}"`
|
||||
: '';
|
||||
|
||||
return [
|
||||
` <entry${postLanguageAttr}>`,
|
||||
@@ -597,9 +609,13 @@ export function buildMultiLanguageSitemap(params: MultiLanguageSitemapParams): s
|
||||
const allPublishedPosts = [...translatablePosts, ...doNotTranslatePosts];
|
||||
for (const post of allPublishedPosts) {
|
||||
const categories = Array.isArray(post.categories) ? post.categories : [];
|
||||
if (!categories.includes('page')) continue;
|
||||
if (!categories.includes('page')) {
|
||||
continue;
|
||||
}
|
||||
const trimmedSlug = (post.slug || '').replace(/^\/+|\/+$/g, '');
|
||||
if (trimmedSlug.length === 0) continue;
|
||||
if (trimmedSlug.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const isTranslatable = !(post as PostData & { doNotTranslate?: boolean }).doNotTranslate;
|
||||
const langs = isTranslatable ? allLanguages : [mainLanguage];
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
|
||||
@@ -7,8 +7,13 @@
|
||||
* Maps to arrays-of-tuples so the data survives the boundary.
|
||||
*/
|
||||
import type { PostData } from './PostEngine';
|
||||
import type { PublishedTranslationVariant } from './BlogGenerationEngine';
|
||||
import type { MediaData } from './MediaEngine';
|
||||
import type { CategoryMetadata, BlogGenerationOptions, BlogGenerationSection } from './BlogGenerationEngine';
|
||||
import type {
|
||||
CategoryMetadata,
|
||||
BlogGenerationOptions,
|
||||
BlogGenerationSection,
|
||||
} from './BlogGenerationEngine';
|
||||
import type { CategoryRenderSettings } from './PageRenderer';
|
||||
import type { MenuDocument } from './MenuEngine';
|
||||
import type { PicoThemeName } from '../shared/picoThemes';
|
||||
@@ -105,7 +110,10 @@ export interface GenerationWorkerTask {
|
||||
mediaItems: SerializedMediaData[];
|
||||
|
||||
/** Pre-resolved backlinks map: postId → linked-by entries. */
|
||||
backlinksMap: Record<string, Array<{ id: string; title: string; slug: string }>>;
|
||||
backlinksMap: Record<
|
||||
string,
|
||||
Array<{ id: string; title: string; slug: string }>
|
||||
>;
|
||||
|
||||
options: SerializedBlogGenerationOptions;
|
||||
maxPostsPerPage: number;
|
||||
@@ -118,7 +126,9 @@ export interface GenerationWorkerTask {
|
||||
postFilePathEntries: Array<[string, string]>;
|
||||
|
||||
/** Post-media links: [postId, [{mediaId, sortOrder}]] tuples for gallery/album macros. */
|
||||
postMediaLinksEntries: Array<[string, Array<{ mediaId: string; sortOrder: number }>]>;
|
||||
postMediaLinksEntries: Array<
|
||||
[string, Array<{ mediaId: string; sortOrder: number }>]
|
||||
>;
|
||||
|
||||
/** Language prefix for subtree generation, e.g. "/fr". */
|
||||
languagePrefix?: string;
|
||||
@@ -190,9 +200,20 @@ export function serializePostData(post: PostData): SerializedPostData {
|
||||
language: post.language,
|
||||
doNotTranslate: post.doNotTranslate,
|
||||
templateSlug: post.templateSlug,
|
||||
createdAt: post.createdAt instanceof Date ? post.createdAt.toISOString() : String(post.createdAt),
|
||||
updatedAt: post.updatedAt instanceof Date ? post.updatedAt.toISOString() : String(post.updatedAt),
|
||||
publishedAt: post.publishedAt instanceof Date ? post.publishedAt.toISOString() : post.publishedAt ? String(post.publishedAt) : undefined,
|
||||
createdAt:
|
||||
post.createdAt instanceof Date
|
||||
? post.createdAt.toISOString()
|
||||
: String(post.createdAt),
|
||||
updatedAt:
|
||||
post.updatedAt instanceof Date
|
||||
? post.updatedAt.toISOString()
|
||||
: String(post.updatedAt),
|
||||
publishedAt:
|
||||
post.publishedAt instanceof Date
|
||||
? post.publishedAt.toISOString()
|
||||
: post.publishedAt
|
||||
? String(post.publishedAt)
|
||||
: undefined,
|
||||
tags: post.tags ?? [],
|
||||
categories: post.categories ?? [],
|
||||
availableLanguages: post.availableLanguages ?? [],
|
||||
@@ -204,9 +225,13 @@ export function serializePostData(post: PostData): SerializedPostData {
|
||||
translationCanonicalLanguage?: string;
|
||||
translationFilePath?: string;
|
||||
};
|
||||
if (variant.translationSourceSlug) serialized.translationSourceSlug = variant.translationSourceSlug;
|
||||
if (variant.translationCanonicalLanguage) serialized.translationCanonicalLanguage = variant.translationCanonicalLanguage;
|
||||
if (variant.translationFilePath) serialized.translationFilePath = variant.translationFilePath;
|
||||
if (variant.translationSourceSlug)
|
||||
{serialized.translationSourceSlug = variant.translationSourceSlug;}
|
||||
if (variant.translationCanonicalLanguage)
|
||||
{serialized.translationCanonicalLanguage =
|
||||
variant.translationCanonicalLanguage;}
|
||||
if (variant.translationFilePath)
|
||||
{serialized.translationFilePath = variant.translationFilePath;}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
@@ -226,7 +251,9 @@ export function deserializePostData(serialized: SerializedPostData): PostData {
|
||||
templateSlug: serialized.templateSlug,
|
||||
createdAt: new Date(serialized.createdAt),
|
||||
updatedAt: new Date(serialized.updatedAt),
|
||||
publishedAt: serialized.publishedAt ? new Date(serialized.publishedAt) : undefined,
|
||||
publishedAt: serialized.publishedAt
|
||||
? new Date(serialized.publishedAt)
|
||||
: undefined,
|
||||
tags: serialized.tags ?? [],
|
||||
categories: serialized.categories ?? [],
|
||||
availableLanguages: serialized.availableLanguages ?? [],
|
||||
@@ -234,13 +261,16 @@ export function deserializePostData(serialized: SerializedPostData): PostData {
|
||||
|
||||
// Re-attach translation variant fields
|
||||
if (serialized.translationSourceSlug) {
|
||||
(post as any).translationSourceSlug = serialized.translationSourceSlug;
|
||||
(post as PublishedTranslationVariant).translationSourceSlug =
|
||||
serialized.translationSourceSlug;
|
||||
}
|
||||
if (serialized.translationCanonicalLanguage) {
|
||||
(post as any).translationCanonicalLanguage = serialized.translationCanonicalLanguage;
|
||||
(post as PublishedTranslationVariant).translationCanonicalLanguage =
|
||||
serialized.translationCanonicalLanguage;
|
||||
}
|
||||
if (serialized.translationFilePath) {
|
||||
(post as any).translationFilePath = serialized.translationFilePath;
|
||||
(post as PublishedTranslationVariant).translationFilePath =
|
||||
serialized.translationFilePath;
|
||||
}
|
||||
|
||||
return post;
|
||||
@@ -260,15 +290,23 @@ export function serializeMediaItem(media: MediaData): SerializedMediaData {
|
||||
caption: media.caption,
|
||||
author: media.author,
|
||||
language: media.language,
|
||||
createdAt: media.createdAt instanceof Date ? media.createdAt.toISOString() : String(media.createdAt),
|
||||
updatedAt: media.updatedAt instanceof Date ? media.updatedAt.toISOString() : String(media.updatedAt),
|
||||
createdAt:
|
||||
media.createdAt instanceof Date
|
||||
? media.createdAt.toISOString()
|
||||
: String(media.createdAt),
|
||||
updatedAt:
|
||||
media.updatedAt instanceof Date
|
||||
? media.updatedAt.toISOString()
|
||||
: String(media.updatedAt),
|
||||
tags: media.tags ?? [],
|
||||
linkedPostIds: media.linkedPostIds,
|
||||
availableLanguages: media.availableLanguages ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function deserializeMediaItem(serialized: SerializedMediaData): MediaData {
|
||||
export function deserializeMediaItem(
|
||||
serialized: SerializedMediaData,
|
||||
): MediaData {
|
||||
return {
|
||||
id: serialized.id,
|
||||
filename: serialized.filename,
|
||||
@@ -290,7 +328,9 @@ export function deserializeMediaItem(serialized: SerializedMediaData): MediaData
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeBlogGenerationOptions(options: BlogGenerationOptions): SerializedBlogGenerationOptions {
|
||||
export function serializeBlogGenerationOptions(
|
||||
options: BlogGenerationOptions,
|
||||
): SerializedBlogGenerationOptions {
|
||||
return {
|
||||
projectId: options.projectId,
|
||||
projectName: options.projectName,
|
||||
@@ -307,21 +347,37 @@ export function serializeBlogGenerationOptions(options: BlogGenerationOptions):
|
||||
}
|
||||
|
||||
/** Serialize a Map<K, PostData[]> to an array of [K, SerializedPostData[]] tuples. */
|
||||
export function serializePostMap<K extends string | number>(map: Map<K, PostData[]>): Array<[K, SerializedPostData[]]> {
|
||||
return Array.from(map.entries()).map(([key, posts]) => [key, posts.map(serializePostData)]);
|
||||
export function serializePostMap<K extends string | number>(
|
||||
map: Map<K, PostData[]>,
|
||||
): Array<[K, SerializedPostData[]]> {
|
||||
return Array.from(map.entries()).map(([key, posts]) => [
|
||||
key,
|
||||
posts.map(serializePostData),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Deserialize an array of [K, SerializedPostData[]] tuples to a Map<K, PostData[]>. */
|
||||
export function deserializePostMap<K extends string | number>(entries: Array<[K, SerializedPostData[]]>): Map<K, PostData[]> {
|
||||
return new Map(entries.map(([key, posts]) => [key, posts.map(deserializePostData)]));
|
||||
export function deserializePostMap<K extends string | number>(
|
||||
entries: Array<[K, SerializedPostData[]]>,
|
||||
): Map<K, PostData[]> {
|
||||
return new Map(
|
||||
entries.map(([key, posts]) => [key, posts.map(deserializePostData)]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Serialize a Map<K, Date> to an array of [K, string] tuples. */
|
||||
export function serializeDateMap<K extends string | number>(map: Map<K, Date>): Array<[K, string]> {
|
||||
return Array.from(map.entries()).map(([key, date]) => [key, date.toISOString()]);
|
||||
export function serializeDateMap<K extends string | number>(
|
||||
map: Map<K, Date>,
|
||||
): Array<[K, string]> {
|
||||
return Array.from(map.entries()).map(([key, date]) => [
|
||||
key,
|
||||
date.toISOString(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Deserialize an array of [K, string] tuples to a Map<K, Date>. */
|
||||
export function deserializeDateMap<K extends string | number>(entries: Array<[K, string]>): Map<K, Date> {
|
||||
export function deserializeDateMap<K extends string | number>(
|
||||
entries: Array<[K, string]>,
|
||||
): Map<K, Date> {
|
||||
return new Map(entries.map(([key, iso]) => [key, new Date(iso)]));
|
||||
}
|
||||
|
||||
@@ -87,26 +87,26 @@ export class GenerationWorkerPool {
|
||||
const msg = raw as WorkerOutboundMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'progress':
|
||||
onProgress(msg.message);
|
||||
break;
|
||||
case 'progress':
|
||||
onProgress(msg.message);
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
totalPages += msg.pagesGenerated;
|
||||
if (msg.hashUpdates) {
|
||||
allHashUpdates.push(...msg.hashUpdates);
|
||||
}
|
||||
activeWorkers--;
|
||||
void worker.terminate();
|
||||
startNextWorker();
|
||||
break;
|
||||
case 'result':
|
||||
totalPages += msg.pagesGenerated;
|
||||
if (msg.hashUpdates) {
|
||||
allHashUpdates.push(...msg.hashUpdates);
|
||||
}
|
||||
activeWorkers--;
|
||||
void worker.terminate();
|
||||
startNextWorker();
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
errors.push({ taskId: msg.taskId, error: msg.error });
|
||||
activeWorkers--;
|
||||
void worker.terminate();
|
||||
startNextWorker();
|
||||
break;
|
||||
case 'error':
|
||||
errors.push({ taskId: msg.taskId, error: msg.error });
|
||||
activeWorkers--;
|
||||
void worker.terminate();
|
||||
startNextWorker();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -337,9 +337,15 @@ export class GitEngine {
|
||||
}
|
||||
|
||||
private getProviderLabel(provider: GitProvider): string {
|
||||
if (provider === 'github') return 'GitHub';
|
||||
if (provider === 'gitlab') return 'GitLab';
|
||||
if (provider === 'gitea-forgejo') return 'Gitea/Forgejo';
|
||||
if (provider === 'github') {
|
||||
return 'GitHub';
|
||||
}
|
||||
if (provider === 'gitlab') {
|
||||
return 'GitLab';
|
||||
}
|
||||
if (provider === 'gitea-forgejo') {
|
||||
return 'Gitea/Forgejo';
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import TurndownService from 'turndown';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, media, tags } from '../database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { WxrData, WxrPost, WxrMedia, WxrSiteInfo, WxrCategory, WxrTag } from './WxrParser';
|
||||
import { getMacroConfigMap, type MacroConfig } from '../config/macroConfig';
|
||||
import type { WxrData, WxrPost, WxrMedia, WxrSiteInfo } from './WxrParser';
|
||||
import { getMacroConfigMap } from '../config/macroConfig';
|
||||
|
||||
export type PostAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate';
|
||||
export type MediaAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing';
|
||||
@@ -202,10 +202,14 @@ export class ImportAnalysisEngine {
|
||||
// WordPress often uses title="name" with alt=""
|
||||
this.turndown.addRule('imageWithTitle', {
|
||||
filter: (node) => {
|
||||
if (node.nodeName !== 'IMG') return false;
|
||||
if (node.nodeName !== 'IMG') {
|
||||
return false;
|
||||
}
|
||||
// Check if this image is NOT inside an <a> tag (those are handled by linkedImage rule)
|
||||
const parent = node.parentNode;
|
||||
if (parent?.nodeName === 'A') return false;
|
||||
if (parent?.nodeName === 'A') {
|
||||
return false;
|
||||
}
|
||||
// Only match if alt is empty but title exists
|
||||
const img = node as HTMLImageElement;
|
||||
const alt = img.getAttribute('alt') || '';
|
||||
@@ -225,16 +229,20 @@ export class ImportAnalysisEngine {
|
||||
this.turndown.addRule('linkedImage', {
|
||||
filter: (node) => {
|
||||
// Match <a> tags that contain only an <img> (possibly with whitespace)
|
||||
if (node.nodeName !== 'A') return false;
|
||||
if (node.nodeName !== 'A') {
|
||||
return false;
|
||||
}
|
||||
const children = Array.from(node.childNodes).filter(
|
||||
child => !(child.nodeType === 3 && !child.textContent?.trim())
|
||||
child => !(child.nodeType === 3 && !child.textContent?.trim()),
|
||||
);
|
||||
return children.length === 1 && children[0].nodeName === 'IMG';
|
||||
},
|
||||
replacement: (_content, node) => {
|
||||
const anchor = node as HTMLAnchorElement;
|
||||
const img = anchor.querySelector('img');
|
||||
if (!img) return '';
|
||||
if (!img) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
const imgSrc = img.getAttribute('src') || '';
|
||||
@@ -271,7 +279,9 @@ export class ImportAnalysisEngine {
|
||||
// Custom rule for Flash embeds - replace with placeholder text
|
||||
this.turndown.addRule('flashEmbed', {
|
||||
filter: (node) => {
|
||||
if (node.nodeName !== 'EMBED') return false;
|
||||
if (node.nodeName !== 'EMBED') {
|
||||
return false;
|
||||
}
|
||||
const embed = node as HTMLEmbedElement;
|
||||
const type = embed.getAttribute('type') || '';
|
||||
const src = embed.getAttribute('src') || '';
|
||||
@@ -593,7 +603,9 @@ export class ImportAnalysisEngine {
|
||||
}
|
||||
|
||||
private convertToMarkdown(html: string): string {
|
||||
if (!html || !html.trim()) return '';
|
||||
if (!html || !html.trim()) {
|
||||
return '';
|
||||
}
|
||||
// Preprocess: Wrap standalone <code> blocks containing newlines in <pre> tags
|
||||
const withCodeBlocks = this.wrapMultilineCode(html);
|
||||
// Preprocess: Convert newlines within text to <br> tags to preserve line breaks
|
||||
@@ -629,14 +641,16 @@ export class ImportAnalysisEngine {
|
||||
* - Wraps content in <p> tags if it starts with plain text
|
||||
*/
|
||||
private preserveLineBreaks(html: string): string {
|
||||
if (!html || !html.trim()) return html;
|
||||
if (!html || !html.trim()) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Check if content starts with a tag or plain text
|
||||
const startsWithTag = /^\s*</.test(html);
|
||||
|
||||
// Protect <pre> blocks from having their newlines modified
|
||||
const preBlocks: string[] = [];
|
||||
let protectedHtml = html.replace(/<pre>([\s\S]*?)<\/pre>/g, (match) => {
|
||||
const protectedHtml = html.replace(/<pre>([\s\S]*?)<\/pre>/g, (match) => {
|
||||
const placeholder = `__PRE_BLOCK_${preBlocks.length}__`;
|
||||
preBlocks.push(match);
|
||||
return placeholder;
|
||||
@@ -659,7 +673,9 @@ export class ImportAnalysisEngine {
|
||||
|
||||
// Also handle newlines at the start (before any tags)
|
||||
processed = processed.replace(/^([^<]+)/g, (match, textContent: string) => {
|
||||
if (!textContent.trim()) return match;
|
||||
if (!textContent.trim()) {
|
||||
return match;
|
||||
}
|
||||
return textContent.replace(/\n/g, '<br>');
|
||||
});
|
||||
|
||||
@@ -723,7 +739,9 @@ export class ImportAnalysisEngine {
|
||||
* - <code> without newlines (inline code)
|
||||
*/
|
||||
private wrapMultilineCode(html: string): string {
|
||||
if (!html) return html;
|
||||
if (!html) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Match <code> blocks containing newlines that are NOT inside <pre>
|
||||
// Use a regex that captures the full <code>...</code> content including any embedded HTML
|
||||
@@ -757,7 +775,9 @@ export class ImportAnalysisEngine {
|
||||
|
||||
// Process each post/page
|
||||
for (const post of posts) {
|
||||
if (!post.content) continue;
|
||||
if (!post.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shortcodes = this.parseShortcodes(post.content);
|
||||
|
||||
|
||||
@@ -73,10 +73,12 @@ export class ImportDefinitionEngine {
|
||||
.from(importDefinitions)
|
||||
.where(and(
|
||||
eq(importDefinitions.id, id),
|
||||
eq(importDefinitions.projectId, this.currentProjectId)
|
||||
eq(importDefinitions.projectId, this.currentProjectId),
|
||||
));
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.rowToData(rows[0]);
|
||||
}
|
||||
@@ -95,11 +97,13 @@ export class ImportDefinitionEngine {
|
||||
|
||||
async updateDefinition(
|
||||
id: string,
|
||||
updates: Partial<Pick<ImportDefinitionData, 'name' | 'wxrFilePath' | 'uploadsFolderPath' | 'lastAnalysisResult'>>
|
||||
updates: Partial<Pick<ImportDefinitionData, 'name' | 'wxrFilePath' | 'uploadsFolderPath' | 'lastAnalysisResult'>>,
|
||||
): Promise<ImportDefinitionData | null> {
|
||||
// Check existence and ownership
|
||||
const existing = await this.getDefinition(id);
|
||||
if (!existing) return null;
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const db = this.getDb();
|
||||
|
||||
@@ -128,7 +132,7 @@ export class ImportDefinitionEngine {
|
||||
.set(updateData)
|
||||
.where(and(
|
||||
eq(importDefinitions.id, id),
|
||||
eq(importDefinitions.projectId, this.currentProjectId)
|
||||
eq(importDefinitions.projectId, this.currentProjectId),
|
||||
));
|
||||
|
||||
return this.getDefinition(id);
|
||||
@@ -137,7 +141,9 @@ export class ImportDefinitionEngine {
|
||||
async deleteDefinition(id: string): Promise<boolean> {
|
||||
// Check existence and ownership
|
||||
const existing = await this.getDefinition(id);
|
||||
if (!existing) return false;
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const db = this.getDb();
|
||||
|
||||
@@ -145,7 +151,7 @@ export class ImportDefinitionEngine {
|
||||
.delete(importDefinitions)
|
||||
.where(and(
|
||||
eq(importDefinitions.id, id),
|
||||
eq(importDefinitions.projectId, this.currentProjectId)
|
||||
eq(importDefinitions.projectId, this.currentProjectId),
|
||||
));
|
||||
|
||||
return true;
|
||||
|
||||
@@ -17,21 +17,19 @@ import matter from 'gray-matter';
|
||||
import { app } from 'electron';
|
||||
import TurndownService from 'turndown';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, media, NewPost, NewMedia } from '../database/schema';
|
||||
import { posts, NewPost } from '../database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { TagEngine } from './TagEngine';
|
||||
import type { PostEngine, PostData } from './PostEngine';
|
||||
import type { MediaEngine, MediaData } from './MediaEngine';
|
||||
import type { MediaEngine } from './MediaEngine';
|
||||
import type { PostMediaEngine } from './PostMediaEngine';
|
||||
import type {
|
||||
ImportAnalysisReport,
|
||||
AnalyzedPost,
|
||||
AnalyzedMedia,
|
||||
AnalyzedCategory,
|
||||
AnalyzedTag,
|
||||
ImportConflictResolution,
|
||||
} from './ImportAnalysisEngine';
|
||||
import type { WxrPost, WxrMedia } from './WxrParser';
|
||||
import type { WxrPost } from './WxrParser';
|
||||
|
||||
export interface ImportExecutionOptions {
|
||||
/** Path to the WordPress uploads folder for media files */
|
||||
@@ -129,10 +127,14 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
// WordPress often uses title="name" with alt=""
|
||||
this.turndown.addRule('imageWithTitle', {
|
||||
filter: (node) => {
|
||||
if (node.nodeName !== 'IMG') return false;
|
||||
if (node.nodeName !== 'IMG') {
|
||||
return false;
|
||||
}
|
||||
// Check if this image is NOT inside an <a> tag (those are handled by linkedImage rule)
|
||||
const parent = node.parentNode;
|
||||
if (parent?.nodeName === 'A') return false;
|
||||
if (parent?.nodeName === 'A') {
|
||||
return false;
|
||||
}
|
||||
// Only match if alt is empty but title exists
|
||||
const img = node as HTMLImageElement;
|
||||
const alt = img.getAttribute('alt') || '';
|
||||
@@ -152,16 +154,20 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
this.turndown.addRule('linkedImage', {
|
||||
filter: (node) => {
|
||||
// Match <a> tags that contain only an <img> (possibly with whitespace)
|
||||
if (node.nodeName !== 'A') return false;
|
||||
if (node.nodeName !== 'A') {
|
||||
return false;
|
||||
}
|
||||
const children = Array.from(node.childNodes).filter(
|
||||
child => !(child.nodeType === 3 && !child.textContent?.trim())
|
||||
child => !(child.nodeType === 3 && !child.textContent?.trim()),
|
||||
);
|
||||
return children.length === 1 && children[0].nodeName === 'IMG';
|
||||
},
|
||||
replacement: (_content, node) => {
|
||||
const anchor = node as HTMLAnchorElement;
|
||||
const img = anchor.querySelector('img');
|
||||
if (!img) return '';
|
||||
if (!img) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
const imgSrc = img.getAttribute('src') || '';
|
||||
@@ -198,7 +204,9 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
// Custom rule for Flash embeds - replace with placeholder text
|
||||
this.turndown.addRule('flashEmbed', {
|
||||
filter: (node) => {
|
||||
if (node.nodeName !== 'EMBED') return false;
|
||||
if (node.nodeName !== 'EMBED') {
|
||||
return false;
|
||||
}
|
||||
const embed = node as HTMLEmbedElement;
|
||||
const type = embed.getAttribute('type') || '';
|
||||
const src = embed.getAttribute('src') || '';
|
||||
@@ -221,7 +229,9 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
private getBaseDir(): string {
|
||||
if (this.dataDir) return this.dataDir;
|
||||
if (this.dataDir) {
|
||||
return this.dataDir;
|
||||
}
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'projects', this.currentProjectId);
|
||||
}
|
||||
@@ -259,7 +269,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
*/
|
||||
async executeImport(
|
||||
report: ImportAnalysisReport,
|
||||
options: ImportExecutionOptions
|
||||
options: ImportExecutionOptions,
|
||||
): Promise<ImportExecutionResult> {
|
||||
const result: ImportExecutionResult = {
|
||||
success: true,
|
||||
@@ -313,7 +323,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
* - Otherwise: use the name and mark for creation
|
||||
*/
|
||||
private buildTaxonomyMapping(
|
||||
items: Array<{ name: string; existsInProject: boolean; mappedTo?: string }>
|
||||
items: Array<{ name: string; existsInProject: boolean; mappedTo?: string }>,
|
||||
): Map<string, { resolved: string; needsCreation: boolean }> {
|
||||
const mapping = new Map<string, { resolved: string; needsCreation: boolean }>();
|
||||
|
||||
@@ -342,7 +352,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
tagMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void,
|
||||
): Promise<void> {
|
||||
const tagEngine = this.tagEngine;
|
||||
tagEngine.setProjectContext(this.currentProjectId);
|
||||
@@ -360,7 +370,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
await tagEngine.createTag({ name: mapping.resolved });
|
||||
result.tags.created++;
|
||||
progress('tags', current, total, `Created tag: ${mapping.resolved}`);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Tag might already exist (race condition or duplicate in list)
|
||||
result.tags.skipped++;
|
||||
}
|
||||
@@ -379,7 +389,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
await tagEngine.createTag({ name: mapping.resolved });
|
||||
result.tags.created++;
|
||||
progress('tags', current, total, `Created category tag: ${mapping.resolved}`);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
result.tags.skipped++;
|
||||
}
|
||||
} else {
|
||||
@@ -397,7 +407,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions,
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void,
|
||||
): Promise<void> {
|
||||
// Filter to only actual posts (postType === 'post'), skip nav_menu_item, revision, etc.
|
||||
const postsToImport = report.posts.items.filter(item => item.wxrPost.postType === 'post');
|
||||
@@ -433,10 +443,8 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
tagMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions
|
||||
options: ImportExecutionOptions,
|
||||
): Promise<boolean> {
|
||||
const wxrPost = analyzed.wxrPost;
|
||||
|
||||
// Handle different analysis statuses
|
||||
if (analyzed.status === 'content-duplicate') {
|
||||
// Skip content duplicates
|
||||
@@ -472,7 +480,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
tagMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions
|
||||
options: ImportExecutionOptions,
|
||||
): Promise<boolean> {
|
||||
const postEngine = this.postEngine;
|
||||
|
||||
@@ -504,7 +512,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
tagMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions
|
||||
options: ImportExecutionOptions,
|
||||
): Promise<boolean> {
|
||||
const wxrPost = analyzed.wxrPost;
|
||||
const db = getDatabase().getLocal();
|
||||
@@ -575,7 +583,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions,
|
||||
status: 'draft' | 'published',
|
||||
overrideSlug?: string
|
||||
overrideSlug?: string,
|
||||
): Promise<boolean> {
|
||||
const wxrPost = analyzed.wxrPost;
|
||||
const db = getDatabase().getLocal();
|
||||
@@ -681,9 +689,15 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
categories: post.categories,
|
||||
};
|
||||
|
||||
if (post.excerpt) metadata.excerpt = post.excerpt;
|
||||
if (post.author) metadata.author = post.author;
|
||||
if (post.publishedAt) metadata.publishedAt = post.publishedAt.toISOString();
|
||||
if (post.excerpt) {
|
||||
metadata.excerpt = post.excerpt;
|
||||
}
|
||||
if (post.author) {
|
||||
metadata.author = post.author;
|
||||
}
|
||||
if (post.publishedAt) {
|
||||
metadata.publishedAt = post.publishedAt.toISOString();
|
||||
}
|
||||
|
||||
const postsDir = this.getPostsDirForDate(post.createdAt);
|
||||
await fs.mkdir(postsDir, { recursive: true });
|
||||
@@ -702,7 +716,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
report: ImportAnalysisReport,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions,
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void,
|
||||
): Promise<void> {
|
||||
const total = report.media.items.length;
|
||||
|
||||
@@ -730,7 +744,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
private async importMediaFile(
|
||||
analyzed: AnalyzedMedia,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions
|
||||
options: ImportExecutionOptions,
|
||||
): Promise<boolean> {
|
||||
const wxrMedia = analyzed.wxrMedia;
|
||||
|
||||
@@ -822,7 +836,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
analyzed: AnalyzedMedia,
|
||||
existingMediaId: string,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions
|
||||
options: ImportExecutionOptions,
|
||||
): Promise<boolean> {
|
||||
const wxrMedia = analyzed.wxrMedia;
|
||||
|
||||
@@ -882,7 +896,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
categoryMapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
result: ImportExecutionResult,
|
||||
options: ImportExecutionOptions,
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void
|
||||
progress: (phase: string, current: number, total: number, detail?: string) => void,
|
||||
): Promise<void> {
|
||||
const total = report.pages.items.length;
|
||||
|
||||
@@ -926,7 +940,9 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
* Convert HTML to Markdown using Turndown
|
||||
*/
|
||||
private convertToMarkdown(html: string): string {
|
||||
if (!html || !html.trim()) return '';
|
||||
if (!html || !html.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Preprocess: Wrap standalone <code> blocks containing newlines in <pre> tags
|
||||
// This must happen BEFORE preserveLineBreaks to prevent newlines from becoming <br>
|
||||
@@ -976,14 +992,16 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
* - Wraps content in <p> tags if it starts with plain text
|
||||
*/
|
||||
private preserveLineBreaks(html: string): string {
|
||||
if (!html || !html.trim()) return html;
|
||||
if (!html || !html.trim()) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Check if content starts with a tag or plain text
|
||||
const startsWithTag = /^\s*</.test(html);
|
||||
|
||||
// Protect <pre> blocks from having their newlines modified
|
||||
const preBlocks: string[] = [];
|
||||
let protectedHtml = html.replace(/<pre>([\s\S]*?)<\/pre>/g, (match) => {
|
||||
const protectedHtml = html.replace(/<pre>([\s\S]*?)<\/pre>/g, (match) => {
|
||||
const placeholder = `__PRE_BLOCK_${preBlocks.length}__`;
|
||||
preBlocks.push(match);
|
||||
return placeholder;
|
||||
@@ -1006,7 +1024,9 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
|
||||
// Also handle newlines at the start (before any tags)
|
||||
processed = processed.replace(/^([^<]+)/g, (match, textContent: string) => {
|
||||
if (!textContent.trim()) return match;
|
||||
if (!textContent.trim()) {
|
||||
return match;
|
||||
}
|
||||
return textContent.replace(/\n/g, '<br>');
|
||||
});
|
||||
|
||||
@@ -1070,7 +1090,9 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
* - <code> without newlines (inline code)
|
||||
*/
|
||||
private wrapMultilineCode(html: string): string {
|
||||
if (!html) return html;
|
||||
if (!html) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Match <code> blocks containing newlines that are NOT inside <pre>
|
||||
// Use a regex that captures the full <code>...</code> content including any embedded HTML
|
||||
@@ -1099,7 +1121,9 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
* - URLs from wp-content/themes/ or wp-content/plugins/ (not imported media)
|
||||
*/
|
||||
private convertMediaUrlsToRelative(markdown: string): string {
|
||||
if (!this.siteBaseUrl || !markdown) return markdown;
|
||||
if (!this.siteBaseUrl || !markdown) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
// Normalize the site URL (remove trailing slash and protocol)
|
||||
const siteUrl = this.siteBaseUrl.replace(/\/$/, '');
|
||||
@@ -1107,7 +1131,9 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
// Extract the hostname from the site URL
|
||||
// Handle both http:// and https://
|
||||
const hostnameMatch = siteUrl.match(/^https?:\/\/(.+)$/);
|
||||
if (!hostnameMatch) return markdown;
|
||||
if (!hostnameMatch) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
const hostname = hostnameMatch[1];
|
||||
const escapedHostname = hostname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -1118,7 +1144,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
// Pattern: http(s)://{hostname}/wp-content/uploads/{path}
|
||||
const uploadsUrlPattern = new RegExp(
|
||||
`https?://${escapedHostname}/wp-content/uploads/([^\\s)"']+)`,
|
||||
'gi'
|
||||
'gi',
|
||||
);
|
||||
|
||||
// Replace with relative media path
|
||||
@@ -1147,7 +1173,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
*/
|
||||
private resolveTaxonomy(
|
||||
items: string[],
|
||||
mapping: Map<string, { resolved: string; needsCreation: boolean }>
|
||||
mapping: Map<string, { resolved: string; needsCreation: boolean }>,
|
||||
): string[] {
|
||||
return items.map(item => {
|
||||
const key = item.toLowerCase();
|
||||
@@ -1161,7 +1187,9 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
* Handles Date objects, ISO strings (from JSON serialization), and null/undefined.
|
||||
*/
|
||||
private toDate(value: Date | string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
|
||||
@@ -73,20 +73,20 @@ export class MCPAgentConfigEngine {
|
||||
/** Resolve the absolute path to the config file for the given agent. */
|
||||
getConfigPath(agentId: MCPAgentId): string {
|
||||
switch (agentId) {
|
||||
case 'claude-code':
|
||||
return path.join(this.homeDir, '.claude.json');
|
||||
case 'claude-desktop':
|
||||
return this.claudeDesktopConfigPath();
|
||||
case 'github-copilot':
|
||||
return this.vsCodeMcpPath();
|
||||
case 'gemini-cli':
|
||||
return path.join(this.homeDir, '.gemini', 'settings.json');
|
||||
case 'opencode':
|
||||
return path.join(this.homeDir, '.opencode.json');
|
||||
case 'mistral-vibe':
|
||||
return path.join(this.homeDir, '.vibe', 'config.toml');
|
||||
case 'openai-codex':
|
||||
return path.join(this.homeDir, '.codex', 'config.toml');
|
||||
case 'claude-code':
|
||||
return path.join(this.homeDir, '.claude.json');
|
||||
case 'claude-desktop':
|
||||
return this.claudeDesktopConfigPath();
|
||||
case 'github-copilot':
|
||||
return this.vsCodeMcpPath();
|
||||
case 'gemini-cli':
|
||||
return path.join(this.homeDir, '.gemini', 'settings.json');
|
||||
case 'opencode':
|
||||
return path.join(this.homeDir, '.opencode.json');
|
||||
case 'mistral-vibe':
|
||||
return path.join(this.homeDir, '.vibe', 'config.toml');
|
||||
case 'openai-codex':
|
||||
return path.join(this.homeDir, '.codex', 'config.toml');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ export class MCPAgentConfigEngine {
|
||||
return { success: true, configPath };
|
||||
}
|
||||
const { [SERVER_NAME]: _removed, ...remainingServers } = currentServers;
|
||||
void _removed;
|
||||
const updated: Record<string, unknown> = { ...existing };
|
||||
if (Object.keys(remainingServers).length === 0) {
|
||||
delete updated[serversKey];
|
||||
@@ -155,7 +156,9 @@ export class MCPAgentConfigEngine {
|
||||
return this.isCodexConfigured();
|
||||
}
|
||||
const configPath = this.getConfigPath(agentId);
|
||||
if (!existsSync(configPath)) return false;
|
||||
if (!existsSync(configPath)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
|
||||
@@ -220,7 +223,9 @@ export class MCPAgentConfigEngine {
|
||||
|
||||
private isVibeConfigured(): boolean {
|
||||
const configPath = this.getConfigPath('mistral-vibe');
|
||||
if (!existsSync(configPath)) return false;
|
||||
if (!existsSync(configPath)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const existing = this.readExistingToml(configPath);
|
||||
const servers = (existing.mcp_servers ?? []) as Record<string, unknown>[];
|
||||
@@ -231,7 +236,9 @@ export class MCPAgentConfigEngine {
|
||||
}
|
||||
|
||||
private readExistingToml(configPath: string): Record<string, unknown> {
|
||||
if (!existsSync(configPath)) return {};
|
||||
if (!existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
return parseToml(raw) as Record<string, unknown>;
|
||||
}
|
||||
@@ -286,7 +293,9 @@ export class MCPAgentConfigEngine {
|
||||
|
||||
private isCodexConfigured(): boolean {
|
||||
const configPath = this.getConfigPath('openai-codex');
|
||||
if (!existsSync(configPath)) return false;
|
||||
if (!existsSync(configPath)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const existing = this.readExistingToml(configPath);
|
||||
const servers = (existing.mcp_servers ?? {}) as Record<string, unknown>;
|
||||
@@ -320,7 +329,9 @@ export class MCPAgentConfigEngine {
|
||||
}
|
||||
|
||||
private readExistingJson(configPath: string): Record<string, unknown> {
|
||||
if (!existsSync(configPath)) return {};
|
||||
if (!existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
}
|
||||
@@ -347,17 +358,17 @@ export class MCPAgentConfigEngine {
|
||||
};
|
||||
|
||||
switch (agentId) {
|
||||
case 'claude-code':
|
||||
case 'claude-desktop':
|
||||
case 'gemini-cli':
|
||||
return stdioEntry;
|
||||
case 'github-copilot':
|
||||
case 'opencode':
|
||||
return { type: 'stdio', ...stdioEntry };
|
||||
case 'mistral-vibe':
|
||||
case 'openai-codex':
|
||||
// TOML-based; handled separately — should not reach here.
|
||||
return stdioEntry;
|
||||
case 'claude-code':
|
||||
case 'claude-desktop':
|
||||
case 'gemini-cli':
|
||||
return stdioEntry;
|
||||
case 'github-copilot':
|
||||
case 'opencode':
|
||||
return { type: 'stdio', ...stdioEntry };
|
||||
case 'mistral-vibe':
|
||||
case 'openai-codex':
|
||||
// TOML-based; handled separately — should not reach here.
|
||||
return stdioEntry;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { createServer as createHttpServer, type Server } from 'http';
|
||||
import { z } from 'zod';
|
||||
import { buildAmbiguityHints, enrichWithLinks, executeCheckTerm } from './ai/blog-tools';
|
||||
import { ProposalStore, type ProposalType } from './ProposalStore';
|
||||
import { ProposalStore } from './ProposalStore';
|
||||
import {
|
||||
reviewPostHtml,
|
||||
reviewScriptHtml,
|
||||
@@ -236,7 +236,7 @@ export class MCPServer {
|
||||
try {
|
||||
await mcpServer.connect(transport);
|
||||
await transport.handleRequest(req, res, await parseBody(req));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
@@ -272,9 +272,13 @@ export class MCPServer {
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!this.httpServer) { resolve(); return; }
|
||||
if (!this.httpServer) {
|
||||
resolve(); return;
|
||||
}
|
||||
this.httpServer.close((error) => {
|
||||
if (error) { reject(error); return; }
|
||||
if (error) {
|
||||
reject(error); return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -312,31 +316,31 @@ export class MCPServer {
|
||||
|
||||
try {
|
||||
switch (proposal.type) {
|
||||
case 'draftPost': {
|
||||
const { postId } = proposalData<'draftPost'>(proposal);
|
||||
await this.deps.postEngine.publishPost(postId);
|
||||
break;
|
||||
}
|
||||
case 'proposeScript': {
|
||||
const { scriptId } = proposalData<'proposeScript'>(proposal);
|
||||
await this.deps.scriptEngine.publishScript(scriptId);
|
||||
break;
|
||||
}
|
||||
case 'proposeTemplate': {
|
||||
const { templateId } = proposalData<'proposeTemplate'>(proposal);
|
||||
await this.deps.templateEngine.publishTemplate(templateId);
|
||||
break;
|
||||
}
|
||||
case 'proposeMediaMetadata': {
|
||||
const { mediaId, changes } = proposalData<'proposeMediaMetadata'>(proposal);
|
||||
await this.deps.mediaEngine.updateMedia(mediaId, changes);
|
||||
break;
|
||||
}
|
||||
case 'proposePostMetadata': {
|
||||
const { postId, changes } = proposalData<'proposePostMetadata'>(proposal);
|
||||
await this.deps.postEngine.updatePost(postId, changes);
|
||||
break;
|
||||
}
|
||||
case 'draftPost': {
|
||||
const { postId } = proposalData<'draftPost'>(proposal);
|
||||
await this.deps.postEngine.publishPost(postId);
|
||||
break;
|
||||
}
|
||||
case 'proposeScript': {
|
||||
const { scriptId } = proposalData<'proposeScript'>(proposal);
|
||||
await this.deps.scriptEngine.publishScript(scriptId);
|
||||
break;
|
||||
}
|
||||
case 'proposeTemplate': {
|
||||
const { templateId } = proposalData<'proposeTemplate'>(proposal);
|
||||
await this.deps.templateEngine.publishTemplate(templateId);
|
||||
break;
|
||||
}
|
||||
case 'proposeMediaMetadata': {
|
||||
const { mediaId, changes } = proposalData<'proposeMediaMetadata'>(proposal);
|
||||
await this.deps.mediaEngine.updateMedia(mediaId, changes);
|
||||
break;
|
||||
}
|
||||
case 'proposePostMetadata': {
|
||||
const { postId, changes } = proposalData<'proposePostMetadata'>(proposal);
|
||||
await this.deps.postEngine.updatePost(postId, changes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.proposalStore.remove(proposalId);
|
||||
return { success: true, message: `Proposal ${proposalId} accepted.` };
|
||||
@@ -547,13 +551,27 @@ export class MCPServer {
|
||||
enriched = await enrichWithLinks(paginated, this.deps.postEngine);
|
||||
} else {
|
||||
const filter: PostFilter = {};
|
||||
if (args.category) filter.categories = [args.category];
|
||||
if (args.tags) filter.tags = args.tags;
|
||||
if (args.language) filter.language = args.language;
|
||||
if (args.missingTranslationLanguage) filter.missingTranslationLanguage = args.missingTranslationLanguage;
|
||||
if (args.year) filter.year = args.year;
|
||||
if (args.month) filter.month = args.month;
|
||||
if (args.status) filter.status = args.status;
|
||||
if (args.category) {
|
||||
filter.categories = [args.category];
|
||||
}
|
||||
if (args.tags) {
|
||||
filter.tags = args.tags;
|
||||
}
|
||||
if (args.language) {
|
||||
filter.language = args.language;
|
||||
}
|
||||
if (args.missingTranslationLanguage) {
|
||||
filter.missingTranslationLanguage = args.missingTranslationLanguage;
|
||||
}
|
||||
if (args.year) {
|
||||
filter.year = args.year;
|
||||
}
|
||||
if (args.month) {
|
||||
filter.month = args.month;
|
||||
}
|
||||
if (args.status) {
|
||||
filter.status = args.status;
|
||||
}
|
||||
|
||||
if (args.query && hasFilters) {
|
||||
const { posts, total: t } = await this.deps.postEngine.searchPostsFiltered(args.query, filter, { offset, limit });
|
||||
@@ -600,11 +618,21 @@ export class MCPServer {
|
||||
}
|
||||
|
||||
const filter: { year?: number; month?: number; status?: string; category?: string; tags?: string[] } = {};
|
||||
if (args.year !== undefined) filter.year = args.year;
|
||||
if (args.month !== undefined) filter.month = args.month;
|
||||
if (args.status) filter.status = args.status;
|
||||
if (args.category) filter.category = args.category;
|
||||
if (args.tags) filter.tags = args.tags;
|
||||
if (args.year !== undefined) {
|
||||
filter.year = args.year;
|
||||
}
|
||||
if (args.month !== undefined) {
|
||||
filter.month = args.month;
|
||||
}
|
||||
if (args.status) {
|
||||
filter.status = args.status;
|
||||
}
|
||||
if (args.category) {
|
||||
filter.category = args.category;
|
||||
}
|
||||
if (args.tags) {
|
||||
filter.tags = args.tags;
|
||||
}
|
||||
|
||||
const result = await this.deps.postEngine.getPostCounts(
|
||||
args.groupBy,
|
||||
@@ -1055,8 +1083,12 @@ function buildDraftPostPrompt(topic?: string, category?: string): string {
|
||||
'3. Use the `draft_post` tool to create the draft for the user to review.',
|
||||
'',
|
||||
];
|
||||
if (topic) parts.push(`Suggested topic: ${topic}`);
|
||||
if (category) parts.push(`Target category: ${category}`);
|
||||
if (topic) {
|
||||
parts.push(`Suggested topic: ${topic}`);
|
||||
}
|
||||
if (category) {
|
||||
parts.push(`Target category: ${category}`);
|
||||
}
|
||||
parts.push('', 'Ensure the post matches the existing blog style and quality standards.');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as crypto from 'crypto';
|
||||
import { eq, and, gte, lte, lt, desc } from 'drizzle-orm';
|
||||
import { app } from 'electron';
|
||||
import { getDatabase } from '../database';
|
||||
import { media, Media, NewMedia, postMedia, mediaTranslations } from '../database/schema';
|
||||
import { media, NewMedia, postMedia, mediaTranslations } from '../database/schema';
|
||||
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
||||
import { CliNotifier, NoopNotifier } from './CliNotifier';
|
||||
|
||||
@@ -102,7 +102,9 @@ export class MediaEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/** No persistent cache — DB is the source of truth. No-op for watcher compat. */
|
||||
invalidate(_entityId?: string): void {}
|
||||
invalidate(entityId?: string): void {
|
||||
void entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the language used for full-text search stemming.
|
||||
@@ -132,7 +134,9 @@ export class MediaEngine extends EventEmitter {
|
||||
tags: string[];
|
||||
}): Promise<void> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete existing entry
|
||||
await client.execute({ sql: 'DELETE FROM media_fts WHERE id = ?', args: [item.id] });
|
||||
@@ -160,7 +164,9 @@ export class MediaEngine extends EventEmitter {
|
||||
*/
|
||||
private async deleteFTSIndex(id: string): Promise<void> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await client.execute({ sql: 'DELETE FROM media_fts WHERE id = ?', args: [id] });
|
||||
}
|
||||
|
||||
@@ -221,7 +227,6 @@ export class MediaEngine extends EventEmitter {
|
||||
this.currentProjectId = projectId;
|
||||
this.dataDir = nextDataDir;
|
||||
this.internalDir = nextInternalDir;
|
||||
console.log(`[MediaEngine] setProjectContext: projectId=${projectId}, dataDir=${this.dataDir}, internalDir=${this.internalDir}`);
|
||||
}
|
||||
|
||||
getProjectContext(): string {
|
||||
@@ -381,13 +386,27 @@ export class MediaEngine extends EventEmitter {
|
||||
`size: ${metadata.size}`,
|
||||
];
|
||||
|
||||
if (metadata.width) lines.push(`width: ${metadata.width}`);
|
||||
if (metadata.height) lines.push(`height: ${metadata.height}`);
|
||||
if (metadata.title) lines.push(`title: "${metadata.title}"`);
|
||||
if (metadata.alt) lines.push(`alt: "${metadata.alt}"`);
|
||||
if (metadata.caption) lines.push(`caption: "${metadata.caption}"`);
|
||||
if (metadata.author) lines.push(`author: "${metadata.author}"`);
|
||||
if (metadata.language) lines.push(`language: ${metadata.language}`);
|
||||
if (metadata.width) {
|
||||
lines.push(`width: ${metadata.width}`);
|
||||
}
|
||||
if (metadata.height) {
|
||||
lines.push(`height: ${metadata.height}`);
|
||||
}
|
||||
if (metadata.title) {
|
||||
lines.push(`title: "${metadata.title}"`);
|
||||
}
|
||||
if (metadata.alt) {
|
||||
lines.push(`alt: "${metadata.alt}"`);
|
||||
}
|
||||
if (metadata.caption) {
|
||||
lines.push(`caption: "${metadata.caption}"`);
|
||||
}
|
||||
if (metadata.author) {
|
||||
lines.push(`author: "${metadata.author}"`);
|
||||
}
|
||||
if (metadata.language) {
|
||||
lines.push(`language: ${metadata.language}`);
|
||||
}
|
||||
|
||||
lines.push(`createdAt: ${metadata.createdAt}`);
|
||||
lines.push(`updatedAt: ${metadata.updatedAt}`);
|
||||
@@ -420,10 +439,14 @@ export class MediaEngine extends EventEmitter {
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '---') continue;
|
||||
if (line === '---') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1) continue;
|
||||
if (colonIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
let value = line.substring(colonIndex + 1).trim();
|
||||
@@ -434,65 +457,65 @@ export class MediaEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'id':
|
||||
metadata.id = value;
|
||||
break;
|
||||
case 'originalName':
|
||||
metadata.originalName = value;
|
||||
break;
|
||||
case 'mimeType':
|
||||
metadata.mimeType = value;
|
||||
break;
|
||||
case 'size':
|
||||
metadata.size = parseInt(value, 10);
|
||||
break;
|
||||
case 'width':
|
||||
metadata.width = parseInt(value, 10);
|
||||
break;
|
||||
case 'height':
|
||||
metadata.height = parseInt(value, 10);
|
||||
break;
|
||||
case 'title':
|
||||
metadata.title = value;
|
||||
break;
|
||||
case 'alt':
|
||||
metadata.alt = value;
|
||||
break;
|
||||
case 'caption':
|
||||
metadata.caption = value;
|
||||
break;
|
||||
case 'author':
|
||||
metadata.author = value;
|
||||
break;
|
||||
case 'language':
|
||||
metadata.language = value;
|
||||
break;
|
||||
case 'createdAt':
|
||||
metadata.createdAt = value;
|
||||
break;
|
||||
case 'updatedAt':
|
||||
metadata.updatedAt = value;
|
||||
break;
|
||||
case 'tags':
|
||||
// Parse array format: ["tag1", "tag2"]
|
||||
const tagsMatch = value.match(/\[(.*)\]/);
|
||||
if (tagsMatch) {
|
||||
metadata.tags = tagsMatch[1]
|
||||
.split(',')
|
||||
.map(t => t.trim().replace(/"/g, ''))
|
||||
.filter(t => t.length > 0);
|
||||
}
|
||||
break;
|
||||
case 'linkedPostIds':
|
||||
// Parse array format: ["postId1", "postId2"]
|
||||
const postIdsMatch = value.match(/\[(.*)\]/);
|
||||
if (postIdsMatch) {
|
||||
metadata.linkedPostIds = postIdsMatch[1]
|
||||
.split(',')
|
||||
.map(id => id.trim().replace(/"/g, ''))
|
||||
.filter(id => id.length > 0);
|
||||
}
|
||||
break;
|
||||
case 'id':
|
||||
metadata.id = value;
|
||||
break;
|
||||
case 'originalName':
|
||||
metadata.originalName = value;
|
||||
break;
|
||||
case 'mimeType':
|
||||
metadata.mimeType = value;
|
||||
break;
|
||||
case 'size':
|
||||
metadata.size = parseInt(value, 10);
|
||||
break;
|
||||
case 'width':
|
||||
metadata.width = parseInt(value, 10);
|
||||
break;
|
||||
case 'height':
|
||||
metadata.height = parseInt(value, 10);
|
||||
break;
|
||||
case 'title':
|
||||
metadata.title = value;
|
||||
break;
|
||||
case 'alt':
|
||||
metadata.alt = value;
|
||||
break;
|
||||
case 'caption':
|
||||
metadata.caption = value;
|
||||
break;
|
||||
case 'author':
|
||||
metadata.author = value;
|
||||
break;
|
||||
case 'language':
|
||||
metadata.language = value;
|
||||
break;
|
||||
case 'createdAt':
|
||||
metadata.createdAt = value;
|
||||
break;
|
||||
case 'updatedAt':
|
||||
metadata.updatedAt = value;
|
||||
break;
|
||||
case 'tags':
|
||||
// Parse array format: ["tag1", "tag2"]
|
||||
const tagsMatch = value.match(/\[(.*)\]/);
|
||||
if (tagsMatch) {
|
||||
metadata.tags = tagsMatch[1]
|
||||
.split(',')
|
||||
.map(t => t.trim().replace(/"/g, ''))
|
||||
.filter(t => t.length > 0);
|
||||
}
|
||||
break;
|
||||
case 'linkedPostIds':
|
||||
// Parse array format: ["postId1", "postId2"]
|
||||
const postIdsMatch = value.match(/\[(.*)\]/);
|
||||
if (postIdsMatch) {
|
||||
metadata.linkedPostIds = postIdsMatch[1]
|
||||
.split(',')
|
||||
.map(id => id.trim().replace(/"/g, ''))
|
||||
.filter(id => id.length > 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,7 +674,9 @@ export class MediaEngine extends EventEmitter {
|
||||
};
|
||||
|
||||
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
||||
if (!dbMedia) return null;
|
||||
if (!dbMedia) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read existing sidecar to preserve fields that may only exist there
|
||||
// (e.g. linkedPostIds is sidecar-only, and author/title may have drifted)
|
||||
@@ -891,8 +916,6 @@ export class MediaEngine extends EventEmitter {
|
||||
const db = getDatabase().getLocal();
|
||||
const conditions = [eq(media.projectId, this.currentProjectId)];
|
||||
|
||||
console.log(`[MediaEngine] getMediaFiltered called with filter:`, JSON.stringify(filter));
|
||||
|
||||
if (filter.startDate) {
|
||||
conditions.push(gte(media.createdAt, filter.startDate));
|
||||
}
|
||||
@@ -905,7 +928,6 @@ export class MediaEngine extends EventEmitter {
|
||||
// Use UTC dates to avoid timezone issues
|
||||
const startOfYear = new Date(Date.UTC(filter.year, 0, 1));
|
||||
const endOfYear = new Date(Date.UTC(filter.year + 1, 0, 1));
|
||||
console.log(`[MediaEngine] Year filter: ${startOfYear.toISOString()} to ${endOfYear.toISOString()}`);
|
||||
conditions.push(gte(media.createdAt, startOfYear));
|
||||
conditions.push(lt(media.createdAt, endOfYear));
|
||||
}
|
||||
@@ -914,7 +936,6 @@ export class MediaEngine extends EventEmitter {
|
||||
// Use UTC dates to avoid timezone issues (filter.month is 1-indexed)
|
||||
const startOfMonth = new Date(Date.UTC(filter.year, filter.month - 1, 1));
|
||||
const endOfMonth = new Date(Date.UTC(filter.year, filter.month, 1));
|
||||
console.log(`[MediaEngine] Month filter: ${startOfMonth.toISOString()} to ${endOfMonth.toISOString()}`);
|
||||
conditions.push(gte(media.createdAt, startOfMonth));
|
||||
conditions.push(lt(media.createdAt, endOfMonth));
|
||||
}
|
||||
@@ -926,9 +947,7 @@ export class MediaEngine extends EventEmitter {
|
||||
.orderBy(desc(media.createdAt))
|
||||
.all();
|
||||
|
||||
console.log(`[MediaEngine] Query returned ${dbMediaList.length} media items`);
|
||||
|
||||
let result: MediaData[] = [];
|
||||
const result: MediaData[] = [];
|
||||
|
||||
for (const dbMedia of dbMediaList) {
|
||||
const mediaData: MediaData = {
|
||||
@@ -953,7 +972,9 @@ export class MediaEngine extends EventEmitter {
|
||||
// Client-side filtering for tags (JSON array)
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
const hasAllTags = filter.tags.every(tag => mediaData.tags.includes(tag));
|
||||
if (!hasAllTags) continue;
|
||||
if (!hasAllTags) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(mediaData);
|
||||
@@ -964,7 +985,9 @@ export class MediaEngine extends EventEmitter {
|
||||
|
||||
async searchMedia(query: string): Promise<MediaSearchResult[]> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return [];
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Stem the query for multilingual matching
|
||||
@@ -972,7 +995,7 @@ export class MediaEngine extends EventEmitter {
|
||||
|
||||
// Search the stemmed content, filtered by project_id for project isolation
|
||||
const result = await client.execute({
|
||||
sql: `SELECT id FROM media_fts WHERE project_id = ? AND media_fts MATCH ? ORDER BY rank LIMIT 50`,
|
||||
sql: 'SELECT id FROM media_fts WHERE project_id = ? AND media_fts MATCH ? ORDER BY rank LIMIT 50',
|
||||
args: [this.currentProjectId, stemmedQuery],
|
||||
});
|
||||
|
||||
@@ -1015,7 +1038,9 @@ export class MediaEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
return Array.from(counts.values()).sort((a, b) => {
|
||||
if (a.year !== b.year) return b.year - a.year;
|
||||
if (a.year !== b.year) {
|
||||
return b.year - a.year;
|
||||
}
|
||||
return b.month - a.month;
|
||||
});
|
||||
}
|
||||
@@ -1063,7 +1088,9 @@ export class MediaEngine extends EventEmitter {
|
||||
async getRelativePath(id: string): Promise<string | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
||||
if (!dbMedia?.filePath) return null;
|
||||
if (!dbMedia?.filePath) {
|
||||
return null;
|
||||
}
|
||||
const dataDir = this.getDataDir();
|
||||
const relativePath = path.relative(dataDir, dbMedia.filePath);
|
||||
return relativePath.replace(/\\/g, '/');
|
||||
@@ -1071,7 +1098,6 @@ export class MediaEngine extends EventEmitter {
|
||||
|
||||
async rebuildDatabaseFromFiles(): Promise<void> {
|
||||
const mediaBaseDir = this.getMediaBaseDir();
|
||||
console.log(`[MediaEngine] rebuildDatabaseFromFiles: scanning mediaBaseDir=${mediaBaseDir}`);
|
||||
const task: Task<void> = {
|
||||
id: uuidv4(),
|
||||
name: 'Rebuild database from media files',
|
||||
@@ -1087,16 +1113,13 @@ export class MediaEngine extends EventEmitter {
|
||||
const existingMedia = await db.select({ id: media.id }).from(media).where(eq(media.projectId, this.currentProjectId)).all();
|
||||
if (existingMedia.length > 0) {
|
||||
await db.delete(media).where(eq(media.projectId, this.currentProjectId));
|
||||
console.log(`Deleted ${existingMedia.length} existing media record(s) for project ${this.currentProjectId}`);
|
||||
}
|
||||
|
||||
// Also delete all post-media links for the current project
|
||||
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
|
||||
console.log(`Deleted post-media links for project ${this.currentProjectId}`);
|
||||
|
||||
// Delete all media translations for the current project
|
||||
await db.delete(mediaTranslations).where(eq(mediaTranslations.projectId, this.currentProjectId));
|
||||
console.log(`Deleted media translations for project ${this.currentProjectId}`);
|
||||
|
||||
// Delete all FTS entries for the current project
|
||||
const client = getDatabase().getLocalClient();
|
||||
@@ -1105,7 +1128,6 @@ export class MediaEngine extends EventEmitter {
|
||||
sql: 'DELETE FROM media_fts WHERE project_id = ?',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
console.log(`Deleted media FTS entries for project ${this.currentProjectId}`);
|
||||
}
|
||||
|
||||
onProgress(5, 'Scanning media directory...');
|
||||
@@ -1281,7 +1303,7 @@ export class MediaEngine extends EventEmitter {
|
||||
|
||||
// Filter to images only (not SVG - they don't need thumbnails)
|
||||
const imageMedia = allMedia.filter(
|
||||
m => m.mimeType.startsWith('image/') && !m.mimeType.includes('svg')
|
||||
m => m.mimeType.startsWith('image/') && !m.mimeType.includes('svg'),
|
||||
);
|
||||
|
||||
if (imageMedia.length === 0) {
|
||||
@@ -1406,7 +1428,6 @@ export class MediaEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
onProgress(100, `Reindexed ${total} media items`);
|
||||
console.log(`Reindexed search text for ${total} media items`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1421,7 +1442,9 @@ export class MediaEngine extends EventEmitter {
|
||||
.where(eq(mediaTranslations.translationFor, mediaId))
|
||||
.all();
|
||||
const row = rows.find(r => r.language === language.toLowerCase());
|
||||
if (!row) return null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return this.toMediaTranslationData(row);
|
||||
}
|
||||
|
||||
@@ -1520,7 +1543,9 @@ export class MediaEngine extends EventEmitter {
|
||||
async deleteMediaTranslation(mediaId: string, language: string): Promise<boolean> {
|
||||
const normalizedLang = language.toLowerCase();
|
||||
const existing = await this.getMediaTranslation(mediaId, normalizedLang);
|
||||
if (!existing) return false;
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const db = getDatabase().getLocal();
|
||||
await db.delete(mediaTranslations).where(eq(mediaTranslations.id, existing.id));
|
||||
@@ -1563,9 +1588,15 @@ export class MediaEngine extends EventEmitter {
|
||||
`translationFor: ${translation.translationFor}`,
|
||||
`language: ${translation.language}`,
|
||||
];
|
||||
if (translation.title) lines.push(`title: "${translation.title}"`);
|
||||
if (translation.alt) lines.push(`alt: "${translation.alt}"`);
|
||||
if (translation.caption) lines.push(`caption: "${translation.caption}"`);
|
||||
if (translation.title) {
|
||||
lines.push(`title: "${translation.title}"`);
|
||||
}
|
||||
if (translation.alt) {
|
||||
lines.push(`alt: "${translation.alt}"`);
|
||||
}
|
||||
if (translation.caption) {
|
||||
lines.push(`caption: "${translation.caption}"`);
|
||||
}
|
||||
lines.push('---');
|
||||
|
||||
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
|
||||
@@ -1580,9 +1611,13 @@ export class MediaEngine extends EventEmitter {
|
||||
const result: { translationFor?: string; language?: string; title?: string; alt?: string; caption?: string } = {};
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (line === '---') continue;
|
||||
if (line === '---') {
|
||||
continue;
|
||||
}
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1) continue;
|
||||
if (colonIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
let value = line.substring(colonIndex + 1).trim();
|
||||
@@ -1591,11 +1626,11 @@ export class MediaEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'translationFor': result.translationFor = value; break;
|
||||
case 'language': result.language = value; break;
|
||||
case 'title': result.title = value; break;
|
||||
case 'alt': result.alt = value; break;
|
||||
case 'caption': result.caption = value; break;
|
||||
case 'translationFor': result.translationFor = value; break;
|
||||
case 'language': result.language = value; break;
|
||||
case 'title': result.title = value; break;
|
||||
case 'alt': result.alt = value; break;
|
||||
case 'caption': result.caption = value; break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ function sanitizeMenuItem(input: unknown): MenuItemData {
|
||||
? 'category-archive'
|
||||
: candidate.kind === 'home'
|
||||
? 'home'
|
||||
: 'page';
|
||||
: 'page';
|
||||
const childrenSource = Array.isArray(candidate.children) ? candidate.children : [];
|
||||
const title = normalizeNonEmptyString(candidate.title) || 'Untitled';
|
||||
|
||||
@@ -171,7 +171,7 @@ function parseOutlineNode(node: OpmlOutlineNode): MenuItemData {
|
||||
? 'category-archive'
|
||||
: rawType === 'home'
|
||||
? 'home'
|
||||
: 'page';
|
||||
: 'page';
|
||||
const textTitle = normalizeNonEmptyString(node['@_text']);
|
||||
const explicitTitle = normalizeNonEmptyString(node['@_title']);
|
||||
const title = kind === 'category-archive'
|
||||
|
||||
@@ -6,7 +6,9 @@ import { eq } from 'drizzle-orm';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, projects } from '../database/schema';
|
||||
import { sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes';
|
||||
import { SUPPORTED_RENDER_LANGUAGES, type SupportedLanguage } from '../shared/i18n';
|
||||
import {
|
||||
SUPPORTED_RENDER_LANGUAGES,
|
||||
} from '../shared/i18n';
|
||||
import {
|
||||
normalizeTaxonomyTerm,
|
||||
normalizeNonEmptyTaxonomyTerm,
|
||||
@@ -89,7 +91,9 @@ function sanitizePublicUrl(value: unknown): string | undefined {
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizePublishingPreferences(prefs: PublishingPreferences): PublishingPreferences {
|
||||
function normalizePublishingPreferences(
|
||||
prefs: PublishingPreferences,
|
||||
): PublishingPreferences {
|
||||
return {
|
||||
sshHost: String(prefs.sshHost ?? '').trim(),
|
||||
sshUser: String(prefs.sshUser ?? '').trim(),
|
||||
@@ -103,7 +107,10 @@ function sanitizeCategoryTitle(value: unknown, fallback: string): string {
|
||||
return trimmed.length > 0 ? trimmed : fallback;
|
||||
}
|
||||
|
||||
type RawCategoryMetadataInput = Record<string, CategoryMetadata | CategoryRenderSettings>;
|
||||
type RawCategoryMetadataInput = Record<
|
||||
string,
|
||||
CategoryMetadata | CategoryRenderSettings
|
||||
>;
|
||||
|
||||
const supportedLanguageSet = new Set<string>(SUPPORTED_RENDER_LANGUAGES);
|
||||
|
||||
@@ -121,12 +128,16 @@ function sanitizeBlogLanguages(value: unknown): string[] | undefined {
|
||||
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 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);
|
||||
const categoryMetadata = normalizeCategoryMetadata(
|
||||
metadata.categoryMetadata ?? metadata.categorySettings,
|
||||
);
|
||||
const blogLanguages = sanitizeBlogLanguages(metadata.blogLanguages);
|
||||
return {
|
||||
...metadata,
|
||||
@@ -155,62 +166,56 @@ function getDefaultCategoryMetadata(): Record<string, CategoryMetadata> {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCategoryMetadata(value: unknown): Record<string, CategoryMetadata> {
|
||||
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)) {
|
||||
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;
|
||||
};
|
||||
const settings =
|
||||
rawSettings as unknown as Partial<CategoryRenderSettings> & {
|
||||
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,
|
||||
postTemplateSlug:
|
||||
typeof settings.postTemplateSlug === 'string'
|
||||
? settings.postTemplateSlug
|
||||
: undefined,
|
||||
listTemplateSlug:
|
||||
typeof settings.listTemplateSlug === 'string'
|
||||
? settings.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.
|
||||
*/
|
||||
@@ -315,9 +320,10 @@ export class MetaEngine extends EventEmitter {
|
||||
*/
|
||||
async setProjectMetadata(metadata: ProjectMetadata): Promise<void> {
|
||||
this.projectMetadata = normalizeProjectMetadata({ ...metadata });
|
||||
this.projectMetadata.categoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
||||
this.projectMetadata.categoryMetadata,
|
||||
);
|
||||
this.projectMetadata.categoryMetadata =
|
||||
this.ensureCategoryMetadataForKnownCategories(
|
||||
this.projectMetadata.categoryMetadata,
|
||||
);
|
||||
await this.saveProjectMetadata();
|
||||
await this.saveCategoryMetadata();
|
||||
this.emit('projectMetadataChanged', this.projectMetadata);
|
||||
@@ -326,16 +332,23 @@ export class MetaEngine extends EventEmitter {
|
||||
/**
|
||||
* Update specific fields of project metadata.
|
||||
*/
|
||||
async updateProjectMetadata(updates: Partial<ProjectMetadata>): Promise<void> {
|
||||
async updateProjectMetadata(
|
||||
updates: Partial<ProjectMetadata>,
|
||||
): Promise<void> {
|
||||
const normalizedUpdates: Partial<ProjectMetadata> = { ...updates };
|
||||
if (updates.maxPostsPerPage !== undefined) {
|
||||
normalizedUpdates.maxPostsPerPage = sanitizeMaxPostsPerPage(updates.maxPostsPerPage);
|
||||
normalizedUpdates.maxPostsPerPage = sanitizeMaxPostsPerPage(
|
||||
updates.maxPostsPerPage,
|
||||
);
|
||||
}
|
||||
if (updates.picoTheme !== undefined) {
|
||||
normalizedUpdates.picoTheme = sanitizePicoTheme(updates.picoTheme);
|
||||
}
|
||||
|
||||
if (updates.categoryMetadata !== undefined || updates.categorySettings !== undefined) {
|
||||
if (
|
||||
updates.categoryMetadata !== undefined ||
|
||||
updates.categorySettings !== undefined
|
||||
) {
|
||||
normalizedUpdates.categoryMetadata = normalizeCategoryMetadata(
|
||||
updates.categoryMetadata ?? updates.categorySettings,
|
||||
);
|
||||
@@ -364,9 +377,10 @@ export class MetaEngine extends EventEmitter {
|
||||
...normalizedUpdates,
|
||||
});
|
||||
}
|
||||
this.projectMetadata.categoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
||||
this.projectMetadata.categoryMetadata,
|
||||
);
|
||||
this.projectMetadata.categoryMetadata =
|
||||
this.ensureCategoryMetadataForKnownCategories(
|
||||
this.projectMetadata.categoryMetadata,
|
||||
);
|
||||
await this.saveProjectMetadata();
|
||||
await this.saveCategoryMetadata();
|
||||
this.emit('projectMetadataChanged', this.projectMetadata);
|
||||
@@ -402,7 +416,10 @@ export class MetaEngine extends EventEmitter {
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('[MetaEngine] Failed to delete publishing preferences:', error);
|
||||
console.error(
|
||||
'[MetaEngine] Failed to delete publishing preferences:',
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -441,7 +458,9 @@ export class MetaEngine extends EventEmitter {
|
||||
this.categories.add(normalizedCategory);
|
||||
const currentMetadata = this.projectMetadata;
|
||||
if (currentMetadata) {
|
||||
const currentCategoryMetadata = normalizeCategoryMetadata(currentMetadata.categoryMetadata ?? currentMetadata.categorySettings);
|
||||
const currentCategoryMetadata = normalizeCategoryMetadata(
|
||||
currentMetadata.categoryMetadata ?? currentMetadata.categorySettings,
|
||||
);
|
||||
if (!currentCategoryMetadata[normalizedCategory]) {
|
||||
currentCategoryMetadata[normalizedCategory] = {
|
||||
renderInLists: true,
|
||||
@@ -469,7 +488,10 @@ export class MetaEngine extends EventEmitter {
|
||||
if (this.categories.delete(normalizedCategory)) {
|
||||
const currentMetadata = this.projectMetadata;
|
||||
const currentCategoryMetadata = currentMetadata
|
||||
? normalizeCategoryMetadata(currentMetadata.categoryMetadata ?? currentMetadata.categorySettings)
|
||||
? normalizeCategoryMetadata(
|
||||
currentMetadata.categoryMetadata ??
|
||||
currentMetadata.categorySettings,
|
||||
)
|
||||
: null;
|
||||
if (currentMetadata && currentCategoryMetadata?.[normalizedCategory]) {
|
||||
const nextCategoryMetadata = { ...currentCategoryMetadata };
|
||||
@@ -493,7 +515,10 @@ export class MetaEngine extends EventEmitter {
|
||||
try {
|
||||
await this.ensureMetaDirExists();
|
||||
const filePath = this.getCategoriesFilePath();
|
||||
await this.writeJsonFileAtomically(filePath, Array.from(this.categories).sort());
|
||||
await this.writeJsonFileAtomically(
|
||||
filePath,
|
||||
Array.from(this.categories).sort(),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[MetaEngine] Failed to save categories:', error);
|
||||
throw error;
|
||||
@@ -507,12 +532,10 @@ export class MetaEngine extends EventEmitter {
|
||||
try {
|
||||
await this.ensureMetaDirExists();
|
||||
const filePath = this.getProjectMetadataFilePath();
|
||||
const {
|
||||
dataPath: _dataPath,
|
||||
categoryMetadata: _categoryMetadata,
|
||||
categorySettings: _categorySettings,
|
||||
...persistedMetadata
|
||||
} = this.projectMetadata || {};
|
||||
const persistedMetadata = { ...(this.projectMetadata || {}) };
|
||||
delete persistedMetadata.dataPath;
|
||||
delete persistedMetadata.categoryMetadata;
|
||||
delete persistedMetadata.categorySettings;
|
||||
await this.writeJsonFileAtomically(filePath, persistedMetadata);
|
||||
} catch (error) {
|
||||
console.error('[MetaEngine] Failed to save project metadata:', error);
|
||||
@@ -549,7 +572,10 @@ export class MetaEngine extends EventEmitter {
|
||||
const filePath = this.getPublishingPreferencesFilePath();
|
||||
await this.writeJsonFileAtomically(filePath, this.publishingPreferences);
|
||||
} catch (error) {
|
||||
console.error('[MetaEngine] Failed to save publishing preferences:', error);
|
||||
console.error(
|
||||
'[MetaEngine] Failed to save publishing preferences:',
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -565,12 +591,18 @@ export class MetaEngine extends EventEmitter {
|
||||
this.publishingPreferences = normalizePublishingPreferences(parsed);
|
||||
} catch (error) {
|
||||
if (isJsonParseError(error)) {
|
||||
console.warn('[MetaEngine] Failed to parse publishing preferences JSON, using null:', 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);
|
||||
console.error(
|
||||
'[MetaEngine] Failed to load publishing preferences:',
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
// File doesn't exist, that's OK
|
||||
@@ -589,7 +621,10 @@ export class MetaEngine extends EventEmitter {
|
||||
this.projectMetadata = normalizeProjectMetadata(parsed);
|
||||
} catch (error) {
|
||||
if (isJsonParseError(error)) {
|
||||
console.warn('[MetaEngine] Failed to parse project metadata JSON, using null metadata:', error);
|
||||
console.warn(
|
||||
'[MetaEngine] Failed to parse project metadata JSON, using null metadata:',
|
||||
error,
|
||||
);
|
||||
this.projectMetadata = null;
|
||||
return;
|
||||
}
|
||||
@@ -605,7 +640,10 @@ export class MetaEngine extends EventEmitter {
|
||||
/**
|
||||
* Load category metadata from the filesystem.
|
||||
*/
|
||||
async loadCategoryMetadata(): Promise<Record<string, CategoryMetadata> | null> {
|
||||
async loadCategoryMetadata(): Promise<Record<
|
||||
string,
|
||||
CategoryMetadata
|
||||
> | null> {
|
||||
try {
|
||||
const filePath = this.getCategoryMetadataFilePath();
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
@@ -613,7 +651,10 @@ export class MetaEngine extends EventEmitter {
|
||||
return normalizeCategoryMetadata(parsed);
|
||||
} catch (error) {
|
||||
if (isJsonParseError(error)) {
|
||||
console.warn('[MetaEngine] Failed to parse category metadata JSON, using default metadata merge:', error);
|
||||
console.warn(
|
||||
'[MetaEngine] Failed to parse category metadata JSON, using default metadata merge:',
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
@@ -641,7 +682,10 @@ export class MetaEngine extends EventEmitter {
|
||||
}
|
||||
} catch (error) {
|
||||
if (isJsonParseError(error)) {
|
||||
console.warn('[MetaEngine] Failed to parse categories JSON, treating as empty and rebuilding from DB/defaults:', error);
|
||||
console.warn(
|
||||
'[MetaEngine] Failed to parse categories JSON, treating as empty and rebuilding from DB/defaults:',
|
||||
error,
|
||||
);
|
||||
this.categories.clear();
|
||||
return;
|
||||
}
|
||||
@@ -678,16 +722,26 @@ export class MetaEngine extends EventEmitter {
|
||||
.where(eq(posts.projectId, this.currentProjectId))
|
||||
.all();
|
||||
|
||||
return collectNormalizedTermsFromJsonValues(dbPosts.map((row) => row.categories));
|
||||
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> {
|
||||
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 })
|
||||
.select({
|
||||
name: projects.name,
|
||||
description: projects.description,
|
||||
dataPath: projects.dataPath,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, this.currentProjectId))
|
||||
.get();
|
||||
@@ -719,7 +773,10 @@ export class MetaEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private async writeJsonFileAtomically(filePath: string, value: unknown): Promise<void> {
|
||||
private async writeJsonFileAtomically(
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
const content = JSON.stringify(value, null, 2);
|
||||
|
||||
@@ -749,7 +806,10 @@ export class MetaEngine extends EventEmitter {
|
||||
showTitle: true,
|
||||
title: category,
|
||||
};
|
||||
} else if (!merged[category].title || merged[category].title.trim().length === 0) {
|
||||
} else if (
|
||||
!merged[category].title ||
|
||||
merged[category].title.trim().length === 0
|
||||
) {
|
||||
merged[category].title = category;
|
||||
}
|
||||
}
|
||||
@@ -759,7 +819,7 @@ export class MetaEngine extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Sync tags and categories on startup.
|
||||
*
|
||||
*
|
||||
* Logic:
|
||||
* - Tags: populated from posts (TagEngine handles persistence with colors)
|
||||
* - Categories: read from file, merge with database
|
||||
@@ -784,34 +844,36 @@ export class MetaEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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) {
|
||||
@@ -820,7 +882,7 @@ export class MetaEngine extends EventEmitter {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Save if there were changes
|
||||
if (changed) {
|
||||
await this.saveCategories();
|
||||
@@ -840,14 +902,16 @@ export class MetaEngine extends EventEmitter {
|
||||
}
|
||||
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}`);
|
||||
throw new Error(
|
||||
`Project not found in database: ${this.currentProjectId}`,
|
||||
);
|
||||
}
|
||||
this.projectMetadata = {
|
||||
name: projectData.name,
|
||||
@@ -857,16 +921,18 @@ export class MetaEngine extends EventEmitter {
|
||||
await this.saveProjectMetadata();
|
||||
}
|
||||
if (this.projectMetadata?.dataPath !== undefined) {
|
||||
const { dataPath: _dataPath, ...metadataWithoutDataPath } = this.projectMetadata;
|
||||
const metadataWithoutDataPath = { ...this.projectMetadata };
|
||||
delete metadataWithoutDataPath.dataPath;
|
||||
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}`);
|
||||
throw new Error(
|
||||
`Project not found in database: ${this.currentProjectId}`,
|
||||
);
|
||||
}
|
||||
this.projectMetadata = {
|
||||
name: projectData.name,
|
||||
@@ -878,14 +944,16 @@ export class MetaEngine extends EventEmitter {
|
||||
|
||||
if (this.projectMetadata) {
|
||||
const legacyCategoryMetadata = normalizeCategoryMetadata(
|
||||
this.projectMetadata.categoryMetadata ?? this.projectMetadata.categorySettings,
|
||||
this.projectMetadata.categoryMetadata ??
|
||||
this.projectMetadata.categorySettings,
|
||||
);
|
||||
const fileCategoryMetadata = categoryMetadataFileExists
|
||||
? await this.loadCategoryMetadata()
|
||||
: null;
|
||||
const mergedCategoryMetadata = this.ensureCategoryMetadataForKnownCategories(
|
||||
fileCategoryMetadata ?? legacyCategoryMetadata,
|
||||
);
|
||||
const mergedCategoryMetadata =
|
||||
this.ensureCategoryMetadataForKnownCategories(
|
||||
fileCategoryMetadata ?? legacyCategoryMetadata,
|
||||
);
|
||||
|
||||
this.projectMetadata = normalizeProjectMetadata({
|
||||
...this.projectMetadata,
|
||||
@@ -898,9 +966,8 @@ export class MetaEngine extends EventEmitter {
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -910,4 +977,3 @@ export class MetaEngine extends EventEmitter {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as path from 'path';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, postTranslations, media, scripts, templates } from '../database/schema';
|
||||
import { readPostFile, PostFileData } from './postFileUtils';
|
||||
import { readPostFile } from './postFileUtils';
|
||||
import { readPostTranslationFile } from './postTranslationFileUtils';
|
||||
import { taskManager } from './TaskManager';
|
||||
import type { PostEngine } from './PostEngine';
|
||||
@@ -223,7 +223,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
postIds: string[],
|
||||
onProgress: ((percent: number, message: string) => void) | undefined,
|
||||
processPost: (postId: string) => Promise<boolean>,
|
||||
errorMessage: (postId: string) => string
|
||||
errorMessage: (postId: string) => string,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
const total = postIds.length;
|
||||
let success = 0;
|
||||
@@ -278,58 +278,59 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
* Get statistics about the posts, media, scripts, and templates tables
|
||||
*/
|
||||
async getTableStats(): Promise<TableStats> {
|
||||
const db = this.getDb();
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Database not initialized');
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
// Get post counts
|
||||
const allPostsResult = await client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM posts WHERE project_id = ?`,
|
||||
sql: 'SELECT COUNT(*) as count FROM posts WHERE project_id = ?',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
const totalPosts = Number(allPostsResult.rows[0]?.count ?? 0);
|
||||
|
||||
const publishedResult = await client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM posts WHERE project_id = ? AND status = 'published' AND file_path IS NOT NULL AND file_path != ''`,
|
||||
sql: 'SELECT COUNT(*) as count FROM posts WHERE project_id = ? AND status = \'published\' AND file_path IS NOT NULL AND file_path != \'\'',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
const publishedPosts = Number(publishedResult.rows[0]?.count ?? 0);
|
||||
|
||||
const draftResult = await client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM posts WHERE project_id = ? AND status = 'draft'`,
|
||||
sql: 'SELECT COUNT(*) as count FROM posts WHERE project_id = ? AND status = \'draft\'',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
const draftPosts = Number(draftResult.rows[0]?.count ?? 0);
|
||||
|
||||
// Get media count
|
||||
const mediaResult = await client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM media WHERE project_id = ?`,
|
||||
sql: 'SELECT COUNT(*) as count FROM media WHERE project_id = ?',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
const totalMedia = Number(mediaResult.rows[0]?.count ?? 0);
|
||||
|
||||
// Get script counts
|
||||
const allScriptsResult = await client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM scripts WHERE project_id = ?`,
|
||||
sql: 'SELECT COUNT(*) as count FROM scripts WHERE project_id = ?',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
const totalScripts = Number(allScriptsResult.rows[0]?.count ?? 0);
|
||||
|
||||
const publishedScriptsResult = await client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM scripts WHERE project_id = ? AND status = 'published'`,
|
||||
sql: 'SELECT COUNT(*) as count FROM scripts WHERE project_id = ? AND status = \'published\'',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
const publishedScripts = Number(publishedScriptsResult.rows[0]?.count ?? 0);
|
||||
|
||||
// Get template counts
|
||||
const allTemplatesResult = await client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM templates WHERE project_id = ?`,
|
||||
sql: 'SELECT COUNT(*) as count FROM templates WHERE project_id = ?',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
const totalTemplates = Number(allTemplatesResult.rows[0]?.count ?? 0);
|
||||
|
||||
const publishedTemplatesResult = await client.execute({
|
||||
sql: `SELECT COUNT(*) as count FROM templates WHERE project_id = ? AND status = 'published'`,
|
||||
sql: 'SELECT COUNT(*) as count FROM templates WHERE project_id = ? AND status = \'published\'',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
const publishedTemplates = Number(publishedTemplatesResult.rows[0]?.count ?? 0);
|
||||
@@ -448,14 +449,30 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
const missingDiffs: Partial<Record<DiffField, FieldDifference>> = {};
|
||||
const dbTags: string[] = JSON.parse(dbPost.tags || '[]');
|
||||
const dbCategories: string[] = JSON.parse(dbPost.categories || '[]');
|
||||
if (dbPost.title) missingDiffs.title = { dbValue: dbPost.title, fileValue: null };
|
||||
if (dbTags.length > 0) missingDiffs.tags = { dbValue: dbTags, fileValue: null };
|
||||
if (dbCategories.length > 0) missingDiffs.categories = { dbValue: dbCategories, fileValue: null };
|
||||
if (dbPost.excerpt) missingDiffs.excerpt = { dbValue: dbPost.excerpt, fileValue: null };
|
||||
if (dbPost.author) missingDiffs.author = { dbValue: dbPost.author, fileValue: null };
|
||||
if (dbPost.language) missingDiffs.language = { dbValue: dbPost.language, fileValue: null };
|
||||
if (dbPost.doNotTranslate) missingDiffs.doNotTranslate = { dbValue: true, fileValue: null };
|
||||
if (dbPost.templateSlug) missingDiffs.templateSlug = { dbValue: dbPost.templateSlug, fileValue: null };
|
||||
if (dbPost.title) {
|
||||
missingDiffs.title = { dbValue: dbPost.title, fileValue: null };
|
||||
}
|
||||
if (dbTags.length > 0) {
|
||||
missingDiffs.tags = { dbValue: dbTags, fileValue: null };
|
||||
}
|
||||
if (dbCategories.length > 0) {
|
||||
missingDiffs.categories = { dbValue: dbCategories, fileValue: null };
|
||||
}
|
||||
if (dbPost.excerpt) {
|
||||
missingDiffs.excerpt = { dbValue: dbPost.excerpt, fileValue: null };
|
||||
}
|
||||
if (dbPost.author) {
|
||||
missingDiffs.author = { dbValue: dbPost.author, fileValue: null };
|
||||
}
|
||||
if (dbPost.language) {
|
||||
missingDiffs.language = { dbValue: dbPost.language, fileValue: null };
|
||||
}
|
||||
if (dbPost.doNotTranslate) {
|
||||
missingDiffs.doNotTranslate = { dbValue: true, fileValue: null };
|
||||
}
|
||||
if (dbPost.templateSlug) {
|
||||
missingDiffs.templateSlug = { dbValue: dbPost.templateSlug, fileValue: null };
|
||||
}
|
||||
return {
|
||||
postId: dbPost.id,
|
||||
title: dbPost.title,
|
||||
@@ -549,7 +566,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
* Compare arrays for equality (order-independent)
|
||||
*/
|
||||
private arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((val, idx) => val === sortedB[idx]);
|
||||
@@ -557,8 +576,12 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
|
||||
/** Compare two dates at second precision (SQLite stores integer seconds). */
|
||||
private datesEqualSeconds(a: Date | null | undefined, b: Date | null | undefined): boolean {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
if (!a && !b) {
|
||||
return true;
|
||||
}
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
return Math.floor(a.getTime() / 1000) === Math.floor(b.getTime() / 1000);
|
||||
}
|
||||
|
||||
@@ -572,7 +595,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
postsBaseDir?: string,
|
||||
): Promise<ScanResult> {
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Database not initialized');
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
// Get all published posts with file paths
|
||||
const result = await client.execute({
|
||||
@@ -609,7 +634,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
const row = publishedPosts[i];
|
||||
const postId = row.id as string;
|
||||
const filePath = row.file_path as string;
|
||||
if (filePath) knownFilePaths.add(filePath);
|
||||
if (filePath) {
|
||||
knownFilePaths.add(filePath);
|
||||
}
|
||||
|
||||
const diff = await this.comparePostMetadata(postId);
|
||||
if (diff && diff.hasDifferences) {
|
||||
@@ -625,7 +652,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
const row = publishedTranslations[i];
|
||||
const translationId = row.id as string;
|
||||
const filePath = row.file_path as string;
|
||||
if (filePath) knownFilePaths.add(filePath);
|
||||
if (filePath) {
|
||||
knownFilePaths.add(filePath);
|
||||
}
|
||||
|
||||
const diff = await this.comparePostMetadata(translationId);
|
||||
if (diff && diff.hasDifferences) {
|
||||
@@ -640,7 +669,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
|
||||
// Also include file_paths from non-published posts so we don't flag them as orphans
|
||||
const allPostsResult = await client.execute({
|
||||
sql: `SELECT file_path FROM posts WHERE project_id = ? AND file_path IS NOT NULL AND file_path != ''`,
|
||||
sql: 'SELECT file_path FROM posts WHERE project_id = ? AND file_path IS NOT NULL AND file_path != \'\'',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
for (const row of allPostsResult.rows) {
|
||||
@@ -648,7 +677,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
const allTranslationsResult = await client.execute({
|
||||
sql: `SELECT file_path FROM post_translations WHERE project_id = ? AND file_path IS NOT NULL AND file_path != ''`,
|
||||
sql: 'SELECT file_path FROM post_translations WHERE project_id = ? AND file_path IS NOT NULL AND file_path != \'\'',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
for (const row of allTranslationsResult.rows) {
|
||||
@@ -679,7 +708,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
onProgress: (current: number, total: number, message: string) => void,
|
||||
scannedSoFar: number,
|
||||
): Promise<OrphanFile[]> {
|
||||
if (!postsBaseDir) return [];
|
||||
if (!postsBaseDir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const markdownExtensions = new Set(['.md', '.markdown', '.mdx']);
|
||||
const allFiles: string[] = [];
|
||||
@@ -709,7 +740,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
// Filter to files not in the known DB set
|
||||
const orphanPaths = allFiles.filter(f => !knownFilePaths.has(f));
|
||||
|
||||
if (orphanPaths.length === 0) return [];
|
||||
if (orphanPaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const orphanFiles: OrphanFile[] = [];
|
||||
for (let i = 0; i < orphanPaths.length; i++) {
|
||||
@@ -777,7 +810,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
for (const diff of diffs) {
|
||||
for (const [field, fieldDiff] of Object.entries(diff.differences)) {
|
||||
const fieldKey = field as DiffField;
|
||||
if (!fieldDiff) continue;
|
||||
if (!fieldDiff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groupMap.has(fieldKey)) {
|
||||
groupMap.set(fieldKey, {
|
||||
@@ -787,7 +822,11 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
groupMap.get(fieldKey)!.posts.push({
|
||||
const postGroup = groupMap.get(fieldKey);
|
||||
if (!postGroup) {
|
||||
continue;
|
||||
}
|
||||
postGroup.posts.push({
|
||||
postId: diff.postId,
|
||||
title: diff.title,
|
||||
slug: diff.slug,
|
||||
@@ -806,10 +845,12 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
*/
|
||||
async syncDbToFile(
|
||||
postIds: string[],
|
||||
onProgress?: (percent: number, message: string) => void
|
||||
onProgress?: (percent: number, message: string) => void,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
const postEngine = this.postEngine;
|
||||
if (!postEngine) throw new Error('MetadataDiffEngine: postEngine not injected');
|
||||
if (!postEngine) {
|
||||
throw new Error('MetadataDiffEngine: postEngine not injected');
|
||||
}
|
||||
return this.runSyncLoop(
|
||||
postIds,
|
||||
onProgress,
|
||||
@@ -823,7 +864,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
}
|
||||
return false;
|
||||
},
|
||||
(postId) => `[MetadataDiffEngine] Failed to sync post ${postId} to file:`
|
||||
(postId) => `[MetadataDiffEngine] Failed to sync post ${postId} to file:`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -834,7 +875,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
async syncFileToDb(
|
||||
postIds: string[],
|
||||
field?: DiffField,
|
||||
onProgress?: (percent: number, message: string) => void
|
||||
onProgress?: (percent: number, message: string) => void,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
const db = this.getDb();
|
||||
return this.runSyncLoop(
|
||||
@@ -944,7 +985,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
|
||||
return true;
|
||||
},
|
||||
(postId) => `[MetadataDiffEngine] Failed to sync post ${postId} to DB:`
|
||||
(postId) => `[MetadataDiffEngine] Failed to sync post ${postId} to DB:`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -955,7 +996,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
*/
|
||||
async compareMediaMetadata(mediaId: string): Promise<MediaMetadataDiff | null> {
|
||||
const db = this.getDb();
|
||||
if (!this.mediaEngine) throw new Error('MetadataDiffEngine: mediaEngine not injected');
|
||||
if (!this.mediaEngine) {
|
||||
throw new Error('MetadataDiffEngine: mediaEngine not injected');
|
||||
}
|
||||
|
||||
const dbMedia = await db
|
||||
.select()
|
||||
@@ -963,19 +1006,33 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
.where(and(eq(media.id, mediaId), eq(media.projectId, this.currentProjectId)))
|
||||
.get();
|
||||
|
||||
if (!dbMedia) return null;
|
||||
if (!dbMedia) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sidecarPath = `${dbMedia.filePath}.meta`;
|
||||
const sidecar = await this.mediaEngine.readSidecarFile(sidecarPath);
|
||||
if (!sidecar) {
|
||||
const missingDiffs: Partial<Record<MediaDiffField, FieldDifference>> = {};
|
||||
if (dbMedia.title) missingDiffs.title = { dbValue: dbMedia.title, fileValue: null };
|
||||
if (dbMedia.alt) missingDiffs.alt = { dbValue: dbMedia.alt, fileValue: null };
|
||||
if (dbMedia.caption) missingDiffs.caption = { dbValue: dbMedia.caption, fileValue: null };
|
||||
if (dbMedia.author) missingDiffs.author = { dbValue: dbMedia.author, fileValue: null };
|
||||
if (dbMedia.language) missingDiffs.language = { dbValue: dbMedia.language, fileValue: null };
|
||||
if (dbMedia.title) {
|
||||
missingDiffs.title = { dbValue: dbMedia.title, fileValue: null };
|
||||
}
|
||||
if (dbMedia.alt) {
|
||||
missingDiffs.alt = { dbValue: dbMedia.alt, fileValue: null };
|
||||
}
|
||||
if (dbMedia.caption) {
|
||||
missingDiffs.caption = { dbValue: dbMedia.caption, fileValue: null };
|
||||
}
|
||||
if (dbMedia.author) {
|
||||
missingDiffs.author = { dbValue: dbMedia.author, fileValue: null };
|
||||
}
|
||||
if (dbMedia.language) {
|
||||
missingDiffs.language = { dbValue: dbMedia.language, fileValue: null };
|
||||
}
|
||||
const dbTags: string[] = JSON.parse(dbMedia.tags || '[]');
|
||||
if (dbTags.length > 0) missingDiffs.tags = { dbValue: dbTags, fileValue: null };
|
||||
if (dbTags.length > 0) {
|
||||
missingDiffs.tags = { dbValue: dbTags, fileValue: null };
|
||||
}
|
||||
return {
|
||||
mediaId: dbMedia.id,
|
||||
originalName: dbMedia.originalName,
|
||||
@@ -1023,13 +1080,15 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
* Scan all media and find metadata differences
|
||||
*/
|
||||
async scanAllMedia(
|
||||
onProgress: (current: number, total: number, message: string) => void
|
||||
onProgress: (current: number, total: number, message: string) => void,
|
||||
): Promise<MediaScanResult> {
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Database not initialized');
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT id FROM media WHERE project_id = ?`,
|
||||
sql: 'SELECT id FROM media WHERE project_id = ?',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
|
||||
@@ -1074,11 +1133,17 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
for (const diff of diffs) {
|
||||
for (const [field, fieldDiff] of Object.entries(diff.differences)) {
|
||||
const fieldKey = field as MediaDiffField;
|
||||
if (!fieldDiff) continue;
|
||||
if (!fieldDiff) {
|
||||
continue;
|
||||
}
|
||||
if (!groupMap.has(fieldKey)) {
|
||||
groupMap.set(fieldKey, { field: fieldKey, label: fieldLabels[fieldKey], items: [] });
|
||||
}
|
||||
groupMap.get(fieldKey)!.items.push({
|
||||
const mediaGroup = groupMap.get(fieldKey);
|
||||
if (!mediaGroup) {
|
||||
continue;
|
||||
}
|
||||
mediaGroup.items.push({
|
||||
mediaId: diff.mediaId,
|
||||
originalName: diff.originalName,
|
||||
dbValue: fieldDiff.dbValue,
|
||||
@@ -1095,9 +1160,11 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
*/
|
||||
async syncMediaDbToFile(
|
||||
mediaIds: string[],
|
||||
onProgress?: (percent: number, message: string) => void
|
||||
onProgress?: (percent: number, message: string) => void,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
if (!this.mediaEngine) throw new Error('MetadataDiffEngine: mediaEngine not injected');
|
||||
if (!this.mediaEngine) {
|
||||
throw new Error('MetadataDiffEngine: mediaEngine not injected');
|
||||
}
|
||||
const mediaEngine = this.mediaEngine;
|
||||
return this.runSyncLoop(
|
||||
mediaIds,
|
||||
@@ -1105,7 +1172,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
async (mediaId) => {
|
||||
// Re-save the media with its current DB values to regenerate sidecar
|
||||
const item = await mediaEngine.getMedia(mediaId);
|
||||
if (!item) return false;
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
await mediaEngine.updateMedia(mediaId, {
|
||||
title: item.title,
|
||||
alt: item.alt,
|
||||
@@ -1115,7 +1184,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
});
|
||||
return true;
|
||||
},
|
||||
(id) => `[MetadataDiffEngine] Failed to sync media ${id} to file:`
|
||||
(id) => `[MetadataDiffEngine] Failed to sync media ${id} to file:`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1125,9 +1194,11 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
async syncMediaFileToDb(
|
||||
mediaIds: string[],
|
||||
field?: MediaDiffField,
|
||||
onProgress?: (percent: number, message: string) => void
|
||||
onProgress?: (percent: number, message: string) => void,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
if (!this.mediaEngine) throw new Error('MetadataDiffEngine: mediaEngine not injected');
|
||||
if (!this.mediaEngine) {
|
||||
throw new Error('MetadataDiffEngine: mediaEngine not injected');
|
||||
}
|
||||
const db = this.getDb();
|
||||
const mediaEngine = this.mediaEngine;
|
||||
return this.runSyncLoop(
|
||||
@@ -1139,24 +1210,40 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
.from(media)
|
||||
.where(and(eq(media.id, mediaId), eq(media.projectId, this.currentProjectId)))
|
||||
.get();
|
||||
if (!dbMedia) return false;
|
||||
if (!dbMedia) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sidecar = await mediaEngine.readSidecarFile(`${dbMedia.filePath}.meta`);
|
||||
if (!sidecar) return false;
|
||||
if (!sidecar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
if (!field || field === 'title') updateData.title = sidecar.title || null;
|
||||
if (!field || field === 'alt') updateData.alt = sidecar.alt || null;
|
||||
if (!field || field === 'caption') updateData.caption = sidecar.caption || null;
|
||||
if (!field || field === 'author') updateData.author = sidecar.author || null;
|
||||
if (!field || field === 'language') updateData.language = sidecar.language || null;
|
||||
if (!field || field === 'tags') updateData.tags = JSON.stringify(sidecar.tags || []);
|
||||
if (!field || field === 'title') {
|
||||
updateData.title = sidecar.title || null;
|
||||
}
|
||||
if (!field || field === 'alt') {
|
||||
updateData.alt = sidecar.alt || null;
|
||||
}
|
||||
if (!field || field === 'caption') {
|
||||
updateData.caption = sidecar.caption || null;
|
||||
}
|
||||
if (!field || field === 'author') {
|
||||
updateData.author = sidecar.author || null;
|
||||
}
|
||||
if (!field || field === 'language') {
|
||||
updateData.language = sidecar.language || null;
|
||||
}
|
||||
if (!field || field === 'tags') {
|
||||
updateData.tags = JSON.stringify(sidecar.tags || []);
|
||||
}
|
||||
|
||||
await db.update(media).set(updateData).where(eq(media.id, mediaId));
|
||||
return true;
|
||||
},
|
||||
(id) => `[MetadataDiffEngine] Failed to sync media ${id} to DB:`
|
||||
(id) => `[MetadataDiffEngine] Failed to sync media ${id} to DB:`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1167,7 +1254,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
*/
|
||||
async compareScriptMetadata(scriptId: string): Promise<ScriptMetadataDiff | null> {
|
||||
const db = this.getDb();
|
||||
if (!this.scriptEngine) throw new Error('MetadataDiffEngine: scriptEngine not injected');
|
||||
if (!this.scriptEngine) {
|
||||
throw new Error('MetadataDiffEngine: scriptEngine not injected');
|
||||
}
|
||||
|
||||
const dbScript = await db
|
||||
.select()
|
||||
@@ -1175,18 +1264,30 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
.where(and(eq(scripts.id, scriptId), eq(scripts.projectId, this.currentProjectId)))
|
||||
.get();
|
||||
|
||||
if (!dbScript) return null;
|
||||
if (!dbScript) {
|
||||
return null;
|
||||
}
|
||||
// Skip drafts — they don't have on-disk files
|
||||
if (dbScript.status === 'draft') return null;
|
||||
if (dbScript.status === 'draft') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await this.scriptEngine.readScriptFileWithMetadata(dbScript.filePath);
|
||||
if (!parsed) {
|
||||
const missingDiffs: Partial<Record<ScriptDiffField, FieldDifference>> = {};
|
||||
if (dbScript.title) missingDiffs.title = { dbValue: dbScript.title, fileValue: null };
|
||||
if (dbScript.kind) missingDiffs.kind = { dbValue: dbScript.kind, fileValue: null };
|
||||
if (dbScript.entrypoint) missingDiffs.entrypoint = { dbValue: dbScript.entrypoint, fileValue: null };
|
||||
if (dbScript.title) {
|
||||
missingDiffs.title = { dbValue: dbScript.title, fileValue: null };
|
||||
}
|
||||
if (dbScript.kind) {
|
||||
missingDiffs.kind = { dbValue: dbScript.kind, fileValue: null };
|
||||
}
|
||||
if (dbScript.entrypoint) {
|
||||
missingDiffs.entrypoint = { dbValue: dbScript.entrypoint, fileValue: null };
|
||||
}
|
||||
missingDiffs.enabled = { dbValue: !!dbScript.enabled, fileValue: null };
|
||||
if (dbScript.version) missingDiffs.version = { dbValue: dbScript.version, fileValue: null };
|
||||
if (dbScript.version) {
|
||||
missingDiffs.version = { dbValue: dbScript.version, fileValue: null };
|
||||
}
|
||||
return {
|
||||
scriptId: dbScript.id,
|
||||
title: dbScript.title,
|
||||
@@ -1233,13 +1334,15 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
* Scan all published scripts and find metadata differences
|
||||
*/
|
||||
async scanAllScripts(
|
||||
onProgress: (current: number, total: number, message: string) => void
|
||||
onProgress: (current: number, total: number, message: string) => void,
|
||||
): Promise<ScriptScanResult> {
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Database not initialized');
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT id FROM scripts WHERE project_id = ? AND status = 'published'`,
|
||||
sql: 'SELECT id FROM scripts WHERE project_id = ? AND status = \'published\'',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
|
||||
@@ -1283,11 +1386,17 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
for (const diff of diffs) {
|
||||
for (const [field, fieldDiff] of Object.entries(diff.differences)) {
|
||||
const fieldKey = field as ScriptDiffField;
|
||||
if (!fieldDiff) continue;
|
||||
if (!fieldDiff) {
|
||||
continue;
|
||||
}
|
||||
if (!groupMap.has(fieldKey)) {
|
||||
groupMap.set(fieldKey, { field: fieldKey, label: fieldLabels[fieldKey], items: [] });
|
||||
}
|
||||
groupMap.get(fieldKey)!.items.push({
|
||||
const scriptGroup = groupMap.get(fieldKey);
|
||||
if (!scriptGroup) {
|
||||
continue;
|
||||
}
|
||||
scriptGroup.items.push({
|
||||
scriptId: diff.scriptId,
|
||||
title: diff.title,
|
||||
slug: diff.slug,
|
||||
@@ -1305,9 +1414,11 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
*/
|
||||
async syncScriptDbToFile(
|
||||
scriptIds: string[],
|
||||
onProgress?: (percent: number, message: string) => void
|
||||
onProgress?: (percent: number, message: string) => void,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
if (!this.scriptEngine) throw new Error('MetadataDiffEngine: scriptEngine not injected');
|
||||
if (!this.scriptEngine) {
|
||||
throw new Error('MetadataDiffEngine: scriptEngine not injected');
|
||||
}
|
||||
const scriptEngine = this.scriptEngine;
|
||||
return this.runSyncLoop(
|
||||
scriptIds,
|
||||
@@ -1315,11 +1426,13 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
async (scriptId) => {
|
||||
// Trigger an updateScript with no actual changes — this re-serialises the file
|
||||
const item = await scriptEngine.getScript(scriptId);
|
||||
if (!item) return false;
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
await scriptEngine.updateScript(scriptId, { title: item.title });
|
||||
return true;
|
||||
},
|
||||
(id) => `[MetadataDiffEngine] Failed to sync script ${id} to file:`
|
||||
(id) => `[MetadataDiffEngine] Failed to sync script ${id} to file:`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1329,9 +1442,11 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
async syncScriptFileToDb(
|
||||
scriptIds: string[],
|
||||
field?: ScriptDiffField,
|
||||
onProgress?: (percent: number, message: string) => void
|
||||
onProgress?: (percent: number, message: string) => void,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
if (!this.scriptEngine) throw new Error('MetadataDiffEngine: scriptEngine not injected');
|
||||
if (!this.scriptEngine) {
|
||||
throw new Error('MetadataDiffEngine: scriptEngine not injected');
|
||||
}
|
||||
const db = this.getDb();
|
||||
const scriptEngine = this.scriptEngine;
|
||||
return this.runSyncLoop(
|
||||
@@ -1343,26 +1458,38 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
.from(scripts)
|
||||
.where(and(eq(scripts.id, scriptId), eq(scripts.projectId, this.currentProjectId)))
|
||||
.get();
|
||||
if (!dbScript) return false;
|
||||
if (!dbScript) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = await scriptEngine.readScriptFileWithMetadata(dbScript.filePath);
|
||||
if (!parsed) return false;
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const fm = parsed.metadata;
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
if (!field || field === 'title') updateData.title = fm.title || dbScript.title;
|
||||
if (!field || field === 'kind') updateData.kind = fm.kind || dbScript.kind;
|
||||
if (!field || field === 'entrypoint') updateData.entrypoint = fm.entrypoint || dbScript.entrypoint;
|
||||
if (!field || field === 'title') {
|
||||
updateData.title = fm.title || dbScript.title;
|
||||
}
|
||||
if (!field || field === 'kind') {
|
||||
updateData.kind = fm.kind || dbScript.kind;
|
||||
}
|
||||
if (!field || field === 'entrypoint') {
|
||||
updateData.entrypoint = fm.entrypoint || dbScript.entrypoint;
|
||||
}
|
||||
if (!field || field === 'enabled') {
|
||||
updateData.enabled = !!fm.enabled;
|
||||
}
|
||||
if (!field || field === 'version') updateData.version = fm.version ?? dbScript.version;
|
||||
if (!field || field === 'version') {
|
||||
updateData.version = fm.version ?? dbScript.version;
|
||||
}
|
||||
|
||||
await db.update(scripts).set(updateData).where(eq(scripts.id, scriptId));
|
||||
return true;
|
||||
},
|
||||
(id) => `[MetadataDiffEngine] Failed to sync script ${id} to DB:`
|
||||
(id) => `[MetadataDiffEngine] Failed to sync script ${id} to DB:`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1373,7 +1500,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
*/
|
||||
async compareTemplateMetadata(templateId: string): Promise<TemplateMetadataDiff | null> {
|
||||
const db = this.getDb();
|
||||
if (!this.templateEngine) throw new Error('MetadataDiffEngine: templateEngine not injected');
|
||||
if (!this.templateEngine) {
|
||||
throw new Error('MetadataDiffEngine: templateEngine not injected');
|
||||
}
|
||||
|
||||
const dbTemplate = await db
|
||||
.select()
|
||||
@@ -1381,16 +1510,26 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
.where(and(eq(templates.id, templateId), eq(templates.projectId, this.currentProjectId)))
|
||||
.get();
|
||||
|
||||
if (!dbTemplate) return null;
|
||||
if (dbTemplate.status === 'draft') return null;
|
||||
if (!dbTemplate) {
|
||||
return null;
|
||||
}
|
||||
if (dbTemplate.status === 'draft') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await this.templateEngine.readTemplateFileWithMetadata(dbTemplate.filePath);
|
||||
if (!parsed) {
|
||||
const missingDiffs: Partial<Record<TemplateDiffField, FieldDifference>> = {};
|
||||
if (dbTemplate.title) missingDiffs.title = { dbValue: dbTemplate.title, fileValue: null };
|
||||
if (dbTemplate.kind) missingDiffs.kind = { dbValue: dbTemplate.kind, fileValue: null };
|
||||
if (dbTemplate.title) {
|
||||
missingDiffs.title = { dbValue: dbTemplate.title, fileValue: null };
|
||||
}
|
||||
if (dbTemplate.kind) {
|
||||
missingDiffs.kind = { dbValue: dbTemplate.kind, fileValue: null };
|
||||
}
|
||||
missingDiffs.enabled = { dbValue: !!dbTemplate.enabled, fileValue: null };
|
||||
if (dbTemplate.version) missingDiffs.version = { dbValue: dbTemplate.version, fileValue: null };
|
||||
if (dbTemplate.version) {
|
||||
missingDiffs.version = { dbValue: dbTemplate.version, fileValue: null };
|
||||
}
|
||||
return {
|
||||
templateId: dbTemplate.id,
|
||||
title: dbTemplate.title,
|
||||
@@ -1434,13 +1573,15 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
* Scan all published templates and find metadata differences
|
||||
*/
|
||||
async scanAllTemplates(
|
||||
onProgress: (current: number, total: number, message: string) => void
|
||||
onProgress: (current: number, total: number, message: string) => void,
|
||||
): Promise<TemplateScanResult> {
|
||||
const client = this.getClient();
|
||||
if (!client) throw new Error('Database not initialized');
|
||||
if (!client) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const result = await client.execute({
|
||||
sql: `SELECT id FROM templates WHERE project_id = ? AND status = 'published'`,
|
||||
sql: 'SELECT id FROM templates WHERE project_id = ? AND status = \'published\'',
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
|
||||
@@ -1483,11 +1624,17 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
for (const diff of diffs) {
|
||||
for (const [field, fieldDiff] of Object.entries(diff.differences)) {
|
||||
const fieldKey = field as TemplateDiffField;
|
||||
if (!fieldDiff) continue;
|
||||
if (!fieldDiff) {
|
||||
continue;
|
||||
}
|
||||
if (!groupMap.has(fieldKey)) {
|
||||
groupMap.set(fieldKey, { field: fieldKey, label: fieldLabels[fieldKey], items: [] });
|
||||
}
|
||||
groupMap.get(fieldKey)!.items.push({
|
||||
const templateGroup = groupMap.get(fieldKey);
|
||||
if (!templateGroup) {
|
||||
continue;
|
||||
}
|
||||
templateGroup.items.push({
|
||||
templateId: diff.templateId,
|
||||
title: diff.title,
|
||||
slug: diff.slug,
|
||||
@@ -1505,20 +1652,24 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
*/
|
||||
async syncTemplateDbToFile(
|
||||
templateIds: string[],
|
||||
onProgress?: (percent: number, message: string) => void
|
||||
onProgress?: (percent: number, message: string) => void,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
if (!this.templateEngine) throw new Error('MetadataDiffEngine: templateEngine not injected');
|
||||
if (!this.templateEngine) {
|
||||
throw new Error('MetadataDiffEngine: templateEngine not injected');
|
||||
}
|
||||
const templateEngine = this.templateEngine;
|
||||
return this.runSyncLoop(
|
||||
templateIds,
|
||||
onProgress,
|
||||
async (templateId) => {
|
||||
const item = await templateEngine.getTemplate(templateId);
|
||||
if (!item) return false;
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
await templateEngine.updateTemplate(templateId, { title: item.title });
|
||||
return true;
|
||||
},
|
||||
(id) => `[MetadataDiffEngine] Failed to sync template ${id} to file:`
|
||||
(id) => `[MetadataDiffEngine] Failed to sync template ${id} to file:`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1528,9 +1679,11 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
async syncTemplateFileToDb(
|
||||
templateIds: string[],
|
||||
field?: TemplateDiffField,
|
||||
onProgress?: (percent: number, message: string) => void
|
||||
onProgress?: (percent: number, message: string) => void,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
if (!this.templateEngine) throw new Error('MetadataDiffEngine: templateEngine not injected');
|
||||
if (!this.templateEngine) {
|
||||
throw new Error('MetadataDiffEngine: templateEngine not injected');
|
||||
}
|
||||
const db = this.getDb();
|
||||
const templateEngine = this.templateEngine;
|
||||
return this.runSyncLoop(
|
||||
@@ -1542,25 +1695,35 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
.from(templates)
|
||||
.where(and(eq(templates.id, templateId), eq(templates.projectId, this.currentProjectId)))
|
||||
.get();
|
||||
if (!dbTemplate) return false;
|
||||
if (!dbTemplate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = await templateEngine.readTemplateFileWithMetadata(dbTemplate.filePath);
|
||||
if (!parsed) return false;
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const fm = parsed.metadata;
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
if (!field || field === 'title') updateData.title = fm.title || dbTemplate.title;
|
||||
if (!field || field === 'kind') updateData.kind = fm.kind || dbTemplate.kind;
|
||||
if (!field || field === 'title') {
|
||||
updateData.title = fm.title || dbTemplate.title;
|
||||
}
|
||||
if (!field || field === 'kind') {
|
||||
updateData.kind = fm.kind || dbTemplate.kind;
|
||||
}
|
||||
if (!field || field === 'enabled') {
|
||||
updateData.enabled = !!fm.enabled;
|
||||
}
|
||||
if (!field || field === 'version') updateData.version = fm.version ?? dbTemplate.version;
|
||||
if (!field || field === 'version') {
|
||||
updateData.version = fm.version ?? dbTemplate.version;
|
||||
}
|
||||
|
||||
await db.update(templates).set(updateData).where(eq(templates.id, templateId));
|
||||
return true;
|
||||
},
|
||||
(id) => `[MetadataDiffEngine] Failed to sync template ${id} to DB:`
|
||||
(id) => `[MetadataDiffEngine] Failed to sync template ${id} to DB:`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1739,7 +1902,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
onProgress?: (current: number, total: number, message: string) => void,
|
||||
): Promise<{ success: number; failed: number }> {
|
||||
const postEngine = this.postEngine;
|
||||
if (!postEngine) throw new Error('MetadataDiffEngine: postEngine not injected');
|
||||
if (!postEngine) {
|
||||
throw new Error('MetadataDiffEngine: postEngine not injected');
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
@@ -110,7 +110,9 @@ export class ModelCatalogEngine {
|
||||
// Search across all providers, return first match
|
||||
rows = await db.select().from(modelCatalog).where(eq(modelCatalog.modelId, modelId));
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const row = rows[0];
|
||||
const modalities = await db.select().from(modelCatalogModalities).where(
|
||||
and(eq(modelCatalogModalities.provider, row.provider), eq(modelCatalogModalities.modelId, row.modelId)),
|
||||
@@ -277,7 +279,9 @@ export class ModelCatalogEngine {
|
||||
let count = 0;
|
||||
|
||||
for (const [id, info] of Object.entries(models)) {
|
||||
if (!info || typeof info !== 'object') continue;
|
||||
if (!info || typeof info !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
provider: providerId,
|
||||
|
||||
@@ -83,12 +83,16 @@ export class NotificationWatcher {
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
this.debounceTimer = setTimeout(() => this.process(), this.debounceMs);
|
||||
}
|
||||
|
||||
private async process(): Promise<void> {
|
||||
if (this.isProcessing) return;
|
||||
if (this.isProcessing) {
|
||||
return;
|
||||
}
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
const rows = await this.db
|
||||
|
||||
@@ -271,7 +271,7 @@ export const PREVIEW_ASSETS: Record<string, PreviewAssetDefinition> = {
|
||||
modulePath: `@picocss/pico/css/pico.${theme}.min.css`,
|
||||
contentType: 'text/css; charset=utf-8',
|
||||
},
|
||||
])
|
||||
]),
|
||||
),
|
||||
'lightbox.min.css': {
|
||||
modulePath: 'lightbox2/dist/css/lightbox.min.css',
|
||||
@@ -352,8 +352,12 @@ export function clampMaxPostsPerPage(value: unknown): number {
|
||||
}
|
||||
|
||||
const normalized = Math.floor(value);
|
||||
if (normalized < MIN_MAX_POSTS_PER_PAGE) return DEFAULT_MAX_POSTS_PER_PAGE;
|
||||
if (normalized > MAX_MAX_POSTS_PER_PAGE) return MAX_MAX_POSTS_PER_PAGE;
|
||||
if (normalized < MIN_MAX_POSTS_PER_PAGE) {
|
||||
return DEFAULT_MAX_POSTS_PER_PAGE;
|
||||
}
|
||||
if (normalized > MAX_MAX_POSTS_PER_PAGE) {
|
||||
return MAX_MAX_POSTS_PER_PAGE;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -391,7 +395,9 @@ function escapeHtml(value: string): string {
|
||||
}
|
||||
|
||||
function parseMacroParams(paramString: string | undefined): Record<string, string> {
|
||||
if (!paramString) return {};
|
||||
if (!paramString) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
const regex = /(\w+)=(?:["']([^"']*?)["']|([^\s\]]+))/g;
|
||||
@@ -405,7 +411,9 @@ function parseMacroParams(paramString: string | undefined): Record<string, strin
|
||||
}
|
||||
|
||||
function parseIntegerParam(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
@@ -734,8 +742,12 @@ function renderTagCloudMacro(params: Record<string, string>, tagUsage: TagUsageE
|
||||
|
||||
function isExternalOrSpecialUrl(value: string): boolean {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return false;
|
||||
if (normalized.startsWith('#') || normalized.startsWith('//')) return true;
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.startsWith('#') || normalized.startsWith('//')) {
|
||||
return true;
|
||||
}
|
||||
return /^[a-z][a-z0-9+.-]*:/i.test(normalized);
|
||||
}
|
||||
|
||||
@@ -841,9 +853,13 @@ export function rewriteRenderedHtmlUrls(html: string, rewriteContext: HtmlRewrit
|
||||
}
|
||||
|
||||
export function applyLanguagePrefixToHtml(html: string, languagePrefix: string): string {
|
||||
if (!languagePrefix) return html;
|
||||
if (!languagePrefix) {
|
||||
return html;
|
||||
}
|
||||
return html.replace(/\bhref=(['"])(\/(?!media\/|assets\/).*?)\1/gi, (_fullMatch, quote: string, href: string) => {
|
||||
if (href.startsWith(languagePrefix + '/') || href === languagePrefix) return `href=${quote}${href}${quote}`;
|
||||
if (href.startsWith(languagePrefix + '/') || href === languagePrefix) {
|
||||
return `href=${quote}${href}${quote}`;
|
||||
}
|
||||
return `href=${quote}${languagePrefix}${href}${quote}`;
|
||||
});
|
||||
}
|
||||
@@ -863,7 +879,9 @@ export function renderMacro(
|
||||
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
|
||||
const id = (params.id || '').trim();
|
||||
const title = (params.title || translateRender(language, 'render.video.youtubeTitle')).trim();
|
||||
if (!id) return '';
|
||||
if (!id) {
|
||||
return '';
|
||||
}
|
||||
return renderMacroTemplate('youtube', { id, title });
|
||||
}
|
||||
|
||||
@@ -871,7 +889,9 @@ export function renderMacro(
|
||||
const language = resolveRenderLanguageFromProjectPreferences(renderLanguage);
|
||||
const id = (params.id || '').trim();
|
||||
const title = (params.title || translateRender(language, 'render.video.vimeoTitle')).trim();
|
||||
if (!id) return '';
|
||||
if (!id) {
|
||||
return '';
|
||||
}
|
||||
return renderMacroTemplate('vimeo', { id, title });
|
||||
}
|
||||
|
||||
@@ -1428,20 +1448,20 @@ export class PageRenderer {
|
||||
show_archive_range_heading: hasRangeHeading,
|
||||
archive_context: options.routeKind === 'date'
|
||||
? {
|
||||
kind: options.archiveContext?.kind ?? 'root',
|
||||
name: options.archiveContext?.name ?? null,
|
||||
year: options.archiveContext?.year ?? null,
|
||||
month: options.archiveContext?.month ?? null,
|
||||
day: options.archiveContext?.day ?? null,
|
||||
}
|
||||
kind: options.archiveContext?.kind ?? 'root',
|
||||
name: options.archiveContext?.name ?? null,
|
||||
year: options.archiveContext?.year ?? null,
|
||||
month: options.archiveContext?.month ?? null,
|
||||
day: options.archiveContext?.day ?? null,
|
||||
}
|
||||
: options.archiveContext
|
||||
? {
|
||||
kind: options.archiveContext.kind,
|
||||
name: options.archiveContext.name ?? null,
|
||||
year: options.archiveContext.year ?? null,
|
||||
month: options.archiveContext.month ?? null,
|
||||
day: options.archiveContext.day ?? null,
|
||||
}
|
||||
kind: options.archiveContext.kind,
|
||||
name: options.archiveContext.name ?? null,
|
||||
year: options.archiveContext.year ?? null,
|
||||
month: options.archiveContext.month ?? null,
|
||||
day: options.archiveContext.day ?? null,
|
||||
}
|
||||
: null,
|
||||
min_date: minDateParts,
|
||||
max_date: maxDateParts,
|
||||
|
||||
@@ -4,13 +4,13 @@ import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import matter from 'gray-matter';
|
||||
import { eq, and, desc, gte, lte, like, inArray, ne, sql } from 'drizzle-orm';
|
||||
import { eq, and, desc, gte, lte, inArray, ne, sql } from 'drizzle-orm';
|
||||
import { app } from 'electron';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, postTranslations, Post, PostTranslation, NewPost, NewPostTranslation, postLinks } from '../database/schema';
|
||||
import { taskManager, Task } from './TaskManager';
|
||||
import { stemText, stemQuery, isoToStemmerLanguage, SupportedLanguage } from './stemmer';
|
||||
import { readPostFile as readPostFileShared, type PostFileData } from './postFileUtils';
|
||||
import { readPostFile as readPostFileShared } from './postFileUtils';
|
||||
import { readPostTranslationFile as readPostTranslationFileShared, type PostTranslationFileData } from './postTranslationFileUtils';
|
||||
import { CliNotifier, NoopNotifier } from './CliNotifier';
|
||||
import type { MediaEngine } from './MediaEngine';
|
||||
@@ -150,7 +150,9 @@ export class PostEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/** No persistent cache — DB is the source of truth. No-op for watcher compat. */
|
||||
invalidate(_entityId?: string): void {}
|
||||
invalidate(entityId?: string): void {
|
||||
void entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the language used for full-text search stemming.
|
||||
@@ -199,7 +201,9 @@ export class PostEngine extends EventEmitter {
|
||||
categories: string[];
|
||||
}): Promise<void> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete existing entry
|
||||
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [post.id] });
|
||||
@@ -268,14 +272,18 @@ export class PostEngine extends EventEmitter {
|
||||
*/
|
||||
private async deleteFTSIndex(id: string): Promise<void> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
|
||||
}
|
||||
|
||||
private dataDir: string | null = null;
|
||||
|
||||
private getDataDir(): string {
|
||||
if (this.dataDir) return this.dataDir;
|
||||
if (this.dataDir) {
|
||||
return this.dataDir;
|
||||
}
|
||||
const userDataPath = app.getPath('userData');
|
||||
return path.join(userDataPath, 'projects', this.currentProjectId);
|
||||
}
|
||||
@@ -339,12 +347,16 @@ export class PostEngine extends EventEmitter {
|
||||
.from(posts)
|
||||
.where(and(
|
||||
eq(posts.slug, slug),
|
||||
eq(posts.projectId, this.currentProjectId)
|
||||
eq(posts.projectId, this.currentProjectId),
|
||||
))
|
||||
.get();
|
||||
|
||||
if (!existing) return true;
|
||||
if (excludePostId && existing.id === excludePostId) return true;
|
||||
if (!existing) {
|
||||
return true;
|
||||
}
|
||||
if (excludePostId && existing.id === excludePostId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -562,12 +574,24 @@ export class PostEngine extends EventEmitter {
|
||||
};
|
||||
|
||||
// Only add optional fields if they have values (gray-matter can't serialize undefined)
|
||||
if (post.excerpt) metadata.excerpt = post.excerpt;
|
||||
if (post.author) metadata.author = post.author;
|
||||
if (post.language) metadata.language = post.language;
|
||||
if (post.doNotTranslate) metadata.doNotTranslate = true;
|
||||
if (post.templateSlug) metadata.templateSlug = post.templateSlug;
|
||||
if (post.publishedAt) metadata.publishedAt = post.publishedAt.toISOString();
|
||||
if (post.excerpt) {
|
||||
metadata.excerpt = post.excerpt;
|
||||
}
|
||||
if (post.author) {
|
||||
metadata.author = post.author;
|
||||
}
|
||||
if (post.language) {
|
||||
metadata.language = post.language;
|
||||
}
|
||||
if (post.doNotTranslate) {
|
||||
metadata.doNotTranslate = true;
|
||||
}
|
||||
if (post.templateSlug) {
|
||||
metadata.templateSlug = post.templateSlug;
|
||||
}
|
||||
if (post.publishedAt) {
|
||||
metadata.publishedAt = post.publishedAt.toISOString();
|
||||
}
|
||||
|
||||
// Use date-based directory structure (posts/YYYY/MM/)
|
||||
const postsDir = this.getPostsDirForDate(post.createdAt);
|
||||
@@ -587,7 +611,9 @@ export class PostEngine extends EventEmitter {
|
||||
title: translation.title,
|
||||
};
|
||||
|
||||
if (translation.excerpt) metadata.excerpt = translation.excerpt;
|
||||
if (translation.excerpt) {
|
||||
metadata.excerpt = translation.excerpt;
|
||||
}
|
||||
|
||||
const postsDir = this.getPostsDirForDate(sourcePost.createdAt);
|
||||
await fs.mkdir(postsDir, { recursive: true });
|
||||
@@ -600,7 +626,9 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
private async readPostFile(filePath: string): Promise<PostData | null> {
|
||||
const data = await readPostFileShared(filePath);
|
||||
if (!data) return null;
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileStem = path.parse(filePath).name;
|
||||
const normalizedTitle = typeof data.title === 'string' && data.title.trim().length > 0
|
||||
@@ -727,7 +755,6 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
async createPost(data: Partial<PostData>): Promise<PostData> {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
const now = new Date();
|
||||
const id = uuidv4();
|
||||
|
||||
@@ -790,7 +817,6 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
async updatePost(id: string, data: Partial<PostData>): Promise<PostData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
const existing = await this.getPost(id);
|
||||
|
||||
if (!existing) {
|
||||
@@ -895,7 +921,6 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
async deletePost(id: string): Promise<boolean> {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
const existing = await db.select().from(posts).where(eq(posts.id, id)).get();
|
||||
|
||||
if (!existing) {
|
||||
@@ -995,7 +1020,7 @@ export class PostEngine extends EventEmitter {
|
||||
.from(posts)
|
||||
.where(and(
|
||||
eq(posts.slug, slug),
|
||||
eq(posts.projectId, this.currentProjectId)
|
||||
eq(posts.projectId, this.currentProjectId),
|
||||
))
|
||||
.get();
|
||||
const post = await this.resolvePostData(dbPost);
|
||||
@@ -1064,12 +1089,20 @@ export class PostEngine extends EventEmitter {
|
||||
const postById = new Map(allPosts.map((p) => [p.id, p]));
|
||||
const result = new Map<string, string[]>();
|
||||
for (const row of allTranslations) {
|
||||
if (row.status !== 'published') continue;
|
||||
if (row.status !== 'published') {
|
||||
continue;
|
||||
}
|
||||
const sourcePost = postById.get(row.translationFor);
|
||||
if (!sourcePost) continue;
|
||||
if (this.isCanonicalTranslationLanguage(sourcePost, row.language)) continue;
|
||||
if (!sourcePost) {
|
||||
continue;
|
||||
}
|
||||
if (this.isCanonicalTranslationLanguage(sourcePost, row.language)) {
|
||||
continue;
|
||||
}
|
||||
const lang = row.language?.trim().toLowerCase();
|
||||
if (!lang) continue;
|
||||
if (!lang) {
|
||||
continue;
|
||||
}
|
||||
const existing = result.get(row.translationFor) ?? [];
|
||||
existing.push(lang);
|
||||
result.set(row.translationFor, existing);
|
||||
@@ -1083,10 +1116,16 @@ export class PostEngine extends EventEmitter {
|
||||
const result = new Map<string, PostTranslationData[]>();
|
||||
|
||||
for (const row of allRows) {
|
||||
if (row.status !== 'published') continue;
|
||||
if (row.status !== 'published') {
|
||||
continue;
|
||||
}
|
||||
const sourcePost = postById.get(row.translationFor);
|
||||
if (!sourcePost) continue;
|
||||
if (this.isCanonicalTranslationLanguage(sourcePost, row.language)) continue;
|
||||
if (!sourcePost) {
|
||||
continue;
|
||||
}
|
||||
if (this.isCanonicalTranslationLanguage(sourcePost, row.language)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const translationData: PostTranslationData = {
|
||||
id: row.id,
|
||||
@@ -1526,7 +1565,9 @@ export class PostEngine extends EventEmitter {
|
||||
const db = getDatabase().getLocal();
|
||||
|
||||
const postData = await this.readPostFile(filePath);
|
||||
if (!postData) return null;
|
||||
if (!postData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure unique ID and slug within the current project
|
||||
const { id, slug } = await this.ensureUniquePostIdentity(postData.id, postData.slug);
|
||||
@@ -1659,7 +1700,7 @@ export class PostEngine extends EventEmitter {
|
||||
.from(posts)
|
||||
.where(and(
|
||||
eq(posts.projectId, this.currentProjectId),
|
||||
eq(posts.status, 'draft')
|
||||
eq(posts.status, 'draft'),
|
||||
))
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.all();
|
||||
@@ -1671,7 +1712,7 @@ export class PostEngine extends EventEmitter {
|
||||
.from(posts)
|
||||
.where(and(
|
||||
eq(posts.projectId, this.currentProjectId),
|
||||
ne(posts.status, 'draft')
|
||||
ne(posts.status, 'draft'),
|
||||
))
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(remainingSlots)
|
||||
@@ -1679,7 +1720,7 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
const allDbPosts = [...draftPosts, ...nonDraftPosts];
|
||||
const items = await this.appendAvailableLanguagesToList(allDbPosts.map(dbPost =>
|
||||
this.dbRowToPostData(dbPost, dbPost.content || '')
|
||||
this.dbRowToPostData(dbPost, dbPost.content || ''),
|
||||
));
|
||||
|
||||
return {
|
||||
@@ -1696,7 +1737,7 @@ export class PostEngine extends EventEmitter {
|
||||
.from(posts)
|
||||
.where(and(
|
||||
eq(posts.projectId, this.currentProjectId),
|
||||
eq(posts.status, 'draft')
|
||||
eq(posts.status, 'draft'),
|
||||
))
|
||||
.all();
|
||||
const numDrafts = draftCount.length;
|
||||
@@ -1709,7 +1750,7 @@ export class PostEngine extends EventEmitter {
|
||||
.from(posts)
|
||||
.where(and(
|
||||
eq(posts.projectId, this.currentProjectId),
|
||||
ne(posts.status, 'draft')
|
||||
ne(posts.status, 'draft'),
|
||||
))
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.limit(limit)
|
||||
@@ -1717,7 +1758,7 @@ export class PostEngine extends EventEmitter {
|
||||
.all();
|
||||
|
||||
const items = await this.appendAvailableLanguagesToList(dbPosts.map(dbPost =>
|
||||
this.dbRowToPostData(dbPost, dbPost.content || '')
|
||||
this.dbRowToPostData(dbPost, dbPost.content || ''),
|
||||
));
|
||||
|
||||
return {
|
||||
@@ -1752,7 +1793,7 @@ export class PostEngine extends EventEmitter {
|
||||
.from(posts)
|
||||
.where(and(
|
||||
eq(posts.projectId, this.currentProjectId),
|
||||
eq(posts.status, status)
|
||||
eq(posts.status, status),
|
||||
))
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.all();
|
||||
@@ -1798,7 +1839,7 @@ export class PostEngine extends EventEmitter {
|
||||
select 1
|
||||
from json_each(${posts.categories}) as included_category
|
||||
where included_category.value = ${category}
|
||||
)`
|
||||
)`,
|
||||
);
|
||||
conditions.push(sql`(${sql.join(includePredicates, sql` OR `)})`);
|
||||
}
|
||||
@@ -1809,7 +1850,7 @@ export class PostEngine extends EventEmitter {
|
||||
select 1
|
||||
from json_each(${posts.categories}) as excluded_category
|
||||
where excluded_category.value = ${category}
|
||||
)`
|
||||
)`,
|
||||
);
|
||||
conditions.push(sql`NOT (${sql.join(excludePredicates, sql` OR `)})`);
|
||||
}
|
||||
@@ -1821,7 +1862,7 @@ export class PostEngine extends EventEmitter {
|
||||
.orderBy(desc(posts.createdAt))
|
||||
.all();
|
||||
|
||||
let result: PostData[] = [];
|
||||
const result: PostData[] = [];
|
||||
|
||||
for (const dbPost of dbPosts) {
|
||||
// Use DB data directly instead of reading from filesystem
|
||||
@@ -1830,7 +1871,9 @@ export class PostEngine extends EventEmitter {
|
||||
// Client-side filtering for tags only (category filtering is done in SQL)
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
const hasAllTags = filter.tags.every(tag => postData.tags.includes(tag));
|
||||
if (!hasAllTags) continue;
|
||||
if (!hasAllTags) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(postData);
|
||||
@@ -1865,7 +1908,9 @@ export class PostEngine extends EventEmitter {
|
||||
const stems = new Set<string>();
|
||||
for (const lang of languages) {
|
||||
const stemmed = stemQuery(query, lang);
|
||||
if (stemmed) stems.add(stemmed);
|
||||
if (stemmed) {
|
||||
stems.add(stemmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (stems.size <= 1) {
|
||||
@@ -1883,21 +1928,25 @@ export class PostEngine extends EventEmitter {
|
||||
const rows = await this.getAllTranslationRows();
|
||||
const langs = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.language) langs.add(row.language);
|
||||
if (row.language) {
|
||||
langs.add(row.language);
|
||||
}
|
||||
}
|
||||
return Array.from(langs);
|
||||
}
|
||||
|
||||
async searchPosts(query: string): Promise<SearchResult[]> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return [];
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const multilingualQuery = await this.buildMultilingualFTSQuery(query);
|
||||
|
||||
// Search the stemmed content, filtered by project_id for project isolation
|
||||
const result = await client.execute({
|
||||
sql: `SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 500`,
|
||||
sql: 'SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 500',
|
||||
args: [this.currentProjectId, multilingualQuery],
|
||||
});
|
||||
|
||||
@@ -1935,10 +1984,14 @@ export class PostEngine extends EventEmitter {
|
||||
filter: PostFilter,
|
||||
pagination?: PaginationOptions,
|
||||
): Promise<{ posts: PostData[]; total: number }> {
|
||||
if (!query.trim()) return { posts: [], total: 0 };
|
||||
if (!query.trim()) {
|
||||
return { posts: [], total: 0 };
|
||||
}
|
||||
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return { posts: [], total: 0 };
|
||||
if (!client) {
|
||||
return { posts: [], total: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const multilingualQuery = await this.buildMultilingualFTSQuery(query);
|
||||
@@ -1972,14 +2025,14 @@ export class PostEngine extends EventEmitter {
|
||||
}
|
||||
if (filter.categories && filter.categories.length > 0) {
|
||||
const catClauses = filter.categories.map(() =>
|
||||
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)`
|
||||
'EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)',
|
||||
);
|
||||
conditions.push(`(${catClauses.join(' OR ')})`);
|
||||
args.push(...filter.categories);
|
||||
}
|
||||
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
|
||||
const exClauses = filter.excludeCategories.map(() =>
|
||||
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)`
|
||||
'EXISTS (SELECT 1 FROM json_each(posts.categories) AS c WHERE c.value = ?)',
|
||||
);
|
||||
conditions.push(`NOT (${exClauses.join(' OR ')})`);
|
||||
args.push(...filter.excludeCategories);
|
||||
@@ -2006,23 +2059,26 @@ export class PostEngine extends EventEmitter {
|
||||
const result = await client.execute({ sql: sqlQuery, args });
|
||||
|
||||
let postDataList: PostData[] = result.rows.map((row) =>
|
||||
this.dbRowToPostData(row as unknown as Post, (row.content as string) || '')
|
||||
this.dbRowToPostData(row as unknown as Post, (row.content as string) || ''),
|
||||
);
|
||||
|
||||
// Tag filtering is done client-side (tags are stored as JSON arrays)
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
const filterTags = filter.tags;
|
||||
if (filterTags && filterTags.length > 0) {
|
||||
postDataList = postDataList.filter((p) =>
|
||||
filter.tags!.every((tag) => p.tags.includes(tag))
|
||||
filterTags.every((tag) => p.tags.includes(tag)),
|
||||
);
|
||||
}
|
||||
|
||||
postDataList = await this.appendAvailableLanguagesToList(postDataList);
|
||||
|
||||
if (filter.language) {
|
||||
postDataList = postDataList.filter((post) => post.availableLanguages.includes(filter.language!));
|
||||
const filterLanguage = filter.language;
|
||||
if (filterLanguage) {
|
||||
postDataList = postDataList.filter((post) => post.availableLanguages.includes(filterLanguage));
|
||||
}
|
||||
if (filter.missingTranslationLanguage) {
|
||||
postDataList = postDataList.filter((post) => !post.availableLanguages.includes(filter.missingTranslationLanguage!));
|
||||
const missingTranslationLanguage = filter.missingTranslationLanguage;
|
||||
if (missingTranslationLanguage) {
|
||||
postDataList = postDataList.filter((post) => !post.availableLanguages.includes(missingTranslationLanguage));
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
@@ -2045,7 +2101,9 @@ export class PostEngine extends EventEmitter {
|
||||
filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] },
|
||||
): Promise<{ groups: Record<string, string | number>[]; totalPosts: number }> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return { groups: [], totalPosts: 0 };
|
||||
if (!client) {
|
||||
return { groups: [], totalPosts: 0 };
|
||||
}
|
||||
|
||||
// Build SELECT expressions and GROUP BY columns
|
||||
const selectExprs: string[] = [];
|
||||
@@ -2054,28 +2112,28 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
for (const dim of groupBy) {
|
||||
switch (dim) {
|
||||
case 'year':
|
||||
selectExprs.push("CAST(strftime('%Y', posts.created_at, 'unixepoch') AS INTEGER) AS g_year");
|
||||
groupByCols.push('g_year');
|
||||
break;
|
||||
case 'month':
|
||||
selectExprs.push("CAST(strftime('%m', posts.created_at, 'unixepoch') AS INTEGER) AS g_month");
|
||||
groupByCols.push('g_month');
|
||||
break;
|
||||
case 'tag':
|
||||
selectExprs.push('t.value AS g_tag');
|
||||
joins.push('JOIN json_each(posts.tags) AS t');
|
||||
groupByCols.push('g_tag');
|
||||
break;
|
||||
case 'category':
|
||||
selectExprs.push('c.value AS g_category');
|
||||
joins.push('JOIN json_each(posts.categories) AS c');
|
||||
groupByCols.push('g_category');
|
||||
break;
|
||||
case 'status':
|
||||
selectExprs.push('posts.status AS g_status');
|
||||
groupByCols.push('g_status');
|
||||
break;
|
||||
case 'year':
|
||||
selectExprs.push('CAST(strftime(\'%Y\', posts.created_at, \'unixepoch\') AS INTEGER) AS g_year');
|
||||
groupByCols.push('g_year');
|
||||
break;
|
||||
case 'month':
|
||||
selectExprs.push('CAST(strftime(\'%m\', posts.created_at, \'unixepoch\') AS INTEGER) AS g_month');
|
||||
groupByCols.push('g_month');
|
||||
break;
|
||||
case 'tag':
|
||||
selectExprs.push('t.value AS g_tag');
|
||||
joins.push('JOIN json_each(posts.tags) AS t');
|
||||
groupByCols.push('g_tag');
|
||||
break;
|
||||
case 'category':
|
||||
selectExprs.push('c.value AS g_category');
|
||||
joins.push('JOIN json_each(posts.categories) AS c');
|
||||
groupByCols.push('g_category');
|
||||
break;
|
||||
case 'status':
|
||||
selectExprs.push('posts.status AS g_status');
|
||||
groupByCols.push('g_status');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2109,14 +2167,14 @@ export class PostEngine extends EventEmitter {
|
||||
}
|
||||
if (filter?.category) {
|
||||
conditions.push(
|
||||
`EXISTS (SELECT 1 FROM json_each(posts.categories) AS fc WHERE fc.value = ?)`,
|
||||
'EXISTS (SELECT 1 FROM json_each(posts.categories) AS fc WHERE fc.value = ?)',
|
||||
);
|
||||
args.push(filter.category);
|
||||
}
|
||||
if (filter?.tags && filter.tags.length > 0) {
|
||||
for (const tag of filter.tags) {
|
||||
conditions.push(
|
||||
`EXISTS (SELECT 1 FROM json_each(posts.tags) AS ft WHERE ft.value = ?)`,
|
||||
'EXISTS (SELECT 1 FROM json_each(posts.tags) AS ft WHERE ft.value = ?)',
|
||||
);
|
||||
args.push(tag);
|
||||
}
|
||||
@@ -2140,12 +2198,18 @@ export class PostEngine extends EventEmitter {
|
||||
g_category: 'category', g_status: 'status',
|
||||
};
|
||||
|
||||
const groups: Record<string, string | number>[] = result.rows.map((row: any) => {
|
||||
const groups: Record<string, string | number>[] = result.rows.map((row) => {
|
||||
const typedRow = row as Record<string, unknown>;
|
||||
const group: Record<string, string | number> = {};
|
||||
for (const col of groupByCols) {
|
||||
group[dimMap[col]] = row[col];
|
||||
const mappedKey = dimMap[col];
|
||||
if (mappedKey === 'year' || mappedKey === 'month') {
|
||||
group[mappedKey] = Number(typedRow[col]);
|
||||
continue;
|
||||
}
|
||||
group[mappedKey] = String(typedRow[col] ?? '');
|
||||
}
|
||||
group.count = Number(row.cnt);
|
||||
group.count = Number(typedRow.cnt);
|
||||
return group;
|
||||
});
|
||||
|
||||
@@ -2240,9 +2304,9 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
for (const row of dbPosts) {
|
||||
switch (row.status) {
|
||||
case 'draft': draftCount++; break;
|
||||
case 'published': publishedCount++; break;
|
||||
case 'archived': archivedCount++; break;
|
||||
case 'draft': draftCount++; break;
|
||||
case 'published': publishedCount++; break;
|
||||
case 'archived': archivedCount++; break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2283,23 +2347,31 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
for (const row of dbPosts) {
|
||||
switch (row.status) {
|
||||
case 'draft': draftCount++; break;
|
||||
case 'published': publishedCount++; break;
|
||||
case 'archived': archivedCount++; break;
|
||||
case 'draft': draftCount++; break;
|
||||
case 'published': publishedCount++; break;
|
||||
case 'archived': archivedCount++; break;
|
||||
}
|
||||
|
||||
const created = row.createdAt;
|
||||
if (!oldestPostDate || created < oldestPostDate) oldestPostDate = created;
|
||||
if (!newestPostDate || created > newestPostDate) newestPostDate = created;
|
||||
if (!oldestPostDate || created < oldestPostDate) {
|
||||
oldestPostDate = created;
|
||||
}
|
||||
if (!newestPostDate || created > newestPostDate) {
|
||||
newestPostDate = created;
|
||||
}
|
||||
|
||||
const year = created.getFullYear();
|
||||
postsPerYear[year] = (postsPerYear[year] || 0) + 1;
|
||||
|
||||
const parsedTags: string[] = JSON.parse(row.tags || '[]');
|
||||
for (const tag of parsedTags) uniqueTags.add(tag);
|
||||
for (const tag of parsedTags) {
|
||||
uniqueTags.add(tag);
|
||||
}
|
||||
|
||||
const parsedCategories: string[] = JSON.parse(row.categories || '[]');
|
||||
for (const cat of parsedCategories) uniqueCategories.add(cat);
|
||||
for (const cat of parsedCategories) {
|
||||
uniqueCategories.add(cat);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -2329,14 +2401,15 @@ export class PostEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
return Array.from(counts.values()).sort((a, b) => {
|
||||
if (a.year !== b.year) return b.year - a.year;
|
||||
if (a.year !== b.year) {
|
||||
return b.year - a.year;
|
||||
}
|
||||
return b.month - a.month;
|
||||
});
|
||||
}
|
||||
|
||||
async publishPost(id: string): Promise<PostData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
const existing = await this.getPost(id);
|
||||
|
||||
if (!existing) {
|
||||
@@ -2396,7 +2469,9 @@ export class PostEngine extends EventEmitter {
|
||||
const translationRows = this.filterCanonicalTranslationRows(published, await this.getTranslationRowsForPost(id));
|
||||
for (const row of translationRows) {
|
||||
const translation = await this.resolvePostTranslationData(row);
|
||||
if (!translation) continue;
|
||||
if (!translation) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const publishedTranslation: PostTranslationData = {
|
||||
...translation,
|
||||
@@ -2432,14 +2507,15 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
async publishPostTranslation(postId: string, language: string): Promise<PostTranslationData | null> {
|
||||
const existing = await this.getTranslationRow(postId, language.trim().toLowerCase());
|
||||
if (!existing) return null;
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
await this.publishPost(postId);
|
||||
return this.getPostTranslation(postId, language.trim().toLowerCase());
|
||||
}
|
||||
|
||||
async discardChanges(id: string): Promise<PostData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
|
||||
|
||||
if (!dbPost) {
|
||||
@@ -2540,7 +2616,9 @@ export class PostEngine extends EventEmitter {
|
||||
|
||||
async getPublishedVersionsBulk(ids: string[]): Promise<Map<string, PostData>> {
|
||||
const result = new Map<string, PostData>();
|
||||
if (ids.length === 0) return result;
|
||||
if (ids.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const db = getDatabase().getLocal();
|
||||
const idSet = new Set(ids);
|
||||
@@ -2550,7 +2628,9 @@ export class PostEngine extends EventEmitter {
|
||||
.all();
|
||||
|
||||
for (const dbPost of dbPosts) {
|
||||
if (!idSet.has(dbPost.id) || !dbPost.filePath) continue;
|
||||
if (!idSet.has(dbPost.id) || !dbPost.filePath) {
|
||||
continue;
|
||||
}
|
||||
result.set(dbPost.id, this.dbRowToPostData(dbPost, ''));
|
||||
}
|
||||
|
||||
@@ -2582,7 +2662,9 @@ export class PostEngine extends EventEmitter {
|
||||
*/
|
||||
async rebuildFTSIndex(): Promise<void> {
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (!client) return;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allPosts = await this.getAllPostsUnpaginated();
|
||||
|
||||
@@ -2590,7 +2672,7 @@ export class PostEngine extends EventEmitter {
|
||||
await this.updateFTSIndex(post);
|
||||
}
|
||||
|
||||
console.log(`Rebuilt FTS index for ${allPosts.length} posts`);
|
||||
return;
|
||||
}
|
||||
|
||||
async reconcilePublishedPostsFromGitChanges(
|
||||
@@ -2878,7 +2960,6 @@ export class PostEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
onProgress(100, `Reindexed ${total} posts`);
|
||||
console.log(`Reindexed search text for ${total} posts`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2892,7 +2973,6 @@ export class PostEngine extends EventEmitter {
|
||||
name: 'Rebuild database from post files',
|
||||
execute: async (onProgress) => {
|
||||
const db = getDatabase().getLocal();
|
||||
const client = getDatabase().getLocalClient();
|
||||
|
||||
onProgress(0, 'Deleting existing posts for project...');
|
||||
|
||||
@@ -2912,7 +2992,6 @@ export class PostEngine extends EventEmitter {
|
||||
await db.delete(postLinks).where(inArray(postLinks.targetPostId, postIds));
|
||||
// Delete posts
|
||||
await db.delete(posts).where(eq(posts.projectId, this.currentProjectId));
|
||||
console.log(`Deleted ${existingPosts.length} existing post(s) for project ${this.currentProjectId}`);
|
||||
}
|
||||
await db.delete(postTranslations).where(eq(postTranslations.projectId, this.currentProjectId));
|
||||
|
||||
@@ -2956,12 +3035,6 @@ export class PostEngine extends EventEmitter {
|
||||
const translationFiles: Array<{ filePath: string; data: PostTranslationFileData }> = [];
|
||||
let importedCount = 0;
|
||||
let importedTranslationCount = 0;
|
||||
let parseFailedCount = 0;
|
||||
let deduplicatedSlugCount = 0;
|
||||
let deduplicatedIdCount = 0;
|
||||
let insertFailedCount = 0;
|
||||
let skippedTranslationMissingSourceCount = 0;
|
||||
let skippedDuplicateTranslationCount = 0;
|
||||
|
||||
for (let i = 0; i < markdownFiles.length; i++) {
|
||||
const filePath = markdownFiles[i];
|
||||
@@ -2980,7 +3053,6 @@ export class PostEngine extends EventEmitter {
|
||||
const postData = await this.readPostFile(filePath);
|
||||
|
||||
if (!postData) {
|
||||
parseFailedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2990,7 +3062,6 @@ export class PostEngine extends EventEmitter {
|
||||
let postId = postData.id;
|
||||
while (insertedIds.has(postId)) {
|
||||
postId = uuidv4();
|
||||
deduplicatedIdCount++;
|
||||
}
|
||||
|
||||
let slug = postData.slug;
|
||||
@@ -2999,7 +3070,6 @@ export class PostEngine extends EventEmitter {
|
||||
while (insertedSlugs.has(`${projectId}:${slug}`)) {
|
||||
slug = `${baseSlug}-${slugAttempt}`;
|
||||
slugAttempt++;
|
||||
deduplicatedSlugCount++;
|
||||
}
|
||||
|
||||
const checksum = this.calculateChecksum(postData.content);
|
||||
@@ -3045,9 +3115,8 @@ export class PostEngine extends EventEmitter {
|
||||
tags: postData.tags,
|
||||
categories: postData.categories,
|
||||
});
|
||||
} catch (error: any) {
|
||||
insertFailedCount++;
|
||||
if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && (error as { code?: unknown }).code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
console.error(`Failed to insert post "${postData.title}" from ${filePath}: Unique constraint violation`);
|
||||
} else {
|
||||
console.error(`Failed to process post from ${filePath}:`, error);
|
||||
@@ -3068,11 +3137,8 @@ export class PostEngine extends EventEmitter {
|
||||
onProgress,
|
||||
);
|
||||
importedTranslationCount = translationImportResult.imported;
|
||||
skippedTranslationMissingSourceCount = translationImportResult.skippedMissingSource;
|
||||
skippedDuplicateTranslationCount = translationImportResult.skippedDuplicates;
|
||||
|
||||
onProgress(100, `Database rebuild complete: imported ${importedCount} posts and ${importedTranslationCount} translations from ${markdownFiles.length} files`);
|
||||
console.log(`[PostEngine] rebuildDatabaseFromFiles complete. scanned=${markdownFiles.length}, importedPosts=${importedCount}, importedTranslations=${importedTranslationCount}, parseFailed=${parseFailedCount}, insertFailed=${insertFailedCount}, deduplicatedSlugs=${deduplicatedSlugCount}, deduplicatedIds=${deduplicatedIdCount}, skippedTranslationsMissingSource=${skippedTranslationMissingSourceCount}, skippedDuplicateTranslations=${skippedDuplicateTranslationCount}`);
|
||||
this.emit('databaseRebuilt');
|
||||
},
|
||||
};
|
||||
@@ -3112,7 +3178,9 @@ export class PostEngine extends EventEmitter {
|
||||
// Delete existing links from this post
|
||||
await db.delete(postLinks).where(eq(postLinks.sourcePostId, postId));
|
||||
|
||||
if (extractedLinks.length === 0) return;
|
||||
if (extractedLinks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all posts to resolve slugs to IDs
|
||||
const allPosts = await db.select({ id: posts.id, slug: posts.slug })
|
||||
@@ -3150,7 +3218,9 @@ export class PostEngine extends EventEmitter {
|
||||
.from(postLinks)
|
||||
.where(eq(postLinks.targetPostId, postId));
|
||||
|
||||
if (links.length === 0) return [];
|
||||
if (links.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceIds = links.map(l => l.sourcePostId);
|
||||
const sourcePosts = await db
|
||||
@@ -3176,7 +3246,9 @@ export class PostEngine extends EventEmitter {
|
||||
})
|
||||
.from(postLinks);
|
||||
|
||||
if (allLinks.length === 0) return new Map();
|
||||
if (allLinks.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const sourceIds = new Set(allLinks.map(l => l.sourcePostId));
|
||||
const allSourcePosts = await db
|
||||
@@ -3191,7 +3263,9 @@ export class PostEngine extends EventEmitter {
|
||||
const result = new Map<string, { id: string; title: string; slug: string }[]>();
|
||||
for (const link of allLinks) {
|
||||
const sourcePost = sourcePostById.get(link.sourcePostId);
|
||||
if (!sourcePost) continue;
|
||||
if (!sourcePost) {
|
||||
continue;
|
||||
}
|
||||
const existing = result.get(link.targetPostId);
|
||||
if (existing) {
|
||||
existing.push(sourcePost);
|
||||
@@ -3217,7 +3291,9 @@ export class PostEngine extends EventEmitter {
|
||||
.from(postLinks)
|
||||
.where(eq(postLinks.sourcePostId, postId));
|
||||
|
||||
if (links.length === 0) return [];
|
||||
if (links.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetIds = links.map(l => l.targetPostId);
|
||||
const targetPosts = await db
|
||||
|
||||
@@ -120,7 +120,7 @@ export class PostMediaEngine extends EventEmitter {
|
||||
existingByMediaId: Map<string, PostMediaLinkData>;
|
||||
nextSortOrder: number;
|
||||
},
|
||||
createdAt: Date
|
||||
createdAt: Date,
|
||||
): Promise<{ linked: true; link: PostMediaLinkData } | { linked: false; existing: PostMediaLinkData }> {
|
||||
const existing = state.existingByMediaId.get(mediaId);
|
||||
if (existing) {
|
||||
@@ -144,8 +144,8 @@ export class PostMediaEngine extends EventEmitter {
|
||||
await db.delete(postMedia).where(
|
||||
and(
|
||||
eq(postMedia.postId, postId),
|
||||
eq(postMedia.mediaId, mediaId)
|
||||
)
|
||||
eq(postMedia.mediaId, mediaId),
|
||||
),
|
||||
);
|
||||
|
||||
await this.removePostFromMediaSidecar(mediaId, postId);
|
||||
@@ -160,7 +160,6 @@ export class PostMediaEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
this.currentProjectId = projectId;
|
||||
console.log(`[PostMediaEngine] setProjectContext: projectId=${projectId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,8 +253,8 @@ export class PostMediaEngine extends EventEmitter {
|
||||
.where(
|
||||
and(
|
||||
eq(postMedia.projectId, this.currentProjectId),
|
||||
eq(postMedia.postId, postId)
|
||||
)
|
||||
eq(postMedia.postId, postId),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(postMedia.sortOrder));
|
||||
|
||||
@@ -274,8 +273,8 @@ export class PostMediaEngine extends EventEmitter {
|
||||
.where(
|
||||
and(
|
||||
eq(postMedia.projectId, this.currentProjectId),
|
||||
eq(postMedia.mediaId, mediaId)
|
||||
)
|
||||
eq(postMedia.mediaId, mediaId),
|
||||
),
|
||||
);
|
||||
|
||||
return links.map(this.mapToLinkData);
|
||||
@@ -296,8 +295,8 @@ export class PostMediaEngine extends EventEmitter {
|
||||
.where(
|
||||
and(
|
||||
eq(postMedia.postId, postId),
|
||||
eq(postMedia.mediaId, mediaIds[i])
|
||||
)
|
||||
eq(postMedia.mediaId, mediaIds[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -312,8 +311,6 @@ export class PostMediaEngine extends EventEmitter {
|
||||
async rebuildFromSidecars(): Promise<void> {
|
||||
const db = this.getDb();
|
||||
|
||||
console.log('[PostMediaEngine] Rebuilding post-media links from sidecars...');
|
||||
|
||||
// Clear existing links for this project
|
||||
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
|
||||
|
||||
@@ -340,8 +337,6 @@ export class PostMediaEngine extends EventEmitter {
|
||||
linksCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[PostMediaEngine] Rebuilt ${linksCreated} post-media links`);
|
||||
this.emit('rebuilt', { linksCreated });
|
||||
}
|
||||
|
||||
@@ -386,8 +381,8 @@ export class PostMediaEngine extends EventEmitter {
|
||||
.where(
|
||||
and(
|
||||
eq(postMedia.postId, postId),
|
||||
eq(postMedia.mediaId, mediaId)
|
||||
)
|
||||
eq(postMedia.mediaId, mediaId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { type CategoryMetadata, type ProjectMetadata } from './MetaEngine';
|
||||
import { type MediaData } from './MediaEngine';
|
||||
import { type MenuDocument } from './MenuEngine';
|
||||
import { type PostData, type PostFilter, type PostTranslationData } from './PostEngine';
|
||||
import {
|
||||
@@ -96,12 +95,24 @@ export class PreviewServer {
|
||||
private drainResolve: (() => void) | null = null;
|
||||
|
||||
constructor(dependencies?: Partial<PreviewServerDependencies>) {
|
||||
if (!dependencies?.postEngine) throw new Error('PreviewServer: postEngine not provided');
|
||||
if (!dependencies?.mediaEngine) throw new Error('PreviewServer: mediaEngine not provided');
|
||||
if (!dependencies?.postMediaEngine) throw new Error('PreviewServer: postMediaEngine not provided');
|
||||
if (!dependencies?.settingsEngine) throw new Error('PreviewServer: settingsEngine not provided');
|
||||
if (!dependencies?.menuEngine) throw new Error('PreviewServer: menuEngine not provided');
|
||||
if (!dependencies?.getActiveProjectContext) throw new Error('PreviewServer: getActiveProjectContext not provided');
|
||||
if (!dependencies?.postEngine) {
|
||||
throw new Error('PreviewServer: postEngine not provided');
|
||||
}
|
||||
if (!dependencies?.mediaEngine) {
|
||||
throw new Error('PreviewServer: mediaEngine not provided');
|
||||
}
|
||||
if (!dependencies?.postMediaEngine) {
|
||||
throw new Error('PreviewServer: postMediaEngine not provided');
|
||||
}
|
||||
if (!dependencies?.settingsEngine) {
|
||||
throw new Error('PreviewServer: settingsEngine not provided');
|
||||
}
|
||||
if (!dependencies?.menuEngine) {
|
||||
throw new Error('PreviewServer: menuEngine not provided');
|
||||
}
|
||||
if (!dependencies?.getActiveProjectContext) {
|
||||
throw new Error('PreviewServer: getActiveProjectContext not provided');
|
||||
}
|
||||
this.postEngine = dependencies.postEngine;
|
||||
this.mediaEngine = dependencies.mediaEngine;
|
||||
this.postMediaEngine = dependencies.postMediaEngine;
|
||||
@@ -221,7 +232,9 @@ export class PreviewServer {
|
||||
loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination),
|
||||
findPublishedPostBySlug: (slug, dateFilter) => findPublishedPostBySlug(this.postEngine, slug, dateFilter),
|
||||
findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => findSinglePostBySlug(this.postEngine, slug, singlePostOptions, dateFilter),
|
||||
getLinkedBy: this.postEngine.getLinkedBy ? (postId) => this.postEngine.getLinkedBy!(postId) : undefined,
|
||||
getLinkedBy: this.postEngine.getLinkedBy
|
||||
? (postId) => this.postEngine.getLinkedBy?.(postId) ?? Promise.resolve([])
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,14 +261,14 @@ export class PreviewServer {
|
||||
|
||||
this.inflightRequests++;
|
||||
try {
|
||||
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
|
||||
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
|
||||
|
||||
const asset = await this.resolveAsset(pathname);
|
||||
if (asset) {
|
||||
this.respondAsset(res, asset.contentType, asset.body);
|
||||
return;
|
||||
}
|
||||
const asset = await this.resolveAsset(pathname);
|
||||
if (asset) {
|
||||
this.respondAsset(res, asset.contentType, asset.body);
|
||||
return;
|
||||
}
|
||||
|
||||
const context = await this.getActiveProjectContext();
|
||||
this.postEngine.setProjectContext(context.projectId, context.dataDir);
|
||||
@@ -320,11 +333,11 @@ export class PreviewServer {
|
||||
: [];
|
||||
const stylePreviewBlogLanguages = allBlogLanguages.length > 0
|
||||
? allBlogLanguages.map((lang) => ({
|
||||
code: lang,
|
||||
flag: POST_LANGUAGE_FLAGS[lang as SupportedLanguage] ?? '',
|
||||
href_prefix: lang === mainLanguage ? '' : `/${lang}`,
|
||||
is_current: lang === mainLanguage,
|
||||
}))
|
||||
code: lang,
|
||||
flag: POST_LANGUAGE_FLAGS[lang as SupportedLanguage] ?? '',
|
||||
href_prefix: lang === mainLanguage ? '' : `/${lang}`,
|
||||
is_current: lang === mainLanguage,
|
||||
}))
|
||||
: [];
|
||||
const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, {
|
||||
pageTitle,
|
||||
@@ -550,11 +563,15 @@ export class PreviewServer {
|
||||
|
||||
private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
|
||||
const match = pathname.match(/^\/assets\/([^/]+)$/);
|
||||
if (!match) return null;
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assetName = match[1];
|
||||
const assetDefinition = PREVIEW_ASSETS[assetName];
|
||||
if (!assetDefinition) return null;
|
||||
if (!assetDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = assetDefinition.sourceText !== undefined
|
||||
@@ -572,11 +589,15 @@ export class PreviewServer {
|
||||
|
||||
private async resolveImageAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
|
||||
const match = pathname.match(/^\/images\/([^/]+)$/);
|
||||
if (!match) return null;
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assetName = match[1] as keyof typeof PREVIEW_IMAGE_ASSETS;
|
||||
const assetDefinition = PREVIEW_IMAGE_ASSETS[assetName];
|
||||
if (!assetDefinition) return null;
|
||||
if (!assetDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const absolutePath = require.resolve(assetDefinition.modulePath);
|
||||
@@ -593,7 +614,9 @@ export class PreviewServer {
|
||||
|
||||
private async resolveMediaAsset(pathname: string, dataDir?: string): Promise<{ contentType: string; body: Buffer } | null> {
|
||||
const match = pathname.match(/^\/media\/(.+)$/);
|
||||
if (!match || !dataDir) return null;
|
||||
if (!match || !dataDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativeMediaPath = path.posix.normalize(`media/${match[1]}`);
|
||||
if (!relativeMediaPath.startsWith('media/')) {
|
||||
@@ -637,31 +660,31 @@ export class PreviewServer {
|
||||
private getMediaContentType(filePath: string): string {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
switch (extension) {
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.bmp':
|
||||
return 'image/bmp';
|
||||
case '.avif':
|
||||
return 'image/avif';
|
||||
case '.mp4':
|
||||
return 'video/mp4';
|
||||
case '.webm':
|
||||
return 'video/webm';
|
||||
case '.mov':
|
||||
return 'video/quicktime';
|
||||
case '.pdf':
|
||||
return 'application/pdf';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.bmp':
|
||||
return 'image/bmp';
|
||||
case '.avif':
|
||||
return 'image/avif';
|
||||
case '.mp4':
|
||||
return 'video/mp4';
|
||||
case '.webm':
|
||||
return 'video/webm';
|
||||
case '.mov':
|
||||
return 'video/quicktime';
|
||||
case '.pdf':
|
||||
return 'application/pdf';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ export class PublishApiAdapter {
|
||||
throw new Error('No active project');
|
||||
}
|
||||
|
||||
this.publishEngine.setProjectContext(project.id, project.dataPath!);
|
||||
if (!project.dataPath) {
|
||||
throw new Error('Active project is missing dataPath');
|
||||
}
|
||||
|
||||
this.publishEngine.setProjectContext(project.id, project.dataPath);
|
||||
|
||||
const ts = Date.now();
|
||||
const groupId = `publish-${ts}`;
|
||||
|
||||
@@ -35,6 +35,14 @@ export class PublishEngine extends EventEmitter {
|
||||
this.dataDir = dataDir;
|
||||
}
|
||||
|
||||
private requireDataDir(): string {
|
||||
if (!this.dataDir) {
|
||||
throw new Error('Project context is not set');
|
||||
}
|
||||
|
||||
return this.dataDir;
|
||||
}
|
||||
|
||||
// ── Public upload methods (one per directory, run as parallel tasks) ───
|
||||
|
||||
/**
|
||||
@@ -48,7 +56,8 @@ export class PublishEngine extends EventEmitter {
|
||||
this.ensureProjectContext();
|
||||
this.validateCredentials(credentials);
|
||||
|
||||
const htmlDir = path.join(this.dataDir!, 'html');
|
||||
const dataDir = this.requireDataDir();
|
||||
const htmlDir = path.join(dataDir, 'html');
|
||||
await this.ensureDirectoryExists(htmlDir, 'Generated site not found. Please render the site first.');
|
||||
|
||||
if (credentials.sshMode === 'rsync') {
|
||||
@@ -72,7 +81,8 @@ export class PublishEngine extends EventEmitter {
|
||||
this.ensureProjectContext();
|
||||
this.validateCredentials(credentials);
|
||||
|
||||
const thumbnailsDir = path.join(this.dataDir!, 'thumbnails');
|
||||
const dataDir = this.requireDataDir();
|
||||
const thumbnailsDir = path.join(dataDir, 'thumbnails');
|
||||
if (!(await this.directoryExists(thumbnailsDir))) {
|
||||
onProgress(100, 'No thumbnails to upload');
|
||||
return { filesUploaded: 0, filesSkipped: 0 };
|
||||
@@ -100,7 +110,8 @@ export class PublishEngine extends EventEmitter {
|
||||
this.ensureProjectContext();
|
||||
this.validateCredentials(credentials);
|
||||
|
||||
const mediaDir = path.join(this.dataDir!, 'media');
|
||||
const dataDir = this.requireDataDir();
|
||||
const mediaDir = path.join(dataDir, 'media');
|
||||
if (!(await this.directoryExists(mediaDir))) {
|
||||
onProgress(100, 'No media to upload');
|
||||
return { filesUploaded: 0, filesSkipped: 0 };
|
||||
@@ -235,11 +246,21 @@ export class PublishEngine extends EventEmitter {
|
||||
const lines = data.toString().split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith('sending ')) continue;
|
||||
if (/\bbytes\b/.test(trimmed)) continue;
|
||||
if (/total size is/.test(trimmed)) continue;
|
||||
if (/speedup is/.test(trimmed)) continue;
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('sending ')) {
|
||||
continue;
|
||||
}
|
||||
if (/\bbytes\b/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
if (/total size is/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
if (/speedup is/.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
filesTransferred++;
|
||||
onProgress(
|
||||
Math.min(filesTransferred, 99),
|
||||
@@ -248,7 +269,7 @@ export class PublishEngine extends EventEmitter {
|
||||
}
|
||||
},
|
||||
},
|
||||
(error, _stdout, _stderr, _cmd) => {
|
||||
(error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
|
||||
@@ -38,7 +38,9 @@ export async function generateRootPages(params: BaseParams & {
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const offset = (page - 1) * params.maxPostsPerPage;
|
||||
const pagePosts = params.posts.slice(offset, offset + params.maxPostsPerPage);
|
||||
if (pagePosts.length === 0) break;
|
||||
if (pagePosts.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const routePath = page === 1 ? '/' : `/page/${page}`;
|
||||
const html = await renderRequiredRoute(params.renderRoute, routePath);
|
||||
@@ -98,7 +100,9 @@ async function generatePaginatedListPages(params: BaseParams & {
|
||||
maxPostsPerPage: number;
|
||||
urlPrefix: string;
|
||||
}): Promise<number> {
|
||||
if (params.posts.length === 0) return 0;
|
||||
if (params.posts.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(params.posts.length / params.maxPostsPerPage));
|
||||
let count = 0;
|
||||
@@ -106,7 +110,9 @@ async function generatePaginatedListPages(params: BaseParams & {
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const offset = (page - 1) * params.maxPostsPerPage;
|
||||
const pagePosts = params.posts.slice(offset, offset + params.maxPostsPerPage);
|
||||
if (pagePosts.length === 0) break;
|
||||
if (pagePosts.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const routePath = page === 1 ? `/${params.urlPrefix}` : `/${params.urlPrefix}/page/${page}`;
|
||||
const html = await renderRequiredRoute(params.renderRoute, routePath);
|
||||
@@ -129,7 +135,9 @@ export async function generateCategoryPages(params: BaseParams & {
|
||||
|
||||
for (const category of Array.from(params.allCategories).sort()) {
|
||||
const categoryPosts = params.postsByCategory?.get(category) ?? params.posts.filter((post) => (post.categories || []).includes(category));
|
||||
if (categoryPosts.length === 0) continue;
|
||||
if (categoryPosts.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(categoryPosts.length / params.maxPostsPerPage));
|
||||
const encodedCategory = encodeURIComponent(category);
|
||||
@@ -137,7 +145,9 @@ export async function generateCategoryPages(params: BaseParams & {
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const offset = (page - 1) * params.maxPostsPerPage;
|
||||
const pagePosts = categoryPosts.slice(offset, offset + params.maxPostsPerPage);
|
||||
if (pagePosts.length === 0) break;
|
||||
if (pagePosts.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const routePath = page === 1
|
||||
? `/category/${encodedCategory}`
|
||||
@@ -165,7 +175,9 @@ export async function generateTagPages(params: BaseParams & {
|
||||
|
||||
for (const tag of Array.from(params.allTags).sort()) {
|
||||
const tagPosts = params.postsByTag?.get(tag) ?? params.posts.filter((post) => (post.tags || []).includes(tag));
|
||||
if (tagPosts.length === 0) continue;
|
||||
if (tagPosts.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(tagPosts.length / params.maxPostsPerPage));
|
||||
const encodedTag = encodeURIComponent(tag);
|
||||
@@ -173,7 +185,9 @@ export async function generateTagPages(params: BaseParams & {
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const offset = (page - 1) * params.maxPostsPerPage;
|
||||
const pagePosts = tagPosts.slice(offset, offset + params.maxPostsPerPage);
|
||||
if (pagePosts.length === 0) break;
|
||||
if (pagePosts.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const routePath = page === 1
|
||||
? `/tag/${encodedTag}`
|
||||
|
||||
@@ -91,7 +91,9 @@ export class ScriptEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/** No persistent cache — no-op for watcher compat. */
|
||||
invalidate(_entityId?: string): void {}
|
||||
invalidate(entityId?: string): void {
|
||||
void entityId;
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string, dataDir?: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
@@ -513,7 +515,7 @@ export class ScriptEngine extends EventEmitter {
|
||||
|
||||
private async toScriptData(row: Script): Promise<ScriptData> {
|
||||
// Draft scripts store content in the DB; published scripts read from disk.
|
||||
const content = row.status === 'draft' && row.content != null
|
||||
const content = row.status === 'draft' && row.content !== null
|
||||
? row.content
|
||||
: await this.readScriptBody(row.filePath);
|
||||
|
||||
@@ -572,7 +574,7 @@ export class ScriptEngine extends EventEmitter {
|
||||
const taken = new Set(
|
||||
rows
|
||||
.filter((item) => item.id !== excludeId)
|
||||
.map((item) => item.slug)
|
||||
.map((item) => item.slug),
|
||||
);
|
||||
|
||||
if (!taken.has(baseSlug)) {
|
||||
@@ -691,7 +693,7 @@ export class ScriptEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
private parseYamlScalar(valueRaw: string): string | number | boolean {
|
||||
if ((valueRaw.startsWith('"') && valueRaw.endsWith('"')) || (valueRaw.startsWith("'") && valueRaw.endsWith("'"))) {
|
||||
if ((valueRaw.startsWith('"') && valueRaw.endsWith('"')) || (valueRaw.startsWith('\'') && valueRaw.endsWith('\''))) {
|
||||
return valueRaw.slice(1, -1)
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\');
|
||||
@@ -838,9 +840,11 @@ export class ScriptEngine extends EventEmitter {
|
||||
/** Publish a draft script: write file to disk, set status='published', clear DB content. */
|
||||
async publishScript(id: string): Promise<ScriptData | null> {
|
||||
const existing = await this.getScriptRow(id);
|
||||
if (!existing) return null;
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = existing.status === 'draft' && existing.content != null
|
||||
const content = existing.status === 'draft' && existing.content !== null
|
||||
? existing.content
|
||||
: await this.readScriptBody(existing.filePath);
|
||||
|
||||
@@ -854,7 +858,9 @@ export class ScriptEngine extends EventEmitter {
|
||||
.where(eq(scripts.id, id));
|
||||
|
||||
const updatedRow = await this.getScriptRow(id);
|
||||
if (!updatedRow) return null;
|
||||
if (!updatedRow) {
|
||||
return null;
|
||||
}
|
||||
const result = await this.toScriptData(updatedRow);
|
||||
this.emit('scriptUpdated', result);
|
||||
await this.notifier.notify('script', id, 'updated');
|
||||
@@ -864,7 +870,9 @@ export class ScriptEngine extends EventEmitter {
|
||||
/** Delete a draft script (only if status='draft'). Returns false if not found or already published. */
|
||||
async deleteDraftScript(id: string): Promise<boolean> {
|
||||
const existing = await this.getScriptRow(id);
|
||||
if (!existing || existing.status !== 'draft') return false;
|
||||
if (!existing || existing.status !== 'draft') {
|
||||
return false;
|
||||
}
|
||||
|
||||
await getDatabase().getLocal()
|
||||
.delete(scripts)
|
||||
|
||||
@@ -95,7 +95,9 @@ export interface SharedRouteRenderServices<TCategoryMetadata> {
|
||||
const MAX_BACKLINK_SLUG_LENGTH = 30;
|
||||
|
||||
function truncateSlug(slug: string): string {
|
||||
if (slug.length <= MAX_BACKLINK_SLUG_LENGTH) return slug;
|
||||
if (slug.length <= MAX_BACKLINK_SLUG_LENGTH) {
|
||||
return slug;
|
||||
}
|
||||
return slug.slice(0, MAX_BACKLINK_SLUG_LENGTH) + '...';
|
||||
}
|
||||
|
||||
@@ -104,14 +106,20 @@ async function resolveBacklinks(
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>,
|
||||
): Promise<BacklinkEntry[]> {
|
||||
if (!getLinkedBy) return [];
|
||||
if (!getLinkedBy) {
|
||||
return [];
|
||||
}
|
||||
const linkedPosts = await getLinkedBy(postId);
|
||||
if (linkedPosts.length === 0) return [];
|
||||
if (linkedPosts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return linkedPosts
|
||||
.map((linked) => {
|
||||
const canonical = rewriteContext.canonicalPostPathBySlug.get(linked.slug);
|
||||
if (!canonical) return null;
|
||||
if (!canonical) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
slug: linked.slug,
|
||||
display_slug: truncateSlug(linked.slug),
|
||||
@@ -276,7 +284,9 @@ async function resolveRouteWithSharedServices(
|
||||
...singlePostOptions,
|
||||
preferredLanguage: singlePostOptions?.preferredLanguage ?? pageContext.language,
|
||||
}, { year, month, day });
|
||||
if (!post) return null;
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
const backlinks = await resolveBacklinks(post.id, rewriteContext, services.getLinkedBy);
|
||||
return services.pageRenderer.renderSinglePost(post, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
@@ -327,7 +337,9 @@ async function resolveRouteWithSharedServices(
|
||||
if (monthMatch) {
|
||||
const year = Number(monthMatch[1]);
|
||||
const month = Number(monthMatch[2]);
|
||||
if (month < 1 || month > 12) return null;
|
||||
if (month < 1 || month > 12) {
|
||||
return null;
|
||||
}
|
||||
const result = await services.loadPublishedSnapshotsPage({ status: 'published', year, month, excludeCategories: listExcludedCategories }, pageOptions);
|
||||
return services.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||
archiveGrouping: true,
|
||||
@@ -449,11 +461,11 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
|
||||
: [];
|
||||
const blogLanguages = allBlogLanguages.length > 0
|
||||
? allBlogLanguages.map((lang) => ({
|
||||
code: lang,
|
||||
flag: POST_LANGUAGE_FLAGS[lang as SupportedLanguage] ?? '',
|
||||
href_prefix: lang === mainLang ? '' : `/${lang}`,
|
||||
is_current: lang === currentLanguage,
|
||||
}))
|
||||
code: lang,
|
||||
flag: POST_LANGUAGE_FLAGS[lang as SupportedLanguage] ?? '',
|
||||
href_prefix: lang === mainLang ? '' : `/${lang}`,
|
||||
is_current: lang === currentLanguage,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {
|
||||
|
||||
@@ -18,10 +18,18 @@ interface SinglePostPreviewOptions {
|
||||
function buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
|
||||
const baseFilter: PostFilter = {};
|
||||
|
||||
if (filter.startDate) baseFilter.startDate = filter.startDate;
|
||||
if (filter.endDate) baseFilter.endDate = filter.endDate;
|
||||
if (filter.year !== undefined) baseFilter.year = filter.year;
|
||||
if (filter.month !== undefined) baseFilter.month = filter.month;
|
||||
if (filter.startDate) {
|
||||
baseFilter.startDate = filter.startDate;
|
||||
}
|
||||
if (filter.endDate) {
|
||||
baseFilter.endDate = filter.endDate;
|
||||
}
|
||||
if (filter.year !== undefined) {
|
||||
baseFilter.year = filter.year;
|
||||
}
|
||||
if (filter.month !== undefined) {
|
||||
baseFilter.month = filter.month;
|
||||
}
|
||||
|
||||
return baseFilter;
|
||||
}
|
||||
@@ -89,11 +97,13 @@ export async function loadPublishedSnapshotsPage(
|
||||
let snapshots = snapshotCandidates.filter((post): post is PostData => post !== null);
|
||||
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
snapshots = snapshots.filter((post) => filter.tags!.every((tag) => post.tags.includes(tag)));
|
||||
const { tags } = filter;
|
||||
snapshots = snapshots.filter((post) => tags.every((tag) => post.tags.includes(tag)));
|
||||
}
|
||||
|
||||
if (filter.categories && filter.categories.length > 0) {
|
||||
snapshots = snapshots.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
|
||||
const { categories } = filter;
|
||||
snapshots = snapshots.filter((post) => categories.some((category) => post.categories.includes(category)));
|
||||
}
|
||||
|
||||
snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
@@ -153,7 +163,9 @@ export async function findPublishedPostBySlug(
|
||||
slug: string,
|
||||
dateFilter?: { year: number; month: number },
|
||||
): Promise<PostData | null> {
|
||||
if (!slug) return null;
|
||||
if (!slug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (postEngine.findPublishedBySlug) {
|
||||
const directMatch = await postEngine.findPublishedBySlug(slug, dateFilter);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { eq, and, asc, sql, like } from 'drizzle-orm';
|
||||
import { eq, and, asc, sql } from 'drizzle-orm';
|
||||
import { getDatabase } from '../database';
|
||||
import { tags, posts } from '../database/schema';
|
||||
import { taskManager } from './TaskManager';
|
||||
@@ -23,6 +23,22 @@ export interface TagData {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
type QueryRow = Record<string, unknown>;
|
||||
|
||||
type TagFileRecord = {
|
||||
name?: unknown;
|
||||
color?: unknown;
|
||||
postTemplateSlug?: unknown;
|
||||
};
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag with post count for tag cloud display
|
||||
*/
|
||||
@@ -138,7 +154,7 @@ export class TagEngine extends EventEmitter {
|
||||
.from(tags)
|
||||
.where(and(
|
||||
eq(tags.id, tagId),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
|
||||
if (tagRows.length === 0) {
|
||||
@@ -155,15 +171,18 @@ export class TagEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
const postsResult = await client.execute({
|
||||
sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`,
|
||||
sql: 'SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?',
|
||||
args: [this.currentProjectId, `%"${tagName}"%`],
|
||||
});
|
||||
|
||||
return postsResult.rows
|
||||
.map((row: any) => ({
|
||||
postId: row.id as string,
|
||||
postTags: JSON.parse(row.tags || '[]') as string[],
|
||||
}))
|
||||
.map((row) => {
|
||||
const typedRow = row as QueryRow;
|
||||
return {
|
||||
postId: String(typedRow.id ?? ''),
|
||||
postTags: JSON.parse(String(typedRow.tags ?? '[]')) as string[],
|
||||
};
|
||||
})
|
||||
.filter((row) => row.postTags.includes(tagName));
|
||||
}
|
||||
|
||||
@@ -185,11 +204,11 @@ export class TagEngine extends EventEmitter {
|
||||
|
||||
private async updateMatchingPosts(
|
||||
tagName: string,
|
||||
transform: (postTags: string[]) => string[]
|
||||
transform: (postTags: string[]) => string[],
|
||||
): Promise<{ total: number; process: (onEachUpdated: (updated: number, total: number) => void) => Promise<number> }> {
|
||||
const rawPostsToUpdate = await this.queryPostsContainingTag(tagName);
|
||||
const postsToUpdate = Array.from(
|
||||
new Map(rawPostsToUpdate.map((row) => [row.postId, row])).values()
|
||||
new Map(rawPostsToUpdate.map((row) => [row.postId, row])).values(),
|
||||
);
|
||||
const total = postsToUpdate.length;
|
||||
|
||||
@@ -285,7 +304,9 @@ export class TagEngine extends EventEmitter {
|
||||
*/
|
||||
async getTagsWithCounts(): Promise<TagWithCount[]> {
|
||||
const client = this.getClient();
|
||||
if (!client) return [];
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Query tags with counts from posts - requires raw SQL for JSON operations
|
||||
const result = await client.execute({
|
||||
@@ -307,11 +328,14 @@ export class TagEngine extends EventEmitter {
|
||||
args: [this.currentProjectId, this.currentProjectId],
|
||||
});
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
name: row.name as string,
|
||||
color: row.color as string | null,
|
||||
count: Number(row.post_count) || 0,
|
||||
}));
|
||||
return result.rows.map((row) => {
|
||||
const typedRow = row as QueryRow;
|
||||
return {
|
||||
name: String(typedRow.name ?? ''),
|
||||
color: typeof typedRow.color === 'string' ? typedRow.color : null,
|
||||
count: Number(typedRow.post_count) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,7 +360,7 @@ export class TagEngine extends EventEmitter {
|
||||
.from(tags)
|
||||
.where(and(
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
sql`LOWER(${tags.name}) = LOWER(${name})`
|
||||
sql`LOWER(${tags.name}) = LOWER(${name})`,
|
||||
));
|
||||
|
||||
if (existing.length > 0) {
|
||||
@@ -380,7 +404,7 @@ export class TagEngine extends EventEmitter {
|
||||
.from(tags)
|
||||
.where(and(
|
||||
eq(tags.id, id),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
|
||||
if (existing.length === 0) {
|
||||
@@ -417,7 +441,7 @@ export class TagEngine extends EventEmitter {
|
||||
.set(setFields)
|
||||
.where(and(
|
||||
eq(tags.id, id),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
|
||||
const updatedTag: TagData = {
|
||||
@@ -451,7 +475,7 @@ export class TagEngine extends EventEmitter {
|
||||
runUpdates: async (onProgress) => {
|
||||
const updateOperation = await this.updateMatchingPosts(
|
||||
tagName,
|
||||
(postTags) => postTags.filter((tagEntry) => tagEntry !== tagName)
|
||||
(postTags) => postTags.filter((tagEntry) => tagEntry !== tagName),
|
||||
);
|
||||
|
||||
return updateOperation.process((updatedCount, totalCount) => {
|
||||
@@ -464,7 +488,7 @@ export class TagEngine extends EventEmitter {
|
||||
.delete(tags)
|
||||
.where(and(
|
||||
eq(tags.id, id),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
},
|
||||
buildResult: (postsUpdated) => ({ success: true, postsUpdated }),
|
||||
@@ -492,7 +516,7 @@ export class TagEngine extends EventEmitter {
|
||||
.from(tags)
|
||||
.where(and(
|
||||
eq(tags.id, id),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
if (rows.length > 0) {
|
||||
sourceTags.push(rows[0]);
|
||||
@@ -505,7 +529,7 @@ export class TagEngine extends EventEmitter {
|
||||
.from(tags)
|
||||
.where(and(
|
||||
eq(tags.id, targetTagId),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
|
||||
if (targetRows.length === 0) {
|
||||
@@ -556,7 +580,7 @@ export class TagEngine extends EventEmitter {
|
||||
.delete(tags)
|
||||
.where(and(
|
||||
eq(tags.id, sourceId),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
}
|
||||
},
|
||||
@@ -589,7 +613,7 @@ export class TagEngine extends EventEmitter {
|
||||
.from(tags)
|
||||
.where(and(
|
||||
eq(tags.id, id),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
|
||||
if (tagRows.length === 0) {
|
||||
@@ -610,7 +634,7 @@ export class TagEngine extends EventEmitter {
|
||||
.where(and(
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
sql`LOWER(${tags.name}) = LOWER(${newName})`,
|
||||
sql`${tags.id} != ${id}`
|
||||
sql`${tags.id} != ${id}`,
|
||||
));
|
||||
|
||||
if (duplicateRows.length > 0) {
|
||||
@@ -624,7 +648,7 @@ export class TagEngine extends EventEmitter {
|
||||
runUpdates: async (onProgress) => {
|
||||
const updateOperation = await this.updateMatchingPosts(
|
||||
oldName,
|
||||
(postTags) => postTags.map((tagEntry) => tagEntry === oldName ? newName : tagEntry)
|
||||
(postTags) => postTags.map((tagEntry) => tagEntry === oldName ? newName : tagEntry),
|
||||
);
|
||||
|
||||
return updateOperation.process((updatedCount, totalCount) => {
|
||||
@@ -641,7 +665,7 @@ export class TagEngine extends EventEmitter {
|
||||
})
|
||||
.where(and(
|
||||
eq(tags.id, id),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
},
|
||||
buildResult: (postsUpdated) => ({
|
||||
@@ -667,7 +691,7 @@ export class TagEngine extends EventEmitter {
|
||||
.from(tags)
|
||||
.where(and(
|
||||
eq(tags.id, id),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -689,7 +713,7 @@ export class TagEngine extends EventEmitter {
|
||||
.from(tags)
|
||||
.where(and(
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
sql`LOWER(${tags.name}) = LOWER(${normalizedName})`
|
||||
sql`LOWER(${tags.name}) = LOWER(${normalizedName})`,
|
||||
));
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -720,7 +744,9 @@ export class TagEngine extends EventEmitter {
|
||||
async getPostsWithTag(tagId: string): Promise<string[]> {
|
||||
const db = this.getDb();
|
||||
const client = this.getClient();
|
||||
if (!client) return [];
|
||||
if (!client) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// First get the tag name
|
||||
const tagRows = await db
|
||||
@@ -728,7 +754,7 @@ export class TagEngine extends EventEmitter {
|
||||
.from(tags)
|
||||
.where(and(
|
||||
eq(tags.id, tagId),
|
||||
eq(tags.projectId, this.currentProjectId)
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
));
|
||||
|
||||
if (tagRows.length === 0) {
|
||||
@@ -739,16 +765,17 @@ export class TagEngine extends EventEmitter {
|
||||
|
||||
// Find posts with this tag - requires raw SQL for JSON
|
||||
const postsResult = await client.execute({
|
||||
sql: `SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?`,
|
||||
sql: 'SELECT id, tags FROM posts WHERE project_id = ? AND tags LIKE ?',
|
||||
args: [this.currentProjectId, `%"${tagName}"%`],
|
||||
});
|
||||
|
||||
return postsResult.rows
|
||||
.filter((row: any) => {
|
||||
const postTags: string[] = JSON.parse(row.tags || '[]');
|
||||
.filter((row) => {
|
||||
const typedRow = row as QueryRow;
|
||||
const postTags: string[] = JSON.parse(String(typedRow.tags ?? '[]')) as string[];
|
||||
return postTags.includes(tagName);
|
||||
})
|
||||
.map((row: any) => row.id as string);
|
||||
.map((row) => String((row as QueryRow).id ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -863,17 +890,21 @@ export class TagEngine extends EventEmitter {
|
||||
try {
|
||||
const filePath = this.getTagsFilePath();
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const rawTags: any[] = JSON.parse(content);
|
||||
const parsed = JSON.parse(content);
|
||||
const rawTags = Array.isArray(parsed) ? parsed as TagFileRecord[] : [];
|
||||
|
||||
const db = this.getDb();
|
||||
const now = new Date();
|
||||
|
||||
for (const tag of rawTags) {
|
||||
// Support both portable format { name, color? } and legacy format with id
|
||||
const name = normalizeTaxonomyTerm(tag.name || '');
|
||||
if (!name) continue;
|
||||
const rawName = typeof tag.name === 'string' ? tag.name : '';
|
||||
const name = normalizeTaxonomyTerm(rawName);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const color = tag.color || null;
|
||||
const color = typeof tag.color === 'string' ? tag.color : null;
|
||||
const postTemplateSlug = typeof tag.postTemplateSlug === 'string' ? tag.postTemplateSlug : null;
|
||||
|
||||
// Check if tag with this name already exists
|
||||
@@ -882,7 +913,7 @@ export class TagEngine extends EventEmitter {
|
||||
.from(tags)
|
||||
.where(and(
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
sql`LOWER(${tags.name}) = LOWER(${name})`
|
||||
sql`LOWER(${tags.name}) = LOWER(${name})`,
|
||||
));
|
||||
|
||||
if (existing.length === 0) {
|
||||
@@ -898,7 +929,7 @@ export class TagEngine extends EventEmitter {
|
||||
});
|
||||
} else if (color || postTemplateSlug) {
|
||||
// Update color/postTemplateSlug if provided and tag exists
|
||||
const setFields: Record<string, unknown> = { updatedAt: now };
|
||||
const setFields: { updatedAt: Date; color?: string | null; postTemplateSlug?: string | null } = { updatedAt: now };
|
||||
if (color) {
|
||||
setFields.color = color;
|
||||
}
|
||||
@@ -910,12 +941,12 @@ export class TagEngine extends EventEmitter {
|
||||
.set(setFields)
|
||||
.where(and(
|
||||
eq(tags.projectId, this.currentProjectId),
|
||||
sql`LOWER(${tags.name}) = LOWER(${name})`
|
||||
sql`LOWER(${tags.name}) = LOWER(${name})`,
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
} catch (error) {
|
||||
if (getErrorCode(error) !== 'ENOENT') {
|
||||
console.error('[TagEngine] Failed to load tags from file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,9 @@ export class TemplateEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/** No persistent cache — no-op for watcher compat. */
|
||||
invalidate(_entityId?: string): void {}
|
||||
invalidate(entityId?: string): void {
|
||||
void entityId;
|
||||
}
|
||||
|
||||
setProjectContext(projectId: string, dataDir?: string): void {
|
||||
this.currentProjectId = projectId;
|
||||
@@ -553,7 +555,7 @@ export class TemplateEngine extends EventEmitter {
|
||||
|
||||
private async toTemplateData(row: Template): Promise<TemplateData> {
|
||||
// Draft templates store content in the DB; published templates read from disk.
|
||||
const content = row.status === 'draft' && row.content != null
|
||||
const content = row.status === 'draft' && row.content !== null
|
||||
? row.content
|
||||
: await this.readTemplateBody(row.filePath);
|
||||
|
||||
@@ -608,9 +610,11 @@ export class TemplateEngine extends EventEmitter {
|
||||
/** Publish a draft template: write file to disk, set status='published', clear DB content. */
|
||||
async publishTemplate(id: string): Promise<TemplateData | null> {
|
||||
const existing = await this.getTemplateRow(id);
|
||||
if (!existing) return null;
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = existing.status === 'draft' && existing.content != null
|
||||
const content = existing.status === 'draft' && existing.content !== null
|
||||
? existing.content
|
||||
: await this.readTemplateBody(existing.filePath);
|
||||
|
||||
@@ -624,7 +628,9 @@ export class TemplateEngine extends EventEmitter {
|
||||
.where(eq(templates.id, id));
|
||||
|
||||
const updatedRow = await this.getTemplateRow(id);
|
||||
if (!updatedRow) return null;
|
||||
if (!updatedRow) {
|
||||
return null;
|
||||
}
|
||||
const result = await this.toTemplateData(updatedRow);
|
||||
this.emit('templateUpdated', result);
|
||||
await this.notifier.notify('template', id, 'updated');
|
||||
@@ -634,7 +640,9 @@ export class TemplateEngine extends EventEmitter {
|
||||
/** Delete a draft template (only if status='draft'). Returns false if not found or already published. */
|
||||
async deleteDraftTemplate(id: string): Promise<boolean> {
|
||||
const existing = await this.getTemplateRow(id);
|
||||
if (!existing || existing.status !== 'draft') return false;
|
||||
if (!existing || existing.status !== 'draft') {
|
||||
return false;
|
||||
}
|
||||
|
||||
await getDatabase().getLocal()
|
||||
.delete(templates)
|
||||
@@ -683,7 +691,7 @@ export class TemplateEngine extends EventEmitter {
|
||||
const taken = new Set(
|
||||
rows
|
||||
.filter((item) => item.id !== excludeId)
|
||||
.map((item) => item.slug)
|
||||
.map((item) => item.slug),
|
||||
);
|
||||
|
||||
if (!taken.has(baseSlug)) {
|
||||
@@ -798,7 +806,7 @@ export class TemplateEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
private parseYamlScalar(valueRaw: string): string | number | boolean {
|
||||
if ((valueRaw.startsWith('"') && valueRaw.endsWith('"')) || (valueRaw.startsWith("'") && valueRaw.endsWith("'"))) {
|
||||
if ((valueRaw.startsWith('"') && valueRaw.endsWith('"')) || (valueRaw.startsWith('\'') && valueRaw.endsWith('\''))) {
|
||||
return valueRaw.slice(1, -1)
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\');
|
||||
|
||||
@@ -70,59 +70,59 @@ function classifyPath(
|
||||
requestedPageSlugs: Set<string>,
|
||||
state: { requestRootRoutes: boolean; requiresFallbackSectionRender: boolean },
|
||||
): void {
|
||||
if (normalizedPath === '/' || /^\/(page\/\d+)$/.test(normalizedPath)) {
|
||||
state.requestRootRoutes = true;
|
||||
return;
|
||||
}
|
||||
if (normalizedPath === '/' || /^\/(page\/\d+)$/.test(normalizedPath)) {
|
||||
state.requestRootRoutes = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryMatch = normalizedPath.match(/^\/category\/([^/]+)(?:\/page\/\d+)?$/);
|
||||
if (categoryMatch) {
|
||||
requestedCategories.add(decodePathSegment(categoryMatch[1]));
|
||||
return;
|
||||
}
|
||||
const categoryMatch = normalizedPath.match(/^\/category\/([^/]+)(?:\/page\/\d+)?$/);
|
||||
if (categoryMatch) {
|
||||
requestedCategories.add(decodePathSegment(categoryMatch[1]));
|
||||
return;
|
||||
}
|
||||
|
||||
const tagMatch = normalizedPath.match(/^\/tag\/([^/]+)(?:\/page\/\d+)?$/);
|
||||
if (tagMatch) {
|
||||
requestedTags.add(decodePathSegment(tagMatch[1]));
|
||||
return;
|
||||
}
|
||||
const tagMatch = normalizedPath.match(/^\/tag\/([^/]+)(?:\/page\/\d+)?$/);
|
||||
if (tagMatch) {
|
||||
requestedTags.add(decodePathSegment(tagMatch[1]));
|
||||
return;
|
||||
}
|
||||
|
||||
const singleMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)$/);
|
||||
if (singleMatch) {
|
||||
requestedPostRoutes.push({
|
||||
year: Number(singleMatch[1]),
|
||||
month: Number(singleMatch[2]),
|
||||
day: Number(singleMatch[3]),
|
||||
slug: decodePathSegment(singleMatch[4]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const singleMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)$/);
|
||||
if (singleMatch) {
|
||||
requestedPostRoutes.push({
|
||||
year: Number(singleMatch[1]),
|
||||
month: Number(singleMatch[2]),
|
||||
day: Number(singleMatch[3]),
|
||||
slug: decodePathSegment(singleMatch[4]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const yearMatch = normalizedPath.match(/^\/(\d{4})(?:\/page\/\d+)?$/);
|
||||
if (yearMatch) {
|
||||
requestedYears.add(Number(yearMatch[1]));
|
||||
return;
|
||||
}
|
||||
const yearMatch = normalizedPath.match(/^\/(\d{4})(?:\/page\/\d+)?$/);
|
||||
if (yearMatch) {
|
||||
requestedYears.add(Number(yearMatch[1]));
|
||||
return;
|
||||
}
|
||||
|
||||
const monthMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})(?:\/page\/\d+)?$/);
|
||||
if (monthMatch) {
|
||||
requestedYearMonths.add(`${monthMatch[1]}/${monthMatch[2]}`);
|
||||
return;
|
||||
}
|
||||
const monthMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})(?:\/page\/\d+)?$/);
|
||||
if (monthMatch) {
|
||||
requestedYearMonths.add(`${monthMatch[1]}/${monthMatch[2]}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dayMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})(?:\/page\/\d+)?$/);
|
||||
if (dayMatch) {
|
||||
requestedYearMonthDays.add(`${dayMatch[1]}/${dayMatch[2]}/${dayMatch[3]}`);
|
||||
return;
|
||||
}
|
||||
const dayMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})(?:\/page\/\d+)?$/);
|
||||
if (dayMatch) {
|
||||
requestedYearMonthDays.add(`${dayMatch[1]}/${dayMatch[2]}/${dayMatch[3]}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pageMatch = normalizedPath.match(/^\/([^/]+)$/);
|
||||
if (pageMatch) {
|
||||
requestedPageSlugs.add(decodePathSegment(pageMatch[1]));
|
||||
return;
|
||||
}
|
||||
const pageMatch = normalizedPath.match(/^\/([^/]+)$/);
|
||||
if (pageMatch) {
|
||||
requestedPageSlugs.add(decodePathSegment(pageMatch[1]));
|
||||
return;
|
||||
}
|
||||
|
||||
state.requiresFallbackSectionRender = true;
|
||||
state.requiresFallbackSectionRender = true;
|
||||
}
|
||||
|
||||
function createEmptyPlan(): {
|
||||
@@ -134,7 +134,7 @@ function createEmptyPlan(): {
|
||||
requestedPostRoutes: RequestedPostRoute[];
|
||||
requestedPageSlugs: Set<string>;
|
||||
state: { requestRootRoutes: boolean; requiresFallbackSectionRender: boolean };
|
||||
} {
|
||||
} {
|
||||
return {
|
||||
requestedCategories: new Set<string>(),
|
||||
requestedTags: new Set<string>(),
|
||||
@@ -179,7 +179,10 @@ export function planMissingValidationPaths(missingPaths: string[], additionalLan
|
||||
if (!langPlanMap.has(lang)) {
|
||||
langPlanMap.set(lang, createEmptyPlan());
|
||||
}
|
||||
const lp = langPlanMap.get(lang)!;
|
||||
const lp = langPlanMap.get(lang);
|
||||
if (!lp) {
|
||||
continue;
|
||||
}
|
||||
classifyPath(
|
||||
strippedPath,
|
||||
lp.requestedCategories,
|
||||
@@ -237,8 +240,6 @@ export function buildTargetedValidationPlan(params: BuildTargetedValidationPlanP
|
||||
publishedPosts,
|
||||
allCategories,
|
||||
allTags,
|
||||
availableYearMonths,
|
||||
availableYearMonthDays,
|
||||
} = params;
|
||||
|
||||
const requestedCategories = new Set(initialPlan.requestedCategories);
|
||||
|
||||
@@ -176,7 +176,9 @@ export class WxrParser {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
// Only process direct children of channel (not item-level category elements)
|
||||
if (el.parentNode !== channel) continue;
|
||||
if (el.parentNode !== channel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
categories.push({
|
||||
name: this.getElementText(el, 'cat_name', NS.wp),
|
||||
@@ -194,7 +196,9 @@ export class WxrParser {
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
if (el.parentNode !== channel) continue;
|
||||
if (el.parentNode !== channel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tags.push({
|
||||
name: this.getElementText(el, 'tag_name', NS.wp),
|
||||
@@ -215,7 +219,9 @@ export class WxrParser {
|
||||
for (let i = 0; i < catElements.length; i++) {
|
||||
const el = catElements[i];
|
||||
// Only direct children of item
|
||||
if (el.parentNode !== item) continue;
|
||||
if (el.parentNode !== item) {
|
||||
continue;
|
||||
}
|
||||
const domain = el.getAttribute('domain');
|
||||
const text = this.getTextContent(el);
|
||||
if (domain === 'category' && text) {
|
||||
@@ -282,7 +288,9 @@ export class WxrParser {
|
||||
}
|
||||
|
||||
private extractFilename(url: string): string {
|
||||
if (!url) return '';
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
return pathname.split('/').pop() || '';
|
||||
@@ -292,7 +300,9 @@ export class WxrParser {
|
||||
}
|
||||
|
||||
private extractRelativePath(url: string): string {
|
||||
if (!url) return '';
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
// Extract path after wp-content/uploads/
|
||||
const marker = 'wp-content/uploads/';
|
||||
const idx = url.indexOf(marker);
|
||||
|
||||
@@ -51,7 +51,7 @@ const tabContentItemSchema = z.object({
|
||||
// Tool factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createA2UITools() {
|
||||
function buildA2UITools() {
|
||||
return {
|
||||
render_chart: tool({
|
||||
description: 'Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization.',
|
||||
@@ -60,7 +60,7 @@ export function createA2UITools() {
|
||||
title: z.string().optional().describe('Optional chart title'),
|
||||
series: z.array(seriesItemSchema).describe('Array of data points.'),
|
||||
}),
|
||||
execute: async (_input) => ({ success: true }),
|
||||
execute: async () => ({ success: true }),
|
||||
}),
|
||||
|
||||
render_table: tool({
|
||||
@@ -70,7 +70,7 @@ export function createA2UITools() {
|
||||
columns: z.array(z.string()).describe('Column header names'),
|
||||
rows: z.array(z.array(z.string())).describe('Table rows, each row is an array of cell values'),
|
||||
}),
|
||||
execute: async (_input) => ({ success: true }),
|
||||
execute: async () => ({ success: true }),
|
||||
}),
|
||||
|
||||
render_form: tool({
|
||||
@@ -92,7 +92,7 @@ export function createA2UITools() {
|
||||
submitLabel: z.string().describe('Label for the submit button'),
|
||||
submitAction: z.string().optional().describe('Action to dispatch on submit'),
|
||||
}),
|
||||
execute: async (_input) => ({ success: true }),
|
||||
execute: async () => ({ success: true }),
|
||||
}),
|
||||
|
||||
render_card: tool({
|
||||
@@ -107,7 +107,7 @@ export function createA2UITools() {
|
||||
payload: z.record(z.string(), z.unknown()).optional().describe('Optional action payload'),
|
||||
})).optional().describe('Optional action buttons on the card'),
|
||||
}),
|
||||
execute: async (_input) => ({ success: true }),
|
||||
execute: async () => ({ success: true }),
|
||||
}),
|
||||
|
||||
render_metric: tool({
|
||||
@@ -116,7 +116,7 @@ export function createA2UITools() {
|
||||
label: z.string().describe('Metric label'),
|
||||
value: z.string().describe('Metric value (displayed prominently)'),
|
||||
}),
|
||||
execute: async (_input) => ({ success: true }),
|
||||
execute: async () => ({ success: true }),
|
||||
}),
|
||||
|
||||
render_list: tool({
|
||||
@@ -125,7 +125,7 @@ export function createA2UITools() {
|
||||
title: z.string().optional().describe('Optional list title'),
|
||||
items: z.array(z.string()).describe('List items'),
|
||||
}),
|
||||
execute: async (_input) => ({ success: true }),
|
||||
execute: async () => ({ success: true }),
|
||||
}),
|
||||
|
||||
render_tabs: tool({
|
||||
@@ -136,7 +136,7 @@ export function createA2UITools() {
|
||||
content: z.array(tabContentItemSchema).describe('Content items within the tab'),
|
||||
})).describe('Array of tabs'),
|
||||
}),
|
||||
execute: async (_input) => ({ success: true }),
|
||||
execute: async () => ({ success: true }),
|
||||
}),
|
||||
|
||||
render_mindmap: tool({
|
||||
@@ -149,10 +149,14 @@ export function createA2UITools() {
|
||||
children: z.array(z.string()).optional().describe('IDs of child nodes'),
|
||||
})).describe('Flat array of nodes. The first node is the root. Each node references children by ID.'),
|
||||
}),
|
||||
execute: async (_input) => ({ success: true }),
|
||||
execute: async () => ({ success: true }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createA2UITools(): ReturnType<typeof buildA2UITools> {
|
||||
return buildA2UITools();
|
||||
}
|
||||
|
||||
/** The return type of createA2UITools — useful for typing tool maps. */
|
||||
export type A2UITools = ReturnType<typeof createA2UITools>;
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function executeCheckTerm(
|
||||
// Tool factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createBlogTools(deps: BlogToolDeps) {
|
||||
function buildBlogTools(deps: BlogToolDeps) {
|
||||
const { postEngine, mediaEngine, postMediaEngine } = deps;
|
||||
|
||||
return {
|
||||
@@ -180,12 +180,24 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
}
|
||||
|
||||
const filter: PostFilter = {};
|
||||
if (category) filter.categories = [category];
|
||||
if (tags && tags.length > 0) filter.tags = tags;
|
||||
if (language) filter.language = language;
|
||||
if (missingTranslationLanguage) filter.missingTranslationLanguage = missingTranslationLanguage;
|
||||
if (year !== undefined) filter.year = year;
|
||||
if (month !== undefined && year !== undefined) filter.month = month;
|
||||
if (category) {
|
||||
filter.categories = [category];
|
||||
}
|
||||
if (tags && tags.length > 0) {
|
||||
filter.tags = tags;
|
||||
}
|
||||
if (language) {
|
||||
filter.language = language;
|
||||
}
|
||||
if (missingTranslationLanguage) {
|
||||
filter.missingTranslationLanguage = missingTranslationLanguage;
|
||||
}
|
||||
if (year !== undefined) {
|
||||
filter.year = year;
|
||||
}
|
||||
if (month !== undefined && year !== undefined) {
|
||||
filter.month = month;
|
||||
}
|
||||
|
||||
const offset = off ?? 0;
|
||||
const limit = lim ?? 10;
|
||||
@@ -213,7 +225,9 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
limit,
|
||||
posts,
|
||||
};
|
||||
if (hints.length > 0) result.hints = hints;
|
||||
if (hints.length > 0) {
|
||||
result.hints = hints;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}),
|
||||
@@ -225,7 +239,9 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
}),
|
||||
execute: async ({ postId }) => {
|
||||
const post = await postEngine.getPost(postId);
|
||||
if (!post) return { success: false, error: 'Post not found' };
|
||||
if (!post) {
|
||||
return { success: false, error: 'Post not found' };
|
||||
}
|
||||
const [backlinks, linksTo] = await Promise.all([
|
||||
postEngine.getLinkedBy(post.id),
|
||||
postEngine.getLinksTo(post.id),
|
||||
@@ -254,7 +270,9 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
}),
|
||||
execute: async ({ slug }) => {
|
||||
const post = await postEngine.getPostBySlug(slug);
|
||||
if (!post) return { success: false, error: 'Post not found' };
|
||||
if (!post) {
|
||||
return { success: false, error: 'Post not found' };
|
||||
}
|
||||
const [backlinks, linksTo] = await Promise.all([
|
||||
postEngine.getLinkedBy(post.id),
|
||||
postEngine.getLinksTo(post.id),
|
||||
@@ -295,13 +313,27 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
}
|
||||
|
||||
const filter: PostFilter = {};
|
||||
if (status) filter.status = status;
|
||||
if (tags) filter.tags = tags;
|
||||
if (category) filter.categories = [category];
|
||||
if (language) filter.language = language;
|
||||
if (missingTranslationLanguage) filter.missingTranslationLanguage = missingTranslationLanguage;
|
||||
if (year !== undefined) filter.year = year;
|
||||
if (month !== undefined && year !== undefined) filter.month = month;
|
||||
if (status) {
|
||||
filter.status = status;
|
||||
}
|
||||
if (tags) {
|
||||
filter.tags = tags;
|
||||
}
|
||||
if (category) {
|
||||
filter.categories = [category];
|
||||
}
|
||||
if (language) {
|
||||
filter.language = language;
|
||||
}
|
||||
if (missingTranslationLanguage) {
|
||||
filter.missingTranslationLanguage = missingTranslationLanguage;
|
||||
}
|
||||
if (year !== undefined) {
|
||||
filter.year = year;
|
||||
}
|
||||
if (month !== undefined && year !== undefined) {
|
||||
filter.month = month;
|
||||
}
|
||||
|
||||
const offset = off ?? 0;
|
||||
const limit = lim ?? 20;
|
||||
@@ -343,7 +375,9 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
limit,
|
||||
posts,
|
||||
};
|
||||
if (hints.length > 0) result.hints = hints;
|
||||
if (hints.length > 0) {
|
||||
result.hints = hints;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}),
|
||||
@@ -355,7 +389,9 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
}),
|
||||
execute: async ({ mediaId }) => {
|
||||
const media = await mediaEngine.getMedia(mediaId);
|
||||
if (!media) return { success: false, error: 'Media not found' };
|
||||
if (!media) {
|
||||
return { success: false, error: 'Media not found' };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
media: {
|
||||
@@ -389,9 +425,15 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
|
||||
if (hasMediaFilter) {
|
||||
const mediaFilter: { year?: number; month?: number; tags?: string[] } = {};
|
||||
if (year !== undefined) mediaFilter.year = year;
|
||||
if (month !== undefined && year !== undefined) mediaFilter.month = month;
|
||||
if (tags) mediaFilter.tags = tags;
|
||||
if (year !== undefined) {
|
||||
mediaFilter.year = year;
|
||||
}
|
||||
if (month !== undefined && year !== undefined) {
|
||||
mediaFilter.month = month;
|
||||
}
|
||||
if (tags) {
|
||||
mediaFilter.tags = tags;
|
||||
}
|
||||
mediaList = await mediaEngine.getMediaFiltered(mediaFilter);
|
||||
} else {
|
||||
mediaList = await mediaEngine.getAllMedia();
|
||||
@@ -433,10 +475,18 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
}),
|
||||
execute: async ({ postId, title, excerpt, tags, categories }) => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (excerpt !== undefined) updates.excerpt = excerpt;
|
||||
if (tags !== undefined) updates.tags = tags;
|
||||
if (categories !== undefined) updates.categories = categories;
|
||||
if (title !== undefined) {
|
||||
updates.title = title;
|
||||
}
|
||||
if (excerpt !== undefined) {
|
||||
updates.excerpt = excerpt;
|
||||
}
|
||||
if (tags !== undefined) {
|
||||
updates.tags = tags;
|
||||
}
|
||||
if (categories !== undefined) {
|
||||
updates.categories = categories;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return { success: false, error: 'No updates provided' };
|
||||
@@ -458,10 +508,18 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
}),
|
||||
execute: async ({ mediaId, title, alt, caption, tags }) => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (alt !== undefined) updates.alt = alt;
|
||||
if (caption !== undefined) updates.caption = caption;
|
||||
if (tags !== undefined) updates.tags = tags;
|
||||
if (title !== undefined) {
|
||||
updates.title = title;
|
||||
}
|
||||
if (alt !== undefined) {
|
||||
updates.alt = alt;
|
||||
}
|
||||
if (caption !== undefined) {
|
||||
updates.caption = caption;
|
||||
}
|
||||
if (tags !== undefined) {
|
||||
updates.tags = tags;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return { success: false, error: 'No updates provided' };
|
||||
@@ -500,11 +558,21 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
|
||||
}
|
||||
const filter: { year?: number; month?: number; status?: string; category?: string; tags?: string[] } = {};
|
||||
if (year !== undefined) filter.year = year;
|
||||
if (month !== undefined) filter.month = month;
|
||||
if (status) filter.status = status;
|
||||
if (category) filter.category = category;
|
||||
if (tags && tags.length > 0) filter.tags = tags;
|
||||
if (year !== undefined) {
|
||||
filter.year = year;
|
||||
}
|
||||
if (month !== undefined) {
|
||||
filter.month = month;
|
||||
}
|
||||
if (status) {
|
||||
filter.status = status;
|
||||
}
|
||||
if (category) {
|
||||
filter.category = category;
|
||||
}
|
||||
if (tags && tags.length > 0) {
|
||||
filter.tags = tags;
|
||||
}
|
||||
|
||||
const result = await postEngine.getPostCounts(groupBy, Object.keys(filter).length > 0 ? filter : undefined);
|
||||
return {
|
||||
@@ -676,5 +744,9 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
};
|
||||
}
|
||||
|
||||
export function createBlogTools(deps: BlogToolDeps): ReturnType<typeof buildBlogTools> {
|
||||
return buildBlogTools(deps);
|
||||
}
|
||||
|
||||
/** The return type of createBlogTools — useful for typing tool maps. */
|
||||
export type BlogTools = ReturnType<typeof createBlogTools>;
|
||||
|
||||
@@ -113,7 +113,9 @@ async function appendBlogStats(
|
||||
const stats = await blogToolDeps.postEngine.getBlogStats();
|
||||
const mediaList = await blogToolDeps.mediaEngine.getAllMedia();
|
||||
|
||||
if (stats.totalPosts === 0) return basePrompt;
|
||||
if (stats.totalPosts === 0) {
|
||||
return basePrompt;
|
||||
}
|
||||
|
||||
const dateRange = stats.oldestPostDate && stats.newestPostDate
|
||||
? `from ${stats.oldestPostDate.toISOString().split('T')[0]} to ${stats.newestPostDate.toISOString().split('T')[0]}`
|
||||
@@ -161,12 +163,16 @@ function truncateMessages(
|
||||
const responseReserve = 4096;
|
||||
const availableBudget = maxContextTokens - systemTokens - toolsTokens - responseReserve;
|
||||
|
||||
if (availableBudget <= 0) return messages.slice(-1);
|
||||
if (availableBudget <= 0) {
|
||||
return messages.slice(-1);
|
||||
}
|
||||
|
||||
const messageTokens = () =>
|
||||
messages.reduce((sum, m) => sum + estimateTokens(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)), 0);
|
||||
|
||||
if (messageTokens() <= availableBudget) return messages;
|
||||
if (messageTokens() <= availableBudget) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
let truncated = [...messages];
|
||||
while (truncated.length > 2 && messageTokens.call(null) > availableBudget) {
|
||||
@@ -377,7 +383,7 @@ export class ChatService {
|
||||
});
|
||||
|
||||
// Consume the stream to completion
|
||||
const finalResult = await result.response;
|
||||
await result.response;
|
||||
|
||||
// Extract usage from the response
|
||||
const usage = await result.usage;
|
||||
@@ -412,7 +418,9 @@ export class ChatService {
|
||||
};
|
||||
} catch (error) {
|
||||
const isAborted = abortController.signal.aborted || (error as Error).message === 'Request cancelled';
|
||||
if (!isAborted) throw error;
|
||||
if (!isAborted) {
|
||||
throw error;
|
||||
}
|
||||
return { success: true, message: '' };
|
||||
} finally {
|
||||
this.abortControllers.delete(conversationId);
|
||||
@@ -475,7 +483,9 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!titleModel) return;
|
||||
if (!titleModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = this.providers.resolveModel(titleModel);
|
||||
|
||||
@@ -511,7 +521,9 @@ export class ChatService {
|
||||
usage: LanguageModelUsage | undefined,
|
||||
callbacks: ChatCallbacks,
|
||||
): void {
|
||||
if (!usage || !callbacks.onTokenUsage) return;
|
||||
if (!usage || !callbacks.onTokenUsage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// AI SDK v6 normalizes usage into inputTokens/outputTokens
|
||||
// Cache tokens are in inputTokenDetails
|
||||
|
||||
@@ -85,16 +85,24 @@ export function createOpenCodeGateway(apiKey: string): Provider {
|
||||
/** Determine which provider backend a model ID belongs to. */
|
||||
export function detectProvider(modelId: string): string {
|
||||
const id = modelId.toLowerCase();
|
||||
if (id.startsWith('claude')) return 'anthropic';
|
||||
if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai';
|
||||
if (id.startsWith('gemini')) return 'google';
|
||||
if (id.startsWith('claude')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) {
|
||||
return 'openai';
|
||||
}
|
||||
if (id.startsWith('gemini')) {
|
||||
return 'google';
|
||||
}
|
||||
if (
|
||||
id.startsWith('mistral') ||
|
||||
id.startsWith('ministral') ||
|
||||
id.startsWith('devstral') ||
|
||||
id.startsWith('codestral') ||
|
||||
id.startsWith('pixtral')
|
||||
) return 'mistral';
|
||||
) {
|
||||
return 'mistral';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
@@ -294,8 +302,12 @@ export class ProviderRegistry {
|
||||
* registration first, then falling back to prefix-based detection.
|
||||
*/
|
||||
detectModelProvider(modelId: string): string {
|
||||
if (this.ollamaModelIds.has(modelId)) return 'ollama';
|
||||
if (this.lmstudioModelIds.has(modelId)) return 'lmstudio';
|
||||
if (this.ollamaModelIds.has(modelId)) {
|
||||
return 'ollama';
|
||||
}
|
||||
if (this.lmstudioModelIds.has(modelId)) {
|
||||
return 'lmstudio';
|
||||
}
|
||||
return detectProvider(modelId);
|
||||
}
|
||||
|
||||
@@ -309,11 +321,19 @@ export class ProviderRegistry {
|
||||
|
||||
/** Check whether the key for a specific provider is set. */
|
||||
isProviderKeySet(provider: string): boolean {
|
||||
if (provider === 'ollama') return this.ollamaEnabled;
|
||||
if (provider === 'lmstudio') return this.lmstudioEnabled;
|
||||
if (provider === 'ollama') {
|
||||
return this.ollamaEnabled;
|
||||
}
|
||||
if (provider === 'lmstudio') {
|
||||
return this.lmstudioEnabled;
|
||||
}
|
||||
// In offline mode, cloud providers are unavailable
|
||||
if (this._offlineMode) return false;
|
||||
if (provider === 'mistral') return !!this.mistralKey;
|
||||
if (this._offlineMode) {
|
||||
return false;
|
||||
}
|
||||
if (provider === 'mistral') {
|
||||
return !!this.mistralKey;
|
||||
}
|
||||
return !!this.opencodeKey;
|
||||
}
|
||||
|
||||
@@ -404,8 +424,12 @@ export class ProviderRegistry {
|
||||
* Used as automatic fallback when no explicit offline model is configured.
|
||||
*/
|
||||
getFirstKnownLocalModelId(): string | null {
|
||||
for (const id of this.ollamaModelIds) return id;
|
||||
for (const id of this.lmstudioModelIds) return id;
|
||||
for (const id of this.ollamaModelIds) {
|
||||
return id;
|
||||
}
|
||||
for (const id of this.lmstudioModelIds) {
|
||||
return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -414,10 +438,14 @@ export class ProviderRegistry {
|
||||
*/
|
||||
getFirstKnownLocalVisionModelId(): string | null {
|
||||
for (const id of this.ollamaModelIds) {
|
||||
if (this.ollamaModelSupportsVision(id)) return id;
|
||||
if (this.ollamaModelSupportsVision(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
for (const id of this.lmstudioModelIds) {
|
||||
if (this.lmstudioModelSupportsVision(id)) return id;
|
||||
if (this.lmstudioModelSupportsVision(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -495,7 +523,9 @@ export class ProviderRegistry {
|
||||
try {
|
||||
const models = await this.fetchOllamaModels();
|
||||
allModels.push(...models);
|
||||
if (models.length > 0) fetched = true;
|
||||
if (models.length > 0) {
|
||||
fetched = true;
|
||||
}
|
||||
} catch {
|
||||
// Ollama not running — skip silently
|
||||
}
|
||||
@@ -506,7 +536,9 @@ export class ProviderRegistry {
|
||||
try {
|
||||
const models = await this.fetchLmstudioModels();
|
||||
allModels.push(...models);
|
||||
if (models.length > 0) fetched = true;
|
||||
if (models.length > 0) {
|
||||
fetched = true;
|
||||
}
|
||||
} catch {
|
||||
// LM Studio not running — skip silently
|
||||
}
|
||||
@@ -524,7 +556,9 @@ export class ProviderRegistry {
|
||||
|
||||
/** Validate an OpenCode API key against the models endpoint. */
|
||||
async validateOpencodeKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> {
|
||||
if (!apiKey || apiKey.length < 3) return { isValid: false, models: [] };
|
||||
if (!apiKey || apiKey.length < 3) {
|
||||
return { isValid: false, models: [] };
|
||||
}
|
||||
|
||||
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
|
||||
|
||||
@@ -548,7 +582,9 @@ export class ProviderRegistry {
|
||||
|
||||
/** Validate a Mistral API key against the Mistral models endpoint. */
|
||||
async validateMistralKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> {
|
||||
if (!apiKey || apiKey.length < 3) return { isValid: false, models: [] };
|
||||
if (!apiKey || apiKey.length < 3) {
|
||||
return { isValid: false, models: [] };
|
||||
}
|
||||
|
||||
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
|
||||
|
||||
@@ -578,10 +614,14 @@ export class ProviderRegistry {
|
||||
const timeout = setTimeout(() => controller.abort(), LMSTUDIO_FETCH_TIMEOUT);
|
||||
const response = await fetch(LMSTUDIO_MODELS_URL, { method: 'GET', signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
if (!response.ok) return [];
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json() as { data?: Array<{ id: string }> };
|
||||
if (!data.data || !Array.isArray(data.data)) return [];
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const models: ChatModel[] = data.data.map(m => ({
|
||||
id: m.id,
|
||||
@@ -591,7 +631,9 @@ export class ProviderRegistry {
|
||||
}));
|
||||
// Only replace registered IDs on successful fetch
|
||||
this.clearLmstudioModels();
|
||||
for (const m of models) this.registerLmstudioModel(m.id);
|
||||
for (const m of models) {
|
||||
this.registerLmstudioModel(m.id);
|
||||
}
|
||||
return models;
|
||||
} catch {
|
||||
return [];
|
||||
@@ -610,10 +652,14 @@ export class ProviderRegistry {
|
||||
const timeout = setTimeout(() => controller.abort(), OLLAMA_FETCH_TIMEOUT);
|
||||
const response = await fetch(OLLAMA_TAGS_URL, { method: 'GET', signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
if (!response.ok) return [];
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json() as { models?: Array<{ name: string; details?: { family?: string } }> };
|
||||
if (!data.models || !Array.isArray(data.models)) return [];
|
||||
if (!data.models || !Array.isArray(data.models)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const models: ChatModel[] = data.models.map(m => ({
|
||||
id: m.name,
|
||||
@@ -623,7 +669,9 @@ export class ProviderRegistry {
|
||||
}));
|
||||
// Only replace registered IDs on successful fetch
|
||||
this.clearOllamaModels();
|
||||
for (const m of models) this.registerOllamaModel(m.id);
|
||||
for (const m of models) {
|
||||
this.registerOllamaModel(m.id);
|
||||
}
|
||||
return models;
|
||||
} catch {
|
||||
return [];
|
||||
@@ -640,10 +688,14 @@ export class ProviderRegistry {
|
||||
filterProvider?: string,
|
||||
): Promise<ChatModel[]> {
|
||||
const response = await fetch(url, { method: 'GET', headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { data?: Array<{ id: string }> };
|
||||
if (!data.data || !Array.isArray(data.data)) return [];
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let models = data.data;
|
||||
if (filterProvider) {
|
||||
|
||||
@@ -249,7 +249,9 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
|
||||
// Get media metadata
|
||||
const mediaItem = await this.mediaEngine.getMedia(mediaId);
|
||||
if (!mediaItem) return { success: false, error: 'Media item not found' };
|
||||
if (!mediaItem) {
|
||||
return { success: false, error: 'Media item not found' };
|
||||
}
|
||||
if (!mediaItem.mimeType.startsWith('image/')) {
|
||||
return { success: false, error: `Cannot analyze this file type: ${mediaItem.mimeType}. Only images are supported.` };
|
||||
}
|
||||
@@ -308,7 +310,9 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
});
|
||||
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
|
||||
if (!jsonMatch) {
|
||||
return { success: false, error: 'Invalid response format from AI' };
|
||||
}
|
||||
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
return {
|
||||
@@ -374,7 +378,9 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
});
|
||||
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
|
||||
if (!jsonMatch) {
|
||||
return { success: false, error: 'Invalid response format from AI' };
|
||||
}
|
||||
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
const detected = (result.language || '').toLowerCase().trim();
|
||||
@@ -402,7 +408,9 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
|
||||
// Load post (resolves content from filesystem for published posts)
|
||||
const post = await this.postEngine.getPost(postId);
|
||||
if (!post) return { success: false, error: 'Post not found' };
|
||||
if (!post) {
|
||||
return { success: false, error: 'Post not found' };
|
||||
}
|
||||
if (!post.content || post.content.trim().length === 0) {
|
||||
return { success: false, error: 'Post has no content to analyze' };
|
||||
}
|
||||
@@ -451,13 +459,17 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
});
|
||||
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
|
||||
if (!jsonMatch) {
|
||||
return { success: false, error: 'Invalid response format from AI' };
|
||||
}
|
||||
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
|
||||
// Sanitize slug: lowercase, hyphens only
|
||||
let resultSlug = result.slug ? slugify(result.slug) : undefined;
|
||||
if (resultSlug === '') resultSlug = undefined;
|
||||
if (resultSlug === '') {
|
||||
resultSlug = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -633,7 +645,9 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
});
|
||||
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
|
||||
if (!jsonMatch) {
|
||||
return { success: false, error: 'Invalid response format from AI' };
|
||||
}
|
||||
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
const detected = (result.language || '').toLowerCase().trim();
|
||||
@@ -721,7 +735,9 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
});
|
||||
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
|
||||
if (!jsonMatch) {
|
||||
return { success: false, error: 'Invalid response format from AI' };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ type WorkerResponseMessage = WorkerReadyMessage | WorkerResultMessage | WorkerEr
|
||||
type PyodideRuntime = {
|
||||
globals: {
|
||||
set: (name: string, value: unknown) => void;
|
||||
} | any;
|
||||
};
|
||||
runPythonAsync: (code: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
createDataBackedPostMediaEngine,
|
||||
} from './DataBackedEngines';
|
||||
import { createPreviewBackedGenerationRouteRenderer } from './GenerationRouteRendererFactory';
|
||||
import type { BlogGenerationPostEngineContract } from './BlogGenerationEngine';
|
||||
import type { MediaEngine } from './MediaEngine';
|
||||
import type { PostMediaEngine } from './PostMediaEngine';
|
||||
import {
|
||||
generateSinglePostPages,
|
||||
generateCategoryPages,
|
||||
@@ -52,11 +55,18 @@ function createWorkerHashStore(hashCache: Map<string, string | null>) {
|
||||
const pendingUpdates: Array<{ relativePath: string; hash: string }> = [];
|
||||
|
||||
return {
|
||||
async get(_projectId: string, relativePath: string): Promise<string | null> {
|
||||
async get(
|
||||
_projectId: string,
|
||||
relativePath: string,
|
||||
): Promise<string | null> {
|
||||
return hashCache.get(relativePath) ?? null;
|
||||
},
|
||||
|
||||
async set(_projectId: string, relativePath: string, hash: string): Promise<void> {
|
||||
async set(
|
||||
_projectId: string,
|
||||
relativePath: string,
|
||||
hash: string,
|
||||
): Promise<void> {
|
||||
pendingUpdates.push({ relativePath, hash });
|
||||
hashCache.set(relativePath, hash);
|
||||
},
|
||||
@@ -106,7 +116,10 @@ async function run(): Promise<void> {
|
||||
}
|
||||
|
||||
// 2c. Reconstruct post-media links for gallery/album macros
|
||||
const postMediaLinks = new Map<string, Array<{ mediaId: string; sortOrder: number }>>();
|
||||
const postMediaLinks = new Map<
|
||||
string,
|
||||
Array<{ mediaId: string; sortOrder: number }>
|
||||
>();
|
||||
if (task.postMediaLinksEntries) {
|
||||
for (const [postId, links] of task.postMediaLinksEntries) {
|
||||
postMediaLinks.set(postId, links);
|
||||
@@ -114,7 +127,10 @@ async function run(): Promise<void> {
|
||||
}
|
||||
|
||||
// 3. Reconstruct backlinks Map
|
||||
const backlinksMap = new Map<string, Array<{ id: string; title: string; slug: string }>>();
|
||||
const backlinksMap = new Map<
|
||||
string,
|
||||
Array<{ id: string; title: string; slug: string }>
|
||||
>();
|
||||
if (task.backlinksMap) {
|
||||
for (const [postId, links] of Object.entries(task.backlinksMap)) {
|
||||
backlinksMap.set(postId, links);
|
||||
@@ -122,9 +138,16 @@ async function run(): Promise<void> {
|
||||
}
|
||||
|
||||
// 4. Create data-backed engines
|
||||
const postEngine = createDataBackedPostEngine({ allPosts: lookupPosts, backlinksMap, postFilePaths });
|
||||
const postEngine = createDataBackedPostEngine({
|
||||
allPosts: lookupPosts,
|
||||
backlinksMap,
|
||||
postFilePaths,
|
||||
});
|
||||
const mediaEngine = createDataBackedMediaEngine(mediaItems);
|
||||
const postMediaEngine = createDataBackedPostMediaEngine({ mediaItems, postMediaLinks });
|
||||
const postMediaEngine = createDataBackedPostMediaEngine({
|
||||
mediaItems,
|
||||
postMediaLinks,
|
||||
});
|
||||
|
||||
// 5. Create route renderer (same factory as main thread, but backed by data)
|
||||
const renderRoute = createPreviewBackedGenerationRouteRenderer({
|
||||
@@ -137,9 +160,9 @@ async function run(): Promise<void> {
|
||||
publishedPostsForLookup: lookupPosts,
|
||||
languagePrefix: task.languagePrefix,
|
||||
engines: {
|
||||
postEngine: postEngine as any,
|
||||
mediaEngine: mediaEngine as any,
|
||||
postMediaEngine: postMediaEngine as any,
|
||||
postEngine: postEngine as BlogGenerationPostEngineContract,
|
||||
mediaEngine: mediaEngine as MediaEngine,
|
||||
postMediaEngine: postMediaEngine as PostMediaEngine,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -173,99 +196,111 @@ async function run(): Promise<void> {
|
||||
const projectId = task.options.projectId;
|
||||
|
||||
switch (task.section) {
|
||||
case 'single': {
|
||||
pagesGenerated += await generateSinglePostPages({
|
||||
projectId,
|
||||
posts,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'single': {
|
||||
pagesGenerated += await generateSinglePostPages({
|
||||
projectId,
|
||||
posts,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'category': {
|
||||
const allCategories = new Set(task.allCategories ?? []);
|
||||
const postsByCategory = task.postsByCategoryEntries
|
||||
? deserializePostMap(task.postsByCategoryEntries)
|
||||
: undefined;
|
||||
case 'category': {
|
||||
const allCategories = new Set(task.allCategories ?? []);
|
||||
const postsByCategory = task.postsByCategoryEntries
|
||||
? deserializePostMap(task.postsByCategoryEntries)
|
||||
: undefined;
|
||||
|
||||
pagesGenerated += await generateCategoryPages({
|
||||
projectId,
|
||||
posts,
|
||||
allCategories,
|
||||
maxPostsPerPage: task.maxPostsPerPage,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
postsByCategory,
|
||||
});
|
||||
break;
|
||||
}
|
||||
pagesGenerated += await generateCategoryPages({
|
||||
projectId,
|
||||
posts,
|
||||
allCategories,
|
||||
maxPostsPerPage: task.maxPostsPerPage,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
postsByCategory,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tag': {
|
||||
const allTags = new Set(task.allTags ?? []);
|
||||
const postsByTag = task.postsByTagEntries
|
||||
? deserializePostMap(task.postsByTagEntries)
|
||||
: undefined;
|
||||
case 'tag': {
|
||||
const allTags = new Set(task.allTags ?? []);
|
||||
const postsByTag = task.postsByTagEntries
|
||||
? deserializePostMap(task.postsByTagEntries)
|
||||
: undefined;
|
||||
|
||||
pagesGenerated += await generateTagPages({
|
||||
projectId,
|
||||
posts,
|
||||
allTags,
|
||||
maxPostsPerPage: task.maxPostsPerPage,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
postsByTag,
|
||||
});
|
||||
break;
|
||||
}
|
||||
pagesGenerated += await generateTagPages({
|
||||
projectId,
|
||||
posts,
|
||||
allTags,
|
||||
maxPostsPerPage: task.maxPostsPerPage,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
postsByTag,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'date': {
|
||||
const yearsMap = task.yearsEntries ? deserializeDateMap(task.yearsEntries) : new Map();
|
||||
const yearMonthsMap = task.yearMonthsEntries ? deserializeDateMap(task.yearMonthsEntries) : new Map();
|
||||
const yearMonthDaysMap = task.yearMonthDaysEntries ? deserializeDateMap(task.yearMonthDaysEntries) : new Map();
|
||||
const postsByYear = task.postsByYearEntries ? deserializePostMap(task.postsByYearEntries) : undefined;
|
||||
const postsByYearMonth = task.postsByYearMonthEntries ? deserializePostMap(task.postsByYearMonthEntries) : undefined;
|
||||
const postsByYearMonthDay = task.postsByYearMonthDayEntries ? deserializePostMap(task.postsByYearMonthDayEntries) : undefined;
|
||||
case 'date': {
|
||||
const yearsMap = task.yearsEntries
|
||||
? deserializeDateMap(task.yearsEntries)
|
||||
: new Map();
|
||||
const yearMonthsMap = task.yearMonthsEntries
|
||||
? deserializeDateMap(task.yearMonthsEntries)
|
||||
: new Map();
|
||||
const yearMonthDaysMap = task.yearMonthDaysEntries
|
||||
? deserializeDateMap(task.yearMonthDaysEntries)
|
||||
: new Map();
|
||||
const postsByYear = task.postsByYearEntries
|
||||
? deserializePostMap(task.postsByYearEntries)
|
||||
: undefined;
|
||||
const postsByYearMonth = task.postsByYearMonthEntries
|
||||
? deserializePostMap(task.postsByYearMonthEntries)
|
||||
: undefined;
|
||||
const postsByYearMonthDay = task.postsByYearMonthDayEntries
|
||||
? deserializePostMap(task.postsByYearMonthDayEntries)
|
||||
: undefined;
|
||||
|
||||
pagesGenerated += await generateDateArchivePages({
|
||||
projectId,
|
||||
posts,
|
||||
yearsMap,
|
||||
yearMonthsMap,
|
||||
yearMonthDaysMap,
|
||||
maxPostsPerPage: task.maxPostsPerPage,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
postsByYear,
|
||||
postsByYearMonth,
|
||||
postsByYearMonthDay,
|
||||
});
|
||||
break;
|
||||
}
|
||||
pagesGenerated += await generateDateArchivePages({
|
||||
projectId,
|
||||
posts,
|
||||
yearsMap,
|
||||
yearMonthsMap,
|
||||
yearMonthDaysMap,
|
||||
maxPostsPerPage: task.maxPostsPerPage,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
postsByYear,
|
||||
postsByYearMonth,
|
||||
postsByYearMonthDay,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'core': {
|
||||
// Core includes root pages and page routes (sitemap/feeds handled by main thread)
|
||||
pagesGenerated += await generateRootPages({
|
||||
projectId,
|
||||
posts,
|
||||
maxPostsPerPage: task.maxPostsPerPage,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
});
|
||||
pagesGenerated += await generatePageRoutes({
|
||||
projectId,
|
||||
posts: lookupPosts,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'core': {
|
||||
// Core includes root pages and page routes (sitemap/feeds handled by main thread)
|
||||
pagesGenerated += await generateRootPages({
|
||||
projectId,
|
||||
posts,
|
||||
maxPostsPerPage: task.maxPostsPerPage,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
});
|
||||
pagesGenerated += await generatePageRoutes({
|
||||
projectId,
|
||||
posts: lookupPosts,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Report result with accumulated hash updates
|
||||
|
||||
@@ -12,7 +12,9 @@ export function setEngineBundle(bundle: EngineBundle): void {
|
||||
|
||||
function requireBundle(): EngineBundle {
|
||||
if (!registeredBundle) {
|
||||
throw new Error('Engine bundle not registered. Call setEngineBundle() before invoking Python API methods.');
|
||||
throw new Error(
|
||||
'Engine bundle not registered. Call setEngineBundle() before invoking Python API methods.',
|
||||
);
|
||||
}
|
||||
return registeredBundle;
|
||||
}
|
||||
@@ -24,7 +26,11 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function validateParamValue(methodName: string, param: PythonApiParamContractV1, value: unknown): void {
|
||||
function validateParamValue(
|
||||
methodName: string,
|
||||
param: PythonApiParamContractV1,
|
||||
value: unknown,
|
||||
): void {
|
||||
if (param.type === 'stringOrNull') {
|
||||
if (value === null || (typeof value === 'string' && value.length > 0)) {
|
||||
return;
|
||||
@@ -79,20 +85,20 @@ function validateParamValue(methodName: string, param: PythonApiParamContractV1,
|
||||
}
|
||||
}
|
||||
|
||||
type EngineGetter = () => Record<string, (...args: unknown[]) => unknown>;
|
||||
type EngineGetter = () => unknown;
|
||||
|
||||
export const ENGINE_MAP: Record<string, EngineGetter> = {
|
||||
posts: () => requireBundle().postEngine as any,
|
||||
media: () => requireBundle().mediaEngine as any,
|
||||
projects: () => requireBundle().projectEngine as any,
|
||||
meta: () => requireBundle().metaEngine as any,
|
||||
tags: () => requireBundle().tagEngine as any,
|
||||
scripts: () => requireBundle().scriptEngine as any,
|
||||
templates: () => requireBundle().templateEngine as any,
|
||||
tasks: () => requireBundle().taskManager as any,
|
||||
sync: () => requireBundle().gitApiAdapter as any,
|
||||
publish: () => requireBundle().publishApiAdapter as any,
|
||||
app: () => requireBundle().appApiAdapter as any,
|
||||
posts: () => requireBundle().postEngine,
|
||||
media: () => requireBundle().mediaEngine,
|
||||
projects: () => requireBundle().projectEngine,
|
||||
meta: () => requireBundle().metaEngine,
|
||||
tags: () => requireBundle().tagEngine,
|
||||
scripts: () => requireBundle().scriptEngine,
|
||||
templates: () => requireBundle().templateEngine,
|
||||
tasks: () => requireBundle().taskManager,
|
||||
sync: () => requireBundle().gitApiAdapter,
|
||||
publish: () => requireBundle().publishApiAdapter,
|
||||
app: () => requireBundle().appApiAdapter,
|
||||
};
|
||||
|
||||
// Map API method names to engine method names where they differ
|
||||
@@ -195,7 +201,10 @@ const METHOD_NAME_MAP: Record<string, string> = {
|
||||
'tasks.clearCompleted': 'clearCompletedTasks',
|
||||
};
|
||||
|
||||
export async function invokeMainProcessPythonApi(method: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
export async function invokeMainProcessPythonApi(
|
||||
method: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const contract = getPythonApiMethodContract(method);
|
||||
if (!contract) {
|
||||
throw new Error(`Unsupported Python API method: ${method}`);
|
||||
@@ -210,14 +219,24 @@ export async function invokeMainProcessPythonApi(method: string, args: Record<st
|
||||
|
||||
// Skip methods that require UI/dialog interaction or are not safe for background use
|
||||
const unsafeMethods = new Set([
|
||||
'media.importDialog', 'media.replaceFileDialog', 'media.getFilePath',
|
||||
'app.openFolder', 'app.selectFolder', 'app.showItemInFolder',
|
||||
'app.getTitleBarMetrics', 'app.notifyRendererReady', 'app.triggerMenuAction',
|
||||
'app.getBlogmarkBookmarklet', 'app.copyToClipboard', 'app.setPreviewPostTarget',
|
||||
'media.importDialog',
|
||||
'media.replaceFileDialog',
|
||||
'media.getFilePath',
|
||||
'app.openFolder',
|
||||
'app.selectFolder',
|
||||
'app.showItemInFolder',
|
||||
'app.getTitleBarMetrics',
|
||||
'app.notifyRendererReady',
|
||||
'app.triggerMenuAction',
|
||||
'app.getBlogmarkBookmarklet',
|
||||
'app.copyToClipboard',
|
||||
'app.setPreviewPostTarget',
|
||||
]);
|
||||
|
||||
if (unsafeMethods.has(method)) {
|
||||
throw new Error(`Python API method '${method}' is not available in main-process macro context`);
|
||||
throw new Error(
|
||||
`Python API method '${method}' is not available in main-process macro context`,
|
||||
);
|
||||
}
|
||||
|
||||
const engineGetter = ENGINE_MAP[namespace];
|
||||
@@ -227,10 +246,12 @@ export async function invokeMainProcessPythonApi(method: string, args: Record<st
|
||||
|
||||
const engine = engineGetter();
|
||||
const engineMethodName = METHOD_NAME_MAP[method] ?? member;
|
||||
const callable = engine[engineMethodName];
|
||||
const callable = (engine as Record<string, unknown>)[engineMethodName];
|
||||
|
||||
if (typeof callable !== 'function') {
|
||||
throw new Error(`Unsupported Python API method: ${method} (engine method '${engineMethodName}' not found)`);
|
||||
throw new Error(
|
||||
`Unsupported Python API method: ${method} (engine method '${engineMethodName}' not found)`,
|
||||
);
|
||||
}
|
||||
|
||||
const orderedArgs = contract.params.map((param) => {
|
||||
|
||||
@@ -69,16 +69,20 @@ let _appBundle: string | null = null;
|
||||
* Result is cached after the first call.
|
||||
*/
|
||||
function getAppBundle(): string {
|
||||
if (_appBundle !== null) return _appBundle;
|
||||
if (_appBundle !== null) {
|
||||
return _appBundle;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
|
||||
const bundlePath: string = require.resolve('@modelcontextprotocol/ext-apps/app-with-deps');
|
||||
let source = fs.readFileSync(bundlePath, 'utf-8');
|
||||
|
||||
// The bundle ends with export{...,X as App,...}.
|
||||
// Extract the internal variable name for `App`.
|
||||
const match = source.match(/export\{[^}]*\b(\w+)\s+as\s+App\b[^}]*\}/);
|
||||
if (!match) throw new Error('Could not find App export in app-with-deps bundle');
|
||||
if (!match) {
|
||||
throw new Error('Could not find App export in app-with-deps bundle');
|
||||
}
|
||||
const internalName = match[1];
|
||||
|
||||
// Strip ESM export block and expose App class on globalThis.
|
||||
@@ -93,7 +97,9 @@ function getAppBundle(): string {
|
||||
|
||||
const SHARED_JS = `\
|
||||
const App = globalThis.__bdsExtApp;
|
||||
if (!App) { document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded"); }
|
||||
if (!App) {
|
||||
document.getElementById("status").textContent = "Error: App bundle not loaded"; document.getElementById("status").className = "status status-error"; document.getElementById("status").style.display = "block"; throw new Error("App bundle not loaded");
|
||||
}
|
||||
|
||||
const app = new App({ name: "bDS Review", version: "1.0.0" });
|
||||
|
||||
@@ -159,7 +165,9 @@ const SHARED_JS = `\
|
||||
}
|
||||
|
||||
window.showStatus = showStatus;
|
||||
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||||
function esc(s) {
|
||||
const d = document.createElement("div"); d.textContent = s; return d.innerHTML;
|
||||
}
|
||||
|
||||
window.__connectApp = () => {
|
||||
app.connect()
|
||||
|
||||
@@ -102,7 +102,7 @@ export function reviewMetadataHtml(): string {
|
||||
.diff-table th { background: #f1f3f5; font-weight: 600; }
|
||||
.diff-old { background: #ffeef0; }
|
||||
.diff-new { background: #e6ffed; }`,
|
||||
extraJsHelpers: `function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }`,
|
||||
extraJsHelpers: 'function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }',
|
||||
renderBody: `\
|
||||
const current = data.current || {};
|
||||
const proposed = data.proposed || {};
|
||||
|
||||
@@ -56,7 +56,7 @@ type WorkerResponseMessage = WorkerReadyMessage | WorkerMacroResultMessage | Wor
|
||||
type PyodideRuntime = {
|
||||
globals: {
|
||||
set: (name: string, value: unknown) => void;
|
||||
} | any;
|
||||
};
|
||||
runPythonAsync: (code: string) => Promise<unknown>;
|
||||
registerJsModule: (name: string, module: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Portuguese, Dutch, Russian, Arabic, and more.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
|
||||
const snowballFactory = require('snowball-stemmers');
|
||||
|
||||
export type SupportedLanguage =
|
||||
@@ -139,7 +139,9 @@ export function stemWord(word: string, language: SupportedLanguage = 'english'):
|
||||
* stemText('Häuser Haus', 'german') // 'haus haus'
|
||||
*/
|
||||
export function stemText(text: string, language: SupportedLanguage = 'english'): string {
|
||||
if (!text) return '';
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const words = tokenize(text);
|
||||
const stemmer = getStemmer(language);
|
||||
@@ -166,7 +168,9 @@ export function stemText(text: string, language: SupportedLanguage = 'english'):
|
||||
* stemQuery('"running fast"', 'english') // '"run fast"'
|
||||
*/
|
||||
export function stemQuery(query: string, language: SupportedLanguage = 'english'): string {
|
||||
if (!query) return '';
|
||||
if (!query) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const stemmer = getStemmer(language);
|
||||
|
||||
@@ -203,7 +207,7 @@ export function stemQuery(query: string, language: SupportedLanguage = 'english'
|
||||
return stemmer.stem(words[0].toLowerCase());
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Clean up multiple spaces
|
||||
|
||||
@@ -8,7 +8,7 @@ export function normalizeNonEmptyTaxonomyTerm(term: string): string | null {
|
||||
}
|
||||
|
||||
export function collectNormalizedTermsFromJsonValues(
|
||||
values: Array<string | null | undefined>
|
||||
values: Array<string | null | undefined>,
|
||||
): string[] {
|
||||
const terms = new Set<string>();
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as path from 'path';
|
||||
import { dialog } from 'electron';
|
||||
import type { IpcMainInvokeEvent } from 'electron';
|
||||
import {
|
||||
resolvePublicBaseUrl,
|
||||
type BlogGenerationResult,
|
||||
type BlogGenerationSection,
|
||||
type BlogGenerationOptions,
|
||||
type SiteValidationReport,
|
||||
type ApplyValidationPreparation,
|
||||
} from '../engine/BlogGenerationEngine';
|
||||
import { resolvePageTitle } from '../engine/PageRenderer';
|
||||
import { buildSearchIndex } from '../engine/SearchIndexEngine';
|
||||
@@ -16,7 +16,7 @@ import { autoTranslatePost, autoTranslateMediaMetadata } from './chatHandlers';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDatabase } from '../database/connection';
|
||||
|
||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||
type SafeHandle = <Args extends unknown[], Result>(channel: string, handler: (event: IpcMainInvokeEvent, ...args: Args) => Promise<Result>) => void;
|
||||
|
||||
export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void {
|
||||
const resolveActiveProjectContext = async (): Promise<{
|
||||
@@ -85,8 +85,8 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
|
||||
blogLanguages: Array.isArray(metadata?.blogLanguages) ? metadata.blogLanguages : [],
|
||||
pageTitle,
|
||||
picoTheme: metadata?.picoTheme,
|
||||
categoryMetadata: (metadata as any)?.categoryMetadata,
|
||||
categorySettings: (metadata as any)?.categorySettings,
|
||||
categoryMetadata: metadata?.categoryMetadata,
|
||||
categorySettings: metadata?.categorySettings,
|
||||
menu,
|
||||
dbPath: getDatabase().getDbPath(),
|
||||
};
|
||||
@@ -321,8 +321,12 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
|
||||
}
|
||||
|
||||
const parts: string[] = ['Done'];
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
if (warned > 0) parts.push(`${warned} warnings`);
|
||||
if (failed > 0) {
|
||||
parts.push(`${failed} failed`);
|
||||
}
|
||||
if (warned > 0) {
|
||||
parts.push(`${warned} warnings`);
|
||||
}
|
||||
onProgress(100, parts.length > 1 ? `${parts[0]} (${parts.slice(1).join(', ')})` : parts[0]);
|
||||
},
|
||||
}).catch(() => { /* errors tracked via task panel */ });
|
||||
|
||||
@@ -24,6 +24,13 @@ let initPromise: Promise<void> | null = null;
|
||||
let mainWindowGetter: (() => BrowserWindow | null) | null = null;
|
||||
let engineBundle: EngineBundle | null = null;
|
||||
|
||||
function requireEngineBundle(): EngineBundle {
|
||||
if (!engineBundle) {
|
||||
throw new Error('Chat handlers not initialized');
|
||||
}
|
||||
return engineBundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the SecureKeyStore instance.
|
||||
*/
|
||||
@@ -77,10 +84,11 @@ function getChatService(): ChatService {
|
||||
if (!chatService) {
|
||||
const engine = getChatEngine();
|
||||
const reg = getProviders();
|
||||
const bundle = requireEngineBundle();
|
||||
const deps: BlogToolDeps = {
|
||||
postEngine: engineBundle!.postEngine,
|
||||
mediaEngine: engineBundle!.mediaEngine,
|
||||
postMediaEngine: engineBundle!.postMediaEngine,
|
||||
postEngine: bundle.postEngine,
|
||||
mediaEngine: bundle.mediaEngine,
|
||||
postMediaEngine: bundle.postMediaEngine,
|
||||
};
|
||||
chatService = new ChatService(engine, reg, deps, () => mainWindowGetter?.() || null);
|
||||
}
|
||||
@@ -92,7 +100,8 @@ function getChatService(): ChatService {
|
||||
*/
|
||||
function getOneShotTasks(): OneShotTasks {
|
||||
if (!oneShotTasks) {
|
||||
oneShotTasks = new OneShotTasks(getProviders(), getChatEngine(), engineBundle!.mediaEngine, engineBundle!.postEngine);
|
||||
const bundle = requireEngineBundle();
|
||||
oneShotTasks = new OneShotTasks(getProviders(), getChatEngine(), bundle.mediaEngine, bundle.postEngine);
|
||||
}
|
||||
return oneShotTasks;
|
||||
}
|
||||
@@ -111,18 +120,24 @@ async function ensureInitialized(): Promise<void> {
|
||||
|
||||
try {
|
||||
const key = await keyStore.retrieve('opencode_api_key');
|
||||
if (key) reg.setOpencodeKey(key);
|
||||
if (key) {
|
||||
reg.setOpencodeKey(key);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
const mistralKey = await keyStore.retrieve('mistral_api_key');
|
||||
if (mistralKey) reg.setMistralKey(mistralKey);
|
||||
if (mistralKey) {
|
||||
reg.setMistralKey(mistralKey);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Restore Ollama enabled state from settings DB
|
||||
try {
|
||||
const ollamaEnabled = await getChatEngine().getSetting('ollama_enabled');
|
||||
if (ollamaEnabled === 'true') reg.setOllamaEnabled(true);
|
||||
if (ollamaEnabled === 'true') {
|
||||
reg.setOllamaEnabled(true);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Restore Ollama model capability overrides
|
||||
@@ -138,14 +153,18 @@ async function ensureInitialized(): Promise<void> {
|
||||
try {
|
||||
const ollamaIds = await getChatEngine().getSetting('ollama_known_model_ids');
|
||||
if (ollamaIds) {
|
||||
for (const id of JSON.parse(ollamaIds) as string[]) reg.registerOllamaModel(id);
|
||||
for (const id of JSON.parse(ollamaIds) as string[]) {
|
||||
reg.registerOllamaModel(id);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Restore LM Studio enabled state from settings DB
|
||||
try {
|
||||
const lmstudioEnabled = await getChatEngine().getSetting('lmstudio_enabled');
|
||||
if (lmstudioEnabled === 'true') reg.setLmstudioEnabled(true);
|
||||
if (lmstudioEnabled === 'true') {
|
||||
reg.setLmstudioEnabled(true);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Restore LM Studio model capability overrides
|
||||
@@ -161,7 +180,9 @@ async function ensureInitialized(): Promise<void> {
|
||||
try {
|
||||
const lmIds = await getChatEngine().getSetting('lmstudio_known_model_ids');
|
||||
if (lmIds) {
|
||||
for (const id of JSON.parse(lmIds) as string[]) reg.registerLmstudioModel(id);
|
||||
for (const id of JSON.parse(lmIds) as string[]) {
|
||||
reg.registerLmstudioModel(id);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@@ -248,7 +269,9 @@ export function registerChatHandlers(): void {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
const key = getProviders().getOpencodeKey();
|
||||
if (!key) return { hasKey: false, maskedKey: '' };
|
||||
if (!key) {
|
||||
return { hasKey: false, maskedKey: '' };
|
||||
}
|
||||
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
|
||||
return { hasKey: true, maskedKey: masked };
|
||||
} catch (error) {
|
||||
@@ -298,7 +321,9 @@ export function registerChatHandlers(): void {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
const key = getProviders().getMistralKey();
|
||||
if (!key) return { hasKey: false, maskedKey: '' };
|
||||
if (!key) {
|
||||
return { hasKey: false, maskedKey: '' };
|
||||
}
|
||||
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
|
||||
return { hasKey: true, maskedKey: masked };
|
||||
} catch (error) {
|
||||
@@ -753,8 +778,9 @@ export function registerChatHandlers(): void {
|
||||
// ============ Chat Messaging ============
|
||||
|
||||
// Send a message
|
||||
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string, _metadata?: { surface?: 'tab' | 'sidebar' }) => {
|
||||
ipcMain.handle('chat:sendMessage', async (_, conversationId: string, message: string, metadata?: { surface?: 'tab' | 'sidebar' }) => {
|
||||
try {
|
||||
void metadata;
|
||||
await ensureInitialized();
|
||||
const service = getChatService();
|
||||
const mainWindow = mainWindowGetter?.();
|
||||
@@ -949,7 +975,7 @@ export function registerChatHandlers(): void {
|
||||
|
||||
ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record<string, unknown> }) => {
|
||||
try {
|
||||
console.log('[Chat IPC] A2UI action dispatched:', action);
|
||||
void action;
|
||||
// Currently, A2UI actions are handled client-side (navigation, UI toggles).
|
||||
// Server-side action handling can be added here in the future.
|
||||
return { success: true };
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { EngineBundle } from '../engine/EngineBundle';
|
||||
import type { IpcMainInvokeEvent } from 'electron';
|
||||
import { startDuplicateSearchTask } from './handlers';
|
||||
import { resolveUiLanguageFromSystemLocale, translateMenu } from '../shared/i18n';
|
||||
import { app } from 'electron';
|
||||
|
||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||
type SafeHandle = <Args extends unknown[], Result>(channel: string, handler: (event: IpcMainInvokeEvent, ...args: Args) => Promise<Result>) => void;
|
||||
|
||||
function tr(key: string): string {
|
||||
const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, clipboard } from 'electron';
|
||||
import type { IpcMainInvokeEvent, WebContents } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@@ -9,7 +10,6 @@ import { MetaEngine } from '../engine/MetaEngine';
|
||||
import type { MenuDocument } from '../engine/MenuEngine';
|
||||
import type { CreateScriptInput, UpdateScriptInput } from '../engine/ScriptEngine';
|
||||
import type { CreateTemplateInput, UpdateTemplateInput } from '../engine/TemplateEngine';
|
||||
import type { TaskProgress } from '../engine/TaskManager';
|
||||
import { getDatabase } from '../database';
|
||||
import { media } from '../database/schema';
|
||||
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuAction } from '../shared/menuCommands';
|
||||
@@ -31,12 +31,16 @@ const SUPPORTED_DROP_IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'ima
|
||||
* Wrap an IPC handler so that "Database is closing" errors during shutdown
|
||||
* are silently swallowed instead of being logged as scary red error messages.
|
||||
*/
|
||||
function safeHandle(channel: string, handler: (...args: any[]) => Promise<any>): void {
|
||||
ipcMain.handle(channel, async (...args) => {
|
||||
function isDatabaseClosingError(error: unknown): error is { message: string } {
|
||||
return typeof error === 'object' && error !== null && 'message' in error && (error as { message: unknown }).message === 'Database is closing';
|
||||
}
|
||||
|
||||
function safeHandle<Args extends unknown[], Result>(channel: string, handler: (event: IpcMainInvokeEvent, ...args: Args) => Promise<Result>): void {
|
||||
ipcMain.handle(channel, async (event, ...args) => {
|
||||
try {
|
||||
return await handler(...args);
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Database is closing') {
|
||||
return await handler(event, ...(args as Args));
|
||||
} catch (error) {
|
||||
if (isDatabaseClosingError(error)) {
|
||||
return null; // Silently ignore during shutdown
|
||||
}
|
||||
throw error; // Re-throw all other errors
|
||||
@@ -60,7 +64,7 @@ function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}
|
||||
|
||||
function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
|
||||
function runWebContentsMenuAction(sender: WebContents | undefined, action: AppMenuAction): boolean {
|
||||
if (!sender) {
|
||||
return false;
|
||||
}
|
||||
@@ -70,63 +74,63 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'undo':
|
||||
sender.undo?.();
|
||||
return true;
|
||||
case 'redo':
|
||||
sender.redo?.();
|
||||
return true;
|
||||
case 'cut':
|
||||
sender.cut?.();
|
||||
return true;
|
||||
case 'copy':
|
||||
sender.copy?.();
|
||||
return true;
|
||||
case 'paste':
|
||||
sender.paste?.();
|
||||
return true;
|
||||
case 'delete':
|
||||
sender.delete?.();
|
||||
return true;
|
||||
case 'selectAll':
|
||||
sender.selectAll?.();
|
||||
return true;
|
||||
case 'toggleDevTools':
|
||||
if (sender.isDevToolsOpened?.()) {
|
||||
sender.closeDevTools?.();
|
||||
} else {
|
||||
sender.openDevTools?.({ mode: 'detach' });
|
||||
}
|
||||
return true;
|
||||
case 'reload':
|
||||
sender.reload?.();
|
||||
return true;
|
||||
case 'forceReload':
|
||||
sender.reloadIgnoringCache?.();
|
||||
return true;
|
||||
case 'resetZoom':
|
||||
sender.setZoomLevel?.(0);
|
||||
return true;
|
||||
case 'zoomIn': {
|
||||
const currentZoomLevel = sender.getZoomLevel?.() ?? 0;
|
||||
sender.setZoomLevel?.(currentZoomLevel + 0.5);
|
||||
return true;
|
||||
case 'undo':
|
||||
sender.undo?.();
|
||||
return true;
|
||||
case 'redo':
|
||||
sender.redo?.();
|
||||
return true;
|
||||
case 'cut':
|
||||
sender.cut?.();
|
||||
return true;
|
||||
case 'copy':
|
||||
sender.copy?.();
|
||||
return true;
|
||||
case 'paste':
|
||||
sender.paste?.();
|
||||
return true;
|
||||
case 'delete':
|
||||
sender.delete?.();
|
||||
return true;
|
||||
case 'selectAll':
|
||||
sender.selectAll?.();
|
||||
return true;
|
||||
case 'toggleDevTools':
|
||||
if (sender.isDevToolsOpened?.()) {
|
||||
sender.closeDevTools?.();
|
||||
} else {
|
||||
sender.openDevTools?.({ mode: 'detach' });
|
||||
}
|
||||
case 'zoomOut': {
|
||||
const currentZoomLevel = sender.getZoomLevel?.() ?? 0;
|
||||
sender.setZoomLevel?.(currentZoomLevel - 0.5);
|
||||
return true;
|
||||
}
|
||||
case 'toggleFullScreen': {
|
||||
const ownerWindow = BrowserWindow.fromWebContents(sender);
|
||||
if (!ownerWindow) {
|
||||
return false;
|
||||
}
|
||||
ownerWindow.setFullScreen(!ownerWindow.isFullScreen());
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
case 'reload':
|
||||
sender.reload?.();
|
||||
return true;
|
||||
case 'forceReload':
|
||||
sender.reloadIgnoringCache?.();
|
||||
return true;
|
||||
case 'resetZoom':
|
||||
sender.setZoomLevel?.(0);
|
||||
return true;
|
||||
case 'zoomIn': {
|
||||
const currentZoomLevel = sender.getZoomLevel?.() ?? 0;
|
||||
sender.setZoomLevel?.(currentZoomLevel + 0.5);
|
||||
return true;
|
||||
}
|
||||
case 'zoomOut': {
|
||||
const currentZoomLevel = sender.getZoomLevel?.() ?? 0;
|
||||
sender.setZoomLevel?.(currentZoomLevel - 0.5);
|
||||
return true;
|
||||
}
|
||||
case 'toggleFullScreen': {
|
||||
const ownerWindow = BrowserWindow.fromWebContents(sender);
|
||||
if (!ownerWindow) {
|
||||
return false;
|
||||
}
|
||||
ownerWindow.setFullScreen(!ownerWindow.isFullScreen());
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +216,7 @@ async function handleDroppedImageImport(
|
||||
};
|
||||
}
|
||||
|
||||
function buildMcpAgentConfigOptions(bundle: EngineBundle): import('../engine/MCPAgentConfigEngine').MCPAgentConfigOptions {
|
||||
function buildMcpAgentConfigOptions(): import('../engine/MCPAgentConfigEngine').MCPAgentConfigOptions {
|
||||
const os = require('os') as typeof import('os');
|
||||
const scriptPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bds-mcp.cjs')
|
||||
@@ -575,19 +579,27 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
// Auto-translate: enqueue translation tasks for each blog language that does
|
||||
// not yet have a translation. Only triggered on manual save or publish.
|
||||
const enqueueAutoTranslations = async (post: PostData): Promise<void> => {
|
||||
if (post.doNotTranslate) return;
|
||||
if (post.doNotTranslate) {
|
||||
return;
|
||||
}
|
||||
const metadata = await bundle.metaEngine.getProjectMetadata();
|
||||
if (!metadata) return;
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
const blogLanguages = metadata.blogLanguages || [];
|
||||
const mainLang = metadata.mainLanguage || 'en';
|
||||
const postLang = post.language || mainLang;
|
||||
const targetLanguages = blogLanguages.filter((lang) => lang !== postLang);
|
||||
if (targetLanguages.length === 0) return;
|
||||
if (targetLanguages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTranslations = await bundle.postEngine.getPostTranslations(post.id);
|
||||
const existingLangs = new Set(existingTranslations.map((t) => t.language));
|
||||
const missingLanguages = targetLanguages.filter((lang) => !existingLangs.has(lang));
|
||||
if (missingLanguages.length === 0) return;
|
||||
if (missingLanguages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId = uuidv4();
|
||||
for (const targetLang of missingLanguages) {
|
||||
@@ -602,7 +614,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || `Translation to ${targetLang} failed`);
|
||||
}
|
||||
onProgress(70, `Translating linked media...`);
|
||||
onProgress(70, 'Translating linked media...');
|
||||
// Cascade: translate linked media metadata
|
||||
const links = await bundle.postMediaEngine.getLinkedMediaForPost(post.id);
|
||||
for (const link of links) {
|
||||
@@ -1351,7 +1363,10 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const handledByWebContents = runWebContentsMenuAction((event as any)?.sender, typedAction);
|
||||
const sender = event && typeof event === 'object' && 'sender' in event
|
||||
? (event as IpcMainInvokeEvent).sender
|
||||
: undefined;
|
||||
const handledByWebContents = runWebContentsMenuAction(sender, typedAction);
|
||||
if (handledByWebContents) {
|
||||
return;
|
||||
}
|
||||
@@ -1742,7 +1757,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
current: number,
|
||||
total: number,
|
||||
detail?: string,
|
||||
eta?: number
|
||||
eta?: number,
|
||||
) => {
|
||||
ipcMain.emit('forward-to-renderer', 'import:executionProgress', {
|
||||
taskId,
|
||||
@@ -1776,7 +1791,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
// Create a task for the import
|
||||
const taskId = `import-${Date.now()}`;
|
||||
let processedItems = 0;
|
||||
let startTime = Date.now();
|
||||
const startTime = Date.now();
|
||||
|
||||
const task = {
|
||||
id: taskId,
|
||||
@@ -1845,7 +1860,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
};
|
||||
|
||||
// Run the task - this returns immediately with a promise
|
||||
const resultPromise = bundle.taskManager.runTask(task);
|
||||
void bundle.taskManager.runTask(task);
|
||||
|
||||
// Return task ID so UI can track it
|
||||
return { taskId, totalItems };
|
||||
@@ -1886,7 +1901,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
return engine.getAllForProject();
|
||||
});
|
||||
|
||||
safeHandle('importDefinitions:update', async (event, id: string, updates: any) => {
|
||||
safeHandle('importDefinitions:update', async (event, id: string, updates: Record<string, unknown>) => {
|
||||
const { ImportDefinitionEngine } = await import('../engine/ImportDefinitionEngine');
|
||||
const engine = new ImportDefinitionEngine();
|
||||
const projectEngine = bundle.projectEngine;
|
||||
@@ -1896,8 +1911,8 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
}
|
||||
const result = await engine.updateDefinition(id, updates);
|
||||
// Notify renderer of name changes for sidebar/tab updates
|
||||
if (result && updates.name !== undefined) {
|
||||
event.sender.send('importDefinition-name-updated', { definitionId: id, name: result.name });
|
||||
if (result && updates.name !== undefined && event && typeof event === 'object' && 'sender' in event) {
|
||||
(event as IpcMainInvokeEvent).sender.send('importDefinition-name-updated', { definitionId: id, name: result.name });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
@@ -1922,25 +1937,25 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
|
||||
safeHandle('mcp:getAgents', async () => {
|
||||
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
|
||||
const engine = new MCPAgentConfigEngine(buildMcpAgentConfigOptions(bundle));
|
||||
const engine = new MCPAgentConfigEngine(buildMcpAgentConfigOptions());
|
||||
return engine.getAgents();
|
||||
});
|
||||
|
||||
safeHandle('mcp:addToAgentConfig', async (_event: unknown, agentId: string) => {
|
||||
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
|
||||
const engine = new MCPAgentConfigEngine(buildMcpAgentConfigOptions(bundle));
|
||||
const engine = new MCPAgentConfigEngine(buildMcpAgentConfigOptions());
|
||||
return engine.addToConfig(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId);
|
||||
});
|
||||
|
||||
safeHandle('mcp:removeFromAgentConfig', async (_event: unknown, agentId: string) => {
|
||||
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
|
||||
const engine = new MCPAgentConfigEngine(buildMcpAgentConfigOptions(bundle));
|
||||
const engine = new MCPAgentConfigEngine(buildMcpAgentConfigOptions());
|
||||
return engine.removeFromConfig(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId);
|
||||
});
|
||||
|
||||
safeHandle('mcp:isConfigured', async (_event: unknown, agentId: string) => {
|
||||
const { MCPAgentConfigEngine } = await import('../engine/MCPAgentConfigEngine');
|
||||
const engine = new MCPAgentConfigEngine(buildMcpAgentConfigOptions(bundle));
|
||||
const engine = new MCPAgentConfigEngine(buildMcpAgentConfigOptions());
|
||||
return engine.isConfigured(agentId as import('../engine/MCPAgentConfigEngine').MCPAgentId);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { IpcMainInvokeEvent } from 'electron';
|
||||
import type { EngineBundle } from '../engine/EngineBundle';
|
||||
import type { MediaDiffField, ScriptDiffField, TemplateDiffField } from '../engine/MetadataDiffEngine';
|
||||
|
||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||
type SafeHandle = <Args extends unknown[], Result>(channel: string, handler: (event: IpcMainInvokeEvent, ...args: Args) => Promise<Result>) => void;
|
||||
|
||||
/** Helper: set project context on the MetadataDiffEngine from the active project */
|
||||
async function withProjectContext(bundle: EngineBundle): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { IpcMainInvokeEvent } from 'electron';
|
||||
import type { PublishCredentials } from '../engine/PublishEngine';
|
||||
import type { EngineBundle } from '../engine/EngineBundle';
|
||||
import { isOfflineModeActive } from './chatHandlers';
|
||||
|
||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||
type SafeHandle = <Args extends unknown[], Result>(channel: string, handler: (event: IpcMainInvokeEvent, ...args: Args) => Promise<Result>) => void;
|
||||
|
||||
export function registerPublishHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void {
|
||||
safeHandle('publish:uploadSite', async (_event: unknown, credentials: PublishCredentials) => {
|
||||
@@ -17,7 +18,11 @@ export function registerPublishHandlers(safeHandle: SafeHandle, bundle: EngineBu
|
||||
}
|
||||
|
||||
const publishEngine = bundle.publishEngine;
|
||||
publishEngine.setProjectContext(project.id, project.dataPath!);
|
||||
if (!project.dataPath) {
|
||||
throw new Error('Active project is missing dataPath');
|
||||
}
|
||||
|
||||
publishEngine.setProjectContext(project.id, project.dataPath);
|
||||
|
||||
const ts = Date.now();
|
||||
const groupId = `publish-${ts}`;
|
||||
|
||||
@@ -43,8 +43,15 @@ let activePreviewPostId: string | null = null;
|
||||
let appInitialized = false;
|
||||
let bundle: EngineBundle | null = null;
|
||||
|
||||
function requireBundle(): EngineBundle {
|
||||
if (!bundle) {
|
||||
throw new Error('Engine bundle not initialized');
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function buildPreviewServerDeps() {
|
||||
const b = bundle!;
|
||||
const b = requireBundle();
|
||||
return {
|
||||
postEngine: b.postEngine,
|
||||
mediaEngine: b.mediaEngine,
|
||||
@@ -53,13 +60,15 @@ function buildPreviewServerDeps() {
|
||||
menuEngine: b.menuEngine,
|
||||
getActiveProjectContext: async () => {
|
||||
const project = await b.projectEngine.getActiveProject();
|
||||
if (!project) throw new Error('No active project');
|
||||
if (!project) {
|
||||
throw new Error('No active project');
|
||||
}
|
||||
const dataDir = b.projectEngine.getDataDir(project.id, project.dataPath);
|
||||
return { projectId: project.id, dataDir, projectName: project.name };
|
||||
},
|
||||
};
|
||||
}
|
||||
let blogmarkQueue: string[] = [];
|
||||
const blogmarkQueue: string[] = [];
|
||||
let blogmarkQueueProcessing = false;
|
||||
let pendingBlogmarkCreatedEvents: unknown[] = [];
|
||||
let rendererReady = false;
|
||||
@@ -276,13 +285,13 @@ function createWindow(): void {
|
||||
...(isMac
|
||||
? {}
|
||||
: {
|
||||
titleBarOverlay: {
|
||||
color: '#252526',
|
||||
symbolColor: '#cccccc',
|
||||
height: 34,
|
||||
},
|
||||
autoHideMenuBar: false,
|
||||
}),
|
||||
titleBarOverlay: {
|
||||
color: '#252526',
|
||||
symbolColor: '#cccccc',
|
||||
height: 34,
|
||||
},
|
||||
autoHideMenuBar: false,
|
||||
}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
@@ -323,7 +332,7 @@ function createWindow(): void {
|
||||
// Forward events to renderer
|
||||
// Note: ipcMain.emit() (used by forwardEvent in handlers) is a raw EventEmitter emit,
|
||||
// so the first arg is NOT an IpcMainEvent — it's the event name string directly.
|
||||
ipcMain.on('forward-to-renderer', (eventNameOrEvent: any, ...args: unknown[]) => {
|
||||
ipcMain.on('forward-to-renderer', (eventNameOrEvent: unknown, ...args: unknown[]) => {
|
||||
// When called via ipcMain.emit(), first arg is the channel string directly
|
||||
const eventName: string = typeof eventNameOrEvent === 'string'
|
||||
? eventNameOrEvent
|
||||
@@ -373,7 +382,7 @@ async function openActivePostPreviewInBrowser(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const postEngine = bundle!.postEngine;
|
||||
const postEngine = requireBundle().postEngine;
|
||||
const post = await postEngine.getPost(activePreviewPostId);
|
||||
if (!post) {
|
||||
setPreviewPostMenuEnabled(false);
|
||||
@@ -427,10 +436,11 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = await bundle!.metaEngine.getProjectMetadata();
|
||||
const activeBundle = requireBundle();
|
||||
const metadata = await activeBundle.metaEngine.getProjectMetadata();
|
||||
const preferredCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory);
|
||||
|
||||
const transformService = bundle!.blogmarkTransformService;
|
||||
const transformService = activeBundle.blogmarkTransformService;
|
||||
const transformResult = await transformService.applyTransforms({
|
||||
post: {
|
||||
title: payload.title,
|
||||
@@ -444,7 +454,7 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
const createdPost = await bundle!.postEngine.createPost({
|
||||
const createdPost = await activeBundle.postEngine.createPost({
|
||||
title: transformResult.post.title,
|
||||
content: transformResult.post.content,
|
||||
tags: transformResult.post.tags,
|
||||
@@ -513,7 +523,8 @@ function registerBlogmarkProtocolClient(): void {
|
||||
|
||||
async function initializeActiveProjectContext(): Promise<void> {
|
||||
try {
|
||||
const projectEngine = bundle!.projectEngine;
|
||||
const activeBundle = requireBundle();
|
||||
const projectEngine = activeBundle.projectEngine;
|
||||
const project = await projectEngine.getActiveProject();
|
||||
|
||||
if (!project) {
|
||||
@@ -521,16 +532,16 @@ async function initializeActiveProjectContext(): Promise<void> {
|
||||
}
|
||||
|
||||
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||
const postEngine = bundle!.postEngine as {
|
||||
const postEngine = activeBundle.postEngine as {
|
||||
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
||||
setSearchLanguage?: (language: string) => void;
|
||||
setMainLanguage?: (language: string) => void;
|
||||
};
|
||||
const mediaEngine = bundle!.mediaEngine as {
|
||||
const mediaEngine = activeBundle.mediaEngine as {
|
||||
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
||||
setSearchLanguage?: (language: string) => void;
|
||||
};
|
||||
const metaEngine = bundle!.metaEngine as {
|
||||
const metaEngine = activeBundle.metaEngine as {
|
||||
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
||||
syncOnStartup?: () => Promise<void>;
|
||||
getProjectMetadata?: () => Promise<{ mainLanguage?: string } | null>;
|
||||
@@ -540,10 +551,10 @@ async function initializeActiveProjectContext(): Promise<void> {
|
||||
mediaEngine.setProjectContext?.(project.id, dataDir, dataDir);
|
||||
metaEngine.setProjectContext?.(project.id, dataDir);
|
||||
|
||||
const embeddingEngineInstance = bundle!.embeddingEngine;
|
||||
const embeddingEngineInstance = activeBundle.embeddingEngine;
|
||||
await embeddingEngineInstance.setProjectContext(project.id);
|
||||
|
||||
const templateEngine = bundle!.templateEngine as {
|
||||
const templateEngine = activeBundle.templateEngine as {
|
||||
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
||||
};
|
||||
templateEngine.setProjectContext?.(project.id, dataDir);
|
||||
@@ -654,7 +665,7 @@ function createApplicationMenu(): Menu {
|
||||
}
|
||||
|
||||
if (action === 'rebuildEmbeddingIndex') {
|
||||
startRebuildEmbeddingIndexTask(bundle!);
|
||||
startRebuildEmbeddingIndexTask(requireBundle());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -694,9 +705,15 @@ function createApplicationMenu(): Menu {
|
||||
await triggerMenuAction(action);
|
||||
},
|
||||
};
|
||||
if (definition.accelerator) item.accelerator = definition.accelerator;
|
||||
if (definition.id) item.id = definition.id;
|
||||
if (definition.enabled !== undefined) item.enabled = definition.enabled;
|
||||
if (definition.accelerator) {
|
||||
item.accelerator = definition.accelerator;
|
||||
}
|
||||
if (definition.id) {
|
||||
item.id = definition.id;
|
||||
}
|
||||
if (definition.enabled !== undefined) {
|
||||
item.enabled = definition.enabled;
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
@@ -762,10 +779,11 @@ function createApplicationMenu(): Menu {
|
||||
}
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
const activeBundle = requireBundle();
|
||||
// Register IPC handlers immediately (synchronous) so they are available
|
||||
// before any async work. This eliminates race conditions where the renderer
|
||||
// calls handlers before the database is ready.
|
||||
registerIpcHandlers(bundle!);
|
||||
registerIpcHandlers(activeBundle);
|
||||
|
||||
// Initialize database
|
||||
const db = getDatabase();
|
||||
@@ -773,7 +791,7 @@ async function initialize(): Promise<void> {
|
||||
|
||||
// Now that the database is ready, register event forwarding from engines
|
||||
// to the renderer (engines need DB access at registration time).
|
||||
registerEventForwarding(bundle!);
|
||||
registerEventForwarding(activeBundle);
|
||||
|
||||
// Register custom protocol for serving media files
|
||||
// URLs like bds-media://media-id will be resolved to the actual file
|
||||
@@ -835,7 +853,7 @@ async function initialize(): Promise<void> {
|
||||
const url = new URL(request.url);
|
||||
const mediaId = url.hostname;
|
||||
|
||||
const engine = bundle!.mediaEngine;
|
||||
const engine = requireBundle().mediaEngine;
|
||||
const thumbnails = await engine.getThumbnailPaths(mediaId);
|
||||
|
||||
if (thumbnails.small) {
|
||||
@@ -885,7 +903,7 @@ async function initialize(): Promise<void> {
|
||||
});
|
||||
|
||||
// Initialize and register chat handlers
|
||||
initializeChatHandlers(() => mainWindow, bundle!);
|
||||
initializeChatHandlers(() => mainWindow, activeBundle);
|
||||
registerChatHandlers();
|
||||
}
|
||||
|
||||
@@ -1004,8 +1022,7 @@ app.whenReady().then(async () => {
|
||||
const db = getDatabase();
|
||||
notificationWatcher = new NotificationWatcher(
|
||||
db.getDbPath(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
db.getLocal() as any,
|
||||
db.getLocal() as unknown as ConstructorParameters<typeof NotificationWatcher>[1],
|
||||
{
|
||||
post: bundle.postEngine,
|
||||
media: bundle.mediaEngine,
|
||||
|
||||
@@ -127,5 +127,5 @@ export function buildBlogmarkMarkdownLink(title: string, url: string): string {
|
||||
}
|
||||
|
||||
export function generateBlogmarkBookmarkletSource(): string {
|
||||
return "javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds://new-post?title='+t+'&url='+u;})();";
|
||||
return 'javascript:(()=>{const t=encodeURIComponent(document.title||\'\');const u=encodeURIComponent(location.href||\'\');location.href=\'bds://new-post?title=\'+t+\'&url=\'+u;})();';
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ function buildPythonMethod(method: {
|
||||
|
||||
function buildPythonNamespaceClass(
|
||||
namespace: string,
|
||||
methods: Array<{ method: string; description: string; params: Array<{ name: string; required: boolean }> }>
|
||||
methods: Array<{ method: string; description: string; params: Array<{ name: string; required: boolean }> }>,
|
||||
): string {
|
||||
const className = `${namespace[0].toUpperCase()}${namespace.slice(1)}Api`;
|
||||
const methodBlocks = methods.map((method) => buildPythonMethod(method)).join('');
|
||||
|
||||
@@ -57,7 +57,7 @@ function method(
|
||||
methodName: PythonPromiseMethodPath,
|
||||
description: string,
|
||||
params: PythonApiParamContractV1[],
|
||||
returns: string
|
||||
returns: string,
|
||||
): PythonApiMethodContractV1 {
|
||||
return {
|
||||
method: methodName,
|
||||
@@ -233,7 +233,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
{ name: 'sshHost', type: 'string', required: true, description: 'SSH hostname for publishing.' },
|
||||
{ name: 'sshUser', type: 'string', required: true, description: 'SSH username for publishing.' },
|
||||
{ name: 'sshRemotePath', type: 'string', required: true, description: 'Remote path on the server.' },
|
||||
{ name: 'sshMode', type: "'scp' | 'rsync'", required: true, description: 'Upload mode (scp or rsync).' },
|
||||
{ name: 'sshMode', type: '\'scp\' | \'rsync\'', required: true, description: 'Upload mode (scp or rsync).' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -260,7 +260,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
{ name: 'slug', type: 'string', required: true, description: 'URL slug used for generated routes.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, description: 'Optional short summary.' },
|
||||
{ name: 'content', type: 'string', required: true, description: 'Markdown body content.' },
|
||||
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' },
|
||||
{ name: 'status', type: '\'draft\' | \'published\' | \'archived\'', required: true, description: 'Publication lifecycle state.' },
|
||||
{ name: 'author', type: 'string', required: false, description: 'Optional author name.' },
|
||||
{ name: 'language', type: 'string', required: false, description: 'Optional per-post language code (e.g. en, de, fr, it, es).' },
|
||||
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
|
||||
@@ -300,7 +300,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
|
||||
{ name: 'slug', type: 'string', required: true, description: 'Stable script slug.' },
|
||||
{ name: 'title', type: 'string', required: true, description: 'Human-readable script title.' },
|
||||
{ name: 'kind', type: "'macro' | 'utility' | 'transform'", required: true, description: 'Script category.' },
|
||||
{ name: 'kind', type: '\'macro\' | \'utility\' | \'transform\'', required: true, description: 'Script category.' },
|
||||
{ name: 'entrypoint', type: 'string', required: true, description: 'Python entrypoint function name.' },
|
||||
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether script is enabled.' },
|
||||
{ name: 'version', type: 'number', required: true, description: 'Incrementing script version.' },
|
||||
@@ -318,7 +318,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
|
||||
{ name: 'slug', type: 'string', required: true, description: 'Stable template slug.' },
|
||||
{ name: 'title', type: 'string', required: true, description: 'Human-readable template title.' },
|
||||
{ name: 'kind', type: "'post' | 'list' | 'not-found' | 'partial'", required: true, description: 'Template category.' },
|
||||
{ name: 'kind', type: '\'post\' | \'list\' | \'not-found\' | \'partial\'', required: true, description: 'Template category.' },
|
||||
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether template is enabled.' },
|
||||
{ name: 'version', type: 'number', required: true, description: 'Incrementing template version.' },
|
||||
{ name: 'filePath', type: 'string', required: true, description: 'Filesystem path to template file.' },
|
||||
@@ -341,7 +341,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
fields: [
|
||||
{ name: 'taskId', type: 'string', required: true, description: 'Unique task identifier.' },
|
||||
{ name: 'name', type: 'string', required: true, description: 'Task display name.' },
|
||||
{ name: 'status', type: "'pending' | 'running' | 'completed' | 'failed' | 'cancelled'", required: true, description: 'Current task status.' },
|
||||
{ name: 'status', type: '\'pending\' | \'running\' | \'completed\' | \'failed\' | \'cancelled\'', required: true, description: 'Current task status.' },
|
||||
{ name: 'progress', type: 'number', required: true, description: 'Progress percentage from 0-100.' },
|
||||
{ name: 'message', type: 'string', required: true, description: 'Current progress message.' },
|
||||
{ name: 'startTime', type: 'string', required: true, description: 'Task start time (ISO string).' },
|
||||
@@ -363,7 +363,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
{ name: 'defaultAuthor', type: 'string', required: false, description: 'Default author for new posts.' },
|
||||
{ name: 'maxPostsPerPage', type: 'number', required: false, description: 'Pagination size for generated lists.' },
|
||||
{ name: 'blogmarkCategory', type: 'string', required: false, description: 'Default category for blogmark imports.' },
|
||||
{ name: 'pythonRuntimeMode', type: "'webworker' | 'main-thread'", required: false, description: 'Python runtime execution mode.' },
|
||||
{ name: 'pythonRuntimeMode', type: '\'webworker\' | \'main-thread\'', required: false, description: 'Python runtime execution mode.' },
|
||||
{ name: 'picoTheme', type: 'string', required: false, description: 'Preferred Pico theme token.' },
|
||||
{ name: 'categoryMetadata', type: 'object', required: false, description: 'Category metadata keyed by category slug.' },
|
||||
{ name: 'categorySettings', type: 'object', required: false, description: 'Category render settings keyed by category slug.' },
|
||||
@@ -412,7 +412,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
description: 'Result from a git operation (fetch, pull, push, commit).',
|
||||
fields: [
|
||||
{ name: 'success', type: 'boolean', required: true, description: 'Whether the operation succeeded.' },
|
||||
{ name: 'code', type: 'string', required: false, description: "Error code when failed ('auth-required', 'conflict', 'network', 'action-failed')." },
|
||||
{ name: 'code', type: 'string', required: false, description: 'Error code when failed (\'auth-required\', \'conflict\', \'network\', \'action-failed\').' },
|
||||
{ name: 'error', type: 'string', required: false, description: 'Error message when failed.' },
|
||||
{ name: 'guidance', type: 'string[]', required: false, description: 'Guidance messages for resolving failures.' },
|
||||
],
|
||||
@@ -460,7 +460,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
{ name: 'title', type: 'string', required: true, description: 'Translated title.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, description: 'Translated excerpt.' },
|
||||
{ name: 'content', type: 'string', required: true, description: 'Translated Markdown content.' },
|
||||
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Translation lifecycle state.' },
|
||||
{ name: 'status', type: '\'draft\' | \'published\' | \'archived\'', required: true, description: 'Translation lifecycle state.' },
|
||||
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp.' },
|
||||
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp.' },
|
||||
{ name: 'publishedAt', type: 'string', required: false, description: 'Publish timestamp when the translation is published.' },
|
||||
|
||||
@@ -2,9 +2,6 @@ import React from 'react';
|
||||
import { Toaster, toast } from 'react-hot-toast';
|
||||
import './Toast.css';
|
||||
|
||||
// Toast types
|
||||
type ToastType = 'success' | 'error' | 'loading' | 'info';
|
||||
|
||||
// Custom toast functions
|
||||
export const showToast = {
|
||||
success: (message: string) => toast.success(message, {
|
||||
|
||||
Reference in New Issue
Block a user