Globally reduce copyright headers
[project/luci.git] / modules / luci-base / luasrc / dispatcher.lua
1 -- Copyright 2008 Steven Barth <steven@midlink.org>
2 -- Licensed to the public under the Apache License 2.0.
3
4 --- LuCI web dispatcher.
5 local fs = require "nixio.fs"
6 local sys = require "luci.sys"
7 local util = require "luci.util"
8 local http = require "luci.http"
9 local nixio = require "nixio", require "nixio.util"
10
11 module("luci.dispatcher", package.seeall)
12 context = util.threadlocal()
13 uci = require "luci.model.uci"
14 i18n = require "luci.i18n"
15 _M.fs = fs
16
17 authenticator = {}
18
19 -- Index table
20 local index = nil
21
22 -- Fastindex
23 local fi
24
25
26 --- Build the URL relative to the server webroot from given virtual path.
27 -- @param ... Virtual path
28 -- @return Relative URL
29 function build_url(...)
30 local path = {...}
31 local url = { http.getenv("SCRIPT_NAME") or "" }
32
33 local k, v
34 for k, v in pairs(context.urltoken) do
35 url[#url+1] = "/;"
36 url[#url+1] = http.urlencode(k)
37 url[#url+1] = "="
38 url[#url+1] = http.urlencode(v)
39 end
40
41 local p
42 for _, p in ipairs(path) do
43 if p:match("^[a-zA-Z0-9_%-%.%%/,;]+$") then
44 url[#url+1] = "/"
45 url[#url+1] = p
46 end
47 end
48
49 return table.concat(url, "")
50 end
51
52 --- Check whether a dispatch node shall be visible
53 -- @param node Dispatch node
54 -- @return Boolean indicating whether the node should be visible
55 function node_visible(node)
56 if node then
57 return not (
58 (not node.title or #node.title == 0) or
59 (not node.target or node.hidden == true) or
60 (type(node.target) == "table" and node.target.type == "firstchild" and
61 (type(node.nodes) ~= "table" or not next(node.nodes)))
62 )
63 end
64 return false
65 end
66
67 --- Return a sorted table of visible childs within a given node
68 -- @param node Dispatch node
69 -- @return Ordered table of child node names
70 function node_childs(node)
71 local rv = { }
72 if node then
73 local k, v
74 for k, v in util.spairs(node.nodes,
75 function(a, b)
76 return (node.nodes[a].order or 100)
77 < (node.nodes[b].order or 100)
78 end)
79 do
80 if node_visible(v) then
81 rv[#rv+1] = k
82 end
83 end
84 end
85 return rv
86 end
87
88
89 --- Send a 404 error code and render the "error404" template if available.
90 -- @param message Custom error message (optional)
91 -- @return false
92 function error404(message)
93 http.status(404, "Not Found")
94 message = message or "Not Found"
95
96 require("luci.template")
97 if not util.copcall(luci.template.render, "error404") then
98 http.prepare_content("text/plain")
99 http.write(message)
100 end
101 return false
102 end
103
104 --- Send a 500 error code and render the "error500" template if available.
105 -- @param message Custom error message (optional)#
106 -- @return false
107 function error500(message)
108 util.perror(message)
109 if not context.template_header_sent then
110 http.status(500, "Internal Server Error")
111 http.prepare_content("text/plain")
112 http.write(message)
113 else
114 require("luci.template")
115 if not util.copcall(luci.template.render, "error500", {message=message}) then
116 http.prepare_content("text/plain")
117 http.write(message)
118 end
119 end
120 return false
121 end
122
123 function authenticator.htmlauth(validator, accs, default)
124 local user = http.formvalue("luci_username")
125 local pass = http.formvalue("luci_password")
126
127 if user and validator(user, pass) then
128 return user
129 end
130
131 require("luci.i18n")
132 require("luci.template")
133 context.path = {}
134 luci.template.render("sysauth", {duser=default, fuser=user})
135 return false
136
137 end
138
139 --- Dispatch an HTTP request.
140 -- @param request LuCI HTTP Request object
141 function httpdispatch(request, prefix)
142 http.context.request = request
143
144 local r = {}
145 context.request = r
146 context.urltoken = {}
147
148 local pathinfo = http.urldecode(request:getenv("PATH_INFO") or "", true)
149
150 if prefix then
151 for _, node in ipairs(prefix) do
152 r[#r+1] = node
153 end
154 end
155
156 local tokensok = true
157 for node in pathinfo:gmatch("[^/]+") do
158 local tkey, tval
159 if tokensok then
160 tkey, tval = node:match(";(%w+)=([a-fA-F0-9]*)")
161 end
162 if tkey then
163 context.urltoken[tkey] = tval
164 else
165 tokensok = false
166 r[#r+1] = node
167 end
168 end
169
170 local stat, err = util.coxpcall(function()
171 dispatch(context.request)
172 end, error500)
173
174 http.close()
175
176 --context._disable_memtrace()
177 end
178
179 --- Dispatches a LuCI virtual path.
180 -- @param request Virtual path
181 function dispatch(request)
182 --context._disable_memtrace = require "luci.debug".trap_memtrace("l")
183 local ctx = context
184 ctx.path = request
185
186 local conf = require "luci.config"
187 assert(conf.main,
188 "/etc/config/luci seems to be corrupt, unable to find section 'main'")
189
190 local lang = conf.main.lang or "auto"
191 if lang == "auto" then
192 local aclang = http.getenv("HTTP_ACCEPT_LANGUAGE") or ""
193 for lpat in aclang:gmatch("[%w-]+") do
194 lpat = lpat and lpat:gsub("-", "_")
195 if conf.languages[lpat] then
196 lang = lpat
197 break
198 end
199 end
200 end
201 require "luci.i18n".setlanguage(lang)
202
203 local c = ctx.tree
204 local stat
205 if not c then
206 c = createtree()
207 end
208
209 local track = {}
210 local args = {}
211 ctx.args = args
212 ctx.requestargs = ctx.requestargs or args
213 local n
214 local token = ctx.urltoken
215 local preq = {}
216 local freq = {}
217
218 for i, s in ipairs(request) do
219 preq[#preq+1] = s
220 freq[#freq+1] = s
221 c = c.nodes[s]
222 n = i
223 if not c then
224 break
225 end
226
227 util.update(track, c)
228
229 if c.leaf then
230 break
231 end
232 end
233
234 if c and c.leaf then
235 for j=n+1, #request do
236 args[#args+1] = request[j]
237 freq[#freq+1] = request[j]
238 end
239 end
240
241 ctx.requestpath = ctx.requestpath or freq
242 ctx.path = preq
243
244 if track.i18n then
245 i18n.loadc(track.i18n)
246 end
247
248 -- Init template engine
249 if (c and c.index) or not track.notemplate then
250 local tpl = require("luci.template")
251 local media = track.mediaurlbase or luci.config.main.mediaurlbase
252 if not pcall(tpl.Template, "themes/%s/header" % fs.basename(media)) then
253 media = nil
254 for name, theme in pairs(luci.config.themes) do
255 if name:sub(1,1) ~= "." and pcall(tpl.Template,
256 "themes/%s/header" % fs.basename(theme)) then
257 media = theme
258 end
259 end
260 assert(media, "No valid theme found")
261 end
262
263 local function _ifattr(cond, key, val)
264 if cond then
265 local env = getfenv(3)
266 local scope = (type(env.self) == "table") and env.self
267 return string.format(
268 ' %s="%s"', tostring(key),
269 util.pcdata(tostring( val
270 or (type(env[key]) ~= "function" and env[key])
271 or (scope and type(scope[key]) ~= "function" and scope[key])
272 or "" ))
273 )
274 else
275 return ''
276 end
277 end
278
279 tpl.context.viewns = setmetatable({
280 write = http.write;
281 include = function(name) tpl.Template(name):render(getfenv(2)) end;
282 translate = i18n.translate;
283 translatef = i18n.translatef;
284 export = function(k, v) if tpl.context.viewns[k] == nil then tpl.context.viewns[k] = v end end;
285 striptags = util.striptags;
286 pcdata = util.pcdata;
287 media = media;
288 theme = fs.basename(media);
289 resource = luci.config.main.resourcebase;
290 ifattr = function(...) return _ifattr(...) end;
291 attr = function(...) return _ifattr(true, ...) end;
292 }, {__index=function(table, key)
293 if key == "controller" then
294 return build_url()
295 elseif key == "REQUEST_URI" then
296 return build_url(unpack(ctx.requestpath))
297 else
298 return rawget(table, key) or _G[key]
299 end
300 end})
301 end
302
303 track.dependent = (track.dependent ~= false)
304 assert(not track.dependent or not track.auto,
305 "Access Violation\nThe page at '" .. table.concat(request, "/") .. "/' " ..
306 "has no parent node so the access to this location has been denied.\n" ..
307 "This is a software bug, please report this message at " ..
308 "http://luci.subsignal.org/trac/newticket"
309 )
310
311 if track.sysauth then
312 local authen = type(track.sysauth_authenticator) == "function"
313 and track.sysauth_authenticator
314 or authenticator[track.sysauth_authenticator]
315
316 local def = (type(track.sysauth) == "string") and track.sysauth
317 local accs = def and {track.sysauth} or track.sysauth
318 local sess = ctx.authsession
319 local verifytoken = false
320 if not sess then
321 sess = http.getcookie("sysauth")
322 sess = sess and sess:match("^[a-f0-9]*$")
323 verifytoken = true
324 end
325
326 local sdat = (util.ubus("session", "get", { ubus_rpc_session = sess }) or { }).values
327 local user
328
329 if sdat then
330 if not verifytoken or ctx.urltoken.stok == sdat.token then
331 user = sdat.user
332 end
333 else
334 local eu = http.getenv("HTTP_AUTH_USER")
335 local ep = http.getenv("HTTP_AUTH_PASS")
336 if eu and ep and sys.user.checkpasswd(eu, ep) then
337 authen = function() return eu end
338 end
339 end
340
341 if not util.contains(accs, user) then
342 if authen then
343 ctx.urltoken.stok = nil
344 local user, sess = authen(sys.user.checkpasswd, accs, def)
345 if not user or not util.contains(accs, user) then
346 return
347 else
348 if not sess then
349 local sdat = util.ubus("session", "create", { timeout = luci.config.sauth.sessiontime })
350 if sdat then
351 local token = sys.uniqueid(16)
352 util.ubus("session", "set", {
353 ubus_rpc_session = sdat.ubus_rpc_session,
354 values = {
355 user = user,
356 token = token,
357 section = sys.uniqueid(16)
358 }
359 })
360 sess = sdat.ubus_rpc_session
361 ctx.urltoken.stok = token
362 end
363 end
364
365 if sess then
366 http.header("Set-Cookie", "sysauth=" .. sess.."; path="..build_url())
367 ctx.authsession = sess
368 ctx.authuser = user
369 end
370 end
371 else
372 http.status(403, "Forbidden")
373 return
374 end
375 else
376 ctx.authsession = sess
377 ctx.authuser = user
378 end
379 end
380
381 if track.setgroup then
382 sys.process.setgroup(track.setgroup)
383 end
384
385 if track.setuser then
386 sys.process.setuser(track.setuser)
387 end
388
389 local target = nil
390 if c then
391 if type(c.target) == "function" then
392 target = c.target
393 elseif type(c.target) == "table" then
394 target = c.target.target
395 end
396 end
397
398 if c and (c.index or type(target) == "function") then
399 ctx.dispatched = c
400 ctx.requested = ctx.requested or ctx.dispatched
401 end
402
403 if c and c.index then
404 local tpl = require "luci.template"
405
406 if util.copcall(tpl.render, "indexer", {}) then
407 return true
408 end
409 end
410
411 if type(target) == "function" then
412 util.copcall(function()
413 local oldenv = getfenv(target)
414 local module = require(c.module)
415 local env = setmetatable({}, {__index=
416
417 function(tbl, key)
418 return rawget(tbl, key) or module[key] or oldenv[key]
419 end})
420
421 setfenv(target, env)
422 end)
423
424 local ok, err
425 if type(c.target) == "table" then
426 ok, err = util.copcall(target, c.target, unpack(args))
427 else
428 ok, err = util.copcall(target, unpack(args))
429 end
430 assert(ok,
431 "Failed to execute " .. (type(c.target) == "function" and "function" or c.target.type or "unknown") ..
432 " dispatcher target for entry '/" .. table.concat(request, "/") .. "'.\n" ..
433 "The called action terminated with an exception:\n" .. tostring(err or "(unknown)"))
434 else
435 local root = node()
436 if not root or not root.target then
437 error404("No root node was registered, this usually happens if no module was installed.\n" ..
438 "Install luci-mod-admin-full and retry. " ..
439 "If the module is already installed, try removing the /tmp/luci-indexcache file.")
440 else
441 error404("No page is registered at '/" .. table.concat(request, "/") .. "'.\n" ..
442 "If this url belongs to an extension, make sure it is properly installed.\n" ..
443 "If the extension was recently installed, try removing the /tmp/luci-indexcache file.")
444 end
445 end
446 end
447
448 --- Generate the dispatching index using the native file-cache based strategy.
449 function createindex()
450 local controllers = { }
451 local base = "%s/controller/" % util.libpath()
452 local _, path
453
454 for path in (fs.glob("%s*.lua" % base) or function() end) do
455 controllers[#controllers+1] = path
456 end
457
458 for path in (fs.glob("%s*/*.lua" % base) or function() end) do
459 controllers[#controllers+1] = path
460 end
461
462 if indexcache then
463 local cachedate = fs.stat(indexcache, "mtime")
464 if cachedate then
465 local realdate = 0
466 for _, obj in ipairs(controllers) do
467 local omtime = fs.stat(obj, "mtime")
468 realdate = (omtime and omtime > realdate) and omtime or realdate
469 end
470
471 if cachedate > realdate and sys.process.info("uid") == 0 then
472 assert(
473 sys.process.info("uid") == fs.stat(indexcache, "uid")
474 and fs.stat(indexcache, "modestr") == "rw-------",
475 "Fatal: Indexcache is not sane!"
476 )
477
478 index = loadfile(indexcache)()
479 return index
480 end
481 end
482 end
483
484 index = {}
485
486 for _, path in ipairs(controllers) do
487 local modname = "luci.controller." .. path:sub(#base+1, #path-4):gsub("/", ".")
488 local mod = require(modname)
489 assert(mod ~= true,
490 "Invalid controller file found\n" ..
491 "The file '" .. path .. "' contains an invalid module line.\n" ..
492 "Please verify whether the module name is set to '" .. modname ..
493 "' - It must correspond to the file path!")
494
495 local idx = mod.index
496 assert(type(idx) == "function",
497 "Invalid controller file found\n" ..
498 "The file '" .. path .. "' contains no index() function.\n" ..
499 "Please make sure that the controller contains a valid " ..
500 "index function and verify the spelling!")
501
502 index[modname] = idx
503 end
504
505 if indexcache then
506 local f = nixio.open(indexcache, "w", 600)
507 f:writeall(util.get_bytecode(index))
508 f:close()
509 end
510 end
511
512 --- Create the dispatching tree from the index.
513 -- Build the index before if it does not exist yet.
514 function createtree()
515 if not index then
516 createindex()
517 end
518
519 local ctx = context
520 local tree = {nodes={}, inreq=true}
521 local modi = {}
522
523 ctx.treecache = setmetatable({}, {__mode="v"})
524 ctx.tree = tree
525 ctx.modifiers = modi
526
527 -- Load default translation
528 require "luci.i18n".loadc("base")
529
530 local scope = setmetatable({}, {__index = luci.dispatcher})
531
532 for k, v in pairs(index) do
533 scope._NAME = k
534 setfenv(v, scope)
535 v()
536 end
537
538 local function modisort(a,b)
539 return modi[a].order < modi[b].order
540 end
541
542 for _, v in util.spairs(modi, modisort) do
543 scope._NAME = v.module
544 setfenv(v.func, scope)
545 v.func()
546 end
547
548 return tree
549 end
550
551 --- Register a tree modifier.
552 -- @param func Modifier function
553 -- @param order Modifier order value (optional)
554 function modifier(func, order)
555 context.modifiers[#context.modifiers+1] = {
556 func = func,
557 order = order or 0,
558 module
559 = getfenv(2)._NAME
560 }
561 end
562
563 --- Clone a node of the dispatching tree to another position.
564 -- @param path Virtual path destination
565 -- @param clone Virtual path source
566 -- @param title Destination node title (optional)
567 -- @param order Destination node order value (optional)
568 -- @return Dispatching tree node
569 function assign(path, clone, title, order)
570 local obj = node(unpack(path))
571 obj.nodes = nil
572 obj.module = nil
573
574 obj.title = title
575 obj.order = order
576
577 setmetatable(obj, {__index = _create_node(clone)})
578
579 return obj
580 end
581
582 --- Create a new dispatching node and define common parameters.
583 -- @param path Virtual path
584 -- @param target Target function to call when dispatched.
585 -- @param title Destination node title
586 -- @param order Destination node order value (optional)
587 -- @return Dispatching tree node
588 function entry(path, target, title, order)
589 local c = node(unpack(path))
590
591 c.target = target
592 c.title = title
593 c.order = order
594 c.module = getfenv(2)._NAME
595
596 return c
597 end
598
599 --- Fetch or create a dispatching node without setting the target module or
600 -- enabling the node.
601 -- @param ... Virtual path
602 -- @return Dispatching tree node
603 function get(...)
604 return _create_node({...})
605 end
606
607 --- Fetch or create a new dispatching node.
608 -- @param ... Virtual path
609 -- @return Dispatching tree node
610 function node(...)
611 local c = _create_node({...})
612
613 c.module = getfenv(2)._NAME
614 c.auto = nil
615
616 return c
617 end
618
619 function _create_node(path)
620 if #path == 0 then
621 return context.tree
622 end
623
624 local name = table.concat(path, ".")
625 local c = context.treecache[name]
626
627 if not c then
628 local last = table.remove(path)
629 local parent = _create_node(path)
630
631 c = {nodes={}, auto=true}
632 -- the node is "in request" if the request path matches
633 -- at least up to the length of the node path
634 if parent.inreq and context.path[#path+1] == last then
635 c.inreq = true
636 end
637 parent.nodes[last] = c
638 context.treecache[name] = c
639 end
640 return c
641 end
642
643 -- Subdispatchers --
644
645 function _firstchild()
646 local path = { unpack(context.path) }
647 local name = table.concat(path, ".")
648 local node = context.treecache[name]
649
650 local lowest
651 if node and node.nodes and next(node.nodes) then
652 local k, v
653 for k, v in pairs(node.nodes) do
654 if not lowest or
655 (v.order or 100) < (node.nodes[lowest].order or 100)
656 then
657 lowest = k
658 end
659 end
660 end
661
662 assert(lowest ~= nil,
663 "The requested node contains no childs, unable to redispatch")
664
665 path[#path+1] = lowest
666 dispatch(path)
667 end
668
669 --- Alias the first (lowest order) page automatically
670 function firstchild()
671 return { type = "firstchild", target = _firstchild }
672 end
673
674 --- Create a redirect to another dispatching node.
675 -- @param ... Virtual path destination
676 function alias(...)
677 local req = {...}
678 return function(...)
679 for _, r in ipairs({...}) do
680 req[#req+1] = r
681 end
682
683 dispatch(req)
684 end
685 end
686
687 --- Rewrite the first x path values of the request.
688 -- @param n Number of path values to replace
689 -- @param ... Virtual path to replace removed path values with
690 function rewrite(n, ...)
691 local req = {...}
692 return function(...)
693 local dispatched = util.clone(context.dispatched)
694
695 for i=1,n do
696 table.remove(dispatched, 1)
697 end
698
699 for i, r in ipairs(req) do
700 table.insert(dispatched, i, r)
701 end
702
703 for _, r in ipairs({...}) do
704 dispatched[#dispatched+1] = r
705 end
706
707 dispatch(dispatched)
708 end
709 end
710
711
712 local function _call(self, ...)
713 local func = getfenv()[self.name]
714 assert(func ~= nil,
715 'Cannot resolve function "' .. self.name .. '". Is it misspelled or local?')
716
717 assert(type(func) == "function",
718 'The symbol "' .. self.name .. '" does not refer to a function but data ' ..
719 'of type "' .. type(func) .. '".')
720
721 if #self.argv > 0 then
722 return func(unpack(self.argv), ...)
723 else
724 return func(...)
725 end
726 end
727
728 --- Create a function-call dispatching target.
729 -- @param name Target function of local controller
730 -- @param ... Additional parameters passed to the function
731 function call(name, ...)
732 return {type = "call", argv = {...}, name = name, target = _call}
733 end
734
735
736 local _template = function(self, ...)
737 require "luci.template".render(self.view)
738 end
739
740 --- Create a template render dispatching target.
741 -- @param name Template to be rendered
742 function template(name)
743 return {type = "template", view = name, target = _template}
744 end
745
746
747 local function _cbi(self, ...)
748 local cbi = require "luci.cbi"
749 local tpl = require "luci.template"
750 local http = require "luci.http"
751
752 local config = self.config or {}
753 local maps = cbi.load(self.model, ...)
754
755 local state = nil
756
757 for i, res in ipairs(maps) do
758 res.flow = config
759 local cstate = res:parse()
760 if cstate and (not state or cstate < state) then
761 state = cstate
762 end
763 end
764
765 local function _resolve_path(path)
766 return type(path) == "table" and build_url(unpack(path)) or path
767 end
768
769 if config.on_valid_to and state and state > 0 and state < 2 then
770 http.redirect(_resolve_path(config.on_valid_to))
771 return
772 end
773
774 if config.on_changed_to and state and state > 1 then
775 http.redirect(_resolve_path(config.on_changed_to))
776 return
777 end
778
779 if config.on_success_to and state and state > 0 then
780 http.redirect(_resolve_path(config.on_success_to))
781 return
782 end
783
784 if config.state_handler then
785 if not config.state_handler(state, maps) then
786 return
787 end
788 end
789
790 http.header("X-CBI-State", state or 0)
791
792 if not config.noheader then
793 tpl.render("cbi/header", {state = state})
794 end
795
796 local redirect
797 local messages
798 local applymap = false
799 local pageaction = true
800 local parsechain = { }
801
802 for i, res in ipairs(maps) do
803 if res.apply_needed and res.parsechain then
804 local c
805 for _, c in ipairs(res.parsechain) do
806 parsechain[#parsechain+1] = c
807 end
808 applymap = true
809 end
810
811 if res.redirect then
812 redirect = redirect or res.redirect
813 end
814
815 if res.pageaction == false then
816 pageaction = false
817 end
818
819 if res.message then
820 messages = messages or { }
821 messages[#messages+1] = res.message
822 end
823 end
824
825 for i, res in ipairs(maps) do
826 res:render({
827 firstmap = (i == 1),
828 applymap = applymap,
829 redirect = redirect,
830 messages = messages,
831 pageaction = pageaction,
832 parsechain = parsechain
833 })
834 end
835
836 if not config.nofooter then
837 tpl.render("cbi/footer", {
838 flow = config,
839 pageaction = pageaction,
840 redirect = redirect,
841 state = state,
842 autoapply = config.autoapply
843 })
844 end
845 end
846
847 --- Create a CBI model dispatching target.
848 -- @param model CBI model to be rendered
849 function cbi(model, config)
850 return {type = "cbi", config = config, model = model, target = _cbi}
851 end
852
853
854 local function _arcombine(self, ...)
855 local argv = {...}
856 local target = #argv > 0 and self.targets[2] or self.targets[1]
857 setfenv(target.target, self.env)
858 target:target(unpack(argv))
859 end
860
861 --- Create a combined dispatching target for non argv and argv requests.
862 -- @param trg1 Overview Target
863 -- @param trg2 Detail Target
864 function arcombine(trg1, trg2)
865 return {type = "arcombine", env = getfenv(), target = _arcombine, targets = {trg1, trg2}}
866 end
867
868
869 local function _form(self, ...)
870 local cbi = require "luci.cbi"
871 local tpl = require "luci.template"
872 local http = require "luci.http"
873
874 local maps = luci.cbi.load(self.model, ...)
875 local state = nil
876
877 for i, res in ipairs(maps) do
878 local cstate = res:parse()
879 if cstate and (not state or cstate < state) then
880 state = cstate
881 end
882 end
883
884 http.header("X-CBI-State", state or 0)
885 tpl.render("header")
886 for i, res in ipairs(maps) do
887 res:render()
888 end
889 tpl.render("footer")
890 end
891
892 --- Create a CBI form model dispatching target.
893 -- @param model CBI form model tpo be rendered
894 function form(model)
895 return {type = "cbi", model = model, target = _form}
896 end
897
898 --- Access the luci.i18n translate() api.
899 -- @class function
900 -- @name translate
901 -- @param text Text to translate
902 translate = i18n.translate
903
904 --- No-op function used to mark translation entries for menu labels.
905 -- This function does not actually translate the given argument but
906 -- is used by build/i18n-scan.pl to find translatable entries.
907 function _(text)
908 return text
909 end