Compare commits
49 commits
codex/add-
...
main
Author | SHA1 | Date | |
---|---|---|---|
c622c07c66 | |||
33003365fb |
|||
9cad2674e3 | |||
d3ee2f128f |
|||
bcac4ad017 | |||
d1ddfc4ad8 |
|||
841a697c69 | |||
45125f634d |
|||
55ccf083c8 | |||
d2de1818ca | |||
587f070886 |
|||
db7ce49a5b | |||
3d5050ded0 |
|||
3391905254 | |||
91b8d15573 | |||
e629cc6518 |
|||
183ca8f355 | |||
f56057c042 | |||
66a1d59971 |
|||
d3b16640b9 | |||
83a718dcfa |
|||
598e74f8cb | |||
32c8a0f415 | |||
8f5496f457 |
|||
1432690fa8 | |||
fcee9a4e8a |
|||
20dfba8406 | |||
5786049950 | |||
bed1bbfd70 |
|||
a56b79dcea | |||
bd050532ec |
|||
f156a653ef | |||
11db14545c |
|||
f33c27e5b9 |
|||
0cddb5b204 | |||
8bfd00dc49 | |||
83b890a419 |
|||
65ff743676 | |||
39ffa288fa |
|||
fa84f37637 | |||
5cc897a668 |
|||
cd0a31ed98 | |||
1c3ced5134 |
|||
6fd6da8a12 | |||
0bd397560d |
|||
9a2ecb0162 | |||
52ea120534 |
|||
fa62afa50e | |||
d992ad9c55 |
|
@ -28,7 +28,7 @@ This file provides guidelines for codex agents contributing to the Sortana proje
|
|||
|
||||
## Testing
|
||||
|
||||
There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird.
|
||||
There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird. Do **not** run the `ps1` build script or the SVG processing script.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
|
29
README.md
|
@ -16,11 +16,15 @@ message meets a specified criterion.
|
|||
- **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page.
|
||||
- **Markdown conversion** – optionally convert HTML bodies to Markdown before sending them to the AI service.
|
||||
- **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service.
|
||||
- **Debug tab** – view the last request payload and a diff between the unaltered message text and the final prompt.
|
||||
- **Light/Dark themes** – automatically match Thunderbird's appearance with optional manual override.
|
||||
- **Automatic rules** – create rules that tag, move, copy, mark read/unread or flag/unflag messages based on AI classification. Rules can optionally apply only to unread messages.
|
||||
- **Automatic rules** – create rules that tag, move, copy, forward, reply, delete, archive, mark read/unread or flag/unflag messages based on AI classification. Rules can optionally apply only to unread messages and can ignore messages outside a chosen age range.
|
||||
- **Rule ordering** – drag rules to prioritize them and optionally stop processing after a match.
|
||||
- **Rule enable/disable** – temporarily turn a rule off without removing it.
|
||||
- **Account & folder filters** – limit rules to specific accounts or folders.
|
||||
- **Context menu** – apply AI rules from the message list or the message display action button.
|
||||
- **Status icons** – toolbar icons show when classification is in progress and briefly display success or error states.
|
||||
- **Status icons** – toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red until you dismiss the notification.
|
||||
- **Error notification** – failed classification displays a notification with a button to clear the error and reset the icon.
|
||||
- **View reasoning** – inspect why rules matched via the Details popup.
|
||||
- **Cache management** – clear cached results from the context menu or options page.
|
||||
- **Queue & timing stats** – monitor processing time on the Maintenance tab.
|
||||
|
@ -69,12 +73,19 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e
|
|||
## Usage
|
||||
|
||||
1. Open the add-on's options and set the URL of your classification service.
|
||||
2. Use the **Classification Rules** section to add a criterion and optional
|
||||
actions such as tagging or moving a message when it matches. Drag rules to
|
||||
reorder them, check *Only apply to unread messages* to skip read mail, and
|
||||
check *Stop after match* to halt further processing.
|
||||
2. Use the **Classification Rules** section to add a criterion and optional
|
||||
actions such as tagging, moving, copying, forwarding, replying,
|
||||
deleting or archiving a message when it matches. Drag rules to
|
||||
reorder them, check *Only apply to unread messages* to skip read mail,
|
||||
set optional minimum or maximum message age limits, select the accounts or
|
||||
folders a rule should apply to. Use the
|
||||
slashed-circle/check button to disable or re-enable a rule. The small
|
||||
circle buttons for optional conditions show a filled dot when active, and
|
||||
check *Stop after match* to halt further processing. Forward and reply actions
|
||||
open a compose window using the account that received the message.
|
||||
3. Save your settings. New mail will be evaluated automatically using the
|
||||
configured rules.
|
||||
4. If the toolbar icon shows a red X, click the notification's **Dismiss** button to clear the error.
|
||||
|
||||
### Example Filters
|
||||
|
||||
|
@ -102,7 +113,7 @@ Here are some useful and fun example criteria you can use in your filters. Filte
|
|||
For when you're ready to filter based on vibes.
|
||||
|
||||
You can define as many filters as you'd like, each using a different prompt and
|
||||
triggering tags, moves, copies, read/unread changes or flag updates based on the model's classification.
|
||||
triggering tags, moves, copies, deletes, archives, read/unread changes or flag updates based on the model's classification.
|
||||
|
||||
## Required Permissions
|
||||
|
||||
|
@ -116,6 +127,8 @@ Sortana requests the following Thunderbird permissions:
|
|||
- `accountsRead` – list accounts and folders for move or copy actions.
|
||||
- `menus` – add context menu commands.
|
||||
- `tabs` – open new tabs and query the active tab.
|
||||
- `notifications` – display error notifications.
|
||||
- `compose` – create reply and forward compose windows for matching rules.
|
||||
|
||||
## Thunderbird Add-on Store Disclosures
|
||||
|
||||
|
@ -128,6 +141,8 @@ uses the following third party libraries:
|
|||
- MIT License
|
||||
- [turndown v7.2.0](https://github.com/mixmark-io/turndown/tree/v7.2.0)
|
||||
- MIT License
|
||||
- [diff](https://github.com/google/diff-match-patch/blob/62f2e689f498f9c92dbc588c58750addec9b1654/javascript/diff_match_patch_uncompressed.js)
|
||||
- Apache-2.0 license
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
@ -18,12 +18,20 @@
|
|||
"options.htmlToMarkdown": { "message": "Convert HTML body to Markdown" },
|
||||
"options.stripUrlParams": { "message": "Remove URL tracking parameters" },
|
||||
"options.altTextImages": { "message": "Replace images with alt text" },
|
||||
"options.collapseWhitespace": { "message": "Collapse long whitespace" }
|
||||
"options.collapseWhitespace": { "message": "Collapse long whitespace" },
|
||||
"options.tokenReduction": { "message": "Aggressive token reduction" }
|
||||
,"action.read": { "message": "read" }
|
||||
,"action.flag": { "message": "flag" }
|
||||
,"action.copy": { "message": "copy" }
|
||||
,"action.copy": { "message": "copy" }
|
||||
,"action.delete": { "message": "delete" }
|
||||
,"action.archive": { "message": "archive" }
|
||||
,"action.forward": { "message": "forward" }
|
||||
,"action.reply": { "message": "reply" }
|
||||
,"param.markRead": { "message": "mark read" }
|
||||
,"param.markUnread": { "message": "mark unread" }
|
||||
,"param.flag": { "message": "flag" }
|
||||
,"param.unflag": { "message": "unflag" }
|
||||
,"param.address": { "message": "address" }
|
||||
,"param.replyAll": { "message": "reply all" }
|
||||
,"param.replySender": { "message": "reply sender" }
|
||||
}
|
||||
|
|
|
@ -108,6 +108,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-175
|
|||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "js", "js", "{21D2A42C-3F85-465C-9141-C106AFD92B68}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
resources\js\diff_match_patch_uncompressed.js = resources\js\diff_match_patch_uncompressed.js
|
||||
resources\js\turndown.js = resources\js\turndown.js
|
||||
EndProjectSection
|
||||
EndProject
|
||||
|
|
215
background.js
|
@ -26,14 +26,24 @@ let htmlToMarkdown = false;
|
|||
let stripUrlParams = false;
|
||||
let altTextImages = false;
|
||||
let collapseWhitespace = false;
|
||||
let tokenReduction = false;
|
||||
let maxTokens = 4096;
|
||||
let TurndownService = null;
|
||||
let userTheme = 'auto';
|
||||
let currentTheme = 'light';
|
||||
let detectSystemTheme;
|
||||
let errorPending = false;
|
||||
let showDebugTab = false;
|
||||
const ERROR_NOTIFICATION_ID = 'sortana-error';
|
||||
|
||||
function normalizeRules(rules) {
|
||||
return Array.isArray(rules) ? rules.map(r => {
|
||||
if (r.actions) return r;
|
||||
if (r.actions) {
|
||||
if (!Array.isArray(r.accounts)) r.accounts = [];
|
||||
if (!Array.isArray(r.folders)) r.folders = [];
|
||||
r.enabled = r.enabled !== false;
|
||||
return r;
|
||||
}
|
||||
const actions = [];
|
||||
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
|
||||
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
|
||||
|
@ -41,6 +51,11 @@ function normalizeRules(rules) {
|
|||
const rule = { criterion: r.criterion, actions };
|
||||
if (r.stopProcessing) rule.stopProcessing = true;
|
||||
if (r.unreadOnly) rule.unreadOnly = true;
|
||||
if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays;
|
||||
if (typeof r.maxAgeDays === 'number') rule.maxAgeDays = r.maxAgeDays;
|
||||
if (Array.isArray(r.accounts)) rule.accounts = r.accounts;
|
||||
if (Array.isArray(r.folders)) rule.folders = r.folders;
|
||||
rule.enabled = r.enabled !== false;
|
||||
return rule;
|
||||
}) : [];
|
||||
}
|
||||
|
@ -58,7 +73,8 @@ const ICONS = {
|
|||
logo: () => 'resources/img/logo.png',
|
||||
circledots: () => iconPaths('circledots'),
|
||||
circle: () => iconPaths('circle'),
|
||||
average: () => iconPaths('average')
|
||||
average: () => iconPaths('average'),
|
||||
error: () => iconPaths('x')
|
||||
};
|
||||
|
||||
function setIcon(path) {
|
||||
|
@ -72,19 +88,31 @@ function setIcon(path) {
|
|||
|
||||
function updateActionIcon() {
|
||||
let path = ICONS.logo();
|
||||
if (processing || queuedCount > 0) {
|
||||
if (errorPending) {
|
||||
path = ICONS.error();
|
||||
} else if (processing || queuedCount > 0) {
|
||||
path = ICONS.circledots();
|
||||
}
|
||||
setIcon(path);
|
||||
}
|
||||
|
||||
function showTransientIcon(factory, delay = 1500) {
|
||||
if (errorPending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(iconTimer);
|
||||
const path = typeof factory === 'function' ? factory() : factory;
|
||||
setIcon(path);
|
||||
iconTimer = setTimeout(updateActionIcon, delay);
|
||||
}
|
||||
|
||||
async function clearError() {
|
||||
errorPending = false;
|
||||
await storage.local.set({ errorPending: false });
|
||||
await browser.notifications.clear(ERROR_NOTIFICATION_ID);
|
||||
updateActionIcon();
|
||||
}
|
||||
|
||||
function refreshMenuIcons() {
|
||||
browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') });
|
||||
browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') });
|
||||
|
@ -100,12 +128,16 @@ function byteSize(str) {
|
|||
}
|
||||
|
||||
function replaceInlineBase64(text) {
|
||||
return text.replace(/[A-Za-z0-9+/]{100,}={0,2}/g,
|
||||
m => `[base64: ${byteSize(m)} bytes]`);
|
||||
return text.replace(/(?:data:[^;]+;base64,)?[A-Za-z0-9+/=\r\n]{100,}/g,
|
||||
m => tokenReduction ? '__BASE64__' : `[base64: ${byteSize(m)} bytes]`);
|
||||
}
|
||||
|
||||
function sanitizeString(text) {
|
||||
let t = String(text);
|
||||
if (tokenReduction) {
|
||||
t = t.replace(/<!--.*?-->/gs, '')
|
||||
.replace(/url\([^\)]*\)/gi, 'url(__IMG__)');
|
||||
}
|
||||
if (stripUrlParams) {
|
||||
t = t.replace(/https?:\/\/[^\s)]+/g, m => {
|
||||
const idx = m.indexOf('?');
|
||||
|
@ -113,7 +145,7 @@ function sanitizeString(text) {
|
|||
});
|
||||
}
|
||||
if (collapseWhitespace) {
|
||||
t = t.replace(/[ \t\u00A0]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n');
|
||||
t = t.replace(/[\u200B-\u200D\u2060\s]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
@ -132,12 +164,26 @@ function collectText(part, bodyParts, attachments) {
|
|||
attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`);
|
||||
} else if (ct.startsWith("text/html")) {
|
||||
const doc = new DOMParser().parseFromString(body, 'text/html');
|
||||
if (altTextImages) {
|
||||
doc.querySelectorAll('img').forEach(img => {
|
||||
const alt = img.getAttribute('alt') || '';
|
||||
img.replaceWith(doc.createTextNode(alt));
|
||||
if (tokenReduction) {
|
||||
doc.querySelectorAll('script,style').forEach(el => el.remove());
|
||||
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_COMMENT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
doc.querySelectorAll('*').forEach(el => {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (!['href','src','alt'].includes(attr.name)) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
doc.querySelectorAll('img').forEach(img => {
|
||||
const alt = img.getAttribute('alt') || '';
|
||||
const text = altTextImages ? alt : '__IMG__';
|
||||
img.replaceWith(doc.createTextNode(text));
|
||||
});
|
||||
if (stripUrlParams) {
|
||||
doc.querySelectorAll('[href]').forEach(a => {
|
||||
const href = a.getAttribute('href');
|
||||
|
@ -164,17 +210,46 @@ function collectText(part, bodyParts, attachments) {
|
|||
}
|
||||
}
|
||||
|
||||
function buildEmailText(full) {
|
||||
function collectRawText(part, bodyParts, attachments) {
|
||||
if (part.parts && part.parts.length) {
|
||||
for (const p of part.parts) collectRawText(p, bodyParts, attachments);
|
||||
return;
|
||||
}
|
||||
const ct = (part.contentType || "text/plain").toLowerCase();
|
||||
const cd = (part.headers?.["content-disposition"]?.[0] || "").toLowerCase();
|
||||
const body = String(part.body || "");
|
||||
if (cd.includes("attachment") || !ct.startsWith("text/")) {
|
||||
const nameMatch = /filename\s*=\s*"?([^";]+)/i.exec(cd) || /name\s*=\s*"?([^";]+)/i.exec(part.headers?.["content-type"]?.[0] || "");
|
||||
const name = nameMatch ? nameMatch[1] : "";
|
||||
attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`);
|
||||
} else if (ct.startsWith("text/html")) {
|
||||
const doc = new DOMParser().parseFromString(body, 'text/html');
|
||||
bodyParts.push(doc.body.textContent || "");
|
||||
} else {
|
||||
bodyParts.push(body);
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmailText(full, applyTransforms = true) {
|
||||
const bodyParts = [];
|
||||
const attachments = [];
|
||||
collectText(full, bodyParts, attachments);
|
||||
const collect = applyTransforms ? collectText : collectRawText;
|
||||
collect(full, bodyParts, attachments);
|
||||
const headers = Object.entries(full.headers || {})
|
||||
.map(([k, v]) => `${k}: ${v.join(' ')}`)
|
||||
.join('\n');
|
||||
const attachInfo = `Attachments: ${attachments.length}` +
|
||||
(attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : "");
|
||||
const combined = `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim();
|
||||
return sanitizeString(combined);
|
||||
let combined = `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim();
|
||||
if (applyTransforms && tokenReduction) {
|
||||
const seen = new Set();
|
||||
combined = combined.split('\n').filter(l => {
|
||||
if (seen.has(l)) return false;
|
||||
seen.add(l);
|
||||
return true;
|
||||
}).join('\n');
|
||||
}
|
||||
return applyTransforms ? sanitizeString(combined) : combined;
|
||||
}
|
||||
|
||||
function updateTimingStats(elapsed) {
|
||||
|
@ -208,22 +283,60 @@ async function processMessage(id) {
|
|||
updateActionIcon();
|
||||
try {
|
||||
const full = await messenger.messages.getFull(id);
|
||||
const text = buildEmailText(full);
|
||||
const originalText = buildEmailText(full, false);
|
||||
let text = buildEmailText(full);
|
||||
if (tokenReduction && maxTokens > 0) {
|
||||
const limit = Math.floor(maxTokens * 0.9);
|
||||
if (text.length > limit) {
|
||||
text = text.slice(0, limit);
|
||||
}
|
||||
}
|
||||
if (showDebugTab) {
|
||||
await storage.local.set({ lastFullText: originalText, lastPromptText: text });
|
||||
}
|
||||
let hdr;
|
||||
let currentTags = [];
|
||||
let alreadyRead = false;
|
||||
let identityId = null;
|
||||
try {
|
||||
const hdr = await messenger.messages.get(id);
|
||||
hdr = await messenger.messages.get(id);
|
||||
currentTags = Array.isArray(hdr.tags) ? [...hdr.tags] : [];
|
||||
alreadyRead = hdr.read === true;
|
||||
const ids = await messenger.identities.list(hdr.folder.accountId);
|
||||
identityId = ids[0]?.id || null;
|
||||
} catch (e) {
|
||||
currentTags = [];
|
||||
alreadyRead = false;
|
||||
identityId = null;
|
||||
}
|
||||
|
||||
for (const rule of aiRules) {
|
||||
if (rule.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
if (hdr && Array.isArray(rule.accounts) && rule.accounts.length &&
|
||||
!rule.accounts.includes(hdr.folder.accountId)) {
|
||||
continue;
|
||||
}
|
||||
if (hdr && Array.isArray(rule.folders) && rule.folders.length &&
|
||||
!rule.folders.includes(hdr.folder.path)) {
|
||||
continue;
|
||||
}
|
||||
if (rule.unreadOnly && alreadyRead) {
|
||||
continue;
|
||||
}
|
||||
if (hdr && (typeof rule.minAgeDays === 'number' || typeof rule.maxAgeDays === 'number')) {
|
||||
const msgTime = new Date(hdr.date).getTime();
|
||||
if (!isNaN(msgTime)) {
|
||||
const ageDays = (Date.now() - msgTime) / 86400000;
|
||||
if (typeof rule.minAgeDays === 'number' && ageDays < rule.minAgeDays) {
|
||||
continue;
|
||||
}
|
||||
if (typeof rule.maxAgeDays === 'number' && ageDays > rule.maxAgeDays) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
const cacheKey = await AiClassifier.buildCacheKey(id, rule.criterion);
|
||||
const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey);
|
||||
if (matched) {
|
||||
|
@ -243,6 +356,14 @@ async function processMessage(id) {
|
|||
await messenger.messages.update(id, { read: !!act.read });
|
||||
} else if (act.type === 'flag') {
|
||||
await messenger.messages.update(id, { flagged: !!act.flagged });
|
||||
} else if (act.type === 'delete') {
|
||||
await messenger.messages.delete([id]);
|
||||
} else if (act.type === 'archive') {
|
||||
await messenger.messages.archive([id]);
|
||||
} else if (act.type === 'forward' && act.address && identityId) {
|
||||
await browser.compose.beginForward(id, { to: [act.address], identityId });
|
||||
} else if (act.type === 'reply' && act.replyType && identityId) {
|
||||
await browser.compose.beginReply(id, { replyType: act.replyType, identityId });
|
||||
}
|
||||
}
|
||||
if (rule.stopProcessing) {
|
||||
|
@ -261,9 +382,17 @@ async function processMessage(id) {
|
|||
const elapsed = Date.now() - currentStart;
|
||||
currentStart = 0;
|
||||
updateTimingStats(elapsed);
|
||||
await storage.local.set({ classifyStats: timingStats });
|
||||
await storage.local.set({ classifyStats: timingStats, errorPending: true });
|
||||
errorPending = true;
|
||||
logger.aiLog("failed to apply AI rules", { level: 'error' }, e);
|
||||
showTransientIcon(ICONS.average);
|
||||
setIcon(ICONS.error());
|
||||
browser.notifications.create(ERROR_NOTIFICATION_ID, {
|
||||
type: 'basic',
|
||||
iconUrl: browser.runtime.getURL('resources/img/logo.png'),
|
||||
title: 'Sortana Error',
|
||||
message: 'Failed to apply AI rules',
|
||||
buttons: [{ title: 'Dismiss' }]
|
||||
});
|
||||
}
|
||||
}
|
||||
async function applyAiRules(idsInput) {
|
||||
|
@ -322,7 +451,7 @@ async function clearCacheForMessages(idsInput) {
|
|||
}
|
||||
|
||||
try {
|
||||
const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "aiRules", "theme"]);
|
||||
const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "errorPending", "showDebugTab"]);
|
||||
logger.setDebug(store.debugLogging);
|
||||
await AiClassifier.setConfig(store);
|
||||
userTheme = store.theme || 'auto';
|
||||
|
@ -332,6 +461,12 @@ async function clearCacheForMessages(idsInput) {
|
|||
stripUrlParams = store.stripUrlParams === true;
|
||||
altTextImages = store.altTextImages === true;
|
||||
collapseWhitespace = store.collapseWhitespace === true;
|
||||
tokenReduction = store.tokenReduction === true;
|
||||
if (store.aiParams && typeof store.aiParams.max_tokens !== 'undefined') {
|
||||
maxTokens = parseInt(store.aiParams.max_tokens) || maxTokens;
|
||||
}
|
||||
errorPending = store.errorPending === true;
|
||||
showDebugTab = store.showDebugTab === true;
|
||||
const savedStats = await storage.local.get('classifyStats');
|
||||
if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') {
|
||||
Object.assign(timingStats, savedStats.classifyStats);
|
||||
|
@ -347,6 +482,25 @@ async function clearCacheForMessages(idsInput) {
|
|||
aiRules = normalizeRules(newRules);
|
||||
logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules);
|
||||
}
|
||||
if (changes.endpoint || changes.templateName || changes.customTemplate || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) {
|
||||
const config = {};
|
||||
if (changes.endpoint) config.endpoint = changes.endpoint.newValue;
|
||||
if (changes.templateName) config.templateName = changes.templateName.newValue;
|
||||
if (changes.customTemplate) config.customTemplate = changes.customTemplate.newValue;
|
||||
if (changes.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue;
|
||||
if (changes.aiParams) {
|
||||
config.aiParams = changes.aiParams.newValue;
|
||||
if (changes.aiParams.newValue && typeof changes.aiParams.newValue.max_tokens !== 'undefined') {
|
||||
maxTokens = parseInt(changes.aiParams.newValue.max_tokens) || maxTokens;
|
||||
}
|
||||
}
|
||||
if (changes.debugLogging) {
|
||||
config.debugLogging = changes.debugLogging.newValue === true;
|
||||
logger.setDebug(config.debugLogging);
|
||||
}
|
||||
await AiClassifier.setConfig(config);
|
||||
logger.aiLog("AiClassifier config updated from storage change", { debug: true }, config);
|
||||
}
|
||||
if (changes.htmlToMarkdown) {
|
||||
htmlToMarkdown = changes.htmlToMarkdown.newValue === true;
|
||||
logger.aiLog("htmlToMarkdown updated from storage change", { debug: true }, htmlToMarkdown);
|
||||
|
@ -363,6 +517,17 @@ async function clearCacheForMessages(idsInput) {
|
|||
collapseWhitespace = changes.collapseWhitespace.newValue === true;
|
||||
logger.aiLog("collapseWhitespace updated from storage change", { debug: true }, collapseWhitespace);
|
||||
}
|
||||
if (changes.tokenReduction) {
|
||||
tokenReduction = changes.tokenReduction.newValue === true;
|
||||
logger.aiLog("tokenReduction updated from storage change", { debug: true }, tokenReduction);
|
||||
}
|
||||
if (changes.showDebugTab) {
|
||||
showDebugTab = changes.showDebugTab.newValue === true;
|
||||
}
|
||||
if (changes.errorPending) {
|
||||
errorPending = changes.errorPending.newValue === true;
|
||||
updateActionIcon();
|
||||
}
|
||||
if (changes.theme) {
|
||||
userTheme = changes.theme.newValue || 'auto';
|
||||
currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme;
|
||||
|
@ -588,6 +753,18 @@ async function clearCacheForMessages(idsInput) {
|
|||
logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason);
|
||||
});
|
||||
|
||||
browser.notifications.onClicked.addListener(id => {
|
||||
if (id === ERROR_NOTIFICATION_ID) {
|
||||
clearError();
|
||||
}
|
||||
});
|
||||
|
||||
browser.notifications.onButtonClicked.addListener((id) => {
|
||||
if (id === ERROR_NOTIFICATION_ID) {
|
||||
clearError();
|
||||
}
|
||||
});
|
||||
|
||||
browser.runtime.onInstalled.addListener(async ({ reason }) => {
|
||||
if (reason === "install") {
|
||||
await browser.runtime.openOptionsPage();
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Sortana",
|
||||
"version": "2.1.1",
|
||||
"version": "2.2.0",
|
||||
"default_locale": "en-US",
|
||||
"applications": {
|
||||
"gecko": {
|
||||
"id": "ai-filter@jordanwages",
|
||||
"strict_min_version": "128.0",
|
||||
"strict_max_version": "139.*"
|
||||
"strict_max_version": "140.*"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
|
@ -32,16 +32,18 @@
|
|||
"page": "options/options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"messagesRead",
|
||||
"messagesMove",
|
||||
"messagesUpdate",
|
||||
"messagesTagsList",
|
||||
"accountsRead",
|
||||
"menus",
|
||||
"scripting",
|
||||
"tabs",
|
||||
"theme"
|
||||
]
|
||||
"permissions": [
|
||||
"storage",
|
||||
"messagesRead",
|
||||
"messagesMove",
|
||||
"messagesUpdate",
|
||||
"messagesTagsList",
|
||||
"accountsRead",
|
||||
"menus",
|
||||
"notifications",
|
||||
"scripting",
|
||||
"tabs",
|
||||
"theme",
|
||||
"compose"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -308,6 +308,11 @@ async function classifyText(text, criterion, cacheKey = null) {
|
|||
}
|
||||
|
||||
const payload = buildPayload(text, criterion);
|
||||
try {
|
||||
await storage.local.set({ lastPayload: JSON.parse(payload) });
|
||||
} catch (e) {
|
||||
aiLog('failed to save last payload', { level: 'warn' }, e);
|
||||
}
|
||||
|
||||
aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true});
|
||||
aiLog(`[AiClassifier] Classification request payload:`, { debug: true }, payload);
|
||||
|
|
|
@ -31,6 +31,10 @@
|
|||
.tag {
|
||||
--bulma-tag-h: 318;
|
||||
}
|
||||
#diff-display {
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
@ -47,6 +51,7 @@
|
|||
<li class="is-active" data-tab="settings"><a><span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span><span>Settings</span></a></li>
|
||||
<li data-tab="rules"><a><span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span><span>Rules</span></a></li>
|
||||
<li data-tab="maintenance"><a><span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span><span>Maintenance</span></a></li>
|
||||
<li id="debug-tab-button" class="is-hidden" data-tab="debug"><a><span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span><span>Debug</span></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -144,6 +149,16 @@
|
|||
<input type="checkbox" id="collapse-whitespace"> Collapse long whitespace
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="token-reduction"> Aggressive token reduction
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="show-debug-tab"> Show debug information
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="max_tokens">Max tokens</label>
|
||||
<div class="control">
|
||||
|
@ -268,8 +283,21 @@
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="debug-tab" class="tab-content is-hidden">
|
||||
<h2 class="title is-4">
|
||||
<span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span>
|
||||
<span>Debug</span>
|
||||
</h2>
|
||||
<pre id="payload-display"></pre>
|
||||
<div id="diff-container" class="mt-4 is-hidden">
|
||||
<label class="label">Prompt diff</label>
|
||||
<div id="diff-display" class="box content is-family-monospace"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script src="../resources/js/diff_match_patch_uncompressed.js"></script>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -16,9 +16,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
'stripUrlParams',
|
||||
'altTextImages',
|
||||
'collapseWhitespace',
|
||||
'tokenReduction',
|
||||
'aiRules',
|
||||
'aiCache',
|
||||
'theme'
|
||||
'theme',
|
||||
'showDebugTab',
|
||||
'lastPayload',
|
||||
'lastFullText',
|
||||
'lastPromptText'
|
||||
]);
|
||||
const tabButtons = document.querySelectorAll('#main-tabs li');
|
||||
const tabs = document.querySelectorAll('.tab-content');
|
||||
|
@ -63,6 +68,33 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
}
|
||||
|
||||
await applyTheme(themeSelect.value);
|
||||
const payloadDisplay = document.getElementById('payload-display');
|
||||
const diffDisplay = document.getElementById('diff-display');
|
||||
const diffContainer = document.getElementById('diff-container');
|
||||
|
||||
let lastFullText = defaults.lastFullText || '';
|
||||
let lastPromptText = defaults.lastPromptText || '';
|
||||
let lastPayload = defaults.lastPayload ? JSON.stringify(defaults.lastPayload, null, 2) : '';
|
||||
|
||||
if (lastPayload) {
|
||||
payloadDisplay.textContent = lastPayload;
|
||||
}
|
||||
if (lastFullText && lastPromptText && diff_match_patch) {
|
||||
const dmp = new diff_match_patch();
|
||||
dmp.Diff_EditCost = 4;
|
||||
const diffs = dmp.diff_main(lastFullText, lastPromptText);
|
||||
dmp.diff_cleanupEfficiency(diffs);
|
||||
const hasDiff = diffs.some(d => d[0] !== 0);
|
||||
if (hasDiff) {
|
||||
diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs);
|
||||
diffContainer.classList.remove('is-hidden');
|
||||
} else {
|
||||
diffDisplay.innerHTML = '';
|
||||
diffContainer.classList.add('is-hidden');
|
||||
}
|
||||
} else {
|
||||
diffContainer.classList.add('is-hidden');
|
||||
}
|
||||
themeSelect.addEventListener('change', async () => {
|
||||
markDirty();
|
||||
await applyTheme(themeSelect.value);
|
||||
|
@ -115,6 +147,20 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const collapseWhitespaceToggle = document.getElementById('collapse-whitespace');
|
||||
collapseWhitespaceToggle.checked = defaults.collapseWhitespace === true;
|
||||
|
||||
const tokenReductionToggle = document.getElementById('token-reduction');
|
||||
tokenReductionToggle.checked = defaults.tokenReduction === true;
|
||||
|
||||
const debugTabToggle = document.getElementById('show-debug-tab');
|
||||
const debugTabBtn = document.getElementById('debug-tab-button');
|
||||
function updateDebugTab() {
|
||||
const visible = debugTabToggle.checked;
|
||||
debugTabBtn.classList.toggle('is-hidden', !visible);
|
||||
}
|
||||
debugTabToggle.checked = defaults.showDebugTab === true;
|
||||
debugTabToggle.addEventListener('change', () => { updateDebugTab(); markDirty(); });
|
||||
updateDebugTab();
|
||||
|
||||
|
||||
const aiParams = Object.assign({}, DEFAULT_AI_PARAMS, defaults.aiParams || {});
|
||||
for (const [key, val] of Object.entries(aiParams)) {
|
||||
const el = document.getElementById(key);
|
||||
|
@ -123,6 +169,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
let tagList = [];
|
||||
let folderList = [];
|
||||
let accountList = [];
|
||||
try {
|
||||
tagList = await messenger.messages.tags.list();
|
||||
} catch (e) {
|
||||
|
@ -130,6 +177,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
}
|
||||
try {
|
||||
const accounts = await messenger.accounts.list(true);
|
||||
accountList = accounts.map(a => ({ id: a.id, name: a.name }));
|
||||
const collect = (f, prefix='') => {
|
||||
folderList.push({ id: f.id ?? f.path, name: prefix + f.name });
|
||||
(f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/'));
|
||||
|
@ -171,7 +219,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const typeWrapper = document.createElement('div');
|
||||
typeWrapper.className = 'select is-small mr-2';
|
||||
const typeSelect = document.createElement('select');
|
||||
['tag','move','copy','junk','read','flag'].forEach(t => {
|
||||
['tag','move','copy','junk','read','flag','delete','archive','forward','reply'].forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t;
|
||||
opt.textContent = t;
|
||||
|
@ -242,6 +290,25 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
sel.value = String(action.flagged ?? true);
|
||||
wrap.appendChild(sel);
|
||||
paramSpan.appendChild(wrap);
|
||||
} else if (typeSelect.value === 'forward') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'input is-small forward-input';
|
||||
input.placeholder = 'address@example.com';
|
||||
input.value = action.address || '';
|
||||
paramSpan.appendChild(input);
|
||||
} else if (typeSelect.value === 'reply') {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'select is-small';
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'reply-select';
|
||||
sel.appendChild(new Option('all','all'));
|
||||
sel.appendChild(new Option('sender','sender'));
|
||||
sel.value = action.replyType || 'all';
|
||||
wrap.appendChild(sel);
|
||||
paramSpan.appendChild(wrap);
|
||||
} else if (typeSelect.value === 'delete' || typeSelect.value === 'archive') {
|
||||
paramSpan.appendChild(document.createElement('span'));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -261,6 +328,39 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
return row;
|
||||
}
|
||||
|
||||
function createConditionButton(label, sectionEl, checkbox, clearFn) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'button is-small is-light';
|
||||
const icon = document.createElement('img');
|
||||
icon.width = 16;
|
||||
icon.height = 16;
|
||||
icon.className = 'mr-1';
|
||||
btn.appendChild(icon);
|
||||
btn.append(label);
|
||||
|
||||
let active = checkbox ? checkbox.checked : sectionEl && !sectionEl.classList.contains('is-hidden');
|
||||
|
||||
function update() {
|
||||
btn.classList.toggle('is-active', active);
|
||||
icon.src = browser.runtime.getURL(`resources/svg/${active ? 'circledot' : 'circle'}.svg`);
|
||||
if (sectionEl) sectionEl.classList.toggle('is-hidden', !active);
|
||||
if (checkbox) checkbox.checked = active;
|
||||
if (!active && typeof clearFn === 'function') {
|
||||
clearFn();
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
active = !active;
|
||||
markDirty();
|
||||
update();
|
||||
});
|
||||
|
||||
update();
|
||||
return btn;
|
||||
}
|
||||
|
||||
function renderRules(rules = []) {
|
||||
ruleCountEl.textContent = rules.length;
|
||||
rulesContainer.innerHTML = '';
|
||||
|
@ -294,16 +394,67 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'message-header';
|
||||
header.appendChild(critInput);
|
||||
|
||||
const leftWrap = document.createElement('div');
|
||||
leftWrap.style.display = 'flex';
|
||||
leftWrap.style.alignItems = 'center';
|
||||
leftWrap.style.flexGrow = '1';
|
||||
|
||||
const statusSpan = document.createElement('span');
|
||||
statusSpan.className = 'rule-status has-text-weight-semibold mr-2';
|
||||
|
||||
leftWrap.appendChild(statusSpan);
|
||||
leftWrap.appendChild(critInput);
|
||||
header.appendChild(leftWrap);
|
||||
|
||||
const btnWrap = document.createElement('div');
|
||||
btnWrap.style.display = 'flex';
|
||||
btnWrap.style.gap = '0.25em';
|
||||
|
||||
let enabled = rule.enabled !== false;
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'button is-small is-light rule-toggle';
|
||||
const toggleIcon = document.createElement('img');
|
||||
toggleIcon.width = 16;
|
||||
toggleIcon.height = 16;
|
||||
toggleBtn.appendChild(toggleIcon);
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'delete';
|
||||
delBtn.setAttribute('aria-label', 'delete');
|
||||
delBtn.type = 'button';
|
||||
delBtn.className = 'button is-small is-danger is-light rule-delete';
|
||||
const delIcon = document.createElement('img');
|
||||
delIcon.src = browser.runtime.getURL('resources/svg/trash.svg');
|
||||
delIcon.width = 16;
|
||||
delIcon.height = 16;
|
||||
delBtn.appendChild(delIcon);
|
||||
|
||||
function updateToggle() {
|
||||
toggleIcon.src = browser.runtime.getURL(
|
||||
`resources/svg/${enabled ? 'circleslash' : 'check'}.svg`
|
||||
);
|
||||
statusSpan.textContent = enabled ? '' : '(Disabled)';
|
||||
article.dataset.enabled = String(enabled);
|
||||
}
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
enabled = !enabled;
|
||||
markDirty();
|
||||
updateToggle();
|
||||
});
|
||||
|
||||
delBtn.addEventListener('click', () => {
|
||||
article.remove();
|
||||
ruleCountEl.textContent = rulesContainer.querySelectorAll('.rule').length;
|
||||
markDirty();
|
||||
});
|
||||
header.appendChild(delBtn);
|
||||
|
||||
btnWrap.appendChild(toggleBtn);
|
||||
btnWrap.appendChild(delBtn);
|
||||
header.appendChild(btnWrap);
|
||||
|
||||
updateToggle();
|
||||
|
||||
const actionsContainer = document.createElement('div');
|
||||
actionsContainer.className = 'rule-actions mb-2';
|
||||
|
@ -319,7 +470,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow()));
|
||||
|
||||
const stopLabel = document.createElement('label');
|
||||
stopLabel.className = 'checkbox mt-2';
|
||||
stopLabel.className = 'checkbox mt-2 is-hidden';
|
||||
const stopCheck = document.createElement('input');
|
||||
stopCheck.type = 'checkbox';
|
||||
stopCheck.className = 'stop-processing';
|
||||
|
@ -328,7 +479,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
stopLabel.append(' Stop after match');
|
||||
|
||||
const unreadLabel = document.createElement('label');
|
||||
unreadLabel.className = 'checkbox mt-2 ml-4';
|
||||
unreadLabel.className = 'checkbox mt-2 ml-4 is-hidden';
|
||||
const unreadCheck = document.createElement('input');
|
||||
unreadCheck.type = 'checkbox';
|
||||
unreadCheck.className = 'unread-only';
|
||||
|
@ -336,12 +487,121 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
unreadLabel.appendChild(unreadCheck);
|
||||
unreadLabel.append(' Only apply to unread messages');
|
||||
|
||||
const ageBox = document.createElement('div');
|
||||
ageBox.className = 'field is-grouped mt-2 is-hidden';
|
||||
const minInput = document.createElement('input');
|
||||
minInput.type = 'number';
|
||||
minInput.placeholder = 'Min days';
|
||||
minInput.className = 'input is-small min-age mr-2';
|
||||
minInput.style.width = '6em';
|
||||
if (typeof rule.minAgeDays === 'number') minInput.value = rule.minAgeDays;
|
||||
const maxInput = document.createElement('input');
|
||||
maxInput.type = 'number';
|
||||
maxInput.placeholder = 'Max days';
|
||||
maxInput.className = 'input is-small max-age';
|
||||
maxInput.style.width = '6em';
|
||||
if (typeof rule.maxAgeDays === 'number') maxInput.value = rule.maxAgeDays;
|
||||
ageBox.appendChild(minInput);
|
||||
ageBox.appendChild(maxInput);
|
||||
|
||||
const acctBox = document.createElement('div');
|
||||
acctBox.className = 'field mt-2 is-hidden';
|
||||
const acctLabel = document.createElement('label');
|
||||
acctLabel.className = 'label';
|
||||
acctLabel.textContent = 'Accounts';
|
||||
const acctControl = document.createElement('div');
|
||||
const acctWrap = document.createElement('div');
|
||||
acctWrap.className = 'select is-multiple is-small';
|
||||
const acctSel = document.createElement('select');
|
||||
acctSel.className = 'account-select';
|
||||
acctSel.multiple = true;
|
||||
acctSel.size = Math.min(accountList.length, 4) || 1;
|
||||
for (const a of accountList) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.name;
|
||||
if ((rule.accounts || []).includes(a.id)) opt.selected = true;
|
||||
acctSel.appendChild(opt);
|
||||
}
|
||||
acctWrap.appendChild(acctSel);
|
||||
acctControl.appendChild(acctWrap);
|
||||
acctBox.appendChild(acctLabel);
|
||||
acctBox.appendChild(acctControl);
|
||||
|
||||
const folderBox = document.createElement('div');
|
||||
folderBox.className = 'field mt-2 is-hidden';
|
||||
const folderLabel = document.createElement('label');
|
||||
folderLabel.className = 'label';
|
||||
folderLabel.textContent = 'Folders';
|
||||
const folderControl = document.createElement('div');
|
||||
const folderWrap = document.createElement('div');
|
||||
folderWrap.className = 'select is-multiple is-small';
|
||||
const folderSel = document.createElement('select');
|
||||
folderSel.className = 'folder-filter-select';
|
||||
folderSel.multiple = true;
|
||||
folderSel.size = Math.min(folderList.length, 6) || 1;
|
||||
for (const f of folderList) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f.id;
|
||||
opt.textContent = f.name;
|
||||
if ((rule.folders || []).includes(f.id)) opt.selected = true;
|
||||
folderSel.appendChild(opt);
|
||||
}
|
||||
folderWrap.appendChild(folderSel);
|
||||
folderControl.appendChild(folderWrap);
|
||||
folderBox.appendChild(folderLabel);
|
||||
folderBox.appendChild(folderControl);
|
||||
|
||||
if (typeof rule.minAgeDays === 'number' || typeof rule.maxAgeDays === 'number') {
|
||||
ageBox.classList.remove('is-hidden');
|
||||
}
|
||||
if ((rule.accounts || []).length) {
|
||||
acctBox.classList.remove('is-hidden');
|
||||
}
|
||||
if ((rule.folders || []).length) {
|
||||
folderBox.classList.remove('is-hidden');
|
||||
}
|
||||
|
||||
const condButtons = document.createElement('div');
|
||||
condButtons.className = 'field is-grouped is-grouped-multiline mb-2';
|
||||
|
||||
function addCond(btn) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'control';
|
||||
p.appendChild(btn);
|
||||
condButtons.appendChild(p);
|
||||
}
|
||||
|
||||
addCond(createConditionButton('Stop', null, stopCheck, () => {
|
||||
stopCheck.checked = false;
|
||||
}));
|
||||
addCond(createConditionButton('Unread', null, unreadCheck, () => {
|
||||
unreadCheck.checked = false;
|
||||
}));
|
||||
addCond(createConditionButton('Age', ageBox, null, () => {
|
||||
minInput.value = '';
|
||||
maxInput.value = '';
|
||||
}));
|
||||
addCond(createConditionButton('Accounts', acctBox, null, () => {
|
||||
for (const opt of acctSel.options) opt.selected = false;
|
||||
}));
|
||||
addCond(createConditionButton('Folders', folderBox, null, () => {
|
||||
for (const opt of folderSel.options) opt.selected = false;
|
||||
}));
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'message-body';
|
||||
body.appendChild(actionsContainer);
|
||||
body.appendChild(addAction);
|
||||
const condDivider = document.createElement('hr');
|
||||
condDivider.className = 'mt-3 mb-2';
|
||||
body.appendChild(condDivider);
|
||||
body.appendChild(condButtons);
|
||||
body.appendChild(stopLabel);
|
||||
body.appendChild(unreadLabel);
|
||||
body.appendChild(ageBox);
|
||||
body.appendChild(acctBox);
|
||||
body.appendChild(folderBox);
|
||||
|
||||
article.appendChild(header);
|
||||
article.appendChild(body);
|
||||
|
@ -373,18 +633,38 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
if (type === 'flag') {
|
||||
return { type, flagged: row.querySelector('.flag-select').value === 'true' };
|
||||
}
|
||||
if (type === 'delete' || type === 'archive') {
|
||||
return { type };
|
||||
}
|
||||
return { type };
|
||||
});
|
||||
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
|
||||
const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
|
||||
return { criterion, actions, unreadOnly, stopProcessing };
|
||||
const enabled = ruleEl.dataset.enabled !== 'false';
|
||||
const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value);
|
||||
const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value);
|
||||
const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value);
|
||||
const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value);
|
||||
const rule = { criterion, actions, enabled };
|
||||
if (unreadOnly) rule.unreadOnly = true;
|
||||
if (stopProcessing) rule.stopProcessing = true;
|
||||
if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays;
|
||||
if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays;
|
||||
if (accounts.length) rule.accounts = accounts;
|
||||
if (folders.length) rule.folders = folders;
|
||||
return rule;
|
||||
});
|
||||
data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false });
|
||||
data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false, enabled: true, accounts: [], folders: [] });
|
||||
renderRules(data);
|
||||
});
|
||||
|
||||
renderRules((defaults.aiRules || []).map(r => {
|
||||
if (r.actions) return r;
|
||||
if (r.actions) {
|
||||
if (!Array.isArray(r.accounts)) r.accounts = [];
|
||||
if (!Array.isArray(r.folders)) r.folders = [];
|
||||
if (r.enabled !== false) r.enabled = true; else r.enabled = false;
|
||||
return r;
|
||||
}
|
||||
const actions = [];
|
||||
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
|
||||
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
|
||||
|
@ -392,6 +672,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const rule = { criterion: r.criterion, actions };
|
||||
if (r.stopProcessing) rule.stopProcessing = true;
|
||||
if (r.unreadOnly) rule.unreadOnly = true;
|
||||
if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays;
|
||||
if (typeof r.maxAgeDays === 'number') rule.maxAgeDays = r.maxAgeDays;
|
||||
if (Array.isArray(r.accounts)) rule.accounts = r.accounts;
|
||||
if (Array.isArray(r.folders)) rule.folders = r.folders;
|
||||
rule.enabled = r.enabled !== false;
|
||||
return rule;
|
||||
}));
|
||||
|
||||
|
@ -459,6 +744,38 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
} catch {
|
||||
cacheCountEl.textContent = '?';
|
||||
}
|
||||
|
||||
try {
|
||||
if (debugTabToggle.checked) {
|
||||
const latest = await storage.local.get(['lastPayload', 'lastFullText', 'lastPromptText']);
|
||||
const payloadStr = latest.lastPayload ? JSON.stringify(latest.lastPayload, null, 2) : '';
|
||||
if (payloadStr !== lastPayload) {
|
||||
lastPayload = payloadStr;
|
||||
payloadDisplay.textContent = payloadStr;
|
||||
}
|
||||
if (latest.lastFullText !== lastFullText || latest.lastPromptText !== lastPromptText) {
|
||||
lastFullText = latest.lastFullText || '';
|
||||
lastPromptText = latest.lastPromptText || '';
|
||||
if (lastFullText && lastPromptText && diff_match_patch) {
|
||||
const dmp = new diff_match_patch();
|
||||
dmp.Diff_EditCost = 4;
|
||||
const diffs = dmp.diff_main(lastFullText, lastPromptText);
|
||||
dmp.diff_cleanupEfficiency(diffs);
|
||||
const hasDiff = diffs.some(d => d[0] !== 0);
|
||||
if (hasDiff) {
|
||||
diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs);
|
||||
diffContainer.classList.remove('is-hidden');
|
||||
} else {
|
||||
diffDisplay.innerHTML = '';
|
||||
diffContainer.classList.add('is-hidden');
|
||||
}
|
||||
} else {
|
||||
diffDisplay.innerHTML = '';
|
||||
diffContainer.classList.add('is-hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
refreshMaintenance();
|
||||
|
@ -525,17 +842,37 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
if (type === 'flag') {
|
||||
return { type, flagged: row.querySelector('.flag-select').value === 'true' };
|
||||
}
|
||||
if (type === 'forward') {
|
||||
return { type, address: row.querySelector('.forward-input').value.trim() };
|
||||
}
|
||||
if (type === 'reply') {
|
||||
return { type, replyType: row.querySelector('.reply-select').value };
|
||||
}
|
||||
return { type };
|
||||
});
|
||||
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
|
||||
const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
|
||||
return { criterion, actions, unreadOnly, stopProcessing };
|
||||
const enabled = ruleEl.dataset.enabled !== 'false';
|
||||
const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value);
|
||||
const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value);
|
||||
const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value);
|
||||
const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value);
|
||||
const rule = { criterion, actions, enabled };
|
||||
if (unreadOnly) rule.unreadOnly = true;
|
||||
if (stopProcessing) rule.stopProcessing = true;
|
||||
if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays;
|
||||
if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays;
|
||||
if (accounts.length) rule.accounts = accounts;
|
||||
if (folders.length) rule.folders = folders;
|
||||
return rule;
|
||||
}).filter(r => r.criterion);
|
||||
const stripUrlParams = stripUrlToggle.checked;
|
||||
const altTextImages = altTextToggle.checked;
|
||||
const collapseWhitespace = collapseWhitespaceToggle.checked;
|
||||
const tokenReduction = tokenReductionToggle.checked;
|
||||
const showDebugTab = debugTabToggle.checked;
|
||||
const theme = themeSelect.value;
|
||||
await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, aiRules: rules, theme });
|
||||
await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab });
|
||||
await applyTheme(theme);
|
||||
try {
|
||||
await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
|
||||
|
|
BIN
resources/img/circledot-dark-16.png
Normal file
After Width: | Height: | Size: 413 B |
BIN
resources/img/circledot-dark-32.png
Normal file
After Width: | Height: | Size: 828 B |
BIN
resources/img/circledot-dark-64.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
resources/img/circledot-light-16.png
Normal file
After Width: | Height: | Size: 408 B |
BIN
resources/img/circledot-light-32.png
Normal file
After Width: | Height: | Size: 813 B |
BIN
resources/img/circledot-light-64.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
resources/img/circleslash-dark-16.png
Normal file
After Width: | Height: | Size: 392 B |
BIN
resources/img/circleslash-dark-32.png
Normal file
After Width: | Height: | Size: 768 B |
BIN
resources/img/circleslash-dark-64.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
resources/img/circleslash-light-16.png
Normal file
After Width: | Height: | Size: 393 B |
BIN
resources/img/circleslash-light-32.png
Normal file
After Width: | Height: | Size: 764 B |
BIN
resources/img/circleslash-light-64.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
resources/img/plus-dark-16.png
Normal file
After Width: | Height: | Size: 199 B |
BIN
resources/img/plus-dark-32.png
Normal file
After Width: | Height: | Size: 301 B |
BIN
resources/img/plus-dark-64.png
Normal file
After Width: | Height: | Size: 431 B |
BIN
resources/img/plus-light-16.png
Normal file
After Width: | Height: | Size: 215 B |
BIN
resources/img/plus-light-32.png
Normal file
After Width: | Height: | Size: 305 B |
BIN
resources/img/plus-light-64.png
Normal file
After Width: | Height: | Size: 457 B |
2236
resources/js/diff_match_patch_uncompressed.js
Normal file
4
resources/svg/circledot.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24">
|
||||
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/>
|
||||
<path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 357 B |
3
resources/svg/circleslash.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24">
|
||||
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm-7-3L19 5"/>
|
||||
</svg>
|
After Width: | Height: | Size: 317 B |
3
resources/svg/plus.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24">
|
||||
<path d="M21 12H3m9-9v18"/>
|
||||
</svg>
|
After Width: | Height: | Size: 250 B |