metaowl 0.4.0 → 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 (79) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -15
  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 +29 -11
  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/index.js +0 -328
  39. package/modules/app-mounter.js +0 -104
  40. package/modules/auto-import.js +0 -225
  41. package/modules/cache.js +0 -59
  42. package/modules/composables.js +0 -600
  43. package/modules/error-boundary.js +0 -228
  44. package/modules/fetch.js +0 -51
  45. package/modules/file-router.js +0 -478
  46. package/modules/forms.js +0 -353
  47. package/modules/i18n.js +0 -333
  48. package/modules/layouts.js +0 -431
  49. package/modules/link.js +0 -255
  50. package/modules/meta.js +0 -119
  51. package/modules/odoo-rpc.js +0 -511
  52. package/modules/pwa.js +0 -515
  53. package/modules/router.js +0 -769
  54. package/modules/seo.js +0 -501
  55. package/modules/store.js +0 -409
  56. package/modules/templates-manager.js +0 -89
  57. package/modules/test-utils.js +0 -532
  58. package/test/auto-import.test.js +0 -110
  59. package/test/cache.test.js +0 -55
  60. package/test/composables.test.js +0 -103
  61. package/test/dynamic-routes.test.js +0 -469
  62. package/test/error-boundary.test.js +0 -126
  63. package/test/fetch.test.js +0 -100
  64. package/test/file-router.test.js +0 -55
  65. package/test/forms.test.js +0 -203
  66. package/test/i18n.test.js +0 -188
  67. package/test/layouts.test.js +0 -395
  68. package/test/link.test.js +0 -189
  69. package/test/meta.test.js +0 -146
  70. package/test/odoo-rpc.test.js +0 -547
  71. package/test/pwa.test.js +0 -154
  72. package/test/router-guards.test.js +0 -229
  73. package/test/router.test.js +0 -77
  74. package/test/seo.test.js +0 -353
  75. package/test/store.test.js +0 -476
  76. package/test/templates-manager.test.js +0 -83
  77. package/test/test-utils.test.js +0 -314
  78. package/vite/plugin.js +0 -277
  79. 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;