diff --git a/files/issue-template/meeting.yml b/files/issue-template/meeting.yml
new file mode 100644
index 0000000..37cb0e7
--- /dev/null
+++ b/files/issue-template/meeting.yml
@@ -0,0 +1,110 @@
+name: Group session
+description: Propose a TPAC group session.
+labels: ["session"]
+body:
+# This repo includes code that validates instances of the data below.
+# The validation code parses this file and uses "id" for some aspects of validation.
+# One implication is that labels below can be changed without disrupting some of the validation code.
+# However, the validation code in some cases also matches on values of "options" below, so if those change,
+# you will need to change the validation code as well.
+
+ - type: markdown
+ attributes:
+ value: |
+ Thank you for proposing a TPAC session. The title should be the name of the group or groups who will be meeting. The name must be the official [group name in the W3C system](https://www.w3.org/groups/), with some abbreviations supported (e.g., WG for Working Group).
+
+ - type: dropdown
+ id: capacity
+ attributes:
+ label: Estimate of in-person participants
+ options:
+ - More than 40
+ - 30 to 39
+ - 20 to 29
+ - Less than 20
+ validations:
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Scheduling preferences
+
+ - type: checkboxes
+ id: times
+ attributes:
+ label: Select preferred dates and times (23-27 September)
+ description: You may select more than one
+ options:
+ - label: Monday, 09:00 - 10:30
+ - label: Monday, 11:00 - 12:30
+ - label: Monday, 14:00 - 16:00
+ - label: Monday, 16:30 - 18:00
+ - label: Tuesday, 09:00 - 10:30
+ - label: Tuesday, 11:00 - 12:30
+ - label: Tuesday, 14:00 - 16:00
+ - label: Tuesday, 16:30 - 18:00
+ - label: Thursday, 09:00 - 10:30
+ - label: Thursday, 11:00 - 12:30
+ - label: Thursday, 14:00 - 16:00
+ - label: Thursday, 16:30 - 18:00
+ - label: Friday, 09:00 - 10:30
+ - label: Friday, 11:00 - 12:30
+ - label: Friday, 14:00 - 16:00
+ - label: Friday, 16:30 - 18:00
+
+ - type: textarea
+ id: conflicts
+ attributes:
+ label: Other sessions where we should avoid scheduling conflicts (Optional)
+ description: |
+ Identify sessions by their issue number in this GitHub repo (e.g., `#32, #18`). Space- or comma-separated list.
+ validations:
+ required: false
+
+ - type: textarea
+ id: comments
+ attributes:
+ label: Other instructions for meeting planners (Optional)
+ description: |
+ Any information for the meeting planners, including timing constraints or groups not yet registered where overlap should be avoided. This information will not be exported to the TPAC site or calendar.
+ validations:
+ required: false
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Logistics
+
+ - type: markdown
+ attributes:
+ value: |
+ > [!Note]
+ The meeting planners will provide additional logistics information automatically, including calendar information.
+
+ - type: input
+ id: discussion
+ attributes:
+ label: Discussion channel (Optional)
+ description: |
+ A URL for a meeting discussion channel (e.g., IRC, Slack). If provided, this will be added to the calendar invitation.
+ validations:
+ required: false
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Agenda
+
+ - type: textarea
+ id: agenda
+ attributes:
+ label: Agenda for the meeting.
+ description: |
+ This part may be completed closer to the meeting. As the agenda becomes available, you will be able to update your session description in markdown to **detail the agenda or link to an external agenda**. Agenda information will be pushed to the calendar.
+
+ - type: markdown
+ attributes:
+ value: |
+ > [!Note]
+ After the meeting, the meeting planners will add a section to the session description for meeting materials such as links to minutes, presentations, and any recordings.
diff --git a/files/issue-template/session.yml b/files/issue-template/session.yml
new file mode 100644
index 0000000..46e2cd4
--- /dev/null
+++ b/files/issue-template/session.yml
@@ -0,0 +1,112 @@
+name: Session proposal
+description: Propose to chair a breakout session
+labels: ["session"]
+body:
+# This repo includes code that validates instances of the data below.
+# The validation code parses this file and uses "id" for some aspects of validation.
+# One implication is that labels below can be changed without disrupting some of the validation code.
+# However, the validation code in some cases also matches on values of "options" below, so if those change,
+# you will need to change the validation code as well.
+
+ - type: markdown
+ attributes:
+ value: |
+ Thank you for proposing to chair a breakout session. Please ensure that the session is [in scope for a breakout](https://github.com/w3c/tpac-breakouts/wiki/Policies#session-scope) and review the [good practices for session chairs](https://github.com/w3c/tpac-breakouts/wiki/Good-Practices-for-Session-Chairs), which includes information about [how you can later update your session](https://github.com/w3c/tpac-breakouts/wiki/Good-Practices-for-Session-Chairs#how-to-update-a-session).
+
+ - type: textarea
+ id: description
+ attributes:
+ label: Session description
+ description: |
+ Simple markdown only please (inline formatting, links, lists).
+ validations:
+ required: true
+
+ - type: input
+ id: goal
+ attributes:
+ label: Session goal
+ validations:
+ required: true
+
+ - type: textarea
+ id: chairs
+ attributes:
+ label: Additional session chairs (Optional)
+ description: |
+ GitHub identities of additional session chairs other than you (e.g., `@tidoust, @ianbjacobs`). Space- or comma-separated list.
+ validations:
+ required: false
+
+ - type: dropdown
+ id: attendance
+ attributes:
+ label: Who can attend
+ description: |
+ TPAC breakouts sessions are usually open to the public (including remote attendees). Please only select “Restricted” if there is a compelling reason why the session should only be open to TPAC registrants.
+ options:
+ - Anyone may attend (Default)
+ - Restricted to TPAC registrants
+ validations:
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Logistics
+
+ - type: markdown
+ attributes:
+ value: |
+ > [!Note]
+ The meeting planners will provide additional logistics information automatically, including calendar information.
+
+ - type: input
+ id: shortname
+ attributes:
+ label: IRC channel (Optional)
+ description: |
+ Shortname for the irc.w3.org channel (e.g., `#my-fav-session`). If not provided, shortname will be generated from title.
+ validations:
+ required: false
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Preferences
+
+ - type: textarea
+ id: conflicts
+ attributes:
+ label: Other sessions where we should avoid scheduling conflicts (Optional)
+ description: |
+ Identify sessions by their issue number in this GitHub repo (e.g., `#32, #18`). Please do not use links, just '#' followed by an issue number. Space- or comma-separated list. Note: When someone chairs 2 or more sessions, we automatically do not schedule those sessions in the same slots, so there is no need to note those conflicts there.
+ validations:
+ required: false
+
+ - type: textarea
+ id: comments
+ attributes:
+ label: Instructions for meeting planners (Optional)
+ description: |
+ Any information for the meeting planners, including [timing constraints](https://github.com/w3c/tpac2024-breakouts/wiki/Breakout%E2%80%90time%E2%80%90slots) or groups not yet registered where overlap should be avoided. This information will not be exported to the event site or calendar.
+ validations:
+ required: false
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Agenda
+
+ - type: textarea
+ id: agenda
+ attributes:
+ label: Agenda for the meeting.
+ description: |
+ This part may be completed closer to the meeting. As the agenda becomes available, you will be able to update your session description in markdown to **detail the agenda or link to an external agenda**. Agenda information will be pushed to the calendar.
+
+ - type: markdown
+ attributes:
+ value: |
+ > [!Note]
+ After the meeting, the meeting planners will add a section to the session description for meeting materials such as links to minutes, presentations, and any recordings.
diff --git a/files/session-created.md b/files/session-created.md
new file mode 100644
index 0000000..c2bcbd4
--- /dev/null
+++ b/files/session-created.md
@@ -0,0 +1,5 @@
+Thank you for proposing a session!
+
+You may update the session description as needed and at any time before the meeting, but please keep in mind that tooling relies on issue formatting: [follow the instructions](https://github.com/w3c/tpac-breakouts/wiki/Good-Practices-for-Session-Chairs#how-to-propose-a-session) and leave all headings and other formatting intact in particular. Bots and W3C meeting organizers may also update the description, to fix formatting issues or add links and other relevant information. Please do not revert these changes. Feel free to use comments to raise questions.
+
+Do not expect formal approval; W3C meeting organizers endeavor to schedule all proposed sessions that are [in scope for a breakout](https://github.com/w3c/tpac-breakouts/wiki/Policies#session-scope). Actual scheduling should take place shortly before the meeting.
diff --git a/files/workflows/add-minutes.yml b/files/workflows/add-minutes.yml
new file mode 100644
index 0000000..20c3e5c
--- /dev/null
+++ b/files/workflows/add-minutes.yml
@@ -0,0 +1,55 @@
+name: "[M] Link to session minutes"
+
+on:
+ workflow_dispatch:
+ inputs:
+ sessionNumber:
+ description: 'Session issue number or "all" to link all possible minutes'
+ required: true
+ default: 'all'
+ type: string
+
+jobs:
+ add-minutes:
+ name: Link to session minutes
+ runs-on: ubuntu-latest
+ steps:
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Checkout latest version of release script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ # Note: no "package-lock.json" and no "npm ci" on purpose to retrieve
+ # latest version of w3c/tpac-breakouts tools (which are unversioned)
+ - name: Install dependencies
+ run: npm install
+
+ - name: Link to session minutes
+ run: npx add-minutes ${{ inputs.sessionNumber }}
+ env:
+ # URL of the annual TPAC XXXX breakout project.
+ # The PROJECT_OWNER and PROJECT_NUMBER variables must be defined on
+ # the repository. PROJECT_OWNER_TYPE needs to be set to "user" if
+ # project belongs to a user. It may be omitted otherwise (or set to
+ # 'org"').
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+
+ # A valid Personal Access Token (classic version) with project
+ # and public_repo scope.
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+
+ # Information about the team user on behalf of which the updates to
+ # the calendar will be made. The password must obviously be stored
+ # as a secret!
+ W3C_LOGIN: ${{ vars.W3C_LOGIN }}
+ W3C_PASSWORD: ${{ secrets.W3C_PASSWORD }}
+
+ # Mapping between chair GitHub identities and W3C IDs must be stored
+ # in a variable. Structure is a JSON object with identities as keys.
+ CHAIR_W3CID: ${{ vars.CHAIR_W3CID }}
diff --git a/files/workflows/setup-irc.yml b/files/workflows/setup-irc.yml
new file mode 100644
index 0000000..a25697b
--- /dev/null
+++ b/files/workflows/setup-irc.yml
@@ -0,0 +1,58 @@
+name: "[M] Setup IRC channels"
+
+on:
+ workflow_dispatch:
+ inputs:
+ sessionNumber:
+ description: 'Session issue number or "all" to initialize IRC channels for all valid sessions in the slot.'
+ required: true
+ default: 'all'
+ type: string
+ dismiss:
+ description: 'Setup channel, or dismiss bots'
+ required: true
+ default: 'setup'
+ type: choice
+ options:
+ - setup
+ - dismiss
+
+jobs:
+ update-calendar:
+ name: Setup IRC channels
+ runs-on: ubuntu-latest
+ steps:
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Checkout latest version of release script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ # Note: no "package-lock.json" and no "npm ci" on purpose to retrieve
+ # latest version of w3c/tpac-breakouts tools (which are unversioned)
+ - name: Install dependencies
+ run: npm install
+
+ - name: Run the setup script
+ run: npx setup-irc ${{ inputs.sessionNumber }} full ${{ inputs.dismiss }}
+ env:
+ # URL of the annual TPAC XXXX breakout project.
+ # The PROJECT_OWNER and PROJECT_NUMBER variables must be defined on
+ # the repository. PROJECT_OWNER_TYPE needs to be set to "user" if
+ # project belongs to a user. It may be omitted otherwise (or set to
+ # 'org"').
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+
+ # A valid Personal Access Token (classic version) with project
+ # and public_repo scope.
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+
+ # Mapping between chair GitHub identities and W3C IDs must be stored
+ # in a variable. Structure is a JSON object with identities as keys.
+ CHAIR_W3CID: ${{ vars.CHAIR_W3CID }}
+
diff --git a/files/workflows/suggest-grid.yml b/files/workflows/suggest-grid.yml
new file mode 100644
index 0000000..65efc4e
--- /dev/null
+++ b/files/workflows/suggest-grid.yml
@@ -0,0 +1,84 @@
+name: "[M] Create a schedule"
+
+on:
+ workflow_dispatch:
+ inputs:
+ preservelist:
+ description: 'Space-separated (no comma!) list of session numbers whose meetings must be preserved. Or "all" to preserve all meetings. Or "none" to ignore existing meetings.'
+ required: true
+ default: 'all'
+ type: string
+ exceptlist:
+ description: 'Only makes sense when previous value is "all"! Space-separated (no comma!) list of session numbers whose meetings are to be discarded. Or "none" to mean "preserve all meetings".'
+ required: true
+ default: 'none'
+ type: string
+ apply:
+ description: 'Whether to suggest a schedule (default) or apply it.'
+ required: true
+ default: 'suggest'
+ type: choice
+ options:
+ - suggest
+ - apply
+ seed:
+ description: 'The seed to use to shuffle the array of sessions initially.'
+ default: ''
+ type: string
+
+jobs:
+ suggest-grid:
+ name: Create a schedule
+ runs-on: ubuntu-latest
+ steps:
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Checkout latest version of release script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Create directory to store result
+ run: mkdir .schedule
+
+ - name: Create a schedule
+ # Note the use of YAML "folded style" (through `>`) to split the
+ # command into multiple lines, and of the ternary like operator to set
+ # CLI parameters correctly. For details, see:
+ # https://yaml-multiline.info/
+ # https://docs.github.com/en/actions/learn-github-actions/expressions#example
+ run: >
+ npx tpac-breakouts schedule
+ --preserve ${{ inputs.preservelist }}
+ --except ${{ inputs.exceptlist }}
+ ${{ inputs.seed && format('--seed {0}', inputs.seed) || '' }}
+ ${{ inputs.apply == 'apply' && ' --apply' || '' }} > .schedule/index.html
+ env:
+ # URL of the annual TPAC XXXX breakout project.
+ # The PROJECT_OWNER and PROJECT_NUMBER variables must be defined on
+ # the repository. PROJECT_OWNER_TYPE needs to be set to "user" if
+ # project belongs to a user. It may be omitted otherwise (or set to
+ # 'org"').
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+
+ # Same valid Personal Access Token (classic version) as above, with
+ # project and public_repo scope.
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ GH_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+
+ # Mapping between chair GitHub identities and W3C IDs must be stored
+ # in a variable. Structure is a JSON object with identities as keys.
+ W3CID_MAP: ${{ vars.W3CID_MAP }}
+
+ - name: Create ZIP artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: schedule
+ path: .schedule
diff --git a/files/workflows/sync-spreadsheet.yml b/files/workflows/sync-spreadsheet.yml
new file mode 100644
index 0000000..70f416e
--- /dev/null
+++ b/files/workflows/sync-spreadsheet.yml
@@ -0,0 +1,40 @@
+name: "[M] Export to a Google spreadsheet"
+
+on:
+ workflow_dispatch:
+ inputs:
+ sheet:
+ description: 'The ID of the Google spreadsheet where data needs to be exported'
+ required: true
+ type: string
+
+jobs:
+ suggest-grid:
+ name: Export data to Google spreadsheet
+ runs-on: ubuntu-latest
+ steps:
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Checkout latest version of release script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Dump key file
+ run: echo '${{ secrets.GOOGLE_KEY }}' > key.json
+
+ - name: Export data
+ run: npx tpac-breakouts sync-sheet --sheet ${{ inputs.sheet }}
+ env:
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ GH_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ W3CID_MAP: ${{ vars.W3CID_MAP }}
+ GOOGLE_KEY_FILE: key.json
diff --git a/files/workflows/update-calendar.yml b/files/workflows/update-calendar.yml
new file mode 100644
index 0000000..768ea24
--- /dev/null
+++ b/files/workflows/update-calendar.yml
@@ -0,0 +1,76 @@
+name: "[M] Update W3C calendar"
+
+on:
+ workflow_dispatch:
+ inputs:
+ sessionNumber:
+ description: 'Session issue number or "all" to convert all valid sessions'
+ required: true
+ default: 'all'
+ type: string
+ calendarstatus:
+ description: 'Calendar entry status to use'
+ required: true
+ default: 'draft'
+ type: choice
+ options:
+ - draft
+ - tentative
+ - confirmed
+
+jobs:
+ update-calendar:
+ name: Update W3C calendar
+ runs-on: ubuntu-latest
+ steps:
+ # Starting with Ubuntu 23+, a security feature prevents running Puppeteer
+ # by default. It needs to be disabled. Using the "easiest" option, see:
+ # https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md
+ # https://github.com/puppeteer/puppeteer/pull/13196/files
+ - name: Disable AppArmor
+ run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
+
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Checkout latest version of release script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Convert session issues to calendar entries.
+ run: npx tpac-breakouts sync-calendar ${{ inputs.sessionNumber }} --status ${{ inputs.calendarstatus }}
+ env:
+ # URL of the annual TPAC XXXX breakout project.
+ # The PROJECT_OWNER and PROJECT_NUMBER variables must be defined on
+ # the repository. PROJECT_OWNER_TYPE needs to be set to "user" if
+ # project belongs to a user. It may be omitted otherwise (or set to
+ # 'org"').
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+
+ # A valid Personal Access Token (classic version) with project
+ # and public_repo scope.
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ GH_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+
+ # Information about the team user on behalf of which the updates to
+ # the calendar will be made. The password must obviously be stored
+ # as a secret!
+ W3C_LOGIN: ${{ vars.W3C_LOGIN }}
+ W3C_PASSWORD: ${{ secrets.W3C_PASSWORD }}
+
+ # Mapping between rooms and Zoom meetings must be stored in a variable
+ # (so that it does not get published). Structure is a JSON object
+ # with room names as keys.
+ ROOM_ZOOM: ${{ vars.ROOM_ZOOM }}
+
+ # Mapping between chair GitHub identities and W3C IDs must be stored
+ # in a variable. Structure is a JSON object with identities as keys.
+ W3CID_MAP: ${{ vars.W3CID_MAP }}
+
diff --git a/files/workflows/validate-grid.yml b/files/workflows/validate-grid.yml
new file mode 100644
index 0000000..1eaf14e
--- /dev/null
+++ b/files/workflows/validate-grid.yml
@@ -0,0 +1,52 @@
+name: "[M] Validate all sessions"
+
+on:
+ workflow_dispatch:
+ inputs:
+ validation:
+ description: 'Validate only scheduling conflicts (default) or re-validate all sessions'
+ required: true
+ default: 'scheduling'
+ type: choice
+ options:
+ - scheduling
+ - everything
+
+jobs:
+ validate-grid:
+ name: Validate session grid
+ runs-on: ubuntu-latest
+ steps:
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Checkout latest version of release script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Validate grid and update project fields
+ run: npx tpac-breakouts validate all --what ${{ inputs.validation }}
+ env:
+ # URL of the annual TPAC XXXX breakout project.
+ # The PROJECT_OWNER and PROJECT_NUMBER variables must be defined on
+ # the repository. PROJECT_OWNER_TYPE needs to be set to "user" if
+ # project belongs to a user. It may be omitted otherwise (or set to
+ # 'org"').
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+
+ # A valid Personal Access Token (classic version) with project
+ # and public_repo scope.
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ GH_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+
+ # Mapping between chair GitHub identities and W3C IDs must be stored
+ # in a variable. Structure is a JSON object with identities as keys.
+ W3CID_MAP: ${{ vars.W3CID_MAP }}
+
diff --git a/files/workflows/validate-session-manual.yml b/files/workflows/validate-session-manual.yml
new file mode 100644
index 0000000..29760d3
--- /dev/null
+++ b/files/workflows/validate-session-manual.yml
@@ -0,0 +1,48 @@
+name: "[M] Validate a session"
+
+on:
+ workflow_dispatch:
+ inputs:
+ sessionNumber:
+ description: 'Session issue number'
+ required: true
+ type: string
+
+jobs:
+ validate-session:
+ name: Validate session
+ runs-on: ubuntu-latest
+ steps:
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Checkout latest version of release script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Validate session and update project fields
+ run: npx tpac-breakouts validate ${{ inputs.sessionNumber }} --what everything
+ env:
+ # URL of the annual TPAC XXXX breakout project.
+ # The PROJECT_OWNER and PROJECT_NUMBER variables must be defined on
+ # the repository. PROJECT_OWNER_TYPE needs to be set to "user" if
+ # project belongs to a user. It may be omitted otherwise (or set to
+ # 'org"').
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+
+ # Same valid Personal Access Token (classic version) as above, with
+ # project and public_repo scope.
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ GH_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+
+ # Mapping between chair GitHub identities and W3C IDs must be stored
+ # in a variable. Structure is a JSON object with identities as keys.
+ W3CID_MAP: ${{ vars.W3CID_MAP }}
+
diff --git a/files/workflows/validate-session.yml b/files/workflows/validate-session.yml
new file mode 100644
index 0000000..663176f
--- /dev/null
+++ b/files/workflows/validate-session.yml
@@ -0,0 +1,119 @@
+name: "[A] Validate session and update W3C calendar"
+
+on:
+ issues:
+ # Details for types below can be found at:
+ # https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads?actionType=edited#issues
+ types:
+ # Job triggered when an issue is created or re-opened
+ - opened
+ - reopened
+
+ # or gets "edited" (title or body updated)
+ - edited
+
+jobs:
+ validate-session:
+ name: Validate session and update W3C calendar
+ runs-on: ubuntu-latest
+ # We're only interested in "session" issues
+ # and don't want to react to edits made by the bot as a consequence of
+ # a previous run of this job
+ if: ${{ !endsWith(github.actor, '-bot') && contains(github.event.issue.labels.*.name, 'session') }}
+ steps:
+ # Starting with Ubuntu 23+, a security feature prevents running Puppeteer
+ # by default. It needs to be disabled. Using the "easiest" option, see:
+ # https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md
+ # https://github.com/puppeteer/puppeteer/pull/13196/files
+ - name: Disable AppArmor
+ run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
+
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Checkout latest version of release script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Add issue to TPAC breakout session project
+ if: ${{ github.event.action == 'opened' || github.event.action == 'reopened' }}
+ uses: actions/add-to-project@v0.5.0
+ with:
+ # Note: This isn't really necessary since we already made sure that
+ # issue is a "session" issue
+ labeled: session
+
+ # URL of the annual TPAC XXXX breakout project.
+ # The PROJECT_OWNER and PROJECT_NUMBER variables must be defined on
+ # the repository. PROJECT_OWNER_TYPE needs to be set to "user" if
+ # project belongs to a user. It may be omitted otherwise (or set to
+ # 'org"').
+ project-url: https://github.com/${{vars.PROJECT_OWNER_TYPE || 'org'}}s/${{vars.PROJECT_OWNER || 'w3c'}}/projects/${{vars.PROJECT_NUMBER}}
+
+ # A valid Personal Access Token (classic version) with project scope
+ # (and public_repo scope so that labels may be updated) needs to be
+ # added as secret to the repo, because the action uses the GraphQL
+ # API under the hoods.
+ github-token: ${{ secrets.GRAPHQL_TOKEN }}
+
+ - name: Add thank you comment with links to documentation
+ if: ${{ github.event.action == 'opened' }}
+ run: gh issue comment "$NUMBER" --body-file "$BODY_FILE"
+ env:
+ GH_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ NUMBER: ${{ github.event.issue.number }}
+ BODY_FILE: .github/session-created.md
+
+ - name: Dump changes to local file
+ run: echo '${{ toJSON(github.event.issue.changes || '{}') }}' > changes.json
+ shell: bash
+
+ - name: Validate session and update issue labels accordingly
+ run: npx tpac-breakouts validate ${{ github.event.issue.number }} --changes changes.json --what everything
+ env:
+ # See above for PROJECT_XX variables
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+
+ # Same valid Personal Access Token (classic version) as above, with
+ # project and public_repo scope.
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ GH_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+
+ # Mapping between chair GitHub identities and W3C IDs must be stored
+ # in a variable. Structure is a JSON object with identities as keys.
+ W3CID_MAP: ${{ vars.W3CID_MAP }}
+
+ - name: Create/Update calendar entry
+ run: npx tpac-breakouts sync-calendar ${{ github.event.issue.number }} --quiet
+ env:
+ # See above for PROJECT_XX variables
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+
+ # Same valid Personal Access Token (classic version) as above, with
+ # project and public_repo scope.
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ GH_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+
+ # Information about the team user on behalf of which the updates to
+ # the calendar will be made. The password must obviously be stored
+ # as a secret!
+ W3C_LOGIN: ${{ vars.W3C_LOGIN }}
+ W3C_PASSWORD: ${{ secrets.W3C_PASSWORD }}
+
+ # Mapping between rooms and Zoom meetings must be stored in a variable
+ # (so that it does not get published). Structure is a JSON object
+ # with room names as keys.
+ ROOM_ZOOM: ${{ vars.ROOM_ZOOM }}
+
+ # Mapping between chair GitHub identities and W3C IDs must be stored
+ # in a variable. Structure is a JSON object with identities as keys.
+ W3CID_MAP: ${{ vars.W3CID_MAP }}
diff --git a/files/workflows/view-event.yml b/files/workflows/view-event.yml
new file mode 100644
index 0000000..c5ea431
--- /dev/null
+++ b/files/workflows/view-event.yml
@@ -0,0 +1,51 @@
+name: "[M] View current schedule"
+
+on:
+ workflow_dispatch:
+
+jobs:
+ view-event:
+ name: View the current schedule
+ runs-on: ubuntu-latest
+ steps:
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Checkout latest version of release script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Create directory to store result
+ run: mkdir .schedule
+
+ - name: Generate the HTML page
+ run: npx tpac-breakouts view --format html > .schedule/index.html
+ env:
+ # URL of the annual TPAC XXXX breakout project.
+ # The PROJECT_OWNER and PROJECT_NUMBER variables must be defined on
+ # the repository. PROJECT_OWNER_TYPE needs to be set to "user" if
+ # project belongs to a user. It may be omitted otherwise (or set to
+ # 'org"').
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+
+ # Same valid Personal Access Token (classic version) as above, with
+ # project and public_repo scope.
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ GH_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+
+ # Mapping between chair GitHub identities and W3C IDs must be stored
+ # in a variable. Structure is a JSON object with identities as keys.
+ W3CID_MAP: ${{ vars.W3CID_MAP }}
+
+ - name: Create ZIP artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: schedule
+ path: .schedule
diff --git a/files/workflows/view-registrants.yml b/files/workflows/view-registrants.yml
new file mode 100644
index 0000000..1cb46b5
--- /dev/null
+++ b/files/workflows/view-registrants.yml
@@ -0,0 +1,70 @@
+name: "[M] Refresh and view registrants"
+
+on:
+ workflow_dispatch:
+ inputs:
+ sessionNumber:
+ description: 'Session issue number or "all" to view/update all valid sessions'
+ required: true
+ default: 'all'
+ type: string
+
+jobs:
+ view-event:
+ name: Refresh and view number of registrants for the meetings
+ runs-on: ubuntu-latest
+ steps:
+ # Starting with Ubuntu 23+, a security feature prevents running Puppeteer
+ # by default. It needs to be disabled. Using the "easiest" option, see:
+ # https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md
+ # https://github.com/puppeteer/puppeteer/pull/13196/files
+ - name: Disable AppArmor
+ run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
+
+ - name: Setup node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Checkout latest version of release script
+ uses: actions/checkout@v4
+ with:
+ ref: main
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Create directory to store result
+ run: mkdir .registrants
+
+ - name: View/Update registrants
+ run: npx tpac-breakouts view-registrants ${{ inputs.sessionNumber }} --fetch --save > .registrants/index.md
+ env:
+ # URL of the annual TPAC XXXX breakout project.
+ # The PROJECT_OWNER and PROJECT_NUMBER variables must be defined on
+ # the repository. PROJECT_OWNER_TYPE needs to be set to "user" if
+ # project belongs to a user. It may be omitted otherwise (or set to
+ # 'org"').
+ PROJECT_OWNER: ${{ vars.PROJECT_OWNER_TYPE || 'organization' }}/${{ vars.PROJECT_OWNER || 'w3c' }}
+ PROJECT_NUMBER: ${{ vars.PROJECT_NUMBER }}
+
+ # Same valid Personal Access Token (classic version) as above, with
+ # project and public_repo scope.
+ GRAPHQL_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+ GH_TOKEN: ${{ secrets.GRAPHQL_TOKEN }}
+
+ # Information about the team user on behalf of which the updates to
+ # the calendar will be made. The password must obviously be stored
+ # as a secret!
+ W3C_LOGIN: ${{ vars.W3C_LOGIN }}
+ W3C_PASSWORD: ${{ secrets.W3C_PASSWORD }}
+
+ # Mapping between chair GitHub identities and W3C IDs must be stored
+ # in a variable. Structure is a JSON object with identities as keys.
+ W3CID_MAP: ${{ vars.W3CID_MAP }}
+
+ - name: Create ZIP artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: registrants
+ path: .registrants
diff --git a/package-lock.json b/package-lock.json
index fc949ec..09ddb8e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,6 @@
"googleapis": "^144.0.0",
"irc": "^0.5.2",
"puppeteer": "^23.6.1",
- "seedrandom": "^3.0.5",
"webvtt-parser": "^2.2.0",
"yaml": "^2.5.0"
},
@@ -4379,12 +4378,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/seedrandom": {
- "version": "3.0.5",
- "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
- "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
- "license": "MIT"
- },
"node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
diff --git a/package.json b/package.json
index 17f53f9..b8ef7a9 100644
--- a/package.json
+++ b/package.json
@@ -49,13 +49,12 @@
"googleapis": "^144.0.0",
"irc": "^0.5.2",
"puppeteer": "^23.6.1",
- "seedrandom": "^3.0.5",
"webvtt-parser": "^2.2.0",
"yaml": "^2.5.0"
},
"devDependencies": {
- "mocha": "^10.7.3",
"@google/clasp": "^2.4.2",
+ "mocha": "^10.7.3",
"rollup": "^4.24.3"
}
}
diff --git a/test/check-group-highlight.mjs b/test/check-group-highlight.mjs
index 99e9c4f..9e4bb1c 100644
--- a/test/check-group-highlight.mjs
+++ b/test/check-group-highlight.mjs
@@ -1,10 +1,11 @@
import * as assert from 'node:assert';
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject, validateProject } from '../tools/lib/project.mjs';
-import { validateSession } from '../tools/lib/validate.mjs';
-import { groupSessionMeetings } from '../tools/lib/meetings.mjs';
-import { convertProjectToHTML } from '../tools/lib/project2html.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateProject } from '../tools/common/project.mjs';
+import { validateSession } from '../tools/common/validate.mjs';
+import { groupSessionMeetings } from '../tools/common/meetings.mjs';
+import { convertProjectToHTML } from '../tools/node/lib/project2html.mjs';
import { readFile, writeFile } from 'node:fs/promises';
async function fetchTestProject() {
diff --git a/test/check-group-meetings.mjs b/test/check-group-meetings.mjs
index 986e696..2167ca5 100644
--- a/test/check-group-meetings.mjs
+++ b/test/check-group-meetings.mjs
@@ -1,14 +1,14 @@
import * as assert from 'node:assert';
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
-import { validateSession } from '../tools/lib/validate.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateSession } from '../tools/common/validate.mjs';
import { groupSessionMeetings,
computeSessionCalendarUpdates,
parseSessionMeetings,
- parseMeetingsChanges,
- serializeSessionMeetings,
- applyMeetingsChanges } from '../tools/lib/meetings.mjs';
+ serializeSessionMeetings } from '../tools/common/meetings.mjs';
+import { parseMeetingsChanges,
+ applyMeetingsChanges } from '../tools/node/lib/meetings.mjs';
async function fetchTestProject() {
return fetchProject(
diff --git a/test/check-group-unknown.mjs b/test/check-group-unknown.mjs
index d9452e8..b18f11e 100644
--- a/test/check-group-unknown.mjs
+++ b/test/check-group-unknown.mjs
@@ -1,7 +1,7 @@
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
-import { validateSession } from '../tools/lib/validate.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateSession } from '../tools/common/validate.mjs';
import * as assert from 'node:assert';
async function fetchTestProject() {
diff --git a/test/check-invalid-scheduling.mjs b/test/check-invalid-scheduling.mjs
index fdb4acb..f741c5c 100644
--- a/test/check-invalid-scheduling.mjs
+++ b/test/check-invalid-scheduling.mjs
@@ -1,10 +1,10 @@
import * as assert from 'node:assert';
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
-import { validateGrid } from '../tools/lib/validate.mjs';
-import { suggestSchedule } from '../tools/lib/schedule.mjs';
-import { convertProjectToHTML } from '../tools/lib/project2html.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateGrid } from '../tools/common/validate.mjs';
+import { convertProjectToHTML } from '../tools/node/lib/project2html.mjs';
+import { suggestSchedule } from '../tools/common/schedule.mjs';
async function fetchTestProject() {
const project = await fetchProject(
diff --git a/test/check-meeting-parsing.mjs b/test/check-meeting-parsing.mjs
index b89a26e..194840a 100644
--- a/test/check-meeting-parsing.mjs
+++ b/test/check-meeting-parsing.mjs
@@ -1,12 +1,11 @@
import * as assert from 'node:assert';
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
import { groupSessionMeetings,
computeSessionCalendarUpdates,
parseSessionMeetings,
- serializeSessionMeetings,
- applyMeetingsChanges } from '../tools/lib/meetings.mjs';
+ serializeSessionMeetings } from '../tools/common/meetings.mjs';
async function fetchTestProject() {
return fetchProject(
diff --git a/test/check-project-tohtml.mjs b/test/check-project-tohtml.mjs
index eed3b8e..db7394c 100644
--- a/test/check-project-tohtml.mjs
+++ b/test/check-project-tohtml.mjs
@@ -1,7 +1,7 @@
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
-import { convertProjectToHTML } from '../tools/lib/project2html.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { convertProjectToHTML } from '../tools/node/lib/project2html.mjs';
import { readFile, writeFile } from 'node:fs/promises';
import * as assert from 'node:assert';
diff --git a/test/check-project-validation.mjs b/test/check-project-validation.mjs
index 08b9ac9..878287a 100644
--- a/test/check-project-validation.mjs
+++ b/test/check-project-validation.mjs
@@ -1,6 +1,7 @@
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject, validateProject } from '../tools/lib/project.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateProject } from '../tools/common/project.mjs';
import * as assert from 'node:assert';
async function fetchTestProject() {
diff --git a/test/check-room-metadata.mjs b/test/check-room-metadata.mjs
index 9d2249d..ed35eaa 100644
--- a/test/check-room-metadata.mjs
+++ b/test/check-room-metadata.mjs
@@ -1,7 +1,7 @@
import * as assert from 'node:assert';
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
async function fetchTestProject() {
const project = await fetchProject(
diff --git a/test/check-serialization.mjs b/test/check-serialization.mjs
index 39ee9f4..78c740d 100644
--- a/test/check-serialization.mjs
+++ b/test/check-serialization.mjs
@@ -1,9 +1,9 @@
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
import { initSectionHandlers,
parseSessionBody,
- serializeSessionDescription } from '../tools/lib/session.mjs';
+ serializeSessionDescription } from '../tools/common/session.mjs';
import * as assert from 'node:assert';
async function fetchTestProject() {
diff --git a/test/check-session-required.mjs b/test/check-session-required.mjs
index 7bfbaa7..6510437 100644
--- a/test/check-session-required.mjs
+++ b/test/check-session-required.mjs
@@ -1,7 +1,7 @@
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
-import { validateSession } from '../tools/lib/validate.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateSession } from '../tools/common/validate.mjs';
import * as assert from 'node:assert';
async function fetchTestProject() {
diff --git a/test/check-session-validation.mjs b/test/check-session-validation.mjs
index 027a55e..cb367bc 100644
--- a/test/check-session-validation.mjs
+++ b/test/check-session-validation.mjs
@@ -1,7 +1,7 @@
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
-import { validateSession } from '../tools/lib/validate.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateSession } from '../tools/common/validate.mjs';
import * as assert from 'node:assert';
async function fetchTestProject() {
diff --git a/test/check-stubs.mjs b/test/check-stubs.mjs
index 18aaffe..c30447c 100644
--- a/test/check-stubs.mjs
+++ b/test/check-stubs.mjs
@@ -1,7 +1,8 @@
import { initTestEnv } from './init-test-env.mjs';
-import { setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject, validateProject } from '../tools/lib/project.mjs';
-import { validateSession } from '../tools/lib/validate.mjs';
+import { setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateProject } from '../tools/common/project.mjs';
+import { validateSession } from '../tools/common/validate.mjs';
import * as assert from 'node:assert';
describe('The stubbing mechanism', function () {
diff --git a/test/check-tpac-scheduling.mjs b/test/check-tpac-scheduling.mjs
index aa30966..e01dce7 100644
--- a/test/check-tpac-scheduling.mjs
+++ b/test/check-tpac-scheduling.mjs
@@ -1,11 +1,11 @@
import * as assert from 'node:assert';
import { readFile, writeFile } from 'node:fs/promises';
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
-import { validateSession, validateGrid } from '../tools/lib/validate.mjs';
-import { suggestSchedule } from '../tools/lib/schedule.mjs';
-import { convertProjectToHTML } from '../tools/lib/project2html.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateSession, validateGrid } from '../tools/common/validate.mjs';
+import { convertProjectToHTML } from '../tools/node/lib/project2html.mjs';
+import { suggestSchedule } from '../tools/common/schedule.mjs';
async function fetchTestProject() {
const project = await fetchProject(
diff --git a/test/check-track-schedule-with-constraints.mjs b/test/check-track-schedule-with-constraints.mjs
index 94f5dd1..378c078 100644
--- a/test/check-track-schedule-with-constraints.mjs
+++ b/test/check-track-schedule-with-constraints.mjs
@@ -1,9 +1,9 @@
import * as assert from 'node:assert';
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
-import { validateGrid } from '../tools/lib/validate.mjs';
-import { suggestSchedule } from '../tools/lib/schedule.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateGrid } from '../tools/common/validate.mjs';
+import { suggestSchedule } from '../tools/common/schedule.mjs';
async function fetchTestProject() {
const project = await fetchProject(
diff --git a/test/check-vip-room.mjs b/test/check-vip-room.mjs
index df1f2b9..3aadb22 100644
--- a/test/check-vip-room.mjs
+++ b/test/check-vip-room.mjs
@@ -1,10 +1,10 @@
import * as assert from 'node:assert';
import { initTestEnv } from './init-test-env.mjs';
-import { getEnvKey, setEnvKey } from '../tools/lib/envkeys.mjs';
-import { fetchProject } from '../tools/lib/project.mjs';
-import { validateGrid } from '../tools/lib/validate.mjs';
-import { suggestSchedule } from '../tools/lib/schedule.mjs';
-import { convertProjectToHTML } from '../tools/lib/project2html.mjs';
+import { getEnvKey, setEnvKey } from '../tools/common/envkeys.mjs';
+import { fetchProject } from '../tools/node/lib/project.mjs';
+import { validateGrid } from '../tools/common/validate.mjs';
+import { convertProjectToHTML } from '../tools/node/lib/project2html.mjs';
+import { suggestSchedule } from '../tools/common/schedule.mjs';
async function fetchTestProject() {
const project = await fetchProject(
diff --git a/test/init-test-env.mjs b/test/init-test-env.mjs
index c7aa795..046218b 100644
--- a/test/init-test-env.mjs
+++ b/test/init-test-env.mjs
@@ -1,7 +1,7 @@
-import { getEnvKey, setEnvKey, resetEnvKeys } from '../tools/lib/envkeys.mjs';
-import { resetGraphQLCache } from '../tools/lib/graphql.mjs';
-import { resetSectionHandlers } from '../tools/lib/session.mjs';
-import { resetW3CCache } from '../tools/lib/w3c.mjs';
+import { getEnvKey, setEnvKey, resetEnvKeys } from '../tools/common/envkeys.mjs';
+import { resetGraphQLCache } from '../tools/common/graphql.mjs';
+import { resetSectionHandlers } from '../tools/common/session.mjs';
+import { resetW3CCache } from '../tools/common/w3c.mjs';
export function initTestEnv() {
resetEnvKeys();
diff --git a/test/stubs.mjs b/test/stubs.mjs
index 7cdd874..9acc0cc 100644
--- a/test/stubs.mjs
+++ b/test/stubs.mjs
@@ -4,7 +4,7 @@
* are only meant for testing purpose!
*/
-import { getEnvKey } from '../tools/lib/envkeys.mjs';
+import { getEnvKey } from '../tools/common/envkeys.mjs';
import defaultGroups from './data/w3cgroups.json' with { type: 'json' };
/**
diff --git a/tools/add-minutes.mjs b/tools/add-minutes.mjs
index 72e4b34..9d6b09f 100644
--- a/tools/add-minutes.mjs
+++ b/tools/add-minutes.mjs
@@ -10,11 +10,11 @@
* Leave empty to add minute links to all sessions.
*/
-import { getEnvKey } from './lib/envkeys.mjs';
-import { fetchProject } from './lib/project.mjs'
-import { validateSession } from './lib/validate.mjs';
-import { updateSessionDescription } from './lib/session.mjs';
-import { todoStrings } from './lib/todostrings.mjs';
+import { getEnvKey } from './common/envkeys.mjs';
+import { fetchProject } from './node/lib/project.mjs'
+import { validateSession } from './common/validate.mjs';
+import { updateSessionDescription } from './node/lib/session.mjs';
+import todoStrings from './common/todostrings.mjs';
async function main(number) {
diff --git a/tools/appscript/add-custom-menu.mjs b/tools/appscript/add-custom-menu.mjs
index 8cd8a43..56789aa 100644
--- a/tools/appscript/add-custom-menu.mjs
+++ b/tools/appscript/add-custom-menu.mjs
@@ -5,7 +5,7 @@
*/
export default function () {
SpreadsheetApp.getUi().createMenu('TPAC')
- .addItem('Associate with GitHub repository', 'associateWithGitHubRepository')
+ .addItem('Export event data as JSON', 'exportEventData')
.addItem('Import data from GitHub', 'importFromGithub')
.addItem('Generate grid', 'generateGrid')
.addToUi();
diff --git a/tools/appscript/export-event-data.mjs b/tools/appscript/export-event-data.mjs
new file mode 100644
index 0000000..abc6b68
--- /dev/null
+++ b/tools/appscript/export-event-data.mjs
@@ -0,0 +1,16 @@
+import { getProject } from './project.mjs';
+
+/**
+ * Export the event data as JSON
+ */
+export default function () {
+ const project = getProject(SpreadsheetApp.getActiveSpreadsheet());
+
+ const htmlOutput = HtmlService
+ .createHtmlOutput(
+ '
' + JSON.stringify(project, null, 2) + '
'
+ )
+ .setWidth(300)
+ .setHeight(400);
+ SpreadsheetApp.getUi().showModalDialog(htmlOutput, 'Event data');
+}
\ No newline at end of file
diff --git a/tools/appscript/generate-grid.mjs b/tools/appscript/generate-grid.mjs
index 85d46ba..1355ef4 100644
--- a/tools/appscript/generate-grid.mjs
+++ b/tools/appscript/generate-grid.mjs
@@ -1,3 +1,4 @@
+import { getProjectSheets } from './project.mjs';
import reportError from './report-error.mjs';
/**
@@ -13,61 +14,11 @@ export default function () {
*/
function generateGrid(spreadsheet) {
// These are the sheets we expect to find
- const sheets = {
- grid: {},
- sessions: { titleMatch: /list/i },
- meetings: { titleMatch: /meetings/i },
- rooms: { titleMatch: /rooms/i },
- days: { titleMatch: /days/i },
- slots: { titleMatch: /slots/i }
- };
-
- // Retrieve the sheets from the spreadsheet
- // (we'll consider that the grid view is the first sheet we find that isn't one of the
- // other well-known sheets. That gives some leeway as to how the sheet gets named.)
- for (const sheet of spreadsheet.getSheets()) {
- const name = sheet.getName().toLowerCase();
- const desc = Object.values(sheets).find(s => s.titleMatch?.test(name));
- if (desc) {
- desc.sheet = sheet;
- desc.values = getValues(sheet);
- }
- else if (!sheets.grid.sheet) {
- sheets.grid.sheet = sheet;
- sheets.grid.values = getValues(sheet);
- }
- }
-
- // Do we have all we need?
- const ui = SpreadsheetApp.getActiveSpreadsheet() ? SpreadsheetApp.getUi() : null;
- if (!sheets.grid.sheet) {
- reportError('No "Grid view" sheet found, please add one and start again.');
- return;
- }
+ const sheets = getProjectSheets(spreadsheet);
if (!sheets.sessions.sheet) {
reportError('No "List view" sheet found, please import data from GitHub first.');
return;
}
- if (!sheets.rooms.sheet) {
- reportError('No "Rooms" sheet found, please import data from GitHub first.');
- return;
- }
- if (!sheets.days.sheet) {
- reportError('No "Days" sheet found, please import data from GitHub first.');
- return;
- }
- if (!sheets.slots.sheet) {
- reportError('No "Slots" sheet found, please import data from GitHub first.');
- return;
- }
-
- if (!sheets.meetings.sheet) {
- // No "Meetings" sheet for breakouts sessions, that's normal, there's a 1:1
- // relationship between breakout sessions and meetings, the sessions sheet
- // already contains the expanded view.
- sheets.meetings.sheet = sheets.sessions.sheet;
- sheets.meetings.values = sheets.sessions.values;
- }
// Re-generate the grid view
const sheet = sheets.grid.sheet;
@@ -90,24 +41,6 @@ function generateGrid(spreadsheet) {
}
-/**
- * Return the values in a sheet as a list of objects whose property names are
- * derived from the header row.
- */
-function getValues(sheet) {
- const rows = sheet.getDataRange().getValues();
- const headers = rows[0];
- const values = rows.slice(1).map(row => {
- const value = {};
- for (let i = 0; i < headers.length; i++) {
- value[headers[i].toLowerCase()] = row[i];
- }
- return value;
- });
- return values;
-}
-
-
/**
* Create the header row of the grid view with the list of rooms.
*
diff --git a/tools/appscript/import-from-github.mjs b/tools/appscript/import-from-github.mjs
index 8a4d7d3..7b63f3b 100644
--- a/tools/appscript/import-from-github.mjs
+++ b/tools/appscript/import-from-github.mjs
@@ -1,22 +1,26 @@
-import associateWithGitHubRepository from './link-to-repository.mjs';
+import { getProject } from './project.mjs';
import reportError from './report-error.mjs';
/**
* Trigger a GitHub workflow that refreshes the data from GitHub
*/
export default function () {
- const scriptProperties = PropertiesService.getScriptProperties();
- const GITHUB_TOKEN = scriptProperties.getProperty('GITHUB_TOKEN');
- const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
- let repository = spreadsheet.getDeveloperMetadata().find(data => data.getKey() === 'repository');
- if (!repository) {
- associateWithGitHubRepository();
- repository = spreadsheet.getDeveloperMetadata().find(data => data.getKey() === 'repository');
- if (!repository) {
- return;
- }
+ const project = getProject(SpreadsheetApp.getActiveSpreadsheet());
+
+ if (!project.metadata.reponame) {
+ reportError(`No GitHub repository associated with the current document.
+
+Make sure that the "GitHub repository name" parameter is set in the "Event" sheet.
+
+Also make sure the targeted repository and project have been properly initialized.
+If not, ask François or Ian to run the required initialization steps.`);
}
- const repo = repository.getValue();
+
+ const repoparts = project.metadata.reponame.split('/');
+ const repo = {
+ owner: repoparts.length > 1 ? repoparts[0] : 'w3c',
+ name: repoparts.length > 1 ? repoparts[1] : repoparts[0]
+ };
const options = {
method : 'post',
@@ -34,7 +38,7 @@ export default function () {
};
const response = UrlFetchApp.fetch(
- `https://api.github.com/repos/${repo}/actions/workflows/sync-spreadsheet.yml/dispatches`,
+ `https://api.github.com/repos/${repo.owner}/${repo.name}/actions/workflows/sync-spreadsheet.yml/dispatches`,
options);
const status = response.getResponseCode();
if (status === 200 || status === 204) {
diff --git a/tools/appscript/link-to-repository.mjs b/tools/appscript/link-to-repository.mjs
deleted file mode 100644
index b8d1b89..0000000
--- a/tools/appscript/link-to-repository.mjs
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Associate with GitHub repository
- */
-export default function () {
- const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
- const repository = spreadsheet.getDeveloperMetadata().find(data => data.getKey() === 'repository');
- const msg = repository ?
- `The spreadsheet is currently associated with the GitHub repository "${repository.getValue()}".
-To change the association, please provide the new repository name below.` :
- 'Please enter the GitHub repository that contains the TPAC/breakouts data, e.g. "w3c/tpac2024-breakouts".';
- const ui = SpreadsheetApp.getUi();
- const response = ui.prompt('Enter GitHub repository', msg, ui.ButtonSet.OK_CANCEL);
- if (response.getSelectedButton() == ui.Button.OK) {
- const value = response.getResponseText();
- // TODO: validate the entered repository somehow
- if (repository) {
- repository.setValue(value);
- }
- else {
- spreadsheet.addDeveloperMetadata('repository', value);
- }
- }
-}
\ No newline at end of file
diff --git a/tools/appscript/main.mjs b/tools/appscript/main.mjs
index fe4e327..3a07175 100644
--- a/tools/appscript/main.mjs
+++ b/tools/appscript/main.mjs
@@ -1,11 +1,11 @@
import _createOnOpenTrigger from './create-onopen-trigger.mjs';
import _addTPACMenu from './add-custom-menu.mjs';
-import _associateWithGitHubRepository from './link-to-repository.mjs';
import _importFromGitHub from './import-from-github.mjs';
import _generateGrid from './generate-grid.mjs';
+import _exportEventData from './export-event-data.mjs';
function main() { _createOnOpenTrigger(); }
function addTPACMenu() { _addTPACMenu(); }
-function associateWithGitHubRepository() { _associateWithGitHubRepository(); }
function importFromGitHub() { _importFromGitHub(); }
function generateGrid() { _generateGrid(); }
+function exportEventData() { _exportEventData(); }
diff --git a/tools/appscript/project.mjs b/tools/appscript/project.mjs
new file mode 100644
index 0000000..b897a51
--- /dev/null
+++ b/tools/appscript/project.mjs
@@ -0,0 +1,260 @@
+import { getEnvKey } from '../common/envkeys.mjs';
+import * as YAML from '../../node_modules/yaml/browser/index.js';
+
+/**
+ * Retrieve an indexed object that contains the list of sheets associated with
+ * the event/project.
+ */
+export function getProjectSheets(spreadsheet) {
+ // These are the sheets we expect to find in the spreadsheet
+ const sheets = {
+ grid: {},
+ event: { titleMatch: /event/i },
+ sessions: { titleMatch: /list/i },
+ meetings: { titleMatch: /meetings/i },
+ rooms: { titleMatch: /rooms/i },
+ days: { titleMatch: /days/i },
+ slots: { titleMatch: /slots/i }
+ };
+
+ // Retrieve the sheets from the spreadsheet
+ // (we'll consider that the grid view is the first sheet we find that isn't one of the
+ // other well-known sheets. That gives some leeway as to how the sheet gets named.)
+ for (const sheet of spreadsheet.getSheets()) {
+ const name = sheet.getName().toLowerCase();
+ const desc = Object.values(sheets).find(s => s.titleMatch?.test(name));
+ if (desc) {
+ desc.sheet = sheet;
+ desc.values = getValues(sheet);
+ }
+ else if (!sheets.grid.sheet) {
+ sheets.grid.sheet = sheet;
+ sheets.grid.values = getValues(sheet);
+ }
+ }
+
+ // Do we have all we need?
+ if (!sheets.grid.sheet) {
+ reportError('No "Grid view" sheet found, please add one and start again.');
+ return;
+ }
+ if (!sheets.event.sheet) {
+ reportError('No "Event" sheet found, please add one and start again.');
+ return;
+ }
+ if (!sheets.rooms.sheet) {
+ reportError('No "Rooms" sheet found, please import data from GitHub first.');
+ return;
+ }
+ if (!sheets.days.sheet) {
+ reportError('No "Days" sheet found, please import data from GitHub first.');
+ return;
+ }
+ if (!sheets.slots.sheet) {
+ reportError('No "Slots" sheet found, please import data from GitHub first.');
+ return;
+ }
+
+ // The "Sessions" and "Meetings" sheets may be created afterwards, no error
+ // to report if they do not exist.
+
+ // No "Meetings" sheet for breakouts sessions? That's normal, there's a 1:1
+ // relationship between breakout sessions and meetings, the sessions sheet
+ // already contains the expanded view.
+ if (sheets.sessions.sheet && !sheets.meetings.sheet) {
+ sheets.meetings.sheet = sheets.sessions.sheet;
+ sheets.meetings.values = sheets.sessions.values;
+ }
+
+ return sheets;
+}
+
+
+/**
+ * Load event/project metadata from the spreadsheet and return an object whose
+ * structure matches the structure returned by the GitHub version of the code:
+ *
+ * {
+ * "title": "TPAC xxxx breakout sessions",
+ * "url": "https://github.com/organization/w3c/projects/xx",
+ * "id": "xxxxxxx",
+ * "roomsFieldId": "xxxxxxx",
+ * "rooms": [
+ * { "id": "xxxxxxx", "name": "Salon Ecija (30)", "label": "Salon Ecija", "capacity": 30 },
+ * ...
+ * ],
+ * "slotsFieldId": "xxxxxxx",
+ * "slots": [
+ * { "id": "xxxxxxx", "name": "9:30 - 10:30", "start": "9:30", "end": "10:30", "duration": 60 },
+ * ...
+ * ],
+ * "severityFieldIds": {
+ * "Check": "xxxxxxx",
+ * "Warning": "xxxxxxx",
+ * "Error": "xxxxxxx",
+ * "Note": "xxxxxxx"
+ * },
+ * "sessions": [
+ * {
+ * "repository": "w3c/tpacxxxx-breakouts",
+ * "number": xx,
+ * "title": "Session title",
+ * "body": "Session body, markdown",
+ * "labels": [ "session", ... ],
+ * "author": {
+ * "databaseId": 1122927,
+ * "login": "tidoust"
+ * },
+ * "room": "Salon Ecija (30)",
+ * "slot": "9:30 - 10:30"
+ * },
+ * ...
+ * ],
+ * "labels": [
+ * {
+ * "id": "xxxxxxx",
+ * "name": "error: format"
+ * },
+ * ...
+ * ]
+ * }
+ */
+export function getProject(spreadsheet) {
+ const sheets = getProjectSheets(spreadsheet);
+ const metadata = sheets.event.values;
+
+ function getSetting(name, defaultValue = null) {
+ const value = metadata.find(v => v.parameter === name)?.value;
+ return !!value ? value : defaultValue;
+ }
+
+ // Parse YAML GitHub issue template if it exists
+ let sessionSections = [];
+ const yamlTemplate = getSetting('GitHub issue template');
+ if (yamlTemplate) {
+ const template = YAML.parse(yamlTemplate);
+ sessionSections = template.body.filter(section => !!section.id);
+
+ // The "calendar" and "materials" sections are not part of the template.
+ // They are added manually or automatically when need arises. For the
+ // purpose of validation and serialization, we need to add them to the list
+ // of sections (as custom "auto hide" sections that only get displayed when
+ // they are not empty).
+ sessionSections.push({
+ id: 'calendar',
+ attributes: {
+ label: 'Links to calendar',
+ autoHide: true
+ }
+ });
+ sessionSections.push({
+ id: 'materials',
+ attributes: {
+ label: 'Meeting materials',
+ autoHide: true
+ }
+ });
+ }
+
+ const project = {
+ title: spreadsheet.getName(),
+ metadata: {
+ type: getSetting('Type', 'breakouts'),
+ timezone: getSetting('Timezone', 'Etc/UTC'),
+ calendar: getSetting('Sync with W3C calendar', 'no'),
+ rooms: getSetting('Show rooms in calendar') === 'no' ? 'hide' : 'show',
+ meeting: getSetting('Meeting name in calendar', ''),
+ reponame: getSetting('GitHub repository name')
+ },
+
+ rooms: sheets.rooms.values
+ .filter(v => !!v.name)
+ .map(v => {
+ if (v['vip room']) {
+ v.vip = v['vip room'] === 'yes' ? true : false;
+ delete v['vip room'];
+ }
+ return v;
+ }),
+
+ days: sheets.days.values
+ .filter(v => !!v.date)
+ .map(v => {
+ const name = v.weekday ?
+ v.weekday + ' (' + v.date + ')' :
+ v.date;
+ return {
+ id: v.id,
+ name,
+ label: !!v.weekday ? v.weekday : v.date,
+ date: v.date
+ };
+ }),
+
+ slots: sheets.slots.values
+ .filter(v => !!v['start time'] && !!v['end time'])
+ .map(v => {
+ const name = v['start time'] + ' - ' + v['end time'];
+ const times =
+ name.match(/^(\d+):(\d+)\s*-\s*(\d+):(\d+)$/) ??
+ [null, '00', '00', '01', '00'];
+ return {
+ id: v.id,
+ start: v['start time'],
+ end: v['end time'],
+ name,
+ duration:
+ (parseInt(times[3], 10) * 60 + parseInt(times[4], 10)) -
+ (parseInt(times[1], 10) * 60 + parseInt(times[2], 10))
+ };
+ }),
+
+ allowMultipleMeetings: getSetting('Type') === 'group',
+ allowTryMeOut: false,
+ allowRegistrants: false,
+
+ sessionSections,
+
+ // TODO: how to retrieve the labels?
+ labels: [],
+
+ // TODO: initialize from sessions sheet if it exists
+ // TODO: complete with meetings sheet if it exists
+ sessions: []
+ };
+
+ return project;
+}
+
+
+/**
+ * Return the values in a sheet as a list of objects whose property names are
+ * derived from the header row.
+ */
+function getValues(sheet) {
+ const rows = sheet.getDataRange().getValues();
+ const headers = rows[0];
+ const values = rows.slice(1).map(row => {
+ const value = {};
+ for (let i = 0; i < headers.length; i++) {
+ let content = row[i];
+ if (typeof row[i] === 'string') {
+ content = content.trim();
+ }
+ else if (row[i] instanceof Date) {
+ const year = row[i].getFullYear();
+ const month = row[i].getMonth() + 1;
+ const day = row[i].getDate();
+ content = '' + year + '-' +
+ (month < 10 ? '0' : '') + month + '-' +
+ (day < 10 ? '0' : '') + day;
+ }
+ if (content === '') {
+ content = null;
+ }
+ value[headers[i].toLowerCase()] = content;
+ }
+ return value;
+ });
+ return values;
+}
\ No newline at end of file
diff --git a/tools/cli.mjs b/tools/cli.mjs
index ded4e09..fbf6e18 100644
--- a/tools/cli.mjs
+++ b/tools/cli.mjs
@@ -11,15 +11,16 @@
*/
import packageConfig from '../package.json' with { type: 'json' };
import { Command } from 'commander';
-import { getEnvKey } from './lib/envkeys.mjs';
-import { fetchProject } from './lib/project.mjs';
-import schedule from './commands/schedule.mjs';
-import synchronizeCalendar from './commands/sync-calendar.mjs';
-import synchronizeSheet from './commands/sync-sheet.mjs';
-import validate from './commands/validate.mjs';
-import viewEvent from './commands/view-event.mjs';
-import viewRegisrants from './commands/view-registrants.mjs';
-import tryChanges from './commands/try-changes.mjs';
+import { getEnvKey } from './common/envkeys.mjs';
+import { fetchProject } from './node/lib/project.mjs';
+import schedule from './node/schedule.mjs';
+import synchronizeCalendar from './node/sync-calendar.mjs';
+import synchronizeSheet from './node/sync-sheet.mjs';
+import validate from './node/validate.mjs';
+import viewEvent from './node/view-event.mjs';
+import viewRegisrants from './node/view-registrants.mjs';
+import tryChanges from './node/try-changes.mjs';
+import createEvent from './node/create-event.mjs';
/**
@@ -50,7 +51,7 @@ async function loadProject() {
/**
* Individual commands expect to receive the project as first parameter.
*/
-function getCommandRunner(command) {
+function getProjectCommandRunner(command) {
return async function () {
const project = await loadProject();
return command(project, ...arguments);
@@ -68,9 +69,9 @@ program
.description('Manage scheduling of TPAC group meetings and breakouts events.')
.addHelpText('after', `
Pre-requisites:
- - This command line interface (CLI) must be run from the root folder a clone of a TPAC group meetings or breakouts event repository.
+ - Except for the "create" command, this command line interface (CLI) must be run from the root folder of a clone of a TPAC group meetings or breakouts event repository.
- The \`gh\` CLI must be available and directly usable. Run \`gh auth login\` if you are not logged in yet.
- - Local environment must define a \`GRAPHL_TOKEN\` variable set to a valid GitHub Personal Access Token (classic version) with \`repo\` and \`project\` scopes. Alternatively, that variable can be defined in a \`config.json\` file.
+ - Local environment must define a \`GRAPHQL_TOKEN\` variable set to a valid GitHub Personal Access Token (classic version) with \`repo\` and \`project\` scopes. Alternatively, that variable can be defined in a \`config.json\` file.
- Calendar synchronization also requires local environment to define \`W3C_LOGIN\` and \`W3C_PASSWORD\` variables. These variables are also needed to validate chairs of breakout sessions.
`);
@@ -85,7 +86,7 @@ program
.argument('', 'session to validate. Either a session number or "all" to validate all sessions.')
.option('-c, --changes ', 'JSON file that describes changes made to an issue description, as generated by GitHub in a change event (`github.event.changes`). Used to manage "check: instructions" flag. Only used if session number is an actual number.')
.option('-w, --what ', 'whether to validate "scheduling" or "everything". Only used if session number is "all". Default value is "everything"', 'everything')
- .action(getCommandRunner(validate))
+ .action(getProjectCommandRunner(validate))
.addHelpText('after', `
Examples:
$ npx tpac-breakouts validate 42
@@ -101,7 +102,7 @@ program
.summary('Vizualize the event\'s schedule as HTML.')
.description('Create an HTML page that contains the event\'s current schedule and additional validation information.')
.option('-f, --format ', 'output format. One of "json" or "html". Default is "html"', 'html')
- .action(getCommandRunner(viewEvent))
+ .action(getProjectCommandRunner(viewEvent))
.addHelpText('after', `
Output:
The command returns HTML content. You may want to redirect the output to a file. For example:
@@ -126,7 +127,7 @@ program
.option('-p, --preserve ', 'numbers of sessions for which scheduling information should be preserved', ['all'])
.option('-s, --seed ', 'seed string to use to shuffle sessions')
.option('-r, --reduce', 'reduce output: schedule only, without github links or room info')
- .action(getCommandRunner(schedule))
+ .action(getProjectCommandRunner(schedule))
.addHelpText('after', `
Output:
The command returns the generated schedule grid as HTML content (same structure as the one returned by the \`view\` command). You may want to redirect the output to a file. For example:
@@ -200,7 +201,7 @@ program
.summary('Try schedule changes in "Try me out" field.')
.description('Update the schedule with the meeting changes proposed in the "Try me out" field and report the adjusted grid and validation issues.')
.option('-a, --apply', 'apply the adjusted schedule, updating events information on GitHub')
- .action(getCommandRunner(tryChanges))
+ .action(getProjectCommandRunner(tryChanges))
.addHelpText('after', `
Output:
The command returns the generated schedule grid as HTML content (same structure as the one returned by the \`view\` command). You may want to redirect the output to a file. For example:
@@ -226,7 +227,7 @@ program
.argument('', 'session to synchronize. Either a session number or "all" to synchronize all sessions.')
.option('-s, --status ', 'status of the calendar entries: "draft", "tentative" or "confirmed".')
.option('-q, --quiet', 'make the command fail silently without error when the session is invalid. Useful for jobs.')
- .action(getCommandRunner(synchronizeCalendar))
+ .action(getProjectCommandRunner(synchronizeCalendar))
.addHelpText('after', `
Notes:
- The command follows the project's "calendar" setting by default. If that setting is absent or set to "no" and the \`status\` option is not set either, the command will not do anything.
@@ -247,7 +248,7 @@ program
.description('Create/Update a Google sheet that contains all project\'s data, including the schedule.')
.option('-s, --sheet ', 'ID of the Google Sheet to update, "new" to create a new one. Default: value of the GOOGLE_SHEET_ID environment variable.')
.option('-d, --drive ', 'ID of the Google shared drive in which to create the new sheet')
- .action(getCommandRunner(synchronizeSheet))
+ .action(getProjectCommandRunner(synchronizeSheet))
.addHelpText('after', `
Notes:
- Local environment must define a \`GOOGLE_KEY_JSON\` variable.
@@ -277,7 +278,7 @@ program
.option('-s, --save', 'save registrants information to the project. The --fetch option must be set.')
.option('-u, --url ', 'URL of the page that lists the registrants per session. The code uses `https://www.w3.org/register/[meeting name]/registrants` when not given. The --fetch option must be set.')
.option('-w, --warnings-only', 'Only return information about sessions that meet in rooms that are too small.')
- .action(getCommandRunner(viewRegisrants))
+ .action(getProjectCommandRunner(viewRegisrants))
.addHelpText('after', `
Examples:
$ npx tpac-breakouts view-registrants all
@@ -287,4 +288,28 @@ Examples:
`);
+/******************************************************************************
+ * The "create" command
+ *****************************************************************************/
+program
+ .command('create')
+ .summary('Create a new event.')
+ .description('Initialize a new event on GitHub from a JSON file.')
+ .argument('', 'relative path to a JSON file that contains the event\'s metadata.')
+ .action(async function (jsonfile) {
+ return createEvent(...arguments);
+ })
+ .addHelpText('after', `
+Output:
+ The command reports progress and remaining tasks as text.
+
+ It will create a local folder in the current directory named after the GitHub repository name of the event. The folder will contain a clone of the created repository.
+
+Usage notes:
+ - The JSON file should typically have been generated from the custom "TPAC" menu of a Google spreadsheet that follows the right template.
+ - The command will create a GitHub repository for the event and a GitHub project, and initialize things correctly.
+ - Additional manual tasks will be needed afterwards to set permissions and access tokens.
+`);
+
+
program.parseAsync(process.argv);
diff --git a/tools/lib/chairs.mjs b/tools/common/chairs.mjs
similarity index 100%
rename from tools/lib/chairs.mjs
rename to tools/common/chairs.mjs
diff --git a/tools/lib/envkeys.mjs b/tools/common/envkeys.mjs
similarity index 60%
rename from tools/lib/envkeys.mjs
rename to tools/common/envkeys.mjs
index 6f3a731..a10d724 100644
--- a/tools/lib/envkeys.mjs
+++ b/tools/common/envkeys.mjs
@@ -1,6 +1,3 @@
-import path from 'path';
-import { execSync } from 'child_process';
-
let localConfig = {};
let fileConfig = null;
let repoConfig = null;
@@ -20,7 +17,7 @@ export async function getEnvKey(key, defaultValue, json) {
}
// If the environment explicitly defines the key, that's good, let's use it!
- if (Object.hasOwn(process.env, key)) {
+ if (typeof process !== 'undefined' && Object.hasOwn(process.env, key)) {
return json ? JSON.parse(process.env[key]) : process.env[key];
}
@@ -28,16 +25,19 @@ export async function getEnvKey(key, defaultValue, json) {
// (note that is done only once)
if (!fileConfig) {
fileConfig = {};
- try {
- const configFileUrl = 'file:///' +
- path.join(process.cwd(), 'config.json').replace(/\\/g, '/');
- const { default: env } = await import(
- configFileUrl,
- { assert: { type: 'json' } }
- );
- fileConfig = env;
- }
- catch {
+ if (typeof process !== 'undefined') {
+ const path = await import('node:path');
+ try {
+ const configFileUrl = 'file:///' +
+ path.join(process.cwd(), 'config.json').replace(/\\/g, '/');
+ const { default: env } = await import(
+ configFileUrl,
+ { with: { type: 'json' } }
+ );
+ fileConfig = env;
+ }
+ catch {
+ }
}
}
if (Object.hasOwn(fileConfig, key)) {
@@ -47,20 +47,32 @@ export async function getEnvKey(key, defaultValue, json) {
// Retrieve variables from the GitHub repo directly through the "gh" CLI.
if (!repoConfig) {
repoConfig = {};
- try {
- const repoVariablesStr = execSync(`gh variable list --json name,value`);
- const repoVariables = JSON.parse(repoVariablesStr);
- for (const variable of repoVariables) {
- repoConfig[variable.name] = variable.value;
+ if (typeof process !== 'undefined') {
+ const childProcess = await import('node:child_process');
+ const execSync = childProcess.execSync;
+ try {
+ const repoVariablesStr = execSync(`gh variable list --json name,value`);
+ const repoVariables = JSON.parse(repoVariablesStr);
+ for (const variable of repoVariables) {
+ repoConfig[variable.name] = variable.value;
+ }
+ }
+ catch {
}
- }
- catch {
}
}
if (Object.hasOwn(repoConfig, key)) {
return json ? JSON.parse(repoConfig[key]) : repoConfig[key];
}
+ if (typeof PropertiesService !== 'undefined') {
+ const scriptProperties = PropertiesService.getScriptProperties();
+ const value = scriptProperties.getProperty(key);
+ if (value) {
+ return value;
+ }
+ }
+
if (defaultValue !== undefined) {
return defaultValue;
}
diff --git a/tools/lib/graphql.mjs b/tools/common/graphql.mjs
similarity index 95%
rename from tools/lib/graphql.mjs
rename to tools/common/graphql.mjs
index 67f0639..a066bff 100644
--- a/tools/lib/graphql.mjs
+++ b/tools/common/graphql.mjs
@@ -1,4 +1,5 @@
import { getEnvKey } from './envkeys.mjs';
+import wrappedFetch from './wrappedfetch.mjs';
/**
* Internal memory cache to avoid sending the same request more than once
@@ -58,7 +59,7 @@ export async function sendGraphQLRequest(query, acceptHeader = '') {
}
const GRAPHQL_TOKEN = await getEnvKey('GRAPHQL_TOKEN');
- const res = await fetch('https://api.github.com/graphql', {
+ const res = await wrappedFetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
diff --git a/tools/lib/groups.mjs b/tools/common/groups.mjs
similarity index 100%
rename from tools/lib/groups.mjs
rename to tools/common/groups.mjs
diff --git a/tools/lib/meetings.mjs b/tools/common/meetings.mjs
similarity index 78%
rename from tools/lib/meetings.mjs
rename to tools/common/meetings.mjs
index a6e793d..0426c36 100644
--- a/tools/lib/meetings.mjs
+++ b/tools/common/meetings.mjs
@@ -1,5 +1,3 @@
-import * as YAML from 'yaml';
-
/**
* Normalize times for comparison purpose, making sure that hours always have
* two digits: from 9:00 to 09:00
@@ -419,109 +417,3 @@ export function meetsInRoom(session, room, project) {
const meetings = parseSessionMeetings(session, project);
return !!meetings.find(m => m.room === room);
}
-
-
-/**
- * Parse a list of meetings changes defined in a YAML string.
- *
- * Meeting changes are used to apply local changes to a schedule.
- */
-export function parseMeetingsChanges(yaml) {
- const resources = ['room', 'day', 'slot', 'meeting'];
- const yamlChanges = YAML.parse(yaml);
- return yamlChanges.map(yamlChange => {
- const change = {};
- for (const [key, value] of Object.entries(yamlChange)) {
- if (!['number', 'reset', 'room', 'day', 'slot', 'meeting'].includes(key)) {
- throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" is an unexpected key`);
- }
- switch (key) {
- case 'number':
- if (!Number.isInteger(value)) {
- throw new Error(`Invalid meetings changes: #${value} is not a session number`);
- }
- change[key] = value;
- break;
- case 'reset':
- if (value === 'all') {
- change[key] = resources.slice();
- }
- else if (Array.isArray(value)) {
- if (value.find(val => !resources.includes(val))) {
- throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" values "${value.join(', ')}" contains an unexpected field`);
- }
- change[key] = value;
- }
- else if (!resources.includes(value)) {
- throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" value "${value}" is unexpected`);
- }
- else {
- change[key] = [value];
- }
- break;
-
- case 'room':
- case 'day':
- case 'slot':
- if (typeof value !== 'string') {
- throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" value is not a string`);
- }
- change[key] = value;
- break;
-
- case 'meeting':
- if (Array.isArray(value)) {
- if (value.find(val => typeof val !== 'string' ||
- val.includes(';') ||
- val.includes('|'))) {
- throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" value is not an array of individual meeting strings`);
- }
- change[key] = value.join('; ');
- }
- else if (typeof value !== 'string') {
- throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" value is not a string`);
- }
- else {
- change[key] = value;
- }
- }
- if (!change.number) {
- throw new Error(`Invalid meetings changes: all changes must reference a session number`);
- }
- }
- return change;
- });
-}
-
-
-/**
- * Apply the list of meetings changes to the given list of sessions.
- *
- * Sessions are updated in place. The sessions that are effectively updated
- * also get an `updated` flag.
- *
- * The list of meetings changes must follow the structure returned by the
- * previous parseMeetingsChanges function.
- */
-export function applyMeetingsChanges(sessions, changes) {
- for (const change of changes) {
- const session = sessions.find(s => s.number === change.number);
- if (!session) {
- throw new Error(`Invalid change requested: #${change.number} does not exist`);
- }
- if (change.reset) {
- for (const field of change.reset) {
- if (session[field]) {
- delete session[field];
- session.updated = true;
- }
- }
- }
- for (const field of ['room', 'day', 'slot', 'meeting']) {
- if (change[field] && change[field] !== session[field]) {
- session[field] = change[field];
- session.updated = true;
- }
- }
- }
-}
\ No newline at end of file
diff --git a/tools/common/project.mjs b/tools/common/project.mjs
new file mode 100644
index 0000000..9b0d73d
--- /dev/null
+++ b/tools/common/project.mjs
@@ -0,0 +1,127 @@
+import timezones from './timezones.mjs';
+
+/**
+ * Helper function to parse a project description and extract additional
+ * metadata about breakout sessions: date, timezone, big meeting id
+ *
+ * Description needs to be a comma-separated list of parameters. Example:
+ * "meeting: tpac2023, day: 2023-09-13, timezone: Europe/Madrid"
+ */
+export function parseProjectDescription(desc) {
+ const metadata = {};
+ if (desc) {
+ desc.split(/,/)
+ .map(param => param.trim())
+ .map(param => param.split(/:/).map(val => val.trim()))
+ .map(param => metadata[param[0]] = param[1]);
+ }
+ return metadata;
+}
+
+
+/**
+ * Helper function to serialize project metadata into a description
+ *
+ * Metadata needs to have been parsed with parseProjectDescription
+ */
+export function serializeProjectMetadata(metadata) {
+ const description = [];
+ for (const [param, value] of Object.entries(metadata)) {
+ description.push(`${param}: ${value}`);
+ }
+ return description.join(', ');
+}
+
+
+/**
+ * Validate that we have the information we need about the project.
+ */
+export function validateProject(project) {
+ const errors = [];
+
+ if (!project.metadata) {
+ errors.push('The short description is missing. It should set the meeting, date, and timezone.');
+ }
+ else {
+ if (!project.metadata.meeting) {
+ errors.push('The "meeting" info in the short description is missing. Should be something like "meeting: TPAC 2023"');
+ }
+ if (!project.metadata.timezone) {
+ errors.push('The "timezone" info in the short description is missing. Should be something like "timezone: Europe/Madrid"');
+ }
+ else if (!timezones.includes(project.metadata.timezone)) {
+ errors.push('The "timezone" info in the short description is not a valid timezone. Value should be a "tz identifier" in https://en.wikipedia.org/wiki/List_of_tz_database_time_zones');
+ }
+ if (!['groups', 'breakouts', undefined].includes(project.metadata?.type)) {
+ errors.push('The "type" info must be one of "groups" or "breakouts"');
+ }
+ if (project.metadata.calendar &&
+ !['no', 'draft', 'tentative', 'confirmed'].includes(project.metadata.calendar)) {
+ errors.push('The "calendar" info must be one of "no", "draft", "tentative" or "confirmed"');
+ }
+ }
+
+ for (const slot of project.slots) {
+ if (!slot.name.match(/^(\d+):(\d+)\s*-\s*(\d+):(\d+)$/)) {
+ errors.push(`Invalid slot name "${slot.name}". Format should be "HH:mm - HH:mm"`);
+ }
+ if (slot.duration < 30 || slot.duration > 120) {
+ errors.push(`Unexpected slot duration ${slot.duration}. Duration should be between 30 and 120 minutes.`);
+ }
+ }
+
+ for (const day of project.days) {
+ if (!day.date.match(/^\d{4}\-\d{2}\-\d{2}$/)) {
+ errors.push(`Invalid day name "${day.name}". Format should be either "YYYY-MM-DD" or "[label] (YYYY-MM-DD)`);
+ }
+ else if (isNaN((new Date(day.date)).valueOf())) {
+ errors.push(`Invalid date in day name "${day.name}".`);
+ }
+ }
+
+ return errors;
+}
+
+
+/**
+ * Convert the project to a simplified JSON data structure
+ * (suitable for tests but also for debugging)
+ */
+export function convertProjectToJSON(project) {
+ const toNameList = list => list.map(item => item.name);
+ const data = {
+ title: project.title,
+ description: project.description
+ };
+ if (project.allowMultipleMeetings) {
+ data.allowMultipleMeetings = true;
+ }
+ if (project.allowTryMeOut) {
+ data.allowTryMeOut = true;
+ }
+ if (project.allowRegistrants) {
+ data.allowRegistrants = true;
+ }
+ for (const list of ['days', 'rooms', 'slots', 'labels']) {
+ data[list] = toNameList(project[list]);
+ }
+
+ data.sessions = project.sessions.map(session => {
+ const simplified = {
+ number: session.number,
+ title: session.title,
+ author: session.author.login,
+ body: session.body,
+ };
+ if (session.labels.length !== 1 || session.labels[0] !== 'session') {
+ simplified.labels = session.labels;
+ }
+ for (const field of ['day', 'room', 'slot', 'meeting', 'registrants']) {
+ if (session[field]) {
+ simplified[field] = session[field];
+ }
+ }
+ return simplified;
+ });
+ return data;
+}
\ No newline at end of file
diff --git a/tools/lib/schedule.mjs b/tools/common/schedule.mjs
similarity index 99%
rename from tools/lib/schedule.mjs
rename to tools/common/schedule.mjs
index 2e6c9a1..e8b9c12 100644
--- a/tools/lib/schedule.mjs
+++ b/tools/common/schedule.mjs
@@ -25,7 +25,7 @@
* it cannot schedule due to a confict that it cannot resolve.
*/
-import seedrandom from 'seedrandom';
+import seedrandom from './seedrandom.mjs';
import { parseSessionMeetings,
serializeSessionMeetings,
meetsInParallelWith,
diff --git a/tools/common/seedrandom.mjs b/tools/common/seedrandom.mjs
new file mode 100644
index 0000000..95a310b
--- /dev/null
+++ b/tools/common/seedrandom.mjs
@@ -0,0 +1,233 @@
+/*
+Copyright 2019 David Bau.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+*/
+/**
+ * TIDOUST (2024-12-17): adjusted the code to export seedrandom as an
+ * ES module.
+ */
+
+const global = this;
+let pool = [];
+const math = Math;
+
+//
+// The following constants are related to IEEE 754 limits.
+//
+
+var width = 256, // each RC4 output is 0 <= x < 256
+ chunks = 6, // at least six RC4 outputs for each double
+ digits = 52, // there are 52 significant digits in a double
+ rngname = 'random', // rngname: name for Math.random and Math.seedrandom
+ startdenom = math.pow(width, chunks),
+ significance = math.pow(2, digits),
+ overflow = significance * 2,
+ mask = width - 1,
+ nodecrypto; // node.js crypto module, initialized at the bottom.
+
+//
+// seedrandom()
+// This is the seedrandom function described above.
+//
+export default function seedrandom(seed, options, callback) {
+ var key = [];
+ options = (options == true) ? { entropy: true } : (options || {});
+
+ // Flatten the seed string or build one from local entropy if needed.
+ var shortseed = mixkey(flatten(
+ options.entropy ? [seed, tostring(pool)] :
+ (seed == null) ? autoseed() : seed, 3), key);
+
+ // Use the seed to initialize an ARC4 generator.
+ var arc4 = new ARC4(key);
+
+ // This function returns a random double in [0, 1) that contains
+ // randomness in every bit of the mantissa of the IEEE 754 value.
+ var prng = function() {
+ var n = arc4.g(chunks), // Start with a numerator n < 2 ^ 48
+ d = startdenom, // and denominator d = 2 ^ 48.
+ x = 0; // and no 'extra last byte'.
+ while (n < significance) { // Fill up all significant digits by
+ n = (n + x) * width; // shifting numerator and
+ d *= width; // denominator and generating a
+ x = arc4.g(1); // new least-significant-byte.
+ }
+ while (n >= overflow) { // To avoid rounding up, before adding
+ n /= 2; // last byte, shift everything
+ d /= 2; // right using integer math until
+ x >>>= 1; // we have exactly the desired bits.
+ }
+ return (n + x) / d; // Form the number within [0, 1).
+ };
+
+ prng.int32 = function() { return arc4.g(4) | 0; }
+ prng.quick = function() { return arc4.g(4) / 0x100000000; }
+ prng.double = prng;
+
+ // Mix the randomness into accumulated entropy.
+ mixkey(tostring(arc4.S), pool);
+
+ // Calling convention: what to return as a function of prng, seed, is_math.
+ return (options.pass || callback ||
+ function(prng, seed, is_math_call, state) {
+ if (state) {
+ // Load the arc4 state from the given state if it has an S array.
+ if (state.S) { copy(state, arc4); }
+ // Only provide the .state method if requested via options.state.
+ prng.state = function() { return copy(arc4, {}); }
+ }
+
+ // If called as a method of Math (Math.seedrandom()), mutate
+ // Math.random because that is how seedrandom.js has worked since v1.0.
+ if (is_math_call) { math[rngname] = prng; return seed; }
+
+ // Otherwise, it is a newer calling convention, so return the
+ // prng directly.
+ else return prng;
+ })(
+ prng,
+ shortseed,
+ 'global' in options ? options.global : (this == math),
+ options.state);
+}
+
+//
+// ARC4
+//
+// An ARC4 implementation. The constructor takes a key in the form of
+// an array of at most (width) integers that should be 0 <= x < (width).
+//
+// The g(count) method returns a pseudorandom integer that concatenates
+// the next (count) outputs from ARC4. Its return value is a number x
+// that is in the range 0 <= x < (width ^ count).
+//
+function ARC4(key) {
+ var t, keylen = key.length,
+ me = this, i = 0, j = me.i = me.j = 0, s = me.S = [];
+
+ // The empty key [] is treated as [0].
+ if (!keylen) { key = [keylen++]; }
+
+ // Set up S using the standard key scheduling algorithm.
+ while (i < width) {
+ s[i] = i++;
+ }
+ for (i = 0; i < width; i++) {
+ s[i] = s[j = mask & (j + key[i % keylen] + (t = s[i]))];
+ s[j] = t;
+ }
+
+ // The "g" method returns the next (count) outputs as one number.
+ (me.g = function(count) {
+ // Using instance members instead of closure state nearly doubles speed.
+ var t, r = 0,
+ i = me.i, j = me.j, s = me.S;
+ while (count--) {
+ t = s[i = mask & (i + 1)];
+ r = r * width + s[mask & ((s[i] = s[j = mask & (j + t)]) + (s[j] = t))];
+ }
+ me.i = i; me.j = j;
+ return r;
+ // For robust unpredictability, the function call below automatically
+ // discards an initial batch of values. This is called RC4-drop[256].
+ // See http://google.com/search?q=rsa+fluhrer+response&btnI
+ })(width);
+}
+
+//
+// copy()
+// Copies internal state of ARC4 to or from a plain object.
+//
+function copy(f, t) {
+ t.i = f.i;
+ t.j = f.j;
+ t.S = f.S.slice();
+ return t;
+};
+
+//
+// flatten()
+// Converts an object tree to nested arrays of strings.
+//
+function flatten(obj, depth) {
+ var result = [], typ = (typeof obj), prop;
+ if (depth && typ == 'object') {
+ for (prop in obj) {
+ try { result.push(flatten(obj[prop], depth - 1)); } catch (e) {}
+ }
+ }
+ return (result.length ? result : typ == 'string' ? obj : obj + '\0');
+}
+
+//
+// mixkey()
+// Mixes a string seed into a key that is an array of integers, and
+// returns a shortened string seed that is equivalent to the result key.
+//
+function mixkey(seed, key) {
+ var stringseed = seed + '', smear, j = 0;
+ while (j < stringseed.length) {
+ key[mask & j] =
+ mask & ((smear ^= key[mask & j] * 19) + stringseed.charCodeAt(j++));
+ }
+ return tostring(key);
+}
+
+//
+// autoseed()
+// Returns an object for autoseeding, using window.crypto and Node crypto
+// module if available.
+//
+function autoseed() {
+ try {
+ var out;
+ if (nodecrypto && (out = nodecrypto.randomBytes)) {
+ // The use of 'out' to remember randomBytes makes tight minified code.
+ out = out(width);
+ } else {
+ out = new Uint8Array(width);
+ (global.crypto || global.msCrypto).getRandomValues(out);
+ }
+ return tostring(out);
+ } catch (e) {
+ var browser = global.navigator,
+ plugins = browser && browser.plugins;
+ return [+new Date, global, plugins, global.screen, tostring(pool)];
+ }
+}
+
+//
+// tostring()
+// Converts an array of charcodes to a string
+//
+function tostring(a) {
+ return String.fromCharCode.apply(0, a);
+}
+
+//
+// When seedrandom.js is loaded, we immediately mix a few bits
+// from the built-in RNG into the entropy pool. Because we do
+// not want to interfere with deterministic PRNG state later,
+// seedrandom will not call math.random on its own again after
+// initialization.
+//
+mixkey(math.random(), pool);
diff --git a/tools/lib/session.mjs b/tools/common/session.mjs
similarity index 99%
rename from tools/lib/session.mjs
rename to tools/common/session.mjs
index f3e77e9..01cea8e 100644
--- a/tools/lib/session.mjs
+++ b/tools/common/session.mjs
@@ -1,5 +1,5 @@
import { sendGraphQLRequest } from './graphql.mjs';
-import { todoStrings } from './todostrings.mjs';
+import todoStrings from './todostrings.mjs';
/**
diff --git a/tools/common/timezones.mjs b/tools/common/timezones.mjs
new file mode 100644
index 0000000..2dbef20
--- /dev/null
+++ b/tools/common/timezones.mjs
@@ -0,0 +1,447 @@
+/**
+ * List of allowed timezone values.
+ *
+ * The list comes from running the following query on any W3C calendar entry:
+ *
+ * [...document.querySelectorAll('#event_timezone option')]
+ * .map(option => option.getAttribute('value'))
+ * .filter(value => !!value);
+ *
+ * This query should return an array with >430 entries.
+ */
+export default [
+ 'Pacific/Niue',
+ 'Pacific/Midway',
+ 'Pacific/Pago_Pago',
+ 'Pacific/Rarotonga',
+ 'Pacific/Honolulu',
+ 'Pacific/Johnston',
+ 'Pacific/Tahiti',
+ 'Pacific/Marquesas',
+ 'Pacific/Gambier',
+ 'America/Adak',
+ 'America/Anchorage',
+ 'America/Juneau',
+ 'America/Metlakatla',
+ 'America/Nome',
+ 'America/Sitka',
+ 'America/Yakutat',
+ 'Pacific/Pitcairn',
+ 'America/Hermosillo',
+ 'America/Mazatlan',
+ 'America/Creston',
+ 'America/Dawson_Creek',
+ 'America/Fort_Nelson',
+ 'America/Phoenix',
+ 'America/Santa_Isabel',
+ 'PST8PDT',
+ 'America/Los_Angeles',
+ 'America/Tijuana',
+ 'America/Vancouver',
+ 'America/Dawson',
+ 'America/Whitehorse',
+ 'America/Bahia_Banderas',
+ 'America/Belize',
+ 'America/Costa_Rica',
+ 'America/El_Salvador',
+ 'America/Guatemala',
+ 'America/Managua',
+ 'America/Merida',
+ 'America/Mexico_City',
+ 'America/Monterrey',
+ 'America/Regina',
+ 'America/Swift_Current',
+ 'America/Tegucigalpa',
+ 'Pacific/Galapagos',
+ 'America/Chihuahua',
+ 'MST7MDT',
+ 'America/Boise',
+ 'America/Cambridge_Bay',
+ 'America/Denver',
+ 'America/Edmonton',
+ 'America/Inuvik',
+ 'America/Yellowknife',
+ 'America/Eirunepe',
+ 'America/Rio_Branco',
+ 'CST6CDT',
+ 'America/North_Dakota/Beulah',
+ 'America/North_Dakota/Center',
+ 'America/Chicago',
+ 'America/Indiana/Knox',
+ 'America/Matamoros',
+ 'America/Menominee',
+ 'America/North_Dakota/New_Salem',
+ 'America/Rainy_River',
+ 'America/Rankin_Inlet',
+ 'America/Resolute',
+ 'America/Indiana/Tell_City',
+ 'America/Winnipeg',
+ 'America/Bogota',
+ 'Pacific/Easter',
+ 'America/Coral_Harbour',
+ 'America/Cancun',
+ 'America/Cayman',
+ 'America/Jamaica',
+ 'America/Panama',
+ 'America/Guayaquil',
+ 'America/Ojinaga',
+ 'America/Lima',
+ 'America/Boa_Vista',
+ 'America/Campo_Grande',
+ 'America/Cuiaba',
+ 'America/Manaus',
+ 'America/Porto_Velho',
+ 'America/Anguilla',
+ 'America/Antigua',
+ 'America/Aruba',
+ 'America/Barbados',
+ 'America/Blanc-Sablon',
+ 'America/Curacao',
+ 'America/Dominica',
+ 'America/Grenada',
+ 'America/Guadeloupe',
+ 'America/Kralendijk',
+ 'America/Lower_Princes',
+ 'America/Marigot',
+ 'America/Martinique',
+ 'America/Montserrat',
+ 'America/Port_of_Spain',
+ 'America/Puerto_Rico',
+ 'America/Santo_Domingo',
+ 'America/St_Barthelemy',
+ 'America/St_Kitts',
+ 'America/St_Lucia',
+ 'America/St_Thomas',
+ 'America/St_Vincent',
+ 'America/Tortola',
+ 'America/La_Paz',
+ 'America/Montreal',
+ 'America/Havana',
+ 'EST5EDT',
+ 'America/Detroit',
+ 'America/Grand_Turk',
+ 'America/Indianapolis',
+ 'America/Iqaluit',
+ 'America/Louisville',
+ 'America/Indiana/Marengo',
+ 'America/Kentucky/Monticello',
+ 'America/Nassau',
+ 'America/New_York',
+ 'America/Nipigon',
+ 'America/Pangnirtung',
+ 'America/Indiana/Petersburg',
+ 'America/Port-au-Prince',
+ 'America/Thunder_Bay',
+ 'America/Toronto',
+ 'America/Indiana/Vevay',
+ 'America/Indiana/Vincennes',
+ 'America/Indiana/Winamac',
+ 'America/Guyana',
+ 'America/Caracas',
+ 'America/Buenos_Aires',
+ 'America/Catamarca',
+ 'America/Cordoba',
+ 'America/Jujuy',
+ 'America/Argentina/La_Rioja',
+ 'America/Mendoza',
+ 'America/Argentina/Rio_Gallegos',
+ 'America/Argentina/Salta',
+ 'America/Argentina/San_Juan',
+ 'America/Argentina/San_Luis',
+ 'America/Argentina/Tucuman',
+ 'America/Argentina/Ushuaia',
+ 'Atlantic/Bermuda',
+ 'America/Glace_Bay',
+ 'America/Goose_Bay',
+ 'America/Halifax',
+ 'America/Moncton',
+ 'America/Thule',
+ 'America/Araguaina',
+ 'America/Bahia',
+ 'America/Belem',
+ 'America/Fortaleza',
+ 'America/Maceio',
+ 'America/Recife',
+ 'America/Santarem',
+ 'America/Sao_Paulo',
+ 'Antarctica/Palmer',
+ 'America/Punta_Arenas',
+ 'America/Santiago',
+ 'Atlantic/Stanley',
+ 'America/Cayenne',
+ 'America/Asuncion',
+ 'Antarctica/Rothera',
+ 'America/Paramaribo',
+ 'America/Montevideo',
+ 'America/St_Johns',
+ 'America/Noronha',
+ 'Atlantic/South_Georgia',
+ 'America/Miquelon',
+ 'America/Godthab',
+ 'Atlantic/Azores',
+ 'Atlantic/Cape_Verde',
+ 'America/Scoresbysund',
+ 'Etc/UTC',
+ 'Etc/GMT',
+ 'Africa/Abidjan',
+ 'Africa/Accra',
+ 'Africa/Bamako',
+ 'Africa/Banjul',
+ 'Africa/Bissau',
+ 'Africa/Conakry',
+ 'Africa/Dakar',
+ 'America/Danmarkshavn',
+ 'Europe/Dublin',
+ 'Africa/Freetown',
+ 'Europe/Guernsey',
+ 'Europe/Isle_of_Man',
+ 'Europe/Jersey',
+ 'Africa/Lome',
+ 'Europe/London',
+ 'Africa/Monrovia',
+ 'Africa/Nouakchott',
+ 'Africa/Ouagadougou',
+ 'Atlantic/Reykjavik',
+ 'Atlantic/St_Helena',
+ 'Africa/Sao_Tome',
+ 'Antarctica/Troll',
+ 'Atlantic/Canary',
+ 'Africa/Casablanca',
+ 'Africa/El_Aaiun',
+ 'Atlantic/Faeroe',
+ 'Europe/Lisbon',
+ 'Atlantic/Madeira',
+ 'Africa/Algiers',
+ 'Europe/Amsterdam',
+ 'Europe/Andorra',
+ 'Europe/Belgrade',
+ 'Europe/Berlin',
+ 'Europe/Bratislava',
+ 'Europe/Brussels',
+ 'Europe/Budapest',
+ 'Europe/Busingen',
+ 'Africa/Ceuta',
+ 'Europe/Copenhagen',
+ 'Europe/Gibraltar',
+ 'Europe/Ljubljana',
+ 'Arctic/Longyearbyen',
+ 'Europe/Luxembourg',
+ 'Europe/Madrid',
+ 'Europe/Malta',
+ 'Europe/Monaco',
+ 'Europe/Oslo',
+ 'Europe/Paris',
+ 'Europe/Podgorica',
+ 'Europe/Prague',
+ 'Europe/Rome',
+ 'Europe/San_Marino',
+ 'Europe/Sarajevo',
+ 'Europe/Skopje',
+ 'Europe/Stockholm',
+ 'Europe/Tirane',
+ 'Africa/Tunis',
+ 'Europe/Vaduz',
+ 'Europe/Vatican',
+ 'Europe/Vienna',
+ 'Europe/Warsaw',
+ 'Europe/Zagreb',
+ 'Europe/Zurich',
+ 'Africa/Bangui',
+ 'Africa/Brazzaville',
+ 'Africa/Douala',
+ 'Africa/Kinshasa',
+ 'Africa/Lagos',
+ 'Africa/Libreville',
+ 'Africa/Luanda',
+ 'Africa/Malabo',
+ 'Africa/Ndjamena',
+ 'Africa/Niamey',
+ 'Africa/Porto-Novo',
+ 'Africa/Blantyre',
+ 'Africa/Bujumbura',
+ 'Africa/Gaborone',
+ 'Africa/Harare',
+ 'Africa/Juba',
+ 'Africa/Khartoum',
+ 'Africa/Kigali',
+ 'Africa/Lubumbashi',
+ 'Africa/Lusaka',
+ 'Africa/Maputo',
+ 'Africa/Windhoek',
+ 'Europe/Athens',
+ 'Asia/Beirut',
+ 'Europe/Bucharest',
+ 'Africa/Cairo',
+ 'Europe/Chisinau',
+ 'Asia/Famagusta',
+ 'Asia/Gaza',
+ 'Asia/Hebron',
+ 'Europe/Helsinki',
+ 'Europe/Kaliningrad',
+ 'Europe/Kiev',
+ 'Europe/Mariehamn',
+ 'Asia/Nicosia',
+ 'Europe/Riga',
+ 'Europe/Sofia',
+ 'Europe/Tallinn',
+ 'Africa/Tripoli',
+ 'Europe/Uzhgorod',
+ 'Europe/Vilnius',
+ 'Europe/Zaporozhye',
+ 'Asia/Jerusalem',
+ 'Africa/Johannesburg',
+ 'Africa/Maseru',
+ 'Africa/Mbabane',
+ 'Asia/Aden',
+ 'Asia/Baghdad',
+ 'Asia/Bahrain',
+ 'Asia/Kuwait',
+ 'Asia/Qatar',
+ 'Asia/Riyadh',
+ 'Africa/Addis_Ababa',
+ 'Indian/Antananarivo',
+ 'Africa/Asmera',
+ 'Indian/Comoro',
+ 'Africa/Dar_es_Salaam',
+ 'Africa/Djibouti',
+ 'Africa/Kampala',
+ 'Indian/Mayotte',
+ 'Africa/Mogadishu',
+ 'Africa/Nairobi',
+ 'Asia/Amman',
+ 'Asia/Damascus',
+ 'Europe/Moscow',
+ 'Europe/Minsk',
+ 'Europe/Simferopol',
+ 'Europe/Kirov',
+ 'Antarctica/Syowa',
+ 'Europe/Istanbul',
+ 'Europe/Volgograd',
+ 'Asia/Tehran',
+ 'Asia/Yerevan',
+ 'Asia/Baku',
+ 'Asia/Tbilisi',
+ 'Asia/Dubai',
+ 'Asia/Muscat',
+ 'Indian/Mauritius',
+ 'Europe/Astrakhan',
+ 'Europe/Saratov',
+ 'Europe/Ulyanovsk',
+ 'Indian/Reunion',
+ 'Europe/Samara',
+ 'Indian/Mahe',
+ 'Asia/Kabul',
+ 'Asia/Almaty',
+ 'Asia/Qostanay',
+ 'Indian/Kerguelen',
+ 'Indian/Maldives',
+ 'Antarctica/Mawson',
+ 'Asia/Karachi',
+ 'Asia/Dushanbe',
+ 'Asia/Ashgabat',
+ 'Asia/Samarkand',
+ 'Asia/Tashkent',
+ 'Antarctica/Vostok',
+ 'Asia/Aqtau',
+ 'Asia/Aqtobe',
+ 'Asia/Atyrau',
+ 'Asia/Oral',
+ 'Asia/Qyzylorda',
+ 'Asia/Yekaterinburg',
+ 'Asia/Colombo',
+ 'Asia/Calcutta',
+ 'Asia/Katmandu',
+ 'Asia/Dhaka',
+ 'Asia/Thimphu',
+ 'Asia/Urumqi',
+ 'Indian/Chagos',
+ 'Asia/Bishkek',
+ 'Asia/Omsk',
+ 'Indian/Cocos',
+ 'Asia/Rangoon',
+ 'Indian/Christmas',
+ 'Antarctica/Davis',
+ 'Asia/Hovd',
+ 'Asia/Bangkok',
+ 'Asia/Saigon',
+ 'Asia/Phnom_Penh',
+ 'Asia/Vientiane',
+ 'Asia/Krasnoyarsk',
+ 'Asia/Novokuznetsk',
+ 'Asia/Novosibirsk',
+ 'Asia/Barnaul',
+ 'Asia/Tomsk',
+ 'Asia/Jakarta',
+ 'Asia/Pontianak',
+ 'Asia/Brunei',
+ 'Antarctica/Casey',
+ 'Asia/Makassar',
+ 'Asia/Macau',
+ 'Asia/Shanghai',
+ 'Asia/Hong_Kong',
+ 'Asia/Irkutsk',
+ 'Asia/Kuala_Lumpur',
+ 'Asia/Kuching',
+ 'Asia/Manila',
+ 'Asia/Singapore',
+ 'Asia/Taipei',
+ 'Asia/Ulaanbaatar',
+ 'Asia/Choibalsan',
+ 'Australia/Perth',
+ 'Australia/Eucla',
+ 'Asia/Dili',
+ 'Asia/Jayapura',
+ 'Asia/Tokyo',
+ 'Asia/Pyongyang',
+ 'Asia/Seoul',
+ 'Pacific/Palau',
+ 'Asia/Yakutsk',
+ 'Asia/Chita',
+ 'Asia/Khandyga',
+ 'Australia/Darwin',
+ 'Pacific/Guam',
+ 'Pacific/Saipan',
+ 'Pacific/Truk',
+ 'Antarctica/DumontDUrville',
+ 'Australia/Brisbane',
+ 'Australia/Lindeman',
+ 'Pacific/Port_Moresby',
+ 'Asia/Vladivostok',
+ 'Asia/Ust-Nera',
+ 'Australia/Adelaide',
+ 'Australia/Broken_Hill',
+ 'Australia/Currie',
+ 'Australia/Hobart',
+ 'Antarctica/Macquarie',
+ 'Australia/Melbourne',
+ 'Australia/Sydney',
+ 'Pacific/Kosrae',
+ 'Australia/Lord_Howe',
+ 'Asia/Magadan',
+ 'Asia/Srednekolymsk',
+ 'Pacific/Noumea',
+ 'Pacific/Bougainville',
+ 'Pacific/Ponape',
+ 'Asia/Sakhalin',
+ 'Pacific/Guadalcanal',
+ 'Pacific/Efate',
+ 'Asia/Anadyr',
+ 'Pacific/Fiji',
+ 'Pacific/Tarawa',
+ 'Pacific/Kwajalein',
+ 'Pacific/Majuro',
+ 'Pacific/Nauru',
+ 'Pacific/Norfolk',
+ 'Asia/Kamchatka',
+ 'Pacific/Funafuti',
+ 'Pacific/Wake',
+ 'Pacific/Wallis',
+ 'Pacific/Apia',
+ 'Pacific/Auckland',
+ 'Antarctica/McMurdo',
+ 'Pacific/Enderbury',
+ 'Pacific/Fakaofo',
+ 'Pacific/Tongatapu',
+ 'Pacific/Chatham',
+ 'Pacific/Kiritimati'
+];
\ No newline at end of file
diff --git a/tools/common/todostrings.mjs b/tools/common/todostrings.mjs
new file mode 100644
index 0000000..0dbbdff
--- /dev/null
+++ b/tools/common/todostrings.mjs
@@ -0,0 +1 @@
+export default ['', '@', '@@', '@@@', 'TBD', 'TODO'];
\ No newline at end of file
diff --git a/tools/lib/validate.mjs b/tools/common/validate.mjs
similarity index 99%
rename from tools/lib/validate.mjs
rename to tools/common/validate.mjs
index 47a3af9..86979db 100644
--- a/tools/lib/validate.mjs
+++ b/tools/common/validate.mjs
@@ -1,9 +1,9 @@
-import { validateProject } from './project.mjs';
-import { initSectionHandlers, validateSessionBody, parseSessionBody } from './session.mjs';
import { fetchSessionChairs, validateSessionChairs } from './chairs.mjs';
import { fetchSessionGroups, validateSessionGroups } from './groups.mjs';
+import { validateProject } from './project.mjs';
+import { initSectionHandlers, validateSessionBody, parseSessionBody } from './session.mjs';
import { parseSessionMeetings, meetsAt, meetsInParallelWith } from './meetings.mjs';
-import { todoStrings } from './todostrings.mjs';
+import todoStrings from './todostrings.mjs';
// List of errors and warnings that are scheduling issues.
const schedulingErrors = [
diff --git a/tools/lib/w3c.mjs b/tools/common/w3c.mjs
similarity index 100%
rename from tools/lib/w3c.mjs
rename to tools/common/w3c.mjs
diff --git a/tools/common/wrappedfetch.mjs b/tools/common/wrappedfetch.mjs
new file mode 100644
index 0000000..2312e80
--- /dev/null
+++ b/tools/common/wrappedfetch.mjs
@@ -0,0 +1,34 @@
+export default async function (url, options) {
+ options = options ?? {};
+ if (typeof UrlFetchApp !== 'undefined') {
+ // AppScript runtime, cannot use fetch directly
+ const params = {};
+ if (options.method) {
+ params.method = options.method;
+ }
+ if (options.headers) {
+ params.header = options.headers;
+ }
+ if (options.body) {
+ params.payload = options.body;
+ }
+
+ const response = UrlFetchApp.fetch(url)
+ return {
+ status: response.getResponseCode(),
+ json: async function () {
+ if (response.getResponseCode() === 200) {
+ const text = response.getContentText('UTF-8');
+ return JSON.parse(text);
+ }
+ },
+ text: async function () {
+ return response.getContentText('UTF-8');
+ }
+ };
+ }
+ else {
+ // Regular runtime, should have a fetch function
+ return fetch(url, options);
+ }
+}
\ No newline at end of file
diff --git a/tools/create-recording-pages.mjs b/tools/create-recording-pages.mjs
index 069929b..b635852 100644
--- a/tools/create-recording-pages.mjs
+++ b/tools/create-recording-pages.mjs
@@ -28,11 +28,11 @@
import path from 'path';
import fs from 'fs/promises';
-import { convert } from './lib/webvtt2html.mjs';
-import { getEnvKey } from './lib/envkeys.mjs';
-import { fetchProject } from './lib/project.mjs';
-import { validateSession } from './lib/validate.mjs';
-import { todoStrings } from './lib/todostrings.mjs';
+import { convert } from './node/lib/webvtt2html.mjs';
+import { getEnvKey } from './common/envkeys.mjs';
+import { fetchProject } from './node/lib/project.mjs';
+import { validateSession } from './common/validate.mjs';
+import todoStrings from './common/todostrings.mjs';
async function listRecordings(accountId, authToken, recordingPrefix) {
const response = await fetch(
diff --git a/tools/init-repo-labels.mjs b/tools/init-repo-labels.mjs
index 0990b99..f288c45 100644
--- a/tools/init-repo-labels.mjs
+++ b/tools/init-repo-labels.mjs
@@ -15,7 +15,7 @@
* created, and each time changes are made to the list of labels below.
*/
-import { sendGraphQLRequest } from './lib/graphql.mjs';
+import { sendGraphQLRequest } from './node/lib/graphql.mjs';
const labels = [
{
diff --git a/tools/init-room-zoom.mjs b/tools/init-room-zoom.mjs
index 59931ae..de6dfb2 100644
--- a/tools/init-room-zoom.mjs
+++ b/tools/init-room-zoom.mjs
@@ -13,8 +13,8 @@
* created and the list of rooms known.
*/
-import { getEnvKey } from './lib/envkeys.mjs';
-import { fetchProject } from './lib/project.mjs';
+import { getEnvKey } from './common/envkeys.mjs';
+import { fetchProject } from './node/lib/project.mjs';
async function run() {
const PROJECT_OWNER = await getEnvKey('PROJECT_OWNER', 'w3c');
diff --git a/tools/lib/todostrings.mjs b/tools/lib/todostrings.mjs
deleted file mode 100644
index 369f64a..0000000
--- a/tools/lib/todostrings.mjs
+++ /dev/null
@@ -1 +0,0 @@
-export const todoStrings = ['', '@', '@@', '@@@', 'TBD', 'TODO'];
\ No newline at end of file
diff --git a/tools/list-chairs.mjs b/tools/list-chairs.mjs
index c59c218..395aebb 100644
--- a/tools/list-chairs.mjs
+++ b/tools/list-chairs.mjs
@@ -7,11 +7,11 @@
* node tools/list-chairs.mjs
*/
-import { getEnvKey } from './lib/envkeys.mjs';
-import { fetchProject } from './lib/project.mjs'
-import { validateGrid } from './lib/validate.mjs';
-import { authenticate } from './lib/calendar.mjs';
-import checkRegistrants from './lib/check-registrants.mjs';
+import { getEnvKey } from './common/envkeys.mjs';
+import { fetchProject } from './node/lib/project.mjs'
+import { validateGrid } from './common/validate.mjs';
+import { authenticate } from './node/lib/calendar.mjs';
+import checkRegistrants from './node/lib/check-registrants.mjs';
import puppeteer from 'puppeteer';
function sleep(ms) {
diff --git a/tools/minutes-to-w3c.mjs b/tools/minutes-to-w3c.mjs
index 674006f..efcedb7 100644
--- a/tools/minutes-to-w3c.mjs
+++ b/tools/minutes-to-w3c.mjs
@@ -10,9 +10,9 @@
* Leave empty to add minute links to all sessions.
*/
-import { getEnvKey } from './lib/envkeys.mjs';
-import { fetchProject } from './lib/project.mjs'
-import { validateSession } from './lib/validate.mjs';
+import { getEnvKey } from './common/envkeys.mjs';
+import { fetchProject } from './node/lib/project.mjs'
+import { validateSession } from './node/lib/validate.mjs';
import fs from 'node:fs/promises';
async function main(number, minutesPrefix) {
diff --git a/tools/node/create-event.mjs b/tools/node/create-event.mjs
new file mode 100644
index 0000000..a21b785
--- /dev/null
+++ b/tools/node/create-event.mjs
@@ -0,0 +1,405 @@
+import path from 'node:path';
+import fs from 'fs/promises';
+import { exec } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import util from 'node:util';
+import { serializeProjectMetadata } from '../common/project.mjs';
+
+/**
+ * The project templates on GitHub for group meetings and breakouts
+ */
+const projectTemplates = {
+ group: 156, // https://github.com/orgs/w3c/projects/156/views/1
+ breakouts: 157 // https://github.com/orgs/w3c/projects/157/views/1
+}
+
+const __dirname = fileURLToPath(new URL('.', import.meta.url));
+
+
+export default async function (jsonfile, options) {
+ console.log(`----- MAGIC BEGINS -----`);
+ console.log(`- Read event data from JSON file`);
+ const data = await fs.readFile(jsonfile, 'utf8');
+ const project = JSON.parse(data);
+
+ // Make sure that we have what we need
+ if (!project.title) {
+ console.warn('No "title" property found in the provided JSON file.');
+ process.exit(1);
+ }
+ if (!project.metadata) {
+ console.warn('No "metadata" property found in the provided JSON file.');
+ process.exit(1);
+ }
+ if (!project.metadata.type in ['group', 'breakouts']) {
+ console.warn('The "metadata.type" property must be one of "group" or "breakouts".');
+ process.exit(1);
+ }
+ if (!project.metadata.reponame) {
+ console.warn('The "metadata.reponame" property must be set to the name of the repository to create.');
+ process.exit(1);
+ }
+ if (project.rooms.length === 0) {
+ console.warn('At least one room must be defined. Room name may remain a placeholder (like "Room 1")');
+ process.exit(1);
+ }
+ if (project.days.length === 0) {
+ console.warn('At least one day must be defined.');
+ process.exit(1);
+ }
+ if (project.slots.length === 0) {
+ console.warn('At least one slot must be defined.');
+ process.exit(1);
+ }
+
+ const repoparts = project.metadata.reponame.split('/');
+ const repo = {
+ owner: repoparts.length > 1 ? repoparts[0] : 'w3c',
+ name: repoparts.length > 1 ? repoparts[1] : repoparts[0]
+ };
+
+ // Make sure the command is not running from within another Git repository
+ // (this would confuse the "npm install" command)
+ {
+ const { stdout, stderr } = await run(`git status`, { ignoreErrors: true });
+ if (stderr && stderr.match(/not a git repository/)) {
+ // Great if that fails!
+ }
+ else {
+ console.error('Command cannot run from within a Git repository!');
+ process.exit(1);
+ }
+ }
+
+
+ // Step: Create the repository on GitHub if not already done
+ {
+ console.log(`- Create "${repo.owner}/${repo.name}" repository on GitHub if needed`);
+ const { stdout, stderr } = await run(`gh repo create ${repo.owner}/${repo.name} --private --clone`, { ignoreErrors: true });
+ if (stderr) {
+ if (stderr.match(/Name already exists/)) {
+ // Repository already exists, no need to worry about that!
+ // Note: we'll assume that, if the local folder exists, it is setup to
+ // be a clone of the GitHub repo already.
+ }
+ else {
+ console.error(`Could not create GitHub repository "${repo.owner}/${repo.name}"`);
+ console.error(stderr);
+ process.exit(1);
+ }
+ }
+ }
+
+ // Step: Create local folder if it does not exist already
+ // (setting "recursive" to true makes function fail silently if folder
+ // already exists)
+ {
+ console.log(`- Clone GitHub repository to "${repo.name}" folder`);
+ try {
+ await fs.stat(repo.name);
+ }
+ catch (err) {
+ if (err.code === 'ENOENT') {
+ await run(`gh repo clone ${repo.owner}/${repo.name} -- --quiet`);
+ }
+ else {
+ throw err;
+ }
+ }
+
+ }
+
+ // Step: Make local folder a git repository if not already done
+ {
+ console.log(`- Run git init in "${repo.name}" folder`);
+ await run('git init', { cwd: repo.name });
+ }
+
+ // Step: Copy files to local folder, but don't override README.md
+ // if it already exists.
+ {
+ console.log(`- Copy content to "${repo.name}" folder`);
+ const filesSource = path.join(__dirname, '..', '..', 'files');
+ const templateSource = path.join(filesSource, 'issue-template');
+ const templateDest = path.join(repo.name, '.github', 'ISSUE_TEMPLATE');
+ await fs.mkdir(templateDest, { recursive: true });
+ await fs.copyFile(
+ path.join(templateSource,
+ project.metadata.type === 'group' ? 'meeting.yml' : 'session.yml'),
+ path.join(templateDest, 'session.yml')
+ );
+
+ const workflowsSource = path.join(filesSource, 'workflows');
+ const workflowsDest = path.join(repo.name, '.github', 'workflows');
+ await fs.mkdir(workflowsDest, { recursive: true });
+ const folderContent = await fs.readdir(workflowsSource);
+ await Promise.all(folderContent.map(async name => {
+ const file = path.join(workflowsSource, name);
+ return fs.copyFile(file, path.join(workflowsDest, name));
+ }));
+
+ if (project.metadata.type === 'groups') {
+ // TODO: the validate-session job needs to be adjusted not to use
+ // session-created.md!
+ }
+ else {
+ await fs.copyFile(
+ path.join(filesSource, 'session-created.md'),
+ path.join(repo.name, '.github', 'session-created.md')
+ );
+ }
+
+ // This repo's "w3c.json" should be good enough for the new repo
+ await fs.copyFile(
+ path.join(__dirname, '..', '..', 'w3c.json'),
+ path.join(repo.name, 'w3c.json')
+ );
+
+ // This repo's ".gitignore" should also be good enough for the new repo.
+ // It contains a couple of entries that are not needed, but so be it!
+ await fs.copyFile(
+ path.join(__dirname, '..', '..', '.gitignore'),
+ path.join(repo.name, '.gitignore')
+ );
+
+ const readmeFile = path.join(repo.name, 'README.md');
+ try {
+ await fs.stat(readmeFile);
+ }
+ catch (err) {
+ if (err.code === 'ENOENT') {
+ await fs.writeFile(readmeFile, `# ${project.title}`, 'utf8');
+ }
+ else {
+ throw err;
+ }
+ }
+ }
+
+ // Step: Add w3c/tpac-breakouts dependency
+ {
+ console.log(`- Install w3c/tpac-breakouts in "${repo.name}" folder`);
+ //await run('npm install w3c/tpac-breakouts', { cwd: repo.name });
+ }
+
+ // Step: Commit git changes in local folder
+ {
+ console.log(`- Commit changes in "${repo.name}" folder`);
+ {
+ const { stdout, stderr } = await run('git add -A', { cwd: repo.name, ignoreErrors: true });
+ if (stderr && !stderr.split('\n').every(line =>
+ line.startsWith('warning:') || !line.trim())) {
+ console.error(`Could not run "git add -A" in folder "${repo.name}"`);
+ console.error(stderr);
+ process.exit(1);
+ }
+ }
+
+ {
+ const { stdout } = await run('git status', { cwd: repo.name });
+ if (!stdout.match(/nothing to commit/)) {
+ await run('git commit -m "Synchronize content with w3c/tpac-breakouts" --quiet', { cwd: repo.name });
+ }
+ }
+ }
+
+ // Step: Push git changes to GitHub
+ {
+ console.log(`- Push changes to "${repo.owner}/${repo.name}"`);
+ {
+ const { stdout, stderr } = await run('git push origin main', { cwd: repo.name, ignoreErrors: true });
+ if (stderr &&
+ !stderr.match(/Everything up-to-date/) &&
+ !stderr.match(/To github.com:/)) {
+ console.error(`Could not run "git push origin main" in folder "${repo.name}"`);
+ console.error(stderr);
+ process.exit(1);
+ }
+ }
+ }
+
+ // Step: Create session label on GitHub
+ {
+ console.log(`- Create session label in "${repo.owner}/${repo.name}"`);
+ const desc = (project.metadata.type === 'groups') ?
+ 'Group meeting' : 'Breakout session proposal'
+ const { stdout } = await run(`gh label create session --color C2E0C6 --description "${desc}" --force`, { cwd: repo.name });
+ }
+
+ // Step: Create GitHub project if needed
+ const gProject = {};
+ {
+ console.log(`- Retrieve/Create associated GitHub project`);
+ const { stdout } = await run('gh repo view --json projectsV2', { cwd: repo.name });
+ const projectsV2 = JSON.parse(stdout);
+ if (projectsV2?.projectsV2?.Nodes?.length === 0) {
+ const { stdout } = await run(`gh project copy ${projectTemplates[project.metadata.type]} --source-owner w3c --target-owner ${repo.owner} --title "${project.title}"`);
+ // stdout should contain a URL like:
+ // https://github.com/users/tidoust/projects/xx
+ // or
+ // https://github.com/orgs/w3c/projects/xx
+ gProject.url = stdout;
+ gProject.number = parseInt(gProject.match(/\/projects\/(\d+)$/)[1], 10);
+ await run(`gh project link ${gProject.number} --owner ${repo.owner}`)
+ }
+ else {
+ gProject.number = projectsV2.projectsV2.Nodes[0].number,
+ gProject.url = projectsV2.projectsV2.Nodes[0].url;
+ }
+ }
+
+ // Step: Refresh project settings as needed
+ {
+ console.log(`- Refresh project title and description`);
+ const settings = {
+ meeting: project.metadata.meeting || '@@',
+ type: project.metadata.type || 'breakouts',
+ timezone: project.metadata.timezone || 'Etc/UTC',
+ calendar: project.metadata.calendar || 'no',
+ rooms: project.metadata.rooms || 'show'
+ };
+ await run(`gh project edit ${gProject.number} --owner ${repo.owner} --title "${project.title}" --description "${serializeProjectMetadata(settings)}"`)
+ }
+
+ // Step: Retrieve the IDs of the custom fields in the GitHub project
+ {
+ console.log(`- Retrieve custom fields' IDs`);
+ const { stdout } = await run(`gh project field-list ${gProject.number} --owner ${repo.owner} --format json`);
+ const desc = JSON.parse(stdout);
+
+ const room = desc.fields.find(field => field.name === "Room");
+ gProject.roomsFieldId = room.id;
+ gProject.rooms = room.options;
+
+ const day = desc.fields.find(field => field.name === 'Day');
+ gProject.daysFieldId = day.id;
+ gProject.days = day.options;
+
+ const slot = desc.fields.find(field => field.name === 'Slot');
+ gProject.slotsFieldId = slot.id;
+ gProject.slots = slot.options;
+ }
+
+ // Step: Refresh the list of options for each custom field
+ for (const field of ['rooms', 'days', 'slots']) {
+ console.log(`- Refresh list of ${field}`);
+ const singleSelectOptions = project[field].map(item => {
+ let desc = [];
+ if (field === 'rooms') {
+ for (const [key, value] of Object.entries(item)) {
+ if (key !== 'id' && key !== 'name' &&
+ value !== false && value !== null) {
+ desc.push(`- ${key}: ${value}`);
+ }
+ }
+ }
+ return `{ name: "${item.name}", description: "${desc.join('\\n')}", color: GRAY }`;
+ });
+ const query = `
+mutation($fieldId: ID!) {
+ updateProjectV2Field(input: {
+ clientMutationId: "mutatis mutandis",
+ fieldId: $fieldId,
+ singleSelectOptions: [${singleSelectOptions.join(',\n')}]
+ }) {
+ clientMutationId
+ }
+}
+ `;
+ const queryFile = path.join(repo.name, 'query.graphql');
+ await fs.writeFile(queryFile, query, 'utf8');
+ const fieldId = gProject[field + 'FieldId'];
+ const { stdout } = await run(
+ `gh api graphql -F fieldId=${fieldId} -F query=@query.graphql`,
+ { cwd: repo.name }
+ );
+ const res = JSON.parse(stdout);
+ if (res?.data?.updateProjectV2Field?.clientMutationId !== 'mutatis mutandis') {
+ console.error(`Could not refresh the list of ${field} in project ${gProject.number}`);
+ console.error(stdout);
+ process.exit(1);
+ }
+ await fs.unlink(queryFile);
+ }
+
+ // Step: Setup repository variables
+ {
+ console.log(`- Setup repository variables`);
+ if (repo.owner !== 'w3c') {
+ await run(`gh variable set PROJECT_OWNER --body "${repo.owner}"`, { cwd: repo.name });
+ }
+ await run(`gh variable set PROJECT_NUMBER --body "${gProject.number}"`, { cwd: repo.name });
+
+ const { stdout } = await run(`gh variable list --json name`, { cwd: repo.name });
+ const variables = JSON.parse(stdout);
+ if (!variables.find(v => v.name === 'W3CID_MAP')) {
+ await run(`gh variable set W3CID_MAP --body "{}"`, { cwd: repo.name });
+ }
+ if (!variables.find(v => v.name === 'ROOM_ZOOM')) {
+ await run(`gh variable set ROOM_ZOOM --body "{}"`, { cwd: repo.name });
+ }
+ if (!variables.find(v => v.name === 'W3C_LOGIN')) {
+ await run(`gh variable set W3C_LOGIN --body "fd"`, { cwd: repo.name });
+ }
+ }
+
+ console.log(
+`----- MAGIC ENDS -----
+
+----- MANUAL STEPS -----
+The following should now exist:
+- Repository: https://github.com/${repo.owner}/${repo.name}
+- Repository clone: in "${repo.name}" subfolder
+- Project: ${gProject.url}
+
+But you still have work to do!
+
+Run the following actions (in any order):
+- Give @tpac-breakout-bot write permissions onto the project at:
+ ${gProject.url}/settings/access
+- Give @tpac-breakout-bot write access to the repository at:
+ https://github.com/${repo.owner}/${repo.name}/settings/access
+- Set "watch" to "All Activity" for the repository to receive comments left on issues:
+ https://github.com/${repo.owner}/${repo.name}
+ (look for the dropdown menu named "Watch" or "Unwatch")
+- Ask François (fd@w3.org) to set the "GRAPHQL_TOKEN" and "W3C_PASSWORD" repository secret. Sorry, not something you can do on your own for now!`);
+
+ if (project.metadata.type !== 'groups') {
+ console.log(`
+Consider adding documentation to the repository:
+- Enable Wiki pages on the repository:
+ https://github.com/${repo.owner}/${repo.name}/settings
+- Add Wiki pages to the repository:
+ https://github.com/${repo.owner}/${repo.name}/wiki
+ see https://github.com/w3c/tpac2024-breakouts/wiki for inspiration
+- Adjust the README as needed:
+ https://github.com/${repo.owner}/${repo.name}/blob/main/README.md
+ see https://github.com/w3c/tpac2024-breakouts/blob/main/README.md for inspiration`);
+ }
+}
+
+
+/**
+ * Helper function to run a bash command within the script and report result
+ */
+async function run(cmd, options) {
+ try {
+ const { stdout, stderr } = await util.promisify(exec)(cmd, options);
+ if (stderr && !options?.ignoreErrors) {
+ console.error(`Could not run command: ${cmd}`);
+ console.error(stderr);
+ process.exit(1);
+ }
+ return { stdout: stdout.trim(), stderr: stderr.trim() };
+ }
+ catch (err) {
+ if (options?.ignoreErrors) {
+ return { stdout: '', stderr: err.toString().trim() };
+ }
+ else {
+ console.error(`Could not run command: ${cmd}`);
+ console.error(err.toString());
+ process.exit(1);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/lib/calendar.mjs b/tools/node/lib/calendar.mjs
similarity index 98%
rename from tools/lib/calendar.mjs
rename to tools/node/lib/calendar.mjs
index a1f190f..4ab71bb 100644
--- a/tools/lib/calendar.mjs
+++ b/tools/node/lib/calendar.mjs
@@ -1,7 +1,15 @@
-import { validateSession } from './validate.mjs';
-import { updateSessionDescription } from './session.mjs';
-import { todoStrings } from './todostrings.mjs';
-import { computeSessionCalendarUpdates, meetsAt } from './meetings.mjs';
+/**
+ * Handles synchronization with the W3C calendar.
+ *
+ * Since there is no proper API endpoint to interact with the W3C calendar, the
+ * code drives a Puppeteer instance behind the scenes to access and manage
+ * calendar entries. As such, the code can only run in a Node.js environment.
+ */
+
+import { validateSession } from '../../common/validate.mjs';
+import { updateSessionDescription } from '../../common/session.mjs';
+import todoStrings from '../../common/todostrings.mjs';
+import { computeSessionCalendarUpdates, meetsAt } from '../../common/meetings.mjs';
/**
diff --git a/tools/lib/check-registrants.mjs b/tools/node/lib/check-registrants.mjs
similarity index 100%
rename from tools/lib/check-registrants.mjs
rename to tools/node/lib/check-registrants.mjs
diff --git a/tools/node/lib/meetings.mjs b/tools/node/lib/meetings.mjs
new file mode 100644
index 0000000..b972d54
--- /dev/null
+++ b/tools/node/lib/meetings.mjs
@@ -0,0 +1,106 @@
+import * as YAML from 'yaml';
+
+/**
+ * Parse a list of meetings changes defined in a YAML string.
+ *
+ * Meeting changes are used to apply local changes to a schedule.
+ */
+export function parseMeetingsChanges(yaml) {
+ const resources = ['room', 'day', 'slot', 'meeting'];
+ const yamlChanges = YAML.parse(yaml);
+ return yamlChanges.map(yamlChange => {
+ const change = {};
+ for (const [key, value] of Object.entries(yamlChange)) {
+ if (!['number', 'reset', 'room', 'day', 'slot', 'meeting'].includes(key)) {
+ throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" is an unexpected key`);
+ }
+ switch (key) {
+ case 'number':
+ if (!Number.isInteger(value)) {
+ throw new Error(`Invalid meetings changes: #${value} is not a session number`);
+ }
+ change[key] = value;
+ break;
+ case 'reset':
+ if (value === 'all') {
+ change[key] = resources.slice();
+ }
+ else if (Array.isArray(value)) {
+ if (value.find(val => !resources.includes(val))) {
+ throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" values "${value.join(', ')}" contains an unexpected field`);
+ }
+ change[key] = value;
+ }
+ else if (!resources.includes(value)) {
+ throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" value "${value}" is unexpected`);
+ }
+ else {
+ change[key] = [value];
+ }
+ break;
+
+ case 'room':
+ case 'day':
+ case 'slot':
+ if (typeof value !== 'string') {
+ throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" value is not a string`);
+ }
+ change[key] = value;
+ break;
+
+ case 'meeting':
+ if (Array.isArray(value)) {
+ if (value.find(val => typeof val !== 'string' ||
+ val.includes(';') ||
+ val.includes('|'))) {
+ throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" value is not an array of individual meeting strings`);
+ }
+ change[key] = value.join('; ');
+ }
+ else if (typeof value !== 'string') {
+ throw new Error(`Invalid meetings changes for #${yamlChange.number}: "${key}" value is not a string`);
+ }
+ else {
+ change[key] = value;
+ }
+ }
+ if (!change.number) {
+ throw new Error(`Invalid meetings changes: all changes must reference a session number`);
+ }
+ }
+ return change;
+ });
+}
+
+
+/**
+ * Apply the list of meetings changes to the given list of sessions.
+ *
+ * Sessions are updated in place. The sessions that are effectively updated
+ * also get an `updated` flag.
+ *
+ * The list of meetings changes must follow the structure returned by the
+ * previous parseMeetingsChanges function.
+ */
+export function applyMeetingsChanges(sessions, changes) {
+ for (const change of changes) {
+ const session = sessions.find(s => s.number === change.number);
+ if (!session) {
+ throw new Error(`Invalid change requested: #${change.number} does not exist`);
+ }
+ if (change.reset) {
+ for (const field of change.reset) {
+ if (session[field]) {
+ delete session[field];
+ session.updated = true;
+ }
+ }
+ }
+ for (const field of ['room', 'day', 'slot', 'meeting']) {
+ if (change[field] && change[field] !== session[field]) {
+ session[field] = change[field];
+ session.updated = true;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/lib/project.mjs b/tools/node/lib/project.mjs
similarity index 56%
rename from tools/lib/project.mjs
rename to tools/node/lib/project.mjs
index 7904b67..df28d8a 100644
--- a/tools/lib/project.mjs
+++ b/tools/node/lib/project.mjs
@@ -1,457 +1,9 @@
-import { sendGraphQLRequest } from './graphql.mjs';
-import { getEnvKey } from './envkeys.mjs';
+import { sendGraphQLRequest } from '../../common/graphql.mjs';
+import { getEnvKey } from '../../common/envkeys.mjs';
import { readFile } from 'node:fs/promises';
import * as path from 'node:path';
import * as YAML from 'yaml';
-
-/**
- * List of allowed timezone values.
- *
- * The list comes from running the following query on any W3C calendar entry:
- *
- * [...document.querySelectorAll('#event_timezone option')]
- * .map(option => option.getAttribute('value'))
- * .filter(value => !!value);
- *
- * This query should return an array with >430 entries.
- */
-const timezones = [
- 'Pacific/Niue',
- 'Pacific/Midway',
- 'Pacific/Pago_Pago',
- 'Pacific/Rarotonga',
- 'Pacific/Honolulu',
- 'Pacific/Johnston',
- 'Pacific/Tahiti',
- 'Pacific/Marquesas',
- 'Pacific/Gambier',
- 'America/Adak',
- 'America/Anchorage',
- 'America/Juneau',
- 'America/Metlakatla',
- 'America/Nome',
- 'America/Sitka',
- 'America/Yakutat',
- 'Pacific/Pitcairn',
- 'America/Hermosillo',
- 'America/Mazatlan',
- 'America/Creston',
- 'America/Dawson_Creek',
- 'America/Fort_Nelson',
- 'America/Phoenix',
- 'America/Santa_Isabel',
- 'PST8PDT',
- 'America/Los_Angeles',
- 'America/Tijuana',
- 'America/Vancouver',
- 'America/Dawson',
- 'America/Whitehorse',
- 'America/Bahia_Banderas',
- 'America/Belize',
- 'America/Costa_Rica',
- 'America/El_Salvador',
- 'America/Guatemala',
- 'America/Managua',
- 'America/Merida',
- 'America/Mexico_City',
- 'America/Monterrey',
- 'America/Regina',
- 'America/Swift_Current',
- 'America/Tegucigalpa',
- 'Pacific/Galapagos',
- 'America/Chihuahua',
- 'MST7MDT',
- 'America/Boise',
- 'America/Cambridge_Bay',
- 'America/Denver',
- 'America/Edmonton',
- 'America/Inuvik',
- 'America/Yellowknife',
- 'America/Eirunepe',
- 'America/Rio_Branco',
- 'CST6CDT',
- 'America/North_Dakota/Beulah',
- 'America/North_Dakota/Center',
- 'America/Chicago',
- 'America/Indiana/Knox',
- 'America/Matamoros',
- 'America/Menominee',
- 'America/North_Dakota/New_Salem',
- 'America/Rainy_River',
- 'America/Rankin_Inlet',
- 'America/Resolute',
- 'America/Indiana/Tell_City',
- 'America/Winnipeg',
- 'America/Bogota',
- 'Pacific/Easter',
- 'America/Coral_Harbour',
- 'America/Cancun',
- 'America/Cayman',
- 'America/Jamaica',
- 'America/Panama',
- 'America/Guayaquil',
- 'America/Ojinaga',
- 'America/Lima',
- 'America/Boa_Vista',
- 'America/Campo_Grande',
- 'America/Cuiaba',
- 'America/Manaus',
- 'America/Porto_Velho',
- 'America/Anguilla',
- 'America/Antigua',
- 'America/Aruba',
- 'America/Barbados',
- 'America/Blanc-Sablon',
- 'America/Curacao',
- 'America/Dominica',
- 'America/Grenada',
- 'America/Guadeloupe',
- 'America/Kralendijk',
- 'America/Lower_Princes',
- 'America/Marigot',
- 'America/Martinique',
- 'America/Montserrat',
- 'America/Port_of_Spain',
- 'America/Puerto_Rico',
- 'America/Santo_Domingo',
- 'America/St_Barthelemy',
- 'America/St_Kitts',
- 'America/St_Lucia',
- 'America/St_Thomas',
- 'America/St_Vincent',
- 'America/Tortola',
- 'America/La_Paz',
- 'America/Montreal',
- 'America/Havana',
- 'EST5EDT',
- 'America/Detroit',
- 'America/Grand_Turk',
- 'America/Indianapolis',
- 'America/Iqaluit',
- 'America/Louisville',
- 'America/Indiana/Marengo',
- 'America/Kentucky/Monticello',
- 'America/Nassau',
- 'America/New_York',
- 'America/Nipigon',
- 'America/Pangnirtung',
- 'America/Indiana/Petersburg',
- 'America/Port-au-Prince',
- 'America/Thunder_Bay',
- 'America/Toronto',
- 'America/Indiana/Vevay',
- 'America/Indiana/Vincennes',
- 'America/Indiana/Winamac',
- 'America/Guyana',
- 'America/Caracas',
- 'America/Buenos_Aires',
- 'America/Catamarca',
- 'America/Cordoba',
- 'America/Jujuy',
- 'America/Argentina/La_Rioja',
- 'America/Mendoza',
- 'America/Argentina/Rio_Gallegos',
- 'America/Argentina/Salta',
- 'America/Argentina/San_Juan',
- 'America/Argentina/San_Luis',
- 'America/Argentina/Tucuman',
- 'America/Argentina/Ushuaia',
- 'Atlantic/Bermuda',
- 'America/Glace_Bay',
- 'America/Goose_Bay',
- 'America/Halifax',
- 'America/Moncton',
- 'America/Thule',
- 'America/Araguaina',
- 'America/Bahia',
- 'America/Belem',
- 'America/Fortaleza',
- 'America/Maceio',
- 'America/Recife',
- 'America/Santarem',
- 'America/Sao_Paulo',
- 'Antarctica/Palmer',
- 'America/Punta_Arenas',
- 'America/Santiago',
- 'Atlantic/Stanley',
- 'America/Cayenne',
- 'America/Asuncion',
- 'Antarctica/Rothera',
- 'America/Paramaribo',
- 'America/Montevideo',
- 'America/St_Johns',
- 'America/Noronha',
- 'Atlantic/South_Georgia',
- 'America/Miquelon',
- 'America/Godthab',
- 'Atlantic/Azores',
- 'Atlantic/Cape_Verde',
- 'America/Scoresbysund',
- 'Etc/UTC',
- 'Etc/GMT',
- 'Africa/Abidjan',
- 'Africa/Accra',
- 'Africa/Bamako',
- 'Africa/Banjul',
- 'Africa/Bissau',
- 'Africa/Conakry',
- 'Africa/Dakar',
- 'America/Danmarkshavn',
- 'Europe/Dublin',
- 'Africa/Freetown',
- 'Europe/Guernsey',
- 'Europe/Isle_of_Man',
- 'Europe/Jersey',
- 'Africa/Lome',
- 'Europe/London',
- 'Africa/Monrovia',
- 'Africa/Nouakchott',
- 'Africa/Ouagadougou',
- 'Atlantic/Reykjavik',
- 'Atlantic/St_Helena',
- 'Africa/Sao_Tome',
- 'Antarctica/Troll',
- 'Atlantic/Canary',
- 'Africa/Casablanca',
- 'Africa/El_Aaiun',
- 'Atlantic/Faeroe',
- 'Europe/Lisbon',
- 'Atlantic/Madeira',
- 'Africa/Algiers',
- 'Europe/Amsterdam',
- 'Europe/Andorra',
- 'Europe/Belgrade',
- 'Europe/Berlin',
- 'Europe/Bratislava',
- 'Europe/Brussels',
- 'Europe/Budapest',
- 'Europe/Busingen',
- 'Africa/Ceuta',
- 'Europe/Copenhagen',
- 'Europe/Gibraltar',
- 'Europe/Ljubljana',
- 'Arctic/Longyearbyen',
- 'Europe/Luxembourg',
- 'Europe/Madrid',
- 'Europe/Malta',
- 'Europe/Monaco',
- 'Europe/Oslo',
- 'Europe/Paris',
- 'Europe/Podgorica',
- 'Europe/Prague',
- 'Europe/Rome',
- 'Europe/San_Marino',
- 'Europe/Sarajevo',
- 'Europe/Skopje',
- 'Europe/Stockholm',
- 'Europe/Tirane',
- 'Africa/Tunis',
- 'Europe/Vaduz',
- 'Europe/Vatican',
- 'Europe/Vienna',
- 'Europe/Warsaw',
- 'Europe/Zagreb',
- 'Europe/Zurich',
- 'Africa/Bangui',
- 'Africa/Brazzaville',
- 'Africa/Douala',
- 'Africa/Kinshasa',
- 'Africa/Lagos',
- 'Africa/Libreville',
- 'Africa/Luanda',
- 'Africa/Malabo',
- 'Africa/Ndjamena',
- 'Africa/Niamey',
- 'Africa/Porto-Novo',
- 'Africa/Blantyre',
- 'Africa/Bujumbura',
- 'Africa/Gaborone',
- 'Africa/Harare',
- 'Africa/Juba',
- 'Africa/Khartoum',
- 'Africa/Kigali',
- 'Africa/Lubumbashi',
- 'Africa/Lusaka',
- 'Africa/Maputo',
- 'Africa/Windhoek',
- 'Europe/Athens',
- 'Asia/Beirut',
- 'Europe/Bucharest',
- 'Africa/Cairo',
- 'Europe/Chisinau',
- 'Asia/Famagusta',
- 'Asia/Gaza',
- 'Asia/Hebron',
- 'Europe/Helsinki',
- 'Europe/Kaliningrad',
- 'Europe/Kiev',
- 'Europe/Mariehamn',
- 'Asia/Nicosia',
- 'Europe/Riga',
- 'Europe/Sofia',
- 'Europe/Tallinn',
- 'Africa/Tripoli',
- 'Europe/Uzhgorod',
- 'Europe/Vilnius',
- 'Europe/Zaporozhye',
- 'Asia/Jerusalem',
- 'Africa/Johannesburg',
- 'Africa/Maseru',
- 'Africa/Mbabane',
- 'Asia/Aden',
- 'Asia/Baghdad',
- 'Asia/Bahrain',
- 'Asia/Kuwait',
- 'Asia/Qatar',
- 'Asia/Riyadh',
- 'Africa/Addis_Ababa',
- 'Indian/Antananarivo',
- 'Africa/Asmera',
- 'Indian/Comoro',
- 'Africa/Dar_es_Salaam',
- 'Africa/Djibouti',
- 'Africa/Kampala',
- 'Indian/Mayotte',
- 'Africa/Mogadishu',
- 'Africa/Nairobi',
- 'Asia/Amman',
- 'Asia/Damascus',
- 'Europe/Moscow',
- 'Europe/Minsk',
- 'Europe/Simferopol',
- 'Europe/Kirov',
- 'Antarctica/Syowa',
- 'Europe/Istanbul',
- 'Europe/Volgograd',
- 'Asia/Tehran',
- 'Asia/Yerevan',
- 'Asia/Baku',
- 'Asia/Tbilisi',
- 'Asia/Dubai',
- 'Asia/Muscat',
- 'Indian/Mauritius',
- 'Europe/Astrakhan',
- 'Europe/Saratov',
- 'Europe/Ulyanovsk',
- 'Indian/Reunion',
- 'Europe/Samara',
- 'Indian/Mahe',
- 'Asia/Kabul',
- 'Asia/Almaty',
- 'Asia/Qostanay',
- 'Indian/Kerguelen',
- 'Indian/Maldives',
- 'Antarctica/Mawson',
- 'Asia/Karachi',
- 'Asia/Dushanbe',
- 'Asia/Ashgabat',
- 'Asia/Samarkand',
- 'Asia/Tashkent',
- 'Antarctica/Vostok',
- 'Asia/Aqtau',
- 'Asia/Aqtobe',
- 'Asia/Atyrau',
- 'Asia/Oral',
- 'Asia/Qyzylorda',
- 'Asia/Yekaterinburg',
- 'Asia/Colombo',
- 'Asia/Calcutta',
- 'Asia/Katmandu',
- 'Asia/Dhaka',
- 'Asia/Thimphu',
- 'Asia/Urumqi',
- 'Indian/Chagos',
- 'Asia/Bishkek',
- 'Asia/Omsk',
- 'Indian/Cocos',
- 'Asia/Rangoon',
- 'Indian/Christmas',
- 'Antarctica/Davis',
- 'Asia/Hovd',
- 'Asia/Bangkok',
- 'Asia/Saigon',
- 'Asia/Phnom_Penh',
- 'Asia/Vientiane',
- 'Asia/Krasnoyarsk',
- 'Asia/Novokuznetsk',
- 'Asia/Novosibirsk',
- 'Asia/Barnaul',
- 'Asia/Tomsk',
- 'Asia/Jakarta',
- 'Asia/Pontianak',
- 'Asia/Brunei',
- 'Antarctica/Casey',
- 'Asia/Makassar',
- 'Asia/Macau',
- 'Asia/Shanghai',
- 'Asia/Hong_Kong',
- 'Asia/Irkutsk',
- 'Asia/Kuala_Lumpur',
- 'Asia/Kuching',
- 'Asia/Manila',
- 'Asia/Singapore',
- 'Asia/Taipei',
- 'Asia/Ulaanbaatar',
- 'Asia/Choibalsan',
- 'Australia/Perth',
- 'Australia/Eucla',
- 'Asia/Dili',
- 'Asia/Jayapura',
- 'Asia/Tokyo',
- 'Asia/Pyongyang',
- 'Asia/Seoul',
- 'Pacific/Palau',
- 'Asia/Yakutsk',
- 'Asia/Chita',
- 'Asia/Khandyga',
- 'Australia/Darwin',
- 'Pacific/Guam',
- 'Pacific/Saipan',
- 'Pacific/Truk',
- 'Antarctica/DumontDUrville',
- 'Australia/Brisbane',
- 'Australia/Lindeman',
- 'Pacific/Port_Moresby',
- 'Asia/Vladivostok',
- 'Asia/Ust-Nera',
- 'Australia/Adelaide',
- 'Australia/Broken_Hill',
- 'Australia/Currie',
- 'Australia/Hobart',
- 'Antarctica/Macquarie',
- 'Australia/Melbourne',
- 'Australia/Sydney',
- 'Pacific/Kosrae',
- 'Australia/Lord_Howe',
- 'Asia/Magadan',
- 'Asia/Srednekolymsk',
- 'Pacific/Noumea',
- 'Pacific/Bougainville',
- 'Pacific/Ponape',
- 'Asia/Sakhalin',
- 'Pacific/Guadalcanal',
- 'Pacific/Efate',
- 'Asia/Anadyr',
- 'Pacific/Fiji',
- 'Pacific/Tarawa',
- 'Pacific/Kwajalein',
- 'Pacific/Majuro',
- 'Pacific/Nauru',
- 'Pacific/Norfolk',
- 'Asia/Kamchatka',
- 'Pacific/Funafuti',
- 'Pacific/Wake',
- 'Pacific/Wallis',
- 'Pacific/Apia',
- 'Pacific/Auckland',
- 'Antarctica/McMurdo',
- 'Pacific/Enderbury',
- 'Pacific/Fakaofo',
- 'Pacific/Tongatapu',
- 'Pacific/Chatham',
- 'Pacific/Kiritimati'
-];
-
+import { parseProjectDescription } from '../../common/project.mjs';
/**
* Retrieve available project data.
@@ -466,7 +18,7 @@ const timezones = [
* Returned object should look like:
* {
* "title": "TPAC xxxx breakout sessions",
- * "url": "https://github.com/organization/w3c/projects/xx",
+ * "url": "https://github.com/orgs/w3c/projects/xx",
* "id": "xxxxxxx",
* "roomsFieldId": "xxxxxxx",
* "rooms": [
@@ -917,24 +469,6 @@ export async function fetchProject(login, id) {
}
-/**
- * Helper function to parse a project description and extract additional
- * metadata about breakout sessions: date, timezone, big meeting id
- *
- * Description needs to be a comma-separated list of parameters. Example:
- * "meeting: tpac2023, day: 2023-09-13, timezone: Europe/Madrid"
- */
-function parseProjectDescription(desc) {
- const metadata = {};
- if (desc) {
- desc.split(/,/)
- .map(param => param.trim())
- .map(param => param.split(/:/).map(val => val.trim()))
- .map(param => metadata[param[0]] = param[1]);
- }
- return metadata;
-}
-
/**
* Record the meetings assignments for the provided session
*/
@@ -1008,97 +542,3 @@ export async function saveSessionValidationResult(session, project) {
}
}
}
-
-
-/**
- * Validate that we have the information we need about the project.
- */
-export function validateProject(project) {
- const errors = [];
-
- if (!project.metadata) {
- errors.push('The short description is missing. It should set the meeting, date, and timezone.');
- }
- else {
- if (!project.metadata.meeting) {
- errors.push('The "meeting" info in the short description is missing. Should be something like "meeting: TPAC 2023"');
- }
- if (!project.metadata.timezone) {
- errors.push('The "timezone" info in the short description is missing. Should be something like "timezone: Europe/Madrid"');
- }
- else if (!timezones.includes(project.metadata.timezone)) {
- errors.push('The "timezone" info in the short description is not a valid timezone. Value should be a "tz identifier" in https://en.wikipedia.org/wiki/List_of_tz_database_time_zones');
- }
- if (!['groups', 'breakouts', undefined].includes(project.metadata?.type)) {
- errors.push('The "type" info must be one of "groups" or "breakouts"');
- }
- if (project.metadata.calendar &&
- !['no', 'draft', 'tentative', 'confirmed'].includes(project.metadata.calendar)) {
- errors.push('The "calendar" info must be one of "no", "draft", "tentative" or "confirmed"');
- }
- }
-
- for (const slot of project.slots) {
- if (!slot.name.match(/^(\d+):(\d+)\s*-\s*(\d+):(\d+)$/)) {
- errors.push(`Invalid slot name "${slot.name}". Format should be "HH:mm - HH:mm"`);
- }
- if (slot.duration < 30 || slot.duration > 120) {
- errors.push(`Unexpected slot duration ${slot.duration}. Duration should be between 30 and 120 minutes.`);
- }
- }
-
- for (const day of project.days) {
- if (!day.date.match(/^\d{4}\-\d{2}\-\d{2}$/)) {
- errors.push(`Invalid day name "${day.name}". Format should be either "YYYY-MM-DD" or "[label] (YYYY-MM-DD)`);
- }
- else if (isNaN((new Date(day.date)).valueOf())) {
- errors.push(`Invalid date in day name "${day.name}".`);
- }
- }
-
- return errors;
-}
-
-
-/**
- * Convert the project to a simplified JSON data structure
- * (suitable for tests but also for debugging)
- */
-export function convertProjectToJSON(project) {
- const toNameList = list => list.map(item => item.name);
- const data = {
- title: project.title,
- description: project.description
- };
- if (project.allowMultipleMeetings) {
- data.allowMultipleMeetings = true;
- }
- if (project.allowTryMeOut) {
- data.allowTryMeOut = true;
- }
- if (project.allowRegistrants) {
- data.allowRegistrants = true;
- }
- for (const list of ['days', 'rooms', 'slots', 'labels']) {
- data[list] = toNameList(project[list]);
- }
-
- data.sessions = project.sessions.map(session => {
- const simplified = {
- number: session.number,
- title: session.title,
- author: session.author.login,
- body: session.body,
- };
- if (session.labels.length !== 1 || session.labels[0] !== 'session') {
- simplified.labels = session.labels;
- }
- for (const field of ['day', 'room', 'slot', 'meeting', 'registrants']) {
- if (session[field]) {
- simplified[field] = session[field];
- }
- }
- return simplified;
- });
- return data;
-}
\ No newline at end of file
diff --git a/tools/lib/project2html.mjs b/tools/node/lib/project2html.mjs
similarity index 99%
rename from tools/lib/project2html.mjs
rename to tools/node/lib/project2html.mjs
index ee86ddc..6493005 100644
--- a/tools/lib/project2html.mjs
+++ b/tools/node/lib/project2html.mjs
@@ -1,5 +1,5 @@
-import { parseSessionMeetings, groupSessionMeetings } from './meetings.mjs';
-import { validateGrid } from './validate.mjs';
+import { parseSessionMeetings, groupSessionMeetings } from '../../common/meetings.mjs';
+import { validateGrid } from '../../common/validate.mjs';
import * as YAML from 'yaml';
const hasMeeting = s => s.atomicMeetings.find(m => m.room && m.day && m.slot);
diff --git a/tools/lib/project2sheet.mjs b/tools/node/lib/project2sheet.mjs
similarity index 99%
rename from tools/lib/project2sheet.mjs
rename to tools/node/lib/project2sheet.mjs
index 11a2891..fde09ee 100644
--- a/tools/lib/project2sheet.mjs
+++ b/tools/node/lib/project2sheet.mjs
@@ -1,5 +1,5 @@
-import { convertProjectToJSON } from './project.mjs';
-import { parseSessionMeetings } from './meetings.mjs';
+import { convertProjectToJSON } from '../../common/project.mjs';
+import { parseSessionMeetings } from '../../common/meetings.mjs';
import { google } from 'googleapis';
/**
diff --git a/tools/lib/webvtt2html.mjs b/tools/node/lib/webvtt2html.mjs
similarity index 100%
rename from tools/lib/webvtt2html.mjs
rename to tools/node/lib/webvtt2html.mjs
diff --git a/tools/commands/schedule.mjs b/tools/node/schedule.mjs
similarity index 94%
rename from tools/commands/schedule.mjs
rename to tools/node/schedule.mjs
index cac1596..4bff47e 100644
--- a/tools/commands/schedule.mjs
+++ b/tools/node/schedule.mjs
@@ -1,9 +1,9 @@
import { readFile } from 'fs/promises';
-import { convertProjectToHTML } from '../lib/project2html.mjs';
-import { suggestSchedule } from '../lib/schedule.mjs';
-import { saveSessionMeetings } from '../lib/project.mjs';
-import { parseMeetingsChanges, applyMeetingsChanges } from '../lib/meetings.mjs';
-import { validateGrid } from '../lib/validate.mjs';
+import { convertProjectToHTML } from './lib/project2html.mjs';
+import { saveSessionMeetings } from './lib/project.mjs';
+import { parseMeetingsChanges, applyMeetingsChanges } from './lib/meetings.mjs';
+import { validateGrid } from '../common/validate.mjs';
+import { suggestSchedule } from '../common/schedule.mjs';
/**
* Helper function to generate a random seed
diff --git a/tools/commands/sync-calendar.mjs b/tools/node/sync-calendar.mjs
similarity index 96%
rename from tools/commands/sync-calendar.mjs
rename to tools/node/sync-calendar.mjs
index 8b4459e..2b70c08 100644
--- a/tools/commands/sync-calendar.mjs
+++ b/tools/node/sync-calendar.mjs
@@ -1,7 +1,7 @@
import puppeteer from 'puppeteer';
-import { getEnvKey } from '../lib/envkeys.mjs';
-import { synchronizeSessionWithCalendar } from '../lib/calendar.mjs';
-import { validateSession, validateGrid } from '../lib/validate.mjs';
+import { getEnvKey } from '../common/envkeys.mjs';
+import { synchronizeSessionWithCalendar } from './lib/calendar.mjs';
+import { validateSession, validateGrid } from '../common/validate.mjs';
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms, 'slept'));
diff --git a/tools/commands/sync-sheet.mjs b/tools/node/sync-sheet.mjs
similarity index 91%
rename from tools/commands/sync-sheet.mjs
rename to tools/node/sync-sheet.mjs
index 8c70d52..36fa4c3 100644
--- a/tools/commands/sync-sheet.mjs
+++ b/tools/node/sync-sheet.mjs
@@ -1,5 +1,5 @@
-import { getEnvKey } from '../lib/envkeys.mjs';
-import { convertProjectToSheet } from '../lib/project2sheet.mjs';
+import { getEnvKey } from '../common/envkeys.mjs';
+import { convertProjectToSheet } from './lib/project2sheet.mjs';
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms, 'slept'));
diff --git a/tools/commands/try-changes.mjs b/tools/node/try-changes.mjs
similarity index 91%
rename from tools/commands/try-changes.mjs
rename to tools/node/try-changes.mjs
index 9b9cbea..7a19105 100644
--- a/tools/commands/try-changes.mjs
+++ b/tools/node/try-changes.mjs
@@ -1,6 +1,6 @@
-import { validateGrid } from '../lib/validate.mjs';
-import { saveSessionMeetings } from '../lib/project.mjs';
-import { convertProjectToHTML } from '../lib/project2html.mjs';
+import { validateGrid } from '../common/validate.mjs';
+import { saveSessionMeetings } from './lib/project.mjs';
+import { convertProjectToHTML } from './lib/project2html.mjs';
export default async function (project, options) {
if (!project.allowTryMeOut) {
diff --git a/tools/commands/validate.mjs b/tools/node/validate.mjs
similarity index 96%
rename from tools/commands/validate.mjs
rename to tools/node/validate.mjs
index 8f53fea..d697dd5 100644
--- a/tools/commands/validate.mjs
+++ b/tools/node/validate.mjs
@@ -1,7 +1,7 @@
import path from 'path';
-import { updateSessionDescription } from '../lib/session.mjs';
-import { saveSessionValidationResult } from '../lib/project.mjs';
-import { validateSession, validateGrid } from '../lib/validate.mjs';
+import { updateSessionDescription } from '../common/session.mjs';
+import { saveSessionValidationResult } from './lib/project.mjs';
+import { validateSession, validateGrid } from '../common/validate.mjs';
/**
* Helper function to generate a shortname from the session's title
diff --git a/tools/commands/view-event.mjs b/tools/node/view-event.mjs
similarity index 70%
rename from tools/commands/view-event.mjs
rename to tools/node/view-event.mjs
index ab121a1..4f5f578 100644
--- a/tools/commands/view-event.mjs
+++ b/tools/node/view-event.mjs
@@ -1,5 +1,5 @@
-import { convertProjectToHTML } from '../lib/project2html.mjs';
-import { convertProjectToJSON } from '../lib/project.mjs';
+import { convertProjectToHTML } from './lib/project2html.mjs';
+import { convertProjectToJSON } from '../common/project.mjs';
export default async function (project, options) {
if (options.format?.toLowerCase() === 'json') {
diff --git a/tools/commands/view-registrants.mjs b/tools/node/view-registrants.mjs
similarity index 97%
rename from tools/commands/view-registrants.mjs
rename to tools/node/view-registrants.mjs
index f629ec3..9962d7c 100644
--- a/tools/commands/view-registrants.mjs
+++ b/tools/node/view-registrants.mjs
@@ -1,9 +1,9 @@
import puppeteer from 'puppeteer';
-import { validateSession } from '../lib/validate.mjs';
-import { authenticate } from '../lib/calendar.mjs';
-import { getEnvKey } from '../lib/envkeys.mjs';
-import { parseSessionMeetings } from '../lib/meetings.mjs';
-import { saveSessionMeetings } from '../lib/project.mjs';
+import { validateSession } from '../common/validate.mjs';
+import { authenticate } from './lib/calendar.mjs';
+import { getEnvKey } from '../common/envkeys.mjs';
+import { saveSessionMeetings } from './lib/project.mjs';
+import { parseSessionMeetings } from '../common/meetings.mjs';
export default async function (project, number, options) {
const meeting = project.metadata.meeting.toLowerCase().replace(/\s+/g, '');
diff --git a/tools/setup-irc.mjs b/tools/setup-irc.mjs
index 1535307..02dfe97 100644
--- a/tools/setup-irc.mjs
+++ b/tools/setup-irc.mjs
@@ -19,10 +19,10 @@
* responses.
*/
-import { getEnvKey } from './lib/envkeys.mjs';
-import { fetchProject } from './lib/project.mjs'
-import { validateSession } from './lib/validate.mjs';
-import { todoStrings } from './lib/todostrings.mjs';
+import { getEnvKey } from './common/envkeys.mjs';
+import { fetchProject } from './node/lib/project.mjs'
+import { validateSession } from './common/validate.mjs';
+import todoStrings from './common/todostrings.mjs';
import irc from 'irc';
const botName = 'tpac-breakout-bot';
diff --git a/tools/truncate-recording.mjs b/tools/truncate-recording.mjs
index fd18e00..4843fca 100644
--- a/tools/truncate-recording.mjs
+++ b/tools/truncate-recording.mjs
@@ -19,7 +19,7 @@ import path from 'path';
import fs from 'fs/promises';
import util from 'node:util';
import webvtt from 'webvtt-parser';
-import { getEnvKey } from './lib/envkeys.mjs';
+import { getEnvKey } from './common/envkeys.mjs';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));