@bobfrankston/rmfmail 1.2.196 → 1.2.197

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.
@@ -3946,8 +3946,23 @@ export class MailxDB {
3946
3946
  const LIKE_SUBJ = ["m.subject"];
3947
3947
  const LIKE_ANY = ["m.subject", "m.from_name", "m.from_address"];
3948
3948
 
3949
+ // `NOT <item>` — exclusion, usable ANYWHERE including as the leading
3950
+ // (or only) token. FTS5's own NOT is strictly binary (`a NOT b`), so
3951
+ // a leading NOT was an FTS5 syntax error that the dangling-operator
3952
+ // trim "fixed" by dropping the NOT — silently searching FOR the term
3953
+ // the user asked to exclude (Bob 2026-07-30). Instead of leaning on
3954
+ // FTS5's operator, every `NOT x` pair is lifted OUT of the MATCH and
3955
+ // applied as a SQL-side exclusion (`m.id NOT IN (SELECT rowid …
3956
+ // MATCH x)`), which also negates qualifier tokens (`NOT from:…`,
3957
+ // `NOT is:read`) uniformly. Uppercase only, same as AND/OR — a
3958
+ // lowercase "not" stays an ordinary search term.
3959
+ const notFrags: string[] = [];
3960
+ let negateNext = false;
3949
3961
  for (let pi = 0; pi < parts.length; pi++) {
3950
3962
  const part = parts[pi];
3963
+ if (part === "NOT") { negateNext = true; continue; }
3964
+ const wPre = extraWhere.length;
3965
+ const fPre = frags.length;
3951
3966
  // A 1-2 char term next to a user-typed OR must be DROPPED, not
3952
3967
  // LIKE'd: LIKE clauses AND against the MATCH, so "ab OR sprinkler"
3953
3968
  // would intersect down to zero. Over-matching (just "sprinkler")
@@ -4029,12 +4044,14 @@ export class MailxDB {
4029
4044
  const v = folderMatch[1].replace(/"/g, "");
4030
4045
  extraWhere.push("LOWER(f.name) LIKE ?");
4031
4046
  extraParams.push(`%${v.toLowerCase()}%`);
4032
- } else if (/^(AND|OR|NOT)$/.test(part)) {
4047
+ } else if (/^(AND|OR)$/.test(part)) {
4033
4048
  // FTS5 boolean operators — pass through verbatim (must be uppercase).
4034
4049
  // Without this branch, `hoddie AND git` got wildcarded into
4035
4050
  // `hoddie* AND* git*`, which FTS5 reads as three required terms
4036
4051
  // (one of them being any word starting with "AND") — so a real
4037
4052
  // match like "Peter Hoddie" + "github" returned zero hits.
4053
+ // (NOT is handled above as an exclusion prefix, not passed
4054
+ // through — FTS5's binary-only NOT can't stand at the front.)
4038
4055
  frags.push({ s: part, op: true });
4039
4056
  } else if (isTri) {
4040
4057
  // Unqualified, trigram dialect — quoted substring match. A
@@ -4087,6 +4104,18 @@ export class MailxDB {
4087
4104
  }
4088
4105
  }
4089
4106
  }
4107
+ // Divert whatever this token contributed into the exclusion set.
4108
+ // FTS frags move to notFrags (applied as a NOT IN subquery below);
4109
+ // SQL-side qualifier clauses get wrapped in NOT(...) in place. An
4110
+ // AND/OR right after NOT is nonsense input — the op frag stands
4111
+ // and the stray NOT is dropped.
4112
+ if (negateNext) {
4113
+ if (!/^(AND|OR)$/.test(part)) {
4114
+ for (let w = wPre; w < extraWhere.length; w++) extraWhere[w] = `NOT (${extraWhere[w]})`;
4115
+ while (frags.length > fPre) notFrags.push(frags.pop()!.s);
4116
+ }
4117
+ negateNext = false;
4118
+ }
4090
4119
  }
4091
4120
 
4092
4121
  // A term diverted to the LIKE fallback can strand a user-typed
@@ -4096,6 +4125,15 @@ export class MailxDB {
4096
4125
  while (frags.length && frags[0].op) frags.shift();
4097
4126
  while (frags.length && frags[frags.length - 1].op) frags.pop();
4098
4127
 
4128
+ // NOT'd terms exclude via a subquery on the same FTS index: a message
4129
+ // matching ANY excluded term is out. Works whether or not there are
4130
+ // positive FTS terms — a pure `NOT foo` query takes the qualifier-only
4131
+ // path below and returns everything except matches.
4132
+ if (notFrags.length > 0) {
4133
+ extraWhere.push("m.id NOT IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH ?)");
4134
+ extraParams.push(notFrags.join(" OR "));
4135
+ }
4136
+
4099
4137
  // Join fragments. Two adjacent value fragments need an explicit `AND`
4100
4138
  // (implicit-AND after a `(...)` / `{...}:` group is an FTS5 syntax
4101
4139
  // error); a user operator fragment glues itself, so no insert around it.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",