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.
This commit is contained in:
2026-09-09 16:33:11 +02:00
parent 6311baccb7
commit 8a1eb54a45
10 changed files with 286 additions and 524 deletions
+4
View File
@@ -5,3 +5,7 @@ dist
.env
.env.production
*.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
# local screenshots
.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/*)
# make npm ci's strict lock-sync check fail across npm/node versions. install
# 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* ./
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
```
## 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
Create a Markdown file in `src/content/blog/`, e.g. `my-post.md`:
+3
View File
@@ -4,6 +4,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"sync:external": "node scripts/sync-external.mjs",
"predev": "npm run sync:external",
"prebuild": "npm run sync:external",
"dev": "astro dev",
"build": "astro build",
"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);
}
+22 -1
View File
@@ -48,4 +48,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 };
+18 -315
View File
@@ -1,328 +1,31 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { LEGAL } from '../../consts';
import { getPolicy } from '../../utils/policy';
// App privacy policy for Agendula required as a public URL by the Google Play
// Console (mandatory for every app) and linked from the app's About card.
// Deliberately separate from /datenschutz, which covers this website only.
// 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.
//
// Kept in English because SITE.lang is 'en' and Play's default store listing is
// en-US; /datenschutz and /impressum stay German for legal reasons.
// 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.
//
// Source of truth for the facts below: the Agendula repository (docs/PRIVACY.md,
// docs/SYNC.md, AndroidManifest.xml, backup_rules.xml). Unlike Calendula,
// Agendula *does* hold INTERNET — it has its own CalDAV sync — so the wording
// here turns on "only to the server you entered", not on the absence of the
// permission. If sync, permissions or data paths change, update this page and
// the "Last updated" date.
const lastUpdated = '9 September 2026';
// 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="Agendula — Privacy Policy"
description="Privacy policy for the Agendula Android task app. No accounts, no analytics, no tracking — data leaves your device only for the CalDAV server you choose."
title={entry.data.title}
description={entry.data.description}
type="article"
width="narrow"
>
<article class="prose">
<h1 class="page-title">Privacy Policy — Agendula</h1>
<p>
<strong>Last updated:</strong> {lastUpdated}<br />
Applies to the Android app <strong>Agendula</strong>{' '}
(package <code>de.jeanlucmakiola.agendula</code>), all versions and all
distribution channels.
</p>
<h2>In short</h2>
<p>
Agendula has no servers, no user accounts and no analytics. Your tasks live
on your device. They leave it in exactly one case: if you set up a CalDAV
account yourself, they are synchronised with <strong>the server you
entered</strong> — and with nothing and no one else. Nothing is ever sent
to the developer.
</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 by the developer</h2>
<p>
Agendula contains <strong>no analytics, no tracking, no advertising, no
crash-reporting SDK and no third-party service that reports anything
anywhere</strong>. No user profile is created, no advertising or device
identifier is generated, and no data is shared with or sold to anyone.
There is no Agendula account, and the developer operates no server that the
app talks to.
</p>
<p>
All of this is verifiable in the{' '}
<a href="https://codeberg.org/jlmakiola/agendula" rel="noopener">source code</a>,
which is public.
</p>
<h2>3. Where your tasks live — your choice</h2>
<p>
Agendula offers two storage modes, and you pick one:
</p>
<ul>
<li>
<strong>On your device (the default)</strong> — your task lists, tasks and
reminders are kept in Agendula's own database inside the app's private
storage. Nothing is published to other apps, and uninstalling the app
removes it.
</li>
<li>
<strong>In a tasks provider you already use</strong> — OpenTasks or
tasks.org. Agendula then reads and writes that app's task database
through Android's provider mechanism, after you grant its read/write
permission. Whatever already synchronises that provider (DAVx5,
SmoothSync, DecSync CC, …) keeps doing so, unchanged; that synchronisation
is performed by those apps, not by Agendula, and their privacy policies
apply to it.
</li>
</ul>
<h2>4. CalDAV sync — the only case where your tasks leave the device</h2>
<p>
Sync is optional and off until you add an account. If you add one,
everything below happens between your device and <strong>the server you
nominated</strong>, and nowhere else.
</p>
<h3>What is stored on your device</h3>
<p>
The server address, your username, and your password or app password. The
password is encrypted with a key held in the Android Keystore, which cannot
be exported from the device.
</p>
<h3>What is transmitted, and to whom</h3>
<ul>
<li>
The tasks in the synchronised lists, as standard iCalendar
(<code>VTODO</code>) data, and the credentials needed to authenticate.
</li>
<li>
Requests carry the user agent <code>Agendula (Android)</code> — a fixed
string, so that you can recognise and revoke the session on your server.
No device identifier is sent.
</li>
<li>
Connections are HTTPS. Cleartext HTTP is refused, so credentials are
never sent over an unencrypted connection.
</li>
</ul>
<p>
Under Google Play's Data Safety definitions this counts as{' '}
<strong>collected</strong> — Play defines collection as transmitting data
off the device, regardless of who receives it — and <strong>not
shared</strong>, because the only recipient is the server you chose. Data is
encrypted in transit.
</p>
<h3>Finding your server</h3>
<p>
When you type a server address or an email domain, Agendula follows the
standard discovery procedure (RFC 6764): a DNS lookup for the{' '}
<code>_caldavs._tcp</code> service record of that domain, then{' '}
<code>/.well-known/caldav</code> on the host. The DNS query goes to whichever
resolver your device or network uses, and the requests go to the domain you
typed — no directory of servers is consulted and no lookup is sent to the
developer.
</p>
<h3>Signing in to a Nextcloud</h3>
<p>
If the server is a Nextcloud, Agendula uses Nextcloud's Login Flow v2: your
browser opens <em>your own server's</em> login page, you authorise there,
and the server hands the app a dedicated app password. Agendula never sees
your actual account password. The app password appears in your server's
“Devices &amp; sessions” list as <code>Agendula (Android)</code>, and you can
revoke it there at any time. Removing the account in Agendula revokes it too,
where the server supports that.
</p>
<h3>Your server's own policy</h3>
<p>
Your CalDAV provider has its own privacy policy, and your data on their
server is governed by it. Agendula has no relationship with them.
</p>
<p>
A note on certificates: Agendula trusts private certificate authorities that
you have installed in your device's user store, because self-hosted servers
routinely use them. That is a deliberate trade-off in favour of
self-hosters — any CA installed on your device (for example by an employer's
management profile) can, in principle, intercept traffic from the app, as it
can from other apps that make the same choice.
</p>
<h2>5. Other data Agendula handles on your device</h2>
<h3>Reminders and notifications</h3>
<p>
Due-date reminders are scheduled by the app itself and displayed as local
notifications. Nothing is sent to a push service — there is no push service.
</p>
<h3>Export files</h3>
<p>
You can export your tasks as standard iCalendar <code>.ics</code> files.
Agendula writes exactly the file you select through Android's system file
picker, and has no access to other files.
</p>
<h3>App settings</h3>
<p>
Your preferences (theme, language, list and reminder defaults and similar)
are stored locally on your device and are removed when you uninstall the
app.
</p>
<h2>6. Backups</h2>
<p>
If Android Auto Backup is enabled on your device, your tasks and settings
may be backed up to your own Google account, under Google's terms — the
developer has no access to it. Two things are deliberately excluded from
that backup: your stored CalDAV password, and Agendula's per-device sync
bookkeeping. After restoring onto a new device you therefore sign in to your
server again.
</p>
<h2>7. Crash reports</h2>
<p>
If Agendula crashes, it offers to report the problem. Nothing is sent
automatically, even though the app has network access. 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 is built from that fixed list and nothing else: <strong>no</strong> task
data, <strong>no</strong> server address or credentials, <strong>no</strong>{' '}
account names, <strong>no</strong> log files 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>8. External links</h2>
<p>
The app links to the source code, the licence, the issue tracker, the
translation platform (Weblate) 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. Agendula transmits no
data of yours in the process — it only opens the address.
</p>
<h2>9. Permissions and why they exist</h2>
<ul>
<li>
<code>INTERNET</code>, <code>ACCESS_NETWORK_STATE</code> — CalDAV sync with
the server you configure, and checking whether a connection exists before
trying. Without a CalDAV account, no connection is made.
</li>
<li>
<code>READ_SYNC_SETTINGS</code>, <code>WRITE_SYNC_SETTINGS</code> — register
the sync account with Android's sync framework so it can be scheduled.
</li>
<li><code>POST_NOTIFICATIONS</code> — show reminders.</li>
<li>
<code>USE_EXACT_ALARM</code>, <code>SCHEDULE_EXACT_ALARM</code> — deliver
reminders at the exact due time.
</li>
<li>
<code>RECEIVE_BOOT_COMPLETED</code> — re-register pending reminders after a
restart.
</li>
<li>
<code>org.dmfs.permission.READ_TASKS</code> /{' '}
<code>WRITE_TASKS</code> and <code>org.tasks.permission.READ_TASKS</code> /{' '}
<code>WRITE_TASKS</code> — optional, requested only if you choose the
external-provider storage mode, and only for the provider you selected
(OpenTasks or tasks.org).
</li>
<li>
<code>WAKE_LOCK</code>, <code>FOREGROUND_SERVICE</code> — required by the
Android system component used for scheduled background work
(WorkManager); on older Android versions it needs them to run an
expedited sync.
</li>
</ul>
<p>
Agendula publishes no content provider of its own and declares no
permissions that other apps could request.
</p>
<h2>10. Distribution channels</h2>
<p>
Agendula 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>11. Children</h2>
<p>
Agendula is not directed at children and collects nothing about anyone.
</p>
<h2>12. Deleting your data</h2>
<ul>
<li>
<strong>Remove a CalDAV account</strong> (Settings → Accounts) deletes the
stored credential and, where the server supports it, revokes the app
password. The task lists become device-only lists rather than being
destroyed.
</li>
<li>
<strong>Remove an account and delete its local data</strong> removes those
lists and their tasks as well.
</li>
<li>
<strong>Uninstalling the app</strong> removes everything Agendula stored on
the device.
</li>
</ul>
<p>
Data on your CalDAV server is deleted on that server; data in an external
tasks provider is deleted in that app.
</p>
<h2>13. Your rights</h2>
<p>
The developer stores no personal data of yours — the only data transfer the
app performs is between your device and a server you operate or chose. There
is therefore no data held by the developer to which rights of access,
rectification, erasure, restriction, data portability or objection
(Art.&nbsp;1521 GDPR) could apply. Your tasks are exportable as standard{' '}
<code>.ics</code> files from within the app at any time. You may contact the
address above with any question, and you have the right to lodge a complaint
with a supervisory authority.
</p>
<h2>14. 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>
<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 { LEGAL } from '../../consts';
import { getPolicy } from '../../utils/policy';
// App privacy policy for Calendula — required as a public URL by the Google
// Play Console (mandatory for every app) and linked from the app's About card.
// Deliberately separate from /datenschutz, which covers this website only.
// The policy itself lives in the Calendula 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/calendula, in a PR.
//
// Kept in English because SITE.lang is 'en' and Play's default store listing is
// en-US; /datenschutz and /impressum stay German for legal reasons.
// The URL is load-bearing: /calendula/privacy is what the app's Settings row
// 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
// permissions or data paths change, update this page and the "Last updated"
// date. The claim "no INTERNET permission" is verifiable via
// `aapt2 dump permissions` on any released APK.
const lastUpdated = '28 July 2026';
// 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('calendulaPolicy');
const { Content } = await render(entry);
---
<BaseLayout
title="Calendula — Privacy Policy"
description="Privacy policy for the Calendula Android calendar app. No data collection, no tracking, no internet permission."
title={entry.data.title}
description={entry.data.description}
type="article"
width="narrow"
>
<article class="prose">
<h1 class="page-title">Privacy Policy — Calendula</h1>
<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>
<h1 class="page-title">{entry.data.title}</h1>
<Content />
</article>
</BaseLayout>
+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];
}