diff --git a/google-sites/PROGRESS.md b/google-sites/PROGRESS.md new file mode 100644 index 0000000..4f2a4e3 --- /dev/null +++ b/google-sites/PROGRESS.md @@ -0,0 +1,75 @@ +# Rebuild progress — Google Sites draft + +Working notes for whoever continues the rebuild (person or Claude session). The live site is +; the editor is the same site with `/edit`. +Target look: the canvas preview and the recipes in +`README.md` §2.3. Edits happen in the **draft** — nothing is live until someone presses Publish. +The old public site belongs to another account and is left as it is: +it is now "the old website", linked from the new site's menu. + +## Done + +- The redesign below is published (docchula.com visitors see it). Still in the draft only: the **Old website** + menu link and the Home background anchor change. +- Theme *MDCU Research 2026*: white top navigation, amber underline on the current page, + amber filled buttons on dark (Style 3) sections. +- **Widgets** (Apps Script project *MDCU site widgets*, owner punnawit.wsr@docchula.com, permissions approved): + - Public deployment (*Who has access: Anyone*) — used by every embed: + `https://script.google.com/a/macros/docchula.com/s/AKfycbxlmnnK6IIt1h-rzJELCRfxEcTbSrcWrGe-6LvC7tZD09l8dghRw050lntIXlAVMkPZ/exec` + - Members deployment (*Anyone within docchula.com*) — opened by "Sign in with docchula.com"; its URL is + `MEMBERS_APP_URL` in `Config.gs`. + - Checked from a signed-out client: the public copy sends titles, departments and seats only (every project + `locked`, no advisor/contact/timeline/description); the members copy redirects to docchula.com sign-in. +- **Home**: hero = native text + `hero-actions` embed (buttons + live numbers) on the left, `open-now` embed + (three newest projects) on the right, on a background image of the translucent club mark + (`assets/hero-logo-background.png`, anchored centre, readability adjustment off). 01–03 native. 04 Events = header + `events-home` embed (Upcoming card + + two "What's new" teasers that link to the Events page). 05 Showcase = header + `showcase-home` embed. + 06 Get involved native. +- **Opportunities**: hero, three steps, `opportunities` embed (project finder) in place of the old sheet embed + (the sheet itself is unchanged in Drive), For faculty. +- **Events**: hero, `events` embed (Upcoming), Previous events. The First Meet 2026 and How to Design posts were + moved here from Home (same posters and full text) above Statistics, AI and Lab tour 2. +- **Showcase**: dark hero with `showcase-stats` embed and a native "Add your presentation +" button (links to the + showcase form), `showcase` embed (search/filter browser), then the year archive (untouched). +- **Footer** (site-wide): three columns — name + description (+ © line), Contact (email, Instagram), Find us. +- **Old website** link at the end of the menu (opens in a new tab). +- Checked before going public: the About page's personal contact is already on the old public site's Contact us + page; the hidden pages (Conference/Abstract templates, test, Alumni Hall of Fame) hold nothing private. + +## To do + +1. Owner: **Publish** the draft, then **Share › General access › Published site › Public** so visitors without a + docchula.com account see the new site (the owner chose this over rebuilding the old public site). +2. Add the faculty form link when it exists (Opportunities "For faculty"; `facultyForm` in `Config.gs`). +3. Move or share the Apps Script project and the *Website data* sheet with researchclub@docchula.com so they + survive graduation. The script must stay owned by a docchula.com account (member check). +4. Optional data clean-up in *Website data*: the ARC 2024 showcase row is not ticked *Approved* (so the site shows + 30, not 31), and MRPC #20's presenter is listed as "MDCU student". +5. The one-time setup project (*MDCU one-time setup*) can be deleted. + +## Maintaining the widgets + +- Code changes: edit `google-sites/apps-script/`, run `node --test google-sites/test/*.test.mjs`, paste into the + project, save, then **Deploy › Manage deployments** and give *each* deployment a **New version**, checking its + *Who has access* (public = Anyone, members = Anyone within docchula.com). Apps Script stores that setting in the + version, so creating a version from the wrong dialog can change it. +- Links inside embeds open in a new tab: Google Sites runs embeds in a frame that may not navigate the page. +- Embed sizes (desktop): hero-actions 661×196, open-now 464×520, events-home 1154×634, showcase-home 1154×731, + opportunities 1154×1149, events 1154×331, showcase-stats ~760×120, showcase 1154×1399. Sites keeps the ratio + on phones; the widgets switch to compact layouts below 640 px. + +## Editor tips (for browser automation) + +- Styled text: dispatch a synthetic `paste` ClipboardEvent with `text/html` on the focused text box — + the editor keeps inline font, size and colour and handles Thai. Don't use the system clipboard. + Pasted text takes the formatting of what it replaces; fix weight with cmd+a / cmd+b afterwards if needed. +- Paste replaces the selection but can leave an empty last line; remove it by putting the caret at the end + of the previous line and sending Delete (never Backspace — it can eat the last character). +- Images: a synthetic `drop` DragEvent carrying a `File` uploads and places the image where it is dropped + (no file dialog, no clipboard). Compute the drop point right before dispatching — the canvas can scroll. +- Side-by-side placement: double-click an empty spot in a section to open the insert wheel there; the new item + lands in that column. Native buttons can be dragged next to a column with synthetic mouse events. +- Moving components: synthetic mouse events (mousedown → several mousemoves → mouseup) work, but keep + the drop point away from the top/bottom edge or the canvas auto-scrolls. Resizing handles need real + mouse drags. Items stacked in one column share its width. +- The small toolbar above a group says "Delete group" — use the item's own toolbar to delete one item. diff --git a/google-sites/README.md b/google-sites/README.md new file mode 100644 index 0000000..fb9a241 --- /dev/null +++ b/google-sites/README.md @@ -0,0 +1,240 @@ +# MDCU Research Division — Google Sites upgrade + +The site stays on **Google Sites**, so anyone on the team can keep editing pages — and adding photos and +posters — the way they do now. What changes: + +- **New look and structure** — one custom theme, five pages (Home, Opportunities, Events, Showcase, About), + with the old site's messages kept: the hero subtitle, the "MDCU Research club" quote, both mission + statements, the "Our impact" lines and every past activity post. +- **Live sections** for the things that go stale. They read Google Sheets and Forms through a small Apps Script + web app (`apps-script/`) embedded in the pages. Edit a row → the site updates within ~5 minutes. + +| Section | Content comes from | Updated by | +| --- | --- | --- | +| Page wording, mission, Executive Board, footer | Google Sites editor | PR & Content team | +| **Activity posts** ("What's new", "Previous events") with photos and posters | Google Sites editor — *Insert › Images* | Activity team | +| Upcoming events (auto-hide after the date) | **Website data** sheet › `Events` tab | Activity team | +| Opportunities finder — titles public, **details only for docchula.com sign-ins** | The existing **Research opportunities** sheet (faculty form) | Faculty, via the form | +| Research Showcase | Showcase form → **Website data** › `Showcase` tab, after a tick in `Approved` | Students submit, team approves | + +--- + +## 1. One-time setup (about an hour, done once) + +### 1.1 Own everything with the club account + +Create the sheet, form and Apps Script projects while signed in as **researchclub@docchula.com** +(or move them into a shared drive). If they belong to a student's personal account, the site breaks when +that student graduates — and the sign-in check in §1.4 only works when the owner is a docchula.com account. + +### 1.2 "Website data" sheet + +1. Create a Google Sheet called **Website data**. +2. Rename the first tab to `Events`, then **File › Import › Upload** `templates/events.csv` → *Replace current sheet*. + Past rows are harmless; only events dated today or later appear on the site. +3. Tidy the columns so nobody can type the wrong thing: + - `Date` → **Format › Number › Date** + - `Type` → **Data › Data validation** › dropdown: `Workshop, Lab tour, Research talk, Meetup, Other` + - `Status` → dropdown: `Open, Full, Closed, Cancelled` + - `Show on site` → **Insert › Checkbox** + - `Poster link` (optional) → a Drive link to the poster, shared as *Anyone with the link — Viewer*. +4. Share the sheet with the team as **Editor**. + +### 1.3 Showcase form + +Create a Google Form **Add my presentation to the MDCU Research Showcase**, restricted to docchula.com. +Use these question titles exactly (the widget finds columns by these words; the Thai part is optional): + +| Question title | Type | +| --- | --- | +| Presenter name / ชื่อผู้นำเสนอ | Short answer | +| Class (e.g. MDCU78) / รุ่น | Short answer | +| Conference name / ชื่องานประชุม | Short answer | +| Short name (e.g. VSS 2026) / ชื่อย่อ | Short answer | +| City, country / เมือง | Short answer | +| Conference date / วันที่นำเสนอ | Date | +| Format / รูปแบบ | Multiple choice: Oral presentation, Poster presentation, Other | +| Research title / ชื่องานวิจัย | Short answer | +| Research field / สาขา | Short answer | +| Keywords / คำสำคัญ | Short answer | +| Advisors / อาจารย์ที่ปรึกษา | Short answer | +| Abstract / บทคัดย่อ | Paragraph | +| Reflection / สิ่งที่ได้เรียนรู้ | Paragraph (optional) | +| Photo / รูป | File upload (optional, image) | +| Consent / ยินยอม | Checkbox, required: "I agree that my name, class, photo and research details are shown on the club website" | + +Then **Responses › Link to Sheets › Select existing spreadsheet › Website data**, rename the new tab to +`Showcase`, and add a last column titled `Approved` filled with checkboxes. Only ticked rows appear on the site. + +To bring over the 31 presentations from the old *Researcher Profile* pages, paste them under the matching +headers (see `templates/showcase-import.csv`) and tick `Approved` for those whose presenters gave consent. + +> Photos and posters from Drive only display if the file is shared as *Anyone with the link — Viewer*. +> Otherwise the widget quietly shows the presenter's initial instead, or leaves the poster out. + +### 1.4 Apps Script web app — one project, two deployments + +Opportunity details (advisor, contact, timeline, description, what to send) are only for MDCU students. +The script removes them on the server for anyone who isn't signed in with a **docchula.com** account, so they +never reach a signed-out visitor's browser. Titles, departments, topics and seat counts stay public. + +Google only tells a script who the visitor is in some setups, so the same project is deployed twice: + +| Deployment | Who has access | Used for | +| --- | --- | --- | +| **Public** | Anyone | Every embed on the site. Details are hidden unless Google reports a docchula.com visitor. | +| **Members** | Anyone within docchula.com | Opened by the "Sign in with docchula.com" button. Google asks for a docchula.com sign-in, then shows everything. | + +1. → **New project** (*MDCU site widgets*), owned by the club's docchula.com account. +2. **Project Settings** → tick *Show "appsscript.json" manifest file*, then paste `apps-script/appsscript.json`. +3. Create the files and paste their contents: `Parse.gs`, `Config.gs`, `Code.gs` (script files) and + `Widget.html`, `Styles.html`, `App.html` (HTML files — name them without the extension). + *(Or use [clasp](https://github.com/google/clasp): `clasp create --type webapp --rootDir google-sites/apps-script`, then `clasp push`.)* +4. In `Config.gs`, set `WEBSITE_DATA_SHEET_ID` (the long ID in the sheet's URL) and the two form links. +5. Run `authorize` once from the editor and approve the prompt (tick every box). Google words it as access to + your spreadsheets because opening a sheet by its ID has no read-only option; the script only reads them. It also + sees the visitor's email address, only to check that it ends in `@docchula.com` (it isn't stored). +6. **Deploy › New deployment › Web app** — *Execute as:* **Me**, *Who has access:* **Anyone**. This is the public URL + used in every embed. Then **Deploy › New deployment** again with *Who has access:* **Anyone within docchula.com**. +7. Paste the members URL into `MEMBERS_APP_URL` in `Config.gs`, save, and move both deployments to a new version + (below). Open the public URL with `?view=opportunities` on the end to check it works. + +If the whole site stays docchula.com-only, one deployment is enough: deploy it for *Anyone within docchula.com* and +leave `MEMBERS_APP_URL` blank. + +After a code change, use **Deploy › Manage deployments › Edit › New version** for each deployment, so the URLs stay the same. +To show a sheet edit immediately instead of within 5 minutes, run `clearCache` from the editor. + +--- + +## 2. Rebuilding the pages in Google Sites + +Edits stay in draft until someone presses **Publish**. Tell the other editors not to publish until the team +has reviewed the draft, and use **⋮ › Version history** if anything needs rolling back. + +**Nothing from the old site is deleted.** Pages that are replaced are hidden from navigation, their wording is +reused, and page addresses stay the same so old links keep working (rename the *title* only). + +### 2.1 Theme + +**Themes › Custom › +** (start from *Simple*), name it *MDCU Research 2026*, upload the logo, then: + +| Setting | Value | +| --- | --- | +| Main / button colour | `#16305A` (navy) — button text white | +| Accent | `#F4B13A` (amber) | +| Section style *Emphasis 1* | background `#F4F7FA` | +| Section style *Emphasis 2* | background `#0B1630`, text white | +| Title, Heading | IBM Plex Sans Thai, bold, `#16305A` | +| Subheading, Normal text | IBM Plex Sans Thai, `#1B2537` | +| Small text (eyebrows, captions) | IBM Plex Mono | +| Navigation | IBM Plex Sans Thai; light background | + +If a font isn't offered, use *Noto Sans Thai* (both cover Thai and English). + +### 2.2 Pages and navigation + +`Home · Opportunities · Events · Showcase · About` + +- Rename the *titles* **Researcher Profile → Showcase** and **Previous events → Events**. +- Keep *Our Structure* and *Our Impact* as sub-pages of About (or hide them once their content is on About). +- Once the Showcase widget is filled, **hide** the 24 conference pages from navigation — keep them as an archive. + +### 2.3 Page recipes + +Embeds: **Insert › Embed › By URL** → paste the **public** web app URL plus the view below. Embed boxes have a +fixed height in Sites; anything taller scrolls inside the box, so start with the heights given and adjust. + +**Home** +1. Header *Title only*. First section **Emphasis 2**, two columns. + Left — *Small text* "MDCU Research Division", title "Today’s research, / tomorrow’s cure." (second line in + amber `#F4B13A`), *Small text* credit "Inspired by Mayo Clinic’s “Tomorrow’s Cure”", the old subtitle + "A student-led research club affiliated with the Student Union of the Faculty of Medicine, Chulalongkorn + University", then embed `?view=hero-actions` (the two buttons and the live numbers, about 660 × 200 px). + Right — embed `?view=open-now` (about 460 × 520 px). Double-click the empty right half of the section to + insert it there. + Background: **Section colors › Image › Select › By URL** with the raw GitHub link to + `assets/hero-logo-background.png` (the club mark, faint, on navy). Then click the sparkle icon to remove the + *readability adjustment* and leave **Anchor image** in the centre: the mark sits at the right on laptops and + drops out of view on phones, like the design. Dark widgets are transparent, so it shows behind them. To change + the mark, edit `assets/hero-logo-background.svg`, mirror the change in `assets/render-hero-background.mjs` + and run it (`node google-sites/assets/render-hero-background.mjs`). +2. **MDCU Research club** — the old quote ("We are the community of medical students…") beside the group photo, + **About us** button. +3. **Our mission** (Emphasis 1) — the two old statements, each with its photo and the old buttons + (**Researcher requirements**, **Previous events**, **Researcher profiles**). +4. **Our impact** — four columns: Research Workshop, Lab tour, Research Talk (old lines) and Opportunities Board. +5. **Events** (Emphasis 1) — eyebrow, title and **All events** link, then embed `?view=events-home` (about 630 px): + the next event plus the two latest activities as short "What's new" teasers. The full posts (poster + + collapsible text) live on the Events page. +6. **Research showcase** — title, intro and **Explore the showcase** link, embed `?view=showcase-home` (about 730 px). +7. **Get involved** (Emphasis 2) — three columns with buttons (Opportunities, Instagram, Facebook group). + +**Opportunities** — intro text, a three-step "How it works" row, embed `?view=opportunities` (about 1150 px), +section **Emphasis 1** "For faculty" with **Post a project** (faculty form) and **Email the club** buttons. + +**Events** — intro text, embed `?view=events` (about 330 px), then **Previous events**: the existing activity posts +(poster or photos + title + collapsible text), newest first. To add one, duplicate a post section +(**⋮ › Duplicate section**), replace the image (**Insert › Images** or click the image › **Replace image**) and +the text, and drag it to the top. + +**Showcase** — Emphasis 2 header with the old intro "Researcher profiles & project highlights by MDCU's researchers" +and embed `?view=showcase-stats` (about 120 px) on the left, an **Add your presentation** button (showcase form) on +the right; then embed `?view=showcase` (about 1400 px) and the year-by-year archive of the old conference pages. + +**About** — mission (two columns on Emphasis 2), Executive Board 2026 (board poster or image + name + role grid), +Core Team photo, contact list, Google Maps embed. **Footer**: three columns — name and description (with the © +line), Contact (email, Instagram), Find us (address). + +--- + +## 3. Everyday updates — no code + +| Job | Where | Time | +| --- | --- | --- | +| Announce an event | Website data › Events — add a row (add a poster link if you have one) | ~2 min | +| Event full / closed | Events › `Status` | 10 s | +| Post photos or a recap of an activity | Sites editor › Events — duplicate a post, swap image and text, Publish | ~10 min | +| Publish a presentation | Website data › Showcase › tick `Approved` | 1 min | +| A project is filled | Opportunities sheet › status → `Closed` | 10 s | +| New academic year | Opportunities sheet › duplicate the tab, rename e.g. `2027-2028` (newest is shown automatically) | ~2 min | +| New Executive Board / wording / photos | Sites editor › edit › Publish | 5–15 min | + +Upcoming events drop off the list on their own the day after they happen. A row with a missing title or an +unreadable date is skipped instead of breaking the page. + +--- + +## 4. Content fixes found while auditing the current site + +- **"What's new [Jul 2026]" and "[Jan 2026]"** sit on Home months later; registration links are hidden inside + collapsed text. → The live "Upcoming" box handles announcements; the posts stay as activity posts. +- **Previous events** has two empty image boxes (Lab tour 2 and an empty row) and is missing the Jan 2026 workshop. +- **About** publishes a former secretary's personal phone number and LINE ID. → Use the club email and Instagram. +- **Researcher Profile**: the VSS 2024 page is titled *Asian Pacific Digestive Week 2024*; the ARC 2024 abstract page + has no title or presenter; TERMIS EU 2025 and HIV Drug Therapy Glasgow 2024 are listed but have no page; + MRPC 2024 is dated Feb 2025; one presenter's name is spelled two ways; typos such as "reseach", "Internation", + "systemic review". +- **Opportunities sheet**: the same department is typed several ways (the widget merges them via `DEPT_ALIASES`); + one project summary is garbled Thai pasted from a PDF; academic titles are in mixed order (the widget reorders them). +- **Page headers**: the two logos overlap the page title on every page. +- **Name**: "MDCU Research Division" and "MDCU Research Club" are used interchangeably — pick one. +- **First Meet 2026** venue differs between the poster (2nd floor) and the text (floor M). + +## 5. Decision for the team: who can see the site? + +Today the published site — and `researchclub.docchula.com`, which redirects to it — asks every visitor to sign in +with a **docchula.com** account, so prospective members, faculty using other accounts, partners and search engines +see nothing. With the two-copy setup in §1.4 the site can be published to **Anyone** (Publish › Settings): visitors +see everything except opportunity details, which stay behind the docchula.com sign-in. + +## 6. Developing + +```bash +node --test google-sites/test/*.test.mjs # parser + widget tests (Node 18+) +node google-sites/test/preview.mjs # renders every view to google-sites/test/out/*.html +``` + +`test/harness.mjs` runs the real `.gs` files with stand-ins for SpreadsheetApp, CacheService, HtmlService and +Session (to simulate signed-in and signed-out visitors), using the placeholder data in `test/fixtures.mjs`. +Keep real names and contact details out of the fixtures — this repository is public. diff --git a/google-sites/apps-script/App.html b/google-sites/apps-script/App.html new file mode 100644 index 0000000..96382db --- /dev/null +++ b/google-sites/apps-script/App.html @@ -0,0 +1,769 @@ + diff --git a/google-sites/apps-script/Code.gs b/google-sites/apps-script/Code.gs new file mode 100644 index 0000000..94b0c62 --- /dev/null +++ b/google-sites/apps-script/Code.gs @@ -0,0 +1,228 @@ +/** + * Web app behind the live sections of the Google Site. Each Sites embed loads + * the deployment URL with ?view=…: + * + * opportunities project finder (Opportunities page) + * open-now three newest projects, dark panel (Home, hero) + * hero-actions buttons + headline numbers, dark (Home, hero) + * stats headline numbers only, dark (spare) + * events-home next event + latest activities (Home) + * events all upcoming events (Events page) + * showcase-home numbers + three latest talks (Home) + * showcase-stats showcase numbers, dark (Showcase page, hero) + * showcase filterable presentations (Showcase page) + * + * Add &full=1 for a standalone page (the "Open full screen" link on phones). + */ +var VIEWS = ['opportunities', 'open-now', 'hero-actions', 'stats', 'events-home', 'events', 'showcase-home', + 'showcase-stats', 'showcase']; + +function doGet(e) { + var params = (e && e.parameter) || {}; + var view = VIEWS.indexOf(params.view) !== -1 ? params.view : 'opportunities'; + var data; + try { + data = loadView_(view, params); + } catch (err) { + console.error('Could not load view "%s": %s', view, err && err.stack); + data = { error: true }; + } + var template = HtmlService.createTemplateFromFile('Widget'); + template.view = view; + template.data = toScriptJson_(data); + template.config = toScriptJson_({ + links: CONFIG.LINKS, + self: appUrl_(), + membersUrl: CONFIG.MEMBERS_APP_URL, + memberDomain: CONFIG.MEMBER_DOMAIN, + fullPage: params.full === '1' + }); + return template.evaluate() + .setTitle('MDCU Research Division') + .addMetaTag('viewport', 'width=device-width, initial-scale=1') + .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); +} + +/** Used by Widget.html to pull in Styles.html and App.html. */ +function include(name) { + return HtmlService.createHtmlOutputFromFile(name).getContent(); +} + +/** + * Run from the editor once after deploying, or whenever the site says a section "couldn't load": + * it makes Google ask for any permission that hasn't been granted yet (tick every box). + */ +function authorize() { + ScriptApp.requireAllScopes(ScriptApp.AuthMode.FULL); + return SpreadsheetApp.openById(CONFIG.OPPORTUNITIES_SHEET_ID).getName(); +} + +/** Run from the editor to make sheet edits show up immediately instead of within CACHE_SECONDS. */ +function clearCache() { + var cache = CacheService.getScriptCache(); + var today = today_(); + cache.removeAll(['opp:latest', 'events:' + today, 'showcase']); +} + +function loadView_(view, params) { + if (view === 'opportunities' || view === 'open-now') { + var member = isMember_(); + var opp = opportunities_(view === 'opportunities' ? params.year : undefined); + var items = member ? opp.items : Parse.publicOpportunities(opp.items, CONFIG.PUBLIC_OPPORTUNITY_FIELDS); + if (view === 'opportunities') return { year: opp.year, years: opp.years, items: items, viewer: { member: member } }; + return { + year: opp.year, + total: items.length, + items: items.filter(function (i) { return i.kind === 'project'; }).slice(0, 3), + viewer: { member: member } + }; + } + // Full write-ups and photos of past activities live as hand-made posts in Google Sites; + // Home only shows the two latest as short "What's new" teasers. + if (view === 'events') return { upcoming: events_().upcoming }; + if (view === 'events-home') { + var ev = events_(); + return { next: ev.upcoming[0] || null, upcomingCount: ev.upcoming.length, recent: ev.past.slice(0, 2) }; + } + var items = showcase_(); + var stats = Parse.showcaseStats(items); + if (view === 'showcase') return { stats: stats, items: items }; + if (view === 'showcase-stats') return { stats: stats }; + if (view === 'showcase-home') { + return { stats: stats, items: items.slice(0, 3), conferences: conferenceNames_(items) }; + } + return { openProjects: opportunities_().items.length, stats: stats }; // 'stats' and 'hero-actions' +} + +/** + * The web app's address in the club-domain form (/a/macros/docchula.com/…). The plain form sends + * people signed in to several Google accounts to the wrong one and Google shows an error instead. + */ +function appUrl_() { + var url = ScriptApp.getService().getUrl() || ''; + var m = url.match(/\/macros\/(?:u\/\d+\/)?s\/([\w-]+)\/(?:exec|dev)/); + return m ? 'https://script.google.com/a/macros/' + CONFIG.MEMBER_DOMAIN + '/s/' + m[1] + '/exec' : url; +} + +/** + * True when the visitor is signed in with a MEMBER_DOMAIN account. Google only tells a script + * who the visitor is when the script's owner is in the same Workspace domain, so deploy it from + * the club's docchula.com account. + */ +function isMember_() { + var email = ''; + try { + email = Session.getActiveUser().getEmail() || ''; + } catch (err) { + email = ''; + } + var suffix = '@' + CONFIG.MEMBER_DOMAIN.toLowerCase(); + return email.toLowerCase().slice(-suffix.length) === suffix; +} + +function opportunities_(year) { + return cached_('opp:' + (year || 'latest'), function () { + var ss = SpreadsheetApp.openById(CONFIG.OPPORTUNITIES_SHEET_ID); + var tabs = ss.getSheets() + .map(function (s) { return s.getName(); }) + .filter(function (n) { return /^\d{4}\s*-\s*\d{4}$/.test(n); }) + .sort() + .reverse(); + var name = year && tabs.indexOf(year) !== -1 ? year : tabs[0]; + if (!name) return { year: '', years: [], items: [] }; + var rows = ss.getSheetByName(name).getDataRange().getDisplayValues(); + var items = Parse.parseOpportunities(rows, { deptAliases: CONFIG.DEPT_ALIASES }); + // Defined projects first, then open labs; newest form answers (lowest in the sheet) first. + items.sort(function (a, b) { + if (a.kind !== b.kind) return a.kind === 'project' ? -1 : 1; + return Number(b.id.slice(4)) - Number(a.id.slice(4)); + }); + return { year: name, years: tabs, items: items }; + }); +} + +function events_() { + var today = today_(); + return cached_('events:' + today, function () { + return Parse.parseEvents(readTab_(CONFIG.WEBSITE_DATA_SHEET_ID, CONFIG.EVENTS_TAB), today); + }); +} + +function showcase_() { + return cached_('showcase', function () { + return Parse.parseShowcase(readTab_(CONFIG.WEBSITE_DATA_SHEET_ID, CONFIG.SHOWCASE_TAB)); + }); +} + +function conferenceNames_(items) { + var seen = {}; + return items + .map(function (i) { return Parse.clean(i.conf).replace(/\s*(19|20)\d{2}$/, ''); }) + .filter(function (n) { + var key = n.toLowerCase(); + if (!n || seen[key]) return false; + seen[key] = true; + return true; + }); +} + +/** + * Reads a tab as display strings, except real dates, which become YYYY-MM-DD so + * the parser never has to guess between day/month orders. Time-only cells keep + * their display text. + */ +function readTab_(spreadsheetId, tabName) { + var sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName(tabName); + if (!sheet) return []; + var range = sheet.getDataRange(); + var raw = range.getValues(); + return range.getDisplayValues().map(function (row, r) { + return row.map(function (text, c) { + var value = raw[r][c]; + if (Object.prototype.toString.call(value) === '[object Date]' && value.getFullYear() > 1900) { + return Utilities.formatDate(value, CONFIG.TIME_ZONE, 'yyyy-MM-dd'); + } + return text; + }); + }); +} + +function today_() { + return Utilities.formatDate(new Date(), CONFIG.TIME_ZONE, 'yyyy-MM-dd'); +} + +// CacheService holds at most 100 KB per key, and Thai text is 3 bytes a character, +// so larger values are stored in pieces. +var CACHE_PIECE = 30000; + +function cached_(key, compute) { + var cache = CacheService.getScriptCache(); + var count = Number(cache.get(key)); + if (count) { + var keys = []; + for (var i = 0; i < count; i++) keys.push(key + ':' + i); + var parts = cache.getAll(keys); + if (keys.every(function (k) { return parts[k] != null; })) { + return JSON.parse(keys.map(function (k) { return parts[k]; }).join('')); + } + } + var value = compute(); + var text = JSON.stringify(value); + var entries = {}; + var pieces = 0; + for (var p = 0; p < text.length; p += CACHE_PIECE) { + entries[key + ':' + pieces] = text.slice(p, p + CACHE_PIECE); + pieces++; + } + entries[key] = String(pieces); + cache.putAll(entries, CONFIG.CACHE_SECONDS); + return value; +} + +/** JSON that is safe to drop inside a + + + diff --git a/google-sites/apps-script/appsscript.json b/google-sites/apps-script/appsscript.json new file mode 100644 index 0000000..6d285e8 --- /dev/null +++ b/google-sites/apps-script/appsscript.json @@ -0,0 +1,13 @@ +{ + "timeZone": "Asia/Bangkok", + "runtimeVersion": "V8", + "exceptionLogging": "STACKDRIVER", + "oauthScopes": [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/userinfo.email" + ], + "webapp": { + "executeAs": "USER_DEPLOYING", + "access": "ANYONE_ANONYMOUS" + } +} diff --git a/google-sites/assets/hero-logo-background.png b/google-sites/assets/hero-logo-background.png new file mode 100644 index 0000000..d63434c Binary files /dev/null and b/google-sites/assets/hero-logo-background.png differ diff --git a/google-sites/assets/hero-logo-background.svg b/google-sites/assets/hero-logo-background.svg new file mode 100644 index 0000000..9e6fea7 --- /dev/null +++ b/google-sites/assets/hero-logo-background.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/google-sites/assets/render-hero-background.mjs b/google-sites/assets/render-hero-background.mjs new file mode 100644 index 0000000..290a465 --- /dev/null +++ b/google-sites/assets/render-hero-background.mjs @@ -0,0 +1,90 @@ +// Renders hero-logo-background.svg to a PNG for Google Sites (Section colors › Image only takes bitmaps). +// node google-sites/assets/render-hero-background.mjs +// No dependencies: the two strokes are flattened and drawn with anti-aliased round caps, then saved with zlib. +import { writeFileSync } from 'node:fs'; +import { deflateSync } from 'node:zlib'; + +const W = 2400, H = 960; +const NAVY = [0x0B, 0x16, 0x30]; +// Same placement as the SVG: translate(1468 -113) scale(1.4125), stroke-width 64 in path units. +const TX = 1468, TY = -113, S = 1.4125, HALF = 32 * S; + +const cubic = (p0, p1, p2, p3, n = 96) => Array.from({ length: n }, (_, i) => { + const t = (i + 1) / n, u = 1 - t; + return [0, 1].map((k) => u * u * u * p0[k] + 3 * u * u * t * p1[k] + 3 * u * t * t * p2[k] + t * t * t * p3[k]); +}); +// The design's paths, written out in absolute coordinates. +const sky = [[150, 790], [150, 270], ...cubic([150, 270], [150, 182], [212, 120], [300, 120]), [470, 120], + ...cubic([470, 120], [610, 120], [710, 230], [710, 360]), ...cubic([710, 360], [710, 490], [610, 600], [470, 600])]; +const amber = [[520, 330], ...cubic([520, 330], [410, 330], [330, 410], [330, 500]), + ...cubic([330, 500], [330, 590], [410, 670], [520, 670]), [710, 670]]; + +const STROKES = [ + { points: sky, color: [0x5B, 0xC6, 0xEA], opacity: 0.09 }, + { points: amber, color: [0xF4, 0xB1, 0x3A], opacity: 0.10 } +]; + +const px = new Float64Array(W * H * 3); +for (let i = 0; i < W * H; i++) px.set(NAVY, i * 3); + +for (const { points, color, opacity } of STROKES) { + const pts = points.map(([x, y]) => [TX + x * S, TY + y * S]); + const dist = new Float64Array(W * H).fill(Infinity); + for (let i = 1; i < pts.length; i++) { + const [ax, ay] = pts[i - 1], [bx, by] = pts[i]; + const x0 = Math.max(0, Math.floor(Math.min(ax, bx) - HALF - 1)), x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx) + HALF + 1)); + const y0 = Math.max(0, Math.floor(Math.min(ay, by) - HALF - 1)), y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by) + HALF + 1)); + const dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy || 1; + for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + const cx = x + 0.5, cy = y + 0.5; + const t = Math.max(0, Math.min(1, ((cx - ax) * dx + (cy - ay) * dy) / len2)); + const d = Math.hypot(cx - (ax + t * dx), cy - (ay + t * dy)); + const k = y * W + x; + if (d < dist[k]) dist[k] = d; + } + } + } + for (let k = 0; k < W * H; k++) { + const cover = Math.max(0, Math.min(1, HALF + 0.5 - dist[k])); + if (!cover) continue; + const a = cover * opacity; + for (let c = 0; c < 3; c++) px[k * 3 + c] = px[k * 3 + c] * (1 - a) + color[c] * a; + } +} + +// PNG: 8-bit RGB, filter 0 on every row. +const raw = Buffer.alloc(H * (1 + W * 3)); +for (let y = 0; y < H; y++) { + raw[y * (1 + W * 3)] = 0; + for (let x = 0; x < W; x++) { + for (let c = 0; c < 3; c++) raw[y * (1 + W * 3) + 1 + x * 3 + c] = Math.round(px[(y * W + x) * 3 + c]); + } +} +const CRC = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +const crc32 = (buf) => { + let c = 0xFFFFFFFF; + for (const b of buf) c = CRC[(c ^ b) & 0xFF] ^ (c >>> 8); + return (c ^ 0xFFFFFFFF) >>> 0; +}; +const chunk = (type, data) => { + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + out.write(type, 4, 'ascii'); + data.copy(out, 8); + out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +}; +const ihdr = Buffer.alloc(13); +ihdr.writeUInt32BE(W, 0); +ihdr.writeUInt32BE(H, 4); +ihdr.set([8, 2, 0, 0, 0], 8); +const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]); +const out = new URL('./hero-logo-background.png', import.meta.url); +writeFileSync(out, png); +console.log('Wrote ' + out.pathname + ' (' + png.length + ' bytes)'); diff --git a/google-sites/setup/Setup.gs b/google-sites/setup/Setup.gs new file mode 100644 index 0000000..6890d72 --- /dev/null +++ b/google-sites/setup/Setup.gs @@ -0,0 +1,104 @@ +/** + * One-time setup. Paste this into its OWN Apps Script project (not the widgets project), run + * setupWebsiteData() once, approve the permission screen, then read the links in View > Logs. + * Afterwards the project can be deleted - the widgets project never needs these permissions. + * + * It creates, in the signed-in account's Drive: + * - a spreadsheet "Website data" with an Events tab (dropdowns, checkboxes, sample rows) + * - a Google Form "Add my presentation to the MDCU Research Showcase" linked to a Showcase tab + * that has an Approved checkbox column (only ticked rows appear on the site) + */ +function setupWebsiteData() { + var ss = SpreadsheetApp.create('Website data'); + var events = ss.getSheets()[0]; + events.setName('Events'); + + var header = ['Date', 'Start', 'End', 'Type', 'Title', 'Venue', 'Description', 'Register link', 'Poster link', + 'Recording links', 'Seats', 'Status', 'Show on site']; + var rows = [ + ['2026-07-06', '16:00', '18:30', 'Meetup', 'MDCU Research Division First Meet 2026', 'Smart Classroom, อาคาร อปร.', + 'What research is and how to start, through a hands-on interactive session — plus talks from four senior researchers and an early look at Core Team recruitment.', '', '', '', '', '', true], + ['2026-01-16', '16:15', '20:00', 'Workshop', 'How to Design High-Impact Research', 'Room 1302, 13th floor, Bhumisiri Building', + 'Lecture on research fundamentals and key ideas, followed by a workshop on the thinking process behind study design.', '', '', '', '40', '', true], + ['2025-07-06', '09:00', '12:00', 'Workshop', 'Statistics in Medical Research & Basic Software', 'Online via Zoom', + 'ResearchNav 2025, Workshop 4. Session 1: statistical fundamentals from principles to practice. Session 2: hands-on with Stata.', '', '', '', '', '', true], + ['2025-03-19', '13:00', '15:30', 'Lab tour', 'Lab Tour 2: Systems Biology & Cancer Biomarkers', 'Faculty of Medicine, Chulalongkorn University', + 'Visits to the Center of Excellence in Systems Biology and the Cancer Biomarker Research Laboratory.', '', '', '', '', '', true], + ['2025-03-11', '16:30', '19:30', 'Workshop', 'Transforming Medical Research with AI', 'Room 1302, 13th floor, Bhumisiri Building', + 'AI tools that help with research, with an interactive session.', '', '', '', '40', '', true] + ]; + var n = rows.length; + events.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold').setBackground('#16305A').setFontColor('#FFFFFF'); + // Text format first, so "16:00" and "40" stay exactly as typed. + events.getRange(2, 2, 500, 2).setNumberFormat('@'); + events.getRange(2, 11, 500, 1).setNumberFormat('@'); + events.getRange(2, 1, 500, 1).setNumberFormat('yyyy-mm-dd'); + var data = rows.map(function (r) { + var out = r.slice(); + var p = r[0].split('-'); + out[0] = new Date(+p[0], +p[1] - 1, +p[2]); + return out; + }); + events.getRange(2, 1, n, header.length).setValues(data); + events.getRange(2, 4, 500, 1).setDataValidation(SpreadsheetApp.newDataValidation() + .requireValueInList(['Workshop', 'Lab tour', 'Research talk', 'Meetup', 'Other'], true).setAllowInvalid(true).build()); + events.getRange(2, 12, 500, 1).setDataValidation(SpreadsheetApp.newDataValidation() + .requireValueInList(['Open', 'Full', 'Closed', 'Cancelled'], true).setAllowInvalid(true).build()); + events.getRange(2, 13, 500, 1).insertCheckboxes(); + events.getRange(2, 13, n, 1).check(); + events.setFrozenRows(1); + events.setColumnWidths(1, 3, 90); + events.setColumnWidth(5, 280); + events.setColumnWidth(6, 200); + events.setColumnWidth(7, 380); + events.getRange(1, 1, 1, 1).setNote('One row per event. Only rows dated today or later appear as "Upcoming" on the site; "Show on site" must be ticked.'); + + var form = FormApp.create('Add my presentation to the MDCU Research Showcase'); + form.setDescription('Presented your research at a conference? Fill this in and the Research Division team will review it. ' + + 'Approved entries appear on the Showcase page of the MDCU Research Division website.'); + form.setCollectEmail(true); + form.setRequireLogin(true); + form.setConfirmationMessage('Thank you! The team will review your presentation before it appears on the website.'); + var text = function (title, required, help) { + var item = form.addTextItem().setTitle(title).setRequired(!!required); + if (help) item.setHelpText(help); + return item; + }; + text('Presenter name / ชื่อผู้นำเสนอ', true); + text('Class (e.g. MDCU78) / รุ่น', true); + text('Conference name / ชื่องานประชุม', true); + text('Short name (e.g. VSS 2026) / ชื่อย่อ', false); + text('City, country / เมือง', true, 'For example: Bangkok, Thailand'); + form.addDateItem().setTitle('Conference date / วันที่นำเสนอ').setRequired(true); + form.addMultipleChoiceItem().setTitle('Format / รูปแบบ') + .setChoiceValues(['Oral presentation', 'Poster presentation', 'Other']).setRequired(true); + text('Research title / ชื่องานวิจัย', true); + text('Research field / สาขา', false); + text('Keywords / คำสำคัญ', false, 'Separate with commas'); + text('Advisors / อาจารย์ที่ปรึกษา', false); + form.addParagraphTextItem().setTitle('Abstract / บทคัดย่อ').setRequired(true); + form.addParagraphTextItem().setTitle('Reflection / สิ่งที่ได้เรียนรู้').setRequired(false); + text('Photo / รูป', false, 'Optional. Paste a Google Drive link to your photo, shared as "Anyone with the link - Viewer".'); + form.addCheckboxItem().setTitle('Consent / ยินยอม') + .setChoiceValues(['I agree that my name, class, photo and research details are shown on the club website']) + .setRequired(true); + form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()); + SpreadsheetApp.flush(); + + var showcase = null; + ss.getSheets().forEach(function (s) { if (s.getName() !== 'Events') showcase = s; }); + if (showcase) { + showcase.setName('Showcase'); + var col = showcase.getLastColumn() + 1; + showcase.getRange(1, col).setValue('Approved').setFontWeight('bold'); + showcase.getRange(2, col, 500, 1).insertCheckboxes(); + showcase.setFrozenRows(1); + showcase.getRange(1, col).setNote('Tick to publish this presentation on the Showcase page.'); + } + + Logger.log('WEBSITE_DATA_SHEET_ID = ' + ss.getId()); + Logger.log('Spreadsheet: ' + ss.getUrl()); + Logger.log('Form (share this with students): ' + form.getPublishedUrl()); + Logger.log('Form (edit): ' + form.getEditUrl()); + return ss.getId(); +} diff --git a/google-sites/templates/events.csv b/google-sites/templates/events.csv new file mode 100644 index 0000000..28c24c6 --- /dev/null +++ b/google-sites/templates/events.csv @@ -0,0 +1,6 @@ +Date,Start,End,Type,Title,Venue,Description,Register link,Poster link,Recording links,Seats,Status,Show on site +2026-07-06,16:00,18:30,Meetup,MDCU Research Division First Meet 2026,"Smart Classroom, อาคาร อปร.","What research is and how to start, through a hands-on interactive session — plus talks from four senior researchers and an early look at Core Team recruitment.",,,,,,TRUE +2026-01-16,16:15,20:00,Workshop,How to Design High-Impact Research,"Room 1302, 13th floor, Bhumisiri Building","Lecture on research fundamentals and key ideas, followed by a workshop on the thinking process behind study design.",,,,40,,TRUE +2025-07-06,09:00,12:00,Workshop,Statistics in Medical Research & Basic Software,Online via Zoom,"ResearchNav 2025, Workshop 4. Session 1: statistical fundamentals from principles to practice. Session 2: hands-on with Stata.",,,,,,TRUE +2025-03-19,13:00,15:30,Lab tour,Lab Tour 2: Systems Biology & Cancer Biomarkers,"Faculty of Medicine, Chulalongkorn University",Visits to the Center of Excellence in Systems Biology and the Cancer Biomarker Research Laboratory.,,,,,,TRUE +2025-03-11,16:30,19:30,Workshop,Transforming Medical Research with AI,"Room 1302, 13th floor, Bhumisiri Building","AI tools that help with research, with an interactive session.",,,,40,,TRUE diff --git a/google-sites/templates/showcase-import.csv b/google-sites/templates/showcase-import.csv new file mode 100644 index 0000000..41cc0d6 --- /dev/null +++ b/google-sites/templates/showcase-import.csv @@ -0,0 +1 @@ +Timestamp,Email address,Presenter name / ชื่อผู้นำเสนอ,Class (e.g. MDCU78) / รุ่น,Conference name / ชื่องานประชุม,Short name (e.g. VSS 2026) / ชื่อย่อ,"City, country / เมือง",Conference date / วันที่นำเสนอ,Format / รูปแบบ,Research title / ชื่องานวิจัย,Research field / สาขา,Keywords / คำสำคัญ,Advisors / อาจารย์ที่ปรึกษา,Abstract / บทคัดย่อ,Reflection / สิ่งที่ได้เรียนรู้,Photo / รูป,Consent / ยินยอม,Approved diff --git a/google-sites/test/.gitignore b/google-sites/test/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/google-sites/test/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/google-sites/test/fixtures.mjs b/google-sites/test/fixtures.mjs new file mode 100644 index 0000000..fc9a130 --- /dev/null +++ b/google-sites/test/fixtures.mjs @@ -0,0 +1,70 @@ +// Placeholder sheet contents shaped like the real tabs. No real people or contact details. + +const time = (hhmm) => { + const [h, m] = hhmm.split(':').map(Number); + return { v: new Date(1899, 11, 30, h, m), d: hhmm }; // how Sheets returns time-only cells +}; +const date = (iso) => { + const [y, m, d] = iso.split('-').map(Number); + return { v: new Date(y, m - 1, d), d: `${d}/${m}/${y}` }; // display order is locale-dependent +}; + +const OPP_HEADER = ['', 'ชื่อหัวข้อโครงการวิจัย', 'สเตตัสการเปิดรับสมัคร', 'ชื่อจริง', 'นามสกุล', 'ตำแหน่งทางวิชาการ', + 'สังกัด/ภาควิชา', 'ช่องทาง', 'คอนแทคติดต่อ', 'ชื่อโครงการ (Title)', 'รายละเอียดของโครงการโดยสังเขป', + 'บทบาทหน้าที่ของนิสิตในโครงการวิจัย', 'ทักษะหรือสิ่งที่นิสิตจะได้เรียนรู้', + 'สิ่งที่ต้องการให้นิสิตที่สนใจส่งมาด้วยเพื่อพิจารณาการรับเข้าร่วมโครงการ', 'ระยะเวลาโดยประมาณตั้งแต่เริ่มต้นจนสิ้นสุดโครงการ', + 'โปสเตอร์ประชาสัมพันธ์โครงการวิจัย', 'keywords ที่สำคัญของโครงการ', 'เรื่องอื่น ๆ ที่อาจารย์ต้องการแจ้งให้นิสิตทราบเพิ่มเติม', + 'จำนวนนิสิตที่ต้องการ']; +const opp = (o) => OPP_HEADER.map((_, i) => o[i] ?? ''); + +export const opportunities = { + '2026-2027': [ + ['', 'MDCU Research Club 📂 แบบฟอร์มเปิดรับสมัครนิสิตช่วยทำวิจัย'], + ['', 'หัวข้อการทำวิจัย 🧐', '', 'ข้อมูลของอาจารย์'], + OPP_HEADER, + opp({ 1: 'Deep learning triage for chest radiographs', 2: 'Available', 3: 'ตัวอย่าง', 4: 'หนึ่ง', 5: 'ดร., นพ., ผศ.', + 6: 'รังสีวิทยา', 7: 'Email', 8: 'example.one@example.com', 10: 'Build and validate a model that flags urgent findings on chest X-rays.', + 11: 'Label images, run baseline models, help write the manuscript', 12: 'Python, model evaluation, study design', + 13: 'Curriculum vitae, Letter of intent', 14: 'Jan 2027 – Dec 2027', 16: 'Deep learning, Radiology, Triage', 18: '3' }), + opp({ 1: 'การศึกษาปัจจัยเสี่ยงของภาวะหกล้มในผู้สูงอายุในชุมชน', 2: 'Available', 3: 'ตัวอย่าง', 4: 'สอง', 5: 'อาจารย์, พญ.', + 6: 'เวชศาสตร์ครอบครัว', 7: 'LINE, Email', 8: 'LINE: example-two\nexample.two@example.com', 10: 'สำรวจและวิเคราะห์ปัจจัยเสี่ยงของการหกล้มในผู้สูงอายุ', + 11: 'เก็บข้อมูลภาคสนามและวิเคราะห์ข้อมูลเบื้องต้น', 12: 'การออกแบบแบบสอบถาม การวิเคราะห์ข้อมูล', 14: 'Mar 2027 – Feb 2028', + 16: 'ผู้สูงอายุ, การหกล้ม, เวชศาสตร์ชุมชน', 18: '2' }), + opp({ 1: 'ยังไม่มีโครงการแต่เปิดรับนิสิตที่สนใจ', 2: 'Available', 3: 'ศ.นพ.ตัวอย่าง', 4: 'สาม', 5: 'ศ.', 6: 'ภาควิชาจุลชีววิทยา', + 7: 'Email', 8: 'example.three@example.com', 16: 'Virology, Vaccine immunology' }), + opp({ 1: 'Closed sample project', 2: 'Closed', 3: 'ตัวอย่าง', 4: 'สี่', 5: 'รศ.', 6: 'ศัลยศาสตร์', 7: 'Email' }) + ], + '2025-2026': [OPP_HEADER, opp({ 1: 'Last year sample', 2: 'Available', 3: 'ตัวอย่าง', 4: 'ห้า', 6: 'อายุรศาสตร์', 7: 'Email' })] +}; + +const EVENT_HEADER = ['Date', 'Start', 'End', 'Type', 'Title', 'Venue', 'Description', 'Register link', 'Poster link', 'Recording links', 'Seats', 'Status', 'Show on site']; + +export const events = [ + EVENT_HEADER, + [date('2026-11-20'), time('16:00'), time('18:30'), 'Workshop', 'Sample workshop: writing your first abstract', 'Room 1302, Bhumisiri Building', + 'Hands-on session on structuring an abstract, with feedback in small groups.', 'https://forms.gle/example', + 'https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz012345/view?usp=sharing', '', '40', 'Open', 'TRUE'], + [date('2026-12-05'), time('13:00'), time('15:30'), 'Lab tour', 'Sample lab tour', 'Faculty of Medicine', '', 'https://forms.gle/example2', '', '', '', 'Full', 'TRUE'], + [date('2026-07-06'), time('16:00'), time('18:30'), 'Meetup', 'Sample First Meet', 'Smart Classroom', 'Meet the team and hear from senior researchers.', '', '', '', '', '', 'TRUE'], + [date('2025-07-06'), time('09:00'), time('12:00'), 'Workshop', 'Sample statistics workshop', 'Online via Zoom', 'Statistics fundamentals, then hands-on software.', '', '', + 'Session 1 | https://youtu.be/example1\nSession 2 | https://youtu.be/example2', '', '', 'TRUE'], + [date('2025-03-19'), time('13:00'), time('15:30'), 'Lab tour', 'Sample lab tour 2', 'Faculty of Medicine', '', '', '', '', '', '', 'TRUE'] +]; + +const SHOWCASE_HEADER = ['Timestamp', 'Email address', 'Presenter name / ชื่อผู้นำเสนอ', 'Class (e.g. MDCU78) / รุ่น', 'Conference name / ชื่องานประชุม', + 'Short name (e.g. VSS 2026) / ชื่อย่อ', 'City, country / เมือง', 'Conference date / วันที่นำเสนอ', 'Format / รูปแบบ', + 'Research title / ชื่องานวิจัย', 'Research field / สาขา', 'Keywords / คำสำคัญ', 'Advisors / อาจารย์ที่ปรึกษา', + 'Abstract / บทคัดย่อ', 'Reflection / สิ่งที่ได้เรียนรู้', 'Photo / รูป', 'Consent / ยินยอม', 'Approved']; + +const sc = (who, cls, conf, short, city, iso, format, title, field, approved = 'TRUE') => + [date('2026-06-01'), 'x@example.com', who, cls, conf, short, city, date(iso), format, title, field, + 'keyword one, keyword two', 'Advisor Example', 'A short placeholder abstract. '.repeat(12).trim(), '', '', 'I agree', approved]; + +export const showcase = [ + SHOWCASE_HEADER, + sc('นักศึกษา ก.', 'MDCU78', 'Sample Vision Meeting 2026', 'SVM 2026', 'Florida, USA', '2026-05-16', 'Poster', 'A sample study on visual attention in older adults', 'Neuroscience'), + sc('นักศึกษา ข.', 'MDCU77', 'Sample Cardiology Session 2026', 'SCS 2026', 'New Orleans, USA', '2026-03-28', 'Poster', 'งานวิจัยตัวอย่างด้านหัวใจล้มเหลวเรื้อรัง', 'Clinical science'), + sc('นักศึกษา ค.', 'MDCU80', 'Sample Education Meeting 2025', 'SEM 2025', 'Tokyo, Japan', '2025-07-20', 'Oral presentation', 'Peer support teams in a sample medical school', 'Medical education'), + sc('นักศึกษา ง.', 'MDCU76', 'Sample Asia Meeting 2024', 'SAM 2024', 'Bangkok, Thailand', '2024-12-01', 'Oral', 'A sample retrospective cohort', 'Emergency medicine'), + sc('นักศึกษา จ.', 'MDCU76', 'Sample Asia Meeting 2024', 'SAM 2024', 'Bangkok, Thailand', '2024-12-01', 'Poster', 'Waiting for approval', '', 'FALSE') +]; diff --git a/google-sites/test/harness.mjs b/google-sites/test/harness.mjs new file mode 100644 index 0000000..aca1557 --- /dev/null +++ b/google-sites/test/harness.mjs @@ -0,0 +1,106 @@ +// Runs the real Apps Script files under Node with small stand-ins for the Google +// services they use, so doGet() can be called for every view. +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const dir = new URL('../apps-script/', import.meta.url); +const read = (name) => readFileSync(new URL(name, dir), 'utf8'); + +const cellValue = (c) => (c && typeof c === 'object' && 'v' in c ? c.v : c); +const cellText = (c) => (c && typeof c === 'object' && 'd' in c ? c.d : String(c ?? '')); + +function fakeSheet(name, rows) { + return { + getName: () => name, + getDataRange: () => ({ + getValues: () => rows.map((r) => r.map(cellValue)), + getDisplayValues: () => rows.map((r) => r.map(cellText)) + }) + }; +} + +function fakeSpreadsheet(tabs) { + const sheets = Object.entries(tabs).map(([name, rows]) => fakeSheet(name, rows)); + return { getSheets: () => sheets, getSheetByName: (n) => sheets.find((s) => s.getName() === n) || null }; +} + +/** + * fixtures: { opportunities: {tab: rows}, events: rows, showcase: rows } + * now: ISO timestamp used as "today". + * email: the visitor Google reports to the script ('' = signed out or another domain). + */ +export function loadApp({ fixtures, now = '2026-10-23T10:00:00+07:00', config = {}, email = '' }) { + const FIXED = new Date(now).getTime(); + class FixedDate extends Date { + constructor(...args) { super(...(args.length ? args : [FIXED])); } + static now() { return FIXED; } + } + const store = new Map(); + const context = { + console, + JSON, + Date: FixedDate, + SpreadsheetApp: { + openById(id) { + if (id === 'OPP') return fakeSpreadsheet(fixtures.opportunities || {}); + if (id === 'DATA') return fakeSpreadsheet({ Events: fixtures.events || [], Showcase: fixtures.showcase || [] }); + throw new Error('Unknown spreadsheet ' + id); + } + }, + CacheService: { + getScriptCache: () => ({ + get: (k) => store.get(k) ?? null, + getAll: (keys) => Object.fromEntries(keys.filter((k) => store.has(k)).map((k) => [k, store.get(k)])), + putAll: (entries) => Object.entries(entries).forEach(([k, v]) => store.set(k, v)), + removeAll: (keys) => keys.forEach((k) => store.delete(k)) + }) + }, + Utilities: { + formatDate: (d, tz) => new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d) + }, + ScriptApp: { getService: () => ({ getUrl: () => 'https://script.google.com/a/macros/docchula.com/s/TEST/exec' }) }, + Session: { getActiveUser: () => ({ getEmail: () => email }) }, + HtmlService: { + XFrameOptionsMode: { ALLOWALL: 'ALLOWALL' }, + createHtmlOutputFromFile: (name) => ({ getContent: () => read(name + '.html') }), + createTemplateFromFile(name) { + const source = read(name + '.html'); + const template = { + evaluate() { + const vars = { ...template, include: context.include }; + const html = source.replace(/<\?!=\s*([\s\S]*?)\s*;?\s*\?>/g, (_, expr) => + String(new Function(...Object.keys(vars), 'return (' + expr + ');')(...Object.values(vars)))); + const output = { + getContent: () => html, + setTitle: () => output, + addMetaTag: () => output, + setXFrameOptionsMode: () => output + }; + return output; + } + }; + return template; + } + } + }; + vm.createContext(context); + for (const file of ['Parse.gs', 'Config.gs', 'Code.gs']) vm.runInContext(read(file), context, { filename: file }); + vm.runInContext( + 'CONFIG.OPPORTUNITIES_SHEET_ID = "OPP"; CONFIG.WEBSITE_DATA_SHEET_ID = "DATA";' + + 'Object.assign(CONFIG, ' + JSON.stringify(config) + ');', + context + ); + return context; +} + +/** Returns the full HTML the web app would serve for ?view=. */ +export function render(view, options) { + const app = loadApp(options); + return app.doGet({ parameter: { view, ...(options.params || {}) } }).getContent(); +} + +/** The JSON payload the page receives, parsed back out of the HTML. */ +export function payload(html) { + const m = html.match(/window\.__DATA__ = ([\s\S]*?);\n/); + return JSON.parse(m[1]); +} diff --git a/google-sites/test/parse.test.mjs b/google-sites/test/parse.test.mjs new file mode 100644 index 0000000..8feb726 --- /dev/null +++ b/google-sites/test/parse.test.mjs @@ -0,0 +1,166 @@ +// Run with: node --test google-sites/test +// Loads Parse.gs exactly as Apps Script would and checks it against sheet-shaped fixtures. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('../apps-script/Parse.gs', import.meta.url), 'utf8'); +const context = {}; +vm.runInNewContext(source + '\nthis.Parse = Parse;', context); +const { Parse } = context; +const plain = (value) => JSON.parse(JSON.stringify(value)); + +// Same layout as the live "Research opportunities for MDCU students" tab: +// a banner row, a group-header row, then the real header row. +const OPP_HEADER = ['', 'ชื่อหัวข้อโครงการวิจัย', 'สเตตัสการเปิดรับสมัคร', 'ชื่อจริง', 'นามสกุล', 'ตำแหน่งทางวิชาการ', + 'สังกัด/ภาควิชา', 'ช่องทาง', 'คอนแทคติดต่อ', 'ชื่อโครงการ (Title)', 'รายละเอียดของโครงการโดยสังเขป', + 'บทบาทหน้าที่ของนิสิตในโครงการวิจัย', 'ทักษะหรือสิ่งที่นิสิตจะได้เรียนรู้', + 'สิ่งที่ต้องการให้นิสิตที่สนใจส่งมาด้วยเพื่อพิจารณาการรับเข้าร่วมโครงการ', 'ระยะเวลาโดยประมาณตั้งแต่เริ่มต้นจนสิ้นสุดโครงการ', + 'โปสเตอร์ประชาสัมพันธ์โครงการวิจัย', 'keywords ที่สำคัญของโครงการ', 'เรื่องอื่น ๆ ที่อาจารย์ต้องการแจ้งให้นิสิตทราบเพิ่มเติม', + 'จำนวนนิสิตที่ต้องการ']; + +const oppRow = (o) => OPP_HEADER.map((_, i) => o[i] ?? ''); + +const OPP_ROWS = [ + ['', 'MDCU Research Club 📂 แบบฟอร์มเปิดรับสมัครนิสิตช่วยทำวิจัย', '', '', '', '', '⚠️ หมายเหตุ'], + ['', 'หัวข้อการทำวิจัย 🧐', '', 'ข้อมูลของอาจารย์', '', '', '', 'ช่องทางการติดต่อที่อาจารย์สะดวก 📩'], + OPP_HEADER, + oppRow({ 1: 'Sample antiviral screening project', 2: 'Available', 3: 'สมหญิง', 4: 'ใจดี', 5: 'ดร., พญ., รศ.', + 6: 'จุลชีววิทยา', 7: 'LINE, Email', 8: 'line: sample-id', 9: 'Sample antiviral screening project', + 10: 'Screening compounds in cell culture', 11: 'ขึ้นกับระดับความสนใจของนิสิต', 12: 'cell-based assays', + 13: 'Curriculum vitae, Letter of intent, Transcript (CR25)', 14: 'Until Jan 2028', + 15: 'https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz012345/view?usp=sharing', + 16: 'antiviral drug, drug discovery', 17: '-', 18: '1 คน' }), + oppRow({ 1: 'ยังไม่มีโครงการแต่เปิดรับนิสิตที่สนใจ', 2: 'Available', 3: 'ศ.นพ.สมชาย', 4: 'ตัวอย่าง', 5: 'ศ.', + 6: 'คณะแพทยศาสตร์/ภาควิชากุมารเวชศาสตร์', 7: 'Email', 16: 'Virology, Vaccines' }), + oppRow({ 1: 'A closed project', 2: 'Closed', 3: 'สมศรี', 4: 'ทดสอบ', 5: 'ผศ.', 6: 'ภาควิชาโสต ศอ นาสิกวิทยา', 7: 'Email' }), + oppRow({}), + oppRow({ 1: 'ยังไม่มีโครงการแต่เปิดรับนิสิตที่สนใจ', 2: 'Available', 3: 'สมปอง', 4: 'ตัวอย่าง', + 5: 'ไม่มีตำแหน่งวิชาการ (นักวิทยาศาสตร์)', 6: 'ภาควิชาโสต ศอ นาสิกวิทยา', 7: 'Email', 16: '' }) +]; + +test('finds the header row below banner rows', () => { + assert.equal(Parse.findHeaderRow(OPP_ROWS, ['ชื่อหัวข้อโครงการวิจัย']), 2); +}); + +test('parses defined projects and open labs, hiding closed ones', () => { + const items = Parse.parseOpportunities(OPP_ROWS); + assert.equal(items.length, 3); + const [project, openLab, scientist] = items; + + assert.equal(project.kind, 'project'); + assert.equal(project.title, 'Sample antiviral screening project'); + assert.equal(project.advisor, 'รศ. ดร. พญ. สมหญิง ใจดี'); + assert.deepEqual(plain(project.channels), ['LINE', 'Email']); + assert.deepEqual(plain(project.docs), ['Curriculum vitae', 'Letter of intent', 'Transcript (CR25)']); + assert.deepEqual(plain(project.keywords), ['antiviral drug', 'drug discovery']); + assert.equal(project.seats, '1'); + assert.equal(project.notes, ''); + assert.equal(project.poster, 'https://lh3.googleusercontent.com/d/1AbCdEfGhIjKlMnOpQrStUvWxYz012345=w1200'); + + assert.equal(openLab.kind, 'open'); + assert.equal(openLab.title, 'Virology · Vaccines'); + assert.equal(openLab.advisor, 'ศ. นพ. สมชาย ตัวอย่าง'); + assert.equal(openLab.dept, 'กุมารเวชศาสตร์'); + assert.equal(openLab.duration, ''); + + assert.equal(scientist.advisor, 'นักวิทยาศาสตร์ สมปอง ตัวอย่าง'); + assert.equal(scientist.dept, 'โสต ศอ นาสิกวิทยา'); + assert.equal(scientist.title, 'Open to interested students'); +}); + +test('closed projects can be included on request', () => { + const items = Parse.parseOpportunities(OPP_ROWS, { includeClosed: true }); + assert.equal(items.filter((i) => i.status === 'closed').length, 1); +}); + +test('columns can be reordered without breaking the parser', () => { + const order = OPP_HEADER.map((_, i) => i).reverse(); + const shuffled = OPP_ROWS.map((row) => order.map((i) => row[i] ?? '')); + const a = plain(Parse.parseOpportunities(OPP_ROWS)).map(({ id, ...rest }) => rest); + const b = plain(Parse.parseOpportunities(shuffled)).map(({ id, ...rest }) => rest); + assert.deepEqual(b, a); +}); + +test('department aliases merge spelling variants', () => { + assert.equal(Parse.normalizeDept('หน่วยปฏิบัติการวิจัยความผิดปกติของรก ภาควิชาสูติศาสตร์-นรีเวชวิทยา', + { 'สูติศาสตร์-นรีเวชวิทยา': 'สูติศาสตร์-นรีเวชวิทยา', 'สูตินรีเวชวิทยา': 'สูติศาสตร์-นรีเวชวิทยา' }), 'สูติศาสตร์-นรีเวชวิทยา'); +}); + +test('reads dates in the formats staff are likely to type', () => { + for (const v of ['2026-07-06', '6/7/2026', '06/07/2026', '6 Jul 2026', '6 July 2026', '6 ก.ค. 2569', '6/7/2569']) { + assert.equal(Parse.toIsoDate(v), '2026-07-06', v); + } + for (const v of ['', 'next week', '31/31/2026', 'TBA']) { + assert.equal(Parse.toIsoDate(v), '', v); + } +}); + +const EVENT_ROWS = [ + ['Date', 'Start', 'End', 'Type', 'Title', 'Venue', 'Description', 'Register link', 'Poster link', 'Recording links', 'Seats', 'Status', 'Show on site'], + ['2026-11-20', '16:00', '18:00', 'Workshop', 'Sample upcoming workshop', 'Room 1302', 'Hands-on session', 'https://forms.gle/example', '', '', '40', '', 'TRUE'], + ['2026-12-05', '13:00', '15:00', 'Lab tour', 'Sample lab tour', 'Lab building', '', 'javascript:alert(1)', '', '', '', 'Full', 'TRUE'], + ['2026-07-06', '16:00', '18:30', 'Meetup', 'Sample first meet', 'Smart Classroom', '', '', '', '', '', '', 'TRUE'], + ['2025-07-06', '09:00', '12:00', 'Workshop', 'Sample online workshop', 'Online (Zoom)', '', '', + '', 'Session 1 | https://youtu.be/example1\nhttps://youtu.be/example2', '', '', 'TRUE'], + ['2026-11-25', '', '', 'Talk', 'Cancelled talk', '', '', '', '', '', '', 'Cancelled', 'TRUE'], + ['2026-11-26', '', '', 'Talk', 'Hidden draft', '', '', '', '', '', '', '', 'FALSE'], + ['', '', '', 'Talk', 'Row without a date', '', '', '', '', '', '', '', 'TRUE'] +]; + +test('splits events into upcoming and past around today', () => { + const { upcoming, past } = Parse.parseEvents(EVENT_ROWS, '2026-10-23'); + assert.deepEqual(plain(upcoming.map((e) => e.title)), ['Sample upcoming workshop', 'Sample lab tour']); + assert.deepEqual(plain(past.map((e) => e.title)), ['Sample first meet', 'Sample online workshop']); + assert.equal(upcoming[0].time, '16:00–18:00'); + assert.equal(upcoming[0].register, 'https://forms.gle/example'); + assert.equal(upcoming[1].status, 'full'); + assert.equal(upcoming[1].register, '', 'unsafe links are dropped'); + assert.deepEqual(plain(past[1].recordings), [ + { label: 'Session 1', url: 'https://youtu.be/example1' }, + { label: 'Watch the recording', url: 'https://youtu.be/example2' } + ]); +}); + +test('an event on today counts as upcoming until the day ends', () => { + const { upcoming } = Parse.parseEvents(EVENT_ROWS, '2026-11-20'); + assert.equal(upcoming[0].title, 'Sample upcoming workshop'); +}); + +const SHOWCASE_ROWS = [ + ['Timestamp', 'Email address', 'Presenter name / ชื่อผู้นำเสนอ', 'Class (e.g. MDCU78) / รุ่น', 'Conference name / ชื่องานประชุม', + 'Short name (e.g. VSS 2026) / ชื่อย่อ', 'City, country / เมือง', 'Conference date / วันที่นำเสนอ', 'Format / รูปแบบ', + 'Research title / ชื่องานวิจัย', 'Research field / สาขา', 'Keywords / คำสำคัญ', 'Advisors / อาจารย์ที่ปรึกษา', + 'Abstract / บทคัดย่อ', 'Reflection / สิ่งที่ได้เรียนรู้', 'Photo / รูป', 'Consent / ยินยอม', 'Approved'], + ['1/6/2026', 'a@example.com', 'Student A', 'mdcu 78', 'Sample Vision Meeting', 'SVM 2026', 'Florida, USA', '2026-05-16', 'Poster presentation', + 'A sample study title', 'Neuroscience', 'attention, aging', 'Dr. Example', 'Abstract text', '', '', 'I agree', 'TRUE'], + ['2/6/2026', 'b@example.com', 'Student B', 'MDCU77', 'Sample Cardiology Session', 'SCS 2025', 'Chicago, USA', '2025-03-30', 'Oral', + 'งานวิจัยตัวอย่าง', 'Cardiology', '', '', '', '', '', 'I agree', 'TRUE'], + ['3/6/2026', 'c@example.com', 'Student C', 'MDCU76', 'Sample Asia Meeting', 'SAM 2024', 'Bangkok, Thailand', '2024-12-01', 'Competition', + 'Awaiting review', '', '', '', '', '', '', 'I agree', 'FALSE'], + ['4/6/2026', 'd@example.com', 'Student D', 'MDCU76', 'Sample Asia Meeting', 'SAM 2024', 'Bangkok, Thailand', '2024-12-01', 'Poster', + 'No consent given', '', '', '', '', '', '', '', 'TRUE'] +]; + +test('showcase shows only approved rows with consent, newest first', () => { + const items = Parse.parseShowcase(SHOWCASE_ROWS); + assert.deepEqual(plain(items.map((i) => i.title)), ['A sample study title', 'งานวิจัยตัวอย่าง']); + assert.equal(items[0].format, 'Poster'); + assert.equal(items[0].cls, 'MDCU78'); + assert.equal(items[0].year, '2026'); + assert.equal(items[0].country, 'USA'); + assert.equal(items[1].format, 'Oral'); + assert.equal(items[1].lang, 'th'); +}); + +test('showcase stats count distinct conferences and countries', () => { + const stats = Parse.showcaseStats(Parse.parseShowcase(SHOWCASE_ROWS)); + assert.deepEqual(plain(stats), { presentations: 2, conferences: 2, countries: 1, since: 2025 }); +}); + +test('only web and mail links are allowed', () => { + assert.equal(Parse.safeUrl('javascript:alert(1)'), ''); + assert.equal(Parse.safeUrl('www.example.com'), 'https://www.example.com'); + assert.equal(Parse.safeUrl('mailto:researchclub@docchula.com'), 'mailto:researchclub@docchula.com'); +}); diff --git a/google-sites/test/preview.mjs b/google-sites/test/preview.mjs new file mode 100644 index 0000000..1f5cef7 --- /dev/null +++ b/google-sites/test/preview.mjs @@ -0,0 +1,41 @@ +// Writes one HTML file per widget view, rendered from the placeholder fixtures, +// so the widgets can be checked in a browser without deploying. +// node google-sites/test/preview.mjs [outDir] [nowIso] +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import * as fixtures from './fixtures.mjs'; +import { render } from './harness.mjs'; + +const out = resolve(process.argv[2] || new URL('./out', import.meta.url).pathname); +const now = process.argv[3]; +const views = ['opportunities', 'open-now', 'hero-actions', 'stats', 'events-home', 'events', 'showcase-home', 'showcase-stats', 'showcase']; + +const pages = views.map((view) => [view, view, {}]).concat([ + ['opportunities-member', 'opportunities', { email: 'student@docchula.com' }], + ['opportunities-full', 'opportunities', { email: 'student@docchula.com', params: { full: '1' } }], + ['showcase-full', 'showcase', { params: { full: '1' } }] +]); + +// Frames each view at the size its Sites embed uses, and at the size Sites shrinks it to on a phone +// (Sites keeps the width-to-height ratio), so both can be checked side by side. +const FRAMES = [ + ['hero-actions', 620, 210, '#0B1630'], ['open-now', 440, 500, '#0B1630'], ['events-home', 1100, 600, '#F4F7FA'], + ['showcase-home', 1100, 800, '#FFFFFF'], ['opportunities', 1100, 1150, '#FFFFFF'], ['events', 1100, 330, '#FFFFFF'], + ['showcase-stats', 900, 120, '#0B1630'], ['showcase', 1100, 1400, '#FFFFFF'] +]; +const frameHtml = (w, h, bg) => FRAMES.map(([v, fw, fh, fbg]) => { + const s = Math.min(1, w / fw); + return `

${v} — ${Math.round(fw * s)}×${Math.round(fh * s)}

` + + `
`; +}).join(''); + +mkdirSync(out, { recursive: true }); +for (const [file, view, extra] of pages) { + writeFileSync(resolve(out, file + '.html'), render(view, { fixtures, ...(now ? { now } : {}), ...extra })); +} +writeFileSync(resolve(out, 'frames-desktop.html'), '' + frameHtml(1100) + ''); +writeFileSync(resolve(out, 'frames-phone.html'), '' + frameHtml(330) + ''); +writeFileSync(resolve(out, 'index.html'), 'Widget previews'); +console.log('Wrote ' + pages.length + ' previews to ' + out); diff --git a/google-sites/test/widgets.test.mjs b/google-sites/test/widgets.test.mjs new file mode 100644 index 0000000..308088e --- /dev/null +++ b/google-sites/test/widgets.test.mjs @@ -0,0 +1,132 @@ +// Run with: node --test google-sites/test/*.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fixtures from './fixtures.mjs'; +import { render, payload } from './harness.mjs'; + +const VIEWS = ['opportunities', 'open-now', 'hero-actions', 'stats', 'events-home', 'events', 'showcase-home', 'showcase-stats', 'showcase']; + +test('every view renders a complete page without the error fallback', () => { + for (const view of VIEWS) { + const html = render(view, { fixtures }); + const data = payload(html); + assert.ok(!data.error, view + ' loaded with an error'); + assert.match(html, /
<\/div>/, view); + assert.match(html, /:root \{/, view + ' is missing Styles.html'); + assert.match(html, /function renderOpportunities/, view + ' is missing App.html'); + assert.ok(!/<\?/.test(html), view + ' has an unevaluated scriptlet'); + } +}); + +const MEMBER = 'student@docchula.com'; + +test('opportunities: newest academic year, projects before open labs, closed hidden', () => { + const data = payload(render('opportunities', { fixtures, email: MEMBER })); + assert.equal(data.viewer.member, true); + assert.equal(data.year, '2026-2027'); + assert.deepEqual(data.years, ['2026-2027', '2025-2026']); + assert.deepEqual(data.items.map((i) => i.kind), ['project', 'project', 'open']); + assert.equal(data.items[0].title, 'การศึกษาปัจจัยเสี่ยงของภาวะหกล้มในผู้สูงอายุในชุมชน', 'newest answer first'); + assert.equal(data.items[2].advisor, 'ศ. นพ. ตัวอย่าง สาม'); + assert.equal(data.items[2].dept, 'จุลชีววิทยา'); +}); + +test('open-now: only defined projects, with the total for the button', () => { + const data = payload(render('open-now', { fixtures })); + assert.equal(data.total, 3); + assert.equal(data.items.length, 2); + assert.ok(data.items.every((i) => i.kind === 'project')); +}); + +test('events: only upcoming events are sent; real date cells are read regardless of display format', () => { + const data = payload(render('events', { fixtures })); + assert.deepEqual(data.upcoming.map((e) => e.date), ['2026-11-20', '2026-12-05']); + assert.equal(data.upcoming[0].time, '16:00–18:30'); + assert.equal(data.upcoming[0].poster, 'https://lh3.googleusercontent.com/d/1AbCdEfGhIjKlMnOpQrStUvWxYz012345=w1200'); + assert.equal(data.past, undefined, 'past activities are hand-made posts in Google Sites'); +}); + +test('events-home: next event, or an empty state once it has passed, plus the two latest activities', () => { + const later = payload(render('events-home', { fixtures, now: '2027-01-10T09:00:00+07:00' })); + assert.equal(later.next, null); + assert.equal(later.upcomingCount, 0); + assert.deepEqual(later.recent.map((e) => e.date), ['2026-12-05', '2026-11-20']); + const now = payload(render('events-home', { fixtures })); + assert.equal(now.next.title, 'Sample workshop: writing your first abstract'); + assert.equal(now.upcomingCount, 2); + assert.deepEqual(now.recent.map((e) => e.title), ['Sample First Meet', 'Sample statistics workshop']); +}); + +test('hero-actions and showcase-stats send only numbers', () => { + const hero = payload(render('hero-actions', { fixtures })); + assert.equal(hero.openProjects, 3); + assert.equal(hero.stats.presentations, 4); + const sc = payload(render('showcase-stats', { fixtures })); + assert.deepEqual(Object.keys(sc), ['stats']); +}); + +test('links to the standalone page use the club-domain address', () => { + const html = render('opportunities', { fixtures }); + assert.match(html, /"self":"https:\/\/script\.google\.com\/a\/macros\/docchula\.com\/s\/TEST\/exec"/); +}); + +test('showcase: approved rows only, stats across conferences and countries', () => { + const data = payload(render('showcase', { fixtures })); + assert.equal(data.items.length, 4); + assert.deepEqual(data.stats, { presentations: 4, conferences: 4, countries: 3, since: 2024 }); + const home = payload(render('showcase-home', { fixtures })); + assert.equal(home.items.length, 3); + assert.deepEqual(home.conferences, ['SVM', 'SCS', 'SEM', 'SAM']); +}); + +test('a broken sheet ID shows the friendly fallback instead of a Google error page', () => { + const html = render('events', { fixtures, config: { WEBSITE_DATA_SHEET_ID: 'MISSING' } }); + assert.equal(payload(html).error, true); +}); + +test('signed-out visitors only receive the public opportunity fields', () => { + const html = render('opportunities', { fixtures }); + const data = payload(html); + assert.equal(data.viewer.member, false); + assert.equal(data.items.length, 3, 'every project is still listed'); + for (const item of data.items) { + assert.deepEqual(Object.keys(item).sort(), ['dept', 'id', 'keywords', 'kind', 'lang', 'locked', 'seats', 'status', 'title']); + } + for (const secret of ['example.one@example.com', 'example-two', 'Build and validate a model', 'Curriculum vitae', 'ตัวอย่าง หนึ่ง', 'Mar 2027']) { + assert.ok(!html.includes(secret), 'sent to a signed-out visitor: ' + secret); + } +}); + +test('only accounts on the member domain unlock details', () => { + const member = payload(render('opportunities', { fixtures, email: 'Student@DocChula.com' })); + assert.equal(member.viewer.member, true); + assert.equal(member.items[1].contact, 'example.one@example.com'); + for (const email of ['someone@gmail.com', 'x@notdocchula.com', 'x@docchula.com.evil.example']) { + const data = payload(render('opportunities', { fixtures, email })); + assert.equal(data.viewer.member, false, email); + assert.ok(data.items.every((i) => i.locked && !('advisor' in i) && !('contact' in i)), email); + } +}); + +test('the home "open now" panel never carries hidden fields for signed-out visitors', () => { + const data = payload(render('open-now', { fixtures })); + assert.equal(data.total, 3); + assert.ok(data.items.every((i) => i.locked && !('advisor' in i) && !('duration' in i))); +}); + +test('the members copy opens as a full page with the sign-in link pointing at it', () => { + const html = render('opportunities', { + fixtures, email: MEMBER, params: { full: '1' }, + config: { MEMBERS_APP_URL: 'https://script.google.com/a/macros/docchula.com/s/MEMBERS/exec' } + }); + assert.match(html, /"fullPage":true/); + assert.match(html, /"membersUrl":"https:\/\/script\.google\.com\/a\/macros\/docchula\.com\/s\/MEMBERS\/exec"/); +}); + +test('sheet text cannot break out of the data script tag', () => { + const hostile = structuredClone(fixtures.events); + hostile[1][4] = ''; + const html = render('events', { fixtures: { ...fixtures, events: hostile } }); + assert.ok(!html.includes(''); +});