git » sdk » commit 5ff4ade

Initial work on XEP-0227 imports

author Stephen Paul Weber
2026-06-03 17:53:49 UTC
committer Stephen Paul Weber
2026-06-04 03:11:54 UTC
parent 939ac77e36dc7af79d3037409c34ba2f1b0f144e

Initial work on XEP-0227 imports

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