From 6b9c2b68ccc386cfb0cd2eefe350273d1432a1ee Mon Sep 17 00:00:00 2001 From: cristoforows Date: Fri, 26 Jun 2026 23:32:37 +0800 Subject: [PATCH] add /tags and /tag: browse notes by #hashtag New tags.py (auth-gated). /tags lists every hashtag found in captured notes with a per-note count; /tag lists the dated notes carrying that tag. Reuses drive_handler.list_markdown_files/read_file. Tag extraction/counting are pure functions. Closes #15. --- src/bot.py | 5 ++ src/drive_handler.py | 20 ++++++ src/tags.py | 153 +++++++++++++++++++++++++++++++++++++++++++ tests/test_tags.py | 35 ++++++++++ 4 files changed, 213 insertions(+) create mode 100644 src/tags.py create mode 100644 tests/test_tags.py diff --git a/src/bot.py b/src/bot.py index 3ba3551..abbe9e4 100644 --- a/src/bot.py +++ b/src/bot.py @@ -15,6 +15,7 @@ from config import config from google_auth import generate_auth_url, TokenStorage from timebox import build_timebox_handler +from tags import tags_command, tag_command import drive_handler # Configure logging @@ -38,6 +39,7 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N "You are authenticated with Google Drive.\n" "Send me any message and I'll save it to your Drive.\n\n" "Commands:\n" + "/tags - List hashtags in your notes (/tag to browse)\n" "/timebox - Plan a timeboxed schedule for tomorrow\n" "/status - Check connection status\n" "/logout - Disconnect Google Drive\n" @@ -69,6 +71,7 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No "Edit a message here and it updates in Drive too.\n\n" "Commands:\n" "/authenticate - Connect your Google Drive\n" + "/tags - List hashtags in your notes (/tag to browse)\n" "/timebox - Plan a timeboxed schedule for tomorrow\n" "/status - Check connection status\n" "/logout - Disconnect Google Drive\n" @@ -309,6 +312,8 @@ def register_handlers(application: Application) -> None: application.add_handler(CommandHandler("authenticate", authenticate_command)) application.add_handler(CommandHandler("status", status_command)) application.add_handler(CommandHandler("logout", logout_command)) + application.add_handler(CommandHandler("tags", tags_command)) + application.add_handler(CommandHandler("tag", tag_command)) application.add_handler(build_timebox_handler()) diff --git a/src/drive_handler.py b/src/drive_handler.py index bc55924..6f88518 100644 --- a/src/drive_handler.py +++ b/src/drive_handler.py @@ -250,6 +250,26 @@ def _replace_message_content( return file_content[:header_start] + new_block + file_content[content_end:] +def list_markdown_files(service, folder_id: str) -> list[dict]: + """Return the markdown files in the folder as [{id, name}, ...] (read-only).""" + try: + results = service.files().list( + q=f"mimeType='{MARKDOWN_MIME_TYPE}' and trashed=false and parents='{folder_id}'", + spaces='drive', + fields='files(id, name)', + pageSize=1000, + ).execute() + return results.get('files', []) + except Exception as e: + logger.error(f"Failed to list markdown files: {e}") + return [] + + +def read_file(service, file_id: str) -> str | None: + """Public read of a Drive file's text content, or None on failure.""" + return _download_file_content(service, file_id) + + def _download_file_content(service, file_id: str) -> str | None: """Download a file's content from Drive.""" try: diff --git a/src/tags.py b/src/tags.py new file mode 100644 index 0000000..ef8ec5d --- /dev/null +++ b/src/tags.py @@ -0,0 +1,153 @@ +"""/tags and /tag: surface the #hashtags already present in captured notes. + +Lightweight organization with zero extra capture effort — hashtags are read +straight out of the notes you already send. Tag extraction and counting are +pure functions so they can be unit-tested without Drive. +""" + +import logging +import re +from collections import Counter + +from telegram import Update +from telegram.ext import ContextTypes + +from config import config +import drive_handler + +logger = logging.getLogger(__name__) + +_BLOCK_RE = re.compile(r"\n") +# A hashtag: '#' not preceded by a word char, then word chars (must include a letter). +_TAG_RE = re.compile(r"(? list[str]: + """Captured note texts in order, stripped of the msg_id markers.""" + parts = _BLOCK_RE.split(content) + return [p.strip() for p in parts[1:] if p.strip()] + + +def extract_tags(text: str) -> set[str]: + """Distinct hashtags in `text`, lowercased and without the leading '#'.""" + return {m.group(1).lower() for m in _TAG_RE.finditer(text)} + + +def count_tags(notes: list[str]) -> Counter: + """Count how many notes each hashtag appears in (once per note).""" + counter: Counter = Counter() + for note in notes: + for tag in extract_tags(note): + counter[tag] += 1 + return counter + + +def _snippet(note: str) -> str: + one_line = " ".join(note.split()) + if len(one_line) > _SNIPPET_LEN: + one_line = one_line[:_SNIPPET_LEN].rstrip() + "…" + return one_line + + +def _date_of(file_name: str) -> str: + m = _DAILY_RE.match(file_name) + return m.group(1) if m else file_name + + +def _scan(service, folder_id: str): + """Yield (date, note) for the most recent daily files, newest first.""" + files = drive_handler.list_markdown_files(service, folder_id) + files = sorted(files, key=lambda f: f.get("name", ""), reverse=True)[:_MAX_FILES] + for f in files: + content = drive_handler.read_file(service, f["id"]) or "" + date = _date_of(f.get("name", "")) + for note in parse_blocks(content): + yield date, note + + +async def _prepare(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Auth-gate and resolve (service, folder_id), or reply and return None.""" + token_storage = context.bot_data.get("token_storage") + user_id = update.effective_user.id + if not token_storage or not token_storage.is_authenticated(user_id): + await update.message.reply_text( + "Please authenticate with Google Drive first using /authenticate" + ) + return None + + service = drive_handler.get_drive_service(user_id, token_storage) + if not service: + await update.message.reply_text( + "Your Google Drive session expired — please /logout then /authenticate." + ) + return None + + folder_id = drive_handler.get_or_create_folder(service, config.drive_folder_name) + if not folder_id: + await update.message.reply_text( + "Couldn't reach your Drive folder — please try again later." + ) + return None + return service, folder_id + + +async def tags_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /tags - list all hashtags with how many notes use each.""" + prepared = await _prepare(update, context) + if not prepared: + return + service, folder_id = prepared + + counts = count_tags([note for _, note in _scan(service, folder_id)]) + if not counts: + await update.message.reply_text( + "No #hashtags found yet. Add #tags to your notes to organize them, " + "then browse with /tag ." + ) + return + + lines = ["šŸ· Tags:"] + for tag, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])): + lines.append(f"#{tag} ({n})") + reply = "\n".join(lines) + if len(reply) > _MAX_REPLY: + reply = reply[:_MAX_REPLY] + "\n…(truncated)" + await update.message.reply_text(reply) + + +async def tag_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /tag - list notes carrying that hashtag.""" + name = (" ".join(context.args).strip().lstrip("#").lower()) if context.args else "" + if not name: + await update.message.reply_text("Usage: /tag \ne.g. /tag todo") + return + + prepared = await _prepare(update, context) + if not prepared: + return + service, folder_id = prepared + + hits = [] + for date, note in _scan(service, folder_id): + if name in extract_tags(note): + hits.append((date, _snippet(note))) + if len(hits) >= _MAX_HITS: + break + + if not hits: + await update.message.reply_text(f"No notes tagged #{name}.") + return + + lines = [f"šŸ· #{name} — {len(hits)} note(s):", ""] + for date, snip in hits: + lines.append(f"[{date}] {snip}") + reply = "\n".join(lines) + if len(reply) > _MAX_REPLY: + reply = reply[:_MAX_REPLY] + "\n…(truncated)" + await update.message.reply_text(reply) diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 0000000..2e8e6d1 --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,35 @@ +from tags import extract_tags, count_tags, parse_blocks + + +def test_extract_tags_lowercased_distinct(): + assert extract_tags("ship #Work then more #work #todo") == {"work", "todo"} + + +def test_extract_tags_ignores_mid_word_hash_and_numbers_only(): + # '#' inside a word is not a tag; a numbers-only '#123' is not a tag. + assert extract_tags("email a#b about issue #123") == set() + assert extract_tags("plan #q3goals") == {"q3goals"} + + +def test_extract_tags_none(): + assert extract_tags("just a plain note") == set() + + +def test_count_tags_counts_notes_not_occurrences(): + notes = [ + "#work standup #work again", # counts once for 'work' + "#work review", + "#todo buy milk", + ] + counts = count_tags(notes) + assert counts["work"] == 2 + assert counts["todo"] == 1 + + +def test_parse_blocks(): + content = ( + "# Telegram Messages\n\n" + "\n#work thing\n" + "\nplain\n" + ) + assert parse_blocks(content) == ["#work thing", "plain"]