@bobfrankston/mailx-store 0.1.76 → 0.1.78

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.
Files changed (2) hide show
  1. package/db.js +43 -1
  2. package/package.json +1 -1
package/db.js CHANGED
@@ -3704,8 +3704,26 @@ export class MailxDB {
3704
3704
  const LIKE_CC = ["m.cc_json"];
3705
3705
  const LIKE_SUBJ = ["m.subject"];
3706
3706
  const LIKE_ANY = ["m.subject", "m.from_name", "m.from_address"];
3707
+ // `NOT <item>` — exclusion, usable ANYWHERE including as the leading
3708
+ // (or only) token. FTS5's own NOT is strictly binary (`a NOT b`), so
3709
+ // a leading NOT was an FTS5 syntax error that the dangling-operator
3710
+ // trim "fixed" by dropping the NOT — silently searching FOR the term
3711
+ // the user asked to exclude (Bob 2026-07-30). Instead of leaning on
3712
+ // FTS5's operator, every `NOT x` pair is lifted OUT of the MATCH and
3713
+ // applied as a SQL-side exclusion (`m.id NOT IN (SELECT rowid …
3714
+ // MATCH x)`), which also negates qualifier tokens (`NOT from:…`,
3715
+ // `NOT is:read`) uniformly. Uppercase only, same as AND/OR — a
3716
+ // lowercase "not" stays an ordinary search term.
3717
+ const notFrags = [];
3718
+ let negateNext = false;
3707
3719
  for (let pi = 0; pi < parts.length; pi++) {
3708
3720
  const part = parts[pi];
3721
+ if (part === "NOT") {
3722
+ negateNext = true;
3723
+ continue;
3724
+ }
3725
+ const wPre = extraWhere.length;
3726
+ const fPre = frags.length;
3709
3727
  // A 1-2 char term next to a user-typed OR must be DROPPED, not
3710
3728
  // LIKE'd: LIKE clauses AND against the MATCH, so "ab OR sprinkler"
3711
3729
  // would intersect down to zero. Over-matching (just "sprinkler")
@@ -3826,12 +3844,14 @@ export class MailxDB {
3826
3844
  extraWhere.push("LOWER(f.name) LIKE ?");
3827
3845
  extraParams.push(`%${v.toLowerCase()}%`);
3828
3846
  }
3829
- else if (/^(AND|OR|NOT)$/.test(part)) {
3847
+ else if (/^(AND|OR)$/.test(part)) {
3830
3848
  // FTS5 boolean operators — pass through verbatim (must be uppercase).
3831
3849
  // Without this branch, `hoddie AND git` got wildcarded into
3832
3850
  // `hoddie* AND* git*`, which FTS5 reads as three required terms
3833
3851
  // (one of them being any word starting with "AND") — so a real
3834
3852
  // match like "Peter Hoddie" + "github" returned zero hits.
3853
+ // (NOT is handled above as an exclusion prefix, not passed
3854
+ // through — FTS5's binary-only NOT can't stand at the front.)
3835
3855
  frags.push({ s: part, op: true });
3836
3856
  }
3837
3857
  else if (isTri) {
@@ -3893,6 +3913,20 @@ export class MailxDB {
3893
3913
  }
3894
3914
  }
3895
3915
  }
3916
+ // Divert whatever this token contributed into the exclusion set.
3917
+ // FTS frags move to notFrags (applied as a NOT IN subquery below);
3918
+ // SQL-side qualifier clauses get wrapped in NOT(...) in place. An
3919
+ // AND/OR right after NOT is nonsense input — the op frag stands
3920
+ // and the stray NOT is dropped.
3921
+ if (negateNext) {
3922
+ if (!/^(AND|OR)$/.test(part)) {
3923
+ for (let w = wPre; w < extraWhere.length; w++)
3924
+ extraWhere[w] = `NOT (${extraWhere[w]})`;
3925
+ while (frags.length > fPre)
3926
+ notFrags.push(frags.pop().s);
3927
+ }
3928
+ negateNext = false;
3929
+ }
3896
3930
  }
3897
3931
  // A term diverted to the LIKE fallback can strand a user-typed
3898
3932
  // operator at the edge of the MATCH string ("ab OR sprinkler" → the
@@ -3902,6 +3936,14 @@ export class MailxDB {
3902
3936
  frags.shift();
3903
3937
  while (frags.length && frags[frags.length - 1].op)
3904
3938
  frags.pop();
3939
+ // NOT'd terms exclude via a subquery on the same FTS index: a message
3940
+ // matching ANY excluded term is out. Works whether or not there are
3941
+ // positive FTS terms — a pure `NOT foo` query takes the qualifier-only
3942
+ // path below and returns everything except matches.
3943
+ if (notFrags.length > 0) {
3944
+ extraWhere.push("m.id NOT IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH ?)");
3945
+ extraParams.push(notFrags.join(" OR "));
3946
+ }
3905
3947
  // Join fragments. Two adjacent value fragments need an explicit `AND`
3906
3948
  // (implicit-AND after a `(...)` / `{...}:` group is an FTS5 syntax
3907
3949
  // error); a user operator fragment glues itself, so no insert around it.
package/package.json CHANGED
@@ -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",