@dench.com/cli 3.11.6 → 3.11.7-staging.488

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.
@@ -11,6 +11,10 @@ export type RecipientTable = {
11
11
  hasHeader: boolean;
12
12
  columnLabels: string[];
13
13
  columnSamples: string[];
14
+ /** Where the file's quoting breaks (`scanCsv`); no one is read from it. */
15
+ error?: string;
16
+ /** Rows with filled cells past the header's last column. */
17
+ raggedRows?: number;
14
18
  };
15
19
 
16
20
  export type RecipientField =
@@ -65,8 +69,10 @@ export const MAX_CSV_CHARS = 5_000_000;
65
69
 
66
70
  const EMAIL_RE = /[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
67
71
  const NAMED_EMAIL_RE = /^(.*?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>$/;
72
+ // A slug may be in any script (linkedin.com/in/张伟): exports don't always
73
+ // percent-encode it, and `isLinkedinProfileUrl` accepts it.
68
74
  const LINKEDIN_URL_RE =
69
- /(?<![A-Za-z0-9.-])(?:https?:\/\/)?(?:[a-z]{1,4}\.)?linkedin\.com\/in\/[A-Za-z0-9\-_.%]+\/?/i;
75
+ /(?<![A-Za-z0-9.-])(?:https?:\/\/)?(?:[a-z]{1,4}\.)?linkedin\.com\/in\/[\p{L}\p{N}\-_.%]+\/?/iu;
70
76
 
71
77
  const FILE_EXTENSION_TLDS = new Set([
72
78
  "png",
@@ -337,8 +343,16 @@ export function extractLinkedinUrl(raw: string): string | null {
337
343
  return isLinkedinProfileUrl(url) ? url : null;
338
344
  }
339
345
 
346
+ // One person per profile, whatever host prefix (uk., www.) or encoding.
340
347
  function linkedinKey(url: string): string {
341
- return `li:${url.toLowerCase().replace(/^https?:\/\/(www\.)?/, "")}`;
348
+ const slug = url.match(/linkedin\.com\/in\/([^/?#]+)/i)?.[1] ?? url;
349
+ let decoded = slug;
350
+ try {
351
+ decoded = decodeURIComponent(slug);
352
+ } catch {
353
+ decoded = slug;
354
+ }
355
+ return `li:${decoded.toLowerCase()}`;
342
356
  }
343
357
 
344
358
  function normalizeEmail(raw: string): string {
@@ -390,17 +404,31 @@ export function detectDelimiter(text: string): CsvDelimiter {
390
404
  return best ?? ",";
391
405
  }
392
406
 
393
- export function splitCsvRows(
407
+ // A quote opens a quoted value only at the start of a cell; a quoted value
408
+ // must close, with nothing but spaces after it. A broken quote would fold
409
+ // the rows after it into one person's cell, so the file is refused with the
410
+ // line. The same rules as the app's `scanCsv` (outreach-recipients.ts).
411
+ export function scanCsv(
394
412
  text: string,
395
413
  delimiter: CsvDelimiter = ",",
396
- ): string[][] {
414
+ ): { rows: string[][]; error?: string } {
397
415
  const rows: string[][] = [];
398
416
  let row: string[] = [];
399
417
  let cell = "";
400
418
  let inQuotes = false;
419
+ let afterQuote = false;
420
+ let line = 1;
421
+ let quoteLine = 1;
422
+ let error: string | undefined;
401
423
  const src = text.replace(/^\uFEFF/, "");
424
+ const endCell = () => {
425
+ row.push(cell);
426
+ cell = "";
427
+ afterQuote = false;
428
+ };
402
429
  for (let i = 0; i < src.length; i++) {
403
430
  const ch = src[i];
431
+ if (ch === "\n" || (ch === "\r" && src[i + 1] !== "\n")) line += 1;
404
432
  if (inQuotes) {
405
433
  if (ch === '"') {
406
434
  if (src[i + 1] === '"') {
@@ -408,28 +436,51 @@ export function splitCsvRows(
408
436
  i += 1;
409
437
  } else {
410
438
  inQuotes = false;
439
+ afterQuote = true;
411
440
  }
412
441
  } else {
413
442
  cell += ch;
414
443
  }
415
- } else if (ch === '"') {
416
- inQuotes = true;
417
444
  } else if (ch === delimiter) {
418
- row.push(cell);
419
- cell = "";
445
+ endCell();
420
446
  } else if (ch === "\n" || ch === "\r") {
421
- if (ch === "\r" && src[i + 1] === "\n") i += 1;
422
- row.push(cell);
423
- cell = "";
447
+ if (ch === "\r" && src[i + 1] === "\n") {
448
+ i += 1;
449
+ line += 1;
450
+ }
451
+ endCell();
424
452
  if (row.some((value) => value.trim() !== "")) rows.push(row);
425
453
  row = [];
454
+ } else if (afterQuote) {
455
+ if (ch.trim() && !error) {
456
+ error = `Line ${line} has text after a closing quote. Put the whole value in quotes and try again.`;
457
+ }
458
+ if (ch.trim()) cell += ch;
459
+ } else if (ch === '"' && !cell.trim()) {
460
+ cell = "";
461
+ inQuotes = true;
462
+ quoteLine = line;
426
463
  } else {
427
464
  cell += ch;
428
465
  }
429
466
  }
430
- row.push(cell);
467
+ if (inQuotes && !error) {
468
+ error = `Line ${quoteLine} opens a quote that never closes, so the rows after it can't be read. Fix the quote and try again.`;
469
+ }
470
+ endCell();
431
471
  if (row.some((value) => value.trim() !== "")) rows.push(row);
432
- return rows;
472
+ return error ? { rows, error } : { rows };
473
+ }
474
+
475
+ export function splitCsvRows(
476
+ text: string,
477
+ delimiter: CsvDelimiter = ",",
478
+ ): string[][] {
479
+ return scanCsv(text, delimiter).rows;
480
+ }
481
+
482
+ function widestRow(rows: string[][], floor = 0): number {
483
+ return rows.reduce((width, row) => Math.max(width, row.length), floor);
433
484
  }
434
485
 
435
486
  function cellIsExactly(cell: string, value: string): boolean {
@@ -450,7 +501,7 @@ type ColumnStats = {
450
501
  };
451
502
 
452
503
  function columnStats(rows: string[][]): ColumnStats[] {
453
- const width = Math.max(...rows.map((row) => row.length), 0);
504
+ const width = widestRow(rows);
454
505
  const stats: ColumnStats[] = Array.from({ length: width }, () => ({
455
506
  samples: 0,
456
507
  emailHits: 0,
@@ -516,21 +567,43 @@ function firstRowIsHeader(row: string[]): boolean {
516
567
 
517
568
  function rowPersonalization(
518
569
  row: string[],
519
- header: string[],
570
+ names: readonly string[],
520
571
  skip: (col: number) => boolean,
521
572
  ): Record<string, string> {
522
573
  const bag: Record<string, string> = {};
523
- header.forEach((rawName, col) => {
524
- const name = rawName.trim();
525
- if (!name || skip(col)) return;
574
+ names.forEach((name, col) => {
575
+ if (skip(col)) return;
526
576
  const value = row[col]?.trim() ?? "";
527
- if (!value) return;
528
- const key = `crm:${name}`;
529
- if (!(key in bag)) bag[key] = value;
577
+ if (value) bag[`crm:${name}`] = value;
530
578
  });
531
579
  return bag;
532
580
  }
533
581
 
582
+ // What each column is called as a merge value: its header, or "Column D".
583
+ // A stored variable name holds printable ASCII only (Convex refuses any
584
+ // other field name): accents drop ("Café" → "Cafe"), a header with nothing
585
+ // left ("職位") falls back to its letter, and names are unique ("Interest",
586
+ // "Interest 2"). The same rule as the app's `columnVariableNames`.
587
+ export function columnVariableNames(table: RecipientTable): string[] {
588
+ const used = new Set<string>();
589
+ return table.columnLabels.map((_, col) => {
590
+ const base =
591
+ (table.header[col] ?? "")
592
+ .normalize("NFKD")
593
+ .replace(/[̀-ͯ]/g, "")
594
+ .replace(/[{}|]/g, " ")
595
+ .replace(/[^\x20-\x7e]/g, " ")
596
+ .replace(/\s+/g, " ")
597
+ .trim()
598
+ .slice(0, 100)
599
+ .trim() || `Column ${columnLetter(col)}`;
600
+ let name = base;
601
+ for (let n = 2; used.has(name.toLowerCase()); n += 1) name = `${base} ${n}`;
602
+ used.add(name.toLowerCase());
603
+ return name;
604
+ });
605
+ }
606
+
534
607
  function findNameColumns(header: string[]): {
535
608
  first?: number;
536
609
  last?: number;
@@ -600,12 +673,17 @@ export function parseRecipientTable(
600
673
  csvText: string,
601
674
  delimiter: CsvDelimiter = ",",
602
675
  ): RecipientTable {
603
- const rows = splitCsvRows(csvText, delimiter);
676
+ const { rows, error } = scanCsv(csvText, delimiter);
604
677
  const first = rows[0] ?? [];
605
678
  const hasHeader = rows.length > 0 && firstRowIsHeader(first);
606
679
  const header = hasHeader ? first.map((cell) => cell.trim()) : [];
607
680
  const dataRows = hasHeader ? rows.slice(1) : rows;
608
- const width = Math.max(header.length, ...dataRows.map((row) => row.length));
681
+ const width = widestRow(dataRows, header.length);
682
+ const raggedRows = hasHeader
683
+ ? dataRows.filter((row) =>
684
+ row.slice(header.length).some((cell) => cell.trim()),
685
+ ).length
686
+ : 0;
609
687
  const columnLabels = Array.from(
610
688
  { length: width },
611
689
  (_, col) => header[col] || columnLetter(col),
@@ -617,25 +695,44 @@ export function parseRecipientTable(
617
695
  .find((cell) => cell.length > 0) ?? "";
618
696
  return sample.length > 40 ? `${sample.slice(0, 39)}…` : sample;
619
697
  });
620
- return { header, rows: dataRows, hasHeader, columnLabels, columnSamples };
698
+ return {
699
+ header,
700
+ rows: dataRows,
701
+ hasHeader,
702
+ columnLabels,
703
+ columnSamples,
704
+ ...(error ? { error } : {}),
705
+ ...(raggedRows ? { raggedRows } : {}),
706
+ };
621
707
  }
622
708
 
709
+ const EMAIL_HEADER_RE =
710
+ /^((work|business|primary|professional|personal)[\s_-]*)?e-?mail([\s_-]*address)?$/i;
711
+ const LINKEDIN_HEADER_RE =
712
+ /^(linkedin([\s_-]*(profile|url|link|profile[\s_-]*(url|link)))?|(linkedin[\s_-]*)?profile[\s_-]*(url|link))$/i;
713
+
714
+ // Best first: a column whose header names the channel ("Email") before one
715
+ // that only holds more of it ("Backup contact"), then most-filled, leftmost.
623
716
  export function detectRecipientColumns(
624
717
  table: RecipientTable,
625
718
  ): DetectedRecipientColumns {
626
719
  const stats = columnStats(table.rows);
627
- const rank = (hits: (stat: ColumnStats) => number) =>
720
+ const rank = (hits: (stat: ColumnStats) => number, header: RegExp) =>
628
721
  stats
629
- .map((stat, col) => ({ col, hits: hits(stat) }))
722
+ .map((stat, col) => ({
723
+ col,
724
+ hits: hits(stat),
725
+ named: header.test(table.header[col]?.trim() ?? "") ? 1 : 0,
726
+ }))
630
727
  .filter((entry) => entry.hits > 0)
631
- .sort((a, b) => b.hits - a.hits || a.col - b.col)
728
+ .sort((a, b) => b.named - a.named || b.hits - a.hits || a.col - b.col)
632
729
  .map((entry) => entry.col);
633
730
  const names = table.hasHeader ? findNameColumns(table.header) : {};
634
731
  return {
635
732
  firstName: names.first,
636
733
  lastName: names.last,
637
- email: rank(emailHitsOf),
638
- linkedin: rank(linkedinHitsOf),
734
+ email: rank(emailHitsOf, EMAIL_HEADER_RE),
735
+ linkedin: rank(linkedinHitsOf, LINKEDIN_HEADER_RE),
639
736
  };
640
737
  }
641
738
 
@@ -668,10 +765,12 @@ export function recipientsFromTable(
668
765
  hasHeader: table.hasHeader,
669
766
  error,
670
767
  });
768
+ if (table.error) return empty(table.error);
671
769
  if (table.rows.length === 0 && !table.hasHeader) {
672
770
  return empty("This file is empty.");
673
771
  }
674
772
  const stats = columnStats(table.rows);
773
+ const columnNames = columnVariableNames(table);
675
774
  const detected = detectRecipientColumns(table);
676
775
  const nameColumns = resolveNameColumns(detected, mapping);
677
776
  const preferred = (field: "email" | "linkedin") =>
@@ -698,7 +797,7 @@ export function recipientsFromTable(
698
797
  nameColumns.first === col ||
699
798
  nameColumns.last === col;
700
799
  const recipients: CsvRecipient[] = [];
701
- const seen = new Set<string>();
800
+ const seen = new Map<string, CsvRecipient>();
702
801
  let noChannel = 0;
703
802
  let duplicates = 0;
704
803
  for (const row of table.rows) {
@@ -716,31 +815,68 @@ export function recipientsFromTable(
716
815
  noChannel += 1;
717
816
  continue;
718
817
  }
719
- const keys = [
720
- ...(address ? [normalizeEmail(address)] : []),
721
- ...(linkedinUrl ? [linkedinKey(linkedinUrl)] : []),
722
- ];
723
- if (keys.some((key) => seen.has(key))) {
724
- duplicates += 1;
725
- continue;
726
- }
727
- for (const key of keys) seen.add(key);
818
+ const emailKey = address ? normalizeEmail(address) : undefined;
819
+ const profileKey = linkedinUrl ? linkedinKey(linkedinUrl) : undefined;
728
820
  const personalization = table.hasHeader
729
- ? rowPersonalization(row, table.header, consumed)
821
+ ? rowPersonalization(row, columnNames, consumed)
730
822
  : undefined;
731
823
  const name =
732
824
  rowDisplayName(row, nameColumns)?.trim() ||
733
825
  extractNameFromNamedEmail(emailRaw) ||
734
826
  (linkedinUrl ? derivedNameFromLinkedinUrl(linkedinUrl) : undefined);
735
- recipients.push({
827
+ const matches = new Set(
828
+ [emailKey, profileKey].flatMap((key) => {
829
+ const person = key ? seen.get(key) : undefined;
830
+ return person ? [person] : [];
831
+ }),
832
+ );
833
+ if (matches.size > 0) {
834
+ // The same person again: keep what this row adds (their LinkedIn, a
835
+ // name, a column that was blank), never what the first row said.
836
+ duplicates += 1;
837
+ const [existing] = matches;
838
+ if (matches.size > 1 || !existing) continue;
839
+ if (address && emailKey && !existing.email) {
840
+ existing.email = address;
841
+ seen.set(emailKey, existing);
842
+ }
843
+ if (linkedinUrl && profileKey && !existing.linkedinProfileUrl) {
844
+ existing.linkedinProfileUrl = linkedinUrl;
845
+ seen.set(profileKey, existing);
846
+ }
847
+ if (name && !existing.displayName) existing.displayName = name;
848
+ if (personalization && Object.keys(personalization).length > 0) {
849
+ existing.personalization = {
850
+ ...personalization,
851
+ ...existing.personalization,
852
+ };
853
+ }
854
+ continue;
855
+ }
856
+ const person: CsvRecipient = {
736
857
  ...(address ? { email: address } : {}),
737
858
  ...(linkedinUrl ? { linkedinProfileUrl: linkedinUrl } : {}),
738
859
  ...(name ? { displayName: name } : {}),
739
860
  ...(personalization && Object.keys(personalization).length > 0
740
861
  ? { personalization }
741
862
  : {}),
742
- });
863
+ };
864
+ if (emailKey) seen.set(emailKey, person);
865
+ if (profileKey) seen.set(profileKey, person);
866
+ recipients.push(person);
743
867
  }
868
+ // A channel switched off with `--map email=none` reads nothing from its
869
+ // column, so the column is reported ignored, not as that channel.
870
+ const channelColumn = (
871
+ field: "email" | "linkedin",
872
+ index: number,
873
+ hits: (stat: ColumnStats) => number,
874
+ ) =>
875
+ preferred(field) === index || isChannelColumn(stats[index], hits)
876
+ ? mapping[field] === null
877
+ ? ("ignored" as const)
878
+ : field
879
+ : undefined;
744
880
  const columns: CsvColumn[] = table.columnLabels.map((label, index) => {
745
881
  const role: ColumnRole =
746
882
  nameColumns.first === index
@@ -749,22 +885,16 @@ export function recipientsFromTable(
749
885
  ? "last name"
750
886
  : nameColumns.full === index
751
887
  ? "full name"
752
- : preferred("email") === index ||
753
- isChannelColumn(stats[index], emailHitsOf)
754
- ? "email"
755
- : preferred("linkedin") === index ||
756
- isChannelColumn(stats[index], linkedinHitsOf)
757
- ? "linkedin"
758
- : table.hasHeader && table.header[index]?.trim()
759
- ? "merge value"
760
- : "ignored";
888
+ : (channelColumn("email", index, emailHitsOf) ??
889
+ channelColumn("linkedin", index, linkedinHitsOf) ??
890
+ (table.hasHeader ? "merge value" : "ignored"));
761
891
  return {
762
892
  index,
763
893
  label,
764
894
  sample: table.columnSamples[index] ?? "",
765
895
  role,
766
- ...(role === "merge value" || role === "full name"
767
- ? { mergeKey: `crm:${table.header[index].trim()}` }
896
+ ...((role === "merge value" || role === "full name") && table.hasHeader
897
+ ? { mergeKey: `crm:${columnNames[index]}` }
768
898
  : {}),
769
899
  };
770
900
  });
@@ -1519,6 +1519,12 @@ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
1519
1519
  export type SignalBuyerBody = {
1520
1520
  companyDomain: string;
1521
1521
  titles?: string[];
1522
+ /** Where the person is; the gateway drops people placed elsewhere. */
1523
+ locations?: string[];
1524
+ /** The company's industries, checked against the person's job. */
1525
+ industries?: string[];
1526
+ /** Kinds of company (Public, Nonprofit, …), checked on the company. */
1527
+ companyTypes?: string[];
1522
1528
  withEmail?: boolean;
1523
1529
  limit?: number;
1524
1530
  };
@@ -1556,6 +1562,9 @@ export async function findSignalBuyer(
1556
1562
  companyDomain: body.companyDomain,
1557
1563
  };
1558
1564
  if (body.titles?.length) payload.titles = body.titles;
1565
+ if (body.locations?.length) payload.locations = body.locations;
1566
+ if (body.industries?.length) payload.industries = body.industries;
1567
+ if (body.companyTypes?.length) payload.companyTypes = body.companyTypes;
1559
1568
  if (body.withEmail !== undefined) payload.withEmail = body.withEmail;
1560
1569
  if (body.limit !== undefined) payload.limit = body.limit;
1561
1570
  return (await callGatewayJson("/v1/enrichment/signals/buyer", {