docs: packages,operations,lua (3 files)

This commit is contained in:
Richard Ward
2025-12-30 21:45:27 +00:00
parent bfce6f6f84
commit 0d61490faf
3 changed files with 778 additions and 1 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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