-n, --dry-run option
[mob/metatag] / metatag.lua
1 -- metatag     Metadata extraction utility
2 -- Copyright (C) 2014  Christian Th├Ąter <ct@pipapo.org>
3 --
4 -- This program is free software: you can redistribute it and/or modify
5 -- it under the terms of the GNU General Public License as published by
6 -- the Free Software Foundation, either version 3 of the License, or
7 -- (at your option) any later version.
8 --
9 -- This program is distributed in the hope that it will be useful,
10 -- but WITHOUT ANY WARRANTY; without even the implied warranty of
11 -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 -- GNU General Public License for more details.
13 --
14 -- You should have received a copy of the GNU General Public License
15 -- along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 pcall(require,"luarocks.loader")
18
19 std = require "std"
20 nixio = require "nixio"
21
22 pkgdir = nixio.getenv ("pkgdir")
23 homedir = nixio.getenv ("HOME")
24
25 --PLANNED tag coversion, import from one exported form, output another (xattr -> git annex, ...) this needs to disable most standard rules
26
27 dofile(pkgdir.."/metatag_doc.lua")
28
29 DOC.version = "metatag 0.1"
30 DOC._usage = [[
31 Metadata extraction utility
32
33 Usage: metatag [options..] [paths..]
34
35 metatag extracts metadata from files, with an easy extensible framework.
36 ]]
37
38
39 DOC._bugs = "For question and bug reports contact <ct@pipapo.org>."
40
41
42 DOC._options_summary = [[
43 -l, --load=file                    load a lua file (additional engines, rules, exporters)
44 -e, --lua=code                     execute inline lua code
45 -v, --verbose                      verbose messages
46 -d, --debug                        debugging messages
47 -r, --recursive                    recurse into subdirs
48 -x, --exclude=pattern              exclude pattern from checking
49 -o, --export=exporter[:specs..]..  enable exporters
50 -O, --option=key[[-]=value],..     generic options
51     --no-buildinrules              do not load buildin rules
52     --no-configfiles               do not load default configfiles
53 -n, --dry-run                      don't process files
54 -q, --quiet                        be silent
55 -V, --version                      display version information, then exit
56 -h, --help                         display this help, then exit
57 -t, --help-topic=topic             display long help about topic
58     --help-list                    list available help topicss
59 ]]
60
61
62 DOC._optparse = [[
63 %version%
64
65 %_usage%
66 Options:
67   %_options_summary%
68 Try 'metatag -t manual' to read the manual.
69 ]]
70
71
72
73
74
75 --
76 -- tool functions
77 --
78 DOC.functions = DOC.functions.."\n"..[[
79  sh(cmd)
80   execute a shell command, return all its output in one string.
81 ]]
82 function sh(cmd)
83    vmsg("sh: '".. cmd.."'")
84    local c = io.popen (cmd)
85    local r = c:read("*a") or error("failed command: '"..cmd.."'")
86    c:close()
87    return r
88 end
89
90
91 DOC.functions = DOC.functions.."\n"..[[
92  shexists(cmd)
93   checks if 'cmd' is in $PATH, returns true or false
94 ]]
95 local shexists_cache = {}
96 function shexists(cmd)
97    if shexists_cache[cmd] == nil then
98       if os.execute ("which "..shquote(cmd)..">/dev/null") == 0 then
99          shexists_cache[cmd] = true
100       else
101          shexists_cache[cmd] = false
102       end
103    end
104    return shexists_cache[cmd]
105 end
106
107 DOC.functions = DOC.functions.."\n"..[[
108  page(text)
109   lets the user interactively view the given text.
110   For that a pager (less, more) is invoked or if not available
111   the text is just written to the standard output.
112 ]]
113 function page(text)
114    if shexists("less") then
115       local pager = io.popen("LC_ALL= less -FX", "w")
116       pager:write(text)
117       io.close(pager)
118    elseif shexists("more") then
119       local pager = io.popen("more -d", "w")
120       pager:write(text)
121       io.close(pager)
122    else
123       io.write(text)
124       io.write("\n")
125    end
126 end
127
128
129 -- quote a string for shell use, uses singlequotes and excapes them with a backslash
130 DOC.functions = DOC.functions.."\n"..[[
131  shquote(s)
132   returns 's' singlequoted and escaped for shell usage
133 ]]
134 function shquote (s)
135    return "'"..string.gsub (tostring(s), "'", "'\\''").."'"
136 end
137
138
139 DOC.functions = DOC.functions.."\n"..[[
140  mktable (t)
141   returns a table, when t is a scalar type then it will be wraped as table {t},
142   when it is already a table, t is returned
143 ]]
144 function mktable (t)
145    return (type(t) == "table") and t or {t}
146 end
147
148 DOC.functions = DOC.functions.."\n"..[[
149  dofileonce(f)
150   ensures that file 'f' is loaded only once, even if symlinked or hardlinked
151 ]]
152 local once_registry = {}
153 function dofileonce(f)
154    local stat = nixio.fs.stat (f)
155    if stat and stat.type == "reg" then
156       local id = stat.dev..":"..stat.ino
157       if not once_registry[id] then
158          once_registry[id] = true
159          return dofile(f)
160       end
161    end
162 end
163
164
165
166 local function nmsg (...) end
167 local function mmsg (...)
168    local args={...}
169    for i=1,#args do
170       io.stderr:write(tostring(args[i]))
171       if i < #args then
172          io.stderr:write("\t")
173       end
174    end
175    io.stderr:write("\n")
176 end
177
178 args, opts = std.optparse(getDOC "_optparse"):parse (arg)
179
180 if opts.quiet then
181    opts.verbose = false
182    opts.debug = false
183 end
184 if opts.debug then
185    opts.verbose = true
186 end
187
188 DOC.functions = DOC.functions.."\n"..[[
189  msg(...)
190   prints varargs on stderr unless --quiet is selected
191
192  vmsg(...)
193   prints varargs on stderr when --verbose or --debug is selected
194
195  dmsg(...)
196   prints varargs on stderr when --debug is selected
197 ]]
198 msg = not opts.quiet and mmsg or nmsg
199 vmsg = opts.verbose and mmsg or nmsg
200 dmsg = opts.debug and mmsg or nmsg
201
202
203 DOC._metadata = [[
204 Metadata for a file is stores and manipulated through a Metadata object.
205 Engines get this passed as first parameter.
206
207 Following methods are available:
208
209  metadata:Stop()
210   deletes all metadata, aborts processing of this file
211
212  metadata:Op(operation)
213   'operation' must be a string expression describing how to manipulate metadata.
214   Metadata is manipulated in 'field=value' pairs, values are a ordered list, so multiple
215   values can be stored under one key (if supported by the exporter)
216
217   Following operations are available:
218    field=value
219     sets metadata (erasing/overwriting the existing metadata)
220
221    field+=value
222     adds another value to the metadata field
223
224    field?=value
225     sets metadata when field does not already exist
226
227    field-=value
228     removes all matching metadata. field and value can be a lua pattern
229     and need to escaped properly
230
231    field:=value -> newvalue
232     replace value with newvalue. field and value must match (no patterns).
233     the spaces around the " -> " are mandatory
234 ]]
235 local Metadata = std.object {
236    _type = "Metadata",
237
238    New =
239       function (self, meta, file)
240          return self {
241             metadata = {},
242             done = {},
243             meta = meta,
244             file = file
245          }
246       end,
247
248    Stop =
249       function (self)
250          self.metadata = nil
251       end,
252
253    Op =
254       function (self, meta)
255          if not self.metadata then
256             return
257          end
258          assert(type(meta,"string"))
259
260          -- operators:
261          --       field=value      sets metadata (erasing/overwriting the existing metadata)
262          --       field+=value     adds another values to the metadata field
263          --       field?=value     sets metadata when field does not exist
264          --       field-=pattern   removes all matching metadata, field can be a pattern
265          --       field:=value:newvalue     replace value with newvalue
266          local k,op,v = meta:match("^(.-)([:?+-]?=)(.*)")
267
268          local added = false
269
270          -- add/remove/modify metadata
271          if (op == "=") then
272             dmsg("  add metadata: ".. shquote(k.."="..v))
273             if self.metadata[k] then
274                table.insert(self.metadata[k], v)
275             else
276                self.metadata[k] = {v}
277             end
278             added = true
279          elseif (op == "+=") then
280             if self.metadata[k] then
281                local found = false
282                for i=1,#self.metadata[k] do
283                   if self.metadata[k][i] == v then
284                      found = true
285                   end
286                end
287                if not found then
288                   dmsg("  append metadata: ".. shquote(k.."="..v))
289                   table.insert (self.metadata[k], v)
290                   added = true
291                end
292             else
293                dmsg("  add metadata: ".. shquote(k.."="..v))
294                self.metadata[k] = {v}
295                added = true
296             end
297          elseif (op == "?=") then
298             if not self.metadata[k] then
299                dmsg("  defalt metadata: ".. shquote(k.."="..v))
300                self.metadata[k] = {v}
301             end
302          elseif (op == ":=") then
303             if self.metadata[k] then
304                local ov,nv = v:match("^(.-) %-> (.*)")
305                for i=1,#self.metadata[k] do
306                   if self.metadata[k][i] == ov then
307                      dmsg("  replace metadata: ".. shquote(k.."="..v))
308                      self.metadata[k][i] = nv
309                      v = nv
310                      added = true
311                   end
312                end
313             end
314          elseif (op == "-=") then
315             local nm = {}
316             for mk,mvs in pairs(self.metadata) do
317                local nvs = {}
318                if not mk:match(k) then
319                   nvs = mvs
320                else
321                   for i=1,#mvs do
322                      if not mvs[i]:match(v) then
323                         table.insert (nvs, mvs[i])
324                      else
325                         dmsg("  remove metadata: ".. shquote(k.."-="..v))
326                      end
327                   end
328                end
329
330                if #nvs > 0 then
331                   nm[mk] = nvs
332                end
333
334             end
335             self.metadata = nm
336          else
337             error ("unknown operator: "..shquote(op))
338          end
339
340          -- added new metadata, may trigger new rules
341          if added then
342             local rules = self.meta.rules
343             for i=1,#rules do
344
345                -- operators:
346                --       field=value      field and value must match
347                --       field~=value     field must match, value must not match
348                local kp,op,vp = rules[i].match:match("^(.-)(~?=)(.*)")
349
350                if k:match(kp) then
351                   local matched
352                   if op == "=" then
353                      matched = v:match(vp)
354                   elseif op == "~=" then
355                      matched = not v:match(vp)
356                   else
357                      error ("illegal op: "..shquote(op))
358                   end
359
360                   if matched then
361                      dmsg(" matched: " .. rules[i].match)
362                      local calls = rules[i].call_engines
363                      for j=1,#calls do
364                         local call,opts = string.match(calls[j], "([^:]*):?(.*)")
365                         if not self.done[call] then
366                            self.done[call] = true
367                            if self.meta.engines[call] then
368                               dmsg("calling: " .. call)
369                               for engine=1,#self.meta.engines[call] do
370                                  local ret = self.meta.engines[call][engine] (self, self.file, opts, k, v)
371                                  if ret == "break" then
372                                     return
373                                  elseif ret == "multi" then
374                                     self.done[call] = nil
375                                  end
376                               end
377                            else
378                               msg("unknown engine: " .. call)
379                            end
380                         end
381                      end
382                   end
383                end
384             end
385          end
386       end
387 }
388
389
390
391
392 local Rule = std.object {
393    _type = "Rule",
394
395    New =
396       function (self, match, ...)
397          local rule = self {
398             match = match,
399             call_engines = {},
400          }
401
402          local args = {...}
403          for i=1,#args do
404             rule:AddCall (args[i])
405          end
406
407          return rule
408       end,
409
410    AddCall =
411       function (self, call)
412          dmsg("  will Call: "..tostring(call))
413          table.insert (self.call_engines, call)
414       end
415 }
416
417
418 DOC._meta = [[
419 'Meta' is the only global exported object. It provids methods to construct Rules, Engines and Exporters.
420
421  Meta:Rule(match, engines...)
422   Register a rule
423   parameters:
424
425    match
426     must be a pattern matching against metadata in the 'field=value' or `field~=value` notation.
427     `field` and `value` are lua patterns. When the operator is `=` then field and value must match
428     some metadata to trigger the rule. When the operator is '~=' then
429     field must match and value must not match to trigger the rule
430
431    engines
432     a list of engine functions or names thereof with posibly colon delimited options of engines to call
433
434    Example:
435    invoke the 'mime_type' engine on every 'regular' file with "test" as option and a custom function
436
437     Meta:Rule (
438       "stat%.type=reg",
439       "mime_type:test",
440     )
441
442  Meta:Exclude(match)
443   Abort processing of the current file when metadata maches.
444   Just a shortcut for `Meta:Rule(match, "stop")`, see above
445
446  Meta:Engine(name, func)
447   Registers `func` as engine under `name`. When an engine is invoked its called with the following
448   parameters:
449    function (meta, file, opts, key, value)
450
451      meta
452       a Metadata object which provides the Metadata:Op(meta) and Metadata:Stop() methods.
453       the engine uses Op() to manipulate metadata and Stop() to abort processing on this file.
454
455      file
456       the full filename of the current file
457
458      opts
459       string with the options part passed from the rule defition
460
461      key, value
462       the field and value which triggered the activation of this engine
463
464  Meta:Export(namespec, func)
465   Registers a function as exporter.
466
467    namespec
468     is the name of the exporter optionally followed by a colon separated list of filters what metadata
469     should be exported. See -o/--export option for details.
470
471    func
472     is a function taking following parameters:
473      function (file, meta)
474
475        file
476         the filename of the current file
477        meta
478         a associative table with field=values relations. Values are stored in a indexed table.
479         The metadata here is already filterd according to the filter specs
480 ]]
481 Meta = std.object {
482    _type = "Meta",
483
484    engines = {},
485    rules = {},
486    exporters = {},
487    export = {},
488    export_specs = {},
489
490    Engine =
491       function (self, name, func)
492          vmsg("adding Engine: "..shquote(name))
493          if not self.engines[name] then
494             self.engines[name] = {func}
495          else
496             table.insert (self.engines[name], func)
497          end
498       end,
499
500    Rule =
501       function (self, match, ...)
502          assert(type(match,"string"))
503          vmsg("adding Rule: "..shquote(match))
504          table.insert (self.rules, Rule:New (match, ...))
505       end,
506
507    Exclude =
508       function (self, match)
509          assert(type(match,"string"))
510          self:Rule (
511             match,
512             "stop"
513          )
514       end,
515
516    Export =
517       function (self, spec, func)
518          assert(type(spec,"string"))
519
520          local name,specs = string.match(spec, "([^:]*)(:?.*)")
521          vmsg("adding Exporter: "..shquote(name))
522          self.exporters[name] = {func = func, specs = specs}
523       end,
524
525    Process =
526       function (self, file)
527          assert(type(file)=='string')
528          file=file:match("(.-)/?$")
529          msg("processing file: ".. shquote(file))
530
531          local m = Metadata:New (self, file)
532
533          -- run "PRE" engines if exist
534          if self.engines.PRE then
535             dmsg("preprocessing: ")
536             for k=1,#self.engines.PRE do
537                self.engines.PRE[k] (m, file)
538             end
539          end
540
541          m:Op("/="..file)
542
543          -- run "POST" engines if exist
544          if self.engines.POST then
545             dmsg("postprocessing: ")
546             for k=1,#self.engines.POST do
547                self.engines.POST[k] (m, file)
548             end
549          end
550
551          -- run the exporters
552          if m.metadata and next(m.metadata) ~= nil then
553             local meta = m.metadata
554             for name,exporter in pairs(self.exporters) do
555                if self.export[name] then
556                   local export_specs = self.export_specs[name] .. exporter.specs .. ":"
557                   local export_meta = {}
558
559                   for k,vs in pairs(m.metadata) do
560                      for i=1,#vs do
561                         local comp = k.."="..vs[i]
562
563                         for spec in string.gmatch(export_specs, ":([^:]*)") do
564                            local _, inv, str = string.match(spec, "(([-!]?)(.*))")
565                            if string.match(comp, str) then
566                               if inv == "-" then
567                                  break
568                               end
569                               if not export_meta[k] then
570                                  export_meta[k] = {}
571                               end
572                               table.insert(export_meta[k], vs[i])
573                               break
574                            end
575                         end
576                      end
577                   end
578                   if next(export_meta) then
579                      exporter.func(file, export_meta)
580                   end
581                end
582             end
583          end
584       end,
585
586 }
587
588 default_exporter = {"print"}
589
590 ---
591 --- optarg handling, pre loading
592 ---
593 do
594    options = {}
595    local option = mktable (opts["option"])
596
597    if opts["recursive"] then
598       table.insert(option, "recursive")
599    end
600
601    local option2 = {}
602    for i=1,#option do
603       option2 = std.list.concat(option2, std.string.split(option[i], ","))
604    end
605    for i=1,#option2 do
606       local k,op,v = string.match(option2[i], "([^-=]*)([-=]?)(.*)")
607       dmsg("option:", k, op, v)
608       if op == "-" then
609          options[k] = nil
610       elseif op == "=" and v ~= "" then
611          options[k] = v
612       else
613          options[k] = true
614       end
615    end
616
617    local exclude = mktable (opts["exclude"])
618    local exclude2 = {}
619    for i=1,#exclude do
620       exclude2 = std.list.concat(exclude2, std.string.split(exclude[i], ","))
621    end
622    for i=1,#exclude2 do
623       dmsg("exclude:", exclude2[i])
624       Meta:Exclude (exclude2[i])
625    end
626
627    local luacode = mktable (opts["lua"])
628    for i=1,#luacode do
629       msg("exec:", luacode[i])
630       assert(loadstring(luacode[i]))()
631    end
632
633    local luafile = mktable (opts["load"])
634    for i=1,#luafile do
635       dmsg("load:", luafile[i])
636       dofile(luafile[i])
637    end
638 end
639
640 DOC.configfiles = [[
641 metatag tries to load "$HOME/.metatag.lua", "$HOME/.config/metatag.lua" and "./.metatag.lua". These files
642 can define extra rules, engines and exporters. This files are loaded before the rules, engines and exporters
643 shipped with the program to allow one to override rules by custom configuration.
644 ]]
645
646 if not opts.no_configfiles then
647    dofileonce(homedir.."/.metatag.lua")
648    dofileonce(homedir.."/.config/metatag.lua")
649    dofileonce(".metatag.lua")
650 end
651
652 if not opts.no_buildinrules then
653    dofile(pkgdir.."/metatag_rules.lua")
654 end
655 dofile(pkgdir.."/metatag_engines.lua")
656 dofile(pkgdir.."/metatag_exporters.lua")
657
658 ---
659 --- optarg handling, post loading
660 ---
661 do
662    if opts["help_topic"] then
663       pageDOC(opts["help_topic"])
664       os.exit(0)
665    end
666
667    if opts["help_list"] then
668       page("Following help topics are available:\n"..listDOC().."Try 'metatag -t <topic>' to read the respective topic.")
669       os.exit(0)
670    end
671
672    local exporter = mktable (opts["export"])
673    local exporter2 = {}
674    for i=1,#exporter do
675       exporter2 = std.list.concat(exporter2, std.string.split(exporter[i], ","))
676    end
677    if #exporter2 == 0 then
678       exporter2 = default_exporter
679    end
680    for i=1,#exporter2 do
681       dmsg("export:", exporter2[i])
682       local name,specs = string.match(exporter2[i], "([^:]*)(:?.*)")
683       Meta.export[name] = true
684       Meta.export_specs[name] = specs
685    end
686
687    if not next(args) then
688       page(getDOC("_usage").."Try 'metatag -h' for help.")
689       os.exit(0)
690    end
691 end
692
693
694 if not opts.dry_run then
695    for i=1,#args do
696       --PLANNED use a queue and N threads/processes
697       Meta:Process(args[i])
698    end
699 end
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718