- fixed lack of feedback on login screen
- fixed broken images on README.md - updated README.md - updated the PR template - adding option for changing the tab name - added tag headers for grid and list view - added function that resizes template app images to 64x64 on startup - fixed error that was blocking image types from being uploaded
23
README.md
@ -2,9 +2,11 @@
|
||||
# DashMachine
|
||||
### Another web application bookmark dashboard, with fun features.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
### Features
|
||||
* creates a dashboard to view web pages
|
||||
@ -60,6 +62,10 @@ Password: adminadmin
|
||||
## Updating
|
||||
For python, use git. For docker, just pull the latest image and recreate the container.
|
||||
|
||||
**Note:** if you update DashMachine and it fails to start, it's possible something is messed up
|
||||
with your database file. Backup your files in the user_data folder, delete the contents and
|
||||
restart DashMachine. This will reset your user table, so log in with the default user/pass.
|
||||
|
||||
## Configuration
|
||||
The user data folder is located at DashMachine/dashmachine/user_data. This is where the config.ini, custom backgrounds/icons, and the database file live. A reference for what can go into the config.ini file can be found on the settings page of the dashmachine by clicking the info icon next to 'Config'.
|
||||
|
||||
@ -67,8 +73,11 @@ The user data folder is located at DashMachine/dashmachine/user_data. This is wh
|
||||
If you change the config.ini file, you either have to restart the container (or python script) or click the 'save' button in the config section of settings for the config to be applied. Pictures added to the backgrounds/icons folders are available immediately.
|
||||
|
||||
## Want to contribute?
|
||||
Check out the pull request template!
|
||||
https://git.wolf-house.net/ross/DashMachine/src/branch/master/pull_request_template.md
|
||||
Please use the pull request template at:
|
||||
https://github.com/rmountjoy92/DashMachine/blob/master/pull_request_template.md
|
||||
|
||||
See this link for how to create a pull request:
|
||||
https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request
|
||||
|
||||
## Tech used
|
||||
* Flask
|
||||
@ -76,4 +85,8 @@ https://git.wolf-house.net/ross/DashMachine/src/branch/master/pull_request_templ
|
||||
* Jinja2
|
||||
* Materialize css
|
||||
* JavaScript/jQuery/jQueryUI
|
||||
* Requests (python)
|
||||
* Requests (python)
|
||||
|
||||
## FAQs
|
||||
1. <application> does not work in iframe
|
||||
see https://github.com/rmountjoy92/DashMachine/issues/6
|
||||
|
@ -14,6 +14,7 @@ background = None
|
||||
roles = admin,user,public_user
|
||||
home_access_groups = admin_only
|
||||
settings_access_groups = admin_only
|
||||
custom_app_title = DashMachine
|
||||
```
|
||||
|
||||
| Variable | Required | Description | Options |
|
||||
@ -25,6 +26,7 @@ settings_access_groups = admin_only
|
||||
| roles | No | User roles for access groups. | comma separated string, if not defined, this is set to 'admin,user,public_user'. Note: admin, user, public_user roles are required and will be added automatically if omitted. |
|
||||
| home_access_groups | No | Define which access groups can access the /home page | Groups defined in your config. If not defined, default is admin_only |
|
||||
| settings_access_groups | No | Define which access groups can access the /settings page | Groups defined in your config. If not defined, default is admin_only |
|
||||
| custom_app_title | No | Change the title of the app for browser tabs | string |
|
||||
|
||||
##### Apps
|
||||
These entries are the cards that you see one the home page, as well as the sidenav. Entries
|
||||
|
@ -63,6 +63,10 @@ def read_config():
|
||||
)
|
||||
settings.home_view_mode = config["Settings"].get("home_view_mode", "grid")
|
||||
|
||||
settings.custom_app_title = config["Settings"].get(
|
||||
"custom_app_title", "DashMachine"
|
||||
)
|
||||
|
||||
db.session.add(settings)
|
||||
db.session.commit()
|
||||
|
||||
@ -143,7 +147,14 @@ def read_config():
|
||||
db.session.add(tag_db)
|
||||
db.session.commit()
|
||||
else:
|
||||
app.tags = None
|
||||
if Tags.query.first():
|
||||
app.tags = "Untagged"
|
||||
if not Tags.query.filter_by(name="Untagged").first():
|
||||
tag_db = Tags(name="Untagged")
|
||||
db.session.add(tag_db)
|
||||
db.session.commit()
|
||||
else:
|
||||
app.tags = None
|
||||
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
|
@ -1,7 +1,8 @@
|
||||
import os
|
||||
import importlib
|
||||
from shutil import copyfile
|
||||
from dashmachine.paths import dashmachine_folder, images_folder, root_folder
|
||||
from PIL import Image, ImageOps
|
||||
from dashmachine.paths import dashmachine_folder, images_folder
|
||||
from dashmachine.main.models import Groups
|
||||
from dashmachine.main.read_config import read_config
|
||||
from dashmachine.settings_system.models import Settings
|
||||
@ -26,6 +27,7 @@ def public_route(decorated_function):
|
||||
|
||||
|
||||
def dashmachine_init():
|
||||
resize_template_app_images()
|
||||
db.create_all()
|
||||
db.session.commit()
|
||||
|
||||
@ -100,3 +102,12 @@ def get_data_source(data_source):
|
||||
)
|
||||
platform = module.Platform(data_source, **data_source_args)
|
||||
return platform.process()
|
||||
|
||||
|
||||
def resize_template_app_images():
|
||||
folder = os.path.join(images_folder, "apps")
|
||||
for file in os.listdir(folder):
|
||||
fp = os.path.join(folder, file)
|
||||
image = Image.open(fp)
|
||||
image.thumbnail((64, 64))
|
||||
image.save(fp)
|
||||
|
@ -10,3 +10,4 @@ class Settings(db.Model):
|
||||
home_access_groups = db.Column(db.String())
|
||||
settings_access_groups = db.Column(db.String())
|
||||
home_view_mode = db.Column(db.String())
|
||||
custom_app_title = db.Column(db.String())
|
||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 2.9 KiB |
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 7.4 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 4.4 KiB |
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 712 B |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.8 KiB |
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 802 B |
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 5.5 KiB |
@ -14,6 +14,19 @@ $( document ).ready(function() {
|
||||
$(this).addClass('hide');
|
||||
}
|
||||
});
|
||||
$(".tag-group").each(function(i, e) {
|
||||
var x = 0
|
||||
$(this).find('.app-a').each(function(i, e) {
|
||||
if ($(this).hasClass("hide") === false){
|
||||
x = x + 1
|
||||
}
|
||||
});
|
||||
if (x === 0){
|
||||
$(this).addClass('hide');
|
||||
} else {
|
||||
$(this).removeClass('hide');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(".data-source-container").each(function(e) {
|
||||
@ -31,8 +44,8 @@ $( document ).ready(function() {
|
||||
|
||||
$("#tags-select").on('change', function(e) {
|
||||
var value = $(this).val();
|
||||
$(".app-a").each(function(i, e) {
|
||||
if ($(this).attr("data-tags").indexOf(value) > -1 || value === "All tags") {
|
||||
$(".tag-group").each(function(i, e) {
|
||||
if ($(this).attr("data-tag").indexOf(value) > -1 || value === "All tags") {
|
||||
$(this).removeClass('filtered');
|
||||
} else {
|
||||
$(this).addClass('filtered');
|
||||
|
17
dashmachine/static/js/main/login.js
Normal file
@ -0,0 +1,17 @@
|
||||
$( document ).ready(function() {
|
||||
$(".login-form").on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
$.ajax({
|
||||
url: $(this).attr('data-url'),
|
||||
type: 'POST',
|
||||
data: $(this).serialize(),
|
||||
success: function(data){
|
||||
if (data.data.err){
|
||||
M.toast({html: data.data.err, classes: 'theme-warning'})
|
||||
} else {
|
||||
$(location).attr('href', data.data.url)
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -21,9 +21,9 @@
|
||||
content="Another web application bookmark dashboard, with fun features.">
|
||||
<meta name="author" content="rmountjoy">
|
||||
{% if title %}
|
||||
<title>{{ title }} - DashMachine</title>
|
||||
<title>{{ title }} - {{ settings.custom_app_title }}</title>
|
||||
{% else %}
|
||||
<title>DashMachine</title>
|
||||
<title>{{ settings.custom_app_title }}</title>
|
||||
{% endif %}
|
||||
<link rel="apple-touch-icon" href="static/images/favicon/apple-touch-icon-152x152.png">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="static/images/favicon/favicon-32x32.png">
|
||||
|
@ -49,15 +49,71 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
{% if apps %}
|
||||
{# If tags are enabled, render the apps like this #}
|
||||
{% if tags_form.tags.choices|count > 1 %}
|
||||
{% if settings.home_view_mode == "list" %}
|
||||
|
||||
<div class="col s12 m12 l8">
|
||||
<div id="list-view-collection" class="collection">
|
||||
{% for tag in tags_form.tags.choices %}
|
||||
{% if tag[0] != 'All tags' %}
|
||||
<div class="tag-group" data-tag="{{ tag[0] }}">
|
||||
<a class="collection-item font-weight-600 theme-on-primary-text theme-primary" style="font-size: 1.2em">{{ tag[0] }}</a>
|
||||
{% for app in apps %}
|
||||
{% if app.tags and tag[0] in app.tags %}
|
||||
{{ ListViewApp(app) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{% for tag in tags_form.tags.choices %}
|
||||
{% if tag[0] != 'All tags' %}
|
||||
<div class="tag-group" data-tag="{{ tag[0] }}">
|
||||
<div class="row">
|
||||
<div class="col s12 m6 l2">
|
||||
<div class="card center-align theme-primary">
|
||||
<h5 class="theme-on-primary-text">{{ tag[0] }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% for app in apps %}
|
||||
{% if app.tags and tag[0] in app.tags %}
|
||||
{{ GridViewApp(app) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if settings.home_view_mode == "list" %}
|
||||
{{ ListViewApp(apps) }}
|
||||
{% else %}
|
||||
{% for app in apps %}
|
||||
{{ GridViewApp(app) }}
|
||||
{% endfor %}
|
||||
{# otherwise, render the apps like this #}
|
||||
{% if settings.home_view_mode == "list" %}
|
||||
|
||||
<div class="col s12 m12 l8">
|
||||
<div id="list-view-collection" class="collection">
|
||||
{% for app in apps %}
|
||||
{{ ListViewApp(app) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{% for app in apps %}
|
||||
{{ GridViewApp(app) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% else %}
|
||||
<a href="{{ url_for('settings_system.settings') }}">
|
||||
<div class="col s12 m6 l3">
|
||||
|
@ -54,39 +54,33 @@
|
||||
{# </a> This closes AppAnchor() #}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ListViewApp(apps) %}
|
||||
<div class="col s12 m12 l8">
|
||||
<div id="list-view-collection" class="collection">
|
||||
{% for app in apps %}
|
||||
{{ AppAnchor(app, classes="collection-item") }}
|
||||
<div class="row">
|
||||
<div class="col s12
|
||||
{% if app.data_sources.count() > 0 %}
|
||||
l6
|
||||
{% else %}
|
||||
l12
|
||||
{% endif %}">
|
||||
<img src="{{ app.icon }}" class="app-icon">
|
||||
<span class="theme-muted-text app-name">{{ app.name }}</span>
|
||||
{% if app.description %}
|
||||
<i class="material-icons-outlined tooltipped" data-position="top" data-tooltip="{{ app.description }}">info</i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col s12 l6 right-align">
|
||||
{% if app.data_sources.count() > 0 %}
|
||||
<span class="data-source-loading">{{ preload_circle() }}</span>
|
||||
{% for data_source in app.data_sources %}
|
||||
<span class="data-source-container theme-primary-text"
|
||||
data-url="{{ url_for('main.load_data_source') }}"
|
||||
data-id="{{ data_source.id }}">
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{# </a> This closes AppAnchor() #}
|
||||
{% endfor %}
|
||||
{% macro ListViewApp(app) %}
|
||||
{{ AppAnchor(app, classes="collection-item") }}
|
||||
<div class="row">
|
||||
<div class="col s12
|
||||
{% if app.data_sources.count() > 0 %}
|
||||
l6
|
||||
{% else %}
|
||||
l12
|
||||
{% endif %}">
|
||||
<img src="{{ app.icon }}" class="app-icon">
|
||||
<span class="theme-muted-text app-name">{{ app.name }}</span>
|
||||
{% if app.description %}
|
||||
<i class="material-icons-outlined tooltipped" data-position="top" data-tooltip="{{ app.description }}">info</i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col s12 l6 right-align">
|
||||
{% if app.data_sources.count() > 0 %}
|
||||
<span class="data-source-loading">{{ preload_circle() }}</span>
|
||||
{% for data_source in app.data_sources %}
|
||||
<span class="data-source-container theme-primary-text"
|
||||
data-url="{{ url_for('main.load_data_source') }}"
|
||||
data-id="{{ data_source.id }}">
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{# </a> This closes AppAnchor() #}
|
||||
{% endmacro %}
|
@ -12,7 +12,7 @@
|
||||
<input name="files" id="add-images-input" class="hide">
|
||||
</form>
|
||||
<div class="col s12">
|
||||
{{ tcdrop(allowed_types='jpg,jpeg,png,gif', id="images-tcdrop", max_files="30") }}
|
||||
{{ tcdrop(allowed_types='apng,bmp,gif,ico,cur,jpg,jpeg,jfif,pjpeg,pjp,png,svg,tif,tiff,webp', id="images-tcdrop", max_files="30") }}
|
||||
{{ button(text="save", icon="save", id="save-images-btn", float="left", data={"url": url_for('settings_system.add_images')}) }}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -9,7 +9,7 @@
|
||||
<img width="120px" src="static/images/logo/logo.svg" alt="DashMachine Logo">
|
||||
</div>
|
||||
<div class="row">
|
||||
<form class="login-form" method="POST">
|
||||
<form class="login-form" method="POST" data-url="{{ url_for('user_system.check_login') }}">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="input-field col s12">
|
||||
@ -44,3 +44,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block page_lvl_js %}
|
||||
{{ process_js_sources(src="main/login.js")|safe }}
|
||||
{% endblock page_lvl_js %}
|
@ -1,4 +1,4 @@
|
||||
from flask import render_template, url_for, redirect, Blueprint
|
||||
from flask import render_template, url_for, redirect, Blueprint, jsonify
|
||||
from flask_login import login_user, logout_user
|
||||
from dashmachine.user_system.forms import LoginForm
|
||||
from dashmachine.user_system.models import User
|
||||
@ -14,25 +14,30 @@ user_system = Blueprint("user_system", __name__)
|
||||
# ------------------------------------------------------------------------------
|
||||
# login page
|
||||
@public_route
|
||||
@user_system.route("/login", methods=["GET", "POST"])
|
||||
@user_system.route("/login", methods=["GET"])
|
||||
def login():
|
||||
user = User.query.first()
|
||||
|
||||
form = LoginForm()
|
||||
return render_template("user/login.html", title="Login", form=form)
|
||||
|
||||
|
||||
@public_route
|
||||
@user_system.route("/check_login", methods=["POST"])
|
||||
def check_login():
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
user = User.query.filter_by(username=form.username.data.lower()).first()
|
||||
if not user:
|
||||
response = {"err": "User not found"}
|
||||
|
||||
if user and bcrypt.check_password_hash(user.password, form.password.data):
|
||||
elif bcrypt.check_password_hash(user.password, form.password.data):
|
||||
login_user(user, remember=form.remember.data)
|
||||
|
||||
return redirect(url_for("main.home"))
|
||||
|
||||
response = {"url": url_for("main.home")}
|
||||
else:
|
||||
print("password was wrong")
|
||||
return redirect(url_for("user_system.login"))
|
||||
response = {"err": "Password is wrong"}
|
||||
else:
|
||||
response = {"err": str(form.errors)}
|
||||
|
||||
return render_template("user/login.html", title="Login", form=form)
|
||||
return jsonify(data=response)
|
||||
|
||||
|
||||
# this logs the user out and redirects to the login page
|
||||
|
@ -1 +1 @@
|
||||
version = "v0.34"
|
||||
version = "v0.4"
|
||||
|
@ -4,4 +4,5 @@ accent = orange
|
||||
background = None
|
||||
roles = admin,user,public_user
|
||||
home_access_groups = admin_only
|
||||
settings_access_groups = admin_only
|
||||
settings_access_groups = admin_only
|
||||
custom_app_title = DashMachine
|
28
migrations/versions/ce94252d9023_.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: ce94252d9023
|
||||
Revises: a36cddc6266e
|
||||
Create Date: 2020-03-08 10:56:30.470619
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "ce94252d9023"
|
||||
down_revision = "a36cddc6266e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("settings", sa.Column("custom_app_title", sa.String(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("settings", "custom_app_title")
|
||||
# ### end Alembic commands ###
|
@ -2,7 +2,8 @@
|
||||
- [ ] I have added a .ini file to DashMachine with the same name used for [App Name] in the .ini file (e.g. 'App Name.ini')
|
||||
- [ ] My .ini has the exact format as the others in the folder, only changing the name, icon location, and description.
|
||||
- [ ] I used the official description and name for the app, found on their repository/website.
|
||||
- [ ] I chose an icon (.png file) sized under 200px, with a transparent background, preferably the icon only version (not icon w/ text) of the app's icon.
|
||||
- [ ] I chose an icon (.png file) sized over 64px, with a transparent background, square aspect ratio, preferably the icon only version (not icon w/ text) of the app's icon.
|
||||
- [ ] I understand that the icon will be resized to 64px x 64px and have verified that it looks good at that size.
|
||||
- [ ] I have added the icon to static/images/apps with the correct name matching what's in the .ini.
|
||||
- [ ] I tested it to make sure it looks good in the interface
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
[Wiki.js]
|
||||
[Wikijs]
|
||||
prefix = https://
|
||||
url = your-website.com
|
||||
icon = static/images/apps/wikijs.png
|
||||
description = A modern, lightweight and powerful wiki app built on Node.js
|
||||
open_in = this_tab
|
||||
open_in = this_tab
|
||||
|