├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── README.md ├── datasette_notebook ├── __init__.py ├── templates │ └── datasette_notebook │ │ ├── edit.html │ │ └── view.html └── utils.py ├── setup.py └── tests ├── test_markup.py └── test_notebook.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: [3.6, 3.7, 3.8, 3.9] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - uses: actions/cache@v2 20 | name: Configure pip caching 21 | with: 22 | path: ~/.cache/pip 23 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 24 | restore-keys: | 25 | ${{ runner.os }}-pip- 26 | - name: Install dependencies 27 | run: | 28 | pip install -e '.[test]' 29 | - name: Run tests 30 | run: | 31 | pytest 32 | deploy: 33 | runs-on: ubuntu-latest 34 | needs: [test] 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Set up Python 38 | uses: actions/setup-python@v2 39 | with: 40 | python-version: '3.9' 41 | - uses: actions/cache@v2 42 | name: Configure pip caching 43 | with: 44 | path: ~/.cache/pip 45 | key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }} 46 | restore-keys: | 47 | ${{ runner.os }}-publish-pip- 48 | - name: Install dependencies 49 | run: | 50 | pip install setuptools wheel twine 51 | - name: Publish 52 | env: 53 | TWINE_USERNAME: __token__ 54 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 55 | run: | 56 | python setup.py sdist bdist_wheel 57 | twine upload dist/* 58 | 59 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [3.6, 3.7, 3.8, 3.9] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - uses: actions/cache@v2 18 | name: Configure pip caching 19 | with: 20 | path: ~/.cache/pip 21 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 22 | restore-keys: | 23 | ${{ runner.os }}-pip- 24 | - name: Install dependencies 25 | run: | 26 | pip install -e '.[test]' 27 | - name: Run tests 28 | run: | 29 | pytest 30 | 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | venv 6 | .eggs 7 | .pytest_cache 8 | *.egg-info 9 | .DS_Store 10 | .vscode 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # datasette-notebook 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/datasette-notebook.svg)](https://pypi.org/project/datasette-notebook/) 4 | [![Changelog](https://img.shields.io/github/v/release/simonw/datasette-notebook?include_prereleases&label=changelog)](https://github.com/simonw/datasette-notebook/releases) 5 | [![Tests](https://github.com/simonw/datasette-notebook/workflows/Test/badge.svg)](https://github.com/simonw/datasette-notebook/actions?query=workflow%3ATest) 6 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette-notebook/blob/main/LICENSE) 7 | 8 | A markdown wiki and dashboarding system for Datasette 9 | 10 | This is an **experimental alpha** and everything about it is likely to change. 11 | 12 | ## Installation 13 | 14 | Install this plugin in the same environment as Datasette. 15 | 16 | $ datasette install datasette-notebook 17 | 18 | ## Usage 19 | 20 | Start Datasette with a SQLite file called `notebook.db`: 21 | 22 | datasette notebook.db --create 23 | 24 | Here the `--create` option will create that file if it does not yet exist. 25 | 26 | Visit `/n` to create an index page. Visit `/n/name` to create a page with that name. 27 | 28 | You can link to other pages using `[[WikiLink]]` syntax. This will create a link to `/n/WikiLink` - spaces will be converted to underscores, and you can link to nested pages such as `[[nested/page]]`. 29 | 30 | ## Configuration 31 | 32 | You can use a file other than `notebook.db` by configuring it using `metadata.yml`. To use a database file called `otherfile.db` you would use this: 33 | 34 | ```yaml 35 | plugins: 36 | datasette-notebook: 37 | database: otherfile 38 | ``` 39 | Then start Datasette like so: 40 | 41 | datasette otherfile.db 42 | 43 | 44 | ## Development 45 | 46 | To set up this plugin locally, first checkout the code. Then create a new virtual environment: 47 | 48 | cd datasette-notebook 49 | python3 -mvenv venv 50 | source venv/bin/activate 51 | 52 | Or if you are using `pipenv`: 53 | 54 | pipenv shell 55 | 56 | Now install the dependencies and test dependencies: 57 | 58 | pip install -e '.[test]' 59 | 60 | To run the tests: 61 | 62 | pytest 63 | -------------------------------------------------------------------------------- /datasette_notebook/__init__.py: -------------------------------------------------------------------------------- 1 | from datasette.utils.asgi import Response, NotFound 2 | from datasette import hookimpl 3 | import sqlite_utils 4 | from .utils import render_markdown 5 | 6 | 7 | @hookimpl 8 | def startup(datasette): 9 | # Create tables for notebook DB if needed 10 | db_name = config_notebook(datasette) 11 | if db_name not in datasette.databases: 12 | return 13 | 14 | def create_tables(conn): 15 | db = sqlite_utils.Database(conn) 16 | if not db["pages"].exists(): 17 | db["pages"].create( 18 | { 19 | "slug": str, 20 | "content": str, 21 | }, 22 | pk="slug", 23 | ) 24 | 25 | async def inner(): 26 | await datasette.get_database(db_name).execute_write_fn( 27 | create_tables, block=True 28 | ) 29 | 30 | return inner 31 | 32 | 33 | async def notebook(request, datasette): 34 | slug = request.url_vars.get("slug") or "" 35 | db_name = config_notebook(datasette) 36 | try: 37 | db = datasette.get_database(db_name) 38 | except KeyError: 39 | raise NotFound("Could not find database: {}".format(db_name)) 40 | 41 | if request.method == "POST": 42 | vars = await request.post_vars() 43 | content = vars.get("content") 44 | if content: 45 | await db.execute_write( 46 | "INSERT OR REPLACE INTO pages (slug, content) VALUES(?, ?)", 47 | [slug, content], 48 | block=True, 49 | ) 50 | return Response.redirect(request.path) 51 | else: 52 | return Response.html("content= is required", status=400) 53 | 54 | row = (await db.execute("select * from pages where slug = ?", [slug])).first() 55 | if row is None: 56 | # Form to create a page 57 | return Response.html( 58 | await datasette.render_template( 59 | "datasette_notebook/edit.html", 60 | { 61 | "slug": slug, 62 | }, 63 | request=request, 64 | ) 65 | ) 66 | 67 | if slug == "": 68 | children = await db.execute("select * from pages where slug != ''") 69 | else: 70 | children = await db.execute( 71 | "select * from pages where slug like ?", ["{}/%".format(slug)] 72 | ) 73 | 74 | return Response.html( 75 | await datasette.render_template( 76 | "datasette_notebook/view.html", 77 | { 78 | "slug": slug, 79 | "content": row["content"], 80 | "rendered": render_markdown(row["content"], datasette), 81 | "children": children.rows, 82 | }, 83 | request=request, 84 | ) 85 | ) 86 | 87 | 88 | @hookimpl 89 | def register_routes(): 90 | return [(r"^/n$", notebook), (r"^/n/(?P.*)$", notebook)] 91 | 92 | 93 | @hookimpl 94 | def menu_links(datasette): 95 | db_name = config_notebook(datasette) 96 | if db_name in datasette.databases: 97 | return [ 98 | {"href": datasette.urls.path("/n"), "label": "Notebook"}, 99 | ] 100 | 101 | 102 | def config_notebook(datasette): 103 | config = datasette.plugin_config("datasette-notebook") or {} 104 | return config.get("database") or "notebook" 105 | -------------------------------------------------------------------------------- /datasette_notebook/templates/datasette_notebook/edit.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Create page: {{ slug }}{% endblock %} 4 | 5 | {% block content %} 6 |

Create page: {{ slug }}

7 | 8 |
9 |

10 | 11 | 12 |

13 |

14 |
15 | 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /datasette_notebook/templates/datasette_notebook/view.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ slug }}{% endblock %} 4 | 5 | {% block extra_head %} 6 | 18 | {% endblock %} 19 | 20 | {% block content %} 21 |

{{ slug or "Index" }}

22 |
23 | {{ rendered }} 24 |
25 | 26 | {% if children %} 27 | 32 | {% endif %} 33 | 34 |
Edit this page 35 |
36 |

37 | 38 | 39 |

40 |

41 |
42 |
43 | 44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /datasette_notebook/utils.py: -------------------------------------------------------------------------------- 1 | import bleach 2 | from bleach.sanitizer import Cleaner 3 | from bleach.html5lib_shim import Filter 4 | import markdown 5 | from markdown.extensions.wikilinks import WikiLinkExtension, WikiLinksInlineProcessor 6 | from markupsafe import Markup 7 | 8 | 9 | def render_markdown(value, datasette): 10 | attributes = {"a": ["href"], "img": ["src", "alt"]} 11 | cleaner = Cleaner( 12 | tags=[ 13 | "a", 14 | "abbr", 15 | "acronym", 16 | "b", 17 | "blockquote", 18 | "code", 19 | "em", 20 | "i", 21 | "li", 22 | "ol", 23 | "strong", 24 | "ul", 25 | "pre", 26 | "p", 27 | "h1", 28 | "h2", 29 | "h3", 30 | "h4", 31 | "h5", 32 | "h6", 33 | "img", 34 | ], 35 | attributes=attributes, 36 | filters=[ImageMaxWidthFilter], 37 | ) 38 | html = bleach.linkify( 39 | cleaner.clean( 40 | markdown.markdown( 41 | value, 42 | output_format="html5", 43 | extensions=[ 44 | "fenced_code", 45 | CustomWikiLinkExtension( 46 | base_url=datasette.urls.path("/n/"), 47 | end_url="", 48 | build_url=build_url, 49 | ), 50 | ], 51 | ) 52 | ) 53 | ) 54 | return Markup(html) 55 | 56 | 57 | class CustomWikiLinkExtension(WikiLinkExtension): 58 | # Subclassed to support [[foo/bar]] with / in it 59 | 60 | def extendMarkdown(self, md): 61 | self.md = md 62 | 63 | WIKILINK_RE = r"\[\[([\w0-9_ -\\]+)\]\]" 64 | wikilinkPattern = WikiLinksInlineProcessor(WIKILINK_RE, self.getConfigs()) 65 | wikilinkPattern.md = md 66 | md.inlinePatterns.register(wikilinkPattern, "wikilink", 75) 67 | 68 | 69 | def build_url(label, base, end): 70 | """ Build a url from the label, a base, and an end. """ 71 | clean_label = label.replace(" ", "_") 72 | return "{}{}{}".format(base, clean_label, end) 73 | 74 | 75 | class ImageMaxWidthFilter(Filter): 76 | """Adds style="max-width: 100%" to any image tags""" 77 | 78 | def __iter__(self): 79 | for token in Filter.__iter__(self): 80 | if token["type"] == "EmptyTag" and token["name"] == "img": 81 | token["data"][(None, "style")] = "max-width: 100%" 82 | yield token 83 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | VERSION = "0.2a0" 5 | 6 | 7 | def get_long_description(): 8 | with open( 9 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"), 10 | encoding="utf8", 11 | ) as fp: 12 | return fp.read() 13 | 14 | 15 | setup( 16 | name="datasette-notebook", 17 | description="A markdown wiki and dashboarding system for Datasette", 18 | long_description=get_long_description(), 19 | long_description_content_type="text/markdown", 20 | author="Simon Willison", 21 | url="https://github.com/simonw/datasette-notebook", 22 | project_urls={ 23 | "Issues": "https://github.com/simonw/datasette-notebook/issues", 24 | "CI": "https://github.com/simonw/datasette-notebook/actions", 25 | "Changelog": "https://github.com/simonw/datasette-notebook/releases", 26 | }, 27 | license="Apache License, Version 2.0", 28 | version=VERSION, 29 | packages=["datasette_notebook"], 30 | entry_points={"datasette": ["notebook = datasette_notebook"]}, 31 | install_requires=["datasette", "sqlite-utils", "markdown", "bleach"], 32 | extras_require={"test": ["pytest", "pytest-asyncio"]}, 33 | tests_require=["datasette-notebook[test]"], 34 | package_data={"datasette_notebook": ["static/*", "templates/*/*"]}, 35 | python_requires=">=3.6", 36 | ) 37 | -------------------------------------------------------------------------------- /tests/test_markup.py: -------------------------------------------------------------------------------- 1 | from datasette.app import Datasette 2 | from datasette_notebook.utils import render_markdown 3 | import pytest 4 | import sqlite3 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "input,expected", 9 | ( 10 | ("# Hello", "

Hello

"), 11 | ("[[foo]]", '

foo

'), 12 | ("[[foo/bar]]", '

foo/bar

'), 13 | ), 14 | ) 15 | def test_render_markdown(input, expected): 16 | datasette = Datasette([], memory=True) 17 | output = render_markdown(input, datasette) 18 | assert output == expected 19 | -------------------------------------------------------------------------------- /tests/test_notebook.py: -------------------------------------------------------------------------------- 1 | from datasette.app import Datasette 2 | import pytest 3 | import sqlite3 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_404_if_no_notebook_database(): 8 | datasette = Datasette([], memory=True) 9 | response = await datasette.client.get("/n") 10 | assert response.status_code == 404 11 | assert "Could not find database: notebook" in response.text 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_successful_startup(tmpdir): 16 | notebook_db = str(tmpdir / "notebook.db") 17 | sqlite3.connect(notebook_db).execute("vacuum") 18 | datasette = Datasette([notebook_db]) 19 | await datasette.invoke_startup() 20 | response = await datasette.client.get("/n") 21 | assert response.status_code == 200 22 | 23 | 24 | @pytest.mark.asyncio 25 | @pytest.mark.parametrize("enabled", (True, False)) 26 | async def test_menu_link(tmpdir, enabled): 27 | files = [] 28 | if enabled: 29 | notebook_db = str(tmpdir / "notebook.db") 30 | sqlite3.connect(notebook_db).execute("vacuum") 31 | files = [notebook_db] 32 | datasette = Datasette(files) 33 | response = await datasette.client.get("/") 34 | assert response.status_code == 200 35 | fragment = '
  • Notebook
  • ' 36 | if enabled: 37 | assert fragment in response.text 38 | else: 39 | assert fragment not in response.text 40 | --------------------------------------------------------------------------------