my eye

Bootstrap

Committed 01ed78

index 0000000..27e538a
--- /dev/null

+name: Run Tests and Analysis
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+    branches:
+      - main
+
+jobs:
+  build-linux:
+    strategy:
+      matrix:
+        python-version: ["3.10"]
+    runs-on: "ubuntu-latest"
+    steps:
+      - name: Install graphviz
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y graphviz
+
+      - uses: actions/checkout@v3
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Install Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      - name: Remove development dependencies
+        run: sed -i '/\[tool.poetry.group.dev.dependencies\]/,/\[/d' pyproject.toml
+
+      - name: Install Poetry
+        uses: snok/install-poetry@v1
+        with:
+          version: 1.2.2
+          virtualenvs-in-project: true
+
+      - name: Install dependencies
+        run: poetry install --no-interaction --no-root
+
+      - name: Install library
+        run: poetry install --no-interaction
+
+      - name: Install development tools
+        run: poetry add gmpg
+
+      - uses: psf/black@stable
+        with:
+          options: "--check --verbose"
+          src: "."
+          version: "23.7"
+
+      - uses: isort/isort-action@v1
+        with:
+          configuration: "--profile black"
+
+      - run: echo "$(poetry env info --path)/bin" >> $GITHUB_PATH
+      - uses: jakebailey/pyright-action@v1
+
+      # - name: Run tests
+      #   run: poetry run gmpg test
+
+      - name: Run analysis
+        run: poetry run gmpg analyze
+
+      - name: Generate dependency graph
+        run: poetry run gmpg graph
+
+      - uses: actions/upload-artifact@v3
+        with:
+          name: analysis
+          path: |
+            test_coverage.xml
+            test_results.xml
+            api_python.json
+            deps.svg

index 0000000..33040d7
--- /dev/null

+[tool.poetry]
+name = "webint-posts"
+version = "0.0.12"
+description = "manage posts on your website"
+keywords = ["Micropub"]
+authors = ["Angelo Gladding <angelo@ragt.ag>"]
+license = "AGPL-3.0-or-later"
+packages = [{include="webint_posts"}]
+
+[tool.poetry.plugins."webapps"]
+posts = "webint_posts:app"
+
+[tool.poetry.dependencies]
+python = ">=3.10,<3.11"
+webint = ">=0.0"
+micropub = ">=0.0"
+microformats = "^0.3.5"
+webint-media = "^0.0.54"
+webint-search = "^0.0.9"
+
+[tool.poetry.group.dev.dependencies]
+gmpg = {path="../gmpg", develop=true}
+bgq = {path="../bgq", develop=true}
+newmath = {path="../newmath", develop=true}
+sqlyte = {path="../sqlyte", develop=true}
+webagt = {path="../webagt", develop=true}
+webint = {path="../webint", develop=true}
+webint-media = {path="../webint-media", develop=true}
+webint-search = {path="../webint-search", develop=true}
+micropub = {path="../python-micropub", develop=true}
+microformats = {path="../python-microformats", develop=true}
+
+# [[tool.poetry.source]]
+# name = "main"
+# url = "https://ragt.ag/code/pypi"
+
+[tool.pyright]
+reportGeneralTypeIssues = false
+reportOptionalMemberAccess = false
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"

index 0000000..6807d4f
--- /dev/null

+"""
+Manage posts on your website.
+
+Implements a Micropub server.
+
+"""
+
+import random
+
+import mf
+
+# import micropub
+import web
+import webagt
+import webint_media
+import webint_search
+
+# import webmention
+# import websub
+
+
+class PostAccessError(Exception):
+    """Post could not be access."""
+
+
+class PostNotFoundError(Exception):
+    """Post could not be found."""
+
+
+app = web.application(
+    __name__,
+    prefix="posts",
+    args={
+        "channel": r".+",
+        "entry": r".+",
+        "year": r"\d{4}",
+        "month": r"\d{2}",
+        "day": r"\d{2}",
+        "post": web.nb60_re + r"{,4}",
+        "slug": r"[\w_-]+",
+        "page": r"[\w-]+",
+    },
+    model={
+        "resources": {
+            "permalink": "TEXT UNIQUE",
+            "version": "TEXT UNIQUE",
+            "resource": "JSON",
+        },
+        "deleted_resources": {
+            "permalink": "TEXT",
+            "version": "TEXT UNIQUE",
+            "resource": "JSON",
+        },
+        "syndication": {"destination": "JSON NOT NULL"},
+    },
+)
+
+# TODO supported_types = {"RSVP": ["in-reply-to", "rsvp"]}
+
+
+def get_config():
+    """"""
+    syndication_endpoints = []
+    # TODO "channels": generate_channels()}
+    return {
+        "q": ["category", "contact", "source", "syndicate-to"],
+        "media-endpoint": f"{web.tx.origin}/media",
+        "syndicate-to": syndication_endpoints,
+        "visibility": ["public", "unlisted", "private"],
+        "timezone": "America/Los_Angeles",
+    }
+
+
+def generate_trailer():
+    letterspace = "abcdefghijkmnopqrstuvwxyz23456789"
+    trailer = "".join([random.choice(letterspace) for i in range(2)])
+    if trailer in ("bs", "ok", "hi", "oz", "lb"):
+        return generate_trailer()
+    else:
+        return trailer
+
+
+@app.wrap
+def linkify_head(handler, main_app):
+    """."""
+    yield
+    if web.tx.request.uri.path == "":
+        web.add_rel_links(micropub="/posts")
+
+
+def route_unrouted(handler, app):  # TODO XXX ???
+    """Handle channels."""
+    for channel in app.model.get_channels():
+        if channel["resource"]["url"][0] == f"/{web.tx.request.uri.path}":
+            posts = app.model.get_posts_by_channel(channel["resource"]["uid"][0])
+            web.header("Content-Type", "text/html")
+            raise web.OK(app.view.channel(channel, posts))
+    yield
+
+
+@app.control("")
+class MicropubEndpoint:
+    """Your posts."""
+
+    def get(self):
+        """"""
+        try:
+            form = web.form("q")
+        except web.BadRequest:
+            return app.view.activity(
+                app.model.get_channels(),
+                [],  # TODO web.application("webint_media").model.get_media(),
+                app.model.get_posts(),
+            )
+
+        def generate_channels():
+            return [
+                {"name": r["name"][0], "uid": r["uid"][0]}
+                for r in app.model.get_channels()
+            ]
+
+        # TODO XXX elif form.q == "channel":
+        # TODO XXX     response = {"channels": generate_channels()}
+        if form.q == "config":
+            response = get_config()
+        elif form.q == "source":
+            response = {}
+            if "search" in form:
+                response = {
+                    "items": [
+                        {"url": [r["resource"]["url"]]}
+                        for r in app.model.search(form.search)
+                    ]
+                }
+            elif "url" in form:
+                response = dict(app.model.read(form.url))
+            else:
+                pass  # TODO list all posts
+        elif form.q == "category":
+            response = {"categories": app.model.get_categories()}
+        else:
+            raise web.BadRequest("unsupported query. check `q=config` for support.")
+        web.header("Content-Type", "application/json")
+        return response
+
+    def post(self):
+        """"""
+        # TODO check for bearer token or session cookie
+        # try:
+        #     payload = web.form("h")
+        # except web.BadRequest:
+        post_type = None
+        properties = {}
+        if str(web.tx.request.headers["content-type"]) == "application/json":
+            payload = web.tx.request.body._data
+            if "action" not in payload:
+                properties = payload["properties"]
+                post_type = payload.pop("type")[0].split("-")[1]
+        else:
+            payload = web.form()
+            post_type = payload.pop("h", None)
+        # else:  # form-encoded update/delete
+        #     properties = payload
+        # else:  # form-encoded create
+        #     post_type = payload.pop("h")
+        #     properties = payload
+        action = payload.pop("action", "create")
+        if not properties:
+            properties = dict(payload)
+
+        if not web.tx.user.is_owner:
+            try:
+                token = properties.pop("access_token")[0]
+            except KeyError:
+                token = str(web.tx.request.headers["authorization"])
+            auth = web.application("webint_auth").model.get_auth_from_token(token)
+            properties["token"] = auth["auth_id"]
+
+        def collect_properties(properties):
+            props = {}
+            for k, v in properties.items():
+                k = k.rstrip("[]")
+                if not isinstance(v, list):
+                    v = [v]
+                if k == "content" and not isinstance(v[0], dict):
+                    v[0] = {"html": v[0]}
+                if not v[0]:
+                    continue
+                props[k] = v
+            return props
+
+        mentions = []
+        permalink = payload.pop("url", None)
+        syndication = []
+        if action == "create":
+            permalink, mentions = app.model.create(
+                post_type, **collect_properties(properties)
+            )
+            syndication = properties.get("syndication")
+            # web.header("Link", '</blat>; rel="shortlink"', add=True)
+            # web.header("Link", '<https://twitter.com/angelogladding/status/'
+            #                    '30493490238590234>; rel="syndication"', add=True)
+
+            # XXX web.braid(permalink, ...)
+
+            # TODO web.enqueue(
+            # TODO     websub.publish,
+            # TODO     f"{web.tx.origin}/subscriptions",
+            # TODO     f"{web.tx.origin}",
+            # TODO     str(content.Homepage().get()),
+            # TODO )
+        elif action == "update":
+            if "add" in payload:
+                app.model.update(permalink, add=collect_properties(payload["add"]))
+                syndication = payload["add"]["syndication"]
+            if "replace" in payload:
+                app.model.update(
+                    permalink, replace=collect_properties(payload["replace"])
+                )
+                syndication = payload["replace"]["syndication"]
+        elif action == "delete":
+            app.model.delete(permalink)
+        else:
+            return f"ACTION `{action}` NOT IMPLEMENTED"
+        # XXX if "mastodon" in syndication:
+        # XXX     mentions.append("https://fed.brid.gy")
+        for mention in mentions:
+            web.application("webint_mentions").model.send_mention(permalink, mention)
+        if action == "create":
+            raise web.Created(f"post created at: {permalink}", permalink)
+
+
+@app.control("channels")
+class Channels:
+    """Your channels."""
+
+    def get(self):
+        """"""
+        return app.view.channels(app.model.get_channels())
+
+
+@app.control("channels/{channel}")
+class Channel:
+    """A single channel."""
+
+    def get(self):
+        """"""
+        return app.view.channel(self.channel)
+
+
+@app.control("syndication")
+class Syndication:
+    """Your syndication destinations."""
+
+    def get(self):
+        """"""
+        return app.view.syndication()
+
+    def post(self):
+        """"""
+        destinations = web.form()
+        if "twitter_username" in destinations:
+            un = destinations.twitter_username
+            # TODO pw = destinations.twitter_password
+            # TODO sign in
+            user_photo = ""  # TODO doc.qS(f"a[href=/{un}/photo] img").src
+            destination = {
+                "uid": f"//twitter.com/{un}",
+                "name": f"{un} on Twitter",
+                "service": {
+                    "name": "Twitter",
+                    "url": "//twitter.com",
+                    "photo": "//abs.twimg.com/favicons/" "twitter.ico",
+                },
+                "user": {"name": un, "url": f"//twitter.com/{un}", "photo": user_photo},
+            }
+            web.tx.db.insert("syndication", destination=destination)
+        if "github_username" in destinations:
+            un = destinations.github_username
+            # TODO token = destinations.github_token
+            # TODO check the token
+            user_photo = ""  # TODO doc.qS("img.avatar-user.width-full").src
+            destination = {
+                "uid": f"//github.com/{un}",
+                "name": f"{un} on GitHub",
+                "service": {
+                    "name": "GitHub",
+                    "url": "//github.com",
+                    "photo": "//github.githubassets.com/" "favicons/favicon.png",
+                },
+                "user": {"name": un, "url": f"//github.com/{un}", "photo": user_photo},
+            }
+            web.tx.db.insert("syndication", destination=destination)
+
+
+@app.control("{year}", prefixed=False)
+class Year:
+    """Posts for a given year."""
+
+    def get(self, year):
+        """Render a chronological list of posts for the given year."""
+        year = int(year)
+        return app.view.year(
+            year,
+            app.model.get_posts(after=f"{year-1}-12-31", before=f"{year+1}-01-01"),
+        )
+
+
+@app.control("{year}/{month}", prefixed=False)
+class Month:
+    """Posts for a given month."""
+
+    def get(self, year, month):
+        """Render a chronological list of posts for the given month."""
+        year = int(year)
+        month = int(month)
+        return app.view.month(
+            year,
+            month,
+            app.model.get_posts(
+                after=f"{year-1}-{month-1:02}-31", before=f"{year+1}-{month:02}-01"
+            ),
+        )
+
+
+@app.control("{year}/{month}/{day}", prefixed=False)
+class Day:
+    """Posts for a given day."""
+
+    def get(self, year, month, day):
+        """Render a chronological list of posts for the given day."""
+        year = int(year)
+        month = int(month)
+        day = int(day)
+        return app.view.day(
+            year,
+            month,
+            day,
+            app.model.get_posts(
+                after=f"{year-1}-{month-1:02}-{day-1:02}",
+                before=f"{year+1}-{month:02}-{day:02}",
+            ),
+        )
+
+
+@app.control(r"{year}/{month}/{day}/{post}(/{slug})?|{page}", prefixed=False)
+class Permalink:
+    """An individual entry."""
+
+    def get(self, year=None, month=None, day=None, post=None, slug=None, page=None):
+        """Render a page."""
+        try:
+            resource = app.model.read(web.tx.request.uri.path)["resource"]
+        except PostNotFoundError as err:
+            web.header("Content-Type", "text/html")  # TODO FIXME XXX
+            raise web.NotFound(app.view.entry_not_found(err))
+        except PostAccessError as err:
+            web.header("Content-Type", "text/html")  # TODO FIXME XXX
+            raise web.NotFound(app.view.access_denied(err))
+        if resource["visibility"] == "private" and not web.tx.user.session:
+            raise web.Unauthorized(f"/auth?return_to={web.tx.request.uri.path}")
+        mentions = web.application(
+            "webint_mentions"
+        ).model.get_received_mentions_by_target(
+            f"{web.tx.origin}/{web.tx.request.uri.path}"
+        )
+        if page:
+            permalink = f"/{page}"
+        else:
+            permalink = f"/{year}/{month}/{day}/{post}"
+        return app.view.entry(permalink, resource, mentions)
+
+
+@app.query
+def create(db, resource_type, **resource):
+    """Create a resource."""
+    for k, v in resource.items():
+        if not isinstance(v, list):
+            resource[k] = [v]
+        flat_values = []
+        for v in resource[k]:
+            if isinstance(v, dict):
+                if not ("html" in v or "datetime" in v):
+                    v = dict(**v["properties"], type=[v["type"][0].removeprefix("h-")])
+            flat_values.append(v)
+        resource[k] = flat_values
+
+    config = get_config()
+    # TODO deal with `updated`/`drafted`?
+    if "published" in resource:
+        # TODO accept simple eg. published=2020-2-20, published=2020-2-20T02:22:22
+        # XXX resource["published"][0]["datetime"] = pendulum.from_format(
+        # XXX     resource["published"][0]["datetime"], "YYYY-MM-DDTHH:mm:ssZ"
+        # XXX )
+        # XXX published = resource["published"]
+        pass
+    else:
+        resource["published"] = [
+            {
+                "datetime": web.now().isoformat(),
+                "timezone": config["timezone"],
+            }
+        ]
+    published = web.parse_dt(
+        resource["published"][0]["datetime"],
+        tz=resource["published"][0]["timezone"],
+    )
+
+    resource["visibility"] = resource.get("visibility", ["private"])
+    if "audience" in resource:
+        resource["visibility"] = ["protected"]
+    # XXX resource["channel"] = resource.get("channel", [])
+    mentions = []
+    urls = resource.pop("url", [])
+    # if resource_type == "card":
+    #     slug = resource.get("nickname", resource.get("name"))[0]
+    # elif resource_type == "event":
+    #     slug = resource.get("nickname", resource.get("name"))[0]
+    #     urls.insert(0, f"/pub/cards/{web.textslug(slug)}")
+    #     # if resource["uid"] == str(web.uri(web.tx.host.name)):
+    #     #     pass
+    #     urls.insert(0, f"/pub/cards/{web.textslug(slug)}")
+    # elif resource_type == "feed":
+    #     name_slug = web.textslug(resource["name"][0])
+    #     try:
+    #         slug = resource["slug"][0]
+    #     except KeyError:
+    #         slug = name_slug
+    #     resource.update(uid=[slug if slug else name_slug])
+    #     resource.pop("channel", None)
+    #     # XXX urls.insert(0, f"/{slug}")
+    permalink = None
+    if resource_type == "ragt-ag-project":
+        name = resource["name"][0]
+        permalink = f"/code/{name}"
+        urls.insert(0, permalink)
+        resource.update(url=urls, type=[resource_type])
+        web.application("webint_code").model.create_project(name)
+        db.insert(
+            "resources",
+            permalink=permalink,
+            version=web.nbrandom(10),
+            resource=resource,
+        )
+    elif resource_type == "entry":
+        #                                         REQUEST URL
+        # 1) given: url=/xyz                        => look for exact match
+        #     then: url=[/xyz, /2021/3/5...]
+        # 2) given: channel=abc, slug=foo           => construct
+        #     then: url=[/2021/3/5...]
+        # 3) given: no slug                         => only via permalink
+        #     then: url=[/2021/3/5...]
+        post_type = mf.discover_post_type(resource)
+        slug = None
+        if post_type == "article":
+            slug = resource["name"][0]
+        elif post_type == "listen":
+            result = webint_search.search_youtube(resource["listen-of"][0])[0]["id"]
+            web.enqueue(webint_media.download, f"https://youtube.com/watch?v={result}")
+        elif post_type == "bookmark":
+            mentions.append(resource["bookmark-of"][0])
+        elif post_type == "like":
+            mentions.append(resource["like-of"][0])
+        elif post_type == "rsvp":
+            event_url = resource["in-reply-to"][0]
+            if event_url.startswith("calshow:"):
+                event_url = event_url.partition("\n")[2]
+            mentions.append(event_url)
+            resource["in-reply-to"][0] = event_url
+        # elif post_type == "identification":
+        #     identifications = resource["identification-of"]
+        #     identifications[0] = {"type": "cite",
+        #                           **identifications[0]["properties"]}
+        #     textslug = identifications[0]["name"]
+        #     mentions.append(identifications[0]["url"])
+        # elif post_type == "follow":
+        #     follows = resource["follow-of"]
+        #     follows[0] = {"type": "cite", **follows[0]["properties"]}
+        #     textslug = follows[0]["name"]
+        #     mentions.append(follows[0]["url"])
+        #     web.tx.sub.follow(follows[0]["url"])
+        # TODO user indieauth.server.get_identity() ??
+        # XXX author_id = list(db.select("identities"))[0]["card"]
+        # XXX author_id = get_card()db.select("resources")[0]["card"]["version"]
+        resource.update(author=[web.tx.origin])
+
+        resource.update(url=urls, type=[resource_type])
+        permalink_base = f"/{web.timeslug(published)}"
+        while True:
+            permalink = f"{permalink_base}/{generate_trailer()}"
+            resource["url"].append(permalink)
+            try:
+                db.insert(
+                    "resources",
+                    permalink=permalink,
+                    version=web.nbrandom(10),
+                    resource=resource,
+                )
+            except db.IntegrityError:
+                continue
+            break
+    return permalink, mentions
+
+
+@app.query
+def read(db, url):
+    """Return an entry with its metadata."""
+    if not url.startswith(("http://", "https://")):
+        url = f"/{url.strip('/')}"
+    try:
+        resource = db.select(
+            "resources",
+            where="""json_extract(resources.resource, '$.url[0]') == ?""",
+            vals=[url],
+        )[0]
+    except IndexError:
+        try:
+            resource = db.select(
+                "resources",
+                where="""json_extract(resources.resource, '$.url[1]') == ?""",
+                vals=[url],
+            )[0]
+        except IndexError:
+            raise PostNotFoundError(url)
+    r = resource["resource"]
+    if r["visibility"][0] == "private" and not web.tx.user.is_owner:
+        raise PostAccessError("Owner only")
+    if r["visibility"][0] == "protected" and not web.tx.user.is_owner:
+        uid = web.tx.user.session.get("uid", [None])
+        if uid[0] not in r.get("audience", []):
+            raise PostAccessError("No access")
+    if "entry" in r["type"]:
+        # XXX r["author"] = web.tx.identities.get_identity(r["author"][0])["card"]
+        r["author"] = [web.application("webint_owner").model.get_identity("/")["card"]]
+    return resource
+
+
+@app.query
+def update(db, url, add=None, replace=None, remove=None):
+    """Update a resource."""
+    if url.startswith(("http://", "https://")):
+        url = webagt.uri(url).path
+    else:
+        url = url.strip("/")
+    permalink = f"/{url}"
+    resource = db.select("resources", where="permalink = ?", vals=[permalink])[0][
+        "resource"
+    ]
+    if add:
+        for prop, vals in add.items():
+            try:
+                resource[prop].extend(vals)
+            except KeyError:
+                resource[prop] = vals
+    if replace:
+        for prop, vals in replace.items():
+            resource[prop] = vals
+    if remove:
+        for prop, vals in remove.items():
+            del resource[prop]
+    resource["updated"] = [web.now().in_timezone("America/Los_Angeles")]
+    resource = web.load(web.dump(resource))
+    db.update("resources", resource=resource, where="permalink = ?", vals=[permalink])
+    # TODO web.publish(url, f".{prop}[-0:-0]", vals)
+
+
+@app.query
+def delete(db, url):
+    """Delete a resource."""
+    # XXX resource = app.model.read(url)
+    with db.transaction as cur:
+        # XXX cur.insert("deleted_resources", **resource)
+        cur.delete("resources", where="permalink = ?", vals=[url])
+
+
+@app.query
+def search(db, query):
+    """Return a list of resources containing `query`."""
+    where = """json_extract(resources.resource,
+                   '$.bookmark-of[0].url') == ?
+               OR json_extract(resources.resource,
+                   '$.like-of[0].url') == ?"""
+    return db.select("resources", vals=[query, query], where=where)
+
+
+@app.query
+def get_identity(db, version):
+    """Return a snapshot of an identity at given version."""
+    return app.model.get_version(version)
+
+
+@app.query
+def get_version(db, version):
+    """Return a snapshot of resource at given version."""
+    return db.select("resources", where="version = ?", vals=[version])[0]
+
+
+@app.query
+def get_entry(db, path):
+    """"""
+
+
+@app.query
+def get_card(db, nickname):
+    """Return the card with given nickname."""
+    resource = db.select(
+        "resources",
+        vals=[nickname],
+        where="""json_extract(resources.resource,
+                                         '$.nickname[0]') == ?""",
+    )[0]
+    return resource["resource"]
+
+
+@app.query
+def get_event(db, path):
+    """"""
+
+
+@app.query
+def get_entries(db, limit=20, modified="DESC"):
+    """Return a list of entries."""
+    return db.select(
+        "resources",
+        order=f"""json_extract(resources.resource,
+                                      '$.published[0]') {modified}""",
+        where="""json_extract(resources.resource,
+                                     '$.type[0]') == 'entry'""",
+        limit=limit,
+    )
+
+
+@app.query
+def get_cards(db, limit=20):
+    """Return a list of alphabetical cards."""
+    return db.select(
+        "resources",  # order="modified DESC",
+        where="""json_extract(resources.resource,
+                                     '$.type[0]') == 'card'""",
+    )
+
+
+@app.query
+def get_rooms(db, limit=20):
+    """Return a list of alphabetical rooms."""
+    return db.select(
+        "resources",  # order="modified DESC",
+        where="""json_extract(resources.resource,
+                                     '$.type[0]') == 'room'""",
+    )
+
+
+@app.query
+def get_channels(db):
+    """Return a list of alphabetical channels."""
+    return db.select(
+        "resources",  # order="modified DESC",
+        where="json_extract(resources.resource, '$.type[0]') == 'feed'",
+    )
+
+
+@app.query
+def get_categories(db):
+    """Return a list of categories."""
+    return [
+        r["value"]
+        for r in db.select(
+            "resources, json_each(resources.resource, '$.category')",
+            what="DISTINCT value",
+        )
+    ]
+
+
+@app.query
+def get_posts(db, after=None, before=None, categories=None):
+    """."""
+    froms = ["resources"]
+    wheres = ""
+    vals = []
+
+    # by visibility
+    vis_sql = "json_extract(resources.resource, '$.visibility[0]')"
+    vis_wheres = [f"{vis_sql} == 'public'"]
+    if web.tx.user.session:
+        vis_wheres.append(f"{vis_sql} == 'protected'")
+    if web.tx.user.is_owner:
+        vis_wheres.append(f"{vis_sql} == 'private'")
+    wheres += "(" + " OR ".join(vis_wheres) + ")"
+
+    # by date
+    dt_wheres = []
+    dt_vals = []
+    if after:
+        dt_wheres.append(
+            "dttz_to_iso(json_extract(resources.resource, '$.published[0]')) > ?"
+        )
+        dt_vals.append(after)
+    if before:
+        dt_wheres.append(
+            "dttz_to_iso(json_extract(resources.resource, '$.published[0]')) < ?"
+        )
+        dt_vals.append(before)
+    if before or after:
+        wheres += " AND (" + " AND ".join(dt_wheres) + ")"
+        vals.extend(dt_vals)
+
+    # by category
+    if categories:
+        cat_wheres = []
+        cat_vals = []
+        for n, category in enumerate(categories):
+            froms.append(f"json_each(resources.resource, '$.category') as v{n}")
+            cat_wheres.append(f"v{n}.value = ?")
+            cat_vals.append(category)
+        wheres += " AND (" + " AND ".join(cat_wheres) + ")"
+        vals.extend(cat_vals)
+
+    for post in db.select(
+        ", ".join(froms),
+        where=wheres,
+        vals=vals,
+        order="""json_extract(resources.resource, '$.published[0]') DESC""",
+    ):
+        r = post["resource"]
+        if (
+            r["visibility"][0] == "protected"
+            and not web.tx.user.is_owner
+            and web.tx.user.session["uid"][0] not in r["audience"]
+        ):
+            continue
+        if "entry" in r["type"]:
+            r["author"] = [
+                web.application("webint_owner").model.get_identity("/")["card"]
+            ]
+        yield r
+
+
+@app.query
+def get_posts_by_channel(db, uid):
+    """."""
+    return db.select(
+        "resources",
+        vals=[uid],
+        where="""json_extract(resources.resource, '$.channel[0]') == ?""",
+        order="""json_extract(resources.resource, '$.published[0]') DESC""",
+    )
+
+
+# def get_channels(db):
+#     """Return a list of channels."""
+#     return [r["value"] for r in
+#             db.select("""resources,
+#                            json_tree(resources.resource, '$.channel')""",
+#                          what="DISTINCT value", where="type = 'text'")]
+
+
+@app.query
+def get_year(db, year):
+    return db.select(
+        "resources",
+        order="""json_extract(resources.resource,
+                                     '$.published[0].datetime') ASC""",
+        where=f"""json_extract(resources.resource,
+                                      '$.published[0].datetime')
+                                      LIKE '{year}%'""",
+    )

index 0000000..89741bc
--- /dev/null

+import operator
+
+import web
+from mf import discover_post_type
+from web import tx
+
+__all__ = ["tx", "operator", "discover_post_type", "post_mkdn"]
+
+
+def post_mkdn(content):
+    return web.mkdn(content)  # XXX , globals=micropub.markdown_globals)

index 0000000..12494b5
--- /dev/null

+$def with (err)
+$var title: Access Denied
+
+<p>Access to this resource is denied.</p>

index 0000000..f5a5d24
--- /dev/null

+$def with (channels, media, posts)
+$var title: Posts
+
+<a href=/all>All</a>
+
+$# <h4>Channels</h4>
+$# $if channels:
+$#     <ul>
+$#     $for channel in channels:
+$#         <li><a href=/channels/$channel>$channel</a></li>
+$#     </ul>
+
+<h4>Editors</h4>
+<ul>
+<li><a href=$tx.origin/editors/text>Text</a></li>
+<li><a href=$tx.origin/editors/photo>Photo</a></li>
+</ul>
+
+<h4>Media</h4>
+<p><a href=$tx.origin/media>$len(media) total</a></p>
+
+$for post in posts:
+    $ post_url = post["url"][0]
+    <p><a href="$tx.origin/$post_url">$post</a></p>
+    $if tx.user.is_owner:
+        <form method=post>
+        <input type=hidden name=action value=delete>
+        <input type=hidden name=url value=$post_url>
+        <button>Delete</button>
+        </form>

index 0000000..b4494f9
--- /dev/null

+$def with (card)
+$var breadcrumbs = ("pub", "Content", "cards", "Cards")
+$ title = card.get("name", card["nickname"])
+$var title: $title[0]
+$var subtitle: $card["nickname"][0]
+
+<div id=photos>
+<ul>
+$for photo in card.get("photo", []):
+    <li>$photo</li>
+</ul>
+</div>
+
+<div id=urls>
+<ul>
+$for url in card.get("url", []):
+    <li>$url</li>
+</ul>
+</div>
+
+<p><strong>$card["visibility"][0]</strong></p>
+
+<button id=save>Save</button>
+
+<script>
+_.load(() => {
+  const mp = new _.Micropub('/pub')
+  let changes = {add: {}, remove: {}, replace: {}}
+  _('#urls').append('<button id=add_url>Add</button>')
+  _('#add_url').click(() => {
+    const url = prompt('Enter a URL associated with the given card:')
+    if (!('url' in changes.add))
+      changes.add.url = []
+    changes.add.url.push(url)
+  })
+  _('#photos').append('<button id=add_photo>Add</button>')
+  _('#add_photo').click(() => {
+    const url = prompt('Enter a URL associated with the given card:')
+    if (!('photo' in changes.add))
+      changes.add.photo = []
+    changes.add.photo.push(url)
+  })
+  _('#save').click(() => {
+    mp.update('pub/cards/$card["nickname"][0]', changes)
+  })
+})
+</script>

index 0000000..c75a701
--- /dev/null

+$def with (cards, render_dict)
+$var breadcrumbs = ("pub", "Content")
+$var title: Cards
+
+<form action=/pub method=post>
+<input type=hidden name=h value=card>
+<div><label>Nickname <input name=nickname[]></label></div>
+<button>Add</button>
+</form>
+
+<ul>
+$for c in cards:
+    $ r = c["resource"]
+    <li>
+    <p><a href=/pub/cards/$r["nickname"][0]>$r["nickname"][0]</a>
+    <small>$c["version"]</small></p>
+    $:render_dict(r)
+    </li>
+</ul>

index 0000000..6c7f454
--- /dev/null

+$def with (channel, posts)
+$ c = channel["resource"]
+$var title: $c["name"][0]
+
+<p>$c["visibility"][0]</p>
+<ul>
+$for post in posts:
+    $ type = post["resource"]["type"][0]
+    <li><strong>$type</strong><br>$dict(post)</li>
+</ul>

index 0000000..c1ed6d5
--- /dev/null

+$def with (channels)
+$var title: Channels
+
+<form action=/pub method=post>
+<input type=hidden name=h value=feed>
+<div><label>Name <input name=name[]></label></div>
+<div><label>Slug <input name=slug[]></label></div>
+<button>Create</button>
+</form>
+
+<ul>
+$for channel in channels:
+    $ r = channel["resource"]
+    <li><a href=$r["url"][0]>$r["name"][0]</a>
+    <small>$r["uid"][0]</small></li>
+    $# <li>
+    $# <p><a href=$c["url"]>$c["url"]</a><br>
+    $# <small><strong>$c["resource"]["type"]</strong>
+    $# last modified $c["modified"].diff_for_humans()</small></p>
+    $# <pre>$pformat(c["resource"])</pre>
+    $# </li>
+</ul>

index 0000000..1e114fe
--- /dev/null

+$def with (year, month, day, posts)
+$var title: $month $day $year
+
+<ul>
+$for post in sorted(posts, key=operator.itemgetter("url")):
+    <li><a href=$post["url"][0]>$post["url"][0]</a>
+    $if name := post.get("name"):
+        $name[0]
+    </li>
+</ul>

index 0000000..71a28ad
--- /dev/null

+$def with (entries, render_dict)
+$var breadcrumbs = ("pub", "Content")
+$var title: Entries
+
+<ul>
+$for entry in entries:
+    <li>
+    $ r = entry["resource"]
+    $ permalink = r["url"][0]
+    <p><a href=$permalink>$permalink</a> <small>$entry["version"]</small></p>
+    <form method=post action=/pub>
+    <input type=hidden name=action value=delete>
+    <input type=hidden name=url value=$permalink>
+    <button>Delete</button>
+    </form>
+    $:render_dict(r)
+</ul>

index 0000000..c6ec558
--- /dev/null

+$def with (permalink, entry, mentions)
+$var title = "?"
+$ p = entry["published"][0]
+$if tx.request.uri.path.startswith("2023"):
+    $var breadcrumbs = (p.format("MM"), p.format("MMMM"), p.format("DD"), p.format("D"))
+$# var body_classes = ["widescreen"]
+$var classes = ["h-entry"]
+
+$# <link rel=alternate type=application/diamond-types href=${permalink}.dt
+$#       title="versioned history of this document"/>
+
+<a class=u-url href=$permalink></a>
+<a class=u-author href=/></a>
+$if "mastodon" in entry.get("syndication", []):
+    <a class=u-bridgy-fed href=https://fed.brid.gy></a>
+
+$ type = discover_post_type(entry)
+
+$if type == "note":
+    ...
+    $# <div id=previewContent>
+    $#     <div class=e-content>$:post_mkdn(entry["content"][0]["html"])</div>
+    $# </div>
+$elif type == "article":
+    $var title = entry["name"][0]
+    $# <h2 class=p-name>$entry["name"][0]</h2>
+    <div id=previewContent>
+    $ content = entry["content"][0]
+    $if isinstance(content, dict) and "html" in content:
+        <div class=e-content>$:post_mkdn(content["html"])</div>
+    $else:
+        <div class=p-content>$:post_mkdn(content)</div>
+    </div>
+$elif type == "weight":
+    <h2 class=p-summary>$entry["summary"][0]</h2>
+    $ weight = entry["weight"][0]
+    <p class="p-weight h-measure"><span class=p-num>$weight["num"]</span>
+    <span class=p-unit>$weight["unit"]</span></p>
+$elif type == "audio/clip":
+    $# $var title = entry["name"][0]
+    $# <h2 class=p-name>$entry["name"][0]</h2>
+    <audio class="u-audio" src="$entry['audio'][0]" controls="controls">
+    $entry["name"][0]
+    </audio>
+    $ quotation_of = entry["quotation-of"][0]
+    <p>Clipped from <strong><a class=u-quotation-of
+    href=$quotation_of>$quotation_of</strong></a></p>
+    $# XXX <div class=u-content>$:post_mkdn(entry["content"][0])</div>
+$elif type == "bookmark":
+    $ bookmark = entry["bookmark-of"][0]
+    $var title: Bookmarked $bookmark
+    <p><big>Bookmarked <a class=u-bookmark-of
+    href=$bookmark>$bookmark</a></big></p>
+$elif type == "like":
+    $ like = entry["like-of"][0]
+    $var title: Liked $like
+    <p class=p-name><big class="h-cite u-like-of">Liked
+    <a class="p-name u-url" href=$like>$like</a></big></p>
+$elif type == "rsvp":
+    $ in_reply_to = entry["in-reply-to"][0]
+    <p>RSVP <strong class=p-rsvp>$entry["rsvp"][0]</strong> to
+    <a class=u-in-reply-to href=$in_reply_to>$in_reply_to</a></p>
+    $# $ event = entry["in-reply-to"][0]
+    $# $var title: RSVP'd $entry["rsvp"] to $event["name"]
+    $# <p><big>RSVP'd <code>$entry["rsvp"]</code> <a class=u-bookmark-of
+    $# href=$event["url"]>$event["name"]</a></big></p>
+$else:
+    <p>$type</p>
+
+$if tx.user.is_owner:
+    <form method=post action=/auth/share>
+    $for member_url in entry.get("audience", []):
+        <p>$member_url <button>Share</button></p>
+    </form>
+    <form method=get action=/editor/draft>
+    <input type=hidden name=permalink value=$permalink>
+    <button>Edit</button>
+    </form>
+
+$# <div id=editor></div>
+$# <div id=editorStatus></div>
+
+$# $# $if tx.user.is_owner:
+$# $#     $for metric, value in Readability(entry["content"][0]).metrics.items():
+$# $#         <h4>$metric</h4>
+$# $#         <pre>$value</pre>
+$# 
+$# $# XXX $ a = entry["author"]
+$# $# XXX <p class="p-author h-card"><a class="u-url p-name" href=$a["uid"]>$a["name"]</a></p>
+$# 
+$# $def aside():
+$#     <p><small><a class=u-url href=$entry["url"][0]><time class=dt-published
+$#     datetime="$entry['published'][0].isoformat()">\
+$#     $entry["published"][0].diff_for_humans()</time></a></small></p>
+$#     <p><small><strong>$entry["visibility"][0]</strong></small></p>
+$# 
+$#     $if tx.user.session:
+$#         <a href=$tx.user.session["uid"][0]/actions?reply=$entry["url"][0]>reply</a>
+$#     $else:
+$#         <a href=web+action://reply?url=$entry["url"][0]>reply</a>
+$# $var aside = aside
+
+<footer>
+$for mention in mentions:
+    <div>
+    $ data = mention["data"]
+    $data
+    $# $if data:
+    $#     $ a = data["author"]
+    $#     $ comment_type = data["comment_type"][0]
+    $#     $if comment_type == "like":
+    $#         $emoji.emojize(":red_heart:")
+    <small>
+    $# $if data:
+    $#     <a href=$a["url"]>$a["name"]</a>
+    <a href=$mention["source"]><time class=dt-published
+    datetime="$mention['mentioned'].isoformat()">\
+    $mention["mentioned"].diff_for_humans()</time></a>
+    </small>
+    </div>
+</footer>
+
+$# <style>
+$# #editor, #editorStatus {
+$#   display: none; }
+$# /* div.e-content {
+$#   font-size: 2em; } */
+$# </style>
+
+$# $elif type == "identification":
+$#     $ identification = entry["identification-of"][0]
+$#     $var title: Identified $identification["name"]
+$#     <p><big>Identified <a class=u-identification-of
+$#     href=$identification["url"]>$identification["name"]</a></big></p>
+$# $elif type == "follow":
+$#     $ follow = entry["follow-of"][0]
+$#     $var title: Followed $follow["name"]
+$#     <p><big>Followed <a class=u-follow-of
+$#     href=$follow["url"]>$follow["name"]</a></big></p>
+
+$# TODO $if tx.user.is_owner:
+$# TODO     <script src=/static/understory.js crossorigin=anonymous></script>
+$# TODO     <script>
+$# TODO     const { MicropubClient } = understory
+$# TODO     const pub = new MicropubClient('/pub')
+$# TODO     document.addEventListener('DOMContentLoaded', () => {
+$# TODO       pub.getConfig().then(data => console.log(data))
+$# TODO       pub.getCategories().then(data => {
+$# TODO         data.categories.forEach(cat => {
+$# TODO           addCategory(cat)
+$# TODO         })
+$# TODO       })
+$# TODO     })
+$# TODO     </script>
+
+<script type=module>
+import { load, getBrowser, diamondMonaco, MicropubClient } from '/static/web.js'
+
+const previewMarkdown = content => {
+  // XXX name = document.querySelector('#name input[type=text]').value
+  // XXX previewName.innerHTML = `<h1>$${name}</h1>`
+  if (content == '') {
+    previewContent.innerHTML = ''
+    return
+  }
+  let body = new FormData()
+  body.append('content', content)
+  fetch(
+    '/editor/preview/markdown',
+    {
+      method: 'POST',
+      body: body
+    }
+  ).then(response => {
+    if (response.status === 200) {
+      return response.json().then(data => {
+        previewContent.innerHTML = data['content']
+      })
+    }
+  })
+}
+
+var frequency = 1.5;  // seconds
+var count = 0;
+var clean = true;
+const tick = () => {
+  setTimeout(() => {
+    if (count++ > frequency * 10 && !clean)
+      updatePreview();
+    tick();
+  }, 100);
+}
+tick();
+const updatePreview = () => {
+  clean = false;
+  if (count > frequency * 10) {
+    previewMarkdown(monaco.getValue());
+    clean = true;
+    count = 0;
+  }
+}
+
+const monaco = diamondMonaco(
+  '$permalink', editor, editorStatus, connection, version,
+  {},
+  '$(tx.user.session["uid"][0] if tx.user.session else tx.user.ip)'
+)
+monaco.onDidChangeModelContent(updatePreview)
+</script>

index 0000000..fd6cd37
--- /dev/null

+$def with (url)
+$var title: Post not found
+
+$url

index 0000000..f03bed3
--- /dev/null

+$def with (year, month, posts)
+$var title: $month $year
+
+<ul>
+$for post in sorted(posts, key=operator.itemgetter("url")):
+    <li><a href=$post["url"][0]>$post["url"][0]</a>
+    $if name := post.get("name"):
+        $name[0]
+    </li>
+</ul>

index 0000000..a4db19c
--- /dev/null

+$def with (obj)
+
+$def render_dict(obj):
+    <dl>
+    $for prop, values in sorted(obj.items()):
+        <dt>$prop</dt>
+        <dd><ul>
+        $for value in values:
+            <li>
+            $if isinstance(value, dict):
+                $:render_dict(value)
+            $else:
+                $value
+            </li>
+        </ul></dd>
+    </dl>
+
+$:render_dict(obj)

index 0000000..45cf11a
--- /dev/null

+$def with (rooms, render_dict)
+$var breadcrumbs = ("pub", "Content")
+$var title: Rooms
+
+<ul>
+$for r in rooms:
+    <p><a href=$r["path"]>$r["path"]</a> <small>$r["version"]</small></p>
+    $:render_dict(r["resource"])
+</ul>

index 0000000..a173205
--- /dev/null

+$def with ()
+$var title: Syndication
+
+<form method=post>
+Twitter<br>
+<input name=twitter_username placeholder=username><br>
+<input name=twitter_password placeholder=password><br>
+GitHub<br>
+<input name=github_username placeholder=username><br>
+<input name=github_token placeholder=token><br>
+<button>Save</button>
+</form>

index 0000000..b08f46e
--- /dev/null

+$def with (year, posts)
+$var title: $year
+
+<ul>
+$for post in sorted(posts, key=operator.itemgetter("url")):
+    <li><a href=$post["url"][0]>$post["url"][0]</a>
+    $if name := post.get("name"):
+        $name[0]
+    </li>
+</ul>