Repository: GioBonvi/GoogleContactsEventsNotifier
Branch: master
Commit: 7c9896465a6b
Files: 15
Total size: 159.4 KB
Directory structure:
gitextract_xfabd126/
├── .gitattributes
├── .github/
│ ├── CODE_OF_CONDUCT.md
│ ├── CONTRIBUTING.md
│ └── ISSUE_TEMPLATE.md
├── .gitignore
├── .jsdoc-conf.json
├── .markdownlint.json
├── LICENSE
├── README.md
├── code.gs
├── docs/
│ ├── git-guide.md
│ ├── install-and-setup.md
│ └── translation-guide.md
├── images/
│ └── Logo.psd
└── tests.gs
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
* text=auto
*.md text
*.json text
*.gs text
.gitattributes text
.gitignore text
LICENSE text
*.png binary
*.psd binary
================================================
FILE: .github/CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of
experience, nationality, personal appearance, race, religion, or sexual identity
and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, or to ban temporarily or permanently any
contributor for other behaviors that they deem inappropriate, threatening,
offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at
[bonvicini.giorgio@gmail.com](mailto:bonvicini.giorgio@gmail.com). All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an
incident. Further details of specific enforcement policies may be posted
separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
================================================
FILE: .github/CONTRIBUTING.md
================================================
# Contributing
Welcome, user!
As you might know Google Contacts Events Notifier is an open source project: any
kind of help or contribution is therefore warmly welcomed.
When I started working on this project I just wanted to solve a problem that I
was experiencing with Google Calendar, but it all went much further than I could
expect: people liked the idea and helped making it even better.
If you now want to be part of this group and give your own contribution this
page contains useful information.
<!-- TOC -->
- [Contributing](#contributing)
- [Where to start](#where-to-start)
- [Code of conduct](#code-of-conduct)
- [Issues](#issues)
- [Coding guidelines](#coding-guidelines)
- [Testing](#testing)
- [PR management](#pr-management)
- [Git guide](#git-guide)
<!-- /TOC -->
## Where to start
First of all make sure you have read the [Code of conduct][Code of conduct]: it
does not contain any extremely bizarre or revolutionary rules. Following common
sense and being nice to other users will create no problem for you at all.
Now let the fun begin! There are many ways you can contribute to this project:
- Are there [unsolved issues][Unsolved issues page]? Look around: there might be
some you can solve or help closing;
- Have you thought of a feature which would improve this project but don't know
how (or don't have time) to code it? You can create an issue with a feature
request detailing what you thought and your proposal will be evaluated by the
developers;
- Are you a developer yourself? You can fork the project and then create a Pull
Request with your edited code. Just make sure you follow the [coding
guidelines][Coding guidelines];
- Do you speak a language other than English? You can [contribute][Contribute
with translation] by submitting a new translation in the form of a Pull
Request or by creating an issue if you don't know how to create Pull
Requests;
- If you wish to contribute by way of Pull Requests and have never used git
before (or never used it as part of a typical Github workflow) the
[git guide][Git guide] might help you.
## Code of conduct
This project adheres to the [Contributor Covenant Code of Conduct][Code of
conduct]: it's a simple set of rules that greatly help collaborating, making the
project easier to maintain.
## Issues
[Issues][Project issue page] are a powerful tool provided by GitHub to report
problems, ask questions and more generally letting users interact with the
developers of a project: they, however, are only useful as long as they are used
correctly.
This project has a few rules regarding issues:
- Help requests in the issues are accepted as long as you first read the [setup
and installation guide][Setup and installation guide] and nothing you have
tried works;
- When reporting an issue please follow the [template provided][Issue template
file];
- Unresponsive help request issues are closed following [this
procedure][Unresponsive issues];
## Coding guidelines
Code consistency is what makes a project maintainable and accessible to
everyone. To maintain consistency please make sure that your code submitted via
Pull Requests complies with these rules:
- PRs should be based against the `development` branch (or another feature
branch if appropriate), but not against the `master` branch;
- If contributing translation strings you just need to ensure they are correctly
formatted (see the first point below under [Testing](#testing)), and you can
ignore all the other guidelines/testing instructions;
- The code must be in English (variable names, comments, documentation...);
- The code in `.gs` files must be formatted following the [Javascript
Semistandard Style][Javascript semistandard];
- The text in `.md` files must be formatted following the [Markdown CommonMark
specification][Commonmark specification];
- Deviations from default rules can be found in the [.markdownlint.json
file][Markdown linter config]
- Additionally, links should be in the reference format, not in the in-text
format
- Each function and class must have associated [JSDoc][JSDoc] comments, with the
fields and description in Markdown format;
- Deviations from default rules can be found in the [.jsdoc-conf.json
file][JSDoc config]
## Testing
Before submitting a PR:
- If you've updated translation strings, please check that you have used the same
format as the other entries, for example:
```javascript
'You can find the latest one here': 'Puoi trovare l\'ultima qui',
```
- Both the "from" and "to" parts of each entry are surrounded by single-quotes
(`'aa'`)
- Any single-quotes within the "from" or "to" are escaped with a backslash
(`'a\'a'`)
- There is a colon and space between the "from" and "to" (`'aa': 'bb'`)
- Each entry has a trailing comma (`... : 'bb',`)
- If you've made updates to the javascript code in any `.gs` files:
- To lint for syntax errors [this semistandard linter][Javascript semistandard]
is one of the options available as a plugin for many editors and can also be
run as a commandline tool using (for example, on a Unix-like system):
```sh
npm install -g "semistandard" "snazzy" # if not yet installed
semistandard --verbose `find . -name "*.gs"` | snazzy
# "snazzy" is an optional pretty-printer
```
- To check for semantic errors:
- Please verify that your code passes all the tests. To do
that either create a new script-file for `tests.gs` within the same
project in the Google script-editor, or append the content of
`tests.gs` to that of the `code.gs` script-file, then `run->unitTests()`.
- It is good to also `run->test()` from `code.gs` with `settings.debug.testDate`
set to a date with some contact-anniversaries on it (or create some fake ones
on that date) to provide real-world testing too.
- For exhaustive real-world testing there is also `testSelectedPeriod()`. Beware
that this test *might* hit an execution timeout limit.
- If there are any other new global functions in the [tests file][Tests file]
you can run them too.
- If you've made updates to any Markdown files:
- To lint for syntax errors [this Markdown linter][Markdown linter] is one of the
options available as a plugin for many editors and can also be run from a
commandline tool using (for example, on a Unix-like system):
```sh
npm install -g "markdownlint-cli" # if not yet installed
find . -name "*.md" -exec markdownlint --config ".markdownlint.json" {} \;
```
- To preview the output to check for semantic errors:
- The simplest way before opening a PR is - after pushing the changes to the
feature-branch on your Github fork - to browse to that file at that branch
on Github to see if it auto-renders correctly.
- If you've already opened a PR and want to preview additional changes
*before* pushing them to the branch (where the changes would otherwise appear
on the PR before you can fix mistakes) you can process and preview them
locally using (for example, on a Debian-based Unix-like system, avoiding
generation of intermediate files):
```sh
apt-get install "python-markdown" "lynx" # if not yet installed
for x in `find . -name "*.md"`; do markdown_py "${x}" | lynx -stdin; done
```
or to just generate temporary `.html` files next to the `.md` ones:
```sh
for x in `find . -name "*.md"`; do markdown_py "${x}" >"${x%.md}.html"; done
```
- If you've made updates to any JSDoc comments:
- To lint for syntax errors, many editors have JSDoc plugins for
auto-previewing the output, or you can manually run [this jsdoc
processor][JSDoc processor] from the commandline (for example, on a Unix-like
system):
```sh
npm install -g "jsdoc" # if not yet installed
jsdoc . --pedantic --verbose --recurse --configure ".jsdoc-conf.json" && \
echo "* Successful" || echo "* Failed"
```
- To preview the output to check for semantic errors you can just navigate around
in the `jsdoc` output with a browser (for example, on a Debian-based Unix-like
system):
```sh
apt-get install "lynx" # if not yet installed
lynx "jsdoc-out/index.html"
```
- The `jsdoc-out/` directory is included in `.gitignore` and as default directory
in `.jsdoc-conf.json` so unless you used
`jsdoc --destination "other-directory"` it will output to `jsdoc-out/`, and
will not interfere with `git status`.
- For advanced git/shell users the added benefit of the commandline tools is that
you can edit `.git/hooks/pre-commit` to automate the above (possibly even the
script's own tests, via Google REST API calls) before committing, and a failed
test or a `Ctrl-C` can exit with non-zero, which aborts the commit. Gurus could
even add a `.git/hook/post-commit` to automate pushing the new version to
Google Apps by REST API (after updating any customized var-settings with a tool
like `sed`).
## PR management
This is the optimal workflow that should be followed when managing a new PR:
1. The submitter (a collaborator or an external user) submits the PR;
2. A collaborator of the project assigns some other collaborators (or his/herself)
to the PR;
3. The assigned user adds the correct tags/milestones to the PR;
4. The assigned user performs a general evaluation of the PR: if he finds any big
problem (exceptionally bad code, the feature added by the PR is not wanted in the
project or something along these lines) he can simply refuse the PR by closing
it (stating the problem clearly in a comment and discussing it with the submitter);
5. If no big problem is found the assigned user starts a review of the code by adding
one or more reviewers (other collaborators or him/herself) to the PR;
6. The reviewers should check the code for errors, problems, possible optimizations,
incompatibilities with the current code base or with planned future developments,
wrong code style and typos/spelling errors and running the tests, then leave a
review accepting it if everything is OK or requesting changes otherwise;
7. If the submitter is asked by the reviewer(s) to implement some changes in the
code he should do it before the process can continue any further.
As a general rule reviewers should refrain from pushing commits to the PR branch
without asking explicit consent from the submitter beforehand.
Note: it's perfectly OK for both the reviewers and the submitter to use `git rebase`
while working on PRs since nobody should fork a branch from an unmerged PR;
8. If any changes have been pushed to the PR in step 7 step 6 must be repeated;
9. Once all the reviewers have accepted the PR they can ask the submitter to rebase,
squash or tidy up the PR branch before merging (For example, this should be done
if the review/correction cycle was repeated multiple times generating lots of
unwanted commits that can be squashed);
10. Once the PR is finally ready to be merged the assigned user should merge it
following these rules of thumb:
- If the PR consists of just one or two commits it could be merged via a fast-
forward merge (`merge --ff`, must be performed manually since GitHub does not
offer this functionality in the web interface to perform it):
- If the PR consists of just one or two commits, but cannot be merged via a
fast-forward merge, a "rebase merge" can be used (this can be performed
both manually and via the GitHub interface);
- If the PR consists of more than two commits a non-fast-forward merge should
be considered (`merge --no-ff`, it can be performed both manually and via
the GitHub interface);
- In any case these rules must not be taken at absolute value: exceptions may
arise which could require these rules to be broken or bent. If you have any
doubts regarding the best route to take just ask and some collaborator will
help you;
11. If any issues remain open which would be auto-closed by the PR when its
target-branch finally gets merged to the default-branch (`master`), and it
will be a noticeable length of time before that will happen, then either the
`solved` or `wontfix` label should be added to those issues to make it clear
at a glance that nothing else needs doing for them in order for them to
auto-close.
## Git guide
If you want to contribute to this project extensively you must learn to use git:
it's not extremely difficult and amazingly useful, moreover @rowanthorpe, one of
the contributors of this project has written an amazing introductory guide to git,
which you can find [here][Git guide].
[Code of conduct]: CODE_OF_CONDUCT.md
[Project issue page]: https://github.com/GioBonvi/GoogleContactsEventsNotifier/issues
[Unsolved issues page]: https://github.com/GioBonvi/GoogleContactsEventsNotifier/issues?q=is%3Aissue%20is%3Aopen%20-label%3Asolved%20-label%3Awontfix%20-label%3Aunresponsive%20no%3Aassignee
[Coding guidelines]: #coding-guidelines
[Contribute with translation]: ../docs/translation-guide.md
[Issue template file]: ISSUE_TEMPLATE.md
[Unresponsive issues]: https://giobonvi.github.io/GoogleContactsEventsNotifier/#unresponsive-help-requests
[Javascript semistandard]: https://github.com/Flet/semistandard
[Commonmark specification]: http://commonmark.org
[Markdown linter config]: ../.markdownlint.json
[JSDoc]: http://usejsdoc.org
[JSDoc config]: ../.jsdoc-conf.json
[Tests file]: ../tests.gs
[Markdown linter]: https://github.com/DavidAnson/markdownlint
[JSDoc processor]: https://github.com/jsdoc3/jsdoc
[Git guide]: ../docs/git-guide.md
[Setup and installation guide]: ../docs/install-and-setup.md
================================================
FILE: .github/ISSUE_TEMPLATE.md
================================================
<!-- markdownlint-disable-->
Before reporting a new issue please double check that:
- you followed the instructions at https://giobonvi.github.io/GoogleContactsEventsNotifier correctly in every step;
- there is no open issue discussing your problem (check here: https://github.com/GioBonvi/GoogleContactsEventsNotifier/issues);
- there is no closed issue which has solved or declared your problem unsolvable (check here: https://github.com/GioBonvi/GoogleContactsEventsNotifier/issues?q=is%3Aissue+is%3Aclosed);
If this issue is not about a bug or a problem you can delete this whole template
and write whatever you want.
****************************************************************************
DELETE THIS AND THE TEXT ABOVE AND INSERT THE CONTENT BELOW
****************************************************************************
<!-- markdownlint-enable-->
<!-- markdownlint-disable MD002 -->
### Steps to reproduce
What action or series of actions is the cause of the issue?
1. action 1;
2. action 2;
3. action 3;
### Expected behavior
What should happen?
### Current behavior
What happens instead?
### Context
- Version of the script: x.x.x - look for a line near the top of the code which
reads:
```javascript
version: 'w.x.y-z',
```
- Any other details which might be related to the context;
### Extended description
Try to describe the problem in the most complete way. You can add images,
error messages, hypothesis and observations regarding the problem here.
### Possible solution
If you think you know what causes the problem or if you know a solution for it
write it here.
================================================
FILE: .gitignore
================================================
*~
/code-customized.gs
/jsdoc-out/
================================================
FILE: .jsdoc-conf.json
================================================
{
"plugins": ["plugins/markdown"],
"recurseDepth": 10,
"source": {
"includePattern": ".+\\.(gs|js(doc|x)?)$",
"excludePattern": "((^|\\/|\\\\)_|(^|\\/)(jsdoc-out/|code-customized\\.gs$))"
},
"sourceType": "module",
"tags": {
"allowUnknownTags": true,
"dictionaries": ["jsdoc","closure"]
},
"templates": {
"cleverLinks": false,
"monospaceLinks": false
},
"opts": {
"destination": "jsdoc-out/"
}
}
================================================
FILE: .markdownlint.json
================================================
{
"MD009": { "br_spaces": 2 },
"MD013": {"code_blocks": false},
"MD029": { "style": "ordered" }
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2016, 2017 Giorgio Bonvicini
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Google Contacts Events Notifier

Receive customized email notifications to alert you about incoming birthdays or
other events of your Google contacts.
Have you ever wondered why on Earth would Google Calendar provide a calendar to
remind you of your contact birthdays, but without letting you set up
notifications for its events?
I did. And after hours of fruitless searching and browsing I found [a post in
the Google Help Forum][Original Google Help Forum Post] which seemed to provide
a solution, however it did not quite work.
This project takes inspiration from that code to solve the problem of the
missing notifications on Google Calendar Birthday Calendar.
**Is your script not working?** Take a look at the updated [installation guide][Setup and installation guide] and see [#199](https://github.com/GioBonvi/GoogleContactsEventsNotifier/issues/199).
<!-- TOC -->
- [Google Contacts Events Notifier](#google-contacts-events-notifier)
- [Note regarding Google Plus integration](#note-regarding-google-plus-integration)
- [Installation and setup](#installation-and-setup)
- [Additional information](#additional-information)
- [Stopping/uninstalling/deleting the script](#stoppinguninstallingdeleting-the-script)
- [Blacklisting specific events for specific contacts](#blacklisting-specific-events-for-specific-contacts)
- [Translation](#translation)
- [Bug and error reporting, help requests](#bug-and-error-reporting-help-requests)
- [Unresponsive help requests](#unresponsive-help-requests)
- [Updating the script](#updating-the-script)
- [Permissions required](#permissions-required)
- [Contributing](#contributing)
- [License](#license)
- [Credits](#credits)
<!-- /TOC -->
## Note regarding Google Plus integration
This script used to include an option to extract additional info about your
contacts from your Google Plus account. As Google Plus is scheduled to be killed
between March and April 2019 (see [this blog post from Google][Google Plus
closing] and [this follow up][Google Plus closing 2] for more details) this
feature had to be removed.
If you are using any version of this script up to and including v4.1.0 you might
have received one or more emails from Google explaining this and asking you to
address this issue in your projects.
If this is the case the only action you need to take regarding GCEN is to update
your script to a more recent version (more recent than v4.1.0) and to remove the
dependecy from the Google Plus API by:
- Opening your script.
- Clicking on `Resources->Advanced Google services` in the menu at the top.
- Disabling the `Google+ API` in the list of available APIs.
- Opening the Google Cloud platform API dashboard with the link provided at the
bottom.
- Searching for `Google+ API` in the search bar at the top.
- Disabling the API by clicking on the `Disable` button.
## Installation and setup
Follow [this guide][Setup and installation guide] to install and setup the
script correctly.
## Additional information
### Stopping/uninstalling/deleting the script
If you just want to stop receiving the notifications, but want to keep the
script for future use just open your script and click `Run->notifStop` in the
menu at the top.
If you want to stop using the script and want to delete it completely follow
these steps:
1. Locate the script in [Google Drive][Google Drive website]:
- It should be in the folder you put it into when you created it.
- If you deleted the file you can look in the [trash folder][Google Drive
trash] and recover it from there.
2. Open the script and click `Run->notifStop` in the menu at the top.
3. In the same menu click `Run->notifStatus`, then `View->Log` and confirm that
the notifications were stopped successfully.
4. Close the script and open [this Google page][Google connected apps], find the
the script (it should have the name you gave it during installation) and
click on "Remove access"
5. If everything went right it should be safe to delete the script file from
Google Drive.
If you want to be extra sure you can wait some days to confirm that no email
is sent to you anymore and only then delete the script file: this is up to
you.
### Blacklisting specific events for specific contacts
There are three event-types for which notifications can be statically
enabled/disabled for by editing the `settings.notifications.eventTypes`
configuration variable at the top of the script:
1. `Birthday`
2. `Anniversary`
3. `Custom`
but you can also achieve more fine-grained control per-contact by adding a
custom-field when editing a contact (click `Add->Custom...`), setting the label
of that field to `notificationBlacklist`, and setting its content to a
comma-separated list of field-names. In the following example, the script would
notify about Fred's birthday but not his anniversary or his SpecialSecretDay due
to the blacklist:
- `Name` -> `Fred`
- `Birthday` -> `1 January 1970`
- `Anniversary` -> `31 December 1995`
- `SpecialSecretDay` -> `15 June 2001`
- `notificationBlacklist` -> `Anniversary,SpecialSecretDay`
To minimize confusion the blacklist matches case-insensitively, so for example
`ANNIVERSARY`, `Anniversary`, `anniversary`, or `AnNiVeRsArY` being in the
blacklist will all succeed in preventing anniversary notifications for the
contact.
### Translation
The text of the email notification can be translated into any language if a
translation for that language is provided to the script. Some languages already
have a translation, but you can easily add your own.
To learn more about translations (how to create your own one, how to share it
with us so that it can be used by other users...) please read the [translation
guide][Translation guide].
### Bug and error reporting, help requests
First of all _before submitting a new error, bug or help request_, please,
__verify that you followed [the setup instructions][Setup and installation
guide] to the letter.__
To report a bug or an error or to request help with this script please use [this
project GitHub issue page][Project issue page]: the collaborators will be
notified immediately and will provide help as soon as possible.
Please follow the template provided (which you can also [preview here][Issue
template file]) when opening a new issue and include:
- A __meaningful description of the problem__. What did you do? What happened?
What did you expect to happen instead?
- A __full copy of any error message you received or of the thing that went
wrong__. Please be advised that it could contain personal information such as
your email: obscure or remove them as all the issues and relative messages are
publicly visible.
These pieces of information are really necessary: without them nobody will be
able to help you.
#### Unresponsive help requests
If you open a help request issue please do not abandon it until it's been solved
and closed. If you want to close it before explicitly state this intention with
a message in the issue.
Issues marked with the `help request` tag that are unresponsive will be sent a
reminder message after three days since the last message from the user and the
issue will be marked with the `unresponsive` tag. If the user still does not
respond to the issue, after a month the issue will be closed.
If you want to re-open a closed `help request` issue ask for this by commenting
on it.
Only the user which has originally opened the issue can ask for it to be
re-opened.
### Updating the script
This script is constantly updated to fix bugs and add new features: keeping it
updated to the latest version is really easy:
1. Whenever a new stable version is released you will see a line of text at the
end of your daily email notification telling you to click on a link to get
the latest version;
2. If you do so you will be taken to a page with a description of the new
release;
3. The description will contain a precise step by step guide on how to update
the script to this version: follow it closely and you should not have any
problem;
4. You might want to follow the setup procedure again, because some steps might
have been added in the new version since the previous one.
5. After updating the code always click `Run->notifStop` and `Run->notifStart`
in the top menu to finish the update process.
Note: you might be asked to grant some new permissions to the script. There is
nothing wrong with this: it just means that the new version requires some
permissions that the previous version did not.
You can read the full list of the permissions and why they are required
[here][Permissions list]
### Permissions required
When running the script for the first time or after an update you might be asked
by Google to "grant some permissions" to the script. This happens because the
script needs your explicit permission to access your data.
This is an exhaustive description of the reason the script needs each of the
permissions:
- **Manage your Google Contacts**
This lets the script access information about your contacts (names, email
addresses, birthdays). The script will not modify any of your contacts.
- **Manage your calendars**
This lets the script access your birthday and events calendar. The script will
get the events from this calendar only and will never modify any event or
calendar.
- **Allow this application to run when you are not present**
This is needed to run the script every day at the hour you specified.
- **Send email as you**
Obviously this script needs your authorization to send you the email
notifications. It won't send any other email to anyone.
- **Connect to an external service**
This permission is needed to check for updates and to load the profile images
of your contacts.
## Contributing
Google Contacts Events Notifier is an open source project: if you want to
know how to contribute please read the [CONTRIBUTING][Contributing file] file.
If you just want to contribute with a translation then the [translation
guide][Translation guide] might be a better place to start.
## License
Google Contacts Events Notifier is licensed under the [MIT license][License
file].
## Credits
- [GioBonvi (Giorgio Bonvicini)][Github GioBonvi] created the project and is
primary maintainer;
- Google user `ajparag` for the [code][Original Google Help Forum post] that
inspired this project;
- [rowanthorpe (Rowan Thorpe)][GitHub rowanthorpe], whose help was invaluable:
he added many new features, refactored the code heavily and solved many bugs;
- [baatochan (Bartosz Rodziewicz)][Github baatochan], for his various contributions
both in solving issues and in adding new features;
- those users who provided translations for the script:
- [rowanthorpe (Rowan Thorpe)][GitHub rowanthorpe] and [apo-mak][Github apo-mak
] - Greek;
- [lboullo0 (Lucas)][Github lboullo0] - Spanish;
- [muzavan (Muhammad Reza Irvanda)][Github muzavan] - Indonesian;
- [DrKrakower][Github DrKrakower], Simone Sottopietra, [shutdown27 (Peter
Berweiler)][Github shutdown27] and [SuperSandro2000 (Sandro Jäckel)][Github
SuperSandro2000] - German;
- [cezarylaksa][Github cezarylaksa] and [baatochan (Bartosz
Rodziewicz)][Github baatochan] - Polish;
- [JayForce][GitHub JayForce], [Vinetos (Valentin Chassignol)][GitHub Vinetos]
and [krial057][Github krial057] - French;
- [88scythe][GitHub 88scythe] - Dutch;
- [miguel-r (Miguel Ribeiro)][Github miguel-r] - Portuguese;
- [AnnaH][Github AnnaH] - Thai;
- [nadyafebi (Nadya Febiana Djojosantoso)][Github nadyafebi] and [jovanzers
(Jovan Ferryal E. F.)][Github jovanzers] - Indonesian;
- [cemysf (Cem Yusuf Aydogdu)][Github cemysf] - Turkish;
- [MatheusNtg][Github MatheusNtg] - Brazilian Portuguese;
- [goggenb (Geir-Ove Bøe)][Github goggenb] - Norwegian;
- [phg98][Github phg98] - Korean;
- [kallanar (Dainius Silvanavičius)][Github kallanar] - Lithuanian;
- [vladimir-kirillovskiy][Github vladimir-kirillovskiy] - Russian;
- [AlesJiranek (Aleš Jiránek)][Github AlesJiranek] - Czech;
- [alialamshahi (Ali Alamshahi)][Github alialamshahi] - Farsi;
- [GisliNielsen (Gisli Nielsen)][Github GisliNielsen] - Norwegian Bokmål
- all the other contributors who are listed [here][Project contributors page];
[Google Plus closing]: https://blog.google/technology/safety-security/project-strobe/
[Google Plus closing 2]: https://www.blog.google/technology/safety-security/expediting-changes-google-plus/
[Project documentation]: https://giobonvi.github.io/GoogleContactsEventsNotifier
[Project issue page]: https://github.com/GioBonvi/GoogleContactsEventsNotifier/issues
[Project contributors page]: https://github.com/GioBonvi/GoogleContactsEventsNotifier/graphs/contributors
[Permissions list]: #permissions-required
[Issue template file]: .github/ISSUE_TEMPLATE.md
[Contributing file]: .github/CONTRIBUTING.md
[License file]: LICENSE
[Setup and installation guide]: docs/install-and-setup.md
[Translation guide]: docs/translation-guide.md
[Google Drive website]: https://drive.google.com/drive/
[Google Drive trash]: https://drive.google.com/drive/trash
[Google connected apps]: https://myaccount.google.com/permissions
[Original Google Help Forum Post]: https://productforums.google.com/d/msg/calendar/OaaO2og9m5w/2VgNNNF5BwAJ
[Github GioBonvi]: https://github.com/GioBonvi
[GitHub rowanthorpe]: https://github.com/rowanthorpe
[Github lboullo0]: https://github.com/lboullo0
[Github muzavan]: https://github.com/muzavan
[Github DrKrakower]: https://github.com/DrKrakower
[Github cezarylaksa]: https://github.com/cezarylaksa
[Github baatochan]: https://github.com/baatochan
[Github JayForce]: https://github.com/JayForce
[Github 88scythe]: https://github.com/88scythe
[Github miguel-r]: https://github.com/miguel-r
[Github Vinetos]: https://github.com/Vinetos
[Github AnnaH]: https://github.com/AnnaH
[Github shutdown27]: https://github.com/shutdown27
[Github nadyafebi]: https://github.com/nadyafebi
[Github cemysf]: https://github.com/cemysf
[Github MatheusNtg]: https://github.com/MatheusNtg
[Github goggenb]: https://github.com/goggenb
[Github phg98]: https://github.com/phg98
[Github SuperSandro2000]: https://github.com/SuperSandro2000
[Github kallanar]: https://github.com/kallanar
[Github krial057]: https://github.com/krial057
[Github vladimir-kirillovskiy]: https://github.com/vladimir-kirillovskiy
[Github AlesJiranek]: https://github.com/AlesJiranek
[Github jovanzers]: https://github.com/jovanzers
[Github alialamshahi]: https://github.com/alialamshahi
[Github GisliNielsen]: https://github.com/GisliNielsen
[Github apo-mak]: https://github.com/apo-mak
================================================
FILE: code.gs
================================================
/* global Logger ScriptApp ContactsApp Utilities Calendar CalendarApp UrlFetchApp MailApp Session */
/* eslint no-multi-spaces: ["error", { ignoreEOLComments: true }] */
/* eslint comma-dangle: ["error", "only-multiline"] */
/*
* Thanks to this script you are going to receive an email before events of each of your contacts.
* The script is easily customizable via some variables listed below.
*/
// SETTINGS
var settings = {
user: {
/*
* GOOGLE EMAIL ADDRESS
*
* Replace this fake Gmail address with the Gmail (or G Suite/Google Apps) address of your
* own Google Account. This is needed to retrieve information about your contacts.
*/
googleEmail: 'YOUREMAILHERE@gmail.com',
/*
* NOTIFICATION EMAIL ADDRESS
*
* Replace this fake email address with the one you want the notifications to be sent
* to. This can be the same email address as 'googleEmail' on or any other email
* address. Non-Gmail addresses are fine as well.
*/
notificationEmail: 'YOUREMEAILHERE@example.com',
/*
* EMAIL SENDER NAME
*
* This is the name you will see as the sender of the email: if you leave it blank it will
* default to your Google account name.
* Note: this may not work when notificationEmail is a Gmail address.
*/
emailSenderName: 'Contacts Events Notifications',
/*
* LANGUAGE
*
* To translate the notifications messages into your language enter the two-letter language
* code here.
* Available languages are:
* en, cs, de, el, es, fa, fr, he, id, it, kr, lt, nl, no, nb, pl, pt, pt-BR, ru, th, tr.
* If you want to add your own language find the variable called i18n below and follow the
* instructions: it's quite simple as long as you can translate from one of the available
* languages.
*/
lang: 'en'
},
notifications: {
/*
* HOUR OF THE NOTIFICATION
*
* Specify at which hour of the day would you like to receive the email notifications.
* This must be an integer between 0 and 23. This will set and automatic trigger for
* the script between e.g. 6 and 7 am.
*/
hour: 6,
/*
* NOTIFICATION TIMEZONE
*
* To ensure the correctness of the notifications timing please set this variable to the
* timezone you are living in.
* Accepted values:
* GMT (e.g. 'GMT-4', 'GMT+6')
* regional timezones (e.g. 'Europe/Berlin' - See here for a complete list: http://joda-time.sourceforge.net/timezones.html)
*/
timeZone: 'Europe/Rome',
/*
* HOW MANY DAYS BEFORE EVENT
*
* Here you have to decide when you want to receive the email notification.
* Insert a comma-separated list of numbers between the square brackets, where each number
* represents how many days before an event you want to be notified.
* If you want to be notified only once then enter a single number between the brackets.
*
* Examples:
* [0] means "Notify me the day of the event";
* [0, 7] means "Notify me the day of the event and 7 days before";
* [0, 1, 7] means "Notify me the day of the event, the day before and 7 days before";
*
* Note: in any case you will receive one email per day: all the notifications will be grouped
* together in that email.
*/
anticipateDays: [0, 1, 7],
/*
* TYPE OF EVENTS
*
* This script can track any Google Contact Event: you can decide which ones by placing true
* or false next to each type in the following lines.
* By default the script only tracks birthday events.
*/
eventTypes: {
BIRTHDAY: true,
ANNIVERSARY: false,
CUSTOM: false
},
/*
* MAXIMUM NUMBER OF EMAIL ADDRESSES
*
* You can limit the maximum number of email addresses displayed for each contact in the notification emails
* by changing this number. If you don't want to impose any limits change it to -1, if you don't want any
* email address to be shown change it to 0.
*/
maxEmailsCount: -1,
/*
* MAXIMUM NUMBER OF PHONE NUMBERS
*
* You can limit the maximum number of phone numbers displayed for each contact in the notification emails
* by changing this number. If you don't want to impose any limits change it to -1, if you don't want any
* phone number to be shown change it to 0.
*/
maxPhonesCount: -1,
/*
* INDENT SIZE
*
* Use this variable to determine how many spaces are used for indentation.
* This is used in the plaintext part of emails only (invisible to email clients which display
* the html part by default).
*/
indentSize: 4,
/*
* GROUP ALL LABELS
*
* By default only the main emails and phone numbers (work, home, mobile, main) are displayed with their
* own label: all the other special and/or custom emails and phone numbers are grouped into a single
* "other" group. By setting this variable to false instead, every phone and email will be grouped
* under its own label.
*/
compactGrouping: true
},
debug: {
log: {
/*
* LOGGING FILTER LEVEL
*
* This settings lets you filter which type of events will get logged:
* - 'INFO' will log all types of events event (messages, warnings and errors);
* - 'WARNING' will log warnings and errors only (discarding messages);
* - 'ERROR' will log errors only (discarding messages and warnings);
* - 'FATAL_ERROR' will log fatal errors only (discarding messages, warnings and non-fatal errors);
* - 'MAX' will effectively disable the logging (nothing will be logged);
*/
filterLevel: 'INFO',
/*
* Set this variable to: 'INFO', 'WARNING', 'ERROR', 'FATAL_ERROR' or 'MAX'. You will be sent an
* email containing the full execution log of the script if at least one event of priority
* equal or greater to sendTrigger has been logged. 'MAX' means that such emails will
* never be sent.
* Note: filterLevel has precedence over this setting! For example if you set filterLevel
* to 'MAX' and sendTrigger to 'WARNING' you will never receive any email as nothing will
* be logged due to the filterLevel setting.
*/
sendTrigger: 'ERROR'
},
/*
* TEST DATE
*
* When using the test() function this date will be used as "now". The date must be in the
* yyyy/MM/dd HH:mm:ss format.
* Choose a date you know should trigger an event notification.
*/
testDate: new Date('2017/08/01 06:00:00')
},
developer: {
/* NB: Users shouldn't need to (or want to) touch these settings. They are here for the
* convenience of developers/maintainers only.
*/
version: '5.1.4',
repoName: 'GioBonvi/GoogleContactsEventsNotifier',
gitHubBranch: 'master'
}
};
/*
* There is no need to edit anything below this line.
* The script will work if you inserted valid values up
* until here, however feel free to take a peek at the code ;)
*/
// CLASSES
/**
* Initialize a LocalCache object.
*
* A LocalCache object is used to store external resources which are used multiple
* times to optimize the number of `UrlFetchApp.fetch()` calls.
*
* @class
*/
function LocalCache () {
this.cache = {};
}
/**
* Fetch an URL, optionally making more than one try.
*
* @param {!string} url - The URL which has to be fetched.
* @param {?number} [tries=1] - Number of times to try the fetch operation before failing.
* @returns {?Object} - The fetch response or null if the fetch failed.
*/
LocalCache.prototype.fetch = function (url, tries) {
var response, i;
tries = tries || 1;
response = null;
// Try fetching the data.
for (i = 0; i < tries; i++) {
try {
response = UrlFetchApp.fetch(url);
if (response.getResponseCode() !== 200) {
throw new Error('');
}
// Break the loop if the fetch was successful.
break;
} catch (error) {
response = null;
Utilities.sleep(1000);
}
}
// Store the result in the cache and return it.
this.cache[url] = response;
return this.cache[url];
};
/**
* Determine whether an url has already been cached.
*
* @param {!string} url - The URL to check.
* @returns {boolean} - True if the cache contains an object for the URL, false otherwise.
*/
LocalCache.prototype.isCached = function (url) {
return !!this.cache[url];
};
/**
* Retrieve an object from the cache.
*
* The object is loaded from the cache if present, otherwise it is fetched.
*
* @param {!string} url - The URL to retrieve.
* @param {?number} tries - Number of times to try the fetch operation before failing (passed to `this.fetch()`).
* @returns {Object} - The response object.
*/
LocalCache.prototype.retrieve = function (url, tries) {
if (this.isCached(url)) {
return this.cache[url];
} else {
return this.fetch(url, tries);
}
};
/**
* Initialize an empty contact.
*
* A MergedContact object holds the data about a contact collected from multiple sources.
*
* @class
*/
function MergedContact () {
/** @type {?string} */
this.contactId = null;
// Consider all the event types excluded by settings.notifications.eventTypes
// as blacklisted for all contacts.
/** @type {string[]} */
this.blacklist = Object.keys(settings.notifications.eventTypes)
.filter(function (label) { return settings.notifications.eventTypes[label] === false; })
.map(eventLabelToLowerCase);
/** @type {ContactDataDC} */
this.data = new ContactDataDC(
null, // Name.
null, // Nickname.
null // Profile image URL.
);
/** @type {EmailAddressDC[]} */
this.emails = [];
/** @type {PhoneNumberDC[]} */
this.phones = [];
/** @type {EventDC[]} */
this.events = [];
}
/**
* Extract all the available data from the raw event object and store them in the `MergedContact`.
*
* @param {Object} rawEvent - The object containing all the data about the event, obtained
* from the Google Calendar API.
*/
MergedContact.prototype.getInfoFromRawEvent = function (rawEvent) {
var self, eventData, eventDate, eventMonth, eventDay, eventLabel;
log.add('Extracting info from raw event object...', Priority.INFO);
// We already know .gadget.preferences exists, we checked before getting contactId, before
// calling this method - to know whether to "merge to existing" or "create new" contact.
eventData = rawEvent.gadget.preferences;
// The raw event can contain the full name and profile photo of the contact (no nickname).
this.data.merge(new ContactDataDC(
eventData['goo.contactsFullName'], // Name.
null, // Nickname.
eventData['goo.contactsPhotoUrl'] // Profile image URL.
));
// The raw event contains an email of the contact, but without label.
this.addToField('emails', new EmailAddressDC(
null, // Label.
eventData['goo.contactsEmail'] // Email address.
));
// The raw event contains the type, day and month of the event, but not the year.
eventDate = /^(\d\d\d\d)-(\d\d)-(\d\d)$/.exec(rawEvent.start.date);
eventMonth = null;
eventDay = null;
if (eventDate) {
eventLabel = eventData['goo.contactsEventType'];
if (eventLabel === 'SELF') {
// Your own birthday is marked as 'SELF'.
eventLabel = 'BIRTHDAY';
} else if (eventLabel === 'CUSTOM') {
// Custom events have an additional field containing the custom name of the event.
eventLabel += ':' + (eventData['goo.contactsCustomEventType'] || '');
}
eventMonth = (eventDate[2] !== '00' ? parseInt(eventDate[2], 10) : null);
eventDay = (eventDate[3] !== '00' ? parseInt(eventDate[3], 10) : null);
}
// Collect info from the contactId if not already collected and if contactsContactId exists.
if (this.contactId === null && eventData['goo.contactsContactId']) {
this.getInfoFromContact(eventData['goo.contactsContactId'], eventMonth, eventDay);
}
// delete any events marked as blacklisted (but already added e.g. from raw event data)
if (this.blacklist) {
self = this;
self.blacklist.forEach(function (label) {
self.deleteFromField('events', label, false);
});
}
};
/**
* Update the `MergedContact` with info collected from a Google Contact.
*
* Some raw events will contain a Google Contact ID which gives access
* to a bunch of new data about the contact.
*
* This data is used to update the information collected until now.
*
* @param {!string} contactId - The id from which to collect the data.
* @param {?string} eventMonth - The month to match events.
* @param {?string} eventDay - The day to match events.
*/
MergedContact.prototype.getInfoFromContact = function (contactId, eventMonth, eventDay) {
var self, googleContact, blacklist;
self = this;
log.add('Extracting info from Google Contact...', Priority.INFO);
log.add('Fetching contact info for: ' + contactId, Priority.INFO);
var pageToken = null;
try {
do {
var requestParams = {personFields: "metadata", pageSize: 1000};
if (pageToken != null) {
requestParams.pageToken = pageToken;
}
const allContacts = People.People.Connections.list('people/me', requestParams);
pageToken = allContacts.getNextPageToken();
// unfortunately, the people API uses a different ID than the calendar API
// so we iterate over all contacts and find the first one that has a source with the correct contact id
function findContactWithId(connections, contactId) {
for (var i = 0; i < connections.length; i++) {
for (var j = 0; j < connections[i].metadata.sources.length; j++) {
if (connections[i].metadata.sources[j].id == contactId) {
return connections[i];
}
}
}
return undefined;
}
googleContact = findContactWithId(allContacts.connections, contactId);
if (googleContact !== undefined) {
log.add('Found contact: ' + googleContact.resourceName, Priority.INFO);
googleContact = People.People.get(googleContact.resourceName, {personFields: "names,events,emailAddresses,phoneNumbers,birthdays,userDefined"});
break;
}
} while(pageToken != null);
if (googleContact === null || googleContact === undefined) {
throw new Error('No suitable contact found');
}
} catch (err) {
log.add(err.message, Priority.WARNING);
log.add('Invalid Google Contact ID or error retrieving data for ID: ' + contactId, Priority.WARNING);
return;
}
try {
self.contactId = googleContact.resourceName;
// Contact identification data.
self.data.merge(new ContactDataDC(
googleContact.names[0].displayName, // Name.
googleContact.givenName, // Nickname.
null // Profile image URL.
));
// Events blacklist.
blacklist = googleContact.getUserDefined('notificationBlacklist');
if (blacklist && blacklist[0]) {
self.blacklist = uniqueStrings(self.blacklist.concat(blacklist[0].getValue().replace(/,+/g, ',').replace(/(^,|,$)/g, '').split(',').map(function (x) {
return x.toLocaleLowerCase();
})));
}
function processEvent(event) {
const date = event.date;
if (date.getDay() !== eventDay || date.getMonth() !== eventMonth) {
return;
}
if (self.blacklist && self.blacklist.length && isIn(event.type.toLocaleLowerCase(), self.blacklist)) {
return;
}
self.addToField('events', new EventDC(
event.formattedType,
date.getYear(),
eventMonth,
eventDay
));
}
if (settings.notifications.eventTypes.CUSTOM) {
googleContact.getEvents()?.forEach(processEvent);
}
bdays = googleContact.getBirthdays();
for (var i = 0; i < bdays.length; i++) {
bdays[i].type = "BIRTHDAY";
bdays[i].formattedType = bdays[i].type;
processEvent(bdays[i]);
}
// Email addresses.
if (googleContact.getEmailAddresses() !== undefined) {
googleContact.getEmailAddresses().forEach(function (emailField) {
self.addToField('emails', new EmailAddressDC(
String(emailField.getFormattedType()),
emailField.getValue()
));
});
}
// Phone numbers.
if (googleContact.getPhoneNumbers() !== undefined) {
googleContact.getPhoneNumbers().forEach(function (phoneField) {
self.addToField('phones', new PhoneNumberDC(
String(phoneField.getFormattedType()),
phoneField.getValue()
));
});
}
} catch (err) {
log.add(err.message, Priority.WARNING)
log.add('Error merging info for: ' + self.contactId, Priority.WARNING);
return;
}
};
/**
* This method is used to insert a new DataCollector into an array of
* DataCollectors.
*
* For example take `EventDC e` and `EventDC[] arr`; This method checks
* all the elements of `arr`: if it finds one that is compatible with `e`
* it merges `e` into that element, otherwise, if no element in the array
* is compatible or if the array is empty, it just adds `e` at the end of
* the array.
*
* @param {!string} field - The name of the field in which to insert the object.
* @param {DataCollector} incData - The object to insert.
*/
MergedContact.prototype.addToField = function (field, incData) {
var merged, i, data;
// incData must have at least one non-empty property.
if (
Object.keys(incData.prop).length === 0 ||
Object.keys(incData.prop)
.filter(function (key) { return !incData.isPropEmpty(key); })
.length === 0
) {
return;
}
// Try to find a non-conflicting object to merge with in the given field.
merged = false;
// Use 'for' instead of 'forEach', so we can short-circuit with 'break'
for (i = 0; i < this[field].length; i++) {
data = this[field][i];
if (!data.isConflicting(incData)) {
data.merge(incData);
merged = true;
break;
}
}
// If incData could not be merged simply append it to the field.
if (!merged) {
this[field].push(incData);
}
};
/**
* This method is used to delete a DataCollector from an array of
* DataCollectors based on label.
*
* @param {!string} field - The name of the field from which to delete the object.
* @param {!string} label - The label to match to signify deletion.
* @param {?boolean} caseSensitive - Whether to match labels case-sensitively or not.
*/
MergedContact.prototype.deleteFromField = function (field, label, caseSensitive) {
var data, eachLabel, fieldIter;
if (!caseSensitive) {
label = eventLabelToLowerCase(label);
}
// Iterate by reverse index to allow safe splicing from within the loop
fieldIter = this[field].length;
while (fieldIter--) {
data = this[field][fieldIter];
eachLabel = data.getProp('label');
if (!caseSensitive) {
eachLabel = eventLabelToLowerCase(eachLabel);
}
// Delete those events whose label exactly matches the one given or,
// if the given label is 'Custom', all the custom events.
if (label === eachLabel || (label === 'custom' && eachLabel.indexOf('CUSTOM:') === 0)) {
this[field].splice(fieldIter, 1);
break;
}
}
};
/**
* Generate a list of text lines of the given format, each describing an
* event of the contact of the type specified on the date specified.
*
* @param {!string} type - The type of the event.
* @param {!Date} date - The date of the event.
* @param {!NotificationType} format - The format of the text line.
* @returns {string[]} - A list of the plain text descriptions of the events.
*/
MergedContact.prototype.getLines = function (type, date, format) {
var self;
self = this;
return self.events.filter(function (event) {
var typeMatch;
switch (event.getProp('label')) {
case 'BIRTHDAY':
typeMatch = (type === 'BIRTHDAY');
break;
case 'ANNIVERSARY':
typeMatch = (type === 'ANNIVERSARY');
break;
default:
typeMatch = (type === 'CUSTOM');
}
return typeMatch && event.getProp('day') === date.getDate() && event.getProp('month') === (date.getMonth() + 1);
}).map(function (event) {
var line, eventLabel, imgCount;
line = [];
// Start line.
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push(indent);
break;
case NotificationType.HTML:
line.push('<li>');
}
// Profile photo.
switch (format) {
case NotificationType.HTML:
imgCount = Object.keys(inlineImages).length;
try {
// Get the default profile image from the cache.
inlineImages['contact-img-' + imgCount] = cache.retrieve(self.data.getProp('photoURL')).getBlob().setName('contact-img-' + imgCount);
line.push('<img src="cid:contact-img-' + imgCount + '" style="height:1.4em;margin-right:0.4em" alt="" />');
} catch (err) {
log.add('Unable to get the profile picture with URL ' + self.data.getProp('photoURL'), Priority.WARNING);
}
}
// Custom label
if (type === 'CUSTOM') {
eventLabel = event.getProp('label') || 'OTHER';
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push('<', beautifyLabel(eventLabel), '> ');
break;
case NotificationType.HTML:
line.push(htmlEscape('<' + beautifyLabel(eventLabel) + '> '));
}
}
// Full name.
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push(self.data.getProp('fullName'));
break;
case NotificationType.HTML:
line.push(htmlEscape(self.data.getProp('fullName')));
}
// Nickname.
if (!self.data.isPropEmpty('nickname')) {
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push(' "', self.data.getProp('nickname'), '"');
break;
case NotificationType.HTML:
line.push(htmlEscape(' "' + self.data.getProp('nickname') + '"'));
}
}
// Age/years passed.
if (!event.isPropEmpty('year')) {
if (type === 'BIRTHDAY') {
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push(' - ', _('Age'), ': ');
break;
case NotificationType.HTML:
line.push(' - ', htmlEscape(_('Age')), ': ');
}
} else {
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push(' - ', _('Years'), ': ');
break;
case NotificationType.HTML:
line.push(' - ', htmlEscape(_('Years')), ': ');
}
}
line.push(Math.round(date.getFullYear() - event.getProp('year')));
}
// Email addresses and phone numbers.
var collected;
// Emails and phones are grouped by label: these are the default main label groups.
collected = {
HOME_EMAIL: [],
WORK_EMAIL: [],
OTHER_EMAIL: [],
MAIN_PHONE: [],
HOME_PHONE: [],
WORK_PHONE: [],
MOBILE_PHONE: [],
OTHER_PHONE: []
};
// Collect and group the email addresses.
self.emails.forEach(function (email, i) {
var label, emailAddr;
if (settings.notifications.maxEmailsCount < 0 || i < settings.notifications.maxEmailsCount) {
label = email.getProp('label');
emailAddr = email.getProp('address');
if (!isIn(collected[label], [undefined, null])) {
// Store the value if the label group is already defined.
collected[label].push(emailAddr);
} else if (!settings.notifications.compactGrouping && label) {
// Define a new label groups different from the main ones only if compactGrouping is set to false.
// Note: Google's OTHER label actually is an empty string.
collected[label] = [emailAddr];
} else {
// Store any other label in the OTHER_EMAIL label group.
collected['OTHER_EMAIL'].push(emailAddr);
}
}
});
// Collect and group the phone numbers.
self.phones.forEach(function (phone, i) {
var label, phoneNum;
if (settings.notifications.maxPhonesCount < 0 || i < settings.notifications.maxPhonesCount) {
label = phone.getProp('label');
phoneNum = phone.getProp('number');
if (!isIn(collected[label], [undefined, null])) {
// Store the value if the label group is already defined.
collected[label].push(phoneNum);
} else if (!settings.notifications.compactGrouping && label) {
// Define a new label groups different from the main ones only if compactGrouping is set to false.
// Note: Google's OTHER label actually is an empty string.
collected[label] = [phoneNum];
} else {
// Store any other label in the OTHER_PHONE label group.
collected['OTHER_PHONE'].push(phoneNum);
}
}
});
// If there is at least an email address/phone number to be added to the email...
if (Object.keys(collected).reduce(function (acc, label) { return acc + collected[label].length; }, 0) >= 1) {
// ...generate the text from the grouped emails and phone numbers.
line.push(' (');
line.push(
Object.keys(collected).map(function (label) {
var output;
if (collected[label].length) {
switch (format) {
case NotificationType.PLAIN_TEXT:
output = beautifyLabel(label);
break;
case NotificationType.HTML:
output = htmlEscape(beautifyLabel(label));
}
return output + ': ' + collected[label].map(function (val) {
var buffer;
switch (format) {
case NotificationType.PLAIN_TEXT:
return val;
case NotificationType.HTML:
buffer = '<a href="';
if (label.match(/_EMAIL$/)) {
buffer += 'mailto';
} else if (label.match(/_PHONE$/)) {
buffer += 'tel';
}
return buffer + ':' + htmlEscape(val) + '">' + htmlEscape(val) + '</a>';
}
}).join(' - ');
}
}).filter(function (val) {
return val;
}).join(', ')
);
line.push(')');
}
// Finish line.
switch (format) {
case NotificationType.HTML:
line.push('</li>');
}
return line.join('');
});
};
/**
* DataCollector is a structure used to collect data about any "object" (an event, an
* email address, a phone number...) from multiple incomplete sources.
*
* For example the raw event could contain the day and month of the birthday, while
* the Google Contact could hold the year as well. DataCollector can be used to accumulate
* the data in multiple takes: each take updates the values that were left empty by the
* previous ones until all info have been collected.
*
* Each DataCollector object can contain an arbitrary number of properties in the form of
* name -> value, stored in the prop object.
*
* Empty properties have null value.
*
* DataCollector is an abstract class. Each data type should have its own implementation
* (`EventDC`, `EmailAddressDC`, `PhoneNumberDC`).
*
* @class
*/
var DataCollector = function () {
if (this.constructor === DataCollector) {
throw new Error('DataCollector is an abstract class and cannot be instantiated!');
}
/** @type {Object.<string,string>} */
this.prop = {};
};
/**
* Get the value of a given property.
*
* @param {!string} key - The name of the property.
* @returns {?string} - The value of the property.
*/
DataCollector.prototype.getProp = function (key) {
return this.prop[key];
};
/**
* Set a given property to a certain value.
*
* If the value is undefined or an empty string it's replaced by `null`.
*
* @param {!string} key - The name of the property.
* @param {?string} value - The value of the property.
*/
DataCollector.prototype.setProp = function (key, value) {
this.prop[key] = value || null;
};
/**
* Determines whether a given property is empty or not.
*
* @param {!string} key - The name of the property.
* @returns {boolean} - True if the property is empty, false otherwise.
*/
DataCollector.prototype.isPropEmpty = function (key) {
return this.prop[key] === null;
};
/**
* Detect whether two DataCollectors have the same constructor or not.
*
* * Examples:
* DC_1 = new EventDC(...a, b, c...)
* DC_2 = new EventDC(...x, y, z...)
* DC_3 = new EmailAddressDC(...a, b, c...)
* DC_4 = new EmailAddressDC(...x, y, z...)
*
* DC_1.isCompatible(DC_2) -> true
* DC_1.isCompatible(DC_3) -> false
* DC_1.isCompatible(DC_4) -> false
*
* @param {DataCollector} otherData - The object to compare the current one with.
* @returns {boolean} - True if the tow objects have the same constructor, false otherwise.
*/
DataCollector.prototype.isCompatible = function (otherData) {
// Only same-implementation objects of DataCollector can be compared.
return this.constructor === otherData.constructor;
};
/**
* Detect whether two DataCollectors are conflicting or not.
*
* * Examples:
* DC_1 = {name='test', number=3, field=null}
* DC_2 = {name=null, number=3, field=3}
* DC_3 = {name='test', number=null, field=1}
* DC_4 = {name='test', number=3, otherfield=null} (using different DC implementation)
*
* DC_1.isConflicting(DC_2) -> false
* DC_1.isConflicting(DC_3) -> false
* DC_1.isConflicting(DC_4) -> false (not .isCompatible())
* DC_2.isConflicting(DC_3) -> true (conflict on field)
*
* @param {DataCollector} otherData - The object to compare the current one with.
* @returns {boolean} - True if the two objects are conflicting, false otherwise.
*/
DataCollector.prototype.isConflicting = function (otherData) {
var self;
self = this;
if (!self.isCompatible(otherData)) {
return false;
}
return Object.keys(otherData.prop)
.filter(function (key) {
return !self.isPropEmpty(key) && !otherData.isPropEmpty(key) && self.getProp(key) !== otherData.getProp(key);
}).length !== 0;
};
/**
* Merge two `DataCollector` objects, filling the empty properties of the
* first one with the non-empty properties of the second one.
*
* * Examples:
* DC_1 = {name='test', number=3, field=null}
* DC_2 = {name=null, number=3, field=3}
* DC_2 = {name='test', number=null, field=1}
*
* DC_1.merge(DC_2) -> {name='test', number=3, field=3}
* DC_1.isCompatible(DC_3) -> {name='test', number=3, field=1}
* DC_2.isCompatible(DC_3) -> INCOMPATIBLE
*
* @param {DataCollector} otherDataCollector - The object to merge into the current one.
*/
DataCollector.prototype.merge = function (otherDataCollector) {
var self;
self = this;
if (!self.isCompatible(otherDataCollector)) {
throw new Error('Trying to merge two different implementations of IncompleteData!');
}
// Fill each empty key of the current DataCollector with the value from the given one.
Object.keys(self.prop).forEach(function (key) {
if (self.isPropEmpty(key)) {
self.setProp(key, otherDataCollector.getProp(key));
}
});
};
// Implementations of DataCollector.
/**
* Init an Event Data Collector.
*
* @param {!string} label - Label of the event (BIRTHDAY, ANNIVERSARY, ANYTHING_ELSE...)
* @param {!number} year - Year of the event.
* @param {!number} month - Month of the event.
* @param {!number} day - Day of the event.
*/
var EventDC = function (label, year, month, day) {
DataCollector.apply(this);
this.setProp('label', label);
this.setProp('year', year);
this.setProp('month', month);
this.setProp('day', day);
};
EventDC.prototype = Object.create(DataCollector.prototype);
EventDC.prototype.constructor = EventDC;
/**
* Init an EmailAddress Data Collector.
*
* @param {!string} label - The label of the email address (WORK_EMAIL, HOME_EMAIL...).
* @param {!string} address - The email address.
*/
var EmailAddressDC = function (label, address) {
DataCollector.apply(this);
this.setProp('label', label);
this.setProp('address', address);
};
EmailAddressDC.prototype = Object.create(DataCollector.prototype);
EmailAddressDC.prototype.constructor = EmailAddressDC;
/**
* Init a PhoneNumber Data Collector.
*
* @param {!string} label - The label of the phone number (WORK_PHONE, HOME_PHONE...).
* @param {!string} number - The phone number.
*/
var PhoneNumberDC = function (label, number) {
DataCollector.apply(this);
this.setProp('label', label);
this.setProp('number', number);
};
PhoneNumberDC.prototype = Object.create(DataCollector.prototype);
PhoneNumberDC.prototype.constructor = PhoneNumberDC;
/**
* Init a ContactData Data Collector.
*
* @param {!string} fullName - The full name of the contact.
* @param {!string} nickname - The nickname of the contact.
* @param {!string} photoURL - The URL of the profile image of the contact.
*/
var ContactDataDC = function (fullName, nickname, photoURL) {
DataCollector.apply(this);
this.setProp('fullName', fullName);
this.setProp('nickname', nickname);
this.setProp('photoURL', photoURL);
};
ContactDataDC.prototype = Object.create(DataCollector.prototype);
ContactDataDC.prototype.constructor = ContactDataDC;
/**
* Init a Log object, used to manage a collection of logEvents {time, text, priority}.
*
* @param {?Priority} [minimumPriority=Priority.INFO] - Logs with priority lower than this will not be recorded.
* @param {?Priority} [emailMinimumPriority=Priority.ERROR] - If at least one log with priority greater than or
equal to this is recorded an email with all the logs will be sent to the user.
* @param {?boolean} [testing=false] - If this is true logging an event with Priority.FATAL_ERROR will not
* cause execution to stop.
* @class
*/
function Log (minimumPriority, emailMinimumPriority, testing) {
this.minimumPriority = minimumPriority || Priority.INFO;
this.emailMinimumPriority = emailMinimumPriority || Priority.ERROR;
this.testing = testing || false;
/** @type {Object[]} */
this.events = [];
}
/**
* Store a new event in the log. The default priority is the lowest one (`INFO`).
*
* @param {!any} data - The data to be logged: best if a string, Objects get JSONized.
* @param {?Priority} [priority=Priority.INFO] - Priority of the log event.
*/
Log.prototype.add = function (data, priority) {
var text;
priority = priority || Priority.INFO;
if (typeof data === 'object') {
text = JSON.stringify(data);
} else if (typeof data !== 'string') {
text = String(data);
} else {
text = data;
}
if (priority.value >= this.minimumPriority.value) {
this.events.push(new LogEvent(new Date(), text, priority));
}
// Still log into the standard logger as a backup in case the program crashes.
Logger.log(priority.name[0] + ': ' + text);
// Throw an Error and interrupt the execution if the log event had FATAL_ERROR
// priority and we are not in test mode.
if (priority.value === Priority.FATAL_ERROR.value && !this.testing) {
this.sendEmail(settings.user.notificationEmail, settings.user.emailSenderName);
throw new Error(text);
}
};
/**
* Get the output of the log as an array of messages.
*
* @returns {string[]}
*/
Log.prototype.getOutput = function () {
return this.events.map(function (e) {
return e.toString();
});
};
/**
* Verify if the log contains at least an event with priority equal to or greater than
* the specified priority.
*
* @param {!Priority} minimumPriority - The numeric value representing the priority limit.
* @returns {boolean}
*/
Log.prototype.containsMinimumPriority = function (minimumPriority) {
var i;
for (i = 0; i < this.events.length; i++) {
if (this.events[i].priority.value >= minimumPriority.value) {
return true;
}
}
return false;
};
/**
* If the filter condition is met send all the logs collected to the specified email.
*
* @param {!string} to - The email address of the recipient of the email.
* @param {!string} senderName - The name of the sender.
*/
Log.prototype.sendEmail = function (to, senderName) {
if (this.containsMinimumPriority(this.emailMinimumPriority)) {
this.add('Sending logs via email.', Priority.INFO);
MailApp.sendEmail({
to: to,
subject: 'Logs for Google Contacts Events Notifications',
body: this.getOutput().join('\n'),
name: senderName
});
this.add('Email sent.', Priority.INFO);
}
};
/**
* A logged event.
*
* @param {Date} time - The time of the event.
* @param {string} message - The message of the event.
* @param {Priority} priority - The priority of the event.
*/
function LogEvent (time, message, priority) {
this.time = time;
this.message = message;
this.priority = priority;
}
/**
* Get a textual description of the LogEvent in this format
* (P is the first letter of the priority):
*
* [TIME] P: MESSAGE
*
* @returns {string} - The textual description of the event.
*/
LogEvent.prototype.toString = function () {
return '[' + Utilities.formatDate(this.time, Session.getScriptTimeZone(), 'dd-MM-yyyy HH:mm:ss') + ' ' + Session.getScriptTimeZone() + '] ' + this.priority.name[0] + ': ' + this.message;
};
/**
* An enum of plurals for eventTypes.
*
* @readonly
* @enum {string}
*/
var eventTypeNamePlural = {
BIRTHDAY: 'birthdays',
ANNIVERSARY: 'anniversaries',
CUSTOM: 'custom events'
};
/**
* A priority enum.
*
* @readonly
* @enum {Object.<string,number>}
*/
var Priority = {
NONE: {name: 'None', value: 0},
INFO: {name: 'Info', value: 10},
WARNING: {name: 'Warning', value: 20},
ERROR: {name: 'Error', value: 30},
FATAL_ERROR: {name: 'Fatal error', value: 40},
MAX: {name: 'Max', value: 100}
};
/**
* Enum for notification type.
*
* @readonly
* @enum {number}
*/
var NotificationType = {
PLAIN_TEXT: 0,
HTML: 1
};
/**
* An object representing a simplified semantic version number.
*
* It must be composed of:
*
* * three dot-separated positive integers (major version,
* minor version and patch number);
* * optionally a pre-release identifier, prefixed by a hyphen;
* * optionally a metadata identifier, prefixed by a plus sign;
*
* This differs from the official SemVer style because the pre-release
* string is compared as a whole in version comparison instead of
* being spliced into chunks.
*
* @param {!string} versionNumber - The version number to build the object with.
*
* @class
*/
function SimplifiedSemanticVersion (versionNumber) {
var matches, self;
self = this;
/** @type {number[]} */
self.numbers = [0, 0, 0];
/** @type {string} */
self.preRelease = '';
/** @type {string} */
self.metadata = '';
// Extract the pieces of information from the given string.
matches = versionNumber.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+?))?(?:\+(.+))?$/);
if (matches) {
self.numbers[0] = parseInt(matches[1], 10);
self.numbers[1] = parseInt(matches[2], 10);
self.numbers[2] = parseInt(matches[3], 10);
self.preRelease = isIn(matches[4], [undefined, null]) ? '' : matches[4];
self.metadata = isIn(matches[5], [undefined, null]) ? '' : matches[5];
} else {
throw new Error('The version number "' + versionNumber + '" is not valid!');
}
}
/**
* Build the version number string from the data.
*
* @returns {string} - The version number of this version.
*/
SimplifiedSemanticVersion.prototype.toString = function () {
return this.numbers.join('.') +
(this.preRelease !== '' ? '-' + this.preRelease : '') +
(this.metadata !== '' ? '+' + this.metadata : '');
};
/**
* Compare a semantic version number with another one.
*
* Order of comparison: major number, minor number, patch number,
* preRelease string (ASCII comparison). Metadata do not influence
* comparisons.
*
* @param {!SimplifiedSemanticVersion} comparedVersion - The version to compare.
* @returns {number} - 1, 0 , -1 if this version number is greater than, equal to or smaller than the one passed as the parameter.
*/
SimplifiedSemanticVersion.prototype.compare = function (comparedVersion) {
var i;
for (i = 0; i < 3; i++) {
if (this.numbers[i] !== comparedVersion.numbers[i]) {
return (this.numbers[i] < comparedVersion.numbers[i] ? -1 : 1);
}
}
if (this.preRelease !== comparedVersion.preRelease) {
// Between two versions with the same numbers, one in pre-release and the
// other not, the one in pre-release must be considered smaller.
if (this.preRelease === '') {
return 1;
} else if (comparedVersion.preRelease === '') {
return -1;
}
return (this.preRelease < comparedVersion.preRelease ? -1 : 1);
}
return 0;
};
// EXTENDED NATIVE PROTOTYPES
if (isIn(Array.prototype.extend, [undefined, null])) {
/**
* Merge an array at the end of an existing array.
*
* * Example:
* a = [1, 2, 3], b = [4, 5, 6];
* a.extend(b);
* a -> [1, 2, 3, 4, 5, 6]
*
* @param {any[]} array - The array used to extend.
* @returns {any[]} - Returns this for subsequent calls.
*/
Array.prototype.extend = function (array) { // eslint-disable-line no-extend-native
var i;
for (i = 0; i < array.length; ++i) {
this.push(array[i]);
}
return this;
};
}
if (isIn(String.prototype.format, [undefined, null])) {
/**
* Format a string, replace {1}, {2}, etc with their corresponding trailing args.
*
* * Examples:
* 'This is a {0}'.format('test') -> 'This is a test.'
* 'This {0} a {1}'.format('is') -> 'This is a {1}.'
*
* @param {...!string} arguments
* @returns {string}
*/
String.prototype.format = function () { // eslint-disable-line no-extend-native
var args;
args = arguments;
return this.replace(/\{(\d+)\}/g, function (match, number) {
return isIn(args[number], [undefined, null])
? match
: args[number]
;
});
};
}
if (isIn(String.prototype.replaceAll, [undefined, null])) {
/**
* Replace all occurrences of a substring (not a regex).
*
* @param {!string} substr - The substring to be replaced.
* @param {!string} repl - The replacement for the substring.
* @returns {string} - The string with the substrings replaced.
*/
String.prototype.replaceAll = function (substr, repl) { // eslint-disable-line no-extend-native
return this.split(substr).join(repl);
};
}
if (isIn(Number.isInteger, [undefined, null])) {
/**
* Determine if a number is an integer.
*
* @param {number} n - The number to check.
* @returns {boolean} - True if the number is an integer, false otherwise.
*/
Number.isInteger = function (n) {
return typeof n === 'number' && (n % 1) === 0;
};
}
if (isIn(Date.prototype.addDays, [undefined, null])) {
/**
* Generate a new date adding a number of days to a given date.
*
* @param {number} days Number of days to be added to the date.
* @author AnthonyWJones
* @see {@link https://stackoverflow.com/a/563442|Stackoverflow}
*/
Date.prototype.addDays = function (days) { // eslint-disable-line no-extend-native
var dat = new Date(this.valueOf());
dat.setDate(dat.getDate() + days);
return dat;
};
}
// GLOBAL VARIABLES
/**
* The version of the script.
*
* @type {!SimplifiedSemanticVersion}
*/
var version = new SimplifiedSemanticVersion(settings.developer.version);
var cache = new LocalCache();
// These URLs are used to access the files in the repository or specific pages on GitHub.
var baseRawFilesURL = 'https://raw.githubusercontent.com/' + settings.developer.repoName + '/' + settings.developer.gitHubBranch + '/';
var baseGitHubProjectURL = 'https://github.com/' + settings.developer.repoName + '/';
var baseGitHubApiURL = 'https://api.github.com/repos/' + settings.developer.repoName + '/';
var defaultProfileImageURL = baseRawFilesURL + 'images/default_profile.jpg';
// Convert user-configured hash to an array
var eventTypes = Object.keys(settings.notifications.eventTypes)
.filter(function (x) { return settings.notifications.eventTypes[x]; });
// Build the indentation from the setting.
var indent = Array(settings.notifications.indentSize + 1).join(' ');
var inlineImages;
var log = new Log(Priority[settings.debug.log.filterLevel], Priority[settings.debug.log.sendTrigger]);
// NB: When Google fixes their too-broad scope bug with ScriptApp, re-wrap this i18n
// table in `eslint-*able comma-dangle` comments (see old git-commits to find it)
var i18n = {
// For all languages, if a translation is not present the untranslated string
// is returned, so just leave out translations which are the same as the English.
// NB: If ever adding a lang which uses non-latin numbers functionality will need
// to be added to handle that differently (arbitrary numbers, not just a small
// selection, e.g. for age calculation).
// An entry for 'en' marks it as a valid lang config-option, but leave it empty
// to just return unaltered phrases.
'en': {},
'cs': {
'Age': 'Věk',
'Years': 'Let',
'Events': 'Události',
'Birthdays today': 'Narozeniny dnes',
'Birthdays tomorrow': 'Narozeniny zítra',
'Birthdays in {0} days': 'Narozeniny za {0} dny/í',
'Anniversaries today': 'Výročí dnes',
'Anniversaries tomorrow': 'Výročí zítra',
'Anniversaries in {0} days': 'Výročí za {0} dny/í',
'Custom events today': 'Jiné události dnes',
'Custom events tomorrow': 'Jiné události zítra',
'Custom events in {0} days': 'Jiné události za {0} dny/í',
'Hey! Don\'t forget these events': 'Hej! Nezapomeň na tyto události',
'version': 'verze',
'dd-MM-yyyy': 'dd.MM.yyyy',
'Mobile phone': 'Mobil',
'Work phone': 'Telefon (pracovní)',
'Home phone': 'Telefon (soukromý)',
'Main phone': 'Telefon (hlavní)',
'Other phone': 'Jiné telefonní číslo',
'Home fax': 'Fax (soukromý)',
'Work fax': 'Fax (pracovní)',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'E-mail (soukromý)',
'Work email': 'E-mail (pracovní)',
'Other email': 'Jiné e-mailové adresy',
'It looks like you are using an outdated version of this script': 'Vypadatá to, že používáte zastaralou verzi skriptu',
'You can find the latest one here': 'Poslední verzi najdete zde',
},
'de': {
'Age': 'Alter',
'Years': 'Jahre',
'Events': 'Termine',
'Birthdays today': 'Geburtstage heute',
'Birthdays tomorrow': 'Geburtstage morgen',
'Birthdays in {0} days': 'Geburtstage in {0} Tagen',
'Anniversaries today': 'Jahrestage heute',
'Anniversaries tomorrow': 'Jahrestage morgen',
'Anniversaries in {0} days': 'Jahrestage in {0} Tagen',
'Custom events today': 'Benutzerdefinierte Termine heute',
'Custom events tomorrow': 'Benutzerdefinierte Termine morgen',
'Custom events in {0} days': 'Benutzerdefinierte Termine in {0} Tagen',
'Hey! Don\'t forget these events': 'Hey! Vergiss diese Termine nicht',
'version': 'Version',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Telefon (mobil)',
'Work phone': 'Telefon (geschäftlich)',
'Home phone': 'Telefon (privat)',
'Main phone': 'Telefon (haupt)',
'Other phone': 'Telefon (sonstige)',
'Home fax': 'Fax (privat)',
'Work fax': 'Fax (geschäftlich)',
'Google voice': 'Google Voice',
'Pager': 'Pager',
'Home email': 'E-Mail (privat)',
'Work email': 'E-Mail (geschäftlich)',
'Other email': 'E-Mail (sonstige)',
'It looks like you are using an outdated version of this script': 'Du verwendest anscheinend eine veraltete Version dieses Skripts',
'You can find the latest one here': 'Du findest die neuste Version hier', // Using feminime version of 'latest', because it refers to 'version'. There's possibility it won't fit into diffrent context.
},
'el': {
'Age': 'Ηλικία',
'Years': 'Χρόνια',
'Events': 'Γεγονότα',
'Birthdays today': 'Γενέθλια σήμερα',
'Birthdays tomorrow': 'Γενέθλια αύριο',
'Birthdays in {0} days': 'Γενέθλια σε {0} ημέρες',
'Anniversaries today': 'Επέτειοι σήμερα',
'Anniversaries tomorrow': 'Επέτειοι αύριο',
'Anniversaries in {0} days': 'Επέτειοι σε {0} ημέρες',
'Custom events today': 'Προσαρμοσμένα γεγονότα σήμερα',
'Custom events tomorrow': 'Προσαρμοσμένα γεγονότα αύριο',
'Custom events in {0} days': 'Προσαρμοσμένα γεγονότα σε {0} ημέρες',
'Hey! Don\'t forget these events': 'Και πού σαι! Μην ξεχάσεις αυτά τα γεγονότα',
'version': 'εκδοχή',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Κινητό',
'Work phone': 'Τηλέφωνο εργασίας',
'Home phone': 'Τηλέφωνο οικίας',
'Main phone': 'Κύριο τηλέφωνο',
'Other phone': 'Άλλο τηλέφωνο',
'Home fax': 'Φαξ οικίας',
'Work fax': 'Φαξ εργασίας',
'Google voice': 'Google voice',
'Pager': 'Τηλεειδοποίηση',
'Home email': 'Προσωπικό email',
'Work email': 'Επαγγελματικό email',
'Other email': 'Άλλο email',
'It looks like you are using an outdated version of this script': 'Φαίνεται οτι χρησιμοποιείς μια παλαιότερη εκδοχή αυτόυ του script',
'You can find the latest one here': 'Μπορείς να βρείς την τελευταία εδώ',
},
'es': {
'Age': 'Edad',
'Years': 'Años',
'Events': 'Eventos',
'Birthdays today': 'Cumpleaños hoy',
'Birthdays tomorrow': 'Cumpleaños mañana',
'Birthdays in {0} days': 'Cumpleaños en {0} días',
'Anniversaries today': 'Aniversarios hoy',
'Anniversaries tomorrow': 'Aniversarios mañana',
'Anniversaries in {0} days': 'Aniversarios en {0} días',
'Custom events today': 'Eventos personalizados de hoy',
'Custom events tomorrow': 'Eventos personalizados de mañana',
'Custom events in {0} days': 'Eventos personalizados en {0} das',
'Hey! Don\'t forget these events': 'Hey! No olvides estos eventos',
'version': 'versión',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Celular',
'Work phone': 'Teléfono del trabajo',
'Home phone': 'Teléfono del hogar',
'Main phone': 'Teléfono principal',
'Other phone': 'Otro teléfono',
'Home fax': 'Fax del hogar',
'Work fax': 'Fax del trabajo',
'Google voice': 'Google voice',
'Pager': 'Buscapersonas',
'Home email': 'Correo electrónico del hogar',
'Work email': 'Correo electrónico del trabajo',
'Other email': 'Otro correo electrónico',
'It looks like you are using an outdated version of this script': 'Parece que estás usando una versión antigua de este script',
'You can find the latest one here': 'Puedes encontrar la última aquí',
},
'fa': {
'Age': 'سن',
'Years': 'سال',
'Events': 'رویدادها',
'Birthdays today': 'تولدهای امروز',
'Birthdays tomorrow': 'تولدهای فردا',
'Birthdays in {0} days': 'تولدهای {0} روز آینده',
'Anniversaries today': 'سالگردهای امروز',
'Anniversaries tomorrow': 'سالگردهای فردا',
'Anniversaries in {0} days': 'سالگردهای {0} روز آینده',
'Custom events today': 'رویدادهای شخصی امروز',
'Custom events tomorrow': 'رویدادهای شخصی فردا',
'Custom events in {0} days': 'رویدادهای شخصی {0} روز آینده',
'Hey! Don\'t forget these events': 'سلام! این رویدادها را فراموش نکن',
'version': 'نسخه',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'شماره موبایل',
'Work phone': 'شماره تلفن محل کار',
'Home phone': 'شماره تلفن خانه',
'Main phone': 'شماره تلفن اصلی',
'Other phone': 'شماره تلفن دیگر',
'Home fax': 'شماره فاکس خانه',
'Work fax': 'شماره فاکس محل کار',
'Google voice': 'وویس گوگل',
'Pager': 'پیجر',
'Home email': 'ایمیل خانه',
'Work email': 'ایمیل محل کار',
'Other email': 'ایمیل دیگر',
'It looks like you are using an outdated version of this script': 'به نظر می رسد شما نسخه قدیمی این اسکریپت را استفاده می کنید',
'You can find the latest one here': 'اینجا می توانید نسخه به روز را بیابید',
},
'fr': {
'Age': 'Age',
'Years': 'Années',
'Events': 'Evénements',
'Birthdays today': 'Anniversaires d`\'aujourd\'hui',
'Birthdays tomorrow': 'Anniversaires de demain',
'Birthdays in {0} days': 'Anniversaires dans {0} jours',
'Anniversaries today': 'Anniversaires d\'aujourd\'hui',
'Anniversaries tomorrow': 'Anniversaires de demain',
'Anniversaries in {0} days': 'Anniversaires dans {0} jours',
'Custom events today': 'Autres événements d\'aujourd\'hui',
'Custom events tomorrow': 'Autres événements de demain',
'Custom events in {0} days': 'Autres événements dans {0} jours',
'Hey! Don\'t forget these events': 'Hey ! N\'oubliez pas ces événements',
'version': 'version',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Téléphone portable',
'Work phone': 'Téléphone professionnel',
'Home phone': 'Téléphone personnel',
'Main phone': 'Téléphone principal',
'Other phone': 'Autre téléphone',
'Home fax': 'Fax personnel',
'Work fax': 'Fax professionnel',
'Google voice': 'Google voice',
'Pager': 'Téléavertisseur',
'Home email': 'Adresse mail personnelle',
'Work email': 'Adresse mail professionnelle',
'Other email': 'Autre adresse mail',
'It looks like you are using an outdated version of this script': 'Il semble que vous utilisez une ancienne version de ce script',
'You can find the latest one here': 'Vous pouvez trouver la dernière ici',
},
'he': {
'Age': 'גיל',
'Years': 'שנים',
'Events': 'אירועים',
'Birthdays today': 'ימי הולדת היום',
'Birthdays tomorrow': 'ימי הולדת מחר',
'Birthdays in {0} days': 'ימי הולדת בעוד {0} ימים',
'Anniversaries today': 'ימי נישואין היום',
'Anniversaries tomorrow': 'ימי נישואין מחר',
'Anniversaries in {0} days': 'ימי נישואין בעוד {0} ימים',
'Custom events today': 'אירועים מיוחדים היום',
'Custom events tomorrow': 'אירועים מיוחדים מחר',
'Custom events in {0} days': 'אירועים מיוחדים בעוד {0} ימים',
'Hey! Don\'t forget these events': 'היי, אל תשכח את האירועים האלה!',
'version': 'גרסה',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'טלפון נייד',
'Work phone': 'טלפון בעבודה',
'Home phone': 'טלפון בבית',
'Main phone': 'מספר טלפון ראשי',
'Other phone': 'טלפון אחר',
'Home fax': 'פקס בבית',
'Work fax': 'פקס בעבודה',
'Google voice': 'Google voice',
'Pager': 'זימונית',
'Home email': 'מייל אישי',
'Work email': 'מייל בעבודה',
'Other email': 'כתובת מייל אחרת',
'It looks like you are using an outdated version of this script': 'נראה שאתה משתמש בגרסה לא עדכנית של התוכנה',
'You can find the latest one here': 'אתה יכול להוריד את הגרסה העדכנית כאן',
},
'id': {
'Age': 'Usia',
'Years': 'Tahun',
'Events': 'Acara',
'Birthdays today': 'Ulang tahun hari ini',
'Birthdays tomorrow': 'Ulang tahun besok',
'Birthdays in {0} days': 'Ulang tahun dalam {0} hari mendatang',
'Anniversaries today': 'Hari jadi hari ini',
'Anniversaries tomorrow': 'Hari jadi besok',
'Anniversaries in {0} days': 'Hari jadi dalam {0} hari mendatang',
'Custom events today': 'Acara khusus hari ini',
'Custom events tomorrow': 'Acara khusus besok',
'Custom events in {0} days': 'Acara khusus dalam {0} hari mendatang',
'Hey! Don\'t forget these events': 'Hei! Jangan lupa acara ini',
'version': 'versi',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Telp. Selular',
'Work phone': 'Telp. Kantor',
'Home phone': 'Telp. Rumah',
'Main phone': 'Telp. Utama',
'Other phone': 'Telp. Lain',
'Home fax': 'Faks. Rumah',
'Work fax': 'Faks. Kantor',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'Email Rumah',
'Work email': 'Email Kantor',
'Other email': 'Email Lain',
'It looks like you are using an outdated version of this script': 'Sepertinya anda menggunakan versi lama dari skrip ini',
'You can find the latest one here': 'Anda bisa menemukan versi terbaru di sini',
},
'it': {
'Age': 'Età',
'Years': 'Anni',
'Events': 'Eventi',
'Birthdays today': 'Compleanni oggi',
'Birthdays tomorrow': 'Compleanni domani',
'Birthdays in {0} days': 'Compleanni fra {0} giorni',
'Anniversaries today': 'Anniversari oggi',
'Anniversaries tomorrow': 'Anniversari domani',
'Anniversaries in {0} days': 'Anniversari fra {0} giorni',
'Custom events today': 'Eventi personalizzati oggi',
'Custom events tomorrow': 'Eventi personalizzati domani',
'Custom events in {0} days': 'Eventi personalizzati fra {0} giorni',
'Hey! Don\'t forget these events': 'Hey! Non dimenticare questi eventi',
'version': 'versione',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Cellulare',
'Work phone': 'Telefono di lavoro',
'Home phone': 'Telefono di casa',
'Main phone': 'Telefono principale',
'Other phone': 'Altro telefono',
'Home fax': 'Fax di casa',
'Work fax': 'Fax di lavoro',
'Google voice': 'Google voice',
'Pager': 'Cercapersone',
'Home email': 'Email di casa',
'Work email': 'Email di lavoro',
'Other email': 'Altra email',
'It looks like you are using an outdated version of this script': 'Sembra che tu stia usando una vecchia versione di questo script',
'You can find the latest one here': 'Puoi trovare l\'ultima qui',
},
'kr': {
'Age': '나이',
'Years': '년도',
'Events': '행사',
'Birthdays today': '오늘 생일',
'Birthdays tomorrow': '내일 생일',
'Birthdays in {0} days': '{0}일 동안 생일',
'Anniversaries today': '오늘 기념일',
'Anniversaries tomorrow': '내일 기념일',
'Anniversaries in {0} days': '{0}일 동안 기념일',
'Custom events today': '오늘 지정된 행사',
'Custom events tomorrow': '내일 지정된 행사',
'Custom events in {0} days': '{0}일 동안 지정된 행사',
'Hey! Don\'t forget these events': '이 행사들을 잊지 마세요!',
'version': '버전',
'dd-MM-yyyy': 'yyyy-MM-dd',
'Mobile phone': '휴대폰',
'Work phone': '직장 전화',
'Home phone': '집 전화',
'Main phone': '대표 전화',
'Other phone': '기타 전화',
'Home fax': '집 팩스',
'Work fax': '직장 팩스',
'Google voice': '구글 보이스',
'Pager': '무선호출기',
'Home email': '집 이메일',
'Work email': '직장 이메일',
'Other email': '기타 이메일',
'It looks like you are using an outdated version of this script': '옛날 버전 스크립트를 사용중인것 같네요',
'You can find the latest one here': '여기에서 최신버전을 찾을 수 있습니다',
},
'lt': {
'Age': 'Amžius',
'Years': 'Metai',
'Events': 'Įvykiai',
'Birthdays today': 'Šiandienos gimtadieniai',
'Birthdays tomorrow': 'Rytojaus gimtadieniai',
'Birthdays in {0} days': 'Gimtadieniai už {0} dienų',
'Anniversaries today': 'Šiandienos jubiliejai',
'Anniversaries tomorrow': 'Rytojaus jubiliejai',
'Anniversaries in {0} days': 'Jubiliejai už {0} dienų',
'Custom events today': 'Priskirti įvykiai šiandien',
'Custom events tomorrow': 'Priskirti įvykiai rytoj',
'Custom events in {0} days': 'Priskirti įvykiai už {0} dienų',
'Hey! Don\'t forget these events': 'Hey! Neužmiršk šių įvykių',
'version': 'versija',
'dd-MM-yyyy': 'yyyy-MM-dd',
'Mobile phone': 'Mobilus telefonas',
'Work phone': 'Darbo telefonas',
'Home phone': 'Namų telefonas',
'Main phone': 'Pagrindinis telefonas',
'Other phone': 'Kitas telefonas',
'Home fax': 'Namų faksas',
'Work fax': 'Darbo faksas',
'Google voice': 'Google voice',
'Pager': 'Peidžeris',
'Home email': 'Namų elektroninis paštas',
'Work email': 'Darbo elektroninis paštas',
'Other email': 'Kitas elektroninis paštas',
'It looks like you are using an outdated version of this script': 'Atrodo, kad jūs naudojate pasenusią šio skripto versiją',
'You can find the latest one here': 'Naujausią galite rasti čia',
},
'nb': {
'Age': 'Alder',
'Years': 'År',
'Events': 'Arrangementer',
'Birthdays today': 'Bursdager idag',
'Birthdays tomorrow': 'Bursdager imorgen',
'Birthdays in {0} days': 'Bursdager om {0} dager',
'Anniversaries today': 'Jubileer idag',
'Anniversaries tomorrow': 'Jubileer imorgen',
'Anniversaries in {0} days': 'Jubileer om {0} dager',
'Custom events today': 'Egendefinerte arrangementer idag',
'Custom events tomorrow': 'Egendefinerte arrangementer imorgen',
'Custom events in {0} days': 'Egendefinerte arrangementer om {0} dager',
'Hey! Don\'t forget these events': 'Hei! Ikke glem disse arrangementene',
'version': 'versjon',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Mobiltelefon',
'Work phone': 'Arbeidstelefon',
'Home phone': 'Hjemmetelefon',
'Main phone': 'Hovedtelefon',
'Other phone': 'Annen telefon',
'Home fax': 'Hjemme faks',
'Work fax': 'Arbeids faks',
'Google voice': 'Google voice',
'Pager': 'Personsøker ',
'Home email': 'Hjemme e-post',
'Work email': 'Arbeids e-post',
'Other email': 'Annen e-post',
'It looks like you are using an outdated version of this script': 'Det ser ut til at du bruker utdatert versjon av dette skriptet',
'You can find the latest one here': 'Du kan finne den nyeste her',
},
'nl': {
'Age': 'Leeftijd',
'Years': 'Jaar',
'Events': 'Gebeurtenissen',
'Birthdays today': 'Verjaardagen vandaag',
'Birthdays tomorrow': 'Verjaardagen morgen',
'Birthdays in {0} days': 'Verjaardagen over {0} dagen',
'Anniversaries today': 'Jubilea vandaag',
'Anniversaries tomorrow': 'Jubilea morgen',
'Anniversaries in {0} days': 'Jubilea over {0} dagen',
'Custom events today': 'Aangepaste gebeurtenissen vandaag',
'Custom events tomorrow': 'Aangepaste gebeurtenissen morgen',
'Custom events in {0} days': 'Aangepaste gebeurtenissen over {0} dagen',
'Hey! Don\'t forget these events': 'Hey! Vergeet volgende gebeurtenissen niet',
'version': 'versie',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Mobiel',
'Work phone': 'Tel. werk',
'Home phone': 'Tel. thuis',
'Main phone': 'Algemeen telefoonnummer',
'Other phone': 'Ander telefoonnummer',
'Home fax': 'Fax thuis',
'Work fax': 'Fax werk',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'E-mail thuis',
'Work email': 'E-mail werk',
'Other email': 'Ander e-mailadres',
'It looks like you are using an outdated version of this script': 'Het lijkt erop alsof je een verouderde versie van dit script gebruikt.',
'You can find the latest one here': 'Je kunt de laatste versie hier vinden',
},
'no': {
'Age': 'Alder',
'Years': 'År',
'Events': 'Arrangementer',
'Birthdays today': 'Bursdager i dag',
'Birthdays tomorrow': 'Bursdager i morgen',
'Birthdays in {0} days': 'Bursdager om {0} dager',
'Anniversaries today': 'Jubileum i dag',
'Anniversaries tomorrow': 'Jubileum i morgen',
'Anniversaries in {0} days': 'Jubileum om {0} dager',
'Custom events today': 'Egendefinerte hendelser i dag',
'Custom events tomorrow': 'Egendefinerte hendelser i morgen',
'Custom events in {0} days': 'Egendefinerte hendelser om {0} dager',
'Hey! Don\'t forget these events': 'Hei! Ikke glem disse arrangementene',
'version': 'versjon',
'dd-MM-yyyy': 'dd.MM.yyyy',
'Mobile phone': 'Mobil',
'Work phone': 'Jobbtelefon',
'Home phone': 'Hjemtelefon',
'Main phone': 'Hovedtelefon',
'Other phone': 'Annen telefon',
'Home fax': 'Hjemmefax',
'Work fax': 'Jobbfax',
'Google voice': 'Google voice',
'Pager': 'Personsøker',
'Home email': 'Hjem e-post',
'Work email': 'Jobb e-post',
'Other email': 'Annen e-post',
'It looks like you are using an outdated version of this script': 'Det ser ut som du bruker en gammel versjon av dette scriptet',
'You can find the latest one here': 'Du kan finne nyeste versjon her',
},
'pl': {
'Age': 'Wiek',
'Years': 'Lat(a)',
'Events': 'Wydarzenia',
'Birthdays today': 'Urodziny dzisiaj',
'Birthdays tomorrow': 'Urodziny jutro',
'Birthdays in {0} days': 'Urodziny za {0} dni',
'Anniversaries today': 'Rocznice dzisiaj',
'Anniversaries tomorrow': 'Rocznice jutro',
'Anniversaries in {0} days': 'Rocznice za {0} dni',
'Custom events today': 'Inne wydarzenia dzisiaj',
'Custom events tomorrow': 'Inne wydarzenia jutro',
'Custom events in {0} days': 'Inne wydarzenia za {0} dni',
'Hey! Don\'t forget these events': 'Hej! Nie zapomnij o tych datach',
'version': 'wersja',
'dd-MM-yyyy': 'dd.MM.yyyy',
'Mobile phone': 'Telefon',
'Work phone': 'Telefon (służbowy)',
'Home phone': 'Telefon (stacjonarny)',
'Main phone': 'Telefon (główny)',
'Other phone': 'Inne numery',
'Home fax': 'Fax (domowy)',
'Work fax': 'Fax (służbowy)',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'E-mail (prywatny)',
'Work email': 'E-mail (służbowy)',
'Other email': 'Inne adresy e-mail',
'It looks like you are using an outdated version of this script': 'Wygląda na to, że używasz nieaktualnej wersji skryptu',
'You can find the latest one here': 'Najnowszą możesz znaleźć tutaj', // Using feminime version of 'latest', because it refers to 'version'. There's possibility it won't fit into diffrent context.
},
'pt': {
'Age': 'Idade',
'Years': 'Anos',
'Events': 'Eventos',
'Birthdays today': 'Aniversários hoje',
'Birthdays tomorrow': 'Aniversários amanhã',
'Birthdays in {0} days': 'Aniversários em {0} dias',
'Anniversaries today': 'Aniversários hoje',
'Anniversaries tomorrow': 'Aniversários amanhã',
'Anniversaries in {0} days': 'Aniversários em {0} dias',
'Custom events today': 'Eventos personalizados hoje',
'Custom events tomorrow': 'Eventos personalizados amanhã',
'Custom events in {0} days': 'Eventos personalizados em {0} dias',
'Hey! Don\'t forget these events': 'Hey! Não te esqueças destes eventos',
'version': 'versão',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Número de telemóvel',
'Work phone': 'Número de trabalho',
'Home phone': 'Número de casa',
'Main phone': 'Número principal',
'Other phone': 'Outro número',
'Home fax': 'Fax de casa',
'Work fax': 'Fax de trabalho',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'Email de casa',
'Work email': 'Email de trabalho',
'Other email': 'Outro email',
'It looks like you are using an outdated version of this script': 'Parece que tens uma versão desatualizada deste script',
'You can find the latest one here': 'Podes encontrar a última versão aqui',
},
'pt-BR': {
'Age': 'Idade',
'Years': 'Anos',
'Events': 'Eventos',
'Birthdays today': 'Aniversários hoje',
'Birthdays tomorrow': 'Aniversários amanhã',
'Birthdays in {0} days': 'Aniversários em {0} dias',
'Anniversaries today': 'Aniversários hoje',
'Anniversaries tomorrow': 'Aniversários amanhã',
'Anniversaries in {0} days': 'Aniversários em {0} dias',
'Custom events today': 'Eventos personalizados hoje',
'Custom events tomorrow': 'Eventos personalizados amanhã',
'Custom events in {0} days': 'Eventos personalizados em {0} dias',
'Hey! Don\'t forget these events': 'Ei! Não se esqueça destes eventos',
'version': 'versão',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Celular',
'Work phone': 'Telefone de trabalho',
'Home phone': 'Telefone residencial',
'Main phone': 'Telefone principal',
'Other phone': 'Outro telefone',
'Home fax': 'Fax residencial',
'Work fax': 'Fax profissional',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'Email residencial',
'Work email': 'Email profissional',
'Other email': 'Outro email',
'It looks like you are using an outdated version of this script': 'Parece que você está usando uma versão desatualizada deste script',
'You can find the latest one here': 'Você pode encontrar a última versão aqui',
},
'ru': {
'Age': 'Возраст',
'Years': 'Лет',
'Events': 'События',
'Birthdays today': 'Дни рождения сегодня',
'Birthdays tomorrow': 'Дни рождения завтра',
'Birthdays in {0} days': 'Дни рождения через {0} дней',
'Anniversaries today': 'Юбилей сегодня',
'Anniversaries tomorrow': 'Юбилей завтра',
'Anniversaries in {0} days': 'Юбилей через {0} дней',
'Custom events today': 'Специальное событие сегодня',
'Custom events tomorrow': 'Специальное событие завтра',
'Custom events in {0} days': 'Специальное событие через {0} дней',
'Hey! Don\'t forget these events': 'Эй! Не забудь об этих мероприятиях',
'version': 'версия',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Мобильный телефон',
'Work phone': 'Рабочий телефон',
'Home phone': 'Домашний телефон',
'Main phone': 'Основной телефон',
'Other phone': 'Другой телефон',
'Home fax': 'Домашний факс',
'Work fax': 'Рабочий факс',
'Google voice': 'Google voice',
'Pager': 'Пейджер',
'Home email': 'Домашний email',
'Work email': 'Рабочий email',
'Other email': 'Другой email',
'It looks like you are using an outdated version of this script': 'Похоже вы используете устаревшую версию этой программы',
'You can find the latest one here': 'Вы можете найти последнюю версию здесь',
},
'th': {
'Age': 'อายุ',
'Years': 'ปี',
'Events': 'อีเวนท์',
'Birthdays today': 'วันเกิดวันนี้',
'Birthdays tomorrow': 'วันเกิดพรุ่งนี้',
'Birthdays in {0} days': 'วันเกิดในอีก {0} วัน',
'Anniversaries today': 'วันครบรอบวันนี้',
'Anniversaries tomorrow': 'วันครบรอบพรุ่งนี้',
'Anniversaries in {0} days': 'วันครบรอบในอีก {0} วัน',
'Custom events today': 'อีเวนท์ที่กำหนดเองวันนี้',
'Custom events tomorrow': 'อีเวนท์ที่กำหนดเองวันพรุ่งนี้',
'Custom events in {0} days': 'อีเวนท์ที่กำหนดเองในอีก {0} วัน',
'Hey! Don\'t forget these events': 'เฮ้! อย่าลืมอีเวน์เหล่านี้ล่ะ',
'version': 'เวอร์ชั่น',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'เบอร์โทรศัพท์',
'Work phone': 'เบอร์โทรศัพท์ที่ทำงาน',
'Home phone': 'เบอร์โทรศัพท์บ้าน',
'Main phone': 'เบอร์โทรศัพท์หลัก',
'Other phone': 'เบอร์โทรศัพท์อื่นๆ',
'Home fax': 'แฟกซ์บ้าน',
'Work fax': 'แฟกซ์ที่ทำงาน',
'Google voice': 'Google voice',
'Pager': 'เพจเจอร์',
'Home email': 'อีเมลบ้าน',
'Work email': 'อีเมลที่ทำงาน',
'Other email': 'อีเมลอื่นๆ',
'It looks like you are using an outdated version of this script': 'ดูเหมือนว่าคุณกำลังใช้เวอร์ชั่นเก่าสำหรับสคริปท์นี้',
'You can find the latest one here': 'คุณสามารถหาเวอร์ชั่นใหม่ได้ที่นี่',
},
'tr': {
'Age': 'Yaş',
'Years': 'Yıl',
'Events': 'Etkinlikler',
'Birthdays today': 'Bugünkü doğum günleri',
'Birthdays tomorrow': 'Yarınki doğum günleri',
'Birthdays in {0} days': '{0} gün içindeki doğum günleri',
'Anniversaries today': 'Bugünkü yıldönümleri',
'Anniversaries tomorrow': 'Yarınki yıldönümleri',
'Anniversaries in {0} days': '{0} gün içindeki yıldönümleri',
'Custom events today': 'Bugünkü özel etkinlikler',
'Custom events tomorrow': 'Yarınki özel etkinlikler',
'Custom events in {0} days': '{0} gün içindeki özel etkinlikler',
'Hey! Don\'t forget these events': 'Hey! Bu etkinlikleri unutma!',
'version': 'sürüm',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Cep telefonu',
'Work phone': 'İş telefonu',
'Home phone': 'Ev telefonu',
'Main phone': 'Birincil telefon',
'Other phone': 'Diğer telefon',
'Home fax': 'Fax (ev)',
'Work fax': 'Fax (iş)',
'Google voice': 'Google voice',
'Pager': 'Çağrı cihazı',
'Home email': 'E-mail (ev)',
'Work email': 'E-mail (iş)',
'Other email': 'Email (diğer)',
'It looks like you are using an outdated version of this script': 'Görünüşe göre bu betiğin eski bir sürümünü kullanıyorsunuz',
'You can find the latest one here': 'En son sürümü burada bulabilirsiniz',
},
/* To add a language:
'[lang-code]': {
'[first phrase]': '[translation here]',
'[second phrase]': '[translation here]',
...
// Note: 'dd-MM-yyyy' should NOT be translated (especially in a different alphabet). You just need to reorder
// dd (day) MM (month) and yyyy (year) in the order your language usually represents dates.
// Examples:
// USA: (month/day/year) should be 'MM-dd-yyyy'
// Italy: (day/month/year) should be 'dd-MM-yyyy'
}
*/
};
// HELPER FUNCTIONS
/**
* Get the translation of a string.
*
* If the language or the chosen string is invalid return the string itself.
*
* @param {!string} str - String to attempt translation for.
* @returns {string}
*/
function _ (str) {
return i18n[settings.user.lang][str] || str;
}
/**
* Return whether an item exists as a value in an object.
*
* @param {!any} item - The item to search the values for.
* @param {!object} arr - The object to search in.
* @returns {boolean} - Whether the item exists as a value in the object.
*/
function isIn (item, arr) {
/*
* Must use "indexOf" with values rather than "in" with keys, because e.g.
* "null" and "undefined" can't be keys. No need for "typeof undefined"
* syntax for comparing "undefined" as we are not targeting browsers, let
* alone old ones.
*/
return arr.indexOf(item) !== -1;
}
/**
* Replace an event label string with its lowercased version, without
* changing the prefix 'CUSTOM:' if it is present.
*
* @param {!string} label - The label to be lowercased.
* @returns {string}
*/
function eventLabelToLowerCase (label) {
if (label.indexOf('CUSTOM:') === 0) {
return label.slice(0, 7) + label.slice(7).toLocaleLowerCase();
} else {
return label.toLocaleLowerCase();
}
}
/**
* Replace a `Field.Label` object with its "beautified" text representation.
*
* @param {?string} label - The internal label to transform to readable form.
* @returns {string}
*/
function beautifyLabel (label) {
switch (String(label)) {
/*
* Phone labels:
*/
case 'MOBILE_PHONE':
case 'WORK_PHONE':
case 'HOME_PHONE':
case 'MAIN_PHONE':
case 'HOME_FAX':
case 'WORK_FAX':
case 'GOOGLE_VOICE':
case 'PAGER':
case 'OTHER_PHONE': // Fake label for output.
/*
* (falls through)
* Email labels:
*/
case 'HOME_EMAIL':
case 'WORK_EMAIL':
case 'OTHER_EMAIL': // Fake label for output.
/*
* (falls through)
* Event labels:
*/
case 'OTHER':
case 'BIRTHDAY':
case 'ANNIVERSARY':
return _(label[0] + label.slice(1).replaceAll('_', ' ').toLocaleLowerCase());
/*
* Custom labels:
*/
case 'CUSTOM:' + label.slice('CUSTOM:'.length):
// Don't interfere with the upper/lower-casing for this one though
return label.slice('CUSTOM:'.length);
default:
return String(label);
}
}
/**
* Replace HTML special characters in a string with their HTML-escaped equivalent.
*
* @param {?string} str - The string to escape.
* @returns {string} - The escaped string.
*/
function htmlEscape (str) {
str = str || '';
return str
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\//g, '/');
}
/**
* Check if the script is not updated to the latest version.
*
* The latest version number is obtained from the GitHub API and compared with the
* script's one.
*
* If there is any problem retrieving the latest version number false is returned.
*
* @returns {boolean} - True if the script version is lower than the latest released one, false otherwise.
*/
function isRunningOutdatedVersion () {
var response, latestVersion, fetchTries;
// Retrieve the last version info.
fetchTries = 2;
try {
response = cache.retrieve(baseGitHubApiURL + 'releases/latest', fetchTries);
if (response === null) {
throw new Error('');
}
} catch (err) {
log.add('Unable to get the latest version number after ' + fetchTries + ' tries', Priority.WARNING);
return false;
}
// Parse the info for the version number.
try {
response = JSON.parse(response);
if (typeof response !== 'object') {
throw new Error('');
}
} catch (err) {
log.add('Unable to get the latest version number: failed to parse the API response as JSON object', Priority.WARNING);
return false;
}
latestVersion = response.tag_name;
if (typeof latestVersion !== 'string' || latestVersion.length === 0) {
log.add('Unable to get the latest version number: there was no valid tag_name string in the API response.', Priority.WARNING);
return false;
}
if (latestVersion.substring(0, 1) === 'v') {
latestVersion = latestVersion.substring(1);
}
// Compare the versions.
try {
return (version).compare(new SimplifiedSemanticVersion(latestVersion)) === -1;
} catch (err) {
log.add(err.message, Priority.WARNING);
return false;
}
}
/**
* Get a `ContactsApp.Month`'s numerical representation.
*
* @param {!Object} month
* @returns {number} - 0-11 for each month, -1 for wrong values.
*/
function monthToInt (month) {
var i;
var months = [
ContactsApp.Month.JANUARY,
ContactsApp.Month.FEBRUARY,
ContactsApp.Month.MARCH,
ContactsApp.Month.APRIL,
ContactsApp.Month.MAY,
ContactsApp.Month.JUNE,
ContactsApp.Month.JULY,
ContactsApp.Month.AUGUST,
ContactsApp.Month.SEPTEMBER,
ContactsApp.Month.OCTOBER,
ContactsApp.Month.NOVEMBER,
ContactsApp.Month.DECEMBER
];
for (i = 0; i < 12; i++) {
if (month === months[i]) {
return i;
}
}
return -1;
}
/**
* Return an array of strings with duplicate strings removed.
*
* @param {!string[]} arr - The array containing the duplicates.
* @returns {string[]} - The array without duplicates.
*/
function uniqueStrings (arr) {
var seen = {};
return arr.filter(function (str) {
return seen.hasOwnProperty(str) ? false : (seen[str] = true);
});
}
// MAIN FUNCTIONS
/**
* Validate the settings, logging all problems found and stopping the script
* execution if a FATAL_ERROR is thrown.
*/
function validateSettings () {
var setting, calendarId;
log.add('validateSettings() running.');
setting = settings.user.googleEmail;
if (!setting || !/^(?!YOUREMAILHERE)\S+@\S+\.\S+$/.test(setting)) {
log.add('Your user.googleEmail setting is invalid!', Priority.FATAL_ERROR);
}
setting = settings.user.notificationEmail;
if (!setting || !/^(?!YOUREMEAILHERE)\S+@(?!example)\S+\.\S+$/.test(setting)) {
log.add('Your user.notificationEmail setting is invalid!', Priority.FATAL_ERROR);
}
// Hardcode the calendar ID for the address book
settings.user.calendarId = "addressbook#contacts@group.v.calendar.google.com";
try {
if (Calendar.Calendars.get(settings.user.calendarId) === null) {
throw new Error('');
}
} catch (err) {
log.add('The birthday calendar failed to load!', Priority.FATAL_ERROR);
}
// emailSenderName has no restrictions.
// lang has no restrictions.
setting = settings.notifications.hour;
if (!Number.isInteger(setting) || setting < 0 || setting >= 24) {
log.add('Your notifications.hour setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.hour = 6;
}
// It would be quite difficult to test the timeZone.
setting = settings.notifications.anticipateDays;
if (
setting.constructor !== Array ||
setting.filter(function (x) {
return Number.isInteger(x) && x >= 0;
}).length !== setting.length
) {
log.add('Your notifications.anticipateDays setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.anticipateDays = [0, 1, 7];
}
setting = settings.notifications.eventTypes;
if (
typeof setting.BIRTHDAY !== 'boolean' ||
typeof setting.ANNIVERSARY !== 'boolean' ||
typeof setting.CUSTOM !== 'boolean'
) {
log.add('Your notifications.eventTypes setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.eventTypes = {
BIRTHDAY: true,
ANNIVERSARY: false,
CUSTOM: false
};
}
setting = settings.notifications.maxEmailsCount;
if (!Number.isInteger(setting) || setting < -1) {
log.add('Your notifications.maxEmailsCount setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.maxEmailsCount = -1;
}
setting = settings.notifications.maxPhonesCount;
if (!Number.isInteger(setting) || setting < -1) {
log.add('Your notifications.maxPhonesCount setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.maxPhonesCount = -1;
}
setting = settings.notifications.indentSize;
if (!Number.isInteger(setting) || setting <= 0) {
log.add('Your notifications.indentSize setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.indentSize = 4;
}
if (typeof settings.notifications.compactGrouping !== 'boolean') {
log.add('Your notifications.compactGrouping setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.compactGrouping = true;
}
setting = settings.debug.log.filterLevel;
if (typeof Priority[setting] !== 'object') {
log.add('Your debug.log.filterLevel setting is invalid!', Priority.ERROR);
// Default value.
settings.debug.log.filterLevel = 'INFO';
}
setting = settings.debug.log.sendTrigger;
if (typeof Priority[setting] !== 'object') {
log.add('Your debug.log.sendTrigger setting is invalid!', Priority.ERROR);
// Default value.
settings.debug.log.sendTrigger = 'ERROR';
}
setting = settings.debug.testDate;
if (setting.constructor !== Date) {
log.add('Your debug.log.testDate setting is invalid!', Priority.ERROR);
// Default value.
settings.debug.log.testDate = new Date();
}
}
/**
* Returns an array with the events happening in the calendar with
* ID `calendarId` on date `eventDate`.
*
* @param {!Number} year - The full year of the date of the event.
* @param {!Number} month - The number representing the month of the date of the event, starting from 0.
* @param {!Number} day - The number of the day of the date of the event.
* @param {!string} calendarId - The id of the calendar from which events are collected.
* @returns {Object[]} - A list of rawEvent Objects.
*/
function getEventsOnDate (year, month, day, calendarId) {
var eventCalendar, eventDate, startDate, endDate, events;
// Verify the existence of the events calendar.
try {
eventCalendar = Calendar.Calendars.get(calendarId);
if (eventCalendar === null) {
throw new Error('');
}
} catch (err) {
log.add('The calendar with ID "' + calendarId + '" is not accessible: check your calendarId value!', Priority.FATAL_ERROR);
}
eventDate = dateWithTimezone(year, month, day, 0, 0, 0, eventCalendar.timeZone);
// Query the events calendar for events on the specified date.
try {
// Look for events from 00:00:00 to 00:01:00 of the specified day.
startDate = Utilities.formatDate(eventDate, eventCalendar.timeZone, 'yyyy-MM-dd\'T\'HH:mm:ssXXX');
endDate = Utilities.formatDate(new Date(eventDate.getTime() + 60000), eventCalendar.timeZone, 'yyyy-MM-dd\'T\'HH:mm:ssXXX');
log.add('Looking for contacts events on ' + eventDate + ' (' + startDate + ' / ' + endDate + ')', Priority.INFO);
} catch (err) {
log.add(err.message, Priority.FATAL_ERROR);
}
events = Calendar.Events.list(
calendarId,
{
singleEvents: true,
timeMin: startDate,
timeMax: endDate
}
).items;
log.add('Found: ' + events.length);
return events;
}
/**
* Generate the content of an email to the user containing a list of the events
* of his/her contacts scheduled on a given date.
*
* @param {?Date} forceDate - If this value is not null it's used as 'now'.
* @returns {Object.<string,any>} - The content of the email.
*/
function generateEmailNotification (forceDate) {
var now, events, contactList, subjectPrefix, subjectBuilder, subject,
bodyPrefix, bodySuffixes, bodyBuilder, body, htmlBody, htmlBodyBuilder,
contactIter, runningOutdatedVersion, maxSubjectLength, ellipsis;
log.add('generateEmailNotification() running.', Priority.INFO);
now = forceDate || new Date();
log.add('Date used: ' + now, Priority.INFO);
events = [].concat.apply(
[],
settings.notifications.anticipateDays
.map(function (days) {
var date = now.addDays(days);
return getEventsOnDate(
parseInt(Utilities.formatDate(date, settings.notifications.timeZone, 'yyyy'), 10),
parseInt(Utilities.formatDate(date, settings.notifications.timeZone, 'MM'), 10) - 1,
parseInt(Utilities.formatDate(date, settings.notifications.timeZone, 'dd'), 10),
settings.user.calendarId
);
})
);
if (events.length === 0) {
log.add('No events found. Exiting now.', Priority.INFO);
return null;
}
log.add('Found ' + events.length + ' events.', Priority.INFO);
contactList = [];
/*
* Build a list of contacts (with complete information) from the event list.
*
* **Note:** multiple events can refer to the same contact.
*/
events.forEach(function (rawEvent) {
var eventData;
if (!rawEvent.gadget || !rawEvent.gadget.preferences) {
log.add(rawEvent, Priority.INFO);
log.add('The structure of this event cannot be parsed.', Priority.FATAL_ERROR);
}
eventData = rawEvent.gadget.preferences;
// Look if the contact of this event is already in the contact list.
for (contactIter = 0; contactIter < contactList.length; contactIter++) {
if (
eventData['goo.contactsContactId'] !== null &&
eventData['goo.contactsContactId'] === contactList[contactIter].contactId
) {
// FOUND!
// Integrate this event information into the contact.
break;
}
}
if (contactIter === contactList.length) {
// NOT FOUND!
// Add a new contact to the contact list and store all the info in that contact.
contactList.push(new MergedContact());
}
contactList[contactIter].getInfoFromRawEvent(rawEvent);
});
// Iterate by reverse index to allow safe splicing from within the loop
contactIter = contactList.length;
while (contactIter--) {
if (!contactList[contactIter].events || !contactList[contactIter].events.length) {
contactList.splice(contactIter, 1);
}
}
if (contactList.length === 0) {
log.add('No contacts with valid events found. Exiting now.', Priority.INFO);
return null;
}
log.add('Found ' + contactList.length + ' contacts with matching events.', Priority.INFO);
// Give a default profile image to the contacts without one.
contactList.forEach(function (contact) {
contact.data.merge(new ContactDataDC(
null, // Full name.
null, // Nickname.
defaultProfileImageURL // Profile photo URL.
));
});
// Start building the email notification text.
subjectPrefix = _('Events') + ': ';
subjectBuilder = contactList.map(function (contact) { return contact.data.getProp('fullName'); });
bodyPrefix = _('Hey! Don\'t forget these events') + ':';
bodySuffixes = [
_('Google Contacts Events Notifier') + ' (' + _('version') + ' ' + version.toString() + ')',
_('It looks like you are using an outdated version of this script') + '.',
_('You can find the latest one here')
];
inlineImages = {};
// The email is built both with plain text and HTML text.
bodyBuilder = [];
htmlBodyBuilder = [];
settings.notifications.anticipateDays
.forEach(function (daysInterval) {
var date, formattedDate;
date = now.addDays(daysInterval);
formattedDate = Utilities.formatDate(date, settings.notifications.timeZone, _('dd-MM-yyyy'));
eventTypes.forEach(function (eventType) {
var plaintextLines, htmlLines, whenIsIt;
// Get all the matching 'eventType' events.
log.add('Checking ' + eventTypeNamePlural[eventType] + ' on ' + formattedDate, Priority.INFO);
plaintextLines = contactList
.map(function (contact) { return contact.getLines(eventType, date, NotificationType.PLAIN_TEXT); })
.filter(function (lines) { return lines.length > 0; });
htmlLines = contactList
.map(function (contact) { return contact.getLines(eventType, date, NotificationType.HTML); })
.filter(function (lines) { return lines.length > 0; });
if (plaintextLines.length === 0 || htmlLines.length === 0) {
log.add('No events found on this date.', Priority.INFO);
return;
}
log.add('Found ' + plaintextLines.length + ' ' + eventTypeNamePlural[eventType], Priority.INFO);
// Build the headers of 'eventType' event grouping by date.
bodyBuilder.push('\n * ');
htmlBodyBuilder.push('<dt style="margin-left:0.8em;font-style:italic">');
whenIsIt = eventTypeNamePlural[eventType].charAt(0).toUpperCase() + eventTypeNamePlural[eventType].slice(1);
switch (daysInterval) {
case 0:
whenIsIt += ' today';
break;
case 1:
whenIsIt += ' tomorrow';
break;
default:
whenIsIt += ' in {0} days';
}
whenIsIt = _(whenIsIt).format(daysInterval) + ' (' + formattedDate + ')';
bodyBuilder.push(whenIsIt, ':\n');
plaintextLines.forEach(function (line) { bodyBuilder.extend(line); });
htmlBodyBuilder.push(whenIsIt, '</dt><dd style="margin-left:0.4em;padding-left:0"><ul style="list-style:none;margin-left:0;padding-left:0;">');
htmlLines.forEach(function (line) { htmlBodyBuilder.extend(line); });
htmlBodyBuilder.push('</ul></dd>');
});
});
if (bodyBuilder.length === 0) {
// If there is no email to send
return null;
} else {
// If there is an email to send build the content...
log.add('Building the email notification.', Priority.INFO);
runningOutdatedVersion = isRunningOutdatedVersion();
subject = subjectPrefix + subjectBuilder.join(' - ');
// An error is thrown if the subject of the email is longer than 250 characters.
maxSubjectLength = 250;
ellipsis = '...';
if (subject.length > maxSubjectLength) {
subject = subject.substr(0, maxSubjectLength - ellipsis.length) + ellipsis;
}
body = [bodyPrefix, '\n']
.concat(bodyBuilder)
.concat(['\n\n ', bodySuffixes[0], '\n '])
.concat('\n', runningOutdatedVersion ? [bodySuffixes[1], ' ', bodySuffixes[2], ':\n', baseGitHubProjectURL + 'releases/latest', '\n '] : [])
.join('');
htmlBody = ['<h3>', htmlEscape(bodyPrefix), '</h3><dl>']
.concat(htmlBodyBuilder)
.concat(['</dl><hr/><p style="text-align:center;font-size:smaller"><a href="' + baseGitHubProjectURL + '">', htmlEscape(bodySuffixes[0]), '</a>'])
.concat(runningOutdatedVersion ? ['<br/><br/><b>', htmlEscape(bodySuffixes[1]), ' <a href="', baseGitHubProjectURL, 'releases/latest', '">', htmlEscape(bodySuffixes[2]), '</a>.</b></p>'] : ['</p>'])
.join('');
// ...and return it.
return {
'subject': subject,
'body': body,
'htmlBody': htmlBody,
'inlineImages': inlineImages
};
}
}
/**
* <div style="clear:both"></div>
* Send an email notification to the user containing a list of the events
* of his/her contacts scheduled for the next days.
*
* @param {?Date} forceDate - If this value is not null it's used as 'now'.
*/
function main (forceDate) {
log.add('main() running.', Priority.INFO);
validateSettings();
var emailData = generateEmailNotification(forceDate);
// If generateEmailNotification returned mail content send it.
if (emailData !== null) {
log.add('Sending email...', Priority.INFO);
MailApp.sendEmail({
to: settings.user.notificationEmail,
subject: emailData.subject,
body: emailData.body,
htmlBody: emailData.htmlBody,
inlineImages: emailData.inlineImages,
name: settings.user.emailSenderName
});
log.add('Email sent.', Priority.INFO);
}
// Send the log if the debug options say so.
log.sendEmail(settings.user.notificationEmail, settings.user.emailSenderName);
}
/**
* Execute the `main()` function without forcing any date as "now".
*/
function normal () { // eslint-disable-line no-unused-vars
log.add('normal() running.', Priority.INFO);
main(null);
}
/**
* Execute the `main()` function forcing a given date (`settings.debug.testDate`) as "now".
*/
function test () { // eslint-disable-line no-unused-vars
log.add('test() running.', Priority.INFO);
main(settings.debug.testDate);
}
// NOTIFICATION SERVICE FUNCTIONS
/**
* Start the notification service.
*/
function notifStart () { // eslint-disable-line no-unused-vars
validateSettings();
// Delete old triggers.
notifStop();
// Add a new trigger.
try {
ScriptApp.newTrigger('normal')
.timeBased()
.atHour(settings.notifications.hour)
.everyDays(1)
.inTimezone(settings.notifications.timeZone)
.create();
} catch (err) {
log.add('Failed to start the notification service: make sure that settings.notifications.timeZone is a valid value.', Priority.FATAL_ERROR);
}
log.add('Notification service started.', Priority.INFO);
}
/**
* Stop the notification service.
*/
function notifStop () {
var triggers;
// Delete all the triggers.
triggers = ScriptApp.getProjectTriggers();
for (var i = 0; i < triggers.length; i++) {
ScriptApp.deleteTrigger(triggers[i]);
}
log.add('Notification service stopped.', Priority.INFO);
}
/**
* Check if notification service is running.
*/
function notifStatus () { // eslint-disable-line no-unused-vars
var toLog = 'Notifications are ';
if (ScriptApp.getProjectTriggers().length < 1) {
toLog += 'not ';
}
toLog += 'running.';
log.add(toLog);
log.sendEmail(settings.user.notificationEmail, settings.user.emailSenderName);
}
/**
* Generate a date with a given timezone id.
*
* @param {!Number} year - Full year of the date.
* @param {!Number} month - Month of the date, starting from 0.
* @param {!Number} day - Day of the date, starting from 1.
* @param {!Number} hour - Hour of the date.
* @param {!Number} minute - Minute of the date.
* @param {!Number} second - Second of the date,
* @param {String} timezoneId - A valid IANA timezone identifier.
*
* @returns {Date} - The date corresponding to the input.
*/
function dateWithTimezone (year, month, day, hour, minute, second, timezoneId) {
var date, offset;
// Generate the date as in the UTC0 timezone.
date = new Date(Date.UTC(year, month, day, hour, minute, second));
// Calculate the offset for the given timezone.
offset = Utilities.formatDate(date, timezoneId, 'Z');
// Evaluate the offset (in minutes).
offset = (offset[0] === '-' ? -1 : +1) * (parseInt(offset[1] + offset[2], 10) * 60 + parseInt(offset[3] + offset[4], 10));
// Apply the offse to the UTC date to get the correct date.
date = new Date(date.getTime() - offset * 60000);
return date;
}
================================================
FILE: docs/git-guide.md
================================================
# Git mini-tutorial
Before starting the tutorial, it is important to clarify definitions of the
following for beginners:
- git commandline tool
- for running "git commands"
- Git SCM (source code management) format
- git repositories are directories of this format
- Github
- projects are hosted at this publicly accessible collaboration platform which
is built around Git repositories
This is a "just the steps" mini-tutorial. It is aimed at first-time users of
Git, or relatively new users wanting to know the steps this project uses in the
context of Github. If any step is confusing it should be easy to find details &
explanations by searching Github documentation, or googling the command and
especially looking at stackoverflow answers that come up for it. The
instructions below are for the example-case of a first-time contributor adding a
language to the translation-table.
Text with preceding `##` is either for you to replace, for example:
```sh
a_variable=## Replace this with your favourite food
```
becomes:
```sh
a_variable=banana
```
or instructions to follow, for example:
```sh
echo "silly command"
## Repeat the above 3 times
```
becomes:
```sh
echo "silly command"
echo "silly command"
echo "silly command"
```
1. Ensure you have the [git][Git] commandline tool installed and usable in a
shell. If on Windows and you haven't already installed a "POSIX compatible"
shell ("command line"), the simplest thing is to install
[the bundled git package][Git windows bundle] which includes its own shell.
2. In your browser, logged into Github, in the top-right corner of
[this repo][Project main page], click the `Fork` icon which will create your
own fork.
3. In the shell do the following. Setting the variables at the beginning is just
to make this tutorial more readable, you can obviously enter the text
directly each time instead of using variables if you prefer:
```sh
common_dir=## Replace this with a common directory to hold your repositories
user=## Replace this with your github username
lang=## Replace this with your translation language
mkdir -p "${common_dir}"
cd "${common_dir}"
```
4. In the shell clone your fork to a local directory. For the simpler https
method (but which requires you to enter your password whenever you do remote
actions), do:
```sh
git clone https://github.com/${user}/GoogleContactsEventsNotifier.git
```
If you **optionally** want to be more advanced and use ssh instead of https
then do the documented steps for
[adding your ssh public-key to your github account][Add ssh key] if you
haven't already, and then do:
```sh
git clone git@github.com:${user}/GoogleContactsEventsNotifier.git
```
5. In the shell do the following to create the new branch with your changes. You
will need to know which existing upstream branch it should be based on.
Although basing it on the upstream `master` branch is usually what you want,
sometimes you may need to base it on an "upstream feature/fix branch" which
the maintainers will later merge to their `master` branch (if in doubt ask
the maintainers):
```sh
upstream_branch=## Replace this with the upstream branch
cd GoogleContactsEventsNotifier
git checkout -b ${lang}-translations ${upstream_branch}
## Edit code.gs in text editor, find the commented text at the bottom of the
## translation-table, create the translations as per the instructions there,
## then save & exit.
git add code.gs
git commit -m "${lang} translations"
git push origin ${lang}-translations
```
6. In your browser, logged into Github, visit your forked repo at
`https://github.com/${user}/GoogleContactsEventsNotifier`, and there will be
a notification about a recently added branch, asking if you wish to open it
as a Pull Request. Do that, remembering that if the Pull Request is based on
an upstream branch other than the default `master`, then you should select
that while opening it.
----
Below are other steps you **might** need to take sometimes. Don't worry though,
if any of them are too intimidating the upstream maintainers can usually make
the changes to your branch for you on request - when they have time! - and you
will still retain "authorship" of your commits:
- If upstream's copy of `${upstream_branch}` has been modified or had new
commits added since you created your commits the maintainers may ask you to
rebase your branch on it to update your PR (so they can merge it cleanly).
This means if you haven't already added the upstream repo as a "remote" you'd
first need to do:
```sh
git remote add upstream https://github.com/GioBonvi/GoogleContactsEventsNotifier.git
```
then either way you'd need to do:
```sh
git checkout ${upstream_branch}
git pull upstream ${upstream_branch}
git checkout ${lang}-translations
git rebase ${upstream_branch}
git push --force origin ${lang}-translations
```
- If the upstream maintainers ask you to fix a commit (e.g. for a typo), if your
PR has only one commit you can do the following:
```sh
git checkout ${lang}-translations
## Edit code.gs in text editor, save & exit.
git add code.gs
git commit --amend
git push --force origin ${lang}-translations
```
If your PR includes more than one commit (usually never, if just
translations for a basic lookup table) then instead of the above you need to
do:
```sh
git checkout ${lang}-translations
git fetch upstream ${upstream_branch}
git rebase -i ${upstream_branch}
## For any commit that needs editing change the beginning of the line from
## "pick" to "edit", then save & exit.
## -- loop starts here --
## Edit code.gs in text editor, save & exit
git add code.gs
git commit --amend
git rebase --continue
## -- loop ends here --
## Do the above loop until the rebase finishes.
git push --force origin ${lang}-translations
```
- If the upstream maintainers ask you to squash several commits into one, then
do:
```sh
git checkout ${lang}-translations
git fetch upstream ${upstream_branch}
git rebase -i ${upstream_branch}
## For any commit which should be included into its previous commit, change
## the beginning of the line from "pick" to "squash" - or to "fixup" if you
## just want to use the first commit-message in the log - then save & exit.
## If you used "squash" you'll be asked to edit the combined commit-message.
git push --force origin ${lang}-translations
```
In the above steps:
- The `git push --force` is because you are rewriting history which is usually a
big no-no for a "real public-facing branch", but this is just a temporary
PR-branch, so no-one should be relying on its history anyway.
- In the unlikely event that you ever have a "merge conflict" during
`git rebase` (either because you've made conflicting changes to your own
earlier commits during `git rebase -i` or if you encounter conflicting changes
during `git rebase` against an upstream branch), then you need to edit
`code.gs` again to cleanup wherever there are lines that look like:
```text
<<<<<<< XXXXX:branch-name
# First conflicting updated line(s)...
=======
# Second conflicting updated line(s)...
>>>>>>> YYYYY:branch-name
```
to replace them with a single correct version (whether replacing with one of
them or a manually edited combination of both) removing the `<<<<<<<`,
`>>>>>>>`, and `=======` lines too, and then do:
```sh
git add code.gs
git rebase --continue
```
This is quite complicated though, so if you ever need to do it you can ask
for help or google for instructions first.
There are **many** more aspects to git for the adventurous, but they are out of
scope for this intro.
[Git]: https://git-scm.com
[Git windows bundle]: https://git-for-windows.github.io
[Project main page]: https://github.com/GioBonvi/GoogleContactsEventsNotifier
[Add ssh key]: https://help.github.com/articles/connecting-to-github-with-ssh
================================================
FILE: docs/install-and-setup.md
================================================
# Installation and setup
Follow these instructions to install and setup the script correctly.
<!-- TOC -->
- [Installation and setup](#installation-and-setup)
- [Enable the calendar](#enable-the-calendar)
- [Create the script](#create-the-script)
- [Customize the script](#customize-the-script)
- [Mandatory customization](#mandatory-customization)
- [Optional customization](#optional-customization)
- [Debugging options](#debugging-options)
- [Developer options](#developer-options)
- [Activate API for the script](#activate-api-for-the-script)
- [Grant rights to the script](#grant-rights-to-the-script)
<!-- /TOC -->
## Enable the calendar
First of all you need to enable your contacts birthday and events calendar in
your Google Calendar (read [this Google help page][Google setup birthday
calendar] to know how to do it).
## Allow sharing data between Google Services
Google has introduced new data sharing policies for citizens of the EU, which requires explicit permission to share data between your contacts and the calendar. Enable it by checking [here](https://myactivity.google.com/linked-services?hl=de&utm_source=google-account&utm_medium=web&continue=https%3A%2F%2Fmyaccount.google.com%2Fdata-and-privacy) the checkbox next to "Contacts".
## Create the script
Copy the whole content of [this file][Main code file].
Open [Google Script][Google scripts website] and login if requested,
click "New project", then paste the code into the page.
[](screenshots/new-script.png)
## Customize the script
Now read carefully the code you've pasted. At the top of the file you will find
some lines you need to modify along with many lines of instructions. Edit the
values as explained by the instructions.
Once you're done editing the variables click `File->Save` in the menu and enter
a name for the script (it doesn't really matter, just name it so that you'll
recognize it in the future).
The customization variables can be categorized in three groups.
### Mandatory customization
These are the first settings you will find: these are variables that you
**must** initialize correctly, otherwise the script will not work at all.
These are the names of the variables:
- `settings.user.googleEmail`
- `settings.user.notificationEmail`
### Optional customization
This second group of settings contains some variables that you could leave as
they are, but you are warmly encouraged to edit them to fit your exact needs.
These are the names of the variables:
- `settings.user.emailSenderName`
- `settings.user.lang`
- `settings.notifications.hour`
- `settings.notifications.timeZone`
- `settings.notifications.anticipateDays`
- `settings.notifications.eventTypes`
- `settings.notifications.maxEmailsCount`
- `settings.notifications.maxPhonesCount`
- `settings.notifications.indentSize`
- `settings.notifications.compactGrouping`
### Debugging options
Variables in this group are used to debug and troubleshoot the script when it
does not work as intended. Generally you should not need to edit these values,
but you may be asked to do so if you submit a help request.
These are the names of the variables:
- `settings.debug.log.filterLevel`
- `settings.debug.log.sendTrigger`
- `settings.debug.testDate`
### Developer options
This list just provides a convenient place for the developers and/or maintainers
to update variables without searching through the code. For normal use you
should never need or want to edit these.
- `settings.developer.version`
- `settings.developer.repoName`
- `settings.developer.gitHubBranch`
## Activate API for the script
Now that the script is saved in your Google Drive folder we need to activate required services for it.
To do so click the "+" next to the Services menu. The Services menu can be found on the left-hand side.
In the popup which will open set "Calendar API" to `enabled` (click the switch
on its row on the right) and press "Okay". Do the same for "Peopleapi".
[](screenshots/add-service.png)
Once you have done this, you should see the two Services "Calendar" and "People" in the Services list.
Next, you need to attach a Google Cloud Platform project to your script.
Open the Settings for your scripts in the far left,
and navigate to the section that says "Google Cloud Platform (GCP) Project",
then click the "Change Project" button.
[](screenshots/gcp-change-project.png)
We're prompted for a project number.
[](screenshots/gcp-enter-number.png)
To get one, follow the instructions in Step 1; that is, open the
[Google Cloud Platform API Dashboard][Google Cloud Platform API Dashboard]
and create a new Google Cloud project
(or, you can use an existing one if you already have one).
[](screenshots/gcloud-create-new-project.png)
Once you have a project either created or selected,
you should then see a project number on the dashboard.
Input it back on the prompt from the Scripts page to link the two together.
[](screenshots/project-number.png)
Once you have done this, go back to the
[Google Cloud Platform API Dashboard][Google Cloud Platform API Dashboard]
and (with your project selected), open the hamburger menu on the left
and scroll through the myriad of services until you find "API & Services".
This takes you to a page which has a button "Enable API's and Services" at the
top; click that one.
[](screenshots/enable-apis.png)
This opens up an enormous list of various API's
Search for "Google Calendar API" in the search box and open it.
Now click `Enable` and close this page.
[](screenshots/calendar-api.png)
That's it for this step.
**Important note**: please double check that you have performed **all** steps
correctly as this seems to be the cause of many reported errors.
## Grant rights to the script
We have given the script access to the resource it needs to work: now the last
step is granting it the rights to access those resources. To do so click on the
menu `Run->notifStart`. You will be prompted to "Review authorizations": do it
and click `Allow` (You can read the full list of the permissions and why they
are required [here][Permissions list]).
During this phase you might be prompted with a "This app isn't verified" error
message: in this case you'll have to click on "Advanced" and click on the link
that will appear to continue with the setup.
From this moment on you will always receive an email before any of your
contacts' birthday (You should have set how many days before at the beginning).
[Main code file]: https://raw.githubusercontent.com/GioBonvi/GoogleContactsEventsNotifier/master/code.gs
[Google Scripts website]: https://script.google.com
[Google setup birthday calendar]: https://support.google.com/calendar/answer/6084659?hl=en
[Permissions list]: ../README.md#permissions-required
[Google Cloud Platform API Dashboard]: https://console.cloud.google.com/apis/dashboard
================================================
FILE: docs/translation-guide.md
================================================
# Translation
Google Contacts Events Notifier can easily be configured so as to use another
language instead of English (which is the default) for the email
notifications.
This guide presents the main points of the translation process.
<!-- TOC -->
- [Translation](#translation)
- [Add a translation](#add-a-translation)
- [Share your translation](#share-your-translation)
- [How to submit a new translation](#how-to-submit-a-new-translation)
- [Translation via git](#translation-via-git)
- [Translation via web](#translation-via-web)
- [Translation via issue](#translation-via-issue)
<!-- /TOC -->
## Add a translation
If you want to add a new translation of the notifications, open your script,
find the line reading `var i18n` and have a look at the structure of the
translation object and at the instructions at the end.
The main idea is to build a little "dictionary" for each language to match the
English string with its translation in the given language.
To add a new language (e.g. Spanish - language code 'es'):
- Find the block of code which represents one existing translation and copy it,
for example:
```javascript
'it': {
'Age': 'Età',
...
'You can find the latest one here': 'Puoi trovare l\'ultima qui',
},
```
- Paste it inside the list of translations respecting the alphabetical order for
the language codes (in this case the new language code will be 'es', so we
will put the new translation between 'de' and 'fr'):
```javascript
'de': {
'Age': 'Alter',
...
'You can find the latest one here': 'Du findest die neuste Version hier',
},
'it': {
'Age': 'Età',
...
'You can find the latest one here': 'Puoi trovare l\'ultima qui',
},
'fr': {
'Age': 'Age',
...
'You can find the latest one here': 'Vous pouvez trouver la dernière version ici',
},
```
- Change the language code and remove all the copied translations on the right
hand side:
```javascript
'de': {
'Age': 'Alter',
...
'You can find the latest one here': 'Du findest die neuste Version hier',
},
'es': {
'Age': '',
...
'You can find the latest one here': '',
},
'fr': {
'Age': 'Age',
...
'You can find the latest one here': 'Vous pouvez trouver la dernière version ici',
},
```
- Proceed to translate every item in the list, leaving the string on the left of
the `:` unchanged and translating the one on the right, like this:
```javascript
'de': {
'Age': 'Alter',
...
'You can find the latest one here': 'Du findest die neuste Version hier',
},
'es': {
'Age': 'Edad',
...
'You can find the latest one here': 'Puedes encontrar la última aquí',
},
'fr': {
'Age': 'Age',
...
'You can find the latest one here': 'Vous pouvez trouver la dernière version ici',
},
```
- These are some things you have to keep in mind to do this correctly:
1. Remember to put a comma at the end of each line except after the open curly
bracket.
2. Do not change the leftmost string.
3. Make sure that the strings are enclosed in a pair of single quotes (`'`).
4. If you need to enter a single quote in the string itself put a backslash
(`\`) before it.
You can find an example above: the string `Puoi trovare l'ultima qui` must
become `'Puoi trovare l\'ultima qui'` when enclosing it between the two
single quotes.
5. Strings such as `'dd-MM-yyyy'` must not be translated or changed into other
alphabets: instead they can be used to change date formats in the language
you are translating into (e.g. a 'en-US' translation would use
`'MM-dd-yyyy'` to display the date as month-day-year).
6. Try to keep the translation as faithful to the original as possible (obviously
keeping context and language rules in mind).
If you have any doubt you can open an [issue][Project issue page] asking
for help.
## Share your translation
Google Contacts Events Notifier is used by various users from around the world;
if you want you can make your translation available to everyone by sharing it
with us: we will be extremely happy to include it in the script in the next
release.
### How to submit a new translation
*Do you know your way around git and GitHub very well?*
Then go to ["Translation via git"][Translation via git].
*You don't know what we are talking about?*
Not a problem: you can follow the ["Translation via web"][Translation via web]
guide.
*You really cannot make your way around this whole "edit, comment and submit"
thing, but still would like to contribute in one way or another?*
We have the right solution for you: the ["Translation via issue"][Translation
via issue].
If you don't know what git and GitHub are, but want to learn more about it you
can read [this guide][git-guide.md] written by a collaborator of this project.
#### Translation via git
Follow this guide only if you are quite familiar with how git and GitHub work.
1. Fork this repository.
2. Clone the forked repository on your computer.
3. Edit the script to include your translation and commit the changes.
4. Push the commit(s) to your forked repository and open a new Pull Request for
the changes to be merged into the original repository.
Note: PRs should **not** be opened against the `master` branch, but against
`development` (or another feature branch if appropriate).
You might want to read the complete [guide to contributing][CONTRIBUTING.md]:
most of it is not needed if you just want to contribute with a translation, but
you still might find some of the info quite useful for contributing further.
#### Translation via web
Follow this guide if you are able to edit text files on the fly on the web.
First of all you will need to create a fork of this repository. A fork is a copy
of the project that you can work on (editing, adding and deleting files for
example). Once you are done working on the fork you will send those edits to us
and we will analyze and merge them into the actual code of the script.
1. To create the fork open the [main page of the project][Project main page] and
click on "Fork" in the top right corner.
![Create the fork][Create fork image]
From now on you can open your fork by visiting the following URL:
`https://github.com/YOURUSERNAME/GoogleContactsEventsNotifier`.
2. Open your fork and choose the branch you want to edit from the dropdown menu.
![Choose a branch][Choose branch image]
Note: Almost always this should be `development` (unless it's clearly stated
otherwise somewhere).
3. Open the file you want to edit (clicking on its name) and click on the pencil
button in the top right corner to edit it.
![Edit the file][Edit file image]
You can modify the file in the browser directly or you can copy the file
content in your editor of choice on your PC, edit it there, then copy the
edited text and paste it in back in the browser.
4. Once you are done add a title and a description to your edit (Something along
the lines of "Added translation for XX" is fine) and click on "Commit
changes".
![Commit the changes][Commit changes image]
5. Get back to your fork home page and verify that the changes have been saved
correctly. (You might have to select the branch again). Then click on the
"New pull request" button next to the branch selection dropdown.
![New pull request][New PR image]
6. Set the "base fork" dropdown to your own fork and the "head fork" to
"GioBonvi/GoogleContactsEventsNotifier". Set both the "base" and "compare"
dropdowns to the branch you have edited, then click "Create pull request".
![Create pull request][Create PR image]
7. This is it. Your request will now be analyzed and eventually approved by one
of the collaborators of the project.
#### Translation via issue
Use this method only if you have tried the previous one and you are completely
lost somewhere in the GitHub labyrinth.
1. Go to the [issue page][Project issue page], click on "New issue".
![New issue][New issue image]
2. Give the issue a meaningful title (e.g. "Translation for XX language").
3. Delete all the pre-compiled text and paste the new translation (just the
translation, not the full script), then click on "Submit new issue".
4. This is it. Your request will now be analyzed and eventually approved by one
of the collaborators of the project.
[Project issue page]: https://github.com/GioBonvi/GoogleContactsEventsNotifier/issues
[Project main page]: https://github.com/GioBonvi/GoogleContactsEventsNotifier
[Translation via git]: #translation-via-git
[Translation via web]: #translation-via-web
[Translation via issue]: #translation-via-issue
[CONTRIBUTING.md]: ../.github/CONTRIBUTING.md
[git-guide.md]: git-guide.md
[Create fork image]: ../images/docs/create_fork.png
[Choose branch image]: ../images/docs/choose_branch.png
[Edit file image]: ../images/docs/edit_file.png
[Commit changes image]: ../images/docs/commit_changes.png
[New PR image]: ../images/docs/new_PR.png
[Create PR image]: ../images/docs/create_PR.png
[New issue image]: ../images/docs/new_issue.png
================================================
FILE: tests.gs
================================================
/* global Logger Log Priority SimplifiedSemanticVersion log generateEmailNotification dateWithTimezone Utilities MailApp settings validateSettings */
/**
* This function throws an error when the condition provided is false.
*
* @param {?boolean} condition - The condition to be asserted.
* @param {string} [message=Assertion failed] - The error message thrown if the assertion fails.
*/
function assert (condition, message) {
if (!condition) {
throw message || 'Assertion failed';
}
}
/**
* Run all the unit tests of the project.
*/
function unitTests () { // eslint-disable-line no-unused-vars
testLog();
Logger.log('Log tests passed!');
testSemVer();
Logger.log('SimplifiedSemanticVersioning tests passed!');
testDSTCorrectness();
Logger.log('DST correctness tests passed!');
Logger.log('All tests passed!');
}
/**
* Test the `Log` class.
*/
function testLog () {
var testLog = new Log(Priority.INFO, Priority.MAX, true);
// Testing Log.add().
try {
testLog.add('', '');
testLog.add(null, null);
testLog.add(undefined, undefined);
assert(testLog.events.length === 3, 'Testing Log.add() failed.');
} catch (err) {
assert(false, 'Testing Log.add() failed.');
}
// Testing log filtering
var logs = [
{name: 'INFO', count: 6},
{name: 'WARNING', count: 3},
{name: 'ERROR', count: 2},
{name: 'FATAL_ERROR', count: 1},
{name: 'MAX', count: 0}
];
logs.forEach(function (test) {
var testLog = new Log(Priority[test.name], Priority.MAX, true);
testLog.add('', '', true);
testLog.add(null, null, true);
testLog.add(undefined, undefined, true);
testLog.add('text', Priority.WARNING, true);
testLog.add('text', Priority.ERROR, true);
testLog.add('text', Priority.FATAL_ERROR, true);
assert(testLog.events.length === test.count, 'Logging with filter "' + test.name + '" failed.');
});
// Remove the logs resulting from the tests.
Logger.clear();
}
/**
* Test the `SimplifiedSemanticVersion` class.
*/
function testSemVer () {
// These version numbers are not valid and should result in errors being thrown.
var errors = [null, undefined, '', 'randomThings', '1.1', '1.1.1.1'];
errors.forEach(function (err) {
try {
var v = new SimplifiedSemanticVersion(err); // eslint-disable-line no-unused-vars
assert(false, String(err) + ' was accepted as a valid SemVer.');
} catch (ex) {}
});
// These version numbers are valid and should generate a valid SimplifiedSemanticVersion.
var valid = ['0.0.1', '123.123.123', '1.1.1+abcd', '1.1.1-abcd', '1.1.1-abcd+efgh', '1.1.1+abcd-efgh'];
valid.forEach(function (valid) {
assert((new SimplifiedSemanticVersion(valid)).toString() === valid, valid + ' was not recognized as a valid SemVer.');
});
// These version numbers are valid and their comparison should match the expected result.
var compare = [
{v1: '0.0.1', v2: '0.0.1', result: 0},
{v1: '0.0.1+abc', v2: '0.0.1+def', result: 0},
{v1: '0.0.1+abc', v2: '0.0.1', result: 0},
{v1: '0.0.1', v2: '0.0.1-alpha', result: 1},
{v1: '0.0.2', v2: '0.0.1', result: 1},
{v1: '0.0.2', v2: '0.0.1-alpha', result: 1},
{v1: '0.1.0', v2: '0.0.1', result: 1},
{v1: '0.1.0', v2: '0.0.1-alpha', result: 1},
{v1: '1.0.0', v2: '0.0.1', result: 1},
{v1: '1.0.0', v2: '0.1.0', result: 1}
];
compare.forEach(function (comp) {
var v1 = new SimplifiedSemanticVersion(comp.v1);
var v2 = new SimplifiedSemanticVersion(comp.v2);
assert(v1.compare(v2) === comp.result, 'Comparison between ' + comp.v1 + ' and ' + comp.v2 + ' did not return the expected value of ' + comp.result);
assert(v2.compare(v1) === (-comp.result), 'Comparison between ' + comp.v2 + ' and ' + comp.v1 + ' did not return the expected value of ' + (-comp.result));
});
}
/**
* Test whether dateWithTimezone() handles daylight saving time (DST) as expected.
*/
function testDSTCorrectness () {
var timezone, date, expectedDate, dateDSTon, dateDSToff, now;
now = new Date();
timezone = 'Europe/Athens';
/*
* Month and day must be strings with two digits.
* Both months and days are 1 indexed (JAN = '01' and 1 = '01').
*/
dateDSTon = {
year: now.getFullYear(),
month: '05',
day: '01'
};
dateDSToff = {
year: now.getFullYear(),
month: '11',
day: '01'
};
// DST ON.
date = dateWithTimezone(
parseInt(dateDSTon.year),
parseInt(dateDSTon.month) - 1,
parseInt(dateDSTon.day),
0, 0, 0,
timezone
);
expectedDate = dateDSTon.year + '-' + dateDSTon.month + '-' + dateDSTon.day + 'T00:00:00+03:00';
assert(
Utilities.formatDate(date, timezone, 'yyyy-MM-dd\'T\'HH:mm:ssXXX') === expectedDate,
'DST ON check FAILED. This could be caused by a change in Google\'s date implementation which broke dateWithTimezone() or by a change in DST policy for \'Europe/Athens\'.'
);
// DST OFF.
date = dateWithTimezone(
parseInt(dateDSToff.year),
parseInt(dateDSToff.month) - 1,
parseInt(dateDSToff.day),
0, 0, 0,
timezone
);
expectedDate = dateDSToff.year + '-' + dateDSToff.month + '-' + dateDSToff.day + 'T00:00:00+02:00';
assert(
Utilities.formatDate(date, timezone, 'yyyy-MM-dd\'T\'HH:mm:ssXXX') === expectedDate,
'DST OFF check FAILED. This could be caused by a change in Google\'s date implementation which broke dateWithTimezone() or by a change in DST policy for \'Europe/Athens\'.'
);
}
/**
* Test all events from the selected period. After running you'll get combined email for all days (for testing subjects and HTML view) and second mail with logs (for testing text view).
*
* **NB:** Execution of this function very often exceeds maximum time (5min - 300s), so it's good idea to split it up into few runs (for me running it for 185 days works perfectly).
*
* @param {Date} [testDate=01/01/CURRENT_YEAR] - First date to test.
* @param {number} [numberOfDaysToTest=365] - Number of days to test.
*/
function testSelectedPeriod (testDate, numberOfDaysToTest) { // eslint-disable-line no-unused-vars
testDate = testDate || new Date(new Date().getFullYear(), 0, 1, 6, 0, 0);
numberOfDaysToTest = numberOfDaysToTest || 365;
log.add('testSelectedPeriod() running checking from ' + testDate.toDateString() + ' for ' + numberOfDaysToTest + ' days.', Priority.INFO);
var emailData = {
'subject': 'testSelectedPeriod run from ' + testDate.toDateString() + ' for ' + numberOfDaysToTest + ' days',
'body': '',
'htmlBody': ''
};
validateSettings();
for (var i = 0; i < numberOfDaysToTest; i++) {
var dayMailContent = generateEmailNotification(testDate);
if (dayMailContent !== null) {
log.add('Subject: ' + dayMailContent.subject);
log.add('Content: ' + dayMailContent.body);
emailData.body += '\n' + dayMailContent.subject + '\n';
emailData.body += dayMailContent.body;
emailData.htmlBody += '<h1>' + dayMailContent.subject + '</h1>';
emailData.htmlBody += dayMailContent.htmlBody;
}
testDate = testDate.addDays(1);
}
MailApp.sendEmail({
to: settings.user.notificationEmail,
subject: emailData.subject,
body: emailData.body,
htmlBody: emailData.htmlBody,
name: settings.user.emailSenderName
});
log.add('Test finished. Sending logs via email.', Priority.MAX);
log.sendEmail(settings.user.notificationEmail, settings.user.emailSenderName);
}
gitextract_xfabd126/ ├── .gitattributes ├── .github/ │ ├── CODE_OF_CONDUCT.md │ ├── CONTRIBUTING.md │ └── ISSUE_TEMPLATE.md ├── .gitignore ├── .jsdoc-conf.json ├── .markdownlint.json ├── LICENSE ├── README.md ├── code.gs ├── docs/ │ ├── git-guide.md │ ├── install-and-setup.md │ └── translation-guide.md ├── images/ │ └── Logo.psd └── tests.gs
Condensed preview — 15 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (172K chars).
[
{
"path": ".gitattributes",
"chars": 121,
"preview": "* text=auto\n\n*.md text\n*.json text\n*.gs text\n.gitattributes text\n.gitignore text\nLICENSE text\n\n*.png binary\n*.psd binary"
},
{
"path": ".github/CODE_OF_CONDUCT.md",
"chars": 3274,
"preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, w"
},
{
"path": ".github/CONTRIBUTING.md",
"chars": 13787,
"preview": "# Contributing\n\nWelcome, user! \nAs you might know Google Contacts Events Notifier is an open source project: any\nkind o"
},
{
"path": ".github/ISSUE_TEMPLATE.md",
"chars": 1616,
"preview": "<!-- markdownlint-disable-->\nBefore reporting a new issue please double check that:\n\n- you followed the instructions at "
},
{
"path": ".gitignore",
"chars": 35,
"preview": "*~\n/code-customized.gs\n/jsdoc-out/\n"
},
{
"path": ".jsdoc-conf.json",
"chars": 496,
"preview": "{\n \"plugins\": [\"plugins/markdown\"],\n \"recurseDepth\": 10,\n \"source\": {\n \"includePattern\": \".+\\\\.(gs|js(do"
},
{
"path": ".markdownlint.json",
"chars": 103,
"preview": "{\n \"MD009\": { \"br_spaces\": 2 },\n \"MD013\": {\"code_blocks\": false},\n \"MD029\": { \"style\": \"ordered\" }\n}"
},
{
"path": "LICENSE",
"chars": 1080,
"preview": "MIT License\n\nCopyright (c) 2016, 2017 Giorgio Bonvicini\n\nPermission is hereby granted, free of charge, to any person obt"
},
{
"path": "README.md",
"chars": 14764,
"preview": "# Google Contacts Events Notifier\n\n\n\nReceive customized email notifications to alert you a"
},
{
"path": "code.gs",
"chars": 95787,
"preview": "/* global Logger ScriptApp ContactsApp Utilities Calendar CalendarApp UrlFetchApp MailApp Session */\n/* eslint no-multi-"
},
{
"path": "docs/git-guide.md",
"chars": 8178,
"preview": "# Git mini-tutorial\n\nBefore starting the tutorial, it is important to clarify definitions of the\nfollowing for beginners"
},
{
"path": "docs/install-and-setup.md",
"chars": 7315,
"preview": "# Installation and setup\n\nFollow these instructions to install and setup the script correctly.\n\n<!-- TOC -->\n\n- [Install"
},
{
"path": "docs/translation-guide.md",
"chars": 9197,
"preview": "# Translation\n\nGoogle Contacts Events Notifier can easily be configured so as to use another\nlanguage instead of English"
},
{
"path": "tests.gs",
"chars": 7438,
"preview": "/* global Logger Log Priority SimplifiedSemanticVersion log generateEmailNotification dateWithTimezone Utilities MailApp"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the GioBonvi/GoogleContactsEventsNotifier GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 15 files (159.4 KB), approximately 41.5k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.