Skip to content

Commit aa0b7a6

Browse files
committed
Add desktop app with Electron
- Add desktop.html with modern UI, drag-drop support, native file dialogs - Add Electron main.js and preload.js for desktop wrapper - Add package.json with build scripts for Windows, macOS, Linux - Add GitHub Actions workflow for automatic binary releases - Original web version (index.html) unchanged
1 parent ed6406c commit aa0b7a6

File tree

8 files changed

+4946
-0
lines changed

8 files changed

+4946
-0
lines changed

.github/workflows/build.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Build
2+
3+
on: push
4+
5+
jobs:
6+
build:
7+
strategy:
8+
matrix:
9+
os: [ubuntu-latest, windows-latest, macos-latest]
10+
runs-on: ${{ matrix.os }}
11+
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: actions/setup-node@v4
16+
with:
17+
node-version: '20'
18+
cache: 'npm'
19+
20+
- run: npm ci
21+
- run: npm run build
22+
23+
- uses: actions/upload-artifact@v4
24+
with:
25+
name: ${{ matrix.os }}
26+
path: dist/*

.gitignore

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Dependencies
2+
node_modules/
3+
4+
# Build output
5+
dist/
6+
7+
# OS files
8+
.DS_Store
9+
Thumbs.db
10+
11+
# IDE
12+
.idea/
13+
*.swp
14+
*.swo
15+
16+
# Logs
17+
*.log
18+
npm-debug.log*

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,72 @@ The way we have decided to do this is by using a hashing algorithm, specifically
1010

1111
The key advantage of this approach to generating pseudo-anonymised IDs is that with the ID, it is impossible to recreate the original input, and sensitive, data. But if someone has access to the original data, then they can use that to cross reference which patient has which pseudo-anonymised ID, thus providing a failsafe.
1212

13+
## Desktop Application
14+
15+
A standalone desktop application is available for Windows, macOS, and Linux. This allows you to use the tool without a web browser.
16+
17+
### Windows Installation
18+
19+
1. Go to the [Releases](https://github.com/cai4cai/sha1inbrowser/releases) page
20+
2. Download `Pseudo-Anonymised-ID-Generator-Setup-x.x.x.exe`
21+
3. Double-click the downloaded file
22+
4. Follow the installation prompts
23+
5. Launch the app from your Start Menu
24+
25+
### macOS Installation
26+
27+
1. Go to the [Releases](https://github.com/cai4cai/sha1inbrowser/releases) page
28+
2. Download `Pseudo-Anonymised-ID-Generator-x.x.x.dmg`
29+
3. Double-click the downloaded file
30+
4. Drag the app icon to your Applications folder
31+
5. Launch the app from your Applications folder
32+
33+
### Linux Installation (AppImage)
34+
35+
1. Go to the [Releases](https://github.com/cai4cai/sha1inbrowser/releases) page
36+
2. Download `Pseudo-Anonymised-ID-Generator-x.x.x.AppImage`
37+
3. Open a terminal in your Downloads folder
38+
4. Make the file executable:
39+
```bash
40+
chmod +x Pseudo-Anonymised-ID-Generator-x.x.x.AppImage
41+
```
42+
5. Run the application:
43+
```bash
44+
./Pseudo-Anonymised-ID-Generator-x.x.x.AppImage
45+
```
46+
47+
### Linux Installation (Debian/Ubuntu)
48+
49+
1. Go to the [Releases](https://github.com/cai4cai/sha1inbrowser/releases) page
50+
2. Download `pseudo-anonymised-id-generator_x.x.x_amd64.deb`
51+
3. Open a terminal in your Downloads folder
52+
4. Install the package:
53+
```bash
54+
sudo dpkg -i pseudo-anonymised-id-generator_x.x.x_amd64.deb
55+
```
56+
5. Launch the app from your applications menu
57+
58+
### Building from Source
59+
60+
If you prefer to build the desktop app yourself:
61+
62+
1. Make sure you have [Node.js](https://nodejs.org/) installed
63+
2. Open a terminal and run the following commands:
64+
```bash
65+
git clone https://github.com/cai4cai/sha1inbrowser.git
66+
cd sha1inbrowser
67+
npm install
68+
```
69+
3. To run in development mode:
70+
```bash
71+
npm start
72+
```
73+
4. To build an installer for your current platform:
74+
```bash
75+
npm run build
76+
```
77+
5. Find the built installer in the `dist/` folder
78+
1379
## How to Use the Application
1480

1581
### Step 0: Preliminary Data Check

desktop.html

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src-elem 'self' 'unsafe-inline'; script-src 'self' https://cdnjs.cloudflare.com 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:">
6+
<title>Pseudo-Anonymised ID Generator</title>
7+
<link rel="icon" type="image/x-icon" href="favicon.png">
8+
<style>
9+
* { box-sizing: border-box; }
10+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #fff; min-height: 100vh; color: #000; line-height: 1.5; padding: 40px 20px; margin: 0; }
11+
.container { max-width: 800px; margin: 0 auto; }
12+
#privacyNotice { color: #c00; font-weight: 600; text-align: center; margin-bottom: 30px; font-size: 14px; }
13+
h1 { font-size: 24px; font-weight: 600; margin-bottom: 30px; text-align: center; }
14+
h2 { font-size: 16px; font-weight: 600; margin-bottom: 15px; border-bottom: 1px solid #000; padding-bottom: 8px; }
15+
.card { margin-bottom: 30px; }
16+
.upload-area { border: 1px dashed #999; padding: 30px; text-align: center; cursor: pointer; }
17+
.upload-area:hover { border-color: #000; }
18+
.upload-text { font-size: 14px; color: #666; }
19+
.file-name { font-weight: 500; margin-top: 10px; }
20+
.btn { background: #000; color: #fff; border: none; padding: 10px 24px; font-size: 14px; cursor: pointer; }
21+
.btn:hover { background: #333; }
22+
.btn:disabled { background: #ccc; cursor: not-allowed; }
23+
.btn-process { width: 100%; margin-top: 20px; padding: 12px; }
24+
.column-options { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 8px; }
25+
.column-option { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
26+
.column-option label { font-size: 14px; }
27+
select { background: #fff; border: 1px solid #000; padding: 6px 10px; font-size: 14px; cursor: pointer; }
28+
select:focus { outline: none; }
29+
table { width: 100%; border-collapse: collapse; margin-top: 15px; font-size: 13px; }
30+
th, td { padding: 8px 12px; text-align: left; border: 1px solid #ddd; }
31+
th { background: #f5f5f5; font-weight: 600; }
32+
.preview-label { color: #666; font-size: 13px; }
33+
.table-container { overflow-x: auto; }
34+
#hashInputContainer { display: flex; flex-direction: column; gap: 10px; }
35+
.hash-input-row { display: flex; gap: 10px; }
36+
input[type="text"] { flex: 1; border: 1px solid #000; padding: 10px; font-size: 14px; }
37+
input[type="text"]:focus { outline: none; }
38+
#hashOutput { font-family: monospace; background: #f5f5f5; padding: 10px; font-size: 13px; min-height: 38px; }
39+
.loading-indicator { text-align: center; padding: 20px; color: #666; display: none; }
40+
footer { text-align: center; padding: 30px 0; color: #666; font-size: 13px; border-top: 1px solid #eee; margin-top: 30px; }
41+
footer a { color: #000; text-decoration: none; }
42+
footer a:hover { text-decoration: underline; }
43+
input[type="file"] { display: none; }
44+
#options, #preview { display: none; }
45+
</style>
46+
</head>
47+
<body>
48+
<div class="container">
49+
<div id="privacyNotice">All processing is done locally. No data leaves your device.</div>
50+
<h1>Pseudo-Anonymised ID Generator</h1>
51+
<div class="card">
52+
<form id="myForm">
53+
<div class="upload-area" id="uploadArea">
54+
<div class="upload-text">Drop CSV file here or click to browse</div>
55+
<input type="file" id="csvFile" accept=".csv">
56+
<div class="file-name" id="fileName"></div>
57+
</div>
58+
<div id="loading" class="loading-indicator">Loading...</div>
59+
<div id="hashing" class="loading-indicator">Processing...</div>
60+
<button type="submit" class="btn btn-process" id="processBtn" disabled>Process and Download</button>
61+
</form>
62+
</div>
63+
<div id="options" class="card">
64+
<h2>Column Options</h2>
65+
<div id="columnBoxes" class="column-options"></div>
66+
</div>
67+
<div id="preview" class="card">
68+
<h2>Data Preview</h2>
69+
<p id="previewLbl" class="preview-label">Showing 0 of 0 rows</p>
70+
<div class="table-container">
71+
<table id="previewTable"><thead><tr id="previewHeadRow"></tr></thead><tbody id="previewTableBody"></tbody></table>
72+
</div>
73+
</div>
74+
<div class="card" id="hashInputContainer">
75+
<h2>Quick Hash Tool</h2>
76+
<div class="hash-input-row">
77+
<input type="text" id="hashInput" placeholder="Enter text to hash...">
78+
<button class="btn" id="hashBtn">Hash</button>
79+
</div>
80+
<div id="hashOutput"></div>
81+
</div>
82+
<footer>
83+
<a href="https://github.com/cai4cai/sha1inbrowser/blob/main/README.md">Instructions</a> &middot;
84+
<a href="https://cai4cai.ml/terms/">Terms</a> &middot;
85+
<a href="https://cai4cai.ml/privacy/">Privacy</a> &middot;
86+
<a href="https://github.com/cai4cai/sha1inbrowser">GitHub</a>
87+
</footer>
88+
</div>
89+
90+
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js" integrity="sha512-vc58qvvBdrDR4etbxMdlTt4GBQk1qjvyORR2nrsPsFPyrs+/u5c3+1Ct6upOgdZoIl7eq6k3a1UPDSNAQi/32A==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
91+
<script>
92+
async function sha1(str) {
93+
const enc = new TextEncoder();
94+
const hash = await crypto.subtle.digest('SHA-1', enc.encode(str));
95+
return Array.from(new Uint8Array(hash)).map(v => v.toString(16).padStart(2, '0')).join('').slice(0, 10);
96+
}
97+
98+
const csvFile = document.getElementById("csvFile");
99+
const table = document.getElementById("previewTable");
100+
const uploadArea = document.getElementById("uploadArea");
101+
const processBtn = document.getElementById("processBtn");
102+
const fileNameDisplay = document.getElementById("fileName");
103+
const hashColour = "#e0e0e0", excludeColour = "#999", stripeWidth = 6;
104+
105+
document.getElementById("hashBtn").onclick = async () => {
106+
document.getElementById("hashOutput").innerText = "Hash: " + await sha1(document.getElementById("hashInput").value);
107+
};
108+
109+
uploadArea.onclick = () => csvFile.click();
110+
uploadArea.ondragover = e => { e.preventDefault(); uploadArea.style.borderColor = "#000"; };
111+
uploadArea.ondragleave = () => uploadArea.style.borderColor = "#999";
112+
uploadArea.ondrop = e => {
113+
e.preventDefault();
114+
uploadArea.style.borderColor = "#999";
115+
if (e.dataTransfer.files[0]?.name.endsWith('.csv')) {
116+
csvFile.files = e.dataTransfer.files;
117+
csvFile.dispatchEvent(new Event('change'));
118+
}
119+
};
120+
121+
document.getElementById("myForm").onsubmit = async e => {
122+
e.preventDefault();
123+
document.getElementById("hashing").style.display = "block";
124+
const text = await csvFile.files[0].text();
125+
const data = d3.csvParse(text), dataWithHash = d3.csvParse(text);
126+
const toHash = [], toHashAndExclude = [], toExclude = [];
127+
128+
data.columns.forEach(col => {
129+
const val = document.getElementById("select_" + col).value;
130+
if (val === "Hash") toHash.push(col);
131+
else if (val === "Hash and exclude") toHashAndExclude.push(col);
132+
else if (val === "Exclude") toExclude.push(col);
133+
});
134+
135+
for (let i = 0; i < data.length; i++) {
136+
for (const col of toHash) { const h = await sha1(data[i][col]); data[i][col + "_hash"] = h; dataWithHash[i][col + "_hash"] = h; }
137+
for (const col of toHashAndExclude) { const h = await sha1(data[i][col]); data[i][col + "_hash"] = h; dataWithHash[i][col + "_hash"] = h; }
138+
toExclude.concat(toHashAndExclude).forEach(col => delete data[i][col]);
139+
}
140+
141+
await window.electronAPI.saveFile(d3.csvFormat(data), "unidentifiable.csv");
142+
await window.electronAPI.saveFile(d3.csvFormat(dataWithHash), "original_with_hash.csv");
143+
document.getElementById("hashing").style.display = "none";
144+
};
145+
146+
csvFile.onchange = async () => {
147+
const input = csvFile.files[0];
148+
table.tBodies[0].innerHTML = "";
149+
document.getElementById("previewHeadRow").innerHTML = "";
150+
document.getElementById("columnBoxes").innerHTML = "";
151+
152+
if (!input) {
153+
document.getElementById("options").style.display = "none";
154+
document.getElementById("preview").style.display = "none";
155+
processBtn.disabled = true;
156+
fileNameDisplay.textContent = "";
157+
return;
158+
}
159+
160+
fileNameDisplay.textContent = input.name;
161+
document.getElementById("loading").style.display = "block";
162+
163+
const data = d3.csvParse(await input.text());
164+
const header = document.getElementById("previewHeadRow");
165+
data.columns.forEach((col, i) => { const c = header.insertCell(i); c.id = "cell_" + col + "_0"; c.innerHTML = col; });
166+
167+
const toShow = Math.min(data.length, 10);
168+
for (let i = 0; i < toShow; i++) {
169+
const row = table.tBodies[0].insertRow(i);
170+
data.columns.forEach((col, j) => {
171+
let content = data[i][col];
172+
if (content.length > 25) content = content.slice(0, 22).trim() + "...";
173+
const c = row.insertCell(j); c.id = "cell_" + col + "_" + (i + 1); c.innerHTML = content;
174+
});
175+
}
176+
177+
const parent = document.getElementById("columnBoxes");
178+
data.columns.forEach(name => {
179+
const wrapper = document.createElement("div"); wrapper.className = "column-option";
180+
const label = document.createElement("label"); label.textContent = name;
181+
const select = document.createElement("select"); select.id = "select_" + name;
182+
["Keep", "Exclude", "Hash", "Hash and exclude"].forEach(opt => { const o = document.createElement("option"); o.value = o.text = opt; select.appendChild(o); });
183+
select.onchange = () => {
184+
const val = select.value.toLowerCase(), toHash = val.includes("hash"), toExclude = val.includes("exclude");
185+
for (let i = 0; i < table.rows.length; i++) {
186+
const cell = document.getElementById("cell_" + name + "_" + i);
187+
cell.style.backgroundImage = `repeating-linear-gradient(58deg, ${toHash ? hashColour : "transparent"}, ${toHash ? hashColour : "transparent"} ${stripeWidth}px, ${toExclude ? excludeColour : "transparent"} ${stripeWidth}px, ${toExclude ? excludeColour : "transparent"} ${stripeWidth * 2}px)`;
188+
}
189+
};
190+
wrapper.append(label, select); parent.appendChild(wrapper);
191+
});
192+
193+
document.getElementById("previewLbl").innerHTML = `Showing ${toShow} of ${data.length} rows`;
194+
document.getElementById("options").style.display = "block";
195+
document.getElementById("preview").style.display = "block";
196+
document.getElementById("loading").style.display = "none";
197+
processBtn.disabled = false;
198+
};
199+
</script>
200+
</body>
201+
</html>

main.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
const { app, BrowserWindow, dialog, ipcMain } = require('electron');
2+
const path = require('path');
3+
const fs = require('fs');
4+
5+
let mainWindow;
6+
7+
function createWindow() {
8+
mainWindow = new BrowserWindow({
9+
width: 1024,
10+
height: 768,
11+
webPreferences: {
12+
nodeIntegration: false,
13+
contextIsolation: true,
14+
preload: path.join(__dirname, 'preload.js')
15+
},
16+
icon: path.join(__dirname, 'favicon.png')
17+
});
18+
19+
mainWindow.loadFile('desktop.html');
20+
21+
// Remove menu bar for cleaner look (optional)
22+
mainWindow.setMenuBarVisibility(false);
23+
}
24+
25+
// Handle file save requests from renderer
26+
ipcMain.handle('save-file', async (event, { content, filename }) => {
27+
const result = await dialog.showSaveDialog(mainWindow, {
28+
defaultPath: filename,
29+
filters: [{ name: 'CSV Files', extensions: ['csv'] }]
30+
});
31+
32+
if (!result.canceled && result.filePath) {
33+
try {
34+
fs.writeFileSync(result.filePath, content, 'utf-8');
35+
return { success: true, path: result.filePath };
36+
} catch (err) {
37+
return { success: false, error: err.message };
38+
}
39+
}
40+
return { success: false };
41+
});
42+
43+
app.whenReady().then(() => {
44+
createWindow();
45+
46+
app.on('activate', () => {
47+
if (BrowserWindow.getAllWindows().length === 0) {
48+
createWindow();
49+
}
50+
});
51+
});
52+
53+
app.on('window-all-closed', () => {
54+
if (process.platform !== 'darwin') {
55+
app.quit();
56+
}
57+
});

0 commit comments

Comments
 (0)