From 0e6c8485690eaa220728f4ed6a76ed2b212c9dd0 Mon Sep 17 00:00:00 2001 From: James Knight Date: Sat, 30 Nov 2024 17:10:53 +1300 Subject: [PATCH] initial commit - work in progress --- .cursorrules | 99 +++++++ .github/config.yml | 11 + .github/dependabot.yml | 34 +++ .gitignore | 173 ++---------- LICENSE | 42 +++ README.md | 141 +++++++++ __main__.py | 628 +++++++++++++++++++++++++++++++++++++++++ config.toml.example | 25 ++ doc | 49 ++++ markdown_handler.py | 183 ++++++++++++ move_docs.sh | 19 ++ repository.py | 234 +++++++++++++++ requirements.txt | 9 + setup.py | 34 +++ setup/__init__.py | 47 +++ setup/config.py | 190 +++++++++++++ setup/database.py | 88 ++++++ setup/display.py | 96 +++++++ setup/interactive.py | 117 ++++++++ watcher.py | 156 ++++++++++ 20 files changed, 2224 insertions(+), 151 deletions(-) create mode 100644 .cursorrules create mode 100644 .github/config.yml create mode 100644 .github/dependabot.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100755 __main__.py create mode 100644 config.toml.example create mode 100755 doc create mode 100755 markdown_handler.py create mode 100755 move_docs.sh create mode 100755 repository.py create mode 100644 requirements.txt create mode 100755 setup.py create mode 100644 setup/__init__.py create mode 100644 setup/config.py create mode 100644 setup/database.py create mode 100644 setup/display.py create mode 100644 setup/interactive.py create mode 100755 watcher.py diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..f41ded8 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,99 @@ +# Instructions + +When helping the user with code, write like you're having a chat. If the user chooses to get you to read this document, it means they would like you to use a more informal, conversational style, like a couple of relaxed senior developers that have a great work/life balance. + +Don't get too formal or structured with the docs - just write like you're explaining what you're doing to a colleague. Think of it as pair programming, but you're documenting your thought process as we go. + +For example, instead of: +"Migration script executed successfully. File verification completed." + +Write more like: +"Cool, the script ran and moved everything where it needed to go. Double-checked all the files are there too." + +A few things that help keep it natural: + +- Tell me what you're thinking about trying next +- If something seems off, just say so - like "hey, that import looks weird" +- Use emojis if they help explain the mood or what's happening ๐Ÿค” +- Keep it flowing like a real conversation +- If you're not sure about something, just ask + +The goal is to make it feel like we're sitting next to each other working through this stuff together. No need to get fancy with the documentation - just keep it clear and conversational. + +Remember: We're not writing a formal spec here, we're just two devs figuring stuff out and keeping notes so we remember what we did later. + +# Memory Management + +Hey future me! When working with users on large codebases, keep these tips in mind to stay performant: + +1. File Tracking: + - Only track files we're actively working on + - Drop tracking of files we've finished with + - Let the user know when you're dropping files, like: + "I'll stop tracking those other files for now to keep things snappy!" + +2. Conversation History: + - Regularly prune old parts of the conversation that aren't relevant + - Keep the current task's context and drop the rest + - Tell the user when you're doing this: + "Mind if I clear some of our older chat history? We've moved past that part!" + +3. Context Management: + - Focus on one feature/component at a time + - Ask the user if you can drop context of completed tasks + - Keep error messages and build outputs only while relevant + +Example conversation: +User: "Here are 50 files to look at..." +You: "Hey, that's a lot to keep in memory! Mind if I focus on just the files we're working on right now? I can drop the others and pick them up again when we need them." + +Remember: Being transparent about memory management helps the user understand why you might need to reload files or ask for context again. + +# Writing Style Examples + +Hey! When we're chatting about code and planning changes, let's keep things pretty casual and collaborative. But when documenting what we did, we should keep that clear and professional. + +Here's what I mean: + +When we're discussing changes, it's totally fine to say: +"I'm thinking we could use a script to handle all these file moves - what do you reckon?" + +But when documenting the actual changes, we want something more like: +"Migration script implemented to relocate files according to the new directory structure. Verification steps added to ensure file integrity post-migration." + +Some guidelines: + +- Keep our discussions natural and collaborative +- Document decisions and changes formally +- Include clear rationale for technical choices +- Maintain professional tone in documentation + +Python scripts are really handy for automating file operations and text processing. Discuss the approach that would work best for the user's specific needs. + +Remember: While we can be casual in our planning discussions, the documentation needs to be clear and professional for future maintainers. + +# Folder Structure Philosophy + +Hey! When it comes to organizing code, let's keep it simple and lean: + +1. Start Flat ๐Ÿฅž + - Keep everything in their root-most folder initially + - Don't create subfolders until they're actually needed + - Let the complexity emerge naturally + +2. When to Add Subfolders ๐Ÿ“ + - Wait until you have a clear pattern of related files + - Only add the subfolder that makes sense right now + - Don't create deep structures preemptively + +Example conversation: +You: "Should we create components/nodes/utils/helpers now?" +Me: "Nah, let's just keep it in components/ for now. We can move stuff when it gets crowded!" + +Remember: YAGNI (You Ain't Gonna Need It) applies to folder structure too. It's easier to: +- Start flat and organize later +- Add structure when it's obviously needed +- Keep things visible at the root level +- Avoid premature categorization + +Think of it like organizing your desk - you don't create a complex filing system for three pieces of paper! ๐Ÿ“ diff --git a/.github/config.yml b/.github/config.yml new file mode 100644 index 0000000..6374d9f --- /dev/null +++ b/.github/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: "๐Ÿ“š Documentation" + url: "https://github.com/jameswilliamknight/was-doing/wiki" + about: "Check out our documentation before creating an issue" + - name: "๐Ÿ’ฌ Discussions" + url: "https://github.com/jameswilliamknight/was-doing/discussions" + about: "Please ask and answer questions here" + - name: "๐Ÿค Commercial Use" + url: "https://github.com/jameswilliamknight/was-doing#license-" + about: "Contact us about commercial licensing" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d6a8af2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +version: 2 +updates: + # Python dependencies + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "python" + reviewers: + - "jknightdev" + assignees: + - "jknightdev" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "โฌ†๏ธ " + include: "scope" + reviewers: + - "jknightdev" + assignees: + - "jknightdev" diff --git a/.gitignore b/.gitignore index 82f9275..4d725af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,162 +1,33 @@ -# Byte-compiled / optimized / DLL files +# Python virtual environment +.venv/ +venv/ +ENV/ + +# Python cache files __pycache__/ *.py[cod] *$py.class -# C extensions -*.so - # Distribution / packaging -.Python -build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ +build/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json -# Pyre type checker -.pyre/ +# Database files +*.db +*.sqlite +*.sqlite3 -# pytype static type analyzer -.pytype/ +# Generated markdown files +*.md +!README.md -# Cython debug symbols -cython_debug/ +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Operating System +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6993655 --- /dev/null +++ b/LICENSE @@ -0,0 +1,42 @@ +"Commons Clause" License Condition v1.0 + +The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition. + +Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software. + +For purposes of the foregoing, "Sell" means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. + +Any license notice or attribution required by the License must also include this Commons Clause License Condition notice. + +--- + +MIT License + +Copyright (c) 2024 James Knight + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Contribution License Agreement + +By submitting a pull request or contribution to this project, you agree: +1. Your contributions will be licensed under the same terms as above +2. You have the right to license your contribution under these terms +3. You understand that the Commons Clause restrictions apply to derivative works diff --git a/README.md b/README.md new file mode 100644 index 0000000..b49b6ff --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# Was Doing (Work Documentation System) + +A development-friendly work documentation system that helps you track what you're doing as you're doing it! ๐Ÿš€ + +_This project started almost completely generated by Cursor AI! using `claude-3.5-sonnet-20241022`, so take everything with a grain of salt while the code is validated by humans._ +๐Ÿฆ _This canary clause will be removed when the code is fully validated._ + +## ๐ŸŽฏ New Here? Start Here! + +**[Click here for our Friendly Onboarding Guide](docs/wiki/onboarding.md)** - We'll walk you through everything step by step! + +## Quick Start + +```bash +# Install +mkdir -p ~/.local/bin +ln -s "$(pwd)/doc" ~/.local/bin/doc + +# First time setup +doc --setup + +# Start documenting +doc -n my-project # Create a context +doc -H "Started work" # Add history +doc -w # Watch mode +``` + +## Command Reference + +### Setup & Configuration +```bash +doc --setup # Run interactive setup +doc --verify # Check configuration +``` + +### Context Management +```bash +doc -c, --context NAME # Switch to a context +doc -n, --new-context # Create new context +doc -l, --list-contexts # List all contexts +``` + +### Entry Management +```bash +doc -H, --add-history # Add history entry +doc -s, --add-summary # Add summary entry +doc --history # View history entries +doc --summary # View summary entries +``` + +### Output Management +```bash +doc -w, --watch # Watch mode: auto-regenerate +doc -r, --hot-reload # Alias for --watch +doc -o, --output PATH # Set output path +doc -e, --export PATH # Export to PDF +``` + +## Documentation + +Our documentation is organized into several sections: + +- [Installation Guide](docs/wiki/installation.md) + + - System requirements + - Installation steps + - First time setup + - Troubleshooting + +- [Working with Contexts](docs/wiki/contexts.md) + + - Creating and switching contexts + - Adding entries + - Best practices + - Storage structure + +- [Watch Mode Guide](docs/wiki/watch-mode.md) + - Real-time updates + - Context integration + - Terminal setup + - Common issues + +- [Database Implementation](docs/wiki/database.md) + - SQLite structure + - Data safety + - Backup procedures + - Performance tips + +See our [complete documentation index](docs/wiki/index.md) for more guides and technical details. + +## Project Structure ๐Ÿ“ + +``` +was-doing/ +โ”œโ”€โ”€ ๐Ÿ“„ __main__.py # Entry Point & CLI +โ”œโ”€โ”€ ๐Ÿ“Š repository.py # Data Layer +โ”œโ”€โ”€ ๐Ÿ“ markdown_handler.py # Document Generation +โ”œโ”€โ”€ ๐Ÿ‘€ watcher.py # File System Monitor +โ”œโ”€โ”€ โš™๏ธ setup/ # Configuration +โ””โ”€โ”€ ๐Ÿ“š docs/ # Documentation + โ””โ”€โ”€ wiki/ # Detailed guides +``` + +## Contributing ๐Ÿค + +We love contributions! Here's how you can help: + +1. **Fork & Clone**: Get your own copy to work on +2. **Branch**: Create a feature branch +3. **Code**: Make your changes +4. **Test**: Ensure everything works +5. **Push & PR**: Submit your contribution + +All contributions are welcome: + +- ๐Ÿ› Bug fixes +- โœจ New features +- ๐Ÿ“š Documentation +- ๐ŸŽจ UI improvements +- ๐Ÿงช Tests + +Check out our [Contributing Guide](docs/wiki/contributing.md) for details. + +## License ๐Ÿ“œ + +This project is licensed under MIT + Commons Clause: + +- โœ… Free for personal use +- โœ… Free for non-commercial projects +- โœ… Modifications and improvements welcome +- ๐Ÿค Commercial use requires permission + +See [LICENSE](LICENSE) for details. + +For commercial use inquiries, please contact jknightdev@gmail.com or visit [jknightdev.com](https://jknightdev.com). + +## Development + +- [Project Structure](docs/wiki/project-structure.md) +- [Contributing Guide](docs/wiki/contributing.md) +- [Development Setup](docs/wiki/development.md) diff --git a/__main__.py b/__main__.py new file mode 100755 index 0000000..5aa7c95 --- /dev/null +++ b/__main__.py @@ -0,0 +1,628 @@ +#!/usr/bin/env python3 + +""" +Main Entry Point for Work Documentation System + +This module follows the Single Responsibility Principle by acting as the composition root +and command-line interface coordinator. It delegates all business logic to appropriate modules. +""" + +# Standard library imports +import argparse +from pathlib import Path +import sys +from typing import Optional + +# Third-party imports +from rich.console import Console +from rich.panel import Panel +import markdown +from weasyprint import HTML, CSS + +# Local application imports +from repository import WorkLogRepository +from markdown_handler import MarkdownGenerator +from watcher import watch_database +from setup import ( + ensure_setup, + setup_wizard, + Config, + get_config_path, + get_active_context, + set_active_context, + list_contexts, + create_context, + delete_context, + add_history_entry, + add_summary_entry, +) +from setup.display import ( + show_panel, + success_panel, + error_panel, + info_panel, + warning_panel, + show_help_panel, +) + +console = Console() + +DEFAULT_CSS = """ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.6; + max-width: 800px; + margin: 0 auto; + padding: 2em; +} +h1, h2, h3 { color: #333; } +code { background: #f4f4f4; padding: 0.2em 0.4em; border-radius: 3px; } +pre { background: #f8f8f8; padding: 1em; border-radius: 5px; overflow-x: auto; } +a { color: #0366d6; } +""" + + +def export_to_pdf(markdown_path: Path, pdf_path: Path) -> None: + """Convert a markdown file to PDF with nice styling""" + try: + # Read markdown content + with open(markdown_path, "r") as f: + md_content = f.read() + + # Convert to HTML + html = markdown.markdown(md_content, extensions=["extra", "codehilite"]) + + # Create styled HTML document + html_doc = f""" + + + + + + + + {html} + + + """ + + # Generate PDF + HTML(string=html_doc).write_pdf(pdf_path, stylesheets=[CSS(string=DEFAULT_CSS)]) + console.print(f"๐Ÿ“„ Exported PDF to: {pdf_path}") + + except Exception as e: + console.print(f"[red]โŒ Failed to export PDF: {str(e)}[/red]") + sys.exit(1) + + +class RichHelpFormatter(argparse.HelpFormatter): + """Custom formatter for creating Rich-styled help output""" + def __init__(self, prog): + super().__init__(prog, max_help_position=40, width=80) + + def format_help(self): + """Override to return our custom formatted help""" + return "" # We'll handle the actual formatting in the parser + + +def main(): + parser = argparse.ArgumentParser( + description="Document your work with history and summary entries.", + formatter_class=RichHelpFormatter, + usage=argparse.SUPPRESS, # We'll handle usage in our custom help + ) + + parser.add_argument( + "--setup", action="store_true", help="Run the interactive setup wizard" + ) + parser.add_argument( + "--verify", action="store_true", help="Verify if Was Doing is properly set up" + ) + + # Context management + context_group = parser.add_argument_group("Context Management") + context_group.add_argument( + "--context", + "-c", + nargs='?', # Make it optional to trigger our custom handling + metavar="CONTEXT_NAME", + help="Set or switch to a context (project/task)", + ) + context_group.add_argument( + "--list-contexts", "-l", action="store_true", help="List all available contexts" + ) + context_group.add_argument( + "--new-context", + "-n", + metavar="CONTEXT_NAME", + help="Create a new context", + ) + + # Entry management + entry_group = parser.add_argument_group("Entry Management") + entry_group.add_argument("-H", "--add-history", metavar="TEXT", help="Add a history entry") + entry_group.add_argument("-s", "--add-summary", metavar="TEXT", help="Add a summary entry") + entry_group.add_argument("--history", action="store_true", help="View history entries") + entry_group.add_argument("--summary", action="store_true", help="View summary entries") + + # Output management + output_group = parser.add_argument_group("Output Management") + output_group.add_argument( + "--watch", + "-w", + action="store_true", + help="Watch mode: automatically regenerate markdown on database changes", + ) + output_group.add_argument( + "--hot-reload", "-r", action="store_true", help="Alias for --watch" + ) + output_group.add_argument( + "--output", + "-o", + help="Output path for the generated markdown file", + default="work_doc.md", + ) + output_group.add_argument( + "--export", + "-e", + help="Export markdown file to PDF (e.g. --export output.pdf)", + metavar="PDF_PATH", + ) + + parser.add_argument("--db-path", help="Path to the SQLite database") + + if len(sys.argv) == 1 or "--help" in sys.argv or "-h" in sys.argv: + # Main help panel + console.print(Panel( + "[bold blue]Was Doing[/]\n" + "[dim]A development-friendly work documentation system[/]", + title="๐Ÿ‘‹ Welcome", + border_style="blue", + width=80 + )) + + # Setup commands panel + setup_panel = Panel( + "[bold]--setup[/] Run the interactive setup wizard\n" + "[bold]--verify[/] Check if Was Doing is properly configured", + title="๐Ÿ”ง Setup", + border_style="cyan", + width=80 + ) + + # Context management panel + context_panel = Panel( + "[bold]-c, --context[/] NAME Switch to a different context\n" + "[bold]-n, --new-context[/] NAME Create a new context\n" + "[bold]-l, --list-contexts[/] List all available contexts", + title="๐Ÿ“‚ Context Management", + border_style="blue", + width=80 + ) + + # Entry management panel + entry_panel = Panel( + "[bold]-H, --add-history[/] TEXT Add a history entry\n" + "[bold]-s, --add-summary[/] TEXT Add a summary entry", + title="๐Ÿ“ Entry Management", + border_style="green", + width=80 + ) + + # Output management panel + output_panel = Panel( + "[bold]-w, --watch[/] Watch mode: auto-regenerate on changes\n" + "[bold]-r, --hot-reload[/] Alias for --watch\n" + "[bold]-o, --output[/] PATH Output path for markdown (default: work_doc.md)\n" + "[bold]-e, --export[/] PATH Export to PDF", + title="๐Ÿ“„ Output Management", + border_style="magenta", + width=80 + ) + + # Examples panel + examples_panel = Panel( + "# Create a new context and add entries\n" + "[dim]$[/] [bold]doc -n[/] my-project\n" + "[dim]$[/] [bold]doc -H[/] \"Started working on authentication\"\n" + "[dim]$[/] [bold]doc -s[/] \"Implemented OAuth2 flow\"\n\n" + "# Switch context and use watch mode\n" + "[dim]$[/] [bold]doc -c[/] another-project\n" + "[dim]$[/] [bold]doc -w[/]", + title="๐Ÿ’ก Examples", + border_style="yellow", + width=80 + ) + + # Print all panels + console.print(setup_panel) + console.print(context_panel) + console.print(entry_panel) + console.print(output_panel) + console.print(examples_panel) + sys.exit(0) + + try: + args = parser.parse_args() + + # Check if setup is needed before any other operations + if not ensure_setup(): + console.print("[yellow]Was Doing needs to be set up first.[/yellow]") + console.print("Run: doc --setup") + sys.exit(1) + + # Handle verify command + if args.verify: + config_path = get_config_path() + if not config_path or not (config_path / "config.toml").exists(): + console.print(Panel( + "[red]Was Doing is not configured![/]\n\n" + "[yellow]Run [bold]doc --setup[/] to configure Was Doing.[/]", + title="โŒ Setup Required", + border_style="red", + width=80 + )) + sys.exit(1) + else: + console.print(Panel( + "[green]Was Doing is properly configured![/]\n\n" + f"[dim]Configuration:[/] {config_path}/config.toml\n" + f"[dim]Tasks Directory:[/] {config_path}/tasks\n" + f"[dim]Active Context:[/] {get_active_context() or '[yellow]None[/]'}", + title="โœ… Setup Verified", + border_style="green", + width=80 + )) + sys.exit(0) + + # Get active context or exit if none + active_context = get_active_context() + if not active_context: + if args.add_history or args.add_summary or args.watch: + error_panel( + "[yellow]No active context.[/]\n\n" + "Set one with [bold]--context[/] or create new with [bold]--new-context[/]", + title="โŒ No Context" + ) + sys.exit(1) + + # Handle history/summary entries first if we have them + if args.add_history: + add_history_entry(args.add_history) + success_panel( + f"[green]Added history entry:[/]\n{args.add_history}", + title="๐Ÿ“ History Entry Added" + ) + sys.exit(0) + + if args.add_summary: + add_summary_entry(args.add_summary) + success_panel( + f"[green]Added summary entry:[/]\n{args.add_summary}", + title="๐Ÿ“ Summary Entry Added" + ) + sys.exit(0) + + # Handle watch mode + if args.watch: + info_panel( + f"[green]Watching for changes in context: {active_context}[/]\n\n" + "[dim]Press Ctrl+C to stop[/]", + title="๐Ÿ‘€ Watch Mode" + ) + watch_database(active_context) + sys.exit(0) + + # Only show context selector for explicit context commands + if (hasattr(args, 'context') and args.context is None) or args.list_contexts: + try: + contexts = list_contexts() + active = get_active_context() + + if not contexts: + console.print(Panel( + "[yellow]No contexts found.[/]\n\n" + "Create one with: [bold]doc -n CONTEXT_NAME[/]", + title="๐Ÿ“‚ Contexts", + border_style="blue", + width=80 + )) + sys.exit(0) + + if hasattr(args, 'context') and args.context is None: + # Interactive context selection + from inquirer import prompt, List + + # Create choices list with active context first + sorted_contexts = sorted(contexts, key=lambda x: (x != active, x.lower())) + choices = [ + (f"{'* ' if ctx == active else ' '}{ctx}{' (active)' if ctx == active else ''}", ctx) + for ctx in sorted_contexts + ] + choices.extend([ + ('+ Create new context', 'new'), + ('- Delete a context', 'delete') + ]) + + questions = [ + List('context', + message="Select a context or action", + choices=choices) + ] + + answer = prompt(questions) + if answer: + if answer['context'] == 'new': + # Ask for new context name + from inquirer import Text + name_q = [ + Text('name', + message="Enter new context name", + validate=lambda _, x: bool(x and all(c.isalnum() or c in '-_' for c in x))) + ] + name_answer = prompt(name_q) + if name_answer and name_answer['name']: + new_name = name_answer['name'] + if create_context(new_name): + set_active_context(new_name) + success_panel( + f"[green]Created and switched to: {new_name}[/]", + title="โœจ Context Created" + ) + else: + error_panel( + f"[red]Failed to create context: {new_name}[/]\n\n" + "[yellow]Context names must be alphanumeric (with hyphens or underscores)[/]" + ) + elif answer['context'] == 'delete': + # Show context selection for deletion + delete_choices = [(ctx, ctx) for ctx in sorted_contexts] + delete_q = [ + List('to_delete', + message="Select context to delete", + choices=delete_choices) + ] + delete_answer = prompt(delete_q) + if delete_answer: + to_delete = delete_answer['to_delete'] + # Double check with the user + confirm_q = [ + List('confirm', + message=f"[red]Are you sure you want to delete '{to_delete}'?[/] This cannot be undone.", + choices=[ + ('No, keep it', False), + ('Yes, delete it', True) + ]) + ] + confirm = prompt(confirm_q) + if confirm and confirm['confirm']: + if delete_context(to_delete): + success_panel( + f"[green]Successfully deleted context: {to_delete}[/]", + title="๐Ÿ—‘๏ธ Context Deleted" + ) + else: + error_panel( + f"[red]Failed to delete context: {to_delete}[/]" + ) + elif answer['context'] != active: + # Switch to selected context + set_active_context(answer['context']) + console.print(Panel( + f"[green]Now working in: {answer['context']}[/]", + title="๐Ÿ”„ Context Switched", + border_style="green", + width=80 + )) + else: + # Just list contexts (for -l/--list-contexts) + console.print(Panel( + "\n".join( + f"[green]* {ctx}[/green] (active)" if ctx == active + else f" {ctx}" + for ctx in sorted_contexts + ), + title="๐Ÿ“‚ Contexts", + border_style="blue", + width=80 + )) + console.print("\n[dim]Use [bold]doc -c[/] to switch contexts[/]") + sys.exit(0) + except FileNotFoundError: + console.print(Panel( + "[yellow]No contexts found.[/]\n\n" + "Create one with: [bold]doc -n CONTEXT_NAME[/]", + title="๐Ÿ“‚ Contexts", + border_style="blue", + width=80 + )) + sys.exit(0) + + # Handle context switching + if args.context: + if set_active_context(args.context): + console.print(Panel( + f"[green]Now working in: {args.context}[/]", + title="๐Ÿ”„ Context Switched", + border_style="green", + width=80 + )) + else: + # Context doesn't exist, ask if they want to create it + console.print(Panel( + f"[yellow]Context '{args.context}' doesn't exist.[/]\n", + title="โ“ Context Not Found", + border_style="yellow", + width=80 + )) + + # Use inquirer for a nice prompt + from inquirer import prompt, List, shortcuts + + questions = [ + List('action', + message=f"Would you like to create '{args.context}'?", + choices=[ + ('Yes, create and switch to it', 'create'), + ('No, show available contexts', 'list'), + ('Cancel', 'cancel') + ]) + ] + + answer = prompt(questions) + if answer and answer['action'] == 'create': + if create_context(args.context): + set_active_context(args.context) + success_panel( + f"[green]Created and switched to: {args.context}[/]", + title="โœจ Context Created" + ) + else: + error_panel( + f"[red]Failed to create context: {args.context}[/]\n\n" + "[yellow]Context names must be alphanumeric (with hyphens or underscores)[/]" + ) + elif answer and answer['action'] == 'list': + # Show available contexts + contexts = list_contexts() + if contexts: + console.print(Panel( + "\n".join(f" {ctx}" for ctx in sorted(contexts)), + title="๐Ÿ“‚ Available Contexts", + border_style="blue", + width=80 + )) + else: + console.print(Panel( + "[yellow]No contexts found.[/]\n\n" + "Create one with: [bold]doc -n CONTEXT_NAME[/]", + title="๐Ÿ“‚ Contexts", + border_style="blue", + width=80 + )) + sys.exit(0) + + # Handle new context creation + if args.new_context: + if create_context(args.new_context): + success_panel( + f"[green]Created new context: {args.new_context}[/]", + title="โœจ Context Created" + ) + else: + error_panel( + f"[red]Failed to create context: {args.new_context}[/]\n\n" + "[yellow]Context names must be alphanumeric (with hyphens or underscores)[/]" + ) + sys.exit(0) + + # Get active context or exit if none + active_context = get_active_context() + if not active_context and ( + args.add_history or args.add_summary or args.watch or args.hot_reload + ): + console.print(Panel( + "[red]No active context found.[/]\n\n" + "[yellow]Set one with [bold]--context[/] or create new with [bold]--new-context[/][/]", + title="โŒ No Context", + border_style="red", + width=80 + )) + sys.exit(1) + + # Use configuration or command line arguments + config_path = get_config_path() + db_path = ( + Path(args.db_path) + if args.db_path + else config_path / "tasks" / f"{active_context}.db" + ) + output_path = Path(args.output) + + # Initialize repository + repo = WorkLogRepository(db_path) + + # Handle commands + if args.add_history: + entry = repo.add_entry("history", args.add_history) + console.print(Panel( + f"[green]Added to {active_context}:[/]\n\n" + f"[dim]{entry.timestamp}[/]\n" + f"{args.add_history}", + title="๐Ÿ“ History Entry Added", + border_style="green", + width=80 + )) + + if args.add_summary: + entry = repo.add_entry("summary", args.add_summary) + console.print(Panel( + f"[green]Added to {active_context}:[/]\n\n" + f"[dim]{entry.timestamp}[/]\n" + f"{args.add_summary}", + title="๐Ÿ“ Summary Entry Added", + border_style="green", + width=80 + )) + + # Generate markdown or watch + if not (args.watch or args.hot_reload): + entries = repo.get_all_entries() + markdown_gen = MarkdownGenerator() + markdown_gen.generate_from_entries(entries, output_path) + console.print(Panel( + f"[green]Generated markdown file:[/] {output_path}", + title="๐Ÿ“„ Documentation Updated", + border_style="blue", + width=80 + )) + + # Handle PDF export if requested + if args.export: + try: + export_to_pdf(output_path, Path(args.export)) + console.print(Panel( + f"[green]Successfully exported to:[/] {args.export}", + title="๐Ÿ“‘ PDF Export Complete", + border_style="green", + width=80 + )) + except Exception as e: + console.print(Panel( + f"[red]Failed to export PDF:[/]\n\n{str(e)}", + title="โŒ Export Failed", + border_style="red", + width=80 + )) + sys.exit(1) + else: + # Start watch mode + watch_database(db_path, output_path) + + except argparse.ArgumentError as e: + parser.print_help() + sys.exit(1) + except SystemExit as e: + # Catch the system exit from argparse's error handling + if str(e) == '2': # This is the exit code for argument errors + command = next((arg for arg in sys.argv[1:] if arg.startswith('-')), None) + if command: + console.print(f"[red]Error: '{command}' is not a valid command[/red]") + console.print("\nDid you mean one of these?") + if command in ['-p']: + console.print(" doc -n PROJECT_NAME Create a new project/context") + console.print(" doc -c PROJECT_NAME Switch to an existing project") + console.print(" doc -l List all projects") + else: + console.print(" -n, --new-context Create a new context") + console.print(" -c, --context Switch to a context") + console.print(" -l, --list-contexts List all contexts") + console.print("\nUse 'doc --help' to see all available commands") + else: + parser.print_help() + sys.exit(1) + raise + + +if __name__ == "__main__": + main() diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..b91c37c --- /dev/null +++ b/config.toml.example @@ -0,0 +1,25 @@ +# Was Doing Configuration File +# This is an example configuration file with documentation. +# The actual config.toml will be created during setup. + +# Directory where task databases are stored +# Can be absolute or relative path +# Default: "tasks" +tasks_dir = "tasks" + +# Default output file for markdown generation +# Can be absolute or relative path +# Default: "work_doc.md" +default_output = "work_doc.md" + +# Your name, used in documentation +# Default: "" +author = "" + +# Default title for documentation files +# Default: "Work Documentation" +default_title = "Work Documentation" + +# Note: This is just an example file. +# The actual configuration will be created when you run: +# doc --setup diff --git a/doc b/doc new file mode 100755 index 0000000..6b14b7d --- /dev/null +++ b/doc @@ -0,0 +1,49 @@ +#!/bin/bash + +# Get the directory where the code is located (not where the script is symlinked) +SCRIPT_DIR="$( cd "$( dirname "$(readlink -f "${BASH_SOURCE[0]}")" )" &> /dev/null && pwd )" + +# Quiet mode for pip +export PIP_QUIET=1 + +# Function to check dependencies silently +check_dependency() { + if ! command -v "$1" &>/dev/null; then + echo "โŒ $2 is required but not found" + exit 1 + fi +} + +# Check dependencies silently +check_dependency "python3" "Python 3" +check_dependency "pip3" "pip3" +python3 -c "import venv" &>/dev/null || { echo "โŒ Python venv module is required but not found"; exit 1; } + +# Create virtual environment if it doesn't exist (silently) +VENV_DIR="$SCRIPT_DIR/.venv" +if [ ! -d "$VENV_DIR" ]; then + python3 -m venv "$VENV_DIR" &>/dev/null +fi + +# Activate virtual environment (silently) +source "$VENV_DIR/bin/activate" &>/dev/null + +# Install dependencies if requirements.txt exists and packages aren't installed (silently) +REQUIREMENTS="$SCRIPT_DIR/requirements.txt" +if [ -f "$REQUIREMENTS" ]; then + if ! pip freeze | grep -q "markdown=="; then + pip install -r "$REQUIREMENTS" &>/dev/null + fi +fi + +# Change to script directory before running Python +cd "$SCRIPT_DIR" + +if [ $# -eq 0 ]; then + python3 __main__.py --help +else + python3 __main__.py "$@" +fi + +# Deactivate virtual environment (silently) +deactivate &>/dev/null diff --git a/markdown_handler.py b/markdown_handler.py new file mode 100755 index 0000000..c068078 --- /dev/null +++ b/markdown_handler.py @@ -0,0 +1,183 @@ +""" +Markdown Generation Module for Work Documentation System + +This module handles the transformation of work log entries into readable documents. +It follows the Template Method pattern for flexible document generation. +""" + +import markdown +from pathlib import Path +import tempfile +from typing import List, Optional, Protocol +from weasyprint import HTML, CSS +from abc import ABC, abstractmethod + +from rich.console import Console + +from repository import Entry + +console = Console() + + +class DocumentError(Exception): + """Base exception for document generation errors""" + pass + + +class FileWriteError(DocumentError): + """Exception raised when writing files fails""" + pass + + +class PDFGenerationError(DocumentError): + """Exception raised when PDF generation fails""" + pass + + +class DocumentTemplate(ABC): + """Abstract base class for document templates""" + + @abstractmethod + def generate_header(self) -> str: + """Generate document header""" + pass + + @abstractmethod + def generate_section(self, entry: Entry) -> str: + """Generate a section for an entry""" + pass + + @abstractmethod + def generate_footer(self) -> str: + """Generate document footer""" + pass + + +class DefaultTemplate(DocumentTemplate): + """Default template implementation""" + + def generate_header(self) -> str: + return "# Work Documentation\n\n" + + def generate_section(self, entry: Entry) -> str: + formatted_time = entry.timestamp.strftime("%Y-%m-%d %H:%M:%S") + if entry.type == "summary": + return f"## Summary ({formatted_time})\n{entry.content}\n\n" + return f"### History Entry ({formatted_time})\n{entry.content}\n\n" + + def generate_footer(self) -> str: + return "\n---\nGenerated by Was Doing\n" + + +DEFAULT_CSS = """ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.6; + max-width: 800px; + margin: 0 auto; + padding: 2em; +} +h1, h2, h3 { color: #333; } +code { background: #f4f4f4; padding: 0.2em 0.4em; border-radius: 3px; } +pre { background: #f8f8f8; padding: 1em; border-radius: 5px; overflow-x: auto; } +a { color: #0366d6; } +""" + + +class DocumentGenerator(ABC): + """Base class for document generation""" + + def __init__(self, template: DocumentTemplate): + self.template = template + + @abstractmethod + def generate(self, entries: List[Entry], output_path: Path) -> None: + """Generate document from entries""" + pass + + +class MarkdownGenerator(DocumentGenerator): + """Generates markdown documents""" + + def generate(self, entries: List[Entry], output_path: Path) -> None: + """ + Generate a markdown file from entries + + Args: + entries: List of Entry objects to include + output_path: Path where to save the markdown file + + Raises: + FileWriteError: If writing the file fails + """ + try: + with open(output_path, "w") as f: + # Write header + f.write(self.template.generate_header()) + + # Write entries + for entry in entries: + section = self.template.generate_section(entry) + f.write(section) + + # Write footer + f.write(self.template.generate_footer()) + + except IOError as e: + raise FileWriteError(f"Failed to write markdown file: {str(e)}") + + +class PDFGenerator(DocumentGenerator): + """Generates PDF documents from markdown""" + + def generate(self, entries: List[Entry], output_path: Path) -> None: + """ + Generate a PDF file from entries via markdown + + Args: + entries: List of Entry objects to include + output_path: Path where to save the PDF file + + Raises: + PDFGenerationError: If PDF generation fails + """ + try: + # First generate markdown + with tempfile.NamedTemporaryFile(mode='w', suffix='.md') as md_file: + # Write markdown content + md_file.write(self.template.generate_header()) + for entry in entries: + section = self.template.generate_section(entry) + md_file.write(section) + md_file.write(self.template.generate_footer()) + md_file.flush() + + # Convert markdown to HTML + with open(md_file.name, 'r') as f: + md_content = f.read() + html = markdown.markdown( + md_content, + extensions=['extra', 'codehilite'] + ) + + # Create PDF from HTML + html_doc = f""" + + + + + + + + {html} + + + """ + + HTML(string=html_doc).write_pdf( + output_path, + stylesheets=[CSS(string=DEFAULT_CSS)] + ) + + except Exception as e: + raise PDFGenerationError(f"Failed to generate PDF: {str(e)}") diff --git a/move_docs.sh b/move_docs.sh new file mode 100755 index 0000000..b3b7b31 --- /dev/null +++ b/move_docs.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Get the script's directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +# Create parent docs directory if it doesn't exist +mkdir -p ../../ + +# Move files to their new locations +mv .github ../../ +mv README.md ../../ +mv LICENSE ../../ + +# Move existing wiki files +mv docs/wiki ../../ + +echo "โœจ Documentation structure moved!" +echo "๐Ÿ“ Wiki files moved to: ../../wiki" diff --git a/repository.py b/repository.py new file mode 100755 index 0000000..7d60206 --- /dev/null +++ b/repository.py @@ -0,0 +1,234 @@ +""" +Repository Module for Work Documentation System + +This module implements the repository pattern for SQLite database operations. +It follows SOLID principles and provides a clean interface for data access. +""" + +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime +import sqlite3 +from pathlib import Path +from typing import Generator, List, Optional, Tuple + +from rich.console import Console + +console = Console() + + +class DatabaseError(Exception): + """Base exception for database operations""" + + pass + + +class ConnectionError(DatabaseError): + """Exception raised when database connection fails""" + + pass + + +class QueryError(DatabaseError): + """Exception raised when a database query fails""" + + pass + + +@dataclass(frozen=True) +class Entry: + """Immutable data structure for work log entries""" + + id: Optional[int] + type: str + content: str + timestamp: datetime + + @classmethod + def from_row(cls, row: Tuple) -> "Entry": + """Create an Entry from a database row""" + return cls( + id=row[0], + type=row[1], + content=row[2], + timestamp=datetime.fromisoformat(row[3]), + ) + + +class WorkLogRepository: + """Repository for work log entries following the repository pattern""" + + SCHEMA = """ + CREATE TABLE IF NOT EXISTS entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + content TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ + + def __init__(self, db_path: Path): + """Initialize the repository with a database path""" + self.db_path = db_path + self._ensure_db_exists() + + @contextmanager + def _get_connection(self) -> Generator[sqlite3.Connection, None, None]: + """ + Context manager for database connections. + Ensures proper connection handling and cleanup. + """ + try: + conn = sqlite3.connect( + self.db_path, + detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES, + ) + yield conn + conn.commit() + except sqlite3.Error as e: + raise ConnectionError(f"Database connection failed: {str(e)}") + finally: + if "conn" in locals(): + conn.close() + + def _ensure_db_exists(self) -> None: + """Initialize the database if it doesn't exist""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(self.SCHEMA) + except DatabaseError as e: + console.print(f"[red]Failed to initialize database: {str(e)}[/red]") + raise + + def add_entry(self, entry_type: str, content: str) -> Entry: + """ + Add a new entry to the database. + + Args: + entry_type: Type of entry ('history' or 'summary') + content: Entry content text + + Returns: + The created Entry object + + Raises: + QueryError: If the database operation fails + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Insert the new entry + cursor.execute( + "INSERT INTO entries (type, content) VALUES (?, ?)", + (entry_type, content), + ) + + # Get the inserted entry + entry_id = cursor.lastrowid + cursor.execute( + "SELECT id, type, content, timestamp FROM entries WHERE id = ?", + (entry_id,), + ) + row = cursor.fetchone() + + if not row: + raise QueryError("Failed to retrieve inserted entry") + + return Entry.from_row(row) + + except sqlite3.Error as e: + raise QueryError(f"Failed to add entry: {str(e)}") + + def get_all_entries(self) -> List[Entry]: + """ + Retrieve all entries from the database. + + Returns: + List of Entry objects ordered by timestamp + + Raises: + QueryError: If the database operation fails + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT id, type, content, timestamp FROM entries ORDER BY timestamp" + ) + return [Entry.from_row(row) for row in cursor.fetchall()] + except sqlite3.Error as e: + raise QueryError(f"Failed to retrieve entries: {str(e)}") + + def get_entry_by_id(self, entry_id: int) -> Optional[Entry]: + """ + Retrieve a specific entry by ID. + + Args: + entry_id: The ID of the entry to retrieve + + Returns: + Entry object if found, None otherwise + + Raises: + QueryError: If the database operation fails + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT id, type, content, timestamp FROM entries WHERE id = ?", + (entry_id,), + ) + row = cursor.fetchone() + return Entry.from_row(row) if row else None + except sqlite3.Error as e: + raise QueryError(f"Failed to retrieve entry {entry_id}: {str(e)}") + + def delete_entry(self, entry_id: int) -> bool: + """ + Delete an entry by ID. + + Args: + entry_id: The ID of the entry to delete + + Returns: + True if entry was deleted, False if not found + + Raises: + QueryError: If the database operation fails + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM entries WHERE id = ?", (entry_id,)) + return cursor.rowcount > 0 + except sqlite3.Error as e: + raise QueryError(f"Failed to delete entry {entry_id}: {str(e)}") + + def update_entry(self, entry_id: int, content: str) -> Optional[Entry]: + """ + Update an entry's content. + + Args: + entry_id: The ID of the entry to update + content: New content for the entry + + Returns: + Updated Entry object if found, None otherwise + + Raises: + QueryError: If the database operation fails + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE entries SET content = ? WHERE id = ?", (content, entry_id) + ) + if cursor.rowcount == 0: + return None + return self.get_entry_by_id(entry_id) + except sqlite3.Error as e: + raise QueryError(f"Failed to update entry {entry_id}: {str(e)}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..880b62a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +rich>=10.0.0 +toml>=0.10.2 +inquirer>=3.1.3 +watchdog==6.0.0 +halo==0.0.31 +tqdm==4.67.1 +markdown==3.7 +weasyprint==63.0 +pygments==2.18.0 diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..b2af29f --- /dev/null +++ b/setup.py @@ -0,0 +1,34 @@ +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +with open("requirements.txt", "r", encoding="utf-8") as fh: + requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")] + +setup( + name="was-doing", + version="0.1.0", + author="James", + description="A development-friendly work documentation system", + long_description=long_description, + long_description_content_type="text/markdown", + packages=find_packages(), + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Software Development :: Documentation", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ], + python_requires=">=3.7", + install_requires=requirements, + entry_points={ + "console_scripts": [ + "was-doing=__main__:main", + ], + }, +) diff --git a/setup/__init__.py b/setup/__init__.py new file mode 100644 index 0000000..054c952 --- /dev/null +++ b/setup/__init__.py @@ -0,0 +1,47 @@ +""" +Setup Module for Was Doing + +This module handles the initial setup and configuration management. +It provides: +1. Interactive setup process +2. Configuration file management +3. User preference handling +4. Path validation and creation +""" + +from pathlib import Path +from typing import Optional + +from .config import ( + Config, + get_config_path, + get_active_context, + set_active_context, + list_contexts, + create_context, + delete_context, + ensure_setup, +) +from .interactive import setup_wizard +from .database import ( + add_history_entry, + add_summary_entry, + get_history_entries, + get_summary_entries, +) + +__all__ = [ + "ensure_setup", + "setup_wizard", + "Config", + "get_config_path", + "get_active_context", + "set_active_context", + "list_contexts", + "create_context", + "delete_context", + "add_history_entry", + "add_summary_entry", + "get_history_entries", + "get_summary_entries", +] diff --git a/setup/config.py b/setup/config.py new file mode 100644 index 0000000..42c0da3 --- /dev/null +++ b/setup/config.py @@ -0,0 +1,190 @@ +"""Configuration management for the work documentation system""" + +import os +from pathlib import Path +import toml +from typing import Optional, List +from dataclasses import dataclass, asdict + +DEFAULT_CONFIG = { + "active_context": None, + "contexts": [], + "default_output": "work_doc.md", + "watch_interval": 1.0, + "config_dir": None, +} + + +@dataclass +class Config: + """Configuration class for Was Doing""" + active_context: Optional[str] + contexts: List[str] + default_output: str + watch_interval: float + config_dir: Optional[str] + + @classmethod + def from_dict(cls, data: dict) -> 'Config': + """Create Config from dictionary""" + return cls( + active_context=data.get("active_context"), + contexts=data.get("contexts", []), + default_output=data.get("default_output", "work_doc.md"), + watch_interval=data.get("watch_interval", 1.0), + config_dir=data.get("config_dir") + ) + + def to_dict(self) -> dict: + """Convert Config to dictionary""" + return { + "active_context": self.active_context, + "contexts": self.contexts, + "default_output": self.default_output, + "watch_interval": self.watch_interval, + "config_dir": self.config_dir, + } + + +def ensure_setup() -> Optional[str]: + """ + Check if Was Doing is properly set up. + Returns an error message if setup is needed, None if everything is good. + """ + config_file = Path.home() / ".wasdoing" / "config" + + if not config_file.exists(): + return "Was Doing needs to be set up. Run with --setup to configure." + + config_path = config_file.read_text().strip() + config_toml = Path(config_path) / "config.toml" + + if not config_toml.exists(): + return f"Configuration file not found at {config_toml}. Run with --setup to reconfigure." + + return None + + +def get_config_dir() -> Path: + """Get the configuration directory path""" + return Path.home() / ".wasdoing" + + +def get_config_path() -> Optional[Path]: + """Get the configuration file path if it exists""" + config_dir = get_config_dir() + config_file = config_dir / "config.toml" + return config_file if config_file.exists() else None + + +def load_config() -> Config: + """Load configuration from file""" + config_dir = get_config_dir() + config_file = config_dir / "config.toml" + + # Ensure config directory exists + config_dir.mkdir(parents=True, exist_ok=True) + + if not config_file.exists(): + config = Config.from_dict(DEFAULT_CONFIG) + save_config(config) + return config + + try: + data = toml.load(config_file) + return Config.from_dict(data) + except Exception: + # If config is corrupted, create a new one + config = Config.from_dict(DEFAULT_CONFIG) + save_config(config) + return config + + +def save_config(config: Config) -> None: + """Save configuration to file""" + config_dir = get_config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + + config_file = config_dir / "config.toml" + with open(config_file, "w") as f: + toml.dump(asdict(config), f) + + +def get_active_context() -> Optional[str]: + """Get the currently active context""" + config = load_config() + return config.active_context + + +def set_active_context(context: str) -> bool: + """Set the active context""" + config = load_config() + if context not in config.contexts: + return False + + config.active_context = context + save_config(config) + return True + + +def list_contexts() -> List[str]: + """List all available contexts""" + config = load_config() + return config.contexts + + +def create_context(name: str) -> bool: + """Create a new context""" + # Allow alphanumeric, hyphens, and underscores + if not all(c.isalnum() or c in '-_' for c in name): + return False + + try: + # Ensure config directory exists + config_dir = get_config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + tasks_dir = config_dir / "tasks" + tasks_dir.mkdir(exist_ok=True) + + # Load or create config + config = load_config() + if name in config.contexts: + return False + + # Add to contexts list + config.contexts.append(name) + save_config(config) + return True + except Exception as e: + print(f"Error creating context: {e}") # For debugging + return False + + +def delete_context(name: str) -> bool: + """Delete a context and its associated database. + + Args: + name: Name of the context to delete + + Returns: + True if deletion was successful, False otherwise + """ + try: + # Delete the context's database file + config_path = get_config_dir() + db_path = config_path / "tasks" / f"{name}.db" + db_path.unlink(missing_ok=True) + + # Remove from contexts list + config = load_config() + if name not in config.contexts: + return False + + config.contexts.remove(name) + if config.active_context == name: + config.active_context = None + save_config(config) + return True + except Exception as e: + print(f"Error deleting context: {e}") # For debugging + return False diff --git a/setup/database.py b/setup/database.py new file mode 100644 index 0000000..8d09b11 --- /dev/null +++ b/setup/database.py @@ -0,0 +1,88 @@ +"""Database operations for Was Doing.""" +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Optional, List, Tuple + +from .config import get_config_dir, get_active_context + +def get_db_path(context: Optional[str] = None) -> Path: + """Get the database path for a context.""" + if context is None: + context = get_active_context() + return get_config_dir() / "tasks" / f"{context}.db" + +def ensure_db_schema(db_path: Path) -> None: + """Ensure the database has the correct schema.""" + with sqlite3.connect(db_path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + content TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.commit() + +def add_entry(content: str, entry_type: str = "history") -> None: + """Add an entry to the active context's database. + + Args: + content: The entry text + entry_type: Type of entry (history or summary) + """ + db_path = get_db_path() + ensure_db_schema(db_path) + + with sqlite3.connect(db_path) as conn: + conn.execute( + "INSERT INTO entries (type, content, timestamp) VALUES (?, ?, ?)", + (entry_type, content, datetime.now().isoformat()) + ) + conn.commit() + +def get_entries(entry_type: Optional[str] = None, limit: Optional[int] = None) -> List[Tuple[str, str, str]]: + """Get entries from the active context's database. + + Args: + entry_type: Optional filter by type (history or summary) + limit: Optional limit on number of entries to return + + Returns: + List of (content, type, timestamp) tuples + """ + db_path = get_db_path() + ensure_db_schema(db_path) + + with sqlite3.connect(db_path) as conn: + query = "SELECT content, type, timestamp FROM entries" + params = [] + + if entry_type: + query += " WHERE type = ?" + params.append(entry_type) + + query += " ORDER BY timestamp DESC" + + if limit: + query += " LIMIT ?" + params.append(limit) + + return conn.execute(query, params).fetchall() + +def add_history_entry(content: str) -> None: + """Add a history entry.""" + add_entry(content, "history") + +def add_summary_entry(content: str) -> None: + """Add a summary entry.""" + add_entry(content, "summary") + +def get_history_entries(limit: Optional[int] = None) -> List[Tuple[str, str, str]]: + """Get history entries.""" + return get_entries("history", limit) + +def get_summary_entries(limit: Optional[int] = None) -> List[Tuple[str, str, str]]: + """Get summary entries.""" + return get_entries("summary", limit) \ No newline at end of file diff --git a/setup/display.py b/setup/display.py new file mode 100644 index 0000000..214589c --- /dev/null +++ b/setup/display.py @@ -0,0 +1,96 @@ +"""Display utilities for Was Doing CLI output.""" +from typing import Optional, List, Tuple +from rich.console import Console +from rich.panel import Panel +from rich.align import Align +from rich.columns import Columns +from rich.text import Text + +console = Console() + +def format_command_help(commands: List[Tuple[str, str]], min_spacing: int = 4) -> str: + """Format command help text with right-aligned descriptions. + + Args: + commands: List of (command, description) tuples + min_spacing: Minimum spaces between command and description + + Returns: + Formatted string with aligned descriptions + """ + if not commands: + return "" + + # Find the longest command length + max_cmd_len = max(len(cmd) for cmd, _ in commands) + + # Format each line with proper spacing + lines = [] + for cmd, desc in commands: + padding = " " * (max_cmd_len - len(cmd) + min_spacing) + lines.append(f"{cmd}{padding}{desc}") + + return "\n".join(lines) + +def show_panel( + content: str, + *, + title: Optional[str] = None, + style: str = "blue", + padding: tuple[int, int] = (1, 3), # (vertical, horizontal) padding + width: int = 80, + align: str = "left", # left, center, right +) -> None: + """Display a consistently styled panel. + + Args: + content: The main text content to display + title: Optional title with emoji + style: Border style color (default: blue) + padding: Tuple of (vertical, horizontal) padding + width: Panel width (default: 80) + align: Content alignment (default: left) + """ + if align != "left": + content = Align(content, align=align) + + panel = Panel( + content, + title=title, + border_style=style, + width=width, + padding=padding, + ) + console.print(panel) + +def show_help_panel( + commands: List[Tuple[str, str]], + *, + title: Optional[str] = None, + style: str = "blue", +) -> None: + """Show a panel with aligned command descriptions. + + Args: + commands: List of (command, description) tuples + title: Optional panel title + style: Border style color + """ + content = format_command_help(commands) + show_panel(content, title=title, style=style) + +def success_panel(content: str, title: str = "โœจ Success") -> None: + """Show a success message in a green panel.""" + show_panel(content, title=title, style="green") + +def error_panel(content: str, title: str = "โŒ Error") -> None: + """Show an error message in a red panel.""" + show_panel(content, title=title, style="red") + +def info_panel(content: str, title: str = "โ„น๏ธ Info") -> None: + """Show an info message in a blue panel.""" + show_panel(content, title=title, style="blue") + +def warning_panel(content: str, title: str = "โš ๏ธ Warning") -> None: + """Show a warning message in a yellow panel.""" + show_panel(content, title=title, style="yellow") \ No newline at end of file diff --git a/setup/interactive.py b/setup/interactive.py new file mode 100644 index 0000000..ac7a011 --- /dev/null +++ b/setup/interactive.py @@ -0,0 +1,117 @@ +""" +Interactive Setup Wizard for Was Doing + +This module provides an interactive CLI experience for setting up Was Doing. +It uses rich for pretty output and inquirer for user input. +""" + +import os +from pathlib import Path +import inquirer +from rich.console import Console +from rich.panel import Panel +from rich.style import Style +from halo import Halo +from tqdm import tqdm + +from .config import Config, save_config, DEFAULT_CONFIG + +# Initialize console with no clearing +console = Console(force_terminal=True, no_color=False, soft_wrap=True) + +def setup_wizard() -> bool: + """Run the interactive setup wizard""" + # Show welcome panel + welcome = Panel( + "[bold blue]Welcome to Was Doing![/]\n\n" + "Let's get your work documentation system set up. " + "This will only take a moment.", + title="๐Ÿ”ง Setup Wizard", + border_style="blue" + ) + console.print(welcome) + + # Get configuration location + questions = [ + inquirer.List( + "location", + message="Where would you like to store your configuration?", + choices=[ + ("Default location (~/.wasdoing)", str(Path.home() / ".wasdoing")), + ("Custom location", "custom") + ] + ) + ] + + answers = inquirer.prompt(questions) + if not answers: + return False + + if answers["location"] == "custom": + questions = [ + inquirer.Path( + "custom_path", + message="Enter the path for configuration storage", + exists=False, + normalize_to_absolute_path=True, + path_type=inquirer.Path.DIRECTORY + ) + ] + + custom = inquirer.prompt(questions) + if not custom: + return False + + config_dir = Path(custom["custom_path"]) + else: + config_dir = Path(answers["location"]) + + try: + # Setup progress spinner + spinner = Halo(text="Setting up configuration", spinner="dots") + spinner.start() + + # Create directories with progress + steps = [ + ("Creating config directory", lambda: config_pointer.mkdir(parents=True, exist_ok=True)), + ("Creating tasks directory", lambda: (config_dir / "tasks").mkdir(parents=True, exist_ok=True)), + ("Saving configuration", lambda: save_config(config, config_dir)), + ("Creating config pointer", lambda: write_config_pointer(config_pointer, config_dir)) + ] + + config_pointer = Path.home() / ".wasdoing" + config = Config.from_dict(DEFAULT_CONFIG) + config.config_dir = str(config_dir) + + for msg, func in steps: + spinner.text = msg + func() + + spinner.stop() + + # Show success panel + success = Panel( + "[bold green]Setup Complete![/]\n\n" + f"[dim]Configuration:[/] {config_dir}\n" + f"[dim]Tasks Directory:[/] {config_dir / 'tasks'}\n\n" + "[bold]Try these commands:[/]\n" + " [blue]doc -n my-project[/] Create your first context\n" + " [blue]doc -H \"Started work\"[/] Add your first entry\n" + " [blue]doc -l[/] List available contexts", + title="โœจ Ready to Go!", + border_style="green" + ) + console.print(success) + + return True + except Exception as e: + if "spinner" in locals(): + spinner.fail(f"Setup failed: {str(e)}") + else: + console.print(f"\n[red]Failed to save configuration: {str(e)}[/red]") + return False + +def write_config_pointer(config_pointer: Path, config_dir: Path) -> None: + """Write the config pointer file""" + with open(config_pointer / "config", "w") as f: + f.write(str(config_dir)) diff --git a/watcher.py b/watcher.py new file mode 100755 index 0000000..11dd4a7 --- /dev/null +++ b/watcher.py @@ -0,0 +1,156 @@ +""" +File System Watcher Module for Work Documentation System + +This module provides real-time document generation through file system monitoring. +It follows the Observer pattern and provides robust error handling. +""" + +# Standard library imports +from dataclasses import dataclass +from pathlib import Path +import sys +import time +from typing import Optional, List + +# Third-party imports +from rich.console import Console +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler, FileModifiedEvent + +# Local application imports +from repository import WorkLogRepository, DatabaseError +from markdown_handler import ( + DocumentGenerator, + MarkdownGenerator, + PDFGenerator, + DefaultTemplate, + DocumentError +) + +# Initialize console with no clearing +console = Console(force_terminal=True, no_color=False, soft_wrap=True) + + +@dataclass +class WatcherConfig: + """Configuration for database watcher""" + db_path: Path + output_paths: List[Path] + check_interval: float = 1.0 + max_retries: int = 3 + retry_delay: float = 2.0 + + +class WatcherError(Exception): + """Base exception for watcher errors""" + pass + + +class DatabaseWatcher: + """Manages the file system observer for database changes""" + + def __init__(self, config: WatcherConfig): + self.config = config + self.observer = Observer() + self.handler = DatabaseChangeHandler(config) + + # Schedule the observer + self.observer.schedule( + self.handler, + str(config.db_path.parent), + recursive=False + ) + + def start(self) -> None: + """Start watching the database file""" + try: + self.observer.start() + console.print(f"๐Ÿ‘€ Watching {self.config.db_path} for changes...") + console.print("Press Ctrl+C to stop") + except Exception as e: + raise WatcherError(f"Failed to start watcher: {str(e)}") + + def stop(self) -> None: + """Stop watching the database file""" + try: + self.observer.stop() + self.observer.join() + console.print("\n๐Ÿ›‘ Stopping watch mode...") + except Exception as e: + raise WatcherError(f"Failed to stop watcher: {str(e)}") + + +class DatabaseChangeHandler(FileSystemEventHandler): + """Handles database file changes and triggers document generation""" + + def __init__(self, config: WatcherConfig): + """Initialize the handler with configuration""" + self.config = config + self.repo = WorkLogRepository(config.db_path) + self.template = DefaultTemplate() + self.generators = [ + (MarkdownGenerator(self.template), path) + for path in config.output_paths + if path.suffix == '.md' + ] + [ + (PDFGenerator(self.template), path) + for path in config.output_paths + if path.suffix == '.pdf' + ] + + # Initial generation + self._safe_regenerate() + + def on_modified(self, event: FileModifiedEvent) -> None: + """Handle file modification events""" + if not event.is_directory and Path(event.src_path) == self.config.db_path: + console.print(f"๐Ÿ”„ Database changed, regenerating documents...") + self._safe_regenerate() + + def _safe_regenerate(self) -> None: + """Safely regenerate documents with retry logic""" + try: + self._regenerate_documents() + self.retries = 0 # Reset retry counter on success + except (DatabaseError, DocumentError) as e: + self.retries += 1 + if self.retries >= self.config.max_retries: + console.print(f"[red]โŒ Failed to regenerate after {self.retries} attempts. Error: {str(e)}[/red]") + return + + console.print(f"[yellow]โš ๏ธ Regeneration failed, retrying in {self.config.retry_delay} seconds...[/yellow]") + time.sleep(self.config.retry_delay) + self._safe_regenerate() + + def _regenerate_documents(self) -> None: + """Regenerate all configured document formats""" + entries = self.repo.get_all_entries() + + for generator, output_path in self.generators: + try: + generator.generate(entries, output_path) + console.print(f"๐Ÿ“ Generated {output_path.suffix[1:].upper()} file: {output_path}") + except DocumentError as e: + console.print(f"[red]โŒ Failed to generate {output_path}: {str(e)}[/red]") + + +def watch_database(db_path: Path, output_paths: List[Path]) -> None: + """ + Watch a database file and regenerate documents on changes + + Args: + db_path: Path to the SQLite database + output_paths: List of paths for output files (can mix .md and .pdf) + """ + config = WatcherConfig(db_path=db_path, output_paths=output_paths) + watcher = DatabaseWatcher(config) + + try: + watcher.start() + while True: + time.sleep(config.check_interval) + except KeyboardInterrupt: + watcher.stop() + except WatcherError as e: + console.print(f"[red]โŒ {str(e)}[/red]") + sys.exit(1)