diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index f69312c..f1477a9 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -13,6 +13,7 @@ import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs'; import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from './user-publish.mjs'; import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs'; import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs'; +import { resolveWorkspaceStoragePath, isWorkspaceStorageKey } from './workspace-storage.mjs'; const ALLOWED_EXTENSIONS = new Map([ ['.txt', 'text/plain'], @@ -297,6 +298,17 @@ export function createAssetService(pool, options = {}) { }; const resolveReadableStoragePath = async (storageKey) => { + if (h5Root && isWorkspaceStorageKey(storageKey)) { + const workspacePath = resolveWorkspaceStoragePath(h5Root, storageKey); + if (workspacePath) { + try { + await fs.stat(workspacePath); + return workspacePath; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + } let lastError = null; for (const candidate of resolveStoragePathCandidates(storageKey)) { const absolutePath = absoluteStoragePath(candidate); @@ -661,7 +673,12 @@ export function createAssetService(pool, options = {}) { const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => { if (syncWorkspace && categoryCode && ['oa', 'private', 'public'].includes(categoryCode)) { - await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch(() => {}); + await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch((error) => { + console.warn( + `[MindSpace] workspace sync failed (${categoryCode}):`, + error?.message ?? error, + ); + }); } const filters = [`a.user_id = ?`, `a.status <> 'deleted'`]; const params = [userId]; diff --git a/mindspace-workspace-sync.mjs b/mindspace-workspace-sync.mjs index 131d730..d47df32 100644 --- a/mindspace-workspace-sync.mjs +++ b/mindspace-workspace-sync.mjs @@ -9,56 +9,99 @@ import { } from './user-space.mjs'; import { assetInternals } from './mindspace-assets.mjs'; import { runBasicFileScan } from './mindspace-scan.mjs'; +import { buildWorkspaceStorageKey } from './workspace-storage.mjs'; const SKIP_FILENAMES = new Set(['index.html', '.tkmindhints', '.goosehints', '.ls_output']); +const SKIP_ZONE_DIR_NAMES = new Set([ + 'node_modules', + '.venv', + '.venv2', + '__pycache__', + '.git', + 'dist', + 'build', + '.next', + 'target', + 'backend', + 'frontend', +]); +/** 子目录内常见的项目/脚手架文件,不同步到 OA 资产库 */ +const NESTED_SKIP_BASENAMES = new Set([ + 'README.md', + 'requirements.txt', + 'package.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +]); function asNumber(value) { return Number(value ?? 0); } -export function shouldSyncWorkspaceFilename(filename) { - const normalized = String(filename ?? '').trim(); - if (!normalized || normalized.startsWith('.')) return false; - if (SKIP_FILENAMES.has(normalized)) return false; - if (normalized.endsWith('.thumbnail.svg')) return false; - if (normalized.endsWith('.sh')) return false; - return assetInternals.expectedMimeType(normalized) !== null; +export function normalizeWorkspaceRelativePath(relativePath) { + const normalized = String(relativePath ?? '') + .normalize('NFKC') + .trim() + .replace(/\\/g, '/'); + if (!normalized || normalized.startsWith('/') || normalized.includes('\0')) return null; + const parts = normalized.split('/').filter(Boolean); + if (parts.some((part) => part === '.' || part === '..')) return null; + if (parts.some((part) => part.startsWith('.'))) return null; + return parts.join('/').slice(0, 255); } -export async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) { - if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return []; - const zoneDir = resolveZoneDir(workspaceRoot, categoryCode); +export function shouldSyncWorkspaceFilename(filename) { + const relativePath = normalizeWorkspaceRelativePath(filename); + if (!relativePath) return false; + const basename = path.posix.basename(relativePath); + if (!basename || basename.startsWith('.')) return false; + if (SKIP_FILENAMES.has(basename)) return false; + if (basename.endsWith('.thumbnail.svg')) return false; + if (basename.endsWith('.sh')) return false; + if (relativePath.includes('/') && NESTED_SKIP_BASENAMES.has(basename)) return false; + return assetInternals.expectedMimeType(basename) !== null; +} + +async function walkWorkspaceZoneDir(zoneDir, relativePrefix, files) { let entries; try { entries = await fsPromises.readdir(zoneDir, { withFileTypes: true }); } catch { - return []; + return; } - const files = []; for (const entry of entries) { - if (!entry.isFile()) continue; - if (!shouldSyncWorkspaceFilename(entry.name)) continue; + if (entry.name.startsWith('.')) continue; const absolutePath = path.join(zoneDir, entry.name); + if (entry.isDirectory()) { + if (SKIP_ZONE_DIR_NAMES.has(entry.name)) continue; + const nextPrefix = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name; + await walkWorkspaceZoneDir(absolutePath, nextPrefix, files); + continue; + } + if (!entry.isFile()) continue; + const filename = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name; + if (!shouldSyncWorkspaceFilename(filename)) continue; const stat = await fsPromises.stat(absolutePath); files.push({ - filename: entry.name, + filename, absolutePath, sizeBytes: stat.size, mtimeMs: stat.mtimeMs, }); } +} + +export async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) { + if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return []; + const zoneDir = resolveZoneDir(workspaceRoot, categoryCode); + const files = []; + await walkWorkspaceZoneDir(zoneDir, '', files); return files; } export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idFactory }) { - const absoluteStoragePath = (storageKey) => { - const resolved = path.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) { - throw new Error('存储路径越界'); - } - return resolved; - }; - + void storageRoot; const loadExistingAssets = async (userId, categoryId) => { const [rows] = await pool.query( `SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status @@ -88,7 +131,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt const importWorkspaceFile = async (userId, category, file, buffer) => { const conn = await pool.getConnection(); - let finalPath; try { await conn.beginTransaction(); const [spaces] = await conn.query( @@ -123,17 +165,11 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); const assetId = idFactory(); const versionId = idFactory(); - const finalStorageKey = path.posix.join( - 'users', + const finalStorageKey = buildWorkspaceStorageKey( userId, - 'assets', - assetId, - 'versions', - versionId, + category.category_code, + file.filename, ); - finalPath = absoluteStoragePath(finalStorageKey); - await fsPromises.mkdir(path.dirname(finalPath), { recursive: true }); - await fsPromises.writeFile(finalPath, buffer, { flag: 'wx' }); const now = Date.now(); const visibility = category.category_code === 'public' ? 'public_candidate' : 'private'; @@ -188,7 +224,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt return { action: 'imported', assetId, filename: file.filename, checksum }; } catch (error) { await conn.rollback(); - if (finalPath) await fsPromises.rm(finalPath, { force: true }).catch(() => {}); throw error; } finally { conn.release(); @@ -197,7 +232,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt const updateWorkspaceFile = async (userId, category, existing, file, buffer) => { const conn = await pool.getConnection(); - let finalPath; try { await conn.beginTransaction(); const [spaces] = await conn.query( @@ -238,17 +272,11 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt ); const versionNo = asNumber(versionRows[0]?.max_version) + 1; const versionId = idFactory(); - const finalStorageKey = path.posix.join( - 'users', + const finalStorageKey = buildWorkspaceStorageKey( userId, - 'assets', - existing.id, - 'versions', - versionId, + category.category_code, + file.filename, ); - finalPath = absoluteStoragePath(finalStorageKey); - await fsPromises.mkdir(path.dirname(finalPath), { recursive: true }); - await fsPromises.writeFile(finalPath, buffer, { flag: 'wx' }); const now = Date.now(); await conn.query( @@ -298,7 +326,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt return { action: 'updated', assetId: existing.id, filename: file.filename, checksum }; } catch (error) { await conn.rollback(); - if (finalPath) await fsPromises.rm(finalPath, { force: true }).catch(() => {}); throw error; } finally { conn.release(); @@ -390,10 +417,20 @@ export function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceB const watchZoneDir = (dirKey, zoneDir, categoryCode) => { try { - fs.watch(zoneDir, (_event, filename) => { - if (!filename || !shouldSyncWorkspaceFilename(filename)) return; - schedule(dirKey, categoryCode); - }); + fs.watch( + zoneDir, + { recursive: true }, + (_event, filename) => { + if (!filename) { + schedule(dirKey, categoryCode); + return; + } + const normalized = String(filename).replace(/\\/g, '/'); + if (shouldSyncWorkspaceFilename(normalized)) { + schedule(dirKey, categoryCode); + } + }, + ); } catch { // ignore unsupported watch targets } diff --git a/mindspace-workspace-sync.test.mjs b/mindspace-workspace-sync.test.mjs index 02bafd1..09f19c5 100644 --- a/mindspace-workspace-sync.test.mjs +++ b/mindspace-workspace-sync.test.mjs @@ -12,22 +12,32 @@ import { resolveUserWorkspaceRoot } from './user-space.mjs'; test('shouldSyncWorkspaceFilename skips helper files', () => { assert.equal(shouldSyncWorkspaceFilename('端午感怀.docx'), true); + assert.equal(shouldSyncWorkspaceFilename('暑假实习报告/暑假生活实习报告.docx'), true); + assert.equal(shouldSyncWorkspaceFilename('诗歌散文/夏日的诗篇.md'), true); + assert.equal(shouldSyncWorkspaceFilename('sales-manager/README.md'), false); assert.equal(shouldSyncWorkspaceFilename('index.html'), false); assert.equal(shouldSyncWorkspaceFilename('note.thumbnail.svg'), false); + assert.equal(shouldSyncWorkspaceFilename('../escape.docx'), false); }); -test('listWorkspaceZoneFiles returns only supported top-level zone files', async () => { +test('listWorkspaceZoneFiles returns supported top-level and nested zone files', async () => { const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-')); const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' }); - await fs.mkdir(path.join(workspace, 'oa'), { recursive: true }); + await fs.mkdir(path.join(workspace, 'oa', '暑假实习报告'), { recursive: true }); + await fs.mkdir(path.join(workspace, 'oa', '诗歌散文'), { recursive: true }); await fs.writeFile(path.join(workspace, 'oa', 'report.csv'), 'a,b\n1,2\n'); await fs.writeFile(path.join(workspace, 'oa', 'index.html'), ''); await fs.writeFile(path.join(workspace, 'oa', 'note.txt'), 'hello'); + await fs.writeFile(path.join(workspace, 'oa', '诗歌散文', '夏日的诗篇.md'), '# 夏日的诗篇\n'); + await fs.writeFile( + path.join(workspace, 'oa', '暑假实习报告', '暑假生活实习报告.docx'), + 'fake docx', + ); const files = await listWorkspaceZoneFiles(workspace, 'oa'); assert.deepEqual( files.map((item) => item.filename).sort(), - ['note.txt', 'report.csv'], + ['note.txt', 'report.csv', '暑假实习报告/暑假生活实习报告.docx', '诗歌散文/夏日的诗篇.md'].sort(), ); }); @@ -145,6 +155,116 @@ test('syncUserWorkspace imports new workspace files into asset library', async ( assert.equal(state.assets.length, 1); assert.equal(state.assets[0].original_filename, 'memo.txt'); assert.ok(state.versions.length === 1); - const stored = path.join(storageRoot, state.versions[0].storage_key); - assert.equal(await fs.readFile(stored, 'utf8'), 'hello workspace\n'); + assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/oa\/memo\.txt$/); + assert.equal(await fs.readFile(path.join(workspace, 'oa', 'memo.txt'), 'utf8'), 'hello workspace\n'); +}); + +test('syncUserWorkspace imports nested workspace files into asset library', async () => { + const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-nested-')); + const storageRoot = path.join(h5Root, 'data', 'mindspace'); + const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' }); + await fs.mkdir(path.join(workspace, 'oa', '暑假实习报告'), { recursive: true }); + await fs.writeFile(path.join(workspace, 'oa', '暑假实习报告', 'report.csv'), 'a,b\n1,2\n'); + + const state = { + categories: [ + { id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' }, + ], + spaces: [ + { + id: 'space-1', + user_id: 'user-1', + quota_bytes: 5 * 1024 * 1024, + used_bytes: 0, + reserved_bytes: 0, + status: 'active', + }, + ], + assets: [], + versions: [], + }; + + let nextId = 0; + const pool = { + async query(sql, params = []) { + if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) { + const category = state.categories.find( + (item) => item.user_id === params[0] && item.category_code === params[1], + ); + return [category ? [category] : []]; + } + if (sql.includes('FROM h5_assets')) { + const assets = state.assets.filter((item) => { + if (item.user_id !== params[0] || item.category_id !== params[1]) return false; + if (sql.includes("status <> 'deleted'") && item.status === 'deleted') return false; + if (sql.includes("status = 'deleted'") && item.status !== 'deleted') return false; + if (sql.includes("source_type = 'workspace'") && item.source_type !== 'workspace') { + return false; + } + return true; + }); + return [assets]; + } + return [[]]; + }, + async getConnection() { + return { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + async query(sql, params = []) { + if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) { + const space = state.spaces.find( + (item) => item.id === params[0] && item.user_id === params[1], + ); + return [space ? [space] : []]; + } + if (sql.includes('INSERT INTO h5_assets')) { + state.assets.push({ + id: params[0], + user_id: params[1], + category_id: params[3], + original_filename: params[6], + checksum: params[10], + size_bytes: params[9], + current_version_id: params[8], + status: params[13], + source_type: 'workspace', + }); + return [[]]; + } + if (sql.includes('INSERT INTO h5_asset_versions')) { + state.versions.push({ + id: params[0], + asset_id: params[1], + storage_key: params[2], + scan_status: params[7], + }); + return [[]]; + } + if (sql.includes('used_bytes = used_bytes +')) { + state.spaces[0].used_bytes += params[0]; + return [[]]; + } + if (sql.includes('COALESCE(MAX(version_no)')) { + return [[{ max_version: 0 }]]; + } + return pool.query(sql, params); + }, + }; + }, + }; + + const sync = createWorkspaceAssetSync({ + pool, + storageRoot, + h5Root, + maxFileBytes: 1024 * 1024, + idFactory: () => `id-${++nextId}`, + }); + + const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'oa' }); + assert.equal(result.imported, 1); + assert.equal(state.assets[0].original_filename, '暑假实习报告/report.csv'); }); diff --git a/mindspace.mjs b/mindspace.mjs index a67c2c9..ef13928 100644 --- a/mindspace.mjs +++ b/mindspace.mjs @@ -179,7 +179,6 @@ export function createMindSpaceService(pool, options = {}) { ON a.category_id = c.id AND a.user_id = c.user_id AND a.status <> 'deleted' - AND a.source_type = 'upload' LEFT JOIN h5_page_records p ON p.category_id = c.id AND p.user_id = c.user_id AND p.status <> 'deleted' LEFT JOIN h5_publish_records pr diff --git a/mindspace.test.mjs b/mindspace.test.mjs index 813d07d..9f963e9 100644 --- a/mindspace.test.mjs +++ b/mindspace.test.mjs @@ -106,6 +106,7 @@ test('getSpace scopes space and categories to the authenticated user', async () assert.equal(space.categories[0].code, 'private'); assert.deepEqual(calls[0].params, ['user-1']); assert.deepEqual(calls[1].params, ['user-1', 'space-1']); + assert.doesNotMatch(calls[1].sql, /source_type\s*=\s*'upload'/); }); test('getSpace includes schedule snapshot when schedule service is available', async () => { diff --git a/src/components/MindSpaceView.tsx b/src/components/MindSpaceView.tsx index c5f7115..e1412a5 100644 --- a/src/components/MindSpaceView.tsx +++ b/src/components/MindSpaceView.tsx @@ -742,6 +742,7 @@ export function MindSpaceView({ setPendingDeleteId(null); setSelectedAssetIds([]); setImagePage(0); + void refreshSpaceQuietly(); routeSync?.pushHome(); }; diff --git a/user-space.mjs b/user-space.mjs index 9ee271a..1cfe401 100644 --- a/user-space.mjs +++ b/user-space.mjs @@ -106,8 +106,9 @@ ${zoneLines.join('\n')} ## 工作区文件与 OA 界面 -- 写入 \`oa/\`、\`private/\`、\`public/\` 根目录的支持类型文件(docx、csv、pdf、图片等)会**自动同步**到 MindSpace 资产库 +- 写入 \`oa/\`、\`private/\`、\`public/\` 下任意层级的支持类型文件(docx、md、txt、csv、pdf、图片等)会**自动同步**到 MindSpace 资产库(含子目录,如 \`oa/诗歌散文/夏日的诗篇.md\`) - 打开对应分区或保存文件后会出现在界面中,可直接预览或下载 +- 生成 docx/pdf 等 OA 资料时,可直接 \`write_file\` 到 \`oa/<子目录>/<文件名>\`,无需手动复制到根目录 - 用户上传的文件仍以界面入库为准,并镜像到上述分区 `; } diff --git a/workspace-storage.mjs b/workspace-storage.mjs new file mode 100644 index 0000000..9176bf3 --- /dev/null +++ b/workspace-storage.mjs @@ -0,0 +1,41 @@ +import path from 'node:path'; +import { resolveUserWorkspaceRoot, resolveZoneFilePath } from './user-space.mjs'; + +export const WORKSPACE_STORAGE_PREFIX = 'workspace://'; + +export function buildWorkspaceStorageKey(userId, categoryCode, relativeFilename) { + const category = String(categoryCode ?? '').trim(); + const relativePath = String(relativeFilename ?? '').replace(/\\/g, '/').replace(/^\/+/, ''); + if (!userId || !category || !relativePath) { + throw new Error('invalid workspace storage key parts'); + } + if (relativePath.split('/').some((part) => part === '..' || part === '.')) { + throw new Error('invalid workspace relative path'); + } + return `${WORKSPACE_STORAGE_PREFIX}${userId}/${category}/${relativePath}`; +} + +export function isWorkspaceStorageKey(storageKey) { + return String(storageKey ?? '').startsWith(WORKSPACE_STORAGE_PREFIX); +} + +export function resolveWorkspaceStoragePath(h5Root, storageKey) { + if (!h5Root || !isWorkspaceStorageKey(storageKey)) return null; + const rest = storageKey.slice(WORKSPACE_STORAGE_PREFIX.length); + const slash = rest.indexOf('/'); + if (slash <= 0) return null; + const userId = rest.slice(0, slash); + const remainder = rest.slice(slash + 1); + const slash2 = remainder.indexOf('/'); + if (slash2 <= 0) return null; + const categoryCode = remainder.slice(0, slash2); + const relativeFilename = remainder.slice(slash2 + 1); + if (!relativeFilename) return null; + const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); + const resolved = path.resolve(resolveZoneFilePath(workspaceRoot, categoryCode, relativeFilename)); + const zoneRoot = path.resolve(resolveZoneFilePath(workspaceRoot, categoryCode, '')); + if (resolved !== zoneRoot && !resolved.startsWith(`${zoneRoot}${path.sep}`)) { + return null; + } + return resolved; +}