vault backup: 2025-12-22 12:22:20
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m12s
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m12s
This commit is contained in:
10
AGENT.md
10
AGENT.md
@@ -5,6 +5,7 @@ Avoid extra new lines, prefer fewer new lines and compact formatting.
|
||||
|
||||
# Coding
|
||||
The rest of this document describes coding conventions to follow when writing code.
|
||||
|
||||
## General
|
||||
- Do not create comments nor docstrings when updating code unless asked.
|
||||
- Do not create a summary .md file unless asked.
|
||||
@@ -12,6 +13,8 @@ The rest of this document describes coding conventions to follow when writing co
|
||||
- When installing a new dependency, prefer to use the latest version.
|
||||
|
||||
## Python
|
||||
- Use uv for handling python installations
|
||||
- Use pyproject.toml to handle dependencies
|
||||
- Avoid exceptions if possible
|
||||
- When exceptions are necessary, use specific exception types, provide meaningful messages, and handle them appropriately.
|
||||
- Exceptions try/except blocks should be as narrow as possible, try extra hard to avoid catching exceptions you did not intend to catch.
|
||||
@@ -19,6 +22,7 @@ The rest of this document describes coding conventions to follow when writing co
|
||||
|
||||
## Python unit testing:
|
||||
- Use pytest framework, version 9 or higher.
|
||||
- Configure using pyproject.toml, avoid pytest.ini to be able to have all configuration in one place.
|
||||
- Prefer parametrized tests for functions that need to be tested with multiple sets of inputs and expected outputs.
|
||||
- Use the new `with pytest.test(...)´ for that purpose:
|
||||
|
||||
@@ -32,3 +36,9 @@ def test_parametrized(subtests: pytest.Subtests) -> None:
|
||||
assert ...
|
||||
# ... tear down ...
|
||||
```
|
||||
|
||||
## Django
|
||||
|
||||
- Prefer function based views over class based views
|
||||
- Use django-stubs for type hints
|
||||
- Use pytest-django for testing django applications
|
||||
|
||||
272
content/.obsidian/workspace.json
vendored
272
content/.obsidian/workspace.json
vendored
@@ -1,105 +1,65 @@
|
||||
{
|
||||
"main": {
|
||||
"id": "5c3e9508c9a74b0c",
|
||||
"id": "19179b278823b064",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "b0a29407702ad8d9",
|
||||
"id": "8dd584e60438200b",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "09f0d52982024b18",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "split-diff-view",
|
||||
"state": {
|
||||
"aFile": "content/.obsidian/plugins/obsidian-icon-folder/data.json",
|
||||
"bFile": "content/.obsidian/plugins/obsidian-icon-folder/data.json",
|
||||
"aRef": ""
|
||||
},
|
||||
"icon": "diff",
|
||||
"title": "Diff: data.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1719ec1bea5bf182",
|
||||
"id": "baa45c5e57825965",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Biokemi/Behöver göra.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Behöver göra"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ef5b64bffe19b453",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Biokemi/Gamla tentor/2024-01-27/29.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "29"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0ee86d2943a05d44",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Biokemi/Metabolism/🩸 Heme/Slides.md",
|
||||
"file": "Anatomi & Histologi 2/1 Öga anatomi/Video.md",
|
||||
"mode": "source",
|
||||
"source": false,
|
||||
"backlinks": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Slides"
|
||||
"title": "Video"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "f5463f24c8d28b65",
|
||||
"id": "7e72057acf1e42f0",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "c1c7815735aa906e",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"type": "pdf",
|
||||
"state": {
|
||||
"file": "Anatomi & Histologi 2/Schema.md",
|
||||
"mode": "source",
|
||||
"source": false,
|
||||
"backlinks": false
|
||||
"file": "Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf"
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "Schema"
|
||||
"icon": "lucide-file-text",
|
||||
"title": "!2023-05-31-0100-DKS"
|
||||
}
|
||||
}
|
||||
],
|
||||
"currentTab": 4
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
},
|
||||
"left": {
|
||||
"id": "2eb9d01da512f2e5",
|
||||
"id": "70dc58e919eddd95",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "7f1ec433ebd415ff",
|
||||
"id": "47a30d427cdfb6db",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "14b12303d7c7ac64",
|
||||
"id": "ef51d026ab2efaae",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical",
|
||||
"sortOrder": "custom",
|
||||
"autoReveal": false
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
@@ -107,68 +67,58 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "500d71ff4998df8b",
|
||||
"id": "eafa93eb6a28e671",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "tag:#provfråga lager ",
|
||||
"query": "spoiler-",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical"
|
||||
"extraContext": true,
|
||||
"sortOrder": "byModifiedTime"
|
||||
},
|
||||
"icon": "lucide-search",
|
||||
"title": "Search"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "0f714096e178591e",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "bookmarks",
|
||||
"state": {},
|
||||
"icon": "lucide-ghost",
|
||||
"title": "bookmarks"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 468.50312423706055
|
||||
"width": 200
|
||||
},
|
||||
"right": {
|
||||
"id": "2d28508435d0e696",
|
||||
"id": "0948c66181b40af9",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "65c571801186bf7b",
|
||||
"id": "8e42749b81d80f27",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "8827fad1b6f2c177",
|
||||
"id": "131da419ce467615",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"file": "Anatomi & Histologi 2/Schema.md",
|
||||
"file": "Biokemi/Plasmidlabb/Provfrågor.md",
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "Outgoing links from Schema"
|
||||
"title": "Outgoing links from Provfrågor"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "74b9983ea2c313f7",
|
||||
"id": "5c1804c056cc2e31",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "tag",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"useHierarchy": true,
|
||||
"useHierarchy": false,
|
||||
"showSearch": true,
|
||||
"searchQuery": ""
|
||||
},
|
||||
@@ -177,7 +127,22 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "758d6d430e3ef7a0",
|
||||
"id": "d4a03ebd29e7b96c",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "Biokemi/Cellulära processer/Translation/Stoff.md",
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Outline of Stoff"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a23e068aac24f909",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "all-properties",
|
||||
@@ -191,22 +156,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1c46c6d504021aa4",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "Biokemi/Cellulära processer/Utforska proteiner/Slides.md",
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Outline of Slides"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "3d06ebe3b85788d4",
|
||||
"id": "41f1a2a8dc1c3ad7",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "git-view",
|
||||
@@ -214,80 +164,102 @@
|
||||
"icon": "git-pull-request",
|
||||
"title": "Source Control"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "42527268e0f60e4a",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-properties",
|
||||
"state": {
|
||||
"file": "Anatomi & Histologi 2/Gamla tentor/Statistik.md"
|
||||
},
|
||||
"icon": "lucide-info",
|
||||
"title": "File properties for Statistik"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "03afb117002fcdff",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "agent-client-chat-view",
|
||||
"state": {},
|
||||
"icon": "bot-message-square",
|
||||
"title": "Agent client"
|
||||
}
|
||||
}
|
||||
],
|
||||
"currentTab": 4
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 423.5
|
||||
"width": 212.5
|
||||
},
|
||||
"left-ribbon": {
|
||||
"hiddenItems": {
|
||||
"agent-client:Open agent client": false,
|
||||
"obsidian42-brat:BRAT": false,
|
||||
"switcher:Open quick switcher": false,
|
||||
"graph:Open graph view": false,
|
||||
"canvas:Create new canvas": false,
|
||||
"templates:Insert template": false,
|
||||
"command-palette:Open command palette": false,
|
||||
"bases:Create new base": false,
|
||||
"obsidian-git:Open Git source control": false,
|
||||
"omnisearch:Omnisearch": false,
|
||||
"obsidian42-brat:BRAT": false
|
||||
"bases:Create new base": false,
|
||||
"canvas:Create new canvas": false,
|
||||
"graph:Open graph view": false,
|
||||
"templates:Insert template": false,
|
||||
"agent-client:Open agent client": false
|
||||
}
|
||||
},
|
||||
"active": "f5463f24c8d28b65",
|
||||
"active": "baa45c5e57825965",
|
||||
"lastOpenFiles": [
|
||||
"test_folder",
|
||||
"conflict-files-obsidian-git.md",
|
||||
"Anatomi & Histologi 2/Schema.md",
|
||||
"Anatomi & Histologi 2/1 Öga anatomi/Video.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/8.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/7.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/6.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/5.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/4.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/3.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/2.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/1.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf",
|
||||
"Anatomi & Histologi 2/Statistik.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/1.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/29.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/30.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/28.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/27.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/26.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/!2023-01-11-0044-PRX.pdf",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/25.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/24.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/23.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/22.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/21.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/20.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/19.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/18.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/17.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2022-06-01/!2022-06-01-0101-MGY.pdf",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2022-01-15/!2022-01-15-0032-BWD.pdf",
|
||||
"attachments/image-121.png",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0009-RYY.pdf",
|
||||
"z-Tech/tag_exam_questions_v2.sh",
|
||||
"z-Tech/tag_exam_questions.sh",
|
||||
"z-Tech/Quiz-app.md",
|
||||
"z-Tech/Obsidian AI.md",
|
||||
"attachments/image.png",
|
||||
"attachments/image-99.png",
|
||||
"attachments/image-98.png",
|
||||
"attachments/image-97.png",
|
||||
"attachments/image-96.png",
|
||||
"attachments/image-95.png",
|
||||
"attachments/image-94.png",
|
||||
"attachments/image-93.png",
|
||||
"attachments/image-92.png",
|
||||
"attachments/image-91.png",
|
||||
"Anatomi & Histologi 2/Statistik.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-08-08/!2025-08-08-0030-SHJ.pdf",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-08-08/!2025-08-08-0030-SHJ.md",
|
||||
"attachments/image-120.png",
|
||||
"attachments/image-119.png",
|
||||
"attachments/image-118.png",
|
||||
"attachments/image-117.png",
|
||||
"attachments/image-116.png",
|
||||
"attachments/image-115.png",
|
||||
"attachments/image-114.png",
|
||||
"attachments/image-113.png",
|
||||
"attachments/image-112.png",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-06-03/!2025-06-03-0003-UJR.pdf",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-06-03/!2025-06-03-0003-UJR.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-02-08/!2025-02-08.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-02-08/!2025-02-08-0004-PNZ.pdf",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-02-08/!2025-02-08-0003-ESW.pdf",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-02-08/!2025-02-08-0003-ESW.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-01-15/!2025-01-15-0024-HFS.pdf",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-01-15/!2025-01-15-0021-HRY.pdf",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2025-01-15/!2025-01-15-0021-HRY.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2024-05-29/!2024-05-29-0125-GZX.pdf",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2024-05-29/!2024-05-29-0125-GZX.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0088-KOM.pdf",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0088-KOM.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/!2023-01-11-0044-PRX.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2022-06-01/!2022-06-01-0101-MGY.md",
|
||||
"Anatomi & Histologi 2/Gamla tentor/2022-01-15/!2022-01-15-0032-BWD.md",
|
||||
"Anatomi & Histologi 2/4 Öra histologi/Målbeskrivning.md",
|
||||
"Anatomi & Histologi 2/4 Öra histologi/Instuderingsfrågor.md",
|
||||
"Anatomi & Histologi 2/4 Öra histologi/25 Ear.md",
|
||||
"Anatomi & Histologi 2/3 Öga histologi/Slides.md",
|
||||
"Anatomi & Histologi 2/3 Öga histologi/Målbeskrivning.md",
|
||||
"Anatomi & Histologi 2/3 Öga histologi/Instuderingsfrågor.md",
|
||||
"Anatomi & Histologi 2/3 Öga histologi/24 Eye.md",
|
||||
"Anatomi & Histologi 2/2 Öra anatomi/Slides.md",
|
||||
"Anatomi & Histologi 2/2 Öra anatomi/Målbeskrivning.md",
|
||||
"Untitled.canvas",
|
||||
"Biokemi/Metabolism/👋 Introduktion till metabolismen/Untitled.canvas",
|
||||
"Biokemi/Metabolism/📋 Metabolismen översikt.canvas",
|
||||
"Biokemi/Metabolism/Metabolismen översikt.canvas",
|
||||
"Biokemi/Metabolism/Introduktion till metabolismen/Untitled.canvas"
|
||||
"🧪 Biokemi/🏋️♀️ Metabolism/📋 Metabolismen översikt.canvas",
|
||||
"🧪 Biokemi/🏋️♀️ Metabolism/👋 Introduktion till metabolismen/Untitled.canvas"
|
||||
]
|
||||
}
|
||||
4
content/Anatomi & Histologi 2/1 Öga anatomi/Video.md
Normal file
4
content/Anatomi & Histologi 2/1 Öga anatomi/Video.md
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -1,26 +1,35 @@
|
||||
from django import forms
|
||||
from django.db.models import Count
|
||||
from .models import Course, Tag
|
||||
from .models import Course, Tag, Exam
|
||||
|
||||
class TagModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
return f"{obj.name} ({obj.question_count})"
|
||||
|
||||
class ExamModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj):
|
||||
return f"{obj.course.name} - {obj.date}"
|
||||
|
||||
class CreateQuizForm(forms.Form):
|
||||
course = forms.ModelChoiceField(
|
||||
queryset=Course.objects.all(),
|
||||
required=False,
|
||||
empty_label="All Courses",
|
||||
empty_label="Alla kurser",
|
||||
widget=forms.Select(attrs={'class': 'form-control'})
|
||||
)
|
||||
exams = ExamModelMultipleChoiceField(
|
||||
queryset=Exam.objects.all(),
|
||||
required=False,
|
||||
widget=forms.SelectMultiple(attrs={'class': 'form-control'})
|
||||
)
|
||||
tags = TagModelMultipleChoiceField(
|
||||
queryset=Tag.objects.annotate(question_count=Count('questions')).order_by('name'),
|
||||
required=False,
|
||||
widget=forms.SelectMultiple(attrs={'class': 'form-control'})
|
||||
)
|
||||
QUESTION_TYPES = [
|
||||
('single', 'Single Choice'),
|
||||
('multi', 'Multiple Choice'),
|
||||
('single', 'Envalsfrågor'),
|
||||
('multi', 'Flervalsfrågor'),
|
||||
]
|
||||
question_type = forms.MultipleChoiceField(
|
||||
choices=QUESTION_TYPES,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0 on 2025-12-22 11:06
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('quiz', '0006_tag_question_tags'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='QuizSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='quiz.course')),
|
||||
('exams', models.ManyToManyField(blank=True, to='quiz.exam')),
|
||||
('tags', models.ManyToManyField(blank=True, to='quiz.tag')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quiz_sessions', to='quiz.quizuser')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='quizresult',
|
||||
name='quiz_session',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='results', to='quiz.quizsession'),
|
||||
),
|
||||
]
|
||||
18
quiz/quiz/migrations/0008_quizsession_question_types.py
Normal file
18
quiz/quiz/migrations/0008_quizsession_question_types.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2025-12-22 11:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('quiz', '0007_quizsession_quizresult_quiz_session'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='quizsession',
|
||||
name='question_types',
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
]
|
||||
18
quiz/quiz/migrations/0009_quizresult_difficulty.py
Normal file
18
quiz/quiz/migrations/0009_quizresult_difficulty.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2025-12-22 11:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('quiz', '0008_quizsession_question_types'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='quizresult',
|
||||
name='difficulty',
|
||||
field=models.CharField(blank=True, choices=[('again', 'Again'), ('hard', 'Hard'), ('good', 'Good'), ('easy', 'Easy')], max_length=10, null=True),
|
||||
),
|
||||
]
|
||||
@@ -72,11 +72,34 @@ class Option(models.Model):
|
||||
return f"{self.letter}. {self.text[:30]}"
|
||||
|
||||
|
||||
class QuizSession(models.Model):
|
||||
user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='quiz_sessions')
|
||||
course = models.ForeignKey(Course, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
exams = models.ManyToManyField(Exam, blank=True)
|
||||
tags = models.ManyToManyField(Tag, blank=True)
|
||||
question_types = models.JSONField(default=list, blank=True) # Store as list of strings
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Session {self.id} for {self.user}"
|
||||
|
||||
|
||||
class QuizResult(models.Model):
|
||||
user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='results')
|
||||
quiz_session = models.ForeignKey(QuizSession, on_delete=models.CASCADE, related_name='results', null=True, blank=True)
|
||||
question = models.ForeignKey(Question, on_delete=models.CASCADE)
|
||||
selected_answer = models.CharField(max_length=1)
|
||||
is_correct = models.BooleanField()
|
||||
difficulty = models.CharField(max_length=10, blank=True, null=True, choices=[
|
||||
('again', 'Again'),
|
||||
('hard', 'Hard'),
|
||||
('good', 'Good'),
|
||||
('easy', 'Easy'),
|
||||
])
|
||||
answered_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
|
||||
from .views import index, get_next_question, submit_answer, stats, create_quiz
|
||||
from .views import (
|
||||
index, get_next_question, submit_answer, stats, create_quiz, close_quiz,
|
||||
quiz_mode, quiz_question, navigate_question, submit_difficulty
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('', index, name='index'),
|
||||
path('next/', get_next_question, name='next_question'),
|
||||
path('submit/', submit_answer, name='submit_answer'),
|
||||
path('quiz/<int:session_id>/', quiz_mode, name='quiz_mode'),
|
||||
path('quiz/<int:session_id>/question/', quiz_question, name='quiz_question'),
|
||||
path('quiz/<int:session_id>/<str:direction>/', navigate_question, name='navigate_question'),
|
||||
path('next/<int:session_id>/', get_next_question, name='next_question'),
|
||||
path('submit/<int:session_id>/', submit_answer, name='submit_answer'),
|
||||
path('difficulty/<int:session_id>/', submit_difficulty, name='submit_difficulty'),
|
||||
path('close/<int:session_id>/', close_quiz, name='close_quiz'),
|
||||
path('stats/', stats, name='stats'),
|
||||
path('create/', create_quiz, name='create_quiz'),
|
||||
]
|
||||
|
||||
@@ -4,10 +4,18 @@ from django.urls import reverse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.db.models import Q
|
||||
|
||||
from .models import Question, QuizResult, Tag
|
||||
from .models import Question, QuizResult, Tag, Course, Exam, QuizSession
|
||||
from .forms import CreateQuizForm
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def close_quiz(request, session_id):
|
||||
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
|
||||
session.is_active = False
|
||||
session.save()
|
||||
return redirect('index')
|
||||
|
||||
|
||||
def handle_tag_filter(request):
|
||||
tag_slug = request.GET.get('tag')
|
||||
if tag_slug is not None:
|
||||
@@ -21,95 +29,181 @@ def create_quiz(request):
|
||||
if request.method == 'POST':
|
||||
form = CreateQuizForm(request.POST)
|
||||
if form.is_valid():
|
||||
# clear existing session data
|
||||
keys_to_clear = ['quiz_filter_course_id', 'quiz_filter_tag_ids', 'quiz_filter_types', 'quiz_tag']
|
||||
for key in keys_to_clear:
|
||||
if key in request.session:
|
||||
del request.session[key]
|
||||
|
||||
course = form.cleaned_data.get('course')
|
||||
exams = form.cleaned_data.get('exams')
|
||||
tags = form.cleaned_data.get('tags')
|
||||
question_types = form.cleaned_data.get('question_type')
|
||||
|
||||
if course:
|
||||
request.session['quiz_filter_course_id'] = course.id
|
||||
q_types = form.cleaned_data.get('question_type')
|
||||
|
||||
session = QuizSession.objects.create(
|
||||
user=request.quiz_user,
|
||||
course=course,
|
||||
question_types=q_types if q_types else []
|
||||
)
|
||||
if tags:
|
||||
request.session['quiz_filter_tag_ids'] = list(tags.values_list('id', flat=True))
|
||||
session.tags.set(tags)
|
||||
if exams:
|
||||
session.exams.set(exams)
|
||||
|
||||
if question_types:
|
||||
request.session['quiz_filter_types'] = question_types
|
||||
|
||||
return redirect('next_question')
|
||||
return redirect('index')
|
||||
else:
|
||||
form = CreateQuizForm()
|
||||
|
||||
return render(request, 'quiz_create.html', {'form': form})
|
||||
|
||||
def index(request):
|
||||
handle_tag_filter(request)
|
||||
active_sessions = QuizSession.objects.filter(user=request.quiz_user, is_active=True)
|
||||
total_questions = Question.objects.count()
|
||||
answered_count = QuizResult.objects.filter(user=request.quiz_user).count()
|
||||
|
||||
context = {
|
||||
'total_questions': total_questions,
|
||||
'answered_count': answered_count,
|
||||
'tags': Tag.objects.all(),
|
||||
'current_tag': request.session.get('quiz_tag'),
|
||||
'active_sessions': active_sessions,
|
||||
'form': CreateQuizForm(), # Include form on landing page
|
||||
}
|
||||
return render(request, 'index.html', context)
|
||||
|
||||
|
||||
def get_next_question(request):
|
||||
# Handle tag filtering
|
||||
handle_tag_filter(request)
|
||||
def quiz_mode(request, session_id):
|
||||
"""Dedicated quiz mode view"""
|
||||
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user, is_active=True)
|
||||
return render(request, 'quiz_mode.html', {'session': session})
|
||||
|
||||
current_tag = request.session.get('quiz_tag')
|
||||
|
||||
# New filters
|
||||
filter_course_id = request.session.get('quiz_filter_course_id')
|
||||
filter_tag_ids = request.session.get('quiz_filter_tag_ids')
|
||||
filter_types = request.session.get('quiz_filter_types')
|
||||
def get_session_questions(session):
|
||||
"""Helper to get filtered questions for a session"""
|
||||
questions = Question.objects.all()
|
||||
|
||||
answered_ids = QuizResult.objects.filter(user=request.quiz_user).values_list('question_id', flat=True)
|
||||
if session.course:
|
||||
questions = questions.filter(exam__course=session.course)
|
||||
|
||||
questions = Question.objects.exclude(id__in=answered_ids)
|
||||
if session.tags.exists():
|
||||
questions = questions.filter(tags__in=session.tags.all())
|
||||
|
||||
# Apply filters
|
||||
if current_tag:
|
||||
questions = questions.filter(tags__slug=current_tag)
|
||||
if session.exams.exists():
|
||||
questions = questions.filter(exam__in=session.exams.all())
|
||||
|
||||
if filter_course_id:
|
||||
questions = questions.filter(exam__course_id=filter_course_id)
|
||||
|
||||
if filter_tag_ids:
|
||||
questions = questions.filter(tags__id__in=filter_tag_ids)
|
||||
|
||||
if filter_types:
|
||||
# "single" -> no comma
|
||||
# "multi" -> comma
|
||||
if session.question_types:
|
||||
q_objs = Q()
|
||||
if 'single' in filter_types:
|
||||
if 'single' in session.question_types:
|
||||
q_objs |= ~Q(correct_answer__contains=',')
|
||||
if 'multi' in filter_types:
|
||||
if 'multi' in session.question_types:
|
||||
q_objs |= Q(correct_answer__contains=',')
|
||||
|
||||
if q_objs:
|
||||
questions = questions.filter(q_objs)
|
||||
|
||||
# Distinguish questions based on filters to ensure we don't get duplicates if filtering by many-to-many
|
||||
questions = questions.distinct()
|
||||
return questions.distinct()
|
||||
|
||||
next_question = questions.first()
|
||||
|
||||
if not next_question:
|
||||
return render(request, 'partials/complete.html')
|
||||
def quiz_question(request, session_id):
|
||||
"""Get current question in quiz mode"""
|
||||
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
|
||||
|
||||
return render(request, 'partials/question.html', {'question': next_question})
|
||||
# Get all questions for this session
|
||||
all_questions = get_session_questions(session)
|
||||
|
||||
# Get answered questions
|
||||
answered_ids = QuizResult.objects.filter(
|
||||
user=request.quiz_user,
|
||||
quiz_session=session
|
||||
).values_list('question_id', flat=True)
|
||||
|
||||
# Get unanswered questions
|
||||
unanswered = all_questions.exclude(id__in=answered_ids)
|
||||
|
||||
# Default to first unanswered question, or first question if all answered
|
||||
if unanswered.exists():
|
||||
question = unanswered.first()
|
||||
show_answer = False
|
||||
else:
|
||||
# All answered, show first question
|
||||
question = all_questions.first()
|
||||
if question:
|
||||
result = QuizResult.objects.filter(
|
||||
user=request.quiz_user,
|
||||
quiz_session=session,
|
||||
question=question
|
||||
).first()
|
||||
show_answer = result is not None
|
||||
else:
|
||||
return render(request, 'partials/complete.html', {'session': session})
|
||||
|
||||
# Calculate navigation
|
||||
all_q_ids = list(all_questions.values_list('id', flat=True))
|
||||
current_index = all_q_ids.index(question.id) if question.id in all_q_ids else 0
|
||||
|
||||
context = {
|
||||
'question': question,
|
||||
'session': session,
|
||||
'show_answer': show_answer,
|
||||
'has_previous': current_index > 0,
|
||||
'has_next': current_index < len(all_q_ids) - 1,
|
||||
}
|
||||
|
||||
if show_answer:
|
||||
result = QuizResult.objects.get(
|
||||
user=request.quiz_user,
|
||||
quiz_session=session,
|
||||
question=question
|
||||
)
|
||||
context['is_correct'] = result.is_correct
|
||||
|
||||
return render(request, 'partials/quiz_question.html', context)
|
||||
|
||||
|
||||
def navigate_question(request, session_id, direction):
|
||||
"""Navigate to previous/next question"""
|
||||
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
|
||||
|
||||
# Get current question from session or query params
|
||||
current_q_id = request.GET.get('q')
|
||||
|
||||
all_questions = get_session_questions(session)
|
||||
all_q_ids = list(all_questions.values_list('id', flat=True))
|
||||
|
||||
if current_q_id:
|
||||
try:
|
||||
current_index = all_q_ids.index(int(current_q_id))
|
||||
except (ValueError, IndexError):
|
||||
current_index = 0
|
||||
else:
|
||||
current_index = 0
|
||||
|
||||
# Navigate
|
||||
if direction == 'previous' and current_index > 0:
|
||||
new_index = current_index - 1
|
||||
elif direction == 'next' and current_index < len(all_q_ids) - 1:
|
||||
new_index = current_index + 1
|
||||
else:
|
||||
new_index = current_index
|
||||
|
||||
question = all_questions.filter(id=all_q_ids[new_index]).first()
|
||||
|
||||
# Check if answered
|
||||
result = QuizResult.objects.filter(
|
||||
user=request.quiz_user,
|
||||
quiz_session=session,
|
||||
question=question
|
||||
).first()
|
||||
|
||||
context = {
|
||||
'question': question,
|
||||
'session': session,
|
||||
'show_answer': result is not None,
|
||||
'has_previous': new_index > 0,
|
||||
'has_next': new_index < len(all_q_ids) - 1,
|
||||
}
|
||||
|
||||
if result:
|
||||
context['is_correct'] = result.is_correct
|
||||
|
||||
return render(request, 'partials/quiz_question.html', context)
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def submit_answer(request):
|
||||
def submit_answer(request, session_id):
|
||||
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
|
||||
question_id = request.POST.get('question_id')
|
||||
selected_answer = request.POST.get('answer')
|
||||
|
||||
@@ -126,13 +220,93 @@ def submit_answer(request):
|
||||
QuizResult.objects.update_or_create(
|
||||
user=request.quiz_user,
|
||||
question=question,
|
||||
quiz_session=session,
|
||||
defaults={
|
||||
'selected_answer': selected_answer,
|
||||
'is_correct': is_correct,
|
||||
}
|
||||
)
|
||||
|
||||
return get_next_question(request)
|
||||
# Return the same question but with answer shown
|
||||
all_questions = get_session_questions(session)
|
||||
all_q_ids = list(all_questions.values_list('id', flat=True))
|
||||
current_index = all_q_ids.index(question.id) if question.id in all_q_ids else 0
|
||||
|
||||
context = {
|
||||
'question': question,
|
||||
'session': session,
|
||||
'show_answer': True,
|
||||
'is_correct': is_correct,
|
||||
'has_previous': current_index > 0,
|
||||
'has_next': current_index < len(all_q_ids) - 1,
|
||||
}
|
||||
|
||||
return render(request, 'partials/quiz_question.html', context)
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
def submit_difficulty(request, session_id):
|
||||
"""Record difficulty rating for FSRS"""
|
||||
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
|
||||
question_id = request.POST.get('question_id')
|
||||
difficulty = request.POST.get('difficulty')
|
||||
|
||||
if not question_id or not difficulty:
|
||||
return HttpResponse("Invalid submission", status=400)
|
||||
|
||||
try:
|
||||
result = QuizResult.objects.get(
|
||||
user=request.quiz_user,
|
||||
quiz_session=session,
|
||||
question_id=question_id
|
||||
)
|
||||
result.difficulty = difficulty
|
||||
result.save()
|
||||
return HttpResponse("OK")
|
||||
except QuizResult.DoesNotExist:
|
||||
return HttpResponse("Result not found", status=404)
|
||||
|
||||
|
||||
def get_next_question(request, session_id):
|
||||
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
|
||||
|
||||
answered_ids = QuizResult.objects.filter(
|
||||
user=request.quiz_user,
|
||||
quiz_session=session
|
||||
).values_list('question_id', flat=True)
|
||||
|
||||
questions = Question.objects.exclude(id__in=answered_ids)
|
||||
|
||||
# Apply filters from session
|
||||
if session.course:
|
||||
questions = questions.filter(exam__course=session.course)
|
||||
|
||||
if session.tags.exists():
|
||||
questions = questions.filter(tags__in=session.tags.all())
|
||||
|
||||
if session.exams.exists():
|
||||
questions = questions.filter(exam__in=session.exams.all())
|
||||
|
||||
if session.question_types:
|
||||
q_objs = Q()
|
||||
if 'single' in session.question_types:
|
||||
q_objs |= ~Q(correct_answer__contains=',')
|
||||
if 'multi' in session.question_types:
|
||||
q_objs |= Q(correct_answer__contains=',')
|
||||
|
||||
if q_objs:
|
||||
questions = questions.filter(q_objs)
|
||||
|
||||
questions = questions.distinct()
|
||||
next_question = questions.first()
|
||||
|
||||
if not next_question:
|
||||
return render(request, 'partials/complete.html', {'session': session})
|
||||
|
||||
return render(request, 'partials/question.html', {
|
||||
'question': next_question,
|
||||
'session': session
|
||||
})
|
||||
|
||||
|
||||
def stats(request):
|
||||
@@ -146,4 +320,3 @@ def stats(request):
|
||||
'percentage': round((correct / total * 100) if total > 0 else 0, 1),
|
||||
}
|
||||
return render(request, 'stats.html', context)
|
||||
|
||||
|
||||
@@ -3,19 +3,170 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Quiz</title>
|
||||
<title>Medical Quiz</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
|
||||
.question { background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0; }
|
||||
.option { padding: 10px; margin: 5px 0; cursor: pointer; border: 2px solid #ddd; border-radius: 4px; }
|
||||
.option:hover { background: #e9e9e9; }
|
||||
.progress { background: #ddd; height: 20px; border-radius: 10px; margin: 20px 0; }
|
||||
.progress-bar { background: #4CAF50; height: 100%; border-radius: 10px; transition: width 0.3s; }
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--primary-hover: #4f46e5;
|
||||
--bg: #f8fafc;
|
||||
--card-bg: rgba(255, 255, 255, 0.8);
|
||||
--text-main: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--glass-bg: rgba(255, 255, 255, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-main);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: 700;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
background: var(--border);
|
||||
height: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: var(--primary);
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
select, input, textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background-color: white;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -1,53 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.filter-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
<div class="header" style="margin-bottom: 3rem; display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<h1 style="font-size: 2.5rem; margin-bottom: 0.5rem;">Välkommen</h1>
|
||||
<p style="color: var(--text-muted);">Här kan du hantera dina medicinska quiz.</p>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="font-weight: 600; font-size: 1.25rem;">{{ answered_count }} / {{ total_questions }}</div>
|
||||
<div style="font-size: 0.875rem; color: var(--text-muted);">Frågor besvarade</div>
|
||||
<div class="progress-container" style="width: 150px; margin-top: 0.5rem;">
|
||||
<div class="progress-bar"
|
||||
style="width: {% if total_questions > 0 %}{{ answered_count|add:0|floatformat:2 }}{% else %}0{% endif %}%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
.tag-chip {
|
||||
display: inline-block;
|
||||
padding: 5px 12px;
|
||||
margin: 4px;
|
||||
border-radius: 16px;
|
||||
background: #e0e0e0;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
<h2 style="margin-bottom: 1.5rem;">Aktiva Quiz</h2>
|
||||
{% if active_sessions %}
|
||||
<div class="grid">
|
||||
{% for session in active_sessions %}
|
||||
<div class="glass-card session-card" id="session-{{ session.id }}">
|
||||
<div class="session-header">
|
||||
<div>
|
||||
<div class="session-title">
|
||||
{% if session.course %}{{ session.course.name }}{% else %}Blandat Quiz{% endif %}
|
||||
</div>
|
||||
<div class="session-meta">
|
||||
Startat {{ session.created_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
<form action="{% url 'close_quiz' session.id %}" method="post" hx-post="{% url 'close_quiz' session.id %}"
|
||||
hx-target="#session-{{ session.id }}" hx-swap="outerHTML">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-secondary"
|
||||
style="padding: 0.25rem 0.5rem; color: #ef4444;">Stäng</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
.tag-chip.active {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
{% if session.tags.exists %}
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
{% for tag in session.tags.all %}
|
||||
<span
|
||||
style="font-size: 0.75rem; background: #e0e7ff; color: #4338ca; padding: 0.25rem 0.5rem; border-radius: 1rem;">{{
|
||||
tag.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
.tag-chip:hover {
|
||||
background: #d5d5d5;
|
||||
}
|
||||
|
||||
.tag-chip.active:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Quiz Application</h1>
|
||||
|
||||
<div class="filter-section">
|
||||
<a href="{% url 'create_quiz' %}" class="tag-chip" style="background: #2196F3; color: white;">+ New Quiz</a>
|
||||
<a href="?tag=" class="tag-chip {% if not current_tag %}active{% endif %}">All</a>
|
||||
{% for tag in tags %}
|
||||
<a href="?tag={{ tag.slug }}" class="tag-chip {% if current_tag == tag.slug %}active{% endif %}">
|
||||
{{ tag.name }}
|
||||
<div style="margin-top: auto;">
|
||||
<a href="{% url 'quiz_mode' session.id %}" class="btn btn-primary" style="width: 100%; text-align: center;">
|
||||
Fortsätt Quiz
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="progress">
|
||||
<div class="progress-bar"
|
||||
style="width: {% if total_questions > 0 %}{{ answered_count|floatformat:0 }}{% else %}0{% endif %}%"></div>
|
||||
{% else %}
|
||||
<div class="glass-card" style="text-align: center; color: var(--text-muted); padding: 3rem;">
|
||||
Inga aktiva quiz. Starta ett nytt nedan!
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top: 4rem;">
|
||||
<h2 style="margin-bottom: 1.5rem;">Starta Nytt Quiz</h2>
|
||||
<div class="glass-card">
|
||||
<form method="post" action="{% url 'create_quiz' %}">
|
||||
{% csrf_token %}
|
||||
<div class="grid">
|
||||
<div class="form-group">
|
||||
<label for="{{ form.course.id_for_label }}">Kurs</label>
|
||||
{{ form.course }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tags.id_for_label }}">Taggar</label>
|
||||
{{ form.tags }}
|
||||
<small style="color: var(--text-muted); margin-top: 0.25rem; display: block;">Håll ner Ctrl/Cmd för
|
||||
att välja flera.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 1rem;">
|
||||
<button type="submit" class="btn btn-primary" style="padding-left: 2rem; padding-right: 2rem;">Skapa
|
||||
Quiz</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<p>Besvarade frågor: {{ answered_count }} / {{ total_questions }}</p>
|
||||
<div id="quiz-container" hx-get="{% url 'next_question' %}" hx-trigger="load"></div>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,13 @@
|
||||
<div class="question">
|
||||
<h2>Quiz Completed!</h2>
|
||||
<p>Du har besvarat alla frågor.</p>
|
||||
<a href="{% url 'stats' %}">Se dina resultat</a>
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">🎉</div>
|
||||
<h2 style="margin-bottom: 1rem;">Quiz Slutfört!</h2>
|
||||
<p style="color: var(--text-muted); margin-bottom: 2rem;">Bra jobbat! Du har besvarat alla tillgängliga frågor i
|
||||
detta urval.</p>
|
||||
<div style="display: flex; gap: 1rem; justify-content: center;">
|
||||
<a href="{% url 'stats' %}" class="btn btn-secondary">Se Statistik</a>
|
||||
<form action="{% url 'close_quiz' session.id %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-primary">Avsluta & Gå Tillbaka</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<div class="question">
|
||||
<h2>{{ question.text }}</h2>
|
||||
<form hx-post="{% url 'submit_answer' %}" hx-target="#quiz-container">
|
||||
<div class="quiz-content">
|
||||
<h3 style="margin-bottom: 1.5rem;">{{ question.text }}</h3>
|
||||
<form hx-post="{% url 'submit_answer' session.id %}" hx-target="#quiz-container-{{ session.id }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="question_id" value="{{ question.id }}">
|
||||
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
{% for option in question.options.all %}
|
||||
<div class="option" onclick="this.querySelector('input').checked = true; this.closest('form').requestSubmit();">
|
||||
<input type="radio" name="answer" value="{{ option.letter }}" id="opt_{{ option.letter }}" style="display:none;">
|
||||
<label for="opt_{{ option.letter }}">{{ option.letter }}. {{ option.text }}</label>
|
||||
<div class="option"
|
||||
style="padding: 1rem; border: 1px solid var(--border); border-radius: 0.5rem; cursor: pointer; transition: all 0.2s;"
|
||||
onmouseover="this.style.backgroundColor='#f1f5f9'" onmouseout="this.style.backgroundColor='transparent'"
|
||||
onclick="this.querySelector('input').checked = true; this.closest('form').requestSubmit();">
|
||||
<input type="radio" name="answer" value="{{ option.letter }}"
|
||||
id="opt_{{ session.id }}_{{ question.id }}_{{ option.letter }}" style="display:none;">
|
||||
<label style="cursor: pointer; font-weight: 500; color: var(--text-main);"
|
||||
for="opt_{{ session.id }}_{{ question.id }}_{{ option.letter }}">
|
||||
<span style="display: inline-block; width: 1.5rem; color: var(--primary); font-weight: 700;">{{
|
||||
option.letter }}</span>
|
||||
{{ option.text }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
106
quiz/templates/partials/quiz_question.html
Normal file
106
quiz/templates/partials/quiz_question.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% if show_answer %}
|
||||
<div class="answer-feedback {{ 'correct' if is_correct else 'incorrect' }}">
|
||||
{% if is_correct %}
|
||||
✓ Rätt svar!
|
||||
{% else %}
|
||||
✗ Fel svar. Rätt svar är: {{ question.correct_answer }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="question-text">{{ question.text }}</div>
|
||||
|
||||
<div class="options-container">
|
||||
{% for option in question.options.all %}
|
||||
<div class="option-item" id="option-{{ option.letter }}"
|
||||
onclick="selectOption('{{ option.letter }}', {{ question.id }}, {{ session.id }})">
|
||||
<span class="option-letter">{{ option.letter }}</span>
|
||||
<span>{{ option.text }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if show_answer %}
|
||||
<div class="difficulty-section">
|
||||
<div class="difficulty-label">Hur svårt var detta?</div>
|
||||
<div class="difficulty-buttons">
|
||||
<button class="difficulty-btn again" onclick="submitDifficulty('again', {{ question.id }}, {{ session.id }})">
|
||||
<div>Igen</div>
|
||||
<small style="font-size: 0.75rem; font-weight: 400;"><1m</small>
|
||||
</button>
|
||||
<button class="difficulty-btn hard" onclick="submitDifficulty('hard', {{ question.id }}, {{ session.id }})">
|
||||
<div>Svårt</div>
|
||||
<small style="font-size: 0.75rem; font-weight: 400;"><6m</small>
|
||||
</button>
|
||||
<button class="difficulty-btn good" onclick="submitDifficulty('good', {{ question.id }}, {{ session.id }})">
|
||||
<div>Bra</div>
|
||||
<small style="font-size: 0.75rem; font-weight: 400;"><10m</small>
|
||||
</button>
|
||||
<button class="difficulty-btn easy" onclick="submitDifficulty('easy', {{ question.id }}, {{ session.id }})">
|
||||
<div>Lätt</div>
|
||||
<small style="font-size: 0.75rem; font-weight: 400;">4d</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="nav-buttons">
|
||||
<button class="nav-btn" {% if not has_previous %}disabled{% endif %}
|
||||
onclick="navigateQuestion('previous', {{ session.id }})">
|
||||
← Föregående
|
||||
</button>
|
||||
<button class="nav-btn" {% if not has_next %}disabled{% endif %}
|
||||
onclick="navigateQuestion('next', {{ session.id }})">
|
||||
Nästa →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedAnswer = null;
|
||||
|
||||
function selectOption(letter, questionId, sessionId) {
|
||||
if ({{ 'true' if show_answer else 'false' }
|
||||
}) return; // Don't allow changing answer after submission
|
||||
|
||||
selectedAnswer = letter;
|
||||
|
||||
// Visual feedback
|
||||
document.querySelectorAll('.option-item').forEach(opt => {
|
||||
opt.style.borderColor = 'var(--border)';
|
||||
opt.style.background = 'white';
|
||||
});
|
||||
|
||||
const selected = document.getElementById('option-' + letter);
|
||||
selected.style.borderColor = 'var(--primary)';
|
||||
selected.style.background = '#f0f4ff';
|
||||
|
||||
// Submit answer
|
||||
htmx.ajax('POST', `/submit/${sessionId}/`, {
|
||||
target: '#quiz-content',
|
||||
values: {
|
||||
question_id: questionId,
|
||||
answer: letter
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function submitDifficulty(difficulty, questionId, sessionId) {
|
||||
htmx.ajax('POST', `/difficulty/${sessionId}/`, {
|
||||
values: {
|
||||
question_id: questionId,
|
||||
difficulty: difficulty
|
||||
}
|
||||
});
|
||||
|
||||
// Move to next question after a brief delay
|
||||
setTimeout(() => {
|
||||
navigateQuestion('next', sessionId);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function navigateQuestion(direction, sessionId) {
|
||||
htmx.ajax('GET', `/quiz/${sessionId}/${direction}/`, {
|
||||
target: '#quiz-content'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
210
quiz/templates/quiz_mode.html
Normal file
210
quiz/templates/quiz_mode.html
Normal file
@@ -0,0 +1,210 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.quiz-mode-container {
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.quiz-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quiz-card {
|
||||
background: white;
|
||||
border-radius: 1.5rem;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.options-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
padding: 1.25rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.option-item:hover {
|
||||
border-color: var(--primary);
|
||||
background: #f8f9fb;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.option-letter {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 2rem;
|
||||
font-weight: 700;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.difficulty-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.difficulty-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.difficulty-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.difficulty-btn {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid var(--border);
|
||||
background: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.difficulty-btn.again {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.difficulty-btn.again:hover {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.difficulty-btn.hard {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.difficulty-btn.hard:hover {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.difficulty-btn.good {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.difficulty-btn.good:hover {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.difficulty-btn.easy {
|
||||
border-color: #6366f1;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.difficulty-btn.easy:hover {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.nav-btn:disabled {
|
||||
background: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.answer-feedback {
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.answer-feedback.correct {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 2px solid #10b981;
|
||||
}
|
||||
|
||||
.answer-feedback.incorrect {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 2px solid #ef4444;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="quiz-mode-container">
|
||||
<div class="quiz-header">
|
||||
<h1 style="margin: 0;">{{ session.course.name|default:"Quiz" }}</h1>
|
||||
<a href="{% url 'index' %}" class="btn btn-secondary">← Tillbaka till Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="quiz-card" id="quiz-content">
|
||||
<!-- Content loaded via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load first question on page load
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
htmx.ajax('GET', '{% url 'quiz_question' session.id %}', { target: '#quiz-content' });
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
3175
wip/89xdN2rC94A.sv.srt
Normal file
3175
wip/89xdN2rC94A.sv.srt
Normal file
File diff suppressed because it is too large
Load Diff
2585
wip/S6YKeDKX8eE.sv.srt
Normal file
2585
wip/S6YKeDKX8eE.sv.srt
Normal file
File diff suppressed because it is too large
Load Diff
1645
wip/WP9PA8iefHE.sv.srt
Normal file
1645
wip/WP9PA8iefHE.sv.srt
Normal file
File diff suppressed because it is too large
Load Diff
80
wip/download-subs.py
Normal file
80
wip/download-subs.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import glob
|
||||
|
||||
def get_clipboard():
|
||||
result = subprocess.run(['pbpaste'], capture_output=True, text=True)
|
||||
return result.stdout.strip()
|
||||
|
||||
def set_clipboard(text):
|
||||
subprocess.run(['pbcopy'], input=text, text=True)
|
||||
|
||||
def get_url_from_dialog():
|
||||
applescript = '''
|
||||
display dialog "Enter YouTube URL:" default answer "" with title "Download Swedish Subtitles" buttons {"Cancel", "OK"} default button "OK"
|
||||
set userInput to text returned of result
|
||||
return userInput
|
||||
'''
|
||||
try:
|
||||
result = subprocess.run(['osascript', '-e', applescript], capture_output=True, text=True, check=True)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
def clean_subtitles(srt_content):
|
||||
lines = srt_content.strip().split('\n')
|
||||
text_lines = []
|
||||
seen_lines = set()
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.isdigit() or '-->' in line:
|
||||
continue
|
||||
if line in seen_lines:
|
||||
continue
|
||||
seen_lines.add(line)
|
||||
text_lines.append(line)
|
||||
|
||||
return ' '.join(text_lines)
|
||||
|
||||
def download_subtitles(url):
|
||||
cmd = [
|
||||
'yt-dlp',
|
||||
'--write-auto-sub',
|
||||
'--sub-lang', 'sv',
|
||||
'--skip-download',
|
||||
'--convert-subs', 'srt',
|
||||
'-o', '%(id)s.%(ext)s',
|
||||
url
|
||||
]
|
||||
|
||||
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
|
||||
srt_files = glob.glob('*.sv.srt')
|
||||
|
||||
if srt_files:
|
||||
subtitle_file = srt_files[0]
|
||||
with open(subtitle_file, 'r', encoding='utf-8') as f:
|
||||
raw_content = f.read()
|
||||
cleaned_content = clean_subtitles(raw_content)
|
||||
os.remove(subtitle_file)
|
||||
return cleaned_content
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
url = get_clipboard()
|
||||
|
||||
if not url or 'youtube.com' not in url and 'youtu.be' not in url:
|
||||
url = get_url_from_dialog()
|
||||
if not url:
|
||||
sys.exit(1)
|
||||
|
||||
subtitles = download_subtitles(url)
|
||||
set_clipboard(subtitles)
|
||||
|
||||
|
||||
|
||||
3495
wip/jsRe6Yg40Ho.sv.srt
Normal file
3495
wip/jsRe6Yg40Ho.sv.srt
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user