diff --git a/plugin/Data/LessonStore.cs b/plugin/Data/LessonStore.cs new file mode 100644 index 0000000..09888b9 --- /dev/null +++ b/plugin/Data/LessonStore.cs @@ -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; + +/// +/// SQLite-backed data store for lessons and their media associations. +/// +public class LessonStore +{ + private readonly string _connectionString; + + /// + /// Creates the data store, expanding ~ in the dbPath to the user's home directory. + /// + 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(); + } + + /// + /// Returns all lessons (without media). + /// + public List GetAllLessons() + { + var lessons = new List(); + 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; + } + + /// + /// Returns a lesson by its id, or null if not found. + /// + 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; + } + + /// + /// Returns all lessons for a given grade level. + /// + public List GetLessonsByGrade(int grade) + { + var lessons = new List(); + 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; + } + + /// + /// Inserts or updates a lesson and its associated media entries. + /// + public void UpsertLesson(Lesson lesson, List 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(); + } + + /// + /// Deletes a lesson and its associated media entries (cascaded by FK if enabled). + /// + 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 GetMediaForLesson(string lessonId) + { + var items = new List(); + 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) + }; + } +} \ No newline at end of file diff --git a/plugin/Data/Models/Lesson.cs b/plugin/Data/Models/Lesson.cs new file mode 100644 index 0000000..d902501 --- /dev/null +++ b/plugin/Data/Models/Lesson.cs @@ -0,0 +1,65 @@ +#nullable enable +using System.Collections.Generic; + +namespace JellyfinLessons.Data.Models; + +/// +/// Represents a homeschool lesson plan. +/// +public class Lesson +{ + /// + /// Unique identifier (Guid). + /// + public string Id { get; set; } = string.Empty; + + /// + /// Lesson title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Target grade level. + /// + public int Grade { get; set; } + + /// + /// Subject area (e.g. Math, Science, History). + /// + public string Subject { get; set; } = string.Empty; + + /// + /// Date the lesson covers (ISO 8601 format). + /// + public string LessonDate { get; set; } = string.Empty; + + /// + /// Optional description of the lesson. + /// + public string? Description { get; set; } + + /// + /// Path to the source markdown or plan file. + /// + public string SourcePath { get; set; } = string.Empty; + + /// + /// Pre-compiled HTML rendering of the lesson. + /// + public string? CompiledHtml { get; set; } + + /// + /// ISO 8601 creation timestamp. + /// + public string CreatedAt { get; set; } = string.Empty; + + /// + /// ISO 8601 last-updated timestamp. + /// + public string UpdatedAt { get; set; } = string.Empty; + + /// + /// Associated media items (lazy-loaded). + /// + public List MediaItems { get; set; } = new(); +} \ No newline at end of file diff --git a/plugin/Data/Models/LessonMedia.cs b/plugin/Data/Models/LessonMedia.cs new file mode 100644 index 0000000..1f30402 --- /dev/null +++ b/plugin/Data/Models/LessonMedia.cs @@ -0,0 +1,22 @@ +namespace JellyfinLessons.Data.Models; + +/// +/// Join model linking lessons to Jellyfin media items. +/// +public class LessonMedia +{ + /// + /// Foreign key to the lesson. + /// + public string LessonId { get; set; } = string.Empty; + + /// + /// Identifier of the Jellyfin media item. + /// + public string JellyfinItemId { get; set; } = string.Empty; + + /// + /// Display order within the lesson. + /// + public int SortOrder { get; set; } +} \ No newline at end of file diff --git a/plugin/Jellyfin.Plugin.Lessons.csproj b/plugin/Jellyfin.Plugin.Lessons.csproj index 37bd5cc..faba00e 100644 --- a/plugin/Jellyfin.Plugin.Lessons.csproj +++ b/plugin/Jellyfin.Plugin.Lessons.csproj @@ -12,5 +12,8 @@ runtime + + runtime +