gempost/gempost

601 lines
23 KiB
Python
Executable File

#!/usr/bin/python
# gempost - experimental gemlog manager for tilde.team, written by ~desertmouse
from os import system as exec
from os import listdir, getlogin
from datetime import date
from sys import argv
from hashlib import sha1
from random import random
import curses
from math import ceil
# colored output functions
def prRed(skk): print("\033[91m {}\033[00m" .format(skk))
def prGreen(skk): print("\033[92m {}\033[00m" .format(skk))
def prYellow(skk): print("\033[93m {}\033[00m" .format(skk))
def prPurple(skk): print("\033[95m {}\033[00m" .format(skk))
# main variable declarations
default_post_page_header = """
# posts
"""
wdir = f"/home/{getlogin()}/public_gemini/" # working/main directory
postDir = wdir + "postdir/" # all post files along with the archive.gmi are stored here
indexFile = wdir + "postIndex" # the index is maintained in this file
editor = "nano --restricted -t"
def menuFunction(menu = [], headerLine = "Use the arrow keys or j,k to make a selection. Press 'c' to cancel.\n"):
"""An ncurses menu that integrates with both <manage> and <search> arguments."""
stdscr = curses.initscr() # initializing ncurses
curses.noecho()
curses.cbreak()
stdscr.keypad(True)
sy, sx = stdscr.getmaxyx() # getting terminal dimensions
pageLength = sy - 5 # no. of items I can show in a single page.
pages = ceil(len(menu)/pageLength) + 1 # calculating the no. of pages required
pageIndex = 1 # current page
imenu = [] # internal list that is modified to only contain items of a specific page
imenu = menu[((pageLength)*(pageIndex-1)):(pageLength*pageIndex)]
endOfPage = len(imenu) # separate variable is required for this because the last page might have lesser entries than {pageLength}
if (menu == []): # do nothing if list is empty
curses.endwin()
return None
highlight, highlight_prev = 0, 0 # variable that controls which entry is selected, variable that stores its previous value
while True:
sy, sx = stdscr.getmaxyx() # getting terminal dimensions again to adapt to window resize events while the menu is being displayed
stdscr.clear()
if (highlight >= endOfPage): # incrementing page number
if (pageIndex == pages - 1) or (pageIndex == 1):
highlight = highlight_prev
pass
else:
pageIndex += 1 # incrementing page index
imenu = [] # resetting imenu
try:
imenu = menu[((pageLength)*(pageIndex-1)):(pageLength*pageIndex)] # only {pageLength} no. of items
except IndexError:
try:
imenu = menu[((pageLength)*(pageIndex-1)):]
except IndexError:
pass
endOfPage = len(imenu)
highlight = 0
if (highlight < 0): # decrementing page number
if (pageIndex == 1):
highlight = highlight_prev
pass
else:
pageIndex -= 1
imenu = []
try:
imenu = menu[((pageLength)*(pageIndex-1)):(pageLength*pageIndex)]
except IndexError:
pass
endOfPage = len(imenu)
highlight = endOfPage - 1
stdscr.addstr(headerLine)
for sno in range(len(imenu)):
if (sno == highlight):
entry = str(((pageLength)*(pageIndex-1)) + (sno+1)) + " - " + imenu[sno] # the menu entry
"""
TODO:
- exit displaying an error if terminal too small
"""
stdscr.addstr(f"\n{entry}{' '*(sx - len(entry) - 1)}", curses.A_STANDOUT)
else:
stdscr.addstr(f"\n{((pageLength)*(pageIndex-1)) + (sno+1)} - {imenu[sno]}")
sno += 1
stdscr.addstr(f"\n\npage {pageIndex} / {pages - 1}\n") # a line on the bottom showing page number
stdscr.refresh()
c = stdscr.getch()
if (c == curses.KEY_DOWN or c == ord('j')):
highlight_prev = highlight
highlight += 1
elif (c == curses.KEY_UP or c == ord('k')):
highlight_prev = highlight
highlight -= 1
elif (c == curses.KEY_ENTER or c == 10 or c == 13):
break
elif (c == ord('c') or c == ord('q')):
highlight = None
break
curses.endwin()
if (highlight != None):
return (pageLength * (pageIndex-1)) + highlight
else:
return highlight
def checkInit():
""" Checks if the public_gemini/ directory has been initialized for use with gempost or is missing some files. """
contents = listdir(wdir)
valid_contents = ["postIndex","trash","postdir"]
validity = 0
missing = []
for i in contents:
if i in valid_contents:
validity += 1
if (validity != len(valid_contents)):
if (validity == 0):
prRed(f'\n Your "public_gemini/" directory has not been initialized yet. Use "gempost qs" to read the quickstart.')
else:
prRed(f'\n Your "public_gemini/" directory is missing some required files and directories / has not been properly initialized.\n\n The following files/directories are required: {valid_contents}\n Validity score: {validity}/{len(valid_contents)}')
exit()
def rebuildReferences(filename = None, title = None, callingFrom = "default"):
""" reads {wdir}postIndex and rebuilds the {wdir}posts.gmi and {postDir}archive.gmi files """
customized = False
allposts = [] # storing all indexFile entries inside a list
with open(f"{indexFile}",'r') as i:
allposts = i.readlines()
if (len(allposts) == 0): # if postIndex is empty, there is nothing to to
# reset posts.gmi and delete archive.gmi
# ie, customize the function
customized = True
headerText = []
try:
with open(f"{wdir}postPageHeader.gmi",'r') as h: # the file postPageHeader.gmi should be defined by the user in {wdir}. It should contain the user's custom post page header
headerText = h.readlines()
except FileNotFoundError: # If user hasn't created that file, a default line "# posts" is added to posts.gmi
headerText.append(default_post_page_header)
if (customized == True): # if postIndex was empty, then do not do more than this. Just remove archive.gmi after this
exec(f"rm {postDir}archive.gmi") # delete archive.gmi
with open(f"{wdir}posts.gmi",'w') as po: # writing the header into posts.gmi
po.writelines(headerText)
return 0
headerText.append("\n## Latest\n")
if (callingFrom == "delete"): # the function's callingFrom switch is to perform special operations based on where it is invoked from
headerText.append(f"{allposts[-1]}")
else:
headerText.append(f"=> postdir/{filename}.gmi {title}\n")
headerText.append("\n## Recent\n") # adding the five most recent posts to posts.gmi
try:
counter = 0
for i in allposts[::-1]:
if (counter == 5):
break
headerText.append(i)
counter += 1
except IndexError:
pass
allposts = [] # storing all indexFile entries inside a list
with open(f"{indexFile}",'r') as i:
allposts = i.readlines()
headerText.append("\n## Older\n") # for older entries, refer to another file, archive.gmi, which is basically a reversed copy of {indexFile} to show the most recent posts on top
archiveLines = [] # archive.gmi needs just ./filenames instead of postdir/filenames
for i in allposts[::-1]: # storing a modified file list with just the filenames as links
archiveLines.append(f"=> ./{i[11:]}")
with open(f"{postDir}archive.gmi","w") as ar: # creating all posts list
ar.writelines(archiveLines)
headerText.append(f"=> postdir/archive.gmi Older Posts")
with open(f"{wdir}posts.gmi",'w') as po: # finally writing into posts.gmi to "bring it all together"
po.writelines(headerText)
def newpost(title, existing_content = None, filename = None):
modified = False
""" Write and submit a new post """
if (existing_content == None or existing_content == []):
pass
else:
modified = True # modify the function if doing this for existing file
if (filename == None):
filename = sha1(str(f"{title}{random()}").encode("utf-8")).hexdigest() # generating a unique filename
if (title[-1] == '\n'): # remove the \n at end of title if its there
title = title[:-1]
if (filename[-1] == '\n'): # remove the \n at end of filename if its there
filename = filename[:-1]
exec(f"touch {wdir + filename}.gmi") # creating empty file
che = input("\nAdd an ASCII Art header to your post? (define it in blogpostHeader.gmi): (y/[n]) ")
if (che == 'y' or che == 'Y'):
try:
with open(f"{wdir}blogpostHeader.gmi") as a: # checking if header for each post has been defined
postHeader = a.readlines()
with open(f"{wdir + filename}.gmi",'w') as f:
f.writelines(postHeader)
except FileNotFoundError:
postHeader = [""]
else:
postHeader = [""]
if modified: # add content to file using the existing_content list instead of opening editor
if (che != 'y' or che == 'Y'):
with open(f"{wdir+filename}.gmi", 'w') as o:
o.writelines(postHeader + existing_content)
else:
with open(f"{wdir+filename}.gmi", 'w') as o:
o.writelines(existing_content)
else:
temp = "# " + title
exec(f'echo "{temp}" | cat >> {wdir + filename}.gmi') # adding title to empty file
exec(f"{editor} {wdir+filename}.gmi") # opening editor
ch = input("\nWould you like to review before posting? (c to cancel) (y/[n]): ")
if (ch == 'y' or ch == 'Y'):
exec(f"less {wdir+filename}.gmi") # opening the file in editor for review
exec(f"mv {wdir+filename}.gmi {postDir+filename}.gmi") # moving file to postdir
elif (ch == 'c'):
exec(f"mv {wdir+filename}.gmi {wdir}trash/")
print("\nCancelled.")
return None
else:
exec(f"mv {wdir+filename}.gmi {postDir+filename}.gmi") # moving file to postdir without review
# updating postIndex
exec(f'echo "=> postdir/{filename}.gmi {title}" | cat >> {indexFile}')
# updating posts.gmi, preserving the user's custom header - rbfn begin
rebuildReferences(filename, title)
# changing file permissions
exec(f"chmod 745 {wdir}posts.gmi")
exec(f"chmod 745 {wdir}postdir && chmod 745 {wdir}postdir/*")
prGreen("\n Your post is live!") # yay?
def manage(direct = ""):
""" Posts management function. If direct is given a string filename, then that filename is selected directly. """
if (direct == ""):
with open(f"{indexFile}",'r') as i: # storing all indexFile entries inside a list
allposts = i.readlines()
postsList = []
for i in allposts: # creating a list containing titles corresponding to filenames
postsList.append(i[11:].split(".gmi ", 1))
for i in postsList: # removing the \n characters
if i[1][-1] == '\n':
i[1] == i[1][:-1]
if i[0][-1] == '\n':
i[0] = i[0][:-1]
menuItems = []
for i in postsList: # formatting it into a string list for the ncurses menu function
menuItems.append(f"{i[0]} | {i[1][:-1]}")
which = menuFunction(menuItems)
if (which == None): # menuFunction returns None if the list supplied to it is empty
if (menuItems == []):
print("You don't have any posts yet.")
print("\nCancelled.")
return 0
"""
TODO:
- remove the following number input based interface with pressing keys for delete, edit and cancel
"""
print(f"\nYou have selected:\n\nTITLE: {postsList[which][1][:-1]}\nFILENAME: {postsList[which][0]}.gmi")
try:
mode = int(input("\nSELECT MODE:\n1 - EDIT\n2 - DELETE\n3 - CANCEL\n-> ")) # what does the user want to do with the selection?
except ValueError:
print("\nCancelled.")
return 0
if (mode == 1): # if mode is EDIT, open editor with the post file
if (direct == ""):
print(f'\nEditing "{postsList[which][1][:-1]}" ...')
exec(f"{editor} {postDir}{postsList[which][0]}.gmi")
else:
print(f'\nEditing {direct}.gmi')
exec(f"{editor} {postDir}{direct}.gmi")
prGreen("\n Post updated.")
elif (mode == 2): # for DELETE mode, first delete post file, delete its reference from {wdir}postIndex, and rebuild the {wdir}posts.gmi and {postDir}archive.gmi files
if (direct == ""):
exec(f"mv {postDir}{postsList[which][0]}.gmi {wdir}trash/")
exec(f"sed -i '/{postsList[which][0]}.gmi/d' {indexFile}")
else:
exec(f"mv {postDir}{direct}.gmi {wdir}trash/")
exec(f"sed -i '/{direct}.gmi/d' {indexFile}")
rebuildReferences(None, None, "delete")
print(f"\nfile moved to trash")
print(f"postIndex updated")
print("rebuilt posts.gmi, archive.gmi")
prGreen("All done.")
elif (mode == 3):
print("\nCancelled.")
else:
pass
# information strings
helpText = """
gempost (v0.6) - *experimental* gemlog manager for tilde.team
Available arguments:
post - create a new post
Usage:
post (without arguments) Write a blog post from scratch
post [path to file] Post from .gmi file, first line will be used as heading
manage - edit or delete your posts
search [search term] - Search your post titles for a search term and manage just those posts
purge - premanently delete the files in trash/
init - set up your public_gemini/ directory for gempost
reset - delete all posts and re-initialize public_gemini/
help - display this help text
desc - a brief description of how the program works
\033[93mqs - Quick Start for new users\033[00m
Source at:
https://tildegit.org/desertmouse/gempost
"""
quickStart = """
Basically, gempost manages a "posts.gmi" file and maintains it in a clean format
such that it shows your latest post, your five most recent posts, along with
an archive of all your posts, arranged from newest to oldest.
Doing all this manually would be *very* tedious.
If you are an existing user, please back up your public_gemini/ directory and then
proceed with the given steps.
Quickstart:
STEP 1
With only your index.gmi file in public_gemini/ , run the following to set everyting up:
gempost init
STEP 2
Skip this if you chose to initialize an index.gmi during gempost init.
gempost doesn't touch your index.gmi file, so you will have to manually add a link to "posts.gmi"
To do that, just add the following line to your index.gmi file:
=> ./posts.gmi Posts
CONGRATULATIONS!
You are ready to use gempost. Please just use "gempost help" from now on to only get a list of commands.
You may run "gempost desc" to get to know a bit more about how gempost works, along with two optional features:
- postPageHeader.gmi , and
- blogpostHeader.gmi
"""
description = """
Directory Structure:
gempost expects the following structure of your ~/public_gemini/ directory:
public_gemini/
├── index.gmi
├── postdir/
├── postIndex
├── postPageHeader.gmi (optional)
├── blogpostHeader.gmi (optional)
├── posts.gmi
└── trash/
2 directories, 5 files
- "postdir/" is where all your gemlog posts are stored.
- "postIndex" is the file that gempost uses to maintain an index of your posts.
- "postPageHeader.gmi" should contain a text banner that will show on your Posts page.
- "blogpostHeader.gmi" contains a text banner that will show on top of each of your posts.
- "posts.gmi" is where the program will maintain links to:
- your latest post
- your 5 most recent posts
- a link to "archive.gmi" that contains a list of all your posts
- "trash/" is the directory where gempost keeps your deleted posts. use "gempost purge" to delete its contents.
Source at:
https://tildegit.org/desertmouse/gempost
"""
# Process command-line arguments
try:
arg = argv[1]
except IndexError:
print(helpText) # print the help text if no arguments passed
exit()
if (arg == "help" or arg == "-h" or arg == "--help"): # help text listing all available arguments
print(helpText)
elif (arg == "desc"): # description of how the program uses different files and dierctories
print(description)
elif (arg == "qs"):
print(quickStart) # a simple 2-step quick start for new users
elif (arg == "post"):
checkInit()
title = ""
while True: # inputting post title from user
title = input("\nPOST TITLE: ")
if (title[-1] == '\n'): # removing the \n that creeps in because of input()
title = title[:-1]
if (len(title) == 0):
prRed("Post title cannot be empty.")
else:
break
fname = None
existing_files = listdir(f"{postDir}")
while True: # inputting custom filename from user, or not. if empty, simply set fname to None, which makes newpost() know to generate filename using the sha1 hash
fname = input("\nFILENAME: (without .gmi extension) (no spaces. leave empty to generate random) ")
try:
if (fname[-1] == '\n'): # removing the \n that creeps in because of input()
fname = fname[:-1]
except IndexError:
fname = ''
if ".gmi" in fname:
prYellow("The filename should not contain .gmi extension because it is automatically added.\n Correcting and continuing...")
fname = fname[:-4]
if ' ' in fname:
prRed("The filename cannot contain spaces.")
elif (fname == ''):
fname = None
break
elif f"{fname}.gmi" in existing_files:
prRed("A file with this name already exists.")
else:
break
if (len(argv) >= 3): # checking if more than 1 arg (except python) to see if source file has been supplied
filePath = argv[2] # storing that file's path in a var
print(f"\nUsing file {filePath}") # tell the user that the program is using a source file
exiting_content = [] # stores content of the file
try:
with open(filePath, 'r') as f: # populating file content list
existing_content = f.readlines()
newpost(title, existing_content, fname)
except FileNotFoundError:
prRed("Invalid file path / file does not exist.")
else: # the normal flow of new post
print("\nAn editor will be launched for you to write your blog post.")
print("The process will continue after you exit the editor (Ctrl+X).")
cont = input("Press enter to continue.")
newpost(title, None, fname)
elif (arg == "manage"):
checkInit()
manage()
elif (arg == "search"):
checkInit()
if (len(argv) >= 3): # getting search term from args
t = argv[2]
if (t == ''): # if empty search term given, skip looping to search all posts and just open the normal manage mode
manage()
exit()
else:
prYellow("No search term specified.")
exit()
allposts = [] # storing all indexFile entries inside a list
with open(f"{indexFile}",'r') as i:
allposts = i.readlines()
tempList = []
for i in allposts: # creating a temporary list having titles corresponding to filenames
tempList.append(i[11:].split(".gmi ", 1))
postsList = [] # adding items that contain the search term from the temporary list to postsList
for i in tempList:
if t in i[1]:
postsList.append(i)
menuItems = []
for i in postsList: # formatting it into a string list for the ncurses menu function
menuItems.append(f"{i[0]} | {i[1][:-1]}")
which = menuFunction(menuItems, f"Search listing for '{t}':\n")
if (which == None): # menuFunction returns None if the list supplied to it is empty
print("\nNo items found.")
exit()
else:
print(f"\nYou have selected:\n\nTITLE: {postsList[which][1][:-1]}\nFILENAME: {postsList[which][0]}.gmi")
c = input("\nOpen manager? (y/[n]) ")
if (c == 'y' or c == 'Y'):
manage(f"{postsList[which][0]}")
elif (arg == "purge"): # permanently delete trashed posts
checkInit()
if (len(listdir(f"{wdir}trash/")) == 0):
prYellow("Trash is already empty.")
else:
prRed("This will permanently delete your trashed posts.")
cc = input("Confirm (y/[n]): ")
if (cc == 'y' or cc == 'Y'):
exec(f"rm {wdir}trash/*")
print("\nTrash cleared.")
else:
print("\nCancelled.")
elif (arg == "init"): # initialize public_gemini/ for use with gempost
if (len(listdir(f"{wdir}")) <= 1):
exec(f"touch {wdir}postIndex && mkdir {wdir}postdir/ {wdir}trash/ && chmod 745 {wdir}* && chmod 700 {wdir}trash/")
ch = input("\nAlso create index.gmi ? (y/[n]) ")
if (ch == 'y' or ch == 'Y'):
index_init = [f"# {getlogin()}'s page\n", "\nThis is your homepage. You can customize it however you want by editing this file.\n", "\nJust remember keeping around the link below to point your visitors to your posts!\n", "\n=> ./posts.gmi Posts"]
exec(f"touch {wdir}index.gmi")
with open(f"{wdir}index.gmi", 'w') as i:
i.writelines(index_init)
prGreen('Done. Use "gempost help" to view available arguments.')
else:
prRed('Only your "index.gmi" should be there in public_gemini/')
elif (arg == "reset"): # delete all posts and re-initialize public_gemini/ for use with gempost
prRed("Warning: This will delete all your posts.")
if (input('Type "yes, I understand" to continue: ') == "yes, I understand"):
exec(f"rm -rf {wdir}postdir/ {wdir}trash/ && rm -f {wdir}posts.gmi {indexFile}")
exec(f"touch {wdir}postIndex && mkdir {wdir}postdir/ {wdir}trash/ && chmod 745 {wdir}* && chmod 700 {wdir}trash/")
ch = input("Also create index.gmi ? (y/[n]) ")
if (ch == 'y' or ch == 'Y'):
index_init = [f"# {getlogin()}'s page\n", "\nThis is your homepage. You can customize it however you want by editing this file.\n", "\nJust remember keeping around the link below to point your visitors to your posts!\n", "\n=> ./posts.gmi Posts"]
exec(f"touch {wdir}index.gmi")
with open(f"{wdir}index.gmi", 'w') as i:
i.writelines(index_init)
prYellow("public_gemini/ has been re-initialized successfully.")
else:
print("\nCancelled.")
else:
print(f"Unknown argument '{arg}'")