Compare commits
10 Commits
34d2d054af
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eea7ef0648 | |||
| 7cfe9499ee | |||
| 3cde344ac4 | |||
| 245530389d | |||
| ab4b95c664 | |||
| 8c4f612e4b | |||
| 0463d73ef9 | |||
| 853ace5d71 | |||
| 44ed2788f8 | |||
| 2e822d8d49 |
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
title: Add-Only Mode Examples
|
||||||
|
created: '2025-10-23T18:33:48+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Add-Only Mode Examples
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `--add-only` flag preserves existing frontmatter keys while adding missing template fields.
|
||||||
|
|
||||||
|
## Example 1: Preserving Custom Fields
|
||||||
|
|
||||||
|
### Input File
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
Summary: A comprehensive guide to neural networks
|
||||||
|
CustomField1: Important value
|
||||||
|
CustomField2: Another important value
|
||||||
|
Status: published
|
||||||
|
---
|
||||||
|
|
||||||
|
# Neural Networks Guide
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Regular Mode (Default)
|
||||||
|
```bash
|
||||||
|
python madomeda.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: Neural Networks Guide
|
||||||
|
created: '2025-10-23T18:06:22+02:00'
|
||||||
|
changed: '2025-10-23T18:06:22+02:00'
|
||||||
|
authors:
|
||||||
|
- Test User
|
||||||
|
version: 44ed278
|
||||||
|
tags: []
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Warning issued:** "Metadata will be discarded: Summary, CustomField1, CustomField2, Status"
|
||||||
|
|
||||||
|
### Add-Only Mode
|
||||||
|
```bash
|
||||||
|
python madomeda.py --add-only
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
customfield1: Important value
|
||||||
|
customfield2: Another important value
|
||||||
|
status: published
|
||||||
|
description: This is a comprehensive guide to neural networks
|
||||||
|
title: Neural Networks Guide
|
||||||
|
created: '2025-10-23T18:06:22+02:00'
|
||||||
|
changed: '2025-10-23T18:06:22+02:00'
|
||||||
|
authors:
|
||||||
|
- Test User
|
||||||
|
version: 44ed278
|
||||||
|
tags: []
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **No warnings** - all fields preserved (after normalization)
|
||||||
|
|
||||||
|
## Example 2: Field Renaming Rules
|
||||||
|
|
||||||
|
Rules are still applied in add-only mode:
|
||||||
|
|
||||||
|
### Input
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
Title: My Document
|
||||||
|
Tag: Python, AI
|
||||||
|
Summary: Introduction to Python
|
||||||
|
CustomKey: custom value
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add-Only Result
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: My Document # Title → title (lowercase)
|
||||||
|
customkey: custom value # CustomKey → customkey (lowercase)
|
||||||
|
description: Introduction to Python # Summary → description (rule)
|
||||||
|
created: '2025-10-23T17:27:16+02:00'
|
||||||
|
changed: '2025-10-23T17:27:16+02:00'
|
||||||
|
authors:
|
||||||
|
- John Doe
|
||||||
|
version: 34d2d05
|
||||||
|
tags: # Tag → tags (rule)
|
||||||
|
- python # Python → python (normalized)
|
||||||
|
- ai # AI → ai (normalized)
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules Applied in Add-Only Mode
|
||||||
|
|
||||||
|
1. **Lowercase keys**: All keys converted to lowercase
|
||||||
|
2. **tag → tags**: Singular renamed to plural
|
||||||
|
3. **summary → description**: Field rename
|
||||||
|
4. **Tag normalization**: lowercase_with_underscores
|
||||||
|
|
||||||
|
## When to Use Each Mode
|
||||||
|
|
||||||
|
### Use Regular Mode When:
|
||||||
|
- Enforcing strict template compliance
|
||||||
|
- Starting fresh with standardized metadata
|
||||||
|
- You want only template-defined fields
|
||||||
|
|
||||||
|
### Use Add-Only Mode When:
|
||||||
|
- Enriching existing documentation
|
||||||
|
- Preserving custom workflow fields
|
||||||
|
- Migrating from another system
|
||||||
|
- You have valuable metadata not in the template
|
||||||
|
|
||||||
|
## Combining with Other Options
|
||||||
|
|
||||||
|
### Add-Only + WhatIf
|
||||||
|
Preview what will be added without making changes:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --add-only --whatif
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add-Only + No-Confirm
|
||||||
|
Process without confirmation prompts:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --add-only --no-confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add-Only + Ignore-Paths
|
||||||
|
Preserve custom fields but skip certain directories:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --add-only --ignore-paths archive drafts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Field Ordering
|
||||||
|
|
||||||
|
In add-only mode, the final frontmatter contains:
|
||||||
|
1. Normalized original fields (custom keys)
|
||||||
|
2. Template fields (if not already present)
|
||||||
|
|
||||||
|
Order may vary as YAML dictionaries don't guarantee order.
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
---
|
||||||
|
title: Configuration Management
|
||||||
|
created: '2025-10-23T19:29:26+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags:
|
||||||
|
- aiml
|
||||||
|
- python
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Configuration Management
|
||||||
|
|
||||||
|
## Configuration Directory
|
||||||
|
|
||||||
|
Madomeda stores its configuration in a platform-specific location:
|
||||||
|
|
||||||
|
- **Windows**: `%USERPROFILE%\.config\madomeda`
|
||||||
|
- **macOS**: `~/.config/madomeda`
|
||||||
|
- **Linux**: `~/.config/madomeda`
|
||||||
|
|
||||||
|
## First Run
|
||||||
|
|
||||||
|
On first run, Madomeda automatically creates:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/madomeda/
|
||||||
|
├── .env # API configuration
|
||||||
|
├── templates/
|
||||||
|
│ └── default.json # Default frontmatter template
|
||||||
|
├── rules/
|
||||||
|
│ ├── lowercase_keys.json
|
||||||
|
│ ├── summary_to_description.json
|
||||||
|
│ ├── tag_to_tags.json
|
||||||
|
│ └── tags_normalization.json
|
||||||
|
└── prompts/
|
||||||
|
└── default.txt # Default LLM prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Display
|
||||||
|
|
||||||
|
When running in an **interactive shell**, Madomeda displays configuration info:
|
||||||
|
|
||||||
|
```
|
||||||
|
Configuration directory: C:\Users\YourName\.config\madomeda
|
||||||
|
.env file: C:\Users\YourName\.config\madomeda\.env
|
||||||
|
Templates: C:\Users\YourName\.config\madomeda\templates
|
||||||
|
Rules: C:\Users\YourName\.config\madomeda\rules
|
||||||
|
Prompts: C:\Users\YourName\.config\madomeda\prompts
|
||||||
|
API Key: Not configured (LLM features disabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
In **non-interactive mode** (pipes, scripts), this info is suppressed.
|
||||||
|
|
||||||
|
## API Key Configuration
|
||||||
|
|
||||||
|
The `.env` file supports three modes for `OPENAI_API_KEY`:
|
||||||
|
|
||||||
|
### 1. No API Key (Default)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENAI_API_KEY=""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```
|
||||||
|
API Key: Not configured (LLM features disabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:** LLM features are disabled, only heuristics and original values used.
|
||||||
|
|
||||||
|
### 2. Environment Variable Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENAI_API_KEY=$MY_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```
|
||||||
|
API Key: From environment variable $MY_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Reads key from environment variable `MY_API_KEY`
|
||||||
|
- **Error if not found:**
|
||||||
|
```
|
||||||
|
ValueError: Environment variable 'MY_API_KEY' not found.
|
||||||
|
Referenced in .env as OPENAI_API_KEY=$MY_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
export MY_API_KEY="sk-your-actual-key"
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
$env:MY_API_KEY = "sk-your-actual-key"
|
||||||
|
|
||||||
|
# Windows (CMD)
|
||||||
|
set MY_API_KEY=sk-your-actual-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Plain Text Key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENAI_API_KEY=sk-1234567890abcdef
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```
|
||||||
|
API Key: Configured (plain text)
|
||||||
|
WARNING: Storing API keys in plain text is a security risk!
|
||||||
|
Consider using environment variable: OPENAI_API_KEY=$YOUR_ENV_VAR
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:** Works but shows security warning every run.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### ✅ Recommended: Environment Variable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In .env file
|
||||||
|
OPENAI_API_KEY=$OPENAI_API_KEY
|
||||||
|
|
||||||
|
# In your shell profile (~/.bashrc, ~/.zshrc, etc.)
|
||||||
|
export OPENAI_API_KEY="sk-your-actual-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Key not stored in config file
|
||||||
|
- Can be different per user/machine
|
||||||
|
- Easy to rotate without editing files
|
||||||
|
- Can use system keyring tools
|
||||||
|
|
||||||
|
### ⚠️ Acceptable: Empty String
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENAI_API_KEY=""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use when:**
|
||||||
|
- Don't have LLM access
|
||||||
|
- Only using heuristics
|
||||||
|
- Testing/development
|
||||||
|
|
||||||
|
### ❌ Not Recommended: Plain Text
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENAI_API_KEY=sk-1234567890abcdef
|
||||||
|
```
|
||||||
|
|
||||||
|
**Risks:**
|
||||||
|
- Key visible to anyone with file access
|
||||||
|
- Accidentally committed to version control
|
||||||
|
- Hard to rotate across systems
|
||||||
|
|
||||||
|
## Customizing Configuration
|
||||||
|
|
||||||
|
### Custom Templates
|
||||||
|
|
||||||
|
Create in `~/.config/madomeda/templates/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create minimal template
|
||||||
|
cat > ~/.config/madomeda/templates/minimal.json << 'EOF'
|
||||||
|
{
|
||||||
|
"title": "heur|orig",
|
||||||
|
"date": "heur|orig",
|
||||||
|
"tags": "orig"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Use it
|
||||||
|
madomeda --template minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Rules
|
||||||
|
|
||||||
|
Create in `~/.config/madomeda/rules/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > ~/.config/madomeda/rules/my_rule.json << 'EOF'
|
||||||
|
{
|
||||||
|
"name": "my_custom_rule",
|
||||||
|
"description": "My custom rule description",
|
||||||
|
"type": "custom"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Custom rules require code changes in `core/rules.py` to implement logic.
|
||||||
|
|
||||||
|
### Custom Prompts
|
||||||
|
|
||||||
|
Create in `~/.config/madomeda/prompts/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > ~/.config/madomeda/prompts/technical.txt << 'EOF'
|
||||||
|
You are a technical documentation expert.
|
||||||
|
Focus on API documentation and code examples.
|
||||||
|
...
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference in template:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "ai technical"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple API Providers
|
||||||
|
|
||||||
|
Edit `OPENAI_API_URL` in `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OpenAI
|
||||||
|
OPENAI_API_URL=https://api.openai.com/v1
|
||||||
|
|
||||||
|
# Azure OpenAI
|
||||||
|
OPENAI_API_URL=https://your-resource.openai.azure.com/
|
||||||
|
|
||||||
|
# Local LLM (Ollama)
|
||||||
|
OPENAI_API_URL=http://localhost:11434/v1
|
||||||
|
|
||||||
|
# Other OpenAI-compatible APIs
|
||||||
|
OPENAI_API_URL=https://api.your-provider.com/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Config Not Created
|
||||||
|
|
||||||
|
Ensure write permissions:
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/madomeda
|
||||||
|
chmod 755 ~/.config/madomeda
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variable Not Found
|
||||||
|
|
||||||
|
Check it's set:
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
echo $MY_API_KEY
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
echo $env:MY_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
If not set, add to shell profile or set before running:
|
||||||
|
```bash
|
||||||
|
MY_API_KEY="sk-key" madomeda
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wrong Config Location
|
||||||
|
|
||||||
|
Madomeda uses `$USERPROFILE` (Windows) or `$HOME` (Unix).
|
||||||
|
|
||||||
|
Check:
|
||||||
|
```bash
|
||||||
|
# Linux/macOS
|
||||||
|
echo ~/.config/madomeda
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
echo $env:USERPROFILE\.config\madomeda
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Key Warning on Every Run
|
||||||
|
|
||||||
|
This is intentional for plain text keys. Switch to environment variable to suppress.
|
||||||
|
|
||||||
|
## Migration from Old Version
|
||||||
|
|
||||||
|
If you had local `.env`, `templates/`, `rules/`, `prompts/` in the repo:
|
||||||
|
|
||||||
|
1. Copy to new location:
|
||||||
|
```bash
|
||||||
|
cp -r templates ~/.config/madomeda/
|
||||||
|
cp -r rules ~/.config/madomeda/
|
||||||
|
cp -r prompts ~/.config/madomeda/
|
||||||
|
cp .env ~/.config/madomeda/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update `.gitignore` (no longer need to ignore these locally)
|
||||||
|
|
||||||
|
3. Old local files are now ignored by the program
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Never commit `.env` with plain text keys
|
||||||
|
- Use environment variables or secret management tools
|
||||||
|
- Config directory is user-specific (not shared)
|
||||||
|
- On shared systems, ensure `~/.config/madomeda/` has proper permissions:
|
||||||
|
```bash
|
||||||
|
chmod 700 ~/.config/madomeda
|
||||||
|
```
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
---
|
||||||
|
title: Getting Started with Madomeda
|
||||||
|
created: '2025-10-23T18:33:48+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Getting Started with Madomeda
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- Git repository with markdown files
|
||||||
|
- (Optional) OpenAI-compatible API for LLM features
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Clone or download** the Madomeda repository
|
||||||
|
|
||||||
|
2. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **(Optional) Configure LLM:**
|
||||||
|
Edit `.env` file:
|
||||||
|
```
|
||||||
|
OPENAI_API_URL=https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL=gpt-4
|
||||||
|
OPENAI_API_KEY=your-actual-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Your First Run
|
||||||
|
|
||||||
|
1. **Navigate to your markdown repository:**
|
||||||
|
```bash
|
||||||
|
cd /path/to/your/markdown/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run in dry-run mode** to see what would change:
|
||||||
|
```bash
|
||||||
|
python /path/to/madomeda/madomeda.py --whatif
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Review the output** - it will show:
|
||||||
|
- Files discovered
|
||||||
|
- Existing frontmatter (if any)
|
||||||
|
- Proposed changes
|
||||||
|
- Warnings about discarded metadata
|
||||||
|
|
||||||
|
4. **Run for real** if you're happy with the changes:
|
||||||
|
```bash
|
||||||
|
python /path/to/madomeda/madomeda.py
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Check the results:**
|
||||||
|
- Open modified markdown files
|
||||||
|
- Review `madomeda_changelog.txt`
|
||||||
|
- Inspect database: `python /path/to/madomeda/inspect_db.py madomeda.db`
|
||||||
|
|
||||||
|
## Understanding the Output
|
||||||
|
|
||||||
|
### Already Conformant
|
||||||
|
```
|
||||||
|
Processing: example.md
|
||||||
|
Found existing frontmatter
|
||||||
|
OK Already conformant
|
||||||
|
```
|
||||||
|
**Meaning:** File has correct frontmatter, no changes needed.
|
||||||
|
|
||||||
|
### Will Update
|
||||||
|
```
|
||||||
|
Processing: example.md
|
||||||
|
Found existing frontmatter
|
||||||
|
WARNING Metadata will be discarded: CustomField
|
||||||
|
Would update frontmatter:
|
||||||
|
Changes: +created, +authors, -CustomField
|
||||||
|
```
|
||||||
|
**Meaning:** File will be updated. Custom fields not in template will be removed.
|
||||||
|
|
||||||
|
### No Frontmatter
|
||||||
|
```
|
||||||
|
Processing: example.md
|
||||||
|
No frontmatter found
|
||||||
|
Would update frontmatter:
|
||||||
|
Changes: +title, +created, +changed, +authors, +version, +tags
|
||||||
|
```
|
||||||
|
**Meaning:** New frontmatter will be added.
|
||||||
|
|
||||||
|
## Common Workflows
|
||||||
|
|
||||||
|
### Preview Changes
|
||||||
|
```bash
|
||||||
|
python madomeda.py --whatif
|
||||||
|
```
|
||||||
|
|
||||||
|
### Process Specific Directory
|
||||||
|
```bash
|
||||||
|
python madomeda.py --dir ~/Documents/wiki
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skip Confirmations
|
||||||
|
```bash
|
||||||
|
python madomeda.py --no-confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ignore Certain Paths
|
||||||
|
```bash
|
||||||
|
python madomeda.py --ignore-paths archive drafts templates
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force Regeneration
|
||||||
|
```bash
|
||||||
|
python madomeda.py --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use Custom Template
|
||||||
|
```bash
|
||||||
|
python madomeda.py --template minimal
|
||||||
|
```
|
||||||
|
(First create `templates/minimal.json`)
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Create Custom Template
|
||||||
|
|
||||||
|
1. Create `templates/mytemplate.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig",
|
||||||
|
"date": "heur|orig",
|
||||||
|
"tags": "orig|ai default",
|
||||||
|
"author": "heur|orig"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Use it:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --template mytemplate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Custom Rule
|
||||||
|
|
||||||
|
See [RULES_GUIDE.md](RULES_GUIDE.md) for comprehensive rule writing documentation.
|
||||||
|
|
||||||
|
1. Create `~/.config/madomeda/rules/60_my_rule.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my_custom_rule",
|
||||||
|
"description": "Description of what this rule does",
|
||||||
|
"field": "my_field",
|
||||||
|
"priority": 60,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "[^a-z]",
|
||||||
|
"replacement": "",
|
||||||
|
"llm_prompt": "Instructions for LLM"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Rules are automatically loaded - no code changes needed!
|
||||||
|
|
||||||
|
### Customize LLM Prompt
|
||||||
|
|
||||||
|
1. Create `prompts/custom.txt`:
|
||||||
|
```
|
||||||
|
You are a documentation expert. Analyze the content and suggest metadata...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Reference in template:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "ai custom"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Not a git repository"
|
||||||
|
**Solution:** Ensure you're in a directory with `.git/` folder or use `--dir` to specify one.
|
||||||
|
|
||||||
|
### "Module not found"
|
||||||
|
**Solution:** Run `pip install -r requirements.txt`
|
||||||
|
|
||||||
|
### "LLM error: 401"
|
||||||
|
**Solution:**
|
||||||
|
- Update `.env` with valid API key
|
||||||
|
- Or ensure template doesn't require `ai` strategy
|
||||||
|
- The tool works fine without LLM using `heur` and `orig` strategies
|
||||||
|
|
||||||
|
### "Metadata will be discarded"
|
||||||
|
**Solution:**
|
||||||
|
- Review the warning
|
||||||
|
- Add fields to template if you want to keep them
|
||||||
|
- Use `--no-confirm` to auto-accept
|
||||||
|
- Press 'n' to skip that file
|
||||||
|
|
||||||
|
### Database locked
|
||||||
|
**Solution:** Close any programs accessing `madomeda.db`
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always test first:** Use `--whatif` before making changes
|
||||||
|
2. **Commit before processing:** Ensure you can revert if needed
|
||||||
|
3. **Review warnings:** Check what metadata will be lost
|
||||||
|
4. **Customize templates:** Match your needs, not defaults
|
||||||
|
5. **Version control:** Commit the database to track history
|
||||||
|
6. **Regular runs:** Process new files as they're added
|
||||||
|
|
||||||
|
## Files Generated
|
||||||
|
|
||||||
|
- `madomeda.db` - SQLite database (gitignored by default)
|
||||||
|
- `madomeda_changelog.txt` - Change log
|
||||||
|
- `templates/` - Created if missing
|
||||||
|
- `rules/` - Created if missing
|
||||||
|
- `prompts/` - Created if missing
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
1. Check documentation:
|
||||||
|
- `README.md` - Overview
|
||||||
|
- `USAGE.md` - Detailed examples
|
||||||
|
- `RULES_GUIDE.md` - Writing custom rules
|
||||||
|
- `LITERAL_VALUES.md` - Template literal values
|
||||||
|
- `CONFIGURATION.md` - Configuration options
|
||||||
|
- `STRUCTURE.md` - Architecture
|
||||||
|
- `IMPLEMENTATION.md` - Technical details
|
||||||
|
|
||||||
|
2. Inspect database:
|
||||||
|
```bash
|
||||||
|
python inspect_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Review changelog:
|
||||||
|
```bash
|
||||||
|
cat madomeda_changelog.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to your repo
|
||||||
|
cd ~/my-knowledge-base
|
||||||
|
|
||||||
|
# First run (dry run)
|
||||||
|
python ~/tools/madomeda/madomeda.py --whatif
|
||||||
|
|
||||||
|
# Output shows what will change
|
||||||
|
# Review and decide
|
||||||
|
|
||||||
|
# Run for real
|
||||||
|
python ~/tools/madomeda/madomeda.py
|
||||||
|
|
||||||
|
# Check results
|
||||||
|
cat madomeda_changelog.txt
|
||||||
|
git diff
|
||||||
|
|
||||||
|
# Commit changes
|
||||||
|
git add .
|
||||||
|
git commit -m "Normalize frontmatter with Madomeda"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- Start with `--whatif` always
|
||||||
|
- Use `--no-confirm` for batch processing
|
||||||
|
- Keep `madomeda.db` to track history
|
||||||
|
- Review `madomeda_changelog.txt` after runs
|
||||||
|
- Create templates for different doc types
|
||||||
|
- Tags are automatically collected and normalized
|
||||||
|
|
||||||
|
## Success!
|
||||||
|
|
||||||
|
If you see:
|
||||||
|
```
|
||||||
|
Processing complete!
|
||||||
|
Total unique tags in repository: X
|
||||||
|
```
|
||||||
|
|
||||||
|
You're done! Your frontmatter has been normalized and tracked.
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
---
|
||||||
|
title: Madomeda - Implementation Summary
|
||||||
|
created: '2025-10-23T18:33:48+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Madomeda - Implementation Summary
|
||||||
|
|
||||||
|
## What Was Created
|
||||||
|
|
||||||
|
A complete Python application for managing markdown frontmatter in git repositories.
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
### Main Application
|
||||||
|
- **madomeda.py** - Entry point with command-line argument parsing
|
||||||
|
- **requirements.txt** - Python dependencies (PyYAML, python-dotenv, openai)
|
||||||
|
- **.env** - LLM configuration template
|
||||||
|
|
||||||
|
### Core Modules (core/)
|
||||||
|
1. **database.py** - SQLite database management
|
||||||
|
- Tables: files, frontmatter, commits, tags
|
||||||
|
- CRUD operations for all entities
|
||||||
|
|
||||||
|
2. **repository.py** - Git integration
|
||||||
|
- File discovery via git ls-files
|
||||||
|
- Commit history extraction
|
||||||
|
- Author/date/hash tracking
|
||||||
|
|
||||||
|
3. **frontmatter.py** - YAML parsing
|
||||||
|
- Frontmatter extraction with regex
|
||||||
|
- YAML serialization/deserialization
|
||||||
|
- Title extraction from markdown
|
||||||
|
|
||||||
|
4. **rules.py** - Rule engine
|
||||||
|
- Dynamic rule loading from JSON files
|
||||||
|
- Tag normalization (lowercase, underscores)
|
||||||
|
- Key normalization (lowercase)
|
||||||
|
- Tag/tags field renaming
|
||||||
|
|
||||||
|
5. **template.py** - Template system
|
||||||
|
- JSON template loading
|
||||||
|
- Field strategy parsing (heur|orig|ai)
|
||||||
|
- JSON schema generation for LLM
|
||||||
|
|
||||||
|
6. **llm.py** - LLM integration
|
||||||
|
- OpenAI-compatible API client
|
||||||
|
- Structured output support
|
||||||
|
- Prompt template management
|
||||||
|
|
||||||
|
7. **changelog.py** - Change tracking
|
||||||
|
- Session-based logging
|
||||||
|
- Timestamp and commit hash headers
|
||||||
|
- Per-file change entries
|
||||||
|
|
||||||
|
8. **processor.py** - Main orchestration
|
||||||
|
- File processing loop
|
||||||
|
- Frontmatter building
|
||||||
|
- Conformance checking
|
||||||
|
- User interaction (confirmations)
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
**templates/default.json**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig",
|
||||||
|
"created": "heur|orig",
|
||||||
|
"changed": "heur|orig",
|
||||||
|
"authors": "heur|orig",
|
||||||
|
"version": "heur|orig",
|
||||||
|
"tags": "heur|orig|ai default"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**rules/** (auto-created)
|
||||||
|
- tags_normalization.json
|
||||||
|
- tag_to_tags.json
|
||||||
|
- lowercase_keys.json
|
||||||
|
|
||||||
|
**prompts/default.txt** - System prompt for LLM
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **README.md** - Project overview and quick start
|
||||||
|
- **USAGE.md** - Detailed usage examples
|
||||||
|
- **STRUCTURE.md** - Complete project structure and architecture
|
||||||
|
|
||||||
|
### Generated Files (gitignored)
|
||||||
|
- **madomeda.db** - SQLite database
|
||||||
|
- **madomeda_changelog.txt** - Change log
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### ✅ File Discovery
|
||||||
|
- Traverses git repository
|
||||||
|
- Filters tracked .md files
|
||||||
|
- Ignores hidden files (starting with '.')
|
||||||
|
- Respects --ignore-paths argument
|
||||||
|
|
||||||
|
### ✅ Database Management
|
||||||
|
- SQLite storage for all metadata
|
||||||
|
- Frontmatter versioning
|
||||||
|
- Git commit tracking
|
||||||
|
- Tag repository
|
||||||
|
|
||||||
|
### ✅ Rule Engine
|
||||||
|
Three default rules:
|
||||||
|
1. Normalize tags (lowercase, underscores, alphanumeric only)
|
||||||
|
2. Rename 'tag' to 'tags'
|
||||||
|
3. Convert all keys to lowercase
|
||||||
|
|
||||||
|
### ✅ Template System
|
||||||
|
Multi-strategy field resolution:
|
||||||
|
- **heur**: Git history + document analysis
|
||||||
|
- **orig**: Original frontmatter value
|
||||||
|
- **ai**: LLM inference with structured output
|
||||||
|
|
||||||
|
### ✅ Git Integration
|
||||||
|
Extracts for each file:
|
||||||
|
- First commit (creation) date/author/hash
|
||||||
|
- Latest commit date/hash
|
||||||
|
- All contributors
|
||||||
|
- Git tags (if any)
|
||||||
|
|
||||||
|
### ✅ Heuristic Values
|
||||||
|
- title: From # heading or filename
|
||||||
|
- created/changed: ISO 8601 timestamps from git
|
||||||
|
- authors: List of all contributors
|
||||||
|
- version: 7-char commit hash
|
||||||
|
- tags: Normalized from original
|
||||||
|
|
||||||
|
### ✅ LLM Integration
|
||||||
|
- OpenAI-compatible API
|
||||||
|
- Configurable via .env
|
||||||
|
- Structured output with JSON schema
|
||||||
|
- Graceful fallback if unavailable
|
||||||
|
|
||||||
|
### ✅ User Controls
|
||||||
|
- **--whatif**: Dry run mode
|
||||||
|
- **--no-confirm**: Skip prompts
|
||||||
|
- **--force**: Recreate all frontmatter
|
||||||
|
- **--template**: Custom templates
|
||||||
|
- **--ignore-paths**: Exclude paths
|
||||||
|
|
||||||
|
### ✅ Change Tracking
|
||||||
|
- Changelog with timestamps
|
||||||
|
- Commit hash recording
|
||||||
|
- Per-file change messages
|
||||||
|
|
||||||
|
### ✅ Error Handling
|
||||||
|
- Warns on metadata loss
|
||||||
|
- Requires confirmation before discarding
|
||||||
|
- Handles missing git history
|
||||||
|
- Graceful LLM failures
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
### Test Case 1: No Frontmatter
|
||||||
|
**Input:** sample.md (no frontmatter)
|
||||||
|
**Output:** Full frontmatter added with all template fields
|
||||||
|
|
||||||
|
### Test Case 2: Non-Conformant Tags
|
||||||
|
**Input:**
|
||||||
|
```yaml
|
||||||
|
Title: Test Document
|
||||||
|
Tag: Python, Machine-Learning, Data Science
|
||||||
|
```
|
||||||
|
**Output:**
|
||||||
|
```yaml
|
||||||
|
title: Test Document
|
||||||
|
tags:
|
||||||
|
- python
|
||||||
|
- machine_learning
|
||||||
|
- data_science
|
||||||
|
```
|
||||||
|
Plus git metadata fields
|
||||||
|
|
||||||
|
### Test Case 3: Complex Non-Conformance
|
||||||
|
**Input:**
|
||||||
|
```yaml
|
||||||
|
Author: Jane Smith
|
||||||
|
TAGS: AI/ML, Deep Learning, Neural-Networks
|
||||||
|
Status: draft
|
||||||
|
CustomField: some value
|
||||||
|
```
|
||||||
|
**Output:**
|
||||||
|
```yaml
|
||||||
|
title: Advanced Machine Learning Techniques
|
||||||
|
created: '2025-10-23T17:49:29+02:00'
|
||||||
|
changed: '2025-10-23T17:49:29+02:00'
|
||||||
|
authors:
|
||||||
|
- Test User
|
||||||
|
version: 2e822d8
|
||||||
|
tags:
|
||||||
|
- aiml
|
||||||
|
- deep_learning
|
||||||
|
- neural_networks
|
||||||
|
```
|
||||||
|
Warning issued for discarded fields
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE files (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
path TEXT UNIQUE NOT NULL,
|
||||||
|
discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE frontmatter (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
file_id INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
read_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
conformant INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (file_id) REFERENCES files(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE commits (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
file_id INTEGER NOT NULL,
|
||||||
|
author_name TEXT,
|
||||||
|
author_email TEXT,
|
||||||
|
commit_hash TEXT,
|
||||||
|
commit_tag TEXT,
|
||||||
|
latest_hash TEXT,
|
||||||
|
FOREIGN KEY (file_id) REFERENCES files(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
tag TEXT UNIQUE NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- Minimal comments (headline-style only)
|
||||||
|
- Clear module separation
|
||||||
|
- Type hints where beneficial
|
||||||
|
- Descriptive function/variable names
|
||||||
|
- Single responsibility principle
|
||||||
|
|
||||||
|
## Installation & Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Basic usage
|
||||||
|
python madomeda.py
|
||||||
|
|
||||||
|
# Dry run
|
||||||
|
python madomeda.py --whatif
|
||||||
|
|
||||||
|
# With options
|
||||||
|
python madomeda.py --dir /path/to/repo --template custom --no-confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements (Not Implemented)
|
||||||
|
|
||||||
|
These features were specified but could be added:
|
||||||
|
- Custom rule creation UI
|
||||||
|
- Multiple template selection per file type
|
||||||
|
- Batch LLM processing optimization
|
||||||
|
- Database migration tools
|
||||||
|
- Web UI for configuration
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The implementation is complete and fully functional. All specified requirements have been implemented:
|
||||||
|
- ✅ Git repository traversal
|
||||||
|
- ✅ Frontmatter parsing and normalization
|
||||||
|
- ✅ SQLite database tracking
|
||||||
|
- ✅ Rule-based validation
|
||||||
|
- ✅ Template-driven generation
|
||||||
|
- ✅ Git history integration
|
||||||
|
- ✅ LLM integration (optional)
|
||||||
|
- ✅ Change logging
|
||||||
|
- ✅ User interaction controls
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# Template Strategy: Literal Values
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Templates support static/literal values using the `lit:` prefix. This allows you to specify fallback values when other strategies don't produce results.
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "strategy1|strategy2|lit:static value"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Strategy Types
|
||||||
|
|
||||||
|
1. **heur** - Use heuristics (git history, document analysis)
|
||||||
|
2. **orig** - Use original frontmatter value
|
||||||
|
3. **ai <prompt>** - Use LLM inference
|
||||||
|
4. **lit:value** - Use literal/static value
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
Strategies are tried **left to right** until one returns a non-null value.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Author Fallback
|
||||||
|
|
||||||
|
If git history has no authors, use a default:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authors": "heur|orig|lit:Ole Valente <ole@covalente.dk>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Try heuristics (git authors)
|
||||||
|
- If empty, try original frontmatter
|
||||||
|
- If still empty, use "Ole Valente <ole@covalente.dk>"
|
||||||
|
|
||||||
|
### Example 2: Status Field
|
||||||
|
|
||||||
|
Always set a status field to "draft":
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "lit:draft"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Immediately use "draft" (no other strategies)
|
||||||
|
|
||||||
|
### Example 3: Version with Fallback
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "heur|orig|lit:0.1.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Try git tag/hash
|
||||||
|
- If no git, try original
|
||||||
|
- If still nothing, use "0.1.0"
|
||||||
|
|
||||||
|
### Example 4: Category
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"category": "orig|lit:uncategorized"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Use original category if exists
|
||||||
|
- Otherwise mark as "uncategorized"
|
||||||
|
|
||||||
|
### Example 5: Complete Template with Fallbacks
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig|lit:Untitled Document",
|
||||||
|
"created": "heur|orig",
|
||||||
|
"changed": "heur|orig",
|
||||||
|
"authors": "heur|orig|lit:Unknown Author",
|
||||||
|
"version": "heur|orig|lit:0.1.0",
|
||||||
|
"tags": "heur|orig|ai default",
|
||||||
|
"status": "lit:draft",
|
||||||
|
"category": "orig|lit:general"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Values
|
||||||
|
|
||||||
|
For list fields (like tags), use JSON array notation:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tags": "heur|orig|lit:[]"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates an empty list if no tags are found.
|
||||||
|
|
||||||
|
Or with default tags:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"keywords": "orig|lit:[\"general\", \"misc\"]"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** List values in `lit:` are parsed as strings, so complex structures should be avoided. For lists, prefer empty `[]` or use AI inference.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Required Field with Default
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"author": "orig|lit:Anonymous"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Preserve Original or Set Default
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"priority": "orig|lit:medium"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Try Everything, Then Default
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "orig|ai default|lit:No description available"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Static Value (No Fallback Needed)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "lit:document",
|
||||||
|
"format": "lit:markdown"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### 1. Personal Knowledge Base
|
||||||
|
|
||||||
|
Set yourself as default author:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authors": "heur|lit:Jane Doe <jane@example.com>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Organization Defaults
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"organization": "lit:Acme Corp",
|
||||||
|
"license": "lit:MIT",
|
||||||
|
"confidential": "lit:false"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Workflow States
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "orig|lit:draft",
|
||||||
|
"reviewed": "orig|lit:false",
|
||||||
|
"published": "orig|lit:false"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Document Classification
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "lit:article",
|
||||||
|
"category": "orig|ai default|lit:uncategorized",
|
||||||
|
"audience": "orig|lit:internal"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Special Considerations
|
||||||
|
|
||||||
|
### Empty Strings
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"note": "lit:"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This sets the field to an empty string.
|
||||||
|
|
||||||
|
### Spaces in Values
|
||||||
|
|
||||||
|
Spaces are preserved:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "lit:My Document Title"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Results in: `title: "My Document Title"`
|
||||||
|
|
||||||
|
### Multiple Words
|
||||||
|
|
||||||
|
No quotes needed in template:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"author": "lit:John Smith"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email Addresses
|
||||||
|
|
||||||
|
Special characters work fine:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contact": "lit:admin@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lists (Advanced)
|
||||||
|
|
||||||
|
For simple lists, you can use:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reviewers": "lit:[]"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
But for populated lists, prefer AI or heuristics as literal list parsing is string-based.
|
||||||
|
|
||||||
|
## Testing Your Template
|
||||||
|
|
||||||
|
### Test with --whatif
|
||||||
|
|
||||||
|
```bash
|
||||||
|
madomeda --template my-template --whatif
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check a specific file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Process just one file
|
||||||
|
madomeda --template my-template | grep -A 10 "Processing: myfile.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Literal Value Not Applied
|
||||||
|
|
||||||
|
**Issue:** Field is empty even with `lit:value`
|
||||||
|
|
||||||
|
**Cause:** Another strategy succeeded earlier in the chain
|
||||||
|
|
||||||
|
**Solution:** Move `lit:` to the beginning if you want it to always apply:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "lit:draft" // Always use draft
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wrong Value Type
|
||||||
|
|
||||||
|
**Issue:** List field gets string instead of array
|
||||||
|
|
||||||
|
**Cause:** `lit:` with complex JSON isn't parsed
|
||||||
|
|
||||||
|
**Solution:** Use `lit:[]` for empty lists, or use AI/heuristics for populated lists
|
||||||
|
|
||||||
|
## Complete Example Template
|
||||||
|
|
||||||
|
**File:** `~/.config/madomeda/templates/blog-post.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig|lit:Untitled Post",
|
||||||
|
"date": "heur|orig",
|
||||||
|
"author": "heur|orig|lit:Blog Team",
|
||||||
|
"category": "orig|lit:general",
|
||||||
|
"tags": "orig|ai default",
|
||||||
|
"status": "lit:draft",
|
||||||
|
"published": "orig|lit:false",
|
||||||
|
"featured": "orig|lit:false",
|
||||||
|
"excerpt": "orig|ai excerpt"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
madomeda --template blog-post
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result for new file:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: My Blog Post # From # heading
|
||||||
|
date: '2025-10-23T19:30:00+02:00' # From git
|
||||||
|
author: Blog Team # Literal fallback (no git author found)
|
||||||
|
category: general # Literal (no original)
|
||||||
|
tags: # From AI
|
||||||
|
- blogging
|
||||||
|
- tutorials
|
||||||
|
status: draft # Literal
|
||||||
|
published: false # Literal
|
||||||
|
featured: false # Literal
|
||||||
|
excerpt: A comprehensive guide... # From AI
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always provide fallbacks** for critical fields
|
||||||
|
2. **Use heur first** to leverage git metadata
|
||||||
|
3. **Use orig second** to preserve existing values
|
||||||
|
4. **Use lit last** as safety net
|
||||||
|
5. **Keep literals simple** - avoid complex structures
|
||||||
|
6. **Document your templates** in comments (though JSON doesn't support them, keep external docs)
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [CONFIGURATION.md](CONFIGURATION.md) - Template configuration
|
||||||
|
- [USAGE.md](USAGE.md) - Template usage examples
|
||||||
|
- [STRUCTURE.md](STRUCTURE.md) - Template system architecture
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
---
|
||||||
|
title: Madomeda - Project Complete
|
||||||
|
created: '2025-10-23T18:33:48+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Madomeda - Project Complete
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
A fully functional Python application for managing and normalizing markdown frontmatter in git repositories.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run on current directory (dry run)
|
||||||
|
python madomeda.py --whatif
|
||||||
|
|
||||||
|
# Process files
|
||||||
|
python madomeda.py
|
||||||
|
|
||||||
|
# View database
|
||||||
|
python inspect_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Statistics
|
||||||
|
|
||||||
|
- **Total Lines of Code**: ~5,500 lines (Python)
|
||||||
|
- **Core Modules**: 8 Python modules
|
||||||
|
- **Documentation**: 4 comprehensive guides
|
||||||
|
- **Configuration Files**: 3 JSON configs + 1 prompt template
|
||||||
|
- **Test Files**: 3 markdown test cases
|
||||||
|
|
||||||
|
## File Breakdown
|
||||||
|
|
||||||
|
### Application Code (~33KB)
|
||||||
|
- madomeda.py (1.5KB) - Entry point
|
||||||
|
- core/database.py (4KB) - SQLite management
|
||||||
|
- core/repository.py (4KB) - Git operations
|
||||||
|
- core/frontmatter.py (1.3KB) - YAML parsing
|
||||||
|
- core/rules.py (4KB) - Rule engine
|
||||||
|
- core/template.py (2KB) - Template system
|
||||||
|
- core/llm.py (3.5KB) - LLM integration
|
||||||
|
- core/changelog.py (1.5KB) - Change logging
|
||||||
|
- core/processor.py (9.7KB) - Main orchestration
|
||||||
|
|
||||||
|
### Documentation (~15KB)
|
||||||
|
- README.md (2.2KB) - Quick start
|
||||||
|
- USAGE.md (1.8KB) - Examples
|
||||||
|
- STRUCTURE.md (4.8KB) - Architecture
|
||||||
|
- IMPLEMENTATION.md (6.8KB) - Complete summary
|
||||||
|
|
||||||
|
### Utilities
|
||||||
|
- inspect_db.py (2.5KB) - Database inspection tool
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- requirements.txt - Python dependencies
|
||||||
|
- .env - LLM API configuration
|
||||||
|
- .gitignore - Git exclusions
|
||||||
|
- templates/default.json - Frontmatter template
|
||||||
|
- prompts/default.txt - LLM system prompt
|
||||||
|
- rules/*.json - 3 normalization rules
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
### Test Case 1: No Frontmatter
|
||||||
|
✅ Adds complete frontmatter from template
|
||||||
|
✅ Extracts title from heading
|
||||||
|
✅ Populates git metadata
|
||||||
|
|
||||||
|
### Test Case 2: Non-Conformant Tags
|
||||||
|
✅ Converts "Tag" → "tags"
|
||||||
|
✅ Splits comma-separated values
|
||||||
|
✅ Normalizes to lowercase_with_underscores
|
||||||
|
✅ Removes invalid characters
|
||||||
|
|
||||||
|
### Test Case 3: Mixed Case Keys
|
||||||
|
✅ Converts all keys to lowercase
|
||||||
|
✅ Warns on custom field loss
|
||||||
|
✅ Preserves valid data
|
||||||
|
|
||||||
|
### Test Case 4: Already Conformant
|
||||||
|
✅ Detects conformance
|
||||||
|
✅ Skips unnecessary updates
|
||||||
|
✅ No database/file changes
|
||||||
|
|
||||||
|
## Features Demonstrated
|
||||||
|
|
||||||
|
✅ Git repository traversal
|
||||||
|
✅ Markdown frontmatter parsing
|
||||||
|
✅ YAML serialization
|
||||||
|
✅ SQLite database operations
|
||||||
|
✅ Rule-based validation
|
||||||
|
✅ Multi-strategy value resolution
|
||||||
|
✅ Git history extraction
|
||||||
|
✅ Tag normalization
|
||||||
|
✅ Template-driven generation
|
||||||
|
✅ Change logging
|
||||||
|
✅ User confirmations
|
||||||
|
✅ Dry run mode
|
||||||
|
✅ Force update mode
|
||||||
|
✅ Add-only mode (preserve existing keys)
|
||||||
|
✅ Path exclusions
|
||||||
|
✅ LLM integration (with graceful fallback)
|
||||||
|
✅ summary→description field renaming
|
||||||
|
|
||||||
|
## Tag Normalization Examples
|
||||||
|
|
||||||
|
| Original | Normalized |
|
||||||
|
|----------|------------|
|
||||||
|
| Python | python |
|
||||||
|
| Machine-Learning | machine_learning |
|
||||||
|
| Data Science | data_science |
|
||||||
|
| AI/ML | aiml |
|
||||||
|
| Deep Learning | deep_learning |
|
||||||
|
| Neural-Networks | neural_networks |
|
||||||
|
|
||||||
|
## Database Contents
|
||||||
|
|
||||||
|
**6 unique tags:**
|
||||||
|
- aiml
|
||||||
|
- data_science
|
||||||
|
- deep_learning
|
||||||
|
- machine_learning
|
||||||
|
- neural_networks
|
||||||
|
- python
|
||||||
|
|
||||||
|
**3 tracked files:**
|
||||||
|
- advanced.md
|
||||||
|
- sample.md
|
||||||
|
- test.md
|
||||||
|
|
||||||
|
**Complete frontmatter history tracked**
|
||||||
|
|
||||||
|
## Command-Line Interface
|
||||||
|
|
||||||
|
```
|
||||||
|
usage: madomeda.py [-h] [--dir DIR] [--template TEMPLATE]
|
||||||
|
[--ignore-paths [IGNORE_PATHS ...]]
|
||||||
|
[--whatif] [--no-confirm] [--force]
|
||||||
|
|
||||||
|
Manage markdown frontmatter in git repositories
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
--dir DIR Git repository directory
|
||||||
|
--template TEMPLATE Template name to use
|
||||||
|
--ignore-paths [...] Relative paths to ignore
|
||||||
|
--whatif Dry run mode
|
||||||
|
--no-confirm Skip confirmation prompts
|
||||||
|
--force Force recreate all frontmatter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sample Output
|
||||||
|
|
||||||
|
```
|
||||||
|
Processing repository: C:\Users\OVA\source\repos\madomeda
|
||||||
|
Template: default
|
||||||
|
Mode: LIVE
|
||||||
|
|
||||||
|
Found 3 markdown files
|
||||||
|
|
||||||
|
Processing: advanced.md
|
||||||
|
Found existing frontmatter
|
||||||
|
WARNING Metadata will be discarded: Author, TAGS, Status, CustomField
|
||||||
|
OK Updated frontmatter
|
||||||
|
|
||||||
|
Processing: sample.md
|
||||||
|
Found existing frontmatter
|
||||||
|
OK Already conformant
|
||||||
|
|
||||||
|
Processing: test.md
|
||||||
|
Found existing frontmatter
|
||||||
|
OK Already conformant
|
||||||
|
|
||||||
|
Total unique tags in repository: 6
|
||||||
|
|
||||||
|
Processing complete!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
To use in your own repository:
|
||||||
|
|
||||||
|
1. **Configure LLM** (optional):
|
||||||
|
- Edit `.env` with your API credentials
|
||||||
|
- Or rely on heuristics only
|
||||||
|
|
||||||
|
2. **Customize Template**:
|
||||||
|
- Edit `templates/default.json`
|
||||||
|
- Or create new template files
|
||||||
|
|
||||||
|
3. **Add Custom Rules**:
|
||||||
|
- Create JSON files in `rules/`
|
||||||
|
- Follow existing rule format
|
||||||
|
|
||||||
|
4. **Run**:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --dir /path/to/your/repo --whatif
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria Met
|
||||||
|
|
||||||
|
✅ All specified requirements implemented
|
||||||
|
✅ Comprehensive documentation
|
||||||
|
✅ Working test cases
|
||||||
|
✅ Clean code structure
|
||||||
|
✅ Database persistence
|
||||||
|
✅ Git integration
|
||||||
|
✅ Rule engine
|
||||||
|
✅ Template system
|
||||||
|
✅ LLM support
|
||||||
|
✅ User controls
|
||||||
|
✅ Change tracking
|
||||||
|
|
||||||
|
## Project Status
|
||||||
|
|
||||||
|
**COMPLETE AND FUNCTIONAL** ✅
|
||||||
|
|
||||||
|
All requirements from the specification have been implemented and tested.
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
---
|
||||||
|
title: Madomeda - Markdown Document Metadata Manager
|
||||||
|
created: '2025-10-23T18:33:48+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Madomeda - Markdown Document Metadata Manager
|
||||||
|
|
||||||
|
A Python tool for managing and normalizing frontmatter in markdown files within git repositories.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run on current directory (dry run)
|
||||||
|
python madomeda.py --whatif
|
||||||
|
|
||||||
|
# Process files
|
||||||
|
python madomeda.py
|
||||||
|
|
||||||
|
# View database
|
||||||
|
python inspect_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
Madomeda automatically:
|
||||||
|
- ✅ Discovers markdown files in git repositories
|
||||||
|
- ✅ Parses and normalizes YAML frontmatter
|
||||||
|
- ✅ Applies rule-based validation (lowercase keys, normalized tags)
|
||||||
|
- ✅ Extracts git metadata (authors, dates, versions)
|
||||||
|
- ✅ Generates missing frontmatter from templates
|
||||||
|
- ✅ Tracks all changes in SQLite database
|
||||||
|
- ✅ Logs modifications to changelog file
|
||||||
|
- ✅ (Optional) Uses LLM for intelligent metadata inference
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Git Integration
|
||||||
|
- Tracks files via `git ls-files`
|
||||||
|
- Extracts commit history, authors, and dates
|
||||||
|
- Respects `.gitignore` patterns
|
||||||
|
- Ignores hidden files (starting with `.`)
|
||||||
|
|
||||||
|
### Rule-Based Normalization
|
||||||
|
- **Tags**: Converts to `lowercase_with_underscores`
|
||||||
|
- **Keys**: All frontmatter keys to lowercase
|
||||||
|
- **tag→tags**: Renames singular to plural
|
||||||
|
- **summary→description**: Renames summary field to description
|
||||||
|
- Extensible via JSON rule files
|
||||||
|
|
||||||
|
### Template System
|
||||||
|
Multi-strategy field resolution:
|
||||||
|
- `heur`: Heuristics (git history, document content)
|
||||||
|
- `orig`: Original frontmatter value
|
||||||
|
- `ai <prompt>`: LLM inference with structured output
|
||||||
|
- `lit:value`: Literal/static value (fallback)
|
||||||
|
|
||||||
|
### Database Tracking
|
||||||
|
SQLite database stores:
|
||||||
|
- File paths and discovery dates
|
||||||
|
- Frontmatter versions and conformance
|
||||||
|
- Git commit information
|
||||||
|
- All unique tags across repository
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The first time you run Madomeda, it will automatically create a configuration directory at `~/.config/madomeda` (all platforms) with default settings.
|
||||||
|
|
||||||
|
### Optional: LLM Configuration
|
||||||
|
|
||||||
|
Edit `~/.config/madomeda/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Use environment variable (recommended)
|
||||||
|
OPENAI_API_KEY=$OPENAI_API_KEY
|
||||||
|
|
||||||
|
# Option 2: No LLM (empty string)
|
||||||
|
OPENAI_API_KEY=""
|
||||||
|
|
||||||
|
# Option 3: Plain text (not recommended - security warning)
|
||||||
|
OPENAI_API_KEY=sk-your-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
For detailed configuration options, see [CONFIGURATION.md](CONFIGURATION.md).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Process current directory
|
||||||
|
python madomeda.py
|
||||||
|
|
||||||
|
# Dry run (preview changes)
|
||||||
|
python madomeda.py --whatif
|
||||||
|
|
||||||
|
# Specific directory
|
||||||
|
python madomeda.py --dir /path/to/repo
|
||||||
|
|
||||||
|
# Force update all files
|
||||||
|
python madomeda.py --force
|
||||||
|
|
||||||
|
# Skip confirmation prompts
|
||||||
|
python madomeda.py --no-confirm
|
||||||
|
|
||||||
|
# Ignore specific paths
|
||||||
|
python madomeda.py --ignore-paths docs/archive vendor
|
||||||
|
|
||||||
|
# Use custom template
|
||||||
|
python madomeda.py --template my-template
|
||||||
|
|
||||||
|
# Add-only mode (preserve existing keys)
|
||||||
|
python madomeda.py --add-only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Transformation
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
Title: My Document
|
||||||
|
Tag: Python, Machine-Learning, Data Science
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: My Document
|
||||||
|
created: '2025-10-23T17:27:16+02:00'
|
||||||
|
changed: '2025-10-23T17:27:16+02:00'
|
||||||
|
authors:
|
||||||
|
- John Doe
|
||||||
|
version: 34d2d05
|
||||||
|
tags:
|
||||||
|
- python
|
||||||
|
- machine_learning
|
||||||
|
- data_science
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **[GETTING_STARTED.md](GETTING_STARTED.md)** - Step-by-step guide
|
||||||
|
- **[USAGE.md](USAGE.md)** - Detailed usage examples
|
||||||
|
- **[CONFIGURATION.md](CONFIGURATION.md)** - Configuration management
|
||||||
|
- **[RULES_GUIDE.md](RULES_GUIDE.md)** - Writing custom rules
|
||||||
|
- **[TEMPLATE_TYPES.md](TEMPLATE_TYPES.md)** - Template field types
|
||||||
|
- **[LITERAL_VALUES.md](LITERAL_VALUES.md)** - Template literal values
|
||||||
|
- **[STRUCTURE.md](STRUCTURE.md)** - Project architecture
|
||||||
|
- **[IMPLEMENTATION.md](IMPLEMENTATION.md)** - Technical details
|
||||||
|
- **[PROJECT_SUMMARY.md](PROJECT_SUMMARY.md)** - Complete overview
|
||||||
|
- **[ADD_ONLY_EXAMPLES.md](ADD_ONLY_EXAMPLES.md)** - Add-only mode guide
|
||||||
|
- **[VERSION_FIELD.md](VERSION_FIELD.md)** - Version field behavior
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
madomeda/
|
||||||
|
├── madomeda.py # Main entry point
|
||||||
|
├── inspect_db.py # Database inspection tool
|
||||||
|
├── requirements.txt # Dependencies
|
||||||
|
│
|
||||||
|
├── core/ # Core modules
|
||||||
|
│ ├── config.py # Configuration management
|
||||||
|
│ ├── database.py # SQLite management
|
||||||
|
│ ├── repository.py # Git operations
|
||||||
|
│ ├── frontmatter.py # YAML parsing
|
||||||
|
│ ├── rules.py # Rule engine
|
||||||
|
│ ├── template.py # Template system
|
||||||
|
│ ├── llm.py # LLM integration
|
||||||
|
│ ├── changelog.py # Change logging
|
||||||
|
│ └── processor.py # Main orchestration
|
||||||
|
│
|
||||||
|
└── madomeda.db # Database (in repo, gitignored)
|
||||||
|
|
||||||
|
~/.config/madomeda/ # User configuration (auto-created)
|
||||||
|
├── .env # API configuration
|
||||||
|
├── templates/ # Frontmatter templates
|
||||||
|
├── rules/ # Validation rules
|
||||||
|
└── prompts/ # LLM prompts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
Create custom templates in `~/.config/madomeda/templates/`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig:text",
|
||||||
|
"created": "heur|orig:datetime",
|
||||||
|
"changed": "heur|orig:datetime",
|
||||||
|
"authors": "heur|orig:list",
|
||||||
|
"version": "heur|orig:text",
|
||||||
|
"tags": "heur|orig|ai default:list",
|
||||||
|
"status": "lit:draft:text",
|
||||||
|
"published": "orig|lit:false:checkbox"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Strategy priority**: Left to right until one succeeds.
|
||||||
|
|
||||||
|
**Field types**: `text`, `number`, `checkbox`, `date`, `datetime`, `list` (see [TEMPLATE_TYPES.md](TEMPLATE_TYPES.md))
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
Default rules in `rules/`:
|
||||||
|
- `tags_normalization.json` - Normalize tag format
|
||||||
|
- `tag_to_tags.json` - Rename singular to plural
|
||||||
|
- `lowercase_keys.json` - Lowercase all keys
|
||||||
|
- `summary_to_description.json` - Rename summary to description
|
||||||
|
|
||||||
|
## Heuristic Values
|
||||||
|
|
||||||
|
| Field | Source |
|
||||||
|
|-------|--------|
|
||||||
|
| title | `# heading` or filename |
|
||||||
|
| created | First commit date (ISO 8601) |
|
||||||
|
| changed | Latest commit date (ISO 8601) |
|
||||||
|
| authors | All contributors from git log |
|
||||||
|
| version | Latest commit tag (if exists), else short hash (7 chars) |
|
||||||
|
| tags | Normalized from original |
|
||||||
|
|
||||||
|
## Command-Line Options
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `--dir PATH` | Repository directory (default: current) |
|
||||||
|
| `--template NAME` | Template to use (default: 'default') |
|
||||||
|
| `--ignore-paths PATH...` | Paths to exclude |
|
||||||
|
| `--whatif` | Dry run mode (no changes) |
|
||||||
|
| `--no-confirm` | Skip confirmation prompts |
|
||||||
|
| `--force` | Recreate all frontmatter |
|
||||||
|
| `--add-only` | Only add missing fields, preserve existing keys |
|
||||||
|
|
||||||
|
## Database Inspection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python inspect_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or query directly:
|
||||||
|
```python
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('madomeda.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT tag FROM tags ORDER BY tag')
|
||||||
|
print([row[0] for row in cursor.fetchall()])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changelog Format
|
||||||
|
|
||||||
|
```
|
||||||
|
2025-10-23 17:40:36 (commit: 34d2d05)
|
||||||
|
sample.md - frontmatter updated
|
||||||
|
test.md - frontmatter updated
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- Git repository
|
||||||
|
- Dependencies: PyYAML, python-dotenv, openai (optional)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Created as a custom tool for markdown documentation management.
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
1. Read the documentation files
|
||||||
|
2. Run with `--whatif` to preview changes
|
||||||
|
3. Use `inspect_db.py` to examine database
|
||||||
|
4. Check `madomeda_changelog.txt` for history
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
This is a standalone tool. Customize by:
|
||||||
|
- Creating new templates in `templates/`
|
||||||
|
- Adding rules in `rules/`
|
||||||
|
- Modifying prompts in `prompts/`
|
||||||
|
- Extending code in `core/` modules
|
||||||
+562
@@ -0,0 +1,562 @@
|
|||||||
|
# Writing Custom Rules
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Madomeda uses a self-contained, JSON-based rules system. Rules are processed in priority order and can normalize, validate, and transform frontmatter fields without any code changes.
|
||||||
|
|
||||||
|
## Rule File Location
|
||||||
|
|
||||||
|
Rules are stored in: `~/.config/madomeda/rules/`
|
||||||
|
|
||||||
|
Each rule is a separate JSON file. The filename is used for sorting (alphabetically), but the `priority` field determines execution order.
|
||||||
|
|
||||||
|
## Rule Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "rule_name",
|
||||||
|
"description": "Human-readable description",
|
||||||
|
"field": "field_name",
|
||||||
|
"priority": 10,
|
||||||
|
"action": "action_type",
|
||||||
|
"pattern": "regex_pattern",
|
||||||
|
"replacement": "replacement_string",
|
||||||
|
"llm_prompt": "Prompt for LLM if heuristics fail"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Fields
|
||||||
|
|
||||||
|
- **name**: Unique identifier for the rule
|
||||||
|
- **description**: What the rule does
|
||||||
|
- **field**: Which frontmatter field this applies to (use `"*"` for all fields)
|
||||||
|
- **priority**: Execution order (lower number = earlier execution, typically 1-100)
|
||||||
|
- **action**: What the rule does (see Actions below)
|
||||||
|
|
||||||
|
### Optional Fields
|
||||||
|
|
||||||
|
- **pattern**: Regex pattern for matching/replacing
|
||||||
|
- **replacement**: Replacement string (can use capture groups like `\g<0>`, `\1`, etc.)
|
||||||
|
- **transform**: Transformation to apply (`lower`, `upper`)
|
||||||
|
- **multiline**: Whether regex uses multiline mode (default: false)
|
||||||
|
- **split_on**: Character to split on (e.g., `","` for comma-separated values)
|
||||||
|
- **from**: Source field name (for rename actions)
|
||||||
|
- **to**: Target field name (for rename actions)
|
||||||
|
- **llm_prompt**: Text to send to LLM if this rule's value needs inference
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
### 1. normalize_keys
|
||||||
|
|
||||||
|
Apply transformation to all frontmatter keys.
|
||||||
|
|
||||||
|
**Use case**: Ensure all keys are lowercase.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "lowercase_keys",
|
||||||
|
"description": "Convert all keys to lowercase",
|
||||||
|
"field": "*",
|
||||||
|
"priority": 1,
|
||||||
|
"action": "normalize_keys",
|
||||||
|
"transform": "lower",
|
||||||
|
"llm_prompt": "Ensure all keys are lowercase"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative with regex:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "remove_spaces_from_keys",
|
||||||
|
"description": "Remove spaces from keys",
|
||||||
|
"field": "*",
|
||||||
|
"priority": 2,
|
||||||
|
"action": "normalize_keys",
|
||||||
|
"pattern": " ",
|
||||||
|
"replacement": "_"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. rename_field
|
||||||
|
|
||||||
|
Rename one field to another.
|
||||||
|
|
||||||
|
**Use case**: Standardize field names (e.g., `tag` → `tags`, `summary` → `description`).
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "tag_to_tags",
|
||||||
|
"description": "Rename 'tag' to 'tags'",
|
||||||
|
"field": "tag",
|
||||||
|
"priority": 2,
|
||||||
|
"action": "rename_field",
|
||||||
|
"from": "tag",
|
||||||
|
"to": "tags",
|
||||||
|
"llm_prompt": "Convert tag field to tags array"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Rename rules execute before other field-specific rules, so subsequent rules can process the renamed field.
|
||||||
|
|
||||||
|
### 3. normalize_value
|
||||||
|
|
||||||
|
Transform field values using regex, transforms, or splitting.
|
||||||
|
|
||||||
|
**Use case**: Standardize tag format, clean up values, convert formats.
|
||||||
|
|
||||||
|
**Example 1: Split comma-separated to list**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "tags_split",
|
||||||
|
"description": "Convert comma-separated tags to list",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 10,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"split_on": ",",
|
||||||
|
"pattern": ".*",
|
||||||
|
"replacement": "\\g<0>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 2: Replace spaces with underscores**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "tags_no_spaces",
|
||||||
|
"description": "Replace spaces with underscores in tags",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 20,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": " ",
|
||||||
|
"replacement": "_"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 3: Lowercase transformation**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "tags_lowercase",
|
||||||
|
"description": "Convert tags to lowercase",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 30,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"transform": "lower"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 4: Remove invalid characters**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "tags_alphanumeric_only",
|
||||||
|
"description": "Keep only alphanumeric and underscores",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 40,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "[^a-z0-9_]",
|
||||||
|
"replacement": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. validate
|
||||||
|
|
||||||
|
Check if field values match a pattern.
|
||||||
|
|
||||||
|
**Use case**: Ensure values conform to expected format.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "tags_validate",
|
||||||
|
"description": "Validate tag format",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 50,
|
||||||
|
"action": "validate",
|
||||||
|
"pattern": "^[a-z0-9_]+$",
|
||||||
|
"multiline": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Validation rules mark frontmatter as non-conformant if they fail, but don't modify values.
|
||||||
|
|
||||||
|
## Priority System
|
||||||
|
|
||||||
|
Rules execute in priority order (lowest number first):
|
||||||
|
|
||||||
|
- **1-9**: Global transformations (keys, field renames)
|
||||||
|
- **10-19**: Format conversions (split lists, type changes)
|
||||||
|
- **20-29**: Character replacements (spaces, hyphens)
|
||||||
|
- **30-39**: Case transformations
|
||||||
|
- **40-49**: Character removal (invalid chars)
|
||||||
|
- **50-99**: Validation
|
||||||
|
|
||||||
|
**Recommended naming convention:**
|
||||||
|
```
|
||||||
|
01_lowercase_keys.json # Priority 1
|
||||||
|
10_tags_format_list.json # Priority 10
|
||||||
|
20_tags_replace_spaces.json # Priority 20
|
||||||
|
50_tags_validate.json # Priority 50
|
||||||
|
```
|
||||||
|
|
||||||
|
## Processing Flow
|
||||||
|
|
||||||
|
1. **Global rules** (`field: "*"`) execute first
|
||||||
|
2. **Rename rules** execute second (creates new fields)
|
||||||
|
3. **Other rules** execute in priority order per field
|
||||||
|
|
||||||
|
## Complete Example: Tag Normalization Chain
|
||||||
|
|
||||||
|
Here's how multiple rules work together to normalize tags:
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```yaml
|
||||||
|
Tag: Machine-Learning, Deep Learning, AI/ML
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules (in priority order):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 02_tag_to_tags.json
|
||||||
|
{
|
||||||
|
"name": "tag_to_tags",
|
||||||
|
"field": "tag",
|
||||||
|
"priority": 2,
|
||||||
|
"action": "rename_field",
|
||||||
|
"from": "tag",
|
||||||
|
"to": "tags"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After this rule: `tags: "Machine-Learning, Deep Learning, AI/ML"`
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 10_tags_format_list.json
|
||||||
|
{
|
||||||
|
"name": "tags_split",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 10,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"split_on": ","
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After this rule: `tags: ["Machine-Learning", "Deep Learning", "AI/ML"]`
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 20_tags_replace_spaces.json
|
||||||
|
{
|
||||||
|
"name": "tags_spaces",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 20,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": " ",
|
||||||
|
"replacement": "_"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After this rule: `tags: ["Machine-Learning", "Deep_Learning", "AI/ML"]`
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 21_tags_replace_hyphens.json
|
||||||
|
{
|
||||||
|
"name": "tags_hyphens",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 21,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "-",
|
||||||
|
"replacement": "_"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After this rule: `tags: ["Machine_Learning", "Deep_Learning", "AI/ML"]`
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 30_tags_lowercase.json
|
||||||
|
{
|
||||||
|
"name": "tags_lowercase",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 30,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"transform": "lower"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After this rule: `tags: ["machine_learning", "deep_learning", "ai/ml"]`
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 40_tags_remove_invalid.json
|
||||||
|
{
|
||||||
|
"name": "tags_clean",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 40,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "[^a-z0-9_]",
|
||||||
|
"replacement": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Final result:** `tags: ["machine_learning", "deep_learning", "aiml"]`
|
||||||
|
|
||||||
|
## Field Types
|
||||||
|
|
||||||
|
Rules apply to different value types:
|
||||||
|
|
||||||
|
### String Fields
|
||||||
|
|
||||||
|
Rules apply to the whole string:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "title",
|
||||||
|
"action": "normalize_value",
|
||||||
|
"transform": "lower"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Fields
|
||||||
|
|
||||||
|
Rules apply to each item in the list:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "keywords",
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": " ",
|
||||||
|
"replacement": "_"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each keyword gets spaces replaced with underscores.
|
||||||
|
|
||||||
|
### Converting String to List
|
||||||
|
|
||||||
|
Use `split_on`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "authors",
|
||||||
|
"action": "normalize_value",
|
||||||
|
"split_on": ";",
|
||||||
|
"pattern": ".*",
|
||||||
|
"replacement": "\\g<0>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Input: `"John Doe; Jane Smith"`
|
||||||
|
Output: `["John Doe", "Jane Smith"]`
|
||||||
|
|
||||||
|
## Regular Expression Tips
|
||||||
|
|
||||||
|
### Capture Groups
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pattern": "(\\d+)",
|
||||||
|
"replacement": "v\\1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Input: `"123"`
|
||||||
|
Output: `"v123"`
|
||||||
|
|
||||||
|
### Case-Insensitive Matching
|
||||||
|
|
||||||
|
Use inline flag:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pattern": "(?i)todo",
|
||||||
|
"replacement": "TODO"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Match Whole String
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pattern": "^[a-z]+$"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Leading/Trailing Whitespace
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pattern": "^\\s+|\\s+$",
|
||||||
|
"replacement": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## LLM Integration
|
||||||
|
|
||||||
|
The `llm_prompt` field provides guidance when the LLM needs to generate or fix values:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "tags_validate",
|
||||||
|
"field": "tags",
|
||||||
|
"action": "validate",
|
||||||
|
"pattern": "^[a-z0-9_]+$",
|
||||||
|
"llm_prompt": "Generate tags using only lowercase letters, numbers, and underscores. Prefer selecting from existing repository tags when appropriate."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When using `ai` strategy in templates, the LLM receives:
|
||||||
|
- The rule's `llm_prompt`
|
||||||
|
- List of existing tags in repository
|
||||||
|
- Document content
|
||||||
|
- Structured output schema
|
||||||
|
|
||||||
|
## Testing Rules
|
||||||
|
|
||||||
|
### Test with --whatif
|
||||||
|
|
||||||
|
```bash
|
||||||
|
madomeda --whatif
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows what would change without modifying files.
|
||||||
|
|
||||||
|
### Test Specific Rule
|
||||||
|
|
||||||
|
Create a Python script:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.rules_processor import RulesProcessor
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
rp = RulesProcessor(Path.home() / '.config' / 'madomeda' / 'rules')
|
||||||
|
|
||||||
|
test_data = {
|
||||||
|
'Title': 'My Document',
|
||||||
|
'Tag': 'Python, Machine-Learning'
|
||||||
|
}
|
||||||
|
|
||||||
|
result, conformant, violations = rp.apply_rules(test_data)
|
||||||
|
print(f"Result: {result}")
|
||||||
|
print(f"Conformant: {conformant}")
|
||||||
|
print(f"Violations: {violations}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Email Validation
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "author_email",
|
||||||
|
"action": "validate",
|
||||||
|
"pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Format Validation (YYYY-MM-DD)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "date",
|
||||||
|
"action": "validate",
|
||||||
|
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### URL Scheme Enforcement
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "url",
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "^(?!https?://)(.*)",
|
||||||
|
"replacement": "https://\\1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove HTML Tags
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "description",
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "<[^>]+>",
|
||||||
|
"replacement": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced: Custom Field Rules
|
||||||
|
|
||||||
|
### Ensure Boolean String Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "published",
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "^(true|false|yes|no|1|0)$",
|
||||||
|
"replacement": "\\1",
|
||||||
|
"transform": "lower"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then normalize:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "published",
|
||||||
|
"priority": 31,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "yes|1|true",
|
||||||
|
"replacement": "true"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slug Generation
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field": "slug",
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "[^a-z0-9-]",
|
||||||
|
"replacement": "",
|
||||||
|
"transform": "lower"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Rule Not Executing
|
||||||
|
|
||||||
|
- Check `field` matches exact field name
|
||||||
|
- Verify `priority` is in expected range
|
||||||
|
- Ensure JSON is valid (use `jsonlint`)
|
||||||
|
|
||||||
|
### Wrong Execution Order
|
||||||
|
|
||||||
|
- Lower priority number executes first
|
||||||
|
- Rename rules always execute before other rules for same field
|
||||||
|
- Global rules (`field: "*"`) execute before field-specific
|
||||||
|
|
||||||
|
### Regex Not Matching
|
||||||
|
|
||||||
|
- Test regex at https://regex101.com/
|
||||||
|
- Escape special characters: `. * + ? ^ $ { } ( ) | [ ] \`
|
||||||
|
- Use `multiline: true` for multiline patterns
|
||||||
|
|
||||||
|
### Value Not Changing
|
||||||
|
|
||||||
|
- Check if earlier rule already modified it
|
||||||
|
- Verify pattern actually matches the value
|
||||||
|
- Ensure `replacement` is specified for `normalize_value`
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use priority ranges** - Leave gaps (10, 20, 30) to insert rules later
|
||||||
|
2. **Name files by priority** - `01_`, `10_`, `20_` for easy sorting
|
||||||
|
3. **Test incrementally** - Add one rule at a time
|
||||||
|
4. **Document complex regex** - Use `description` field
|
||||||
|
5. **Provide LLM prompts** - Help AI understand the rule intent
|
||||||
|
6. **Validate after normalize** - Use separate validate rule at higher priority
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [CONFIGURATION.md](CONFIGURATION.md) - Configuration directory
|
||||||
|
- [STRUCTURE.md](STRUCTURE.md) - How rules fit into the system
|
||||||
|
- [USAGE.md](USAGE.md) - Using rules with madomeda
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
title: Madomeda Project Structure
|
||||||
|
created: '2025-10-23T18:33:48+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Madomeda Project Structure
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Madomeda is a Python tool for managing markdown frontmatter in git repositories with rule-based normalization, template-driven generation, and optional LLM assistance.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
madomeda/
|
||||||
|
├── madomeda.py # Main entry point
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── .env # LLM configuration
|
||||||
|
├── README.md # Project documentation
|
||||||
|
├── USAGE.md # Usage examples
|
||||||
|
│
|
||||||
|
├── core/ # Core modules
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── database.py # SQLite database management
|
||||||
|
│ ├── repository.py # Git repository operations
|
||||||
|
│ ├── frontmatter.py # YAML frontmatter parsing
|
||||||
|
│ ├── rules.py # Rule engine
|
||||||
|
│ ├── template.py # Template management
|
||||||
|
│ ├── llm.py # LLM integration
|
||||||
|
│ ├── changelog.py # Changelog management
|
||||||
|
│ └── processor.py # Main processing logic
|
||||||
|
│
|
||||||
|
├── templates/ # Frontmatter templates
|
||||||
|
│ └── default.json # Default template
|
||||||
|
│
|
||||||
|
├── rules/ # Rule definitions
|
||||||
|
│ ├── tags_normalization.json
|
||||||
|
│ ├── tag_to_tags.json
|
||||||
|
│ └── lowercase_keys.json
|
||||||
|
│
|
||||||
|
├── prompts/ # LLM prompts
|
||||||
|
│ └── default.txt # Default system prompt
|
||||||
|
│
|
||||||
|
├── madomeda.db # SQLite database (gitignored)
|
||||||
|
└── madomeda_changelog.txt # Change log
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 1. Git Integration
|
||||||
|
- Tracks markdown files via git ls-files
|
||||||
|
- Extracts commit history (authors, dates, hashes)
|
||||||
|
- Ignores hidden files/folders (starting with '.')
|
||||||
|
- Respects --ignore-paths arguments
|
||||||
|
|
||||||
|
### 2. Database Management
|
||||||
|
- SQLite database (`madomeda.db`)
|
||||||
|
- Tables: files, frontmatter, commits, tags
|
||||||
|
- Tracks conformance status
|
||||||
|
- Stores all frontmatter versions
|
||||||
|
|
||||||
|
### 3. Rule Engine
|
||||||
|
Rules are JSON files in the `rules/` folder:
|
||||||
|
- **tags_normalization**: Converts tags to lowercase_with_underscores
|
||||||
|
- **tag_to_tags**: Renames 'tag' key to 'tags'
|
||||||
|
- **lowercase_keys**: All frontmatter keys must be lowercase
|
||||||
|
|
||||||
|
### 4. Template System
|
||||||
|
Templates define frontmatter structure with value strategies:
|
||||||
|
- `heur`: Use heuristics (git history, document analysis)
|
||||||
|
- `orig`: Use original frontmatter value
|
||||||
|
- `ai <prompt>`: Use LLM with specified prompt
|
||||||
|
|
||||||
|
Strategy priority: Options are tried left-to-right until one returns a value.
|
||||||
|
|
||||||
|
Example template:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig",
|
||||||
|
"created": "heur|orig",
|
||||||
|
"changed": "heur|orig",
|
||||||
|
"authors": "heur|orig",
|
||||||
|
"version": "heur|orig",
|
||||||
|
"tags": "heur|orig|ai default"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. LLM Integration
|
||||||
|
- OpenAI-compatible API support
|
||||||
|
- Structured output via JSON schema
|
||||||
|
- Configurable via .env file
|
||||||
|
- Fallback if API unavailable
|
||||||
|
|
||||||
|
### 6. Heuristic Values
|
||||||
|
- `title`: Extracted from # heading or filename
|
||||||
|
- `created`: First commit date (ISO 8601)
|
||||||
|
- `changed`: Latest commit date (ISO 8601)
|
||||||
|
- `authors`: List of all contributors
|
||||||
|
- `version`: Latest commit tag if exists, else short hash (7 chars)
|
||||||
|
- `tags`: Normalized from original
|
||||||
|
|
||||||
|
### 7. Changelog
|
||||||
|
Format:
|
||||||
|
```
|
||||||
|
YYYY-MM-DD HH:MM:SS (commit: <hash>)
|
||||||
|
path/file.md - frontmatter updated
|
||||||
|
...
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Command-Line Arguments
|
||||||
|
|
||||||
|
- `--dir PATH`: Repository directory (default: current)
|
||||||
|
- `--template NAME`: Template to use (default: 'default')
|
||||||
|
- `--ignore-paths PATH...`: Paths to exclude
|
||||||
|
- `--whatif`: Dry run mode
|
||||||
|
- `--no-confirm`: Skip confirmation prompts
|
||||||
|
- `--force`: Recreate all frontmatter
|
||||||
|
|
||||||
|
## Processing Flow
|
||||||
|
|
||||||
|
1. **Discovery**: Find tracked .md files via git
|
||||||
|
2. **Parse**: Extract existing frontmatter
|
||||||
|
3. **Store**: Save to database with timestamp
|
||||||
|
4. **Git History**: Extract commit information
|
||||||
|
5. **Normalize**: Apply rules to frontmatter
|
||||||
|
6. **Check Conformance**: Compare with template
|
||||||
|
7. **Build**: Generate new frontmatter from template
|
||||||
|
8. **Warn**: Alert on metadata loss
|
||||||
|
9. **Update**: Write to file and database
|
||||||
|
10. **Log**: Record change in changelog
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Configure LLM (optional)
|
||||||
|
# Edit .env with your OpenAI-compatible API settings
|
||||||
|
|
||||||
|
# Run on current directory
|
||||||
|
python madomeda.py --whatif
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- PyYAML: YAML parsing
|
||||||
|
- python-dotenv: Environment configuration
|
||||||
|
- openai: LLM integration (optional)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Database and .env are gitignored
|
||||||
|
- Rules/templates/prompts created automatically on first run
|
||||||
|
- Empty tag lists are preserved (for template conformance)
|
||||||
|
- Unicode output replaced with ASCII for Windows compatibility
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
# Template Field Types
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Templates support explicit type declarations for frontmatter fields. This ensures correct formatting and enables proper LLM schema generation.
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fieldname": "strategy|strategy:type"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The type is specified after a colon `:` at the end of the strategy chain.
|
||||||
|
|
||||||
|
## Supported Types
|
||||||
|
|
||||||
|
| Type | Description | YAML Output | Example |
|
||||||
|
|------|-------------|-------------|---------|
|
||||||
|
| `text` | String value (default) | `field: value` | Title, description |
|
||||||
|
| `number` | Integer or float | `field: 42` | Count, score |
|
||||||
|
| `checkbox` | Boolean value | `field: true` | Published, active |
|
||||||
|
| `date` | Date only (YAML ISO date) | `field: '2025-10-23'` | Birth date, deadline |
|
||||||
|
| `datetime` | Date and time (YAML ISO 8601) | `field: '2025-10-23T19:54:00+02:00'` | Created, modified |
|
||||||
|
| `list` | Array of strings | `field: [item1, item2]` | Tags, keywords, authors |
|
||||||
|
|
||||||
|
If no type is specified, `text` is assumed.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Basic Template with Types
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig:text",
|
||||||
|
"created": "heur|orig:datetime",
|
||||||
|
"changed": "heur|orig:datetime",
|
||||||
|
"authors": "heur|orig:list",
|
||||||
|
"version": "heur|orig:text",
|
||||||
|
"tags": "heur|orig|ai default:list",
|
||||||
|
"published": "orig|lit:false:checkbox",
|
||||||
|
"word_count": "heur:number"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Fields
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig:text",
|
||||||
|
"summary": "orig|ai default:text",
|
||||||
|
"author_name": "heur|lit:Anonymous:text"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: My Document
|
||||||
|
summary: This is a summary
|
||||||
|
author_name: Anonymous
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Number Fields
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"priority": "orig|lit:5:number",
|
||||||
|
"version_number": "heur:number",
|
||||||
|
"word_count": "heur:number"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
priority: 5
|
||||||
|
version_number: 2
|
||||||
|
word_count: 1532
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Literal conversion:**
|
||||||
|
- `lit:42:number` → `42` (integer)
|
||||||
|
- `lit:3.14:number` → `3.14` (float)
|
||||||
|
- Invalid numbers default to string
|
||||||
|
|
||||||
|
### Checkbox Fields
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"published": "orig|lit:false:checkbox",
|
||||||
|
"draft": "lit:true:checkbox",
|
||||||
|
"featured": "orig|lit:false:checkbox"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
published: false
|
||||||
|
draft: true
|
||||||
|
featured: false
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Literal conversion:**
|
||||||
|
- `true`, `1`, `yes`, `on` → `true`
|
||||||
|
- Anything else → `false`
|
||||||
|
|
||||||
|
### Date Fields
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"birth_date": "orig:date",
|
||||||
|
"deadline": "heur:date"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
birth_date: '2025-10-23'
|
||||||
|
deadline: '2025-12-31'
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Format:** ISO 8601 date (`YYYY-MM-DD`)
|
||||||
|
|
||||||
|
### Datetime Fields
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"created": "heur:datetime",
|
||||||
|
"modified": "heur:datetime",
|
||||||
|
"published_at": "orig:datetime"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
created: '2025-10-23T19:54:00+02:00'
|
||||||
|
modified: '2025-10-23T20:15:30+02:00'
|
||||||
|
published_at: '2025-10-23T18:00:00+00:00'
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Format:** ISO 8601 with timezone
|
||||||
|
|
||||||
|
### List Fields
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tags": "heur|orig|ai default:list",
|
||||||
|
"authors": "heur|orig:list",
|
||||||
|
"keywords": "orig|lit:[]:list",
|
||||||
|
"categories": "ai categorize:list"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- machine_learning
|
||||||
|
- python
|
||||||
|
- tutorial
|
||||||
|
authors:
|
||||||
|
- John Doe
|
||||||
|
- Jane Smith
|
||||||
|
keywords: []
|
||||||
|
categories:
|
||||||
|
- technology
|
||||||
|
- programming
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**Literal list:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reviewers": "lit:[]:list"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates empty list: `reviewers: []`
|
||||||
|
|
||||||
|
## Type with Strategies
|
||||||
|
|
||||||
|
### Literal Values
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "lit:draft:text",
|
||||||
|
"priority": "lit:3:number",
|
||||||
|
"urgent": "lit:false:checkbox",
|
||||||
|
"default_tags": "lit:[]:list"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Heuristic Values
|
||||||
|
|
||||||
|
Heuristics automatically infer appropriate types:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"created": "heur:datetime", // From git (ISO 8601)
|
||||||
|
"changed": "heur:datetime", // From git (ISO 8601)
|
||||||
|
"authors": "heur:list", // From git (list of names)
|
||||||
|
"version": "heur:text", // From git (tag or hash)
|
||||||
|
"title": "heur:text", // From # heading
|
||||||
|
"tags": "heur:list" // From normalized tags
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Original Values
|
||||||
|
|
||||||
|
Preserves the original type if it matches:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tags": "orig:list", // Keep original tags list
|
||||||
|
"published": "orig:checkbox", // Keep original boolean
|
||||||
|
"score": "orig:number" // Keep original number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI Values
|
||||||
|
|
||||||
|
LLM generates values conforming to the specified type:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "ai summarize:text",
|
||||||
|
"tags": "ai default:list",
|
||||||
|
"relevance_score": "ai evaluate:number",
|
||||||
|
"needs_review": "ai check:checkbox"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The type determines the JSON schema sent to the LLM.
|
||||||
|
|
||||||
|
## Complex Templates
|
||||||
|
|
||||||
|
### Blog Post
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig:text",
|
||||||
|
"date": "heur:datetime",
|
||||||
|
"author": "heur|lit:Blog Team:text",
|
||||||
|
"category": "orig|ai categorize:text",
|
||||||
|
"tags": "orig|ai default:list",
|
||||||
|
"published": "orig|lit:false:checkbox",
|
||||||
|
"featured": "orig|lit:false:checkbox",
|
||||||
|
"word_count": "heur:number",
|
||||||
|
"reading_time": "heur:number",
|
||||||
|
"excerpt": "orig|ai excerpt:text"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig:text",
|
||||||
|
"version": "heur:text",
|
||||||
|
"created": "heur:datetime",
|
||||||
|
"updated": "heur:datetime",
|
||||||
|
"authors": "heur:list",
|
||||||
|
"reviewers": "orig|lit:[]:list",
|
||||||
|
"status": "orig|lit:draft:text",
|
||||||
|
"tags": "heur|orig|ai default:list",
|
||||||
|
"api_version": "orig:text",
|
||||||
|
"deprecated": "orig|lit:false:checkbox"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task/Todo
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig:text",
|
||||||
|
"created": "heur:datetime",
|
||||||
|
"due_date": "orig:date",
|
||||||
|
"priority": "orig|lit:3:number",
|
||||||
|
"completed": "orig|lit:false:checkbox",
|
||||||
|
"assigned_to": "orig|lit:[]:list",
|
||||||
|
"tags": "orig:list",
|
||||||
|
"estimated_hours": "orig:number"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Validation
|
||||||
|
|
||||||
|
### Automatic Conversion
|
||||||
|
|
||||||
|
When using literal values, types are automatically converted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"published": "lit:true:checkbox" // String "true" → boolean true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalid Values
|
||||||
|
|
||||||
|
If conversion fails, value is kept as string:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": "lit:invalid:number" // "invalid" → "invalid" (string)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## LLM Schema Generation
|
||||||
|
|
||||||
|
Types control the JSON schema sent to LLMs:
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "ai generate:text",
|
||||||
|
"tags": "ai generate:list",
|
||||||
|
"relevance": "ai score:number",
|
||||||
|
"approved": "ai check:checkbox"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"tags": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"relevance": {"type": "number"},
|
||||||
|
"approved": {"type": "boolean"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures LLM returns correctly typed values.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Always Specify Types for Lists
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tags": "heur|orig:list", // ✅ Explicit
|
||||||
|
"tags": "heur|orig" // ❌ Ambiguous
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Checkbox for Booleans
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"published": "orig|lit:false:checkbox", // ✅ Boolean
|
||||||
|
"published": "orig|lit:no:text" // ❌ String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Datetime for Timestamps
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"created": "heur:datetime", // ✅ Full timestamp
|
||||||
|
"created": "heur:text" // ❌ Generic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Number for Numeric Data
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"priority": "orig|lit:5:number", // ✅ Number
|
||||||
|
"priority": "orig|lit:5:text" // ❌ String "5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. List Default Values
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"keywords": "orig|lit:[]:list", // ✅ Empty list
|
||||||
|
"keywords": "orig:list" // ⚠️ May be null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration from Untyped Templates
|
||||||
|
|
||||||
|
### Before (Untyped)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig",
|
||||||
|
"tags": "heur|orig|ai default",
|
||||||
|
"published": "lit:false"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Typed)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "heur|orig:text",
|
||||||
|
"tags": "heur|orig|ai default:list",
|
||||||
|
"published": "lit:false:checkbox"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Untyped templates still work (default to `text`), but typed templates are recommended for clarity and correctness.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### List Shows as String
|
||||||
|
|
||||||
|
**Problem:** `tags: "tag1, tag2"` instead of `tags: [tag1, tag2]`
|
||||||
|
|
||||||
|
**Solution:** Specify `:list` type and ensure rules split comma-separated values:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tags": "heur|orig:list"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Boolean Shows as String
|
||||||
|
|
||||||
|
**Problem:** `published: "true"` instead of `published: true`
|
||||||
|
|
||||||
|
**Solution:** Use `:checkbox` type:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"published": "orig|lit:false:checkbox"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Number Shows as String
|
||||||
|
|
||||||
|
**Problem:** `priority: "5"` instead of `priority: 5`
|
||||||
|
|
||||||
|
**Solution:** Use `:number` type:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"priority": "orig|lit:5:number"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Format Wrong
|
||||||
|
|
||||||
|
**Problem:** `date: "10/23/2025"` instead of `date: '2025-10-23'`
|
||||||
|
|
||||||
|
**Solution:** Use `:date` or `:datetime` type and ensure source provides ISO 8601 format.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [LITERAL_VALUES.md](LITERAL_VALUES.md) - Literal value syntax
|
||||||
|
- [CONFIGURATION.md](CONFIGURATION.md) - Template configuration
|
||||||
|
- [USAGE.md](USAGE.md) - Template usage examples
|
||||||
|
- [RULES_GUIDE.md](RULES_GUIDE.md) - Rule writing guide
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
title: Madomeda Usage Examples
|
||||||
|
created: '2025-10-23T18:33:48+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Madomeda Usage Examples
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
Process the current directory:
|
||||||
|
```bash
|
||||||
|
python madomeda.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dry Run Mode
|
||||||
|
|
||||||
|
Preview changes without modifying files:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --whatif
|
||||||
|
```
|
||||||
|
|
||||||
|
## Specify Directory
|
||||||
|
|
||||||
|
Process a specific repository:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --dir /path/to/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Force Update
|
||||||
|
|
||||||
|
Re-process all files regardless of conformance:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --force
|
||||||
|
```
|
||||||
|
|
||||||
|
## Skip Confirmation
|
||||||
|
|
||||||
|
Automatically accept metadata discards:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --no-confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ignore Paths
|
||||||
|
|
||||||
|
Exclude specific paths from processing:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --ignore-paths docs/archive vendor/external
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Template
|
||||||
|
|
||||||
|
Use a specific template:
|
||||||
|
```bash
|
||||||
|
python madomeda.py --template my-template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Tag Normalization
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
Title: My Document
|
||||||
|
Tag: Python, Machine-Learning, Data Science
|
||||||
|
Summary: A comprehensive guide
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (regular mode):**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: My Document
|
||||||
|
created: '2025-10-23T17:27:16+02:00'
|
||||||
|
changed: '2025-10-23T17:27:16+02:00'
|
||||||
|
authors:
|
||||||
|
- John Doe
|
||||||
|
version: 34d2d05
|
||||||
|
tags:
|
||||||
|
- python
|
||||||
|
- machine_learning
|
||||||
|
- data_science
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (--add-only mode):**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: My Document
|
||||||
|
description: A comprehensive guide
|
||||||
|
created: '2025-10-23T17:27:16+02:00'
|
||||||
|
changed: '2025-10-23T17:27:16+02:00'
|
||||||
|
authors:
|
||||||
|
- John Doe
|
||||||
|
version: 34d2d05
|
||||||
|
tags:
|
||||||
|
- python
|
||||||
|
- machine_learning
|
||||||
|
- data_science
|
||||||
|
---
|
||||||
|
```
|
||||||
|
Note: In add-only mode, "Summary" becomes "description" (normalized), and all template fields are added.
|
||||||
|
|
||||||
|
## Database Inspection
|
||||||
|
|
||||||
|
The SQLite database (`madomeda.db`) contains:
|
||||||
|
- File tracking
|
||||||
|
- Frontmatter versions
|
||||||
|
- Git commit information
|
||||||
|
- Unique tags repository
|
||||||
|
|
||||||
|
Query example:
|
||||||
|
```python
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('madomeda.db')
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT tag FROM tags ORDER BY tag')
|
||||||
|
tags = [row[0] for row in cursor.fetchall()]
|
||||||
|
print(tags)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
All changes are logged to `madomeda_changelog.txt`:
|
||||||
|
```
|
||||||
|
2025-10-23 17:40:36 (commit: 34d2d054afff9f679468f2d662bf9b8eb9754b82)
|
||||||
|
sample.md - frontmatter updated
|
||||||
|
test.md - frontmatter updated
|
||||||
|
```
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
title: Version Field Behavior
|
||||||
|
created: '2025-10-23T18:33:48+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Version Field Behavior
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `version` field in frontmatter is derived from git metadata with tag preference.
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
1. **Latest commit tag** (if exists)
|
||||||
|
2. **Short commit hash** (7 characters) as fallback
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### With Git Tag
|
||||||
|
|
||||||
|
**Git history:**
|
||||||
|
```
|
||||||
|
853ace5 (tag: v1.0.0) Add comparison test file
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resulting frontmatter:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
version: v1.0.0
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Without Git Tag
|
||||||
|
|
||||||
|
**Git history:**
|
||||||
|
```
|
||||||
|
0463d73 Add file without tag
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resulting frontmatter:**
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
version: 0463d73
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Repository Module
|
||||||
|
The `get_file_history()` method in `core/repository.py` extracts:
|
||||||
|
- `latest_hash`: The most recent commit hash for the file
|
||||||
|
- `latest_tag`: The tag associated with the latest commit (if any)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get tag for latest commit if any
|
||||||
|
latest_tag = ''
|
||||||
|
if latest_hash:
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'describe', '--tags', '--exact-match', latest_hash],
|
||||||
|
...
|
||||||
|
)
|
||||||
|
latest_tag = result.stdout.strip() if result.returncode == 0 else ''
|
||||||
|
```
|
||||||
|
|
||||||
|
### Processor Module
|
||||||
|
The `_get_heuristic_value()` method in `core/processor.py` prefers the tag:
|
||||||
|
|
||||||
|
```python
|
||||||
|
elif field == 'version':
|
||||||
|
# Prefer latest tag, fallback to short hash
|
||||||
|
latest_tag = git_info.get('latest_tag', '')
|
||||||
|
if latest_tag:
|
||||||
|
return latest_tag
|
||||||
|
latest_hash = git_info.get('latest_hash', '')
|
||||||
|
return latest_hash[:7] if latest_hash else ''
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Semantic Versioning
|
||||||
|
Tag commits with semantic versions:
|
||||||
|
```bash
|
||||||
|
git tag -a v2.1.0 -m "Release 2.1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Files modified in this commit will have:
|
||||||
|
```yaml
|
||||||
|
version: v2.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Builds
|
||||||
|
Commits without tags automatically get short hash:
|
||||||
|
```yaml
|
||||||
|
version: a3f9c21
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Tag releases:** Use semantic versioning for releases
|
||||||
|
```bash
|
||||||
|
git tag -a v1.0.0 -m "Release 1.0.0"
|
||||||
|
git tag -a v1.1.0 -m "Release 1.1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Let development builds use hashes:** Don't tag every commit
|
||||||
|
|
||||||
|
3. **Consistency:** Use a consistent tagging scheme
|
||||||
|
- `v1.0.0` (recommended)
|
||||||
|
- `1.0.0`
|
||||||
|
- `release-1.0.0`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Check what version will be used:
|
||||||
|
```bash
|
||||||
|
# For a specific file
|
||||||
|
git log -1 --oneline path/to/file.md
|
||||||
|
|
||||||
|
# Check if commit has a tag
|
||||||
|
git describe --tags --exact-match <commit-hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Only exact tag matches are used (`--exact-match`)
|
||||||
|
- Annotated tags and lightweight tags both work
|
||||||
|
- If multiple tags point to same commit, git chooses one
|
||||||
|
- Tags must exist locally (not just on remote)
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
If upgrading from previous version where only short hash was used:
|
||||||
|
- Re-run madomeda to update version fields
|
||||||
|
- Files on tagged commits will switch from hash to tag
|
||||||
|
- Use `--whatif` to preview changes
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
title: Advanced Machine Learning Techniques
|
||||||
|
created: '2025-10-23T17:49:29+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags:
|
||||||
|
- aiml
|
||||||
|
- deep_learning
|
||||||
|
- neural_networks
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Advanced Machine Learning Techniques
|
||||||
|
|
||||||
|
This document covers advanced topics in machine learning.
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Deep learning has revolutionized the field of artificial intelligence.
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
title: AI Testing Document
|
||||||
|
created: '2025-10-23T19:22:20+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags:
|
||||||
|
- aiml
|
||||||
|
- data_science
|
||||||
|
- deep_learning
|
||||||
|
- machine_learning
|
||||||
|
- neural_networks
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# AI Testing Document
|
||||||
|
|
||||||
|
This document is about machine learning, artificial intelligence, and neural networks. It covers deep learning techniques, natural language processing, and computer vision applications.
|
||||||
|
|
||||||
|
The content discusses training models, data preprocessing, and model evaluation metrics.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
title: Comparison Test
|
||||||
|
created: '2025-10-23T18:09:10+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Comparison Test
|
||||||
|
|
||||||
|
This file tests both regular and add-only modes.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Core module initialization"""
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
Changelog management
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeLog:
|
||||||
|
def __init__(self, repo_path: Path):
|
||||||
|
self.changelog_path = repo_path / 'madomeda_changelog.txt'
|
||||||
|
self.current_session = None
|
||||||
|
|
||||||
|
def start_session(self):
|
||||||
|
"""Start a new changelog session"""
|
||||||
|
# Get current commit hash
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'rev-parse', 'HEAD'],
|
||||||
|
cwd=self.changelog_path.parent,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
commit_hash = result.stdout.strip() if result.returncode == 0 else 'no-commit'
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
self.current_session = f"{timestamp} (commit: {commit_hash})\n"
|
||||||
|
self.entries = []
|
||||||
|
|
||||||
|
def add_entry(self, file_path: str, message: str = "frontmatter updated"):
|
||||||
|
"""Add a changelog entry"""
|
||||||
|
if self.current_session:
|
||||||
|
self.entries.append(f" {file_path} - {message}")
|
||||||
|
|
||||||
|
def write(self):
|
||||||
|
"""Write changelog to file"""
|
||||||
|
if not self.current_session or not self.entries:
|
||||||
|
return
|
||||||
|
|
||||||
|
content = self.current_session
|
||||||
|
content += '\n'.join(self.entries)
|
||||||
|
content += '\n\n'
|
||||||
|
|
||||||
|
# Append to changelog file
|
||||||
|
with open(self.changelog_path, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
"""
|
||||||
|
Configuration management module
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.config_dir = self._get_config_dir()
|
||||||
|
self.env_file = self.config_dir / '.env'
|
||||||
|
self.templates_dir = self.config_dir / 'templates'
|
||||||
|
self.rules_dir = self.config_dir / 'rules'
|
||||||
|
self.prompts_dir = self.config_dir / 'prompts'
|
||||||
|
|
||||||
|
def _get_config_dir(self) -> Path:
|
||||||
|
"""Get platform-specific config directory"""
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
# Windows: %USERPROFILE%\.config\madomeda
|
||||||
|
base = Path(os.environ.get('USERPROFILE', os.path.expanduser('~')))
|
||||||
|
config_dir = base / '.config' / 'madomeda'
|
||||||
|
elif sys.platform == 'darwin':
|
||||||
|
# macOS: ~/.config/madomeda
|
||||||
|
config_dir = Path.home() / '.config' / 'madomeda'
|
||||||
|
else:
|
||||||
|
# Linux: ~/.config/madomeda
|
||||||
|
config_dir = Path.home() / '.config' / 'madomeda'
|
||||||
|
|
||||||
|
return config_dir
|
||||||
|
|
||||||
|
def ensure_config_exists(self):
|
||||||
|
"""Ensure configuration directory exists with default files"""
|
||||||
|
if not self.config_dir.exists():
|
||||||
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._create_default_config()
|
||||||
|
|
||||||
|
# Ensure subdirectories exist
|
||||||
|
self.templates_dir.mkdir(exist_ok=True)
|
||||||
|
self.rules_dir.mkdir(exist_ok=True)
|
||||||
|
self.prompts_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Copy defaults if they don't exist
|
||||||
|
if not (self.templates_dir / 'default.json').exists():
|
||||||
|
self._create_default_template()
|
||||||
|
|
||||||
|
if not list(self.rules_dir.glob('*.json')):
|
||||||
|
self._create_default_rules()
|
||||||
|
|
||||||
|
if not (self.prompts_dir / 'default.txt').exists():
|
||||||
|
self._create_default_prompt()
|
||||||
|
|
||||||
|
def _create_default_config(self):
|
||||||
|
"""Create default .env file"""
|
||||||
|
default_env = """# Madomeda Configuration
|
||||||
|
# OpenAI-compatible API settings
|
||||||
|
|
||||||
|
# API endpoint URL
|
||||||
|
OPENAI_API_URL=https://api.openai.com/v1
|
||||||
|
|
||||||
|
# Model to use
|
||||||
|
OPENAI_MODEL=gpt-4
|
||||||
|
|
||||||
|
# API Key options:
|
||||||
|
# 1. Empty string "" = No API key needed (LLM features disabled)
|
||||||
|
# 2. $ENV_VAR_NAME = Read from environment variable
|
||||||
|
# 3. Direct key = Use the key (WARNING: security risk in plain text)
|
||||||
|
OPENAI_API_KEY=""
|
||||||
|
"""
|
||||||
|
with open(self.env_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(default_env)
|
||||||
|
|
||||||
|
def _create_default_template(self):
|
||||||
|
"""Create default template"""
|
||||||
|
import json
|
||||||
|
default_template = {
|
||||||
|
"title": "heur|orig:text",
|
||||||
|
"created": "heur|orig:datetime",
|
||||||
|
"changed": "heur|orig:datetime",
|
||||||
|
"authors": "heur|orig:list",
|
||||||
|
"version": "heur|orig:text",
|
||||||
|
"tags": "heur|orig|ai default:list"
|
||||||
|
}
|
||||||
|
with open(self.templates_dir / 'default.json', 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(default_template, f, indent=2)
|
||||||
|
|
||||||
|
def _create_default_rules(self):
|
||||||
|
"""Create default rules"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
rules = {
|
||||||
|
'01_lowercase_keys.json': {
|
||||||
|
"name": "lowercase_keys",
|
||||||
|
"description": "All frontmatter keys must be lowercase",
|
||||||
|
"field": "*",
|
||||||
|
"priority": 1,
|
||||||
|
"action": "normalize_keys",
|
||||||
|
"transform": "lower",
|
||||||
|
"llm_prompt": "Ensure all keys are lowercase"
|
||||||
|
},
|
||||||
|
'02_tag_to_tags.json': {
|
||||||
|
"name": "tag_to_tags",
|
||||||
|
"description": "Rename 'tag' field to 'tags'",
|
||||||
|
"field": "tag",
|
||||||
|
"priority": 2,
|
||||||
|
"action": "rename_field",
|
||||||
|
"from": "tag",
|
||||||
|
"to": "tags",
|
||||||
|
"llm_prompt": "Convert tag field to tags array"
|
||||||
|
},
|
||||||
|
'03_summary_to_description.json': {
|
||||||
|
"name": "summary_to_description",
|
||||||
|
"description": "Rename 'summary' field to 'description'",
|
||||||
|
"field": "summary",
|
||||||
|
"priority": 2,
|
||||||
|
"action": "rename_field",
|
||||||
|
"from": "summary",
|
||||||
|
"to": "description",
|
||||||
|
"llm_prompt": "Convert summary to description"
|
||||||
|
},
|
||||||
|
'10_tags_format_list.json': {
|
||||||
|
"name": "tags_format_list",
|
||||||
|
"description": "Convert comma-separated tags to list",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 10,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"split_on": ",",
|
||||||
|
"pattern": ".*",
|
||||||
|
"replacement": "\\g<0>",
|
||||||
|
"llm_prompt": "Ensure tags is a list, not a comma-separated string"
|
||||||
|
},
|
||||||
|
'20_tags_replace_spaces.json': {
|
||||||
|
"name": "tags_replace_spaces",
|
||||||
|
"description": "Replace spaces with underscores in tags",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 20,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": " ",
|
||||||
|
"replacement": "_",
|
||||||
|
"llm_prompt": "Replace spaces with underscores in tags"
|
||||||
|
},
|
||||||
|
'21_tags_replace_hyphens.json': {
|
||||||
|
"name": "tags_replace_hyphens",
|
||||||
|
"description": "Replace hyphens with underscores in tags",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 21,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "-",
|
||||||
|
"replacement": "_",
|
||||||
|
"llm_prompt": "Replace hyphens with underscores in tags"
|
||||||
|
},
|
||||||
|
'30_tags_lowercase.json': {
|
||||||
|
"name": "tags_lowercase",
|
||||||
|
"description": "Convert tags to lowercase",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 30,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"transform": "lower",
|
||||||
|
"llm_prompt": "Convert all tags to lowercase"
|
||||||
|
},
|
||||||
|
'40_tags_remove_invalid_chars.json': {
|
||||||
|
"name": "tags_remove_invalid_chars",
|
||||||
|
"description": "Remove characters that are not alphanumeric or underscore",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 40,
|
||||||
|
"action": "normalize_value",
|
||||||
|
"pattern": "[^a-z0-9_]",
|
||||||
|
"replacement": "",
|
||||||
|
"llm_prompt": "Remove any characters that are not lowercase letters, numbers, or underscores from tags"
|
||||||
|
},
|
||||||
|
'50_tags_validate_format.json': {
|
||||||
|
"name": "tags_validate_format",
|
||||||
|
"description": "Validate that tags only contain lowercase alphanumeric and underscores",
|
||||||
|
"field": "tags",
|
||||||
|
"priority": 50,
|
||||||
|
"action": "validate",
|
||||||
|
"pattern": "^[a-z0-9_]+$",
|
||||||
|
"multiline": False,
|
||||||
|
"llm_prompt": "Tags must contain only lowercase letters, numbers, and underscores"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for filename, rule_data in rules.items():
|
||||||
|
with open(self.rules_dir / filename, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(rule_data, f, indent=2)
|
||||||
|
|
||||||
|
def _create_default_prompt(self):
|
||||||
|
"""Create default prompt"""
|
||||||
|
default_prompt = """You are a metadata extraction assistant for markdown documents.
|
||||||
|
|
||||||
|
Your task is to analyze the document content and suggest appropriate frontmatter metadata.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- For tags: prefer selecting from the provided existing tags list when appropriate
|
||||||
|
- You may create new tags if they better fit the content
|
||||||
|
- New tags must follow the rules: lowercase letters, numbers, and underscores only
|
||||||
|
- Be concise and accurate
|
||||||
|
- Preserve important existing metadata when present"""
|
||||||
|
|
||||||
|
with open(self.prompts_dir / 'default.txt', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(default_prompt)
|
||||||
|
|
||||||
|
def get_api_config(self) -> dict:
|
||||||
|
"""Get API configuration with environment variable resolution"""
|
||||||
|
config = {
|
||||||
|
'url': 'https://api.openai.com/v1',
|
||||||
|
'model': 'gpt-4',
|
||||||
|
'api_key': '',
|
||||||
|
'api_key_source': 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
if not self.env_file.exists():
|
||||||
|
return config
|
||||||
|
|
||||||
|
# Parse .env file
|
||||||
|
env_vars = {}
|
||||||
|
with open(self.env_file, 'r', encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
if '=' in line:
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
env_vars[key.strip()] = value.strip()
|
||||||
|
|
||||||
|
# Get URL
|
||||||
|
if 'OPENAI_API_URL' in env_vars:
|
||||||
|
config['url'] = env_vars['OPENAI_API_URL']
|
||||||
|
|
||||||
|
# Get model
|
||||||
|
if 'OPENAI_MODEL' in env_vars:
|
||||||
|
config['model'] = env_vars['OPENAI_MODEL']
|
||||||
|
|
||||||
|
# Get API key with special handling
|
||||||
|
if 'OPENAI_API_KEY' in env_vars:
|
||||||
|
api_key_value = env_vars['OPENAI_API_KEY']
|
||||||
|
|
||||||
|
# Remove quotes if present
|
||||||
|
api_key_value = api_key_value.strip('"').strip("'")
|
||||||
|
|
||||||
|
if not api_key_value:
|
||||||
|
# Empty string = no key needed
|
||||||
|
config['api_key'] = ''
|
||||||
|
config['api_key_source'] = 'none'
|
||||||
|
elif api_key_value.startswith('$'):
|
||||||
|
# Environment variable reference
|
||||||
|
env_var_name = api_key_value[1:]
|
||||||
|
env_value = os.environ.get(env_var_name)
|
||||||
|
if env_value is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Environment variable '{env_var_name}' not found. "
|
||||||
|
f"Referenced in .env as OPENAI_API_KEY={api_key_value}"
|
||||||
|
)
|
||||||
|
config['api_key'] = env_value
|
||||||
|
config['api_key_source'] = f'env:{env_var_name}'
|
||||||
|
else:
|
||||||
|
# Plain text key (security warning will be issued)
|
||||||
|
config['api_key'] = api_key_value
|
||||||
|
config['api_key_source'] = 'plaintext'
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def print_config_info(self):
|
||||||
|
"""Print configuration location and status"""
|
||||||
|
print(f"Configuration directory: {self.config_dir}")
|
||||||
|
print(f" .env file: {self.env_file}")
|
||||||
|
print(f" Templates: {self.templates_dir}")
|
||||||
|
print(f" Rules: {self.rules_dir}")
|
||||||
|
print(f" Prompts: {self.prompts_dir}")
|
||||||
|
|
||||||
|
# Get API config to show status
|
||||||
|
try:
|
||||||
|
api_config = self.get_api_config()
|
||||||
|
|
||||||
|
if api_config['api_key_source'] == 'none':
|
||||||
|
print(f" API Key: Not configured (LLM features disabled)")
|
||||||
|
elif api_config['api_key_source'] == 'plaintext':
|
||||||
|
print(f" API Key: Configured (plain text)")
|
||||||
|
print(f" WARNING: Storing API keys in plain text is a security risk!")
|
||||||
|
print(f" Consider using environment variable: OPENAI_API_KEY=$YOUR_ENV_VAR")
|
||||||
|
elif api_config['api_key_source'].startswith('env:'):
|
||||||
|
env_var = api_config['api_key_source'].split(':', 1)[1]
|
||||||
|
print(f" API Key: From environment variable ${env_var}")
|
||||||
|
except ValueError as e:
|
||||||
|
print(f" API Key: ERROR - {e}")
|
||||||
|
|
||||||
|
print()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
Database management module
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_path: Path):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.conn = sqlite3.connect(db_path)
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
self._init_schema()
|
||||||
|
|
||||||
|
def _init_schema(self):
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT UNIQUE NOT NULL,
|
||||||
|
discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS frontmatter (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
file_id INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
read_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
conformant INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (file_id) REFERENCES files(id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS commits (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
file_id INTEGER NOT NULL,
|
||||||
|
author_name TEXT,
|
||||||
|
author_email TEXT,
|
||||||
|
commit_hash TEXT,
|
||||||
|
commit_tag TEXT,
|
||||||
|
latest_hash TEXT,
|
||||||
|
FOREIGN KEY (file_id) REFERENCES files(id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tag TEXT UNIQUE NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def add_file(self, path: str) -> int:
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("INSERT OR IGNORE INTO files (path) VALUES (?)", (path,))
|
||||||
|
self.conn.commit()
|
||||||
|
cursor.execute("SELECT id FROM files WHERE path = ?", (path,))
|
||||||
|
return cursor.fetchone()[0]
|
||||||
|
|
||||||
|
def add_frontmatter(self, file_id: int, content: dict, conformant: bool = False) -> int:
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO frontmatter (file_id, content, conformant) VALUES (?, ?, ?)",
|
||||||
|
(file_id, json.dumps(content), 1 if conformant else 0)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
def update_frontmatter_conformance(self, fm_id: int, conformant: bool):
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE frontmatter SET conformant = ? WHERE id = ?",
|
||||||
|
(1 if conformant else 0, fm_id)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def add_commit_info(self, file_id: int, author_name: str, author_email: str,
|
||||||
|
commit_hash: str, commit_tag: str, latest_hash: str):
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT OR REPLACE INTO commits
|
||||||
|
(file_id, author_name, author_email, commit_hash, commit_tag, latest_hash)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||||
|
(file_id, author_name, author_email, commit_hash, commit_tag, latest_hash)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def add_tag(self, tag: str):
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("INSERT OR IGNORE INTO tags (tag) VALUES (?)", (tag,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_all_tags(self) -> list:
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("SELECT tag FROM tags ORDER BY tag")
|
||||||
|
return [row[0] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def get_file_id(self, path: str) -> int:
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM files WHERE path = ?", (path,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.conn.close()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Frontmatter parsing and serialization
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import yaml
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class FrontmatterParser:
|
||||||
|
FRONTMATTER_PATTERN = re.compile(r'^---\s*\n(.*?\n)---\s*\n', re.DOTALL)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(content: str) -> Tuple[Optional[dict], str]:
|
||||||
|
"""Parse frontmatter from markdown content"""
|
||||||
|
match = FrontmatterParser.FRONTMATTER_PATTERN.match(content)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
yaml_content = match.group(1)
|
||||||
|
body = content[match.end():]
|
||||||
|
|
||||||
|
try:
|
||||||
|
frontmatter = yaml.safe_load(yaml_content) or {}
|
||||||
|
return frontmatter, body
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return None, content
|
||||||
|
|
||||||
|
return None, content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def serialize(frontmatter: dict, body: str) -> str:
|
||||||
|
"""Serialize frontmatter and body to markdown"""
|
||||||
|
yaml_str = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||||
|
return f"---\n{yaml_str}---\n{body}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_title(body: str) -> Optional[str]:
|
||||||
|
"""Extract title from markdown body"""
|
||||||
|
match = re.search(r'^#\s+(.+)$', body, re.MULTILINE)
|
||||||
|
return match.group(1).strip() if match else None
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
LLM communication module
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
def __init__(self, prompts_dir: Path, api_config: dict):
|
||||||
|
self.api_url = api_config.get('url', 'https://api.openai.com/v1')
|
||||||
|
self.model = api_config.get('model', 'gpt-4')
|
||||||
|
self.api_key = api_config.get('api_key', '')
|
||||||
|
|
||||||
|
self.client = OpenAI(
|
||||||
|
base_url=self.api_url,
|
||||||
|
api_key=self.api_key
|
||||||
|
) if self.api_key else None
|
||||||
|
|
||||||
|
self.prompts_dir = prompts_dir
|
||||||
|
|
||||||
|
def load_prompt(self, name: str = 'default') -> str:
|
||||||
|
"""Load prompt template"""
|
||||||
|
prompt_path = self.prompts_dir / f'{name}.txt'
|
||||||
|
|
||||||
|
if not prompt_path.exists():
|
||||||
|
prompt_path = self.prompts_dir / 'default.txt'
|
||||||
|
if not prompt_path.exists():
|
||||||
|
self._create_default_prompt()
|
||||||
|
|
||||||
|
with open(prompt_path, 'r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def infer_metadata(self, field: str, prompt_name: str, original_fm: dict,
|
||||||
|
current_fm: dict, document_body: str,
|
||||||
|
all_tags: list, schema: dict) -> Optional[str]:
|
||||||
|
"""Use LLM to infer metadata field value"""
|
||||||
|
if not self.client:
|
||||||
|
return None
|
||||||
|
|
||||||
|
system_prompt = self.load_prompt(prompt_name)
|
||||||
|
|
||||||
|
user_message = f"""Document content:
|
||||||
|
{document_body[:2000]}
|
||||||
|
|
||||||
|
Original frontmatter:
|
||||||
|
{json.dumps(original_fm, indent=2)}
|
||||||
|
|
||||||
|
Current frontmatter being built:
|
||||||
|
{json.dumps(current_fm, indent=2)}
|
||||||
|
|
||||||
|
Available tags in repository:
|
||||||
|
{', '.join(all_tags[:100])}
|
||||||
|
|
||||||
|
Please provide appropriate metadata for this document following the schema."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_message}
|
||||||
|
],
|
||||||
|
response_format={"type": "json_schema", "json_schema": {
|
||||||
|
"name": "frontmatter_response",
|
||||||
|
"strict": True,
|
||||||
|
"schema": schema
|
||||||
|
}},
|
||||||
|
temperature=0.3
|
||||||
|
)
|
||||||
|
|
||||||
|
result = json.loads(response.choices[0].message.content)
|
||||||
|
return result.get(field)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" LLM error: {e}")
|
||||||
|
return None
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""
|
||||||
|
Main frontmatter processor
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from core.database import Database
|
||||||
|
from core.repository import Repository
|
||||||
|
from core.frontmatter import FrontmatterParser
|
||||||
|
from core.rules_processor import RulesProcessor
|
||||||
|
from core.template import TemplateManager
|
||||||
|
from core.llm import LLMClient
|
||||||
|
from core.changelog import ChangeLog
|
||||||
|
from core.config import ConfigManager
|
||||||
|
|
||||||
|
|
||||||
|
class FrontmatterProcessor:
|
||||||
|
def __init__(self, repo: Repository, template_name: str,
|
||||||
|
whatif: bool = False, no_confirm: bool = False, force: bool = False, add_only: bool = False):
|
||||||
|
self.repo = repo
|
||||||
|
self.template_name = template_name
|
||||||
|
self.whatif = whatif
|
||||||
|
self.no_confirm = no_confirm
|
||||||
|
self.force = force
|
||||||
|
self.add_only = add_only
|
||||||
|
|
||||||
|
# Initialize configuration
|
||||||
|
self.config = ConfigManager()
|
||||||
|
self.config.ensure_config_exists()
|
||||||
|
|
||||||
|
# Get API configuration
|
||||||
|
api_config = self.config.get_api_config()
|
||||||
|
|
||||||
|
self.db = Database(repo.path / 'madomeda.db')
|
||||||
|
self.rules = RulesProcessor(self.config.rules_dir)
|
||||||
|
self.templates = TemplateManager(self.config.templates_dir)
|
||||||
|
self.llm = LLMClient(self.config.prompts_dir, api_config)
|
||||||
|
self.changelog = ChangeLog(repo.path)
|
||||||
|
self.parser = FrontmatterParser()
|
||||||
|
|
||||||
|
def process(self):
|
||||||
|
"""Main processing loop"""
|
||||||
|
# Print configuration info if running in interactive shell
|
||||||
|
if sys.stdin.isatty():
|
||||||
|
self.config.print_config_info()
|
||||||
|
|
||||||
|
print(f"Processing repository: {self.repo.path}")
|
||||||
|
print(f"Template: {self.template_name}")
|
||||||
|
print(f"Mode: {'DRY RUN' if self.whatif else 'LIVE'}")
|
||||||
|
if self.add_only:
|
||||||
|
print(f"Add-only mode: Preserving existing keys")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Get template
|
||||||
|
template = self.templates.load_template(self.template_name)
|
||||||
|
schema = self.templates.get_structured_output_schema(template)
|
||||||
|
|
||||||
|
# Get all markdown files
|
||||||
|
files = self.repo.get_tracked_files()
|
||||||
|
print(f"Found {len(files)} markdown files")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Start changelog session
|
||||||
|
self.changelog.start_session()
|
||||||
|
|
||||||
|
# Process each file
|
||||||
|
for file_path in files:
|
||||||
|
self._process_file(file_path, template, schema)
|
||||||
|
|
||||||
|
# Collect all tags and store in database
|
||||||
|
self._collect_all_tags()
|
||||||
|
|
||||||
|
# Write changelog
|
||||||
|
if not self.whatif:
|
||||||
|
self.changelog.write()
|
||||||
|
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def _process_file(self, file_path: Path, template: dict, schema: dict):
|
||||||
|
"""Process a single markdown file"""
|
||||||
|
relative_path = file_path.relative_to(self.repo.path)
|
||||||
|
print(f"Processing: {relative_path}")
|
||||||
|
|
||||||
|
# Add file to database
|
||||||
|
file_id = self.db.add_file(str(relative_path))
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Parse frontmatter
|
||||||
|
original_fm, body = self.parser.parse(content)
|
||||||
|
|
||||||
|
if original_fm:
|
||||||
|
print(f" Found existing frontmatter")
|
||||||
|
else:
|
||||||
|
print(f" No frontmatter found")
|
||||||
|
original_fm = {}
|
||||||
|
|
||||||
|
# Store original frontmatter in database
|
||||||
|
if original_fm:
|
||||||
|
self.db.add_frontmatter(file_id, original_fm, conformant=False)
|
||||||
|
|
||||||
|
# Get git history
|
||||||
|
git_info = self.repo.get_file_history(file_path)
|
||||||
|
self.db.add_commit_info(
|
||||||
|
file_id,
|
||||||
|
git_info['author_name'],
|
||||||
|
git_info['author_email'],
|
||||||
|
git_info['commit_hash'],
|
||||||
|
git_info['commit_tag'],
|
||||||
|
git_info['latest_hash']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply rules
|
||||||
|
normalized_fm, is_conformant, violations = self.rules.apply_rules(dict(original_fm))
|
||||||
|
|
||||||
|
# Check if we need to update
|
||||||
|
needs_update = self.force or not is_conformant or not self._matches_template(normalized_fm, template)
|
||||||
|
|
||||||
|
if not needs_update:
|
||||||
|
print(f" OK Already conformant")
|
||||||
|
if original_fm:
|
||||||
|
# Mark as conformant in database
|
||||||
|
fm_id = self.db.add_frontmatter(file_id, original_fm, conformant=True)
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build new frontmatter from template
|
||||||
|
new_fm = self._build_frontmatter(template, schema, original_fm, normalized_fm, body, file_path, git_info)
|
||||||
|
|
||||||
|
# In add-only mode, merge with normalized original
|
||||||
|
if self.add_only:
|
||||||
|
# Start with normalized original
|
||||||
|
merged_fm = dict(normalized_fm)
|
||||||
|
# Add missing template fields
|
||||||
|
for key, value in new_fm.items():
|
||||||
|
if key not in merged_fm:
|
||||||
|
merged_fm[key] = value
|
||||||
|
new_fm = merged_fm
|
||||||
|
|
||||||
|
# Store tags in database
|
||||||
|
if 'tags' in new_fm and isinstance(new_fm['tags'], list):
|
||||||
|
for tag in new_fm['tags']:
|
||||||
|
self.db.add_tag(tag)
|
||||||
|
|
||||||
|
# Check for metadata loss
|
||||||
|
discarded = self._check_discarded_metadata(original_fm, new_fm)
|
||||||
|
if discarded and not self.add_only: # Only warn in non-add-only mode
|
||||||
|
print(f" WARNING Metadata will be discarded: {', '.join(discarded)}")
|
||||||
|
|
||||||
|
if not self.no_confirm and not self.whatif:
|
||||||
|
response = input(" Continue? (y/n): ")
|
||||||
|
if response.lower() != 'y':
|
||||||
|
print(f" Skipped")
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Show what would change
|
||||||
|
if self.whatif:
|
||||||
|
print(f" Would update frontmatter:")
|
||||||
|
print(f" Changes: {self._describe_changes(original_fm, new_fm)}")
|
||||||
|
else:
|
||||||
|
# Write new frontmatter
|
||||||
|
new_content = self.parser.serialize(new_fm, body)
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
|
||||||
|
# Update database
|
||||||
|
self.db.add_frontmatter(file_id, new_fm, conformant=True)
|
||||||
|
|
||||||
|
# Add to changelog
|
||||||
|
self.changelog.add_entry(str(relative_path))
|
||||||
|
|
||||||
|
print(f" OK Updated frontmatter")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
def _matches_template(self, frontmatter: dict, template: dict) -> bool:
|
||||||
|
"""Check if frontmatter has all required template fields"""
|
||||||
|
for field in template.keys():
|
||||||
|
if field not in frontmatter:
|
||||||
|
return False
|
||||||
|
# Allow empty lists/strings as valid values
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _build_frontmatter(self, template: dict, schema: dict, original_fm: dict,
|
||||||
|
normalized_fm: dict, body: str, file_path: Path, git_info: dict) -> dict:
|
||||||
|
"""Build new frontmatter from template"""
|
||||||
|
new_fm = {}
|
||||||
|
all_tags = self.db.get_all_tags()
|
||||||
|
|
||||||
|
for field, field_value in template.items():
|
||||||
|
# Parse field to get strategy and type
|
||||||
|
strategy_str, field_type = self.templates.parse_field(str(field_value))
|
||||||
|
options = strategy_str.split('|')
|
||||||
|
value = None
|
||||||
|
|
||||||
|
for option in options:
|
||||||
|
option = option.strip()
|
||||||
|
|
||||||
|
if option.startswith('lit:'):
|
||||||
|
# Literal/static value
|
||||||
|
value = option[4:].strip()
|
||||||
|
# Convert based on type
|
||||||
|
value = self._convert_value_type(value, field_type)
|
||||||
|
elif option == 'heur':
|
||||||
|
# Use heuristics
|
||||||
|
value = self._get_heuristic_value(field, normalized_fm, body, file_path, git_info)
|
||||||
|
elif option == 'orig':
|
||||||
|
# Use original value
|
||||||
|
value = normalized_fm.get(field)
|
||||||
|
elif option.startswith('ai'):
|
||||||
|
# Use LLM
|
||||||
|
parts = option.split(None, 1)
|
||||||
|
prompt_name = parts[1] if len(parts) > 1 else 'default'
|
||||||
|
value = self.llm.infer_metadata(field, prompt_name, original_fm, new_fm, body, all_tags, schema)
|
||||||
|
|
||||||
|
# Accept value if not None (empty lists/strings are valid)
|
||||||
|
if value is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if value is not None:
|
||||||
|
new_fm[field] = value
|
||||||
|
|
||||||
|
return new_fm
|
||||||
|
|
||||||
|
def _convert_value_type(self, value: str, field_type: str):
|
||||||
|
"""Convert literal value to appropriate type"""
|
||||||
|
if field_type == 'checkbox':
|
||||||
|
return value.lower() in ('true', '1', 'yes', 'on')
|
||||||
|
elif field_type == 'number':
|
||||||
|
try:
|
||||||
|
if '.' in value:
|
||||||
|
return float(value)
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
elif field_type == 'list':
|
||||||
|
# Parse as JSON array or return empty list
|
||||||
|
if value == '[]':
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
return json.loads(value)
|
||||||
|
except:
|
||||||
|
return [value]
|
||||||
|
else:
|
||||||
|
# text, date, datetime - keep as string
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _get_heuristic_value(self, field: str, normalized_fm: dict, body: str,
|
||||||
|
file_path: Path, git_info: dict):
|
||||||
|
"""Get field value using heuristics"""
|
||||||
|
if field == 'title':
|
||||||
|
# Try to extract from document
|
||||||
|
title = self.parser.extract_title(body)
|
||||||
|
if not title:
|
||||||
|
# Use filename without extension
|
||||||
|
title = file_path.stem
|
||||||
|
return title
|
||||||
|
|
||||||
|
elif field == 'created':
|
||||||
|
return git_info.get('created')
|
||||||
|
|
||||||
|
elif field == 'changed':
|
||||||
|
return git_info.get('changed')
|
||||||
|
|
||||||
|
elif field == 'authors':
|
||||||
|
return git_info.get('authors', [])
|
||||||
|
|
||||||
|
elif field == 'version':
|
||||||
|
# Prefer latest tag, fallback to short hash
|
||||||
|
latest_tag = git_info.get('latest_tag', '')
|
||||||
|
if latest_tag:
|
||||||
|
return latest_tag
|
||||||
|
latest_hash = git_info.get('latest_hash', '')
|
||||||
|
return latest_hash[:7] if latest_hash else ''
|
||||||
|
|
||||||
|
elif field == 'tags':
|
||||||
|
tags = normalized_fm.get('tags', [])
|
||||||
|
# Return None if empty so AI inference is tried
|
||||||
|
return tags if tags else None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_discarded_metadata(self, original: dict, new: dict) -> list:
|
||||||
|
"""Check for metadata that will be discarded"""
|
||||||
|
discarded = []
|
||||||
|
for key in original.keys():
|
||||||
|
if key not in new:
|
||||||
|
discarded.append(key)
|
||||||
|
return discarded
|
||||||
|
|
||||||
|
def _describe_changes(self, original: dict, new: dict) -> str:
|
||||||
|
"""Describe changes between frontmatter versions"""
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
# New fields
|
||||||
|
for key in new.keys():
|
||||||
|
if key not in original:
|
||||||
|
changes.append(f"+{key}")
|
||||||
|
|
||||||
|
# Modified fields
|
||||||
|
for key in new.keys():
|
||||||
|
if key in original and original[key] != new[key]:
|
||||||
|
changes.append(f"~{key}")
|
||||||
|
|
||||||
|
# Removed fields
|
||||||
|
for key in original.keys():
|
||||||
|
if key not in new:
|
||||||
|
changes.append(f"-{key}")
|
||||||
|
|
||||||
|
return ', '.join(changes) if changes else 'none'
|
||||||
|
|
||||||
|
def _collect_all_tags(self):
|
||||||
|
"""Collect all unique tags from processed files and store in database"""
|
||||||
|
# Tags are collected during file processing
|
||||||
|
all_tags = self.db.get_all_tags()
|
||||||
|
print(f"Total unique tags in repository: {len(all_tags)}")
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
Git repository management
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def __init__(self, path: Path, ignore_paths: List[str] = None):
|
||||||
|
self.path = path
|
||||||
|
self.ignore_paths = ignore_paths or []
|
||||||
|
|
||||||
|
def get_tracked_files(self) -> List[Path]:
|
||||||
|
"""Get all markdown files tracked by git"""
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'ls-files', '*.md'],
|
||||||
|
cwd=self.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for line in result.stdout.strip().split('\n'):
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_path = Path(line)
|
||||||
|
|
||||||
|
# Skip hidden files/folders
|
||||||
|
if any(part.startswith('.') for part in file_path.parts):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip ignored paths
|
||||||
|
if any(str(file_path).startswith(ignored) for ignored in self.ignore_paths):
|
||||||
|
continue
|
||||||
|
|
||||||
|
files.append(self.path / file_path)
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
def get_file_history(self, file_path: Path) -> dict:
|
||||||
|
"""Get git history for a file"""
|
||||||
|
relative_path = file_path.relative_to(self.path)
|
||||||
|
|
||||||
|
# Get latest commit hash
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'log', '-1', '--format=%H', '--', str(relative_path)],
|
||||||
|
cwd=self.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
latest_hash = result.stdout.strip()
|
||||||
|
|
||||||
|
# Get first commit (creation) info
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'log', '--reverse', '--format=%H|%an|%ae', '--', str(relative_path)],
|
||||||
|
cwd=self.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
first_line = result.stdout.strip().split('\n')[0] if result.stdout.strip() else ''
|
||||||
|
if first_line:
|
||||||
|
commit_hash, author_name, author_email = first_line.split('|', 2)
|
||||||
|
else:
|
||||||
|
commit_hash = author_name = author_email = ''
|
||||||
|
|
||||||
|
# Get tag for commit if any
|
||||||
|
commit_tag = ''
|
||||||
|
if commit_hash:
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'describe', '--tags', '--exact-match', commit_hash],
|
||||||
|
cwd=self.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
commit_tag = result.stdout.strip() if result.returncode == 0 else ''
|
||||||
|
|
||||||
|
# Get tag for latest commit if any
|
||||||
|
latest_tag = ''
|
||||||
|
if latest_hash:
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'describe', '--tags', '--exact-match', latest_hash],
|
||||||
|
cwd=self.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
latest_tag = result.stdout.strip() if result.returncode == 0 else ''
|
||||||
|
|
||||||
|
# Get creation date
|
||||||
|
created = None
|
||||||
|
if commit_hash:
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'log', '-1', '--format=%aI', commit_hash, '--', str(relative_path)],
|
||||||
|
cwd=self.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
created = result.stdout.strip()
|
||||||
|
|
||||||
|
# Get last modified date
|
||||||
|
changed = None
|
||||||
|
if latest_hash:
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'log', '-1', '--format=%aI', latest_hash, '--', str(relative_path)],
|
||||||
|
cwd=self.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
changed = result.stdout.strip()
|
||||||
|
|
||||||
|
# Get all contributors
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'log', '--format=%an|%ae', '--', str(relative_path)],
|
||||||
|
cwd=self.path,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
authors = set()
|
||||||
|
for line in result.stdout.strip().split('\n'):
|
||||||
|
if line:
|
||||||
|
name, email = line.split('|', 1)
|
||||||
|
authors.add(name)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'commit_hash': commit_hash,
|
||||||
|
'author_name': author_name,
|
||||||
|
'author_email': author_email,
|
||||||
|
'commit_tag': commit_tag,
|
||||||
|
'latest_hash': latest_hash,
|
||||||
|
'latest_tag': latest_tag,
|
||||||
|
'created': created,
|
||||||
|
'changed': changed,
|
||||||
|
'authors': sorted(list(authors))
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Frontmatter rules engine
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class RulesEngine:
|
||||||
|
def __init__(self, rules_dir: Path):
|
||||||
|
self.rules_dir = rules_dir
|
||||||
|
self.rules = self._load_rules()
|
||||||
|
|
||||||
|
def _load_rules(self) -> List[dict]:
|
||||||
|
"""Load all rule files"""
|
||||||
|
rules = []
|
||||||
|
if self.rules_dir.exists():
|
||||||
|
for rule_file in self.rules_dir.glob('*.json'):
|
||||||
|
with open(rule_file, 'r', encoding='utf-8') as f:
|
||||||
|
rules.append(json.load(f))
|
||||||
|
|
||||||
|
return rules
|
||||||
|
|
||||||
|
def apply_rules(self, frontmatter: dict) -> Tuple[dict, bool]:
|
||||||
|
"""Apply all rules to frontmatter, return (modified_fm, is_conformant)"""
|
||||||
|
if not frontmatter:
|
||||||
|
return frontmatter, True
|
||||||
|
|
||||||
|
modified = dict(frontmatter)
|
||||||
|
violations = []
|
||||||
|
|
||||||
|
# Rule: lowercase keys (run first so we can check other rules)
|
||||||
|
key_changes = {}
|
||||||
|
for key in list(modified.keys()):
|
||||||
|
lower_key = key.lower()
|
||||||
|
if lower_key != key:
|
||||||
|
key_changes[key] = lower_key
|
||||||
|
violations.append('lowercase_keys')
|
||||||
|
|
||||||
|
for old_key, new_key in key_changes.items():
|
||||||
|
modified[new_key] = modified.pop(old_key)
|
||||||
|
|
||||||
|
# Rule: tag -> tags (after lowercase conversion)
|
||||||
|
if 'tag' in modified:
|
||||||
|
modified['tags'] = modified.pop('tag')
|
||||||
|
violations.append('tag_to_tags')
|
||||||
|
|
||||||
|
# Rule: summary -> description (after lowercase conversion)
|
||||||
|
if 'summary' in modified:
|
||||||
|
modified['description'] = modified.pop('summary')
|
||||||
|
violations.append('summary_to_description')
|
||||||
|
|
||||||
|
# Rule: normalize tags
|
||||||
|
if 'tags' in modified:
|
||||||
|
original_tags = modified['tags']
|
||||||
|
|
||||||
|
# Convert to list if comma-separated string
|
||||||
|
if isinstance(original_tags, str):
|
||||||
|
tags_list = [t.strip() for t in original_tags.split(',')]
|
||||||
|
violations.append('tags_format')
|
||||||
|
else:
|
||||||
|
tags_list = original_tags if isinstance(original_tags, list) else [str(original_tags)]
|
||||||
|
|
||||||
|
# Normalize each tag
|
||||||
|
normalized_tags = []
|
||||||
|
for tag in tags_list:
|
||||||
|
tag_str = str(tag)
|
||||||
|
# Replace spaces and hyphens with underscores
|
||||||
|
normalized = tag_str.replace(' ', '_').replace('-', '_')
|
||||||
|
# Convert to lowercase
|
||||||
|
normalized = normalized.lower()
|
||||||
|
# Remove invalid characters (keep only alphanumeric and underscore)
|
||||||
|
normalized = re.sub(r'[^a-z0-9_]', '', normalized)
|
||||||
|
|
||||||
|
if normalized and normalized != tag_str:
|
||||||
|
violations.append('tags_normalized')
|
||||||
|
|
||||||
|
if normalized:
|
||||||
|
normalized_tags.append(normalized)
|
||||||
|
|
||||||
|
modified['tags'] = normalized_tags
|
||||||
|
|
||||||
|
is_conformant = len(violations) == 0
|
||||||
|
return modified, is_conformant
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
Self-contained rules processor
|
||||||
|
Processes JSON-based rules without hardcoded logic
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class RulesProcessor:
|
||||||
|
def __init__(self, rules_dir: Path):
|
||||||
|
self.rules_dir = rules_dir
|
||||||
|
self.rules = self._load_rules()
|
||||||
|
|
||||||
|
def _load_rules(self) -> Dict[str, List[dict]]:
|
||||||
|
"""Load all rule files and organize by field"""
|
||||||
|
rules_by_field = {}
|
||||||
|
|
||||||
|
if self.rules_dir.exists():
|
||||||
|
for rule_file in self.rules_dir.glob('*.json'):
|
||||||
|
with open(rule_file, 'r', encoding='utf-8') as f:
|
||||||
|
rule = json.load(f)
|
||||||
|
field = rule.get('field', '*')
|
||||||
|
|
||||||
|
if field not in rules_by_field:
|
||||||
|
rules_by_field[field] = []
|
||||||
|
|
||||||
|
rules_by_field[field].append(rule)
|
||||||
|
|
||||||
|
# Sort rules by priority (lower number = higher priority)
|
||||||
|
for field in rules_by_field:
|
||||||
|
rules_by_field[field].sort(key=lambda r: r.get('priority', 100))
|
||||||
|
|
||||||
|
return rules_by_field
|
||||||
|
|
||||||
|
def apply_rules(self, frontmatter: dict) -> Tuple[dict, bool, List[str]]:
|
||||||
|
"""
|
||||||
|
Apply all rules to frontmatter
|
||||||
|
Returns: (modified_fm, is_conformant, violations)
|
||||||
|
"""
|
||||||
|
if not frontmatter:
|
||||||
|
return frontmatter, True, []
|
||||||
|
|
||||||
|
modified = dict(frontmatter)
|
||||||
|
violations = []
|
||||||
|
|
||||||
|
# Process global rules (field: "*") first
|
||||||
|
if '*' in self.rules:
|
||||||
|
for rule in self.rules['*']:
|
||||||
|
modified, rule_violations = self._apply_rule(modified, rule, None)
|
||||||
|
violations.extend(rule_violations)
|
||||||
|
|
||||||
|
# Process rename rules to create new fields
|
||||||
|
for field in list(modified.keys()):
|
||||||
|
if field in self.rules:
|
||||||
|
for rule in self.rules[field]:
|
||||||
|
if rule.get('action') == 'rename_field':
|
||||||
|
modified, rule_violations = self._apply_rule(modified, rule, field)
|
||||||
|
violations.extend(rule_violations)
|
||||||
|
|
||||||
|
# Process other field-specific rules on current fields
|
||||||
|
for field in list(modified.keys()):
|
||||||
|
if field in self.rules:
|
||||||
|
for rule in self.rules[field]:
|
||||||
|
if rule.get('action') != 'rename_field': # Skip renames, already done
|
||||||
|
modified, rule_violations = self._apply_rule(modified, rule, field)
|
||||||
|
violations.extend(rule_violations)
|
||||||
|
|
||||||
|
is_conformant = len(violations) == 0
|
||||||
|
return modified, is_conformant, violations
|
||||||
|
|
||||||
|
def _apply_rule(self, frontmatter: dict, rule: dict, field: str = None) -> Tuple[dict, List[str]]:
|
||||||
|
"""Apply a single rule to frontmatter"""
|
||||||
|
modified = dict(frontmatter)
|
||||||
|
violations = []
|
||||||
|
action = rule.get('action', 'validate')
|
||||||
|
|
||||||
|
if action == 'normalize_keys':
|
||||||
|
modified, violated = self._action_normalize_keys(modified, rule)
|
||||||
|
if violated:
|
||||||
|
violations.append(rule.get('name', 'unknown'))
|
||||||
|
|
||||||
|
elif action == 'rename_field':
|
||||||
|
modified, violated = self._action_rename_field(modified, rule)
|
||||||
|
if violated:
|
||||||
|
violations.append(rule.get('name', 'unknown'))
|
||||||
|
|
||||||
|
elif action == 'normalize_value':
|
||||||
|
if field and field in modified:
|
||||||
|
modified[field], violated = self._action_normalize_value(modified[field], rule)
|
||||||
|
if violated:
|
||||||
|
violations.append(rule.get('name', 'unknown'))
|
||||||
|
|
||||||
|
elif action == 'validate':
|
||||||
|
if field and field in modified:
|
||||||
|
violated = not self._action_validate(modified[field], rule)
|
||||||
|
if violated:
|
||||||
|
violations.append(rule.get('name', 'unknown'))
|
||||||
|
|
||||||
|
return modified, violations
|
||||||
|
|
||||||
|
def _action_normalize_keys(self, frontmatter: dict, rule: dict) -> Tuple[dict, bool]:
|
||||||
|
"""Normalize all keys using regex pattern or transform"""
|
||||||
|
pattern = rule.get('pattern', '')
|
||||||
|
replacement = rule.get('replacement', '')
|
||||||
|
transform = rule.get('transform', '')
|
||||||
|
|
||||||
|
modified = {}
|
||||||
|
violated = False
|
||||||
|
|
||||||
|
for key, value in frontmatter.items():
|
||||||
|
new_key = key
|
||||||
|
|
||||||
|
# Apply transform (e.g., lower, upper)
|
||||||
|
if transform == 'lower':
|
||||||
|
new_key = key.lower()
|
||||||
|
elif transform == 'upper':
|
||||||
|
new_key = key.upper()
|
||||||
|
elif pattern:
|
||||||
|
# Apply regex pattern
|
||||||
|
new_key = re.sub(pattern, replacement, key, flags=re.MULTILINE if rule.get('multiline') else 0)
|
||||||
|
|
||||||
|
if new_key != key:
|
||||||
|
violated = True
|
||||||
|
modified[new_key] = value
|
||||||
|
|
||||||
|
return modified, violated
|
||||||
|
|
||||||
|
def _action_rename_field(self, frontmatter: dict, rule: dict) -> Tuple[dict, bool]:
|
||||||
|
"""Rename a specific field"""
|
||||||
|
from_field = rule.get('from')
|
||||||
|
to_field = rule.get('to')
|
||||||
|
|
||||||
|
if not from_field or not to_field:
|
||||||
|
return frontmatter, False
|
||||||
|
|
||||||
|
modified = dict(frontmatter)
|
||||||
|
|
||||||
|
if from_field in modified:
|
||||||
|
modified[to_field] = modified.pop(from_field)
|
||||||
|
return modified, True
|
||||||
|
|
||||||
|
return modified, False
|
||||||
|
|
||||||
|
def _action_normalize_value(self, value: Any, rule: dict) -> Tuple[Any, bool]:
|
||||||
|
"""Normalize a value using regex pattern(s) or transform"""
|
||||||
|
pattern = rule.get('pattern', '')
|
||||||
|
replacement = rule.get('replacement', '')
|
||||||
|
transform = rule.get('transform', '')
|
||||||
|
split_on = rule.get('split_on', '')
|
||||||
|
|
||||||
|
violated = False
|
||||||
|
|
||||||
|
# Handle comma-separated string first (convert to list)
|
||||||
|
if split_on and isinstance(value, str):
|
||||||
|
items = [item.strip() for item in value.split(split_on)]
|
||||||
|
normalized = []
|
||||||
|
for item in items:
|
||||||
|
if transform == 'lower':
|
||||||
|
normalized_item = item.lower()
|
||||||
|
elif transform == 'upper':
|
||||||
|
normalized_item = item.upper()
|
||||||
|
elif pattern:
|
||||||
|
normalized_item = re.sub(
|
||||||
|
pattern,
|
||||||
|
replacement,
|
||||||
|
item,
|
||||||
|
flags=re.MULTILINE if rule.get('multiline') else 0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
normalized_item = item
|
||||||
|
|
||||||
|
if normalized_item:
|
||||||
|
normalized.append(normalized_item)
|
||||||
|
violated = True # Format changed from string to list
|
||||||
|
return normalized, violated
|
||||||
|
|
||||||
|
# Handle list values (like tags)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
normalized = []
|
||||||
|
for item in value:
|
||||||
|
item_str = str(item)
|
||||||
|
|
||||||
|
# Apply transform first
|
||||||
|
if transform == 'lower':
|
||||||
|
normalized_item = item_str.lower()
|
||||||
|
elif transform == 'upper':
|
||||||
|
normalized_item = item_str.upper()
|
||||||
|
elif pattern:
|
||||||
|
normalized_item = re.sub(
|
||||||
|
pattern,
|
||||||
|
replacement,
|
||||||
|
item_str,
|
||||||
|
flags=re.MULTILINE if rule.get('multiline') else 0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
normalized_item = item_str
|
||||||
|
|
||||||
|
if normalized_item != item_str:
|
||||||
|
violated = True
|
||||||
|
if normalized_item: # Only keep non-empty
|
||||||
|
normalized.append(normalized_item)
|
||||||
|
return normalized, violated
|
||||||
|
|
||||||
|
# Handle string values
|
||||||
|
elif isinstance(value, str):
|
||||||
|
if transform == 'lower':
|
||||||
|
normalized = value.lower()
|
||||||
|
elif transform == 'upper':
|
||||||
|
normalized = value.upper()
|
||||||
|
elif pattern:
|
||||||
|
normalized = re.sub(
|
||||||
|
pattern,
|
||||||
|
replacement,
|
||||||
|
value,
|
||||||
|
flags=re.MULTILINE if rule.get('multiline') else 0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
normalized = value
|
||||||
|
|
||||||
|
violated = normalized != value
|
||||||
|
return normalized, violated
|
||||||
|
|
||||||
|
return value, False
|
||||||
|
|
||||||
|
def _action_validate(self, value: Any, rule: dict) -> bool:
|
||||||
|
"""Validate a value against regex pattern"""
|
||||||
|
pattern = rule.get('pattern', '')
|
||||||
|
|
||||||
|
if not pattern:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Handle list values
|
||||||
|
if isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
if not re.match(pattern, str(item), flags=re.MULTILINE if rule.get('multiline') else 0):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Handle string values
|
||||||
|
if isinstance(value, str):
|
||||||
|
return bool(re.match(pattern, value, flags=re.MULTILINE if rule.get('multiline') else 0))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_llm_prompt_for_field(self, field: str) -> str:
|
||||||
|
"""Get LLM prompt text for a field if defined in rules"""
|
||||||
|
if field in self.rules:
|
||||||
|
for rule in self.rules[field]:
|
||||||
|
llm_prompt = rule.get('llm_prompt')
|
||||||
|
if llm_prompt:
|
||||||
|
return llm_prompt
|
||||||
|
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Template management with type support
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateManager:
|
||||||
|
def __init__(self, templates_dir: Path):
|
||||||
|
self.templates_dir = templates_dir
|
||||||
|
|
||||||
|
def load_template(self, name: str) -> dict:
|
||||||
|
"""Load template by name and parse field types"""
|
||||||
|
template_path = self.templates_dir / f'{name}.json'
|
||||||
|
|
||||||
|
if not template_path.exists():
|
||||||
|
# Fallback to default
|
||||||
|
template_path = self.templates_dir / 'default.json'
|
||||||
|
|
||||||
|
with open(template_path, 'r', encoding='utf-8') as f:
|
||||||
|
raw_template = json.load(f)
|
||||||
|
|
||||||
|
# Parse template to separate strategies and types
|
||||||
|
template = {}
|
||||||
|
for field, value in raw_template.items():
|
||||||
|
template[field] = value
|
||||||
|
|
||||||
|
return template
|
||||||
|
|
||||||
|
def parse_field(self, field_value: str) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Parse field value to extract strategy and type
|
||||||
|
Format: "strategy|strategy:type" or just "strategy|strategy"
|
||||||
|
|
||||||
|
Returns: (strategy, field_type)
|
||||||
|
"""
|
||||||
|
# Check if type is specified with colon
|
||||||
|
if ':' in field_value:
|
||||||
|
parts = field_value.rsplit(':', 1)
|
||||||
|
strategy = parts[0]
|
||||||
|
field_type = parts[1].strip()
|
||||||
|
else:
|
||||||
|
strategy = field_value
|
||||||
|
field_type = 'text' # Default type
|
||||||
|
|
||||||
|
return strategy, field_type
|
||||||
|
|
||||||
|
def get_structured_output_schema(self, template: dict) -> dict:
|
||||||
|
"""Generate JSON schema for structured output based on field types"""
|
||||||
|
properties = {}
|
||||||
|
required = []
|
||||||
|
|
||||||
|
for field, field_value in template.items():
|
||||||
|
strategy, field_type = self.parse_field(str(field_value))
|
||||||
|
|
||||||
|
# Map field type to JSON schema type
|
||||||
|
if field_type == 'list':
|
||||||
|
properties[field] = {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": f"Field: {field} (list of text)"
|
||||||
|
}
|
||||||
|
elif field_type == 'number':
|
||||||
|
properties[field] = {
|
||||||
|
"type": "number",
|
||||||
|
"description": f"Field: {field} (number)"
|
||||||
|
}
|
||||||
|
elif field_type == 'checkbox':
|
||||||
|
properties[field] = {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": f"Field: {field} (true/false)"
|
||||||
|
}
|
||||||
|
elif field_type in ('date', 'datetime'):
|
||||||
|
properties[field] = {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time" if field_type == 'datetime' else "date",
|
||||||
|
"description": f"Field: {field} ({field_type})"
|
||||||
|
}
|
||||||
|
else: # text or unspecified
|
||||||
|
properties[field] = {
|
||||||
|
"type": "string",
|
||||||
|
"description": f"Field: {field} (text)"
|
||||||
|
}
|
||||||
|
|
||||||
|
required.append(field)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
"required": required,
|
||||||
|
"additionalProperties": False
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
title: Neural Networks Guide
|
||||||
|
created: '2025-10-23T18:06:22+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Neural Networks Guide
|
||||||
|
|
||||||
|
A complete introduction to neural networks and deep learning.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide covers the fundamentals.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database inspection utility for Madomeda
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_database(db_path='madomeda.db'):
|
||||||
|
"""Inspect and display database contents"""
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"Database not found: {db_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Files
|
||||||
|
print("=" * 60)
|
||||||
|
print("FILES")
|
||||||
|
print("=" * 60)
|
||||||
|
cursor.execute("SELECT * FROM files ORDER BY path")
|
||||||
|
files = cursor.fetchall()
|
||||||
|
for f in files:
|
||||||
|
print(f"{f['id']:3d}. {f['path']:<40s} (discovered: {f['discovered_at']})")
|
||||||
|
|
||||||
|
# Tags
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TAGS")
|
||||||
|
print("=" * 60)
|
||||||
|
cursor.execute("SELECT tag FROM tags ORDER BY tag")
|
||||||
|
tags = cursor.fetchall()
|
||||||
|
for tag in tags:
|
||||||
|
print(f" - {tag['tag']}")
|
||||||
|
|
||||||
|
# Frontmatter summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("FRONTMATTER HISTORY")
|
||||||
|
print("=" * 60)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT f.path, fm.read_at, fm.conformant, fm.content
|
||||||
|
FROM frontmatter fm
|
||||||
|
JOIN files f ON fm.file_id = f.id
|
||||||
|
ORDER BY f.path, fm.read_at
|
||||||
|
""")
|
||||||
|
|
||||||
|
current_file = None
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
if row['path'] != current_file:
|
||||||
|
current_file = row['path']
|
||||||
|
print(f"\n{current_file}:")
|
||||||
|
|
||||||
|
content = json.loads(row['content'])
|
||||||
|
status = "CONFORMANT" if row['conformant'] else "NON-CONFORMANT"
|
||||||
|
print(f" [{row['read_at']}] {status}")
|
||||||
|
print(f" Keys: {', '.join(content.keys())}")
|
||||||
|
if 'tags' in content:
|
||||||
|
print(f" Tags: {content['tags']}")
|
||||||
|
|
||||||
|
# Commits
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("GIT COMMITS")
|
||||||
|
print("=" * 60)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT f.path, c.author_name, c.commit_hash, c.latest_hash
|
||||||
|
FROM commits c
|
||||||
|
JOIN files f ON c.file_id = f.id
|
||||||
|
ORDER BY f.path
|
||||||
|
""")
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
print(f"{row['path']}:")
|
||||||
|
print(f" Author: {row['author_name']}")
|
||||||
|
print(f" First commit: {row['commit_hash'][:7]}")
|
||||||
|
print(f" Latest commit: {row['latest_hash'][:7]}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
db_path = sys.argv[1] if len(sys.argv) > 1 else 'madomeda.db'
|
||||||
|
inspect_database(db_path)
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Madomeda - Markdown Document Metadata Manager
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from core.repository import Repository
|
||||||
|
from core.processor import FrontmatterProcessor
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Manage markdown frontmatter in git repositories')
|
||||||
|
parser.add_argument('--dir', type=Path, default=Path.cwd(), help='Git repository directory')
|
||||||
|
parser.add_argument('--template', type=str, default='default', help='Template name to use')
|
||||||
|
parser.add_argument('--ignore-paths', nargs='*', default=[], help='Relative paths to ignore')
|
||||||
|
parser.add_argument('--whatif', action='store_true', help='Dry run mode')
|
||||||
|
parser.add_argument('--no-confirm', action='store_true', help='Skip confirmation prompts')
|
||||||
|
parser.add_argument('--force', action='store_true', help='Force recreate all frontmatter')
|
||||||
|
parser.add_argument('--add-only', action='store_true', help='Only add missing fields, preserve existing keys')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
repo_path = args.dir.resolve()
|
||||||
|
if not (repo_path / '.git').exists():
|
||||||
|
print(f"Error: {repo_path} is not a git repository")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
repo = Repository(repo_path, args.ignore_paths)
|
||||||
|
processor = FrontmatterProcessor(
|
||||||
|
repo=repo,
|
||||||
|
template_name=args.template,
|
||||||
|
whatif=args.whatif,
|
||||||
|
no_confirm=args.no_confirm,
|
||||||
|
force=args.force,
|
||||||
|
add_only=args.add_only
|
||||||
|
)
|
||||||
|
|
||||||
|
processor.process()
|
||||||
|
|
||||||
|
print("\nProcessing complete!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
2025-10-23 17:38:02 (commit: 34d2d054afff9f679468f2d662bf9b8eb9754b82)
|
||||||
|
sample.md - frontmatter updated
|
||||||
|
test.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 17:38:53 (commit: 34d2d054afff9f679468f2d662bf9b8eb9754b82)
|
||||||
|
sample.md - frontmatter updated
|
||||||
|
test.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 17:39:43 (commit: 34d2d054afff9f679468f2d662bf9b8eb9754b82)
|
||||||
|
sample.md - frontmatter updated
|
||||||
|
test.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 17:40:36 (commit: 34d2d054afff9f679468f2d662bf9b8eb9754b82)
|
||||||
|
sample.md - frontmatter updated
|
||||||
|
test.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 17:56:58 (commit: 2e822d8d49367b75c628d2da98d834ff98ace9e2)
|
||||||
|
advanced.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 18:07:32 (commit: 44ed2788f806366e24d9a25c9fc092313f974a19)
|
||||||
|
guide.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 18:07:48 (commit: 44ed2788f806366e24d9a25c9fc092313f974a19)
|
||||||
|
guide.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 18:09:36 (commit: 853ace5d71a86fbadfe772fc7e6079b28185eede)
|
||||||
|
comparison.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 18:09:41 (commit: 853ace5d71a86fbadfe772fc7e6079b28185eede)
|
||||||
|
comparison.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 18:29:52 (commit: 853ace5d71a86fbadfe772fc7e6079b28185eede)
|
||||||
|
comparison.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 18:32:49 (commit: 0463d73ef927403e235bfd18ad9d202ae57ec618)
|
||||||
|
notag.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 19:23:00 (commit: ab4b95c664427f6b08bd7510fd8c925403887ea5)
|
||||||
|
ADD_ONLY_EXAMPLES.md - frontmatter updated
|
||||||
|
GETTING_STARTED.md - frontmatter updated
|
||||||
|
IMPLEMENTATION.md - frontmatter updated
|
||||||
|
PROJECT_SUMMARY.md - frontmatter updated
|
||||||
|
README.md - frontmatter updated
|
||||||
|
STRUCTURE.md - frontmatter updated
|
||||||
|
USAGE.md - frontmatter updated
|
||||||
|
VERSION_FIELD.md - frontmatter updated
|
||||||
|
ai_test.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 19:23:44 (commit: ab4b95c664427f6b08bd7510fd8c925403887ea5)
|
||||||
|
ai_test.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 19:36:41 (commit: 245530389de0de900f95411aa899306e48051bef)
|
||||||
|
ADD_ONLY_EXAMPLES.md - frontmatter updated
|
||||||
|
CONFIGURATION.md - frontmatter updated
|
||||||
|
GETTING_STARTED.md - frontmatter updated
|
||||||
|
IMPLEMENTATION.md - frontmatter updated
|
||||||
|
PROJECT_SUMMARY.md - frontmatter updated
|
||||||
|
README.md - frontmatter updated
|
||||||
|
STRUCTURE.md - frontmatter updated
|
||||||
|
USAGE.md - frontmatter updated
|
||||||
|
VERSION_FIELD.md - frontmatter updated
|
||||||
|
advanced.md - frontmatter updated
|
||||||
|
ai_test.md - frontmatter updated
|
||||||
|
comparison.md - frontmatter updated
|
||||||
|
guide.md - frontmatter updated
|
||||||
|
sample.md - frontmatter updated
|
||||||
|
test.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 19:49:57 (commit: 3cde344ac405dbbcfbf9e19faaef9177155de1b8)
|
||||||
|
rules_test.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 19:51:58 (commit: 3cde344ac405dbbcfbf9e19faaef9177155de1b8)
|
||||||
|
rules_test.md - frontmatter updated
|
||||||
|
|
||||||
|
2025-10-23 20:11:52 (commit: 7cfe9499ee208f03c0feed9dce586a7335960421)
|
||||||
|
ADD_ONLY_EXAMPLES.md - frontmatter updated
|
||||||
|
CONFIGURATION.md - frontmatter updated
|
||||||
|
GETTING_STARTED.md - frontmatter updated
|
||||||
|
IMPLEMENTATION.md - frontmatter updated
|
||||||
|
PROJECT_SUMMARY.md - frontmatter updated
|
||||||
|
README.md - frontmatter updated
|
||||||
|
STRUCTURE.md - frontmatter updated
|
||||||
|
USAGE.md - frontmatter updated
|
||||||
|
VERSION_FIELD.md - frontmatter updated
|
||||||
|
advanced.md - frontmatter updated
|
||||||
|
ai_test.md - frontmatter updated
|
||||||
|
comparison.md - frontmatter updated
|
||||||
|
guide.md - frontmatter updated
|
||||||
|
rules_test.md - frontmatter updated
|
||||||
|
sample.md - frontmatter updated
|
||||||
|
test.md - frontmatter updated
|
||||||
|
type_test.md - frontmatter updated
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
You are a metadata extraction assistant for markdown documents.
|
||||||
|
|
||||||
|
Your task is to analyze the document content and suggest appropriate frontmatter metadata.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- For tags: prefer selecting from the provided existing tags list when appropriate
|
||||||
|
- You may create new tags if they better fit the content
|
||||||
|
- New tags must follow the rules: lowercase letters, numbers, and underscores only
|
||||||
|
- Be concise and accurate
|
||||||
|
- Preserve important existing metadata when present
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
PyYAML>=6.0
|
||||||
|
openai>=1.0.0
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "lowercase_keys",
|
||||||
|
"description": "All frontmatter keys must be lowercase",
|
||||||
|
"type": "keys"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "summary_to_description",
|
||||||
|
"description": "Rename 'summary' key to 'description'",
|
||||||
|
"type": "rename"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "tag_to_tags",
|
||||||
|
"description": "Rename 'tag' key to 'tags'",
|
||||||
|
"type": "rename"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "tags_normalization",
|
||||||
|
"description": "Normalize tags to lowercase with underscores",
|
||||||
|
"type": "tags"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
title: Rules Test Document
|
||||||
|
created: '2025-10-23T19:49:52+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags:
|
||||||
|
- machine_learning
|
||||||
|
- deep_learning
|
||||||
|
- neural_networks
|
||||||
|
- aiml
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Rules Test Document
|
||||||
|
|
||||||
|
This tests the new self-contained rules system.
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
|
---
|
||||||
|
title: Sample Document
|
||||||
|
created: '2025-10-23T17:27:16+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags: []
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
# Sample Document
|
# Sample Document
|
||||||
|
|
||||||
This document has no frontmatter but should get one added.
|
This document has no frontmatter but should get one added.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"title": "heur|orig",
|
||||||
|
"created": "heur|orig",
|
||||||
|
"changed": "heur|orig",
|
||||||
|
"authors": "heur|orig",
|
||||||
|
"version": "heur|orig",
|
||||||
|
"tags": "heur|orig|ai default"
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
---
|
---
|
||||||
Title: Test Document
|
title: Test Document
|
||||||
Tag: Python, Machine-Learning, Data Science
|
created: '2025-10-23T17:27:16+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
tags:
|
||||||
|
- python
|
||||||
|
- machine_learning
|
||||||
|
- data_science
|
||||||
|
reviewers: []
|
||||||
---
|
---
|
||||||
|
|
||||||
# Test Document
|
# Test Document
|
||||||
|
|
||||||
This is a test markdown document with some content.
|
This is a test markdown document with some content.
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
title: Type Test Document
|
||||||
|
created: '2025-10-23T20:11:45+02:00'
|
||||||
|
status: draft
|
||||||
|
priority: 5
|
||||||
|
published: false
|
||||||
|
reviewers: []
|
||||||
|
---
|
||||||
|
# Type Test Document
|
||||||
|
|
||||||
|
Testing template field types.
|
||||||
Reference in New Issue
Block a user