Merge pull request #30 from wagesj45/codex/upgrade-rule-generation-ui-with-multi-action-support

Add rule actions tab
This commit is contained in:
Jordan Wages 2025-06-25 03:23:41 -05:00 committed by GitHub
commit 0858b52309
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 212 additions and 36 deletions

View file

@ -97,6 +97,9 @@ Sortana requests the following Thunderbird permissions:
- `storage` store configuration and cached classification results.
- `messagesRead` read message contents for classification.
- `messagesMove` move messages when a rule specifies a target folder.
- `messagesUpdate` change message properties such as tags and junk status.
- `messagesTagsList` retrieve existing message tags for rule actions.
- `accountsRead` list accounts and folders for move actions.
## License

View file

@ -33,7 +33,13 @@ async function sha256Hex(str) {
const store = await browser.storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "aiRules"]);
logger.setDebug(store.debugLogging);
await AiClassifier.setConfig(store);
aiRules = Array.isArray(store.aiRules) ? store.aiRules : [];
aiRules = Array.isArray(store.aiRules) ? store.aiRules.map(r => {
if (r.actions) return r;
const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
return { criterion: r.criterion, actions };
}) : [];
logger.aiLog("configuration loaded", {debug: true}, store);
} catch (err) {
logger.aiLog("failed to load config", {level: 'error'}, err);
@ -73,7 +79,13 @@ if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) {
logger.aiLog("onNewMailReceived", {debug: true}, messages);
if (!aiRules.length) {
const { aiRules: stored } = await browser.storage.local.get("aiRules");
aiRules = Array.isArray(stored) ? stored : [];
aiRules = Array.isArray(stored) ? stored.map(r => {
if (r.actions) return r;
const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
return { criterion: r.criterion, actions };
}) : [];
}
for (const msg of (messages?.messages || messages || [])) {
const id = msg.id ?? msg;
@ -84,11 +96,14 @@ if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) {
const cacheKey = await sha256Hex(`${id}|${rule.criterion}`);
const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey);
if (matched) {
if (rule.tag) {
await messenger.messages.update(id, {tags: [rule.tag]});
}
if (rule.moveTo) {
await messenger.messages.move([id], rule.moveTo);
for (const act of (rule.actions || [])) {
if (act.type === 'tag' && act.tagKey) {
await messenger.messages.update(id, {tags: [act.tagKey]});
} else if (act.type === 'move' && act.folder) {
await messenger.messages.move([id], act.folder);
} else if (act.type === 'junk') {
await messenger.messages.update(id, {junk: !!act.junk});
}
}
}
}

View file

@ -23,5 +23,12 @@
"page": "options/options.html",
"open_in_tab": true
},
"permissions": [ "storage", "messagesRead", "accountsRead" ]
"permissions": [
"storage",
"messagesRead",
"messagesMove",
"messagesUpdate",
"messagesTagsList",
"accountsRead"
]
}

View file

@ -78,6 +78,19 @@
flex-wrap: wrap;
}
.tab-button {
background: #e0e0e0;
}
.tab-button.active {
background: #007acc;
color: #fff;
}
.tab {
margin-top: 20px;
}
#rules-container {
margin-top: 10px;
}
@ -93,6 +106,10 @@
margin-top: 10px;
}
.action-row {
margin-bottom: 5px;
}
button {
padding: 10px 20px;
border: none;
@ -132,7 +149,13 @@
<img src="../resources/img/full-logo.png" alt="AI Filter Logo">
</header>
<nav style="display:flex; gap:10px; justify-content:center; margin-top:10px;">
<button type="button" data-tab="settings" class="tab-button">Settings</button>
<button type="button" data-tab="rules" class="tab-button">Rules</button>
</nav>
<main>
<div id="settings-tab" class="tab">
<div class="form-group">
<label for="endpoint">Endpoint:</label>
<input type="text" id="endpoint" placeholder="https://api.example.com">
@ -213,10 +236,14 @@
</div>
</div>
<hr>
</div>
<div id="rules-tab" class="tab" style="display:none">
<h2>Classification Rules</h2>
<div id="rules-container"></div>
<button id="add-rule" type="button">Add Rule</button>
</div>
</main>
<script src="options.js"></script>

View file

@ -10,6 +10,16 @@ document.addEventListener('DOMContentLoaded', async () => {
'debugLogging',
'aiRules'
]);
const tabButtons = document.querySelectorAll('.tab-button');
const tabs = document.querySelectorAll('.tab');
tabButtons.forEach(btn => btn.addEventListener('click', () => {
tabButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
tabs.forEach(tab => {
tab.style.display = tab.id === `${btn.dataset.tab}-tab` ? 'block' : 'none';
});
}));
tabButtons[0]?.click();
logger.setDebug(defaults.debugLogging === true);
const DEFAULT_AI_PARAMS = {
max_tokens: 4096,
@ -66,6 +76,26 @@ document.addEventListener('DOMContentLoaded', async () => {
if (el) el.value = val;
}
let tagList = [];
let folderList = [];
try {
tagList = await messenger.messages.tags.list();
} catch (e) {
logger.aiLog('failed to list tags', {level:'error'}, e);
}
try {
const accounts = await messenger.accounts.list(true);
const collect = (f, prefix='') => {
folderList.push({ id: f.id ?? f.path, name: prefix + f.name });
(f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/'));
};
for (const acct of accounts) {
(acct.folders || []).forEach(f => collect(f, `${acct.name}/`));
}
} catch (e) {
logger.aiLog('failed to list folders', {level:'error'}, e);
}
const DEFAULT_SYSTEM = 'Determine whether the email satisfies the user\'s criterion.';
const systemBox = document.getElementById('system-instructions');
systemBox.value = defaults.customSystemPrompt || DEFAULT_SYSTEM;
@ -76,6 +106,70 @@ document.addEventListener('DOMContentLoaded', async () => {
const rulesContainer = document.getElementById('rules-container');
const addRuleBtn = document.getElementById('add-rule');
function createActionRow(action = {type: 'tag'}) {
const row = document.createElement('div');
row.className = 'action-row';
const typeSelect = document.createElement('select');
['tag','move','junk'].forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
typeSelect.appendChild(opt);
});
typeSelect.value = action.type;
const paramSpan = document.createElement('span');
function updateParams() {
paramSpan.innerHTML = '';
if (typeSelect.value === 'tag') {
const sel = document.createElement('select');
sel.className = 'tag-select';
for (const t of tagList) {
const opt = document.createElement('option');
opt.value = t.key;
opt.textContent = t.tag;
sel.appendChild(opt);
}
sel.value = action.tagKey || '';
paramSpan.appendChild(sel);
} else if (typeSelect.value === 'move') {
const sel = document.createElement('select');
sel.className = 'folder-select';
for (const f of folderList) {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
sel.appendChild(opt);
}
sel.value = action.folder || '';
paramSpan.appendChild(sel);
} else if (typeSelect.value === 'junk') {
const sel = document.createElement('select');
sel.className = 'junk-select';
sel.appendChild(new Option('mark junk','true'));
sel.appendChild(new Option('mark not junk','false'));
sel.value = String(action.junk ?? true);
paramSpan.appendChild(sel);
}
}
typeSelect.addEventListener('change', updateParams);
updateParams();
const removeBtn = document.createElement('button');
removeBtn.textContent = 'Remove';
removeBtn.type = 'button';
removeBtn.addEventListener('click', () => row.remove());
row.appendChild(typeSelect);
row.appendChild(paramSpan);
row.appendChild(removeBtn);
return row;
}
function renderRules(rules = []) {
rulesContainer.innerHTML = '';
for (const rule of rules) {
@ -86,45 +180,63 @@ document.addEventListener('DOMContentLoaded', async () => {
critInput.type = 'text';
critInput.placeholder = 'Criterion';
critInput.value = rule.criterion || '';
critInput.className = 'criterion';
const tagInput = document.createElement('input');
tagInput.type = 'text';
tagInput.placeholder = 'Tag (e.g. $label1)';
tagInput.value = rule.tag || '';
const actionsContainer = document.createElement('div');
actionsContainer.className = 'rule-actions';
const moveInput = document.createElement('input');
moveInput.type = 'text';
moveInput.placeholder = 'Folder URL';
moveInput.value = rule.moveTo || '';
for (const act of (rule.actions || [])) {
actionsContainer.appendChild(createActionRow(act));
}
const actionsDiv = document.createElement('div');
actionsDiv.className = 'rule-actions';
const addAction = document.createElement('button');
addAction.textContent = 'Add Action';
addAction.type = 'button';
addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow()));
const delBtn = document.createElement('button');
delBtn.textContent = 'Delete';
delBtn.textContent = 'Delete Rule';
delBtn.type = 'button';
delBtn.addEventListener('click', () => div.remove());
actionsDiv.appendChild(delBtn);
div.appendChild(critInput);
div.appendChild(tagInput);
div.appendChild(moveInput);
div.appendChild(actionsDiv);
div.appendChild(actionsContainer);
div.appendChild(addAction);
div.appendChild(delBtn);
rulesContainer.appendChild(div);
}
}
addRuleBtn.addEventListener('click', () => {
renderRules([...rulesContainer.querySelectorAll('.rule')].map(el => ({
criterion: el.children[0].value,
tag: el.children[1].value,
moveTo: el.children[2].value
})).concat([{ criterion: '', tag: '', moveTo: '' }]));
const data = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
const criterion = ruleEl.querySelector('.criterion').value;
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
const type = row.querySelector('select').value;
if (type === 'tag') {
return { type, tagKey: row.querySelector('.tag-select').value };
}
if (type === 'move') {
return { type, folder: row.querySelector('.folder-select').value };
}
if (type === 'junk') {
return { type, junk: row.querySelector('.junk-select').value === 'true' };
}
return { type };
});
return { criterion, actions };
});
data.push({ criterion: '', actions: [] });
renderRules(data);
});
renderRules(defaults.aiRules || []);
renderRules((defaults.aiRules || []).map(r => {
if (r.actions) return r;
const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
return { criterion: r.criterion, actions };
}));
document.getElementById('save').addEventListener('click', async () => {
const endpoint = document.getElementById('endpoint').value;
@ -140,11 +252,23 @@ document.addEventListener('DOMContentLoaded', async () => {
}
}
const debugLogging = debugToggle.checked;
const rules = [...rulesContainer.querySelectorAll('.rule')].map(el => ({
criterion: el.children[0].value,
tag: el.children[1].value,
moveTo: el.children[2].value
})).filter(r => r.criterion);
const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
const criterion = ruleEl.querySelector('.criterion').value;
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
const type = row.querySelector('select').value;
if (type === 'tag') {
return { type, tagKey: row.querySelector('.tag-select').value };
}
if (type === 'move') {
return { type, folder: row.querySelector('.folder-select').value };
}
if (type === 'junk') {
return { type, junk: row.querySelector('.junk-select').value === 'true' };
}
return { type };
});
return { criterion, actions };
}).filter(r => r.criterion);
await browser.storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, aiRules: rules });
try {
await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });