Compare commits
8
Commits
bf40e27820
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddc2c22a30 | ||
|
|
5056a03a05 | ||
|
|
8a1eb54a45 | ||
|
|
ff3c0fa437 | ||
|
|
6311baccb7 | ||
|
|
92472937cc | ||
|
|
0fe0eaf2bd | ||
|
|
d18692b693 |
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 e. 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. 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.
|
|
||||||
</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
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user