├── tokens.py ├── pip_requirements.txt ├── exceptions.py ├── Makefile ├── Dockerfile ├── middlewares.py ├── README.md ├── createdb.sql ├── db.py ├── categories.py ├── .gitignore ├── server.py ├── expenses.py └── LICENSE /tokens.py: -------------------------------------------------------------------------------- 1 | API_TOKEN = "" # Bot API telegram token 2 | ACCESS_ID = "" # Your telegram ID 3 | -------------------------------------------------------------------------------- /pip_requirements.txt: -------------------------------------------------------------------------------- 1 | aiogram==2.4 2 | aiohttp==3.12.14 3 | async-timeout==3.0.1 4 | attrs==19.3.0 5 | Babel==2.9.1 6 | certifi==2024.7.4 7 | chardet==3.0.4 8 | idna==3.7 9 | multidict==4.7.3 10 | pytz==2019.3 11 | yarl==1.4.2 12 | -------------------------------------------------------------------------------- /exceptions.py: -------------------------------------------------------------------------------- 1 | # Custom exceptions generated by the app 2 | 3 | 4 | class NotCorrectMessage(Exception): 5 | """Invalid bot message that could not be parsed""" 6 | pass 7 | 8 | 9 | class NotCorrectExpenseIDToDelete(Exception): 10 | """Invalid expense id in /del command""" 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: 3 | python server.py 4 | 5 | .PHONY: sql 6 | sql: 7 | C:/sqlite/sqlite3 ./db/finance.db < createdb.sql 8 | 9 | .PHONY: ubuntu 10 | ubuntu: 11 | echo "[infos]----->: Creation of DB with sql file-------- :" 12 | sqlite3 /db/finance.db < createdb.sql 13 | echo "[infos]----->: Done------------------------------ :" 14 | 15 | .PHONY: tokens 16 | tokens: 17 | git update-index --assume-unchanged tokens.py 18 | 19 | .DEFAULT_GOAL := build 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | WORKDIR /home 4 | 5 | ENV TELEGRAM_API_TOKEN="" 6 | ENV TELEGRAM_ACCESS_ID="" 7 | ENV TELEGRAM_PROXY_URL="" 8 | ENV TELEGRAM_PROXY_LOGIN="" 9 | ENV TELEGRAM_PROXY_PASSWORD="" 10 | 11 | ENV TZ=Europe/Moscow 12 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 13 | 14 | RUN pip install -U pip aiogram pytz && apt-get update 15 | COPY *.py ./ 16 | #COPY createdb.sql ./ 17 | 18 | ENTRYPOINT ["python", "server.py"] 19 | -------------------------------------------------------------------------------- /middlewares.py: -------------------------------------------------------------------------------- 1 | # Authentication - skip messages from only one Telegram account 2 | 3 | from aiogram import types 4 | from aiogram.dispatcher.handler import CancelHandler 5 | from aiogram.dispatcher.middlewares import BaseMiddleware 6 | 7 | 8 | class AccessMiddleware(BaseMiddleware): 9 | def __init__(self, access_id: int): 10 | self.access_id = access_id 11 | super().__init__() 12 | 13 | async def on_process_message(self, message: types.Message, _): 14 | if int(message.from_user.id) != int(self.access_id): 15 | await message.answer("Access Denied ❌") 16 | raise CancelHandler() 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Minuki

4 |
5 | 6 | [![Badge](https://img.shields.io/badge/Uses-Python-blue.svg?style=flat-square)]("Python") 7 | [![Badge](https://img.shields.io/badge/Open-Source-important.svg?style=flat-square)]("OpenSource") 8 | [![Badge](https://img.shields.io/badge/Made_with-Love-ff69b4.svg?style=flat-square)]("MadeWithLove") 9 | 10 |
11 | 12 | 13 | ## Install 14 | - Install [Python](https://www.python.org/downloads/) 15 | - Clone this repo: `git clone https://github.com/Ythosa/minuki` 16 | - Install dependencies 17 | - Insert bot token and your ID in `API_TOKEN` and `ACCESS_ID` in `tokens.py` 18 | - Done, you can run it by writing `make` command in your cmd :3 19 | 20 | 21 | ## Description 22 | - Telegram bot for recording personal expenses and maintaining a budget 23 | - You can use it to save your expenses and get spending statistics, which will allow you to adjust your spending to your budget 24 | 25 | 26 | ## FAQ 27 | *Q*: How can I help to develop this project? 28 | *A*: You can put a :star: :3 29 | 30 | 31 |
32 | Copyright 2020 Ythosa 33 |
34 | -------------------------------------------------------------------------------- /createdb.sql: -------------------------------------------------------------------------------- 1 | create table budget( 2 | codename varchar(255) primary key, 3 | daily_limit integer 4 | ); 5 | 6 | create table category( 7 | codename varchar(255) primary key, 8 | name varchar(255), 9 | is_base_expense boolean, 10 | aliases text 11 | ); 12 | 13 | create table expense( 14 | id integer primary key, 15 | amount integer, 16 | created datetime, 17 | category_codename integer, 18 | raw_text text, 19 | FOREIGN KEY(category_codename) REFERENCES category(codename) 20 | ); 21 | 22 | insert into category (codename, name, is_base_expense, aliases) 23 | values 24 | ("products", "products", true, "продукты, еда"), 25 | ("coffee", "coffee", true, "кофе, латте"), 26 | ("dinner", "dinner", true, "обед, столовая, ланч, бизнес-ланч, бизнес ланч"), 27 | ("cafe", "cafe", true, "кафе, ресторан, рест, мак, макдональдс, макдак, kfc, ilpatio, il patio, burger king"), 28 | ("transport", "transport", false, "транспорт, метро, автобус, metro"), 29 | ("taxi", "taxi", false, "такси, яндекс такси, yandex taxi, uber,"), 30 | ("phone", "phone", false, "телефон, теле2, связь"), 31 | ("books", "books", false, "книги, литература, литра, лит-ра"), 32 | ("internet", "internet", false, "интернет, инет, inet"), 33 | ("subscriptions", "subscriptions", false, "подписки, подписка"), 34 | ("other", "other", true, "прочее, другое"); 35 | 36 | insert into budget(codename, daily_limit) values ('base', 500); -------------------------------------------------------------------------------- /db.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Dict, List, Any 3 | import sqlite3 4 | 5 | conn = sqlite3.connect(os.path.join("db", "finance.db")) 6 | cursor = conn.cursor() 7 | 8 | 9 | def insert(table: str, column_values: Dict): 10 | columns = ', '.join(column_values.keys()) 11 | values = [tuple(column_values.values())] 12 | placeholders = ", ".join("?" * len(column_values.keys())) 13 | cursor.executemany( 14 | f"INSERT INTO {table} " 15 | f"({columns}) " 16 | f"VALUES ({placeholders})", 17 | values 18 | ) 19 | conn.commit() 20 | 21 | 22 | def fetchall(table: str, columns: List[str]) -> List[Dict[str, Any]]: 23 | columns_joined = ", ".join(columns) 24 | cursor.execute(f"SELECT {columns_joined} FROM {table}") 25 | rows = cursor.fetchall() 26 | result = [] 27 | for row in rows: 28 | dict_row = {} 29 | for index, column in enumerate(columns): 30 | dict_row[column] = row[index] 31 | result.append(dict_row) 32 | return result 33 | 34 | 35 | def delete(table: str, row_id: int) -> None: 36 | row_id = int(row_id) 37 | cursor.execute(f"delete from {table} where id={row_id}") 38 | conn.commit() 39 | 40 | 41 | def get_cursor(): 42 | return cursor 43 | 44 | 45 | def _init_db(): 46 | """Init DB""" 47 | with open("createdb.sql", "r") as f: 48 | sql = f.read() 49 | cursor.executescript(sql) 50 | conn.commit() 51 | 52 | 53 | def check_db_exists(): 54 | """Checks whether the database is initialized; if not, initializes it""" 55 | cursor.execute( 56 | "SELECT name FROM sqlite_master " 57 | "WHERE type='table' AND name='expense'" 58 | ) 59 | table_exists = cursor.fetchall() 60 | if table_exists: 61 | return 62 | _init_db() 63 | 64 | 65 | check_db_exists() 66 | -------------------------------------------------------------------------------- /categories.py: -------------------------------------------------------------------------------- 1 | # Work with categories 2 | from typing import NamedTuple, List, Dict 3 | 4 | import db 5 | 6 | 7 | class Category(NamedTuple): 8 | """Structure of category""" 9 | codename: str 10 | name: str 11 | is_base_expanse: bool 12 | aliases: List[str] 13 | 14 | 15 | class Categories: 16 | def __init__(self): 17 | self._categories = self._load_categories() 18 | 19 | def _load_categories(self): 20 | """Returns a reference list of expense categories from the database""" 21 | categories = db.fetchall( 22 | "category", 23 | "codename name is_base_expense aliases".split() 24 | ) 25 | categories = self._fill_aliases(categories) 26 | 27 | return categories 28 | 29 | @staticmethod 30 | def _fill_aliases(categories: List[Dict]) -> List[Category]: 31 | """Fills in aliases for each category, i.e. possible 32 | names of this category that we can write in the message text. 33 | For example, the category "cafe" can be written as cafe, 34 | a restaurant and so on.""" 35 | categories_result = [] 36 | 37 | for index, category in enumerate(categories): 38 | aliases = category["aliases"].split(",") 39 | aliases = list(filter(None, map(str.strip, aliases))) 40 | 41 | aliases.append(category["codename"]) 42 | aliases.append(category["name"]) 43 | 44 | categories_result.append(Category( 45 | codename=category["codename"], 46 | name=category["name"], 47 | is_base_expanse=category["is_base_expense"], 48 | aliases=aliases 49 | )) 50 | 51 | return categories_result 52 | 53 | def get_all_categories(self) -> List[Category]: 54 | """Returns reference of the categories""" 55 | return self._categories 56 | 57 | def get_category(self, category_name: str) -> Category: 58 | """Returns a category by one of its aliases""" 59 | found = None 60 | other_category = None 61 | 62 | for category in self.get_all_categories(): 63 | if category.codename == "other": 64 | other_category = category 65 | for aliases in category.aliases: 66 | if category_name in aliases: 67 | found = category 68 | 69 | if not found: 70 | found = other_category 71 | 72 | return found 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .idea/ 132 | .vscode/ 133 | 134 | finance.db 135 | db/ -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | # The telegram bot server that runs directly 2 | 3 | from aiogram import Bot, Dispatcher, executor, types 4 | import logging 5 | 6 | import expenses 7 | from exceptions import NotCorrectMessage, NotCorrectExpenseIDToDelete 8 | from middlewares import AccessMiddleware 9 | from tokens import API_TOKEN, ACCESS_ID 10 | 11 | logging.basicConfig(level=logging.INFO) 12 | 13 | bot = Bot(token=API_TOKEN) 14 | dp = Dispatcher(bot) 15 | dp.middleware.setup(AccessMiddleware(ACCESS_ID)) 16 | 17 | 18 | @dp.message_handler(commands=['start', 'help']) 19 | async def send_welcome(message: types.Message): 20 | """Sends a welcome message and help on the bot""" 21 | await message.answer( 22 | "Bot for accounting for finances\n\n" 23 | "Add expense: 250 taxis\n" 24 | "Today's statistics: /today\n" 25 | "For current month: /month\n" 26 | "Last expenses paid: /expenses\n" 27 | "Categories of expenses: /categories") 28 | 29 | 30 | @dp.message_handler(commands=['categories']) 31 | async def categories_list(message: types.Message): 32 | """Sends a list of expense categories""" 33 | categories = expenses.Categories().get_all_categories() 34 | answer_message = "Categories of expenses:\n\n* " + \ 35 | "\n* ".join([c.name + ' (' + ", ".join(c.aliases) + ')' for c in categories]) 36 | await message.answer(answer_message) 37 | 38 | 39 | @dp.message_handler(commands=['expenses']) 40 | async def expenses_list(message: types.Message): 41 | """Sends the last few records on the costs""" 42 | last_expenses = expenses.last() 43 | if not last_expenses: 44 | await message.answer("Expenses haven't been set up yet") 45 | return 46 | 47 | last_expenses_row = [ 48 | f"{expense.amount} rub. of {expense.category_name} — press " 49 | f"/del{expense.id} for removal" 50 | for expense in last_expenses 51 | ] 52 | 53 | answer_message = "Last saved expenses:\n\n* " + "\n\n* ".join(last_expenses_row) 54 | await message.answer(answer_message) 55 | 56 | 57 | @dp.message_handler(lambda message: message.text.startswith('/del')) 58 | async def delete_expense(message: types.Message): 59 | """Deletes a single expense record by its ID""" 60 | row_id = int(message.text[4:]) 61 | 62 | try: 63 | expenses.delete_expense(row_id) 64 | except NotCorrectExpenseIDToDelete as e: 65 | await message.answer(str(e)) 66 | return 67 | 68 | await message.answer("Removed 👌") 69 | 70 | 71 | @dp.message_handler(commands=['today']) 72 | async def today_statistics(message: types.Message): 73 | """Sends today's spending statistics""" 74 | answer_message = expenses.get_today_statistics() 75 | await message.answer(answer_message) 76 | 77 | 78 | @dp.message_handler(commands=['month']) 79 | async def month_statistics(message: types.Message): 80 | """Sends spending statistics for the current month""" 81 | answer_message = expenses.get_month_statistics() 82 | await message.answer(answer_message) 83 | 84 | 85 | @dp.message_handler() 86 | async def add_expense(message: types.Message): 87 | """Adds new expense""" 88 | try: 89 | expense = expenses.add_expense(message.text) 90 | except NotCorrectMessage as e: 91 | await message.answer(str(e)) 92 | return 93 | 94 | answer_message = ( 95 | f"Added expenses {expense.amount} rub on {expense.category_name}.\n\n" 96 | f"{expenses.get_today_statistics()}" 97 | ) 98 | 99 | await message.answer(answer_message) 100 | 101 | 102 | if __name__ == '__main__': 103 | executor.start_polling(dp, skip_updates=True) 104 | -------------------------------------------------------------------------------- /expenses.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | from typing import NamedTuple, Optional, List 4 | import pytz 5 | 6 | import db 7 | from categories import Categories 8 | import exceptions 9 | 10 | 11 | class Message(NamedTuple): 12 | """Structure of a unpaired message about a new expense""" 13 | amount: int 14 | category_text: str 15 | 16 | 17 | class Expense(NamedTuple): 18 | """Structure of a new expense added to the database""" 19 | id: Optional[int] 20 | amount: int 21 | category_name: str 22 | 23 | 24 | def add_expense(raw_message: str) -> Expense: 25 | """Adds a new message. Accepts the text of the message that came to the bot as input.""" 26 | parsed_message = _parse_message(raw_message) 27 | category = Categories().get_category(parsed_message.category_text) 28 | 29 | db.insert("expense", { 30 | "amount": parsed_message.amount, 31 | "created": _get_now_formatted(), 32 | "category_codename": category.codename, 33 | "raw_text": raw_message 34 | }) 35 | 36 | return Expense( 37 | id=None, 38 | amount=parsed_message.amount, 39 | category_name=category.name 40 | ) 41 | 42 | 43 | def last() -> List[Expense]: 44 | """Returns the last few expenses""" 45 | cursor = db.get_cursor() 46 | cursor.execute( 47 | "select e.id, e.amount, c.name " 48 | "from expense e left join category c " 49 | "on c.codename=e.category_codename " 50 | "order by created desc limit 10" 51 | ) 52 | rows = cursor.fetchall() 53 | last_expenses = [Expense(id=row[0], amount=row[1], category_name=row[2]) for row in rows] 54 | return last_expenses 55 | 56 | 57 | def delete_expense(row_id: int): 58 | """Deletes a single expense record by its ID""" 59 | is_exist = False 60 | 61 | expenses = last() 62 | for expense in expenses: 63 | if expense.id == row_id: 64 | is_exist = True 65 | break 66 | 67 | if is_exist: 68 | db.delete("expense", row_id) 69 | else: 70 | raise exceptions.NotCorrectExpenseIDToDelete( 71 | "Invalid expense id ❌\n" 72 | "Write or press the /expenses to get a list of expenses that can be deleted :3" 73 | ) 74 | 75 | 76 | def get_today_statistics() -> str: 77 | """Returns a string of expense statistics for today""" 78 | cursor = db.get_cursor() 79 | cursor.execute( 80 | "select sum(amount)" 81 | "from expense where date(created)=date('now', 'localtime')" 82 | ) 83 | result = cursor.fetchone() 84 | if not result[0]: 85 | return "Today there are no expenses yet" 86 | all_today_expenses = result[0] 87 | 88 | cursor.execute( 89 | "select sum(amount) " 90 | "from expense where date(created)=date('now', 'localtime') " 91 | "and category_codename in (select codename " 92 | "from category where is_base_expense=true)" 93 | ) 94 | result = cursor.fetchone() 95 | base_today_expenses = result[0] if result[0] else 0 96 | 97 | return ( 98 | f"Spending today:\n" 99 | f"Total — {all_today_expenses} rub\n" 100 | f"Base — {base_today_expenses} rub of {_get_budget_limit()} rub\n\n" 101 | f"For current month: /month" 102 | ) 103 | 104 | 105 | def get_month_statistics() -> str: 106 | """Returns a string of expense statistics for the current month""" 107 | now = _get_now_datetime() 108 | first_day_of_month = f'{now.year:04d}-{now.month:02d}-01' 109 | 110 | cursor = db.get_cursor() 111 | cursor.execute( 112 | f"select sum(amount) " 113 | f"from expense where date(created) >= '{first_day_of_month}'" 114 | ) 115 | result = cursor.fetchone() 116 | if not result[0]: 117 | return 'There are no expenses yet this month' 118 | all_today_expenses = result[0] 119 | 120 | cursor.execute( 121 | f"select sum(amount) " 122 | f"from expense where date(created) >= '{first_day_of_month}' " 123 | f"and category_codename in (select codename " 124 | f"from category where is_base_expense=true)" 125 | ) 126 | result = cursor.fetchone() 127 | base_today_expenses = result[0] if result[0] else 0 128 | 129 | return ( 130 | f"Expenses in the current month:\n" 131 | f"Total — {all_today_expenses} rub\n" 132 | f"Base — {base_today_expenses} rub out of " 133 | f"{now.day * _get_budget_limit()} rub" 134 | ) 135 | 136 | 137 | def _parse_message(raw_message: str) -> Message: 138 | """Parses the text of the incoming message about the new expense.""" 139 | regexp_result = re.match(r"([\d ]+) (.*)", raw_message) 140 | if not regexp_result or not regexp_result.group(0) or not regexp_result.group(1) or not regexp_result.group(2): 141 | raise exceptions.NotCorrectMessage( 142 | "I can't understand the message. Write a message in the format, for example:\n\t1500 metro." 143 | ) 144 | 145 | amount = int(regexp_result.group(1).replace(" ", "")) 146 | category_text = regexp_result.group(2).strip().lower() 147 | 148 | return Message(amount=amount, category_text=category_text) 149 | 150 | 151 | def _get_now_formatted() -> str: 152 | """Returns current date by string""" 153 | return _get_now_datetime().strftime("%Y-%m-%d %H:%M:%S") 154 | 155 | 156 | def _get_now_datetime() -> datetime.datetime: 157 | """Returns the current datetime, taking into account the time zone of Moscow Time.""" 158 | tz = pytz.timezone("Europe/Moscow") 159 | now = datetime.datetime.now(tz) 160 | return now 161 | 162 | 163 | def _get_budget_limit() -> int: 164 | """Returns the daily spending limit for basic basic spending""" 165 | return db.fetchall("budget", ["daily_limit"])[0]["daily_limit"] 166 | -------------------------------------------------------------------------------- /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 Ythosa Babin Ruslan Alexandrovich 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 | --------------------------------------------------------------------------------