Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: favicon, drag-drop import, and architecture ADRs' (#50) from feat/favicon-and-dragdrop into main

scott 5328849c 68f98685

+1135
+116
PRODUCT.md
··· 894 894 **Week 3 (Formula Power):** #112 (color-coded refs), #91 (array spill) -- transform the formula editing experience and unlock the next wave of functions. 895 895 896 896 **Week 4 (Platform):** #52 (command palette), #21 (toolbar cleanup) -- polish the interaction layer that every user touches. 897 + 898 + --- 899 + 900 + ## 11. Gap Analysis: Daily Driver Readiness 901 + 902 + What would need to be true to use Tools as the ONLY docs+sheets tool, replacing Google Docs and Google Sheets entirely? This section identifies the gaps honestly. 903 + 904 + ### Data Loss Prevention 905 + 906 + **Current state:** Good, with caveats. 907 + 908 + - Snapshots are saved on a 500ms debounce after every edit, plus every 10 seconds via periodic timer, plus on `beforeunload` (tab close) and `visibilitychange` (tab switch). 909 + - The Yjs CRDT document is the source of truth. If the WebSocket disconnects, local edits are preserved in the Yjs Doc in memory. They sync when the connection resumes. 910 + - Version history keeps up to 50 snapshots per document on the server. 911 + 912 + **Gaps:** 913 + - **Browser crash during edit.** If the browser process crashes (not a clean tab close), `beforeunload` does not fire. The last saved snapshot on the server may be up to 10 seconds old. Edits between the last save and the crash are lost. Mitigation: IndexedDB persistence (#84) would save the Yjs state to disk on every change, surviving a crash. 914 + - **localStorage is not durable.** Encryption keys stored in localStorage can be lost if the user clears browser data. There is no key backup mechanism. If the key is lost and the user does not have the URL saved, the document is permanently unrecoverable. 915 + - **No undo across sessions.** The Yjs UndoManager tracks undo history in memory. If the user closes the tab and reopens, undo history is gone. Only version history provides cross-session recovery. 916 + 917 + **Verdict:** Adequate for normal use. Not crash-proof. IndexedDB persistence (#84) is the key improvement. 918 + 919 + ### Reliability 920 + 921 + **Current state:** Reasonable for a self-hosted tool. 922 + 923 + - WebSocket reconnects with 2-4 second randomized backoff on disconnect. 924 + - When reconnecting, the client sends its state vector and the peer (or snapshot) provides missing updates. No data is lost during disconnection. 925 + - Single-client scenario (no peers) works fully offline after initial load. The "synced" flag is set immediately if no peers are detected. 926 + 927 + **Gaps:** 928 + - **Server restart loses WebSocket rooms.** If the server restarts, all WebSocket connections drop. Clients reconnect and re-sync from snapshots, but there is a brief interruption. In-flight encrypted messages that were not snapshot-persisted are lost (up to 500ms of edits). 929 + - **No health monitoring.** The `/health` endpoint exists but there is no alerting, no uptime monitoring, no automatic restart on crash. This is a deployment concern, not an application concern, but it matters for daily-driver reliability. 930 + - **Snapshot conflicts.** If two clients save snapshots simultaneously, the last write wins. Yjs state merges correctly on load, but there is a theoretical window where a client loads a snapshot that is missing another client's most recent changes. Those changes are recovered on the next WebSocket sync, but the user might see a brief "time travel" if they reload during this window. 931 + 932 + **Verdict:** Reliable for 1-3 concurrent users on a stable network. Not yet battle-tested for high-concurrency or unreliable networks. 933 + 934 + ### Performance 935 + 936 + **Current state:** Good for typical use, with known scaling limits. 937 + 938 + - The sheets grid renders all cells on every `renderGrid()` call (100 rows x 26 cols = 2,600 cells). This is fast enough for default grids but would degrade with 1,000+ rows. 939 + - The virtual scrolling module exists but is not wired up (#146). Activating it would allow 10,000+ row sheets. 940 + - Formula evaluation caches results by formula text. Dependent formulas are evaluated recursively. There is no topological sort (though `RecalcEngine` appears to have been added for some formulas), and circular references crash the browser (#145). 941 + - `updateSelectionVisuals()` queries the DOM for each cell in the selection range. A 50-column selection triggers 50+ `querySelector` calls. Noticeable lag at large selections. 942 + - TipTap/ProseMirror handles document editing performance well for documents up to ~100 pages. Very long documents (500+ pages) may become sluggish due to ProseMirror's document traversal. 943 + 944 + **Gaps:** 945 + - **No lazy loading for the landing page.** If a user has 500+ documents, the landing page fetches all of them, decrypts all names, and renders the full list. Pagination or virtual list rendering would help. 946 + - **Chart rendering blocks the main thread.** Chart.js renders synchronously on a canvas. Large datasets in charts can cause a visible frame drop. 947 + - **Full grid re-render on Yjs change.** Any Yjs update (from any peer) triggers `scheduleRenderGrid()` which does a `requestAnimationFrame(renderGrid)`. This rebuilds the entire grid HTML. A targeted cell update (only re-render changed cells) would be more efficient. 948 + 949 + **Verdict:** Fine for sheets up to ~500 rows and docs up to ~50 pages. Wire up virtual scrolling (#146) and fix the circular reference crash (#145) to unlock larger workloads. 950 + 951 + ### Missing Basics (Compared to Google Sheets/Docs) 952 + 953 + These are things a Google Sheets/Docs user would notice within the first 30 minutes: 954 + 955 + | Feature | Google Has It | Tools Status | 956 + |---------|:---:|---| 957 + | Insert row/column in the middle | Yes | No -- can only append (#113) | 958 + | Paste from clipboard | Yes | Broken (#144 -- crashes) | 959 + | Drag-and-drop images | Yes | No -- URL-only (#81) | 960 + | Find and replace in sheets | Yes | No (only in docs) | 961 + | Conditional formatting presets (color scales, data bars) | Yes | No -- rules only (#120) | 962 + | Cell comments/notes with author | Yes | Partial -- notes exist but no author tracking | 963 + | Sparklines in cells | Yes | No (#87) | 964 + | IMPORTRANGE / cross-document data | Yes | No (#72) | 965 + | Print dialog with options | Yes | Docs only -- sheets print is basic (#115) | 966 + | Download as CSV | Yes | Exists (implemented) | 967 + | Download as .xlsx | Yes | No (#109) | 968 + | Revision history diff view | Yes | No -- versions exist but no visual diff (#49) | 969 + | Mobile app / PWA | Yes | No PWA yet (#54, #83, #84) | 970 + | Multiple undo levels with history | Yes | Yjs UndoManager works but no visual undo history | 971 + | Data validation with custom error messages | Yes | Partial -- validation exists, messages are generic | 972 + | Hyperlinks in cells | Yes | No -- cells are plain text | 973 + 974 + ### Trust Assessment 975 + 976 + **Would I trust Tools with tax documents?** 977 + 978 + Yes, with caveats: 979 + - **Encryption is sound.** AES-256-GCM with random IVs via the Web Crypto API is industry-standard. The key-in-fragment approach is clever and verifiable. 980 + - **Server is zero-knowledge.** Auditable by reading server.js (322 lines). The server never calls decrypt, never stores keys, never parses document content. 981 + - **Self-hosted eliminates third-party trust.** Running your own instance means no one else touches your data. 982 + 983 + But: 984 + - **Key management is fragile.** One cleared localStorage away from permanent data loss. No backup mechanism, no recovery phrase, no key escrow. 985 + - **No audit log.** Cannot prove who accessed the document or when. 986 + - **Server-served JavaScript.** If someone compromises the server, they could serve modified JS that exfiltrates keys. Self-hosting mitigates this, but most users will trust the deployed instance. 987 + 988 + **Verdict:** Trustworthy for privacy-sensitive documents. Not yet trustworthy for "only copy of critical financial records" without an external backup strategy. 989 + 990 + ### Summary: What's Needed for Daily Driver 991 + 992 + **Must-fix (blocking daily use):** 993 + 1. Fix paste crash (#144) -- basic operations must not crash 994 + 2. Fix circular reference crash (#145) -- formulas must not freeze the browser 995 + 3. Insert row/column in the middle (#113) -- fundamental spreadsheet operation 996 + 4. Wire up virtual scrolling (#146) -- needed for any real-world sheet 997 + 998 + **High-value improvements:** 999 + 5. IndexedDB persistence (#84) -- crash recovery and true offline 1000 + 6. Command palette (#52) -- fast navigation between documents 1001 + 7. Row/column context menu actions (#149) -- right-click must work 1002 + 8. Image drag-and-drop (#81) -- basic docs expectation 1003 + 9. .xlsx export (#109) -- interoperability with the outside world 1004 + 10. Key backup/export mechanism -- prevent catastrophic key loss 1005 + 1006 + **Nice-to-have for parity:** 1007 + 11. Find and replace in sheets 1008 + 12. Revision history diff view (#49) 1009 + 13. PWA with offline support (#83, #84) 1010 + 14. Hyperlinks in sheet cells 1011 + 1012 + The honest assessment: Tools is approximately **70% of the way to daily-driver status** for a technical user who values privacy. The remaining 30% is dominated by missing basics (#113, #144, #145), data durability (IndexedDB, key backup), and interoperability (.xlsx export, PWA).
+156
docs/adr/005-plugin-extension-system.md
··· 1 + # ADR 005: Plugin/Extension System Design 2 + 3 + ## Status 4 + 5 + Proposed (not yet implemented) 6 + 7 + ## Context 8 + 9 + Users want to extend Tools with custom behavior: additional formula functions, custom cell renderers (progress bars, star ratings), custom slash commands, and themes. We need an extension system that: 10 + 11 + 1. **Preserves E2EE.** Plugin code runs entirely client-side. No plugin data or logic ever executes on the server. The server remains a zero-knowledge relay. 12 + 2. **Is safe by default.** A malicious plugin should not be able to exfiltrate encryption keys, document content, or user data without explicit user consent. 13 + 3. **Is simple for authors.** Writing a formula function or cell renderer should take 10 minutes, not 10 hours. 14 + 4. **Does not require server changes.** Plugins are loaded and executed by the browser. The server does not need a plugin registry, execution environment, or API. 15 + 16 + ### Threat Model for Plugins 17 + 18 + The critical risk: a plugin with access to the main page's JavaScript context can read `localStorage` (which contains encryption keys), access the Yjs document (which contains plaintext content), and exfiltrate data via `fetch`. This is the same risk as browser extensions, but we should minimize it. 19 + 20 + ## Decision 21 + 22 + A three-tier plugin system, each tier with different capabilities and trust levels. 23 + 24 + ### Tier 1: Formula Functions (Trusted, Inline) 25 + 26 + **Capability:** Register custom spreadsheet functions callable from formulas. 27 + 28 + **Trust level:** Full access to cell values (necessary for computation). No access to DOM, network, or encryption keys. 29 + 30 + **Mechanism:** The user writes a function definition and registers it: 31 + 32 + ```javascript 33 + // Example: FIBONACCI(n) 34 + tools.formulas.register('FIBONACCI', { 35 + signature: 'FIBONACCI(n)', 36 + description: 'Compute the nth Fibonacci number', 37 + evaluate: (args) => { 38 + const n = Math.round(args[0]); 39 + if (n <= 0) return 0; 40 + let a = 0, b = 1; 41 + for (let i = 2; i <= n; i++) [a, b] = [b, a + b]; 42 + return b; 43 + }, 44 + }); 45 + ``` 46 + 47 + **Implementation:** 48 + - `tools.formulas.register(name, { signature, description, evaluate })` adds to the formula function library. 49 + - The `evaluate` function receives a flat args array (same as built-in functions in `callFunction()`). 50 + - Custom functions appear in the autocomplete dropdown with their signatures. 51 + - Functions are stored as JavaScript text in a `plugins` key in localStorage. They are `eval()`-ed on page load. 52 + 53 + **Security:** Tier 1 functions run in the main context. They CAN access the DOM and network. This is acceptable because: 54 + - The user explicitly wrote or pasted the code. 55 + - The code only runs in the user's own browser. 56 + - It is equivalent to pasting code into the browser console. 57 + - A future sandboxed version (Tier 2) can provide isolation for untrusted plugins. 58 + 59 + ### Tier 2: Custom Cell Renderers and Slash Commands (Sandboxed) 60 + 61 + **Capability:** Render custom cell content (progress bar, rating stars, color chips, sparklines) and add custom slash commands to the docs editor. 62 + 63 + **Trust level:** Receives cell values or editor context. Cannot access encryption keys, localStorage, or network without explicit permission. 64 + 65 + **Mechanism:** Plugins run inside a sandboxed `<iframe>` with `sandbox="allow-scripts"` (no `allow-same-origin`). Communication is via `postMessage`. 66 + 67 + ```javascript 68 + // Host sends cell data to the iframe: 69 + pluginFrame.contentWindow.postMessage({ 70 + type: 'render-cell', 71 + value: 75, 72 + format: 'percent', 73 + width: 96, 74 + height: 26, 75 + }, '*'); 76 + 77 + // Plugin iframe renders and sends back HTML or canvas data: 78 + window.addEventListener('message', (e) => { 79 + if (e.data.type === 'render-cell') { 80 + const pct = e.data.value; 81 + // Render a progress bar 82 + const html = `<div style="background:#e0e0e0;height:100%"> 83 + <div style="background:#5ea3e0;width:${pct}%;height:100%"></div> 84 + </div>`; 85 + parent.postMessage({ type: 'rendered', html }, '*'); 86 + } 87 + }); 88 + ``` 89 + 90 + **Implementation:** 91 + - Each plugin is an HTML file loaded in a sandboxed iframe. 92 + - The host provides a `postMessage` API: `render-cell`, `execute-command`, `get-selection`. 93 + - The plugin responds with rendered HTML (sanitized by the host before insertion) or action descriptors. 94 + - Plugins cannot access `document.cookie`, `localStorage`, `fetch` to the host origin, or the parent frame's DOM. 95 + 96 + **Distribution:** Plugin HTML files are loaded from: 97 + - A local `plugins/` directory served by the Tools server. 98 + - A URL pasted by the user (loaded in a sandboxed iframe). 99 + 100 + ### Tier 3: Themes and Skins (CSS Only) 101 + 102 + **Capability:** Override CSS custom properties and add style rules. 103 + 104 + **Trust level:** No JavaScript execution. CSS only. 105 + 106 + **Mechanism:** The user provides a CSS file (or CSS text) that is injected as a `<style>` tag. Themes override the `:root` custom properties: 107 + 108 + ```css 109 + /* Custom theme: Nord */ 110 + :root { 111 + --color-bg: #2e3440; 112 + --color-surface: #3b4252; 113 + --color-text: #eceff4; 114 + --color-accent: #88c0d0; 115 + /* ... */ 116 + } 117 + ``` 118 + 119 + **Implementation:** 120 + - Themes are stored in localStorage as CSS text. 121 + - On page load, the stored CSS is injected into a `<style id="user-theme">` element. 122 + - A theme picker in settings allows selecting from built-in themes or pasting custom CSS. 123 + - No JavaScript in themes. The `<style>` tag is injected with `textContent` (not `innerHTML`) to prevent injection. 124 + 125 + **Distribution:** Themes are shared as CSS files or text snippets. No registry needed. 126 + 127 + ### Plugin Discovery and Distribution 128 + 129 + For the initial implementation, plugins are local to each user's browser (stored in localStorage). There is no central plugin registry. Distribution methods: 130 + 131 + 1. **Copy-paste:** Share a code snippet (formula function) or URL (iframe plugin) via any channel. 132 + 2. **Git repositories:** A plugin author publishes a repo with the plugin file(s). The user downloads and loads them. 133 + 3. **Future: Plugin manifest.** A JSON file describing the plugin (name, version, type, entry point) that can be loaded by URL. No centralized registry -- just a URL. 134 + 135 + A centralized plugin registry is explicitly out of scope. It would require trust infrastructure (code review, signing, hosting) that conflicts with the self-hosted, zero-trust philosophy. 136 + 137 + ## Consequences 138 + 139 + **Positive:** 140 + - Formula extensions are trivially simple (a single function definition). 141 + - Sandboxed iframe plugins cannot access encryption keys or document content beyond what the host explicitly sends them. 142 + - CSS-only themes eliminate all JavaScript risk. 143 + - No server changes needed. Plugins are entirely client-side. 144 + - The tier system lets users choose their comfort level: Tier 1 (full trust, maximum power), Tier 2 (sandboxed, limited), Tier 3 (CSS only, zero risk). 145 + 146 + **Negative:** 147 + - Tier 1 plugins (formula functions) have full main-context access. A careless user who pastes malicious code could have their keys exfiltrated. Mitigation: clear warnings in the plugin UI, and a future migration path to Web Worker sandboxing. 148 + - Sandboxed iframe plugins add latency (postMessage round-trip per render). For cell renderers called on every visible cell, this could be slow. Mitigation: batch rendering messages, cache rendered output. 149 + - No automatic updates for plugins. Users must manually update. 150 + - Plugin state is per-browser (localStorage). A plugin installed on one device is not available on another. Future mitigation: store plugin definitions in the Yjs document (encrypted, synced). 151 + 152 + **E2EE Interaction:** 153 + - Plugins run client-side, after decryption. They operate on plaintext data, same as the built-in code. 154 + - Tier 2 plugins receive only the data the host sends them (individual cell values, not entire documents). 155 + - No plugin data is ever sent to the server. The server does not know plugins exist. 156 + - If a plugin needs external data (e.g., a stock price lookup), the user must explicitly grant network access. The sandboxed iframe cannot make network requests by default.
+225
docs/adr/006-forms-architecture.md
··· 1 + # ADR 006: Forms Architecture 2 + 3 + ## Status 4 + 5 + Proposed (not yet implemented) 6 + 7 + ## Context 8 + 9 + Tools needs a form builder that allows users to collect structured data (RSVPs, feedback, surveys, intake forms) with responses flowing into an encrypted Sheet. The key challenge: how does a form respondent encrypt their response if they do not have the document key? 10 + 11 + ### E2EE Challenge 12 + 13 + In the current model, everyone who accesses a document shares the same AES key via the URL fragment. For forms, this is problematic: 14 + 15 + - The form creator has the key. 16 + - Form respondents should be able to submit data without seeing other responses. 17 + - If respondents get the full Sheet key, they can read all other responses. 18 + 19 + Three approaches considered: 20 + 21 + 1. **Same key for form and sheet.** Respondents get the key. Simple, but they can see other responses. Acceptable for internal/trusted forms (team feedback), not for public forms. 22 + 2. **Write-only key pair.** Use asymmetric encryption: the form creator generates a key pair, publishes the public key in the form URL, and respondents encrypt with the public key. Only the creator (with the private key) can decrypt responses. Web Crypto API supports RSA-OAEP and ECDH. 23 + 3. **Per-response symmetric key.** Each response gets its own AES key. The respondent encrypts their response and sends both the encrypted blob and the key, encrypted with the form creator's public key. This is hybrid encryption (asymmetric envelope, symmetric payload). 24 + 25 + ## Decision 26 + 27 + Implement **approach 3: hybrid encryption** for forms. This provides the strongest security guarantees while keeping the system practical. 28 + 29 + ### Encryption Flow 30 + 31 + **Form creation:** 32 + 1. Creator clicks "New Form" (linked to a Sheet). 33 + 2. Client generates an RSA-OAEP key pair (2048-bit) or ECDH key pair (P-256). 34 + 3. The public key is stored on the server alongside the form metadata (NOT encrypted -- it is public by definition). 35 + 4. The private key is stored in the URL fragment of the creator's form management link, similar to how document AES keys work. 36 + 5. The form's shareable URL includes the form ID and the public key fingerprint (for verification). 37 + 38 + **Response submission:** 39 + 1. Respondent opens the form URL (`/forms/{formId}`). 40 + 2. Client fetches the form definition (question structure) and the public key from the server. 41 + 3. Respondent fills out the form. 42 + 4. Client generates a one-time AES-256-GCM key. 43 + 5. Client encrypts the response payload with the one-time AES key. 44 + 6. Client encrypts the one-time AES key with the creator's public key (RSA-OAEP or ECDH+AES-KW). 45 + 7. Client POSTs to the server: `{ encrypted_response, encrypted_key }`. 46 + 8. Server stores the encrypted response as a new row. 47 + 48 + **Response viewing:** 49 + 1. Creator opens the Sheet linked to the form. 50 + 2. Client loads encrypted responses from the server. 51 + 3. For each response: decrypts the one-time AES key using the private key, then decrypts the response payload. 52 + 4. Decrypted responses are inserted as rows in the Sheet. 53 + 54 + ### Data Model 55 + 56 + #### Server-Side (new table) 57 + 58 + ```sql 59 + CREATE TABLE forms ( 60 + id TEXT PRIMARY KEY, 61 + sheet_id TEXT NOT NULL, -- linked Sheet document ID 62 + definition TEXT NOT NULL, -- encrypted form structure (JSON) 63 + public_key TEXT NOT NULL, -- RSA/ECDH public key (JWK format, NOT encrypted) 64 + created_at TEXT DEFAULT (datetime('now')), 65 + updated_at TEXT DEFAULT (datetime('now')) 66 + ); 67 + 68 + CREATE TABLE form_responses ( 69 + id TEXT PRIMARY KEY, 70 + form_id TEXT NOT NULL, 71 + encrypted_response BLOB NOT NULL, -- AES-256-GCM encrypted response data 72 + encrypted_key BLOB NOT NULL, -- RSA/ECDH encrypted one-time AES key 73 + created_at TEXT DEFAULT (datetime('now')) 74 + ); 75 + ``` 76 + 77 + #### Yjs Structure (form definition) 78 + 79 + The form definition is stored as an encrypted JSON blob on the server, decryptable by the creator. The public-facing form definition (question text, types, options) is a subset that the server serves to respondents. 80 + 81 + ```javascript 82 + { 83 + title: "Event RSVP", 84 + description: "Please let us know if you can attend.", 85 + questions: [ 86 + { 87 + id: "q1", 88 + type: "text", 89 + label: "Your Name", 90 + required: true, 91 + placeholder: "Full name", 92 + }, 93 + { 94 + id: "q2", 95 + type: "select", 96 + label: "Attending?", 97 + required: true, 98 + options: ["Yes", "No", "Maybe"], 99 + }, 100 + { 101 + id: "q3", 102 + type: "multiselect", 103 + label: "Dietary needs", 104 + required: false, 105 + options: ["Vegetarian", "Vegan", "Gluten-free", "None"], 106 + }, 107 + { 108 + id: "q4", 109 + type: "textarea", 110 + label: "Comments", 111 + required: false, 112 + } 113 + ], 114 + settings: { 115 + confirmationMessage: "Thank you for your response!", 116 + allowMultiple: false, 117 + closeAfter: null, // ISO date string or null 118 + }, 119 + mapping: { 120 + // Maps question IDs to Sheet columns 121 + q1: { col: 1 }, // Column A 122 + q2: { col: 2 }, // Column B 123 + q3: { col: 3 }, // Column C 124 + q4: { col: 4 }, // Column D 125 + _timestamp: { col: 5 }, // Auto-populated 126 + } 127 + } 128 + ``` 129 + 130 + ### Question Types 131 + 132 + | Type | Input Widget | Data Stored | 133 + |------|-------------|-------------| 134 + | `text` | Single-line input | String | 135 + | `textarea` | Multi-line textarea | String | 136 + | `number` | Number input | Number | 137 + | `email` | Email input | String (validated) | 138 + | `url` | URL input | String (validated) | 139 + | `date` | Date picker | ISO date string | 140 + | `time` | Time picker | Time string | 141 + | `select` | Dropdown / radio buttons | Single string | 142 + | `multiselect` | Checkbox group | Array of strings (comma-joined in cell) | 143 + | `rating` | Star rating (1-5) | Number | 144 + | `scale` | Linear scale (1-N) | Number | 145 + | `file` | File upload (future, requires #80) | Encrypted file reference | 146 + 147 + ### Form URL Structure 148 + 149 + ``` 150 + https://tools.example.com/forms/{formId} 151 + ``` 152 + 153 + No encryption key in the URL. The respondent does not need to decrypt anything -- they only need the public key (fetched from the server) to encrypt their response. 154 + 155 + The creator's management URL: 156 + ``` 157 + https://tools.example.com/forms/{formId}/manage#{privateKeyBase64} 158 + ``` 159 + 160 + ### Server-Side Changes 161 + 162 + 1. **New routes:** 163 + - `POST /api/forms` -- create a form linked to a sheet 164 + - `GET /api/forms/:id` -- get form definition + public key (for respondents) 165 + - `PUT /api/forms/:id` -- update form definition 166 + - `POST /api/forms/:id/responses` -- submit an encrypted response 167 + - `GET /api/forms/:id/responses` -- list encrypted responses (for creator) 168 + 169 + 2. **New HTML entry point:** `src/forms/index.html` -- form respondent view. Minimal UI: form title, questions, submit button. No toolbar, no editor. 170 + 171 + 3. **New Vite input:** Add `forms` to `rollupOptions.input` in `vite.config.js`. 172 + 173 + ### Conditional Logic 174 + 175 + Question visibility can depend on previous answers: 176 + 177 + ```javascript 178 + { 179 + id: "q5", 180 + type: "text", 181 + label: "Plus-one name", 182 + required: false, 183 + condition: { 184 + questionId: "q2", 185 + operator: "equals", 186 + value: "Yes", 187 + }, 188 + } 189 + ``` 190 + 191 + Condition evaluation is client-side (pure logic module). Only shown questions are included in the response payload. 192 + 193 + ### Estimated Complexity 194 + 195 + | Component | Effort | Dependencies | 196 + |-----------|--------|-------------| 197 + | Server routes + DB schema | Small (1 day) | None | 198 + | Hybrid encryption (RSA-OAEP + AES) | Medium (2 days) | Extend crypto.js | 199 + | Form builder UI | Medium (3 days) | None | 200 + | Form respondent view | Small (2 days) | None | 201 + | Response decryption + Sheet insertion | Medium (2 days) | None | 202 + | Conditional logic | Small (1 day) | Form builder | 203 + | **Total** | **~11 days** | | 204 + 205 + ### What to Build First 206 + 207 + **Phase 1 (MVP):** Simple forms with text, select, and textarea questions. Respondents use the sheet's AES key (approach 1 -- same key). This ships faster and covers the internal/trusted use case. 208 + 209 + **Phase 2:** Hybrid encryption (approach 3). Add the RSA key pair generation, per-response encryption, and the separate form URL without the private key. This enables public/untrusted forms. 210 + 211 + **Phase 3:** Conditional logic, file uploads, and form analytics (response count, completion rate). 212 + 213 + ## Consequences 214 + 215 + **Positive:** 216 + - Form responses are E2E encrypted. The server stores only ciphertext. 217 + - Respondents cannot see other responses (with hybrid encryption). 218 + - Forms feed directly into Sheets, maintaining a single data model. 219 + - The form URL does not contain a decryption key, so it can be shared publicly. 220 + 221 + **Negative:** 222 + - Hybrid encryption adds complexity (RSA key pair management, two encryption layers). 223 + - The form definition (question text) must be partially unencrypted so respondents can see the questions. The server can see question labels but not response data. 224 + - No real-time collaboration on form responses (they are append-only, not CRDT-synced). The creator must refresh to see new responses. 225 + - The private key is in the creator's URL fragment. If lost, responses are unrecoverable (same limitation as document keys).
+186
docs/adr/007-ai-integration-via-aperture.md
··· 1 + # ADR 007: AI Integration via Aperture 2 + 3 + ## Status 4 + 5 + Proposed (not yet implemented) 6 + 7 + ## Context 8 + 9 + Tools can benefit from AI assistance for formula generation, document writing, data analysis, and content explanation. The AI gateway is Aperture, a self-hosted Tailscale service at `http://ai` that routes requests by model name to various providers (Anthropic, OpenAI, local models). Aperture injects provider credentials based on Tailscale identity. 10 + 11 + The critical constraint: **AI features must not break E2EE guarantees.** The Tools server must never see plaintext document content. Any data sent to an AI provider is an explicit, user-initiated action -- the user chooses what context to share. 12 + 13 + ### Trust Model for AI 14 + 15 + | Entity | Trusts With | 16 + |--------|------------| 17 + | **User** | Decrypted document content (they own it) | 18 + | **Aperture** | Selected context sent for AI processing (user's explicit choice) | 19 + | **AI provider** (Anthropic, OpenAI) | The prompt content (governed by provider's data policies) | 20 + | **Tools server** | Nothing. AI requests bypass the server entirely. | 21 + 22 + The user is making a deliberate trade-off: sharing a specific piece of content with an AI provider to get assistance. This is no different from copying text from Tools and pasting it into ChatGPT -- we just make the workflow smoother. 23 + 24 + ## Decision 25 + 26 + AI features are **client-side only**. The browser sends requests directly to Aperture (or any OpenAI-compatible API endpoint). The Tools server is not involved in AI requests. 27 + 28 + ### Architecture 29 + 30 + ``` 31 + Browser (decrypted content) 32 + | 33 + | HTTPS (selected context only) 34 + v 35 + Aperture (ai.lobster-hake.ts.net) 36 + | 37 + | Provider API 38 + v 39 + AI Provider (Anthropic Claude, OpenAI, etc.) 40 + ``` 41 + 42 + ### Configuration 43 + 44 + The Aperture URL is stored in `localStorage` under `tools-ai-endpoint`. Default: none (AI features are disabled until configured). 45 + 46 + Settings UI: 47 + - **AI Endpoint:** Text input for the API base URL (e.g., `http://ai`, `https://api.openai.com/v1`). 48 + - **API Key:** Text input (stored in localStorage, never sent to Tools server). For Aperture on Tailscale, this can be empty (Aperture injects creds). 49 + - **Model:** Dropdown or text input (e.g., `claude-sonnet-4-20250514`, `gpt-4o`). 50 + - **Enable AI features:** Toggle. Off by default. 51 + 52 + ### Sheets: Formula Assistant 53 + 54 + **Entry points:** 55 + 1. **Formula bar AI button:** Small sparkle icon next to the formula input. Click to open the AI panel. 56 + 2. **Context menu:** Right-click a cell -> "AI: Suggest formula" or "AI: Explain formula". 57 + 3. **Keyboard shortcut:** Cmd+Shift+A to open the AI panel for the selected cell. 58 + 59 + **UX Flow: Generate Formula from Description** 60 + 1. User selects a cell (or range) and opens the AI panel. 61 + 2. User types a natural-language description: "Sum of column B where column A is 'Revenue'" 62 + 3. Client builds a prompt including: 63 + - The user's description. 64 + - Column headers (first row values for context). 65 + - A few sample data rows (limited, to minimize data sent). 66 + - The cell reference context (where the formula will be placed). 67 + 4. Client sends to Aperture: `POST /chat/completions` with the prompt. 68 + 5. AI responds with a suggested formula: `=SUMIF(A2:A100,"Revenue",B2:B100)`. 69 + 6. Client shows the formula in a preview panel with: 70 + - The formula text (editable). 71 + - A live preview of the computed result. 72 + - "Insert" and "Cancel" buttons. 73 + 7. User clicks "Insert" -> formula is written to the cell. 74 + 75 + **UX Flow: Explain Formula** 76 + 1. User selects a cell containing a formula. 77 + 2. Right-click -> "AI: Explain formula" (or click the AI button). 78 + 3. Client sends the formula text to Aperture with a system prompt: "Explain this spreadsheet formula in plain English. Break down each function and reference." 79 + 4. AI response is displayed in a sidebar panel with formatted text. 80 + 81 + **Context Minimization:** 82 + - Only the selected cell's formula, column headers, and a limited sample (first 5 data rows) are sent. 83 + - The user can see exactly what context will be sent before confirming. 84 + - A "Context preview" expandable section shows the prompt. 85 + 86 + ### Docs: Writing Assistant 87 + 88 + **Entry points:** 89 + 1. **Inline AI menu:** Select text -> floating toolbar shows AI button. 90 + 2. **Slash command:** Type `/ai` to open the AI prompt in the editor. 91 + 3. **Sidebar panel:** Toggle an AI sidebar for longer interactions. 92 + 93 + **Capabilities:** 94 + - **Continue writing:** AI extends the current paragraph or section. 95 + - **Rewrite:** Rephrase selected text (shorter, longer, simpler, more formal). 96 + - **Summarize:** Condense selected text or the entire document. 97 + - **Translate:** Translate selected text to another language. 98 + - **Fix grammar:** Correct spelling and grammar in selected text. 99 + - **Explain:** Break down complex text in simpler terms. 100 + 101 + **UX Flow: Rewrite Selected Text** 102 + 1. User selects a paragraph. 103 + 2. Clicks AI button -> dropdown shows: Continue, Rewrite, Summarize, Translate, Fix Grammar. 104 + 3. User selects "Rewrite -> More concise". 105 + 4. Client sends the selected text + instruction to Aperture. 106 + 5. AI response appears as a suggestion (using the suggesting mode marks if enabled, or an inline diff preview). 107 + 6. User accepts or rejects the rewrite. 108 + 109 + **Context sent:** 110 + - Selected text (or surrounding paragraph for "Continue"). 111 + - Document title (for tone context). 112 + - A preceding paragraph for context (optional, user-visible). 113 + - Never the entire document unless the user explicitly chooses "Summarize entire document." 114 + 115 + ### Implementation Details 116 + 117 + **API Client Module:** `src/lib/ai-client.js` 118 + 119 + ```javascript 120 + export class ApertureClient { 121 + constructor({ endpoint, apiKey, model }) { 122 + this.endpoint = endpoint; 123 + this.apiKey = apiKey; 124 + this.model = model; 125 + } 126 + 127 + async complete({ messages, maxTokens = 1024 }) { 128 + const res = await fetch(`${this.endpoint}/chat/completions`, { 129 + method: 'POST', 130 + headers: { 131 + 'Content-Type': 'application/json', 132 + ...(this.apiKey ? { 'Authorization': `Bearer ${this.apiKey}` } : {}), 133 + }, 134 + body: JSON.stringify({ 135 + model: this.model, 136 + messages, 137 + max_tokens: maxTokens, 138 + }), 139 + }); 140 + if (!res.ok) throw new Error(`AI request failed: ${res.status}`); 141 + const data = await res.json(); 142 + return data.choices?.[0]?.message?.content || ''; 143 + } 144 + } 145 + ``` 146 + 147 + **Prompt Templates:** `src/lib/ai-prompts.js` 148 + 149 + System prompts for each use case, optimized for concise, actionable responses. Formula prompts include the function library reference so the AI suggests only functions that Tools supports. 150 + 151 + **UI Components:** 152 + - AI panel (shared between docs and sheets): a slide-out sidebar with a text input, response area, and action buttons. 153 + - Inline suggestion preview: for docs, uses the existing suggesting mode marks. For sheets, shows a formula preview with computed result. 154 + 155 + ### Fallback When Aperture Is Unavailable 156 + 157 + - If the AI endpoint is not configured: AI buttons and menu items are hidden. 158 + - If Aperture is unreachable: show a toast notification "AI assistant unavailable -- check your connection to [endpoint]." AI buttons become disabled with a tooltip. 159 + - If the AI returns an error: show the error message in the AI panel. Do not silently fail. 160 + 161 + ### Privacy Indicators 162 + 163 + When AI features are active, show a subtle indicator: 164 + - A small "AI" badge in the status bar when the AI endpoint is configured. 165 + - Before sending any request, a brief flash of "Sending to AI..." in the AI panel. 166 + - A "What's being sent" expandable section in every AI interaction so the user can audit the context. 167 + 168 + ## Consequences 169 + 170 + **Positive:** 171 + - AI requests go directly from browser to Aperture. The Tools server never sees the content. 172 + - Users explicitly opt in by configuring the endpoint and initiating each AI action. 173 + - Context minimization: only the selected content (not the entire document) is sent. 174 + - Works with any OpenAI-compatible API, not just Aperture. 175 + - Gracefully degrades: AI features are invisible when not configured. 176 + 177 + **Negative:** 178 + - Content sent to AI providers leaves the E2EE boundary. This is an explicit trade-off, but it must be clearly communicated to users. 179 + - Aperture requires Tailscale access. Users outside the Tailnet need a direct API key to their provider. 180 + - AI responses may suggest formulas that use functions Tools doesn't support. Mitigation: include the supported function list in the system prompt. 181 + - Streaming responses (for longer doc writing) require SSE or chunked response handling, adding complexity. 182 + 183 + **E2EE Interaction:** 184 + - AI features do not weaken E2EE. The server's zero-knowledge properties are unchanged. 185 + - The trust boundary expands to include the AI provider, but only for the specific content the user chooses to send. 186 + - AI configuration (endpoint, API key) is stored in localStorage, not in the encrypted document. Different users of the same document can have different AI configurations (or none).
+42
public/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"> 2 + <!-- 3 + Tools — E2EE Office Suite Favicon 4 + Two overlapping document forms (docs + sheets) with a small shield-lock badge. 5 + Colors from the OkLch palette converted to hex: 6 + - Teal (#3a8a7a) from oklch(0.48 0.1 195) — encryption/security 7 + - Warm accent (#c45a3c) from oklch(0.52 0.14 25) — brand accent 8 + Works on both light and dark tab bars. 9 + --> 10 + 11 + <!-- Back document (spreadsheet — teal) --> 12 + <rect x="8" y="2" width="22" height="24" rx="3" fill="#3a8a7a"/> 13 + <!-- Grid lines suggesting cells --> 14 + <line x1="16" y1="6" x2="16" y2="22" stroke="#fff" stroke-opacity="0.3" stroke-width="0.75"/> 15 + <line x1="23" y1="6" x2="23" y2="22" stroke="#fff" stroke-opacity="0.3" stroke-width="0.75"/> 16 + <line x1="11" y1="10" x2="27" y2="10" stroke="#fff" stroke-opacity="0.3" stroke-width="0.75"/> 17 + <line x1="11" y1="14" x2="27" y2="14" stroke="#fff" stroke-opacity="0.3" stroke-width="0.75"/> 18 + <line x1="11" y1="18" x2="27" y2="18" stroke="#fff" stroke-opacity="0.3" stroke-width="0.75"/> 19 + 20 + <!-- Front document (text doc — warm accent) --> 21 + <rect x="2" y="6" width="22" height="24" rx="3" fill="#c45a3c"/> 22 + <!-- Text lines --> 23 + <line x1="6" y1="12" x2="20" y2="12" stroke="#fff" stroke-opacity="0.4" stroke-width="1.3" stroke-linecap="round"/> 24 + <line x1="6" y1="16" x2="17" y2="16" stroke="#fff" stroke-opacity="0.3" stroke-width="1.1" stroke-linecap="round"/> 25 + <line x1="6" y1="20" x2="19" y2="20" stroke="#fff" stroke-opacity="0.3" stroke-width="1.1" stroke-linecap="round"/> 26 + <line x1="6" y1="24" x2="13" y2="24" stroke="#fff" stroke-opacity="0.3" stroke-width="1.1" stroke-linecap="round"/> 27 + 28 + <!-- Shield badge (bottom-right corner) --> 29 + <g transform="translate(19, 20)"> 30 + <!-- Shield shape --> 31 + <path d="M6,0.5 L11.5,2.5 L11.5,7 C11.5,9.5 9.2,11.2 6,12 C2.8,11.2 0.5,9.5 0.5,7 L0.5,2.5 Z" 32 + fill="#1a5c50" stroke="#0d3d35" stroke-width="0.6"/> 33 + <!-- Lock shackle --> 34 + <path d="M4.5,5.5 L4.5,4.2 C4.5,3 5.2,2.3 6,2.3 C6.8,2.3 7.5,3 7.5,4.2 L7.5,5.5" 35 + fill="none" stroke="#fff" stroke-opacity="0.95" stroke-width="1.1" stroke-linecap="round"/> 36 + <!-- Lock body --> 37 + <rect x="3.5" y="5.5" width="5" height="3.8" rx="0.8" fill="#fff" fill-opacity="0.95"/> 38 + <!-- Keyhole --> 39 + <circle cx="6" cy="7.2" r="0.7" fill="#1a5c50"/> 40 + <rect x="5.65" y="7.2" width="0.7" height="1.2" rx="0.2" fill="#1a5c50"/> 41 + </g> 42 + </svg>
+51
src/css/app.css
··· 2261 2261 opacity: 1; 2262 2262 transform: translateX(-50%) translateY(0); 2263 2263 } 2264 + .toast-notification.toast-error { 2265 + background: var(--color-danger); 2266 + } 2267 + 2268 + /* --- Drag-and-drop import overlay --- */ 2269 + .drop-overlay { 2270 + position: fixed; 2271 + inset: 0; 2272 + z-index: 300; 2273 + background: var(--color-modal-backdrop); 2274 + backdrop-filter: blur(2px); 2275 + display: flex; 2276 + align-items: center; 2277 + justify-content: center; 2278 + animation: drop-overlay-in 150ms ease-out; 2279 + } 2280 + 2281 + @keyframes drop-overlay-in { 2282 + from { opacity: 0; } 2283 + to { opacity: 1; } 2284 + } 2285 + 2286 + .drop-overlay-content { 2287 + display: flex; 2288 + flex-direction: column; 2289 + align-items: center; 2290 + gap: var(--space-md); 2291 + padding: var(--space-2xl); 2292 + border: 2px dashed var(--color-teal); 2293 + border-radius: var(--radius-lg); 2294 + background: var(--color-surface); 2295 + box-shadow: var(--shadow-lg); 2296 + pointer-events: none; 2297 + } 2298 + 2299 + .drop-overlay-icon { 2300 + font-size: 3rem; 2301 + line-height: 1; 2302 + } 2303 + 2304 + .drop-overlay-text { 2305 + font-family: var(--font-display); 2306 + font-size: 1.4rem; 2307 + font-weight: 600; 2308 + color: var(--color-text); 2309 + } 2310 + 2311 + .drop-overlay-hint { 2312 + font-size: 0.85rem; 2313 + color: var(--color-text-muted); 2314 + } 2264 2315 2265 2316 /* --- Find & Replace Bar --- */ 2266 2317 .find-bar {
+1
src/docs/index.html
··· 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 6 <title>Tools — Docs</title> 7 + <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 7 8 <link rel="stylesheet" href="../css/app.css"> 8 9 <script> 9 10 (function() {
+17
src/docs/main.js
··· 920 920 921 921 provider.on('sync', () => { 922 922 statusText.textContent = 'Synced'; 923 + 924 + // Check for pending file import from landing page drag-and-drop 925 + const pendingKey = `pending-import-${docId}`; 926 + const pendingRaw = sessionStorage.getItem(pendingKey); 927 + if (pendingRaw) { 928 + sessionStorage.removeItem(pendingKey); 929 + try { 930 + const pending = JSON.parse(pendingRaw); 931 + // Convert data URL back to a File object 932 + fetch(pending.data) 933 + .then(r => r.blob()) 934 + .then(blob => { 935 + const file = new File([blob], pending.name, { type: blob.type }); 936 + handleImportedFile(file); 937 + }); 938 + } catch { /* ignore corrupt data */ } 939 + } 923 940 }); 924 941 925 942 // --- Download helper ---
+10
src/index.html
··· 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 6 <title>Tools — Encrypted Office</title> 7 + <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 7 8 <link rel="stylesheet" href="./css/app.css"> 8 9 <script> 9 10 // Theme init — runs before paint to avoid FOUC ··· 122 123 <div class="move-modal-actions"> 123 124 <button class="btn-secondary" id="move-cancel">Cancel</button> 124 125 </div> 126 + </div> 127 + </div> 128 + 129 + <!-- Drag-and-drop import overlay --> 130 + <div class="drop-overlay" id="drop-overlay" style="display:none;"> 131 + <div class="drop-overlay-content"> 132 + <span class="drop-overlay-icon">&#128196;</span> 133 + <span class="drop-overlay-text">Drop to import</span> 134 + <span class="drop-overlay-hint">.docx, .xlsx, .csv, .md</span> 125 135 </div> 126 136 </div> 127 137
+66
src/landing-dragdrop.js
··· 1 + /** 2 + * Pure utility functions for drag-and-drop file import on the landing page. 3 + * Extracted for testability — no DOM or browser API access here. 4 + */ 5 + 6 + /** File extension to document type mapping. */ 7 + const EXT_TO_DOC_TYPE = { 8 + docx: 'doc', 9 + md: 'doc', 10 + xlsx: 'sheet', 11 + csv: 'sheet', 12 + }; 13 + 14 + /** File extension to import type mapping. */ 15 + const EXT_TO_IMPORT_TYPE = { 16 + docx: 'docx', 17 + md: 'md', 18 + xlsx: 'xlsx', 19 + csv: 'csv', 20 + }; 21 + 22 + /** 23 + * Get the document type ('doc' | 'sheet') for a filename based on its extension. 24 + * @param {string} filename 25 + * @returns {'doc' | 'sheet' | null} 26 + */ 27 + export function getFileType(filename) { 28 + if (!filename || typeof filename !== 'string') return null; 29 + const ext = filename.split('.').pop().toLowerCase(); 30 + return EXT_TO_DOC_TYPE[ext] || null; 31 + } 32 + 33 + /** 34 + * Get the import type for a filename based on its extension. 35 + * @param {string} filename 36 + * @returns {'docx' | 'xlsx' | 'csv' | 'md' | null} 37 + */ 38 + export function getImportType(filename) { 39 + if (!filename || typeof filename !== 'string') return null; 40 + const ext = filename.split('.').pop().toLowerCase(); 41 + return EXT_TO_IMPORT_TYPE[ext] || null; 42 + } 43 + 44 + /** 45 + * Build the sessionStorage key for a pending import. 46 + * @param {string} docId 47 + * @returns {string} 48 + */ 49 + export function pendingImportKey(docId) { 50 + return `pending-import-${docId}`; 51 + } 52 + 53 + /** 54 + * Build the editor URL path for a given document type and ID. 55 + * @param {'doc' | 'sheet'} type 56 + * @param {string} docId 57 + * @param {string} keyStr - base64url encryption key 58 + * @returns {string} 59 + */ 60 + export function buildEditorUrl(type, docId, keyStr) { 61 + const base = type === 'doc' ? '/docs' : '/sheets'; 62 + return `${base}/${docId}#${keyStr}`; 63 + } 64 + 65 + /** Supported file extensions for display purposes. */ 66 + export const SUPPORTED_EXTENSIONS = Object.keys(EXT_TO_DOC_TYPE);
+122
src/landing.js
··· 20 20 validateUsername, 21 21 DEFAULT_SORT, 22 22 } from './landing-utils.js'; 23 + import { 24 + getFileType, 25 + getImportType, 26 + pendingImportKey, 27 + buildEditorUrl, 28 + } from './landing-dragdrop.js'; 23 29 24 30 // --- DOM refs --- 25 31 const docListEl = document.getElementById('doc-list'); ··· 619 625 modal.style.display = 'none'; 620 626 } 621 627 }); 628 + }); 629 + 630 + // --- Drag-and-drop file import --- 631 + const dropOverlay = document.getElementById('drop-overlay'); 632 + let dragCounter = 0; 633 + 634 + function showDropOverlay() { 635 + dropOverlay.style.display = ''; 636 + } 637 + 638 + function hideDropOverlay() { 639 + dropOverlay.style.display = 'none'; 640 + } 641 + 642 + function showToast(message, duration = 3000, isError = false) { 643 + const existing = document.querySelector('.toast-notification'); 644 + if (existing) existing.remove(); 645 + const toast = document.createElement('div'); 646 + toast.className = 'toast-notification' + (isError ? ' toast-error' : ''); 647 + toast.textContent = message; 648 + document.body.appendChild(toast); 649 + toast.offsetHeight; // force reflow 650 + toast.classList.add('toast-visible'); 651 + setTimeout(() => { 652 + toast.classList.remove('toast-visible'); 653 + setTimeout(() => toast.remove(), 300); 654 + }, duration); 655 + } 656 + 657 + document.addEventListener('dragenter', (e) => { 658 + e.preventDefault(); 659 + dragCounter++; 660 + if (dragCounter === 1) showDropOverlay(); 661 + }); 662 + 663 + document.addEventListener('dragover', (e) => { 664 + e.preventDefault(); 665 + e.dataTransfer.dropEffect = 'copy'; 666 + }); 667 + 668 + document.addEventListener('dragleave', (e) => { 669 + e.preventDefault(); 670 + dragCounter--; 671 + if (dragCounter <= 0) { 672 + dragCounter = 0; 673 + hideDropOverlay(); 674 + } 675 + }); 676 + 677 + document.addEventListener('drop', async (e) => { 678 + e.preventDefault(); 679 + dragCounter = 0; 680 + hideDropOverlay(); 681 + 682 + const file = e.dataTransfer.files[0]; 683 + if (!file) return; 684 + 685 + const docType = getFileType(file.name); 686 + const importType = getImportType(file.name); 687 + 688 + if (!docType || !importType) { 689 + showToast(`Unsupported file type: .${file.name.split('.').pop()}`, 4000, true); 690 + return; 691 + } 692 + 693 + try { 694 + // Generate encryption key 695 + const key = await generateKey(); 696 + const keyStr = await exportKey(key); 697 + 698 + // Encrypt the default document name 699 + const defaultName = docType === 'doc' ? 'Untitled Document' : 'Untitled Spreadsheet'; 700 + const nameBytes = new TextEncoder().encode(defaultName); 701 + const { encrypt } = await import('./lib/crypto.js'); 702 + const encryptedName = await encrypt(nameBytes, key); 703 + const nameB64 = btoa(String.fromCharCode(...encryptedName)); 704 + 705 + // Create document via API 706 + const res = await fetch('/api/documents', { 707 + method: 'POST', 708 + headers: { 'Content-Type': 'application/json' }, 709 + body: JSON.stringify({ type: docType, name_encrypted: nameB64 }), 710 + }); 711 + const { id } = await res.json(); 712 + 713 + // Store encryption key 714 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 715 + keys[id] = keyStr; 716 + localStorage.setItem('tools-keys', JSON.stringify(keys)); 717 + 718 + // If inside a folder, assign to it 719 + if (currentFolderId) { 720 + folderAssignments = moveToFolder(folderAssignments, id, currentFolderId); 721 + localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 722 + } 723 + 724 + // Read file and store in sessionStorage for the editor to pick up 725 + const reader = new FileReader(); 726 + reader.onload = () => { 727 + const payload = JSON.stringify({ 728 + name: file.name, 729 + type: importType, 730 + data: reader.result, // data URL (base64-encoded) 731 + }); 732 + sessionStorage.setItem(pendingImportKey(id), payload); 733 + 734 + // Navigate to the editor 735 + window.location.href = buildEditorUrl(docType, id, keyStr); 736 + }; 737 + reader.onerror = () => { 738 + showToast('Failed to read file', 4000, true); 739 + }; 740 + reader.readAsDataURL(file); 741 + } catch (err) { 742 + showToast('Failed to create document for import', 4000, true); 743 + } 622 744 }); 623 745 624 746 // --- Init ---
+1
src/sheets/index.html
··· 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 6 <title>Tools — Sheets</title> 7 + <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 7 8 <link rel="stylesheet" href="../css/app.css"> 8 9 <script> 9 10 (function() {
+17
src/sheets/main.js
··· 1305 1305 // that getCells() returned during initial setup (before data loaded) 1306 1306 getCells().observeDeep(() => { evalCache.clear(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 1307 1307 renderGrid(); 1308 + 1309 + // Check for pending file import from landing page drag-and-drop 1310 + const pendingKey = `pending-import-${docId}`; 1311 + const pendingRaw = sessionStorage.getItem(pendingKey); 1312 + if (pendingRaw) { 1313 + sessionStorage.removeItem(pendingKey); 1314 + try { 1315 + const pending = JSON.parse(pendingRaw); 1316 + // Convert data URL back to a File object 1317 + fetch(pending.data) 1318 + .then(r => r.blob()) 1319 + .then(blob => { 1320 + const file = new File([blob], pending.name, { type: blob.type }); 1321 + handleImportFile(file); 1322 + }); 1323 + } catch { /* ignore corrupt data */ } 1324 + } 1308 1325 }); 1309 1326 1310 1327 // --- Collaboration avatars ---
+125
tests/landing-dragdrop.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + getFileType, 4 + getImportType, 5 + pendingImportKey, 6 + buildEditorUrl, 7 + SUPPORTED_EXTENSIONS, 8 + } from '../src/landing-dragdrop.js'; 9 + 10 + describe('getFileType', () => { 11 + it('returns "doc" for .docx files', () => { 12 + expect(getFileType('report.docx')).toBe('doc'); 13 + }); 14 + 15 + it('returns "doc" for .md files', () => { 16 + expect(getFileType('notes.md')).toBe('doc'); 17 + }); 18 + 19 + it('returns "sheet" for .xlsx files', () => { 20 + expect(getFileType('budget.xlsx')).toBe('sheet'); 21 + }); 22 + 23 + it('returns "sheet" for .csv files', () => { 24 + expect(getFileType('data.csv')).toBe('sheet'); 25 + }); 26 + 27 + it('is case-insensitive on extensions', () => { 28 + expect(getFileType('FILE.DOCX')).toBe('doc'); 29 + expect(getFileType('DATA.CSV')).toBe('sheet'); 30 + expect(getFileType('NOTES.MD')).toBe('doc'); 31 + expect(getFileType('budget.XLSX')).toBe('sheet'); 32 + }); 33 + 34 + it('returns null for unsupported extensions', () => { 35 + expect(getFileType('image.png')).toBeNull(); 36 + expect(getFileType('script.js')).toBeNull(); 37 + expect(getFileType('archive.zip')).toBeNull(); 38 + expect(getFileType('document.pdf')).toBeNull(); 39 + expect(getFileType('readme.txt')).toBeNull(); 40 + }); 41 + 42 + it('returns null for empty or invalid input', () => { 43 + expect(getFileType('')).toBeNull(); 44 + expect(getFileType(null)).toBeNull(); 45 + expect(getFileType(undefined)).toBeNull(); 46 + expect(getFileType(123)).toBeNull(); 47 + }); 48 + 49 + it('handles filenames with multiple dots', () => { 50 + expect(getFileType('my.report.final.docx')).toBe('doc'); 51 + expect(getFileType('q1.budget.2026.xlsx')).toBe('sheet'); 52 + }); 53 + 54 + it('handles filenames with no extension', () => { 55 + expect(getFileType('README')).toBeNull(); 56 + }); 57 + }); 58 + 59 + describe('getImportType', () => { 60 + it('returns "docx" for .docx files', () => { 61 + expect(getImportType('report.docx')).toBe('docx'); 62 + }); 63 + 64 + it('returns "md" for .md files', () => { 65 + expect(getImportType('notes.md')).toBe('md'); 66 + }); 67 + 68 + it('returns "xlsx" for .xlsx files', () => { 69 + expect(getImportType('budget.xlsx')).toBe('xlsx'); 70 + }); 71 + 72 + it('returns "csv" for .csv files', () => { 73 + expect(getImportType('data.csv')).toBe('csv'); 74 + }); 75 + 76 + it('is case-insensitive on extensions', () => { 77 + expect(getImportType('FILE.DOCX')).toBe('docx'); 78 + expect(getImportType('DATA.CSV')).toBe('csv'); 79 + }); 80 + 81 + it('returns null for unsupported extensions', () => { 82 + expect(getImportType('image.png')).toBeNull(); 83 + expect(getImportType('document.pdf')).toBeNull(); 84 + }); 85 + 86 + it('returns null for empty or invalid input', () => { 87 + expect(getImportType('')).toBeNull(); 88 + expect(getImportType(null)).toBeNull(); 89 + expect(getImportType(undefined)).toBeNull(); 90 + }); 91 + }); 92 + 93 + describe('pendingImportKey', () => { 94 + it('returns a prefixed key for a document ID', () => { 95 + expect(pendingImportKey('abc123')).toBe('pending-import-abc123'); 96 + }); 97 + 98 + it('handles various ID formats', () => { 99 + expect(pendingImportKey('doc-uuid-here')).toBe('pending-import-doc-uuid-here'); 100 + expect(pendingImportKey('123')).toBe('pending-import-123'); 101 + }); 102 + }); 103 + 104 + describe('buildEditorUrl', () => { 105 + it('builds a docs URL for doc type', () => { 106 + expect(buildEditorUrl('doc', 'abc123', 'keyXYZ')).toBe('/docs/abc123#keyXYZ'); 107 + }); 108 + 109 + it('builds a sheets URL for sheet type', () => { 110 + expect(buildEditorUrl('sheet', 'def456', 'keyABC')).toBe('/sheets/def456#keyABC'); 111 + }); 112 + }); 113 + 114 + describe('SUPPORTED_EXTENSIONS', () => { 115 + it('includes all four supported extensions', () => { 116 + expect(SUPPORTED_EXTENSIONS).toContain('docx'); 117 + expect(SUPPORTED_EXTENSIONS).toContain('md'); 118 + expect(SUPPORTED_EXTENSIONS).toContain('xlsx'); 119 + expect(SUPPORTED_EXTENSIONS).toContain('csv'); 120 + }); 121 + 122 + it('has exactly four entries', () => { 123 + expect(SUPPORTED_EXTENSIONS).toHaveLength(4); 124 + }); 125 + });