├── .dockerignore ├── .gitattributes ├── .github └── workflows │ ├── backend_ci.yml │ └── frontend_ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── alembic.ini ├── alembic ├── env.py ├── script.py.mako └── versions │ └── 675e708a78c0_initial_migration.py ├── docker-compose.yml ├── docs ├── notebooks_import_navigate.gif ├── pr_notebook_diff.png └── python_file_diff.png ├── package-lock.json ├── package.json ├── public ├── css │ └── notebook.css ├── img │ ├── favicon.ico │ ├── icon.png │ ├── icon_blue.png │ └── icon_bluelight.png └── index.html ├── requirements.txt ├── run.py ├── setup.cfg └── src ├── __init__.py ├── backend ├── __init__.py ├── api │ ├── __init__.py │ ├── code_repositories.py │ └── notebooks.py ├── api_service.py ├── config.py ├── domain │ └── code_comment.py └── model │ ├── code_repository.py │ └── notebook.py └── frontend ├── App.vue ├── components ├── Loader.vue ├── NavBar.vue ├── SubNav.vue └── codeComment.js ├── main.js ├── router.js ├── shared ├── credentialManager.js ├── getAPIUrl.js └── userCredentials.js ├── store.js └── views ├── Home.vue ├── Login.vue ├── PullRequest.vue ├── PullRequests.vue └── notebooks ├── ImportNotebooks.vue ├── Notebook.vue └── Notebooks.vue /.dockerignore: -------------------------------------------------------------------------------- 1 | venv 2 | node_modules 3 | README.md 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.vue linguist-detectable=false 2 | 3 | # Set the default behavior, in case people don't have core.autocrlf set. 4 | * text=auto 5 | -------------------------------------------------------------------------------- /.github/workflows/backend_ci.yml: -------------------------------------------------------------------------------- 1 | name: Backend CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | max-parallel: 2 11 | matrix: 12 | python-version: [3.8] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -r requirements.txt 24 | - name: Lint with pylint 25 | run: | 26 | pip install pylint 27 | pylint -E src/backend 28 | -------------------------------------------------------------------------------- /.github/workflows/frontend_ci.yml: -------------------------------------------------------------------------------- 1 | name: Frontend CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Use Node.js 12.x 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 12.x 16 | - name: npm install, lint, and build 17 | run: | 18 | npm install 19 | npm run lint 20 | npm run build 21 | env: 22 | CI: true 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__ 3 | .idea 4 | .vscode 5 | venv 6 | node_modules 7 | src/backend/static/ 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nikolaik/python-nodejs:python3.8-nodejs16 2 | 3 | LABEL org.opencontainers.image.authors="rsn_4_91@hotmail.com" 4 | 5 | WORKDIR /app 6 | 7 | COPY . /app 8 | 9 | RUN pip install -r requirements.txt 10 | RUN npm install 11 | RUN npm run build 12 | 13 | ENTRYPOINT [ "python" ] 14 | 15 | CMD ["run.py" ] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ricardo Neves 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Koopera 2 | ========= 3 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/rsn491/koopera) 4 | ![Frontend CI](https://github.com/rsn491/koopera/workflows/Frontend%20CI/badge.svg?branch=master) 5 | ![Backend CI](https://github.com/rsn491/koopera/workflows/Backend%20CI/badge.svg?branch=master) 6 | 7 | Koopera 12 | 13 | **Koopera** is a collaboration app that enables Data Science teams to share and review their jupyter notebooks. 14 | 15 | # Features 16 | * Review jupyter notebooks by looking at the diffs and commenting directly on the cells 17 | * Review source code with syntax highlight for python, scala, etc. (using [diff2html](https://github.com/rtfpessoa/diff2html)) 18 | * Import notebooks from your GitHub repositories 19 | * Navigate through your notebooks 20 | * GitHub authentication via personal access token 21 | 22 | # Screenshots 23 | 24 | **Review jupyter notebooks** 25 | 26 |
27 | 28 | **Review python code** 29 | 30 |
31 | 32 | **Import and navigate through your notebooks** 33 | 34 |
35 | 36 | # Quickstart 37 | You can easily start using Koopera with docker in just two steps. 38 | 39 | 1. Build and start Koopera + dependencies: 40 | 41 | `docker-compose up --build -d` 42 | 43 | 1. Run migrations: 44 | 45 | `docker-compose run --rm koopera alembic upgrade head` 46 | 47 | When the migration ends, you can now access Koopera at: [http://0.0.0.0:8080](http://0.0.0.0:8080) 48 | 49 | 50 | # Setting up dev environment 51 | 52 | ## Dependencies 53 | * Python 3.6 54 | * nodejs 16 55 | 56 | ## Setting up backend for local development 57 | 58 | 1. Enable CORS by setting `ALLOW_CORS` to `True` in [src/backend/config.py](src/backend/config.py) 59 | 60 | 2. Create virtualenv 61 | 62 | `python -m venv venv` 63 | 64 | 3. Activate virtualenv 65 | 66 | `source venv/bin/activate` 67 | 68 | 4. Install dependencies 69 | 70 | `pip install -r requirements.txt` 71 | 72 | 5. Run migrations 73 | `alembic upgrade head` 74 | 75 | 6. Start flask app on default port (5000) 76 | 77 | `python run.py` 78 | 79 | ## Setting up frontend for local development 80 | 81 | 1. Install dependencies 82 | 83 | `npm install` 84 | 2. Run web server for serving frontend 85 | 86 | `npm run serve` 87 | 88 | A web server for serving frontend with hot reload will be listening on 89 | [http://localhost:8080](http://localhost:8080) 90 | 91 | # Credits 92 | 93 |
Icons made by Nikita Golubev from www.flaticon.com is licensed by CC 3.0 BY
94 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | # truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to alembic/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat alembic/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # sqlalchemy.url = driver://user:pass@localhost/dbname 39 | 40 | 41 | [post_write_hooks] 42 | # post_write_hooks defines scripts or Python functions that are run 43 | # on newly generated revision scripts. See the documentation for further 44 | # detail and examples 45 | 46 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 47 | # hooks=black 48 | # black.type=console_scripts 49 | # black.entrypoint=black 50 | # black.options=-l 79 51 | 52 | # Logging configuration 53 | [loggers] 54 | keys = root,sqlalchemy,alembic 55 | 56 | [handlers] 57 | keys = console 58 | 59 | [formatters] 60 | keys = generic 61 | 62 | [logger_root] 63 | level = WARN 64 | handlers = console 65 | qualname = 66 | 67 | [logger_sqlalchemy] 68 | level = WARN 69 | handlers = 70 | qualname = sqlalchemy.engine 71 | 72 | [logger_alembic] 73 | level = INFO 74 | handlers = 75 | qualname = alembic 76 | 77 | [handler_console] 78 | class = StreamHandler 79 | args = (sys.stderr,) 80 | level = NOTSET 81 | formatter = generic 82 | 83 | [formatter_generic] 84 | format = %(levelname)-5.5s [%(name)s] %(message)s 85 | datefmt = %H:%M:%S 86 | -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from logging.config import fileConfig 4 | 5 | from alembic import context 6 | from sqlalchemy import engine_from_config, pool 7 | 8 | sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 9 | 10 | from src.backend.config import DATABASE_URI 11 | 12 | from src.backend.model.code_repository import CodeRepositoryBase 13 | from src.backend.model.notebook import NotebookBase 14 | 15 | 16 | # this is the Alembic Config object, which provides 17 | # access to the values within the .ini file in use. 18 | config = context.config 19 | 20 | # Interpret the config file for Python logging. 21 | # This line sets up loggers basically. 22 | fileConfig(config.config_file_name) 23 | config.set_main_option('sqlalchemy.url', DATABASE_URI) 24 | 25 | # add your model's MetaData object here 26 | # for 'autogenerate' support 27 | # from myapp import mymodel 28 | # target_metadata = mymodel.Base.metadata 29 | target_metadata = [CodeRepositoryBase.metadata, NotebookBase.metadata] 30 | 31 | # other values from the config, defined by the needs of env.py, 32 | # can be acquired: 33 | # my_important_option = config.get_main_option("my_important_option") 34 | # ... etc. 35 | 36 | 37 | def run_migrations_offline(): 38 | """Run migrations in 'offline' mode. 39 | 40 | This configures the context with just a URL 41 | and not an Engine, though an Engine is acceptable 42 | here as well. By skipping the Engine creation 43 | we don't even need a DBAPI to be available. 44 | 45 | Calls to context.execute() here emit the given string to the 46 | script output. 47 | 48 | """ 49 | url = config.get_main_option("sqlalchemy.url") 50 | context.configure( 51 | url=url, 52 | target_metadata=target_metadata, 53 | literal_binds=True, 54 | dialect_opts={"paramstyle": "named"}, 55 | ) 56 | 57 | with context.begin_transaction(): 58 | context.run_migrations() 59 | 60 | 61 | def run_migrations_online(): 62 | """Run migrations in 'online' mode. 63 | 64 | In this scenario we need to create an Engine 65 | and associate a connection with the context. 66 | 67 | """ 68 | connectable = engine_from_config( 69 | config.get_section(config.config_ini_section), 70 | prefix="sqlalchemy.", 71 | poolclass=pool.NullPool, 72 | ) 73 | 74 | with connectable.connect() as connection: 75 | context.configure( 76 | connection=connection, target_metadata=target_metadata 77 | ) 78 | 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | 82 | 83 | if context.is_offline_mode(): 84 | run_migrations_offline() 85 | else: 86 | run_migrations_online() 87 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /alembic/versions/675e708a78c0_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: 675e708a78c0 4 | Revises: 5 | Create Date: 2020-01-03 22:05:19.343989 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '675e708a78c0' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('code_repositories', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(), nullable=True), 24 | sa.Column('owner', sa.String(), nullable=True), 25 | sa.PrimaryKeyConstraint('id') 26 | ) 27 | op.create_table('notebooks', 28 | sa.Column('id', sa.Integer(), nullable=False), 29 | sa.Column('code_repo_id', sa.Integer(), nullable=True), 30 | sa.Column('sha', sa.String(), nullable=True), 31 | sa.Column('path', sa.String(), nullable=True), 32 | sa.Column('title', sa.String(), nullable=True), 33 | sa.Column('summary', sa.String(), nullable=True), 34 | sa.ForeignKeyConstraint(['code_repo_id'], ['code_repositories.id'], ), 35 | sa.PrimaryKeyConstraint('id') 36 | ) 37 | # ### end Alembic commands ### 38 | 39 | 40 | def downgrade(): 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | op.drop_table('notebooks') 43 | op.drop_table('code_repositories') 44 | # ### end Alembic commands ### 45 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | koopera: 5 | image: koopera 6 | build: . 7 | links: 8 | - postgres:postgres 9 | ports: 10 | - "5000:5000" 11 | environment: 12 | - DATABASE_URI=postgres+psycopg2://postgres:password@postgres:5432/koopera 13 | postgres: 14 | image: postgres:12.0-alpine 15 | volumes: 16 | - postgres_data:/var/lib/postgresql/data/ 17 | environment: 18 | - POSTGRES_USER=postgres 19 | - POSTGRES_PASSWORD=password 20 | - POSTGRES_DB=koopera 21 | expose: 22 | - "5432" 23 | 24 | volumes: 25 | postgres_data: 26 | -------------------------------------------------------------------------------- /docs/notebooks_import_navigate.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsn491/koopera/7760786a024388575f85366a951993114e123e99/docs/notebooks_import_navigate.gif -------------------------------------------------------------------------------- /docs/pr_notebook_diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsn491/koopera/7760786a024388575f85366a951993114e123e99/docs/pr_notebook_diff.png -------------------------------------------------------------------------------- /docs/python_file_diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsn491/koopera/7760786a024388575f85366a951993114e123e99/docs/python_file_diff.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "bootstrap": "^5.2.1", 12 | "diff2html": "^3.4.19", 13 | "htmldiff-js": "^1.0.5", 14 | "vue": "^2.6.12", 15 | "vue-router": "^3.5.1" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "^7.19.1", 19 | "@babel/eslint-parser": "^7.19.1", 20 | "@vue/cli-plugin-babel": "^5.0.8", 21 | "@vue/cli-plugin-eslint": "^5.0.8", 22 | "@vue/cli-service": "^5.0.8", 23 | "eslint": "^8.23.1", 24 | "eslint-plugin-vue": "^9.5.1", 25 | "vue-template-compiler": "^2.7.10" 26 | }, 27 | "eslintConfig": { 28 | "root": true, 29 | "env": { 30 | "node": true 31 | }, 32 | "extends": [ 33 | "plugin:vue/essential" 34 | ], 35 | "rules": { 36 | "vue/multi-word-component-names": "off", 37 | "no-underscore-dangle": "off", 38 | "comma-dangle": "off", 39 | "no-alert": "off", 40 | "linebreak-style": "off" 41 | }, 42 | "parserOptions": { 43 | "parser": "@babel/eslint-parser" 44 | } 45 | }, 46 | "eslintIgnore": [ 47 | "src/backend/static" 48 | ], 49 | "postcss": { 50 | "plugins": { 51 | "autoprefixer": {} 52 | } 53 | }, 54 | "browserslist": [ 55 | "> 1%", 56 | "last 2 versions" 57 | ], 58 | "babel": { 59 | "presets": [ 60 | "@vue/app" 61 | ] 62 | }, 63 | "vue": { 64 | "outputDir": "src/backend/static", 65 | "pages": { 66 | "index": { 67 | "entry": "src/frontend/main.js" 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /public/css/notebook.css: -------------------------------------------------------------------------------- 1 | .koopera-nb .img-responsive,.koopera-nb .thumbnail>img,.koopera-nb .thumbnail a>img,.koopera-nb .carousel-inner>.item>img,.koopera-nb .carousel-inner>.item>a>img{display:block;max-width:100%;height:auto}.koopera-nb .img-rounded{border-radius:3px}.koopera-nb .img-thumbnail{padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:2px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out;display:inline-block;max-width:100%;height:auto}.koopera-nb .img-circle{border-radius:50%}.koopera-nb hr{margin-top:18px;margin-bottom:18px;border:0;border-top:1px solid #eee}.koopera-nb .sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);border:0}.koopera-nb .sr-only-focusable:active,.koopera-nb .sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role="button"]{cursor:pointer}.koopera-nb h1,.koopera-nb h2,.koopera-nb h3,.koopera-nb h4,.koopera-nb h5,.koopera-nb h6,.koopera-nb .h1,.koopera-nb .h2,.koopera-nb .h3,.koopera-nb .h4,.koopera-nb .h5,.koopera-nb .h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.koopera-nb h1 small,.koopera-nb h2 small,.koopera-nb h3 small,.koopera-nb h4 small,.koopera-nb h5 small,.koopera-nb h6 small,.koopera-nb .h1 small,.koopera-nb .h2 small,.koopera-nb .h3 small,.koopera-nb .h4 small,.koopera-nb .h5 small,.koopera-nb .h6 small,.koopera-nb h1 .small,.koopera-nb h2 .small,.koopera-nb h3 .small,.koopera-nb h4 .small,.koopera-nb h5 .small,.koopera-nb h6 .small,.koopera-nb .h1 .small,.koopera-nb .h2 .small,.koopera-nb .h3 .small,.koopera-nb .h4 .small,.koopera-nb .h5 .small,.koopera-nb .h6 .small{font-weight:400;line-height:1;color:#777}.koopera-nb h1,.koopera-nb .h1,.koopera-nb h2,.koopera-nb .h2,.koopera-nb h3,.koopera-nb .h3{margin-top:18px;margin-bottom:9px}.koopera-nb h1 small,.koopera-nb .h1 small,.koopera-nb h2 small,.koopera-nb .h2 small,.koopera-nb h3 small,.koopera-nb .h3 small,.koopera-nb h1 .small,.koopera-nb .h1 .small,.koopera-nb h2 .small,.koopera-nb .h2 .small,.koopera-nb h3 .small,.koopera-nb .h3 .small{font-size:65%}.koopera-nb h4,.koopera-nb .h4,.koopera-nb h5,.koopera-nb .h5,.koopera-nb h6,.koopera-nb .h6{margin-top:9px;margin-bottom:9px}.koopera-nb h4 small,.koopera-nb .h4 small,.koopera-nb h5 small,.koopera-nb .h5 small,.koopera-nb h6 small,.koopera-nb .h6 small,.koopera-nb h4 .small,.koopera-nb .h4 .small,.koopera-nb h5 .small,.koopera-nb .h5 .small,.koopera-nb h6 .small,.koopera-nb .h6 .small{font-size:75%}.koopera-nb h1,.koopera-nb .h1{font-size:33px}.koopera-nb h2,.koopera-nb .h2{font-size:27px}.koopera-nb h3,.koopera-nb .h3{font-size:23px}.koopera-nb h4,.koopera-nb .h4{font-size:17px}.koopera-nb h5,.koopera-nb .h5{font-size:13px}.koopera-nb h6,.koopera-nb .h6{font-size:12px}.koopera-nb p{margin:0 0 9px}.koopera-nb .lead{margin-bottom:18px;font-size:14px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:19.5px}}.koopera-nb small,.koopera-nb .small{font-size:92%}.koopera-nb mark,.koopera-nb .mark{background-color:#fcf8e3;padding:.2em}.koopera-nb .text-left{text-align:left}.koopera-nb .text-right{text-align:right}.koopera-nb .text-center{text-align:center}.koopera-nb .text-justify{text-align:justify}.koopera-nb .text-nowrap{white-space:nowrap}.koopera-nb .text-lowercase{text-transform:lowercase}.koopera-nb .text-uppercase{text-transform:uppercase}.koopera-nb .text-capitalize{text-transform:capitalize}.koopera-nb .text-muted{color:#777}.koopera-nb .text-primary{color:#337ab7}.koopera-nb a.text-primary:hover,.koopera-nb a.text-primary:focus{color:#286090}.koopera-nb .text-success{color:#3c763d}.koopera-nb a.text-success:hover,.koopera-nb a.text-success:focus{color:#2b542c}.koopera-nb .text-info{color:#31708f}.koopera-nb a.text-info:hover,.koopera-nb a.text-info:focus{color:#245269}.koopera-nb .text-warning{color:#8a6d3b}.koopera-nb a.text-warning:hover,.koopera-nb a.text-warning:focus{color:#66512c}.koopera-nb .text-danger{color:#a94442}.koopera-nb a.text-danger:hover,.koopera-nb a.text-danger:focus{color:#843534}.koopera-nb .bg-primary{color:#fff;background-color:#337ab7}.koopera-nb a.bg-primary:hover,.koopera-nb a.bg-primary:focus{background-color:#286090}.koopera-nb .bg-success{background-color:#dff0d8}.koopera-nb a.bg-success:hover,.koopera-nb a.bg-success:focus{background-color:#c1e2b3}.koopera-nb .bg-info{background-color:#d9edf7}.koopera-nb a.bg-info:hover,.koopera-nb a.bg-info:focus{background-color:#afd9ee}.koopera-nb .bg-warning{background-color:#fcf8e3}.koopera-nb a.bg-warning:hover,.koopera-nb a.bg-warning:focus{background-color:#f7ecb5}.koopera-nb .bg-danger{background-color:#f2dede}.koopera-nb a.bg-danger:hover,.koopera-nb a.bg-danger:focus{background-color:#e4b9b9}.koopera-nb .page-header{padding-bottom:8px;margin:36px 0 18px;border-bottom:1px solid #eee}.koopera-nb ul,.koopera-nb ol{margin-top:0;margin-bottom:9px}.koopera-nb ul ul,.koopera-nb ol ul,.koopera-nb ul ol,.koopera-nb ol ol{margin-bottom:0}.koopera-nb .list-unstyled{padding-left:0;list-style:none}.koopera-nb .list-inline{padding-left:0;list-style:none;margin-left:-5px}.koopera-nb .list-inline>li{display:inline-block;padding-left:5px;padding-right:5px}.koopera-nb dl{margin-top:0;margin-bottom:18px}.koopera-nb dt,.koopera-nb dd{line-height:1.42857143}.koopera-nb dt{font-weight:700}.koopera-nb dd{margin-left:0}@media (min-width:541px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}.koopera-nb abbr[title],.koopera-nb abbr[data-original-title]{cursor:help;border-bottom:1px dotted #777}.koopera-nb .initialism{font-size:90%;text-transform:uppercase}.koopera-nb blockquote{padding:9px 18px;margin:0 0 18px;font-size:inherit;border-left:5px solid #eee}.koopera-nb blockquote p:last-child,.koopera-nb blockquote ul:last-child,.koopera-nb blockquote ol:last-child{margin-bottom:0}.koopera-nb blockquote footer,.koopera-nb blockquote small,.koopera-nb blockquote .small{display:block;font-size:80%;line-height:1.42857143;color:#777}.koopera-nb blockquote footer:before,.koopera-nb blockquote small:before,.koopera-nb blockquote .small:before{content:\'\\2014 \\00A0\'}.koopera-nb .blockquote-reverse,.koopera-nb blockquote.pull-right{padding-right:15px;padding-left:0;border-right:5px solid #eee;border-left:0;text-align:right}.koopera-nb .blockquote-reverse footer:before,.koopera-nb blockquote.pull-right footer:before,.koopera-nb .blockquote-reverse small:before,.koopera-nb blockquote.pull-right small:before,.koopera-nb .blockquote-reverse .small:before,.koopera-nb blockquote.pull-right .small:before{content:\'\'}.koopera-nb .blockquote-reverse footer:after,.koopera-nb blockquote.pull-right footer:after,.koopera-nb .blockquote-reverse small:after,.koopera-nb blockquote.pull-right small:after,.koopera-nb .blockquote-reverse .small:after,.koopera-nb blockquote.pull-right .small:after{content:\'\\00A0 \\2014\'}.koopera-nb address{margin-bottom:18px;font-style:normal;line-height:1.42857143}.koopera-nb code,.koopera-nb kbd,.koopera-nb pre,.koopera-nb samp{font-family:monospace}.koopera-nb code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:2px}.koopera-nb kbd{padding:2px 4px;font-size:90%;color:#888;background-color:transparent;border-radius:1px;box-shadow:inset 0 -1px 0 rgba(0,0,0,0.25)}.koopera-nb kbd kbd{padding:0;font-size:100%;font-weight:700;box-shadow:none}.koopera-nb pre{display:block;padding:8.5px;margin:0 0 9px;font-size:12px;line-height:1.42857143;word-break:break-all;word-wrap:break-word;color:#333;background-color:#f5f5f5;border:1px solid #ccc;border-radius:2px}.koopera-nb pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.koopera-nb .pre-scrollable{max-height:340px;overflow-y:scroll}@media (min-width: 768px){.container{width:768px}}@media (min-width: 992px){.container{width:940px}}@media (min-width: 1200px){.container{width:1140px}}.koopera-nb .container-fluid{margin-right:auto;margin-left:auto;padding-left:0;padding-right:0}.koopera-nb .row{margin-left:0;margin-right:0}.koopera-nb .koopera-nb .col-xs-1,.col-sm-1,.col-md-1,.col-lg-1,.col-xs-2,.col-sm-2,.col-md-2,.col-lg-2,.col-xs-3,.col-sm-3,.col-md-3,.col-lg-3,.col-xs-4,.col-sm-4,.col-md-4,.col-lg-4,.col-xs-5,.col-sm-5,.col-md-5,.col-lg-5,.col-xs-6,.col-sm-6,.col-md-6,.col-lg-6,.col-xs-7,.col-sm-7,.col-md-7,.col-lg-7,.col-xs-8,.col-sm-8,.col-md-8,.col-lg-8,.col-xs-9,.col-sm-9,.col-md-9,.col-lg-9,.col-xs-10,.col-sm-10,.col-md-10,.col-lg-10,.col-xs-11,.col-sm-11,.col-md-11,.col-lg-11,.col-xs-12,.col-sm-12,.col-md-12,.col-lg-12{position:relative;min-height:1px;padding-left:0;padding-right:0}.koopera-nb .koopera-nb .col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{float:left}.koopera-nb .col-xs-12{width:100%}.koopera-nb .col-xs-11{width:91.66666667%}.koopera-nb .col-xs-10{width:83.33333333%}.koopera-nb .col-xs-9{width:75%}.koopera-nb .col-xs-8{width:66.66666667%}.koopera-nb .col-xs-7{width:58.33333333%}.koopera-nb .col-xs-6{width:50%}.koopera-nb .col-xs-5{width:41.66666667%}.koopera-nb .col-xs-4{width:33.33333333%}.koopera-nb .col-xs-3{width:25%}.koopera-nb .col-xs-2{width:16.66666667%}.koopera-nb .col-xs-1{width:8.33333333%}.koopera-nb .col-xs-pull-12{right:100%}.koopera-nb .col-xs-pull-11{right:91.66666667%}.koopera-nb .col-xs-pull-10{right:83.33333333%}.koopera-nb .col-xs-pull-9{right:75%}.koopera-nb .col-xs-pull-8{right:66.66666667%}.koopera-nb .col-xs-pull-7{right:58.33333333%}.koopera-nb .col-xs-pull-6{right:50%}.koopera-nb .col-xs-pull-5{right:41.66666667%}.koopera-nb .col-xs-pull-4{right:33.33333333%}.koopera-nb .col-xs-pull-3{right:25%}.koopera-nb .col-xs-pull-2{right:16.66666667%}.koopera-nb .col-xs-pull-1{right:8.33333333%}.koopera-nb .col-xs-pull-0{right:auto}.koopera-nb .col-xs-push-12{left:100%}.koopera-nb .col-xs-push-11{left:91.66666667%}.koopera-nb .col-xs-push-10{left:83.33333333%}.koopera-nb .col-xs-push-9{left:75%}.koopera-nb .col-xs-push-8{left:66.66666667%}.koopera-nb .col-xs-push-7{left:58.33333333%}.koopera-nb .col-xs-push-6{left:50%}.koopera-nb .col-xs-push-5{left:41.66666667%}.koopera-nb .col-xs-push-4{left:33.33333333%}.koopera-nb .col-xs-push-3{left:25%}.koopera-nb .col-xs-push-2{left:16.66666667%}.koopera-nb .col-xs-push-1{left:8.33333333%}.koopera-nb .col-xs-push-0{left:auto}.koopera-nb .col-xs-offset-12{margin-left:100%}.koopera-nb .col-xs-offset-11{margin-left:91.66666667%}.koopera-nb .col-xs-offset-10{margin-left:83.33333333%}.koopera-nb .col-xs-offset-9{margin-left:75%}.koopera-nb .col-xs-offset-8{margin-left:66.66666667%}.koopera-nb .col-xs-offset-7{margin-left:58.33333333%}.koopera-nb .col-xs-offset-6{margin-left:50%}.koopera-nb .col-xs-offset-5{margin-left:41.66666667%}.koopera-nb .col-xs-offset-4{margin-left:33.33333333%}.koopera-nb .col-xs-offset-3{margin-left:25%}.koopera-nb .col-xs-offset-2{margin-left:16.66666667%}.koopera-nb .col-xs-offset-1{margin-left:8.33333333%}.koopera-nb .col-xs-offset-0{margin-left:0}@media (min-width: 768px){.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width: 992px){.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width: 1200px){.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}.koopera-nb table{background-color:transparent}.koopera-nb caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}.koopera-nb th{text-align:left}.koopera-nb .table{width:100%;max-width:100%;margin-bottom:18px}.koopera-nb .table > thead > tr > th,.koopera-nb .table > tbody > tr > th,.koopera-nb .table > tfoot > tr > th,.koopera-nb .table > thead > tr > td,.koopera-nb .table > tbody > tr > td,.koopera-nb .table > tfoot > tr > td{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.koopera-nb .table > thead > tr > th{vertical-align:bottom;border-bottom:2px solid #ddd}.koopera-nb .table > caption + thead > tr:first-child > th,.koopera-nb .table > colgroup + thead > tr:first-child > th,.koopera-nb .table > thead:first-child > tr:first-child > th,.koopera-nb .table > caption + thead > tr:first-child > td,.koopera-nb .table > colgroup + thead > tr:first-child > td,.koopera-nb .table > thead:first-child > tr:first-child > td{border-top:0}.koopera-nb .table > tbody + tbody{border-top:2px solid #ddd}.koopera-nb .table .table{background-color:#fff}.koopera-nb .table-condensed > thead > tr > th,.koopera-nb .table-condensed > tbody > tr > th,.koopera-nb .table-condensed > tfoot > tr > th,.koopera-nb .table-condensed > thead > tr > td,.koopera-nb .table-condensed > tbody > tr > td,.koopera-nb .table-condensed > tfoot > tr > td{padding:5px}.koopera-nb .table-bordered{border:1px solid #ddd}.koopera-nb .table-bordered > thead > tr > th,.koopera-nb .table-bordered > tbody > tr > th,.koopera-nb .table-bordered > tfoot > tr > th,.koopera-nb .table-bordered > thead > tr > td,.koopera-nb .table-bordered > tbody > tr > td,.koopera-nb .table-bordered > tfoot > tr > td{border:1px solid #ddd}.koopera-nb .table-bordered > thead > tr > th,.koopera-nb .table-bordered > thead > tr > td{border-bottom-width:2px}.koopera-nb .table-striped > tbody > tr:nth-of-type(odd){background-color:#f9f9f9}.koopera-nb .table-hover > tbody > tr:hover{background-color:#f5f5f5}.koopera-nb table col[class*="col-"]{position:static;float:none;display:table-column}.koopera-nb table td[class*="col-"],.koopera-nb table th[class*="col-"]{position:static;float:none;display:table-cell}.koopera-nb .table > thead > tr > td.active,.koopera-nb .table > tbody > tr > td.active,.koopera-nb .table > tfoot > tr > td.active,.koopera-nb .table > thead > tr > th.active,.koopera-nb .table > tbody > tr > th.active,.koopera-nb .table > tfoot > tr > th.active,.koopera-nb .table > thead > tr.active > td,.koopera-nb .table > tbody > tr.active > td,.koopera-nb .table > tfoot > tr.active > td,.koopera-nb .table > thead > tr.active > th,.koopera-nb .table > tbody > tr.active > th,.koopera-nb .table > tfoot > tr.active > th{background-color:#f5f5f5}.koopera-nb .table-hover > tbody > tr > td.active:hover,.koopera-nb .table-hover > tbody > tr > th.active:hover,.koopera-nb .table-hover > tbody > tr.active:hover > td,.koopera-nb .table-hover > tbody > tr:hover > .active,.koopera-nb .table-hover > tbody > tr.active:hover > th{background-color:#e8e8e8}.koopera-nb .table > thead > tr > td.success,.koopera-nb .table > tbody > tr > td.success,.koopera-nb .table > tfoot > tr > td.success,.koopera-nb .table > thead > tr > th.success,.koopera-nb .table > tbody > tr > th.success,.koopera-nb .table > tfoot > tr > th.success,.koopera-nb .table > thead > tr.success > td,.koopera-nb .table > tbody > tr.success > td,.koopera-nb .table > tfoot > tr.success > td,.koopera-nb .table > thead > tr.success > th,.koopera-nb .table > tbody > tr.success > th,.koopera-nb .table > tfoot > tr.success > th{background-color:#dff0d8}.koopera-nb .table-hover > tbody > tr > td.success:hover,.koopera-nb .table-hover > tbody > tr > th.success:hover,.koopera-nb .table-hover > tbody > tr.success:hover > td,.koopera-nb .table-hover > tbody > tr:hover > .success,.koopera-nb .table-hover > tbody > tr.success:hover > th{background-color:#d0e9c6}.koopera-nb .table > thead > tr > td.info,.koopera-nb .table > tbody > tr > td.info,.koopera-nb .table > tfoot > tr > td.info,.koopera-nb .table > thead > tr > th.info,.koopera-nb .table > tbody > tr > th.info,.koopera-nb .table > tfoot > tr > th.info,.koopera-nb .table > thead > tr.info > td,.koopera-nb .table > tbody > tr.info > td,.koopera-nb .table > tfoot > tr.info > td,.koopera-nb .table > thead > tr.info > th,.koopera-nb .table > tbody > tr.info > th,.koopera-nb .table > tfoot > tr.info > th{background-color:#d9edf7}.koopera-nb .table-hover > tbody > tr > td.info:hover,.koopera-nb .table-hover > tbody > tr > th.info:hover,.koopera-nb .table-hover > tbody > tr.info:hover > td,.koopera-nb .table-hover > tbody > tr:hover > .info,.koopera-nb .table-hover > tbody > tr.info:hover > th{background-color:#c4e3f3}.koopera-nb .command_mode .modal_indicator:before.fa-pull-left{margin-right:.3em}.koopera-nb .command_mode .modal_indicator:before.fa-pull-right{margin-left:.3em}.koopera-nb .command_mode .modal_indicator:before.pull-left{margin-right:.3em}.koopera-nb .command_mode .modal_indicator:before.pull-right{margin-left:.3em}.koopera-nb .kernel_idle_icon:before{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;content:"\\f10c"}.koopera-nb .kernel_idle_icon:before.fa-pull-left{margin-right:.3em}.koopera-nb .kernel_idle_icon:before.fa-pull-right{margin-left:.3em}.koopera-nb .kernel_idle_icon:before.pull-left{margin-right:.3em}.koopera-nb .kernel_idle_icon:before.pull-right{margin-left:.3em}.koopera-nb .kernel_busy_icon:before{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;content:"\\f111"}.koopera-nb .kernel_busy_icon:before.fa-pull-left{margin-right:.3em}.koopera-nb .kernel_busy_icon:before.fa-pull-right{margin-left:.3em}.koopera-nb .kernel_busy_icon:before.pull-left{margin-right:.3em}.koopera-nb .kernel_busy_icon:before.pull-right{margin-left:.3em}.koopera-nb .kernel_dead_icon:before{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;content:"\\f1e2"}.koopera-nb .kernel_dead_icon:before.fa-pull-left{margin-right:.3em}.koopera-nb .kernel_dead_icon:before.fa-pull-right{margin-left:.3em}.koopera-nb .kernel_dead_icon:before.pull-left{margin-right:.3em}.koopera-nb .kernel_dead_icon:before.pull-right{margin-left:.3em}.koopera-nb .kernel_disconnected_icon:before{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;content:"\\f127"}.koopera-nb .kernel_disconnected_icon:before.fa-pull-left{margin-right:.3em}.koopera-nb .kernel_disconnected_icon:before.fa-pull-right{margin-left:.3em}.koopera-nb .kernel_disconnected_icon:before.pull-left{margin-right:.3em}.koopera-nb .kernel_disconnected_icon:before.pull-right{margin-left:.3em}.koopera-nb .notification_widget{color:#777;z-index:10;background:rgba(240,240,240,0.5);margin-right:4px;color:#333;background-color:#fff;border-color:#ccc}.koopera-nb .notification_widget:focus,.koopera-nb .notification_widget.focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.koopera-nb .notification_widget:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.koopera-nb .notification_widget:active,.koopera-nb .notification_widget.active,.koopera-nb .open > .dropdown-toggle.notification_widget{color:#333;background-color:#e6e6e6;border-color:#adadad}.koopera-nb .notification_widget:active:hover,.koopera-nb .notification_widget.active:hover,.koopera-nb .open > .dropdown-toggle.notification_widget:hover,.koopera-nb .notification_widget:active:focus,.koopera-nb .notification_widget.active:focus,.koopera-nb .open > .dropdown-toggle.notification_widget:focus,.koopera-nb .notification_widget:active.focus,.koopera-nb .notification_widget.active.focus,.koopera-nb .open > .dropdown-toggle.notification_widget.focus{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.koopera-nb .notification_widget:active,.koopera-nb .notification_widget.active,.koopera-nb .open > .dropdown-toggle.notification_widget{background-image:none}.koopera-nb .notification_widget.disabled:hover,.koopera-nb .notification_widget[disabled]:hover,.koopera-nb fieldset[disabled] .notification_widget:hover,.koopera-nb .notification_widget.disabled:focus,.koopera-nb .notification_widget[disabled]:focus,.koopera-nb fieldset[disabled] .notification_widget:focus,.koopera-nb .notification_widget.disabled.focus,.koopera-nb .notification_widget[disabled].focus,.koopera-nb fieldset[disabled] .notification_widget.focus{background-color:#fff;border-color:#ccc}.koopera-nb .notification_widget .badge{color:#fff;background-color:#333}.koopera-nb .notification_widget.warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.koopera-nb .notification_widget.warning:focus,.koopera-nb .notification_widget.warning.focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.koopera-nb .notification_widget.warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.koopera-nb .notification_widget.warning:active,.koopera-nb .notification_widget.warning.active,.koopera-nb .open > .dropdown-toggle.notification_widget.warning{color:#fff;background-color:#ec971f;border-color:#d58512}.koopera-nb .notification_widget.warning:active:hover,.koopera-nb .notification_widget.warning.active:hover,.koopera-nb .open > .dropdown-toggle.notification_widget.warning:hover,.koopera-nb .notification_widget.warning:active:focus,.koopera-nb .notification_widget.warning.active:focus,.koopera-nb .open > .dropdown-toggle.notification_widget.warning:focus,.koopera-nb .notification_widget.warning:active.focus,.koopera-nb .notification_widget.warning.active.focus,.koopera-nb .open > .dropdown-toggle.notification_widget.warning.focus{color:#fff;background-color:#d58512;border-color:#985f0d}.koopera-nb .notification_widget.warning:active,.koopera-nb .notification_widget.warning.active,.koopera-nb .open > .dropdown-toggle.notification_widget.warning{background-image:none}.koopera-nb .notification_widget.warning.disabled:hover,.koopera-nb .notification_widget.warning[disabled]:hover,.koopera-nb fieldset[disabled] .notification_widget.warning:hover,.koopera-nb .notification_widget.warning.disabled:focus,.koopera-nb .notification_widget.warning[disabled]:focus,.koopera-nb fieldset[disabled] .notification_widget.warning:focus,.koopera-nb .notification_widget.warning.disabled.focus,.koopera-nb .notification_widget.warning[disabled].focus,.koopera-nb fieldset[disabled] .notification_widget.warning.focus{background-color:#f0ad4e;border-color:#eea236}.koopera-nb .notification_widget.warning .badge{color:#f0ad4e;background-color:#fff}.koopera-nb .notification_widget.success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.koopera-nb .notification_widget.success:focus,.koopera-nb .notification_widget.success.focus{color:#fff;background-color:#449d44;border-color:#255625}.koopera-nb .notification_widget.success:hover{color:#fff;background-color:#449d44;border-color:#398439}.koopera-nb .notification_widget.success:active,.koopera-nb .notification_widget.success.active,.koopera-nb .open > .dropdown-toggle.notification_widget.success{color:#fff;background-color:#449d44;border-color:#398439}.koopera-nb .notification_widget.success:active:hover,.koopera-nb .notification_widget.success.active:hover,.koopera-nb .open > .dropdown-toggle.notification_widget.success:hover,.koopera-nb .notification_widget.success:active:focus,.koopera-nb .notification_widget.success.active:focus,.koopera-nb .open > .dropdown-toggle.notification_widget.success:focus,.koopera-nb .notification_widget.success:active.focus,.koopera-nb .notification_widget.success.active.focus,.koopera-nb .open > .dropdown-toggle.notification_widget.success.focus{color:#fff;background-color:#398439;border-color:#255625}.koopera-nb .notification_widget.success:active,.koopera-nb .notification_widget.success.active,.koopera-nb .open > .dropdown-toggle.notification_widget.success{background-image:none}.koopera-nb .notification_widget.success.disabled:hover,.koopera-nb .notification_widget.success[disabled]:hover,.koopera-nb fieldset[disabled] .notification_widget.success:hover,.koopera-nb .notification_widget.success.disabled:focus,.koopera-nb .notification_widget.success[disabled]:focus,.koopera-nb fieldset[disabled] .notification_widget.success:focus,.koopera-nb .notification_widget.success.disabled.focus,.koopera-nb .notification_widget.success[disabled].focus,.koopera-nb fieldset[disabled] .notification_widget.success.focus{background-color:#5cb85c;border-color:#4cae4c}.koopera-nb .notification_widget.success .badge{color:#5cb85c;background-color:#fff}.koopera-nb .notification_widget.info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.koopera-nb .notification_widget.info:focus,.koopera-nb .notification_widget.info.focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.koopera-nb .notification_widget.info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.koopera-nb .notification_widget.info:active,.koopera-nb .notification_widget.info.active,.koopera-nb .open > .dropdown-toggle.notification_widget.info{color:#fff;background-color:#31b0d5;border-color:#269abc}.koopera-nb .notification_widget.info:active:hover,.koopera-nb .notification_widget.info.active:hover,.koopera-nb .open > .dropdown-toggle.notification_widget.info:hover,.koopera-nb .notification_widget.info:active:focus,.koopera-nb .notification_widget.info.active:focus,.koopera-nb .open > .dropdown-toggle.notification_widget.info:focus,.koopera-nb .notification_widget.info:active.focus,.koopera-nb .notification_widget.info.active.focus,.koopera-nb .open > .dropdown-toggle.notification_widget.info.focus{color:#fff;background-color:#269abc;border-color:#1b6d85}.koopera-nb .notification_widget.info:active,.koopera-nb .notification_widget.info.active,.koopera-nb .open > .dropdown-toggle.notification_widget.info{background-image:none}.koopera-nb .notification_widget.info.disabled:hover,.koopera-nb .notification_widget.info[disabled]:hover,.koopera-nb fieldset[disabled] .notification_widget.info:hover,.koopera-nb .notification_widget.info.disabled:focus,.koopera-nb .notification_widget.info[disabled]:focus,.koopera-nb fieldset[disabled] .notification_widget.info:focus,.koopera-nb .notification_widget.info.disabled.focus,.koopera-nb .notification_widget.info[disabled].focus,.koopera-nb fieldset[disabled] .notification_widget.info.focus{background-color:#5bc0de;border-color:#46b8da}.koopera-nb .notification_widget.info .badge{color:#5bc0de;background-color:#fff}.koopera-nb .notification_widget.danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.koopera-nb .notification_widget.danger:focus,.koopera-nb .notification_widget.danger.focus{color:#fff;background-color:#c9302c;border-color:#761c19}.koopera-nb .notification_widget.danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.koopera-nb .notification_widget.danger:active,.koopera-nb .notification_widget.danger.active,.koopera-nb .open > .dropdown-toggle.notification_widget.danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.koopera-nb .notification_widget.danger:active:hover,.koopera-nb .notification_widget.danger.active:hover,.koopera-nb .open > .dropdown-toggle.notification_widget.danger:hover,.koopera-nb .notification_widget.danger:active:focus,.koopera-nb .notification_widget.danger.active:focus,.koopera-nb .open > .dropdown-toggle.notification_widget.danger:focus,.koopera-nb .notification_widget.danger:active.focus,.koopera-nb .notification_widget.danger.active.focus,.koopera-nb .open > .dropdown-toggle.notification_widget.danger.focus{color:#fff;background-color:#ac2925;border-color:#761c19}.koopera-nb .notification_widget.danger:active,.koopera-nb .notification_widget.danger.active,.koopera-nb .open > .dropdown-toggle.notification_widget.danger{background-image:none}.koopera-nb .notification_widget.danger.disabled:hover,.koopera-nb .notification_widget.danger[disabled]:hover,.koopera-nb fieldset[disabled] .notification_widget.danger:hover,.koopera-nb .notification_widget.danger.disabled:focus,.koopera-nb .notification_widget.danger[disabled]:focus,.koopera-nb fieldset[disabled] .notification_widget.danger:focus,.koopera-nb .notification_widget.danger.disabled.focus,.koopera-nb .notification_widget.danger[disabled].focus,.koopera-nb fieldset[disabled] .notification_widget.danger.focus{background-color:#d9534f;border-color:#d43f3a}.koopera-nb .notification_widget.danger .badge{color:#d9534f;background-color:#fff}.koopera-nb div#pager{background-color:#fff;font-size:14px;line-height:20px;overflow:hidden;display:none;position:fixed;bottom:0;width:100%;max-height:50%;padding-top:8px;-webkit-box-shadow:0 0 12px 1px rgba(87,87,87,0.2);box-shadow:0 0 12px 1px rgba(87,87,87,0.2);z-index:100;top:auto!important}.koopera-nb div#pager pre{line-height:1.21429em;color:#000;background-color:#f7f7f7;padding:.4em}.koopera-nb div#pager #pager-button-area{position:absolute;top:8px;right:20px}.koopera-nb div#pager #pager-contents{position:relative;overflow:auto;width:100%;height:100%}.koopera-nb div#pager #pager-contents #pager-container{position:relative;padding:15px 0;box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box}.koopera-nb div#pager .ui-resizable-handle{top:0;height:8px;background:#f7f7f7;border-top:1px solid #cfcfcf;border-bottom:1px solid #cfcfcf}.koopera-nb div#pager .ui-resizable-handle::after{content:\'\';top:2px;left:50%;height:3px;width:30px;margin-left:-15px;position:absolute;border-top:1px solid #cfcfcf}.koopera-nb .quickhelp{display:-webkit-box;-webkit-box-orient:horizontal;-webkit-box-align:stretch;display:-moz-box;-moz-box-orient:horizontal;-moz-box-align:stretch;display:box;box-orient:horizontal;box-align:stretch;display:flex;flex-direction:row;align-items:stretch;line-height:1.8em}.koopera-nb .shortcut_key{display:inline-block;width:21ex;text-align:right;font-family:monospace}.koopera-nb .shortcut_descr{display:inline-block;-webkit-box-flex:1;-moz-box-flex:1;box-flex:1;flex:1}.koopera-nb span.save_widget{height:30px;margin-top:4px;display:flex;justify-content:flex-start;align-items:baseline;width:50%;flex:1}.koopera-nb span.save_widget span.filename{height:100%;line-height:1em;margin-left:16px;border:none;font-size:146.5%;text-overflow:ellipsis;overflow:hidden;white-space:nowrap;border-radius:2px}.koopera-nb span.save_widget span.filename:hover{background-color:#e6e6e6}[dir="rtl"] span.save_widget.pull-left{float:right!important;float:right}[dir="rtl"] span.save_widget span.filename{margin-left:0;margin-right:16px}.koopera-nb span.checkpoint_status,.koopera-nb span.autosave_status{font-size:small;white-space:nowrap;padding:0 5px}@media (max-width: 767px){span.save_widget{font-size:small;padding:0 0 0 5px}span.checkpoint_status,span.autosave_status{display:none}}@media (min-width: 768px) and (max-width: 991px){span.checkpoint_status{display:none}span.autosave_status{font-size:x-small}}.koopera-nb .toolbar{padding:0;margin-left:-5px;margin-top:2px;margin-bottom:5px;box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box}.koopera-nb .toolbar select,.koopera-nb .toolbar label{width:auto;vertical-align:middle;margin-right:2px;margin-bottom:0;display:inline;font-size:92%;margin-left:.3em;margin-right:.3em;padding:0;padding-top:3px}.koopera-nb .toolbar .btn{padding:2px 8px}.koopera-nb .toolbar .btn-group{margin-top:0;margin-left:5px}.koopera-nb .toolbar-btn-label{margin-left:6px}#maintoolbar{margin-bottom:-3px;margin-top:-8px;border:0;min-height:27px;margin-left:0;padding-top:11px;padding-bottom:3px}#maintoolbar .navbar-text{float:none;vertical-align:middle;text-align:right;margin-left:5px;margin-right:0;margin-top:0}.koopera-nb .select-xs{height:24px}[dir="rtl"] .btn-group > .btn,.koopera-nb .btn-group-vertical > .btn{float:right}.koopera-nb .pulse,.koopera-nb .dropdown-menu > li > a.pulse,.koopera-nb li.pulse > a.dropdown-toggle,.koopera-nb li.pulse.open > a.dropdown-toggle{background-color:#F37626;color:#fff}@-moz-keyframes fadeOut{from{opacity:1}to{opacity:0}}@-webkit-keyframes fadeOut{from{opacity:1}to{opacity:0}}@-moz-keyframes fadeIn{from{opacity:0}to{opacity:1}}@-webkit-keyframes fadeIn{from{opacity:0}to{opacity:1}}.koopera-nb .bigtooltip{overflow:auto;height:200px;-webkit-transition-property:height;-webkit-transition-duration:500ms;-moz-transition-property:height;-moz-transition-duration:500ms;transition-property:height;transition-duration:500ms}.koopera-nb .smalltooltip{-webkit-transition-property:height;-webkit-transition-duration:500ms;-moz-transition-property:height;-moz-transition-duration:500ms;transition-property:height;transition-duration:500ms;text-overflow:ellipsis;overflow:hidden;height:80px}.koopera-nb .tooltipbuttons{position:absolute;padding-right:15px;top:0;right:0}.koopera-nb .tooltiptext{padding-right:30px}.koopera-nb .ipython_tooltip{max-width:700px;-webkit-animation:fadeOut 400ms;-moz-animation:fadeOut 400ms;animation:fadeOut 400ms;-webkit-animation:fadeIn 400ms;-moz-animation:fadeIn 400ms;animation:fadeIn 400ms;vertical-align:middle;background-color:#f7f7f7;overflow:visible;border:#ababab 1px solid;outline:none;padding:3px;margin:0;padding-left:7px;font-family:monospace;min-height:50px;-moz-box-shadow:0 6px 10px -1px #adadad;-webkit-box-shadow:0 6px 10px -1px #adadad;box-shadow:0 6px 10px -1px #adadad;border-radius:2px;position:absolute;z-index:1000}.koopera-nb .ipython_tooltip a{float:right}.koopera-nb .ipython_tooltip .tooltiptext pre{border:0;border-radius:0;font-size:100%;background-color:#f7f7f7}.koopera-nb .pretooltiparrow{left:0;margin:0;top:-16px;width:40px;height:16px;overflow:hidden;position:absolute}.koopera-nb .pretooltiparrow:before{background-color:#f7f7f7;border:1px #ababab solid;z-index:11;content:"";position:absolute;left:15px;top:10px;width:25px;height:25px;-webkit-transform:rotate(45deg);-moz-transform:rotate(45deg);-ms-transform:rotate(45deg);-o-transform:rotate(45deg)}.koopera-nb ul.typeahead-list i{margin-left:-10px;width:18px}[dir="rtl"] ul.typeahead-list i{margin-left:0;margin-right:-10px}.koopera-nb ul.typeahead-list{max-height:80vh;overflow:auto}.koopera-nb ul.typeahead-list > li > a{white-space:normal}.koopera-nb ul.typeahead-list > li > a.pull-right{float:left!important;float:left}[dir="rtl"] .typeahead-list{text-align:right}.koopera-nb .cmd-palette .modal-body{padding:7px}.koopera-nb .cmd-palette form{background:#fff}.koopera-nb .cmd-palette input{outline:none}.koopera-nb .no-shortcut{min-width:20px;color:transparent}[dir="rtl"] .no-shortcut.pull-right{float:left!important;float:left}[dir="rtl"] .command-shortcut.pull-right{float:left!important;float:left}.koopera-nb .command-shortcut:before{content:"(command mode)";padding-right:3px;color:#777}.koopera-nb .edit-shortcut:before{content:"(edit)";padding-right:3px;color:#777}[dir="rtl"] .edit-shortcut.pull-right{float:left!important;float:left}#find-and-replace #replace-preview .match,#find-and-replace #replace-preview .insert{background-color:#BBDEFB;border-color:#90CAF9;border-style:solid;border-width:1px;border-radius:0}[dir="ltr"] #find-and-replace .input-group-btn + .form-control{border-left:none}[dir="rtl"] #find-and-replace .input-group-btn + .form-control{border-right:none}#find-and-replace #replace-preview .replace .match{background-color:#FFCDD2;border-color:#EF9A9A;border-radius:0}#find-and-replace #replace-preview .replace .insert{background-color:#C8E6C9;border-color:#A5D6A7;border-radius:0}#find-and-replace #replace-preview{max-height:60vh;overflow:auto}#find-and-replace #replace-preview pre{padding:5px 10px}.koopera-nb .terminal-app{background:#EEE}.koopera-nb .terminal-app #header{background:#fff;-webkit-box-shadow:0 0 12px 1px rgba(87,87,87,0.2);box-shadow:0 0 12px 1px rgba(87,87,87,0.2)}.koopera-nb .terminal-app .terminal{width:100%;float:left;font-family:monospace;color:#fff;background:#000;padding:.4em;border-radius:2px;-webkit-box-shadow:0 0 12px 1px rgba(87,87,87,0.4);box-shadow:0 0 12px 1px rgba(87,87,87,0.4)}.koopera-nb .terminal-app .terminal,.koopera-nb .terminal-app .terminal dummy-screen{line-height:1em;font-size:14px}.koopera-nb .terminal-app .terminal .xterm-rows{padding:10px}.koopera-nb .terminal-app .terminal-cursor{color:#000;background:#fff}.koopera-nb .terminal-app #terminado-container{margin-top:20px}.koopera-nb .highlight .hll{background-color:#ffc}.koopera-nb .highlight{background:#f8f8f8}.koopera-nb .highlight .c{color:#408080;font-style:italic}.koopera-nb .highlight .err{border:1px solid red}.koopera-nb .highlight .k{color:green;font-weight:700}.koopera-nb .highlight .o{color:#666}.koopera-nb .highlight .ch{color:#408080;font-style:italic}.koopera-nb .highlight .cm{color:#408080;font-style:italic}.koopera-nb .highlight .cp{color:#BC7A00}.koopera-nb .highlight .cpf{color:#408080;font-style:italic}.koopera-nb .highlight .c1{color:#408080;font-style:italic}.koopera-nb .highlight .cs{color:#408080;font-style:italic}.koopera-nb .highlight .gd{color:#A00000}.koopera-nb .highlight .ge{font-style:italic}.koopera-nb .highlight .gr{color:red}.koopera-nb .highlight .gh{color:navy;font-weight:700}.koopera-nb .highlight .gi{color:#00A000}.koopera-nb .highlight .go{color:#888}.koopera-nb .highlight .gp{color:navy;font-weight:700}.koopera-nb .highlight .gs{font-weight:700}.koopera-nb .highlight .gu{color:purple;font-weight:700}.koopera-nb .highlight .gt{color:#04D}.koopera-nb .highlight .kc{color:green;font-weight:700}.koopera-nb .highlight .kd{color:green;font-weight:700}.koopera-nb .highlight .kn{color:green;font-weight:700}.koopera-nb .highlight .kp{color:green}.koopera-nb .highlight .kr{color:green;font-weight:700}.koopera-nb .highlight .kt{color:#B00040}.koopera-nb .highlight .m{color:#666}.koopera-nb .highlight .s{color:#BA2121}.koopera-nb .highlight .na{color:#7D9029}.koopera-nb .highlight .nb{color:green}.koopera-nb .highlight .nc{color:#00F;font-weight:700}.koopera-nb .highlight .no{color:#800}.koopera-nb .highlight .nd{color:#A2F}.koopera-nb .highlight .ni{color:#999;font-weight:700}.koopera-nb .highlight .ne{color:#D2413A;font-weight:700}.koopera-nb .highlight .nf{color:#00F}.koopera-nb .highlight .nl{color:#A0A000}.koopera-nb .highlight .nn{color:#00F;font-weight:700}.koopera-nb .highlight .nt{color:green;font-weight:700}.koopera-nb .highlight .nv{color:#19177C}.koopera-nb .highlight .ow{color:#A2F;font-weight:700}.koopera-nb .highlight .w{color:#bbb}.koopera-nb .highlight .mb{color:#666}.koopera-nb .highlight .mf{color:#666}.koopera-nb .highlight .mh{color:#666}.koopera-nb .highlight .mi{color:#666}.koopera-nb .highlight .mo{color:#666}.koopera-nb .highlight .sa{color:#BA2121}.koopera-nb .highlight .sb{color:#BA2121}.koopera-nb .highlight .sc{color:#BA2121}.koopera-nb .highlight .dl{color:#BA2121}.koopera-nb .highlight .sd{color:#BA2121;font-style:italic}.koopera-nb .highlight .s2{color:#BA2121}.koopera-nb .highlight .se{color:#B62;font-weight:700}.koopera-nb .highlight .sh{color:#BA2121}.koopera-nb .highlight .si{color:#B68;font-weight:700}.koopera-nb .highlight .sx{color:green}.koopera-nb .highlight .sr{color:#B68}.koopera-nb .highlight .s1{color:#BA2121}.koopera-nb .highlight .ss{color:#19177C}.koopera-nb .highlight .bp{color:green}.koopera-nb .highlight .fm{color:#00F}.koopera-nb .highlight .vc{color:#19177C}.koopera-nb .highlight .vg{color:#19177C}.koopera-nb .highlight .vi{color:#19177C}.koopera-nb .highlight .vm{color:#19177C}.koopera-nb .highlight .il{color:#666} 2 | -------------------------------------------------------------------------------- /public/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsn491/koopera/7760786a024388575f85366a951993114e123e99/public/img/favicon.ico -------------------------------------------------------------------------------- /public/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsn491/koopera/7760786a024388575f85366a951993114e123e99/public/img/icon.png -------------------------------------------------------------------------------- /public/img/icon_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsn491/koopera/7760786a024388575f85366a951993114e123e99/public/img/icon_blue.png -------------------------------------------------------------------------------- /public/img/icon_bluelight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsn491/koopera/7760786a024388575f85366a951993114e123e99/public/img/icon_bluelight.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Koopera 18 | 19 | 20 | 23 |
24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask==1.1.1 2 | nbconvert==5.6.0 3 | nbformat==4.4.0 4 | pygments==2.9.0 5 | PyGithub==1.55 6 | Flask-Cors==3.0.10 7 | flask-jwt-extended==4.2.0 8 | SQLAlchemy==1.3.12 9 | alembic==1.3.2 10 | psycopg2-binary==2.8.4 11 | markdown==2.6.11 12 | py-gfm==0.1.4 13 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from src.backend.api_service import APIService 2 | 3 | app = APIService.get() 4 | 5 | if __name__ == '__main__': 6 | app.run(host='0.0.0.0') 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [yapf] 2 | based_on_style = google 3 | ALLOW_SPLIT_BEFORE_DICT_VALUE = False 4 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsn491/koopera/7760786a024388575f85366a951993114e123e99/src/__init__.py -------------------------------------------------------------------------------- /src/backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsn491/koopera/7760786a024388575f85366a951993114e123e99/src/backend/__init__.py -------------------------------------------------------------------------------- /src/backend/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsn491/koopera/7760786a024388575f85366a951993114e123e99/src/backend/api/__init__.py -------------------------------------------------------------------------------- /src/backend/api/code_repositories.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from datetime import datetime 3 | from typing import Iterable 4 | 5 | import markdown 6 | import nbformat 7 | from flask import Blueprint, jsonify, request 8 | from flask_jwt_extended import get_jwt_identity, jwt_required 9 | from github import Github, GithubException, Repository 10 | from mdx_gfm import GithubFlavoredMarkdownExtension 11 | from nbconvert import HTMLExporter 12 | from pygments import highlight 13 | from pygments.formatters.html import HtmlFormatter 14 | from pygments.lexers import guess_lexer_for_filename 15 | 16 | from src.backend.domain.code_comment import CodeComment 17 | 18 | CODE_REPOSITORIES_BLUEPRINT = Blueprint('code_repositories', __name__) 19 | 20 | 21 | @CODE_REPOSITORIES_BLUEPRINT.route('/coderepositories') 22 | @jwt_required() 23 | def get_code_repositories(): 24 | github = Github(get_jwt_identity()) 25 | code_repos: Iterable[Repository] = github.get_user().get_repos() 26 | 27 | return jsonify({ 28 | "codeRepositories": list( 29 | map( 30 | lambda repo: { 31 | "id": repo.id, 32 | "owner": repo.owner.login, 33 | "name": repo.name, 34 | "openPullRequestCount": repo.get_pulls().totalCount 35 | }, code_repos)) 36 | }) 37 | 38 | 39 | @CODE_REPOSITORIES_BLUEPRINT.route( 40 | '/coderepositories//pullrequests') 41 | @jwt_required() 42 | def get_pull_requests(code_repository_id: int): 43 | github = Github(get_jwt_identity()) 44 | code_repository = github.get_repo(full_name_or_id=int(code_repository_id)) 45 | 46 | return jsonify({ 47 | "codeRepoName": code_repository.name, 48 | "pullRequests": list( 49 | map( 50 | lambda pull_request: { 51 | "id": pull_request.id, 52 | "number": pull_request.number, 53 | "title": pull_request.title, 54 | "userName": pull_request.user.name, 55 | "userAvatarUrl": pull_request.user.avatar_url 56 | }, 57 | code_repository.get_pulls().get_page(0))) 58 | }) 59 | 60 | 61 | @CODE_REPOSITORIES_BLUEPRINT.route( 62 | '/coderepositories//pullrequests/') 63 | @jwt_required() 64 | def get_pull_request(code_repository_id, pull_request_id): 65 | github = Github(get_jwt_identity()) 66 | code_repository: Repository = github.get_repo( 67 | full_name_or_id=int(code_repository_id)) 68 | pull_request = code_repository.get_pull(number=int(pull_request_id)) 69 | 70 | code_comments = map( 71 | lambda code_comment: CodeComment.from_github_code_comment(code_comment), 72 | pull_request.get_comments()) 73 | 74 | return jsonify({ 75 | "title": pull_request.title, 76 | "codeRepoName": code_repository.name, 77 | "userAvatarUrl": pull_request.user.avatar_url, 78 | "body": markdown.markdown( 79 | pull_request.body, extensions=[GithubFlavoredMarkdownExtension()]), 80 | "state": pull_request.state.capitalize(), 81 | "comments": list( 82 | map( 83 | lambda code_comment: code_comment.__dict__, 84 | filter(lambda code_comment: code_comment is not None, 85 | code_comments))), 86 | "files": list( 87 | map( 88 | lambda file: { 89 | "path": file.filename, 90 | "ref": pull_request.base.ref if file.status == 'removed' 91 | else pull_request.head.ref, 92 | "prevRef": pull_request.base.ref, 93 | "rawUrl": file.raw_url, 94 | "status": file.status, 95 | "sha": file.sha, 96 | "patch": file.patch 97 | }, pull_request.get_files())) 98 | }) 99 | 100 | 101 | @CODE_REPOSITORIES_BLUEPRINT.route( 102 | '/coderepositories//pullrequests//merge', 103 | methods=['POST']) 104 | @jwt_required() 105 | def merge_pull_request(code_repository_id, pull_request_id): 106 | github = Github(get_jwt_identity()) 107 | code_repository: Repository = github.get_repo( 108 | full_name_or_id=int(code_repository_id)) 109 | pull_request = code_repository.get_pull(number=int(pull_request_id)) 110 | 111 | merge_strategy = request.json['mergeStrategy'] 112 | 113 | try: 114 | pull_request.merge(merge_method=merge_strategy) 115 | except GithubException as exception: 116 | return jsonify({'message': exception.data['message']}), 400 117 | 118 | return '', 200 119 | 120 | 121 | @CODE_REPOSITORIES_BLUEPRINT.route( 122 | '/coderepositories//pullrequests//comment', 123 | methods=['POST']) 124 | @jwt_required() 125 | def add_comment(code_repository_id, pull_request_id): 126 | body = request.json 127 | 128 | github = Github(get_jwt_identity()) 129 | 130 | in_reply_to_comment_id = body['inReplyToCommentId'] 131 | code_comment = CodeComment(file_path=body['path'], 132 | code_block_id=body['codeBlockId'], 133 | body=body['comment'], 134 | author=github.get_user().name, 135 | updated_at=datetime.utcnow()) 136 | 137 | code_repo: Repository = github.get_repo(int(code_repository_id)) 138 | pull_request = code_repo.get_pull(number=int(pull_request_id)) 139 | 140 | if in_reply_to_comment_id: 141 | created_comment = pull_request.create_review_comment_reply( 142 | in_reply_to_comment_id, code_comment.get_github_comment()) 143 | else: 144 | commit = code_repo.get_commit(pull_request.head.sha) 145 | created_comment = pull_request.create_comment( 146 | code_comment.get_github_comment(), commit, 147 | code_comment.get_github_path(), code_comment.get_github_position()) 148 | 149 | return jsonify({'id': created_comment.id}) 150 | 151 | def convert_github_file_to_html(file_path, content): 152 | file_content = base64.b64decode(content) 153 | 154 | if file_path.endswith('.ipynb'): 155 | html_exporter = HTMLExporter() 156 | html_exporter.template_file = 'basic' 157 | notebook = nbformat.reads(file_content, as_version=4) 158 | (body, _) = html_exporter.from_notebook_node(notebook) 159 | else: 160 | lexer = guess_lexer_for_filename(file_path, '', stripall=True) 161 | formatter = HtmlFormatter(linenos='inline', 162 | full=True, 163 | noclasses=False, 164 | nobackground=True, 165 | lineseparator="
", 166 | classprefix='koopera-code-viewer') 167 | body = highlight(file_content, lexer, formatter) 168 | 169 | return body 170 | 171 | @CODE_REPOSITORIES_BLUEPRINT.route('/coderepositories//file' 172 | ) 173 | @jwt_required() 174 | def get_file(code_repository_id): 175 | file_sha = request.args.get("sha") 176 | file_path = request.args.get("path") 177 | file_ref = request.args.get("ref") 178 | 179 | github = Github(get_jwt_identity()) 180 | code_repo: Repository = github.get_repo(int(code_repository_id)) 181 | 182 | if file_sha: 183 | content = code_repo.get_git_blob(file_sha).content 184 | else: 185 | content = code_repo.get_contents(file_path, file_ref).content 186 | 187 | return jsonify({'body': convert_github_file_to_html(file_path, content)}) 188 | -------------------------------------------------------------------------------- /src/backend/api/notebooks.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, redirect, request 2 | from flask_jwt_extended import get_jwt_identity, jwt_required 3 | from github import Github 4 | from sqlalchemy import create_engine 5 | from sqlalchemy.orm import sessionmaker 6 | 7 | from src.backend.config import DATABASE_URI 8 | from src.backend.model.code_repository import CodeRepository 9 | from src.backend.model.notebook import Notebook 10 | 11 | # pylint: disable=no-member 12 | # Remove this when pylint no longer shows a false-positive "no-member" for SQLAlchemy "Session" 13 | # Issue: https://github.com/PyCQA/pylint/issues/3610 14 | 15 | SESSION = sessionmaker(create_engine(DATABASE_URI)) 16 | 17 | NOTEBOOKS_BLUEPRINT = Blueprint('notebooks', __name__) 18 | 19 | @NOTEBOOKS_BLUEPRINT.route('/notebooks') 20 | @jwt_required() 21 | def get_all_notebooks(): 22 | session = SESSION() 23 | 24 | return jsonify({ 25 | "notebooks": list( 26 | map( 27 | lambda notebook: { 28 | 'id': notebook.id, 29 | 'title': notebook.title, 30 | 'summary': notebook.summary, 31 | 'sha': notebook.sha, 32 | 'repoId': notebook.code_repo_id, 33 | 'repoName': notebook.code_repo.name 34 | }, 35 | session.query(Notebook).all())) 36 | }) 37 | 38 | 39 | @NOTEBOOKS_BLUEPRINT.route('/notebooks/') 40 | @jwt_required() 41 | def get_notebook(notebook_id): 42 | session = SESSION() 43 | 44 | notebook = session.query(Notebook).get(int(notebook_id)) 45 | 46 | if notebook is None: 47 | return jsonify({}), 404 48 | 49 | return redirect( 50 | f'/api/coderepositories/{notebook.code_repo_id}/file?path={notebook.path}&sha={notebook.sha}' 51 | ) 52 | 53 | 54 | @NOTEBOOKS_BLUEPRINT.route('/notebooks/', methods=['DELETE']) 55 | @jwt_required() 56 | def delete_notebook(notebook_id): 57 | session = SESSION() 58 | 59 | session.query(Notebook).filter(Notebook.id == int(notebook_id)).delete() 60 | session.commit() 61 | 62 | return jsonify({}) 63 | 64 | 65 | @NOTEBOOKS_BLUEPRINT.route('/notebooks', methods=['POST']) 66 | @jwt_required() 67 | def import_notebooks(): 68 | session = SESSION() 69 | body = request.json if request.data else None 70 | 71 | github = Github(get_jwt_identity()) 72 | 73 | if body and 'codeRepositories' in body: 74 | code_repos = body['codeRepositories'] 75 | code_repos_ids = set(map(lambda repo: repo['id'], code_repos)) 76 | code_repos_owners = set(map(lambda repo: repo['owner'], code_repos)) 77 | else: 78 | # no repos passed! 79 | # update notebooks for current repos 80 | code_repos = session.query(CodeRepository).all() 81 | code_repos_ids = set(map(lambda repo: repo.id, code_repos)) 82 | code_repos_owners = set(map(lambda repo: repo.owner, code_repos)) 83 | 84 | notebooks_added = 0 85 | notebooks_updated = 0 86 | 87 | for owner in code_repos_owners: 88 | notebooks = filter( 89 | lambda notebook: notebook.repository.id in code_repos_ids, 90 | github.search_code(f'user:{owner} extension:ipynb')) 91 | 92 | for notebook in notebooks: 93 | notebook_db = session.query(Notebook).filter( 94 | Notebook.path == notebook.path and 95 | Notebook.code_repo_id == notebook.repository.id).first() 96 | 97 | if notebook_db: 98 | notebook_db.sha = notebook.sha 99 | notebooks_updated += 1 100 | else: 101 | if session.query(CodeRepository).get( 102 | notebook.repository.id) is None: 103 | # create repo 104 | session.add( 105 | CodeRepository(id=notebook.repository.id, 106 | name=notebook.repository.name, 107 | owner=owner)) 108 | 109 | session.add( 110 | Notebook(code_repo_id=notebook.repository.id, 111 | sha=notebook.sha, 112 | path=notebook.path, 113 | title=notebook.name, 114 | summary='')) 115 | notebooks_added += 1 116 | 117 | session.commit() 118 | 119 | return jsonify({ 120 | 'notebooksAdded': notebooks_added, 121 | 'notebooksUpdated': notebooks_updated 122 | }) 123 | -------------------------------------------------------------------------------- /src/backend/api_service.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, request, send_from_directory 2 | from flask_cors import CORS 3 | from flask_jwt_extended import (JWTManager, create_access_token, 4 | get_jwt_identity, jwt_required) 5 | from github import Github, NamedUser, BadCredentialsException 6 | 7 | from src.backend.api.code_repositories import CODE_REPOSITORIES_BLUEPRINT 8 | from src.backend.api.notebooks import NOTEBOOKS_BLUEPRINT 9 | 10 | from .config import ALLOW_CORS, JWT_SECRET_KEY 11 | 12 | 13 | class APIService: 14 | 15 | @staticmethod 16 | def get() -> Flask: 17 | app = Flask(__name__) 18 | 19 | if ALLOW_CORS: 20 | CORS(app, resources={r'/*': {'origins': '*'}}) 21 | 22 | app.config['JWT_SECRET_KEY'] = JWT_SECRET_KEY 23 | JWTManager(app) 24 | 25 | app.register_blueprint(CODE_REPOSITORIES_BLUEPRINT, url_prefix='/api') 26 | app.register_blueprint(NOTEBOOKS_BLUEPRINT, url_prefix='/api') 27 | 28 | @app.route('/img/') 29 | def get_img_file(path): 30 | return send_from_directory('static/img', path) 31 | 32 | @app.route('/js/') 33 | def get_js_file(path): 34 | return send_from_directory('static/js', path) 35 | 36 | @app.route('/css/') 37 | def get_css_file(path): 38 | return send_from_directory('static/css', path) 39 | 40 | @app.route('/api/login', methods=['POST']) 41 | @jwt_required(optional=True) 42 | def login(): 43 | personal_access_token = request.json['personalAccessToken'] 44 | github = Github(personal_access_token) 45 | 46 | try: 47 | github.get_user().login 48 | except BadCredentialsException: 49 | return '', 401 50 | 51 | access_token = create_access_token(identity=personal_access_token) 52 | return jsonify(accessToken=access_token), 200 53 | 54 | @app.route('/api/me') 55 | @jwt_required() 56 | def user_info(): 57 | github = Github(get_jwt_identity()) 58 | user: NamedUser = github.get_user() 59 | 60 | return jsonify({ 61 | "avatarUrl": user.avatar_url, 62 | "name": user.name, 63 | }) 64 | 65 | @app.route('/') 66 | @app.route('/') 67 | def index(path=None): 68 | return app.send_static_file('index.html') 69 | 70 | return app 71 | -------------------------------------------------------------------------------- /src/backend/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ALLOW_CORS = os.getenv('ALLOW_CORS', True) 4 | JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', '') 5 | DATABASE_URI = os.getenv( 6 | 'DATABASE_URI', 7 | 'postgres+psycopg2://postgres:password@localhost:5432/koopera') 8 | -------------------------------------------------------------------------------- /src/backend/domain/code_comment.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Optional 3 | 4 | from github.PullRequestComment import PullRequestComment 5 | 6 | 7 | class CodeComment: 8 | 9 | def __init__(self, 10 | file_path: str, 11 | code_block_id: int, 12 | body: str, 13 | author: str, 14 | updated_at: datetime, 15 | id: Optional[int] = None): 16 | self.id = id 17 | self.filePath = file_path 18 | self.codeBlockId = code_block_id 19 | self.body = body 20 | self.author = author 21 | self.updatedAt = updated_at 22 | 23 | @classmethod 24 | def is_ipynb_file(cls, path): 25 | return path.endswith('.ipynb') 26 | 27 | def get_github_path(self) -> int: 28 | return self.filePath 29 | 30 | def get_github_position(self) -> int: 31 | if self.is_ipynb_file(self.filePath): 32 | return 1 33 | 34 | return self.codeBlockId 35 | 36 | def get_github_comment(self) -> str: 37 | if self.is_ipynb_file(self.filePath): 38 | return f'[{self.codeBlockId}] {self.body}' 39 | 40 | return self.body 41 | 42 | @classmethod 43 | def from_github_code_comment(cls, code_comment: PullRequestComment): 44 | body = code_comment.body 45 | 46 | if cls.is_ipynb_file(code_comment.path): 47 | separator_index = body.find(']') 48 | code_block_id = body[1:separator_index] 49 | 50 | if code_block_id.isdigit() is False: 51 | return None 52 | 53 | code_block_id = int(code_block_id) 54 | body = body[separator_index + 2:body.__len__()] 55 | else: 56 | code_block_id = code_comment.position if code_comment.position is not None else 1 57 | 58 | return CodeComment(id=code_comment.id, 59 | file_path=code_comment.path, 60 | code_block_id=code_block_id, 61 | body=body, 62 | author=code_comment.user.name, 63 | updated_at=code_comment.updated_at.replace(tzinfo=timezone.utc).timestamp()) 64 | -------------------------------------------------------------------------------- /src/backend/model/code_repository.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from sqlalchemy.ext.declarative import declarative_base 3 | 4 | CodeRepositoryBase = declarative_base() 5 | 6 | 7 | class CodeRepository(CodeRepositoryBase): 8 | __tablename__ = 'code_repositories' 9 | 10 | id = Column(Integer, primary_key=True) 11 | name = Column(String) 12 | owner = Column(String) 13 | -------------------------------------------------------------------------------- /src/backend/model/notebook.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String 2 | from sqlalchemy.ext.declarative import declarative_base 3 | from sqlalchemy.orm import relationship 4 | 5 | from src.backend.model.code_repository import CodeRepository 6 | 7 | NotebookBase = declarative_base() 8 | 9 | 10 | class Notebook(NotebookBase): 11 | __tablename__ = 'notebooks' 12 | 13 | id = Column(Integer, primary_key=True) 14 | code_repo_id = Column(Integer, ForeignKey(CodeRepository.id)) 15 | sha = Column(String) 16 | path = Column(String) 17 | title = Column(String) 18 | summary = Column(String) 19 | 20 | code_repo = relationship(CodeRepository, lazy='joined') 21 | -------------------------------------------------------------------------------- /src/frontend/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 27 | 28 | 78 | -------------------------------------------------------------------------------- /src/frontend/components/Loader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /src/frontend/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 75 | 76 | 121 | -------------------------------------------------------------------------------- /src/frontend/components/SubNav.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 49 | 50 | 68 | -------------------------------------------------------------------------------- /src/frontend/components/codeComment.js: -------------------------------------------------------------------------------- 1 | const CODE_COMMENT_STATUS = { 2 | OPENED: 0, 3 | CLOSED: 1, 4 | }; 5 | 6 | export default class CodeComment { 7 | constructor( 8 | containerElement, 9 | path, 10 | codeBlockId, 11 | status, 12 | addCommentCallback, 13 | comments = [], 14 | ) { 15 | this.containerElement = containerElement; 16 | this.path = path; 17 | this.codeBlockId = codeBlockId; 18 | this.status = status; 19 | this.addCommentCallback = addCommentCallback; 20 | this.comments = comments; 21 | } 22 | 23 | static _timePassedFormatting(timestampInSeconds) { 24 | const timeDiffInSeconds = (Date.now() / 1000) - timestampInSeconds; 25 | 26 | if (timeDiffInSeconds > 604800) { 27 | return 'more than a week ago'; 28 | } 29 | if (timeDiffInSeconds > 86400) { 30 | return `${Math.round(timeDiffInSeconds / 86400)} days ago`; 31 | } 32 | if (timeDiffInSeconds > 3600) { 33 | return `${Math.round(timeDiffInSeconds / 3600)} hours ago`; 34 | } 35 | if (timeDiffInSeconds > 60) { 36 | return `${Math.round(timeDiffInSeconds / 60)} minutes ago`; 37 | } 38 | 39 | return 'less than a minute ago'; 40 | } 41 | 42 | static _getCommentsAsHtmlStr(comments) { 43 | return comments.map(comment => ` 44 |
45 |
46 | ${comment.author} 47 | ${CodeComment._timePassedFormatting(comment.updatedAt)} 48 |
49 |
${comment.comment}
50 |
`).join(''); 51 | } 52 | 53 | attachOnClickToSaveBtn() { 54 | document.getElementsByClassName('save-code-comment-btn')[0].onclick = () => { 55 | const comment = document.getElementsByClassName('code-comment-text')[0].value; 56 | const inReplyToCommentId = this.comments.length > 0 57 | ? this.comments[this.comments.length - 1].id 58 | : null; 59 | this.addCommentCallback(this, comment, inReplyToCommentId); 60 | }; 61 | } 62 | 63 | template() { 64 | if (this.status === CODE_COMMENT_STATUS.OPENED) { 65 | return ` 66 |
67 | ${this.comments.length > 0 ? CodeComment._getCommentsAsHtmlStr(this.comments) : ''} 68 |
69 |
70 | 71 |
72 |
73 | 74 |
75 |
76 |
77 | `; 78 | } 79 | 80 | return `
81 | ${CodeComment._getCommentsAsHtmlStr(this.comments)} 82 |
`; 83 | } 84 | 85 | addComment(id, newComment) { 86 | this.comments.push({ 87 | id, 88 | author: 'Me', 89 | comment: newComment, 90 | updatedAt: (Date.now() / 1000), 91 | }); 92 | this.status = CODE_COMMENT_STATUS.CLOSED; 93 | this.render(); 94 | } 95 | 96 | open() { 97 | this.status = CODE_COMMENT_STATUS.OPENED; 98 | this.render(); 99 | this.attachOnClickToSaveBtn(); 100 | } 101 | 102 | close() { 103 | if (this.comments.length === 0) { 104 | // no comments in this code block -> just remove it from UI 105 | this.containerElement.parentElement.removeChild(this.containerElement); 106 | } else { 107 | this.status = CODE_COMMENT_STATUS.CLOSED; 108 | } 109 | this.render(); 110 | } 111 | 112 | render() { 113 | this.containerElement.innerHTML = this.template(); 114 | } 115 | 116 | static createNew(containerElement, path, codeBlockId, addCommentCallback) { 117 | const codeComment = new CodeComment( 118 | containerElement, 119 | path, 120 | codeBlockId, 121 | CODE_COMMENT_STATUS.OPENED, 122 | addCommentCallback, 123 | ); 124 | 125 | codeComment.render(); 126 | codeComment.attachOnClickToSaveBtn(); 127 | 128 | return codeComment; 129 | } 130 | 131 | static createExisting(containerElement, path, codeBlockId, addCommentCallback, comments) { 132 | const codeComment = new CodeComment( 133 | containerElement, 134 | path, 135 | codeBlockId, 136 | CODE_COMMENT_STATUS.CLOSED, 137 | addCommentCallback, 138 | comments, 139 | ); 140 | 141 | codeComment.render(); 142 | 143 | return codeComment; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/frontend/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import router from './router'; 4 | 5 | Vue.config.productionTip = false; 6 | 7 | new Vue({ 8 | router, 9 | render: h => h(App), 10 | }).$mount('#app'); 11 | -------------------------------------------------------------------------------- /src/frontend/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Router from 'vue-router'; 3 | import Home from './views/Home.vue'; 4 | import Login from './views/Login.vue'; 5 | 6 | Vue.use(Router); 7 | 8 | export default new Router({ 9 | mode: 'history', 10 | base: process.env.BASE_URL, 11 | routes: [ 12 | { 13 | path: '/', 14 | name: 'home', 15 | component: Home, 16 | }, 17 | { 18 | path: '/login', 19 | name: 'login', 20 | component: Login, 21 | }, 22 | { 23 | path: '/notebooks', 24 | name: 'notebooks', 25 | component: () => import('./views/notebooks/Notebooks.vue'), 26 | }, 27 | { 28 | path: '/notebooks/view/:notebookId', 29 | name: 'notebook', 30 | component: () => import('./views/notebooks/Notebook.vue'), 31 | }, 32 | { 33 | path: '/notebooks/import', 34 | name: 'notebooksImport', 35 | component: () => import('./views/notebooks/ImportNotebooks.vue'), 36 | }, 37 | { 38 | path: '/coderepositories/:codeRepositoryId/pullrequests', 39 | name: 'pullRequests', 40 | component: () => import('./views/PullRequests.vue'), 41 | }, 42 | { 43 | path: '/coderepositories/:codeRepositoryId/pullrequests/:pullRequestNumber', 44 | name: 'pullRequest', 45 | component: () => import('./views/PullRequest.vue'), 46 | }, 47 | ], 48 | }); 49 | -------------------------------------------------------------------------------- /src/frontend/shared/credentialManager.js: -------------------------------------------------------------------------------- 1 | import UserCredentials from './userCredentials'; 2 | 3 | export default class CredentialManager { 4 | static store(sourceControlAccessToken, apiAccessToken) { 5 | localStorage.setItem('sourceControlAccessToken', sourceControlAccessToken); 6 | localStorage.setItem('apiAccessToken', apiAccessToken); 7 | } 8 | 9 | static load() { 10 | return new UserCredentials( 11 | localStorage.getItem('sourceControlAccessToken'), 12 | localStorage.getItem('apiAccessToken'), 13 | ); 14 | } 15 | 16 | static erase() { 17 | localStorage.removeItem('sourceControlAccessToken'); 18 | localStorage.removeItem('apiAccessToken'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/frontend/shared/getAPIUrl.js: -------------------------------------------------------------------------------- 1 | function getAPIUrl(relativePath) { 2 | if (process.env.NODE_ENV === 'development') { 3 | if (process.env.API_BASE_URL) { 4 | return `${process.env.API_BASE_URL}/api/${relativePath}`; 5 | } 6 | 7 | return `http://localhost:5000/api/${relativePath}`; 8 | } 9 | 10 | return `/api/${relativePath}`; 11 | } 12 | 13 | export default getAPIUrl; 14 | -------------------------------------------------------------------------------- /src/frontend/shared/userCredentials.js: -------------------------------------------------------------------------------- 1 | export default class UserCredentials { 2 | constructor(sourceControlAccessToken, apiAccessToken) { 3 | this.sourceControlAccessToken = sourceControlAccessToken; 4 | this.apiAccessToken = apiAccessToken; 5 | } 6 | 7 | isValid() { 8 | return !!this.sourceControlAccessToken && !!this.apiAccessToken; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/frontend/store.js: -------------------------------------------------------------------------------- 1 | const store = { 2 | state: { 3 | codeRepoId: null, 4 | codeRepoName: '' 5 | }, 6 | setCodeRepo(id, name) { 7 | this.state.codeRepoId = id; 8 | this.state.codeRepoName = name; 9 | }, 10 | getCodeRepoName(id) { 11 | if (this.state.codeRepoId !== id) { 12 | return null; 13 | } 14 | return this.state.codeRepoName; 15 | } 16 | }; 17 | 18 | export default store; 19 | -------------------------------------------------------------------------------- /src/frontend/views/Home.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 67 | 68 | 95 | -------------------------------------------------------------------------------- /src/frontend/views/Login.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 59 | 60 | 86 | -------------------------------------------------------------------------------- /src/frontend/views/PullRequest.vue: -------------------------------------------------------------------------------- 1 | 98 | 99 | 390 | 391 | 683 | -------------------------------------------------------------------------------- /src/frontend/views/PullRequests.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 70 | 71 | 81 | -------------------------------------------------------------------------------- /src/frontend/views/notebooks/ImportNotebooks.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 143 | 144 | 166 | -------------------------------------------------------------------------------- /src/frontend/views/notebooks/Notebook.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 47 | -------------------------------------------------------------------------------- /src/frontend/views/notebooks/Notebooks.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 181 | 182 | 241 | --------------------------------------------------------------------------------