proton-mail-bridge-client 2.2.3 → 2.3.0
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/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/services/local-index-service.d.ts +1 -0
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +122 -66
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/utils/helpers.d.ts +4 -0
- package/dist/utils/helpers.d.ts.map +1 -1
- package/dist/utils/helpers.js +35 -1
- package/dist/utils/helpers.js.map +1 -1
- package/package.json +1 -1
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"local-index-service.d.ts","sourceRoot":"","sources":["../../src/services/local-index-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -3,9 +3,13 @@ import { chmodSync } from "node:fs";
|
|
|
3
3
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { ensureAccountIdentityMatches } from "../utils/account-identity.js";
|
|
6
|
-
import { createEmailId, dedupeEmails, extractDomain, extractMessageIdList, lowerCaseAddress, nextDay, normalizeMailboxLabel, normalizeMessageId, normalizeSubjectForThread, sortEmailsByNewest, } from "../utils/helpers.js";
|
|
6
|
+
import { createEmailId, dedupeEmails, extractDomain, extractMessageIdList, foldSearchText, lowerCaseAddress, nextDay, normalizeMailboxLabel, normalizeMessageId, normalizeSubjectForThread, searchEquals, searchIncludes, searchKey, searchNeedles, sortEmailsByNewest, } from "../utils/helpers.js";
|
|
7
7
|
import { logger } from "../utils/logger.js";
|
|
8
8
|
const DB_SCHEMA_VERSION = 3;
|
|
9
|
+
// Version of what the full-text index stores. 2 = every column holds the text's searchKey (folded,
|
|
10
|
+
// with Müller also as "mueller"), so ł, ß, ø and the Mueller spelling are found. Bumping it makes the
|
|
11
|
+
// next open rebuild the index from the messages table, once.
|
|
12
|
+
const FTS_KEY_VERSION = "2";
|
|
9
13
|
const STALE_THRESHOLD_MINUTES = 60;
|
|
10
14
|
const DEFAULT_SNAPSHOT_LIMIT = 5000;
|
|
11
15
|
// Results older than this carry `stale:true` plus a warning. Deliberately much lower than
|
|
@@ -48,6 +52,19 @@ export function shapeThreadForList(thread) {
|
|
|
48
52
|
function escapeLike(value) {
|
|
49
53
|
return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
50
54
|
}
|
|
55
|
+
// WHERE fragment: any of `columns` contains `needle`, ignoring case, accents and spelling variants
|
|
56
|
+
// (Müller/Mueller, see searchKey). FOLD() is the SQL function registered in ensureDb.
|
|
57
|
+
function foldedLike(columns, needle) {
|
|
58
|
+
const parts = [];
|
|
59
|
+
const params = [];
|
|
60
|
+
for (const form of searchNeedles(needle)) {
|
|
61
|
+
for (const column of columns) {
|
|
62
|
+
parts.push(`FOLD(${column}) LIKE ? ESCAPE '\\'`);
|
|
63
|
+
params.push(`%${escapeLike(form)}%`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { sql: `(${parts.join(" OR ")})`, params };
|
|
67
|
+
}
|
|
51
68
|
function parseSearchQuery(query) {
|
|
52
69
|
const parsed = { residualTerms: [] };
|
|
53
70
|
for (const rawToken of query?.trim().split(/\s+/) ?? []) {
|
|
@@ -100,9 +117,9 @@ function matchesIndexedSearch(email, filters) {
|
|
|
100
117
|
return false;
|
|
101
118
|
}
|
|
102
119
|
if (normalizedFilters.label) {
|
|
103
|
-
const
|
|
104
|
-
const folderMatch = normalizeMailboxLabel(email.folder)
|
|
105
|
-
const labelMatch = email.labels.some((label) => normalizeMailboxLabel(label)
|
|
120
|
+
const wanted = normalizedFilters.label;
|
|
121
|
+
const folderMatch = searchEquals(normalizeMailboxLabel(email.folder) ?? "", wanted);
|
|
122
|
+
const labelMatch = email.labels.some((label) => searchEquals(normalizeMailboxLabel(label) ?? "", wanted));
|
|
106
123
|
if (!folderMatch && !labelMatch) {
|
|
107
124
|
return false;
|
|
108
125
|
}
|
|
@@ -131,8 +148,8 @@ function matchesIndexedSearch(email, filters) {
|
|
|
131
148
|
return false;
|
|
132
149
|
}
|
|
133
150
|
if (normalizedFilters.attachmentName) {
|
|
134
|
-
const attachmentNeedle = normalizedFilters.attachmentName
|
|
135
|
-
const match = email.attachments.some((attachment) => (attachment.filename || ""
|
|
151
|
+
const attachmentNeedle = normalizedFilters.attachmentName;
|
|
152
|
+
const match = email.attachments.some((attachment) => searchIncludes(attachment.filename || "", attachmentNeedle));
|
|
136
153
|
if (!match) {
|
|
137
154
|
return false;
|
|
138
155
|
}
|
|
@@ -152,35 +169,34 @@ function matchesIndexedSearch(email, filters) {
|
|
|
152
169
|
...email.from.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
|
|
153
170
|
...email.to.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
|
|
154
171
|
...email.cc.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
|
|
155
|
-
]
|
|
156
|
-
|
|
157
|
-
.toLowerCase();
|
|
172
|
+
].join("\n");
|
|
173
|
+
const haystackKey = searchKey(haystacks);
|
|
158
174
|
// searchFtsIds() ANDs every whitespace-separated query token as its own quoted FTS5 term
|
|
159
175
|
// (so "invoice payment" matches a preview like "payment for the invoice is overdue"), but
|
|
160
176
|
// this post-filter used to require the ENTIRE joined query as one literal substring —
|
|
161
177
|
// rejecting anything FTS already matched whose words were merely out of order or separated
|
|
162
178
|
// by other words. Match FTS's own AND-of-terms semantics instead of a single-phrase substring.
|
|
163
179
|
if (normalizedFilters.query) {
|
|
164
|
-
const terms = normalizedFilters.query.
|
|
165
|
-
if (terms.length > 0 && !terms.every((term) =>
|
|
180
|
+
const terms = normalizedFilters.query.split(/\s+/).filter(Boolean);
|
|
181
|
+
if (terms.length > 0 && !terms.every((term) => searchNeedles(term).some((form) => haystackKey.includes(form)))) {
|
|
166
182
|
return false;
|
|
167
183
|
}
|
|
168
184
|
}
|
|
169
185
|
if (normalizedFilters.subject &&
|
|
170
|
-
!email.subject
|
|
186
|
+
!searchIncludes(email.subject, normalizedFilters.subject)) {
|
|
171
187
|
return false;
|
|
172
188
|
}
|
|
173
189
|
if (normalizedFilters.from) {
|
|
174
|
-
const fromNeedle = normalizedFilters.from
|
|
175
|
-
const match = email.from.some((value) => `${value.name ?? ""} ${value.address ?? ""}
|
|
190
|
+
const fromNeedle = normalizedFilters.from;
|
|
191
|
+
const match = email.from.some((value) => searchIncludes(`${value.name ?? ""} ${value.address ?? ""}`, fromNeedle));
|
|
176
192
|
if (!match) {
|
|
177
193
|
return false;
|
|
178
194
|
}
|
|
179
195
|
}
|
|
180
196
|
if (normalizedFilters.to) {
|
|
181
|
-
const toNeedle = normalizedFilters.to
|
|
197
|
+
const toNeedle = normalizedFilters.to;
|
|
182
198
|
const recipients = [...email.to, ...email.cc, ...email.bcc];
|
|
183
|
-
const match = recipients.some((value) => `${value.name ?? ""} ${value.address ?? ""}
|
|
199
|
+
const match = recipients.some((value) => searchIncludes(`${value.name ?? ""} ${value.address ?? ""}`, toNeedle));
|
|
184
200
|
if (!match) {
|
|
185
201
|
return false;
|
|
186
202
|
}
|
|
@@ -382,6 +398,11 @@ function safeJsonParse(value, fallback) {
|
|
|
382
398
|
return fallback;
|
|
383
399
|
}
|
|
384
400
|
}
|
|
401
|
+
// The six searchable columns of messages_fts, in table order after email_id, as their searchKey.
|
|
402
|
+
function ftsColumns(email, preview, attachmentText) {
|
|
403
|
+
const parts = emailToSearchParts({ ...email, attachmentText });
|
|
404
|
+
return [email.subject, preview, email.folder, parts.labels, parts.participants, parts.attachmentNames].map(searchKey);
|
|
405
|
+
}
|
|
385
406
|
function emailToSearchParts(email) {
|
|
386
407
|
return {
|
|
387
408
|
labels: [normalizeMailboxLabel(email.folder) || email.folder, ...email.labels.map((label) => normalizeMailboxLabel(label) || label)].join(" "),
|
|
@@ -763,21 +784,18 @@ export class LocalIndexService {
|
|
|
763
784
|
// get plain ThreadSummary shapes, not the full per-message detail.
|
|
764
785
|
const threads = this.buildThreads(snapshot, true).filter((thread) => {
|
|
765
786
|
if (input.label) {
|
|
766
|
-
const
|
|
767
|
-
if (!thread.normalizedLabels.some((label) => label
|
|
787
|
+
const wanted = input.label;
|
|
788
|
+
if (!thread.normalizedLabels.some((label) => searchEquals(label, wanted))) {
|
|
768
789
|
return false;
|
|
769
790
|
}
|
|
770
791
|
}
|
|
771
792
|
if (input.query) {
|
|
772
|
-
const queryNeedle = input.query.toLowerCase();
|
|
773
793
|
const threadLevelHaystack = [
|
|
774
794
|
...thread.participants.map((participant) => `${participant.name ?? ""} ${participant.address ?? ""}`),
|
|
775
795
|
...thread.normalizedLabels,
|
|
776
|
-
]
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
const matchesAnyMessage = thread.messages.some((message) => message.subject.toLowerCase().includes(queryNeedle));
|
|
780
|
-
if (!matchesAnyMessage && !threadLevelHaystack.includes(queryNeedle)) {
|
|
796
|
+
].join("\n");
|
|
797
|
+
const matchesAnyMessage = thread.messages.some((message) => searchIncludes(message.subject, input.query));
|
|
798
|
+
if (!matchesAnyMessage && !searchIncludes(threadLevelHaystack, input.query)) {
|
|
781
799
|
return false;
|
|
782
800
|
}
|
|
783
801
|
}
|
|
@@ -868,14 +886,14 @@ export class LocalIndexService {
|
|
|
868
886
|
conditions.push(`is_read = 0`);
|
|
869
887
|
}
|
|
870
888
|
if (input.label) {
|
|
871
|
-
const
|
|
872
|
-
conditions.push(
|
|
873
|
-
params.push(
|
|
889
|
+
const like = foldedLike(["folder", "labels_json"], input.label);
|
|
890
|
+
conditions.push(like.sql);
|
|
891
|
+
params.push(...like.params);
|
|
874
892
|
}
|
|
875
893
|
if (input.query) {
|
|
876
|
-
const
|
|
877
|
-
conditions.push(
|
|
878
|
-
params.push(
|
|
894
|
+
const like = foldedLike(["subject", "preview", "from_json", "labels_json"], input.query);
|
|
895
|
+
conditions.push(like.sql);
|
|
896
|
+
params.push(...like.params);
|
|
879
897
|
}
|
|
880
898
|
const sql = `SELECT * FROM messages${conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""}`;
|
|
881
899
|
const candidateMessages = db
|
|
@@ -916,8 +934,8 @@ export class LocalIndexService {
|
|
|
916
934
|
return false;
|
|
917
935
|
}
|
|
918
936
|
if (input.label) {
|
|
919
|
-
const
|
|
920
|
-
if (!thread.normalizedLabels.some((label) => label
|
|
937
|
+
const wanted = input.label;
|
|
938
|
+
if (!thread.normalizedLabels.some((label) => searchEquals(label, wanted))) {
|
|
921
939
|
return false;
|
|
922
940
|
}
|
|
923
941
|
}
|
|
@@ -927,10 +945,8 @@ export class LocalIndexService {
|
|
|
927
945
|
thread.latestPreview || "",
|
|
928
946
|
...thread.latestFrom.map((value) => `${value.name ?? ""} ${value.address ?? ""}`),
|
|
929
947
|
...thread.normalizedLabels,
|
|
930
|
-
]
|
|
931
|
-
|
|
932
|
-
.toLowerCase();
|
|
933
|
-
if (!haystack.includes(input.query.toLowerCase())) {
|
|
948
|
+
].join("\n");
|
|
949
|
+
if (!searchIncludes(haystack, input.query)) {
|
|
934
950
|
return false;
|
|
935
951
|
}
|
|
936
952
|
}
|
|
@@ -1151,7 +1167,7 @@ export class LocalIndexService {
|
|
|
1151
1167
|
// membership so a matching document thread outside DEFAULT_SNAPSHOT_LIMIT is never
|
|
1152
1168
|
// silently missed. A message with no attachments can never contribute a document
|
|
1153
1169
|
// match regardless of its text, so has_attachments = 1 narrows this further.
|
|
1154
|
-
const keywordConditions = keywords.map(() => `(
|
|
1170
|
+
const keywordConditions = keywords.map(() => `(FOLD(attachments_json) LIKE ? ESCAPE '\\' OR FOLD(subject) LIKE ? ESCAPE '\\' OR FOLD(preview) LIKE ? ESCAPE '\\' OR FOLD(attachment_text) LIKE ? ESCAPE '\\')`);
|
|
1155
1171
|
const keywordParams = [];
|
|
1156
1172
|
for (const keyword of keywords) {
|
|
1157
1173
|
const needle = `%${escapeLike(keyword)}%`;
|
|
@@ -1225,19 +1241,19 @@ export class LocalIndexService {
|
|
|
1225
1241
|
const db = await this.ensureDb();
|
|
1226
1242
|
const { ownerEmail, updatedAt, folders, indexedFolders } = this.loadFoldersAndMetadata(db);
|
|
1227
1243
|
const syncCheckpoints = this.loadCheckpointsSync(db);
|
|
1228
|
-
const personNeedle = input.person
|
|
1244
|
+
const personNeedle = input.person || undefined;
|
|
1229
1245
|
const domainNeedle = input.domain?.toLowerCase();
|
|
1230
1246
|
const limit = input.limit ?? 10;
|
|
1231
1247
|
const conditions = [];
|
|
1232
1248
|
const params = [];
|
|
1233
1249
|
if (personNeedle) {
|
|
1234
|
-
const
|
|
1235
|
-
conditions.push(
|
|
1236
|
-
params.push(
|
|
1250
|
+
const like = foldedLike(["from_json", "to_json", "cc_json"], personNeedle);
|
|
1251
|
+
conditions.push(like.sql);
|
|
1252
|
+
params.push(...like.params);
|
|
1237
1253
|
}
|
|
1238
1254
|
if (domainNeedle) {
|
|
1239
1255
|
const needle = `%@${escapeLike(domainNeedle)}%`;
|
|
1240
|
-
conditions.push(`(
|
|
1256
|
+
conditions.push(`(FOLD(from_json) LIKE ? ESCAPE '\\' OR FOLD(to_json) LIKE ? ESCAPE '\\' OR FOLD(cc_json) LIKE ? ESCAPE '\\')`);
|
|
1241
1257
|
params.push(needle, needle, needle);
|
|
1242
1258
|
}
|
|
1243
1259
|
let messages;
|
|
@@ -1263,9 +1279,9 @@ export class LocalIndexService {
|
|
|
1263
1279
|
const snapshot = { ownerEmail, updatedAt, folders, indexedFolders, syncCheckpoints, messages };
|
|
1264
1280
|
const threads = this.buildThreads(snapshot, true)
|
|
1265
1281
|
.filter((thread) => thread.participants.some((participant) => {
|
|
1266
|
-
const participantText = `${participant.name ?? ""} ${participant.address ?? ""}
|
|
1282
|
+
const participantText = `${participant.name ?? ""} ${participant.address ?? ""}`;
|
|
1267
1283
|
const participantDomain = extractDomain(participant.address || "");
|
|
1268
|
-
if (personNeedle && participantText
|
|
1284
|
+
if (personNeedle && searchIncludes(participantText, personNeedle)) {
|
|
1269
1285
|
return true;
|
|
1270
1286
|
}
|
|
1271
1287
|
if (domainNeedle && participantDomain === domainNeedle) {
|
|
@@ -1327,6 +1343,9 @@ export class LocalIndexService {
|
|
|
1327
1343
|
const db = this.db ?? new Database(this.dbPath);
|
|
1328
1344
|
if (isFirstOpen) {
|
|
1329
1345
|
this.chmodDbFiles();
|
|
1346
|
+
// SQLite's LOWER() only folds ASCII and LIKE does not ignore accents, so "łódź" could not find
|
|
1347
|
+
// "Łódź" and "pelcova" could not find "Pelcová". Compare folded text on both sides instead.
|
|
1348
|
+
db.function("FOLD", { deterministic: true }, (value) => (value == null ? null : searchKey(String(value))));
|
|
1330
1349
|
}
|
|
1331
1350
|
// auto_vacuum only takes effect on a brand-new/empty database (page_count 0) —
|
|
1332
1351
|
// setting the pragma alone does NOT retroactively enable incremental vacuuming
|
|
@@ -1354,6 +1373,7 @@ export class LocalIndexService {
|
|
|
1354
1373
|
db.exec("VACUUM");
|
|
1355
1374
|
}
|
|
1356
1375
|
this.runMigrations(db);
|
|
1376
|
+
this.rebuildFtsIfNeeded(db);
|
|
1357
1377
|
this.db = db;
|
|
1358
1378
|
await this.maybeImportLegacyIndex(db);
|
|
1359
1379
|
this.initialized = true;
|
|
@@ -1709,10 +1729,8 @@ export class LocalIndexService {
|
|
|
1709
1729
|
is_automated: email.isAutomated === undefined ? null : email.isAutomated ? 1 : 0,
|
|
1710
1730
|
delivered_to: email.deliveredTo ?? null,
|
|
1711
1731
|
});
|
|
1712
|
-
const mergedPreview = persisted.preview ?? "";
|
|
1713
|
-
const search = emailToSearchParts({ ...email, attachmentText: persisted.attachment_text ?? undefined });
|
|
1714
1732
|
deleteFts.run(email.id);
|
|
1715
|
-
insertFts.run(email.id, email
|
|
1733
|
+
insertFts.run(email.id, ...ftsColumns(email, persisted.preview ?? "", persisted.attachment_text ?? undefined));
|
|
1716
1734
|
}
|
|
1717
1735
|
if (cleanupExpunged) {
|
|
1718
1736
|
createSnapshotUidTable.run();
|
|
@@ -1779,6 +1797,37 @@ export class LocalIndexService {
|
|
|
1779
1797
|
db.pragma("incremental_vacuum");
|
|
1780
1798
|
}
|
|
1781
1799
|
}
|
|
1800
|
+
// Rebuilds messages_fts from the messages table when it was written by an older version (text stored
|
|
1801
|
+
// as-is, so "lodzi" could not find "Łodzi"). One transaction: either every row is converted or none.
|
|
1802
|
+
rebuildFtsIfNeeded(db) {
|
|
1803
|
+
const stored = db.prepare(`SELECT value FROM metadata WHERE key = 'ftsKeyVersion'`).get();
|
|
1804
|
+
if (stored?.value === FTS_KEY_VERSION)
|
|
1805
|
+
return;
|
|
1806
|
+
const total = Number(db.prepare(`SELECT COUNT(*) AS count FROM messages`).get().count);
|
|
1807
|
+
if (total > 0) {
|
|
1808
|
+
this.log.info(`Rebuilding the full-text index for ${total} messages (one-time)`, "LocalIndexService");
|
|
1809
|
+
}
|
|
1810
|
+
const insertFts = db.prepare(`
|
|
1811
|
+
INSERT INTO messages_fts (email_id, subject, preview, folder, labels, participants, attachment_names)
|
|
1812
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1813
|
+
`);
|
|
1814
|
+
const nextBatch = db.prepare(`SELECT rowid AS _rowid, * FROM messages WHERE rowid > ? ORDER BY rowid LIMIT 500`);
|
|
1815
|
+
db.transaction(() => {
|
|
1816
|
+
db.exec(`DELETE FROM messages_fts`);
|
|
1817
|
+
let after = 0;
|
|
1818
|
+
for (;;) {
|
|
1819
|
+
const rows = nextBatch.all(after);
|
|
1820
|
+
if (rows.length === 0)
|
|
1821
|
+
break;
|
|
1822
|
+
for (const row of rows) {
|
|
1823
|
+
const email = this.rowToEmailSummary(row);
|
|
1824
|
+
insertFts.run(email.id, ...ftsColumns(email, row.preview ?? "", row.attachment_text ?? undefined));
|
|
1825
|
+
}
|
|
1826
|
+
after = rows[rows.length - 1]._rowid;
|
|
1827
|
+
}
|
|
1828
|
+
db.prepare(`INSERT INTO metadata (key, value) VALUES ('ftsKeyVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(FTS_KEY_VERSION);
|
|
1829
|
+
})();
|
|
1830
|
+
}
|
|
1782
1831
|
runMigrations(db) {
|
|
1783
1832
|
db.exec(`
|
|
1784
1833
|
CREATE TABLE IF NOT EXISTS metadata (
|
|
@@ -1963,12 +2012,13 @@ export class LocalIndexService {
|
|
|
1963
2012
|
params.push(filters.hasAttachment ? 1 : 0);
|
|
1964
2013
|
}
|
|
1965
2014
|
if (filters.subject) {
|
|
1966
|
-
|
|
1967
|
-
|
|
2015
|
+
const like = foldedLike(["subject"], filters.subject);
|
|
2016
|
+
conditions.push(like.sql);
|
|
2017
|
+
params.push(...like.params);
|
|
1968
2018
|
}
|
|
1969
2019
|
if (filters.senderDomain) {
|
|
1970
|
-
conditions.push(`
|
|
1971
|
-
params.push(`%${escapeLike(filters.senderDomain
|
|
2020
|
+
conditions.push(`FOLD(from_json) LIKE ? ESCAPE '\\'`);
|
|
2021
|
+
params.push(`%${escapeLike(foldSearchText(filters.senderDomain))}%`);
|
|
1972
2022
|
}
|
|
1973
2023
|
// from/to/messageId were accepted by search_indexed_emails but never
|
|
1974
2024
|
// narrowed the SQL candidate scan — only applied afterward by
|
|
@@ -1978,13 +2028,14 @@ export class LocalIndexService {
|
|
|
1978
2028
|
// genuine match older than the window instead of finding it. Mirror the
|
|
1979
2029
|
// existing senderDomain LIKE pattern (a safe superset pre-filter).
|
|
1980
2030
|
if (filters.from) {
|
|
1981
|
-
|
|
1982
|
-
|
|
2031
|
+
const like = foldedLike(["from_json"], filters.from);
|
|
2032
|
+
conditions.push(like.sql);
|
|
2033
|
+
params.push(...like.params);
|
|
1983
2034
|
}
|
|
1984
2035
|
if (filters.to) {
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
params.push(
|
|
2036
|
+
const like = foldedLike(["to_json", "cc_json", "bcc_json"], filters.to);
|
|
2037
|
+
conditions.push(like.sql);
|
|
2038
|
+
params.push(...like.params);
|
|
1988
2039
|
}
|
|
1989
2040
|
if (filters.messageId) {
|
|
1990
2041
|
conditions.push(`message_id = ?`);
|
|
@@ -2035,8 +2086,13 @@ export class LocalIndexService {
|
|
|
2035
2086
|
// NOT-prefix, so this searches for the term itself instead of silently dropping
|
|
2036
2087
|
// it (e.g. `AND gate schematics` now matches "AND", "gate" and "schematics" all
|
|
2037
2088
|
// required, rather than dropping "AND" and searching only the other two).
|
|
2089
|
+
// Each term is looked for in its folded form(s): the stored text is a searchKey (see
|
|
2090
|
+
// rebuildFtsIfNeeded), so Müller is stored as "muller" and "mueller" and either finds it.
|
|
2091
|
+
const quote = (form) => `"${form.replace(/"/g, '""')}"`;
|
|
2038
2092
|
const match = parsed.residualTerms
|
|
2039
|
-
.map((token) =>
|
|
2093
|
+
.map((token) => searchNeedles(token).filter(Boolean).map(quote))
|
|
2094
|
+
.filter((forms) => forms.length > 0)
|
|
2095
|
+
.map((forms) => (forms.length > 1 ? `(${forms.join(" OR ")})` : forms[0]))
|
|
2040
2096
|
.join(" AND ");
|
|
2041
2097
|
if (!match) {
|
|
2042
2098
|
// No terms survived parsing at all (e.g. an empty/whitespace-only query), so
|
|
@@ -2148,9 +2204,9 @@ export class LocalIndexService {
|
|
|
2148
2204
|
messageParams.push(options.folder);
|
|
2149
2205
|
}
|
|
2150
2206
|
if (options.label) {
|
|
2151
|
-
const
|
|
2152
|
-
messageConditions.push(
|
|
2153
|
-
messageParams.push(
|
|
2207
|
+
const like = foldedLike(["folder", "labels_json"], options.label);
|
|
2208
|
+
messageConditions.push(like.sql);
|
|
2209
|
+
messageParams.push(...like.params);
|
|
2154
2210
|
}
|
|
2155
2211
|
if (typeof options.isRead === "boolean") {
|
|
2156
2212
|
messageConditions.push(`is_read = ?`);
|
|
@@ -2190,14 +2246,14 @@ export class LocalIndexService {
|
|
|
2190
2246
|
params.push(input.folder);
|
|
2191
2247
|
}
|
|
2192
2248
|
if (input.label) {
|
|
2193
|
-
const
|
|
2194
|
-
conditions.push(
|
|
2195
|
-
params.push(
|
|
2249
|
+
const like = foldedLike(["folder", "labels_json"], input.label);
|
|
2250
|
+
conditions.push(like.sql);
|
|
2251
|
+
params.push(...like.params);
|
|
2196
2252
|
}
|
|
2197
2253
|
if (input.query) {
|
|
2198
|
-
const
|
|
2199
|
-
conditions.push(
|
|
2200
|
-
params.push(
|
|
2254
|
+
const like = foldedLike(["subject", "from_json", "to_json", "cc_json", "bcc_json", "labels_json"], input.query);
|
|
2255
|
+
conditions.push(like.sql);
|
|
2256
|
+
params.push(...like.params);
|
|
2201
2257
|
}
|
|
2202
2258
|
const sql = `SELECT * FROM messages${conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : ""}`;
|
|
2203
2259
|
const candidates = db
|