Migrate all repos into monorepo context folders

Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.env
__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
*.db
alembic/versions/__pycache__
tests/__pycache__
+34
View File
@@ -0,0 +1,34 @@
DATABASE_URL=sqlite:///./orgmylife.db
# For Docker/production use PostgreSQL:
# DATABASE_URL=postgresql://orgmylife:changeme@db:5432/orgmylife
# PostgreSQL password (used by docker-compose)
POSTGRES_PASSWORD=changeme
# API Secret for orchestrator endpoints (leave empty for local dev)
API_SECRET=
# App login credentials (for the web UI)
APP_USERNAME=andre
APP_PASSWORD=your_login_password
# Nextcloud Configuration
NEXTCLOUD_URL=https://your-nextcloud-instance.com
NEXTCLOUD_USERNAME=your_app_username
NEXTCLOUD_PASSWORD=your_app_password
# dHive Email / IMAP Configuration
IMAP_SERVER=imap.your-email-server.com
IMAP_PORT=993
EMAIL_USERNAME=your@dhive-email.com
EMAIL_PASSWORD=your_email_password
EMAIL_WEBMAIL_URL=https://your-webmail.com
# Gmail Configuration (use App Password, not regular password)
# Generate at: https://myaccount.google.com/apppasswords
GMAIL_USERNAME=your@gmail.com
GMAIL_PASSWORD=your_app_password
# NoteGraph Configuration (Send to Notes feature)
NOTEGRAPH_TOKEN=your_notegraph_token
+37
View File
@@ -0,0 +1,37 @@
name: Backup Dismissed Tasks
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
workflow_dispatch: {}
concurrency:
group: repo-file-writes
cancel-in-progress: false
jobs:
backup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch dismissed task IDs from OrgMyLife
run: |
# Get all dismissed and completed tasks (their source_ids)
RESPONSE=$(curl -s -u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
"https://api.andreknie.de/api/tasks/dismissed")
echo "$RESPONSE" > dismissed_tasks.json
cat dismissed_tasks.json | head -c 200
- name: Commit if changed
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add dismissed_tasks.json
git diff --cached --quiet && echo "No changes" && exit 0
git commit -m "chore: backup dismissed task IDs [skip ci]"
git pull --rebase origin main || true
git push
+81
View File
@@ -0,0 +1,81 @@
name: Mark Issues for Review
on:
push:
paths:
- 'DONE_ISSUES.md'
concurrency:
group: repo-file-writes
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
mark-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Mark issues as ready for review
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const content = fs.readFileSync('DONE_ISSUES.md', 'utf8');
const issueNumbers = [...content.matchAll(/#(\d+)/g)].map(m => parseInt(m[1]));
if (issueNumbers.length === 0) {
console.log('No issue numbers found in DONE_ISSUES.md');
return;
}
for (const num of issueNumbers) {
try {
const issue = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: num,
});
if (issue.data.state === 'open') {
// Add review label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: num,
labels: ['ready-for-review'],
});
// Add a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: num,
body: '✅ Implementation complete and deployed. Ready for your review.\n\nClose this issue when you\'re satisfied with the result.',
});
console.log(`Marked #${num} as ready-for-review`);
} else {
console.log(`Issue #${num} already closed.`);
}
} catch (e) {
console.log(`Error processing #${num}: ${e.message}`);
}
}
// Clear the file after processing
fs.writeFileSync('DONE_ISSUES.md', '# Done Issues\n\n_Empty — no issues to process._\n');
- name: Commit cleared file
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add DONE_ISSUES.md
git diff --cached --quiet && exit 0
git commit -m "chore: clear DONE_ISSUES.md after processing [skip ci]"
git pull --rebase origin main || true
git push
+80
View File
@@ -0,0 +1,80 @@
name: Collect AI Tasks
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch: {}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync-tasks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch agent-ready tasks from OrgMyLife
id: fetch
run: |
curl -s -u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
"https://api.andreknie.de/api/orchestrator/tasks" > /tmp/tasks.json
cat /tmp/tasks.json | head -c 500
- name: Create GitHub Issues for new tasks
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('/tmp/tasks.json', 'utf8'));
const tasks = data.tasks || [];
if (tasks.length === 0) {
console.log('No agent-ready tasks found.');
return;
}
const existingIssues = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'ai-task',
per_page: 100,
});
const existingTitles = new Set(
existingIssues.data.map(i => i.title)
);
for (const task of tasks) {
const issueTitle = `[AI] ${task.identifier}: ${task.title}`;
if (existingTitles.has(issueTitle)) {
console.log(`Skipping (already exists): ${issueTitle}`);
continue;
}
const body = [
`**Task ID:** ${task.identifier}`,
`**Priority:** ${task.priority || 'unset'}`,
`**State:** ${task.state}`,
task.due_date ? `**Due:** ${task.due_date}` : '',
'',
'## Description',
task.description || '_No description provided._',
'',
'---',
'_Auto-created from OrgMyLife task board._',
].filter(Boolean).join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: issueTitle,
body: body,
labels: ['ai-task'],
});
console.log(`Created issue: ${issueTitle}`);
}
+57
View File
@@ -0,0 +1,57 @@
name: Update Task State on Issue Events
on:
issues:
types: [closed, labeled]
jobs:
mark-review:
if: |
github.event.action == 'labeled' &&
github.event.label.name == 'ready-for-review' &&
contains(github.event.issue.labels.*.name, 'ai-task')
runs-on: ubuntu-latest
steps:
- name: Mark task as review in OrgMyLife
run: |
TITLE="${{ github.event.issue.title }}"
TASK_ID=$(echo "$TITLE" | grep -oP 'OML-\K[0-9]+')
if [ -z "$TASK_ID" ]; then
echo "Could not extract task ID from: $TITLE"
exit 0
fi
echo "Marking task $TASK_ID as review..."
curl -s -X POST \
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
-H "Content-Type: application/json" \
-d '{"status": "review"}' \
"https://api.andreknie.de/api/orchestrator/tasks/${TASK_ID}/state"
mark-done:
if: |
github.event.action == 'closed' &&
contains(github.event.issue.labels.*.name, 'ai-task')
runs-on: ubuntu-latest
steps:
- name: Mark task as completed in OrgMyLife
run: |
TITLE="${{ github.event.issue.title }}"
TASK_ID=$(echo "$TITLE" | grep -oP 'OML-\K[0-9]+')
if [ -z "$TASK_ID" ]; then
echo "Could not extract task ID from: $TITLE"
exit 0
fi
echo "Marking task $TASK_ID as completed..."
curl -s -X POST \
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
-H "Authorization: Bearer ${{ secrets.ORGMYLIFE_API_SECRET }}" \
-H "Content-Type: application/json" \
-d '{"status": "completed"}' \
"https://api.andreknie.de/api/orchestrator/tasks/${TASK_ID}/state"
+49
View File
@@ -0,0 +1,49 @@
name: Deploy to VPS
on:
push:
branches: [main]
workflow_dispatch: {}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Write .env file to VPS
uses: appleboy/ssh-action@v1
env:
ENV_CONTENT: |
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }}
API_SECRET=${{ secrets.ENV_API_SECRET }}
APP_USERNAME=${{ secrets.ENV_APP_USERNAME }}
APP_PASSWORD=${{ secrets.ENV_APP_PASSWORD }}
NEXTCLOUD_URL=${{ secrets.ENV_NEXTCLOUD_URL }}
NEXTCLOUD_USERNAME=${{ secrets.ENV_NEXTCLOUD_USERNAME }}
NEXTCLOUD_PASSWORD=${{ secrets.ENV_NEXTCLOUD_PASSWORD }}
IMAP_SERVER=${{ secrets.ENV_IMAP_SERVER }}
IMAP_PORT=993
EMAIL_USERNAME=${{ secrets.ENV_EMAIL_USERNAME }}
EMAIL_PASSWORD=${{ secrets.ENV_EMAIL_PASSWORD }}
EMAIL_WEBMAIL_URL=${{ secrets.ENV_EMAIL_WEBMAIL_URL }}
GMAIL_USERNAME=${{ secrets.ENV_GMAIL_USERNAME }}
GMAIL_PASSWORD=${{ secrets.ENV_GMAIL_PASSWORD }}
with:
host: 217.160.174.2
username: root
key: ${{ secrets.VPS_SSH_KEY }}
envs: ENV_CONTENT
script: |
# Ensure directory exists
if [ ! -d /opt/OrgMyLife ]; then
cd /opt
git clone https://github.com/DoctoDre/OrgMyLife.git || true
fi
cd /opt/OrgMyLife
git pull origin main || true
# Write .env from environment variable (safe for special chars)
printf '%s\n' "$ENV_CONTENT" > .env
docker compose up -d --build
echo "Deploy complete"
@@ -0,0 +1,68 @@
name: Sync Issues to File
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch: {}
issues:
types: [opened, closed, reopened, edited, labeled, unlabeled]
concurrency:
group: repo-file-writes
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Write open AI issues to file
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const issues = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'ai-task',
per_page: 100,
});
let content = '# AI Tasks (auto-generated)\n\n';
content += `_Last updated: ${new Date().toISOString()}_\n\n`;
if (issues.data.length === 0) {
content += 'No open AI tasks.\n';
} else {
content += `${issues.data.length} open task(s):\n\n`;
for (const issue of issues.data) {
content += `---\n\n`;
content += `## #${issue.number}: ${issue.title}\n\n`;
content += `- **State:** open\n`;
content += `- **Created:** ${issue.created_at}\n`;
content += `- **URL:** ${issue.html_url}\n\n`;
content += `### Description\n\n`;
content += `${issue.body || '_No description._'}\n\n`;
}
}
fs.writeFileSync('AI_TASKS.md', content);
console.log(`Wrote ${issues.data.length} issues to AI_TASKS.md`);
- name: Commit and push if changed
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add AI_TASKS.md
git diff --cached --quiet && echo "No changes" && exit 0
git commit -m "chore: update AI_TASKS.md [skip ci]"
git pull --rebase origin main || true
git push
@@ -0,0 +1,35 @@
name: Sync OrgMyLife Plan to Projects Board
on:
push:
paths: ['PLAN.md']
workflow_dispatch: {}
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Parse open tasks and sync to Projects board
run: |
REPO_NAME="OrgMyLife"
TASKS="[]"
if [ -f "PLAN.md" ]; then
ITEMS=$(grep -E '^\s*- \[ \]' PLAN.md | sed 's/.*- \[ \] //' | head -50)
if [ -n "$ITEMS" ]; then
TASKS=$(echo "$ITEMS" | jq -R -s 'split("\n") | map(select(length > 0)) | map({"title": ., "status": "backlog"})')
fi
fi
echo "Found tasks: $TASKS"
PAYLOAD=$(jq -n --arg repo "$REPO_NAME" --argjson tasks "$TASKS" '{"repo": $repo, "tasks": $tasks}')
curl -s -X POST \
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
-H "X-API-Key: ${{ secrets.ORGMYLIFE_API_SECRET }}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"https://api.andreknie.de/api/projects/sync"
+6
View File
@@ -0,0 +1,6 @@
.env
venv/
__pycache__/
*.pyc
*.pyo
*.db
@@ -0,0 +1,48 @@
---
inclusion: auto
---
# OrgMyLife Standards
## Quality Gates (before every commit)
1. `gitleaks detect --source . --no-git` — no secrets in code
2. `ruff check .` — linting passes (if configured)
3. `pytest` — all tests pass
4. No hardcoded secrets in source
## Conventional Commits
```
feat(sync): add Gmail label filtering
fix(api): handle empty task list gracefully
test(digest): add test for overdue calculation
docs(spec): update component descriptions
```
## Testing
- pytest for unit and integration tests
- httpx TestClient for API endpoint tests
- Given-When-Then structure
- 80% coverage target for new code
## Autonomous Mode
- Never push directly to main — use feature branches
- Create PRs on GitHub targeting main
- If stuck after 3 attempts: stop and document
- Report: `PROGRESS: X%` / `HELP: description`
## Architecture
- FastAPI + SQLAlchemy + PostgreSQL
- Alembic for migrations
- MCP server for Kiro integration
- Sync services: Nextcloud (CalDAV/ToDo), Gmail (IMAP), Backlog.md
## Deployment
- Docker on VPS (https://api.andreknie.de)
- `docker compose up -d --build` for deploy
- .env file on VPS for secrets (not in repo)
+122
View File
@@ -0,0 +1,122 @@
# AGENTS.md
---
## Coding rules
1. Read `SPEC.md` first and treat it as source of truth ("the spec"). If `SPEC.md` does not exist, **stop and ask**.
2. Follow the user prompt exactly; do not omit explicitly requested steps.
3. **Avoid over-engineering.** Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused:
- Scope: Don't add features, refactor code, or make "improvements" beyond what was asked. A bug fix doesn't need surrounding code cleaned up. A simple feature doesn't need extra configurability.
- Documentation: Don't add docstrings, comments, or type annotations to code you didn't change. Only add comments where the logic isn't self-evident.
- Defensive coding: Don't add error handling, fallbacks, or validation for scenarios that can't happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs).
- Abstractions: Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task.
- Custom coding: Don't reinvent the wheel, use the functions the framework provides you with. If you think that using framework is not possible, consult the relevant online or MCP docs to make sure. If you still cannot find a solution within the framework, **stop and ask**.
4. Do not hard-code values or create solutions that only work for specific test inputs; implement the actual logic that solves the problem generally.
5. The resulting code must be robust, maintainable, and extendable.
6. The resulting code must not contradict the spec.
7. **Before writing code**, post a short and concise "Plan + spec mapping" summary and **ask for approval to proceed**. Do not implement until you receive the go-ahead.
8. Documentation Rules:
- filenames are always capitalized.
- describes the **current state only**. Do not include change/history phrasing.
- must always be coherent end-to-end never have stale or conflicting info.
9. Always use repo virtual environment (unless there is none).
---
## Documentation MCP policy
- We run ONE docs MCP server: `docs` (multi-library).
### Library selection
- For any docs query call (everything except `list_libraries`), you MUST set `library`.
- If the prompt or repo context involves a specific product/project unambiguously, use that as `library` (e.g. `library="haystack"`).
- If more than one library could plausibly match, call `list_libraries` and select the best match (do not invent/guess library ids).
- If the task spans multiple products, run separate docs calls per library and label results by library.
### Version selection
- If the prompt or repo context involves a version or range (e.g. `2.25`, `2.x`), call `find_version(library=..., targetVersion=...)` and record the returned `version`.
- Use that resolved `version` for all subsequent calls for that library.
- If `find_version` cannot resolve unambiguously, **stop and ask**.
- If no version is mentioned, omit `version` (defaults to latest indexed for that library).
- We also run the `github` MCP server. Consult it always for code that is in public github instead of searching at the disk or cloning the repos locally.
---
## Base branch rule
**BASE_BRANCH = the branch that new work branches are created from and PRs target.**
Default:
- If the user did not explicitly specify a base/target branch, set:
- `BASE_BRANCH=$(git branch --show-current)` (from the user's main checkout at session start)
Override:
- If the user explicitly specifies a base/target branch in the prompt, use that as `BASE_BRANCH`.
Remote base ref:
- If `origin/$BASE_BRANCH` exists, use that as `BASE_REF`, else use `$BASE_BRANCH`:
- `git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH" && BASE_REF="origin/$BASE_BRANCH" || BASE_REF="$BASE_BRANCH"`
Notes:
- This supports long-lived feature integration branches and stacked PRs.
- The base branch may itself be another PR branch (for stacking); set BASE_BRANCH accordingly.
---
## Before work
1. Determine base branch (unless user specified it explicitly):
- `BASE_BRANCH=$(git branch --show-current)`
2. Fetch latest refs:
- `git fetch origin --prune`
3. Update base branch (fast-forward only) if it has an `origin/` tracking ref:
- `git switch "$BASE_BRANCH"`
- `if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then git pull --ff-only origin "$BASE_BRANCH"; fi`
- If the pull fails for any reason, stop and ask.
4. Read `SPEC.md` + `README.md` once per session. Re-read only if changed/unsure:
- `git log -1 --oneline -- SPEC.md README.md`
5. If deviating from `SPEC.md`: **stop and ask**.
6. Create a new local work branch (once per session) **from BASE_BRANCH**. **ALWAYS push to the branch you created.**
- `git switch -c "agent/<topic>-YYYYMMDD-<shortid>"`
## Tests
- Run tests only if the prompt requests or `SPEC.md` requires them; run all requested tests.
- If any cannot be run: **stop and ask**.
## After work
1. Make sure you did not miss requested tests.
2. If you are addressing a **remote github issue** with
- "to-do" checklist: for **each** item, make sure your implementation is **fully complete** and:
- if yes, check it off
- if not, finish implementation and recheck.
- "acceptance" checklist: for **each** item, make the acceptance criterion is **completely fulfilled** and:
- if yes, check it off
- if not, implement missing code and recheck.
3. Update all relevant documentation appropriately.
4. **Before making a commit**: Stop and ask user to `/review``Review uncommited changes`.
5. Commit **only after** user confirms code review passed.
6. Open a PR or use already opened PR
- **use your created branch**.
- **Target `BASE_BRANCH`**.
- PR text requirements:
- commands in backticks
- real newlines (ANSI-C quoting/heredoc)
- if addressing a **remote github issue**, reference it so that github can close automatically after merging.
7. **Only after** PR is merged:
- switch back to base branch and sync it
- `git switch "$BASE_BRANCH"`
- `git fetch origin --prune`
- `if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then git reset --hard "origin/$BASE_BRANCH"; fi`
- If the reset fails, **stop and ask**.
- delete the local merged work branch
---
## Required completion checklist (final response)
- [ ] Base/target branch used correctly (BASE_BRANCH)
- [ ] **all** Coding rules followed
- [ ] All requested tests run (commands + results), or blocked → asked
- [ ] Documentation updated appropriately: **list updated files**
- [ ] PR opened (summary + command log) targeting BASE_BRANCH
- [ ] After merge (if applicable): base branch synced; merged branch deleted
---
+125
View File
@@ -0,0 +1,125 @@
# AI Tasks (auto-generated)
_Last updated: 2026-05-21T04:53:17.650Z_
6 open task(s):
---
## #23: [AI] OML-460: Silverbullet frontend
- **State:** open
- **Created:** 2026-05-15T19:59:08Z
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/23
### Description
**Task ID:** OML-460
**Priority:** 2
**State:** open
**Due:** 2026-05-17T00:00:00+00:00
## Description
Ich brauche eine Suchfunktion, eine schnelle neue Notiz-Funktion, eine übersichtliche Darstellung der Notizen mit zeitlicher suche
---
_Auto-created from OrgMyLife task board._
---
## #22: [AI] OML-459: Call list entries bearbeitbar machen
- **State:** open
- **Created:** 2026-05-15T18:01:57Z
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/22
### Description
**Task ID:** OML-459
**Priority:** 4
**State:** open
**Due:** 2026-05-31T00:00:00+00:00
## Description
Calls nachträglich mit Informationen anreichern wäre sehr praktisch.
---
_Auto-created from OrgMyLife task board._
---
## #21: [AI] OML-458: Tasks als Basis für notes ermöglichen
- **State:** open
- **Created:** 2026-05-15T18:01:57Z
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/21
### Description
**Task ID:** OML-458
**Priority:** 3
**State:** open
**Due:** 2026-05-18T00:00:00+00:00
## Description
Neuer knopf im Taskboard an notes senden. Möglichkeit finden Duplikate zu vermeiden.
---
_Auto-created from OrgMyLife task board._
---
## #20: [AI] OML-457: Übertrage das aktuelle lernen aus den Agenten automatisch in notegraph
- **State:** open
- **Created:** 2026-05-15T18:01:56Z
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/20
### Description
**Task ID:** OML-457
**Priority:** 3
**State:** open
**Due:** 2026-05-18T00:00:00+00:00
## Description
Überlege, wie alle Agenten dazu enabled werden gewonnenes wissen in die notegraph zu bekommen. Die wesentlichen Specs und alles an knowledge sollte darin enthalten sein und beim Aufräumen auch dort abgelegt werden.
---
_Auto-created from OrgMyLife task board._
---
## #19: [AI] OML-394: Jury-Auswertungstabelle und Bewertungsbogen
- **State:** open
- **Created:** 2026-05-14T09:14:38Z
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/19
### Description
**Task ID:** OML-394
**Priority:** 4
**State:** open
## Description
Lieber André,
wie besprochen Bewertungsbogen und Auswertungstabelle für die Jurysitzung.
Die Jurynamen und Top11 sind bereits eingetragen.
Melde dich jederzeit, wenn du noch etwas brauchst. Wäre cool, wenn du eine digitale Lösung schaffst. Sonst sind wir in diesem Jahr nochmal per Stift unt
---
_Auto-created from OrgMyLife task board._
---
## #18: [AI] OML-423: Supabase von kiq project entfernen
- **State:** open
- **Created:** 2026-05-14T09:14:38Z
- **URL:** https://github.com/DoctoDre/OrgMyLife/issues/18
### Description
**Task ID:** OML-423
**Priority:** 2
**State:** open
**Due:** 2026-05-17T00:00:00+00:00
## Description
_No description provided._
---
_Auto-created from OrgMyLife task board._
+17
View File
@@ -0,0 +1,17 @@
# ARCHITECTURE.md
## System Architecture
**Product:** OrgMyLife
**Goal:** A self-sufficient personal coordination system aggregating life domains (calendars, emails, notes, tasks) with a tidy UI, prioritizing local control over external dependencies.
## Technology Stack
- **Backend:** Python (e.g., FastAPI) - Chosen for self-sufficiency and ease of future AI/LLM integration.
- **Database:** PostgreSQL (self-hosted/local).
- **Frontend:** Standard web technologies (HTML/CSS/JS) - Focus on a tidy, clean, functional structure before implementing fancy designs.
## Integration Strategy
Data aggregation will be implemented in the following order:
1. **Nextcloud:** Initial proof of concept for calendar and notes aggregation.
2. **Gmail:** Email aggregation and initial task identification.
3. **Outlook:** Handled last due to specialized security and enterprise protection requirements.
+27
View File
@@ -0,0 +1,27 @@
| ToDo | Details | Ansprechpartner | Quelle | Deadline | Priorität | Umfang |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| Bewerbung OFK2 DB Regio | | | signal | 05.05.26 | hoch | 1h |
| Angebot RWK | Thomas Fabich | Zettel/Gespräch | | 03.05.26 | hoch | |
| CV Rotari | Josef | Email | | 03.05.26 | hoch | |
| Rechnung Uni Kassel | Manu | | | 03.05.26 | hoch | |
| Rechnung Schlachthof | | | | 03.05.26 | Mittel | |
| Termine nächste Wochen klären | | | | 03.05.26 | hoch | |
| Folien Digitalisierungsforum | | | | 03.05.26 | sehr hoch | |
| Projekte UKS Anlegen | | | | zeitnah | | |
| BCIS Anfragen Sales | | | | naechste Woche | | |
| Mit Andrea weiteres Vorgehen klären | Andrea Schlachthof | | | naechste Woche | | |
| Steuerberater bezahlen | | | | asap | | |
| Podcast aufnehmen | Lasse | | | 03.05.26 | terminiert | |
| KIQ Pitchdeck aufbauen | | | | asap | | |
| KIQ Termin mit Deck 4 machen | | | | asap | | |
| dHive Homepage | | | | asap | | |
| Termin mit Alex über Zukunft machen | | | | Mai 26 | | |
| Dhive Zukunft durchdenken | | | | asap | | |
| Hautscreening Termin machen | | | | 2026 | | |
| Augenarzt Termin machen | | | | Juni/Juli | | |
| Vitamin D testen lassen | | | | Juni/Juli | | |
| Orga Setup klären, am besten Haupteingangskanal Email | eigene Email adresse? | | | asap | | |
| Zahnreinigung vereinbaren | | | | 2026 | | |
| KanBan Board für Thanasis aufbauen | | | | Mai 26 | | |
| Habil Plan machen | | | | Sommer 26 | | |
| Buch Plan machen (KI, New Work (#Einfachbahn), Science Fiction) | | | | Sommer 26 | | |
+24
View File
@@ -0,0 +1,24 @@
# COMPONENTS.md
## Project Components
*This document tracks all specific components used in the project.*
> **Rule:** Components are only added to this list once they have been actively included and utilized in the codebase.
### Backend Core
- **FastAPI**: Main web framework for the API backend.
- **Uvicorn**: ASGI web server implementation.
- **SQLAlchemy**: SQL toolkit and Object-Relational Mapper (ORM).
- **Alembic**: Database migration tool for SQLAlchemy.
- **Psycopg2**: PostgreSQL database adapter for Python.
- **caldav**: Library for CalDAV client functionality (Nextcloud sync).
- **python-multipart**: Form data parsing for login page.
- **python-dotenv**: Environment variable loading from .env files.
### Infrastructure
- **Docker**: Containerization for app and database.
- **PostgreSQL 16**: Primary database (Docker container with persistent volume).
- **Caddy**: Reverse proxy with automatic HTTPS (Let's Encrypt).
### Frontend
- **Vanilla HTML/CSS/JS**: No framework, clean and lightweight.
+180
View File
@@ -0,0 +1,180 @@
# Deployment Guide — OrgMyLife on a VPS
## Architecture (Multi-Machine)
```
┌─────────────────┐ ┌──────────────────────────────┐
│ Your Laptop │ │ VPS (e.g., IONOS) │
│ ───────────── │ │ ────────────────── │
│ AI-Orchestrator│────▶│ OrgMyLife (Docker) │
│ (polls API) │ │ PostgreSQL (Docker) │
│ │ │ https://api.knie.email │
└─────────────────┘ └──────────────────────────────┘
│ │
│ │ syncs with
▼ ▼
┌─────────────────┐ ┌──────────────────────────────┐
│ Other machines │ │ Nextcloud (existing) │
│ (API clients) │ │ Calendar + ToDo persistence │
└─────────────────┘ └──────────────────────────────┘
```
## Option 1: IONOS VPS (Recommended — 3-5€/month)
### 1. Get a VPS
- Go to IONOS Cloud or VPS section
- Pick the smallest Linux VPS (1 vCPU, 1GB RAM is enough)
- Choose Ubuntu 22.04 or Debian 12
- Note the IP address
### 2. Point your domain
Since you own `andreknie.de`, add a DNS record:
- `A` record: `api.andreknie.de` → your VPS IP (`217.160.174.2`)
### 3. SSH into the VPS and install Docker
```bash
ssh root@YOUR_VPS_IP
# Install Docker
curl -fsSL https://get.docker.com | sh
# Install docker-compose
apt install docker-compose-plugin
```
### 4. Deploy OrgMyLife
```bash
# Clone your repo
git clone https://github.com/DoctoDre/OrgMyLife.git
cd OrgMyLife
# Create .env with your real credentials
cp .env.example .env
nano .env
# Fill in:
# POSTGRES_PASSWORD=<strong random password>
# API_SECRET=<strong random token for orchestrator auth>
# NEXTCLOUD_URL, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD
# IMAP_SERVER, EMAIL_USERNAME, EMAIL_PASSWORD
# Start everything
docker compose up -d
# Check it's running
docker compose logs -f app
```
### 5. Add HTTPS with Caddy (reverse proxy)
```bash
# Install Caddy
apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install caddy
```
Create `/etc/caddy/Caddyfile`:
```
api.andreknie.de {
reverse_proxy localhost:8000
}
```
```bash
systemctl restart caddy
```
Caddy automatically gets a Let's Encrypt certificate. Your API is now at `https://api.andreknie.de`.
### 6. Configure AI-Orchestrator on your machines
Update `WORKFLOW.md` on any machine:
```yaml
tracker:
kind: orgmylife
endpoint: https://api.andreknie.de
api_key: $ORGMYLIFE_API_SECRET
```
Set the env var:
```bash
export ORGMYLIFE_API_SECRET=<same token you put in .env on the VPS>
```
---
## Option 2: Run Locally with Docker (No Server Needed)
If you just want multi-machine via your home network or Tailscale:
```bash
cd OrgMyLife
# Add to your .env:
# POSTGRES_PASSWORD=localdev
# API_SECRET= (empty = no auth, fine for local)
docker compose up -d
```
Access from other machines on your network at `http://YOUR_IP:8000`.
For access outside your network without a VPS, use **Tailscale** (free):
- Install Tailscale on all your machines
- Access OrgMyLife at `http://your-tailscale-ip:8000`
---
## Option 3: IONOS Managed Domain Only (No VPS)
If you really don't want a VPS yet, you can use a free tier service:
- **Railway.app** — free tier, deploy from GitHub, auto-HTTPS
- **Render.com** — free tier with PostgreSQL
- **Fly.io** — free tier, deploy Docker containers
These all support Docker and can connect to your `knie.email` domain via CNAME records.
---
## Nextcloud as Persistence Layer
Regardless of where OrgMyLife runs, Nextcloud stays the durable backend:
1. OrgMyLife syncs tasks FROM Nextcloud (import)
2. OrgMyLife syncs changes BACK to Nextcloud (two-way)
3. If OrgMyLife goes down, your tasks are still in Nextcloud
4. If you rebuild OrgMyLife, just re-sync from Nextcloud
This means Nextcloud is your backup and your mobile access (Nextcloud Tasks app on phone).
---
## Periodic Sync (Cron)
Add automatic syncing so OrgMyLife stays fresh:
```bash
# Add to crontab on the VPS (or as a Docker healthcheck)
# Sync every 5 minutes
*/5 * * * * curl -s -X POST http://localhost:8000/api/sync/nextcloud-todo
*/5 * * * * curl -s -X POST http://localhost:8000/api/sync/email
```
Or add a background scheduler inside the app (future enhancement).
---
## Security Checklist
- [ ] Strong `POSTGRES_PASSWORD` (use `openssl rand -hex 32`)
- [ ] Strong `API_SECRET` (use `openssl rand -hex 32`)
- [ ] HTTPS enabled (Caddy handles this automatically)
- [ ] Firewall: only ports 80, 443, 22 open on VPS
- [ ] Don't expose PostgreSQL port (5432) to the internet
- [ ] Keep `.env` out of git (already in .gitignore)
+3
View File
@@ -0,0 +1,3 @@
# Done Issues
_Empty — no issues to process._
+31
View File
@@ -0,0 +1,31 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies for psycopg2 and lxml
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
libxml2-dev \
libxslt-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create data directory for SQLite (if used)
RUN mkdir -p /data
# Expose the API port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD python -c "import httpx; r = httpx.get('http://localhost:8000/api/health'); assert r.status_code == 200"
# Run with uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+51
View File
@@ -0,0 +1,51 @@
# PLAN.md
## Current Phase: Live & Operational
### Completed ✅
- [x] Python backend (FastAPI) + PostgreSQL (Docker)
- [x] Frontend (HTML/CSS/JS) with Dashboard, Tasks, Digest, Retro, Projects tabs
- [x] Nextcloud Calendar sync (CalDAV, Berlin timezone)
- [x] Nextcloud ToDo sync (two-way, own calendars only, respects local status)
- [x] dHive Email/IMAP sync (IONOS, archive to Archiv/2026, delete to Papierkorb)
- [x] Gmail sync (App Password, archive/delete both directions)
- [x] Auto-sync every 5 minutes (background scheduler)
- [x] Auto-dismiss emails archived/deleted externally
- [x] Session-based login (7-day, DB-persistent, survives deploys)
- [x] Task sorting (priority, due date, created, manual)
- [x] Task filtering (by origin, by agent_ready)
- [x] Delete (✕ dismiss/archive), Done (✓ complete/archive), Purge (🗑 trash/remove)
- [x] Move to top/bottom arrows
- [x] Due-date editing in UI
- [x] Email sender shown below title
- [x] Origin labels with clickable links to source app
- [x] Prioritization Engine (rule-based suggestions on dashboard)
- [x] Daily Digest page (overdue, due today, top priorities, schedule)
- [x] Weekly Retrospective page (completed vs created, backlog health)
- [x] Projects Kanban board (6 columns, multi-repo sync)
- [x] AI-Orchestrator with OrgMyLife tracker adapter
- [x] GitHub Actions auto-deploy (push → VPS)
- [x] AI task pipeline (🤖 → GitHub Issue → implement → review → close)
- [x] Dismissed tasks backup (survives VPS resets)
- [x] Multi-repo project sync (PLAN.md → Projects board)
- [x] Confluence Bot standalone repo
- [x] Mobile-responsive layout
- [x] Input validation and security fixes
- [x] Implement Call List feature (📞 tab, phone number extraction, daily digest integration)
### Short-Term Goals (Next Session)
- [ ] Fix Git Action sync error (race condition on DONE_ISSUES.md)
- [ ] Verify Gmail archive works end-to-end after latest fix
- [ ] Add DB GitLab (project-audit) sync via GitLab CI
### Medium-Term Goals
- [ ] Add conflict resolution for two-way sync
- [ ] Add test coverage (sync services, API endpoints)
- [ ] Implement task labels/tags system
- [ ] Add Prometheus metrics / structured JSON logging
### Long-Term Goals
- [ ] Integrate Outlook (enterprise security constraints)
- [ ] Implement Knowledge Graph / Notes Organization
- [ ] Develop Availability Sharing for external booking
- [ ] Support multi-user roles (family, colleagues)
+10
View File
@@ -0,0 +1,10 @@
# RULES.md
## Foundational Rules
1. **No documents outside the folder:** All project-related documents and files must reside strictly within this repository folder.
2. **Keep documentation concise:** Documentation should be brief, clear, and to the point.
3. **Single source of truth:** Use single, dedicated documents for specific types of information to avoid fragmentation and redundancy.
- Use `ARCHITECTURE.md` for all architectural information.
- Use `PLAN.md` for ongoing steps, which must be revised after each PR.
- Use `COMPONENTS.md` to track all specific components used in the project.
4. **Document Components After Inclusion:** Only add components to `COMPONENTS.md` once you have actually included them in the code.
+355
View File
@@ -0,0 +1,355 @@
# SPEC.md
## 1. Executive Summary
**Product Name:** OrgMyLife
**Stakeholder Role:** Developer and primary user
**Product Type:** New product
**Goal (one sentence):**
Create a personal coordination system that aggregates life domains, proposes prioritized actions, and reduces mental load while increasing quality of life.
**Business Context:**
The stakeholder manages multiple professional roles (DB, dHive), personal life, and commitments across fragmented tools (calendars, emails, notes, task systems), leading to coordination overhead and missed prioritization.
**Primary Target Users:**
* Initially: single primary user (self)
* Later: family members and dHive colleagues
**Core Functional Scope Summary:**
The system consolidates calendars, emails, notes, and task sources, identifies actionable items, proposes prioritization, and interacts with the user through daily digests and weekly retrospectives. It supports user-confirmed execution of actions such as scheduling, task creation, and restructuring of information.
**In Scope:**
* Aggregation of multiple calendars, email systems, and note sources
* Central task identification and management
* Suggestion-driven prioritization
* Daily and weekly guided interactions
* User-confirmed execution of planning actions
* Visibility into key planning data (tasks, deadlines, conflicts, priorities)
**Out of Scope:**
* Fully autonomous decision-making without user confirmation
* Technical implementation details
* Final definition of tool-specific integration rules (to be defined later)
**Main Open Questions:**
* Role-specific differences for future users (family, colleagues)
* Exact definition of task lifecycle and statuses
* Level of automation for time tracking analysis
* Boundaries of knowledge graph usage and structure
* Handling of sensitive/private calendar data across contexts
**Main Acceptance Indicators:**
* Reduced perceived mental load
* Increased feeling of control
* Regular engagement with daily and weekly flows
* Fewer forgotten tasks and missed priorities
* Measurable increase in time for sport and personal life
---
## 2. Goal
**One-sentence goal:**
Reduce coordination overhead by centralizing, structuring, and actively guiding personal and professional planning.
**Detailed goal:**
OrgMyLife aims to unify fragmented information sources (calendars, emails, notes, task systems) into a single coordination layer that not only aggregates data but actively assists in prioritization and execution. The system should act as a proactive assistant that continuously surfaces relevant actions, identifies conflicts, and guides the user through structured planning and reflection routines.
**Business value:**
* Increased productivity through better prioritization
* Reduced manual coordination effort
* Improved work-life balance
* Better utilization of time and energy
**Intended outcome:**
Users experience clarity, control, and reduced cognitive load while maintaining or increasing output quality and personal well-being.
---
## 3. Stakeholder Context
* **Respondent role:** Developer and primary user
* **Perspective:** Individual productivity and life management
* **Context:** Multiple jobs (DB, dHive), family life, sports, volunteering
* **Scope:** This specification reflects a single stakeholder interview
---
## 4. Target Users
### Primary User
* Individual managing multiple life domains
* Needs centralized visibility and guidance
* Experiences fragmentation and overload
### Future Users
* Family members
* dHive colleagues
### Needs
* Clear prioritization
* Reliable overview of commitments
* Reduced manual coordination
* Guided planning routines
### Pain Points
* Fragmented tools and data sources
* Missing synchronization
* Forgotten tasks
* Lack of centralized task management
* No structured prioritization process
* Insufficient planning for personal time (e.g., sports)
### Differences
* Primary user: full control and configuration
* Future users: likely limited visibility and permissions (to be defined)
---
## 5. Current Situation / Current Process
### Current Setup
* Multiple calendars: Outlook (DB), Google (private/family), Nextcloud (dHive)
* Multiple email systems: Outlook 365, Gmail, Nextcloud, GMX
* Notes across various systems (including Confluence, Jira, loose notes)
* Mattermost used without structured task management
* Separate time tracking application
### Workarounds
* Manual tracking and coordination
* Ad-hoc prioritization
* Informal note-taking
### Main Gaps
* Incomplete or broken synchronization
* Lack of central task list
* Missing buffer times
* No systematic prioritization
* No analysis of time tracking data
* High manual effort
### Failure Points
* Tasks forgotten
* Deadlines missed or recognized too late
* Conflicting or invisible calendar entries
* Under-planning of personal priorities (e.g., sports)
---
## 6. Functional Scope
### Core Capabilities
1. **Aggregation Layer**
* Combine multiple calendars, email systems, and note sources
* Provide unified visibility
2. **Task Identification**
* Extract tasks from emails and notes
* Consolidate into a central task system
3. **Prioritization Support**
* Suggest prioritization based on:
* urgency
* importance hierarchy (family > DB/dHive critical > customer potential > personal interest)
* energy level
4. **Planning Assistance**
* Suggest scheduling (e.g., sport, tasks, buffer times)
* Detect conflicts and gaps
5. **Interactive Guidance**
* Daily digest:
* overview of the day
* new important items
* Weekly retrospective:
* structured reflection
* prioritization review
6. **Action Execution (User-confirmed)**
* Create/modify calendar events
* Create and manage tasks
* Block time slots
* Send invitations
* Organize notes and maintain knowledge graph
7. **Availability Sharing**
* Provide selected free time slots for external booking
8. **Transparency Layer**
* Show:
* full calendar overview
* open tasks
* top 5 priorities
* deadlines
* conflicts
* origin of suggestions
---
## 7. Business Rules
* The system must not act autonomously without user confirmation
* All suggested actions require explicit approval
* Exceptions: explicitly commanded actions may be executed directly
* All rules must be visible and modifiable by the user
* The system should actively ask clarifying questions when ambiguity exists
* Prioritization follows defined hierarchy but allows subjective override
* The system must explain its suggestions (traceability of origin)
---
## 8. Business Objects / Functional Data Objects
* **Task**
* Represents actionable work item
* Has priority, status, origin
* **Calendar Event**
* Represents scheduled commitment
* May originate from different systems
* **Time Slot**
* Represents available or blocked time
* **Suggestion**
* System-generated proposal (e.g., reschedule, prioritize)
* Includes explanation/source
* **Reminder**
* Unscheduled but relevant item
* **Note / Knowledge Object**
* Structured or unstructured information
* Connected via knowledge graph
* **Priority Item (Top 5)**
* High-level focus topics
---
## 9. In Scope
* Multi-source aggregation
* Central task management
* Suggestion-based prioritization
* Daily and weekly interaction formats
* User-controlled execution of actions
* Conflict detection and visibility
* Knowledge organization support
---
## 10. Out of Scope
* Fully autonomous system behavior
* Technical integration details
* Final definition of all external system rules
* Detailed UI design specifications
---
## 11. Requirements
1. The system must aggregate multiple calendars into a unified view.
2. The system must aggregate inputs from multiple email systems.
3. The system must identify potential tasks from incoming information.
4. The system must provide a central task list.
5. The system must suggest prioritization of tasks based on defined criteria.
6. The system must allow user override of all prioritization decisions.
7. The system must provide a daily digest of relevant items.
8. The system must conduct a weekly retrospective in an interactive format.
9. The system must propose scheduling actions (e.g., tasks, sports, buffers).
10. The system must detect and highlight conflicts across calendars.
11. The system must display key information (tasks, deadlines, priorities, conflicts).
12. The system must explain the origin of suggestions.
13. The system must require user confirmation before executing actions.
14. The system must allow execution of user-approved actions (e.g., scheduling, task creation).
15. The system must support availability sharing for external booking.
16. The system must organize notes and maintain a knowledge structure.
17. The system must ask clarifying questions when data is ambiguous.
18. The system must allow rules to be viewed and modified.
---
## 12. Open Questions / Items to Clarify
* How should task statuses and lifecycle be defined?
* What level of automation is expected for time tracking analysis?
* How should different user roles (family, colleagues) interact with the system?
* What data privacy boundaries exist between contexts (work vs private)?
* How detailed should the knowledge graph be?
* How should duplicate or conflicting tasks be resolved?
---
## 13. Risks and Ambiguities
* Ambiguity in prioritization logic (subjective vs rule-based)
* Undefined role model for future multi-user usage
* Potential overload from too many suggestions or questions
* Integration limitations affecting completeness of data
* Risk of user distrust if suggestions are not transparent or accurate
---
## 14. Acceptance Perspective
The product is successful if:
* The user feels in control of their commitments
* The system is used daily and weekly as intended
* Fewer tasks are forgotten or delayed
* Planning effort is significantly reduced
* The user gains measurable time for personal priorities (e.g., sports, family)
* The backlog no longer creates mental stress
---
## 15. Glossary
| Original Term | English Explanation | Notes |
| ----------------- | ---------------------------- | ------------------------------------------ |
| Priorisierung | Prioritization | Based on subjective and rule-based factors |
| Bauchgefühl | Gut feeling | Subjective decision influence |
| Knowledge Graph | Structured knowledge network | Used for notes and relationships |
| Retro | Retrospective | Weekly reflection format |
| Mental Load | Cognitive burden | Key problem to reduce |
| Backlogjonglage | Managing backlog overload | Informal term for task overload |
| Zeiterfassungsapp | Time tracking application | Currently underutilized |
| Mattermost | Team communication tool | Used without structured task management |
+22
View File
@@ -0,0 +1,22 @@
# STARTFRESH.md
## General Approach to New Projects
This document serves as a template and set of guiding principles for starting any new project, capturing the preferred methodology and structural rules.
### 1. Design & Architecture Philosophy
- **Self-Sufficiency:** Minimize external dependencies. Prefer tech stacks that offer local control and self-hosting capabilities (e.g., Python backend, PostgreSQL database).
- **Tidy First, Fancy Later:** Focus initially on clean structures, functional layouts, and standard technologies. Complex visuals and design polish should only be added once the foundational logic is proven.
### 2. Repository Rules
- **Containment:** No documents outside the folder. All project-related documents, notes, and files must reside strictly within the repository.
- **Conciseness:** Keep documentation brief, clear, and to the point.
- **Single Source of Truth:** Use single, dedicated documents for specific types of information to avoid fragmentation.
### 3. Required Foundational Documents
Every new project should initialize with the following document structure:
* **`RULES.md`**: To declare foundational repository and workflow rules.
* **`ARCHITECTURE.md`**: To collect all architectural decisions, technology stack choices, and integration strategies.
* **`PLAN.md`**: To track ongoing steps, categorized by short, medium, and long-term goals. *Must be revised after each PR.*
* **`COMPONENTS.md`**: To track specific components used in the project. *Rule: Only add components to this document once they have been actively included and utilized in the codebase.*
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+83
View File
@@ -0,0 +1,83 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.models import Base
from app.db.session import DATABASE_URL
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", DATABASE_URL)
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,24 @@
"""Add agent_ready and labels columns to tasks
Revision ID: 003
Revises:
Create Date: 2026-05-04
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '003'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('tasks', sa.Column('agent_ready', sa.Boolean(), nullable=True, server_default='false'))
op.add_column('tasks', sa.Column('labels', sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column('tasks', 'labels')
op.drop_column('tasks', 'agent_ready')
@@ -0,0 +1,20 @@
"""Add source_url column to tasks
Revision ID: 004
Create Date: 2026-05-06
"""
from alembic import op
import sqlalchemy as sa
revision = '004'
down_revision = '003'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('tasks', sa.Column('source_url', sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column('tasks', 'source_url')
@@ -0,0 +1,36 @@
"""Add call_items table
Revision ID: 005
Create Date: 2026-05-10
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '005'
down_revision = '004'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table('call_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('person', sa.String(200), nullable=False),
sa.Column('reason', sa.String(500), nullable=False),
sa.Column('phone_number', sa.String(30), nullable=True),
sa.Column('original_task_id', sa.Integer(), nullable=True),
sa.Column('priority', sa.Integer(), server_default='3'),
sa.Column('status', sa.String(), server_default="'pending'"),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)')),
sa.ForeignKeyConstraint(['original_task_id'], ['tasks.id']),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_call_items_id'), 'call_items', ['id'], unique=False)
op.create_index(op.f('ix_call_items_status'), 'call_items', ['status'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_call_items_status'), table_name='call_items')
op.drop_index(op.f('ix_call_items_id'), table_name='call_items')
op.drop_table('call_items')
@@ -0,0 +1,21 @@
"""Add hidden_until column to tasks table
Revision ID: 006
Create Date: 2026-05-13
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '006'
down_revision = '005'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('tasks', sa.Column('hidden_until', sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column('tasks', 'hidden_until')
@@ -0,0 +1,25 @@
"""Add conflict resolution columns to tasks table
Revision ID: 007
Create Date: 2026-05-20
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = '007'
down_revision = '006'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('tasks', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('tasks', sa.Column('has_conflict', sa.Boolean(), server_default='0', nullable=True))
op.add_column('tasks', sa.Column('conflict_data', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column('tasks', 'conflict_data')
op.drop_column('tasks', 'has_conflict')
op.drop_column('tasks', 'last_synced_at')
@@ -0,0 +1,32 @@
"""add_position_to_tasks
Revision ID: 0ce1353d9433
Revises: 9d39809cb00a
Create Date: 2026-05-03 11:45:56.407583
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0ce1353d9433'
down_revision: Union[str, Sequence[str], None] = '9d39809cb00a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tasks', sa.Column('position', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('tasks', 'position')
# ### end Alembic commands ###
@@ -0,0 +1,25 @@
"""add_source_id_to_tasks
Revision ID: 28a023783522
Revises: 0ce1353d9433
Create Date: 2026-05-03 21:42:57.678532
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '28a023783522'
down_revision: Union[str, Sequence[str], None] = '0ce1353d9433'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('tasks', sa.Column('source_id', sa.String(), nullable=True))
op.create_index(op.f('ix_tasks_source_id'), 'tasks', ['source_id'], unique=True)
def downgrade() -> None:
op.drop_index(op.f('ix_tasks_source_id'), table_name='tasks')
op.drop_column('tasks', 'source_id')
@@ -0,0 +1,73 @@
"""Initial DB
Revision ID: 9d39809cb00a
Revises:
Create Date: 2026-04-29 23:49:47.777582
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9d39809cb00a'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('start_time', sa.DateTime(timezone=True), nullable=False),
sa.Column('end_time', sa.DateTime(timezone=True), nullable=False),
sa.Column('source', sa.String(), nullable=False),
sa.Column('is_conflict', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_events_id'), 'events', ['id'], unique=False)
op.create_index(op.f('ix_events_title'), 'events', ['title'], unique=False)
op.create_table('suggestions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('content', sa.String(), nullable=False),
sa.Column('explanation', sa.Text(), nullable=True),
sa.Column('type', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_suggestions_id'), 'suggestions', ['id'], unique=False)
op.create_table('tasks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('priority', sa.Integer(), nullable=True),
sa.Column('status', sa.String(), nullable=True),
sa.Column('origin', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('due_date', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tasks_id'), 'tasks', ['id'], unique=False)
op.create_index(op.f('ix_tasks_title'), 'tasks', ['title'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_tasks_title'), table_name='tasks')
op.drop_index(op.f('ix_tasks_id'), table_name='tasks')
op.drop_table('tasks')
op.drop_index(op.f('ix_suggestions_id'), table_name='suggestions')
op.drop_table('suggestions')
op.drop_index(op.f('ix_events_title'), table_name='events')
op.drop_index(op.f('ix_events_id'), table_name='events')
op.drop_table('events')
# ### end Alembic commands ###
+25
View File
@@ -0,0 +1,25 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./orgmylife.db")
connect_args = {}
if DATABASE_URL.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine = create_engine(DATABASE_URL, connect_args=connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency for FastAPI endpoints
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.sql import func
from app.db.session import Base
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
priority = Column(Integer, default=0) # e.g. 1=High, 2=Medium, 3=Low
position = Column(Integer, default=0) # order within priority
status = Column(String, default="open") # open, in_progress, review, completed, dismissed
origin = Column(String, nullable=True) # e.g. "gmail", "nextcloud_todo:Personal", "manual"
source_id = Column(String, nullable=True, index=True) # CalDAV UID or IMAP Message-ID
source_url = Column(String, nullable=True) # Link to original item (email, task board, etc.)
created_at = Column(DateTime(timezone=True), server_default=func.now())
due_date = Column(DateTime(timezone=True), nullable=True)
hidden_until = Column(DateTime(timezone=True), nullable=True)
agent_ready = Column(Boolean, default=False) # Flag: orchestrator can pick this up
labels = Column(String, nullable=True) # Comma-separated labels for filtering
last_synced_at = Column(DateTime(timezone=True), nullable=True) # When last synced with remote
has_conflict = Column(Boolean, default=False) # True if conflict detected
conflict_data = Column(Text, nullable=True) # JSON: remote version of conflicting fields
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True, nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=False)
source = Column(String, nullable=False) # e.g. "google", "nextcloud"
is_conflict = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class Suggestion(Base):
__tablename__ = "suggestions"
id = Column(Integer, primary_key=True, index=True)
content = Column(String, nullable=False)
explanation = Column(Text, nullable=True)
type = Column(String, nullable=False) # e.g. "schedule_task", "resolve_conflict"
status = Column(String, default="pending") # pending, accepted, rejected
created_at = Column(DateTime(timezone=True), server_default=func.now())
class UserSession(Base):
__tablename__ = "user_sessions"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(String, unique=True, index=True, nullable=False)
username = Column(String, nullable=False)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class ProjectTask(Base):
__tablename__ = "project_tasks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
repo = Column(String, nullable=True) # e.g. "OrgMyLife", "AI-Orchestrator"
status = Column(String, default="backlog") # backlog, ready, in_progress, blocked, review, done
priority = Column(Integer, default=3)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class CallItem(Base):
__tablename__ = "call_items"
id = Column(Integer, primary_key=True, index=True)
person = Column(String(200), nullable=False)
reason = Column(String(500), nullable=False)
phone_number = Column(String(30), nullable=True)
original_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
priority = Column(Integer, default=3) # 1=Very High, 2=High, 3=Medium, 4=Low
status = Column(String, default="pending") # "pending" or "done"
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -0,0 +1,79 @@
import os
from app.db.session import SessionLocal
from app.models import Task
def sync_backlog():
filepath = "BACKLOG.md"
if not os.path.exists(filepath):
return False
db = SessionLocal()
try:
with open(filepath, "r", encoding="utf-8") as f:
lines = f.readlines()
table_lines = [l.strip() for l in lines if l.strip().startswith("|")]
if len(table_lines) > 2:
data_lines = table_lines[2:] # Skip header and separator
for line in data_lines:
cols = [c.strip() for c in line.split("|")]
if len(cols) >= 8:
todo = cols[1]
details = cols[2]
ansprechpartner = cols[3]
quelle = cols[4]
deadline = cols[5]
prioritaet = cols[6].lower()
umfang = cols[7]
if not todo:
continue
priority_val = 4
if "sehr hoch" in prioritaet:
priority_val = 1
elif "hoch" in prioritaet:
priority_val = 2
elif "mittel" in prioritaet:
priority_val = 3
# Build a structured description
description_parts = []
if details: description_parts.append(f"Details: {details}")
if ansprechpartner: description_parts.append(f"Ansprechpartner: {ansprechpartner}")
if deadline: description_parts.append(f"Deadline: {deadline}")
if umfang: description_parts.append(f"Umfang: {umfang}")
full_desc = "\n".join(description_parts)
existing_task = db.query(Task).filter(Task.title == todo).first()
if existing_task:
existing_task.description = full_desc
existing_task.origin = quelle
existing_task.priority = priority_val
else:
new_task = Task(
title=todo,
description=full_desc,
origin=quelle,
priority=priority_val,
status="open"
)
db.add(new_task)
db.commit()
return True
except Exception as e:
print(f"Error syncing backlog: {e}")
db.rollback()
return False
finally:
db.close()
return False
if __name__ == "__main__":
success = sync_backlog()
print(f"Sync successful: {success}")
+199
View File
@@ -0,0 +1,199 @@
"""Daily Digest and Weekly Retrospective generation."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import Task, Event, CallItem
BERLIN_TZ = ZoneInfo("Europe/Berlin")
def generate_daily_digest(db: Session) -> dict:
"""Generate a structured daily digest.
Returns a dict with sections: greeting, today_events, top_tasks,
overdue, due_today, new_since_yesterday, stats.
"""
now = datetime.now(BERLIN_TZ)
today_start = datetime.combine(now.date(), datetime.min.time()).replace(tzinfo=BERLIN_TZ)
today_end = datetime.combine(now.date(), datetime.max.time()).replace(tzinfo=BERLIN_TZ)
yesterday = today_start - timedelta(days=1)
# Greeting based on time of day
hour = now.hour
if hour < 12:
greeting = "Good morning"
elif hour < 17:
greeting = "Good afternoon"
else:
greeting = "Good evening"
# Today's events
todays_events = db.query(Event).filter(
Event.start_time >= today_start,
Event.start_time <= today_end
).order_by(Event.start_time).all()
# Top 5 priority tasks
top_tasks = db.query(Task).filter(
Task.status == "open"
).order_by(Task.priority, Task.position).limit(5).all()
# Overdue tasks
overdue = db.query(Task).filter(
Task.status == "open",
Task.due_date != None,
Task.due_date < today_start
).order_by(Task.due_date).all()
# Due today
due_today = db.query(Task).filter(
Task.status == "open",
Task.due_date != None,
Task.due_date >= today_start,
Task.due_date <= today_end
).all()
# New tasks since yesterday
new_tasks = db.query(Task).filter(
Task.status == "open",
Task.created_at >= yesterday
).count()
# Total open
total_open = db.query(Task).filter(Task.status == "open").count()
# Pending calls
pending_calls = db.query(CallItem).filter(
CallItem.status == "pending"
).all()
priority_labels = {1: "Very High", 2: "High", 3: "Medium", 4: "Low"}
result = {
"greeting": greeting,
"date": now.strftime("%A, %B %d, %Y"),
"total_open": total_open,
"events_today": [
{
"title": e.title,
"time": f"{e.start_time.astimezone(BERLIN_TZ).strftime('%H:%M')} - {e.end_time.astimezone(BERLIN_TZ).strftime('%H:%M')}"
}
for e in todays_events
],
"top_tasks": [
{"id": t.id, "title": t.title, "priority": priority_labels.get(t.priority, "Low")}
for t in top_tasks
],
"overdue": [
{
"id": t.id,
"title": t.title,
"days_overdue": (now.date() - t.due_date.date()).days
}
for t in overdue
],
"due_today": [
{"id": t.id, "title": t.title}
for t in due_today
],
"new_since_yesterday": new_tasks,
"stats": {
"events_count": len(todays_events),
"overdue_count": len(overdue),
"due_today_count": len(due_today),
}
}
# Only include pending_calls section if there are pending calls
if pending_calls:
result["pending_calls"] = [
{"person": c.person, "reason": c.reason}
for c in pending_calls
]
return result
def generate_weekly_retro(db: Session) -> dict:
"""Generate a weekly retrospective summary.
Looks at the past 7 days: what was completed, what slipped,
what's still open, and workload trends.
"""
now = datetime.now(BERLIN_TZ)
week_ago = now - timedelta(days=7)
today_start = datetime.combine(now.date(), datetime.min.time()).replace(tzinfo=BERLIN_TZ)
# Tasks completed this week (check for status change isn't tracked,
# so we look at tasks with status=completed that exist)
completed_this_week = db.query(Task).filter(
Task.status == "completed"
).count()
# Tasks still open
total_open = db.query(Task).filter(Task.status == "open").count()
# Tasks in review
in_review = db.query(Task).filter(Task.status == "review").count()
# Overdue tasks
overdue = db.query(Task).filter(
Task.status == "open",
Task.due_date != None,
Task.due_date < today_start
).order_by(Task.due_date).all()
# Tasks created this week
created_this_week = db.query(Task).filter(
Task.created_at >= week_ago,
Task.status.notin_(["dismissed"])
).count()
# Tasks by priority
priority_breakdown = {}
for prio in [1, 2, 3, 4]:
count = db.query(Task).filter(
Task.status == "open", Task.priority == prio
).count()
priority_breakdown[prio] = count
# Tasks by origin
origin_breakdown = db.query(
Task.origin, func.count(Task.id)
).filter(Task.status == "open").group_by(Task.origin).all()
# Events this week
events_this_week = db.query(Event).filter(
Event.start_time >= week_ago,
Event.start_time <= now
).count()
priority_labels = {1: "Very High", 2: "High", 3: "Medium", 4: "Low"}
return {
"period": f"{week_ago.strftime('%b %d')} - {now.strftime('%b %d, %Y')}",
"completed": completed_this_week,
"created": created_this_week,
"total_open": total_open,
"in_review": in_review,
"overdue": [
{
"id": t.id,
"title": t.title,
"days_overdue": (now.date() - t.due_date.date()).days
}
for t in overdue
],
"priority_breakdown": {
priority_labels[k]: v for k, v in priority_breakdown.items()
},
"origin_breakdown": {
(origin or "unknown"): count for origin, count in origin_breakdown
},
"events_attended": events_this_week,
"net_change": created_this_week - completed_this_week,
"health": "growing" if created_this_week > completed_this_week + 5 else
"shrinking" if completed_this_week > created_this_week + 3 else "stable",
}
+339
View File
@@ -0,0 +1,339 @@
import os
import imaplib
import email as email_lib
import traceback
from email.header import decode_header
from app.db.session import SessionLocal
from app.models import Task
# IMAP label constants
LABEL_TODO = "todo"
LABEL_NO_TODO = "no_todo"
LABEL_DONE = "done"
def _get_imap_connection():
server = os.getenv("IMAP_SERVER")
port = int(os.getenv("IMAP_PORT", "993"))
username = os.getenv("EMAIL_USERNAME")
password = os.getenv("EMAIL_PASSWORD")
if not all([server, username, password]):
raise ValueError("IMAP credentials missing. Set IMAP_SERVER, IMAP_PORT, EMAIL_USERNAME, EMAIL_PASSWORD.")
conn = imaplib.IMAP4_SSL(server, port)
conn.login(username, password)
return conn
def _decode_header_value(value) -> str:
if value is None:
return ""
parts = decode_header(value)
decoded = []
for part, charset in parts:
if isinstance(part, bytes):
decoded.append(part.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(part)
return " ".join(decoded)
def _get_body_snippet(msg, max_chars: int = 300) -> str:
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain" and part.get("Content-Disposition") is None:
payload = part.get_payload(decode=True)
if payload:
return payload.decode(part.get_content_charset() or "utf-8", errors="replace")[:max_chars]
else:
payload = msg.get_payload(decode=True)
if payload:
return payload.decode(msg.get_content_charset() or "utf-8", errors="replace")[:max_chars]
return ""
def _apply_label(conn, uid_bytes: bytes, label: str):
try:
conn.uid("STORE", uid_bytes, "+FLAGS", f"({label})")
except Exception as e:
print(f"Warning: could not apply label '{label}': {e}")
def _remove_label(conn, uid_bytes: bytes, label: str):
try:
conn.uid("STORE", uid_bytes, "-FLAGS", f"({label})")
except Exception as e:
print(f"Warning: could not remove label '{label}': {e}")
def _archive_email(conn, uid_bytes: bytes):
"""Move email to archive folder. Uses IONOS year-based Archiv folders."""
from datetime import datetime
current_year = str(datetime.now().year)
archive_folders = [
f"Archiv/{current_year}", # IONOS: "Archiv/2026"
"Archiv",
"Archive",
"INBOX.Archive",
]
for folder in archive_folders:
try:
result = conn.uid("COPY", uid_bytes, folder)
if result[0] == "OK":
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
conn.expunge()
print(f"Email archived to: {folder}")
return True
except Exception:
continue
print(f"Could not archive email. Tried: {archive_folders}")
return False
def _delete_email(conn, uid_bytes: bytes):
"""Permanently delete email (move to Papierkorb/Trash)."""
trash_folders = [
"Papierkorb", # IONOS German
"Trash",
"INBOX.Trash",
"Deleted Items",
]
for folder in trash_folders:
try:
result = conn.uid("COPY", uid_bytes, folder)
if result[0] == "OK":
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
conn.expunge()
print(f"Email moved to trash: {folder}")
return True
except Exception:
continue
# Fallback: just mark as deleted in INBOX
try:
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
conn.expunge()
print("Email deleted from INBOX (no trash folder found)")
return True
except Exception as e:
print(f"Could not delete email: {e}")
return False
def sync_emails():
"""Fetch untracked INBOX emails and create tasks."""
try:
conn = _get_imap_connection()
except ValueError as e:
print(f"IMAP not configured: {e}")
return True
except Exception as e:
print(f"IMAP connection failed: {e}")
traceback.print_exc()
return False
try:
conn.select("INBOX")
# Try keyword-based search, fall back to UNSEEN
try:
status, data = conn.uid(
"SEARCH", None,
f"UNKEYWORD {LABEL_TODO} UNKEYWORD {LABEL_NO_TODO} UNKEYWORD {LABEL_DONE}"
)
except Exception:
print("UNKEYWORD not supported, falling back to UNSEEN")
status, data = conn.uid("SEARCH", None, "UNSEEN")
if status != "OK" or not data[0]:
return True # Nothing to process
uid_list = data[0].split()
db = SessionLocal()
for uid_bytes in uid_list:
try:
_, msg_data = conn.uid("FETCH", uid_bytes, "(RFC822)")
if not msg_data or not msg_data[0]:
continue
raw = msg_data[0][1]
msg = email_lib.message_from_bytes(raw)
message_id = msg.get("Message-ID", "").strip()
if not message_id:
continue
title = _decode_header_value(msg.get("Subject", "(No Subject)"))
body_snippet = _get_body_snippet(msg)
# Parse email send date
from email.utils import parsedate_to_datetime
email_date = None
try:
date_str = msg.get("Date")
if date_str:
email_date = parsedate_to_datetime(date_str)
except Exception:
pass
existing = db.query(Task).filter(Task.source_id == message_id).first()
if existing:
_apply_label(conn, uid_bytes, LABEL_TODO)
continue
max_pos = db.query(Task).filter(Task.status == "open").count()
db.add(Task(
title=title[:200],
description=body_snippet[:2000],
priority=4,
position=max_pos,
status="open",
origin="dhive_email",
source_id=message_id,
source_url=os.getenv("EMAIL_WEBMAIL_URL", ""),
created_at=email_date,
))
_apply_label(conn, uid_bytes, LABEL_TODO)
except Exception as e:
print(f"Error processing email UID {uid_bytes}: {e}")
# Cleanup: dismiss tasks whose emails are no longer in INBOX
open_email_tasks = db.query(Task).filter(
Task.origin == "dhive_email",
Task.status == "open",
Task.source_id != None
).all()
dismissed_count = 0
for task in open_email_tasks:
try:
# Search by Message-ID header
status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{task.source_id}"')
if status == "OK" and data[0]:
# Email still in INBOX — keep task open
continue
# Also try without quotes (some servers are picky)
status2, data2 = conn.uid("SEARCH", None, f'HEADER Message-ID {task.source_id}')
if status2 == "OK" and data2[0]:
continue
# Email not found in INBOX — dismiss
task.status = "dismissed"
dismissed_count += 1
except Exception as e:
print(f" Cleanup check failed for {task.source_id[:30]}: {e}")
if dismissed_count:
print(f" Auto-dismissed {dismissed_count} tasks (emails no longer in INBOX)")
db.commit()
db.close()
return True
except Exception as e:
print(f"Email sync error: {e}")
traceback.print_exc()
return False
finally:
try:
conn.logout()
except Exception:
pass
def mark_email_done_and_archive(source_id: str) -> bool:
"""Find an email by Message-ID, mark done, and archive it."""
try:
conn = _get_imap_connection()
except Exception as e:
print(f"IMAP connection failed: {e}")
return False
try:
conn.select("INBOX")
# Try multiple search approaches
uid_bytes = None
# Approach 1: HEADER search with quotes
status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{source_id}"')
if status == "OK" and data[0]:
uid_bytes = data[0].split()[0]
# Approach 2: Without CHARSET
if not uid_bytes:
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}')
if status == "OK" and data[0]:
uid_bytes = data[0].split()[0]
# Approach 3: Search ALL and match manually (slow but reliable)
if not uid_bytes:
print(f" Header search failed, trying full scan for: {source_id[:40]}...")
status, data = conn.uid("SEARCH", None, "ALL")
if status == "OK" and data[0]:
for uid in data[0].split()[-100:]: # Check last 100 emails
try:
_, msg_data = conn.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (Message-ID)])")
if msg_data and msg_data[0] and source_id.encode() in msg_data[0][1]:
uid_bytes = uid
break
except Exception:
continue
if not uid_bytes:
print(f" Email not found in INBOX: {source_id[:50]}")
return False
print(f" Found email UID: {uid_bytes}")
_remove_label(conn, uid_bytes, LABEL_TODO)
_apply_label(conn, uid_bytes, LABEL_DONE)
result = _archive_email(conn, uid_bytes)
return result
except Exception as e:
print(f"Error archiving email {source_id[:50]}: {e}")
traceback.print_exc()
return False
finally:
try:
conn.logout()
except Exception:
pass
def delete_email_from_server(source_id: str) -> bool:
"""Find an email by Message-ID and permanently delete it (move to Trash)."""
try:
conn = _get_imap_connection()
except Exception as e:
print(f"IMAP connection failed: {e}")
return False
try:
conn.select("INBOX")
# Try multiple search approaches
uid_bytes = None
status, data = conn.uid("SEARCH", "CHARSET", "UTF-8", f'HEADER Message-ID "{source_id}"')
if status == "OK" and data[0]:
uid_bytes = data[0].split()[0]
if not uid_bytes:
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}')
if status == "OK" and data[0]:
uid_bytes = data[0].split()[0]
if not uid_bytes:
print(f" Email not found for delete: {source_id[:50]}")
return False
_delete_email(conn, uid_bytes)
return True
except Exception as e:
print(f"Error deleting email {source_id[:50]}: {e}")
return False
finally:
try:
conn.logout()
except Exception:
pass
+290
View File
@@ -0,0 +1,290 @@
"""Gmail IMAP sync — second email source for task ingestion.
Requirements:
- Enable IMAP in Gmail settings (Settings → See all settings → Forwarding and POP/IMAP)
- Generate an App Password at https://myaccount.google.com/apppasswords
(requires 2FA to be enabled on your Google account)
- Set GMAIL_USERNAME and GMAIL_PASSWORD env vars
"""
import os
import imaplib
import email as email_lib
from email.header import decode_header
import traceback
from app.db.session import SessionLocal
from app.models import Task
def _get_gmail_connection():
server = os.getenv("GMAIL_IMAP_SERVER", "imap.gmail.com")
port = int(os.getenv("GMAIL_IMAP_PORT", "993"))
username = os.getenv("GMAIL_USERNAME", "")
password = os.getenv("GMAIL_PASSWORD", "")
if not username or not password:
return None # Not configured, skip silently
conn = imaplib.IMAP4_SSL(server, port)
conn.login(username, password)
return conn
def _decode_header_value(value) -> str:
if value is None:
return ""
parts = decode_header(value)
decoded = []
for part, charset in parts:
if isinstance(part, bytes):
decoded.append(part.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(part)
return " ".join(decoded)
def _get_body_snippet(msg, max_chars: int = 300) -> str:
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain" and part.get("Content-Disposition") is None:
payload = part.get_payload(decode=True)
if payload:
return payload.decode(part.get_content_charset() or "utf-8", errors="replace")[:max_chars]
else:
payload = msg.get_payload(decode=True)
if payload:
return payload.decode(msg.get_content_charset() or "utf-8", errors="replace")[:max_chars]
return ""
def sync_gmail():
"""Fetch unread emails from Gmail inbox and create tasks.
Returns True on success (or if not configured), False on error.
"""
try:
conn = _get_gmail_connection()
except imaplib.IMAP4.error as e:
print(f"Gmail IMAP auth failed: {e}")
print("Make sure you're using an App Password (not your regular Google password).")
print("Generate one at: https://myaccount.google.com/apppasswords")
return False
except Exception as e:
print(f"Gmail IMAP connection failed: {e}")
traceback.print_exc()
return False
if conn is None:
# Not configured — skip silently
return True
try:
conn.select("INBOX")
# Search for unread emails
status, data = conn.uid("SEARCH", None, "UNSEEN")
if status != "OK" or not data[0]:
conn.logout()
return True # Nothing to process
uid_list = data[0].split()
db = SessionLocal()
synced_count = 0
for uid_bytes in uid_list:
try:
_, msg_data = conn.uid("FETCH", uid_bytes, "(RFC822)")
if not msg_data or not msg_data[0]:
continue
raw = msg_data[0][1]
msg = email_lib.message_from_bytes(raw)
message_id = msg.get("Message-ID", "").strip()
if not message_id:
continue
title = _decode_header_value(msg.get("Subject", "(No Subject)"))
sender = _decode_header_value(msg.get("From", ""))
body_snippet = _get_body_snippet(msg)
# Parse email send date
from email.utils import parsedate_to_datetime
email_date = None
try:
date_str = msg.get("Date")
if date_str:
email_date = parsedate_to_datetime(date_str)
except Exception:
pass
# Skip if already tracked (including dismissed)
existing = db.query(Task).filter(Task.source_id == message_id).first()
if existing:
continue
# Limit title length
title = title[:200] if title else "(No Subject)"
description = f"From: {sender}\n\n{body_snippet}" if sender else body_snippet
max_pos = db.query(Task).filter(Task.status == "open").count()
db.add(Task(
title=title,
description=description[:2000], # Limit description length
priority=4,
position=max_pos,
status="open",
origin="gmail",
source_id=message_id,
source_url="https://mail.google.com/mail/",
created_at=email_date,
))
synced_count += 1
except Exception as e:
print(f"Error processing Gmail UID {uid_bytes}: {e}")
db.commit()
db.close()
print(f"Gmail sync: {synced_count} new tasks from {len(uid_list)} unread emails")
except Exception as e:
print(f"Gmail sync error: {e}")
traceback.print_exc()
return False
finally:
try:
conn.logout()
except Exception:
pass
return True
def gmail_archive_email(source_id: str) -> bool:
"""Archive a Gmail email (remove from Inbox, keep in All Mail)."""
try:
conn = _get_gmail_connection()
except Exception as e:
print(f"Gmail connection failed: {e}")
return False
if conn is None:
return False
try:
conn.select("INBOX")
# Find the email
uid_bytes = _find_gmail_email(conn, source_id)
if not uid_bytes:
print(f" Gmail: email not found: {source_id[:40]}")
return False
# Gmail archive = move to All Mail (remove INBOX label)
# In IMAP terms: COPY to [Gmail]/All Mail, then delete from INBOX
try:
conn.uid("COPY", uid_bytes, "[Gmail]/All Mail")
except Exception:
pass # Might already be in All Mail
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
conn.expunge()
print(f" Gmail: archived email")
return True
except Exception as e:
print(f"Gmail archive error: {e}")
return False
finally:
try:
conn.logout()
except Exception:
pass
def gmail_delete_email(source_id: str) -> bool:
"""Delete a Gmail email (move to Trash)."""
try:
conn = _get_gmail_connection()
except Exception as e:
print(f"Gmail connection failed: {e}")
return False
if conn is None:
return False
try:
conn.select("INBOX")
uid_bytes = _find_gmail_email(conn, source_id)
if not uid_bytes:
print(f" Gmail: email not found for delete: {source_id[:40]}")
return False
# Move to Gmail Trash
conn.uid("COPY", uid_bytes, "[Gmail]/Trash")
conn.uid("STORE", uid_bytes, "+FLAGS", r"(\Deleted)")
conn.expunge()
print(f" Gmail: deleted email")
return True
except Exception as e:
print(f"Gmail delete error: {e}")
return False
finally:
try:
conn.logout()
except Exception:
pass
def _find_gmail_email(conn, source_id: str):
"""Find a Gmail email by Message-ID. Returns UID bytes or None."""
# Try HEADER search
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID "{source_id}"')
if status == "OK" and data[0]:
return data[0].split()[0]
# Try without quotes
status, data = conn.uid("SEARCH", None, f'HEADER Message-ID {source_id}')
if status == "OK" and data[0]:
return data[0].split()[0]
return None
def gmail_cleanup_dismissed(db_session=None):
"""Check if Gmail tasks are still in INBOX, dismiss if not."""
try:
conn = _get_gmail_connection()
except Exception:
return
if conn is None:
return
try:
conn.select("INBOX")
db = db_session or SessionLocal()
open_gmail_tasks = db.query(Task).filter(
Task.origin == "gmail",
Task.status == "open",
Task.source_id != None
).all()
dismissed = 0
for task in open_gmail_tasks:
uid = _find_gmail_email(conn, task.source_id)
if not uid:
task.status = "dismissed"
dismissed += 1
if dismissed:
db.commit()
print(f" Gmail: auto-dismissed {dismissed} tasks")
if not db_session:
db.close()
except Exception as e:
print(f"Gmail cleanup error: {e}")
finally:
try:
conn.logout()
except Exception:
pass
@@ -0,0 +1,325 @@
import os
import caldav
import json
import traceback
from datetime import datetime
from zoneinfo import ZoneInfo
from app.db.session import SessionLocal
from app.models import Event, Task
# Berlin timezone for display
BERLIN_TZ = ZoneInfo("Europe/Berlin")
UTC_TZ = ZoneInfo("UTC")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_caldav_principal():
url = os.getenv("NEXTCLOUD_URL")
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
if not all([url, username, password]):
raise ValueError("Nextcloud credentials missing. Set NEXTCLOUD_URL, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD.")
caldav_url = url if url.endswith("/remote.php/dav/") else f"{url.rstrip('/')}/remote.php/dav/"
client = caldav.DAVClient(url=caldav_url, username=username, password=password)
return client.principal()
def _to_berlin(dt):
"""Convert a datetime to Berlin timezone. Handles naive and aware datetimes."""
if dt is None:
return None
if not isinstance(dt, datetime):
# date-only: treat as midnight Berlin time
return datetime.combine(dt, datetime.min.time()).replace(tzinfo=BERLIN_TZ)
if dt.tzinfo is None:
# Naive datetime — assume UTC
dt = dt.replace(tzinfo=UTC_TZ)
return dt.astimezone(BERLIN_TZ)
def _is_own_calendar(cal_url_str: str) -> bool:
"""Check if a calendar belongs to the authenticated user (not shared by others)."""
return "_shared_by_" not in cal_url_str
def _get_nextcloud_base_url() -> str:
"""Get the base Nextcloud URL for building links."""
url = os.getenv("NEXTCLOUD_URL", "")
return url.rstrip("/")
# ---------------------------------------------------------------------------
# Calendar events
# ---------------------------------------------------------------------------
def sync_calendar_events():
try:
principal = _get_caldav_principal()
calendars = principal.calendars()
except Exception as e:
print(f"Failed to connect to Nextcloud CalDAV: {e}")
return False
db = SessionLocal()
for calendar in calendars:
cal_url_str = str(calendar.url)
if not _is_own_calendar(cal_url_str):
print(f"Skipping shared calendar: {calendar.get_display_name()}")
continue
try:
events = calendar.events()
for event in events:
ical = event.icalendar_component
if ical.name == "VEVENT":
title = str(ical.get("SUMMARY", "Untitled Event"))
dtstart = ical.get("DTSTART")
dtend = ical.get("DTEND")
if not dtstart or not dtend:
continue
start_time = _to_berlin(dtstart.dt)
end_time = _to_berlin(dtend.dt)
existing = db.query(Event).filter(Event.title == title, Event.source == "nextcloud").first()
if not existing:
db.add(Event(title=title, start_time=start_time, end_time=end_time, source="nextcloud"))
except Exception as e:
print(f"Error reading calendar {calendar}: {e}")
db.commit()
db.close()
return True
# ---------------------------------------------------------------------------
# ToDo sync (two-way, only own tasks)
# ---------------------------------------------------------------------------
def _parse_vtodo_datetime(val):
"""Parse a VTODO datetime and convert to Berlin timezone."""
if val is None:
return None
return _to_berlin(val.dt)
def _has_local_changes(task, remote_title: str, remote_description: str, remote_status: str) -> bool:
"""Check if the local task differs from what was last synced (i.e., user edited locally)."""
# If never synced, treat as no local changes (first sync)
if task.last_synced_at is None:
return False
# If the task was modified after last sync, local has changes
# We compare local fields against remote to detect divergence
local_title = task.title or ""
local_desc = task.description or ""
local_status = task.status or "open"
# If local differs from remote, and we have a last_synced_at, local was changed
if local_title != remote_title:
return True
if local_desc != (remote_description or ""):
return True
if local_status != remote_status:
return True
return False
def _has_remote_changes(task, remote_title: str, remote_description: str, remote_status: str) -> bool:
"""Check if remote data differs from what's currently stored locally.
This detects whether the remote side changed since our last sync.
If the remote values differ from what we have locally, remote changed.
"""
local_title = task.title or ""
local_desc = task.description or ""
local_status = task.status or "open"
if local_title != remote_title:
return True
if local_desc != (remote_description or ""):
return True
if local_status != remote_status:
return True
return False
def sync_todo_tasks():
"""Import VTODOs from own Nextcloud todo lists into local Task table.
Only imports from calendars owned by the authenticated user (skips shared).
Detects conflicts when both local and remote changed since last sync.
"""
try:
principal = _get_caldav_principal()
calendars = principal.calendars()
except Exception as e:
print(f"Failed to connect to Nextcloud CalDAV: {e}")
return False
db = SessionLocal()
nc_username = os.getenv("NEXTCLOUD_USERNAME", "")
base_url = _get_nextcloud_base_url()
for calendar in calendars:
cal_url_str = str(calendar.url)
cal_name = calendar.get_display_name() or "Tasks"
# Skip shared calendars (these belong to coworkers)
if not _is_own_calendar(cal_url_str):
print(f"Skipping shared todo list: {cal_name}")
continue
try:
todos = calendar.todos(include_completed=True)
except Exception:
continue # calendar may not support VTODOs
for todo in todos:
try:
ical = todo.icalendar_component
if ical.name != "VTODO":
continue
uid = str(ical.get("UID", ""))
if not uid:
continue
title = str(ical.get("SUMMARY", "Untitled Task"))
description = str(ical.get("DESCRIPTION", "") or "")
due_date = _parse_vtodo_datetime(ical.get("DUE"))
vtodo_status = str(ical.get("STATUS", "NEEDS-ACTION")).upper()
status = "completed" if vtodo_status == "COMPLETED" else "open"
# Build origin label with calendar name
origin_label = f"nextcloud_todo:{cal_name}"
# Build link to Nextcloud tasks app
source_url = f"{base_url}/apps/tasks/#/calendars/{cal_name}" if base_url else None
existing = db.query(Task).filter(Task.source_id == uid).first()
if existing:
# Don't touch dismissed or completed tasks (user already handled them)
if existing.status in ("dismissed", "completed"):
continue
# --- Conflict detection ---
# Check if local was modified since last sync
local_changed = _has_local_changes(existing, title, description, status)
remote_changed = _has_remote_changes(existing, title, description, status)
if local_changed and remote_changed:
# Both sides changed → conflict
existing.has_conflict = True
existing.conflict_data = json.dumps({
"title": title,
"status": status,
"description": description,
})
continue
if not remote_changed:
# Only local changed (or nothing changed) → push local to remote if needed
if local_changed and existing.source_id:
push_updates = {}
if existing.title != title:
push_updates["title"] = existing.title
if existing.description != (description or ""):
push_updates["description"] = existing.description
if existing.status != status:
push_updates["status"] = existing.status
if push_updates:
update_remote_todo(existing.source_id, push_updates)
existing.last_synced_at = datetime.now(tz=UTC_TZ)
continue
# Only remote changed → update local normally
# Don't revert in_progress or review tasks back to open
if existing.status in ("in_progress", "review") and status == "open":
existing.title = title
existing.description = description
existing.due_date = due_date
existing.origin = origin_label
existing.source_url = source_url
existing.last_synced_at = datetime.now(tz=UTC_TZ)
continue
existing.title = title
existing.description = description
existing.due_date = due_date
existing.status = status
existing.origin = origin_label
existing.source_url = source_url
existing.last_synced_at = datetime.now(tz=UTC_TZ)
else:
max_pos = db.query(Task).count()
db.add(Task(
title=title,
description=description,
priority=4,
position=max_pos,
status=status,
origin=origin_label,
source_id=uid,
source_url=source_url,
due_date=due_date,
last_synced_at=datetime.now(tz=UTC_TZ),
))
except Exception as e:
print(f"Error processing VTODO: {e}")
db.commit()
db.close()
return True
def update_remote_todo(source_id: str, updates: dict):
"""Push local task changes back to the Nextcloud server.
updates: dict with any of: title, description, status
"""
try:
principal = _get_caldav_principal()
calendars = principal.calendars()
except Exception as e:
print(f"Failed to connect to Nextcloud CalDAV for push: {e}")
return False
for calendar in calendars:
cal_url_str = str(calendar.url)
# Only push to own calendars
if not _is_own_calendar(cal_url_str):
continue
try:
todos = calendar.todos(include_completed=True)
except Exception:
continue
for todo in todos:
try:
ical = todo.icalendar_component
if ical.name != "VTODO":
continue
if str(ical.get("UID", "")) != source_id:
continue
# Apply updates
if "title" in updates:
ical["SUMMARY"] = updates["title"]
if "description" in updates:
ical["DESCRIPTION"] = updates["description"]
if "status" in updates:
ical["STATUS"] = "COMPLETED" if updates["status"] == "completed" else "NEEDS-ACTION"
todo.save()
return True
except Exception as e:
print(f"Error updating remote VTODO {source_id}: {e}")
print(f"Remote VTODO with UID {source_id} not found.")
return False
@@ -0,0 +1,207 @@
"""Phone number extraction and normalization for German telephone formats.
Supports:
- +49 international format (e.g., +49 30 12345678, +49-171-1234567)
- 0xxx domestic format (e.g., 030 12345678, 0171-1234567)
- (0xxx) parenthesized area code format (e.g., (030) 12345678, (0171) 1234567)
"""
import re
# Pattern 1: +49 international format
# +49 followed by 9 to 12 digits with optional spaces or hyphens
_INTERNATIONAL_PATTERN = re.compile(
r"\+49[\s\-]?(\d[\d\s\-]{8,14}\d)"
)
# Pattern 2: 0xxx domestic format
# 0 followed by area code (2-5 digits) then subscriber (4-8 digits), with optional separators
_DOMESTIC_PATTERN = re.compile(
r"(?<!\()0(\d{2,5})[\s\-/]?(\d[\d\s\-]{3,9}\d)"
)
# Pattern 3: (0xxx) parenthesized area code format
# (0xxx) followed by subscriber digits with optional separators
_PARENTHESIZED_PATTERN = re.compile(
r"\(0(\d{2,5})\)[\s\-]?(\d[\d\s\-]{3,9}\d)"
)
def extract_phone_number(text: str) -> str | None:
"""Extract the first German phone number from text.
Supports formats:
- +49 xxx xxxx xxxx (international)
- 0xxx xxxxxxx (domestic with area code)
- (0xxx) xxxxxxx (domestic with parenthesized area code)
Returns the raw matched string or None if no match found.
"""
if not text:
return None
# Collect all matches with their start positions to find the first one
matches: list[tuple[int, str]] = []
for m in _INTERNATIONAL_PATTERN.finditer(text):
matches.append((m.start(), m.group(0)))
for m in _PARENTHESIZED_PATTERN.finditer(text):
matches.append((m.start(), m.group(0)))
for m in _DOMESTIC_PATTERN.finditer(text):
# Avoid matching numbers already captured by parenthesized pattern
start = m.start()
raw = m.group(0)
# Check this isn't inside parentheses
if start > 0 and text[start - 1] == "(":
continue
matches.append((start, raw))
if not matches:
return None
# Return the first match by position
matches.sort(key=lambda x: x[0])
return matches[0][1]
def normalize_phone_number(raw: str) -> str:
"""Normalize a phone number by removing separators and standardizing format.
Strips spaces, hyphens, slashes, and parentheses.
Converts domestic format (leading 0) to international (+49) format.
Returns the normalized number string (e.g., "+4930123456").
"""
if not raw:
return raw
# Remove all separators: spaces, hyphens, slashes, parentheses
cleaned = re.sub(r"[\s\-/\(\)]", "", raw)
# Convert domestic (0xxx) to international (+49xxx)
if cleaned.startswith("0") and not cleaned.startswith("+"):
cleaned = "+49" + cleaned[1:]
return cleaned
def parse_phone_number(text: str) -> dict | None:
"""Parse a phone number string into components.
Returns a dict with keys: country_code, area_code, subscriber
or None if the text cannot be parsed as a German phone number.
"""
if not text:
return None
# First try to extract a phone number from the text
raw = extract_phone_number(text)
if raw is None:
# Try parsing the text directly as a phone number
normalized = normalize_phone_number(text)
if not normalized.startswith("+49"):
return None
digits_after_country = normalized[3:]
if not digits_after_country or len(digits_after_country) < 5:
return None
# Use heuristic: area codes are 2-5 digits, rest is subscriber
area_code, subscriber = _split_area_subscriber(digits_after_country)
return {
"country_code": "+49",
"area_code": area_code,
"subscriber": subscriber,
}
# Parse the extracted raw number
# Try international pattern
m = _INTERNATIONAL_PATTERN.match(raw)
if m:
digits = re.sub(r"[\s\-]", "", m.group(1))
area_code, subscriber = _split_area_subscriber(digits)
return {
"country_code": "+49",
"area_code": area_code,
"subscriber": subscriber,
}
# Try parenthesized pattern
m = _PARENTHESIZED_PATTERN.match(raw)
if m:
area_code = m.group(1)
subscriber = re.sub(r"[\s\-]", "", m.group(2))
return {
"country_code": "+49",
"area_code": area_code,
"subscriber": subscriber,
}
# Try domestic pattern
m = _DOMESTIC_PATTERN.match(raw)
if m:
area_code = m.group(1)
subscriber = re.sub(r"[\s\-]", "", m.group(2))
return {
"country_code": "+49",
"area_code": area_code,
"subscriber": subscriber,
}
return None
def _split_area_subscriber(digits: str) -> tuple[str, str]:
"""Split a digit string into area code and subscriber number.
Uses known German area code lengths:
- 2 digits: major cities (30=Berlin, 40=Hamburg, etc.)
- 3 digits: large cities (211=Düsseldorf, 221=Köln, etc.)
- 4 digits: medium cities and mobile prefixes (171, 172, etc.)
- 5 digits: smaller areas
Heuristic: if total digits <= 10, use shorter area code.
"""
# Known 2-digit area codes (major cities)
two_digit_codes = {"30", "40", "69", "89"}
# Known 3-digit mobile prefixes
three_digit_mobile = {
"151", "152", "153", "155", "156", "157", "159",
"160", "162", "163", "170", "171", "172", "173",
"174", "175", "176", "177", "178", "179",
}
# Check 2-digit area codes
if len(digits) >= 4 and digits[:2] in two_digit_codes:
return digits[:2], digits[2:]
# Check 3-digit codes (mobile and city)
if len(digits) >= 5 and digits[:3] in three_digit_mobile:
return digits[:3], digits[3:]
# Check common 3-digit city codes
three_digit_city = {
"211", "212", "221", "228", "231", "241", "251",
"261", "271", "281", "291",
"311", "321", "331", "341", "351", "361", "371", "381", "391",
"421", "431", "441", "451", "461", "471", "481",
"511", "521", "531", "541", "551", "561", "571", "581", "591",
"611", "621", "631", "641", "651", "661", "671", "681",
"711", "721", "731", "741", "751", "761", "771",
"811", "821", "831", "841", "851", "861", "871",
"911", "921", "931", "941", "951", "961", "971", "981", "991",
}
if len(digits) >= 5 and digits[:3] in three_digit_city:
return digits[:3], digits[3:]
# Default heuristic based on total length
if len(digits) <= 8:
# Short number: assume 3-digit area code
return digits[:3], digits[3:]
elif len(digits) <= 10:
# Medium: assume 4-digit area code
return digits[:4], digits[4:]
else:
# Long: assume 4-digit area code
return digits[:4], digits[4:]
@@ -0,0 +1,140 @@
"""Prioritization Engine — generates actionable suggestions based on task state."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from app.models import Task
BERLIN_TZ = ZoneInfo("Europe/Berlin")
def generate_suggestions(db: Session) -> list[dict]:
"""Analyze open tasks and generate prioritization suggestions.
Returns a list of suggestion dicts with: id, content, type, severity.
"""
now = datetime.now(BERLIN_TZ)
today = now.date()
suggestions = []
suggestion_id = 1
open_tasks = db.query(Task).filter(Task.status == "open").all()
if not open_tasks:
return []
# --- 1. Overdue tasks ---
overdue_tasks = [
t for t in open_tasks
if t.due_date and t.due_date.date() < today
]
for t in overdue_tasks[:3]: # Limit to top 3
days_overdue = (today - t.due_date.date()).days
suggestions.append({
"id": suggestion_id,
"content": f"⚠️ \"{t.title}\" is {days_overdue} day{'s' if days_overdue != 1 else ''} overdue. Reschedule or complete it.",
"type": "overdue",
"severity": "high",
"task_id": t.id,
})
suggestion_id += 1
# --- 2. Due tomorrow ---
tomorrow = today + timedelta(days=1)
due_tomorrow = [
t for t in open_tasks
if t.due_date and t.due_date.date() == tomorrow
]
for t in due_tomorrow:
suggestions.append({
"id": suggestion_id,
"content": f"📅 \"{t.title}\" is due tomorrow.",
"type": "upcoming",
"severity": "medium",
"task_id": t.id,
})
suggestion_id += 1
# --- 3. Due this week ---
week_end = today + timedelta(days=7)
due_this_week = [
t for t in open_tasks
if t.due_date and today < t.due_date.date() <= week_end
and t.due_date.date() != tomorrow # Already covered above
]
if len(due_this_week) > 3:
suggestions.append({
"id": suggestion_id,
"content": f"📋 You have {len(due_this_week)} tasks due this week. Plan your time.",
"type": "workload",
"severity": "medium",
})
suggestion_id += 1
# --- 4. High-priority tasks without due dates ---
high_no_date = [
t for t in open_tasks
if t.priority and t.priority <= 2 and not t.due_date
]
for t in high_no_date[:2]: # Limit to top 2
suggestions.append({
"id": suggestion_id,
"content": f"🎯 \"{t.title}\" is high priority but has no deadline. Set a due date.",
"type": "missing_date",
"severity": "medium",
"task_id": t.id,
})
suggestion_id += 1
# --- 5. Tasks idle for 7+ days ---
idle_threshold = now - timedelta(days=7)
idle_tasks = [
t for t in open_tasks
if t.created_at and t.created_at < idle_threshold
and (not t.due_date) # Only flag idle tasks without deadlines
and t.priority and t.priority >= 3 # Low/medium priority
]
if len(idle_tasks) > 5:
suggestions.append({
"id": suggestion_id,
"content": f"🧹 {len(idle_tasks)} low-priority tasks have been sitting for over a week. Review and clean up.",
"type": "stale",
"severity": "low",
})
suggestion_id += 1
elif idle_tasks:
oldest = max(idle_tasks, key=lambda t: (now - t.created_at).days if t.created_at else 0)
if oldest.created_at:
days_idle = (now - oldest.created_at).days
suggestions.append({
"id": suggestion_id,
"content": f"🧹 \"{oldest.title}\" has been open for {days_idle} days. Still relevant?",
"type": "stale",
"severity": "low",
"task_id": oldest.id,
})
suggestion_id += 1
# --- 6. Too many high-priority tasks ---
high_priority_count = len([t for t in open_tasks if t.priority and t.priority <= 2])
if high_priority_count > 5:
suggestions.append({
"id": suggestion_id,
"content": f"🔥 You have {high_priority_count} high-priority tasks. If everything is urgent, nothing is. Consider demoting some.",
"type": "overload",
"severity": "medium",
})
suggestion_id += 1
# --- 7. No tasks due soon (might need planning) ---
tasks_with_dates = [t for t in open_tasks if t.due_date]
if len(open_tasks) > 10 and len(tasks_with_dates) < 3:
suggestions.append({
"id": suggestion_id,
"content": "📆 Most of your tasks have no due dates. Consider scheduling your top priorities.",
"type": "planning",
"severity": "low",
})
suggestion_id += 1
return suggestions
File diff suppressed because one or more lines are too long
+52
View File
@@ -0,0 +1,52 @@
version: "3.8"
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: orgmylife
POSTGRES_USER: orgmylife
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U orgmylife"]
interval: 5s
timeout: 3s
retries: 5
app:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://orgmylife:${POSTGRES_PASSWORD:-changeme}@db:5432/orgmylife
NEXTCLOUD_URL: ${NEXTCLOUD_URL}
NEXTCLOUD_USERNAME: ${NEXTCLOUD_USERNAME}
NEXTCLOUD_PASSWORD: ${NEXTCLOUD_PASSWORD}
IMAP_SERVER: ${IMAP_SERVER}
IMAP_PORT: ${IMAP_PORT:-993}
EMAIL_USERNAME: ${EMAIL_USERNAME}
EMAIL_PASSWORD: ${EMAIL_PASSWORD}
EMAIL_WEBMAIL_URL: ${EMAIL_WEBMAIL_URL:-}
GMAIL_USERNAME: ${GMAIL_USERNAME:-}
GMAIL_PASSWORD: ${GMAIL_PASSWORD:-}
# API authentication for remote access
API_SECRET: ${API_SECRET:-}
APP_USERNAME: ${APP_USERNAME:-admin}
APP_PASSWORD: ${APP_PASSWORD:-changeme}
volumes:
- ./BACKLOG.md:/app/BACKLOG.md:ro
command: >
sh -c "python -c 'from app.db.session import engine, Base; from app.models import *; Base.metadata.create_all(bind=engine)' &&
uvicorn app.main:app --host 0.0.0.0 --port 8000"
volumes:
pgdata:
+891
View File
@@ -0,0 +1,891 @@
/* Base tidy styles */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f9f9f9;
margin: 0;
padding: 0;
}
header {
background-color: #ffffff;
padding: 1rem 2rem;
border-bottom: 1px solid #eaeaea;
}
h1 {
margin: 0;
font-size: 1.5rem;
color: #111;
}
main {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.top-nav {
display: flex;
gap: 1rem;
}
.nav-btn {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
color: #555;
}
.nav-btn:hover {
color: #111;
}
.nav-btn.active {
border-bottom: 2px solid #1a73e8;
color: #1a73e8;
font-weight: 500;
}
#dashboard {
/* Removed default card style from wrapper */
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.card {
background: #ffffff;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border: 1px solid #eaeaea;
}
.card h2 {
margin-top: 0;
font-size: 1.25rem;
color: #222;
border-bottom: 2px solid #f0f0f0;
padding-bottom: 0.5rem;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
.tidy-list {
list-style: none;
padding: 0;
margin: 0;
}
.tidy-list li {
padding: 0.75rem 0;
border-bottom: 1px solid #f5f5f5;
}
.tidy-list li:last-child {
border-bottom: none;
}
.priority-high {
color: #d93025;
font-weight: 500;
}
.suggestion-box {
background-color: #e8f0fe;
border-left: 4px solid #1a73e8;
padding: 0.75rem;
margin-top: 1rem;
font-size: 0.9rem;
}
/* Task Item Layout */
.task-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem 0;
border-bottom: 1px solid #f5f5f5;
transition: background 0.15s;
}
.task-item:last-child {
border-bottom: none;
}
.drag-handle {
cursor: grab;
color: #bbb;
font-size: 1.1rem;
user-select: none;
padding-top: 2px;
}
.drag-handle:active {
cursor: grabbing;
}
.task-content {
flex: 1;
min-width: 0;
}
.task-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.btn-edit, .btn-done {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
padding: 2px 6px;
line-height: 1;
color: #555;
transition: all 0.15s;
}
.btn-edit:hover {
background: #e8f0fe;
border-color: #1a73e8;
color: #1a73e8;
}
.btn-done:hover {
background: #e6f4ea;
border-color: #34a853;
color: #34a853;
}
.priority-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
background: #eee;
}
.priority-badge.priority-1 {
background: #fce8e6;
color: #d93025;
}
.priority-badge.priority-2 {
background: #fef7e0;
color: #ea8600;
}
.priority-badge.priority-3 {
background: #e8f0fe;
color: #1a73e8;
}
/* Drag and Drop States */
.task-item.dragging {
opacity: 0.4;
}
.task-item.drag-over {
border-top: 2px solid #1a73e8;
padding-top: calc(0.75rem - 2px);
}
/* Delete button */
.btn-delete {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
padding: 2px 6px;
line-height: 1;
color: #555;
transition: all 0.15s;
}
.btn-delete:hover {
background: #fce8e6;
border-color: #d93025;
color: #d93025;
}
/* Agent-ready button */
.btn-agent {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
padding: 2px 6px;
line-height: 1;
color: #aaa;
transition: all 0.15s;
}
.btn-agent:hover {
background: #e8f0fe;
border-color: #1a73e8;
}
.btn-agent.active {
background: #e8f0fe;
border-color: #1a73e8;
color: #1a73e8;
}
/* Agent-ready task highlight */
.task-item.agent-ready {
border-left: 3px solid #1a73e8;
padding-left: 0.5rem;
}
/* Sort bar */
.sort-bar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #f0f0f0;
}
.sort-btn {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
padding: 4px 10px;
font-size: 0.8rem;
cursor: pointer;
color: #555;
transition: all 0.15s;
}
.sort-btn:hover {
border-color: #1a73e8;
color: #1a73e8;
}
.sort-btn.active {
background: #1a73e8;
border-color: #1a73e8;
color: white;
}
/* Due date badge */
.due-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7rem;
background: #e8f5e9;
color: #2e7d32;
}
.due-badge.overdue {
background: #fce8e6;
color: #d93025;
font-weight: 600;
}
/* Mobile responsiveness */
@media (max-width: 600px) {
header {
padding: 1rem;
}
main {
padding: 1rem;
}
.top-nav {
flex-wrap: wrap;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.sort-bar {
flex-wrap: wrap;
}
/* Task item: stack vertically on mobile */
.task-item {
flex-direction: column;
gap: 0.25rem;
}
/* Task content header: stack title and actions into two rows */
.task-content > div:first-child {
flex-direction: column !important;
align-items: flex-start !important;
gap: 0.5rem;
}
/* Action buttons: full-width row below the title */
.task-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
width: 100%;
padding-top: 0.4rem;
border-top: 1px solid #f0f0f0;
margin-top: 0.25rem;
}
.drag-handle {
display: none;
}
.move-arrows {
display: none;
}
#add-task-form {
flex-direction: column;
}
#add-task-form input,
#add-task-form select,
#add-task-form button {
width: 100% !important;
min-width: unset !important;
}
}
/* Origin badge */
.origin-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7rem;
background: #f3e5f5;
color: #7b1fa2;
}
/* Move arrows */
.move-arrows {
display: flex;
flex-direction: column;
gap: 1px;
margin-right: 4px;
}
.btn-move-top, .btn-move-bottom {
background: none;
border: none;
cursor: pointer;
font-size: 0.75rem;
color: #aaa;
padding: 0;
line-height: 1;
transition: color 0.15s;
}
.btn-move-top:hover, .btn-move-bottom:hover {
color: #1a73e8;
}
@media (max-width: 600px) {
.move-arrows {
display: none;
}
}
/* Suggestion severity */
.suggestion-high {
background-color: #fce8e6;
border-left: 4px solid #d93025;
}
.suggestion-medium {
background-color: #fef7e0;
border-left: 4px solid #ea8600;
}
.suggestion-low {
background-color: #e8f5e9;
border-left: 4px solid #34a853;
}
/* Retro stats */
.retro-stats {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.retro-stat {
text-align: center;
flex: 1;
min-width: 60px;
}
.retro-num {
font-size: 2rem;
font-weight: bold;
color: #1a73e8;
}
/* Origin link (clickable) */
.origin-link {
text-decoration: none;
cursor: pointer;
}
.origin-link:hover {
background: #e1bee7;
text-decoration: underline;
}
/* Purge (permanent delete) button */
.btn-purge {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
padding: 2px 5px;
line-height: 1;
color: #555;
transition: all 0.15s;
}
.btn-purge:hover {
background: #fce8e6;
border-color: #d93025;
color: #d93025;
}
/* Kanban Board */
.kanban-board {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-bottom: 1rem;
}
.kanban-column {
min-width: 200px;
flex: 1;
background: #f5f5f5;
border-radius: 8px;
padding: 0.75rem;
}
.kanban-column h3 {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
color: #333;
}
.kanban-cards {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 50px;
}
.kanban-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 0.6rem;
font-size: 0.85rem;
}
.kanban-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.kanban-repo {
display: inline-block;
background: #e8f0fe;
color: #1a73e8;
padding: 1px 6px;
border-radius: 3px;
font-size: 0.7rem;
margin-top: 4px;
}
.kanban-desc {
font-size: 0.75rem;
color: #666;
margin: 4px 0 0;
}
.kanban-actions {
display: flex;
gap: 4px;
margin-top: 6px;
}
.kanban-move {
background: none;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
padding: 2px 8px;
font-size: 0.75rem;
color: #555;
}
.kanban-move:hover {
background: #e8f0fe;
border-color: #1a73e8;
color: #1a73e8;
}
.kanban-empty {
color: #999;
font-size: 0.8rem;
text-align: center;
padding: 1rem 0;
}
.btn-delete-project {
background: none;
border: none;
cursor: pointer;
color: #aaa;
font-size: 0.8rem;
}
.btn-delete-project:hover {
color: #d93025;
}
@media (max-width: 768px) {
.kanban-board {
flex-direction: column;
}
.kanban-column {
min-width: unset;
}
}
/* Email sender subtitle */
.task-sender {
font-size: 0.8rem;
color: #7b1fa2;
margin-top: 2px;
}
/* --- Calls View --- */
.call-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #f5f5f5;
}
.call-item:last-child {
border-bottom: none;
}
.call-item a[href^="tel:"] {
color: #1a73e8;
text-decoration: none;
}
.call-item a[href^="tel:"]:hover {
text-decoration: underline;
}
.btn-call {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
padding: 2px 6px;
line-height: 1;
color: #555;
transition: all 0.15s;
}
.btn-call:hover {
background: #e8f0fe;
border-color: #1a73e8;
color: #1a73e8;
}
.btn-call-done {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
padding: 4px 10px;
line-height: 1;
color: #555;
transition: all 0.15s;
}
.btn-call-done:hover {
background: #e6f4ea;
border-color: #34a853;
color: #34a853;
}
.btn-call-delete {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
padding: 4px 10px;
line-height: 1;
color: #555;
transition: all 0.15s;
}
.btn-call-delete:hover {
background: #fce8e6;
border-color: #d93025;
color: #d93025;
}
/* Priority badge: low (missing variant) */
.priority-badge.priority-4 {
background: #f5f5f5;
color: #757575;
}
/* Inline phone edit in calls view */
.call-phone-input {
border: 1px solid #ddd;
border-radius: 4px;
padding: 2px 6px;
font-size: 0.85rem;
width: 140px;
transition: border-color 0.15s;
}
.call-phone-input:focus {
outline: none;
border-color: #1a73e8;
}
/* Snooze button */
.btn-snooze {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
padding: 2px 6px;
line-height: 1;
color: #555;
transition: all 0.15s;
}
.btn-snooze:hover {
background: #fef7e0;
border-color: #ea8600;
color: #ea8600;
}
/* Label badges */
.label-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 10px;
font-size: 0.7rem;
background: #e0f2f1;
color: #00695c;
margin-right: 3px;
}
/* --- Conflict Resolution --- */
/* Conflict badge on task items */
.conflict-badge {
display: inline-block;
cursor: pointer;
font-size: 0.9rem;
margin-left: 6px;
animation: conflict-pulse 2s infinite;
vertical-align: middle;
}
.conflict-badge:hover {
transform: scale(1.2);
}
@keyframes conflict-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Task item with conflict highlight */
.task-item.has-conflict {
border-left: 3px solid #ea8600;
padding-left: 0.5rem;
}
/* Conflict notice on dashboard */
.conflict-notice {
background: #fef7e0;
border: 1px solid #ea8600;
border-left: 4px solid #ea8600;
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #5d4e37;
}
.conflict-notice a {
color: #1a73e8;
text-decoration: underline;
cursor: pointer;
}
/* Conflict modal overlay */
.conflict-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
}
.conflict-modal {
background: white;
border-radius: 12px;
padding: 1.5rem;
max-width: 700px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.conflict-modal h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.conflict-comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 1rem 0;
}
@media (max-width: 600px) {
.conflict-comparison {
grid-template-columns: 1fr;
}
}
.conflict-version {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1rem;
}
.conflict-version h4 {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
}
.conflict-local {
border-color: #1a73e8;
background: #f8fbff;
}
.conflict-remote {
border-color: #ea8600;
background: #fffdf5;
}
.conflict-field {
font-size: 0.85rem;
margin-bottom: 0.5rem;
word-break: break-word;
}
.conflict-field strong {
color: #333;
}
.conflict-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
justify-content: flex-end;
}
.conflict-btn-local {
background: #1a73e8 !important;
color: white !important;
border: none !important;
padding: 8px 16px !important;
border-radius: 4px !important;
cursor: pointer !important;
font-weight: 500 !important;
}
.conflict-btn-local:hover {
background: #1557b0 !important;
}
.conflict-btn-remote {
background: #ea8600 !important;
color: white !important;
border: none !important;
padding: 8px 16px !important;
border-radius: 4px !important;
cursor: pointer !important;
font-weight: 500 !important;
}
.conflict-btn-remote:hover {
background: #c77200 !important;
}
.conflict-btn-cancel {
background: #f5f5f5 !important;
color: #555 !important;
border: 1px solid #ddd !important;
padding: 8px 16px !important;
border-radius: 4px !important;
cursor: pointer !important;
}
.conflict-btn-cancel:hover {
background: #e8e8e8 !important;
}
+224
View File
@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OrgMyLife</title>
<link rel="stylesheet" href="/static/css/style.css?v=17">
</head>
<body>
<header>
<h1>OrgMyLife</h1>
<p>Your tidy coordination system.</p>
<nav class="top-nav" style="margin-top: 15px;">
<button id="nav-dashboard" class="nav-btn active">Dashboard</button>
<button id="nav-tasks" class="nav-btn">All Tasks</button>
<button id="nav-projects" class="nav-btn">Projects</button>
<button id="nav-calls" class="nav-btn">📞 Calls</button>
<button id="nav-digest" class="nav-btn">Daily Digest</button>
<button id="nav-retro" class="nav-btn">Weekly Retro</button>
</nav>
<div class="actions" style="margin-top: 10px;">
<button id="sync-all-btn" class="btn" style="padding: 8px 16px; cursor: pointer; background: #1a73e8; color: white; border: none; border-radius: 4px; font-weight: 500;">⟳ Sync All</button>
<button id="sync-backlog-btn" class="btn" style="padding: 8px 16px; cursor: pointer;">Sync Backlog</button>
<button id="sync-todo-btn" class="btn" style="padding: 8px 16px; cursor: pointer;">Sync ToDo</button>
<button id="sync-email-btn" class="btn" style="padding: 8px 16px; cursor: pointer;">Sync Email</button>
<button id="sync-gmail-btn" class="btn" style="padding: 8px 16px; cursor: pointer;">Sync Gmail</button>
</div>
</header>
<main>
<div id="dashboard">
<section id="digest-section" class="card">
<h2>Daily Digest</h2>
<p id="digest-content">Loading your digest...</p>
<div id="suggestions-container"></div>
</section>
<div class="grid-2">
<section id="priorities-section" class="card">
<h2>Top Priorities</h2>
<ul id="priorities-list" class="tidy-list">
<li>Loading priorities...</li>
</ul>
</section>
<section id="calendar-section" class="card">
<h2>Today's Calendar</h2>
<ul id="calendar-list" class="tidy-list">
<li>Loading events...</li>
</ul>
</section>
</div>
</div>
<div id="all-tasks-view" style="display: none; flex-direction: column; gap: 1.5rem;">
<section class="card">
<h2 id="form-heading">Add New Task</h2>
<form id="add-task-form" style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 10px;">
<input type="hidden" id="task-edit-id" value="">
<input type="text" id="task-title" placeholder="Task Title" required maxlength="200" style="flex: 1; min-width: 200px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<input type="text" id="task-desc" placeholder="Details (optional)" maxlength="5000" style="flex: 2; min-width: 200px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<input type="date" id="task-due" style="padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<select id="task-priority" style="padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<option value="4">Low</option>
<option value="3">Medium</option>
<option value="2">High</option>
<option value="1">Very High</option>
</select>
<input type="text" id="task-labels" placeholder="Labels (comma-separated)" maxlength="200" style="min-width: 160px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<button type="submit" id="form-submit-btn" class="btn" style="padding: 8px 16px; background: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer;">Add</button>
<button type="button" id="form-cancel-btn" class="btn" style="padding: 8px 16px; background: #666; color: white; border: none; border-radius: 4px; cursor: pointer; display: none;">Cancel</button>
</form>
</section>
<section class="card">
<h2>All Open Tasks <small style="font-weight: normal; font-size: 0.75rem; color: #888;">drag to reorder</small></h2>
<div class="sort-bar">
<span style="font-size: 0.85rem; color: #666;">Sort:</span>
<button class="sort-btn active" data-sort="">Manual</button>
<button class="sort-btn" data-sort="priority">Priority</button>
<button class="sort-btn" data-sort="due_date">Due Date</button>
<button class="sort-btn" data-sort="created">Created</button>
<span style="font-size: 0.85rem; color: #666; margin-left: 1rem;">Filter:</span>
<select id="filter-origin" style="padding: 3px 8px; font-size: 0.8rem; border: 1px solid #ddd; border-radius: 4px;">
<option value="">All sources</option>
<option value="manual">Manual</option>
<option value="nextcloud_todo">Nextcloud</option>
<option value="dhive_email">Email</option>
</select>
<select id="filter-agent" style="padding: 3px 8px; font-size: 0.8rem; border: 1px solid #ddd; border-radius: 4px;">
<option value="">All</option>
<option value="true">🤖 Agent</option>
<option value="false">Manual only</option>
</select>
<select id="filter-label" style="padding: 3px 8px; font-size: 0.8rem; border: 1px solid #ddd; border-radius: 4px;">
<option value="">All labels</option>
</select>
</div>
<ul id="all-tasks-list" class="tidy-list">
<li>Loading tasks...</li>
</ul>
</section>
</div>
<div id="digest-view" style="display: none; flex-direction: column; gap: 1.5rem;">
<section class="card">
<h2 id="digest-greeting">Loading...</h2>
<p id="digest-date" style="color: #666;"></p>
</section>
<div class="grid-2">
<section class="card">
<h2>⚠️ Overdue</h2>
<ul id="digest-overdue" class="tidy-list"><li>Loading...</li></ul>
</section>
<section class="card">
<h2>📅 Due Today</h2>
<ul id="digest-due-today" class="tidy-list"><li>Loading...</li></ul>
</section>
</div>
<div class="grid-2">
<section class="card">
<h2>🎯 Top Priorities</h2>
<ul id="digest-top-tasks" class="tidy-list"><li>Loading...</li></ul>
</section>
<section class="card">
<h2>📆 Today's Schedule</h2>
<ul id="digest-events" class="tidy-list"><li>Loading...</li></ul>
</section>
</div>
<section id="digest-calls-section" class="card" style="display: none;">
<h2>📞 Calls to make</h2>
<ul id="digest-calls" class="tidy-list"></ul>
</section>
</div>
<div id="retro-view" style="display: none; flex-direction: column; gap: 1.5rem;">
<section class="card">
<h2>📊 Weekly Retrospective</h2>
<p id="retro-period" style="color: #666;"></p>
</section>
<div class="grid-2">
<section class="card">
<h2>Summary</h2>
<div id="retro-summary"></div>
</section>
<section class="card">
<h2>Backlog Health</h2>
<div id="retro-health"></div>
</section>
</div>
<div class="grid-2">
<section class="card">
<h2>Priority Breakdown</h2>
<div id="retro-priorities"></div>
</section>
<section class="card">
<h2>⚠️ Still Overdue</h2>
<ul id="retro-overdue" class="tidy-list"><li>Loading...</li></ul>
</section>
</div>
</div>
<div id="projects-view" style="display: none; flex-direction: column; gap: 1.5rem;">
<section class="card">
<h2>AI Projects Board</h2>
<p style="color: #666; margin-top: 0;">Manage your AI agent tasks across repos</p>
<form id="add-project-task-form" style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px;">
<input type="text" id="pt-title" placeholder="Task title" required maxlength="200" style="flex: 2; min-width: 180px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<input type="text" id="pt-repo" placeholder="Repo (e.g. OrgMyLife)" maxlength="100" style="flex: 1; min-width: 120px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<button type="submit" class="btn" style="padding: 8px 16px; background: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer;">Add</button>
</form>
</section>
<div class="kanban-board">
<div class="kanban-column" data-status="backlog">
<h3>📋 Backlog</h3>
<div class="kanban-cards" id="kanban-backlog"></div>
</div>
<div class="kanban-column" data-status="ready">
<h3>🟢 Ready</h3>
<div class="kanban-cards" id="kanban-ready"></div>
</div>
<div class="kanban-column" data-status="in_progress">
<h3>⚙️ In Progress</h3>
<div class="kanban-cards" id="kanban-in_progress"></div>
</div>
<div class="kanban-column" data-status="blocked">
<h3>🚫 Blocked</h3>
<div class="kanban-cards" id="kanban-blocked"></div>
</div>
<div class="kanban-column" data-status="review">
<h3>👁 Review</h3>
<div class="kanban-cards" id="kanban-review"></div>
</div>
<div class="kanban-column" data-status="done">
<h3>✅ Done</h3>
<div class="kanban-cards" id="kanban-done"></div>
</div>
</div>
</div>
<div id="calls-view" style="display: none; flex-direction: column; gap: 1.5rem;">
<section class="card">
<h2>📞 Call List</h2>
<p style="color: #666; margin-top: 0;">Pending phone calls to make</p>
<form id="add-call-form" style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; align-items: center;">
<input type="text" id="call-person" placeholder="Person" required maxlength="200" style="flex: 1; min-width: 140px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<input type="text" id="call-reason" placeholder="Reason" required maxlength="500" style="flex: 2; min-width: 160px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<input type="text" id="call-phone" placeholder="Phone (optional)" maxlength="30" style="width: 140px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<select id="call-priority" style="padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<option value="4">Low</option>
<option value="3" selected>Medium</option>
<option value="2">High</option>
<option value="1">Very High</option>
</select>
<button type="submit" class="btn" style="padding: 8px 16px; background: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer;">Add Call</button>
</form>
</section>
<section class="card">
<div id="calls-list"></div>
</section>
</div>
</main>
<script src="/static/js/app.js?v=20"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
# Call List Feature Spec
## Overview
A new "Call List" tab that acts as a separate funnel for phone-call tasks.
## Requirements
### UI
- New tab: "📞 Calls" in the navigation
- Phone icon button (📞) on each task item (next to 🤖, ✎, ✓, ✕, 🗑)
- Clicking 📞 moves the task to the Call List
- Call List shows: person name, reason, phone number (if available), priority
- Own priority ordering within the Call List
### Data Model
- New model `CallItem` or reuse Task with `status = "call_list"`
- Fields: person, reason, phone_number, original_task_id, priority, status (pending/done)
- Phone number auto-extracted from email body if present
### Integration
- Daily Digest shows "📞 Calls to make" section with pending call items
- If the call item originated from an email, show the email sender and snippet
- Completing a call item can optionally mark the original task as done
### API
- POST /api/calls (create from task)
- GET /api/calls (list pending)
- PUT /api/calls/{id} (update status/priority)
- DELETE /api/calls/{id} (remove)
### Phone Number Extraction
- Regex scan of email body for phone patterns
- German formats: +49..., 0xxx..., (0xxx) xxx...
- Store extracted number, allow manual edit
+44
View File
@@ -0,0 +1,44 @@
alembic==1.18.4
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.13.0
caldav==3.2.0
charset-normalizer==3.4.7
click==8.3.3
colorama==0.4.6
dnspython==2.8.0
fastapi==0.136.1
greenlet==3.5.0
h11==0.16.0
httptools==0.7.1
icalendar==7.0.3
icalendar-searcher==1.0.5
idna==3.13
jh2==5.0.11
lxml==6.1.0
Mako==1.3.12
MarkupSafe==3.0.3
niquests==3.18.7
psycopg2-binary==2.9.12
pydantic==2.13.3
pydantic_core==2.46.3
python-dateutil==2.9.0.post0
python-dotenv==1.2.2
PyYAML==6.0.3
qh3==1.7.4
recurring-ical-events==3.8.1
six==1.17.0
SQLAlchemy==2.0.49
starlette==1.0.0
typing-inspection==0.4.2
typing_extensions==4.15.0
tzdata==2026.2
urllib3-future==2.19.913
uvicorn==0.46.0
wassima==2.0.6
watchfiles==1.1.1
websockets==16.0
x-wr-timezone==2.0.1
pytest==9.0.3
httpx==0.28.1
python-multipart==0.0.20
+46
View File
@@ -0,0 +1,46 @@
# Copy this file to .github/workflows/sync-to-orgmylife.yml in any repo
# Then add these secrets: ORGMYLIFE_USER, ORGMYLIFE_PASS, ORGMYLIFE_API_SECRET
#
# It reads PLAN.md for unchecked items (- [ ] ...) and syncs them to OrgMyLife
name: Sync Tasks to OrgMyLife
on:
push:
paths: ['PLAN.md', 'TASKS.md', 'BACKLOG.md']
workflow_dispatch: {}
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Parse open tasks and sync to OrgMyLife
run: |
REPO_NAME="${{ github.event.repository.name }}"
# Find open tasks from PLAN.md, TASKS.md, or BACKLOG.md
TASKS="[]"
for file in PLAN.md TASKS.md BACKLOG.md; do
if [ -f "$file" ]; then
# Extract unchecked items: "- [ ] Some task"
ITEMS=$(grep -E '^\s*- \[ \]' "$file" | sed 's/.*- \[ \] //' | head -50)
if [ -n "$ITEMS" ]; then
TASKS=$(echo "$ITEMS" | jq -R -s 'split("\n") | map(select(length > 0)) | map({"title": ., "status": "backlog"})')
break
fi
fi
done
echo "Found tasks: $TASKS"
# Sync to OrgMyLife
PAYLOAD=$(jq -n --arg repo "$REPO_NAME" --argjson tasks "$TASKS" '{"repo": $repo, "tasks": $tasks}')
curl -s -X POST \
-u "${{ secrets.ORGMYLIFE_USER }}:${{ secrets.ORGMYLIFE_PASS }}" \
-H "X-API-Key: ${{ secrets.ORGMYLIFE_API_SECRET }}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"https://api.andreknie.de/api/projects/sync"
+41
View File
@@ -0,0 +1,41 @@
"""Shared test fixtures for OrgMyLife tests.
Provides:
- Fresh in-memory SQLite DB per test session (avoids stale schema issues)
- Authenticated test client (bypasses session auth via API_SECRET)
"""
import os
import pytest
# Set API_SECRET before importing app so verify_session accepts Bearer token
os.environ["API_SECRET"] = "test-secret"
os.environ["DATABASE_URL"] = "sqlite:///./test_orgmylife.db"
from fastapi.testclient import TestClient
from app.db.session import Base, engine, SessionLocal
from app.main import app
@pytest.fixture(autouse=True)
def fresh_db():
"""Drop and recreate all tables for each test to avoid schema drift."""
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
@pytest.fixture
def client():
"""Authenticated test client with Bearer token."""
c = TestClient(app)
c.headers["Authorization"] = "Bearer test-secret"
return c
@pytest.fixture
def db():
"""Database session for direct DB assertions."""
session = SessionLocal()
yield session
session.close()
+118
View File
@@ -0,0 +1,118 @@
"""Tests for email_sync and nextcloud_sync service logic (with mocks)."""
import pytest
from unittest.mock import MagicMock, patch
from email.mime.text import MIMEText
import email as email_lib
from app.services.email_sync import (
_decode_header_value,
_get_body_snippet,
LABEL_TODO,
LABEL_DONE,
)
# ---------------------------------------------------------------------------
# email_sync unit tests
# ---------------------------------------------------------------------------
def test_decode_header_value_plain():
assert _decode_header_value("Hello World") == "Hello World"
def test_decode_header_value_encoded():
encoded = "=?utf-8?b?SGVsbG8gV29ybGQ=?=" # "Hello World" base64
assert _decode_header_value(encoded) == "Hello World"
def test_decode_header_value_none():
assert _decode_header_value(None) == ""
def test_get_body_snippet_plain():
msg = MIMEText("This is the email body content.")
result = _get_body_snippet(email_lib.message_from_string(msg.as_string()))
assert "This is the email body content." in result
def test_get_body_snippet_truncated():
long_body = "A" * 500
msg = MIMEText(long_body)
result = _get_body_snippet(email_lib.message_from_string(msg.as_string()), max_chars=100)
assert len(result) <= 100
def test_sync_emails_creates_task():
"""sync_emails() should create a Task for each untracked inbox email."""
from app.db.session import SessionLocal
from app.models import Task
# Build a fake raw email bytes
raw_msg = MIMEText("Task body snippet")
raw_msg["Subject"] = "Do the important thing"
raw_msg["From"] = "sender@example.com"
raw_msg["Message-ID"] = "<test-email-001@example.com>"
raw_bytes = raw_msg.as_bytes()
mock_conn = MagicMock()
mock_conn.uid.side_effect = [
# SEARCH call
("OK", [b"1"]),
# FETCH call
("OK", [(b"1 (RFC822 {123})", raw_bytes)]),
# STORE (apply label) call
("OK", []),
]
with patch("app.services.email_sync._get_imap_connection", return_value=mock_conn):
from app.services.email_sync import sync_emails
result = sync_emails()
assert result is True
db = SessionLocal()
task = db.query(Task).filter(Task.source_id == "<test-email-001@example.com>").first()
assert task is not None
assert task.title == "Do the important thing"
assert task.origin == "dhive_email"
db.delete(task)
db.commit()
db.close()
def test_mark_email_done_and_archive():
"""mark_email_done_and_archive() should swap labels and copy to Archive."""
mock_conn = MagicMock()
# SEARCH returns one UID
mock_conn.uid.side_effect = [
("OK", [b"42"]), # SEARCH
("OK", []), # STORE remove todo
("OK", []), # STORE add done
("OK", []), # COPY to archive
("OK", []), # STORE add \Deleted
]
mock_conn.expunge.return_value = ("OK", [])
with patch("app.services.email_sync._get_imap_connection", return_value=mock_conn):
from app.services.email_sync import mark_email_done_and_archive
result = mark_email_done_and_archive("<test-email-001@example.com>")
assert result is True
# ---------------------------------------------------------------------------
# nextcloud_sync unit tests
# ---------------------------------------------------------------------------
def test_update_remote_todo_not_found():
"""update_remote_todo() should return False when UID not found in any calendar."""
mock_principal = MagicMock()
mock_calendar = MagicMock()
mock_principal.calendars.return_value = [mock_calendar]
mock_calendar.todos.return_value = [] # no todos
with patch("app.services.nextcloud_sync._get_caldav_principal", return_value=mock_principal):
from app.services.nextcloud_sync import update_remote_todo
result = update_remote_todo("nonexistent-uid", {"status": "completed"})
assert result is False
+33
View File
@@ -0,0 +1,33 @@
"""Tests for task CRUD API endpoints."""
from app.models import Task
def test_update_task(client, db):
"""Create a task, update it, verify changes persisted."""
# 1. Create a task
response = client.post("/api/tasks", json={
"title": "Initial Task",
"description": "Initial description",
"priority": 3
})
assert response.status_code == 200
task_id = response.json()["task_id"]
# 2. Update title, description, and priority
update_data = {
"title": "Updated Task",
"description": "Updated description",
"priority": 1
}
response = client.put(f"/api/tasks/{task_id}", json=update_data)
# 3. Assert success
assert response.status_code == 200
assert response.json()["status"] == "success"
# 4. Verify in DB
task = db.query(Task).filter(Task.id == task_id).first()
assert task.title == "Updated Task"
assert task.description == "Updated description"
assert task.priority == 1