8 Commits
Author SHA1 Message Date
makiolaj ddc2c22a30 Merge pull request 'Render the app privacy policies from the app repos' (#9) from privacy-from-app-repos into main 2026-09-09 14:53:33 +00:00
makiolaj 5056a03a05 Merge pull request 'Codeberg sources, a Play button, and current project descriptions' (#8) from codeberg-sources-play-store into main
Reviewed-on: #8
2026-09-09 14:46:46 +00:00
makiolaj 8a1eb54a45 Render the app privacy policies from the app repos
Each policy now has exactly one copy: docs/PRIVACY.md in the app's own
repository. The pages keep their URLs and chrome and render that file
through a content collection, so the published page and the app's own
documentation cannot drift.

scripts/sync-external.mjs shallow-clones both repos into external/ from
prebuild and predev — not from CI: Coolify builds the site from the repo,
so a checkout that only ran in a Gitea job would never reach the deploy.
It falls back to the raw file if git is unavailable, and takes <APP>_REF
or <APP>_LOCAL for work against a branch or an unpushed working copy.

A missing, empty or malformed policy fails the build, verified against the
real image: the deploy stops rather than publishing an empty privacy page.
2026-09-09 16:33:11 +02:00
makiolaj ff3c0fa437 Point sources at Codeberg, add the Play button, refresh descriptions
Codeberg is where the apps live now, so the source link, its icon and the
release lookup follow. Forgejo serves Gitea's /api/v1, so the version badge
keeps working — with a fallback to the newest pre-release, since a pre-1.0
app publishes nothing else (Agendula showed a stale v0.2.0 before).

Calendula is on Google Play as well as F-Droid; adds a tonal button for it,
and its fifteen community translations replace "German and English".

Agendula's entry described the app it stopped being: it now carries its own
VTODO store, with an external provider as a choice rather than a requirement,
plus export and its own CalDAV sync in progress. floret-kit's modules have
grown past core-time. Footer forge link and the /uses infrastructure list
separate the two roles: Codeberg for source, self-hosted Gitea for builds.
2026-09-09 16:09:30 +02:00
makiolaj 6311baccb7 Merge pull request 'Add the Calendula and Agendula privacy policies' (#7) from calendula-privacy-policy into main
Reviewed-on: #7
2026-09-09 14:02:01 +00:00
makiolaj 92472937cc Restore spaces stripped at wrapped inline-tag boundaries
Astro drops the whitespace run when a line break falls exactly at an inline
tag boundary, so "the\n<a ...>" renders glued. Adds {' '} at the wrap.
The footer one showed on every page.
2026-09-09 16:01:16 +02:00
makiolaj 0fe0eaf2bd Add the Agendula app privacy policy
Same structure as the Calendula page, but Agendula holds INTERNET and syncs
CalDAV itself, so the wording turns on "only to the server you entered"
rather than on the absence of the permission. Facts from the app repo:
storage modes, Keystore-encrypted credentials, RFC 6764 discovery,
Nextcloud Login Flow v2, Auto Backup exclusions, crash-report allowlist.
2026-09-09 16:01:11 +02:00
makiolaj d18692b693 Merge pull request 'Add the Calendula privacy policy, switch to business@' (#6) from calendula-privacy-policy into main
scheduled-deploy / trigger-deploy (push) Successful in 22s
Reviewed-on: #6
2026-07-28 17:11:08 +00:00
18 changed files with 426 additions and 266 deletions
+4
View File
@@ -5,3 +5,7 @@ dist
.env .env
.env.production .env.production
*.log *.log
# Fetched during the build by scripts/sync-external.mjs; never copied in — a
# local checkout may be a symlink to a working copy on the developer's machine.
external
+3
View File
@@ -12,3 +12,6 @@ node_modules/
*.log *.log
# local screenshots # local screenshots
.shots/ .shots/
# App repositories fetched at build time (see scripts/sync-external.mjs).
external/
+8
View File
@@ -7,6 +7,14 @@ WORKDIR /app
# Use npm install (not npm ci): Astro 7's wasm32 optional deps (@emnapi/*) # Use npm install (not npm ci): Astro 7's wasm32 optional deps (@emnapi/*)
# make npm ci's strict lock-sync check fail across npm/node versions. install # make npm ci's strict lock-sync check fail across npm/node versions. install
# reconciles the lockfile and builds reliably. # reconciles the lockfile and builds reliably.
# git: the build fetches the app repositories whose docs/PRIVACY.md this site
# renders (scripts/sync-external.mjs, run from `prebuild`). node:slim ships
# without it; the script would fall back to fetching the raw files over HTTPS,
# but a shallow clone is the intended path and keeps the failure modes obvious.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund RUN npm install --no-audit --no-fund
+43
View File
@@ -15,6 +15,49 @@ npm run build # output -> dist/
npm run preview # serve the built site locally npm run preview # serve the built site locally
``` ```
## App privacy policies (rendered from the app repos)
`/calendula/privacy` and `/agendula/privacy` hold **no copy** of their prose.
Each policy lives in its own app repository, as `docs/PRIVACY.md` with
`title` / `description` / `updated` frontmatter, and is the single copy of that
policy anywhere:
| Page | Source |
|------|--------|
| `/calendula/privacy` | [`jlmakiola/calendula`](https://codeberg.org/jlmakiola/calendula) → `docs/PRIVACY.md` |
| `/agendula/privacy` | [`jlmakiola/agendula`](https://codeberg.org/jlmakiola/agendula) → `docs/PRIVACY.md` |
`scripts/sync-external.mjs` clones them (shallow, single-branch, no
credentials) into `external/` before every build and dev server — it is wired
to `prebuild` and `predev`, **not** to CI, because Coolify builds the site from
the repo and a checkout that only ran in a Gitea job would never reach the
deployed page. `src/content.config.ts` exposes each file as a content
collection; the pages render it and supply the `<h1>` from `title`.
**To change a policy, edit it in the app repo, in a PR.** It reaches the live
page on the site's next build.
```sh
npm run sync:external # refresh the checkouts by hand
CALENDULA_REF=some/branch npm run build # build against a branch
AGENDULA_LOCAL=../agendula npm run dev # render a working copy, no network
```
The build **fails** if either policy is missing, empty, or malformed. That is
deliberate: a privacy page silently rendering nothing is the one failure this
arrangement exists to prevent, and it is worth a red deploy.
### Getting a policy edit onto the site
A build is what publishes it, so:
- **Floor:** the daily `scheduled-deploy` cron rebuilds every morning, so any
edit is live within a day without anyone doing anything.
- **Immediate:** add a webhook in the app repo (Codeberg → Settings → Webhooks)
pointing at the same Coolify deploy URL the cron uses, so a merge to the app's
`main` triggers a site rebuild at once. One-time setup per app repo; the
secret lives in Coolify, not here.
## Writing a post ## Writing a post
Create a Markdown file in `src/content/blog/`, e.g. `my-post.md`: Create a Markdown file in `src/content/blog/`, e.g. `my-post.md`:
+3
View File
@@ -4,6 +4,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"sync:external": "node scripts/sync-external.mjs",
"predev": "npm run sync:external",
"prebuild": "npm run sync:external",
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
+146
View File
@@ -0,0 +1,146 @@
// Fetch the app repositories whose Markdown this site renders.
//
// Each app's docs/PRIVACY.md is the single copy of that policy: the app repo
// owns it, and this site renders it through a content collection (see
// src/content.config.ts). Nothing here is authored in this repository, and
// nothing is written back — this only makes the sources present at build time.
//
// It runs from `prebuild` and `predev`, NOT from CI: Coolify builds the site
// straight from the repo, so a checkout that only happened in a Gitea job
// would never reach the deployed page.
//
// Refs default to `main`. Point a source at a branch with e.g.
// AGENDULA_REF=feat/caldav-sync, or at a working copy on this machine with
// AGENDULA_LOCAL=/path/to/agendula (which skips the network entirely).
//
// The guardrail is the point of the script: if a policy source is missing,
// empty, or malformed, the build FAILS. A privacy page that silently renders
// nothing is the one outcome this design exists to prevent.
import { execFileSync } from 'node:child_process';
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const EXTERNAL = join(ROOT, 'external');
const SOURCES = [
{
name: 'calendula',
repo: 'https://codeberg.org/jlmakiola/calendula.git',
raw: (ref) => `https://codeberg.org/jlmakiola/calendula/raw/branch/${ref}/docs/PRIVACY.md`,
file: 'docs/PRIVACY.md',
},
{
name: 'agendula',
repo: 'https://codeberg.org/jlmakiola/agendula.git',
raw: (ref) => `https://codeberg.org/jlmakiola/agendula/raw/branch/${ref}/docs/PRIVACY.md`,
file: 'docs/PRIVACY.md',
},
];
const REQUIRED_FRONTMATTER = ['title', 'description', 'updated'];
const MIN_BODY_CHARS = 400;
const env = (name, suffix) => process.env[`${name.toUpperCase()}_${suffix}`]?.trim() || '';
const log = (msg) => console.log(`[external] ${msg}`);
const git = (args, cwd) =>
execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
/** Shallow single-branch clone, or a shallow fetch if it is already there. */
function syncRepo(source, ref, dir) {
if (existsSync(join(dir, '.git'))) {
git(['fetch', '--depth', '1', 'origin', ref], dir);
git(['checkout', '--detach', 'FETCH_HEAD'], dir);
return `fetched ${ref}`;
}
rmSync(dir, { recursive: true, force: true });
git(['clone', '--depth', '1', '--single-branch', '--branch', ref, source.repo, dir]);
return `cloned ${ref}`;
}
/** Last resort when git is unavailable or the clone fails: the raw file. */
async function fetchRaw(source, ref, dir) {
const res = await fetch(source.raw(ref), { headers: { Accept: 'text/plain' } });
if (!res.ok) throw new Error(`raw fetch returned HTTP ${res.status}`);
const body = await res.text();
const target = join(dir, source.file);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, body);
return `fetched ${source.file} at ${ref} over HTTPS`;
}
/** Parse just enough frontmatter to know the entry will satisfy the schema. */
function validate(source, dir) {
const path = join(dir, source.file);
if (!existsSync(path)) return `${source.file} is missing`;
const text = readFileSync(path, 'utf8');
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return `${source.file} has no frontmatter block`;
const [, frontmatter, body] = match;
const missing = REQUIRED_FRONTMATTER.filter(
(key) => !new RegExp(`^${key}:\\s*\\S`, 'm').test(frontmatter)
);
if (missing.length) return `${source.file} frontmatter is missing: ${missing.join(', ')}`;
// Strip HTML comments — the maintainer note must not count as content.
const prose = body.replace(/<!--[\s\S]*?-->/g, '').trim();
if (prose.length < MIN_BODY_CHARS) return `${source.file} body is empty or too short`;
if (!/^##\s/m.test(prose)) return `${source.file} body has no sections`;
return null;
}
let failed = false;
mkdirSync(EXTERNAL, { recursive: true });
for (const source of SOURCES) {
const dir = join(EXTERNAL, source.name);
const local = env(source.name, 'LOCAL');
const ref = env(source.name, 'REF') || 'main';
if (local) {
// Development escape hatch: render a working copy on this machine, for a
// policy edit that is not pushed yet. Never set in CI or on the server.
const target = isAbsolute(local) ? local : resolve(ROOT, local);
if (!existsSync(target)) {
console.error(`[external] ${source.name}: ${source.name.toUpperCase()}_LOCAL points at ${target}, which does not exist`);
failed = true;
continue;
}
if (existsSync(dir) || lstatSync(dir, { throwIfNoEntry: false })) {
rmSync(dir, { recursive: true, force: true });
}
symlinkSync(target, dir, 'dir');
log(`${source.name}: local override -> ${target}`);
} else {
try {
log(`${source.name}: ${syncRepo(source, ref, dir)}`);
} catch (error) {
const reason = (error.stderr?.toString() || error.message).split('\n')[0];
log(`${source.name}: git failed (${reason}); falling back to the raw file`);
try {
log(`${source.name}: ${await fetchRaw(source, ref, dir)}`);
} catch (fallbackError) {
log(`${source.name}: fallback failed (${fallbackError.message})`);
}
}
}
const problem = validate(source, dir);
if (problem) {
console.error(`[external] ${source.name}: ${problem}`);
failed = true;
} else {
log(`${source.name}: ${source.file} ok`);
}
}
if (failed) {
console.error(
'\n[external] A privacy policy source is unusable, so the build stops here.\n' +
' These pages must never render empty — fix the source, or set\n' +
' <APP>_REF / <APP>_LOCAL if you are working against a branch.'
);
process.exit(1);
}
+1 -1
View File
@@ -7,7 +7,7 @@ const year = new Date().getFullYear();
<footer class="site-footer"> <footer class="site-footer">
<div class="site-footer__inner"> <div class="site-footer__inner">
<p> <p>
© {year} {SITE.author} · <a href="/uses">Uses</a> · © {year} {SITE.author} · <a href="/uses">Uses</a> ·{' '}
<a href="/impressum">Impressum</a> · <a href="/datenschutz">Datenschutz</a> <a href="/impressum">Impressum</a> · <a href="/datenschutz">Datenschutz</a>
</p> </p>
{ {
+1 -1
View File
@@ -22,7 +22,7 @@ export const LEGAL = {
// Social / external links shown in the footer. `icon` is an Iconify name. // Social / external links shown in the footer. `icon` is an Iconify name.
export const SOCIALS: { label: string; href: string; icon: string }[] = [ export const SOCIALS: { label: string; href: string; icon: string }[] = [
{ label: 'Gitea', href: 'https://gitea.jeanlucmakiola.de/makiolaj', icon: 'simple-icons:gitea' }, { label: 'Codeberg', href: 'https://codeberg.org/jlmakiola', icon: 'simple-icons:codeberg' },
{ label: 'Ko-fi', href: 'https://ko-fi.com/jeanlucmakiola', icon: 'simple-icons:kofi' }, { label: 'Ko-fi', href: 'https://ko-fi.com/jeanlucmakiola', icon: 'simple-icons:kofi' },
{ label: 'Email', href: `mailto:${LEGAL.email}`, icon: 'mdi:email-outline' }, { label: 'Email', href: `mailto:${LEGAL.email}`, icon: 'mdi:email-outline' },
]; ];
+25 -2
View File
@@ -39,8 +39,10 @@ const projects = defineCollection({
.default([]), .default([]),
links: z links: z
.object({ .object({
gitea: z.string().url().optional(), // Canonical source is Codeberg; the self-hosted Gitea is build infra.
codeberg: z.string().url().optional(),
fdroid: z.string().url().optional(), fdroid: z.string().url().optional(),
play: z.string().url().optional(),
donate: z.string().url().optional(), donate: z.string().url().optional(),
translate: z.string().url().optional(), translate: z.string().url().optional(),
}) })
@@ -48,4 +50,25 @@ const projects = defineCollection({
}), }),
}); });
export const collections = { blog, projects }; // App privacy policies. The Markdown lives in each app's own repository and is
// the only copy of that policy; scripts/sync-external.mjs puts the repo under
// external/ before a build or a dev server, and the pages under
// src/pages/<app>/privacy.astro render it. Nothing here is authored in this
// repo — edit the policy in the app repo, in a PR.
const policySchema = z.object({
title: z.string(),
description: z.string(),
updated: z.coerce.date(),
});
const calendulaPolicy = defineCollection({
loader: glob({ pattern: 'PRIVACY.md', base: './external/calendula/docs' }),
schema: policySchema,
});
const agendulaPolicy = defineCollection({
loader: glob({ pattern: 'PRIVACY.md', base: './external/agendula/docs' }),
schema: policySchema,
});
export const collections = { blog, projects, calendulaPolicy, agendulaPolicy };
+33 -19
View File
@@ -6,37 +6,51 @@ released: false
platform: Android 10+ platform: Android 10+
license: MIT license: MIT
summary: >- summary: >-
The task-list sibling to Calendula — a pure front-end over the OpenTasks The task-list sibling to Calendula. Keeps your tasks in its own store built
provider (CalDAV VTODOs), with no reinvented sync stack. around iCalendar VTODOs — or on top of a tasks provider you already sync —
with no account required.
tech: tech:
- Kotlin - Kotlin
- Jetpack Compose - Jetpack Compose
- Material 3 Expressive - Material 3 Expressive
- Room
features: features:
- title: Provider-native - title: Two places to keep tasks
body: >- body: >-
A pure front-end over the OpenTasks TaskContract provider. DAVx5, By default, Agendula's own database, designed around RFC 5545's VTODO —
SmoothSync, and DecSync sync your CalDAV VTODOs in; Agendula reads and no account, no permission, no other app needed. Or a provider you already
writes them — no own database. have (OpenTasks, tasks.org), left to whatever syncs it for you.
- title: Data layer done - title: Coexists, never replaces
body: >- body: >-
Provider resolution, live-updating reads, writes, smart-list filtering, Agendula's store is an ordinary app database, published to nothing, so
and a self-scheduled reminder engine are built and unit-tested. installing it never breaks OpenTasks — and if you already sync through a
- title: UI in progress provider, that keeps working exactly as it did.
- title: Nothing quietly lost
body: >- body: >-
The Material 3 Expressive screens are being built on top of the tested Recurring tasks are expanded per RFC 5545, and anything the schema doesn't
data layer, one at a time. model is round-tripped verbatim rather than dropped. Export to standard
.ics files is built in, because data you can't take with you isn't yours.
- title: Open standards only - title: Open standards only
body: >- body: >-
CalDAV, iCalendar, and DecSync are the lane. Proprietary task services are CalDAV, iCalendar, and DecSync are the lane; a CalDAV sync engine of
out of scope by design — they would mean owning a sync stack. Agendula's own is being built now. Google Tasks and Microsoft To Do are
out of scope by design.
links: links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/agendula codeberg: https://codeberg.org/jlmakiola/agendula
donate: https://ko-fi.com/jeanlucmakiola donate: https://ko-fi.com/jeanlucmakiola
translate: https://weblate.dev.jeanlucmakiola.de/engage/agendula/
--- ---
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula
is a pure front-end over the **OpenTasks `TaskContract` provider**. The name carries **its own store** — a database designed against `VTODO`, the same shape
rhymes with its sibling on purpose: *Agendula* is *agenda* — Latin for "things DAVx5 (and SmoothSync, DecSync, …) syncs out of a CalDAV server. Using a tasks
to be done" — given Calendula's `-ula` ending. A Calendula flower head is a provider you already have is a choice rather than a requirement. The name rhymes
cluster of many small *florets*, so the two apps are florets of one bloom. with its sibling on purpose: *Agendula* is *agenda* — Latin for "things to be
done" — given Calendula's `-ula` ending. A Calendula flower head is a cluster of
many small *florets*, so the two apps are florets of one bloom.
**Where it stands:** the store, reminders, export, and the Material 3 Expressive
screens through task detail, editing and settings are built; German and
Brazilian Portuguese are the first community translations. Still ahead: a Glance
widget, the first F-Droid release, and Agendula's own CalDAV sync. Its
[privacy policy](/agendula/privacy) is already published.
+9 -2
View File
@@ -32,10 +32,12 @@ features:
- title: Private by default - title: Private by default
body: >- body: >-
Zero telemetry, zero analytics, no internet permission — your data never Zero telemetry, zero analytics, no internet permission — your data never
leaves the device. Dynamic color on Android 12+, German and English UI. leaves the device. Dynamic color on Android 12+, and fifteen community
translations alongside English.
links: links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/calendula codeberg: https://codeberg.org/jlmakiola/calendula
fdroid: https://f-droid.org/packages/de.jeanlucmakiola.calendula/ fdroid: https://f-droid.org/packages/de.jeanlucmakiola.calendula/
play: https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula
donate: https://ko-fi.com/jeanlucmakiola donate: https://ko-fi.com/jeanlucmakiola
translate: https://weblate.dev.jeanlucmakiola.de/engage/calendula/ translate: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
--- ---
@@ -46,3 +48,8 @@ on top of Android's `CalendarContract`: any calendar synced to your device
(CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply appears, and (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply appears, and
everything you create or edit syncs back the same way. **No own database, no everything you create or edit syncs back the same way. **No own database, no
sync stack reinvented.** sync stack reinvented.**
Install it from F-Droid or Google Play — same app, same MIT source on Codeberg,
and still no internet permission in either build. There is also a
[privacy policy](/calendula/privacy) covering what the app does and doesn't do
with your data.
+16 -7
View File
@@ -10,22 +10,31 @@ summary: >-
app family — so each app draws from one bloom instead of reinventing it. app family — so each app draws from one bloom instead of reinventing it.
tech: tech:
- Kotlin - Kotlin
- Jetpack Compose
- Gradle composite build - Gradle composite build
features: features:
- title: Built from source - title: Built from source
body: >- body: >-
Embedded as a git submodule and wired in with a Gradle composite build — Embedded as a git submodule and wired in with a Gradle composite build —
no published artifacts, which keeps every app reproducible for F-Droid. no published artifacts, which keeps every app reproducible for F-Droid.
- title: core-time - title: Identity and components
body: >- body: >-
Pure-Kotlin date/time helpers: local-day windows for smart-list logic and The M3 Expressive theme factory, navigation motion and predictive-back
locale/zone-aware display formatting. No Android, no dependencies. peek, plus the shared Compose vocabulary each screen is written out of —
- title: Growing grouped rows, inline fields, option cards, full-screen pickers,
collapsing scaffolds.
- title: Plumbing, not looks
body: >- body: >-
Identity/theme, components, screen recipes, and provider / prefs / core-time (local-day windows, locale-aware formatting), core-reminders
reminders / crash plumbing land as they're extracted from the apps. (lead-time model and codec), core-locale (per-app language), core-crash
(on-device capture and issue hand-off) — each app layers its own storage
and strings on top.
- title: Mechanics shared, look per-app
body: >-
The principle the kit is held to: a component moves here once two apps
need the same behaviour, while palette and personality stay with the app.
links: links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/floret-kit codeberg: https://codeberg.org/jlmakiola/floret-kit
donate: https://ko-fi.com/jeanlucmakiola donate: https://ko-fi.com/jeanlucmakiola
--- ---
+31
View File
@@ -0,0 +1,31 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getPolicy } from '../../utils/policy';
// The policy itself lives in the Agendula repository, at docs/PRIVACY.md, and is
// rendered from there — this page holds no copy of the prose, so the published
// page and the app's own documentation cannot drift apart. Edit the policy in
// https://codeberg.org/jlmakiola/agendula, in a PR.
//
// The URL is load-bearing: /agendula/privacy is what the app's Settings row
// opens and what the Play Console field holds. Do not move or rename it.
//
// Title and description come from the file's frontmatter; the body carries its
// own "last updated" line and deliberately starts below the h1 this page
// supplies.
const entry = await getPolicy('agendulaPolicy');
const { Content } = await render(entry);
---
<BaseLayout
title={entry.data.title}
description={entry.data.description}
type="article"
width="narrow"
>
<article class="prose">
<h1 class="page-title">{entry.data.title}</h1>
<Content />
</article>
</BaseLayout>
+18 -208
View File
@@ -1,221 +1,31 @@
--- ---
import { render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import { LEGAL } from '../../consts'; import { getPolicy } from '../../utils/policy';
// App privacy policy for Calendula — required as a public URL by the Google // The policy itself lives in the Calendula repository, at docs/PRIVACY.md, and is
// Play Console (mandatory for every app) and linked from the app's About card. // rendered from there — this page holds no copy of the prose, so the published
// Deliberately separate from /datenschutz, which covers this website only. // page and the app's own documentation cannot drift apart. Edit the policy in
// https://codeberg.org/jlmakiola/calendula, in a PR.
// //
// Kept in English because SITE.lang is 'en' and Play's default store listing is // The URL is load-bearing: /calendula/privacy is what the app's Settings row
// en-US; /datenschutz and /impressum stay German for legal reasons. // opens and what the Play Console field holds. Do not move or rename it.
// //
// Source of truth for the facts below: the Calendula repository. If the app's // Title and description come from the file's frontmatter; the body carries its
// permissions or data paths change, update this page and the "Last updated" // own "last updated" line and deliberately starts below the h1 this page
// date. The claim "no INTERNET permission" is verifiable via // supplies.
// `aapt2 dump permissions` on any released APK. const entry = await getPolicy('calendulaPolicy');
const lastUpdated = '28 July 2026'; const { Content } = await render(entry);
--- ---
<BaseLayout <BaseLayout
title="Calendula — Privacy Policy" title={entry.data.title}
description="Privacy policy for the Calendula Android calendar app. No data collection, no tracking, no internet permission." description={entry.data.description}
type="article"
width="narrow" width="narrow"
> >
<article class="prose"> <article class="prose">
<h1 class="page-title">Privacy Policy — Calendula</h1> <h1 class="page-title">{entry.data.title}</h1>
<Content />
<p>
<strong>Last updated:</strong> {lastUpdated}<br />
Applies to the Android app <strong>Calendula</strong>
(package <code>de.jeanlucmakiola.calendula</code>), all versions and all
distribution channels.
</p>
<h2>In short</h2>
<p>
Calendula collects nothing, sends nothing, and has no user accounts.
It has <strong>no internet permission at all</strong> — the app is
technically incapable of transmitting your data anywhere. Everything it
shows you is read from the calendars that already exist on your device.
</p>
<h2>1. Controller</h2>
<p>
{LEGAL.business}<br />
{LEGAL.street}<br />
{LEGAL.city}<br />
Email: <a href={`mailto:${LEGAL.email}`}>{LEGAL.email}</a>
</p>
<h2>2. No data collection</h2>
<p>
Calendula contains <strong>no analytics, no tracking, no advertising, no
crash-reporting SDK and no third-party services of any kind</strong>. No
user profile is created, no identifier is generated, and no data is shared
with or sold to anyone.
</p>
<p>
The app does not request the <code>android.permission.INTERNET</code> permission.
Without it, Android prevents the app from opening any network connection.
This is verifiable: inspect the permission list of any released APK, or read
the <a href="https://codeberg.org/jlmakiola/calendula" rel="noopener">source code</a>.
</p>
<h2>3. Data Calendula accesses on your device</h2>
<p>
All of the following is processed <strong>locally on your device only</strong>.
None of it is transmitted, and none of it is stored by the developer.
</p>
<h3>Calendar data</h3>
<p>
Calendula is a viewer and editor for the calendars Android already manages.
It reads and writes events, reminders and calendar settings through
Android's system calendar provider. The app keeps <strong>no database of
its own</strong> — your events live in the system calendar, exactly where
they lived before you installed Calendula, and they remain there if you
uninstall it.
</p>
<p>
Note: if one of those system calendars is itself synchronised with an
online account (for example a Google account, or a CalDAV server via
DAVx5), that synchronisation is performed by Android and that other app —
not by Calendula. The privacy policy of the respective provider applies
to it.
</p>
<h3>Contacts (optional)</h3>
<p>
The “Contact special dates” feature reads birthdays and anniversaries from
your contacts and mirrors them one-way into a local calendar, so they
appear alongside your other events. This feature is switched off by
default, the contacts permission is requested only when you enable it, and
it is never requested at startup. Contacts are only ever read, never
modified, and the data does not leave your device.
</p>
<h3>Notifications</h3>
<p>
Reminders are displayed as local notifications on your device. Nothing is
sent to a push service.
</p>
<h3>Files</h3>
<p>
When you import or export an ICS file, Calendula reads or writes exactly
the file you select through Android's system file picker. The optional
automatic backup writes an ICS export to the folder you choose. The app has
no access to other files.
</p>
<h3>App settings</h3>
<p>
Your preferences (view options, theme, reminder defaults and similar) are
stored locally on your device and are removed when you uninstall the app.
</p>
<h2>4. Crash reports — the only case where data can leave your device</h2>
<p>
If Calendula crashes, it offers to report the problem. Nothing is sent
automatically. The report is copied to your clipboard and your browser is
opened with the project's issue tracker, the text
pre-filled. <strong>You see the full content, you decide whether to submit
it, and you can edit or discard it.</strong>
</p>
<p>Such a report contains:</p>
<ul>
<li>app version,</li>
<li>Android version,</li>
<li>device manufacturer and model,</li>
<li>your device language,</li>
<li>the timestamp,</li>
<li>and the technical stack trace.</li>
</ul>
<p>
It contains <strong>no</strong> event data, <strong>no</strong> contacts
and <strong>no</strong> personal identifiers.
</p>
<p>
If you choose to submit it, the report becomes a public issue on the
project's issue tracker at Codeberg, operated by Codeberg&nbsp;e.&nbsp;V.
Their privacy policy then applies to that submission.
</p>
<h2>5. External links</h2>
<p>
The settings screen contains links to the source code, the licence, the
issue tracker and a voluntary donation page (Ko-fi). Following one of these
links opens your browser and leaves the app; the privacy policy of the
respective website then applies. Calendula transmits no data of yours in
the process — it only opens the address.
</p>
<h2>6. Permissions and why they exist</h2>
<ul>
<li>
<code>READ_CALENDAR</code>, <code>WRITE_CALENDAR</code> — display and
edit your events; the core function.
</li>
<li><code>POST_NOTIFICATIONS</code> — show reminders.</li>
<li>
<code>READ_CONTACTS</code> — optional, only for the “Contact special
dates” feature.
</li>
<li>
<code>USE_EXACT_ALARM</code>, <code>SCHEDULE_EXACT_ALARM</code> — deliver
reminders at the exact time, including after snoozing.
</li>
<li>
<code>RECEIVE_BOOT_COMPLETED</code> — re-register pending reminders after
a restart.
</li>
<li>
<code>REQUEST_IGNORE_BATTERY_OPTIMIZATIONS</code> — only to open the
system dialog for the “Reliable delivery” setting.
</li>
<li>
<code>WAKE_LOCK</code>, <code>FOREGROUND_SERVICE</code>, <code>ACCESS_NETWORK_STATE</code>
— required by the Android system component used for scheduled background
work (WorkManager).
</li>
</ul>
<p>
<code>ACCESS_NETWORK_STATE</code> allows reading <em>whether</em> a network
connection exists — it does <strong>not</strong> permit using one.
Without <code>INTERNET</code>, no connection is possible.
</p>
<h2>7. Distribution channels</h2>
<p>
Calendula is distributed via F-Droid, Obtainium, a self-hosted repository
and, where applicable, the Google Play Store. When you download or update
the app, the operator of that channel processes data (such as your IP
address) under their own privacy policy. This is outside the developer's
control and unrelated to the app's own behaviour.
</p>
<h2>8. Children</h2>
<p>
Calendula is suitable for all ages. Since it collects no data at all, no
data of children is processed either.
</p>
<h2>9. Your rights</h2>
<p>
Because the developer processes no personal data of yours, there is no
stored data to which rights of access, rectification, erasure,
restriction, data portability or objection (Art.&nbsp;1521 GDPR) could
apply. You may nevertheless contact the address above at any time with any
question. You also have the right to lodge a complaint with a supervisory
authority.
</p>
<h2>10. Changes to this policy</h2>
<p>
Should the app's functionality change in a way that affects data
processing, this policy will be updated and the date at the top adjusted.
The version history is publicly traceable in the project's source
repository.
</p>
</article> </article>
</BaseLayout> </BaseLayout>
+21 -10
View File
@@ -15,7 +15,7 @@ import { LEGAL } from '../consts';
<article class="prose"> <article class="prose">
<h1 class="page-title">Uses</h1> <h1 class="page-title">Uses</h1>
<p> <p>
A running colophon of the tools and stack behind this site, the A running colophon of the tools and stack behind this site, the{' '}
<a href="/work">Floret apps</a>, and the infrastructure they run on. The <a href="/work">Floret apps</a>, and the infrastructure they run on. The
throughline: open, self-hostable standards, and as little reliance on throughline: open, self-hostable standards, and as little reliance on
third-party services as I can manage. third-party services as I can manage.
@@ -29,14 +29,14 @@ import { LEGAL } from '../consts';
the repo. the repo.
</li> </li>
<li> <li>
Typefaces are <a href="https://rsms.me/inter/" rel="noopener">Inter</a> Typefaces are <a href="https://rsms.me/inter/" rel="noopener">Inter</a>{' '}
and <a href="https://www.jetbrains.com/lp/mono/" rel="noopener">JetBrains and <a href="https://www.jetbrains.com/lp/mono/" rel="noopener">JetBrains
Mono</a>, self-hosted via Fontsource — no Google Fonts CDN, so no Mono</a>, self-hosted via Fontsource — no Google Fonts CDN, so no
third-party request on page load. third-party request on page load.
</li> </li>
<li> <li>
Analytics is self-hosted <a href="https://umami.is" rel="noopener">Umami</a>: Analytics is self-hosted <a href="https://umami.is" rel="noopener">Umami</a>:
cookieless, no cross-site tracking, no personal profiles. See the cookieless, no cross-site tracking, no personal profiles. See the{' '}
<a href="/datenschutz">Datenschutzerklärung</a> for what that means. <a href="/datenschutz">Datenschutzerklärung</a> for what that means.
</li> </li>
<li>Icons from <a href="https://iconify.design" rel="noopener">Iconify</a> (Material Design Icons + Simple Icons).</li> <li>Icons from <a href="https://iconify.design" rel="noopener">Iconify</a> (Material Design Icons + Simple Icons).</li>
@@ -46,30 +46,41 @@ import { LEGAL } from '../consts';
<ul> <ul>
<li><strong>Kotlin</strong> and <strong>Jetpack Compose</strong>, designed in <strong>Material 3 Expressive</strong>.</li> <li><strong>Kotlin</strong> and <strong>Jetpack Compose</strong>, designed in <strong>Material 3 Expressive</strong>.</li>
<li> <li>
No reinvented sync stack — each app is a front-end over a platform Open standards over proprietary services — CalDAV, iCalendar, DecSync.
provider (<code>CalendarContract</code>, the OpenTasks Calendula is a pure front-end over Android's <code>CalendarContract</code>;
<code>TaskContract</code>) and open standards like CalDAV, iCalendar, and Agendula keeps its own <code>VTODO</code> store, or rides a tasks
DecSync. provider you already sync.
</li> </li>
<li> <li>
A shared design system, <a href="/work/floret-kit">floret-kit</a>, wired A shared design system, <a href="/work/floret-kit">floret-kit</a>, wired
in as a git submodule via a Gradle composite build — no published in as a git submodule via a Gradle composite build — no published
artifacts, so every app stays reproducible. artifacts, so every app stays reproducible.
</li> </li>
<li>Released on <a href="https://f-droid.org" rel="noopener">F-Droid</a>, MIT-licensed, zero telemetry.</li> <li>
Released on <a href="https://f-droid.org" rel="noopener">F-Droid</a> and
Google Play, MIT-licensed, zero telemetry.
</li>
</ul> </ul>
<h2>Self-hosted infrastructure</h2> <h2>Self-hosted infrastructure</h2>
<p>Code, translations, builds, and this site all run on infrastructure I host myself.</p> <p>Code, translations, builds, and this site all run on infrastructure I host myself.</p>
<ul> <ul>
<li><a href="https://gitea.jeanlucmakiola.de/makiolaj" rel="noopener">Gitea</a> for source hosting and as the home for smaller experiments.</li> <li>
<a href="https://codeberg.org/jlmakiola" rel="noopener">Codeberg</a> is
where the apps live — source, issues, releases.
</li>
<li>
Self-hosted <a href="https://gitea.jeanlucmakiola.de/makiolaj" rel="noopener">Gitea</a>{' '}
runs the build side of that — signing, the F-Droid repo, release
pipelines — and is home to smaller experiments.
</li>
<li><a href="https://weblate.org" rel="noopener">Weblate</a> for community translations of the apps.</li> <li><a href="https://weblate.org" rel="noopener">Weblate</a> for community translations of the apps.</li>
<li><a href="https://coolify.io" rel="noopener">Coolify</a> to build and deploy this site.</li> <li><a href="https://coolify.io" rel="noopener">Coolify</a> to build and deploy this site.</li>
<li>Umami for the privacy-respecting analytics above.</li> <li>Umami for the privacy-respecting analytics above.</li>
</ul> </ul>
<p class="muted"> <p class="muted">
This list grows as the stack does. Spotted something you'd ask about? This list grows as the stack does. Spotted something you'd ask about?{' '}
<a href={`mailto:${LEGAL.email}`}>Get in touch</a>. <a href={`mailto:${LEGAL.email}`}>Get in touch</a>.
</p> </p>
</article> </article>
+38 -16
View File
@@ -5,26 +5,41 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import type { GetStaticPaths } from 'astro'; import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async () => { export const getStaticPaths = (async () => {
// Build-time: resolve the latest release tag from the project's Gitea repo. // Build-time: resolve the latest release tag from the project's Codeberg
// repo. Forgejo serves Gitea's /api/v1, so the same call fits both.
// `releases/latest` skips pre-releases, and a pre-1.0 app publishes nothing
// else — so fall back to the newest release of any kind before giving up.
// Never throws — returns null on 404/offline so the build can't break. // Never throws — returns null on 404/offline so the build can't break.
// (Defined inside getStaticPaths: Astro extracts this fn into its own scope.) // (Defined inside getStaticPaths: Astro extracts this fn into its own scope.)
const fetchLatestRelease = async (repoUrl?: string): Promise<string | null> => { const fetchLatestRelease = async (repoUrl?: string): Promise<string | null> => {
if (!repoUrl) return null; if (!repoUrl) return null;
const tagFrom = async (url: string): Promise<string | null> => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
try {
const res = await fetch(url, {
signal: ctrl.signal,
headers: { Accept: 'application/json' },
});
if (!res.ok) return null;
const data = await res.json();
const release = Array.isArray(data) ? data[0] : data;
return typeof release?.tag_name === 'string' ? release.tag_name : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
};
try { try {
const u = new URL(repoUrl); const u = new URL(repoUrl);
const [, owner, repo] = u.pathname.split('/'); const [, owner, repo] = u.pathname.split('/');
if (!owner || !repo) return null; if (!owner || !repo) return null;
const api = `${u.origin}/api/v1/repos/${owner}/${repo}/releases/latest`; const base = `${u.origin}/api/v1/repos/${owner}/${repo}/releases`;
const ctrl = new AbortController(); return (
const timer = setTimeout(() => ctrl.abort(), 8000); (await tagFrom(`${base}/latest`)) ??
const res = await fetch(api, { (await tagFrom(`${base}?draft=false&limit=1`))
signal: ctrl.signal, );
headers: { Accept: 'application/json' },
});
clearTimeout(timer);
if (!res.ok) return null;
const data = await res.json();
return typeof data.tag_name === 'string' ? data.tag_name : null;
} catch { } catch {
return null; return null;
} }
@@ -34,7 +49,7 @@ export const getStaticPaths = (async () => {
return Promise.all( return Promise.all(
projects.map(async (project) => ({ projects.map(async (project) => ({
params: { slug: project.id }, params: { slug: project.id },
props: { project, version: await fetchLatestRelease(project.data.links.gitea) }, props: { project, version: await fetchLatestRelease(project.data.links.codeberg) },
})) }))
); );
}) satisfies GetStaticPaths; }) satisfies GetStaticPaths;
@@ -67,9 +82,16 @@ const { Content } = await render(project);
) )
} }
{ {
links.gitea && ( links.play && (
<a class="btn btn--outlined" href={links.gitea} rel="noopener"> <a class="btn btn--tonal" href={links.play} rel="noopener">
<Icon name="simple-icons:gitea" /> Source on Gitea <Icon name="simple-icons:googleplay" /> Get it on Google Play
</a>
)
}
{
links.codeberg && (
<a class="btn btn--outlined" href={links.codeberg} rel="noopener">
<Icon name="simple-icons:codeberg" /> Source on Codeberg
</a> </a>
) )
} }
+5
View File
@@ -212,6 +212,11 @@ a:hover { text-decoration-color: currentColor; }
.btn:active { transform: scale(0.97); } .btn:active { transform: scale(0.97); }
.btn--filled { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); } .btn--filled { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); }
.btn--filled:hover { box-shadow: var(--md-sys-elevation-2); } .btn--filled:hover { box-shadow: var(--md-sys-elevation-2); }
.btn--tonal {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
.btn--tonal:hover { box-shadow: var(--md-sys-elevation-1); }
.btn--outlined { .btn--outlined {
background: transparent; background: transparent;
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
+21
View File
@@ -0,0 +1,21 @@
import { getCollection } from 'astro:content';
/**
* The single entry of an app's privacy-policy collection.
*
* The Markdown lives in the app's own repository (see scripts/sync-external.mjs);
* this site holds no copy. If the checkout is missing or empty the collection
* resolves nothing, and a privacy page that renders nothing is worse than a
* failed build — so this throws rather than returning undefined.
*/
export async function getPolicy(collection: 'calendulaPolicy' | 'agendulaPolicy') {
const entries = await getCollection(collection);
if (entries.length !== 1) {
throw new Error(
`[policy] ${collection} resolved ${entries.length} entries, expected exactly 1. ` +
'The app repository under external/ is missing, empty, or holds more than ' +
'one PRIVACY.md. Run `npm run sync:external` and read its output.'
);
}
return entries[0];
}