metaowl 0.4.1 → 0.5.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +12 -0
  3. package/build/runtime/bin/metaowl-build.js +10 -0
  4. package/{bin → build/runtime/bin}/metaowl-create.js +96 -177
  5. package/build/runtime/bin/metaowl-dev.js +10 -0
  6. package/build/runtime/bin/metaowl-generate.js +231 -0
  7. package/build/runtime/bin/metaowl-lint.js +58 -0
  8. package/build/runtime/bin/utils.js +68 -0
  9. package/build/runtime/index.js +141 -0
  10. package/build/runtime/modules/app-mounter.js +65 -0
  11. package/build/runtime/modules/auto-import.js +140 -0
  12. package/build/runtime/modules/cache.js +49 -0
  13. package/build/runtime/modules/composables.js +353 -0
  14. package/build/runtime/modules/error-boundary.js +116 -0
  15. package/build/runtime/modules/fetch.js +31 -0
  16. package/build/runtime/modules/file-router.js +205 -0
  17. package/build/runtime/modules/forms.js +193 -0
  18. package/build/runtime/modules/i18n.js +167 -0
  19. package/build/runtime/modules/layouts.js +163 -0
  20. package/build/runtime/modules/link.js +141 -0
  21. package/build/runtime/modules/meta.js +117 -0
  22. package/build/runtime/modules/odoo-rpc.js +264 -0
  23. package/build/runtime/modules/pwa.js +262 -0
  24. package/build/runtime/modules/router.js +389 -0
  25. package/build/runtime/modules/seo.js +186 -0
  26. package/build/runtime/modules/store.js +196 -0
  27. package/build/runtime/modules/templates-manager.js +52 -0
  28. package/build/runtime/modules/test-utils.js +238 -0
  29. package/build/runtime/vite/plugin.js +183 -0
  30. package/eslint.js +29 -0
  31. package/package.json +28 -10
  32. package/CONTRIBUTING.md +0 -49
  33. package/bin/metaowl-build.js +0 -12
  34. package/bin/metaowl-dev.js +0 -12
  35. package/bin/metaowl-generate.js +0 -339
  36. package/bin/metaowl-lint.js +0 -71
  37. package/bin/utils.js +0 -82
  38. package/eslint.config.js +0 -3
  39. package/index.js +0 -328
  40. package/modules/app-mounter.js +0 -104
  41. package/modules/auto-import.js +0 -225
  42. package/modules/cache.js +0 -59
  43. package/modules/composables.js +0 -600
  44. package/modules/error-boundary.js +0 -228
  45. package/modules/fetch.js +0 -51
  46. package/modules/file-router.js +0 -478
  47. package/modules/forms.js +0 -353
  48. package/modules/i18n.js +0 -333
  49. package/modules/layouts.js +0 -431
  50. package/modules/link.js +0 -255
  51. package/modules/meta.js +0 -119
  52. package/modules/odoo-rpc.js +0 -511
  53. package/modules/pwa.js +0 -515
  54. package/modules/router.js +0 -769
  55. package/modules/seo.js +0 -501
  56. package/modules/store.js +0 -409
  57. package/modules/templates-manager.js +0 -89
  58. package/modules/test-utils.js +0 -532
  59. package/test/auto-import.test.js +0 -110
  60. package/test/cache.test.js +0 -55
  61. package/test/composables.test.js +0 -103
  62. package/test/dynamic-routes.test.js +0 -469
  63. package/test/error-boundary.test.js +0 -126
  64. package/test/fetch.test.js +0 -100
  65. package/test/file-router.test.js +0 -55
  66. package/test/forms.test.js +0 -203
  67. package/test/i18n.test.js +0 -188
  68. package/test/layouts.test.js +0 -395
  69. package/test/link.test.js +0 -189
  70. package/test/meta.test.js +0 -146
  71. package/test/odoo-rpc.test.js +0 -547
  72. package/test/pwa.test.js +0 -154
  73. package/test/router-guards.test.js +0 -229
  74. package/test/router.test.js +0 -77
  75. package/test/seo.test.js +0 -353
  76. package/test/store.test.js +0 -476
  77. package/test/templates-manager.test.js +0 -83
  78. package/test/test-utils.test.js +0 -314
  79. package/vite/plugin.js +0 -290
  80. package/vitest.config.js +0 -8
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @module Layouts
3
+ *
4
+ * Layout system for OWL applications, enabling shared page structures.
5
+ */
6
+ import { Component, mount, xml } from '@odoo/owl';
7
+ const layouts = new Map();
8
+ let defaultLayout = 'default';
9
+ let currentLayout = null;
10
+ const listeners = [];
11
+ const routeLayouts = new Map();
12
+ export function registerLayout(name, layoutComponent, options = {}) {
13
+ layouts.set(name, layoutComponent);
14
+ if (options.default) {
15
+ defaultLayout = name;
16
+ }
17
+ for (const listener of listeners) {
18
+ listener({ type: 'register', name, layout: layoutComponent });
19
+ }
20
+ }
21
+ export function unregisterLayout(name) {
22
+ const removed = layouts.delete(name);
23
+ if (removed) {
24
+ for (const listener of listeners) {
25
+ listener({ type: 'unregister', name });
26
+ }
27
+ }
28
+ return removed;
29
+ }
30
+ export function getLayout(name) {
31
+ return layouts.get(name);
32
+ }
33
+ export function hasLayout(name) {
34
+ return layouts.has(name);
35
+ }
36
+ export function getLayoutNames() {
37
+ return Array.from(layouts.keys());
38
+ }
39
+ export function setDefaultLayout(name) {
40
+ if (!layouts.has(name)) {
41
+ console.warn(`[metaowl] Layout "${name}" is not registered yet`);
42
+ }
43
+ defaultLayout = name;
44
+ }
45
+ export function getDefaultLayout() {
46
+ return defaultLayout;
47
+ }
48
+ export function resolveLayout(component, routePath) {
49
+ if (routePath && routeLayouts.has(routePath)) {
50
+ return routeLayouts.get(routePath);
51
+ }
52
+ if (component.layout) {
53
+ return component.layout;
54
+ }
55
+ if (component._layout) {
56
+ return component._layout;
57
+ }
58
+ return defaultLayout;
59
+ }
60
+ export function setRouteLayout(routePath, layoutName) {
61
+ routeLayouts.set(routePath, layoutName);
62
+ }
63
+ export function getRouteLayout(routePath) {
64
+ return routeLayouts.get(routePath);
65
+ }
66
+ export function createLayoutWrapper(layoutComponent, pageComponent, props = {}) {
67
+ const LayoutClass = layoutComponent;
68
+ const PageClass = pageComponent;
69
+ return class LayoutWrapper extends Component {
70
+ static template = xml `
71
+ <t t-component="layout" t-props="layoutProps">
72
+ <t t-component="page" t-props="pageProps"/>
73
+ </t>
74
+ `;
75
+ layout;
76
+ page;
77
+ layoutProps;
78
+ pageProps;
79
+ setup() {
80
+ this.layout = LayoutClass;
81
+ this.page = PageClass;
82
+ this.layoutProps = {};
83
+ this.pageProps = props;
84
+ }
85
+ };
86
+ }
87
+ export async function mountWithLayout(pageComponent, target, options = {}, config = {}) {
88
+ const { routePath, props = {}, templates } = options;
89
+ const layoutName = resolveLayout(pageComponent, routePath);
90
+ const LayoutClass = getLayout(layoutName);
91
+ if (!LayoutClass) {
92
+ console.warn(`[metaowl] Layout "${layoutName}" not found, mounting page without layout`);
93
+ return await mount(pageComponent, target, { ...config, props, templates });
94
+ }
95
+ const WrapperClass = createLayoutWrapper(LayoutClass, pageComponent, props);
96
+ const instance = await mount(WrapperClass, target, { ...config, templates });
97
+ currentLayout = instance;
98
+ for (const listener of listeners) {
99
+ listener({ type: 'mount', layout: layoutName, page: pageComponent.name });
100
+ }
101
+ return instance;
102
+ }
103
+ export function getCurrentLayout() {
104
+ return currentLayout;
105
+ }
106
+ export function subscribeToLayouts(callback) {
107
+ listeners.push(callback);
108
+ return () => {
109
+ const index = listeners.indexOf(callback);
110
+ if (index > -1)
111
+ listeners.splice(index, 1);
112
+ };
113
+ }
114
+ export function clearLayouts() {
115
+ layouts.clear();
116
+ routeLayouts.clear();
117
+ listeners.length = 0;
118
+ defaultLayout = 'default';
119
+ currentLayout = null;
120
+ }
121
+ export function layout(name) {
122
+ return function decorator(componentClass) {
123
+ componentClass.layout = name;
124
+ return componentClass;
125
+ };
126
+ }
127
+ export function defineLayout(name, options = {}) {
128
+ return function decorator(componentClass) {
129
+ componentClass.layout = name;
130
+ componentClass.layoutOptions = options;
131
+ return componentClass;
132
+ };
133
+ }
134
+ export function buildLayouts(modules) {
135
+ const discoveredLayouts = {};
136
+ for (const [key, mod] of Object.entries(modules)) {
137
+ const match = key.match(/\.\/layouts\/([^/]+)/);
138
+ if (!match)
139
+ continue;
140
+ const layoutName = match[1];
141
+ const componentClass = resolveLayoutComponent(mod);
142
+ if (componentClass) {
143
+ discoveredLayouts[layoutName] = componentClass;
144
+ registerLayout(layoutName, componentClass);
145
+ }
146
+ }
147
+ return discoveredLayouts;
148
+ }
149
+ export async function discoverLayouts(options = {}) {
150
+ const { defaultLayout: nextDefaultLayout = 'default' } = options;
151
+ const modules = import.meta.glob('./layouts/**/*.js', { eager: true });
152
+ const discoveredLayouts = buildLayouts(modules);
153
+ if (discoveredLayouts[nextDefaultLayout]) {
154
+ setDefaultLayout(nextDefaultLayout);
155
+ }
156
+ return discoveredLayouts;
157
+ }
158
+ function resolveLayoutComponent(mod) {
159
+ if (typeof mod.default === 'function') {
160
+ return mod.default;
161
+ }
162
+ return Object.values(mod).find((value) => typeof value === 'function');
163
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @module Link
3
+ *
4
+ * SPA Link component for metaowl with automatic external link detection.
5
+ */
6
+ import { Component, onMounted, onWillUnmount, useState } from '@odoo/owl';
7
+ const EXTERNAL_URL_REGEX = /^(https?:|\/\/|mailto:|tel:|ftp:|file:|javascript:)/i;
8
+ function isExternalUrl(url) {
9
+ if (!url || typeof url !== 'string')
10
+ return false;
11
+ return EXTERNAL_URL_REGEX.test(url);
12
+ }
13
+ function isActiveLink(linkPath, currentPath) {
14
+ if (!linkPath || !currentPath)
15
+ return false;
16
+ const normalizedLink = linkPath.replace(/\/$/, '') || '/';
17
+ const normalizedCurrent = currentPath.replace(/\/$/, '') || '/';
18
+ return normalizedCurrent === normalizedLink ||
19
+ (normalizedLink !== '/' && normalizedCurrent.startsWith(normalizedLink + '/'));
20
+ }
21
+ export class Link extends Component {
22
+ static template = 'Link';
23
+ static props = {
24
+ to: { type: String, optional: false },
25
+ class: { type: String, optional: true },
26
+ activeClass: { type: String, optional: true },
27
+ target: { type: String, optional: true },
28
+ rel: { type: String, optional: true },
29
+ title: { type: String, optional: true },
30
+ download: { type: [String, Boolean], optional: true },
31
+ hreflang: { type: String, optional: true },
32
+ type: { type: String, optional: true },
33
+ ping: { type: String, optional: true },
34
+ referrerpolicy: { type: String, optional: true },
35
+ media: { type: String, optional: true },
36
+ '*': true
37
+ };
38
+ state;
39
+ _navigate = null;
40
+ _updateActiveState = () => { };
41
+ setup() {
42
+ this.state = useState({
43
+ isActive: false
44
+ });
45
+ onMounted(() => {
46
+ this._updateActiveState();
47
+ window.addEventListener('popstate', this._updateActiveState);
48
+ });
49
+ onWillUnmount(() => {
50
+ window.removeEventListener('popstate', this._updateActiveState);
51
+ });
52
+ this._updateActiveState = () => {
53
+ if (this.props.activeClass) {
54
+ this.state.isActive = isActiveLink(this.props.to, document.location.pathname);
55
+ }
56
+ };
57
+ }
58
+ get linkClasses() {
59
+ const classes = [];
60
+ if (this.props.class) {
61
+ classes.push(this.props.class);
62
+ }
63
+ if (this.state.isActive && this.props.activeClass) {
64
+ classes.push(this.props.activeClass);
65
+ }
66
+ return classes.join(' ');
67
+ }
68
+ get linkRel() {
69
+ if (this.props.rel)
70
+ return this.props.rel;
71
+ if (isExternalUrl(this.props.to) && this.props.target === '_blank') {
72
+ return 'noopener noreferrer';
73
+ }
74
+ return undefined;
75
+ }
76
+ get forwardedAttrs() {
77
+ const attrs = { ...this.props };
78
+ delete attrs.to;
79
+ delete attrs.class;
80
+ delete attrs.activeClass;
81
+ delete attrs.target;
82
+ delete attrs.rel;
83
+ delete attrs.title;
84
+ delete attrs.download;
85
+ return attrs;
86
+ }
87
+ onClick(ev) {
88
+ const url = this.props.to;
89
+ if (isExternalUrl(url)) {
90
+ return;
91
+ }
92
+ if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) {
93
+ return;
94
+ }
95
+ if (ev.button !== 0) {
96
+ return;
97
+ }
98
+ if (this.props.download) {
99
+ return;
100
+ }
101
+ ev.preventDefault();
102
+ window.history.pushState({ path: url }, '', url);
103
+ if (typeof window.__metaowlNavigate === 'function') {
104
+ window.__metaowlNavigate(url);
105
+ }
106
+ else {
107
+ window.location.href = url;
108
+ }
109
+ }
110
+ }
111
+ export const LinkTemplate = /* xml */ `
112
+ <templates>
113
+ <t t-name="Link">
114
+ <a
115
+ t-att="forwardedAttrs"
116
+ t-att-href="props.to"
117
+ t-att-class="linkClasses"
118
+ t-att-target="props.target"
119
+ t-att-rel="linkRel"
120
+ t-att-title="props.title"
121
+ t-att-download="props.download"
122
+ t-on-click="onClick"
123
+ >
124
+ <t t-slot="default"/>
125
+ </a>
126
+ </t>
127
+ </templates>
128
+ `;
129
+ export function registerLinkTemplate(templates) {
130
+ if (typeof templates === 'string') {
131
+ const linkContent = LinkTemplate
132
+ .replace('<templates>', '')
133
+ .replace('</templates>', '')
134
+ .trim();
135
+ return templates.replace('</templates>', linkContent + '\n</templates>');
136
+ }
137
+ if (templates && typeof templates === 'object') {
138
+ templates.Link = LinkTemplate;
139
+ }
140
+ }
141
+ export default Link;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @module Meta
3
+ *
4
+ * Programmatic helpers for managing document meta tags at runtime.
5
+ *
6
+ * Each function is idempotent: the relevant <meta> or <link> element is
7
+ * created on first call if it does not already exist, then its content is
8
+ * updated on every subsequent call as well.
9
+ *
10
+ * Import the entire namespace via:
11
+ * import { Meta } from 'metaowl'
12
+ * Meta.title('My Page')
13
+ */
14
+ function nameMeta(name, value) {
15
+ if (!value)
16
+ return;
17
+ let element = document.querySelector(`meta[name="${name}"]`);
18
+ if (!element) {
19
+ element = document.createElement('meta');
20
+ element.name = name;
21
+ document.head.appendChild(element);
22
+ }
23
+ element.content = String(value);
24
+ }
25
+ function propertyMeta(property, value) {
26
+ if (!value)
27
+ return;
28
+ let element = document.querySelector(`meta[property="${property}"]`);
29
+ if (!element) {
30
+ element = document.createElement('meta');
31
+ element.setAttribute('property', property);
32
+ document.head.appendChild(element);
33
+ }
34
+ element.content = String(value);
35
+ }
36
+ export function title(value) {
37
+ if (!value)
38
+ return;
39
+ document.title = value;
40
+ }
41
+ export function description(value) {
42
+ nameMeta('description', value);
43
+ }
44
+ export function keywords(value) {
45
+ nameMeta('keywords', value);
46
+ }
47
+ export function author(value) {
48
+ nameMeta('author', value);
49
+ }
50
+ export function canonical(value) {
51
+ if (!value)
52
+ return;
53
+ let element = document.querySelector('link[rel="canonical"]');
54
+ if (!element) {
55
+ element = document.createElement('link');
56
+ element.rel = 'canonical';
57
+ document.head.appendChild(element);
58
+ }
59
+ element.href = value;
60
+ }
61
+ export function ogTitle(value) {
62
+ propertyMeta('og:title', value);
63
+ }
64
+ export function ogDescription(value) {
65
+ propertyMeta('og:description', value);
66
+ }
67
+ export function ogImage(value) {
68
+ propertyMeta('og:image', value);
69
+ }
70
+ export function ogUrl(value) {
71
+ propertyMeta('og:url', value);
72
+ }
73
+ export function ogType(value) {
74
+ propertyMeta('og:type', value);
75
+ }
76
+ export function ogSiteName(value) {
77
+ propertyMeta('og:site_name', value);
78
+ }
79
+ export function ogLocale(value) {
80
+ propertyMeta('og:locale', value);
81
+ }
82
+ export function ogImageWidth(value) {
83
+ propertyMeta('og:image:width', value);
84
+ }
85
+ export function ogImageHeight(value) {
86
+ propertyMeta('og:image:height', value);
87
+ }
88
+ export function twitterCard(value) {
89
+ nameMeta('twitter:card', value);
90
+ }
91
+ export function twitterSite(value) {
92
+ nameMeta('twitter:site', value);
93
+ }
94
+ export function twitterCreator(value) {
95
+ nameMeta('twitter:creator', value);
96
+ }
97
+ export function twitterTitle(value) {
98
+ nameMeta('twitter:title', value);
99
+ }
100
+ export function twitterDescription(value) {
101
+ nameMeta('twitter:description', value);
102
+ }
103
+ export function twitterImage(value) {
104
+ nameMeta('twitter:image', value);
105
+ }
106
+ export function twitterImageAlt(value) {
107
+ nameMeta('twitter:image:alt', value);
108
+ }
109
+ export function twitterUrl(value) {
110
+ nameMeta('twitter:url', value);
111
+ }
112
+ export function twitterSiteId(value) {
113
+ nameMeta('twitter:site:id', value);
114
+ }
115
+ export function twitterCreatorId(value) {
116
+ nameMeta('twitter:creator:id', value);
117
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * @module OdooRPC
3
+ *
4
+ * Odoo JSON-RPC Service for MetaOwl applications.
5
+ */
6
+ let config = null;
7
+ let session = null;
8
+ let csrfToken = null;
9
+ const authListeners = [];
10
+ const SESSION_KEY = 'metaowl:odoo:session';
11
+ const CSRF_KEY = 'metaowl:odoo:csrf';
12
+ export function configure(nextConfig) {
13
+ config = {
14
+ persistSession: true,
15
+ baseUrl: '',
16
+ database: '',
17
+ ...nextConfig
18
+ };
19
+ if (config.persistSession) {
20
+ restoreSession();
21
+ }
22
+ }
23
+ export function getConfig() {
24
+ return config;
25
+ }
26
+ export function isConfigured() {
27
+ return config !== null && Boolean(config.baseUrl) && Boolean(config.database);
28
+ }
29
+ function restoreSession() {
30
+ try {
31
+ const sessionData = localStorage.getItem(SESSION_KEY);
32
+ const csrfData = localStorage.getItem(CSRF_KEY);
33
+ if (sessionData) {
34
+ session = JSON.parse(sessionData);
35
+ }
36
+ if (csrfData) {
37
+ csrfToken = csrfData;
38
+ }
39
+ }
40
+ catch {
41
+ // Ignore storage errors
42
+ }
43
+ }
44
+ function saveSession() {
45
+ if (!config?.persistSession)
46
+ return;
47
+ try {
48
+ if (session) {
49
+ localStorage.setItem(SESSION_KEY, JSON.stringify(session));
50
+ }
51
+ else {
52
+ localStorage.removeItem(SESSION_KEY);
53
+ }
54
+ if (csrfToken) {
55
+ localStorage.setItem(CSRF_KEY, csrfToken);
56
+ }
57
+ else {
58
+ localStorage.removeItem(CSRF_KEY);
59
+ }
60
+ }
61
+ catch {
62
+ // Ignore storage errors
63
+ }
64
+ }
65
+ async function jsonRpc(service, method, args = []) {
66
+ if (!isConfigured() || !config) {
67
+ throw new Error('[metaowl] OdooService not configured. Call configure() first.');
68
+ }
69
+ const url = `${config.baseUrl}/jsonrpc`;
70
+ const payload = {
71
+ jsonrpc: '2.0',
72
+ method: 'call',
73
+ params: {
74
+ service,
75
+ method,
76
+ args
77
+ },
78
+ id: Math.floor(Math.random() * 1000000000)
79
+ };
80
+ const headers = {
81
+ 'Content-Type': 'application/json'
82
+ };
83
+ if (csrfToken) {
84
+ headers['X-CSRF-Token'] = csrfToken;
85
+ }
86
+ const response = await fetch(url, {
87
+ method: 'POST',
88
+ headers,
89
+ body: JSON.stringify(payload),
90
+ credentials: 'include'
91
+ });
92
+ if (!response.ok) {
93
+ throw new Error(`[metaowl] HTTP ${response.status}: ${response.statusText}`);
94
+ }
95
+ const data = await response.json();
96
+ if (data.error) {
97
+ const error = data.error;
98
+ throw new Error(`[metaowl] Odoo Error: ${error.message || error.data?.message || JSON.stringify(error)}`);
99
+ }
100
+ const setCookie = response.headers.get('set-cookie');
101
+ if (setCookie?.includes('csrf_token')) {
102
+ const match = setCookie.match(/csrf_token=([^;]+)/);
103
+ if (match) {
104
+ csrfToken = match[1] ?? null;
105
+ saveSession();
106
+ }
107
+ }
108
+ return data.result;
109
+ }
110
+ export async function authenticate(username, password) {
111
+ const user = username || config?.username;
112
+ const pass = password || config?.password || config?.apiKey;
113
+ if (!user || !pass || !config) {
114
+ throw new Error('[metaowl] Authentication requires username and password/apiKey');
115
+ }
116
+ const uid = await jsonRpc('common', 'authenticate', [
117
+ config.database,
118
+ user,
119
+ pass,
120
+ {}
121
+ ]);
122
+ if (!uid) {
123
+ throw new Error('[metaowl] Authentication failed: invalid credentials');
124
+ }
125
+ session = {
126
+ uid,
127
+ username: user
128
+ };
129
+ try {
130
+ const userInfo = await searchRead('res.users', {
131
+ domain: [['id', '=', uid]],
132
+ fields: ['name', 'partner_id', 'lang', 'tz'],
133
+ limit: 1
134
+ });
135
+ if (userInfo.length > 0) {
136
+ const firstUser = userInfo[0];
137
+ session.name = typeof firstUser.name === 'string' ? firstUser.name : undefined;
138
+ session.partner_id = Array.isArray(firstUser.partner_id) ? Number(firstUser.partner_id[0]) : undefined;
139
+ session.lang = typeof firstUser.lang === 'string' ? firstUser.lang : undefined;
140
+ session.tz = typeof firstUser.tz === 'string' ? firstUser.tz : undefined;
141
+ }
142
+ }
143
+ catch {
144
+ // Ignore user info fetch errors
145
+ }
146
+ saveSession();
147
+ notifyAuthListeners();
148
+ return session;
149
+ }
150
+ export function isAuthenticated() {
151
+ return session !== null && session.uid !== null;
152
+ }
153
+ export function getSession() {
154
+ return session;
155
+ }
156
+ export function logout() {
157
+ session = null;
158
+ csrfToken = null;
159
+ saveSession();
160
+ notifyAuthListeners();
161
+ }
162
+ export async function searchRead(model, options = {}) {
163
+ const { domain = [], fields = [], limit = 80, offset = 0, order = '', context = {} } = options;
164
+ if (!isAuthenticated() || !config || !session) {
165
+ throw new Error('[metaowl] Not authenticated. Call authenticate() first.');
166
+ }
167
+ const args = [
168
+ config.database,
169
+ session.uid,
170
+ config.password || config.apiKey,
171
+ model,
172
+ 'search_read',
173
+ [domain],
174
+ { fields, limit, offset, order, context }
175
+ ];
176
+ return await jsonRpc('object', 'execute_kw', args);
177
+ }
178
+ export async function call(model, method, args = [], kwargs = {}) {
179
+ if (!isAuthenticated() || !config || !session) {
180
+ throw new Error('[metaowl] Not authenticated. Call authenticate() first.');
181
+ }
182
+ const rpcArgs = [
183
+ config.database,
184
+ session.uid,
185
+ config.password || config.apiKey,
186
+ model,
187
+ method,
188
+ args,
189
+ kwargs
190
+ ];
191
+ return await jsonRpc('object', 'execute_kw', rpcArgs);
192
+ }
193
+ export async function read(model, ids, fields = []) {
194
+ return await call(model, 'read', [ids], { fields });
195
+ }
196
+ export async function create(model, values) {
197
+ return await call(model, 'create', [[values]]);
198
+ }
199
+ export async function write(model, ids, values) {
200
+ return await call(model, 'write', [ids, values]);
201
+ }
202
+ export async function unlink(model, ids) {
203
+ return await call(model, 'unlink', [ids]);
204
+ }
205
+ export async function searchCount(model, domain = []) {
206
+ return await call(model, 'search_count', [domain]);
207
+ }
208
+ export async function listDatabases() {
209
+ return await jsonRpc('db', 'list', []);
210
+ }
211
+ export async function versionInfo() {
212
+ if (!config) {
213
+ throw new Error('[metaowl] OdooService not configured. Call configure() first.');
214
+ }
215
+ const response = await fetch(`${config.baseUrl}/web/webclient/version_info`, {
216
+ method: 'POST',
217
+ headers: { 'Content-Type': 'application/json' },
218
+ body: '{}'
219
+ });
220
+ if (!response.ok) {
221
+ throw new Error(`[metaowl] Failed to get version info: ${response.status}`);
222
+ }
223
+ const data = await response.json();
224
+ return data.result;
225
+ }
226
+ export function onAuthChange(callback) {
227
+ authListeners.push(callback);
228
+ return () => {
229
+ const index = authListeners.indexOf(callback);
230
+ if (index > -1) {
231
+ authListeners.splice(index, 1);
232
+ }
233
+ };
234
+ }
235
+ function notifyAuthListeners() {
236
+ for (const listener of authListeners) {
237
+ try {
238
+ listener(session);
239
+ }
240
+ catch {
241
+ // Ignore listener errors
242
+ }
243
+ }
244
+ }
245
+ export const OdooService = {
246
+ configure,
247
+ getConfig,
248
+ isConfigured,
249
+ authenticate,
250
+ isAuthenticated,
251
+ getSession,
252
+ logout,
253
+ searchRead,
254
+ call,
255
+ read,
256
+ create,
257
+ write,
258
+ unlink,
259
+ searchCount,
260
+ listDatabases,
261
+ versionInfo,
262
+ onAuthChange
263
+ };
264
+ export default OdooService;