feat: add SQLite data store with Lesson and LessonMedia models
- Add Microsoft.Data.Sqlite NuGet package to csproj - Create Lesson model (Id, Title, Grade, Subject, LessonDate, Description, SourcePath, CompiledHtml, CreatedAt, UpdatedAt) - Create LessonMedia model (LessonId, JellyfinItemId, SortOrder) - Create LessonStore with SQLite-backed CRUD operations: - Constructor expands ~ in dbPath to home directory - Creates lessons and lesson_media tables on init - GetAllLessons(), GetLessonById(), GetLessonsByGrade() - UpsertLesson() with media association handling - DeleteLesson() with FK cascade - Enables foreign key enforcement via PRAGMA - Build passes with 0 warnings, 0 errors Closes #4
This commit is contained in:
parent
1e1d059642
commit
deecc1dd7c
4 changed files with 313 additions and 0 deletions
223
plugin/Data/LessonStore.cs
Normal file
223
plugin/Data/LessonStore.cs
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using JellyfinLessons.Data.Models;
|
||||||
|
|
||||||
|
namespace JellyfinLessons.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQLite-backed data store for lessons and their media associations.
|
||||||
|
/// </summary>
|
||||||
|
public class LessonStore
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the data store, expanding ~ in the dbPath to the user's home directory.
|
||||||
|
/// </summary>
|
||||||
|
public LessonStore(string dbPath)
|
||||||
|
{
|
||||||
|
if (dbPath.StartsWith("~/") || dbPath == "~")
|
||||||
|
{
|
||||||
|
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
dbPath = Path.Combine(home, dbPath.TrimStart('~', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectionString = $"Data Source={dbPath}";
|
||||||
|
CreateTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateTables()
|
||||||
|
{
|
||||||
|
using var connection = new SqliteConnection(_connectionString);
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS lessons (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
grade INTEGER NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
lesson_date TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
source_path TEXT NOT NULL,
|
||||||
|
compiled_html TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lesson_media (
|
||||||
|
lesson_id TEXT NOT NULL,
|
||||||
|
jellyfin_item_id TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (lesson_id, jellyfin_item_id),
|
||||||
|
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
";
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var pragmaCmd = connection.CreateCommand();
|
||||||
|
pragmaCmd.CommandText = "PRAGMA foreign_keys = ON;";
|
||||||
|
pragmaCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all lessons (without media).
|
||||||
|
/// </summary>
|
||||||
|
public List<Lesson> GetAllLessons()
|
||||||
|
{
|
||||||
|
var lessons = new List<Lesson>();
|
||||||
|
using var connection = new SqliteConnection(_connectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT * FROM lessons ORDER BY lesson_date DESC, title ASC";
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
lessons.Add(MapLesson(reader));
|
||||||
|
return lessons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a lesson by its id, or null if not found.
|
||||||
|
/// </summary>
|
||||||
|
public Lesson? GetLessonById(string id)
|
||||||
|
{
|
||||||
|
using var connection = new SqliteConnection(_connectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT * FROM lessons WHERE id = @id";
|
||||||
|
command.Parameters.AddWithValue("@id", id);
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
var lesson = MapLesson(reader);
|
||||||
|
lesson.MediaItems = GetMediaForLesson(id);
|
||||||
|
return lesson;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all lessons for a given grade level.
|
||||||
|
/// </summary>
|
||||||
|
public List<Lesson> GetLessonsByGrade(int grade)
|
||||||
|
{
|
||||||
|
var lessons = new List<Lesson>();
|
||||||
|
using var connection = new SqliteConnection(_connectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT * FROM lessons WHERE grade = @grade ORDER BY lesson_date DESC, title ASC";
|
||||||
|
command.Parameters.AddWithValue("@grade", grade);
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
lessons.Add(MapLesson(reader));
|
||||||
|
return lessons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts or updates a lesson and its associated media entries.
|
||||||
|
/// </summary>
|
||||||
|
public void UpsertLesson(Lesson lesson, List<LessonMedia> media)
|
||||||
|
{
|
||||||
|
using var connection = new SqliteConnection(_connectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var transaction = connection.BeginTransaction();
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = @"
|
||||||
|
INSERT INTO lessons (id, title, grade, subject, lesson_date, description, source_path, compiled_html, created_at, updated_at)
|
||||||
|
VALUES (@id, @title, @grade, @subject, @lessonDate, @description, @sourcePath, @compiledHtml, @createdAt, @updatedAt)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
grade = excluded.grade,
|
||||||
|
subject = excluded.subject,
|
||||||
|
lesson_date = excluded.lesson_date,
|
||||||
|
description = excluded.description,
|
||||||
|
source_path = excluded.source_path,
|
||||||
|
compiled_html = excluded.compiled_html,
|
||||||
|
updated_at = excluded.updated_at;
|
||||||
|
";
|
||||||
|
command.Parameters.AddWithValue("@id", lesson.Id);
|
||||||
|
command.Parameters.AddWithValue("@title", lesson.Title);
|
||||||
|
command.Parameters.AddWithValue("@grade", lesson.Grade);
|
||||||
|
command.Parameters.AddWithValue("@subject", lesson.Subject);
|
||||||
|
command.Parameters.AddWithValue("@lessonDate", lesson.LessonDate);
|
||||||
|
command.Parameters.AddWithValue("@description", (object?)lesson.Description ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@sourcePath", lesson.SourcePath);
|
||||||
|
command.Parameters.AddWithValue("@compiledHtml", (object?)lesson.CompiledHtml ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("@createdAt", lesson.CreatedAt);
|
||||||
|
command.Parameters.AddWithValue("@updatedAt", lesson.UpdatedAt);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
|
||||||
|
using var deleteMediaCmd = connection.CreateCommand();
|
||||||
|
deleteMediaCmd.CommandText = "DELETE FROM lesson_media WHERE lesson_id = @lessonId";
|
||||||
|
deleteMediaCmd.Parameters.AddWithValue("@lessonId", lesson.Id);
|
||||||
|
deleteMediaCmd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
foreach (var item in media)
|
||||||
|
{
|
||||||
|
using var insertMediaCmd = connection.CreateCommand();
|
||||||
|
insertMediaCmd.CommandText = "INSERT INTO lesson_media (lesson_id, jellyfin_item_id, sort_order) VALUES (@lessonId, @jellyfinItemId, @sortOrder)";
|
||||||
|
insertMediaCmd.Parameters.AddWithValue("@lessonId", lesson.Id);
|
||||||
|
insertMediaCmd.Parameters.AddWithValue("@jellyfinItemId", item.JellyfinItemId);
|
||||||
|
insertMediaCmd.Parameters.AddWithValue("@sortOrder", item.SortOrder);
|
||||||
|
insertMediaCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a lesson and its associated media entries (cascaded by FK if enabled).
|
||||||
|
/// </summary>
|
||||||
|
public void DeleteLesson(string id)
|
||||||
|
{
|
||||||
|
using var connection = new SqliteConnection(_connectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "DELETE FROM lessons WHERE id = @id";
|
||||||
|
command.Parameters.AddWithValue("@id", id);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<LessonMedia> GetMediaForLesson(string lessonId)
|
||||||
|
{
|
||||||
|
var items = new List<LessonMedia>();
|
||||||
|
using var connection = new SqliteConnection(_connectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT * FROM lesson_media WHERE lesson_id = @lessonId ORDER BY sort_order ASC";
|
||||||
|
command.Parameters.AddWithValue("@lessonId", lessonId);
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
items.Add(new LessonMedia
|
||||||
|
{
|
||||||
|
LessonId = reader.GetString(0),
|
||||||
|
JellyfinItemId = reader.GetString(1),
|
||||||
|
SortOrder = reader.GetInt32(2)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Lesson MapLesson(SqliteDataReader reader)
|
||||||
|
{
|
||||||
|
return new Lesson
|
||||||
|
{
|
||||||
|
Id = reader.GetString(0),
|
||||||
|
Title = reader.GetString(1),
|
||||||
|
Grade = reader.GetInt32(2),
|
||||||
|
Subject = reader.GetString(3),
|
||||||
|
LessonDate = reader.GetString(4),
|
||||||
|
Description = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
|
SourcePath = reader.GetString(6),
|
||||||
|
CompiledHtml = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
|
CreatedAt = reader.GetString(8),
|
||||||
|
UpdatedAt = reader.GetString(9)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
65
plugin/Data/Models/Lesson.cs
Normal file
65
plugin/Data/Models/Lesson.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#nullable enable
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace JellyfinLessons.Data.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a homeschool lesson plan.
|
||||||
|
/// </summary>
|
||||||
|
public class Lesson
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier (Guid).
|
||||||
|
/// </summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lesson title.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Target grade level.
|
||||||
|
/// </summary>
|
||||||
|
public int Grade { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subject area (e.g. Math, Science, History).
|
||||||
|
/// </summary>
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date the lesson covers (ISO 8601 format).
|
||||||
|
/// </summary>
|
||||||
|
public string LessonDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional description of the lesson.
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the source markdown or plan file.
|
||||||
|
/// </summary>
|
||||||
|
public string SourcePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-compiled HTML rendering of the lesson.
|
||||||
|
/// </summary>
|
||||||
|
public string? CompiledHtml { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ISO 8601 creation timestamp.
|
||||||
|
/// </summary>
|
||||||
|
public string CreatedAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ISO 8601 last-updated timestamp.
|
||||||
|
/// </summary>
|
||||||
|
public string UpdatedAt { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Associated media items (lazy-loaded).
|
||||||
|
/// </summary>
|
||||||
|
public List<LessonMedia> MediaItems { get; set; } = new();
|
||||||
|
}
|
||||||
22
plugin/Data/Models/LessonMedia.cs
Normal file
22
plugin/Data/Models/LessonMedia.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace JellyfinLessons.Data.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Join model linking lessons to Jellyfin media items.
|
||||||
|
/// </summary>
|
||||||
|
public class LessonMedia
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key to the lesson.
|
||||||
|
/// </summary>
|
||||||
|
public string LessonId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifier of the Jellyfin media item.
|
||||||
|
/// </summary>
|
||||||
|
public string JellyfinItemId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display order within the lesson.
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -12,5 +12,8 @@
|
||||||
<PackageReference Include="Jellyfin.Model" Version="10.10.*">
|
<PackageReference Include="Jellyfin.Model" Version="10.10.*">
|
||||||
<ExcludeAssets>runtime</ExcludeAssets>
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.*">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue