improve IRC channel continuity and formatting
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
"bundle:novnc": "node scripts/bundle-novnc.js",
|
"bundle:novnc": "node scripts/bundle-novnc.js",
|
||||||
"build": "node scripts/build.js",
|
"build": "node scripts/build.js",
|
||||||
"dev": "node scripts/build.js --watch",
|
"dev": "node scripts/build.js --watch",
|
||||||
"test": "node scripts/test-session-lifecycle.js && node scripts/test-regressions.js",
|
"test": "node scripts/test-session-lifecycle.js && node scripts/test-irc.js && node scripts/test-regressions.js",
|
||||||
"postinstall": "npm run install:assets && npm run bundle:novnc"
|
"postinstall": "npm run install:assets && npm run bundle:novnc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const vm = require("vm");
|
||||||
|
|
||||||
|
const frontendRoot = path.resolve(__dirname, "..");
|
||||||
|
const source = fs.readFileSync(path.join(frontendRoot, "src/js/irc.js"), "utf8");
|
||||||
|
const context = {
|
||||||
|
window: {
|
||||||
|
NexusTerminal: {
|
||||||
|
createElement: () => {
|
||||||
|
throw new Error("formatting tests must not create DOM elements");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(source, context, { filename: "src/js/irc.js" });
|
||||||
|
|
||||||
|
const formatting = context.window.NexusTerminal.IRCFormatting;
|
||||||
|
assert.ok(formatting, "IRC formatting helpers should be exported");
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
formatting.formatText("plain \x02bold\x02 \x1ditalic\x1d \x1funderlined\x1f"),
|
||||||
|
"plain \x1b[1mbold\x1b[22m \x1b[3mitalic\x1b[23m \x1b[4munderlined\x1b[24m",
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatting.formatText("\x0304red\x03 default"),
|
||||||
|
"\x1b[38;5;9mred\x1b[39;49m default",
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatting.formatText("\x0352extended \x0303,01matrix"),
|
||||||
|
"\x1b[38;5;196mextended \x1b[38;5;2m\x1b[48;5;0mmatrix",
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatting.formatText("\x04ff8800orange\x04 default"),
|
||||||
|
"\x1b[38;2;255;136;0morange\x1b[39;49m default",
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatting.formatText("\x0304,comma"),
|
||||||
|
"\x1b[38;5;9m,comma",
|
||||||
|
"a colour code without a background must leave its comma visible",
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatting.plainText("\x02bold\x02 \x0304red\x03"),
|
||||||
|
"bold red",
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatting.formatText("before\x1b[31munsafe\x1b]0;title\x07after"),
|
||||||
|
"beforeunsafeafter",
|
||||||
|
"untrusted ANSI and OSC sequences must not reach xterm",
|
||||||
|
);
|
||||||
|
|
||||||
|
const message = formatting.formatEvent(
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
from: "alice",
|
||||||
|
text: "hello \x02world\x02",
|
||||||
|
},
|
||||||
|
"12:34:56",
|
||||||
|
);
|
||||||
|
assert.ok(message.includes("[12:34:56]"));
|
||||||
|
assert.ok(message.includes("\x1b[1mworld\x1b[22m"));
|
||||||
|
assert.ok(message.includes("\x1b[1m\x1b[38;5;"), "nicknames should be bold and coloured");
|
||||||
|
assert.ok(message.endsWith("\x1b[0m"), "every line must reset terminal styling");
|
||||||
|
|
||||||
|
const action = formatting.formatEvent(
|
||||||
|
{ type: "action", from: "bob", text: "waves" },
|
||||||
|
"01:02:03",
|
||||||
|
);
|
||||||
|
assert.ok(action.includes("\x1b[3m"), "actions should be italicised");
|
||||||
|
|
||||||
|
console.log("IRC formatting regression tests passed.");
|
||||||
@@ -240,6 +240,16 @@ assertContains(
|
|||||||
"window.NexusTerminal.IRCViewer?.destroyViewer(sessionId)",
|
"window.NexusTerminal.IRCViewer?.destroyViewer(sessionId)",
|
||||||
"session teardown must dispose IRC terminals and resize observers",
|
"session teardown must dispose IRC terminals and resize observers",
|
||||||
);
|
);
|
||||||
|
assertContains(
|
||||||
|
"frontend/src/js/irc.js",
|
||||||
|
"wailsService.IRCRooms(this.sessionID)",
|
||||||
|
"IRC viewers created after connection must hydrate the already joined rooms",
|
||||||
|
);
|
||||||
|
assertContains(
|
||||||
|
"internal/sessions/manager.go",
|
||||||
|
"GetIRCLastRooms(profile.ID)",
|
||||||
|
"saved IRC profiles must restore the channels open in their last session",
|
||||||
|
);
|
||||||
assertContains(
|
assertContains(
|
||||||
"frontend/src/js/app.js",
|
"frontend/src/js/app.js",
|
||||||
"this.recordings.cleanup(sessionId)",
|
"this.recordings.cleanup(sessionId)",
|
||||||
|
|||||||
+206
-35
@@ -11,6 +11,188 @@
|
|||||||
const SERVER_TARGET = "server";
|
const SERVER_TARGET = "server";
|
||||||
const MAX_LINES_PER_TARGET = 2000;
|
const MAX_LINES_PER_TARGET = 2000;
|
||||||
const MAX_LISTED_ROOMS = 200;
|
const MAX_LISTED_ROOMS = 200;
|
||||||
|
const ANSI_RESET = "\x1b[0m";
|
||||||
|
const ANSI_DIM = "\x1b[2m";
|
||||||
|
const ANSI_BOLD = "\x1b[1m";
|
||||||
|
const ANSI_ITALIC = "\x1b[3m";
|
||||||
|
const ANSI_UNDERLINE = "\x1b[4m";
|
||||||
|
const ANSI_REVERSE = "\x1b[7m";
|
||||||
|
const ANSI_STRIKE = "\x1b[9m";
|
||||||
|
const ANSI_GREY = "\x1b[38;5;244m";
|
||||||
|
const ANSI_RED = "\x1b[38;5;203m";
|
||||||
|
const ANSI_GREEN = "\x1b[38;5;83m";
|
||||||
|
const ANSI_YELLOW = "\x1b[38;5;221m";
|
||||||
|
const ANSI_CYAN = "\x1b[38;5;81m";
|
||||||
|
const ANSI_MAGENTA = "\x1b[38;5;177m";
|
||||||
|
const NICK_COLOURS = [33, 39, 45, 69, 75, 81, 111, 141, 177, 207, 214, 220];
|
||||||
|
|
||||||
|
// IRC colours 00-98 mapped to the xterm 256-colour palette described by
|
||||||
|
// the modern IRC formatting specification. Colour 99 means terminal default.
|
||||||
|
const IRC_ANSI_COLOURS = [
|
||||||
|
15, 0, 4, 2, 9, 1, 5, 130, 11, 10, 6, 14, 12, 13, 8, 7,
|
||||||
|
52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89,
|
||||||
|
88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125,
|
||||||
|
124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161,
|
||||||
|
196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198,
|
||||||
|
203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205,
|
||||||
|
217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212,
|
||||||
|
16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
|
||||||
|
];
|
||||||
|
|
||||||
|
function stripTerminalEscapes(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
||||||
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
||||||
|
.replace(/\x1b./g, "")
|
||||||
|
.replace(/[\x80-\x9f]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDecimalColour(input, offset) {
|
||||||
|
const match = input.slice(offset).match(/^\d{1,2}/);
|
||||||
|
if (!match) return null;
|
||||||
|
return { value: Number(match[0]), length: match[0].length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readHexColour(input, offset) {
|
||||||
|
const value = input.slice(offset, offset + 6);
|
||||||
|
if (!/^[0-9a-f]{6}$/i.test(value)) return null;
|
||||||
|
return { value, length: 6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexedColour(code, background = false) {
|
||||||
|
if (code === 99 || code < 0 || code >= IRC_ANSI_COLOURS.length) {
|
||||||
|
return `\x1b[${background ? 49 : 39}m`;
|
||||||
|
}
|
||||||
|
return `\x1b[${background ? 48 : 38};5;${IRC_ANSI_COLOURS[code]}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexColour(value, background = false) {
|
||||||
|
const red = parseInt(value.slice(0, 2), 16);
|
||||||
|
const green = parseInt(value.slice(2, 4), 16);
|
||||||
|
const blue = parseInt(value.slice(4, 6), 16);
|
||||||
|
return `\x1b[${background ? 48 : 38};2;${red};${green};${blue}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIRCText(value) {
|
||||||
|
const input = stripTerminalEscapes(value);
|
||||||
|
const toggles = {
|
||||||
|
"\x02": [ANSI_BOLD, "\x1b[22m", false],
|
||||||
|
"\x1d": [ANSI_ITALIC, "\x1b[23m", false],
|
||||||
|
"\x1e": [ANSI_STRIKE, "\x1b[29m", false],
|
||||||
|
"\x1f": [ANSI_UNDERLINE, "\x1b[24m", false],
|
||||||
|
"\x16": [ANSI_REVERSE, "\x1b[27m", false],
|
||||||
|
};
|
||||||
|
let output = "";
|
||||||
|
|
||||||
|
for (let index = 0; index < input.length; index += 1) {
|
||||||
|
const char = input[index];
|
||||||
|
if (toggles[char]) {
|
||||||
|
const toggle = toggles[char];
|
||||||
|
toggle[2] = !toggle[2];
|
||||||
|
output += toggle[toggle[2] ? 0 : 1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "\x0f") {
|
||||||
|
output += ANSI_RESET;
|
||||||
|
for (const toggle of Object.values(toggles)) toggle[2] = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "\x03" || char === "\x04") {
|
||||||
|
const readColour = char === "\x03" ? readDecimalColour : readHexColour;
|
||||||
|
const foreground = readColour(input, index + 1);
|
||||||
|
if (!foreground) {
|
||||||
|
output += "\x1b[39;49m";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index += foreground.length;
|
||||||
|
output +=
|
||||||
|
char === "\x03"
|
||||||
|
? indexedColour(foreground.value)
|
||||||
|
: hexColour(foreground.value);
|
||||||
|
|
||||||
|
if (input[index + 1] !== ",") continue;
|
||||||
|
const background = readColour(input, index + 2);
|
||||||
|
if (!background) {
|
||||||
|
output += ",";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index += background.length + 1;
|
||||||
|
output +=
|
||||||
|
char === "\x03"
|
||||||
|
? indexedColour(background.value, true)
|
||||||
|
: hexColour(background.value, true);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// xterm is already monospace, so the IRC monospace toggle needs no SGR.
|
||||||
|
if (char === "\x11") continue;
|
||||||
|
const code = char.charCodeAt(0);
|
||||||
|
if (code < 0x20 || code === 0x7f) continue;
|
||||||
|
output += char;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainIRCText(value) {
|
||||||
|
return formatIRCText(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nickColour(nick) {
|
||||||
|
let hash = 0;
|
||||||
|
for (const char of String(nick || "")) {
|
||||||
|
hash = (hash * 31 + char.codePointAt(0)) >>> 0;
|
||||||
|
}
|
||||||
|
return `\x1b[38;5;${NICK_COLOURS[hash % NICK_COLOURS.length]}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIRCEvent(event, time) {
|
||||||
|
const from = plainIRCText(event?.from || "");
|
||||||
|
const text = formatIRCText(event?.text || "");
|
||||||
|
const subject = plainIRCText(event?.command || "");
|
||||||
|
const timestamp = `${ANSI_DIM}${ANSI_GREY}[${time}]${ANSI_RESET}`;
|
||||||
|
const styledNick = from
|
||||||
|
? `${ANSI_BOLD}${nickColour(from)}${from}${ANSI_RESET}`
|
||||||
|
: "";
|
||||||
|
let body;
|
||||||
|
|
||||||
|
switch (event?.type) {
|
||||||
|
case "message":
|
||||||
|
body = `${ANSI_GREY}<${ANSI_RESET}${styledNick}${ANSI_GREY}>${ANSI_RESET} ${text}`;
|
||||||
|
break;
|
||||||
|
case "action":
|
||||||
|
body = `${ANSI_MAGENTA}${ANSI_ITALIC}* ${styledNick}${ANSI_MAGENTA}${ANSI_ITALIC} ${text}`;
|
||||||
|
break;
|
||||||
|
case "notice":
|
||||||
|
body = `${ANSI_YELLOW}-${styledNick || "notice"}${ANSI_YELLOW}-${ANSI_RESET} ${text}`;
|
||||||
|
break;
|
||||||
|
case "join":
|
||||||
|
body = `${ANSI_GREEN}-->${ANSI_RESET} ${styledNick || "user"} joined`;
|
||||||
|
break;
|
||||||
|
case "part":
|
||||||
|
body = `${ANSI_RED}<--${ANSI_RESET} ${styledNick || "user"} left${text ? ` (${text})` : ""}`;
|
||||||
|
break;
|
||||||
|
case "kick":
|
||||||
|
body = `${ANSI_RED}<--${ANSI_RESET} ${ANSI_BOLD}${subject || "user"}${ANSI_RESET} was kicked by ${styledNick || "server"}${text ? ` (${text})` : ""}`;
|
||||||
|
break;
|
||||||
|
case "quit":
|
||||||
|
body = `${ANSI_RED}<--${ANSI_RESET} ${styledNick || "user"} quit${text ? ` (${text})` : ""}`;
|
||||||
|
break;
|
||||||
|
case "nick":
|
||||||
|
body = `${styledNick || "user"} is now known as ${ANSI_BOLD}${plainIRCText(event?.text || "")}${ANSI_RESET}`;
|
||||||
|
break;
|
||||||
|
case "topic":
|
||||||
|
body = `${ANSI_CYAN}${ANSI_BOLD}topic:${ANSI_RESET} ${text}`;
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
body = `${ANSI_RED}${ANSI_BOLD}error:${ANSI_RESET} ${text}`;
|
||||||
|
break;
|
||||||
|
case "system":
|
||||||
|
default:
|
||||||
|
body = `${ANSI_CYAN}${text}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return `${timestamp} ${body}${ANSI_RESET}`;
|
||||||
|
}
|
||||||
|
|
||||||
class IRCViewer {
|
class IRCViewer {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -68,6 +250,7 @@
|
|||||||
this.roomList = [];
|
this.roomList = [];
|
||||||
this.roomFilter = "";
|
this.roomFilter = "";
|
||||||
this.unreadTargets = new Set();
|
this.unreadTargets = new Set();
|
||||||
|
this.destroyed = false;
|
||||||
|
|
||||||
this.tabsEl = null;
|
this.tabsEl = null;
|
||||||
this.termHost = null;
|
this.termHost = null;
|
||||||
@@ -131,6 +314,7 @@
|
|||||||
text: "Connecting...",
|
text: "Connecting...",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
void this._refreshJoinedRooms();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEvent(event) {
|
handleEvent(event) {
|
||||||
@@ -170,7 +354,7 @@
|
|||||||
this._renderTabs();
|
this._renderTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["join", "part", "topic"].includes(event.type)) {
|
if (["join", "part", "kick", "topic"].includes(event.type)) {
|
||||||
this._renderRooms();
|
this._renderRooms();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,6 +371,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
this.destroyed = true;
|
||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
this.observer = null;
|
this.observer = null;
|
||||||
@@ -350,7 +535,7 @@
|
|||||||
for (const room of this.joinedRooms) {
|
for (const room of this.joinedRooms) {
|
||||||
const row = createElement("button", "irc-room-row", {
|
const row = createElement("button", "irc-room-row", {
|
||||||
type: "button",
|
type: "button",
|
||||||
title: room.topic || room.name,
|
title: plainIRCText(room.topic || room.name),
|
||||||
});
|
});
|
||||||
row.classList.toggle("active", room.name === this.activeTarget);
|
row.classList.toggle("active", room.name === this.activeTarget);
|
||||||
row.appendChild(createElement("span", "irc-room-name", { textContent: room.name }));
|
row.appendChild(createElement("span", "irc-room-name", { textContent: room.name }));
|
||||||
@@ -420,7 +605,7 @@
|
|||||||
const row = createElement("div", "irc-listed-room");
|
const row = createElement("div", "irc-listed-room");
|
||||||
const meta = createElement("button", "irc-listed-room-main", {
|
const meta = createElement("button", "irc-listed-room-main", {
|
||||||
type: "button",
|
type: "button",
|
||||||
title: room.topic || room.name,
|
title: plainIRCText(room.topic || room.name),
|
||||||
});
|
});
|
||||||
meta.appendChild(
|
meta.appendChild(
|
||||||
createElement("span", "irc-listed-room-name", {
|
createElement("span", "irc-listed-room-name", {
|
||||||
@@ -429,7 +614,7 @@
|
|||||||
);
|
);
|
||||||
meta.appendChild(
|
meta.appendChild(
|
||||||
createElement("span", "irc-listed-room-topic", {
|
createElement("span", "irc-listed-room-topic", {
|
||||||
textContent: room.topic || "",
|
textContent: plainIRCText(room.topic || ""),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
meta.addEventListener("click", () => this._joinRoom(room.name));
|
meta.addEventListener("click", () => this._joinRoom(room.name));
|
||||||
@@ -507,6 +692,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _refreshJoinedRooms() {
|
||||||
|
try {
|
||||||
|
const rooms = await window.NexusTerminal.wailsService.IRCRooms(this.sessionID);
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.handleEvent({ type: "rooms", rooms: Array.isArray(rooms) ? rooms : [] });
|
||||||
|
} catch (_) {
|
||||||
|
// The session may have closed while the viewer was being created.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_completeMention() {
|
_completeMention() {
|
||||||
if (!this.inputEl || !this._isRoom(this.activeTarget)) return false;
|
if (!this.inputEl || !this._isRoom(this.activeTarget)) return false;
|
||||||
const start = this.inputEl.selectionStart || 0;
|
const start = this.inputEl.selectionStart || 0;
|
||||||
@@ -560,32 +755,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_formatEvent(event) {
|
_formatEvent(event) {
|
||||||
const time = this._time(event.timestamp);
|
return formatIRCEvent(event, this._time(event.timestamp));
|
||||||
const from = this._sanitize(event.from || "");
|
|
||||||
const text = this._sanitize(event.text || "");
|
|
||||||
switch (event.type) {
|
|
||||||
case "message":
|
|
||||||
return `[${time}] <${from}> ${text}`;
|
|
||||||
case "action":
|
|
||||||
return `[${time}] * ${from} ${text}`;
|
|
||||||
case "notice":
|
|
||||||
return `[${time}] -${from || "notice"}- ${text}`;
|
|
||||||
case "join":
|
|
||||||
return `[${time}] --> ${from || "user"} joined`;
|
|
||||||
case "part":
|
|
||||||
return `[${time}] <-- ${from || "user"} left${text ? ` (${text})` : ""}`;
|
|
||||||
case "quit":
|
|
||||||
return `[${time}] <-- ${from || "user"} quit${text ? ` (${text})` : ""}`;
|
|
||||||
case "nick":
|
|
||||||
return `[${time}] ${from || "user"} is now known as ${text}`;
|
|
||||||
case "topic":
|
|
||||||
return `[${time}] topic: ${text}`;
|
|
||||||
case "error":
|
|
||||||
return `[${time}] error: ${text}`;
|
|
||||||
case "system":
|
|
||||||
default:
|
|
||||||
return `[${time}] ${text}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_time(value) {
|
_time(value) {
|
||||||
@@ -597,11 +767,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
_sanitize(value) {
|
_sanitize(value) {
|
||||||
return String(value || "")
|
return plainIRCText(value);
|
||||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
||||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
|
||||||
.replace(/\x03(\d{1,2}(,\d{1,2})?)?/g, "")
|
|
||||||
.replace(/[\x02\x0f\x16\x1d\x1f]/g, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_displayNick(user) {
|
_displayNick(user) {
|
||||||
@@ -620,5 +786,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.NexusTerminal = window.NexusTerminal || {};
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
|
window.NexusTerminal.IRCFormatting = Object.freeze({
|
||||||
|
formatEvent: formatIRCEvent,
|
||||||
|
formatText: formatIRCText,
|
||||||
|
plainText: plainIRCText,
|
||||||
|
});
|
||||||
window.NexusTerminal.IRCViewer = new IRCViewer();
|
window.NexusTerminal.IRCViewer = new IRCViewer();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type Config struct {
|
|||||||
OnEvent func(Event)
|
OnEvent func(Event)
|
||||||
OnError func(error)
|
OnError func(error)
|
||||||
OnClose func()
|
OnClose func()
|
||||||
|
OnRoomsChanged func([]string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event is a typed IRC event suitable for JSON delivery to the frontend.
|
// Event is a typed IRC event suitable for JSON delivery to the frontend.
|
||||||
@@ -85,6 +86,7 @@ type Client struct {
|
|||||||
connected bool
|
connected bool
|
||||||
closing bool
|
closing bool
|
||||||
listRooms []RoomInfo
|
listRooms []RoomInfo
|
||||||
|
roomNames []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an IRC client wrapper.
|
// New creates an IRC client wrapper.
|
||||||
@@ -406,10 +408,14 @@ func (c *Client) Rooms() []RoomInfo {
|
|||||||
if c.client == nil {
|
if c.client == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
channels := c.client.Channels()
|
c.mu.Lock()
|
||||||
rooms := make([]RoomInfo, 0, len(channels))
|
roomNames := append([]string{}, c.roomNames...)
|
||||||
for _, ch := range channels {
|
c.mu.Unlock()
|
||||||
|
rooms := make([]RoomInfo, 0, len(roomNames))
|
||||||
|
for _, roomName := range roomNames {
|
||||||
|
ch := c.client.LookupChannel(roomName)
|
||||||
if ch == nil {
|
if ch == nil {
|
||||||
|
rooms = append(rooms, RoomInfo{Name: roomName})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rooms = append(rooms, RoomInfo{
|
rooms = append(rooms, RoomInfo{
|
||||||
@@ -445,6 +451,7 @@ func (c *Client) registerHandlers() {
|
|||||||
c.client.Handlers.Add(girc.NOTICE, c.handleNotice)
|
c.client.Handlers.Add(girc.NOTICE, c.handleNotice)
|
||||||
c.client.Handlers.Add(girc.JOIN, c.handleJoin)
|
c.client.Handlers.Add(girc.JOIN, c.handleJoin)
|
||||||
c.client.Handlers.Add(girc.PART, c.handlePart)
|
c.client.Handlers.Add(girc.PART, c.handlePart)
|
||||||
|
c.client.Handlers.Add(girc.KICK, c.handleKick)
|
||||||
c.client.Handlers.Add(girc.QUIT, c.handleQuit)
|
c.client.Handlers.Add(girc.QUIT, c.handleQuit)
|
||||||
c.client.Handlers.Add(girc.NICK, c.handleNick)
|
c.client.Handlers.Add(girc.NICK, c.handleNick)
|
||||||
c.client.Handlers.Add(girc.TOPIC, c.handleTopic)
|
c.client.Handlers.Add(girc.TOPIC, c.handleTopic)
|
||||||
@@ -499,6 +506,9 @@ func (c *Client) handleJoin(gc *girc.Client, e girc.Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
from := sourceName(e)
|
from := sourceName(e)
|
||||||
|
if strings.EqualFold(from, gc.GetNick()) {
|
||||||
|
c.rememberRoomMembership(room, true)
|
||||||
|
}
|
||||||
c.emit(Event{Type: "join", Target: room, From: from, Text: fmt.Sprintf("%s joined %s", from, room), Timestamp: eventTime(e)})
|
c.emit(Event{Type: "join", Target: room, From: from, Text: fmt.Sprintf("%s joined %s", from, room), Timestamp: eventTime(e)})
|
||||||
c.emitRooms()
|
c.emitRooms()
|
||||||
c.emitUsers(room)
|
c.emitUsers(room)
|
||||||
@@ -514,11 +524,35 @@ func (c *Client) handlePart(gc *girc.Client, e girc.Event) {
|
|||||||
if len(e.Params) > 1 {
|
if len(e.Params) > 1 {
|
||||||
message = strings.TrimSpace(e.Last())
|
message = strings.TrimSpace(e.Last())
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(from, gc.GetNick()) {
|
||||||
|
c.rememberRoomMembership(room, false)
|
||||||
|
}
|
||||||
c.emit(Event{Type: "part", Target: room, From: from, Text: message, Timestamp: eventTime(e)})
|
c.emit(Event{Type: "part", Target: room, From: from, Text: message, Timestamp: eventTime(e)})
|
||||||
c.emitRooms()
|
c.emitRooms()
|
||||||
c.emitUsers(room)
|
c.emitUsers(room)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleKick(gc *girc.Client, e girc.Event) {
|
||||||
|
if len(e.Params) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
room := e.Params[0]
|
||||||
|
kicked := e.Params[1]
|
||||||
|
if strings.EqualFold(kicked, gc.GetNick()) {
|
||||||
|
c.rememberRoomMembership(room, false)
|
||||||
|
}
|
||||||
|
c.emit(Event{
|
||||||
|
Type: "kick",
|
||||||
|
Target: room,
|
||||||
|
From: sourceName(e),
|
||||||
|
Command: kicked,
|
||||||
|
Text: strings.TrimSpace(e.Last()),
|
||||||
|
Timestamp: eventTime(e),
|
||||||
|
})
|
||||||
|
c.emitRooms()
|
||||||
|
c.emitUsers(room)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) handleQuit(gc *girc.Client, e girc.Event) {
|
func (c *Client) handleQuit(gc *girc.Client, e girc.Event) {
|
||||||
from := sourceName(e)
|
from := sourceName(e)
|
||||||
c.emit(Event{Type: "quit", Target: ServerTarget, From: from, Text: strings.TrimSpace(e.Last()), Timestamp: eventTime(e)})
|
c.emit(Event{Type: "quit", Target: ServerTarget, From: from, Text: strings.TrimSpace(e.Last()), Timestamp: eventTime(e)})
|
||||||
@@ -661,7 +695,39 @@ func (c *Client) sshAuthMethods() ([]ssh.AuthMethod, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) emitRooms() {
|
func (c *Client) emitRooms() {
|
||||||
c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: c.Rooms(), Timestamp: time.Now().UnixMilli()})
|
rooms := c.Rooms()
|
||||||
|
c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: rooms, Timestamp: time.Now().UnixMilli()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) rememberRoomMembership(room string, joined bool) {
|
||||||
|
room = strings.TrimSpace(room)
|
||||||
|
if room == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
position := -1
|
||||||
|
changed := false
|
||||||
|
for i, existing := range c.roomNames {
|
||||||
|
if strings.EqualFold(existing, room) {
|
||||||
|
position = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if joined && position == -1 {
|
||||||
|
c.roomNames = append(c.roomNames, room)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if !joined && position >= 0 {
|
||||||
|
c.roomNames = append(c.roomNames[:position], c.roomNames[position+1:]...)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
names := append([]string{}, c.roomNames...)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if changed && c.cfg.OnRoomsChanged != nil {
|
||||||
|
c.cfg.OnRoomsChanged(names)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) emitUsers(room string) {
|
func (c *Client) emitUsers(room string) {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package ircclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRememberRoomMembershipTracksConfirmedChannels(t *testing.T) {
|
||||||
|
var updates []string
|
||||||
|
client := &Client{cfg: Config{
|
||||||
|
OnRoomsChanged: func(rooms []string) {
|
||||||
|
updates = append(updates, strings.Join(rooms, ","))
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
|
||||||
|
client.rememberRoomMembership("#One", true)
|
||||||
|
client.rememberRoomMembership("#two", true)
|
||||||
|
client.rememberRoomMembership("#one", true)
|
||||||
|
client.rememberRoomMembership("#missing", false)
|
||||||
|
client.rememberRoomMembership("#ONE", false)
|
||||||
|
|
||||||
|
if got, want := strings.Join(updates, "|"), "#One|#One,#two|#two"; got != want {
|
||||||
|
t.Fatalf("room updates = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := strings.Join(client.roomNames, ","); got != "#two" {
|
||||||
|
t.Fatalf("remembered rooms = %q, want %q", got, "#two")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1466,6 +1466,15 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin
|
|||||||
nickServPass = password
|
nickServPass = password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
autoJoinRooms := append([]string{}, ircConf.AutoJoinRooms...)
|
||||||
|
if m.store != nil && profile.ID != "" {
|
||||||
|
lastRooms, err := m.store.GetIRCLastRooms(profile.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loading remembered IRC rooms: %w", err)
|
||||||
|
}
|
||||||
|
autoJoinRooms = mergeIRCRooms(autoJoinRooms, lastRooms)
|
||||||
|
}
|
||||||
|
|
||||||
cfg := ircclient.Config{
|
cfg := ircclient.Config{
|
||||||
Server: profile.Host,
|
Server: profile.Host,
|
||||||
Port: profile.Port,
|
Port: profile.Port,
|
||||||
@@ -1474,7 +1483,7 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin
|
|||||||
RealName: ircConf.RealName,
|
RealName: ircConf.RealName,
|
||||||
UseTLS: ircConf.UseTLS,
|
UseTLS: ircConf.UseTLS,
|
||||||
ServerPass: serverPass,
|
ServerPass: serverPass,
|
||||||
AutoJoinRooms: append([]string{}, ircConf.AutoJoinRooms...),
|
AutoJoinRooms: autoJoinRooms,
|
||||||
NickServIdentify: ircConf.NickServIdentify,
|
NickServIdentify: ircConf.NickServIdentify,
|
||||||
NickServAccount: ircConf.NickServAccount,
|
NickServAccount: ircConf.NickServAccount,
|
||||||
NickServPassword: nickServPass,
|
NickServPassword: nickServPass,
|
||||||
@@ -1490,6 +1499,19 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin
|
|||||||
SSHAgentAuth: ircConf.SSHAgentAuth,
|
SSHAgentAuth: ircConf.SSHAgentAuth,
|
||||||
SSHHostKeyCallback: m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk),
|
SSHHostKeyCallback: m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk),
|
||||||
}
|
}
|
||||||
|
if m.store != nil && profile.ID != "" {
|
||||||
|
profileID := profile.ID
|
||||||
|
cfg.OnRoomsChanged = func(rooms []string) {
|
||||||
|
if err := m.store.SaveIRCLastRooms(profileID, rooms); err != nil {
|
||||||
|
m.emitIRCEvent(sess.Info.ID, ircclient.Event{
|
||||||
|
Type: "system",
|
||||||
|
Target: ircclient.ServerTarget,
|
||||||
|
Text: "Could not remember open IRC rooms: " + err.Error(),
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.User == "" {
|
if cfg.User == "" {
|
||||||
cfg.User = ircConf.Nick
|
cfg.User = ircConf.Nick
|
||||||
@@ -1554,6 +1576,26 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mergeIRCRooms(groups ...[]string) []string {
|
||||||
|
var rooms []string
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, group := range groups {
|
||||||
|
for _, room := range group {
|
||||||
|
room = strings.TrimSpace(room)
|
||||||
|
if room == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(room)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
rooms = append(rooms, room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rooms
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) emitIRCEvent(sessionID string, event ircclient.Event) {
|
func (m *Manager) emitIRCEvent(sessionID string, event ircclient.Event) {
|
||||||
m.emitEvent("session:irc:event", map[string]interface{}{
|
m.emitEvent("session:irc:event", map[string]interface{}{
|
||||||
"sessionID": sessionID,
|
"sessionID": sessionID,
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ func TestCreateTabName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeIRCRoomsPreservesConfiguredAndRememberedOrder(t *testing.T) {
|
||||||
|
got := mergeIRCRooms(
|
||||||
|
[]string{" #configured ", "#Shared"},
|
||||||
|
[]string{"#shared", "#last-session", "", "#CONFIGURED"},
|
||||||
|
)
|
||||||
|
if joined := strings.Join(got, ","); joined != "#configured,#Shared,#last-session" {
|
||||||
|
t.Fatalf("mergeIRCRooms() = %q, want configured rooms followed by new remembered rooms", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// RenameSession on non-existent session
|
// RenameSession on non-existent session
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Migration 006: Remember the channels open for each saved IRC profile.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS irc_channel_state (
|
||||||
|
profile_id TEXT NOT NULL,
|
||||||
|
room_name TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (profile_id, room_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_irc_channel_state_profile_position
|
||||||
|
ON irc_channel_state (profile_id, position);
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version, applied_at)
|
||||||
|
VALUES (6, datetime('now'));
|
||||||
@@ -33,6 +33,15 @@ func makeSSHProfile(name, host string) *models.Profile {
|
|||||||
return &p
|
return &p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeIRCProfile(name, host string) *models.Profile {
|
||||||
|
p := models.NewProfile(models.ProtocolIRC)
|
||||||
|
p.DisplayName = name
|
||||||
|
p.Host = host
|
||||||
|
p.Username = "nexus"
|
||||||
|
p.IRC.Nick = "nexus"
|
||||||
|
return &p
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Profile CRUD
|
// Profile CRUD
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -104,6 +113,54 @@ func TestDeleteProfileNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIRCLastRoomsRoundTripReplaceAndDelete(t *testing.T) {
|
||||||
|
store := setupTestStore(t)
|
||||||
|
p := makeIRCProfile("IRC Network", "irc.example.com")
|
||||||
|
if err := store.CreateProfile(p); err != nil {
|
||||||
|
t.Fatalf("CreateProfile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.SaveIRCLastRooms(p.ID, []string{" #Go-Nuts ", "#nexus", "#go-nuts", ""}); err != nil {
|
||||||
|
t.Fatalf("SaveIRCLastRooms() error = %v", err)
|
||||||
|
}
|
||||||
|
rooms, err := store.GetIRCLastRooms(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetIRCLastRooms() error = %v", err)
|
||||||
|
}
|
||||||
|
if got, want := strings.Join(rooms, ","), "#Go-Nuts,#nexus"; got != want {
|
||||||
|
t.Fatalf("GetIRCLastRooms() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.SaveIRCLastRooms(p.ID, []string{"#new-room"}); err != nil {
|
||||||
|
t.Fatalf("SaveIRCLastRooms(replace) error = %v", err)
|
||||||
|
}
|
||||||
|
rooms, err = store.GetIRCLastRooms(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetIRCLastRooms(replaced) error = %v", err)
|
||||||
|
}
|
||||||
|
if got, want := strings.Join(rooms, ","), "#new-room"; got != want {
|
||||||
|
t.Fatalf("GetIRCLastRooms(replaced) = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.DeleteProfile(p.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteProfile() error = %v", err)
|
||||||
|
}
|
||||||
|
rooms, err = store.GetIRCLastRooms(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetIRCLastRooms(after delete) error = %v", err)
|
||||||
|
}
|
||||||
|
if len(rooms) != 0 {
|
||||||
|
t.Fatalf("GetIRCLastRooms(after delete) = %v, want empty", rooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveIRCLastRoomsRejectsEmptyProfileID(t *testing.T) {
|
||||||
|
store := setupTestStore(t)
|
||||||
|
if err := store.SaveIRCLastRooms(" ", []string{"#room"}); err == nil {
|
||||||
|
t.Fatal("SaveIRCLastRooms() accepted an empty profile ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// List and search
|
// List and search
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -215,7 +215,16 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
|||||||
|
|
||||||
// DeleteProfile removes a profile by ID.
|
// DeleteProfile removes a profile by ID.
|
||||||
func (s *Store) DeleteProfile(id string) error {
|
func (s *Store) DeleteProfile(id string) error {
|
||||||
res, err := s.db.Exec("DELETE FROM profiles WHERE id = ?", id)
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage: failed to begin profile deletion: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec("DELETE FROM irc_channel_state WHERE profile_id = ?", id); err != nil {
|
||||||
|
return fmt.Errorf("storage: failed to delete IRC channel state: %w", err)
|
||||||
|
}
|
||||||
|
res, err := tx.Exec("DELETE FROM profiles WHERE id = ?", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("storage: failed to delete profile: %w", err)
|
return fmt.Errorf("storage: failed to delete profile: %w", err)
|
||||||
}
|
}
|
||||||
@@ -223,6 +232,9 @@ func (s *Store) DeleteProfile(id string) error {
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return fmt.Errorf("storage: profile %q not found", id)
|
return fmt.Errorf("storage: profile %q not found", id)
|
||||||
}
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("storage: failed to commit profile deletion: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,6 +562,80 @@ func (s *Store) ListAuditEntries(limit int) ([]*models.AuditEntry, error) {
|
|||||||
return entries, rows.Err()
|
return entries, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IRC channel state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GetIRCLastRooms returns the channels that were open the last time the given
|
||||||
|
// saved IRC profile was connected.
|
||||||
|
func (s *Store) GetIRCLastRooms(profileID string) ([]string, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT room_name FROM irc_channel_state
|
||||||
|
WHERE profile_id = ? ORDER BY position, room_name`, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: failed to get IRC channel state: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
rooms := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var room string
|
||||||
|
if err := rows.Scan(&room); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: failed to scan IRC channel state: %w", err)
|
||||||
|
}
|
||||||
|
rooms = append(rooms, room)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: failed to read IRC channel state: %w", err)
|
||||||
|
}
|
||||||
|
return rooms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveIRCLastRooms atomically replaces the remembered channel set for a saved
|
||||||
|
// IRC profile. Empty names and case-insensitive duplicates are discarded.
|
||||||
|
func (s *Store) SaveIRCLastRooms(profileID string, rooms []string) error {
|
||||||
|
profileID = strings.TrimSpace(profileID)
|
||||||
|
if profileID == "" {
|
||||||
|
return fmt.Errorf("storage: IRC channel state requires a profile ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := make([]string, 0, len(rooms))
|
||||||
|
seen := make(map[string]struct{}, len(rooms))
|
||||||
|
for _, room := range rooms {
|
||||||
|
room = strings.TrimSpace(room)
|
||||||
|
if room == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(room)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
normalized = append(normalized, room)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage: failed to begin IRC channel state update: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec("DELETE FROM irc_channel_state WHERE profile_id = ?", profileID); err != nil {
|
||||||
|
return fmt.Errorf("storage: failed to clear IRC channel state: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now().Format(time.RFC3339)
|
||||||
|
for position, room := range normalized {
|
||||||
|
if _, err := tx.Exec(`INSERT INTO irc_channel_state
|
||||||
|
(profile_id, room_name, position, updated_at) VALUES (?, ?, ?, ?)`,
|
||||||
|
profileID, room, position, now); err != nil {
|
||||||
|
return fmt.Errorf("storage: failed to save IRC channel state: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("storage: failed to commit IRC channel state: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Preferences (key-value)
|
// Preferences (key-value)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user