feat(projects/khora): scaffold graphical vdir calendar

Add a read-only GTK/libadwaita agenda backed by khal, with calendar
filters, Nix packaging, desktop metadata, and model tests.

Assisted-by: pi (gpt-5.6-sol)
This commit is contained in:
Gabriel Fontes
2026-08-15 12:23:27 -03:00
parent a9a0110a36
commit 1d21154a47
14 changed files with 468 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@
jellysearch = pkgs.callPackage ./jellysearch {};
website = pkgs.callPackage ../projects/website {};
runelite-mcp = pkgs.callPackage ../projects/runelite-mcp {};
khora = pkgs.callPackage ../projects/khora {};
runescape = pkgs.callPackage ./runescape {};
# Personal scripts
+25
View File
@@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2026, Gabriel Fontes
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+36
View File
@@ -0,0 +1,36 @@
# Khora
Khora is a graphical calendar for local
[vdirs](https://vdirsyncer.pimutils.org/). It uses khal for configuration,
indexing, recurrence expansion, and iCalendar semantics; vdirsyncer remains in
charge of talking to CalDAV servers.
## Status
Khora is an early, read-only prototype. It currently provides:
- a native GTK 4/libadwaita interface;
- calendar discovery from the existing khal configuration;
- per-calendar visibility controls;
- a day agenda with recurring and all-day events; and
- explicit refreshes of khal's local index.
Editing, week and month views, event details, and filesystem monitoring come
next. The khal dependency is isolated in `khora.khal_adapter` so its internal
API can change without leaking through the application.
## Development
Build and run it from the Foundry root:
```sh
nix build .#khora
nix run .#khora
```
Khora reads the same XDG configuration and vdirs as khal. It does not configure
accounts or perform network synchronization.
## License
[BSD 2-Clause](LICENSE)
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Name=Khora
Comment=View calendars stored in local vdirs
Exec=khora
Icon=rs.m7.Khora
Terminal=false
Type=Application
Categories=Office;Calendar;
Keywords=calendar;agenda;vdir;khal;
StartupNotify=true
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect x="16" y="24" width="96" height="88" rx="16" fill="#3584e4"/>
<path d="M16 48h96" stroke="#fff" stroke-width="8"/>
<path d="M40 16v24M88 16v24" stroke="#1c71d8" stroke-linecap="round" stroke-width="10"/>
<circle cx="43" cy="70" r="6" fill="#fff"/>
<circle cx="64" cy="70" r="6" fill="#fff"/>
<circle cx="85" cy="70" r="6" fill="#fff"/>
<circle cx="43" cy="91" r="6" fill="#fff"/>
<circle cx="64" cy="91" r="6" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

+55
View File
@@ -0,0 +1,55 @@
{
lib,
python3Packages,
khal,
gobject-introspection,
gtk4,
libadwaita,
wrapGAppsHook4,
}:
python3Packages.buildPythonApplication {
pname = "khora";
version = "0.1.0";
pyproject = true;
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./data
./pyproject.toml
./src
./tests
];
};
build-system = [python3Packages.setuptools];
dependencies = [
khal
python3Packages.pygobject3
];
nativeBuildInputs = [
gobject-introspection
wrapGAppsHook4
];
buildInputs = [
gtk4
libadwaita
];
nativeCheckInputs = [python3Packages.pytestCheckHook];
postInstall = ''
install -Dm644 data/rs.m7.Khora.desktop \
$out/share/applications/rs.m7.Khora.desktop
install -Dm644 data/rs.m7.Khora.svg \
$out/share/icons/hicolor/scalable/apps/rs.m7.Khora.svg
'';
meta = {
description = "Graphical calendar for local vdirs";
homepage = "https://github.com/misterio77/Foundry/tree/main/projects/khora";
license = lib.licenses.bsd2;
mainProgram = "khora";
platforms = lib.platforms.linux;
};
}
+24
View File
@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "khora-calendar"
version = "0.1.0"
description = "A graphical calendar for local vdirs"
readme = "README.md"
requires-python = ">=3.11"
license = "BSD-2-Clause"
dependencies = [
"khal>=0.14,<0.15",
"PyGObject>=3.50",
]
[project.scripts]
khora = "khora.application:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+3
View File
@@ -0,0 +1,3 @@
"""Khora, a graphical calendar for local vdirs."""
__version__ = "0.1.0"
+3
View File
@@ -0,0 +1,3 @@
from .application import main
raise SystemExit(main())
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
import sys
import gi
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio
from .khal_adapter import KhalRepository
from .window import KhoraWindow
class KhoraApplication(Adw.Application):
def __init__(self) -> None:
super().__init__(application_id="rs.m7.Khora", flags=Gio.ApplicationFlags.DEFAULT_FLAGS)
def do_activate(self) -> None:
window = self.get_active_window()
if window is None:
try:
repository = KhalRepository()
error = None
except Exception as caught:
repository = None
error = str(caught)
window = KhoraWindow(self, repository, error)
window.present()
def main() -> int:
return KhoraApplication().run(sys.argv)
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import datetime as dt
from pathlib import Path
from khal.cli_utils import build_collection
from khal.settings import get_config
from .model import Calendar, Event
class KhalRepository:
"""Keep khal's internal API behind one deliberately small boundary."""
def __init__(self, config_path: Path | None = None) -> None:
self._config = get_config(str(config_path) if config_path else None)
self._collection = build_collection(self._config, selection=None)
@property
def calendars(self) -> tuple[Calendar, ...]:
return tuple(
Calendar(
name=name,
color=settings.get("color") or None,
readonly=settings.get("readonly", False),
)
for name, settings in self._config["calendars"].items()
if settings.get("type", "calendar") == "calendar"
)
def events_on(self, day: dt.date, visible: set[str] | None = None) -> tuple[Event, ...]:
self._collection.update_db()
events = (
self._to_event(event)
for event in self._collection.get_events_on(day)
if visible is None or event.calendar in visible
)
return tuple(sorted(events, key=self._sort_key))
@staticmethod
def _to_event(event) -> Event:
return Event(
summary=event.summary or "(untitled)",
calendar=event.calendar,
start=event.start_local,
end=event.end_local,
all_day=event.allday,
location=event.location or "",
color=event.color,
)
@staticmethod
def _sort_key(event: Event) -> tuple[bool, dt.date | dt.datetime, str]:
return (not event.all_day, event.start, event.summary.casefold())
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import datetime as dt
from dataclasses import dataclass
@dataclass(frozen=True)
class Calendar:
name: str
color: str | None = None
readonly: bool = False
@dataclass(frozen=True)
class Event:
summary: str
calendar: str
start: dt.date | dt.datetime
end: dt.date | dt.datetime
all_day: bool = False
location: str = ""
color: str | None = None
@property
def time_label(self) -> str:
if self.all_day:
return "All day"
assert isinstance(self.start, dt.datetime)
assert isinstance(self.end, dt.datetime)
return f"{self.start:%H:%M}{self.end:%H:%M}"
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import datetime as dt
import gi
gi.require_version("Adw", "1")
gi.require_version("Gtk", "4.0")
from gi.repository import Adw, Gio, GLib, Gtk
from .khal_adapter import KhalRepository
from .model import Event
class KhoraWindow(Adw.ApplicationWindow):
def __init__(
self,
application: Adw.Application,
repository: KhalRepository | None,
error: str | None = None,
) -> None:
super().__init__(application=application, title="Khora")
self.set_default_size(960, 680)
self._repository = repository
self._visible_calendars: set[str] = set()
self._toolbar = Adw.ToolbarView()
self.set_content(self._toolbar)
self._toolbar.add_top_bar(self._build_header())
if error is not None:
self._toolbar.set_content(self._error_page(error))
return
assert repository is not None
self._visible_calendars = {calendar.name for calendar in repository.calendars}
self._event_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self._event_list.add_css_class("boxed-list")
self._empty = Adw.StatusPage(
icon_name="x-office-calendar-symbolic",
title="No events",
description="A suspiciously peaceful day.",
)
split = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL, position=280)
split.set_start_child(self._build_sidebar())
split.set_end_child(self._build_agenda())
split.set_resize_start_child(False)
split.set_shrink_start_child(False)
self._toolbar.set_content(split)
self._refresh()
def _build_header(self) -> Adw.HeaderBar:
header = Adw.HeaderBar()
header.set_title_widget(Gtk.Label(label="Khora", css_classes=["title"]))
today = Gtk.Button(label="Today", action_name="win.today")
header.pack_start(today)
header.pack_end(Gtk.Button(icon_name="view-refresh-symbolic", action_name="win.refresh"))
self._install_action("today", self._on_today)
self._install_action("refresh", lambda *_: self._refresh())
return header
def _build_sidebar(self) -> Gtk.Widget:
box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=18,
margin_top=18,
margin_bottom=18,
margin_start=18,
margin_end=18,
)
self._calendar = Gtk.Calendar(show_day_names=True, show_heading=True)
self._calendar.connect("day-selected", lambda *_: self._refresh())
box.append(self._calendar)
box.append(Gtk.Label(label="Calendars", xalign=0, css_classes=["heading"]))
calendars = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
calendars.add_css_class("boxed-list")
assert self._repository is not None
for calendar in self._repository.calendars:
toggle = Gtk.CheckButton(label=calendar.name, active=True)
toggle.connect("toggled", self._on_calendar_toggled, calendar.name)
row = Gtk.ListBoxRow(child=toggle, activatable=False)
calendars.append(row)
box.append(calendars)
return box
def _build_agenda(self) -> Gtk.Widget:
overlay = Gtk.Overlay()
scroller = Gtk.ScrolledWindow(
child=self._event_list,
hscrollbar_policy=Gtk.PolicyType.NEVER,
margin_top=24,
margin_bottom=24,
margin_start=24,
margin_end=24,
)
overlay.set_child(scroller)
overlay.add_overlay(self._empty)
return overlay
def _error_page(self, error: str) -> Adw.StatusPage:
return Adw.StatusPage(
icon_name="dialog-error-symbolic",
title="Could not open khal",
description=error,
)
def _selected_day(self) -> dt.date:
selected: GLib.DateTime = self._calendar.get_date()
return dt.date(selected.get_year(), selected.get_month(), selected.get_day_of_month())
def _refresh(self) -> None:
if self._repository is None or not hasattr(self, "_event_list"):
return
try:
events = self._repository.events_on(self._selected_day(), self._visible_calendars)
except Exception as error: # khal exposes several backend-specific errors
self._show_toast(str(error))
return
while row := self._event_list.get_row_at_index(0):
self._event_list.remove(row)
for event in events:
self._event_list.append(self._event_row(event))
self._empty.set_visible(not events)
self._event_list.set_visible(bool(events))
@staticmethod
def _event_row(event: Event) -> Adw.ActionRow:
details = f"{event.time_label} · {event.calendar}"
if event.location:
details += f" · {event.location}"
return Adw.ActionRow(title=event.summary, subtitle=details)
def _on_calendar_toggled(self, button: Gtk.CheckButton, name: str) -> None:
if button.get_active():
self._visible_calendars.add(name)
else:
self._visible_calendars.discard(name)
self._refresh()
def _on_today(self, *_args) -> None:
self._calendar.select_day(GLib.DateTime.new_now_local())
self._refresh()
def _install_action(self, name: str, callback) -> None:
action = Gio.SimpleAction.new(name, None)
action.connect("activate", callback)
self.add_action(action)
def _show_toast(self, message: str) -> None:
dialog = Adw.AlertDialog(heading="Calendar error", body=message)
dialog.add_response("close", "Close")
dialog.present(self)
+26
View File
@@ -0,0 +1,26 @@
import datetime as dt
from khora.model import Event
def test_timed_event_label_uses_24_hour_time() -> None:
event = Event(
summary="Write a calendar",
calendar="Personal",
start=dt.datetime(2026, 8, 15, 13, 30),
end=dt.datetime(2026, 8, 15, 15, 0),
)
assert event.time_label == "13:3015:00"
def test_all_day_event_label() -> None:
event = Event(
summary="Escape the calendar mines",
calendar="Personal",
start=dt.date(2026, 8, 15),
end=dt.date(2026, 8, 15),
all_day=True,
)
assert event.time_label == "All day"