| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-06-03 17:53:49 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-06-04 03:11:54 UTC |
| parent | 939ac77e36dc7af79d3037409c34ba2f1b0f144e |
| Makefile | +2 | -0 |
| borogove/Import.hx | +202 | -0 |
| borogove/Stanza.hx | +10 | -3 |
| browserjs.hxml | +3 | -1 |
| nodejs.hxml | +2 | -0 |
| npm/index.ts | +1 | -0 |
| test.hxml | +2 | -1 |
| test/TestAll.hx | +11 | -10 |
| test/TestImport.hx | +180 | -0 |
| testcpp.hxml | +1 | -0 |
| testjs.hxml | +1 | -0 |
diff --git a/Makefile b/Makefile index 3fdbd34..97c1743 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,7 @@ hx-build-dep: npm/borogove-browser.js: haxe browserjs.hxml + sed -i 's/ implements haxe_IMap<K,V>//g' npm/borogove-browser.d.ts sed -i '/;var $$hx_exports = typeof exports != "undefined" ? exports : globalThis;/d' npm/borogove-browser.js sed -i '/\$$hx_exports.*|| {};/d' npm/borogove-browser.js sed -i 's/^$$hx_exports[^=]*=\(.*\);$$/export {\1 };/g' npm/borogove-browser.js @@ -44,6 +45,7 @@ npm/borogove-browser.js: npm/borogove.js: haxe nodejs.hxml + sed -i 's/ implements haxe_IMap<K,V>//g' npm/borogove.d.ts sed -i '/;var $$hx_exports = typeof exports != "undefined" ? exports : globalThis;/d' npm/borogove.js sed -i '/\$$hx_exports.*|| {};/d' npm/borogove.js sed -i 's/^$$hx_exports[^=]*=\(.*\);$$/export {\1 };/g' npm/borogove.js diff --git a/borogove/Import.hx b/borogove/Import.hx new file mode 100644 index 0000000..f2f24e4 --- /dev/null +++ b/borogove/Import.hx @@ -0,0 +1,202 @@ +package borogove; + +import ltx.Sax; +using StringTools; + +@:expose +@:access(Xml) +class Import { + private final p: Sax; + private final targetAccount: Null<String>; + private var sortA: String; + private final sortB: String; + private var counterSameTime = 0; + private var previousMessageTime = ""; + private var ignoredSources = new Map(); + + public var onAccount: String->Void; + public var onChannel: String->Void; + public var onMessage: (String, Message)->Void; + + public function new(targetAccount: Null<String>, sortA: String = "Wt@|q", sortB: String = "a ") { + this.targetAccount = targetAccount; + this.sortA = sortA; + this.sortB = sortB; + p = new Sax(); + + var ns = new NsContext(null, new Map()); + var host = null; + var item = null; + var mucComponent = false; + var inArchive = false; + var resultStanza: Null<Stanza> = null; + p.onStartElement = (tag: Xml) -> { + ns = NsContext.from(ns, tag); + + if (resultStanza != null) { + resultStanza.tag(tag.nodeName, tag.attributeMap); + return; + } + + switch (ns.expandName(tag.nodeName)) { + case "{urn:xmpp:pie:0}host": + host = tag.get("jid"); + case "{urn:xmpp:pie:0}user": + item = tag.get("name"); + if (onAccount != null) onAccount(item + "@" + host); + case "{urn:xmpp:pie:0#component}component": + host = tag.get("jid"); + mucComponent = tag.get("type") == "muc"; + case "{urn:xmpp:pie:0#component}item": + item = tag.get("name"); + if (mucComponent && onChannel != null) onChannel(item + "@" + host); + case "{urn:xmpp:pie:0#mam}archive": + if (item != null && host != null) inArchive = true; + case "{urn:xmpp:mam:2}result": + if (inArchive && onMessage != null && !ignoredSources[item + "@" + host]) { + resultStanza = new Stanza("result", tag.attributeMap); + } + } + }; + p.onEndElement = (tagName: String) -> { + if (resultStanza != null) { + resultStanza.up(); + if (resultStanza.atTop()) { + if (onMessage != null) processResultStanza(resultStanza, item, host); + resultStanza = null; + } + return; + } + + switch (ns.expandName(tagName)) { + case "{urn:xmpp:pie:0}host": + host = null; + case "{urn:xmpp:pie:0#component}component": + host = null; + mucComponent = false; + case "{urn:xmpp:pie:0#component}item", "{urn:xmpp:pie:0}user": + item = null; + case "{urn:xmpp:pie:0#mam}archive": + inArchive = false; + case "{urn:xmpp:mam:2}result": + resultStanza = null; + } + + ns = ns.parent; + }; + + p.onText = (txt: String) -> { + if (resultStanza != null) resultStanza.text(txt); + }; + } + + public function ignoreSource(source: String) { + ignoredSources.set(source, true); + } + + public function write(chunk: String) { + p.write(chunk); + } + + inline private function processResultStanza(resultStanza: Stanza, item: String, host: String) { + final originalMessage = resultStanza.findChild("{urn:xmpp:forward:0}forwarded/{jabber:client}message"); + if (originalMessage == null) return; + + final source = item + "@" + host; + if (ignoredSources.get(source)) return; + + if (targetAccount != null) { + // TODO: setup senderId and direction on MUC messages based on targetAccount? + // What if we don't even know target occupant id at this time? Just direction? + // Worst case if we don't is sent messages show as received from a different account... + // which is honestly kinda right so may don't do this? + + final from = originalMessage.attr.get("from"); + if (from != null && from.startsWith(source)) { + originalMessage.attr.set("from", targetAccount); + } + + final to = originalMessage.attr.get("to"); + if (to != null && to.startsWith(source)) { + originalMessage.attr.set("to", targetAccount); + } + } + + sortA = FractionalIndexing.between(sortA, sortB, FractionalIndexing.BASE_95_DIGITS); + var timestamp = resultStanza.findText("{urn:xmpp:forward:0}forwarded/{urn:xmpp:delay}delay@stamp"); + if (timestamp != null) { + // If no subseconds, fix them to at least sort right + timestamp = ~/([0-9][0-9]:[0-9][0-9]:[0-9][0-9])(\.[0-9][0-9][0-9])?/.map(timestamp, (ereg) -> { + if (ereg.matched(2) == null || ereg.matched(2) == ".000") { + if (ereg.matched(1) == previousMessageTime) { + counterSameTime++; + } else { + previousMessageTime = ereg.matched(1); + counterSameTime = 1; + } + + return ereg.matched(1) + "." + Std.string(counterSameTime).lpad("0", 3); + } + + return ereg.matched(0); + }); + } + + if (resultStanza.attr.get("id") == null && originalMessage.attr.get("id") == null) { + // Skip messages in the export with no id of any kind + return; + } + + final msg = Message.fromStanza(originalMessage, targetAccount == null ? new JID(item, host) : JID.parse(targetAccount), (builder, stanza) -> { + builder.sortId = sortA; + builder.serverId = resultStanza.attr.get("id"); + builder.serverIdBy = source; + if (timestamp != null && builder.timestamp == null) builder.timestamp = timestamp; + return builder; + }); + + onMessage(source, msg); + } +} + +class NsContext { + public final parent: Null<NsContext>; + private final ns: Map<String, String>; + + public function new(parent: Null<NsContext>, ns: Map<String, String>) { + this.parent = parent; + this.ns = ns; + } + + static public function from(parent: Null<NsContext>, tag: Xml) { + final ns = new Map(); + for (att in tag.attributes()) { + if (att == "xmlns") { + ns.set("", tag.get(att)); + } else if(att.startsWith("xmlns:")) { + ns.set(att.substr(6), tag.get(att)); + } + } + + return new NsContext(parent, ns); + } + + public function get(prefix: String) { + final uri = ns.get(prefix); + if (uri != null) return uri; + + return parent?.get(prefix); + } + + public function expandName(name: String) { + var parts = name.split(":"); + if (parts[0] == "xml") return name; + + if (parts.length < 2) parts = ["", name]; + + final ns = get(parts[0]); + if (ns == null) throw "Undefined prefix"; + + return '{$ns}${parts[1]}'; + } +} diff --git a/borogove/Stanza.hx b/borogove/Stanza.hx index fb9a555..44f1953 100644 --- a/borogove/Stanza.hx +++ b/borogove/Stanza.hx @@ -72,11 +72,14 @@ class Stanza { private var last_added_stack(null, null):Array<Stanza> = []; private var serialized: Null<String> = null; - public function new(name:String, ?attr:DynamicAccess<String>) { + public function new(name:String, ?attr:DynamicAccess<String>, ?attrMap:haxe.ds.StringMap<String>) { this.name = name; if(attr != null) { this.attr = attr; } + if (attrMap != null) { + for (k => v in attrMap) this.attr.set(k, v); + } this.last_added = this; }; @@ -155,9 +158,9 @@ class Stanza { } #end - public function tag(name:String, ?attr:DynamicAccess<String>) { + public function tag(name:String, ?attr:DynamicAccess<String>, ?attrMap:haxe.ds.StringMap<String>) { serialized = null; - var child = new Stanza(name, attr); + var child = new Stanza(name, attr, attrMap); this.last_added.addDirectChild(Element(child)); this.last_added_stack.push(this.last_added); this.last_added = child; @@ -176,6 +179,10 @@ class Stanza { return this; } + public function atTop() { + return this.last_added == this; + } + public function up() { if(this.last_added != this) { this.last_added = this.last_added_stack.pop(); diff --git a/browserjs.hxml b/browserjs.hxml index 08fead8..942e9d8 100644 --- a/browserjs.hxml +++ b/browserjs.hxml @@ -1,13 +1,14 @@ --library HtmlParser --library datetime +--library fractional-indexing --library haxe-strings --library hsluv --library hxtsdgen --library jsImport +--library ltx --library thenshim --library tink_http --library uuidv7 ---library fractional-indexing borogove.Client borogove.Register @@ -15,6 +16,7 @@ borogove.Push borogove.Version borogove.persistence.Sqlite borogove.Html +borogove.Import -D analyzer-optimize -D js-es=6 diff --git a/nodejs.hxml b/nodejs.hxml index df0df9f..cbc4efe 100644 --- a/nodejs.hxml +++ b/nodejs.hxml @@ -6,6 +6,7 @@ --library hxnodejs --library hxtsdgen --library jsImport +--library ltx --library thenshim --library tink_http --library uuidv7 @@ -17,6 +18,7 @@ borogove.Version borogove.persistence.Sqlite borogove.persistence.MediaStoreFS borogove.Html +borogove.Import --macro borogove.Util.DummyRequireMacro.init() -D analyzer-optimize diff --git a/npm/index.ts b/npm/index.ts index bee6fb7..3224dcf 100644 --- a/npm/index.ts +++ b/npm/index.ts @@ -29,6 +29,7 @@ export { borogove_Hash as Hash, borogove_Html as Html, borogove_Identicon as Identicon, + borogove_Import as Import, borogove_LinkMetadata as LinkMetadata, borogove_Notification as Notification, borogove_Member as Member, diff --git a/test.hxml b/test.hxml index 2c7f27b..4284c3b 100644 --- a/test.hxml +++ b/test.hxml @@ -1,12 +1,13 @@ --library HtmlParser --library datetime +--library fractional-indexing --library haxe-strings --library hsluv --library hxtsdgen +--library ltx --library thenshim --library tink_http --library uuidv7 ---library fractional-indexing --library utest diff --git a/test/TestAll.hx b/test/TestAll.hx index 91750a2..fa91928 100644 --- a/test/TestAll.hx +++ b/test/TestAll.hx @@ -15,24 +15,25 @@ class TestAll { utest.UTest.run([ new TestCapsRepo(), + new TestChat(), new TestChatMessage(), - new TestSessionDescription(), new TestChatMessageBuilder(), - new TestStanza(), - new TestPresence(), new TestClient(), + new TestEmojiUtil(), + new TestHtml(), + new TestImport(), + new TestJID(), new TestMember(), new TestMemberUpdate(), - new TestChat(), + new TestPresence(), + new TestReaction(), + new TestSessionDescription(), new TestSortId(), - new TestXEP0393(), - new TestEmojiUtil(), - new TestJID(), + new TestStanza(), + new TestStatus(), new TestStringUtil(), new TestUtil(), - new TestReaction(), - new TestHtml(), - new TestStatus(), + new TestXEP0393(), #if eval new TestCaps(), #else diff --git a/test/TestImport.hx b/test/TestImport.hx new file mode 100644 index 0000000..94eb070 --- /dev/null +++ b/test/TestImport.hx @@ -0,0 +1,180 @@ +package test; + +import utest.Assert; + +import borogove.Import; +import borogove.Message; +import borogove.JID; + +class TestImport extends utest.Test { + public function testOnAccount() { + final accounts = []; + + final i = new Import(null); + i.onAccount = (id) -> { + accounts.push(id); + }; + i.write("<server-data xmlns='urn:xmpp:pie:0' xmlns:ns0='urn:xmpp:pie:0'>"); + i.write("<host jid='capulet.com'>"); + i.write("<user name='juliet' />"); + i.write("</host>"); + i.write("<ns0:host jid='montague.net'>"); + i.write("<ns0:user name='romeo' />"); + i.write("</ns0:host>"); + i.write("</server-data>"); + + Assert.equals(2, accounts.length); + Assert.equals("juliet@capulet.com", accounts[0]); + Assert.equals("romeo@montague.net", accounts[1]); + } + + public function testOnChannel() { + final channels = []; + + final i = new Import(null); + i.onChannel = (id) -> { + channels.push(id); + }; + i.write("<server-data xmlns='urn:xmpp:pie:0'>"); + i.write("<component xmlns='urn:xmpp:pie:0#component' jid='capulet.com' type='muc'>"); + i.write("<item name='party' />"); + i.write("</component>"); + i.write("<component xmlns='urn:xmpp:pie:0#component' jid='other.capulet.com' type='other'>"); + i.write("<item name='party' />"); + i.write("</component>"); + i.write("</server-data>"); + + Assert.equals(1, channels.length); + Assert.equals("party@capulet.com", channels[0]); + } + + public function testOnMamItem() { + final items = []; + + final i = new Import(null); + i.onMessage = (source, msg) -> { + items.push(msg); + }; + i.write("<server-data xmlns='urn:xmpp:pie:0'>"); + i.write("<host jid='capulet.com'>"); + i.write("<user name='juliet'>"); + i.write("<archive xmlns='urn:xmpp:pie:0#mam'>"); + i.write("<result xmlns='urn:xmpp:mam:2' id='mam-id-1'>"); + i.write("<forwarded xmlns='urn:xmpp:forward:0'>"); + i.write("<delay xmlns='urn:xmpp:delay' stamp='2023-10-27T10:00:00Z' />"); + i.write("<message xmlns='jabber:client' from='juliet@capulet.com/balcony' to='romeo@montague.net' id='msg-id-1'>"); + i.write("<body>Hello Romeo</body>"); + i.write("</message>"); + i.write("</forwarded>"); + i.write("</result>"); + i.write("</archive>"); + i.write("</user>"); + i.write("</host>"); + i.write("</server-data>"); + + Assert.equals(1, items.length); + final msg = items[0]; + Assert.equals("romeo@montague.net", msg.chatId); + Assert.equals("juliet@capulet.com", msg.senderId); + switch (msg.parsed) { + case ChatMessageStanza(chatMsg): + Assert.equals("Hello Romeo", chatMsg.body().toPlainText()); + Assert.equals("mam-id-1", chatMsg.serverId); + Assert.equals("juliet@capulet.com", chatMsg.serverIdBy); + Assert.equals("2023-10-27T10:00:00.001Z", chatMsg.timestamp); + Assert.isFalse(chatMsg.isIncoming()); + default: + Assert.fail("Expected ChatMessageStanza"); + } + } + + public function testTargetAccount() { + final items = []; + + final i = new Import("me@example.com"); + i.onMessage = (source, msg) -> { + items.push(msg); + }; + i.write("<server-data xmlns='urn:xmpp:pie:0'>"); + i.write("<host jid='capulet.com'>"); + i.write("<user name='juliet'>"); + i.write("<archive xmlns='urn:xmpp:pie:0#mam'>"); + i.write("<result xmlns='urn:xmpp:mam:2' id='mam-id-1'>"); + i.write("<forwarded xmlns='urn:xmpp:forward:0'>"); + i.write("<delay xmlns='urn:xmpp:delay' stamp='2023-10-27T10:00:00Z' />"); + i.write("<message xmlns='jabber:client' from='juliet@capulet.com/balcony' to='romeo@montague.net' id='msg-id-1'>"); + i.write("<body>Hello Romeo</body>"); + i.write("</message>"); + i.write("</forwarded>"); + i.write("</result>"); + i.write("</archive>"); + i.write("</user>"); + i.write("</host>"); + i.write("</server-data>"); + + Assert.equals(1, items.length); + final msg = items[0]; + Assert.equals("romeo@montague.net", msg.chatId); + Assert.equals("me@example.com", msg.senderId); + switch (msg.parsed) { + case ChatMessageStanza(chatMsg): + Assert.isFalse(chatMsg.isIncoming()); + default: + Assert.fail("Expected ChatMessageStanza"); + } + } + + public function testTimestampFixing() { + final items = []; + + final i = new Import(null); + i.onMessage = (source, msg) -> { + items.push(msg); + }; + i.write("<server-data xmlns='urn:xmpp:pie:0'>"); + i.write("<host jid='capulet.com'>"); + i.write("<user name='juliet'>"); + i.write("<archive xmlns='urn:xmpp:pie:0#mam'>"); + + i.write("<result xmlns='urn:xmpp:mam:2' id='mam-id-1'>"); + i.write("<forwarded xmlns='urn:xmpp:forward:0'>"); + i.write("<delay xmlns='urn:xmpp:delay' stamp='2023-10-27T10:00:00Z' />"); + i.write("<message xmlns='jabber:client' from='juliet@capulet.com' to='romeo@montague.net' id='m1'><body>1</body></message>"); + i.write("</forwarded>"); + i.write("</result>"); + + i.write("<result xmlns='urn:xmpp:mam:2' id='mam-id-2'>"); + i.write("<forwarded xmlns='urn:xmpp:forward:0'>"); + i.write("<delay xmlns='urn:xmpp:delay' stamp='2023-10-27T10:00:00Z' />"); + i.write("<message xmlns='jabber:client' from='juliet@capulet.com' to='romeo@montague.net' id='m2'><body>2</body></message>"); + i.write("</forwarded>"); + i.write("</result>"); + + i.write("<result xmlns='urn:xmpp:mam:2' id='mam-id-3'>"); + i.write("<forwarded xmlns='urn:xmpp:forward:0'>"); + i.write("<delay xmlns='urn:xmpp:delay' stamp='2023-10-27T10:00:01Z' />"); + i.write("<message xmlns='jabber:client' from='juliet@capulet.com' to='romeo@montague.net' id='m3'><body>3</body></message>"); + i.write("</forwarded>"); + i.write("</result>"); + + i.write("</archive>"); + i.write("</user>"); + i.write("</host>"); + i.write("</server-data>"); + + Assert.equals(3, items.length); + + switch (items[0].parsed) { + case ChatMessageStanza(m): Assert.equals("2023-10-27T10:00:00.001Z", m.timestamp); + default: Assert.fail(); + } + switch (items[1].parsed) { + case ChatMessageStanza(m): Assert.equals("2023-10-27T10:00:00.002Z", m.timestamp); + default: Assert.fail(); + } + switch (items[2].parsed) { + case ChatMessageStanza(m): Assert.equals("2023-10-27T10:00:01.001Z", m.timestamp); + default: Assert.fail(); + } + } +} diff --git a/testcpp.hxml b/testcpp.hxml index e655c76..76f4de0 100644 --- a/testcpp.hxml +++ b/testcpp.hxml @@ -3,6 +3,7 @@ --library fractional-indexing --library haxe-strings --library hsluv +--library ltx --library thenshim --library tink_http --library uuidv7 diff --git a/testjs.hxml b/testjs.hxml index c7db676..a095639 100644 --- a/testjs.hxml +++ b/testjs.hxml @@ -5,6 +5,7 @@ --library hsluv --library hxnodejs --library jsImport +--library ltx --library thenshim --library tink_http --library uuidv7