UNPKG

mira-storage-sqlite

Version:

SQLite storage implementation for Mira - provides database persistence using SQLite

687 lines 25.8 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.LibraryServerDataSQLite = void 0; const sqlite3_1 = require("sqlite3"); const path = __importStar(require("path")); const fs = __importStar(require("fs")); class LibraryServerDataSQLite { constructor(config, opts) { this.db = null; this.inTransaction = false; this.config = config; this.enableHash = config.customFields?.enableHash ?? false; } async initialize() { const basePath = await this.getLibraryPath(); if (!fs.existsSync(basePath)) { fs.mkdirSync(basePath, { recursive: true }); } // 初始化数据库连接和表结构 const dbPath = path.join(basePath, 'library_data.db'); this.db = new sqlite3_1.Database(dbPath); // 创建文件表 await this.executeSql(` CREATE TABLE IF NOT EXISTS files( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_at INTEGER NOT NULL, imported_at INTEGER NOT NULL, size INTEGER NOT NULL, hash TEXT NOT NULL, custom_fields TEXT, notes TEXT, stars INTEGER DEFAULT 0, folder_id INTEGER, reference TEXT, path TEXT, thumb INTEGER DEFAULT 0, recycled INTEGER DEFAULT 0, tags TEXT, FOREIGN KEY(folder_id) REFERENCES folders(id) ) `); // 创建文件夹表 await this.executeSql(` CREATE TABLE IF NOT EXISTS folders( id INTEGER PRIMARY KEY, title TEXT NOT NULL, parent_id INTEGER, color INTEGER, icon TEXT, FOREIGN KEY(parent_id) REFERENCES folders(id) ) `); // 创建标签表 await this.executeSql(` CREATE TABLE IF NOT EXISTS tags( id INTEGER PRIMARY KEY, title TEXT NOT NULL, parent_id INTEGER, color INTEGER, icon INTEGER, FOREIGN KEY(parent_id) REFERENCES tags(id) ) `); } // 文件操作方法实现 async createFile(fileData) { const result = await this.runSql(`INSERT INTO files( name, created_at, imported_at, size, hash, custom_fields, notes, stars, folder_id, reference, path, thumb, recycled, tags ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ fileData.name, fileData.created_at, fileData.imported_at, fileData.size, fileData.hash, fileData.custom_fields, fileData.notes, fileData.stars ?? 0, fileData.folder_id, fileData.reference, fileData.path, fileData.thumb ?? 0, fileData.recycled ?? 0, fileData.tags, ]); return { id: result.lastID, ...fileData }; } async updateFile(id, fileData) { const fields = []; const params = []; const addField = (key, value) => { if (fileData[key] !== undefined) { fields.push(`${key} = ?`); params.push(value); } }; addField('name', fileData.name); addField('created_at', fileData.created_at); addField('imported_at', fileData.imported_at); addField('size', fileData.size); addField('hash', fileData.hash); addField('custom_fields', fileData.custom_fields); addField('notes', fileData.notes); addField('stars', fileData.stars ?? 0); addField('tags', fileData.tags); addField('folder_id', fileData.folder_id); addField('reference', fileData.reference); addField('path', fileData.path); addField('thumb', fileData.thumb ?? 0); addField('recycled', fileData.recycled ?? 0); if (fields.length === 0) return false; const query = `UPDATE files SET ${fields.join(', ')} WHERE id = ?`; params.push(id); const result = await this.runSql(query, params); return result.changes > 0; } async deleteFile(id, options) { const query = options?.moveToRecycleBin ? 'UPDATE files SET recycled = 1 WHERE id = ?' : 'DELETE FROM files WHERE id = ?'; const result = await this.runSql(query, [id]); return result.changes > 0; } async recoverFile(id) { const result = await this.runSql('UPDATE files SET recycled = 0 WHERE id = ?', [id]); return result.changes > 0; } async getFile(id) { const rows = await this.getSql('SELECT * FROM files WHERE id = ? LIMIT 1', [id]); return rows.length > 0 ? this.rowToMap(rows[0]) : null; } async getFiles(options) { const select = options?.select || '*'; const filters = options?.filters || {}; const whereClauses = []; const params = []; const folderId = parseInt(filters.folder?.toString() || '0') || 0; const tagIds = Array.isArray(filters.tags) ? filters.tags : []; const limit = parseInt(filters.limit?.toString() || '100') || 100; const offset = parseInt(filters.offset?.toString() || '0') || 0; // 构建查询条件 if (filters.recycled !== undefined) { whereClauses.push('recycled = ?'); params.push(filters.recycled ? 1 : 0); } if (filters.star !== undefined) { whereClauses.push('stars >= ?'); params.push(filters.star); } if (filters.name) { whereClauses.push('name LIKE ?'); params.push(`%${filters.name}%`); } if (filters.dateRange) { whereClauses.push('created_at BETWEEN ? AND ?'); params.push(filters.dateRange.start.getTime(), filters.dateRange.end.getTime()); } if (filters.minSize !== undefined) { whereClauses.push('size >= ?'); params.push(filters.minSize * 1024); } if (filters.maxSize !== undefined) { whereClauses.push('size <= ?'); params.push(filters.maxSize * 1024); } if (filters.minRating !== undefined) { whereClauses.push('stars >= ?'); params.push(filters.minRating); } if (folderId !== 0) { whereClauses.push('folder_id = ?'); params.push(folderId); } if (tagIds.length > 0) { whereClauses.push(`( SELECT COUNT(*) FROM json_each(tags) WHERE value IN (${tagIds.map(() => '?').join(',')}) ) = ${tagIds.length}`); params.push(...tagIds); } if (filters.custom_fields) { const customFields = filters.custom_fields; const convertValue = (value) => { if (value == 'null') { value = null; } return value; }; for (const [key, value] of Object.entries(customFields)) { if (typeof value === 'string' && value.startsWith('!=')) { let actualValue = value.substring(2).trim(); whereClauses.push(`(json_extract(custom_fields, '$.${key}') IS NOT NULL OR json_extract(custom_fields, '$.${key}') != ?)`); params.push(convertValue(actualValue)); } else if (typeof value === 'string' && value.startsWith('>')) { whereClauses.push(`json_extract(custom_fields, '$.${key}') > ?`); params.push(convertValue(value.substring(1).trim())); } else if (typeof value === 'string' && value.startsWith('<')) { whereClauses.push(`json_extract(custom_fields, '$.${key}') < ?`); params.push(convertValue(value.substring(1).trim())); } else { whereClauses.push(`json_extract(custom_fields, '$.${key}') = ?`); params.push(convertValue(value)); } } } const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''; // 处理排序 let orderBy = ''; // sort?: 'imported_at' | 'id' | 'size' | 'stars' | 'folder_id' | 'tags' | 'name' | 'custom_fields'; // order?: 'asc' | 'desc'; if (filters?.sort) { const order = filters?.order || 'asc'; if (filters.sort === 'custom_fields') { // 自定义字段排序需要特殊处理 orderBy = ` ORDER BY json_extract(custom_fields, '$') ${order}`; } else { orderBy = ` ORDER BY ${filters.sort} ${order}`; } } const query = `SELECT ${select} FROM files ${where}${orderBy} LIMIT ? OFFSET ?`; const countQuery = `SELECT COUNT(*) as total FROM files ${where}`; const [rows, countRows] = await Promise.all([ this.getSql(query, [...params, limit, offset]), this.getSql(countQuery, params), ]); return { result: await this.processingFiles(rows.map(row => this.rowToMap(row)), options?.isUrlFile), limit, offset, total: countRows[0].total, }; } // 文件夹操作方法 async createFolder(folderData) { const result = await this.runSql('INSERT INTO folders(id, title, parent_id, color, icon) VALUES (?, ?, ?, ?, ?)', [ folderData.id, folderData.title, folderData.parent_id, folderData.color, folderData.icon, ]); return result.lastID; } async updateFolder(id, folderData) { const result = await this.runSql('UPDATE folders SET title = ?, parent_id = ?, color = ?, icon = ? WHERE id = ?', [ folderData.title, folderData.parent_id, folderData.color, folderData.icon, id, ]); return result.changes > 0; } async deleteFolder(id) { await this.beginTransaction(); try { // 递归删除子文件夹 const children = await this.getFolders({ parentId: id }); for (const child of children) { await this.deleteFolder(child.id); } // 更新文件的folder_id为null await this.runSql('UPDATE files SET folder_id = NULL WHERE folder_id = ?', [id]); // 删除文件夹 const result = await this.runSql('DELETE FROM folders WHERE id = ?', [id]); await this.commitTransaction(); return result.changes > 0; } catch (err) { await this.rollbackTransaction(); throw err; } } async getFolder(id) { const rows = await this.getSql('SELECT * FROM folders WHERE id = ? LIMIT 1', [id]); return rows.length > 0 ? this.rowToMap(rows[0]) : null; } async findFolderByName(name, parentId) { const query = parentId !== undefined && parentId !== null ? 'SELECT * FROM folders WHERE title = ? AND parent_id = ? LIMIT 1' : 'SELECT * FROM folders WHERE title = ? AND parent_id IS NULL LIMIT 1'; const params = parentId !== undefined && parentId !== null ? [name, parentId] : [name]; const rows = await this.getSql(query, params); return rows.length > 0 ? this.rowToMap(rows[0]) : null; } async getFolders(options) { const parentId = options?.parentId; const limit = options?.limit || 100; const offset = options?.offset || 0; const where = parentId !== undefined ? 'WHERE parent_id = ?' : 'WHERE parent_id IS NULL'; const params = parentId !== undefined ? [parentId, limit, offset] : [limit, offset]; const query = `SELECT * FROM folders ${where} LIMIT ? OFFSET ?`; const rows = await this.getSql(query, params); return rows.map(row => this.rowToMap(row)); } // 标签操作方法 async createTag(tagData) { const result = await this.runSql('INSERT INTO tags(id, title, parent_id, color, icon) VALUES (?, ?, ?, ?, ?)', [ tagData.id, tagData.title, tagData.parent_id, tagData.color, tagData.icon, ]); return result.lastID; } async updateTag(id, tagData) { const result = await this.runSql('UPDATE tags SET title = ?, parent_id = ?, color = ?, icon = ? WHERE id = ?', [ tagData.title, tagData.parent_id, tagData.color, tagData.icon, id, ]); return result.changes > 0; } async deleteTag(id) { await this.beginTransaction(); try { // 递归删除子标签 const children = await this.getTags({ parentId: id }); for (const child of children) { await this.deleteTag(child.id); } // 删除标签 const result = await this.runSql('DELETE FROM tags WHERE id = ?', [id]); await this.commitTransaction(); return result.changes > 0; } catch (err) { await this.rollbackTransaction(); throw err; } } async getTag(id) { const rows = await this.getSql('SELECT * FROM tags WHERE id = ? LIMIT 1', [id]); return rows.length > 0 ? this.rowToMap(rows[0]) : null; } async getTags(options) { const parentId = options?.parentId; const limit = options?.limit || 100; const offset = options?.offset || 0; const where = parentId !== undefined ? 'WHERE parent_id = ?' : 'WHERE parent_id IS NULL'; const params = parentId !== undefined ? [parentId, limit, offset] : [limit, offset]; const query = `SELECT * FROM tags ${where} LIMIT ? OFFSET ?`; const rows = await this.getSql(query, params); return rows.map(row => this.rowToMap(row)); } // 事务管理 async beginTransaction() { if (!this.inTransaction) { await this.executeSql('BEGIN TRANSACTION'); this.inTransaction = true; } } async commitTransaction() { if (this.inTransaction) { await this.executeSql('COMMIT'); this.inTransaction = false; } } async rollbackTransaction() { if (this.inTransaction) { await this.executeSql('ROLLBACK'); this.inTransaction = false; } } async close() { if (this.db) { this.db.close(); this.db = null; } } async createFileFromPath(filePath, fileMeta, options) { if (!fs.existsSync(filePath)) { throw new Error(`File does not exist: ${filePath}`); } const stat = fs.statSync(filePath); const hash = this.enableHash ? this.calculateFileHashSync(filePath) : ''; const fileData = { path: filePath, name: path.basename(filePath), created_at: stat.mtime.getTime(), imported_at: Date.now(), size: stat.size, hash, ...fileMeta, }; await this.handleFile(filePath, fileData, options?.importType || 'copy'); return this.createFile(fileData); } async getFileFolder(fileId) { const rows = await this.getSql('SELECT f.* FROM folders f JOIN files fi ON fi.folder_id = f.id WHERE fi.id = ?', [fileId]); return rows.map(row => this.rowToMap(row)); } async getFileTags(fileId) { const rows = await this.getSql('SELECT tags FROM files WHERE id = ?', [fileId]); if (rows.length === 0) return []; try { const tagsStr = rows[0].tags; if (!tagsStr) return []; const tagIds = JSON.parse(tagsStr).filter((id) => id); if (tagIds.length === 0) return []; const tagRows = await this.getSql(`SELECT * FROM tags WHERE id IN (${tagIds.map(() => '?').join(',')})`, tagIds); return tagRows.map(row => this.rowToMap(row)); } catch (err) { return []; } } async setFileFolder(fileId, folderId) { if (!folderId) return false; await this.beginTransaction(); try { const result = await this.runSql('UPDATE files SET folder_id = ? WHERE id = ?', [ folderId, fileId, ]); await this.commitTransaction(); return result.changes > 0; } catch (err) { await this.rollbackTransaction(); throw err; } } async setFileTags(fileId, tagIds) { await this.beginTransaction(); try { const result = await this.runSql('UPDATE files SET tags = ? WHERE id = ?', [ JSON.stringify(tagIds), fileId, ]); await this.commitTransaction(); return result.changes > 0; } catch (err) { await this.rollbackTransaction(); throw err; } } async getAllTags() { const rows = await this.getSql('SELECT * FROM tags', []); return rows.map(row => this.rowToMap(row)); } async getAllFolders() { const rows = await this.getSql('SELECT * FROM folders', []); return rows.map(row => this.rowToMap(row)); } getLibraryId() { return this.config.id; } async getItemPath(item) { const libraryPath = await this.getLibraryPath(); const folderName = await this.getFolderName(item.folder_id); return path.join(libraryPath, folderName); } getPublicURL(url) { return `${this.config['serverURL']}:${this.config['serverPort']}/${url}`; } async getItemFilePath(item, options) { const libraryPath = await this.getLibraryPath(); const folderName = await this.getFolderName(item.folder_id); const filePath = path.join(libraryPath, folderName, item.name); return options?.isUrlFile ? this.getPublicURL(`api/file/${this.getLibraryId()}/${item.id}`) : filePath; } async getItemThumbPath(item, options) { const libraryPath = await this.getLibraryPath(); const fileName = item.hash ? `${item.hash}.png` : `${item.id}.png`; const thumbFile = path.join(libraryPath, 'thumbs', fileName); return options?.isUrlFile ? this.getPublicURL(`api/thumb/${this.getLibraryId()}/${item.id}`) : thumbFile; } rowToMap(row) { const map = {}; for (const key in row) { map[key] = row[key]; } return map; } calculateFileHashSync(filePath) { const buffer = fs.readFileSync(filePath); // 这里应该使用实际的哈希算法实现 return buffer.toString('hex').substring(0, 32); // 简化示例 } async handleFile(filePath, fileData, importType) { const destPath = path.join(await this.getItemPath(fileData), fileData.name); const destDir = path.dirname(destPath); console.log({ filePath, destPath }); switch (importType) { case 'link': // 保持原文件位置不变 break; case 'copy': if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } fs.copyFileSync(filePath, destPath); fileData.path = destPath; break; case 'move': if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } // 如果不同是跨盘符操作,则单独复制一份,再删除源文件 if (path.parse(filePath).root !== path.parse(destPath).root) { fs.copyFileSync(filePath, destPath); fs.unlinkSync(filePath); } else { fs.renameSync(filePath, destPath); } fileData.path = destPath; break; default: throw new Error(`Unknown import type: ${importType}`); } } async getFolderName(folderId) { if (folderId) { const folder = await this.getFolder(folderId); if (folder) return folder.title; } return '未分类'; } executeSql(sql, params) { return new Promise((resolve, reject) => { if (!this.db) { reject(new Error('Database not initialized')); return; } this.db.run(sql, params, (err) => { if (err) reject(err); else resolve(); }); }); } runSql(sql, params) { return new Promise((resolve, reject) => { if (!this.db) { reject(new Error('Database not initialized')); return; } this.db.run(sql, params, function (err) { if (err) reject(err); else resolve({ lastID: this.lastID, changes: this.changes }); }); }); } getSql(sql, params) { return new Promise((resolve, reject) => { if (!this.db) { reject(new Error('Database not initialized')); return; } this.db.all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows); }); }); } async getLibraryPath() { return this.config.customFields?.path || ''; } async query(sql, params) { return this.getSql(sql, params); } async getLibraryInfo() { const tags = await this.getAllTags(); const folders = await this.getAllFolders(); return { libraryId: this.getLibraryId(), status: 'connected', tags, folders, }; } // 查询方法 async queryFile(query, isUrlFile = true) { const { result } = await this.getFiles({ filters: query }); return this.processingFiles(result, isUrlFile); } async processingFiles(files, isUrlFile = true) { return Promise.all(files.map(async (file) => { return { ...file, ...{ thumb: await this.getItemThumbPath(file, { isUrlFile }), path: await this.getItemFilePath(file, { isUrlFile }), } }; })); } async queryFolder(query) { const folders = await this.getFolders(); return folders.filter(folder => { return Object.entries(query).every(([key, value]) => { return folder[key] === value; }); }); } async queryLibrary(query) { return this.getLibraryInfo(); } async createLibrary(data) { this.config.id = data.id || this.config.id; this.config.customFields = { ...this.config.customFields, ...data }; return this.getLibraryInfo(); } async closeLibrary() { await this.close(); return true; } async queryTag(query) { const tags = await this.getTags(); return tags.filter(tag => { return Object.entries(query).every(([key, value]) => { return tag[key] === value; }); }); } async getStats() { try { const result = await this.getSql('SELECT COUNT(*) as total_files, COALESCE(SUM(size), 0) as total_size FROM files WHERE recycled = 0'); return { totalFiles: result[0]?.total_files || 0, totalSize: result[0]?.total_size || 0 }; } catch (error) { console.error('Error getting library stats:', error); return { totalFiles: 0, totalSize: 0 }; } } } exports.LibraryServerDataSQLite = LibraryServerDataSQLite; //# sourceMappingURL=LibraryServerDataSQLite.js.map