From 34abd50f95e449560f162bf5097d087cc748ca23 Mon Sep 17 00:00:00 2001 From: Zodiac Date: Mon, 17 Feb 2025 17:10:25 -0800 Subject: [PATCH] add notes plugin --- plugins/notes.py | 557 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 557 insertions(+) create mode 100644 plugins/notes.py diff --git a/plugins/notes.py b/plugins/notes.py new file mode 100644 index 0000000..40cb1c6 --- /dev/null +++ b/plugins/notes.py @@ -0,0 +1,557 @@ +#!/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 [...] + View or update today's daily note. + + daily [--date ] [...] + View or update a daily note for the specified date (default is today). + + 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'). + + 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.1 +Date: 2025-02-17 +""" + +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()} + +""" + + +# ----------------------------------------------------------------------------- +# 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 + async def note(self, mask, target, args): + """ + Unified note command. + + %%note today [<content>...] + %%note daily [--date <date>] [<content>...] + %%note general <title> [--tags <tags>] [<content>...] + %%note list [<type>] + %%note search <type> <keyword> + %%note summary + + Options: + --date <date> Date in YYYY-MM-DD format. + --tags <tags> Tags for a general note. + <type> Either 'daily' or 'general'. + <title> Title for a general note. + <content> Content to update the note. + <keyword> Keyword to search in notes. + """ + 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) + else: + help_msg = ircstyle.style("Usage: !note <subcommand> [options]\n", fg="red", bold=True) + help_msg += "Subcommands: today, daily, general, list, search, summary" + self.bot.privmsg(target, help_msg) + 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) + ) + + async def _note_today(self, mask, target, args): + """ + Handle the 'today' subcommand for daily notes. + + If content is provided, update today's note; otherwise, display it. + """ + day = today_str() + content_list = args.get('<content>') + try: + note = self._get_daily_note(day) + if content_list: + new_content = " ".join(content_list) + if note: + self.daily_table.update( + {'content': new_content, 'updated': now_str()}, + self.User.date == day + ) + else: + 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} updated.", 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; otherwise, display it. + """ + 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) + if note: + self.daily_table.update( + {'content': new_content, 'updated': now_str()}, + self.User.date == day + ) + else: + 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} updated.", 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. + + 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 + daily_notes = self.daily_table.all() + general_notes = self.general_table.all() + + # Filter valid daily notes + valid_daily_dates = [n.get('date') for n in daily_notes if isinstance(n.get('date'), str)] + # Filter valid general notes + valid_general_titles = [n.get('title') for n in general_notes if isinstance(n.get('title'), str)] + + if not valid_daily_dates and not valid_general_titles: + msg = ircstyle.style("No notes found.", fg="red", bold=True) + else: + msg = ircstyle.style("📝 All Notes:", fg="blue", bold=True) + "\n" + if valid_daily_dates: + msg += ircstyle.style("📅 Daily Notes:", fg="yellow", bold=True) + "\n" + msg += "\n".join([f"• {date}" for date in sorted(valid_daily_dates)]) + "\n" + if valid_general_titles: + msg += ircstyle.style("📋 General Notes:", fg="cyan", bold=True) + "\n" + msg += "\n".join([f"• {title}" for title in sorted(valid_general_titles)]) + elif note_type == 'daily': + notes = self.daily_table.all() + # Filter only notes with a valid date string + valid_dates = [n.get('date') for n in notes if isinstance(n.get('date'), str)] + if not valid_dates: + msg = ircstyle.style("No daily notes found.", fg="red", bold=True) + else: + dates = sorted(valid_dates) + msg = ircstyle.style("📅 Daily Notes:", fg="blue", bold=True) + "\n" + msg += "\n".join([f"• {date}" for date in dates]) + elif note_type == 'general': + notes = self.general_table.all() + if not notes: + msg = ircstyle.style("No general notes found.", fg="red", bold=True) + else: + titles = sorted(n['title'] for n in notes if isinstance(n.get('title'), str)) + msg = ircstyle.style("📋 General Notes:", fg="blue", bold=True) + "\n" + msg += "\n".join([f"• {title}" for title in titles]) + 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