metaowl 0.1.3 → 0.2.0

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.
@@ -0,0 +1,511 @@
1
+ /**
2
+ * @module OdooRPC
3
+ *
4
+ * Odoo JSON-RPC Service for MetaOwl applications.
5
+ *
6
+ * Features:
7
+ * - JSON-RPC 2.0 compliant communication
8
+ * - Authentication handling
9
+ * - Automatic CSRF token management
10
+ * - Session persistence
11
+ * - Common Odoo operations (search_read, call, etc.)
12
+ *
13
+ * Usage:
14
+ * import { OdooService } from 'metaowl'
15
+ *
16
+ * // Configure connection
17
+ * OdooService.configure({
18
+ * baseUrl: 'https://my-odoo-instance.com',
19
+ * database: 'my_database',
20
+ * username: 'admin',
21
+ * password: 'admin'
22
+ * })
23
+ *
24
+ * // Authenticate
25
+ * await OdooService.authenticate()
26
+ *
27
+ * // Search and read records
28
+ * const partners = await OdooService.searchRead('res.partner', {
29
+ * domain: [['is_company', '=', true]],
30
+ * fields: ['name', 'email'],
31
+ * limit: 10
32
+ * })
33
+ *
34
+ * // Call any model method
35
+ * const result = await OdooService.call('res.partner', 'create', [{
36
+ * name: 'New Partner',
37
+ * email: 'partner@example.com'
38
+ * }])
39
+ */
40
+
41
+ import { Fetch } from './fetch.js'
42
+
43
+ /**
44
+ * @typedef {Object} OdooConfig
45
+ * @property {string} baseUrl - Odoo instance URL
46
+ * @property {string} database - Database name
47
+ * @property {string} [username] - Username for authentication
48
+ * @property {string} [password] - Password for authentication
49
+ * @property {string} [apiKey] - API key for authentication (alternative to password)
50
+ * @property {boolean} [persistSession=true] - Persist session in localStorage
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} SearchReadOptions
55
+ * @property {Array[]} [domain=[]] - Search domain
56
+ * @property {string[]} [fields=[]] - Fields to read
57
+ * @property {number} [limit=80] - Max records
58
+ * @property {number} [offset=0] - Offset for pagination
59
+ * @property {string} [order] - Order by clause
60
+ * @property {Object} [context={}] - Odoo context
61
+ */
62
+
63
+ /**
64
+ * @typedef {Object} OdooSession
65
+ * @property {number} uid - User ID
66
+ * @property {string} username - Username
67
+ * @property {string} [name] - Display name
68
+ * @property {number} [partner_id] - Partner ID
69
+ * @property {string[]} [user_context] - User context
70
+ */
71
+
72
+ /** @type {OdooConfig|null} */
73
+ let _config = null
74
+
75
+ /** @type {OdooSession|null} */
76
+ let _session = null
77
+
78
+ /** @type {string|null} */
79
+ let _csrfToken = null
80
+
81
+ /** @type {Function[]} */
82
+ const _authListeners = []
83
+
84
+ const SESSION_KEY = 'metaowl:odoo:session'
85
+ const CSRF_KEY = 'metaowl:odoo:csrf'
86
+
87
+ /**
88
+ * Configure the Odoo RPC service.
89
+ *
90
+ * @param {OdooConfig} config
91
+ */
92
+ export function configure(config) {
93
+ _config = {
94
+ persistSession: true,
95
+ ...config
96
+ }
97
+
98
+ // Try to restore session from localStorage
99
+ if (_config.persistSession) {
100
+ restoreSession()
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Get current configuration.
106
+ *
107
+ * @returns {OdooConfig|null}
108
+ */
109
+ export function getConfig() {
110
+ return _config
111
+ }
112
+
113
+ /**
114
+ * Check if service is configured.
115
+ *
116
+ * @returns {boolean}
117
+ */
118
+ export function isConfigured() {
119
+ return _config !== null && !!_config.baseUrl && !!_config.database
120
+ }
121
+
122
+ /**
123
+ * Restore session from localStorage.
124
+ */
125
+ function restoreSession() {
126
+ try {
127
+ const sessionData = localStorage.getItem(SESSION_KEY)
128
+ const csrfData = localStorage.getItem(CSRF_KEY)
129
+
130
+ if (sessionData) {
131
+ _session = JSON.parse(sessionData)
132
+ }
133
+ if (csrfData) {
134
+ _csrfToken = csrfData
135
+ }
136
+ } catch {
137
+ // Ignore storage errors
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Save session to localStorage.
143
+ */
144
+ function saveSession() {
145
+ if (!_config?.persistSession) return
146
+
147
+ try {
148
+ if (_session) {
149
+ localStorage.setItem(SESSION_KEY, JSON.stringify(_session))
150
+ } else {
151
+ localStorage.removeItem(SESSION_KEY)
152
+ }
153
+ if (_csrfToken) {
154
+ localStorage.setItem(CSRF_KEY, _csrfToken)
155
+ } else {
156
+ localStorage.removeItem(CSRF_KEY)
157
+ }
158
+ } catch {
159
+ // Ignore storage errors
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Make JSON-RPC request to Odoo.
165
+ *
166
+ * @param {string} service - Service name (e.g., 'common', 'object', 'db')
167
+ @param {string} method - Method name
168
+ * @param {any[]} args - Method arguments
169
+ * @returns {Promise<any>}
170
+ * @throws {Error}
171
+ */
172
+ async function jsonRpc(service, method, args = []) {
173
+ if (!isConfigured()) {
174
+ throw new Error('[metaowl] OdooService not configured. Call configure() first.')
175
+ }
176
+
177
+ const url = `${_config.baseUrl}/jsonrpc`
178
+
179
+ const payload = {
180
+ jsonrpc: '2.0',
181
+ method: 'call',
182
+ params: {
183
+ service,
184
+ method,
185
+ args
186
+ },
187
+ id: Math.floor(Math.random() * 1000000000)
188
+ }
189
+
190
+ const headers = {
191
+ 'Content-Type': 'application/json'
192
+ }
193
+
194
+ if (_csrfToken) {
195
+ headers['X-CSRF-Token'] = _csrfToken
196
+ }
197
+
198
+ const response = await fetch(url, {
199
+ method: 'POST',
200
+ headers,
201
+ body: JSON.stringify(payload),
202
+ credentials: 'include'
203
+ })
204
+
205
+ if (!response.ok) {
206
+ throw new Error(`[metaowl] HTTP ${response.status}: ${response.statusText}`)
207
+ }
208
+
209
+ const data = await response.json()
210
+
211
+ if (data.error) {
212
+ const error = data.error
213
+ throw new Error(`[metaowl] Odoo Error: ${error.message || error.data?.message || JSON.stringify(error)}`)
214
+ }
215
+
216
+ // Extract CSRF token from cookies if present
217
+ const setCookie = response.headers.get('set-cookie')
218
+ if (setCookie?.includes('csrf_token')) {
219
+ const match = setCookie.match(/csrf_token=([^;]+)/)
220
+ if (match) {
221
+ _csrfToken = match[1]
222
+ saveSession()
223
+ }
224
+ }
225
+
226
+ return data.result
227
+ }
228
+
229
+ /**
230
+ * Authenticate with Odoo.
231
+ *
232
+ * @param {string} [username] - Override configured username
233
+ * @param {string} [password] - Override configured password
234
+ * @returns {Promise<OdooSession>} Session info
235
+ * @throws {Error}
236
+ */
237
+ export async function authenticate(username, password) {
238
+ const user = username || _config?.username
239
+ const pass = password || _config?.password || _config?.apiKey
240
+
241
+ if (!user || !pass) {
242
+ throw new Error('[metaowl] Authentication requires username and password/apiKey')
243
+ }
244
+
245
+ const uid = await jsonRpc('common', 'authenticate', [
246
+ _config.database,
247
+ user,
248
+ pass,
249
+ {}
250
+ ])
251
+
252
+ if (!uid) {
253
+ throw new Error('[metaowl] Authentication failed: invalid credentials')
254
+ }
255
+
256
+ _session = {
257
+ uid,
258
+ username: user
259
+ }
260
+
261
+ // Get user info
262
+ try {
263
+ const userInfo = await searchRead('res.users', {
264
+ domain: [['id', '=', uid]],
265
+ fields: ['name', 'partner_id', 'lang', 'tz'],
266
+ limit: 1
267
+ })
268
+
269
+ if (userInfo.length > 0) {
270
+ _session.name = userInfo[0].name
271
+ _session.partner_id = userInfo[0].partner_id?.[0]
272
+ _session.lang = userInfo[0].lang
273
+ _session.tz = userInfo[0].tz
274
+ }
275
+ } catch {
276
+ // Ignore user info fetch errors
277
+ }
278
+
279
+ saveSession()
280
+ notifyAuthListeners()
281
+
282
+ return _session
283
+ }
284
+
285
+ /**
286
+ * Check if currently authenticated.
287
+ *
288
+ * @returns {boolean}
289
+ */
290
+ export function isAuthenticated() {
291
+ return _session !== null && _session.uid !== null
292
+ }
293
+
294
+ /**
295
+ * Get current session.
296
+ *
297
+ * @returns {OdooSession|null}
298
+ */
299
+ export function getSession() {
300
+ return _session
301
+ }
302
+
303
+ /**
304
+ * Logout and clear session.
305
+ */
306
+ export function logout() {
307
+ _session = null
308
+ _csrfToken = null
309
+ saveSession()
310
+ notifyAuthListeners()
311
+ }
312
+
313
+ /**
314
+ * Search and read records from Odoo.
315
+ *
316
+ * @param {string} model - Model name (e.g., 'res.partner')
317
+ * @param {SearchReadOptions} options - Search options
318
+ * @returns {Promise<Object[]>} Records
319
+ */
320
+ export async function searchRead(model, options = {}) {
321
+ const {
322
+ domain = [],
323
+ fields = [],
324
+ limit = 80,
325
+ offset = 0,
326
+ order = '',
327
+ context = {}
328
+ } = options
329
+
330
+ if (!isAuthenticated()) {
331
+ throw new Error('[metaowl] Not authenticated. Call authenticate() first.')
332
+ }
333
+
334
+ const args = [
335
+ _config.database,
336
+ _session.uid,
337
+ _config.password || _config.apiKey,
338
+ model,
339
+ 'search_read',
340
+ [domain],
341
+ { fields, limit, offset, order, context }
342
+ ]
343
+
344
+ return await jsonRpc('object', 'execute_kw', args)
345
+ }
346
+
347
+ /**
348
+ * Call any model method.
349
+ *
350
+ * @param {string} model - Model name
351
+ * @param {string} method - Method name
352
+ * @param {any[]} [args=[]] - Positional arguments
353
+ * @param {Object} [kwargs={}] - Keyword arguments
354
+ * @returns {Promise<any>}
355
+ */
356
+ export async function call(model, method, args = [], kwargs = {}) {
357
+ if (!isAuthenticated()) {
358
+ throw new Error('[metaowl] Not authenticated. Call authenticate() first.')
359
+ }
360
+
361
+ const rpcArgs = [
362
+ _config.database,
363
+ _session.uid,
364
+ _config.password || _config.apiKey,
365
+ model,
366
+ method,
367
+ args,
368
+ kwargs
369
+ ]
370
+
371
+ return await jsonRpc('object', 'execute_kw', rpcArgs)
372
+ }
373
+
374
+ /**
375
+ * Read specific records by ID.
376
+ *
377
+ * @param {string} model - Model name
378
+ * @param {number[]} ids - Record IDs
379
+ * @param {string[]} [fields=[]] - Fields to read
380
+ * @returns {Promise<Object[]>}
381
+ */
382
+ export async function read(model, ids, fields = []) {
383
+ return await call(model, 'read', [ids], { fields })
384
+ }
385
+
386
+ /**
387
+ * Create a new record.
388
+ *
389
+ * @param {string} model - Model name
390
+ * @param {Object} values - Field values
391
+ * @returns {Promise<number>} New record ID
392
+ */
393
+ export async function create(model, values) {
394
+ return await call(model, 'create', [[values]])
395
+ }
396
+
397
+ /**
398
+ * Update existing records.
399
+ *
400
+ * @param {string} model - Model name
401
+ * @param {number[]} ids - Record IDs to update
402
+ * @param {Object} values - New field values
403
+ * @returns {Promise<boolean>}
404
+ */
405
+ export async function write(model, ids, values) {
406
+ return await call(model, 'write', [ids, values])
407
+ }
408
+
409
+ /**
410
+ * Delete records.
411
+ *
412
+ * @param {string} model - Model name
413
+ * @param {number[]} ids - Record IDs to delete
414
+ * @returns {Promise<boolean>}
415
+ */
416
+ export async function unlink(model, ids) {
417
+ return await call(model, 'unlink', [ids])
418
+ }
419
+
420
+ /**
421
+ * Get count of records matching domain.
422
+ *
423
+ * @param {string} model - Model name
424
+ * @param {Array[]} [domain=[]] - Search domain
425
+ * @returns {Promise<number>}
426
+ */
427
+ export async function searchCount(model, domain = []) {
428
+ return await call(model, 'search_count', [domain])
429
+ }
430
+
431
+ /**
432
+ * Get list of available databases.
433
+ *
434
+ * @returns {Promise<string[]>}
435
+ */
436
+ export async function listDatabases() {
437
+ return await jsonRpc('db', 'list', [])}
438
+
439
+ /**
440
+ * Check version of Odoo server.
441
+ *
442
+ * @returns {Promise<Object>} Version info
443
+ */
444
+ export async function versionInfo() {
445
+ const response = await fetch(`${_config.baseUrl}/web/webclient/version_info`, {
446
+ method: 'POST',
447
+ headers: { 'Content-Type': 'application/json' },
448
+ body: '{}'
449
+ })
450
+
451
+ if (!response.ok) {
452
+ throw new Error(`[metaowl] Failed to get version info: ${response.status}`)
453
+ }
454
+
455
+ const data = await response.json()
456
+ return data.result
457
+ }
458
+
459
+ /**
460
+ * Register auth state change listener.
461
+ *
462
+ * @param {Function} callback - Called with (session|null) when auth changes
463
+ * @returns {Function} Unsubscribe function
464
+ */
465
+ export function onAuthChange(callback) {
466
+ _authListeners.push(callback)
467
+ return () => {
468
+ const index = _authListeners.indexOf(callback)
469
+ if (index > -1) {
470
+ _authListeners.splice(index, 1)
471
+ }
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Notify all auth listeners.
477
+ */
478
+ function notifyAuthListeners() {
479
+ for (const listener of _authListeners) {
480
+ try {
481
+ listener(_session)
482
+ } catch {
483
+ // Ignore listener errors
484
+ }
485
+ }
486
+ }
487
+
488
+ /**
489
+ * OdooService namespace for convenient access.
490
+ */
491
+ export const OdooService = {
492
+ configure,
493
+ getConfig,
494
+ isConfigured,
495
+ authenticate,
496
+ isAuthenticated,
497
+ getSession,
498
+ logout,
499
+ searchRead,
500
+ call,
501
+ read,
502
+ create,
503
+ write,
504
+ unlink,
505
+ searchCount,
506
+ listDatabases,
507
+ versionInfo,
508
+ onAuthChange
509
+ }
510
+
511
+ export default OdooService