@majikah/majik-message 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/core/client-state-manager.d.ts +105 -0
- package/dist/core/client-state-manager.js +250 -0
- package/dist/core/contacts/majik-contact-directory.d.ts +0 -5
- package/dist/core/contacts/majik-contact-directory.js +0 -12
- package/dist/core/contacts/majik-contact-groups.d.ts +1 -0
- package/dist/core/contacts/majik-contact-groups.js +5 -0
- package/dist/core/contacts/majik-contact-manager.d.ts +99 -185
- package/dist/core/contacts/majik-contact-manager.js +469 -289
- package/dist/core/contacts/types.d.ts +1 -0
- package/dist/core/crypto/keystore-manager.d.ts +166 -0
- package/dist/core/crypto/keystore-manager.js +371 -0
- package/dist/core/storage/chats/_types.d.ts +8 -0
- package/dist/core/storage/chats/_types.js +1 -0
- package/dist/core/storage/chats/adapter-idb.d.ts +3 -0
- package/dist/core/storage/chats/adapter-idb.js +5 -0
- package/dist/core/storage/chats/adapter-memory.d.ts +23 -0
- package/dist/core/storage/chats/adapter-memory.js +44 -0
- package/dist/core/storage/chats/adapter-sql.d.ts +17 -0
- package/dist/core/storage/chats/adapter-sql.js +85 -0
- package/dist/core/storage/client-state/_types.d.ts +37 -0
- package/dist/core/storage/client-state/_types.js +16 -0
- package/dist/core/storage/client-state/adapter-idb.d.ts +17 -0
- package/dist/core/storage/client-state/adapter-idb.js +19 -0
- package/dist/core/storage/client-state/adapter-memory.d.ts +20 -0
- package/dist/core/storage/client-state/adapter-memory.js +44 -0
- package/dist/core/storage/client-state/adapter-sql.d.ts +41 -0
- package/dist/core/storage/client-state/adapter-sql.js +104 -0
- package/dist/core/storage/contact-directory/contacts/_types.d.ts +3 -0
- package/dist/core/storage/contact-directory/contacts/_types.js +1 -0
- package/dist/core/storage/contact-directory/contacts/adapter-idb.d.ts +3 -0
- package/dist/core/storage/contact-directory/contacts/adapter-idb.js +5 -0
- package/dist/core/storage/contact-directory/contacts/adapter-memory.d.ts +14 -0
- package/dist/core/storage/contact-directory/contacts/adapter-memory.js +32 -0
- package/dist/core/storage/contact-directory/contacts/adapter-sql.d.ts +18 -0
- package/dist/core/storage/contact-directory/contacts/adapter-sql.js +97 -0
- package/dist/core/storage/contact-directory/groups/_types.d.ts +3 -0
- package/dist/core/storage/contact-directory/groups/_types.js +1 -0
- package/dist/core/storage/contact-directory/groups/adapter-idb.d.ts +3 -0
- package/dist/core/storage/contact-directory/groups/adapter-idb.js +5 -0
- package/dist/core/storage/contact-directory/groups/adapter-memory.d.ts +14 -0
- package/dist/core/storage/contact-directory/groups/adapter-memory.js +32 -0
- package/dist/core/storage/contact-directory/groups/adapter-sql.d.ts +16 -0
- package/dist/core/storage/contact-directory/groups/adapter-sql.js +72 -0
- package/dist/core/storage/idb-adapter.d.ts +21 -0
- package/dist/core/storage/idb-adapter.js +107 -0
- package/dist/core/storage/index.d.ts +24 -0
- package/dist/core/storage/index.js +19 -0
- package/dist/core/storage/keystore/_types.d.ts +3 -0
- package/dist/core/storage/keystore/_types.js +1 -0
- package/dist/core/storage/keystore/adapter-idb.d.ts +3 -0
- package/dist/core/storage/keystore/adapter-idb.js +5 -0
- package/dist/core/storage/keystore/adapter-memory.d.ts +14 -0
- package/dist/core/storage/keystore/adapter-memory.js +32 -0
- package/dist/core/storage/keystore/adapter-sql.d.ts +18 -0
- package/dist/core/storage/keystore/adapter-sql.js +93 -0
- package/dist/core/storage/sql-db-manager.d.ts +13 -0
- package/dist/core/storage/sql-db-manager.js +59 -0
- package/dist/core/storage/sql-schema.d.ts +25 -0
- package/dist/core/storage/sql-schema.js +122 -0
- package/dist/core/storage/storage-adapter.d.ts +22 -0
- package/dist/core/storage/storage-adapter.js +1 -0
- package/dist/index.d.ts +2 -4
- package/dist/index.js +2 -4
- package/dist/majik-message.d.ts +114 -174
- package/dist/majik-message.js +449 -675
- package/package.json +5 -6
|
@@ -1,36 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
* Types
|
|
3
|
-
* ------------------------------- */
|
|
1
|
+
import { MajikContact, } from "@majikah/majik-contact";
|
|
4
2
|
import { MajikContactDirectory } from "./majik-contact-directory";
|
|
5
3
|
import { MajikContactGroupManager } from "./majik-contact-groups";
|
|
6
4
|
import { MajikContactManagerError } from "./errors";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* Responsibilities:
|
|
14
|
-
* - Owns both the directory and the group manager as a single cohesive unit
|
|
15
|
-
* - Proxies all directory methods so MajikMessage call sites need only change
|
|
16
|
-
* `contactDirectory` → `contacts` with no logic changes
|
|
17
|
-
* - Wires lifecycle hooks automatically so callers can never forget them:
|
|
18
|
-
* • removeContact() → always calls groups.handleContactRemoved()
|
|
19
|
-
* • blockContact() → always syncs the Blocked system group
|
|
20
|
-
* • unblockContact() → always syncs the Blocked system group
|
|
21
|
-
* - Exposes the group manager via `.group` for all group-specific operations
|
|
22
|
-
* - Serializes both directory and groups into one unified payload for
|
|
23
|
-
* MajikMessage.toJSON() / MajikMessage.fromJSON()
|
|
24
|
-
*
|
|
25
|
-
* Construction:
|
|
26
|
-
* - Pass nothing → fresh directory + fresh group manager (new session)
|
|
27
|
-
* - Pass a directory → wraps it, creates a fresh group manager bound to it
|
|
28
|
-
* - Pass both → fully restores a prior session (used by fromJSON)
|
|
29
|
-
*/
|
|
5
|
+
import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, base64ToUint8Array, } from "../utils/utilities";
|
|
6
|
+
import { KEY_ALGO } from "../crypto/constants";
|
|
7
|
+
import { gunzipSync, gzipSync } from "fflate";
|
|
8
|
+
import { InMemoryContactAdapter } from "../storage/contact-directory/contacts/adapter-memory";
|
|
9
|
+
import { InMemoryContactGroupAdapter } from "../storage/contact-directory/groups/adapter-memory";
|
|
10
|
+
import { MajikEnvelope } from "@majikah/majik-envelope";
|
|
30
11
|
export class MajikContactManager {
|
|
31
12
|
directory;
|
|
32
13
|
groupManager;
|
|
33
|
-
|
|
14
|
+
_contactAdapter;
|
|
15
|
+
_groupAdapter;
|
|
16
|
+
constructor(directory, groupManager, adapters) {
|
|
34
17
|
this.directory = directory ?? new MajikContactDirectory();
|
|
35
18
|
if (groupManager) {
|
|
36
19
|
this.assertGroupManagerInstance(groupManager);
|
|
@@ -39,54 +22,169 @@ export class MajikContactManager {
|
|
|
39
22
|
else {
|
|
40
23
|
this.groupManager = new MajikContactGroupManager(this.directory);
|
|
41
24
|
}
|
|
25
|
+
this._contactAdapter = adapters?.contacts ?? new InMemoryContactAdapter();
|
|
26
|
+
this._groupAdapter = adapters?.groups ?? new InMemoryContactGroupAdapter();
|
|
42
27
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
28
|
+
// ── Adapter management ────────────────────────────────────────────────────
|
|
29
|
+
get contactAdapter() {
|
|
30
|
+
return this._contactAdapter;
|
|
31
|
+
}
|
|
32
|
+
get groupAdapter() {
|
|
33
|
+
return this._groupAdapter;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Swap both adapters at runtime. Does NOT migrate data.
|
|
37
|
+
*
|
|
38
|
+
* Migration pattern:
|
|
39
|
+
* ```ts
|
|
40
|
+
* const snap = await manager.toJSON();
|
|
41
|
+
* manager.setAdapters({ contacts: new IDBContactAdapter(), groups: new IDBGroupAdapter() });
|
|
42
|
+
* await manager.hydrate(); // warms from new (empty) adapters
|
|
43
|
+
* await manager.bulkRestoreFromJSON(snap); // writes old data into new adapters
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
setAdapters(adapters) {
|
|
47
|
+
if (adapters.contacts)
|
|
48
|
+
this._contactAdapter = adapters.contacts;
|
|
49
|
+
if (adapters.groups)
|
|
50
|
+
this._groupAdapter = adapters.groups;
|
|
51
|
+
}
|
|
52
|
+
// ── Hydration ─────────────────────────────────────────────────────────────
|
|
53
|
+
/**
|
|
54
|
+
* Load all contacts and groups from the adapters into the in-memory
|
|
55
|
+
* directory and group manager. Call once after construction (or after
|
|
56
|
+
* swapping adapters).
|
|
57
|
+
*
|
|
58
|
+
* Restoration order:
|
|
59
|
+
* 1. Contacts — must come first so groups can validate member existence.
|
|
60
|
+
* 2. Groups — restored via groupManager.fromJSON() which rebuilds the
|
|
61
|
+
* reverse index and re-bootstraps system groups.
|
|
62
|
+
* 3. Orphan pruning — any group member ID not present in the restored
|
|
63
|
+
* directory is silently removed (guards against data drift).
|
|
64
|
+
*/
|
|
65
|
+
async hydrate() {
|
|
66
|
+
// ── 1. Contacts ───────────────────────────────────────────────────────
|
|
67
|
+
const serializedContacts = await this._contactAdapter.list();
|
|
68
|
+
this.directory.clear();
|
|
69
|
+
for (const item of serializedContacts) {
|
|
70
|
+
try {
|
|
71
|
+
const raw = base64ToArrayBuffer(item.publicKeyBase64);
|
|
72
|
+
let publicKey;
|
|
73
|
+
try {
|
|
74
|
+
publicKey = await crypto.subtle.importKey("raw", raw, KEY_ALGO, true, []);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
publicKey = { raw: new Uint8Array(raw) };
|
|
78
|
+
}
|
|
79
|
+
const contact = MajikContact.create(item.id, publicKey, item.mlKey, item.fingerprint, item.meta, item.edPublicKeyBase64, item.mlDsaPublicKeyBase64);
|
|
80
|
+
// Use the internal map directly to avoid addContact's duplicate-check
|
|
81
|
+
// (re-hydrating from persisted state, not user-facing add)
|
|
82
|
+
this.directory["contacts"].set(contact.id, contact);
|
|
83
|
+
this.directory["fingerprintMap"].set(contact.fingerprint, contact.id);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
console.warn(`MajikContactManager.hydrate: skipping malformed contact "${item?.id}":`, err);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ── 2. Groups ─────────────────────────────────────────────────────────
|
|
90
|
+
const serializedGroups = await this._groupAdapter.list();
|
|
91
|
+
this.groupManager.fromJSON({ groups: serializedGroups });
|
|
92
|
+
// ── 3. Orphan pruning ─────────────────────────────────────────────────
|
|
93
|
+
MajikContactManager.pruneOrphanedMembers(this.directory, this.groupManager);
|
|
94
|
+
}
|
|
95
|
+
// ── Write-through helpers ─────────────────────────────────────────────────
|
|
46
96
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* manager.group.addToFavorites(contactId)
|
|
50
|
-
* manager.group.createGroup(id, name)
|
|
51
|
-
* manager.group.getContactsInGroup(groupId)
|
|
52
|
-
* etc.
|
|
97
|
+
* Persists a single contact to the adapter (called after every mutating
|
|
98
|
+
* operation that affects a contact's serialized form).
|
|
53
99
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
100
|
+
async persistContact(contact) {
|
|
101
|
+
const json = await contact.toJSON();
|
|
102
|
+
await this._contactAdapter.save(json);
|
|
56
103
|
}
|
|
57
104
|
/**
|
|
58
|
-
*
|
|
59
|
-
* Prefer the proxied methods on this class over accessing the directory
|
|
60
|
-
* directly — they keep group state in sync automatically.
|
|
105
|
+
* Persists a single group to the adapter.
|
|
61
106
|
*/
|
|
62
|
-
|
|
63
|
-
|
|
107
|
+
async persistGroup(group) {
|
|
108
|
+
await this._groupAdapter.save(group.toJSON());
|
|
64
109
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
addContact(contact) {
|
|
110
|
+
// ── CRUD ──────────────────────────────────────────────────────────────────
|
|
111
|
+
/**
|
|
112
|
+
* Adds a contact to the directory and persists it to the adapter.
|
|
113
|
+
*/
|
|
114
|
+
async addContact(contact) {
|
|
71
115
|
this.directory.addContact(contact);
|
|
116
|
+
await this.persistContact(contact);
|
|
72
117
|
return this;
|
|
73
118
|
}
|
|
74
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Adds multiple contacts atomically — adapter write uses bulkSave.
|
|
121
|
+
*/
|
|
122
|
+
async addContacts(contacts) {
|
|
75
123
|
this.directory.addContacts(contacts);
|
|
124
|
+
const jsons = await Promise.all(contacts.map((c) => c.toJSON()));
|
|
125
|
+
await this._contactAdapter.bulkSave(jsons);
|
|
76
126
|
return this;
|
|
77
127
|
}
|
|
78
128
|
/**
|
|
79
|
-
* Removes a contact from the directory and
|
|
80
|
-
* from every group they belong to via the group manager hook.
|
|
81
|
-
* The two operations are always atomic from the caller's perspective.
|
|
129
|
+
* Removes a contact from the directory, all groups, and the adapter.
|
|
82
130
|
*/
|
|
83
|
-
removeContact(id) {
|
|
131
|
+
async removeContact(id) {
|
|
84
132
|
const result = this.directory.removeContact(id);
|
|
85
133
|
if (result.success) {
|
|
86
134
|
this.groupManager.handleContactRemoved(id);
|
|
135
|
+
await this._contactAdapter.remove(id);
|
|
136
|
+
// Persist every group whose membership changed
|
|
137
|
+
await this._persistAllGroups();
|
|
87
138
|
}
|
|
88
139
|
return result;
|
|
89
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Updates contact metadata and persists the change.
|
|
143
|
+
*/
|
|
144
|
+
async updateContactMeta(id, meta) {
|
|
145
|
+
const contact = this.directory.updateContactMeta(id, meta);
|
|
146
|
+
await this.persistContact(contact);
|
|
147
|
+
return contact;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Blocks a contact and persists both the contact and the Blocked group.
|
|
151
|
+
*/
|
|
152
|
+
async blockContact(id) {
|
|
153
|
+
const contact = this.directory.blockContact(id);
|
|
154
|
+
const blocked = this.groupManager.addContactToGroupIfAbsent(this.groupManager.getBlockedGroup().id, id);
|
|
155
|
+
await this.persistContact(contact);
|
|
156
|
+
await this.persistGroup(blocked);
|
|
157
|
+
return contact;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Unblocks a contact and persists both the contact and the Blocked group.
|
|
161
|
+
*/
|
|
162
|
+
async unblockContact(id) {
|
|
163
|
+
const contact = this.directory.unblockContact(id);
|
|
164
|
+
const blocked = this.groupManager.removeContactFromGroupIfPresent(this.groupManager.getBlockedGroup().id, id);
|
|
165
|
+
await this.persistContact(contact);
|
|
166
|
+
await this.persistGroup(blocked);
|
|
167
|
+
return contact;
|
|
168
|
+
}
|
|
169
|
+
async setMajikahStatus(id, status) {
|
|
170
|
+
const contact = this.directory.setMajikahStatus(id, status);
|
|
171
|
+
await this.persistContact(contact);
|
|
172
|
+
return contact;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Clears all contacts and groups from both the in-memory stores and adapters.
|
|
176
|
+
*/
|
|
177
|
+
async clear() {
|
|
178
|
+
const allContactIds = this.directory.listContacts().map((c) => c.id);
|
|
179
|
+
this.directory.clear();
|
|
180
|
+
allContactIds.forEach((id) => this.groupManager.handleContactRemoved(id));
|
|
181
|
+
this.directory.clear();
|
|
182
|
+
this.groupManager.clear();
|
|
183
|
+
await this._contactAdapter.clear();
|
|
184
|
+
await this._groupAdapter.clear();
|
|
185
|
+
return this;
|
|
186
|
+
}
|
|
187
|
+
// ── Sync reads (unchanged from original) ──────────────────────────────────
|
|
90
188
|
getContact(id) {
|
|
91
189
|
return this.directory.getContact(id);
|
|
92
190
|
}
|
|
@@ -94,7 +192,106 @@ export class MajikContactManager {
|
|
|
94
192
|
return this.directory.getContactByFingerprint(fingerprint);
|
|
95
193
|
}
|
|
96
194
|
async getContactByPublicKeyBase64(publicKeyBase64) {
|
|
97
|
-
return this.directory.getContactByPublicKeyBase64(publicKeyBase64);
|
|
195
|
+
return await this.directory.getContactByPublicKeyBase64(publicKeyBase64);
|
|
196
|
+
}
|
|
197
|
+
getContactsByIds(ids, strict = false) {
|
|
198
|
+
if (!ids?.length)
|
|
199
|
+
return [];
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
const results = [];
|
|
202
|
+
for (const id of ids) {
|
|
203
|
+
if (seen.has(id))
|
|
204
|
+
continue;
|
|
205
|
+
seen.add(id);
|
|
206
|
+
const contact = this.directory.getContact(id);
|
|
207
|
+
if (!contact) {
|
|
208
|
+
if (strict) {
|
|
209
|
+
throw new MajikContactManagerError(`Contact not found: ${id}`);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
results.push(contact);
|
|
214
|
+
}
|
|
215
|
+
return results;
|
|
216
|
+
}
|
|
217
|
+
async getContactsByPublicKeys(publicKeys, strict = false) {
|
|
218
|
+
if (!publicKeys?.length)
|
|
219
|
+
return [];
|
|
220
|
+
const uniqueKeys = [...new Set(publicKeys)];
|
|
221
|
+
const contacts = await Promise.all(uniqueKeys.map(async (key) => {
|
|
222
|
+
const contact = await this.directory.getContactByPublicKeyBase64(key);
|
|
223
|
+
if (!contact && strict) {
|
|
224
|
+
throw new MajikContactManagerError(`Contact not found for publicKey: ${key}`);
|
|
225
|
+
}
|
|
226
|
+
return contact;
|
|
227
|
+
}));
|
|
228
|
+
return contacts.filter((c) => Boolean(c));
|
|
229
|
+
}
|
|
230
|
+
async getMajikRecipients(mode = "id", input, strict) {
|
|
231
|
+
if (!input?.length)
|
|
232
|
+
throw new MajikContactManagerError("At least 1 id/key is required");
|
|
233
|
+
const contacts = mode === "public_key"
|
|
234
|
+
? await this.getContactsByPublicKeys(input, strict)
|
|
235
|
+
: this.getContactsByIds(input, strict);
|
|
236
|
+
if (!contacts || contacts.length === 0)
|
|
237
|
+
return [];
|
|
238
|
+
const recipients = [];
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
const invalidContacts = [];
|
|
241
|
+
for (const contact of contacts) {
|
|
242
|
+
if (!contact)
|
|
243
|
+
continue;
|
|
244
|
+
// dedupe by fingerprint
|
|
245
|
+
if (seen.has(contact.fingerprint))
|
|
246
|
+
continue;
|
|
247
|
+
const mlPubKey = base64ToUint8Array(contact.mlKey);
|
|
248
|
+
if (!mlPubKey) {
|
|
249
|
+
invalidContacts.push(contact.fingerprint);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const builtMajikRecipient = await MajikEnvelope.buildMajikRecipientFromContact(contact);
|
|
253
|
+
recipients.push(builtMajikRecipient);
|
|
254
|
+
seen.add(contact.fingerprint);
|
|
255
|
+
}
|
|
256
|
+
if (invalidContacts.length > 0) {
|
|
257
|
+
throw new MajikContactManagerError(`Invalid ML-KEM public key for contact(s): ${invalidContacts.join(", ")}`);
|
|
258
|
+
}
|
|
259
|
+
return recipients;
|
|
260
|
+
}
|
|
261
|
+
async getExpectedSigners(mode = "id", input, strict) {
|
|
262
|
+
if (!input?.length)
|
|
263
|
+
throw new MajikContactManagerError("At least 1 id/key is required");
|
|
264
|
+
const contacts = mode === "public_key"
|
|
265
|
+
? await this.getContactsByPublicKeys(input, strict)
|
|
266
|
+
: this.getContactsByIds(input, strict);
|
|
267
|
+
if (!contacts || contacts.length === 0)
|
|
268
|
+
return [];
|
|
269
|
+
const signers = [];
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
const invalidContacts = [];
|
|
272
|
+
for (const contact of contacts) {
|
|
273
|
+
if (!contact)
|
|
274
|
+
continue;
|
|
275
|
+
// dedupe by fingerprint
|
|
276
|
+
if (seen.has(contact.fingerprint))
|
|
277
|
+
continue;
|
|
278
|
+
const mlDsaPublicKey = contact.mlDsaPublicKeyBase64;
|
|
279
|
+
if (!mlDsaPublicKey?.trim()) {
|
|
280
|
+
invalidContacts.push(contact.fingerprint);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const expectedSigner = {
|
|
284
|
+
edPublicKey: contact.edPublicKeyBase64,
|
|
285
|
+
mlDsaPublicKey: contact.mlDsaPublicKeyBase64,
|
|
286
|
+
signerId: contact.fingerprint,
|
|
287
|
+
};
|
|
288
|
+
signers.push(expectedSigner);
|
|
289
|
+
seen.add(contact.fingerprint);
|
|
290
|
+
}
|
|
291
|
+
if (invalidContacts.length > 0) {
|
|
292
|
+
throw new MajikContactManagerError(`Invalid ML-KEM public key for contact(s): ${invalidContacts.join(", ")}`);
|
|
293
|
+
}
|
|
294
|
+
return signers;
|
|
98
295
|
}
|
|
99
296
|
hasContact(id) {
|
|
100
297
|
return this.directory.hasContact(id);
|
|
@@ -108,263 +305,266 @@ export class MajikContactManager {
|
|
|
108
305
|
listContacts(sortedByLabel = false, majikahOnly = false) {
|
|
109
306
|
return this.directory.listContacts(sortedByLabel, majikahOnly);
|
|
110
307
|
}
|
|
111
|
-
updateContactMeta(id, meta) {
|
|
112
|
-
return this.directory.updateContactMeta(id, meta);
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Blocks a contact on the directory AND adds them to the system Blocked
|
|
116
|
-
* group — both sides are always kept in sync.
|
|
117
|
-
*/
|
|
118
|
-
blockContact(id) {
|
|
119
|
-
const contact = this.directory.blockContact(id);
|
|
120
|
-
this.groupManager.addContactToGroupIfAbsent(this.groupManager.getBlockedGroup().id, id);
|
|
121
|
-
return contact;
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Unblocks a contact on the directory AND removes them from the system
|
|
125
|
-
* Blocked group — both sides are always kept in sync.
|
|
126
|
-
*/
|
|
127
|
-
unblockContact(id) {
|
|
128
|
-
const contact = this.directory.unblockContact(id);
|
|
129
|
-
this.groupManager.removeContactFromGroupIfPresent(this.groupManager.getBlockedGroup().id, id);
|
|
130
|
-
return contact;
|
|
131
|
-
}
|
|
132
|
-
setMajikahStatus(id, status) {
|
|
133
|
-
return this.directory.setMajikahStatus(id, status);
|
|
134
|
-
}
|
|
135
308
|
isMajikahRegistered(id) {
|
|
136
309
|
return this.directory.isMajikahRegistered(id);
|
|
137
310
|
}
|
|
138
311
|
isMajikahIdentityChecked(id) {
|
|
139
312
|
return this.directory.isMajikahIdentityChecked(id);
|
|
140
313
|
}
|
|
141
|
-
|
|
142
|
-
|
|
314
|
+
// ── Group CRUD (now async, write-through) ─────────────────────────────────
|
|
315
|
+
get group() {
|
|
316
|
+
return this.groupManager;
|
|
143
317
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
* ================================ */
|
|
147
|
-
/**
|
|
148
|
-
* Creates and registers a new user-defined group.
|
|
149
|
-
* Throws if a group with the same ID already exists.
|
|
150
|
-
*/
|
|
151
|
-
createGroup(id, name, meta, initialMemberIds) {
|
|
152
|
-
return this.groupManager.createGroup(id, name, meta, initialMemberIds);
|
|
318
|
+
get directory_() {
|
|
319
|
+
return this.directory;
|
|
153
320
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
321
|
+
async createGroup(id, name, meta, initialMemberIds) {
|
|
322
|
+
const group = this.groupManager.createGroup(id, name, meta, initialMemberIds);
|
|
323
|
+
await this.persistGroup(group);
|
|
324
|
+
return group;
|
|
325
|
+
}
|
|
326
|
+
async addGroup(group) {
|
|
159
327
|
this.groupManager.addGroup(group);
|
|
328
|
+
await this.persistGroup(group);
|
|
160
329
|
return this;
|
|
161
330
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return
|
|
331
|
+
async removeGroup(id) {
|
|
332
|
+
const result = this.groupManager.removeGroup(id);
|
|
333
|
+
if (result.success) {
|
|
334
|
+
await this._groupAdapter.remove(id);
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
168
337
|
}
|
|
169
|
-
/**
|
|
170
|
-
* Returns a group by ID, or undefined if not found.
|
|
171
|
-
*/
|
|
172
338
|
getGroup(id) {
|
|
173
339
|
return this.groupManager.getGroup(id);
|
|
174
340
|
}
|
|
175
|
-
/**
|
|
176
|
-
* Returns a group by ID. Throws if not found.
|
|
177
|
-
*/
|
|
178
341
|
getGroupOrThrow(id) {
|
|
179
342
|
return this.groupManager.getGroupOrThrow(id);
|
|
180
343
|
}
|
|
181
|
-
/**
|
|
182
|
-
* Returns true if a group with the given ID exists.
|
|
183
|
-
*/
|
|
184
344
|
hasGroup(id) {
|
|
185
345
|
return this.groupManager.hasGroup(id);
|
|
186
346
|
}
|
|
187
|
-
/**
|
|
188
|
-
* Returns all groups.
|
|
189
|
-
*
|
|
190
|
-
* @param includeSystem Include system groups (Favorites, Blocked). Default: true.
|
|
191
|
-
* @param sortedByName Sort results alphabetically by group name. Default: false.
|
|
192
|
-
*/
|
|
193
347
|
listGroups(includeSystem = true, sortedByName = false) {
|
|
194
348
|
return this.groupManager.listGroups(includeSystem, sortedByName);
|
|
195
349
|
}
|
|
196
|
-
/**
|
|
197
|
-
* Returns only user-created groups (excludes Favorites and Blocked).
|
|
198
|
-
* Sorted alphabetically by name.
|
|
199
|
-
*/
|
|
200
350
|
listUserGroups(sortedByName = true) {
|
|
201
351
|
return this.groupManager.listGroups(false, sortedByName);
|
|
202
352
|
}
|
|
203
|
-
/**
|
|
204
|
-
* Returns only system groups (Favorites and Blocked).
|
|
205
|
-
*/
|
|
206
353
|
listSystemGroups() {
|
|
207
354
|
return this.groupManager.listGroups(true).filter((g) => g.isSystem);
|
|
208
355
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Moves a contact from one group to another atomically.
|
|
256
|
-
* Throws if the contact is not a member of the source group.
|
|
257
|
-
*/
|
|
258
|
-
moveContactBetweenGroups(contactId, fromGroupId, toGroupId) {
|
|
259
|
-
return this.groupManager.moveContact(contactId, fromGroupId, toGroupId);
|
|
260
|
-
}
|
|
261
|
-
/* ================================
|
|
262
|
-
* Group Query Pass-throughs
|
|
263
|
-
* ================================ */
|
|
264
|
-
/**
|
|
265
|
-
* Returns all hydrated MajikContact instances in the given group.
|
|
266
|
-
* Contacts removed from the directory since last save are silently skipped.
|
|
267
|
-
*/
|
|
356
|
+
async updateGroupMeta(id, meta) {
|
|
357
|
+
const group = this.groupManager.updateGroupMeta(id, meta);
|
|
358
|
+
await this.persistGroup(group);
|
|
359
|
+
return group;
|
|
360
|
+
}
|
|
361
|
+
// ── Group membership (async, write-through) ───────────────────────────────
|
|
362
|
+
async addContactToGroup(groupId, contactId) {
|
|
363
|
+
const group = this.groupManager.addContactToGroup(groupId, contactId);
|
|
364
|
+
await this.persistGroup(group);
|
|
365
|
+
return group;
|
|
366
|
+
}
|
|
367
|
+
async addContactToGroupIfAbsent(groupId, contactId) {
|
|
368
|
+
const group = this.groupManager.addContactToGroupIfAbsent(groupId, contactId);
|
|
369
|
+
await this.persistGroup(group);
|
|
370
|
+
return group;
|
|
371
|
+
}
|
|
372
|
+
async addContactsToGroup(groupId, contactIds) {
|
|
373
|
+
const group = this.groupManager.addContactsToGroup(groupId, contactIds);
|
|
374
|
+
await this.persistGroup(group);
|
|
375
|
+
return group;
|
|
376
|
+
}
|
|
377
|
+
async removeContactFromGroup(groupId, contactId) {
|
|
378
|
+
const group = this.groupManager.removeContactFromGroup(groupId, contactId);
|
|
379
|
+
await this.persistGroup(group);
|
|
380
|
+
return group;
|
|
381
|
+
}
|
|
382
|
+
async removeContactFromGroupIfPresent(groupId, contactId) {
|
|
383
|
+
const group = this.groupManager.removeContactFromGroupIfPresent(groupId, contactId);
|
|
384
|
+
await this.persistGroup(group);
|
|
385
|
+
return group;
|
|
386
|
+
}
|
|
387
|
+
async moveContactBetweenGroups(contactId, fromGroupId, toGroupId) {
|
|
388
|
+
this.groupManager.moveContact(contactId, fromGroupId, toGroupId);
|
|
389
|
+
// Persist both affected groups
|
|
390
|
+
const from = this.groupManager.getGroup(fromGroupId);
|
|
391
|
+
const to = this.groupManager.getGroup(toGroupId);
|
|
392
|
+
const writes = [];
|
|
393
|
+
if (from)
|
|
394
|
+
writes.push(this.persistGroup(from));
|
|
395
|
+
if (to)
|
|
396
|
+
writes.push(this.persistGroup(to));
|
|
397
|
+
await Promise.all(writes);
|
|
398
|
+
}
|
|
399
|
+
// ── Group query pass-throughs (sync, unchanged) ───────────────────────────
|
|
268
400
|
getContactsInGroup(groupId) {
|
|
269
401
|
return this.groupManager.getContactsInGroup(groupId);
|
|
270
402
|
}
|
|
271
|
-
/**
|
|
272
|
-
* Returns hydrated contacts in the group, sorted by label (or ID if no label).
|
|
273
|
-
*/
|
|
274
403
|
getContactsInGroupSorted(groupId) {
|
|
275
404
|
return this.groupManager.getContactsInGroupSorted(groupId);
|
|
276
405
|
}
|
|
277
|
-
/**
|
|
278
|
-
* Returns true if the contact is a member of the given group.
|
|
279
|
-
*/
|
|
280
406
|
isContactInGroup(groupId, contactId) {
|
|
281
407
|
return this.groupManager.isContactInGroup(groupId, contactId);
|
|
282
408
|
}
|
|
283
|
-
/**
|
|
284
|
-
* Returns all groups the contact belongs to.
|
|
285
|
-
*/
|
|
286
409
|
getGroupsForContact(contactId) {
|
|
287
410
|
return this.groupManager.getGroupsForContact(contactId);
|
|
288
411
|
}
|
|
289
|
-
/**
|
|
290
|
-
* Returns all group IDs the contact belongs to.
|
|
291
|
-
*/
|
|
292
412
|
getGroupIdsForContact(contactId) {
|
|
293
413
|
return this.groupManager.getGroupIdsForContact(contactId);
|
|
294
414
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
*/
|
|
301
|
-
addToFavorites(contactId) {
|
|
302
|
-
return this.groupManager.addToFavorites(contactId);
|
|
415
|
+
// ── System group convenience (async, write-through) ───────────────────────
|
|
416
|
+
async addToFavorites(contactId) {
|
|
417
|
+
const group = this.groupManager.addToFavorites(contactId);
|
|
418
|
+
await this.persistGroup(group);
|
|
419
|
+
return group;
|
|
303
420
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
return this.groupManager.removeFromFavorites(contactId);
|
|
421
|
+
async removeFromFavorites(contactId) {
|
|
422
|
+
const group = this.groupManager.removeFromFavorites(contactId);
|
|
423
|
+
await this.persistGroup(group);
|
|
424
|
+
return group;
|
|
309
425
|
}
|
|
310
|
-
/**
|
|
311
|
-
* Returns true if the contact is in the Favorites group.
|
|
312
|
-
*/
|
|
313
426
|
isFavorite(contactId) {
|
|
314
427
|
return this.groupManager.isFavorite(contactId);
|
|
315
428
|
}
|
|
316
|
-
/**
|
|
317
|
-
* Returns true if the contact is in the Blocked group.
|
|
318
|
-
*/
|
|
319
429
|
isContactBlocked(contactId) {
|
|
320
430
|
return this.groupManager.isBlocked(contactId);
|
|
321
431
|
}
|
|
322
|
-
/**
|
|
323
|
-
* Returns the Favorites system group instance.
|
|
324
|
-
*/
|
|
325
432
|
getFavoritesGroup() {
|
|
326
433
|
return this.groupManager.getFavoritesGroup();
|
|
327
434
|
}
|
|
328
|
-
/**
|
|
329
|
-
* Returns the Blocked system group instance.
|
|
330
|
-
*/
|
|
331
435
|
getBlockedGroup() {
|
|
332
436
|
return this.groupManager.getBlockedGroup();
|
|
333
437
|
}
|
|
334
|
-
/**
|
|
335
|
-
* Returns all contacts in the Favorites group as hydrated MajikContact instances.
|
|
336
|
-
*/
|
|
337
438
|
getFavoriteContacts() {
|
|
338
439
|
return this.groupManager.getContactsInGroup(this.groupManager.getFavoritesGroup().id);
|
|
339
440
|
}
|
|
340
|
-
/**
|
|
341
|
-
* Returns all contacts in the Blocked group as hydrated MajikContact instances.
|
|
342
|
-
*/
|
|
343
441
|
getBlockedContacts() {
|
|
344
442
|
return this.groupManager.getContactsInGroup(this.groupManager.getBlockedGroup().id);
|
|
345
443
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
444
|
+
// ── Import / Export (unchanged) ───────────────────────────────────────────
|
|
445
|
+
async exportContactAsJSON(contactId) {
|
|
446
|
+
const contact = this.getContact(contactId);
|
|
447
|
+
if (!contact)
|
|
448
|
+
return null;
|
|
449
|
+
let publicKeyBase64;
|
|
450
|
+
const anyPub = contact.publicKey;
|
|
451
|
+
if (anyPub?.raw instanceof Uint8Array) {
|
|
452
|
+
publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
|
|
456
|
+
publicKeyBase64 = arrayBufferToBase64(raw);
|
|
457
|
+
}
|
|
458
|
+
return JSON.stringify({
|
|
459
|
+
id: contact.id,
|
|
460
|
+
label: contact.meta?.label || "",
|
|
461
|
+
publicKey: publicKeyBase64,
|
|
462
|
+
fingerprint: contact.fingerprint,
|
|
463
|
+
mlKey: contact.mlKey,
|
|
464
|
+
edPublicKeyBase64: contact.edPublicKeyBase64,
|
|
465
|
+
mlDsaPublicKeyBase64: contact.mlDsaPublicKeyBase64,
|
|
466
|
+
}, null, 2);
|
|
467
|
+
}
|
|
468
|
+
async exportContactAsString(contactId) {
|
|
469
|
+
const contact = this.getContact(contactId);
|
|
470
|
+
if (!contact)
|
|
471
|
+
return null;
|
|
472
|
+
return this.exportContactCompressed(contact);
|
|
473
|
+
}
|
|
474
|
+
async importContactFromJSON(jsonStr) {
|
|
475
|
+
try {
|
|
476
|
+
const data = JSON.parse(jsonStr);
|
|
477
|
+
if (!data.id || !data.publicKey || !data.fingerprint) {
|
|
478
|
+
return { success: false, message: "Invalid contact JSON" };
|
|
479
|
+
}
|
|
480
|
+
const rawBuffer = base64ToArrayBuffer(data.publicKey);
|
|
481
|
+
let publicKey;
|
|
482
|
+
try {
|
|
483
|
+
publicKey = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
publicKey = { raw: new Uint8Array(rawBuffer) };
|
|
487
|
+
}
|
|
488
|
+
const contact = new MajikContact({
|
|
489
|
+
id: data.id,
|
|
490
|
+
publicKey,
|
|
491
|
+
fingerprint: data.fingerprint,
|
|
492
|
+
meta: { label: data.label },
|
|
493
|
+
mlKey: data.mlKey,
|
|
494
|
+
edPublicKeyBase64: data.edPublicKeyBase64,
|
|
495
|
+
mlDsaPublicKeyBase64: data.mlDsaPublicKeyBase64,
|
|
496
|
+
});
|
|
497
|
+
await this.addContact(contact);
|
|
498
|
+
return { success: true, message: "Contact imported successfully" };
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
return {
|
|
502
|
+
success: false,
|
|
503
|
+
message: err instanceof Error ? err.message : "Unknown error",
|
|
504
|
+
};
|
|
505
|
+
}
|
|
360
506
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
507
|
+
async importContactFromString(base64Str) {
|
|
508
|
+
try {
|
|
509
|
+
const contact = await this.importContactCompressed(base64Str);
|
|
510
|
+
await this.addContact(contact);
|
|
511
|
+
return { success: true, message: "Contact imported successfully" };
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
return {
|
|
515
|
+
success: false,
|
|
516
|
+
message: err instanceof Error ? err.message : "Unknown error",
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async exportContactCompressed(contact) {
|
|
521
|
+
let publicKeyBase64;
|
|
522
|
+
const anyPub = contact.publicKey;
|
|
523
|
+
if (anyPub?.raw instanceof Uint8Array) {
|
|
524
|
+
publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
|
|
528
|
+
publicKeyBase64 = arrayBufferToBase64(raw);
|
|
529
|
+
}
|
|
530
|
+
const jsonObj = {
|
|
531
|
+
id: contact.id,
|
|
532
|
+
label: contact.meta?.label || "",
|
|
533
|
+
publicKey: publicKeyBase64,
|
|
534
|
+
fingerprint: contact.fingerprint,
|
|
535
|
+
mlKey: contact.mlKey,
|
|
536
|
+
edPublicKeyBase64: contact.edPublicKeyBase64,
|
|
537
|
+
mlDsaPublicKeyBase64: contact.mlDsaPublicKeyBase64,
|
|
538
|
+
};
|
|
539
|
+
const compressed = gzipSync(new TextEncoder().encode(JSON.stringify(jsonObj)));
|
|
540
|
+
return arrayToBase64(compressed);
|
|
541
|
+
}
|
|
542
|
+
async importContactCompressed(base64Str) {
|
|
543
|
+
const compressed = base64ToArrayBuffer(base64Str);
|
|
544
|
+
const jsonStr = new TextDecoder().decode(gunzipSync(new Uint8Array(compressed)));
|
|
545
|
+
const data = JSON.parse(jsonStr);
|
|
546
|
+
const rawBuffer = base64ToArrayBuffer(data.publicKey);
|
|
547
|
+
let publicKey;
|
|
548
|
+
try {
|
|
549
|
+
publicKey = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
publicKey = { raw: new Uint8Array(rawBuffer) };
|
|
553
|
+
}
|
|
554
|
+
if (!data?.id || !publicKey || !data?.fingerprint || !data?.mlKey) {
|
|
555
|
+
throw new Error("Invalid contact JSON");
|
|
556
|
+
}
|
|
557
|
+
return new MajikContact({
|
|
558
|
+
id: data.id,
|
|
559
|
+
publicKey,
|
|
560
|
+
fingerprint: data.fingerprint,
|
|
561
|
+
meta: { label: data.label },
|
|
562
|
+
mlKey: data.mlKey,
|
|
563
|
+
edPublicKeyBase64: data.edPublicKeyBase64,
|
|
564
|
+
mlDsaPublicKeyBase64: data.mlDsaPublicKeyBase64,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
// ── Serialization ─────────────────────────────────────────────────────────
|
|
368
568
|
async toJSON() {
|
|
369
569
|
return {
|
|
370
570
|
contacts: await this.directory.toJSON(),
|
|
@@ -372,18 +572,19 @@ export class MajikContactManager {
|
|
|
372
572
|
};
|
|
373
573
|
}
|
|
374
574
|
/**
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* 1. Restore the directory (contacts + crypto keys)
|
|
379
|
-
* 2. Restore groups via the group manager
|
|
380
|
-
* 3. Silently strip any group member IDs that no longer exist in the
|
|
381
|
-
* restored directory (orphan pruning) — guards against data drift
|
|
382
|
-
* between directory and group state across serialization rounds
|
|
383
|
-
*
|
|
384
|
-
* @param data The payload produced by toJSON().
|
|
575
|
+
* Restore from a JSON snapshot into the current adapters.
|
|
576
|
+
* Writes all contacts and groups through to the adapters.
|
|
577
|
+
* Used after setAdapters() to migrate data into a new store.
|
|
385
578
|
*/
|
|
386
|
-
|
|
579
|
+
async bulkRestoreFromJSON(data) {
|
|
580
|
+
if (!data?.contacts || !data?.groups) {
|
|
581
|
+
throw new MajikContactManagerError("bulkRestoreFromJSON: invalid payload — expected { contacts, groups }");
|
|
582
|
+
}
|
|
583
|
+
await this._contactAdapter.bulkSave(data.contacts.contacts);
|
|
584
|
+
await this._groupAdapter.bulkSave(data.groups.groups);
|
|
585
|
+
await this.hydrate();
|
|
586
|
+
}
|
|
587
|
+
static async fromJSON(data, adapters) {
|
|
387
588
|
if (!data || typeof data !== "object") {
|
|
388
589
|
throw new MajikContactManagerError("fromJSON: invalid payload — expected { contacts, groups }");
|
|
389
590
|
}
|
|
@@ -393,52 +594,31 @@ export class MajikContactManager {
|
|
|
393
594
|
if (!data.groups) {
|
|
394
595
|
throw new MajikContactManagerError("fromJSON: missing required field 'groups'");
|
|
395
596
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
directory = new MajikContactDirectory();
|
|
400
|
-
await directory.fromJSON(data.contacts);
|
|
401
|
-
}
|
|
402
|
-
catch (err) {
|
|
403
|
-
throw new MajikContactManagerError("fromJSON: failed to restore contact directory", err);
|
|
404
|
-
}
|
|
405
|
-
// Step 2 — restore group manager bound to the restored directory
|
|
406
|
-
let groupManager;
|
|
407
|
-
try {
|
|
408
|
-
groupManager = new MajikContactGroupManager(directory);
|
|
409
|
-
groupManager.fromJSON(data.groups);
|
|
410
|
-
}
|
|
411
|
-
catch (err) {
|
|
412
|
-
throw new MajikContactManagerError("fromJSON: failed to restore group manager", err);
|
|
413
|
-
}
|
|
414
|
-
// Step 3 — silently prune orphaned member IDs from every group
|
|
415
|
-
// An orphan is a contact ID that exists in a group but is absent from
|
|
416
|
-
// the restored directory. This can happen if a contact was removed
|
|
417
|
-
// between two save cycles or data was partially corrupted.
|
|
418
|
-
MajikContactManager.pruneOrphanedMembers(directory, groupManager);
|
|
419
|
-
return new MajikContactManager(directory, groupManager);
|
|
597
|
+
const manager = new MajikContactManager(undefined, undefined, adapters);
|
|
598
|
+
await manager.bulkRestoreFromJSON(data);
|
|
599
|
+
return manager;
|
|
420
600
|
}
|
|
601
|
+
// ── Private helpers ───────────────────────────────────────────────────────
|
|
421
602
|
/**
|
|
422
|
-
*
|
|
423
|
-
*
|
|
603
|
+
* Persists every group currently in the group manager to the adapter.
|
|
604
|
+
* Used after bulk contact removal where multiple groups may be affected.
|
|
424
605
|
*/
|
|
606
|
+
async _persistAllGroups() {
|
|
607
|
+
const all = this.groupManager.listGroups(true);
|
|
608
|
+
await this._groupAdapter.bulkSave(all.map((g) => g.toJSON()));
|
|
609
|
+
}
|
|
425
610
|
static pruneOrphanedMembers(directory, groupManager) {
|
|
426
|
-
const allGroups = groupManager.listGroups(true);
|
|
611
|
+
const allGroups = groupManager.listGroups(true);
|
|
427
612
|
for (const group of allGroups) {
|
|
428
613
|
const orphans = group
|
|
429
614
|
.listMemberIds()
|
|
430
615
|
.filter((id) => !directory.hasContact(id));
|
|
431
616
|
for (const orphanId of orphans) {
|
|
432
|
-
// Use the idempotent variant — safe even if the index is already clean
|
|
433
617
|
group.removeMemberIfPresent(orphanId);
|
|
434
|
-
// Also clean up the reverse index on the group manager
|
|
435
618
|
groupManager.handleContactRemoved(orphanId);
|
|
436
619
|
}
|
|
437
620
|
}
|
|
438
621
|
}
|
|
439
|
-
/* ================================
|
|
440
|
-
* Assertions
|
|
441
|
-
* ================================ */
|
|
442
622
|
assertGroupManagerInstance(gm) {
|
|
443
623
|
if (!gm || !(gm instanceof MajikContactGroupManager)) {
|
|
444
624
|
throw new MajikContactManagerError("groupManager must be a valid MajikContactGroupManager instance");
|