@meith/plugin-calendar 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jordan Harrison and the Meith contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @meith/plugin-calendar
2
+
3
+ A shared calendar: events the community can see, linked to the threads that
4
+ discuss them.
5
+
6
+ ## What it adds
7
+
8
+ - **A calendar page** at `/plugins/calendar`, and a navigation item: an
9
+ agenda grouped by month, each event with its date block, when it is, how
10
+ soon ("in 4 days", "next week"), and where. Readable by anyone who can
11
+ read the board, with a **Past** view for what has already happened.
12
+ - **Events linked to threads.** An event can name a thread — paste the link
13
+ or the id — and the calendar links to the discussion.
14
+ - **The event, shown in its thread.** A card above the first post says what
15
+ is scheduled and when, so somebody who arrives at the discussion sees the
16
+ event without going looking for it.
17
+ - **A link, in the organiser's own words.** An event can carry one address
18
+ and the text to show for it — *Join online* for a video call, *Get
19
+ tickets* for a Meetup or GDG page. Only `http://` and `https://` are
20
+ accepted; anything else is refused rather than rendered, and the anchor
21
+ carries `nofollow ugc noopener noreferrer` like every other member-supplied
22
+ link on the board.
23
+ - **An organiser roster**, under Admin → Plugins → Calendar.
24
+ - **Add to your calendar** on every event — a `.ics` file the reader's own
25
+ calendar app understands. An event with no end is given an hour, and the
26
+ thread link travels with it as the event URL when the board knows its own
27
+ address.
28
+
29
+ ## Who may add an event
30
+
31
+ By default, only the members on the organiser roster. An administrator adds
32
+ them by username. Turning on **Any member may add an event** opens it to
33
+ every signed-in member; guests never may.
34
+
35
+ Removing an event is allowed to whoever added it, and to any organiser.
36
+
37
+ This is a roster rather than a usergroup on purpose. `@meith/plugin-kit`
38
+ gives a plugin no way to read a member's groups, and the board's own guard
39
+ says why: group membership is the Authorizer's business, and a plugin never
40
+ gets an `Actor` to ask with. A roster the plugin owns keeps the decision
41
+ inside the plugin's own surface, where it belongs.
42
+
43
+ In the downloaded `.ics`, the event's own link becomes the calendar entry's
44
+ `URL` — it is the one a reader wants to act on from their calendar app — and
45
+ the thread moves to the description. An event with no link of its own keeps
46
+ the thread as its `URL`, as before.
47
+
48
+ ## What it stores
49
+
50
+ Two tables in its own namespace: `plugin_calendar_event` and
51
+ `plugin_calendar_organiser`. The link and its text are two columns added by
52
+ a second migration rather than folded into the first, because the first has
53
+ already been applied wherever the plugin runs and a migration that has run
54
+ is never edited. The thread id is a plain column, not a foreign
55
+ key — a plugin's schema may not reference the board's, so an event whose
56
+ thread has since been deleted simply links to a thread that is not there,
57
+ rather than blocking the deletion.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@meith/plugin-calendar",
3
+ "version": "0.23.0",
4
+ "description": "A shared calendar: events the community can see, linked to the threads that discuss them.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/meith-dev/meith.git",
9
+ "directory": "plugins/calendar"
10
+ },
11
+ "type": "module",
12
+ "main": "./src/index.ts",
13
+ "types": "./src/index.ts",
14
+ "files": [
15
+ "src",
16
+ "!src/**/*.test.*",
17
+ "!src/**/*.type-test.*"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dependencies": {
23
+ "@meith/plugin-kit": "^0.23.0"
24
+ },
25
+ "peerDependencies": {
26
+ "react": "^19.2.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "^19.2.18"
30
+ }
31
+ }
package/src/access.ts ADDED
@@ -0,0 +1,32 @@
1
+ export type AddVerdict = 'allowed' | 'guest' | 'not-an-organiser'
2
+
3
+ export interface CalendarConfig {
4
+ readonly anyMemberMayAdd: boolean
5
+ }
6
+
7
+ export function resolveCalendarConfig(
8
+ settings: Readonly<Record<string, string | number | boolean>>,
9
+ ): CalendarConfig {
10
+ return { anyMemberMayAdd: settings.any_member_may_add === true }
11
+ }
12
+
13
+ export function mayAdd(input: {
14
+ readonly userId: number | null
15
+ readonly config: CalendarConfig
16
+ readonly organisers: readonly number[]
17
+ }): AddVerdict {
18
+ if (input.userId === null) return 'guest'
19
+ if (input.config.anyMemberMayAdd) return 'allowed'
20
+ return input.organisers.includes(input.userId) ? 'allowed' : 'not-an-organiser'
21
+ }
22
+
23
+ export function mayDelete(input: {
24
+ readonly userId: number | null
25
+ readonly createdByUserId: number | null
26
+ readonly config: CalendarConfig
27
+ readonly organisers: readonly number[]
28
+ }): boolean {
29
+ if (input.userId === null) return false
30
+ if (input.createdByUserId === input.userId) return true
31
+ return input.organisers.includes(input.userId)
32
+ }
@@ -0,0 +1,98 @@
1
+ import { definePlugin } from '@meith/plugin-kit'
2
+
3
+ import {
4
+ handleAddOrganiser,
5
+ handleCreateEvent,
6
+ handleEventIcs,
7
+ handleRemoveOrganiser,
8
+ } from './handlers'
9
+ import en from './messages/en.json'
10
+ import { CALENDAR_MIGRATIONS } from './schema'
11
+ import { OrganisersPage } from './ui/admin'
12
+ import { CalendarPage } from './ui/page'
13
+ import { ThreadEventCard } from './ui/thread-card'
14
+
15
+ export const ADD_RATE_LIMIT = { limit: 20, windowSeconds: 3600 }
16
+
17
+ export const calendarPlugin = definePlugin({
18
+ key: 'calendar',
19
+ name: en['calendar.definition.name'],
20
+ nameKey: 'calendar.definition.name',
21
+ version: '0.22.0',
22
+ description: en['calendar.definition.description'],
23
+ descriptionKey: 'calendar.definition.description',
24
+ apiVersion: '0',
25
+
26
+ settings: [
27
+ {
28
+ key: 'any_member_may_add',
29
+ label: en['calendar.setting.anyMember.label'],
30
+ labelKey: 'calendar.setting.anyMember.label',
31
+ description: en['calendar.setting.anyMember.description'],
32
+ descriptionKey: 'calendar.setting.anyMember.description',
33
+ type: 'boolean',
34
+ default: false,
35
+ },
36
+ ],
37
+
38
+ migrations: CALENDAR_MIGRATIONS,
39
+
40
+ pages: [
41
+ {
42
+ path: '',
43
+ title: en['calendar.page.title'],
44
+ titleKey: 'calendar.page.title',
45
+ access: 'anonymous',
46
+ render: CalendarPage,
47
+ },
48
+ ],
49
+
50
+ navigation: [
51
+ {
52
+ key: 'calendar',
53
+ label: en['calendar.nav.label'],
54
+ labelKey: 'calendar.nav.label',
55
+ path: '',
56
+ audience: 'all',
57
+ },
58
+ ],
59
+
60
+ adminPages: [
61
+ {
62
+ path: 'organisers',
63
+ title: en['calendar.admin.organisers.title'],
64
+ titleKey: 'calendar.admin.organisers.title',
65
+ render: OrganisersPage,
66
+ },
67
+ ],
68
+
69
+ routes: [
70
+ {
71
+ path: 'events',
72
+ method: 'POST',
73
+ access: 'member',
74
+ rateLimit: ADD_RATE_LIMIT,
75
+ handler: handleCreateEvent,
76
+ },
77
+ {
78
+ path: 'events/ics',
79
+ method: 'GET',
80
+ access: 'anonymous',
81
+ handler: handleEventIcs,
82
+ },
83
+ {
84
+ path: 'organisers/add',
85
+ method: 'POST',
86
+ access: 'admin',
87
+ handler: handleAddOrganiser,
88
+ },
89
+ {
90
+ path: 'organisers/remove',
91
+ method: 'POST',
92
+ access: 'admin',
93
+ handler: handleRemoveOrganiser,
94
+ },
95
+ ],
96
+
97
+ contributions: [{ region: 'thread.header', render: ThreadEventCard }],
98
+ })
package/src/events.ts ADDED
@@ -0,0 +1,249 @@
1
+ export const MAX_TITLE = 120
2
+
3
+ export const MAX_LOCATION = 120
4
+
5
+ export const MAX_LINK_LABEL = 40
6
+
7
+ export const MAX_LINK_URL = 500
8
+
9
+ export const MAX_DURATION_HOURS = 24 * 14
10
+
11
+ export interface EventDraft {
12
+ readonly title: string
13
+ readonly startsAt: Date
14
+ readonly endsAt: Date | null
15
+ readonly location: string
16
+ readonly threadId: number | null
17
+ readonly linkUrl: string
18
+ readonly linkLabel: string
19
+ }
20
+
21
+ export type DraftProblem =
22
+ | 'title-missing'
23
+ | 'title-too-long'
24
+ | 'location-too-long'
25
+ | 'starts-missing'
26
+ | 'ends-before-starts'
27
+ | 'too-long'
28
+ | 'link-not-a-url'
29
+ | 'link-too-long'
30
+ | 'link-label-too-long'
31
+ | 'link-label-without-link'
32
+
33
+ export interface CalendarEvent {
34
+ readonly id: string
35
+ readonly title: string
36
+ readonly startsAt: Date
37
+ readonly endsAt: Date | null
38
+ readonly location: string
39
+ readonly threadId: number | null
40
+ readonly createdByUserId: number | null
41
+ readonly linkUrl: string
42
+ readonly linkLabel: string
43
+ }
44
+
45
+ export const DEFAULT_LINK_LABEL = 'calendar.event.linkFallback'
46
+
47
+ export function safeLinkUrl(raw: string): string | null {
48
+ const trimmed = raw.trim()
49
+ if (trimmed === '' || trimmed.length > MAX_LINK_URL) return null
50
+
51
+ let parsed: URL
52
+ try {
53
+ parsed = new URL(trimmed)
54
+ } catch {
55
+ return null
56
+ }
57
+
58
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null
59
+ if (parsed.hostname === '') return null
60
+
61
+ return parsed.toString()
62
+ }
63
+
64
+ function parseDate(raw: string): Date | null {
65
+ const trimmed = raw.trim()
66
+ if (trimmed === '') return null
67
+
68
+ const parsed = new Date(trimmed)
69
+ return Number.isNaN(parsed.getTime()) ? null : parsed
70
+ }
71
+
72
+ export function parseThreadRef(raw: string): number | null {
73
+ const trimmed = raw.trim()
74
+ if (trimmed === '') return null
75
+
76
+ const fromUrl = /\/threads?\/(\d+)/.exec(trimmed)
77
+ const digits = fromUrl?.[1] ?? (/^\d+$/.test(trimmed) ? trimmed : null)
78
+ if (digits === null) return null
79
+
80
+ const id = Number(digits)
81
+ return Number.isSafeInteger(id) && id > 0 ? id : null
82
+ }
83
+
84
+ export function readDraft(form: Readonly<Record<string, string>>): {
85
+ readonly draft: EventDraft | null
86
+ readonly problems: readonly DraftProblem[]
87
+ } {
88
+ const problems: DraftProblem[] = []
89
+
90
+ const title = (form.title ?? '').trim()
91
+ if (title === '') problems.push('title-missing')
92
+ else if (title.length > MAX_TITLE) problems.push('title-too-long')
93
+
94
+ const location = (form.location ?? '').trim()
95
+ if (location.length > MAX_LOCATION) problems.push('location-too-long')
96
+
97
+ const startsAt = parseDate(form.starts_at ?? '')
98
+ if (startsAt === null) problems.push('starts-missing')
99
+
100
+ const endsAt = parseDate(form.ends_at ?? '')
101
+ if (startsAt !== null && endsAt !== null) {
102
+ if (endsAt.getTime() <= startsAt.getTime()) problems.push('ends-before-starts')
103
+ else if (endsAt.getTime() - startsAt.getTime() > MAX_DURATION_HOURS * 3_600_000) {
104
+ problems.push('too-long')
105
+ }
106
+ }
107
+
108
+ const rawLink = (form.link ?? '').trim()
109
+ const linkLabel = (form.link_text ?? '').trim()
110
+ const linkUrl = rawLink === '' ? '' : (safeLinkUrl(rawLink) ?? '')
111
+
112
+ if (rawLink !== '' && linkUrl === '') {
113
+ problems.push(rawLink.length > MAX_LINK_URL ? 'link-too-long' : 'link-not-a-url')
114
+ }
115
+ if (linkLabel.length > MAX_LINK_LABEL) problems.push('link-label-too-long')
116
+ if (linkLabel !== '' && rawLink === '') problems.push('link-label-without-link')
117
+
118
+ if (problems.length > 0 || startsAt === null) return { draft: null, problems }
119
+
120
+ return {
121
+ draft: {
122
+ title,
123
+ startsAt,
124
+ endsAt,
125
+ location,
126
+ threadId: parseThreadRef(form.thread ?? ''),
127
+ linkUrl,
128
+ linkLabel,
129
+ },
130
+ problems: [],
131
+ }
132
+ }
133
+
134
+ export const DEFAULT_LOCALE = 'en-GB'
135
+
136
+ const DATE_AND_TIME: Intl.DateTimeFormatOptions = {
137
+ dateStyle: 'medium',
138
+ timeStyle: 'short',
139
+ timeZone: 'UTC',
140
+ }
141
+
142
+ const TIME_ONLY: Intl.DateTimeFormatOptions = { timeStyle: 'short', timeZone: 'UTC' }
143
+
144
+ function sameUtcDay(a: Date, b: Date): boolean {
145
+ return a.toISOString().slice(0, 10) === b.toISOString().slice(0, 10)
146
+ }
147
+
148
+ export function formatRange(
149
+ startsAt: Date,
150
+ endsAt: Date | null,
151
+ locale: string = DEFAULT_LOCALE,
152
+ ): string {
153
+ const start = new Intl.DateTimeFormat(locale, DATE_AND_TIME).format(startsAt)
154
+ if (endsAt === null) return start
155
+
156
+ const end = sameUtcDay(startsAt, endsAt)
157
+ ? new Intl.DateTimeFormat(locale, TIME_ONLY).format(endsAt)
158
+ : new Intl.DateTimeFormat(locale, DATE_AND_TIME).format(endsAt)
159
+
160
+ return `${start} — ${end}`
161
+ }
162
+
163
+ export interface DayParts {
164
+ readonly day: string
165
+ readonly weekday: string
166
+ }
167
+
168
+ export function dayParts(date: Date, locale: string = DEFAULT_LOCALE): DayParts {
169
+ return {
170
+ day: new Intl.DateTimeFormat(locale, { day: '2-digit', timeZone: 'UTC' }).format(date),
171
+ weekday: new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' })
172
+ .format(date)
173
+ .toUpperCase(),
174
+ }
175
+ }
176
+
177
+ export function monthKey(date: Date): string {
178
+ return date.toISOString().slice(0, 7)
179
+ }
180
+
181
+ export function monthLabel(date: Date, locale: string = DEFAULT_LOCALE): string {
182
+ return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' })
183
+ .format(date)
184
+ .toUpperCase()
185
+ }
186
+
187
+ export interface EventMonth {
188
+ readonly key: string
189
+ readonly label: string
190
+ readonly events: readonly CalendarEvent[]
191
+ }
192
+
193
+ export function groupByMonth(
194
+ events: readonly CalendarEvent[],
195
+ locale: string = DEFAULT_LOCALE,
196
+ ): readonly EventMonth[] {
197
+ const months: EventMonth[] = []
198
+
199
+ for (const event of events) {
200
+ const key = monthKey(event.startsAt)
201
+ const last = months.at(-1)
202
+
203
+ if (last !== undefined && last.key === key) {
204
+ months[months.length - 1] = { ...last, events: [...last.events, event] }
205
+ continue
206
+ }
207
+ months.push({ key, label: monthLabel(event.startsAt, locale), events: [event] })
208
+ }
209
+
210
+ return months
211
+ }
212
+
213
+ function wholeDaysBetween(from: Date, to: Date): number {
214
+ const day = 86_400_000
215
+ const startOf = (date: Date) =>
216
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
217
+ return Math.round((startOf(to) - startOf(from)) / day)
218
+ }
219
+
220
+ export function relativeHint(startsAt: Date, now: Date, locale: string = DEFAULT_LOCALE): string {
221
+ const days = wholeDaysBetween(now, startsAt)
222
+ const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
223
+
224
+ if (Math.abs(days) < 7) return format.format(days, 'day')
225
+ if (Math.abs(days) < 31) return format.format(Math.trunc(days / 7), 'week')
226
+ return format.format(Math.trunc(days / 30), 'month')
227
+ }
228
+
229
+ export function isUpcoming(event: CalendarEvent, now: Date): boolean {
230
+ const finishes = event.endsAt ?? event.startsAt
231
+ return finishes.getTime() >= now.getTime()
232
+ }
233
+
234
+ export function pickThreadEvent(events: readonly CalendarEvent[], now: Date): CalendarEvent | null {
235
+ if (events.length === 0) return null
236
+
237
+ const upcoming = events.filter((event) => isUpcoming(event, now)).sort(byStart)
238
+ if (upcoming.length > 0) return upcoming[0] ?? null
239
+
240
+ return [...events].sort(byStart).at(-1) ?? null
241
+ }
242
+
243
+ export function byStart(a: CalendarEvent, b: CalendarEvent): number {
244
+ return a.startsAt.getTime() - b.startsAt.getTime()
245
+ }
246
+
247
+ export function eventHref(event: CalendarEvent): string | null {
248
+ return event.threadId === null ? null : `/thread/${event.threadId}`
249
+ }
@@ -0,0 +1,82 @@
1
+ import type { PluginRequest, PluginResponse, PluginRuntimeContext } from '@meith/plugin-kit'
2
+
3
+ import { mayAdd, resolveCalendarConfig } from './access'
4
+ import { readDraft } from './events'
5
+ import { ICS_CONTENT_TYPE, toIcs } from './ics'
6
+ import { addOrganiser, createEvent, eventById, organiserIds, removeOrganiser } from './store'
7
+
8
+ export const CALENDAR_PATH = '/plugins/calendar'
9
+
10
+ function seeCalendar(): PluginResponse {
11
+ return { kind: 'redirect', to: CALENDAR_PATH }
12
+ }
13
+
14
+ function refused(status: number, message: string): PluginResponse {
15
+ return { kind: 'json', status, body: { error: message } }
16
+ }
17
+
18
+ export async function handleCreateEvent(
19
+ request: PluginRequest,
20
+ context: PluginRuntimeContext,
21
+ ): Promise<PluginResponse> {
22
+ const form = request.form
23
+ if (form === null) return refused(400, 'form-required')
24
+
25
+ const config = resolveCalendarConfig(context.settings)
26
+ const verdict = mayAdd({
27
+ userId: request.viewer.userId,
28
+ config,
29
+ organisers: await organiserIds(context.data),
30
+ })
31
+
32
+ if (verdict !== 'allowed') return refused(403, verdict)
33
+
34
+ const { draft, problems } = readDraft(form)
35
+ if (draft === null) return refused(400, problems.join(','))
36
+
37
+ await createEvent(context.data, draft, request.viewer.userId)
38
+ return seeCalendar()
39
+ }
40
+
41
+ export async function handleEventIcs(
42
+ request: PluginRequest,
43
+ context: PluginRuntimeContext,
44
+ ): Promise<PluginResponse> {
45
+ const id = request.query.id?.trim() ?? ''
46
+ if (!/^\d+$/.test(id)) return refused(400, 'id-required')
47
+
48
+ const event = await eventById(context.data, id)
49
+ if (event === null) return refused(404, 'no-such-event')
50
+
51
+ return {
52
+ kind: 'text',
53
+ body: toIcs(event, request.boardUrl, new Date()),
54
+ contentType: ICS_CONTENT_TYPE,
55
+ }
56
+ }
57
+
58
+ export async function handleAddOrganiser(
59
+ request: PluginRequest,
60
+ context: PluginRuntimeContext,
61
+ ): Promise<PluginResponse> {
62
+ const username = request.form?.username?.trim() ?? ''
63
+ if (username === '') return refused(400, 'username-required')
64
+
65
+ const member = await context.users.byUsername(username)
66
+ if (member === null) return refused(404, 'unknown-member')
67
+
68
+ await addOrganiser(context.data, member.userId, request.viewer.userId)
69
+ return { kind: 'redirect', to: '/admin/plugins/calendar/organisers' }
70
+ }
71
+
72
+ export async function handleRemoveOrganiser(
73
+ request: PluginRequest,
74
+ context: PluginRuntimeContext,
75
+ ): Promise<PluginResponse> {
76
+ const raw = request.form?.user_id?.trim() ?? ''
77
+ const userId = Number(raw)
78
+ if (!Number.isSafeInteger(userId) || userId <= 0) return refused(400, 'user-id-required')
79
+
80
+ await removeOrganiser(context.data, userId)
81
+ return { kind: 'redirect', to: '/admin/plugins/calendar/organisers' }
82
+ }
package/src/ics.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { type CalendarEvent, eventHref } from './events'
2
+
3
+ export const ICS_CONTENT_TYPE = `text/calendar; charset=utf-8`
4
+
5
+ export const DEFAULT_DURATION_MINUTES = 60
6
+
7
+ const PRODID = `PRODID:-//Meith//Calendar//EN`
8
+
9
+ function stamp(date: Date): string {
10
+ return `${date.toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`
11
+ }
12
+
13
+ function escapeText(value: string): string {
14
+ return value
15
+ .replace(/\\/g, '\\\\')
16
+ .replace(/;/g, '\\;')
17
+ .replace(/,/g, '\\,')
18
+ .replace(/\r?\n/g, '\\n')
19
+ }
20
+
21
+ export function fold(line: string): string {
22
+ if (line.length <= 75) return line
23
+
24
+ const parts: string[] = [line.slice(0, 75)]
25
+ let rest = line.slice(75)
26
+ while (rest.length > 74) {
27
+ parts.push(` ${rest.slice(0, 74)}`)
28
+ rest = rest.slice(74)
29
+ }
30
+ if (rest.length > 0) parts.push(` ${rest}`)
31
+ return parts.join('\r\n')
32
+ }
33
+
34
+ export function icsFileName(event: CalendarEvent): string {
35
+ const slug = event.title
36
+ .toLowerCase()
37
+ .replace(/[^a-z0-9]+/g, '-')
38
+ .replace(/^-|-$/g, '')
39
+ .slice(0, 60)
40
+ return `${slug === '' ? 'event' : slug}.ics`
41
+ }
42
+
43
+ export function absoluteBase(boardUrl: string): string | null {
44
+ const trimmed = boardUrl.trim().replace(/\/+$/, '')
45
+ if (trimmed === '') return null
46
+
47
+ try {
48
+ const parsed = new URL(trimmed)
49
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? trimmed : null
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ export function toIcs(event: CalendarEvent, boardUrl: string, now: Date): string {
56
+ const ends =
57
+ event.endsAt ?? new Date(event.startsAt.getTime() + DEFAULT_DURATION_MINUTES * 60_000)
58
+
59
+ const base = absoluteBase(boardUrl)
60
+ const host = base === null ? 'meith' : new URL(base).host
61
+
62
+ const href = eventHref(event)
63
+ const threadUrl = href === null || base === null ? null : `${base}${href}`
64
+
65
+ const hasOwnLink = event.linkUrl !== ''
66
+ const actionUrl = hasOwnLink ? event.linkUrl : threadUrl
67
+ const describedUrl = hasOwnLink ? threadUrl : null
68
+
69
+ const lines = [
70
+ 'BEGIN:VCALENDAR',
71
+ 'VERSION:2.0',
72
+ PRODID,
73
+ 'CALSCALE:GREGORIAN',
74
+ 'METHOD:PUBLISH',
75
+ 'BEGIN:VEVENT',
76
+ `UID:calendar-${event.id}@${host}`,
77
+ `DTSTAMP:${stamp(now)}`,
78
+ `DTSTART:${stamp(event.startsAt)}`,
79
+ `DTEND:${stamp(ends)}`,
80
+ `SUMMARY:${escapeText(event.title)}`,
81
+ ...(event.location === '' ? [] : [`LOCATION:${escapeText(event.location)}`]),
82
+ ...(actionUrl === null ? [] : [`URL:${escapeText(actionUrl)}`]),
83
+ ...(describedUrl === null ? [] : [`DESCRIPTION:${escapeText(describedUrl)}`]),
84
+ 'END:VEVENT',
85
+ 'END:VCALENDAR',
86
+ ]
87
+
88
+ return `${lines.map(fold).join('\r\n')}\r\n`
89
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { calendarPlugin, calendarPlugin as plugin } from './definition'
2
+ export type { CalendarEvent, EventDraft } from './events'
3
+ export { calendarMessages, calendarMessages as messages } from './messages'
4
+ export { addOrganiser, createEvent } from './store'
@@ -0,0 +1,32 @@
1
+ {
2
+ "calendar.admin.organisers.add": "Add organiser",
3
+ "calendar.admin.organisers.empty": "Nobody is an organiser yet, so only the setting above decides who may add events.",
4
+ "calendar.admin.organisers.remove": "Remove",
5
+ "calendar.admin.organisers.title": "Organisers",
6
+ "calendar.admin.organisers.username": "Username",
7
+ "calendar.definition.description": "A shared calendar: events the community can see, linked to the threads that discuss them.",
8
+ "calendar.definition.name": "Calendar",
9
+ "calendar.error.notAnOrganiser": "Only an organiser may add an event to this calendar.",
10
+ "calendar.event.add": "Add event",
11
+ "calendar.event.discuss": "Discussion",
12
+ "calendar.event.download": "Add to your calendar",
13
+ "calendar.event.link": "Link",
14
+ "calendar.event.linkFallback": "Open link",
15
+ "calendar.event.linkHint": "An https:// address people should open — a video call, a ticket page.",
16
+ "calendar.event.linkText": "Link text",
17
+ "calendar.event.linkTextHint": "What the link should say — “Join online”, “Get tickets”.",
18
+ "calendar.event.location": "Location",
19
+ "calendar.event.starts": "Starts",
20
+ "calendar.event.thread": "Thread link or id",
21
+ "calendar.event.title": "Title",
22
+ "calendar.event.until": "Until",
23
+ "calendar.nav.label": "Calendar",
24
+ "calendar.page.empty": "Nothing is scheduled yet.",
25
+ "calendar.page.emptyPast": "Nothing has happened yet.",
26
+ "calendar.page.past": "Past",
27
+ "calendar.page.title": "Calendar",
28
+ "calendar.page.upcoming": "Upcoming",
29
+ "calendar.setting.anyMember.description": "When off, only the organisers named under Admin → Plugins → Calendar may add events.",
30
+ "calendar.setting.anyMember.label": "Any member may add an event",
31
+ "calendar.thread.card": "This thread discusses an event."
32
+ }
@@ -0,0 +1,3 @@
1
+ import en from './en.json'
2
+
3
+ export const calendarMessages = { en }
package/src/schema.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { PluginMigration } from '@meith/plugin-kit'
2
+
3
+ export const CALENDAR_MIGRATIONS: readonly PluginMigration[] = [
4
+ {
5
+ id: '0001_events_and_organisers',
6
+ statements: [
7
+ `create table if not exists plugin_calendar_event (
8
+ id bigint generated by default as identity primary key,
9
+ title text not null,
10
+ starts_at timestamptz not null,
11
+ ends_at timestamptz,
12
+ location text not null default '',
13
+ thread_id integer,
14
+ created_by_user_id integer,
15
+ created_at timestamptz not null default now()
16
+ )`,
17
+ `create index if not exists plugin_calendar_event_starts
18
+ on plugin_calendar_event (starts_at)`,
19
+ `create index if not exists plugin_calendar_event_thread
20
+ on plugin_calendar_event (thread_id)
21
+ where thread_id is not null`,
22
+ `create table if not exists plugin_calendar_organiser (
23
+ user_id integer not null primary key,
24
+ added_by_user_id integer,
25
+ added_at timestamptz not null default now()
26
+ )`,
27
+ ],
28
+ },
29
+ {
30
+ id: '0002_event_link',
31
+ statements: [
32
+ `alter table plugin_calendar_event
33
+ add column if not exists link_url text not null default ''`,
34
+ `alter table plugin_calendar_event
35
+ add column if not exists link_label text not null default ''`,
36
+ ],
37
+ },
38
+ ]
package/src/store.ts ADDED
@@ -0,0 +1,134 @@
1
+ import type { PluginData } from '@meith/plugin-kit'
2
+
3
+ import type { CalendarEvent, EventDraft } from './events'
4
+
5
+ interface EventRow extends Record<string, unknown> {
6
+ readonly id: string | number
7
+ readonly title: string
8
+ readonly starts_at: Date | string
9
+ readonly ends_at: Date | string | null
10
+ readonly location: string
11
+ readonly thread_id: number | null
12
+ readonly created_by_user_id: number | null
13
+ readonly link_url: string | null
14
+ readonly link_label: string | null
15
+ }
16
+
17
+ function toEvent(row: EventRow): CalendarEvent {
18
+ return {
19
+ id: String(row.id),
20
+ title: row.title,
21
+ startsAt: new Date(row.starts_at),
22
+ endsAt: row.ends_at === null ? null : new Date(row.ends_at),
23
+ location: row.location,
24
+ threadId: row.thread_id,
25
+ createdByUserId: row.created_by_user_id,
26
+ linkUrl: row.link_url ?? '',
27
+ linkLabel: row.link_label ?? '',
28
+ }
29
+ }
30
+
31
+ const COLUMNS = `id, title, starts_at, ends_at, location, thread_id, created_by_user_id,
32
+ link_url, link_label`
33
+
34
+ export async function createEvent(
35
+ data: PluginData,
36
+ draft: EventDraft,
37
+ createdByUserId: number | null,
38
+ ): Promise<void> {
39
+ await data.query(
40
+ `insert into plugin_calendar_event
41
+ (title, starts_at, ends_at, location, thread_id, created_by_user_id, link_url, link_label)
42
+ values ($1, $2, $3, $4, $5, $6, $7, $8)`,
43
+ [
44
+ draft.title,
45
+ draft.startsAt,
46
+ draft.endsAt,
47
+ draft.location,
48
+ draft.threadId,
49
+ createdByUserId,
50
+ draft.linkUrl,
51
+ draft.linkLabel,
52
+ ],
53
+ )
54
+ }
55
+
56
+ export async function upcomingEvents(
57
+ data: PluginData,
58
+ limit: number,
59
+ ): Promise<readonly CalendarEvent[]> {
60
+ const rows = await data.query<EventRow>(
61
+ `select ${COLUMNS} from plugin_calendar_event
62
+ where coalesce(ends_at, starts_at) >= now()
63
+ order by starts_at
64
+ limit $1`,
65
+ [limit],
66
+ )
67
+ return rows.map(toEvent)
68
+ }
69
+
70
+ export async function pastEvents(
71
+ data: PluginData,
72
+ limit: number,
73
+ ): Promise<readonly CalendarEvent[]> {
74
+ const rows = await data.query<EventRow>(
75
+ `select ${COLUMNS} from plugin_calendar_event
76
+ where coalesce(ends_at, starts_at) < now()
77
+ order by starts_at desc
78
+ limit $1`,
79
+ [limit],
80
+ )
81
+ return rows.map(toEvent)
82
+ }
83
+
84
+ export async function eventById(data: PluginData, id: string): Promise<CalendarEvent | null> {
85
+ const row = await data.one<EventRow>(
86
+ `select ${COLUMNS} from plugin_calendar_event where id = $1`,
87
+ [id],
88
+ )
89
+ return row === null ? null : toEvent(row)
90
+ }
91
+
92
+ export const THREAD_EVENT_SCAN = 20
93
+
94
+ export async function eventsForThread(
95
+ data: PluginData,
96
+ threadId: number,
97
+ ): Promise<readonly CalendarEvent[]> {
98
+ const rows = await data.query<EventRow>(
99
+ `select ${COLUMNS} from plugin_calendar_event
100
+ where thread_id = $1
101
+ order by starts_at desc
102
+ limit $2`,
103
+ [threadId, THREAD_EVENT_SCAN],
104
+ )
105
+ return rows.map(toEvent)
106
+ }
107
+
108
+ export async function deleteEvent(data: PluginData, id: string): Promise<void> {
109
+ await data.query(`delete from plugin_calendar_event where id = $1`, [id])
110
+ }
111
+
112
+ export async function organiserIds(data: PluginData): Promise<readonly number[]> {
113
+ const rows = await data.query<{ user_id: number }>(
114
+ `select user_id from plugin_calendar_organiser order by added_at`,
115
+ )
116
+ return rows.map((row) => Number(row.user_id))
117
+ }
118
+
119
+ export async function addOrganiser(
120
+ data: PluginData,
121
+ userId: number,
122
+ addedByUserId: number | null,
123
+ ): Promise<void> {
124
+ await data.query(
125
+ `insert into plugin_calendar_organiser (user_id, added_by_user_id)
126
+ values ($1, $2)
127
+ on conflict (user_id) do nothing`,
128
+ [userId, addedByUserId],
129
+ )
130
+ }
131
+
132
+ export async function removeOrganiser(data: PluginData, userId: number): Promise<void> {
133
+ await data.query(`delete from plugin_calendar_organiser where user_id = $1`, [userId])
134
+ }
@@ -0,0 +1,57 @@
1
+ import type { PluginAdminPageContext } from '@meith/plugin-kit'
2
+
3
+ import en from '../messages/en.json'
4
+ import { organiserIds } from '../store'
5
+
6
+ function translated(context: PluginAdminPageContext, key: keyof typeof en): string {
7
+ return context.t.has(key) ? context.t.t(key) : en[key]
8
+ }
9
+
10
+ export async function OrganisersPage(context: PluginAdminPageContext) {
11
+ const ids = await organiserIds(context.data).catch(() => [] as readonly number[])
12
+
13
+ const named = await Promise.all(
14
+ ids.map(async (userId) => ({
15
+ userId,
16
+ username: (await context.users.byId(userId).catch(() => null))?.username ?? String(userId),
17
+ })),
18
+ )
19
+
20
+ return (
21
+ <div className="flex flex-col gap-4 text-sm">
22
+ {named.length === 0 ? (
23
+ <p className="text-muted-foreground">
24
+ {translated(context, 'calendar.admin.organisers.empty')}
25
+ </p>
26
+ ) : (
27
+ <ul className="flex flex-col gap-1">
28
+ {named.map((organiser) => (
29
+ <li key={organiser.userId} className="flex items-center justify-between gap-3">
30
+ <span>{organiser.username}</span>
31
+ <form method="post" action="/admin/api/plugins/calendar/organisers/remove">
32
+ <input type="hidden" name="user_id" value={organiser.userId} />
33
+ <button type="submit" className="rounded border px-2 py-0.5 text-xs">
34
+ {translated(context, 'calendar.admin.organisers.remove')}
35
+ </button>
36
+ </form>
37
+ </li>
38
+ ))}
39
+ </ul>
40
+ )}
41
+
42
+ <form
43
+ method="post"
44
+ action="/admin/api/plugins/calendar/organisers/add"
45
+ className="flex items-end gap-2"
46
+ >
47
+ <label className="flex flex-col gap-1">
48
+ {translated(context, 'calendar.admin.organisers.username')}
49
+ <input name="username" required className="rounded border p-1" />
50
+ </label>
51
+ <button type="submit" className="rounded border px-3 py-1">
52
+ {translated(context, 'calendar.admin.organisers.add')}
53
+ </button>
54
+ </form>
55
+ </div>
56
+ )
57
+ }
@@ -0,0 +1,20 @@
1
+ import type { CalendarEvent } from '../events'
2
+
3
+ export const EXTERNAL_REL = 'nofollow ugc noopener noreferrer'
4
+
5
+ export function EventLink({ event, label }: { event: CalendarEvent; label: string }) {
6
+ if (event.linkUrl === '') return null
7
+
8
+ return (
9
+ <p className="text-sm">
10
+ <a
11
+ className="underline underline-offset-2"
12
+ href={event.linkUrl}
13
+ rel={EXTERNAL_REL}
14
+ target="_blank"
15
+ >
16
+ {event.linkLabel === '' ? label : event.linkLabel}
17
+ </a>
18
+ </p>
19
+ )
20
+ }
@@ -0,0 +1,221 @@
1
+ import type { PluginPageContext } from '@meith/plugin-kit'
2
+
3
+ import { mayAdd, resolveCalendarConfig } from '../access'
4
+ import {
5
+ type CalendarEvent,
6
+ dayParts,
7
+ eventHref,
8
+ formatRange,
9
+ groupByMonth,
10
+ relativeHint,
11
+ } from '../events'
12
+ import en from '../messages/en.json'
13
+ import { organiserIds, pastEvents, upcomingEvents } from '../store'
14
+ import { EventLink } from './event-link'
15
+
16
+ export const UPCOMING_LIMIT = 50
17
+
18
+ export const PAST_LIMIT = 30
19
+
20
+ function translated(context: PluginPageContext, key: keyof typeof en): string {
21
+ return context.t.has(key) ? context.t.t(key) : en[key]
22
+ }
23
+
24
+ function DateBlock({ event, locale }: { event: CalendarEvent; locale: string }) {
25
+ const { day, weekday } = dayParts(event.startsAt, locale)
26
+
27
+ return (
28
+ <div className="bg-muted text-foreground flex h-14 w-14 shrink-0 flex-col items-center justify-center rounded-md border">
29
+ <span className="text-lg font-semibold leading-none tabular-nums">{day}</span>
30
+ <span className="text-muted-foreground mt-1 text-[0.625rem] font-medium tracking-widest">
31
+ {weekday}
32
+ </span>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ function EventRow({
38
+ event,
39
+ locale,
40
+ now,
41
+ context,
42
+ }: {
43
+ event: CalendarEvent
44
+ locale: string
45
+ now: Date
46
+ context: PluginPageContext
47
+ }) {
48
+ const href = eventHref(event)
49
+
50
+ return (
51
+ <li className="flex items-start gap-4 py-4 first:pt-0 last:pb-0">
52
+ <DateBlock event={event} locale={locale} />
53
+
54
+ <div className="flex min-w-0 flex-col gap-1">
55
+ <p className="font-semibold leading-tight">{event.title}</p>
56
+
57
+ <p className="text-muted-foreground text-sm">
58
+ <time dateTime={event.startsAt.toISOString()}>
59
+ {formatRange(event.startsAt, event.endsAt, locale)}
60
+ </time>
61
+ {event.location !== '' && <span> · {event.location}</span>}
62
+ </p>
63
+
64
+ <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
65
+ <span className="text-muted-foreground">{relativeHint(event.startsAt, now, locale)}</span>
66
+ {href !== null && (
67
+ <a className="underline underline-offset-2" href={href}>
68
+ {translated(context, 'calendar.event.discuss')}
69
+ </a>
70
+ )}
71
+ <a
72
+ className="underline underline-offset-2"
73
+ href={`/api/plugins/calendar/events/ics?id=${event.id}`}
74
+ >
75
+ {translated(context, 'calendar.event.download')}
76
+ </a>
77
+ </p>
78
+
79
+ <EventLink event={event} label={translated(context, 'calendar.event.linkFallback')} />
80
+ </div>
81
+ </li>
82
+ )
83
+ }
84
+
85
+ function Agenda({
86
+ events,
87
+ locale,
88
+ now,
89
+ context,
90
+ }: {
91
+ events: readonly CalendarEvent[]
92
+ locale: string
93
+ now: Date
94
+ context: PluginPageContext
95
+ }) {
96
+ return (
97
+ <div className="flex flex-col gap-6">
98
+ {groupByMonth(events, locale).map((month) => (
99
+ <section key={month.key} className="bg-card flex flex-col gap-3 rounded-md border p-4">
100
+ <h2 className="text-muted-foreground text-xs font-semibold tracking-widest">
101
+ {month.label}
102
+ </h2>
103
+ <ul className="divide-border divide-y">
104
+ {month.events.map((event) => (
105
+ <EventRow key={event.id} event={event} locale={locale} now={now} context={context} />
106
+ ))}
107
+ </ul>
108
+ </section>
109
+ ))}
110
+ </div>
111
+ )
112
+ }
113
+
114
+ function AddForm({ context }: { context: PluginPageContext }) {
115
+ return (
116
+ <form
117
+ method="post"
118
+ action="/api/plugins/calendar/events"
119
+ className="bg-card flex flex-col gap-3 rounded-md border p-4"
120
+ >
121
+ <h2 className="font-semibold">{translated(context, 'calendar.event.add')}</h2>
122
+
123
+ <div className="grid gap-3 sm:grid-cols-2">
124
+ <label className="flex flex-col gap-1 text-sm sm:col-span-2">
125
+ {translated(context, 'calendar.event.title')}
126
+ <input name="title" required maxLength={120} className="rounded border p-1.5" />
127
+ </label>
128
+ <label className="flex flex-col gap-1 text-sm">
129
+ {translated(context, 'calendar.event.starts')}
130
+ <input name="starts_at" type="datetime-local" required className="rounded border p-1.5" />
131
+ </label>
132
+ <label className="flex flex-col gap-1 text-sm">
133
+ {translated(context, 'calendar.event.until')}
134
+ <input name="ends_at" type="datetime-local" className="rounded border p-1.5" />
135
+ </label>
136
+ <label className="flex flex-col gap-1 text-sm">
137
+ {translated(context, 'calendar.event.location')}
138
+ <input name="location" maxLength={120} className="rounded border p-1.5" />
139
+ </label>
140
+ <label className="flex flex-col gap-1 text-sm">
141
+ {translated(context, 'calendar.event.thread')}
142
+ <input name="thread" className="rounded border p-1.5" />
143
+ </label>
144
+ <label className="flex flex-col gap-1 text-sm">
145
+ {translated(context, 'calendar.event.link')}
146
+ <input
147
+ name="link"
148
+ type="url"
149
+ maxLength={500}
150
+ placeholder="https://"
151
+ className="rounded border p-1.5"
152
+ />
153
+ <span className="text-muted-foreground text-xs">
154
+ {translated(context, 'calendar.event.linkHint')}
155
+ </span>
156
+ </label>
157
+ <label className="flex flex-col gap-1 text-sm">
158
+ {translated(context, 'calendar.event.linkText')}
159
+ <input name="link_text" maxLength={40} className="rounded border p-1.5" />
160
+ <span className="text-muted-foreground text-xs">
161
+ {translated(context, 'calendar.event.linkTextHint')}
162
+ </span>
163
+ </label>
164
+ </div>
165
+
166
+ <button type="submit" className="bg-muted self-start rounded border px-3 py-1.5 text-sm">
167
+ {translated(context, 'calendar.event.add')}
168
+ </button>
169
+ </form>
170
+ )
171
+ }
172
+
173
+ export async function CalendarPage(context: PluginPageContext) {
174
+ const config = resolveCalendarConfig(context.settings)
175
+ const showingPast = context.query.show === 'past'
176
+ const now = new Date()
177
+
178
+ const [events, organisers] = await Promise.all([
179
+ (showingPast
180
+ ? pastEvents(context.data, PAST_LIMIT)
181
+ : upcomingEvents(context.data, UPCOMING_LIMIT)
182
+ ).catch(() => [] as readonly CalendarEvent[]),
183
+ organiserIds(context.data).catch(() => [] as readonly number[]),
184
+ ])
185
+
186
+ const verdict = mayAdd({ userId: context.viewer.userId, config, organisers })
187
+
188
+ return (
189
+ <div className="flex flex-col gap-6">
190
+ <nav className="flex gap-4 text-sm">
191
+ <a
192
+ className={showingPast ? 'underline underline-offset-2' : 'font-semibold'}
193
+ href="/plugins/calendar"
194
+ >
195
+ {translated(context, 'calendar.page.upcoming')}
196
+ </a>
197
+ <a
198
+ className={showingPast ? 'font-semibold' : 'underline underline-offset-2'}
199
+ href="/plugins/calendar?show=past"
200
+ >
201
+ {translated(context, 'calendar.page.past')}
202
+ </a>
203
+ </nav>
204
+
205
+ {events.length === 0 ? (
206
+ <p className="text-muted-foreground text-sm">
207
+ {translated(context, showingPast ? 'calendar.page.emptyPast' : 'calendar.page.empty')}
208
+ </p>
209
+ ) : (
210
+ <Agenda events={events} locale={context.locale} now={now} context={context} />
211
+ )}
212
+
213
+ {!showingPast && verdict === 'allowed' && <AddForm context={context} />}
214
+ {!showingPast && verdict === 'not-an-organiser' && (
215
+ <p className="text-muted-foreground text-sm">
216
+ {translated(context, 'calendar.error.notAnOrganiser')}
217
+ </p>
218
+ )}
219
+ </div>
220
+ )
221
+ }
@@ -0,0 +1,36 @@
1
+ import type { PluginRegionContext, PluginRuntimeContext } from '@meith/plugin-kit'
2
+
3
+ import { type CalendarEvent, formatRange, pickThreadEvent } from '../events'
4
+ import en from '../messages/en.json'
5
+ import { eventsForThread } from '../store'
6
+ import { EventLink } from './event-link'
7
+
8
+ export async function ThreadEventCard(context: PluginRegionContext) {
9
+ if (context.subjectId === null) return null
10
+
11
+ let event: CalendarEvent | null = null
12
+ try {
13
+ const runtime = (await context.runtime()) as PluginRuntimeContext
14
+ event = pickThreadEvent(await eventsForThread(runtime.data, context.subjectId), new Date())
15
+ } catch {
16
+ return null
17
+ }
18
+
19
+ if (event === null) return null
20
+
21
+ return (
22
+ <section className="rounded-md border border-border bg-card p-3 text-sm" data-plugin="calendar">
23
+ <p className="text-xs uppercase tracking-wide text-muted-foreground">
24
+ {en['calendar.thread.card']}
25
+ </p>
26
+ <p className="font-semibold">{event.title}</p>
27
+ <p className="text-muted-foreground">
28
+ <time dateTime={event.startsAt.toISOString()}>
29
+ {formatRange(event.startsAt, event.endsAt)}
30
+ </time>
31
+ {event.location !== '' && <span> · {event.location}</span>}
32
+ </p>
33
+ <EventLink event={event} label={en['calendar.event.linkFallback']} />
34
+ </section>
35
+ )
36
+ }