feat: group private apps into andreknie-privat
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
.git
|
||||
.env
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
*.db
|
||||
alembic/versions/__pycache__
|
||||
tests/__pycache__
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
@@ -1,6 +0,0 @@
|
||||
.env
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.db
|
||||
@@ -1,77 +0,0 @@
|
||||
stages:
|
||||
- sync
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DEPLOY_HOST: "217.160.174.2"
|
||||
DEPLOY_USER: "root"
|
||||
DEPLOY_PATH: "/opt/OrgMyLife"
|
||||
|
||||
.ssh_setup: &ssh_setup
|
||||
before_script:
|
||||
- apt-get update -qq && apt-get install -y openssh-client rsync curl
|
||||
- install -d -m 700 ~/.ssh
|
||||
- echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key
|
||||
- chmod 600 ~/.ssh/deploy_key
|
||||
- ssh-keyscan -H "${DEPLOY_HOST}" > ~/.ssh/known_hosts
|
||||
- chmod 600 ~/.ssh/known_hosts
|
||||
|
||||
deploy-vps:
|
||||
stage: deploy
|
||||
image: node:20
|
||||
<<: *ssh_setup
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
|
||||
changes:
|
||||
- "shared/OrgMyLife/**/*"
|
||||
script:
|
||||
- ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" "install -d -m 755 ${DEPLOY_PATH}"
|
||||
- |
|
||||
cat <<EOF > .env
|
||||
POSTGRES_PASSWORD=$ENV_POSTGRES_PASSWORD
|
||||
API_SECRET=$ENV_API_SECRET
|
||||
APP_USERNAME=$ENV_APP_USERNAME
|
||||
APP_PASSWORD=$ENV_APP_PASSWORD
|
||||
NEXTCLOUD_URL=$ENV_NEXTCLOUD_URL
|
||||
NEXTCLOUD_USERNAME=$ENV_NEXTCLOUD_USERNAME
|
||||
NEXTCLOUD_PASSWORD=$ENV_NEXTCLOUD_PASSWORD
|
||||
IMAP_SERVER=$ENV_IMAP_SERVER
|
||||
IMAP_PORT=993
|
||||
EMAIL_USERNAME=$ENV_EMAIL_USERNAME
|
||||
EMAIL_PASSWORD=$ENV_EMAIL_PASSWORD
|
||||
EMAIL_WEBMAIL_URL=$ENV_EMAIL_WEBMAIL_URL
|
||||
GMAIL_USERNAME=$ENV_GMAIL_USERNAME
|
||||
GMAIL_PASSWORD=$ENV_GMAIL_PASSWORD
|
||||
EOF
|
||||
- rsync -az --delete -e "ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" shared/OrgMyLife/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/"
|
||||
- rsync -az -e "ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" .env "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/.env"
|
||||
- ssh -i ~/.ssh/deploy_key -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes "${DEPLOY_USER}@${DEPLOY_HOST}" "cd ${DEPLOY_PATH} && docker compose up -d --build"
|
||||
|
||||
collect-tasks:
|
||||
stage: sync
|
||||
image: alpine:latest
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "schedule" && $SCHEDULE_TARGET == "collect-tasks"'
|
||||
script:
|
||||
- apk add --no-cache curl jq
|
||||
- |
|
||||
curl -s -u "${ORGMYLIFE_USER}:${ORGMYLIFE_PASS}" \
|
||||
-H "Authorization: Bearer ${ORGMYLIFE_API_SECRET}" \
|
||||
"https://api.andreknie.de/api/orchestrator/tasks" > /tmp/tasks.json
|
||||
- echo "Tasks fetched. Since GitLab handles issues differently, issue creation logic needs to be adapted for GitLab API."
|
||||
|
||||
backup-dismissed:
|
||||
stage: sync
|
||||
image: alpine:latest
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "schedule" && $SCHEDULE_TARGET == "backup-dismissed"'
|
||||
script:
|
||||
- apk add --no-cache curl git
|
||||
- |
|
||||
curl -s -u "${ORGMYLIFE_USER}:${ORGMYLIFE_PASS}" \
|
||||
-H "Authorization: Bearer ${ORGMYLIFE_API_SECRET}" \
|
||||
"https://api.andreknie.de/api/tasks/dismissed" > shared/OrgMyLife/dismissed_tasks.json
|
||||
- git config user.name "gitlab-ci[bot]"
|
||||
- git config user.email "gitlab-ci[bot]@gitlab.dhive.io"
|
||||
- git add shared/OrgMyLife/dismissed_tasks.json
|
||||
- git diff --cached --quiet || (git commit -m "chore: backup dismissed task IDs [skip ci]" && git push "https://oauth2:${PROJECT_ACCESS_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" HEAD:$CI_COMMIT_BRANCH)
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,122 +0,0 @@
|
||||
# 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
|
||||
---
|
||||
@@ -1,125 +0,0 @@
|
||||
# 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._
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,27 +0,0 @@
|
||||
| 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 | | |
|
||||
@@ -1,16 +0,0 @@
|
||||
# Component: OrgMyLife
|
||||
|
||||
## Description
|
||||
Personal task, call, and digest coordination system (FastAPI + PostgreSQL)
|
||||
|
||||
## Metadata
|
||||
- **Deployment Target:** vps-docker
|
||||
- **Upstream URL:** https://github.com/DoctoDre/OrgMyLife
|
||||
- **Status:** active
|
||||
|
||||
## Interconnections
|
||||
- (to be documented)
|
||||
|
||||
## Notes
|
||||
- Part of shared context in the Orchestrator monorepo
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,180 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,3 +0,0 @@
|
||||
# Done Issues
|
||||
|
||||
_Empty — no issues to process._
|
||||
@@ -1,31 +0,0 @@
|
||||
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"]
|
||||
@@ -1,31 +0,0 @@
|
||||
# GitLab Migration Plan & Status
|
||||
|
||||
## What has been done
|
||||
- The scheduled GitHub workflows have been disabled (`Collect AI Tasks`, `Sync Issues to File`, `Backup Dismissed Tasks`) because of GitHub billing limits that were causing pipeline failures and costing money.
|
||||
- This repository is being prepared for migration to GitLab.
|
||||
|
||||
## What needs to be done
|
||||
In order to get the CI/CD and automation processes running again on GitLab, the following tasks must be completed:
|
||||
|
||||
1. **Import the repository to GitLab**:
|
||||
- Migrate the source code.
|
||||
- Migrate GitHub Issues, labels, and Projects.
|
||||
|
||||
2. **Translate GitHub Actions to GitLab CI/CD (`.gitlab-ci.yml`)**:
|
||||
- `Backup Dismissed Tasks`: Implement as a GitLab Scheduled Pipeline running every 6 hours.
|
||||
- `Mark Issues for Review`: Migrate to a standard GitLab CI pipeline triggered by pushes changing `DONE_ISSUES.md`.
|
||||
- `Collect AI Tasks`: Implement as a GitLab Scheduled Pipeline running every 30 minutes.
|
||||
- `Update Task State on Issue Events`: Create a GitLab webhook responding to issue events, or use GitLab CI pipelines with issue event triggers.
|
||||
- `Deploy to VPS`: Migrate to a GitLab CI pipeline triggered on branch pushes to `main`.
|
||||
- `Sync Issues to File`: Migrate to a GitLab Scheduled Pipeline (every 30 minutes) and/or use GitLab Webhooks for issue event triggers.
|
||||
- `Sync OrgMyLife Plan to Projects Board`: Migrate to a GitLab CI pipeline triggered by pushes to `PLAN.md`.
|
||||
|
||||
3. **Configure CI/CD Variables**:
|
||||
- Transfer any repository secrets or environment variables (such as deployment keys, SSH credentials, API tokens) into GitLab CI/CD Settings under Variables.
|
||||
|
||||
4. **Runner Configuration**:
|
||||
- Ensure the GitLab Runners have the necessary capabilities (e.g., Node.js environments) by utilizing standard Docker images in the `.gitlab-ci.yml` (e.g., `image: node:20`).
|
||||
|
||||
5. **Optimize Pipeline Compute Efficiency**:
|
||||
- Analyze and optimize the pipeline execution to drastically reduce compute time.
|
||||
- **Context:** The previous runner consumed ~2000 minutes of compute time in just 10 days, which is highly inefficient for a small application. Strategies like reducing cron frequencies, utilizing caching, and conditionally running jobs should be implemented.
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,24 +0,0 @@
|
||||
# OrgMyLife
|
||||
|
||||
Personal task, call, and digest coordination system.
|
||||
|
||||
## Stack
|
||||
|
||||
- Backend: FastAPI + PostgreSQL
|
||||
- Deployment: VPS Docker (port 8000)
|
||||
- MCP: Local stdio server for Kiro integration
|
||||
|
||||
## API
|
||||
|
||||
- Endpoint: https://api.andreknie.de
|
||||
- Tasks: GET /api/tasks, POST /api/tasks
|
||||
- Projects: GET/PUT /api/projects, POST /api/projects/create
|
||||
- Agent filter: ?filter_agent=true
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,355 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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.*
|
||||
@@ -1,149 +0,0 @@
|
||||
# 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 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
@@ -1,83 +0,0 @@
|
||||
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()
|
||||
@@ -1,28 +0,0 @@
|
||||
"""${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"}
|
||||
@@ -1,24 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,20 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,36 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,21 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,25 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,32 +0,0 @@
|
||||
"""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 ###
|
||||
@@ -1,25 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,73 +0,0 @@
|
||||
"""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 ###
|
||||
@@ -1,25 +0,0 @@
|
||||
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
@@ -1,81 +0,0 @@
|
||||
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())
|
||||
@@ -1,79 +0,0 @@
|
||||
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}")
|
||||
@@ -1,199 +0,0 @@
|
||||
"""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",
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
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
|
||||
@@ -1,290 +0,0 @@
|
||||
"""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
|
||||
@@ -1,325 +0,0 @@
|
||||
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
|
||||
@@ -1,207 +0,0 @@
|
||||
"""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:]
|
||||
@@ -1,140 +0,0 @@
|
||||
"""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
@@ -1,52 +0,0 @@
|
||||
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:
|
||||
@@ -1,891 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
<!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
@@ -1,34 +0,0 @@
|
||||
# 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
|
||||
@@ -1,44 +0,0 @@
|
||||
alembic==1.18.5
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.14.1
|
||||
caldav==3.2.0
|
||||
charset-normalizer==3.4.9
|
||||
click==8.4.2
|
||||
colorama==0.4.6
|
||||
dnspython==2.8.0
|
||||
fastapi==0.139.0
|
||||
greenlet==3.5.0
|
||||
h11==0.16.0
|
||||
httptools==0.7.1
|
||||
httpx==0.28.1
|
||||
icalendar==7.0.3
|
||||
icalendar-searcher==1.0.5
|
||||
idna==3.18
|
||||
jh2==5.0.11
|
||||
lxml==6.1.1
|
||||
Mako==1.3.12
|
||||
MarkupSafe==3.0.3
|
||||
niquests==3.18.7
|
||||
psycopg2-binary==2.9.12
|
||||
pydantic==2.13.4
|
||||
pydantic_core==2.46.4
|
||||
pytest==9.1.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.2.2
|
||||
python-multipart==0.0.32
|
||||
PyYAML==6.0.3
|
||||
qh3==1.7.4
|
||||
recurring-ical-events==3.8.1
|
||||
six==1.17.0
|
||||
SQLAlchemy==2.0.51
|
||||
starlette==1.3.1
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2026.2
|
||||
urllib3-future==2.19.913
|
||||
uvicorn==0.51.0
|
||||
wassima==2.0.6
|
||||
watchfiles==1.2.0
|
||||
websockets==16.0
|
||||
x-wr-timezone==2.0.1
|
||||
@@ -1,46 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,41 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,118 +0,0 @@
|
||||
"""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
|
||||
@@ -1,33 +0,0 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user