proton-mail-bridge-client 2.0.6 → 2.0.7

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.
@@ -95,6 +95,7 @@ export declare class LocalIndexService {
95
95
  private loadCheckpointsSync;
96
96
  private loadMessages;
97
97
  private loadThreadCandidateMessages;
98
+ private hasUnresolvedReferenceCandidate;
98
99
  private loadAllMessages;
99
100
  private expandCandidatesToFullThreads;
100
101
  private loadSnapshot;
@@ -1 +1 @@
1
- {"version":3,"file":"local-index-service.d.ts","sourceRoot":"","sources":["../../src/services/local-index-service.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,uBAAuB,EACvB,YAAY,EACZ,UAAU,EACV,YAAY,EACZ,cAAc,EAEd,gBAAgB,EAChB,qBAAqB,EACrB,gBAAgB,EAChB,iBAAiB,EACjB,YAAY,EACZ,aAAa,EACd,MAAM,mBAAmB,CAAC;AAc3B,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,oBAAoB,CAAC;AA+hBzD,qBAAa,iBAAiB;IAO1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,EAAE,CAAC,CAAoB;IAC/B,OAAO,CAAC,WAAW,CAAS;gBAGT,MAAM,EAAE,gBAAgB,EACxB,GAAG,GAAE,MAAe;IAMjC,cAAc,CAAC,KAAK,EAAE;QAC1B,OAAO,EAAE,UAAU,EAAE,CAAC;QACtB,MAAM,EAAE,YAAY,EAAE,CAAC;QACvB,QAAQ,EAAE,MAAM,CAAC;QACjB,WAAW,EAAE,KAAK,CAAC,qBAAqB,CAAC,CAAC;KAC3C,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAOvB,SAAS,IAAI,OAAO,CAAC,gBAAgB,CAAC;YA0B9B,iBAAiB;IAezB,MAAM,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC;QAChD,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,OAAO,CAAC;QACjB,MAAM,EAAE,YAAY,EAAE,CAAC;QACvB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,qBAAqB,CAAC,EAAE,MAAM,CAAC;QAC/B,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;KACrB,CAAC;IAqEI,KAAK,IAAI,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC;IAgCpD,kBAAkB,CAAC,KAAK,SAAK,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAKzD,SAAS,CAAC,KAAK,SAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IA8I/C,UAAU,CAAC,KAAK,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAAC;QACzG,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,OAAO,CAAC;QACjB,OAAO,EAAE,aAAa,EAAE,CAAC;QACzB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,qBAAqB,CAAC,EAAE,MAAM,CAAC;QAC/B,cAAc,CAAC,EAAE,OAAO,CAAC;KAC1B,CAAC;IA4DI,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IA8CtD,oBAAoB,CAAC,KAAK,GAAE;QAChC,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,UAAU,CAAC,EAAE,OAAO,CAAC;QACrB,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,CAAC;KAC/B,GAAG,OAAO,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,OAAO,CAAC;QACjB,OAAO,EAAE,uBAAuB,EAAE,CAAC;QACnC,cAAc,CAAC,EAAE,OAAO,CAAC;KAC1B,CAAC;IAwHI,oBAAoB,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAC;IAStE,cAAc,CAAC,KAAK,GAAE;QAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,WAAW,CAAC,EAAE,MAAM,CAAC;KACjB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAgGnC,qBAAqB,CAAC,KAAK,GAAE;QACjC,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,CAAC;KAC/B,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAgFnC,mBAAmB,CAAC,KAAK,GAAE;QAC/B,QAAQ,CAAC,EAAE,UAAU,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,GAAG,UAAU,CAAC;QACvE,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;KACX,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAqGnC,cAAc,CAAC,KAAK,EAAE;QAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAsF9B,iBAAiB,IAAI,OAAO,CAAC;QACjC,EAAE,EAAE,OAAO,CAAC;QACZ,SAAS,EAAE,MAAM,CAAC;QAClB,kBAAkB,EAAE,MAAM,CAAC;QAC3B,WAAW,EAAE,MAAM,CAAC;QACpB,mBAAmB,EAAE,MAAM,CAAC;KAC7B,CAAC;YAwBY,QAAQ;IA4DtB,OAAO,CAAC,YAAY;IAapB,OAAO,CAAC,aAAa;IAoYrB,OAAO,CAAC,aAAa;IAsFrB,OAAO,CAAC,qBAAqB;YAuBf,sBAAsB;IAmDpC,OAAO,CAAC,mBAAmB;IAqG3B,OAAO,CAAC,YAAY;IA8CpB,OAAO,CAAC,sBAAsB;IA2C9B,OAAO,CAAC,mBAAmB;IAuC3B,OAAO,CAAC,YAAY;IA6CpB,OAAO,CAAC,2BAA2B;IA0EnC,OAAO,CAAC,eAAe;IAkBvB,OAAO,CAAC,6BAA6B;YA8BvB,YAAY;IAgB1B,OAAO,CAAC,eAAe;IAevB,OAAO,CAAC,iBAAiB;IA8BzB,OAAO,CAAC,QAAQ;IA8BhB,OAAO,CAAC,oBAAoB;IAwC5B,OAAO,CAAC,YAAY;IA+DpB,OAAO,CAAC,wBAAwB;IAgEhC,OAAO,CAAC,cAAc;IAKtB,OAAO,CAAC,oBAAoB;IAW5B,OAAO,CAAC,OAAO;CAOhB"}
1
+ {"version":3,"file":"local-index-service.d.ts","sourceRoot":"","sources":["../../src/services/local-index-service.ts"],"names":[],"mappings":"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"}
@@ -118,8 +118,16 @@ function matchesIndexedSearch(email, filters) {
118
118
  ]
119
119
  .join("\n")
120
120
  .toLowerCase();
121
- if (normalizedFilters.query && !haystacks.includes(normalizedFilters.query.toLowerCase())) {
122
- return false;
121
+ // searchFtsIds() ANDs every whitespace-separated query token as its own quoted FTS5 term
122
+ // (so "invoice payment" matches a preview like "payment for the invoice is overdue"), but
123
+ // this post-filter used to require the ENTIRE joined query as one literal substring —
124
+ // rejecting anything FTS already matched whose words were merely out of order or separated
125
+ // by other words. Match FTS's own AND-of-terms semantics instead of a single-phrase substring.
126
+ if (normalizedFilters.query) {
127
+ const terms = normalizedFilters.query.toLowerCase().split(/\s+/).filter(Boolean);
128
+ if (terms.length > 0 && !terms.every((term) => haystacks.includes(term))) {
129
+ return false;
130
+ }
123
131
  }
124
132
  if (normalizedFilters.subject &&
125
133
  !email.subject.toLowerCase().includes(normalizedFilters.subject.toLowerCase())) {
@@ -279,13 +287,18 @@ function isOutgoingMessage(message, ownerEmail) {
279
287
  }
280
288
  return message.from.some((address) => lowerCaseAddress(address.address) === owner);
281
289
  }
282
- // ponytail: cheap local-part heuristic, no schema change (no stored List-Unsubscribe/Precedence
283
- // header). Catches the bulk of automated senders (auction/shipping/billing notifications, etc.)
284
- // that were otherwise all counted as threads "pending on you" forever, since a one-way automated
285
- // message is never replied to and never ages out. Upgrade path: store List-Unsubscribe/Precedence
286
- // at index time and use that instead if this substring heuristic proves too coarse in practice.
290
+ // ponytail: the upgrade path has been taken — messages.is_automated now stores the real
291
+ // List-Unsubscribe/List-Id/Precedence/Auto-Submitted verdict captured at index time
292
+ // (EmailSummary.isAutomated), and it wins whenever present. This local-part heuristic is kept
293
+ // only as the fallback for rows indexed before that column existed (is_automated NULL), which
294
+ // stay on the heuristic until a full re-sync backfills them. It proved too coarse live: it cut
295
+ // "pending on you" from 49,026 to 38,532 of ~57k threads, but transactional senders like an
296
+ // order-confirmation address with no no-reply marker are invisible to it.
287
297
  const AUTOMATED_SENDER_PATTERN = /(^|[._-])(no-?reply|donotreply|do-not-reply|notification|powiadomien|mailer-daemon|postmaster|bounce)/i;
288
298
  function isLikelyAutomatedSender(message) {
299
+ if (message.isAutomated !== undefined) {
300
+ return message.isAutomated;
301
+ }
289
302
  return message.from.some((address) => AUTOMATED_SENDER_PATTERN.test(address.address ?? ""));
290
303
  }
291
304
  function actionableThreadScore(thread, ownerEmail) {
@@ -673,7 +686,17 @@ export class LocalIndexService {
673
686
  messagesCapped = messages.length >= DEFAULT_SNAPSHOT_LIMIT;
674
687
  }
675
688
  const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
676
- const threads = this.buildThreads(snapshot).filter((thread) => {
689
+ // includeMessages:true so the query check below can look at EVERY message's subject, not
690
+ // just thread.subject (which buildThreads derives from the single LATEST message). Found
691
+ // live via the fix above: once loadThreadCandidateMessages() correctly pulls in a
692
+ // reference-chain thread's full membership even when the query only matched its root, this
693
+ // filter's old `thread.subject` check started wrongly excluding that thread entirely — the
694
+ // root's subject may share nothing with the thread's most recent reply (e.g. root "Invoice
695
+ // 03/2026" vs. latest reply "Question about VAT"). Checking every message means a thread
696
+ // the SQL prefilter already proved relevant is never re-excluded by which message happened
697
+ // to be newest. `messages` is stripped back off in the final return below — callers still
698
+ // get plain ThreadSummary shapes, not the full per-message detail.
699
+ const threads = this.buildThreads(snapshot, true).filter((thread) => {
677
700
  if (input.label) {
678
701
  const labelNeedle = input.label.toLowerCase();
679
702
  if (!thread.normalizedLabels.some((label) => label.toLowerCase() === labelNeedle)) {
@@ -681,14 +704,15 @@ export class LocalIndexService {
681
704
  }
682
705
  }
683
706
  if (input.query) {
684
- const haystack = [
685
- thread.subject,
707
+ const queryNeedle = input.query.toLowerCase();
708
+ const threadLevelHaystack = [
686
709
  ...thread.participants.map((participant) => `${participant.name ?? ""} ${participant.address ?? ""}`),
687
710
  ...thread.normalizedLabels,
688
711
  ]
689
712
  .join("\n")
690
713
  .toLowerCase();
691
- if (!haystack.includes(input.query.toLowerCase())) {
714
+ const matchesAnyMessage = thread.messages.some((message) => message.subject.toLowerCase().includes(queryNeedle));
715
+ if (!matchesAnyMessage && !threadLevelHaystack.includes(queryNeedle)) {
692
716
  return false;
693
717
  }
694
718
  }
@@ -697,10 +721,11 @@ export class LocalIndexService {
697
721
  const limit = input.limit ?? 100;
698
722
  const offset = 0;
699
723
  const totalCount = threads.length;
724
+ const summaries = threads.map(({ messages: _messages, ...summary }) => summary);
700
725
  return {
701
726
  total: totalCount,
702
727
  hasMore: totalCount > offset + limit,
703
- threads: threads.slice(offset, offset + limit),
728
+ threads: summaries.slice(offset, offset + limit),
704
729
  ...this.indexFreshnessFields(snapshot.updatedAt),
705
730
  ...(messagesCapped ? { messagesCapped: true } : {}),
706
731
  };
@@ -1302,11 +1327,13 @@ export class LocalIndexService {
1302
1327
  INSERT INTO messages (
1303
1328
  email_id, folder, uid, seq, message_id, in_reply_to, references_json, thread_id, subject,
1304
1329
  from_json, to_json, cc_json, bcc_json, reply_to_json, date, internal_date,
1305
- is_read, is_starred, flags_json, size, preview, has_attachments, attachments_json, attachment_text, labels_json
1330
+ is_read, is_starred, flags_json, size, preview, has_attachments, attachments_json, attachment_text, labels_json,
1331
+ is_automated
1306
1332
  ) VALUES (
1307
1333
  @email_id, @folder, @uid, @seq, @message_id, @in_reply_to, @references_json, @thread_id, @subject,
1308
1334
  @from_json, @to_json, @cc_json, @bcc_json, @reply_to_json, @date, @internal_date,
1309
- @is_read, @is_starred, @flags_json, @size, @preview, @has_attachments, @attachments_json, @attachment_text, @labels_json
1335
+ @is_read, @is_starred, @flags_json, @size, @preview, @has_attachments, @attachments_json, @attachment_text, @labels_json,
1336
+ @is_automated
1310
1337
  )
1311
1338
  ON CONFLICT(email_id) DO UPDATE SET
1312
1339
  folder = excluded.folder,
@@ -1336,7 +1363,10 @@ export class LocalIndexService {
1336
1363
  has_attachments = excluded.has_attachments,
1337
1364
  attachments_json = excluded.attachments_json,
1338
1365
  attachment_text = COALESCE(excluded.attachment_text, messages.attachment_text),
1339
- labels_json = excluded.labels_json
1366
+ labels_json = excluded.labels_json,
1367
+ -- Same reasoning: a flags-only refresh fetches no headers, so is_automated arrives
1368
+ -- NULL; the headers of a fixed UID never change, so keeping the stored verdict is safe.
1369
+ is_automated = COALESCE(excluded.is_automated, messages.is_automated)
1340
1370
  RETURNING preview, attachment_text
1341
1371
  `);
1342
1372
  const upsertSyncState = db.prepare(`
@@ -1552,6 +1582,7 @@ export class LocalIndexService {
1552
1582
  attachments_json: JSON.stringify(email.attachments),
1553
1583
  attachment_text: email.attachmentText ?? legacyAttachmentText,
1554
1584
  labels_json: JSON.stringify(email.labels),
1585
+ is_automated: email.isAutomated === undefined ? null : email.isAutomated ? 1 : 0,
1555
1586
  });
1556
1587
  const mergedPreview = persisted.preview ?? "";
1557
1588
  const search = emailToSearchParts({ ...email, attachmentText: persisted.attachment_text ?? undefined });
@@ -1670,7 +1701,8 @@ export class LocalIndexService {
1670
1701
  has_attachments INTEGER NOT NULL,
1671
1702
  attachments_json TEXT NOT NULL,
1672
1703
  attachment_text TEXT,
1673
- labels_json TEXT NOT NULL
1704
+ labels_json TEXT NOT NULL,
1705
+ is_automated INTEGER
1674
1706
  );
1675
1707
 
1676
1708
  CREATE TABLE IF NOT EXISTS sync_state (
@@ -1716,6 +1748,12 @@ export class LocalIndexService {
1716
1748
  if (!columns.has("attachment_text")) {
1717
1749
  db.exec(`ALTER TABLE messages ADD COLUMN attachment_text TEXT`);
1718
1750
  }
1751
+ // Nullable on purpose: existing rows read back as NULL (= isAutomated undefined) so the
1752
+ // actionable-thread scorer falls back to the sender regex for them instead of treating
1753
+ // every pre-migration message as human. A full re-sync backfills the real value.
1754
+ if (!columns.has("is_automated")) {
1755
+ db.exec(`ALTER TABLE messages ADD COLUMN is_automated INTEGER`);
1756
+ }
1719
1757
  const syncStateColumns = new Set(db.prepare(`PRAGMA table_info(sync_state)`).all().map((row) => row.name));
1720
1758
  if (!syncStateColumns.has("backfilled_to_uid")) {
1721
1759
  db.exec(`ALTER TABLE sync_state ADD COLUMN backfilled_to_uid INTEGER`);
@@ -1826,8 +1864,14 @@ export class LocalIndexService {
1826
1864
  params.push(filters.threadId);
1827
1865
  }
1828
1866
  if (filters.dateFrom) {
1867
+ // Same bug class as dateTo below: dateFrom used to be pushed raw and compared as a
1868
+ // string against full ISO timestamps ("internal_date" is always a full ISO string).
1869
+ // A caller-supplied value that isn't already exactly that shape (a bare date, a value
1870
+ // with a timezone offset, an English date like "March 24, 2026") does a wrong
1871
+ // lexicographic comparison instead of a real date comparison, silently dropping
1872
+ // matching messages with no error. Normalize the same way dateTo already does.
1829
1873
  conditions.push(`COALESCE(internal_date, date) >= ?`);
1830
- params.push(filters.dateFrom);
1874
+ params.push(new Date(filters.dateFrom).toISOString());
1831
1875
  }
1832
1876
  if (filters.dateTo) {
1833
1877
  // dateTo is commonly a bare date ("2026-09-02") without a time
@@ -1982,8 +2026,11 @@ export class LocalIndexService {
1982
2026
  messageParams.push(options.isRead ? 1 : 0);
1983
2027
  }
1984
2028
  if (options.since) {
2029
+ // Same normalization as the dateFrom fix in loadCandidateEmails() above — options.since
2030
+ // is fed straight from a caller's dateFrom (see the threadId search path), so it needs
2031
+ // the identical ISO normalization to avoid a wrong string comparison.
1985
2032
  messageConditions.push(`COALESCE(internal_date, date) >= ?`);
1986
- messageParams.push(options.since);
2033
+ messageParams.push(new Date(options.since).toISOString());
1987
2034
  }
1988
2035
  if (messageConditions.length > 0) {
1989
2036
  messageSqlParts.push(`WHERE ${messageConditions.join(" AND ")}`);
@@ -2049,14 +2096,43 @@ export class LocalIndexService {
2049
2096
  // is present, pull in the full, uncapped message set — the same source
2050
2097
  // getThreadById() now uses for these threads — so the thread is always built
2051
2098
  // from its complete membership, filtered query or not.
2052
- const hasUnresolvedReferenceCandidate = candidates.some((email) => !email.threadId?.trim() && (email.inReplyTo || (email.references?.length ?? 0) > 0));
2053
- if (hasUnresolvedReferenceCandidate) {
2099
+ if (this.hasUnresolvedReferenceCandidate(db, candidates)) {
2054
2100
  for (const email of this.loadAllMessages(db)) {
2055
2101
  byId.set(email.id, email);
2056
2102
  }
2057
2103
  }
2058
2104
  return [...byId.values()];
2059
2105
  }
2106
+ // The check above only looked at the candidate's OWN inReplyTo/references — but a
2107
+ // candidate that IS the root of a reference chain has neither (it started the thread),
2108
+ // while a reply elsewhere in the mailbox points AT it via In-Reply-To. Found live: a
2109
+ // getThreads({query}) match on the root message alone built a 1-message thread instead of
2110
+ // the real 3-message thread, because the root has no headers to trip the original check and
2111
+ // no persisted thread_id to expand through. A candidate lacking a persisted thread_id is
2112
+ // genuinely resolved by full-membership-via-references in exactly two cases: it references
2113
+ // something else (the original check), or something else references IT. The in_reply_to
2114
+ // column is indexed (idx_messages_in_reply_to), so this stays a targeted lookup per
2115
+ // reference-less candidate rather than paying for the full scan on the common case where
2116
+ // every candidate already has a native thread_id.
2117
+ hasUnresolvedReferenceCandidate(db, candidates) {
2118
+ return candidates.some((email) => {
2119
+ if (email.threadId?.trim()) {
2120
+ return false;
2121
+ }
2122
+ if (email.inReplyTo || (email.references?.length ?? 0) > 0) {
2123
+ return true;
2124
+ }
2125
+ if (email.messageId) {
2126
+ const referencedByOther = db
2127
+ .prepare(`SELECT 1 FROM messages WHERE in_reply_to = ? LIMIT 1`)
2128
+ .get(email.messageId);
2129
+ if (referencedByOther) {
2130
+ return true;
2131
+ }
2132
+ }
2133
+ return false;
2134
+ });
2135
+ }
2060
2136
  // Uncapped message read, unlike loadMessages() (deliberately capped at
2061
2137
  // DEFAULT_SNAPSHOT_LIMIT). Used only where correctness requires seeing every
2062
2138
  // message regardless of index size — resolving a fallback/reference-chain
@@ -2093,8 +2169,10 @@ export class LocalIndexService {
2093
2169
  byId.set(email.id, email);
2094
2170
  }
2095
2171
  }
2096
- const hasUnresolvedReferenceCandidate = candidates.some((email) => !email.threadId?.trim() && (email.inReplyTo || (email.references?.length ?? 0) > 0));
2097
- if (hasUnresolvedReferenceCandidate) {
2172
+ // See hasUnresolvedReferenceCandidate()'s comment above (loadThreadCandidateMessages) for
2173
+ // why this must also check whether some OTHER message's In-Reply-To points at a
2174
+ // header-less candidate, not just the candidate's own inReplyTo/references.
2175
+ if (this.hasUnresolvedReferenceCandidate(db, candidates)) {
2098
2176
  for (const email of this.loadAllMessages(db)) {
2099
2177
  byId.set(email.id, email);
2100
2178
  }
@@ -2156,6 +2234,7 @@ export class LocalIndexService {
2156
2234
  attachments: safeJsonParse(row.attachments_json, []),
2157
2235
  attachmentText: row.attachment_text ?? undefined,
2158
2236
  labels: safeJsonParse(row.labels_json, []),
2237
+ isAutomated: row.is_automated === null || row.is_automated === undefined ? undefined : Boolean(row.is_automated),
2159
2238
  };
2160
2239
  }
2161
2240
  toStatus(snapshot, counts) {