diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ee5ce9d --- /dev/null +++ b/.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/* + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..cd7e525 --- /dev/null +++ b/.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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d146d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.venv +__pycache__/ +*.py[cod] +*$py.class +venv +.eggs +.pytest_cache +*.egg-info +.DS_Store +.vscode diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e54419 --- /dev/null +++ b/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 diff --git a/datasette_notebook/__init__.py b/datasette_notebook/__init__.py new file mode 100644 index 0000000..d024c9c --- /dev/null +++ b/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.*)$", notebook)] + + +def config_notebook(datasette): + config = datasette.plugin_config("datasette-notebook") or {} + return config.get("database") or "notebook" diff --git a/datasette_notebook/templates/datasette_notebook/edit.html b/datasette_notebook/templates/datasette_notebook/edit.html new file mode 100644 index 0000000..81470a0 --- /dev/null +++ b/datasette_notebook/templates/datasette_notebook/edit.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Create page: {{ slug }}{% endblock %} + +{% block content %} +

Create page: {{ slug }}

+ +
+

+ + +

+

+
+ +{% endblock %} diff --git a/datasette_notebook/templates/datasette_notebook/view.html b/datasette_notebook/templates/datasette_notebook/view.html new file mode 100644 index 0000000..98097d9 --- /dev/null +++ b/datasette_notebook/templates/datasette_notebook/view.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block title %}{{ slug }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +

{{ slug or "Index" }}

+
+{{ rendered }} +
+ +{% if children %} + +{% endif %} + +
Edit this page +
+

+ + +

+

+
+
+ +{% endblock %} diff --git a/datasette_notebook/utils.py b/datasette_notebook/utils.py new file mode 100644 index 0000000..5fe8547 --- /dev/null +++ b/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 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..cc29315 --- /dev/null +++ b/setup.py @@ -0,0 +1,36 @@ +from setuptools import setup +import os + +VERSION = "0.1a0" + + +def get_long_description(): + with open( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"), + encoding="utf8", + ) as fp: + return fp.read() + + +setup( + name="datasette-notebook", + description="A markdown wiki and dashboarding system for Datasette", + long_description=get_long_description(), + long_description_content_type="text/markdown", + author="Simon Willison", + url="https://github.com/simonw/datasette-notebook", + project_urls={ + "Issues": "https://github.com/simonw/datasette-notebook/issues", + "CI": "https://github.com/simonw/datasette-notebook/actions", + "Changelog": "https://github.com/simonw/datasette-notebook/releases", + }, + license="Apache License, Version 2.0", + version=VERSION, + packages=["datasette_notebook"], + entry_points={"datasette": ["notebook = datasette_notebook"]}, + install_requires=["datasette", "sqlite-utils", "markdown", "bleach"], + extras_require={"test": ["pytest", "pytest-asyncio"]}, + tests_require=["datasette-notebook[test]"], + package_data={"datasette_notebook": ["static/*", "templates/*"]}, + python_requires=">=3.6", +) diff --git a/tests/test_notebook.py b/tests/test_notebook.py new file mode 100644 index 0000000..cc2e1f4 --- /dev/null +++ b/tests/test_notebook.py @@ -0,0 +1,11 @@ +from datasette.app import Datasette +import pytest + + +@pytest.mark.asyncio +async def test_plugin_is_installed(): + datasette = Datasette([], memory=True) + response = await datasette.client.get("/-/plugins.json") + assert response.status_code == 200 + installed_plugins = {p["name"] for p in response.json()} + assert "datasette-notebook" in installed_plugins