Web server and MOAP - Part II (Advanced)
The objects with these scripts are available at SLua Yardang 178,2,23 (SLua Class Study Area):

You are welcome to the Study Groups all Saturdays from 11AM to 1PM SLT with your questions, practices, and projects to chat about this and anything else scripting related.
List of Sitters with auto-refresh
Similar code to the previous List of Visitors. The JavaScript requests the in-world script, to refresh the page if the list of sitters has changed. Using sitters instead of visitors for easier testing.
List of Sitters, auto-refreshHTMLCSSJavaScript
-- List of Sitters, auto-refresh
local FACE_MEDIA = 2
local url = ""
local htmlHeader = [=[
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>@TITLE@</title>
@STYLE@
</head>
<body>
@BODY@
@SCRIPT@
</body>
</html>
]=]
local htmlStyle = [=[
<style type="text/css">
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 20px;
text-align: center;
}
h1 {
color: #333;
font-size: 24px;
margin-bottom: 20px;
}
table {
width: 80%;
margin: 0 auto;
border-collapse: collapse;
background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #4CAF50;
color: white;
text-transform: uppercase;
letter-spacing: 0.1em;
}
tr:hover {
background-color: #f1f1f1;
}
tfoot td {
font-weight: bold;
background-color: #f9f9f9;
}
tfoot td:first-child {
text-align: right;
}
</style>
]=]
local htmlSitters = [=[
<h1>Table of Sitters</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Username</th>
</tr>
</thead>
<tfoot>
<tr>
<td colspan="2">Total Sitters: @TOTAL_SITTERS@</td>
</tr>
</tfoot>
<tbody>
@TABLE@
</tbody>
</table>
]=]
local htmlSittersTable = [=[
<tr>
<td>@NAME@</td>
<td>@USERNAME@</td>
</tr>
]=]
local htmlSittersTitle = "Table of Sitters"
local htmlSittersScript = [=[
<script type="text/javascript">
let loadTime;
let intervalId;
async function checkChanges() {
const elapsedTime = Math.floor((performance.now() - loadTime) / 1000);
try {
const response = await fetch("sitterschange?time=" + elapsedTime);
const data = await response.text();
if (data === "1") {
clearInterval(intervalId);
window.location.reload();
}
} catch (err) {
console.error("Fetch error:", err);
}
}
window.onload = function() {
loadTime = performance.now();
intervalId = setInterval(checkChanges, 3000);
};
</script>
]=]
local changeTime = 0
local sitters = {}
local function show(url)
ll.SetPrimMediaParams(FACE_MEDIA, {
PRIM_MEDIA_CURRENT_URL, url,
PRIM_MEDIA_HOME_URL, url,
PRIM_MEDIA_AUTO_ZOOM, false,
PRIM_MEDIA_FIRST_CLICK_INTERACT, true,
PRIM_MEDIA_PERMS_INTERACT, PRIM_MEDIA_PERM_ANYONE,
PRIM_MEDIA_PERMS_CONTROL, PRIM_MEDIA_PERM_NONE,
PRIM_MEDIA_AUTO_PLAY, true
})
end
local function getSitters()
local newSitters = {}
local visitors = ll.GetAgentList(AGENT_LIST_REGION, {})
for _, visitor in visitors do
if bit32.btest(ll.GetAgentInfo(visitor), AGENT_SITTING) then
table.insert(newSitters, visitor)
end
end
table.sort(newSitters)
if ll.DumpList2String(sitters, ",") ~= ll.DumpList2String(newSitters, ",") then
sitters = newSitters
changeTime = ll.GetWallclock()
end
end
local function tableSitters(html)
local rows = {}
getSitters()
for _, sitter in sitters do
local row = htmlSittersTable
row = ll.ReplaceSubString(row, "@NAME@", ll.GetDisplayName(sitter), 0)
row = ll.ReplaceSubString(row, "@USERNAME@", ll.GetUsername(sitter), 0)
table.insert(rows, row)
end
html = ll.ReplaceSubString(html, "@TOTAL_SITTERS@", tostring(#sitters), 0)
html = ll.ReplaceSubString(html, "@TABLE@", table.concat(rows) ,0)
return html
end
local function initialize()
ll.RequestURL()
ll.SetTimerEvent(2)
end
function http_request(id, method, body)
if method == URL_REQUEST_GRANTED then
url = body .. "/sitters"
ll.Say(0, url)
show(url)
elseif method == URL_REQUEST_DENIED then
ll.OwnerSay("Unable to get URL!")
elseif method == "GET" then
local html = ""
local path = ll.ToLower(ll.GetHTTPHeader(id, "x-path-info"))
local query = ll.ToLower(ll.GetHTTPHeader(id, "x-query-string"))
if path == "/sitters" then
html = ll.ReplaceSubString(htmlHeader, "@STYLE@", htmlStyle, 0)
html = ll.ReplaceSubString(html, "@TITLE@", htmlSittersTitle, 0)
html = ll.ReplaceSubString(html, "@BODY@", tableSitters(htmlSitters), 0)
html = ll.ReplaceSubString(html, "@SCRIPT@", htmlSittersScript, 0)
ll.SetContentType(id, CONTENT_TYPE_XHTML)
elseif path == "/sitterschange" then
local seconds = tonumber(query:split("=")[2])
local now = ll.GetWallclock()
if now - seconds <= changeTime then
html = "1"
else
html = "0"
end
ll.SetContentType(id, CONTENT_TYPE_TEXT)
end
ll.HTTPResponse(id, 200, html)
end
end
function timer()
getSitters()
end
function on_rez(start_param)
ll.ResetScript()
end
function changed(change)
if bit32.btest(change, bit32.bor(CHANGED_REGION_START, CHANGED_OWNER, CHANGED_INVENTORY)) then
ll.ResetScript()
end
end
initialize()
Chat Transcript
Stores the public chat in linkset data and shows it in a webpage that we can copy-paste in text or save in PDF with better coloring.
Useful to keep the transcript for any kind of meetings.
It doesn’t use MOAP, it gives a link to the owner to open in a web browser.
There are two scripts, one for storing and the other for displaying.
Chat Transcript - StoreHTMLCSSJavaScript
-- Chat Transcript - Store script 1/2 (by Suzanna Linn, 2025-09-27)
local ME = ""
local counter = 1000
local function storeMessage(name, id, message)
local format = ""
local time = ll.GetWallclock()
local hour = string.format("%02d:%02d", time // 3600, time % 3600 // 60)
if id == ME then
format = "m"
name = ll.GetDisplayName(ME)
else
if ll.GetAgentSize(id) ~= ZERO_VECTOR then
format = "a"
name = ll.GetDisplayName(id)
else
format = "o"
if ll.GetOwnerKey(id) == ME then
format = "x"
end
end
end
counter += 1
ll.LinksetDataWrite(tostring(counter), lljson.encode({format, hour, name, message}))
end
local function initialize()
counter = ll.LinksetDataCountKeys() + 1000
ME = ll.GetOwner()
ll.Listen(0, "", "", "")
end
function listen(channel, name, id, message)
if channel == 0 then
storeMessage(name, id, message)
end
end
function on_rez(start_param)
ll.ResetScript()
end
function changed(change)
if bit32.btest(change, bit32.bor(CHANGED_OWNER, CHANGED_INVENTORY)) then
ll.ResetScript()
end
end
initialize()
Chat Transcript - DisplayHTMLCSSJavaScript
-- Chat Transcript - Display script 2/2 (by Suzanna Linn, 2025-09-27)
local url = ""
local html = [=[
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>@TITLE@</title>
<style>
body {
font-family: sans-serif;
margin: 20px;
color: #2196F3;
}
.classname {
font-style: italic;
}
.line {
margin-bottom: 10px;
font-family: "Cascadia Code", "Cascadia Mono", monospace;
}
.line span {
white-space: pre-wrap;
}
.hour {
color: #808080; /* Gray */
}
.name-m {
color: #FF8C00; /* Dark Orange */
}
.name-a {
color: #9370DB; /* Medium Purple */
}
.name-o {
color: #87CEEB; /* Light Blue (Sky Blue) */
margin-right: 10px;
}
.name-x {
color: #FF6347; /* Red (Tomato) */
}
.text-m {
color: #00008B; /* Dark Blue */
}
.text-a {
color: #8B4513; /* Saddle Brown */
}
.text-o {
color: #228B22; /* Forest Green */
}
.text-x {
color: #000000; /* Black */
}
.header-bar {
position: sticky;
top: 0;
margin: 0;
display: flex;
justify-content: space-between;
align-items: center;
background: white;
padding: 10px 0;
z-index: 1000;
}
.buttons {
margin-bottom: 20px;
}
button {
margin-right: 10px;
padding: 8px 16px;
font-size: 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: background-color 0.3s ease;
background-color: #2196F3;
color: white;
}
button:hover {
opacity: 0.9;
}
@media print {
.buttons {
display: none;
}
}
</style>
</head>
<body>
<div class="header-bar">
<h1 class="classname">@TITLE@</h1>
<div class="buttons">
<button onclick="window.scrollTo(0, document.body.scrollHeight)">↓ Latest</button>
<button onclick="window.scrollTo(0, 0)">↑ Top</button>
<button onclick="copyPlainText()">Copy to clipbboard</button>
<button onclick="scrollTopAndPrint()">Save to PDF</button>
</div>
</div>
<div id="content"></div>
<script type="text/javascript">
//<![CDATA[
const container = document.getElementById("content");
async function loadData(nextMessage) {
try {
const response = await fetch('messages?numKey=' + nextMessage);
const data = await response.text();
const messages = JSON.parse(data);
for (const message of messages) {
if (message[0] == "") return;
const typeStr = message[0];
const hour = message[1];
let name = message[2];
let text = message[3];
const nameColorClass = "name-" + typeStr;
const textColorClass = "text-" + typeStr;
if (text.startsWith("/me ")) {
text = text.substring(3);
} else {
name += ": ";
}
const lineDiv = document.createElement("div");
lineDiv.className = "line";
const hourSpan = document.createElement("span");
hourSpan.className = "hour";
hourSpan.textContent = "[" + hour + "] ";
const nameSpan = document.createElement("span");
nameSpan.className = nameColorClass;
nameSpan.textContent = name;
const textSpan = document.createElement("span");
textSpan.className = textColorClass;
textSpan.textContent = text;
lineDiv.appendChild(hourSpan);
lineDiv.appendChild(nameSpan);
lineDiv.appendChild(textSpan);
container.appendChild(lineDiv);
}
if (messages.length > 0) {
loadData(nextMessage + messages.length);
}
} catch (err) {
console.error("Fetch error:", err);
}
}
function copyPlainText() {
const content = document.getElementById("content");
const text = content.innerText || content.textContent;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
const success = document.execCommand("copy");
alert(success ? "Text copied to clipboard." : "Failed to copy text.");
} catch (err) {
alert("Fallback copy failed: " + err);
}
document.body.removeChild(textarea);
}
function scrollTopAndPrint() {
window.scrollTo(0, 0);
setTimeout(() => {
window.print();
}, 200);
}
window.onload = function() {
loadData(0);
};
//]]>
</script>
</body>
</html>
]=]
local function initialize()
ll.RequestURL()
html = ll.ReplaceSubString(html, "@TITLE@", ll.GetObjectDesc(), 0)
end
function http_request(id, method, body)
if method == URL_REQUEST_GRANTED then
url = body
ll.OwnerSay(url .. "/chat")
elseif method == URL_REQUEST_DENIED then
ll.OwnerSay("Unable to get URL!")
elseif method == "GET" then
local path = ll.ToLower(ll.GetHTTPHeader(id, "x-path-info"))
local query = ll.ToLower(ll.GetHTTPHeader(id, "x-query-string"))
if path == "/chat" then
ll.SetContentType(id, CONTENT_TYPE_XHTML)
ll.HTTPResponse(id, 200, html)
elseif path == "/messages" then
local numKey = tonumber(query:split("=")[2])
local totalKeys = ll.LinksetDataCountKeys()
local data = {}
local length = 0
while numKey < totalKeys and length < 10000 do
local lKey = ll.LinksetDataListKeys(numKey, 1)[1]
local lValue = ll.LinksetDataRead(lKey)
table.insert(data, lljson.decode(lValue))
length += #lValue
numKey += 1
end
if numKey == totalKeys then
table.insert(data, {"", "", "", ""})
end
ll.SetContentType(id, CONTENT_TYPE_TEXT)
ll.HTTPResponse(id, 200, lljson.encode(data))
end
end
end
function on_rez(start_param)
ll.ResetScript()
end
function changed(change)
if bit32.btest(change, bit32.bor(CHANGED_REGION_START, CHANGED_OWNER, CHANGED_INVENTORY)) then
ll.ResetScript()
end
end
initialize()
Notecard Display
Displaying a formatted notecard that we can write in Markdown, in HTML, or in a mix of both. It has a notecard “style” with the CSS styles to use.
We have to serve XHTML pages for public view, but we can use JavaScript to insert HTML code in the XHTML page.
Useful to display in public a nicely formatted info that is easy to modify, even by non-scripters.
It’s also useful as a HUD to send notecards that can be viewed in a beautiful and personalized way (each user can have their own favorite CSS styles), avoiding the ugly SL notecard look.
It uses the JavaScript library Markdown-it to convert markdown to HTML. There is also, commented, another library, Showdown, that does the same.
There are examples in-world, including a Markdown Demo with the different markdown options. Info on how to get the examples is on top of the page.
Notecard DisplayHTMLCSSJavaScript
-- Notecard Display (by Suzanna Linn, 2025-09-27)
local html = [=[
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>@TITLE@</title>
<style>
@STYLES@
</style>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/showdown/dist/showdown.min.js"></script> -->
</head>
<body>
@CONTENT@
</body>
</html>
]=]
local htmlNotecardList = [=[
<h1>Available Notecards:</h1>
@LIST@
]=]
local htmlNotecard = [=[
<form action="" method="post"><button type="submit" name="option" value="back">Back to Notecards List</button></form>
<hr/>
<div id="content"></div>
<script type="text/javascript">
//<![CDATA[
let contentBuffer = "";
async function loadNotecard(line) {
try {
const response = await fetch(`notecard?name=${encodeURIComponent("@NOTECARD@")}&line=${line}`);
if (!response.ok) {
throw new Error(`HTTP error Status: ${response.status}`);
}
const data = await response.text();
contentBuffer += data;
if (data.length > 10000) {
loadNotecard(line + data.split("\n").length - 1);
} else {
// markdown-it
const md = window.markdownit({
html: true, // Enable HTML tags in source
linkify: true, // Autoconvert URL-like text to links
typographer: true // Enable smartquotes and other typographic replacements
});
const htmlString = md.render(contentBuffer);
//
/* showdown
const converter = new showdown.Converter({ outputXHTML: true });
const htmlString = converter.makeHtml(contentBuffer);
*/
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, "text/html");
const container = document.getElementById("content");
for (let node of doc.body.childNodes) {
container.appendChild(node);
}
}
} catch (err) {
console.error("Error loading notecard:", err);
}
}
window.onload = function() {
loadNotecard(0);
};
//]]>
</script>
]=]
local FACE_MEDIA = 2
local NOTECARD_STYLES = "style"
local url = ""
local notecardName = ""
local notecardLine = 0
local notecardText = {}
local requestLineStylesId = NULL_KEY
local requestLineNotecardId = NULL_KEY
local requestId = NULL_KEY
local function show(url)
ll.SetPrimMediaParams(FACE_MEDIA, {
PRIM_MEDIA_CURRENT_URL, url,
PRIM_MEDIA_HOME_URL, url,
PRIM_MEDIA_AUTO_ZOOM, false,
PRIM_MEDIA_FIRST_CLICK_INTERACT, true,
PRIM_MEDIA_PERMS_INTERACT, PRIM_MEDIA_PERM_ANYONE,
PRIM_MEDIA_PERMS_CONTROL, PRIM_MEDIA_PERM_NONE,
PRIM_MEDIA_AUTO_PLAY, true,
PRIM_MEDIA_WIDTH_PIXELS, 2048,
PRIM_MEDIA_HEIGHT_PIXELS, 1024
})
end
local function readStyles()
local data = ""
repeat
data = ll.GetNotecardLineSync(notecardName, notecardLine)
if data ~= EOF then
if data ~= NAK then
table.insert(notecardText, data .. "\n")
notecardLine += 1
else
requestLineStylesId = ll.GetNotecardLine(notecardName, notecardLine)
end
end
until data == EOF or data == NAK
if data == EOF then
html = ll.ReplaceSubString(html, "@STYLES@", table.concat(notecardText), 0)
show(url .. "/view")
end
end
local function readNotecard()
local data = ""
local length = 0
repeat
data = ll.GetNotecardLineSync(notecardName, notecardLine)
if data ~= EOF then
if data ~= NAK then
table.insert(notecardText, data .. "\n")
length += #data
notecardLine += 1
else
requestLineNotecardId = ll.GetNotecardLine(notecardName, notecardLine)
end
end
until length > 10000 or data == EOF or data == NAK
if data ~= NAK then
ll.SetContentType(requestId, CONTENT_TYPE_TEXT)
ll.HTTPResponse(requestId, 200, table.concat(notecardText))
end
end
local function parseQuery(query)
local params = {}
for key, value in query:gmatch("([^&=]+)=?([^&]*)") do
params[ll.UnescapeURL(key)] = ll.UnescapeURL((value:gsub("+"," ")))
end
return params
end
local function notecardsList()
local list = {}
for i = 0, ll.GetInventoryNumber(INVENTORY_NOTECARD) - 1 do
local name = ll.GetInventoryName(INVENTORY_NOTECARD, i)
if name ~= NOTECARD_STYLES then
table.insert(list, `<form action="" method="post"><button type="submit" name="name" value="{name}">{name}</button></form>\n`)
end
end
local htmlList = ll.ReplaceSubString(htmlNotecardList, "@LIST@", table.concat(list), 0)
htmlList = ll.ReplaceSubString(html, "@CONTENT@", htmlList, 0)
return htmlList
end
local function initialize()
ll.ClearPrimMedia(FACE_MEDIA)
if ll.GetInventoryNumber(INVENTORY_NOTECARD) > 1 then
ll.RequestURL()
html = ll.ReplaceSubString(html, "@TITLE@", ll.GetObjectDesc(), 0)
else
ll.OwnerSay("No notecard to show")
end
end
function dataserver(queryid, data)
if queryid == requestLineStylesId then
readStyles()
elseif queryid == requestLineNotecardId then
readNotecard()
end
end
function http_request(id, method, body)
if method == URL_REQUEST_GRANTED then
notecardName = NOTECARD_STYLES
notecardLine = 0
notecardText = {}
url = body
readStyles()
ll.OwnerSay(url .. "/view")
elseif method == URL_REQUEST_DENIED then
ll.OwnerSay("Unable to get URL!")
elseif method == "GET" then
local path = ll.ToLower(ll.GetHTTPHeader(id, "x-path-info"))
local query = ll.ToLower(ll.GetHTTPHeader(id, "x-query-string"))
if path == "/notecard" then
requestId = id
local params = parseQuery(query)
notecardName = params.name
notecardLine = tonumber(params.line)
notecardText = {}
readNotecard()
else
ll.SetContentType(id, CONTENT_TYPE_XHTML)
ll.HTTPResponse(id, 200, notecardsList())
end
elseif method == "POST" then
local params = parseQuery(body)
if params.name then
local htmlView = ll.ReplaceSubString(htmlNotecard, "@NOTECARD@", params.name, 0)
htmlView = ll.ReplaceSubString(html, "@CONTENT@", htmlView, 0)
ll.SetContentType(id, CONTENT_TYPE_XHTML)
ll.HTTPResponse(id, 200, htmlView)
elseif params.option == "back" then
ll.SetContentType(id, CONTENT_TYPE_XHTML)
ll.HTTPResponse(id, 200, notecardsList())
end
end
end
function on_rez(start_param)
ll.ResetScript()
end
function changed(change)
if bit32.btest(change, bit32.bor(CHANGED_REGION_START, CHANGED_OWNER, CHANGED_INVENTORY)) then
ll.ResetScript()
end
end
initialize()
Notecard Website
This is an evolution of the previous script. It’s useful to make a website with several pages, each one in a notecard.
The notecards can have Markdown and HTML as before, and also JavaScript in
There is also a notecard “style” with the CSS.
And another notecard “layout” with the common layout for all the pages. This notecard has the elements common to all the pages, like headers, menus, scripts, and so on. It must have a placeholder, @CONTENT@, to insert each page.
The website must have a notecard “index” with the home page.
There is an example in-world. Info on how to get the examples is on top of the page.
The example layout has a menu with buttons to allow independent navigation for each user. It also has, commented, the menu with links to show the same page to everyone. It also has a button and a script for light/dark mode.
There are two scripts, because of memory limits.
Notecards Website, InitializationHTMLCSSJavaScript
-- Notecards Website, Initialization script 1/2 (by Suzanna Linn, 2025-09-27)
local html = [=[
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>@TITLE@</title>
<style>
@STYLES@
</style>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
// <script src="https://cdn.jsdelivr.net/npm/showdown/dist/showdown.min.js"></script>
</head>
<body>
<div id="content"></div>
<script type="text/javascript">
//<![CDATA[
let contentBuffer = "";
async function loadNotecard(line) {
try {
const response = await fetch(`notecard?name=${encodeURIComponent("@NOTECARD@")}&line=${line}`);
if (!response.ok) {
throw new Error(`HTTP error Status: ${response.status}`);
}
const data = await response.text();
contentBuffer += data;
if (data.length > 10000) {
loadNotecard(line + data.split("\n").length - 1);
} else {
// markdown-it
const md = window.markdownit({
html: true, // Enable HTML tags in source
linkify: true, // Autoconvert URL-like text to links
typographer: true // Enable smartquotes and other typographic replacements
});
const htmlString = md.render(contentBuffer);
//
/* showdown
const converter = new showdown.Converter({ outputXHTML: true });
const htmlString = converter.makeHtml(contentBuffer);
*/
const parser = new DOMParser();
const doc = parser.parseFromString(htmlString, "text/html");
const container = document.getElementById("content");
for (let node of doc.body.childNodes) {
if (node.tagName === "SCRIPT") {
const script = document.createElement("script");
script.textContent = node.textContent;
document.body.appendChild(script);
} else {
container.appendChild(node);
}
}
}
} catch (err) {
console.error("Error loading notecard:", err);
}
}
window.onload = function() {
if (localStorage.getItem("theme") === "dark") {
document.body.classList.add("dark");
}
loadNotecard(0);
};
//]]>
</script>
</body>
</html>
]=]
local NOTECARD_STYLES = "style"
local NOTECARD_LAYOUT = "layout"
local notecardName = ""
local notecardLine = 0
local notecardText = {}
local requestLineStylesId = NULL_KEY
local requestLineLayoutId = NULL_KEY
local readLayout
local function readStyles()
local data = ""
repeat
data = ll.GetNotecardLineSync(notecardName, notecardLine)
if data ~= EOF then
if data ~= NAK then
table.insert(notecardText, data .. "\n")
notecardLine += 1
else
requestLineStylesId = ll.GetNotecardLine(notecardName, notecardLine)
end
end
until data == EOF or data == NAK
if data == EOF then
html = ll.ReplaceSubString(html, "@STYLES@", table.concat(notecardText), 0)
ll.LinksetDataWrite("XHTML", html)
html = ""
notecardName = NOTECARD_LAYOUT
notecardLine = 0
notecardText = {}
readLayout()
end
end
function readLayout()
local data = ""
repeat
data = ll.GetNotecardLineSync(notecardName, notecardLine)
if data ~= EOF then
if data ~= NAK then
table.insert(notecardText, data .. "\n")
notecardLine += 1
else
requestLineLayoutId = ll.GetNotecardLine(notecardName, notecardLine)
end
end
until data == EOF or data == NAK or data:find("@CONTENT@", 1, true)
if data ~= NAK then
if data ~= EOF then
local startPos, endPos = data:find("@CONTENT@", 1, true)
notecardText[#notecardText] = data:sub(1, startPos - 1) .. "\n"
ll.LinksetDataWrite("LAYOUT1", table.concat(notecardText))
notecardText = {}
table.insert(notecardText, data:sub(endPos + 1))
readLayout()
else
ll.LinksetDataWrite("LAYOUT2", table.concat(notecardText))
end
end
end
local function initialize()
html = ll.ReplaceSubString(html, "@TITLE@", ll.GetObjectDesc(), 0)
notecardName = NOTECARD_STYLES
notecardLine = 0
notecardText = {}
readStyles()
end
function dataserver(queryid, data)
if queryid == requestLineStylesId then
readStyles()
elseif queryid == requestLineLayoutId then
readLayout()
end
end
function on_rez(start_param)
ll.ResetScript()
end
function changed(change)
if bit32.btest(change, bit32.bor(CHANGED_OWNER, CHANGED_INVENTORY)) then
ll.ResetScript()
end
end
initialize()
Notecards Website, ExecutionHTMLCSSJavaScript
-- Notecards Website, Execution script 2/2 (by Suzanna Linn, 2025-09-27)
local FACE_MEDIA = 2
local NOTECARD_INDEX = "index"
local url = ""
local notecardName = ""
local notecardLine = 0
local notecardText = {}
local requestLineNotecardId = NULL_KEY
local requestId = NULL_KEY
local function show(url)
ll.SetPrimMediaParams(FACE_MEDIA, {
PRIM_MEDIA_CURRENT_URL, url,
PRIM_MEDIA_HOME_URL, url,
PRIM_MEDIA_AUTO_ZOOM, false,
PRIM_MEDIA_FIRST_CLICK_INTERACT, true,
PRIM_MEDIA_PERMS_INTERACT, PRIM_MEDIA_PERM_ANYONE,
PRIM_MEDIA_PERMS_CONTROL, PRIM_MEDIA_PERM_NONE,
PRIM_MEDIA_AUTO_PLAY, true,
PRIM_MEDIA_WIDTH_PIXELS, 2048,
PRIM_MEDIA_HEIGHT_PIXELS, 1024
})
end
local function readNotecard()
local data = ""
local length = 0
repeat
data = ll.GetNotecardLineSync(notecardName, notecardLine)
if data ~= EOF then
if data ~= NAK then
table.insert(notecardText, data .. "\n")
length += #data
notecardLine += 1
else
requestLineNotecardId = ll.GetNotecardLine(notecardName, notecardLine)
end
end
until length > 10000 or data == EOF or data == NAK
if data ~= NAK then
if data == EOF and #notecardText > 0 then
table.insert(notecardText, ll.LinksetDataRead("LAYOUT2"))
end
ll.SetContentType(requestId, CONTENT_TYPE_TEXT)
ll.HTTPResponse(requestId, 200, table.concat(notecardText))
end
end
local function parseQuery(query)
local params = {}
for key, value in query:gmatch("([^&=]+)=?([^&]*)") do
params[ll.UnescapeURL(key)] = ll.UnescapeURL((value:gsub("+"," ")))
end
return params
end
local function initialize()
ll.ClearPrimMedia(FACE_MEDIA)
ll.Sleep(1)
ll.RequestURL()
end
function dataserver(queryid, data)
if queryid == requestLineNotecardId then
readNotecard()
end
end
function http_request(id, method, body)
if method == URL_REQUEST_GRANTED then
url = body
show(url .. "/" .. NOTECARD_INDEX)
ll.OwnerSay(url .. "/" .. NOTECARD_INDEX)
elseif method == URL_REQUEST_DENIED then
ll.OwnerSay("Unable to get URL!")
elseif method == "GET" then
local path = ll.ToLower(ll.GetHTTPHeader(id, "x-path-info"))
local query = ll.ToLower(ll.GetHTTPHeader(id, "x-query-string"))
if path == "/notecard" then
requestId = id
local params = parseQuery(query)
notecardName = params.name
notecardLine = tonumber(params.line)
notecardText = {}
table.insert(notecardText, ll.LinksetDataRead("LAYOUT1"))
readNotecard()
else
ll.SetContentType(id, CONTENT_TYPE_XHTML)
ll.HTTPResponse(id, 200, ll.ReplaceSubString(ll.LinksetDataRead("XHTML"), "@NOTECARD@", path:sub(2), 0))
end
elseif method == "POST" then
local path = ll.ToLower(ll.GetHTTPHeader(id, "x-path-info"))
local query = ll.ToLower(ll.GetHTTPHeader(id, "x-query-string"))
ll.SetContentType(id, CONTENT_TYPE_XHTML)
ll.HTTPResponse(id, 200, ll.ReplaceSubString(ll.LinksetDataRead("XHTML"), "@NOTECARD@", parseQuery(body).page, 0))
end
end
function on_rez(start_param)
ll.ResetScript()
end
function changed(change)
if bit32.btest(change, bit32.bor(CHANGED_REGION_START, CHANGED_OWNER, CHANGED_INVENTORY)) then
ll.ResetScript()
end
end
initialize()
Linkset Data Editor
A webpage to view and edit the linkset data. We can insert pairs of key-value, modify a value, or delete a key.
There are two scripts, because of memory limits, to copy in the object that we want to view/edit the linkset data. When the object rezzes the script gives to the owner an URL to open in the browser.
Linkset Data Editor, InitializationHTMLCSSJavaScript
-- Linkset Data Editor, Initialization script 1/2 (by Suzanna Linn, 2025-09-27)
local html = [=[
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Linkset data editor</title>
<style type="text/css">
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 20px;
text-align: center;
}
h1 {
color: #333;
font-size: 24px;
margin-bottom: 20px;
}
#insert-btn-container {
margin-bottom: 20px;
}
table {
width: 90%;
margin: 0 auto;
border-collapse: collapse;
background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
th, td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #ddd;
word-wrap: break-word;
word-break: break-all;
}
th {
background-color: #4CAF50;
color: white;
text-transform: uppercase;
letter-spacing: 0.1em;
}
tr:hover {
background-color: #f1f1f1;
}
td input[type="text"] {
width: 95%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.action-btn {
padding: 5px 10px;
border: none;
border-radius: 3px;
cursor: pointer;
margin-right: 5px;
color: white;
}
#insert-btn {
background-color: #007BFF; /* Primary Blue */
padding: 10px 15px;
}
#sort-btn {
background-color: #ffc107; /* Yellow */
padding: 10px 15px;
}
#reload-btn {
background-color: #17a2b8; /* Teal */
padding: 10px 15px;
}
.edit-btn { background-color: #28a745; } /* Green */
.save-btn { background-color: #008CBA; } /* Blue */
.delete-btn { background-color: #dc3545; } /* Red */
.cancel-btn { background-color: #6c757d; } /* Gray */
th.actions-header,
td:last-child {
width: 1%;
white-space: nowrap;
}
.alert {
position: fixed;
top: 80px;
right: 40px;
background: #333;
color: #fff;
padding: 10px 15px;
border-radius: 6px;
opacity: 0.9;
}
#data tbody td:nth-child(1),
#data tbody td:nth-child(2) {
white-space: pre-wrap;
word-break: break-word;
}
</style>
</head>
<body>
<h1>@OBJECT@</h1>
<div id="insert-btn-container">
<button id="insert-btn" class="action-btn">Insert New Row</button>
<button id="sort-btn" class="action-btn">Sort by Key</button>
<button id="reload-btn" class="action-btn">Reload</button>
</div>
<table id="data">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th class="actions-header">Actions</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div id="alert" style="display:none;"></div>
<script type="text/javascript">
//<![CDATA[
const tbody = document.getElementById("data").querySelector("tbody");
const insertBtn = document.getElementById("insert-btn");
const sortBtn = document.getElementById("sort-btn");
const reloadBtn = document.getElementById("reload-btn");
async function loadData(nextRow) {
try {
const response = await fetch("data?numkey=" + nextRow);
const data = await response.text();
const rows = JSON.parse(data);
for (const rowData of rows) {
if (rowData[0] == "") return;
createRow(rowData);
}
if (rows.length > 0) {
loadData(nextRow + rows.length);
}
} catch (err) {
console.error("Error loading data:", err);
}
}
function createRow(rowData, isNew = false) {
const row = tbody.insertRow(isNew ? 0 : -1);
const keyCell = row.insertCell(0);
const valueCell = row.insertCell(1);
const actionsCell = row.insertCell(2);
if (isNew) {
keyCell.contentEditable = true;
valueCell.contentEditable = true;
keyCell.focus();
const saveButton = document.createElement("button");
saveButton.textContent = "Save";
saveButton.className = "action-btn save-btn";
saveButton.onclick = () => saveNewRow(row);
actionsCell.appendChild(saveButton);
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.className = "action-btn cancel-btn";
cancelButton.onclick = () => row.remove();
actionsCell.appendChild(cancelButton);
} else {
keyCell.textContent = rowData[0];
valueCell.textContent = rowData[1];
addDefaultActions(row);
}
}
function addDefaultActions(row) {
const actionsCell = row.cells[2];
actionsCell.innerHTML = "";
const editButton = document.createElement("button");
editButton.textContent = "Edit";
editButton.className = "action-btn edit-btn";
editButton.onclick = () => enableEditing(row);
actionsCell.appendChild(editButton);
const deleteButton = document.createElement("button");
deleteButton.textContent = "Delete";
deleteButton.className = "action-btn delete-btn";
deleteButton.onclick = () => deleteRow(row, deleteButton);
actionsCell.appendChild(deleteButton);
}
function insertNewRow() {
if (tbody.rows[0] && tbody.rows[0].cells[0].querySelector("input")) {
showAlert("Please save or cancel the current new row first.");
return;
}
createRow(null, true);
}
async function saveNewRow(row) {
const key = row.cells[0].innerText;
const value = row.cells[1].innerText;
if (!key) {
showAlert("Key cannot be empty.");
return;
}
const allRows = tbody.querySelectorAll("tr");
for (const existingRow of allRows) {
if (existingRow === row) {
continue;
}
const existingKey = existingRow.cells[0].textContent;
if (existingKey === key) {
showAlert(`The key "${key}" already exists. Please use a unique key.`);
return;
}
}
row.cells[0].contentEditable = false;
row.cells[1].contentEditable = false;
addDefaultActions(row);
try {
const response = await fetch("insert", {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify({ key, value }),
});
const data = await response.text();
console.log("Successfully inserted:", data);
} catch (error) {
console.error("Error inserting:", error);
}
}
function enableEditing(row) {
const valueCell = row.cells[1];
const actionsCell = row.cells[2];
const originalValue = valueCell.innerText;
valueCell.contentEditable = true;
valueCell.focus();
const range = document.createRange();
range.selectNodeContents(valueCell);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
actionsCell.innerHTML = "";
const saveButton = document.createElement("button");
saveButton.textContent = "Save";
saveButton.className = "action-btn save-btn";
saveButton.onclick = () => saveData(row);
actionsCell.appendChild(saveButton);
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.className = "action-btn cancel-btn";
cancelButton.onclick = () => {
valueCell.textContent = originalValue;
valueCell.contentEditable = false;
valueCell.blur();
window.getSelection().removeAllRanges();
addDefaultActions(row);
};
actionsCell.appendChild(cancelButton);
}
async function saveData(row) {
const key = row.cells[0].innerText;
const value = row.cells[1].innerText;
const valueCell = row.cells[1];
valueCell.contentEditable = false;
valueCell.blur();
window.getSelection().removeAllRanges();
addDefaultActions(row);
try {
const response = await fetch("update", {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify({ key, value }),
});
const data = await response.text();
console.log("Successfully updated:", data);
} catch (error) {
console.error("Error updating:", error);
}
}
async function deleteRow(row, button) {
const actionsCell = row.cells[2];
const key = row.cells[0].innerText;
const originalButtons = Array.from(actionsCell.childNodes);
actionsCell.innerHTML = "";
showAlert(`Delete row with key "${key}"?`);
const confirmButton = document.createElement("button");
confirmButton.textContent = "Confirm";
confirmButton.className = "action-btn delete-btn";
confirmButton.onclick = async () => {
row.remove();
try {
const response = await fetch("delete", {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify({ key, value: row.cells[1].innerText }),
});
const data = await response.text();
console.log("Successfully deleted:", data);
} catch (error) {
console.error("Error deleting:", error);
}
};
actionsCell.appendChild(confirmButton);
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.className = "action-btn cancel-btn";
cancelButton.onclick = () => {
addDefaultActions(row);
};
actionsCell.appendChild(cancelButton);
}
function sortTable() {
const rows = Array.from(tbody.querySelectorAll("tr"));
rows.sort((a, b) => {
const keyA = a.cells[0].textContent.toLowerCase();
const keyB = b.cells[0].textContent.toLowerCase();
if (keyA < keyB) {
return -1;
}
if (keyA > keyB) {
return 1;
}
return 0;
});
rows.forEach(row => tbody.appendChild(row));
}
function reloadData() {
tbody.innerHTML = "";
loadData(0);
}
function showAlert(msg) {
const alert = document.getElementById("alert");
alert.textContent = msg;
alert.className = "alert";
alert.style.display = "block";
setTimeout(() => {
alert.style.display = "none";
}, 3000);
}
insertBtn.onclick = insertNewRow;
sortBtn.onclick = sortTable;
reloadBtn.onclick = reloadData;
window.onload = function() {
loadData(0);
};
//]]>
</script>
</body>
</html>
]=]
local ASKING = 1
local SENDING = 2
local function initialize()
html = ll.ReplaceSubString(html, "@OBJECT@", ll.GetObjectName(), 0)
end
function link_message(sender_num, num, str, id)
if num == ASKING then
ll.MessageLinked(LINK_THIS, SENDING, html, "")
end
end
function on_rez(start_param)
ll.ResetScript()
end
function changed(change)
if bit32.btest(change, CHANGED_OWNER) then
ll.ResetScript()
end
end
initialize()
Linkset Data Editor, ExecutionHTMLCSSJavaScript
-- Linkset Data Editor, Execution script 2/2 (by Suzanna Linn, 2025-09-27)
local url = ""
local ASKING = 1
local SENDING = 2
local requestId
local function initialize()
ll.RequestURL()
end
function link_message(sender_num, num, str, id)
if num == SENDING then
ll.SetContentType(requestId, CONTENT_TYPE_XHTML)
ll.HTTPResponse(requestId, 200, str)
end
end
function http_request(id, method, body)
if method == URL_REQUEST_GRANTED then
url = body
ll.OwnerSay(url .. "/edit")
elseif method == URL_REQUEST_DENIED then
ll.OwnerSay("Unable to get URL!")
elseif method == "GET" then
local path = ll.ToLower(ll.GetHTTPHeader(id, "x-path-info"))
local query = ll.ToLower(ll.GetHTTPHeader(id, "x-query-string"))
if path == "/edit" then
requestId = id
ll.MessageLinked(LINK_THIS, ASKING, "", "")
elseif path == "/data" then
local numKey = tonumber(query:split("=")[2])
local totalKeys = ll.LinksetDataCountKeys()
local data = {}
local length = 0
while numKey < totalKeys and length < 20000 do
local lKey = ll.LinksetDataListKeys(numKey, 1)[1]
local lValue = ll.LinksetDataRead(lKey)
table.insert(data, {lKey, lValue})
length += #lKey + #lValue
numKey += 1
end
if numKey == totalKeys then
table.insert(data, {"", ""})
end
ll.SetContentType(id, CONTENT_TYPE_TEXT)
ll.HTTPResponse(id, 200, lljson.encode(data))
end
elseif method == "POST" then
local path = ll.ToLower(ll.GetHTTPHeader(id, "x-path-info"))
local data = lljson.decode(body)
local lKey = data.key
local lValue = data.value
if path == "/insert" then
ll.LinksetDataWrite(lKey, lValue)
elseif path == "/update" then
ll.LinksetDataWrite(lKey, lValue)
elseif path == "/delete" then
ll.LinksetDataDelete(lKey)
end
ll.SetContentType(id, CONTENT_TYPE_TEXT)
ll.HTTPResponse(id, 200, "Ok")
end
end
function on_rez(start_param)
ll.ResetScript()
end
function changed(change)
if bit32.btest(change, bit32.bor(CHANGED_REGION_START, CHANGED_OWNER)) then
ll.ResetScript()
end
end
initialize()