conf-cal
Version:
Convenient calendar for conferences
397 lines (375 loc) • 12.3 kB
JavaScript
const CalError = require('./CalError')
const getTimezone = require('./getTimezone')
const slotsForRooms = require('./slotsForRooms')
const renderSlots = require('./renderSlots')
const moment = require('moment')
const MD_INDENT = 4
function applyAutoIds (entries, entriesList) {
for (const entry of entriesList) {
if (!entry.id) {
let id = entry.auto_id
// This works because the parent
// is always before the child in the list
if (entry.parent) {
id = `${entry.parent.id}${entry.auto_id}`
entry.parentId = entry.parent.id
delete entry.parent
}
while (entries[id]) {
id = `$${id}`
}
entry.id = id
entries[id] = entry
} else {
entry.hasCustomId = true
}
delete entry.auto_id
}
}
function extractPerson (roomEntry) {
let personParts = /\s+by\s+(.*)$/ig.exec(roomEntry.summary)
if (personParts) {
roomEntry.summary = roomEntry.summary.substr(0, personParts.index)
roomEntry.person = personParts[1]
} else {
roomEntry.person = null
}
}
function processInput (options, string) {
const lines = string.split('\n')
const isEmptyLine = (line) => /^\s*$/.test(line)
if (lines.filter(isEmptyLine).length === lines.length) {
throw new CalError('empty', 'Input is empty')
}
const rooms = {}
const persons = {}
function addToPersons (roomEntry) {
if (roomEntry.person) {
let roomEntries = persons[roomEntry.person]
if (!roomEntries) {
roomEntries = []
persons[roomEntry.person] = roomEntries
}
roomEntries.push(roomEntry)
}
}
const entries = {}
const entriesList = []
const doc = {
rooms
}
const checkMissing = (lineIndex) => {
if (!doc.date) {
throw new CalError('missing-data', 'Date not specified in header, use "on YYYY/MM/DD" as a date format', lineIndex)
}
if (!doc.location) {
throw new CalError('missing-data', 'Location not specified in header, use "at <location>#<google-place-id>" to specify one', lineIndex)
}
if (!doc.title) {
throw new CalError('missing-data', 'No title specified, before the first room, enter any title in the first line', lineIndex)
}
}
let room = null
let roomIndex = 0
let roomData = null
let continueLine = false
let docIndent = -1
let entryIndent = -1
let subEntryIndent = -1
let restrictIndent = -1
let continueDescription = false
let wasEmpty = false
function extractId (roomEntry, lineIndex, columOffset) {
let idParts = /\s+#([a-zA-Z0-9-._~:@/?!$&'()*+,;=]*)/ig.exec(roomEntry.summary)
if (idParts) {
roomEntry.summary = roomEntry.summary.substr(0, idParts.index)
const id = idParts[1]
if (entries[id]) {
throw new CalError('duplicate-id', `There are two or more entries with the id: ${id}`, lineIndex, columOffset + idParts.index)
}
roomEntry.id = id
}
}
function extractLang (roomEntry, lineIndex, columOffset) {
let langParts = /\s+in\s+([a-z]{2}(-[a-z]{2})?)$/ig.exec(roomEntry.summary)
if (langParts) {
roomEntry.summary = roomEntry.summary.substr(0, langParts.index)
roomEntry.lang = langParts[1]
} else {
roomEntry.lang = null
}
}
function extractEntryMeta (roomEntry, lineIndex, columOffset) {
extractId(roomEntry, lineIndex, columOffset)
extractLang(roomEntry, lineIndex, columOffset)
extractPerson(roomEntry)
}
function processFirstEntryLine (roomEntry, lineIndex, columOffset) {
extractEntryMeta(roomEntry, lineIndex, columOffset)
const continueLine = /\\$/ig.test(roomEntry.summary)
if (continueLine) {
roomEntry.summary = roomEntry.summary.substr(0, roomEntry.summary.length - 1)
}
return continueLine
}
function processRoom (line, lineIndex) {
const r = /^\[(.*)\]\s*$/ig.exec(line)
if (r) {
room = r[1]
roomData = []
rooms[room] = roomData
roomIndex += 1
checkMissing(lineIndex)
return true
}
}
function assertStrictIndent (lineIndex, lineIndent) {
if (lineIndent !== docIndent) {
throw new CalError('invalid-indent', `The document's indent is derminded in the first line to be ${docIndent} spaces, it is ${lineIndent} spaces at line ${lineIndex}`, lineIndex, lineIndent)
}
}
function processHeader (line, lineIndex, lineIndent) {
assertStrictIndent(lineIndex, lineIndent)
if (processRoom(line, lineIndex)) {
return true
}
const loc = /^at ([^#]*)#(.*)\s*$/ig.exec(line)
if (loc) {
doc.location = loc[1]
doc.googleObjectId = loc[2]
return true
}
const time = /^on ([0-9]{4})\/([0-9]{2})\/([0-9]{2})\s*$/ig.exec(line)
if (time) {
doc.date = `${time[1]}${time[2]}${time[3]}`
return true
}
if (!doc.title) {
doc.title = line.trim()
return true
}
throw new CalError('invalid-data', `Unknown header "${line}"`, lineIndex, lineIndent)
}
function processDateLine (line, lineIndex, columnOffset) {
const parts = /^(([0-9]{2}):([0-9]{2})-([0-9]{2}):([0-9]{2})\s*)(.*)\s*$/ig.exec(line)
if (parts) {
if (continueLine) {
throw new CalError('invalid-data', 'Line tries to extend over entry boundaries', lineIndex - 1, columnOffset)
}
const roomEntry = {
auto_id: `${roomIndex}-${roomData.length + 1}`,
start: `${doc.date}T${parts[2]}${parts[3]}00`,
end: `${doc.date}T${parts[4]}${parts[5]}00`,
summary: parts[6],
room
}
continueDescription = false
continueLine = processFirstEntryLine(roomEntry, lineIndex, columnOffset + parts[1].length)
if (continueLine) {
restrictIndent = entryIndent
} else {
finishSummary(roomEntry)
}
addToPersons(roomEntry)
if (roomEntry.id) {
entries[roomEntry.id] = roomEntry
}
entriesList.push(roomEntry)
roomData.push(roomEntry)
return true
}
}
function finishSummary (roomEntry) {
roomEntry.summary = roomEntry.summary.trim()
}
function processBody (roomEntry, formerRoom, line, lineIndex, lineIndent, allowSubentries) {
let nextLine = line.replace(/(\s*)$/, '')
if (!continueLine) {
const listParts = /^(-\s+)(.*)$/g.exec(nextLine)
if (listParts && allowSubentries) {
if (!formerRoom.entries) {
formerRoom.entries = []
}
roomEntry = {
auto_id: `-${formerRoom.entries.length + 1}`,
summary: listParts[2],
room
}
if (roomEntry.id) {
entries[roomEntry.id] = roomEntry
}
entriesList.push(roomEntry)
continueDescription = false
continueLine = processFirstEntryLine(roomEntry, lineIndex, listParts[1].length + lineIndent)
if (!continueLine) {
finishSummary(roomEntry)
}
addToPersons(roomEntry)
roomEntry.parent = formerRoom
formerRoom.entries.push(roomEntry)
return true
}
nextLine = '\n' + nextLine
}
continueLine = /\\$/ig.test(nextLine)
if (continueLine) {
nextLine = nextLine.substr(0, nextLine.length - 1)
restrictIndent = lineIndent
} else {
restrictIndent = -1
}
if (continueDescription) {
if (wasEmpty) {
nextLine = '\n' + nextLine
}
roomEntry.description += nextLine
return true
}
if (wasEmpty) {
roomEntry.description = nextLine.substr(1)
continueDescription = true
return true
}
roomEntry.summary += nextLine
if (!continueLine) {
finishSummary(roomEntry)
}
return true
}
function processLine (line, lineIndex) {
const lineParts = /^([ ]*)(.*)/g.exec(line)
const lineIndent = lineParts[1].length
line = lineParts[2]
if (docIndent === -1) {
docIndent = lineIndent
entryIndent = docIndent + MD_INDENT
subEntryIndent = docIndent + MD_INDENT * 2
}
if (room === null) {
if (processHeader(line, lineIndex, lineIndent)) {
return
}
} else {
const formerRoom = roomData[roomData.length - 1]
if (restrictIndent !== -1) {
if (lineIndent !== restrictIndent) {
throw new CalError('invalid-indent', `Line is a continuation of the former line and as such is expected to have ${restrictIndent} spaces, but it has ${lineIndent}`, lineIndex, lineIndent)
}
}
if (!formerRoom) {
assertStrictIndent(lineIndex, lineIndent)
}
if (!formerRoom || lineIndent === docIndent) {
if (processRoom(line, lineIndex)) {
return true
}
if (processDateLine(line, lineIndex, lineIndent)) {
return true
}
} else {
let roomEntry = formerRoom.entries ? formerRoom.entries[formerRoom.entries.length - 1] : formerRoom
let expectedIndent = entryIndent
if (roomEntry.parent) {
if (lineIndent < subEntryIndent && !continueLine) {
roomEntry = formerRoom
continueDescription = false
} else {
expectedIndent = subEntryIndent
}
}
const allowSubentries = (!roomEntry.parent) && !roomEntry.description
if (lineIndent < expectedIndent) {
throw new CalError('invalid-indent', `The indentation of line needs to be at least ${expectedIndent} as it is content of the entry but is ${lineIndent}`, lineIndex, lineIndent)
}
if (lineIndent > expectedIndent) {
line = spaces(lineIndent - expectedIndent) + line
}
if (processBody(roomEntry, formerRoom, line, lineIndex, expectedIndent, allowSubentries)) {
return true
}
}
}
throw new CalError('invalid-data', `Unprocessable line.`, lineIndex, lineIndent)
}
try {
lines.forEach((line, lineIndex) => {
if (isEmptyLine(line)) {
wasEmpty = true
return // empty lines
}
processLine(line, lineIndex)
wasEmpty = false
})
} catch (err) {
if (err instanceof CalError) {
let line = lines[err.line - 1]
err.message += '\n\nL' + err.line + ':' + line + '\n' + spaces(err.column - 1 + err.line.toString().length + 2) + '↑'
}
throw err
}
checkMissing(lines.length - 1)
return getTimezone(options, doc.googleObjectId)
.then(googleObject => {
applyTimeZone(rooms, googleObject.timeZone)
doc.googleObject = googleObject
doc.persons = persons
applyAutoIds(entries, entriesList)
doc.entries = entries
return doc
})
}
function spaces (count) {
return new Array(count + 1).join(' ')
}
function applyTimeZone (rooms, timeZone) {
Object.keys(rooms).forEach(room => {
rooms[room].forEach(roomEntry => {
roomEntry.start = moment.tz(roomEntry.start, timeZone).toISOString()
roomEntry.end = moment.tz(roomEntry.end, timeZone).toISOString()
})
})
}
function toPromise (input) {
if (input instanceof Promise || (input && input.then)) {
return input
}
return Promise.resolve(input)
}
const confCal = (options, input) => {
if (input === null || input === undefined) {
return Promise.reject(new CalError('empty', 'input is missing'))
}
if (!options || typeof options !== 'object') {
return Promise.reject(new CalError('missing-option', 'options are missing'))
}
return toPromise(input)
.then(stringOrBuffer => {
if (!stringOrBuffer) {
throw new CalError('empty', 'Input not given')
}
return String(stringOrBuffer)
})
.then(string => processInput(options, string))
.then(rawData => {
return Object.assign(
rawData,
{
toSlots: function () {
return slotsForRooms(this.googleObject.timeZone, this.rooms)
},
render: function (options) {
const slots = this.toSlots()
options = Object.assign({
header: `## ${this.title}\nat [${this.location}](${this.googleObject.url})\n`
}, options)
return renderSlots(options, slots)
},
toMarkdown: function () {
return this.render({})
}
}
)
})
}
confCal.CalError = CalError
module.exports = confCal