Compare commits
130 commits
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 |
|||
5c309c9b69 | |||
23c067e2e7 |
|||
85224f2d4d | |||
3eef24d2dd |
|||
34ed955a5b | |||
0d543e5567 | |||
959482c3d0 |
|||
2d22404198 | |||
64d5c99ba5 | |||
3db1c851fa |
|||
0c105cb6e4 | |||
766afc9079 |
|||
3c87950dfb | |||
5d87ec658d | |||
ea8888f057 | |||
52583cebc1 |
|||
2a31b02039 |
|||
044e7df07d | |||
f6b67b3407 |
|||
1b7fa3d5ee | |||
3c292096eb |
|||
f3e20f8941 | |||
e979869601 |
|||
32e79a13d5 | |||
0c7d40a56d |
|||
a62a882791 | |||
c420699e51 |
|||
0695272b21 | |||
ea6952655b |
|||
0313a03fa4 | |||
0490b67e0a | |||
e1beb5fa35 |
|||
179c81a156 | |||
0041f339e3 |
|||
7e64a428ac | |||
b29d5c702e | |||
cd5ef92e0e |
|||
9a4b1ce239 | |||
6c352e904e | |||
58a2891847 | |||
97628c693b | |||
ff17bd9c1f |
|||
c7333482ce | |||
6a85dbb2eb | |||
f9a31415a0 |
|||
51816d8a19 | |||
46c101f101 |
|||
caf18ed5ab | |||
13751b3ab2 | |||
bd56cbf19a |
|||
aec56aac33 | |||
254f0c5ffc | |||
713a75eaae |
|||
34cf8e234e | |||
58543b9ccd |
|||
d60725eb4b | |||
0c07479fa9 | |||
12e8c940b5 |
|||
8f5165dcec | |||
40835bd9f6 |
|||
eb91474f5a | |||
11cc89dbc1 |
|||
97bfabfbea | |||
846d1270c5 | |||
af8218f6c3 | |||
4fd5e259c6 |
|||
79f49fd502 | |||
9724c19b7d | |||
f40715add1 |
|||
6b741595cc | |||
ac2c017082 |
|||
c0ba2d1fdd | |||
3a10609399 |
|||
41769c0e96 | |||
a358bc5703 |
|||
149ff03cf9 | |||
b160f2221e |
|||
200c03c875 | |||
5e0356086a | |||
1057d8c7fd | |||
8ae32fe752 |
16
AGENTS.md
|
@ -5,8 +5,9 @@ This file provides guidelines for codex agents contributing to the Sortana proje
|
||||||
## Repository Overview
|
## Repository Overview
|
||||||
|
|
||||||
- `background.js`: Handles startup tasks and coordinates message passing within the extension.
|
- `background.js`: Handles startup tasks and coordinates message passing within the extension.
|
||||||
- `modules/`: Contains reusable JavaScript modules such as `AiClassifier.js`.
|
- `modules/`: Contains reusable JavaScript modules such as `AiClassifier.js`,
|
||||||
- `options/`: The options page HTML, JavaScript and Bulma CSS.
|
`defaultParams.js` and `themeUtils.js`.
|
||||||
|
- `options/`: The options page HTML, JavaScript and bundled Bulma CSS (v1.0.3).
|
||||||
- `details.html` and `details.js`: View AI reasoning and clear cache for a message.
|
- `details.html` and `details.js`: View AI reasoning and clear cache for a message.
|
||||||
- `resources/`: Images and other static files.
|
- `resources/`: Images and other static files.
|
||||||
- `prompt_templates/`: Prompt template files for the AI service.
|
- `prompt_templates/`: Prompt template files for the AI service.
|
||||||
|
@ -27,7 +28,7 @@ This file provides guidelines for codex agents contributing to the Sortana proje
|
||||||
|
|
||||||
## Testing
|
## 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
|
## Documentation
|
||||||
|
|
||||||
|
@ -41,7 +42,7 @@ Additional documentation exists outside this repository.
|
||||||
- Thunderbird Add-on Store Policies
|
- Thunderbird Add-on Store Policies
|
||||||
- [Third Party Library Usage](https://extensionworkshop.com/documentation/publish/third-party-library-usage/)
|
- [Third Party Library Usage](https://extensionworkshop.com/documentation/publish/third-party-library-usage/)
|
||||||
- Third Party Libraries
|
- Third Party Libraries
|
||||||
- [Bulma.css](https://github.com/jgthms/bulma)
|
- [Bulma.css v1.0.3](https://github.com/jgthms/bulma/blob/1.0.3/css/bulma.css)
|
||||||
- Issue tracker: [Thunderbird tracker on Bugzilla](https://bugzilla.mozilla.org/describecomponents.cgi?product=Thunderbird)
|
- Issue tracker: [Thunderbird tracker on Bugzilla](https://bugzilla.mozilla.org/describecomponents.cgi?product=Thunderbird)
|
||||||
|
|
||||||
|
|
||||||
|
@ -66,3 +67,10 @@ text extracted from all text parts.
|
||||||
properties. Any legacy `aiReasonCache` data is merged into `aiCache` the first
|
properties. Any legacy `aiReasonCache` data is merged into `aiCache` the first
|
||||||
time the add-on loads after an update.
|
time the add-on loads after an update.
|
||||||
|
|
||||||
|
### Icon Set Usage
|
||||||
|
|
||||||
|
Toolbar and menu icons reside under `resources/img` and are provided in 16, 32
|
||||||
|
and 64 pixel variants. When changing these icons, pass a dictionary mapping the
|
||||||
|
sizes to the paths in `browserAction.setIcon` or `messageDisplayAction.setIcon`.
|
||||||
|
Use `resources/svg2img.ps1` to regenerate PNGs from the SVG sources.
|
||||||
|
|
||||||
|
|
50
README.md
|
@ -14,11 +14,17 @@ message meets a specified criterion.
|
||||||
- **Custom system prompts** – tailor the instructions sent to the model for more precise results.
|
- **Custom system prompts** – tailor the instructions sent to the model for more precise results.
|
||||||
- **Persistent result caching** – classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts.
|
- **Persistent result caching** – classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts.
|
||||||
- **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page.
|
- **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 logging** – optional colorized logs help troubleshoot interactions with the AI service.
|
||||||
- **Automatic rules** – create rules that tag or move new messages based on AI classification.
|
- **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, 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 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.
|
- **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.
|
- **View reasoning** – inspect why rules matched via the Details popup.
|
||||||
- **Cache management** – clear cached results from the context menu or options page.
|
- **Cache management** – clear cached results from the context menu or options page.
|
||||||
- **Queue & timing stats** – monitor processing time on the Maintenance tab.
|
- **Queue & timing stats** – monitor processing time on the Maintenance tab.
|
||||||
|
@ -57,21 +63,29 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e
|
||||||
|
|
||||||
1. Ensure PowerShell is available (for Windows) or adapt the script for other
|
1. Ensure PowerShell is available (for Windows) or adapt the script for other
|
||||||
environments.
|
environments.
|
||||||
2. Ensure the Bulma stylesheet (v1.0.4) is saved as `options/bulma.css`. You can
|
2. The Bulma stylesheet (v1.0.3) is already included as `options/bulma.css`.
|
||||||
download it from <https://github.com/jgthms/bulma/blob/1.0.4/css/bulma.css>.
|
|
||||||
3. Run `powershell ./build-xpi.ps1` from the repository root. The script reads
|
3. Run `powershell ./build-xpi.ps1` from the repository root. The script reads
|
||||||
the version from `manifest.json` and creates an XPI in the `release` folder.
|
the version from `manifest.json` and creates an XPI in the `release` folder.
|
||||||
4. Install the generated XPI in Thunderbird via the Add-ons Manager. During
|
4. Install the generated XPI in Thunderbird via the Add-ons Manager. During
|
||||||
development you can also load the directory as a temporary add-on.
|
development you can also load the directory as a temporary add-on.
|
||||||
|
5. To regenerate PNG icons from the SVG sources, run `resources/svg2img.ps1`.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. Open the add-on's options and set the URL of your classification service.
|
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
|
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
|
actions such as tagging, moving, copying, forwarding, replying,
|
||||||
reorder them and check *Stop after match* to halt further processing.
|
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
|
3. Save your settings. New mail will be evaluated automatically using the
|
||||||
configured rules.
|
configured rules.
|
||||||
|
4. If the toolbar icon shows a red X, click the notification's **Dismiss** button to clear the error.
|
||||||
|
|
||||||
### Example Filters
|
### Example Filters
|
||||||
|
|
||||||
|
@ -99,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.
|
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
|
You can define as many filters as you'd like, each using a different prompt and
|
||||||
triggering tags, moves, or actions 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
|
## Required Permissions
|
||||||
|
|
||||||
|
@ -107,11 +121,14 @@ Sortana requests the following Thunderbird permissions:
|
||||||
|
|
||||||
- `storage` – store configuration and cached classification results.
|
- `storage` – store configuration and cached classification results.
|
||||||
- `messagesRead` – read message contents for classification.
|
- `messagesRead` – read message contents for classification.
|
||||||
- `messagesMove` – move messages when a rule specifies a target folder.
|
- `messagesMove` – move or copy messages when a rule specifies a target folder.
|
||||||
- `messagesUpdate` – change message properties such as tags and junk status.
|
- `messagesUpdate` – change message properties such as tags, junk status, read/unread state and flags.
|
||||||
- `messagesTagsList` – retrieve existing message tags for rule actions.
|
- `messagesTagsList` – retrieve existing message tags for rule actions.
|
||||||
- `accountsRead` – list accounts and folders for move actions.
|
- `accountsRead` – list accounts and folders for move or copy actions.
|
||||||
- `menus` – add context menu commands.
|
- `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
|
## Thunderbird Add-on Store Disclosures
|
||||||
|
|
||||||
|
@ -120,12 +137,17 @@ requires disclosure of third party libraries that are included in the add-on. Ev
|
||||||
the disclosure is only required for add-on review, they'll be listed here as well. Sortana
|
the disclosure is only required for add-on review, they'll be listed here as well. Sortana
|
||||||
uses the following third party libraries:
|
uses the following third party libraries:
|
||||||
|
|
||||||
- [Bulma.css v1.0.4](https://github.com/jgthms/bulma/blob/1.0.4/css/bulma.css)
|
- [Bulma.css v1.0.3](https://github.com/jgthms/bulma/blob/1.0.3/css/bulma.css)
|
||||||
|
- 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
|
## License
|
||||||
|
|
||||||
This project is licensed under the terms of the GNU General Public License
|
This project is licensed under the terms of the GNU General Public License
|
||||||
version 3. See `LICENSE` for the full text.
|
version 3. See `LICENSE` for the full text. Third party libraries are licensed seperately.
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
|
@ -135,3 +157,5 @@ Sortana builds upon knowledge gained from open-source projects. In particular,
|
||||||
how Thunderbird's WebExtension and experiment APIs can be extended. Their code
|
how Thunderbird's WebExtension and experiment APIs can be extended. Their code
|
||||||
provided invaluable guidance during development.
|
provided invaluable guidance during development.
|
||||||
|
|
||||||
|
- Icons from [cc0-icons.jonh.eu](https://cc0-icons.jonh.eu/) are used under the CC0 license.
|
||||||
|
|
||||||
|
|
|
@ -14,5 +14,24 @@
|
||||||
"template.mistral": { "message": "Mistral" },
|
"template.mistral": { "message": "Mistral" },
|
||||||
"template.custom": { "message": "Custom" },
|
"template.custom": { "message": "Custom" },
|
||||||
"options.save": { "message": "Save" },
|
"options.save": { "message": "Save" },
|
||||||
"options.debugLogging": { "message": "Enable debug logging" }
|
"options.debugLogging": { "message": "Enable debug logging" },
|
||||||
|
"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.tokenReduction": { "message": "Aggressive token reduction" }
|
||||||
|
,"action.read": { "message": "read" }
|
||||||
|
,"action.flag": { "message": "flag" }
|
||||||
|
,"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" }
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,14 +47,41 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "prompt_templates", "prompt_
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{68A87938-5C2B-49F5-8AAA-8A34FBBFD854}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{68A87938-5C2B-49F5-8AAA-8A34FBBFD854}"
|
||||||
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
resources\svg2img.ps1 = resources\svg2img.ps1
|
||||||
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-1755-4A95-A11B-6C90C701C5BF}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-1755-4A95-A11B-6C90C701C5BF}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
resources\img\brain.png = resources\img\brain.png
|
resources\img\average-16.png = resources\img\average-16.png
|
||||||
resources\img\busy.png = resources\img\busy.png
|
resources\img\average-32.png = resources\img\average-32.png
|
||||||
resources\img\done.png = resources\img\done.png
|
resources\img\average-64.png = resources\img\average-64.png
|
||||||
resources\img\error.png = resources\img\error.png
|
resources\img\check-16.png = resources\img\check-16.png
|
||||||
|
resources\img\check-32.png = resources\img\check-32.png
|
||||||
|
resources\img\check-64.png = resources\img\check-64.png
|
||||||
|
resources\img\circle-16.png = resources\img\circle-16.png
|
||||||
|
resources\img\circle-32.png = resources\img\circle-32.png
|
||||||
|
resources\img\circle-64.png = resources\img\circle-64.png
|
||||||
|
resources\img\circledots-16.png = resources\img\circledots-16.png
|
||||||
|
resources\img\circledots-32.png = resources\img\circledots-32.png
|
||||||
|
resources\img\circledots-64.png = resources\img\circledots-64.png
|
||||||
|
resources\img\clipboarddata-16.png = resources\img\clipboarddata-16.png
|
||||||
|
resources\img\clipboarddata-32.png = resources\img\clipboarddata-32.png
|
||||||
|
resources\img\clipboarddata-64.png = resources\img\clipboarddata-64.png
|
||||||
|
resources\img\download-16.png = resources\img\download-16.png
|
||||||
|
resources\img\download-32.png = resources\img\download-32.png
|
||||||
|
resources\img\download-64.png = resources\img\download-64.png
|
||||||
|
resources\img\eye-16.png = resources\img\eye-16.png
|
||||||
|
resources\img\eye-32.png = resources\img\eye-32.png
|
||||||
|
resources\img\eye-64.png = resources\img\eye-64.png
|
||||||
|
resources\img\flag-16.png = resources\img\flag-16.png
|
||||||
|
resources\img\flag-32.png = resources\img\flag-32.png
|
||||||
|
resources\img\flag-64.png = resources\img\flag-64.png
|
||||||
|
resources\img\full-logo-white.png = resources\img\full-logo-white.png
|
||||||
resources\img\full-logo.png = resources\img\full-logo.png
|
resources\img\full-logo.png = resources\img\full-logo.png
|
||||||
|
resources\img\gear-16.png = resources\img\gear-16.png
|
||||||
|
resources\img\gear-32.png = resources\img\gear-32.png
|
||||||
|
resources\img\gear-64.png = resources\img\gear-64.png
|
||||||
resources\img\logo.png = resources\img\logo.png
|
resources\img\logo.png = resources\img\logo.png
|
||||||
resources\img\logo128.png = resources\img\logo128.png
|
resources\img\logo128.png = resources\img\logo128.png
|
||||||
resources\img\logo16.png = resources\img\logo16.png
|
resources\img\logo16.png = resources\img\logo16.png
|
||||||
|
@ -62,6 +89,45 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-175
|
||||||
resources\img\logo48.png = resources\img\logo48.png
|
resources\img\logo48.png = resources\img\logo48.png
|
||||||
resources\img\logo64.png = resources\img\logo64.png
|
resources\img\logo64.png = resources\img\logo64.png
|
||||||
resources\img\logo96.png = resources\img\logo96.png
|
resources\img\logo96.png = resources\img\logo96.png
|
||||||
|
resources\img\reply-16.png = resources\img\reply-16.png
|
||||||
|
resources\img\reply-32.png = resources\img\reply-32.png
|
||||||
|
resources\img\reply-64.png = resources\img\reply-64.png
|
||||||
|
resources\img\settings-16.png = resources\img\settings-16.png
|
||||||
|
resources\img\settings-32.png = resources\img\settings-32.png
|
||||||
|
resources\img\settings-64.png = resources\img\settings-64.png
|
||||||
|
resources\img\trash-16.png = resources\img\trash-16.png
|
||||||
|
resources\img\trash-32.png = resources\img\trash-32.png
|
||||||
|
resources\img\trash-64.png = resources\img\trash-64.png
|
||||||
|
resources\img\upload-16.png = resources\img\upload-16.png
|
||||||
|
resources\img\upload-32.png = resources\img\upload-32.png
|
||||||
|
resources\img\upload-64.png = resources\img\upload-64.png
|
||||||
|
resources\img\x-16.png = resources\img\x-16.png
|
||||||
|
resources\img\x-32.png = resources\img\x-32.png
|
||||||
|
resources\img\x-64.png = resources\img\x-64.png
|
||||||
|
EndProjectSection
|
||||||
|
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
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "svg", "svg", "{D4E9C905-4884-488E-B763-5BD39049C1B1}"
|
||||||
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
resources\svg\average.svg = resources\svg\average.svg
|
||||||
|
resources\svg\check.svg = resources\svg\check.svg
|
||||||
|
resources\svg\circle.svg = resources\svg\circle.svg
|
||||||
|
resources\svg\circledots.svg = resources\svg\circledots.svg
|
||||||
|
resources\svg\clipboarddata.svg = resources\svg\clipboarddata.svg
|
||||||
|
resources\svg\download.svg = resources\svg\download.svg
|
||||||
|
resources\svg\eye.svg = resources\svg\eye.svg
|
||||||
|
resources\svg\flag.svg = resources\svg\flag.svg
|
||||||
|
resources\svg\gear.svg = resources\svg\gear.svg
|
||||||
|
resources\svg\reply.svg = resources\svg\reply.svg
|
||||||
|
resources\svg\settings.svg = resources\svg\settings.svg
|
||||||
|
resources\svg\trash.svg = resources\svg\trash.svg
|
||||||
|
resources\svg\upload.svg = resources\svg\upload.svg
|
||||||
|
resources\svg\x.svg = resources\svg\x.svg
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
|
@ -76,5 +142,7 @@ Global
|
||||||
{86516D53-50D4-4FE2-9D8A-977A8F5EBDBD} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
|
{86516D53-50D4-4FE2-9D8A-977A8F5EBDBD} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
|
||||||
{68A87938-5C2B-49F5-8AAA-8A34FBBFD854} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
|
{68A87938-5C2B-49F5-8AAA-8A34FBBFD854} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
|
||||||
{F266602F-1755-4A95-A11B-6C90C701C5BF} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854}
|
{F266602F-1755-4A95-A11B-6C90C701C5BF} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854}
|
||||||
|
{21D2A42C-3F85-465C-9141-C106AFD92B68} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854}
|
||||||
|
{D4E9C905-4884-488E-B763-5BD39049C1B1} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
818
background.js
|
@ -22,6 +22,60 @@ let iconTimer = null;
|
||||||
let timingStats = { count: 0, mean: 0, m2: 0, total: 0, last: -1 };
|
let timingStats = { count: 0, mean: 0, m2: 0, total: 0, last: -1 };
|
||||||
let currentStart = 0;
|
let currentStart = 0;
|
||||||
let logGetTiming = true;
|
let logGetTiming = true;
|
||||||
|
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) {
|
||||||
|
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 });
|
||||||
|
if (r.copyTarget || r.copyTo) actions.push({ type: 'copy', copyTarget: r.copyTarget || r.copyTo });
|
||||||
|
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;
|
||||||
|
}) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconPaths(name) {
|
||||||
|
return {
|
||||||
|
16: `resources/img/${name}-${currentTheme}-16.png`,
|
||||||
|
32: `resources/img/${name}-${currentTheme}-32.png`,
|
||||||
|
64: `resources/img/${name}-${currentTheme}-64.png`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
logo: () => 'resources/img/logo.png',
|
||||||
|
circledots: () => iconPaths('circledots'),
|
||||||
|
circle: () => iconPaths('circle'),
|
||||||
|
average: () => iconPaths('average'),
|
||||||
|
error: () => iconPaths('x')
|
||||||
|
};
|
||||||
|
|
||||||
function setIcon(path) {
|
function setIcon(path) {
|
||||||
if (browser.browserAction) {
|
if (browser.browserAction) {
|
||||||
|
@ -33,27 +87,67 @@ function setIcon(path) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateActionIcon() {
|
function updateActionIcon() {
|
||||||
let path = "resources/img/brain.png";
|
let path = ICONS.logo();
|
||||||
if (processing || queuedCount > 0) {
|
if (errorPending) {
|
||||||
path = "resources/img/busy.png";
|
path = ICONS.error();
|
||||||
|
} else if (processing || queuedCount > 0) {
|
||||||
|
path = ICONS.circledots();
|
||||||
}
|
}
|
||||||
setIcon(path);
|
setIcon(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTransientIcon(path, delay = 1500) {
|
function showTransientIcon(factory, delay = 1500) {
|
||||||
|
if (errorPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearTimeout(iconTimer);
|
clearTimeout(iconTimer);
|
||||||
|
const path = typeof factory === 'function' ? factory() : factory;
|
||||||
setIcon(path);
|
setIcon(path);
|
||||||
iconTimer = setTimeout(updateActionIcon, delay);
|
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') });
|
||||||
|
browser.menus.update('clear-ai-cache-list', { icons: iconPaths('trash') });
|
||||||
|
browser.menus.update('clear-ai-cache-display', { icons: iconPaths('trash') });
|
||||||
|
browser.menus.update('view-ai-reason-list', { icons: iconPaths('clipboarddata') });
|
||||||
|
browser.menus.update('view-ai-reason-display', { icons: iconPaths('clipboarddata') });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function byteSize(str) {
|
function byteSize(str) {
|
||||||
return new TextEncoder().encode(str || "").length;
|
return new TextEncoder().encode(str || "").length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceInlineBase64(text) {
|
function replaceInlineBase64(text) {
|
||||||
return text.replace(/[A-Za-z0-9+/]{100,}={0,2}/g,
|
return text.replace(/(?:data:[^;]+;base64,)?[A-Za-z0-9+/=\r\n]{100,}/g,
|
||||||
m => `[base64: ${byteSize(m)} bytes]`);
|
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('?');
|
||||||
|
return idx >= 0 ? m.slice(0, idx) : m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (collapseWhitespace) {
|
||||||
|
t = t.replace(/[\u200B-\u200D\u2060\s]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n');
|
||||||
|
}
|
||||||
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectText(part, bodyParts, attachments) {
|
function collectText(part, bodyParts, attachments) {
|
||||||
|
@ -70,21 +164,236 @@ function collectText(part, bodyParts, attachments) {
|
||||||
attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`);
|
attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`);
|
||||||
} else if (ct.startsWith("text/html")) {
|
} else if (ct.startsWith("text/html")) {
|
||||||
const doc = new DOMParser().parseFromString(body, 'text/html');
|
const doc = new DOMParser().parseFromString(body, 'text/html');
|
||||||
bodyParts.push(replaceInlineBase64(doc.body.textContent || ""));
|
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');
|
||||||
|
if (href) a.setAttribute('href', href.split('?')[0]);
|
||||||
|
});
|
||||||
|
doc.querySelectorAll('[src]').forEach(e => {
|
||||||
|
const src = e.getAttribute('src');
|
||||||
|
if (src) e.setAttribute('src', src.split('?')[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (htmlToMarkdown && TurndownService) {
|
||||||
|
try {
|
||||||
|
const td = new TurndownService();
|
||||||
|
const md = sanitizeString(td.turndown(doc.body.innerHTML || body));
|
||||||
|
bodyParts.push(replaceInlineBase64(`[HTML Body converted to Markdown]\n${md}`));
|
||||||
|
} catch (e) {
|
||||||
|
bodyParts.push(replaceInlineBase64(sanitizeString(doc.body.textContent || "")));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bodyParts.push(replaceInlineBase64(sanitizeString(doc.body.textContent || "")));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
bodyParts.push(replaceInlineBase64(body));
|
bodyParts.push(replaceInlineBase64(sanitizeString(body)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 bodyParts = [];
|
||||||
const attachments = [];
|
const attachments = [];
|
||||||
collectText(full, bodyParts, attachments);
|
const collect = applyTransforms ? collectText : collectRawText;
|
||||||
|
collect(full, bodyParts, attachments);
|
||||||
const headers = Object.entries(full.headers || {})
|
const headers = Object.entries(full.headers || {})
|
||||||
.map(([k,v]) => `${k}: ${v.join(' ')}`)
|
.map(([k, v]) => `${k}: ${v.join(' ')}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
const attachInfo = `Attachments: ${attachments.length}` + (attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : "");
|
const attachInfo = `Attachments: ${attachments.length}` +
|
||||||
return `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim();
|
(attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : "");
|
||||||
|
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) {
|
||||||
|
const t = timingStats;
|
||||||
|
t.count += 1;
|
||||||
|
t.total += elapsed;
|
||||||
|
t.last = elapsed;
|
||||||
|
const delta = elapsed - t.mean;
|
||||||
|
t.mean += delta / t.count;
|
||||||
|
t.m2 += delta * (elapsed - t.mean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllMessageIds(list) {
|
||||||
|
const ids = [];
|
||||||
|
if (!list) {
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
let page = list;
|
||||||
|
ids.push(...(page.messages || []).map(m => m.id));
|
||||||
|
while (page.id) {
|
||||||
|
page = await messenger.messages.continueList(page.id);
|
||||||
|
ids.push(...(page.messages || []).map(m => m.id));
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processMessage(id) {
|
||||||
|
processing = true;
|
||||||
|
currentStart = Date.now();
|
||||||
|
queuedCount--;
|
||||||
|
updateActionIcon();
|
||||||
|
try {
|
||||||
|
const full = await messenger.messages.getFull(id);
|
||||||
|
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 {
|
||||||
|
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) {
|
||||||
|
for (const act of (rule.actions || [])) {
|
||||||
|
if (act.type === 'tag' && act.tagKey) {
|
||||||
|
if (!currentTags.includes(act.tagKey)) {
|
||||||
|
currentTags.push(act.tagKey);
|
||||||
|
await messenger.messages.update(id, { tags: currentTags });
|
||||||
|
}
|
||||||
|
} else if (act.type === 'move' && act.folder) {
|
||||||
|
await messenger.messages.move([id], act.folder);
|
||||||
|
} else if (act.type === 'copy' && act.copyTarget) {
|
||||||
|
await messenger.messages.copy([id], act.copyTarget);
|
||||||
|
} else if (act.type === 'junk') {
|
||||||
|
await messenger.messages.update(id, { junk: !!act.junk });
|
||||||
|
} else if (act.type === 'read') {
|
||||||
|
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) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processing = false;
|
||||||
|
const elapsed = Date.now() - currentStart;
|
||||||
|
currentStart = 0;
|
||||||
|
updateTimingStats(elapsed);
|
||||||
|
await storage.local.set({ classifyStats: timingStats });
|
||||||
|
showTransientIcon(ICONS.circle);
|
||||||
|
} catch (e) {
|
||||||
|
processing = false;
|
||||||
|
const elapsed = Date.now() - currentStart;
|
||||||
|
currentStart = 0;
|
||||||
|
updateTimingStats(elapsed);
|
||||||
|
await storage.local.set({ classifyStats: timingStats, errorPending: true });
|
||||||
|
errorPending = true;
|
||||||
|
logger.aiLog("failed to apply AI rules", { level: 'error' }, e);
|
||||||
|
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) {
|
async function applyAiRules(idsInput) {
|
||||||
const ids = Array.isArray(idsInput) ? idsInput : [idsInput];
|
const ids = Array.isArray(idsInput) ? idsInput : [idsInput];
|
||||||
|
@ -92,86 +401,14 @@ async function applyAiRules(idsInput) {
|
||||||
|
|
||||||
if (!aiRules.length) {
|
if (!aiRules.length) {
|
||||||
const { aiRules: stored } = await storage.local.get("aiRules");
|
const { aiRules: stored } = await storage.local.get("aiRules");
|
||||||
aiRules = Array.isArray(stored) ? stored.map(r => {
|
aiRules = normalizeRules(stored);
|
||||||
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 });
|
|
||||||
const rule = { criterion: r.criterion, actions };
|
|
||||||
if (r.stopProcessing) rule.stopProcessing = true;
|
|
||||||
return rule;
|
|
||||||
}) : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const msg of ids) {
|
for (const msg of ids) {
|
||||||
const id = msg?.id ?? msg;
|
const id = msg?.id ?? msg;
|
||||||
queuedCount++;
|
queuedCount++;
|
||||||
updateActionIcon();
|
updateActionIcon();
|
||||||
queue = queue.then(async () => {
|
queue = queue.then(() => processMessage(id));
|
||||||
processing = true;
|
|
||||||
currentStart = Date.now();
|
|
||||||
queuedCount--;
|
|
||||||
updateActionIcon();
|
|
||||||
try {
|
|
||||||
const full = await messenger.messages.getFull(id);
|
|
||||||
const text = buildEmailText(full);
|
|
||||||
let currentTags = [];
|
|
||||||
try {
|
|
||||||
const hdr = await messenger.messages.get(id);
|
|
||||||
currentTags = Array.isArray(hdr.tags) ? [...hdr.tags] : [];
|
|
||||||
} catch (e) {
|
|
||||||
currentTags = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const rule of aiRules) {
|
|
||||||
const cacheKey = await AiClassifier.buildCacheKey(id, rule.criterion);
|
|
||||||
const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey);
|
|
||||||
if (matched) {
|
|
||||||
for (const act of (rule.actions || [])) {
|
|
||||||
if (act.type === 'tag' && act.tagKey) {
|
|
||||||
if (!currentTags.includes(act.tagKey)) {
|
|
||||||
currentTags.push(act.tagKey);
|
|
||||||
await messenger.messages.update(id, { tags: currentTags });
|
|
||||||
}
|
|
||||||
} 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.stopProcessing) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
processing = false;
|
|
||||||
const elapsed = Date.now() - currentStart;
|
|
||||||
currentStart = 0;
|
|
||||||
const t = timingStats;
|
|
||||||
t.count += 1;
|
|
||||||
t.total += elapsed;
|
|
||||||
t.last = elapsed;
|
|
||||||
const delta = elapsed - t.mean;
|
|
||||||
t.mean += delta / t.count;
|
|
||||||
t.m2 += delta * (elapsed - t.mean);
|
|
||||||
await storage.local.set({ classifyStats: t });
|
|
||||||
showTransientIcon("resources/img/done.png");
|
|
||||||
} catch (e) {
|
|
||||||
processing = false;
|
|
||||||
const elapsed = Date.now() - currentStart;
|
|
||||||
currentStart = 0;
|
|
||||||
const t = timingStats;
|
|
||||||
t.count += 1;
|
|
||||||
t.total += elapsed;
|
|
||||||
t.last = elapsed;
|
|
||||||
const delta = elapsed - t.mean;
|
|
||||||
t.mean += delta / t.count;
|
|
||||||
t.m2 += delta * (elapsed - t.mean);
|
|
||||||
await storage.local.set({ classifyStats: t });
|
|
||||||
logger.aiLog("failed to apply AI rules", { level: 'error' }, e);
|
|
||||||
showTransientIcon("resources/img/error.png");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return queue;
|
return queue;
|
||||||
|
@ -183,15 +420,7 @@ async function clearCacheForMessages(idsInput) {
|
||||||
|
|
||||||
if (!aiRules.length) {
|
if (!aiRules.length) {
|
||||||
const { aiRules: stored } = await storage.local.get("aiRules");
|
const { aiRules: stored } = await storage.local.get("aiRules");
|
||||||
aiRules = Array.isArray(stored) ? stored.map(r => {
|
aiRules = normalizeRules(stored);
|
||||||
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 });
|
|
||||||
const rule = { criterion: r.criterion, actions };
|
|
||||||
if (r.stopProcessing) rule.stopProcessing = true;
|
|
||||||
return rule;
|
|
||||||
}) : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = [];
|
const keys = [];
|
||||||
|
@ -204,25 +433,40 @@ async function clearCacheForMessages(idsInput) {
|
||||||
}
|
}
|
||||||
if (keys.length) {
|
if (keys.length) {
|
||||||
await AiClassifier.removeCacheEntries(keys);
|
await AiClassifier.removeCacheEntries(keys);
|
||||||
showTransientIcon("resources/img/done.png");
|
showTransientIcon(ICONS.circle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
logger = await import(browser.runtime.getURL("logger.js"));
|
logger = await import(browser.runtime.getURL("logger.js"));
|
||||||
|
({ detectSystemTheme } = await import(browser.runtime.getURL('modules/themeUtils.js')));
|
||||||
try {
|
try {
|
||||||
AiClassifier = await import(browser.runtime.getURL("modules/AiClassifier.js"));
|
AiClassifier = await import(browser.runtime.getURL("modules/AiClassifier.js"));
|
||||||
logger.aiLog("AiClassifier imported", {debug: true});
|
logger.aiLog("AiClassifier imported", { debug: true });
|
||||||
|
const td = await import(browser.runtime.getURL("resources/js/turndown.js"));
|
||||||
|
TurndownService = td.default || td.TurndownService;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("failed to import AiClassifier", e);
|
console.error("failed to import AiClassifier", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "aiRules"]);
|
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);
|
logger.setDebug(store.debugLogging);
|
||||||
await AiClassifier.setConfig(store);
|
await AiClassifier.setConfig(store);
|
||||||
|
userTheme = store.theme || 'auto';
|
||||||
|
currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme;
|
||||||
await AiClassifier.init();
|
await AiClassifier.init();
|
||||||
|
htmlToMarkdown = store.htmlToMarkdown === true;
|
||||||
|
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');
|
const savedStats = await storage.local.get('classifyStats');
|
||||||
if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') {
|
if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') {
|
||||||
Object.assign(timingStats, savedStats.classifyStats);
|
Object.assign(timingStats, savedStats.classifyStats);
|
||||||
|
@ -230,93 +474,152 @@ async function clearCacheForMessages(idsInput) {
|
||||||
if (typeof timingStats.last !== 'number') {
|
if (typeof timingStats.last !== 'number') {
|
||||||
timingStats.last = -1;
|
timingStats.last = -1;
|
||||||
}
|
}
|
||||||
aiRules = Array.isArray(store.aiRules) ? store.aiRules.map(r => {
|
aiRules = normalizeRules(store.aiRules);
|
||||||
if (r.actions) return r;
|
logger.aiLog("configuration loaded", { debug: true }, store);
|
||||||
const actions = [];
|
|
||||||
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
|
|
||||||
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
|
|
||||||
const rule = { criterion: r.criterion, actions };
|
|
||||||
if (r.stopProcessing) rule.stopProcessing = true;
|
|
||||||
return rule;
|
|
||||||
}) : [];
|
|
||||||
logger.aiLog("configuration loaded", {debug: true}, store);
|
|
||||||
storage.onChanged.addListener(async changes => {
|
storage.onChanged.addListener(async changes => {
|
||||||
if (changes.aiRules) {
|
if (changes.aiRules) {
|
||||||
const newRules = changes.aiRules.newValue || [];
|
const newRules = changes.aiRules.newValue || [];
|
||||||
aiRules = newRules.map(r => {
|
aiRules = normalizeRules(newRules);
|
||||||
if (r.actions) return r;
|
logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules);
|
||||||
const actions = [];
|
}
|
||||||
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
|
if (changes.endpoint || changes.templateName || changes.customTemplate || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) {
|
||||||
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
|
const config = {};
|
||||||
const rule = { criterion: r.criterion, actions };
|
if (changes.endpoint) config.endpoint = changes.endpoint.newValue;
|
||||||
if (r.stopProcessing) rule.stopProcessing = true;
|
if (changes.templateName) config.templateName = changes.templateName.newValue;
|
||||||
return rule;
|
if (changes.customTemplate) config.customTemplate = changes.customTemplate.newValue;
|
||||||
});
|
if (changes.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue;
|
||||||
logger.aiLog("aiRules updated from storage change", {debug: true}, aiRules);
|
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);
|
||||||
|
}
|
||||||
|
if (changes.stripUrlParams) {
|
||||||
|
stripUrlParams = changes.stripUrlParams.newValue === true;
|
||||||
|
logger.aiLog("stripUrlParams updated from storage change", { debug: true }, stripUrlParams);
|
||||||
|
}
|
||||||
|
if (changes.altTextImages) {
|
||||||
|
altTextImages = changes.altTextImages.newValue === true;
|
||||||
|
logger.aiLog("altTextImages updated from storage change", { debug: true }, altTextImages);
|
||||||
|
}
|
||||||
|
if (changes.collapseWhitespace) {
|
||||||
|
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;
|
||||||
|
updateActionIcon();
|
||||||
|
refreshMenuIcons();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (browser.theme?.onUpdated) {
|
||||||
|
browser.theme.onUpdated.addListener(async () => {
|
||||||
|
if (userTheme === 'auto') {
|
||||||
|
const theme = await detectSystemTheme();
|
||||||
|
if (theme !== currentTheme) {
|
||||||
|
currentTheme = theme;
|
||||||
|
updateActionIcon();
|
||||||
|
refreshMenuIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.aiLog("failed to load config", {level: 'error'}, err);
|
logger.aiLog("failed to load config", { level: 'error' }, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.aiLog("background.js loaded – ready to classify", {debug: true});
|
logger.aiLog("background.js loaded – ready to classify", { debug: true });
|
||||||
|
updateActionIcon();
|
||||||
if (browser.messageDisplayAction) {
|
if (browser.messageDisplayAction) {
|
||||||
browser.messageDisplayAction.setTitle({ title: "Details" });
|
browser.messageDisplayAction.setTitle({ title: "Details" });
|
||||||
if (browser.messageDisplayAction.setLabel) {
|
if (browser.messageDisplayAction.setLabel) {
|
||||||
browser.messageDisplayAction.setLabel({ label: "Details" });
|
browser.messageDisplayAction.setLabel({ label: "Details" });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
browser.menus.create({
|
browser.menus.create({
|
||||||
id: "apply-ai-rules-list",
|
id: "apply-ai-rules-list",
|
||||||
title: "Apply AI Rules",
|
title: "Apply AI Rules",
|
||||||
contexts: ["message_list"],
|
contexts: ["message_list"],
|
||||||
|
icons: iconPaths('eye')
|
||||||
});
|
});
|
||||||
browser.menus.create({
|
browser.menus.create({
|
||||||
id: "apply-ai-rules-display",
|
id: "apply-ai-rules-display",
|
||||||
title: "Apply AI Rules",
|
title: "Apply AI Rules",
|
||||||
contexts: ["message_display_action"],
|
contexts: ["message_display_action"],
|
||||||
|
icons: iconPaths('eye')
|
||||||
});
|
});
|
||||||
browser.menus.create({
|
browser.menus.create({
|
||||||
id: "clear-ai-cache-list",
|
id: "clear-ai-cache-list",
|
||||||
title: "Clear AI Cache",
|
title: "Clear AI Cache",
|
||||||
contexts: ["message_list"],
|
contexts: ["message_list"],
|
||||||
|
icons: iconPaths('trash')
|
||||||
});
|
});
|
||||||
browser.menus.create({
|
browser.menus.create({
|
||||||
id: "clear-ai-cache-display",
|
id: "clear-ai-cache-display",
|
||||||
title: "Clear AI Cache",
|
title: "Clear AI Cache",
|
||||||
contexts: ["message_display_action"],
|
contexts: ["message_display_action"],
|
||||||
|
icons: iconPaths('trash')
|
||||||
});
|
});
|
||||||
browser.menus.create({
|
browser.menus.create({
|
||||||
id: "view-ai-reason-list",
|
id: "view-ai-reason-list",
|
||||||
title: "View Reasoning",
|
title: "View Reasoning",
|
||||||
contexts: ["message_list"],
|
contexts: ["message_list"],
|
||||||
icons: { "16": "resources/img/brain.png" }
|
icons: iconPaths('clipboarddata')
|
||||||
});
|
});
|
||||||
browser.menus.create({
|
browser.menus.create({
|
||||||
id: "view-ai-reason-display",
|
id: "view-ai-reason-display",
|
||||||
title: "View Reasoning",
|
title: "View Reasoning",
|
||||||
contexts: ["message_display_action"],
|
contexts: ["message_display_action"],
|
||||||
icons: { "16": "resources/img/brain.png" }
|
icons: iconPaths('clipboarddata')
|
||||||
});
|
});
|
||||||
|
refreshMenuIcons();
|
||||||
|
|
||||||
|
browser.menus.onClicked.addListener(async (info, tab) => {
|
||||||
|
|
||||||
browser.menus.onClicked.addListener(async info => {
|
|
||||||
if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") {
|
if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") {
|
||||||
const ids = info.selectedMessages?.messages?.map(m => m.id) ||
|
let ids = info.messageId ? [info.messageId] : [];
|
||||||
(info.messageId ? [info.messageId] : []);
|
if (info.selectedMessages) {
|
||||||
|
ids = await getAllMessageIds(info.selectedMessages);
|
||||||
|
}
|
||||||
await applyAiRules(ids);
|
await applyAiRules(ids);
|
||||||
} else if (info.menuItemId === "clear-ai-cache-list" || info.menuItemId === "clear-ai-cache-display") {
|
} else if (info.menuItemId === "clear-ai-cache-list" || info.menuItemId === "clear-ai-cache-display") {
|
||||||
const ids = info.selectedMessages?.messages?.map(m => m.id) ||
|
let ids = info.messageId ? [info.messageId] : [];
|
||||||
(info.messageId ? [info.messageId] : []);
|
if (info.selectedMessages) {
|
||||||
|
ids = await getAllMessageIds(info.selectedMessages);
|
||||||
|
}
|
||||||
await clearCacheForMessages(ids);
|
await clearCacheForMessages(ids);
|
||||||
} else if (info.menuItemId === "view-ai-reason-list" || info.menuItemId === "view-ai-reason-display") {
|
} else if (info.menuItemId === "view-ai-reason-list" || info.menuItemId === "view-ai-reason-display") {
|
||||||
const id = info.messageId || info.selectedMessages?.messages?.[0]?.id;
|
const [header] = await browser.messageDisplay.getDisplayedMessages(tab.id);
|
||||||
if (id) {
|
if (!header) { return; }
|
||||||
const url = browser.runtime.getURL(`details.html?mid=${id}`);
|
|
||||||
browser.tabs.create({ url });
|
const url = `${browser.runtime.getURL("details.html")}?mid=${header.id}`;
|
||||||
}
|
|
||||||
|
await browser.tabs.create({ url });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -328,134 +631,139 @@ async function clearCacheForMessages(idsInput) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg?.type === "sortana:test") {
|
if (msg?.type === "sortana:test") {
|
||||||
const { text = "", criterion = "" } = msg;
|
const { text = "", criterion = "" } = msg;
|
||||||
logger.aiLog("sortana:test – text", {debug: true}, text);
|
logger.aiLog("sortana:test – text", { debug: true }, text);
|
||||||
logger.aiLog("sortana:test – criterion", {debug: true}, criterion);
|
logger.aiLog("sortana:test – criterion", { debug: true }, criterion);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.aiLog("Calling AiClassifier.classifyText()", {debug: true});
|
logger.aiLog("Calling AiClassifier.classifyText()", { debug: true });
|
||||||
const result = await AiClassifier.classifyText(text, criterion);
|
const result = await AiClassifier.classifyText(text, criterion);
|
||||||
logger.aiLog("classify() returned", {debug: true}, result);
|
logger.aiLog("classify() returned", { debug: true }, result);
|
||||||
return { match: result };
|
return { match: result };
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
logger.aiLog("Error in classify()", {level: 'error'}, err);
|
|
||||||
// rethrow so the caller sees the failure
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
} else if (msg?.type === "sortana:clearCacheForDisplayed") {
|
|
||||||
try {
|
|
||||||
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
|
||||||
const tabId = tabs[0]?.id;
|
|
||||||
const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : [];
|
|
||||||
const ids = msgs.map(m => m.id);
|
|
||||||
await clearCacheForMessages(ids);
|
|
||||||
} catch (e) {
|
|
||||||
logger.aiLog("failed to clear cache from message script", { level: 'error' }, e);
|
|
||||||
}
|
|
||||||
} else if (msg?.type === "sortana:getReasons") {
|
|
||||||
try {
|
|
||||||
const id = msg.id;
|
|
||||||
const hdr = await messenger.messages.get(id);
|
|
||||||
const subject = hdr?.subject || "";
|
|
||||||
if (!aiRules.length) {
|
|
||||||
const { aiRules: stored } = await storage.local.get("aiRules");
|
|
||||||
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 });
|
|
||||||
const rule = { criterion: r.criterion, actions };
|
|
||||||
if (r.stopProcessing) rule.stopProcessing = true;
|
|
||||||
return rule;
|
|
||||||
}) : [];
|
|
||||||
}
|
}
|
||||||
const reasons = [];
|
catch (err) {
|
||||||
for (const rule of aiRules) {
|
logger.aiLog("Error in classify()", { level: 'error' }, err);
|
||||||
const key = await AiClassifier.buildCacheKey(id, rule.criterion);
|
// rethrow so the caller sees the failure
|
||||||
const reason = AiClassifier.getReason(key);
|
throw err;
|
||||||
if (reason) {
|
}
|
||||||
reasons.push({ criterion: rule.criterion, reason });
|
} else if (msg?.type === "sortana:clearCacheForDisplayed") {
|
||||||
|
try {
|
||||||
|
const msgs = await browser.messageDisplay.getDisplayedMessages();
|
||||||
|
const ids = msgs.map(m => m.id);
|
||||||
|
await clearCacheForMessages(ids);
|
||||||
|
} catch (e) {
|
||||||
|
logger.aiLog("failed to clear cache from message script", { level: 'error' }, e);
|
||||||
|
}
|
||||||
|
} else if (msg?.type === "sortana:getReasons") {
|
||||||
|
try {
|
||||||
|
const id = msg.id;
|
||||||
|
const hdr = await messenger.messages.get(id);
|
||||||
|
const subject = hdr?.subject || "";
|
||||||
|
if (!aiRules.length) {
|
||||||
|
const { aiRules: stored } = await storage.local.get("aiRules");
|
||||||
|
aiRules = normalizeRules(stored);
|
||||||
}
|
}
|
||||||
}
|
const reasons = [];
|
||||||
return { subject, reasons };
|
for (const rule of aiRules) {
|
||||||
} catch (e) {
|
const key = await AiClassifier.buildCacheKey(id, rule.criterion);
|
||||||
logger.aiLog("failed to collect reasons", { level: 'error' }, e);
|
const reason = AiClassifier.getReason(key);
|
||||||
return { subject: '', reasons: [] };
|
if (reason) {
|
||||||
}
|
reasons.push({ criterion: rule.criterion, reason });
|
||||||
} else if (msg?.type === "sortana:getDetails") {
|
}
|
||||||
try {
|
|
||||||
const id = msg.id;
|
|
||||||
const hdr = await messenger.messages.get(id);
|
|
||||||
const subject = hdr?.subject || "";
|
|
||||||
if (!aiRules.length) {
|
|
||||||
const { aiRules: stored } = await storage.local.get("aiRules");
|
|
||||||
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 });
|
|
||||||
const rule = { criterion: r.criterion, actions };
|
|
||||||
if (r.stopProcessing) rule.stopProcessing = true;
|
|
||||||
return rule;
|
|
||||||
}) : [];
|
|
||||||
}
|
|
||||||
const results = [];
|
|
||||||
for (const rule of aiRules) {
|
|
||||||
const key = await AiClassifier.buildCacheKey(id, rule.criterion);
|
|
||||||
const matched = AiClassifier.getCachedResult(key);
|
|
||||||
const reason = AiClassifier.getReason(key);
|
|
||||||
if (matched !== null || reason) {
|
|
||||||
results.push({ criterion: rule.criterion, matched, reason });
|
|
||||||
}
|
}
|
||||||
|
return { subject, reasons };
|
||||||
|
} catch (e) {
|
||||||
|
logger.aiLog("failed to collect reasons", { level: 'error' }, e);
|
||||||
|
return { subject: '', reasons: [] };
|
||||||
}
|
}
|
||||||
return { subject, results };
|
} else if (msg?.type === "sortana:getDetails") {
|
||||||
} catch (e) {
|
try {
|
||||||
logger.aiLog("failed to collect details", { level: 'error' }, e);
|
const id = msg.id;
|
||||||
return { subject: '', results: [] };
|
const hdr = await messenger.messages.get(id);
|
||||||
}
|
const subject = hdr?.subject || "";
|
||||||
} else if (msg?.type === "sortana:clearCacheForMessage") {
|
if (!aiRules.length) {
|
||||||
try {
|
const { aiRules: stored } = await storage.local.get("aiRules");
|
||||||
await clearCacheForMessages([msg.id]);
|
aiRules = normalizeRules(stored);
|
||||||
return { ok: true };
|
}
|
||||||
} catch (e) {
|
const results = [];
|
||||||
logger.aiLog("failed to clear cache for message", { level: 'error' }, e);
|
for (const rule of aiRules) {
|
||||||
return { ok: false };
|
const key = await AiClassifier.buildCacheKey(id, rule.criterion);
|
||||||
}
|
const matched = AiClassifier.getCachedResult(key);
|
||||||
} else if (msg?.type === "sortana:getQueueCount") {
|
const reason = AiClassifier.getReason(key);
|
||||||
return { count: queuedCount + (processing ? 1 : 0) };
|
if (matched !== null || reason) {
|
||||||
} else if (msg?.type === "sortana:getTiming") {
|
results.push({ criterion: rule.criterion, matched, reason });
|
||||||
const t = timingStats;
|
}
|
||||||
const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0;
|
}
|
||||||
return {
|
return { subject, results };
|
||||||
count: queuedCount + (processing ? 1 : 0),
|
} catch (e) {
|
||||||
current: currentStart ? Date.now() - currentStart : -1,
|
logger.aiLog("failed to collect details", { level: 'error' }, e);
|
||||||
last: t.last,
|
return { subject: '', results: [] };
|
||||||
runs: t.count,
|
}
|
||||||
average: t.mean,
|
} else if (msg?.type === "sortana:getDisplayedMessages") {
|
||||||
total: t.total,
|
try {
|
||||||
stddev: std
|
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
|
||||||
};
|
const messages = await browser.messageDisplay.getDisplayedMessages(tab?.id);
|
||||||
} else {
|
const ids = messages.map(hdr => hdr.id);
|
||||||
logger.aiLog("Unknown message type, ignoring", {level: 'warn'}, msg?.type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Automatically classify new messages
|
return { ids };
|
||||||
if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) {
|
} catch (e) {
|
||||||
messenger.messages.onNewMailReceived.addListener(async (folder, messages) => {
|
logger.aiLog("failed to get displayed messages", { level: 'error' }, e);
|
||||||
logger.aiLog("onNewMailReceived", {debug: true}, messages);
|
return { messages: [] };
|
||||||
const ids = (messages?.messages || messages || []).map(m => m.id ?? m);
|
}
|
||||||
await applyAiRules(ids);
|
} else if (msg?.type === "sortana:clearCacheForMessage") {
|
||||||
|
try {
|
||||||
|
await clearCacheForMessages([msg.id]);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
logger.aiLog("failed to clear cache for message", { level: 'error' }, e);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
} else if (msg?.type === "sortana:getQueueCount") {
|
||||||
|
return { count: queuedCount + (processing ? 1 : 0) };
|
||||||
|
} else if (msg?.type === "sortana:getTiming") {
|
||||||
|
const t = timingStats;
|
||||||
|
const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0;
|
||||||
|
return {
|
||||||
|
count: queuedCount + (processing ? 1 : 0),
|
||||||
|
current: currentStart ? Date.now() - currentStart : -1,
|
||||||
|
last: t.last,
|
||||||
|
runs: t.count,
|
||||||
|
average: t.mean,
|
||||||
|
total: t.total,
|
||||||
|
stddev: std
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.aiLog("Unknown message type, ignoring", { level: 'warn' }, msg?.type);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
logger.aiLog("messenger.messages API unavailable, skipping new mail listener", { level: 'warn' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Catch any unhandled rejections
|
// Automatically classify new messages
|
||||||
window.addEventListener("unhandledrejection", ev => {
|
if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) {
|
||||||
logger.aiLog("Unhandled promise rejection", {level: 'error'}, ev.reason);
|
messenger.messages.onNewMailReceived.addListener(async (folder, messages) => {
|
||||||
});
|
logger.aiLog("onNewMailReceived", { debug: true }, messages);
|
||||||
|
const ids = (messages?.messages || messages || []).map(m => m.id ?? m);
|
||||||
|
await applyAiRules(ids);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.aiLog("messenger.messages API unavailable, skipping new mail listener", { level: 'warn' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch any unhandled rejections
|
||||||
|
window.addEventListener("unhandledrejection", ev => {
|
||||||
|
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 }) => {
|
browser.runtime.onInstalled.addListener(async ({ reason }) => {
|
||||||
if (reason === "install") {
|
if (reason === "install") {
|
||||||
|
|
|
@ -15,6 +15,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script src="details.js"></script>
|
<script type="module" src="details.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
53
details.js
|
@ -1,23 +1,51 @@
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
const aiLog = (await import(browser.runtime.getURL("logger.js"))).aiLog;
|
||||||
const params = new URLSearchParams(location.search);
|
const storage = (globalThis.messenger ?? browser).storage;
|
||||||
let id = parseInt(params.get('mid'), 10);
|
const { detectSystemTheme } = await import(browser.runtime.getURL('modules/themeUtils.js'));
|
||||||
|
const { theme } = await storage.local.get('theme');
|
||||||
|
const mode = (theme || 'auto') === 'auto'
|
||||||
|
? await detectSystemTheme()
|
||||||
|
: theme;
|
||||||
|
document.documentElement.dataset.theme = mode;
|
||||||
|
|
||||||
if (!id) {
|
const qMid = parseInt(new URLSearchParams(location.search).get("mid"), 10);
|
||||||
try {
|
if (!isNaN(qMid)) {
|
||||||
|
loadMessage(qMid);
|
||||||
|
} else {
|
||||||
|
const { ids } = await browser.runtime.sendMessage({
|
||||||
|
type: "sortana:getDisplayedMessages",
|
||||||
|
});
|
||||||
|
if (ids && ids[0]) {
|
||||||
|
loadMessage(ids[0]);
|
||||||
|
} else {
|
||||||
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
||||||
const tabId = tabs[0]?.id;
|
const tabId = tabs[0]?.id;
|
||||||
const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : [];
|
const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : [];
|
||||||
id = msgs[0]?.id;
|
let id = msgs[0]?.id;
|
||||||
} catch (e) {
|
if (id) {
|
||||||
console.error('failed to determine message id', e);
|
loadMessage(id);
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
aiLog("Details popup: no displayed message found");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!id) return;
|
}
|
||||||
|
|
||||||
|
async function loadMessage(id) {
|
||||||
|
const storage = (globalThis.messenger ?? browser).storage;
|
||||||
|
const logMod = await import(browser.runtime.getURL('logger.js'));
|
||||||
|
const { debugLogging } = await storage.local.get('debugLogging');
|
||||||
|
logMod.setDebug(debugLogging === true);
|
||||||
|
const log = logMod.aiLog;
|
||||||
|
|
||||||
|
log('details page loaded', { debug: true });
|
||||||
try {
|
try {
|
||||||
|
log('requesting message details', {}, id);
|
||||||
const { subject, results } = await browser.runtime.sendMessage({ type: 'sortana:getDetails', id });
|
const { subject, results } = await browser.runtime.sendMessage({ type: 'sortana:getDetails', id });
|
||||||
|
log('received details', { debug: true }, { subject, results });
|
||||||
document.getElementById('subject').textContent = subject;
|
document.getElementById('subject').textContent = subject;
|
||||||
const container = document.getElementById('rules');
|
const container = document.getElementById('rules');
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
|
log('rendering rule result', { debug: true }, r);
|
||||||
const article = document.createElement('article');
|
const article = document.createElement('article');
|
||||||
const color = r.matched === true ? 'is-success' : 'is-danger';
|
const color = r.matched === true ? 'is-success' : 'is-danger';
|
||||||
article.className = `message ${color} mb-4`;
|
article.className = `message ${color} mb-4`;
|
||||||
|
@ -37,10 +65,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
container.appendChild(article);
|
container.appendChild(article);
|
||||||
}
|
}
|
||||||
document.getElementById('clear').addEventListener('click', async () => {
|
document.getElementById('clear').addEventListener('click', async () => {
|
||||||
|
log('clearing cache for message', {}, id);
|
||||||
await browser.runtime.sendMessage({ type: 'sortana:clearCacheForMessage', id });
|
await browser.runtime.sendMessage({ type: 'sortana:clearCacheForMessage', id });
|
||||||
window.close();
|
window.close();
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('failed to load details', e);
|
log('failed to load details', { level: 'error' }, e);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "Sortana",
|
"name": "Sortana",
|
||||||
"version": "2.0.0",
|
"version": "2.2.0",
|
||||||
"default_locale": "en-US",
|
"default_locale": "en-US",
|
||||||
"applications": {
|
"applications": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
"id": "ai-filter@jordanwages",
|
"id": "ai-filter@jordanwages",
|
||||||
"strict_min_version": "128.0",
|
"strict_min_version": "128.0",
|
||||||
"strict_max_version": "*"
|
"strict_max_version": "140.*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"icons": {
|
"icons": {
|
||||||
|
@ -22,7 +22,7 @@
|
||||||
"default_icon": "resources/img/logo32.png"
|
"default_icon": "resources/img/logo32.png"
|
||||||
},
|
},
|
||||||
"message_display_action": {
|
"message_display_action": {
|
||||||
"default_icon": "resources/img/brain.png",
|
"default_icon": "resources/img/logo.png",
|
||||||
"default_title": "Details",
|
"default_title": "Details",
|
||||||
"default_label": "Details",
|
"default_label": "Details",
|
||||||
"default_popup": "details.html"
|
"default_popup": "details.html"
|
||||||
|
@ -32,14 +32,18 @@
|
||||||
"page": "options/options.html",
|
"page": "options/options.html",
|
||||||
"open_in_tab": true
|
"open_in_tab": true
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"messagesRead",
|
"messagesRead",
|
||||||
"messagesMove",
|
"messagesMove",
|
||||||
"messagesUpdate",
|
"messagesUpdate",
|
||||||
"messagesTagsList",
|
"messagesTagsList",
|
||||||
"accountsRead",
|
"accountsRead",
|
||||||
"menus",
|
"menus",
|
||||||
"scripting"
|
"notifications",
|
||||||
]
|
"scripting",
|
||||||
|
"tabs",
|
||||||
|
"theme",
|
||||||
|
"compose"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
import { aiLog, setDebug } from "../logger.js";
|
import { aiLog, setDebug } from "../logger.js";
|
||||||
|
import { DEFAULT_AI_PARAMS } from "./defaultParams.js";
|
||||||
|
|
||||||
const storage = (globalThis.messenger ?? globalThis.browser).storage;
|
const storage = (globalThis.messenger ?? globalThis.browser).storage;
|
||||||
|
|
||||||
|
@ -33,19 +34,7 @@ let gCustomTemplate = "";
|
||||||
let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT;
|
let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT;
|
||||||
let gTemplateText = "";
|
let gTemplateText = "";
|
||||||
|
|
||||||
let gAiParams = {
|
let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS);
|
||||||
max_tokens: 4096,
|
|
||||||
temperature: 0.6,
|
|
||||||
top_p: 0.95,
|
|
||||||
seed: -1,
|
|
||||||
repetition_penalty: 1.0,
|
|
||||||
top_k: 20,
|
|
||||||
min_p: 0,
|
|
||||||
presence_penalty: 0,
|
|
||||||
frequency_penalty: 0,
|
|
||||||
typical_p: 1,
|
|
||||||
tfs: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
let gCache = new Map();
|
let gCache = new Map();
|
||||||
let gCacheLoaded = false;
|
let gCacheLoaded = false;
|
||||||
|
@ -72,10 +61,6 @@ async function sha256Hex(str) {
|
||||||
return sha256HexSync(str);
|
return sha256HexSync(str);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCacheKeySync(id, criterion) {
|
|
||||||
return sha256HexSync(`${id}|${criterion}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveHeaderId(id) {
|
async function resolveHeaderId(id) {
|
||||||
if (typeof id === "number" && typeof messenger?.messages?.get === "function") {
|
if (typeof id === "number" && typeof messenger?.messages?.get === "function") {
|
||||||
try {
|
try {
|
||||||
|
@ -93,7 +78,7 @@ async function resolveHeaderId(id) {
|
||||||
async function buildCacheKey(id, criterion) {
|
async function buildCacheKey(id, criterion) {
|
||||||
const resolvedId = await resolveHeaderId(id);
|
const resolvedId = await resolveHeaderId(id);
|
||||||
if (Services) {
|
if (Services) {
|
||||||
return buildCacheKeySync(resolvedId, criterion);
|
return sha256HexSync(`${resolvedId}|${criterion}`);
|
||||||
}
|
}
|
||||||
return sha256Hex(`${resolvedId}|${criterion}`);
|
return sha256Hex(`${resolvedId}|${criterion}`);
|
||||||
}
|
}
|
||||||
|
@ -133,16 +118,6 @@ async function loadCache() {
|
||||||
gCacheLoaded = true;
|
gCacheLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCacheSync() {
|
|
||||||
if (!gCacheLoaded) {
|
|
||||||
if (!Services?.tm?.spinEventLoopUntil) {
|
|
||||||
throw new Error("loadCacheSync requires Services");
|
|
||||||
}
|
|
||||||
let done = false;
|
|
||||||
loadCache().finally(() => { done = true; });
|
|
||||||
Services.tm.spinEventLoopUntil(() => done);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCache(updatedKey, updatedValue) {
|
async function saveCache(updatedKey, updatedValue) {
|
||||||
if (typeof updatedKey !== "undefined") {
|
if (typeof updatedKey !== "undefined") {
|
||||||
|
@ -233,11 +208,7 @@ function buildPrompt(body, criterion) {
|
||||||
|
|
||||||
function getCachedResult(cacheKey) {
|
function getCachedResult(cacheKey) {
|
||||||
if (!gCacheLoaded) {
|
if (!gCacheLoaded) {
|
||||||
if (Services?.tm?.spinEventLoopUntil) {
|
return null;
|
||||||
loadCacheSync();
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (cacheKey && gCache.has(cacheKey)) {
|
if (cacheKey && gCache.has(cacheKey)) {
|
||||||
aiLog(`[AiClassifier] Cache hit for key: ${cacheKey}`, {debug: true});
|
aiLog(`[AiClassifier] Cache hit for key: ${cacheKey}`, {debug: true});
|
||||||
|
@ -249,11 +220,7 @@ function getCachedResult(cacheKey) {
|
||||||
|
|
||||||
function getReason(cacheKey) {
|
function getReason(cacheKey) {
|
||||||
if (!gCacheLoaded) {
|
if (!gCacheLoaded) {
|
||||||
if (Services?.tm?.spinEventLoopUntil) {
|
return null;
|
||||||
loadCacheSync();
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const entry = gCache.get(cacheKey);
|
const entry = gCache.get(cacheKey);
|
||||||
return cacheKey && entry ? entry.reason || null : null;
|
return cacheKey && entry ? entry.reason || null : null;
|
||||||
|
@ -330,48 +297,6 @@ async function getCacheSize() {
|
||||||
return gCache.size;
|
return gCache.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyTextSync(text, criterion, cacheKey = null) {
|
|
||||||
if (!Services?.tm?.spinEventLoopUntil) {
|
|
||||||
throw new Error("classifyTextSync requires Services");
|
|
||||||
}
|
|
||||||
const cached = getCachedResult(cacheKey);
|
|
||||||
if (cached !== null) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = buildPayload(text, criterion);
|
|
||||||
|
|
||||||
aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true});
|
|
||||||
|
|
||||||
let result;
|
|
||||||
let done = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(gEndpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: payload,
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const json = await response.json();
|
|
||||||
aiLog(`[AiClassifier] Received response:`, {debug: true}, json);
|
|
||||||
result = parseMatch(json);
|
|
||||||
cacheEntry(cacheKey, result.matched, result.reason);
|
|
||||||
result = result.matched;
|
|
||||||
} else {
|
|
||||||
aiLog(`HTTP status ${response.status}`, {level: 'warn'});
|
|
||||||
result = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
aiLog(`HTTP request failed`, {level: 'error'}, e);
|
|
||||||
result = false;
|
|
||||||
} finally {
|
|
||||||
done = true;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
Services.tm.spinEventLoopUntil(() => done);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function classifyText(text, criterion, cacheKey = null) {
|
async function classifyText(text, criterion, cacheKey = null) {
|
||||||
if (!gCacheLoaded) {
|
if (!gCacheLoaded) {
|
||||||
|
@ -383,6 +308,11 @@ async function classifyText(text, criterion, cacheKey = null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = buildPayload(text, criterion);
|
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] Sending classification request to ${gEndpoint}`, {debug: true});
|
||||||
aiLog(`[AiClassifier] Classification request payload:`, { debug: true }, payload);
|
aiLog(`[AiClassifier] Classification request payload:`, { debug: true }, payload);
|
||||||
|
@ -414,4 +344,4 @@ async function init() {
|
||||||
await loadCache();
|
await loadCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
export { classifyText, classifyTextSync, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, buildCacheKeySync, getCacheSize, init };
|
export { classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init };
|
||||||
|
|
16
modules/defaultParams.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
export const DEFAULT_AI_PARAMS = {
|
||||||
|
max_tokens: 4096,
|
||||||
|
temperature: 0.6,
|
||||||
|
top_p: 0.95,
|
||||||
|
seed: -1,
|
||||||
|
repetition_penalty: 1.0,
|
||||||
|
top_k: 20,
|
||||||
|
min_p: 0,
|
||||||
|
presence_penalty: 0,
|
||||||
|
frequency_penalty: 0,
|
||||||
|
typical_p: 1,
|
||||||
|
tfs: 1,
|
||||||
|
};
|
||||||
|
|
20
modules/themeUtils.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
export async function detectSystemTheme() {
|
||||||
|
try {
|
||||||
|
const t = await browser.theme.getCurrent();
|
||||||
|
const scheme = t?.properties?.color_scheme;
|
||||||
|
if (scheme === 'dark' || scheme === 'light') {
|
||||||
|
return scheme;
|
||||||
|
}
|
||||||
|
const color = t?.colors?.frame || t?.colors?.toolbar;
|
||||||
|
if (color && /^#/.test(color)) {
|
||||||
|
const r = parseInt(color.slice(1, 3), 16);
|
||||||
|
const g = parseInt(color.slice(3, 5), 16);
|
||||||
|
const b = parseInt(color.slice(5, 7), 16);
|
||||||
|
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
return lum < 0.5 ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return 'light';
|
||||||
|
}
|
45
options/dataTransfer.js
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
"use strict";
|
||||||
|
const storage = (globalThis.messenger ?? browser).storage;
|
||||||
|
const KEY_GROUPS = {
|
||||||
|
settings: [
|
||||||
|
'endpoint',
|
||||||
|
'templateName',
|
||||||
|
'customTemplate',
|
||||||
|
'customSystemPrompt',
|
||||||
|
'aiParams',
|
||||||
|
'debugLogging',
|
||||||
|
'htmlToMarkdown',
|
||||||
|
'stripUrlParams',
|
||||||
|
'altTextImages',
|
||||||
|
'collapseWhitespace'
|
||||||
|
],
|
||||||
|
rules: ['aiRules'],
|
||||||
|
cache: ['aiCache']
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectKeys(categories = Object.keys(KEY_GROUPS)) {
|
||||||
|
return categories.flatMap(cat => KEY_GROUPS[cat] || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportData(categories) {
|
||||||
|
const data = await storage.local.get(collectKeys(categories));
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'sortana-export.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importData(file, categories) {
|
||||||
|
const text = await file.text();
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
const data = {};
|
||||||
|
for (const key of collectKeys(categories)) {
|
||||||
|
if (key in parsed) data[key] = parsed[key];
|
||||||
|
}
|
||||||
|
await storage.local.set(data);
|
||||||
|
}
|
|
@ -31,31 +31,43 @@
|
||||||
.tag {
|
.tag {
|
||||||
--bulma-tag-h: 318;
|
--bulma-tag-h: 318;
|
||||||
}
|
}
|
||||||
|
#diff-display {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container" id="options-container">
|
<div class="container" id="options-container">
|
||||||
<figure class="has-text-centered mb-4">
|
<figure class="has-text-centered mb-4">
|
||||||
<img src="../resources/img/full-logo.png" alt="AI Filter Logo" style="max-height:40px;">
|
<img data-icon="full-logo" src="../resources/img/full-logo.png" alt="AI Filter Logo" style="max-height:40px;">
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
<div class="level mb-4">
|
<div class="level mb-4">
|
||||||
<div class="level-left">
|
<div class="level-left">
|
||||||
<div class="tabs" id="main-tabs">
|
<div class="tabs" id="main-tabs">
|
||||||
<ul>
|
<ul>
|
||||||
<li class="is-active" data-tab="settings"><a>Settings</a></li>
|
<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>Rules</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>Maintenance</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="level-right">
|
<div class="level-right">
|
||||||
<button class="button is-primary" id="save" disabled>Save</button>
|
<button class="button is-primary" id="save" disabled>
|
||||||
|
<span class="icon is-small"><img data-icon="flag" data-size="16" src="../resources/img/flag-light-16.png" alt=""></span>
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="settings-tab" class="tab-content">
|
<div id="settings-tab" class="tab-content">
|
||||||
|
<h2 class="title is-4">
|
||||||
|
<span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span>
|
||||||
|
<span>Settings</span>
|
||||||
|
</h2>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="endpoint">Endpoint</label>
|
<label class="label" for="endpoint">Endpoint</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
|
@ -87,9 +99,28 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="theme-select">Theme</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select">
|
||||||
|
<select id="theme-select">
|
||||||
|
<option value="auto">Match Thunderbird</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button class="button is-danger" id="reset-system">Reset to default</button>
|
<button class="button is-danger" id="reset-system">
|
||||||
<button class="button" id="toggle-advanced" type="button">Advanced</button>
|
<span class="icon is-small"><img data-icon="reply" data-size="16" src="../resources/img/reply-light-16.png" alt=""></span>
|
||||||
|
<span>Reset to default</span>
|
||||||
|
</button>
|
||||||
|
<button class="button" id="toggle-advanced" type="button">
|
||||||
|
<span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span>
|
||||||
|
<span>Advanced</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="advanced-options" class="mt-4 is-hidden">
|
<div id="advanced-options" class="mt-4 is-hidden">
|
||||||
|
@ -98,6 +129,36 @@
|
||||||
<input type="checkbox" id="debug-logging"> Enable debug logging
|
<input type="checkbox" id="debug-logging"> Enable debug logging
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" id="html-to-markdown"> Convert HTML body to Markdown
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" id="strip-url-params"> Remove URL tracking parameters
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" id="alt-text-images"> Replace images with alt text
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<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">
|
<div class="field">
|
||||||
<label class="label" for="max_tokens">Max tokens</label>
|
<label class="label" for="max_tokens">Max tokens</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
|
@ -168,13 +229,19 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="rules-tab" class="tab-content is-hidden">
|
<div id="rules-tab" class="tab-content is-hidden">
|
||||||
<h2 class="title is-4">Classification Rules</h2>
|
<h2 class="title is-4">
|
||||||
|
<span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span>
|
||||||
|
<span>Classification Rules</span>
|
||||||
|
</h2>
|
||||||
<div id="rules-container"></div>
|
<div id="rules-container"></div>
|
||||||
<button class="button is-link" id="add-rule" type="button">Add Rule</button>
|
<button class="button is-link" id="add-rule" type="button">Add Rule</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="maintenance-tab" class="tab-content is-hidden">
|
<div id="maintenance-tab" class="tab-content is-hidden">
|
||||||
<h2 class="title is-4">Maintenance</h2>
|
<h2 class="title is-4">
|
||||||
|
<span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span>
|
||||||
|
<span>Maintenance</span>
|
||||||
|
</h2>
|
||||||
<table class="table is-fullwidth">
|
<table class="table is-fullwidth">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><th>Rule count</th><td id="rule-count"></td></tr>
|
<tr><th>Rule count</th><td id="rule-count"></td></tr>
|
||||||
|
@ -184,12 +251,53 @@
|
||||||
<tr><th>Last run time</th><td id="last-time">--:--:--</td></tr>
|
<tr><th>Last run time</th><td id="last-time">--:--:--</td></tr>
|
||||||
<tr><th>Average run time</th><td id="average-time">--:--:--</td></tr>
|
<tr><th>Average run time</th><td id="average-time">--:--:--</td></tr>
|
||||||
<tr><th>Total run time</th><td id="total-time">--:--:--</td></tr>
|
<tr><th>Total run time</th><td id="total-time">--:--:--</td></tr>
|
||||||
|
<tr><th>Messages per hour</th><td id="per-hour">0</td></tr>
|
||||||
|
<tr><th>Messages per day</th><td id="per-day">0</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<button class="button is-danger" id="clear-cache" type="button">Clear Cache</button>
|
<button class="button is-danger" id="clear-cache" type="button">
|
||||||
|
<span class="icon is-small"><img data-icon="trash" data-size="16" src="../resources/img/trash-light-16.png" alt=""></span>
|
||||||
|
<span>Clear Cache</span>
|
||||||
|
</button>
|
||||||
|
<div class="field mt-4">
|
||||||
|
<label class="label">Data categories</label>
|
||||||
|
<div class="control">
|
||||||
|
<label class="checkbox mr-3"><input class="transfer-category" type="checkbox" value="settings" checked> Settings</label>
|
||||||
|
<label class="checkbox mr-3"><input class="transfer-category" type="checkbox" value="rules" checked> Rules</label>
|
||||||
|
<label class="checkbox"><input class="transfer-category" type="checkbox" value="cache" checked> Cache</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field is-grouped mt-4">
|
||||||
|
<p class="control">
|
||||||
|
<button class="button" id="export-data" type="button">
|
||||||
|
<span class="icon is-small"><img data-icon="download" data-size="16" src="../resources/img/download-light-16.png" alt=""></span>
|
||||||
|
<span>Export Data</span>
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p class="control">
|
||||||
|
<button class="button" id="import-data" type="button">
|
||||||
|
<span class="icon is-small"><img data-icon="upload" data-size="16" src="../resources/img/upload-light-16.png" alt=""></span>
|
||||||
|
<span>Import Data</span>
|
||||||
|
</button>
|
||||||
|
<input class="is-hidden" type="file" id="import-file" accept="application/json">
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<script src="../resources/js/diff_match_patch_uncompressed.js"></script>
|
||||||
<script src="options.js"></script>
|
<script src="options.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -2,6 +2,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const storage = (globalThis.messenger ?? browser).storage;
|
const storage = (globalThis.messenger ?? browser).storage;
|
||||||
const logger = await import(browser.runtime.getURL('logger.js'));
|
const logger = await import(browser.runtime.getURL('logger.js'));
|
||||||
const AiClassifier = await import(browser.runtime.getURL('modules/AiClassifier.js'));
|
const AiClassifier = await import(browser.runtime.getURL('modules/AiClassifier.js'));
|
||||||
|
const dataTransfer = await import(browser.runtime.getURL('options/dataTransfer.js'));
|
||||||
|
const { detectSystemTheme } = await import(browser.runtime.getURL('modules/themeUtils.js'));
|
||||||
|
const { DEFAULT_AI_PARAMS } = await import(browser.runtime.getURL('modules/defaultParams.js'));
|
||||||
const defaults = await storage.local.get([
|
const defaults = await storage.local.get([
|
||||||
'endpoint',
|
'endpoint',
|
||||||
'templateName',
|
'templateName',
|
||||||
|
@ -9,8 +12,18 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
'customSystemPrompt',
|
'customSystemPrompt',
|
||||||
'aiParams',
|
'aiParams',
|
||||||
'debugLogging',
|
'debugLogging',
|
||||||
|
'htmlToMarkdown',
|
||||||
|
'stripUrlParams',
|
||||||
|
'altTextImages',
|
||||||
|
'collapseWhitespace',
|
||||||
|
'tokenReduction',
|
||||||
'aiRules',
|
'aiRules',
|
||||||
'aiCache'
|
'aiCache',
|
||||||
|
'theme',
|
||||||
|
'showDebugTab',
|
||||||
|
'lastPayload',
|
||||||
|
'lastFullText',
|
||||||
|
'lastPromptText'
|
||||||
]);
|
]);
|
||||||
const tabButtons = document.querySelectorAll('#main-tabs li');
|
const tabButtons = document.querySelectorAll('#main-tabs li');
|
||||||
const tabs = document.querySelectorAll('.tab-content');
|
const tabs = document.querySelectorAll('.tab-content');
|
||||||
|
@ -32,20 +45,61 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
document.addEventListener('input', markDirty, true);
|
document.addEventListener('input', markDirty, true);
|
||||||
document.addEventListener('change', markDirty, true);
|
document.addEventListener('change', markDirty, true);
|
||||||
logger.setDebug(defaults.debugLogging === true);
|
logger.setDebug(defaults.debugLogging === true);
|
||||||
const DEFAULT_AI_PARAMS = {
|
|
||||||
max_tokens: 4096,
|
const themeSelect = document.getElementById('theme-select');
|
||||||
temperature: 0.6,
|
themeSelect.value = defaults.theme || 'auto';
|
||||||
top_p: 0.95,
|
|
||||||
seed: -1,
|
function updateIcons(theme) {
|
||||||
repetition_penalty: 1.0,
|
document.querySelectorAll('img[data-icon]').forEach(img => {
|
||||||
top_k: 20,
|
const name = img.dataset.icon;
|
||||||
min_p: 0,
|
const size = img.dataset.size || 16;
|
||||||
presence_penalty: 0,
|
if (name === 'full-logo') {
|
||||||
frequency_penalty: 0,
|
img.src = `../resources/img/full-logo${theme === 'dark' ? '-white' : ''}.png`;
|
||||||
typical_p: 1,
|
} else {
|
||||||
tfs: 1
|
img.src = `../resources/img/${name}-${theme}-${size}.png`;
|
||||||
};
|
}
|
||||||
document.getElementById('endpoint').value = defaults.endpoint || 'http://127.0.0.1:5000/v1/classify';
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyTheme(setting) {
|
||||||
|
const mode = setting === 'auto' ? await detectSystemTheme() : setting;
|
||||||
|
document.documentElement.dataset.theme = mode;
|
||||||
|
updateIcons(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
document.getElementById('endpoint').value = defaults.endpoint || 'http://127.0.0.1:5000/v1/completions';
|
||||||
|
|
||||||
const templates = {
|
const templates = {
|
||||||
openai: browser.i18n.getMessage('template.openai'),
|
openai: browser.i18n.getMessage('template.openai'),
|
||||||
|
@ -81,6 +135,32 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const debugToggle = document.getElementById('debug-logging');
|
const debugToggle = document.getElementById('debug-logging');
|
||||||
debugToggle.checked = defaults.debugLogging === true;
|
debugToggle.checked = defaults.debugLogging === true;
|
||||||
|
|
||||||
|
const htmlToggle = document.getElementById('html-to-markdown');
|
||||||
|
htmlToggle.checked = defaults.htmlToMarkdown === true;
|
||||||
|
|
||||||
|
const stripUrlToggle = document.getElementById('strip-url-params');
|
||||||
|
stripUrlToggle.checked = defaults.stripUrlParams === true;
|
||||||
|
|
||||||
|
const altTextToggle = document.getElementById('alt-text-images');
|
||||||
|
altTextToggle.checked = defaults.altTextImages === true;
|
||||||
|
|
||||||
|
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 || {});
|
const aiParams = Object.assign({}, DEFAULT_AI_PARAMS, defaults.aiParams || {});
|
||||||
for (const [key, val] of Object.entries(aiParams)) {
|
for (const [key, val] of Object.entries(aiParams)) {
|
||||||
const el = document.getElementById(key);
|
const el = document.getElementById(key);
|
||||||
|
@ -89,6 +169,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
let tagList = [];
|
let tagList = [];
|
||||||
let folderList = [];
|
let folderList = [];
|
||||||
|
let accountList = [];
|
||||||
try {
|
try {
|
||||||
tagList = await messenger.messages.tags.list();
|
tagList = await messenger.messages.tags.list();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -96,6 +177,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const accounts = await messenger.accounts.list(true);
|
const accounts = await messenger.accounts.list(true);
|
||||||
|
accountList = accounts.map(a => ({ id: a.id, name: a.name }));
|
||||||
const collect = (f, prefix='') => {
|
const collect = (f, prefix='') => {
|
||||||
folderList.push({ id: f.id ?? f.path, name: prefix + f.name });
|
folderList.push({ id: f.id ?? f.path, name: prefix + f.name });
|
||||||
(f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/'));
|
(f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/'));
|
||||||
|
@ -117,6 +199,19 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const rulesContainer = document.getElementById('rules-container');
|
const rulesContainer = document.getElementById('rules-container');
|
||||||
const addRuleBtn = document.getElementById('add-rule');
|
const addRuleBtn = document.getElementById('add-rule');
|
||||||
|
|
||||||
|
const ruleCountEl = document.getElementById('rule-count');
|
||||||
|
const cacheCountEl = document.getElementById('cache-count');
|
||||||
|
const queueCountEl = document.getElementById('queue-count');
|
||||||
|
const currentTimeEl = document.getElementById('current-time');
|
||||||
|
const lastTimeEl = document.getElementById('last-time');
|
||||||
|
const averageTimeEl = document.getElementById('average-time');
|
||||||
|
const totalTimeEl = document.getElementById('total-time');
|
||||||
|
const perHourEl = document.getElementById('per-hour');
|
||||||
|
const perDayEl = document.getElementById('per-day');
|
||||||
|
let timingLogged = false;
|
||||||
|
ruleCountEl.textContent = (defaults.aiRules || []).length;
|
||||||
|
cacheCountEl.textContent = defaults.aiCache ? Object.keys(defaults.aiCache).length : 0;
|
||||||
|
|
||||||
function createActionRow(action = {type: 'tag'}) {
|
function createActionRow(action = {type: 'tag'}) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'action-row field is-grouped mb-2';
|
row.className = 'action-row field is-grouped mb-2';
|
||||||
|
@ -124,7 +219,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const typeWrapper = document.createElement('div');
|
const typeWrapper = document.createElement('div');
|
||||||
typeWrapper.className = 'select is-small mr-2';
|
typeWrapper.className = 'select is-small mr-2';
|
||||||
const typeSelect = document.createElement('select');
|
const typeSelect = document.createElement('select');
|
||||||
['tag','move','junk'].forEach(t => {
|
['tag','move','copy','junk','read','flag','delete','archive','forward','reply'].forEach(t => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = t;
|
opt.value = t;
|
||||||
opt.textContent = t;
|
opt.textContent = t;
|
||||||
|
@ -151,7 +246,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
sel.value = action.tagKey || '';
|
sel.value = action.tagKey || '';
|
||||||
wrap.appendChild(sel);
|
wrap.appendChild(sel);
|
||||||
paramSpan.appendChild(wrap);
|
paramSpan.appendChild(wrap);
|
||||||
} else if (typeSelect.value === 'move') {
|
} else if (typeSelect.value === 'move' || typeSelect.value === 'copy') {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'select is-small';
|
wrap.className = 'select is-small';
|
||||||
const sel = document.createElement('select');
|
const sel = document.createElement('select');
|
||||||
|
@ -162,7 +257,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
opt.textContent = f.name;
|
opt.textContent = f.name;
|
||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
}
|
}
|
||||||
sel.value = action.folder || '';
|
sel.value = action.folder || action.copyTarget || '';
|
||||||
wrap.appendChild(sel);
|
wrap.appendChild(sel);
|
||||||
paramSpan.appendChild(wrap);
|
paramSpan.appendChild(wrap);
|
||||||
} else if (typeSelect.value === 'junk') {
|
} else if (typeSelect.value === 'junk') {
|
||||||
|
@ -175,6 +270,45 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
sel.value = String(action.junk ?? true);
|
sel.value = String(action.junk ?? true);
|
||||||
wrap.appendChild(sel);
|
wrap.appendChild(sel);
|
||||||
paramSpan.appendChild(wrap);
|
paramSpan.appendChild(wrap);
|
||||||
|
} else if (typeSelect.value === 'read') {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'select is-small';
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.className = 'read-select';
|
||||||
|
sel.appendChild(new Option('mark read','true'));
|
||||||
|
sel.appendChild(new Option('mark unread','false'));
|
||||||
|
sel.value = String(action.read ?? true);
|
||||||
|
wrap.appendChild(sel);
|
||||||
|
paramSpan.appendChild(wrap);
|
||||||
|
} else if (typeSelect.value === 'flag') {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'select is-small';
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.className = 'flag-select';
|
||||||
|
sel.appendChild(new Option('flag','true'));
|
||||||
|
sel.appendChild(new Option('unflag','false'));
|
||||||
|
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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,7 +328,41 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
return row;
|
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 = []) {
|
function renderRules(rules = []) {
|
||||||
|
ruleCountEl.textContent = rules.length;
|
||||||
rulesContainer.innerHTML = '';
|
rulesContainer.innerHTML = '';
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
const article = document.createElement('article');
|
const article = document.createElement('article');
|
||||||
|
@ -226,13 +394,67 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'message-header';
|
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');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.className = 'delete';
|
delBtn.type = 'button';
|
||||||
delBtn.setAttribute('aria-label', 'delete');
|
delBtn.className = 'button is-small is-danger is-light rule-delete';
|
||||||
delBtn.addEventListener('click', () => article.remove());
|
const delIcon = document.createElement('img');
|
||||||
header.appendChild(delBtn);
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnWrap.appendChild(toggleBtn);
|
||||||
|
btnWrap.appendChild(delBtn);
|
||||||
|
header.appendChild(btnWrap);
|
||||||
|
|
||||||
|
updateToggle();
|
||||||
|
|
||||||
const actionsContainer = document.createElement('div');
|
const actionsContainer = document.createElement('div');
|
||||||
actionsContainer.className = 'rule-actions mb-2';
|
actionsContainer.className = 'rule-actions mb-2';
|
||||||
|
@ -247,20 +469,139 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
addAction.className = 'button is-small mb-2';
|
addAction.className = 'button is-small mb-2';
|
||||||
addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow()));
|
addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow()));
|
||||||
|
|
||||||
const stopLabel = document.createElement('label');
|
const stopLabel = document.createElement('label');
|
||||||
stopLabel.className = 'checkbox mt-2';
|
stopLabel.className = 'checkbox mt-2 is-hidden';
|
||||||
const stopCheck = document.createElement('input');
|
const stopCheck = document.createElement('input');
|
||||||
stopCheck.type = 'checkbox';
|
stopCheck.type = 'checkbox';
|
||||||
stopCheck.className = 'stop-processing';
|
stopCheck.className = 'stop-processing';
|
||||||
stopCheck.checked = rule.stopProcessing === true;
|
stopCheck.checked = rule.stopProcessing === true;
|
||||||
stopLabel.appendChild(stopCheck);
|
stopLabel.appendChild(stopCheck);
|
||||||
stopLabel.append(' Stop after match');
|
stopLabel.append(' Stop after match');
|
||||||
|
|
||||||
|
const unreadLabel = document.createElement('label');
|
||||||
|
unreadLabel.className = 'checkbox mt-2 ml-4 is-hidden';
|
||||||
|
const unreadCheck = document.createElement('input');
|
||||||
|
unreadCheck.type = 'checkbox';
|
||||||
|
unreadCheck.className = 'unread-only';
|
||||||
|
unreadCheck.checked = rule.unreadOnly === true;
|
||||||
|
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');
|
const body = document.createElement('div');
|
||||||
body.className = 'message-body';
|
body.className = 'message-body';
|
||||||
body.appendChild(actionsContainer);
|
body.appendChild(actionsContainer);
|
||||||
body.appendChild(addAction);
|
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(stopLabel);
|
||||||
|
body.appendChild(unreadLabel);
|
||||||
|
body.appendChild(ageBox);
|
||||||
|
body.appendChild(acctBox);
|
||||||
|
body.appendChild(folderBox);
|
||||||
|
|
||||||
article.appendChild(header);
|
article.appendChild(header);
|
||||||
article.appendChild(body);
|
article.appendChild(body);
|
||||||
|
@ -280,38 +621,65 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
if (type === 'move') {
|
if (type === 'move') {
|
||||||
return { type, folder: row.querySelector('.folder-select').value };
|
return { type, folder: row.querySelector('.folder-select').value };
|
||||||
}
|
}
|
||||||
|
if (type === 'copy') {
|
||||||
|
return { type, copyTarget: row.querySelector('.folder-select').value };
|
||||||
|
}
|
||||||
if (type === 'junk') {
|
if (type === 'junk') {
|
||||||
return { type, junk: row.querySelector('.junk-select').value === 'true' };
|
return { type, junk: row.querySelector('.junk-select').value === 'true' };
|
||||||
}
|
}
|
||||||
|
if (type === 'read') {
|
||||||
|
return { type, read: row.querySelector('.read-select').value === 'true' };
|
||||||
|
}
|
||||||
|
if (type === 'flag') {
|
||||||
|
return { type, flagged: row.querySelector('.flag-select').value === 'true' };
|
||||||
|
}
|
||||||
|
if (type === 'delete' || type === 'archive') {
|
||||||
|
return { type };
|
||||||
|
}
|
||||||
return { type };
|
return { type };
|
||||||
});
|
});
|
||||||
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
|
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
|
||||||
return { criterion, actions, stopProcessing };
|
const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
|
||||||
|
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: [], stopProcessing: false });
|
data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false, enabled: true, accounts: [], folders: [] });
|
||||||
renderRules(data);
|
renderRules(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
renderRules((defaults.aiRules || []).map(r => {
|
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 = [];
|
const actions = [];
|
||||||
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
|
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
|
||||||
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
|
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
|
||||||
|
if (r.copyTarget || r.copyTo) actions.push({ type: 'copy', copyTarget: r.copyTarget || r.copyTo });
|
||||||
const rule = { criterion: r.criterion, actions };
|
const rule = { criterion: r.criterion, actions };
|
||||||
if (r.stopProcessing) rule.stopProcessing = true;
|
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;
|
return rule;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const ruleCountEl = document.getElementById('rule-count');
|
|
||||||
const cacheCountEl = document.getElementById('cache-count');
|
|
||||||
const queueCountEl = document.getElementById('queue-count');
|
|
||||||
const currentTimeEl = document.getElementById('current-time');
|
|
||||||
const lastTimeEl = document.getElementById('last-time');
|
|
||||||
const averageTimeEl = document.getElementById('average-time');
|
|
||||||
const totalTimeEl = document.getElementById('total-time');
|
|
||||||
let timingLogged = false;
|
|
||||||
ruleCountEl.textContent = (defaults.aiRules || []).length;
|
|
||||||
cacheCountEl.textContent = defaults.aiCache ? Object.keys(defaults.aiCache).length : 0;
|
|
||||||
|
|
||||||
function format(ms) {
|
function format(ms) {
|
||||||
if (ms < 0) return '--:--:--';
|
if (ms < 0) return '--:--:--';
|
||||||
|
@ -352,6 +720,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
averageTimeEl.textContent = stats.runs > 0 ? format(stats.average) : '--:--:--';
|
averageTimeEl.textContent = stats.runs > 0 ? format(stats.average) : '--:--:--';
|
||||||
totalTimeEl.textContent = format(stats.total);
|
totalTimeEl.textContent = format(stats.total);
|
||||||
|
const perHour = stats.average > 0 ? Math.round(3600000 / stats.average) : 0;
|
||||||
|
const perDay = stats.average > 0 ? Math.round(86400000 / stats.average) : 0;
|
||||||
|
perHourEl.textContent = perHour;
|
||||||
|
perDayEl.textContent = perDay;
|
||||||
if (!timingLogged) {
|
if (!timingLogged) {
|
||||||
logger.aiLog('retrieved timing stats', {debug: true});
|
logger.aiLog('retrieved timing stats', {debug: true});
|
||||||
timingLogged = true;
|
timingLogged = true;
|
||||||
|
@ -362,14 +734,48 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
lastTimeEl.textContent = '--:--:--';
|
lastTimeEl.textContent = '--:--:--';
|
||||||
averageTimeEl.textContent = '--:--:--';
|
averageTimeEl.textContent = '--:--:--';
|
||||||
totalTimeEl.textContent = '--:--:--';
|
totalTimeEl.textContent = '--:--:--';
|
||||||
|
perHourEl.textContent = '0';
|
||||||
|
perDayEl.textContent = '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
ruleCountEl.textContent = document.querySelectorAll('#rules-container .rule').length;
|
|
||||||
try {
|
try {
|
||||||
cacheCountEl.textContent = await AiClassifier.getCacheSize();
|
const { aiCache } = await storage.local.get('aiCache');
|
||||||
|
cacheCountEl.textContent = aiCache ? Object.keys(aiCache).length : 0;
|
||||||
} catch {
|
} catch {
|
||||||
cacheCountEl.textContent = '?';
|
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();
|
refreshMaintenance();
|
||||||
|
@ -379,6 +785,24 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
await AiClassifier.clearCache();
|
await AiClassifier.clearCache();
|
||||||
cacheCountEl.textContent = '0';
|
cacheCountEl.textContent = '0';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function selectedCategories() {
|
||||||
|
return [...document.querySelectorAll('.transfer-category:checked')].map(el => el.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('export-data').addEventListener('click', () => {
|
||||||
|
dataTransfer.exportData(selectedCategories());
|
||||||
|
});
|
||||||
|
|
||||||
|
const importInput = document.getElementById('import-file');
|
||||||
|
document.getElementById('import-data').addEventListener('click', () => importInput.click());
|
||||||
|
importInput.addEventListener('change', async () => {
|
||||||
|
if (importInput.files.length) {
|
||||||
|
await dataTransfer.importData(importInput.files[0], selectedCategories());
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
document.getElementById('save').addEventListener('click', async () => {
|
document.getElementById('save').addEventListener('click', async () => {
|
||||||
|
@ -395,6 +819,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const debugLogging = debugToggle.checked;
|
const debugLogging = debugToggle.checked;
|
||||||
|
const htmlToMarkdown = htmlToggle.checked;
|
||||||
const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
|
const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
|
||||||
const criterion = ruleEl.querySelector('.criterion').value;
|
const criterion = ruleEl.querySelector('.criterion').value;
|
||||||
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
|
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
|
||||||
|
@ -405,15 +830,50 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
if (type === 'move') {
|
if (type === 'move') {
|
||||||
return { type, folder: row.querySelector('.folder-select').value };
|
return { type, folder: row.querySelector('.folder-select').value };
|
||||||
}
|
}
|
||||||
|
if (type === 'copy') {
|
||||||
|
return { type, copyTarget: row.querySelector('.folder-select').value };
|
||||||
|
}
|
||||||
if (type === 'junk') {
|
if (type === 'junk') {
|
||||||
return { type, junk: row.querySelector('.junk-select').value === 'true' };
|
return { type, junk: row.querySelector('.junk-select').value === 'true' };
|
||||||
}
|
}
|
||||||
|
if (type === 'read') {
|
||||||
|
return { type, read: row.querySelector('.read-select').value === 'true' };
|
||||||
|
}
|
||||||
|
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 };
|
return { type };
|
||||||
});
|
});
|
||||||
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
|
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
|
||||||
return { criterion, actions, stopProcessing };
|
const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
|
||||||
|
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);
|
}).filter(r => r.criterion);
|
||||||
await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, aiRules: rules });
|
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, tokenReduction, aiRules: rules, theme, showDebugTab });
|
||||||
|
await applyTheme(theme);
|
||||||
try {
|
try {
|
||||||
await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
|
await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
|
||||||
logger.setDebug(debugLogging);
|
logger.setDebug(debugLogging);
|
||||||
|
|
BIN
resources/img/average-dark-16.png
Normal file
After Width: | Height: | Size: 427 B |
BIN
resources/img/average-dark-32.png
Normal file
After Width: | Height: | Size: 791 B |
BIN
resources/img/average-dark-64.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
resources/img/average-light-16.png
Normal file
After Width: | Height: | Size: 416 B |
BIN
resources/img/average-light-32.png
Normal file
After Width: | Height: | Size: 794 B |
BIN
resources/img/average-light-64.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 3.5 KiB |
BIN
resources/img/check-dark-16.png
Normal file
After Width: | Height: | Size: 307 B |
BIN
resources/img/check-dark-32.png
Normal file
After Width: | Height: | Size: 449 B |
BIN
resources/img/check-dark-64.png
Normal file
After Width: | Height: | Size: 940 B |
BIN
resources/img/check-light-16.png
Normal file
After Width: | Height: | Size: 300 B |
BIN
resources/img/check-light-32.png
Normal file
After Width: | Height: | Size: 450 B |
BIN
resources/img/check-light-64.png
Normal file
After Width: | Height: | Size: 927 B |
BIN
resources/img/circle-dark-16.png
Normal file
After Width: | Height: | Size: 389 B |
BIN
resources/img/circle-dark-32.png
Normal file
After Width: | Height: | Size: 722 B |
BIN
resources/img/circle-dark-64.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
resources/img/circle-light-16.png
Normal file
After Width: | Height: | Size: 389 B |
BIN
resources/img/circle-light-32.png
Normal file
After Width: | Height: | Size: 724 B |
BIN
resources/img/circle-light-64.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
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/circledots-dark-16.png
Normal file
After Width: | Height: | Size: 396 B |
BIN
resources/img/circledots-dark-32.png
Normal file
After Width: | Height: | Size: 773 B |
BIN
resources/img/circledots-dark-64.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
resources/img/circledots-light-16.png
Normal file
After Width: | Height: | Size: 394 B |
BIN
resources/img/circledots-light-32.png
Normal file
After Width: | Height: | Size: 773 B |
BIN
resources/img/circledots-light-64.png
Normal file
After Width: | Height: | Size: 1.6 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/clipboarddata-dark-16.png
Normal file
After Width: | Height: | Size: 320 B |
BIN
resources/img/clipboarddata-dark-32.png
Normal file
After Width: | Height: | Size: 537 B |
BIN
resources/img/clipboarddata-dark-64.png
Normal file
After Width: | Height: | Size: 969 B |
BIN
resources/img/clipboarddata-light-16.png
Normal file
After Width: | Height: | Size: 314 B |
BIN
resources/img/clipboarddata-light-32.png
Normal file
After Width: | Height: | Size: 543 B |
BIN
resources/img/clipboarddata-light-64.png
Normal file
After Width: | Height: | Size: 989 B |
Before Width: | Height: | Size: 3.5 KiB |
BIN
resources/img/download-dark-16.png
Normal file
After Width: | Height: | Size: 340 B |
BIN
resources/img/download-dark-32.png
Normal file
After Width: | Height: | Size: 556 B |
BIN
resources/img/download-dark-64.png
Normal file
After Width: | Height: | Size: 1 KiB |
BIN
resources/img/download-light-16.png
Normal file
After Width: | Height: | Size: 345 B |
BIN
resources/img/download-light-32.png
Normal file
After Width: | Height: | Size: 571 B |
BIN
resources/img/download-light-64.png
Normal file
After Width: | Height: | Size: 1,006 B |
Before Width: | Height: | Size: 2.9 KiB |
BIN
resources/img/eye-dark-16.png
Normal file
After Width: | Height: | Size: 374 B |
BIN
resources/img/eye-dark-32.png
Normal file
After Width: | Height: | Size: 729 B |
BIN
resources/img/eye-dark-64.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
resources/img/eye-light-16.png
Normal file
After Width: | Height: | Size: 371 B |
BIN
resources/img/eye-light-32.png
Normal file
After Width: | Height: | Size: 733 B |
BIN
resources/img/eye-light-64.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
resources/img/flag-dark-16.png
Normal file
After Width: | Height: | Size: 293 B |
BIN
resources/img/flag-dark-32.png
Normal file
After Width: | Height: | Size: 469 B |
BIN
resources/img/flag-dark-64.png
Normal file
After Width: | Height: | Size: 766 B |
BIN
resources/img/flag-light-16.png
Normal file
After Width: | Height: | Size: 300 B |
BIN
resources/img/flag-light-32.png
Normal file
After Width: | Height: | Size: 475 B |
BIN
resources/img/flag-light-64.png
Normal file
After Width: | Height: | Size: 800 B |
BIN
resources/img/gear-dark-16.png
Normal file
After Width: | Height: | Size: 484 B |
BIN
resources/img/gear-dark-32.png
Normal file
After Width: | Height: | Size: 1 KiB |
BIN
resources/img/gear-dark-64.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
resources/img/gear-light-16.png
Normal file
After Width: | Height: | Size: 462 B |
BIN
resources/img/gear-light-32.png
Normal file
After Width: | Height: | Size: 993 B |
BIN
resources/img/gear-light-64.png
Normal file
After Width: | Height: | Size: 2.1 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 |
BIN
resources/img/reply-dark-16.png
Normal file
After Width: | Height: | Size: 280 B |
BIN
resources/img/reply-dark-32.png
Normal file
After Width: | Height: | Size: 414 B |
BIN
resources/img/reply-dark-64.png
Normal file
After Width: | Height: | Size: 749 B |
BIN
resources/img/reply-light-16.png
Normal file
After Width: | Height: | Size: 289 B |
BIN
resources/img/reply-light-32.png
Normal file
After Width: | Height: | Size: 432 B |
BIN
resources/img/reply-light-64.png
Normal file
After Width: | Height: | Size: 750 B |
BIN
resources/img/settings-dark-16.png
Normal file
After Width: | Height: | Size: 418 B |
BIN
resources/img/settings-dark-32.png
Normal file
After Width: | Height: | Size: 792 B |
BIN
resources/img/settings-dark-64.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
resources/img/settings-light-16.png
Normal file
After Width: | Height: | Size: 421 B |