Skip to content

Commit 9947b20

Browse files
authored
Merge pull request #2 from CCU-Class/oauth
New feature: Oauth
2 parents 9088595 + 178708d commit 9947b20

File tree

7 files changed

+208
-5
lines changed

7 files changed

+208
-5
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ src/css/output.css
44
dist/
55
*.zip
66
*.crx
7-
*.pem
7+
*.pem
8+
.env

example.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VITE_NOT_CHROME_CLIENT_ID=

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"dependencies": {
33
"@tailwindcss/cli": "^4.0.17",
4+
"dotenv": "^16.4.7",
45
"jsonpath-plus": "^10.3.0",
56
"rollup": "^4.38.0",
67
"tailwindcss": "^4.0.17"

src/js/popup.js

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,20 @@ function initializeInputs() {
66
// set year max to year + 1
77
document.getElementById("yearInput").max = year + 1;
88
document.getElementById("monthInput").value = month;
9+
// add browser to session storage
10+
const ua = navigator.userAgent;
11+
const isChrome = ua.includes("Chrome") && !ua.includes("Edg");
12+
sessionStorage.setItem("isChrome", isChrome ? "true" : "false");
13+
if (isChrome) {
14+
// show checkbox if auto import is enabled
15+
const autoImportCheckbox = document.getElementById("autoImportContainer");
16+
autoImportCheckbox.classList.remove("invisible");
17+
autoImportCheckbox.querySelector("input").checked = true; // default checked
18+
} else {
19+
// remove checkbox if not chrome
20+
document.getElementById("autoImportContainer").remove();
21+
}
922
}
10-
1123
async function fetchCalendarData(sesskey) {
1224
const year = parseInt(document.getElementById("yearInput").value);
1325
const month = parseInt(document.getElementById("monthInput").value);
@@ -73,6 +85,159 @@ function setupEventListeners() {
7385
});
7486
});
7587
});
88+
89+
document.getElementById("importBtn").addEventListener("click", async () => {
90+
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
91+
chrome.tabs.sendMessage(tabs[0].id, { action: "get_sesskey" }, async (res) => {
92+
const sesskey = res?.sesskey;
93+
if (!sesskey) {
94+
console.log("no touch sesskey");
95+
document.getElementById("result").textContent = "無法獲取 sesskey,請先登入 Moodle";
96+
return;
97+
}
98+
99+
try {
100+
const events = await fetchCalendarData(sesskey);
101+
insertEventsToGCal(events);
102+
document.getElementById("result").textContent = `成功匯入 ${events.length} 個行事曆事件`;
103+
} catch (error) {
104+
console.error(error);
105+
document.getElementById("result").textContent = "發生錯誤,請稍後再試";
106+
}
107+
});
108+
});
109+
});
110+
}
111+
112+
async function insertEventsToGCal(events) {
113+
// Google OAuth token
114+
const token = await getGoogleAuthToken();
115+
116+
// add event Google Calendar
117+
for (const event of events) {
118+
try {
119+
await addEventToCalendar(token, event);
120+
console.log("add Event Success", event.title);
121+
} catch (err) {
122+
console.error("add Event error:", event.title, err);
123+
}
124+
}
125+
126+
// batch API,done after...
127+
}
128+
129+
async function launchWebAuthFlowForGoogle() {
130+
return new Promise((resolve, reject) => {
131+
// Except Chrome Client ID
132+
const clientId = import.meta.env.VITE_NOT_CHROME_CLIENT_ID;
133+
const scope = "https://www.googleapis.com/auth/calendar.events";
134+
135+
const redirectUri = `https://${chrome.runtime.id}.chromiumapp.org/`;
136+
137+
const authUrl =
138+
"https://accounts.google.com/o/oauth2/v2/auth" +
139+
`?response_type=token` +
140+
`&client_id=${encodeURIComponent(clientId)}` +
141+
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
142+
`&scope=${encodeURIComponent(scope)}` +
143+
`&prompt=consent`;
144+
145+
chrome.identity.launchWebAuthFlow(
146+
{
147+
url: authUrl,
148+
interactive: true,
149+
},
150+
(responseUrl) => {
151+
if (chrome.runtime.lastError) {
152+
console.error("launchWebAuthFlow error:", chrome.runtime.lastError);
153+
return reject(chrome.runtime.lastError);
154+
}
155+
if (!responseUrl) {
156+
return reject("Unable to obtain authorization result");
157+
}
158+
159+
const urlFragment = responseUrl.split("#")[1];
160+
if (!urlFragment) {
161+
return reject("Unable to retrieve token from callback URL");
162+
}
163+
const params = new URLSearchParams(urlFragment);
164+
const token = params.get("access_token");
165+
if (!token) {
166+
return reject("Postback URL has no access_token");
167+
}
168+
169+
resolve(token);
170+
}
171+
);
172+
});
173+
}
174+
175+
// get token
176+
function getGoogleAuthToken() {
177+
return new Promise((resolve, reject) => {
178+
const ua = navigator.userAgent;
179+
const isChrome = ua.includes("Chrome") && !ua.includes("Edg");
180+
console.log("ischrome", isChrome);
181+
182+
// 1. First check if getAuthToken is available (most common in Chrome)
183+
if (isChrome && chrome.identity && chrome.identity.getAuthToken) {
184+
console.log("Trying chrome.identity.getAuthToken...");
185+
chrome.identity.getAuthToken({ interactive: false }, (token) => {
186+
if (chrome.runtime.lastError || !token) {
187+
chrome.identity.getAuthToken({ interactive: true }, (token2) => {
188+
if (chrome.runtime.lastError || !token2) {
189+
console.error("lastError:", chrome.runtime.lastError);
190+
return reject("User denied authorization or an error occurred");
191+
}
192+
resolve(token2);
193+
});
194+
} else {
195+
resolve(token);
196+
}
197+
});
198+
199+
// 2. Otherwise try launchWebAuthFlow (Edge may support it)
200+
} else if (chrome.identity && chrome.identity.launchWebAuthFlow) {
201+
console.log("Trying chrome.identity.launchWebAuthFlow...");
202+
launchWebAuthFlowForGoogle()
203+
.then((token) => resolve(token))
204+
.catch((err) => reject(err));
205+
} else {
206+
reject("This browser does not support the chrome.identity API");
207+
}
208+
});
209+
}
210+
211+
// Google Calendar API add Event
212+
async function addEventToCalendar(token, event) {
213+
// Google Calendar API call
214+
// Docs: https://developers.google.com/calendar/api/v3/reference/events/insert
215+
const res = await fetch("https://www.googleapis.com/calendar/v3/calendars/primary/events", {
216+
method: "POST",
217+
headers: {
218+
Authorization: `Bearer ${token}`,
219+
"Content-Type": "application/json",
220+
},
221+
body: JSON.stringify({
222+
summary: event.title,
223+
description: event.description,
224+
start: {
225+
dateTime: event.startDate.toISOString(),
226+
timeZone: "Asia/Taipei",
227+
},
228+
end: {
229+
dateTime: event.endDate.toISOString(),
230+
timeZone: "Asia/Taipei",
231+
},
232+
}),
233+
});
234+
235+
if (!res.ok) {
236+
const errText = await res.text();
237+
throw new Error(`API Error: ${res.status}, ${errText}`);
238+
}
239+
240+
return res.json();
76241
}
77242

78243
// Initialize when DOM is fully loaded

src/manifest.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,16 @@
1818
"128": "public/ccuclass_icon.png"
1919
},
2020
"permissions": [
21-
"cookies"
21+
"cookies",
22+
"identity"
2223
],
2324
"host_permissions": [
2425
"https://ecourse2.ccu.edu.tw/*"
25-
]
26-
}
26+
],
27+
"oauth2": {
28+
"client_id": "529259850785-eg40qfiu14k4806pocqtgoqprs4v5njn.apps.googleusercontent.com",
29+
"scopes": [
30+
"https://www.googleapis.com/auth/calendar.events"
31+
]
32+
}
33+
}

src/popup.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ <h1 class="text-xl font-bold">Moodle Calendar</h1>
3232
>
3333
取得行事曆
3434
</button>
35+
<button
36+
id="importBtn"
37+
class="bg-gray-400 text-white px-4 py-2 my-2 rounded cursor-pointer hover:bg-gray-700"
38+
>
39+
匯入行事曆
40+
</button>
41+
<div class="flex items-center my-2 invisible" id="autoImportContainer">
42+
<label for="autoImportToggle" class="mr-2 text-sm text-gray-700">自動匯入</label>
43+
<input
44+
type="checkbox"
45+
id="autoImportToggle"
46+
class="w-5 h-5 text-violet-500 border-gray-300 rounded focus:ring-violet-500"
47+
checked
48+
/>
49+
</div>
3550
<pre id="result" class="text-sm whitespace-pre-wrap"></pre>
3651
<div class="text-gray-600 text-sm text-center">
3752
This tool helps you export your Moodle calendar events to a .ics file.

0 commit comments

Comments
 (0)