gen-example-config.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. #!/usr/bin/env python3
  2. """Generate pisg.cfg.example: a complete, ready-to-use configuration.
  3. Every option comes from two sources so nothing can be missed or drift:
  4. * docs/pisg-doc.xml - name, description, documented default, grouping
  5. * modules/Pisg.pm - the real default value used by the code
  6. Options the old docs never mention (BotNicks, TableWidth, the bar image
  7. options) are described in EXTRA below.
  8. Usage: python3 docs/gen-example-config.py -> writes pisg.cfg.example
  9. Then: python3 docs/xml2html.py -> embeds it in pisg-doc.html
  10. """
  11. import re
  12. import textwrap
  13. import xml.etree.ElementTree as ET
  14. from pathlib import Path
  15. root_dir = Path(__file__).resolve().parent.parent
  16. xml_raw = (root_dir / "docs" / "pisg-doc.xml").read_text(encoding="utf-8")
  17. xml_raw = re.sub(r"^\s*<!--.*?-->\s*", "", xml_raw, count=1, flags=re.S)
  18. xml_raw = re.sub(r"<\?xml[^>]*\?>", "", xml_raw, count=1)
  19. xml_raw = re.sub(r"<!DOCTYPE.*?>", "", xml_raw, count=1, flags=re.S)
  20. tree = ET.fromstring(xml_raw)
  21. # ---- real defaults from the code ------------------------------------------------
  22. code = (root_dir / "modules" / "Pisg.pm").read_text(encoding="utf-8")
  23. block = code[code.index("$self->{cfg} = {"):code.index("version =>")]
  24. DEFAULTS = {}
  25. for m in re.finditer(r"^\s+(\w+)\s*=>\s*(?:'((?:[^'\\]|\\.)*)'|\"([^\"]*)\"|(-?\d+)|(\[\]))", block, re.M):
  26. val = next((g for g in m.groups()[1:] if g is not None), "")
  27. DEFAULTS[m.group(1).lower()] = "" if val == "[]" else val.replace("\\\\", "\\").replace("\\'", "'")
  28. # ---- options the old documentation does not cover --------------------------------
  29. EXTRA = {
  30. "BotNicks": "nicks of bots, for log formats that cannot tell bots from people (DCpp)",
  31. "TableWidth": "width in pixels of the statistics tables",
  32. "Pic_H_0": "image id of the horizontal bar for hours 0-5",
  33. "Pic_H_6": "image id of the horizontal bar for hours 6-11",
  34. "Pic_H_12": "image id of the horizontal bar for hours 12-17",
  35. "Pic_H_18": "image id of the horizontal bar for hours 18-23",
  36. "Pic_V_0": "image id of the vertical bar for hours 0-5",
  37. "Pic_V_6": "image id of the vertical bar for hours 6-11",
  38. "Pic_V_12": "image id of the vertical bar for hours 12-17",
  39. "Pic_V_18": "image id of the vertical bar for hours 18-23",
  40. }
  41. # ---- choices for the example ---------------------------------------------------------
  42. # Recommended values that differ from pisg's stock defaults, with the reason.
  43. RECOMMENDED = {
  44. "ColorScheme": ("modern", "modern, light/dark theme; try midnight, amoled, terminal, or default"),
  45. "Charset": ("utf-8", "UTF-8 shows accents, emoji and non-Latin scripts correctly"),
  46. "NickTracking": ("1", "follow nick changes so Alice, Alice_ and Alice- count as one person"),
  47. "DailyActivity": ("14", "show the last 14 days as a bar chart"),
  48. "ShowWords": ("1", "also show total words"),
  49. "ShowWpl": ("1", "also show words per line"),
  50. "ShowCpl": ("1", "also show characters per line"),
  51. "ShowMostNicks": ("1", "show who changed nick most often"),
  52. "ShowSmileys": ("1", "show the most used smileys"),
  53. "ShowKarma": ("1", "show karma (nick++ / nick--)"),
  54. "ShowMostActiveByHour": ("1", "show the most active nicks by time of day"),
  55. "TopicHistory": ("5", "show the last 5 topics"),
  56. }
  57. # Options that are alternatives, need a file, or are off/empty by default. They stay
  58. # commented out with an example value so the file still works exactly as it stands.
  59. COMMENTED = {
  60. "LogDir": ("/path/to/logs/", "use INSTEAD of Logfile to read a whole folder of dated logs"),
  61. "LogPrefix": ("example.log.", "with LogDir: only read files starting with this"),
  62. "LogSuffix": (".%d%b%Y", "with LogDir: date format at the end of the file names, so they sort by date"),
  63. "NFiles": ("30", "with LogDir: only parse the newest N files (0 = all)"),
  64. "OutputTag": ("-week", "replaces %t in OutputFile; used with the -nf / -t command line options"),
  65. "AltColorScheme": ("midnight.css amoled.css", "extra stylesheets the visitor can switch to"),
  66. "PageHead": ("header.html", "HTML file inserted above the statistics"),
  67. "PageFoot": ("footer.html", "HTML file inserted below the statistics"),
  68. "CacheDir": ("cache/", "cache parsed logs here to speed up runs (delete it when you change settings)"),
  69. "IgnoreWords": ("badword otherword", "words to leave out of the word statistics"),
  70. "BadUrls": ("imagetwist imgur.com postimg.cc/*", "matched anywhere in a URL, any case; * = any characters, ? = one; for spam and unwanted image hosts"),
  71. "DefaultPic": ("images/nobody.png", "picture shown for users who have none"),
  72. "ImagePath": ("images/", "folder of user pictures, as seen by the web page"),
  73. "ImageGlobPath": ("/var/www/pisg/images/", "folder of user pictures, as seen by pisg (for pic=\"x_*.jpg\" globs)"),
  74. "PicWidth": ("55", "show every user picture this wide, in pixels"),
  75. "PicHeight": ("55", "show every user picture this tall, in pixels"),
  76. "LogCharset": ("iso-8859-1", "convert logs from this charset (needs the Text::Iconv perl module)"),
  77. "LogCharsetFallback": ("iso-8859-1", "used for lines that are not valid in LogCharset (needs Text::Iconv)"),
  78. "BotNicks": ("bot1 bot2", "only needed for the DCpp log format"),
  79. "StatsDump": ("stats.dump", "debugging: dump the raw statistics to this file"),
  80. }
  81. # Options that live in the <channel> block (set per channel), in display order.
  82. CHANNEL_OPTS = ["Logfile", "LogDir", "LogPrefix", "LogSuffix", "NFiles", "Format", "Network",
  83. "OutputFile", "OutputTag", "Maintainer", "LogType"]
  84. CHANNEL_VALUES = {
  85. "Logfile": ("/path/to/example.log", "the log to read; you can list several Logfile lines"),
  86. "Format": ("eggdrop", "your log format: eggdrop, mIRC, xchat, irssi, ... (see docs/FORMATS)"),
  87. "Network": ("ExampleNet", "the IRC network, shown on the page"),
  88. "OutputFile": ("/var/www/html/example.html", "the page pisg writes"),
  89. "Maintainer": ("Your Name", "who is named as maintainer on the page"),
  90. "LogType": ("Logfile", "only \"Logfile\" exists, leave it"),
  91. }
  92. def parse_options():
  93. """[(section title, [(name, purpose, documented default)])] in document order."""
  94. out = []
  95. for ch in tree.findall("chapter"):
  96. items = []
  97. for r in ch.findall("refentry"):
  98. name = r.findtext("refnamediv/refname").strip()
  99. purpose = " ".join((r.findtext("refnamediv/refpurpose") or "").split())
  100. dflt = ""
  101. for rs in r.findall("refsect1"):
  102. if (rs.findtext("title") or "").strip() == "Default":
  103. dflt = " ".join("".join(rs.find("para").itertext()).split())
  104. for n in [x.strip() for x in name.split(",")]: # "HiCell, HiCell2"
  105. items.append((n, purpose, dflt))
  106. if items:
  107. out.append((ch.findtext("title").strip(), items))
  108. return out
  109. def comment(text, indent=""):
  110. return "\n".join(textwrap.wrap(text, 88, initial_indent=indent + "# ", subsequent_indent=indent + "# "))
  111. def value_for(name):
  112. if name in RECOMMENDED:
  113. return RECOMMENDED[name][0]
  114. v = DEFAULTS.get(name.lower(), "")
  115. return "1" if (name == "UserPics" and v == "y") else v
  116. lines = []
  117. add = lines.append
  118. add("""\
  119. # pisg.cfg.example - a complete configuration for pisg, ready to copy.
  120. #
  121. # 1. Copy this file to pisg.cfg
  122. # 2. Change the values marked EDIT (log path, format, network, output file, name)
  123. # 3. Run ./pisg
  124. #
  125. # Every option pisg understands is listed below with its meaning. Options are set to
  126. # pisg's own default unless the comment says "recommended". Lines starting with # are
  127. # comments; an option shown commented out is off/unset - remove the # to use it.
  128. #
  129. # Syntax reminders:
  130. # <set Name="value"> a global option, applies to every channel
  131. # <channel="#name"> ... </channel> settings for one channel (they override <set>)
  132. # <user nick="..." ...> per-user settings
  133. # Each <set> must be on one line, and every value must be in quotes.
  134. """)
  135. sections = parse_options()
  136. documented = {n for _, items in sections for n, _, _ in items}
  137. documented |= set(CHANNEL_VALUES)
  138. add("")
  139. add("#" * 78)
  140. add("# GLOBAL OPTIONS - apply to every channel below")
  141. add("#" * 78)
  142. seen = set(CHANNEL_OPTS) | {"Channel"}
  143. emitted = set()
  144. for title, items in sections:
  145. body = [(n, p, d) for n, p, d in items if n not in seen]
  146. if title.startswith("General"):
  147. title = "General options"
  148. if not body:
  149. continue
  150. add("")
  151. add(f"# ---- {title} " + "-" * max(4, 70 - len(title)))
  152. for name, purpose, dflt in body:
  153. emitted.add(name)
  154. add("")
  155. if name in RECOMMENDED:
  156. add(comment(f"{purpose} - recommended: {RECOMMENDED[name][1]}"))
  157. elif name in COMMENTED:
  158. add(comment(f"{purpose} - {COMMENTED[name][1]}"))
  159. else:
  160. add(comment(purpose))
  161. if name in COMMENTED:
  162. add(f'#<set {name}="{COMMENTED[name][0]}">')
  163. else:
  164. add(f'<set {name}="{value_for(name)}">')
  165. if name == "HiCell": # documented together with HiCell2
  166. add(f'<set HiCell2="{DEFAULTS["hicell2"]}">')
  167. emitted.add("HiCell2")
  168. # options that exist in the code but not in the old docs
  169. add("")
  170. add("# ---- Advanced (not in the original documentation) " + "-" * 25)
  171. for name, purpose in EXTRA.items():
  172. add("")
  173. add(comment(purpose))
  174. if name in COMMENTED:
  175. add(f'#<set {name}="{COMMENTED[name][0]}">')
  176. else:
  177. add(f'<set {name}="{value_for(name)}">')
  178. emitted.add(name)
  179. add("")
  180. add("")
  181. add("#" * 78)
  182. add("# YOUR CHANNEL - copy this block for each extra channel")
  183. add("#" * 78)
  184. add("")
  185. add('<channel="#example">')
  186. by_name = {n: (p, d) for _, items in sections for n, p, d in items}
  187. for name in CHANNEL_OPTS:
  188. purpose = by_name[name][0] if name in by_name else ""
  189. if name in CHANNEL_VALUES:
  190. val, note = CHANNEL_VALUES[name]
  191. add(comment(f"{name}: {note}" + (" EDIT" if name in ("Logfile", "Format", "Network", "OutputFile", "Maintainer") else ""), " "))
  192. add(f' {name}="{val}"')
  193. else:
  194. val, note = COMMENTED[name]
  195. add(comment(f"{name}: {note}", " "))
  196. add(f' #{name}="{val}"')
  197. add("")
  198. add(comment("Any global option can be overridden for this channel only, for example:", " "))
  199. add(' #Lang="FR"')
  200. add(' #ColorScheme="midnight"')
  201. add("</channel>")
  202. add("""
  203. ##############################################################################
  204. # USERS - link nicks together, add pictures, mark bots
  205. ##############################################################################
  206. # nick the name shown in the stats (required)
  207. # alias other nicks of the same person, space separated; * matches anything
  208. # (Joe* also counts Joe_, Joe^away ...). NickTracking="1" also finds many
  209. # pic picture shown next to the user bigpic larger picture it links to
  210. # link a web address or e-mail address sex m, f or b (bot)
  211. # ignore y = leave this nick out of the stats altogether (for bots)
  212. <user nick="Alice" alias="Alice_ Alice-* AliceAway" pic="alice.png" link="https://example.com/alice" sex="f">
  213. <user nick="Bob" alias="Bob_ Bobby" pic="bob.png" bigpic="bob-big.png" link="bob@example.com" sex="m">
  214. <user nick="ChanBot" sex="b" ignore="y">
  215. <user nick="Seb" alias="Seb- Seb_" pic="https://r2.fivemanage.com/X8I0LGoLdHY2Wx9DdTrvx/Pics/me.png" sex="m" link="https://dooubletap.github.io/">
  216. ##############################################################################
  217. # LINKS - keep addresses out of "Most referenced URLs"
  218. ##############################################################################
  219. <link url="https://example.com/spam" ignore="y">
  220. ##############################################################################
  221. # INCLUDE - share users between channels or config files
  222. ##############################################################################
  223. # Put your <user> lines in users.cfg and load them here (an included file cannot
  224. # include another file):
  225. #<include="users.cfg">
  226. """)
  227. text = "\n".join(lines).rstrip() + "\n"
  228. # completeness check: every documented option + every code default a user can set
  229. internal = {"configfile", "cchannels", "modules_dir", "channel"}
  230. present = {m.lower() for m in re.findall(r'^#?\s*<set (\w+)=', text, re.M)}
  231. present |= {m.lower() for m in re.findall(r'^\s+#?(\w+)="', text, re.M)}
  232. missing = sorted(k for k in DEFAULTS if k not in present and k not in internal)
  233. assert not missing, f"options missing from the example: {missing}"
  234. (root_dir / "pisg.cfg.example").write_text(text, encoding="utf-8")
  235. print(f"wrote pisg.cfg.example: {len(text.splitlines())} lines, "
  236. f"{len(present)} options ({len(DEFAULTS)} defaults in code)")