diff --git a/.dockerignore b/.dockerignore index 1f82636..7264dec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index 8929305..689971b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ node_modules/ *.log # local screenshots .shots/ + +# App repositories fetched at build time (see scripts/sync-external.mjs). +external/ diff --git a/Dockerfile b/Dockerfile index 859422b..4e6631d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index e29bf34..b1077fb 100644 --- a/README.md +++ b/README.md @@ -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 `

` 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`: diff --git a/package.json b/package.json index fc1517e..b7dd89d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/sync-external.mjs b/scripts/sync-external.mjs new file mode 100644 index 0000000..6e0695d --- /dev/null +++ b/scripts/sync-external.mjs @@ -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(//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' + + ' _REF / _LOCAL if you are working against a branch.' + ); + process.exit(1); +} diff --git a/src/content.config.ts b/src/content.config.ts index f9c398d..9b4b66e 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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//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 }; diff --git a/src/pages/agendula/privacy.astro b/src/pages/agendula/privacy.astro index 5addcd9..210cf2c 100644 --- a/src/pages/agendula/privacy.astro +++ b/src/pages/agendula/privacy.astro @@ -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); ---
-

Privacy Policy — Agendula

- -

- Last updated: {lastUpdated}
- Applies to the Android app Agendula{' '} - (package de.jeanlucmakiola.agendula), all versions and all - distribution channels. -

- -

In short

-

- 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 the server you - entered — and with nothing and no one else. Nothing is ever sent - to the developer. -

- -

1. Controller

-

- {LEGAL.business}
- {LEGAL.street}
- {LEGAL.city}
- Email: {LEGAL.email} -

- -

2. No data collection by the developer

-

- Agendula contains no analytics, no tracking, no advertising, no - crash-reporting SDK and no third-party service that reports anything - anywhere. 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. -

-

- All of this is verifiable in the{' '} - source code, - which is public. -

- -

3. Where your tasks live — your choice

-

- Agendula offers two storage modes, and you pick one: -

-
    -
  • - On your device (the default) — 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. -
  • -
  • - In a tasks provider you already use — 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. -
  • -
- -

4. CalDAV sync — the only case where your tasks leave the device

-

- Sync is optional and off until you add an account. If you add one, - everything below happens between your device and the server you - nominated, and nowhere else. -

- -

What is stored on your device

-

- 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. -

- -

What is transmitted, and to whom

-
    -
  • - The tasks in the synchronised lists, as standard iCalendar - (VTODO) data, and the credentials needed to authenticate. -
  • -
  • - Requests carry the user agent Agendula (Android) — a fixed - string, so that you can recognise and revoke the session on your server. - No device identifier is sent. -
  • -
  • - Connections are HTTPS. Cleartext HTTP is refused, so credentials are - never sent over an unencrypted connection. -
  • -
-

- Under Google Play's Data Safety definitions this counts as{' '} - collected — Play defines collection as transmitting data - off the device, regardless of who receives it — and not - shared, because the only recipient is the server you chose. Data is - encrypted in transit. -

- -

Finding your server

-

- When you type a server address or an email domain, Agendula follows the - standard discovery procedure (RFC 6764): a DNS lookup for the{' '} - _caldavs._tcp service record of that domain, then{' '} - /.well-known/caldav 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. -

- -

Signing in to a Nextcloud

-

- If the server is a Nextcloud, Agendula uses Nextcloud's Login Flow v2: your - browser opens your own server's 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 & sessions” list as Agendula (Android), and you can - revoke it there at any time. Removing the account in Agendula revokes it too, - where the server supports that. -

- -

Your server's own policy

-

- Your CalDAV provider has its own privacy policy, and your data on their - server is governed by it. Agendula has no relationship with them. -

-

- 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. -

- -

5. Other data Agendula handles on your device

- -

Reminders and notifications

-

- 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. -

- -

Export files

-

- You can export your tasks as standard iCalendar .ics files. - Agendula writes exactly the file you select through Android's system file - picker, and has no access to other files. -

- -

App settings

-

- Your preferences (theme, language, list and reminder defaults and similar) - are stored locally on your device and are removed when you uninstall the - app. -

- -

6. Backups

-

- 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. -

- -

7. Crash reports

-

- 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. You see the full content, you decide - whether to submit it, and you can edit or discard it. -

-

Such a report contains:

-
    -
  • app version,
  • -
  • Android version,
  • -
  • device manufacturer and model,
  • -
  • your device language,
  • -
  • the timestamp,
  • -
  • and the technical stack trace.
  • -
-

- It is built from that fixed list and nothing else: no task - data, no server address or credentials, no{' '} - account names, no log files and no personal - identifiers. -

-

- If you choose to submit it, the report becomes a public issue on the - project's issue tracker at Codeberg, operated by Codeberg e. V. - Their privacy policy then applies to that submission. -

- -

8. External links

-

- 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. -

- -

9. Permissions and why they exist

-
    -
  • - INTERNET, ACCESS_NETWORK_STATE — CalDAV sync with - the server you configure, and checking whether a connection exists before - trying. Without a CalDAV account, no connection is made. -
  • -
  • - READ_SYNC_SETTINGS, WRITE_SYNC_SETTINGS — register - the sync account with Android's sync framework so it can be scheduled. -
  • -
  • POST_NOTIFICATIONS — show reminders.
  • -
  • - USE_EXACT_ALARM, SCHEDULE_EXACT_ALARM — deliver - reminders at the exact due time. -
  • -
  • - RECEIVE_BOOT_COMPLETED — re-register pending reminders after a - restart. -
  • -
  • - org.dmfs.permission.READ_TASKS /{' '} - WRITE_TASKS and org.tasks.permission.READ_TASKS /{' '} - WRITE_TASKS — optional, requested only if you choose the - external-provider storage mode, and only for the provider you selected - (OpenTasks or tasks.org). -
  • -
  • - WAKE_LOCK, FOREGROUND_SERVICE — required by the - Android system component used for scheduled background work - (WorkManager); on older Android versions it needs them to run an - expedited sync. -
  • -
-

- Agendula publishes no content provider of its own and declares no - permissions that other apps could request. -

- -

10. Distribution channels

-

- 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. -

- -

11. Children

-

- Agendula is not directed at children and collects nothing about anyone. -

- -

12. Deleting your data

-
    -
  • - Remove a CalDAV account (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. -
  • -
  • - Remove an account and delete its local data removes those - lists and their tasks as well. -
  • -
  • - Uninstalling the app removes everything Agendula stored on - the device. -
  • -
-

- Data on your CalDAV server is deleted on that server; data in an external - tasks provider is deleted in that app. -

- -

13. Your rights

-

- 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. 15–21 GDPR) could apply. Your tasks are exportable as standard{' '} - .ics 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. -

- -

14. Changes to this policy

-

- 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. -

+

{entry.data.title}

+
diff --git a/src/pages/calendula/privacy.astro b/src/pages/calendula/privacy.astro index 07969fd..2929bdd 100644 --- a/src/pages/calendula/privacy.astro +++ b/src/pages/calendula/privacy.astro @@ -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); ---
-

Privacy Policy — Calendula

- -

- Last updated: {lastUpdated}
- Applies to the Android app Calendula{' '} - (package de.jeanlucmakiola.calendula), all versions and all - distribution channels. -

- -

In short

-

- Calendula collects nothing, sends nothing, and has no user accounts. - It has no internet permission at all — 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. -

- -

1. Controller

-

- {LEGAL.business}
- {LEGAL.street}
- {LEGAL.city}
- Email: {LEGAL.email} -

- -

2. No data collection

-

- Calendula contains no analytics, no tracking, no advertising, no - crash-reporting SDK and no third-party services of any kind. No - user profile is created, no identifier is generated, and no data is shared - with or sold to anyone. -

-

- The app does not request the android.permission.INTERNET 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 source code. -

- -

3. Data Calendula accesses on your device

-

- All of the following is processed locally on your device only. - None of it is transmitted, and none of it is stored by the developer. -

- -

Calendar data

-

- 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 no database of - its own — your events live in the system calendar, exactly where - they lived before you installed Calendula, and they remain there if you - uninstall it. -

-

- 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. -

- -

Contacts (optional)

-

- 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. -

- -

Notifications

-

- Reminders are displayed as local notifications on your device. Nothing is - sent to a push service. -

- -

Files

-

- 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. -

- -

App settings

-

- Your preferences (view options, theme, reminder defaults and similar) are - stored locally on your device and are removed when you uninstall the app. -

- -

4. Crash reports — the only case where data can leave your device

-

- 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. You see the full content, you decide whether to submit - it, and you can edit or discard it. -

-

Such a report contains:

-
    -
  • app version,
  • -
  • Android version,
  • -
  • device manufacturer and model,
  • -
  • your device language,
  • -
  • the timestamp,
  • -
  • and the technical stack trace.
  • -
-

- It contains no event data, no contacts - and no personal identifiers. -

-

- If you choose to submit it, the report becomes a public issue on the - project's issue tracker at Codeberg, operated by Codeberg e. V. - Their privacy policy then applies to that submission. -

- -

5. External links

-

- 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. -

- -

6. Permissions and why they exist

-
    -
  • - READ_CALENDAR, WRITE_CALENDAR — display and - edit your events; the core function. -
  • -
  • POST_NOTIFICATIONS — show reminders.
  • -
  • - READ_CONTACTS — optional, only for the “Contact special - dates” feature. -
  • -
  • - USE_EXACT_ALARM, SCHEDULE_EXACT_ALARM — deliver - reminders at the exact time, including after snoozing. -
  • -
  • - RECEIVE_BOOT_COMPLETED — re-register pending reminders after - a restart. -
  • -
  • - REQUEST_IGNORE_BATTERY_OPTIMIZATIONS — only to open the - system dialog for the “Reliable delivery” setting. -
  • -
  • - WAKE_LOCK, FOREGROUND_SERVICE, ACCESS_NETWORK_STATE{' '} - — required by the Android system component used for scheduled background - work (WorkManager). -
  • -
-

- ACCESS_NETWORK_STATE allows reading whether a network - connection exists — it does not permit using one. - Without INTERNET, no connection is possible. -

- -

7. Distribution channels

-

- 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. -

- -

8. Children

-

- Calendula is suitable for all ages. Since it collects no data at all, no - data of children is processed either. -

- -

9. Your rights

-

- 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. 15–21 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. -

- -

10. Changes to this policy

-

- 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. -

+

{entry.data.title}

+
diff --git a/src/utils/policy.ts b/src/utils/policy.ts new file mode 100644 index 0000000..a4251bd --- /dev/null +++ b/src/utils/policy.ts @@ -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]; +}