Example: add a post

Version 5

This version features the ability to import the module without problems with the app having to be created during the load. This allows it to be deployed easily with a python app file and to change from a Jupyter server to a more straightforward uvicorn server

Developments still to add in: 1. Add ability to delete posts from the UI if admin 2. Add ability to edit posts in situ 3. Check if there is a way to enable image layout in Obscidian

Sets the default export module for nbdev. All cells marked #| export will be written to my_blog/core_v5.py.


AppState


def AppState(
    pdb:Database, posts_t:Table, tags_t:Table, post_tags_t:Table, auth:AuthManager, db:Database
)->None:

create_database_tables


def create_database_tables(
    pdb:Database, # database to save posts and all associated tag tables
):

Call self as a function.


create_post_database


def create_post_database(
    db_path:str, # path to posts database
)->Database: # Creates all the required database tables if they don't already exist

Call self as a function.

Convenience wrapper that creates a database connection and initializes all tables.


create_app


def create_app(
    
):

Call self as a function.


route


def route(
    path:NoneType=None
):

Decorator to collect routes without registering them immediately. Use @route(‘/path’) or @route() for function-name-based paths.


register_routes


def register_routes(
    app
):

Register all collected routes with the app.


logout


def logout(
    sess
):

Overwrite logout route from fasthtml_auth to return users to home page after logging out


intro


def intro(
    
):

Call self as a function.


hx_attrs


def hx_attrs(
    target:str='#main-content'
):

Call self as a function.

This dialogue is part of an nbdev project. When deployed to github the actions setup for publishing to github pages is resulting in two entries for this dialogue (see: https://fromlittleacorns.github.io/my-blog/). Can you use dialogue tools to explore why this might be happening

Yes please create the sidebar file. The pages looks like this:

image.png

You can see the duplication there


is_logged_in


def is_logged_in(
    req
):

Call self as a function.


x_icon


def x_icon(
    
):

Call self as a function.


layout


def layout(
    req, content:VAR_POSITIONAL, htmx, title:NoneType=None
):
*title here is used to set the browser tab label in the

section and will not be visible on the page. The article title is appended into the main content and shows below the navbar*


slug_exists


def slug_exists(
    slug
):

Call self as a function.

from datetime import datetime

get_slug


def get_slug(
    title
):

Call self as a function.


blogpost


def blogpost(
    htmx, req, slug:str
):

Call self as a function.


index


def index(
    req, htmx
):

Call self as a function.


get_tags


def get_tags(
    tags_tbl
):

Call self as a function.


get_post_tags


def get_post_tags(
    post_id:int
):

Call self as a function.

def get_posts(n: Union[int, None]=None, tags: Union[List, None] = None, logged_in: bool=False): if tags: place_holders = ‘,’.join(‘?’ * len(tags)) private_posts_query = ’’ if logged_in else ‘AND p.private = False’ query = f”“” SELECT DISTINCT p.* FROM posts p JOIN post_tags pt ON p.id = pt.post_id JOIN tags t ON pt.tag_id = t.id WHERE t.name IN ({place_holders}) AND p.published = True {private_posts_query} ORDER BY p.created DESC ““” if n: query += f” LIMIT {n}” posts = state.pdb.q(query, tags) else: posts = list(state.posts_t.rows_where(“published = ? AND (private = False OR ?)”, [True, logged_in], order_by=“created DESC”, limit=n)) posts = [dict(r) for r in posts]

for p in posts:
    p['created'] = datetime.fromisoformat(p['created']) if isinstance(p['created'], str) else p['created']
    p['tags'] = get_post_tags(p['id'])
return posts

get_posts


def get_posts(
    n:Optional=None, # number of posts to load
    tags:Optional=None, # list of tags
    logged_in:bool=False, # is user logged in
    offset:Optional=None, # where to start to load posts once ordered
)->list: # list of posts

Call self as a function.


tag_pill


def tag_pill(
    tag_name, selected_tags
):

Call self as a function.


tag_filter


def tag_filter(
    selected
):

Call self as a function.


tag_badge


def tag_badge(
    name
):

Call self as a function.


decode_tag_str


def decode_tag_str(
    tag_str:str
):

Call self as a function.

test_tags_decode_str = 'cycling, motorhome, coding'
decode_tag_str(test_tags_decode_str)
assert decode_tag_str(test_tags_decode_str) == {'cycling', 'motorhome', 'coding'}

sentinal


def sentinal(
    n:int, # number of posts to load
    offset:int=None, # where to start the load
    tags:str=None, # a list of active tags separated by commas
)->functools.partial(<function ft_hx at 0x7fb9c1b385e0>, 'div'): # Div to add to the end of the list of posts to act as a sentinal

Create a div to add to the end of the posts on the blog. When it becomes visible then it activates the load of more posts

@route def blog(htmx, req, tags:str=None): logged_in = is_logged_in(req) posts_to_load = 10 # selected is a SET of the name of the selected tags selected = {unquote(t.strip()) for t in (tags or ’‘).split(’,’) if t.strip()} filtered = get_posts(n=posts_to_load, tags=selected, logged_in=logged_in) tag_filter_div = tag_filter(selected) items = [post_card(p, req) for p in filtered] if len(items) == posts_to_load: items.append(sentinal(offset=0, n=posts_to_load, tags=tags))

post_content = Div(*items, cls="space-y-2", id="posts-list") if items else P("No posts yet.", cls="text-muted-foreground", id="posts-list")
if htmx and htmx.target == "posts-list":
    tag_filter_div.attrs['hx-swap-oob'] = 'true'
    return post_content, tag_filter_div
return layout(req, H2("Blog"), tag_filter_div, Divider(cls=('my-2')), post_content, title="Blog", htmx=htmx)

blog


def blog(
    htmx, req, tags:str=None
):

Call self as a function.


get_post_image


def get_post_image(
    p
):

Call self as a function.


check_if_admin


def check_if_admin(
    req
):

Call self as a function.


post_card


def post_card(
    p, req
):

Post summary card with optional admin controls


add_post


def add_post(
    title, content, excerpt:str='', tags:NoneType=None, published:bool=True, created:NoneType=None,
    updated:NoneType=None, slug:str=None, private:bool=False
):

Call self as a function.

def post_exists(slug: str) -> bool:
    return bool(_state.posts_t(where='slug=?', vals=[slug]))

process_upload


def process_upload(
    content:bytes, filename:str, slug:str=None, overwrite:bool=False
):

Call self as a function.


get


def get(
    htmx
):

Call self as a function.


save_pending


def save_pending(
    slug:str, md_name:str, md_content:bytes, images:list
):

Save md content and images to temp storage keyed by slug. images is a list of (filename, bytes) tuples.


load_pending


def load_pending(
    slug:str
):

Returns (md_content, images) or None if not found.


clear_pending


def clear_pending(
    slug:str
):

Call self as a function.

@route('/admin/upload')    
def post(upload2: list[UploadFile]):
    files = [(f.filename, f.file.read()) for f in upload2]
    md_files = [(n, c) for n, c in files if n.endswith('.md')]
    img_files = [(n, c) for n, c in files if not n.endswith('.md')]
    results = []
    slug = None
    if img_files and len(md_files)==0:
        return Div(Alert("Please upload a post (.md file) with images, or upload images separately", cls=AlertT.warning))
    for name, content in md_files:
        success, message, slug = process_upload(content, name)
        results.append((name, success, message))
    for name, content in img_files:
        success, message, _ = process_upload(content, name, slug=slug)
        results.append((name, success, message))
    header = ["Name", 'Success', 'Message']
    body = [[r[0], r[1], r[2]] for r in results]
    return Div(H2("Post upload results"), TableFromLists(header, body))

do_upload


def do_upload(
    md_files, img_files, slug:str=None, overwrite:bool=False
):

Call self as a function.


post


def post(
    upload2:list
):

Call self as a function.


post


def post(
    slug:str
):

Call self as a function.

end-section

I don’t think this is correct as the filename of the md file is not necessarily going to be slug + ‘.md’. The filename surely needs to come from the originally supplied name? I think that this flaw is built into the load and save pending functions as well. Do you agree or have I misunderstood?


rewrite_image_paths


def rewrite_image_paths(
    content:str, slug:str
)->str:

Call self as a function.


load_md_file


def load_md_file(
    path:str, image_base:str=None
)->str:

Load markdown file, optionally converting Obsidian image syntax


convert_obsidian_images


def convert_obsidian_images(
    content:str, image_base:str='/static/image/about'
)->str:

Convert Obsidian ![[image.ext]] syntax to standard markdown

test_img_1_content = '''test string with obscidian markdown, first without a table:
![[IMG_3932.jpeg]]
next with size:
![[IMG_3932.jpeg|250]]
and with size and position:
![[IMG_3932.jpeg|250|right]]
Then some rubbish
'''
base_path = "/static/image/post_images/post_1"
converted = convert_obsidian_images(test_img_1_content, base_path)
converted
'test string with obscidian markdown, first without a table:\n![](/static/image/post_images/post_1/IMG_3932.jpeg)\nnext with size:\n![](/static/image/post_images/post_1/IMG_3932.jpeg)\nand with size and position:\n![](/static/image/post_images/post_1/IMG_3932.jpeg)\nThen some rubbish\n'

about_content


def about_content(
    
):

Call self as a function.


about


def about(
    req, htmx
):

Call self as a function.


strava_embed


def strava_embed(
    activity_id:str
):

Call self as a function.

# In your markdown, use:
# {{strava:12345678}}
# This will be replaced with an embedded Strava activity

Returns a Strava embed div with the given activity ID. The Strava embed.js script (loaded in headers) will transform this into a full embed.


process_strava_embeddings


def process_strava_embeddings(
    page:NotStr
):

Call self as a function.

Post-processes rendered HTML to find {strava:ID} placeholders and replace them with actual Strava embed divs.


process_obsidian_images


def process_obsidian_images(
    page:NotStr, image_base:str
)->NotStr:

Call self as a function.

# Obsidian image syntax examples:
# ![[image.jpg|300]]           - width 300px
# ![[image.jpg|300|right]]     - float right, text wraps
# ![[image.jpg|300x200|center]] - fixed size, centered

Post-processes rendered HTML to find Obsidian image syntax ![[image.jpg|width|position]] and replace with styled <img> tags. Supports width, height, and positioning (left/right/center).

Additional imports for the custom markdown renderer.


EnhancedRenderer


def EnhancedRenderer(
    args:VAR_POSITIONAL, img_dir:NoneType=None, kwargs:VAR_KEYWORD
):

Custom renderer for Franken UI that handles image paths

@route('/googleada316577537ad2b.html')
def get(): return 'google-site-verification: googleada316577537ad2b.html'

sitemap


def sitemap(
    
):

Call self as a function.

Custom markdown renderer that extends FrankenRenderer. Automatically adds target="_blank" and rel="noopener noreferrer" to external links (http/https).

Export

nbdev.nbdev_export()

Exports all cells marked with #| export to the my_blog/core_v5.py module file.