1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
|
import { getEvent } from "~/lib/db.server";
import type { Route } from "./+types/api.events.$id.ics";
import type { Event } from "~/lib/db.server";
export async function loader({ params }: Route.LoaderArgs) {
const id = parseInt(params.id, 10);
if (isNaN(id)) throw new Response("Not Found", { status: 404 });
const event = getEvent(id);
if (!event) throw new Response("Not Found", { status: 404 });
return new Response(buildIcs(event), {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="event-${id}.ics"`,
"Cache-Control": "no-cache",
},
});
}
function buildIcs(event: Event): string {
const uid = `event-${event.id}@tokyo-livehouse-events`;
const dtstamp = toIcsDateTime(new Date());
let dtstart: string;
let dtend: string;
if (event.start_time && /^\d{1,2}:\d{2}/.test(event.start_time)) {
const [h, m] = event.start_time.split(":").map(Number);
const [y, mo, d] = event.date.split("-").map(Number);
// Floating time (no timezone suffix) — display in user's local time
const pad = (n: number) => String(n).padStart(2, "0");
const startStr = `${event.date.replace(/-/g, "")}T${pad(h)}${pad(m)}00`;
// End time = start + 2 hours
const endH = h + 2;
const endStr =
endH < 24
? `${event.date.replace(/-/g, "")}T${pad(endH)}${pad(m)}00`
: (() => {
const next = new Date(y, mo - 1, d + 1);
return (
`${next.getFullYear()}${pad(next.getMonth() + 1)}${pad(next.getDate())}` +
`T${pad(endH - 24)}${pad(m)}00`
);
})();
dtstart = `DTSTART:${startStr}`;
dtend = `DTEND:${endStr}`;
} else {
// All-day event
const [y, mo, d] = event.date.split("-").map(Number);
const next = new Date(y, mo - 1, d + 1);
const pad = (n: number) => String(n).padStart(2, "0");
const nextStr = `${next.getFullYear()}${pad(next.getMonth() + 1)}${pad(next.getDate())}`;
dtstart = `DTSTART;VALUE=DATE:${event.date.replace(/-/g, "")}`;
dtend = `DTEND;VALUE=DATE:${nextStr}`;
}
const descParts: string[] = [];
if (event.artist) descParts.push(`出演: ${event.artist}`);
if (event.open_time) descParts.push(`OPEN: ${event.open_time}`);
if (event.start_time) descParts.push(`START: ${event.start_time}`);
if (event.price) descParts.push(`料金: ${event.price}`);
if (event.source_url) descParts.push(`詳細: ${event.source_url}`);
if (event.ticket_url) descParts.push(`チケット: ${event.ticket_url}`);
const location = [event.venue_name, event.venue_area].filter(Boolean).join(" ");
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Tokyo Livehouse Events//JA",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${dtstamp}`,
dtstart,
dtend,
foldLine(`SUMMARY:${escIcs(event.title)}`),
foldLine(`LOCATION:${escIcs(location)}`),
];
if (descParts.length > 0) {
lines.push(foldLine(`DESCRIPTION:${escIcs(descParts.join("\\n"))}`));
}
if (event.source_url) {
lines.push(`URL:${event.source_url}`);
}
lines.push("END:VEVENT", "END:VCALENDAR");
return lines.join("\r\n") + "\r\n";
}
function escIcs(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,");
}
// Fold lines longer than 75 chars (RFC 5545 §3.1)
function foldLine(line: string): string {
if (line.length <= 75) return line;
const chunks: string[] = [];
let pos = 0;
while (pos < line.length) {
const limit = pos === 0 ? 75 : 74;
chunks.push((pos === 0 ? "" : " ") + line.slice(pos, pos + limit));
pos += limit;
}
return chunks.join("\r\n");
}
function toIcsDateTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
);
}
|