cgi-bin/tek/class/loona.lua
changeset 188 77f536d09f08
parent 183 8fd80d31c396
child 189 efb1bd425c56
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/cgi-bin/tek/class/loona.lua	Sun May 20 18:28:42 2007 +0200
     1.3 @@ -0,0 +1,1717 @@
     1.4 +
     1.5 +--
     1.6 +--	loona - tiny CMS
     1.7 +--	Written by Timm S. Mueller <tmueller at neoscientists.org>
     1.8 +--	See copyright notice in COPYRIGHT
     1.9 +--
    1.10 +
    1.11 +local lib = require "tek.lib"
    1.12 +local luahtml = require "tek.lib.luahtml"
    1.13 +local posix = require "tek.os.posix"
    1.14 +local http = require "tek.class.http"
    1.15 +local Request = require "tek.class.http.request"
    1.16 +local util = require "tek.class.loona.util"
    1.17 +local markup = require "tek.class.loona.markup"
    1.18 +
    1.19 +local boxed_G = { 
    1.20 +	string = string, table = table,
    1.21 +	assert = assert, collectgarbage = collectgarbage, dofile = dofile,
    1.22 +	error = error, getfenv = getfenv, getmetatable = getmetatable,
    1.23 +	ipairs = ipairs, load = load, loadfile = loadfile, loadstring = loadstring,
    1.24 +	next = next, pairs = pairs, pcall = pcall, print = print,
    1.25 +	rawequal = rawequal, rawget = rawget, rawset = rawset, require = require,
    1.26 +	select = select, setfenv = setfenv, setmetatable = setmetatable,
    1.27 +	tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
    1.28 +	xpcall = xpcall
    1.29 +}
    1.30 +
    1.31 +local table, string, assert, unpack, ipairs, pairs, type, require =
    1.32 +	table, string, assert, unpack, ipairs, pairs, type, require
    1.33 +local setmetatable, setfenv, getfenv = setmetatable, setfenv, getfenv
    1.34 +local open, remove, rename, getenv, time, date =
    1.35 +	io.open, os.remove, os.rename, os.getenv, os.time, os.date
    1.36 +
    1.37 +
    1.38 +module "tek.class.loona"
    1.39 +
    1.40 +
    1.41 +_VERSION = 4
    1.42 +_REVISION = 1
    1.43 +
    1.44 +
    1.45 +-- Session
    1.46 +
    1.47 +
    1.48 +local Session = { }
    1.49 +
    1.50 +
    1.51 +function Session:new(o)
    1.52 +
    1.53 +	o = o or { }
    1.54 + 	setmetatable(o, self)
    1.55 + 	self.__index = self
    1.56 +	
    1.57 +	assert(o.id, "No session Id")
    1.58 + 	assert(o.sessiondir, "No session directory")
    1.59 +	
    1.60 +	o.name = o.id:gsub("(.)", function(a)
    1.61 +		return ("%02x"):format(a:byte())
    1.62 +	end)
    1.63 +	o.filename = o.sessiondir .. "/" .. o.name
    1.64 +	-- remove non-dotted files (expired sessions) from sessions dir:
    1.65 +	util.expire(o.sessiondir, "[^.]%S+", o.maxage or 600)
    1.66 +	-- load session state:
    1.67 +	o.data = lib.source(o.filename) or { }
    1.68 +	
    1.69 +	return o
    1.70 +end
    1.71 +
    1.72 +
    1.73 +function Session:save()
    1.74 +	local f = open(self.filename, "wb")
    1.75 +	assert(f, "Failed to open session file for writing")
    1.76 +	lib.dump(self.data, function(...) 
    1.77 +		f:write(unpack(arg)) 
    1.78 +	end)
    1.79 +	f:close()
    1.80 +end
    1.81 +
    1.82 +
    1.83 +function Session:delete()
    1.84 +	remove(self.filename)
    1.85 +end
    1.86 +
    1.87 +
    1.88 +-- LOona
    1.89 +
    1.90 +
    1.91 +local Loona = getfenv()
    1.92 +
    1.93 +
    1.94 +function Loona:dbmsg(msg, detail)
    1.95 + 	return (msg and detail and self.authuser) and
    1.96 + 		("%s : %s"):format(msg, detail) or msg
    1.97 +end
    1.98 +
    1.99 +
   1.100 +function Loona:checkprofilename(n)
   1.101 +	assert(n:match("^%w+$") and n ~= "current",
   1.102 +		self:dbmsg("Invalid profile name", n))
   1.103 +	return n
   1.104 +end
   1.105 +
   1.106 +
   1.107 +function Loona:checklanguage(n)
   1.108 +	assert(n:match("^%l%l$"), self:dbmsg("Invalid language code", n))
   1.109 +	return n
   1.110 +end
   1.111 +
   1.112 +
   1.113 +function Loona:checkbodyname(s)
   1.114 +	s = s or "main"
   1.115 +	assert(s:match("^[%w_]*%w+[%w_]*$"), self:dbmsg("Invalid body name", s))
   1.116 +	return s
   1.117 +end
   1.118 +
   1.119 +
   1.120 +function Loona:deleteprofile(p, lang)
   1.121 +	p = self.config.contentdir .. "/" .. p .. "_" .. (lang or self.lang)
   1.122 +	for e in util.readdir(p) do
   1.123 + 		local success, msg = remove(p .. "/" .. e)
   1.124 +		assert(success, self:dbmsg("Error removing entry in profile", msg))
   1.125 +	end
   1.126 +	return remove(p)
   1.127 +end
   1.128 +
   1.129 +
   1.130 +function Loona:copyprofile(dstprof, srcprof, dstlang, srclang)
   1.131 +	local contentdir = self.config.contentdir
   1.132 +	local src = ("%s/%s_%s"):format(contentdir,
   1.133 +		srcprof or self.profile, srclang or self.lang)
   1.134 +	local dst = ("%s/%s_%s"):format(contentdir,
   1.135 +		dstprof or self.profile, dstlang or self.lang)
   1.136 +	assert(src ~= dst, self:dbmsg("Attempt to copy profile over itself"))
   1.137 +	assert(posix.stat(src, "mode") == "directory",
   1.138 +		self:dbmsg("Source profile not a directory", src))
   1.139 +	local success, msg = posix.mkdir(dst)
   1.140 +	assert(success, self:dbmsg("Error creating profile directory " .. dst, msg))
   1.141 +	for e in util.readdir(src) do
   1.142 +		local ext = e:match("^[^.].*%.([^.]*)$")
   1.143 +		if ext ~= "LOCK" then
   1.144 +			local f = src .. "/" .. e
   1.145 +			if posix.stat(f, "mode") == "file" then
   1.146 +				success, msg = lib.copyfile(f, dst .. "/" .. e)
   1.147 +				assert(success, self:dbmsg("Error copying file", msg))
   1.148 +			end
   1.149 +		end
   1.150 +	end
   1.151 +	-- create "current" symlink if none exists for new profile/language
   1.152 +	if not posix.readlink(contentdir .. "/current_" .. dstlang) then
   1.153 +		self:makecurrent(dstprof, dstlang)
   1.154 +	end
   1.155 +end
   1.156 +
   1.157 +
   1.158 +function Loona:makecurrent(prof, lang)
   1.159 +	prof = prof or self.profile
   1.160 +	lang = lang or self.lang
   1.161 +	local contentdir = self.config.contentdir
   1.162 +	local newpath = ("%s/current_%s"):format(contentdir, lang)
   1.163 +	local tmppath = newpath .. "." .. self.session.name
   1.164 +	local success, msg = posix.symlink(prof .. "_" .. lang, tmppath)
   1.165 +	assert(success, self:dbmsg("Cannot create symlink", msg))
   1.166 +	success, msg = rename(tmppath, newpath)
   1.167 +	assert(success, self:dbmsg("Cannot put symlink in place", msg))
   1.168 +	return true
   1.169 +end
   1.170 +
   1.171 +
   1.172 +function Loona:publishprofile(profile, lang)
   1.173 +	lang = lang or self.lang
   1.174 +	local contentdir = self.config.contentdir
   1.175 +	
   1.176 +	-- Get languages for the current profile
   1.177 +	
   1.178 +	local plangs = { }
   1.179 +	local lmatch = "^" .. self.profile .. "_(%w+)$"
   1.180 +	for e in util.readdir(self.config.contentdir) do
   1.181 +		local l = e:match(lmatch)
   1.182 +		if l then
   1.183 +			table.insert(plangs, l)
   1.184 +		end
   1.185 +	end
   1.186 +	
   1.187 +	-- For all languages, update "current" symlink
   1.188 +	
   1.189 +	for _, lang in ipairs(plangs) do
   1.190 +		self:makecurrent(profile, lang)
   1.191 +	end
   1.192 +	
   1.193 +	-- These arguments are overwritten globally and need to get restored
   1.194 +	
   1.195 +	local save_args = { self.args.lang, self.args.profile, self.args.session }
   1.196 +	
   1.197 +	-- For all languages, unroll site to static HTML
   1.198 +	
   1.199 +	for _, lang in ipairs(plangs) do
   1.200 +		local ext = (#plangs == 1 and ".html") or (".html." .. lang)
   1.201 +		self:recursesections(self.sections, function(self, s, e, path)
   1.202 +			path = path and path .. "/" .. e.name or e.name
   1.203 +			if not e.notvisible then
   1.204 +				Loona:dumphtml { requestpath = path, requestlang = lang,
   1.205 +					htmlext = ext, insecure = true }
   1.206 +			end
   1.207 +			return path
   1.208 +		end)
   1.209 +	end
   1.210 +	
   1.211 +	-- Restore arguments
   1.212 +	
   1.213 +	self.args.lang, self.args.profile, self.args.session = unpack(save_args)
   1.214 +	
   1.215 +	-- Update file cache
   1.216 +
   1.217 +	local htdocs = self.config.htdocsdir
   1.218 +	local cache = self.config.htmlcachedir
   1.219 +
   1.220 +	for e in util.readdir(cache) do
   1.221 +		local f = e:match("^.*%.html%.?(%w*)$")
   1.222 +		if f and f ~= "tmp" then
   1.223 +			local success, msg = remove(htdocs .. "/" .. e)
   1.224 +			success, msg = remove(cache .. "/" .. e)
   1.225 + 			assert(success,
   1.226 + 				self:dbmsg("Could not purge cached HTML file", msg))
   1.227 +		end
   1.228 +	end
   1.229 +	
   1.230 +	for e in util.readdir(cache) do
   1.231 +		local f = e:match("^(.*%.html%.?%w*)%.tmp$")
   1.232 +		if f then
   1.233 +			local success, msg = rename(cache .. "/" .. e, cache .. "/" .. f)
   1.234 +			assert(success,
   1.235 +				self:dbmsg("Could not update cached HTML file", msg))
   1.236 +			success, msg = rename(htdocs .. "/" .. e, htdocs .. "/" .. f)
   1.237 +			assert(success,
   1.238 +				self:dbmsg("Could not update cached HTML file", msg))
   1.239 +		end
   1.240 +	end
   1.241 +end
   1.242 +
   1.243 +
   1.244 +function Loona:recursesections(s, func, ...)
   1.245 +	for _, e in ipairs(s) do
   1.246 +		local udata = { func(self, s, e, unpack(arg)) }
   1.247 +		if e.subs then
   1.248 +			self:recursesections(e.subs, func, unpack(udata))
   1.249 +		end
   1.250 +	end
   1.251 +end
   1.252 +
   1.253 +
   1.254 +function Loona:indexsections()
   1.255 +	self:recursesections(self.sections, function(self, s, e)
   1.256 +		e.notvalid = (not self.secure and e.secure) or 
   1.257 +			(not self.authuser and e.secret) or nil
   1.258 +		e.notvisible = e.notvalid or not self.authuser and e.hidden or nil
   1.259 +		s[e.name] = e
   1.260 +	end)
   1.261 +end
   1.262 +
   1.263 +
   1.264 +--	Decompose section path into a stack of sections, returning only up to
   1.265 +--	the last valid element in the path. additionally returns the table of
   1.266 +--	the last section path element (or the default section)
   1.267 +
   1.268 +function Loona:getsection(path)
   1.269 +	local default = not self.authuser and self.config.defname
   1.270 +	local tab = { { entries = self.sections, name = default } }
   1.271 +	local ss = self.sections
   1.272 +	local sectionpath
   1.273 +	(path or ""):gsub("(%w+)/?", function(a)
   1.274 +		if ss then
   1.275 +			local s = ss[a]
   1.276 +			if s and not s.notvalid then
   1.277 +				sectionpath = s
   1.278 +				tab[#tab].name = a
   1.279 +				ss = s.subs
   1.280 +				if ss then
   1.281 +					table.insert(tab, { entries = ss })
   1.282 +				end
   1.283 +			else
   1.284 +				ss = nil -- stop.
   1.285 +			end
   1.286 +		end
   1.287 +	end)
   1.288 +	if not self.section and not sectionpath then
   1.289 +		sectionpath = self.sections[default]
   1.290 +		if sectionpath then
   1.291 +			table.insert(tab, { entries = sectionpath.subs })
   1.292 +		end
   1.293 +	end
   1.294 +	return tab, sectionpath
   1.295 +end
   1.296 +
   1.297 +
   1.298 +function Loona:getpath(delimiter, maxdepth)
   1.299 +	local t = { }
   1.300 +	local d = 0
   1.301 +	maxdepth = maxdepth or #self.submenus
   1.302 +	for _, menu in ipairs(self.submenus) do
   1.303 +		if menu.name then
   1.304 +			table.insert(t, menu.name)
   1.305 +		end
   1.306 +		d = d + 1
   1.307 +		if d == maxdepth then
   1.308 +			break
   1.309 +		end
   1.310 +	end
   1.311 +	return table.concat(t, delimiter or "/")
   1.312 +end
   1.313 +
   1.314 +
   1.315 +function Loona:deletesection(fname, all_bodies)
   1.316 +	local fullname = self.contentdir .. "/" .. fname
   1.317 +	local success, msg = remove(fullname)
   1.318 +	if all_bodies then
   1.319 +		local pat = "^" .. 
   1.320 +			fname:gsub("%^%$%(%)%%%.%[%]%*%+%-%?", "%%%1") .. "%..*$"
   1.321 +		for e in util.readdir(self.contentdir) do
   1.322 +			if e:match(pat) then
   1.323 +				remove(self.contentdir .. "/" .. e)
   1.324 +			end
   1.325 +		end
   1.326 +	end
   1.327 +	return success, msg
   1.328 +end
   1.329 +
   1.330 +
   1.331 +function Loona:addpath(path, e)
   1.332 +	local tab = self.sections
   1.333 +	path:gsub("(%w+)/?", function(a)
   1.334 +		if tab then
   1.335 +			local s = tab[a]
   1.336 +			if s then
   1.337 +				if not s.subs then
   1.338 +					s.subs = { }
   1.339 +				end
   1.340 +				tab = s.subs
   1.341 +			else
   1.342 +				table.insert(tab, e)
   1.343 +				tab[a] = e
   1.344 + 				tab = nil -- stop
   1.345 +			end
   1.346 +		end
   1.347 +	end)
   1.348 +	return e
   1.349 +end
   1.350 +
   1.351 +
   1.352 +local function lookupname(tab, val)
   1.353 +	for i, v in ipairs(tab) do
   1.354 +		if v.name == val then
   1.355 +			return i
   1.356 +		end
   1.357 +	end
   1.358 +end
   1.359 +
   1.360 +
   1.361 +function Loona:rmpath(path)
   1.362 +	local parent
   1.363 +	local tab = self.sections
   1.364 +	path:gsub("(%w+)/?", function(a)
   1.365 +		if tab then
   1.366 +			local idx = lookupname(tab, a)
   1.367 +			if idx then
   1.368 +				if tab[idx].subs then
   1.369 +					parent = tab[idx]
   1.370 +					tab = tab[idx].subs
   1.371 +				else
   1.372 +					table.remove(tab, idx)
   1.373 +					tab[a] = nil
   1.374 +					if #tab == 0 and parent then
   1.375 +						parent.subs = nil
   1.376 +					end
   1.377 +					tab = nil
   1.378 +				end
   1.379 +			end
   1.380 +		end
   1.381 +	end)
   1.382 +end
   1.383 +
   1.384 +
   1.385 +function Loona:checkpath(path)
   1.386 +	if path ~= "index" then -- "index" is reserved
   1.387 +		local res, idx
   1.388 +		local tab = self.sections
   1.389 +		path:gsub("(%w+)/?", function(a)
   1.390 +			if tab then
   1.391 +				local i = lookupname(tab, a)
   1.392 +				if i then
   1.393 +					res, idx = tab, i
   1.394 +					tab = tab[i].subs
   1.395 +				else
   1.396 +					res, idx = nil, nil
   1.397 +				end
   1.398 +			end
   1.399 +		end)
   1.400 +		return res, idx
   1.401 +	end
   1.402 +end
   1.403 +
   1.404 +
   1.405 +function Loona:title()
   1.406 +	return self.section and (self.section.title or self.section.label or
   1.407 +		self.section.name) or ""
   1.408 +end
   1.409 +
   1.410 +
   1.411 +--	Run a site function snippet, with full error recovery
   1.412 +--	(also recovers from errors in error handling function)
   1.413 +
   1.414 +function Loona:dosnippet(func, errfunc)
   1.415 +	local ret = { lib.catch(func) }
   1.416 +	if ret[1] == 0 or (errfunc and lib.catch(errfunc) == 0) then
   1.417 +		return unpack(ret)
   1.418 +	end
   1.419 +	self:out("<h2>Error</h2>")
   1.420 +	self:out("<h3>" .. self:encodeform(ret[2] or "") .. "</h3>")
   1.421 +	if self.authuser then
   1.422 +		if type(ret[3]) == "string" then
   1.423 +			self:out("<p>" .. self:encodeform(ret[3]) .. "</p>")
   1.424 +		end
   1.425 +		if ret[4] then
   1.426 +			self:out("<pre>" .. self:encodeform(ret[4]) .. "</pre>")
   1.427 +		end
   1.428 +	end
   1.429 +end	
   1.430 +
   1.431 +
   1.432 +function Loona:lockfile(file)
   1.433 +	return not self.session and true or 
   1.434 +		posix.symlink(self.session.filename, file .. ".LOCK")
   1.435 +end
   1.436 +
   1.437 +
   1.438 +function Loona:unlockfile(file)
   1.439 +	return not self.session and true or remove(file .. ".LOCK")
   1.440 +end
   1.441 +
   1.442 +
   1.443 +function Loona:saveindex()
   1.444 +	local tempname = self.indexfname .. "." .. self.session.name
   1.445 +	local f, msg = open(tempname, "wb")
   1.446 +	assert(f, self:dbmsg("Error opening section file for writing", msg))
   1.447 +	lib.dump(self.sections, function(...)
   1.448 +		f:write(unpack(arg))
   1.449 +	end)
   1.450 +	f:close()
   1.451 +	local success, msg = rename(tempname, self.indexfname)
   1.452 +	assert(success, self:dbmsg("Error renaming section file", msg))
   1.453 +end
   1.454 +
   1.455 +
   1.456 +function Loona:savebody(fname, content)
   1.457 +	fname = self.contentdir .. "/" .. fname
   1.458 +	local f, msg = open(fname, "wb")
   1.459 +	assert(f, self:dbmsg("Could not open file for writing", msg))
   1.460 +	f:write(content or "")
   1.461 +	f:close()
   1.462 +end
   1.463 +
   1.464 +
   1.465 +function Loona:runboxed(func, envitems, ...)
   1.466 +	local fenv = {
   1.467 + 		arg = arg,
   1.468 + 		loona = self
   1.469 + 	}
   1.470 + 	if envitems then
   1.471 +	 	for k, v in pairs(envitems) do
   1.472 + 			fenv[k] = v
   1.473 + 		end
   1.474 + 	end
   1.475 + 	setmetatable(fenv, { __index = boxed_G }) -- boxed global environment
   1.476 +	setfenv(func, fenv)
   1.477 +	return func()
   1.478 +end
   1.479 +
   1.480 +
   1.481 +function Loona:include(fname, ...)
   1.482 +	assert(not fname:match("%W"), self:dbmsg("Invalid include name", fname))
   1.483 +	local fname2 = ("%s/%s.lua"):format(self.config.extdir, fname)
   1.484 +	local f, msg = open(fname2)
   1.485 +	assert(f, self:dbmsg("Cannot open file", msg))
   1.486 +	local parsed, msg = self:loadhtml(f, "loona:out", fname2)
   1.487 +	assert(parsed, self:dbmsg("Syntax error", msg))
   1.488 +	return self:runboxed(parsed, nil, unpack(arg))
   1.489 +end
   1.490 +
   1.491 +
   1.492 +--	produce link target (simple)
   1.493 +
   1.494 +function Loona:shref(section, arg)
   1.495 +	local args2 = { } -- propagated or new arguments
   1.496 +	for _, a in ipairs(arg) do
   1.497 +		local key, val = a:match("^(%w+)=(.*)$")
   1.498 +		if key and val then -- "arg=val" sets/overrides argument
   1.499 +			table.insert(args2, { name = key, value = val })
   1.500 +		elseif self.args[a] then -- just "arg" propagates argument
   1.501 +			table.insert(args2, { name = a, value = self.args[a] })
   1.502 +		end
   1.503 +	end
   1.504 +	local doc = self:getdocname(section, #args2 > 0)
   1.505 +	local url, anch = doc:match("^(.+)(#.+)$")
   1.506 +	local notfirst = doc:match("%?")
   1.507 +	local href = { anch and url or doc }
   1.508 +	for i, arg in ipairs(args2) do
   1.509 +		if i > 1 or notfirst then
   1.510 +			table.insert(href, "&amp;")
   1.511 +		else
   1.512 +			table.insert(href, "?")
   1.513 +		end
   1.514 +		table.insert(href, arg.name .. "=" .. http.encodeurl(arg.value))
   1.515 +	end
   1.516 +	if anch then
   1.517 +		insert(href, anch)
   1.518 +	end
   1.519 +	return table.concat(href)
   1.520 +end
   1.521 +
   1.522 +
   1.523 +--	produce link target, implicit propagation of lang, profile, session
   1.524 +
   1.525 +function Loona:href(section, ...)
   1.526 +	if self.session then
   1.527 +		table.insert(arg, 1, "profile")
   1.528 +		table.insert(arg, 1, "session")
   1.529 +	end
   1.530 +	if self.explicitlang then
   1.531 +		table.insert(arg, 1, "lang")
   1.532 +	end
   1.533 +	return self:shref(section, arg)
   1.534 +end
   1.535 +
   1.536 +
   1.537 +function Loona:ilink(target, text, extra)
   1.538 +	return ('<a href="%s"%s>%s</a>'):format(target, extra or "", text)
   1.539 +end
   1.540 +
   1.541 +
   1.542 +--	internal link, implicit propagation of lang, profile, session
   1.543 +
   1.544 +function Loona:link(section, text, ...)
   1.545 +	return self:ilink(self:href(section, unpack(arg)), text or section,
   1.546 +	' class="intlink"')
   1.547 +end
   1.548 +
   1.549 +
   1.550 +--	external link (opens in a new window), no argument propagation
   1.551 +
   1.552 +function Loona:elink(target, text)
   1.553 +	return self:ilink(target, text or target, self.config.extlinkextra)
   1.554 +end
   1.555 +
   1.556 +
   1.557 +--	plain link, no implicit argument propagation
   1.558 +
   1.559 +function Loona:plink(section, text, ...)
   1.560 +	return self:ilink(self:shref(section, arg), text or section)
   1.561 +end
   1.562 +
   1.563 +
   1.564 +--	user interface link, implicit propagation of lang, profile, session
   1.565 +
   1.566 +function Loona:uilink(section, text, ...)
   1.567 +	return self:ilink(self:href(section, unpack(arg)), text or section,
   1.568 +	' class="uilink"')
   1.569 +end
   1.570 +
   1.571 +
   1.572 +--	produce a hidden input value in forms
   1.573 +
   1.574 +function Loona:hidden(name, value)
   1.575 +	return not value and "" or
   1.576 +		('<input type="hidden" name="%s" value="%s" />'):format(name, value)
   1.577 +end
   1.578 +
   1.579 +
   1.580 +function Loona:scanprofiles(func)
   1.581 +	local tab = { }
   1.582 +	local dir = self.config.contentdir
   1.583 +	for f in util.readdir(dir) do
   1.584 +		if posix.lstat(dir .. "/" .. f, "mode") == "directory" then
   1.585 +			f = func(f)
   1.586 +			if f then
   1.587 +				table.insert(tab, f)
   1.588 +			end
   1.589 +		end
   1.590 +	end
   1.591 +	table.sort(tab)
   1.592 +	for _, v in ipairs(tab) do
   1.593 +		tab[v] = v	
   1.594 +	end
   1.595 +	return tab
   1.596 +end
   1.597 +
   1.598 +
   1.599 +function Loona:getprofiles(lang)
   1.600 +	lang = lang or self.lang
   1.601 +	return self:scanprofiles(function(f)
   1.602 +		return f:match("^(%w+)_" .. lang .. "$")
   1.603 +	end)
   1.604 +end
   1.605 +
   1.606 +
   1.607 +function Loona:getlanguages(prof)
   1.608 +	prof = prof or self.profile
   1.609 +	return self:scanprofiles(function(f)
   1.610 +		return f:match("^" .. prof .. "_(%l%l)$")
   1.611 +	end)
   1.612 +end
   1.613 +
   1.614 +
   1.615 +--	Functions to produce a navigation menu
   1.616 +
   1.617 +local newent = { name = "new", label = "[+]", action="actionnew=true" }
   1.618 +
   1.619 +function Loona:rmenu(level, render, path, addnew, recurse)
   1.620 +	local sub = (addnew and level == #self.submenus + 1) and
   1.621 +		{ name = "new", entries = { }} or self.submenus[level]
   1.622 + 	if sub and sub.entries then
   1.623 +		local visible = { }
   1.624 +		for _, e in ipairs(sub.entries) do
   1.625 +			if not e.notvisible then
   1.626 +				table.insert(visible, e)
   1.627 +			end
   1.628 +		end
   1.629 +		if addnew then
   1.630 +			table.insert(visible, newent)
   1.631 +		end
   1.632 +		local numvis = #visible
   1.633 +		if numvis > 0 then
   1.634 +			render.listbegin(self, level, numvis, path)
   1.635 +			for idx, e in ipairs(visible) do
   1.636 +				local label = self:encodeform(e.label or e.name)
   1.637 +				local newpath = path and path .. "/" .. e.name or e.name
   1.638 +				local active = (e.name == sub.name)
   1.639 +				render.itembegin(self, level, idx)
   1.640 +				render.link(self, level, newpath, label, active, e.action)
   1.641 +				if recurse and active then
   1.642 +					self:rmenu(level + 1, render, newpath, addnew, recurse)
   1.643 +				end
   1.644 +				render.itemend(self)
   1.645 +			end
   1.646 +			render.listend(self)
   1.647 +		end
   1.648 +	end
   1.649 +end
   1.650 +
   1.651 +
   1.652 +function Loona:menu(level, recurse, render)
   1.653 +	level = level or 1
   1.654 +	render = render or { }
   1.655 +	render.link = render.link or 
   1.656 +		function(self, level, path, label, active, ...)
   1.657 +			self:out(('<a %shref="%s">%s</a>\n'):format(active and 
   1.658 +				'class="active" ' or "", self:href(path, unpack(arg)), label))
   1.659 +		end
   1.660 +	render.listbegin = render.listbegin or
   1.661 +		function(self, level) -- , numvis, path
   1.662 +			self:out('<ul id="menulevel' .. level .. '">\n')
   1.663 +		end
   1.664 +	render.listend = render.listend or
   1.665 +		function(self)
   1.666 +			self:out('</ul>\n')
   1.667 +		end
   1.668 +	render.itembegin = render.itembegin or
   1.669 +		function(self) -- , level, idx
   1.670 +			self:out('<li>\n')
   1.671 +		end
   1.672 +	render.itemend = render.itemend or
   1.673 +		function(self)
   1.674 +			self:out('</li>\n')
   1.675 +		end
   1.676 +	recurse = recurse == nil and true or recurse
   1.677 +	local path = level > 1 and self:getpath("/", level - 1) or nil
   1.678 +	local addnew = self.authuser and not self.ispubprofile
   1.679 +	self:rmenu(level, render, path, addnew, recurse)
   1.680 +end
   1.681 +
   1.682 +
   1.683 +function Loona:loadcontent(fname)
   1.684 +	if fname then
   1.685 +		local f = open(self.contentdir .. "/" .. fname)
   1.686 +		local c = f:read("*a")
   1.687 +		f:close()
   1.688 +		return c
   1.689 +	end
   1.690 +	return ""
   1.691 +end
   1.692 +
   1.693 +
   1.694 +function Loona:loadmarkup(fname)
   1.695 +	return (fname and fname ~= "") and
   1.696 +		self:domarkup(self:loadcontent(fname)) or ""
   1.697 +end
   1.698 +
   1.699 +
   1.700 +function Loona:editable(editkey, fname, savename)
   1.701 +	
   1.702 +	local contentdir = self.contentdir
   1.703 +	local edit, show, hidden, extramsg, changed
   1.704 +	
   1.705 +	if self.authuser then
   1.706 +		
   1.707 +		local hiddenvars = table.concat( {
   1.708 +			self:hidden("lang", self.args.lang),
   1.709 +			self:hidden("profile", self.profile),
   1.710 +			self:hidden("session", self.session.id),
   1.711 +			self:hidden("editkey", editkey) }, " ")
   1.712 +	
   1.713 +		local lockfname = fname and (contentdir .. "/" .. fname)
   1.714 +		
   1.715 +		if self.useralert and editkey == self.args.editkey then
   1.716 +			
   1.717 +			--	display user alert/request/confirmation
   1.718 +			
   1.719 +			hidden = true
   1.720 +			self:out([[
   1.721 +			<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   1.722 +				<fieldset>
   1.723 +					<legend>]] .. self.useralert.text ..[[</legend>
   1.724 +					]] .. (self.useralert.confirm or "") .. [[
   1.725 +					]] .. (self.useralert.returnto or "") .. [[
   1.726 +					<input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL  ..[[" />
   1.727 +					]] .. hiddenvars .. [[
   1.728 +				</fieldset>
   1.729 +			</form>
   1.730 +			]])
   1.731 +			
   1.732 +		elseif self.args.actionnew and editkey == "main" then
   1.733 +			
   1.734 +			--	form for creating a new section
   1.735 +			
   1.736 +			hidden = true
   1.737 +			if self.ispubprofile then
   1.738 +				self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
   1.739 +			end
   1.740 +			self:out([[
   1.741 +			<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   1.742 +				<fieldset>
   1.743 +					<legend>
   1.744 +						]] .. self.locale.CREATE_NEW_SECTION_UNDER .. " " .. self.sectionpath .. [[
   1.745 +					</legend>
   1.746 +					<table>
   1.747 +						<tr>
   1.748 +							<td align="right">
   1.749 +								]] .. self.locale.PATHNAME .. [[
   1.750 +							</td>
   1.751 +							<td>
   1.752 +								<input size="30" maxlength="30" name="editname" />
   1.753 +							</td>
   1.754 +						</tr>
   1.755 +						<tr>
   1.756 +							<td align="right">
   1.757 +								]] .. self.locale.MENULABEL .. [[
   1.758 +							</td>
   1.759 +							<td>
   1.760 +								<input size="30" maxlength="50" name="editlabel" />
   1.761 +							</td>
   1.762 +						</tr>
   1.763 +						<tr>
   1.764 +							<td align="right">
   1.765 +								]] .. self.locale.WINDOWTITLE .. [[
   1.766 +							</td>
   1.767 +							<td>
   1.768 +								<input size="30" maxlength="50" name="edittitle" />
   1.769 +							</td>
   1.770 +						</tr>
   1.771 +						<tr>
   1.772 +							<td align="right">
   1.773 +								]] .. self.locale.INVISIBLE .. [[
   1.774 +							</td>
   1.775 +							<td>
   1.776 +								<input type="checkbox" name="editvisibility" />
   1.777 +							</td>
   1.778 +						</tr>
   1.779 +						<tr>
   1.780 +							<td align="right">
   1.781 +								]] .. self.locale.SECRET .. [[
   1.782 +							</td>
   1.783 +							<td>
   1.784 +								<input type="checkbox" name="editsecrecy" />
   1.785 +							</td>
   1.786 +						</tr>
   1.787 +						<tr>
   1.788 +							<td align="right">
   1.789 +								]] .. self.locale.SECURE_CONNECTION .. [[
   1.790 +							</td>
   1.791 +							<td>
   1.792 +								<input type="checkbox" name="editsecure" />
   1.793 +							</td>
   1.794 +						</tr>
   1.795 +						<tr>
   1.796 +							<td align="right">
   1.797 +								]] .. self.locale.REDIRECT .. [[
   1.798 +							</td>
   1.799 +							<td>
   1.800 +								<input size="30" maxlength="50" name="editredirect" />
   1.801 +							</td>
   1.802 +						</tr>
   1.803 +					</table>					
   1.804 +					<input type="submit" name="actioncreate" value="]] .. self.locale.CREATE .. [[" />
   1.805 +					]] .. hiddenvars .. [[
   1.806 +				</fieldset>
   1.807 +			</form>
   1.808 +			<hr />
   1.809 +			]])
   1.810 +		
   1.811 +		elseif self.args.actioneditprops and editkey == "main" then
   1.812 +			hidden = true
   1.813 +			if self.ispubprofile then
   1.814 +				self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
   1.815 +			end
   1.816 +			self:out([[
   1.817 +			<form action="]] ..self.document .. [[" method="post" accept-charset="utf-8">
   1.818 +				<fieldset>
   1.819 +					<legend>
   1.820 +						]] .. self.locale.MODIFY_PROPERTIES_OF_SECTION .. " " .. self.sectionpath .. [[
   1.821 +					</legend>
   1.822 +					<table>
   1.823 +						<tr>
   1.824 +							<td align="right">
   1.825 +								]] .. self.locale.MENULABEL .. [[
   1.826 +							</td>
   1.827 +							<td>
   1.828 +								<input size="30" maxlength="50" name="editlabel" value="]] .. (self.section.label or "") .. [[" />
   1.829 +							</td>
   1.830 +						</tr>
   1.831 +						<tr>
   1.832 +							<td align="right">
   1.833 +								]] .. self.locale.WINDOWTITLE .. [[
   1.834 +							</td>
   1.835 +							<td>
   1.836 +								<input size="30" maxlength="50" name="edittitle" value="]] .. (self.section.title or "") .. [[" />
   1.837 +							</td>
   1.838 +						</tr>
   1.839 +						<tr>
   1.840 +							<td align="right">
   1.841 +								]] .. self.locale.INVISIBLE .. [[
   1.842 +							</td>
   1.843 +							<td>
   1.844 +								<input type="checkbox" name="editvisibility" ]] .. (self.section.hidden and 'checked="checked"' or "") .. [[/>
   1.845 +							</td>
   1.846 +						</tr>
   1.847 +						<tr>
   1.848 +							<td align="right">
   1.849 +								]] .. self.locale.SECRET .. [[
   1.850 +							</td>
   1.851 +							<td>
   1.852 +								<input type="checkbox" name="editsecrecy" ]] .. (self.section.secret and 'checked="checked"' or "") .. [[/>
   1.853 +							</td>
   1.854 +						</tr>
   1.855 +						<tr>
   1.856 +							<td align="right">
   1.857 +								]] .. self.locale.SECURE_CONNECTION .. [[
   1.858 +							</td>
   1.859 +							<td>
   1.860 +								<input type="checkbox" name="editsecure" ]] .. (self.section.secure and 'checked="checked"' or "") .. [[/>
   1.861 +							</td>
   1.862 +						</tr>
   1.863 +						<tr>
   1.864 +							<td align="right">
   1.865 +								]] .. self.locale.REDIRECT .. [[
   1.866 +							</td>
   1.867 +							<td>
   1.868 +								<input size="30" maxlength="50" name="editredirect" value="]] .. (self.section.redirect or "") .. [[" />
   1.869 +							</td>
   1.870 +						</tr>
   1.871 +					</table>
   1.872 +					<input type="submit" name="actionsaveprops" value="]] .. self.locale.SAVE .. [[" />
   1.873 +					<input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
   1.874 +					]] .. hiddenvars .. [[
   1.875 +				</fieldset>
   1.876 +			</form>
   1.877 +			]])
   1.878 +		
   1.879 +		elseif (self.args.actioneditprofiles or
   1.880 +			self.args.actioncreateprofile or 
   1.881 +			self.args.actionchangeprofile or 
   1.882 +			self.args.actionchangelanguage or
   1.883 +			self.args.actionpublishprofile) and editkey == "main" then
   1.884 +			hidden = true
   1.885 +			self:out([[
   1.886 +			<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   1.887 +				<fieldset>
   1.888 +					<legend>
   1.889 +						]] .. self.locale.CHANGEPROFILE .. [[
   1.890 +					</legend>
   1.891 +					<select name="changeprofile" size="1">]])
   1.892 +						for _, val in ipairs(self:getprofiles()) do
   1.893 +							self:out('<option' .. (val == self.profile and ' selected="selected"' or '') .. '>')
   1.894 +							self:out(val)
   1.895 +							self:out('</option>')
   1.896 +						end
   1.897 +					self:out([[
   1.898 +					</select>							
   1.899 +					<input type="submit" name="actionchangeprofile" value="]] .. self.locale.CHANGE ..[[" />
   1.900 +					]] .. hiddenvars .. [[
   1.901 +				</fieldset>
   1.902 +			</form>
   1.903 +			<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   1.904 +				<fieldset>
   1.905 +					<legend>
   1.906 +						]] .. self.locale.CHANGELANGUAGE .. [[
   1.907 +					</legend>
   1.908 +					<select name="changelanguage" size="1">]])
   1.909 +						for _, val in ipairs(self:getlanguages()) do
   1.910 +							self:out('<option' .. (val == self.lang and ' selected="selected"' or '') .. '>')
   1.911 +							self:out(val)
   1.912 +							self:out('</option>')
   1.913 +						end
   1.914 +					self:out([[
   1.915 +					</select>							
   1.916 +					<input type="submit" name="actionchangelanguage" value="]] .. self.locale.CHANGE ..[[" />
   1.917 +					]] .. hiddenvars .. [[
   1.918 +				</fieldset>
   1.919 +			</form>
   1.920 +			<form action="]] .. self.document ..[[" method="post" accept-charset="utf-8">
   1.921 +				<fieldset>
   1.922 +					<legend>
   1.923 +						]] .. self.locale.CREATEPROFILE .. [[
   1.924 +					</legend>
   1.925 +					<input size="20" maxlength="20" name="createprofile" />
   1.926 +					]] .. self.locale.LANGUAGE ..[[
   1.927 +					<input size="2" maxlength="2" name="createlanguage" value="]] .. self.lang ..[[" />
   1.928 +					<input type="submit" name="actioncreateprofile" value="]] .. self.locale.CREATE .. [[" />
   1.929 +					]] .. hiddenvars .. [[
   1.930 +				</fieldset>
   1.931 +			</form>
   1.932 +			]])
   1.933 +			if not self.ispubprofile then
   1.934 +				self:out([[
   1.935 +				<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   1.936 +					<fieldset>
   1.937 +						<legend>
   1.938 +							]] .. self.locale.PUBLISHPROFILE .. [[
   1.939 +						</legend>
   1.940 +						]] .. self:hidden("publishprofile", self.profile) .. [[
   1.941 +						<input type="submit" name="actionpublishprofile" value="]] .. self.locale.PUBLISH .. [[" />
   1.942 +						]] .. hiddenvars .. [[
   1.943 +					</fieldset>
   1.944 +				</form>
   1.945 +				<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   1.946 +					<fieldset>
   1.947 +						<legend>
   1.948 +							]] .. self.locale.DELETEPROFILE .. [[
   1.949 +						</legend>
   1.950 +						]] .. self:hidden("deleteprofile", self.profile) .. [[
   1.951 +						<input type="submit" name="actiondeleteprofile" value="]] .. self.locale.DELETE .. [[" />
   1.952 +						]] .. hiddenvars .. [[
   1.953 +					</fieldset>
   1.954 +				</form>
   1.955 +				]])
   1.956 +			end
   1.957 +			
   1.958 +		elseif self.args.actionedit and editkey == self.args.editkey then
   1.959 +			if not self.section.redirect then
   1.960 +				extramsg = self.ispubprofile and
   1.961 +					self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE
   1.962 +				edit = self:loadcontent(fname):gsub("\194\160", "&nbsp;") -- TODO
   1.963 +				changed = self.section and (self.section.revisiondate or self.section.creationdate)
   1.964 +			end
   1.965 +		
   1.966 +		elseif self.args.actionpreview and editkey == self.args.editkey then
   1.967 +			edit = self.args.editform
   1.968 +			show = self:domarkup(edit:gsub("&nbsp;", "\194\160")) -- TODO
   1.969 +		
   1.970 +		elseif self.args.actionsave and editkey == self.args.editkey then
   1.971 +			local c = self.args.editform
   1.972 +			local dynamic
   1.973 +			
   1.974 +			if lockfname then
   1.975 +				self:expire(contentdir, "[^.]%S+.LOCK")
   1.976 +				if self:lockfile(lockfname) then
   1.977 +					-- lock was expired, aquired a new one
   1.978 +					extramsg = self.locale.SECTION_COULD_HAVE_CHANGED
   1.979 +					edit = c
   1.980 +				else
   1.981 +					local tab = lib.source(lockfname .. ".LOCK")
   1.982 +					if tab and tab.id == self.session.id then
   1.983 +						-- lock already held and is mine - try to save:
   1.984 +						local savec = c:gsub("&nbsp;", "\194\160") -- TODO
   1.985 +						remove(contentdir .. "/" .. savename .. ".html")
   1.986 +						self:savebody(savename, savec)
   1.987 +						-- TODO: error handling
   1.988 +						self:unlockfile(lockfname)
   1.989 +						show, dynamic = self:domarkup(savec)
   1.990 +						changed = time()
   1.991 +					else
   1.992 +						-- lock was expired and someone else has it now
   1.993 +						extramsg = self.locale.SECTION_IN_USE
   1.994 +						edit = c
   1.995 +					end
   1.996 +				end
   1.997 +			else
   1.998 +				-- new sidefile
   1.999 +				local savec = c:gsub("&nbsp;", "\194\160") -- TODO
  1.1000 +				self:savebody(savename, savec)
  1.1001 +				-- TODO: error handling
  1.1002 +				show, dynamic = self:domarkup(savec)
  1.1003 +			end
  1.1004 +			
  1.1005 +			-- mark dynamic text bodies
  1.1006 +			if not self.section.dynamic then
  1.1007 +				self.section.dynamic = { }
  1.1008 +			end
  1.1009 +			self.section.dynamic[editkey] = dynamic
  1.1010 +			local n = 0
  1.1011 +			for _ in pairs(self.section.dynamic) do
  1.1012 +				n = n + 1
  1.1013 +			end
  1.1014 +			if n == 0 then
  1.1015 +				self.section.dynamic = nil
  1.1016 +			end
  1.1017 +			
  1.1018 +			self:saveindex()
  1.1019 +			
  1.1020 +		elseif self.args.actioncancel and editkey == self.args.editkey then
  1.1021 +			if lockfname then
  1.1022 +				self:unlockfile(lockfname) -- remove lock
  1.1023 +			end
  1.1024 +		end
  1.1025 +		
  1.1026 +		if editkey == "main" and self.section and self.section.redirect then
  1.1027 +			self:out('<h2>' .. self.locale.SECTION_IS_REDIRECT ..'</h2>')
  1.1028 +			self:out(self:link(self.section.redirect))
  1.1029 +			self:out('<hr />')
  1.1030 +		end
  1.1031 +	
  1.1032 +		if edit then
  1.1033 +			self:expire(contentdir, "[^.]%S+.LOCK")
  1.1034 +			if fname and not self:lockfile(contentdir .. "/" .. fname) then
  1.1035 +				local tab = lib.source(contentdir .. "/" .. fname .. ".LOCK")
  1.1036 +				if tab and tab.id ~= self.session.id then
  1.1037 +					extramsg = self.locale.SECTION_IN_USE
  1.1038 +				end
  1.1039 +				-- else already owner
  1.1040 +			end
  1.1041 +			if extramsg then
  1.1042 +				self:out('<h2><span class="warn">' .. extramsg .. '</span></h2>')
  1.1043 +			end
  1.1044 +			self:out([[
  1.1045 +			<form action="]] .. self.document .. [[#preview" method="post" accept-charset="utf-8">
  1.1046 +				<fieldset>
  1.1047 +					<legend>
  1.1048 +						]] .. self.locale.EDIT_SECTION .. [[
  1.1049 +					</legend>
  1.1050 +					<textarea cols="80" rows="25" name="editform">
  1.1051 +]] .. self:encodeform(edit) .. [[</textarea>
  1.1052 +					<br />
  1.1053 +					<input type="submit" name="actionsave" value="]] .. self.locale.SAVE .. [[" />
  1.1054 +					<input type="submit" name="actionpreview" value="]] .. self.locale.PREVIEW .. [[" />
  1.1055 +					<input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
  1.1056 +					]] .. hiddenvars .. [[
  1.1057 +				</fieldset>
  1.1058 +			</form>
  1.1059 +			]])
  1.1060 +		end
  1.1061 +	end	
  1.1062 +	
  1.1063 +	if not hidden then
  1.1064 +		self:dosnippet(function()
  1.1065 +			if not show then
  1.1066 +				show = self:loadmarkup(fname)
  1.1067 +				changed = self.section and (self.section.revisiondate or self.section.creationdate)
  1.1068 +			end
  1.1069 +			local parsed, msg = self:loadhtml(show, "loona:out", "<parsed html>")
  1.1070 +			assert(parsed, msg and "Syntax error : " .. msg)
  1.1071 +			self:runboxed(parsed)
  1.1072 +		end)
  1.1073 +	end
  1.1074 +	
  1.1075 +	if self.authuser then
  1.1076 +		self:out([[
  1.1077 +		<hr />
  1.1078 +		<div class="edit">]])
  1.1079 +			if editkey == "main" then
  1.1080 +				self:out([[
  1.1081 +				<a name="preview"></a>
  1.1082 +				]] .. self.authuser .. [[ : 
  1.1083 +				]] .. self:uilink(self.sectionpath, "[" .. self.locale.PROFILE .. "]", "actioneditprofiles=true", "editkey=" .. editkey) .. [[ :
  1.1084 +				]] .. self.profile .. "_" .. self.lang)
  1.1085 +				if self.ispubprofile then
  1.1086 +					self:out([[
  1.1087 +						<span class="warn">[]] .. self.locale.PUBLIC .. [[]</span>]])
  1.1088 +				end
  1.1089 +				self:out(' : ' .. self.sectionpath .. ' ')
  1.1090 +			end
  1.1091 +			if self.section and not self.ispubprofile then
  1.1092 +				self:out(self:uilink(self.sectionpath, "[" .. self.locale.EDIT .. "]", "actionedit=true", "editkey=" .. editkey) .. " ")
  1.1093 +				if editkey == "main" then
  1.1094 +					self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.PROPERTIES .. "]", "actioneditprops=true", "editkey=" .. editkey) .. " ")
  1.1095 +				end
  1.1096 +				if fname == savename or not self.section.subs then
  1.1097 +					self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.DELETE .. "]", "actiondelete=true", "editkey=" .. editkey) .. " ")
  1.1098 +				end
  1.1099 +				if editkey == "main" then
  1.1100 +					self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEUP .. "]", "actionup=true", "editkey=" .. editkey) .. " ")
  1.1101 +					self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEDOWN .. "]", "actiondown=true", "editkey=" .. editkey) .. " ")
  1.1102 +				end
  1.1103 +				if changed and editkey == "main" then
  1.1104 +					self:out('- ' .. self.locale.CHANGED .. ': ' .. date("%d-%b-%Y %T", changed))
  1.1105 +				end
  1.1106 +			end
  1.1107 +		self:out('</div>')
  1.1108 +	end
  1.1109 +
  1.1110 +end
  1.1111 +
  1.1112 +
  1.1113 +--	Get pathname of an existing content file that
  1.1114 +--	the current path is determined by (or defaults to)
  1.1115 +
  1.1116 +function Loona:getsectionpath(bodyname, requestpath)
  1.1117 +	local ext = (not bodyname or bodyname == "main") and "" or "." .. bodyname
  1.1118 +	local t, path, section = { }
  1.1119 +	for _, menu in ipairs(self.submenus) do
  1.1120 +		if menu.entries and menu.entries[menu.name] then
  1.1121 +			table.insert(t, menu.name)
  1.1122 +			local fn = table.concat(t, "_")
  1.1123 +			if posix.stat(self.contentdir .. "/" .. fn .. ext, 
  1.1124 +				"mode") == "file" then
  1.1125 +				path, section = fn, menu
  1.1126 +			end
  1.1127 +		end
  1.1128 +	end
  1.1129 +	return path, ext, section
  1.1130 +end
  1.1131 +
  1.1132 +
  1.1133 +function Loona:body(name)
  1.1134 +	name = self:checkbodyname(name)
  1.1135 +	local path, ext = self:getsectionpath(name)
  1.1136 +	self:dosnippet(function()
  1.1137 +		self:editable(name, path and path .. ext, self.sectionname .. ext)
  1.1138 +	end)
  1.1139 +end
  1.1140 +
  1.1141 +
  1.1142 +function Loona:init()
  1.1143 +	
  1.1144 +	-- get list of languages, in order of preference
  1.1145 +	-- TODO: respect quality parameter, not just order
  1.1146 +	
  1.1147 +	local l = self.requestlang or self.args.lang
  1.1148 +	self.langs = { l and l:match("^%w+$") }
  1.1149 +	local s = getenv("HTTP_ACCEPT_LANGUAGE")
  1.1150 +	while s do
  1.1151 +		local l, r = s:match("^([%w.=]+)[,;](.*)$")
  1.1152 +		l = l or s
  1.1153 +		s = r
  1.1154 +		if l:match("^%w+$") then
  1.1155 +			table.insert(self.langs, l)
  1.1156 +		end
  1.1157 +	end
  1.1158 +	table.insert(self.langs, self.config.deflang)
  1.1159 +	
  1.1160 +	-- get list of possible profiles
  1.1161 +	
  1.1162 +	local profiles = { }
  1.1163 +	for e in util.readdir(self.config.contentdir) do
  1.1164 +		profiles[e] = e
  1.1165 +	end
  1.1166 +	
  1.1167 +	-- get pubprofile
  1.1168 +	
  1.1169 +	for _, lang in ipairs(self.langs) do
  1.1170 +		local p = posix.readlink(self.config.contentdir .. "/current_" .. lang)
  1.1171 +		p = p and p:match("^(%w+)_" .. lang .. "$")
  1.1172 +		if p then
  1.1173 +			self.pubprofile = p
  1.1174 +			break
  1.1175 +		end
  1.1176 +	end
  1.1177 +	
  1.1178 +	-- get profile
  1.1179 +	
  1.1180 +	local checkprofile =
  1.1181 +		self.authuser and self.args.profile or self.pubprofile or "work"
  1.1182 +	for _, lang in ipairs(self.langs) do
  1.1183 +		if profiles[checkprofile .. "_" .. lang] then
  1.1184 +			self.profile = checkprofile
  1.1185 +			self.lang = lang
  1.1186 +			break
  1.1187 +		end
  1.1188 +	end
  1.1189 +	
  1.1190 +	assert(self.profile and self.lang, "Invalid profile or language")
  1.1191 +	
  1.1192 +	
  1.1193 +	self.ispubprofile = self.profile == self.pubprofile
  1.1194 +	
  1.1195 +	-- write back language and profile
  1.1196 +	
  1.1197 +	self.args.lang = (self.explicitlang or self.lang ~= self.config.deflang)
  1.1198 +		and self.lang or nil
  1.1199 +	self.args.profile = self.profile
  1.1200 +	
  1.1201 +	-- determine content directory pathname and section filename
  1.1202 +	
  1.1203 +	self.contentdir =
  1.1204 +		("%s/%s_%s"):format(self.config.contentdir, self.profile, self.lang)
  1.1205 + 	self.indexfname = self.contentdir .. "/.sections"
  1.1206 +	
  1.1207 +	-- load sections
  1.1208 +	
  1.1209 + 	self.sections = lib.source(self.indexfname)
  1.1210 +	
  1.1211 +	-- index sections, determine visibility in menu
  1.1212 +	
  1.1213 +	self:indexsections()
  1.1214 +	
  1.1215 +	-- decompose request path, produce a stack of sections
  1.1216 +	
  1.1217 +	self.submenus, self.section = self:getsection(self.requestpath)
  1.1218 +
  1.1219 +	-- handle redirects if not logged on
  1.1220 +	
  1.1221 +	if not self.authuser and self.section and self.section.redirect then
  1.1222 +		self.submenus, self.section = self:getsection(self.section.redirect)
  1.1223 +	end
  1.1224 +			
  1.1225 +	-- section path and document name (refined)
  1.1226 +	
  1.1227 +	self.sectionpath = self:getpath()
  1.1228 +	self.sectionname = self:getpath("_")
  1.1229 +
  1.1230 +end
  1.1231 +
  1.1232 +
  1.1233 +function Loona:handlechanges()
  1.1234 +	
  1.1235 +	local save
  1.1236 +
  1.1237 +	if self.args.editkey == "main" then
  1.1238 +		
  1.1239 +		-- In main editable section:
  1.1240 +		
  1.1241 +		if self.args.actioncreate then
  1.1242 +			
  1.1243 +			-- Create new section
  1.1244 +			
  1.1245 +			local editname = self.args.editname:lower()
  1.1246 +			assert(not editname:match("%W"),
  1.1247 +				self:dbmsg("Invalid section name", editname))
  1.1248 +			if not (section and (section.subs or section)[editname]) then
  1.1249 +				local newpath = (self.sectionpath and 
  1.1250 +					(self.sectionpath .. "/")) .. editname
  1.1251 +				local s = self:addpath(newpath, { name = editname,
  1.1252 +					label = self.args.editlabel ~= "" and
  1.1253 +						self.args.editlabel or nil,
  1.1254 +					title = self.args.edittitle ~= "" and
  1.1255 +						self.args.edittitle or nil,
  1.1256 +					redirect = self.args.editredirect ~= "" and
  1.1257 +						self.args.editredirect or nil,
  1.1258 +					hidden = self.args.editvisibility and true,
  1.1259 +					secret = self.args.editsecrecy and true,
  1.1260 +					secure = self.args.editsecure and true,
  1.1261 +					creator = self.authuser,
  1.1262 +					creationdate = time() })
  1.1263 +				save = true
  1.1264 +			end
  1.1265 +		
  1.1266 +		elseif self.args.actionsave then
  1.1267 +			
  1.1268 +			-- Save section
  1.1269 +			
  1.1270 +			self.section.revisiondate = time()
  1.1271 +			self.section.revisioner = self.authuser
  1.1272 +			save = true
  1.1273 + 		
  1.1274 +		elseif self.args.actionsaveprops then
  1.1275 +			
  1.1276 +			-- Save properties
  1.1277 +			
  1.1278 +			self.section.hidden = self.args.editvisibility and true
  1.1279 +			self.section.secret = self.args.editsecrecy and true
  1.1280 +			self.section.secure = self.args.editsecure and true
  1.1281 +			self.section.label = self.args.editlabel ~= "" and
  1.1282 +				self.args.editlabel or nil
  1.1283 +			self.section.title = self.args.edittitle ~= "" and
  1.1284 +				self.args.edittitle or nil
  1.1285 +			self.section.redirect =
  1.1286 +				self.args.editredirect ~= "" and self.args.editredirect or nil
  1.1287 +			save = true
  1.1288 +		
  1.1289 +		elseif self.args.actionup then
  1.1290 +			
  1.1291 +			-- Move section up
  1.1292 +			
  1.1293 +			local t, i = self:checkpath(self.sectionpath)
  1.1294 +			if t and i > 1 then
  1.1295 +				if self.ispubprofile and not self.args.actionconfirm then
  1.1296 +					useralert = {
  1.1297 +						text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
  1.1298 +						confirm =
  1.1299 +							'<input type="submit" name="actionup" value="' ..
  1.1300 +							self.locale.MOVE .. '" /> ' ..
  1.1301 +							self:hidden("actionconfirm", "true")
  1.1302 +					}
  1.1303 +				else
  1.1304 +					local item = table.remove(t, i)
  1.1305 +					table.insert(t, i - 1, item)
  1.1306 +					save = true
  1.1307 +				end
  1.1308 +			end
  1.1309 +		
  1.1310 +		elseif self.args.actiondown then
  1.1311 +			
  1.1312 +			-- Move section down
  1.1313 +			
  1.1314 +			local t, i = self:checkpath(self.sectionpath)
  1.1315 +			if t and i < #t then
  1.1316 +				if self.ispubprofile and not self.args.actionconfirm then
  1.1317 +					useralert = {
  1.1318 +						text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
  1.1319 +						confirm =
  1.1320 +							'<input type="submit" name="actiondown" value="' ..
  1.1321 +							self.locale.MOVE .. '" /> ' ..
  1.1322 +							self:hidden("actionconfirm", "true")
  1.1323 +					}
  1.1324 +				else
  1.1325 +					local item = table.remove(t, i)
  1.1326 +					table.insert(t, i + 1, item)
  1.1327 +					save = true
  1.1328 +				end
  1.1329 +			end
  1.1330 +		
  1.1331 +		elseif self.args.actioncreateprofile and self.args.createprofile then
  1.1332 +			
  1.1333 +			-- Create profile
  1.1334 +			
  1.1335 +			local c = self.args.createprofile
  1.1336 +			if c == "" then
  1.1337 +				c = self.profile
  1.1338 +			end
  1.1339 +			c = self:checkprofilename(c:lower())
  1.1340 +			local l = self:checklanguage((self.args.createlanguage or self.lang):lower())
  1.1341 +			if c == self.profile and l == self.lang then
  1.1342 +				useralert = { 
  1.1343 +					text = self.locale.ALERT_CANNOT_COPY_PROFILE_TO_SELF,
  1.1344 +					returnto = self:hidden("actioneditprofiles", "true")
  1.1345 +				}
  1.1346 +			else
  1.1347 +				local profiles = self:getprofiles(l)
  1.1348 +				local text
  1.1349 +				if c == self.pubprofile then
  1.1350 +					text = self.locale.ALERT_OVERWRITE_PUBLISHED_PROFILE
  1.1351 +				elseif profiles[c] and l == self.lang then
  1.1352 +					text = self.locale.ALERT_OVERWRITE_EXISTING_PROFILE
  1.1353 +				end
  1.1354 +				if text and not self.args.actionconfirm then
  1.1355 +					useralert = {
  1.1356 +						text = text,
  1.1357 +						returnto = self:hidden("actioneditprofiles", "true"),
  1.1358 +						confirm = '<input type="submit" ' ..
  1.1359 +							'name="actioncreateprofile" value="' ..
  1.1360 +							self.locale.OVERWRITE .. '" /> ' ..
  1.1361 +							self:hidden("actionconfirm", "true") ..
  1.1362 +							self:hidden("createlanguage", l) ..
  1.1363 +							self:hidden("createprofile", c)
  1.1364 +					}
  1.1365 +				else
  1.1366 +					if profiles[c] then
  1.1367 +						self:deleteprofile(c, l)
  1.1368 +					end
  1.1369 +					self:copyprofile(c, self.profile, l, self.lang)
  1.1370 +				end
  1.1371 +			end
  1.1372 +		
  1.1373 +		elseif self.args.actiondeleteprofile and self.args.deleteprofile then
  1.1374 +			
  1.1375 +			-- Delete profile
  1.1376 +			
  1.1377 +			local c = self:checkprofilename(self.args.deleteprofile:lower())
  1.1378 +			assert(c ~= self.pubprofile,
  1.1379 +				self:dbmsg("Cannot delete published profile", c))
  1.1380 +			if self.args.actionconfirm then
  1.1381 +				self:deleteprofile(c)
  1.1382 +				self.profile = nil
  1.1383 +				self.args.profile = nil
  1.1384 +				self:init()
  1.1385 +				save = true
  1.1386 +			else
  1.1387 +				useralert = { 
  1.1388 +					text = self.locale.ALERT_DELETE_PROFILE,
  1.1389 +					returnto = self:hidden("actioneditprofiles", "true"),
  1.1390 +					confirm = '<input type="submit" ' ..
  1.1391 +						'name="actiondeleteprofile" value="' .. 
  1.1392 +						self.locale.DELETE .. '" /> ' ..
  1.1393 +						self:hidden("actionconfirm", "true") ..
  1.1394 +						self:hidden("deleteprofile", c)
  1.1395 +				}
  1.1396 +			end
  1.1397 +		
  1.1398 +		elseif self.args.actionchangeprofile and self.args.changeprofile then
  1.1399 +			
  1.1400 +			-- Change profile
  1.1401 +			
  1.1402 +			local c = self:checkprofilename(self.args.changeprofile:lower())
  1.1403 +			self.profile = c
  1.1404 +			self.args.profile = c
  1.1405 +			save = true
  1.1406 +		
  1.1407 +		elseif self.args.actionchangelanguage and self.args.changelanguage then
  1.1408 +			
  1.1409 +			-- Change language
  1.1410 +			
  1.1411 +			local l = self:checklanguage(self.args.changelanguage:lower())
  1.1412 +			self.lang = l
  1.1413 +			self.args.lang = l
  1.1414 + 			self.explicitlang = l
  1.1415 +			save = true
  1.1416 +		
  1.1417 +		elseif self.args.actionpublishprofile and self.args.publishprofile then
  1.1418 +			
  1.1419 +			-- Publish profile
  1.1420 +			
  1.1421 +			local c = self:checkprofilename(self.args.publishprofile:lower())
  1.1422 +			if c ~= self.publicprofile then
  1.1423 +				if self.args.actionconfirm then
  1.1424 +					self:publishprofile(c)
  1.1425 +					save = true
  1.1426 +				else
  1.1427 +					useralert = {
  1.1428 +						text = self.locale.ALERT_PUBLISH_PROFILE,
  1.1429 +						returnto = self:hidden("actioneditprofiles", "true"),
  1.1430 +						confirm = '<input type="submit" ' ..
  1.1431 +							'name="actionpublishprofile" value="' ..
  1.1432 +							self.locale.PUBLISH .. '" /> ' ..
  1.1433 +							self:hidden("actionconfirm", "true") ..
  1.1434 +							self:hidden("publishprofile", c)
  1.1435 +					}
  1.1436 +				end
  1.1437 +			end
  1.1438 +		end
  1.1439 +		
  1.1440 +	end
  1.1441 +	
  1.1442 +	if self.args.actiondelete then
  1.1443 +		
  1.1444 +		-- Delete section
  1.1445 +		
  1.1446 +		if not self.args.actionconfirm then
  1.1447 +			useralert = {
  1.1448 +				text = self.ispubprofile and
  1.1449 +					self.locale.ALERT_DELETE_IN_PUBLISHED_PROFILE or
  1.1450 +					self.locale.ALERT_DELETE_SECTION,
  1.1451 +				confirm =
  1.1452 +					'<input type="submit" name="actiondelete" value="' .. 
  1.1453 +					self.locale.DELETE .. '" /> ' ..
  1.1454 +					self:hidden("actionconfirm", "true")
  1.1455 +			}
  1.1456 +		else
  1.1457 +			local key = self.args.editkey
  1.1458 +			if key == "main" and not self.section.subs then
  1.1459 +				self:deletesection(self.sectionname, true) -- all bodies
  1.1460 +				self:rmpath(self.sectionpath) -- and node
  1.1461 +			else
  1.1462 +				local ext = (key == "main" and "") or "." .. key
  1.1463 +				self:deletesection(self.sectionname .. ext) -- only text
  1.1464 +				if self.section.dynamic then
  1.1465 +					self.section.dynamic[key] = nil
  1.1466 +					local n = 0
  1.1467 +					for _ in pairs(self.section.dynamic) do
  1.1468 +						n = n + 1
  1.1469 +					end
  1.1470 +					if n == 0 then
  1.1471 +						self.section.dynamic = nil
  1.1472 +					end
  1.1473 +				end
  1.1474 +			end
  1.1475 +			save = true
  1.1476 +		end
  1.1477 +	end
  1.1478 +		
  1.1479 +	if save then
  1.1480 +		self:saveindex()
  1.1481 +		self:init()
  1.1482 +	end
  1.1483 +	
  1.1484 +end
  1.1485 +
  1.1486 +
  1.1487 +function Loona:encodeform(s)
  1.1488 +	return http.encodeform(s)
  1.1489 +end
  1.1490 +
  1.1491 +
  1.1492 +function Loona:loadhtml(src, outfunc, chunkname)
  1.1493 + 	return luahtml.load(src, outfunc, chunkname)
  1.1494 +end
  1.1495 +
  1.1496 +
  1.1497 +function Loona:domarkup(s)
  1.1498 +	return markup.load(s)
  1.1499 +end
  1.1500 +
  1.1501 +
  1.1502 +function Loona:expire(dir, pat, maxage)
  1.1503 +	return util.expire(dir, pat, maxage or self.config.sessionmaxage)
  1.1504 +end
  1.1505 +
  1.1506 +
  1.1507 +function Loona:new(o)
  1.1508 +
  1.1509 +	local parsed, msg
  1.1510 +	
  1.1511 +	o = o or { }
  1.1512 +	setmetatable(o, self)
  1.1513 +	self.__index = self
  1.1514 +-- 	o = atom.new(self, o)
  1.1515 +	
  1.1516 +	-- Buffer
  1.1517 +	
  1.1518 +	o.out = o.out or function(self, s)
  1.1519 +		self.buf:out(s)
  1.1520 +	end
  1.1521 +	o.setheader = o.setheader or function(self, s)
  1.1522 +		self.buf:setheader(s)
  1.1523 +	end
  1.1524 +	
  1.1525 +	-- Get configuration
  1.1526 +	
  1.1527 +	o.config = o.config or lib.source(o.conffile or "../etc/config.lua") or { }
  1.1528 +	o.config.defname = o.config.defname or "home"
  1.1529 +	o.config.deflang = o.config.deflang or "en"
  1.1530 +	o.config.sessionmaxage = o.config.sessionmaxage or 6000
  1.1531 +	o.config.secureport = o.config.secureport or 443
  1.1532 +	o.config.passwdfile =
  1.1533 +		posix.abspath(o.config.passwdfile or "../etc/passwd.lua")
  1.1534 +	o.config.sessiondir =
  1.1535 +		posix.abspath(o.config.sessiondir or "../var/sessions")
  1.1536 +	o.config.extdir = posix.abspath(o.config.extdir or "../extensions")
  1.1537 +	o.config.contentdir = posix.abspath(o.config.contentdir or "../content")
  1.1538 +	o.config.localedir = posix.abspath(o.config.localedir or "../locale")
  1.1539 +	o.config.htdocsdir = posix.abspath(o.config.htdocsdir or "../htdocs")
  1.1540 +	o.config.htmlcachedir = 
  1.1541 +		posix.abspath(o.config.htmlcachedir or "../var/htmlcache")
  1.1542 +	o.config.extlinkextra = o.config.extlinksamewindow and ' class="extlink"'
  1.1543 +		or ' class="extlink" onclick="void(window.open(this.href, \'\', \'\')); return false;"'
  1.1544 +	
  1.1545 +	-- Create proxy for on-demand loading of locales
  1.1546 +	
  1.1547 +	o.locale = { }
  1.1548 +	local locmt = { }
  1.1549 +	locmt.__index = function(_, key)
  1.1550 +		for _, l in ipairs(o.langs) do
  1.1551 +			locmt.__locale = lib.source(o.config.localedir .. "/" .. l)
  1.1552 +			if locmt.__locale then
  1.1553 +				break
  1.1554 +			end
  1.1555 +		end
  1.1556 +		locmt.__index = function(tab, key)
  1.1557 +			return locmt.__locale[key] or key
  1.1558 +		end
  1.1559 +		return locmt.__locale[key] or key
  1.1560 +	end
  1.1561 +	setmetatable(o.locale, locmt)
  1.1562 +	
  1.1563 +	-- Get request, args, document, script name, request path
  1.1564 +	
  1.1565 +	o.request = o.request or Request:new()
  1.1566 +	o.args = o.request:getargs()
  1.1567 +	o.cgi_document = o.request:getdocument()
  1.1568 + 	
  1.1569 + 	o.scriptpath = o.scriptpath or o.cgi_document.Path
  1.1570 +	o.requesthandler = o.requesthandler or o.cgi_document.Handler
  1.1571 + 	o.requestdocument = o.requestdocument or o.cgi_document.Name
  1.1572 +	o.requestpath = o.requestpath or o.cgi_document.VirtualPath
  1.1573 +	o.explicitlang = not o.requestlang and o.args.lang
  1.1574 +	o.secure = not o.insecure and (o.request.Port == o.config.secureport)
  1.1575 +
  1.1576 +	-- Manage login and establish session
  1.1577 +	
  1.1578 +	if not o.nologin then
  1.1579 +		local sid = o.args.session or o.request.UniqueID
  1.1580 +		o.session = o.session or Session:new {
  1.1581 +			id = sid,
  1.1582 +			sessiondir = o.config.sessiondir,
  1.1583 +			maxage = o.config.sessionmaxage
  1.1584 +		}
  1.1585 +		if o.args.login then
  1.1586 +			-- write back session ID into request args:
  1.1587 +			o.args.session = sid -- !
  1.1588 +			if o.args.login == "false" then
  1.1589 +				o.session:delete()
  1.1590 +				o.session = nil
  1.1591 +			elseif o.args.password then
  1.1592 +				o.loginfailed = true
  1.1593 +				local pwddb = lib.source(o.config.passwdfile)
  1.1594 +				local pwdentry = pwddb[o.args.login]
  1.1595 +				if pwdentry and pwdentry.password == o.args.password then
  1.1596 +					o.session.data.authuser = pwdentry.username
  1.1597 +					o.session.data.id = o.session.id
  1.1598 +					o.loginfailed = nil
  1.1599 +				end
  1.1600 +			end
  1.1601 +		end
  1.1602 +		o.authuser = o.session and o.session.data.authuser
  1.1603 +	end
  1.1604 +
  1.1605 +	if o.nologin or not o.authuser then
  1.1606 +		o.authuser = nil
  1.1607 +		o.session = nil
  1.1608 +		o.args.session = nil
  1.1609 +	end
  1.1610 +
  1.1611 +	-- Get lang, locale, profile, section
  1.1612 +
  1.1613 +	o:init()
  1.1614 +	if o.authuser then
  1.1615 +		o:handlechanges()
  1.1616 +	else
  1.1617 +		o.args.profile = nil
  1.1618 +	end
  1.1619 +	
  1.1620 +	-- Current document
  1.1621 +	
  1.1622 +	o.document = o.requestdocument .. "/" .. o.sectionpath
  1.1623 +	if o.authuser then
  1.1624 +		o.getdocname = function(self, path)
  1.1625 +			return self.requestdocument .. "/" .. (path or self.sectionpath)
  1.1626 +		end
  1.1627 +	else
  1.1628 +		o.getdocname = function(self, path, haveargs)
  1.1629 +			local dyn
  1.1630 +			dyn, path = self:isdynamic(path or self.sectionpath)
  1.1631 +			if dyn or haveargs then
  1.1632 +				return self.requestdocument .. "/" .. path
  1.1633 +			end
  1.1634 +			path = path == self.config.defname and "index" or path
  1.1635 +			return "/" .. path:gsub("/", "_") .. ".html"
  1.1636 +		end
  1.1637 +	end
  1.1638 +	
  1.1639 +	-- Save session state
  1.1640 +	
  1.1641 +	if o.session then
  1.1642 +		o.session:save()
  1.1643 +	end
  1.1644 +	
  1.1645 +	return o
  1.1646 +end
  1.1647 +
  1.1648 +
  1.1649 +function Loona:run(fname)
  1.1650 +	self:indexdynamic()
  1.1651 +	fname = fname or self.requesthandler
  1.1652 +	local parsed, msg = self:loadhtml(open(fname), "loona:out", fname)
  1.1653 +	assert(parsed, self:dbmsg("HTML/Lua parsing failed", msg))
  1.1654 +	self:runboxed(parsed)
  1.1655 +	return self
  1.1656 +end
  1.1657 +
  1.1658 +
  1.1659 +function Loona:indexdynamic()
  1.1660 +	self:recursesections(self.sections, function(self, s, e, path, dynamic)
  1.1661 +		path = path and path .. "_" .. e.name or e.name
  1.1662 +		dynamic = dynamic or { }
  1.1663 +		for k in pairs(e.dynamic or { }) do
  1.1664 +			dynamic[k] = true
  1.1665 +		end
  1.1666 +		for k in pairs(dynamic) do
  1.1667 +			local ext = (k == "main" and "") or "." .. k
  1.1668 +			if posix.stat(self.contentdir .. "/" .. path .. ext,
  1.1669 +				"mode") == "file" then
  1.1670 +				dynamic[k] = e.dynamic and e.dynamic[k]
  1.1671 +			end
  1.1672 +		end
  1.1673 +		local n = 0
  1.1674 +		for k in pairs(dynamic) do
  1.1675 +			n = n + 1
  1.1676 +		end
  1.1677 +		if n > 0 then
  1.1678 +			e.dynamic = { }
  1.1679 +			for k in pairs(dynamic) do
  1.1680 +				e.dynamic[k] = true
  1.1681 +			end
  1.1682 +		else
  1.1683 +			e.dynamic = nil
  1.1684 +		end
  1.1685 +		return path, dynamic
  1.1686 +	end)
  1.1687 +end
  1.1688 +
  1.1689 +
  1.1690 +function Loona:isdynamic(path)
  1.1691 +	path = path or self.sectionpath
  1.1692 +	local t, i = self:checkpath(path)
  1.1693 +	if t and t[i].redirect then
  1.1694 +		path = t[i].redirect
  1.1695 +		t, i = self:isdynamic(path) -- TODO: prohibit endless recursion
  1.1696 +	end
  1.1697 +	return t and t[i].dynamic, path
  1.1698 +end
  1.1699 +
  1.1700 +
  1.1701 +function Loona:dumphtml(o)
  1.1702 +	local outbuf = { }
  1.1703 +	o = o or { }
  1.1704 +	o.nologin = true
  1.1705 +	o.out = function(self, s) table.insert(outbuf, s) end
  1.1706 +	o.setheader = function(self, s) end
  1.1707 +	o = self:new(o):run()
  1.1708 +	if not o:isdynamic() then
  1.1709 +		local path = o.sectionname
  1.1710 +		path = path == o.config.defname and "index" or path
  1.1711 +		local srcname = o.config.htdocsdir .. "/" .. path .. o.htmlext
  1.1712 +		local fh, msg = open(srcname .. ".tmp", "wb")
  1.1713 +		assert(fh, self:dbmsg("Could not write cached HTML", msg))
  1.1714 +		fh:write(unpack(outbuf))
  1.1715 +		fh:close()
  1.1716 +		local dstname = o.config.htmlcachedir .. "/" .. path .. o.htmlext
  1.1717 +		local success, msg = posix.symlink(srcname, dstname .. ".tmp")
  1.1718 +-- 		assert(success, self:dbmsg("Could not link to cached HTML", msg))
  1.1719 +	end
  1.1720 +end