mirror of
https://github.com/Misterio77/Foundry.git
synced 2026-08-24 10:04:09 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Khora, a graphical calendar for local vdirs."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .application import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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:30–15: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"
|
||||
Reference in New Issue
Block a user