• showold.py

    From Stephen Walsh@3:633/280 to All on Mon Oct 6 14:32:08 2025

    Hello everybody!

    Enjoy!

    Sample output:

    Showold.py version '3.0'
    Searching for outbounds in: '/home/husky' +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+
    | 1:229/426 | 0 | 0 | 963 | 0 |
    | 3:633/414 | 0 | 0 | 963 | 0 |
    | 3:633/418 | 0 | 0 | 963 | 0 |
    | 3:633/420 | 0 | 0 | 963 | 0 |
    | 3:633/422 | 0 | 0 | 963 | 0 |
    | 3:633/2744 | 0 | 0 | 963 | 0 |
    | 39:901/620 | 1 | 0 | 14525 | 0 | +------------------+--------+-----------+-----------+-----------+




    === Cut ===
    #!/usr/bin/env python3
    """
    Display outbound summary for every link
    for which there is anything in the outbound

    Created by Pavel Gulchouck 2:463/68@fidonet
    Fixed by Stas Degteff 2:5080/102@fidonet
    Modified by Michael Dukelsky 2:5020/1042@fidonet
    Modified by Stephen Walsh 3:633/280@fidonet
    Python version by Stephen Walsh 3:633/280@fidonet
    """

    import os
    import sys
    import glob
    import re
    import time
    from pathlib import Path
    from typing import Dict, List, Tuple, Optional

    VERSION = "3.0"

    # Size constants
    MB = 1024 * 1024
    GB = MB * 1024


    def usage():
    """Print usage information"""
    print("""
    The script showold.py prints out to STDOUT how much netmail,
    echomail and files are stored for every link in the outbound
    and fileboxes and for how long they are stored.

    If FIDOCONFIG environment variable is defined, you may use the
    script without arguments, otherwise you have to supply the path
    to fidoconfig as an argument.

    Usage:
    python3 showold.py
    python3 showold.py <path to fidoconfig>

    Example:
    python3 showold.py /opt/husky/etc/config
    """)
    sys.exit(1)


    def parse_fido_address(addr: str) -> Tuple[int, int, int, int]:
    """Parse FidoNet address and return sortable tuple.

    Returns: (zone, net, node, point)
    """
    # Format: zone:net/node[.point][@domain]
    addr = addr.split('@')[0] # Remove domain

    zone, net, node, point = 0, 0, 0, 0

    if ':' in addr:
    zone_part, rest = addr.split(':', 1)
    zone = int(zone_part) if zone_part.isdigit() else 0
    else:
    rest = addr

    if '/' in rest:
    net_part, node_part = rest.split('/', 1)
    net = int(net_part) if net_part.isdigit() else 0

    if '.' in node_part:
    node_str, point_str = node_part.split('.', 1)
    node = int(node_str) if node_str.isdigit() else 0
    point = int(point_str) if point_str.isdigit() else 0
    else:
    node = int(node_part) if node_part.isdigit() else 0

    return (zone, net, node, point)


    def node_sort_key(addr: str) -> Tuple[int, int, int, int]:
    """Return sortable key for FidoNet address"""
    return parse_fido_address(addr)


    def unbso(filename: str, directory: str, def_zone: int) -> str:
    """Parse BSO filename and directory to get FidoNet address"""
    # Check if we're in a point directory
    dir_name = os.path.basename(directory)
    is_point_dir = False
    net = None
    node = None

    # Match point directory: NNNNPPPP.pnt
    pnt_match = re.match(r'^([0-9a-f]{4})([0-9a-f]{4})\.pnt$',
    dir_name, re.I)
    if pnt_match:
    net = int(pnt_match.group(1), 16)
    node = int(pnt_match.group(2), 16)
    is_point_dir = True
    directory = os.path.dirname(directory)

    # Determine zone from directory name
    dir_name = os.path.basename(directory)
    zone_match = re.match(r'^outbound\.([0-9a-f]{3})$', dir_name, re.I)
    if zone_match:
    zone = int(zone_match.group(1), 16)
    elif dir_name.lower() == 'outbound':
    zone = def_zone
    else:
    zone = def_zone

    # Parse filename
    if is_point_dir:
    # In point directory: filename is 8 hex digits (point number)
    file_match = re.match(r'^([0-9a-f]{8})', filename, re.I)
    if file_match and net is not None and node is not None:
    point = int(file_match.group(1), 16)
    return f"{zone}:{net}/{node}.{point}"
    else:
    # Not in point directory: NNNNPPPP format
    file_match = re.match(r'^([0-9a-f]{4})([0-9a-f]{4})', filename,
    re.I)
    if file_match:
    net = int(file_match.group(1), 16)
    node = int(file_match.group(2), 16)
    return f"{zone}:{net}/{node}"

    return ""


    def unaso(filename: str) -> str:
    """Parse ASO filename to get FidoNet address"""
    match = re.match(r'(\d+)\.(\d+)\.(\d+)\.(\d+)', filename)
    if match:
    zone, net, node, point = match.groups()
    if point == '0':
    return f"{zone}:{net}/{node}"
    else:
    return f"{zone}:{net}/{node}.{point}"
    return ""


    def unbox(directory: str) -> str:
    """Parse filebox directory name to get FidoNet address"""
    dir_name = os.path.basename(directory.rstrip('/'))
    match = re.match(r'(\d+)\.(\d+)\.(\d+)\.(\d+)(?:\.h)?$',
    dir_name, re.I)
    if match:
    zone, net, node, point = match.groups()
    if point == '0':
    return f"{zone}:{net}/{node}"
    else:
    return f"{zone}:{net}/{node}.{point}"
    return ""


    def nice_number(num: int) -> float:
    """Convert number to nice format (MB or GB)"""
    if num < MB:
    return num
    elif num >= MB and num < GB:
    return num / MB
    else:
    return num / GB


    def nice_number_format(num: int) -> str:
    """Return format string for nice number"""
    if num < MB:
    return f"{num:9d} "
    elif num < GB:
    return f"{num/MB:9.4f}M"
    else:
    return f"{num/GB:9.4f}G"


    def find_outbounds(base_dir: str) -> List[str]:
    """Find all outbound directories"""
    outbounds = []
    for root, dirs, files in os.walk(base_dir):
    for d in dirs:
    if re.match(r'^outbound(?:\.[0-9a-f]{3})?$', d, re.I):
    outbounds.append(os.path.join(root, d))
    return outbounds


    def find_fileboxes(base_dir: str) -> List[str]:
    """Find all filebox directories"""
    boxes = []
    for root, dirs, files in os.walk(base_dir):
    for d in dirs:
    if re.match(r'\d+\.\d+\.\d+\.\d+(?:\.h)?$', d, re.I):
    boxes.append(os.path.join(root, d))
    return boxes


    def read_fidoconfig(config_path: str) -> Dict[str, str]:
    """Read fidoconfig file and extract needed values"""
    config = {}

    with open(config_path, 'r', encoding='utf-8',
    errors='replace') as f:
    for line in f:
    line = line.strip()
    if not line or line.startswith('#'):
    continue

    # Simple tokenization
    parts = line.split(None, 1)
    if len(parts) < 2:
    continue

    keyword = parts[0].lower()
    value = parts[1]

    if keyword == 'address' and 'address' not in config:
    config['address'] = value
    elif keyword == 'outbound':
    config['outbound'] = value.rstrip('/')
    elif keyword == 'fileboxesdir':
    config['fileboxesdir'] = value.rstrip('/')
    elif keyword == 'passfileareadir':
    config['passfileareadir'] = value.rstrip('/')

    return config


    def process_bso_outbound(outbound_dir: str, def_zone: int,
    pass_file_area_dir: str,
    minmtime: Dict, netmail: Dict,
    echomail: Dict, files: Dict):
    """Process BSO outbound directory"""
    # Find all control files in outbound
    control_patterns = ['*.[IiCcDdFfHh][Ll][Oo]', '*.[IiCcDdOoHh][Uu][Tt]']
    control_files = []

    for pattern in control_patterns:
    control_files.extend(
    glob.glob(os.path.join(outbound_dir, pattern)))

    # Find point directories
    pnt_dirs = glob.glob(os.path.join(outbound_dir, '*.[Pp][Nn][Tt]'))
    for pnt_dir in pnt_dirs:
    if os.path.isdir(pnt_dir):
    for pattern in control_patterns:
    control_files.extend(
    glob.glob(os.path.join(pnt_dir, pattern)))

    for ctrl_file in control_files:
    directory = os.path.dirname(ctrl_file)
    filename = os.path.basename(ctrl_file)

    node = unbso(filename, directory, def_zone)
    if not node:
    continue

    stat_info = os.stat(ctrl_file)
    size = stat_info.st_size
    mtime = stat_info.st_mtime

    if size == 0:
    continue

    # Update min mtime
    if node not in minmtime or mtime < minmtime[node]:
    minmtime[node] = mtime

    # Check if it's netmail
    if ctrl_file.lower().endswith('ut'):
    netmail[node] = netmail.get(node, 0) + size
    continue

    # Process control file contents
    is_echomail_ctrl = bool(re.search(r'\.(c|h|f)lo$', ctrl_file,
    re.I))
    is_file_ctrl = bool(re.search(r'\.(c|i|d)lo$', ctrl_file, re.I))

    try:
    with open(ctrl_file, 'r', encoding='utf-8',
    errors='replace') as f:
    for line in f:
    line = line.strip()
    line = re.sub(r'^[#~^]', '', line)
    if not line:
    continue

    bundle_path = None

    # Check if absolute path
    if line.startswith('/'):
    bundle_path = line
    # Check if it's in passFileAreaDir
    elif (pass_file_area_dir and
    line.startswith(pass_file_area_dir)):
    bundle_path = line
    # Check for bundle patterns
    elif re.match(
    r'^[0-9a-f]{8}\.(su|mo|tu|we|th|fr|sa)[0-9a-z]$',
    line, re.I):
    bundle_path = os.path.join(directory, line)
    elif re.match(r'\.tic$', line, re.I):
    bundle_path = os.path.join(directory, line)
    elif re.match(r'^[0-9a-f]{8}\.pkt$', line, re.I):
    bundle_path = os.path.join(directory, line)

    if bundle_path and os.path.exists(bundle_path):
    b_stat = os.stat(bundle_path)
    b_size = b_stat.st_size
    b_mtime = b_stat.st_mtime

    if (node not in minmtime or
    b_mtime < minmtime[node]):
    minmtime[node] = b_mtime

    # Categorize
    if re.search(
    r'\.(su|mo|tu|we|th|fr|sa)[0-9a-z]$',
    line, re.I):
    echomail[node] = echomail.get(node, 0) + b_size
    elif (bundle_path.endswith('.pkt') and
    is_echomail_ctrl):
    echomail[node] = echomail.get(node, 0) + b_size
    elif bundle_path.endswith('.tic'):
    files[node] = files.get(node, 0) + b_size
    elif (pass_file_area_dir and
    bundle_path.startswith(pass_file_area_dir)):
    files[node] = files.get(node, 0) + b_size
    elif line.startswith('/') and is_file_ctrl:
    files[node] = files.get(node, 0) + b_size
    elif line.startswith('/'):
    files[node] = files.get(node, 0) + b_size
    except Exception as e:
    print(f"WARN: Could not process {ctrl_file}: {e}",
    file=sys.stderr)


    def process_fileboxes(box_dir: str, minmtime: Dict, netmail: Dict,
    echomail: Dict, files: Dict):
    """Process filebox directory"""
    node = unbox(box_dir)
    if not node:
    return

    # Find all files in the filebox
    patterns = ['*.[IiCcDdOoHh][Uu][Tt]',
    '*.[Ss][Uu][0-9a-zA-Z]', '*.[Mm][Oo][0-9a-zA-Z]',
    '*.[Tt][Uu][0-9a-zA-Z]', '*.[Ww][Ee][0-9a-zA-Z]',
    '*.[Tt][Hh][0-9a-zA-Z]', '*.[Ff][Rr][0-9a-zA-Z]',
    '*.[Ss][Aa][0-9a-zA-Z]']

    file_list = []
    for pattern in patterns:
    file_list.extend(glob.glob(os.path.join(box_dir, pattern)))

    for fpath in file_list:
    if not os.path.isfile(fpath):
    continue

    stat_info = os.stat(fpath)
    size = stat_info.st_size
    mtime = stat_info.st_mtime

    if size == 0:
    continue

    if node not in minmtime or mtime < minmtime[node]:
    minmtime[node] = mtime

    filename = os.path.basename(fpath)
    if re.search(r'ut$', filename, re.I):
    netmail[node] = netmail.get(node, 0) + size
    elif re.search(r'\.(su|mo|tu|we|th|fr|sa)[0-9a-z]$',
    filename, re.I):
    echomail[node] = echomail.get(node, 0) + size
    else:
    files[node] = files.get(node, 0) + size


    def main():
    # Get fidoconfig path
    fidoconfig = os.environ.get('FIDOCONFIG')

    if len(sys.argv) == 2:
    if sys.argv[1] in ['-h', '--help', '-?', '/?', '/h']:
    usage()
    fidoconfig = sys.argv[1]
    elif not fidoconfig:
    usage()

    if not os.path.isfile(fidoconfig):
    print(f"\n'{fidoconfig}' is not a fidoconfig file\n")
    usage()

    # Read config
    print(f"Showold.py version '{VERSION}'")
    config = read_fidoconfig(fidoconfig)

    if 'address' not in config:
    print("\nYour FTN address is not defined\n", file=sys.stderr)
    sys.exit(1)

    # Parse default zone
    addr_match = re.match(r'^(\d+):\d+/\d+', config['address'])
    if not addr_match:
    print("\nYour FTN address has a syntax error\n",
    file=sys.stderr)
    sys.exit(1)
    def_zone = int(addr_match.group(1))

    if 'outbound' not in config:
    print("\nOutbound is not defined\n", file=sys.stderr)
    sys.exit(1)

    outbound = config['outbound']
    if not os.path.isdir(outbound):
    print(f"\nOutbound '{outbound}' is not a directory\n",
    file=sys.stderr)
    sys.exit(1)

    # Get parent directory for finding all outbounds
    husky_base_dir = os.path.dirname(outbound)

    print(f"Searching for outbounds in: '{husky_base_dir}'")

    # Find directories
    outbound_dirs = find_outbounds(husky_base_dir)

    fileboxes_dir = config.get('fileboxesdir', '')
    filebox_dirs = []
    if fileboxes_dir and os.path.isdir(fileboxes_dir):
    filebox_dirs = find_fileboxes(fileboxes_dir)

    pass_file_area_dir = config.get('passfileareadir', '')

    # Process all outbounds
    minmtime = {}
    netmail = {}
    echomail = {}
    files_dict = {}

    for ob_dir in outbound_dirs:
    process_bso_outbound(ob_dir, def_zone, pass_file_area_dir,
    minmtime, netmail, echomail, files_dict)

    for fb_dir in filebox_dirs:
    process_fileboxes(fb_dir, minmtime, netmail, echomail,
    files_dict)

    # Print results
    print("+------------------+--------+-----------+-----------+"
    "-----------+")
    print("| Node | Days | NetMail | EchoMail "
    "| Files |")
    print("+------------------+--------+-----------+-----------+"
    "-----------+")

    for node in sorted(minmtime.keys(), key=node_sort_key):
    nm = netmail.get(node, 0)
    em = echomail.get(node, 0)
    fl = files_dict.get(node, 0)

    days = (time.time() - minmtime[node]) / (24 * 60 * 60)

    print(f"| {node:16s} |{days:7.0f} |"
    f"{nice_number_format(nm)} |"
    f"{nice_number_format(em)} |"
    f"{nice_number_format(fl)} |")

    print("+------------------+--------+-----------+-----------+"
    "-----------+")


    if __name__ == '__main__':
    main()
    === Cut ===

    Stephen


    --- GoldED+/LNX 1.1.5-b20250409
    * Origin: Dragon's Lair ---:- dragon.vk3heg.net -:--- Prt: 6800 (3:633/280)
  • From Stephen Walsh@3:633/280 to All on Mon Oct 6 14:36:22 2025

    Hello everybody!

    Enjoy!

    Sample output:

    Showold.py version '3.0'
    Searching for outbounds in: '/home/husky' +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+
    | 1:229/426 | 0 | 0 | 963 | 0 |
    | 3:633/414 | 0 | 0 | 963 | 0 |
    | 3:633/418 | 0 | 0 | 963 | 0 |
    | 3:633/420 | 0 | 0 | 963 | 0 |
    | 3:633/422 | 0 | 0 | 963 | 0 |
    | 3:633/2744 | 0 | 0 | 963 | 0 |
    | 39:901/620 | 1 | 0 | 14525 | 0 | +------------------+--------+-----------+-----------+-----------+

    If you make the script executable, then there is no need to call python3 before hand,
    just: showold.py (If it's in your path, like on my system).



    === Cut ===
    #!/usr/bin/env python3
    """
    Display outbound summary for every link
    for which there is anything in the outbound

    Created by Pavel Gulchouck 2:463/68@fidonet
    Fixed by Stas Degteff 2:5080/102@fidonet
    Modified by Michael Dukelsky 2:5020/1042@fidonet
    Modified by Stephen Walsh 3:633/280@fidonet
    Python version by Stephen Walsh 3:633/280@fidonet
    """

    import os
    import sys
    import glob
    import re
    import time
    from pathlib import Path
    from typing import Dict, List, Tuple, Optional

    VERSION = "3.0"

    # Size constants
    MB = 1024 * 1024
    GB = MB * 1024


    def usage():
    """Print usage information"""
    print("""
    The script showold.py prints out to STDOUT how much netmail,
    echomail and files are stored for every link in the outbound
    and fileboxes and for how long they are stored.

    If FIDOCONFIG environment variable is defined, you may use the
    script without arguments, otherwise you have to supply the path
    to fidoconfig as an argument.

    Usage:
    python3 showold.py
    python3 showold.py <path to fidoconfig>

    Example:
    python3 showold.py /opt/husky/etc/config
    """)
    sys.exit(1)


    def parse_fido_address(addr: str) -> Tuple[int, int, int, int]:
    """Parse FidoNet address and return sortable tuple.

    Returns: (zone, net, node, point)
    """
    # Format: zone:net/node[.point][@domain]
    addr = addr.split('@')[0] # Remove domain

    zone, net, node, point = 0, 0, 0, 0

    if ':' in addr:
    zone_part, rest = addr.split(':', 1)
    zone = int(zone_part) if zone_part.isdigit() else 0
    else:
    rest = addr

    if '/' in rest:
    net_part, node_part = rest.split('/', 1)
    net = int(net_part) if net_part.isdigit() else 0

    if '.' in node_part:
    node_str, point_str = node_part.split('.', 1)
    node = int(node_str) if node_str.isdigit() else 0
    point = int(point_str) if point_str.isdigit() else 0
    else:
    node = int(node_part) if node_part.isdigit() else 0

    return (zone, net, node, point)


    def node_sort_key(addr: str) -> Tuple[int, int, int, int]:
    """Return sortable key for FidoNet address"""
    return parse_fido_address(addr)


    def unbso(filename: str, directory: str, def_zone: int) -> str:
    """Parse BSO filename and directory to get FidoNet address"""
    # Check if we're in a point directory
    dir_name = os.path.basename(directory)
    is_point_dir = False
    net = None
    node = None

    # Match point directory: NNNNPPPP.pnt
    pnt_match = re.match(r'^([0-9a-f]{4})([0-9a-f]{4})\.pnt$',
    dir_name, re.I)
    if pnt_match:
    net = int(pnt_match.group(1), 16)
    node = int(pnt_match.group(2), 16)
    is_point_dir = True
    directory = os.path.dirname(directory)

    # Determine zone from directory name
    dir_name = os.path.basename(directory)
    zone_match = re.match(r'^outbound\.([0-9a-f]{3})$', dir_name, re.I)
    if zone_match:
    zone = int(zone_match.group(1), 16)
    elif dir_name.lower() == 'outbound':
    zone = def_zone
    else:
    zone = def_zone

    # Parse filename
    if is_point_dir:
    # In point directory: filename is 8 hex digits (point number)
    file_match = re.match(r'^([0-9a-f]{8})', filename, re.I)
    if file_match and net is not None and node is not None:
    point = int(file_match.group(1), 16)
    return f"{zone}:{net}/{node}.{point}"
    else:
    # Not in point directory: NNNNPPPP format
    file_match = re.match(r'^([0-9a-f]{4})([0-9a-f]{4})', filename,
    re.I)
    if file_match:
    net = int(file_match.group(1), 16)
    node = int(file_match.group(2), 16)
    return f"{zone}:{net}/{node}"

    return ""


    def unaso(filename: str) -> str:
    """Parse ASO filename to get FidoNet address"""
    match = re.match(r'(\d+)\.(\d+)\.(\d+)\.(\d+)', filename)
    if match:
    zone, net, node, point = match.groups()
    if point == '0':
    return f"{zone}:{net}/{node}"
    else:
    return f"{zone}:{net}/{node}.{point}"
    return ""


    def unbox(directory: str) -> str:
    """Parse filebox directory name to get FidoNet address"""
    dir_name = os.path.basename(directory.rstrip('/'))
    match = re.match(r'(\d+)\.(\d+)\.(\d+)\.(\d+)(?:\.h)?$',
    dir_name, re.I)
    if match:
    zone, net, node, point = match.groups()
    if point == '0':
    return f"{zone}:{net}/{node}"
    else:
    return f"{zone}:{net}/{node}.{point}"
    return ""


    def nice_number(num: int) -> float:
    """Convert number to nice format (MB or GB)"""
    if num < MB:
    return num
    elif num >= MB and num < GB:
    return num / MB
    else:
    return num / GB


    def nice_number_format(num: int) -> str:
    """Return format string for nice number"""
    if num < MB:
    return f"{num:9d} "
    elif num < GB:
    return f"{num/MB:9.4f}M"
    else:
    return f"{num/GB:9.4f}G"


    def find_outbounds(base_dir: str) -> List[str]:
    """Find all outbound directories"""
    outbounds = []
    for root, dirs, files in os.walk(base_dir):
    for d in dirs:
    if re.match(r'^outbound(?:\.[0-9a-f]{3})?$', d, re.I):
    outbounds.append(os.path.join(root, d))
    return outbounds


    def find_fileboxes(base_dir: str) -> List[str]:
    """Find all filebox directories"""
    boxes = []
    for root, dirs, files in os.walk(base_dir):
    for d in dirs:
    if re.match(r'\d+\.\d+\.\d+\.\d+(?:\.h)?$', d, re.I):
    boxes.append(os.path.join(root, d))
    return boxes


    def read_fidoconfig(config_path: str) -> Dict[str, str]:
    """Read fidoconfig file and extract needed values"""
    config = {}

    with open(config_path, 'r', encoding='utf-8',
    errors='replace') as f:
    for line in f:
    line = line.strip()
    if not line or line.startswith('#'):
    continue

    # Simple tokenization
    parts = line.split(None, 1)
    if len(parts) < 2:
    continue

    keyword = parts[0].lower()
    value = parts[1]

    if keyword == 'address' and 'address' not in config:
    config['address'] = value
    elif keyword == 'outbound':
    config['outbound'] = value.rstrip('/')
    elif keyword == 'fileboxesdir':
    config['fileboxesdir'] = value.rstrip('/')
    elif keyword == 'passfileareadir':
    config['passfileareadir'] = value.rstrip('/')

    return config


    def process_bso_outbound(outbound_dir: str, def_zone: int,
    pass_file_area_dir: str,
    minmtime: Dict, netmail: Dict,
    echomail: Dict, files: Dict):
    """Process BSO outbound directory"""
    # Find all control files in outbound
    control_patterns = ['*.[IiCcDdFfHh][Ll][Oo]', '*.[IiCcDdOoHh][Uu][Tt]']
    control_files = []

    for pattern in control_patterns:
    control_files.extend(
    glob.glob(os.path.join(outbound_dir, pattern)))

    # Find point directories
    pnt_dirs = glob.glob(os.path.join(outbound_dir, '*.[Pp][Nn][Tt]'))
    for pnt_dir in pnt_dirs:
    if os.path.isdir(pnt_dir):
    for pattern in control_patterns:
    control_files.extend(
    glob.glob(os.path.join(pnt_dir, pattern)))

    for ctrl_file in control_files:
    directory = os.path.dirname(ctrl_file)
    filename = os.path.basename(ctrl_file)

    node = unbso(filename, directory, def_zone)
    if not node:
    continue

    stat_info = os.stat(ctrl_file)
    size = stat_info.st_size
    mtime = stat_info.st_mtime

    if size == 0:
    continue

    # Update min mtime
    if node not in minmtime or mtime < minmtime[node]:
    minmtime[node] = mtime

    # Check if it's netmail
    if ctrl_file.lower().endswith('ut'):
    netmail[node] = netmail.get(node, 0) + size
    continue

    # Process control file contents
    is_echomail_ctrl = bool(re.search(r'\.(c|h|f)lo$', ctrl_file,
    re.I))
    is_file_ctrl = bool(re.search(r'\.(c|i|d)lo$', ctrl_file, re.I))

    try:
    with open(ctrl_file, 'r', encoding='utf-8',
    errors='replace') as f:
    for line in f:
    line = line.strip()
    line = re.sub(r'^[#~^]', '', line)
    if not line:
    continue

    bundle_path = None

    # Check if absolute path
    if line.startswith('/'):
    bundle_path = line
    # Check if it's in passFileAreaDir
    elif (pass_file_area_dir and
    line.startswith(pass_file_area_dir)):
    bundle_path = line
    # Check for bundle patterns
    elif re.match(
    r'^[0-9a-f]{8}\.(su|mo|tu|we|th|fr|sa)[0-9a-z]$',
    line, re.I):
    bundle_path = os.path.join(directory, line)
    elif re.match(r'\.tic$', line, re.I):
    bundle_path = os.path.join(directory, line)
    elif re.match(r'^[0-9a-f]{8}\.pkt$', line, re.I):
    bundle_path = os.path.join(directory, line)

    if bundle_path and os.path.exists(bundle_path):
    b_stat = os.stat(bundle_path)
    b_size = b_stat.st_size
    b_mtime = b_stat.st_mtime

    if (node not in minmtime or
    b_mtime < minmtime[node]):
    minmtime[node] = b_mtime

    # Categorize
    if re.search(
    r'\.(su|mo|tu|we|th|fr|sa)[0-9a-z]$',
    line, re.I):
    echomail[node] = echomail.get(node, 0) + b_size
    elif (bundle_path.endswith('.pkt') and
    is_echomail_ctrl):
    echomail[node] = echomail.get(node, 0) + b_size
    elif bundle_path.endswith('.tic'):
    files[node] = files.get(node, 0) + b_size
    elif (pass_file_area_dir and
    bundle_path.startswith(pass_file_area_dir)):
    files[node] = files.get(node, 0) + b_size
    elif line.startswith('/') and is_file_ctrl:
    files[node] = files.get(node, 0) + b_size
    elif line.startswith('/'):
    files[node] = files.get(node, 0) + b_size
    except Exception as e:
    print(f"WARN: Could not process {ctrl_file}: {e}",
    file=sys.stderr)


    def process_fileboxes(box_dir: str, minmtime: Dict, netmail: Dict,
    echomail: Dict, files: Dict):
    """Process filebox directory"""
    node = unbox(box_dir)
    if not node:
    return

    # Find all files in the filebox
    patterns = ['*.[IiCcDdOoHh][Uu][Tt]',
    '*.[Ss][Uu][0-9a-zA-Z]', '*.[Mm][Oo][0-9a-zA-Z]',
    '*.[Tt][Uu][0-9a-zA-Z]', '*.[Ww][Ee][0-9a-zA-Z]',
    '*.[Tt][Hh][0-9a-zA-Z]', '*.[Ff][Rr][0-9a-zA-Z]',
    '*.[Ss][Aa][0-9a-zA-Z]']

    file_list = []
    for pattern in patterns:
    file_list.extend(glob.glob(os.path.join(box_dir, pattern)))

    for fpath in file_list:
    if not os.path.isfile(fpath):
    continue

    stat_info = os.stat(fpath)
    size = stat_info.st_size
    mtime = stat_info.st_mtime

    if size == 0:
    continue

    if node not in minmtime or mtime < minmtime[node]:
    minmtime[node] = mtime

    filename = os.path.basename(fpath)
    if re.search(r'ut$', filename, re.I):
    netmail[node] = netmail.get(node, 0) + size
    elif re.search(r'\.(su|mo|tu|we|th|fr|sa)[0-9a-z]$',
    filename, re.I):
    echomail[node] = echomail.get(node, 0) + size
    else:
    files[node] = files.get(node, 0) + size


    def main():
    # Get fidoconfig path
    fidoconfig = os.environ.get('FIDOCONFIG')

    if len(sys.argv) == 2:
    if sys.argv[1] in ['-h', '--help', '-?', '/?', '/h']:
    usage()
    fidoconfig = sys.argv[1]
    elif not fidoconfig:
    usage()

    if not os.path.isfile(fidoconfig):
    print(f"\n'{fidoconfig}' is not a fidoconfig file\n")
    usage()

    # Read config
    print(f"Showold.py version '{VERSION}'")
    config = read_fidoconfig(fidoconfig)

    if 'address' not in config:
    print("\nYour FTN address is not defined\n", file=sys.stderr)
    sys.exit(1)

    # Parse default zone
    addr_match = re.match(r'^(\d+):\d+/\d+', config['address'])
    if not addr_match:
    print("\nYour FTN address has a syntax error\n",
    file=sys.stderr)
    sys.exit(1)
    def_zone = int(addr_match.group(1))

    if 'outbound' not in config:
    print("\nOutbound is not defined\n", file=sys.stderr)
    sys.exit(1)

    outbound = config['outbound']
    if not os.path.isdir(outbound):
    print(f"\nOutbound '{outbound}' is not a directory\n",
    file=sys.stderr)
    sys.exit(1)

    # Get parent directory for finding all outbounds
    husky_base_dir = os.path.dirname(outbound)

    print(f"Searching for outbounds in: '{husky_base_dir}'")

    # Find directories
    outbound_dirs = find_outbounds(husky_base_dir)

    fileboxes_dir = config.get('fileboxesdir', '')
    filebox_dirs = []
    if fileboxes_dir and os.path.isdir(fileboxes_dir):
    filebox_dirs = find_fileboxes(fileboxes_dir)

    pass_file_area_dir = config.get('passfileareadir', '')

    # Process all outbounds
    minmtime = {}
    netmail = {}
    echomail = {}
    files_dict = {}

    for ob_dir in outbound_dirs:
    process_bso_outbound(ob_dir, def_zone, pass_file_area_dir,
    minmtime, netmail, echomail, files_dict)

    for fb_dir in filebox_dirs:
    process_fileboxes(fb_dir, minmtime, netmail, echomail,
    files_dict)

    # Print results
    print("+------------------+--------+-----------+-----------+"
    "-----------+")
    print("| Node | Days | NetMail | EchoMail "
    "| Files |")
    print("+------------------+--------+-----------+-----------+"
    "-----------+")

    for node in sorted(minmtime.keys(), key=node_sort_key):
    nm = netmail.get(node, 0)
    em = echomail.get(node, 0)
    fl = files_dict.get(node, 0)

    days = (time.time() - minmtime[node]) / (24 * 60 * 60)

    print(f"| {node:16s} |{days:7.0f} |"
    f"{nice_number_format(nm)} |"
    f"{nice_number_format(em)} |"
    f"{nice_number_format(fl)} |")

    print("+------------------+--------+-----------+-----------+"
    "-----------+")


    if __name__ == '__main__':
    main()
    === Cut ===

    Stephen


    --- GoldED+/LNX 1.1.5-b20250409
    * Origin: Dragon's Lair ---:- dragon.vk3heg.net -:--- Prt: 6800 (3:633/280)
  • From Tommi Koivula@2:221/1 to Stephen Walsh on Tue Oct 7 08:40:54 2025
    Hi Stephen.

    06 Oct 25 14:36, you wrote to All:

    Showold.py version '3.0'
    Searching for outbounds in: '/home/husky'


    Sounds fun, but it shows nothing here. :(

    tommi@mxo:~$ ./showold.py /bbs/fido.cfg
    Showold.py version '3.0'
    Searching for outbounds in: '/bbs/01/bso' +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+ +------------------+--------+-----------+-----------+-----------+

    tommi@mxo:~$ showold.pl +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+
    | 1:320/219 | 0 | 0 | 0 | 2814 |
    | 2:201/0 | 0 | 0 | 0 | 2814 |
    | 2:221/1.59 | 0 | 0 | 3551 | 0 |
    | 2:221/10.1 | 0 | 0 | 0 | 2814 |
    | 2:280/5003 | 0 | 0 | 0 | 2814 |
    | 2:371/52 | 2 | 0 | 0 | 256863 |
    | 2:423/81 | 0 | 0 | 0 | 4067 | +------------------+--------+-----------+-----------+-----------+

    'Tommi

    --- GoldED+/LNX 1.1.5-b20250409
    * Origin: nntps://news.fidonet.fi (2:221/1)
  • From Tommi Koivula@2:221/1 to Stephen Walsh on Tue Oct 7 08:58:32 2025
    Hi Stephen.

    07 Oct 25 08:40, I wrote to you:

    Showold.py version '3.0'
    Searching for outbounds in: '/home/husky'


    Sounds fun, but it shows nothing here. :(

    It seems to look for hardcoded "outbound" base dir...

    Fixed:

    tommi@mxo:/bbs$ diff showold.py.org showold.py
    103c103
    < zone_match = re.match(r'^outbound\.([0-9a-f]{3})$', dir_name, re.I)
    -+-

    zone_match = re.match(r'^fido\.([0-9a-f]{3})$', dir_name, re.I)

    181c181
    < if re.match(r'^outbound(?:\.[0-9a-f]{3})?$', d, re.I):
    -+-

    if re.match(r'^fido(?:\.[0-9a-f]{3})?$', d, re.I):

    tommi@mxo:/bbs$ ./showold.py
    Showold.py version '3.0'
    Searching for outbounds in: '/bbs/01/bso' +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+
    | 2:221/1.59 | 0 | 0 | 3551 | 0 |
    | 2:371/52 | 2 | 0 | 0 | 256863 | +------------------+--------+-----------+-----------+-----------+

    'Tommi

    --- GoldED+/LNX 1.1.5-b20250409
    * Origin: nntps://news.fidonet.fi (2:221/1)
  • From Stephen Walsh@3:633/280 to Tommi Koivula on Tue Oct 7 16:55:42 2025

    Hello Tommi!

    07 Oct 25 08:40, you wrote to me:

    Sounds fun, but it shows nothing here. :(

    tommi@mxo:~$ ./showold.py /bbs/fido.cfg
    Showold.py version '3.0'
    Searching for outbounds in: '/bbs/01/bso' +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+ +------------------+--------+-----------+-----------+-----------+


    Do you have the "FIDOCONFIG" environment set?

    I had the same type of issue with the perl version...



    Stephen


    --- GoldED+/LNX 1.1.5-b20250409
    * Origin: Dragon's Lair ---:- dragon.vk3heg.net -:--- Prt: 6800 (3:633/280)
  • From Tommi Koivula@2:221/1 to Stephen Walsh on Tue Oct 7 09:07:00 2025
    Hi Stephen.

    07 Oct 25 16:55, you wrote to me:

    Do you have the "FIDOCONFIG" environment set?

    Yes, it works also without any paramaters. But see my previous message. :)

    tommi@mxo:/bbs$ ./showold.py
    Showold.py version '3.0'
    Searching for outbounds in: '/bbs/01/bso' +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+
    | 2:221/1.59 | 0 | 0 | 3551 | 0 |
    | 2:371/52 | 2 | 0 | 0 | 256863 |
    | 2:423/81 | 0 | 0 | 80139 | 0 | +------------------+--------+-----------+-----------+-----------+

    'Tommi

    --- GoldED+/LNX 1.1.5-b20250409
    * Origin: nntps://news.fidonet.fi (2:221/1)
  • From Stephen Walsh@3:633/280 to Tommi Koivula on Tue Oct 7 21:23:40 2025

    Hello Tommi!

    07 Oct 25 09:07, you wrote to me:

    Do you have the "FIDOCONFIG" environment set?

    Yes, it works also without any paramaters. But see my previous
    message. :)


    Fixed...

    The script now:

    1. Actually does something with the "FIDOCONFIG" environment.
    1. Extracts the basename from the configured Outbound path
    (e.g., "fred" from /home/vk3heg/stats/fred/)
    2. Uses that basename instead of hardcoded "outbound" (doh.. I though I'd removed them... )


    Try this version.



    Stephen



    === Cut ===
    #!/usr/bin/env python3
    """
    Display outbound summary for every link
    for which there is anything in the outbound

    Created by Pavel Gulchouck 2:463/68@fidonet
    Fixed by Stas Degteff 2:5080/102@fidonet
    Modified by Michael Dukelsky 2:5020/1042@fidonet
    Modified by Stephen Walsh 3:633/280@fidonet
    Python version by Stephen Walsh 3:633/280@fidonet
    """

    import os
    import sys
    import glob
    import re
    import time
    from pathlib import Path
    from typing import Dict, List, Tuple, Optional

    VERSION = "3.1

    # Size constants
    MB = 1024 * 1024
    GB = MB * 1024


    def usage():
    """Print usage information"""
    print("""
    The script showold.py prints out to STDOUT how much netmail,
    echomail and files are stored for every link in the outbound
    and fileboxes and for how long they are stored.

    If FIDOCONFIG environment variable is defined, you may use the
    script without arguments, otherwise you have to supply the path
    to fidoconfig as an argument.

    Usage:
    python3 showold.py
    python3 showold.py <path to fidoconfig>

    Example:
    python3 showold.py /home/husky/etc/config
    """)
    sys.exit(1)


    def parse_fido_address(addr: str) -> Tuple[int, int, int, int]:
    """Parse FidoNet address and return sortable tuple.

    Returns: (zone, net, node, point)
    """
    # Format: zone:net/node[.point][@domain]
    addr = addr.split('@')[0] # Remove domain

    zone, net, node, point = 0, 0, 0, 0

    if ':' in addr:
    zone_part, rest = addr.split(':', 1)
    zone = int(zone_part) if zone_part.isdigit() else 0
    else:
    rest = addr

    if '/' in rest:
    net_part, node_part = rest.split('/', 1)
    net = int(net_part) if net_part.isdigit() else 0

    if '.' in node_part:
    node_str, point_str = node_part.split('.', 1)
    node = int(node_str) if node_str.isdigit() else 0
    point = int(point_str) if point_str.isdigit() else 0
    else:
    node = int(node_part) if node_part.isdigit() else 0

    return (zone, net, node, point)


    def node_sort_key(addr: str) -> Tuple[int, int, int, int]:
    """Return sortable key for FidoNet address"""
    return parse_fido_address(addr)


    def unbso(filename: str, directory: str, def_zone: int, outbound_basename: str = 'outbound') -> str:
    """Parse BSO filename and directory to get FidoNet address"""
    # Check if we're in a point directory
    dir_name = os.path.basename(directory)
    is_point_dir = False
    net = None
    node = None

    # Match point directory: NNNNPPPP.pnt
    pnt_match = re.match(r'^([0-9a-f]{4})([0-9a-f]{4})\.pnt$',
    dir_name, re.I)
    if pnt_match:
    net = int(pnt_match.group(1), 16)
    node = int(pnt_match.group(2), 16)
    is_point_dir = True
    directory = os.path.dirname(directory)

    # Determine zone from directory name
    dir_name = os.path.basename(directory)
    zone_match = re.match(rf'^{re.escape(outbound_basename)}\.([0-9a-f]{{3}})$', dir_name, re.I)
    if zone_match:
    zone = int(zone_match.group(1), 16)
    elif dir_name.lower() == outbound_basename.lower():
    zone = def_zone
    else:
    zone = def_zone

    # Parse filename
    if is_point_dir:
    # In point directory: filename is 8 hex digits (point number)
    file_match = re.match(r'^([0-9a-f]{8})', filename, re.I)
    if file_match and net is not None and node is not None:
    point = int(file_match.group(1), 16)
    return f"{zone}:{net}/{node}.{point}"
    else:
    # Not in point directory: NNNNPPPP format
    file_match = re.match(r'^([0-9a-f]{4})([0-9a-f]{4})', filename,
    re.I)
    if file_match:
    net = int(file_match.group(1), 16)
    node = int(file_match.group(2), 16)
    return f"{zone}:{net}/{node}"

    return ""


    def unaso(filename: str) -> str:
    """Parse ASO filename to get FidoNet address"""
    match = re.match(r'(\d+)\.(\d+)\.(\d+)\.(\d+)', filename)
    if match:
    zone, net, node, point = match.groups()
    if point == '0':
    return f"{zone}:{net}/{node}"
    else:
    return f"{zone}:{net}/{node}.{point}"
    return ""


    def unbox(directory: str) -> str:
    """Parse filebox directory name to get FidoNet address"""
    dir_name = os.path.basename(directory.rstrip('/'))
    match = re.match(r'(\d+)\.(\d+)\.(\d+)\.(\d+)(?:\.h)?$',
    dir_name, re.I)
    if match:
    zone, net, node, point = match.groups()
    if point == '0':
    return f"{zone}:{net}/{node}"
    else:
    return f"{zone}:{net}/{node}.{point}"
    return ""


    def nice_number(num: int) -> float:
    """Convert number to nice format (MB or GB)"""
    if num < MB:
    return num
    elif num >= MB and num < GB:
    return num / MB
    else:
    return num / GB


    def nice_number_format(num: int) -> str:
    """Return format string for nice number"""
    if num < MB:
    return f"{num:9d} "
    elif num < GB:
    return f"{num/MB:9.4f}M"
    else:
    return f"{num/GB:9.4f}G"


    def find_outbounds(base_dir: str, outbound_basename: str = 'outbound') -> List[str]:
    """Find all outbound directories"""
    outbounds = []
    for root, dirs, files in os.walk(base_dir):
    for d in dirs:
    if re.match(rf'^{re.escape(outbound_basename)}(?:\.[0-9a-f]{{3}})?$', d, re.I):
    outbounds.append(os.path.join(root, d))
    return outbounds


    def find_fileboxes(base_dir: str) -> List[str]:
    """Find all filebox directories"""
    boxes = []
    for root, dirs, files in os.walk(base_dir):
    for d in dirs:
    if re.match(r'\d+\.\d+\.\d+\.\d+(?:\.h)?$', d, re.I):
    boxes.append(os.path.join(root, d))
    return boxes


    def read_fidoconfig(config_path: str) -> Dict[str, str]:
    """Read fidoconfig file and extract needed values"""
    config = {}

    with open(config_path, 'r', encoding='utf-8',
    errors='replace') as f:
    for line in f:
    line = line.strip()
    if not line or line.startswith('#'):
    continue

    # Simple tokenization
    parts = line.split(None, 1)
    if len(parts) < 2:
    continue

    keyword = parts[0].lower()
    value = parts[1]

    if keyword == 'address' and 'address' not in config:
    config['address'] = value
    elif keyword == 'outbound':
    config['outbound'] = value.rstrip('/')
    elif keyword == 'fileboxesdir':
    config['fileboxesdir'] = value.rstrip('/')
    elif keyword == 'passfileareadir':
    config['passfileareadir'] = value.rstrip('/')

    return config


    def process_bso_outbound(outbound_dir: str, def_zone: int,
    pass_file_area_dir: str,
    minmtime: Dict, netmail: Dict,
    echomail: Dict, files: Dict,
    outbound_basename: str = 'outbound'):
    """Process BSO outbound directory"""
    # Find all control files in outbound
    control_patterns = ['*.[IiCcDdFfHh][Ll][Oo]', '*.[IiCcDdOoHh][Uu][Tt]']
    control_files = []

    for pattern in control_patterns:
    control_files.extend(
    glob.glob(os.path.join(outbound_dir, pattern)))

    # Find point directories
    pnt_dirs = glob.glob(os.path.join(outbound_dir, '*.[Pp][Nn][Tt]'))
    for pnt_dir in pnt_dirs:
    if os.path.isdir(pnt_dir):
    for pattern in control_patterns:
    control_files.extend(
    glob.glob(os.path.join(pnt_dir, pattern)))

    for ctrl_file in control_files:
    directory = os.path.dirname(ctrl_file)
    filename = os.path.basename(ctrl_file)

    node = unbso(filename, directory, def_zone, outbound_basename)
    if not node:
    continue

    stat_info = os.stat(ctrl_file)
    size = stat_info.st_size
    mtime = stat_info.st_mtime

    if size == 0:
    continue

    # Update min mtime
    if node not in minmtime or mtime < minmtime[node]:
    minmtime[node] = mtime

    # Check if it's netmail
    if ctrl_file.lower().endswith('ut'):
    netmail[node] = netmail.get(node, 0) + size
    continue

    # Process control file contents
    is_echomail_ctrl = bool(re.search(r'\.(c|h|f)lo$', ctrl_file,
    re.I))
    is_file_ctrl = bool(re.search(r'\.(c|i|d)lo$', ctrl_file, re.I))

    try:
    with open(ctrl_file, 'r', encoding='utf-8',
    errors='replace') as f:
    for line in f:
    line = line.strip()
    line = re.sub(r'^[#~^]', '', line)
    if not line:
    continue

    bundle_path = None

    # Check if absolute path
    if line.startswith('/'):
    bundle_path = line
    # Check if it's in passFileAreaDir
    elif (pass_file_area_dir and
    line.startswith(pass_file_area_dir)):
    bundle_path = line
    # Check for bundle patterns
    elif re.match(
    r'^[0-9a-f]{8}\.(su|mo|tu|we|th|fr|sa)[0-9a-z]$',
    line, re.I):
    bundle_path = os.path.join(directory, line)
    elif re.match(r'\.tic$', line, re.I):
    bundle_path = os.path.join(directory, line)
    elif re.match(r'^[0-9a-f]{8}\.pkt$', line, re.I):
    bundle_path = os.path.join(directory, line)

    if bundle_path and os.path.exists(bundle_path):
    b_stat = os.stat(bundle_path)
    b_size = b_stat.st_size
    b_mtime = b_stat.st_mtime

    if (node not in minmtime or
    b_mtime < minmtime[node]):
    minmtime[node] = b_mtime

    # Categorize
    if re.search(
    r'\.(su|mo|tu|we|th|fr|sa)[0-9a-z]$',
    line, re.I):
    echomail[node] = echomail.get(node, 0) + b_size
    elif (bundle_path.endswith('.pkt') and
    is_echomail_ctrl):
    echomail[node] = echomail.get(node, 0) + b_size
    elif bundle_path.endswith('.tic'):
    files[node] = files.get(node, 0) + b_size
    elif (pass_file_area_dir and
    bundle_path.startswith(pass_file_area_dir)):
    files[node] = files.get(node, 0) + b_size
    elif line.startswith('/') and is_file_ctrl:
    files[node] = files.get(node, 0) + b_size
    elif line.startswith('/'):
    files[node] = files.get(node, 0) + b_size
    except Exception as e:
    print(f"WARN: Could not process {ctrl_file}: {e}",
    file=sys.stderr)


    def process_fileboxes(box_dir: str, minmtime: Dict, netmail: Dict,
    echomail: Dict, files: Dict):
    """Process filebox directory"""
    node = unbox(box_dir)
    if not node:
    return

    # Find all files in the filebox
    patterns = ['*.[IiCcDdOoHh][Uu][Tt]',
    '*.[Ss][Uu][0-9a-zA-Z]', '*.[Mm][Oo][0-9a-zA-Z]',
    '*.[Tt][Uu][0-9a-zA-Z]', '*.[Ww][Ee][0-9a-zA-Z]',
    '*.[Tt][Hh][0-9a-zA-Z]', '*.[Ff][Rr][0-9a-zA-Z]',
    '*.[Ss][Aa][0-9a-zA-Z]']

    file_list = []
    for pattern in patterns:
    file_list.extend(glob.glob(os.path.join(box_dir, pattern)))

    for fpath in file_list:
    if not os.path.isfile(fpath):
    continue

    stat_info = os.stat(fpath)
    size = stat_info.st_size
    mtime = stat_info.st_mtime

    if size == 0:
    continue

    if node not in minmtime or mtime < minmtime[node]:
    minmtime[node] = mtime

    filename = os.path.basename(fpath)
    if re.search(r'ut$', filename, re.I):
    netmail[node] = netmail.get(node, 0) + size
    elif re.search(r'\.(su|mo|tu|we|th|fr|sa)[0-9a-z]$',
    filename, re.I):
    echomail[node] = echomail.get(node, 0) + size
    else:
    files[node] = files.get(node, 0) + size


    def main():
    # Get fidoconfig path
    fidoconfig = os.environ.get('FIDOCONFIG')

    if len(sys.argv) == 2:
    if sys.argv[1] in ['-h', '--help', '-?', '/?', '/h']:
    usage()
    fidoconfig = sys.argv[1]
    elif not fidoconfig:
    usage()

    if not os.path.isfile(fidoconfig):
    print(f"\n'{fidoconfig}' is not a fidoconfig file\n")
    usage()

    # Read config
    print(f"Showold.py version '{VERSION}'")
    config = read_fidoconfig(fidoconfig)

    if 'address' not in config:
    print("\nYour FTN address is not defined\n", file=sys.stderr)
    sys.exit(1)

    # Parse default zone
    addr_match = re.match(r'^(\d+):\d+/\d+', config['address'])
    if not addr_match:
    print("\nYour FTN address has a syntax error\n",
    file=sys.stderr)
    sys.exit(1)
    def_zone = int(addr_match.group(1))

    if 'outbound' not in config:
    print("\nOutbound is not defined\n", file=sys.stderr)
    sys.exit(1)

    outbound = config['outbound']
    if not os.path.isdir(outbound):
    print(f"\nOutbound '{outbound}' is not a directory\n",
    file=sys.stderr)
    sys.exit(1)

    # Get parent directory for finding all outbounds
    husky_base_dir = os.path.dirname(outbound)
    outbound_basename = os.path.basename(outbound)

    print(f"Searching for outbounds in: '{husky_base_dir}'")

    # Find directories
    outbound_dirs = find_outbounds(husky_base_dir, outbound_basename)

    fileboxes_dir = config.get('fileboxesdir', '')
    filebox_dirs = []
    if fileboxes_dir and os.path.isdir(fileboxes_dir):
    filebox_dirs = find_fileboxes(fileboxes_dir)

    pass_file_area_dir = config.get('passfileareadir', '')

    # Process all outbounds
    minmtime = {}
    netmail = {}
    echomail = {}
    files_dict = {}

    for ob_dir in outbound_dirs:
    process_bso_outbound(ob_dir, def_zone, pass_file_area_dir,
    minmtime, netmail, echomail, files_dict,
    outbound_basename)

    for fb_dir in filebox_dirs:
    process_fileboxes(fb_dir, minmtime, netmail, echomail,
    files_dict)

    # Print results
    print("+------------------+--------+-----------+-----------+"
    "-----------+")
    print("| Node | Days | NetMail | EchoMail "
    "| Files |")
    print("+------------------+--------+-----------+-----------+"
    "-----------+")

    for node in sorted(minmtime.keys(), key=node_sort_key):
    nm = netmail.get(node, 0)
    em = echomail.get(node, 0)
    fl = files_dict.get(node, 0)

    days = (time.time() - minmtime[node]) / (24 * 60 * 60)

    print(f"| {node:16s} |{days:7.0f} |"
    f"{nice_number_format(nm)} |"
    f"{nice_number_format(em)} |"
    f"{nice_number_format(fl)} |")

    print("+------------------+--------+-----------+-----------+"
    "-----------+")


    if __name__ == '__main__':
    main()
    === Cut ===



    --- GoldED+/LNX 1.1.5-b20250409
    * Origin: Dragon's Lair ---:- dragon.vk3heg.net -:--- Prt: 6800 (3:633/280)
  • From Tommi Koivula@2:221/1.1 to Stephen Walsh on Tue Oct 7 15:30:15 2025
    Hi Stephen.

    07 Oct 25 21:23:40, you wrote to me:

    Fixed...

    The script now:

    1. Actually does something with the "FIDOCONFIG" environment.
    1. Extracts the basename from the configured Outbound path
    (e.g., "fred" from /home/vk3heg/stats/fred/)
    2. Uses that basename instead of hardcoded "outbound" (doh.. I though I'd removed them... )


    Try this version.


    Yep, better now. :)

    VERSION = "3.1

    However, I had to fix this line as well as some long line wrappings.

    'Tommi

    ---
    * Origin: Point One (2:221/1.1)
  • From Stephen Walsh@3:633/280 to Tommi Koivula on Thu Oct 9 10:51:18 2025

    Hello Tommi!

    07 Oct 25 15:30, you wrote to me:

    Fixed...
    ]...]
    Try this version.

    Yep, better now. :)

    VERSION = "3.1

    However, I had to fix this line as well as some long line wrappings.

    Weird. The on disk version that I copied into the message has the trailing " after the 1...




    Stephen


    --- GoldED+/LNX 1.1.5-b20250409
    * Origin: Dragon's Lair ---:- dragon.vk3heg.net -:--- Prt: 6800 (3:633/280)
  • From Kai Richter@2:240/77 to Stephen Walsh on Thu Oct 9 12:34:08 2025
    Hello Stephen!

    06 Oct 25, Stephen Walsh wrote to All:

    Enjoy!

    Bug found.

    Showold.py version '3.0' +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+
    | 3:633/2744 | 0 | 0 | 963 | 0 |
    | 39:901/620 | 1 | 0 | 14525 | 0 | +------------------+--------+-----------+-----------+-----------+

    There are zero and one day holds in the list. Within a store and forward network that is actual mail on hold but not "old" mail.

    The report for that outbound should be "No old found". ;-)

    Regards

    Kai


    --- GoldED+/LNX 1.1.4.7
    * Origin: Monobox (2:240/77)
  • From Vorlon@3:633/280 to Kai Richter on Thu Oct 9 22:07:46 2025

    Hello Kai!

    09 Oct 25 12:34, you wrote to me:

    Showold.py version '3.0'

    See my message to Tommi about v3.1.

    +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files |
    +------------------+--------+-----------+-----------+-----------+
    | 3:633/2744 | 0 | 0 | 963 | 0 |
    | 39:901/620 | 1 | 0 | 14525 | 0 |
    +------------------+--------+-----------+-----------+-----------+

    There are zero and one day holds in the list. Within a store and
    forward network that is actual mail on hold but not "old" mail.

    The report for that outbound should be "No old found". ;-)

    That depends on what you want to call it on your system... My main use is to make sure that the nodes I feed and operate as the NC for are actually
    operational and collecting the stuff... My list is always changing depending on what is going on.. I've even had empty reports.


    [22:03:21] husky@zack:~/ $> showold.py
    Showold.py version '3.1'
    Searching for outbounds in: '/home/husky' +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+
    | 1:229/426 | 0 | 0 | 14866 | 0 |
    | 3:633/267 | 0 | 0 | 5933 | 0 |
    | 3:633/384 | 0 | 0 | 4327 | 0 |
    | 3:633/414 | 0 | 0 | 5933 | 0 |
    | 3:633/418 | 0 | 0 | 12463 | 0 |
    | 3:633/420 | 0 | 0 | 29960 | 0 |
    | 3:633/422 | 0 | 0 | 29960 | 0 |
    | 3:633/2744 | 0 | 0 | 14866 | 0 |
    | 3:712/848 | 0 | 0 | 8933 | 0 | +------------------+--------+-----------+-----------+-----------+



    Vorlon


    --- GoldED+/LNX 1.1.5-b20250409
    * Origin: Dragon's Lair ---:- dragon.vk3heg.net -:--- Prt: 6800 (3:633/280)
  • From Jay Harris@1:229/664 to Tommi Koivula on Thu Oct 9 11:35:28 2025
    On 07 Oct 2025, Tommi Koivula said the following...

    However, I had to fix this line as well as some long line wrappings.

    Ditto, once I fixed those, it runs well and looks to be about 39% faster than the perl version.

    $ time ./showold.pl ; echo ; time ./showold.py +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+
    | 21:3/110.10 | 0 | 0 | 695 | 0 |
    | 618:400/23.1 | 0 | 0 | 18605 | 0 |
    | 618:400/23.10 | 0 | 0 | 9971 | 0 | +------------------+--------+-----------+-----------+-----------+

    real 0m0.138s
    user 0m0.094s
    sys 0m0.043s

    Showold.py version '3.1'
    Searching for outbounds in: '/home/ubuntu/fido' +------------------+--------+-----------+-----------+-----------+
    | Node | Days | NetMail | EchoMail | Files | +------------------+--------+-----------+-----------+-----------+
    | 21:3/110.10 | 0 | 0 | 695 | 0 |
    | 618:400/23.1 | 0 | 0 | 18605 | 0 |
    | 618:400/23.10 | 0 | 0 | 9971 | 0 | +------------------+--------+-----------+-----------+-----------+

    real 0m0.084s
    user 0m0.055s
    sys 0m0.028s


    Jay

    ... Distrust your first impressions; they are invariably too favorable

    --- Mystic BBS v1.12 A49 2024/05/29 (Linux/64)
    * Origin: Northern Realms (1:229/664)