diff --git a/hyrolo.py b/hyrolo.py new file mode 100644 index 00000000..661c8df5 --- /dev/null +++ b/hyrolo.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python +# +# Summary: hyrolo.py --- Output file header and matching entries from HyRolo files via the command-line +# Usage: [ ... ] +# If no files are given, uses the env variable, HYROLO, or if that is not found, the file +# "~/.rolo.otl". +# +# Author: Bob Weiner +# +# Orig-Date: 1-Apr-24 at 01:45:27 +# Last-Mod: 15-Apr-24 at 00:04:58 by Bob Weiner +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Copyright (C) 2024 Free Software Foundation, Inc. +# See the "HY-COPY" file for license information. +# +# This file is part of GNU Hyperbole. + +# Commentary: +# See the Info manual "(hyperbole)HyRolo" for a description of HyRolo and associated file formats. +# +# Detects entries in files with Org, Markdown or Emacs outline formats. +# Koutlines are not presently supported. +# +# Unlike hyrolo.el, this outputs only the innermost matching entries rather than the entire +# subtree of matching entries. +# +# This outputs a file header only if there is a matching entry in that file. + +# Code: + +import argparse +import os +import re + +# String to match at bol for file header start and end +file_header_delimiter = '===' +# Header to insert before a file's first entry match when file has no header. +# Used with one argument, the file name. +file_header_format = \ + "===============================================================================\n" \ + "@loc> \"%s\"\n" \ + "===============================================================================\n" + +# The ANSI escape sequence for the red color +red = "\033[31m" +# The ANSI escape sequence for inverting colors is \033[7m +invert = "\033[7m" +# The ANSI escape sequence to reset the color is \033[0m +reset = "\033[0m" + +def find_matching_entries(match_string, file_paths): + quoted_match_string = re.escape(match_string) + + # Remove any null items from file_paths and expand them + file_paths = [os.path.abspath(os.path.expanduser(os.path.expandvars(p))) for p in file_paths if p] + + for file_path in file_paths: + # Initialize variables + buffer = '' + file_header_buffer = '' + inside_entry = False + inside_file_header = False + first_line = True + first_entry = True + headline_match = False + + # Open the file + with open(file_path, 'r') as file: + for line in file: + if first_line: + first_line = False + if line.startswith(file_header_delimiter): + inside_file_header = True + file_header_buffer += line + continue + + if inside_file_header: + file_header_buffer += line + if line.startswith(file_header_delimiter): + inside_file_header = False + continue + + headline_match = re.match(r'[\*\#]+[ \t]', line, re.IGNORECASE) + # If inside a entry and the line starts with an asterisk, check + # if the buffer contains the match string. + if inside_entry and headline_match: + if re.search(quoted_match_string, buffer, re.IGNORECASE): + if first_entry: + first_entry = False + if file_header_buffer: + print(file_header_buffer, end='') + file_header_buffer = '' + print("@loc> \"%s\"\n" % file_path) + else: + print(file_header_format % file_path) + + highlight_matches(match_string, buffer) + + buffer = '' + inside_entry = False + + # If we're not inside a entry and the line starts with an asterisk, start a new entry + elif not inside_entry and headline_match: + inside_entry = True + + # If we're inside a entry, add the line to the buffer + if inside_entry: + buffer += line + + # Check the last entry if it's still inside a entry + if inside_entry and re.search(quoted_match_string, buffer, re.IGNORECASE): + if first_entry: + first_entry = False + if file_header_buffer: + print(file_header_buffer) + file_header_buffer = '' + else: + print(file_header_format % file_path) + + highlight_matches(match_string, buffer) + + +def highlight_matches(match_string, buffer): + "Split the last buffer into lines and print each line, inverting 'mymatch' colors." + for b_line in buffer.splitlines(): + if match_string.casefold() in b_line.casefold(): + # Replace the search string with the inverted version + print(re.sub(re.escape(match_string), invert + match_string + reset, + b_line, flags=re.IGNORECASE)) + else: + print(b_line) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('match_string', help='string to match within HyRolo entries') + parser.add_argument('files', nargs='*', help='list of HyRolo files to search') + args = parser.parse_args() + + # find_matching_entries('case_insensitive_string_to_match', 'hyrolo_contact_file') + if args.files: + pass + elif os.getenv("HYROLO"): + args.files = [os.getenv("HYROLO")] + else: + args.files = ["~/.rolo.otl"] + find_matching_entries(args.match_string, args.files) + +if __name__ == '__main__': + main()