Release Calendar Tools 0.1.0
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
import "./styles.css";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { HelpDialog } from "./components/HelpDialog";
|
||||
import { manifest } from "./toolbox/manifest";
|
||||
|
||||
const Workbench = lazy(async () => ({
|
||||
default: (await import("./components/Workbench")).Workbench,
|
||||
}));
|
||||
|
||||
export function App() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppShell
|
||||
app={manifest}
|
||||
manifestUrl="./toolbox-app.json"
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="loading" role="status">
|
||||
Preparing Calendar Tools…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
import ICAL from "ical.js";
|
||||
|
||||
export interface CalendarDiagnostic {
|
||||
severity: "info" | "warning" | "error";
|
||||
message: string;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
uid: string;
|
||||
recurrenceId: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
location: string;
|
||||
status: string;
|
||||
organizer: string;
|
||||
attendees: number;
|
||||
startText: string;
|
||||
endText: string;
|
||||
startMs: number | null;
|
||||
endMs: number | null;
|
||||
allDay: boolean;
|
||||
timezone: string;
|
||||
recurring: boolean;
|
||||
recurrenceRules: string[];
|
||||
sequence: number;
|
||||
stamp: string;
|
||||
component: InstanceType<typeof ICAL.Component>;
|
||||
}
|
||||
|
||||
export interface TimezoneRecord {
|
||||
tzid: string;
|
||||
observances: number;
|
||||
standard: number;
|
||||
daylight: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
export interface CalendarDocument {
|
||||
component: InstanceType<typeof ICAL.Component>;
|
||||
events: CalendarEvent[];
|
||||
timezones: TimezoneRecord[];
|
||||
diagnostics: CalendarDiagnostic[];
|
||||
properties: { prodid: string; version: string; method: string; name: string };
|
||||
}
|
||||
|
||||
export interface Occurrence {
|
||||
uid: string;
|
||||
summary: string;
|
||||
startText: string;
|
||||
endText: string;
|
||||
startMs: number | null;
|
||||
endMs: number | null;
|
||||
timezone: string;
|
||||
allDay: boolean;
|
||||
}
|
||||
|
||||
export interface Conflict {
|
||||
left: Occurrence;
|
||||
right: Occurrence;
|
||||
kind: "overlap" | "duplicate";
|
||||
}
|
||||
|
||||
const MAX_SOURCE = 4 * 1024 * 1024;
|
||||
const MAX_LINES = 100_000;
|
||||
const MAX_COMPONENTS = 10_000;
|
||||
const MAX_EVENTS = 5_000;
|
||||
|
||||
function textValue(
|
||||
component: InstanceType<typeof ICAL.Component>,
|
||||
name: string,
|
||||
): string {
|
||||
const value = component.getFirstPropertyValue(name);
|
||||
return typeof value === "string" ? value : (value?.toString() ?? "");
|
||||
}
|
||||
|
||||
function formatLocalParts(epochMs: number, zone: string): string {
|
||||
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: zone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
const parts = Object.fromEntries(
|
||||
formatter
|
||||
.formatToParts(new Date(epochMs))
|
||||
.filter((part) => part.type !== "literal")
|
||||
.map((part) => [part.type, part.value]),
|
||||
);
|
||||
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`;
|
||||
}
|
||||
|
||||
function offsetAt(epochMs: number, zone: string): number {
|
||||
const local = formatLocalParts(epochMs, zone);
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/u.exec(
|
||||
local,
|
||||
);
|
||||
if (!match) throw new Error(`Could not format time in ${zone}.`);
|
||||
const interpreted = Date.UTC(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
Number(match[4]),
|
||||
Number(match[5]),
|
||||
Number(match[6]),
|
||||
);
|
||||
return Math.round((interpreted - epochMs) / 60_000);
|
||||
}
|
||||
|
||||
export function localTimeCandidates(local: string, zone: string): number[] {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/u.exec(
|
||||
local,
|
||||
);
|
||||
if (!match) throw new Error("Local time must use YYYY-MM-DDTHH:mm[:ss].");
|
||||
const base = Date.UTC(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
Number(match[4]),
|
||||
Number(match[5]),
|
||||
Number(match[6] ?? 0),
|
||||
);
|
||||
const offsets = new Set<number>();
|
||||
for (let delta = -48; delta <= 48; delta += 6)
|
||||
offsets.add(offsetAt(base + delta * 3_600_000, zone));
|
||||
const canonical = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6] ?? "00"}`;
|
||||
return [...offsets]
|
||||
.map((offset) => base - offset * 60_000)
|
||||
.filter((candidate) => formatLocalParts(candidate, zone) === canonical)
|
||||
.sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function calendarTimeText(time: InstanceType<typeof ICAL.Time>): string {
|
||||
const year = String(time.year).padStart(4, "0");
|
||||
const month = String(time.month).padStart(2, "0");
|
||||
const day = String(time.day).padStart(2, "0");
|
||||
if (time.isDate) return `${year}-${month}-${day}`;
|
||||
return `${year}-${month}-${day}T${String(time.hour).padStart(2, "0")}:${String(time.minute).padStart(2, "0")}:${String(time.second).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function resolveTime(
|
||||
time: InstanceType<typeof ICAL.Time>,
|
||||
tzid: string,
|
||||
hasDefinition: boolean,
|
||||
): number | null {
|
||||
if (time.isDate) return Date.UTC(time.year, time.month - 1, time.day);
|
||||
if (time.zone?.tzid === "UTC" || tzid.toUpperCase() === "UTC")
|
||||
return time.toJSDate().getTime();
|
||||
if (hasDefinition && time.zone?.tzid && time.zone.tzid !== "floating")
|
||||
return time.toJSDate().getTime();
|
||||
if (tzid) {
|
||||
try {
|
||||
return localTimeCandidates(calendarTimeText(time), tzid)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return Date.UTC(
|
||||
time.year,
|
||||
time.month - 1,
|
||||
time.day,
|
||||
time.hour,
|
||||
time.minute,
|
||||
time.second,
|
||||
);
|
||||
}
|
||||
|
||||
function preflight(source: string) {
|
||||
if (source.length > MAX_SOURCE)
|
||||
throw new Error("Calendar input exceeds the 4 MiB limit.");
|
||||
const lines = source
|
||||
.replaceAll("\r\n", "\n")
|
||||
.replaceAll("\r", "\n")
|
||||
.split("\n");
|
||||
if (lines.length > MAX_LINES)
|
||||
throw new Error("Calendar input exceeds the 100,000-line limit.");
|
||||
const components = lines.filter((line) => /^BEGIN:/iu.test(line)).length;
|
||||
if (components > MAX_COMPONENTS)
|
||||
throw new Error("Calendar input exceeds the 10,000-component limit.");
|
||||
}
|
||||
|
||||
function eventFromComponent(
|
||||
component: InstanceType<typeof ICAL.Component>,
|
||||
index: number,
|
||||
timezoneIds: Set<string>,
|
||||
diagnostics: CalendarDiagnostic[],
|
||||
): CalendarEvent {
|
||||
const event = new ICAL.Event(component);
|
||||
const startProperty = component.getFirstProperty("dtstart");
|
||||
if (!startProperty) throw new Error(`VEVENT ${index + 1} has no DTSTART.`);
|
||||
const start = event.startDate;
|
||||
const end = event.endDate;
|
||||
const tzidParameter = startProperty.getParameter("tzid");
|
||||
const timezone =
|
||||
typeof tzidParameter === "string"
|
||||
? tzidParameter
|
||||
: start.zone?.tzid === "UTC"
|
||||
? "UTC"
|
||||
: "floating";
|
||||
const hasDefinition = timezoneIds.has(timezone);
|
||||
const startMs = resolveTime(
|
||||
start,
|
||||
timezone === "floating" ? "" : timezone,
|
||||
hasDefinition,
|
||||
);
|
||||
const endMs = resolveTime(
|
||||
end,
|
||||
timezone === "floating" ? "" : timezone,
|
||||
hasDefinition,
|
||||
);
|
||||
const uid = event.uid || `missing-uid-${index + 1}`;
|
||||
if (!event.uid)
|
||||
diagnostics.push({
|
||||
severity: "warning",
|
||||
uid,
|
||||
message:
|
||||
"VEVENT has no UID; a deterministic inspection-only identifier was assigned.",
|
||||
});
|
||||
if (
|
||||
timezone &&
|
||||
timezone !== "floating" &&
|
||||
timezone !== "UTC" &&
|
||||
!hasDefinition
|
||||
) {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en", { timeZone: timezone }).format(0);
|
||||
diagnostics.push({
|
||||
severity: "info",
|
||||
uid,
|
||||
message: `${timezone} has no embedded VTIMEZONE; preview uses this browser's IANA timezone data.`,
|
||||
});
|
||||
} catch {
|
||||
diagnostics.push({
|
||||
severity: "warning",
|
||||
uid,
|
||||
message: `${timezone} has no embedded definition and is unknown to this browser; absolute-time diagnostics are unavailable.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (startMs === null || endMs === null)
|
||||
diagnostics.push({
|
||||
severity: "warning",
|
||||
uid,
|
||||
message:
|
||||
"The event falls in an invalid or unresolved local time; absolute conflict checks skip it.",
|
||||
});
|
||||
const recurrenceRules = component
|
||||
.getAllProperties("rrule")
|
||||
.map((property) => property.getFirstValue()?.toString() ?? "");
|
||||
return {
|
||||
id: `${uid}:${textValue(component, "recurrence-id") || "master"}:${index}`,
|
||||
uid,
|
||||
recurrenceId: textValue(component, "recurrence-id"),
|
||||
summary: event.summary || "(untitled event)",
|
||||
description: event.description || "",
|
||||
location: event.location || "",
|
||||
status: textValue(component, "status"),
|
||||
organizer: event.organizer || "",
|
||||
attendees: event.attendees.length,
|
||||
startText: calendarTimeText(start),
|
||||
endText: calendarTimeText(end),
|
||||
startMs,
|
||||
endMs,
|
||||
allDay: start.isDate,
|
||||
timezone,
|
||||
recurring: event.isRecurring(),
|
||||
recurrenceRules,
|
||||
sequence: event.sequence || 0,
|
||||
stamp: textValue(component, "dtstamp"),
|
||||
component,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCalendar(source: string): CalendarDocument {
|
||||
preflight(source);
|
||||
let component: InstanceType<typeof ICAL.Component>;
|
||||
try {
|
||||
component = new ICAL.Component(ICAL.parse(source.replace(/^\uFEFF/u, "")));
|
||||
} catch (reason) {
|
||||
throw new Error(
|
||||
`iCalendar parse failed: ${reason instanceof Error ? reason.message : "invalid syntax"}`,
|
||||
{ cause: reason },
|
||||
);
|
||||
}
|
||||
if (component.name !== "vcalendar")
|
||||
throw new Error("The root component must be VCALENDAR.");
|
||||
const diagnostics: CalendarDiagnostic[] = [];
|
||||
const zones = component.getAllSubcomponents("vtimezone");
|
||||
const timezoneIds = new Set(
|
||||
zones.map((zone) => textValue(zone, "tzid")).filter(Boolean),
|
||||
);
|
||||
const eventComponents = component.getAllSubcomponents("vevent");
|
||||
if (eventComponents.length > MAX_EVENTS)
|
||||
throw new Error("Calendar input exceeds the 5,000-event limit.");
|
||||
const events: CalendarEvent[] = [];
|
||||
for (const [index, eventComponent] of eventComponents.entries()) {
|
||||
try {
|
||||
events.push(
|
||||
eventFromComponent(eventComponent, index, timezoneIds, diagnostics),
|
||||
);
|
||||
} catch (reason) {
|
||||
diagnostics.push({
|
||||
severity: "error",
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: `Could not inspect VEVENT ${index + 1}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const version = textValue(component, "version");
|
||||
const prodid = textValue(component, "prodid");
|
||||
if (version !== "2.0")
|
||||
diagnostics.push({
|
||||
severity: "warning",
|
||||
message: `VERSION is ${version || "missing"}; canonical repair writes 2.0.`,
|
||||
});
|
||||
if (!prodid)
|
||||
diagnostics.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"PRODID is missing; canonical repair supplies a local identifier.",
|
||||
});
|
||||
const timezones = zones.map((zone) => ({
|
||||
tzid: textValue(zone, "tzid") || "(missing TZID)",
|
||||
observances: zone
|
||||
.getAllSubcomponents()
|
||||
.filter((item) => item.name === "standard" || item.name === "daylight")
|
||||
.length,
|
||||
standard: zone.getAllSubcomponents("standard").length,
|
||||
daylight: zone.getAllSubcomponents("daylight").length,
|
||||
lastModified: textValue(zone, "last-modified"),
|
||||
}));
|
||||
return {
|
||||
component,
|
||||
events,
|
||||
timezones,
|
||||
diagnostics,
|
||||
properties: {
|
||||
prodid,
|
||||
version,
|
||||
method: textValue(component, "method"),
|
||||
name: textValue(component, "x-wr-calname"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function occurrenceFromTime(
|
||||
event: CalendarEvent,
|
||||
time: InstanceType<typeof ICAL.Time>,
|
||||
durationMs: number,
|
||||
timezoneIds: Set<string>,
|
||||
): Occurrence {
|
||||
const startText = calendarTimeText(time);
|
||||
const startMs = resolveTime(
|
||||
time,
|
||||
event.timezone === "floating" ? "" : event.timezone,
|
||||
timezoneIds.has(event.timezone),
|
||||
);
|
||||
return {
|
||||
uid: event.uid,
|
||||
summary: event.summary,
|
||||
startText,
|
||||
endText:
|
||||
startMs === null
|
||||
? "unresolved"
|
||||
: new Date(startMs + durationMs).toISOString(),
|
||||
startMs,
|
||||
endMs: startMs === null ? null : startMs + durationMs,
|
||||
timezone: event.timezone,
|
||||
allDay: event.allDay,
|
||||
};
|
||||
}
|
||||
|
||||
export function previewOccurrences(
|
||||
document: CalendarDocument,
|
||||
limit = 100,
|
||||
horizonDays = 730,
|
||||
): { occurrences: Occurrence[]; truncated: boolean } {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 500)
|
||||
throw new Error("Occurrence limit must be 1–500.");
|
||||
if (!Number.isInteger(horizonDays) || horizonDays < 1 || horizonDays > 3_650)
|
||||
throw new Error("Recurrence horizon must be 1–3,650 days.");
|
||||
const timezoneIds = new Set(document.timezones.map((zone) => zone.tzid));
|
||||
const occurrences: Occurrence[] = [];
|
||||
let truncated = false;
|
||||
for (const event of document.events.filter((entry) => !entry.recurrenceId)) {
|
||||
const duration =
|
||||
event.startMs !== null && event.endMs !== null
|
||||
? Math.max(0, event.endMs - event.startMs)
|
||||
: 0;
|
||||
if (!event.recurring) {
|
||||
occurrences.push({
|
||||
uid: event.uid,
|
||||
summary: event.summary,
|
||||
startText: event.startText,
|
||||
endText: event.endText,
|
||||
startMs: event.startMs,
|
||||
endMs: event.endMs,
|
||||
timezone: event.timezone,
|
||||
allDay: event.allDay,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const iterator = new ICAL.Event(event.component).iterator();
|
||||
const startBound = event.startMs ?? 0;
|
||||
const horizon = startBound + horizonDays * 86_400_000;
|
||||
for (let count = 0; count < limit; count += 1) {
|
||||
const next = iterator.next();
|
||||
if (!next) break;
|
||||
const occurrence = occurrenceFromTime(event, next, duration, timezoneIds);
|
||||
if (occurrence.startMs !== null && occurrence.startMs > horizon) break;
|
||||
occurrences.push(occurrence);
|
||||
if (count === limit - 1) truncated = true;
|
||||
}
|
||||
}
|
||||
occurrences.sort(
|
||||
(left, right) =>
|
||||
(left.startMs ?? Number.MAX_SAFE_INTEGER) -
|
||||
(right.startMs ?? Number.MAX_SAFE_INTEGER) ||
|
||||
left.summary.localeCompare(right.summary),
|
||||
);
|
||||
if (occurrences.length > 5_000) {
|
||||
occurrences.length = 5_000;
|
||||
truncated = true;
|
||||
}
|
||||
return { occurrences, truncated };
|
||||
}
|
||||
|
||||
function eventIdentity(
|
||||
component: InstanceType<typeof ICAL.Component>,
|
||||
): string | null {
|
||||
const uid = textValue(component, "uid");
|
||||
return uid ? `${uid}|${textValue(component, "recurrence-id")}` : null;
|
||||
}
|
||||
|
||||
function newerEvent(
|
||||
left: InstanceType<typeof ICAL.Component>,
|
||||
right: InstanceType<typeof ICAL.Component>,
|
||||
): InstanceType<typeof ICAL.Component> {
|
||||
const leftSequence = Number(textValue(left, "sequence") || 0);
|
||||
const rightSequence = Number(textValue(right, "sequence") || 0);
|
||||
if (rightSequence !== leftSequence)
|
||||
return rightSequence > leftSequence ? right : left;
|
||||
return textValue(right, "dtstamp") > textValue(left, "dtstamp")
|
||||
? right
|
||||
: left;
|
||||
}
|
||||
|
||||
export function mergeCalendars(documents: readonly CalendarDocument[]): {
|
||||
source: string;
|
||||
diagnostics: CalendarDiagnostic[];
|
||||
deduplicated: number;
|
||||
} {
|
||||
if (documents.length < 1 || documents.length > 20)
|
||||
throw new Error("Merge accepts 1–20 calendars.");
|
||||
const root = new ICAL.Component("vcalendar");
|
||||
root.addPropertyWithValue("version", "2.0");
|
||||
root.addPropertyWithValue("prodid", "-//add·ideas//Calendar Tools 0.1.0//EN");
|
||||
const zones = new Map<string, InstanceType<typeof ICAL.Component>>();
|
||||
const events = new Map<string, InstanceType<typeof ICAL.Component>>();
|
||||
let deduplicated = 0;
|
||||
let missingUids = 0;
|
||||
for (const [documentIndex, document] of documents.entries()) {
|
||||
for (const zone of document.component.getAllSubcomponents("vtimezone")) {
|
||||
const id = textValue(zone, "tzid");
|
||||
if (id && !zones.has(id)) zones.set(id, zone);
|
||||
}
|
||||
for (const [eventIndex, event] of document.component
|
||||
.getAllSubcomponents("vevent")
|
||||
.entries()) {
|
||||
const declaredIdentity = eventIdentity(event);
|
||||
const identity =
|
||||
declaredIdentity ?? `~missing-${documentIndex}-${eventIndex}`;
|
||||
if (!declaredIdentity) missingUids += 1;
|
||||
const previous = events.get(identity);
|
||||
if (previous) {
|
||||
events.set(identity, newerEvent(previous, event));
|
||||
deduplicated += 1;
|
||||
} else events.set(identity, event);
|
||||
}
|
||||
}
|
||||
for (const zone of [...zones.values()].sort((a, b) =>
|
||||
textValue(a, "tzid").localeCompare(textValue(b, "tzid")),
|
||||
))
|
||||
root.addSubcomponent(new ICAL.Component(structuredClone(zone.toJSON())));
|
||||
for (const [, event] of [...events.entries()].sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
))
|
||||
root.addSubcomponent(new ICAL.Component(structuredClone(event.toJSON())));
|
||||
return {
|
||||
source:
|
||||
root.toString().replaceAll("\n", "\r\n").replaceAll("\r\r\n", "\r\n") +
|
||||
(root.toString().endsWith("\n") ? "" : "\r\n"),
|
||||
diagnostics: [
|
||||
{
|
||||
severity: "info",
|
||||
message: `Merged ${documents.length} calendars; retained ${events.size} event identities and ${zones.size} timezone definitions.`,
|
||||
},
|
||||
...(missingUids
|
||||
? [
|
||||
{
|
||||
severity: "warning" as const,
|
||||
message: `${missingUids} UID-less events were retained independently because they have no safe cross-calendar identity.`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
deduplicated,
|
||||
};
|
||||
}
|
||||
|
||||
export function findConflicts(
|
||||
occurrences: readonly Occurrence[],
|
||||
limit = 500,
|
||||
): Conflict[] {
|
||||
const usable = occurrences
|
||||
.filter((entry) => entry.startMs !== null && entry.endMs !== null)
|
||||
.slice(0, 5_000)
|
||||
.sort((a, b) => a.startMs! - b.startMs!);
|
||||
const conflicts: Conflict[] = [];
|
||||
for (let leftIndex = 0; leftIndex < usable.length; leftIndex += 1) {
|
||||
const left = usable[leftIndex]!;
|
||||
for (
|
||||
let rightIndex = leftIndex + 1;
|
||||
rightIndex < usable.length;
|
||||
rightIndex += 1
|
||||
) {
|
||||
const right = usable[rightIndex]!;
|
||||
if (right.startMs! >= left.endMs!) break;
|
||||
if (left.uid === right.uid && left.startMs === right.startMs)
|
||||
conflicts.push({ left, right, kind: "duplicate" });
|
||||
else if (left.uid !== right.uid && right.endMs! > left.startMs!)
|
||||
conflicts.push({ left, right, kind: "overlap" });
|
||||
if (conflicts.length >= limit) return conflicts;
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
export function canonicalRepair(source: string): {
|
||||
source: string;
|
||||
repairs: string[];
|
||||
} {
|
||||
preflight(source);
|
||||
const repairs: string[] = [];
|
||||
let normalized = source
|
||||
.replace(/^\uFEFF/u, "")
|
||||
.replaceAll("\0", "")
|
||||
.replaceAll("\r\n", "\n")
|
||||
.replaceAll("\r", "\n");
|
||||
if (normalized !== source)
|
||||
repairs.push("Removed BOM/NUL bytes and normalized physical line endings.");
|
||||
const parsed = parseCalendar(normalized);
|
||||
const clone = new ICAL.Component(structuredClone(parsed.component.toJSON()));
|
||||
if (textValue(clone, "version") !== "2.0") {
|
||||
clone.updatePropertyWithValue("version", "2.0");
|
||||
repairs.push("Set VERSION:2.0.");
|
||||
}
|
||||
if (!textValue(clone, "prodid")) {
|
||||
clone.addPropertyWithValue(
|
||||
"prodid",
|
||||
"-//add·ideas//Calendar Tools 0.1.0//EN",
|
||||
);
|
||||
repairs.push("Added PRODID.");
|
||||
}
|
||||
normalized = clone.toString();
|
||||
normalized = normalized
|
||||
.replaceAll("\r\n", "\n")
|
||||
.replaceAll("\r", "\n")
|
||||
.replaceAll("\n", "\r\n");
|
||||
if (!normalized.endsWith("\r\n")) normalized += "\r\n";
|
||||
if (!repairs.length)
|
||||
repairs.push("Re-serialized the calendar into canonical folded CRLF form.");
|
||||
return { source: normalized, repairs };
|
||||
}
|
||||
|
||||
export interface ZoneTransition {
|
||||
at: string;
|
||||
fromMinutes: number;
|
||||
toMinutes: number;
|
||||
}
|
||||
export function timezoneDiagnostics(
|
||||
zone: string,
|
||||
year: number,
|
||||
): {
|
||||
transitions: ZoneTransition[];
|
||||
januaryOffset: number;
|
||||
julyOffset: number;
|
||||
} {
|
||||
if (!Number.isInteger(year) || year < 1970 || year > 2100)
|
||||
throw new Error("Timezone year must be 1970–2100.");
|
||||
new Intl.DateTimeFormat("en", { timeZone: zone }).format(0);
|
||||
const transitions: ZoneTransition[] = [];
|
||||
let previousAt = Date.UTC(year, 0, 1);
|
||||
let previous = offsetAt(previousAt, zone);
|
||||
for (
|
||||
let at = previousAt + 6 * 3_600_000;
|
||||
at < Date.UTC(year + 1, 0, 2);
|
||||
at += 6 * 3_600_000
|
||||
) {
|
||||
const next = offsetAt(at, zone);
|
||||
if (next !== previous) {
|
||||
let low = previousAt;
|
||||
let high = at;
|
||||
while (high - low > 60_000) {
|
||||
const middle = Math.floor((low + high) / 120_000) * 60_000;
|
||||
if (offsetAt(middle, zone) === previous) low = middle;
|
||||
else high = middle;
|
||||
}
|
||||
transitions.push({
|
||||
at: new Date(high).toISOString(),
|
||||
fromMinutes: previous,
|
||||
toMinutes: next,
|
||||
});
|
||||
previous = next;
|
||||
}
|
||||
previousAt = at;
|
||||
}
|
||||
return {
|
||||
transitions,
|
||||
januaryOffset: offsetAt(Date.UTC(year, 0, 15, 12), zone),
|
||||
julyOffset: offsetAt(Date.UTC(year, 6, 15, 12), zone),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Application failure", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<main className="fatal">
|
||||
<h1>Calendar Tools could not continue</h1>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function HelpDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const node = dialog.current;
|
||||
if (!node) return;
|
||||
if (open && !node.open) node.showModal();
|
||||
if (!open && node.open) node.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="help-dialog"
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
aria-labelledby="help-title"
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first help</p>
|
||||
<h2 id="help-title">About Calendar Tools</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close help">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p>Inspect, repair, and reconcile calendars locally in the browser.</p>
|
||||
<p>
|
||||
All processing is performed in this browser. Imported data is treated as
|
||||
untrusted and bounded before parsing.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import {
|
||||
canonicalRepair,
|
||||
findConflicts,
|
||||
localTimeCandidates,
|
||||
mergeCalendars,
|
||||
parseCalendar,
|
||||
previewOccurrences,
|
||||
timezoneDiagnostics,
|
||||
type CalendarDocument,
|
||||
} from "../calendar/model";
|
||||
|
||||
const sample = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//add ideas//Calendar Tools sample//EN\r\nX-WR-CALNAME:Project calendar\r\nBEGIN:VEVENT\r\nUID:weekly-review@example.test\r\nDTSTAMP:20260801T080000Z\r\nDTSTART;TZID=Europe/Berlin:20260901T100000\r\nDTEND;TZID=Europe/Berlin:20260901T110000\r\nRRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU\r\nSUMMARY:Weekly review\r\nLOCATION:Studio\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:design-session@example.test\r\nDTSTAMP:20260801T080000Z\r\nDTSTART:20260901T103000Z\r\nDTEND:20260901T120000Z\r\nSUMMARY:Design session\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||
|
||||
const secondSample = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//add ideas//Second sample//EN\r\nBEGIN:VEVENT\r\nUID:weekly-review@example.test\r\nSEQUENCE:2\r\nDTSTAMP:20260802T080000Z\r\nDTSTART;TZID=Europe/Berlin:20260901T103000\r\nDTEND;TZID=Europe/Berlin:20260901T113000\r\nRRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU\r\nSUMMARY:Updated weekly review\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||
|
||||
type Tab = "inspect" | "recurrence" | "merge" | "timezone";
|
||||
|
||||
function downloadIcs(value: string, name = "calendar.ics") {
|
||||
triggerBlobDownload(
|
||||
new Blob([value], { type: "text/calendar;charset=utf-8" }),
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
function formatInstant(value: number | null, fallback: string) {
|
||||
return value === null ? fallback : new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function offsetLabel(minutes: number) {
|
||||
const sign = minutes < 0 ? "−" : "+";
|
||||
const absolute = Math.abs(minutes);
|
||||
return `UTC${sign}${String(Math.floor(absolute / 60)).padStart(2, "0")}:${String(absolute % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const initial = parseCalendar(sample);
|
||||
const [tab, setTab] = useState<Tab>("inspect");
|
||||
const [source, setSource] = useState(sample);
|
||||
const [document, setDocument] = useState<CalendarDocument>(initial);
|
||||
const [error, setError] = useState("");
|
||||
const [repairs, setRepairs] = useState<string[]>([]);
|
||||
const [limit, setLimit] = useState(100);
|
||||
const [horizon, setHorizon] = useState(730);
|
||||
const [occurrenceState, setOccurrenceState] = useState(() =>
|
||||
previewOccurrences(initial, 100, 730),
|
||||
);
|
||||
const [mergeLeft, setMergeLeft] = useState(sample);
|
||||
const [mergeRight, setMergeRight] = useState(secondSample);
|
||||
const [mergeOutput, setMergeOutput] = useState("");
|
||||
const [mergeInfo, setMergeInfo] = useState("");
|
||||
const [zone, setZone] = useState("Europe/Berlin");
|
||||
const [zoneYear, setZoneYear] = useState(2026);
|
||||
const [localTime, setLocalTime] = useState("2026-10-25T02:30");
|
||||
const [zoneResult, setZoneResult] = useState(() =>
|
||||
timezoneDiagnostics("Europe/Berlin", 2026),
|
||||
);
|
||||
const [candidates, setCandidates] = useState(() =>
|
||||
localTimeCandidates("2026-10-25T02:30", "Europe/Berlin"),
|
||||
);
|
||||
const conflicts = useMemo(
|
||||
() => findConflicts(occurrenceState.occurrences),
|
||||
[occurrenceState],
|
||||
);
|
||||
|
||||
const accept = (value: string) => {
|
||||
const next = parseCalendar(value);
|
||||
setDocument(next);
|
||||
setOccurrenceState(previewOccurrences(next, limit, horizon));
|
||||
setRepairs([]);
|
||||
setError("");
|
||||
};
|
||||
const parse = () => {
|
||||
try {
|
||||
accept(source);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Could not inspect the calendar.",
|
||||
);
|
||||
}
|
||||
};
|
||||
const open = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
if (file.size > 4 * 1024 * 1024) {
|
||||
setError("Calendar file exceeds the 4 MiB limit.");
|
||||
return;
|
||||
}
|
||||
const value = await file.text();
|
||||
setSource(value);
|
||||
try {
|
||||
accept(value);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Could not inspect the calendar file.",
|
||||
);
|
||||
}
|
||||
};
|
||||
const repair = () => {
|
||||
try {
|
||||
const repaired = canonicalRepair(source);
|
||||
setSource(repaired.source);
|
||||
setRepairs(repaired.repairs);
|
||||
accept(repaired.source);
|
||||
setRepairs(repaired.repairs);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "Calendar repair failed.",
|
||||
);
|
||||
}
|
||||
};
|
||||
const expand = () => {
|
||||
try {
|
||||
setOccurrenceState(previewOccurrences(document, limit, horizon));
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "Recurrence preview failed.",
|
||||
);
|
||||
}
|
||||
};
|
||||
const merge = () => {
|
||||
try {
|
||||
const result = mergeCalendars([
|
||||
parseCalendar(mergeLeft),
|
||||
parseCalendar(mergeRight),
|
||||
]);
|
||||
setMergeOutput(result.source);
|
||||
setMergeInfo(
|
||||
`${result.deduplicated} duplicate event identit${result.deduplicated === 1 ? "y" : "ies"} resolved by SEQUENCE, then DTSTAMP.`,
|
||||
);
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Merge failed.");
|
||||
}
|
||||
};
|
||||
const inspectZone = () => {
|
||||
try {
|
||||
setZoneResult(timezoneDiagnostics(zone, zoneYear));
|
||||
setCandidates(localTimeCandidates(localTime, zone));
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Timezone inspection failed.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="workbench">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Calendar evidence, not guesswork</p>
|
||||
<h1>Calendar Tools</h1>
|
||||
<p>
|
||||
Inspect iCalendar structure, preview bounded recurrence, reconcile
|
||||
files, and diagnose local times without uploading schedules.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">Browser-local</span>
|
||||
</header>
|
||||
{error && (
|
||||
<p className="alert" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<nav className="workspace-tabs" aria-label="Calendar workspaces">
|
||||
{(["inspect", "recurrence", "merge", "timezone"] as const).map(
|
||||
(value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
onClick={() => setTab(value)}
|
||||
>
|
||||
{value[0]!.toUpperCase() + value.slice(1)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{tab === "inspect" && (
|
||||
<div className="split-layout">
|
||||
<section className="panel workspace">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">RFC 5545 source</p>
|
||||
<h2>Calendar input</h2>
|
||||
</div>
|
||||
<label className="button file-button">
|
||||
Open .ics
|
||||
<input
|
||||
type="file"
|
||||
accept=".ics,text/calendar"
|
||||
onChange={(event) => void open(event.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
aria-label="iCalendar source"
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="actions">
|
||||
<button type="button" className="primary" onClick={parse}>
|
||||
Inspect calendar
|
||||
</button>
|
||||
<button type="button" onClick={repair}>
|
||||
Repair and canonicalize
|
||||
</button>
|
||||
<button type="button" onClick={() => downloadIcs(source)}>
|
||||
Download current .ics
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Failed parses leave the last successful inspection visible. Repair
|
||||
never guesses missing event times.
|
||||
</p>
|
||||
{repairs.length > 0 && (
|
||||
<ul className="notice-list">
|
||||
{repairs.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section className="panel workspace">
|
||||
<div className="facts">
|
||||
<div>
|
||||
<span>Events</span>
|
||||
<strong>{document.events.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Recurring</span>
|
||||
<strong>
|
||||
{document.events.filter((event) => event.recurring).length}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>VTIMEZONE</span>
|
||||
<strong>{document.timezones.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Diagnostics</span>
|
||||
<strong>{document.diagnostics.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="metadata">
|
||||
<div>
|
||||
<dt>Calendar name</dt>
|
||||
<dd>{document.properties.name || "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>PRODID</dt>
|
||||
<dd>{document.properties.prodid || "missing"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>VERSION / METHOD</dt>
|
||||
<dd>
|
||||
{document.properties.version || "missing"} /{" "}
|
||||
{document.properties.method || "none"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Start</th>
|
||||
<th>Zone</th>
|
||||
<th>Rule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{document.events.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td>
|
||||
<strong>{event.summary}</strong>
|
||||
<span>{event.location || event.uid}</span>
|
||||
</td>
|
||||
<td>{formatInstant(event.startMs, event.startText)}</td>
|
||||
<td>{event.timezone}</td>
|
||||
<td>{event.recurrenceRules.join("; ") || "single"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details>
|
||||
<summary>
|
||||
Calendar and timezone diagnostics (
|
||||
{document.diagnostics.length + document.timezones.length})
|
||||
</summary>
|
||||
<ul className="diagnostics">
|
||||
{document.timezones.map((timezone) => (
|
||||
<li key={timezone.tzid}>
|
||||
{timezone.tzid}: {timezone.standard} STANDARD,{" "}
|
||||
{timezone.daylight} DAYLIGHT observances
|
||||
</li>
|
||||
))}
|
||||
{document.diagnostics.map((diagnostic, index) => (
|
||||
<li
|
||||
key={`${diagnostic.uid ?? "calendar"}-${index}`}
|
||||
data-severity={diagnostic.severity}
|
||||
>
|
||||
{diagnostic.uid ? `${diagnostic.uid}: ` : ""}
|
||||
{diagnostic.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "recurrence" && (
|
||||
<section className="panel workspace">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Explicitly bounded expansion</p>
|
||||
<h2>Occurrence and conflict preview</h2>
|
||||
</div>
|
||||
<span>
|
||||
{occurrenceState.occurrences.length} occurrences ·{" "}
|
||||
{conflicts.length} conflicts
|
||||
</span>
|
||||
</div>
|
||||
<div className="control-row">
|
||||
<label className="field">
|
||||
<span>Per-series limit</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
value={limit}
|
||||
onChange={(event) => setLimit(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Horizon (days)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
value={horizon}
|
||||
onChange={(event) => setHorizon(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="primary" onClick={expand}>
|
||||
Refresh preview
|
||||
</button>
|
||||
</div>
|
||||
{occurrenceState.truncated && (
|
||||
<p className="warning">
|
||||
Preview reached a per-series or global bound; later occurrences
|
||||
are intentionally not shown.
|
||||
</p>
|
||||
)}
|
||||
<div className="timeline">
|
||||
{occurrenceState.occurrences.map((occurrence, index) => (
|
||||
<article
|
||||
key={`${occurrence.uid}-${occurrence.startText}-${index}`}
|
||||
>
|
||||
<time>
|
||||
{formatInstant(occurrence.startMs, occurrence.startText)}
|
||||
</time>
|
||||
<div>
|
||||
<strong>{occurrence.summary}</strong>
|
||||
<span>
|
||||
{occurrence.timezone}
|
||||
{occurrence.allDay ? " · all day" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<details open={conflicts.length > 0}>
|
||||
<summary>Conflicts ({conflicts.length})</summary>
|
||||
<ul className="diagnostics">
|
||||
{conflicts.map((conflict, index) => (
|
||||
<li key={`${conflict.left.uid}-${conflict.right.uid}-${index}`}>
|
||||
{conflict.kind}: {conflict.left.summary} ↔{" "}
|
||||
{conflict.right.summary} at{" "}
|
||||
{formatInstant(
|
||||
conflict.right.startMs,
|
||||
conflict.right.startText,
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "merge" && (
|
||||
<section className="panel workspace">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">UID + RECURRENCE-ID identity</p>
|
||||
<h2>Merge and deduplicate</h2>
|
||||
</div>
|
||||
<button type="button" className="primary" onClick={merge}>
|
||||
Merge calendars
|
||||
</button>
|
||||
</div>
|
||||
<div className="merge-grid">
|
||||
<label className="field">
|
||||
<span>Calendar A</span>
|
||||
<textarea
|
||||
aria-label="Calendar A"
|
||||
value={mergeLeft}
|
||||
onChange={(event) => setMergeLeft(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Calendar B</span>
|
||||
<textarea
|
||||
aria-label="Calendar B"
|
||||
value={mergeRight}
|
||||
onChange={(event) => setMergeRight(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Canonical merged output</span>
|
||||
<textarea
|
||||
aria-label="Merged calendar"
|
||||
value={mergeOutput}
|
||||
readOnly
|
||||
placeholder="Merge output appears here."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{mergeInfo && <p className="notice">{mergeInfo}</p>}
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!mergeOutput}
|
||||
onClick={() => downloadIcs(mergeOutput, "merged-calendar.ics")}
|
||||
>
|
||||
Download merged .ics
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!mergeOutput}
|
||||
onClick={() => {
|
||||
if (!mergeOutput) return;
|
||||
setSource(mergeOutput);
|
||||
accept(mergeOutput);
|
||||
setTab("inspect");
|
||||
}}
|
||||
>
|
||||
Inspect merged output
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "timezone" && (
|
||||
<section className="panel workspace">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Browser IANA database</p>
|
||||
<h2>Timezone and DST diagnostics</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="control-row timezone-controls">
|
||||
<label className="field">
|
||||
<span>IANA timezone</span>
|
||||
<input
|
||||
value={zone}
|
||||
onChange={(event) => setZone(event.target.value)}
|
||||
placeholder="Europe/Berlin"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Year</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1970"
|
||||
max="2100"
|
||||
value={zoneYear}
|
||||
onChange={(event) => setZoneYear(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Local wall time</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
value={localTime}
|
||||
onChange={(event) => setLocalTime(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="primary" onClick={inspectZone}>
|
||||
Diagnose
|
||||
</button>
|
||||
</div>
|
||||
<div className="facts">
|
||||
<div>
|
||||
<span>January offset</span>
|
||||
<strong>{offsetLabel(zoneResult.januaryOffset)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>July offset</span>
|
||||
<strong>{offsetLabel(zoneResult.julyOffset)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Transitions</span>
|
||||
<strong>{zoneResult.transitions.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Wall-time candidates</span>
|
||||
<strong>{candidates.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="zone-grid">
|
||||
<article>
|
||||
<h3>Offset transitions</h3>
|
||||
{zoneResult.transitions.length ? (
|
||||
<ul>
|
||||
{zoneResult.transitions.map((transition) => (
|
||||
<li key={transition.at}>
|
||||
{transition.at}: {offsetLabel(transition.fromMinutes)} →{" "}
|
||||
{offsetLabel(transition.toMinutes)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No offset changes detected in this year.</p>
|
||||
)}
|
||||
</article>
|
||||
<article>
|
||||
<h3>Wall-time classification</h3>
|
||||
{candidates.length === 0 && (
|
||||
<p>
|
||||
Gap: this local time does not exist because clocks move
|
||||
forward.
|
||||
</p>
|
||||
)}
|
||||
{candidates.length === 1 && (
|
||||
<p>Unique instant: {new Date(candidates[0]!).toISOString()}</p>
|
||||
)}
|
||||
{candidates.length > 1 && (
|
||||
<>
|
||||
<p>
|
||||
Overlap: this local time occurs {candidates.length} times
|
||||
when clocks move backward.
|
||||
</p>
|
||||
<ul>
|
||||
{candidates.map((candidate) => (
|
||||
<li key={candidate}>
|
||||
{new Date(candidate).toISOString()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Results use the timezone database shipped by this browser. They can
|
||||
differ from another runtime or from historic local law.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
const url = new URL("./sw.js", document.baseURI);
|
||||
void navigator.serviceWorker
|
||||
.register(url, { scope: new URL("./", document.baseURI).pathname })
|
||||
.catch(() => undefined);
|
||||
});
|
||||
}
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
:root {
|
||||
--toolbox-background: #f6f7fb;
|
||||
--toolbox-surface: #fff;
|
||||
--toolbox-surface-soft: #eff1f7;
|
||||
--toolbox-text: #202332;
|
||||
--toolbox-muted: #656b7d;
|
||||
--toolbox-border: #d9dce7;
|
||||
--toolbox-accent: #5b4ec4;
|
||||
--toolbox-accent-hover: #493caf;
|
||||
--toolbox-accent-soft: #ece9ff;
|
||||
--toolbox-accent-contrast: #fff;
|
||||
--toolbox-focus: #137d75;
|
||||
--toolbox-danger: #b42342;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
min-width: 20rem;
|
||||
min-height: 100%;
|
||||
background: var(--toolbox-background);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body {
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--toolbox-background);
|
||||
color: var(--toolbox-text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
.button {
|
||||
min-height: 2.55rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 720;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled),
|
||||
.button:hover {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
:where(button, input, select, textarea, a):focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
textarea {
|
||||
min-height: 10rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
line-height: 1.48;
|
||||
}
|
||||
.toolbox-shell__main {
|
||||
width: min(100%, 90rem);
|
||||
padding: clamp(0.75rem, 1.8vw, 1.5rem);
|
||||
}
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hero,
|
||||
.panel {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
padding: clamp(1.1rem, 3vw, 2rem);
|
||||
}
|
||||
.hero h1,
|
||||
.panel h2,
|
||||
.panel h3,
|
||||
.help-dialog h2,
|
||||
.fatal h1 {
|
||||
margin: 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.hero p:not(.eyebrow) {
|
||||
max-width: 52rem;
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 0.3rem;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.115em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.privacy-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.38rem 0.62rem;
|
||||
border-radius: 999px;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.capability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.capability-grid article {
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.72rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.capability-grid p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.48;
|
||||
}
|
||||
.workspace-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.workspace-tabs button[aria-selected="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.field > span {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.muted {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.result {
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.68rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.loading,
|
||||
.fatal {
|
||||
width: min(100% - 2rem, 60rem);
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.help-dialog {
|
||||
width: min(36rem, calc(100% - 2rem));
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
.help-dialog::backdrop {
|
||||
background: rgb(20 24 45 / 55%);
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.editor-grid textarea {
|
||||
min-height: 22rem;
|
||||
}
|
||||
.encoding-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 16rem) minmax(12rem, 1fr);
|
||||
gap: 0.7rem;
|
||||
align-items: end;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.primary {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.file-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.file-button input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.check {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
min-height: 2.55rem;
|
||||
}
|
||||
.check input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
.inventory {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
}
|
||||
.inventory div {
|
||||
padding: 0.55rem;
|
||||
border-radius: 0.55rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.inventory dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.inventory dd {
|
||||
margin: 0.15rem 0 0;
|
||||
}
|
||||
.steps {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.steps li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 0.8fr) minmax(12rem, 1.4fr) auto;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.step-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.step-actions button {
|
||||
min-width: 2.55rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.warning,
|
||||
.error,
|
||||
.notice {
|
||||
margin: 0;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.warning {
|
||||
border-color: #d9a72e;
|
||||
background: #fff8df;
|
||||
color: #725000;
|
||||
}
|
||||
.error {
|
||||
border-color: var(--toolbox-danger);
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
.notice {
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
details {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
summary {
|
||||
padding: 0.65rem;
|
||||
cursor: pointer;
|
||||
font-weight: 750;
|
||||
}
|
||||
.recipe {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 0 0.65rem 0.65rem;
|
||||
}
|
||||
.alert,
|
||||
.warning,
|
||||
.notice {
|
||||
margin: 0;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.68rem;
|
||||
}
|
||||
.alert {
|
||||
border-color: var(--toolbox-danger);
|
||||
color: var(--toolbox-danger);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 7%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
.warning {
|
||||
border-color: #c18a12;
|
||||
background: color-mix(in srgb, #e0a423 12%, var(--toolbox-surface));
|
||||
}
|
||||
.notice {
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(20rem, 0.9fr) minmax(0, 1.25fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.split-layout textarea {
|
||||
min-height: 29rem;
|
||||
}
|
||||
.compact {
|
||||
width: auto;
|
||||
}
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.facts > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.facts span,
|
||||
.metadata dt,
|
||||
tbody td span,
|
||||
.timeline span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.facts strong {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.metadata {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
}
|
||||
.metadata > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, 0.3fr) minmax(0, 1fr);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.metadata dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.table-scroll {
|
||||
max-height: 28rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.6rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.69rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
tbody td:first-child {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.diagnostics,
|
||||
.notice-list {
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.diagnostics [data-severity="error"] {
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
.control-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(9rem, 14rem)) auto;
|
||||
gap: 0.7rem;
|
||||
align-items: end;
|
||||
}
|
||||
.timezone-controls {
|
||||
grid-template-columns:
|
||||
minmax(12rem, 1fr) minmax(7rem, 0.35fr) minmax(14rem, 0.8fr)
|
||||
auto;
|
||||
}
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
max-height: 38rem;
|
||||
overflow: auto;
|
||||
}
|
||||
.timeline article {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(13rem, 0.55fr) minmax(0, 1fr);
|
||||
gap: 0.8rem;
|
||||
padding: 0.65rem;
|
||||
border-left: 4px solid var(--toolbox-accent);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.timeline div {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.merge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.merge-grid textarea {
|
||||
min-height: 27rem;
|
||||
}
|
||||
.zone-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.zone-grid article {
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.7rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.zone-grid h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
@media (max-width: 62rem) {
|
||||
.editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.split-layout,
|
||||
.merge-grid,
|
||||
.zone-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.timezone-controls {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 48rem) {
|
||||
.steps li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 42rem) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
.privacy-pill {
|
||||
order: -1;
|
||||
}
|
||||
.control-row,
|
||||
.timezone-controls,
|
||||
.timeline article {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.calendar-tools",
|
||||
"name": "Calendar Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect, repair, and reconcile calendars locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["productivity", "data", "time"],
|
||||
"tags": ["calendar", "ics", "icalendar", "rrule", "timezone", "dst", "merge"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": false,
|
||||
"workers": false,
|
||||
"indexedDb": false,
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
"telemetry": false,
|
||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/calendar-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/lotobo/calendar-tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user