[
  {
    "path": ".github/FUNDING.yml",
    "content": "github: tejas-raskar\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  release:\n    types:\n      - published\n\njobs:\n  build-and-upload:\n    name: Build (${{ matrix.build }})\n    runs-on: ${{ matrix.os }}\n\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - build: linux\n            os: ubuntu-latest\n            target: x86_64-unknown-linux-musl\n\n          - build: macos\n            os: macos-latest\n            target: x86_64-apple-darwin\n\n          - build: macos-arm64\n            os: macos-latest\n            target: aarch64-apple-darwin\n\n          - build: windows-gnu\n            os: windows-latest\n            target: x86_64-pc-windows-msvc\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v4\n\n      - name: Install Rust\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: ${{ matrix.target }}\n\n      - name: Install musl-tools (Linux)\n        if: matrix.os == 'ubuntu-latest'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y musl-tools musl-dev libssl-dev pkg-config\n\n      - name: Build binary\n        run: cargo build --release --target ${{ matrix.target }}\n\n      - name: Package the binary\n        shell: bash\n        run: |\n          BINARY_NAME=\"notedmd\"\n          RELEASE_VERSION=\"${{ github.ref_name }}\"\n\n          ROOT_DIR=\"${BINARY_NAME}-${RELEASE_VERSION}-${{ matrix.target }}\"\n          mkdir -p \"${ROOT_DIR}/bin\"\n\n          if [ \"${{ matrix.os }}\" = \"windows-latest\" ]; then\n            SOURCE_FILE=\"target/${{ matrix.target }}/release/${BINARY_NAME}.exe\"\n            cp \"$SOURCE_FILE\" \"${ROOT_DIR}/bin/\"\n          else\n            SOURCE_FILE=\"target/${{ matrix.target }}/release/${BINARY_NAME}\"\n            cp \"$SOURCE_FILE\" \"${ROOT_DIR}/bin/\"\n            chmod +x \"${ROOT_DIR}/bin/${BINARY_NAME}\"\n          fi\n\n          cp LICENSE README.md CHANGELOG.md \"${ROOT_DIR}/\"\n\n          if [ \"${{ matrix.os }}\" = \"windows-latest\" ]; then\n            ASSET_NAME=\"${ROOT_DIR}.zip\"\n            7z a \"$ASSET_NAME\" \"$ROOT_DIR\"\n            echo \"ASSET=${ASSET_NAME}\" >> $GITHUB_ENV\n          else\n            ASSET_NAME=\"${ROOT_DIR}.tar.gz\"\n            tar -czf \"$ASSET_NAME\" \"$ROOT_DIR\"\n            echo \"ASSET=${ASSET_NAME}\" >> $GITHUB_ENV\n          fi\n\n      - name: Upload binary\n        uses: softprops/action-gh-release@v2\n        with:\n          files: |\n            ${{ env.ASSET }}\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## [0.3.0]\n\n### Fixed\n- Resolved an issue where Claude API throwed an error when using PDF files due to wrong type in request body.\n\n### Added\n- Added support for Notion. You can now save your notes directly to a Notion database.\n\n## [0.2.4]\n\n### Added\n- Added support for all clients that are compatible with OpenAI's API. LM Studio for example.\n\n## [0.2.3]\n\n### Added\n- Added `--show`, `--edit`, and `--set-provider` subcommands to the `config` command for better configuration management.\n\n### Changed\n- Updated the Claude model selection from a text input to a selection menu to improve user experience and prevent typos.\n\n### Fixed\n- Resolved an issue where API errors in successful (`200 OK`) responses were ignored, preventing silent failures.\n- Corrected a bug where configuring the Ollama provider would erase all other existing provider settings.\n\n## [0.2.2]\n\n### Added\n- Added Claude support.\n\n### Changed\n- Refactored the project to move the individual client files to a client subfolder.\n\n## [0.2.1]\n\n### Fixed\n- Fixed a bug where `active_provider` was not being set when using `--set-api-key` option.\n\n## [0.2.0]\n\n### Added\n  - Added Ollama support\n  - Added a `prompt` option to the `convert` command to override the default prompt.\n\n## [0.1.1]\n\n### Added\n  - Ollama provider support in onboarding (configuration only)\n  - Provider abstraction for AI client support\n  - Unified configuration via `notedmd config` command\n\n  ### Changed\n  - Improved provider selection and configuration flow in onboarding process\n\n## [0.1.0]\n\n### Added\n- Initial release of `notedmd`.\n- `convert` command to process single files or directories of images and PDFs.\n- `config` command to manage the Gemini API key.\n- Interactive prompt to enter API key if not configured.\n- Progress bar for batch processing.\n\n### Fixed\n- Progress bar rendering correctly during batch processing without being disrupted by log messages.\n- Removed redundant ASCII art display on every command run.\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"notedmd\"\nversion = \"0.3.0\"\nedition = \"2024\"\ndescription = \"A command-line tool to convert handwritten notes into a clean and readable Markdown file.\"\nlicense = \"MIT\"\nrepository = \"https://github.com/tejas-raskar/noted.md\"\nreadme = \"README.md\"\nkeywords = [\"cli\", \"notes\", \"markdown\", \"gemini\", \"ollama\"]\ncategories = [\"command-line-utilities\"]\n\n[dependencies]\nbase64 = \"0.22.1\"\nclap = { version = \"4.5.40\", features = [\"derive\", \"env\"] }\ntokio = { version = \"1.45.1\", features = [\"full\"] }\nreqwest = { version = \"0.12\", features = [\"json\"] }\nserde = { version = \"1.0.219\", features = [\"derive\"] }\nopenssl = { version = \"0.10\", features = [\"vendored\"] }\nserde_json = \"1.0.140\"\ntoml = \"0.8.23\"\ndirectories = \"6.0.0\"\ndialoguer = \"0.11.0\"\ncolored = \"3.0.0\"\nindicatif = \"0.17.11\"\nasync-trait = \"0.1.88\"\nthiserror = \"2.0.12\"\ncomrak = \"0.39.1\"\nnotion-client = \"1.0.10\"\nanyhow = \"1.0.98\"\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Tejas Raskar\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <pre>\n          ███╗   ██╗ ██████╗ ████████╗███████╗██████╗    ███╗   ███╗██████╗\n          ████╗  ██║██╔═══██╗╚══██╔══╝██╔════╝██╔══██╗   ████╗ ████║██╔══██╗\n          ██╔██╗ ██║██║   ██║   ██║   █████╗  ██║  ██║   ██╔████╔██║██║  ██║\n          ██║╚██╗██║██║   ██║   ██║   ██╔══╝  ██║  ██║   ██║╚██╔╝██║██║  ██║\n          ██║ ╚████║╚██████╔╝   ██║   ███████╗██████╔╝██╗██║ ╚═╝ ██║██████╔╝\n          ╚═╝  ╚═══╝ ╚═════╝    ╚═╝   ╚══════╝╚═════╝ ╚═╝╚═╝     ╚═╝╚═════╝\n  </pre>\n</div>\n\n<p align=\"center\">\n  <strong>A command-line tool to convert handwritten notes into a clean and readable Markdown file.</strong>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/tejas-raskar/noted.md/actions\"><img src=\"https://github.com/tejas-raskar/noted.md/actions/workflows/release.yml/badge.svg\" alt=\"Build Status\"></a>\n  <a href=\"http://github.com/tejas-raskar/noted.md/releases\"><img src=\"https://img.shields.io/github/v/tag/tejas-raskar/noted.md\" alt=\"Version\"></a>\n  <a href=\"http://github.com/tejas-raskar/noted.md/releases\"><img src=\"https://img.shields.io/github/downloads/tejas-raskar/noted.md/total?color=red\" alt=\"Downloads\"></a>\n  <a href=\"https://github.com/tejas-raskar/noted.md/blob/main/LICENSE\"><img src=\"https://img.shields.io/badge/license-MIT-blue.svg\" alt=\"License\"></a>\n</p>\n\n---\n\n`noted.md` is a CLI tool that uses LLMs to convert your handwritten text into markdown files. It's an interactive program that accepts pdfs, jpg, jpeg, png as an input and processes them accordingly. It can recognize mathematical equations too and can correctly format them in LaTeX. And if you have bunch of files to convert them at once, `noted.md` supports batch processing too!\n\n\nhttps://github.com/user-attachments/assets/5e2f4ab5-2043-4ea4-b95d-bf63e36ce9d9\n\n\n## Installation\n\n`noted.md` can be installed on macOS, Linux, and Windows.\n\n### macOS & Linux (Recommended: Homebrew)\n\nFor the easiest installation on macOS and Linux, use Homebrew:\n\n```bash\nbrew tap tejas-raskar/noted.md\nbrew install notedmd\n```\n\nTo update `noted.md` to the latest version:\n\n```bash\nbrew upgrade notedmd\n```\n\n### Manual Download (Windows)\n\nFor Windows, download the latest `.zip` archive from the [Releases page](https://github.com/tejas-raskar/noted.md/releases/latest). Extract the contents and add the `bin` directory to your system's PATH.\n\n### Building from Source\n\nIf you prefer to build from source, clone the repository and use Cargo:\n\n```bash\ngit clone https://github.com/tejas-raskar/noted.md.git\ncd noted.md\ncargo build --release\n# The executable will be in target/release/notedmd\n```\n\n## Usage\n\nThe typical workflow is:\n1.  **Configure your AI provider**: Use `notedmd config --edit` for a guided setup.\n2.  **Convert your files**: Use `notedmd convert <path>` to process your notes.\n\n### Commands\n\n| Command           | Description                                                                          |\n| ----------------- | ------------------------------------------------------------------------------------ |\n| `notedmd convert` | Converts a file or all supported files in a directory into Markdown.                 |\n| `notedmd config`  | Manages the AI provider configuration. Shows the current config if no flags are used. |\n\n---\n\n## Configuration\n\n### Interactive Setup (Recommended)\n\nFor first-time users, the interactive setup is the easiest way to get started. Run:\n```bash\nnotedmd config --edit\n```\nThis will guide you through selecting an AI provider (Gemini, Claude, or Ollama) and entering the necessary credentials, such as API keys or server details.\n\n### AI Providers\n\nYou can choose between three AI providers.\n\n#### Gemini and Claude APIs\nYou will need an API key from your chosen provider:\n- **Gemini API:** [Google AI Studio](https://aistudio.google.com/app/apikey)\n- **Claude API:** [Anthropic's website](https://console.anthropic.com/dashboard)\n\n#### Ollama\nMake sure Ollama is installed and running on your local machine. You can download it from [Ollama's website](https://ollama.com/).\n\n#### OpenAI API compatible clients\nSupports all clients that are compatible with the OpenAI API. [LM Studio](https://lmstudio.ai/) for example.\n\n---\n\n### Notion\nYou can also save your converted notes directly to a Notion database. To do this, you'll need to create a Notion integration and provide the API key and database ID.\n\n**1. Create a Notion Integration:**\nFollow the [official Notion guide](https://developers.notion.com/docs/create-a-notion-integration#create-your-integration-in-notion) to create an integration and get your API key (Internal Integration Token).\n\n**2. Share the Database with the Integration:**\nFor `noted.md` to be able to add pages to your database, you need to share it with the integration you created.\n- Go to your database in Notion.\n- Click the **•••** menu in the top-right corner.\n- Click **+ Add connections** and select your integration.\n\n**3. Get the Database ID:**\nThe database ID is the long string of characters in the URL of your database. For example, if your database URL is `https://www.notion.so/my-workspace/1234567890abcdef1234567890abcdef?v=...`, your database ID is `1234567890abcdef1234567890abcdef`.\n\nYou will be prompted to enter the API key and database ID when you run `notedmd config --edit` and choose to configure Notion.\n\n---\n\n### Managing Configuration via Flags\n\nYou can also manage your configuration directly using flags.\n\n| Flag                             | Description                                                                 |\n| -------------------------------- | --------------------------------------------------------------------------- |\n| `--set-provider <provider>`      | Set the active provider (`gemini`, `claude`, `ollama`).                     |\n| `--set-api-key <key>`            | Set the API key for Gemini.                                                 |\n| `--set-claude-api-key <key>`     | Set the API key for Claude.                                                 |\n| `--show`                         | Display the current configuration.                                          |\n| `--show-path`                    | Show the path to your configuration file.                                   |\n| `--edit`                         | Start the interactive configuration wizard.                                 |\n\n**Examples:**\n- Set the active provider to Claude:\n  ```bash\n  notedmd config --set-provider claude\n  ```\n- Set your Gemini API key:\n  ```bash\n  notedmd config --set-api-key YOUR_GEMINI_API_KEY\n  ```\n\n---\n\n## Converting Files\n\nOnce configured, you can convert your handwritten notes.\n\n| Flag                             | Description                                                                 |\n| -------------------------------- | --------------------------------------------------------------------------- |\n| `-o`, `--output <dir>`           | Specify a directory to save the converted Markdown file(s).                 |\n| `-p`, `--prompt <prompt>`        | Add a custom prompt to override the default instructions for the LLM.       |\n| `--api-key <key>`                | Temporarily override the stored API key for a single `convert` command.     |\n| `-n`, `--notion`                 | Save the converted file to your configured Notion database.                 |\n\n**Examples:**\n\n-   **Convert a single file**:\n    The converted file will be saved in the same directory with a `.md` extension (e.g., `my_document.md`).\n    ```bash\n    notedmd convert my_document.pdf\n    ```\n\n-   **Convert a file and save it to Notion**:\n    ```bash\n    notedmd convert my_notes.png --notion\n    ```\n\n-   **Convert a file with a custom prompt**:\n    ```bash\n    notedmd convert my_notes.png --prompt \"Transcribe this into a bulleted list.\"\n    ```\n\n-   **Convert a file and save it to a different directory**:\n    ```bash\n    notedmd convert my_document.pdf --output ./markdown_notes/\n    ```\n\n-   **Convert all supported files in a directory**:\n    ```bash\n    notedmd convert ./my_project_files/\n    ```\n\n-   **Convert all files in a directory to a specific output directory**:\n    ```bash\n    notedmd convert ./my_project_files/ --output ./markdown_notes/\n    ```\n\n## Contributing\n\nContributions are welcome! If you have a feature request, bug report, or want to contribute to the code, please feel free to open an issue or a pull request on our [GitHub repository](https://github.com/tejas-raskar/noted.md).\n\n## License\n\nThis project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.\n"
  },
  {
    "path": "src/ai_provider.rs",
    "content": "use crate::{error::NotedError, file_utils::FileData};\nuse async_trait::async_trait;\n\n#[async_trait]\npub trait AiProvider {\n    async fn send_request(&self, file_data: FileData) -> Result<String, NotedError>;\n}\n"
  },
  {
    "path": "src/cli.rs",
    "content": "use clap::{Parser, Subcommand};\n\n#[derive(Parser, Debug)]\n#[command(\n    version,\n    about = \"A command-line tool to convert handwritten notes into clean and readable Markdown files\",\n    long_about = None)]\npub struct Cli {\n    #[command(subcommand)]\n    pub command: Commands,\n}\n\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    /// Convert files to Markdown format\n    Convert {\n        /// Path to a file or directory to convert\n        #[arg(required = true)]\n        path: String,\n\n        /// Output directory to save converted files\n        #[arg(\n            short,\n            long,\n            help = \"Directory where converted markdown files will be saved\"\n        )]\n        output: Option<String>,\n\n        /// API key for conversion\n        #[arg(long, env = \"GEMINI_API_KEY\", hide_env_values = true)]\n        api_key: Option<String>,\n\n        /// Prompt the LLM\n        #[arg(short, long, help = \"Add a custom prompt to pass to the LLM\")]\n        prompt: Option<String>,\n\n        /// Notion Support\n        #[arg(short, long, help = \"Use Notion to store the generated output\")]\n        notion: bool,\n    },\n\n    /// Configure notedmd settings\n    Config {\n        /// Set your Gemini API key\n        #[arg(long, help = \"Set your Gemini API key for future use\")]\n        set_api_key: Option<String>,\n\n        /// Set your Claude API key\n        #[arg(long, help = \"Set your Claude API key for future use\")]\n        set_claude_api_key: Option<String>,\n\n        /// Set active provider\n        #[arg(long, help = \"Set the active provider\")]\n        set_provider: Option<String>,\n\n        /// Show config file location\n        #[arg(long, help = \"Shows the location of your configuration file\")]\n        show_path: bool,\n\n        /// Show config file\n        #[arg(long, help = \"Shows the content of your configuration\")]\n        show: bool,\n\n        /// Trigger onboarding flow\n        #[arg(long, help = \"Edit the configuration file\")]\n        edit: bool,\n    },\n}\n"
  },
  {
    "path": "src/clients/claude_client.rs",
    "content": "use crate::ai_provider::AiProvider;\nuse crate::error::NotedError;\nuse crate::file_utils::FileData;\nuse async_trait::async_trait;\nuse reqwest::{Client, StatusCode};\nuse serde::{Deserialize, Serialize};\n\n// Request structs\n\n#[derive(Serialize)]\nstruct ClaudeRequest {\n    model: String,\n    max_tokens: u32,\n    messages: Vec<Message>,\n}\n\n#[derive(Serialize)]\nstruct Message {\n    role: String,\n    content: Vec<Content>,\n}\n\n#[derive(Serialize)]\nstruct Content {\n    #[serde(rename = \"type\")]\n    content_type: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    text: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    source: Option<Source>,\n}\n\n#[derive(Serialize)]\nstruct Source {\n    #[serde(rename = \"type\")]\n    source_type: String,\n    media_type: String,\n    data: String,\n}\n\n//  Response structs\n\n#[derive(Deserialize, Debug)]\npub struct ClaudeResponse {\n    pub content: Vec<ContentResponse>,\n    #[serde(default)]\n    pub error: Option<ClaudeError>,\n}\n\n#[derive(Deserialize, Debug)]\npub struct ClaudeError {\n    pub message: String,\n}\n\n#[derive(Deserialize, Debug)]\npub struct ContentResponse {\n    pub text: String,\n}\n\n// Client\npub struct ClaudeClient {\n    client: Client,\n    api_key: String,\n    model: String,\n    prompt: Option<String>,\n}\n\nimpl ClaudeClient {\n    pub fn new(api_key: String, model: String, prompt: Option<String>) -> Self {\n        Self {\n            client: Client::new(),\n            api_key,\n            model,\n            prompt,\n        }\n    }\n}\n\n#[async_trait]\nimpl AiProvider for ClaudeClient {\n    async fn send_request(&self, file_data: FileData) -> Result<String, NotedError> {\n        let url = \"https://api.anthropic.com/v1/messages\".to_string();\n\n        let prompt = if let Some(custom_prompt) = &self.prompt {\n            custom_prompt.clone()\n        } else {\n            \"Take the handwritten notes from this image and convert them into a clean, well-structured Markdown file. Pay attention to headings, lists, and any other formatting. Resemble the hierarchy. Use latex for mathematical equations. For latex use the $$ syntax instead of ```latex. Do not skip anything from the original text. The output should be suitable for use in Obsidian. Just give me the markdown, do not include other text in the response apart from the markdown file. No explanation on how the changes were made is needed\".to_string()\n        };\n\n        let file_type = if file_data.mime_type == \"application/pdf\" {\n            \"document\".to_string()\n        } else {\n            \"image\".to_string()\n        };\n\n        let request_body = ClaudeRequest {\n            model: self.model.clone(),\n            max_tokens: 4096,\n            messages: vec![Message {\n                role: \"user\".to_string(),\n                content: vec![\n                    Content {\n                        content_type: file_type,\n                        text: None,\n                        source: Some(Source {\n                            source_type: \"base64\".to_string(),\n                            media_type: file_data.mime_type,\n                            data: file_data.encoded_data,\n                        }),\n                    },\n                    Content {\n                        content_type: \"text\".to_string(),\n                        text: Some(prompt),\n                        source: None,\n                    },\n                ],\n            }],\n        };\n\n        let response = self\n            .client\n            .post(&url)\n            .header(\"x-api-key\", &self.api_key)\n            .header(\"anthropic-version\", \"2023-06-01\")\n            .json(&request_body)\n            .send()\n            .await?;\n\n        let status = response.status();\n        let response_body = response.text().await?;\n\n        if status != StatusCode::OK {\n            if status == StatusCode::UNAUTHORIZED {\n                return Err(NotedError::InvalidApiKey);\n            }\n            let error_response: Result<ClaudeResponse, _> = serde_json::from_str(&response_body);\n            if let Ok(err_resp) = error_response {\n                if let Some(error) = err_resp.error {\n                    return Err(NotedError::ApiError(error.message));\n                }\n            }\n            return Err(NotedError::ApiError(format!(\n                \"Received status code: {}\",\n                status\n            )));\n        }\n\n        let claude_response: ClaudeResponse = serde_json::from_str(&response_body)\n            .map_err(|e| NotedError::ResponseDecodeError(e.to_string()))?;\n\n        if let Some(error) = claude_response.error {\n            return Err(NotedError::ApiError(error.message));\n        }\n\n        let markdown_text = claude_response\n            .content\n            .first()\n            .map(|c| c.text.as_str())\n            .unwrap_or(\"\");\n\n        let cleaned_markdown = markdown_text\n            .trim_start_matches(\"```markdown\\n\")\n            .trim_end_matches(\"```\");\n\n        Ok(cleaned_markdown.to_string())\n    }\n}\n"
  },
  {
    "path": "src/clients/gemini_client.rs",
    "content": "use crate::ai_provider::AiProvider;\nuse crate::error::NotedError;\nuse crate::file_utils::FileData;\nuse async_trait::async_trait;\nuse reqwest::{Client, StatusCode};\nuse serde::{Deserialize, Serialize};\n\n// Request structs\n\n#[derive(Serialize)]\nstruct GeminiRequest {\n    contents: Vec<Content>,\n}\n\n#[derive(Serialize)]\nstruct Content {\n    parts: Vec<Part>,\n}\n\n#[derive(Serialize)]\nstruct Part {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    text: Option<String>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    inline_data: Option<InlineData>,\n}\n\n#[derive(Serialize)]\nstruct InlineData {\n    #[serde(rename = \"mimeType\")]\n    mime_type: String,\n    data: String,\n}\n\n//  Response structs\n\n#[derive(Deserialize, Debug)]\npub struct GeminiResponse {\n    pub candidates: Option<Vec<Candidate>>,\n    #[serde(default)]\n    pub error: Option<GeminiError>,\n}\n\n#[derive(Deserialize, Debug)]\npub struct GeminiError {\n    pub message: String,\n}\n\n#[derive(Deserialize, Debug)]\npub struct Candidate {\n    pub content: ContentResponse,\n}\n\n#[derive(Deserialize, Debug)]\npub struct ContentResponse {\n    pub parts: Vec<PartResponse>,\n}\n\n#[derive(Deserialize, Debug)]\npub struct PartResponse {\n    pub text: String,\n}\n\n// Client\npub struct GeminiClient {\n    client: Client,\n    api_key: String,\n    prompt: Option<String>,\n}\n\nimpl GeminiClient {\n    pub fn new(api_key: String, prompt: Option<String>) -> Self {\n        Self {\n            client: Client::new(),\n            api_key,\n            prompt,\n        }\n    }\n}\n\n#[async_trait]\nimpl AiProvider for GeminiClient {\n    async fn send_request(&self, file_data: FileData) -> Result<String, NotedError> {\n        let url = format!(\n            \"https://generativelanguage.googleapis.com/v1beta/models/gemma-3-27b-it:generateContent?key={}\",\n            self.api_key\n        );\n\n        let prompt = if let Some(custom_prompt) = &self.prompt {\n            custom_prompt.clone()\n        } else {\n            \"Take the handwritten notes from this image and convert them into a clean, well-structured Markdown file. Pay attention to headings, lists, and any other formatting. Resemble the hierarchy. Use latex for mathematical equations. For latex use the $$ syntax instead of ```latex. Do not skip anything from the original text. The output should be suitable for use in Obsidian. Just give me the markdown, do not include other text in the response apart from the markdown file. No explanation on how the changes were made is needed\".to_string()\n        };\n\n        let request_body = GeminiRequest {\n            contents: vec![Content {\n                parts: vec![\n                    Part {\n                        text: Some(prompt),\n                        inline_data: None,\n                    },\n                    Part {\n                        text: None,\n                        inline_data: Some(InlineData {\n                            mime_type: file_data.mime_type,\n                            data: file_data.encoded_data,\n                        }),\n                    },\n                ],\n            }],\n        };\n\n        let response = self.client.post(&url).json(&request_body).send().await?;\n\n        let status = response.status();\n        let response_body = response.text().await?;\n\n        if status != StatusCode::OK {\n            if status == StatusCode::UNAUTHORIZED {\n                return Err(NotedError::InvalidApiKey);\n            }\n            let error_response: Result<GeminiResponse, _> = serde_json::from_str(&response_body);\n            if let Ok(err_resp) = error_response {\n                if let Some(error) = err_resp.error {\n                    return Err(NotedError::ApiError(error.message));\n                }\n            }\n            return Err(NotedError::ApiError(format!(\n                \"Received status code: {}\",\n                status\n            )));\n        }\n\n        let gemini_response: GeminiResponse = serde_json::from_str(&response_body)\n            .map_err(|e| NotedError::ResponseDecodeError(e.to_string()))?;\n\n        if let Some(error) = gemini_response.error {\n            return Err(NotedError::ApiError(error.message));\n        }\n\n        let markdown_text = gemini_response\n            .candidates\n            .as_ref()\n            .and_then(|candidates| candidates.first())\n            .and_then(|candidate| candidate.content.parts.first())\n            .map(|part| part.text.as_str())\n            .unwrap_or(\"\");\n\n        let cleaned_markdown = markdown_text\n            .trim_start_matches(\"```markdown\\n\")\n            .trim_end_matches(\"```\");\n\n        Ok(cleaned_markdown.to_string())\n    }\n}\n"
  },
  {
    "path": "src/clients/mod.rs",
    "content": "pub mod claude_client;\npub mod gemini_client;\npub mod notion_client;\npub mod ollama_client;\npub mod openai_client;\n"
  },
  {
    "path": "src/clients/notion_client.rs",
    "content": "use std::collections::HashMap;\n\nuse anyhow::Result;\nuse colored::Colorize;\nuse comrak::Arena;\nuse notion_client::objects::block::Block;\nuse reqwest::Client;\nuse serde::{Deserialize, Serialize};\n\nuse crate::{config, error::NotedError, notion::converter};\n\n// Request structs\n#[derive(Serialize)]\npub struct NotionRequest {\n    pub parent: Parent,\n    pub properties: serde_json::Map<String, serde_json::Value>,\n    pub children: Vec<Block>,\n}\n\n#[derive(Serialize)]\npub struct Parent {\n    pub database_id: String,\n}\n\n// Response Struct\n#[derive(Deserialize, Debug)]\npub struct NotionResponse {\n    #[serde(rename = \"id\")]\n    pub _id: String,\n    pub url: String,\n}\n\n#[derive(Deserialize, Debug)]\npub struct NotionDatabase {\n    pub properties: HashMap<String, DatabaseProperty>,\n}\n\n#[derive(Deserialize, Debug)]\npub struct DatabaseProperty {\n    #[serde(rename = \"id\")]\n    pub _id: String,\n    pub name: String,\n    #[serde(flatten)]\n    pub type_specific_config: PropertyType,\n}\n\n#[derive(Deserialize, Debug)]\n#[serde(tag = \"type\")]\n#[serde(rename_all = \"snake_case\")]\npub enum PropertyType {\n    Title(EmptyStruct),\n    RichText(EmptyStruct),\n    Number(EmptyStruct),\n    Select { select: SelectStruct },\n    MultiSelect { multi_select: SelectStruct },\n    Date(EmptyStruct),\n    Checkbox(EmptyStruct),\n    People(EmptyStruct),\n    Files(EmptyStruct),\n    Url(EmptyStruct),\n    Email(EmptyStruct),\n    CreatedTime(EmptyStruct),\n    CreatedBy(EmptyStruct),\n    LastEditedTime(EmptyStruct),\n    LastEditedBy(EmptyStruct),\n    Status { status: SelectStruct },\n    Formula(EmptyStruct),\n    Relation(EmptyStruct),\n    Rollup(EmptyStruct),\n    PhoneNumber(EmptyStruct),\n    Button(EmptyStruct),\n    UniqueId(EmptyStruct),\n    Verification(EmptyStruct),\n}\n\n#[derive(Deserialize, Debug)]\npub struct SelectStruct {\n    pub options: Vec<DatabaseSelectOption>,\n}\n\n#[derive(Deserialize, Debug, Clone)]\npub struct DatabaseSelectOption {\n    #[serde(rename = \"id\")]\n    pub _id: String,\n    pub name: String,\n    #[serde(rename = \"color\")]\n    pub _color: String,\n}\n\n#[derive(Deserialize, Debug)]\npub struct NumberStruct {\n    pub _number: NumberFormat,\n}\n\n#[derive(Deserialize, Debug)]\npub struct NumberFormat {\n    pub _format: String,\n}\n\n#[derive(Deserialize, Debug)]\npub struct EmptyStruct {}\n\n#[derive(Deserialize, Debug)]\npub struct NotionError {\n    pub message: String,\n}\n\n// Client\npub struct NotionClient {\n    client: Client,\n    api_key: String,\n    database_id: String,\n}\n\nimpl NotionClient {\n    pub fn new(api_key: String, database_id: String) -> Self {\n        Self {\n            client: Client::new(),\n            api_key,\n            database_id,\n        }\n    }\n\n    pub async fn get_database_schema(&self) -> Result<NotionDatabase, NotedError> {\n        let url = format!(\"https://api.notion.com/v1/databases/{}\", self.database_id);\n        let response = self\n            .client\n            .get(url)\n            .header(\"Authorization\", format!(\"Bearer {}\", self.api_key))\n            .header(\"Notion-Version\", \"2022-06-28\")\n            .send()\n            .await?;\n\n        let status = response.status();\n        let response_body = response.text().await?;\n        if status.is_success() {\n            let notion_database: NotionDatabase = serde_json::from_str(&response_body)\n                .map_err(|e| NotedError::ResponseDecodeError(e.to_string()))?;\n            Ok(notion_database)\n        } else {\n            let error_response: NotionError = serde_json::from_str(&response_body)\n                .map_err(|e| NotedError::ResponseDecodeError(e.to_string()))?;\n            Err(NotedError::ApiError(format!(\n                \"Notion API Error ({}): {}\",\n                status,\n                error_response.message.red()\n            )))\n        }\n    }\n\n    pub async fn create_notion_page(\n        &self,\n        title: &str,\n        title_property_name: &str,\n        properties: &[config::NotionPropertyConfig],\n        markdown_content: &str,\n    ) -> Result<NotionResponse, NotedError> {\n        let url = \"https://api.notion.com/v1/pages\";\n        let arena = Arena::new();\n        let blocks = converter::Converter::run(&markdown_content, &arena)\n            .map_err(|e| NotedError::ApiError(e.to_string()))?;\n\n        let mut props_map = serde_json::Map::new();\n        props_map.insert(\n            title_property_name.to_string(),\n            serde_json::json!(\n            {\n                \"title\": [\n                    {\n                        \"text\":{\n                            \"content\": title\n                        }\n                    }\n                ]\n            }),\n        );\n\n        for prop_config in properties {\n            let prop_name = &prop_config.name;\n            let prop_type = &prop_config.property_type;\n            let prop_value = &prop_config.default_value;\n\n            let notion_property_value = match prop_type.as_str() {\n                \"multi_select\" => {\n                    if let Some(arr) = prop_value.as_array() {\n                        let options: Vec<_> = arr\n                            .iter()\n                            .map(|val| serde_json::json!({\"name\": val}))\n                            .collect();\n                        serde_json::json!({\"multi_select\": options})\n                    } else {\n                        continue;\n                    }\n                }\n                \"select\" => serde_json::json!({\n                    \"select\": {\n                        \"name\": prop_value\n                    }\n                }),\n                \"rich_text\" => serde_json::json!({\n                    \"rich_text\": [\n                        {\n                            \"type\": \"text\",\n                            \"text\": {\n                                \"content\": prop_value\n                            }\n                        }\n                    ]\n                }),\n                \"number\" => serde_json::json!({\n                    \"number\": prop_value\n                }),\n                \"date\" => serde_json::json!({\n                    \"date\": {\n                        \"start\": prop_value\n                    }\n                }),\n                \"checkbox\" => serde_json::json!({\n                    \"checkbox\": prop_value\n                }),\n                _ => continue,\n            };\n\n            props_map.insert(prop_name.clone(), notion_property_value);\n        }\n        let request_body = NotionRequest {\n            parent: Parent {\n                database_id: self.database_id.clone(),\n            },\n            properties: props_map,\n            children: blocks,\n        };\n\n        let response = self\n            .client\n            .post(url)\n            .header(\"Authorization\", format!(\"Bearer {}\", self.api_key))\n            .header(\"Notion-Version\", \"2022-06-28\")\n            .json(&request_body)\n            .send()\n            .await?;\n\n        let status = response.status();\n        let response_body = response.text().await?;\n\n        if status.is_success() {\n            let notion_reponse: NotionResponse = serde_json::from_str(&response_body)\n                .map_err(|e| NotedError::ResponseDecodeError(e.to_string()))?;\n            Ok(notion_reponse)\n        } else {\n            let error_response: NotionError = serde_json::from_str(&response_body)\n                .map_err(|e| NotedError::ResponseDecodeError(e.to_string()))?;\n            Err(NotedError::ApiError(format!(\n                \"Notion API Error ({}): {}\",\n                status, error_response.message\n            )))\n        }\n    }\n}\n"
  },
  {
    "path": "src/clients/ollama_client.rs",
    "content": "use async_trait::async_trait;\nuse reqwest::{Client, StatusCode};\nuse serde::{Deserialize, Serialize};\n\nuse crate::{ai_provider::AiProvider, error::NotedError, file_utils::FileData};\n\n// Request struct\n#[derive(Serialize)]\nstruct OllamaRequest {\n    model: String,\n    prompt: String,\n    images: Vec<String>,\n    stream: bool,\n}\n\n// Response struct\n#[derive(Deserialize, Debug)]\npub struct OllamaResponse {\n    pub response: String,\n    #[serde(default)]\n    pub error: Option<String>,\n}\n\n// Client struct\npub struct OllamaClient {\n    client: Client,\n    url: String,\n    model: String,\n    prompt: Option<String>,\n}\n\nimpl OllamaClient {\n    pub fn new(url: String, model: String, prompt: Option<String>) -> Self {\n        Self {\n            client: Client::new(),\n            url,\n            model,\n            prompt,\n        }\n    }\n}\n\n#[async_trait]\nimpl AiProvider for OllamaClient {\n    async fn send_request(&self, file_data: FileData) -> Result<String, NotedError> {\n        let url = format!(\"{}/api/generate\", self.url);\n        let prompt = if let Some(custom_prompt) = &self.prompt {\n            custom_prompt.clone()\n        } else {\n            \"The user has provided an image of handwritten notes. Your task is to accurately transcribe these notes into a well-structured Markdown file. Preserve the original hierarchy, including headings and lists. Use LaTeX for any mathematical equations that appear in the notes. The output should only be the markdown content.\".to_string()\n        };\n\n        let request_body = OllamaRequest {\n            model: self.model.clone(),\n            prompt,\n            images: vec![file_data.encoded_data],\n            stream: false,\n        };\n\n        let response = self.client.post(&url).json(&request_body).send().await?;\n\n        let status = response.status();\n        let response_body = response.text().await?;\n\n        if status != StatusCode::OK {\n            let error_response: Result<OllamaResponse, _> = serde_json::from_str(&response_body);\n            if let Ok(err_resp) = error_response {\n                if let Some(error) = err_resp.error {\n                    return Err(NotedError::ApiError(error));\n                }\n            }\n            return Err(NotedError::ApiError(format!(\n                \"Received status code: {}\",\n                status\n            )));\n        }\n\n        let ollama_response: OllamaResponse = serde_json::from_str(&response_body)\n            .map_err(|e| NotedError::ResponseDecodeError(e.to_string()))?;\n\n        if let Some(error) = ollama_response.error {\n            return Err(NotedError::ApiError(error));\n        }\n\n        let cleaned_markdown = ollama_response\n            .response\n            .trim_start_matches(\"```markdown\\n\")\n            .trim_end_matches(\"```\");\n\n        Ok(cleaned_markdown.to_string())\n    }\n}\n"
  },
  {
    "path": "src/clients/openai_client.rs",
    "content": "use crate::{ai_provider::AiProvider, error::NotedError, file_utils::FileData};\nuse async_trait::async_trait;\nuse reqwest::{Client, StatusCode};\nuse serde::{Deserialize, Serialize};\n\n// Request structs\n\n#[derive(Serialize)]\nstruct OpenAIRequest {\n    model: String,\n    messages: Vec<Message>,\n}\n\n#[derive(Serialize)]\nstruct Message {\n    role: String,\n    content: Vec<Content>,\n}\n\n#[derive(Serialize)]\nstruct Content {\n    #[serde(rename = \"type\")]\n    content_type: String,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    text: Option<String>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    image_url: Option<Image>,\n}\n\n#[derive(Serialize)]\nstruct Image {\n    url: String,\n}\n\n// Response structs\n#[derive(Deserialize, Debug)]\npub struct OpenAIResponse {\n    pub choices: Vec<Choice>,\n\n    #[serde(default)]\n    pub error: Option<OpenAIError>,\n}\n\n#[derive(Deserialize, Debug)]\npub struct OpenAIError {\n    pub message: String,\n}\n\n#[derive(Deserialize, Debug)]\npub struct Choice {\n    pub message: ResponseMessage,\n}\n\n#[derive(Deserialize, Debug)]\npub struct ResponseMessage {\n    pub content: String,\n}\n\n//Client\npub struct OpenAIClient {\n    client: Client,\n    url: String,\n    model: String,\n    api_key: Option<String>,\n    prompt: Option<String>,\n}\n\nimpl OpenAIClient {\n    pub fn new(\n        url: String,\n        model: String,\n        api_key: Option<String>,\n        prompt: Option<String>,\n    ) -> Self {\n        Self {\n            client: Client::new(),\n            url,\n            model,\n            api_key,\n            prompt,\n        }\n    }\n}\n\n#[async_trait]\nimpl AiProvider for OpenAIClient {\n    async fn send_request(&self, file_data: FileData) -> Result<String, NotedError> {\n        let url = format!(\"{}/v1/chat/completions\", self.url);\n        let prompt = if let Some(custom_prompt) = &self.prompt {\n            custom_prompt.clone()\n        } else {\n            \"The user has provided an image of handwritten notes. Your task is to accurately transcribe these notes into a well-structured Markdown file. Preserve the original hierarchy, including headings and lists. Use LaTeX for any mathematical equations that appear in the notes. The output should only be the markdown content.\".to_string()\n        };\n        let image_url = format!(\n            \"data:{};base64,{}\",\n            file_data.mime_type, file_data.encoded_data\n        );\n\n        let request_body = OpenAIRequest {\n            model: self.model.clone(),\n            messages: vec![Message {\n                role: \"user\".to_string(),\n                content: vec![\n                    Content {\n                        content_type: \"text\".to_string(),\n                        text: Some(prompt),\n                        image_url: None,\n                    },\n                    Content {\n                        content_type: \"image_url\".to_string(),\n                        text: None,\n                        image_url: Some(Image { url: image_url }),\n                    },\n                ],\n            }],\n        };\n\n        let mut request = self.client.post(&url);\n\n        if let Some(api_key) = &self.api_key {\n            request = request.header(\"Authorization\", format!(\"Bearer {}\", api_key));\n        }\n\n        let response = request.json(&request_body).send().await?;\n\n        let status = response.status();\n        let response_body = response.text().await?;\n\n        if status != StatusCode::OK {\n            let error_response: Result<OpenAIResponse, _> = serde_json::from_str(&response_body);\n            if let Ok(err_resp) = error_response {\n                if let Some(error) = err_resp.error {\n                    return Err(NotedError::ApiError(error.message));\n                }\n            }\n            return Err(NotedError::ApiError(format!(\n                \"Received status code: {}\",\n                status\n            )));\n        }\n\n        let openai_response: OpenAIResponse = serde_json::from_str(&response_body)\n            .map_err(|e| NotedError::ResponseDecodeError(e.to_string()))?;\n\n        if let Some(error) = openai_response.error {\n            return Err(NotedError::ApiError(error.message));\n        }\n\n        let markdown_text = openai_response\n            .choices\n            .first()\n            .map(|c| c.message.content.as_str())\n            .unwrap_or(\"\");\n\n        let cleaned_markdown = markdown_text\n            .trim_start_matches(\"```markdown\\n\")\n            .trim_end_matches(\"```\");\n\n        Ok(cleaned_markdown.to_string())\n    }\n}\n"
  },
  {
    "path": "src/config.rs",
    "content": "use crate::error::NotedError;\nuse directories::ProjectDirs;\nuse serde::{Deserialize, Serialize};\nuse std::{fs, path::PathBuf};\n\n#[derive(Serialize, Deserialize, Debug, Default)]\npub struct Config {\n    pub active_provider: Option<String>,\n    pub gemini: Option<GeminiConfig>,\n    pub ollama: Option<OllamaConfig>,\n    pub claude: Option<ClaudeConfig>,\n    pub openai: Option<OpenAIConfig>,\n    pub notion: Option<NotionConfig>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\npub struct NotionConfig {\n    pub api_key: String,\n    pub database_id: String,\n    #[serde(default)]\n    pub title_property_name: String,\n    #[serde(default)]\n    pub properties: Vec<NotionPropertyConfig>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default, Clone)]\npub struct NotionPropertyConfig {\n    pub name: String,\n    pub property_type: String,\n    pub default_value: serde_json::Value,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\npub struct ClaudeConfig {\n    pub api_key: String,\n    pub model: String,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\npub struct GeminiConfig {\n    pub api_key: String,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\npub struct OllamaConfig {\n    pub url: String,\n    pub model: String,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\npub struct OpenAIConfig {\n    pub url: String,\n    pub model: String,\n    pub api_key: Option<String>,\n}\n\npub fn get_config_path() -> Option<PathBuf> {\n    ProjectDirs::from(\"com\", \"company\", \"notedmd\").map(|dirs| {\n        let config_dir = dirs.config_dir();\n        if !config_dir.exists() {\n            fs::create_dir_all(config_dir).ok();\n        }\n        config_dir.join(\"config.toml\")\n    })\n}\n\nimpl Config {\n    pub fn load() -> Result<Self, NotedError> {\n        if let Some(config_path) = get_config_path() {\n            if config_path.exists() {\n                let content = fs::read_to_string(config_path)?;\n                return Ok(toml::from_str(&content)?);\n            }\n        }\n        Ok(Self::default())\n    }\n\n    pub fn save(&self) -> Result<(), NotedError> {\n        if let Some(config_path) = get_config_path() {\n            let toml_string = toml::to_string_pretty(self)?;\n            fs::write(config_path, toml_string)?;\n        }\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src/error.rs",
    "content": "use thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum NotedError {\n    #[error(\" Configuration file not found. Please run 'notedmd config --edit' to set it up.\")]\n    ConfigNotFound,\n\n    #[error(\" Failed to save configuration: {0}\")]\n    ConfigSaveError(#[from] toml::ser::Error),\n\n    #[error(\" Failed to read configuration: {0}\")]\n    ConfigReadError(#[from] toml::de::Error),\n\n    #[error(\" I/O error: {0}\")]\n    IoError(#[from] std::io::Error),\n\n    #[error(\" Network request failed: {0}\")]\n    NetworkError(#[from] reqwest::Error),\n\n    #[error(\" API key is invalid or missing. Please check your configuration.\")]\n    InvalidApiKey,\n\n    #[error(\" The AI provider returned an error: {0}\")]\n    ApiError(String),\n\n    #[error(\" Failed to decode API response: {0}\")]\n    ResponseDecodeError(String),\n\n    #[error(\" Could not determine the file name for the path: {0}\")]\n    FileNameError(String),\n\n    #[error(\" File type not supported: {0}\")]\n    UnsupportedFileType(String),\n\n    #[error(\" Ollama is not configured properly. Please run 'notedmd config --edit' to set it up.\")]\n    OllamaNotConfigured,\n\n    #[error(\" Gemini is not configured properly. Please run 'notedmd config --edit' to set it up.\")]\n    GeminiNotConfigured,\n\n    #[error(\" Claude is not configured properly. Please run 'notedmd config --edit' to set it up.\")]\n    ClaudeNotConfigured,\n\n    #[error(\" Notion is not configured properly. Please run 'notedmd config --edit' to set it up.\")]\n    NotionNotConfigured,\n\n    #[error(\n        \" OpenAI/LM Studio is not configured properly. Please run 'notedmd config --edit' to set it up.\"\n    )]\n    OpenAINotConfigured,\n\n    #[error(\" No active provider. Please run 'notedmd config --edit' to set a provider.\")]\n    NoActiveProvider,\n\n    #[error(\" Dialoguer error: {0}\")]\n    DialoguerError(#[from] dialoguer::Error),\n}\n"
  },
  {
    "path": "src/file_utils.rs",
    "content": "use crate::error::NotedError;\nuse base64::{Engine, engine::general_purpose};\nuse std::{fs, path::Path};\n\npub struct FileData {\n    pub encoded_data: String,\n    pub mime_type: String,\n}\n\npub fn process_file(file_path: &str) -> Result<FileData, NotedError> {\n    let data = fs::read(file_path)?;\n    let encoded_data: String = general_purpose::STANDARD.encode(&data);\n    let mime_type = get_file_mime_type(file_path)?;\n\n    Ok(FileData {\n        encoded_data,\n        mime_type,\n    })\n}\n\npub fn get_file_mime_type(file_path: &str) -> Result<String, NotedError> {\n    let file_extension = Path::new(file_path)\n        .extension()\n        .and_then(|ext| ext.to_str());\n\n    match file_extension {\n        Some(\"png\") => Ok(\"image/png\".to_string()),\n        Some(\"pdf\") => Ok(\"application/pdf\".to_string()),\n        Some(\"jpg\") => Ok(\"image/jpeg\".to_string()),\n        Some(\"jpeg\") => Ok(\"image/jpeg\".to_string()),\n        Some(ext) => Err(NotedError::UnsupportedFileType(ext.to_string())),\n        None => Err(NotedError::UnsupportedFileType(\"No extension\".to_string())),\n    }\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "mod ai_provider;\nmod cli;\nmod clients;\nmod config;\nmod error;\nmod file_utils;\nmod notion;\nmod ui;\n\nuse ai_provider::AiProvider;\nuse clap::Parser;\nuse cli::{Cli, Commands};\nuse colored::*;\nuse config::{ClaudeConfig, Config, GeminiConfig, OllamaConfig};\nuse dialoguer::Confirm;\nuse dialoguer::Input;\nuse dialoguer::MultiSelect;\nuse dialoguer::Select;\nuse dialoguer::{Password, theme::ColorfulTheme};\nuse error::NotedError;\nuse indicatif::ProgressBar;\nuse indicatif::ProgressStyle;\n\nuse crate::clients::claude_client::ClaudeClient;\nuse crate::clients::gemini_client::GeminiClient;\nuse crate::clients::notion_client::NotionClient;\nuse crate::clients::notion_client::PropertyType;\nuse crate::clients::ollama_client::OllamaClient;\nuse crate::clients::openai_client::OpenAIClient;\nuse crate::config::NotionConfig;\nuse crate::config::OpenAIConfig;\nuse std::path::Path;\nuse ui::{ascii_art, print_clean_config};\n\nuse crate::config::get_config_path;\n\nasync fn process_and_save_file(\n    file_path: &str,\n    client: &dyn AiProvider,\n    output_dir: Option<&str>,\n    progress_bar: &ProgressBar,\n    notion_client: Option<&NotionClient>,\n    notion_config: Option<&NotionConfig>,\n) -> Result<(), NotedError> {\n    let path = Path::new(file_path);\n    let file_name = match path.file_name() {\n        Some(name) => name,\n        None => {\n            return Err(NotedError::FileNameError(file_path.to_string()));\n        }\n    };\n\n    progress_bar.println(format!(\n        \"\\n{}\",\n        format!(\"Processing file: {:#?}\", file_name).bold()\n    ));\n\n    let file_data = file_utils::process_file(file_path)?;\n    progress_bar.println(format!(\n        \"{} {}\",\n        \"✔\".green(),\n        \"File read successfully.\".green()\n    ));\n\n    progress_bar.set_message(format!(\"{}\", \"Sending to your AI model...\".yellow()));\n\n    let markdown = client.send_request(file_data).await?;\n    progress_bar.println(format!(\"{} {}\", \"✔\".green(), \"Received response.\".green()));\n\n    let output_path = match output_dir {\n        Some(dir) => {\n            let dir_path = Path::new(dir);\n            if !dir_path.exists() {\n                std::fs::create_dir_all(dir_path)?;\n            }\n            let final_path = dir_path.join(file_name);\n            final_path\n                .with_extension(\"md\")\n                .to_string_lossy()\n                .into_owned()\n        }\n        None => path.with_extension(\"md\").to_string_lossy().into_owned(),\n    };\n\n    match std::fs::write(&output_path, &markdown) {\n        Ok(_) => {\n            progress_bar.println(format!(\n                \"{} {}\",\n                \"✔\".green(),\n                format!(\"Markdown saved to '{}'\", output_path.cyan()).green()\n            ));\n            if let (Some(client), Some(config)) = (notion_client, notion_config) {\n                match client\n                    .create_notion_page(\n                        file_name.to_string_lossy().into_owned().as_str(),\n                        &config.title_property_name,\n                        &config.properties,\n                        &markdown,\n                    )\n                    .await\n                {\n                    Ok(page) => {\n                        progress_bar.println(format!(\n                            \"{} {}\",\n                            \"✔\".green(),\n                            format!(\"Notion page created at '{}'\", page.url.cyan()).green()\n                        ));\n                    }\n                    Err(e) => {\n                        return Err(e);\n                    }\n                }\n            };\n            Ok(())\n        }\n        Err(e) => {\n            progress_bar.println(format!(\n                \"{} {}\",\n                \"✖\".red(),\n                format!(\"Failed to save file to '{}'. Error: {}\", &output_path, e).red()\n            ));\n            Err(e.into())\n        }\n    }\n}\n\nasync fn run() -> Result<(), NotedError> {\n    let args = Cli::parse();\n    match args.command {\n        Commands::Config {\n            set_api_key,\n            set_claude_api_key,\n            set_provider,\n            show_path,\n            show,\n            edit,\n        } => {\n            if show_path {\n                if let Some(config_path) = config::get_config_path() {\n                    if config_path.exists() {\n                        println!(\"Config saved in {:?}\", config_path);\n                    } else {\n                        return Err(NotedError::ConfigNotFound);\n                    }\n                }\n            }\n\n            if show {\n                if let Some(config_path) = config::get_config_path() {\n                    if config_path.exists() {\n                        let config = Config::load()?;\n                        print_clean_config(config);\n                    } else {\n                        return Err(NotedError::ConfigNotFound);\n                    }\n                }\n            }\n\n            if let Some(ref key) = set_api_key {\n                let mut config = Config::load()?;\n                config.active_provider = Some(\"gemini\".to_string());\n                config.gemini = Some(config::GeminiConfig {\n                    api_key: key.to_string(),\n                });\n\n                config.save()?;\n                println!(\"Config saved successfully.\");\n            }\n\n            if let Some(ref key) = set_claude_api_key {\n                let mut config = Config::load()?;\n                config.active_provider = Some(\"claude\".to_string());\n                let model = Input::with_theme(&ColorfulTheme::default())\n                    .with_prompt(\"Claude model\")\n                    .default(\"claude-3-opus-20240229\".to_string())\n                    .interact_text()?;\n\n                config.claude = Some(config::ClaudeConfig {\n                    api_key: key.to_string(),\n                    model,\n                });\n\n                config.save()?;\n                println!(\"Config saved successfully.\");\n            }\n\n            if edit {\n                ascii_art();\n                println!(\n                    \"{}\\n\",\n                    \"Welcome to noted.md! Let's set up your AI provider.\".bold()\n                );\n\n                let providers = vec![\n                    \"Gemini API (Cloud-based, requires API key)\",\n                    \"Claude API (Cloud-based, requires API key)\",\n                    \"Ollama (Local, requires Ollama to be set up)\",\n                    \"OpenAI Compatible API (Cloud/Local, works with LM Studio)\",\n                ];\n                let selected_provider = Select::with_theme(&ColorfulTheme::default())\n                    .with_prompt(\"Choose your AI provider\")\n                    .items(&providers)\n                    .default(0)\n                    .interact()?;\n\n                match selected_provider {\n                    0 => {\n                        let mut config = Config::load()?;\n                        let api_key = Password::with_theme(&ColorfulTheme::default())\n                            .with_prompt(\"Enter your Gemini API key: \")\n                            .interact()?;\n                        config.active_provider = Some(\"gemini\".to_string());\n                        config.gemini = Some(GeminiConfig { api_key });\n                        config.save()?;\n                        println!(\"{}\", \"Config saved successfully.\".green());\n                    }\n                    1 => {\n                        let mut config = Config::load()?;\n                        let api_key = Password::with_theme(&ColorfulTheme::default())\n                            .with_prompt(\"Enter your Claude API key: \")\n                            .interact()?;\n                        config.active_provider = Some(\"claude\".to_string());\n                        let anthropic_models = vec![\n                            \"    claude-opus-4-20250514\",\n                            \"    claude-sonnet-4-20250514\",\n                            \"    claude-3-7-sonnet-20250219\",\n                            \"    claude-3-5-haiku-20241022\",\n                            \"    claude-3-5-sonnet-20241022\",\n                            \"    Other\",\n                        ];\n                        let selected_model = Select::with_theme(&ColorfulTheme::default())\n                            .with_prompt(\"Choose your Claude model:\")\n                            .items(&anthropic_models)\n                            .default(0)\n                            .interact()?;\n\n                        let model = if selected_model == anthropic_models.len() - 1 {\n                            Input::with_theme(&ColorfulTheme::default())\n                                .with_prompt(\"Enter the custom model name:\")\n                                .interact_text()?\n                        } else {\n                            anthropic_models[selected_model].trim().to_string()\n                        };\n\n                        config.claude = Some(ClaudeConfig { api_key, model });\n                        config.save()?;\n                        println!(\"{}\", \"Config saved successfully.\".green());\n                    }\n                    2 => {\n                        let url = Input::with_theme(&ColorfulTheme::default())\n                            .with_prompt(\"Ollama server url\")\n                            .default(\"http://localhost:11434\".to_string())\n                            .interact_text()?;\n\n                        let model = Input::with_theme(&ColorfulTheme::default())\n                            .with_prompt(\"Ollama model\")\n                            .default(\"gemma3:27b\".to_string())\n                            .interact_text()?;\n\n                        let mut config = Config::load()?;\n                        config.active_provider = Some(\"ollama\".to_string());\n                        config.ollama = Some(OllamaConfig { url, model });\n                        config.save()?;\n                        println!(\"{}\", \"Config saved successfully.\".green());\n                    }\n                    3 => {\n                        let url = Input::with_theme(&ColorfulTheme::default())\n                            .with_prompt(\"Server url\")\n                            .default(\"http://localhost:1234\".to_string())\n                            .interact_text()?;\n\n                        let model = Input::with_theme(&ColorfulTheme::default())\n                            .with_prompt(\"Model\")\n                            .default(\"gemma3:27b\".to_string())\n                            .interact_text()?;\n\n                        let api_key_str = Password::with_theme(&ColorfulTheme::default())\n                            .with_prompt(\"Enter your API key (Optional, press Enter if none): \")\n                            .allow_empty_password(true)\n                            .interact()?;\n\n                        let api_key = if api_key_str.is_empty() {\n                            None\n                        } else {\n                            Some(api_key_str)\n                        };\n\n                        let mut config = Config::load()?;\n                        config.active_provider = Some(\"openai\".to_string());\n                        config.openai = Some(OpenAIConfig {\n                            url,\n                            model,\n                            api_key,\n                        });\n                        config.save()?;\n                        println!(\"{}\", \"Config saved successfully.\".green());\n                    }\n                    _ => unreachable!(),\n                }\n\n                // notion\n                let is_notion = Confirm::with_theme(&ColorfulTheme::default())\n                    .with_prompt(\"Do you want to configure Notion to save your notes there?\")\n                    .interact()?;\n\n                if is_notion {\n                    let api_key = Password::with_theme(&ColorfulTheme::default())\n                        .with_prompt(\"Enter your Notion API key: \")\n                        .interact()?;\n                    let database_id = Password::with_theme(&ColorfulTheme::default())\n                        .with_prompt(\"Enter your Notion Database ID: \")\n                        .interact()?;\n\n                    let spinner = ProgressBar::new_spinner();\n                    spinner.set_style(\n                        ProgressStyle::default_spinner()\n                            .template(\"{spinner:.cyan} {msg}\")\n                            .unwrap(),\n                    );\n                    spinner.set_message(\"Fetching Notion database schema...\");\n                    spinner.enable_steady_tick(std::time::Duration::from_millis(100));\n\n                    let client = NotionClient::new(api_key.clone(), database_id.clone());\n                    let schema_result = client.get_database_schema().await;\n                    spinner.finish_and_clear();\n                    match schema_result {\n                        Ok(schema) => {\n                            let title_property_name = schema\n                                .properties\n                                .values()\n                                .find(|prop| {\n                                    matches!(prop.type_specific_config, PropertyType::Title(_))\n                                })\n                                .map(|prop| prop.name.clone())\n                                .ok_or_else(|| {\n                                    NotedError::ApiError(format!(\n                                        \"{}\",\n                                        \"Database has no title property\".red()\n                                    ))\n                                })?;\n\n                            let properties: Vec<_> = schema\n                                .properties\n                                .into_iter()\n                                .filter(|(_name, property)| match &property.type_specific_config {\n                                    PropertyType::Select { .. }\n                                    | PropertyType::MultiSelect { .. }\n                                    | PropertyType::RichText(_)\n                                    | PropertyType::Number(_)\n                                    | PropertyType::Date(_)\n                                    | PropertyType::Checkbox(_) => true,\n\n                                    _ => false,\n                                })\n                                .collect();\n\n                            let mut default_properties = Vec::new();\n                            if properties.is_empty() {\n                                println!(\n                                    \"{}\",\n                                    \"No user configurable properties found in this database.\"\n                                        .yellow()\n                                );\n                            } else {\n                                println!(\"Enter the default values for the following properties: \");\n                            }\n                            for (name, property) in &properties {\n                                match &property.type_specific_config {\n                                    PropertyType::MultiSelect { multi_select } => {\n                                        let options: Vec<_> = multi_select\n                                            .options\n                                            .iter()\n                                            .map(|option| option.name.clone())\n                                            .collect();\n\n                                        let selections =\n                                            MultiSelect::with_theme(&ColorfulTheme::default())\n                                                .with_prompt(format!(\n                                                    \"Select default options for '{}' (press Space to select and Enter to confirm)\",\n                                                    name\n                                                ))\n                                                .items(&options)\n                                                .interact()?;\n                                        let selected_names: Vec<String> = selections\n                                            .iter()\n                                            .map(|&i| options[i].clone())\n                                            .collect();\n                                        let prop_config = config::NotionPropertyConfig {\n                                            name: name.clone(),\n                                            property_type: \"multi_select\".to_string(),\n                                            default_value: serde_json::json!(selected_names),\n                                        };\n                                        default_properties.push(prop_config);\n                                    }\n                                    PropertyType::Select { select } => {\n                                        let options: Vec<_> = select\n                                            .options\n                                            .iter()\n                                            .map(|option| option.name.clone())\n                                            .collect();\n                                        let selection = Select::with_theme(&ColorfulTheme::default())\n                                                .with_prompt(format!(\"Select default option for '{}' (Select and Enter to confirm)\", name))\n                                                .items(&options)\n                                                .interact()?;\n                                        let selected_name = options[selection].clone();\n                                        let prop_config = config::NotionPropertyConfig {\n                                            name: name.clone(),\n                                            property_type: \"select\".to_string(),\n                                            default_value: serde_json::json!(selected_name),\n                                        };\n                                        default_properties.push(prop_config);\n                                    }\n                                    PropertyType::RichText(_) => {\n                                        let default_value: String =\n                                            Input::with_theme(&ColorfulTheme::default())\n                                                .with_prompt(format!(\"Default text for '{}'\", name))\n                                                .interact_text()?;\n                                        let prop_config = config::NotionPropertyConfig {\n                                            name: name.clone(),\n                                            property_type: \"rich_text\".to_string(),\n                                            default_value: serde_json::json!(default_value),\n                                        };\n                                        default_properties.push(prop_config);\n                                    }\n                                    PropertyType::Checkbox(_) => {\n                                        let checked =\n                                            Confirm::with_theme(&ColorfulTheme::default())\n                                                .with_prompt(format!(\n                                                    \"Should '{}' be checked by default?\",\n                                                    name\n                                                ))\n                                                .interact()?;\n                                        let prop_config = config::NotionPropertyConfig {\n                                            name: name.clone(),\n                                            property_type: \"checkbox\".to_string(),\n                                            default_value: serde_json::json!(checked),\n                                        };\n                                        default_properties.push(prop_config);\n                                    }\n\n                                    PropertyType::Date(_) => {\n                                        let default_value: String =\n                                            Input::with_theme(&ColorfulTheme::default())\n                                                .with_prompt(format!(\n                                                    \"Default date for '{}' (YYYY-MM-DD)\",\n                                                    name\n                                                ))\n                                                .interact_text()?;\n                                        let prop_config = config::NotionPropertyConfig {\n                                            name: name.clone(),\n                                            property_type: \"date\".to_string(),\n                                            default_value: serde_json::json!(default_value),\n                                        };\n                                        default_properties.push(prop_config);\n                                    }\n\n                                    PropertyType::Number(_) => {\n                                        let default_value: f64 =\n                                            Input::with_theme(&ColorfulTheme::default())\n                                                .with_prompt(format!(\n                                                    \"Default number for '{}'\",\n                                                    name\n                                                ))\n                                                .interact()?;\n                                        let prop_config = config::NotionPropertyConfig {\n                                            name: name.clone(),\n                                            property_type: \"number\".to_string(),\n                                            default_value: serde_json::json!(default_value),\n                                        };\n\n                                        default_properties.push(prop_config);\n                                    }\n                                    _ => {\n                                        println!(\n                                            \"{} Property '{}' is not supported for default configuration.\",\n                                            \"✖\".red(),\n                                            name\n                                        );\n                                    }\n                                }\n                            }\n\n                            let mut config = Config::load()?;\n                            config.notion = Some(NotionConfig {\n                                api_key,\n                                database_id,\n                                title_property_name,\n                                properties: default_properties,\n                            });\n                            config.save()?;\n                        }\n                        Err(e) => eprintln!(\"{}\", e),\n                    }\n                }\n                println!(\n                    \"{}\",\n                    \"You can now run 'notedmd convert <file>' to convert your files.\".cyan()\n                );\n            }\n\n            if let Some(ref new_provider) = set_provider {\n                if let Some(config_path) = get_config_path() {\n                    if !config_path.exists() {\n                        return Err(NotedError::ConfigNotFound);\n                    }\n\n                    let mut config = Config::load()?;\n                    let new_provider_str = new_provider.as_str();\n                    let is_configured = match new_provider_str {\n                        \"gemini\" => config.gemini.is_some(),\n                        \"claude\" => config.claude.is_some(),\n                        \"ollama\" => config.ollama.is_some(),\n                        \"openai\" => config.openai.is_some(),\n                        _ => {\n                            eprintln!(\n                                \"Invalid provider '{}'. Please choose from 'gemini', 'claude', or 'ollama'.\",\n                                new_provider\n                            );\n                            return Ok(());\n                        }\n                    };\n\n                    if is_configured {\n                        config.active_provider = Some(new_provider_str.to_string());\n                        config.save()?;\n                        println!(\"Active provider set to '{}'.\", new_provider_str.cyan());\n                    } else {\n                        eprintln!(\n                            \"{} is not configured. Please run 'notedmd config --edit' to set it up.\",\n                            new_provider_str.yellow()\n                        );\n                    }\n                }\n            }\n\n            if !edit\n                && !show\n                && !show_path\n                && set_api_key.is_none()\n                && set_claude_api_key.is_none()\n                && set_provider.is_none()\n            {\n                if let Some(config_path) = get_config_path() {\n                    if config_path.exists() {\n                        let config = Config::load()?;\n                        print_clean_config(config);\n                    } else {\n                        return Err(NotedError::ConfigNotFound);\n                    }\n                }\n            }\n        }\n        Commands::Convert {\n            path,\n            output,\n            api_key,\n            prompt,\n            notion,\n        } => {\n            let config = Config::load()?;\n            let client: Box<dyn AiProvider> = match config.active_provider.as_deref() {\n                Some(\"gemini\") => {\n                    let final_api_key = if let Some(key) = api_key {\n                        key\n                    } else if let Some(gemini_config) = &config.gemini {\n                        gemini_config.api_key.clone()\n                    } else {\n                        return Err(NotedError::GeminiNotConfigured);\n                    };\n                    Box::new(GeminiClient::new(final_api_key, prompt))\n                }\n                Some(\"ollama\") => {\n                    let url = if let Some(ollama_config) = &config.ollama {\n                        ollama_config.url.clone()\n                    } else {\n                        return Err(NotedError::OllamaNotConfigured);\n                    };\n                    let model = if let Some(ollama_config) = &config.ollama {\n                        ollama_config.model.clone()\n                    } else {\n                        return Err(NotedError::OllamaNotConfigured);\n                    };\n                    Box::new(OllamaClient::new(url, model, prompt))\n                }\n                Some(\"claude\") => {\n                    let api_key = if let Some(key) = api_key {\n                        key\n                    } else if let Some(claude_config) = &config.claude {\n                        claude_config.api_key.clone()\n                    } else {\n                        return Err(NotedError::ClaudeNotConfigured);\n                    };\n\n                    let model = if let Some(claude_config) = &config.claude {\n                        claude_config.model.clone()\n                    } else {\n                        return Err(NotedError::ClaudeNotConfigured);\n                    };\n\n                    Box::new(ClaudeClient::new(api_key, model, prompt))\n                }\n                Some(\"openai\") => {\n                    let url = if let Some(openai_config) = &config.openai {\n                        openai_config.url.clone()\n                    } else {\n                        return Err(NotedError::OpenAINotConfigured);\n                    };\n                    let model = if let Some(openai_config) = &config.openai {\n                        openai_config.model.clone()\n                    } else {\n                        return Err(NotedError::OpenAINotConfigured);\n                    };\n                    let api_key = if let Some(openai_config) = &config.openai {\n                        openai_config.api_key.clone()\n                    } else {\n                        return Err(NotedError::OpenAINotConfigured);\n                    };\n                    Box::new(OpenAIClient::new(url, model, api_key, prompt))\n                }\n                _ => return Err(NotedError::NoActiveProvider),\n            };\n\n            let input_path = Path::new(&path);\n            if !input_path.exists() {\n                return Err(NotedError::IoError(std::io::Error::new(\n                    std::io::ErrorKind::NotFound,\n                    format!(\"Input path not found: {}\", path),\n                )));\n            }\n            let (notion_client, notion_config) = if notion {\n                if let Some(config) = &config.notion {\n                    let client =\n                        NotionClient::new(config.api_key.clone(), config.database_id.clone());\n                    (Some(client), Some(config))\n                } else {\n                    return Err(NotedError::NotionNotConfigured);\n                }\n            } else {\n                (None, None)\n            };\n\n            if input_path.is_dir() {\n                let files_to_convert: Vec<_> = std::fs::read_dir(input_path)?\n                    .filter_map(Result::ok)\n                    .filter_map(|entry| {\n                        let path = entry.path();\n                        if path.is_file() {\n                            if let Some(path_str) = path.to_str() {\n                                if file_utils::get_file_mime_type(path_str).is_ok() {\n                                    return Some(path);\n                                }\n                            }\n                        }\n                        None\n                    })\n                    .collect();\n\n                if files_to_convert.is_empty() {\n                    println!(\"No supported files found in the directory.\");\n                    return Ok(());\n                }\n\n                let progress_bar = ProgressBar::new(files_to_convert.len() as u64);\n                progress_bar.set_style(\n                    ProgressStyle::default_bar()\n                        .template(\"{bar:40.cyan/blue} {pos}/{len} {msg}\")\n                        .unwrap(),\n                );\n                progress_bar.set_message(\"Processing files...\");\n\n                for file_path_buf in files_to_convert {\n                    if let Some(file_path_str) = file_path_buf.to_str() {\n                        if let Err(e) = process_and_save_file(\n                            file_path_str,\n                            client.as_ref(),\n                            output.as_deref(),\n                            &progress_bar,\n                            notion_client.as_ref(),\n                            notion_config,\n                        )\n                        .await\n                        {\n                            progress_bar.println(format!(\"{}\", e.to_string().red()));\n                        }\n                    }\n                    progress_bar.inc(1);\n                }\n\n                progress_bar\n                    .finish_with_message(format!(\"{}\", \"Completed processing all files\".green()));\n            } else {\n                let path_str = input_path.to_str().ok_or_else(|| {\n                    NotedError::FileNameError(input_path.to_string_lossy().to_string())\n                })?;\n                file_utils::get_file_mime_type(path_str)?;\n                let progress_bar = ProgressBar::new(1);\n                progress_bar.set_style(\n                    ProgressStyle::default_bar()\n                        .template(\"{bar:40.cyan/blue} {pos}/{len} {msg}\")\n                        .unwrap(),\n                );\n                progress_bar.set_message(\"Processing file...\");\n                if let Err(e) = process_and_save_file(\n                    path_str,\n                    client.as_ref(),\n                    output.as_deref(),\n                    &progress_bar,\n                    notion_client.as_ref(),\n                    notion_config,\n                )\n                .await\n                {\n                    progress_bar.println(format!(\"{}\", e.to_string().red()));\n                }\n                progress_bar.inc(1);\n                progress_bar\n                    .finish_with_message(format!(\"{}\", \"Completed processing file\".green()));\n            }\n        }\n    }\n    Ok(())\n}\n#[tokio::main]\nasync fn main() {\n    if let Err(e) = run().await {\n        eprintln!(\"{} {}\", \"✖\".red(), e.to_string().red());\n        std::process::exit(1);\n    }\n}\n"
  },
  {
    "path": "src/notion/converter.rs",
    "content": "use anyhow::Result;\nuse comrak::{\n    Arena, ComrakOptions,\n    nodes::{AstNode, ListType, NodeValue},\n    parse_document,\n};\nuse notion_client::objects::{\n    block::{\n        Block, BlockType, BulletedListItemValue, EquationValue, HeadingsValue,\n        NumberedListItemValue, ParagraphValue,\n    },\n    rich_text::{self, RichText},\n};\n\npub struct Converter<'a> {\n    _arena: &'a Arena<AstNode<'a>>,\n}\n\nimpl<'a> Converter<'a> {\n    pub fn run(markdown: &str, arena: &'a Arena<AstNode<'a>>) -> Result<Vec<Block>, anyhow::Error> {\n        let mut options = ComrakOptions::default();\n        options.extension.math_dollars = true;\n        let root = parse_document(arena, markdown, &options);\n        let mut converter = Self { _arena: arena };\n        let blocks = converter.render_nodes(root.children())?;\n\n        Ok(blocks)\n    }\n    fn render_nodes(\n        &mut self,\n        nodes: impl Iterator<Item = &'a AstNode<'a>>,\n    ) -> Result<Vec<Block>, anyhow::Error> {\n        let mut blocks = Vec::new();\n        for node in nodes {\n            blocks.extend(self.render_node(node)?);\n        }\n        Ok(blocks)\n    }\n\n    fn render_node(&mut self, node: &'a AstNode<'a>) -> Result<Vec<Block>> {\n        match &node.data.borrow().value {\n            NodeValue::Heading(heading) => Ok(vec![self.render_heading(node, heading)?]),\n            NodeValue::Paragraph => {\n                let mut children = node.children();\n                if let (Some(child), None) = (children.next(), children.next()) {\n                    if let NodeValue::Math(_) = &child.data.borrow().value {\n                        return Ok(vec![self.render_math(child)?]);\n                    }\n                }\n                Ok(vec![self.render_paragraph(node)?])\n            }\n            NodeValue::List(list) => match list.list_type {\n                ListType::Bullet => self.render_bullet_list(node),\n                ListType::Ordered => self.render_numbered_list(node),\n            },\n            _ => Ok(Vec::new()),\n        }\n    }\n\n    fn render_bullet_list(&mut self, node: &'a AstNode<'a>) -> Result<Vec<Block>> {\n        let mut items = Vec::new();\n        for child in node.children() {\n            let block = self.render_bulleted_list_item(child)?;\n            items.push(block);\n        }\n        Ok(items)\n    }\n\n    fn render_numbered_list(&mut self, node: &'a AstNode<'a>) -> Result<Vec<Block>> {\n        let mut items = Vec::new();\n        for child in node.children() {\n            let block = self.render_numbered_list_item(child)?;\n            items.push(block);\n        }\n        Ok(items)\n    }\n\n    fn render_numbered_list_item(&mut self, node: &'a AstNode<'a>) -> Result<Block> {\n        let mut rich_text = Vec::new();\n\n        if let Some(paragraph) = node\n            .children()\n            .find(|child| matches!(child.data.borrow().value, NodeValue::Paragraph))\n        {\n            rich_text = self.render_rich_text(paragraph)?;\n        }\n\n        let value = NumberedListItemValue {\n            rich_text,\n            color: notion_client::objects::block::TextColor::Default,\n            children: None,\n        };\n\n        Ok(Block {\n            block_type: BlockType::NumberedListItem {\n                numbered_list_item: value,\n            },\n            ..Default::default()\n        })\n    }\n\n    fn render_bulleted_list_item(&mut self, node: &'a AstNode<'a>) -> Result<Block> {\n        let mut rich_text = Vec::new();\n\n        if let Some(paragraph) = node\n            .children()\n            .find(|child| matches!(child.data.borrow().value, NodeValue::Paragraph))\n        {\n            rich_text = self.render_rich_text(paragraph)?;\n        }\n\n        let value = BulletedListItemValue {\n            rich_text,\n            color: notion_client::objects::block::TextColor::Default,\n            children: None,\n        };\n\n        Ok(Block {\n            block_type: BlockType::BulletedListItem {\n                bulleted_list_item: value,\n            },\n            ..Default::default()\n        })\n    }\n\n    fn render_math(&mut self, node: &'a AstNode<'a>) -> Result<Block> {\n        if let NodeValue::Math(math) = &node.data.borrow().value {\n            let expression = math.literal.clone();\n            let value = EquationValue { expression };\n            let block_type = BlockType::Equation { equation: value };\n            Ok(Block {\n                block_type,\n                ..Default::default()\n            })\n        } else {\n            Err(anyhow::anyhow!(\n                \"Node passed to render_math was not a Math node\"\n            ))\n        }\n    }\n\n    fn render_paragraph(&mut self, node: &'a AstNode<'a>) -> Result<Block> {\n        let rich_text = self.render_rich_text(node)?;\n        let value = ParagraphValue {\n            rich_text,\n            ..Default::default()\n        };\n        let block_type = BlockType::Paragraph { paragraph: value };\n        Ok(Block {\n            block_type,\n            ..Default::default()\n        })\n    }\n\n    fn render_heading(\n        &mut self,\n        node: &'a AstNode<'a>,\n        heading: &comrak::nodes::NodeHeading,\n    ) -> Result<Block> {\n        let rich_text = self.render_rich_text(node)?;\n\n        let value = HeadingsValue {\n            rich_text,\n            ..Default::default()\n        };\n        let block_type = match &heading.level {\n            1 => BlockType::Heading1 { heading_1: value },\n            2 => BlockType::Heading2 { heading_2: value },\n            _ => BlockType::Heading3 { heading_3: value },\n        };\n\n        Ok(Block {\n            block_type,\n            ..Default::default()\n        })\n    }\n\n    fn render_rich_text(\n        &mut self,\n        node: &'a AstNode<'a>,\n    ) -> Result<Vec<notion_client::objects::rich_text::RichText>> {\n        let mut rich_text_nodes = Vec::new();\n        for child in node.children() {\n            match &child.data.borrow().value {\n                NodeValue::Text(text) => {\n                    rich_text_nodes.push(notion_client::objects::rich_text::RichText::Text {\n                        text: notion_client::objects::rich_text::Text {\n                            content: text.clone(),\n                            link: None,\n                        },\n                        annotations: Default::default(),\n                        plain_text: Some(text.clone()),\n                        href: None,\n                    });\n                }\n                NodeValue::Math(math) => {\n                    let latex = math.literal.clone();\n                    rich_text_nodes.push(RichText::Equation {\n                        equation: rich_text::Equation {\n                            expression: latex.clone(),\n                        },\n                        annotations: Default::default(),\n                        plain_text: latex.to_string(),\n                        href: None,\n                    })\n                }\n                _ => {}\n            }\n        }\n        Ok(rich_text_nodes)\n    }\n}\n"
  },
  {
    "path": "src/notion/mod.rs",
    "content": "pub mod converter;\n"
  },
  {
    "path": "src/ui.rs",
    "content": "use crate::Config;\nuse colored::Colorize;\n\npub fn ascii_art() {\n    println!(\n        \"{}\",\n        r\"\n\n          ███╗   ██╗ ██████╗ ████████╗███████╗██████╗    ███╗   ███╗██████╗\n          ████╗  ██║██╔═══██╗╚══██╔══╝██╔════╝██╔══██╗   ████╗ ████║██╔══██╗\n          ██╔██╗ ██║██║   ██║   ██║   █████╗  ██║  ██║   ██╔████╔██║██║  ██║\n          ██║╚██╗██║██║   ██║   ██║   ██╔══╝  ██║  ██║   ██║╚██╔╝██║██║  ██║\n          ██║ ╚████║╚██████╔╝   ██║   ███████╗██████╔╝██╗██║ ╚═╝ ██║██████╔╝\n          ╚═╝  ╚═══╝ ╚═════╝    ╚═╝   ╚══════╝╚═════╝ ╚═╝╚═╝     ╚═╝╚═════╝\n        \"\n        .bright_blue()\n    );\n    println!(\n        \"{}\",\n        \"-------------------------------------------------\".dimmed()\n    );\n}\n\npub fn print_clean_config(config: Config) {\n    println!(\"{}\", \"noted.md Configuration\".bold());\n    println!(\"-------------------------\");\n\n    if let Some(provider) = config.active_provider {\n        println!(\"Active Provider: {}\", provider.green());\n    } else {\n        println!(\"Active Provider: {}\", \"Not Set\".yellow());\n    }\n\n    println!(\"{}\", \"Gemini\".bold());\n    if let Some(gemini_config) = config.gemini {\n        let api_key = format!(\n            \"{:.3}***************** (hidden for security)\",\n            gemini_config.api_key\n        );\n        println!(\"  API Key: {}\", api_key);\n    } else {\n        println!(\"  (Not Configured)\");\n    }\n\n    println!(\"{}\", \"Claude\".bold());\n    if let Some(claude_config) = config.claude {\n        let api_key = format!(\n            \"{:.3}***************** (hidden for security)\",\n            claude_config.api_key\n        );\n        println!(\"  API Key: {}\", api_key);\n        println!(\"  Model:   {}\", claude_config.model);\n    } else {\n        println!(\"  (Not Configured)\");\n    }\n\n    println!(\"{}\", \"Ollama\".bold());\n    if let Some(ollama_config) = config.ollama {\n        println!(\"  URL:     {}\", ollama_config.url);\n        println!(\"  Model:   {}\", ollama_config.model);\n    } else {\n        println!(\"  (Not Configured)\");\n    }\n\n    println!(\"{}\", \"OpenAI (Compatible)\".bold());\n    if let Some(openai_config) = config.openai {\n        println!(\"  URL:     {}\", openai_config.url);\n        println!(\"  Model:   {}\", openai_config.model);\n        let api_key = if openai_config.api_key.is_none() {\n            \"API key empty.\".to_string()\n        } else {\n            format!(\n                \"{:.3}***************** (hidden for security)\",\n                openai_config.api_key.unwrap()\n            )\n        };\n\n        println!(\"  API Key: {}\", api_key);\n    } else {\n        println!(\"  (Not Configured)\");\n    }\n\n    println!(\"{}\", \"Notion\".bold());\n    if let Some(notion_config) = config.notion {\n        let api_key = format!(\n            \"{:.3}***************** (hidden for security)\",\n            notion_config.api_key\n        );\n        println!(\"  API Key: {}\", api_key);\n        println!(\"  Database ID: {}\", notion_config.database_id);\n        println!(\n            \"  Title Property Name: {}\",\n            notion_config.title_property_name\n        );\n        println!(\"  Database Properties: {:#?}\", notion_config.properties);\n    } else {\n        println!(\"  (Not Configured)\");\n    }\n}\n"
  }
]