From 0d61490faf7774156f3dd81b08fe39e84ca84998 Mon Sep 17 00:00:00 2001 From: Richard Ward Date: Tue, 30 Dec 2025 21:45:27 +0000 Subject: [PATCH] docs: packages,operations,lua (3 files) --- frontends/qt6/CMakeLists.txt | 5 +- .../seed/scripts/db/operations.lua | 357 +++++++++++++++ .../seed/scripts/db/operations.lua | 417 ++++++++++++++++++ 3 files changed, 778 insertions(+), 1 deletion(-) create mode 100644 packages/forum_forge/seed/scripts/db/operations.lua create mode 100644 packages/irc_webchat/seed/scripts/db/operations.lua diff --git a/frontends/qt6/CMakeLists.txt b/frontends/qt6/CMakeLists.txt index 61b0406f7..3ceb240c2 100644 --- a/frontends/qt6/CMakeLists.txt +++ b/frontends/qt6/CMakeLists.txt @@ -15,12 +15,15 @@ qt_add_executable(dbal-qml main.cpp src/PackageRegistry.cpp src/ModPlayer.cpp + src/DBALClient.cpp ) qt_add_qml_module(dbal-qml URI DBALObservatory VERSION 1.0 - QML_FILES FrontPage.qml + QML_FILES + FrontPage.qml + qmllib/dbal/DBALProvider.qml ) target_link_libraries(dbal-qml PRIVATE Qt6::Core Qt6::Gui Qt6::Quick cpr::cpr libopenmpt::libopenmpt) diff --git a/packages/forum_forge/seed/scripts/db/operations.lua b/packages/forum_forge/seed/scripts/db/operations.lua new file mode 100644 index 000000000..7ad5f0ce6 --- /dev/null +++ b/packages/forum_forge/seed/scripts/db/operations.lua @@ -0,0 +1,357 @@ +-- forum_forge/seed/scripts/db/operations.lua +-- DBAL operations for Forum entities (Category, Thread, Post) +-- @module forum_forge.db.operations + +local M = {} +local json = require('json') + +--------------------------------------------------------------------------- +-- CATEGORY OPERATIONS +--------------------------------------------------------------------------- + +---@class ForumCategoryCreateParams +---@field tenantId string +---@field name string +---@field slug string +---@field description string|nil +---@field icon string|nil +---@field color string|nil +---@field parentId string|nil +---@field minLevel number|nil + +---Create a new forum category +---@param dbal table DBAL client instance +---@param params ForumCategoryCreateParams +---@return table Created category +function M.createCategory(dbal, params) + -- Get next sort order + local existing = dbal:list('ForumCategory', { + where = { tenantId = params.tenantId }, + orderBy = { sortOrder = 'desc' }, + take = 1, + }) + + local maxOrder = 0 + if existing.items and #existing.items > 0 then + maxOrder = existing.items[1].sortOrder or 0 + end + + return dbal:create('ForumCategory', { + tenantId = params.tenantId, + name = params.name, + slug = params.slug, + description = params.description, + icon = params.icon, + color = params.color, + parentId = params.parentId, + sortOrder = maxOrder + 1, + minLevel = params.minLevel or 0, + threadCount = 0, + postCount = 0, + createdAt = os.time() * 1000, + updatedAt = os.time() * 1000, + }) +end + +---List categories +---@param dbal table DBAL client instance +---@param tenantId string +---@param parentId string|nil Filter by parent (nil for root) +---@return table[] Categories +function M.listCategories(dbal, tenantId, parentId) + local where = { tenantId = tenantId } + if parentId then + where.parentId = parentId + end + + local result = dbal:list('ForumCategory', { + where = where, + orderBy = { sortOrder = 'asc' }, + take = 100, + }) + + return result.items or {} +end + +---Update category +---@param dbal table +---@param categoryId string +---@param updates table +---@return table Updated category +function M.updateCategory(dbal, categoryId, updates) + updates.updatedAt = os.time() * 1000 + return dbal:update('ForumCategory', categoryId, updates) +end + +--------------------------------------------------------------------------- +-- THREAD OPERATIONS +--------------------------------------------------------------------------- + +---@class ForumThreadCreateParams +---@field tenantId string +---@field categoryId string +---@field authorId string +---@field authorName string +---@field title string +---@field tags table|nil Array of tag strings +---@field isPinned boolean|nil +---@field isLocked boolean|nil + +---Create a new thread +---@param dbal table DBAL client instance +---@param params ForumThreadCreateParams +---@return table Created thread +function M.createThread(dbal, params) + local thread = dbal:create('ForumThread', { + tenantId = params.tenantId, + categoryId = params.categoryId, + authorId = params.authorId, + authorName = params.authorName, + title = params.title, + slug = M._slugify(params.title), + tags = params.tags and json.encode(params.tags) or '[]', + isPinned = params.isPinned or false, + isLocked = params.isLocked or false, + viewCount = 0, + replyCount = 0, + createdAt = os.time() * 1000, + updatedAt = os.time() * 1000, + lastPostAt = os.time() * 1000, + lastPostAuthorId = params.authorId, + lastPostAuthorName = params.authorName, + }) + + -- Increment category thread count + local category = dbal:read('ForumCategory', params.categoryId) + if category then + dbal:update('ForumCategory', params.categoryId, { + threadCount = (category.threadCount or 0) + 1, + updatedAt = os.time() * 1000, + }) + end + + return thread +end + +---List threads in a category +---@param dbal table DBAL client instance +---@param categoryId string +---@param take number|nil +---@param skip number|nil +---@return table List result +function M.listThreads(dbal, categoryId, take, skip) + return dbal:list('ForumThread', { + where = { categoryId = categoryId }, + orderBy = { isPinned = 'desc', lastPostAt = 'desc' }, + take = take or 20, + skip = skip or 0, + }) +end + +---Get thread by ID +---@param dbal table DBAL client instance +---@param threadId string +---@param incrementView boolean|nil Increment view count +---@return table|nil Thread +function M.getThread(dbal, threadId, incrementView) + local thread = dbal:read('ForumThread', threadId) + + if thread and incrementView then + dbal:update('ForumThread', threadId, { + viewCount = (thread.viewCount or 0) + 1, + }) + thread.viewCount = (thread.viewCount or 0) + 1 + end + + return thread +end + +---Update thread +---@param dbal table +---@param threadId string +---@param updates table +---@return table Updated thread +function M.updateThread(dbal, threadId, updates) + updates.updatedAt = os.time() * 1000 + return dbal:update('ForumThread', threadId, updates) +end + +---Pin/unpin a thread +---@param dbal table +---@param threadId string +---@param isPinned boolean +function M.pinThread(dbal, threadId, isPinned) + return M.updateThread(dbal, threadId, { isPinned = isPinned }) +end + +---Lock/unlock a thread +---@param dbal table +---@param threadId string +---@param isLocked boolean +function M.lockThread(dbal, threadId, isLocked) + return M.updateThread(dbal, threadId, { isLocked = isLocked }) +end + +--------------------------------------------------------------------------- +-- POST OPERATIONS +--------------------------------------------------------------------------- + +---@class ForumPostCreateParams +---@field tenantId string +---@field threadId string +---@field authorId string +---@field authorName string +---@field content string +---@field replyToId string|nil + +---Create a new post (reply) +---@param dbal table DBAL client instance +---@param params ForumPostCreateParams +---@return table Created post +function M.createPost(dbal, params) + local thread = dbal:read('ForumThread', params.threadId) + if not thread then + error('Thread not found: ' .. params.threadId) + end + + if thread.isLocked then + error('Thread is locked') + end + + local post = dbal:create('ForumPost', { + tenantId = params.tenantId, + threadId = params.threadId, + authorId = params.authorId, + authorName = params.authorName, + content = params.content, + replyToId = params.replyToId, + isEdited = false, + likeCount = 0, + createdAt = os.time() * 1000, + updatedAt = os.time() * 1000, + }) + + -- Update thread stats + dbal:update('ForumThread', params.threadId, { + replyCount = (thread.replyCount or 0) + 1, + lastPostAt = os.time() * 1000, + lastPostAuthorId = params.authorId, + lastPostAuthorName = params.authorName, + updatedAt = os.time() * 1000, + }) + + -- Update category post count + local category = dbal:read('ForumCategory', thread.categoryId) + if category then + dbal:update('ForumCategory', thread.categoryId, { + postCount = (category.postCount or 0) + 1, + updatedAt = os.time() * 1000, + }) + end + + return post +end + +---List posts in a thread +---@param dbal table DBAL client instance +---@param threadId string +---@param take number|nil +---@param skip number|nil +---@return table List result +function M.listPosts(dbal, threadId, take, skip) + return dbal:list('ForumPost', { + where = { threadId = threadId }, + orderBy = { createdAt = 'asc' }, + take = take or 50, + skip = skip or 0, + }) +end + +---Update post content +---@param dbal table +---@param postId string +---@param content string +---@return table Updated post +function M.updatePost(dbal, postId, content) + return dbal:update('ForumPost', postId, { + content = content, + isEdited = true, + editedAt = os.time() * 1000, + updatedAt = os.time() * 1000, + }) +end + +---Like a post +---@param dbal table +---@param postId string +---@return table Updated post +function M.likePost(dbal, postId) + local post = dbal:read('ForumPost', postId) + if post then + return dbal:update('ForumPost', postId, { + likeCount = (post.likeCount or 0) + 1, + }) + end + return post +end + +---Delete a post (soft delete by clearing content) +---@param dbal table +---@param postId string +---@param deletedBy string Username who deleted +---@return table Updated post +function M.deletePost(dbal, postId, deletedBy) + return dbal:update('ForumPost', postId, { + content = '[Deleted by ' .. deletedBy .. ']', + isEdited = true, + editedAt = os.time() * 1000, + updatedAt = os.time() * 1000, + }) +end + +--------------------------------------------------------------------------- +-- UTILITY FUNCTIONS +--------------------------------------------------------------------------- + +---Generate a URL-safe slug from text +---@param text string +---@return string Slug +function M._slugify(text) + local slug = text:lower() + slug = slug:gsub('[^%w%s-]', '') + slug = slug:gsub('%s+', '-') + slug = slug:gsub('-+', '-') + slug = slug:gsub('^-', ''):gsub('-$', '') + return slug:sub(1, 100) +end + +---Search threads by title +---@param dbal table +---@param tenantId string +---@param query string +---@param take number|nil +---@return table[] Matching threads +function M.searchThreads(dbal, tenantId, query, take) + -- Basic implementation - fetch and filter + -- TODO: Implement proper full-text search via DBAL + local result = dbal:list('ForumThread', { + where = { tenantId = tenantId }, + take = 1000, + }) + + local matches = {} + local lowerQuery = query:lower() + + for _, thread in ipairs(result.items or {}) do + if thread.title:lower():find(lowerQuery, 1, true) then + table.insert(matches, thread) + if #matches >= (take or 20) then + break + end + end + end + + return matches +end + +return M diff --git a/packages/irc_webchat/seed/scripts/db/operations.lua b/packages/irc_webchat/seed/scripts/db/operations.lua new file mode 100644 index 000000000..2c974aa7a --- /dev/null +++ b/packages/irc_webchat/seed/scripts/db/operations.lua @@ -0,0 +1,417 @@ +-- irc_webchat/seed/scripts/db/operations.lua +-- DBAL operations for IRC entities (Channel, Message, Membership) +-- @module irc_webchat.db.operations + +local M = {} +local json = require('json') + +--------------------------------------------------------------------------- +-- CHANNEL OPERATIONS +--------------------------------------------------------------------------- + +---@class IRCChannelCreateParams +---@field tenantId string +---@field name string Must start with # +---@field displayName string|nil +---@field description string|nil +---@field topic string|nil +---@field minLevel number|nil +---@field creatorId string + +---Create a new IRC channel +---@param dbal table DBAL client instance +---@param params IRCChannelCreateParams +---@return table Created channel +function M.createChannel(dbal, params) + -- Validate channel name starts with # + local name = params.name + if name:sub(1, 1) ~= '#' then + name = '#' .. name + end + + -- Check for duplicate channel name + local existing = dbal:findFirst('IRCChannel', { + where = { tenantId = params.tenantId, name = name }, + }) + + if existing then + error('Channel already exists: ' .. name) + end + + return dbal:create('IRCChannel', { + tenantId = params.tenantId, + name = name, + displayName = params.displayName or name:sub(2), + description = params.description, + topic = params.topic or 'Welcome to ' .. name, + minLevel = params.minLevel or 0, + isPrivate = false, + isArchived = false, + creatorId = params.creatorId, + memberCount = 0, + messageCount = 0, + createdAt = os.time() * 1000, + updatedAt = os.time() * 1000, + lastActivityAt = os.time() * 1000, + }) +end + +---Get channel by name +---@param dbal table +---@param tenantId string +---@param name string +---@return table|nil Channel +function M.getChannelByName(dbal, tenantId, name) + if name:sub(1, 1) ~= '#' then + name = '#' .. name + end + + return dbal:findFirst('IRCChannel', { + where = { tenantId = tenantId, name = name }, + }) +end + +---List all channels +---@param dbal table +---@param tenantId string +---@param includeArchived boolean|nil +---@return table[] Channels +function M.listChannels(dbal, tenantId, includeArchived) + local result = dbal:list('IRCChannel', { + where = { tenantId = tenantId }, + orderBy = { memberCount = 'desc' }, + take = 100, + }) + + local channels = result.items or {} + + if not includeArchived then + local active = {} + for _, ch in ipairs(channels) do + if not ch.isArchived then + table.insert(active, ch) + end + end + channels = active + end + + return channels +end + +---Update channel topic +---@param dbal table +---@param channelId string +---@param topic string +---@param userId string Who set the topic +---@return table Updated channel +function M.setTopic(dbal, channelId, topic, userId) + return dbal:update('IRCChannel', channelId, { + topic = topic, + topicSetBy = userId, + topicSetAt = os.time() * 1000, + updatedAt = os.time() * 1000, + }) +end + +---Archive a channel +---@param dbal table +---@param channelId string +function M.archiveChannel(dbal, channelId) + return dbal:update('IRCChannel', channelId, { + isArchived = true, + updatedAt = os.time() * 1000, + }) +end + +--------------------------------------------------------------------------- +-- MESSAGE OPERATIONS +--------------------------------------------------------------------------- + +---@class IRCMessageCreateParams +---@field tenantId string +---@field channelId string +---@field userId string +---@field username string +---@field displayName string|nil +---@field type string message|join|part|action|notice|system +---@field content string +---@field replyToId string|nil + +---Send a message to a channel +---@param dbal table DBAL client instance +---@param params IRCMessageCreateParams +---@return table Created message +function M.sendMessage(dbal, params) + local message = dbal:create('IRCMessage', { + tenantId = params.tenantId, + channelId = params.channelId, + userId = params.userId, + username = params.username, + displayName = params.displayName or params.username, + type = params.type or 'message', + content = params.content, + replyToId = params.replyToId, + timestamp = os.time() * 1000, + }) + + -- Update channel stats + local channel = dbal:read('IRCChannel', params.channelId) + if channel then + dbal:update('IRCChannel', params.channelId, { + messageCount = (channel.messageCount or 0) + 1, + lastActivityAt = os.time() * 1000, + updatedAt = os.time() * 1000, + }) + end + + return message +end + +---Get channel messages (most recent first) +---@param dbal table +---@param channelId string +---@param take number|nil +---@param before number|nil Timestamp - get messages before this time +---@return table[] Messages +function M.getMessages(dbal, channelId, take, before) + local result = dbal:list('IRCMessage', { + where = { channelId = channelId }, + orderBy = { timestamp = 'desc' }, + take = take or 50, + }) + + local messages = result.items or {} + + -- Filter by timestamp if specified + if before then + local filtered = {} + for _, msg in ipairs(messages) do + if msg.timestamp < before then + table.insert(filtered, msg) + end + end + messages = filtered + end + + -- Reverse to get chronological order for display + local reversed = {} + for i = #messages, 1, -1 do + table.insert(reversed, messages[i]) + end + + return reversed +end + +---Send a system message +---@param dbal table +---@param tenantId string +---@param channelId string +---@param content string +function M.sendSystemMessage(dbal, tenantId, channelId, content) + return M.sendMessage(dbal, { + tenantId = tenantId, + channelId = channelId, + userId = 'system', + username = '*system*', + type = 'system', + content = content, + }) +end + +---Delete a message (mark as deleted) +---@param dbal table +---@param messageId string +---@param deletedBy string +function M.deleteMessage(dbal, messageId, deletedBy) + return dbal:update('IRCMessage', messageId, { + content = '[deleted by ' .. deletedBy .. ']', + type = 'system', + }) +end + +--------------------------------------------------------------------------- +-- MEMBERSHIP OPERATIONS +--------------------------------------------------------------------------- + +---@class IRCMembershipParams +---@field tenantId string +---@field channelId string +---@field userId string +---@field username string +---@field role string owner|operator|voiced|member + +---Join a channel +---@param dbal table DBAL client instance +---@param params IRCMembershipParams +---@return table Membership record +function M.joinChannel(dbal, params) + -- Check if already a member + local existing = dbal:findFirst('IRCMembership', { + where = { + channelId = params.channelId, + userId = params.userId, + }, + }) + + if existing then + -- Update last seen + return dbal:update('IRCMembership', existing.id, { + isOnline = true, + lastSeenAt = os.time() * 1000, + }) + end + + local membership = dbal:create('IRCMembership', { + tenantId = params.tenantId, + channelId = params.channelId, + userId = params.userId, + username = params.username, + role = params.role or 'member', + isOnline = true, + joinedAt = os.time() * 1000, + lastSeenAt = os.time() * 1000, + }) + + -- Update channel member count + local channel = dbal:read('IRCChannel', params.channelId) + if channel then + dbal:update('IRCChannel', params.channelId, { + memberCount = (channel.memberCount or 0) + 1, + updatedAt = os.time() * 1000, + }) + end + + -- Send join message + M.sendMessage(dbal, { + tenantId = params.tenantId, + channelId = params.channelId, + userId = params.userId, + username = params.username, + type = 'join', + content = params.username .. ' has joined the channel', + }) + + return membership +end + +---Leave a channel +---@param dbal table +---@param channelId string +---@param userId string +---@param message string|nil Part message +function M.leaveChannel(dbal, channelId, userId, message) + local membership = dbal:findFirst('IRCMembership', { + where = { channelId = channelId, userId = userId }, + }) + + if not membership then + return false + end + + -- Send part message + local channel = dbal:read('IRCChannel', channelId) + M.sendMessage(dbal, { + tenantId = membership.tenantId, + channelId = channelId, + userId = userId, + username = membership.username, + type = 'part', + content = membership.username .. ' has left' .. (message and (': ' .. message) or ''), + }) + + -- Delete membership + dbal:delete('IRCMembership', membership.id) + + -- Update channel member count + if channel then + dbal:update('IRCChannel', channelId, { + memberCount = math.max(0, (channel.memberCount or 1) - 1), + updatedAt = os.time() * 1000, + }) + end + + return true +end + +---Get channel members +---@param dbal table +---@param channelId string +---@param onlineOnly boolean|nil +---@return table[] Members +function M.getMembers(dbal, channelId, onlineOnly) + local result = dbal:list('IRCMembership', { + where = { channelId = channelId }, + orderBy = { role = 'asc', username = 'asc' }, + take = 500, + }) + + local members = result.items or {} + + if onlineOnly then + local online = {} + for _, m in ipairs(members) do + if m.isOnline then + table.insert(online, m) + end + end + members = online + end + + return members +end + +---Set user's online status +---@param dbal table +---@param channelId string +---@param userId string +---@param isOnline boolean +function M.setOnlineStatus(dbal, channelId, userId, isOnline) + local membership = dbal:findFirst('IRCMembership', { + where = { channelId = channelId, userId = userId }, + }) + + if membership then + dbal:update('IRCMembership', membership.id, { + isOnline = isOnline, + lastSeenAt = os.time() * 1000, + }) + end +end + +---Update user role in channel +---@param dbal table +---@param channelId string +---@param userId string +---@param role string owner|operator|voiced|member +function M.setRole(dbal, channelId, userId, role) + local membership = dbal:findFirst('IRCMembership', { + where = { channelId = channelId, userId = userId }, + }) + + if membership then + dbal:update('IRCMembership', membership.id, { role = role }) + end +end + +---Get channels a user is in +---@param dbal table +---@param userId string +---@return table[] Channels +function M.getUserChannels(dbal, userId) + local memberships = dbal:list('IRCMembership', { + where = { userId = userId }, + take = 100, + }) + + local channels = {} + for _, m in ipairs(memberships.items or {}) do + local channel = dbal:read('IRCChannel', m.channelId) + if channel and not channel.isArchived then + table.insert(channels, channel) + end + end + + return channels +end + +return M