@ezetgalaxy/cxr 1.1.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/LICENSE +15 -0
- package/README.md +235 -0
- package/grammar.md +150 -0
- package/package.json +25 -0
- package/src/executor.js +159 -0
- package/src/index.d.ts +141 -0
- package/src/index.js +56 -0
- package/src/lexer.js +188 -0
- package/src/parser.js +246 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Ezet Galaxy
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
|
|
2
|
+
# CXR - Email Rule eXecutor
|
|
3
|
+
|
|
4
|
+
**CXR** is a production-ready, open-source rule engine / compiler for email filtering logic, written in JavaScript (Node.js, ESM).
|
|
5
|
+
It parses a custom `.cxr` domain language and executes deterministically against normalized email objects.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Safe**: No `eval`, no `Function`, purely AST-based execution.
|
|
10
|
+
- **Deterministic**: Input -> Output is always the same.
|
|
11
|
+
- **Explainable**: Returns metadata and logs of all actions taken.
|
|
12
|
+
- **Extensible**: Pure JSON output format.
|
|
13
|
+
- **Zero Dependencies**: Lightweight and fast.
|
|
14
|
+
- **Flexible Loading**: Load rules from strings, files (`.cxr`), or combine multiple sources.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @ezetgalaxy/cxr
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
import cxr from "@ezetgalaxy/cxr";
|
|
26
|
+
|
|
27
|
+
// 1. Prepare email objects
|
|
28
|
+
const emails = [
|
|
29
|
+
{
|
|
30
|
+
id: "1",
|
|
31
|
+
from: "notifications@github.com",
|
|
32
|
+
subject: "New login from Chrome",
|
|
33
|
+
body: "...",
|
|
34
|
+
unread: true,
|
|
35
|
+
receivedAt: Date.now()
|
|
36
|
+
}
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// 2. Execute using file path
|
|
40
|
+
const result = cxr.init({
|
|
41
|
+
rules: "./rules/alerts.cxr", // Path to .cxr file
|
|
42
|
+
emails
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Or combine multiple files/strings
|
|
46
|
+
const resultCombined = cxr.init({
|
|
47
|
+
rules: [
|
|
48
|
+
"./rules/alerts.cxr",
|
|
49
|
+
"./rules/personal.cxr",
|
|
50
|
+
`Folder Temporary WHEN subject contains "urgent" THEN notify`
|
|
51
|
+
],
|
|
52
|
+
emails
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
console.log("Folders:", result.folders);
|
|
57
|
+
console.log("Actions Log:", result.actionsLog);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Output Example
|
|
61
|
+
|
|
62
|
+
The `result` object is pure JSON, making it easy to consume:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"folders": {
|
|
67
|
+
"Inbox": [],
|
|
68
|
+
"Alerts": [
|
|
69
|
+
{
|
|
70
|
+
"id": "1",
|
|
71
|
+
"from": "notifications@github.com",
|
|
72
|
+
"subject": "New login from Chrome",
|
|
73
|
+
"unread": true
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
"actionsLog": [
|
|
78
|
+
{
|
|
79
|
+
"emailId": "1",
|
|
80
|
+
"folder": "Alerts",
|
|
81
|
+
"actions": ["move to Alerts", "notify", "mark as unread"]
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"meta": {
|
|
85
|
+
"engine": "cxr",
|
|
86
|
+
"version": "1.0.0",
|
|
87
|
+
"processedAt": 1700000000000,
|
|
88
|
+
"ruleCount": 5,
|
|
89
|
+
"emailCount": 1
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## UI Integration Example (React)
|
|
95
|
+
|
|
96
|
+
Because CXR returns structured data, building a UI is straightforward.
|
|
97
|
+
|
|
98
|
+
```jsx
|
|
99
|
+
import React from 'react';
|
|
100
|
+
import cxr from 'cxr';
|
|
101
|
+
|
|
102
|
+
function EmailClient({ rules, rawEmails }) {
|
|
103
|
+
// 1. Run the engine
|
|
104
|
+
const { folders } = cxr.init({ rules, emails: rawEmails });
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="email-client">
|
|
108
|
+
{Object.entries(folders).map(([folderName, emails]) => (
|
|
109
|
+
<div key={folderName} className="folder">
|
|
110
|
+
<h3>📂 {folderName} <span className="count">({emails.length})</span></h3>
|
|
111
|
+
|
|
112
|
+
<ul className="email-list">
|
|
113
|
+
{emails.map(email => (
|
|
114
|
+
<li key={email.id} className={email.unread ? 'unread' : ''}>
|
|
115
|
+
<span className="sender">{email.from}</span>
|
|
116
|
+
<span className="subject">{email.subject}</span>
|
|
117
|
+
</li>
|
|
118
|
+
))}
|
|
119
|
+
</ul>
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
## Language Syntax
|
|
129
|
+
|
|
130
|
+
See [GRAMMAR.md](./grammar.md) for full specification.
|
|
131
|
+
|
|
132
|
+
### File Extension
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
.cxr
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Example Rule File (`alerts.cxr`)
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
Folder Alerts
|
|
142
|
+
|
|
143
|
+
WHEN sender contains "github"
|
|
144
|
+
OR subject contains "login"
|
|
145
|
+
AND NOT sender contains "newsletter"
|
|
146
|
+
|
|
147
|
+
THEN move to Alerts
|
|
148
|
+
AND notify
|
|
149
|
+
AND mark as unread
|
|
150
|
+
|
|
151
|
+
Folder Personal
|
|
152
|
+
|
|
153
|
+
WHEN sender IN ["mom@family.com", "dad@family.com"]
|
|
154
|
+
THEN move to Personal
|
|
155
|
+
AND mark as read
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## API
|
|
159
|
+
|
|
160
|
+
### `cxr.init(config)`
|
|
161
|
+
|
|
162
|
+
- `config.rules` (string | string[]):
|
|
163
|
+
- A string containing rule content.
|
|
164
|
+
- A string containing a file path (must end in `.cxr`).
|
|
165
|
+
- An array of strings (content or file paths).
|
|
166
|
+
|
|
167
|
+
- `config.emails` (Array): List of email objects.
|
|
168
|
+
- Each email must have: `id`, `from`, `subject`, `body`, `unread`, `receivedAt` (by default).
|
|
169
|
+
- `config.schema` (Object, optional): Custom field mapping.
|
|
170
|
+
- Maps rule field names (e.g., `sender`) to your email object keys (e.g., `from_address`).
|
|
171
|
+
- Default: `{ sender: 'from', subject: 'subject', body: 'body' }`
|
|
172
|
+
|
|
173
|
+
### Custom Schema Example
|
|
174
|
+
|
|
175
|
+
If your email objects look different, you can map the fields:
|
|
176
|
+
|
|
177
|
+
```javascript
|
|
178
|
+
/* Email Object:
|
|
179
|
+
{
|
|
180
|
+
id: '1',
|
|
181
|
+
author: 'admin@site.com', // Instead of 'from'
|
|
182
|
+
content: 'Text...', // Instead of 'body'
|
|
183
|
+
'X-Priority': 'High' // Custom header
|
|
184
|
+
}
|
|
185
|
+
*/
|
|
186
|
+
|
|
187
|
+
/* Rule:
|
|
188
|
+
Folder Urgent
|
|
189
|
+
WHEN priority contains "High" AND sender contains "admin"
|
|
190
|
+
THEN move to Urgent
|
|
191
|
+
*/
|
|
192
|
+
|
|
193
|
+
cxr.init({
|
|
194
|
+
rules: rules,
|
|
195
|
+
emails: myEmails,
|
|
196
|
+
schema: {
|
|
197
|
+
sender: 'author',
|
|
198
|
+
body: 'content',
|
|
199
|
+
priority: 'X-Priority'
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
Returns an object with:
|
|
206
|
+
- `folders`: Mapping of folder names to arrays of matched emails.
|
|
207
|
+
|
|
208
|
+
- `actionsLog`: Array of action records per email.
|
|
209
|
+
- `meta`: Execution metadata (engine name: "cxr").
|
|
210
|
+
|
|
211
|
+
## VS Code Extension
|
|
212
|
+
|
|
213
|
+
Included in this repository is a VS Code extension for `.cxr` files.
|
|
214
|
+
|
|
215
|
+
**Features:**
|
|
216
|
+
- Syntax Highlighting for Keywords, Actions, and Fields.
|
|
217
|
+
- Autocomplete / IntelliSense for rule structures.
|
|
218
|
+
- Snippets for quick rule creation.
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
**Installation (Manual):**
|
|
222
|
+
1. **Download**: Get the `cxr-language-support-0.0.1.vsix` file from this repository.
|
|
223
|
+
2. **Open VS Code / Cursor**:
|
|
224
|
+
- Go to the **Extensions View** (Ctrl+Shift+X).
|
|
225
|
+
- Click the **... (More Actions)** menu (top right of the pane).
|
|
226
|
+
- Select **Install from VSIX...**.
|
|
227
|
+
3. **Select File**: Choose the `.vsix` file you downloaded.
|
|
228
|
+
4. **Restart**: Reload the window to activate full support.
|
|
229
|
+
|
|
230
|
+
*Note: This extension is not yet published to the Visual Studio Marketplace so it will not appear in search results.*
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
ISC
|
package/grammar.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
|
|
2
|
+
# CXR Grammar Specification
|
|
3
|
+
|
|
4
|
+
This document defines the formal grammar and syntax for the `.cxr` file format.
|
|
5
|
+
|
|
6
|
+
## Language Overview
|
|
7
|
+
|
|
8
|
+
A `.cxr` file consists of one or more **Folder** definitions. Each folder contains a set of **Rules** that determine which emails should be sorted into that folder.
|
|
9
|
+
|
|
10
|
+
The engine evaluates rules from top to bottom. The **first matching folder wins**.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Basic Structure
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Folder <FolderName>
|
|
18
|
+
|
|
19
|
+
WHEN <Condition>
|
|
20
|
+
THEN <Actions>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Example
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
Folder "Important"
|
|
27
|
+
|
|
28
|
+
WHEN sender contains "boss"
|
|
29
|
+
THEN move to "Important"
|
|
30
|
+
AND notify
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 1. Folder Definition
|
|
36
|
+
|
|
37
|
+
Every rule block must start with a `Folder` declaration.
|
|
38
|
+
|
|
39
|
+
- **Syntax**: `Folder <Name>`
|
|
40
|
+
- **Name**: Can be a simple word (`Alerts`) or a quoted string (`"My Alerts"`).
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
Folder Personal
|
|
44
|
+
Folder "Work Projects"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 2. Conditions (`WHEN`)
|
|
50
|
+
|
|
51
|
+
The `WHEN` clause defines the logic for matching an email.
|
|
52
|
+
|
|
53
|
+
### Fields
|
|
54
|
+
- `sender`: The email address of the sender (mapped to `from`).
|
|
55
|
+
- `subject`: The subject line.
|
|
56
|
+
- `body`: The email body content.
|
|
57
|
+
|
|
58
|
+
### Operators
|
|
59
|
+
- `contains`: Checks if the field contains a substring (case-insensitive).
|
|
60
|
+
- `IN`: Checks if the field matches any value in a list.
|
|
61
|
+
|
|
62
|
+
### Examples
|
|
63
|
+
|
|
64
|
+
**Simple Match:**
|
|
65
|
+
```text
|
|
66
|
+
WHEN subject contains "urgent"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**List Match:**
|
|
70
|
+
```text
|
|
71
|
+
WHEN sender IN ["mom@family.com", "dad@family.com"]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Logical Operators:**
|
|
75
|
+
- `AND`: Both conditions must be true.
|
|
76
|
+
- `OR`: At least one condition must be true.
|
|
77
|
+
- `NOT`: The condition must be false.
|
|
78
|
+
|
|
79
|
+
**Complex Logic:**
|
|
80
|
+
```text
|
|
81
|
+
WHEN (sender contains "github" OR subject contains "login")
|
|
82
|
+
AND NOT sender contains "newsletter"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
*Note: You can use parentheses `()` to group logic.*
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 3. Actions (`THEN`)
|
|
90
|
+
|
|
91
|
+
The `THEN` clause defines what happens when a rule matches. Multiple actions can be chained with `AND`.
|
|
92
|
+
|
|
93
|
+
### Supported Actions
|
|
94
|
+
|
|
95
|
+
| Action | Description |
|
|
96
|
+
| :--- | :--- |
|
|
97
|
+
| `move to <Folder>` | Sorts the email into the specified folder. |
|
|
98
|
+
| `remove from <Folder>` | (Metadata) Indicates removal from a source folder. |
|
|
99
|
+
| `notify` | Flags the email for a notification. |
|
|
100
|
+
| `call` | Flags the email for a phone call alert. |
|
|
101
|
+
| `remind` | Sets a reminder flag. |
|
|
102
|
+
| `mark as read` | Marks the email as read. |
|
|
103
|
+
| `mark as unread` | Marks the email as unread. |
|
|
104
|
+
| `auto` | Generic automation flag. |
|
|
105
|
+
|
|
106
|
+
### Example
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
THEN move to "Finance"
|
|
110
|
+
AND notify
|
|
111
|
+
AND mark as read
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Formal EBNF Grammar
|
|
117
|
+
|
|
118
|
+
For parser implementers, here is the Extended Backus-Naur Form (EBNF) grammar:
|
|
119
|
+
|
|
120
|
+
```ebnf
|
|
121
|
+
program = { folder_block } ;
|
|
122
|
+
folder_block = "Folder" , identifier , { rule } ;
|
|
123
|
+
rule = "WHEN" , condition_expr , "THEN" , action_list ;
|
|
124
|
+
|
|
125
|
+
condition_expr = or_term ;
|
|
126
|
+
or_term = and_term , { "OR" , and_term } ;
|
|
127
|
+
and_term = not_factor , { "AND" , not_factor } ;
|
|
128
|
+
not_factor = [ "NOT" ] , factor ;
|
|
129
|
+
factor = "(" , condition_expr , ")" | predicate ;
|
|
130
|
+
|
|
131
|
+
predicate = field , op_contains , string_literal
|
|
132
|
+
| field , "IN" , ( string_literal | string_list ) ;
|
|
133
|
+
|
|
134
|
+
field = "sender" | "subject" | "body" ;
|
|
135
|
+
op_contains = "contains" ;
|
|
136
|
+
|
|
137
|
+
action_list = action , { "AND" , action } ;
|
|
138
|
+
action = "move to" , identifier
|
|
139
|
+
| "remove from" , identifier
|
|
140
|
+
| "notify"
|
|
141
|
+
| "call"
|
|
142
|
+
| "remind"
|
|
143
|
+
| "mark as read"
|
|
144
|
+
| "mark as unread"
|
|
145
|
+
| "auto" ;
|
|
146
|
+
|
|
147
|
+
identifier = ? alphanumeric string or quoted string ? ;
|
|
148
|
+
string_literal = '"' , { ? characters ? } , '"' ;
|
|
149
|
+
string_list = "[" , string_literal , { "," , string_literal } , "]" ;
|
|
150
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ezetgalaxy/cxr",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "CXR - Email Rule eXecutor. A safe, deterministic, and explainable rule engine for email filtering.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publisher": "ezetgalaxy",
|
|
7
|
+
"author": "Ezet Galaxy",
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"exports": "./src/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"email",
|
|
15
|
+
"rule-engine",
|
|
16
|
+
"compiler",
|
|
17
|
+
"automation",
|
|
18
|
+
"filtering",
|
|
19
|
+
"cxr"
|
|
20
|
+
],
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18.0.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/executor.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
|
|
2
|
+
import { Parser } from './parser.js';
|
|
3
|
+
import { Lexer } from './lexer.js';
|
|
4
|
+
|
|
5
|
+
export class Executor {
|
|
6
|
+
constructor() {
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
static execute(rulesContent, emails, schema = {}) {
|
|
11
|
+
// 1. Lex & Parse
|
|
12
|
+
const lexer = new Lexer(rulesContent);
|
|
13
|
+
const tokens = lexer.tokenize();
|
|
14
|
+
const parser = new Parser(tokens);
|
|
15
|
+
const ast = parser.parse();
|
|
16
|
+
|
|
17
|
+
// 2. Prepare Output Structures
|
|
18
|
+
const folders = {
|
|
19
|
+
Inbox: []
|
|
20
|
+
};
|
|
21
|
+
const actionsLog = [];
|
|
22
|
+
let ruleCount = 0;
|
|
23
|
+
|
|
24
|
+
// Initialize folder buckets
|
|
25
|
+
for (const folder of ast.folders) {
|
|
26
|
+
// Ensure folder buckets exist, even if empty
|
|
27
|
+
if (!folders[folder.name]) {
|
|
28
|
+
folders[folder.name] = [];
|
|
29
|
+
}
|
|
30
|
+
ruleCount += folder.rules.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const customSchema = {
|
|
34
|
+
sender: 'from',
|
|
35
|
+
subject: 'subject',
|
|
36
|
+
body: 'body',
|
|
37
|
+
...schema
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// 3. Process Emails
|
|
41
|
+
for (const email of emails) {
|
|
42
|
+
let matched = false;
|
|
43
|
+
let targetFolder = 'Inbox';
|
|
44
|
+
let emailActions = [];
|
|
45
|
+
let matchedFolderName = null;
|
|
46
|
+
|
|
47
|
+
// Iterate Folders
|
|
48
|
+
for (const folder of ast.folders) {
|
|
49
|
+
// Iterate Rules in Folder
|
|
50
|
+
for (const rule of folder.rules) {
|
|
51
|
+
if (Executor.evaluateCondition(rule.condition, email, customSchema)) {
|
|
52
|
+
matched = true;
|
|
53
|
+
matchedFolderName = folder.name;
|
|
54
|
+
|
|
55
|
+
// Execute Actions
|
|
56
|
+
for (const actionNode of rule.actions) {
|
|
57
|
+
if (actionNode.action === 'move') {
|
|
58
|
+
targetFolder = actionNode.target;
|
|
59
|
+
}
|
|
60
|
+
// Add to log
|
|
61
|
+
emailActions.push(Executor.serializeAction(actionNode));
|
|
62
|
+
}
|
|
63
|
+
break; // Stop checking rules in this folder
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (matched) break; // Stop checking other folders (First matching folder wins)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Assign to bucket
|
|
70
|
+
if (!folders[targetFolder]) {
|
|
71
|
+
folders[targetFolder] = [];
|
|
72
|
+
}
|
|
73
|
+
folders[targetFolder].push(email);
|
|
74
|
+
|
|
75
|
+
// Log actions if matched
|
|
76
|
+
if (matched) {
|
|
77
|
+
actionsLog.push({
|
|
78
|
+
emailId: email.id,
|
|
79
|
+
folder: matchedFolderName,
|
|
80
|
+
actions: emailActions
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
folders,
|
|
87
|
+
actionsLog,
|
|
88
|
+
meta: {
|
|
89
|
+
engine: "cxr",
|
|
90
|
+
version: "1.0.0",
|
|
91
|
+
processedAt: Date.now(),
|
|
92
|
+
ruleCount,
|
|
93
|
+
emailCount: emails.length
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static evaluateCondition(node, email, schema) {
|
|
99
|
+
switch (node.type) {
|
|
100
|
+
case 'BinaryExpression':
|
|
101
|
+
if (node.operator === 'AND') {
|
|
102
|
+
return Executor.evaluateCondition(node.left, email, schema) && Executor.evaluateCondition(node.right, email, schema);
|
|
103
|
+
} else if (node.operator === 'OR') {
|
|
104
|
+
return Executor.evaluateCondition(node.left, email, schema) || Executor.evaluateCondition(node.right, email, schema);
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`Unknown binary operator: ${node.operator}`);
|
|
107
|
+
|
|
108
|
+
case 'UnaryExpression':
|
|
109
|
+
if (node.operator === 'NOT') {
|
|
110
|
+
return !Executor.evaluateCondition(node.argument, email, schema);
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`Unknown unary operator: ${node.operator}`);
|
|
113
|
+
|
|
114
|
+
case 'Predicate':
|
|
115
|
+
return Executor.evaluatePredicate(node, email, schema);
|
|
116
|
+
|
|
117
|
+
case 'InPredicate':
|
|
118
|
+
return Executor.evaluateInPredicate(node, email, schema);
|
|
119
|
+
|
|
120
|
+
default:
|
|
121
|
+
throw new Error(`Unknown condition node type: ${node.type}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static evaluatePredicate(node, email, schema) {
|
|
126
|
+
const fieldName = schema[node.field] || node.field;
|
|
127
|
+
const value = (email[fieldName] || '').toString();
|
|
128
|
+
const target = node.value;
|
|
129
|
+
|
|
130
|
+
if (node.operator === 'contains') {
|
|
131
|
+
return value.toLowerCase().includes(target.toLowerCase());
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
static evaluateInPredicate(node, email, schema) {
|
|
137
|
+
const fieldName = schema[node.field] || node.field;
|
|
138
|
+
const value = (email[fieldName] || '').toString();
|
|
139
|
+
const targets = node.value; // Array of strings
|
|
140
|
+
|
|
141
|
+
const lowerValue = value.toLowerCase();
|
|
142
|
+
// Check if value includes any of the target strings (case-insensitive)
|
|
143
|
+
return targets.some(target => lowerValue.includes(target.toLowerCase()));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
static serializeAction(actionNode) {
|
|
147
|
+
switch (actionNode.action) {
|
|
148
|
+
case 'move': return `move to ${actionNode.target}`;
|
|
149
|
+
case 'remove': return `remove from ${actionNode.target}`;
|
|
150
|
+
case 'notify': return 'notify';
|
|
151
|
+
case 'call': return 'call';
|
|
152
|
+
case 'remind': return 'remind';
|
|
153
|
+
case 'mark_read': return 'mark as read';
|
|
154
|
+
case 'mark_unread': return 'mark as unread';
|
|
155
|
+
case 'auto': return 'auto';
|
|
156
|
+
default: return actionNode.action;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
|
|
2
|
+
export interface Email {
|
|
3
|
+
id: string;
|
|
4
|
+
from: string; // Mapped from 'sender' in rules
|
|
5
|
+
subject: string;
|
|
6
|
+
body: string;
|
|
7
|
+
unread: boolean;
|
|
8
|
+
receivedAt: number;
|
|
9
|
+
[key: string]: any; // Allow other properties
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ActionLog {
|
|
13
|
+
emailId: string;
|
|
14
|
+
folder: string;
|
|
15
|
+
actions: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResultMeta {
|
|
19
|
+
engine: string;
|
|
20
|
+
version: string;
|
|
21
|
+
processedAt: number;
|
|
22
|
+
ruleCount: number;
|
|
23
|
+
emailCount: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ExecutionResult {
|
|
27
|
+
folders: Record<string, Email[]>;
|
|
28
|
+
actionsLog: ActionLog[];
|
|
29
|
+
meta: ResultMeta;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InitConfig {
|
|
33
|
+
/**
|
|
34
|
+
* Rule content or file paths.
|
|
35
|
+
* Can be a single string (content or .cxr path) or an array of strings.
|
|
36
|
+
* If a string ends with '.cxr', it is treated as a file path and read from disk.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* // Load from file
|
|
40
|
+
* rules: "./rules/alerts.cxr"
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* // Inline content
|
|
44
|
+
* rules: `Folder Alerts WHEN sender contains "github" THEN move to Alerts`
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* // Combined
|
|
48
|
+
* rules: ["./common.cxr", "./personal.cxr"]
|
|
49
|
+
*/
|
|
50
|
+
rules: string | string[];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* List of email objects to process.
|
|
54
|
+
* Each email object must minimally contain: id, from, subject, body, unread, receivedAt.
|
|
55
|
+
*/
|
|
56
|
+
emails: Email[];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Optional custom schema mapping.
|
|
60
|
+
* Maps rule field names to email object property names.
|
|
61
|
+
* Default schema: { sender: 'from', subject: 'subject', body: 'body' }
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* // Map 'priority' in rules to 'X-Priority' in email object
|
|
65
|
+
* schema: { priority: 'X-Priority' }
|
|
66
|
+
*/
|
|
67
|
+
schema?: Record<string, string>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Initialize the CXR engine with rules and emails.
|
|
72
|
+
*
|
|
73
|
+
* This is the main entry point. It parses the provided rules (from strings or files)
|
|
74
|
+
* and executes them against the provided list of emails.
|
|
75
|
+
*
|
|
76
|
+
* @param config Configuration object containing rules and emails.
|
|
77
|
+
* @returns The classification result and action logs.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* const result = cxr.init({
|
|
81
|
+
* rules: "./my-rules.cxr",
|
|
82
|
+
* emails: myEmails
|
|
83
|
+
* });
|
|
84
|
+
* console.log(result.folders);
|
|
85
|
+
*/
|
|
86
|
+
export function init(config: InitConfig): ExecutionResult;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* **CXR - Email Rule eXecutor**
|
|
90
|
+
*
|
|
91
|
+
* A rule engine / compiler for email filtering logic.
|
|
92
|
+
*
|
|
93
|
+
* CXR parses a custom `.cxr` domain language and executes deterministically against normalized email objects without mutation.
|
|
94
|
+
*
|
|
95
|
+
* ### Features
|
|
96
|
+
* - **Safe**: No `eval`, no `Function`, purely AST-based execution.
|
|
97
|
+
* - **Deterministic**: Input -> Output is always the same.
|
|
98
|
+
* - **Explainable**: Returns metadata and logs of all actions taken.
|
|
99
|
+
* - **Extensible**: Pure JSON output format.
|
|
100
|
+
* - **Zero Dependencies**: Lightweight and fast.
|
|
101
|
+
*
|
|
102
|
+
* ### Example Usage
|
|
103
|
+
* ```javascript
|
|
104
|
+
* import cxr from "cxr";
|
|
105
|
+
*
|
|
106
|
+
* // 1. Define Rules (.cxr content)
|
|
107
|
+
* const rules = `
|
|
108
|
+
* Folder "Important"
|
|
109
|
+
* WHEN sender contains "boss"
|
|
110
|
+
* THEN move to "Important"
|
|
111
|
+
* AND notify
|
|
112
|
+
* `;
|
|
113
|
+
*
|
|
114
|
+
* // 2. Run Engine
|
|
115
|
+
* const result = cxr.init({
|
|
116
|
+
* rules,
|
|
117
|
+
* emails: [{
|
|
118
|
+
* id: '1',
|
|
119
|
+
* from: 'boss@company.com',
|
|
120
|
+
* subject: 'Urgent',
|
|
121
|
+
* body: 'Read this',
|
|
122
|
+
* unread: true,
|
|
123
|
+
* receivedAt: Date.now()
|
|
124
|
+
* }]
|
|
125
|
+
* });
|
|
126
|
+
*
|
|
127
|
+
* // 3. Use Results
|
|
128
|
+
* console.log(result.folders['Important']); // [ { id: '1', ... } ]
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
declare const cxr: {
|
|
132
|
+
/**
|
|
133
|
+
* Initialize the CXR engine.
|
|
134
|
+
*
|
|
135
|
+
* @param config {InitConfig} Configuration object containing rules and emails.
|
|
136
|
+
* @returns {ExecutionResult} The classification result and action logs.
|
|
137
|
+
*/
|
|
138
|
+
init: typeof init;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export default cxr;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
|
|
2
|
+
import { Executor } from './executor.js';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CXR Main Entry Point
|
|
7
|
+
*/
|
|
8
|
+
const cxr = {
|
|
9
|
+
/**
|
|
10
|
+
* Initialize result engine with rules and emails.
|
|
11
|
+
* @param {Object} config
|
|
12
|
+
* @param {string|string[]} config.rules - The .cxr rules content or file path(s)
|
|
13
|
+
* @param {Array} config.emails - Array of email objects
|
|
14
|
+
* @returns {Object} Result object containing folders and actionsLog
|
|
15
|
+
*/
|
|
16
|
+
init: function (config) {
|
|
17
|
+
if (!config || !config.rules) {
|
|
18
|
+
throw new Error("Invalid configuration: 'rules' is required (string or array).");
|
|
19
|
+
}
|
|
20
|
+
if (!Array.isArray(config.emails)) {
|
|
21
|
+
throw new Error("Invalid configuration: 'emails' array is required.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
// Resolve Rules (Handle Strings and File Paths)
|
|
26
|
+
const inputs = Array.isArray(config.rules) ? config.rules : [config.rules];
|
|
27
|
+
const combinedRules = inputs.map(input => {
|
|
28
|
+
if (typeof input !== 'string') {
|
|
29
|
+
throw new Error("Invalid rule format: expected string content or file path.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
// Heuristic: If string ends with .cxr, treat as file path.
|
|
34
|
+
if (input.trim().endsWith('.cxr')) {
|
|
35
|
+
try {
|
|
36
|
+
if (fs.existsSync(input)) {
|
|
37
|
+
return fs.readFileSync(input, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`Rule file not found: ${input}`);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return input;
|
|
46
|
+
}).join('\n'); // Combine with newline for separation
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
return Executor.execute(combinedRules, config.emails, config.schema);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default cxr;
|
package/src/lexer.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* TokenType definitions
|
|
4
|
+
*/
|
|
5
|
+
export const TokenType = {
|
|
6
|
+
// Structural
|
|
7
|
+
FOLDER: 'FOLDER',
|
|
8
|
+
WHEN: 'WHEN',
|
|
9
|
+
THEN: 'THEN',
|
|
10
|
+
|
|
11
|
+
// Logic
|
|
12
|
+
AND: 'AND',
|
|
13
|
+
OR: 'OR',
|
|
14
|
+
NOT: 'NOT',
|
|
15
|
+
IN: 'IN',
|
|
16
|
+
CONTAINS: 'CONTAINS', // Although 'contains' acts like an operator
|
|
17
|
+
|
|
18
|
+
// Fields
|
|
19
|
+
FIELD_SENDER: 'FIELD_SENDER',
|
|
20
|
+
FIELD_SUBJECT: 'FIELD_SUBJECT',
|
|
21
|
+
FIELD_BODY: 'FIELD_BODY',
|
|
22
|
+
|
|
23
|
+
// Actions (Start words)
|
|
24
|
+
KW_MOVE: 'KW_MOVE',
|
|
25
|
+
KW_REMOVE: 'KW_REMOVE',
|
|
26
|
+
KW_NOTIFY: 'KW_NOTIFY',
|
|
27
|
+
KW_CALL: 'KW_CALL',
|
|
28
|
+
KW_REMIND: 'KW_REMIND',
|
|
29
|
+
KW_MARK: 'KW_MARK',
|
|
30
|
+
KW_AUTO: 'KW_AUTO',
|
|
31
|
+
|
|
32
|
+
// Prepositions / Helpers
|
|
33
|
+
KW_TO: 'KW_TO',
|
|
34
|
+
KW_FROM: 'KW_FROM',
|
|
35
|
+
KW_AS: 'KW_AS',
|
|
36
|
+
KW_READ: 'KW_READ',
|
|
37
|
+
KW_UNREAD: 'KW_UNREAD',
|
|
38
|
+
|
|
39
|
+
// Literals
|
|
40
|
+
STRING: 'STRING',
|
|
41
|
+
IDENTIFIER: 'IDENTIFIER',
|
|
42
|
+
|
|
43
|
+
// Symbols
|
|
44
|
+
LPAREN: 'LPAREN',
|
|
45
|
+
RPAREN: 'RPAREN',
|
|
46
|
+
LBRACKET: 'LBRACKET',
|
|
47
|
+
RBRACKET: 'RBRACKET',
|
|
48
|
+
COMMA: 'COMMA',
|
|
49
|
+
|
|
50
|
+
EOF: 'EOF'
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
class Token {
|
|
54
|
+
constructor(type, value, line, column) {
|
|
55
|
+
this.type = type;
|
|
56
|
+
this.value = value;
|
|
57
|
+
this.line = line;
|
|
58
|
+
this.column = column;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class Lexer {
|
|
63
|
+
constructor(input) {
|
|
64
|
+
this.input = input;
|
|
65
|
+
this.pos = 0;
|
|
66
|
+
this.line = 1;
|
|
67
|
+
this.column = 1;
|
|
68
|
+
this.tokens = [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
tokenize() {
|
|
72
|
+
while (this.pos < this.input.length) {
|
|
73
|
+
const char = this.input[this.pos];
|
|
74
|
+
|
|
75
|
+
// Handle whitespace
|
|
76
|
+
if (/\s/.test(char)) {
|
|
77
|
+
if (char === '\n') {
|
|
78
|
+
this.line++;
|
|
79
|
+
this.column = 1;
|
|
80
|
+
} else {
|
|
81
|
+
this.column++;
|
|
82
|
+
}
|
|
83
|
+
this.pos++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Handle strings
|
|
88
|
+
if (char === '"') {
|
|
89
|
+
this.readString();
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Handle symbols
|
|
94
|
+
if (char === '(') { this.addToken(TokenType.LPAREN, '('); this.pos++; this.column++; continue; }
|
|
95
|
+
if (char === ')') { this.addToken(TokenType.RPAREN, ')'); this.pos++; this.column++; continue; }
|
|
96
|
+
if (char === '[') { this.addToken(TokenType.LBRACKET, '['); this.pos++; this.column++; continue; }
|
|
97
|
+
if (char === ']') { this.addToken(TokenType.RBRACKET, ']'); this.pos++; this.column++; continue; }
|
|
98
|
+
if (char === ',') { this.addToken(TokenType.COMMA, ','); this.pos++; this.column++; continue; }
|
|
99
|
+
|
|
100
|
+
// Handle keywords and identifiers
|
|
101
|
+
if (/[a-zA-Z0-9_]/.test(char)) {
|
|
102
|
+
this.readIdentifierOrKeyword();
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// If we reach here, it's an unexpected character
|
|
107
|
+
throw new Error(`Unexpected character '${char}' at line ${this.line}, column ${this.column}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.addToken(TokenType.EOF, null);
|
|
111
|
+
return this.tokens;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
addToken(type, value) {
|
|
115
|
+
this.tokens.push(new Token(type, value, this.line, this.column));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
readString() {
|
|
119
|
+
let value = '';
|
|
120
|
+
const startColumn = this.column;
|
|
121
|
+
this.pos++; // Skip opening quote
|
|
122
|
+
this.column++;
|
|
123
|
+
|
|
124
|
+
while (this.pos < this.input.length && this.input[this.pos] !== '"') {
|
|
125
|
+
value += this.input[this.pos];
|
|
126
|
+
this.pos++;
|
|
127
|
+
this.column++;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (this.pos >= this.input.length) {
|
|
131
|
+
throw new Error(`Unterminated string starting at line ${this.line}, column ${startColumn}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.pos++; // Skip closing quote
|
|
135
|
+
this.column++;
|
|
136
|
+
this.tokens.push(new Token(TokenType.STRING, value, this.line, startColumn));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
readIdentifierOrKeyword() {
|
|
140
|
+
let value = '';
|
|
141
|
+
const startColumn = this.column;
|
|
142
|
+
|
|
143
|
+
// Read word
|
|
144
|
+
while (this.pos < this.input.length && /[a-zA-Z0-9_]/.test(this.input[this.pos])) {
|
|
145
|
+
value += this.input[this.pos];
|
|
146
|
+
this.pos++;
|
|
147
|
+
this.column++;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const key = value.toLowerCase();
|
|
151
|
+
|
|
152
|
+
// Mapping of keywords to TokenTypes
|
|
153
|
+
const KEYWORDS = {
|
|
154
|
+
'folder': TokenType.FOLDER,
|
|
155
|
+
'when': TokenType.WHEN,
|
|
156
|
+
'then': TokenType.THEN,
|
|
157
|
+
'and': TokenType.AND,
|
|
158
|
+
'or': TokenType.OR,
|
|
159
|
+
'not': TokenType.NOT,
|
|
160
|
+
'in': TokenType.IN,
|
|
161
|
+
'contains': TokenType.CONTAINS,
|
|
162
|
+
|
|
163
|
+
'sender': TokenType.FIELD_SENDER,
|
|
164
|
+
'subject': TokenType.FIELD_SUBJECT,
|
|
165
|
+
'body': TokenType.FIELD_BODY,
|
|
166
|
+
|
|
167
|
+
'move': TokenType.KW_MOVE,
|
|
168
|
+
'remove': TokenType.KW_REMOVE,
|
|
169
|
+
'notify': TokenType.KW_NOTIFY,
|
|
170
|
+
'call': TokenType.KW_CALL,
|
|
171
|
+
'remind': TokenType.KW_REMIND,
|
|
172
|
+
'mark': TokenType.KW_MARK,
|
|
173
|
+
'auto': TokenType.KW_AUTO,
|
|
174
|
+
|
|
175
|
+
'to': TokenType.KW_TO,
|
|
176
|
+
'from': TokenType.KW_FROM,
|
|
177
|
+
'as': TokenType.KW_AS,
|
|
178
|
+
'read': TokenType.KW_READ,
|
|
179
|
+
'unread': TokenType.KW_UNREAD
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (KEYWORDS.hasOwnProperty(key)) {
|
|
183
|
+
this.addToken(KEYWORDS[key], value);
|
|
184
|
+
} else {
|
|
185
|
+
this.addToken(TokenType.IDENTIFIER, value);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/parser.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
|
|
2
|
+
import { TokenType } from './lexer.js';
|
|
3
|
+
|
|
4
|
+
export class Parser {
|
|
5
|
+
constructor(tokens) {
|
|
6
|
+
this.tokens = tokens;
|
|
7
|
+
this.pos = 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
parse() {
|
|
11
|
+
const folders = [];
|
|
12
|
+
while (this.peek().type !== TokenType.EOF) {
|
|
13
|
+
if (this.peek().type === TokenType.FOLDER) {
|
|
14
|
+
folders.push(this.parseFolder());
|
|
15
|
+
} else {
|
|
16
|
+
throw this.error(`Expected 'Folder' declaration, found ${this.peek().value}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return { type: 'Program', folders };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
parseFolder() {
|
|
23
|
+
this.consume(TokenType.FOLDER);
|
|
24
|
+
const name = this.parseTarget();
|
|
25
|
+
|
|
26
|
+
const rules = [];
|
|
27
|
+
while (this.peek().type === TokenType.WHEN) {
|
|
28
|
+
rules.push(this.parseRule());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (rules.length === 0) {
|
|
32
|
+
throw this.error(`Folder '${name}' must have at least one rule.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { type: 'Folder', name, rules };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
parseRule() {
|
|
39
|
+
this.consume(TokenType.WHEN);
|
|
40
|
+
const condition = this.parseCondition();
|
|
41
|
+
|
|
42
|
+
this.consume(TokenType.THEN);
|
|
43
|
+
const actions = this.parseActions();
|
|
44
|
+
|
|
45
|
+
return { type: 'Rule', condition, actions };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
parseCondition() {
|
|
49
|
+
return this.parseOr();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
parseOr() {
|
|
53
|
+
let left = this.parseAnd();
|
|
54
|
+
|
|
55
|
+
while (this.match(TokenType.OR)) {
|
|
56
|
+
const right = this.parseAnd();
|
|
57
|
+
left = {
|
|
58
|
+
type: 'BinaryExpression',
|
|
59
|
+
operator: 'OR',
|
|
60
|
+
left,
|
|
61
|
+
right
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return left;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
parseAnd() {
|
|
68
|
+
let left = this.parseNot();
|
|
69
|
+
|
|
70
|
+
while (this.match(TokenType.AND)) {
|
|
71
|
+
const right = this.parseNot();
|
|
72
|
+
left = {
|
|
73
|
+
type: 'BinaryExpression',
|
|
74
|
+
operator: 'AND',
|
|
75
|
+
left,
|
|
76
|
+
right
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return left;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
parseNot() {
|
|
83
|
+
if (this.match(TokenType.NOT)) {
|
|
84
|
+
return {
|
|
85
|
+
type: 'UnaryExpression',
|
|
86
|
+
operator: 'NOT',
|
|
87
|
+
argument: this.parseNot()
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return this.parseFactor();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
parseFactor() {
|
|
94
|
+
if (this.match(TokenType.LPAREN)) {
|
|
95
|
+
const expr = this.parseCondition();
|
|
96
|
+
this.consume(TokenType.RPAREN, "Expected ')'");
|
|
97
|
+
return expr;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const field = this.parseField();
|
|
101
|
+
|
|
102
|
+
if (this.match(TokenType.CONTAINS)) {
|
|
103
|
+
const valueToken = this.consume(TokenType.STRING, "Expected string literal after 'contains'");
|
|
104
|
+
return {
|
|
105
|
+
type: 'Predicate',
|
|
106
|
+
field: field.value,
|
|
107
|
+
operator: 'contains',
|
|
108
|
+
value: valueToken.value
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (this.match(TokenType.IN)) {
|
|
113
|
+
if (this.peek().type === TokenType.STRING) {
|
|
114
|
+
const val = this.consume(TokenType.STRING).value;
|
|
115
|
+
return {
|
|
116
|
+
type: 'InPredicate',
|
|
117
|
+
field: field.value,
|
|
118
|
+
value: [val]
|
|
119
|
+
};
|
|
120
|
+
} else if (this.match(TokenType.LBRACKET)) {
|
|
121
|
+
const values = [];
|
|
122
|
+
do {
|
|
123
|
+
const val = this.consume(TokenType.STRING, "Expected string in list").value;
|
|
124
|
+
values.push(val);
|
|
125
|
+
} while (this.match(TokenType.COMMA));
|
|
126
|
+
this.consume(TokenType.RBRACKET, "Expected ']'");
|
|
127
|
+
return {
|
|
128
|
+
type: 'InPredicate',
|
|
129
|
+
field: field.value,
|
|
130
|
+
value: values
|
|
131
|
+
};
|
|
132
|
+
} else {
|
|
133
|
+
throw this.error("Expected string or list after 'IN'");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
throw this.error(`Unexpected token in condition: ${this.peek().value}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
parseField() {
|
|
141
|
+
if (this.check(TokenType.FIELD_SENDER) ||
|
|
142
|
+
this.check(TokenType.FIELD_SUBJECT) ||
|
|
143
|
+
this.check(TokenType.FIELD_BODY) ||
|
|
144
|
+
this.check(TokenType.IDENTIFIER)) {
|
|
145
|
+
return this.advance();
|
|
146
|
+
}
|
|
147
|
+
throw this.error("Expected field (sender, subject, body, or custom field)");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
parseActions() {
|
|
151
|
+
const actions = [];
|
|
152
|
+
actions.push(this.parseAction());
|
|
153
|
+
|
|
154
|
+
while (this.match(TokenType.AND)) {
|
|
155
|
+
actions.push(this.parseAction());
|
|
156
|
+
}
|
|
157
|
+
return actions;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
parseTarget() {
|
|
161
|
+
if (this.check(TokenType.IDENTIFIER) || this.check(TokenType.STRING)) {
|
|
162
|
+
return this.advance().value;
|
|
163
|
+
}
|
|
164
|
+
throw this.error("Expected folder name (identifier or string)");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
parseAction() {
|
|
168
|
+
const token = this.peek();
|
|
169
|
+
|
|
170
|
+
if (this.match(TokenType.KW_MOVE)) {
|
|
171
|
+
this.consume(TokenType.KW_TO, "Expected 'to' after 'move'");
|
|
172
|
+
const folder = this.parseTarget();
|
|
173
|
+
return { type: 'Action', action: 'move', target: folder };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (this.match(TokenType.KW_REMOVE)) {
|
|
177
|
+
this.consume(TokenType.KW_FROM, "Expected 'from' after 'remove'");
|
|
178
|
+
const folder = this.parseTarget();
|
|
179
|
+
return { type: 'Action', action: 'remove', target: folder };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (this.match(TokenType.KW_NOTIFY)) {
|
|
183
|
+
return { type: 'Action', action: 'notify' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (this.match(TokenType.KW_CALL)) {
|
|
187
|
+
return { type: 'Action', action: 'call' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (this.match(TokenType.KW_REMIND)) {
|
|
191
|
+
return { type: 'Action', action: 'remind' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (this.match(TokenType.KW_AUTO)) {
|
|
195
|
+
return { type: 'Action', action: 'auto' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this.match(TokenType.KW_MARK)) {
|
|
199
|
+
if (this.match(TokenType.KW_AS)) {
|
|
200
|
+
if (this.match(TokenType.KW_READ)) {
|
|
201
|
+
return { type: 'Action', action: 'mark_read' };
|
|
202
|
+
} else if (this.match(TokenType.KW_UNREAD)) {
|
|
203
|
+
return { type: 'Action', action: 'mark_unread' };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
throw this.error("Expected 'as read' or 'as unread' after 'mark'");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
throw this.error(`Unknown or invalid action start: ${token.value}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
peek() {
|
|
213
|
+
return this.tokens[this.pos];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
advance() {
|
|
217
|
+
if (this.pos < this.tokens.length - 1) {
|
|
218
|
+
this.pos++;
|
|
219
|
+
}
|
|
220
|
+
return this.tokens[this.pos - 1];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
check(type) {
|
|
224
|
+
return this.peek().type === type;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
match(type) {
|
|
228
|
+
if (this.check(type)) {
|
|
229
|
+
this.advance();
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
consume(type, errorMessage) {
|
|
236
|
+
if (this.check(type)) {
|
|
237
|
+
return this.advance();
|
|
238
|
+
}
|
|
239
|
+
throw this.error(errorMessage || `Expected ${type}, found ${this.peek().type}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
error(message) {
|
|
243
|
+
const token = this.peek();
|
|
244
|
+
return new Error(`Parser Error at line ${token.line}, column ${token.column}: ${message}`);
|
|
245
|
+
}
|
|
246
|
+
}
|