├── .dir-locals.el ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ └── prettier.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── Makefile ├── Pipfile ├── Pipfile.lock ├── README.md ├── SECURITY.md ├── TRANSLATING.md ├── _locales ├── bg │ └── messages.json ├── ca │ └── messages.json ├── cs │ └── messages.json ├── da │ └── messages.json ├── de │ └── messages.json ├── el │ └── messages.json ├── en │ └── messages.json ├── es │ └── messages.json ├── fi │ └── messages.json ├── fr │ └── messages.json ├── gl │ └── messages.json ├── he │ └── messages.json ├── hu │ └── messages.json ├── hy │ └── messages.json ├── it │ └── messages.json ├── ja │ └── messages.json ├── nb │ └── messages.json ├── nl │ └── messages.json ├── pl │ └── messages.json ├── pt │ └── messages.json ├── pt_BR │ └── messages.json ├── ro │ └── messages.json ├── ru │ └── messages.json ├── sl │ └── messages.json ├── sv │ └── messages.json ├── tr │ └── messages.json ├── zh │ └── messages.json └── zh_TW │ └── messages.json ├── background.js ├── beta-channel.json ├── contrib └── scheduling-functions │ ├── CertainDaysAndHours.txt │ ├── DaysBeforeNthWeekday.txt │ ├── FirstDaysOfEachMonth.txt │ ├── NextChol.txt │ └── README.md ├── crowdin.yml ├── dev ├── beta-channel-generator.py ├── compare-translations.py ├── fix_message.py ├── graphics │ ├── Green_arrow_right.svg.png │ ├── clock.jpg │ ├── logo-big.png │ ├── logo-big.xcf │ ├── sample_cancel_button.jpg │ └── stamp.png ├── include-manifest ├── restore-translation-string.py └── scrape-pontoon-translation.py ├── experiments ├── hdrViewImplementation.js ├── hdrViewSchema.json ├── legacyColumnImplementation.js ├── legacyColumnSchema.json ├── quitter.json ├── quitterImplementation.js ├── sl3u.js └── sl3u.json ├── manifest.json ├── package.json ├── test ├── adjustdaterestrictionstests.js ├── data │ ├── 01-plaintext.eml │ ├── 01-plaintext.eml.out │ ├── 05-HTML+embedded-image.eml │ ├── 05-HTML+embedded-image.eml.out │ ├── 21-plaintext.eml │ └── 21-plaintext.eml.out ├── datetime_handling_tests.js ├── diff_match_patch.js ├── formatrecurtests.js ├── headers_to_dom_element_tests.js ├── mimetests.js ├── miscellaneoustests.js ├── nextrecurtests.js ├── parserecurtests.js └── run_tests.js ├── ui ├── browserActionPopup.html ├── browserActionPopup.js ├── icons │ ├── icon.png │ ├── social-preview.png │ └── social-preview.xcf ├── log.html ├── log.js ├── msgDisplayPopup.html ├── msgDisplayPopup.js ├── notification.html ├── notification.js ├── options.html ├── options.js ├── popup.html ├── popup.js ├── telemetry.html └── telemetry.js └── utils ├── defaultPrefs.json ├── i18n.js ├── ical.js ├── static.js ├── sugar-custom.js ├── tools.js └── varNameValidator.js /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ((js-mode . ((js-indent-level . 2) 2 | (js-switch-indent-offset . 2) 3 | (prettier-js-command . "npx") 4 | (prettier-js-args . ("prettier")) 5 | (mode . prettier-js))) 6 | (js-json-mode . ((js-indent-level . 2) 7 | (js-switch-indent-offset . 2) 8 | (prettier-js-command . "npx") 9 | (prettier-js-args . ("prettier")) 10 | (mode . prettier-js)))) 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | liberapay: jik 4 | patreon: jikseclecticofferings 5 | custom: https://extended-thunder.github.io/send-later/#support-send-later 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | body: 4 | - type: checkboxes 5 | id: terms 6 | attributes: 7 | label: Prerequisites 8 | description: Please ensure that the following prerequisites are complete 9 | before filing a bug report. Check off each one to confirm. 10 | options: 11 | - label: I am reporting a problem, not asking a question (for questions, go 12 | to [Discussions](https://github.com/Extended-Thunder/send-later/discussions)). 13 | required: true 14 | - label: I have consulted the 15 | [user guide](https://extended-thunder.github.io/send-later/). 16 | required: true 17 | - label: I have consulted the 18 | [release notes](https://extended-thunder.github.io/send-later/release-notes.html). 19 | required: true 20 | - label: I have confirmed that I'm running the current version and the 21 | problem persists there (note the version number in fine print next to 22 | the add-on name [on addons.thunderbird.net](https://addons.thunderbird.net/thunderbird/addon/send-later-3/) 23 | and compare it to the version number listed in the add-on details 24 | opened from the Add-ons page in Thunderbird; if you've got an old 25 | version, click the gear icon and select "Check for Updates"). 26 | required: true 27 | - label: If I'm having an issue with date-parsing confusion, I've read the 28 | [section about that in the user guide](https://extended-thunder.github.io/send-later/#date-format-confusion-in-the-scheduler-pop-up) 29 | and I still think this is something different. 30 | required: true 31 | - label: If I'm having an issue with messages not being delivered, I've 32 | followed the [troubleshooting steps in the user guide](https://extended-thunder.github.io/send-later/#messages-dont-send-or-send-multiple-times) 33 | and it's still an issue. 34 | required: true 35 | - type: textarea 36 | id: description 37 | attributes: 38 | label: Describe the bug 39 | description: A clear and concise description of the bug 40 | validations: 41 | required: true 42 | - type: textarea 43 | id: reproduce 44 | attributes: 45 | label: How to reproduce 46 | description: Steps to reproduce the behavior 47 | placeholder: | 48 | Steps to reproduce the behavior: 49 | 1. Go to '...' 50 | 2. Click on '....' 51 | 3. Scroll down to '....' 52 | 4. See error 53 | - type: textarea 54 | id: expected 55 | attributes: 56 | label: Expected behavior 57 | description: A clear and concise description of what you expected to happen 58 | - type: textarea 59 | id: context 60 | attributes: 61 | label: Additional context 62 | description: Any other relevant context about the problem 63 | - type: input 64 | id: os 65 | attributes: 66 | label: Operating System 67 | placeholder: Windows, macOS, Linux, etc. 68 | - type: input 69 | id: os-version 70 | attributes: 71 | label: Operating System Version 72 | - type: input 73 | id: thunderbird-version 74 | attributes: 75 | label: Thunderbird version 76 | validations: 77 | required: true 78 | - type: textarea 79 | id: send-later-string 80 | attributes: 81 | label: Send Later start-up string 82 | description: Open the error console with Ctrl-Shift-j or Command-Shift-j and 83 | enter "locale" in the "filter" box at the top to search for the start-up 84 | string, since it always includes that word. If it's not there, go to the 85 | Add-ons page and disable and re-enable Send Later and the message should 86 | appear in the error console. 87 | - type: markdown 88 | attributes: 89 | value: | 90 | #### Screenshots 91 | If applicable, add screenshots to help explain your problem in comments after submitting the bug report. 92 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '18 14 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v3.5.3 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v2 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v2 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v2 68 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Prettier PR checker 2 | 3 | # This action works with pull requests and pushes 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | prettier: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Prettify code 19 | uses: creyD/prettier_action@v4.3 20 | with: 21 | # This part is also where you can pass other options, for example: 22 | dry: True 23 | prettier_options: --check . 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.tmp 2 | .max_gecko_version 3 | dist 4 | node_modules 5 | package-lock.json 6 | send_later.xpi 7 | send_later_atn.xpi 8 | send_later_beta.xpi 9 | release 10 | secrets.json 11 | tmp 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.html 2 | *.md 3 | *.yml 4 | Pipfile.lock 5 | _locales 6 | .github 7 | contrib 8 | package.json 9 | test/diff_match_patch.js 10 | utils/varNameValidator.js 11 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { "printWidth": 79 } 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | VERSION=$(shell jq -r .version < manifest.json) 3 | MEDITOR=$(if $(EDITOR),$(EDITOR),$(VISUAL)) 4 | # https://stackoverflow.com/a/17055840/937306 5 | define n 6 | 7 | 8 | endef 9 | RELEASE_PATTERNS=$(subst $n, ,$(file Versions: 0.3, 0.4, ....lots of versions..., 119.*, * 13 | # We can't currently use "*" as max version, so we need the second-to-last 14 | # version number on the line. 15 | # We cache this so we don't have to fetch it every time. 16 | VERSIONS_URL=https://addons.thunderbird.net/en-US/thunderbird/pages/appversions/ 17 | MAX_GECKO_VERSION=$(shell \ 18 | if (($$(date +%s) - $$(stat -c %Y .max_gecko_version) < 60 * 60 * 24)); then \ 19 | cat .max_gecko_version; \ 20 | else \ 21 | curl --silent "$(VERSIONS_URL)" | \ 22 | sed -n -e 's/.*Versions: .*, \([^,]*\),.*/\1/p' | \ 23 | head -1 >| .max_gecko_version; \ 24 | if [ ! -s .max_gecko_version ]; then \ 25 | open "$(VERSIONS_URL)"; \ 26 | echo -n "Enter max gecko version: " 1>&2; \ 27 | read max_gecko_version; \ 28 | echo $$max_gecko_version >| .max_gecko_version; \ 29 | fi; \ 30 | cat .max_gecko_version; \ 31 | fi) 32 | BETA_JSON=beta-channel.json 33 | 34 | .PHONY: release 35 | 36 | all: send_later.xpi send_later_beta.xpi send_later_atn.xpi 37 | clean:: ; -rm -f send_later.xpi 38 | 39 | send_later.xpi: dev/include-manifest $(RELEASE_FILES) 40 | rm -f "$@" "$@".tmp 41 | zip -q -r "$@".tmp $(RELEASE_FILES) 42 | mv "$@".tmp "$@" 43 | 44 | # Makes sure release tag is on origin. 45 | # Makes sure our checked-out files are what's supposed to be in the release. 46 | # Creates XPI files. 47 | # Prompts for title and for user to edit release notes. 48 | # Creates GitHub release as prerelease, uploading release notes and XPI files. 49 | # Regenerates and commits beta-channel.json. 50 | # Rebuilds github pages to deploy new beta XPI download link in user guide. 51 | # Pushes new beta-channel.json to GitHub. 52 | 53 | TITLE_FILE=/tmp/send-later-github-release-title.$(VERSION) 54 | NOTES_FILE=/tmp/send-later-github-release-notes.$(VERSION).md 55 | FULL_NOTES_FILE=/tmp/send-later-github-release-notes-full.$(VERSION).md 56 | 57 | ASSETS=dist/send_later_$(VERSION).xpi dist/send_later_$(VERSION)_beta.xpi 58 | 59 | gh_release: $(ASSETS) 60 | # No releases that aren't pushed to main 61 | git merge-base --is-ancestor v$(VERSION) origin/main 62 | # No changes to release files in our checked-out tree 63 | @set -e; for file in $(RELEASE_FILES); do \ 64 | git diff --exit-code v$(VERSION) $$file; \ 65 | done 66 | @title=$$(cat $(TITLE_FILE) 2>/dev/null); \ 67 | [ -n "$$title" ] || title=$(VERSION); \ 68 | echo -n "Enter release title [$$title]: "; \ 69 | read newtitle; if [ -n "$$newtitle" ]; then title="$$newtitle"; fi; \ 70 | echo "$$title" > $(TITLE_FILE) 71 | touch $(NOTES_FILE) 72 | $(MEDITOR) $(NOTES_FILE) 73 | cat $(NOTES_FILE) >| $(FULL_NOTES_FILE) 74 | echo -e '\n\n**Note:** Downloading and installing `send_later_beta.xpi` will subscribe you to future beta releases. We love beta-testers! The advantage is that if there'\''s a bug that affects your workflow you'\''ll help find it and get it fixed quickly. The disadvantage is that bugs are a bit more likely in beta releases. You can unsubscribe from beta releases at any time by downloading and installing `send_later.xpi` or installing from [addons.thunderbird.net](https://addons.thunderbird.net/thunderbird/addon/send-later-3/).' >> $(FULL_NOTES_FILE) 75 | @echo -n "Hit Enter to proceed or ctrl-C to abort: "; read response 76 | # N.B. This uses $$(cat instead of $(file to read the title file because the 77 | # contents of the file change while the rule is running but Make expands the 78 | # rule earlier. 79 | @set -x; gh release create v$(VERSION) --prerelease \ 80 | --title "$$(cat $(TITLE_FILE))" --notes-file $(FULL_NOTES_FILE) \ 81 | --verify-tag $(ASSETS) 82 | set -e; \ 83 | gh_user=$$(yq -r '."github.com".user' ~/.config/gh/hosts.yml); \ 84 | pat=$$(jq -r .pages_pat secrets.json); \ 85 | curl -u $$gh_user:$$pat -X POST https://api.github.com/repos/Extended-Thunder/send-later/pages/builds 86 | $(MAKE) update_beta_channel 87 | .PHONY: gh_release 88 | 89 | dist/send_later_$(VERSION)%xpi: send_later%xpi 90 | mkdir -p dist 91 | cp $^ $@.tmp 92 | mv $@.tmp $@ 93 | 94 | update_beta_channel: 95 | pipenv run dev/beta-channel-generator.py $(BETA_JSON) > $(BETA_JSON).tmp 96 | mv $(BETA_JSON).tmp $(BETA_JSON) 97 | @if git diff --exit-code $(BETA_JSON); then \ 98 | echo "$(BETA_JSON) unchanged" 1>&2; exit 1; \ 99 | else \ 100 | git commit -m "Update beta channel from GitHub" $(BETA_JSON); \ 101 | git push; \ 102 | fi 103 | .PHONY: update_beta_channel 104 | 105 | beta: send_later_beta.xpi 106 | clean:: ; rm -fr send_later_beta.xpi tmp 107 | .PHONY: beta 108 | 109 | send_later_beta.xpi: send_later.xpi 110 | rm -rf tmp $@.tmp 111 | mkdir tmp 112 | cd tmp && unzip -q ../send_later.xpi 113 | cd tmp && jq '.applications.gecko.update_url="https://raw.githubusercontent.com/Extended-Thunder/send-later/main/beta-channel.json"' manifest.json > manifest.json.tmp 114 | cd tmp && mv manifest.json.tmp manifest.json 115 | cd tmp && zip -q -r ../$@.tmp * 116 | mv $@.tmp $@ 117 | 118 | clean:: ; rm -fr send_later_beta.xpi tmp 119 | 120 | send_later_atn.xpi: send_later.xpi 121 | [ -n "$(MAX_GECKO_VERSION)" ] 122 | rm -rf $@.tmp tmp 123 | mkdir tmp 124 | cd tmp && unzip -q ../send_later.xpi 125 | cd tmp && jq '.applications.gecko.strict_max_version="$(MAX_GECKO_VERSION)"' manifest.json > manifest.json.tmp 126 | cd tmp && mv manifest.json.tmp manifest.json 127 | cd tmp && zip -q -r ../$@.tmp * 128 | mv $@.tmp $@ 129 | 130 | ## Requires the Node 'addons-linter' package is installed 131 | ## npm install -g addons-linter 132 | ## Note: this will produce a lot of "MANIFEST_PERMISSIONS" 133 | ## warnings because the addons-linter assumes vanilla firefox target. 134 | lint: 135 | addons-linter . 136 | 137 | unit_test: $(RELEASE_FILES) 138 | @node test/run_tests.js 2>&1 \ 139 | | sed -e '/^+ TEST'/s//"`printf '\033[32m+ TEST\033[0m'`"'/' \ 140 | | sed -e '/^- TEST'/s//"`printf '\033[31m- TEST\033[0m'`"'/' \ 141 | | sed -e 's/All \([0-9]*\) tests are passing!'/"`printf '\033[1m\033[32m'`"'All \1 tests are passing!'"`printf '\033[0m'`"/ \ 142 | | sed -e 's/\([0-9]*\/[0-9]*\) tests failed.'/"`printf '\033[1m\033[31m'`"'\1 tests failed.'"`printf '\033[0m'`"/ 143 | 144 | test: lint unit_test 145 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | bs4 = "*" 8 | requests = "*" 9 | markdown = "*" 10 | lxml = "*" 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Send Later Thunderbird extension 2 | ============================ 3 | 4 | ### Schedule email messages to be sent later 5 | 6 | Links: 7 | 8 | - [addons.thunderbird.net home page][atn] 9 | - [user guide][guide] 10 | - [support email][support] 11 | 12 | Maintainer: Jonathan Kamens__ 13 | Previous maintainer (thanks!): Jonathan Perry-Houts 14 | 15 | Copyright (c) 2010-2020,2024 Jonathan Kamens, 2020-2021 Jonathan 16 | Perry-Houts, 2022-2023 Extended Thunder Inc. 17 | 18 | You can [contribute][donate] to ongoing development and maintenance of 19 | this add-on. 20 | 21 | 22 | Released under the terms of the Mozilla Public License, v. 2.0. Full text of 23 | the MPL can be found in the file [LICENSE](LICENSE). 24 | 25 | [atn]: https://addons.thunderbird.net/thunderbird/addon/send-later-3/ 26 | [guide]: https://extended-thunder.github.io/send-later/ 27 | [support]: mailto:send-later-support@kamens.us 28 | [donate]: https://extended-thunder.github.io/send-later/#support-send-later 29 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 9.x.x | :white_check_mark: | 8 | | 8.x.x | Partial | 9 | | 7.x.x | Partial | 10 | | <= 6.x | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Send Later is, by its nature, not prone to 15 | sensitive security vulnerabilities. If you somehow 16 | feel you have encountered one, please let us know directly 17 | at [send-later-support@kamens..us](mailto:send-later-support@kamens.us). 18 | 19 | For all other bug reports, please use the regular 20 | [GitHub issues](https://github.com/Extended-Thunder/send-later/issues) page. 21 | -------------------------------------------------------------------------------- /TRANSLATING.md: -------------------------------------------------------------------------------- 1 | # Translating Send Later 2 | 3 | If you're reading this, then either you've offered to help translate Send Later, or you've stumbled across this file in the Send Later source tree and started reading it out of curiosity. Either way, welcome! 4 | 5 | Below, I explain how to be a Send Later translator and try to answer the questions you are likely to have about it. If you have any questions that aren't answered here, please don't hesitate to [email me](mailto:send-later-support@kamens.us)! 6 | 7 | I know that for many of my translators, English is not your first language, so I feel a bit rude writing to you in English, but of course my need to do that is why I need translators. :-/ Please bear with me. 8 | 9 | ## How much work is this going to be, really? 10 | 11 | I try to avoid changing locale strings if at all possible, and when I do it's not more than one or two at a time. The Send Later UI is generally pretty stable, and has been for quite a while. I intend to keep it that way unless there are pressing needs to update the text. So while you may need to put in several hours of effort to do a new translation from scratch, after that the work will be rather intermittent and not particularly time-consuming. Crowdin offers suggested translations which can save you a lot of time. 12 | 13 | ## Signing up 14 | 15 | I use [Crowdin](https://crwd.in/send-later) to manage translations of Send Later. Your first step in becoming a translator is to [register for a Crowdin account](https://crowdin.com/join) if you don't already have one. Once you've registered, you can either jump right in or review the [Interface overview](https://support.crowdin.com/for-volunteer-translators/) for more information about how to use Crowdin. 16 | 17 | Once you are registered, visit the [Send Later extension page](https://crwd.in/send-later) and click the "Join" button in the upper right corner of the page to join the project. Then you can drill down into individual translations and start working on them. If you are interested in creating a new translation that is not listed, please contact me and I'll add it. 18 | 19 | If you are updating a translation originally done by someone else, _please respect the approach of the previous translator,_ and make changes to existing translations only if they are actually wrong, not just because you would prefer to have worded a particular message a different way. It is not helpful to me if my translators engage in battles with each other. :-/ If the old translator is gone and no longer active and you believe strongly that a significant change in approach is needed, then please [email me](mailto:send-later-support@kamens.us) and we can talk about it. 20 | 21 | ## Determining how strings are used 22 | 23 | Over time I've tried to get better about putting explanations of what strings are for in the translation template, but there are unfortunately a bunch of legacy strings with no explanation. 24 | 25 | One way you can determine the context of a particular string you're translating is to guess where you think the string is probably used, and then try to use that bit of functionality in the add-on itself within Thunderbird, to see if you're right. 26 | 27 | You can also [search the source code](https://github.com/Extended-Thunder/send-later) for the string's identifier to see where it is used in the code. 28 | 29 | If all else fails, [ask me](mailto:send-later-support@kamens.us) for an explanation of any string context you can't figure out. After explaining it to you I'll add a proper explanation to the translation template to make things easier for future translators. 30 | 31 | ## Notes about how to translate things 32 | 33 | ### Substitution variables in strings 34 | 35 | Whenever you see "$1" or "$2" or "$N" (where "N" is a number), that indicates a slot where a value is inserted dynamically by the add-on. _You must preserve these sequences in your translated strings_ -- Even if you need to switch their order. It is okay to write strings with "$2" before "$1" if for example an adjective and noun need to change order. I don't think there is anywhere in the code that requires this, but it's worth pointing out as an implementation detail nonetheless. 36 | 37 | ### "accesskey" strings 38 | 39 | You will see a number of strings whose identifiers end with "accesskey", with corresponding strings whose identifiers end with "label". The "label" strings are button labels, and the "accesskey" strings encode the characters that users can type with "Alt" (or Alt+Shift or whatever) to activate those buttons from the keyboard. 40 | 41 | There are some rules you need to follow when translating accesskey strings: 42 | 43 | * All of the accesskeys in a particular window need to be case-insensitively different. That means, e.g., that you can't have both "C" and "c" as accesskeys in the same file. 44 | * It's preferable to use the first letter of a word as an accesskey, and it's also preferable to use the first significant word in the label as an accesskey (e.g., for "Put in Outbox", "O" would arguably be a better accesskey than "P"). Sometimes, depending on the labels of the various buttons in a file, these two goals conflict with each other. Use your best judgment. 45 | 46 | ### Template functions for dynamic function editor 47 | 48 | You will see three long strings that are blocks of source code, labeled as `EditorReadMeCode`, `_BusinessHoursCode`, and `DaysInARowCode`. These blocks of text are used to create the bundled sample functions that show up in the dynamic function editor. **You should translate the comments in these strings but not the variable names.** 49 | 50 | Oh, and contrary to what is written below, for these three specific strings you should use dumb, ugly, ASCII quotes, not pretty Unicode quotes! 51 | 52 | ### Quotation marks 53 | 54 | Please try to use the "pretty" quotation marks that are appropriate for your language whenever possible, rather than plaintext \" or \' characters. For example, “this” looks nice in English. If you aren't working on Mac OS so your keyboard isn't kind enough to type these for you automatically, then you can cut and paste them from elsewhere or launch a smart text editor like Microsoft Office or LibreOffice that has its own smart quotes implementation, type the phrase you want so that the editor substitutes the smart quotes, and then paste it into Crowdin. 55 | 56 | Rumor has it (I have not verified this) that on Mac OS you can type Opt+] to get a left single quote, Opt+Shift+] to get a right single quote (which is also a pretty apostrophe), Opt+\[ to get a left double quote, and Opt+Shift+\[ to get a right double quote. 57 | 58 | Similarly, rumor has it that you can type Alt+0145, Alt+0146, Alt+0147, and Alt+0148 to get these same four characters on Windows. 59 | 60 | Finally, on GNOME, you can type Ctrl-Shift-u 2018 Enter to get a left single quote, or replace the 2018 with 2019, 201C, or 201D for the other three. 61 | 62 | ## Final notes 63 | 64 | If you would like to be credited for your translation, please let me know how you would like to be credited -- name, company, email address, BabelZilla nickname, etc. 65 | 66 | Please [email me](mailto:send-later-support@kamens.us) if you have any questions. 67 | -------------------------------------------------------------------------------- /_locales/sl/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "recipients": { 3 | "message": "Prejemniki", 4 | "description": "Column header in message list in main window pop-up" 5 | }, 6 | "subject": { 7 | "message": "Zadeva", 8 | "description": "Column header in message list in main window pop-up" 9 | }, 10 | "folder": { 11 | "message": "Mapo", 12 | "description": "Column header in message list in main window pop-up" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /_locales/tr/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "clearDefaultsLabel": { 3 | "message": "Varsayılanları temizle", 4 | "description": "" 5 | }, 6 | "recurAnnuallyLabel": { 7 | "message": "yıllık", 8 | "description": "Appears next to recurLabel to indicate the frequency of recurrence when the multiple is 1, e.g., 'Recur daily' vs. 'Recur every 2 days' (for those, see the every_* strings)." 9 | }, 10 | "recurDailyLabel": { 11 | "message": "günlük", 12 | "description": "Appears next to recurLabel to indicate the frequency of recurrence when the multiple is 1, e.g., 'Recur daily' vs. 'Recur every 2 days' (for those, see the every_* strings)." 13 | }, 14 | "recurMinutelyLabel": { 15 | "message": "dakikalık", 16 | "description": "Appears next to recurLabel to indicate the frequency of recurrence when the multiple is 1, e.g., 'Recur daily' vs. 'Recur every 2 days' (for those, see the every_* strings)." 17 | }, 18 | "recurMonthlyLabel": { 19 | "message": "aylık", 20 | "description": "Appears next to recurLabel to indicate the frequency of recurrence when the multiple is 1, e.g., 'Recur daily' vs. 'Recur every 2 days' (for those, see the every_* strings)." 21 | }, 22 | "recurOnceLabel": { 23 | "message": "tek sefer", 24 | "description": "" 25 | }, 26 | "recurWeeklyLabel": { 27 | "message": "haftalık", 28 | "description": "Appears next to recurLabel to indicate the frequency of recurrence when the multiple is 1, e.g., 'Recur daily' vs. 'Recur every 2 days' (for those, see the every_* strings)." 29 | }, 30 | "saveDefaultsLabel": { 31 | "message": "Bu değerleri varsayılan olarak kaydet", 32 | "description": "" 33 | }, 34 | "sendBtwnLabel": { 35 | "message": "Arasında", 36 | "description": "" 37 | }, 38 | "sendSundayLabel": { 39 | "message": "Pazar", 40 | "description": "Appears in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. Separate from the corresponding everymonthly_day? string (where ? is 0 for Sunday and 6 for Saturday), which is used next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. These are separate strings because in some languages the weekday names are written differently in these two contexts." 41 | }, 42 | "sendMondayLabel": { 43 | "message": "Pazartesi", 44 | "description": "Appears in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. Separate from the corresponding everymonthly_day? string (where ? is 0 for Sunday and 6 for Saturday), which is used next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. These are separate strings because in some languages the weekday names are written differently in these two contexts." 45 | }, 46 | "sendTuesdayLabel": { 47 | "message": "Salı", 48 | "description": "Appears in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. Separate from the corresponding everymonthly_day? string (where ? is 0 for Sunday and 6 for Saturday), which is used next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. These are separate strings because in some languages the weekday names are written differently in these two contexts." 49 | }, 50 | "sendWednesdayLabel": { 51 | "message": "Çarşamba", 52 | "description": "Appears in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. Separate from the corresponding everymonthly_day? string (where ? is 0 for Sunday and 6 for Saturday), which is used next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. These are separate strings because in some languages the weekday names are written differently in these two contexts." 53 | }, 54 | "sendThursdayLabel": { 55 | "message": "Perşembe", 56 | "description": "Appears in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. Separate from the corresponding everymonthly_day? string (where ? is 0 for Sunday and 6 for Saturday), which is used next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. These are separate strings because in some languages the weekday names are written differently in these two contexts." 57 | }, 58 | "sendFridayLabel": { 59 | "message": "Cuma", 60 | "description": "Appears in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. Separate from the corresponding everymonthly_day? string (where ? is 0 for Sunday and 6 for Saturday), which is used next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. These are separate strings because in some languages the weekday names are written differently in these two contexts." 61 | }, 62 | "sendSaturdayLabel": { 63 | "message": "Cumartesi", 64 | "description": "Appears in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. Separate from the corresponding everymonthly_day? string (where ? is 0 for Sunday and 6 for Saturday), which is used next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. These are separate strings because in some languages the weekday names are written differently in these two contexts." 65 | }, 66 | "sendOnlyOnLabel": { 67 | "message": "Yalnızca", 68 | "description": "" 69 | }, 70 | "sendlater.prompt.calculate.label": { 71 | "message": "Hesapla", 72 | "description": "" 73 | }, 74 | "sendlater.prompt.every.label": { 75 | "message": "Her", 76 | "description": "" 77 | }, 78 | "sendlater.button1.label": { 79 | "message": "15 dakika sonra", 80 | "description": "" 81 | }, 82 | "sendlater.button2.label": { 83 | "message": "30 dakika sonra", 84 | "description": "" 85 | }, 86 | "sendlater.button3.label": { 87 | "message": "2 saat sonra", 88 | "description": "" 89 | }, 90 | "donatelink.value": { 91 | "message": "Bağış yapmak", 92 | "description": "" 93 | }, 94 | "windowtitle": { 95 | "message": "Send Later Dinamik Fonksiyon Düzenleyicisi", 96 | "description": "" 97 | }, 98 | "selectfunction": { 99 | "message": "İşlev seçin", 100 | "description": "" 101 | }, 102 | "selectfunctiontip": { 103 | "message": "Düzenlemek veya silmek için bir işlev seçin, veya yeni bir işlev oluşturmak için “(yeni işlev oluştur)”u seçin.", 104 | "description": "" 105 | }, 106 | "createnewfunction": { 107 | "message": "(yeni bir işlev oluşturun)", 108 | "description": "" 109 | }, 110 | "delete.label": { 111 | "message": "Sil", 112 | "description": "" 113 | }, 114 | "functionnameplaceholder": { 115 | "message": "işlev adı", 116 | "description": "" 117 | }, 118 | "inputtime": { 119 | "message": "Giriş saati", 120 | "description": "" 121 | }, 122 | "output": { 123 | "message": "Çıktı", 124 | "description": "" 125 | }, 126 | "reset.label": { 127 | "message": "Değişiklikleri gözardı et", 128 | "description": "" 129 | }, 130 | "save.label": { 131 | "message": "Kaydet", 132 | "description": "" 133 | }, 134 | "close.label": { 135 | "message": "Kapat", 136 | "description": "" 137 | }, 138 | "everymonthly_day0": { 139 | "message": "Pazar", 140 | "description": "Appears next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. Separate from the corresponding send?Label string (where ? is Sunday, Monday, etc.), which is used in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. These are separate strings because in some languages the weekday names are written differently in these two contexts." 141 | }, 142 | "everymonthly_day1": { 143 | "message": "Pazartesi", 144 | "description": "Appears next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. Separate from the corresponding send?Label string (where ? is Sunday, Monday, etc.), which is used in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. These are separate strings because in some languages the weekday names are written differently in these two contexts." 145 | }, 146 | "everymonthly_day2": { 147 | "message": "Salı", 148 | "description": "Appears next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. Separate from the corresponding send?Label string (where ? is Sunday, Monday, etc.), which is used in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. These are separate strings because in some languages the weekday names are written differently in these two contexts." 149 | }, 150 | "everymonthly_day3": { 151 | "message": "Çarşamba", 152 | "description": "Appears next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. Separate from the corresponding send?Label string (where ? is Sunday, Monday, etc.), which is used in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. These are separate strings because in some languages the weekday names are written differently in these two contexts." 153 | }, 154 | "everymonthly_day4": { 155 | "message": "Perşembe", 156 | "description": "Appears next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. Separate from the corresponding send?Label string (where ? is Sunday, Monday, etc.), which is used in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. These are separate strings because in some languages the weekday names are written differently in these two contexts." 157 | }, 158 | "everymonthly_day5": { 159 | "message": "Cuma", 160 | "description": "Appears next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. Separate from the corresponding send?Label string (where ? is Sunday, Monday, etc.), which is used in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. These are separate strings because in some languages the weekday names are written differently in these two contexts." 161 | }, 162 | "everymonthly_day6": { 163 | "message": "Cumartesi", 164 | "description": "Appears next to an one of the ordinals (e.g., ord1 for 1st) to indicate the 1st, 2nd, etc. of a particular day of the month, e.g., '1st Monday'. Separate from the corresponding send?Label string (where ? is Sunday, Monday, etc.), which is used in the message scheduling pop-up next to sendOnlyOnLabel to allow the user to select which day(s) to send the message on. These are separate strings because in some languages the weekday names are written differently in these two contexts." 165 | }, 166 | "AreYouSure": { 167 | "message": "Emin misin?", 168 | "description": "" 169 | }, 170 | "answerYes": { 171 | "message": "Evet", 172 | "description": "Affirmative answer for a yes/no question presented in a confirmation dialog" 173 | }, 174 | "answerNo": { 175 | "message": "Hayır", 176 | "description": "Negative answer for a yes/no question presented in a confirmation dialog" 177 | }, 178 | "DSN": { 179 | "message": "Teslimat durumu bildirimi", 180 | "description": "The Thunderbird 'Options' menu command in the compose window for enabling delivery status notification for a message. This needs to match what's in the menu. NOTE: This is *different from* the 'Return Receipt' menu command which appears adjacent to it." 181 | }, 182 | "selectNone": { 183 | "message": "(yok)", 184 | "description": "Used in pop-up menus and other menus where the user needs to be able to explicitly chose none as an option." 185 | }, 186 | "recipients": { 187 | "message": "Alıcılar", 188 | "description": "Column header in message list in main window pop-up" 189 | }, 190 | "subject": { 191 | "message": "Konu", 192 | "description": "Column header in message list in main window pop-up" 193 | }, 194 | "folder": { 195 | "message": "Dizin", 196 | "description": "Column header in message list in main window pop-up" 197 | }, 198 | "internalLogCopyLabel": { 199 | "message": "Kopyala", 200 | "description": "Label for the buttom to copy the internal log to the clipboard" 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /beta-channel.json: -------------------------------------------------------------------------------- 1 | { 2 | "addons": { 3 | "sendlater3@kamens.us": { 4 | "updates": [ 5 | { 6 | "version": "10.6.6", 7 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.6.6", 8 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.6.6/send_later_10.6.6_beta.xpi", 9 | "applications": { 10 | "gecko": { 11 | "strict_min_version": "126.0" 12 | } 13 | } 14 | }, 15 | { 16 | "version": "10.6.5", 17 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.6.5", 18 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.6.5/send_later_10.6.5_beta.xpi", 19 | "applications": { 20 | "gecko": { 21 | "strict_min_version": "126.0" 22 | } 23 | } 24 | }, 25 | { 26 | "version": "10.6.4", 27 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.6.4", 28 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.6.4/send_later_10.6.4_beta.xpi", 29 | "applications": { 30 | "gecko": { 31 | "strict_min_version": "126.0" 32 | } 33 | } 34 | }, 35 | { 36 | "version": "10.6.3", 37 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.6.3", 38 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.6.3/send_later_10.6.3_beta.xpi", 39 | "applications": { 40 | "gecko": { 41 | "strict_min_version": "126.0" 42 | } 43 | } 44 | }, 45 | { 46 | "version": "10.6.1", 47 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.6.1", 48 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.6.1/send_later_10.6.1_beta.xpi", 49 | "applications": { 50 | "gecko": { 51 | "strict_min_version": "102.0" 52 | } 53 | } 54 | }, 55 | { 56 | "version": "10.6.0", 57 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.6.0", 58 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.6.0/send_later_10.6.0_beta.xpi", 59 | "applications": { 60 | "gecko": { 61 | "strict_min_version": "102.0" 62 | } 63 | } 64 | }, 65 | { 66 | "version": "10.5.8", 67 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.5.8", 68 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.5.8/send_later_10.5.8_beta.xpi", 69 | "applications": { 70 | "gecko": { 71 | "strict_min_version": "102.0" 72 | } 73 | } 74 | }, 75 | { 76 | "version": "10.5.7", 77 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.5.7", 78 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.5.7/send_later_10.5.7_beta.xpi", 79 | "applications": { 80 | "gecko": { 81 | "strict_min_version": "102.0" 82 | } 83 | } 84 | }, 85 | { 86 | "version": "10.5.6", 87 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.5.6", 88 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.5.6/send_later_10.5.6_beta.xpi", 89 | "applications": { 90 | "gecko": { 91 | "strict_min_version": "102.0" 92 | } 93 | } 94 | }, 95 | { 96 | "version": "10.5.5", 97 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.5.5", 98 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.5.5/send_later_10.5.5_beta.xpi", 99 | "applications": { 100 | "gecko": { 101 | "strict_min_version": "102.0" 102 | } 103 | } 104 | }, 105 | { 106 | "version": "10.5.4", 107 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.5.4", 108 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.5.4/send_later_10.5.4_beta.xpi", 109 | "applications": { 110 | "gecko": { 111 | "strict_min_version": "102.0" 112 | } 113 | } 114 | }, 115 | { 116 | "version": "10.5.3", 117 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.5.3", 118 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.5.3/send_later_10.5.3_beta.xpi", 119 | "applications": { 120 | "gecko": { 121 | "strict_min_version": "102.0" 122 | } 123 | } 124 | }, 125 | { 126 | "version": "10.5.2", 127 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.5.2", 128 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.5.2/send_later_10.5.2_beta.xpi", 129 | "applications": { 130 | "gecko": { 131 | "strict_min_version": "102.0" 132 | } 133 | } 134 | }, 135 | { 136 | "version": "10.5.1", 137 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.5.1", 138 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.5.1/send_later_10.5.1_beta.xpi", 139 | "applications": { 140 | "gecko": { 141 | "strict_min_version": "102.0" 142 | } 143 | } 144 | }, 145 | { 146 | "version": "10.5.0", 147 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.5.0", 148 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.5.0/send_later_10.5.0_beta.xpi", 149 | "applications": { 150 | "gecko": { 151 | "strict_min_version": "102.0" 152 | } 153 | } 154 | }, 155 | { 156 | "version": "10.4.3", 157 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.4.3", 158 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.4.3/send_later_10.4.3_beta.xpi", 159 | "applications": { 160 | "gecko": { 161 | "strict_min_version": "102.0" 162 | } 163 | } 164 | }, 165 | { 166 | "version": "10.4.2", 167 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.4.2", 168 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.4.2/send_later_10.4.2_beta.xpi", 169 | "applications": { 170 | "gecko": { 171 | "strict_min_version": "102.0" 172 | } 173 | } 174 | }, 175 | { 176 | "version": "10.4.1", 177 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.4.1", 178 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.4.1/send_later_10.4.1_beta.xpi", 179 | "applications": { 180 | "gecko": { 181 | "strict_min_version": "102.0" 182 | } 183 | } 184 | }, 185 | { 186 | "version": "10.4.0", 187 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.4.0", 188 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.4.0/send_later_10.4.0_beta.xpi", 189 | "applications": { 190 | "gecko": { 191 | "strict_min_version": "102.0" 192 | } 193 | } 194 | }, 195 | { 196 | "version": "10.3.6", 197 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.3.6", 198 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.3.6/send_later_10.3.6_beta.xpi", 199 | "applications": { 200 | "gecko": { 201 | "strict_min_version": "102.0" 202 | } 203 | } 204 | }, 205 | { 206 | "version": "10.3.5", 207 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.3.5", 208 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.3.5/send_later_10.3.5_beta.xpi", 209 | "applications": { 210 | "gecko": { 211 | "strict_min_version": "102.0" 212 | } 213 | } 214 | }, 215 | { 216 | "version": "10.3.4", 217 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.3.4", 218 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.3.4/send_later_10.3.4_beta.xpi", 219 | "applications": { 220 | "gecko": { 221 | "strict_min_version": "102.0" 222 | } 223 | } 224 | }, 225 | { 226 | "version": "10.3.3", 227 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.3.3", 228 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.3.3/send_later_10.3.3_beta.xpi", 229 | "applications": { 230 | "gecko": { 231 | "strict_min_version": "102.0" 232 | } 233 | } 234 | }, 235 | { 236 | "version": "10.3.2", 237 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.3.2", 238 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.3.2/send_later_10.3.2_beta.xpi", 239 | "applications": { 240 | "gecko": { 241 | "strict_min_version": "102.0" 242 | } 243 | } 244 | }, 245 | { 246 | "version": "10.3.1", 247 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.3.1", 248 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.3.1/send_later_10.3.1_beta.xpi", 249 | "applications": { 250 | "gecko": { 251 | "strict_min_version": "102.0" 252 | } 253 | } 254 | }, 255 | { 256 | "version": "10.3.0", 257 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.3.0", 258 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.3.0/send_later_10.3.0_beta.xpi", 259 | "applications": { 260 | "gecko": { 261 | "strict_min_version": "102.0" 262 | } 263 | } 264 | }, 265 | { 266 | "version": "10.2.10", 267 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.2.10", 268 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.2.10/send_later_10.2.10_beta.xpi", 269 | "applications": { 270 | "gecko": { 271 | "strict_min_version": "102.0" 272 | } 273 | } 274 | }, 275 | { 276 | "version": "10.2.9", 277 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.2.9", 278 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.2.9/send_later_beta.xpi", 279 | "applications": { 280 | "gecko": { 281 | "strict_min_version": "102.0" 282 | } 283 | } 284 | }, 285 | { 286 | "version": "10.2.8", 287 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.2.8", 288 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.2.8/send_later_beta.xpi", 289 | "applications": { 290 | "gecko": { 291 | "strict_min_version": "102.0" 292 | } 293 | } 294 | }, 295 | { 296 | "version": "10.2.7", 297 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.2.7", 298 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.2.7/send_later_beta.xpi", 299 | "applications": { 300 | "gecko": { 301 | "strict_min_version": "102.0" 302 | } 303 | } 304 | }, 305 | { 306 | "version": "10.2.6", 307 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.2.6", 308 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.2.6/send_later_beta.xpi", 309 | "applications": { 310 | "gecko": { 311 | "strict_min_version": "102.0" 312 | } 313 | } 314 | }, 315 | { 316 | "version": "10.2.5", 317 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.2.5", 318 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.2.5/send_later_beta.xpi", 319 | "applications": { 320 | "gecko": { 321 | "strict_min_version": "102.0" 322 | } 323 | } 324 | }, 325 | { 326 | "version": "10.2.4", 327 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.2.4", 328 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.2.4/send_later_beta.xpi", 329 | "applications": { 330 | "gecko": { 331 | "strict_min_version": "102.0" 332 | } 333 | } 334 | }, 335 | { 336 | "version": "10.2.3", 337 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.2.3", 338 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.2.3/send_later_beta.xpi", 339 | "applications": { 340 | "gecko": { 341 | "strict_min_version": "102.0" 342 | } 343 | } 344 | }, 345 | { 346 | "version": "10.2.2", 347 | "update_info_url": "https://github.com/Extended-Thunder/send-later/releases/tag/v10.2.2", 348 | "update_link": "https://github.com/Extended-Thunder/send-later/releases/download/v10.2.2/send_later_beta.xpi", 349 | "applications": { 350 | "gecko": { 351 | "strict_min_version": "102.0" 352 | } 353 | } 354 | } 355 | ] 356 | } 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /contrib/scheduling-functions/CertainDaysAndHours.txt: -------------------------------------------------------------------------------- 1 | Function name: CertainDaysAndHours 2 | 3 | Author: Jonathan Kamens 4 | 5 | Help text 6 | --------- 7 | 8 | This function is similar to the BusinessHours function that comes predefined in 9 | Send Later, but rather than just having one start time and end time per day, it 10 | allows multiple time ranges per day and ensures that messages are sent within 11 | one of those ranges. The default code below defines the work days as Monday 12 | through Friday and the time ranges as 8:30 AM to 12:00 PM and 1:00 PM to 5:30 13 | PM, but you can easily adjust these either by modifying the code or configuring 14 | Send Later to pass in arguments that specify different work days and work 15 | periods. See the comment in the code for the format of each. 16 | 17 | Code 18 | ---- 19 | 20 | // Sun == 0, Sat == 6 21 | var workDays = [1, 2, 3, 4, 5]; 22 | 23 | // Each item in this array is another array with two items each of which is 24 | // itself an array of two numbers. Each item represents a range of work hours 25 | // during the day. The default example here says that it's OK to send emails 26 | // between 8:30 AM and 12:00 PM, and again from 1:00 PM to 5:30 PM. In other 27 | // words, no emails before 8:30 AM, or between 12:00PM and 1:00 PM, or after 28 | // 5:30 PM. 29 | // *** THESE MUST BE IN ORDER from earliest to latest and non-overlapping. *** 30 | var workPeriods = [ 31 | [[8, 30], [12, 0]], 32 | [[13, 00], [17, 30]], 33 | ]; 34 | 35 | if (args && args[0]) 36 | workDays = args[0]; 37 | if (args && args[1]) 38 | workPeriods = args[1]; 39 | 40 | try { 41 | if (!workDays || (workDays.length) == 0 || 42 | !workDays.every(d => (d >= 0) && (d <= 6))) { 43 | console.log("workDays is bad"); 44 | return undefined; 45 | } 46 | 47 | if (!workPeriods || (workPeriods.length == 0) || 48 | !workPeriods.every(([s, e]) => { 49 | return s.length == 2 && 50 | e.length == 2 && 51 | Number(s[0]) >= 0 && 52 | Number(s[0]) <= 23 && 53 | Number(s[1]) >= 0 && 54 | Number(s[1]) <= 59 && 55 | Number(e[0]) >= 0 && 56 | Number(e[0]) <= 23 && 57 | Number(e[1]) >= 0 && 58 | Number(e[1]) <= 59 59 | })) { 60 | console.log("workPeriods is bad"); 61 | return undefined; 62 | } 63 | } catch (ex) { 64 | console.log("exception validating workPeriods, it's probably bad"); 65 | return undefined; 66 | } 67 | 68 | function tCompare(t1, t2) { 69 | if (t1[0] < t2[0]) 70 | return -1; 71 | else if (t1[0] == t2[0] && t1[1] < t2[1]) 72 | return -1; 73 | else if (t1[0] == t2[0] && t1[1] == t2[1]) 74 | return 0; 75 | else 76 | return 1; 77 | } 78 | 79 | function alignToWorkPeriods(d) { 80 | // If the specified timestamp is within one of the work periods for the 81 | // day, it is returned unmodified. If it's not but there is a later work 82 | // period for the day, the start of the next work period is returned. 83 | // Otherwise, undefined is returned. 84 | let hours = d.getHours(); 85 | let minutes = d.getMinutes(); 86 | let dt = [hours, minutes]; 87 | for (let p of workPeriods) { 88 | if (tCompare(dt, p[0]) >= 0 && tCompare(dt, p[1]) <= 0) 89 | return d; 90 | if (tCompare(dt, p[0]) < 0) { 91 | d = new Date(d); 92 | d.setHours(p[0][0]); 93 | d.setMinutes(p[0][1]); 94 | return d; 95 | } 96 | } 97 | return undefined; 98 | } 99 | 100 | if (prev) 101 | // Not expected in normal usage, but used as the current time for testing. 102 | next = new Date(prev); 103 | else 104 | next = new Date(); 105 | 106 | while (true) { 107 | let aligned = alignToWorkPeriods(next); 108 | if (aligned && workDays.includes(next.getDay())) { 109 | next = aligned; 110 | break; 111 | } 112 | next.setDate(next.getDate() + 1); 113 | next.setHours(0); 114 | next.setMinutes(0); 115 | } 116 | -------------------------------------------------------------------------------- /contrib/scheduling-functions/DaysBeforeNthWeekday.txt: -------------------------------------------------------------------------------- 1 | Function name: DaysBeforeNthWeekday 2 | 3 | Author: Jonathan Kamens 4 | 5 | Back story 6 | ---------- 7 | 8 | A user on the send-later-users mailing list 9 | (https://groups.google.com/forum/#!forum/send-later-users) asked how to 10 | generate an email a certain number of days before a particular weekday of the 11 | month, e.g., "five days before the fourth Wednesday of the month." This 12 | function demonstrates how to do that. It can be configured by passing in 13 | arguments or by editing the defaults at the top of the function. 14 | 15 | Help text 16 | --------- 17 | 18 | Send an email n days before a specific day of the week on a specific week of 19 | the month, e.g., "9:00am five days before the 4th Wednesday of the month." 20 | Arguments: 21 | 22 | - Recurring -- true or false value indicating whether this is a recurring 23 | message (default false) 24 | - Weekday to look for (0 is Sunday, 1 is Monday, etc.); default is Wednesday 25 | (3) 26 | - Which week of the month to look for (first, second, third, etc.) (default is 27 | 4th) 28 | - How many days in advance to send the message (default is 5) 29 | - Hour at which to send the message (default is 9) 30 | - Minutes after the hour at which to send the message (default is 0) 31 | 32 | Specifying "-" for any argument is the same as not specifying the argument at 33 | all. 34 | 35 | If today is the day the email should be sent, the message will be scheduled for 36 | today even if the specified hour and minute are earlier than the current time, 37 | which means that Send Later will send out today's late but if you've specified 38 | recurring then subsequent ones will be sent out on time. 39 | 40 | Code 41 | ---- 42 | 43 | function getarg(num, deflt) { 44 | if (args[num] == '-' || args[num] === undefined) 45 | return deflt; 46 | return args[num]; 47 | } 48 | 49 | recurring = getarg(0, false); 50 | weekday = getarg(1, 3); 51 | week_number = getarg(2, 4); 52 | days_in_advance = getarg(3, 5); 53 | send_hour = getarg(5, 9); 54 | send_minute = getarg(5, 0); 55 | 56 | one_day_value = 24 * 60 * 60 * 1000; 57 | in_advance_value = one_day_value * days_in_advance; 58 | 59 | if (prev) 60 | now = new Date(prev.valueOf() + one_day_value); 61 | else 62 | now = new Date(); 63 | 64 | while (true) { 65 | then = new Date(now.valueOf() + in_advance_value); 66 | if (then.getDay() == weekday && 67 | Math.floor((then.getDate() - 1) / 7) == week_number - 1) { 68 | break; 69 | } 70 | now = new Date(now.valueOf() + one_day_value); 71 | } 72 | 73 | now.setHours(send_hour); 74 | now.setMinutes(send_minute); 75 | now.setSeconds(0); 76 | now.setMilliseconds(0); 77 | 78 | next = now; 79 | 80 | if (recurring) { 81 | nextspec = "function " + specname; 82 | nextargs = [true, weekday, week_number, days_in_advance, 83 | send_hour, send_minute]; 84 | } 85 | -------------------------------------------------------------------------------- /contrib/scheduling-functions/FirstDaysOfEachMonth.txt: -------------------------------------------------------------------------------- 1 | Function name: FirstDaysOfEachMonth 2 | 3 | Author: Jonathan Kamens 4 | 5 | Help text 6 | --------- 7 | 8 | Schedule a recurring message to be sent at 9:00am on the first days of every 9 | month. You can alter the behavior of the function with the following arguments 10 | in this order: hour at which to send the message (default 9), minutes after the 11 | hour to send the message (default 0), last day of each month to send the 12 | message (default 4). 13 | 14 | Code 15 | ---- 16 | function getarg(idx, deflt) { 17 | if (args[idx] == null) 18 | return deflt; 19 | return args[idx]; 20 | }; 21 | 22 | if (! prev) { 23 | prev = new Date(); 24 | } 25 | hour = getarg(0, 9); 26 | minute = getarg(1, 0); 27 | lastDay = getarg(2, 4); 28 | dayOfMonth = prev.getDate() + 1; 29 | month = prev.getMonth(); 30 | year = prev.getFullYear(); 31 | if (dayOfMonth > lastDay) { 32 | dayOfMonth = 1; 33 | month++; 34 | if (month > 11) { // month in JavaScript Date objects is 0-based 35 | month = 0; 36 | year++; 37 | } 38 | } 39 | next = new Date(year, month, dayOfMonth, hour, minute); 40 | nextspec = "function " + specname; 41 | nextargs = args; 42 | 43 | -------------------------------------------------------------------------------- /contrib/scheduling-functions/NextChol.txt: -------------------------------------------------------------------------------- 1 | Function name: NextChol 2 | 3 | Author: Jonathan Kamens 4 | 5 | Help text 6 | --------- 7 | 8 | Schedules the message to send a short time after the end of the current Sabbath 9 | or Jewish holiday, or the next Sabbath or Jewish Holiday if it isn't currently 10 | one (e.g., if you use this function on a regular Thursday the message will be 11 | scheduled to be sent Saturday night). Useful if you use email at these times 12 | but don't want to offend your Jewish friends who don't. 13 | 14 | Note that this function calls out to a little HTTP API endpoint I wrote to 15 | calculate the end time of the Sabbath or holiday. 16 | 17 | You will probably want to edit the function after importing it to specify what 18 | city you're in, so the times will be correct for you. See the API endpoint's 19 | help page (https://jewish-holidays.kamens.us/next-chol?help) for a list of 20 | supported cities. 21 | 22 | Code 23 | ---- 24 | 25 | // See https://jewish-holidays.kamens.us/next-chol?help for supported cities. 26 | let city = 'Boston'; 27 | let url = 'https://jewish-holidays.kamens.us/next-chol?force&city=' + city; 28 | let req = new XMLHttpRequest(); 29 | req.open('GET', url, false); 30 | req.send(); 31 | if (req.readyState != 4) 32 | return; 33 | if (req.status != 200) { 34 | msg = 'Error fetching from ' + url; 35 | throw(msg); 36 | } 37 | let matches = req.responseText.match(/^\s*(\d+)/); 38 | if (matches.length < 1) { 39 | msg = url + ' did not return a number'; 40 | throw(msg); 41 | } 42 | let unix_time = matches[1]; 43 | next = new Date(); 44 | next.setTime(unix_time * 1000); 45 | next.setMinutes(next.getMinutes() + Math.floor(Math.random() * 15)); 46 | -------------------------------------------------------------------------------- /contrib/scheduling-functions/README.md: -------------------------------------------------------------------------------- 1 | # Send Later dynamic scheduling function library 2 | 3 | This directory contains dynamic scheduling functions for use with the [Send 4 | Later Thunderbird add-on][atn]. For more information about dynamic scheduling 5 | functions, see the [Send Later user guide][guide]. 6 | 7 | The preferred method for contributing a new function to this library is: 8 | 9 | * Fork [Send Later on Github][github]. 10 | * Copy your function from the dynamic function editor into a new file in this 11 | directory formatted to look like the other files. 12 | * Commit your new file to the repository. 13 | * Submit a pull request. 14 | 15 | Alternatively, you can just [email me][email] your function as a plain text 16 | file attachment and let me know what it does. 17 | 18 | Thanks for your contributions! 19 | 20 | ## Contributed functions 21 | 22 | [atn]: https://addons.thunderbird.net/thunderbird/addon/send-later-3/ 23 | [guide]: https://extended-thunder.github.io/send-later/#dynamic 24 | [github]: https://github.com/Extended-Thunder/send-later 25 | [email]: mailto:send-later-support@kamens.us 26 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /_locales/en/messages.json 3 | translation: /_locales/%two_letters_code%/messages.json 4 | -------------------------------------------------------------------------------- /dev/beta-channel-generator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import re 5 | import subprocess 6 | import sys 7 | 8 | addon_id = 'sendlater3@kamens.us' 9 | 10 | json_filename = sys.argv[1] 11 | try: 12 | data = json.load(open(json_filename, 'r')) 13 | except FileNotFoundError: 14 | data = { 15 | 'addons': { 16 | addon_id: { 17 | 'updates': [ 18 | ] 19 | } 20 | } 21 | } 22 | updates = data['addons'][addon_id]['updates'] 23 | 24 | # It sure would be great if gh release list had JSON output 25 | result = subprocess.run(('gh', 'release', 'list', '--exclude-drafts'), 26 | check=True, capture_output=True, encoding='utf-8') 27 | insert_index = 0 28 | for release_line in result.stdout.split('\n'): 29 | match = re.search(r'\bv[0-9.]+\b', release_line) 30 | if not match: 31 | continue 32 | tag = match[0] 33 | version = tag[1:] 34 | if any(u for u in updates if u['version'] == version): 35 | print(f'Found version ${version} in ${json_filename}`', 36 | file=sys.stderr) 37 | break 38 | 39 | result = subprocess.run(('gh', 'release', 'view', tag, '--json', 40 | 'url,assets'), check=True, capture_output=True, 41 | encoding='utf-8') 42 | release_data = json.loads(result.stdout) 43 | try: 44 | beta_asset = next(a for a in release_data['assets'] 45 | if a['name'].endswith('_beta.xpi')) 46 | except StopIteration: 47 | print(f'No beta asset in ${version}, skipping', file=sys.stderr) 48 | continue 49 | download_url = beta_asset['url'] 50 | 51 | result = subprocess.run(('git', 'show', f'{tag}:manifest.json'), 52 | check=True, capture_output=True, encoding='utf-8') 53 | manifest_data = json.loads(result.stdout) 54 | strict_min_version = \ 55 | manifest_data['applications']['gecko']['strict_min_version'] 56 | 57 | updates.insert(insert_index, { 58 | 'version': version, 59 | 'update_info_url': release_data['url'], 60 | 'update_link': download_url, 61 | 'applications': { 62 | 'gecko': { 63 | 'strict_min_version': strict_min_version 64 | } 65 | } 66 | }) 67 | insert_index += 1 68 | 69 | print(json.dumps(data, indent=2)) 70 | -------------------------------------------------------------------------------- /dev/compare-translations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Compares two locale directories and lists the differences between them, with 4 | # some smarts to eliminate unnecessary output. 5 | 6 | import glob 7 | import itertools 8 | import json 9 | import sys 10 | import textwrap 11 | 12 | default_locale = 'en' 13 | obsolete_keys = ( 14 | 'January', 15 | 'February', 16 | 'March', 17 | 'April', 18 | 'May', 19 | 'June', 20 | 'July', 21 | 'August', 22 | 'September', 23 | 'October', 24 | 'November', 25 | 'December', 26 | 27 | 'close.accesskey', 28 | 'close.cmdkey', 29 | 'CompactionFailureNoError', 30 | 'CompactionFailureWithError', 31 | 'delete.accesskey', 32 | 'everymonthly_short', 33 | 'export.accesskey', 34 | 'export.label', 35 | 'exporttip', 36 | 'ExportTitle', 37 | 'import.accesskey', 38 | 'import.label', 39 | 'ImportError', 40 | 'importtip', 41 | 'ImportTitle', 42 | 'no', 43 | 'RenameFunctionBody', 44 | 'RenameFunctionNewButton', 45 | 'RenameFunctionRenameButton', 46 | 'RenameFunctionTitle', 47 | 'reset.accesskey', 48 | 'SanityCheckConfirmOptionMessage', 49 | 'SanityCheckCorruptFolderWarning', 50 | 'SanityCheckDrafts', 51 | 'SanityCheckOutbox', 52 | 'save.accesskey', 53 | ) 54 | 55 | 56 | def main(): 57 | dir1 = sys.argv[1] 58 | dir2 = sys.argv[2] 59 | 60 | old = read_tree(dir1) 61 | new = read_tree(dir2) 62 | compare_trees(old, new) 63 | 64 | 65 | def read_tree(dir): 66 | file_paths = glob.glob(f'{dir}/*/messages.json') 67 | strings = {} 68 | for file_path in file_paths: 69 | locale = file_path.split('/')[-2] 70 | data = json.load(open(file_path)) 71 | strings[locale] = {key: value['message'] 72 | for key, value in data.items()} 73 | return strings 74 | 75 | 76 | def compare_trees(old, new): 77 | locales = sorted(set(itertools.chain(old.keys(), new.keys()))) 78 | for locale in locales: 79 | try: 80 | before = old[locale] 81 | except KeyError: 82 | print(f'{locale}: New locale') 83 | continue 84 | try: 85 | after = new[locale] 86 | except KeyError: 87 | print(f'{locale}: Deleted locale') 88 | continue 89 | keys = sorted(set(itertools.chain(before.keys(), after.keys()))) 90 | for key in keys: 91 | try: 92 | before_value = before[key] 93 | except KeyError: 94 | print(wrap(f'{locale}: {key} added (translated from ' 95 | f'{old[default_locale][key]}): {after[key]}')) 96 | continue 97 | try: 98 | after_value = after[key] 99 | except KeyError: 100 | try: 101 | if old[default_locale][key] == old[locale][key]: 102 | continue 103 | except KeyError: 104 | pass 105 | 106 | if key not in obsolete_keys: 107 | print(wrap(f'{locale}: {key} deleted (was {before[key]})')) 108 | continue 109 | if key in obsolete_keys: 110 | print(f'{locale}: {key} should be deleted') 111 | continue 112 | if before_value != after_value: 113 | print(wrap(f'{locale}: {key}: {before_value} -> ' 114 | f'{after_value}')) 115 | 116 | 117 | def wrap(str): 118 | width = 79 119 | indent = ' ' 120 | lines = str.split('\n') 121 | wrapped = textwrap.wrap(lines.pop(0), width=width, 122 | subsequent_indent=indent) 123 | for line in lines: 124 | wrapped.extend(textwrap.wrap(line, width=width, initial_indent=indent, 125 | subsequent_indent=indent)) 126 | return '\n'.join(wrapped) 127 | 128 | 129 | if __name__ == '__main__': 130 | main() 131 | -------------------------------------------------------------------------------- /dev/fix_message.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | from bs4 import BeautifulSoup 5 | import json 6 | import markdown 7 | import os 8 | import re 9 | import requests 10 | import subprocess 11 | import sys 12 | import tempfile 13 | from textwrap import dedent 14 | import time 15 | 16 | atn_url = 'https://addons.thunderbird.net/thunderbird/addon/send-later-3/' 17 | atn_versions_url = f'{atn_url}versions/' 18 | gh_versions_url = 'https://github.com/Extended-Thunder/send-later/releases' 19 | markdown_file_path = 'download-response.md' 20 | 21 | 22 | def parse_args(): 23 | parser = argparse.ArgumentParser(description='Create Send Later upgrade ' 24 | 'message in Markdown or HTML') 25 | parser.add_argument('--test', action='store_true') 26 | parser.add_argument('--version', action='store', help='Defaults to most ' 27 | 'recent version on GitHub or ATN') 28 | parser.add_argument('--html', action='store_true') 29 | group = parser.add_mutually_exclusive_group() 30 | group.add_argument('--browser', action='store_true', 31 | help='Show message in browser (implies --html)') 32 | group.add_argument('--clipboard', action='store_true', help='Copy message ' 33 | 'to clipboard using xclip or wl-copy') 34 | args = parser.parse_args() 35 | if args.browser: 36 | args.html = True 37 | return args 38 | 39 | 40 | def check_atn_version(want_version): 41 | '''Returns true if the specified release is available''' 42 | response = requests.get(atn_versions_url) 43 | soup = BeautifulSoup(response.content, 'lxml') 44 | versions = soup.find_all('a') 45 | versions = (v.get('href') for v in versions) 46 | versions = (re.search(r'-([0-9.]+)-', v)[1] 47 | for v in versions if re.search(r'send_later.*\.xpi', v) and 48 | 'type:attachment' not in v) 49 | for v in versions: 50 | if v == want_version: 51 | return True 52 | else: 53 | return False 54 | 55 | 56 | def get_gh_info(want_version): 57 | '''Returns info for the specified release, if present''' 58 | result = subprocess.run(('gh', 'release', 'view', f'v{want_version}', 59 | '--json', 'url,isDraft,isPrerelease,url,assets'), 60 | capture_output=True, encoding='utf8') 61 | if not result.stdout: 62 | return None 63 | return json.loads(result.stdout) 64 | 65 | 66 | def unwrap(text): 67 | return re.sub(r'(\S)\n[ \t]*\b', r'\1 ', dedent(text)) 68 | 69 | 70 | def generate_markdown(version, on_atn, gh_info): 71 | atn_steps = unwrap(f'''\ 72 | * Go to [addons.thunderbird.net]({atn_versions_url}) and confirm that 73 | release {version} is available for download there. 74 | * Open the Add-ons page in Thunderbird ("Add-ons and Themes" from the 75 | corner "hamburger" menu). 76 | * Click on Send later. 77 | * Click the gear icon and select "Check for Updates". 78 | * The add-on should update and then you should see the message, "Your 79 | add-ons have been updated," next to the gear icon.\n\n''') 80 | 81 | markdown = '' 82 | markdown += unwrap(f'''\ 83 | This issue should be fixed in release {version} of Send Later. 84 | 85 | To check what version of Send Later you have: 86 | 87 | * Open the Add-ons page in Thunderbird ("Add-ons and Themes" from 88 | the corner "hamburger" menu). 89 | * Click on Extensions on the left if you're not already on the 90 | left if you're not already there. 91 | * Find Send Later in the list of extensions and click on it to 92 | open its details page. 93 | * The version number should be displayed there next to 94 | "Version".\n 95 | ''') 96 | 97 | if on_atn: 98 | markdown += unwrap('''\ 99 | This release is available on addons.thunderbird.net, and unless you've 100 | turned off automatic add-on updates, Thunderbird will eventually update 101 | to the new version automatically. To update immediately:\n\n''') 102 | 103 | markdown += atn_steps 104 | else: 105 | xpi_url = next(a['url'] for a in gh_info['assets'] 106 | if re.match(r'send_later_[0-9.]+\.xpi$', a['name'])) 107 | beta_url = next(a['url'] for a in gh_info['assets'] 108 | if a['name'].endswith('_beta.xpi')) 109 | release_url = gh_info['url'] 110 | markdown += unwrap(f'''\ 111 | This release is not yet available for download from 112 | addons.thunderbird.net, but you can download and install it from 113 | its [GitHub release page]({release_url}) as follows: 114 | 115 | * Download [send_later_{version}.xpi]({xpi_url}) to your computer. 116 | (If you are using Firefox, make sure to right click on the link and 117 | select "Save Link As..." rather than just clicking on it, because 118 | otherwise Firefox will try to install it as a Firefox add-on.) 119 | * Open the Add-ons page in Thunderbird ("Add-ons and Themes" from 120 | the corner "hamburger" menu). 121 | * Click the gear icon at the top and select "Install Add-on From 122 | File...". 123 | * Browse to and select the downloaded file. 124 | * Click through the installation pop-ups. 125 | * You can delete the downloaded file once you've installed it into 126 | Thunderbird. 127 | 128 | To subscribe to future beta releases (we love beta testers!), 129 | download and install [send_later_beta_{version}.xpi]({beta_url}) 130 | instead. The advantage is that if there's a bug that affects 131 | your workflow you'll help find it and get it fixed quickly. 132 | The disadvantage is that bugs are a bit more likely in beta 133 | releases. You can unsubscribe from beta releases at any time 134 | by downloading and installing send_later.xpi or installing 135 | from [addons.thunderbird.net]({atn_url}).\n\n''') 136 | 137 | if gh_info['isPrerelease']: 138 | markdown += unwrap('''\ 139 | Note that this is a prerelease. While we make every effort to 140 | ensure that prereleases are stable, they are a bit more likely 141 | to have bugs, so proceed with caution.\n\n''') 142 | 143 | markdown += unwrap('''\ 144 | If you prefer to wait, the release will eventually be available on 145 | addons.thunderbird.net, and unless you've turned off automatic 146 | add-on updates, Thunderbird will eventually update to the new 147 | version automatically. To update immediately once it's available:\n 148 | ''') 149 | 150 | markdown += atn_steps 151 | 152 | markdown += ('Please let me know if you still see this issue in this ' 153 | 'release.\n\n') 154 | 155 | return markdown 156 | 157 | 158 | def process(args, version, on_atn, gh_info): 159 | output = generate_markdown(version, on_atn, gh_info) 160 | if args.html: 161 | output = markdown.markdown(output) 162 | if args.browser: 163 | with tempfile.NamedTemporaryFile(mode='w', encoding='utf8', 164 | suffix='.html', dir=os.getcwd()) as f: 165 | f.write(output) 166 | f.flush() 167 | subprocess.run(('xdg-open', f'file://{f.name}'), 168 | check=True) 169 | # Give the browser time to read the file. 170 | time.sleep(5) 171 | elif args.clipboard: 172 | clip_command = (('xclip', '-selection', 'clipboard') 173 | if os.environ['XDG_SESSION_TYPE'] == 'x11' 174 | else ('wl-copy',)) 175 | subprocess.run(clip_command, input=output, check=True, 176 | encoding='utf8') 177 | else: 178 | print(output, end='') 179 | 180 | 181 | def main(): 182 | args = parse_args() 183 | if args.version: 184 | on_atn = check_atn_version(args.version) 185 | gh_info = get_gh_info(args.verson) 186 | if not (on_atn or gh_info): 187 | sys.exit(f"Version {args.version} isn't on ATN or GitHub.") 188 | return process(args, args.version, on_atn, gh_info) 189 | result = subprocess.run( 190 | ('git', 'tag', '--list', '--sort=-v:refname', 'v*'), 191 | check=True, capture_output=True, encoding='utf8') 192 | tags = result.stdout.strip().split('\n') 193 | for tag in tags: 194 | version = re.match(r'v(.*)', tag)[1] 195 | on_atn = check_atn_version(version) 196 | gh_info = get_gh_info(version) 197 | if gh_info and gh_info['isDraft']: 198 | gh_info = None 199 | try: 200 | next(a for a in gh_info['assets'] 201 | if re.match(r'send_later.*.xpi', a['name'])) 202 | except StopIteration: 203 | print(f'WARNING: release {version} has no assets on GitHub', 204 | file=sys.stderr) 205 | continue 206 | if on_atn or gh_info: 207 | break 208 | else: 209 | sys.exit('Failed to find any release') 210 | return process(args, version, on_atn, gh_info) 211 | 212 | 213 | if __name__ == '__main__': 214 | main() 215 | -------------------------------------------------------------------------------- /dev/graphics/Green_arrow_right.svg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Extended-Thunder/send-later/93295abeaf478133cd671e4baf6877f022a6bcb7/dev/graphics/Green_arrow_right.svg.png -------------------------------------------------------------------------------- /dev/graphics/clock.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Extended-Thunder/send-later/93295abeaf478133cd671e4baf6877f022a6bcb7/dev/graphics/clock.jpg -------------------------------------------------------------------------------- /dev/graphics/logo-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Extended-Thunder/send-later/93295abeaf478133cd671e4baf6877f022a6bcb7/dev/graphics/logo-big.png -------------------------------------------------------------------------------- /dev/graphics/logo-big.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Extended-Thunder/send-later/93295abeaf478133cd671e4baf6877f022a6bcb7/dev/graphics/logo-big.xcf -------------------------------------------------------------------------------- /dev/graphics/sample_cancel_button.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Extended-Thunder/send-later/93295abeaf478133cd671e4baf6877f022a6bcb7/dev/graphics/sample_cancel_button.jpg -------------------------------------------------------------------------------- /dev/graphics/stamp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Extended-Thunder/send-later/93295abeaf478133cd671e4baf6877f022a6bcb7/dev/graphics/stamp.png -------------------------------------------------------------------------------- /dev/include-manifest: -------------------------------------------------------------------------------- 1 | _locales/*/* 2 | experiments/* 3 | ui/*.* 4 | ui/*/* 5 | utils/* 6 | background.js 7 | manifest.json 8 | -------------------------------------------------------------------------------- /dev/restore-translation-string.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | from contextlib import contextmanager 5 | import glob 6 | import json 7 | import os 8 | import pickle 9 | import pprint 10 | import subprocess 11 | import sys 12 | import tempfile 13 | 14 | 15 | # 1. Build a database of all strings in all previous commits. 16 | # 2. Read in all locales. 17 | # 3. Find the most recent version of the requested string in all locales. 18 | # 4. Insert the string into the locales that have it, in the right place. 19 | # 5. Output the result. 20 | 21 | 22 | def parse_args(): 23 | parser = argparse.ArgumentParser() 24 | parser.add_argument('--after', metavar='KEY', action='store') 25 | parser.add_argument('--rename', metavar='KEY', action='store') 26 | parser.add_argument('--save-database', action='store_true') 27 | parser.add_argument('--database', metavar='FILENAME', action='store') 28 | parser.add_argument('--print-strings', action='store_true') 29 | parser.add_argument('--print-deleted', action='store_true') 30 | parser.add_argument('string', nargs='?') 31 | args = parser.parse_args() 32 | if args.print_deleted: 33 | args.print_strings = True 34 | if (args.after or args.rename or args.string) and args.print_strings: 35 | parser.error( 36 | '--print-strings and --print-deleted are incompatible with most ' 37 | 'other arguments') 38 | if not args.string and not args.print_strings: 39 | parser.error('string must be specified unless --print-strings is') 40 | return args 41 | 42 | 43 | @contextmanager 44 | def save_directory(): 45 | cwd = os.getcwd() 46 | try: 47 | yield 48 | finally: 49 | os.chdir(cwd) 50 | 51 | 52 | def get_current_strings(): 53 | strings = {} 54 | with save_directory(): 55 | try: 56 | os.chdir('_locales') 57 | except FileNotFoundError: 58 | return {} 59 | locale_files = glob.glob('*/messages.json') 60 | for locale_file in locale_files: 61 | locale = locale_file.split('/')[0] 62 | try: 63 | locale_data = json.load(open(locale_file, 'r')) 64 | except Exception: 65 | continue 66 | strings[locale] = locale_data 67 | return strings 68 | 69 | 70 | def get_old_strings(args): 71 | if args.database and not args.save_database: 72 | return pickle.load(open(args.database, 'rb')) 73 | repodir = os.getcwd() 74 | strings = {} 75 | with save_directory(), tempfile.TemporaryDirectory() as clonedir: 76 | os.chdir(clonedir) 77 | subprocess.check_call(('git', 'clone', '-q', repodir, 'send-later')) 78 | os.chdir('send-later') 79 | res = subprocess.run(('git', 'log', '--oneline', '.'), 80 | capture_output=True, check=True, encoding='utf8') 81 | commits = res.stdout.strip().split("\n") 82 | commits = [line.split(' ')[0] for line in commits] 83 | for commit in commits: 84 | subprocess.run(('git', 'checkout', '-q', commit), check=True) 85 | commit_strings = get_current_strings() 86 | for locale, locale_data in commit_strings.items(): 87 | if locale not in strings: 88 | strings[locale] = locale_data 89 | else: 90 | for key, value in locale_data.items(): 91 | if key not in strings[locale]: 92 | strings[locale][key] = value 93 | if args.save_database and args.database: 94 | pickle.dump(strings, open(args.database, 'wb')) 95 | return strings 96 | 97 | 98 | def insert_string(args, after_keys, locale, value): 99 | new_name = args.rename or args.string 100 | target_file = f'_locales/{locale}/messages.json' 101 | try: 102 | strings = json.load(open(target_file, 'r')) 103 | except FileNotFoundError: 104 | print(f'{locale} does not currently exist', file=sys.stderr) 105 | return 106 | if new_name in strings: 107 | print(f'{locale} already has {new_name}', file=sys.stderr) 108 | return 109 | print(f'Adding {new_name} to {locale}', file=sys.stderr) 110 | new_strings = {} 111 | locale_keys = list(strings.keys()) 112 | added = False 113 | while locale_keys: 114 | key = locale_keys.pop(0) 115 | if key == args.after: 116 | new_strings[key] = strings[key] 117 | new_strings[new_name] = value 118 | added = True 119 | break 120 | elif key in after_keys: 121 | new_strings[new_name] = value 122 | new_strings[key] = strings[key] 123 | added = True 124 | break 125 | else: 126 | new_strings[key] = strings[key] 127 | while locale_keys: 128 | key = locale_keys.pop(0) 129 | new_strings[key] = strings[key] 130 | if not added: 131 | new_strings[new_name] = value 132 | print(json.dumps(new_strings, indent=2, ensure_ascii=False), 133 | file=open(target_file, 'w')) 134 | 135 | 136 | def main(): 137 | args = parse_args() 138 | 139 | if args.print_strings: 140 | old_strings = get_old_strings(args) 141 | if args.print_deleted: 142 | current_strings = get_current_strings() 143 | deleted_strings = {} 144 | for locale, locale_data in old_strings.items(): 145 | if locale not in current_strings: 146 | continue 147 | deleted_strings[locale] = { 148 | key: value 149 | for key, value in locale_data.items() 150 | if key not in current_strings[locale] 151 | } 152 | pprint.pprint(deleted_strings) 153 | else: 154 | pprint.pprint(old_strings) 155 | sys.exit() 156 | 157 | if args.after: 158 | en_strings = json.load(open('_locales/en/messages.json', 'r')) 159 | en_keys = list(en_strings.keys()) 160 | try: 161 | after_index = en_keys.index(args.after) 162 | except ValueError: 163 | sys.exit(f'{args.after} not in _locales/en/messages.json') 164 | after_keys = en_keys[after_index+1:] 165 | else: 166 | after_keys = [] 167 | 168 | old_strings = get_old_strings(args) 169 | 170 | for locale, strings in old_strings.items(): 171 | try: 172 | old_string = strings[args.string] 173 | except KeyError: 174 | print(f'{args.string} is not in {locale}', file=sys.stderr) 175 | continue 176 | insert_string(args, after_keys, locale, old_string) 177 | 178 | 179 | if __name__ == '__main__': 180 | main() 181 | -------------------------------------------------------------------------------- /dev/scrape-pontoon-translation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | '''pontoon.mozilla.org translation scraper 4 | 5 | Script to pull a particular translation string from Thunderbird (or any other 6 | app translated on pontoon.mozilla.org) for all available languages and add it 7 | to Send Later's messages.json files. 8 | 9 | To use it, you first need to browse around on pontoon.thunderbird.org to find 10 | the specific string you want. When you find it, it will have a `string=#` query 11 | parameter in the URL. That query parameter is the ID you need to give to this 12 | script. 13 | ''' 14 | 15 | import argparse 16 | import json 17 | import os 18 | import re 19 | import requests 20 | import sys 21 | 22 | fix_locales = { 23 | 'es-ES': 'es', 24 | 'hy-AM': 'hy', 25 | 'nb-NO': 'nb', 26 | 'pt-BR': 'pt_BR', 27 | 'pt-PT': 'pt', 28 | 'sv-SE': 'sv', 29 | 'zh-CN': 'zh', 30 | 'zh-TW': 'zh_TW', 31 | } 32 | 33 | 34 | def parse_args(): 35 | parser = argparse.ArgumentParser(description='Pull translation from ' 36 | 'Pontoon and add to messages.json files') 37 | parser.add_argument('string_id', metavar='STRING-ID', help='"string" ' 38 | 'query parameter from URL of desired string') 39 | parser.add_argument('name', metavar='NAME', help='Name to assign to the ' 40 | 'string in messages.json') 41 | return parser.parse_args() 42 | 43 | 44 | def main(): 45 | args = parse_args() 46 | locales = [dir for dir in os.listdir('_locales') if dir != "en"] 47 | translations = {} 48 | for translate in get_translations(args.string_id): 49 | locale = fix_locale(translate['locale']['code']) 50 | if locale not in locales: 51 | continue 52 | translation_string = fix_translation( 53 | translate.get('translation', None)) 54 | if not translation_string: 55 | continue 56 | translations[locale] = translation_string 57 | for missing in sorted(set(locales) - set(translations.keys())): 58 | print(f'Warning: no translation for {missing}') 59 | description = get_description(args.name) 60 | for locale, translation in translations.items(): 61 | add_translation(locale, translation, args.name, description) 62 | 63 | 64 | def fix_locale(locale): 65 | return fix_locales.get(locale, locale) 66 | 67 | 68 | def fix_translation(translate): 69 | if not translate: 70 | return None 71 | match = re.search(r' = (.*)', translate) 72 | if not match: 73 | return None 74 | return match[1].strip() 75 | 76 | 77 | def get_translations(string_id): 78 | # Arbitrary choice, but we double-check that it's valid. 79 | unwanted_locale = 'af' 80 | locales = os.listdir('_locales') 81 | if any(locale for locale in locales if locale.startswith(unwanted_locale)): 82 | sys.exit('You need to update unwanted_locale in get_translations!') 83 | url = f'https://pontoon.mozilla.org/other-locales/?entity={string_id}' \ 84 | f'&locale={unwanted_locale}' 85 | response = requests.get( 86 | url, headers={'x-requested-with': 'XMLHttpRequest'}) 87 | return response.json() 88 | 89 | 90 | def get_description(name): 91 | current = json.load(open('_locales/en/messages.json')) 92 | return current[name]['description'] 93 | 94 | 95 | def add_translation(locale, translation, name, description): 96 | target = f'_locales/{locale}/messages.json' 97 | current = json.load(open(target, 'r', encoding='utf-8')) 98 | if name in current: 99 | if current[name]['message'] == translation: 100 | print(f'Locale {locale} already has {name}') 101 | else: 102 | print(f'Locale {locale} already has {name} with value ' 103 | f'{current[name]["message"]} instead of fetched value ' 104 | f'{translation}') 105 | return 106 | current[name] = { 107 | 'message': translation, 108 | 'description': description 109 | } 110 | tmpfile = f'{target}.new' 111 | with open(tmpfile, 'w', encoding='utf-8') as f: 112 | print(json.dumps(current, sort_keys=False, 113 | ensure_ascii=False, indent=2), file=f) 114 | # Ensure final newline 115 | print('', file=f) 116 | os.rename(tmpfile, target) 117 | print(f'Added {name}={translation} to {locale}') 118 | 119 | 120 | if __name__ == '__main__': 121 | main() 122 | -------------------------------------------------------------------------------- /experiments/hdrViewImplementation.js: -------------------------------------------------------------------------------- 1 | var { ExtensionSupport } = ChromeUtils.importESModule( 2 | "resource:///modules/ExtensionSupport.sys.mjs", 3 | ); 4 | 5 | var thunderbirdVersion = parseInt(Services.appinfo.version.split(".")[0]); 6 | 7 | class CustomHdrRow { 8 | constructor(context, name) { 9 | this.context = context; 10 | this.name = name; 11 | this.rowId = ExtensionCommon.makeWidgetId( 12 | `${context.extension.id}-${name}-custom-hdr`, 13 | ); 14 | } 15 | 16 | async getDocument(tab) { 17 | // Three possibilities: 18 | // 1. Message pane in 3pane tab 19 | // 2. Message in its own tab 20 | // 3. Message in its own window 21 | // Message pane in 3pane tab 22 | try { 23 | let doc = 24 | tab.nativeTab.chromeBrowser.contentDocument.getElementById( 25 | "messageBrowser", 26 | ).contentDocument; 27 | if (doc) { 28 | return doc; 29 | } 30 | } catch (ex) {} 31 | // Message in its own tab 32 | try { 33 | let doc = tab.nativeTab.chromeBrowser.contentDocument; 34 | if (doc.getElementById("messageHeader")) { 35 | return doc; 36 | } 37 | } catch (ex) {} 38 | // Message in its own window 39 | try { 40 | let doc = 41 | tab.nativeTab.document.getElementById( 42 | "messageBrowser", 43 | ).contentDocument; 44 | if (doc) { 45 | return doc; 46 | } 47 | } catch (ex) {} 48 | } 49 | 50 | async remove(window) { 51 | try { 52 | let elt = (await this.getDocument(window)).getElementById(this.rowId); 53 | if (elt) { 54 | elt.remove(); 55 | } 56 | } catch (ex) { 57 | console.error(ex); 58 | } 59 | } 60 | 61 | async addToWindow(window, value) { 62 | let document = await this.getDocument(window); 63 | 64 | // If a row already exists, do not create another one 65 | let newRowNode = document.getElementById(this.rowId); 66 | if (!newRowNode) { 67 | // Create new row. 68 | 69 | // I copied this structure from "expandedorganizationRow" in the DOM. 70 | newRowNode = document.createElement("html:div"); 71 | newRowNode.id = this.rowId; 72 | newRowNode.classList.add("message-header-row"); 73 | 74 | let boxNode = document.createElement("html:div"); 75 | newRowNode.appendChild(boxNode); 76 | 77 | boxNode.id = `${this.rowId}Box`; 78 | boxNode.classList.add("header-row"); 79 | boxNode.tabIndex = 0; 80 | 81 | let headingNode = document.createElement("html:span"); 82 | boxNode.appendChild(headingNode); 83 | 84 | headingNode.id = `${this.rowId}Heading`; 85 | headingNode.classList.add("row-heading"); 86 | headingNode.textContent = this.name; 87 | 88 | let sep = document.createElement("html:span"); 89 | headingNode.appendChild(sep); 90 | 91 | sep.classList.add("screen-reader-only"); 92 | sep.setAttribute("data-l10n-name", "field-separator"); 93 | sep.textContent = ":"; 94 | 95 | let valueNode = document.createElement("html:span"); 96 | valueNode.id = `${this.rowId}Value`; 97 | valueNode.textContent = value; 98 | boxNode.appendChild(valueNode); 99 | 100 | // Add the new row to the extra headers container. 101 | let topViewNode = document.getElementById("messageHeader"); 102 | topViewNode.appendChild(newRowNode); 103 | } else { 104 | let valueNode = document.getElementById(`${this.rowId}Value`); 105 | valueNode.textContent = value; 106 | } 107 | } 108 | } 109 | 110 | var headerView = class extends ExtensionCommon.ExtensionAPI { 111 | async close() {} 112 | 113 | getAPI(context) { 114 | context.callOnClose(this); 115 | let hdrRows = new Map(); 116 | this.hdrRows = hdrRows; 117 | 118 | return { 119 | headerView: { 120 | async addCustomHdrRow(tabId, name, value) { 121 | let tab = context.extension.tabManager.get(tabId); 122 | let hdrRow = hdrRows.get(name); 123 | if (!hdrRow) { 124 | hdrRow = new CustomHdrRow(context, name); 125 | hdrRows.set(name, hdrRow); 126 | } 127 | await hdrRow.addToWindow(tab, value); 128 | }, 129 | 130 | async removeCustomHdrRow(tabId, name) { 131 | let tab = context.extension.tabManager.get(tabId); 132 | let hdrRow = hdrRows.get(name); 133 | if (!hdrRow) { 134 | return; 135 | } 136 | await hdrRow.remove(tab); 137 | }, 138 | }, 139 | }; 140 | } 141 | }; 142 | -------------------------------------------------------------------------------- /experiments/hdrViewSchema.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "namespace": "headerView", 4 | "description": "", 5 | "functions": [ 6 | { 7 | "name": "addCustomHdrRow", 8 | "type": "function", 9 | "description": "", 10 | "async": true, 11 | "parameters": [ 12 | { 13 | "name": "windowId", 14 | "type": "integer" 15 | }, 16 | { 17 | "name": "name", 18 | "type": "string" 19 | }, 20 | { 21 | "name": "value", 22 | "type": "string" 23 | } 24 | ] 25 | }, 26 | { 27 | "name": "removeCustomHdrRow", 28 | "type": "function", 29 | "description": "", 30 | "async": true, 31 | "parameters": [ 32 | { 33 | "name": "windowId", 34 | "type": "integer" 35 | }, 36 | { 37 | "name": "name", 38 | "type": "string" 39 | } 40 | ] 41 | } 42 | ] 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /experiments/legacyColumnImplementation.js: -------------------------------------------------------------------------------- 1 | var ExtensionSupport = 2 | globalThis.ExtensionSupport || 3 | ChromeUtils.importESModule("resource:///modules/ExtensionSupport.sys.mjs") 4 | .ExtensionSupport; 5 | 6 | var LegacyColumn = { 7 | preferences: {}, 8 | storageLocalMap: new Map(), 9 | 10 | getStorageLocal(key) { 11 | return this.storageLocalMap.get(key); 12 | }, 13 | 14 | setStorageLocal(key, val) { 15 | this.storageLocalMap.set(key, val); 16 | }, 17 | 18 | async getRawMessage(hdr) { 19 | let folder = hdr.folder.QueryInterface(Ci.nsIMsgFolder); 20 | let messageUri = folder.generateMessageURI(hdr.messageKey); 21 | const messenger = Cc["@mozilla.org/messenger;1"].createInstance( 22 | Ci.nsIMessenger, 23 | ); 24 | 25 | const streamListener = Cc[ 26 | "@mozilla.org/network/sync-stream-listener;1" 27 | ].createInstance(Ci.nsISyncStreamListener); 28 | 29 | const service = messenger.messageServiceFromURI(messageUri); 30 | 31 | await new Promise((resolve, reject) => { 32 | service.streamMessage( 33 | messageUri, 34 | streamListener, 35 | null, 36 | { 37 | OnStartRunningUrl() {}, 38 | OnStopRunningUrl(url, exitCode) { 39 | console.debug( 40 | `LegacyColumn.getRawMessage.streamListener.OnStopRunning ` + 41 | `received ${streamListener.inputStream.available()} bytes ` + 42 | `(exitCode: ${exitCode})`, 43 | ); 44 | if (exitCode === 0) { 45 | resolve(); 46 | } else { 47 | Cu.reportError(exitCode); 48 | reject(); 49 | } 50 | }, 51 | }, 52 | false, 53 | "", 54 | ); 55 | }).catch((ex) => { 56 | console.error(`Error reading message ${messageUri}`, ex); 57 | }); 58 | 59 | const available = streamListener.inputStream.available(); 60 | if (available > 0) { 61 | const rawMessage = NetUtil.readInputStreamToString( 62 | streamListener.inputStream, 63 | available, 64 | ); 65 | return rawMessage; 66 | } else { 67 | console.debug(`No data available for message ${messageUri}`); 68 | return null; 69 | } 70 | }, 71 | 72 | getHeader(content, header) { 73 | // Get header's value (e.g. "subject: foo bar baz" returns "foo bar baz") 74 | const regex = new RegExp( 75 | `^${header}:([^\r\n]*)\r\n(\\s[^\r\n]*\r\n)*`, 76 | "im", 77 | ); 78 | const hdrContent = content.split(/\r\n\r\n/m)[0] + "\r\n"; 79 | if (regex.test(hdrContent)) { 80 | const hdrLine = hdrContent.match(regex)[0]; 81 | return hdrLine.replace(/[^:]*:/m, "").trim(); 82 | } else { 83 | return undefined; 84 | } 85 | }, 86 | 87 | checkValidSchedule(hdr) { 88 | const instanceUUID = this.preferences.instanceUUID; 89 | const msgId = hdr.getStringProperty("message-id"); 90 | const CTPropertyName = `content-type-${msgId}`; 91 | const msgContentType = this.getStorageLocal(CTPropertyName); 92 | const sendAtStr = hdr.getStringProperty("x-send-later-at"); 93 | const msgUuid = hdr.getStringProperty("x-send-later-uuid"); 94 | const incorrectUUIDMsg = this.SLStatic.i18n.getMessage("incorrectUUID"); 95 | if (!sendAtStr) { 96 | return { valid: false, detail: "Not scheduled" }; 97 | } else if (msgUuid !== instanceUUID) { 98 | return { 99 | valid: false, 100 | detail: `${msgUuid} != ${instanceUUID}`, 101 | msg: incorrectUUIDMsg, 102 | }; 103 | } else if (!msgContentType) { 104 | return { valid: false, detail: "Missing ContentType" }; 105 | } 106 | return { valid: true }; 107 | }, 108 | 109 | getSchedule(hdr) { 110 | const sendAtStr = hdr.getStringProperty("x-send-later-at"); 111 | const recurStr = hdr.getStringProperty("x-send-later-recur"); 112 | const argsStr = hdr.getStringProperty("x-send-later-args"); 113 | const cancelStr = hdr.getStringProperty("x-send-later-cancel-on-reply"); 114 | 115 | const schedule = { sendAt: new Date(sendAtStr) }; 116 | schedule.recur = this.SLStatic.parseRecurSpec(recurStr); 117 | schedule.recur.cancelOnReply = cancelStr === "yes" || cancelStr === "true"; 118 | schedule.recur.args = argsStr; 119 | 120 | return schedule; 121 | }, 122 | }; 123 | 124 | class MessageViewsCustomColumn { 125 | constructor(context, name, tooltip) { 126 | this.context = context; 127 | this.name = name; 128 | this.tooltip = tooltip; 129 | this.columnId = ExtensionCommon.makeWidgetId( 130 | `${context.extension.id}${name}-custom-column`, 131 | ); 132 | this.visibility = new Map(); 133 | this.msgTracker = new Map(); 134 | } 135 | 136 | destroy() { 137 | if (this.destroyed) 138 | throw new Error("Unable to destroy ExtensionScriptParent twice"); 139 | 140 | for (let window of Services.wm.getEnumerator("mail:3pane")) { 141 | try { 142 | window.document.getElementById(this.columnId + "-splitter").remove(); 143 | window.document.getElementById(this.columnId).remove(); 144 | } catch (ex) { 145 | console.error(ex); 146 | } 147 | } 148 | 149 | if (this.observer !== undefined) 150 | Services.obs.removeObserver(this.observer, "MsgCreateDBView"); 151 | 152 | this.destroyed = true; 153 | } 154 | 155 | async addToCurrentWindows() { 156 | for (let window of Services.wm.getEnumerator("mail:3pane")) { 157 | await MessageViewsCustomColumn.waitForWindow(window); 158 | this.addToWindow(window); 159 | } 160 | } 161 | 162 | static waitForWindow(win) { 163 | return new Promise((resolve) => { 164 | if (win.document.readyState == "complete") resolve(); 165 | else win.addEventListener("load", resolve, { once: true }); 166 | }); 167 | } 168 | 169 | setVisible(visible, windowId) { 170 | try { 171 | let windows; 172 | if (windowId) { 173 | let wm = this.context.extension.windowManager.get( 174 | windowId, 175 | this.context, 176 | ); 177 | windows = [wm.window]; 178 | } else { 179 | windows = Services.wm.getEnumerator("mail:3pane"); 180 | } 181 | 182 | for (let window of windows) { 183 | for (let id of [this.columnId, `${this.columnId}-splitter`]) { 184 | let e = window.document.getElementById(id); 185 | if (e && visible) e.removeAttribute("hidden"); 186 | else if (e && !visible) e.setAttribute("hidden", "true"); 187 | } 188 | } 189 | } catch (ex) { 190 | console.error("Unable to set column visible", ex); 191 | } 192 | } 193 | 194 | addToWindow(window) { 195 | if (window.document.getElementById(this.columnId)) { 196 | console.warn("Attempted to add duplicate column", this.columnId); 197 | return; 198 | } 199 | let treecol = window.document.createXULElement("treecol"); 200 | let column = { 201 | id: this.columnId, 202 | flex: "1", 203 | persist: "width hidden ordinal sortDirection", 204 | label: this.name, 205 | tooltiptext: this.tooltip, 206 | hidden: "true", 207 | }; 208 | for (let [key, value] of Object.entries(column)) { 209 | treecol.setAttribute(key, value); 210 | } 211 | let parent = window.document.getElementById("threadCols"); 212 | parent.appendChild(treecol); 213 | let splitter = window.document.createXULElement("splitter"); 214 | splitter.id = this.columnId + "-splitter"; 215 | splitter.classList.add("tree-splitter"); 216 | parent.appendChild(splitter); 217 | 218 | this.addHandlerToWindow(window); 219 | } 220 | 221 | addHandlerToWindow(window) { 222 | let columnHandler = { 223 | getCellText(row, col) { 224 | const hdr = window.gDBView.getMsgHdrAt(row); 225 | const status = LegacyColumn.checkValidSchedule(hdr); 226 | 227 | if (status.detail === "Missing ContentType") { 228 | // The content-type header is not included in the MsgHdr object, so 229 | // we need to actually read the whole message, and find it manually. 230 | LegacyColumn.getRawMessage(hdr).then((rawMessage) => { 231 | const msgId = hdr.getStringProperty("message-id"); 232 | const CTPropertyName = `content-type-${msgId}`; 233 | const msgContentType = LegacyColumn.getHeader( 234 | rawMessage, 235 | "content-type", 236 | ); 237 | LegacyColumn.setStorageLocal(CTPropertyName, msgContentType); 238 | window.gDBView.NoteChange(row, 1, 2); 239 | }); 240 | } 241 | 242 | if (status.valid === true || status.detail === "Missing ContentType") { 243 | const schedule = LegacyColumn.getSchedule(hdr); 244 | const cellTxt = 245 | LegacyColumn.SLStatic.formatScheduleForUIColumn(schedule); 246 | return cellTxt; 247 | } else { 248 | return status.msg || ""; 249 | } 250 | }, 251 | getSortStringForRow(hdr) { 252 | // This should be ignored because isString returns false. Setting it anyway. 253 | return LegacyColumn.SLStatic.padNum(this.getSortLongForRow(hdr), 12); 254 | }, 255 | isString() { 256 | return false; 257 | }, 258 | getCellProperties(row, col, props) {}, 259 | getRowProperties(row, props) {}, 260 | getImageSrc(row, col) { 261 | return null; 262 | }, 263 | getSortLongForRow(hdr) { 264 | const status = LegacyColumn.checkValidSchedule(hdr); 265 | if (status.valid === true) { 266 | const sendAtStr = hdr.getStringProperty("x-send-later-at"); 267 | const sendAt = new Date(sendAtStr); 268 | // Numbers will be truncated. Be sure this fits in 32 bits 269 | return (sendAt.getTime() / 1000) | 0; 270 | } else if (status.detail === "Missing ContentType") { 271 | return (Math.pow(2, 31) - 3) | 0; 272 | } else if (status.detail === "Encrypted") { 273 | return (Math.pow(2, 31) - 2) | 0; 274 | } else { 275 | // Not scheduled or wrong UUID 276 | return (Math.pow(2, 31) - 1) | 0; 277 | } 278 | }, 279 | }; 280 | let columnId = this.columnId; 281 | 282 | this.observer = { 283 | observe(aMsgFolder, aTopic, aData) { 284 | if (window.gDBView) 285 | window.gDBView.addColumnHandler(columnId, columnHandler); 286 | }, 287 | }; 288 | 289 | Services.obs.addObserver(this.observer, "MsgCreateDBView", false); 290 | 291 | if (window.gDBView) this.observer.observe(); 292 | } 293 | } 294 | 295 | var columnHandler = class extends ExtensionCommon.ExtensionAPI { 296 | close() { 297 | for (let column of this.columns.values()) 298 | try { 299 | column.destroy(); 300 | } catch (ex) { 301 | console.error("Unable to destroy column:", ex); 302 | } 303 | 304 | LegacyColumn = undefined; 305 | ExtensionSupport.unregisterWindowListener("customColumnWL"); 306 | } 307 | 308 | getAPI(context) { 309 | context.callOnClose(this); 310 | 311 | let columns = new Map(); 312 | this.columns = columns; 313 | 314 | for (let urlBase of ["utils/sugar-custom.js", "utils/static.js"]) { 315 | let url = context.extension.rootURI.resolve(urlBase); 316 | Services.scriptloader.loadSubScript(url, LegacyColumn); 317 | } 318 | 319 | ExtensionSupport.registerWindowListener("customColumnWL", { 320 | chromeURLs: ["chrome://messenger/content/messenger.xhtml"], 321 | async onLoadWindow(window) { 322 | await MessageViewsCustomColumn.waitForWindow(window); 323 | for (let column of columns.values()) column.addToWindow(window); 324 | }, 325 | }); 326 | 327 | return { 328 | columnHandler: { 329 | async addCustomColumn({ name, tooltip }) { 330 | if (columns.has(name)) 331 | throw new ExtensionUtils.ExtensionError( 332 | "Cannot add columns with the same name", 333 | ); 334 | let column = new MessageViewsCustomColumn(context, name, tooltip); 335 | await column.addToCurrentWindows(); 336 | columns.set(name, column); 337 | }, 338 | 339 | async removeCustomColumn(name) { 340 | let column = columns.get(name); 341 | if (!column) 342 | throw new ExtensionUtils.ExtensionError( 343 | "Cannot remove non-existent column", 344 | ); 345 | column.destroy(); 346 | columns.delete(name); 347 | }, 348 | 349 | async cachePrefs(preferences) { 350 | await LegacyColumn.SLStatic.cachePrefs(preferences); 351 | LegacyColumn.preferences = preferences; 352 | }, 353 | 354 | async setColumnVisible(name, visible, windowId) { 355 | let column = columns.get(name); 356 | if (column) { 357 | column.setVisible(visible, windowId); 358 | } else { 359 | console.error( 360 | "Attempted to update visibility of non-existent column", 361 | ); 362 | } 363 | }, 364 | }, 365 | }; 366 | } 367 | }; 368 | -------------------------------------------------------------------------------- /experiments/legacyColumnSchema.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "namespace": "columnHandler", 4 | "description": "", 5 | "types": [ 6 | { 7 | "id": "ColumnProperties", 8 | "type": "object", 9 | "properties": { 10 | "name": { 11 | "type": "string", 12 | "description": "The name of the column to add." 13 | }, 14 | "tooltip": { 15 | "type": "string", 16 | "description": "The tooltip that will be displayed when the user hovers over the column name.", 17 | "optional": true 18 | } 19 | } 20 | } 21 | ], 22 | "functions": [ 23 | { 24 | "name": "cachePrefs", 25 | "type": "function", 26 | "async": true, 27 | "parameters": [ 28 | { 29 | "name": "preferences", 30 | "type": "any", 31 | "description": "Preferences object" 32 | } 33 | ] 34 | }, 35 | { 36 | "name": "addCustomColumn", 37 | "type": "function", 38 | "description": "", 39 | "async": true, 40 | "parameters": [ 41 | { 42 | "name": "properties", 43 | "$ref": "ColumnProperties" 44 | } 45 | ] 46 | }, 47 | { 48 | "name": "removeCustomColumn", 49 | "type": "function", 50 | "description": "", 51 | "async": true, 52 | "parameters": [] 53 | }, 54 | { 55 | "name": "setColumnVisible", 56 | "type": "function", 57 | "description": "", 58 | "async": true, 59 | "parameters": [ 60 | { 61 | "name": "name", 62 | "type": "string" 63 | }, 64 | { 65 | "name": "visible", 66 | "type": "boolean" 67 | }, 68 | { 69 | "name": "windowId", 70 | "type": "integer", 71 | "optional": true 72 | } 73 | ] 74 | } 75 | ] 76 | } 77 | ] 78 | -------------------------------------------------------------------------------- /experiments/quitter.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "namespace": "quitter", 4 | "functions": [ 5 | { 6 | "name": "setQuitRequestedAlert", 7 | "type": "function", 8 | "async": true, 9 | "parameters": [ 10 | { 11 | "name": "title", 12 | "type": "string" 13 | }, 14 | { 15 | "name": "message", 16 | "type": "string" 17 | } 18 | ] 19 | }, 20 | { 21 | "name": "setQuitGrantedAlert", 22 | "type": "function", 23 | "async": true, 24 | "parameters": [ 25 | { 26 | "name": "title", 27 | "type": "string" 28 | }, 29 | { 30 | "name": "message", 31 | "type": "string" 32 | } 33 | ] 34 | }, 35 | { 36 | "name": "removeQuitRequestedObserver", 37 | "type": "function", 38 | "async": true, 39 | "parameters": [] 40 | }, 41 | { 42 | "name": "removeQuitGrantedObserver", 43 | "type": "function", 44 | "async": true, 45 | "parameters": [] 46 | } 47 | ] 48 | } 49 | ] 50 | -------------------------------------------------------------------------------- /experiments/quitterImplementation.js: -------------------------------------------------------------------------------- 1 | const requestAlerts = new Map(); 2 | const grantedAlerts = new Map(); 3 | let quitConfirmed; 4 | 5 | const QuitObservers = { 6 | requested: { 7 | observe(subject, topic, data) { 8 | if (requestAlerts.size > 0) { 9 | const aWindow = Services.wm.getMostRecentWindow(null); 10 | 11 | quitConfirmed = new Set(); 12 | aWindow.setTimeout(() => { 13 | // In case the quit request is canceled by some other listener, we 14 | // want to ensure that the user still gets prompted *next* time they 15 | // exit, so we'll clear quitConfirmed. 16 | console.log("clearing quitConfirmed"); 17 | quitConfirmed = undefined; 18 | }, 0); 19 | 20 | for (let ext of requestAlerts.keys()) { 21 | quitConfirmed.add(ext); 22 | let alert = requestAlerts.get(ext); 23 | let result = Services.prompt.confirm( 24 | aWindow, 25 | alert.title, 26 | alert.message, 27 | ); 28 | if (!result) { 29 | subject.QueryInterface(Ci.nsISupportsPRBool); 30 | subject.data = true; 31 | break; 32 | } 33 | } 34 | } 35 | }, 36 | }, 37 | 38 | granted: { 39 | observe() { 40 | if (grantedAlerts.size > 0) { 41 | const aWindow = Services.wm.getMostRecentWindow(null); 42 | for (let ext of grantedAlerts.keys()) { 43 | let alert = grantedAlerts.get(ext); 44 | if (quitConfirmed && quitConfirmed.has(ext)) { 45 | // Don't allow an extension to create more than one notification. 46 | continue; 47 | } else { 48 | Services.prompt.alert(aWindow, alert.title, alert.message); 49 | } 50 | } 51 | } 52 | }, 53 | }, 54 | }; 55 | 56 | var quitter = class extends ExtensionCommon.ExtensionAPI { 57 | getAPI(context) { 58 | let { extension } = context; 59 | 60 | context.callOnClose(this); 61 | 62 | // Setup application quit observer 63 | Services.obs.addObserver( 64 | QuitObservers.requested, 65 | "quit-application-requested", 66 | ); 67 | Services.obs.addObserver( 68 | QuitObservers.granted, 69 | "quit-application-granted", 70 | ); 71 | 72 | return { 73 | quitter: { 74 | async setQuitRequestedAlert(title, message) { 75 | requestAlerts.set(extension.id, { title, message }); 76 | }, 77 | async setQuitGrantedAlert(title, message) { 78 | grantedAlerts.set(extension.id, { title, message }); 79 | }, 80 | async removeQuitRequestedObserver() { 81 | if (requestAlerts.has(extension.id)) 82 | requestAlerts.delete(extension.id); 83 | }, 84 | async removeQuitGrantedObserver() { 85 | if (grantedAlerts.has(extension.id)) 86 | grantedAlerts.delete(extension.id); 87 | }, 88 | }, 89 | }; 90 | } 91 | 92 | close() { 93 | console.debug("Removing quit observers"); 94 | Services.obs.removeObserver( 95 | QuitObservers.requested, 96 | "quit-application-requested", 97 | ); 98 | Services.obs.removeObserver( 99 | QuitObservers.granted, 100 | "quit-application-granted", 101 | ); 102 | } 103 | }; 104 | -------------------------------------------------------------------------------- /experiments/sl3u.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "namespace": "SL3U", 4 | "functions": [ 5 | { 6 | "name": "SendMessageWithCheck", 7 | "type": "function", 8 | "async": true, 9 | "parameters": [ 10 | { 11 | "name": "tabId", 12 | "type": "integer" 13 | } 14 | ] 15 | }, 16 | { 17 | "name": "queueSendUnsentMessages", 18 | "type": "function", 19 | "async": true, 20 | "parameters": [] 21 | }, 22 | { 23 | "name": "setLogConsoleLevel", 24 | "type": "function", 25 | "async": true, 26 | "parameters": [ 27 | { 28 | "name": "level", 29 | "type": "string", 30 | "description": "Logging level" 31 | } 32 | ] 33 | }, 34 | { 35 | "name": "expandRecipients", 36 | "type": "function", 37 | "async": true, 38 | "description": "Expand mailing lists into individual recipients.", 39 | "parameters": [ 40 | { 41 | "name": "tabId", 42 | "type": "integer" 43 | }, 44 | { 45 | "name": "field", 46 | "type": "string" 47 | } 48 | ] 49 | }, 50 | { 51 | "name": "signingOrEncryptingMessage", 52 | "type": "function", 53 | "async": true, 54 | "description": "Indicates whether the draft in the specified tab has signing or encryption enabled", 55 | "parameters": [ 56 | { 57 | "name": "tabId", 58 | "type": "integer" 59 | } 60 | ] 61 | }, 62 | { 63 | "name": "GenericPreSendCheck", 64 | "type": "function", 65 | "async": true, 66 | "description": "Check if message is ready to be sent, and alert other extensions.", 67 | "parameters": [] 68 | }, 69 | { 70 | "name": "isDraftsFolder", 71 | "type": "function", 72 | "async": true, 73 | "description": "Check whether folder is a Drafts folder", 74 | "parameters": [ 75 | { 76 | "name": "accountId", 77 | "type": "string", 78 | "description": "Account ID" 79 | }, 80 | { 81 | "name": "path", 82 | "type": "string", 83 | "description": "Folder path" 84 | } 85 | ] 86 | }, 87 | { 88 | "name": "setDispositionState", 89 | "type": "function", 90 | "async": true, 91 | "description": "Set message disposition", 92 | "parameters": [ 93 | { 94 | "name": "messageId", 95 | "type": "integer", 96 | "description": "ID of message to modify." 97 | }, 98 | { 99 | "name": "disposition", 100 | "type": "string", 101 | "description": "Disposition to set ('replied' or 'forwarded')" 102 | } 103 | ] 104 | }, 105 | { 106 | "name": "updateFolder", 107 | "type": "function", 108 | "async": true, 109 | "description": "Force TB to look for new messages in folder", 110 | "parameters": [ 111 | { 112 | "name": "folder", 113 | "$ref": "folders.MailFolder" 114 | } 115 | ] 116 | }, 117 | { 118 | "name": "waitUntilIdle", 119 | "type": "function", 120 | "async": true, 121 | "description": "Wait until folders aren't being modified", 122 | "parameters": [ 123 | { 124 | "name": "folders", 125 | "type": "array", 126 | "items": { 127 | "$ref": "folders.MailFolder" 128 | } 129 | } 130 | ] 131 | }, 132 | { 133 | "name": "expungeOrCompactFolder", 134 | "type": "function", 135 | "async": true, 136 | "description": "Expunge (IMAP) or compact a folder", 137 | "parameters": [ 138 | { 139 | "name": "folder", 140 | "$ref": "folders.MailFolder" 141 | } 142 | ] 143 | }, 144 | { 145 | "name": "saveMessage", 146 | "type": "function", 147 | "async": true, 148 | "description": "Save a message into a folder", 149 | "parameters": [ 150 | { 151 | "name": "content", 152 | "type": "string" 153 | }, 154 | { 155 | "name": "folder", 156 | "$ref": "folders.MailFolder" 157 | }, 158 | { 159 | "name": "markRead", 160 | "type": "boolean" 161 | } 162 | ] 163 | }, 164 | { 165 | "name": "getLegacyPref", 166 | "type": "function", 167 | "description": "Gets a preference.", 168 | "async": true, 169 | "parameters": [ 170 | { 171 | "name": "name", 172 | "type": "string", 173 | "description": "The preference name" 174 | }, 175 | { 176 | "name": "dtype", 177 | "type": "string", 178 | "description": "Data type of the preference" 179 | }, 180 | { 181 | "name": "defVal", 182 | "type": "string", 183 | "description": "Fallback value if nothing is returned." 184 | }, 185 | { 186 | "name": "isFull", 187 | "type": "boolean", 188 | "optional": true, 189 | "description": "True if it's a full Thunderbird preference key, not a Send Later preference." 190 | } 191 | ] 192 | }, 193 | { 194 | "name": "setLegacyPref", 195 | "type": "function", 196 | "description": "Sets a preference.", 197 | "async": true, 198 | "parameters": [ 199 | { 200 | "name": "name", 201 | "type": "string", 202 | "description": "The preference name." 203 | }, 204 | { 205 | "name": "dtype", 206 | "type": "string", 207 | "description": "Data type of the preference." 208 | }, 209 | { 210 | "name": "value", 211 | "type": "string", 212 | "description": "Preference value as a string." 213 | } 214 | ] 215 | }, 216 | { 217 | "name": "setHeader", 218 | "type": "function", 219 | "async": true, 220 | "description": "Add a custom header to a compose message", 221 | "parameters": [ 222 | { 223 | "name": "tabId", 224 | "type": "integer", 225 | "description": "Compose window to modify" 226 | }, 227 | { 228 | "name": "name", 229 | "type": "string", 230 | "description": "Name of the header to set" 231 | }, 232 | { 233 | "name": "value", 234 | "type": "string", 235 | "description": "Value of custom header" 236 | } 237 | ] 238 | }, 239 | { 240 | "name": "setCustomDBHeaders", 241 | "type": "function", 242 | "async": true, 243 | "description": "", 244 | "parameters": [ 245 | { 246 | "name": "requestedHdrs", 247 | "type": "any" 248 | } 249 | ] 250 | }, 251 | { 252 | "name": "findAssociatedDraft", 253 | "type": "function", 254 | "async": true, 255 | "description": "Find whether a composition window is editing an existing draft.", 256 | "parameters": [ 257 | { 258 | "name": "windowId", 259 | "type": "integer", 260 | "optional": true, 261 | "description": "" 262 | } 263 | ] 264 | }, 265 | { 266 | "name": "forceToolbarVisible", 267 | "type": "function", 268 | "async": true, 269 | "description": "Handle when a new compose window is opened.", 270 | "parameters": [ 271 | { 272 | "name": "windowId", 273 | "type": "integer", 274 | "optional": true, 275 | "description": "If unset, the function will run on all available windows. Otherwise just the most recent msgcompose window." 276 | } 277 | ] 278 | }, 279 | { 280 | "name": "hijackComposeWindowKeyBindings", 281 | "type": "function", 282 | "async": true, 283 | "description": "Attach key bindings to compose windows.", 284 | "parameters": [ 285 | { 286 | "name": "windowId", 287 | "type": "integer", 288 | "optional": true, 289 | "description": "If unset, the function will run on all available windows. Otherwise just the most recent msgcompose window." 290 | } 291 | ] 292 | } 293 | ], 294 | "events": [ 295 | { 296 | "name": "onKeyCode", 297 | "type": "function", 298 | "description": "Fires from message compose windows on Alt+Shift+Enter", 299 | "parameters": [] 300 | } 301 | ] 302 | } 303 | ] 304 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | 4 | "name": "__MSG_extensionName__", 5 | 6 | "description": "__MSG_extensionDescription__", 7 | 8 | "version": "10.6.6", 9 | 10 | "homepage_url": "https://github.com/Extended-Thunder/send-later/", 11 | 12 | "icons": { 13 | "48": "ui/icons/icon.png" 14 | }, 15 | 16 | "default_locale": "en", 17 | 18 | "applications": { 19 | "gecko": { 20 | "id": "sendlater3@kamens.us", 21 | "strict_min_version": "126.0", 22 | "strict_max_version": "*" 23 | } 24 | }, 25 | 26 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self';", 27 | 28 | "permissions": [ 29 | "accountsFolders", 30 | "accountsRead", 31 | "activeTab", 32 | "addressBooks", 33 | "alarms", 34 | "compose", 35 | "compose.save", 36 | "compose.send", 37 | "menus", 38 | "messagesDelete", 39 | "messagesImport", 40 | "messagesMove", 41 | "messagesRead", 42 | "messagesUpdate", 43 | "notifications", 44 | "storage", 45 | "tabs" 46 | ], 47 | 48 | "background": { 49 | "scripts": [ 50 | "utils/sugar-custom.js", 51 | "utils/static.js", 52 | "utils/tools.js", 53 | "utils/ical.js", 54 | "background.js" 55 | ] 56 | }, 57 | 58 | "web_accessible_resources": [ 59 | "utils/sugar-custom.js", 60 | "utils/static.js", 61 | "utils/defaultPrefs.json" 62 | ], 63 | 64 | "options_ui": { 65 | "page": "ui/options.html", 66 | "open_in_tab": false, 67 | "browser_style": false 68 | }, 69 | 70 | "compose_action": { 71 | "browser_style": false, 72 | "default_area": "maintoolbar", 73 | "default_icon": "ui/icons/icon.png", 74 | "default_title": "__MSG_extensionName__" 75 | }, 76 | 77 | "commands": { 78 | "send-later-shortcut-1": { 79 | "suggested_key": { "default": "Ctrl+Alt+1" }, 80 | "description": "Execute Send Later's shortcut 1" 81 | }, 82 | "send-later-shortcut-2": { 83 | "suggested_key": { "default": "Ctrl+Alt+2" }, 84 | "description": "Execute Send Later's shortcut 2" 85 | }, 86 | "send-later-shortcut-3": { 87 | "suggested_key": { "default": "Ctrl+Alt+3" }, 88 | "description": "Execute Send Later's shortcut 3" 89 | } 90 | }, 91 | 92 | "message_display_action": { 93 | "browser_style": false, 94 | "default_icon": "ui/icons/icon.png", 95 | "default_popup": "ui/msgDisplayPopup.html", 96 | "default_title": "__MSG_extensionName__" 97 | }, 98 | 99 | "browser_action": { 100 | "browser_style": false, 101 | "default_icon": "ui/icons/icon.png", 102 | "default_label": "__MSG_extensionName__", 103 | "default_title": "__MSG_extensionName__", 104 | "default_popup": "ui/browserActionPopup.html" 105 | }, 106 | 107 | "experiment_apis": { 108 | "SL3U": { 109 | "schema": "experiments/sl3u.json", 110 | "parent": { 111 | "scopes": ["addon_parent"], 112 | "paths": [["SL3U"]], 113 | "script": "experiments/sl3u.js" 114 | } 115 | }, 116 | "columnHandler": { 117 | "schema": "experiments/legacyColumnSchema.json", 118 | "parent": { 119 | "scopes": ["addon_parent"], 120 | "paths": [["columnHandler"]], 121 | "script": "experiments/legacyColumnImplementation.js" 122 | } 123 | }, 124 | "headerView": { 125 | "schema": "experiments/hdrViewSchema.json", 126 | "parent": { 127 | "scopes": ["addon_parent"], 128 | "paths": [["headerView"]], 129 | "script": "experiments/hdrViewImplementation.js" 130 | } 131 | }, 132 | "quitter": { 133 | "schema": "experiments/quitter.json", 134 | "parent": { 135 | "scopes": ["addon_parent"], 136 | "paths": [["quitter"]], 137 | "script": "experiments/quitterImplementation.js" 138 | } 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "prettier": "^3.3.3" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/adjustdaterestrictionstests.js: -------------------------------------------------------------------------------- 1 | exports.init = function () { 2 | function NormalTest(dt, start_time, end_time, days, expected) { 3 | const orig_dt = new Date(dt); 4 | let orig_days; 5 | if (days) { 6 | orig_days = days.slice(); 7 | } 8 | const result = SLStatic.adjustDateForRestrictions( 9 | dt, 10 | start_time, 11 | end_time, 12 | days, 13 | ); 14 | if (orig_dt.getTime() != dt.getTime()) { 15 | throw "adjustDateForRestrictions modified dt!"; 16 | } 17 | if (orig_days && String(orig_days) != String(days)) { 18 | throw "AdjustedDateForRestrictions modified days!"; 19 | } 20 | return ( 21 | expected.getTime() == result.getTime() || 22 | `Expected ${expected}, got ${result}` 23 | ); 24 | } 25 | 26 | SLTests.AddTest("adjustDateForRestrictions no-op", NormalTest, [ 27 | new Date("1/1/2016 10:37:00"), 28 | null, 29 | null, 30 | null, 31 | new Date("1/1/2016 10:37:00"), 32 | ]); 33 | SLTests.AddTest("adjustDateForRestrictions before start", NormalTest, [ 34 | new Date("1/1/2016 05:30:37"), 35 | 830, 36 | 1700, 37 | null, 38 | new Date("1/1/2016 08:30:37"), 39 | ]); 40 | SLTests.AddTest("adjustDateForRestrictions after end", NormalTest, [ 41 | new Date("1/1/2016 18:30:37"), 42 | 830, 43 | 1700, 44 | null, 45 | new Date("1/2/2016 08:30:37"), 46 | ]); 47 | SLTests.AddTest("adjustDateForRestrictions OK time", NormalTest, [ 48 | new Date("1/1/2016 12:37:00"), 49 | 830, 50 | 1700, 51 | null, 52 | new Date("1/1/2016 12:37:00"), 53 | ]); 54 | SLTests.AddTest("adjustDateForRestrictions start edge", NormalTest, [ 55 | new Date("1/1/2016 8:30:00"), 56 | 830, 57 | 1700, 58 | null, 59 | new Date("1/1/2016 8:30:00"), 60 | ]); 61 | SLTests.AddTest("adjustDateForRestrictions end edge", NormalTest, [ 62 | new Date("1/1/2016 17:00:00"), 63 | 830, 64 | 1700, 65 | null, 66 | new Date("1/1/2016 17:00:00"), 67 | ]); 68 | SLTests.AddTest("adjustDateForRestrictions OK day", NormalTest, [ 69 | new Date("1/1/2016 8:30:00"), 70 | null, 71 | null, 72 | [5], 73 | new Date("1/1/2016 8:30:00"), 74 | ]); 75 | SLTests.AddTest("adjustDateForRestrictions later day", NormalTest, [ 76 | new Date("1/1/2016 8:30:00"), 77 | null, 78 | null, 79 | [6], 80 | new Date("1/2/2016 8:30:00"), 81 | ]); 82 | SLTests.AddTest("adjustDateForRestrictions earlier day", NormalTest, [ 83 | new Date("1/1/2016 8:30:00"), 84 | null, 85 | null, 86 | [1, 2, 3], 87 | new Date("1/4/2016 8:30:00"), 88 | ]); 89 | }; 90 | -------------------------------------------------------------------------------- /test/data/01-plaintext.eml: -------------------------------------------------------------------------------- 1 | To: test@example.com 2 | From: test@example.com 3 | Subject: 1 plaintext 4 | Message-ID: <8259dd8e-2293-8765-e720-61dfcd10a6f3@example.com> 5 | Date: Sat, 30 Dec 2017 19:12:38 +0100 6 | User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:59.0) Gecko/20100101 7 | Thunderbird/59.0a1 8 | MIME-Version: 1.0 9 | Content-Type: text/plain; charset=windows-1252; format=flowed 10 | Content-Transfer-Encoding: base64 11 | Content-Language: en-GB 12 | 13 | U2VhcmNoIGZvciBodWh1 14 | 15 | -------------------------------------------------------------------------------- /test/data/01-plaintext.eml.out: -------------------------------------------------------------------------------- 1 | To: test@example.com 2 | From: test@example.com 3 | Subject: new subject 4 | Message-ID: <8259dd8e-2293-8765-e720-61dfcd10a6f3@example.com> 5 | Date: Sat, 30 Dec 2017 19:12:38 +0100 6 | User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:59.0) Gecko/20100101 7 | Thunderbird/59.0a1 8 | MIME-Version: 1.0 9 | Content-Type: text/plain; charset=windows-1252; format=flowed 10 | Content-Transfer-Encoding: base64 11 | Content-Language: en-GB 12 | 13 | U2VhcmNoIGZvciBodWh1 14 | 15 | -------------------------------------------------------------------------------- /test/data/05-HTML+embedded-image.eml: -------------------------------------------------------------------------------- 1 | To: test@example.com 2 | From: test@example.com 3 | Subject: 5 HTML + embedded image 4 | Message-ID: 5 | Date: Sat, 30 Dec 2017 19:26:23 +0100 6 | User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:59.0) Gecko/20100101 7 | Thunderbird/59.0a1 8 | MIME-Version: 1.0 9 | Content-Type: multipart/related; 10 | boundary="------------B2BBD36A919AB2B2F84E2469" 11 | Content-Language: en-GB 12 | 13 | This is a multi-part message in MIME format. 14 | --------------B2BBD36A919AB2B2F84E2469 15 | Content-Type: text/html; charset=windows-1252 16 | Content-Transfer-Encoding: base64 17 | 18 | PGh0bWw+DQogIDxoZWFkPg0KDQogICAgPG1ldGEgaHR0cC1lcXVpdj0iY29udGVudC10eXBl 19 | IiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9d2luZG93cy0xMjUyIj4NCiAgPC9oZWFk 20 | Pg0KICA8Ym9keSB0ZXh0PSIjMDAwMDAwIiBiZ2NvbG9yPSIjRkZGRkZGIj4NCiAgICA8cD48 21 | dHQ+U2VhcmNoIGZvciBodWh1PC90dD48L3A+DQogICAgPHA+PGltZyBzcmM9ImNpZDpwYXJ0 22 | MS44QzVFNkE4MS5EMEMxQjkxQUBqb3Jnay5jb20iIGFsdD0iIj48L3A+DQogIDwvYm9keT4N 23 | CjwvaHRtbD4= 24 | 25 | --------------B2BBD36A919AB2B2F84E2469 26 | Content-Type: image/png; 27 | name="kigaaldcbanejcbi.png" 28 | Content-Transfer-Encoding: base64 29 | Content-ID: 30 | Content-Disposition: inline; 31 | filename="kigaaldcbanejcbi.png" 32 | 33 | iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXNSR0IArs4c6QAAAARnQU1B 34 | AACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAASdEVYdFNvZnR3YXJlAEdyZWVuc2hv 35 | dF5VCAUAAABpSURBVDhP3dA7EoAgDEXR7Ew+bgdx/018BEYyiICtb27FcCig3Z7Im6gK3ZxN 36 | /RcQkb6aK8DjtuRMzMEAiNGvlFpgtyOdEjFz14xA10wA1pg5wLRZAthtVgEm5vGtA4DhvILa 37 | O8A+AuYLy0U5xUUpL8kAAAAASUVORK5CYII= 38 | --------------B2BBD36A919AB2B2F84E2469-- 39 | -------------------------------------------------------------------------------- /test/data/05-HTML+embedded-image.eml.out: -------------------------------------------------------------------------------- 1 | To: test@example.com 2 | From: test@example.com 3 | Subject: 5 HTML + embedded image 4 | Message-ID: 5 | Date: Sat, 30 Dec 2017 19:26:23 +0100 6 | User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:59.0) Gecko/20100101 7 | Thunderbird/59.0a1 8 | MIME-Version: 1.0 9 | Content-Type: multipart/related; 10 | boundary="------------B2BBD36A919AB2B2F84E2469" 11 | Content-Language: en-GB 12 | X-Send-Later-At: Fri, 30 Oct 2020 18:28:18 -0700 13 | 14 | This is a multi-part message in MIME format. 15 | --------------B2BBD36A919AB2B2F84E2469 16 | Content-Type: text/html; charset=windows-1252 17 | Content-Transfer-Encoding: base64 18 | 19 | PGh0bWw+DQogIDxoZWFkPg0KDQogICAgPG1ldGEgaHR0cC1lcXVpdj0iY29udGVudC10eXBl 20 | IiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9d2luZG93cy0xMjUyIj4NCiAgPC9oZWFk 21 | Pg0KICA8Ym9keSB0ZXh0PSIjMDAwMDAwIiBiZ2NvbG9yPSIjRkZGRkZGIj4NCiAgICA8cD48 22 | dHQ+U2VhcmNoIGZvciBodWh1PC90dD48L3A+DQogICAgPHA+PGltZyBzcmM9ImNpZDpwYXJ0 23 | MS44QzVFNkE4MS5EMEMxQjkxQUBqb3Jnay5jb20iIGFsdD0iIj48L3A+DQogIDwvYm9keT4N 24 | CjwvaHRtbD4= 25 | 26 | --------------B2BBD36A919AB2B2F84E2469 27 | Content-Type: image/png; 28 | name="kigaaldcbanejcbi.png" 29 | Content-Transfer-Encoding: base64 30 | Content-ID: 31 | Content-Disposition: inline; 32 | filename="kigaaldcbanejcbi.png" 33 | 34 | iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXNSR0IArs4c6QAAAARnQU1B 35 | AACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAASdEVYdFNvZnR3YXJlAEdyZWVuc2hv 36 | dF5VCAUAAABpSURBVDhP3dA7EoAgDEXR7Ew+bgdx/018BEYyiICtb27FcCig3Z7Im6gK3ZxN 37 | /RcQkb6aK8DjtuRMzMEAiNGvlFpgtyOdEjFz14xA10wA1pg5wLRZAthtVgEm5vGtA4DhvILa 38 | O8A+AuYLy0U5xUUpL8kAAAAASUVORK5CYII= 39 | --------------B2BBD36A919AB2B2F84E2469-- 40 | -------------------------------------------------------------------------------- /test/data/21-plaintext.eml: -------------------------------------------------------------------------------- 1 | X-Send-Later-Recur: baz 2 | To: test@example.com 3 | From: test@example.com 4 | Subject: 21 plaintext 5 | X-Send-Later-At: foo 6 | X-Send-Later-Recur: bar 7 | X-Send-Later-whatever: baz 8 | Message-ID: <8259dd8e-2293-8765-e720-61dfcd10a6f3@example.com> 9 | Date: Sat, 30 Dec 2017 19:12:38 +0100 10 | User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:59.0) Gecko/20100101 11 | Thunderbird/59.0a1 12 | X-Send-Later-cancel-on-reply: foobar 13 | MIME-Version: 1.0 14 | Content-Type: text/plain; charset="utf-8"; format=flowed 15 | Content-Transfer-Encoding: quoted-printable 16 | X-Send-Later-At: bar 17 | Content-Language: en-GB 18 | 19 | Search for h=C3=B6h=C3=B6 20 | Test that we ignore a soft= 21 | break correctly. 22 | X-fake-header: foobar 23 | -------------------------------------------------------------------------------- /test/data/21-plaintext.eml.out: -------------------------------------------------------------------------------- 1 | To: test@example.com 2 | From: test@example.com 3 | Subject: 21 plaintext 4 | Message-ID: <8259dd8e-2293-8765-e720-61dfcd10a6f3@example.com> 5 | Date: Sat, 30 Dec 2017 19:12:38 +0100 6 | User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:59.0) Gecko/20100101 7 | Thunderbird/59.0a1 8 | MIME-Version: 1.0 9 | Content-Type: text/plain; charset="utf-8"; format=flowed 10 | Content-Transfer-Encoding: quoted-printable 11 | Content-Language: en-GB 12 | 13 | Search for h=C3=B6h=C3=B6 14 | Test that we ignore a soft= 15 | break correctly. 16 | X-fake-header: foobar 17 | -------------------------------------------------------------------------------- /test/formatrecurtests.js: -------------------------------------------------------------------------------- 1 | exports.init = function () { 2 | function FormatRecurTest(spec, expected) { 3 | const recur = SLStatic.parseRecurSpec(spec); 4 | const out = SLStatic.formatRecurForUI(recur).replace(/\n/g, " "); 5 | if (out == expected) { 6 | return true; 7 | } else { 8 | return `expected "${expected}", got "${out}"`; 9 | } 10 | } 11 | 12 | const tests = [ 13 | ["none", ""], 14 | ["none between 730 1930", ""], 15 | ["none on 1 3 5", ""], 16 | ["none between 100 600 on 1 3 6", ""], 17 | ["minutely", "Recur minutely"], 18 | ["daily", "Recur daily"], 19 | ["weekly", "Recur weekly"], 20 | ["monthly 3", "Recur monthly"], 21 | ["monthly 0 3", "Recur monthly, 3rd Sunday of the month"], 22 | ["yearly 10 5", "Recur yearly"], 23 | ["function froodle", "Recur according to function “froodle”"], 24 | ["minutely / 5", "Recur every 5 minutes"], 25 | ["minutely between 830 1730", "Recur minutely betw. 8:30 and 17:30"], 26 | [ 27 | "minutely on 1 2 3", 28 | "Recur minutely Only on Monday, Tuesday, and Wednesday", 29 | ], 30 | ["none until 2021-09-16T16:16:24.397Z", ""], 31 | ["none between 730 1930 until 2021-09-16T16:16:24.397Z", ""], 32 | ["none until 2021-09-16T16:16:24.397Z on 1 3 5", ""], 33 | ["none between 100 600 on 1 3 6 until 2021-09-16T16:16:24.397Z", ""], 34 | [ 35 | "minutely until 2021-09-16T16:16:24.397Z", 36 | "Recur minutely until 9/16/2021, 9:16 AM", 37 | ], 38 | [ 39 | "daily until 2021-09-16T16:16:24.397Z", 40 | "Recur daily until 9/16/2021, 9:16 AM", 41 | ], 42 | [ 43 | "weekly until 2021-09-16T16:16:24.397Z", 44 | "Recur weekly until 9/16/2021, 9:16 AM", 45 | ], 46 | [ 47 | "monthly 3 until 2021-09-16T16:16:24.397Z", 48 | "Recur monthly until 9/16/2021, 9:16 AM", 49 | ], 50 | [ 51 | "monthly 0 3 until 2021-09-16T16:16:24.397Z", 52 | "Recur monthly, 3rd Sunday of the month until 9/16/2021, 9:16 AM", 53 | ], 54 | [ 55 | "yearly 10 5 until 2021-09-16T16:16:24.397Z", 56 | "Recur yearly until 9/16/2021, 9:16 AM", 57 | ], 58 | [ 59 | "function froodle until 2021-09-16T16:16:24.397Z", 60 | "Recur according to function “froodle” until 9/16/2021, 9:16 AM", 61 | ], 62 | [ 63 | "minutely / 5 until 2021-09-16T16:16:24.397Z", 64 | "Recur every 5 minutes until 9/16/2021, 9:16 AM", 65 | ], 66 | [ 67 | "minutely until 2021-09-16T16:16:24.397Z between 830 1730", 68 | "Recur minutely betw. 8:30 and 17:30 until 9/16/2021, 9:16 AM", 69 | ], 70 | [ 71 | "minutely on 1 2 3 until 2021-09-16T16:16:24.397Z", 72 | "Recur minutely Only on Monday, Tuesday, and Wednesday until " + 73 | "9/16/2021, 9:16 AM", 74 | ], 75 | ]; 76 | for (const test of tests) { 77 | SLTests.AddTest("FormatRecur " + test[0], FormatRecurTest, test); 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /test/headers_to_dom_element_tests.js: -------------------------------------------------------------------------------- 1 | exports.init = function () { 2 | function popupDOMTest(headers, expected) { 3 | const result = SLStatic.parseHeadersForPopupUICache(headers); 4 | return ( 5 | DeepCompare(result, expected) || 6 | `expected ${ObjToStr(expected)}", got "${ObjToStr(result)}"` 7 | ); 8 | } 9 | 10 | SLTests.AddTest("popupCacheTest simple-norecur", popupDOMTest, [ 11 | { 12 | "x-send-later-at": "2021-01-22T15:31", 13 | "x-send-later-recur": "none", 14 | }, 15 | { 16 | "send-datetime": "1/22/2021, 3:31 PM", 17 | once: true, 18 | minutely: false, 19 | daily: false, 20 | weekly: false, 21 | monthly: false, 22 | yearly: false, 23 | function: false, 24 | }, 25 | ]); 26 | 27 | SLTests.AddTest("popupDOMTest recur every 3 days", popupDOMTest, [ 28 | { 29 | "x-send-later-at": "Fri, 22 Jan 2021 15:31:00 -0800", 30 | "x-send-later-recur": "daily / 3", 31 | }, 32 | { 33 | "send-datetime": "1/22/2021, 3:31 PM", 34 | once: false, 35 | minutely: false, 36 | daily: true, 37 | weekly: false, 38 | monthly: false, 39 | yearly: false, 40 | function: false, 41 | "recur-cancelonreply": false, 42 | "recur-multiplier": 3, 43 | "recur-function-args": "", 44 | sendbetween: false, 45 | sendon: false, 46 | senduntil: false, 47 | }, 48 | ]); 49 | 50 | SLTests.AddTest( 51 | "popupDOMTest recur every other month on the second Friday", 52 | popupDOMTest, 53 | [ 54 | { 55 | "x-send-later-at": "Fri, 22 Jan 2021 15:31:00 -0800", 56 | "x-send-later-recur": "monthly 6 2 / 2", 57 | }, 58 | { 59 | "send-datetime": "1/22/2021, 3:31 PM", 60 | once: false, 61 | minutely: false, 62 | daily: false, 63 | weekly: false, 64 | monthly: true, 65 | yearly: false, 66 | function: false, 67 | "recur-cancelonreply": false, 68 | "recur-multiplier": 2, 69 | "recur-function-args": "", 70 | "recur-monthly-byweek": true, 71 | "recur-monthly-byweek-day": "6", 72 | "recur-monthly-byweek-week": "2", 73 | sendbetween: false, 74 | sendon: false, 75 | senduntil: false, 76 | }, 77 | ], 78 | ); 79 | 80 | SLTests.AddTest( 81 | "popupDOMTest recur every 3 days with time limit", 82 | popupDOMTest, 83 | [ 84 | { 85 | "x-send-later-at": "Fri, 22 Jan 2021 15:31:00 -0800", 86 | "x-send-later-recur": "daily / 3 until 2021-09-16T16:16:24.397Z", 87 | }, 88 | { 89 | "send-datetime": "1/22/2021, 3:31 PM", 90 | once: false, 91 | minutely: false, 92 | daily: true, 93 | weekly: false, 94 | monthly: false, 95 | yearly: false, 96 | function: false, 97 | "recur-cancelonreply": false, 98 | "recur-multiplier": 3, 99 | "recur-function-args": "", 100 | sendbetween: false, 101 | sendon: false, 102 | senduntil: true, 103 | "senduntil-date": "2021-09-16", 104 | "senduntil-time": "16:16", 105 | }, 106 | ], 107 | ); 108 | 109 | SLTests.AddTest( 110 | "popupDOMTest recur every other month on the second Friday with time limit", 111 | popupDOMTest, 112 | [ 113 | { 114 | "x-send-later-at": "Fri, 22 Jan 2021 15:31:00 -0800", 115 | "x-send-later-recur": "monthly 6 2 / 2 until 2021-05-01T23:16:24.397Z", 116 | }, 117 | { 118 | "send-datetime": "1/22/2021, 3:31 PM", 119 | once: false, 120 | minutely: false, 121 | daily: false, 122 | weekly: false, 123 | monthly: true, 124 | yearly: false, 125 | function: false, 126 | "recur-cancelonreply": false, 127 | "recur-multiplier": 2, 128 | "recur-function-args": "", 129 | "recur-monthly-byweek": true, 130 | "recur-monthly-byweek-day": "6", 131 | "recur-monthly-byweek-week": "2", 132 | sendbetween: false, 133 | sendon: false, 134 | senduntil: true, 135 | "senduntil-date": "2021-05-01", 136 | "senduntil-time": "23:16", 137 | }, 138 | ], 139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /test/mimetests.js: -------------------------------------------------------------------------------- 1 | exports.init = function () { 2 | const fs = require("fs"); 3 | const dmp = require("./diff_match_patch.js"); 4 | 5 | function diff(aText, bText) { 6 | const differencer = new dmp.diff_match_patch(); 7 | const a = differencer.diff_linesToChars_(aText, bText); 8 | const lineText1 = a.chars1; 9 | const lineText2 = a.chars2; 10 | const lineArray = a.lineArray; 11 | const diffs = differencer.diff_main(lineText1, lineText2, false); 12 | differencer.diff_charsToLines_(diffs, lineArray); 13 | return diffs; 14 | } 15 | 16 | SLTests.AddTest( 17 | "MimeTests 01-plaintext.eml", 18 | (hdrstring, newvalue) => { 19 | const original = fs.readFileSync("test/data/01-plaintext.eml", { 20 | encoding: "utf-8", 21 | }); 22 | const expected = fs.readFileSync("test/data/01-plaintext.eml.out", { 23 | encoding: "utf-8", 24 | }); 25 | let result = original; 26 | 27 | result = SLStatic.replaceHeader(result, hdrstring, newvalue, false); 28 | 29 | if (result === expected) { 30 | return true; 31 | } else { 32 | const diffs = diff(result, expected); 33 | return `Replace headers failed with difference:\n${diffs}`; 34 | } 35 | }, 36 | ["Subject", "new subject"], 37 | ); 38 | 39 | function getHeaderTest(data, hdrstring, expected) { 40 | const original = fs.readFileSync(data, { encoding: "utf-8" }); 41 | result = SLStatic.getHeader(original, hdrstring); 42 | return result === expected || `Expected "${expected}", got "${result}".`; 43 | } 44 | 45 | SLTests.AddTest("MimeTests getHeader (simple)", getHeaderTest, [ 46 | "test/data/01-plaintext.eml", 47 | "Subject", 48 | "1 plaintext", 49 | ]); 50 | 51 | SLTests.AddTest("MimeTests getHeader (multi-line)", getHeaderTest, [ 52 | "test/data/01-plaintext.eml", 53 | "User-Agent", 54 | "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:59.0) Gecko/20100101 " + 55 | "Thunderbird/59.0a1", 56 | ]); 57 | 58 | SLTests.AddTest("MimeTests getHeader (first line)", getHeaderTest, [ 59 | "test/data/01-plaintext.eml", 60 | "To", 61 | "test@example.com", 62 | ]); 63 | 64 | SLTests.AddTest("MimeTests getHeader (last header line)", getHeaderTest, [ 65 | "test/data/01-plaintext.eml", 66 | "Content-Language", 67 | "en-GB", 68 | ]); 69 | 70 | SLTests.AddTest("MimeTests getHeader (case sensitivity)", getHeaderTest, [ 71 | "test/data/01-plaintext.eml", 72 | "cONtEnt-lAngUage", 73 | "en-GB", 74 | ]); 75 | 76 | SLTests.AddTest( 77 | "MimeTests getHeader (multipart repeated header)", 78 | getHeaderTest, 79 | [ 80 | "test/data/05-HTML+embedded-image.eml", 81 | "Content-Type", 82 | 'multipart/related; boundary="------------B2BBD36A919AB2B2F84E2469"', 83 | ], 84 | ); 85 | 86 | SLTests.AddTest( 87 | "MimeTests getHeader (multipart missing header)", 88 | getHeaderTest, 89 | [ 90 | "test/data/05-HTML+embedded-image.eml", 91 | "Content-Transfer-Encoding", 92 | undefined, 93 | ], 94 | ); 95 | 96 | SLTests.AddTest( 97 | "MimeTests getHeader (multipart invalid header)", 98 | getHeaderTest, 99 | ["test/data/21-plaintext.eml", "X-fake-header", undefined], 100 | ); 101 | 102 | SLTests.AddTest( 103 | "MimeTests 21-plaintext.eml", 104 | () => { 105 | const original = fs.readFileSync("test/data/21-plaintext.eml", { 106 | encoding: "utf-8", 107 | }); 108 | const expected = fs.readFileSync("test/data/21-plaintext.eml.out", { 109 | encoding: "utf-8", 110 | }); 111 | let result = original; 112 | 113 | result = SLStatic.replaceHeader( 114 | result, 115 | "X-Send-Later-[a-zA-Z0-9-]*", 116 | null, 117 | true, 118 | ); 119 | 120 | if (result === expected) { 121 | return true; 122 | } else { 123 | const diffs = diff(result, expected); 124 | return `Replace headers failed with difference:\n${diffs}`; 125 | } 126 | }, 127 | [], 128 | ); 129 | 130 | SLTests.AddTest( 131 | "MimeTests 05-HTML+embedded-image.eml", 132 | (hdrstring, newvalue) => { 133 | const original = fs.readFileSync( 134 | "test/data/05-HTML+embedded-image.eml", 135 | { 136 | encoding: "utf-8", 137 | }, 138 | ); 139 | const expected = fs.readFileSync( 140 | "test/data/05-HTML+embedded-image.eml.out", 141 | { encoding: "utf-8" }, 142 | ); 143 | let result = original; 144 | 145 | result = SLStatic.appendHeader( 146 | result, 147 | hdrstring, 148 | "RANDOM INTERMEDIATE VALUE 1", 149 | ); 150 | result = SLStatic.appendHeader( 151 | result, 152 | hdrstring, 153 | "RANDOM INTERMEDIATE VALUE 2", 154 | ); 155 | result = SLStatic.appendHeader( 156 | result, 157 | hdrstring, 158 | "RANDOM INTERMEDIATE VALUE 3", 159 | ); 160 | result = SLStatic.replaceHeader(result, hdrstring, newvalue, true); 161 | 162 | if (result === expected) { 163 | return true; 164 | } else { 165 | const diffs = diff(result, expected); 166 | return `Replace headers failed with difference:\n${diffs}`; 167 | } 168 | }, 169 | ["X-Send-Later-At", "Fri, 30 Oct 2020 18:28:18 -0700"], 170 | ); 171 | }; 172 | -------------------------------------------------------------------------------- /test/miscellaneoustests.js: -------------------------------------------------------------------------------- 1 | exports.init = function () { 2 | // // Example 3 | // SLTests.AddTest("Test name", (input, expected) => { 4 | // const result = input+"foobar"; 5 | // return (result === expected) ||`Expected "${expected}", got "${result}"`; 6 | // }, ['arg1','arg1foobar']); 7 | 8 | SLTests.AddTest( 9 | "Test flattener", 10 | (input, expected) => { 11 | const result = SLStatic.flatten(input); 12 | return ( 13 | JSON.stringify(result) === JSON.stringify(expected) || 14 | `Expected "${expected}", got "${result}"` 15 | ); 16 | }, 17 | [ 18 | [[2, 3, 4, 1], [1, 5, 2], [1], [44, 4], [7]], 19 | [2, 3, 4, 1, 1, 5, 2, 1, 44, 4, 7], 20 | ], 21 | ); 22 | 23 | SLTests.AddTest( 24 | "Test unparseArgs", 25 | (input, expected) => { 26 | const result = SLStatic.unparseArgs(input); 27 | return result === expected || `Expected "${expected}", got "${result}"`; 28 | }, 29 | [[1, 2, "foo", [1, "bar"], 6], '1, 2, "foo", [1, "bar"], 6'], 30 | ); 31 | 32 | function testEstimatedSendTime(scheduledDate, previousLoop, loopMinutes) { 33 | scheduledDate = SLStatic.floorDateTime(scheduledDate); 34 | 35 | let result = SLStatic.estimateSendTime( 36 | scheduledDate, 37 | previousLoop, 38 | loopMinutes, 39 | ); 40 | 41 | if (result.getTime() < Date.now()) 42 | return `Estimated send time is in the past: ${result}`; 43 | 44 | if (result.getTime() < scheduledDate.getTime()) 45 | return `Estimated send time is before scheduled send time: ${result}`; 46 | 47 | if ( 48 | result.getTime() - scheduledDate.getTime() > loopMinutes * 60000 && 49 | result.getTime() - Date.now() > loopMinutes * 60000 50 | ) 51 | return `Estimated send time is > ${loopMinutes} past scheduled time.`; 52 | 53 | return true; 54 | } 55 | 56 | SLTests.AddTest("Test estimate send time", testEstimatedSendTime, [ 57 | new Date(), 58 | new Date(Date.now()), 59 | 1, 60 | ]); 61 | 62 | SLTests.AddTest("Test estimate send time 2", testEstimatedSendTime, [ 63 | new Date(), 64 | new Date(Date.now() - 30e3), 65 | 1, 66 | ]); 67 | 68 | SLTests.AddTest("Test estimate send time 3", testEstimatedSendTime, [ 69 | new Date(), 70 | new Date(Date.now() - 300e3), 71 | 1, 72 | ]); 73 | 74 | SLTests.AddTest("Test estimate send time 4", testEstimatedSendTime, [ 75 | new Date(), 76 | new Date(Date.now() - 120e3), 77 | 5, 78 | ]); 79 | }; 80 | -------------------------------------------------------------------------------- /test/nextrecurtests.js: -------------------------------------------------------------------------------- 1 | exports.init = function () { 2 | async function NextRecurNormalTest(sendat, recur, now, expected) { 3 | let result; 4 | try { 5 | result = await SLStatic.nextRecurDate( 6 | new Date(sendat), 7 | recur, 8 | new Date(now), 9 | ); 10 | } catch (ex) { 11 | return "Unexpected error: " + ex; 12 | } 13 | expected = new Date(expected); 14 | if (result.sendAt.getTime() == expected.getTime()) { 15 | return true; 16 | } else { 17 | return `Expected ${expected}, got ${result.sendAt}`; 18 | } 19 | } 20 | 21 | async function NextRecurExceptionTest(sendat, recur, now, expected) { 22 | let result; 23 | try { 24 | result = await SLStatic.nextRecurDate( 25 | new Date(sendat), 26 | recur, 27 | new Date(now), 28 | ); 29 | return "Expected exception, got " + result.sendAt; 30 | } catch (ex) { 31 | if ((ex + "").indexOf(expected) !== -1) { 32 | return true; 33 | } else { 34 | return `Expected exception matching ${expected}, got ${ex.message}`; 35 | } 36 | } 37 | } 38 | 39 | async function NextRecurFunctionTest( 40 | sendat, 41 | recur, 42 | now, 43 | args, 44 | func_name, 45 | func, 46 | expected, 47 | ) { 48 | SLStatic.mockStorage.ufuncs[func_name] = 49 | func === undefined ? undefined : { body: func }; 50 | let result; 51 | try { 52 | now = new Date(now); 53 | sendat = new Date(sendat); 54 | result = await SLStatic.nextRecurDate(sendat, recur, now, args); 55 | delete SLStatic.mockStorage.ufuncs[func_name]; 56 | } catch (ex) { 57 | delete SLStatic.mockStorage.ufuncs[func_name]; 58 | return "Unexpected error: " + ex.message; 59 | } 60 | if (DeepCompare(result, expected)) { 61 | return true; 62 | } else { 63 | return `Expected ${expected}, got ${result}`; 64 | } 65 | } 66 | 67 | async function NextRecurFunctionErrorTest( 68 | sendat, 69 | recur, 70 | now, 71 | func_name, 72 | func, 73 | expected, 74 | ) { 75 | SLStatic.mockStorage.ufuncs[func_name] = 76 | func === undefined ? undefined : { body: func }; 77 | let result; 78 | result = await SLStatic.nextRecurDate( 79 | new Date(sendat), 80 | recur, 81 | new Date(now), 82 | ); 83 | delete SLStatic.mockStorage.ufuncs[func_name]; 84 | if ((result.error + "").indexOf(expected) != -1) { 85 | return true; 86 | } else { 87 | return `Expected error matching ${expected}, got ${result.error}`; 88 | } 89 | } 90 | 91 | SLTests.AddTest("nextRecurDate daily", NextRecurNormalTest, [ 92 | "1/1/2012", 93 | "daily", 94 | "1/1/2012", 95 | "1/2/2012", 96 | ]); 97 | SLTests.AddTest("nextRecurDate weekly", NextRecurNormalTest, [ 98 | "1/2/2012", 99 | "weekly", 100 | "1/10/2012", 101 | "1/16/2012", 102 | ]); 103 | SLTests.AddTest("nextRecurDate monthly 5", NextRecurNormalTest, [ 104 | "1/5/2012", 105 | "monthly 5", 106 | "1/5/2012", 107 | "2/5/2012", 108 | ]); 109 | SLTests.AddTest("nextRecurDate monthly 30", NextRecurNormalTest, [ 110 | "3/1/2012", 111 | "monthly 30", 112 | "3/1/2012", 113 | "3/30/2012", 114 | ]); 115 | SLTests.AddTest("nextRecurDate monthly 0 3", NextRecurNormalTest, [ 116 | "4/15/2012", 117 | "monthly 0 3", 118 | "4/15/2012", 119 | "5/20/2012", 120 | ]); 121 | SLTests.AddTest("nextRecurDate monthly 0 5", NextRecurNormalTest, [ 122 | "1/29/2012", 123 | "monthly 0 5", 124 | "1/30/2012", 125 | "4/29/2012", 126 | ]); 127 | SLTests.AddTest("nextRecurDate yearly 1 29", NextRecurNormalTest, [ 128 | "2/29/2012", 129 | "yearly 1 29", 130 | "2/29/2012", 131 | "3/1/2013", 132 | ]); 133 | SLTests.AddTest("nextRecurDate yearly 1 29 / 3", NextRecurNormalTest, [ 134 | "3/1/2013", 135 | "yearly 1 29 / 3", 136 | "3/1/2013", 137 | "2/29/2016", 138 | ]); 139 | SLTests.AddTest("nextRecurDate minutely timely", NextRecurNormalTest, [ 140 | "1/1/2012 11:26:37", 141 | "minutely", 142 | "1/1/2012 11:26:50", 143 | "1/1/2012 11:27:37", 144 | ]); 145 | SLTests.AddTest("nextRecurDate minutely late", NextRecurNormalTest, [ 146 | "1/1/2012 11:26:37", 147 | "minutely", 148 | "1/1/2012 11:29:50", 149 | "1/1/2012 11:30:37", 150 | ]); 151 | SLTests.AddTest("nextRecurDate minutely / 5 timely", NextRecurNormalTest, [ 152 | "1/1/2012 11:26:37", 153 | "minutely / 5", 154 | "1/1/2012 11:26:50", 155 | "1/1/2012 11:31:37", 156 | ]); 157 | SLTests.AddTest("nextRecurDate minutely / 5 late", NextRecurNormalTest, [ 158 | "1/1/2012 11:26:37", 159 | "minutely / 5", 160 | "1/1/2012 11:35:05", 161 | "1/1/2012 11:35:37", 162 | ]); 163 | 164 | SLTests.AddTest( 165 | "nextRecurDate nonexistent function", 166 | NextRecurExceptionTest, 167 | ["10/3/2012", "function foo", undefined, "is not defined"], 168 | ); 169 | 170 | SLTests.AddTest( 171 | "nextRecurDate function doesn't return a value", 172 | NextRecurFunctionErrorTest, 173 | [ 174 | "10/3/2012", 175 | "function Test1", 176 | "10/3/2012", 177 | "Test1", 178 | "return undefined", 179 | "did not return a value", 180 | ], 181 | ); 182 | SLTests.AddTest( 183 | "nextRecurDate function doesn't return number or array", 184 | NextRecurFunctionErrorTest, 185 | [ 186 | "10/3/2012", 187 | "function Test2", 188 | "10/3/2012", 189 | "Test2", 190 | 'return "foo"', 191 | "did not return number, Date, or array", 192 | ], 193 | ); 194 | SLTests.AddTest( 195 | "nextRecurDate function returns too-short array", 196 | NextRecurFunctionErrorTest, 197 | [ 198 | "10/3/2012", 199 | "function Test3", 200 | "10/3/2012", 201 | "Test3", 202 | "return new Array()", 203 | "is too short", 204 | ], 205 | ); 206 | SLTests.AddTest( 207 | "nextRecurDate function did not start with a number", 208 | NextRecurFunctionErrorTest, 209 | [ 210 | "10/3/2012", 211 | "function Test4", 212 | "10/3/2012", 213 | "Test4", 214 | 'return new Array("monthly", "extra");', 215 | "did not start with a number", 216 | ], 217 | ); 218 | SLTests.AddTest( 219 | "nextRecurDate function finished recurring", 220 | NextRecurFunctionTest, 221 | [ 222 | "10/3/2012", 223 | "function Test5", 224 | "10/3/2012", 225 | null, 226 | "Test5", 227 | "return -1;", 228 | { sendAt: null }, 229 | ], 230 | ); 231 | 232 | const d1 = new Date(); 233 | d1.setTime(new Date("10/3/2012").getTime() + 5 * 60 * 1000); 234 | SLTests.AddTest( 235 | "nextRecurDate function returning minutes", 236 | NextRecurFunctionTest, 237 | [ 238 | "10/3/2012", 239 | "function Test6", 240 | "10/4/2012", 241 | null, 242 | "Test6", 243 | "return 5", 244 | { sendAt: d1 }, 245 | ], 246 | ); 247 | 248 | const d2 = new Date(); 249 | d2.setTime(new Date("10/3/2012").getTime() + 7 * 60 * 1000); 250 | SLTests.AddTest( 251 | "nextRecurDate function returning array", 252 | NextRecurFunctionTest, 253 | [ 254 | "10/3/2012", 255 | "function Test7", 256 | "10/4/2012", 257 | null, 258 | "Test7", 259 | 'return (new Array(7, "monthly 5"));', 260 | { 261 | sendAt: d2, 262 | nextspec: "monthly 5", 263 | nextargs: undefined, 264 | error: undefined, 265 | }, 266 | ], 267 | ); 268 | SLTests.AddTest( 269 | "nextRecurDate function returning array with args", 270 | NextRecurFunctionTest, 271 | [ 272 | "10/3/2012", 273 | "function Test8", 274 | "10/4/2012", 275 | ["froodle"], 276 | "Test8", 277 | 'if (args[0] !== "froodle") ' + 278 | "{ throw `bad args: ${args}`; }" + 279 | 'else { return [7, "monthly 5", "freeble"]; }', 280 | { 281 | sendAt: d2, 282 | nextspec: "monthly 5", 283 | nextargs: "freeble", 284 | error: undefined, 285 | }, 286 | ], 287 | ); 288 | SLTests.AddTest( 289 | "nextRecurDate function returning array with args and " + 290 | "until time restriction", 291 | NextRecurFunctionTest, 292 | [ 293 | "2012-10-04T11:31:00.000Z", // most recent sendAt 294 | "function Test9 until 2012-10-05T11:31:00.000Z", // recurspec 295 | "2012-10-04T11:31:00.000Z", // current date-time 296 | null, // args 297 | "Test9", // func name 298 | 'return [new Date("2012-10-07T11:31:00.000Z"), ' + 299 | '"function Test9 until 2012-10-05T11:31:00.000Z"];', 300 | null, 301 | ], 302 | ); 303 | 304 | // async function NextRecurFunctionTest(sendat, recur, now, args, func_name, 305 | // func, expected) { 306 | 307 | SLTests.AddTest("nextRecurDate between before", NextRecurNormalTest, [ 308 | "3/1/2016 17:00", 309 | "minutely / 600 between 0900 1700", 310 | "3/1/2016 17:01", 311 | "3/2/2016 9:00", 312 | ]); 313 | SLTests.AddTest("nextRecurDate between after", NextRecurNormalTest, [ 314 | "3/1/2016 16:45", 315 | "minutely / 60 between 0900 1700", 316 | "3/1/2016 16:45", 317 | "3/2/2016 9:00", 318 | ]); 319 | SLTests.AddTest("nextRecurDate between ok", NextRecurNormalTest, [ 320 | "3/1/2016 12:45", 321 | "minutely / 60 between 0900 1700", 322 | "3/1/2016 12:46", 323 | "3/1/2016 13:45", 324 | ]); 325 | 326 | SLTests.AddTest("nextRecurDate day match", NextRecurNormalTest, [ 327 | "3/1/2016 12:45", 328 | "minutely on 2", 329 | "3/1/2016 12:45", 330 | "3/1/2016 12:46", 331 | ]); 332 | SLTests.AddTest("nextRecurDate day no match", NextRecurNormalTest, [ 333 | "3/1/2016 12:45", 334 | "minutely on 4 5 6", 335 | "3/1/2016 12:45", 336 | "3/3/2016 12:46", 337 | ]); 338 | }; 339 | -------------------------------------------------------------------------------- /test/parserecurtests.js: -------------------------------------------------------------------------------- 1 | exports.init = function () { 2 | function CompareRecurs(a, b) { 3 | if (!a && !b) return true; 4 | if (!a || !b) return false; 5 | if (a.type != b.type) return false; 6 | if (!a.monthly_day != !b.monthly_day) return false; 7 | if ( 8 | a.monthly_day && 9 | (a.monthly_day.day != b.monthly_day.day || 10 | a.monthly_day.week != b.monthly_day.week) 11 | ) 12 | return false; 13 | if (a.monthly != b.monthly) return false; 14 | if (!a.yearly != !b.yearly) return false; 15 | if ( 16 | a.yearly && 17 | (a.yearly.month != b.yearly.month || a.yearly.date != b.yearly.date) 18 | ) 19 | return false; 20 | if (a.function != b.function) return false; 21 | if (a.multiplier != b.multiplier) return false; 22 | if (!a.between != !b.between) return false; 23 | if ( 24 | a.between && 25 | (a.between.start != b.between.start || a.between.end != b.between.end) 26 | ) 27 | return false; 28 | if (!a.days != !b.days) return false; 29 | if (String(a.days) != String(b.days)) return false; 30 | return true; 31 | } 32 | 33 | function ParseRecurGoodTest(spec, expected) { 34 | const out = SLStatic.parseRecurSpec(spec); 35 | if (CompareRecurs(out, expected)) { 36 | return true; 37 | } else { 38 | return ( 39 | "expected " + JSON.stringify(expected) + ", got " + JSON.stringify(out) 40 | ); 41 | } 42 | } 43 | 44 | const goodTests = [ 45 | ["none", { type: "none" }], 46 | ["minutely", { type: "minutely" }], 47 | ["daily", { type: "daily" }], 48 | ["weekly", { type: "weekly" }], 49 | ["monthly 3", { type: "monthly", monthly: 3 }], 50 | ["monthly 0 3", { type: "monthly", monthly_day: { day: 0, week: 3 } }], 51 | ["yearly 10 5", { type: "yearly", yearly: { month: 10, date: 5 } }], 52 | ["function froodle", { type: "function", function: "froodle" }], 53 | ["minutely / 5", { type: "minutely", multiplier: 5 }], 54 | [ 55 | "minutely between 830 1730", 56 | { 57 | type: "minutely", 58 | between: { 59 | start: "830", 60 | end: "1730", 61 | }, 62 | }, 63 | ], 64 | ["minutely on 1 2 3 4 5", { type: "minutely", days: [1, 2, 3, 4, 5] }], 65 | ]; 66 | 67 | for (let test of goodTests) { 68 | SLTests.AddTest("parseRecurSpec " + test[0], ParseRecurGoodTest, test); 69 | } 70 | 71 | for (let test of goodTests) { 72 | SLTests.AddTest( 73 | `parseUnparseRecurSpec ${test[0]}`, 74 | (spec) => { 75 | const parsed = SLStatic.parseRecurSpec(spec); 76 | const unparsed = SLStatic.unparseRecurSpec(parsed); 77 | return ( 78 | spec === `${unparsed}` || `Expected "${spec}", got "${unparsed}"` 79 | ); 80 | }, 81 | test, 82 | ); 83 | } 84 | 85 | function ParseRecurBadTest(spec, expected) { 86 | try { 87 | const out = SLStatic.parseRecurSpec(spec); 88 | return "expected exception, got " + JSON.stringify(out); 89 | } catch (ex) { 90 | if ((ex + "").indexOf(expected) === -1) { 91 | return "exception " + ex + " did not match " + expected; 92 | } else { 93 | return true; 94 | } 95 | } 96 | } 97 | 98 | const badTests = [ 99 | ["bad-recurrence-type", "Invalid recurrence type"], 100 | ["none extra-arg", "Extra arguments"], 101 | ["monthly bad", "Invalid first monthly argument"], 102 | ["monthly 7 3", "Invalid monthly day argument"], 103 | ["monthly 4 6", "Invalid monthly week argument"], 104 | ["monthly 32", "Invalid monthly date argument"], 105 | ["yearly bad", "Invalid first yearly argument"], 106 | ["yearly 10 bad", "Invalid second yearly argument"], 107 | ["yearly 20 3", "Invalid yearly date"], 108 | ["yearly 10 31", "Invalid yearly date"], 109 | ["yearly 01 30", "Invalid yearly date"], 110 | ["yearly 10 40", "Invalid yearly date"], 111 | ["function", "Invalid function recurrence spec"], 112 | ["function foo bar", "Extra arguments"], 113 | ["daily / bad", "Invalid multiplier argument"], 114 | ["minutely between 11111 1730", "Invalid between start"], 115 | ["daily between 1100 17305", "Invalid between end"], 116 | ["daily extra-argument", "Extra arguments"], 117 | ["minutely on bad", "Day restriction with no days"], 118 | ["minutely on", "Day restriction with no days"], 119 | ["minutely on 8", "Bad restriction day"], 120 | ]; 121 | for (const test of badTests) { 122 | SLTests.AddTest("parseRecurSpec " + test[0], ParseRecurBadTest, test); 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /test/run_tests.js: -------------------------------------------------------------------------------- 1 | global.SLTests = { 2 | UnitTests: [], 3 | 4 | AddTest: function (test_name, test_function, test_args) { 5 | SLTests.UnitTests.push([test_name, test_function, test_args]); 6 | }, 7 | 8 | RunTests: async function (event, names) { 9 | let n_fail = 0; 10 | let n_pass = 0; 11 | for (const params of SLTests.UnitTests) { 12 | const name = params[0]; 13 | const func = params[1]; 14 | const args = params[2]; 15 | 16 | if (names && names.indexOf(name) === -1) { 17 | continue; 18 | } 19 | 20 | await Promise.resolve(func.apply(null, args)) 21 | .then((result) => { 22 | if (result === true) { 23 | console.info(`+ TEST ${name} PASS`); 24 | n_pass += 1; 25 | } else if (result === false) { 26 | console.warn(`- TEST ${name} FAIL`); 27 | n_fail += 1; 28 | } else { 29 | console.warn(`- TEST ${name} FAIL ${result}`); 30 | n_fail += 1; 31 | } 32 | }) 33 | .catch((ex) => { 34 | console.warn(`- TEST ${name} EXCEPTION: ${ex.message}`); 35 | n_fail += 1; 36 | }); 37 | } 38 | if (n_fail === 0) { 39 | console.info(`\n All ${n_pass} tests are passing!\n`); 40 | } else { 41 | console.info(`\n ${n_fail}/${n_pass + n_fail} tests failed.\n`); 42 | } 43 | }, 44 | }; 45 | 46 | global.DeepCompare = (a, b) => { 47 | if (a === b) { 48 | return true; 49 | } else if (a && a.splice) { 50 | if (b && b.splice) { 51 | if (a.length != b.length) { 52 | console.log(a, "!=", b); 53 | return false; 54 | } 55 | for (let i = 0; i < a.length; i++) { 56 | if (!DeepCompare(a[i], b[i])) { 57 | console.log(a[i], "!=", b[i]); 58 | return false; 59 | } 60 | } 61 | return true; 62 | } 63 | console.log(a, "!=", b); 64 | return false; 65 | } else if (b && b.splice) { 66 | console.log(a, "!=", b); 67 | return false; 68 | } else if (a && a.getTime) { 69 | if (b && b.getTime) { 70 | if (a.getTime() == b.getTime()) { 71 | return true; 72 | } else { 73 | console.log(a, "!=", b); 74 | return false; 75 | } 76 | } else { 77 | console.log(a, "!=", b); 78 | return false; 79 | } 80 | } 81 | if (typeof a === "object" && typeof b === "object") { 82 | const aKeys = [...Object.keys(a)]; 83 | const bKeys = [...Object.keys(b)]; 84 | if (DeepCompare(aKeys, bKeys)) { 85 | for (let key of aKeys) { 86 | if (!DeepCompare(a[key], b[key])) { 87 | console.log(a[key], "!=", b[key]); 88 | return false; 89 | } 90 | } 91 | } else { 92 | console.log(aKeys, "!=", bKeys); 93 | return false; 94 | } 95 | return true; 96 | } 97 | return a == b; 98 | }; 99 | 100 | global.ObjToStr = (obj) => { 101 | if (typeof obj === "object") { 102 | let contents = []; 103 | for (let [key, value] of Object.entries(obj)) { 104 | contents.push(`${key}: ${ObjToStr(value)}\n`); 105 | } 106 | 107 | if (contents.length === 1) { 108 | return `{ ${contents[0].trim()} }`; 109 | } else { 110 | let str = "{"; 111 | for (let c of contents) { 112 | str += `\n ${String(c.trim()).replace(/\n/g, "\n ")},`; 113 | } 114 | return str + "\n}"; 115 | } 116 | } else if (typeof obj === "string") { 117 | return `"${obj}"`; 118 | } else { 119 | return "" + obj; 120 | } 121 | }; 122 | 123 | require("../utils/static.js"); 124 | 125 | const testPaths = [ 126 | "./adjustdaterestrictionstests.js", 127 | "./formatrecurtests.js", 128 | "./nextrecurtests.js", 129 | "./parserecurtests.js", 130 | "./mimetests.js", 131 | "./headers_to_dom_element_tests.js", 132 | "./datetime_handling_tests.js", 133 | "./miscellaneoustests.js", 134 | ]; 135 | 136 | for (let i = 0; i < testPaths.length; i++) { 137 | const tests = require(testPaths[i]); 138 | tests.init(); 139 | } 140 | 141 | SLTests.RunTests().catch(console.error); 142 | -------------------------------------------------------------------------------- /ui/browserActionPopup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 57 | 58 | 59 | 60 |
61 | 62 |
63 | 64 |
65 | 66 |
67 | 70 | | 71 | 74 | | 75 | 78 | | 79 | __MSG_contactAuthorLabel__ 82 | | 83 | 86 | | 87 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /ui/browserActionPopup.js: -------------------------------------------------------------------------------- 1 | function truncateString(s, limit) { 2 | if (s.length <= limit) { 3 | return String(s); 4 | } else { 5 | return String(s).substring(0, limit - 3) + "..."; 6 | } 7 | } 8 | 9 | function formatRecipientList(recipients, limit) { 10 | let contactList = recipients.map((contact) => { 11 | let contactName = String(contact).split(/<.*>/)[0].trim(); 12 | return truncateString(contactName, Math.min(limit, 15)); 13 | }); 14 | 15 | let ret = contactList[0]; 16 | for (let i = 1; i < contactList.length; i++) { 17 | let contact = contactList[i]; 18 | if (ret.length + contact.length <= limit - 2) { 19 | ret += ", " + contact; 20 | } else { 21 | let remaining = contactList.length - i; 22 | return `${ret} +${remaining}`; 23 | } 24 | } 25 | return ret; 26 | } 27 | 28 | function formatSchedule(msgData) { 29 | let schedule = { 30 | sendAt: new Date(msgData.sendAt), 31 | recur: SLStatic.parseRecurSpec(msgData.recur), 32 | }; 33 | schedule.recur.cancelOnReply = ["true", "yes"].includes(msgData.cancel); 34 | schedule.recur.args = msgData.args; 35 | return SLStatic.formatScheduleForUIColumn(schedule); 36 | } 37 | 38 | function setCellValue(elt, value, html) { 39 | if (html) elt.innerHTML = value; 40 | else elt.textContent = value; 41 | } 42 | 43 | function makeRow(scheduleStr, recipientsStr, subjectStr, folderStr, isHtml) { 44 | let rowElement = document.createElement("div"); 45 | rowElement.classList.add("div-table-row"); 46 | 47 | let scheduleCell = document.createElement("div"); 48 | scheduleCell.classList.add("div-table-cell"); 49 | setCellValue(scheduleCell, scheduleStr, isHtml); 50 | rowElement.appendChild(scheduleCell); 51 | 52 | let recipientCell = document.createElement("div"); 53 | recipientCell.classList.add("div-table-cell"); 54 | setCellValue(recipientCell, recipientsStr, isHtml); 55 | rowElement.appendChild(recipientCell); 56 | 57 | let subjectCell = document.createElement("div"); 58 | subjectCell.classList.add("div-table-cell"); 59 | setCellValue(subjectCell, subjectStr, isHtml); 60 | subjectCell.style.minWidth = "40%"; 61 | rowElement.appendChild(subjectCell); 62 | 63 | let folderCell = document.createElement("div"); 64 | folderCell.classList.add("div-table-cell"); 65 | setCellValue(folderCell, folderStr, isHtml); 66 | rowElement.appendChild(folderCell); 67 | 68 | return rowElement; 69 | } 70 | 71 | function makeHeader() { 72 | return makeRow( 73 | `${SLStatic.i18n.getMessage("sendAtLabel")}`, 74 | `${SLStatic.i18n.getMessage("recipients")}`, 75 | `${SLStatic.i18n.getMessage("subject")}`, 76 | `${SLStatic.i18n.getMessage("folder")}`, 77 | true, 78 | ); 79 | } 80 | 81 | function init() { 82 | document.getElementById("showPrefsButton").addEventListener("click", () => { 83 | messenger.runtime 84 | .sendMessage({ 85 | action: "showPreferences", 86 | }) 87 | .then(() => window.close()); 88 | }); 89 | document.getElementById("showGuideButton").addEventListener("click", () => { 90 | messenger.runtime 91 | .sendMessage({ 92 | action: "showUserGuide", 93 | }) 94 | .then(() => window.close()); 95 | }); 96 | document.getElementById("showNotesButton").addEventListener("click", () => { 97 | messenger.runtime 98 | .sendMessage({ 99 | action: "showReleaseNotes", 100 | }) 101 | .then(() => window.close()); 102 | }); 103 | document.getElementById("donateButton").addEventListener("click", () => { 104 | messenger.runtime 105 | .sendMessage({ 106 | action: "donateLink", 107 | }) 108 | .then(() => window.close()); 109 | }); 110 | document.getElementById("logButton").addEventListener("click", () => { 111 | messenger.runtime 112 | .sendMessage({ 113 | action: "logLink", 114 | }) 115 | .then(() => window.close()); 116 | }); 117 | messenger.runtime.sendMessage({ action: "getAllSchedules" }).then((res) => { 118 | let headerAdded = false; 119 | let scheduleTable = document.getElementById("scheduleTable"); 120 | res.schedules 121 | .sort( 122 | (a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime(), 123 | ) 124 | .forEach((msgData) => { 125 | if (!headerAdded) { 126 | scheduleTable.appendChild(makeHeader()); 127 | headerAdded = true; 128 | } 129 | scheduleTable.appendChild( 130 | makeRow( 131 | truncateString(formatSchedule(msgData), 40), 132 | formatRecipientList(msgData.recipients, 15), 133 | truncateString(msgData.subject, 40), 134 | msgData.folder, 135 | ), 136 | ); 137 | }); 138 | if (!headerAdded) { 139 | scheduleTable.appendChild( 140 | makeRow(SLStatic.i18n.getMessage("noneScheduled"), "", "", ""), 141 | ); 142 | } 143 | }); 144 | } 145 | 146 | window.addEventListener("load", init, false); 147 | -------------------------------------------------------------------------------- /ui/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Extended-Thunder/send-later/93295abeaf478133cd671e4baf6877f022a6bcb7/ui/icons/icon.png -------------------------------------------------------------------------------- /ui/icons/social-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Extended-Thunder/send-later/93295abeaf478133cd671e4baf6877f022a6bcb7/ui/icons/social-preview.png -------------------------------------------------------------------------------- /ui/icons/social-preview.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Extended-Thunder/send-later/93295abeaf478133cd671e4baf6877f022a6bcb7/ui/icons/social-preview.xcf -------------------------------------------------------------------------------- /ui/log.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | __MSG_internalLogTitle__ 5 | 6 | 7 | 8 | 9 | 10 |

__MSG_internalLogLevelLabel__ 11 | 29 | 31 | 33 |

34 |

35 |   
36 | 
37 | 


--------------------------------------------------------------------------------
/ui/log.js:
--------------------------------------------------------------------------------
 1 | async function onLoad() {
 2 |   messenger.storage.onChanged.addListener(storageChanged);
 3 |   let { preferences, log } = await messenger.storage.local.get({
 4 |     preferences: {},
 5 |     log: "",
 6 |   });
 7 |   document
 8 |     .getElementById("internalLogLevel")
 9 |     .addEventListener("change", logLevelChanged);
10 |   document.getElementById("clearButton").addEventListener("click", clearLog);
11 |   document.getElementById("copyButton").addEventListener("click", copyLog);
12 |   preferencesChanged(preferences);
13 |   logChanged(log);
14 | }
15 | 
16 | async function storageChanged(changes, areaName) {
17 |   if (changes.preferences) preferencesChanged(changes.preferences.newValue);
18 |   if (changes.log) logChanged(changes.log.newValue);
19 | }
20 | 
21 | function preferencesChanged(preferences) {
22 |   document.getElementById("internalLogLevel").value =
23 |     preferences.logStorageLevel || "none";
24 | }
25 | 
26 | function logChanged(log) {
27 |   document.getElementById("logContent").innerText = log;
28 | }
29 | 
30 | async function logLevelChanged() {
31 |   let level = document.getElementById("internalLogLevel").value;
32 |   let { preferences } = await messenger.storage.local.get({ preferences: {} });
33 |   if (preferences.logStorageLevel == level) return;
34 |   preferences.logStorageLevel = level;
35 |   console.log("FOO2", level);
36 |   await messenger.storage.local.set({ preferences });
37 | }
38 | 
39 | async function clearLog() {
40 |   await messenger.storage.local.set({ log: "" });
41 |   // We don't need to explicitly clear the div here because the above storage
42 |   // change will trigger our storage changed listener.
43 | }
44 | 
45 | async function copyLog() {
46 |   let elt = document.getElementById("logContent");
47 |   let text = elt.innerText;
48 |   await navigator.clipboard.writeText(text);
49 | }
50 | 
51 | window.addEventListener("load", onLoad, false);
52 | 


--------------------------------------------------------------------------------
/ui/msgDisplayPopup.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 |   
 5 |     
 6 |     
20 |   
21 | 
22 |   
23 |     
24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /ui/msgDisplayPopup.js: -------------------------------------------------------------------------------- 1 | const SLMsgDisplay = { 2 | async init() { 3 | const tabs = await browser.tabs.query({ 4 | active: true, 5 | currentWindow: true, 6 | }); 7 | const msg = { tabId: tabs[0].id, action: "getScheduleText" }; 8 | const { scheduleTxt, err } = await browser.runtime.sendMessage(msg); 9 | if (err) { 10 | const titleNode = document.createElement("DIV"); 11 | titleNode.textContent = "ERROR"; 12 | titleNode.style.display = "block"; 13 | 14 | const msgNode = document.createElement("DIV"); 15 | msgNode.textContent = "" + err; 16 | titleNode.style.display = "block"; 17 | 18 | const errElement = document.getElementById("error"); 19 | errElement.textContent = ""; 20 | errElement.appendChild(titleNode); 21 | errElement.appendChild(msgNode); 22 | } else { 23 | const contentElement = document.getElementById("content"); 24 | contentElement.textContent = ""; 25 | scheduleTxt.split("\n").forEach((segment) => { 26 | const lineNode = document.createElement("DIV"); 27 | lineNode.style.display = "block"; 28 | lineNode.style.margin = "0px"; 29 | lineNode.textContent = segment.trim(); 30 | contentElement.appendChild(lineNode); 31 | }); 32 | } 33 | }, 34 | }; 35 | 36 | window.addEventListener("load", SLMsgDisplay.init, false); 37 | -------------------------------------------------------------------------------- /ui/notification.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 11 | 12 | 13 | 14 |
15 | 16 |
17 | 21 |
22 | 23 |
24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /ui/notification.js: -------------------------------------------------------------------------------- 1 | function buttonListener(evt) { 2 | let user_check = document.getElementById("userCheck"); 3 | let button_ok = document.getElementById("button_ok"); 4 | messenger.runtime 5 | .sendMessage({ 6 | ok: evt.target === button_ok, 7 | check: user_check.checked, 8 | }) 9 | .then(async () => { 10 | let window = await messenger.windows.getCurrent(); 11 | messenger.windows.remove(window.id); 12 | }); 13 | } 14 | 15 | async function onLoad() { 16 | await SLStatic.cachePrefs(); 17 | 18 | let params = new URL(window.document.location).searchParams; 19 | 20 | let message = document.getElementById("message"); 21 | message.textContent = params.get("message"); 22 | 23 | let button_ok = document.getElementById("button_ok"); 24 | button_ok.value = browser.i18n.getMessage("okay"); 25 | button_ok.addEventListener("click", buttonListener); 26 | 27 | let confirmDiv = document.getElementById("confirmDiv"); 28 | if (/check/i.exec(params.get("type"))) { 29 | confirmDiv.style.display = "block"; 30 | let userCheck = document.getElementById("userCheck"); 31 | let checkboxLabel = document.getElementById("checkboxLabel"); 32 | userCheck.checked = params.get("checked") === "true"; 33 | checkboxLabel.textContent = `${params.get("checkLabel")}`; 34 | } else { 35 | confirmDiv.style.display = "none"; 36 | } 37 | 38 | let button_cancel = document.getElementById("button_cancel"); 39 | if (/confirm/i.exec(params.get("type"))) { 40 | button_cancel.style.display = "inline"; 41 | button_cancel.value = browser.i18n.getMessage("cancel"); 42 | button_cancel.addEventListener("click", buttonListener); 43 | } else { 44 | button_cancel.style.display = "none"; 45 | } 46 | SLStatic.makeContentsVisible(); 47 | } 48 | 49 | window.addEventListener("load", onLoad); 50 | -------------------------------------------------------------------------------- /ui/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | __MSG_extensionName__ 5 | 6 | 7 | 90 | 91 | 92 | 93 |
94 |
95 |
96 |
98 | __MSG_sendAtLabel__ 99 |
100 |
101 | 103 |
104 |
105 |
106 |
107 |
108 | 109 | 110 |
111 |
112 |
113 |
114 | 115 |
116 |
117 |
118 | __MSG_recurLabel__ 119 |
120 |
121 | 125 | 129 | 133 | 137 | 141 | 145 |
146 | 156 | 157 | 205 |
206 |
207 |
208 | 209 |
210 |
211 |
212 |
213 | 217 |
218 |
219 |
220 | 221 | - 222 | 223 |
224 |
225 |
226 |
227 | 228 |
229 |
230 |
231 | 235 |
236 |
237 | 241 | 245 | 249 | 253 |
254 | 258 | 262 | 266 |
267 |
268 |
269 | 270 |
271 |
272 |
273 | 277 |
278 |
279 |
280 | 281 | 282 |
283 |
284 |
285 |
286 |
287 | 288 |
289 | 293 | 297 |
298 | 299 |
300 | 304 |
305 | 306 | 307 | 312 | 317 | 322 | 323 |
308 | 311 | 313 | 316 | 318 | 321 |
324 | 325 | 326 | 333 | 340 | 341 |
327 | 332 | 334 | 339 |
342 |
343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | -------------------------------------------------------------------------------- /ui/telemetry.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Frobnitz 6 | 7 | 8 | 9 | 10 |

11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /ui/telemetry.js: -------------------------------------------------------------------------------- 1 | let privacyUrl = SLStatic.translationURL( 2 | "https://extended-thunder.github.io/send-later/privacy-policy.html", 3 | ); 4 | 5 | function init() { 6 | let name = browser.i18n.getMessage("extensionName"); 7 | let title = browser.i18n.getMessage("TelemetryAskTitle", [name]); 8 | document.title = title; 9 | document.getElementById("TelemetryAskTitle").innerHTML = title; 10 | let pp = browser.i18n.getMessage("PrivacyPolicy"); 11 | let privacyBlurb = `${pp}`; 12 | let text = browser.i18n.getMessage("TelemetryAskText", [name, privacyBlurb]); 13 | document.getElementById("TelemetryAskText").innerHTML = text; 14 | let yesButton = document.getElementById("TelemetryAskYes"); 15 | yesButton.value = browser.i18n.getMessage("TelemetryAskYes"); 16 | yesButton.addEventListener("click", onClickYes); 17 | let noButton = document.getElementById("TelemetryAskNo"); 18 | noButton.value = browser.i18n.getMessage("TelemetryAskNo"); 19 | noButton.addEventListener("click", onClickNo); 20 | } 21 | 22 | async function onClick(enable) { 23 | let { preferences } = await messenger.storage.local.get({ preferences: {} }); 24 | preferences.telemetryAsked = true; 25 | preferences.telemetryEnabled = enable; 26 | await messenger.storage.local.set({ preferences }); 27 | window.close(); 28 | } 29 | 30 | function onClickYes() { 31 | onClick(true); 32 | } 33 | 34 | function onClickNo() { 35 | onClick(false); 36 | } 37 | 38 | window.addEventListener("load", init, false); 39 | -------------------------------------------------------------------------------- /utils/defaultPrefs.json: -------------------------------------------------------------------------------- 1 | { 2 | "checkTimePref": ["number", 1], 3 | "quickOptions1Key": ["string", "1"], 4 | "quickOptions1Label": ["string", "in 15 minutes"], 5 | "quickOptions1funcselect": ["string", "Delay"], 6 | "quickOptions1Args": ["string", "15"], 7 | "quickOptions2Key": ["string", "2"], 8 | "quickOptions2Label": ["string", "in 30 minutes"], 9 | "quickOptions2funcselect": ["string", "Delay"], 10 | "quickOptions2Args": ["string", "30"], 11 | "quickOptions3Key": ["string", "3"], 12 | "quickOptions3Label": ["string", "in 2 hours"], 13 | "quickOptions3funcselect": ["string", "Delay"], 14 | "quickOptions3Args": ["string", "120"], 15 | "accelCtrlfuncselect": ["string", "Delay"], 16 | "accelCtrlArgs": ["string", "5"], 17 | "accelShiftfuncselect": ["string", "BusinessHours"], 18 | "accelShiftArgs": ["string", ""], 19 | "showHeader": ["bool", true], 20 | "showColumn": ["bool", true], 21 | "showStatus": ["bool", true], 22 | "logConsoleLevel": ["string", "info"], 23 | "logStorageLevel": ["string", "none"], 24 | "sendUnsentMsgs": ["bool", true], 25 | "sendDrafts": ["bool", true], 26 | "sendDoesDelay": ["bool", false], 27 | "sendDelay": ["int", 10], 28 | "sendDoesSL": ["bool", false], 29 | "whitelistName": ["string", ""], 30 | "showChangedAlert": ["bool", true], 31 | "showEditAlert": ["bool", true], 32 | "showOutboxAlert": ["bool", true], 33 | "showSendNowAlert": ["bool", true], 34 | "showSkipAlert": ["bool", true], 35 | "altBinding": ["bool", false], 36 | "sendWhileOffline": ["bool", true], 37 | "enforceTimeRestrictions": ["bool", true], 38 | "blockLateMessages": ["bool", true], 39 | "lateGracePeriod": ["int", 2880], 40 | "askQuit": ["bool", true], 41 | "markDraftsRead": ["bool", true], 42 | "instanceUUID": ["string", ""], 43 | "throttleDelay": ["int", 0], 44 | "customizeDateTime": ["boolean", false], 45 | "shortDateTimeFormat": ["string", ""], 46 | "longDateTimeFormat": ["string", ""], 47 | "scheduledDateField": ["boolean", true], 48 | "releaseNotesShow": ["boolean", true], 49 | "releaseNotesVersion": ["string", "0.0.0"], 50 | "telemetryAsked": ["boolean", false], 51 | "telemetryEnabled": ["boolean", true], 52 | "telemetryUUID": ["string", ""], 53 | "telemetryUUIDEnabled": ["boolean", true], 54 | "telemetryURL": ["string", "https://telemetry.kamens.us/"], 55 | "storeInSubfolder": ["boolean", false], 56 | "subfolderName": ["string", ""], 57 | "compactDrafts": ["boolean", false], 58 | "autoUpdateDraftsFolders": ["boolean", false], 59 | "detachedPopup": ["boolean", false] 60 | } 61 | -------------------------------------------------------------------------------- /utils/i18n.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is provided by the addon-developer-support repository at 3 | * https://github.com/thundernest/addon-developer-support 4 | * 5 | * For usage descriptions, please check: 6 | * https://github.com/thundernest/addon-developer-support/tree/master/scripts/i18n 7 | * 8 | * Version 1.1 9 | * 10 | * Derived from: 11 | * http://github.com/piroor/webextensions-lib-l10n 12 | * 13 | * Original license: 14 | * The MIT License, Copyright (c) 2016-2019 YUKI "Piro" Hiroshi 15 | * 16 | */ 17 | 18 | var i18n = { 19 | updateString(string) { 20 | let re = new RegExp(this.keyPrefix + "(.+?)__", "g"); 21 | return string.replace(re, (matched) => { 22 | const key = matched.slice(this.keyPrefix.length, -2); 23 | let rv = this.extension 24 | ? this.extension.localeData.localizeMessage(key) 25 | : messenger.i18n.getMessage(key); 26 | return rv || matched; 27 | }); 28 | }, 29 | 30 | updateSubtree(node) { 31 | const texts = document.evaluate( 32 | 'descendant::text()[contains(self::text(), "' + this.keyPrefix + '")]', 33 | node, 34 | null, 35 | XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, 36 | null, 37 | ); 38 | for (let i = 0, maxi = texts.snapshotLength; i < maxi; i++) { 39 | const text = texts.snapshotItem(i); 40 | if (text.nodeValue.includes(this.keyPrefix)) 41 | text.nodeValue = this.updateString(text.nodeValue); 42 | } 43 | 44 | const attributes = document.evaluate( 45 | 'descendant::*/attribute::*[contains(., "' + this.keyPrefix + '")]', 46 | node, 47 | null, 48 | XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, 49 | null, 50 | ); 51 | for (let i = 0, maxi = attributes.snapshotLength; i < maxi; i++) { 52 | const attribute = attributes.snapshotItem(i); 53 | if (attribute.value.includes(this.keyPrefix)) 54 | attribute.value = this.updateString(attribute.value); 55 | } 56 | }, 57 | 58 | updateDocument(options = {}) { 59 | this.extension = null; 60 | this.keyPrefix = "__MSG_"; 61 | if (options) { 62 | if (options.extension) this.extension = options.extension; 63 | if (options.keyPrefix) this.keyPrefix = options.keyPrefix; 64 | } 65 | this.updateSubtree(document); 66 | }, 67 | }; 68 | 69 | window.addEventListener( 70 | "DOMContentLoaded", 71 | () => { 72 | i18n.updateDocument(); 73 | }, 74 | { once: true }, 75 | ); 76 | --------------------------------------------------------------------------------