├── .gitignore
├── requirements.txt
├── images
├── widget.gif
└── sidebar.png
├── MANIFEST.in
├── i3notifier
├── rofi-theme
│ ├── widget.rasi
│ ├── sidebar.rasi
│ └── common.rasi
├── user_config.py
├── utils.py
├── rofi_gui.py
├── config.py
├── data_manager.py
├── notification.py
└── notification_fetcher.py
├── setup.py
├── bin
├── switch-to-urgent.py
└── i3-notifier
├── MANIFEST
├── tests
├── test_notification.py
└── test_data_manager.py
├── examples
└── i3_notifier_config.py
├── README.md
└── LICENSE
/.gitignore:
--------------------------------------------------------------------------------
1 | **/.mypy_cache
2 | **/__pycache__
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | dbus-python==1.2.16
2 | pyxdg==0.26
3 | python-daemon==2.2.4
4 |
--------------------------------------------------------------------------------
/images/widget.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sencer/i3-notifier/HEAD/images/widget.gif
--------------------------------------------------------------------------------
/images/sidebar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sencer/i3-notifier/HEAD/images/sidebar.png
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include i3notifier/rofi-theme/common.rasi
2 | include i3notifier/rofi-theme/widget.rasi
3 | include i3notifier/rofi-theme/sidebar.rasi
4 |
--------------------------------------------------------------------------------
/i3notifier/rofi-theme/widget.rasi:
--------------------------------------------------------------------------------
1 | @import "common.rasi"
2 |
3 | #window {
4 | background-color: #00000000;
5 | location: center;
6 | anchor: center;
7 | }
8 |
--------------------------------------------------------------------------------
/i3notifier/rofi-theme/sidebar.rasi:
--------------------------------------------------------------------------------
1 | @import "common.rasi"
2 |
3 | #window {
4 | background-color: #252525;
5 | location: east;
6 | anchor: east;
7 | border: 0 0 0 2px;
8 | border-color: @red;
9 | }
10 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from distutils.core import setup
2 |
3 | setup(
4 | name="i3-notifier",
5 | version="0.18",
6 | description="A notification daemon for i3",
7 | author="Sencer Selcuk",
8 | packages=["i3notifier", "tests"],
9 | scripts=["bin/i3-notifier", "bin/switch-to-urgent.py"],
10 | package_data={"i3notifier": ["rofi-theme/*.rasi"]},
11 | include_package_data=True,
12 | )
13 |
--------------------------------------------------------------------------------
/bin/switch-to-urgent.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import logging
3 |
4 | import i3ipc
5 |
6 |
7 | def switch():
8 | def switch_to_urgent(i3, data):
9 | i3.main_quit()
10 | i3.command(f"workspace {data.current.name}")
11 |
12 | i3 = i3ipc.Connection(auto_reconnect=True)
13 | i3.on(i3ipc.Event.WORKSPACE_URGENT, switch_to_urgent)
14 | i3.main(3)
15 |
16 |
17 | switch()
18 |
--------------------------------------------------------------------------------
/MANIFEST:
--------------------------------------------------------------------------------
1 | # file GENERATED by distutils, do NOT edit
2 | setup.py
3 | bin/i3-notifier
4 | bin/switch-to-urgent.py
5 | i3notifier/config.py
6 | i3notifier/data_manager.py
7 | i3notifier/notification.py
8 | i3notifier/notification_fetcher.py
9 | i3notifier/rofi_gui.py
10 | i3notifier/user_config.py
11 | i3notifier/utils.py
12 | i3notifier/rofi-theme/common.rasi
13 | i3notifier/rofi-theme/sidebar.rasi
14 | i3notifier/rofi-theme/widget.rasi
15 | tests/test_data_manager.py
16 | tests/test_notification.py
17 |
--------------------------------------------------------------------------------
/tests/test_notification.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from i3notifier.notification import Notification, NotificationCluster
4 |
5 |
6 | class TestNotificaton(unittest.TestCase):
7 |
8 | _notification = Notification(
9 | 1, "app", "icon", "summary", "body", ["default"], 1595608250375722880, 1
10 | )
11 |
12 | def test_format(self):
13 | self.assertEqual(
14 | self._notification.formatted(),
15 | b"summary app\nbody",
16 | )
17 |
18 |
19 | if __name__ == "__main__":
20 | unittest.main()
21 |
--------------------------------------------------------------------------------
/i3notifier/user_config.py:
--------------------------------------------------------------------------------
1 | import importlib.util
2 | import logging
3 | import os
4 |
5 | logger = logging.getLogger(__name__)
6 |
7 |
8 | class UserConfig:
9 | config_list = []
10 | theme = None
11 |
12 |
13 | def read_user_config():
14 |
15 | config_path = os.path.join(
16 | os.environ["XDG_CONFIG_HOME"]
17 | if "XDG_CONFIG_HOME" in os.environ
18 | else os.path.join(os.environ["HOME"], ".config"),
19 | "i3",
20 | "i3_notifier_config.py",
21 | )
22 |
23 | if os.path.exists(config_path):
24 | logger.info(f"Loading user config from {config_path}")
25 | spec = importlib.util.spec_from_file_location("i3_notifier_config", config_path)
26 | userconfig = importlib.util.module_from_spec(spec)
27 | spec.loader.exec_module(userconfig)
28 | return userconfig
29 | logger.info(f"File not found: {config_path}")
30 |
31 |
32 | def get_user_config():
33 | userconfig = UserConfig()
34 | userconfig_ = read_user_config()
35 |
36 | if userconfig_ is not None:
37 | if hasattr(userconfig_, "config_list"):
38 | userconfig.config_list = userconfig_.config_list
39 |
40 | if hasattr(userconfig_, "theme"):
41 | userconfig.theme = userconfig_.theme
42 |
43 | return userconfig
44 |
--------------------------------------------------------------------------------
/i3notifier/utils.py:
--------------------------------------------------------------------------------
1 | import html
2 | import threading
3 | from html.parser import HTMLParser
4 | from io import StringIO
5 |
6 |
7 | class MLStripper(HTMLParser):
8 | def __init__(self):
9 | super().__init__()
10 | self.reset()
11 | self.strict = False
12 | self.convert_charrefs = True
13 | self.text = StringIO()
14 |
15 | def handle_data(self, d):
16 | self.text.write(d)
17 |
18 | def get_data(self):
19 | return self.text.getvalue()
20 |
21 |
22 | def strip_tags(data):
23 | s = MLStripper()
24 | s.feed(html.escape(data))
25 | return s.get_data()
26 |
27 |
28 | def strip_tags_and_escape(data):
29 | return html.escape(strip_tags(data))
30 |
31 |
32 | class RunAsync(threading.Thread):
33 |
34 | __slots__ = "args", "kwargs", "closure"
35 |
36 | def __init__(self, closure, *args, **kwargs):
37 | self.closure = closure
38 | self.args = args
39 | self.kwargs = kwargs
40 | threading.Thread.__init__(self)
41 |
42 | def run(self):
43 | return self.closure(*self.args, **self.kwargs)
44 |
45 |
46 | class RunAsyncFactory:
47 | __slots__ = "closure"
48 |
49 | def __init__(self, closure):
50 | self.closure = closure
51 |
52 | def __call__(self, *args, **kwargs):
53 | return RunAsync(self.closure, *args, **kwargs).start()
54 |
--------------------------------------------------------------------------------
/examples/i3_notifier_config.py:
--------------------------------------------------------------------------------
1 | import subprocess
2 | import time
3 |
4 | from i3notifier.config import Config
5 | from i3notifier.utils import RunAsyncFactory
6 |
7 |
8 | class DefaultConfig(Config):
9 | pre_action_hooks = [
10 | # Start a script to listen for urgent workspaces & switch to it
11 | RunAsyncFactory(lambda n: subprocess.call("switch-to-urgent.py")),
12 | # Wait for the script become available
13 | lambda n: time.sleep(0.2),
14 | ]
15 |
16 |
17 | def ChromeAppFactory(title, url, icon=None, second_key="body"):
18 | kChrome = "Google Chrome"
19 | lURL = len(url)
20 | icon = icon or "chrome"
21 |
22 | class ChromeApp(DefaultConfig):
23 | def should_apply(notification):
24 | return (
25 | notification.body.startswith(url) and notification.app_name == kChrome
26 | )
27 |
28 | def update_notification(notification):
29 | notification.body = notification.body[lURL:].strip()
30 | notification.app_name = title
31 | notification.app_icon = icon
32 |
33 | def get_keys(notification):
34 | return title, str(getattr(notification, second_key))
35 |
36 | return ChromeApp
37 |
38 |
39 | Gmail = ChromeAppFactory("Gmail", "mail.google.com", "gmail")
40 | Gmail.pre_close_hooks = ["ignore"]
41 |
42 |
43 | config_list = [
44 | Gmail,
45 | ChromeAppFactory("WhatsApp", "web.whatsapp.com", "web-whatsapp", "summary"),
46 | ChromeAppFactory("Chat", "chat.google.com", "chat"),
47 | ChromeAppFactory("Meet", "meet.google.com", "meet"),
48 | ChromeAppFactory("Twitter", "twitter.com", "twitter"),
49 | ChromeAppFactory("Instagram", "instagram.com", "twitter"),
50 | DefaultConfig,
51 | ]
52 |
53 | theme = "widget" # or sidebar
54 |
--------------------------------------------------------------------------------
/i3notifier/rofi-theme/common.rasi:
--------------------------------------------------------------------------------
1 | configuration {
2 | columns: 1;
3 | eh: 2;
4 | show-icons: true;
5 |
6 | kb-accept-alt: "";
7 | kb-remove-char-back: "";
8 | kb-remove-char-backward: "";
9 | kb-remove-char-forward: "";
10 | kb-remove-word-back: "";
11 | kb-toggle-case-sensitivity: "";
12 | me-select-entry: "";
13 | kb-delete-entry: "";
14 |
15 | kb-accept-entry: "Control+j,Control+m,Return,KP_Enter,space";
16 | kb-cancel: "grave,Shift+BackSpace,Control+BackSpace";
17 | kb-custom-1: "Delete,d";
18 | kb-custom-2: "Escape,Control+bracketleft,BackSpace";
19 | kb-custom-3: "Shift+Return";
20 | kb-custom-4: "Shift+Delete,Control+Delete";
21 | kb-row-down: "Down,Control+n,j";
22 | kb-row-up: "Up,Control+p,k";
23 | me-accept-entry: "MousePrimary,MouseDPrimary";
24 |
25 | }
26 |
27 | * {
28 | background-color: rgba(0,0,0,0);
29 | dark: #1c1c1c;
30 | lightred: #cc5533;
31 | red: #cd5c5c;
32 | blue: #6495ed;
33 | lightblue: #87ceeb;
34 | lightwhite: #ddccbb;
35 | bg: #4c4c4c;
36 | bggroup: #4c4c6c;
37 | }
38 |
39 | #window {
40 | height: 100%;
41 | width: 20em;
42 | text-color: @lightwhite;
43 | children: [listview];
44 | }
45 |
46 | #listview {
47 | padding: 10px;
48 | dynamic: false;
49 | lines: 0;
50 | spacing: 10px;
51 | }
52 |
53 | #element {
54 | padding: 10px;
55 | border-radius: 3px;
56 | background-color: @bg;
57 | text-color: @lightwhite;
58 | }
59 |
60 | #element.active {
61 | background-color: @bggroup;
62 | }
63 |
64 | #element.urgent {
65 | background-color: @lightred;
66 | }
67 |
68 | #element.selected {
69 | background-color: @blue;
70 | text-color: @dark;
71 | }
72 |
73 | #element.selected.active {
74 | background-color: @lightblue;
75 | text-color: @dark;
76 | }
77 |
78 | #element.selected.urgent {
79 | background-color: @red;
80 | text-color: @dark;
81 | }
82 |
83 | #element-icon {
84 | size: 2em;
85 | horizontal-align: 0.5;
86 | vertical-align: 0.5;
87 | padding: 0 5px 0 0;
88 | }
89 |
--------------------------------------------------------------------------------
/i3notifier/rofi_gui.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os.path
3 | import subprocess
4 | from enum import Enum
5 |
6 | logger = logging.getLogger(__name__)
7 |
8 |
9 | class Operation(Enum):
10 | SELECT = 0
11 | EXIT_COMPLETELY = 1
12 | DELETE = 10
13 | EXIT = 11
14 | SELECT_ALT = 12
15 | DELETE_ALT = 13
16 |
17 |
18 | class RofiGUI:
19 |
20 | _separator = b"\x01"
21 | _args = [
22 | "-dmenu",
23 | "-markup-rows",
24 | "-i",
25 | "-format",
26 | "i",
27 | "-sep",
28 | r"\x01",
29 | ]
30 |
31 | __slots__ = "cmd"
32 |
33 | def __init__(self, *args, theme=None, cmd=None):
34 | self.cmd = cmd or "rofi"
35 | self._args.extend(args)
36 | if theme is not None:
37 | self._args.extend(
38 | ["-theme", f"{os.path.dirname(__file__)}/rofi-theme/{theme}"]
39 | )
40 |
41 | def show_notifications(self, notifications, row=0):
42 |
43 | formatted_notifications = []
44 | urgent = []
45 | active = []
46 | for i, notification in enumerate(notifications):
47 | formatted_notifications.append(notification.formatted())
48 |
49 | if notification.urgency == 2:
50 | urgent.append(str(i))
51 |
52 | if len(notification) > 1:
53 | active.append(str(i))
54 |
55 | proc = subprocess.Popen(
56 | [self.cmd]
57 | + self._args
58 | + ["-selected-row", str(row)]
59 | + (["-u", ",".join(urgent)] if urgent else [])
60 | + (["-a", ",".join(active)] if active else []),
61 | stdin=subprocess.PIPE,
62 | stdout=subprocess.PIPE,
63 | )
64 |
65 | proc.stdin.write(self._separator.join(formatted_notifications))
66 | proc.stdin.close()
67 |
68 | maybe_selection = (lambda x: int(x) if x else None)(
69 | proc.stdout.read().decode("utf-8")
70 | )
71 | operation = proc.wait()
72 | logger.info(f"Operation {operation}")
73 | return maybe_selection, Operation(operation)
74 |
--------------------------------------------------------------------------------
/i3notifier/config.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from .utils import strip_tags, strip_tags_and_escape
4 |
5 |
6 | class Config:
7 | pre_close_hooks = []
8 | post_close_hooks = []
9 | pre_action_hooks = []
10 | post_action_hooks = []
11 |
12 | # Ignore expiration by default.
13 | expires = False
14 |
15 | def should_apply(notification):
16 | # To group notifications and apply certain formatting inherit this
17 | # class and make sure should_apply(notification) is the first Config
18 | # that returns True to those notifications.
19 | return True
20 |
21 | def get_keys(notification):
22 | # Returns a tuple, each element of which corresponds to the group
23 | # name that the notification should be assigned at the
24 | # corresponding nesting level.
25 | return (notification.app_name or "_", notification.body)
26 |
27 | def update_notification(notification):
28 | # Edit the notification before saving it. By default no edits are
29 | # made.
30 | return notification
31 |
32 | def format_notification(notification):
33 | time = datetime.fromtimestamp(notification.created_at // 1e9).strftime("%H:%M")
34 | # Format the notification
35 | text = f"{strip_tags_and_escape(notification.summary)} "
36 | text += f"{time}"
37 | text += f" {strip_tags_and_escape(notification.app_name)}"
38 |
39 | if notification.body:
40 | text += (
41 | "\n"
42 | + strip_tags_and_escape(notification.body.replace("\n", "").strip())
43 | + ""
44 | )
45 |
46 | if notification.app_icon:
47 | return (
48 | text.encode("utf-8")
49 | + b"\x00icon\x1f"
50 | + notification.app_icon.encode("utf-8")
51 | )
52 | else:
53 | return text.encode("utf-8")
54 |
55 | def single_line(notification):
56 | return f"{strip_tags(notification.summary)} : {strip_tags(notification.body)}".replace(
57 | "\n", ""
58 | ).strip()
59 |
--------------------------------------------------------------------------------
/bin/i3-notifier:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | import logging
3 | import logging.handlers
4 | import os
5 | import signal
6 | import sys
7 |
8 | import dbus.mainloop.glib
9 |
10 | import daemon
11 | import daemon.pidfile
12 | from gi.repository import GLib
13 | from i3notifier.data_manager import DataManager
14 | from i3notifier.notification_fetcher import NotificationFetcher
15 | from i3notifier.rofi_gui import RofiGUI
16 | from i3notifier.user_config import get_user_config
17 |
18 |
19 | def run_daemon():
20 | userconfig = get_user_config()
21 |
22 | logger.info(f"Found {len(userconfig.config_list)} configurations.")
23 |
24 | dump_path = "/tmp/i3-notifier.dump"
25 | logger.info(
26 | f"Notifications will be dumped to {dump_path} on graceful exit or when asked."
27 | )
28 | data_manager = DataManager(userconfig.config_list, dump_path)
29 |
30 | gui = RofiGUI(theme=userconfig.theme)
31 |
32 | def dump_and_exit(n, f):
33 | data_manager.dump()
34 | data_manager.cancel_timers()
35 | sys.exit(0)
36 |
37 | pid_file = "/tmp/i3-notifier.pid"
38 |
39 | if os.path.exists(pid_file):
40 | try:
41 | pid = int(open(pid_file).read().strip())
42 | os.kill(pid, 0)
43 | logger.info(f"i3-notifier is already running with pid {pid}.")
44 | except:
45 | os.remove(pid_file)
46 | logger.info(
47 | "i3-notifier is not running, but a lock file exists. Cleaning up."
48 | )
49 |
50 | with daemon.DaemonContext(
51 | pidfile=daemon.pidfile.PIDLockFile(pid_file),
52 | signal_map={signal.SIGTERM: dump_and_exit},
53 | files_preserve=files_preserve,
54 | ):
55 | logger.info(f"Creating lock file {pid_file}")
56 | dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
57 | fetcher = NotificationFetcher(data_manager, gui)
58 |
59 | logger.info(f"Starting i3-notifier.")
60 | try:
61 | GLib.MainLoop().run()
62 | except:
63 | logger.info("Exiting Glib.MainLoop")
64 | data_manager.dump()
65 |
66 |
67 | if __name__ == "__main__":
68 | logger = logging.getLogger("i3notifier")
69 | files_preserve = []
70 |
71 | if len(sys.argv) > 1 and sys.argv[1] == "--debug":
72 | file_logger = logging.FileHandler("/tmp/i3-notifier.log", "w",)
73 | file_logger.setFormatter(
74 | logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
75 | )
76 | files_preserve.append(file_logger.stream.fileno())
77 |
78 | logger.addHandler(file_logger)
79 | logger.setLevel(logging.INFO)
80 |
81 | run_daemon()
82 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # i3 Notification Manager (Requires a recent version of rofi)
2 |
3 | This is a notification manager for i3 desktop environment inspired by
4 | [Rofication](https://github.com/DaveDavenport/Rofication). Like
5 | Rofication, it implements the [Gnome Desktop Notifications
6 | Standard](https://developer.gnome.org/notification-spec/) standard.
7 |
8 | Also see the companion py3status module
9 | [py3-notifier](https://github.com/sencer/py3-notifier).
10 |
11 | ## Differences from Rofication
12 |
13 | - Notifications are stored in a tree structure, where they can be grouped
14 | by, for example, the application that is sending the notification and
15 | the subject. This is highly configurable. (No docs at the moment, but
16 | see the `examples/i3_notifier_config.py`; also see the comments in
17 | `i3notifier/config.py`)
18 | - Allows bulk deletion of notifications in a category.
19 | - Implements "default" action.
20 | - Shows icons.
21 | - Does not use sockets, rather adds new dbus methods to show the
22 | notifications and get the count of notifications.
23 | - Code is modular, should be straight forward to use another GUI rather
24 | than Rofi; or another data structure rather than tree structure.
25 |
26 | ## What does it look like?
27 |
28 | 
29 |
30 | ## Usage
31 |
32 | To install (also see the requirements.txt)
33 |
34 | pip install i3-notifier
35 |
36 | Make sure you are not running any other notification daemon (if you are running `dunst` for example, kill it with `killall dunst`). Then start `i3-notifier`. You might want to make sure `i3-notifier` and its companion script `switch-to-urgent.py` are in the `$PATH`. You can confirm it is running by
37 |
38 | dbus-send --session --print-reply --dest=org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications.GetServerInformation
39 |
40 | Then to launch GUI; bind this to a shortcut
41 |
42 | dbus-send --session --print-reply --dest=org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications.ShowNotifications
43 |
44 | ## Keybindings
45 | | Key | Action |
46 | |--------------------------------------------:|-------------------------------------|
47 | `j`, `Down`, `Tab` or `Ctrl-N` | Choose next
48 | `k`, `Up` or `Ctrl-P` | Choose previous
49 | `Enter`, `Space` or `Left Click` | Expand if group; execute action if singleton
50 | `Shift-Enter` | Execute action
51 | `Esc`, `Ctrl+[` or `Backspace` | One level up or exit from top level
52 | `` ` ``, `Ctrl+Backspace` or `Shift-Backspace`| Exit from any level.
53 | `Delete` | Delete notification or group
54 | `Ctrl+Delete`, `Shift+Delete` | Delete single notification
55 |
56 | To get notification count & urgency
57 |
58 | dbus-send --session --print-reply=literal --dest=org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications.ShowNotificationCount
59 |
60 | One can use this with i3blocks to show notifications in the bar
61 | or even better switch to py3status and install
62 | [py3-notifier](https://github.com/sencer/py3-notifier).
63 |
64 | command = "(dbus-send --session --print-reply=literal --dest=org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications.ShowNotificationCount 2>/dev/null || ($HOME/.local/bin/i3-notifier && echo '? ? ?'))|tr -s ' '|cut -d' ' -f 3"
65 |
--------------------------------------------------------------------------------
/i3notifier/data_manager.py:
--------------------------------------------------------------------------------
1 | import pickle
2 | import threading
3 |
4 | from .notification import Notification, NotificationCluster
5 |
6 |
7 | class DataManager(threading.Thread):
8 |
9 | __slots__ = "tree", "map", "lock", "configs", "dump_path", "last"
10 |
11 | def __init__(self, configs, dump_path):
12 | super().__init__()
13 |
14 | self.tree = NotificationCluster()
15 | self.map = dict()
16 | self.last = None
17 |
18 | self.lock = threading.Lock()
19 | self.configs = configs
20 | self.dump_path = dump_path
21 |
22 | try:
23 | for notification in pickle.load(open(dump_path, "rb")):
24 | self.add_notification(notification)
25 | except:
26 | pass
27 |
28 | def _recursive_add_notification(cluster, notification, keys, i=0):
29 | if i == len(keys):
30 | return
31 |
32 | if keys[i] not in cluster.notifications:
33 | cluster.notifications[keys[i]] = NotificationCluster()
34 |
35 | DataManager._recursive_add_notification(
36 | cluster.notifications[keys[i]], notification, keys, i + 1
37 | )
38 | cluster.add(keys[i], notification)
39 |
40 | def add_notification(self, notification):
41 | for config in self.configs:
42 | if config.should_apply(notification):
43 | config.update_notification(notification)
44 | notification.config = config
45 | break
46 |
47 | keys = notification.keys()
48 |
49 | if notification.id in self.map:
50 | self.remove_notification(notification.id)
51 |
52 | with self.lock:
53 | self.last = notification
54 | self.map[notification.id] = keys
55 | DataManager._recursive_add_notification(
56 | self.tree, notification, [*keys, notification.id]
57 | )
58 |
59 | def _recursive_remove_notification(cluster, keys, i=0):
60 | key = keys[i]
61 | best_key = i == len(keys) - 1
62 | has_key = key in cluster.notifications
63 | if best_key and not has_key:
64 | # Short-cutted view, descend
65 | key = list(cluster.notifications.keys())[0]
66 | i -= 1
67 |
68 | stop_case = best_key and has_key
69 | if stop_case:
70 | cluster_to_delete = cluster.notifications[key]
71 | best = cluster_to_delete.best
72 | urgency = cluster.urgency
73 | nremoved = len(cluster_to_delete)
74 | else:
75 | nremoved, best, urgency = DataManager._recursive_remove_notification(
76 | cluster.notifications[key], keys, i + 1
77 | )
78 |
79 | if len(cluster.notifications[key]) == 0 or stop_case:
80 | del cluster.notifications[key]
81 |
82 | cluster._len -= nremoved
83 |
84 | if best is cluster._best:
85 | cluster._best = None
86 |
87 | if urgency == cluster._urgency:
88 | cluster._urgency = None
89 |
90 | return nremoved, best, urgency
91 |
92 | def remove_notification(self, id, context=()):
93 | with self.lock:
94 | if isinstance(id, int):
95 | if self.last and id == self.last.id:
96 | self.last = None
97 |
98 | context = self.map.pop(id)
99 | notification = self.get_context(context).notifications[id]
100 | if notification.timer is not None:
101 | notification.timer.cancel()
102 | else:
103 | for leaf in self.get_context(context).notifications[id].leafs():
104 | if self.last and leaf.id == self.last.id:
105 | self.last = None
106 | if leaf.timer is not None:
107 | leaf.timer.cancel()
108 | self.map.pop(leaf.id)
109 |
110 | DataManager._recursive_remove_notification(self.tree, [*context, id], i=0)
111 |
112 | def get_context_by_id(self, id):
113 | return self.get_context(self.map[id])
114 |
115 | def get_context(self, context=()):
116 | p = self.tree
117 |
118 | if context and context[0] not in p.notifications:
119 | while len(p.notifications) == 1:
120 | p = next(iter(p.notifications.values()))
121 |
122 | for key in context:
123 | p = p.notifications[key]
124 |
125 | while len(p.notifications) == 1:
126 | child = next(iter(p.notifications.values()))
127 |
128 | if isinstance(child, Notification):
129 | break
130 |
131 | p = child
132 |
133 | return p
134 |
135 | def dump(self):
136 | pickle.dump(
137 | [notification.strip() for notification in self.tree.leafs()],
138 | open(self.dump_path, "wb"),
139 | )
140 |
141 | def cancel_timers(self):
142 | for notification in self.tree.leafs():
143 | if notification.timer is not None:
144 | notification.timer.cancel()
145 |
--------------------------------------------------------------------------------
/tests/test_data_manager.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from i3notifier.config import Config
4 | from i3notifier.data_manager import DataManager
5 | from i3notifier.notification import Notification, NotificationCluster
6 |
7 |
8 | class DummyConfig(Config):
9 | def get_keys(notification):
10 | return (notification.app_name, notification.summary)
11 |
12 |
13 | class TestDataManager(unittest.TestCase):
14 | def __init__(self, *args, **kwargs):
15 | super(TestDataManager, self).__init__(*args, **kwargs)
16 | self.notifications = [
17 | Notification(1, "A3", "icon", "1", "113", ["dflt"], 1595608250375722879, 1),
18 | Notification(2, "A1", "icon", "1", "111", ["dflt"], 1595608250375722880, 1),
19 | Notification(3, "A1", "icon", "2", "121", ["dflt"], 1595608250375722881, 1),
20 | Notification(4, "A1", "icon", "1", "112", ["dflt"], 1595608250375722882, 1),
21 | Notification(5, "A2", "icon", "1", "211", ["dflt"], 1595608250375722884, 1),
22 | Notification(6, "A2", "icon", "2", "212", ["dflt"], 1595608250375722885, 1),
23 | Notification(7, "A1", "icon", "1", "113", ["dflt"], 1595608250375722886, 1),
24 | ]
25 |
26 | self.dm = DataManager([DummyConfig], "/dev/null")
27 | for notification in self.notifications:
28 | self.dm.add_notification(notification)
29 |
30 | def test_get_context_by_id(self):
31 | self.assertIs(
32 | self.dm.get_context_by_id(7),
33 | self.dm.tree.notifications["A1"].notifications["1"],
34 | )
35 |
36 | def test_get_context(self):
37 | self.assertIs(
38 | self.dm.get_context(("A1", "1")),
39 | self.dm.tree.notifications["A1"].notifications["1"],
40 | )
41 |
42 | def test_get_nocontext(self):
43 | self.assertIs(self.dm.get_context(), self.dm.tree)
44 |
45 | def test_leafs(self):
46 | self.assertCountEqual(self.dm.tree.leafs(), self.notifications)
47 | self.assertCountEqual(
48 | self.dm.get_context(("A1", "1")).leafs(),
49 | [self.notifications[1], self.notifications[3], self.notifications[6],],
50 | )
51 |
52 | def test_add_notification(self):
53 | self.assertEqual(len(self.dm.tree), 7)
54 | self.assertEqual(len(self.dm.get_context(("A2",))), 2)
55 | self.assertIs(self.dm.tree.best, self.notifications[-1])
56 |
57 | notification = Notification(
58 | 8, "A2", "icon", "1", "212", ["dflt"], 1595608250375722891, 1
59 | )
60 |
61 | self.dm.add_notification(notification)
62 | self.assertEqual(len(self.dm.tree), 8)
63 | self.assertEqual(len(self.dm.tree.notifications["A2"]), 3)
64 | self.assertIs(self.dm.get_context().best, notification)
65 | self.assertIs(self.dm.get_context(("A2",)).best, notification)
66 |
67 | def test_overwrite_notification(self):
68 | notification = Notification(
69 | 3, "A1", "icon", "1", "212", ["dflt"], 1595608250375722891, 1
70 | )
71 |
72 | self.dm.add_notification(notification)
73 | self.assertEqual(len(self.dm.get_context()), len(self.notifications))
74 | self.assertEqual(len(self.dm.get_context(("A1",))), 4)
75 | self.assertIs(self.dm.get_context().best, notification)
76 | self.assertIs(self.dm.get_context(("A1",)).best, notification)
77 |
78 | def test_remove_notification(self):
79 | self.assertEqual(len(self.dm.get_context(("A1",))), 4)
80 | self.assertEqual(len(self.dm.tree), len(self.notifications))
81 |
82 | self.assertIs(self.dm.get_context().best, self.notifications[6])
83 | self.assertIs(self.dm.get_context(("A1",)).best, self.notifications[6])
84 |
85 | self.dm.remove_notification("1", ("A1",))
86 |
87 | self.assertEqual(len(self.dm.get_context(("A1",))), 1)
88 | self.assertEqual(len(self.dm.tree), 4)
89 |
90 | self.assertIs(self.dm.tree.best, self.notifications[5])
91 | self.assertIs(self.dm.get_context(("A1",)).best, self.notifications[2])
92 |
93 | def test_remove_notification_integer(self):
94 | self.assertEqual(len(self.dm.get_context(("A1",))), 4)
95 | self.assertEqual(len(self.dm.tree), 7)
96 |
97 | self.assertIs(self.dm.get_context().best, self.notifications[6])
98 | self.assertIs(self.dm.get_context(("A1",)).best, self.notifications[6])
99 |
100 | self.dm.remove_notification(7)
101 |
102 | self.assertEqual(len(self.dm.get_context(("A1",))), 3)
103 | self.assertEqual(len(self.dm.tree), 6)
104 |
105 | self.assertIs(self.dm.tree.best, self.notifications[5])
106 | self.assertIs(self.dm.get_context(("A1",)).best, self.notifications[3])
107 |
108 | def test_remove_shortcutted(self):
109 | self.dm.remove_notification(1)
110 | self.assertCountEqual(self.dm.tree.leafs(), self.notifications[1:])
111 |
112 | self.assertEqual(len(self.dm.tree), 6)
113 | self.assertNotIn("A3", self.dm.tree.notifications)
114 |
115 |
116 | if __name__ == "__main__":
117 | unittest.main()
118 |
119 |
120 | if __name__ == "__main__":
121 | unittest.main()
122 |
--------------------------------------------------------------------------------
/i3notifier/notification.py:
--------------------------------------------------------------------------------
1 | import copy
2 | import itertools
3 | from enum import IntEnum
4 |
5 | from .config import Config
6 |
7 |
8 | class Urgency(IntEnum):
9 |
10 | LOW = 0
11 | MEDIUM = 1
12 | CRITICAL = 2
13 |
14 |
15 | class Notification:
16 | __slots__ = (
17 | "id",
18 | "app_name",
19 | "app_icon",
20 | "body",
21 | "summary",
22 | "actions",
23 | "created_at",
24 | "expires_at",
25 | "urgency",
26 | "config",
27 | "timer",
28 | )
29 |
30 | def __init__(
31 | self,
32 | id,
33 | app_name,
34 | app_icon,
35 | summary,
36 | body,
37 | actions,
38 | created_at,
39 | expires_at=None,
40 | urgency=0,
41 | ):
42 | self.id = id
43 | self.app_name = app_name
44 | self.app_icon = app_icon
45 | self.summary = summary
46 | self.body = body
47 | self.actions = actions
48 | self.created_at = created_at
49 | self.expires_at = expires_at
50 | self.urgency = urgency
51 | self.config = Config
52 | self.timer = None
53 |
54 | @property
55 | def pre_action_hooks(self):
56 | return self.config.pre_action_hooks
57 |
58 | @property
59 | def post_action_hooks(self):
60 | return self.config.post_action_hooks
61 |
62 | @property
63 | def pre_close_hooks(self):
64 | return self.config.pre_close_hooks
65 |
66 | @property
67 | def post_close_hooks(self):
68 | return self.config.post_close_hooks
69 |
70 | @property
71 | def expires(self):
72 | return self.config.expires
73 |
74 | def formatted(self):
75 | return self.config.format_notification(self)
76 |
77 | def single_line(self):
78 | return self.config.single_line(self)
79 |
80 | def keys(self):
81 | return self.config.get_keys(self)
82 |
83 | def strip(self):
84 | return Notification(
85 | self.id,
86 | self.app_name,
87 | self.app_icon,
88 | self.summary,
89 | self.body,
90 | self.actions,
91 | self.created_at,
92 | self.expires_at,
93 | self.urgency,
94 | )
95 |
96 | def __len__(self):
97 | return 1
98 |
99 | @property
100 | def best(self):
101 | return self
102 |
103 | def leafs(self):
104 | return [self]
105 |
106 | def __repr__(self):
107 | return (
108 | ""
118 | )
119 |
120 | def __str__(self):
121 | return self.__repr__()
122 |
123 |
124 | class NotificationCluster:
125 | __slots__ = "notifications", "_best", "_len", "_urgency"
126 |
127 | def __init__(self):
128 | self.notifications = dict()
129 | self._best = None
130 | self._len = 0
131 | self._urgency = None
132 |
133 | @property
134 | def urgency(self):
135 |
136 | if self._urgency is None and self.notifications:
137 | self._urgency = self.best.urgency
138 |
139 | return self._urgency or 0
140 |
141 | def formatted(self):
142 | if len(self) == 1:
143 | return self.best.formatted()
144 |
145 | dummy = self.best.strip()
146 | dummy.app_name = f"{dummy.app_name} ({len(self)})"
147 | dummy.config = self.best.config
148 | return dummy.formatted()
149 |
150 | def reset(self):
151 | self._len = 0
152 | self._urgency = None
153 | self._best = None
154 |
155 | def add(self, key, notification):
156 |
157 | if self._best is None or notification.urgency >= self.best.urgency:
158 | self._best = notification
159 | self._urgency = self.best.urgency
160 | self._len += 1
161 |
162 | if isinstance(key, int):
163 | self.notifications[key] = notification
164 |
165 | def remove(self, key):
166 | if self.urgency == self.notifications[key].urgency:
167 | self._urgency = None
168 |
169 | if self.notifications[key] == self.best:
170 | self._best = None
171 |
172 | self._len -= len(self.notifications[key])
173 |
174 | del self.notifications[key]
175 |
176 | @property
177 | def best(self):
178 | if self._best is None and self.notifications:
179 | self._best = max(
180 | self.notifications.values(),
181 | key=lambda x: (x.urgency, x.best.created_at),
182 | ).best
183 |
184 | return self._best
185 |
186 | def __len__(self):
187 | self._len = self._len or sum(len(n) for n in self.notifications.values())
188 | return self._len or 0
189 |
190 | def leafs(self):
191 | return list(
192 | itertools.chain.from_iterable(
193 | v.leafs() for v in self.notifications.values()
194 | )
195 | )
196 |
197 | def __str__(self):
198 | return str(self.notifications)
199 |
200 | def __repr__(self):
201 | return repr(self.notifications)
202 |
--------------------------------------------------------------------------------
/i3notifier/notification_fetcher.py:
--------------------------------------------------------------------------------
1 | import enum
2 | import logging
3 | import os.path
4 | import threading
5 | import time
6 |
7 | import dbus
8 | import dbus.service
9 |
10 | import xdg.BaseDirectory
11 | from xdg.DesktopEntry import DesktopEntry
12 |
13 | from .notification import Notification
14 | from .rofi_gui import Operation
15 |
16 | DBUS_PATH = "org.freedesktop.Notifications"
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | def xdg_name_and_icon(app):
22 | entry = DesktopEntry()
23 | for directory in xdg.BaseDirectory.xdg_data_dirs:
24 | path = os.path.join(directory, "applications", f"{app}.desktop")
25 | if os.path.exists(path):
26 | entry.parse(path)
27 | return entry.getName(), entry.getIcon()
28 | return None, None
29 |
30 |
31 | class RemoveReason(enum.Enum):
32 | APP_REQUESTED = 0
33 | USER_DELETED = 1
34 | ACTION_INVOKED = 2
35 | EXPIRED = 3
36 |
37 |
38 | class NotificationFetcher(dbus.service.Object):
39 | __slots__ = "dm", "gui", "context"
40 | _id = 1
41 |
42 | def __init__(self, dm, gui):
43 | self.dm = dm
44 | self.gui = gui
45 | self.context = []
46 |
47 | if len(self.dm.tree):
48 | self._id = self.dm.tree.best.id + 1
49 |
50 | name = dbus.service.BusName(DBUS_PATH, dbus.SessionBus())
51 | super().__init__(name, "/org/freedesktop/Notifications")
52 |
53 | @dbus.service.method(DBUS_PATH, in_signature="u", out_signature="")
54 | def CloseNotification(self, id):
55 |
56 | notification = self.dm.get_context_by_id(id).notifications[id]
57 | logger.info(f"Received CloseNotification request for {notification}")
58 |
59 | if self._process_hooks(notification, "pre_close_hooks"):
60 | self.NotificationClosed(id, 3)
61 | else:
62 | logger.info(f"Didn't send NotificationClosed signal for {id}")
63 |
64 | if self._process_hooks(notification, "post_close_hooks"):
65 | self._remove_notification(id, RemoveReason.APP_REQUESTED)
66 | else:
67 | logger.info(f"Didn't delete notification {id}.")
68 |
69 | @dbus.service.method(DBUS_PATH, in_signature="", out_signature="s")
70 | def DumpNotifications(self):
71 | self.dm.dump()
72 | return str(self.dm.tree)
73 |
74 | @dbus.service.method(DBUS_PATH, in_signature="", out_signature="as")
75 | def GetCapabilities(self):
76 | return [
77 | "actions",
78 | "body",
79 | "body-markup",
80 | "icon-static",
81 | "persistence",
82 | ]
83 |
84 | @dbus.service.method(DBUS_PATH, in_signature="", out_signature="ssss")
85 | def GetServerInformation(self):
86 | return "i3notifier", "github.com/sencer/i3-notifier", "0.18", "1.2"
87 |
88 | @dbus.service.method(DBUS_PATH, in_signature="susssasa{ss}i", out_signature="u")
89 | def Notify(
90 | self,
91 | app_name,
92 | replaces_id,
93 | app_icon,
94 | summary,
95 | body,
96 | actions,
97 | hints,
98 | expire_timeout,
99 | ):
100 | logger.info(
101 | "Received notification:\n"
102 | f'app_name:"{app_name}" '
103 | f"replaces_id:{replaces_id} "
104 | f'app_icon:"{app_icon}" '
105 | f'summary:"{summary}" '
106 | f'body:"{body}" '
107 | f'actions:"{actions}" '
108 | f'hints:"{hints}" '
109 | f"expire_timeout:{expire_timeout}"
110 | )
111 |
112 | if replaces_id > 0:
113 | id = replaces_id
114 | else:
115 | id = self._id
116 | self._id += 1
117 |
118 | app = icon = None
119 | if "desktop-entry" in hints and not (app_name and app_icon):
120 | app, icon = xdg_name_and_icon(hints["desktop-entry"])
121 | app_name = app_name or app or hints["desktop-entry"].split(".")[-1]
122 |
123 | if not app_icon or app_icon.startswith("file://"):
124 | if icon:
125 | app_icon = icon
126 | elif "image-path" in hints:
127 | app_icon = hints["image-path"]
128 |
129 | notification = Notification(
130 | id=id,
131 | app_name=app_name,
132 | app_icon=app_icon,
133 | summary=summary,
134 | body=body,
135 | actions=actions,
136 | created_at=time.time_ns(),
137 | )
138 |
139 | if expire_timeout > 0:
140 | notification.expires_at = notification.created_at + expire_timeout
141 |
142 | if "urgency" in hints:
143 | notification.urgency = (
144 | int(hints["urgency"])
145 | if isinstance(hints["urgency"], str)
146 | else hints["urgency"].real
147 | )
148 |
149 | self.dm.add_notification(notification)
150 |
151 | if notification.expires and notification.expires_at:
152 | notification.timer = threading.Timer(
153 | (notification.expires_at - notification.created_at) / 1000,
154 | self._remove_notification,
155 | (notification.id, RemoveReason.EXPIRED),
156 | )
157 | notification.timer.start()
158 |
159 | self._notifications_updated(0) # 0: notification added
160 | return id
161 |
162 | @dbus.service.method(DBUS_PATH, in_signature="", out_signature="uu")
163 | def ShowNotificationCount(self):
164 | return len(self.dm.tree), self.dm.tree.urgency or 0
165 |
166 | @dbus.service.method(DBUS_PATH, in_signature="", out_signature="")
167 | def ShowNotifications(self):
168 | self.context = []
169 | if len(self.dm.tree) > 0:
170 | self._show_notifications()
171 |
172 | @dbus.service.method(DBUS_PATH, in_signature="", out_signature="")
173 | def SignalNotificationCount(self):
174 | self._notifications_updated(2) # 2: manual
175 |
176 | # Signals
177 |
178 | @dbus.service.signal(DBUS_PATH, signature="us")
179 | def ActionInvoked(self, id, action):
180 | logger.info(f"ActionInvoked with action {action} signalled for {id}.")
181 |
182 | @dbus.service.signal(DBUS_PATH, signature="uu")
183 | def NotificationClosed(self, id, reason):
184 | logger.info(f"NotificationClosed signalled for {id} due to {reason}.")
185 |
186 | @dbus.service.signal(DBUS_PATH, signature="uuus")
187 | def NotificationsUpdated(self, mode, num, urgency, single_line):
188 | logger.info(f"Notifications updated.")
189 |
190 | # Internal methods
191 |
192 | def _notifications_updated(self, mode):
193 | self.NotificationsUpdated(
194 | mode,
195 | len(self.dm.tree),
196 | (self.dm.tree.urgency or 0),
197 | self.dm.last.single_line() if self.dm.last else "",
198 | )
199 |
200 | def _process_hooks(self, notification, hook_name):
201 | logger.info(f"Processing {hook_name}")
202 | ret = True
203 |
204 | for hook in getattr(notification, hook_name):
205 | logger.info(f" > Hook {hook}")
206 | if hook == "ignore":
207 | ret = False
208 | else:
209 | hook(notification)
210 |
211 | return ret
212 |
213 | def _remove_notification(self, key, reason):
214 | logger.info(f"Attempting to remove notification {key} since {reason}")
215 | self.dm.remove_notification(key, self.context)
216 | self._notifications_updated(1) # 1: deleted
217 | return self._update_context()
218 |
219 | def _show_notifications(self, row=0):
220 | notifications = self.dm.get_context(self.context).notifications
221 |
222 | items = sorted(
223 | notifications.items(), key=lambda x: (-x[1].urgency, -x[1].best.created_at)
224 | )
225 |
226 | selected, op = self.gui.show_notifications([item[1] for item in items], row)
227 | logger.info(
228 | f"Selection is {None if selected is None else items[selected][0]}"
229 | f", operation is {op}."
230 | )
231 |
232 | if op == Operation.EXIT_COMPLETELY:
233 | return
234 |
235 | if op == Operation.EXIT:
236 | if self.context:
237 | self.context.pop()
238 | self._show_notifications()
239 | return
240 |
241 | if selected is None:
242 | logger.info(f"DEBUG THIS {items}")
243 | return
244 |
245 | key, notification = items[selected]
246 |
247 | if op == Operation.SELECT or op == Operation.SELECT_ALT:
248 | if len(notification) == 1 or op == Operation.SELECT_ALT:
249 | logger.info("Selection is a singleton. Invoking default action.")
250 | self.context = self.dm.map[notification.best.id]
251 |
252 | if self._process_hooks(notification.best, "pre_action_hooks"):
253 | self.ActionInvoked(notification.best.id, "default")
254 | else:
255 | logger.info(f"Skipping action for {notification.id}.")
256 |
257 | if self._process_hooks(notification.best, "post_action_hooks"):
258 | self._remove_notification(
259 | notification.best.id, RemoveReason.ACTION_INVOKED
260 | )
261 | self.NotificationClosed(notification.best.id, 2)
262 | else:
263 | logger.info(
264 | f"Skipping CloseNotification (after action) for {notification.id}."
265 | )
266 | else:
267 | logger.info("Selection is a cluster. Expanding.")
268 | self.context.append(key)
269 | self._show_notifications()
270 | elif op == Operation.DELETE or op == Operation.DELETE_ALT:
271 | key_ = key if op == Operation.DELETE else notification.best.id
272 | context_changed = self._remove_notification(key_, RemoveReason.USER_DELETED)
273 |
274 | row = 0 if context_changed else selected - (selected == len(notifications))
275 |
276 | if len(self.dm.tree):
277 | self._show_notifications(row)
278 |
279 | def _update_context(self):
280 | new_context = []
281 | p = self.dm.tree
282 | for key in self.context:
283 | if key not in p.notifications:
284 | break
285 | new_context.append(key)
286 | p = p.notifications[key]
287 | if self.context == new_context:
288 | logger.info("Context did not change")
289 | return False
290 |
291 | logger.info("Context updated.")
292 | self.context = new_context
293 | return True
294 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------