Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ Pas besoin de Python, de venv ni de terminal. Les
[releases GitHub](https://github.com/SKOHscripts/Kairos/releases) proposent un exécutable
autonome par OS (`kairos-linux-x86_64`, `kairos-windows-x86_64.exe`) et un APK Android
(`kairos-android-arm64.apk`). Télécharge, double-clique (sous Linux, rends d'abord le
fichier exécutable avec `chmod +x kairos-linux-x86_64`), et le navigateur s'ouvre tout
seul sur Kairos. Les réglages et la base de tâches vivent dans le dossier de données
fichier exécutable avec `chmod +x kairos-linux-x86_64`), et une fenêtre s'ouvre toute
seule sur Kairos — sans barre d'adresse ni onglets, le ressenti d'une vraie application
de bureau (si un navigateur de la famille Chromium — Chrome, Edge, Brave, Vivaldi... —
est installé ; sinon repli automatique sur un onglet du navigateur par défaut, sans rien
à configurer). Les réglages et la base de tâches vivent dans le dossier de données
standard de ton système, entièrement éditables depuis la page **Réglages**. Aucun
fichier `.env` à copier ou à éditer à la main.

Expand Down Expand Up @@ -108,6 +111,17 @@ make test # venv + suite de tests complète

## Fonctionnalités

### Notes (capture GTD)
Une page dédiée (`/kairos/notes`, entre Accueil et Jour dans la navigation) pour se
décharger l'esprit sans réfléchir à la structure : une seule zone de texte libre, aucune
priorité ni échéance à choisir sur le moment (Ctrl/Cmd+Entrée pour capturer sans lâcher
le clavier). Chaque note capturée apparaît immédiatement dans la liste, sans rechargement
de page. Une fois qu'une idée est prête à devenir actionnable, un clic sur **« → Tâche »**
la convertit en tâche titre-seul, qui atterrit directement dans la boîte de réception «
À traiter » de la vue Jour — la note d'origine est archivée (jamais supprimée) avec un
lien vers la tâche créée. Une note peut aussi être éditée sur place ou classée sans suite
(archivée) si elle ne mène nulle part.

### Gestion des tâches
- **Création rapide** en une ligne (le titre seul suffit). Édition complète ensuite :
titre, description, priorité 0-2 (P0 = la plus forte), échéance, date programmée,
Expand Down
153 changes: 153 additions & 0 deletions app/desktop_browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Détection d'un navigateur Chromium et ouverture en « fenêtre d'application ».

`webbrowser.open` (utilisé par défaut dans `app/launcher.py`) ouvre un onglet
dans le navigateur système par défaut — barre d'adresse, onglets, tout
l'attirail d'un navigateur généraliste, alors que Kairos se veut ressenti
comme une application de bureau à part entière (voir `docs/spec/
packaging-lancement.md`). Les navigateurs de la famille Chromium (Chrome,
Chromium, Edge, Brave, Vivaldi — tous basés sur le même moteur et acceptant
les mêmes indicateurs de ligne de commande) savent s'ouvrir en **fenêtre
d'application** via `--app=URL` : pas de barre d'adresse, pas d'onglets, un
ressenti de « web app installée ». Firefox et Safari n'ont pas d'équivalent
strict à cet indicateur — ce module ne cible donc que la famille Chromium.

Ce module reste volontairement séparé de `app/launcher.py` : sa logique
(détection d'un binaire, construction des arguments) est pure et se teste
sans toucher à uvicorn, aux threads ou au fichier de verrou. `app/launcher.py`
l'appelle depuis `_open_browser`, avec un repli automatique et silencieux vers
`webbrowser.open` si la détection ou le lancement échoue — voir le
commentaire à l'appel pour le détail de cette décision.
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys

from app.settings_store import data_dir
from app.subprocess_env import external_process_env

# Ordre de préférence indicatif seulement (le premier trouvé gagne) — pas de
# hiérarchie qualitative entre ces navigateurs, juste une liste stable pour
# un comportement déterministe d'un poste à l'autre.
_LINUX_BROWSER_NAMES = (
"google-chrome-stable",
"google-chrome",
"chromium-browser",
"chromium",
"brave-browser",
"microsoft-edge",
"microsoft-edge-stable",
"vivaldi-stable",
"vivaldi",
)

# Chemins relatifs sous chacun des dossiers de base Windows testés (l'ordre des
# bases ci-dessous est : Program Files, Program Files (x86), puis LocalAppData
# — un même navigateur peut atterrir sous l'une ou l'autre selon qu'il a été
# installé pour tous les utilisateurs ou seulement l'utilisateur courant ; on
# ne présume pas laquelle pour ne pas rater une installation valide).
_WINDOWS_BROWSER_RELATIVE_PATHS = (
r"Google\Chrome\Application\chrome.exe",
r"Microsoft\Edge\Application\msedge.exe",
r"BraveSoftware\Brave-Browser\Application\brave.exe",
r"Vivaldi\Application\vivaldi.exe",
)

_BROWSER_PROFILE_DIRNAME = "browser-profile"


def _windows_base_dirs() -> list[str]:
bases = []
for env_var in ("ProgramFiles", "ProgramFiles(x86)", "LocalAppData"):
base = os.environ.get(env_var)
if base:
bases.append(base)
return bases


def find_app_capable_browser() -> str | None:
"""Cherche un navigateur de la famille Chromium installé sur ce poste.

Fonction pure (aucun effet de bord, pas d'impression) : ne fait que des
vérifications sur le système de fichiers / l'environnement, pour rester
facilement testable en monkeypatchant `shutil.which`, `os.environ` et
`os.path.exists`.
"""
# `KAIROS_BROWSER` : échappatoire explicite pour les tests/CI (imposer un
# binaire précis sans dépendre de ce qui est réellement installé), et pour
# un utilisateur avancé qui voudrait forcer un navigateur particulier —
# prioritaire sur toute détection automatique.
override = os.environ.get("KAIROS_BROWSER")
if override:
if os.path.isfile(override) and os.access(override, os.X_OK):
return override
resolved = shutil.which(override)
if resolved:
return resolved
return None

if sys.platform == "linux":
for name in _LINUX_BROWSER_NAMES:
found = shutil.which(name)
if found:
return found
return None

if sys.platform == "win32":
base_dirs = _windows_base_dirs()
for relative_path in _WINDOWS_BROWSER_RELATIVE_PATHS:
for base_dir in base_dirs:
candidate = os.path.join(base_dir, relative_path)
if os.path.isfile(candidate):
return candidate
return None

# macOS (et tout autre OS) : hors périmètre de Kairos, voir
# `docs/spec/packaging-lancement.md` § Hors périmètre. Pas de détection
# dédiée, repli automatique vers `webbrowser.open` côté appelant.
return None


def launch_app_window(browser_path: str, url: str) -> bool:
"""Lance ``browser_path`` en fenêtre d'application sur ``url``.

Retourne `True` si le processus a bien été lancé (pas de garantie que la
fenêtre s'affiche effectivement — fonctionnalité de confort, jamais
bloquante), `False` sur tout échec.
"""
# Profil dédié, séparé du profil personnel de l'utilisateur : la fenêtre
# d'application ne doit pas se mêler à ses onglets/extensions/sessions du
# navigateur habituel, et un profil Chromium ne peut de toute façon pas
# être ouvert deux fois simultanément par deux processus distincts.
profile_dir = str(data_dir() / _BROWSER_PROFILE_DIRNAME)
argv = [browser_path, f"--user-data-dir={profile_dir}", f"--app={url}"]

kwargs: dict = {}
if sys.platform == "win32" and hasattr(subprocess, "DETACHED_PROCESS"):
kwargs["creationflags"] = subprocess.DETACHED_PROCESS

try:
# `external_process_env()` (pas la version context manager,
# `Popen` accepte un `env=` explicite) : évite qu'un navigateur lancé
# depuis l'exécutable PyInstaller onefile hérite du `LD_LIBRARY_PATH`
# détourné vers les bibliothèques embarquées — voir
# `app/subprocess_env.py`.
subprocess.Popen(
argv,
env=external_process_env(),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
**kwargs,
)
except Exception:
# Fonctionnalité de confort en arrière-plan : un binaire manquant
# malgré la détection, un droit refusé, ou toute autre surprise ne
# doit jamais faire planter ni bloquer le lancement de Kairos —
# l'appelant retombe sur `webbrowser.open`.
return False
return True
11 changes: 11 additions & 0 deletions app/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
# fonctionnent dans les deux cas (script figé et `pip install -e .`), tant que
# la racine du dépôt est sur `sys.path` (c'est le cas ici : `pathex` du spec,
# ou le `.pth` du mode editable).
from app.desktop_browser import find_app_capable_browser, launch_app_window
from app.main import app
from app.settings_store import data_dir
from app.subprocess_env import external_process_environ
Expand Down Expand Up @@ -71,6 +72,16 @@ def _open_browser(url: str) -> None:
# (processus fantôme sur un runner CI, effets de bord imprévisibles).
if os.environ.get("KAIROS_NO_BROWSER"):
return
# Fenêtre d'application (Chromium `--app=URL`, voir `app/desktop_browser.py`)
# imposée par défaut, sans réglage utilisateur : c'est le ressenti recherché
# pour l'exécutable de bureau (pas un onglet de navigateur généraliste), et
# ça se dégrade tout seul vers l'ancien comportement (onglet du navigateur
# par défaut) si aucun navigateur Chromium n'est trouvé ou si son lancement
# échoue pour n'importe quelle raison — jamais d'erreur remontée à
# l'utilisateur pour cette fonctionnalité de confort.
browser_path = find_app_capable_browser()
if browser_path and launch_app_window(browser_path, url):
return
# `external_process_environ()` : voir `app/subprocess_env.py` — évite
# qu'un navigateur/`xdg-open` lancé par PyInstaller (mode onefile) hérite
# du `LD_LIBRARY_PATH` détourné vers ses bibliothèques embarquées.
Expand Down
141 changes: 141 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
)
from .tasks_models import (
FIBONACCI_SCALE,
Note,
Task,
TaskDependency,
TimeBlock,
Expand Down Expand Up @@ -1292,3 +1293,143 @@ def delete_manual_block(request: Request) -> RedirectResponse:
tasks_session.commit()
return RedirectResponse("/kairos", status_code=303)


# --------------------------------------------------------------------------
# Notes (capture GTD, en amont de l'inbox de la vue Jour) — voir
# docs/spec/notes-capture.md. Même patron de rendu que « Kairos » (fragment
# AJAX négocié sur `X-Requested-With: fetch`), mirroré ici avec son propre
# contenu (`_notes_list.html`, id `#mj-notes-content`) plutôt que réutilisé.
# --------------------------------------------------------------------------


def _build_notes_context(request: Request, tasks_session: Session) -> dict:
"""Contexte de rendu de la page Notes : notes ouvertes (capture active) et
notes archivées (converties ou classées sans suite), les deux triées de la
plus récente à la plus ancienne — symétrique de `_build_kairos_context`."""
open_notes = list(
tasks_session.scalars(
select(Note).where(Note.status == "open").order_by(Note.created_at.desc())
)
)
archived_notes = list(
tasks_session.scalars(
select(Note).where(Note.status == "archived").order_by(Note.created_at.desc())
)
)
return {
"page": "notes",
"open_notes": open_notes,
"archived_notes": archived_notes,
}


def render_notes_response(request: Request, *, fragment: bool) -> Response:
"""Point d'entrée unique de rendu de la page Notes, même contrat que
`render_kairos_response` : `fragment=False` rend la page pleine
(`notes.html`), `fragment=True` rend seulement `_notes_list.html`
(id `#mj-notes-content`), utilisé par les handlers d'action pour
l'amélioration progressive AJAX. Les handlers doivent avoir committé et
fermé leur propre session avant d'appeler cette fonction, qui rouvre une
session fraîche — jamais de session imbriquée (même invariant que Kairos)."""
with _request_session(get_tasks_session) as tasks_session:
context = _build_notes_context(request, tasks_session)
template_name = "_notes_list.html" if fragment else "notes.html"
return templates.TemplateResponse(request, template_name, context)


@app.get("/kairos/notes")
def notes_page(request: Request) -> HTMLResponse:
return render_notes_response(request, fragment=False)


def _notes_action_response(request: Request) -> Response:
"""Réponse commune des handlers d'action Notes : mêmes règles que
`_kairos_action_response` (fragment AJAX si `X-Requested-With: fetch`,
sinon redirection 303 complète — repli sans JS pour la WebView Android et
l'accessibilité)."""
if request.headers.get("X-Requested-With") == "fetch":
return render_notes_response(request, fragment=True)
return RedirectResponse("/kairos/notes", status_code=303)


@app.post("/kairos/notes")
async def create_note(request: Request) -> Response:
"""Capture rapide : un corps de texte libre, rien d'autre — pas de priorité,
pas de points, pas d'échéance (c'est tout l'intérêt par rapport à la boîte
de réception de la vue Jour, qui porte déjà ces champs sur `Task`)."""
form = await request.form()
body = str(form.get("body", "")).strip()
with _request_session(get_tasks_session) as tasks_session:
if body:
tasks_session.add(Note(body=body))
tasks_session.commit()
return _notes_action_response(request)


@app.post("/kairos/notes/{note_id:int}/edit")
async def edit_note(request: Request) -> Response:
"""Édite le corps d'une note existante. Note disparue entre-temps → no-op
silencieux (même tolérance que les handlers d'action de la vue Jour)."""
form = await request.form()
with _request_session(get_tasks_session) as tasks_session:
note = tasks_session.get(Note, request.path_params["note_id"])
if note is not None:
note.body = str(form.get("body", "")).strip()
tasks_session.commit()
return _notes_action_response(request)


def _note_title_from_body(body: str) -> str:
"""Titre de la tâche créée par conversion : première ligne non vide du corps
de la note, tronquée à 200 caractères (cohérent avec `Task.title`,
`String(512)`, mais une capture rapide n'a pas besoin d'approcher cette
limite)."""
first_line = next((line.strip() for line in body.splitlines() if line.strip()), "")
return first_line[:200]


@app.post("/kairos/notes/{note_id:int}/convert")
def convert_note_to_task(request: Request) -> Response:
"""Le moment clé du flux : la note devient une tâche titre-seul (elle atterrit
dans l'inbox « À traiter » de la vue Jour, à qualifier comme n'importe quelle
autre capture), la note elle-même est **archivée et liée**, jamais supprimée —
préserve l'historique de la capture d'origine (voir `Note.converted_task_id`,
sans contrainte FK, cohérent avec le reste du schéma)."""
with _request_session(get_tasks_session) as tasks_session:
note = tasks_session.get(Note, request.path_params["note_id"])
if note is not None and note.status == "open":
title = _note_title_from_body(note.body)
if title:
task = Task(title=title, source="native")
tasks_session.add(task)
tasks_session.flush() # attribue l'id avant de le référencer
note.status = "archived"
note.converted_task_id = task.id
tasks_session.commit()
return _notes_action_response(request)


@app.post("/kairos/notes/{note_id:int}/archive")
def archive_note(request: Request) -> Response:
"""Classe une note sans suite : retirée de la liste de capture active, jamais
supprimée (même sémantique que la conversion, sans `converted_task_id`)."""
with _request_session(get_tasks_session) as tasks_session:
note = tasks_session.get(Note, request.path_params["note_id"])
if note is not None:
note.status = "archived"
tasks_session.commit()
return _notes_action_response(request)


@app.post("/kairos/notes/{note_id:int}/delete")
def delete_note(request: Request) -> Response:
"""Suppression définitive — à la différence de l'archivage, retire la ligne en
base (une note n'a pas d'historique de priorisation à préserver, contrairement
à une tâche)."""
with _request_session(get_tasks_session) as tasks_session:
note = tasks_session.get(Note, request.path_params["note_id"])
if note is not None:
tasks_session.delete(note)
tasks_session.commit()
return _notes_action_response(request)

29 changes: 29 additions & 0 deletions app/tasks_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,35 @@ class WorkSession(TasksBase):
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)


class Note(TasksBase):
"""Note libre (« brain dump » GTD), capturée sans friction avant qualification.

Étape de **capture** en amont de la boîte de réception de la vue Jour (qui ne
reçoit que des `Task` titre-seul, avec priorité/points à poser) : une note n'a
ni priorité, ni points, ni échéance — juste un corps de texte libre, posé le
plus vite possible, décidé plus tard. `converted_task_id` (nullable, **sans
contrainte FK**, même parti pris que `Task.parent_id`/`linked_ticket_id` — voir
docs/spec/modele-donnees.md) trace la tâche créée lors d'une conversion
note → tâche, en lecture seule (aucune synchro retour). `status='archived'` est
la façon dont une note convertie (ou classée sans suite) sort de la liste de
capture active **sans jamais être supprimée** : mêmes principes de non-perte
que `Task.status='archived'`.
"""

__tablename__ = "note"

id: Mapped[int] = mapped_column(Integer, primary_key=True)
body: Mapped[str] = mapped_column(Text, default="")
# 'open' | 'archived'.
status: Mapped[str] = mapped_column(String(16), default="open", index=True)
# Id local de la tâche créée par conversion, sans contrainte FK — référence
# « molle », cohérente avec le reste du schéma. None tant que la note n'a pas
# été convertie.
converted_task_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)


class TaskSyncMeta(TasksBase):
"""Méta du dernier fetch réussi par source (mirror de `GitLabRefreshMeta`).

Expand Down
Loading
Loading