From 14669d0979cd1cdce2196d1e3951edd0f71a91c8 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Fri, 10 Nov 2023 14:21:35 +0100 Subject: [PATCH 1/5] feat(web-ui): make the UI image focus and YAML aware --- packages/web-ui/package.json | 3 + packages/web-ui/src/App.svelte | 2 +- packages/web-ui/src/FillBallotForm.svelte | 58 ++++++++--- packages/web-ui/src/fetchDataFromGitHub.ts | 111 ++++++--------------- yarn.lock | 12 ++- 5 files changed, 87 insertions(+), 99 deletions(-) diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 0bc12ab..26a6456 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -11,9 +11,12 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "devDependencies": { + "@node-core/caritat": "workspace:^", "@sveltejs/vite-plugin-svelte": "^2.0.1", "@tsconfig/svelte": "^4.0.0", + "@types/js-yaml": "^4", "gh-pages": "^4.0.0", + "js-yaml": "^4.1.0", "svelte": "^4.0.0", "svelte-check": "^3.0.0", "svelte-preprocess": "^5.0.0", diff --git a/packages/web-ui/src/App.svelte b/packages/web-ui/src/App.svelte index 9fbabea..b48b606 100644 --- a/packages/web-ui/src/App.svelte +++ b/packages/web-ui/src/App.svelte @@ -4,7 +4,7 @@ import GitHubCredentials from "./GitHubCredentials.svelte"; import FindPrForm from "./FindPRForm.svelte"; - let encryptDataPromise = new Promise(() => {}) as Promise; + let encryptDataPromise = new Promise(() => {}); let url = globalThis.location?.hash.slice(1); diff --git a/packages/web-ui/src/FillBallotForm.svelte b/packages/web-ui/src/FillBallotForm.svelte index 1ad697c..025e26f 100644 --- a/packages/web-ui/src/FillBallotForm.svelte +++ b/packages/web-ui/src/FillBallotForm.svelte @@ -5,21 +5,43 @@ import uint8ArrayToBase64 from "./uint8ArrayToBase64.ts"; import fetchFromGitHub from "./fetchDataFromGitHub.ts"; + import type { VoteFileFormat } from "@node-core/caritat/parser"; + import { templateBallot } from "@node-core/caritat/parser"; + import { + getSummarizedBallot, + summarizeCondorcetBallotForVoter, + } from "@node-core/caritat/summary/condorcet"; + export let url, username, token, registerEncryptedBallot; - let fetchedBallot: Promise, fetchedPublicKey; + let fetchedVoteConfig: Promise; const textEncoder = typeof TextEncoder === "undefined" ? { encode() {} } : new TextEncoder(); function onSubmit(this: HTMLFormElement, event: SubmitEvent) { event.preventDefault(); - const textarea = this.elements.namedItem("ballot") as HTMLInputElement; registerEncryptedBallot( (async () => { + const voteConfig = await fetchedVoteConfig; + const preferences = new Map( + voteConfig.candidates.map((candidate) => [ + candidate, + Number( + (this.elements.namedItem(candidate) as HTMLInputElement).value + ), + ]) + ); + const ballot = templateBallot(voteConfig, undefined, preferences); + const summary = summarizeCondorcetBallotForVoter( + getSummarizedBallot({ voter: {}, preferences }) + ); + + if (!confirm(summary)) throw new Error("Aborted by user"); + const { encryptedSecret, saltedCiphertext } = await encryptData( - textEncoder.encode(textarea.value) as Uint8Array, - await fetchedPublicKey + textEncoder.encode(ballot) as Uint8Array, + voteConfig.publicKey ); return JSON.stringify({ encryptedSecret: uint8ArrayToBase64(new Uint8Array(encryptedSecret)), @@ -29,26 +51,34 @@ ); } - fetchedBallot = fetchedPublicKey = Promise.reject("no data"); + fetchedVoteConfig = Promise.reject("no data"); beforeUpdate(() => { fetchFromGitHub({ url, username, token }, (errOfResult) => { - [fetchedBallot, fetchedPublicKey] = errOfResult; + fetchedVoteConfig = errOfResult; }); }); Fill in ballot -{#await fetchedBallot} +{#await fetchedVoteConfig}

...loading as {username || "anonymous"}

-{:then ballotPlainText} +{:then voteConfig}
- - {#await fetchedPublicKey} - - {:then} - - {/await} +
    + {#each voteConfig.imageCandidates ?? [] as candidate} +
  • + {candidate.alt} +
  • + {/each} +
+
{:catch error}

diff --git a/packages/web-ui/src/fetchDataFromGitHub.ts b/packages/web-ui/src/fetchDataFromGitHub.ts index 752b7cd..0d91457 100644 --- a/packages/web-ui/src/fetchDataFromGitHub.ts +++ b/packages/web-ui/src/fetchDataFromGitHub.ts @@ -1,5 +1,8 @@ +import * as yaml from "js-yaml"; +import type { VoteFileFormat } from "@node-core/caritat/parser"; +import base64ArrayBuffer from "./uint8ArrayToBase64"; + const githubPRUrlPattern = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/; -const startCandidateList = /\npreferences:[^\n\S]*(#[^\n]*)?\n/; const fetch2JSON = (response: Awaited>) => response.ok @@ -184,97 +187,39 @@ async function act( }, }; try { - const { voteFile, ballotFile, publicKeyFile } = await fetchVoteFilesInfo( - url as string, - fetchOptions - ); - - // This won't catch all the cases (if the PR modifies an existing file - // rather than creating a new one, or if the YAML is formatted differently), - // but it saves us from doing another request so deemed worth it. - const shouldShuffleCandidates = /^\+canShuffleCandidates:\s*true$/m.test( - voteFile.patch + const { voteFile } = await fetchVoteFilesInfo(url as string, fetchOptions); + + const response = await fetch(voteFile.contents_url, contentsFetchOptions); + if (!response.ok) + throw new Error(`Fetch error: ${response.status} ${response.statusText}`); + const ballotData = await response.arrayBuffer(); + const hash = await crypto.subtle.digest("SHA-512", ballotData); + const decoder = new TextDecoder(); + const voteData = yaml.load(decoder.decode(ballotData)) as VoteFileFormat; + voteData.checksum = base64ArrayBuffer(new Uint8Array(hash)); + voteData.imageCandidates = shuffle( + voteData.candidates.map((raw) => { + const imageMatch = /^!\[([^\]]+)\]\(([^)]+)\)$/.exec(raw); + if (imageMatch != null) { + return { + raw, + alt: imageMatch[1], + src: imageMatch[2], + }; + } + }) ); - - return [ - fetch(ballotFile.contents_url, contentsFetchOptions) - .then((response) => - response.ok - ? response.text() - : Promise.reject( - new Error( - `Fetch error: ${response.status} ${response.statusText}` - ) - ) - ) - .then( - shouldShuffleCandidates - ? (ballotData) => { - const match = startCandidateList.exec(ballotData); - if (match == null) { - console.warn( - "Cannot find the list of candidates to shuffle, ignoring..." - ); - return ballotData; - } - const headerEnd = match.index + match[0].length - 1; - - const candidates = []; - let currentCandidate: string; - let lineStart; - let lineEnd = headerEnd; - for (;;) { - lineStart = lineEnd + 1; - lineEnd = ballotData.indexOf("\n", lineStart); - if (lineEnd === -1) { - if (lineStart !== ballotData.length) { - currentCandidate += ballotData.slice(lineStart - 1); - } - break; - } - if ( - ballotData[lineStart] !== " " || - ballotData[lineStart + 1] !== " " - ) - break; - if (ballotData[lineStart + 2] === "-") { - if (currentCandidate) candidates.push(currentCandidate); - currentCandidate = ""; - } - currentCandidate += ballotData.slice(lineStart - 1, lineEnd); - } - if (currentCandidate) candidates.push(currentCandidate); - return ( - ballotData.slice(0, headerEnd) + - shuffle(candidates).join("") + - "\n" + - ballotData.slice(lineStart) - ); - } - : undefined - ), - fetch(publicKeyFile.contents_url, contentsFetchOptions).then((response) => - response.ok - ? response.arrayBuffer() - : Promise.reject( - new Error( - `Fetch error: ${response.status} ${response.statusText}` - ) - ) - ), - ] as [Promise, Promise]; + return voteData; } catch (err) { const error = Promise.reject(err); - return [error, error] as [never, never]; + return error as never; } } let previousURL: string | null; export default function fetchFromGitHub( { url, username, token }: { url: string; username?: string; token?: string }, - callback: ( - errOfResult: [Promise, Promise] - ) => void | Promise + callback: (errOfResult: Promise) => void | Promise ) { const options = username && token diff --git a/yarn.lock b/yarn.lock index 20b69d1..6027283 100644 --- a/yarn.lock +++ b/yarn.lock @@ -340,7 +340,7 @@ __metadata: languageName: unknown linkType: soft -"@node-core/caritat@workspace:*, @node-core/caritat@workspace:packages/core": +"@node-core/caritat@workspace:*, @node-core/caritat@workspace:^, @node-core/caritat@workspace:packages/core": version: 0.0.0-use.local resolution: "@node-core/caritat@workspace:packages/core" dependencies: @@ -454,6 +454,13 @@ __metadata: languageName: node linkType: hard +"@types/js-yaml@npm:^4": + version: 4.0.9 + resolution: "@types/js-yaml@npm:4.0.9" + checksum: 139f705e10a7c50459eeee31d18eec685f8df689a6686eff392466d4accb72ec1ecb8da2d71349945de8663cd0f22f715420b5ea19774190e1feb595827f8439 + languageName: node + linkType: hard + "@types/js-yaml@npm:^4.0.5": version: 4.0.5 resolution: "@types/js-yaml@npm:4.0.5" @@ -3311,9 +3318,12 @@ __metadata: version: 0.0.0-use.local resolution: "web-ui@workspace:packages/web-ui" dependencies: + "@node-core/caritat": "workspace:^" "@sveltejs/vite-plugin-svelte": "npm:^2.0.1" "@tsconfig/svelte": "npm:^4.0.0" + "@types/js-yaml": "npm:^4" gh-pages: "npm:^4.0.0" + js-yaml: "npm:^4.1.0" svelte: "npm:^4.0.0" svelte-check: "npm:^3.0.0" svelte-preprocess: "npm:^5.0.0" From 528063c69535bc93ab1d426f51c00bd17cc94389 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Fri, 10 Nov 2023 21:35:47 +0100 Subject: [PATCH 2/5] fixup! feat(web-ui): make the UI image focus and YAML aware --- packages/web-ui/src/FillBallotForm.svelte | 13 +++++++++---- packages/web-ui/src/fetchDataFromGitHub.ts | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/web-ui/src/FillBallotForm.svelte b/packages/web-ui/src/FillBallotForm.svelte index 025e26f..7df9357 100644 --- a/packages/web-ui/src/FillBallotForm.svelte +++ b/packages/web-ui/src/FillBallotForm.svelte @@ -66,19 +66,24 @@ {:then voteConfig}

    - {#each voteConfig.imageCandidates ?? [] as candidate} + {#each voteConfig.imageCandidates ?? voteConfig.candidates ?? [] as candidate}
  • - {candidate.alt}
  • {/each}
- +
{:catch error}

diff --git a/packages/web-ui/src/fetchDataFromGitHub.ts b/packages/web-ui/src/fetchDataFromGitHub.ts index 0d91457..ba2faf3 100644 --- a/packages/web-ui/src/fetchDataFromGitHub.ts +++ b/packages/web-ui/src/fetchDataFromGitHub.ts @@ -207,6 +207,7 @@ async function act( src: imageMatch[2], }; } + return raw; }) ); return voteData; From 81cc5f192ac2679b94f90972730c489cd2258603 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sat, 11 Nov 2023 16:59:22 +0100 Subject: [PATCH 3/5] UI/UX --- packages/web-ui/src/FillBallotForm.svelte | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/web-ui/src/FillBallotForm.svelte b/packages/web-ui/src/FillBallotForm.svelte index 7df9357..94aae13 100644 --- a/packages/web-ui/src/FillBallotForm.svelte +++ b/packages/web-ui/src/FillBallotForm.svelte @@ -19,6 +19,9 @@ const textEncoder = typeof TextEncoder === "undefined" ? { encode() {} } : new TextEncoder(); + function onClick(this: HTMLImageElement, event: MouseEvent) { + (this.nextElementSibling.lastElementChild as HTMLInputElement).focus(); + } function onSubmit(this: HTMLFormElement, event: SubmitEvent) { event.preventDefault(); registerEncryptedBallot( @@ -71,7 +74,7 @@ {#if typeof candidate === "string"} {candidate} {:else} - {candidate.alt} + {candidate.alt} {/if}

...loading as {username || "anonymous"}

{:then voteConfig} +

+ {voteConfig.headerInstructions} +

    {#each voteConfig.imageCandidates ?? voteConfig.candidates ?? [] as candidate} @@ -83,7 +88,10 @@ {#if typeof candidate === "string"} {candidate} {:else} - {candidate.alt} +
    + {candidate.alt} +
    {candidate.alt}
    +
    {/if}