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>