Skip to content

Commit d7387de

Browse files
committed
finish it
1 parent 043b0ba commit d7387de

File tree

14 files changed

+602
-1
lines changed

14 files changed

+602
-1
lines changed

.gitignore

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
node_modules/
2+
3+
.DS_Store
4+
Thumbs.db
5+
6+
.vscode/
7+
.idea/
8+
*.swp
9+
*.swo
10+
*~
11+
12+
*.log
13+
*.tmp
14+
15+
dist/
16+
build/
17+
18+
__pycache__/
19+
*.pyc
20+
21+
*.zip
22+
*.crx
23+
*.xpi
24+
25+
.direnv/
26+
result
27+
result-*

README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,31 @@
11
# protoncalendar-tasks
2-
✅ A browser extension to add tasks to Proton Calendar.
2+
3+
fulfills [a promise that Proton has ignored for three years](https://proton.me/blog/proton-mail-calendar-roadmap): tasks in the calendar.
4+
5+
## Installation
6+
7+
### Chromium, Edge, Brave
8+
9+
1. Download this repository (green "Code" button → Download ZIP)
10+
2. Unzip the folder somewhere on your computer
11+
3. Open your browser and go to `chrome://extensions/`
12+
4. Turn on "Developer mode" (toggle in top right corner)
13+
5. Click "Load unpacked" button
14+
6. Select the folder you unzipped
15+
16+
### LibreWolf, FireFox
17+
18+
1. Download and unzip (same as above)
19+
2. Open Firefox and go to `about:debugging#/runtime/this-firefox`
20+
3. Click "Load Temporary Add-on..."
21+
4. Navigate to the folder and select `manifest.json`
22+
23+
Note: The extension will be removed when you close Firefox. For permanent installation, Firefox requires extension signing.
24+
25+
## Usage
26+
27+
1. Create an all-day event in Proton Calendar
28+
2. Start the event name with:
29+
- `[ ]` for an unchecked task
30+
- `[x]` for a completed task
31+
4. Click the checkbox to toggle, then quickly click the event again to save

content.js

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
(function() {
2+
'use strict';
3+
4+
const UNCHECKED_PATTERN = /^\[\s*\]\s*/;
5+
const CHECKED_PATTERN = /^\[x\]\s*/i;
6+
const ANY_TASK_PATTERN = /^\[[\sx]\]\s*/i;
7+
8+
// svg checkbox icons
9+
const CHECK_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" style="display:block;">
10+
<rect x="1" y="1" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
11+
<path d="M 4 8 L 7 11 L 12 5" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
12+
</svg>`;
13+
14+
const UNCHECK_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" style="display:block;">
15+
<rect x="1" y="1" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
16+
</svg>`;
17+
18+
function processEventCell(eventCell) {
19+
const titleSpan = eventCell.querySelector('.calendar-dayeventcell-title');
20+
if (!titleSpan) return;
21+
22+
const titleText = titleSpan.textContent.trim();
23+
24+
if (!ANY_TASK_PATTERN.test(titleText)) {
25+
return;
26+
}
27+
28+
const isChecked = CHECKED_PATTERN.test(titleText);
29+
const taskText = titleText.replace(ANY_TASK_PATTERN, '').trim();
30+
31+
// check if already has checkbox
32+
let checkbox = eventCell.querySelector('.proton-task-checkbox');
33+
if (checkbox) {
34+
// update existing checkbox and title (handles reprocessing after save)
35+
titleSpan.textContent = taskText;
36+
checkbox.innerHTML = isChecked ? CHECK_SVG : UNCHECK_SVG;
37+
checkbox.dataset.checked = isChecked ? 'true' : 'false';
38+
if (isChecked) {
39+
titleSpan.style.textDecoration = 'line-through';
40+
titleSpan.style.opacity = '0.6';
41+
} else {
42+
titleSpan.style.textDecoration = 'none';
43+
titleSpan.style.opacity = '1';
44+
}
45+
return;
46+
}
47+
48+
eventCell.dataset.taskProcessed = 'true';
49+
50+
checkbox = document.createElement('span');
51+
checkbox.className = 'proton-task-checkbox';
52+
checkbox.innerHTML = isChecked ? CHECK_SVG : UNCHECK_SVG;
53+
checkbox.dataset.checked = isChecked ? 'true' : 'false';
54+
55+
// remove the [ ] or [x] from the title
56+
titleSpan.textContent = taskText;
57+
if (isChecked) {
58+
titleSpan.style.textDecoration = 'line-through';
59+
titleSpan.style.opacity = '0.6';
60+
}
61+
62+
const container = titleSpan.parentElement;
63+
container.insertBefore(checkbox, titleSpan);
64+
65+
// only checkbox is clickable, prevent event modal from opening
66+
checkbox.addEventListener('click', async (e) => {
67+
e.preventDefault();
68+
e.stopPropagation();
69+
e.stopImmediatePropagation();
70+
71+
const currentChecked = checkbox.dataset.checked === 'true';
72+
const newChecked = !currentChecked;
73+
74+
// update ui immediately
75+
checkbox.innerHTML = newChecked ? CHECK_SVG : UNCHECK_SVG;
76+
checkbox.dataset.checked = newChecked ? 'true' : 'false';
77+
78+
if (newChecked) {
79+
titleSpan.style.textDecoration = 'line-through';
80+
titleSpan.style.opacity = '0.6';
81+
} else {
82+
titleSpan.style.textDecoration = 'none';
83+
titleSpan.style.opacity = '1';
84+
}
85+
86+
// store current state for pending update (use current title text)
87+
const currentTaskText = titleSpan.textContent.trim();
88+
const newTitle = newChecked ? `[x] ${currentTaskText}` : `[ ] ${currentTaskText}`;
89+
eventCell.dataset.pendingTitle = newTitle;
90+
}, true);
91+
}
92+
93+
// intercept clicks to handle auto-save
94+
function setupInterceptor() {
95+
document.addEventListener('click', async (e) => {
96+
const eventCell = e.target.closest('.calendar-dayeventcell');
97+
if (!eventCell || !eventCell.dataset.pendingTitle) return;
98+
99+
const newTitle = eventCell.dataset.pendingTitle;
100+
delete eventCell.dataset.pendingTitle;
101+
102+
// hide modals
103+
const hideStyle = document.createElement('style');
104+
hideStyle.id = 'proton-task-hide-modal';
105+
hideStyle.textContent = '.modal-two, [role="dialog"], .eventpopover { opacity: 0 !important; pointer-events: none !important; }';
106+
document.head.appendChild(hideStyle);
107+
108+
try {
109+
// wait for popover
110+
await new Promise(resolve => setTimeout(resolve, 400));
111+
112+
const editButton = document.querySelector('button[data-testid="event-popover:edit"]');
113+
if (!editButton) return;
114+
115+
editButton.click();
116+
await new Promise(resolve => setTimeout(resolve, 500));
117+
118+
const titleInput = document.querySelector('#event-title-input');
119+
if (!titleInput) return;
120+
121+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
122+
nativeInputValueSetter.call(titleInput, newTitle);
123+
124+
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
125+
titleInput.dispatchEvent(new Event('change', { bubbles: true }));
126+
127+
await new Promise(resolve => setTimeout(resolve, 150));
128+
129+
const saveButton = document.querySelector('button[data-testid="create-event-modal:save"]');
130+
if (!saveButton) return;
131+
132+
saveButton.click();
133+
await new Promise(resolve => setTimeout(resolve, 800));
134+
135+
} finally {
136+
const style = document.getElementById('proton-task-hide-modal');
137+
if (style) style.remove();
138+
139+
setTimeout(() => processAllEvents(), 1500);
140+
}
141+
}, true);
142+
}
143+
144+
function processAllEvents() {
145+
const eventCells = document.querySelectorAll('.calendar-dayeventcell');
146+
eventCells.forEach(processEventCell);
147+
}
148+
149+
function setupObserver() {
150+
const observer = new MutationObserver((mutations) => {
151+
let shouldProcess = false;
152+
153+
for (const mutation of mutations) {
154+
if (mutation.addedNodes.length > 0 ||
155+
mutation.type === 'characterData' ||
156+
mutation.type === 'childList') {
157+
shouldProcess = true;
158+
break;
159+
}
160+
}
161+
162+
if (shouldProcess) {
163+
processAllEvents();
164+
}
165+
});
166+
167+
observer.observe(document.body, {
168+
childList: true,
169+
subtree: true,
170+
characterData: true
171+
});
172+
173+
return observer;
174+
}
175+
176+
function init() {
177+
processAllEvents();
178+
setupObserver();
179+
setupInterceptor();
180+
setInterval(processAllEvents, 2000);
181+
}
182+
183+
if (document.readyState === 'loading') {
184+
document.addEventListener('DOMContentLoaded', init);
185+
} else {
186+
init();
187+
}
188+
})();

flake.lock

Lines changed: 61 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flake.nix

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
description = "protoncalendar-tasks browser extension";
3+
4+
inputs = {
5+
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6+
flake-utils.url = "github:numtide/flake-utils";
7+
};
8+
9+
outputs = { self, nixpkgs, flake-utils }:
10+
flake-utils.lib.eachDefaultSystem (system:
11+
let
12+
pkgs = nixpkgs.legacyPackages.${system};
13+
in
14+
{
15+
devShells.default = pkgs.mkShell {
16+
buildInputs = [
17+
pkgs.imagemagick
18+
];
19+
20+
shellHook = ''
21+
echo "protoncalendar-tasks dev environment"
22+
echo "run: ./scripts/generate_icons.sh"
23+
'';
24+
};
25+
}
26+
);
27+
}
28+

icons/icon.svg

Lines changed: 11 additions & 0 deletions
Loading

icons/icon128.png

5.96 KB
Loading

icons/icon16.png

1.2 KB
Loading

icons/icon48.png

4.61 KB
Loading

manifest.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "Proton Calendar Tasks",
4+
"version": "1.0.0",
5+
"description": "adds tasks/checkboxes to events in Proton Calendar",
6+
"permissions": [
7+
"storage"
8+
],
9+
"host_permissions": [
10+
"https://calendar.proton.me/*"
11+
],
12+
"content_scripts": [
13+
{
14+
"matches": ["https://calendar.proton.me/*"],
15+
"js": ["content.js"],
16+
"css": ["styles.css"],
17+
"run_at": "document_idle"
18+
}
19+
],
20+
"icons": {
21+
"16": "icons/icon16.png",
22+
"48": "icons/icon48.png",
23+
"128": "icons/icon128.png"
24+
},
25+
"action": {
26+
"default_popup": "popup.html",
27+
"default_icon": {
28+
"16": "icons/icon16.png",
29+
"48": "icons/icon48.png",
30+
"128": "icons/icon128.png"
31+
}
32+
}
33+
}
34+

0 commit comments

Comments
 (0)