vesta-maildir-import.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. import { createHash } from 'node:crypto';
  2. import { opendir, readFile, stat } from 'node:fs/promises';
  3. import path from 'node:path';
  4. import { parseInboundMessage } from './inbound-mail.js';
  5. import { decodeModifiedUtf7 } from './imap-utf7.js';
  6. export { decodeModifiedUtf7 };
  7. const standardFolders = new Map([
  8. ['inbox', 'INBOX'],
  9. ['sent', 'Sent'],
  10. ['sent items', 'Sent'],
  11. ['draft', 'Drafts'],
  12. ['drafts', 'Drafts'],
  13. ['trash', 'Trash'],
  14. ['deleted messages', 'Trash'],
  15. ['junk', 'Junk'],
  16. ['junk e-mail', 'Junk'],
  17. ['spam', 'Junk'],
  18. ['archive', 'Archive']
  19. ]);
  20. const standardMaildirFlags = new Map([
  21. ['D', '\\Draft'],
  22. ['F', '\\Flagged'],
  23. ['P', '\\Passed'],
  24. ['R', '\\Answered'],
  25. ['S', '\\Seen'],
  26. ['T', '\\Deleted']
  27. ]);
  28. /**
  29. * Imports a filesystem snapshot without coupling the reader to MailHub's DB.
  30. *
  31. * A writable adapter implements ensureDomain, ensureMailbox, hasMessage and
  32. * createMessage. hasMessage receives the stable sourceKey and makes reruns and
  33. * crash recovery idempotent. dryRun never invokes adapter methods.
  34. */
  35. export async function importVestaSnapshot({
  36. root,
  37. adapter = null,
  38. dryRun = false,
  39. vestaUser = '',
  40. userDataRoot = '',
  41. homeRoot = '',
  42. resumeAfterSourceKey = '',
  43. onCheckpoint = null,
  44. signal = null,
  45. parseMessage = parseInboundMessage
  46. } = {}) {
  47. if (!dryRun) validateAdapter(adapter);
  48. const snapshot = await readVestaSnapshotMetadata({ root, vestaUser, userDataRoot, homeRoot });
  49. const report = {
  50. dryRun: Boolean(dryRun),
  51. domains: snapshot.domains.length,
  52. mailboxes: snapshot.mailboxes.length,
  53. messages: 0,
  54. bytes: 0,
  55. plannedMessages: 0,
  56. importedMessages: 0,
  57. skippedMessages: 0,
  58. resumeSkippedMessages: 0,
  59. lastSourceKey: '',
  60. warnings: [...snapshot.warnings],
  61. warningCount: snapshot.warnings.length
  62. };
  63. const domainReferences = new Map();
  64. const mailboxReferences = new Map();
  65. const seenSourceKeys = new Set();
  66. let waitingForCheckpoint = Boolean(resumeAfterSourceKey);
  67. for (const domain of snapshot.domains) {
  68. abortIfNeeded(signal);
  69. const reference = dryRun ? domain : await adapter.ensureDomain(domain);
  70. domainReferences.set(domainKey(domain), reference ?? domain);
  71. }
  72. for (const mailbox of snapshot.mailboxes) {
  73. abortIfNeeded(signal);
  74. const domainReference = domainReferences.get(domainKey(mailbox));
  75. const mailboxReference = dryRun
  76. ? mailbox
  77. : await adapter.ensureMailbox(mailbox, { domain: domainReference });
  78. mailboxReferences.set(mailbox.address, mailboxReference ?? mailbox);
  79. if (!dryRun && typeof adapter.ensureFolder === 'function') {
  80. for (const folder of mailbox.folders || []) {
  81. await adapter.ensureFolder(mailboxReference ?? mailbox, folder, { sourceMailbox: mailbox });
  82. }
  83. }
  84. for await (const sourceMessage of iterateVestaMaildirMessages(mailbox, { signal })) {
  85. abortIfNeeded(signal);
  86. report.messages += 1;
  87. report.bytes += sourceMessage.rawMessageBytes.length;
  88. if (waitingForCheckpoint) {
  89. report.resumeSkippedMessages += 1;
  90. seenSourceKeys.add(sourceMessage.sourceKey);
  91. if (sourceMessage.sourceKey === resumeAfterSourceKey) {
  92. waitingForCheckpoint = false;
  93. report.lastSourceKey = sourceMessage.sourceKey;
  94. }
  95. continue;
  96. }
  97. if (seenSourceKeys.has(sourceMessage.sourceKey)) {
  98. report.skippedMessages += 1;
  99. continue;
  100. }
  101. seenSourceKeys.add(sourceMessage.sourceKey);
  102. const context = {
  103. domain: domainReference,
  104. mailbox: mailboxReferences.get(mailbox.address),
  105. sourceMailbox: mailbox
  106. };
  107. if (!dryRun && await adapter.hasMessage(sourceMessage.sourceKey, context)) {
  108. report.skippedMessages += 1;
  109. report.lastSourceKey = sourceMessage.sourceKey;
  110. await onCheckpoint?.(sourceMessage.sourceKey, { ...context, message: sourceMessage });
  111. continue;
  112. }
  113. let parsed = {};
  114. if (parseMessage) {
  115. try {
  116. parsed = await parseMessage(sourceMessage.rawMessageBytes, []);
  117. } catch {
  118. // Preserve the original message even when a malformed MIME structure
  119. // cannot be indexed. Protocol clients can still retrieve it verbatim.
  120. report.warningCount += 1;
  121. }
  122. }
  123. const message = {
  124. ...parsed,
  125. ...sourceMessage,
  126. recipients: parsed.recipients?.length ? parsed.recipients : [mailbox.address],
  127. receivedAt: sourceMessage.modifiedAt,
  128. read: sourceMessage.flags.includes('\\Seen')
  129. };
  130. if (dryRun) report.plannedMessages += 1;
  131. else {
  132. const result = await adapter.createMessage(message, context);
  133. if (result?.created === false) report.skippedMessages += 1;
  134. else report.importedMessages += 1;
  135. }
  136. report.lastSourceKey = sourceMessage.sourceKey;
  137. if (!dryRun) await onCheckpoint?.(sourceMessage.sourceKey, { ...context, message });
  138. }
  139. }
  140. if (waitingForCheckpoint) {
  141. throw new Error(`Vesta 导入断点不存在:${resumeAfterSourceKey}`);
  142. }
  143. return report;
  144. }
  145. export async function readVestaSnapshotMetadata({
  146. root,
  147. vestaUser = '',
  148. userDataRoot = '',
  149. homeRoot = ''
  150. } = {}) {
  151. const snapshotRoot = requireSnapshotRoot(root);
  152. const userLocations = await findVestaUserLocations(snapshotRoot, { vestaUser, userDataRoot });
  153. if (!userLocations.length) throw new Error('Vesta 快照中未找到 mail.conf。');
  154. const domains = [];
  155. const mailboxes = [];
  156. const warnings = [];
  157. for (const location of userLocations) {
  158. const domainRecords = await readConfigRecords(path.join(location.userDataDir, 'mail.conf'));
  159. const home = await resolveVestaHome(snapshotRoot, location.vestaUser, homeRoot);
  160. for (const config of domainRecords) {
  161. if (!config.DOMAIN) continue;
  162. const name = String(config.DOMAIN).trim().toLowerCase();
  163. const catchAll = normalizeAddress(config.CATCHALL, name);
  164. const domain = {
  165. source: 'vesta',
  166. vestaUser: location.vestaUser,
  167. name,
  168. domain: name,
  169. catchAll,
  170. catchAllAddress: catchAll,
  171. suspended: isYes(config.SUSPENDED),
  172. status: isYes(config.SUSPENDED) ? 'suspended' : 'active',
  173. config,
  174. sourceFile: path.join(location.userDataDir, 'mail.conf')
  175. };
  176. domains.push(domain);
  177. const accountFile = path.join(location.userDataDir, 'mail', `${name}.conf`);
  178. const accountRecords = await readConfigRecords(accountFile, { optional: true });
  179. const passwdFile = home.confMailRoot ? path.join(home.confMailRoot, name, 'passwd') : '';
  180. const passwdRecords = passwdFile
  181. ? await readVestaPasswd(passwdFile, { optional: true })
  182. : [];
  183. const accounts = mergeAccountRecords(accountRecords, passwdRecords);
  184. if (!accounts.length) warnings.push(`域名 ${name} 没有邮箱账户元数据。`);
  185. for (const account of accounts) {
  186. const localPart = String(account.ACCOUNT || account.account || '').trim().toLowerCase();
  187. if (!localPart) continue;
  188. const passwordHash = String(account.passwordHash || account.MD5 || '').trim();
  189. const maildirPath = home.mailRoot
  190. ? path.join(home.mailRoot, name, localPart)
  191. : '';
  192. const address = `${localPart}@${name}`;
  193. const maildirAvailable = Boolean(maildirPath) && await isDirectory(maildirPath);
  194. const folders = maildirAvailable
  195. ? (await listMaildirFolders(maildirPath)).map((folder) => folder.name)
  196. : [];
  197. if (!passwordHash) warnings.push(`邮箱 ${address} 没有可迁移密码哈希。`);
  198. if (!maildirAvailable) warnings.push(`邮箱 ${address} 没有 Maildir,仍会导入账户。`);
  199. mailboxes.push({
  200. source: 'vesta',
  201. vestaUser: location.vestaUser,
  202. domain: name,
  203. localPart,
  204. address,
  205. displayName: '',
  206. aliases: normalizeAddressList(account.ALIAS, name),
  207. forwardTo: normalizeAddressList(account.FWD),
  208. forwardOnly: isYes(account.FWD_ONLY),
  209. keepForwarded: !isYes(account.FWD_ONLY),
  210. quotaMb: normalizeQuota(account.QUOTA ?? account.quota),
  211. suspended: isYes(account.SUSPENDED),
  212. status: isYes(account.SUSPENDED) ? 'suspended' : 'active',
  213. legacyPasswordHash: passwordHash,
  214. passwordHash,
  215. passwordScheme: passwordScheme(passwordHash),
  216. createdAt: vestaTimestamp(account.DATE, account.TIME),
  217. maildirPath,
  218. maildirAvailable,
  219. folders,
  220. config: account,
  221. sourceFile: accountFile,
  222. passwordSourceFile: account.passwordSourceFile || ''
  223. });
  224. }
  225. }
  226. }
  227. domains.sort((left, right) => compareText(domainKey(left), domainKey(right)));
  228. mailboxes.sort((left, right) => compareText(left.address, right.address));
  229. return { root: snapshotRoot, domains, mailboxes, warnings };
  230. }
  231. export async function* iterateVestaMaildirMessages(mailbox, { signal = null } = {}) {
  232. if (!mailbox?.maildirPath || !await isDirectory(mailbox.maildirPath)) return;
  233. const folders = await listMaildirFolders(mailbox.maildirPath);
  234. for (const folder of folders) {
  235. const keywordMap = await readDovecotKeywords(folder.path, mailbox.maildirPath);
  236. for (const bucket of ['new', 'cur']) {
  237. const directory = path.join(folder.path, bucket);
  238. const entries = await readDirectoryEntries(directory);
  239. for (const entry of entries.filter((item) => item.isFile()).sort(compareDirents)) {
  240. abortIfNeeded(signal);
  241. const filePath = path.join(directory, entry.name);
  242. const [rawMessageBytes, fileStat] = await Promise.all([readFile(filePath), stat(filePath)]);
  243. const flagInfo = parseMaildirFlags(entry.name, keywordMap);
  244. const contentSha256 = createHash('sha256').update(rawMessageBytes).digest('hex');
  245. const sourceKey = createVestaMessageSourceKey({
  246. address: mailbox.address,
  247. folder: folder.name,
  248. fileName: entry.name,
  249. contentSha256
  250. });
  251. yield {
  252. source: 'vesta-maildir',
  253. sourceKey,
  254. sourcePath: path.relative(mailbox.maildirPath, filePath).split(path.sep).join('/'),
  255. fileName: entry.name,
  256. folder: folder.name,
  257. rawMessageBytes,
  258. contentSha256,
  259. modifiedAt: fileStat.mtime.toISOString(),
  260. size: fileStat.size,
  261. flags: flagInfo.flags,
  262. keywords: flagInfo.keywords,
  263. maildirFlags: flagInfo.raw
  264. };
  265. }
  266. }
  267. }
  268. }
  269. export function createVestaMessageSourceKey({ address, folder, fileName, contentSha256 }) {
  270. const stableFileName = String(fileName || '').replace(/:2,[^/]*$/, '');
  271. const source = [
  272. 'vesta-maildir-v1',
  273. String(address || '').trim().toLowerCase(),
  274. normalizeFolder(folder),
  275. stableFileName,
  276. String(contentSha256 || '').toLowerCase()
  277. ].join('\0');
  278. return `vesta-maildir-v1:${createHash('sha256').update(source).digest('hex')}`;
  279. }
  280. export function parseVestaConfigLine(line) {
  281. const source = String(line || '');
  282. const output = {};
  283. let index = 0;
  284. while (index < source.length) {
  285. while (/\s/.test(source[index] || '')) index += 1;
  286. if (!source[index] || source[index] === '#') break;
  287. const keyMatch = source.slice(index).match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
  288. if (!keyMatch) {
  289. while (source[index] && !/\s/.test(source[index])) index += 1;
  290. continue;
  291. }
  292. const key = keyMatch[1];
  293. index += keyMatch[0].length;
  294. let value = '';
  295. const quote = source[index] === "'" || source[index] === '"' ? source[index++] : '';
  296. if (quote) {
  297. while (index < source.length && source[index] !== quote) {
  298. if (quote === '"' && source[index] === '\\' && index + 1 < source.length) index += 1;
  299. value += source[index++];
  300. }
  301. if (source[index] === quote) index += 1;
  302. } else {
  303. while (source[index] && !/\s/.test(source[index])) value += source[index++];
  304. }
  305. output[key] = value;
  306. }
  307. return output;
  308. }
  309. export async function readVestaPasswd(filePath, { optional = false } = {}) {
  310. const content = await readTextFile(filePath, { optional });
  311. if (content === null) return [];
  312. return content.split(/\r?\n/).map((line) => {
  313. const clean = line.trim();
  314. if (!clean || clean.startsWith('#')) return null;
  315. const fields = clean.split(':');
  316. if (!fields[0] || !fields[1]) return null;
  317. return {
  318. account: fields[0].trim().toLowerCase(),
  319. passwordHash: fields[1].trim(),
  320. user: fields[2] || '',
  321. group: fields[3] || '',
  322. home: fields[5] || '',
  323. quota: fields[6] || '',
  324. passwordSourceFile: filePath
  325. };
  326. }).filter(Boolean);
  327. }
  328. function validateAdapter(adapter) {
  329. for (const method of ['ensureDomain', 'ensureMailbox', 'hasMessage', 'createMessage']) {
  330. if (typeof adapter?.[method] !== 'function') throw new TypeError(`Vesta 导入 adapter 缺少 ${method}()。`);
  331. }
  332. }
  333. async function findVestaUserLocations(root, { vestaUser, userDataRoot }) {
  334. const locations = [];
  335. const explicitRoot = userDataRoot ? path.resolve(userDataRoot) : '';
  336. const bases = explicitRoot
  337. ? [explicitRoot]
  338. : [
  339. path.join(root, 'usr/local/vesta/data/users'),
  340. path.join(root, 'usr/local/hestia/data/users'),
  341. path.join(root, 'vesta/data/users'),
  342. path.join(root, 'data/users'),
  343. path.join(root, 'users')
  344. ];
  345. if (await isFile(path.join(root, 'mail.conf'))) {
  346. locations.push({ vestaUser: vestaUser || path.basename(root), userDataDir: root });
  347. }
  348. for (const base of bases) {
  349. if (await isFile(path.join(base, 'mail.conf'))) {
  350. const user = vestaUser || path.basename(base);
  351. locations.push({ vestaUser: user, userDataDir: base });
  352. continue;
  353. }
  354. for (const entry of await readDirectoryEntries(base)) {
  355. if (!entry.isDirectory() || (vestaUser && entry.name !== vestaUser)) continue;
  356. const userDataDir = path.join(base, entry.name);
  357. if (await isFile(path.join(userDataDir, 'mail.conf'))) {
  358. locations.push({ vestaUser: entry.name, userDataDir });
  359. }
  360. }
  361. }
  362. const unique = new Map(locations.map((location) => [path.resolve(location.userDataDir), location]));
  363. return [...unique.values()].sort((left, right) => compareText(left.vestaUser, right.vestaUser));
  364. }
  365. async function resolveVestaHome(root, vestaUser, homeRoot) {
  366. const explicitRoot = homeRoot ? path.resolve(homeRoot) : '';
  367. const homeCandidates = explicitRoot
  368. ? [path.join(explicitRoot, vestaUser), explicitRoot]
  369. : [path.join(root, 'home', vestaUser), path.join(root, vestaUser)];
  370. for (const homeDirectory of homeCandidates) {
  371. if (!await isDirectory(homeDirectory)) continue;
  372. return {
  373. homeDirectory,
  374. mailRoot: await isDirectory(path.join(homeDirectory, 'mail')) ? path.join(homeDirectory, 'mail') : '',
  375. confMailRoot: await isDirectory(path.join(homeDirectory, 'conf/mail')) ? path.join(homeDirectory, 'conf/mail') : ''
  376. };
  377. }
  378. return { homeDirectory: '', mailRoot: '', confMailRoot: '' };
  379. }
  380. async function readConfigRecords(filePath, { optional = false } = {}) {
  381. const content = await readTextFile(filePath, { optional });
  382. if (content === null) return [];
  383. return content.split(/\r?\n/)
  384. .map(parseVestaConfigLine)
  385. .filter((record) => Object.keys(record).length);
  386. }
  387. function mergeAccountRecords(accountRecords, passwdRecords) {
  388. const records = new Map();
  389. for (const config of accountRecords) {
  390. const account = String(config.ACCOUNT || '').trim().toLowerCase();
  391. if (account) records.set(account, { ...config, ACCOUNT: account });
  392. }
  393. for (const passwd of passwdRecords) {
  394. const previous = records.get(passwd.account) || { ACCOUNT: passwd.account };
  395. records.set(passwd.account, {
  396. ...previous,
  397. passwordHash: passwd.passwordHash || previous.MD5 || '',
  398. passwordSourceFile: passwd.passwordSourceFile,
  399. quota: passwd.quota
  400. });
  401. }
  402. return [...records.values()].sort((left, right) => compareText(left.ACCOUNT, right.ACCOUNT));
  403. }
  404. async function listMaildirFolders(maildirPath) {
  405. const folders = [{ name: 'INBOX', path: maildirPath }];
  406. for (const entry of (await readDirectoryEntries(maildirPath)).sort(compareDirents)) {
  407. if (!entry.isDirectory() || !entry.name.startsWith('.') || entry.name === '.') continue;
  408. const folderPath = path.join(maildirPath, entry.name);
  409. if (!await hasMaildirMessagesDirectory(folderPath)) continue;
  410. const segments = entry.name.slice(1).split('.').filter(Boolean).map(decodeModifiedUtf7);
  411. if (segments[0]?.toLowerCase() === 'inbox') segments.shift();
  412. const name = normalizeFolder(segments.join('/'));
  413. if (name && name !== 'INBOX') folders.push({ name, path: folderPath });
  414. }
  415. return folders.sort((left, right) => compareText(left.name, right.name));
  416. }
  417. async function hasMaildirMessagesDirectory(directory) {
  418. return await isDirectory(path.join(directory, 'cur')) || await isDirectory(path.join(directory, 'new'));
  419. }
  420. async function readDovecotKeywords(folderPath, maildirPath) {
  421. const mapping = new Map();
  422. for (const filePath of [...new Set([
  423. path.join(maildirPath, 'dovecot-keywords'),
  424. path.join(folderPath, 'dovecot-keywords')
  425. ])]) {
  426. const content = await readTextFile(filePath, { optional: true });
  427. if (content === null) continue;
  428. for (const line of content.split(/\r?\n/)) {
  429. const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
  430. const index = Number(match?.[1]);
  431. if (!match || !Number.isInteger(index) || index < 0 || index > 25) continue;
  432. mapping.set(String.fromCharCode(97 + index), match[2]);
  433. }
  434. }
  435. return mapping;
  436. }
  437. function parseMaildirFlags(fileName, keywordMap) {
  438. const raw = String(fileName || '').match(/:2,([^/]*)$/)?.[1] || '';
  439. const flags = [];
  440. const keywords = [];
  441. for (const flag of raw) {
  442. if (standardMaildirFlags.has(flag)) flags.push(standardMaildirFlags.get(flag));
  443. else if (/[a-z]/.test(flag)) keywords.push(keywordMap.get(flag) || flag);
  444. else flags.push(flag);
  445. }
  446. return { raw, flags: [...new Set(flags)], keywords: [...new Set(keywords)] };
  447. }
  448. function normalizeFolder(value) {
  449. const clean = String(value || '').trim().replace(/^\/+|\/+$/g, '');
  450. const standard = standardFolders.get(clean.toLowerCase());
  451. return standard || clean || 'INBOX';
  452. }
  453. function normalizeAddress(value, domain = '') {
  454. const clean = String(value || '').trim().toLowerCase();
  455. if (!clean || ['no', 'none', 'reject', ':fail:'].includes(clean)) return '';
  456. if (['blackhole', ':blackhole:'].includes(clean)) return '/dev/null';
  457. return clean.includes('@') || !domain ? clean : `${clean}@${domain}`;
  458. }
  459. function normalizeAddressList(value, domain = '') {
  460. return [...new Set(String(value || '')
  461. .split(/[\s,;]+/)
  462. .map((item) => normalizeAddress(item, domain))
  463. .filter(Boolean))];
  464. }
  465. function normalizeQuota(value) {
  466. const clean = String(value ?? '').trim().toLowerCase();
  467. if (!clean || clean === '0' || clean === 'unlimited') return null;
  468. const quota = Number(clean);
  469. return Number.isFinite(quota) && quota >= 0 ? quota : null;
  470. }
  471. function passwordScheme(value) {
  472. const clean = String(value || '').trim();
  473. if (/^(?:\{(?:MD5|MD5-CRYPT)\})?\$1\$/i.test(clean)) return 'md5-crypt';
  474. if (/^\{[^}]+\}/.test(clean)) return clean.slice(1, clean.indexOf('}')).toLowerCase();
  475. return clean ? 'unknown' : '';
  476. }
  477. function vestaTimestamp(date, time) {
  478. const cleanDate = String(date || '').trim();
  479. const cleanTime = String(time || '').trim();
  480. return cleanDate ? `${cleanDate}${cleanTime ? `T${cleanTime}` : ''}` : null;
  481. }
  482. function isYes(value) {
  483. return ['yes', 'true', '1', 'on'].includes(String(value || '').trim().toLowerCase());
  484. }
  485. function domainKey(value) {
  486. return `${value.vestaUser || ''}\0${value.domain || value.name || ''}`;
  487. }
  488. function requireSnapshotRoot(root) {
  489. const clean = String(root || '').trim();
  490. if (!clean) throw new TypeError('Vesta 快照 root 不能为空。');
  491. return path.resolve(clean);
  492. }
  493. async function readTextFile(filePath, { optional = false } = {}) {
  494. try {
  495. return await readFile(filePath, 'utf8');
  496. } catch (error) {
  497. if (optional && error?.code === 'ENOENT') return null;
  498. throw error;
  499. }
  500. }
  501. async function readDirectoryEntries(directory) {
  502. try {
  503. const entries = [];
  504. const handle = await opendir(directory);
  505. for await (const entry of handle) entries.push(entry);
  506. return entries;
  507. } catch (error) {
  508. if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return [];
  509. throw error;
  510. }
  511. }
  512. async function isFile(filePath) {
  513. try {
  514. return (await stat(filePath)).isFile();
  515. } catch (error) {
  516. if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false;
  517. throw error;
  518. }
  519. }
  520. async function isDirectory(directory) {
  521. try {
  522. return (await stat(directory)).isDirectory();
  523. } catch (error) {
  524. if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false;
  525. throw error;
  526. }
  527. }
  528. function compareDirents(left, right) {
  529. return compareText(left.name, right.name);
  530. }
  531. function compareText(left, right) {
  532. return left < right ? -1 : left > right ? 1 : 0;
  533. }
  534. function abortIfNeeded(signal) {
  535. if (signal?.aborted) throw signal.reason || new Error('Vesta 导入已取消。');
  536. }