@shirokuma-library/mcp-knowledge-base
Version:
Shirokuma MCP Server for comprehensive knowledge management including issues, plans, documents, and work sessions. All stored data is structured for AI processing, not human readability.
760 lines (759 loc) • 34.2 kB
JavaScript
import { TypeRepository } from '../database/type-repository.js';
import { createLogger } from '../utils/logger.js';
import { cleanString } from '../utils/string-utils.js';
import { normalizeVersion } from '../utils/version-utils.js';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { UnifiedStorage, STORAGE_CONFIGS } from '../storage/unified-storage.js';
export class ItemRepository {
db;
statusRepo;
tagRepo;
fileDb;
storage;
logger = createLogger('ItemRepository');
constructor(db, dataDir, statusRepo, tagRepo, fileDb) {
this.db = db;
this.statusRepo = statusRepo;
this.tagRepo = tagRepo;
this.fileDb = fileDb;
this.storage = new UnifiedStorage(dataDir);
}
getStorageConfig(type) {
if (type in STORAGE_CONFIGS) {
return STORAGE_CONFIGS[type];
}
return {
baseDir: type,
filePrefix: `${type}-`,
useDateSubdir: false
};
}
async itemToStorageItem(item) {
const typeRepo = new TypeRepository(this.fileDb);
await typeRepo.init();
const fieldDefs = await typeRepo.getFieldsForType(item.type);
const metadata = {};
if (item.type !== 'sessions' && item.type !== 'dailies') {
const typeInfo = await this.db.getAsync('SELECT base_type FROM sequences WHERE type = ?', [item.type]);
if (typeInfo?.base_type) {
metadata.base = typeInfo.base_type;
}
}
for (const fieldDef of fieldDefs) {
const fieldName = fieldDef.field_name;
let value;
switch (fieldName) {
case 'id':
value = parseInt(item.id) || item.id;
break;
case 'title':
value = item.title;
break;
case 'description':
value = item.description || fieldDef.default_value;
break;
case 'content':
continue;
case 'priority':
value = item.priority || fieldDef.default_value;
break;
case 'status':
value = item.status || fieldDef.default_value;
break;
case 'status_id':
value = item.status_id;
break;
case 'tags':
value = item.tags || JSON.parse(fieldDef.default_value || '[]');
break;
case 'start_date':
value = item.start_date || fieldDef.default_value;
break;
case 'end_date':
value = item.end_date || fieldDef.default_value;
break;
case 'start_time':
value = item.start_time || fieldDef.default_value;
break;
case 'related':
value = item.related || JSON.parse(fieldDef.default_value || '[]');
break;
case 'related_tasks':
value = item.related_tasks || JSON.parse(fieldDef.default_value || '[]');
break;
case 'related_documents':
value = item.related_documents || JSON.parse(fieldDef.default_value || '[]');
break;
case 'created_at':
value = item.created_at;
break;
case 'updated_at':
value = item.updated_at;
break;
case 'version':
value = ('version' in item ? item.version : undefined) || fieldDef.default_value;
break;
default:
value = fieldDef.default_value;
}
metadata[fieldName] = value;
}
return {
id: item.id,
metadata,
content: item.content
};
}
async storageItemToUnifiedItem(item, type, statusName) {
const metadata = item.metadata;
const related = (Array.isArray(metadata.related) ? metadata.related : []);
const related_tasks = (Array.isArray(metadata.related_tasks) ? metadata.related_tasks : related.filter((r) => r.match(/^(issues|plans)-/)));
const related_documents = (Array.isArray(metadata.related_documents) ? metadata.related_documents : related.filter((r) => r.match(/^(docs|knowledge)-/)));
const statuses = await this.statusRepo.getAllStatuses();
let statusId = Number(metadata.status_id || 1);
if (!statusName) {
if (metadata.status_id) {
const status = statuses.find(s => s.id === Number(metadata.status_id));
statusName = status?.name || 'Open';
}
else if (metadata.status) {
const status = statuses.find(s => s.name === String(metadata.status));
if (status) {
statusName = status.name;
statusId = status.id;
}
else {
statusName = String(metadata.status);
}
}
else {
statusName = 'Open';
}
}
const unifiedItem = {
id: item.id,
type,
title: String(metadata.title || ''),
description: metadata.description ? String(metadata.description) : undefined,
version: metadata.version ? String(metadata.version) : undefined,
content: item.content,
priority: (metadata.priority === 'high' || metadata.priority === 'medium' || metadata.priority === 'low' ? metadata.priority : 'medium'),
status: statusName,
status_id: Number(statusId),
start_date: metadata.start_date ? String(metadata.start_date) : null,
end_date: metadata.end_date ? String(metadata.end_date) : null,
start_time: metadata.start_time ? String(metadata.start_time) : null,
tags: Array.isArray(metadata.tags) ? metadata.tags.map(t => String(t)) : [],
related,
related_tasks,
related_documents,
created_at: String(metadata.created_at || new Date().toISOString()),
updated_at: String(metadata.updated_at || metadata.created_at || new Date().toISOString())
};
if (type === 'sessions' || type === 'dailies') {
unifiedItem.date = metadata.start_date || null;
}
return unifiedItem;
}
async getNextId(type) {
await this.db.runAsync('UPDATE sequences SET current_value = current_value + 1 WHERE type = ?', [type]);
const row = await this.db.getAsync('SELECT current_value FROM sequences WHERE type = ?', [type]);
return row.current_value;
}
async getCurrentSequenceValue(type) {
const row = await this.db.getAsync('SELECT current_value FROM sequences WHERE type = ?', [type]);
if (!row) {
throw new McpError(ErrorCode.InvalidRequest, `Sequence not found for type: ${type}`);
}
return row.current_value;
}
async createItem(params) {
const { type } = params;
let typeInfo;
if (type === 'sessions') {
typeInfo = { base_type: 'sessions' };
}
else if (type === 'dailies') {
typeInfo = { base_type: 'documents' };
}
else {
typeInfo = await this.db.getAsync('SELECT base_type FROM sequences WHERE type = ?', [type]);
}
if (!typeInfo) {
throw new McpError(ErrorCode.InvalidRequest, `Unknown type: ${type}`);
}
if (typeInfo.base_type === 'tasks' && !params.content) {
throw new McpError(ErrorCode.InvalidRequest, `Content is required for ${type}`);
}
const cleanedTitle = cleanString(params.title);
if (cleanedTitle.length === 0) {
throw new McpError(ErrorCode.InvalidRequest, 'Title cannot be empty');
}
if (cleanedTitle.length > 500) {
throw new McpError(ErrorCode.InvalidRequest, 'Title must be 500 characters or less');
}
const validateDate = (dateStr, fieldName) => {
if (!dateStr) {
return;
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
throw new McpError(ErrorCode.InvalidRequest, `Invalid ${fieldName} format. Date must be in YYYY-MM-DD format`);
}
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day) {
throw new McpError(ErrorCode.InvalidRequest, `Invalid date: ${dateStr}`);
}
};
validateDate(params.start_date || undefined, 'start_date');
validateDate(params.end_date || undefined, 'end_date');
let id;
let createdAt;
let startDate = null;
const now = new Date();
const nowISOString = now.toISOString();
if (type === 'sessions') {
if (params.datetime) {
const sessionDate = new Date(params.datetime);
if (isNaN(sessionDate.getTime())) {
throw new McpError(ErrorCode.InvalidRequest, 'Invalid datetime format');
}
createdAt = sessionDate.toISOString();
}
else {
createdAt = nowISOString;
}
if (params.id) {
id = params.id;
const idMatch = id.match(/^(\d{4}-\d{2}-\d{2})/);
if (idMatch) {
startDate = idMatch[1];
}
else {
startDate = now.toISOString().split('T')[0];
}
}
else {
const dateObj = params.datetime ? new Date(params.datetime) : now;
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const day = String(dateObj.getDate()).padStart(2, '0');
const hours = String(dateObj.getHours()).padStart(2, '0');
const minutes = String(dateObj.getMinutes()).padStart(2, '0');
const seconds = String(dateObj.getSeconds()).padStart(2, '0');
const milliseconds = String(dateObj.getMilliseconds()).padStart(3, '0');
id = `${year}-${month}-${day}-${hours}.${minutes}.${seconds}.${milliseconds}`;
startDate = `${year}-${month}-${day}`;
}
}
else if (type === 'dailies') {
if (params.date) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(params.date)) {
throw new McpError(ErrorCode.InvalidRequest, 'Invalid date format. Date must be in YYYY-MM-DD format');
}
const [year, month, day] = params.date.split('-').map(Number);
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day) {
throw new McpError(ErrorCode.InvalidRequest, `Invalid date: ${params.date}`);
}
id = params.date;
startDate = params.date;
createdAt = params.date + 'T00:00:00.000Z';
}
else {
id = nowISOString.split('T')[0];
startDate = id;
createdAt = nowISOString;
}
const config = this.getStorageConfig(type);
const exists = await this.storage.exists(config, id);
if (exists) {
throw new McpError(ErrorCode.InvalidRequest, `Daily summary for ${id} already exists. Use update instead.`);
}
}
else {
const currentValue = await this.getCurrentSequenceValue(type);
const nextId = currentValue + 1;
const config = this.getStorageConfig(type);
const testId = String(nextId);
const exists = await this.storage.exists(config, testId);
if (exists) {
throw new McpError(ErrorCode.InvalidRequest, `File already exists for ${type}-${testId}. ` +
'This may indicate a sequence corruption. ' +
`Current sequence value: ${currentValue}, ` +
`Next ID would be: ${nextId}. ` +
'Please check the sequences table and rebuild if necessary.');
}
const numId = await this.getNextId(type);
id = String(numId);
createdAt = nowISOString;
}
const statusName = params.status || 'Open';
const statuses = await this.statusRepo.getAllStatuses();
const status = statuses.find(s => s.name === statusName);
if (!status) {
throw new McpError(ErrorCode.InvalidRequest, `Invalid status: ${statusName}`);
}
const validateRelatedArray = (arr, fieldName) => {
if (arr && arr.some(item => item === '')) {
throw new McpError(ErrorCode.InvalidRequest, `Related items cannot contain empty strings in ${fieldName}. Use format like ["issues-1", "plans-2"] with valid type-id references.`);
}
};
validateRelatedArray(params.related_tasks, 'related_tasks');
validateRelatedArray(params.related_documents, 'related_documents');
validateRelatedArray(params.related, 'related');
if (params.version !== undefined && params.version !== null && params.version !== '') {
if (!/^\d+\.\d+\.\d+$/.test(params.version)) {
throw new McpError(ErrorCode.InvalidRequest, 'Version must be in X.Y.Z format where X, Y, Z are numbers (e.g., "1.0.0", "0.7.11")');
}
const parts = params.version.split('.');
const [major, minor, patch] = parts.map(p => parseInt(p, 10));
if (major > 99999 || minor > 99999 || patch > 99999) {
throw new McpError(ErrorCode.InvalidRequest, 'Version numbers must not exceed 99999 (e.g., "99999.99999.99999" is the maximum)');
}
}
const uniqueRelatedTasks = params.related_tasks ? [...new Set(params.related_tasks)] : [];
const uniqueRelatedDocuments = params.related_documents ? [...new Set(params.related_documents)] : [];
const uniqueRelated = [...new Set([...uniqueRelatedTasks, ...uniqueRelatedDocuments])];
const cleanedTags = (params.tags || [])
.map(tag => cleanString(tag))
.filter(tag => tag.length > 0);
const item = {
id: id,
type,
title: cleanedTitle,
description: params.description,
version: params.version,
content: params.content || '',
priority: params.priority || 'medium',
status: statusName,
status_id: status.id,
start_date: type === 'dailies' ? id : (startDate || params.start_date || null),
end_date: params.end_date || null,
start_time: null,
tags: cleanedTags,
related: uniqueRelated,
related_tasks: uniqueRelatedTasks,
related_documents: uniqueRelatedDocuments,
created_at: createdAt,
updated_at: createdAt
};
if (type === 'sessions' || type === 'dailies') {
item.date = item.start_date;
}
const config = this.getStorageConfig(type);
const storageItem = await this.itemToStorageItem(item);
await this.storage.save(config, storageItem);
await this.syncItemToSQLite(item);
if (item.tags.length > 0) {
await this.tagRepo.ensureTagsExist(item.tags);
}
this.logger.info(`Created ${type} ${id}`);
return item;
}
async getItem(type, id) {
let typeInfo;
if (type === 'sessions') {
typeInfo = { base_type: 'sessions' };
}
else if (type === 'dailies') {
typeInfo = { base_type: 'documents' };
}
else {
typeInfo = await this.db.getAsync('SELECT base_type FROM sequences WHERE type = ?', [type]);
}
if (!typeInfo) {
throw new McpError(ErrorCode.InvalidRequest, `Unknown type: ${type}`);
}
const config = this.getStorageConfig(type);
const storageItem = await this.storage.load(config, id);
if (!storageItem) {
return null;
}
const statuses = await this.statusRepo.getAllStatuses();
const status = statuses.find(s => s.id === storageItem.metadata.status_id);
return await this.storageItemToUnifiedItem(storageItem, type, status?.name);
}
async updateItem(params) {
const { type, id } = params;
const current = await this.getItem(type, id);
if (!current) {
return null;
}
let cleanedTitle;
if (params.title !== undefined) {
cleanedTitle = cleanString(params.title);
if (cleanedTitle.length > 500) {
throw new McpError(ErrorCode.InvalidRequest, 'Title must be 500 characters or less');
}
}
const validateDate = (dateStr, fieldName) => {
if (!dateStr) {
return;
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
throw new McpError(ErrorCode.InvalidRequest, `Invalid ${fieldName} format. Date must be in YYYY-MM-DD format`);
}
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day) {
throw new McpError(ErrorCode.InvalidRequest, `Invalid date: ${dateStr}`);
}
};
validateDate(params.start_date || undefined, 'start_date');
validateDate(params.end_date || undefined, 'end_date');
const validateRelatedArray = (arr, fieldName) => {
if (arr && arr.some(item => item === '')) {
throw new McpError(ErrorCode.InvalidRequest, `Related items cannot contain empty strings in ${fieldName}. Use format like ["issues-1", "plans-2"] with valid type-id references.`);
}
};
validateRelatedArray(params.related_tasks, 'related_tasks');
validateRelatedArray(params.related_documents, 'related_documents');
validateRelatedArray(params.related, 'related');
const currentItemRef = `${type}-${id}`;
const allRelated = [
...(params.related_tasks || []),
...(params.related_documents || []),
...(params.related || [])
];
if (allRelated.includes(currentItemRef)) {
throw new McpError(ErrorCode.InvalidRequest, 'Items cannot reference themselves');
}
if (params.version !== undefined && params.version !== null && params.version !== '') {
if (!/^\d+\.\d+\.\d+$/.test(params.version)) {
throw new McpError(ErrorCode.InvalidRequest, 'Version must be in X.Y.Z format where X, Y, Z are numbers (e.g., "1.0.0", "0.7.11")');
}
const parts = params.version.split('.');
const [major, minor, patch] = parts.map(p => parseInt(p, 10));
if (major > 99999 || minor > 99999 || patch > 99999) {
throw new McpError(ErrorCode.InvalidRequest, 'Version numbers must not exceed 99999 (e.g., "99999.99999.99999" is the maximum)');
}
}
const uniqueRelatedTasks = params.related_tasks !== undefined
? [...new Set(params.related_tasks)]
: current.related_tasks;
const uniqueRelatedDocuments = params.related_documents !== undefined
? [...new Set(params.related_documents)]
: current.related_documents;
const cleanedTags = params.tags !== undefined
? params.tags.map(tag => cleanString(tag)).filter(tag => tag.length > 0)
: current.tags;
const updated = {
...current,
title: cleanedTitle !== undefined ? cleanedTitle : current.title,
description: params.description !== undefined ? params.description : current.description,
version: params.version !== undefined ? params.version : current.version,
content: params.content !== undefined ? params.content : current.content,
priority: params.priority !== undefined ? params.priority : current.priority,
start_date: params.start_date !== undefined ? params.start_date : current.start_date,
end_date: params.end_date !== undefined ? params.end_date : current.end_date,
tags: cleanedTags,
related_tasks: uniqueRelatedTasks,
related_documents: uniqueRelatedDocuments,
updated_at: new Date().toISOString()
};
if (params.status !== undefined) {
const statuses = await this.statusRepo.getAllStatuses();
const status = statuses.find(s => s.name === params.status);
if (!status) {
throw new McpError(ErrorCode.InvalidRequest, `Invalid status: ${params.status}`);
}
updated.status = params.status;
updated.status_id = status.id;
}
updated.related = [
...(updated.related_tasks || []),
...(updated.related_documents || [])
];
const config = this.getStorageConfig(type);
const storageItem = await this.itemToStorageItem(updated);
await this.storage.save(config, storageItem);
await this.syncItemToSQLite(updated);
if (updated.tags.length > 0) {
await this.tagRepo.ensureTagsExist(updated.tags);
}
this.logger.info(`Updated ${type} ${id}`);
return updated;
}
async deleteItem(type, id) {
const config = this.getStorageConfig(type);
const deleted = await this.storage.delete(config, id);
if (deleted) {
const row = await this.db.getAsync('SELECT rowid FROM items WHERE type = ? AND id = ?', [type, id]);
await this.db.runAsync('DELETE FROM items WHERE type = ? AND id = ?', [type, id]);
if (row) {
await this.db.runAsync('DELETE FROM items_fts WHERE rowid = ?', [row.rowid]);
}
this.logger.info(`Deleted ${type} ${id}`);
}
return deleted;
}
async getItems(type, includeClosedStatuses, statuses, startDate, endDate, limit) {
let typeInfo;
if (type === 'sessions') {
typeInfo = { base_type: 'sessions' };
}
else if (type === 'dailies') {
typeInfo = { base_type: 'documents' };
}
else {
typeInfo = await this.db.getAsync('SELECT base_type FROM sequences WHERE type = ?', [type]);
}
if (!typeInfo) {
throw new McpError(ErrorCode.InvalidRequest, `Unknown type: ${type}`);
}
let query = `
SELECT i.type, i.id, i.title, i.description, i.priority,
i.tags, i.updated_at, s.name as status_name,
i.start_date
FROM items i
LEFT JOIN statuses s ON i.status_id = s.id
WHERE i.type = ?
`;
const params = [type];
if (!includeClosedStatuses) {
query += ' AND s.is_closed = 0';
}
if (statuses !== undefined) {
if (statuses.length === 0) {
return [];
}
query += ` AND s.name IN (${statuses.map(() => '?').join(',')})`;
params.push(...statuses);
}
if (startDate || endDate) {
const dateField = (type === 'sessions' || type === 'dailies') ? 'i.start_date' : 'i.updated_at';
const isDateOnly = (type === 'sessions' || type === 'dailies');
if (startDate) {
query += ` AND ${dateField} >= ?`;
params.push(isDateOnly ? startDate : startDate + 'T00:00:00.000Z');
}
if (endDate) {
query += ` AND ${dateField} <= ?`;
params.push(isDateOnly ? endDate : endDate + 'T23:59:59.999Z');
}
}
query += ' ORDER BY i.created_at DESC';
if (limit && limit > 0) {
const maxLimit = 10000;
const safeLimit = Math.min(limit, maxLimit);
query += ` LIMIT ${safeLimit}`;
}
const rows = await this.db.allAsync(query, params);
return rows.map(row => this.rowToListItem(row));
}
async searchItemsByTag(tag, types) {
let query = `
SELECT DISTINCT i.type, i.id, i.title, i.description, i.priority,
i.tags, i.updated_at, s.name as status_name,
i.start_date
FROM items i
JOIN item_tags it ON i.type = it.item_type AND i.id = it.item_id
JOIN tags t ON it.tag_id = t.id
LEFT JOIN statuses s ON i.status_id = s.id
WHERE t.name = ?
`;
const params = [tag];
if (types && types.length > 0) {
query += ` AND i.type IN (${types.map(() => '?').join(',')})`;
params.push(...types);
}
query += ' ORDER BY i.created_at DESC';
const rows = await this.db.allAsync(query, params);
return rows.map(row => this.rowToListItem(row));
}
async syncItemToSQLite(item) {
const params = [
item.type,
item.id,
item.title,
item.description || null,
item.content,
item.priority,
item.status_id,
item.start_date,
item.end_date,
item.start_time,
normalizeVersion(item.version) || null,
JSON.stringify(item.tags),
JSON.stringify(item.related),
item.created_at,
item.updated_at
];
await this.db.runAsync(`
INSERT OR REPLACE INTO items
(type, id, title, description, content, priority, status_id,
start_date, end_date, start_time, version, tags, related, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, params);
await this.db.runAsync(`
INSERT OR REPLACE INTO items_fts
(rowid, type, title, description, content, tags)
VALUES (
(SELECT rowid FROM items WHERE type = ? AND id = ?),
?, ?, ?, ?, ?
)
`, [
item.type, item.id,
item.type, item.title, item.description || '', item.content, JSON.stringify(item.tags)
]);
await this.db.runAsync('DELETE FROM item_tags WHERE item_type = ? AND item_id = ?', [item.type, item.id]);
if (item.tags.length > 0) {
const tagIds = await Promise.all(item.tags.map(tag => this.tagRepo.getOrCreateTagId(tag)));
for (const tagId of tagIds) {
await this.db.runAsync('INSERT INTO item_tags (item_type, item_id, tag_id) VALUES (?, ?, ?)', [item.type, item.id, tagId]);
}
}
await this.db.runAsync('DELETE FROM related_items WHERE (source_type = ? AND source_id = ?) OR (target_type = ? AND target_id = ?)', [item.type, item.id, item.type, item.id]);
if (item.related.length > 0) {
const uniqueRelated = [...new Set(item.related)];
for (const relatedRef of uniqueRelated) {
const [relatedType, relatedId] = relatedRef.split('-');
try {
await this.db.runAsync('INSERT INTO related_items (source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?)', [item.type, item.id, relatedType, relatedId]);
}
catch (error) {
if (error instanceof Error && error.message?.includes('UNIQUE constraint failed')) {
this.logger.debug(`Skipping duplicate related item: ${item.type}-${item.id} -> ${relatedRef}`);
}
else {
throw error;
}
}
}
}
}
rowToListItem(row) {
const tags = row.tags ? JSON.parse(row.tags) : [];
const item = {
id: row.id,
type: row.type,
title: row.title,
description: row.description || undefined,
tags,
updated_at: row.updated_at
};
if (row.status_name) {
item.status = row.status_name;
}
if (row.priority) {
item.priority = row.priority;
}
if ((row.type === 'sessions' || row.type === 'dailies') && row.start_date) {
item.date = row.start_date;
}
return item;
}
rowToUnifiedItem(row) {
const tags = row.tags ? JSON.parse(row.tags) : [];
const related = row.related ? JSON.parse(row.related) : [];
const item = {
id: row.id,
type: row.type,
title: row.title,
description: row.description || undefined,
version: row.version || undefined,
content: row.content !== undefined ? (row.content || '') : '',
priority: row.priority,
status: row.status_name || 'Unknown',
status_id: row.status_id || 1,
start_date: row.start_date,
end_date: row.end_date,
start_time: row.start_time,
tags,
related,
created_at: row.created_at,
updated_at: row.updated_at
};
if (row.type === 'sessions' || row.type === 'dailies') {
item.date = row.start_date;
}
return item;
}
async rebuildFromMarkdown(type) {
const config = this.getStorageConfig(type);
let syncedCount = 0;
if (config.useDateSubdir) {
const dateDirs = await this.storage.listDateDirs(config);
for (const dateDir of dateDirs) {
const ids = await this.storage.list(config, dateDir);
for (const id of ids) {
const storageItem = await this.storage.load(config, id);
if (storageItem && storageItem.metadata.title) {
try {
this.migrateRelatedFields(storageItem);
const item = await this.storageItemToUnifiedItem(storageItem, type);
await this.syncItemToSQLite(item);
await this.tagRepo.ensureTagsExist(item.tags);
syncedCount++;
}
catch (error) {
this.logger.error(`Failed to sync ${type} ${id}:`, error);
}
}
else if (storageItem) {
this.logger.warn(`Skipping ${type} ${id}: missing title`);
}
}
}
}
else {
const ids = await this.storage.list(config);
this.logger.info(`Found ${ids.length} ${type} files to rebuild`);
for (const id of ids) {
try {
const storageItem = await this.storage.load(config, id);
if (storageItem && storageItem.metadata.title) {
this.migrateRelatedFields(storageItem);
const item = await this.storageItemToUnifiedItem(storageItem, type);
await this.syncItemToSQLite(item);
await this.tagRepo.ensureTagsExist(item.tags);
syncedCount++;
}
else if (storageItem) {
this.logger.warn(`Skipping ${type} ${id}: missing title`);
}
else {
this.logger.warn(`Failed to load ${type} ${id}`);
}
}
catch (error) {
this.logger.error(`Failed to sync ${type} ${id}:`, error);
if (error instanceof Error) {
this.logger.error(` Error message: ${error.message}`);
this.logger.error(` Stack: ${error.stack}`);
}
}
}
this.logger.info(`Completed rebuilding ${type}: ${syncedCount}/${ids.length} files synced`);
}
return syncedCount;
}
migrateRelatedFields(storageItem) {
const metadata = storageItem.metadata;
if (!metadata.related && (metadata.related_tasks || metadata.related_documents)) {
const related = new Set();
if (Array.isArray(metadata.related_tasks)) {
metadata.related_tasks.forEach((item) => related.add(item));
}
if (Array.isArray(metadata.related_documents)) {
metadata.related_documents.forEach((item) => related.add(item));
}
metadata.related = Array.from(related);
delete metadata.related_tasks;
delete metadata.related_documents;
this.logger.info(`Migrated related fields for ${storageItem.id}: ${metadata.related.length} items`);
}
}
async changeItemType(fromType, fromId, toType) {
const { ItemRepository: DatabaseItemRepository } = await import('../database/item-repository.js');
const dbItemRepo = new DatabaseItemRepository(this.db, this.fileDb.dataDirectory, this.statusRepo, this.tagRepo);
return dbItemRepo.changeItemType(fromType, fromId, toType);
}
}