Save and restore global and/or buffer command history.
Author: nils_2
— Version: 0.9.0
— License: GPL-3.0-or-later
For WeeChat ≥ 0.4.0.
Tags: history, py2, py3
Added: 2012-12-18
— Updated: 2026-10-01
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 | # SPDX-FileCopyrightText: 2012-2026 nils_2@libera.#weechat # # SPDX-License-Identifier: GPL-3.0-or-later # # 2026-09-29 # 0.9.0 : revised version (security and robustness fixes) # : fix: negative value for option 'number' truncated the history file # and raised an exception # : fix: IndexError for one-character lines in mode "text" # : fix: options 'min_length' and 'number' are validated # : fix: restore honours localvar 'save_history' / 'save_buffer' # (condition in read_history() was never true) # : fix: option 'skip_double' is really case sensitive (as documented) # : security: buffer names are sanitized before they are used as # file names (no path traversal); legacy file names are still read # : security: files are written with mode 0600, atomically # (temporary file + rename), symlinks are not followed # : security: invalid regex in option 'pattern' -> nothing is saved # (fail closed); pattern is compiled only once # : security: broader default value for option 'pattern' (new # installations only, existing settings are not changed) # : files are read and written as UTF-8; empty and multi-line # entries are skipped # : one failing buffer does not abort saving of all other buffers # : infolists are always freed # : add option 'save_free': buffers with free content (fset, color, # secure, script, ...) are only saved/restored if it is "on" # : entries typed in free-content buffers are also removed from the # global history if 'save_free' is "off" # : requires WeeChat >= 3.2 (string_eval_path_home), Python 3 only # : add SPDX copyright and license tags # # 2021-05-02, Sébastien Helleu <flashcode@flashtux.org> # 0.8.2 : add compatibility with WeeChat >= 3.2 (XDG directories) # # 2018-08-04: nils_2 (freenode.#weechat) # 0.8 : add option 'save_buffer' # : thanks catbeard for revise the help text # # 2018-08-01: nils_2 (freenode.#weechat) # 0.7 : rmodifier routine removed # : bug fixes # # 2017-12-14: Sébastien Helleu <flashcode@flashtux.org> # 0.6 : rename command "/autosetbuffer" by "/buffer_autoset" in example # # 2015-04-05: nils_2 (freenode.#weechat) # 0.5 : change priority of hook_signal('buffer_opened') to 100 # # 2013-01-25: nils_2 (freenode.#weechat) # 0.4 : make script compatible with Python 3.x # # 2013-01-20: nils_2, (freenode.#weechat) # 0.3: fix wrong command argument in help-text # # 2012-12-21: nils_2, (freenode.#weechat) # 0.2 : fix UnicodeEncodeError # # 2012-12-09: nils_2, (freenode.#weechat) # 0.1 : initial release # # thanks to nestib for help with regex and for the rmodifier idea # # requires: WeeChat version 3.2 # # Development is currently hosted at # https://github.com/weechatter/weechat-scripts import hashlib import io import os import re import sys from collections import Counter from urllib.parse import quote try: import weechat except ImportError: print("This script must be run under WeeChat.") print("Get WeeChat now at: https://weechat.org/") sys.exit(1) SCRIPT_NAME = 'histman' SCRIPT_AUTHOR = 'nils_2 <weechatter@arcor.de>' SCRIPT_VERSION = '0.9.0' SCRIPT_LICENSE = 'GPL' SCRIPT_DESC = 'save and restore global and/or buffer command history' OPTION_DEFAULTS = { 'number' : ('0', 'number of history commands/text to save. A positive number will save from oldest to latest, a negative number will save from latest to oldest. 0 = save whole history (e.g. -10 will save the last 10 history entries)'), 'pattern' : ('(.*password|.*passphrase|.*nickserv|.*chanserv|.*authserv|/(oper|secure|identify)( |$)|/quote pass|/quit)', 'a simple regex (case insensitive, matched at the beginning of the line) to ignore commands/text. Empty value disables pattern matching. If the regex is invalid, nothing will be saved'), 'skip_double' : ('on', 'skip lines that already exist (case sensitive)'), 'save' : ('all', 'define what should be saved from history. Possible values are "command", "text", "all". This is a fallback option (see /help ' + SCRIPT_NAME + ')'), 'history_dir' : ('%h/history', 'local cache directory for history files ("%h" will be replaced by WeeChat data directory)'), 'save_global' : ('off', 'save global history, possible values are "command", "text", "all" or "off" (default: off)'), 'save_buffer' : ('off', 'save buffer history from all buffers, possible values are "on", "off". Using this option, localvar from buffer will be ignored (default: off)'), 'save_free' : ('off', 'save and restore history of buffers with free content (e.g. fset, color, secure, script), possible values are "on", "off". If "off", these buffers are never handled, even if localvar "save_history" is set or "save_buffer" is "on" (default: off)'), 'min_length' : ('2', 'minimum length of command/text (default: 2)'), 'buffer_close' : ('off', 'save command history, when buffer will be closed (default: off)'), } OPTIONS = {} HELP_TEXT = '\n'.join([ ' save: save the command history now (do not wait for /quit)', ' list: list the "save_history" settings made with buffer_autoset', '', 'What is saved, and when:', ' - On /quit the history of all selected buffers (and the global history, if enabled) is written', ' to files in the directory given by option "history_dir".', ' - With option "buffer_close" set to "on", the history of a buffer is also saved when the buffer is closed.', ' - When a buffer is opened again (or WeeChat starts), its history is restored from the file.', '', 'Which buffers are handled:', ' - buffers with the local variable "save_history" set (values: see below)', ' - all buffers, if option "save_buffer" is "on" (the local variable is then not required;', ' buffers without it use the value of option "save")', ' - the global history, if option "save_global" is not "off"', ' - buffers with free content (fset, color, secure, script, ...) only if option "save_free" is "on",', ' regardless of the settings above', '', 'Values for the local variable "save_history" (and for the options "save" and "save_global"):', ' command: save commands only (lines starting with a command char; "//text" counts as text)', ' text: save text only (e.g. messages sent to a channel)', ' all: save commands and text', '', 'A local variable is lost when WeeChat restarts. Use the command "/buffer setauto" to make it persistent', '(see examples below).', '', 'Lines that are not saved:', ' - lines shorter than option "min_length"', ' - lines matching the regex in option "pattern" (case insensitive, matched from the beginning of the line)', ' - duplicates, if option "skip_double" is "on" (case sensitive)', ' - empty and multi-line entries', 'Option "number" limits the amount: a positive value N saves the first (oldest) N entries, a negative', 'value saves the last N entries, 0 saves everything.', '', 'Security:', ' History files are written with mode 0600. The option "pattern" is a blacklist and can not catch every', ' secret; if the regex is invalid, nothing is saved. Be careful with "save_buffer" and "save_free".', '', 'Examples:', ' save the command history manually (e.g. from a /trigger or a cron job):', ' /' + SCRIPT_NAME + ' save', ' save and restore only the text typed in buffer #weechat on libera:', ' /command -buffer irc.libera.#weechat core /buffer setauto localvar_set_save_history text', ' save and restore only the commands typed in current buffer:', ' /buffer setauto localvar_set_save_history command', ]) FILENAME_GLOBAL_HISTORY = 'global_history' POSSIBLE_SAVE_OPTIONS = ('command', 'text', 'all') MAX_FILENAME_LEN = 200 # cache for the compiled 'pattern' option _filter = {'source': None, 'regex': None, 'valid': True} # history entries of free-content buffers that were closed in this session # (they stay in the global history, so they must be excluded from it) _closed_free_lines = Counter() # ==================================[ helpers ]=================================== def report(message): weechat.prnt('', '%s%s: %s' % (weechat.prefix('error'), SCRIPT_NAME, message)) def info(message): weechat.prnt('', '%s: %s' % (SCRIPT_NAME, message)) def opt_int(name, default): try: return int(str(OPTIONS.get(name, default)).strip()) except ValueError: return default def opt_on(name): return str(OPTIONS.get(name, '')).strip().lower() == 'on' def compile_filter(): source = OPTIONS.get('pattern', '') _filter['source'] = source _filter['regex'] = None _filter['valid'] = True if source != '': try: _filter['regex'] = re.compile(source, re.I) except re.error as e: _filter['valid'] = False report('invalid regular expression in option "pattern" (%s). ' 'Nothing will be saved until this is fixed.' % e) def get_filter(): if _filter['source'] != OPTIONS.get('pattern', ''): compile_filter() return _filter['regex'], _filter['valid'] # ===================================[ paths ]==================================== def get_history_dir(): options = {'directory': 'data'} return weechat.string_eval_path_home(OPTIONS['history_dir'], {}, {}, options) def config_create_dir(): path = get_history_dir() if not os.path.isdir(path): os.makedirs(path, mode=0o700, exist_ok=True) try: os.chmod(path, 0o700) # do not depend on umask except OSError: pass def inside_dir(path, base): """True if the (resolved) path is a direct child of base.""" return os.path.dirname(os.path.realpath(path)) == base def history_path(name): """Return a safe path for the history file 'name' (raises ValueError).""" base = os.path.realpath(get_history_dir()) safe = quote(name, safe='#@+', errors='replace') if len(safe) > MAX_FILENAME_LEN: digest = hashlib.sha1(name.encode('utf-8', 'replace')).hexdigest()[:12] safe = safe[:MAX_FILENAME_LEN - 20] + '~' + digest path = os.path.join(base, safe) if not inside_dir(path, base): raise ValueError('invalid history file name: %r' % name) return path def resolve_read_path(name): """Return path of an existing history file or None.""" try: path = history_path(name) except ValueError: return None if os.path.isfile(path): return path # fallback: file name as used by histman < 0.9.0 base = os.path.realpath(get_history_dir()) legacy = os.path.join(base, name) if os.path.isfile(legacy) and inside_dir(legacy, base): return legacy return None def buffer_filename(ptr_buffer): plugin = weechat.buffer_get_string(ptr_buffer, 'localvar_plugin') name = weechat.buffer_get_string(ptr_buffer, 'localvar_name') return '%s.%s' % (plugin, name) # ===============================[ collect history ]============================== def is_free_buffer(ptr_buffer): """True for buffers with free content (fset, color, secure, script, ...).""" return weechat.buffer_get_integer(ptr_buffer, 'type') == 1 def buffer_wanted(ptr_buffer): """Should the history of this buffer be saved?""" if is_free_buffer(ptr_buffer) and not opt_on('save_free'): return False if weechat.buffer_get_string(ptr_buffer, 'localvar_save_history'): return True return opt_on('save_buffer') def get_save_mode(ptr_buffer): """Return 'command', 'text', 'all' or '' (= save nothing).""" if ptr_buffer: mode = weechat.buffer_get_string(ptr_buffer, 'localvar_save_history').strip().lower() else: mode = str(OPTIONS.get('save_global', '')).strip().lower() if mode in POSSIBLE_SAVE_OPTIONS: return mode fallback = str(OPTIONS.get('save', '')).strip().lower() return fallback if fallback in POSSIBLE_SAVE_OPTIONS else '' def get_command_chars(): extra = weechat.config_string(weechat.config_get('weechat.look.command_chars')) or '' return '/' + extra def is_command(line, command_chars): # a valid command has at least two chars, first and second char differ ("//" = text) return len(line) > 1 and line[0] in command_chars and line[0] != line[1] def line_matches_mode(line, mode, command_chars): if mode == 'command': return is_command(line, command_chars) if mode == 'text': return not is_command(line, command_chars) return mode == 'all' def count_history_lines(ptr_buffer, counts): """Add the history entries of a buffer to the Counter 'counts'.""" infolist = weechat.infolist_get('history', ptr_buffer, '') if not infolist: return try: while weechat.infolist_next(infolist): counts[weechat.infolist_string(infolist, 'text')] += 1 finally: weechat.infolist_free(infolist) def get_free_buffer_lines(): """Entries typed in free-content buffers. WeeChat also stores them in the global history.""" counts = Counter(_closed_free_lines) infolist = weechat.infolist_get('buffer', '', '') if infolist: try: while weechat.infolist_next(infolist): ptr_buffer = weechat.infolist_pointer(infolist, 'pointer') if is_free_buffer(ptr_buffer): count_history_lines(ptr_buffer, counts) finally: weechat.infolist_free(infolist) return counts def get_buffer_history(ptr_buffer): """Return the lines to save (oldest first). Empty ptr_buffer = global history.""" lines = [] mode = get_save_mode(ptr_buffer) if not mode: return lines regex, valid = get_filter() if not valid: # fail closed return lines min_length = max(opt_int('min_length', 2), 1) skip_double = opt_on('skip_double') command_chars = get_command_chars() # global history: drop the entries that were typed in free-content buffers exclude = Counter() if not ptr_buffer and not opt_on('save_free'): exclude = get_free_buffer_lines() infolist = weechat.infolist_get('history', ptr_buffer, '') if not infolist: return lines seen = set() try: # WeeChat returns the newest entry first while weechat.infolist_next(infolist): line = weechat.infolist_string(infolist, 'text') if exclude[line] > 0: exclude[line] -= 1 continue if len(line) < min_length or not line.strip(): continue if '\n' in line or '\r' in line: # would corrupt the file format continue if not line_matches_mode(line, mode, command_chars): continue if regex is not None and regex.match(line): continue if skip_double: if line in seen: continue seen.add(line) lines.append(line) finally: weechat.infolist_free(infolist) lines.reverse() return lines def select_lines(lines): """Apply option 'number' to the list (oldest first).""" n = opt_int('number', 0) if n > 0: return lines[:n] if n < 0: return lines[n:] # the last abs(n) entries return lines # ===============================[ read/write files ]============================= def write_history(name, lines): lines = select_lines(lines) if not lines: return tmp = None try: config_create_dir() path = history_path(name) tmp = path + '.tmp' flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, 'O_NOFOLLOW', 0) fd = os.open(tmp, flags, 0o600) with io.open(fd, 'w', encoding='utf-8', errors='replace', newline='\n') as f: for line in lines: f.write(line + '\n') f.flush() os.fsync(f.fileno()) os.replace(tmp, path) tmp = None except Exception as e: report('error writing history "%s": %s' % (name, e)) finally: if tmp is not None: try: os.remove(tmp) except OSError: pass def read_history(name, ptr_buffer): """Restore history from file. Empty ptr_buffer = global history.""" try: if ptr_buffer and is_free_buffer(ptr_buffer) and not opt_on('save_free'): return if ptr_buffer and not opt_on('save_buffer'): # is history saving enabled for this buffer? plugin = weechat.buffer_get_string(ptr_buffer, 'localvar_plugin') bname = weechat.buffer_get_string(ptr_buffer, 'localvar_name') # fallback: localvar may not be set yet (buffer_autoset not applied) autoset = weechat.config_get('buffer_autoset.buffer.%s.%s.localvar_set_save_history' % (plugin, bname)) if not autoset and not weechat.buffer_get_string(ptr_buffer, 'localvar_save_history'): return path = resolve_read_path(name) if not path: return hdata = weechat.hdata_get('history') if not hdata: return with io.open(path, 'r', encoding='utf-8', errors='replace') as f: for raw in f: line = raw.rstrip('\r\n') if not line.strip(): continue values = {'text': line} if ptr_buffer: values['buffer'] = ptr_buffer weechat.hdata_update(hdata, '', values) except Exception as e: report('error loading history "%s": %s' % (name, e)) def save_buffer_history(ptr_buffer): if not buffer_wanted(ptr_buffer): return lines = get_buffer_history(ptr_buffer) if lines: write_history(buffer_filename(ptr_buffer), lines) def save_history(): infolist = weechat.infolist_get('buffer', '', '') if infolist: try: while weechat.infolist_next(infolist): ptr_buffer = weechat.infolist_pointer(infolist, 'pointer') try: save_buffer_history(ptr_buffer) except Exception as e: # continue with the other buffers report('error saving buffer history: %s' % e) finally: weechat.infolist_free(infolist) if str(OPTIONS.get('save_global', 'off')).strip().lower() != 'off': try: lines = get_buffer_history('') # no buffer pointer = global history if lines: write_history(FILENAME_GLOBAL_HISTORY, lines) except Exception as e: report('error saving global history: %s' % e) # ===========================================[ Hooks() ]========================================== def create_hooks(): weechat.hook_signal('quit', 'quit_signal_cb', '') weechat.hook_signal('upgrade_ended', 'upgrade_ended_cb', '') # low priority for hook_signal('buffer_opened') to ensure that buffer_autoset hook_signal() runs first weechat.hook_signal('1000|buffer_opened', 'buffer_opened_cb', '') weechat.hook_config('plugins.var.python.' + SCRIPT_NAME + '.*', 'toggle_refresh', '') weechat.hook_signal('buffer_closing', 'buffer_closing_cb', '') def quit_signal_cb(data, signal, signal_data): save_history() return weechat.WEECHAT_RC_OK def buffer_opened_cb(data, signal, signal_data): read_history(buffer_filename(signal_data), signal_data) return weechat.WEECHAT_RC_OK def buffer_closing_cb(data, signal, signal_data): if signal_data: try: if is_free_buffer(signal_data) and not opt_on('save_free'): # remember the entries: they stay in the global history after the buffer is gone count_history_lines(signal_data, _closed_free_lines) elif opt_on('buffer_close'): save_buffer_history(signal_data) except Exception as e: report('error saving buffer history: %s' % e) return weechat.WEECHAT_RC_OK def upgrade_ended_cb(data, signal, signal_data): weechat.buffer_set(weechat.buffer_search_main(), 'localvar_set_histman', 'on') return weechat.WEECHAT_RC_OK def histman_cmd_cb(data, buffer, args): argv = args.strip().split(' ', 1) command = argv[0].lower() if command == 'save': save_history() elif command == 'list': weechat.command('', '/set *.localvar_set_save_history') else: weechat.command('', '/help %s' % SCRIPT_NAME) return weechat.WEECHAT_RC_OK # ================================[ weechat options & description ]=============================== def init_options(): for option, (default, description) in OPTION_DEFAULTS.items(): if not weechat.config_is_set_plugin(option): weechat.config_set_plugin(option, default) weechat.config_set_desc_plugin(option, '%s (default: "%s")' % (description, default)) OPTIONS[option] = weechat.config_get_plugin(option) compile_filter() def toggle_refresh(pointer, name, value): option = name[len('plugins.var.python.' + SCRIPT_NAME + '.'):] if option in OPTION_DEFAULTS: OPTIONS[option] = value if value is not None else OPTION_DEFAULTS[option][0] if option == 'pattern': compile_filter() return weechat.WEECHAT_RC_OK # ================================[ main ]=============================== if __name__ == '__main__': if weechat.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE, SCRIPT_DESC, '', ''): try: version = int(weechat.info_get('version_number', '') or 0) except ValueError: version = 0 weechat.hook_command(SCRIPT_NAME, SCRIPT_DESC, '[save] || [list]', HELP_TEXT, 'save %-' '|| list %-', 'histman_cmd_cb', '') if version >= 0x03020000: main_buffer = weechat.buffer_search_main() init_options() if weechat.buffer_get_string(main_buffer, 'localvar_histman') == 'on': # script was reloaded: history is already in memory, do not restore it twice create_hooks() info('script was already started in this session, history will not be restored again.') else: try: config_create_dir() except Exception as e: report('can not create history directory: %s' % e) # look for global_history if str(OPTIONS.get('save_global', 'off')).strip().lower() != 'off': read_history(FILENAME_GLOBAL_HISTORY, '') # core buffer is already open on script startup. Check manually! read_history(buffer_filename(main_buffer), main_buffer) create_hooks() # set localvar, to start script only once! weechat.buffer_set(main_buffer, 'localvar_set_histman', 'on') else: report('needs WeeChat version 3.2 or higher') weechat.command('', '/wait 1ms /python unload %s' % SCRIPT_NAME) |