vanity_press/vanity.py

320 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
import os, re, subprocess
from glob import glob
from shutil import copyfile
from datetime import datetime as dt
from settings import *
def default(var, default, dir = False):
#if the user didn't specify something in settings, then just return the default val that I'll specify
try:
if type(var) == type(default):
return var if not dir else parse_directory(var)
return default
except NameError: return default
def parse_directory(dir):
if not dir.endswith('/'): dir += '/'
if not os.path.exists(dir): os.mkdir(dir)
return dir
SOURCE_DIR = default(SOURCE_DIR, "./", True)
DEST_DIR = default(DEST_DIR, "/var/gemini/", True)
TEMPLATE_DIR = default(TEMPLATE_DIR, "./templates/", True)
TEMPLATE_EXTENSIONS = default(TEMPLATE_EXTENSIONS, ["gmi", "xml", "html", "gemini"])
IGNORE_PREFIX = default(IGNORE_PREFIX, '_')
replace_table = default(replace_table, {})
VAR_REGEX = re.compile("({{ ?(&?[\d\w]+):?(.*[^ ])? ?}})")
dynamic_dict = {
"atom": lambda args: atom(*args),
"date": lambda args: dt.now().strftime(*args) if args else dt.now().strftime('%Y-%m-%d'),
"index": lambda args: index(*args),
"recent": lambda args: recent(*args),
"last_updated": lambda args: dt.fromtimestamp(os.path.getmtime(args[0])).strftime('%Y-%m-%d'),
"python": lambda args: python(*args),
"shell": lambda args: shell(*args),
"tags": lambda args: tags(*args),
"words": lambda args: len(args[0].split()) if args else "",
}
dynamic_dict.update(replace_table) #add whatever's specified in settings.py
messages = {}
#START HELPER FUNCTIONS
def add_message(m):
if not messages.get(m) is None:
messages[m] += 1
else:
messages[m] = 1
def files_to_gem(dir, files, date=None, name=lambda f, dir:f[len(dir):]):
s = []
for f in files: #I apoligize for this one-liner, but it works?
fname = name(f, dir)
fdate = date(f)
if fdate: s.append("=> %s %s %s" % (f[len(dir):], fdate, fname))
else: s.append("=> %s %s" % (f[len(dir):], fname))
return "\n".join(s)
def get_meta(file, target):
found = False
with open(file, 'r') as f:
line = '&'
while line.startswith("&") or line.startswith("!"):
line = f.readline()
if line[:len(target)+1].lower() == '&' + target.lower():
found = True
break
if found:
return line[len(target)+2:].strip() #+2 because there's a colon and an ampersand
return None
def link(src, dest):
try:
os.link(src, dest)
except FileExistsError:
if not os.path.islink(dest):
os.remove(dest)
link(src, dest)
except OSError:
add_message("Can't create hard-link, destination folder is on a different partition. Copying instead.")
copyfile(src, dest)
perms_s = oct(os.stat(src).st_mode)[-3:]
perms_d = oct(os.stat(dest).st_mode)[-3:]
if perms_s != perms_d: #the destination file should have the same perms as the source
os.chmod(dest, int(perms_s, 8))
def parse_str_int(s):
if not s: return None, None
if isinstance(s, int): return s, s<0
negative = s[0] == '-' #I want users to be able to write "-0" and use that as a value
try:
i = int(s[1:]) if negative else int(s)
return i, negative
except:
add_message("Error: Could not parse argument as integer")
return None, None
rel_path = lambda path: path[len(SOURCE_DIR):]
def replace_dynamic(body, fpath, d_list=None, dynamic_dict=dynamic_dict, VAR_REGEX=VAR_REGEX):
if d_list == None:
#^don't recompile regex if we don't have to
d_list = VAR_REGEX.findall(body)
d_list = [i for i in d_list if i[1] != 'words'] + [i for i in d_list if i[1] == 'words']
#^sort so that {{ words }} gets calculated last, that way other tags' additions get added to the word count
for d in d_list:
replace, key, args = d
args = args.split(",")
if args == ['']: args = []
for i in range(len(args)):
args[i] = dynamic_dict.get(args[i]) if not dynamic_dict.get(args[i]) is None else args[i]
#^allows us to use variables as an argument
if key in ["index", "recent", "atom", "tags"]:
if not args and key != "tags":args.append('')
if len(args) == 1 or not args and key == "tags":args.append(dynamic_dict["CURRENT_DIR"])
#^if index, or recent don't specify a dir, assume it's the current one
elif key == 'last_updated' and not args or key in key in ['shell', 'python'] and len(args) == 1:
args.append(dynamic_dict["CURRENT_FILE"])
#^similarly, assume these functions reference the current file if not specified
elif key == 'words' and not args:
args.append(body)
if key.startswith('&'):
replace_val = get_meta(fpath, key[1:])
elif isinstance(dynamic_dict.get(key), type(lambda: 1)):
try:
if not args: replace_val = dynamic_dict[key]([])
else: replace_val = dynamic_dict[key](args)
except TypeError:
add_message("Too many args supplied to key %s: %s" % (key, args))
replace_val = dynamic_dict[key]([])
#^If the function takes arguments, pass arguments
else:
replace_val = dynamic_dict.get(key)
if replace_val is None:
add_message("Variable used but not defined in dynamic_dict or metadata: %s" % key)
replace_val = ""
body = body.replace(replace, str(replace_val))
if body[0:10] == "!TEMPLATE":
body = "\n".join(body.splitlines()[1:])
return body
#END HELPER FUNCTIONS
#START FUNCTIONS FOR DYNAMIC CONTENT
def atom(count=0, dir=SOURCE_DIR, d=dynamic_dict):
if not d.get('site_url'): return "Must specify `site_url` in settings.py for atom feeds to work"
fname = lambda fpath: get_meta(f, "TITLE") if get_meta(fpath, "TITLE") else fpath[len(dir):]
url = lambda fpath: d['site_url'] + '/' + rel_path(dir) + os.path.basename(fpath)
date = lambda fpath: dt.fromtimestamp(os.path.getmtime(fpath)).strftime('%Y-%m-%dT%H:%M:%S+00:00')
files = [i for i in glob(dir + '/*') if os.path.isfile(i) and len(i.split('.')) > 1 and i.split('.')[-1] in TEMPLATE_EXTENSIONS and not os.path.basename(i).startswith(IGNORE_PREFIX)]
[files.remove(f) for f in files if os.path.basename(f) in ['index.gmi', 'atom.xml']]
count, _ = parse_str_int(count)
if count is None or 0: count = len(files) #parse the input
files.sort(key=lambda x: -os.path.getmtime(x)) #sort new to old (recent)
files = files[:count] #make it only the most count recent if specified
feed_s = "<?xml version='1.0' encoding='UTF-8'?>\n<feed xmlns='http://www.w3.org/2005/Atom'>" #some basic header stuff that I fill in the details of
feed_s += '\n <id>%s</id>\n <title>%s</title>\n <updated>%s</updated>' % (url(''), d.get('site_name'), date(files[0]))
feed_s += '\n <link href="%s" rel="self"/>' % url('atom.xml')
feed_s += '\n <link href="%s" rel="alternate"/>' % url('')
if d.get('author') or d.get('email'): #add author information if provided
feed_s += "\n <author>"
if d.get('author'): feed_s += "\n <name>%s</name>" % d.get('author')
if d.get('email'): feed_s += "\n <email>%s</email>" % d.get('email')
feed_s += "\n <author>"
for f in files:
feed_s += '\n <entry>\n <id>%s</id>\n <title>%s</title>\n <updated>%s</updated>\n <link href="%s" rel="alternate"/>\n </entry>' % (url(f), fname(f), date(f), url(f))
#^I should apologize for the one liner, but It takes up less space for me
feed_s += "\n</feed>"
return feed_s
def index(count=0, dir=SOURCE_DIR):
files = [i for i in glob(dir + '/*') if not i[len(dir):].startswith('_')]
[files.remove(f) for f in files if os.path.basename(f) in ['index.gmi', 'atom.xml']]
count, negative = parse_str_int(count)
if count is None or negative is None: count = 0; negative = False
if negative: files.sort(key=lambda x: os.path.getmtime(x))
else: files.sort(key=lambda x: -os.path.getmtime(x))
if count != 0: files = files[:count]
fdate = lambda f: get_meta(f, "PUB_DATE") if os.path.isfile(f) else None
fname = lambda f, dir: get_meta(f, "TITLE") if os.path.isfile(f) and get_meta(f, "TITLE") else f[len(dir):]
return files_to_gem(dir, files, date=fdate, name=fname)
def recent(count=10, dir=SOURCE_DIR):
files = [i for i in glob(dir + '/**/*', recursive=True) if os.path.isfile(i) and not os.path.basename(os.path.abspath(os.path.join(i, os.pardir))).startswith(IGNORE_PREFIX) and not os.path.basename(i).startswith(IGNORE_PREFIX)]
count, negative = parse_str_int(count) #parse the input
if count is None or negative is None: count = 10; negative = False
if negative: files.sort(key=lambda x: os.path.getmtime(x)) #the "reverse" of recent is sort latest to present
else: files.sort(key=lambda x: -os.path.getmtime(x)) #sort new to old (recent)
if count != 0: files = files[:count] #limit list if specified
fdate = lambda f: dt.fromtimestamp(os.path.getmtime(f)).strftime("%Y-%m-%d")
return files_to_gem(dir, files, date=fdate)
def python(command, fpath):
try:
namespace = {}
command = replace_dynamic(command, fpath)
exec(command, namespace)
return namespace["out"]
except KeyError:
add_message("python did not return anything because you did not define the variable `out` in your statement")
return ""
except:
add_message("python encountered a problem")
return ""
def shell(command, fpath, encoding="UTF-8"):
try:
command = replace_dynamic(command, fpath)
parent_dir = "/".join(fpath.split('/')[:-1]) + '/'
return subprocess.check_output(command, shell=True, cwd=parent_dir).decode(encoding).strip()
except:
add_message("shell encountered a problem")
return ""
def tags(dir=SOURCE_DIR):
files = [i for i in glob(dir + '/*') if os.path.isfile(i) and len(i.split('.')) > 1 and i.split('.')[-1] in TEMPLATE_EXTENSIONS and not i.startswith(IGNORE_PREFIX)]
tags = {} #dictionary of tags, and a list of all of the files with those tags
for f in files:
f_tags = get_meta(f, 'TAGS')
if f_tags:
f_tags = f_tags.replace(", ", ",").split(',')
for tag in f_tags:
tags[tag.lower()] = [f] if tags.get(tag.lower()) is None else tags[tag.lower()] + [f]
if not tags: return "No tags created yet"
tag_dir = DEST_DIR + rel_path(dir) + 'tag'
tag_template_exists = os.path.isfile(TEMPLATE_DIR + "tag.template") #defined here so the function isn't called multiple times in the for loop
if not os.path.isdir(tag_dir): os.mkdir(tag_dir)
for tag, files in tags.items():
fname = lambda f, dir: get_meta(f, "TITLE") if os.path.isfile(f) and get_meta(f, "TITLE") else f[len(dir):]
file_s = '\n'.join(['=> /%s %s' % (rel_path(f), fname(f, dir)) for f in files])
with open("%s/%s.gmi" % (tag_dir, tag), 'w') as f:
if tag_template_exists:
lines = [file_s]
with open(TEMPLATE_DIR + "tag.template", 'r') as tfile: #users can template their tag pages
template = tfile.read().splitlines()
lines = template[:template.index("!CONTENT")] + [file_s] + template[template.index("!CONTENT")+1:]
dynamic_dict['tag'] = tag #for the purposes of templating, add this variable to the dynamic_dict
f.write(replace_dynamic("\n".join(lines), "%s/%s.gmi" %(tag_dir, tag)))
else:
f.write("# %s\n\n%s" % (tag, file_s))
dynamic_dict.pop('tag', None) #a little bit of cleanup
return "\n".join(["=> tag/%s.gmi %s (%s)" % (tag, tag, len(files)) for tag, files in tags.items()])
#END FUNCTIONS FOR DYNAMIC CONTENT
def make(SOURCE_DIR=SOURCE_DIR,
dir = '',
DEST_DIR=DEST_DIR,
TEMPLATE_DIR=TEMPLATE_DIR,
TEMPLATE_EXTENSIONS=TEMPLATE_EXTENSIONS,
IGNORE_PREFIX=IGNORE_PREFIX,
VAR_REGEX=VAR_REGEX,
dynamic_dict=dynamic_dict):
if not os.path.isdir(DEST_DIR): os.mkdir(DEST_DIR)
for root, dirs, files in os.walk(SOURCE_DIR + dir):
dynamic_dict["CURRENT_DIR"] = root if root.endswith("/") else root + '/' #I use this in the source files
[dirs.remove(d) for d in list(dirs) if d.startswith('_')]
for d in dirs: #create the directories if they haven't been created
if not os.path.isdir(DEST_DIR + os.path.join(rel_path(root), d)):
os.mkdir(DEST_DIR + os.path.join(rel_path(root), d))
for fname in files:
parent = rel_path(root)
fpath = os.path.join(root, fname)
dynamic_dict["CURRENT_FILE"] = fpath #Use this in the source files, too
dpath = "%s%s" % (DEST_DIR, os.path.join(parent, fname))
template = None
isChangable = os.path.splitext(fname)[1][1:] in TEMPLATE_EXTENSIONS
if not isChangable:
link(fpath, dpath)
else:
#^Only look at files with a suffix in template_extensions, saves time + sometimes you can't read weird files like executables
with open(fpath, 'r') as f:
rl = f.read().splitlines()
#Get the template
if rl and rl[0].strip() == "!TEMPLATE:none": #don't do a template if it says !TEMPLATE:none
rl.pop(0)
elif rl[0].startswith("!TEMPLATE"):
#TEMPLATE CASE 1: It's explicitely written out
try:
with open(TEMPLATE_DIR + rl[0][10:] + ".template", 'r') as tfile:
template = tfile.read().splitlines()
except:
add_message("Template '%s' specified but does not exist" % r[0][10:])
elif os.path.isfile(TEMPLATE_DIR + parent + ".template"):
#TEMPALTE CASE 2: It's automatically applied to the dynamic_dict
with open(TEMPLATE_DIR + parent + ".template", 'r') as tfile:
template = tfile.read().splitlines()
if not template is None:
rl = template[:template.index("!CONTENT")] + rl + template[template.index("!CONTENT")+1:]
body = "\n".join([i for i in rl if not i.startswith("&")])
d_list = VAR_REGEX.findall(body)
if body == "\n".join(rl) and not d_list: #the files aren't dynamic, link them
link(fpath, dpath)
else:
#replace the static variables with the dynamic content
body = replace_dynamic(body, fpath, d_list)
with open(dpath, "w") as dfile:
dfile.write(body)
def main():
make()
for message, count in messages.items():
if count > 1: print("%s (x%s)" % (message, count))
else: print(message)
if __name__ == "__main__":
main()