#
# Copyright 2003, 2004 S. Kevin Hester, kevinh@geeksville.com
#
# Airplane build log keeper - automatically scans the Pictures folder and
# creates thumbnails/web pages/build totals.
#
# History:
# V1.0 - Original version
# V1.1 - Update for windows/Python2.3
import os
import time
import string
import Image
import EXIF
from xml.sax.saxutils import *
# Fix day of week FIXME
HUMAN_DATE_FORMAT = "%A, %B %d, %Y"
# HUMAN_DATE_FORMAT = "%B %d, %Y"
FILE_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
ISO_FORMAT = "%Y-%m-%d"
PICT_DIR = "pictures"
PICT_PREFIX = "bld-"
PAGE_DIR = "pages"
THUMB_DIR = "thumbs"
# task codes:
# vs, hs, elev, rudder, shop, elect, wing, aileron, flap
TASK_MAP = {
"vs" : "Vertical Stabilizer",
"hs" : "Horizontal Stabilizer",
"elev" : "Elevator",
"elect" : "Electrical/Instruments",
"fuse" : "Fuselage",
"ail" : "Ailerons",
"flap" : "Flaps",
"engine" : "Engine/Firewall forward",
"finish" : "Finish kit (Canopy/gear/trim)",
"shop" : "Shop",
"rudder" : "Rudder",
"cowl" : "Cowl and Baffles (older entries under finish Kit)",
"canopy" : "Canopy (older entries under finish kit)",
"" : "Not yet categorized"
}
TASK_SEQUENCE = [
"vs",
"hs",
"elev",
"rudder",
"Wing",
"ail",
"flap",
"elect",
"fuse",
"finish",
"cowl",
"canopy",
"engine",
"shop",
""
]
def fixWebPath(path):
"""Convert a path that may have windows style seperators
into something civilized - so that web browsers don't freak."""
return path.replace(os.sep, '/')
def taskTypeToHuman(task):
"""Return human version of a particular task type"""
try:
return TASK_MAP[task]
except Exception:
return task # Perhaps not a short string
def writeElement(file, element, value):
"""Write a simple XML element to a file"""
file.write("<%s>" % element)
file.write(escape(value));
file.write("%s>\n" % element)
def genStdHeaders(out):
"""Writes web page headers"""
out.write('''
''')
def dateToHuman(date):
"""Return human readable version of this date"""
year = date / (31 * 15)
date = date % (31 * 15)
mon = date / 31
day = date % 31
sectime = time.mktime( ( year, mon, day, 0, 0, 0, 0, 0, -1 ) )
localtime = time.localtime( sectime )
return time.strftime(HUMAN_DATE_FORMAT, localtime )
def getThumbnail(pictpath):
"""Return the name of our thumbnail"""
(dir, filename) = os.path.split(pictpath[0 : pictpath.rindex('.')])
if not os.path.exists(THUMB_DIR):
os.mkdir(THUMB_DIR)
return os.path.join(THUMB_DIR, filename + "-thumb.jpg")
def renameUniqueFile(filename):
"""Rename the given filepath using our naming convention and ensure all filenames are unique
Return the new filename"""
# For now I've decided not to rename files
return filename
# How many filenames have we tried
attempt = 0
oldname = filename
(dir, srcfile) = os.path.split(filename)
if not srcfile.startswith(PICT_PREFIX): # Already been processed?
filename = None
while not filename:
newname = PICT_PREFIX
if attempt != 0:
newname = newname + str(attempt) + "-"
newname = newname + srcfile
if os.path.exists(newname):
attempt = attempt + 1
else:
filename = os.path.join(dir, newname)
os.rename(oldname, filename)
return filename
def dateToPath(date):
"""Convert a path into the URL for that page"""
# yuck - nasty hack cause 2.2 python don't have Date objects
year = date / (31 * 15)
date = date % (31 * 15)
mon = date / 31
day = date % 31
return os.path.join(PAGE_DIR, "day-%04u-%02u-%02u.html" % (year, mon, day))
# Ugly hack to swap endianness
endianchar = '>'
# TIFF file format
TIFF_FORMAT = lambda : "%c2HL" % endianchar
# each IFD is [ # fields, interop fields, offset to next ifd ]
IFD_FORMAT = lambda : "%cH" % endianchar
# tag, type, count, valoffset
IFD_INTEROP = lambda : "%cHHLL" % endianchar
# offset to next id
IFD_NEXT_INTEROP = lambda : "%cL" % endianchar
EXIM_DATE_FORMAT = "%Y:%m:%d %H:%M:%S"
def getEximDate(eximdata):
"""Extract the creation date as a struct_time object"""
s = eximdata['EXIF DateTimeOriginal']
return time.strptime(str(s), EXIM_DATE_FORMAT)
class LogRecord:
def __init__(self):
self.date = None
self.pictPath = None
self.numHours = None
self.description = ""
self.subsystem = ""
def __cmp__(self, other):
"""Order by date"""
mydate = time.mktime(self.date)
otherdate = time.mktime(other.date)
return mydate - otherdate
def makeRow(self):
"""Return a CSV row to write this item"""
return [ time.strftime(FILE_DATE_FORMAT, self.date), self.pictPath, self.numHours, self.subsystem, self.description ]
def writeRecord(self, file):
"""Return a CSV row to write this item"""
file.write(time.strftime(FILE_DATE_FORMAT, self.date) + "~")
file.write(self.pictPath + "~")
file.write(str(self.numHours) + "~")
file.write(self.subsystem + "~")
file.write(self.description + "\n")
def writeRecordXML(self, file):
"""Write an XML representation of this item"""
file.write("\n" % quoteattr(time.strftime(FILE_DATE_FORMAT, self.date)))
writeElement(file, "Picture", self.pictPath)
writeElement(file, "Hours", str(self.numHours))
writeElement(file, "Subsystem", self.subsystem)
writeElement(file, "Description", self.description)
file.write("\n")
def setFromXML(self, elementname, value):
pass
def readRow(self, row):
"""Init this item based on a CSV row"""
try:
[ self.date, self.pictPath, self.numHours, self.subsystem, self.description ] = row
except ValueError:
raise Exception("Can't parse row: %s" % ( row ) )
# Convert the file path to have the appropriate seperator
# for whatever platform we are on
self.pictPath = self.pictPath.replace('/', os.sep)
self.pictPath = self.pictPath.replace('\\', os.sep)
# fix the date (kinda yucky)
self.date = time.strptime(self.date, FILE_DATE_FORMAT)
self.numHours = float(self.numHours)
def readRecord(self, line):
"""Init this item based on a CSV row"""
line = string.strip(line)
row = string.split(line, "~")
self.readRow(row)
def importPict(self, pictpath):
"""Import as much info as possible from a JPG picture"""
self.pictPath = pictpath
self.numHours = 0
self.description = ""
self.date = time.gmtime(os.path.getmtime(pictpath))
# Get creation date
f = file(pictpath, "rb")
tifftags = EXIF.process_file(f)
f.close()
self.date = getEximDate(tifftags)
# Create a thumbnail
im = Image.open(pictpath)
im.load() # for debugging
im.thumbnail((256, 256))
im.save(getThumbnail(pictpath), "JPEG")
def getSubsystem(self):
"""Return human readable string for the subsystem"""
return taskTypeToHuman(self.subsystem)
def getDescription(self):
"""Return a human readable description"""
if self.description != "":
return self.description
else:
return "(No description yet)"
def getUniqueKey(self):
"""Return a unique key for this item"""
return ( self.date, self.pictpath )
def getDate(self):
"""Return a date object for this date - i.e. drop the time info"""
obj = self.date
date = obj.tm_year * 31 * 15 + obj.tm_mon * 31 + obj.tm_mday
return date
class LogDB:
def __init__(self, filename):
self.pictdict = {} # A mapping based solely on picture path
self.datedict = {} # A mapping based solely by date
self.records = [] # Our data records
self.filename = filename
try:
f = file(filename, "r")
self.readXML(f)
f.close()
except IOError, arg:
print "Ignoring ", arg
def save(self):
"""Save to our existing filename"""
tempname = self.filename + "~"
f = file(tempname, "w")
self.write(f)
f.close()
# If we don't already have a DB this will ignore the failure
try:
tempname2 = self.filename + "~~"
try:
os.unlink(tempname2)
except OSError, arg:
pass
os.rename(self.filename, tempname2)
except OSError, arg:
print "Ignoring ", arg
os.rename(tempname, self.filename)
def append(self, rec):
"""Add a record"""
self.records.append(rec)
self.pictdict[rec.pictPath] = rec
date = rec.getDate()
if date in self.datedict:
list = self.datedict[date]
list.append(rec)
list.sort() # Yuck - very ineffiecent to repeatedly sort like this
else:
self.datedict[date] = [ rec ]
def read(self, file):
"""Read from a file"""
for line in file:
rec = LogRecord()
rec.readRecord(line)
self.append(rec)
def readXML(self, file):
"""Read from an XML file"""
for line in file:
rec = LogRecord()
rec.readRecord(line)
self.append(rec)
def write(self, file):
"""Write to a file"""
for rec in self.records:
rec.writeRecord(file)
def writeXML(self, file):
"""Write to an XML file"""
file.write("\n");
file.write("\n");
for rec in self.records:
rec.writeRecordXML(file)
file.write("\n");
def readCsv(self, file):
"""Read from a CSV based file"""
import csv
reader = csv.reader(file)
for row in reader:
rec = LogRecord()
rec.readRow(row)
self.append(rec)
def writeCsv(self, file):
"""Write to a CSV based file"""
import csv
writer = csv.writer(file)
for rec in self.records:
writer.writerow(rec.makeRow())
def addPictures(self, dirpath):
"""Add all pictures in the indicated directory"""
import mimetypes
filenames = os.listdir(dirpath)
for file in filenames:
filepath = os.path.join(dirpath, file)
if (mimetypes.guess_type(filepath)[0] == "image/jpeg") and (
filepath not in self.pictdict) and (
not filepath.endswith("-thumb.jpg")):
filepath = renameUniqueFile(filepath)
print "Adding record for " + filepath
rec = LogRecord()
rec.importPict(filepath)
# Ugly hack - always assume at least 2 hours work if we find a picture
if self.hoursByDate(rec.getDate()) == 0.0:
rec.numHours = 2.0
self.append(rec)
def hoursByDate(self, date):
"""Return the number of hours worked on this day"""
try:
tasks = self.datedict[date]
return reduce((lambda val, task: val + task.numHours), tasks, 0)
except KeyError:
return 0.0 # Not found
def getDatesByType(self, tasktype):
"""Return a list of all dates that mention the specified subsystem"""
matchdates = {}
for rec in self.records:
if rec.subsystem.lower() == tasktype.lower():
matchdates[rec.getDate()] = True
dates = matchdates.keys()
dates.sort()
return dates
def genIndexPage(self):
"""Generate the top level index page and all subpages"""
if not os.path.exists(PAGE_DIR):
os.mkdir(PAGE_DIR)
# Make a filename
filename = "index.html"
# Make the page
simpleTitle = """Kevin's build log"""
title = '''Kevin's build log'''
out = file(filename, "w")
out.write("\n")
out.write("
\n")
out.write("")
out.write(simpleTitle)
out.write("\n")
out.write("\n")
out.write("\n")
genStdHeaders(out)
out.write("")
out.write(title)
out.write("
")
# Generate the subpages
dates = self.datedict.keys()
dates.sort()
totalhours = 0.0
# Map from date to pagepath
dateToPage = {}
prevPath = None
nextPath = None
for i in range(len(dates)):
date = dates[i]
if i < len(dates) - 1:
nextPath= dateToPath(dates[i + 1])
else:
nextPath = None
prevPath = dateToPage[date] = self.genPageForDate(date, prevPath, nextPath)
hours = self.hoursByDate(date)
totalhours = totalhours + hours
latestdate = dates[-1]
latestpage = dateToPage[latestdate]
out.write('''\n''')
out.write('''Latest update: %s
''' %
( fixWebPath(latestpage), dateToHuman(latestdate) ))
out.write('''This is the actual FAA mandated build log for my RV-7A project.
''')
out.write('''This HTML is currently rather ugly - but it is at least complete.
I've written a little tool to automatically pull
pictures out of my camera, update a database, and generate this webpage.
''')
out.write('''New! I now have a link for a printable version of this log.
''')
out.write("Total hours (all subsystems): " + str(totalhours) + "
\n" +
"This time estimate is of pretty poor quality, typically I work two hours on week nights and six hours on weekends. However - I usually forget to tell my tool these details.")
# Order by date, but within each category
for taskType in TASK_SEQUENCE:
totalhours = 0.0
out.write("
" + taskTypeToHuman(taskType) + "
")
out.write("\n")
for date in self.getDatesByType(taskType):
pagename = dateToPage[date]
out.write("- \n")
out.write('%s' %
( fixWebPath(pagename), dateToHuman(date) ) )
hours = self.hoursByDate(date)
totalhours = totalhours + hours
out.write(' ' + str(hours) + " hours ")
out.write("
\n")
out.write("
\n")
out.write("Total hours: " + str(totalhours) + "\n")
out.write("\n")
out.write("\n")
out.close()
return filename
def genPrintableBody(self, out, date):
"""Generate the body for a day of work - formatted for a printer"""
out.write("
\n") # page divider
# Make the page
title = "Build log date: " + dateToHuman(date)
out.write("")
out.write(title)
out.write("
")
out.write("" + str(self.hoursByDate(date)) + " hours\n");
tasks = self.datedict[date]
out.write('\n')
# Specify two columns, each equal sized
out.write('')
colnum = 0
for task in tasks:
if colnum == 0:
out.write("")
out.write("\n")
out.write(task.getDescription())
out.write(" \n")
# width="80%"
if len(task.pictPath) != 0:
out.write('' % fixWebPath(getThumbnail(task.pictPath)) )
out.write(" " + task.getSubsystem() + " \n");
out.write(" | \n")
colnum = colnum + 1
if colnum == 2:
colnum = 0
out.write("
\n")
def genPrintablePage(self):
"""Generate a large printable document"""
if not os.path.exists(PAGE_DIR):
os.mkdir(PAGE_DIR)
# Make a filename
filename = "printable.html"
# Make the page
title = "Kevin's build log"
out = file(filename, "w")
out.write("\n")
out.write("\n")
# Give hint about page breaks
if None:
out.write('''''');
out.write("")
out.write(title)
out.write("\n")
out.write("\n")
out.write("\n")
out.write("")
out.write(title)
out.write("
")
# Generate the subpages
dates = self.datedict.keys()
dates.sort()
out.write('''This is the actual FAA mandated build log for my RV-7A project.''')
for date in dates:
self.genPrintableBody(out, date)
out.write("\n")
out.write("\n")
out.close()
return filename
def genPageForDate(self, date, prevPath, nextPath):
"""Create the HTML file for the indicated date - return the name of the file"""
# Make a filename
filename = dateToPath(date)
# Make the page
title = "Build log date: " + dateToHuman(date)
out = file(filename, "w")
out.write("\n")
out.write("
\n")
out.write("")
out.write(title)
out.write("\n")
out.write("\n")
out.write("\n")
genStdHeaders(out)
out.write("")
out.write(title)
out.write("
")
tasks = self.datedict[date]
out.write("\n")
if prevPath:
out.write('Previous ')
out.write('Top ');
if nextPath:
out.write('Next')
out.write("\n")
out.write("\n")
out.close()
return filename
# Begin main
print "Buildlog update starting, v1.0"
# os.chdir("/Users/kevinh/development/log-keeper")
db = LogDB("buildlog.db")
if not os.path.exists(PICT_DIR):
os.mkdir(PICT_DIR)
db.addPictures(PICT_DIR)
db.save()
db.genIndexPage()
db.genPrintablePage()