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.
- package/CHANGELOG.md +22 -0
- package/README.md +12 -0
- package/build/runtime/bin/metaowl-build.js +10 -0
- package/{bin → build/runtime/bin}/metaowl-create.js +96 -177
- package/build/runtime/bin/metaowl-dev.js +10 -0
- package/build/runtime/bin/metaowl-generate.js +231 -0
- package/build/runtime/bin/metaowl-lint.js +58 -0
- package/build/runtime/bin/utils.js +68 -0
- package/build/runtime/index.js +141 -0
- package/build/runtime/modules/app-mounter.js +65 -0
- package/build/runtime/modules/auto-import.js +140 -0
- package/build/runtime/modules/cache.js +49 -0
- package/build/runtime/modules/composables.js +353 -0
- package/build/runtime/modules/error-boundary.js +116 -0
- package/build/runtime/modules/fetch.js +31 -0
- package/build/runtime/modules/file-router.js +205 -0
- package/build/runtime/modules/forms.js +193 -0
- package/build/runtime/modules/i18n.js +167 -0
- package/build/runtime/modules/layouts.js +163 -0
- package/build/runtime/modules/link.js +141 -0
- package/build/runtime/modules/meta.js +117 -0
- package/build/runtime/modules/odoo-rpc.js +264 -0
- package/build/runtime/modules/pwa.js +262 -0
- package/build/runtime/modules/router.js +389 -0
- package/build/runtime/modules/seo.js +186 -0
- package/build/runtime/modules/store.js +196 -0
- package/build/runtime/modules/templates-manager.js +52 -0
- package/build/runtime/modules/test-utils.js +238 -0
- package/build/runtime/vite/plugin.js +183 -0
- package/eslint.js +29 -0
- package/package.json +28 -10
- package/CONTRIBUTING.md +0 -49
- package/bin/metaowl-build.js +0 -12
- package/bin/metaowl-dev.js +0 -12
- package/bin/metaowl-generate.js +0 -339
- package/bin/metaowl-lint.js +0 -71
- package/bin/utils.js +0 -82
- package/eslint.config.js +0 -3
- package/index.js +0 -328
- package/modules/app-mounter.js +0 -104
- package/modules/auto-import.js +0 -225
- package/modules/cache.js +0 -59
- package/modules/composables.js +0 -600
- package/modules/error-boundary.js +0 -228
- package/modules/fetch.js +0 -51
- package/modules/file-router.js +0 -478
- package/modules/forms.js +0 -353
- package/modules/i18n.js +0 -333
- package/modules/layouts.js +0 -431
- package/modules/link.js +0 -255
- package/modules/meta.js +0 -119
- package/modules/odoo-rpc.js +0 -511
- package/modules/pwa.js +0 -515
- package/modules/router.js +0 -769
- package/modules/seo.js +0 -501
- package/modules/store.js +0 -409
- package/modules/templates-manager.js +0 -89
- package/modules/test-utils.js +0 -532
- package/test/auto-import.test.js +0 -110
- package/test/cache.test.js +0 -55
- package/test/composables.test.js +0 -103
- package/test/dynamic-routes.test.js +0 -469
- package/test/error-boundary.test.js +0 -126
- package/test/fetch.test.js +0 -100
- package/test/file-router.test.js +0 -55
- package/test/forms.test.js +0 -203
- package/test/i18n.test.js +0 -188
- package/test/layouts.test.js +0 -395
- package/test/link.test.js +0 -189
- package/test/meta.test.js +0 -146
- package/test/odoo-rpc.test.js +0 -547
- package/test/pwa.test.js +0 -154
- package/test/router-guards.test.js +0 -229
- package/test/router.test.js +0 -77
- package/test/seo.test.js +0 -353
- package/test/store.test.js +0 -476
- package/test/templates-manager.test.js +0 -83
- package/test/test-utils.test.js +0 -314
- package/vite/plugin.js +0 -290
- 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;
|