+22
-17
.env.example
+22
-17
.env.example
······-DKIM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----"+DKIM_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nPASTE_YOUR_DKIM_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----"
+107
CRUSH.md
+107
CRUSH.md
···+This project uses [Tangled](https://tangled.org) for issue tracking via the `tangled-cli` tool.+tangled-cli issue create --repo "thistle" --title "Issue title" --label "bug" --label "priority:high" --body "Issue description"+**Note:** The repo name for this project is `thistle` (resolves to `dunkirk.sh/thistle` in Tangled). Labels are supported but need to be created in the repository first.+- The CLI may have decoding issues with some API responses (missing `createdAt` field). If `tangled-cli issue list` fails, you can access issues via the web interface at https://tangled.org/dunkirk.sh/thistle
+10
LICENSE.md
+10
LICENSE.md
···+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.+No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2
-1
README.md
+2
-1
README.md
······-<a href="https://github.com/taciturnaxolotl/thistle/blob/main/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=MIT&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>+<a href="https://github.com/taciturnaxolotl/thistle/blob/main/LICENSE.md"><img src="https://img.shields.io/static/v1.svg?style=for-the-badge&label=License&message=O'Saasy&logoColor=d9e0ee&colorA=363a4f&colorB=b7bdf8"/></a>
-399
index.html
-399
index.html
···-@import url("https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap");
+28
-28
package.json
+28
-28
package.json
···
+19
-1
public/favicon/site.webmanifest
+19
-1
public/favicon/site.webmanifest
···-{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+17
scripts/clear-rate-limits.ts
+17
scripts/clear-rate-limits.ts
···
+39
scripts/remove-from-classes.ts
+39
scripts/remove-from-classes.ts
···
+1
-1
scripts/send-test-emails.ts
+1
-1
scripts/send-test-emails.ts
+223
-21
src/components/admin-classes.ts
+223
-21
src/components/admin-classes.ts
·································+<h2 class="modal-title">${this.editingClassInfo.course_code} - ${this.editingClassInfo.name}</h2>+style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: none; border-bottom: 2px solid var(--primary); font-weight: 600; cursor: pointer; margin-bottom: -2px;"+style="flex: 1; padding: 0.75rem; border: 2px solid var(--secondary); border-radius: 6px; font-size: 1rem; background: var(--background); color: var(--text);"+style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer; white-space: nowrap;"+? html`<p style="color: var(--paynes-gray); text-align: center; padding: 2rem;">No sections yet. Add one above.</p>`+<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; background: color-mix(in srgb, var(--secondary) 30%, transparent); border-radius: 6px;">+style="padding: 0.5rem 1rem; background: transparent; color: red; border: 2px solid red; border-radius: 4px; font-size: 0.875rem; cursor: pointer;"+<div style="display: flex; gap: 0.75rem; justify-content: space-between; padding-top: 1.5rem; border-top: 2px solid var(--secondary);">+style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;"+style="padding: 0.75rem 1.5rem; background: transparent; color: red; border: 2px solid red; border-radius: 6px; font-weight: 500; cursor: pointer;"+style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;"+${this.error ? html`<div style="color: red; margin-top: 1rem; padding: 0.75rem; background: color-mix(in srgb, red 10%, transparent); border-radius: 6px;">${this.error}</div>` : ""}············+<div class="help-text">Comma-separated list of section numbers. Leave blank if no sections.</div>
+19
-10
src/components/admin-pending-recordings.ts
+19
-10
src/components/admin-pending-recordings.ts
······-this.error = err instanceof Error ? err.message : "Failed to load pending recordings. Please try again.";···-this.error = err instanceof Error ? err.message : "Failed to approve recording. Please try again.";···-this.error = err instanceof Error ? err.message : "Failed to delete recording. Please try again.";
+10
-3
src/components/admin-transcriptions.ts
+10
-3
src/components/admin-transcriptions.ts
···-this.error = err instanceof Error ? err.message : "Failed to load transcriptions. Please try again.";···-this.error = err instanceof Error ? err.message : "Failed to delete transcription. Please try again.";
+66
-31
src/components/admin-users.ts
+66
-31
src/components/admin-users.ts
·········-private handleRevokeClick(userId: number, email: string, subscriptionId: string, event: Event) {···-private async performRevokeSubscription(userId: number, _email: string, subscriptionId: string) {······// Don't open modal if clicking on delete button, revoke button, sync button, or role select······-<div class="user-card ${u.id === 0 ? 'system' : ''}" @click=${(e: Event) => this.handleCardClick(u.id, e)}>+<div class="user-card ${u.id === 0 ? "system" : ""}" @click=${(e: Event) => this.handleCardClick(u.id, e)}>······-? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`+? html`<span class="subscription-badge ${u.subscription_status.toLowerCase()}">${u.subscription_status}</span>`···-? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`+? html`<div style="color: var(--paynes-gray); font-size: 0.875rem; padding: 0.5rem;">System account cannot be modified</div>`···?disabled=${!u.subscription_status || !u.subscription_id || this.revokingSubscriptions.has(u.id)}···
+10
-10
src/components/auth.ts
+10
-10
src/components/auth.ts
···············
+60
-11
src/components/class-registration-modal.ts
+60
-11
src/components/class-registration-modal.ts
·····················+style="width: 100%; padding: 0.5rem; border: 2px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; background: var(--background); color: var(--text);"+html`<option value="${s.id}" ?selected=${this.selectedSections.get(cls.id) === s.id}>${s.section_number}</option>`,······
+123
-13
src/components/class-view.ts
+123
-13
src/components/class-view.ts
·····················<div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin: 2rem 0; text-align: center;"><p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and view transcriptions.</p><a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a>+style="padding: 0.5rem 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);"+html`<option value=${s.id} ?selected=${s.id === this.selectedSectionFilter}>${s.section_number}</option>`,······<p>${this.searchQuery ? "Try a different search term" : "Upload a recording to get started!"}</p>···
+1
src/components/classes-overview.ts
+1
src/components/classes-overview.ts
+436
src/components/pending-recordings-view.ts
+436
src/components/pending-recordings-view.ts
···+Vote for the best quality recording. The winner will be automatically transcribed when 40% of class votes or after 30 minutes.+<div class="recording-card ${this.userVote === recording.id ? "voted" : ""} ${this.winningRecordingId === recording.id ? "winning" : ""}">
+18
-7
src/components/reset-password-form.ts
+18
-7
src/components/reset-password-form.ts
············
+10
-5
src/components/transcription.ts
+10
-5
src/components/transcription.ts
······<div style="background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid var(--accent); border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem; text-align: center;"><h3 style="margin: 0 0 0.5rem 0; color: var(--text);">Subscribe to Upload Transcriptions</h3><p style="margin: 0 0 1rem 0; color: var(--text); opacity: 0.8;">You need an active subscription to upload and transcribe audio files.</p><a href="/settings?tab=billing" style="display: inline-block; padding: 0.75rem 1.5rem; background: var(--accent); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;">Subscribe Now</a><div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!canUpload ? "disabled" : ""}"
+360
-66
src/components/upload-recording-modal.ts
+360
-66
src/components/upload-recording-modal.ts
······+formData.append("recording_date", Math.floor(this.selectedFile.lastModified / 1000).toString());···+style="padding: 0.75rem; border: 1px solid var(--secondary); border-radius: 4px; font-size: 0.875rem; color: var(--text); background: var(--background);"+class="meeting-time-button ${this.selectedMeetingTimeId === meeting.id ? "selected" : ""} ${this.detectedMeetingTime === meeting.id ? "detected" : ""}"+<option value="">Use my section ${this.userSection ? `(${this.sections.find((s) => s.id === this.userSection)?.section_number})` : ""}</option>+<div style="background: color-mix(in srgb, var(--primary) 5%, transparent); border-radius: 8px; padding: 1rem;">+<div style="background: var(--secondary); border-radius: 4px; height: 8px; overflow: hidden;">+<div style="background: var(--accent); height: 100%; width: ${this.uploadProgress}%; transition: width 0.3s;"></div>+<button class="btn-cancel" @click=${this.handleClose} ?disabled=${this.uploading || this.submitting}>
+25
-8
src/components/user-modal.ts
+25
-8
src/components/user-modal.ts
···············"Password reset email sent successfully. The user will receive a link to set a new password.",···<input type="email" id="new-email" class="form-input" placeholder="Enter new email" value=${this.user.email}>+<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.875rem;">
+153
-46
src/components/user-settings.ts
+153
-46
src/components/user-settings.ts
···-type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "notifications" | "danger";············-return ["account", "sessions", "passkeys", "billing", "notifications", "danger"].includes(tab);·····················+${this.pendingEmailChange ? html`<br><strong>New email:</strong> ${this.pendingEmailChange}` : ""}·······································
+98
-59
src/db/schema.ts
+98
-59
src/db/schema.ts
···············CREATE INDEX IF NOT EXISTS idx_transcriptions_whisper_job_id ON transcriptions(whisper_job_id);+CREATE INDEX IF NOT EXISTS idx_transcriptions_meeting_time_id ON transcriptions(meeting_time_id);·········CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);+CREATE INDEX IF NOT EXISTS idx_recording_votes_transcription_id ON recording_votes(transcription_id);+CREATE INDEX IF NOT EXISTS idx_transcriptions_recording_date ON transcriptions(recording_date);
+3
-1
src/index.test.README.md
+3
-1
src/index.test.README.md
···
+353
-747
src/index.test.ts
+353
-747
src/index.test.ts
···-`\n⚠️ Test server not running on port ${TEST_PORT}. Start it with:\n PORT=${TEST_PORT} bun run src/index.ts\n Then run tests in another terminal.\n`,+// Dummy env vars to pass startup validation (won't be used due to SKIP_EMAILS/SKIP_POLAR_SYNC)···-"DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",-"DELETE FROM passkeys WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",-"DELETE FROM transcriptions WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%')",-"DELETE FROM users WHERE email LIKE 'test%' OR email LIKE 'admin@%' OR email LIKE 'newemail%'",·············································································································································
+1567
-481
src/index.ts
+1567
-481
src/index.ts
·························································"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,···············-return Response.json({ message: "If an account exists with that email, a verification code has been sent" });·····················-"SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",+"SELECT status FROM subscriptions WHERE user_id = ? AND status IN ('active', 'trialing', 'past_due') ORDER BY created_at DESC LIMIT 1",·····················-return Response.json({ error: "email_notifications_enabled must be a boolean" }, { status: 400 });-db.run("UPDATE users SET email_notifications_enabled = ? WHERE id = ?", [email_notifications_enabled ? 1 : 0, user.id]);·······································-"SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC",·········-"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, filename, original_filename, status) VALUES (?, ?, ?, ?, ?, ?, ?)",+"INSERT INTO transcriptions (id, user_id, class_id, meeting_time_id, section_id, filename, original_filename, status, recording_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",···+return Response.json(result.data); // Return just the array for now, can add pagination UI later···+return Response.json(result.data); // Return just the array for now, can add pagination UI later·······················································································+"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://hostedboringavatars.vercel.app; font-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none';",
+136
src/lib/api-response-format.test.ts
+136
src/lib/api-response-format.test.ts
···
+55
src/lib/audio-metadata.integration.test.ts
+55
src/lib/audio-metadata.integration.test.ts
···+await Bun.$`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -metadata creation_time=${creationTime} -y ${testAudioPath}`.quiet();
+128
src/lib/audio-metadata.test.ts
+128
src/lib/audio-metadata.test.ts
···
+144
src/lib/audio-metadata.ts
+144
src/lib/audio-metadata.ts
···+`[AudioMetadata] Extracted creation date from metadata: ${date.toISOString()} from ${filePath}`,+`[AudioMetadata] No meeting time found matching ${dayName} in available options: ${meetingTimes.map((mt) => mt.label).join(", ")}`,
+34
src/lib/auth.test.ts
+34
src/lib/auth.test.ts
···
+291
-81
src/lib/auth.ts
+291
-81
src/lib/auth.ts
······"INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)",···"SELECT id, status, cancel_at_period_end FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",······-export function createEmailVerificationToken(userId: number): { code: string; token: string; sentAt: number } {···"INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)","INSERT INTO email_verification_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)",·········+"INSERT INTO email_change_tokens (id, user_id, new_email, token, expires_at) VALUES (?, ?, ?, ?, ?)",······-LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')+LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')+LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due')
+7
-7
src/lib/classes.test.ts
+7
-7
src/lib/classes.test.ts
···
+240
-27
src/lib/classes.ts
+240
-27
src/lib/classes.ts
·····················+"INSERT OR IGNORE INTO class_members (class_id, user_id, section_id, enrolled_at) VALUES (?, ?, ?, ?)",·········-`SELECT id, user_id, meeting_time_id, filename, original_filename, status, progress, error_message, created_at, updated_at+`SELECT id, user_id, meeting_time_id, section_id, filename, original_filename, status, progress, error_message, created_at, updated_at······
-1
src/lib/client-auth.ts
-1
src/lib/client-auth.ts
+1
-2
src/lib/crypto-fallback.ts
+1
-2
src/lib/crypto-fallback.ts
···
+117
src/lib/cursor.test.ts
+117
src/lib/cursor.test.ts
···
+92
src/lib/cursor.ts
+92
src/lib/cursor.ts
···
+116
src/lib/email-change.test.ts
+116
src/lib/email-change.test.ts
···
+66
-2
src/lib/email-templates.ts
+66
-2
src/lib/email-templates.ts
······+<a href="${options.verifyLink}" class="button" style="display: inline-block; background-color: #ef8354; color: #ffffff; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 500; font-size: 1rem; margin: 0; border: 2px solid #ef8354;">Verify Email Change</a>+<a href="${options.verifyLink}" style="color: #4f5d75; word-break: break-all;">${options.verifyLink}</a>+<p>If you didn't request this change, please ignore this email and your email address will remain unchanged.</p>
+22
-21
src/lib/email-verification.test.ts
+22
-21
src/lib/email-verification.test.ts
··················
+11
-14
src/lib/email.ts
+11
-14
src/lib/email.ts
···+`[Email] SKIPPED (test mode): "${options.subject}" to ${typeof options.to === "string" ? options.to : options.to.email}`,
+326
src/lib/pagination.test.ts
+326
src/lib/pagination.test.ts
···+"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",+"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",+"INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)",+"INSERT INTO transcriptions (id, user_id, filename, original_filename, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",+"INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)",+"INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)",
+12
-10
src/lib/rate-limit.ts
+12
-10
src/lib/rate-limit.ts
···
+2
-2
src/lib/subscription-routes.test.ts
+2
-2
src/lib/subscription-routes.test.ts
······
+21
-6
src/lib/transcription.ts
+21
-6
src/lib/transcription.ts
······
+118
src/lib/validation.test.ts
+118
src/lib/validation.test.ts
···
+223
src/lib/validation.ts
+223
src/lib/validation.ts
···+/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
+227
src/lib/voting.ts
+227
src/lib/voting.ts
···+let query = `SELECT id, user_id, filename, original_filename, vote_count, created_at, section_id+`[Voting] Auto-submitting ${topRecording.id} - reached ${topRecording.vote_count}/${voteThreshold} votes (40% threshold)`,
+10
src/lib/vtt-cleaner.test.ts
+10
src/lib/vtt-cleaner.test.ts
···
+8
-8
src/lib/vtt-cleaner.ts
+8
-8
src/lib/vtt-cleaner.ts
···-"[VTTCleaner] LLM configuration incomplete (need LLM_API_KEY, LLM_API_BASE_URL, LLM_MODEL), returning uncleaned VTT",
+4
-254
src/pages/admin.html
+4
-254
src/pages/admin.html
···<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">······
+151
src/pages/admin.ts
+151
src/pages/admin.ts
···
+2
-85
src/pages/index.html
+2
-85
src/pages/index.html
···<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">···
+14
src/pages/index.ts
+14
src/pages/index.ts
···
+2
-20
src/pages/reset-password.html
+2
-20
src/pages/reset-password.html
···<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">···
+10
src/pages/reset-password.ts
+10
src/pages/reset-password.ts
···
+1
-6
src/pages/settings.html
+1
-6
src/pages/settings.html
···<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
+3
-21
src/pages/transcribe.html
+3
-21
src/pages/transcribe.html
···<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">···-<a href="/classes" style="color: var(--paynes-gray); text-decoration: none; font-size: 0.875rem;">
+120
src/styles/admin.css
+120
src/styles/admin.css
···
+71
src/styles/index.css
+71
src/styles/index.css
···
+6
src/styles/reset-password.css
+6
src/styles/reset-password.css
+27
src/styles/transcribe.css
+27
src/styles/transcribe.css
···