Skip to content

Commit

Permalink
Initial prototye
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Sep 22, 2021
0 parents commit 34195e7
Show file tree
Hide file tree
Showing 10 changed files with 405 additions and 0 deletions.
58 changes: 58 additions & 0 deletions .github/workflows/publish.yml
@@ -0,0 +1,58 @@
name: Publish Python Package

on:
release:
types: [created]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
pip install -e '.[test]'
- name: Run tests
run: |
pytest
deploy:
runs-on: ubuntu-latest
needs: [test]
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-publish-pip-
- name: Install dependencies
run: |
pip install setuptools wheel twine
- name: Publish
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*
30 changes: 30 additions & 0 deletions .github/workflows/test.yml
@@ -0,0 +1,30 @@
name: Test

on: [push]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
pip install -e '.[test]'
- name: Run tests
run: |
pytest
10 changes: 10 additions & 0 deletions .gitignore
@@ -0,0 +1,10 @@
.venv
__pycache__/
*.py[cod]
*$py.class
venv
.eggs
.pytest_cache
*.egg-info
.DS_Store
.vscode
55 changes: 55 additions & 0 deletions README.md
@@ -0,0 +1,55 @@
# datasette-notebook

[![PyPI](https://img.shields.io/pypi/v/datasette-notebook.svg)](https://pypi.org/project/datasette-notebook/)
[![Changelog](https://img.shields.io/github/v/release/simonw/datasette-notebook?include_prereleases&label=changelog)](https://github.com/simonw/datasette-notebook/releases)
[![Tests](https://github.com/simonw/datasette-notebook/workflows/Test/badge.svg)](https://github.com/simonw/datasette-notebook/actions?query=workflow%3ATest)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette-notebook/blob/main/LICENSE)

A markdown wiki and dashboarding system for Datasette

This is an **experimental alpha** and everything about it is likely to change.

## Installation

Install this plugin in the same environment as Datasette.

$ datasette install datasette-notebook

## Usage

With this plugin you must either run Datasette with a file called `notebook.db`:

datasette notebook.db --create

Here the `--create` option will create that file if it does not yet exist.

Or you can use some other file name and configure that using `metadata.yml`:

```yaml
plugins:
datasette-notebook:
database: otherfile
```
Then run Datasette with `otherfile.db`.

Visit `/n` to create an index page. Visit `/n/name` to create a page with that name.

## Development

To set up this plugin locally, first checkout the code. Then create a new virtual environment:

cd datasette-notebook
python3 -mvenv venv
source venv/bin/activate

Or if you are using `pipenv`:

pipenv shell

Now install the dependencies and test dependencies:

pip install -e '.[test]'

To run the tests:

pytest
93 changes: 93 additions & 0 deletions datasette_notebook/__init__.py
@@ -0,0 +1,93 @@
from datasette.utils.asgi import Response
from datasette import hookimpl
import sqlite_utils
from .utils import render_markdown


@hookimpl
def startup(datasette):
# Create tables for notebook DB if needed
db_name = config_notebook(datasette)
assert (
db_name in datasette.databases
), "datasette-notebook needs a '{}' database to start".format(db_name)

def create_tables(conn):
db = sqlite_utils.Database(conn)
if not db["pages"].exists():
db["pages"].create(
{
"slug": str,
"content": str,
},
pk="slug",
)

async def inner():
await datasette.get_database(db_name).execute_write_fn(
create_tables, block=True
)

return inner


async def notebook(request, datasette):
slug = request.url_vars.get("slug") or ""
db_name = config_notebook(datasette)
db = datasette.get_database(db_name)

if request.method == "POST":
vars = await request.post_vars()
content = vars.get("content")
if content:
await db.execute_write(
"INSERT OR REPLACE INTO pages (slug, content) VALUES(?, ?)",
[slug, content],
block=True,
)
return Response.redirect(request.path)
else:
return Response.html("content= is required", status=400)

row = (await db.execute("select * from pages where slug = ?", [slug])).first()
if row is None:
# Form to create a page
return Response.html(
await datasette.render_template(
"datasette_notebook/edit.html",
{
"slug": slug,
},
request=request,
)
)

if slug == "":
children = await db.execute("select * from pages where slug != ''")
else:
children = await db.execute(
"select * from pages where slug like ?", ["{}/%".format(slug)]
)

return Response.html(
await datasette.render_template(
"datasette_notebook/view.html",
{
"slug": slug,
"content": row["content"],
"rendered": render_markdown(row["content"]),
"children": children.rows,
},
request=request,
)
)


@hookimpl
def register_routes():
return [(r"^/n$", notebook), (r"^/n/(?P<slug>.*)$", notebook)]


def config_notebook(datasette):
config = datasette.plugin_config("datasette-notebook") or {}
return config.get("database") or "notebook"
16 changes: 16 additions & 0 deletions datasette_notebook/templates/datasette_notebook/edit.html
@@ -0,0 +1,16 @@
{% extends "base.html" %}

{% block title %}Create page: {{ slug }}{% endblock %}

{% block content %}
<h1>Create page: {{ slug }}</h1>

<form action="" method="POST">
<p>
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<textarea style="width: 90%; height: 20em" name="content"></textarea>
</p>
<p><input type="submit" value="Save"></p>
</form>

{% endblock %}
44 changes: 44 additions & 0 deletions datasette_notebook/templates/datasette_notebook/view.html
@@ -0,0 +1,44 @@
{% extends "base.html" %}

{% block title %}{{ slug }}{% endblock %}

{% block extra_head %}
<style>
.markdown ul {
margin-bottom: 0.8rem;
padding-left: 1.25rem;
}
.markdown ul li {
list-style-type: disc;
}
summary {
cursor: pointer;
}
</style>
{% endblock %}

{% block content %}
<h1>{{ slug or "Index" }}</h1>
<div class="markdown">
{{ rendered }}
</div>

{% if children %}
<ul class="bullets">
{% for child in children %}
<li><a href="/n/{{ child.slug }}">{{ child.slug }}</a></li>
{% endfor %}
</ul>
{% endif %}

<details style="margin-top: 2em"><summary>Edit this page</summary>
<form action="" method="POST">
<p>
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
<textarea style="width: 90%; height: 20em" name="content">{{ content }}</textarea>
</p>
<p><input type="submit" value="Save"></p>
</form>
</details>

{% endblock %}
52 changes: 52 additions & 0 deletions datasette_notebook/utils.py
@@ -0,0 +1,52 @@
import bleach
from bleach.sanitizer import Cleaner
from bleach.html5lib_shim import Filter
import markdown
from markupsafe import Markup


def render_markdown(value):
attributes = {"a": ["href"], "img": ["src", "alt"]}
cleaner = Cleaner(
tags=[
"a",
"abbr",
"acronym",
"b",
"blockquote",
"code",
"em",
"i",
"li",
"ol",
"strong",
"ul",
"pre",
"p",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"img",
],
attributes=attributes,
filters=[ImageMaxWidthFilter],
)
html = bleach.linkify(
cleaner.clean(
markdown.markdown(value, output_format="html5", extensions=["fenced_code"])
)
)
return Markup(html)


class ImageMaxWidthFilter(Filter):
"""Adds style="max-width: 100%" to any image tags"""

def __iter__(self):
for token in Filter.__iter__(self):
if token["type"] == "EmptyTag" and token["name"] == "img":
token["data"][(None, "style")] = "max-width: 100%"
yield token

0 comments on commit 34195e7

Please sign in to comment.