Skip to content

Commit 37d748e

Browse files
committed
Add SnapCal core logic libraries
1 parent 5e1afdc commit 37d748e

5 files changed

Lines changed: 431 additions & 0 deletions

File tree

src/lib/calendar.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type { EventDetails, CalendarEvent } from "../types";
2+
import { getSettings } from "./storage";
3+
4+
const CALENDAR_BASE = "https://www.googleapis.com/calendar/v3";
5+
6+
function getAuthToken(interactive = true): Promise<string> {
7+
return new Promise((resolve, reject) => {
8+
chrome.identity.getAuthToken({ interactive }, (token) => {
9+
if (chrome.runtime.lastError) {
10+
reject(new Error(chrome.runtime.lastError.message));
11+
} else if (!token) {
12+
reject(new Error("No auth token received. Please sign in."));
13+
} else {
14+
resolve(token);
15+
}
16+
});
17+
});
18+
}
19+
20+
export async function checkAuthStatus(): Promise<boolean> {
21+
try {
22+
await getAuthToken(false);
23+
return true;
24+
} catch {
25+
return false;
26+
}
27+
}
28+
29+
export async function getAuthTokenInteractive(): Promise<string> {
30+
return getAuthToken(true);
31+
}
32+
33+
function revokeToken(token: string): Promise<void> {
34+
return new Promise<void>((resolve) => {
35+
chrome.identity.removeCachedAuthToken({ token }, () => {
36+
resolve();
37+
});
38+
}).then(() => {
39+
// Also revoke on Google's side (best effort)
40+
return fetch(
41+
`https://accounts.google.com/o/oauth2/revoke?token=${token}`,
42+
).then(() => undefined, () => undefined);
43+
});
44+
}
45+
46+
function nextDay(dateStr: string): string {
47+
const d = new Date(dateStr + "T00:00:00Z");
48+
d.setUTCDate(d.getUTCDate() + 1);
49+
return d.toISOString().split("T")[0];
50+
}
51+
52+
async function buildEventBody(
53+
details: EventDetails,
54+
): Promise<Record<string, unknown>> {
55+
const { timezone, defaultDuration } = await getSettings();
56+
const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
57+
58+
const body: Record<string, unknown> = {
59+
summary: details.title,
60+
};
61+
62+
if (details.location) body.location = details.location;
63+
if (details.description) body.description = details.description;
64+
65+
if (details.isAllDay) {
66+
body.start = { date: details.date };
67+
body.end = { date: nextDay(details.date) };
68+
} else {
69+
const startDT = `${details.date}T${details.startTime}:00`;
70+
71+
let endDT: string;
72+
if (details.endTime) {
73+
endDT = `${details.date}T${details.endTime}:00`;
74+
} else {
75+
const dur = defaultDuration || 60;
76+
const [startH, startM] = details.startTime!.split(":").map(Number);
77+
const totalMinutes = startH * 60 + startM + dur;
78+
const endH = Math.floor(totalMinutes / 60) % 24;
79+
const endM = totalMinutes % 60;
80+
const endDate =
81+
totalMinutes >= 24 * 60 ? nextDay(details.date) : details.date;
82+
const hh = String(endH).padStart(2, "0");
83+
const mm = String(endM).padStart(2, "0");
84+
endDT = `${endDate}T${hh}:${mm}:00`;
85+
}
86+
87+
body.start = { dateTime: startDT, timeZone: tz };
88+
body.end = { dateTime: endDT, timeZone: tz };
89+
}
90+
91+
return body;
92+
}
93+
94+
async function postEvent(
95+
token: string,
96+
eventBody: Record<string, unknown>,
97+
): Promise<Response> {
98+
return fetch(`${CALENDAR_BASE}/calendars/primary/events`, {
99+
method: "POST",
100+
headers: {
101+
Authorization: `Bearer ${token}`,
102+
"Content-Type": "application/json",
103+
},
104+
body: JSON.stringify(eventBody),
105+
});
106+
}
107+
108+
export async function createEvent(
109+
details: EventDetails,
110+
): Promise<CalendarEvent> {
111+
const eventBody = await buildEventBody(details);
112+
let token = await getAuthToken();
113+
let response = await postEvent(token, eventBody);
114+
115+
// If token is stale, revoke and retry once
116+
if (response.status === 401) {
117+
await revokeToken(token);
118+
token = await getAuthToken();
119+
response = await postEvent(token, eventBody);
120+
}
121+
122+
if (!response.ok) {
123+
const err = await response.json().catch(() => ({}));
124+
const msg =
125+
(err as { error?: { message?: string } }).error?.message ??
126+
`Status ${response.status}`;
127+
throw new Error(`Calendar API error: ${msg}`);
128+
}
129+
130+
return (await response.json()) as CalendarEvent;
131+
}

src/lib/extractor.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
export async function extractPageText(): Promise<string> {
2+
const [tab] = await chrome.tabs.query({
3+
active: true,
4+
currentWindow: true,
5+
});
6+
if (!tab?.id) throw new Error("No active tab found.");
7+
8+
const url = tab.url ?? "";
9+
if (
10+
url.startsWith("chrome://") ||
11+
url.startsWith("chrome-extension://") ||
12+
url.startsWith("about:")
13+
) {
14+
throw new Error("Cannot extract text from browser internal pages.");
15+
}
16+
17+
const results = await chrome.scripting.executeScript({
18+
target: { tabId: tab.id },
19+
func: () => {
20+
// Runs in the web page context — must be fully self-contained.
21+
const selectors = [
22+
"article",
23+
"main",
24+
'[role="main"]',
25+
".post-content",
26+
".entry-content",
27+
];
28+
for (const sel of selectors) {
29+
const el = document.querySelector(sel);
30+
if (el && (el as HTMLElement).innerText.trim().length > 100) {
31+
return (el as HTMLElement).innerText.trim();
32+
}
33+
}
34+
return document.body.innerText.trim();
35+
},
36+
});
37+
38+
const text = results?.[0]?.result;
39+
if (!text) throw new Error("No text could be extracted from the page.");
40+
return text;
41+
}
42+
43+
export async function captureScreenshot(): Promise<string> {
44+
const dataUrl = await chrome.tabs.captureVisibleTab({ format: "png" });
45+
if (!dataUrl) {
46+
throw new Error("Screenshot capture returned no data.");
47+
}
48+
return dataUrl;
49+
}

src/lib/gemini.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import type { EventDetails } from "../types";
2+
import { getSettings } from "./storage";
3+
import { sanitize, truncateForAPI } from "./utils";
4+
5+
const API_BASE = "https://generativelanguage.googleapis.com/v1beta/models";
6+
7+
function buildPrompt(extra: string): string {
8+
const today = new Date().toISOString().split("T")[0];
9+
return `You are a calendar event parser. Today's date is ${today}.
10+
${extra}
11+
Return a JSON object with exactly these fields:
12+
- "title": string (event name/title)
13+
- "date": string (YYYY-MM-DD format)
14+
- "startTime": string (HH:MM in 24-hour format, or null if all-day)
15+
- "endTime": string (HH:MM in 24-hour format, or null if unknown)
16+
- "location": string (or null if not found)
17+
- "description": string (brief summary, or null)
18+
- "isAllDay": boolean
19+
20+
If multiple events are found, return only the most prominent one.
21+
If a field cannot be determined, use null.
22+
Return ONLY valid JSON, no markdown fences, no explanation.`;
23+
}
24+
25+
async function callGemini(
26+
contents: unknown[],
27+
retries = 2,
28+
): Promise<EventDetails> {
29+
const { geminiApiKey, geminiModel } = await getSettings();
30+
if (!geminiApiKey) {
31+
throw new Error(
32+
"Gemini API key not configured. Open extension options to set it.",
33+
);
34+
}
35+
36+
const model = geminiModel || "gemini-2.5-flash";
37+
const url = `${API_BASE}/${model}:generateContent?key=${geminiApiKey}`;
38+
39+
const body = JSON.stringify({
40+
contents: [{ parts: contents }],
41+
generationConfig: {
42+
temperature: 0.1,
43+
responseMimeType: "application/json",
44+
},
45+
});
46+
47+
let lastError: Error = new Error("Gemini request failed.");
48+
49+
for (let attempt = 0; attempt <= retries; attempt++) {
50+
try {
51+
const response = await fetch(url, {
52+
method: "POST",
53+
headers: { "Content-Type": "application/json" },
54+
body,
55+
});
56+
57+
if (response.status === 429) {
58+
lastError = mapApiError(429, "");
59+
const waitMs = Math.pow(2, attempt) * 1000;
60+
await new Promise((r) => setTimeout(r, waitMs));
61+
continue;
62+
}
63+
64+
if (!response.ok) {
65+
const errorBody = await response.json().catch(() => ({}));
66+
const msg =
67+
(errorBody as { error?: { message?: string } }).error?.message ?? "";
68+
throw mapApiError(response.status, msg);
69+
}
70+
71+
const data = await response.json();
72+
const rawText = (
73+
data as {
74+
candidates?: { content?: { parts?: { text?: string }[] } }[];
75+
}
76+
).candidates?.[0]?.content?.parts?.[0]?.text;
77+
if (!rawText) throw new Error("Gemini returned no content.");
78+
79+
return parseAndValidate(rawText);
80+
} catch (err) {
81+
lastError = err instanceof Error ? err : new Error(String(err));
82+
if (attempt < retries && isRetryable(lastError)) {
83+
const waitMs = Math.pow(2, attempt) * 1000;
84+
await new Promise((r) => setTimeout(r, waitMs));
85+
continue;
86+
}
87+
throw lastError;
88+
}
89+
}
90+
91+
throw lastError;
92+
}
93+
94+
function parseAndValidate(raw: string): EventDetails {
95+
const parsed = JSON.parse(raw);
96+
if (!parsed || typeof parsed !== "object") {
97+
throw new Error("Gemini returned invalid JSON.");
98+
}
99+
100+
const title = typeof parsed.title === "string" ? sanitize(parsed.title) : "";
101+
const date =
102+
typeof parsed.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(parsed.date)
103+
? parsed.date
104+
: new Date().toISOString().split("T")[0];
105+
const startTime =
106+
typeof parsed.startTime === "string" &&
107+
/^\d{2}:\d{2}$/.test(parsed.startTime)
108+
? parsed.startTime
109+
: null;
110+
const endTime =
111+
typeof parsed.endTime === "string" && /^\d{2}:\d{2}$/.test(parsed.endTime)
112+
? parsed.endTime
113+
: null;
114+
115+
return {
116+
title: title || "Untitled Event",
117+
date,
118+
startTime,
119+
endTime,
120+
location:
121+
typeof parsed.location === "string" ? sanitize(parsed.location) : null,
122+
description:
123+
typeof parsed.description === "string"
124+
? sanitize(parsed.description)
125+
: null,
126+
isAllDay: typeof parsed.isAllDay === "boolean" ? parsed.isAllDay : !startTime,
127+
};
128+
}
129+
130+
function mapApiError(status: number, msg: string): Error {
131+
switch (status) {
132+
case 400:
133+
return new Error(
134+
"Bad request to Gemini API. The content may be too long or unsupported.",
135+
);
136+
case 401:
137+
return new Error(
138+
"Invalid Gemini API key. Check your key in extension options.",
139+
);
140+
case 403:
141+
return new Error(
142+
"Gemini API access denied. Verify the API is enabled and billing is active.",
143+
);
144+
case 429:
145+
return new Error(
146+
"Gemini API rate limit exceeded. Please wait and try again.",
147+
);
148+
case 500:
149+
case 503:
150+
return new Error("Gemini API is temporarily unavailable. Try again later.");
151+
default:
152+
return new Error(`Gemini API error (${status}): ${msg}`);
153+
}
154+
}
155+
156+
function isRetryable(err: Error): boolean {
157+
return (
158+
err.message.includes("rate limit") ||
159+
err.message.includes("temporarily unavailable") ||
160+
err.message.includes("Failed to fetch")
161+
);
162+
}
163+
164+
export async function parseEventFromText(
165+
text: string,
166+
): Promise<EventDetails> {
167+
const truncated = truncateForAPI(text);
168+
const prompt = buildPrompt(
169+
`Extract event details from the following text.\n\nText:\n${truncated}`,
170+
);
171+
return callGemini([{ text: prompt }]);
172+
}
173+
174+
export async function parseEventFromImage(
175+
dataUrl: string,
176+
): Promise<EventDetails> {
177+
const prompt = buildPrompt(
178+
"Look at this image and extract event details from it.",
179+
);
180+
const pureBase64 = dataUrl.replace(/^data:image\/\w+;base64,/, "");
181+
return callGemini([
182+
{ inlineData: { mimeType: "image/png", data: pureBase64 } },
183+
{ text: prompt },
184+
]);
185+
}

src/lib/storage.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { StorageSettings } from "../types";
2+
3+
const DEFAULTS: StorageSettings = {
4+
geminiApiKey: "",
5+
geminiModel: "gemini-2.5-flash",
6+
defaultDuration: 60,
7+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
8+
};
9+
10+
export async function getSettings(): Promise<StorageSettings> {
11+
const items = await chrome.storage.sync.get(DEFAULTS);
12+
return items as StorageSettings;
13+
}
14+
15+
export async function saveSettings(
16+
partial: Partial<StorageSettings>,
17+
): Promise<void> {
18+
await chrome.storage.sync.set(partial);
19+
}

0 commit comments

Comments
 (0)