fix(mindspace): sync nested OA files and count workspace assets
Recursive workspace sync now imports files in oa/private/public subfolders, references workspace paths without duplicating storage, and includes workspace-sourced assets in category itemCount. Refresh counts when leaving a category view. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user