#!/usr/bin/env python # -*- coding: utf-8 -*- """ IRC Bot Plugin for Note Taking using TinyDB. This plugin provides a single !note command with subcommands to manage both daily and general notes. It supports the following subcommands: today [--add|--replace] [...] View or update today's daily note. If content is provided and a note for today already exists, a warning is shown unless you specify --add (to append) or --replace (to replace). daily [--date ] [--add|--replace] [...] View or update a daily note for the specified date (default is today). If content is provided and a note already exists, a warning is shown unless you specify --add (to append) or --replace (to replace). general [--tags <tags>] [<content>...] View or update a general note with the given title. Optionally specify tags. list <type> List all notes of the specified type (either 'daily' or 'general'). The content of each note is displayed truncated. search <type> <keyword> Search for a keyword in notes of the specified type. summary Display a summary of note counts. Notes are stored persistently in the TinyDB database file "notes.json". Daily notes are keyed by date (YYYY-MM-DD) and general notes by a slugified title. Dependencies: - irc3 - ircstyle - tinydb Author: Your Name Version: 1.3 Date: 2025-02-18 """ import re from datetime import datetime, date from tinydb import TinyDB, Query import irc3 from irc3.plugins.command import command import ircstyle import logging # ----------------------------------------------------------------------------- # Helper Functions # ----------------------------------------------------------------------------- def slugify(text): """ Convert text to a slug (lowercase, underscores, alphanumerics only). Args: text (str): The text to slugify. Returns: str: A slugified version of the text. """ text = text.lower() text = re.sub(r'\s+', '_', text) text = re.sub(r'[^a-z0-9_-]', '', text) return text def now_str(): """ Return the current datetime as an ISO formatted string. Returns: str: The current datetime. """ return datetime.now().isoformat() def today_str(): """ Return today's date in ISO format (YYYY-MM-DD). Returns: str: Today's date. """ return date.today().isoformat() def default_daily_template(day): """ Generate a default daily note template. Args: day (str): The date string (YYYY-MM-DD). Returns: str: Default content for a daily note. """ return f"""# Daily Note for {day} ## To do: ## Tomorrow: ## Reminder: ## Journal: """ def default_general_template(title, tags): """ Generate a default general note template. Args: title (str): The note title. tags (str): Tags for the note. Returns: str: Default content for a general note. """ tag_line = f"Tags: {tags}" if tags else "" return f"""# General Note: {title} {tag_line} Created on: {now_str()} """ def truncate(text, length=50): """ Truncate the text to a specified length, appending "..." if truncated. Args: text (str): The text to truncate. length (int): Maximum length of the returned text. Returns: str: The truncated text. """ if not isinstance(text, str): text = str(text) return text if len(text) <= length else text[:length] + "..." # ----------------------------------------------------------------------------- # NoteTaking Plugin # ----------------------------------------------------------------------------- @irc3.plugin class NoteTaking: """ IRC3 plugin for unified note taking. Provides a single !note command with subcommands to create, update, view, list, and search both daily and general notes stored in TinyDB. """ requires = [ 'irc3.plugins.userlist', # Ensure userlist plugin is loaded, if using user-specific notes ] def __init__(self, bot): """ Initialize the NoteTaking plugin. Args: bot (irc3.IrcBot): The IRC bot instance. """ self.bot = bot # Configuration options with defaults self.db_filename = self.bot.config.get('note_db_filename', 'notes.json') self.daily_template = self.bot.config.get('daily_template', default_daily_template) self.general_template = self.bot.config.get('general_template', default_general_template) try: self.db = TinyDB(self.db_filename) self.daily_table = self.db.table('daily') self.general_table = self.db.table('general') except Exception as e: self.bot.log.error(f"Error initializing TinyDB: {e}") raise # Re-raise to prevent the plugin from loading self.User = Query() self.log = logging.getLogger(__name__) @command(aliases=['notes'], public=True, options_first=False, use_shlex=True) async def note(self, mask, target, args): """ %%note today [--add|--replace] [<content>...] %%note daily [--date=<date>] [--add|--replace] [<content>...] %%note general <title> [--tags=<tags>] [<content>...] %%note list [<type>] %%note search <type> <keyword> %%note summary %%note """ # If no subcommand is provided, show the help message. if not any(args.get(key) for key in ('today', 'daily', 'general', 'list', 'search', 'summary')): # If there's content provided without a subcommand, treat it as a daily note update. if args.get('<content>'): await self._note_today(mask, target, args) else: self._show_help(target) return try: if args.get('today'): await self._note_today(mask, target, args) elif args.get('daily'): await self._note_daily(mask, target, args) elif args.get('general'): await self._note_general(mask, target, args) elif args.get('list'): await self._note_list(target, args) elif args.get('search'): await self._note_search(target, args) elif args.get('summary'): await self._note_summary(target) except Exception as e: self.bot.log.error(f"Error processing note command: {e}", exc_info=True) self.bot.privmsg( target, ircstyle.style(f"❌ Error processing command: {e}", fg="red", bold=True) ) def _show_help(self, target): help_lines = [ ircstyle.style("Usage: !note <subcommand> [options]", fg="red", bold=True), "Subcommands:", f"{ircstyle.style(' today [--add|--replace] [<content>...]', fg='yellow')} - View or update today's daily note.", f"{ircstyle.style(' daily [--date <date>] [--add|--replace] [<content>...]', fg='yellow')} - View or update a daily note for a specific date (default is today).", f"{ircstyle.style(' general <title> [--tags <tags>] [<content>...]', fg='yellow')} - View or update a general note with the given title. Optionally, specify tags.", f"{ircstyle.style(' list [daily|general]', fg='yellow')} - List all notes of a specific type with truncated content.", f"{ircstyle.style(' search <type> <keyword>', fg='yellow')} - Search for a keyword in notes of a specific type.", f"{ircstyle.style(' summary', fg='yellow')} - Display a summary of note counts.", ] for line in help_lines: self.bot.privmsg(target, line) async def _note_today(self, mask, target, args): """ Handle the 'today' subcommand for daily notes. If content is provided, update today's note. If a note already exists and content is provided, warn the user unless --add or --replace is specified. """ day = today_str() content_list = args.get('<content>') try: note = self._get_daily_note(day) if content_list: new_content = " ".join(content_list) # Check if both flags are provided if args.get('--add') and args.get('--replace'): self.bot.privmsg( target, ircstyle.style("❌ Error: Cannot use both --add and --replace simultaneously. Please choose one.", fg="red", bold=True) ) return if note: # If note exists but neither flag is provided, warn the user. if not (args.get('--add') or args.get('--replace')): warning = ircstyle.style( f"⚠️ Warning: Daily note for {day} already exists. Use --add to append to it or --replace to overwrite it.", fg="red", bold=True ) self.bot.privmsg(target, warning) return if args.get('--add'): appended_content = note.get('content') + "\n" + new_content self.daily_table.update( {'content': appended_content, 'updated': now_str()}, self.User.date == day ) msg = ircstyle.style("✅ ", fg="green") + ircstyle.style( f"Daily note for {day} updated (content added).", fg="blue", bold=True ) elif args.get('--replace'): self.daily_table.update( {'content': new_content, 'updated': now_str()}, self.User.date == day ) msg = ircstyle.style("✅ ", fg="green") + ircstyle.style( f"Daily note for {day} replaced.", fg="blue", bold=True ) else: # No existing note; create a new one. self.daily_table.insert({ 'date': day, 'content': new_content, 'created': now_str(), 'updated': now_str() }) msg = ircstyle.style("✅ ", fg="green") + ircstyle.style( f"Daily note for {day} created.", fg="blue", bold=True ) else: if not note: default_content = self.daily_template(day) self.daily_table.insert({ 'date': day, 'content': default_content, 'created': now_str(), 'updated': now_str() }) note = self._get_daily_note(day) msg = ircstyle.style( f"📝 Daily Note for {day}:\n", fg="yellow", bold=True ) + note.get('content') self.bot.privmsg(target, msg) except Exception as e: self.bot.log.error(f"Error in _note_today: {e}", exc_info=True) self.bot.privmsg( target, ircstyle.style(f"❌ Error: {e}", fg="red", bold=True) ) async def _note_daily(self, mask, target, args): """ Handle the 'daily' subcommand for daily notes with an optional date. If content is provided, update the note. If a note already exists and content is provided, warn the user unless --add or --replace is specified. """ day = args.get('--date') or today_str() try: datetime.strptime(day, '%Y-%m-%d') # Validate date format except ValueError: self.bot.privmsg( target, ircstyle.style("❌ Invalid date format. Use YYYY-MM-DD.", fg="red", bold=True) ) return content_list = args.get('<content>') try: note = self._get_daily_note(day) if content_list: new_content = " ".join(content_list) # Check if both flags are provided if args.get('--add') and args.get('--replace'): self.bot.privmsg( target, ircstyle.style("❌ Error: Cannot use both --add and --replace simultaneously. Please choose one.", fg="red", bold=True) ) return if note: if not (args.get('--add') or args.get('--replace')): self.bot.privmsg( target, ircstyle.style(f"⚠️ Warning: Daily note for {day} already exists. Use --add to append or --replace to overwrite.", fg="red", bold=True) ) return if args.get('--add'): appended_content = note.get('content') + "\n" + new_content self.daily_table.update( {'content': appended_content, 'updated': now_str()}, self.User.date == day ) msg = ircstyle.style("✅ ", fg="green") + ircstyle.style( f"Daily note for {day} updated (content added).", fg="blue", bold=True ) elif args.get('--replace'): self.daily_table.update( {'content': new_content, 'updated': now_str()}, self.User.date == day ) msg = ircstyle.style("✅ ", fg="green") + ircstyle.style( f"Daily note for {day} replaced.", fg="blue", bold=True ) else: # Create new note if none exists. self.daily_table.insert({ 'date': day, 'content': new_content, 'created': now_str(), 'updated': now_str() }) msg = ircstyle.style("✅ ", fg="green") + ircstyle.style( f"Daily note for {day} created.", fg="blue", bold=True ) else: if not note: default_content = self.daily_template(day) self.daily_table.insert({ 'date': day, 'content': default_content, 'created': now_str(), 'updated': now_str() }) note = self._get_daily_note(day) msg = ircstyle.style( f"📝 Daily Note for {day}:\n", fg="yellow", bold=True ) + note.get('content') self.bot.privmsg(target, msg) except Exception as e: self.bot.log.error(f"Error in _note_daily: {e}", exc_info=True) self.bot.privmsg( target, ircstyle.style(f"❌ Error: {e}", fg="red", bold=True) ) async def _note_general(self, mask, target, args): """ Handle the 'general' subcommand for general notes. If content is provided, update the note; otherwise, display it. """ nick = mask.nick # Get the user's nickname # Retrieve <title> as a string (not a list) title = args.get('<title>') if not title: self.bot.privmsg( target, ircstyle.style("❌ ", fg="red") + ircstyle.style("Title is required.", fg="red", bold=True) ) return slug = slugify(title) tags = args.get('--tags') or "" content_list = args.get('<content>') try: # Now filter by owner (nick) so users only access their own general notes note = self._get_general_note(slug, nick) if content_list: new_content = " ".join(content_list) update_fields = {'content': new_content, 'updated': now_str()} if tags: update_fields['tags'] = tags if note: self.general_table.update( update_fields, (self.User.slug == slug) & (self.User.owner == nick) ) msg = ircstyle.style("✅ ", fg="green") + ircstyle.style( f"General note '{title}' updated.", fg="blue", bold=True ) else: self.general_table.insert({ 'title': title, 'slug': slug, 'tags': tags, 'content': new_content, 'created': now_str(), 'updated': now_str(), 'owner': nick # Store the owner of the note }) msg = ircstyle.style("✅ ", fg="green") + ircstyle.style( f"General note '{title}' created.", fg="blue", bold=True ) else: if not note: default_content = self.general_template(title, tags) self.general_table.insert({ 'title': title, 'slug': slug, 'tags': tags, 'content': default_content, 'created': now_str(), 'updated': now_str(), 'owner': nick # Store the owner of the note }) note = self._get_general_note(slug, nick) header = ircstyle.style( f"📝 General Note: {note.get('title')}\n", fg="yellow", bold=True ) tag_line = ircstyle.style( f"Tags: {note.get('tags')}\n", fg="cyan" ) msg = header + tag_line + note.get('content') self.bot.privmsg(target, msg) except Exception as e: self.bot.log.error(f"Error in _note_general: {e}", exc_info=True) self.bot.privmsg( target, ircstyle.style(f"❌ Error: {e}", fg="red", bold=True) ) async def _note_list(self, target, args): """ Handle the 'list' subcommand to list notes with truncated content. Usage: %%note list [<type>] where <type> is optional and can be 'daily' or 'general'. If <type> is not provided, both daily and general notes are listed. """ note_type = args.get('<type>') try: if not note_type: # List both daily and general notes with content truncated. daily_notes = self.daily_table.all() general_notes = self.general_table.all() if not daily_notes and not general_notes: msg = ircstyle.style("No notes found.", fg="red", bold=True) else: msg_lines = [ircstyle.style("📝 All Notes:", fg="blue", bold=True)] if daily_notes: msg_lines.append(ircstyle.style("📅 Daily Notes:", fg="yellow", bold=True)) # Sort daily notes by date. daily_sorted = sorted( [n for n in daily_notes if isinstance(n.get('date'), str)], key=lambda x: x['date'] ) for note in daily_sorted: date_str = note.get('date') snippet = truncate(note.get('content', '')) msg_lines.append(f" • {ircstyle.style(date_str, fg='green')}: {ircstyle.style(snippet, fg='white')}") if general_notes: msg_lines.append(ircstyle.style("📋 General Notes:", fg="cyan", bold=True)) # Sort general notes by title. general_sorted = sorted( [n for n in general_notes if isinstance(n.get('title'), str)], key=lambda x: x['title'] ) for note in general_sorted: title = note.get('title') snippet = truncate(note.get('content', '')) msg_lines.append(f" • {ircstyle.style(title, fg='green')}: {ircstyle.style(snippet, fg='white')}") msg = "\n".join(msg_lines) elif note_type == 'daily': notes = self.daily_table.all() valid_notes = [n for n in notes if isinstance(n.get('date'), str)] if not valid_notes: msg = ircstyle.style("No daily notes found.", fg="red", bold=True) else: msg_lines = [ircstyle.style("📅 Daily Notes:", fg="blue", bold=True)] daily_sorted = sorted(valid_notes, key=lambda x: x['date']) for note in daily_sorted: date_str = note.get('date') snippet = truncate(note.get('content', '')) msg_lines.append(f" • {ircstyle.style(date_str, fg='green')}: {ircstyle.style(snippet, fg='white')}") msg = "\n".join(msg_lines) elif note_type == 'general': notes = self.general_table.all() valid_notes = [n for n in notes if isinstance(n.get('title'), str)] if not valid_notes: msg = ircstyle.style("No general notes found.", fg="red", bold=True) else: msg_lines = [ircstyle.style("📋 General Notes:", fg="blue", bold=True)] general_sorted = sorted(valid_notes, key=lambda x: x['title']) for note in general_sorted: title = note.get('title') snippet = truncate(note.get('content', '')) msg_lines.append(f" • {ircstyle.style(title, fg='green')}: {ircstyle.style(snippet, fg='white')}") msg = "\n".join(msg_lines) else: msg = ircstyle.style("Usage: !note list [daily|general]", fg="red", bold=True) for line in msg.split('\n'): self.bot.privmsg(target, line) except Exception as e: self.bot.log.error(f"Error in _note_list: {e}", exc_info=True) self.bot.privmsg(target, ircstyle.style(f"❌ Error: {e}", fg="red", bold=True)) async def _note_search(self, target, args): """ Handle the 'search' subcommand to search notes by keyword. Usage: %%note search <type> <keyword> """ note_type = args.get('<type>') keyword = args.get('<keyword>') if note_type not in ('daily', 'general') or not keyword: msg = ircstyle.style("Usage: !note search <daily|general> <keyword>", fg="red", bold=True) self.bot.privmsg(target, msg) return try: if note_type == 'daily': notes = self.daily_table.search(self.User.content.search(keyword, flags=re.IGNORECASE)) header = "Daily" else: notes = self.general_table.search(self.User.content.search(keyword, flags=re.IGNORECASE)) header = "General" if not notes: msg = ircstyle.style(f"No {note_type} notes found with '{keyword}'.", fg="red", bold=True) else: results = [] for n in notes: identifier = n.get('date') if note_type == 'daily' else n.get('title') content_snippet = n.get('content')[:50] + "..." # Show a snippet of the content results.append(f"{identifier} ({content_snippet})") msg = ircstyle.style(f"{header} Notes matching '{keyword}':\n", fg="blue", bold=True) + "\n".join(results) for line in msg.split('\n'): self.bot.privmsg(target, line) except Exception as e: self.bot.log.error(f"Error in _note_search: {e}", exc_info=True) self.bot.privmsg( target, ircstyle.style(f"❌ Error: {e}", fg="red", bold=True) ) async def _note_summary(self, target): """ Handle the 'summary' subcommand to display note counts. """ try: daily_count = len(self.daily_table.all()) general_count = len(self.general_table.all()) lines = [ ircstyle.style("===== NOTE SUMMARY =====", fg="teal", bold=True), ircstyle.style(f"Daily notes: {daily_count}", fg="yellow"), ircstyle.style(f"General notes: {general_count}", fg="cyan"), ircstyle.style("===== END SUMMARY =====", fg="teal", bold=True) ] for line in lines: self.bot.privmsg(target, line) except Exception as e: self.bot.log.error(f"Error in _note_summary: {e}", exc_info=True) self.bot.privmsg( target, ircstyle.style(f"❌ Error: {e}", fg="red", bold=True) ) def _get_daily_note(self, day): """ Retrieve a daily note for the given day. Args: day (str): The date (YYYY-MM-DD). Returns: dict or None: The note if found, else None. """ try: results = self.daily_table.search(self.User.date == day) return results[0] if results else None except Exception as e: self.bot.log.error(f"Error in _get_daily_note: {e}", exc_info=True) return None def _get_general_note(self, slug, owner=None): """ Retrieve a general note by its slug and (optionally) owner. Args: slug (str): The slugified title. owner (str, optional): The owner of the note. Returns: dict or None: The note if found, else None. """ try: if owner: results = self.general_table.search((self.User.slug == slug) & (self.User.owner == owner)) else: results = self.general_table.search(self.User.slug == slug) return results[0] if results else None except Exception as e: self.bot.log.error(f"Error in _get_general_note: {e}", exc_info=True) return None