├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .vscode └── launch.json ├── DEVELOP.md ├── LICENSE ├── README.md ├── images └── catman │ ├── README │ └── catman.png ├── jsconfig.json ├── package.json ├── screenshots ├── catman001.jpg ├── catman002.jpg ├── catman003.jpg ├── catman004.jpg ├── catman005.jpg ├── catman006.jpg ├── catman007.jpg ├── catman008.jpg ├── catman6_001.jpg ├── catman6_002.jpg ├── catman6_003.jpg └── catman6_004.jpg ├── scripts ├── build.sh ├── fake-data-to-vcf.js ├── gen-icons.sh └── generate-fake-data.py ├── src ├── CONTRIBUTORS.md ├── _locales │ ├── cs │ │ └── messages.json │ ├── de │ │ └── messages.json │ ├── en-US │ │ └── messages.json │ ├── es-AR │ │ └── messages.json │ ├── es-ES │ │ └── messages.json │ ├── fr │ │ └── messages.json │ ├── nl │ │ └── messages.json │ ├── pt-BR │ │ └── messages.json │ ├── ru │ │ └── messages.json │ └── zh-CN │ │ └── messages.json ├── background │ ├── background.html │ └── cacher.mjs ├── external │ ├── METADATA │ ├── email-addresses │ │ ├── .travis.yml │ │ ├── Changes.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bower.json │ │ ├── lib │ │ │ ├── email-addresses.d.ts │ │ │ ├── email-addresses.js │ │ │ └── email-addresses.min.js │ │ ├── package.json │ │ └── test │ │ │ ├── email-addresses.js │ │ │ ├── is_email.js │ │ │ └── tests.xml │ ├── fontawesome │ │ ├── LICENSE.txt │ │ ├── VERSION │ │ ├── css │ │ │ ├── all.css │ │ │ ├── all.min.css │ │ │ ├── brands.css │ │ │ ├── brands.min.css │ │ │ ├── fontawesome.css │ │ │ ├── fontawesome.min.css │ │ │ ├── regular.css │ │ │ ├── regular.min.css │ │ │ ├── solid.css │ │ │ ├── solid.min.css │ │ │ ├── v4-font-face.css │ │ │ ├── v4-font-face.min.css │ │ │ ├── v4-shims.css │ │ │ ├── v4-shims.min.css │ │ │ ├── v5-font-face.css │ │ │ └── v5-font-face.min.css │ │ └── webfonts │ │ │ ├── fa-brands-400.ttf │ │ │ ├── fa-brands-400.woff2 │ │ │ ├── fa-regular-400.ttf │ │ │ ├── fa-regular-400.woff2 │ │ │ ├── fa-solid-900.ttf │ │ │ ├── fa-solid-900.woff2 │ │ │ ├── fa-v4compatibility.ttf │ │ │ └── fa-v4compatibility.woff2 │ ├── ical.js │ └── micromodal.min.js ├── images │ ├── Pulse-96px.gif │ ├── Pulse-96px.license │ ├── Pulse-96px.svg │ ├── icon-16px.png │ ├── icon-32px.png │ └── icon-64px.png ├── manifest.json ├── modules │ ├── cache │ │ ├── addressbook.mjs │ │ ├── category.mjs │ │ ├── index.mjs │ │ ├── listeners.mjs │ │ └── update.mjs │ ├── contacts │ │ ├── category-edit.mjs │ │ └── contact.mjs │ ├── ui │ │ ├── context-menu-utils.mjs │ │ ├── reef.mjs │ │ └── ui.mjs │ └── utils.mjs ├── popup │ ├── address-book-list.mjs │ ├── address-book.css │ ├── category-tree.css │ ├── category-tree.mjs │ ├── compose.mjs │ ├── contact-list.mjs │ ├── contact.css │ ├── context-menu.mjs │ ├── drag-menu.css │ ├── drag-menu.mjs │ ├── error-handler.mjs │ ├── layout.css │ ├── modal.css │ ├── modal.mjs │ ├── popup.html │ ├── popup.mjs │ ├── state.mjs │ └── utils.mjs └── styles.css ├── unused ├── csv │ ├── LICENSE │ ├── SRC │ └── csv.js ├── skin │ ├── arrow-down.svg │ ├── arrow-right.svg │ ├── checkbox-all.png │ ├── checkbox-none.png │ ├── checkbox-some.png │ ├── double.gif │ ├── icon.png │ ├── notok.gif │ ├── ok-double.gif │ ├── ok.gif │ ├── slider-off.png │ └── slider-on.png ├── theme.mjs └── vcf │ ├── LICENSE │ ├── Math.uuid.js │ ├── SRC │ ├── VERSION │ ├── vcard.js │ └── vcf.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | src/external/* -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | }, 7 | extends: "eslint:recommended", 8 | overrides: [], 9 | parserOptions: { 10 | ecmaVersion: "latest", 11 | sourceType: "module", 12 | }, 13 | rules: {}, 14 | globals: { 15 | ICAL: "readonly", 16 | emailAddresses: "readonly", 17 | browser: true, 18 | MicroModal: "readonly", 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.xpi 2 | *.zip 3 | dev.config.json 4 | node_modules 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "TB:Linux", 6 | "type": "firefox", 7 | "request": "launch", 8 | "reAttach": true, 9 | "firefoxExecutable": "${input:thunderbird}", 10 | "reloadOnChange": { 11 | "watch": "${workspaceFolder}/src/**/*.{js,mjs,html,css,json}" 12 | }, 13 | "profileDir": "${input:profile}", 14 | "keepProfileChanges": true, 15 | "internalConsoleOptions": "openOnSessionStart", 16 | "addonPath": "${workspaceFolder}/src/", 17 | "firefoxArgs": ["--start-debugger-server"], 18 | "timeout": 10 19 | } 20 | ], 21 | "inputs": [ 22 | { 23 | "id": "thunderbird", 24 | "type": "command", 25 | "command": "extension.commandvariable.file.content", 26 | "args": { 27 | "fileName": "${workspaceFolder}/dev.config.json", 28 | "json": "content.thunderbird.path", 29 | "default": "/usr/bin/thunderbird" 30 | } 31 | }, 32 | { 33 | "id": "profile", 34 | "type": "command", 35 | "command": "extension.commandvariable.file.content", 36 | "args": { 37 | "fileName": "${workspaceFolder}/dev.config.json", 38 | "json": "content.thunderbird.profile", 39 | "default": "" 40 | } 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /DEVELOP.md: -------------------------------------------------------------------------------- 1 | # How to start working on this project 2 | 3 | To contribute to the code, we assume you already know how to use git and GitHub and you understand HTML, JavaScript and CSS. 4 | 5 | ## Quick Guide 6 | 7 | 1. Clone the repository. 8 | 9 | ```bash 10 | git clone https://github.com/jobisoft/CategoryManager 11 | ``` 12 | 13 | 2. Open it in your favorite editor. (We will use vscode in this example) 14 | 15 | ```bash 16 | code CategoryManager 17 | ``` 18 | 19 | 3. Install the dev dependencies. (Optional) 20 | 21 | This step is optional but it could improve your development experience. 22 | 23 | ```bash 24 | yarn install 25 | ``` 26 | 27 | Now you can run `yarn lint` to see if the code has any linting errors. 28 | 29 | You can also install [the ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) for vscode to get live linting. 30 | 31 | 4. Open Thunderbird and load the extension in debug mode. 32 | 33 | It is recommended to use a new profile for this so that you don't mess up your existing profile. 34 | 35 | 5. Open the inspector for this extension. And make your changes in your IDE. Reload the extension in the inspector to apply your changes. 36 | 37 | 6. Debug your changes using whatever method you want. 38 | 39 | Some helpful resources: 40 | 41 | - [Thunderbird WebExtension API Documentation](https://webextension-api.thunderbird.net/en/stable/#thunderbird-webextension-api-documentation) 42 | - [Debugging Guide](https://extensionworkshop.com/documentation/develop/debugging/#developer-tools-toolbox) 43 | - https://developer.thunderbird.net/ 44 | 45 | 7. When you are done, create a pull request if you want to contribute. 46 | 47 | ## Fake Data 48 | 49 | You can generate some fake data for your debugging purpose. You need to install python3 and a recent version of node.js. 50 | 51 | First, install dependencies. 52 | 53 | ```bash 54 | pip3 install names random-word 55 | ``` 56 | 57 | Then, generate the fake data (`data.json`): 58 | 59 | ```bash 60 | python scripts/generate-fake-data.py 61 | ``` 62 | 63 | And convert it to vCard format: 64 | 65 | ```bash 66 | node scripts/fake-data-to-vcf.js 67 | ``` 68 | 69 | Now you can import `output.vcf` into ThunderBird. 70 | 71 | ## Caveats 72 | 73 | Sometimes the errors from background page won't be logged to the console. You can see them in your terminal if you launched the Thunderbird instance in a shell. 74 | 75 | ## VSCode Debugger 76 | 77 | **Not Recommended.** It has many bugs but I listed it here because it is cool. I only tested it on Linux. 78 | 79 | Install the [Command Variable](https://marketplace.visualstudio.com/items?itemName=rioj7.command-variable) extension in vscode and create `dev.config.json` in the root directory of this repo. 80 | 81 | Fill in the path to thunderbird executable and your profile directory like the following example: 82 | 83 | ```json 84 | { 85 | "thunderbird": { 86 | "path": "/mnt/data/thunderbird/thunderbird-bin", 87 | "profile": "/home/kxxt/.thunderbird/test-profile" 88 | } 89 | } 90 | ``` 91 | 92 | Hit F5 in vscode to start debugging and the extension will hot reload if your code changes. 93 | 94 | Some bugs: 95 | 96 | - Sometimes messages from `console.log/info/...` get lost. 97 | - Sometimes the breakpoint won't hit. 98 | - You might get other weird bugs sometimes. 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Category Manager 2 | Category manager for Thunderbird contacts, also allows to send an email to all members of a category (category based contact groups). 3 | 4 | This add-on scans all contact-cards of the currently selected addressbook, extracts all categories and displays them in a popup available behind a "Categories" button in Thunderbird's main toolbar. It allows to rename and to remove a category (changes the category string in all member contact cards). 5 | 6 | 7 | 8 | **Add-ons for Thunderbird page: [CategoryManager](https://addons.thunderbird.net/en-US/thunderbird/addon/categorymanager/)** 9 | 10 | ## Icon sources 11 | 12 | * [slider-on.png] by [John Bieling](https://github.com/jobisoft/TbSync/blob/master/content/skin/src/LICENSE) 13 | * [slider-off.png] by [John Bieling](https://github.com/jobisoft/TbSync/blob/master/content/skin/src/LICENSE) 14 | * [checkbox-all/some/none.png] by [Cole Bemis](https://www.iconfinder.com/icons/226561/check_square_icon) 15 | * [icon.png] based on 'Venn Diagram' by [WARPAINT Media Inc., CA](https://thenounproject.com/search/?q=three%20circles&i=31898#) from Noun Project ([info](https://github.com/jobisoft/CategoryManager/tree/master/sendtocategory/content/skin/catman)) 16 | 17 | ## Contributing 18 | 19 | Open an issue if you want to ... 20 | 21 | 1. report a bug (Please check if there are existing issues for the bug first) 22 | 2. give us feedbacks and suggestions 23 | 24 | If you know HTML, JavaScript and CSS, you can directly contribute to this repo by creating a pull request. 25 | 26 | Read [DEVELOP.md](./DEVELOP.md) to get started. 27 | -------------------------------------------------------------------------------- /images/catman/README: -------------------------------------------------------------------------------- 1 | This icon was derived from: 2 | https://thenounproject.com/search/?q=three%20circles&i=31898# 3 | 4 | Attribution: Venn Diagram by WARPAINT Media Inc. from the Noun Project -------------------------------------------------------------------------------- /images/catman/catman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/images/catman/catman.png -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeAcquisition": { 3 | // Well, I didn't found type definitions for thunderbird. 4 | "include": ["firefox-webext-browser"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "category-manager", 3 | "version": "1.0.0", 4 | "description": "Category manager for Thunderbird contacts, also allows to send an email to all members of a category (category based contact groups).", 5 | "main": "index.js", 6 | "private": true, 7 | "scripts": { 8 | "lint": "eslint src/ --ext=.js,.mjs" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/jobisoft/CategoryManager.git" 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/jobisoft/CategoryManager/issues" 18 | }, 19 | "homepage": "https://github.com/jobisoft/CategoryManager#readme", 20 | "devDependencies": { 21 | "eslint": "^8.30.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /screenshots/catman001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman001.jpg -------------------------------------------------------------------------------- /screenshots/catman002.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman002.jpg -------------------------------------------------------------------------------- /screenshots/catman003.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman003.jpg -------------------------------------------------------------------------------- /screenshots/catman004.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman004.jpg -------------------------------------------------------------------------------- /screenshots/catman005.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman005.jpg -------------------------------------------------------------------------------- /screenshots/catman006.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman006.jpg -------------------------------------------------------------------------------- /screenshots/catman007.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman007.jpg -------------------------------------------------------------------------------- /screenshots/catman008.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman008.jpg -------------------------------------------------------------------------------- /screenshots/catman6_001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman6_001.jpg -------------------------------------------------------------------------------- /screenshots/catman6_002.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman6_002.jpg -------------------------------------------------------------------------------- /screenshots/catman6_003.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman6_003.jpg -------------------------------------------------------------------------------- /screenshots/catman6_004.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/screenshots/catman6_004.jpg -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | 3 | set -eux 4 | 5 | # Build XPI file 6 | 7 | # Get the path to the containing dir 8 | SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) 9 | 10 | # XPI filename 11 | OUTPUT_FILE='category-manager-ng.xpi' 12 | 13 | cd "$SCRIPT_DIR" 14 | cd .. 15 | 16 | scripts/gen-icons.sh 17 | 18 | rm -f "$OUTPUT_FILE" 19 | 20 | cd src 21 | 22 | zip -r ../"$OUTPUT_FILE" * 23 | -------------------------------------------------------------------------------- /scripts/fake-data-to-vcf.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // convert fake data to vcf 4 | // data path: /src/modules/fake-data-provider.mjs 5 | // output path: ./output.vcf 6 | // change abOfInterest to the address book you want to convert 7 | 8 | const { writeFile } = require("node:fs/promises"); 9 | const path = require("path"); 10 | const url = require("url"); 11 | 12 | const providerPath = url.pathToFileURL( 13 | path.join(__dirname, "../data.json") 14 | ).href; 15 | 16 | async function main() { 17 | const data = (await import(providerPath, { assert: { type: "json" } })).default; 18 | const abOfInterest = data; 19 | const vcards = abOfInterest.map( 20 | ({ name, email, categories }) => `BEGIN:VCARD 21 | VERSION:4.0 22 | EMAIL;PREF=1:${email} 23 | FN:${name} 24 | ${categories.map((cat) => `CATEGORIES:${cat.join(" / ")}`).join("\n")} 25 | END:VCARD` 26 | ); 27 | await writeFile("output.vcf", vcards.join("\n"), (err) => { 28 | console.log(err); 29 | }); 30 | } 31 | 32 | main(); 33 | -------------------------------------------------------------------------------- /scripts/gen-icons.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eux 4 | 5 | # Get the path to the containing dir 6 | SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) 7 | 8 | cd "$SCRIPT_DIR" 9 | 10 | im_resize() { 11 | # resize the original icon to $1x$1 px 12 | convert "../images/catman/catman.png" -resize "$1x$1" "../src/images/icon-$1px.png" 13 | } 14 | 15 | im_resize 64 16 | im_resize 32 17 | im_resize 16 18 | -------------------------------------------------------------------------------- /scripts/generate-fake-data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # requirements: 4 | # pip install names random-word 5 | 6 | # usage: python path-to-this-file.py 7 | # outputs: data.json (in current folder) 8 | # Wait patiently because it might take some time 9 | 10 | 11 | import names 12 | import json 13 | import random 14 | from random_word import RandomWords 15 | from itertools import chain 16 | 17 | rand_word = RandomWords() 18 | 19 | 20 | def generate_entry(): 21 | first_name = names.get_first_name() 22 | last_name = names.get_last_name() 23 | name = f"{first_name} {last_name}" 24 | email = f"{first_name}@{last_name}.example" 25 | return {"name": name, "email": email} 26 | 27 | 28 | def generate_category(max_depth, children_cnt, prefix): 29 | if children_cnt == 0 or max_depth <= 1: 30 | return [[*prefix, rand_word.get_random_word()]] 31 | depth = random.randint(1, max_depth - 1) 32 | next_children_cnt = random.randint(0, children_cnt - 1) 33 | cat_name = rand_word.get_random_word() 34 | subcats = ( 35 | generate_category(depth, next_children_cnt, [*prefix, cat_name]) for _ in range(children_cnt) 36 | ) 37 | flatten = chain(*subcats) 38 | result = [[*prefix, cat_name], *flatten] 39 | print(result) 40 | return result 41 | 42 | 43 | def assign_categories_to_entries(entries, categories, max_cat_cnt): 44 | for entry in entries: 45 | cat_cnt = random.randint(0, max_cat_cnt) 46 | entry["categories"] = [random.choice( 47 | categories) for _ in range(cat_cnt)] 48 | 49 | 50 | def generate(entries_len, cat_root_cnt, max_depth, children_cnt, max_contact_cat_cnt): 51 | contacts = [generate_entry() for _ in range(entries_len)] 52 | categories = list(chain.from_iterable(generate_category( 53 | max_depth, children_cnt, []) for _ in range(cat_root_cnt))) 54 | print(categories) 55 | assign_categories_to_entries(contacts, categories, max_contact_cat_cnt) 56 | return contacts 57 | 58 | 59 | if __name__ == "__main__": 60 | with open("data.json", "w") as f: 61 | json.dump(generate(200, 6, 5, 4, 3), f, indent=2) 62 | -------------------------------------------------------------------------------- /src/CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | ## Developers 2 | * John Bieling 3 | * kxxt 4 | 5 | ## Translators 6 | * John Bieling (de, en-US) 7 | * Pierrick Brun (fr) 8 | * Lisandro Gallo (es-AR) 9 | * Wanderlei Hüttel (pt-BR) 10 | * Alexey Sinitsyn (ru) 11 | * Jan Zaunbrecher (nl) 12 | -------------------------------------------------------------------------------- /src/_locales/cs/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors.contact-update-failure": { 3 | "message": "Aktualizace kontaktu selhala!" 4 | }, 5 | "errors.hint.common-reasons": { 6 | "message": "Časté příčiny této chyby jsou:" 7 | }, 8 | "errors.hint.most-likely": { 9 | "message": "(Nejpravděpodobněji)" 10 | }, 11 | "errors.operation-cancel": { 12 | "message": "Operace zrušena." 13 | }, 14 | "errors.partial-deletion": { 15 | "message": "Nastala chyba při odstraňování této kategorie. Některé kontakty stále patří do této kategorie." 16 | }, 17 | "errors.reason.address-book-readonly": { 18 | "message": "Kniha kontaktů je jen pro čtení." 19 | }, 20 | "errors.reason.contact-changed": { 21 | "message": "Kontakt byl změněn mimo Správce kategorií." 22 | }, 23 | "info.no-address-book": { 24 | "message": "Není k dispozici žádná kniha kontaktů" 25 | }, 26 | "info.spinner-text": { 27 | "message": "Aktualizování..." 28 | }, 29 | "manifest_action_title": { 30 | "message": "Kategorie" 31 | }, 32 | "manifest_description": { 33 | "message": "Správce kategorií pro kontakty. Také umí poslat e-mail všem členům kategorie (kategorie založené na skupinách kontaktů)." 34 | }, 35 | "menu.category.add_members_to_current_message": { 36 | "message": "Přidat členy kategorie do ..." 37 | }, 38 | "menu.category.add_members_to_new_message": { 39 | "message": "Vytvořit novou zprávu se členy kategorie v ..." 40 | }, 41 | "menu.category.add_to_bcc": { 42 | "message": "Skryté kopie" 43 | }, 44 | "menu.category.add_to_cc": { 45 | "message": "Kopie" 46 | }, 47 | "menu.category.add_to_to": { 48 | "message": "Komu" 49 | }, 50 | "menu.category.delete": { 51 | "message": "Odstranit tuto kategorii" 52 | }, 53 | "menu.category.rename": { 54 | "message": "Přejmenovat nebo přesunout kategorii" 55 | }, 56 | "menu.contact.context.add_to_category": { 57 | "message": "Přidat kontakt do kategorie '$CATEGORY$'", 58 | "placeholders": { 59 | "category": { 60 | "content": "$1" 61 | } 62 | } 63 | }, 64 | "menu.contact.context.add_to_new_sub_category": { 65 | "message": "Přidat kontakt do nové podkategorie v kategorii '$CATEGORY$'", 66 | "placeholders": { 67 | "category": { 68 | "content": "$1" 69 | } 70 | } 71 | }, 72 | "menu.contact.context.add_to_new_top_level_category": { 73 | "message": "Přidat kontakt do nové hlavní kategorie" 74 | }, 75 | "menu.contact.context.manage_categories_of_contact": { 76 | "message": "Spravovat kategorie tohoto kontaktu" 77 | }, 78 | "menu.contact.context.remove_from_category": { 79 | "message": "Odebrat kontakt z kategorie '$CATEGORY$'", 80 | "placeholders": { 81 | "category": { 82 | "content": "$1" 83 | } 84 | } 85 | }, 86 | "menu.contact.context.remove_from_category_recursively": { 87 | "message": "Odebrat kontakt z kategorie '$CATEGORY$' a všech jejích podkategorií", 88 | "placeholders": { 89 | "category": { 90 | "content": "$1" 91 | } 92 | } 93 | }, 94 | "menu.contact.drag.add_to_new_category": { 95 | "message": "Vytvořit zde novou kategorii a přidat kontakt" 96 | }, 97 | "menu.contact.drag.add_to_subcategory": { 98 | "message": "Vytvořit zde novou podkategorii a přidat kontakt" 99 | }, 100 | "menu.contact.drag.add_to_this_category": { 101 | "message": "Přidat kontakt do této kategorie" 102 | }, 103 | "popup.error.content.footer": { 104 | "message": "Pokud si myslíte, že se jedná od chybu Správce kategorií, prosíme nahlašte ji" 105 | }, 106 | "popup.error.title": { 107 | "message": "Chyba" 108 | }, 109 | "popup.input.button.cancel": { 110 | "message": "Zrušit" 111 | }, 112 | "popup.input.button.ok": { 113 | "message": "Dobře" 114 | }, 115 | "popup.input.contentHTML": { 116 | "message": "Můžete použít ' / ' (mezeru, lomítko,mezeru) jako rozdělovač pro vytváření podkategorií.
například KategorieA / Kategorie / KategorieC" 117 | }, 118 | "popup.input.title": { 119 | "message": "Prosíme zadejte (pod)kategorii" 120 | }, 121 | "tree.category.all": { 122 | "message": "Všechny kontakty" 123 | }, 124 | "tree.category.none": { 125 | "message": "Nezařazené" 126 | }, 127 | "validation-error.empty-category": { 128 | "message": "Kategorie by neměla být prázdná." 129 | }, 130 | "validation-error.empty-subcategory": { 131 | "message": "Podkategorie by neměla být prázdná." 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/_locales/de/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors.contact-update-failure": { 3 | "message": "Kontakt konnte nicht aktualisiert werden!" 4 | }, 5 | "errors.hint.common-reasons": { 6 | "message": "Häufige Ursachen für diesen Fehler sind:" 7 | }, 8 | "errors.hint.most-likely": { 9 | "message": "(am wahrscheinlichsten)" 10 | }, 11 | "errors.operation-cancel": { 12 | "message": "Vorgang abgebrochen." 13 | }, 14 | "errors.partial-deletion": { 15 | "message": "Beim Löschen der Kategorie ist ein Fehler aufgetreten. Einige Kontakte sind noch immer mit dieser Kategory verknüpft." 16 | }, 17 | "errors.reason.address-book-readonly": { 18 | "message": "Das Adressbuch ist schreibgeschüzt." 19 | }, 20 | "errors.reason.contact-changed": { 21 | "message": "Der Kontakte wurde zwischenzeitlich anderweitig verändert." 22 | }, 23 | "info.no-address-book": { 24 | "message": "Es ist kein Adressbuch verfügbar." 25 | }, 26 | "info.spinner-text": { 27 | "message": "Aktualisierung ..." 28 | }, 29 | "manifest_action_title": { 30 | "message": "Kategorien" 31 | }, 32 | "manifest_description": { 33 | "message": "Kategorie-Verwaltung für Thunderbird-Kontakte. Erlaubt auch das Senden einer E-Mail an alle Kategoriemitglieder, d.h. Kategorien können als Kontaktgruppen verwendet werden." 34 | }, 35 | "menu.category.add_members_to_current_message": { 36 | "message": "Kategoriemitglieder hinzufügen zu ..." 37 | }, 38 | "menu.category.add_members_to_new_message": { 39 | "message": "Neue Nachricht erstellen und Kategoriemitglieder einfügen in ..." 40 | }, 41 | "menu.category.add_to_bcc": { 42 | "message": "Bcc" 43 | }, 44 | "menu.category.add_to_cc": { 45 | "message": "Cc" 46 | }, 47 | "menu.category.add_to_to": { 48 | "message": "An" 49 | }, 50 | "menu.category.delete": { 51 | "message": "Diese Kategorie löschen" 52 | }, 53 | "menu.category.rename": { 54 | "message": "Diese Kategorie umbenennen oder verschieben" 55 | }, 56 | "menu.contact.context.add_to_category": { 57 | "message": "Kontakt zu '$CATEGORY$' hinzufügen", 58 | "placeholders": { 59 | "category": { 60 | "content": "$1" 61 | } 62 | } 63 | }, 64 | "menu.contact.context.add_to_new_sub_category": { 65 | "message": "Kontakt zu neuer Unterkategorie von '$CATEGORY$' hinzufügen", 66 | "placeholders": { 67 | "category": { 68 | "content": "$1" 69 | } 70 | } 71 | }, 72 | "menu.contact.context.add_to_new_top_level_category": { 73 | "message": "Kontakt zu neuer Hauptkategorie hinzufügen" 74 | }, 75 | "menu.contact.context.manage_categories_of_contact": { 76 | "message": "Kategorien dieses Kontakts verwalten" 77 | }, 78 | "menu.contact.context.remove_from_category": { 79 | "message": "Kontakt aus '$CATEGORY$' entfernen", 80 | "placeholders": { 81 | "category": { 82 | "content": "$1" 83 | } 84 | } 85 | }, 86 | "menu.contact.context.remove_from_category_recursively": { 87 | "message": "Kontakt aus '$CATEGORY$' und allen Unterkategorien entfernen", 88 | "placeholders": { 89 | "category": { 90 | "content": "$1" 91 | } 92 | } 93 | }, 94 | "menu.contact.drag.add_to_new_category": { 95 | "message": "Hier neue Kategorie erstellen und Kontakt hinzufügen" 96 | }, 97 | "menu.contact.drag.add_to_subcategory": { 98 | "message": "Hier neue Unterkategorie erstellen und Kontakt hinzufügen" 99 | }, 100 | "menu.contact.drag.add_to_this_category": { 101 | "message": "Kontakt zu dieser Kategorie hinzufügen" 102 | }, 103 | "popup.error.content.footer": { 104 | "message": "Falls sie vermuten, dies ist ein Fehler im Category Manager, erstellen Sie bitte einen entsprechenden Report." 105 | }, 106 | "popup.error.title": { 107 | "message": "Fehler" 108 | }, 109 | "popup.input.button.cancel": { 110 | "message": "Abbrechen" 111 | }, 112 | "popup.input.button.ok": { 113 | "message": "Ok" 114 | }, 115 | "popup.input.contentHTML": { 116 | "message": "Sie können ' / ' als Trenngruppe für Unterkategorien verwenden.
e.g. KategorieA / KategorieB / KategorieC" 117 | }, 118 | "popup.input.title": { 119 | "message": "Bitte den Kategorienamen definieren" 120 | }, 121 | "tree.category.all": { 122 | "message": "Alle Kontakte" 123 | }, 124 | "tree.category.none": { 125 | "message": "Unkategorisiert" 126 | }, 127 | "validation-error.empty-category": { 128 | "message": "Kategorie darf nicht leer sein." 129 | }, 130 | "validation-error.empty-subcategory": { 131 | "message": "Unterkategorie darf nicht leer sein." 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/_locales/en-US/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors.contact-update-failure": { 3 | "message": "Failed to update contact!" 4 | }, 5 | "errors.hint.common-reasons": { 6 | "message": "Common reasons for this error are:" 7 | }, 8 | "errors.hint.most-likely": { 9 | "message": "(Most Likely)" 10 | }, 11 | "errors.operation-cancel": { 12 | "message": "Operation Canceled." 13 | }, 14 | "errors.partial-deletion": { 15 | "message": "An error occurred while deleting this category. Some contacts are still in this category." 16 | }, 17 | "errors.reason.address-book-readonly": { 18 | "message": "The address book is readonly." 19 | }, 20 | "errors.reason.contact-changed": { 21 | "message": "The contact has been changed outside Category Manager." 22 | }, 23 | "info.no-address-book": { 24 | "message": "No address book is available" 25 | }, 26 | "info.spinner-text": { 27 | "message": "Updating..." 28 | }, 29 | "manifest_action_title": { 30 | "message": "Categories" 31 | }, 32 | "manifest_description": { 33 | "message": "Category manager for contacts. Also allows to send an email to all members of a category (a.k.a category based contact groups)." 34 | }, 35 | "menu.category.add_members_to_current_message": { 36 | "message": "Add category members to ..." 37 | }, 38 | "menu.category.add_members_to_new_message": { 39 | "message": "Compose new message with category members in ..." 40 | }, 41 | "menu.category.add_to_bcc": { 42 | "message": "Bcc" 43 | }, 44 | "menu.category.add_to_cc": { 45 | "message": "Cc" 46 | }, 47 | "menu.category.add_to_to": { 48 | "message": "To" 49 | }, 50 | "menu.category.delete": { 51 | "message": "Delete this category" 52 | }, 53 | "menu.category.rename": { 54 | "message": "Rename or move this category" 55 | }, 56 | "menu.contact.context.add_to_category": { 57 | "message": "Add contact to '$CATEGORY$'", 58 | "placeholders": { 59 | "category": { 60 | "content": "$1" 61 | } 62 | } 63 | }, 64 | "menu.contact.context.add_to_new_sub_category": { 65 | "message": "Add contact to new subcategory in '$CATEGORY$'", 66 | "placeholders": { 67 | "category": { 68 | "content": "$1" 69 | } 70 | } 71 | }, 72 | "menu.contact.context.add_to_new_top_level_category": { 73 | "message": "Add contact to new main category" 74 | }, 75 | "menu.contact.context.manage_categories_of_contact": { 76 | "message": "Manage categories of this contact" 77 | }, 78 | "menu.contact.context.remove_from_category": { 79 | "message": "Remove contact from '$CATEGORY$'", 80 | "placeholders": { 81 | "category": { 82 | "content": "$1" 83 | } 84 | } 85 | }, 86 | "menu.contact.context.remove_from_category_recursively": { 87 | "message": "Remove contact from '$CATEGORY$' and all its sub-categories", 88 | "placeholders": { 89 | "category": { 90 | "content": "$1" 91 | } 92 | } 93 | }, 94 | "menu.contact.drag.add_to_new_category": { 95 | "message": "Create new category here and add contact" 96 | }, 97 | "menu.contact.drag.add_to_subcategory": { 98 | "message": "Create new subcategory here and add contact" 99 | }, 100 | "menu.contact.drag.add_to_this_category": { 101 | "message": "Add contact to this category" 102 | }, 103 | "popup.error.content.footer": { 104 | "message": "If you think this is a bug of Category Manager, Please file an issue at" 105 | }, 106 | "popup.error.title": { 107 | "message": "Error" 108 | }, 109 | "popup.input.button.cancel": { 110 | "message": "Cancel" 111 | }, 112 | "popup.input.button.ok": { 113 | "message": "Ok" 114 | }, 115 | "popup.input.contentHTML": { 116 | "message": "You can use ' / ' (Space, Forward Slash,Space) as a delimiter for creating subcategories.
e.g. CategoryA / CategoryB / CategoryC" 117 | }, 118 | "popup.input.title": { 119 | "message": "Please enter a (sub)category" 120 | }, 121 | "tree.category.all": { 122 | "message": "All contacts" 123 | }, 124 | "tree.category.none": { 125 | "message": "Uncategorized" 126 | }, 127 | "validation-error.empty-category": { 128 | "message": "Category should not be empty." 129 | }, 130 | "validation-error.empty-subcategory": { 131 | "message": "Subcategory should not be empty." 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/_locales/es-AR/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors.contact-update-failure": { 3 | "message": "Failed to update contact!" 4 | }, 5 | "errors.hint.common-reasons": { 6 | "message": "Common reasons for this error are:" 7 | }, 8 | "errors.hint.most-likely": { 9 | "message": "(Most Likely)" 10 | }, 11 | "errors.operation-cancel": { 12 | "message": "Operation Canceled." 13 | }, 14 | "errors.partial-deletion": { 15 | "message": "An error occurred while deleting this category. Some contacts are still in this category." 16 | }, 17 | "errors.reason.address-book-readonly": { 18 | "message": "The address book is readonly." 19 | }, 20 | "errors.reason.contact-changed": { 21 | "message": "The contact has been changed outside Category Manager." 22 | }, 23 | "info.no-address-book": { 24 | "message": "No address book is available" 25 | }, 26 | "info.spinner-text": { 27 | "message": "Updating..." 28 | }, 29 | "manifest_action_title": { 30 | "message": "Categories" 31 | }, 32 | "manifest_description": { 33 | "message": "Administrador de categorias para contactos, que permite enviar un correo a todos los miembros de una categoria." 34 | }, 35 | "menu.category.add_members_to_current_message": { 36 | "message": "Add category members to ..." 37 | }, 38 | "menu.category.add_members_to_new_message": { 39 | "message": "Compose new message with category members in ..." 40 | }, 41 | "menu.category.add_to_bcc": { 42 | "message": "Bcc" 43 | }, 44 | "menu.category.add_to_cc": { 45 | "message": "Cc" 46 | }, 47 | "menu.category.add_to_to": { 48 | "message": "To" 49 | }, 50 | "menu.category.delete": { 51 | "message": "Delete this category" 52 | }, 53 | "menu.category.rename": { 54 | "message": "Rename or move this category" 55 | }, 56 | "menu.contact.context.add_to_category": { 57 | "message": "Add contact to '$CATEGORY$'", 58 | "placeholders": { 59 | "category": { 60 | "content": "$1" 61 | } 62 | } 63 | }, 64 | "menu.contact.context.add_to_new_sub_category": { 65 | "message": "Add to new sub-category in '$CATEGORY$'", 66 | "placeholders": { 67 | "category": { 68 | "content": "$1" 69 | } 70 | } 71 | }, 72 | "menu.contact.context.add_to_new_top_level_category": { 73 | "message": "Add to new category" 74 | }, 75 | "menu.contact.context.manage_categories_of_contact": { 76 | "message": "Manage categories of this contact" 77 | }, 78 | "menu.contact.context.remove_from_category": { 79 | "message": "Remove contact from '$CATEGORY$'", 80 | "placeholders": { 81 | "category": { 82 | "content": "$1" 83 | } 84 | } 85 | }, 86 | "menu.contact.context.remove_from_category_recursively": { 87 | "message": "Remove contact from '$CATEGORY$' and all its sub-categories", 88 | "placeholders": { 89 | "category": { 90 | "content": "$1" 91 | } 92 | } 93 | }, 94 | "menu.contact.drag.add_to_new_category": { 95 | "message": "Add to a new category" 96 | }, 97 | "menu.contact.drag.add_to_subcategory": { 98 | "message": "Add to a new subcategory" 99 | }, 100 | "menu.contact.drag.add_to_this_category": { 101 | "message": "Add to this category" 102 | }, 103 | "popup.error.content.footer": { 104 | "message": "If you think this is a bug of Category Manager, Please file an issue at" 105 | }, 106 | "popup.error.title": { 107 | "message": "Error" 108 | }, 109 | "popup.input.button.cancel": { 110 | "message": "Cancelar" 111 | }, 112 | "popup.input.button.ok": { 113 | "message": "Ok" 114 | }, 115 | "popup.input.contentHTML": { 116 | "message": "You can use ' / ' (Space, Forward Slash,Space) as a delimiter for creating subcategories.
e.g. CategoryA / CategoryB / CategoryC" 117 | }, 118 | "popup.input.title": { 119 | "message": "Please enter a (sub)category" 120 | }, 121 | "tree.category.all": { 122 | "message": "Todos los contactos" 123 | }, 124 | "tree.category.none": { 125 | "message": "Sin categoría" 126 | }, 127 | "validation-error.empty-category": { 128 | "message": "Category should not be empty." 129 | }, 130 | "validation-error.empty-subcategory": { 131 | "message": "Subcategory should not be empty." 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/_locales/es-ES/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors.contact-update-failure": { 3 | "message": "Failed to update contact!" 4 | }, 5 | "errors.hint.common-reasons": { 6 | "message": "Common reasons for this error are:" 7 | }, 8 | "errors.hint.most-likely": { 9 | "message": "(Most Likely)" 10 | }, 11 | "errors.operation-cancel": { 12 | "message": "Operation Canceled." 13 | }, 14 | "errors.partial-deletion": { 15 | "message": "An error occurred while deleting this category. Some contacts are still in this category." 16 | }, 17 | "errors.reason.address-book-readonly": { 18 | "message": "The address book is readonly." 19 | }, 20 | "errors.reason.contact-changed": { 21 | "message": "The contact has been changed outside Category Manager." 22 | }, 23 | "info.no-address-book": { 24 | "message": "No address book is available" 25 | }, 26 | "info.spinner-text": { 27 | "message": "Updating..." 28 | }, 29 | "manifest_action_title": { 30 | "message": "Categories" 31 | }, 32 | "manifest_description": { 33 | "message": "Administrador de categorías para los contactos. También permite enviar un correo a todos los miembros de una categoría (grupos de contactos basados en su categoría)." 34 | }, 35 | "menu.category.add_members_to_current_message": { 36 | "message": "Add category members to ..." 37 | }, 38 | "menu.category.add_members_to_new_message": { 39 | "message": "Compose new message with category members in ..." 40 | }, 41 | "menu.category.add_to_bcc": { 42 | "message": "Bcc" 43 | }, 44 | "menu.category.add_to_cc": { 45 | "message": "Cc" 46 | }, 47 | "menu.category.add_to_to": { 48 | "message": "To" 49 | }, 50 | "menu.category.delete": { 51 | "message": "Delete this category" 52 | }, 53 | "menu.category.rename": { 54 | "message": "Rename or move this category" 55 | }, 56 | "menu.contact.context.add_to_category": { 57 | "message": "Add contact to '$CATEGORY$'", 58 | "placeholders": { 59 | "category": { 60 | "content": "$1" 61 | } 62 | } 63 | }, 64 | "menu.contact.context.add_to_new_sub_category": { 65 | "message": "Add to new sub-category in '$CATEGORY$'", 66 | "placeholders": { 67 | "category": { 68 | "content": "$1" 69 | } 70 | } 71 | }, 72 | "menu.contact.context.add_to_new_top_level_category": { 73 | "message": "Add to new category" 74 | }, 75 | "menu.contact.context.manage_categories_of_contact": { 76 | "message": "Manage categories of this contact" 77 | }, 78 | "menu.contact.context.remove_from_category": { 79 | "message": "Remove contact from '$CATEGORY$'", 80 | "placeholders": { 81 | "category": { 82 | "content": "$1" 83 | } 84 | } 85 | }, 86 | "menu.contact.context.remove_from_category_recursively": { 87 | "message": "Remove contact from '$CATEGORY$' and all its sub-categories", 88 | "placeholders": { 89 | "category": { 90 | "content": "$1" 91 | } 92 | } 93 | }, 94 | "menu.contact.drag.add_to_new_category": { 95 | "message": "Add to a new category" 96 | }, 97 | "menu.contact.drag.add_to_subcategory": { 98 | "message": "Add to a new subcategory" 99 | }, 100 | "menu.contact.drag.add_to_this_category": { 101 | "message": "Add to this category" 102 | }, 103 | "popup.error.content.footer": { 104 | "message": "If you think this is a bug of Category Manager, Please file an issue at" 105 | }, 106 | "popup.error.title": { 107 | "message": "Error" 108 | }, 109 | "popup.input.button.cancel": { 110 | "message": "Cancelar" 111 | }, 112 | "popup.input.button.ok": { 113 | "message": "Aceptar" 114 | }, 115 | "popup.input.contentHTML": { 116 | "message": "You can use ' / ' (Space, Forward Slash,Space) as a delimiter for creating subcategories.
e.g. CategoryA / CategoryB / CategoryC" 117 | }, 118 | "popup.input.title": { 119 | "message": "Please enter a (sub)category" 120 | }, 121 | "tree.category.all": { 122 | "message": "Todos los contactos" 123 | }, 124 | "tree.category.none": { 125 | "message": "Sin categoría" 126 | }, 127 | "validation-error.empty-category": { 128 | "message": "Category should not be empty." 129 | }, 130 | "validation-error.empty-subcategory": { 131 | "message": "Subcategory should not be empty." 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/_locales/fr/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors.contact-update-failure": { 3 | "message": "Failed to update contact!" 4 | }, 5 | "errors.hint.common-reasons": { 6 | "message": "Common reasons for this error are:" 7 | }, 8 | "errors.hint.most-likely": { 9 | "message": "(Most Likely)" 10 | }, 11 | "errors.operation-cancel": { 12 | "message": "Operation Canceled." 13 | }, 14 | "errors.partial-deletion": { 15 | "message": "An error occurred while deleting this category. Some contacts are still in this category." 16 | }, 17 | "errors.reason.address-book-readonly": { 18 | "message": "The address book is readonly." 19 | }, 20 | "errors.reason.contact-changed": { 21 | "message": "The contact has been changed outside Category Manager." 22 | }, 23 | "info.no-address-book": { 24 | "message": "No address book is available" 25 | }, 26 | "info.spinner-text": { 27 | "message": "Updating..." 28 | }, 29 | "manifest_action_title": { 30 | "message": "Categories" 31 | }, 32 | "manifest_description": { 33 | "message": "Category Manager pour contacts, permet aussi d'envoyer un email à tous les membres d'une catégorie." 34 | }, 35 | "menu.category.add_members_to_current_message": { 36 | "message": "Add category members to ..." 37 | }, 38 | "menu.category.add_members_to_new_message": { 39 | "message": "Compose new message with category members in ..." 40 | }, 41 | "menu.category.add_to_bcc": { 42 | "message": "Bcc" 43 | }, 44 | "menu.category.add_to_cc": { 45 | "message": "Cc" 46 | }, 47 | "menu.category.add_to_to": { 48 | "message": "To" 49 | }, 50 | "menu.category.delete": { 51 | "message": "Delete this category" 52 | }, 53 | "menu.category.rename": { 54 | "message": "Rename or move this category" 55 | }, 56 | "menu.contact.context.add_to_category": { 57 | "message": "Add contact to '$CATEGORY$'", 58 | "placeholders": { 59 | "category": { 60 | "content": "$1" 61 | } 62 | } 63 | }, 64 | "menu.contact.context.add_to_new_sub_category": { 65 | "message": "Add to new sub-category in '$CATEGORY$'", 66 | "placeholders": { 67 | "category": { 68 | "content": "$1" 69 | } 70 | } 71 | }, 72 | "menu.contact.context.add_to_new_top_level_category": { 73 | "message": "Add to new category" 74 | }, 75 | "menu.contact.context.manage_categories_of_contact": { 76 | "message": "Manage categories of this contact" 77 | }, 78 | "menu.contact.context.remove_from_category": { 79 | "message": "Remove contact from '$CATEGORY$'", 80 | "placeholders": { 81 | "category": { 82 | "content": "$1" 83 | } 84 | } 85 | }, 86 | "menu.contact.context.remove_from_category_recursively": { 87 | "message": "Remove contact from '$CATEGORY$' and all its sub-categories", 88 | "placeholders": { 89 | "category": { 90 | "content": "$1" 91 | } 92 | } 93 | }, 94 | "menu.contact.drag.add_to_new_category": { 95 | "message": "Add to a new category" 96 | }, 97 | "menu.contact.drag.add_to_subcategory": { 98 | "message": "Add to a new subcategory" 99 | }, 100 | "menu.contact.drag.add_to_this_category": { 101 | "message": "Add to this category" 102 | }, 103 | "popup.error.content.footer": { 104 | "message": "If you think this is a bug of Category Manager, Please file an issue at" 105 | }, 106 | "popup.error.title": { 107 | "message": "Error" 108 | }, 109 | "popup.input.button.cancel": { 110 | "message": "Annuler" 111 | }, 112 | "popup.input.button.ok": { 113 | "message": "Ok" 114 | }, 115 | "popup.input.contentHTML": { 116 | "message": "You can use ' / ' (Space, Forward Slash,Space) as a delimiter for creating subcategories.
e.g. CategoryA / CategoryB / CategoryC" 117 | }, 118 | "popup.input.title": { 119 | "message": "Please enter a (sub)category" 120 | }, 121 | "tree.category.all": { 122 | "message": "Tous les contacts" 123 | }, 124 | "tree.category.none": { 125 | "message": "Non classé" 126 | }, 127 | "validation-error.empty-category": { 128 | "message": "Category should not be empty." 129 | }, 130 | "validation-error.empty-subcategory": { 131 | "message": "Subcategory should not be empty." 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/_locales/nl/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors.contact-update-failure": { 3 | "message": "Bijwerken van contact is mislukt!" 4 | }, 5 | "errors.hint.common-reasons": { 6 | "message": "Veelvoorkomende redenen voor deze fout zijn:" 7 | }, 8 | "errors.hint.most-likely": { 9 | "message": "(Hoogstwaarschijnlijk)" 10 | }, 11 | "errors.operation-cancel": { 12 | "message": "Bewerking is geannuleerd." 13 | }, 14 | "errors.partial-deletion": { 15 | "message": "Er is een fout opgetreden bij het verwijderen van deze categorie. Sommige contacten bevinden zich nog in deze categorie." 16 | }, 17 | "errors.reason.address-book-readonly": { 18 | "message": "Het adresboek is alleen-lezen." 19 | }, 20 | "errors.reason.contact-changed": { 21 | "message": "Het contact is buiten Category Manager om gewijzigd." 22 | }, 23 | "info.no-address-book": { 24 | "message": "Er is geen adresboek aanwezig." 25 | }, 26 | "info.spinner-text": { 27 | "message": "Bezig met bijwerken..." 28 | }, 29 | "manifest_action_title": { 30 | "message": "Categorieën" 31 | }, 32 | "manifest_description": { 33 | "message": "Category Manager voor contacten. Ook mogelijk een e-mail te sturen naar alle contacten van een categorie (ook bekend als categoriegebaseerde contactgroepen)." 34 | }, 35 | "menu.category.add_members_to_current_message": { 36 | "message": "Contacten van categorie toevoegen aan..." 37 | }, 38 | "menu.category.add_members_to_new_message": { 39 | "message": "Nieuw bericht aanmaken met contacten van categorie..." 40 | }, 41 | "menu.category.add_to_bcc": { 42 | "message": "BCC" 43 | }, 44 | "menu.category.add_to_cc": { 45 | "message": "CC" 46 | }, 47 | "menu.category.add_to_to": { 48 | "message": "To" 49 | }, 50 | "menu.category.delete": { 51 | "message": "Deze categorie verwijderen" 52 | }, 53 | "menu.category.rename": { 54 | "message": "Deze categorie herbenoemen of verplaatsen" 55 | }, 56 | "menu.contact.context.add_to_category": { 57 | "message": "Contact toevoegen aan '$CATEGORY$'", 58 | "placeholders": { 59 | "category": { 60 | "content": "$1" 61 | } 62 | } 63 | }, 64 | "menu.contact.context.add_to_new_sub_category": { 65 | "message": "Nieuwe subcategorie toevoegen aan '$CATEGORY$'", 66 | "placeholders": { 67 | "category": { 68 | "content": "$1" 69 | } 70 | } 71 | }, 72 | "menu.contact.context.add_to_new_top_level_category": { 73 | "message": "Toevoegen aan nieuwe categorie" 74 | }, 75 | "menu.contact.context.manage_categories_of_contact": { 76 | "message": "Categorieën van dit contact beheren" 77 | }, 78 | "menu.contact.context.remove_from_category": { 79 | "message": "Contact verwijderen van '$CATEGORY$'", 80 | "placeholders": { 81 | "category": { 82 | "content": "$1" 83 | } 84 | } 85 | }, 86 | "menu.contact.context.remove_from_category_recursively": { 87 | "message": "Contact verwijderen van '$CATEGORY$' al haar subcategorieën", 88 | "placeholders": { 89 | "category": { 90 | "content": "$1" 91 | } 92 | } 93 | }, 94 | "menu.contact.drag.add_to_new_category": { 95 | "message": "Toevoegen aan een nieuwe categorie" 96 | }, 97 | "menu.contact.drag.add_to_subcategory": { 98 | "message": "Toevoegen aan een nieuwe subcategorie" 99 | }, 100 | "menu.contact.drag.add_to_this_category": { 101 | "message": "Toevoegen aan deze categorie" 102 | }, 103 | "popup.error.content.footer": { 104 | "message": "Als je denkt dat dit een bug is van Category Manager, maak een issue aan op" 105 | }, 106 | "popup.error.title": { 107 | "message": "Fout" 108 | }, 109 | "popup.input.button.cancel": { 110 | "message": "Annuleren" 111 | }, 112 | "popup.input.button.ok": { 113 | "message": "Oké" 114 | }, 115 | "popup.input.contentHTML": { 116 | "message": "Door ' / ' (spatie, slash, spatie) als een scheidingsteken te gebruiken worden subcategorieën aangemaakt.
bijvoorbeeld CategorieA / CategorieB / CategorieC" 117 | }, 118 | "popup.input.title": { 119 | "message": "Invoeren van een (sub)categorie" 120 | }, 121 | "tree.category.all": { 122 | "message": "Alle contacten" 123 | }, 124 | "tree.category.none": { 125 | "message": "Ongecategoriseerd" 126 | }, 127 | "validation-error.empty-category": { 128 | "message": "Categorie zou niet leeg mogen zijn." 129 | }, 130 | "validation-error.empty-subcategory": { 131 | "message": "Subcategorie zou niet leeg mogen zijn." 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/_locales/pt-BR/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors.contact-update-failure": { 3 | "message": "Failed to update contact!" 4 | }, 5 | "errors.hint.common-reasons": { 6 | "message": "Common reasons for this error are:" 7 | }, 8 | "errors.hint.most-likely": { 9 | "message": "(Most Likely)" 10 | }, 11 | "errors.operation-cancel": { 12 | "message": "Operation Canceled." 13 | }, 14 | "errors.partial-deletion": { 15 | "message": "An error occurred while deleting this category. Some contacts are still in this category." 16 | }, 17 | "errors.reason.address-book-readonly": { 18 | "message": "The address book is readonly." 19 | }, 20 | "errors.reason.contact-changed": { 21 | "message": "The contact has been changed outside Category Manager." 22 | }, 23 | "info.no-address-book": { 24 | "message": "No address book is available" 25 | }, 26 | "info.spinner-text": { 27 | "message": "Updating..." 28 | }, 29 | "manifest_action_title": { 30 | "message": "Categories" 31 | }, 32 | "manifest_description": { 33 | "message": "O Gerenciador de categorias para contatos também permite que você envie um email para todos os membros de uma categoria (grupos de contatos baseados em categorias)." 34 | }, 35 | "menu.category.add_members_to_current_message": { 36 | "message": "Add category members to ..." 37 | }, 38 | "menu.category.add_members_to_new_message": { 39 | "message": "Compose new message with category members in ..." 40 | }, 41 | "menu.category.add_to_bcc": { 42 | "message": "Bcc" 43 | }, 44 | "menu.category.add_to_cc": { 45 | "message": "Cc" 46 | }, 47 | "menu.category.add_to_to": { 48 | "message": "To" 49 | }, 50 | "menu.category.delete": { 51 | "message": "Delete this category" 52 | }, 53 | "menu.category.rename": { 54 | "message": "Rename or move this category" 55 | }, 56 | "menu.contact.context.add_to_category": { 57 | "message": "Add contact to '$CATEGORY$'", 58 | "placeholders": { 59 | "category": { 60 | "content": "$1" 61 | } 62 | } 63 | }, 64 | "menu.contact.context.add_to_new_sub_category": { 65 | "message": "Add to new sub-category in '$CATEGORY$'", 66 | "placeholders": { 67 | "category": { 68 | "content": "$1" 69 | } 70 | } 71 | }, 72 | "menu.contact.context.add_to_new_top_level_category": { 73 | "message": "Add to new category" 74 | }, 75 | "menu.contact.context.manage_categories_of_contact": { 76 | "message": "Manage categories of this contact" 77 | }, 78 | "menu.contact.context.remove_from_category": { 79 | "message": "Remove contact from '$CATEGORY$'", 80 | "placeholders": { 81 | "category": { 82 | "content": "$1" 83 | } 84 | } 85 | }, 86 | "menu.contact.context.remove_from_category_recursively": { 87 | "message": "Remove contact from '$CATEGORY$' and all its sub-categories", 88 | "placeholders": { 89 | "category": { 90 | "content": "$1" 91 | } 92 | } 93 | }, 94 | "menu.contact.drag.add_to_new_category": { 95 | "message": "Add to a new category" 96 | }, 97 | "menu.contact.drag.add_to_subcategory": { 98 | "message": "Add to a new subcategory" 99 | }, 100 | "menu.contact.drag.add_to_this_category": { 101 | "message": "Add to this category" 102 | }, 103 | "popup.error.content.footer": { 104 | "message": "If you think this is a bug of Category Manager, Please file an issue at" 105 | }, 106 | "popup.error.title": { 107 | "message": "Error" 108 | }, 109 | "popup.input.button.cancel": { 110 | "message": "Cancelar" 111 | }, 112 | "popup.input.button.ok": { 113 | "message": "OK" 114 | }, 115 | "popup.input.contentHTML": { 116 | "message": "You can use ' / ' (Space, Forward Slash,Space) as a delimiter for creating subcategories.
e.g. CategoryA / CategoryB / CategoryC" 117 | }, 118 | "popup.input.title": { 119 | "message": "Please enter a (sub)category" 120 | }, 121 | "tree.category.all": { 122 | "message": "Todos os contatos" 123 | }, 124 | "tree.category.none": { 125 | "message": "Sem categoria" 126 | }, 127 | "validation-error.empty-category": { 128 | "message": "Category should not be empty." 129 | }, 130 | "validation-error.empty-subcategory": { 131 | "message": "Subcategory should not be empty." 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors.contact-update-failure": { 3 | "message": "Failed to update contact!" 4 | }, 5 | "errors.hint.common-reasons": { 6 | "message": "Common reasons for this error are:" 7 | }, 8 | "errors.hint.most-likely": { 9 | "message": "(Most Likely)" 10 | }, 11 | "errors.operation-cancel": { 12 | "message": "Operation Canceled." 13 | }, 14 | "errors.partial-deletion": { 15 | "message": "An error occurred while deleting this category. Some contacts are still in this category." 16 | }, 17 | "errors.reason.address-book-readonly": { 18 | "message": "The address book is readonly." 19 | }, 20 | "errors.reason.contact-changed": { 21 | "message": "The contact has been changed outside Category Manager." 22 | }, 23 | "info.no-address-book": { 24 | "message": "No address book is available" 25 | }, 26 | "info.spinner-text": { 27 | "message": "Updating..." 28 | }, 29 | "manifest_action_title": { 30 | "message": "Categories" 31 | }, 32 | "manifest_description": { 33 | "message": "Менеджер категорий для контактов, также позволяет отправлять электронную почту всем членам категории (групп контактов, основанных на категории)." 34 | }, 35 | "menu.category.add_members_to_current_message": { 36 | "message": "Add category members to ..." 37 | }, 38 | "menu.category.add_members_to_new_message": { 39 | "message": "Compose new message with category members in ..." 40 | }, 41 | "menu.category.add_to_bcc": { 42 | "message": "Bcc" 43 | }, 44 | "menu.category.add_to_cc": { 45 | "message": "Cc" 46 | }, 47 | "menu.category.add_to_to": { 48 | "message": "To" 49 | }, 50 | "menu.category.delete": { 51 | "message": "Delete this category" 52 | }, 53 | "menu.category.rename": { 54 | "message": "Rename or move this category" 55 | }, 56 | "menu.contact.context.add_to_category": { 57 | "message": "Add contact to '$CATEGORY$'", 58 | "placeholders": { 59 | "category": { 60 | "content": "$1" 61 | } 62 | } 63 | }, 64 | "menu.contact.context.add_to_new_sub_category": { 65 | "message": "Add to new sub-category in '$CATEGORY$'", 66 | "placeholders": { 67 | "category": { 68 | "content": "$1" 69 | } 70 | } 71 | }, 72 | "menu.contact.context.add_to_new_top_level_category": { 73 | "message": "Add to new category" 74 | }, 75 | "menu.contact.context.manage_categories_of_contact": { 76 | "message": "Manage categories of this contact" 77 | }, 78 | "menu.contact.context.remove_from_category": { 79 | "message": "Remove contact from '$CATEGORY$'", 80 | "placeholders": { 81 | "category": { 82 | "content": "$1" 83 | } 84 | } 85 | }, 86 | "menu.contact.context.remove_from_category_recursively": { 87 | "message": "Remove contact from '$CATEGORY$' and all its sub-categories", 88 | "placeholders": { 89 | "category": { 90 | "content": "$1" 91 | } 92 | } 93 | }, 94 | "menu.contact.drag.add_to_new_category": { 95 | "message": "Add to a new category" 96 | }, 97 | "menu.contact.drag.add_to_subcategory": { 98 | "message": "Add to a new subcategory" 99 | }, 100 | "menu.contact.drag.add_to_this_category": { 101 | "message": "Add to this category" 102 | }, 103 | "popup.error.content.footer": { 104 | "message": "If you think this is a bug of Category Manager, Please file an issue at" 105 | }, 106 | "popup.error.title": { 107 | "message": "Error" 108 | }, 109 | "popup.input.button.cancel": { 110 | "message": "Отмена" 111 | }, 112 | "popup.input.button.ok": { 113 | "message": "Готово" 114 | }, 115 | "popup.input.contentHTML": { 116 | "message": "You can use ' / ' (Space, Forward Slash,Space) as a delimiter for creating subcategories.
e.g. CategoryA / CategoryB / CategoryC" 117 | }, 118 | "popup.input.title": { 119 | "message": "Please enter a (sub)category" 120 | }, 121 | "tree.category.all": { 122 | "message": "Все контакты" 123 | }, 124 | "tree.category.none": { 125 | "message": "Не категоризировано" 126 | }, 127 | "validation-error.empty-category": { 128 | "message": "Category should not be empty." 129 | }, 130 | "validation-error.empty-subcategory": { 131 | "message": "Subcategory should not be empty." 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/_locales/zh-CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "errors.contact-update-failure": { 3 | "message": "更新联系人失败!" 4 | }, 5 | "errors.hint.common-reasons": { 6 | "message": "导致这个错误的常见原因有:" 7 | }, 8 | "errors.hint.most-likely": { 9 | "message": "(最有可能)" 10 | }, 11 | "errors.operation-cancel": { 12 | "message": "操作已取消。" 13 | }, 14 | "errors.partial-deletion": { 15 | "message": "我们在删除这个类别时发生了错误。可能还有一些联系人仍然属于此类别。" 16 | }, 17 | "errors.reason.address-book-readonly": { 18 | "message": "这个地址簿是只读的。" 19 | }, 20 | "errors.reason.contact-changed": { 21 | "message": "这个联系人在 Category Manager 之外被修改了。" 22 | }, 23 | "info.no-address-book": { 24 | "message": "没有可用的地址簿" 25 | }, 26 | "info.spinner-text": { 27 | "message": "正在更新..." 28 | }, 29 | "manifest_action_title": { 30 | "message": "Categories" 31 | }, 32 | "manifest_description": { 33 | "message": "联系人类别管理器。你可以一次向整个类别的联系人发送邮件(基于类别的联系人群组)。" 34 | }, 35 | "menu.category.add_members_to_current_message": { 36 | "message": "Add category members to ..." 37 | }, 38 | "menu.category.add_members_to_new_message": { 39 | "message": "Compose new message with category members in ..." 40 | }, 41 | "menu.category.add_to_bcc": { 42 | "message": "Bcc" 43 | }, 44 | "menu.category.add_to_cc": { 45 | "message": "Cc" 46 | }, 47 | "menu.category.add_to_to": { 48 | "message": "To" 49 | }, 50 | "menu.category.delete": { 51 | "message": "删除此类别" 52 | }, 53 | "menu.category.rename": { 54 | "message": "Rename or move this category" 55 | }, 56 | "menu.contact.context.add_to_category": { 57 | "message": "添加到 '$CATEGORY$'", 58 | "placeholders": { 59 | "category": { 60 | "content": "$1" 61 | } 62 | } 63 | }, 64 | "menu.contact.context.add_to_new_sub_category": { 65 | "message": "添加到 '$CATEGORY$' 下的新子类别", 66 | "placeholders": { 67 | "category": { 68 | "content": "$1" 69 | } 70 | } 71 | }, 72 | "menu.contact.context.add_to_new_top_level_category": { 73 | "message": "添加到新类别" 74 | }, 75 | "menu.contact.context.manage_categories_of_contact": { 76 | "message": "管理此联系人的类别" 77 | }, 78 | "menu.contact.context.remove_from_category": { 79 | "message": "从 '$CATEGORY$' 类别中删除", 80 | "placeholders": { 81 | "category": { 82 | "content": "$1" 83 | } 84 | } 85 | }, 86 | "menu.contact.context.remove_from_category_recursively": { 87 | "message": "从 '$CATEGORY$' 及其所有子类别中删除", 88 | "placeholders": { 89 | "category": { 90 | "content": "$1" 91 | } 92 | } 93 | }, 94 | "menu.contact.drag.add_to_new_category": { 95 | "message": "添加到新类别" 96 | }, 97 | "menu.contact.drag.add_to_subcategory": { 98 | "message": "添加到新建子类别" 99 | }, 100 | "menu.contact.drag.add_to_this_category": { 101 | "message": "添加到此类别" 102 | }, 103 | "popup.error.content.footer": { 104 | "message": "如果你认为这是 Category Manager 的一个 Bug,请在以下网址提交一个 Bug 报告:" 105 | }, 106 | "popup.error.title": { 107 | "message": "错误" 108 | }, 109 | "popup.input.button.cancel": { 110 | "message": "取消" 111 | }, 112 | "popup.input.button.ok": { 113 | "message": "确定" 114 | }, 115 | "popup.input.contentHTML": { 116 | "message": "你可以使用 ' / ' (空格, 斜杠, 空格) 作为创建子类别的分隔符
e.g. 类别A / 类别B / 类别C" 117 | }, 118 | "popup.input.title": { 119 | "message": "请输入(子)类别" 120 | }, 121 | "tree.category.none": { 122 | "message": "未分类" 123 | }, 124 | "validation-error.empty-category": { 125 | "message": "类别不能为空。" 126 | }, 127 | "validation-error.empty-subcategory": { 128 | "message": "子类别不能为空。" 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/background/background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/background/cacher.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | AddressBook, 3 | registerCacheUpdateCallback, 4 | } from "../modules/cache/index.mjs"; 5 | import { printToConsole } from "../modules/utils.mjs"; 6 | 7 | async function storeCache(addressBooks) { 8 | await browser.storage.local.set({ addressBooks }); 9 | } 10 | 11 | // This needs to be awaited only once, all following calls in this window are 12 | // synchronous. 13 | await printToConsole.info("Populating cache..."); 14 | 15 | // Disable action buttons while cache is being populated. 16 | browser.browserAction.disable(); 17 | browser.browserAction.setBadgeText({ text: "🔄" }); 18 | browser.browserAction.setBadgeBackgroundColor({ color: [0, 0, 0, 0] }); 19 | browser.composeAction.disable(); 20 | browser.composeAction.setBadgeText({ text: "🔄" }); 21 | browser.composeAction.setBadgeBackgroundColor({ color: [0, 0, 0, 0] }); 22 | 23 | let abInfos = await browser.addressBooks.list(); 24 | 25 | // Add each single address book. 26 | let abValues = await Promise.all( 27 | abInfos.map((ab) => AddressBook.fromTBAddressBook(ab)) 28 | ); 29 | 30 | // Add the virtual "All Contacts" address book and make it the first one. 31 | const allContactsVirtualAddressBook = AddressBook.fromAllContacts( 32 | abValues, 33 | await browser.i18n.getMessage("tree.category.all") 34 | ); 35 | abValues.unshift(allContactsVirtualAddressBook); 36 | 37 | let addressBooks = new Map(abValues.map((ab) => [ab.id, ab])); 38 | 39 | // Store the newly created cache to extension's local storage. Note: This 40 | // removes all additional prototypes of our AddressBook and Category classes. 41 | await storeCache(addressBooks); 42 | 43 | printToConsole.log(addressBooks); 44 | printToConsole.info("Done populating cache!"); 45 | 46 | // Register listener to update cache on backend changes. 47 | registerCacheUpdateCallback(addressBooks, storeCache) 48 | 49 | // Re-enable our action buttons after cache has been populated. 50 | browser.browserAction.enable(); 51 | browser.browserAction.setBadgeText({ text: null }); 52 | browser.composeAction.enable(); 53 | browser.composeAction.setBadgeText({ text: null }); 54 | -------------------------------------------------------------------------------- /src/external/METADATA: -------------------------------------------------------------------------------- 1 | micromodal == 0.4.10 2 | -------------------------------------------------------------------------------- /src/external/email-addresses/.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | env: 4 | - CXX=g++-4.8 5 | 6 | node_js: 7 | - 12 8 | 9 | addons: 10 | apt: 11 | sources: 12 | - ubuntu-toolchain-r-test 13 | packages: 14 | - g++-4.8 15 | 16 | before_install: 17 | - $CXX --version 18 | - npm install node-gyp -g 19 | 20 | before_script: 21 | 22 | script: 23 | - npm test 24 | 25 | sudo: false 26 | -------------------------------------------------------------------------------- /src/external/email-addresses/Changes.md: -------------------------------------------------------------------------------- 1 | 2 | ## 2021-08-17 - 5.0.0 3 | 4 | Note: Again there is a major version bump because of changes to the typescript definitions. If you are not a typescript user, this is more like a minor version bump. 5 | 6 | - Added "addressListSeperator" option (#57) 7 | - Typescript: improvements to the type definitions (#58, #59) 8 | 9 | ## 2021-01-11 - 4.0.0 10 | 11 | Note: Again there is a major version bump because of changes to the typescript definitions. Some of the typescript changes are really bug fixes, noting where null is a possible value. If you are not a typescript user, this is more like a minor version bump. 12 | 13 | - Added "commaInDisplayName" option (#54) 14 | - Typescript: improvements to the type definitions (#44, #45, #47) 15 | 16 | ## 2019-10-24 - 3.1.0 17 | - Added "atInDisplayName" option (#46) 18 | - Added "comments" field to result mailbox (#46) 19 | 20 | ## 2018-11-09 - 3.0.3 21 | - No changes 22 | 23 | ## 2018-09-21 - 3.0.2 24 | - Fixed npe with rejectTLD option (#33) 25 | 26 | ## 2017-06-21 - 3.0.0 27 | 28 | Note: There is a major version bump because of two things: changes to the typescript definition and changes to the results returned for "group" addresses. 29 | 30 | - Full typescript definition (#30, a12b003) 31 | - Fixed typescript "typings" field in package.json (#32) 32 | - Proper results for groups (#31). Previously a "group" "address" would show its results as a single address, but it is now returned as a list. See the typescript definition for full return type. 33 | - Support for parsing RFC6854 originator fields (#31). This adds new functions: parseFrom, parseSender, parseReplyTo. It also adds a new option "startAt". See source for possible values of "startAt". 34 | 35 | ## 2016-04-30 - 36 | 37 | - minified version 38 | 39 | ## 2015-12-28 - 2.0.2 40 | 41 | - Improves type definition #18 42 | - Adds TypeScript definition file and declares in package.json #17 43 | - remove inaccurate comment on obs-FWS 44 | - add bower.json 45 | 46 | 47 | ## 2014-11-02 - 2.0.1 48 | 49 | - properly parse unquoted names with periods (version 2.0.1) 50 | 51 | 52 | ## 2014-10-14 - 2.0.0 53 | 54 | - add rejectTLD option, off by default 55 | - add proper unicode support (rfc 6532) 56 | - improve 'semantic interpretation' of names 57 | 58 | 59 | ## 2014-09-08 - 1.1.2 60 | 61 | - document the return values more 62 | - for 'address', 'local', and 'domain' convenience methods return semantic content 63 | - update readme to show results from current code 64 | - fix invalid reference to address node introduced in 51836f1 65 | - support loading in the browser #4 66 | 67 | 68 | # 2014-01-10 - 1.1.1 69 | 70 | - return name and other fields with whitespace collapsed properly (closes #2) 71 | - readme: add "why use this" and "installation" 72 | - readme: link to @dominicsayers #1 73 | 74 | 75 | ## 2013-09-10 - 1.1.0 76 | 77 | - Initial commit 78 | -------------------------------------------------------------------------------- /src/external/email-addresses/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Fog Creek Software 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/external/email-addresses/README.md: -------------------------------------------------------------------------------- 1 | email-addresses.js 2 | ================== 3 | 4 | An RFC 5322 email address parser. 5 | 6 | v 5.0.0 7 | 8 | What? 9 | ----- 10 | Want to see if something could be an email address? Want to grab the display name or just the address out of a string? Put your regexes down and use this parser! 11 | 12 | This library does not validate email addresses - we can't really do that without sending an email. However, it attempts to parse addresses using the (fairly liberal) grammar specified in RFC 5322. You can use this to check if user input looks like an email address. 13 | 14 | Note carefully though - this parser supports all features of RFC 5322, which means that `"Bob Example" ` 15 | is a valid email address. If you just want to validate the `bob@example.com` part, that is RFC 5321, for which you want 16 | to use something like node-address-rfc2821. 17 | 18 | Why use this? 19 | ------------- 20 | Use this library because you can be sure it really respects the RFC: 21 | - The functions in the recursive decent parser match up with the productions in the RFC 22 | - The productions from the RFC are written above each function for easy verification 23 | - Tests include all of the test cases from the [is_email](https://github.com/dominicsayers/isemail) project, which are extensive 24 | 25 | Installation 26 | ------------ 27 | npm install email-addresses 28 | 29 | Example 30 | ------- 31 | 32 | ``` 33 | $ node 34 | > addrs = require("email-addresses") 35 | { [Function: parse5322] 36 | parseOneAddress: [Function: parseOneAddressSimple], 37 | parseAddressList: [Function: parseAddressListSimple] } 38 | > addrs.parseOneAddress('"Jack Bowman" ') 39 | { parts: 40 | { name: [Object], 41 | address: [Object], 42 | local: [Object], 43 | domain: [Object] }, 44 | name: 'Jack Bowman', 45 | address: 'jack@fogcreek.com', 46 | local: 'jack', 47 | domain: 'fogcreek.com' } 48 | > addrs.parseAddressList('jack@fogcreek.com, Bob ') 49 | [ { parts: 50 | { name: null, 51 | address: [Object], 52 | local: [Object], 53 | domain: [Object] }, 54 | name: null, 55 | address: 'jack@fogcreek.com', 56 | local: 'jack', 57 | domain: 'fogcreek.com' }, 58 | { parts: 59 | { name: [Object], 60 | address: [Object], 61 | local: [Object], 62 | domain: [Object] }, 63 | name: 'Bob', 64 | address: 'bob@example.com', 65 | local: 'bob', 66 | domain: 'example.com' } ] 67 | > addrs("jack@fogcreek.com") 68 | { ast: 69 | { name: 'address-list', 70 | tokens: 'jack@fogcreek.com', 71 | semantic: 'jack@fogcreek.com', 72 | children: [ [Object] ] }, 73 | addresses: 74 | [ { node: [Object], 75 | parts: [Object], 76 | name: null, 77 | address: 'jack@fogcreek.com', 78 | local: 'jack', 79 | domain: 'fogcreek.com' } ] } 80 | > addrs("bogus") 81 | null 82 | ``` 83 | 84 | API 85 | --- 86 | 87 | `obj = addrs(opts)` 88 | =================== 89 | 90 | Call the module directly as a function to get access to the AST. Returns null for a failed parse (an invalid 91 | address). 92 | 93 | Options: 94 | 95 | * `string` - An email address to parse. Parses as `address-list`, a list of email addresses separated by commas. 96 | * `object` with the following keys: 97 | * `input` - An email address to parse. Required. 98 | * `rfc6532` - Enable rfc6532 support (unicode in email addresses). Default: `false`. 99 | * `partial` - Allow a failed parse to return the AST it managed to produce so far. Default: `false`. 100 | * `simple` - Return just the address or addresses parsed. Default: `false`. 101 | * `strict` - Turn off features of RFC 5322 marked "Obsolete". Default: `false`. 102 | * `rejectTLD` - Require at least one `.` in domain names. Default: `false`. 103 | * `startAt` - Start the parser at one of `address`, `address-list`, `angle-addr`, `from`, `group`, `mailbox`, `mailbox-list`, `reply-to`, `sender`. Default: `address-list`. 104 | * `atInDisplayName` - Allow the `@` character in the display name of the email address. Default: `false`. 105 | * `commaInDisplayName` - Allow the `,` character in the display name of the email address. Default: `false`. 106 | * `addressListSeparator` - Specifies the character separating the list of email addresses. Default: `,`. 107 | 108 | Returns an object with the following properties: 109 | 110 | * `ast` - the full AST of the parse. 111 | * `addresses` - array of addresses found. Each has the following properties: 112 | * `parts` - components of the AST that make up the address. 113 | * `type` - The type of the node, e.g. `mailbox`, `address`, `group`. 114 | * `name` - The extracted name from the email. e.g. parsing `"Bob" ` will give `Bob` for the `name`. 115 | * `address` - The full email address. e.g. parsing the above will give `bob@example.com` for the `address`. 116 | * `local` - The local part. e.g. parsing the above will give `bob` for `local`. 117 | * `domain` - The domain part. e.g. parsing the above will give `example.com` for `domain`. 118 | 119 | Note if `simple` is set, the return will be an array of addresses rather than the object above. 120 | 121 | Note that addresses can contain a `group` address, which in contrast to the `address` objects 122 | will simply contain two properties: a `name` and `addresses` which is an array of the addresses in 123 | the group. You can identify groups because they will have a `type` of `group`. A group looks 124 | something like this: `Managing Partners:ben@example.com,carol@example.com;` 125 | 126 | `obj = addrs.parseOneAddress(opts)` 127 | =================================== 128 | 129 | Parse a single email address. 130 | 131 | Operates similarly to `addrs(opts)`, with the exception that `rfc6532` and `simple` default to `true`. 132 | 133 | Returns a single address object as described above. If you set `simple: false` the returned object 134 | includes a `node` object that contains the AST for the address. 135 | 136 | `obj = addrs.parseAddressList(opts)` 137 | ==================================== 138 | 139 | Parse a list of email addresses separated by comma. 140 | 141 | Operates similarly to `addrs(opts)`, with the exception that `rfc6532` and `simple` default to `true`. 142 | 143 | Returns a list of address objects as described above. If you set `simple: false` each address will 144 | include a `node` object that contains the AST for the address. 145 | 146 | `obj = addrs.parseFrom(opts)` 147 | ============================= 148 | 149 | Parse an email header "From:" address (specified as mailbox-list or address-list). 150 | 151 | Operates similarly to `addrs(opts)`, with the exception that `rfc6532` and `simple` default to `true`. 152 | 153 | Returns a list of address objects as described above. If you set `simple: false` each address will 154 | include a `node` object that contains the AST for the address. 155 | 156 | `obj = addrs.parseSender(opts)` 157 | =============================== 158 | 159 | Parse an email header "Sender:" address (specified as mailbox or address). 160 | 161 | Operates similarly to `addrs(opts)`, with the exception that `rfc6532` and `simple` default to `true`. 162 | 163 | Returns a single address object as described above. If you set `simple: false` the returned object 164 | includes a `node` object that contains the AST for the address. 165 | 166 | `obj = addrs.parseReplyTo(opts)` 167 | ================================ 168 | 169 | Parse an email header "Reply-To:" address (specified as address-list). 170 | 171 | Operates identically to `addrs.parseAddressList(opts)`. 172 | 173 | Usage 174 | ----- 175 | If you want to simply check whether an address or address list parses, you'll want to call the following functions and check whether the results are null or not: ```parseOneAddress``` for a single address and ```parseAddressList``` for multiple addresses. 176 | 177 | If you want to examine the parsed address, for example to extract a name or address, you have some options. The object returned by ```parseOneAddress``` has four helper values on it: ```name```, ```address```, ```local```, and ```domain```. See the example above to understand is actually returned. (These are equivalent to ```parts.name.semantic```, ```parts.address.semantic```, etc.) These values try to be smart about collapsing whitespace, quotations, and excluding RFC 5322 comments. If you desire, you can also obtain the raw parsed tokens or semantic tokens for those fields. The ```parts``` value is an object referencing nodes in the AST generated. Nodes in the AST have two values of interest here, ```tokens``` and ```semantic```. 178 | 179 | ``` 180 | > a = addrs.parseOneAddress('Jack Bowman ') 181 | > a.parts.name.tokens 182 | 'Jack Bowman ' 183 | > a.name 184 | 'Jack Bowman' 185 | > a.parts.name.semantic 186 | 'Jack Bowman ' 187 | > a.parts.address.tokens 188 | 'jack@fogcreek.com ' 189 | > a.address 190 | 'jack@fogcreek.com' 191 | > a.parts.address.semantic 192 | 'jack@fogcreek.com' 193 | ``` 194 | 195 | If you need to, you can inspect the AST directly. The entire AST is returned when calling the module's function. 196 | 197 | References 198 | ---------- 199 | - http://tools.ietf.org/html/rfc5322 200 | - https://tools.ietf.org/html/rfc6532 201 | - https://tools.ietf.org/html/rfc6854 202 | - http://code.google.com/p/isemail/ 203 | 204 | Props 205 | ----- 206 | Many thanks to [Dominic Sayers](https://github.com/dominicsayers) and his documentation and tests 207 | for the [is_email](https://github.com/dominicsayers/isemail) function which helped greatly in writing this parser. 208 | 209 | License 210 | ------- 211 | Licensed under the MIT License. See the LICENSE file. 212 | -------------------------------------------------------------------------------- /src/external/email-addresses/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "email-addresses", 3 | "version": "5.0.0", 4 | "homepage": "https://github.com/jackbearheart/email-addresses", 5 | "authors": [ 6 | "Jack Bearheart " 7 | ], 8 | "description": "An email address parser based on rfc5322", 9 | "main": "./lib/email-addresses.js", 10 | "moduleType": [ 11 | "globals", 12 | "node" 13 | ], 14 | "keywords": [ 15 | "email", 16 | "address", 17 | "parser", 18 | "rfc5322", 19 | "5322" 20 | ], 21 | "license": "MIT", 22 | "ignore": [ 23 | "**/.*", 24 | "node_modules", 25 | "bower_components", 26 | "test", 27 | "tests" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /src/external/email-addresses/lib/email-addresses.d.ts: -------------------------------------------------------------------------------- 1 | declare module emailAddresses { 2 | function parseOneAddress(input: string | Options): ParsedMailbox | ParsedGroup | null; 3 | function parseAddressList(input: string | Options): (ParsedMailbox | ParsedGroup)[] | null; 4 | function parseFrom(input: string | Options): (ParsedMailbox | ParsedGroup)[] | null; 5 | function parseSender(input: string | Options): ParsedMailbox | ParsedGroup | null; 6 | function parseReplyTo(input: string | Options): (ParsedMailbox | ParsedGroup)[] | null; 7 | 8 | interface ParsedMailbox { 9 | node?: ASTNode; 10 | parts: { 11 | name: ASTNode | null; 12 | address: ASTNode; 13 | local: ASTNode; 14 | domain: ASTNode; 15 | comments: ASTNode[]; 16 | }; 17 | type: "mailbox"; 18 | name: string | null; 19 | address: string; 20 | local: string; 21 | domain: string; 22 | } 23 | 24 | interface ParsedGroup { 25 | node?: ASTNode; 26 | parts: { 27 | name: ASTNode; 28 | }; 29 | type: "group"; 30 | name: string; 31 | addresses: ParsedMailbox[]; 32 | } 33 | 34 | interface ASTNode { 35 | name: string; 36 | tokens: string; 37 | semantic: string; 38 | children: ASTNode[]; 39 | } 40 | 41 | type StartProductions = 42 | "address" 43 | | "address-list" 44 | | "angle-addr" 45 | | "from" 46 | | "group" 47 | | "mailbox" 48 | | "mailbox-list" 49 | | "reply-to" 50 | | "sender"; 51 | 52 | interface Options { 53 | input: string; 54 | oneResult?: boolean; 55 | partial?: boolean; 56 | rejectTLD?: boolean; 57 | rfc6532?: boolean; 58 | simple?: boolean; 59 | startAt?: StartProductions; 60 | strict?: boolean; 61 | atInDisplayName?: boolean; 62 | commaInDisplayName?: boolean; 63 | addressListSeparator?: string; 64 | } 65 | 66 | interface ParsedResult { 67 | ast: ASTNode; 68 | addresses: (ParsedMailbox | ParsedGroup)[]; 69 | } 70 | } 71 | 72 | declare function emailAddresses(opts: emailAddresses.Options): emailAddresses.ParsedResult | null; 73 | 74 | declare module "email-addresses" { 75 | export = emailAddresses; 76 | } 77 | 78 | /* Example usage: 79 | 80 | // Run this file with: 81 | // tsc test.ts && NODE_PATH="../emailaddresses/lib" node test.js 82 | /// 83 | import emailAddresses = require('email-addresses'); 84 | 85 | function isParsedMailbox(mailboxOrGroup: emailAddresses.ParsedMailbox | emailAddresses.ParsedGroup): mailboxOrGroup is emailAddresses.ParsedMailbox { 86 | return mailboxOrGroup.type === 'mailbox'; 87 | } 88 | 89 | var testEmail : string = "TestName (a comment) "; 90 | console.log(testEmail); 91 | 92 | var parsed = emailAddresses.parseOneAddress(testEmail); 93 | console.log(parsed); 94 | 95 | var a : string = parsed.parts.name.children[0].name; 96 | console.log(a); 97 | 98 | if (isParsedMailbox(parsed)) { 99 | var comment : string = parsed.parts.comments[0].tokens; 100 | console.log(comment); 101 | } else { 102 | console.error('error, should be a ParsedMailbox'); 103 | } 104 | 105 | // 106 | 107 | var emailList : string = "TestName , TestName2 "; 108 | console.log(emailList); 109 | 110 | var parsedList = emailAddresses.parseAddressList(emailList); 111 | console.log(parsedList); 112 | 113 | var b : string = parsedList[1].parts.name.children[0].semantic; 114 | console.log(b); 115 | 116 | // 117 | 118 | var parsedByModuleFxn = emailAddresses({ input: emailList, rfc6532: true }); 119 | console.log(parsedByModuleFxn.addresses[0].name); 120 | 121 | */ 122 | -------------------------------------------------------------------------------- /src/external/email-addresses/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5.0.0", 3 | "name": "email-addresses", 4 | "description": "An email address parser based on rfc5322", 5 | "keywords": [ 6 | "email address", 7 | "parser", 8 | "rfc5322", 9 | "5322" 10 | ], 11 | "homepage": "https://github.com/jackbearheart/email-addresses", 12 | "author": "Jack Bearheart ", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/jackbearheart/email-addresses.git" 16 | }, 17 | "directories": { 18 | "lib": "./lib" 19 | }, 20 | "main": "./lib/email-addresses.js", 21 | "devDependencies": { 22 | "libxmljs": "~0.19.7", 23 | "tap": "^14.8.2" 24 | }, 25 | "scripts": { 26 | "test": "tap ./test" 27 | }, 28 | "license": "MIT", 29 | "typings": "./lib/email-addresses.d.ts" 30 | } 31 | -------------------------------------------------------------------------------- /src/external/email-addresses/test/is_email.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"), 2 | libxmljs = require("libxmljs"), 3 | test = require("tap").test; 4 | 5 | var addrs = require("../lib/email-addresses"); 6 | 7 | var TESTS_FILE = "tests.xml", 8 | TESTS_FILE_ENCODING = "utf8"; 9 | 10 | var ISEMAIL_ERR = "ISEMAIL_ERR", 11 | ISEMAIL_ERR_DOMAINHYPHENSTART = "ISEMAIL_ERR_DOMAINHYPHENSTART", 12 | ISEMAIL_ERR_DOMAINHYPHENEND = "ISEMAIL_ERR_DOMAINHYPHENEND"; 13 | 14 | 15 | function isEmailTest(t, data) { 16 | var nodes = getNodes(data, "//test"); 17 | nodes.forEach(function (node) { 18 | var id = getAttr(node, "id"), 19 | address = getChildValue(node, "address"), 20 | diagnosis = getChildValue(node, "diagnosis"); 21 | 22 | var result = addrs(convertAddress(address)), 23 | ast = null; 24 | if (result !== null) { 25 | ast = result.addresses[0].node; 26 | } 27 | 28 | var isValid = ast !== null, 29 | expectedToBeValid = shouldParse(diagnosis); 30 | 31 | t.equal(isValid, expectedToBeValid, 32 | "[test " + id + "] address: " + address + ", expects: " + expectedToBeValid); 33 | }); 34 | t.end(); 35 | } 36 | 37 | function shouldParse(diagnosis) { 38 | var isOk = !startsWith(diagnosis, ISEMAIL_ERR) || 39 | // is_email considers address with a domain beginning 40 | // or ending with "-" to be incorrect because they are not 41 | // valid domains, but we are only concerned with rfc5322. 42 | // From rfc5322's perspective, this is OK. 43 | diagnosis === ISEMAIL_ERR_DOMAINHYPHENSTART || 44 | diagnosis === ISEMAIL_ERR_DOMAINHYPHENEND; 45 | return isOk; 46 | } 47 | 48 | // the is_email tests encode control characters 49 | // in the U+2400 block for display purposes 50 | function convertAddress(s) { 51 | var chars = []; 52 | for (var i = 0; i < s.length; i += 1) { 53 | var code = s.charCodeAt(i); 54 | if (code >= 0x2400) { 55 | code -= 0x2400; 56 | } 57 | chars.push(String.fromCharCode(code)); 58 | } 59 | return chars.join(''); 60 | } 61 | 62 | function getChildValue(parent, nodeName) { 63 | return parent.find(nodeName)[0].text(); 64 | } 65 | 66 | function getAttr(node, attrName) { 67 | return node.attr(attrName).value(); 68 | } 69 | 70 | function getNodes(xml, xpath) { 71 | var doc = libxmljs.parseXml(xml); 72 | return doc.find(xpath); 73 | } 74 | 75 | function startsWith(s, t) { 76 | return s.substring(0, t.length) === t; 77 | } 78 | 79 | test("isemail tests", function (t) { 80 | fs.readFile(TESTS_FILE, TESTS_FILE_ENCODING, function (err, data) { 81 | if (err) { 82 | t.end(); 83 | return console.error(err); 84 | } 85 | isEmailTest(t, data); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/external/fontawesome/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Fonticons, Inc. (https://fontawesome.com) 2 | 3 | -------------------------------------------------------------------------------- 4 | 5 | Font Awesome Free License 6 | 7 | Font Awesome Free is free, open source, and GPL friendly. You can use it for 8 | commercial projects, open source projects, or really almost whatever you want. 9 | Full Font Awesome Free license: https://fontawesome.com/license/free. 10 | 11 | -------------------------------------------------------------------------------- 12 | 13 | # Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/) 14 | 15 | The Font Awesome Free download is licensed under a Creative Commons 16 | Attribution 4.0 International License and applies to all icons packaged 17 | as SVG and JS file types. 18 | 19 | -------------------------------------------------------------------------------- 20 | 21 | # Fonts: SIL OFL 1.1 License 22 | 23 | In the Font Awesome Free download, the SIL OFL license applies to all icons 24 | packaged as web and desktop font files. 25 | 26 | Copyright (c) 2022 Fonticons, Inc. (https://fontawesome.com) 27 | with Reserved Font Name: "Font Awesome". 28 | 29 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 30 | This license is copied below, and is also available with a FAQ at: 31 | http://scripts.sil.org/OFL 32 | 33 | SIL OPEN FONT LICENSE 34 | Version 1.1 - 26 February 2007 35 | 36 | PREAMBLE 37 | The goals of the Open Font License (OFL) are to stimulate worldwide 38 | development of collaborative font projects, to support the font creation 39 | efforts of academic and linguistic communities, and to provide a free and 40 | open framework in which fonts may be shared and improved in partnership 41 | with others. 42 | 43 | The OFL allows the licensed fonts to be used, studied, modified and 44 | redistributed freely as long as they are not sold by themselves. The 45 | fonts, including any derivative works, can be bundled, embedded, 46 | redistributed and/or sold with any software provided that any reserved 47 | names are not used by derivative works. The fonts and derivatives, 48 | however, cannot be released under any other type of license. The 49 | requirement for fonts to remain under this license does not apply 50 | to any document created using the fonts or their derivatives. 51 | 52 | DEFINITIONS 53 | "Font Software" refers to the set of files released by the Copyright 54 | Holder(s) under this license and clearly marked as such. This may 55 | include source files, build scripts and documentation. 56 | 57 | "Reserved Font Name" refers to any names specified as such after the 58 | copyright statement(s). 59 | 60 | "Original Version" refers to the collection of Font Software components as 61 | distributed by the Copyright Holder(s). 62 | 63 | "Modified Version" refers to any derivative made by adding to, deleting, 64 | or substituting — in part or in whole — any of the components of the 65 | Original Version, by changing formats or by porting the Font Software to a 66 | new environment. 67 | 68 | "Author" refers to any designer, engineer, programmer, technical 69 | writer or other person who contributed to the Font Software. 70 | 71 | PERMISSION & CONDITIONS 72 | Permission is hereby granted, free of charge, to any person obtaining 73 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 74 | redistribute, and sell modified and unmodified copies of the Font 75 | Software, subject to the following conditions: 76 | 77 | 1) Neither the Font Software nor any of its individual components, 78 | in Original or Modified Versions, may be sold by itself. 79 | 80 | 2) Original or Modified Versions of the Font Software may be bundled, 81 | redistributed and/or sold with any software, provided that each copy 82 | contains the above copyright notice and this license. These can be 83 | included either as stand-alone text files, human-readable headers or 84 | in the appropriate machine-readable metadata fields within text or 85 | binary files as long as those fields can be easily viewed by the user. 86 | 87 | 3) No Modified Version of the Font Software may use the Reserved Font 88 | Name(s) unless explicit written permission is granted by the corresponding 89 | Copyright Holder. This restriction only applies to the primary font name as 90 | presented to the users. 91 | 92 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 93 | Software shall not be used to promote, endorse or advertise any 94 | Modified Version, except to acknowledge the contribution(s) of the 95 | Copyright Holder(s) and the Author(s) or with their explicit written 96 | permission. 97 | 98 | 5) The Font Software, modified or unmodified, in part or in whole, 99 | must be distributed entirely under this license, and must not be 100 | distributed under any other license. The requirement for fonts to 101 | remain under this license does not apply to any document created 102 | using the Font Software. 103 | 104 | TERMINATION 105 | This license becomes null and void if any of the above conditions are 106 | not met. 107 | 108 | DISCLAIMER 109 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 110 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 111 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 112 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 113 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 114 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 115 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 116 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 117 | OTHER DEALINGS IN THE FONT SOFTWARE. 118 | 119 | -------------------------------------------------------------------------------- 120 | 121 | # Code: MIT License (https://opensource.org/licenses/MIT) 122 | 123 | In the Font Awesome Free download, the MIT license applies to all non-font and 124 | non-icon files. 125 | 126 | Copyright 2022 Fonticons, Inc. 127 | 128 | Permission is hereby granted, free of charge, to any person obtaining a copy of 129 | this software and associated documentation files (the "Software"), to deal in the 130 | Software without restriction, including without limitation the rights to use, copy, 131 | modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 132 | and to permit persons to whom the Software is furnished to do so, subject to the 133 | following conditions: 134 | 135 | The above copyright notice and this permission notice shall be included in all 136 | copies or substantial portions of the Software. 137 | 138 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 139 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 140 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 141 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 142 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 143 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 144 | 145 | -------------------------------------------------------------------------------- 146 | 147 | # Attribution 148 | 149 | Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font 150 | Awesome Free files already contain embedded comments with sufficient 151 | attribution, so you shouldn't need to do anything additional when using these 152 | files normally. 153 | 154 | We've kept attribution comments terse, so we ask that you do not actively work 155 | to remove them from files, especially code. They're a great way for folks to 156 | learn about Font Awesome. 157 | 158 | -------------------------------------------------------------------------------- 159 | 160 | # Brand Icons 161 | 162 | All brand icons are trademarks of their respective owners. The use of these 163 | trademarks does not indicate endorsement of the trademark holder by Font 164 | Awesome, nor vice versa. **Please do not use brand logos for any purpose except 165 | to represent the company, product, or service to which they refer.** 166 | -------------------------------------------------------------------------------- /src/external/fontawesome/VERSION: -------------------------------------------------------------------------------- 1 | 6.2 Free 2 | -------------------------------------------------------------------------------- /src/external/fontawesome/css/regular.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | * Copyright 2022 Fonticons, Inc. 5 | */ 6 | :root, :host { 7 | --fa-style-family-classic: 'Font Awesome 6 Free'; 8 | --fa-font-regular: normal 400 1em/1 'Font Awesome 6 Free'; } 9 | 10 | @font-face { 11 | font-family: 'Font Awesome 6 Free'; 12 | font-style: normal; 13 | font-weight: 400; 14 | font-display: block; 15 | src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); } 16 | 17 | .far, 18 | .fa-regular { 19 | font-weight: 400; } 20 | -------------------------------------------------------------------------------- /src/external/fontawesome/css/regular.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | * Copyright 2022 Fonticons, Inc. 5 | */ 6 | :host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400} -------------------------------------------------------------------------------- /src/external/fontawesome/css/solid.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | * Copyright 2022 Fonticons, Inc. 5 | */ 6 | :root, :host { 7 | --fa-style-family-classic: 'Font Awesome 6 Free'; 8 | --fa-font-solid: normal 900 1em/1 'Font Awesome 6 Free'; } 9 | 10 | @font-face { 11 | font-family: 'Font Awesome 6 Free'; 12 | font-style: normal; 13 | font-weight: 900; 14 | font-display: block; 15 | src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); } 16 | 17 | .fas, 18 | .fa-solid { 19 | font-weight: 900; } 20 | -------------------------------------------------------------------------------- /src/external/fontawesome/css/solid.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | * Copyright 2022 Fonticons, Inc. 5 | */ 6 | :host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900} -------------------------------------------------------------------------------- /src/external/fontawesome/css/v4-font-face.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | * Copyright 2022 Fonticons, Inc. 5 | */ 6 | @font-face { 7 | font-family: 'FontAwesome'; 8 | font-display: block; 9 | src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); } 10 | 11 | @font-face { 12 | font-family: 'FontAwesome'; 13 | font-display: block; 14 | src: url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.ttf") format("truetype"); } 15 | 16 | @font-face { 17 | font-family: 'FontAwesome'; 18 | font-display: block; 19 | src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); 20 | unicode-range: U+F003,U+F006,U+F014,U+F016-F017,U+F01A-F01B,U+F01D,U+F022,U+F03E,U+F044,U+F046,U+F05C-F05D,U+F06E,U+F070,U+F087-F088,U+F08A,U+F094,U+F096-F097,U+F09D,U+F0A0,U+F0A2,U+F0A4-F0A7,U+F0C5,U+F0C7,U+F0E5-F0E6,U+F0EB,U+F0F6-F0F8,U+F10C,U+F114-F115,U+F118-F11A,U+F11C-F11D,U+F133,U+F147,U+F14E,U+F150-F152,U+F185-F186,U+F18E,U+F190-F192,U+F196,U+F1C1-F1C9,U+F1D9,U+F1DB,U+F1E3,U+F1EA,U+F1F7,U+F1F9,U+F20A,U+F247-F248,U+F24A,U+F24D,U+F255-F25B,U+F25D,U+F271-F274,U+F278,U+F27B,U+F28C,U+F28E,U+F29C,U+F2B5,U+F2B7,U+F2BA,U+F2BC,U+F2BE,U+F2C0-F2C1,U+F2C3,U+F2D0,U+F2D2,U+F2D4,U+F2DC; } 21 | 22 | @font-face { 23 | font-family: 'FontAwesome'; 24 | font-display: block; 25 | src: url("../webfonts/fa-v4compatibility.woff2") format("woff2"), url("../webfonts/fa-v4compatibility.ttf") format("truetype"); 26 | unicode-range: U+F041,U+F047,U+F065-F066,U+F07D-F07E,U+F080,U+F08B,U+F08E,U+F090,U+F09A,U+F0AC,U+F0AE,U+F0B2,U+F0D0,U+F0D6,U+F0E4,U+F0EC,U+F10A-F10B,U+F123,U+F13E,U+F148-F149,U+F14C,U+F156,U+F15E,U+F160-F161,U+F163,U+F175-F178,U+F195,U+F1F8,U+F219,U+F27A; } 27 | -------------------------------------------------------------------------------- /src/external/fontawesome/css/v4-font-face.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | * Copyright 2022 Fonticons, Inc. 5 | */ 6 | @font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a} -------------------------------------------------------------------------------- /src/external/fontawesome/css/v5-font-face.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | * Copyright 2022 Fonticons, Inc. 5 | */ 6 | @font-face { 7 | font-family: 'Font Awesome 5 Brands'; 8 | font-display: block; 9 | font-weight: 400; 10 | src: url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.ttf") format("truetype"); } 11 | 12 | @font-face { 13 | font-family: 'Font Awesome 5 Free'; 14 | font-display: block; 15 | font-weight: 900; 16 | src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); } 17 | 18 | @font-face { 19 | font-family: 'Font Awesome 5 Free'; 20 | font-display: block; 21 | font-weight: 400; 22 | src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); } 23 | -------------------------------------------------------------------------------- /src/external/fontawesome/css/v5-font-face.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.2.0 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | * Copyright 2022 Fonticons, Inc. 5 | */ 6 | @font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")} -------------------------------------------------------------------------------- /src/external/fontawesome/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/external/fontawesome/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /src/external/fontawesome/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/external/fontawesome/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /src/external/fontawesome/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/external/fontawesome/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /src/external/fontawesome/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/external/fontawesome/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /src/external/fontawesome/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/external/fontawesome/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /src/external/fontawesome/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/external/fontawesome/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /src/external/fontawesome/webfonts/fa-v4compatibility.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/external/fontawesome/webfonts/fa-v4compatibility.ttf -------------------------------------------------------------------------------- /src/external/fontawesome/webfonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/external/fontawesome/webfonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /src/external/micromodal.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).MicroModal=t()}(this,(function(){"use strict";function e(e,t){for(var o=0;oe.length)&&(t=e.length);for(var o=0,n=new Array(t);o0&&this.registerTriggers.apply(this,t(a)),this.onClick=this.onClick.bind(this),this.onKeydown=this.onKeydown.bind(this)}var i,a,r;return i=o,(a=[{key:"registerTriggers",value:function(){for(var e=this,t=arguments.length,o=new Array(t),n=0;n0&&void 0!==arguments[0]?arguments[0]:null;if(this.activeElement=document.activeElement,this.modal.setAttribute("aria-hidden","false"),this.modal.classList.add(this.config.openClass),this.scrollBehaviour("disable"),this.addEventListeners(),this.config.awaitOpenAnimation){var o=function t(){e.modal.removeEventListener("animationend",t,!1),e.setFocusToFirstNode()};this.modal.addEventListener("animationend",o,!1)}else this.setFocusToFirstNode();this.config.onShow(this.modal,this.activeElement,t)}},{key:"closeModal",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,t=this.modal;if(this.modal.setAttribute("aria-hidden","true"),this.removeEventListeners(),this.scrollBehaviour("enable"),this.activeElement&&this.activeElement.focus&&this.activeElement.focus(),this.config.onClose(this.modal,this.activeElement,e),this.config.awaitCloseAnimation){var o=this.config.openClass;this.modal.addEventListener("animationend",(function e(){t.classList.remove(o),t.removeEventListener("animationend",e,!1)}),!1)}else t.classList.remove(this.config.openClass)}},{key:"closeModalById",value:function(e){this.modal=document.getElementById(e),this.modal&&this.closeModal()}},{key:"scrollBehaviour",value:function(e){if(this.config.disableScroll){var t=document.querySelector("body");switch(e){case"enable":Object.assign(t.style,{overflow:""});break;case"disable":Object.assign(t.style,{overflow:"hidden"})}}}},{key:"addEventListeners",value:function(){this.modal.addEventListener("touchstart",this.onClick),this.modal.addEventListener("click",this.onClick),document.addEventListener("keydown",this.onKeydown)}},{key:"removeEventListeners",value:function(){this.modal.removeEventListener("touchstart",this.onClick),this.modal.removeEventListener("click",this.onClick),document.removeEventListener("keydown",this.onKeydown)}},{key:"onClick",value:function(e){(e.target.hasAttribute(this.config.closeTrigger)||e.target.parentNode.hasAttribute(this.config.closeTrigger))&&(e.preventDefault(),e.stopPropagation(),this.closeModal(e))}},{key:"onKeydown",value:function(e){27===e.keyCode&&this.closeModal(e),9===e.keyCode&&this.retainFocus(e)}},{key:"getFocusableNodes",value:function(){var e=this.modal.querySelectorAll(n);return Array.apply(void 0,t(e))}},{key:"setFocusToFirstNode",value:function(){var e=this;if(!this.config.disableFocus){var t=this.getFocusableNodes();if(0!==t.length){var o=t.filter((function(t){return!t.hasAttribute(e.config.closeTrigger)}));o.length>0&&o[0].focus(),0===o.length&&t[0].focus()}}}},{key:"retainFocus",value:function(e){var t=this.getFocusableNodes();if(0!==t.length)if(t=t.filter((function(e){return null!==e.offsetParent})),this.modal.contains(document.activeElement)){var o=t.indexOf(document.activeElement);e.shiftKey&&0===o&&(t[t.length-1].focus(),e.preventDefault()),!e.shiftKey&&t.length>0&&o===t.length-1&&(t[0].focus(),e.preventDefault())}else t[0].focus()}}])&&e(i.prototype,a),r&&e(i,r),o}(),a=null,r=function(e){if(!document.getElementById(e))return console.warn("MicroModal: ❗Seems like you have missed %c'".concat(e,"'"),"background-color: #f8f9fa;color: #50596c;font-weight: bold;","ID somewhere in your code. Refer example below to resolve it."),console.warn("%cExample:","background-color: #f8f9fa;color: #50596c;font-weight: bold;",'')),!1},s=function(e,t){if(function(e){e.length<=0&&(console.warn("MicroModal: ❗Please specify at least one %c'micromodal-trigger'","background-color: #f8f9fa;color: #50596c;font-weight: bold;","data attribute."),console.warn("%cExample:","background-color: #f8f9fa;color: #50596c;font-weight: bold;",''))}(e),!t)return!0;for(var o in t)r(o);return!0},{init:function(e){var o=Object.assign({},{openTrigger:"data-micromodal-trigger"},e),n=t(document.querySelectorAll("[".concat(o.openTrigger,"]"))),r=function(e,t){var o=[];return e.forEach((function(e){var n=e.attributes[t].value;void 0===o[n]&&(o[n]=[]),o[n].push(e)})),o}(n,o.openTrigger);if(!0!==o.debugMode||!1!==s(n,r))for(var l in r){var c=r[l];o.targetModal=l,o.triggers=t(c),a=new i(o)}},show:function(e,t){var o=t||{};o.targetModal=e,!0===o.debugMode&&!1===r(e)||(a&&a.removeEventListeners(),(a=new i(o)).showModal())},close:function(e){e?a.closeModalById(e):a.closeModal()}});return"undefined"!=typeof window&&(window.MicroModal=l),l})); 2 | -------------------------------------------------------------------------------- /src/images/Pulse-96px.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/images/Pulse-96px.gif -------------------------------------------------------------------------------- /src/images/Pulse-96px.license: -------------------------------------------------------------------------------- 1 | Loading.io Free License 2 | 3 | With Loading.io Free license ( LD-FREE / FREE / Free License ), items are dedicated to the public domain by waiving all our right worldwide under copyright law. You can use items under LD-FREE freely for any purpose. No attribution is required. -------------------------------------------------------------------------------- /src/images/Pulse-96px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/images/icon-16px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/images/icon-16px.png -------------------------------------------------------------------------------- /src/images/icon-32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/images/icon-32px.png -------------------------------------------------------------------------------- /src/images/icon-64px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/src/images/icon-64px.png -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Category Manager", 4 | "version": "6.4", 5 | "author": "John Bieling, Levi Zim", 6 | "homepage_url": "https://github.com/jobisoft/CategoryManager/", 7 | "default_locale": "en-US", 8 | "description": "__MSG_manifest_description__", 9 | "applications": { 10 | "gecko": { 11 | "id": "sendtocategory@jobisoft.de", 12 | "strict_min_version": "115.0" 13 | } 14 | }, 15 | "icons": { 16 | "64": "images/icon-64px.png", 17 | "32": "images/icon-32px.png", 18 | "16": "images/icon-16px.png" 19 | }, 20 | "background": { 21 | "page": "background/background.html" 22 | }, 23 | "permissions": [ 24 | "addressBooks", 25 | "compose", 26 | "tabs", 27 | "menus", 28 | "menus.overrideContext", 29 | "storage" 30 | ], 31 | "compose_action": { 32 | "default_popup": "popup/popup.html", 33 | "default_title": "__MSG_manifest_action_title__", 34 | "default_icon": "images/icon-32px.png" 35 | }, 36 | "browser_action": { 37 | "default_popup": "popup/popup.html", 38 | "default_title": "__MSG_manifest_action_title__", 39 | "default_icon": "images/icon-32px.png", 40 | "allowed_spaces": ["mail", "addressbook"] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/modules/cache/addressbook.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | buildUncategorizedCategory, 3 | Category, 4 | categoryStringToArr, 5 | sortCategoriesMap, 6 | sortContactsMap, 7 | SUBCATEGORY_SEPARATOR, 8 | } from "./index.mjs"; 9 | import { parseContact } from "../contacts/contact.mjs"; 10 | import { printToConsole } from "../utils.mjs"; 11 | 12 | export class AddressBook { 13 | constructor(name, contacts, id) { 14 | this.name = name; 15 | this.categories = new Map(); 16 | this.contacts = sortContactsMap(contacts); 17 | this.id = id ?? name; 18 | } 19 | 20 | static async fromTBAddressBook({ name, id }) { 21 | const rawContacts = await browser.contacts.list(id); 22 | const contacts = new Map(rawContacts.map(contact => { 23 | const parsed = parseContact(contact); 24 | return [parsed.id, parsed]; 25 | })); 26 | let ab = new AddressBook(name, contacts, id); 27 | ab.#build(); 28 | return ab; 29 | } 30 | 31 | static fromAllContacts(addressBooks, name) { 32 | let contacts = new Map(); 33 | for (const ab of addressBooks) { 34 | ab.contacts.forEach((contact, id) => contacts.set(id, contact)); 35 | } 36 | let ret = new AddressBook(name, contacts, "all-contacts"); 37 | ret.#build(); 38 | return ret; 39 | } 40 | 41 | #build() { 42 | this.contacts.forEach((contact, id) => { 43 | for (const category of contact.categories) { 44 | this.#addContactToCategoryWhenBuildingTree(contact, category); 45 | } 46 | }) 47 | } 48 | 49 | #addContactToCategoryWhenBuildingTree(contact, categoryStr) { 50 | const category = categoryStringToArr(categoryStr); 51 | let rootName = category[0]; 52 | if (!this.categories.has(rootName)) { 53 | this.categories.set(rootName, new Category(rootName, rootName)); 54 | this.categories = sortCategoriesMap(this.categories); 55 | } 56 | let cur = this.categories.get(rootName); 57 | cur.contacts.set(contact.id, this.contacts.get(contact.id)); 58 | let path = rootName; 59 | category.slice(1).forEach((cat) => { 60 | path += SUBCATEGORY_SEPARATOR + cat; 61 | if (!cur.categories.has(cat)) { 62 | cur.categories.set(cat, new Category(cat, path)); 63 | cur.categories = sortCategoriesMap(cur.categories); 64 | } 65 | cur = cur.categories.get(cat); 66 | cur.contacts.set(contact.id, this.contacts.get(contact.id)); 67 | }); 68 | } 69 | } 70 | 71 | export function lookupCategory( 72 | addressBook, 73 | categoryKey, 74 | getUncategorized = false 75 | ) { 76 | // Look up a category using a key like `A / B`. 77 | printToConsole.log("Looking up", categoryKey); 78 | const category = categoryStringToArr(categoryKey); 79 | if (getUncategorized) { 80 | // Remove the last sub category, which is "Uncategorized". It is called, by 81 | // lookupContactsByCategoryElement() when clicked on the Uncategorized 82 | // category. 83 | category.pop(); 84 | } 85 | let cur = addressBook; 86 | for (const cat of category) { 87 | if (!cur.categories.has(cat)) return null; 88 | cur = cur.categories.get(cat); 89 | } 90 | return getUncategorized 91 | ? buildUncategorizedCategory(cur) 92 | : cur; 93 | } 94 | -------------------------------------------------------------------------------- /src/modules/cache/category.mjs: -------------------------------------------------------------------------------- 1 | export const SUBCATEGORY_SEPARATOR = " / "; 2 | export const UNCATEGORIZED_CATEGORY_NAME = await browser.i18n.getMessage( 3 | "tree.category.none" 4 | ); 5 | 6 | const VALIDATION_ERR_EMPTY_CATEGORY = await browser.i18n.getMessage( 7 | "validation-error.empty-category" 8 | ); 9 | 10 | const VALIDATION_ERR_EMPTY_SUBCATEGORY = await browser.i18n.getMessage( 11 | "validation-error.empty-subcategory" 12 | ); 13 | 14 | export class Category { 15 | constructor( 16 | name, 17 | path, 18 | contacts = new Map(), 19 | subCategories = new Map(), 20 | isUncategorized = false 21 | ) { 22 | this.name = name; 23 | this.path = path; 24 | this.categories = subCategories; 25 | this.contacts = contacts; 26 | this.isUncategorized = isUncategorized; 27 | } 28 | 29 | static createSubcategory(parentCategoryObj, name, contacts) { 30 | const newPath = 31 | parentCategoryObj.path == null 32 | ? name 33 | : parentCategoryObj.path + SUBCATEGORY_SEPARATOR + name; 34 | return new Category(name, newPath, contacts); 35 | } 36 | } 37 | 38 | // In an ideal world, we would place this as a method on our Category class, 39 | // but since we need to store the entire cache object which removes all extra 40 | // prototypes, we need to have this extra function. 41 | // We do not store the uncategorized elements in the cache, since it is a simple 42 | // filter operation and it requires much more complex logic to update cached 43 | // uncategorized elements. 44 | export function buildUncategorizedCategory(cat) { 45 | if (cat.isUncategorized) { 46 | return null; 47 | } 48 | let basePath = ""; 49 | let contacts = new Map(); 50 | if (cat.path) { 51 | // This is a real category. 52 | basePath = cat.path + SUBCATEGORY_SEPARATOR; 53 | cat.contacts.forEach(contact => { 54 | if (![...contact.categories].some(category => category.startsWith(basePath))) { 55 | contacts.set(contact.id, contact); 56 | } 57 | }); 58 | } else { 59 | // This is an address book. 60 | cat.contacts.forEach(contact => { 61 | if (contact.categories.size == 0) { 62 | contacts.set(contact.id, contact); 63 | } 64 | }); 65 | } 66 | 67 | if (contacts.size == 0) { 68 | return null; 69 | } 70 | 71 | return new Category( 72 | UNCATEGORIZED_CATEGORY_NAME, 73 | basePath + UNCATEGORIZED_CATEGORY_NAME, 74 | contacts, 75 | new Map(), 76 | true 77 | ); 78 | } 79 | 80 | /** 81 | * Joins the individual nested category names to a full category string: 82 | * * e.g. ["A","B"] -> "A / B" 83 | */ 84 | export function categoryArrToString(cat) { 85 | return cat.join(SUBCATEGORY_SEPARATOR); 86 | } 87 | 88 | /** 89 | * Splits a category string into its individual nested category names: 90 | * e.g. "A / B" -> ["A","B"] 91 | */ 92 | export function categoryStringToArr(cat) { 93 | return cat.split(SUBCATEGORY_SEPARATOR); 94 | } 95 | 96 | export function hasSubcategories(cat) { 97 | return cat.categories.size > 0; 98 | } 99 | 100 | export function isSubcategoryOf(categoryStr, parentStr) { 101 | return categoryStr.startsWith(parentStr + SUBCATEGORY_SEPARATOR); 102 | } 103 | 104 | export function validateCategoryString(s) { 105 | if (s == null || s.trim() === "") { 106 | return VALIDATION_ERR_EMPTY_CATEGORY; 107 | } 108 | const splitted = categoryStringToArr(s); 109 | for (const cat of splitted) { 110 | if (cat.trim() == "") { 111 | return VALIDATION_ERR_EMPTY_SUBCATEGORY; 112 | } 113 | } 114 | return "LGTM"; 115 | } 116 | 117 | export function isContactInCategory(categoryObj, contactId) { 118 | return categoryObj.contacts.has(contactId); 119 | } 120 | 121 | export function isContactInAnySubcategory(categoryObj, contactId) { 122 | let result = false; 123 | for (const subcategory of categoryObj.categories.values()) { 124 | if (isContactInCategory(subcategory, contactId)) { 125 | result = true; 126 | break; 127 | } 128 | } 129 | return result; 130 | } 131 | 132 | /** 133 | * Remove all implicit parent categories. 134 | * 135 | */ 136 | export function removeImplicitCategories(categoriesArray) { 137 | let reducedCategories = categoriesArray.filter(e => !!e).reduce((acc, cur) => { 138 | if (!categoriesArray.find((e) => e.trim().startsWith(cur + SUBCATEGORY_SEPARATOR))) { 139 | acc.push(cur); 140 | } 141 | return acc; 142 | }, []); 143 | // Remove duplicates. 144 | return [...new Set(reducedCategories)] 145 | } 146 | 147 | /** 148 | * Return only parent categories. 149 | * e.g. ["A / B", "C", "A / B / X"] -> ["A / B", "C"] 150 | */ 151 | export function removeSubCategories(categoriesArray) { 152 | const byLength = (a, b) => a.length - b.length; 153 | 154 | let reducedCategories = []; 155 | for (let categoryStr of [...categoriesArray].sort(byLength)) { 156 | let cat = categoryStr.trim(); 157 | if (!reducedCategories.find(e => cat == e || cat.startsWith(e + SUBCATEGORY_SEPARATOR))) { 158 | reducedCategories.push(cat); 159 | } 160 | } 161 | return reducedCategories; 162 | } 163 | 164 | /** 165 | * Expand the categories to include all implicit parent categories. 166 | */ 167 | export function expandImplicitCategories(categoriesArray) { 168 | let expandedCategories = []; 169 | for (let categoryStr of categoriesArray.filter(e => !!e)) { 170 | const pendingCategoryLevels = categoryStringToArr(categoryStr); 171 | let categoryLevels = []; 172 | while (pendingCategoryLevels.length > 0) { 173 | let categoryPart = pendingCategoryLevels.shift(); 174 | categoryLevels.push(categoryPart); 175 | expandedCategories.push(categoryArrToString(categoryLevels)); 176 | } 177 | } 178 | // Remove duplicates and empty categories. 179 | return [...new Set(expandedCategories)] 180 | } 181 | 182 | /** 183 | * Get the parent category string of a category string. 184 | * If the category string is already a top level category, 185 | * this function returns null. 186 | */ 187 | export function getParentCategoryStr(categoryStr) { 188 | const idx = categoryStr.lastIndexOf(SUBCATEGORY_SEPARATOR); 189 | return idx !== -1 190 | ? categoryStr.substring(0, categoryStr.lastIndexOf(SUBCATEGORY_SEPARATOR)) 191 | : null; // Return null if no parent category 192 | } 193 | 194 | /** 195 | * Merge a category and all implicit parent categories into a given categories array. 196 | * Returns the updated categories array (but changes are done in-place). 197 | */ 198 | export function mergeCategory(categories, categoryStr) { 199 | for (let cat of expandImplicitCategories([categoryStr])) { 200 | if (!categories.includes(cat)) { 201 | categories.push(cat); 202 | } 203 | } 204 | return categories; 205 | } 206 | 207 | /** 208 | * Strip a category and all its subcategories from a given categories array. 209 | * Returns the updated categories array. 210 | */ 211 | export function stripCategory(categories, categoryStr) { 212 | // Categories in cache always include all implicit categories. 213 | return categories.filter( 214 | cat => cat != categoryStr && !isSubcategoryOf(cat, categoryStr) 215 | ); 216 | } 217 | -------------------------------------------------------------------------------- /src/modules/cache/index.mjs: -------------------------------------------------------------------------------- 1 | export * from "./category.mjs"; 2 | export * from "./addressbook.mjs"; 3 | export * from "./update.mjs"; 4 | export * from "./listeners.mjs"; 5 | -------------------------------------------------------------------------------- /src/modules/cache/listeners.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This module provides a method to register all the required listeners in order 3 | * to keep our caches up-to-date, if any of the contacts have been changed in the 4 | * backend. 5 | * 6 | * This also includes changes which are caused by this add-on, so there is no need 7 | * to manually update the cache at all. 8 | */ 9 | 10 | import { AddressBook } from "./addressbook.mjs"; 11 | import { parseContact } from "../contacts/contact.mjs"; 12 | import { 13 | createContactInCache, 14 | modifyContactInCache, 15 | deleteContactInCache, 16 | } from "./update.mjs"; 17 | 18 | /** 19 | * Main cache update listener registration. The callback can be used to store 20 | * the updated cache or to update the UI. 21 | */ 22 | export function registerCacheUpdateCallback(addressBooks, callback) { 23 | browser.contacts.onCreated.addListener(async (node) => { 24 | await updateCacheOnContactCreation(addressBooks, node); 25 | await callback(addressBooks); 26 | }); 27 | browser.contacts.onUpdated.addListener(async (node, changedProperties) => { 28 | await updateCacheOnContactUpdate(addressBooks, node); 29 | await callback(addressBooks); 30 | }); 31 | browser.contacts.onDeleted.addListener(async (addressBookId, contactId) => { 32 | await updateCacheOnContactDeletion(addressBooks, addressBookId, contactId); 33 | await callback(addressBooks); 34 | }); 35 | browser.addressBooks.onCreated.addListener((node) => { 36 | // This listener must be synchronous, because the "onCreated" listener for 37 | // contacts will fire after this one, and we need to have the address book 38 | // in the cache already. 39 | updateCacheOnAddressBookCreation(addressBooks, node); 40 | callback(addressBooks); 41 | }); 42 | browser.addressBooks.onUpdated.addListener(async (node) => { 43 | await updateCacheOnAddressBookUpdate(addressBooks, node); 44 | await callback(addressBooks); 45 | }); 46 | browser.addressBooks.onDeleted.addListener(async (node) => { 47 | await updateCacheOnAddressBookDeletion(addressBooks, node); 48 | await callback(addressBooks); 49 | }); 50 | } 51 | 52 | async function updateCacheOnContactCreation(addressBooks, node) { 53 | let addressBookId = node.parentId; 54 | const contact = parseContact(node); 55 | await createContactInCache( 56 | addressBooks.get(addressBookId), 57 | addressBooks.get("all-contacts"), 58 | contact 59 | ); 60 | } 61 | 62 | async function updateCacheOnContactUpdate( 63 | addressBooks, 64 | node 65 | ) { 66 | const newContact = parseContact(node); 67 | await modifyContactInCache( 68 | addressBooks.get(node.parentId), 69 | addressBooks.get("all-contacts"), 70 | newContact 71 | ); 72 | } 73 | 74 | async function updateCacheOnContactDeletion( 75 | addressBooks, 76 | addressBookId, 77 | contactId 78 | ) { 79 | await deleteContactInCache( 80 | addressBooks.get(addressBookId), 81 | addressBooks.get("all-contacts"), 82 | contactId 83 | ); 84 | } 85 | 86 | function updateCacheOnAddressBookCreation(addressBooks, { name, id }) { 87 | // Create the new address book 88 | // We don't deal with the contacts here, because the "onCreated" event for 89 | // contacts will fire for each contact in the address book. 90 | const newAddressBook = new AddressBook(name, new Map(), id); 91 | addressBooks.set(id, newAddressBook); 92 | } 93 | 94 | async function updateCacheOnAddressBookUpdate(addressBooks, { id, name }) { 95 | // This event is only fired if the name of the address book has been changed. 96 | addressBooks.get(id).name = name; 97 | } 98 | 99 | async function updateCacheOnAddressBookDeletion(addressBooks, addressBookId) { 100 | // 1. Update the "all-contacts" address book 101 | const addressBook = addressBooks.get(addressBookId); 102 | const virtualAddressBook = addressBooks.get("all-contacts"); 103 | for (const contactId of addressBook.contacts.keys()) { 104 | await deleteContactInCache( 105 | null, // We are going to delete the entire cache, so no need to delete the contacts. 106 | virtualAddressBook, 107 | contactId 108 | ); 109 | } 110 | // 2. Delete the address book 111 | addressBooks.delete(addressBookId); 112 | } 113 | -------------------------------------------------------------------------------- /src/modules/contacts/category-edit.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This module provides convenient helper methods to update the categories of 3 | * contacts in the Thunderbird backend. 4 | */ 5 | 6 | import { updateCategoriesForVCard } from "./contact.mjs"; 7 | import { 8 | lookupCategory, 9 | mergeCategory, 10 | stripCategory, 11 | } from "../cache/index.mjs"; 12 | 13 | /** 14 | * Remove a vCard from this category and all its subcategories. 15 | */ 16 | export async function removeCategoryFromVCard({ 17 | contactId, 18 | addressBook, 19 | categoryStr, 20 | }) { 21 | let contact = addressBook.contacts.get(contactId); 22 | let newCategories = stripCategory([...contact.categories], categoryStr); 23 | return updateCategoriesForVCard(contact, newCategories); 24 | } 25 | 26 | /** 27 | * Add a vCard to this category and all its parent categories. 28 | */ 29 | export async function addCategoryToVCard({ 30 | contactId, 31 | addressBook, 32 | categoryStr, 33 | }) { 34 | let contact = addressBook.contacts.get(contactId); 35 | let newCategories = mergeCategory([...contact.categories], categoryStr); 36 | return updateCategoriesForVCard(contact, newCategories); 37 | } 38 | 39 | /** 40 | * Replace a category name in all affected vCards (move/rename/remove). 41 | */ 42 | export async function replaceCategoryInVCards({ 43 | addressBook, 44 | addressBooks, 45 | oldCategoryStr, 46 | newCategoryStr, 47 | }) { 48 | let pendingAddressBooks = addressBook.id == "all-contacts" 49 | ? addressBooks.values() 50 | : [addressBook]; 51 | 52 | for (const ab of pendingAddressBooks) { 53 | if (ab.id == "all-contacts") { 54 | continue; 55 | } 56 | // Loop over all contacts of that category. 57 | const categoryObj = lookupCategory( 58 | ab, 59 | oldCategoryStr 60 | ); 61 | if (!categoryObj) { 62 | continue; 63 | } 64 | for (let [contactId, contact] of categoryObj.contacts) { 65 | let newCategories = stripCategory([...contact.categories], oldCategoryStr); 66 | mergeCategory(newCategories, newCategoryStr); 67 | await updateCategoriesForVCard(contact, newCategories); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/modules/contacts/contact.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This module includes methods to access Thunderbird contacts and read, parse and 3 | * update their vcard strings. 4 | */ 5 | 6 | import { 7 | removeImplicitCategories, 8 | expandImplicitCategories, 9 | } from "../cache/index.mjs"; 10 | import { arrayEqual, printToConsole } from "../utils.mjs"; 11 | // global object: ICAL from external ical.js 12 | 13 | const ERR_OPERATION_CANCEL = await browser.i18n.getMessage( 14 | "errors.operation-cancel" 15 | ); 16 | const ERR_CONTACT_UPDATE_FAILURE = await browser.i18n.getMessage( 17 | "errors.contact-update-failure" 18 | ); 19 | const ERR_OPCANCEL_UPDATE_FAILURE = 20 | ERR_OPERATION_CANCEL + " " + ERR_CONTACT_UPDATE_FAILURE; 21 | const ERR_HINT_MOST_LIKELY = await browser.i18n.getMessage( 22 | "errors.hint.most-likely" 23 | ); 24 | const ERR_HINT_COMMON_REASONS = await browser.i18n.getMessage( 25 | "errors.hint.common-reasons" 26 | ); 27 | const ERR_REASON_ADDRESS_BOOK_READONLY = await browser.i18n.getMessage( 28 | "errors.reason.address-book-readonly" 29 | ); 30 | const ERR_REASON_CONTACT_CHANGED = await browser.i18n.getMessage( 31 | "errors.reason.contact-changed" 32 | ); 33 | 34 | export function getError(str, id) { 35 | const error = new Error(`${str} 36 | ${ERR_HINT_COMMON_REASONS} 37 | 1. ${id === 1 ? ERR_HINT_MOST_LIKELY : ""}${ERR_REASON_ADDRESS_BOOK_READONLY} 38 | 2. ${id === 2 ? ERR_HINT_MOST_LIKELY : ""}${ERR_REASON_CONTACT_CHANGED}`); 39 | error.id = id; 40 | return error; 41 | } 42 | 43 | /** 44 | * Modify the category string of a vCard. 45 | * 46 | * @param {*} contact - contact to work on 47 | * @param {array} categories - new categories 48 | */ 49 | export async function updateCategoriesForVCard(contact, categories) { 50 | const { 51 | properties: { vCard }, 52 | } = await browser.contacts.get(contact.id); 53 | const component = new ICAL.Component(ICAL.parse(vCard)); 54 | 55 | const newCategories = removeImplicitCategories(categories) 56 | const cachedCategories = removeImplicitCategories([...contact.categories]); 57 | const backendCategories = removeImplicitCategories( 58 | component.getAllProperties("categories").flatMap((x) => x.getValues()) 59 | ); 60 | 61 | if (!arrayEqual(backendCategories, cachedCategories)) { 62 | printToConsole.error("Categories have been changed outside category manager!"); 63 | printToConsole.log("Currently stored categories", structuredClone(backendCategories)); 64 | printToConsole.log("Currently cached categories", structuredClone(cachedCategories)); 65 | throw getError(ERR_OPCANCEL_UPDATE_FAILURE, 2); 66 | } 67 | 68 | if (arrayEqual(backendCategories, newCategories)) { 69 | // No change, return 70 | printToConsole.warn("No change made to the vCard!"); 71 | return; 72 | } 73 | 74 | // Store new categories. 75 | component.removeAllProperties("categories"); 76 | if (newCategories.length > 0) { 77 | var categoriesProperty = new ICAL.Property("categories"); 78 | categoriesProperty.setValues(newCategories); 79 | component.addProperty(categoriesProperty); 80 | } 81 | 82 | const newVCard = component.toString(); 83 | printToConsole.log("new vCard:", newVCard); 84 | try { 85 | await browser.contacts.update(contact.id, { vCard: newVCard }); 86 | } catch (e) { 87 | printToConsole.error("Error when updating contact: ", e); 88 | throw getError(ERR_OPCANCEL_UPDATE_FAILURE, 1); 89 | } 90 | return null; 91 | } 92 | 93 | export function parseContact({ 94 | id, 95 | parentId, 96 | properties: { vCard, DisplayName, PrimaryEmail }, 97 | }) { 98 | const component = new ICAL.Component(ICAL.parse(vCard)); 99 | const categories = expandImplicitCategories(component 100 | .getAllProperties("categories") 101 | .flatMap((x) => x.getValues()) 102 | ); 103 | return { 104 | id, 105 | addressBookId: parentId, 106 | email: PrimaryEmail, 107 | name: DisplayName, 108 | categories: new Set(categories), 109 | }; 110 | } 111 | 112 | export function toRFC5322EmailAddress(value) { 113 | // Accepts: [email, name] or { email, name } 114 | let email, name; 115 | if (Array.isArray(value)) { 116 | [email, name] = value; 117 | } else { 118 | ({ email, name } = value); 119 | } 120 | return name ? `${name} <${email}>` : email; 121 | } 122 | -------------------------------------------------------------------------------- /src/modules/ui/context-menu-utils.mjs: -------------------------------------------------------------------------------- 1 | import { lookupCategory } from "../cache/addressbook.mjs"; 2 | import { 3 | categoryStringToArr, 4 | isContactInCategory, 5 | isContactInAnySubcategory, 6 | SUBCATEGORY_SEPARATOR, 7 | } from "../cache/index.mjs"; 8 | import { printToConsole } from "../utils.mjs"; 9 | 10 | let { type } = await browser.windows.getCurrent(); 11 | const MENU_TITLE_LOCALE_KEY = 12 | type == "messageCompose" 13 | ? "menu.category.add_members_to_current_message" 14 | : "menu.category.add_members_to_new_message"; 15 | const MENU_ADD_TITLE = await browser.i18n.getMessage(MENU_TITLE_LOCALE_KEY); 16 | const MENU_ADD_TO_TO = await browser.i18n.getMessage("menu.category.add_to_to"); 17 | const MENU_ADD_TO_CC = await browser.i18n.getMessage("menu.category.add_to_cc"); 18 | const MENU_ADD_TO_BCC = await browser.i18n.getMessage( 19 | "menu.category.add_to_bcc" 20 | ); 21 | const MENU_DELETE_CATEGORY = await browser.i18n.getMessage( 22 | "menu.category.delete" 23 | ); 24 | const MENU_HEADER_TEXT = await browser.i18n.getMessage( 25 | "menu.contact.context.manage_categories_of_contact" 26 | ); 27 | const MENU_RENAME_CATEGORY = await browser.i18n.getMessage("menu.category.rename"); 28 | 29 | function createMenu(properties) { 30 | return browser.menus.create({ 31 | ...properties, 32 | contexts: ["tab"], 33 | viewTypes: ["popup"], 34 | documentUrlPatterns: ["moz-extension://*/popup/popup.html"], 35 | }); 36 | } 37 | 38 | function createCheckBoxMenu({ 39 | id, 40 | title, 41 | checked = false, 42 | parentId = undefined, 43 | }) { 44 | return createMenu({ 45 | id, 46 | title: `${checked ? "☑ " : "☐ "} ${title}`, 47 | type: "normal", 48 | parentId, 49 | }); 50 | } 51 | 52 | export function destroyAllMenus() { 53 | browser.menus.removeAll(); 54 | } 55 | 56 | export function createMenuForCategoryTree(categoryElement) { 57 | createMenu({ 58 | id: "actionTitle", 59 | enabled: false, 60 | title: MENU_ADD_TITLE, 61 | }); 62 | createMenu({ 63 | id: "addToTO", 64 | title: MENU_ADD_TO_TO, 65 | }); 66 | createMenu({ 67 | id: "addToCC", 68 | title: MENU_ADD_TO_CC, 69 | }); 70 | createMenu({ 71 | id: "addToBCC", 72 | title: MENU_ADD_TO_BCC, 73 | }); 74 | if (!categoryElement.dataset.uncategorized) { 75 | // Add an option to delete this category 76 | createSeparator(); 77 | createMenu({ id: "renameCategory", title: MENU_RENAME_CATEGORY }); 78 | createMenu({ id: "deleteCategory", title: MENU_DELETE_CATEGORY }); 79 | } 80 | } 81 | 82 | async function createCategoryEditingMenuRecursively( 83 | category, 84 | contactId, 85 | prefix = "", 86 | parentId = undefined 87 | ) { 88 | const menuId = prefix + category.name; 89 | const checked = isContactInCategory(category, contactId); 90 | 91 | createCheckBoxMenu({ 92 | id: menuId, 93 | title: category.name, 94 | checked, 95 | parentId, 96 | }); 97 | 98 | // Add submenu entries. 99 | if (checked) { 100 | let remove_string_key = isContactInAnySubcategory(category, contactId) 101 | ? "menu.contact.context.remove_from_category_recursively" 102 | : "menu.contact.context.remove_from_category"; 103 | createMenu({ 104 | id: "@" + menuId.slice(1), 105 | title: await browser.i18n.getMessage(remove_string_key, category.name), 106 | parentId: menuId, 107 | }); 108 | } else { 109 | createMenu({ 110 | id: "%" + menuId.slice(1), 111 | title: await browser.i18n.getMessage( 112 | "menu.contact.context.add_to_category", 113 | category.name 114 | ), 115 | parentId: menuId, 116 | }); 117 | } 118 | 119 | if (category.categories.size > 0) { 120 | createSeparator(menuId); 121 | for (const subcategory of category.categories.values()) { 122 | await createCategoryEditingMenuRecursively( 123 | subcategory, 124 | contactId, 125 | menuId + SUBCATEGORY_SEPARATOR, 126 | menuId 127 | ); 128 | } 129 | } 130 | 131 | createSeparator(menuId); 132 | createMenu({ 133 | id: "$" + menuId.slice(1), 134 | title: await browser.i18n.getMessage( 135 | "menu.contact.context.add_to_new_sub_category", 136 | category.name 137 | ), 138 | parentId: menuId, 139 | }); 140 | } 141 | 142 | let separatorIdCounter = 0; 143 | function createSeparator(parentId = undefined) { 144 | return createMenu({ 145 | id: `separator-${separatorIdCounter++}`, 146 | type: "separator", 147 | parentId, 148 | }); 149 | } 150 | 151 | export function createDispatcherForContactListContextMenu({ 152 | onDeletion, 153 | onAddition, 154 | }) { 155 | return async function (menuId) { 156 | const categoryStr = menuId.slice(1); 157 | switch (menuId.charAt(0)) { 158 | case "!": 159 | case "@": 160 | await onDeletion(categoryStr); 161 | break; 162 | case "$": 163 | await onAddition(categoryStr, true); 164 | break; 165 | case "%": 166 | await onAddition(categoryStr, false); 167 | break; 168 | case "#": 169 | printToConsole.error("This menu item should not be clickable!"); 170 | break; 171 | default: 172 | printToConsole.error("Unknown menu id:", menuId); 173 | break; 174 | } 175 | }; 176 | } 177 | 178 | export async function createMenuForContact(addressBook, contactId, categoryElm) { 179 | // Symbols: 180 | // @: remove from category 181 | // #: normal category 182 | // $: 183 | // %: 184 | 185 | // Menu: 186 | // - Manage belonging categories 187 | // - Add to ... 188 | createMenu({ 189 | id: "header", 190 | title: MENU_HEADER_TEXT, 191 | enabled: false, 192 | }); 193 | 194 | 195 | if (categoryElm && categoryElm.dataset.category && !categoryElm.dataset.uncategorized) { 196 | let categoryStr = categoryElm.dataset.category; 197 | let categoryObj = lookupCategory(addressBook, categoryStr); 198 | let remove_string_key = isContactInAnySubcategory(categoryObj, contactId) 199 | ? "menu.contact.context.remove_from_category_recursively" 200 | : "menu.contact.context.remove_from_category"; 201 | createMenu({ 202 | id: "!" + categoryStr, 203 | title: await browser.i18n.getMessage(remove_string_key, categoryStringToArr(categoryStr).pop()), 204 | }); 205 | } 206 | 207 | if (addressBook.categories.size > 0) { 208 | createSeparator(); 209 | for (const category of addressBook.categories.values()) { 210 | // Add # prefix to avoid id conflicts 211 | await createCategoryEditingMenuRecursively(category, contactId, "#"); 212 | } 213 | } 214 | 215 | createSeparator(); 216 | 217 | createMenu({ 218 | id: "$", 219 | title: await browser.i18n.getMessage( 220 | "menu.contact.context.add_to_new_top_level_category" 221 | ), 222 | }); 223 | } 224 | -------------------------------------------------------------------------------- /src/modules/ui/ui.mjs: -------------------------------------------------------------------------------- 1 | import { render } from "./reef.mjs"; 2 | 3 | export class Component { 4 | element; // The DOM element 5 | data; 6 | template; 7 | debounce = null; 8 | constructor({ element, data, template, ...rest }) { 9 | this.element = document.querySelector(element); 10 | this.data = data; 11 | this.template = template; 12 | for (const key in rest) { 13 | let value = rest[key]; 14 | if (typeof value === "function") { 15 | value = value.bind(this); 16 | } 17 | this[key] = value; 18 | } 19 | } 20 | async render() { 21 | const templated = this.template(this.data); 22 | // Cache instance 23 | let self = this; 24 | // If there's a pending render, cancel it 25 | if (self.debounce) { 26 | window.cancelAnimationFrame(self.debounce); 27 | } 28 | return new Promise((resolve) => { 29 | // Setup the new render to run at the next animation frame 30 | self.debounce = window.requestAnimationFrame(function () { 31 | render(self.element, templated, false); 32 | resolve(); 33 | }); 34 | }); 35 | } 36 | update(data) { 37 | this.data = data; 38 | return this.render(); 39 | } 40 | } 41 | 42 | export function escapeHtmlAttr(unsafe) { 43 | // taken from https://stackoverflow.com/questions/6234773/can-i-escape-html-special-chars-in-javascript 44 | return unsafe 45 | .replaceAll("&", "&") 46 | .replaceAll("<", "<") 47 | .replaceAll(">", ">") 48 | .replaceAll('"', """) 49 | .replaceAll("'", "'"); 50 | } 51 | 52 | export function escapeHtmlContent(input) { 53 | return escapeHtmlAttr(input).replaceAll(" ", " "); 54 | } 55 | -------------------------------------------------------------------------------- /src/modules/utils.mjs: -------------------------------------------------------------------------------- 1 | export function setEqual(xs, ys) { 2 | return xs.size === ys.size && [...xs].every((x) => ys.has(x)); 3 | } 4 | 5 | // Set intersection for ES6 Set. 6 | export function setIntersection(a, b) { 7 | return new Set([...a].filter((x) => b.has(x))); 8 | } 9 | 10 | export function arrayEqual(a, b) { 11 | return a.length === b.length && a.every(val => b.includes(val)); 12 | } 13 | 14 | async function initLog() { 15 | let { logToConsole } = await browser.storage.local.get( { logToConsole: null }); 16 | // Set a default so it can be toggled via the add-on inspector storage tab. 17 | if (logToConsole == null) { 18 | logToConsole = false; 19 | await browser.storage.local.set( { logToConsole }); 20 | } 21 | window.logToConsole = logToConsole; 22 | console.info(`CategoryManager.logToConsole is set to ${logToConsole}`); 23 | } 24 | 25 | function printLog(type, ...args) { 26 | if (window.logToConsole) { 27 | console[type](...args); 28 | } 29 | } 30 | 31 | function _printToConsole(type, ...args) { 32 | // This function is synchronous but returns a Promise when called the first 33 | // time in a given window, which fulfills once the storage has been read. 34 | if (!window.hasOwnProperty("logToConsole")) { 35 | return initLog().then(() => printLog(type, ...args)); 36 | } 37 | return printLog(type, ...args); 38 | } 39 | 40 | export const printToConsole = { 41 | debug: (...args) => _printToConsole("debug", ...args), 42 | error: (...args) => _printToConsole("error", ...args), 43 | info: (...args) => _printToConsole("info", ...args), 44 | log: (...args) => _printToConsole("log", ...args), 45 | warn: (...args) => _printToConsole("warn", ...args), 46 | } 47 | -------------------------------------------------------------------------------- /src/popup/address-book-list.mjs: -------------------------------------------------------------------------------- 1 | import { escapeHtmlAttr, Component } from "../modules/ui/ui.mjs"; 2 | 3 | function writeAddressBookElement(addressBook, activeAddressBookId) { 4 | let name = escapeHtmlAttr(addressBook.name); 5 | let className = 6 | activeAddressBookId === addressBook.id ? 'class="selected"' : ""; 7 | return `
  • ${name}
  • `; 8 | } 9 | 10 | export function createAddressBookList({ 11 | addressBooks, 12 | activeAddressBookId, 13 | components: { categoryTitle, contactList, categoryTree }, 14 | }) { 15 | const state = window.state; 16 | let component = new Component({ 17 | element: "#address-books", 18 | data: { addressBooks, activeAddressBookId }, 19 | template({ addressBooks, activeAddressBookId }) { 20 | let elements = addressBooks 21 | .map((x) => writeAddressBookElement(x, activeAddressBookId)) 22 | .join("\n"); 23 | return elements; 24 | }, 25 | }); 26 | async function click({ target }) { 27 | const addressBookId = target.dataset.addressBook; 28 | if (addressBookId == null) return; 29 | state.currentAddressBook = state.addressBooks.get(addressBookId); 30 | state.currentCategoryElement = null; 31 | categoryTitle.innerText = state.currentAddressBook.name; 32 | for (const e of target.parentElement.children) { 33 | e.classList.remove("selected"); 34 | } 35 | target.classList.toggle("selected"); 36 | return Promise.all([ 37 | categoryTree.update({ 38 | addressBook: state.currentAddressBook, 39 | activeCategory: null, 40 | }), 41 | contactList.update({ 42 | addressBook: state.currentAddressBook, 43 | contacts: state.currentAddressBook.contacts, 44 | }), 45 | ]); 46 | } 47 | component.element.addEventListener("click", click); 48 | return component; 49 | } 50 | -------------------------------------------------------------------------------- /src/popup/address-book.css: -------------------------------------------------------------------------------- 1 | /* Address Book List */ 2 | 3 | #address-books { 4 | padding-left: 0; 5 | font-size: 1rem; 6 | user-select: none; 7 | } 8 | 9 | #address-books > li::before { 10 | font: var(--fa-font-solid); 11 | content: "\f2b9"; 12 | margin-right: 0.4rem; 13 | } 14 | 15 | #address-books > li { 16 | display: block; 17 | padding-block: 0.5rem; 18 | transition: color 0.2s; 19 | } 20 | 21 | #address-books > li[data-address-book="all-contacts"] { 22 | border-bottom: 0.2rem solid var(--catman-foreground); 23 | } 24 | 25 | #address-books > li.selected { 26 | color: var(--catman-highlight); 27 | } 28 | 29 | #address-books > li:hover { 30 | color: var(--catman-hover); 31 | } 32 | -------------------------------------------------------------------------------- /src/popup/category-tree.css: -------------------------------------------------------------------------------- 1 | /* Tree component */ 2 | 3 | #tree > summary { 4 | display: block; 5 | cursor: pointer; 6 | outline: 0; 7 | } 8 | 9 | .tree-nav__item { 10 | display: block; 11 | white-space: nowrap; 12 | position: relative; 13 | } 14 | 15 | .tree-nav__item.is-expandable::before { 16 | /* vertical line */ 17 | border-left: 0.1rem solid #333; 18 | content: ""; 19 | height: 100%; 20 | left: 0.9rem; 21 | position: absolute; 22 | top: 2rem; 23 | height: calc(100% - 2rem); 24 | } 25 | 26 | .tree-nav__item > p.tree-nav__item-title { 27 | /* Leaf element */ 28 | display: inline; 29 | line-height: 2rem; 30 | } 31 | 32 | .tree-nav__item-title { 33 | cursor: pointer; 34 | display: block; 35 | outline: 0; 36 | font-size: 1rem; 37 | line-height: 2rem; 38 | transition: color 0.2s; 39 | cursor: context-menu; 40 | } 41 | 42 | .tree-nav__item-title[data-uncategorized] { 43 | font-style: italic; 44 | font-weight: bold; 45 | } 46 | 47 | .tree-nav__item.new-category { 48 | display: none; 49 | font-weight: bold; 50 | color: fuchsia; 51 | } 52 | 53 | .tree-nav__item.new-category.show { 54 | display: block !important; 55 | } 56 | 57 | .tree-nav__item-title:hover { 58 | color: var(--catman-hover); 59 | } 60 | 61 | .tree-nav__item-title.active { 62 | color: var(--catman-highlight); 63 | } 64 | 65 | .tree-nav__item-title.drag-over { 66 | color: var(--catman-hover); 67 | } 68 | 69 | .tree-nav__item > p.tree-nav__item-title::before { 70 | /* Leaf element marker */ 71 | position: absolute; 72 | display: inline-block; 73 | font: var(--fa-font-regular); 74 | content: "\f111"; 75 | text-align: center; 76 | top: 0; 77 | left: 0; 78 | padding: 0.5rem; 79 | } 80 | 81 | .tree-nav__item .tree-nav__item { 82 | margin-left: 2rem; 83 | } 84 | 85 | .tree-nav__item > .tree-nav__item-title { 86 | padding-left: 2rem; 87 | } 88 | 89 | .tree-nav__item.is-expandable[open] 90 | > .tree-nav__item-title 91 | > .tree-nav__expander { 92 | /* rotate the icon on expand */ 93 | transform: rotate(90deg); 94 | } 95 | 96 | .tree-nav__item.is-expandable > .tree-nav__item-title > .tree-nav__expander { 97 | /* icon: expand */ 98 | position: absolute; 99 | will-change: transform; 100 | transition: transform 300ms ease; 101 | font-size: 1rem; 102 | text-align: center; 103 | display: inline-block; 104 | top: 0; 105 | left: 0; 106 | /* Make the clickable area larger */ 107 | padding-inline: 0.5rem; 108 | padding-block: 0.5rem; 109 | } 110 | -------------------------------------------------------------------------------- /src/popup/compose.mjs: -------------------------------------------------------------------------------- 1 | import { toRFC5322EmailAddress } from "../modules/contacts/contact.mjs"; 2 | 3 | export async function addContactsToComposeDetails(fieldName, state, contacts) { 4 | const details = await browser.compose.getComposeDetails(state.tab.id); 5 | const addresses = details[fieldName]; 6 | 7 | let map = new Map(); 8 | addresses.forEach((addr) => { 9 | const { address, name } = emailAddresses.parseOneAddress(addr); 10 | map.set(address, name); 11 | }); 12 | 13 | // Remove contacts that do not have an email address and map the filtered 14 | // contacts to rfc 5322 email address format. 15 | contacts.forEach(contact => { 16 | // Add this contact if it doesn't exist in the map 17 | const { name, email } = contact; 18 | if (email != null && !map.has(email)) map.set(email, name); 19 | }); 20 | 21 | // Set compose details. 22 | const emailList = [...map.entries()].map(toRFC5322EmailAddress); 23 | return browser.compose.setComposeDetails(state.tab.id, { 24 | ...details, 25 | [fieldName]: emailList, 26 | }); 27 | } 28 | 29 | export async function openComposeWindowWithContacts( 30 | fieldName, 31 | state, 32 | contacts, 33 | categoryPath 34 | ) { 35 | let map = new Map(); 36 | 37 | // Remove contacts that do not have an email address and map the filtered 38 | // contacts to rfc 5322 email address format. 39 | contacts.forEach(contact => { 40 | const { name, email } = contact; 41 | if (email != null && !map.has(email)) map.set(email, name); 42 | }); 43 | 44 | // Open compose window. 45 | const emailList = [...map.entries()].map(toRFC5322EmailAddress); 46 | return browser.compose.beginNew(null, { 47 | [fieldName]: emailList, 48 | subject: `[${categoryPath}]`, 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/popup/contact-list.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | escapeHtmlContent, 4 | escapeHtmlAttr, 5 | } from "../modules/ui/ui.mjs"; 6 | 7 | export function createContactList(data) { 8 | let component = new Component({ 9 | element: "#contacts", 10 | data, 11 | template(data) { 12 | let html = []; 13 | if (data?.addressBook != null) { 14 | data.contacts.forEach((contact, id) => { 15 | const { name, email, addressBookId } = contact; 16 | html.push( 17 | `
  • 21 |

    22 | ${ 23 | name != null 24 | ? escapeHtmlContent(name) 25 | : 'Unnamed' 26 | } 27 |

    28 | 35 |
  • ` 36 | ); 37 | }) 38 | } 39 | return `
      ${html.join("\n")}
    `; 40 | }, 41 | }); 42 | component.element.addEventListener("dragstart", (e) => { 43 | if (!window.state.allowEdit) { 44 | e.preventDefault(); 45 | return; 46 | } 47 | e.dataTransfer.effectAllowed = "copy"; 48 | e.dataTransfer.setData( 49 | "category-manager/contact", 50 | e.target.dataset.addressbook + "\n" + e.target.dataset.id 51 | ); 52 | }); 53 | return component; 54 | } 55 | -------------------------------------------------------------------------------- /src/popup/contact.css: -------------------------------------------------------------------------------- 1 | /* Contact Row */ 2 | 3 | #contacts > ul { 4 | padding: 0; 5 | margin: 0; 6 | } 7 | 8 | #contacts > ul > li:not(:last-child) { 9 | margin-bottom: 0.5rem; 10 | } 11 | 12 | .contact-row { 13 | display: flex; 14 | flex-direction: column; 15 | box-sizing: border-box; 16 | cursor: context-menu; 17 | } 18 | 19 | .contact-row > p { 20 | margin: 0; 21 | } 22 | 23 | .contact-row__name { 24 | font-size: 1.05rem; 25 | font-weight: bold; 26 | } 27 | 28 | .contact-row__email { 29 | font-size: 0.95rem; 30 | } 31 | 32 | .contact-row__name .no-name { 33 | font-style: italic; 34 | color: red; 35 | } 36 | 37 | .contact-row__email .no-email { 38 | color: orangered; 39 | } 40 | -------------------------------------------------------------------------------- /src/popup/context-menu.mjs: -------------------------------------------------------------------------------- 1 | // ------------------- 2 | // Native Context Menu 3 | // ------------------- 4 | 5 | import { createDispatcherForContactListContextMenu } from "../modules/ui/context-menu-utils.mjs"; 6 | import { printToConsole } from "../modules/utils.mjs"; 7 | import { 8 | addContactsToComposeDetails, 9 | openComposeWindowWithContacts, 10 | } from "./compose.mjs"; 11 | import { lookupContactsByCategoryElement } from "./utils.mjs"; 12 | import { 13 | createMenuForCategoryTree, 14 | createMenuForContact, 15 | destroyAllMenus, 16 | } from "../modules/ui/context-menu-utils.mjs"; 17 | import { 18 | getCategoryStringFromInput, 19 | showCategoryInputModalAsync, 20 | } from "./modal.mjs"; 21 | import { 22 | addCategoryToVCard, 23 | replaceCategoryInVCards, 24 | removeCategoryFromVCard, 25 | } from "../modules/contacts/category-edit.mjs"; 26 | 27 | function makeCategoryMenuHandler(fieldName) { 28 | const state = window.state; 29 | return async (categoryElement) => { 30 | const contacts = lookupContactsByCategoryElement( 31 | categoryElement, 32 | state.currentAddressBook 33 | ); 34 | if (state.isComposeAction) { 35 | await addContactsToComposeDetails(fieldName, state, contacts); 36 | } else { 37 | await openComposeWindowWithContacts( 38 | fieldName, 39 | state, 40 | contacts, 41 | categoryElement.dataset.category 42 | ); 43 | } 44 | window.close(); 45 | }; 46 | } 47 | 48 | function overrideMenuForCategoryTree(categoryElement) { 49 | destroyAllMenus(); 50 | createMenuForCategoryTree(categoryElement); 51 | } 52 | 53 | async function overrideMenuForContactList() { 54 | const state = window.state; 55 | destroyAllMenus(); 56 | await createMenuForContact( 57 | state.currentAddressBook, 58 | state.elementForContextMenu.dataset.id, 59 | state.currentCategoryElement, 60 | ); 61 | } 62 | 63 | export function initContextMenu() { 64 | const state = window.state; 65 | const contextMenuHandlers = { 66 | addToTO: makeCategoryMenuHandler("to"), 67 | addToCC: makeCategoryMenuHandler("cc"), 68 | addToBCC: makeCategoryMenuHandler("bcc"), 69 | async deleteCategory(categoryElement) { 70 | await replaceCategoryInVCards({ 71 | addressBook: state.currentAddressBook, 72 | addressBooks: state.addressBooks, 73 | oldCategoryStr: categoryElement.dataset.category, 74 | newCategoryStr: "", 75 | }); 76 | }, 77 | async renameCategory(categoryElement) { 78 | const oldCategoryStr = categoryElement.dataset.category; 79 | const newCategoryStr = await showCategoryInputModalAsync(oldCategoryStr); 80 | if (newCategoryStr == null) return; 81 | await replaceCategoryInVCards({ 82 | addressBook: state.currentAddressBook, 83 | addressBooks: state.addressBooks, 84 | oldCategoryStr, 85 | newCategoryStr, 86 | }); 87 | }, 88 | }; 89 | const dispatchMenuEventsForContactList = 90 | createDispatcherForContactListContextMenu({ 91 | async onDeletion(categoryStr) { 92 | const contactId = state.elementForContextMenu.dataset.id; 93 | const addressBookId = state.elementForContextMenu.dataset.addressbook; 94 | const addressBook = state.addressBooks.get(addressBookId); 95 | await removeCategoryFromVCard({ 96 | addressBook, 97 | contactId, 98 | categoryStr, 99 | }); 100 | }, 101 | async onAddition(categoryStr, createSubCategory) { 102 | const contactId = state.elementForContextMenu.dataset.id; 103 | const addressBookId = state.elementForContextMenu.dataset.addressbook; 104 | const addressBook = state.addressBooks.get(addressBookId); 105 | if (createSubCategory) { 106 | const subcategory = await getCategoryStringFromInput(categoryStr); 107 | if (subcategory == null) return; 108 | categoryStr = subcategory; 109 | } 110 | await addCategoryToVCard({ 111 | addressBook, 112 | contactId, 113 | categoryStr, 114 | }); 115 | }, 116 | }); 117 | 118 | document.addEventListener("contextmenu", async (e) => { 119 | if (!state.allowEdit) { 120 | e.preventDefault(); 121 | return; 122 | } 123 | browser.menus.overrideContext({ context: "tab", tabId: state.tab.id }); 124 | state.elementForContextMenu = e.target; 125 | printToConsole.log(state.elementForContextMenu); 126 | // Check if the right click originates from contact list 127 | if (state.elementForContextMenu.parentNode.dataset.id != null) { 128 | // Right click on contact info 129 | state.elementForContextMenu = state.elementForContextMenu.parentNode; 130 | await overrideMenuForContactList(); 131 | return; 132 | } else if (state.elementForContextMenu.dataset.id != null) { 133 | await overrideMenuForContactList(); 134 | return; 135 | } 136 | overrideMenuForCategoryTree(state.elementForContextMenu); 137 | // Check if the right click originates from category tree 138 | if (state.elementForContextMenu.nodeName === "I") 139 | // Right click on the expander icon. Use the parent element 140 | state.elementForContextMenu = state.elementForContextMenu.parentNode; 141 | if (state.elementForContextMenu.dataset.category == null) 142 | // No context menu outside category tree 143 | e.preventDefault(); 144 | }); 145 | 146 | browser.menus.onClicked.addListener(async ({ menuItemId }) => { 147 | const handler = contextMenuHandlers[menuItemId]; 148 | try { 149 | state.allowEdit = false; 150 | if (handler != null) { 151 | await handler(state.elementForContextMenu); 152 | } else { 153 | await dispatchMenuEventsForContactList(menuItemId); 154 | } 155 | } finally { 156 | state.allowEdit = true; 157 | } 158 | }); 159 | } 160 | -------------------------------------------------------------------------------- /src/popup/drag-menu.css: -------------------------------------------------------------------------------- 1 | /* from https://jsfiddle.net/u2kJq/241/ */ 2 | 3 | .custom-menu { 4 | display: none; 5 | z-index: 1000; 6 | position: absolute; 7 | overflow: hidden; 8 | border: 1px solid #ccc; 9 | white-space: nowrap; 10 | font-family: sans-serif; 11 | font-size: 1rem; 12 | background: var(--catman-background); 13 | color: var(--catman-foreground); 14 | padding: 0; 15 | margin: 0; 16 | border-radius: 0.5rem; 17 | list-style-type: none; 18 | user-select: none; 19 | } 20 | 21 | .custom-menu.show { 22 | display: block; 23 | } 24 | 25 | .custom-menu li { 26 | padding: 0.3rem 0.8rem; 27 | cursor: pointer; 28 | } 29 | 30 | .custom-menu li:hover { 31 | background-color: rgba(9, 113, 231, 0.4); 32 | } 33 | -------------------------------------------------------------------------------- /src/popup/drag-menu.mjs: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------- 2 | // Custom Context Menu for drag and drop on category tree 3 | // ------------------------------------------------------- 4 | 5 | import { printToConsole, setIntersection } from "../modules/utils.mjs"; 6 | import { getCategoryStringFromInput } from "./modal.mjs"; 7 | import { addCategoryToVCard } from "../modules/contacts/category-edit.mjs"; 8 | 9 | const customMenu = document.getElementById("custom-menu"); 10 | 11 | async function updateCustomMenu( 12 | allowedActions, 13 | currentDraggingOverCategoryElement 14 | ) { 15 | for (const item of customMenu.children) { 16 | item.style.display = allowedActions.has(item.id) ? "block" : "none"; 17 | } 18 | // Update the text 19 | const menuItemKey = 20 | currentDraggingOverCategoryElement.nodeName == "NAV" 21 | ? "menu.contact.drag.add_to_new_category" 22 | : "menu.contact.drag.add_to_this_category"; 23 | customMenu.children[0].innerText = await browser.i18n.getMessage(menuItemKey); 24 | } 25 | 26 | const ALLOWED_ACTIONS_ON_NEW_CATEGORY = new Set(["menu-add"]); 27 | const ALLOWED_ACTIONS_DEFAULT = new Set(["menu-add", "menu-add-sub"]); 28 | const ALLOWED_ACTIONS_FROM_NOWHERE = new Set(["menu-add", "menu-add-sub"]); 29 | 30 | document.getElementById("menu-add").innerText = await browser.i18n.getMessage( 31 | "menu.contact.drag.add_to_this_category" 32 | ); 33 | document.getElementById("menu-add-sub").innerText = 34 | await browser.i18n.getMessage("menu.contact.drag.add_to_subcategory"); 35 | 36 | export async function showCustomMenu( 37 | x, 38 | y, 39 | { currentDraggingOverCategoryElement, currentCategoryElement } 40 | ) { 41 | customMenu.style.top = y + "px"; 42 | customMenu.style.left = x + "px"; 43 | let allowedActions = ALLOWED_ACTIONS_DEFAULT; 44 | if ( 45 | // Dragging over new category or empty area 46 | currentDraggingOverCategoryElement.classList.contains( 47 | "new-category-title" 48 | ) || 49 | currentDraggingOverCategoryElement.nodeName == "NAV" 50 | ) { 51 | allowedActions = setIntersection( 52 | allowedActions, 53 | ALLOWED_ACTIONS_ON_NEW_CATEGORY 54 | ); 55 | } 56 | if (currentCategoryElement == null) { 57 | allowedActions = setIntersection( 58 | allowedActions, 59 | ALLOWED_ACTIONS_FROM_NOWHERE 60 | ); 61 | } 62 | await updateCustomMenu(allowedActions, currentDraggingOverCategoryElement); 63 | customMenu.classList.add("show"); 64 | } 65 | 66 | export function hideCustomMenu() { 67 | customMenu.classList.remove("show"); 68 | } 69 | 70 | export function initCustomMenu(categoryTree) { 71 | const state = window.state; 72 | document.addEventListener("mousedown", (e) => { 73 | let element = e.target; 74 | while (element !== customMenu && element != null) { 75 | element = element.parentElement; 76 | } 77 | if (element == null) { 78 | customMenu.classList.remove("show"); 79 | categoryTree.hideNewCategory(); 80 | categoryTree.hideDragOverHighlight(); 81 | state.currentContactDataFromDragAndDrop = null; 82 | } 83 | }); 84 | customMenu.addEventListener("click", async (e) => { 85 | if (state.currentContactDataFromDragAndDrop == null) { 86 | printToConsole.error("No contact info from drag & drop!"); 87 | return; 88 | } 89 | let categoryStr; 90 | hideCustomMenu(); 91 | const [addressBookId, contactId] = 92 | state.currentContactDataFromDragAndDrop.split("\n"); 93 | const addressBook = state.addressBooks.get(addressBookId); 94 | state.allowEdit = false; 95 | try { 96 | switch (e.target.id) { 97 | case "menu-add": 98 | // Get user input if dragging onto [ New Category ] 99 | categoryStr = 100 | state.currentDraggingOverCategoryElement.dataset.category ?? 101 | (await getCategoryStringFromInput()); 102 | if (categoryStr == null) break; 103 | await addCategoryToVCard({ 104 | addressBook, 105 | contactId, 106 | categoryStr 107 | }); 108 | break; 109 | case "menu-add-sub": 110 | categoryStr = await getCategoryStringFromInput( 111 | state.currentDraggingOverCategoryElement.dataset.category 112 | ); 113 | if (categoryStr == null) break; 114 | await addCategoryToVCard({ 115 | addressBook, 116 | contactId, 117 | categoryStr 118 | }); 119 | break; 120 | default: 121 | printToConsole.error("Unknown action! from", e.target); 122 | break; 123 | } 124 | state.currentContactDataFromDragAndDrop = null; 125 | categoryTree.hideNewCategory(); 126 | categoryTree.hideDragOverHighlight(); 127 | } finally { 128 | state.allowEdit = true; 129 | } 130 | }); 131 | } 132 | -------------------------------------------------------------------------------- /src/popup/error-handler.mjs: -------------------------------------------------------------------------------- 1 | import { showErrorModal } from "./modal.mjs"; 2 | function errorHandler(e) { 3 | console.log(e); 4 | showErrorModal(e.message); 5 | } 6 | 7 | export function initErrorHandler() { 8 | window.addEventListener("error", errorHandler); 9 | window.addEventListener("unhandledrejection", ({ reason }) => 10 | errorHandler(reason) 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/popup/layout.css: -------------------------------------------------------------------------------- 1 | /* Grid layout */ 2 | 3 | #container { 4 | display: grid; 5 | /* TODO: determine the max width of tree view in JS. use 15rem for now */ 6 | grid-template-columns: auto 15rem 14rem; 7 | grid-template-rows: 2rem auto auto; 8 | gap: 0.5rem 2rem; 9 | grid-template-areas: 10 | "header header header" 11 | "address-books category-tree contacts" 12 | "info category-tree contacts"; 13 | /* No idea why `calc(100vh - 2rem)` doesn't work */ 14 | height: calc(600px - 2rem); 15 | color: var(--catman-foreground); 16 | } 17 | 18 | #address-books { 19 | grid-area: address-books; 20 | overflow: auto; 21 | margin: 0; 22 | } 23 | 24 | #tree { 25 | grid-area: category-tree; 26 | user-select: none; 27 | overflow: auto; 28 | } 29 | 30 | #contacts { 31 | grid-area: contacts; 32 | position: relative; 33 | overflow: auto; 34 | } 35 | 36 | #category-title { 37 | grid-area: header; 38 | margin: 0; 39 | font-size: 1rem; 40 | text-align: right; 41 | line-height: 2rem; 42 | background: var(--catman-background); 43 | } 44 | 45 | #info { 46 | grid-area: info; 47 | display: flex; 48 | flex-direction: row; 49 | } 50 | 51 | #spinner { 52 | align-self: flex-end; 53 | display: none; 54 | align-items: center; 55 | } 56 | 57 | #spinner.show { 58 | display: flex; 59 | } 60 | 61 | #spinner-icon { 62 | background-image: url("../images/Pulse-96px.gif"); 63 | background-size: contain; 64 | height: 48px; 65 | width: 48px; 66 | } 67 | 68 | #spinner-text { 69 | font-size: 1.3rem; 70 | } 71 | 72 | /* Text when no address book is available */ 73 | #info-text { 74 | display: none; 75 | position: absolute; 76 | text-align: center; 77 | margin: auto; 78 | top: 50%; 79 | left: 50%; 80 | transform: translate(-50%, -50%); 81 | font-size: 3rem; 82 | } 83 | -------------------------------------------------------------------------------- /src/popup/modal.css: -------------------------------------------------------------------------------- 1 | /**************************\ 2 | Basic Modal Styles 3 | \**************************/ 4 | 5 | .modal { 6 | font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, 7 | helvetica neue, helvetica, ubuntu, roboto, noto, segoe ui, arial, sans-serif; 8 | user-select: none; 9 | } 10 | 11 | .modal__overlay { 12 | position: fixed; 13 | top: 0; 14 | left: 0; 15 | right: 0; 16 | bottom: 0; 17 | background: rgba(0, 0, 0, 0.6); 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | } 22 | 23 | .modal__container { 24 | background-color: var(--catman-background); 25 | padding: 30px; 26 | max-width: 500px; 27 | max-height: 100vh; 28 | border-radius: 4px; 29 | overflow-y: auto; 30 | box-sizing: border-box; 31 | } 32 | 33 | .modal__header { 34 | display: flex; 35 | justify-content: space-between; 36 | align-items: center; 37 | } 38 | 39 | .modal__footer { 40 | display: flex; 41 | justify-content: end; 42 | } 43 | 44 | .modal__title { 45 | margin-top: 0; 46 | margin-bottom: 0; 47 | font-weight: 600; 48 | font-size: 1.25rem; 49 | line-height: 1.25; 50 | color: #00449e; 51 | box-sizing: border-box; 52 | } 53 | 54 | .modal__close { 55 | background: transparent; 56 | border: 0; 57 | } 58 | 59 | .modal__header .modal__close:before { 60 | content: "\2715"; 61 | } 62 | 63 | .modal__content { 64 | margin-top: 2rem; 65 | margin-bottom: 2rem; 66 | line-height: 1.5; 67 | color: var(--catman-foreground); 68 | } 69 | 70 | .modal__btn { 71 | font-size: 0.875rem; 72 | padding-left: 1rem; 73 | padding-right: 1rem; 74 | padding-top: 0.5rem; 75 | padding-bottom: 0.5rem; 76 | background-color: #e6e6e6; 77 | color: rgba(0, 0, 0, 0.8); 78 | border-radius: 0.25rem; 79 | border-style: none; 80 | border-width: 0; 81 | cursor: pointer; 82 | text-transform: none; 83 | overflow: visible; 84 | line-height: 1.15; 85 | margin: 0; 86 | will-change: transform; 87 | backface-visibility: hidden; 88 | transform: translateZ(0); 89 | transition: transform 0.25s ease-out; 90 | margin-inline-start: 1rem; 91 | } 92 | 93 | .modal__btn:focus, 94 | .modal__btn:hover { 95 | -webkit-transform: scale(1.05); 96 | transform: scale(1.05); 97 | } 98 | 99 | .modal__btn-primary { 100 | background-color: #00449e; 101 | color: #fff; 102 | } 103 | 104 | /**************************\ 105 | Demo Animation Style 106 | \**************************/ 107 | @keyframes mmfadeIn { 108 | from { 109 | opacity: 0; 110 | } 111 | to { 112 | opacity: 1; 113 | } 114 | } 115 | 116 | @keyframes mmfadeOut { 117 | from { 118 | opacity: 1; 119 | } 120 | to { 121 | opacity: 0; 122 | } 123 | } 124 | 125 | @keyframes mmslideIn { 126 | from { 127 | transform: translateY(15%); 128 | } 129 | to { 130 | transform: translateY(0); 131 | } 132 | } 133 | 134 | @keyframes mmslideOut { 135 | from { 136 | transform: translateY(0); 137 | } 138 | to { 139 | transform: translateY(-10%); 140 | } 141 | } 142 | 143 | .micromodal-slide { 144 | display: none; 145 | } 146 | 147 | .micromodal-slide.is-open { 148 | display: block; 149 | } 150 | 151 | .micromodal-slide[aria-hidden="false"] .modal__overlay { 152 | animation: mmfadeIn 0.3s cubic-bezier(0, 0, 0.2, 1); 153 | } 154 | 155 | .micromodal-slide[aria-hidden="false"] .modal__container { 156 | animation: mmslideIn 0.3s cubic-bezier(0, 0, 0.2, 1); 157 | } 158 | 159 | .micromodal-slide[aria-hidden="true"] .modal__overlay { 160 | animation: mmfadeOut 0.3s cubic-bezier(0, 0, 0.2, 1); 161 | } 162 | 163 | .micromodal-slide[aria-hidden="true"] .modal__container { 164 | animation: mmslideOut 0.3s cubic-bezier(0, 0, 0.2, 1); 165 | } 166 | 167 | .micromodal-slide .modal__container, 168 | .micromodal-slide .modal__overlay { 169 | will-change: transform; 170 | } 171 | 172 | #category-input { 173 | width: 100%; 174 | } 175 | 176 | #category-input:invalid { 177 | background-color: rgb(212, 77, 77); 178 | } 179 | 180 | #category-input-error { 181 | color: red; 182 | } 183 | 184 | #modal-error-title { 185 | color: red; 186 | display: inline-flex; 187 | align-items: center; 188 | font-size: 2rem; 189 | } 190 | 191 | #modal-error-title i { 192 | margin-right: 1rem; 193 | } 194 | -------------------------------------------------------------------------------- /src/popup/modal.mjs: -------------------------------------------------------------------------------- 1 | // ---------- 2 | // Modal 3 | // ---------- 4 | 5 | import { 6 | SUBCATEGORY_SEPARATOR, 7 | validateCategoryString, 8 | } from "../modules/cache/index.mjs"; 9 | import { printToConsole } from "../modules/utils.mjs"; 10 | 11 | const categoryInput = document.getElementById("category-input"); 12 | const categoryInputError = document.getElementById("category-input-error"); 13 | const categoryInputConfirmBtn = document.getElementById( 14 | "category-input-confirm" 15 | ); 16 | const categoryInputCancelBtn = document.getElementById("category-input-cancel"); 17 | 18 | // I18N 19 | 20 | categoryInputConfirmBtn.innerText = await browser.i18n.getMessage( 21 | "popup.input.button.ok" 22 | ); 23 | categoryInputCancelBtn.innerText = await browser.i18n.getMessage( 24 | "popup.input.button.cancel" 25 | ); 26 | document.getElementById("modal-category-input-title").innerText = 27 | await browser.i18n.getMessage("popup.input.title"); 28 | document.getElementById("modal-category-input-content").children[0].innerHTML = 29 | await browser.i18n.getMessage("popup.input.contentHTML"); 30 | 31 | export async function showCategoryInputModalAsync(initialValue) { 32 | return new Promise((resolve) => { 33 | categoryInput.value = initialValue; 34 | MicroModal.show("modal-category-input"); 35 | function onConfirmClick() { 36 | if (validateCategoryUserInput()) { 37 | MicroModal.close("modal-category-input"); 38 | cleanUp(); 39 | resolve(categoryInput.value); 40 | } 41 | } 42 | function onCancelClick() { 43 | MicroModal.close("modal-category-input"); 44 | cleanUp(); 45 | resolve(null); 46 | } 47 | function onKeyPress(ev) { 48 | if (ev.key === "Enter" && validateCategoryUserInput()) { 49 | MicroModal.close("modal-category-input"); 50 | cleanUp(); 51 | resolve(categoryInput.value); 52 | } 53 | } 54 | function cleanUp() { 55 | categoryInputConfirmBtn.removeEventListener("click", onConfirmClick); 56 | categoryInputCancelBtn.removeEventListener("click", onCancelClick); 57 | categoryInput.removeEventListener("keypress", onKeyPress); 58 | } 59 | categoryInputConfirmBtn.addEventListener("click", onConfirmClick); 60 | categoryInputCancelBtn.addEventListener("click", onCancelClick); 61 | categoryInput.addEventListener("keypress", onKeyPress); 62 | }); 63 | } 64 | 65 | export async function getCategoryStringFromInput(parentCategory = null) { 66 | const result = await showCategoryInputModalAsync( 67 | parentCategory ? parentCategory + SUBCATEGORY_SEPARATOR : null 68 | ); 69 | printToConsole.log(categoryInput); 70 | printToConsole.log(result); 71 | return result; 72 | } 73 | 74 | function validateCategoryUserInput() { 75 | const validationResult = validateCategoryString(categoryInput.value); 76 | if (validationResult == "LGTM") { 77 | categoryInputError.style.visibility = "hidden"; 78 | categoryInput.setCustomValidity(""); 79 | return true; 80 | } 81 | categoryInputError.style.visibility = "visible"; 82 | categoryInputError.innerText = validationResult; 83 | categoryInput.setCustomValidity(validationResult); 84 | return false; 85 | } 86 | 87 | export function initModal() { 88 | categoryInput.addEventListener("input", validateCategoryUserInput); 89 | document 90 | .getElementsByClassName("modal__overlay")[0] 91 | .addEventListener("mousedown", (e) => e.stopPropagation()); 92 | MicroModal.init(); 93 | } 94 | 95 | const errorContent = document.getElementById("error-content"); 96 | document.querySelector("#modal-error-title > span").innerText = 97 | await browser.i18n.getMessage("popup.error.title"); 98 | document.getElementById("modal-error-content-footer").innerText = 99 | await browser.i18n.getMessage("popup.error.content.footer"); 100 | document.querySelector("#modal-error .modal__footer button").innerText = 101 | await browser.i18n.getMessage("popup.input.button.ok"); 102 | export function showErrorModal(errorMessage) { 103 | errorContent.innerText = errorMessage; 104 | MicroModal.show("modal-error"); 105 | } 106 | -------------------------------------------------------------------------------- /src/popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Category Manager 7 | 11 | 12 | 13 | 14 | 15 | 21 | 27 | 28 | 29 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |

    No address book is available

    42 |
    43 |
      44 |
      45 |
      46 |
      47 |

      Updating...

      48 |
      49 |
      50 | 51 |

      52 |
      53 |
      54 |
        55 | 56 | 57 |
      58 | 59 | 106 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /src/popup/popup.mjs: -------------------------------------------------------------------------------- 1 | import { printToConsole } from "../modules/utils.mjs" 2 | import { createContactList } from "./contact-list.mjs"; 3 | import { createCategoryTree } from "./category-tree.mjs"; 4 | import { createAddressBookList } from "./address-book-list.mjs"; 5 | import { lookupContactsByCategoryElement } from "./utils.mjs"; 6 | import { initCustomMenu } from "./drag-menu.mjs"; 7 | import { initContextMenu } from "./context-menu.mjs"; 8 | import { initModal } from "./modal.mjs"; 9 | import State from "./state.mjs"; 10 | import { registerCacheUpdateCallback } from "../modules/cache/listeners.mjs"; 11 | import { initErrorHandler } from "./error-handler.mjs"; 12 | 13 | // global object: emailAddresses, ICAL, MicroModal from popup.html 14 | 15 | initErrorHandler(); 16 | 17 | // Put the state object onto the window (it is our own popup window, so no risk of 18 | // namespace collisions). 19 | const state = new State(); 20 | window.state = state; 21 | await state.init(); 22 | 23 | // This needs to be awaited only once, all following calls in this window are 24 | // synchronous. 25 | await printToConsole.log(state.addressBooks); 26 | 27 | // i18n 28 | document.getElementById("info-text").innerText = await browser.i18n.getMessage( 29 | "info.no-address-book" 30 | ); 31 | document.getElementById("spinner-text").innerText = 32 | await browser.i18n.getMessage("info.spinner-text"); 33 | 34 | initModal(); 35 | 36 | const categoryTitle = document.getElementById("category-title"); 37 | 38 | const contactList = createContactList( 39 | { 40 | addressBook: state.currentAddressBook, 41 | contacts: state.currentAddressBook?.contacts ?? new Map(), 42 | }, 43 | state 44 | ); 45 | 46 | const categoryTree = createCategoryTree({ 47 | addressBook: state.currentAddressBook, 48 | activeCategory: null, 49 | components: { categoryTitle, contactList }, 50 | }); 51 | 52 | const addressBookList = createAddressBookList({ 53 | addressBooks: [...state.addressBooks.values()], 54 | activeAddressBookId: "all-contacts", 55 | components: { categoryTitle, categoryTree, contactList }, 56 | }); 57 | 58 | 59 | /** 60 | * Wrapper for updateUI() to collapse multiple UI update requests. 61 | */ 62 | let updateTimerId = undefined; 63 | function requestUpdateUI() { 64 | if (typeof updateTimerId === 'number') { 65 | clearTimeout(updateTimerId); 66 | } 67 | updateTimerId = setTimeout(updateUI, 250); 68 | } 69 | 70 | async function updateUI() { 71 | if (!state.addressBooks.has(state.currentAddressBook.id)) { 72 | // The current address book was removed, so we need to switch to the "all contacts" address book 73 | state.currentAddressBook = state.allContactsVirtualAddressBook; 74 | state.currentCategoryElement = null; 75 | } 76 | await addressBookList.update({ 77 | addressBooks: [...state.addressBooks.values()], 78 | activeAddressBookId: state.currentAddressBook.id, 79 | }); 80 | printToConsole.log("Active category:", state.currentCategoryElement); 81 | await categoryTree.update({ 82 | addressBook: state.currentAddressBook, 83 | activeCategory: 84 | state.currentCategoryElement != null 85 | ? { 86 | path: state.currentCategoryElement.dataset.category, 87 | isUncategorized: !!state.currentCategoryElement.dataset.uncategorized, 88 | } 89 | : null, 90 | }); 91 | let activeElement = document.getElementsByClassName("active")[0]; 92 | printToConsole.log("Active Element after UI update:", activeElement); 93 | let contacts; 94 | if (activeElement != null) { 95 | state.currentCategoryElement = activeElement; 96 | categoryTitle.innerText = activeElement.dataset.category; 97 | contacts = lookupContactsByCategoryElement( 98 | state.currentCategoryElement, 99 | state.currentAddressBook 100 | ); 101 | } else { 102 | state.currentCategoryElement = null; 103 | categoryTitle.innerText = state.currentAddressBook?.name ?? ""; 104 | contacts = state.currentAddressBook?.contacts ?? new Map(); 105 | } 106 | await contactList.update({ 107 | addressBook: state.currentAddressBook, 108 | contacts, 109 | }); 110 | } 111 | 112 | registerCacheUpdateCallback(state.addressBooks, requestUpdateUI); 113 | 114 | initCustomMenu(categoryTree); 115 | initContextMenu(); 116 | 117 | addressBookList.render(); 118 | categoryTree.render(); 119 | contactList.render(); 120 | categoryTitle.innerText = state.currentAddressBook?.name ?? ""; 121 | -------------------------------------------------------------------------------- /src/popup/state.mjs: -------------------------------------------------------------------------------- 1 | class State { 2 | #tab; 3 | addressBooks; 4 | #allowEdit = false; 5 | allContactsVirtualAddressBook; 6 | currentAddressBook; 7 | currentContactDataFromDragAndDrop; 8 | currentCategoryElement; 9 | elementForContextMenu; 10 | currentDraggingOverCategoryElement; 11 | async init() { 12 | const [tab] = await browser.tabs.query({ 13 | currentWindow: true, 14 | active: true, 15 | }); 16 | this.#tab = tab; 17 | const { addressBooks } = await browser.storage.local.get("addressBooks"); 18 | this.addressBooks = addressBooks; 19 | this.allContactsVirtualAddressBook = this.addressBooks.get("all-contacts"); 20 | this.currentAddressBook = this.allContactsVirtualAddressBook; 21 | if (this.currentAddressBook == null) 22 | document.getElementById("info-text").style.display = "initial"; 23 | this.allowEdit = true; 24 | } 25 | 26 | get tab() { 27 | if (!this.#tab) { 28 | throw new Error("init() was not called"); 29 | } 30 | return this.#tab; 31 | } 32 | get isComposeAction() { 33 | return this.tab.type == "messageCompose"; 34 | } 35 | get spinnerElement() { 36 | return document.getElementById("spinner"); 37 | } 38 | get allowEdit() { 39 | return this.#allowEdit; 40 | } 41 | set allowEdit(value) { 42 | this.#allowEdit = value; 43 | if (this.#allowEdit) { 44 | this.spinnerElement.classList.remove("show"); 45 | } else { 46 | this.spinnerElement.classList.add("show"); 47 | } 48 | } 49 | } 50 | 51 | export default State; 52 | -------------------------------------------------------------------------------- /src/popup/utils.mjs: -------------------------------------------------------------------------------- 1 | import { lookupCategory } from "../modules/cache/addressbook.mjs"; 2 | 3 | export function lookupContactsByCategoryElement(element, addressBook) { 4 | // Find contacts by an category html element. 5 | const categoryKey = element.dataset.category; 6 | const isUncategorized = !!element.dataset.uncategorized; 7 | return lookupCategory(addressBook, categoryKey, isUncategorized) 8 | .contacts; 9 | } 10 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* Global style sheet */ 2 | 3 | html { 4 | font-size: 14px; 5 | } 6 | 7 | body { 8 | background-color: var(--catman-background); 9 | color: var(--catman-foreground); 10 | font-family: arial; 11 | margin: 0; 12 | padding: 1rem; 13 | } 14 | 15 | :root { 16 | --catman-background: white; 17 | --catman-foreground: black; 18 | --catman-highlight: turquoise; 19 | --catman-hover: dodgerblue; 20 | } 21 | 22 | @media (prefers-color-scheme: dark) { 23 | :root { 24 | --catman-background: #191c1e; 25 | --catman-foreground: white; 26 | --catman-highlight: aqua; 27 | --catman-hover: dodgerblue; 28 | } 29 | } -------------------------------------------------------------------------------- /unused/csv/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Jamie Phelps 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /unused/csv/SRC: -------------------------------------------------------------------------------- 1 | https://github.com/jxpx777/js-csv-parser -------------------------------------------------------------------------------- /unused/csv/csv.js: -------------------------------------------------------------------------------- 1 | /* jshint curly: false */ 2 | var CSVParser = (function(){ 3 | "use strict"; 4 | function captureFields(fields) { 5 | /* jshint -W040 */ 6 | if (this.options.ignoreEmpty === false || fields.some(function(field){ return field.length !== 0; })) { 7 | this.rows.push(fields); 8 | } 9 | /* jshint +W040 */ 10 | } 11 | 12 | function Parser(data, options){ 13 | var defaultOptions = { "textIdentifier": "\"", "fieldSeparator": ",", "strict": true, "ignoreEmpty": true}; 14 | if (options === undefined) options = {}; 15 | this.options = {}; 16 | Object.keys(defaultOptions).forEach(function(key) { 17 | this.options[key] = options[key] === undefined ? defaultOptions[key] : options[key]; 18 | }, this); 19 | this.rows = []; 20 | this.data = data; 21 | } 22 | Parser.prototype.toString = function toString() { return "[object CSVParser]"; }; 23 | Parser.prototype.numberOfRows = function numberOfRows() { return this.rows.length; }; 24 | Parser.prototype.parse = function parse(){ 25 | // Regular expression for parsing CSV from [Kirtan](http://stackoverflow.com/users/83664/kirtan) on Stack Overflow 26 | // http://stackoverflow.com/a/1293163/34386 27 | var regexString = ( 28 | // Delimiters. 29 | "(\\" + this.options.fieldSeparator + "|\\r?\\n|\\r|^)" + 30 | 31 | // Quoted fields. 32 | "(?:" + this.options.textIdentifier + "([^" + this.options.textIdentifier + "]*(?:" + this.options.textIdentifier + this.options.textIdentifier + "[^" + this.options.textIdentifier + "]*)*)" + this.options.textIdentifier + "|" + 33 | 34 | // Standard fields. 35 | "([^" + this.options.textIdentifier + "\\" + this.options.fieldSeparator + "\\r\\n]*))"); 36 | var objPattern = new RegExp(regexString, "gi"); 37 | var doubleQuotePattern = new RegExp(this.options.textIdentifier + this.options.textIdentifier, "g" ); 38 | 39 | var fields = []; 40 | var arrMatches = null; 41 | var strMatchedDelimiter, strMatchedValue; 42 | /* jshint -W084 */ 43 | while (arrMatches = objPattern.exec( this.data )){ 44 | /* jshint +W084 */ 45 | strMatchedDelimiter = arrMatches[ 1 ]; 46 | if (strMatchedDelimiter.length && (strMatchedDelimiter != this.options.fieldSeparator)){ 47 | captureFields.apply(this, [fields]); 48 | fields = []; 49 | } 50 | 51 | if (arrMatches[ 2 ]){ 52 | strMatchedValue = arrMatches[ 2 ].replace(doubleQuotePattern, this.options.textIdentifier); 53 | } else { 54 | strMatchedValue = arrMatches[ 3 ]; 55 | } 56 | fields.push( strMatchedValue ); 57 | } 58 | captureFields.apply(this, [fields]); 59 | if (this.options.strict === true && !this.rows.every(function(row){ return (row.length === this.length); }, this.rows[0])) { 60 | throw new Error("Invalid CSV data. Strict mode requires all rows to have the same number of fields. You can override this by passing `strict: false` in the CSVParser options"); 61 | } 62 | }; 63 | return Parser; 64 | })(); -------------------------------------------------------------------------------- /unused/skin/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /unused/skin/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /unused/skin/checkbox-all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/unused/skin/checkbox-all.png -------------------------------------------------------------------------------- /unused/skin/checkbox-none.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/unused/skin/checkbox-none.png -------------------------------------------------------------------------------- /unused/skin/checkbox-some.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/unused/skin/checkbox-some.png -------------------------------------------------------------------------------- /unused/skin/double.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/unused/skin/double.gif -------------------------------------------------------------------------------- /unused/skin/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/unused/skin/icon.png -------------------------------------------------------------------------------- /unused/skin/notok.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/unused/skin/notok.gif -------------------------------------------------------------------------------- /unused/skin/ok-double.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/unused/skin/ok-double.gif -------------------------------------------------------------------------------- /unused/skin/ok.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/unused/skin/ok.gif -------------------------------------------------------------------------------- /unused/skin/slider-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/unused/skin/slider-off.png -------------------------------------------------------------------------------- /unused/skin/slider-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jobisoft/CategoryManager/9b7fad67ba17d6456b3df9c06bada27d4e2f3c6d/unused/skin/slider-on.png -------------------------------------------------------------------------------- /unused/theme.mjs: -------------------------------------------------------------------------------- 1 | export async function getOrSetToDefaultTheme() { 2 | let { useDarkTheme } = await browser.storage.local.get("useDarkTheme"); 3 | if (useDarkTheme == null) { 4 | useDarkTheme = false; 5 | await browser.storage.local.set({ useDarkTheme }); 6 | } 7 | return useDarkTheme; 8 | } 9 | 10 | export function setTheme(useDarkTheme) { 11 | let root = document.documentElement; 12 | root.style.setProperty( 13 | "--catman-foreground", 14 | useDarkTheme ? "white" : "black" 15 | ); 16 | root.style.setProperty( 17 | "--catman-background", 18 | useDarkTheme ? "#191c1e" : "white" 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /unused/vcf/LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright (C) 2012 Niklas Cathor (http://github.com/nilclass) 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished 9 | to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. -------------------------------------------------------------------------------- /unused/vcf/Math.uuid.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Math.uuid.js (v1.4) 3 | http://www.broofa.com 4 | mailto:robert@broofa.com 5 | 6 | Copyright (c) 2010 Robert Kieffer 7 | Dual licensed under the MIT and GPL licenses. 8 | */ 9 | 10 | /* 11 | * Generate a random uuid. 12 | * 13 | * USAGE: Math.uuid(length, radix) 14 | * length - the desired number of characters 15 | * radix - the number of allowable values for each character. 16 | * 17 | * EXAMPLES: 18 | * // No arguments - returns RFC4122, version 4 ID 19 | * >>> Math.uuid() 20 | * "92329D39-6F5C-4520-ABFC-AAB64544E172" 21 | * 22 | * // One argument - returns ID of the specified length 23 | * >>> Math.uuid(15) // 15 character ID (default base=62) 24 | * "VcydxgltxrVZSTV" 25 | * 26 | * // Two arguments - returns ID of the specified length, and radix. (Radix must be <= 62) 27 | * >>> Math.uuid(8, 2) // 8 character ID (base=2) 28 | * "01001010" 29 | * >>> Math.uuid(8, 10) // 8 character ID (base=10) 30 | * "47473046" 31 | * >>> Math.uuid(8, 16) // 8 character ID (base=16) 32 | * "098F4D35" 33 | */ 34 | (function() { 35 | // Private array of chars to use 36 | var CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split(''); 37 | 38 | Math.uuid = function (len, radix) { 39 | var chars = CHARS, uuid = [], i; 40 | radix = radix || chars.length; 41 | 42 | if (len) { 43 | // Compact form 44 | for (i = 0; i < len; i++) uuid[i] = chars[0 | Math.random()*radix]; 45 | } else { 46 | // rfc4122, version 4 form 47 | var r; 48 | 49 | // rfc4122 requires these characters 50 | uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-'; 51 | uuid[14] = '4'; 52 | 53 | // Fill in random data. At i==19 set the high bits of clock sequence as 54 | // per rfc4122, sec. 4.1.5 55 | for (i = 0; i < 36; i++) { 56 | if (!uuid[i]) { 57 | r = 0 | Math.random()*16; 58 | uuid[i] = chars[(i == 19) ? (r & 0x3) | 0x8 : r]; 59 | } 60 | } 61 | } 62 | 63 | return uuid.join(''); 64 | }; 65 | 66 | // A more performant, but slightly bulkier, RFC4122v4 solution. We boost performance 67 | // by minimizing calls to random() 68 | Math.uuidFast = function() { 69 | var chars = CHARS, uuid = new Array(36), rnd=0, r; 70 | for (var i = 0; i < 36; i++) { 71 | if (i==8 || i==13 || i==18 || i==23) { 72 | uuid[i] = '-'; 73 | } else if (i==14) { 74 | uuid[i] = '4'; 75 | } else { 76 | if (rnd <= 0x02) rnd = 0x2000000 + (Math.random()*0x1000000)|0; 77 | r = rnd & 0xf; 78 | rnd = rnd >> 4; 79 | uuid[i] = chars[(i == 19) ? (r & 0x3) | 0x8 : r]; 80 | } 81 | } 82 | return uuid.join(''); 83 | }; 84 | 85 | // A more compact, but less performant, RFC4122v4 solution: 86 | Math.uuidCompact = function() { 87 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 88 | var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); 89 | return v.toString(16); 90 | }); 91 | }; 92 | })(); 93 | -------------------------------------------------------------------------------- /unused/vcf/SRC: -------------------------------------------------------------------------------- 1 | https://github.com/nilclass/vcardjs -------------------------------------------------------------------------------- /unused/vcf/VERSION: -------------------------------------------------------------------------------- 1 | 0.3 2 | -------------------------------------------------------------------------------- /unused/vcf/vcard.js: -------------------------------------------------------------------------------- 1 | 2 | // exported globals 3 | var VCard; 4 | 5 | (function() { 6 | 7 | VCard = function(attributes) { 8 | this.changed = false; 9 | if(typeof(attributes) === 'object') { 10 | for(var key in attributes) { 11 | this[key] = attributes[key]; 12 | this.changed = true; 13 | } 14 | } 15 | }; 16 | 17 | VCard.prototype = { 18 | 19 | // Check validity of this VCard instance. Properties that can be generated, 20 | // will be generated. If any error is found, false is returned and vcard.errors 21 | // set to an Array of [attribute, errorType] arrays. 22 | // Otherwise true is returned. 23 | // 24 | // In case of multivalued properties, the "attribute" part of the error is 25 | // the attribute name, plus it's index (starting at 0). Example: email0, tel7, ... 26 | // 27 | // It is recommended to call this method even if this VCard object was imported, 28 | // as some software (e.g. Gmail) doesn't generate UIDs. 29 | validate: function() { 30 | var errors = []; 31 | 32 | function addError(attribute, type) { 33 | errors.push([attribute, type]); 34 | } 35 | 36 | if(! this.fn) { // FN is a required attribute 37 | addError("fn", "required"); 38 | } 39 | 40 | // make sure multivalued properties are *always* in array form 41 | for(var key in VCard.multivaluedKeys) { 42 | if(this[key] && ! (this[key] instanceof Array)) { 43 | this[key] = [this[key]]; 44 | } 45 | } 46 | 47 | // make sure compound fields have their type & value set 48 | // (to prevent mistakes such as vcard.addAttribute('email', 'foo@bar.baz') 49 | function validateCompoundWithType(attribute, values) { 50 | for(var i in values) { 51 | var value = values[i]; 52 | if(typeof(value) !== 'object') { 53 | errors.push([attribute + '-' + i, "not-an-object"]); 54 | } else if(! value.type) { 55 | errors.push([attribute + '-' + i, "missing-type"]); 56 | } else if(! value.value) { // empty values are not allowed. 57 | errors.push([attribute + '-' + i, "missing-value"]); 58 | } 59 | } 60 | } 61 | 62 | if(this.email) { 63 | validateCompoundWithType('email', this.email); 64 | } 65 | 66 | if(this.tel) { 67 | validateCompoundWithType('email', this.tel); 68 | } 69 | 70 | if(! this.uid) { 71 | this.addAttribute('uid', this.generateUID()); 72 | } 73 | 74 | if(! this.rev) { 75 | this.addAttribute('rev', this.generateRev()); 76 | } 77 | 78 | this.errors = errors; 79 | 80 | return ! (errors.length > 0); 81 | }, 82 | 83 | // generate a UID. This generates a UUID with uuid: URN namespace, as suggested 84 | // by RFC 6350, 6.7.6 85 | generateUID: function() { 86 | return 'uuid:' + Math.uuid(); 87 | }, 88 | 89 | // generate revision timestamp (a full ISO 8601 date/time string in basic format) 90 | generateRev: function() { 91 | return (new Date()).toISOString().replace(/[\.\:\-]/g, ''); 92 | }, 93 | 94 | // Set the given attribute to the given value. 95 | // This sets vcard.changed to true, so you can check later whether anything 96 | // was updated by your code. 97 | setAttribute: function(key, value) { 98 | this[key] = value; 99 | this.changed = true; 100 | }, 101 | 102 | // Set the given attribute to the given value. 103 | // If the given attribute's key has cardinality > 1, instead of overwriting 104 | // the current value, an additional value is appended. 105 | addAttribute: function(key, value) { 106 | console.log('add attribute', key, value); 107 | if(! value) { 108 | return; 109 | } 110 | if(VCard.multivaluedKeys[key]) { 111 | if(this[key]) { 112 | console.log('multivalued push'); 113 | this[key].push(value) 114 | } else { 115 | console.log('multivalued set'); 116 | this.setAttribute(key, [value]); 117 | } 118 | } else { 119 | this.setAttribute(key, value); 120 | } 121 | }, 122 | 123 | // convenience method to get a JSON serialized jCard. 124 | toJSON: function() { 125 | return JSON.stringify(this.toJCard()); 126 | }, 127 | 128 | // Copies all properties (i.e. all specified in VCard.allKeys) to a new object 129 | // and returns it. 130 | // Useful to serialize to JSON afterwards. 131 | toJCard: function() { 132 | var jcard = {}; 133 | for(var k in VCard.allKeys) { 134 | var key = VCard.allKeys[k]; 135 | if(this[key]) { 136 | jcard[key] = this[key]; 137 | } 138 | } 139 | return jcard; 140 | }, 141 | 142 | // synchronizes two vcards, using the mechanisms described in 143 | // RFC 6350, Section 7. 144 | // Returns a new VCard object. 145 | // If a property is present in both source vcards, and that property's 146 | // maximum cardinality is 1, then the value from the second (given) vcard 147 | // precedes. 148 | // 149 | // TODO: implement PID matching as described in 7.3.1 150 | merge: function(other) { 151 | if(typeof(other.uid) !== 'undefined' && 152 | typeof(this.uid) !== 'undefined' && 153 | other.uid !== this.uid) { 154 | // 7.1.1 155 | throw "Won't merge vcards without matching UIDs."; 156 | } 157 | 158 | var result = new VCard(); 159 | 160 | function mergeProperty(key) { 161 | if(other[key]) { 162 | if(other[key] == this[key]) { 163 | result.setAttribute(this[key]); 164 | } else { 165 | result.addAttribute(this[key]); 166 | result.addAttribute(other[key]); 167 | } 168 | } else { 169 | result[key] = this[key]; 170 | } 171 | } 172 | 173 | for(key in this) { // all properties of this 174 | mergeProperty(key); 175 | } 176 | for(key in other) { // all properties of other *not* in this 177 | if(! result[key]) { 178 | mergeProperty(key); 179 | } 180 | } 181 | } 182 | }; 183 | 184 | VCard.enums = { 185 | telType: ["text", "voice", "fax", "cell", "video", "pager", "textphone"], 186 | relatedType: ["contact", "acquaintance", "friend", "met", "co-worker", 187 | "colleague", "co-resident", "neighbor", "child", "parent", 188 | "sibling", "spouse", "kin", "muse", "crush", "date", 189 | "sweetheart", "me", "agent", "emergency"], 190 | // FIXME: these aren't actually defined anywhere. just very commmon. 191 | // maybe there should be more? 192 | emailType: ["work", "home", "internet"], 193 | langType: ["work", "home"], 194 | 195 | }; 196 | 197 | VCard.allKeys = [ 198 | 'fn', 'n', 'nickname', 'photo', 'bday', 'anniversary', 'gender', 199 | 'tel', 'email', 'impp', 'lang', 'tz', 'geo', 'title', 'role', 'logo', 200 | 'org', 'member', 'related', 'categories', 'note', 'prodid', 'rev', 201 | 'sound', 'uid' 202 | ]; 203 | 204 | VCard.multivaluedKeys = { 205 | email: true, 206 | tel: true, 207 | geo: true, 208 | title: true, 209 | role: true, 210 | logo: true, 211 | org: true, 212 | member: true, 213 | related: true, 214 | categories: true, 215 | note: true 216 | }; 217 | 218 | })(); 219 | --------------------------------------------------------------------------------