@xuda.io/drive_module 1.1.1499 → 1.1.1501

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -5,6 +5,7 @@ import fse from 'fs-extra';
5
5
  import unzipper from 'unzipper';
6
6
  import fs from 'fs';
7
7
  import os from 'os';
8
+ import child_process from 'child_process';
8
9
  import tesseract from 'node-tesseract-ocr';
9
10
  import countryLanguage from 'country-language';
10
11
  import url from 'url';
@@ -29,6 +30,10 @@ const { get_account_default_project_id } = await import(path.join(process.env.XU
29
30
  const module_path = path.join(process.env.XUDA_HOME, 'cpi') + (!_conf.is_debug ? '/node_modules/@xuda.io' : '');
30
31
  const fs_module = await import(`${module_path}/fs_module/index.mjs`);
31
32
  const db_module = await import(`${module_path}/db_module/index.mjs`);
33
+ // UI-113: the Drive storage ladder is one line item on the consolidated
34
+ // subscription, so drive_set_plan drives Stripe the same way every other module
35
+ // tier does (add_subscription_item to buy or reprice, remove on the free tier).
36
+ const stripe_ms = await import(`${module_path}/stripe_module/index_ms.mjs`);
32
37
 
33
38
  // const account_msa = await import(`${module_path}/account_module/index_msa.mjs`);
34
39
 
@@ -222,13 +227,15 @@ export const get_drive_files = async (req) => {
222
227
  }
223
228
  try {
224
229
  files.sizeInBytes += doc.size;
230
+ const access_link = `https://${domain}/${drive_type}-drive/${datasource_id || app_id_reference || uid}/${doc._id}${ts}`;
225
231
  files.children.push({
226
232
  name: doc.originalname,
227
233
  sizeInBytes: doc.size,
228
234
  size: formatBytes(doc.size),
229
235
  extension: doc?.file_ext?.substr(1),
230
236
  type: doc.type,
231
- access_link: `https://${domain}/${drive_type}-drive/${datasource_id || app_id_reference || uid}/${doc._id}${ts}`,
237
+ access_link,
238
+ ...preview_links_for(doc, access_link),
232
239
  is_archive: doc.is_archive,
233
240
  public: doc.public,
234
241
  path: doc.file_path,
@@ -672,6 +679,9 @@ export const update_drive_file = async (req, job_id, headers, file_obj) => {
672
679
 
673
680
  doc = { ...doc, ...(await get_file_info(tempPath)) };
674
681
 
682
+ // New bytes, so any preview on the doc now shows the old content. Re-queue it.
683
+ if (is_office_preview_ext(doc.file_ext)) doc.preview_stat = 1;
684
+
675
685
  let save_ret;
676
686
  let ref;
677
687
  switch (drive_type) {
@@ -722,6 +732,10 @@ export const update_drive_file = async (req, job_id, headers, file_obj) => {
722
732
 
723
733
  let file_url = get_file_url(drive_type, file_name, ref);
724
734
 
735
+ if (doc.preview_stat === 1) {
736
+ generate_drive_file_preview({ app_id, drive_type, uid, doc_id: doc._id }).catch(() => {});
737
+ }
738
+
725
739
  return {
726
740
  code: 760,
727
741
  data: {
@@ -1888,6 +1902,9 @@ export const upload_drive_file = async (req, job_id, headers = {}, file_obj) =>
1888
1902
  drive_type,
1889
1903
  mime: file_obj.mimetype,
1890
1904
  ocr_stat: ['image', 'application', 'text'].includes(file_obj.mimetype.split('/')[0]) ? 1 : 0,
1905
+ // studio drive keeps its files on local disk rather than in a bucket, and the
1906
+ // preview reader fetches from the bucket, so it is not a candidate.
1907
+ preview_stat: is_office_preview_ext(ext) && drive_type !== 'studio' ? 1 : 0,
1891
1908
  target_file,
1892
1909
  file_path: file_path || '/',
1893
1910
  size: stat.isFile() ? stat.size : 0,
@@ -2016,6 +2033,13 @@ export const upload_drive_file = async (req, job_id, headers = {}, file_obj) =>
2016
2033
 
2017
2034
  await fs_module.unlink(tempPath);
2018
2035
 
2036
+ // Office files get a rendered preview. Fire and forget: converting takes seconds and
2037
+ // an upload must not wait for it, nor fail because of it. The doc already carries
2038
+ // preview_stat 1, so if this box never gets to it the sweep will.
2039
+ if (doc.preview_stat === 1) {
2040
+ generate_drive_file_preview({ app_id: req.app_id, drive_type, uid, doc_id: doc._id }).catch(() => {});
2041
+ }
2042
+
2019
2043
  return {
2020
2044
  code: 760,
2021
2045
  data: {
@@ -2347,19 +2371,100 @@ export const check_drive_file = async (req, job_id, headers, file_obj) => {
2347
2371
  return ret.files[0];
2348
2372
  };
2349
2373
 
2350
- const ocr_drive_file = async (app_id, doc) => {
2351
- const { drive_type, uid, file_path, originalname } = doc;
2374
+ // Which languages tesseract on THIS box actually has traineddata for. Asked once and
2375
+ // cached: it shells out, and the answer cannot change while the process lives.
2376
+ let _tesseract_langs_cache;
2377
+ const tesseract_langs = () => {
2378
+ if (_tesseract_langs_cache) return _tesseract_langs_cache;
2379
+ try {
2380
+ const out = child_process.execFileSync('tesseract', ['--list-langs'], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] });
2381
+ // First line is a "List of available languages (N):" header, which has spaces in it.
2382
+ const langs = out.split('\n').map((s) => s.trim()).filter((s) => s && !s.includes(' '));
2383
+ _tesseract_langs_cache = new Set(langs.length ? langs : ['eng']);
2384
+ } catch (err) {
2385
+ console.warn('[drive_module] tesseract --list-langs failed, assuming eng only:', err.message);
2386
+ _tesseract_langs_cache = new Set(['eng']);
2387
+ }
2388
+ return _tesseract_langs_cache;
2389
+ };
2390
+
2391
+ // Pick the OCR languages for a file, from the country its upload came from.
2392
+ //
2393
+ // Two traps, both of which used to fail the whole OCR run:
2394
+ // - country-language's getCountry REQUIRES a callback and throws "cb is not a function"
2395
+ // for a missing or unknown code. doc.country comes from the cf-ipcountry header, which
2396
+ // is absent on EVERY server-side write (AI output, plugin saves, restores), so this
2397
+ // threw for most files and marked them ocr_stat 4.
2398
+ // - a language whose traineddata is not installed makes tesseract exit with "Error during
2399
+ // processing", losing the English pass along with it. So only offer what is on the box.
2400
+ const detectLanguageByCountry = (cfCountry) => {
2401
+ const have = tesseract_langs();
2402
+ const langs = ['eng'];
2403
+ try {
2404
+ const code = String(cfCountry || '').trim().toUpperCase();
2405
+ const countryData = code ? countryLanguage.getCountry(code) : null;
2406
+ for (const language of countryData?.languages || []) {
2407
+ const iso = language?.iso639_2;
2408
+ if (iso && have.has(iso) && !langs.includes(iso)) langs.push(iso);
2409
+ }
2410
+ } catch (err) {
2411
+ // Unknown or missing country: English only, which is the right default and is what
2412
+ // the caller wanted all along.
2413
+ }
2414
+ // Each extra language costs real time on every page, and the tail of a country's list
2415
+ // is rarely what the document is written in.
2416
+ return langs.slice(0, 4).join('+');
2417
+ };
2352
2418
 
2353
- let target_file = get_file_url(drive_type, path.join(file_path, doc._id), app_id || uid);
2419
+ // Put a drive file's bytes on local disk and hand back the path. EVERY OCR reader below
2420
+ // wants a real file: tesseract streams one, mammoth and node-xlsx take a `path`, pdf2image
2421
+ // rasterises one. They were being handed a URL instead, and a wrong URL at that (built
2422
+ // with the uid where the route wants the project id, and with the folder baked into the
2423
+ // filename), so the fetch 404'd and tesseract failed with "Error during processing" while
2424
+ // the text branch quietly stored an ENOENT string as the document's OCR text.
2425
+ //
2426
+ // The bytes are already in the region bucket, so read them straight from there and skip
2427
+ // HTTP entirely. Only a drive with no bucket entry (a datacenter workspace drive, whose
2428
+ // files are scp'd to the hosting box) still needs the round trip, and that one now uses a
2429
+ // URL built the same way the drive listing builds it.
2430
+ const ocr_file_to_tmp = async (app_id, doc) => {
2431
+ const write_tmp = async (buffer) => {
2432
+ const tmp_path = path.join(os.tmpdir(), `xuda_ocr_${await _common.xuda_get_uuid('tmp')}${doc.file_ext || path.extname(doc.originalname || '') || ''}`);
2433
+ await fs.promises.writeFile(tmp_path, buffer);
2434
+ return tmp_path;
2435
+ };
2436
+
2437
+ const region = _pick_file_region(doc);
2438
+ const { Bucket, Key } = _drive_bucket_for(doc, region);
2439
+ const s3 = region ? _drive_s3_for(region) : null;
2354
2440
 
2355
- function addQueryParam(urlString, key, value) {
2356
- const parsedUrl = new url.URL(urlString);
2357
- parsedUrl.searchParams.set(key, value);
2358
- return parsedUrl.toString();
2441
+ if (s3 && Bucket && Key) {
2442
+ const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
2443
+ return await write_tmp(await _stream_to_buffer(obj.Body));
2359
2444
  }
2360
2445
 
2361
- target_file = addQueryParam(target_file, 'xuda_internal_request_code', _conf.xuda_internal_request_code);
2446
+ const ref = doc.drive_type === 'user' ? await get_account_default_project_id(doc.uid) : await _common.get_project_app_id(app_id);
2447
+ const target = new url.URL(get_file_url(doc.drive_type, doc._id, ref));
2448
+ target.searchParams.set('xuda_internal_request_code', _conf.xuda_internal_request_code);
2449
+
2450
+ const res = await fetch(target.toString());
2451
+ if (!res.ok) throw new Error(`file unavailable (${res.status})`);
2452
+ return await write_tmp(Buffer.from(await res.arrayBuffer()));
2453
+ };
2362
2454
 
2455
+ // read_file answers { code, data } and puts the ERROR MESSAGE in data when it fails, so
2456
+ // reading it blindly stored strings like "ENOENT: no such file" as the document's own
2457
+ // searchable text. A failed read has no text in it, and saying so is the honest answer.
2458
+ const read_text_for_ocr = async (file_path) => {
2459
+ const ret = await fs_module.read_file(file_path);
2460
+ if (!ret || ret.code < 0) throw new Error(`could not read file for ocr: ${ret?.data || 'unknown error'}`);
2461
+ return String(ret.data || '');
2462
+ };
2463
+
2464
+ const ocr_drive_file = async (app_id, doc) => {
2465
+ const { drive_type, uid, file_path, originalname } = doc;
2466
+
2467
+ let target_file;
2363
2468
  let save_ret;
2364
2469
  try {
2365
2470
  doc.ocr_stat = 2;
@@ -2368,24 +2473,19 @@ const ocr_drive_file = async (app_id, doc) => {
2368
2473
 
2369
2474
  doc = await get_drive_doc('file', drive_type, app_id, uid, path.join(file_path, originalname));
2370
2475
 
2371
- let ocr = '';
2476
+ target_file = await ocr_file_to_tmp(app_id, doc);
2372
2477
 
2373
- function detectLanguageByCountry(cfCountry) {
2374
- const countryData = countryLanguage.getCountry(cfCountry);
2375
- let lang = 'eng';
2376
- if (countryData && countryData.languages && countryData.languages.length > 0) {
2377
- for (let language of countryData.languages) {
2378
- lang += `+${language.iso639_2}`;
2379
- }
2380
- }
2381
- return lang;
2382
- }
2478
+ let ocr = '';
2383
2479
 
2384
2480
  let lang = detectLanguageByCountry(doc.country);
2385
2481
 
2386
2482
  const generalConfig = { oem: 1, psm: 3, lang };
2387
2483
 
2388
- switch (doc.mime.split('/')[0]) {
2484
+ // A doc written before mime was recorded would throw on .split here and be marked
2485
+ // failed for ever. Fall back to the extension, which every drive doc carries.
2486
+ const mime = doc.mime || _drive_content_type_for(doc.originalname || '') || '';
2487
+
2488
+ switch (mime.split('/')[0]) {
2389
2489
  case 'image': {
2390
2490
  ocr = await tesseract.recognize(target_file, generalConfig);
2391
2491
 
@@ -2393,7 +2493,7 @@ const ocr_drive_file = async (app_id, doc) => {
2393
2493
  }
2394
2494
 
2395
2495
  case 'application': {
2396
- switch (doc.mime.split('/')[1]) {
2496
+ switch (mime.split('/')[1]) {
2397
2497
  case 'pdf': {
2398
2498
  const images = await convertPDF(target_file);
2399
2499
  for await (const imageData of images) {
@@ -2407,7 +2507,10 @@ const ocr_drive_file = async (app_id, doc) => {
2407
2507
  break;
2408
2508
  }
2409
2509
 
2510
+ // 'nd.ms-excel' was a typo for the real .xls mime, so no legacy spreadsheet
2511
+ // ever reached this branch. Both spellings kept, the wrong one costs nothing.
2410
2512
  case 'nd.ms-excel':
2513
+ case 'vnd.ms-excel':
2411
2514
  case 'vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
2412
2515
  const sheets = xlsx.parse(target_file);
2413
2516
  sheets.forEach((sheet) => {
@@ -2425,7 +2528,7 @@ const ocr_drive_file = async (app_id, doc) => {
2425
2528
  break;
2426
2529
 
2427
2530
  default:
2428
- ocr = (await fs_module.read_file(target_file)).data;
2531
+ ocr = await read_text_for_ocr(target_file);
2429
2532
  break;
2430
2533
  }
2431
2534
  break;
@@ -2450,12 +2553,286 @@ const ocr_drive_file = async (app_id, doc) => {
2450
2553
  doc.ocr_stat = 4;
2451
2554
  doc.ocr_error = err.message;
2452
2555
  } finally {
2556
+ // The bytes were copied here only so a reader could open them.
2557
+ if (target_file) await fs.promises.unlink(target_file).catch(() => {});
2453
2558
  doc.ocr_stat_ts = Date.now();
2454
2559
 
2455
2560
  save_ret = await save_drive_doc(drive_type, uid, app_id, doc, file_path);
2456
2561
  }
2457
2562
  };
2458
2563
 
2564
+ // ── Office previews ────────────────────────────────────────────────────────────
2565
+ // A deck, a document or a spreadsheet had no preview anywhere in the Drive: the
2566
+ // thumbnail was a grey category tile and the lightbox said "No preview available".
2567
+ // So render one. Each office file gets two derived assets stored beside it:
2568
+ //
2569
+ // pdf - the whole file, which the lightbox already knows how to show in an iframe
2570
+ // png - page/slide one, which every thumbnail in the app can draw as an image
2571
+ //
2572
+ // The conversion needs LibreOffice on the box. Where it is absent the file simply
2573
+ // keeps its category tile, exactly as before, and preview_stat records that so the
2574
+ // sweep does not retry a box that can never succeed.
2575
+ //
2576
+ // preview_stat: 0 not applicable / unsupported here, 1 queued, 2 running, 3 done, 4 failed.
2577
+ const PREVIEW_OFFICE_EXTS = ['.pptx', '.ppt', '.docx', '.doc', '.xlsx', '.xls', '.odp', '.odt', '.ods', '.rtf'];
2578
+
2579
+ const is_office_preview_ext = (ext) => PREVIEW_OFFICE_EXTS.includes(String(ext || '').toLowerCase());
2580
+
2581
+ // Resolved once per process. `null` means "looked and it is not here", which is a
2582
+ // real answer and must not be retried on every file.
2583
+ let _soffice_bin_cache;
2584
+ const soffice_bin = () => {
2585
+ if (_soffice_bin_cache !== undefined) return _soffice_bin_cache;
2586
+ _soffice_bin_cache = ['/usr/bin/soffice', '/usr/bin/libreoffice', '/usr/lib/libreoffice/program/soffice'].find((p) => {
2587
+ try {
2588
+ return fs.existsSync(p);
2589
+ } catch (_) {
2590
+ return false;
2591
+ }
2592
+ }) || null;
2593
+ if (!_soffice_bin_cache) console.warn('[drive_module] office previews disabled: no LibreOffice on this box');
2594
+ return _soffice_bin_cache;
2595
+ };
2596
+
2597
+ const run_cmd = (bin, args, { timeout_ms = 120000, cwd } = {}) =>
2598
+ new Promise((resolve, reject) => {
2599
+ const child = child_process.execFile(bin, args, { timeout: timeout_ms, cwd, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
2600
+ if (err) return reject(new Error(`${path.basename(bin)} failed: ${err.message} ${String(stderr || '').slice(0, 300)}`));
2601
+ resolve({ stdout, stderr });
2602
+ });
2603
+ child.on('error', reject);
2604
+ });
2605
+
2606
+ // Convert one office file into { pdf, png }. Runs entirely in a private temp dir so
2607
+ // two conversions of the same filename cannot collide, and so a partial run leaves
2608
+ // nothing behind.
2609
+ const render_office_preview = async (buffer, originalname) => {
2610
+ const bin = soffice_bin();
2611
+ if (!bin) return null;
2612
+
2613
+ const work_dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'xuda_preview_'));
2614
+ const src_path = path.join(work_dir, `src${path.extname(originalname) || '.bin'}`);
2615
+
2616
+ try {
2617
+ await fs.promises.writeFile(src_path, buffer);
2618
+
2619
+ // -env:UserInstallation gives this run its own profile dir. Without it concurrent
2620
+ // conversions share ~/.config/libreoffice and the second one exits doing nothing.
2621
+ await run_cmd(bin, [
2622
+ '--headless',
2623
+ '--norestore',
2624
+ '--nolockcheck',
2625
+ `-env:UserInstallation=file://${path.join(work_dir, 'lo_profile')}`,
2626
+ '--convert-to',
2627
+ 'pdf',
2628
+ '--outdir',
2629
+ work_dir,
2630
+ src_path,
2631
+ ]);
2632
+
2633
+ const pdf_path = path.join(work_dir, 'src.pdf');
2634
+ if (!fs.existsSync(pdf_path)) throw new Error('conversion produced no pdf');
2635
+ const pdf = await fs.promises.readFile(pdf_path);
2636
+
2637
+ // Page one only, capped on width so a poster-sized slide does not become a
2638
+ // multi-megabyte thumbnail. -singlefile keeps the output name predictable.
2639
+ await run_cmd('/usr/bin/pdftoppm', ['-png', '-f', '1', '-l', '1', '-singlefile', '-scale-to-x', '960', '-scale-to-y', '-1', pdf_path, path.join(work_dir, 'page1')]);
2640
+
2641
+ const png_path = path.join(work_dir, 'page1.png');
2642
+ if (!fs.existsSync(png_path)) throw new Error('conversion produced no page image');
2643
+ const png = await fs.promises.readFile(png_path);
2644
+
2645
+ return { pdf, png };
2646
+ } finally {
2647
+ await rimraf(work_dir).catch(() => {});
2648
+ }
2649
+ };
2650
+
2651
+ // Store one derived asset next to the file it came from, in the same bucket and region.
2652
+ const put_preview_asset = async (region, base_key, suffix, body, content_type) => {
2653
+ const Bucket = _conf.storage_bucket?.[region]?.name;
2654
+ const s3 = _drive_s3_for(region);
2655
+ if (!s3 || !Bucket) throw new Error(`no storage bucket for region ${region}`);
2656
+ const Key = `${base_key}.preview.${suffix}`;
2657
+ await s3.send(new PutObjectCommand({ Bucket, Key, Body: body, ContentType: content_type }));
2658
+ return { Bucket, Key, key: Key };
2659
+ };
2660
+
2661
+ // Build (or rebuild) the preview assets for one drive doc and record them on it.
2662
+ // Shaped like ocr_drive_file: takes the doc, mutates preview state, saves it back.
2663
+ const preview_drive_file = async (app_id, doc) => {
2664
+ const { drive_type, uid, file_path } = doc;
2665
+
2666
+ try {
2667
+ if (!soffice_bin()) {
2668
+ doc.preview_stat = 0;
2669
+ doc.preview_error = 'no converter on this box';
2670
+ doc.preview_stat_ts = Date.now();
2671
+ return await save_drive_doc(drive_type, uid, app_id, doc, file_path);
2672
+ }
2673
+
2674
+ doc.preview_stat = 2;
2675
+ doc.preview_stat_ts = Date.now();
2676
+ await save_drive_doc(drive_type, uid, app_id, doc, file_path);
2677
+
2678
+ const region = _pick_file_region(doc);
2679
+ const { Bucket, Key } = _drive_bucket_for(doc, region);
2680
+ const s3 = region ? _drive_s3_for(region) : null;
2681
+ if (!s3 || !Bucket || !Key) throw new Error('file bytes unavailable');
2682
+
2683
+ const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
2684
+ const buffer = await _stream_to_buffer(obj.Body);
2685
+
2686
+ const rendered = await render_office_preview(buffer, doc.originalname || 'file');
2687
+ if (!rendered) throw new Error('no converter on this box');
2688
+
2689
+ const [pdf_ref, png_ref] = await Promise.all([
2690
+ put_preview_asset(region, Key, 'pdf', rendered.pdf, 'application/pdf'),
2691
+ put_preview_asset(region, Key, 'png', rendered.png, 'image/png'),
2692
+ ]);
2693
+
2694
+ doc.preview = { ...(doc.preview || {}), [region]: { pdf: pdf_ref, png: png_ref } };
2695
+ doc.preview_stat = 3;
2696
+ doc.preview_error = '';
2697
+ } catch (err) {
2698
+ doc.preview_stat = 4;
2699
+ doc.preview_error = err.message;
2700
+ console.error('[drive_module] preview failed for', doc?._id, err.message);
2701
+ } finally {
2702
+ doc.preview_stat_ts = Date.now();
2703
+ await save_drive_doc(drive_type, uid, app_id, doc, file_path).catch(() => {});
2704
+ }
2705
+ };
2706
+
2707
+ // Stat 2 means "a box is working on this right now", and the work is in-process. So a
2708
+ // pm2 restart mid-run (a deploy, a crash, an OOM) leaves the doc claimed by a process that
2709
+ // no longer exists, and nothing ever looks at stat 2 again: it is a dead end, not a state.
2710
+ // Anything that has been "running" for longer than the slowest plausible job is therefore
2711
+ // finished-by-death, and goes back in the queue. STALE_MS is generous on purpose, a big
2712
+ // deck with images is a slow convert and reclaiming a LIVE job would run it twice.
2713
+ const STRANDED_STAT_MS = 15 * 60 * 1000;
2714
+
2715
+ const reclaim_stranded = async (app_id, docType, stat_field, ts_field) => {
2716
+ try {
2717
+ const ret = await db_module.find_app_couch_query(app_id, {
2718
+ selector: { docType, type: 'file', [stat_field]: 2 },
2719
+ limit: 50,
2720
+ });
2721
+ const stale = (ret?.docs || []).filter((d) => !d[ts_field] || Date.now() - d[ts_field] > STRANDED_STAT_MS);
2722
+ for (const doc of stale) {
2723
+ doc[stat_field] = 1;
2724
+ await save_drive_doc(doc.drive_type, doc.uid, docType === 'user_drive' ? null : app_id, doc, doc.file_path).catch(() => {});
2725
+ console.warn(`[drive_module] reclaimed stranded ${stat_field}=2 on ${doc._id}`);
2726
+ }
2727
+ } catch (err) {
2728
+ console.error('[drive_module] reclaim_stranded failed', docType, stat_field, err.message);
2729
+ }
2730
+ };
2731
+
2732
+ // Same sweep shape as run_drive_pending_ocr, and the same region-ownership rule:
2733
+ // only the box that owns the app converts its files, so an active/standby pair does
2734
+ // not run the same conversion twice and race the write.
2735
+ export const run_drive_pending_previews = async () => {
2736
+ try {
2737
+ if (!soffice_bin()) return { code: 1, data: 'no converter on this box' };
2738
+
2739
+ const app_res = await db_module.find_couch_query('xuda_master', {
2740
+ selector: {
2741
+ docType: 'app',
2742
+ app_status_code: { $eq: 3 },
2743
+ $or: [{ app_type: 'master' }, { app_type: 'instance' }, { app_type: 'datacenter' }],
2744
+ },
2745
+ limit: 99999,
2746
+ });
2747
+
2748
+ let done = 0;
2749
+ for (let app of app_res.docs) {
2750
+ if (!_common.is_app_owned_by_local_box(app)) continue;
2751
+
2752
+ for (const docType of ['user_drive', 'workspace_drive']) {
2753
+ const owner = docType === 'user_drive' ? null : app._id;
2754
+
2755
+ await reclaim_stranded(app._id, docType, 'preview_stat', 'preview_stat_ts');
2756
+
2757
+ const queued = await db_module.find_app_couch_query(app._id, {
2758
+ selector: { docType, preview_stat: 1, stat: 3 },
2759
+ limit: 5,
2760
+ });
2761
+ for await (let doc of queued.docs) {
2762
+ await preview_drive_file(owner, doc);
2763
+ done++;
2764
+ }
2765
+ if (queued.docs?.length) continue;
2766
+
2767
+ // Backfill: every file that predates this feature has no preview_stat at all, so
2768
+ // the queued pass above can never see it. Filtered in JS rather than with $in on
2769
+ // file_ext, which no index can serve.
2770
+ const legacy = await db_module.find_app_couch_query(app._id, {
2771
+ selector: { docType, stat: 3, type: 'file', preview_stat: { $exists: false } },
2772
+ limit: 60,
2773
+ });
2774
+ const legacy_docs = legacy.docs || [];
2775
+
2776
+ // Stamp the ones that will never have a preview, so the batch above returns
2777
+ // different docs next tick. Without this a drive whose first 60 files are all
2778
+ // images hands back the same 60 for ever and the backfill never advances.
2779
+ for await (let doc of legacy_docs.filter((d) => !is_office_preview_ext(d.file_ext))) {
2780
+ doc.preview_stat = 0;
2781
+ await save_drive_doc(doc.drive_type, doc.uid, owner, doc, doc.file_path).catch(() => {});
2782
+ }
2783
+
2784
+ for await (let doc of legacy_docs.filter((d) => is_office_preview_ext(d.file_ext)).slice(0, 5)) {
2785
+ await preview_drive_file(owner, doc);
2786
+ done++;
2787
+ }
2788
+ }
2789
+ }
2790
+ return { code: 1, data: `previewed ${done}` };
2791
+ } catch (err) {
2792
+ return { code: -400, data: err.message };
2793
+ }
2794
+ };
2795
+
2796
+ // Called right after an upload so the preview is there in seconds rather than on the
2797
+ // next sweep. Deliberately tolerant: an unconvertible file must never fail its upload.
2798
+ export const generate_drive_file_preview = async (req) => {
2799
+ try {
2800
+ const { app_id, drive_type = 'user', uid, doc_id } = req || {};
2801
+ if (!doc_id) return { code: -1, data: 'doc_id required' };
2802
+
2803
+ const project_id = drive_type === 'user' ? await get_account_default_project_id(uid) : await _common.get_project_app_id(app_id);
2804
+ const ret = await db_module.get_app_couch_doc(project_id, doc_id);
2805
+ const doc = ret?.code >= 0 ? ret.data : null;
2806
+ if (!doc || doc.type !== 'file') return { code: -404, data: 'not_found' };
2807
+ if (!is_office_preview_ext(doc.file_ext)) return { code: 1, data: 'not previewable' };
2808
+
2809
+ await preview_drive_file(drive_type === 'user' ? null : app_id, doc);
2810
+ return { code: 1, data: { preview_stat: doc.preview_stat, preview_error: doc.preview_error || '' } };
2811
+ } catch (err) {
2812
+ return { code: -400, data: err.message };
2813
+ }
2814
+ };
2815
+
2816
+ // The links the UI needs, or nothing at all when there is no preview yet. Returned on
2817
+ // every drive row so a thumbnail and a lightbox can decide without a second call.
2818
+ //
2819
+ // preview_pending is the third answer, and it matters most right after an AI builds a
2820
+ // deck: the file is saved and its card renders in the chat within a second, while the
2821
+ // conversion behind it takes several. Without this the card can only say "no preview" and
2822
+ // mean "not yet", so it stayed a grey tile until the page was reloaded. Telling the client
2823
+ // a preview is coming lets it look again, and only for files that will actually get one.
2824
+ const preview_links_for = (doc, base_link) => {
2825
+ const region = doc?.preview ? _pick_file_region({ bucket: doc.preview }) || Object.keys(doc.preview)[0] : null;
2826
+ if (!region || !doc.preview[region]?.png) {
2827
+ return [1, 2].includes(doc?.preview_stat) ? { preview_pending: true } : {};
2828
+ }
2829
+ const sep = base_link.includes('?') ? '&' : '?';
2830
+ return {
2831
+ preview_link: `${base_link}${sep}preview=png`,
2832
+ preview_doc_link: `${base_link}${sep}preview=pdf`,
2833
+ };
2834
+ };
2835
+
2459
2836
  const validate_drive_type = (drive_type) => {
2460
2837
  if (!['studio', 'workspace', 'user'].includes(drive_type)) {
2461
2838
  throw new Error('error - drive_type values allowed: studio, workspace or users');
@@ -2496,6 +2873,11 @@ export const run_drive_pending_ocr = async () => {
2496
2873
  // active/standby master pair double-run expensive OCR and race the writes.
2497
2874
  if (!_common.is_app_owned_by_local_box(app)) continue;
2498
2875
 
2876
+ // Same dead-end as the preview sweep: ocr_stat 2 survives the restart that killed
2877
+ // the run holding it, and nothing ever selects a 2 again.
2878
+ await reclaim_stranded(app._id, 'user_drive', 'ocr_stat', 'ocr_stat_date');
2879
+ await reclaim_stranded(app._id, 'workspace_drive', 'ocr_stat', 'ocr_stat_date');
2880
+
2499
2881
  let opt = {
2500
2882
  selector: { docType: 'user_drive', ocr_stat: 1, stat: 3 },
2501
2883
  limit: 1,
@@ -3181,6 +3563,64 @@ export const get_user_drive_file = async function (uid, filename) {
3181
3563
  return drive_ret;
3182
3564
  };
3183
3565
 
3566
+ // Look a user-drive file up by the id in its access link, for surfaces that hold a
3567
+ // link and nothing else (an AI chat reply that produced a file, a pasted link). The
3568
+ // listing methods all key off a folder path, which a link does not carry.
3569
+ //
3570
+ // Returns the SAME row shape a listing child has, so a caller can hand it straight to
3571
+ // the drive thumbnail and lightbox. file_path is the point of the call: it is what
3572
+ // "Show in drive" needs to reveal the file, and it cannot be derived from the link.
3573
+ //
3574
+ // The lookup runs in the CALLER's own project (get_user_drive_file resolves the project
3575
+ // from the authenticated uid, never from anything the client sends), so a link into
3576
+ // someone else's drive answers not_found here. That is the honest answer: the file may
3577
+ // be readable over its link, but it is not in this user's drive and Show in drive has
3578
+ // nothing to reveal.
3579
+ export const get_user_drive_file_by_id = async (req) => {
3580
+ try {
3581
+ const uid = req.uid || req.token_ret?.data?.uid;
3582
+ if (!uid) return { code: -1, data: 'uid_required' };
3583
+
3584
+ // Links may carry a cache-buster (?ts=) and are often pasted URL-encoded.
3585
+ const raw = String(req.file_id || '').trim();
3586
+ const file_id = decodeURIComponent(raw.split('?')[0].split('/').filter(Boolean).pop() || '');
3587
+ if (!file_id) return { code: -1, data: 'file_id_required' };
3588
+
3589
+ const ret = await get_user_drive_file(uid, file_id);
3590
+ const doc = ret?.code >= 0 ? ret.data : null;
3591
+ if (!doc || doc.type !== 'file' || doc.stat === 4) return { code: -404, data: 'not_found' };
3592
+
3593
+ const app_id = await get_account_default_project_id(uid);
3594
+ const fmt = (b = 0) =>
3595
+ b < 1024 ? b + ' Bytes' : b < 1048576 ? (b / 1024).toFixed(2) + ' KB' : b < 1073741824 ? (b / 1048576).toFixed(2) + ' MB' : (b / 1073741824).toFixed(2) + ' GB';
3596
+
3597
+ const access_link = `https://${process.env.XUDA_HOSTNAME}/user-drive/${app_id}/${doc._id}`;
3598
+
3599
+ return {
3600
+ code: 200,
3601
+ data: {
3602
+ name: doc.originalname,
3603
+ sizeInBytes: doc.size,
3604
+ size: fmt(doc.size),
3605
+ extension: doc?.file_ext?.substr(1),
3606
+ type: doc.type,
3607
+ access_link,
3608
+ ...preview_links_for(doc, access_link),
3609
+ is_archive: doc.is_archive,
3610
+ public: doc.public,
3611
+ path: doc.file_path,
3612
+ date_created: doc.date_created,
3613
+ date_modified: doc.date_modified,
3614
+ file_path: path.join(doc.file_path, doc.originalname),
3615
+ tags: doc.tags || [],
3616
+ starred: !!doc.starred,
3617
+ },
3618
+ };
3619
+ } catch (err) {
3620
+ return { code: -404, data: 'not_found' };
3621
+ }
3622
+ };
3623
+
3184
3624
  // ── Static-site vibe: see + edit the site's source folder directly through the
3185
3625
  // cpi (Spaces + CouchDB) — no temp dir, no server disk growth. The "folder" is
3186
3626
  // the app's static_website.source.folder_path; files live in Spaces, hierarchy
@@ -3280,9 +3720,7 @@ export const read_site_file = async (req) => {
3280
3720
  const d = ret?.docs?.[0];
3281
3721
  if (!d) return { code: -1, data: `file not found: ${rel}` };
3282
3722
  const region = _pick_file_region(d);
3283
- const b = (region && d.bucket?.[region]) || {};
3284
- const Bucket = b.Bucket || b.bucket;
3285
- const Key = b.Key || b.key;
3723
+ const { Bucket, Key } = _drive_bucket_for(d, region);
3286
3724
  const s3 = region ? _drive_s3_for(region) : null;
3287
3725
  if (!s3 || !Bucket || !Key) return { code: -1, data: 'file bytes unavailable' };
3288
3726
  const obj = await s3.send(new GetObjectCommand({ Bucket, Key }));
@@ -3691,6 +4129,23 @@ const _pick_file_region = (doc) => {
3691
4129
  return null;
3692
4130
  };
3693
4131
 
4132
+ // Where a drive doc's bytes actually live, for a given region.
4133
+ //
4134
+ // The CONFIG's bucket name wins over the one stored on the doc. Docs written before the
4135
+ // DigitalOcean to R2 migration still carry the old Spaces bucket in doc.bucket[region],
4136
+ // which no longer exists, so trusting the doc fails with "The specified bucket does not
4137
+ // exist" on files that serve perfectly well over https. The router has always resolved the
4138
+ // bucket from config alone (_presign_get), which is exactly why it can still read them;
4139
+ // this keeps every other reader consistent with the one that works. The doc's own name is
4140
+ // kept as a fallback for a region that config does not describe.
4141
+ const _drive_bucket_for = (doc, region) => {
4142
+ const entry = (region && doc?.bucket?.[region]) || {};
4143
+ return {
4144
+ Bucket: (region && _conf.storage_bucket?.[region]?.name) || entry.Bucket || entry.bucket || null,
4145
+ Key: entry.Key || entry.key || null,
4146
+ };
4147
+ };
4148
+
3694
4149
  const _stream_to_buffer = async (stream) => {
3695
4150
  if (!stream) return Buffer.alloc(0);
3696
4151
  // AWS SDK v3 Body is a Node Readable in this runtime.
@@ -3730,9 +4185,7 @@ export const read_user_drive_folder = async (req) => {
3730
4185
  for (const d of docs) {
3731
4186
  // Resolve the file's own region, then read from THAT region's bucket.
3732
4187
  const region = _pick_file_region(d);
3733
- const b = (region && d.bucket?.[region]) || {};
3734
- const Bucket = b.Bucket || b.bucket || (region && _conf.storage_bucket?.[region]?.name);
3735
- const Key = b.Key || b.key;
4188
+ const { Bucket, Key } = _drive_bucket_for(d, region);
3736
4189
  const s3 = region ? _drive_s3_for(region) : null;
3737
4190
  if (!s3 || !Bucket || !Key) {
3738
4191
  // No reachable region (e.g. datacenter-scp file with no Spaces
@@ -3837,9 +4290,7 @@ export const fetch_drive_file_to_tmp = async (req) => {
3837
4290
  if (!doc) return { code: -1, data: `file not found: ${file_ref}` };
3838
4291
 
3839
4292
  const region = _pick_file_region(doc);
3840
- const b = (region && doc.bucket?.[region]) || {};
3841
- const Bucket = b.Bucket || b.bucket || (region && _conf.storage_bucket?.[region]?.name);
3842
- const Key = b.Key || b.key;
4293
+ const { Bucket, Key } = _drive_bucket_for(doc, region);
3843
4294
  const s3 = region ? _drive_s3_for(region) : null;
3844
4295
  if (!s3 || !Bucket || !Key) return { code: -1, data: 'file bytes unavailable' };
3845
4296
 
@@ -4470,3 +4921,172 @@ export const file_upload_validator = function (file_name) {
4470
4921
  return { valid: false, error: `Unsupported file extension: .${ext}` };
4471
4922
  }
4472
4923
  };
4924
+
4925
+ ///////////////////////////////////////////////////////////////////////////////
4926
+ // DRIVE PLANS (UI-113)
4927
+ //
4928
+ // Drive is sold on its own drive_* ladder and what the ladder sells is SPACE.
4929
+ // A tier's `flags.extra_gb` is added on top of the storage the account already
4930
+ // has from its membership plan and its AI workspace plan, it does not replace
4931
+ // either, so an account that never picks a tier is on drive_free and nothing
4932
+ // about its Drive changes.
4933
+ //
4934
+ // The money is one line item on the consolidated subscription, the same shape
4935
+ // every other module tier uses: a paid tier creates or reprices the line, free
4936
+ // removes it. Billing moves FIRST and the plan is only written once Stripe
4937
+ // agreed, so a failed charge can never leave an account holding space it is
4938
+ // not paying for.
4939
+ ///////////////////////////////////////////////////////////////////////////////
4940
+
4941
+ const DRIVE_CATEGORY = 'drive';
4942
+ const GB = 1073741824;
4943
+
4944
+ const _drive_tiers = () =>
4945
+ Object.values(_conf.PLAN_OBJ || {})
4946
+ .filter((p) => p.category === DRIVE_CATEGORY)
4947
+ .sort((a, b) => (Number(a.rank) || 0) - (Number(b.rank) || 0));
4948
+
4949
+ const _drive_plan = (plan_id) => {
4950
+ const plan = _conf.PLAN_OBJ?.[plan_id];
4951
+ return plan && plan.category === DRIVE_CATEGORY ? plan : null;
4952
+ };
4953
+
4954
+ const _drive_account_doc = async (uid) => {
4955
+ const ret = await db_module.get_couch_doc('xuda_accounts', uid);
4956
+ return ret.code < 0 || !ret.data ? {} : ret.data;
4957
+ };
4958
+
4959
+ // An account that never touched the ladder is on free. Same default the billing
4960
+ // Modules card applies, kept identical on purpose so the two never disagree.
4961
+ const _drive_account_plan = (account) => (_drive_plan(account?.drive_plan) ? account.drive_plan : 'drive_free');
4962
+
4963
+ // What the account is storing, using the SAME sum the overage metering charges
4964
+ // on (account_module increment_account_usage). project_data_size is deliberately
4965
+ // out of it there, so it is out of it here: a guard that counted bytes the
4966
+ // metering ignores would refuse a downgrade the customer is not billed for.
4967
+ const _drive_used_bytes = (account) =>
4968
+ (account?.user_drive_size || 0) +
4969
+ (account?.studio_drive_size || 0) +
4970
+ (account?.workspace_drive_size || 0) +
4971
+ (account?.builds_drive_size || 0) +
4972
+ (account?.plugins_drive_size || 0);
4973
+
4974
+ // The quota the account WOULD have on a given tier, in bytes. Delegates to
4975
+ // account_module's get_effective_entitlements rather than re-deriving the stack,
4976
+ // because that function is what the metering and every storage bar already use.
4977
+ // Imported lazily: this is the only place in the drive module that needs it and
4978
+ // account_module pulls in a large tree of its own.
4979
+ const _drive_quota_bytes = async (account, plan_id) => {
4980
+ const { get_effective_entitlements } = await import(`${module_path}/account_module/index.mjs`);
4981
+ const ent = get_effective_entitlements({ ...account, drive_plan: plan_id ?? account?.drive_plan });
4982
+ return {
4983
+ unlimited: !Number.isFinite(ent.drive_gb),
4984
+ bytes: Number.isFinite(ent.drive_gb) ? Math.round(ent.drive_gb * GB) : 0,
4985
+ breakdown: ent.drive_breakdown || {},
4986
+ };
4987
+ };
4988
+
4989
+ const _drive_pretty = (bytes) => {
4990
+ if (bytes >= 1099511627776) return `${Math.round((bytes / 1099511627776) * 10) / 10} TB`;
4991
+ if (bytes >= GB) return `${Math.round((bytes / GB) * 10) / 10} GB`;
4992
+ return `${Math.round(bytes / 1048576)} MB`;
4993
+ };
4994
+
4995
+ export const get_drive_plans = async function (req) {
4996
+ try {
4997
+ const uid = req?.uid;
4998
+ if (!uid) return { code: -401, data: 'not authenticated' };
4999
+ const account = await _drive_account_doc(uid);
5000
+ if (!account._id) return { code: -404, data: 'account not found' };
5001
+
5002
+ const plan_id = _drive_account_plan(account);
5003
+ const used = _drive_used_bytes(account);
5004
+ const quota = await _drive_quota_bytes(account, plan_id);
5005
+
5006
+ // Every tier carries the quota it WOULD give and whether the account already
5007
+ // stores more than that, so the screen can mark a tier as out of reach
5008
+ // without doing the entitlement arithmetic itself.
5009
+ const plans = [];
5010
+ for (const p of _drive_tiers()) {
5011
+ const q = await _drive_quota_bytes(account, p.id);
5012
+ plans.push({
5013
+ ...p,
5014
+ quota_bytes: q.bytes,
5015
+ quota_unlimited: q.unlimited,
5016
+ fits: q.unlimited || used <= q.bytes,
5017
+ });
5018
+ }
5019
+
5020
+ return {
5021
+ code: 1,
5022
+ data: {
5023
+ plan_id,
5024
+ plan_name: _drive_plan(plan_id)?.name || '',
5025
+ changed_ts: account.drive_plan_changed || null,
5026
+ // The line on the invoice. Free holds no item at all.
5027
+ subscribed: !!account?.stripe_subscription_items?.[DRIVE_CATEGORY],
5028
+ used_bytes: used,
5029
+ quota_bytes: quota.bytes,
5030
+ quota_unlimited: quota.unlimited,
5031
+ // Where the space comes from, so a full bar can point at the plan that
5032
+ // would actually fix it rather than at this one by default.
5033
+ quota_from: {
5034
+ membership_bytes: Math.round((quota.breakdown.membership_gb === Infinity ? 0 : quota.breakdown.membership_gb || 0) * GB),
5035
+ workspace_bytes: Math.round((quota.breakdown.workspace_gb || 0) * GB),
5036
+ drive_plan_bytes: Math.round((quota.breakdown.drive_plan_gb || 0) * GB),
5037
+ },
5038
+ membership_plan: account.membership_plan || 'free',
5039
+ ai_workspace_plan: account.ai_workspace_plan || '',
5040
+ plans,
5041
+ },
5042
+ };
5043
+ } catch (err) {
5044
+ return { code: -1, data: err.message || String(err) };
5045
+ }
5046
+ };
5047
+
5048
+ export const drive_set_plan = async function (req) {
5049
+ try {
5050
+ const uid = req?.uid;
5051
+ if (!uid) return { code: -401, data: 'not authenticated' };
5052
+ const plan_id = req?.plan_id || '';
5053
+ const plan = _drive_plan(plan_id);
5054
+ if (!plan) return { code: -1, data: 'unknown drive plan' };
5055
+
5056
+ const account = await _drive_account_doc(uid);
5057
+ if (!account._id) return { code: -404, data: 'account not found' };
5058
+ const current = _drive_account_plan(account);
5059
+ if (current === plan_id) return await get_drive_plans({ uid });
5060
+
5061
+ // Downgrade guard. Taking space away from an account that is already using it
5062
+ // would drop it straight into metered overage on files it cannot see a way to
5063
+ // remove, so name the gap instead and let the customer clear it first.
5064
+ const target = await _drive_quota_bytes(account, plan_id);
5065
+ const used = _drive_used_bytes(account);
5066
+ if (!target.unlimited && used > target.bytes) {
5067
+ return {
5068
+ code: -1,
5069
+ data: `You are storing ${_drive_pretty(used)} and the ${plan.name} plan would leave you with ${_drive_pretty(target.bytes)}. Free up ${_drive_pretty(used - target.bytes)} in Drive first.`,
5070
+ };
5071
+ }
5072
+
5073
+ const paid = Number(plan.price) > 0;
5074
+ if (paid) {
5075
+ const ret = await stripe_ms.add_subscription_item({ uid, plan_id });
5076
+ if (!ret || ret.code < 0) return { code: ret?.code === -402 || ret?.code === -3 ? -402 : -1, data: ret?.data || 'could not change the plan' };
5077
+ } else if (account.stripe_subscription_items?.[DRIVE_CATEGORY]) {
5078
+ const ret = await stripe_ms.remove_subscription_item({ uid, category: DRIVE_CATEGORY });
5079
+ if (!ret || ret.code < 0) return { code: -1, data: ret?.data || 'could not remove the subscription line' };
5080
+ }
5081
+
5082
+ // Re-read: the stripe call just wrote stripe_subscription_items on this doc.
5083
+ const fresh = await _drive_account_doc(uid);
5084
+ fresh.drive_plan = plan_id;
5085
+ fresh.drive_plan_changed = Date.now();
5086
+ await db_module.save_couch_doc('xuda_accounts', fresh);
5087
+
5088
+ return await get_drive_plans({ uid });
5089
+ } catch (err) {
5090
+ return { code: -1, data: err.message || String(err) };
5091
+ }
5092
+ };
package/index_ms.mjs CHANGED
@@ -121,6 +121,14 @@ export const check_drive_file = async function (...args) {
121
121
  return await broker.send_to_queue("check_drive_file", ...args);
122
122
  };
123
123
 
124
+ export const run_drive_pending_previews = async function (...args) {
125
+ return await broker.send_to_queue("run_drive_pending_previews", ...args);
126
+ };
127
+
128
+ export const generate_drive_file_preview = async function (...args) {
129
+ return await broker.send_to_queue("generate_drive_file_preview", ...args);
130
+ };
131
+
124
132
  export const run_drive_pending_ocr = async function (...args) {
125
133
  return await broker.send_to_queue("run_drive_pending_ocr", ...args);
126
134
  };
@@ -153,6 +161,10 @@ export const update_drive_addons = async function (...args) {
153
161
  return await broker.send_to_queue("update_drive_addons", ...args);
154
162
  };
155
163
 
164
+ export const get_user_drive_file_by_id = async function (...args) {
165
+ return await broker.send_to_queue("get_user_drive_file_by_id", ...args);
166
+ };
167
+
156
168
  export const list_site_files = async function (...args) {
157
169
  return await broker.send_to_queue("list_site_files", ...args);
158
170
  };
@@ -421,6 +433,14 @@ export const file_upload_validator = async function (...args) {
421
433
  return await broker.send_to_queue("file_upload_validator", ...args);
422
434
  };
423
435
 
436
+ export const get_drive_plans = async function (...args) {
437
+ return await broker.send_to_queue("get_drive_plans", ...args);
438
+ };
439
+
440
+ export const drive_set_plan = async function (...args) {
441
+ return await broker.send_to_queue("drive_set_plan", ...args);
442
+ };
443
+
424
444
  export const get_drive_files_studio = async function (...args) {
425
445
  return await broker.send_to_queue("get_drive_files_studio", ...args);
426
446
  };
package/index_msa.mjs CHANGED
@@ -121,6 +121,14 @@ export const check_drive_file = function (...args) {
121
121
  broker.send_to_queue_async("check_drive_file", ...args);
122
122
  };
123
123
 
124
+ export const run_drive_pending_previews = function (...args) {
125
+ broker.send_to_queue_async("run_drive_pending_previews", ...args);
126
+ };
127
+
128
+ export const generate_drive_file_preview = function (...args) {
129
+ broker.send_to_queue_async("generate_drive_file_preview", ...args);
130
+ };
131
+
124
132
  export const run_drive_pending_ocr = function (...args) {
125
133
  broker.send_to_queue_async("run_drive_pending_ocr", ...args);
126
134
  };
@@ -153,6 +161,10 @@ export const update_drive_addons = function (...args) {
153
161
  broker.send_to_queue_async("update_drive_addons", ...args);
154
162
  };
155
163
 
164
+ export const get_user_drive_file_by_id = function (...args) {
165
+ broker.send_to_queue_async("get_user_drive_file_by_id", ...args);
166
+ };
167
+
156
168
  export const list_site_files = function (...args) {
157
169
  broker.send_to_queue_async("list_site_files", ...args);
158
170
  };
@@ -421,6 +433,14 @@ export const file_upload_validator = function (...args) {
421
433
  broker.send_to_queue_async("file_upload_validator", ...args);
422
434
  };
423
435
 
436
+ export const get_drive_plans = function (...args) {
437
+ broker.send_to_queue_async("get_drive_plans", ...args);
438
+ };
439
+
440
+ export const drive_set_plan = function (...args) {
441
+ broker.send_to_queue_async("drive_set_plan", ...args);
442
+ };
443
+
424
444
  export const get_drive_files_studio = function (...args) {
425
445
  broker.send_to_queue_async("get_drive_files_studio", ...args);
426
446
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/drive_module",
3
- "version": "1.1.1499",
3
+ "version": "1.1.1501",
4
4
  "description": "Xuda Drive Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {