Add multi-action rules UI
This commit is contained in:
parent
b23d601637
commit
74fb932b45
5 changed files with 212 additions and 36 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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]});
|
||||
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});
|
||||
}
|
||||
if (rule.moveTo) {
|
||||
await messenger.messages.move([id], rule.moveTo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,5 +23,12 @@
|
|||
"page": "options/options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"permissions": [ "storage", "messagesRead", "accountsRead" ]
|
||||
"permissions": [
|
||||
"storage",
|
||||
"messagesRead",
|
||||
"messagesMove",
|
||||
"messagesUpdate",
|
||||
"messagesTagsList",
|
||||
"accountsRead"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 });
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue