Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ WatDesk is an independent project maintained by Jelly-RayTian. It is not an offi

With the browser permissions you grant, WatDesk accesses supported University of Waterloo LEARN, Crowdmark and Odyssey pages or APIs to organize your enrolled courses, deadlines, quizzes, exams, released grades and submission metadata. This can include course names and identifiers, assessment titles and links, dates, scores, submission status, exam room/seat information, and account names or identifiers displayed by those services. Account identifiers help prevent records from different signed-in accounts being combined. Relevant page content is read and parsed for these features; WatDesk does not collect general browsing history.

Optional CS135 access reads the public course schedule and submission metadata available to your authorized account. Optional Möbius access reads already-open activity settings pages, such as published availability, duration and explicit completion information. The optional Waterloo sign-in helper checks an already-entered university username and may advance the username-only Next step. It does not read password or verification-code values or complete those steps. WatDesk does not start quizzes, request exceptions, upload answers or submit coursework.
Optional CS135 access reads the public course schedule and submission metadata available to your authorized account. Optional Möbius access reads already-open activity settings pages, such as published availability, duration and explicit completion information. The optional Waterloo sign-in helper checks an already-entered university username and may advance the username-only Next step. It does not read password or verification-code values or complete those steps. WatDesk does not start quizzes, request exceptions, upload answers or submit coursework. Optional course-outline access reads the signed-in outline.uwaterloo.ca list of your own courses and each outline's "Assessments & Activities" table to add explicitly dated assessments; nothing is written back and dates that are not published are never guessed.

You may also enter personal items, class schedules, notes, completion marks, reminder preferences and grade-estimation weights. These are used only for the features you select. Synchronization times, source status and error details support freshness information and troubleshooting.

Expand Down Expand Up @@ -44,7 +44,7 @@ This policy will be updated when data handling changes. The effective date above

## 中文说明

WatDesk 是由 Jelly-RayTian 维护的独立 Edge 扩展,非滑铁卢大学官方产品。为整理你的课程、截止事项、测验、考试、成绩与提交状态,扩展会在授权范围内读取 LEARN、Crowdmark、Odyssey,以及你选择启用的 CS135 或 Möbius。相关记录可能包含平台显示的账号名称或标识、课程与考核信息、成绩、考试教室和座位。账号标识用于防止混合不同账号的数据,不采集一般浏览历史。
WatDesk 是由 Jelly-RayTian 维护的独立 Edge 扩展,非滑铁卢大学官方产品。为整理你的课程、截止事项、测验、考试、成绩与提交状态,扩展会在授权范围内读取 LEARN、Crowdmark、Odyssey,以及你选择启用的 CS135 或 Möbius。相关记录可能包含平台显示的账号名称或标识、课程与考核信息、成绩、考试教室和座位。账号标识用于防止混合不同账号的数据,不采集一般浏览历史。 可选的“课程大纲”来源会读取你已登录的 outline.uwaterloo.ca 中属于你的课程列表及各大纲的“Assessments & Activities”表,仅添加明确写出的日期;不会写入任何内容,也不会猜测未公布的日期。

课程数据、个人事项、备注、完成标记、提醒设置和来源状态保存在当前 Edge 配置文件的本地扩展存储中。WatDesk 没有接收这些学业数据的开发者后台,不向广告、统计或 AI 服务上传这些记录。为同步,浏览器使用现有登录状态直接通过 HTTPS 访问对应学校或 Crowdmark 服务;平台按自身隐私政策处理请求。扩展不收集或保存密码、原始会话 Cookie、访问令牌或验证码。可选学校登录辅助只检查已输入的大学用户名并推进用户名步骤,不读取或处理密码和验证码。

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
| Source | What WatDesk reads |
|---|---|
| LEARN, Crowdmark, Odyssey | Deadlines, quizzes, released grades, submission status, exam time, room and seat |
| Course outlines (optional) | Dated assessments from the outline of every course you are in, read from outline.uwaterloo.ca while you are signed in. Items without a published date or time are never guessed |
| CS 135 (optional) | Course calendar, assignments, quizzes and submission records |
| CS 145, CS 240, CS 241 (optional) | Assignment deadlines and exam times from the course website |
| 22 more CS course sites (optional) | Link only, shown in *Course resources* |
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
| 来源 | WatDesk 读取的内容 |
|---|---|
| LEARN、Crowdmark、Odyssey | 截止时间、测验、已公布成绩、提交状态、考试时间、教室和座位 |
| 课程大纲(可选) | 读取你已登录的 outline.uwaterloo.ca 里每门课大纲中写明日期的考核与作业;没有公布日期或时间的不会猜测 |
| CS 135(可选) | 课程日历、作业、测验和提交记录 |
| CS 145、CS 240、CS 241(可选) | 课程网站上的作业截止时间和考试时间 |
| 另外 22 个 CS 课程网站(可选) | 仅链接,显示在“课程资源”里 |
Expand Down
4 changes: 2 additions & 2 deletions docs/store-permissions.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# WatDesk 1.9.0 — Edge Add-ons permission justifications
# WatDesk 1.10.0 — Edge Add-ons permission justifications

Single purpose: organize a Waterloo student's own coursework, exams and grades from sites they are already signed in to. Academic data stays in local extension storage. A usage count is on by default, can be disabled in setup or Settings, and sends only a random install ID and version to the developer’s Cloudflare Worker. No academic data is sent; there is no remote code.

Expand All @@ -11,7 +11,7 @@ Single purpose: organize a Waterloo student's own coursework, exams and grades f
| `scripting` | Runs read-only page readers on LEARN, Crowdmark and (optionally) Möbius pages the user granted. Never submits coursework or starts quizzes. |
| `offscreen` | Parses fetched HTML and Course Schedule PDFs locally, because the service worker has no DOM. |
| Host `learn.uwaterloo.ca`, `app.crowdmark.com`, `odyssey.uwaterloo.ca` | Reads the user's own deadlines, exams and released grades from these services. |
| Optional hosts `student.cs.uwaterloo.ca`, `adfs.uwaterloo.ca`, `uwmo.mobius.cloud` | Requested only when the user turns on CS 135 or optional CS course sites, the username-only sign-in helper, or Möbius. The helper never reads passwords or verification codes. |
| Optional hosts `student.cs.uwaterloo.ca`, `adfs.uwaterloo.ca`, `uwmo.mobius.cloud`, `outline.uwaterloo.ca` | Requested only when the user turns on CS 135 or optional CS course sites, the username-only sign-in helper, Möbius, or course outlines. The outline source reads only the signed-in user's own course list and each outline's assessments table; it never writes. The helper never reads passwords or verification codes. |

Extension-page CSP: `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; connect-src 'self' + source hosts + https://watdesk-stats.guojelly123.workers.dev` (no remote scripts or fonts; connections are limited to declared source hosts and the usage counter).
Icons: 16, 32, 48 and 128 px are bundled. The store listing also needs a separate 300x300 logo and 1280x800 screenshots, which are not part of the package.
18 changes: 18 additions & 0 deletions fixtures/outline-sample.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html><html><head><title>Fall 2026: Communications in Mathematics and Computer Science</title></head><body>
<h1>ENGL 119 Fall 2026</h1>
<h2 id="class_schedule" class="header">Class Schedule</h2>
<table class="multitable"><thead><th>Course</th><th>Meet Days</th></thead><tbody><tr><td>ENGL 119 009 [LEC]</td><td>Tuesday, Thursday</td></tr></tbody></table>
<h2 id="tentative_class_plan" class="header">Tentative Class Plan</h2><div class="html-block"><p>November 3: <strong>In-Class Proposal Test</strong></p></div>
<h2 id="assessments_amp_activities" class="header">Assessments &amp; Activities</h2>
<div class="multitable-container"><table class="multitable"><thead>
<th>Component / Activity</th><th>Date or Due Date</th><th>Location / Submission Method</th><th>Weight (%)</th></thead><tbody>
<tr><td>Participation and Attendance</td><td></td><td>In-Person</td><td>15%</td></tr>
<tr><td>Video Podcast</td><td>October 27</td><td>LEARN Drop Box</td><td>15%</td></tr>
<tr><td>Midterm 1</td><td>Available: Tuesday, October 20, 2026 at 12:01 AM<br>Due: Tuesday, October 20, 2026 at 11:59 PM</td><td>LEARN Quiz</td><td>20%</td></tr>
<tr><td>Proposal Test</td><td>November 3</td><td>In-Person</td><td>10%</td></tr>
<tr><td>Recommendation Report</td><td>November 17th and November 19th</td><td>In-Person</td><td>20%</td></tr>
<tr><td>Poster Presentation</td><td>December 3rd and 8th</td><td>In-Person</td><td>15%</td></tr>
</tbody></table></div>
<h2 id="late_missed_content" class="header">Late / Missed Content</h2>
<table><tr><td>Unrelated</td><td>November 30</td></tr></table>
</body></html>
7 changes: 4 additions & 3 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_extName__",
"description": "__MSG_extDescription__",
"version": "1.9.0",
"version": "1.10.0",
"minimum_chrome_version": "120",
"permissions": [
"storage",
Expand All @@ -15,7 +15,8 @@
"optional_host_permissions": [
"https://student.cs.uwaterloo.ca/*",
"https://adfs.uwaterloo.ca/*",
"https://uwmo.mobius.cloud/*"
"https://uwmo.mobius.cloud/*",
"https://outline.uwaterloo.ca/*"
],
"background": {
"service_worker": "worker.js",
Expand Down Expand Up @@ -44,7 +45,7 @@
"https://odyssey.uwaterloo.ca/*"
],
"content_security_policy": {
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self' https://learn.uwaterloo.ca https://app.crowdmark.com https://odyssey.uwaterloo.ca https://student.cs.uwaterloo.ca https://uwmo.mobius.cloud https://watdesk-stats.guojelly123.workers.dev"
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self' https://learn.uwaterloo.ca https://app.crowdmark.com https://odyssey.uwaterloo.ca https://student.cs.uwaterloo.ca https://uwmo.mobius.cloud https://outline.uwaterloo.ca https://watdesk-stats.guojelly123.workers.dev"
},
"default_locale": "en"
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "watdesk",
"version": "1.9.0",
"version": "1.10.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/background-html.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {withTimeout} from '../core/sync';
export type HtmlKind='rows'|'odyssey'|'cs135';
export type HtmlKind='rows'|'odyssey'|'cs135'|'outline';
let parsing:Promise<unknown>=Promise.resolve();
// Serialize the short-lived local parser so one caller cannot close another's document.
export function parseBackgroundHtml<T>(kind:HtmlKind,html:string):Promise<T>{
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export async function clickPlatformLogin(p:string){
}
const jobs=new Map<number,Promise<void>>();
export function autoLogin(p:SourcePlatform,tabId:number,check:()=>void=()=>{}):Promise<void>{
if(p==='cs135'||p==='mobius')return Promise.resolve();
if(p==='cs135'||p==='mobius'||p==='outline')return Promise.resolve();
const existing=jobs.get(tabId);if(existing)return existing;
const job=(async()=>{
let clickedAt='';const deadline=Date.now()+30000;
Expand Down
96 changes: 96 additions & 0 deletions src/adapters/outline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type {AppState,Entry} from '../domain';
import {campusInstant} from '../core/util';
import {backgroundHtml,parseBackgroundHtml} from './background-html';
// Course outlines (outline.uwaterloo.ca) list every assessment in one "Assessments & Activities" table. Dates are free text,
// so only explicit calendar dates are used: "TBD", weekday patterns and ranges are skipped, never guessed.
const BASE='https://outline.uwaterloo.ca';
const MONTHS=['january','february','march','april','may','june','july','august','september','october','november','december'];
const pad=(n:number)=>String(n).padStart(2,'0');
const month=(name:string)=>MONTHS.findIndex(m=>m.startsWith(name.toLowerCase().slice(0,3)));
export interface OutlineListItem {courses:string;url:string;term?:string;title?:string}
export interface OutlinePage {heading:string;rows:string[][]}
interface Moment {y:number;m:number;d:number;time:string|null}
const MONTH_NAME='(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)';
function clock(text:string){const m=text.match(/(\d{1,2})(?::(\d{2}))?\s*([ap])\.?m\b/i);if(!m)return null;const h=Number(m[1]);if(h<1||h>12)return null;return `${pad(h%12+(m[3].toLowerCase()==='p'?12:0))}:${m[2]??'00'}`}
const valid=(y:number,m:number,d:number)=>{const t=new Date(Date.UTC(y,m,d));return t.getUTCFullYear()===y&&t.getUTCMonth()===m&&t.getUTCDate()===d};
// "October 6 & 8", "November 17th and November 19th", "Wednesday October 21", "Friday, September 11, 2026 at 11:00 PM"
export function parseOutlineDates(cell:string,defaultYear:number):Moment[]{
const text=cell.replace(/ /g,' ').replace(/\*/g,' ').replace(/\s+/g,' ').trim();
if(!text||/\b(?:tbd|to be (?:scheduled|announced|determined)|registrar)\b/i.test(text))return [];
// A range such as "October 10-October 18" is not a single assessment date.
if(new RegExp(`${MONTH_NAME}\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?\\s*[-–—]\\s*(?:${MONTH_NAME}\\.?\\s+)?\\d{1,2}`,'i').test(text))return [];
const found:Moment[]=[];let lastMonth=-1,joined=false,i=0;
const token=new RegExp(`\\b(${MONTH_NAME})\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?(?:,?\\s*(\\d{4}))?|(\\d{1,2})(?:st|nd|rd|th)?\\b|(&|\\band\\b|,)`,'gi');
let m:RegExpExecArray|null;
while((m=token.exec(text))){
if(m[1]){lastMonth=month(m[1]);const y=m[3]?Number(m[3]):defaultYear,d=Number(m[2]);if(lastMonth>=0&&valid(y,lastMonth,d))found.push({y,m:lastMonth,d,time:null});joined=false;i=token.lastIndex}
else if(m[5]){joined=true}
else if(m[4]&&joined&&lastMonth>=0&&/^\s*(?:&|and|,)\s*$/i.test(text.slice(i,m.index).replace(/\d+(?:st|nd|rd|th)?/g,'')) ){const d=Number(m[4]);if(valid(defaultYear,lastMonth,d)){const prev=found[found.length-1];found.push({y:prev?prev.y:defaultYear,m:lastMonth,d,time:null})}joined=false;i=token.lastIndex}
}
const t=found.length===1?clock(text):null;if(t)found[0].time=t;
return found;
}
const slug=(s:string)=>s.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'').slice(0,60);
const keep=(iso:string,now:number)=>Date.parse(iso)>=now-14*86400000;
export function outlineYear(heading:string,now=Date.now()){const m=heading.match(/\b(?:Fall|Winter|Spring)\s+(\d{4})\b/i)||heading.match(/\b(20\d{2})\b/);return m?Number(m[1]):new Date(now).getFullYear()}
export function parseOutlineRows(rows:string[][],opts:{course:string;outlineId:string;year:number;zone?:string;now?:number}):Entry[]{
const {course,outlineId,year}=opts,zone=opts.zone||'America/Toronto',now=opts.now??Date.now();
const header=rows.findIndex(r=>r.length>=2&&/component|activity|assessment/i.test(r[0])&&/date|due/i.test(r[1]));
if(header<0)throw Error('outline-format');
const url=`${BASE}/viewer/view/${outlineId}#assessments_amp_activities`;const out:Entry[]=[];
for(const row of rows.slice(header+1)){
const title=(row[0]||'').replace(/\s+/g,' ').trim();if(!title||title.length>200)continue;
const dueText=row[1]||'',how=row[2]||'';
// "Available: … Due: …" windows (online quizzes): both ends must be explicit dates.
const parts=dueText.split(/\bDue:?\s*/i);const windowed=/\bAvailable\b/i.test(dueText)&&parts.length===2;
const dates=parseOutlineDates(windowed?parts[1]:dueText,year);if(!dates.length)continue;
const avail=windowed?parseOutlineDates(parts[0],year)[0]:undefined;
const inPerson=/in[- ]?(?:person|class)/i.test(how)||/\bin[- ]class\b/i.test(title);
const exam=/\b(?:midterm|exam|test)\b/i.test(title)&&inPerson;
dates.forEach((when,idx)=>{
const day=`${when.y}-${pad(when.m+1)}-${pad(when.d)}`;
const stampAt=(w:Moment)=>campusInstant(`${w.y}-${pad(w.m+1)}-${pad(w.d)}T${w.time||'23:59:59'}`,zone);
const at=stampAt(when);if(!at||!keep(at,now))return;
const label=dates.length>1?`${title} (${idx+1}/${dates.length})`:title;
const dateOnly=when.time?undefined:day;
const base={id:`outline:${outlineId}:${slug(title)}:${day}`,platform:'outline' as const,course,title:label,status:'open' as const,url,seenIn:['course-outline'],...(dateOnly?{dateOnly}:{})};
if(exam)out.push({...base,type:'exam',category:'quiz',start:at,end:null,due:null});
else out.push({...base,type:'task',category:/\b(?:quiz|test|midterm|exam)\b/i.test(title)?'quiz':'assignment',start:avail?stampAt(avail):null,end:null,due:at});
});
}
return out;
}
const termCode=(t?:string)=>/^\d+$/.test(t||'')?Number(t):0;
const outlineCourse=(courses:string)=>{const m=courses.match(/\b([A-Z]{2,8})\s*(\d{3}[A-Z]?)\b/);return m?`${m[1]} ${m[2]}`:null};
export interface OutlineDeps {list():Promise<unknown>;page(url:string):Promise<OutlinePage>}
async function listOutlines():Promise<unknown>{
const r=await fetch(`${BASE}/viewer/api/search/?q=`,{credentials:'include',redirect:'manual',cache:'no-store',headers:{Accept:'application/json'},signal:AbortSignal.timeout(20000)});
if(r.type==='opaqueredirect'||r.status>=300&&r.status<400||r.status===401||r.status===403)throw Error('signed-out');
if(!r.ok)throw Error('outline-http:'+r.status);
if(!r.headers.get('content-type')?.includes('json'))throw Error('signed-out');
return r.json();
}
const liveDeps:OutlineDeps={list:listOutlines,page:async url=>parseBackgroundHtml<OutlinePage>('outline',await backgroundHtml(url))};
export async function readOutline(state:AppState,deps:OutlineDeps=liveDeps,now=Date.now()){
if(!state.settings.outlineEnabled)throw Error('outline-disabled');
const raw=await deps.list();if(!Array.isArray(raw))throw Error('outline-format');
const items=raw.filter((v):v is OutlineListItem=>!!v&&typeof v==='object'&&typeof (v as OutlineListItem).courses==='string'&&/^\/viewer\/view\/[a-z0-9]{4,12}\/?$/.test(String((v as OutlineListItem).url)));
const latest=Math.max(0,...items.map(v=>termCode(v.term)));
const current=items.filter(v=>!latest||termCode(v.term)===latest).slice(0,20);
if(!current.length)throw Error('outline-no-courses');
const entries:Entry[]=[],failed:string[]=[];
for(const item of current){
const course=outlineCourse(item.courses);const id=item.url.replace(/\/$/,'').split('/').pop()!;if(!course)continue;
try{
const page=await deps.page(`${BASE}${item.url}`);
entries.push(...parseOutlineRows(page.rows,{course,outlineId:id,year:outlineYear(page.heading,now),zone:state.settings.zone,now}));
}catch(e){
if(e instanceof Error&&e.message==='signed-out')throw e;
failed.push(`${course}=${e instanceof Error&&e.message==='outline-format'?'layout':'network'}`);
// Keep the last good read of a course whose page changed or failed to load.
entries.push(...state.entries.filter(v=>v.platform==='outline'&&v.id.startsWith(`outline:${id}:`)&&!v.gone).map(v=>({...v,readState:'retained' as const})));
}
}
if(failed.length===current.length)throw Error(`outline-unavailable:${failed.join(',')}`);
return {entries,courses:[],warning:failed.length?`outline-partial:${failed.join(',')}`:undefined};
}
Loading
Loading