#!/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 and 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}'")